ITN Dev 07-22
feat: 신청목록 시트 파서 추가
@a4dd8ba0c3a6b6c417f0f15bebcb28649e7c6d29
 
src/main/java/kr/itn/itnhub/seed/SeedParseException.java (added)
+++ src/main/java/kr/itn/itnhub/seed/SeedParseException.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.seed;
+
+public class SeedParseException extends RuntimeException {
+    public SeedParseException(String message) {
+        super(message);
+    }
+
+    public SeedParseException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
 
src/main/java/kr/itn/itnhub/seed/SeedParser.java (added)
+++ src/main/java/kr/itn/itnhub/seed/SeedParser.java
@@ -0,0 +1,122 @@
+package kr.itn.itnhub.seed;
+
+import kr.itn.itnhub.provision.ChannelNaming;
+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.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;
+
+/**
+ * 신유형 신청목록 시트를 대표기관 단위로 축약한다.
+ *
+ * 시트 배치 (1행 그룹제목, 2행 헤더, 3행부터 데이터):
+ *   B=연번1  C=연번2  F=기관명  G=부서명  H=담당자명  I=직급/직함  J=유선전화  K=이메일
+ *
+ * 축약 규칙:
+ *   - 연번2가 "00"인 행이 대표기관이며 담당자 정보를 갖는다
+ *   - 나머지 하위기관 행은 대표기관에 흡수한다 (008 경찰청의 서울청·부산청 등)
+ *   - 단 경찰청_치안정책연구소만 예외로 별도 기관으로 남긴다
+ */
+@Component
+public class SeedParser {
+
+    public static final String SHEET_NAME = "신유형 신청목록";
+
+    private static final int DATA_START_ROW_INDEX = 2; // 3행
+    private static final int COL_ORG_NO = 1;    // B
+    private static final int COL_SUB_NO = 2;    // C
+    private static final int COL_ORG_NAME = 5;  // F
+    private static final int COL_DEPT = 6;      // G
+    private static final int COL_MANAGER = 7;   // H
+    private static final int COL_TITLE = 8;     // I
+    private static final int COL_PHONE = 9;     // J
+    private static final int COL_EMAIL = 10;    // K
+
+    public List<SeedRow> parse(InputStream xlsx) {
+        try (Workbook wb = WorkbookFactory.create(xlsx)) {
+            Sheet sheet = wb.getSheet(SHEET_NAME);
+            if (sheet == null) {
+                throw new SeedParseException(
+                        "통합문서에 '" + SHEET_NAME + "' 시트가 없습니다.");
+            }
+            return readRows(sheet);
+        } catch (IOException e) {
+            throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e);
+        }
+    }
+
+    private List<SeedRow> readRows(Sheet sheet) {
+        List<SeedRow> result = new ArrayList<>();
+
+        for (int i = DATA_START_ROW_INDEX; i <= sheet.getLastRowNum(); i++) {
+            Row row = sheet.getRow(i);
+            if (row == null) {
+                continue;
+            }
+
+            String orgNo = orgNo(row);
+            String orgName = string(row, COL_ORG_NAME);
+            if (orgNo.isBlank() || orgName.isBlank()) {
+                continue;
+            }
+
+            boolean representative = "00".equals(subNo(row));
+            boolean special = ChannelNaming.SPECIAL_ORG_NAME.equalsIgnoreCase(orgName);
+
+            if (!representative && !special) {
+                continue; // 하위기관은 대표기관에 흡수된다
+            }
+
+            result.add(new SeedRow(
+                    orgNo,
+                    orgName,
+                    special ? orgNo + "-policy" : orgNo,
+                    nullIfBlank(string(row, COL_DEPT)),
+                    nullIfBlank(string(row, COL_MANAGER)),
+                    nullIfBlank(string(row, COL_TITLE)),
+                    nullIfBlank(string(row, COL_PHONE)),
+                    nullIfBlank(string(row, COL_EMAIL))));
+        }
+        return result;
+    }
+
+    /** 연번은 항상 3자리 문자열이다. 숫자로 저장된 셀도 앞자리 0을 복원해 준다. */
+    private String orgNo(Row row) {
+        String raw = string(row, COL_ORG_NO);
+        return raw.isBlank() ? "" : pad3(raw);
+    }
+
+    private String subNo(Row row) {
+        String raw = string(row, COL_SUB_NO);
+        return raw.isBlank() ? "" : (raw.length() == 1 ? "0" + raw : raw);
+    }
+
+    private String pad3(String value) {
+        String v = value.trim();
+        return v.length() >= 3 ? v : "0".repeat(3 - v.length()) + v;
+    }
+
+    private String string(Row row, int columnIndex) {
+        Cell cell = row.getCell(columnIndex);
+        if (cell == null) {
+            return "";
+        }
+        return switch (cell.getCellType()) {
+            case STRING -> cell.getStringCellValue().trim();
+            case NUMERIC -> String.valueOf((long) cell.getNumericCellValue());
+            case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
+            default -> "";
+        };
+    }
+
+    private String nullIfBlank(String value) {
+        return (value == null || value.isBlank()) ? null : value;
+    }
+}
 
src/main/java/kr/itn/itnhub/seed/SeedRow.java (added)
+++ src/main/java/kr/itn/itnhub/seed/SeedRow.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.seed;
+
+public record SeedRow(
+        String orgNo,
+        String orgName,
+        String channelSlug,
+        String deptName,
+        String managerName,
+        String managerTitle,
+        String managerPhone,
+        String managerEmail) {
+}
 
src/test/java/kr/itn/itnhub/seed/SeedParserTest.java (added)
+++ src/test/java/kr/itn/itnhub/seed/SeedParserTest.java
@@ -0,0 +1,165 @@
+package kr.itn.itnhub.seed;
+
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.DataFormat;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SeedParserTest {
+
+    /** 실제 시트와 같은 배치로 최소 통합문서를 만든다. 데이터는 3행(index 2)부터. */
+    private byte[] workbook(String[]... dataRows) throws Exception {
+        try (XSSFWorkbook wb = new XSSFWorkbook();
+             ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+
+            Sheet sheet = wb.createSheet(SeedParser.SHEET_NAME);
+            DataFormat fmt = wb.createDataFormat();
+            CellStyle text = wb.createCellStyle();
+            text.setDataFormat(fmt.getFormat("@"));
+
+            sheet.createRow(0).createCell(1).setCellValue("자료 넘버링");
+            Row header = sheet.createRow(1);
+            header.createCell(1).setCellValue("연번1");
+            header.createCell(2).setCellValue("연번2");
+            header.createCell(5).setCellValue("기관명");
+
+            int rowNum = 2;
+            for (String[] data : dataRows) {
+                Row row = sheet.createRow(rowNum++);
+                for (int i = 0; i < data.length; i++) {
+                    if (data[i] == null) {
+                        continue;
+                    }
+                    // B=1, C=2, F=5, G=6, H=7, I=8, J=9, K=10
+                    int col = switch (i) {
+                        case 0 -> 1;
+                        case 1 -> 2;
+                        case 2 -> 5;
+                        default -> i + 3;
+                    };
+                    Cell cell = row.createCell(col);
+                    cell.setCellStyle(text);
+                    cell.setCellValue(data[i]);
+                }
+            }
+            wb.write(out);
+            return out.toByteArray();
+        }
+    }
+
+    private SeedParser parser = new SeedParser();
+
+    @Test
+    void 대표기관_행을_읽는다() throws Exception {
+        byte[] xlsx = workbook(new String[]{
+                "001", "00", "국제방송교류재단",
+                "데이터정보화팀", "송민지", "과장", "02-3475-5434", "ming@arirang.com"});
+
+        List<SeedRow> rows = parser.parse(new ByteArrayInputStream(xlsx));
+
+        assertThat(rows).hasSize(1);
+        SeedRow row = rows.get(0);
+        assertThat(row.orgNo()).isEqualTo("001");
+        assertThat(row.orgName()).isEqualTo("국제방송교류재단");
+        assertThat(row.channelSlug()).isEqualTo("001");
+        assertThat(row.deptName()).isEqualTo("데이터정보화팀");
+        assertThat(row.managerName()).isEqualTo("송민지");
+        assertThat(row.managerTitle()).isEqualTo("과장");
+        assertThat(row.managerPhone()).isEqualTo("02-3475-5434");
+        assertThat(row.managerEmail()).isEqualTo("ming@arirang.com");
+    }
+
+    @Test
+    void 하위기관은_대표기관으로_흡수되어_행이_늘지_않는다() throws Exception {
+        byte[] xlsx = workbook(
+                new String[]{"008", "00", "경찰청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"},
+                new String[]{"008", "02", "서울청", null, null, null, null, null},
+                new String[]{"008", "03", "부산청", null, null, null, null, null});
+
+        List<SeedRow> rows = parser.parse(new ByteArrayInputStream(xlsx));
+
+        assertThat(rows).hasSize(1);
+        assertThat(rows.get(0).orgName()).isEqualTo("경찰청");
+    }
+
+    @Test
+    void 치안정책연구소는_하위기관이지만_별도_기관으로_남는다() throws Exception {
+        byte[] xlsx = workbook(
+                new String[]{"008", "00", "경찰청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"},
+                new String[]{"008", "01", "경찰청_치안정책연구소", null, null, null, null, null},
+                new String[]{"008", "02", "서울청", null, null, null, null, null});
+
+        List<SeedRow> rows = parser.parse(new ByteArrayInputStream(xlsx));
+
+        assertThat(rows).hasSize(2);
+        assertThat(rows).extracting(SeedRow::orgName)
+                .containsExactly("경찰청", "경찰청_치안정책연구소");
+        assertThat(rows).extracting(SeedRow::channelSlug)
+                .containsExactly("008", "008-policy");
+    }
+
+    @Test
+    void 연번의_앞자리_영이_보존된다() throws Exception {
+        byte[] xlsx = workbook(new String[]{
+                "007", "00", "기후에너지환경부",
+                "정보화담당관", "임난주", "공무직", "044-201-6431", "envlib@korea.kr"});
+
+        assertThat(parser.parse(new ByteArrayInputStream(xlsx)).get(0).orgNo())
+                .isEqualTo("007");
+    }
+
+    @Test
+    void 연번이_숫자로_저장돼_있어도_세자리로_복원한다() throws Exception {
+        try (XSSFWorkbook wb = new XSSFWorkbook();
+             ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+
+            Sheet sheet = wb.createSheet(SeedParser.SHEET_NAME);
+            sheet.createRow(0);
+            sheet.createRow(1);
+            Row row = sheet.createRow(2);
+            row.createCell(1).setCellValue(7);     // B: 숫자 7
+            row.createCell(2).setCellValue(0);     // C: 숫자 0
+            row.createCell(5).setCellValue("기후에너지환경부");
+            wb.write(out);
+
+            List<SeedRow> rows = parser.parse(new ByteArrayInputStream(out.toByteArray()));
+
+            assertThat(rows.get(0).orgNo()).isEqualTo("007");
+        }
+    }
+
+    @Test
+    void 빈_행은_건너뛴다() throws Exception {
+        byte[] xlsx = workbook(
+                new String[]{"001", "00", "국제방송교류재단", "팀", "김", "과장", "02", "a@b.kr"},
+                new String[]{null, null, null, null, null, null, null, null},
+                new String[]{"002", "00", "세종학당재단", "팀", "권", "6급", "02", "c@d.kr"});
+
+        assertThat(parser.parse(new ByteArrayInputStream(xlsx))).hasSize(2);
+    }
+
+    @Test
+    void 시트가_없으면_설명이_있는_예외를_던진다() throws Exception {
+        try (XSSFWorkbook wb = new XSSFWorkbook();
+             ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+
+            wb.createSheet("다른시트");
+            wb.write(out);
+            byte[] xlsx = out.toByteArray();
+
+            assertThatThrownBy(() -> parser.parse(new ByteArrayInputStream(xlsx)))
+                    .isInstanceOf(SeedParseException.class)
+                    .hasMessageContaining(SeedParser.SHEET_NAME);
+        }
+    }
+}
Add a comment
List