feat: 신청목록 시트 파서 추가
@a4dd8ba0c3a6b6c417f0f15bebcb28649e7c6d29
+++ src/main/java/kr/itn/itnhub/seed/SeedParseException.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.seed; | |
| 2 | + | |
| 3 | +public class SeedParseException extends RuntimeException { | |
| 4 | + public SeedParseException(String message) { | |
| 5 | + super(message); | |
| 6 | + } | |
| 7 | + | |
| 8 | + public SeedParseException(String message, Throwable cause) { | |
| 9 | + super(message, cause); | |
| 10 | + } | |
| 11 | +} |
+++ src/main/java/kr/itn/itnhub/seed/SeedParser.java
... | ... | @@ -0,0 +1,122 @@ |
| 1 | +package kr.itn.itnhub.seed; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.provision.ChannelNaming; | |
| 4 | +import org.apache.poi.ss.usermodel.Cell; | |
| 5 | +import org.apache.poi.ss.usermodel.Row; | |
| 6 | +import org.apache.poi.ss.usermodel.Sheet; | |
| 7 | +import org.apache.poi.ss.usermodel.Workbook; | |
| 8 | +import org.apache.poi.ss.usermodel.WorkbookFactory; | |
| 9 | +import org.springframework.stereotype.Component; | |
| 10 | + | |
| 11 | +import java.io.IOException; | |
| 12 | +import java.io.InputStream; | |
| 13 | +import java.util.ArrayList; | |
| 14 | +import java.util.List; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * 신유형 신청목록 시트를 대표기관 단위로 축약한다. | |
| 18 | + * | |
| 19 | + * 시트 배치 (1행 그룹제목, 2행 헤더, 3행부터 데이터): | |
| 20 | + * B=연번1 C=연번2 F=기관명 G=부서명 H=담당자명 I=직급/직함 J=유선전화 K=이메일 | |
| 21 | + * | |
| 22 | + * 축약 규칙: | |
| 23 | + * - 연번2가 "00"인 행이 대표기관이며 담당자 정보를 갖는다 | |
| 24 | + * - 나머지 하위기관 행은 대표기관에 흡수한다 (008 경찰청의 서울청·부산청 등) | |
| 25 | + * - 단 경찰청_치안정책연구소만 예외로 별도 기관으로 남긴다 | |
| 26 | + */ | |
| 27 | +@Component | |
| 28 | +public class SeedParser { | |
| 29 | + | |
| 30 | + public static final String SHEET_NAME = "신유형 신청목록"; | |
| 31 | + | |
| 32 | + private static final int DATA_START_ROW_INDEX = 2; // 3행 | |
| 33 | + private static final int COL_ORG_NO = 1; // B | |
| 34 | + private static final int COL_SUB_NO = 2; // C | |
| 35 | + private static final int COL_ORG_NAME = 5; // F | |
| 36 | + private static final int COL_DEPT = 6; // G | |
| 37 | + private static final int COL_MANAGER = 7; // H | |
| 38 | + private static final int COL_TITLE = 8; // I | |
| 39 | + private static final int COL_PHONE = 9; // J | |
| 40 | + private static final int COL_EMAIL = 10; // K | |
| 41 | + | |
| 42 | + public List<SeedRow> parse(InputStream xlsx) { | |
| 43 | + try (Workbook wb = WorkbookFactory.create(xlsx)) { | |
| 44 | + Sheet sheet = wb.getSheet(SHEET_NAME); | |
| 45 | + if (sheet == null) { | |
| 46 | + throw new SeedParseException( | |
| 47 | + "통합문서에 '" + SHEET_NAME + "' 시트가 없습니다."); | |
| 48 | + } | |
| 49 | + return readRows(sheet); | |
| 50 | + } catch (IOException e) { | |
| 51 | + throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e); | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + private List<SeedRow> readRows(Sheet sheet) { | |
| 56 | + List<SeedRow> result = new ArrayList<>(); | |
| 57 | + | |
| 58 | + for (int i = DATA_START_ROW_INDEX; i <= sheet.getLastRowNum(); i++) { | |
| 59 | + Row row = sheet.getRow(i); | |
| 60 | + if (row == null) { | |
| 61 | + continue; | |
| 62 | + } | |
| 63 | + | |
| 64 | + String orgNo = orgNo(row); | |
| 65 | + String orgName = string(row, COL_ORG_NAME); | |
| 66 | + if (orgNo.isBlank() || orgName.isBlank()) { | |
| 67 | + continue; | |
| 68 | + } | |
| 69 | + | |
| 70 | + boolean representative = "00".equals(subNo(row)); | |
| 71 | + boolean special = ChannelNaming.SPECIAL_ORG_NAME.equalsIgnoreCase(orgName); | |
| 72 | + | |
| 73 | + if (!representative && !special) { | |
| 74 | + continue; // 하위기관은 대표기관에 흡수된다 | |
| 75 | + } | |
| 76 | + | |
| 77 | + result.add(new SeedRow( | |
| 78 | + orgNo, | |
| 79 | + orgName, | |
| 80 | + special ? orgNo + "-policy" : orgNo, | |
| 81 | + nullIfBlank(string(row, COL_DEPT)), | |
| 82 | + nullIfBlank(string(row, COL_MANAGER)), | |
| 83 | + nullIfBlank(string(row, COL_TITLE)), | |
| 84 | + nullIfBlank(string(row, COL_PHONE)), | |
| 85 | + nullIfBlank(string(row, COL_EMAIL)))); | |
| 86 | + } | |
| 87 | + return result; | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** 연번은 항상 3자리 문자열이다. 숫자로 저장된 셀도 앞자리 0을 복원해 준다. */ | |
| 91 | + private String orgNo(Row row) { | |
| 92 | + String raw = string(row, COL_ORG_NO); | |
| 93 | + return raw.isBlank() ? "" : pad3(raw); | |
| 94 | + } | |
| 95 | + | |
| 96 | + private String subNo(Row row) { | |
| 97 | + String raw = string(row, COL_SUB_NO); | |
| 98 | + return raw.isBlank() ? "" : (raw.length() == 1 ? "0" + raw : raw); | |
| 99 | + } | |
| 100 | + | |
| 101 | + private String pad3(String value) { | |
| 102 | + String v = value.trim(); | |
| 103 | + return v.length() >= 3 ? v : "0".repeat(3 - v.length()) + v; | |
| 104 | + } | |
| 105 | + | |
| 106 | + private String string(Row row, int columnIndex) { | |
| 107 | + Cell cell = row.getCell(columnIndex); | |
| 108 | + if (cell == null) { | |
| 109 | + return ""; | |
| 110 | + } | |
| 111 | + return switch (cell.getCellType()) { | |
| 112 | + case STRING -> cell.getStringCellValue().trim(); | |
| 113 | + case NUMERIC -> String.valueOf((long) cell.getNumericCellValue()); | |
| 114 | + case BOOLEAN -> String.valueOf(cell.getBooleanCellValue()); | |
| 115 | + default -> ""; | |
| 116 | + }; | |
| 117 | + } | |
| 118 | + | |
| 119 | + private String nullIfBlank(String value) { | |
| 120 | + return (value == null || value.isBlank()) ? null : value; | |
| 121 | + } | |
| 122 | +} |
+++ src/main/java/kr/itn/itnhub/seed/SeedRow.java
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +package kr.itn.itnhub.seed; | |
| 2 | + | |
| 3 | +public record SeedRow( | |
| 4 | + String orgNo, | |
| 5 | + String orgName, | |
| 6 | + String channelSlug, | |
| 7 | + String deptName, | |
| 8 | + String managerName, | |
| 9 | + String managerTitle, | |
| 10 | + String managerPhone, | |
| 11 | + String managerEmail) { | |
| 12 | +} |
+++ src/test/java/kr/itn/itnhub/seed/SeedParserTest.java
... | ... | @@ -0,0 +1,165 @@ |
| 1 | +package kr.itn.itnhub.seed; | |
| 2 | + | |
| 3 | +import org.apache.poi.ss.usermodel.Cell; | |
| 4 | +import org.apache.poi.ss.usermodel.CellStyle; | |
| 5 | +import org.apache.poi.ss.usermodel.DataFormat; | |
| 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.junit.jupiter.api.Test; | |
| 10 | + | |
| 11 | +import java.io.ByteArrayInputStream; | |
| 12 | +import java.io.ByteArrayOutputStream; | |
| 13 | +import java.util.List; | |
| 14 | + | |
| 15 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 16 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | |
| 17 | + | |
| 18 | +class SeedParserTest { | |
| 19 | + | |
| 20 | + /** 실제 시트와 같은 배치로 최소 통합문서를 만든다. 데이터는 3행(index 2)부터. */ | |
| 21 | + private byte[] workbook(String[]... dataRows) throws Exception { | |
| 22 | + try (XSSFWorkbook wb = new XSSFWorkbook(); | |
| 23 | + ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 24 | + | |
| 25 | + Sheet sheet = wb.createSheet(SeedParser.SHEET_NAME); | |
| 26 | + DataFormat fmt = wb.createDataFormat(); | |
| 27 | + CellStyle text = wb.createCellStyle(); | |
| 28 | + text.setDataFormat(fmt.getFormat("@")); | |
| 29 | + | |
| 30 | + sheet.createRow(0).createCell(1).setCellValue("자료 넘버링"); | |
| 31 | + Row header = sheet.createRow(1); | |
| 32 | + header.createCell(1).setCellValue("연번1"); | |
| 33 | + header.createCell(2).setCellValue("연번2"); | |
| 34 | + header.createCell(5).setCellValue("기관명"); | |
| 35 | + | |
| 36 | + int rowNum = 2; | |
| 37 | + for (String[] data : dataRows) { | |
| 38 | + Row row = sheet.createRow(rowNum++); | |
| 39 | + for (int i = 0; i < data.length; i++) { | |
| 40 | + if (data[i] == null) { | |
| 41 | + continue; | |
| 42 | + } | |
| 43 | + // B=1, C=2, F=5, G=6, H=7, I=8, J=9, K=10 | |
| 44 | + int col = switch (i) { | |
| 45 | + case 0 -> 1; | |
| 46 | + case 1 -> 2; | |
| 47 | + case 2 -> 5; | |
| 48 | + default -> i + 3; | |
| 49 | + }; | |
| 50 | + Cell cell = row.createCell(col); | |
| 51 | + cell.setCellStyle(text); | |
| 52 | + cell.setCellValue(data[i]); | |
| 53 | + } | |
| 54 | + } | |
| 55 | + wb.write(out); | |
| 56 | + return out.toByteArray(); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + private SeedParser parser = new SeedParser(); | |
| 61 | + | |
| 62 | + @Test | |
| 63 | + void 대표기관_행을_읽는다() throws Exception { | |
| 64 | + byte[] xlsx = workbook(new String[]{ | |
| 65 | + "001", "00", "국제방송교류재단", | |
| 66 | + "데이터정보화팀", "송민지", "과장", "02-3475-5434", "ming@arirang.com"}); | |
| 67 | + | |
| 68 | + List<SeedRow> rows = parser.parse(new ByteArrayInputStream(xlsx)); | |
| 69 | + | |
| 70 | + assertThat(rows).hasSize(1); | |
| 71 | + SeedRow row = rows.get(0); | |
| 72 | + assertThat(row.orgNo()).isEqualTo("001"); | |
| 73 | + assertThat(row.orgName()).isEqualTo("국제방송교류재단"); | |
| 74 | + assertThat(row.channelSlug()).isEqualTo("001"); | |
| 75 | + assertThat(row.deptName()).isEqualTo("데이터정보화팀"); | |
| 76 | + assertThat(row.managerName()).isEqualTo("송민지"); | |
| 77 | + assertThat(row.managerTitle()).isEqualTo("과장"); | |
| 78 | + assertThat(row.managerPhone()).isEqualTo("02-3475-5434"); | |
| 79 | + assertThat(row.managerEmail()).isEqualTo("ming@arirang.com"); | |
| 80 | + } | |
| 81 | + | |
| 82 | + @Test | |
| 83 | + void 하위기관은_대표기관으로_흡수되어_행이_늘지_않는다() throws Exception { | |
| 84 | + byte[] xlsx = workbook( | |
| 85 | + new String[]{"008", "00", "경찰청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"}, | |
| 86 | + new String[]{"008", "02", "서울청", null, null, null, null, null}, | |
| 87 | + new String[]{"008", "03", "부산청", null, null, null, null, null}); | |
| 88 | + | |
| 89 | + List<SeedRow> rows = parser.parse(new ByteArrayInputStream(xlsx)); | |
| 90 | + | |
| 91 | + assertThat(rows).hasSize(1); | |
| 92 | + assertThat(rows.get(0).orgName()).isEqualTo("경찰청"); | |
| 93 | + } | |
| 94 | + | |
| 95 | + @Test | |
| 96 | + void 치안정책연구소는_하위기관이지만_별도_기관으로_남는다() throws Exception { | |
| 97 | + byte[] xlsx = workbook( | |
| 98 | + new String[]{"008", "00", "경찰청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"}, | |
| 99 | + new String[]{"008", "01", "경찰청_치안정책연구소", null, null, null, null, null}, | |
| 100 | + new String[]{"008", "02", "서울청", null, null, null, null, null}); | |
| 101 | + | |
| 102 | + List<SeedRow> rows = parser.parse(new ByteArrayInputStream(xlsx)); | |
| 103 | + | |
| 104 | + assertThat(rows).hasSize(2); | |
| 105 | + assertThat(rows).extracting(SeedRow::orgName) | |
| 106 | + .containsExactly("경찰청", "경찰청_치안정책연구소"); | |
| 107 | + assertThat(rows).extracting(SeedRow::channelSlug) | |
| 108 | + .containsExactly("008", "008-policy"); | |
| 109 | + } | |
| 110 | + | |
| 111 | + @Test | |
| 112 | + void 연번의_앞자리_영이_보존된다() throws Exception { | |
| 113 | + byte[] xlsx = workbook(new String[]{ | |
| 114 | + "007", "00", "기후에너지환경부", | |
| 115 | + "정보화담당관", "임난주", "공무직", "044-201-6431", "envlib@korea.kr"}); | |
| 116 | + | |
| 117 | + assertThat(parser.parse(new ByteArrayInputStream(xlsx)).get(0).orgNo()) | |
| 118 | + .isEqualTo("007"); | |
| 119 | + } | |
| 120 | + | |
| 121 | + @Test | |
| 122 | + void 연번이_숫자로_저장돼_있어도_세자리로_복원한다() throws Exception { | |
| 123 | + try (XSSFWorkbook wb = new XSSFWorkbook(); | |
| 124 | + ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 125 | + | |
| 126 | + Sheet sheet = wb.createSheet(SeedParser.SHEET_NAME); | |
| 127 | + sheet.createRow(0); | |
| 128 | + sheet.createRow(1); | |
| 129 | + Row row = sheet.createRow(2); | |
| 130 | + row.createCell(1).setCellValue(7); // B: 숫자 7 | |
| 131 | + row.createCell(2).setCellValue(0); // C: 숫자 0 | |
| 132 | + row.createCell(5).setCellValue("기후에너지환경부"); | |
| 133 | + wb.write(out); | |
| 134 | + | |
| 135 | + List<SeedRow> rows = parser.parse(new ByteArrayInputStream(out.toByteArray())); | |
| 136 | + | |
| 137 | + assertThat(rows.get(0).orgNo()).isEqualTo("007"); | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + @Test | |
| 142 | + void 빈_행은_건너뛴다() throws Exception { | |
| 143 | + byte[] xlsx = workbook( | |
| 144 | + new String[]{"001", "00", "국제방송교류재단", "팀", "김", "과장", "02", "a@b.kr"}, | |
| 145 | + new String[]{null, null, null, null, null, null, null, null}, | |
| 146 | + new String[]{"002", "00", "세종학당재단", "팀", "권", "6급", "02", "c@d.kr"}); | |
| 147 | + | |
| 148 | + assertThat(parser.parse(new ByteArrayInputStream(xlsx))).hasSize(2); | |
| 149 | + } | |
| 150 | + | |
| 151 | + @Test | |
| 152 | + void 시트가_없으면_설명이_있는_예외를_던진다() throws Exception { | |
| 153 | + try (XSSFWorkbook wb = new XSSFWorkbook(); | |
| 154 | + ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 155 | + | |
| 156 | + wb.createSheet("다른시트"); | |
| 157 | + wb.write(out); | |
| 158 | + byte[] xlsx = out.toByteArray(); | |
| 159 | + | |
| 160 | + assertThatThrownBy(() -> parser.parse(new ByteArrayInputStream(xlsx))) | |
| 161 | + .isInstanceOf(SeedParseException.class) | |
| 162 | + .hasMessageContaining(SeedParser.SHEET_NAME); | |
| 163 | + } | |
| 164 | + } | |
| 165 | +} |
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?