package kr.itn.itnhub.process; import kr.itn.itnhub.code.Code; import kr.itn.itnhub.code.CodeService; import kr.itn.itnhub.code.Codes; 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 { /** * 1행 그룹 제목(A=게시물 정보(조사원), M=권리확인(변호사), U=권리처리(변호사)) + 2행 컬럼 헤더. * U열(수정 링크)과 Y열은 원본 시트의 건너뛰는 열이라 빈 문자열로 남겨둔다. */ static final String[] COLUMN_HEADERS = { "순번", "사이트명", "범주", "게시판명", "게시물제목", "URL주소", "설명", "첨부파일여부", "기존 공공누리유형(+AI)", "계약서 유무", "제작일", "공표일", "권리확인(대분류)", "권리확인(세부)", "처리 구분", "공공누리 유형", "AI유형", "의견", "비고", "기존 증빙자료", "", "공공누리유형(판정)", "AI유형(판정)", "최종의견", "", "판단근거", "처리상태" }; private final ProcessItemMapper mapper; private final OrganizationMapper orgMapper; private final CodeService codeService; public ProcessService(ProcessItemMapper mapper, OrganizationMapper orgMapper, CodeService codeService) { this.mapper = mapper; this.orgMapper = orgMapper; this.codeService = codeService; } /** * 권리처리 완료로 셀 상태값. 코드표에서 done 속성이 붙은 코드를 찾는다. * *

지정이 없으면 어떤 행도 완료로 세지 않도록 매칭되지 않는 값을 준다 - 빈 문자열을 * 주면 처리상태가 비어 있는 행이 전부 완료로 잡힌다.

*/ private String doneStatus() { return codeService.group(Codes.PROCESS_STATUS).stream() .filter(code -> code.flag(Codes.ATTR_DONE)) .map(Code::code) .findFirst() .orElse(""); } public ProcessPage list(Long orgId, String keyword, String site, String board, String title, String status, int page, int size) { requireOrg(orgId); String kw = normalizeKeyword(keyword); String siteFilter = normalizeKeyword(site); String boardFilter = normalizeKeyword(board); String titleFilter = normalizeKeyword(title); String st = normalizeStatus(status); int safePage = Math.max(page, 0); int safeSize = size <= 0 ? 30 : size; int offset = safePage * safeSize; String done = doneStatus(); List items = mapper.findPage(orgId, kw, siteFilter, boardFilter, titleFilter, st, done, offset, safeSize); // total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다(헤더/통계 카드용 - ProcessPage 참고). ProcessStats stats = mapper.stats(orgId, done); return new ProcessPage(items, stats.total(), stats.done(), safePage, safeSize); } /** 목록에서 고른 여러 건에 같은 값을 한 번에 반영한다. 화면에서 비워 둔 항목은 유지된다. */ @Transactional public int updateBulk(Long orgId, BulkProcessingRequest request) { requireOrg(orgId); if (request.ids() == null || request.ids().isEmpty()) { return 0; } return mapper.updateProcessingBulk(orgId, request.ids(), blankToNull(request.processStatus()), blankToNull(request.judgedKoglType()), blankToNull(request.finalOpinion()), blankToNull(request.reviewNote()), doneStatus()); } /** 목록에서 고른 여러 건을 지운다. */ @Transactional public int deleteBulk(Long orgId, List ids) { requireOrg(orgId); if (ids == null || ids.isEmpty()) { return 0; } return mapper.deleteByIds(orgId, ids); } private String blankToNull(String value) { return value == null || value.isBlank() ? null : value; } 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(), doneStatus()); 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 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 || processStatus.isBlank()) { return; } if (!codeService.isValid(Codes.PROCESS_STATUS, processStatus)) { String allowed = codeService.group(Codes.PROCESS_STATUS).stream() .map(Code::label) .collect(java.util.stream.Collectors.joining(" 또는 ")); throw new InvalidProcessStatusException("처리상태는 " + allowed + "여야 합니다."); } } 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; } }