ITN Dev 07-24
feat: 권리처리 게시물 관리 백엔드(가져오기/목록/처리등록/다운로드) 추가
권리확인 모듈을 참고해 권리처리 탭 전용 process_item 테이블과 CRUD API를 구성한다.
조사원+권리확인 영역은 재업로드마다 항상 최신 파일값으로 갱신하고, 계약서 유무와
권리처리 판정(공공누리유형/최종의견/판단근거/처리상태)은 웹에서 한 번이라도 수정하면
재업로드가 덮어쓰지 않는다. 처리완료 최초 도달 시각은 processed_at에 남기고 이후
재저장에도 값이 바뀌지 않는다.
@6d1810b250a02fa0ec86df1472761b642718a2e1
src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
@@ -5,6 +5,8 @@
 import kr.itn.itnhub.feed.ChannelNotReadyException;
 import kr.itn.itnhub.feed.InvalidMessageException;
 import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.process.InvalidProcessStatusException;
+import kr.itn.itnhub.process.ProcessNotFoundException;
 import kr.itn.itnhub.review.ReviewNotFoundException;
 import kr.itn.itnhub.seed.SeedParseException;
 import kr.itn.itnhub.stage.InvalidStageException;
@@ -56,6 +58,12 @@
  *
  * <p>{@link ReviewNotFoundException}은 존재하지 않는 권리확인 게시물 id로 조회/수정/삭제를
  * 시도했을 때 던진다 - URL의 id가 단순히 틀린 것뿐이므로 404가 맞다.</p>
+ *
+ * <p>{@link ProcessNotFoundException}은 존재하지 않는 권리처리 게시물 id로 조회/수정/삭제를
+ * 시도했을 때 던진다 - URL의 id가 단순히 틀린 것뿐이므로 404가 맞다.</p>
+ *
+ * <p>{@link InvalidProcessStatusException}은 권리처리상태가 "미처리"/"처리완료"가 아닌
+ * 값으로 저장을 시도할 때 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p>
  *
  * <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
  * 예외까지 4xx로 감싸버리면 진짜 버그가 조용히 묻힌다. 예상 밖 예외는 기본 500 처리
@@ -120,4 +128,14 @@
     public ResponseEntity<ApiError> handleReviewNotFound(ReviewNotFoundException e) {
         return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage()));
     }
+
+    @ExceptionHandler(ProcessNotFoundException.class)
+    public ResponseEntity<ApiError> handleProcessNotFound(ProcessNotFoundException e) {
+        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage()));
+    }
+
+    @ExceptionHandler(InvalidProcessStatusException.class)
+    public ResponseEntity<ApiError> handleInvalidProcessStatus(InvalidProcessStatusException e) {
+        return ResponseEntity.badRequest().body(new ApiError(e.getMessage()));
+    }
 }
 
src/main/java/kr/itn/itnhub/process/InvalidProcessStatusException.java (added)
+++ src/main/java/kr/itn/itnhub/process/InvalidProcessStatusException.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.process;
+
+/**
+ * 권리처리상태(processStatus)가 null이 아니면서 "미처리"/"처리완료" 둘 중 하나도 아닐 때 던진다.
+ * 흔한 사용자 실수(오타, 빈 문자열)이므로 GlobalExceptionHandler가 400으로 변환한다.
+ */
+public class InvalidProcessStatusException extends RuntimeException {
+    public InvalidProcessStatusException(String message) {
+        super(message);
+    }
+}
 
src/main/java/kr/itn/itnhub/process/ProcessController.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessController.java
@@ -0,0 +1,107 @@
+package kr.itn.itnhub.process;
+
+import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import kr.itn.itnhub.seed.SeedParseException;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/** 기관관리 상세의 권리처리 탭이 부르는 엔드포인트. */
+@RestController
+public class ProcessController {
+
+    private final ProcessService processService;
+    private final ProcessImportService importService;
+    private final OrganizationMapper orgMapper;
+
+    public ProcessController(ProcessService processService, ProcessImportService importService,
+                              OrganizationMapper orgMapper) {
+        this.processService = processService;
+        this.importService = importService;
+        this.orgMapper = orgMapper;
+    }
+
+    @PostMapping("/api/orgs/{id}/process/import")
+    public ProcessImportReport upload(@PathVariable("id") Long orgId, @RequestParam("file") MultipartFile file) {
+        if (file.isEmpty()) {
+            throw new SeedParseException("업로드된 파일이 비어 있습니다.");
+        }
+        String filename = file.getOriginalFilename();
+        if (filename == null || !hasAllowedExtension(filename)) {
+            throw new SeedParseException("엑셀 파일(.xlsx 또는 .xlsm)만 업로드할 수 있습니다.");
+        }
+        try (InputStream in = file.getInputStream()) {
+            return importService.importFile(orgId, in);
+        } catch (IOException e) {
+            throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e);
+        }
+    }
+
+    @GetMapping("/api/orgs/{id}/process")
+    public ProcessPage list(@PathVariable("id") Long orgId,
+                             @RequestParam(required = false) String keyword,
+                             @RequestParam(required = false) String status,
+                             @RequestParam(defaultValue = "0") int page,
+                             @RequestParam(defaultValue = "30") int size) {
+        return processService.list(orgId, keyword, status, page, size);
+    }
+
+    @GetMapping("/api/orgs/{id}/process/{itemId}")
+    public ProcessItem get(@PathVariable("id") Long orgId, @PathVariable Long itemId) {
+        return processService.get(orgId, itemId);
+    }
+
+    @PutMapping("/api/orgs/{id}/process/{itemId}")
+    public ProcessItem update(@PathVariable("id") Long orgId, @PathVariable Long itemId,
+                               @RequestBody ProcessingRequest request) {
+        return processService.updateProcessing(orgId, itemId, request);
+    }
+
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    @DeleteMapping("/api/orgs/{id}/process/{itemId}")
+    public void delete(@PathVariable("id") Long orgId, @PathVariable Long itemId) {
+        processService.delete(orgId, itemId);
+    }
+
+    /** 한글 파일명이라 ReviewController.download와 동일하게 RFC 5987 인코딩을 쓴다. */
+    @GetMapping("/api/orgs/{id}/process/download")
+    public ResponseEntity<byte[]> download(@PathVariable("id") Long orgId) {
+        Organization org = orgMapper.findById(orgId);
+        if (org == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+        byte[] content = processService.downloadWorkbook(orgId);
+
+        String filename = "권리처리_" + org.getOrgName() + ".xlsx";
+        String encodedName = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
+
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName)
+                .contentType(MediaType.parseMediaType(
+                        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+                .body(content);
+    }
+
+    private static boolean hasAllowedExtension(String filename) {
+        String lower = filename.toLowerCase();
+        return lower.endsWith(".xlsx") || lower.endsWith(".xlsm");
+    }
+}
 
src/main/java/kr/itn/itnhub/process/ProcessImportReport.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessImportReport.java
@@ -0,0 +1,4 @@
+package kr.itn.itnhub.process;
+
+public record ProcessImportReport(int created, int updated, int total) {
+}
 
src/main/java/kr/itn/itnhub/process/ProcessImportService.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessImportService.java
@@ -0,0 +1,89 @@
+package kr.itn.itnhub.process;
+
+import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.io.InputStream;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@Service
+public class ProcessImportService {
+
+    private final ProcessParser parser;
+    private final ProcessItemMapper mapper;
+    private final OrganizationMapper orgMapper;
+    private final TransactionTemplate transactionTemplate;
+
+    public ProcessImportService(ProcessParser parser, ProcessItemMapper mapper, OrganizationMapper orgMapper,
+                                 PlatformTransactionManager transactionManager) {
+        this.parser = parser;
+        this.mapper = mapper;
+        this.orgMapper = orgMapper;
+        this.transactionTemplate = new TransactionTemplate(transactionManager);
+    }
+
+    /**
+     * 엑셀 파싱은 DB와 무관하므로 트랜잭션 밖에서 수행한다(ReviewImportService와 동일한 이유).
+     */
+    public ProcessImportReport importFile(Long orgId, InputStream xlsx) {
+        if (orgMapper.findById(orgId) == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+        List<ProcessRow> rows = parser.parse(xlsx);
+        return transactionTemplate.execute(status -> importRows(orgId, rows));
+    }
+
+    private ProcessImportReport importRows(Long orgId, List<ProcessRow> rows) {
+        // 이미 DB에 있는 순번 + 이번 파일에서 먼저 처리된 순번을 모두 "이미 존재함"으로 취급한다
+        // (ReviewImportService.importRows와 동일한 관례).
+        Set<Integer> seen = new HashSet<>(mapper.findSeqsByOrg(orgId));
+
+        int created = 0;
+        int updated = 0;
+        for (ProcessRow row : rows) {
+            if (seen.add(row.seq())) {
+                created++;
+            } else {
+                updated++;
+            }
+            mapper.upsertFromFile(toProcessItem(orgId, row));
+        }
+        return new ProcessImportReport(created, updated, rows.size());
+    }
+
+    private ProcessItem toProcessItem(Long orgId, ProcessRow row) {
+        ProcessItem item = new ProcessItem();
+        item.setOrgId(orgId);
+        item.setSeq(row.seq());
+        item.setSiteName(row.siteName());
+        item.setCategory(row.category());
+        item.setBoardName(row.boardName());
+        item.setPostTitle(row.postTitle());
+        item.setUrl(row.url());
+        item.setDescription(row.description());
+        item.setHasAttachment(row.hasAttachment());
+        item.setPriorKoglType(row.priorKoglType());
+        item.setContractDocs(row.contractDocs());
+        item.setProducedDate(row.producedDate());
+        item.setPublishedDate(row.publishedDate());
+        item.setReviewMajor(row.reviewMajor());
+        item.setReviewMinor(row.reviewMinor());
+        item.setReviewResult(row.reviewResult());
+        item.setReviewKoglType(row.reviewKoglType());
+        item.setReviewAiType(row.reviewAiType());
+        item.setReviewOpinion(row.reviewOpinion());
+        item.setReviewNote(row.reviewNote());
+        item.setPriorEvidence(row.priorEvidence());
+        item.setJudgedKoglType(row.judgedKoglType());
+        item.setJudgedAiType(row.judgedAiType());
+        item.setFinalOpinion(row.finalOpinion());
+        item.setJudgmentBasis(row.judgmentBasis());
+        item.setProcessStatus(row.processStatus());
+        return item;
+    }
+}
 
src/main/java/kr/itn/itnhub/process/ProcessItem.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessItem.java
@@ -0,0 +1,146 @@
+package kr.itn.itnhub.process;
+
+/**
+ * 권리처리 게시물 한 건. {@code A:T} 열(수정 링크 U열은 제외)과 {@code V:X, Z, AA} 열이
+ * 그대로 대응한다({@code U, Y}는 원본 시트의 건너뛰는 열이라 컬럼이 없다). 조사원+권리확인
+ * 영역(siteName..priorEvidence)은 재업로드마다 항상 파일이 최신값으로 덮어쓰되, 계약서
+ * 유무(contractDocs)만은 웹에서 입력하는 값이라 {@link #webEditedAt}이 null일 때만 파일이
+ * 덮어쓴다. 권리처리 영역(judgedKoglType..processStatus)도 {@link #webEditedAt}이 null일
+ * 때만 파일이 덮어쓴다 - 자세한 병합 정책은 {@code ProcessItemMapper.xml}의 upsertFromFile 참고.
+ */
+public class ProcessItem {
+
+    private Long id;
+    private Long orgId;
+    private int seq;
+
+    // 게시물 정보(조사원) + 권리확인(변호사) - 재업로드 시 항상 파일 값으로 갱신
+    private String siteName;
+    private String category;
+    private String boardName;
+    private String postTitle;
+    private String url;
+    private String description;
+    private String hasAttachment;
+    private String priorKoglType;
+    private String producedDate;
+    private String publishedDate;
+    private String reviewMajor;
+    private String reviewMinor;
+    private String reviewResult;
+    private String reviewKoglType;
+    private String reviewAiType;
+    private String reviewOpinion;
+    private String reviewNote;
+    private String priorEvidence;
+
+    // 계약서 유무 - 웹에서 입력하는 값이라 webEditedAt이 null일 때만 재업로드가 덮어씀
+    private String contractDocs;
+
+    // 권리처리(변호사) - webEditedAt이 null일 때만 재업로드가 덮어씀
+    private String judgedKoglType;
+    private String judgedAiType;
+    private String finalOpinion;
+    private String judgmentBasis;
+    private String processStatus;
+
+    /** null이면 아직 웹에서 권리처리 판정을 수정한 적이 없다는 뜻이다(재업로드 병합 판단 기준). */
+    private Long webEditedAt;
+    /** 처리상태가 "처리완료"로 처음 저장된 시각. 이후 재저장돼도 바뀌지 않는다. */
+    private Long processedAt;
+    private long createdAt;
+    private long updatedAt;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+
+    public Long getOrgId() { return orgId; }
+    public void setOrgId(Long orgId) { this.orgId = orgId; }
+
+    public int getSeq() { return seq; }
+    public void setSeq(int seq) { this.seq = seq; }
+
+    public String getSiteName() { return siteName; }
+    public void setSiteName(String siteName) { this.siteName = siteName; }
+
+    public String getCategory() { return category; }
+    public void setCategory(String category) { this.category = category; }
+
+    public String getBoardName() { return boardName; }
+    public void setBoardName(String boardName) { this.boardName = boardName; }
+
+    public String getPostTitle() { return postTitle; }
+    public void setPostTitle(String postTitle) { this.postTitle = postTitle; }
+
+    public String getUrl() { return url; }
+    public void setUrl(String url) { this.url = url; }
+
+    public String getDescription() { return description; }
+    public void setDescription(String description) { this.description = description; }
+
+    public String getHasAttachment() { return hasAttachment; }
+    public void setHasAttachment(String hasAttachment) { this.hasAttachment = hasAttachment; }
+
+    public String getPriorKoglType() { return priorKoglType; }
+    public void setPriorKoglType(String priorKoglType) { this.priorKoglType = priorKoglType; }
+
+    public String getContractDocs() { return contractDocs; }
+    public void setContractDocs(String contractDocs) { this.contractDocs = contractDocs; }
+
+    public String getProducedDate() { return producedDate; }
+    public void setProducedDate(String producedDate) { this.producedDate = producedDate; }
+
+    public String getPublishedDate() { return publishedDate; }
+    public void setPublishedDate(String publishedDate) { this.publishedDate = publishedDate; }
+
+    public String getReviewMajor() { return reviewMajor; }
+    public void setReviewMajor(String reviewMajor) { this.reviewMajor = reviewMajor; }
+
+    public String getReviewMinor() { return reviewMinor; }
+    public void setReviewMinor(String reviewMinor) { this.reviewMinor = reviewMinor; }
+
+    public String getReviewResult() { return reviewResult; }
+    public void setReviewResult(String reviewResult) { this.reviewResult = reviewResult; }
+
+    public String getReviewKoglType() { return reviewKoglType; }
+    public void setReviewKoglType(String reviewKoglType) { this.reviewKoglType = reviewKoglType; }
+
+    public String getReviewAiType() { return reviewAiType; }
+    public void setReviewAiType(String reviewAiType) { this.reviewAiType = reviewAiType; }
+
+    public String getReviewOpinion() { return reviewOpinion; }
+    public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; }
+
+    public String getReviewNote() { return reviewNote; }
+    public void setReviewNote(String reviewNote) { this.reviewNote = reviewNote; }
+
+    public String getPriorEvidence() { return priorEvidence; }
+    public void setPriorEvidence(String priorEvidence) { this.priorEvidence = priorEvidence; }
+
+    public String getJudgedKoglType() { return judgedKoglType; }
+    public void setJudgedKoglType(String judgedKoglType) { this.judgedKoglType = judgedKoglType; }
+
+    public String getJudgedAiType() { return judgedAiType; }
+    public void setJudgedAiType(String judgedAiType) { this.judgedAiType = judgedAiType; }
+
+    public String getFinalOpinion() { return finalOpinion; }
+    public void setFinalOpinion(String finalOpinion) { this.finalOpinion = finalOpinion; }
+
+    public String getJudgmentBasis() { return judgmentBasis; }
+    public void setJudgmentBasis(String judgmentBasis) { this.judgmentBasis = judgmentBasis; }
+
+    public String getProcessStatus() { return processStatus; }
+    public void setProcessStatus(String processStatus) { this.processStatus = processStatus; }
+
+    public Long getWebEditedAt() { return webEditedAt; }
+    public void setWebEditedAt(Long webEditedAt) { this.webEditedAt = webEditedAt; }
+
+    public Long getProcessedAt() { return processedAt; }
+    public void setProcessedAt(Long processedAt) { this.processedAt = processedAt; }
+
+    public long getCreatedAt() { return createdAt; }
+    public void setCreatedAt(long createdAt) { this.createdAt = createdAt; }
+
+    public long getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; }
+}
 
src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
@@ -0,0 +1,53 @@
+package kr.itn.itnhub.process;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface ProcessItemMapper {
+
+    /**
+     * 순번 오름차순, keyword는 사이트명/게시물제목 LIKE. status는 null=전체,
+     * "DONE"=처리완료, "PENDING"=그 외(null/빈/미처리)만.
+     */
+    List<ProcessItem> findPage(@Param("orgId") Long orgId, @Param("keyword") String keyword,
+                                @Param("status") String status, @Param("offset") int offset,
+                                @Param("limit") int limit);
+
+    /** keyword/status로 필터링된 건수 - 필터별 페이지 경계 계산용(현재 응답의 total과는 별개). */
+    int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword, @Param("status") String status);
+
+    /** 다운로드/재업로드 판정용으로 페이징 없이 순번순 전체를 읽는다. */
+    List<ProcessItem> findAllByOrg(@Param("orgId") Long orgId);
+
+    /** 이미 저장된 순번 전체 - ImportService가 신규/갱신 집계를 메모리에서 판단하기 위함. */
+    List<Integer> findSeqsByOrg(@Param("orgId") Long orgId);
+
+    ProcessItem findById(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    /**
+     * 엑셀 재업로드 1행 반영. (org_id, seq) 충돌 시 조사원+권리확인 영역(계약서 유무 제외)은
+     * 항상 덮어쓰고, 계약서 유무와 권리처리 영역은 web_edited_at이 null일 때만 덮어쓴다 -
+     * 정책 세부는 XML 주석 참고.
+     */
+    int upsertFromFile(ProcessItem item);
+
+    /** 권리처리 상세의 [처리등록]/[수정] 저장. web_edited_at을 찍고, 처리완료 최초 도달 시각을 processed_at에 남긴다. */
+    int updateProcessing(@Param("orgId") Long orgId, @Param("id") Long id,
+                          @Param("contractDocs") String contractDocs,
+                          @Param("judgedKoglType") String judgedKoglType,
+                          @Param("judgedAiType") String judgedAiType,
+                          @Param("finalOpinion") String finalOpinion,
+                          @Param("judgmentBasis") String judgmentBasis,
+                          @Param("processStatus") String processStatus);
+
+    int deleteById(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    /** 기관 삭제 캐스케이드 외에는 아직 쓰이지 않지만, 저장 비용이 낮아 함께 둔다(review_item과 동일 관례). */
+    int deleteByOrg(@Param("orgId") Long orgId);
+
+    /** 검색어/상태 필터와 무관한 기관 전체 진행률 - 목록 헤더와 기관 상세 통계 카드가 함께 쓴다. */
+    ProcessStats stats(@Param("orgId") Long orgId);
+}
 
src/main/java/kr/itn/itnhub/process/ProcessNotFoundException.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessNotFoundException.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.process;
+
+/**
+ * 존재하지 않는 권리처리 게시물 id로 조회/수정/삭제를 시도했을 때 던진다.
+ * URL 경로의 id가 단순히 틀린 것뿐이므로 GlobalExceptionHandler가 404로 변환한다.
+ */
+public class ProcessNotFoundException extends RuntimeException {
+    public ProcessNotFoundException(String message) {
+        super(message);
+    }
+}
 
src/main/java/kr/itn/itnhub/process/ProcessPage.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessPage.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.process;
+
+import java.util.List;
+
+/**
+ * 권리처리 탭 목록 응답. {@code items}는 keyword/status 필터가 적용된 현재 페이지 결과지만,
+ * {@code total}/{@code done}은 검색어·상태 필터와 무관한 기관 전체 통계다(헤더의
+ * "총 n건 · 처리완료 m건"과 기관 상세 통계 카드가 항상 같은 숫자를 보도록 하기 위함 -
+ * {@link ProcessStats} 참고).
+ */
+public record ProcessPage(List<ProcessItem> items, int total, int done, int page, int size) {
+}
 
src/main/java/kr/itn/itnhub/process/ProcessParser.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessParser.java
@@ -0,0 +1,163 @@
+package kr.itn.itnhub.process;
+
+import kr.itn.itnhub.seed.SeedParseException;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.DateUtil;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 기관별 권리처리 엑셀(시트명 {@code 권리처리}, 없으면 첫 시트로 대체)을 읽는다.
+ *
+ * 시트 배치 (1행 그룹제목, 2행 헤더, 3행부터 데이터, A열(순번)이 빈 첫 행에서 데이터 끝):
+ *   A 순번  B 사이트명  C 범주  D 게시판명  E 게시물제목  F URL주소  G 설명
+ *   H 첨부파일여부  I 기존 공공누리유형(+AI)  J 계약서 유무  K 제작일  L 공표일
+ *   M 권리확인(대분류)  N 권리확인(세부)  O 처리 구분  P 공공누리 유형  Q AI유형
+ *   R 의견  S 비고  T 기존 증빙자료  U (수정 링크 열, 건너뜀)
+ *   V 공공누리유형(판정)  W AI유형(판정)  X 최종의견  Y (빈 열, 건너뜀)
+ *   Z 판단근거  AA 처리상태
+ *
+ * ReviewParser와 마찬가지로 lastRowNum까지 돌되 A열이 비면 즉시 멈춘다.
+ *
+ * 제작일/공표일(K, L열)은 실제 날짜서식이 입혀진 값이라 {@code yyyy-MM-dd} 문자열로
+ * 정규화한다({@link DateUtil#isCellDateFormatted}로 판별). 그 외 숫자 셀은 ReviewParser와
+ * 동일하게 반올림 문자열로 바꾼다. 수식 오류(#VALUE! 등) 셀은 캐시된 결과 타입이 ERROR가
+ * 되어 default 분기(빈 문자열 → null)로 처리된다.
+ */
+@Component
+public class ProcessParser {
+
+    public static final String PRIMARY_SHEET_NAME = "권리처리";
+
+    private static final int DATA_START_ROW_INDEX = 2; // 3행
+
+    private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    private static final int COL_SEQ = 0;               // A
+    private static final int COL_SITE_NAME = 1;         // B
+    private static final int COL_CATEGORY = 2;          // C
+    private static final int COL_BOARD_NAME = 3;        // D
+    private static final int COL_POST_TITLE = 4;        // E
+    private static final int COL_URL = 5;                // F
+    private static final int COL_DESCRIPTION = 6;       // G
+    private static final int COL_HAS_ATTACHMENT = 7;    // H
+    private static final int COL_PRIOR_KOGL_TYPE = 8;   // I
+    private static final int COL_CONTRACT_DOCS = 9;     // J
+    private static final int COL_PRODUCED_DATE = 10;    // K
+    private static final int COL_PUBLISHED_DATE = 11;   // L
+    private static final int COL_REVIEW_MAJOR = 12;     // M
+    private static final int COL_REVIEW_MINOR = 13;     // N
+    private static final int COL_REVIEW_RESULT = 14;    // O
+    private static final int COL_REVIEW_KOGL_TYPE = 15; // P
+    private static final int COL_REVIEW_AI_TYPE = 16;   // Q
+    private static final int COL_REVIEW_OPINION = 17;   // R
+    private static final int COL_REVIEW_NOTE = 18;      // S
+    private static final int COL_PRIOR_EVIDENCE = 19;   // T
+    // 20 = U, 수정 링크 열, 건너뜀
+    private static final int COL_JUDGED_KOGL_TYPE = 21; // V
+    private static final int COL_JUDGED_AI_TYPE = 22;   // W
+    private static final int COL_FINAL_OPINION = 23;    // X
+    // 24 = Y, 빈 열, 건너뜀
+    private static final int COL_JUDGMENT_BASIS = 25;   // Z
+    private static final int COL_PROCESS_STATUS = 26;   // AA
+
+    public List<ProcessRow> parse(InputStream xlsx) {
+        try (Workbook wb = WorkbookFactory.create(xlsx)) {
+            Sheet sheet = wb.getSheet(PRIMARY_SHEET_NAME);
+            if (sheet == null && wb.getNumberOfSheets() > 0) {
+                sheet = wb.getSheetAt(0);
+            }
+            if (sheet == null) {
+                throw new SeedParseException("통합문서에 읽을 수 있는 시트가 없습니다.");
+            }
+            return readRows(sheet);
+        } catch (IOException e) {
+            throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e);
+        }
+    }
+
+    private List<ProcessRow> readRows(Sheet sheet) {
+        List<ProcessRow> result = new ArrayList<>();
+
+        for (int i = DATA_START_ROW_INDEX; i <= sheet.getLastRowNum(); i++) {
+            Row row = sheet.getRow(i);
+            if (row == null) {
+                break; // 데이터 영역은 연속적이다 - 빈 행이 나오면 그게 끝이다
+            }
+
+            String seqRaw = string(row, COL_SEQ);
+            if (seqRaw.isBlank()) {
+                break;
+            }
+            int seq;
+            try {
+                seq = Integer.parseInt(seqRaw.trim());
+            } catch (NumberFormatException e) {
+                break;
+            }
+
+            result.add(new ProcessRow(
+                    seq,
+                    nullIfBlank(string(row, COL_SITE_NAME)),
+                    nullIfBlank(string(row, COL_CATEGORY)),
+                    nullIfBlank(string(row, COL_BOARD_NAME)),
+                    nullIfBlank(string(row, COL_POST_TITLE)),
+                    nullIfBlank(string(row, COL_URL)),
+                    nullIfBlank(string(row, COL_DESCRIPTION)),
+                    nullIfBlank(string(row, COL_HAS_ATTACHMENT)),
+                    nullIfBlank(string(row, COL_PRIOR_KOGL_TYPE)),
+                    nullIfBlank(string(row, COL_CONTRACT_DOCS)),
+                    nullIfBlank(string(row, COL_PRODUCED_DATE)),
+                    nullIfBlank(string(row, COL_PUBLISHED_DATE)),
+                    nullIfBlank(string(row, COL_REVIEW_MAJOR)),
+                    nullIfBlank(string(row, COL_REVIEW_MINOR)),
+                    nullIfBlank(string(row, COL_REVIEW_RESULT)),
+                    nullIfBlank(string(row, COL_REVIEW_KOGL_TYPE)),
+                    nullIfBlank(string(row, COL_REVIEW_AI_TYPE)),
+                    nullIfBlank(string(row, COL_REVIEW_OPINION)),
+                    nullIfBlank(string(row, COL_REVIEW_NOTE)),
+                    nullIfBlank(string(row, COL_PRIOR_EVIDENCE)),
+                    nullIfBlank(string(row, COL_JUDGED_KOGL_TYPE)),
+                    nullIfBlank(string(row, COL_JUDGED_AI_TYPE)),
+                    nullIfBlank(string(row, COL_FINAL_OPINION)),
+                    nullIfBlank(string(row, COL_JUDGMENT_BASIS)),
+                    nullIfBlank(string(row, COL_PROCESS_STATUS))));
+        }
+        return result;
+    }
+
+    private String string(Row row, int columnIndex) {
+        Cell cell = row.getCell(columnIndex);
+        if (cell == null) {
+            return "";
+        }
+        return cellValue(cell, cell.getCellType());
+    }
+
+    /** ReviewParser와 동일한 셀 값 변환 규칙에, 날짜서식 숫자 셀을 yyyy-MM-dd로 정규화하는 분기를 더한다. */
+    private String cellValue(Cell cell, CellType cellType) {
+        return switch (cellType) {
+            case STRING -> cell.getStringCellValue().trim();
+            case NUMERIC -> DateUtil.isCellDateFormatted(cell)
+                    ? cell.getLocalDateTimeCellValue().format(DATE_FORMAT)
+                    : String.valueOf(Math.round(cell.getNumericCellValue()));
+            case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
+            case FORMULA -> cellValue(cell, cell.getCachedFormulaResultType());
+            default -> "";
+        };
+    }
+
+    private String nullIfBlank(String value) {
+        return (value == null || value.isBlank()) ? null : value;
+    }
+}
 
src/main/java/kr/itn/itnhub/process/ProcessRow.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessRow.java
@@ -0,0 +1,33 @@
+package kr.itn.itnhub.process;
+
+/**
+ * {@link ProcessParser}가 엑셀 한 행에서 읽어낸 원시 값. U열(수정 링크)과 Y열(빈 열)은
+ * 원본 시트에서 건너뛰는 열이라 대응 필드가 없다.
+ */
+public record ProcessRow(
+        int seq,
+        String siteName,
+        String category,
+        String boardName,
+        String postTitle,
+        String url,
+        String description,
+        String hasAttachment,
+        String priorKoglType,
+        String contractDocs,
+        String producedDate,
+        String publishedDate,
+        String reviewMajor,
+        String reviewMinor,
+        String reviewResult,
+        String reviewKoglType,
+        String reviewAiType,
+        String reviewOpinion,
+        String reviewNote,
+        String priorEvidence,
+        String judgedKoglType,
+        String judgedAiType,
+        String finalOpinion,
+        String judgmentBasis,
+        String processStatus) {
+}
 
src/main/java/kr/itn/itnhub/process/ProcessService.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessService.java
@@ -0,0 +1,176 @@
+package kr.itn.itnhub.process;
+
+import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.List;
+import java.util.Set;
+
+/** 기관관리 상세의 권리처리 탭이 쓰는 목록/상세/수정/삭제/다운로드 서비스. */
+@Service
+public class ProcessService {
+
+    private static final Set<String> VALID_PROCESS_STATUSES = Set.of("미처리", "처리완료");
+
+    /**
+     * 1행 그룹 제목(A=게시물 정보(조사원), M=권리확인(변호사), U=권리처리(변호사)) + 2행 컬럼 헤더.
+     * U열(수정 링크)과 Y열은 원본 시트의 건너뛰는 열이라 빈 문자열로 남겨둔다.
+     */
+    private static final String[] COLUMN_HEADERS = {
+            "순번", "사이트명", "범주", "게시판명", "게시물제목", "URL주소", "설명",
+            "첨부파일여부", "기존 공공누리유형(+AI)", "계약서 유무", "제작일", "공표일",
+            "권리확인(대분류)", "권리확인(세부)", "처리 구분", "공공누리 유형", "AI유형", "의견", "비고", "기존 증빙자료",
+            "", "공공누리유형(판정)", "AI유형(판정)", "최종의견", "", "판단근거", "처리상태"
+    };
+
+    private final ProcessItemMapper mapper;
+    private final OrganizationMapper orgMapper;
+
+    public ProcessService(ProcessItemMapper mapper, OrganizationMapper orgMapper) {
+        this.mapper = mapper;
+        this.orgMapper = orgMapper;
+    }
+
+    public ProcessPage list(Long orgId, String keyword, String status, int page, int size) {
+        requireOrg(orgId);
+        String kw = normalizeKeyword(keyword);
+        String st = normalizeStatus(status);
+        int safePage = Math.max(page, 0);
+        int safeSize = size <= 0 ? 30 : size;
+        int offset = safePage * safeSize;
+
+        List<ProcessItem> items = mapper.findPage(orgId, kw, st, offset, safeSize);
+        // total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다(헤더/통계 카드용 - ProcessPage 참고).
+        ProcessStats stats = mapper.stats(orgId);
+        return new ProcessPage(items, stats.total(), stats.done(), safePage, safeSize);
+    }
+
+    public ProcessItem get(Long orgId, Long itemId) {
+        requireOrg(orgId);
+        return requireItem(orgId, itemId);
+    }
+
+    @Transactional
+    public ProcessItem updateProcessing(Long orgId, Long itemId, ProcessingRequest request) {
+        requireOrg(orgId);
+        validateProcessStatus(request.processStatus());
+        int updated = mapper.updateProcessing(orgId, itemId,
+                request.contractDocs(), request.judgedKoglType(), request.judgedAiType(),
+                request.finalOpinion(), request.judgmentBasis(), request.processStatus());
+        if (updated == 0) {
+            throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
+        }
+        return mapper.findById(orgId, itemId);
+    }
+
+    @Transactional
+    public void delete(Long orgId, Long itemId) {
+        requireOrg(orgId);
+        int deleted = mapper.deleteById(orgId, itemId);
+        if (deleted == 0) {
+            throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
+        }
+    }
+
+    /** 원본 파일과 같은 헤더 배치(1행 그룹제목, 2행 컬럼헤더, 3행부터 데이터)로 xlsx를 만든다. */
+    public byte[] downloadWorkbook(Long orgId) {
+        requireOrg(orgId);
+        List<ProcessItem> items = mapper.findAllByOrg(orgId);
+
+        try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+            Sheet sheet = wb.createSheet(ProcessParser.PRIMARY_SHEET_NAME);
+
+            Row groupRow = sheet.createRow(0);
+            groupRow.createCell(0).setCellValue("게시물 정보(조사원)");
+            groupRow.createCell(12).setCellValue("권리확인(변호사)");
+            groupRow.createCell(20).setCellValue("권리처리(변호사)");
+
+            Row headerRow = sheet.createRow(1);
+            for (int i = 0; i < COLUMN_HEADERS.length; i++) {
+                headerRow.createCell(i).setCellValue(COLUMN_HEADERS[i]);
+            }
+
+            int rowNum = 2;
+            for (ProcessItem item : items) {
+                Row row = sheet.createRow(rowNum++);
+                setCell(row, 0, String.valueOf(item.getSeq()));
+                setCell(row, 1, item.getSiteName());
+                setCell(row, 2, item.getCategory());
+                setCell(row, 3, item.getBoardName());
+                setCell(row, 4, item.getPostTitle());
+                setCell(row, 5, item.getUrl());
+                setCell(row, 6, item.getDescription());
+                setCell(row, 7, item.getHasAttachment());
+                setCell(row, 8, item.getPriorKoglType());
+                setCell(row, 9, item.getContractDocs());
+                setCell(row, 10, item.getProducedDate());
+                setCell(row, 11, item.getPublishedDate());
+                setCell(row, 12, item.getReviewMajor());
+                setCell(row, 13, item.getReviewMinor());
+                setCell(row, 14, item.getReviewResult());
+                setCell(row, 15, item.getReviewKoglType());
+                setCell(row, 16, item.getReviewAiType());
+                setCell(row, 17, item.getReviewOpinion());
+                setCell(row, 18, item.getReviewNote());
+                setCell(row, 19, item.getPriorEvidence());
+                // 20 = U, 수정 링크 열, 건너뜀
+                setCell(row, 21, item.getJudgedKoglType());
+                setCell(row, 22, item.getJudgedAiType());
+                setCell(row, 23, item.getFinalOpinion());
+                // 24 = Y, 빈 열
+                setCell(row, 25, item.getJudgmentBasis());
+                setCell(row, 26, item.getProcessStatus());
+            }
+
+            wb.write(out);
+            return out.toByteArray();
+        } catch (IOException e) {
+            throw new UncheckedIOException("권리처리 엑셀을 만들지 못했습니다.", e);
+        }
+    }
+
+    private void setCell(Row row, int columnIndex, String value) {
+        if (value == null) {
+            return;
+        }
+        Cell cell = row.createCell(columnIndex);
+        cell.setCellValue(value);
+    }
+
+    private void validateProcessStatus(String processStatus) {
+        if (processStatus != null && !VALID_PROCESS_STATUSES.contains(processStatus)) {
+            throw new InvalidProcessStatusException("처리상태는 미처리 또는 처리완료여야 합니다.");
+        }
+    }
+
+    private String normalizeKeyword(String keyword) {
+        return (keyword == null || keyword.isBlank()) ? null : keyword.trim();
+    }
+
+    private String normalizeStatus(String status) {
+        return (status == null || status.isBlank()) ? null : status.trim();
+    }
+
+    private void requireOrg(Long orgId) {
+        if (orgMapper.findById(orgId) == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+    }
+
+    private ProcessItem requireItem(Long orgId, Long itemId) {
+        ProcessItem item = mapper.findById(orgId, itemId);
+        if (item == null) {
+            throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
+        }
+        return item;
+    }
+}
 
src/main/java/kr/itn/itnhub/process/ProcessStats.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessStats.java
@@ -0,0 +1,5 @@
+package kr.itn.itnhub.process;
+
+/** 기관 전체(검색어/상태 필터와 무관) 권리처리 진행률. 목록 헤더와 기관 상세 통계 카드가 함께 쓴다. */
+public record ProcessStats(int total, int done) {
+}
 
src/main/java/kr/itn/itnhub/process/ProcessingRequest.java (added)
+++ src/main/java/kr/itn/itnhub/process/ProcessingRequest.java
@@ -0,0 +1,15 @@
+package kr.itn.itnhub.process;
+
+/**
+ * 권리처리 상세의 [처리등록]/[수정] 저장 요청. 전부 선택값이며, 저장 시 web_edited_at이
+ * 채워진다. processStatus는 null이 아니면 "미처리"/"처리완료" 둘 중 하나여야 한다 -
+ * {@link ProcessService}가 그 외 값을 거부한다.
+ */
+public record ProcessingRequest(
+        String contractDocs,
+        String judgedKoglType,
+        String judgedAiType,
+        String finalOpinion,
+        String judgmentBasis,
+        String processStatus) {
+}
 
src/main/resources/db/migration/V10__process_item.sql (added)
+++ src/main/resources/db/migration/V10__process_item.sql
@@ -0,0 +1,35 @@
+create table process_item (
+    id              bigserial primary key,
+    org_id          bigint not null references organization (id) on delete cascade,
+    seq             integer not null,
+    site_name       varchar(300),
+    category        varchar(500),
+    board_name      varchar(300),
+    post_title      varchar(1000),
+    url             varchar(1000),
+    description     varchar(1000),
+    has_attachment  varchar(50),
+    prior_kogl_type varchar(100),   -- 기존 공공누리유형(+AI)
+    contract_docs   varchar(500),   -- 계약서 유무 (웹에선 체크박스 조합, 쉼표 연결 저장)
+    produced_date   varchar(20),
+    published_date  varchar(20),
+    review_major    varchar(200),
+    review_minor    varchar(300),
+    review_result   varchar(200),   -- 처리 구분
+    review_kogl_type varchar(50),
+    review_ai_type  varchar(50),
+    review_opinion  varchar(2000),
+    review_note     varchar(1000),
+    prior_evidence  varchar(1000),  -- 기존 증빙자료
+    judged_kogl_type varchar(50),
+    judged_ai_type  varchar(50),
+    final_opinion   varchar(3000),
+    judgment_basis  varchar(3000),  -- 판단근거
+    process_status  varchar(50),    -- 처리상태 (미처리/처리완료)
+    processed_at    timestamptz,    -- 처리완료로 저장된 시각
+    web_edited_at   timestamptz,
+    created_at      timestamptz not null default now(),
+    updated_at      timestamptz not null default now(),
+    constraint uq_process_item unique (org_id, seq)
+);
+create index ix_process_item_org on process_item (org_id, seq);
 
src/main/resources/mapper/ProcessItemMapper.xml (added)
+++ src/main/resources/mapper/ProcessItemMapper.xml
@@ -0,0 +1,168 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="kr.itn.itnhub.process.ProcessItemMapper">
+
+  <!--
+    processed_at/web_edited_at/created_at/updated_at을 밀리초 epoch로 내려준다
+    (review_item과 동일 관례). null이면 extract(epoch from null)도 null이라 그대로 null이 내려간다.
+  -->
+  <sql id="columns">
+    id, org_id, seq, site_name, category, board_name, post_title, url, description,
+    has_attachment, prior_kogl_type, contract_docs, produced_date, published_date,
+    review_major, review_minor, review_result, review_kogl_type, review_ai_type,
+    review_opinion, review_note, prior_evidence,
+    judged_kogl_type, judged_ai_type, final_opinion, judgment_basis, process_status,
+    (extract(epoch from processed_at) * 1000)::bigint as processed_at,
+    (extract(epoch from web_edited_at) * 1000)::bigint as web_edited_at,
+    (extract(epoch from created_at) * 1000)::bigint as created_at,
+    (extract(epoch from updated_at) * 1000)::bigint as updated_at
+  </sql>
+
+  <sql id="keywordFilter">
+    <if test="keyword != null">
+      and (site_name ilike concat('%', #{keyword}, '%')
+        or post_title ilike concat('%', #{keyword}, '%'))
+    </if>
+  </sql>
+
+  <!-- status: null=전체, "DONE"=처리완료, "PENDING"=그 외(null/빈/미처리 등 처리완료가 아닌 전부). -->
+  <sql id="statusFilter">
+    <if test="status != null and status == 'DONE'">
+      and process_status = '처리완료'
+    </if>
+    <if test="status != null and status == 'PENDING'">
+      and (process_status is null or process_status = '' or process_status &lt;&gt; '처리완료')
+    </if>
+  </sql>
+
+  <select id="findPage" resultType="kr.itn.itnhub.process.ProcessItem">
+    select <include refid="columns"/>
+    from process_item
+    where org_id = #{orgId}
+    <include refid="keywordFilter"/>
+    <include refid="statusFilter"/>
+    order by seq asc
+    limit #{limit} offset #{offset}
+  </select>
+
+  <select id="countByOrg" resultType="int">
+    select count(*)
+    from process_item
+    where org_id = #{orgId}
+    <include refid="keywordFilter"/>
+    <include refid="statusFilter"/>
+  </select>
+
+  <select id="findAllByOrg" resultType="kr.itn.itnhub.process.ProcessItem">
+    select <include refid="columns"/>
+    from process_item
+    where org_id = #{orgId}
+    order by seq asc
+  </select>
+
+  <select id="findSeqsByOrg" resultType="int">
+    select seq from process_item where org_id = #{orgId}
+  </select>
+
+  <select id="findById" resultType="kr.itn.itnhub.process.ProcessItem">
+    select <include refid="columns"/>
+    from process_item
+    where org_id = #{orgId} and id = #{id}
+  </select>
+
+  <!--
+    정책(b): 조사원+권리확인 영역(site_name..prior_evidence, 계약서 유무 제외)은 재업로드마다
+    항상 최신 파일값으로 덮어쓴다. 계약서 유무(contract_docs)는 웹에서 입력하는 값이라
+    web_edited_at이 null일 때만(=웹에서 아직 아무도 손대지 않았을 때만) 파일값을 반영한다.
+    권리처리 영역(judged_kogl_type..process_status)도 마찬가지로 web_edited_at이 null일
+    때만 파일값을 반영한다 - 이미 웹에서 처리 결과를 입력한 행은 재업로드가 절대 덮어쓰지
+    않는다. processed_at/web_edited_at 자체는 이 upsert가 건드리지 않는다.
+  -->
+  <insert id="upsertFromFile" parameterType="kr.itn.itnhub.process.ProcessItem">
+    insert into process_item (
+      org_id, seq, site_name, category, board_name, post_title, url, description,
+      has_attachment, prior_kogl_type, contract_docs, produced_date, published_date,
+      review_major, review_minor, review_result, review_kogl_type, review_ai_type,
+      review_opinion, review_note, prior_evidence,
+      judged_kogl_type, judged_ai_type, final_opinion, judgment_basis, process_status
+    ) values (
+      #{orgId}, #{seq}, #{siteName}, #{category}, #{boardName}, #{postTitle}, #{url}, #{description},
+      #{hasAttachment}, #{priorKoglType}, #{contractDocs}, #{producedDate}, #{publishedDate},
+      #{reviewMajor}, #{reviewMinor}, #{reviewResult}, #{reviewKoglType}, #{reviewAiType},
+      #{reviewOpinion}, #{reviewNote}, #{priorEvidence},
+      #{judgedKoglType}, #{judgedAiType}, #{finalOpinion}, #{judgmentBasis}, #{processStatus}
+    )
+    on conflict (org_id, seq) do update set
+      site_name       = excluded.site_name,
+      category        = excluded.category,
+      board_name      = excluded.board_name,
+      post_title      = excluded.post_title,
+      url             = excluded.url,
+      description     = excluded.description,
+      has_attachment  = excluded.has_attachment,
+      prior_kogl_type = excluded.prior_kogl_type,
+      produced_date   = excluded.produced_date,
+      published_date  = excluded.published_date,
+      review_major    = excluded.review_major,
+      review_minor    = excluded.review_minor,
+      review_result   = excluded.review_result,
+      review_kogl_type = excluded.review_kogl_type,
+      review_ai_type  = excluded.review_ai_type,
+      review_opinion  = excluded.review_opinion,
+      review_note     = excluded.review_note,
+      prior_evidence  = excluded.prior_evidence,
+      contract_docs   = case when process_item.web_edited_at is null then excluded.contract_docs else process_item.contract_docs end,
+      judged_kogl_type = case when process_item.web_edited_at is null then excluded.judged_kogl_type else process_item.judged_kogl_type end,
+      judged_ai_type  = case when process_item.web_edited_at is null then excluded.judged_ai_type else process_item.judged_ai_type end,
+      final_opinion   = case when process_item.web_edited_at is null then excluded.final_opinion else process_item.final_opinion end,
+      judgment_basis  = case when process_item.web_edited_at is null then excluded.judgment_basis else process_item.judgment_basis end,
+      process_status  = case when process_item.web_edited_at is null then excluded.process_status else process_item.process_status end,
+      updated_at      = now()
+  </insert>
+
+  <!--
+    processed_at은 "처리완료"에 처음 도달한 시각만 남긴다: 이미 값이 있으면(먼저 처리완료로
+    저장된 적이 있으면) 이번 저장이 다시 처리완료든 미처리로 되돌리든 손대지 않는다.
+  -->
+  <update id="updateProcessing">
+    update process_item set
+      contract_docs    = #{contractDocs},
+      judged_kogl_type = #{judgedKoglType},
+      judged_ai_type   = #{judgedAiType},
+      final_opinion    = #{finalOpinion},
+      judgment_basis   = #{judgmentBasis},
+      process_status   = #{processStatus},
+      web_edited_at    = now(),
+      processed_at     = case
+                            when #{processStatus} = '처리완료' and process_item.processed_at is null then now()
+                            else process_item.processed_at
+                          end,
+      updated_at       = now()
+    where org_id = #{orgId} and id = #{id}
+  </update>
+
+  <delete id="deleteById">
+    delete from process_item where org_id = #{orgId} and id = #{id}
+  </delete>
+
+  <delete id="deleteByOrg">
+    delete from process_item where org_id = #{orgId}
+  </delete>
+
+  <resultMap id="statsResultMap" type="kr.itn.itnhub.process.ProcessStats">
+    <constructor>
+      <arg column="total" javaType="_int"/>
+      <arg column="done" javaType="_int"/>
+    </constructor>
+  </resultMap>
+
+  <select id="stats" resultMap="statsResultMap">
+    select
+      count(*) as total,
+      count(*) filter (where process_status = '처리완료') as done
+    from process_item
+    where org_id = #{orgId}
+  </select>
+
+</mapper>
 
src/test/java/kr/itn/itnhub/process/ProcessControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/process/ProcessControllerTest.java
@@ -0,0 +1,383 @@
+package kr.itn.itnhub.process;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.DataFormat;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.io.ByteArrayOutputStream;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WithMockUser(roles = "ADMIN")
+class ProcessControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    ProcessItemMapper processMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from process_item");
+        jdbc.update("delete from organization");
+
+        Organization org = new Organization();
+        org.setOrgNo("001");
+        org.setOrgName("한국출판문화산업진흥원");
+        org.setChannelSlug("001");
+        orgMapper.upsertBySeed(org);
+
+        orgId = orgMapper.findAll().get(0).getId();
+    }
+
+    /**
+     * 필드 순서: seq,siteName,category,boardName,postTitle,url,description,hasAttachment,
+     * priorKoglType,contractDocs,producedDate,publishedDate,reviewMajor,reviewMinor,reviewResult,
+     * reviewKoglType,reviewAiType,reviewOpinion,reviewNote,priorEvidence,
+     * judgedKoglType,judgedAiType,finalOpinion,judgmentBasis,processStatus (25개, U/Y열은 건너뛴다).
+     */
+    private byte[] workbook(String[]... dataRows) throws Exception {
+        try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+            Sheet sheet = wb.createSheet(ProcessParser.PRIMARY_SHEET_NAME);
+            sheet.createRow(0);
+            sheet.createRow(1);
+
+            int rowNum = 2;
+            for (String[] data : dataRows) {
+                Row row = sheet.createRow(rowNum++);
+                for (int i = 0; i < data.length; i++) {
+                    if (data[i] == null) {
+                        continue;
+                    }
+                    row.createCell(sheetColumn(i)).setCellValue(data[i]);
+                }
+            }
+            wb.write(out);
+            return out.toByteArray();
+        }
+    }
+
+    /** 논리 필드 인덱스(0..24) → 실제 시트 열(U=20, Y=24 건너뜀). */
+    private int sheetColumn(int logicalIndex) {
+        int col = logicalIndex;
+        if (logicalIndex >= 20) {
+            col += 1; // U열 건너뜀
+        }
+        if (logicalIndex >= 23) {
+            col += 1; // Y열 건너뜀
+        }
+        return col;
+    }
+
+    private MockMultipartFile file(byte[] bytes) {
+        return new MockMultipartFile("file", "process.xlsx",
+                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", bytes);
+    }
+
+    private String[] row(String seq, String siteName, String category) {
+        String[] data = new String[25];
+        data[0] = seq;
+        data[1] = siteName;
+        data[2] = category;
+        data[4] = "제목";
+        data[5] = "http://example.com";
+        return data;
+    }
+
+    private String[] withProcessing(String[] base, String contractDocs, String judgedKoglType,
+                                     String finalOpinion, String processStatus) {
+        String[] copy = base.clone();
+        copy[9] = contractDocs;
+        copy[20] = judgedKoglType;
+        copy[22] = finalOpinion;
+        copy[24] = processStatus;
+        return copy;
+    }
+
+    @Test
+    void 최초_업로드는_전부_신규이고_제작일_공표일은_실제_날짜셀이어도_yyyyMMdd_문자열로_저장된다() throws Exception {
+        byte[] xlsx = dateCellWorkbook();
+
+        mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.created").value(1))
+                .andExpect(jsonPath("$.updated").value(0))
+                .andExpect(jsonPath("$.total").value(1));
+
+        ProcessItem item = processMapper.findAllByOrg(orgId).get(0);
+        assertThat(item.getProducedDate()).isEqualTo("2025-01-03");
+        assertThat(item.getSiteName()).isEqualTo("사이트");
+    }
+
+    /** 제작일(K열, index 10)에 실제 datetime 셀 값 2025-01-03T00:00:00을 심어 재현한다. */
+    private byte[] dateCellWorkbook() throws Exception {
+        try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+            Sheet sheet = wb.createSheet(ProcessParser.PRIMARY_SHEET_NAME);
+            sheet.createRow(0);
+            sheet.createRow(1);
+            Row row = sheet.createRow(2);
+            row.createCell(0).setCellValue("1");
+            row.createCell(1).setCellValue("사이트");
+
+            CellStyle dateStyle = wb.createCellStyle();
+            DataFormat fmt = wb.createDataFormat();
+            dateStyle.setDataFormat(fmt.getFormat("yyyy-mm-dd hh:mm:ss"));
+            Cell produced = row.createCell(10);
+            produced.setCellStyle(dateStyle);
+            produced.setCellValue(LocalDateTime.of(2025, 1, 3, 0, 0, 0));
+
+            wb.write(out);
+            return out.toByteArray();
+        }
+    }
+
+    @Test
+    void 같은_파일을_다시_올려도_행이_늘지_않고_갱신으로_집계된다() throws Exception {
+        byte[] xlsx = workbook(row("1", "사이트A", "공지"));
+
+        mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk());
+        mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.created").value(0))
+                .andExpect(jsonPath("$.updated").value(1));
+
+        assertThat(processMapper.findAllByOrg(orgId)).hasSize(1);
+    }
+
+    @Test
+    void 웹에서_수정한_권리처리값은_계약서유무를_포함해_재업로드가_덮어쓰지_않고_조사원_항목은_계속_갱신된다() throws Exception {
+        byte[] first = workbook(
+                row("1", "사이트A", "공지"),
+                row("2", "사이트B", "공지"));
+        mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(first)).with(csrf()))
+                .andExpect(status().isOk());
+
+        Long editedId = processMapper.findAllByOrg(orgId).stream()
+                .filter(i -> i.getSeq() == 1).findFirst().get().getId();
+
+        // seq=1은 웹에서 계약서 유무 + 처리결과를 직접 입력한다.
+        mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, editedId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contractDocs": "양도계약서", "finalOpinion": "전부 양도 체결", "processStatus": "처리완료"}
+                                """))
+                .andExpect(status().isOk());
+
+        // 재업로드: 두 행 모두 조사원 항목(site_name)이 바뀌고, 파일 쪽 계약서/처리 값도 새로 온다.
+        byte[] second = workbook(
+                withProcessing(row("1", "사이트A-수정", "공지"), "제안요청서", "1유형", "파일값", "미처리"),
+                withProcessing(row("2", "사이트B-수정", "공지"), "공문", "2유형", "새 의견", "처리완료"));
+        mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(second)).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.created").value(0))
+                .andExpect(jsonPath("$.updated").value(2));
+
+        ProcessItem edited = processMapper.findById(orgId, editedId);
+        assertThat(edited.getSiteName()).isEqualTo("사이트A-수정"); // 조사원 항목은 항상 갱신
+        assertThat(edited.getContractDocs()).isEqualTo("양도계약서"); // 웹 입력값 보존(계약서 유무 포함)
+        assertThat(edited.getFinalOpinion()).isEqualTo("전부 양도 체결");
+        assertThat(edited.getProcessStatus()).isEqualTo("처리완료");
+        assertThat(edited.getWebEditedAt()).isNotNull();
+
+        ProcessItem untouched = processMapper.findAllByOrg(orgId).stream()
+                .filter(i -> i.getSeq() == 2).findFirst().get();
+        assertThat(untouched.getSiteName()).isEqualTo("사이트B-수정");
+        assertThat(untouched.getContractDocs()).isEqualTo("공문"); // 웹 수정 이력 없으니 파일값 반영
+        assertThat(untouched.getFinalOpinion()).isEqualTo("새 의견");
+        assertThat(untouched.getProcessStatus()).isEqualTo("처리완료");
+        assertThat(untouched.getWebEditedAt()).isNull();
+    }
+
+    @Test
+    void 목록은_검색어와_상태로_필터링되고_처리완료는_미처리_필터에서_제외된다() throws Exception {
+        byte[] xlsx = workbook(
+                row("1", "국립중앙도서관", "공지"),
+                row("2", "국립중앙박물관", "공지"),
+                row("3", "다른기관", "공지"));
+        mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk());
+
+        Long doneId = processMapper.findAllByOrg(orgId).stream()
+                .filter(i -> i.getSeq() == 1).findFirst().get().getId();
+        mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, doneId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"processStatus": "처리완료"}
+                                """))
+                .andExpect(status().isOk());
+
+        // 검색어로 "국립"이 들어간 2건만 필터링된다.
+        mvc.perform(get("/api/orgs/{id}/process", orgId).param("keyword", "국립"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.items.length()").value(2));
+
+        // status=DONE이면 처리완료 1건만.
+        mvc.perform(get("/api/orgs/{id}/process", orgId).param("status", "DONE"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.items.length()").value(1))
+                .andExpect(jsonPath("$.items[0].seq").value(1));
+
+        // status=PENDING이면 처리완료 1건을 제외한 나머지 2건.
+        mvc.perform(get("/api/orgs/{id}/process", orgId).param("status", "PENDING"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.items.length()").value(2));
+
+        // total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다.
+        mvc.perform(get("/api/orgs/{id}/process", orgId).param("status", "PENDING"))
+                .andExpect(jsonPath("$.total").value(3))
+                .andExpect(jsonPath("$.done").value(1));
+    }
+
+    @Test
+    void 처리완료로_저장하면_웹수정시각과_처리완료시각이_찍히고_이후_저장에도_처리완료시각은_유지된다() throws Exception {
+        byte[] xlsx = workbook(row("1", "사이트A", "공지"));
+        mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk());
+        Long itemId = processMapper.findAllByOrg(orgId).get(0).getId();
+
+        mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, itemId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"processStatus": "처리완료", "finalOpinion": "1차 완료"}
+                                """))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.processStatus").value("처리완료"))
+                .andExpect(jsonPath("$.webEditedAt").isNumber())
+                .andExpect(jsonPath("$.processedAt").isNumber());
+
+        Long firstProcessedAt = processMapper.findById(orgId, itemId).getProcessedAt();
+        assertThat(firstProcessedAt).isNotNull();
+
+        // 이후 다른 필드만 바꿔 다시 저장해도(여전히 처리완료) 최초 완료 시각은 그대로다.
+        mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, itemId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"processStatus": "처리완료", "finalOpinion": "2차 수정"}
+                                """))
+                .andExpect(status().isOk());
+
+        ProcessItem afterSecondSave = processMapper.findById(orgId, itemId);
+        assertThat(afterSecondSave.getProcessedAt()).isEqualTo(firstProcessedAt);
+        assertThat(afterSecondSave.getFinalOpinion()).isEqualTo("2차 수정");
+
+        // 미처리로 되돌려도 처리완료 시각 자체는 지우지 않는다(표시만 처리완료일 때 한다).
+        mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, itemId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"processStatus": "미처리"}
+                                """))
+                .andExpect(status().isOk());
+
+        assertThat(processMapper.findById(orgId, itemId).getProcessedAt()).isEqualTo(firstProcessedAt);
+    }
+
+    @Test
+    void 처리상태가_미처리_처리완료가_아니면_400이다() throws Exception {
+        byte[] xlsx = workbook(row("1", "사이트A", "공지"));
+        mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk());
+        Long itemId = processMapper.findAllByOrg(orgId).get(0).getId();
+
+        mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, itemId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"processStatus": "진행중"}
+                                """))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(containsString("미처리")));
+    }
+
+    @Test
+    void 게시물을_삭제하면_204와_함께_목록에서_사라진다() throws Exception {
+        byte[] xlsx = workbook(row("1", "사이트A", "공지"));
+        mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk());
+        Long itemId = processMapper.findAllByOrg(orgId).get(0).getId();
+
+        mvc.perform(delete("/api/orgs/{id}/process/{itemId}", orgId, itemId).with(csrf()))
+                .andExpect(status().isNoContent());
+
+        assertThat(processMapper.findAllByOrg(orgId)).isEmpty();
+    }
+
+    @Test
+    void 존재하지_않는_기관으로_업로드하면_404다() throws Exception {
+        long missingOrgId = orgId + 999999L;
+        byte[] xlsx = workbook(row("1", "사이트A", "공지"));
+
+        mvc.perform(multipart("/api/orgs/{id}/process/import", missingOrgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingOrgId))));
+    }
+
+    @Test
+    void 존재하지_않는_게시물이면_404다() throws Exception {
+        long missingItemId = 999999L;
+
+        mvc.perform(get("/api/orgs/{id}/process/{itemId}", orgId, missingItemId))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingItemId))));
+    }
+
+    @Test
+    void 확장자가_xlsx_xlsm이_아니면_400이다() throws Exception {
+        MockMultipartFile txt = new MockMultipartFile("file", "process.txt",
+                "text/plain", "아무 내용".getBytes());
+
+        mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(txt).with(csrf()))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(containsString("xlsx")));
+    }
+
+    @Test
+    void 다운로드는_엑셀_파일을_돌려준다() throws Exception {
+        byte[] xlsx = workbook(row("1", "사이트A", "공지"));
+        mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk());
+
+        mvc.perform(get("/api/orgs/{id}/process/download", orgId))
+                .andExpect(status().isOk())
+                .andExpect(result -> assertThat(result.getResponse().getContentAsByteArray()).isNotEmpty());
+    }
+}
Add a comment
List