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;
}
}