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"); static final int COL_SEQ = 0; // A static final int COL_SITE_NAME = 1; // B static final int COL_CATEGORY = 2; // C static final int COL_BOARD_NAME = 3; // D static final int COL_POST_TITLE = 4; // E static final int COL_URL = 5; // F static final int COL_DESCRIPTION = 6; // G static final int COL_HAS_ATTACHMENT = 7; // H static final int COL_PRIOR_KOGL_TYPE = 8; // I static final int COL_CONTRACT_DOCS = 9; // J static final int COL_PRODUCED_DATE = 10; // K static final int COL_PUBLISHED_DATE = 11; // L static final int COL_REVIEW_MAJOR = 12; // M static final int COL_REVIEW_MINOR = 13; // N static final int COL_REVIEW_RESULT = 14; // O static final int COL_REVIEW_KOGL_TYPE = 15; // P static final int COL_REVIEW_AI_TYPE = 16; // Q static final int COL_REVIEW_OPINION = 17; // R static final int COL_REVIEW_NOTE = 18; // S // 실파일에서 헤더는 T열에 '기존 증빙자료'라고 적혀 있지만 실제 데이터는 U열에 있다 // (T열에는 엑셀 화면용 '수정' 링크 텍스트가 들어 있음 - 데이터 기준으로 U를 읽는다) static final int COL_PRIOR_EVIDENCE = 20; // U /** 헤더('기존 증빙자료')가 적힌 열. 이 시스템이 만드는 다운로드 파일은 여기에 값을 쓴다. */ static final int COL_PRIOR_EVIDENCE_HEADER = 19; // T static final int COL_JUDGED_KOGL_TYPE = 21; // V static final int COL_JUDGED_AI_TYPE = 22; // W static final int COL_FINAL_OPINION = 23; // X // 24 = Y, 빈 열, 건너뜀 static final int COL_JUDGMENT_BASIS = 25; // Z static final int COL_PROCESS_STATUS = 26; // AA public List 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 readRows(Sheet sheet) { List 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 = result.size() + 1; 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)), priorEvidence(row), 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; } /** * 기존 증빙자료는 파일마다 값이 있는 열이 다르다. * *

기관이 보내오는 원본은 헤더가 T열인데 데이터가 U열에 있고(T에는 엑셀 화면용 '수정' * 링크 텍스트가 들어 있다), 이 시스템이 만들어 주는 다운로드 파일은 헤더 자리인 T열에 * 값을 쓴다. 한쪽만 읽으면 "내려받아 고쳐서 다시 올리는" 흐름에서 값이 조용히 사라지므로 * U를 먼저 보고, 비어 있으면 T를 본다.

*/ private String priorEvidence(Row row) { String fromDataColumn = nullIfBlank(string(row, COL_PRIOR_EVIDENCE)); return fromDataColumn != null ? fromDataColumn : nullIfBlank(string(row, COL_PRIOR_EVIDENCE_HEADER)); } 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; } }