package kr.itn.itnhub.review; 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.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.util.ArrayList; import java.util.List; /** * 기관별 권리확인 엑셀(시트명 {@code Sheet0}, 없으면 첫 시트로 대체)을 읽는다. * * 시트 배치 (1행 그룹제목, 2행 헤더, 3행부터 데이터, A열(순번)이 빈 첫 행에서 데이터 끝): * A 순번 B 사이트명 C 범주 D 게시판 경로 E 게시판명 F 게시물제목 G URL주소 * H 게시글 등록 I 제작일 J 공표일 K 첨부파일여부 L 기존 공공누리부착 M 기존 공공누리유형 * N AI유형 O 비고(조사원) P 개방가능여부 Q 권리확인(대분류) R 권리확인(세부) S 처리결과 * T 공공누리유형(판정) U AI유형(판정) V 의견 W (빈 열) X 비고(변호사) Y 권리처리필요 * * 시트의 선언된 dimension은 서식만 채워진 40만 행 안팎이라, lastRowNum까지 무조건 돌되 * A열이 비면 즉시 멈춘다(전체 dimension을 다 읽지 않는다). * * 제작일/공표일 셀은 날짜서식이 입혀진 숫자({@code 20230904})라도 절대 날짜로 읽지 않고 * 문자열 그대로 저장한다 - {@link #cellValue}가 NUMERIC을 항상 반올림 문자열로 바꾸고 * {@code getDateCellValue()}를 호출하지 않으므로 자동으로 이 요구사항을 만족한다. * 수식 오류(#VALUE! 등) 셀은 캐시된 결과 타입이 ERROR가 되어 default 분기(빈 문자열 → null)로 * 처리된다. */ @Component public class ReviewParser { public static final String PRIMARY_SHEET_NAME = "Sheet0"; private static final int DATA_START_ROW_INDEX = 2; // 3행 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_PATH = 3; // D private static final int COL_BOARD_NAME = 4; // E private static final int COL_POST_TITLE = 5; // F private static final int COL_URL = 6; // G private static final int COL_POST_REGISTERED = 7; // H private static final int COL_PRODUCED_DATE = 8; // I private static final int COL_PUBLISHED_DATE = 9; // J private static final int COL_HAS_ATTACHMENT = 10; // K private static final int COL_KOGL_ATTACHED = 11; // L private static final int COL_KOGL_TYPE = 12; // M private static final int COL_AI_TYPE = 13; // N private static final int COL_SURVEYOR_NOTE = 14; // O private static final int COL_OPENABLE = 15; // P private static final int COL_REVIEW_MAJOR = 16; // Q private static final int COL_REVIEW_MINOR = 17; // R private static final int COL_REVIEW_RESULT = 18; // S private static final int COL_JUDGED_KOGL_TYPE = 19; // T private static final int COL_JUDGED_AI_TYPE = 20; // U private static final int COL_OPINION = 21; // V // 22 = W, 빈 열 private static final int COL_LAWYER_NOTE = 23; // X private static final int COL_NEEDS_PROCESSING = 24; // Y 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; } // 실파일에서 순번이 구간별로 리셋되어 중복된다(확인됨: 1,006행 중 43행 중복). // 순번을 키로 쓰면 중복 행이 서로 덮어써 데이터가 사라지므로, // 파일 내 행 순서(1..N)를 병합 키(seq)로 쓴다. int seq = result.size() + 1; result.add(new ReviewRow( seq, nullIfBlank(string(row, COL_SITE_NAME)), nullIfBlank(string(row, COL_CATEGORY)), nullIfBlank(string(row, COL_BOARD_PATH)), nullIfBlank(string(row, COL_BOARD_NAME)), nullIfBlank(string(row, COL_POST_TITLE)), nullIfBlank(string(row, COL_URL)), nullIfBlank(string(row, COL_POST_REGISTERED)), nullIfBlank(string(row, COL_PRODUCED_DATE)), nullIfBlank(string(row, COL_PUBLISHED_DATE)), nullIfBlank(string(row, COL_HAS_ATTACHMENT)), nullIfBlank(string(row, COL_KOGL_ATTACHED)), nullIfBlank(string(row, COL_KOGL_TYPE)), nullIfBlank(string(row, COL_AI_TYPE)), nullIfBlank(string(row, COL_SURVEYOR_NOTE)), nullIfBlank(string(row, COL_OPENABLE)), nullIfBlank(string(row, COL_REVIEW_MAJOR)), nullIfBlank(string(row, COL_REVIEW_MINOR)), nullIfBlank(string(row, COL_REVIEW_RESULT)), nullIfBlank(string(row, COL_JUDGED_KOGL_TYPE)), nullIfBlank(string(row, COL_JUDGED_AI_TYPE)), nullIfBlank(string(row, COL_OPINION)), nullIfBlank(string(row, COL_LAWYER_NOTE)), nullIfBlank(string(row, COL_NEEDS_PROCESSING)))); } return result; } private String string(Row row, int columnIndex) { Cell cell = row.getCell(columnIndex); if (cell == null) { return ""; } return cellValue(cell, cell.getCellType()); } /** SeedParser/MemberDirectoryParser와 동일한 셀 값 변환 규칙. */ private String cellValue(Cell cell, CellType cellType) { return switch (cellType) { case STRING -> cell.getStringCellValue().trim(); case NUMERIC -> 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; } }