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
... | ... | @@ -5,6 +5,8 @@ |
| 5 | 5 |
import kr.itn.itnhub.feed.ChannelNotReadyException; |
| 6 | 6 |
import kr.itn.itnhub.feed.InvalidMessageException; |
| 7 | 7 |
import kr.itn.itnhub.org.OrgNotFoundException; |
| 8 |
+import kr.itn.itnhub.process.InvalidProcessStatusException; |
|
| 9 |
+import kr.itn.itnhub.process.ProcessNotFoundException; |
|
| 8 | 10 |
import kr.itn.itnhub.review.ReviewNotFoundException; |
| 9 | 11 |
import kr.itn.itnhub.seed.SeedParseException; |
| 10 | 12 |
import kr.itn.itnhub.stage.InvalidStageException; |
... | ... | @@ -56,6 +58,12 @@ |
| 56 | 58 |
* |
| 57 | 59 |
* <p>{@link ReviewNotFoundException}은 존재하지 않는 권리확인 게시물 id로 조회/수정/삭제를
|
| 58 | 60 |
* 시도했을 때 던진다 - URL의 id가 단순히 틀린 것뿐이므로 404가 맞다.</p> |
| 61 |
+ * |
|
| 62 |
+ * <p>{@link ProcessNotFoundException}은 존재하지 않는 권리처리 게시물 id로 조회/수정/삭제를
|
|
| 63 |
+ * 시도했을 때 던진다 - URL의 id가 단순히 틀린 것뿐이므로 404가 맞다.</p> |
|
| 64 |
+ * |
|
| 65 |
+ * <p>{@link InvalidProcessStatusException}은 권리처리상태가 "미처리"/"처리완료"가 아닌
|
|
| 66 |
+ * 값으로 저장을 시도할 때 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p> |
|
| 59 | 67 |
* |
| 60 | 68 |
* <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
|
| 61 | 69 |
* 예외까지 4xx로 감싸버리면 진짜 버그가 조용히 묻힌다. 예상 밖 예외는 기본 500 처리 |
... | ... | @@ -120,4 +128,14 @@ |
| 120 | 128 |
public ResponseEntity<ApiError> handleReviewNotFound(ReviewNotFoundException e) {
|
| 121 | 129 |
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage())); |
| 122 | 130 |
} |
| 131 |
+ |
|
| 132 |
+ @ExceptionHandler(ProcessNotFoundException.class) |
|
| 133 |
+ public ResponseEntity<ApiError> handleProcessNotFound(ProcessNotFoundException e) {
|
|
| 134 |
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage())); |
|
| 135 |
+ } |
|
| 136 |
+ |
|
| 137 |
+ @ExceptionHandler(InvalidProcessStatusException.class) |
|
| 138 |
+ public ResponseEntity<ApiError> handleInvalidProcessStatus(InvalidProcessStatusException e) {
|
|
| 139 |
+ return ResponseEntity.badRequest().body(new ApiError(e.getMessage())); |
|
| 140 |
+ } |
|
| 123 | 141 |
} |
+++ src/main/java/kr/itn/itnhub/process/InvalidProcessStatusException.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 권리처리상태(processStatus)가 null이 아니면서 "미처리"/"처리완료" 둘 중 하나도 아닐 때 던진다. | |
| 5 | + * 흔한 사용자 실수(오타, 빈 문자열)이므로 GlobalExceptionHandler가 400으로 변환한다. | |
| 6 | + */ | |
| 7 | +public class InvalidProcessStatusException extends RuntimeException { | |
| 8 | + public InvalidProcessStatusException(String message) { | |
| 9 | + super(message); | |
| 10 | + } | |
| 11 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessController.java
... | ... | @@ -0,0 +1,107 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 4 | +import kr.itn.itnhub.org.Organization; | |
| 5 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 6 | +import kr.itn.itnhub.seed.SeedParseException; | |
| 7 | +import org.springframework.http.HttpHeaders; | |
| 8 | +import org.springframework.http.HttpStatus; | |
| 9 | +import org.springframework.http.MediaType; | |
| 10 | +import org.springframework.http.ResponseEntity; | |
| 11 | +import org.springframework.web.bind.annotation.DeleteMapping; | |
| 12 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 13 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 14 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 15 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 16 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 17 | +import org.springframework.web.bind.annotation.RequestParam; | |
| 18 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 19 | +import org.springframework.web.bind.annotation.RestController; | |
| 20 | +import org.springframework.web.multipart.MultipartFile; | |
| 21 | + | |
| 22 | +import java.io.IOException; | |
| 23 | +import java.io.InputStream; | |
| 24 | +import java.net.URLEncoder; | |
| 25 | +import java.nio.charset.StandardCharsets; | |
| 26 | + | |
| 27 | +/** 기관관리 상세의 권리처리 탭이 부르는 엔드포인트. */ | |
| 28 | +@RestController | |
| 29 | +public class ProcessController { | |
| 30 | + | |
| 31 | + private final ProcessService processService; | |
| 32 | + private final ProcessImportService importService; | |
| 33 | + private final OrganizationMapper orgMapper; | |
| 34 | + | |
| 35 | + public ProcessController(ProcessService processService, ProcessImportService importService, | |
| 36 | + OrganizationMapper orgMapper) { | |
| 37 | + this.processService = processService; | |
| 38 | + this.importService = importService; | |
| 39 | + this.orgMapper = orgMapper; | |
| 40 | + } | |
| 41 | + | |
| 42 | + @PostMapping("/api/orgs/{id}/process/import") | |
| 43 | + public ProcessImportReport upload(@PathVariable("id") Long orgId, @RequestParam("file") MultipartFile file) { | |
| 44 | + if (file.isEmpty()) { | |
| 45 | + throw new SeedParseException("업로드된 파일이 비어 있습니다."); | |
| 46 | + } | |
| 47 | + String filename = file.getOriginalFilename(); | |
| 48 | + if (filename == null || !hasAllowedExtension(filename)) { | |
| 49 | + throw new SeedParseException("엑셀 파일(.xlsx 또는 .xlsm)만 업로드할 수 있습니다."); | |
| 50 | + } | |
| 51 | + try (InputStream in = file.getInputStream()) { | |
| 52 | + return importService.importFile(orgId, in); | |
| 53 | + } catch (IOException e) { | |
| 54 | + throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e); | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + @GetMapping("/api/orgs/{id}/process") | |
| 59 | + public ProcessPage list(@PathVariable("id") Long orgId, | |
| 60 | + @RequestParam(required = false) String keyword, | |
| 61 | + @RequestParam(required = false) String status, | |
| 62 | + @RequestParam(defaultValue = "0") int page, | |
| 63 | + @RequestParam(defaultValue = "30") int size) { | |
| 64 | + return processService.list(orgId, keyword, status, page, size); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @GetMapping("/api/orgs/{id}/process/{itemId}") | |
| 68 | + public ProcessItem get(@PathVariable("id") Long orgId, @PathVariable Long itemId) { | |
| 69 | + return processService.get(orgId, itemId); | |
| 70 | + } | |
| 71 | + | |
| 72 | + @PutMapping("/api/orgs/{id}/process/{itemId}") | |
| 73 | + public ProcessItem update(@PathVariable("id") Long orgId, @PathVariable Long itemId, | |
| 74 | + @RequestBody ProcessingRequest request) { | |
| 75 | + return processService.updateProcessing(orgId, itemId, request); | |
| 76 | + } | |
| 77 | + | |
| 78 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 79 | + @DeleteMapping("/api/orgs/{id}/process/{itemId}") | |
| 80 | + public void delete(@PathVariable("id") Long orgId, @PathVariable Long itemId) { | |
| 81 | + processService.delete(orgId, itemId); | |
| 82 | + } | |
| 83 | + | |
| 84 | + /** 한글 파일명이라 ReviewController.download와 동일하게 RFC 5987 인코딩을 쓴다. */ | |
| 85 | + @GetMapping("/api/orgs/{id}/process/download") | |
| 86 | + public ResponseEntity<byte[]> download(@PathVariable("id") Long orgId) { | |
| 87 | + Organization org = orgMapper.findById(orgId); | |
| 88 | + if (org == null) { | |
| 89 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 90 | + } | |
| 91 | + byte[] content = processService.downloadWorkbook(orgId); | |
| 92 | + | |
| 93 | + String filename = "권리처리_" + org.getOrgName() + ".xlsx"; | |
| 94 | + String encodedName = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); | |
| 95 | + | |
| 96 | + return ResponseEntity.ok() | |
| 97 | + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName) | |
| 98 | + .contentType(MediaType.parseMediaType( | |
| 99 | + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |
| 100 | + .body(content); | |
| 101 | + } | |
| 102 | + | |
| 103 | + private static boolean hasAllowedExtension(String filename) { | |
| 104 | + String lower = filename.toLowerCase(); | |
| 105 | + return lower.endsWith(".xlsx") || lower.endsWith(".xlsm"); | |
| 106 | + } | |
| 107 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessImportReport.java
... | ... | @@ -0,0 +1,4 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +public record ProcessImportReport(int created, int updated, int total) { | |
| 4 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessImportService.java
... | ... | @@ -0,0 +1,89 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 4 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 5 | +import org.springframework.stereotype.Service; | |
| 6 | +import org.springframework.transaction.PlatformTransactionManager; | |
| 7 | +import org.springframework.transaction.support.TransactionTemplate; | |
| 8 | + | |
| 9 | +import java.io.InputStream; | |
| 10 | +import java.util.HashSet; | |
| 11 | +import java.util.List; | |
| 12 | +import java.util.Set; | |
| 13 | + | |
| 14 | +@Service | |
| 15 | +public class ProcessImportService { | |
| 16 | + | |
| 17 | + private final ProcessParser parser; | |
| 18 | + private final ProcessItemMapper mapper; | |
| 19 | + private final OrganizationMapper orgMapper; | |
| 20 | + private final TransactionTemplate transactionTemplate; | |
| 21 | + | |
| 22 | + public ProcessImportService(ProcessParser parser, ProcessItemMapper mapper, OrganizationMapper orgMapper, | |
| 23 | + PlatformTransactionManager transactionManager) { | |
| 24 | + this.parser = parser; | |
| 25 | + this.mapper = mapper; | |
| 26 | + this.orgMapper = orgMapper; | |
| 27 | + this.transactionTemplate = new TransactionTemplate(transactionManager); | |
| 28 | + } | |
| 29 | + | |
| 30 | + /** | |
| 31 | + * 엑셀 파싱은 DB와 무관하므로 트랜잭션 밖에서 수행한다(ReviewImportService와 동일한 이유). | |
| 32 | + */ | |
| 33 | + public ProcessImportReport importFile(Long orgId, InputStream xlsx) { | |
| 34 | + if (orgMapper.findById(orgId) == null) { | |
| 35 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 36 | + } | |
| 37 | + List<ProcessRow> rows = parser.parse(xlsx); | |
| 38 | + return transactionTemplate.execute(status -> importRows(orgId, rows)); | |
| 39 | + } | |
| 40 | + | |
| 41 | + private ProcessImportReport importRows(Long orgId, List<ProcessRow> rows) { | |
| 42 | + // 이미 DB에 있는 순번 + 이번 파일에서 먼저 처리된 순번을 모두 "이미 존재함"으로 취급한다 | |
| 43 | + // (ReviewImportService.importRows와 동일한 관례). | |
| 44 | + Set<Integer> seen = new HashSet<>(mapper.findSeqsByOrg(orgId)); | |
| 45 | + | |
| 46 | + int created = 0; | |
| 47 | + int updated = 0; | |
| 48 | + for (ProcessRow row : rows) { | |
| 49 | + if (seen.add(row.seq())) { | |
| 50 | + created++; | |
| 51 | + } else { | |
| 52 | + updated++; | |
| 53 | + } | |
| 54 | + mapper.upsertFromFile(toProcessItem(orgId, row)); | |
| 55 | + } | |
| 56 | + return new ProcessImportReport(created, updated, rows.size()); | |
| 57 | + } | |
| 58 | + | |
| 59 | + private ProcessItem toProcessItem(Long orgId, ProcessRow row) { | |
| 60 | + ProcessItem item = new ProcessItem(); | |
| 61 | + item.setOrgId(orgId); | |
| 62 | + item.setSeq(row.seq()); | |
| 63 | + item.setSiteName(row.siteName()); | |
| 64 | + item.setCategory(row.category()); | |
| 65 | + item.setBoardName(row.boardName()); | |
| 66 | + item.setPostTitle(row.postTitle()); | |
| 67 | + item.setUrl(row.url()); | |
| 68 | + item.setDescription(row.description()); | |
| 69 | + item.setHasAttachment(row.hasAttachment()); | |
| 70 | + item.setPriorKoglType(row.priorKoglType()); | |
| 71 | + item.setContractDocs(row.contractDocs()); | |
| 72 | + item.setProducedDate(row.producedDate()); | |
| 73 | + item.setPublishedDate(row.publishedDate()); | |
| 74 | + item.setReviewMajor(row.reviewMajor()); | |
| 75 | + item.setReviewMinor(row.reviewMinor()); | |
| 76 | + item.setReviewResult(row.reviewResult()); | |
| 77 | + item.setReviewKoglType(row.reviewKoglType()); | |
| 78 | + item.setReviewAiType(row.reviewAiType()); | |
| 79 | + item.setReviewOpinion(row.reviewOpinion()); | |
| 80 | + item.setReviewNote(row.reviewNote()); | |
| 81 | + item.setPriorEvidence(row.priorEvidence()); | |
| 82 | + item.setJudgedKoglType(row.judgedKoglType()); | |
| 83 | + item.setJudgedAiType(row.judgedAiType()); | |
| 84 | + item.setFinalOpinion(row.finalOpinion()); | |
| 85 | + item.setJudgmentBasis(row.judgmentBasis()); | |
| 86 | + item.setProcessStatus(row.processStatus()); | |
| 87 | + return item; | |
| 88 | + } | |
| 89 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessItem.java
... | ... | @@ -0,0 +1,146 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 권리처리 게시물 한 건. {@code A:T} 열(수정 링크 U열은 제외)과 {@code V:X, Z, AA} 열이 | |
| 5 | + * 그대로 대응한다({@code U, Y}는 원본 시트의 건너뛰는 열이라 컬럼이 없다). 조사원+권리확인 | |
| 6 | + * 영역(siteName..priorEvidence)은 재업로드마다 항상 파일이 최신값으로 덮어쓰되, 계약서 | |
| 7 | + * 유무(contractDocs)만은 웹에서 입력하는 값이라 {@link #webEditedAt}이 null일 때만 파일이 | |
| 8 | + * 덮어쓴다. 권리처리 영역(judgedKoglType..processStatus)도 {@link #webEditedAt}이 null일 | |
| 9 | + * 때만 파일이 덮어쓴다 - 자세한 병합 정책은 {@code ProcessItemMapper.xml}의 upsertFromFile 참고. | |
| 10 | + */ | |
| 11 | +public class ProcessItem { | |
| 12 | + | |
| 13 | + private Long id; | |
| 14 | + private Long orgId; | |
| 15 | + private int seq; | |
| 16 | + | |
| 17 | + // 게시물 정보(조사원) + 권리확인(변호사) - 재업로드 시 항상 파일 값으로 갱신 | |
| 18 | + private String siteName; | |
| 19 | + private String category; | |
| 20 | + private String boardName; | |
| 21 | + private String postTitle; | |
| 22 | + private String url; | |
| 23 | + private String description; | |
| 24 | + private String hasAttachment; | |
| 25 | + private String priorKoglType; | |
| 26 | + private String producedDate; | |
| 27 | + private String publishedDate; | |
| 28 | + private String reviewMajor; | |
| 29 | + private String reviewMinor; | |
| 30 | + private String reviewResult; | |
| 31 | + private String reviewKoglType; | |
| 32 | + private String reviewAiType; | |
| 33 | + private String reviewOpinion; | |
| 34 | + private String reviewNote; | |
| 35 | + private String priorEvidence; | |
| 36 | + | |
| 37 | + // 계약서 유무 - 웹에서 입력하는 값이라 webEditedAt이 null일 때만 재업로드가 덮어씀 | |
| 38 | + private String contractDocs; | |
| 39 | + | |
| 40 | + // 권리처리(변호사) - webEditedAt이 null일 때만 재업로드가 덮어씀 | |
| 41 | + private String judgedKoglType; | |
| 42 | + private String judgedAiType; | |
| 43 | + private String finalOpinion; | |
| 44 | + private String judgmentBasis; | |
| 45 | + private String processStatus; | |
| 46 | + | |
| 47 | + /** null이면 아직 웹에서 권리처리 판정을 수정한 적이 없다는 뜻이다(재업로드 병합 판단 기준). */ | |
| 48 | + private Long webEditedAt; | |
| 49 | + /** 처리상태가 "처리완료"로 처음 저장된 시각. 이후 재저장돼도 바뀌지 않는다. */ | |
| 50 | + private Long processedAt; | |
| 51 | + private long createdAt; | |
| 52 | + private long updatedAt; | |
| 53 | + | |
| 54 | + public Long getId() { return id; } | |
| 55 | + public void setId(Long id) { this.id = id; } | |
| 56 | + | |
| 57 | + public Long getOrgId() { return orgId; } | |
| 58 | + public void setOrgId(Long orgId) { this.orgId = orgId; } | |
| 59 | + | |
| 60 | + public int getSeq() { return seq; } | |
| 61 | + public void setSeq(int seq) { this.seq = seq; } | |
| 62 | + | |
| 63 | + public String getSiteName() { return siteName; } | |
| 64 | + public void setSiteName(String siteName) { this.siteName = siteName; } | |
| 65 | + | |
| 66 | + public String getCategory() { return category; } | |
| 67 | + public void setCategory(String category) { this.category = category; } | |
| 68 | + | |
| 69 | + public String getBoardName() { return boardName; } | |
| 70 | + public void setBoardName(String boardName) { this.boardName = boardName; } | |
| 71 | + | |
| 72 | + public String getPostTitle() { return postTitle; } | |
| 73 | + public void setPostTitle(String postTitle) { this.postTitle = postTitle; } | |
| 74 | + | |
| 75 | + public String getUrl() { return url; } | |
| 76 | + public void setUrl(String url) { this.url = url; } | |
| 77 | + | |
| 78 | + public String getDescription() { return description; } | |
| 79 | + public void setDescription(String description) { this.description = description; } | |
| 80 | + | |
| 81 | + public String getHasAttachment() { return hasAttachment; } | |
| 82 | + public void setHasAttachment(String hasAttachment) { this.hasAttachment = hasAttachment; } | |
| 83 | + | |
| 84 | + public String getPriorKoglType() { return priorKoglType; } | |
| 85 | + public void setPriorKoglType(String priorKoglType) { this.priorKoglType = priorKoglType; } | |
| 86 | + | |
| 87 | + public String getContractDocs() { return contractDocs; } | |
| 88 | + public void setContractDocs(String contractDocs) { this.contractDocs = contractDocs; } | |
| 89 | + | |
| 90 | + public String getProducedDate() { return producedDate; } | |
| 91 | + public void setProducedDate(String producedDate) { this.producedDate = producedDate; } | |
| 92 | + | |
| 93 | + public String getPublishedDate() { return publishedDate; } | |
| 94 | + public void setPublishedDate(String publishedDate) { this.publishedDate = publishedDate; } | |
| 95 | + | |
| 96 | + public String getReviewMajor() { return reviewMajor; } | |
| 97 | + public void setReviewMajor(String reviewMajor) { this.reviewMajor = reviewMajor; } | |
| 98 | + | |
| 99 | + public String getReviewMinor() { return reviewMinor; } | |
| 100 | + public void setReviewMinor(String reviewMinor) { this.reviewMinor = reviewMinor; } | |
| 101 | + | |
| 102 | + public String getReviewResult() { return reviewResult; } | |
| 103 | + public void setReviewResult(String reviewResult) { this.reviewResult = reviewResult; } | |
| 104 | + | |
| 105 | + public String getReviewKoglType() { return reviewKoglType; } | |
| 106 | + public void setReviewKoglType(String reviewKoglType) { this.reviewKoglType = reviewKoglType; } | |
| 107 | + | |
| 108 | + public String getReviewAiType() { return reviewAiType; } | |
| 109 | + public void setReviewAiType(String reviewAiType) { this.reviewAiType = reviewAiType; } | |
| 110 | + | |
| 111 | + public String getReviewOpinion() { return reviewOpinion; } | |
| 112 | + public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; } | |
| 113 | + | |
| 114 | + public String getReviewNote() { return reviewNote; } | |
| 115 | + public void setReviewNote(String reviewNote) { this.reviewNote = reviewNote; } | |
| 116 | + | |
| 117 | + public String getPriorEvidence() { return priorEvidence; } | |
| 118 | + public void setPriorEvidence(String priorEvidence) { this.priorEvidence = priorEvidence; } | |
| 119 | + | |
| 120 | + public String getJudgedKoglType() { return judgedKoglType; } | |
| 121 | + public void setJudgedKoglType(String judgedKoglType) { this.judgedKoglType = judgedKoglType; } | |
| 122 | + | |
| 123 | + public String getJudgedAiType() { return judgedAiType; } | |
| 124 | + public void setJudgedAiType(String judgedAiType) { this.judgedAiType = judgedAiType; } | |
| 125 | + | |
| 126 | + public String getFinalOpinion() { return finalOpinion; } | |
| 127 | + public void setFinalOpinion(String finalOpinion) { this.finalOpinion = finalOpinion; } | |
| 128 | + | |
| 129 | + public String getJudgmentBasis() { return judgmentBasis; } | |
| 130 | + public void setJudgmentBasis(String judgmentBasis) { this.judgmentBasis = judgmentBasis; } | |
| 131 | + | |
| 132 | + public String getProcessStatus() { return processStatus; } | |
| 133 | + public void setProcessStatus(String processStatus) { this.processStatus = processStatus; } | |
| 134 | + | |
| 135 | + public Long getWebEditedAt() { return webEditedAt; } | |
| 136 | + public void setWebEditedAt(Long webEditedAt) { this.webEditedAt = webEditedAt; } | |
| 137 | + | |
| 138 | + public Long getProcessedAt() { return processedAt; } | |
| 139 | + public void setProcessedAt(Long processedAt) { this.processedAt = processedAt; } | |
| 140 | + | |
| 141 | + public long getCreatedAt() { return createdAt; } | |
| 142 | + public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } | |
| 143 | + | |
| 144 | + public long getUpdatedAt() { return updatedAt; } | |
| 145 | + public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } | |
| 146 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
... | ... | @@ -0,0 +1,53 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +import org.apache.ibatis.annotations.Mapper; | |
| 4 | +import org.apache.ibatis.annotations.Param; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +@Mapper | |
| 9 | +public interface ProcessItemMapper { | |
| 10 | + | |
| 11 | + /** | |
| 12 | + * 순번 오름차순, keyword는 사이트명/게시물제목 LIKE. status는 null=전체, | |
| 13 | + * "DONE"=처리완료, "PENDING"=그 외(null/빈/미처리)만. | |
| 14 | + */ | |
| 15 | + List<ProcessItem> findPage(@Param("orgId") Long orgId, @Param("keyword") String keyword, | |
| 16 | + @Param("status") String status, @Param("offset") int offset, | |
| 17 | + @Param("limit") int limit); | |
| 18 | + | |
| 19 | + /** keyword/status로 필터링된 건수 - 필터별 페이지 경계 계산용(현재 응답의 total과는 별개). */ | |
| 20 | + int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword, @Param("status") String status); | |
| 21 | + | |
| 22 | + /** 다운로드/재업로드 판정용으로 페이징 없이 순번순 전체를 읽는다. */ | |
| 23 | + List<ProcessItem> findAllByOrg(@Param("orgId") Long orgId); | |
| 24 | + | |
| 25 | + /** 이미 저장된 순번 전체 - ImportService가 신규/갱신 집계를 메모리에서 판단하기 위함. */ | |
| 26 | + List<Integer> findSeqsByOrg(@Param("orgId") Long orgId); | |
| 27 | + | |
| 28 | + ProcessItem findById(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 29 | + | |
| 30 | + /** | |
| 31 | + * 엑셀 재업로드 1행 반영. (org_id, seq) 충돌 시 조사원+권리확인 영역(계약서 유무 제외)은 | |
| 32 | + * 항상 덮어쓰고, 계약서 유무와 권리처리 영역은 web_edited_at이 null일 때만 덮어쓴다 - | |
| 33 | + * 정책 세부는 XML 주석 참고. | |
| 34 | + */ | |
| 35 | + int upsertFromFile(ProcessItem item); | |
| 36 | + | |
| 37 | + /** 권리처리 상세의 [처리등록]/[수정] 저장. web_edited_at을 찍고, 처리완료 최초 도달 시각을 processed_at에 남긴다. */ | |
| 38 | + int updateProcessing(@Param("orgId") Long orgId, @Param("id") Long id, | |
| 39 | + @Param("contractDocs") String contractDocs, | |
| 40 | + @Param("judgedKoglType") String judgedKoglType, | |
| 41 | + @Param("judgedAiType") String judgedAiType, | |
| 42 | + @Param("finalOpinion") String finalOpinion, | |
| 43 | + @Param("judgmentBasis") String judgmentBasis, | |
| 44 | + @Param("processStatus") String processStatus); | |
| 45 | + | |
| 46 | + int deleteById(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 47 | + | |
| 48 | + /** 기관 삭제 캐스케이드 외에는 아직 쓰이지 않지만, 저장 비용이 낮아 함께 둔다(review_item과 동일 관례). */ | |
| 49 | + int deleteByOrg(@Param("orgId") Long orgId); | |
| 50 | + | |
| 51 | + /** 검색어/상태 필터와 무관한 기관 전체 진행률 - 목록 헤더와 기관 상세 통계 카드가 함께 쓴다. */ | |
| 52 | + ProcessStats stats(@Param("orgId") Long orgId); | |
| 53 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessNotFoundException.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 존재하지 않는 권리처리 게시물 id로 조회/수정/삭제를 시도했을 때 던진다. | |
| 5 | + * URL 경로의 id가 단순히 틀린 것뿐이므로 GlobalExceptionHandler가 404로 변환한다. | |
| 6 | + */ | |
| 7 | +public class ProcessNotFoundException extends RuntimeException { | |
| 8 | + public ProcessNotFoundException(String message) { | |
| 9 | + super(message); | |
| 10 | + } | |
| 11 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessPage.java
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 권리처리 탭 목록 응답. {@code items}는 keyword/status 필터가 적용된 현재 페이지 결과지만, | |
| 7 | + * {@code total}/{@code done}은 검색어·상태 필터와 무관한 기관 전체 통계다(헤더의 | |
| 8 | + * "총 n건 · 처리완료 m건"과 기관 상세 통계 카드가 항상 같은 숫자를 보도록 하기 위함 - | |
| 9 | + * {@link ProcessStats} 참고). | |
| 10 | + */ | |
| 11 | +public record ProcessPage(List<ProcessItem> items, int total, int done, int page, int size) { | |
| 12 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessParser.java
... | ... | @@ -0,0 +1,163 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.seed.SeedParseException; | |
| 4 | +import org.apache.poi.ss.usermodel.Cell; | |
| 5 | +import org.apache.poi.ss.usermodel.CellType; | |
| 6 | +import org.apache.poi.ss.usermodel.DateUtil; | |
| 7 | +import org.apache.poi.ss.usermodel.Row; | |
| 8 | +import org.apache.poi.ss.usermodel.Sheet; | |
| 9 | +import org.apache.poi.ss.usermodel.Workbook; | |
| 10 | +import org.apache.poi.ss.usermodel.WorkbookFactory; | |
| 11 | +import org.springframework.stereotype.Component; | |
| 12 | + | |
| 13 | +import java.io.IOException; | |
| 14 | +import java.io.InputStream; | |
| 15 | +import java.time.format.DateTimeFormatter; | |
| 16 | +import java.util.ArrayList; | |
| 17 | +import java.util.List; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * 기관별 권리처리 엑셀(시트명 {@code 권리처리}, 없으면 첫 시트로 대체)을 읽는다. | |
| 21 | + * | |
| 22 | + * 시트 배치 (1행 그룹제목, 2행 헤더, 3행부터 데이터, A열(순번)이 빈 첫 행에서 데이터 끝): | |
| 23 | + * A 순번 B 사이트명 C 범주 D 게시판명 E 게시물제목 F URL주소 G 설명 | |
| 24 | + * H 첨부파일여부 I 기존 공공누리유형(+AI) J 계약서 유무 K 제작일 L 공표일 | |
| 25 | + * M 권리확인(대분류) N 권리확인(세부) O 처리 구분 P 공공누리 유형 Q AI유형 | |
| 26 | + * R 의견 S 비고 T 기존 증빙자료 U (수정 링크 열, 건너뜀) | |
| 27 | + * V 공공누리유형(판정) W AI유형(판정) X 최종의견 Y (빈 열, 건너뜀) | |
| 28 | + * Z 판단근거 AA 처리상태 | |
| 29 | + * | |
| 30 | + * ReviewParser와 마찬가지로 lastRowNum까지 돌되 A열이 비면 즉시 멈춘다. | |
| 31 | + * | |
| 32 | + * 제작일/공표일(K, L열)은 실제 날짜서식이 입혀진 값이라 {@code yyyy-MM-dd} 문자열로 | |
| 33 | + * 정규화한다({@link DateUtil#isCellDateFormatted}로 판별). 그 외 숫자 셀은 ReviewParser와 | |
| 34 | + * 동일하게 반올림 문자열로 바꾼다. 수식 오류(#VALUE! 등) 셀은 캐시된 결과 타입이 ERROR가 | |
| 35 | + * 되어 default 분기(빈 문자열 → null)로 처리된다. | |
| 36 | + */ | |
| 37 | +@Component | |
| 38 | +public class ProcessParser { | |
| 39 | + | |
| 40 | + public static final String PRIMARY_SHEET_NAME = "권리처리"; | |
| 41 | + | |
| 42 | + private static final int DATA_START_ROW_INDEX = 2; // 3행 | |
| 43 | + | |
| 44 | + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); | |
| 45 | + | |
| 46 | + private static final int COL_SEQ = 0; // A | |
| 47 | + private static final int COL_SITE_NAME = 1; // B | |
| 48 | + private static final int COL_CATEGORY = 2; // C | |
| 49 | + private static final int COL_BOARD_NAME = 3; // D | |
| 50 | + private static final int COL_POST_TITLE = 4; // E | |
| 51 | + private static final int COL_URL = 5; // F | |
| 52 | + private static final int COL_DESCRIPTION = 6; // G | |
| 53 | + private static final int COL_HAS_ATTACHMENT = 7; // H | |
| 54 | + private static final int COL_PRIOR_KOGL_TYPE = 8; // I | |
| 55 | + private static final int COL_CONTRACT_DOCS = 9; // J | |
| 56 | + private static final int COL_PRODUCED_DATE = 10; // K | |
| 57 | + private static final int COL_PUBLISHED_DATE = 11; // L | |
| 58 | + private static final int COL_REVIEW_MAJOR = 12; // M | |
| 59 | + private static final int COL_REVIEW_MINOR = 13; // N | |
| 60 | + private static final int COL_REVIEW_RESULT = 14; // O | |
| 61 | + private static final int COL_REVIEW_KOGL_TYPE = 15; // P | |
| 62 | + private static final int COL_REVIEW_AI_TYPE = 16; // Q | |
| 63 | + private static final int COL_REVIEW_OPINION = 17; // R | |
| 64 | + private static final int COL_REVIEW_NOTE = 18; // S | |
| 65 | + private static final int COL_PRIOR_EVIDENCE = 19; // T | |
| 66 | + // 20 = U, 수정 링크 열, 건너뜀 | |
| 67 | + private static final int COL_JUDGED_KOGL_TYPE = 21; // V | |
| 68 | + private static final int COL_JUDGED_AI_TYPE = 22; // W | |
| 69 | + private static final int COL_FINAL_OPINION = 23; // X | |
| 70 | + // 24 = Y, 빈 열, 건너뜀 | |
| 71 | + private static final int COL_JUDGMENT_BASIS = 25; // Z | |
| 72 | + private static final int COL_PROCESS_STATUS = 26; // AA | |
| 73 | + | |
| 74 | + public List<ProcessRow> parse(InputStream xlsx) { | |
| 75 | + try (Workbook wb = WorkbookFactory.create(xlsx)) { | |
| 76 | + Sheet sheet = wb.getSheet(PRIMARY_SHEET_NAME); | |
| 77 | + if (sheet == null && wb.getNumberOfSheets() > 0) { | |
| 78 | + sheet = wb.getSheetAt(0); | |
| 79 | + } | |
| 80 | + if (sheet == null) { | |
| 81 | + throw new SeedParseException("통합문서에 읽을 수 있는 시트가 없습니다."); | |
| 82 | + } | |
| 83 | + return readRows(sheet); | |
| 84 | + } catch (IOException e) { | |
| 85 | + throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e); | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + private List<ProcessRow> readRows(Sheet sheet) { | |
| 90 | + List<ProcessRow> result = new ArrayList<>(); | |
| 91 | + | |
| 92 | + for (int i = DATA_START_ROW_INDEX; i <= sheet.getLastRowNum(); i++) { | |
| 93 | + Row row = sheet.getRow(i); | |
| 94 | + if (row == null) { | |
| 95 | + break; // 데이터 영역은 연속적이다 - 빈 행이 나오면 그게 끝이다 | |
| 96 | + } | |
| 97 | + | |
| 98 | + String seqRaw = string(row, COL_SEQ); | |
| 99 | + if (seqRaw.isBlank()) { | |
| 100 | + break; | |
| 101 | + } | |
| 102 | + int seq; | |
| 103 | + try { | |
| 104 | + seq = Integer.parseInt(seqRaw.trim()); | |
| 105 | + } catch (NumberFormatException e) { | |
| 106 | + break; | |
| 107 | + } | |
| 108 | + | |
| 109 | + result.add(new ProcessRow( | |
| 110 | + seq, | |
| 111 | + nullIfBlank(string(row, COL_SITE_NAME)), | |
| 112 | + nullIfBlank(string(row, COL_CATEGORY)), | |
| 113 | + nullIfBlank(string(row, COL_BOARD_NAME)), | |
| 114 | + nullIfBlank(string(row, COL_POST_TITLE)), | |
| 115 | + nullIfBlank(string(row, COL_URL)), | |
| 116 | + nullIfBlank(string(row, COL_DESCRIPTION)), | |
| 117 | + nullIfBlank(string(row, COL_HAS_ATTACHMENT)), | |
| 118 | + nullIfBlank(string(row, COL_PRIOR_KOGL_TYPE)), | |
| 119 | + nullIfBlank(string(row, COL_CONTRACT_DOCS)), | |
| 120 | + nullIfBlank(string(row, COL_PRODUCED_DATE)), | |
| 121 | + nullIfBlank(string(row, COL_PUBLISHED_DATE)), | |
| 122 | + nullIfBlank(string(row, COL_REVIEW_MAJOR)), | |
| 123 | + nullIfBlank(string(row, COL_REVIEW_MINOR)), | |
| 124 | + nullIfBlank(string(row, COL_REVIEW_RESULT)), | |
| 125 | + nullIfBlank(string(row, COL_REVIEW_KOGL_TYPE)), | |
| 126 | + nullIfBlank(string(row, COL_REVIEW_AI_TYPE)), | |
| 127 | + nullIfBlank(string(row, COL_REVIEW_OPINION)), | |
| 128 | + nullIfBlank(string(row, COL_REVIEW_NOTE)), | |
| 129 | + nullIfBlank(string(row, COL_PRIOR_EVIDENCE)), | |
| 130 | + nullIfBlank(string(row, COL_JUDGED_KOGL_TYPE)), | |
| 131 | + nullIfBlank(string(row, COL_JUDGED_AI_TYPE)), | |
| 132 | + nullIfBlank(string(row, COL_FINAL_OPINION)), | |
| 133 | + nullIfBlank(string(row, COL_JUDGMENT_BASIS)), | |
| 134 | + nullIfBlank(string(row, COL_PROCESS_STATUS)))); | |
| 135 | + } | |
| 136 | + return result; | |
| 137 | + } | |
| 138 | + | |
| 139 | + private String string(Row row, int columnIndex) { | |
| 140 | + Cell cell = row.getCell(columnIndex); | |
| 141 | + if (cell == null) { | |
| 142 | + return ""; | |
| 143 | + } | |
| 144 | + return cellValue(cell, cell.getCellType()); | |
| 145 | + } | |
| 146 | + | |
| 147 | + /** ReviewParser와 동일한 셀 값 변환 규칙에, 날짜서식 숫자 셀을 yyyy-MM-dd로 정규화하는 분기를 더한다. */ | |
| 148 | + private String cellValue(Cell cell, CellType cellType) { | |
| 149 | + return switch (cellType) { | |
| 150 | + case STRING -> cell.getStringCellValue().trim(); | |
| 151 | + case NUMERIC -> DateUtil.isCellDateFormatted(cell) | |
| 152 | + ? cell.getLocalDateTimeCellValue().format(DATE_FORMAT) | |
| 153 | + : String.valueOf(Math.round(cell.getNumericCellValue())); | |
| 154 | + case BOOLEAN -> String.valueOf(cell.getBooleanCellValue()); | |
| 155 | + case FORMULA -> cellValue(cell, cell.getCachedFormulaResultType()); | |
| 156 | + default -> ""; | |
| 157 | + }; | |
| 158 | + } | |
| 159 | + | |
| 160 | + private String nullIfBlank(String value) { | |
| 161 | + return (value == null || value.isBlank()) ? null : value; | |
| 162 | + } | |
| 163 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessRow.java
... | ... | @@ -0,0 +1,33 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * {@link ProcessParser}가 엑셀 한 행에서 읽어낸 원시 값. U열(수정 링크)과 Y열(빈 열)은 | |
| 5 | + * 원본 시트에서 건너뛰는 열이라 대응 필드가 없다. | |
| 6 | + */ | |
| 7 | +public record ProcessRow( | |
| 8 | + int seq, | |
| 9 | + String siteName, | |
| 10 | + String category, | |
| 11 | + String boardName, | |
| 12 | + String postTitle, | |
| 13 | + String url, | |
| 14 | + String description, | |
| 15 | + String hasAttachment, | |
| 16 | + String priorKoglType, | |
| 17 | + String contractDocs, | |
| 18 | + String producedDate, | |
| 19 | + String publishedDate, | |
| 20 | + String reviewMajor, | |
| 21 | + String reviewMinor, | |
| 22 | + String reviewResult, | |
| 23 | + String reviewKoglType, | |
| 24 | + String reviewAiType, | |
| 25 | + String reviewOpinion, | |
| 26 | + String reviewNote, | |
| 27 | + String priorEvidence, | |
| 28 | + String judgedKoglType, | |
| 29 | + String judgedAiType, | |
| 30 | + String finalOpinion, | |
| 31 | + String judgmentBasis, | |
| 32 | + String processStatus) { | |
| 33 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessService.java
... | ... | @@ -0,0 +1,176 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 4 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 5 | +import org.apache.poi.ss.usermodel.Cell; | |
| 6 | +import org.apache.poi.ss.usermodel.Row; | |
| 7 | +import org.apache.poi.ss.usermodel.Sheet; | |
| 8 | +import org.apache.poi.xssf.usermodel.XSSFWorkbook; | |
| 9 | +import org.springframework.stereotype.Service; | |
| 10 | +import org.springframework.transaction.annotation.Transactional; | |
| 11 | + | |
| 12 | +import java.io.ByteArrayOutputStream; | |
| 13 | +import java.io.IOException; | |
| 14 | +import java.io.UncheckedIOException; | |
| 15 | +import java.util.List; | |
| 16 | +import java.util.Set; | |
| 17 | + | |
| 18 | +/** 기관관리 상세의 권리처리 탭이 쓰는 목록/상세/수정/삭제/다운로드 서비스. */ | |
| 19 | +@Service | |
| 20 | +public class ProcessService { | |
| 21 | + | |
| 22 | + private static final Set<String> VALID_PROCESS_STATUSES = Set.of("미처리", "처리완료"); | |
| 23 | + | |
| 24 | + /** | |
| 25 | + * 1행 그룹 제목(A=게시물 정보(조사원), M=권리확인(변호사), U=권리처리(변호사)) + 2행 컬럼 헤더. | |
| 26 | + * U열(수정 링크)과 Y열은 원본 시트의 건너뛰는 열이라 빈 문자열로 남겨둔다. | |
| 27 | + */ | |
| 28 | + private static final String[] COLUMN_HEADERS = { | |
| 29 | + "순번", "사이트명", "범주", "게시판명", "게시물제목", "URL주소", "설명", | |
| 30 | + "첨부파일여부", "기존 공공누리유형(+AI)", "계약서 유무", "제작일", "공표일", | |
| 31 | + "권리확인(대분류)", "권리확인(세부)", "처리 구분", "공공누리 유형", "AI유형", "의견", "비고", "기존 증빙자료", | |
| 32 | + "", "공공누리유형(판정)", "AI유형(판정)", "최종의견", "", "판단근거", "처리상태" | |
| 33 | + }; | |
| 34 | + | |
| 35 | + private final ProcessItemMapper mapper; | |
| 36 | + private final OrganizationMapper orgMapper; | |
| 37 | + | |
| 38 | + public ProcessService(ProcessItemMapper mapper, OrganizationMapper orgMapper) { | |
| 39 | + this.mapper = mapper; | |
| 40 | + this.orgMapper = orgMapper; | |
| 41 | + } | |
| 42 | + | |
| 43 | + public ProcessPage list(Long orgId, String keyword, String status, int page, int size) { | |
| 44 | + requireOrg(orgId); | |
| 45 | + String kw = normalizeKeyword(keyword); | |
| 46 | + String st = normalizeStatus(status); | |
| 47 | + int safePage = Math.max(page, 0); | |
| 48 | + int safeSize = size <= 0 ? 30 : size; | |
| 49 | + int offset = safePage * safeSize; | |
| 50 | + | |
| 51 | + List<ProcessItem> items = mapper.findPage(orgId, kw, st, offset, safeSize); | |
| 52 | + // total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다(헤더/통계 카드용 - ProcessPage 참고). | |
| 53 | + ProcessStats stats = mapper.stats(orgId); | |
| 54 | + return new ProcessPage(items, stats.total(), stats.done(), safePage, safeSize); | |
| 55 | + } | |
| 56 | + | |
| 57 | + public ProcessItem get(Long orgId, Long itemId) { | |
| 58 | + requireOrg(orgId); | |
| 59 | + return requireItem(orgId, itemId); | |
| 60 | + } | |
| 61 | + | |
| 62 | + @Transactional | |
| 63 | + public ProcessItem updateProcessing(Long orgId, Long itemId, ProcessingRequest request) { | |
| 64 | + requireOrg(orgId); | |
| 65 | + validateProcessStatus(request.processStatus()); | |
| 66 | + int updated = mapper.updateProcessing(orgId, itemId, | |
| 67 | + request.contractDocs(), request.judgedKoglType(), request.judgedAiType(), | |
| 68 | + request.finalOpinion(), request.judgmentBasis(), request.processStatus()); | |
| 69 | + if (updated == 0) { | |
| 70 | + throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId); | |
| 71 | + } | |
| 72 | + return mapper.findById(orgId, itemId); | |
| 73 | + } | |
| 74 | + | |
| 75 | + @Transactional | |
| 76 | + public void delete(Long orgId, Long itemId) { | |
| 77 | + requireOrg(orgId); | |
| 78 | + int deleted = mapper.deleteById(orgId, itemId); | |
| 79 | + if (deleted == 0) { | |
| 80 | + throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId); | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + /** 원본 파일과 같은 헤더 배치(1행 그룹제목, 2행 컬럼헤더, 3행부터 데이터)로 xlsx를 만든다. */ | |
| 85 | + public byte[] downloadWorkbook(Long orgId) { | |
| 86 | + requireOrg(orgId); | |
| 87 | + List<ProcessItem> items = mapper.findAllByOrg(orgId); | |
| 88 | + | |
| 89 | + try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 90 | + Sheet sheet = wb.createSheet(ProcessParser.PRIMARY_SHEET_NAME); | |
| 91 | + | |
| 92 | + Row groupRow = sheet.createRow(0); | |
| 93 | + groupRow.createCell(0).setCellValue("게시물 정보(조사원)"); | |
| 94 | + groupRow.createCell(12).setCellValue("권리확인(변호사)"); | |
| 95 | + groupRow.createCell(20).setCellValue("권리처리(변호사)"); | |
| 96 | + | |
| 97 | + Row headerRow = sheet.createRow(1); | |
| 98 | + for (int i = 0; i < COLUMN_HEADERS.length; i++) { | |
| 99 | + headerRow.createCell(i).setCellValue(COLUMN_HEADERS[i]); | |
| 100 | + } | |
| 101 | + | |
| 102 | + int rowNum = 2; | |
| 103 | + for (ProcessItem item : items) { | |
| 104 | + Row row = sheet.createRow(rowNum++); | |
| 105 | + setCell(row, 0, String.valueOf(item.getSeq())); | |
| 106 | + setCell(row, 1, item.getSiteName()); | |
| 107 | + setCell(row, 2, item.getCategory()); | |
| 108 | + setCell(row, 3, item.getBoardName()); | |
| 109 | + setCell(row, 4, item.getPostTitle()); | |
| 110 | + setCell(row, 5, item.getUrl()); | |
| 111 | + setCell(row, 6, item.getDescription()); | |
| 112 | + setCell(row, 7, item.getHasAttachment()); | |
| 113 | + setCell(row, 8, item.getPriorKoglType()); | |
| 114 | + setCell(row, 9, item.getContractDocs()); | |
| 115 | + setCell(row, 10, item.getProducedDate()); | |
| 116 | + setCell(row, 11, item.getPublishedDate()); | |
| 117 | + setCell(row, 12, item.getReviewMajor()); | |
| 118 | + setCell(row, 13, item.getReviewMinor()); | |
| 119 | + setCell(row, 14, item.getReviewResult()); | |
| 120 | + setCell(row, 15, item.getReviewKoglType()); | |
| 121 | + setCell(row, 16, item.getReviewAiType()); | |
| 122 | + setCell(row, 17, item.getReviewOpinion()); | |
| 123 | + setCell(row, 18, item.getReviewNote()); | |
| 124 | + setCell(row, 19, item.getPriorEvidence()); | |
| 125 | + // 20 = U, 수정 링크 열, 건너뜀 | |
| 126 | + setCell(row, 21, item.getJudgedKoglType()); | |
| 127 | + setCell(row, 22, item.getJudgedAiType()); | |
| 128 | + setCell(row, 23, item.getFinalOpinion()); | |
| 129 | + // 24 = Y, 빈 열 | |
| 130 | + setCell(row, 25, item.getJudgmentBasis()); | |
| 131 | + setCell(row, 26, item.getProcessStatus()); | |
| 132 | + } | |
| 133 | + | |
| 134 | + wb.write(out); | |
| 135 | + return out.toByteArray(); | |
| 136 | + } catch (IOException e) { | |
| 137 | + throw new UncheckedIOException("권리처리 엑셀을 만들지 못했습니다.", e); | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + private void setCell(Row row, int columnIndex, String value) { | |
| 142 | + if (value == null) { | |
| 143 | + return; | |
| 144 | + } | |
| 145 | + Cell cell = row.createCell(columnIndex); | |
| 146 | + cell.setCellValue(value); | |
| 147 | + } | |
| 148 | + | |
| 149 | + private void validateProcessStatus(String processStatus) { | |
| 150 | + if (processStatus != null && !VALID_PROCESS_STATUSES.contains(processStatus)) { | |
| 151 | + throw new InvalidProcessStatusException("처리상태는 미처리 또는 처리완료여야 합니다."); | |
| 152 | + } | |
| 153 | + } | |
| 154 | + | |
| 155 | + private String normalizeKeyword(String keyword) { | |
| 156 | + return (keyword == null || keyword.isBlank()) ? null : keyword.trim(); | |
| 157 | + } | |
| 158 | + | |
| 159 | + private String normalizeStatus(String status) { | |
| 160 | + return (status == null || status.isBlank()) ? null : status.trim(); | |
| 161 | + } | |
| 162 | + | |
| 163 | + private void requireOrg(Long orgId) { | |
| 164 | + if (orgMapper.findById(orgId) == null) { | |
| 165 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + private ProcessItem requireItem(Long orgId, Long itemId) { | |
| 170 | + ProcessItem item = mapper.findById(orgId, itemId); | |
| 171 | + if (item == null) { | |
| 172 | + throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId); | |
| 173 | + } | |
| 174 | + return item; | |
| 175 | + } | |
| 176 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessStats.java
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +/** 기관 전체(검색어/상태 필터와 무관) 권리처리 진행률. 목록 헤더와 기관 상세 통계 카드가 함께 쓴다. */ | |
| 4 | +public record ProcessStats(int total, int done) { | |
| 5 | +} |
+++ src/main/java/kr/itn/itnhub/process/ProcessingRequest.java
... | ... | @@ -0,0 +1,15 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 권리처리 상세의 [처리등록]/[수정] 저장 요청. 전부 선택값이며, 저장 시 web_edited_at이 | |
| 5 | + * 채워진다. processStatus는 null이 아니면 "미처리"/"처리완료" 둘 중 하나여야 한다 - | |
| 6 | + * {@link ProcessService}가 그 외 값을 거부한다. | |
| 7 | + */ | |
| 8 | +public record ProcessingRequest( | |
| 9 | + String contractDocs, | |
| 10 | + String judgedKoglType, | |
| 11 | + String judgedAiType, | |
| 12 | + String finalOpinion, | |
| 13 | + String judgmentBasis, | |
| 14 | + String processStatus) { | |
| 15 | +} |
+++ src/main/resources/db/migration/V10__process_item.sql
... | ... | @@ -0,0 +1,35 @@ |
| 1 | +create table process_item ( | |
| 2 | + id bigserial primary key, | |
| 3 | + org_id bigint not null references organization (id) on delete cascade, | |
| 4 | + seq integer not null, | |
| 5 | + site_name varchar(300), | |
| 6 | + category varchar(500), | |
| 7 | + board_name varchar(300), | |
| 8 | + post_title varchar(1000), | |
| 9 | + url varchar(1000), | |
| 10 | + description varchar(1000), | |
| 11 | + has_attachment varchar(50), | |
| 12 | + prior_kogl_type varchar(100), -- 기존 공공누리유형(+AI) | |
| 13 | + contract_docs varchar(500), -- 계약서 유무 (웹에선 체크박스 조합, 쉼표 연결 저장) | |
| 14 | + produced_date varchar(20), | |
| 15 | + published_date varchar(20), | |
| 16 | + review_major varchar(200), | |
| 17 | + review_minor varchar(300), | |
| 18 | + review_result varchar(200), -- 처리 구분 | |
| 19 | + review_kogl_type varchar(50), | |
| 20 | + review_ai_type varchar(50), | |
| 21 | + review_opinion varchar(2000), | |
| 22 | + review_note varchar(1000), | |
| 23 | + prior_evidence varchar(1000), -- 기존 증빙자료 | |
| 24 | + judged_kogl_type varchar(50), | |
| 25 | + judged_ai_type varchar(50), | |
| 26 | + final_opinion varchar(3000), | |
| 27 | + judgment_basis varchar(3000), -- 판단근거 | |
| 28 | + process_status varchar(50), -- 처리상태 (미처리/처리완료) | |
| 29 | + processed_at timestamptz, -- 처리완료로 저장된 시각 | |
| 30 | + web_edited_at timestamptz, | |
| 31 | + created_at timestamptz not null default now(), | |
| 32 | + updated_at timestamptz not null default now(), | |
| 33 | + constraint uq_process_item unique (org_id, seq) | |
| 34 | +); | |
| 35 | +create index ix_process_item_org on process_item (org_id, seq); |
+++ src/main/resources/mapper/ProcessItemMapper.xml
... | ... | @@ -0,0 +1,168 @@ |
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 3 | + "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 4 | +<mapper namespace="kr.itn.itnhub.process.ProcessItemMapper"> | |
| 5 | + | |
| 6 | + <!-- | |
| 7 | + processed_at/web_edited_at/created_at/updated_at을 밀리초 epoch로 내려준다 | |
| 8 | + (review_item과 동일 관례). null이면 extract(epoch from null)도 null이라 그대로 null이 내려간다. | |
| 9 | + --> | |
| 10 | + <sql id="columns"> | |
| 11 | + id, org_id, seq, site_name, category, board_name, post_title, url, description, | |
| 12 | + has_attachment, prior_kogl_type, contract_docs, produced_date, published_date, | |
| 13 | + review_major, review_minor, review_result, review_kogl_type, review_ai_type, | |
| 14 | + review_opinion, review_note, prior_evidence, | |
| 15 | + judged_kogl_type, judged_ai_type, final_opinion, judgment_basis, process_status, | |
| 16 | + (extract(epoch from processed_at) * 1000)::bigint as processed_at, | |
| 17 | + (extract(epoch from web_edited_at) * 1000)::bigint as web_edited_at, | |
| 18 | + (extract(epoch from created_at) * 1000)::bigint as created_at, | |
| 19 | + (extract(epoch from updated_at) * 1000)::bigint as updated_at | |
| 20 | + </sql> | |
| 21 | + | |
| 22 | + <sql id="keywordFilter"> | |
| 23 | + <if test="keyword != null"> | |
| 24 | + and (site_name ilike concat('%', #{keyword}, '%') | |
| 25 | + or post_title ilike concat('%', #{keyword}, '%')) | |
| 26 | + </if> | |
| 27 | + </sql> | |
| 28 | + | |
| 29 | + <!-- status: null=전체, "DONE"=처리완료, "PENDING"=그 외(null/빈/미처리 등 처리완료가 아닌 전부). --> | |
| 30 | + <sql id="statusFilter"> | |
| 31 | + <if test="status != null and status == 'DONE'"> | |
| 32 | + and process_status = '처리완료' | |
| 33 | + </if> | |
| 34 | + <if test="status != null and status == 'PENDING'"> | |
| 35 | + and (process_status is null or process_status = '' or process_status <> '처리완료') | |
| 36 | + </if> | |
| 37 | + </sql> | |
| 38 | + | |
| 39 | + <select id="findPage" resultType="kr.itn.itnhub.process.ProcessItem"> | |
| 40 | + select <include refid="columns"/> | |
| 41 | + from process_item | |
| 42 | + where org_id = #{orgId} | |
| 43 | + <include refid="keywordFilter"/> | |
| 44 | + <include refid="statusFilter"/> | |
| 45 | + order by seq asc | |
| 46 | + limit #{limit} offset #{offset} | |
| 47 | + </select> | |
| 48 | + | |
| 49 | + <select id="countByOrg" resultType="int"> | |
| 50 | + select count(*) | |
| 51 | + from process_item | |
| 52 | + where org_id = #{orgId} | |
| 53 | + <include refid="keywordFilter"/> | |
| 54 | + <include refid="statusFilter"/> | |
| 55 | + </select> | |
| 56 | + | |
| 57 | + <select id="findAllByOrg" resultType="kr.itn.itnhub.process.ProcessItem"> | |
| 58 | + select <include refid="columns"/> | |
| 59 | + from process_item | |
| 60 | + where org_id = #{orgId} | |
| 61 | + order by seq asc | |
| 62 | + </select> | |
| 63 | + | |
| 64 | + <select id="findSeqsByOrg" resultType="int"> | |
| 65 | + select seq from process_item where org_id = #{orgId} | |
| 66 | + </select> | |
| 67 | + | |
| 68 | + <select id="findById" resultType="kr.itn.itnhub.process.ProcessItem"> | |
| 69 | + select <include refid="columns"/> | |
| 70 | + from process_item | |
| 71 | + where org_id = #{orgId} and id = #{id} | |
| 72 | + </select> | |
| 73 | + | |
| 74 | + <!-- | |
| 75 | + 정책(b): 조사원+권리확인 영역(site_name..prior_evidence, 계약서 유무 제외)은 재업로드마다 | |
| 76 | + 항상 최신 파일값으로 덮어쓴다. 계약서 유무(contract_docs)는 웹에서 입력하는 값이라 | |
| 77 | + web_edited_at이 null일 때만(=웹에서 아직 아무도 손대지 않았을 때만) 파일값을 반영한다. | |
| 78 | + 권리처리 영역(judged_kogl_type..process_status)도 마찬가지로 web_edited_at이 null일 | |
| 79 | + 때만 파일값을 반영한다 - 이미 웹에서 처리 결과를 입력한 행은 재업로드가 절대 덮어쓰지 | |
| 80 | + 않는다. processed_at/web_edited_at 자체는 이 upsert가 건드리지 않는다. | |
| 81 | + --> | |
| 82 | + <insert id="upsertFromFile" parameterType="kr.itn.itnhub.process.ProcessItem"> | |
| 83 | + insert into process_item ( | |
| 84 | + org_id, seq, site_name, category, board_name, post_title, url, description, | |
| 85 | + has_attachment, prior_kogl_type, contract_docs, produced_date, published_date, | |
| 86 | + review_major, review_minor, review_result, review_kogl_type, review_ai_type, | |
| 87 | + review_opinion, review_note, prior_evidence, | |
| 88 | + judged_kogl_type, judged_ai_type, final_opinion, judgment_basis, process_status | |
| 89 | + ) values ( | |
| 90 | + #{orgId}, #{seq}, #{siteName}, #{category}, #{boardName}, #{postTitle}, #{url}, #{description}, | |
| 91 | + #{hasAttachment}, #{priorKoglType}, #{contractDocs}, #{producedDate}, #{publishedDate}, | |
| 92 | + #{reviewMajor}, #{reviewMinor}, #{reviewResult}, #{reviewKoglType}, #{reviewAiType}, | |
| 93 | + #{reviewOpinion}, #{reviewNote}, #{priorEvidence}, | |
| 94 | + #{judgedKoglType}, #{judgedAiType}, #{finalOpinion}, #{judgmentBasis}, #{processStatus} | |
| 95 | + ) | |
| 96 | + on conflict (org_id, seq) do update set | |
| 97 | + site_name = excluded.site_name, | |
| 98 | + category = excluded.category, | |
| 99 | + board_name = excluded.board_name, | |
| 100 | + post_title = excluded.post_title, | |
| 101 | + url = excluded.url, | |
| 102 | + description = excluded.description, | |
| 103 | + has_attachment = excluded.has_attachment, | |
| 104 | + prior_kogl_type = excluded.prior_kogl_type, | |
| 105 | + produced_date = excluded.produced_date, | |
| 106 | + published_date = excluded.published_date, | |
| 107 | + review_major = excluded.review_major, | |
| 108 | + review_minor = excluded.review_minor, | |
| 109 | + review_result = excluded.review_result, | |
| 110 | + review_kogl_type = excluded.review_kogl_type, | |
| 111 | + review_ai_type = excluded.review_ai_type, | |
| 112 | + review_opinion = excluded.review_opinion, | |
| 113 | + review_note = excluded.review_note, | |
| 114 | + prior_evidence = excluded.prior_evidence, | |
| 115 | + contract_docs = case when process_item.web_edited_at is null then excluded.contract_docs else process_item.contract_docs end, | |
| 116 | + judged_kogl_type = case when process_item.web_edited_at is null then excluded.judged_kogl_type else process_item.judged_kogl_type end, | |
| 117 | + judged_ai_type = case when process_item.web_edited_at is null then excluded.judged_ai_type else process_item.judged_ai_type end, | |
| 118 | + final_opinion = case when process_item.web_edited_at is null then excluded.final_opinion else process_item.final_opinion end, | |
| 119 | + judgment_basis = case when process_item.web_edited_at is null then excluded.judgment_basis else process_item.judgment_basis end, | |
| 120 | + process_status = case when process_item.web_edited_at is null then excluded.process_status else process_item.process_status end, | |
| 121 | + updated_at = now() | |
| 122 | + </insert> | |
| 123 | + | |
| 124 | + <!-- | |
| 125 | + processed_at은 "처리완료"에 처음 도달한 시각만 남긴다: 이미 값이 있으면(먼저 처리완료로 | |
| 126 | + 저장된 적이 있으면) 이번 저장이 다시 처리완료든 미처리로 되돌리든 손대지 않는다. | |
| 127 | + --> | |
| 128 | + <update id="updateProcessing"> | |
| 129 | + update process_item set | |
| 130 | + contract_docs = #{contractDocs}, | |
| 131 | + judged_kogl_type = #{judgedKoglType}, | |
| 132 | + judged_ai_type = #{judgedAiType}, | |
| 133 | + final_opinion = #{finalOpinion}, | |
| 134 | + judgment_basis = #{judgmentBasis}, | |
| 135 | + process_status = #{processStatus}, | |
| 136 | + web_edited_at = now(), | |
| 137 | + processed_at = case | |
| 138 | + when #{processStatus} = '처리완료' and process_item.processed_at is null then now() | |
| 139 | + else process_item.processed_at | |
| 140 | + end, | |
| 141 | + updated_at = now() | |
| 142 | + where org_id = #{orgId} and id = #{id} | |
| 143 | + </update> | |
| 144 | + | |
| 145 | + <delete id="deleteById"> | |
| 146 | + delete from process_item where org_id = #{orgId} and id = #{id} | |
| 147 | + </delete> | |
| 148 | + | |
| 149 | + <delete id="deleteByOrg"> | |
| 150 | + delete from process_item where org_id = #{orgId} | |
| 151 | + </delete> | |
| 152 | + | |
| 153 | + <resultMap id="statsResultMap" type="kr.itn.itnhub.process.ProcessStats"> | |
| 154 | + <constructor> | |
| 155 | + <arg column="total" javaType="_int"/> | |
| 156 | + <arg column="done" javaType="_int"/> | |
| 157 | + </constructor> | |
| 158 | + </resultMap> | |
| 159 | + | |
| 160 | + <select id="stats" resultMap="statsResultMap"> | |
| 161 | + select | |
| 162 | + count(*) as total, | |
| 163 | + count(*) filter (where process_status = '처리완료') as done | |
| 164 | + from process_item | |
| 165 | + where org_id = #{orgId} | |
| 166 | + </select> | |
| 167 | + | |
| 168 | +</mapper> |
+++ src/test/java/kr/itn/itnhub/process/ProcessControllerTest.java
... | ... | @@ -0,0 +1,383 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.org.Organization; | |
| 5 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 6 | +import org.apache.poi.ss.usermodel.Cell; | |
| 7 | +import org.apache.poi.ss.usermodel.CellStyle; | |
| 8 | +import org.apache.poi.ss.usermodel.DataFormat; | |
| 9 | +import org.apache.poi.ss.usermodel.Row; | |
| 10 | +import org.apache.poi.ss.usermodel.Sheet; | |
| 11 | +import org.apache.poi.xssf.usermodel.XSSFWorkbook; | |
| 12 | +import org.junit.jupiter.api.BeforeEach; | |
| 13 | +import org.junit.jupiter.api.Test; | |
| 14 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 15 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 16 | +import org.springframework.http.MediaType; | |
| 17 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 18 | +import org.springframework.mock.web.MockMultipartFile; | |
| 19 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 20 | +import org.springframework.test.web.servlet.MockMvc; | |
| 21 | + | |
| 22 | +import java.io.ByteArrayOutputStream; | |
| 23 | +import java.time.LocalDateTime; | |
| 24 | + | |
| 25 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 26 | +import static org.hamcrest.Matchers.containsString; | |
| 27 | +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; | |
| 28 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; | |
| 29 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 30 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; | |
| 31 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 32 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 33 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 34 | + | |
| 35 | +@AutoConfigureMockMvc | |
| 36 | +@WithMockUser(roles = "ADMIN") | |
| 37 | +class ProcessControllerTest extends AbstractDbTest { | |
| 38 | + | |
| 39 | + @Autowired | |
| 40 | + MockMvc mvc; | |
| 41 | + | |
| 42 | + @Autowired | |
| 43 | + OrganizationMapper orgMapper; | |
| 44 | + | |
| 45 | + @Autowired | |
| 46 | + ProcessItemMapper processMapper; | |
| 47 | + | |
| 48 | + @Autowired | |
| 49 | + JdbcTemplate jdbc; | |
| 50 | + | |
| 51 | + private Long orgId; | |
| 52 | + | |
| 53 | + @BeforeEach | |
| 54 | + void setUp() { | |
| 55 | + jdbc.update("delete from process_item"); | |
| 56 | + jdbc.update("delete from organization"); | |
| 57 | + | |
| 58 | + Organization org = new Organization(); | |
| 59 | + org.setOrgNo("001"); | |
| 60 | + org.setOrgName("한국출판문화산업진흥원"); | |
| 61 | + org.setChannelSlug("001"); | |
| 62 | + orgMapper.upsertBySeed(org); | |
| 63 | + | |
| 64 | + orgId = orgMapper.findAll().get(0).getId(); | |
| 65 | + } | |
| 66 | + | |
| 67 | + /** | |
| 68 | + * 필드 순서: seq,siteName,category,boardName,postTitle,url,description,hasAttachment, | |
| 69 | + * priorKoglType,contractDocs,producedDate,publishedDate,reviewMajor,reviewMinor,reviewResult, | |
| 70 | + * reviewKoglType,reviewAiType,reviewOpinion,reviewNote,priorEvidence, | |
| 71 | + * judgedKoglType,judgedAiType,finalOpinion,judgmentBasis,processStatus (25개, U/Y열은 건너뛴다). | |
| 72 | + */ | |
| 73 | + private byte[] workbook(String[]... dataRows) throws Exception { | |
| 74 | + try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 75 | + Sheet sheet = wb.createSheet(ProcessParser.PRIMARY_SHEET_NAME); | |
| 76 | + sheet.createRow(0); | |
| 77 | + sheet.createRow(1); | |
| 78 | + | |
| 79 | + int rowNum = 2; | |
| 80 | + for (String[] data : dataRows) { | |
| 81 | + Row row = sheet.createRow(rowNum++); | |
| 82 | + for (int i = 0; i < data.length; i++) { | |
| 83 | + if (data[i] == null) { | |
| 84 | + continue; | |
| 85 | + } | |
| 86 | + row.createCell(sheetColumn(i)).setCellValue(data[i]); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + wb.write(out); | |
| 90 | + return out.toByteArray(); | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** 논리 필드 인덱스(0..24) → 실제 시트 열(U=20, Y=24 건너뜀). */ | |
| 95 | + private int sheetColumn(int logicalIndex) { | |
| 96 | + int col = logicalIndex; | |
| 97 | + if (logicalIndex >= 20) { | |
| 98 | + col += 1; // U열 건너뜀 | |
| 99 | + } | |
| 100 | + if (logicalIndex >= 23) { | |
| 101 | + col += 1; // Y열 건너뜀 | |
| 102 | + } | |
| 103 | + return col; | |
| 104 | + } | |
| 105 | + | |
| 106 | + private MockMultipartFile file(byte[] bytes) { | |
| 107 | + return new MockMultipartFile("file", "process.xlsx", | |
| 108 | + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", bytes); | |
| 109 | + } | |
| 110 | + | |
| 111 | + private String[] row(String seq, String siteName, String category) { | |
| 112 | + String[] data = new String[25]; | |
| 113 | + data[0] = seq; | |
| 114 | + data[1] = siteName; | |
| 115 | + data[2] = category; | |
| 116 | + data[4] = "제목"; | |
| 117 | + data[5] = "http://example.com"; | |
| 118 | + return data; | |
| 119 | + } | |
| 120 | + | |
| 121 | + private String[] withProcessing(String[] base, String contractDocs, String judgedKoglType, | |
| 122 | + String finalOpinion, String processStatus) { | |
| 123 | + String[] copy = base.clone(); | |
| 124 | + copy[9] = contractDocs; | |
| 125 | + copy[20] = judgedKoglType; | |
| 126 | + copy[22] = finalOpinion; | |
| 127 | + copy[24] = processStatus; | |
| 128 | + return copy; | |
| 129 | + } | |
| 130 | + | |
| 131 | + @Test | |
| 132 | + void 최초_업로드는_전부_신규이고_제작일_공표일은_실제_날짜셀이어도_yyyyMMdd_문자열로_저장된다() throws Exception { | |
| 133 | + byte[] xlsx = dateCellWorkbook(); | |
| 134 | + | |
| 135 | + mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf())) | |
| 136 | + .andExpect(status().isOk()) | |
| 137 | + .andExpect(jsonPath("$.created").value(1)) | |
| 138 | + .andExpect(jsonPath("$.updated").value(0)) | |
| 139 | + .andExpect(jsonPath("$.total").value(1)); | |
| 140 | + | |
| 141 | + ProcessItem item = processMapper.findAllByOrg(orgId).get(0); | |
| 142 | + assertThat(item.getProducedDate()).isEqualTo("2025-01-03"); | |
| 143 | + assertThat(item.getSiteName()).isEqualTo("사이트"); | |
| 144 | + } | |
| 145 | + | |
| 146 | + /** 제작일(K열, index 10)에 실제 datetime 셀 값 2025-01-03T00:00:00을 심어 재현한다. */ | |
| 147 | + private byte[] dateCellWorkbook() throws Exception { | |
| 148 | + try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 149 | + Sheet sheet = wb.createSheet(ProcessParser.PRIMARY_SHEET_NAME); | |
| 150 | + sheet.createRow(0); | |
| 151 | + sheet.createRow(1); | |
| 152 | + Row row = sheet.createRow(2); | |
| 153 | + row.createCell(0).setCellValue("1"); | |
| 154 | + row.createCell(1).setCellValue("사이트"); | |
| 155 | + | |
| 156 | + CellStyle dateStyle = wb.createCellStyle(); | |
| 157 | + DataFormat fmt = wb.createDataFormat(); | |
| 158 | + dateStyle.setDataFormat(fmt.getFormat("yyyy-mm-dd hh:mm:ss")); | |
| 159 | + Cell produced = row.createCell(10); | |
| 160 | + produced.setCellStyle(dateStyle); | |
| 161 | + produced.setCellValue(LocalDateTime.of(2025, 1, 3, 0, 0, 0)); | |
| 162 | + | |
| 163 | + wb.write(out); | |
| 164 | + return out.toByteArray(); | |
| 165 | + } | |
| 166 | + } | |
| 167 | + | |
| 168 | + @Test | |
| 169 | + void 같은_파일을_다시_올려도_행이_늘지_않고_갱신으로_집계된다() throws Exception { | |
| 170 | + byte[] xlsx = workbook(row("1", "사이트A", "공지")); | |
| 171 | + | |
| 172 | + mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf())) | |
| 173 | + .andExpect(status().isOk()); | |
| 174 | + mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf())) | |
| 175 | + .andExpect(status().isOk()) | |
| 176 | + .andExpect(jsonPath("$.created").value(0)) | |
| 177 | + .andExpect(jsonPath("$.updated").value(1)); | |
| 178 | + | |
| 179 | + assertThat(processMapper.findAllByOrg(orgId)).hasSize(1); | |
| 180 | + } | |
| 181 | + | |
| 182 | + @Test | |
| 183 | + void 웹에서_수정한_권리처리값은_계약서유무를_포함해_재업로드가_덮어쓰지_않고_조사원_항목은_계속_갱신된다() throws Exception { | |
| 184 | + byte[] first = workbook( | |
| 185 | + row("1", "사이트A", "공지"), | |
| 186 | + row("2", "사이트B", "공지")); | |
| 187 | + mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(first)).with(csrf())) | |
| 188 | + .andExpect(status().isOk()); | |
| 189 | + | |
| 190 | + Long editedId = processMapper.findAllByOrg(orgId).stream() | |
| 191 | + .filter(i -> i.getSeq() == 1).findFirst().get().getId(); | |
| 192 | + | |
| 193 | + // seq=1은 웹에서 계약서 유무 + 처리결과를 직접 입력한다. | |
| 194 | + mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, editedId) | |
| 195 | + .with(csrf()) | |
| 196 | + .contentType(MediaType.APPLICATION_JSON) | |
| 197 | + .content(""" | |
| 198 | + {"contractDocs": "양도계약서", "finalOpinion": "전부 양도 체결", "processStatus": "처리완료"} | |
| 199 | + """)) | |
| 200 | + .andExpect(status().isOk()); | |
| 201 | + | |
| 202 | + // 재업로드: 두 행 모두 조사원 항목(site_name)이 바뀌고, 파일 쪽 계약서/처리 값도 새로 온다. | |
| 203 | + byte[] second = workbook( | |
| 204 | + withProcessing(row("1", "사이트A-수정", "공지"), "제안요청서", "1유형", "파일값", "미처리"), | |
| 205 | + withProcessing(row("2", "사이트B-수정", "공지"), "공문", "2유형", "새 의견", "처리완료")); | |
| 206 | + mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(second)).with(csrf())) | |
| 207 | + .andExpect(status().isOk()) | |
| 208 | + .andExpect(jsonPath("$.created").value(0)) | |
| 209 | + .andExpect(jsonPath("$.updated").value(2)); | |
| 210 | + | |
| 211 | + ProcessItem edited = processMapper.findById(orgId, editedId); | |
| 212 | + assertThat(edited.getSiteName()).isEqualTo("사이트A-수정"); // 조사원 항목은 항상 갱신 | |
| 213 | + assertThat(edited.getContractDocs()).isEqualTo("양도계약서"); // 웹 입력값 보존(계약서 유무 포함) | |
| 214 | + assertThat(edited.getFinalOpinion()).isEqualTo("전부 양도 체결"); | |
| 215 | + assertThat(edited.getProcessStatus()).isEqualTo("처리완료"); | |
| 216 | + assertThat(edited.getWebEditedAt()).isNotNull(); | |
| 217 | + | |
| 218 | + ProcessItem untouched = processMapper.findAllByOrg(orgId).stream() | |
| 219 | + .filter(i -> i.getSeq() == 2).findFirst().get(); | |
| 220 | + assertThat(untouched.getSiteName()).isEqualTo("사이트B-수정"); | |
| 221 | + assertThat(untouched.getContractDocs()).isEqualTo("공문"); // 웹 수정 이력 없으니 파일값 반영 | |
| 222 | + assertThat(untouched.getFinalOpinion()).isEqualTo("새 의견"); | |
| 223 | + assertThat(untouched.getProcessStatus()).isEqualTo("처리완료"); | |
| 224 | + assertThat(untouched.getWebEditedAt()).isNull(); | |
| 225 | + } | |
| 226 | + | |
| 227 | + @Test | |
| 228 | + void 목록은_검색어와_상태로_필터링되고_처리완료는_미처리_필터에서_제외된다() throws Exception { | |
| 229 | + byte[] xlsx = workbook( | |
| 230 | + row("1", "국립중앙도서관", "공지"), | |
| 231 | + row("2", "국립중앙박물관", "공지"), | |
| 232 | + row("3", "다른기관", "공지")); | |
| 233 | + mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf())) | |
| 234 | + .andExpect(status().isOk()); | |
| 235 | + | |
| 236 | + Long doneId = processMapper.findAllByOrg(orgId).stream() | |
| 237 | + .filter(i -> i.getSeq() == 1).findFirst().get().getId(); | |
| 238 | + mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, doneId) | |
| 239 | + .with(csrf()) | |
| 240 | + .contentType(MediaType.APPLICATION_JSON) | |
| 241 | + .content(""" | |
| 242 | + {"processStatus": "처리완료"} | |
| 243 | + """)) | |
| 244 | + .andExpect(status().isOk()); | |
| 245 | + | |
| 246 | + // 검색어로 "국립"이 들어간 2건만 필터링된다. | |
| 247 | + mvc.perform(get("/api/orgs/{id}/process", orgId).param("keyword", "국립")) | |
| 248 | + .andExpect(status().isOk()) | |
| 249 | + .andExpect(jsonPath("$.items.length()").value(2)); | |
| 250 | + | |
| 251 | + // status=DONE이면 처리완료 1건만. | |
| 252 | + mvc.perform(get("/api/orgs/{id}/process", orgId).param("status", "DONE")) | |
| 253 | + .andExpect(status().isOk()) | |
| 254 | + .andExpect(jsonPath("$.items.length()").value(1)) | |
| 255 | + .andExpect(jsonPath("$.items[0].seq").value(1)); | |
| 256 | + | |
| 257 | + // status=PENDING이면 처리완료 1건을 제외한 나머지 2건. | |
| 258 | + mvc.perform(get("/api/orgs/{id}/process", orgId).param("status", "PENDING")) | |
| 259 | + .andExpect(status().isOk()) | |
| 260 | + .andExpect(jsonPath("$.items.length()").value(2)); | |
| 261 | + | |
| 262 | + // total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다. | |
| 263 | + mvc.perform(get("/api/orgs/{id}/process", orgId).param("status", "PENDING")) | |
| 264 | + .andExpect(jsonPath("$.total").value(3)) | |
| 265 | + .andExpect(jsonPath("$.done").value(1)); | |
| 266 | + } | |
| 267 | + | |
| 268 | + @Test | |
| 269 | + void 처리완료로_저장하면_웹수정시각과_처리완료시각이_찍히고_이후_저장에도_처리완료시각은_유지된다() throws Exception { | |
| 270 | + byte[] xlsx = workbook(row("1", "사이트A", "공지")); | |
| 271 | + mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf())) | |
| 272 | + .andExpect(status().isOk()); | |
| 273 | + Long itemId = processMapper.findAllByOrg(orgId).get(0).getId(); | |
| 274 | + | |
| 275 | + mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, itemId) | |
| 276 | + .with(csrf()) | |
| 277 | + .contentType(MediaType.APPLICATION_JSON) | |
| 278 | + .content(""" | |
| 279 | + {"processStatus": "처리완료", "finalOpinion": "1차 완료"} | |
| 280 | + """)) | |
| 281 | + .andExpect(status().isOk()) | |
| 282 | + .andExpect(jsonPath("$.processStatus").value("처리완료")) | |
| 283 | + .andExpect(jsonPath("$.webEditedAt").isNumber()) | |
| 284 | + .andExpect(jsonPath("$.processedAt").isNumber()); | |
| 285 | + | |
| 286 | + Long firstProcessedAt = processMapper.findById(orgId, itemId).getProcessedAt(); | |
| 287 | + assertThat(firstProcessedAt).isNotNull(); | |
| 288 | + | |
| 289 | + // 이후 다른 필드만 바꿔 다시 저장해도(여전히 처리완료) 최초 완료 시각은 그대로다. | |
| 290 | + mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, itemId) | |
| 291 | + .with(csrf()) | |
| 292 | + .contentType(MediaType.APPLICATION_JSON) | |
| 293 | + .content(""" | |
| 294 | + {"processStatus": "처리완료", "finalOpinion": "2차 수정"} | |
| 295 | + """)) | |
| 296 | + .andExpect(status().isOk()); | |
| 297 | + | |
| 298 | + ProcessItem afterSecondSave = processMapper.findById(orgId, itemId); | |
| 299 | + assertThat(afterSecondSave.getProcessedAt()).isEqualTo(firstProcessedAt); | |
| 300 | + assertThat(afterSecondSave.getFinalOpinion()).isEqualTo("2차 수정"); | |
| 301 | + | |
| 302 | + // 미처리로 되돌려도 처리완료 시각 자체는 지우지 않는다(표시만 처리완료일 때 한다). | |
| 303 | + mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, itemId) | |
| 304 | + .with(csrf()) | |
| 305 | + .contentType(MediaType.APPLICATION_JSON) | |
| 306 | + .content(""" | |
| 307 | + {"processStatus": "미처리"} | |
| 308 | + """)) | |
| 309 | + .andExpect(status().isOk()); | |
| 310 | + | |
| 311 | + assertThat(processMapper.findById(orgId, itemId).getProcessedAt()).isEqualTo(firstProcessedAt); | |
| 312 | + } | |
| 313 | + | |
| 314 | + @Test | |
| 315 | + void 처리상태가_미처리_처리완료가_아니면_400이다() throws Exception { | |
| 316 | + byte[] xlsx = workbook(row("1", "사이트A", "공지")); | |
| 317 | + mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf())) | |
| 318 | + .andExpect(status().isOk()); | |
| 319 | + Long itemId = processMapper.findAllByOrg(orgId).get(0).getId(); | |
| 320 | + | |
| 321 | + mvc.perform(put("/api/orgs/{id}/process/{itemId}", orgId, itemId) | |
| 322 | + .with(csrf()) | |
| 323 | + .contentType(MediaType.APPLICATION_JSON) | |
| 324 | + .content(""" | |
| 325 | + {"processStatus": "진행중"} | |
| 326 | + """)) | |
| 327 | + .andExpect(status().isBadRequest()) | |
| 328 | + .andExpect(jsonPath("$.message").value(containsString("미처리"))); | |
| 329 | + } | |
| 330 | + | |
| 331 | + @Test | |
| 332 | + void 게시물을_삭제하면_204와_함께_목록에서_사라진다() throws Exception { | |
| 333 | + byte[] xlsx = workbook(row("1", "사이트A", "공지")); | |
| 334 | + mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf())) | |
| 335 | + .andExpect(status().isOk()); | |
| 336 | + Long itemId = processMapper.findAllByOrg(orgId).get(0).getId(); | |
| 337 | + | |
| 338 | + mvc.perform(delete("/api/orgs/{id}/process/{itemId}", orgId, itemId).with(csrf())) | |
| 339 | + .andExpect(status().isNoContent()); | |
| 340 | + | |
| 341 | + assertThat(processMapper.findAllByOrg(orgId)).isEmpty(); | |
| 342 | + } | |
| 343 | + | |
| 344 | + @Test | |
| 345 | + void 존재하지_않는_기관으로_업로드하면_404다() throws Exception { | |
| 346 | + long missingOrgId = orgId + 999999L; | |
| 347 | + byte[] xlsx = workbook(row("1", "사이트A", "공지")); | |
| 348 | + | |
| 349 | + mvc.perform(multipart("/api/orgs/{id}/process/import", missingOrgId).file(file(xlsx)).with(csrf())) | |
| 350 | + .andExpect(status().isNotFound()) | |
| 351 | + .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingOrgId)))); | |
| 352 | + } | |
| 353 | + | |
| 354 | + @Test | |
| 355 | + void 존재하지_않는_게시물이면_404다() throws Exception { | |
| 356 | + long missingItemId = 999999L; | |
| 357 | + | |
| 358 | + mvc.perform(get("/api/orgs/{id}/process/{itemId}", orgId, missingItemId)) | |
| 359 | + .andExpect(status().isNotFound()) | |
| 360 | + .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingItemId)))); | |
| 361 | + } | |
| 362 | + | |
| 363 | + @Test | |
| 364 | + void 확장자가_xlsx_xlsm이_아니면_400이다() throws Exception { | |
| 365 | + MockMultipartFile txt = new MockMultipartFile("file", "process.txt", | |
| 366 | + "text/plain", "아무 내용".getBytes()); | |
| 367 | + | |
| 368 | + mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(txt).with(csrf())) | |
| 369 | + .andExpect(status().isBadRequest()) | |
| 370 | + .andExpect(jsonPath("$.message").value(containsString("xlsx"))); | |
| 371 | + } | |
| 372 | + | |
| 373 | + @Test | |
| 374 | + void 다운로드는_엑셀_파일을_돌려준다() throws Exception { | |
| 375 | + byte[] xlsx = workbook(row("1", "사이트A", "공지")); | |
| 376 | + mvc.perform(multipart("/api/orgs/{id}/process/import", orgId).file(file(xlsx)).with(csrf())) | |
| 377 | + .andExpect(status().isOk()); | |
| 378 | + | |
| 379 | + mvc.perform(get("/api/orgs/{id}/process/download", orgId)) | |
| 380 | + .andExpect(status().isOk()) | |
| 381 | + .andExpect(result -> assertThat(result.getResponse().getContentAsByteArray()).isNotEmpty()); | |
| 382 | + } | |
| 383 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?