ITN Dev 07-23
feat: 기관 코드를 연번1_연번2 합성 코드로 재구성
경찰청 계열처럼 연번1(기관번호) 하나로 하위기관을 대표기관에 흡수하던 모델을
폐기하고, 시트의 모든 행(연번1_연번2)이 각자 독립된 기관·채널 쌍을 갖도록 바꿨다.
경찰청_치안정책연구소에 대한 -policy- 특례는 연번 자체가 하위기관까지 구분하므로
더 이상 필요 없어 제거했다. 기존 채널 ID는 V5 마이그레이션이 org_no만 변환해
그대로 보존한다.
@aa053cd166cf3e996242ee51863e8a4ae213f873
src/main/java/kr/itn/itnhub/provision/ChannelNaming.java
--- src/main/java/kr/itn/itnhub/provision/ChannelNaming.java
+++ src/main/java/kr/itn/itnhub/provision/ChannelNaming.java
@@ -3,11 +3,12 @@
 /**
  * 채널 이름 규칙. VBA M06/M07의 이식이며 기존에 만들어진 채널과 반드시 일치해야 한다.
  * 규칙이 바뀌면 기존 채널을 찾지 못하고 중복 생성된다.
+ *
+ * 기관 코드({@code orgNo})는 {연번1}_{연번2} 합성 코드다(예: 008_00 경찰청,
+ * 008_01 경찰청_치안정책연구소, 008_02 서울청 …). 연번 자체가 하위기관까지 구분하므로
+ * 기관명에 따른 별도 특례는 없다.
  */
 public final class ChannelNaming {
-
-    /** 같은 연번을 쓰지만 별도 기관으로 취급하는 유일한 예외. */
-    public static final String SPECIAL_ORG_NAME = "경찰청_치안정책연구소";
 
     private ChannelNaming() {
     }
@@ -22,9 +23,6 @@
                 .replace("_", "-")
                 .replace(" ", "-");
 
-        if (SPECIAL_ORG_NAME.equalsIgnoreCase(orgName.trim())) {
-            return "org-" + slug + "-policy-" + kind.code();
-        }
         return "org-" + slug + "-" + kind.code();
     }
 }
src/main/java/kr/itn/itnhub/seed/SeedParser.java
--- src/main/java/kr/itn/itnhub/seed/SeedParser.java
+++ src/main/java/kr/itn/itnhub/seed/SeedParser.java
@@ -1,6 +1,5 @@
 package kr.itn.itnhub.seed;
 
-import kr.itn.itnhub.provision.ChannelNaming;
 import org.apache.poi.ss.usermodel.Cell;
 import org.apache.poi.ss.usermodel.CellType;
 import org.apache.poi.ss.usermodel.Row;
@@ -15,15 +14,16 @@
 import java.util.List;
 
 /**
- * 신유형 신청목록 시트를 대표기관 단위로 축약한다.
+ * 신유형 신청목록 시트의 모든 행을 개별 기관으로 읽는다.
  *
  * 시트 배치 (1행 그룹제목, 2행 헤더, 3행부터 데이터):
  *   B=연번1  C=연번2  F=기관명  G=부서명  H=담당자명  I=직급/직함  J=유선전화  K=이메일
  *
- * 축약 규칙:
- *   - 연번2가 "00"인 행이 대표기관이며 담당자 정보를 갖는다
- *   - 나머지 하위기관 행은 대표기관에 흡수한다 (008 경찰청의 서울청·부산청 등)
- *   - 단 경찰청_치안정책연구소만 예외로 별도 기관으로 남긴다
+ * 기관 코드는 {연번1}_{연번2} 합성 코드다(예: 001_00, 008_02). 연번1+연번2+기관명이
+ * 모두 있는 행은 전부 별도 기관이 된다 - 예전처럼 연번2="00" 대표기관에 하위기관을
+ * 흡수하지 않는다(경찰청_치안정책연구소 같은 예외도 더 이상 필요 없다 - 연번 자체가
+ * 하위기관까지 구분하기 때문). 담당자 정보가 없는 행은 담당자 필드만 null이 되고
+ * 행 자체는 그대로 만들어진다.
  */
 @Component
 public class SeedParser {
@@ -62,23 +62,19 @@
                 continue;
             }
 
-            String orgNo = orgNo(row);
+            String orgNo1 = orgNo1(row);
+            String subNo = subNo(row);
             String orgName = string(row, COL_ORG_NAME);
-            if (orgNo.isBlank() || orgName.isBlank()) {
+            if (orgNo1.isBlank() || subNo.isBlank() || orgName.isBlank()) {
                 continue;
             }
 
-            boolean representative = "00".equals(subNo(row));
-            boolean special = ChannelNaming.SPECIAL_ORG_NAME.equalsIgnoreCase(orgName);
-
-            if (!representative && !special) {
-                continue; // 하위기관은 대표기관에 흡수된다
-            }
+            String orgNo = orgNo1 + "_" + subNo;
 
             result.add(new SeedRow(
                     orgNo,
                     orgName,
-                    special ? orgNo + "-policy" : orgNo,
+                    orgNo,
                     nullIfBlank(string(row, COL_DEPT)),
                     nullIfBlank(string(row, COL_MANAGER)),
                     nullIfBlank(string(row, COL_TITLE)),
@@ -88,20 +84,21 @@
         return result;
     }
 
-    /** 연번은 항상 3자리 문자열이다. 숫자로 저장된 셀도 앞자리 0을 복원해 준다. */
-    private String orgNo(Row row) {
+    /** 연번1은 항상 3자리 문자열이다. 숫자로 저장된 셀도 앞자리 0을 복원해 준다. */
+    private String orgNo1(Row row) {
         String raw = string(row, COL_ORG_NO);
-        return raw.isBlank() ? "" : pad3(raw);
+        return raw.isBlank() ? "" : pad(raw, 3);
     }
 
+    /** 연번2도 항상 2자리 문자열이다("0" → "00", "1" → "01"). */
     private String subNo(Row row) {
         String raw = string(row, COL_SUB_NO);
-        return raw.isBlank() ? "" : (raw.length() == 1 ? "0" + raw : raw);
+        return raw.isBlank() ? "" : pad(raw, 2);
     }
 
-    private String pad3(String value) {
+    private String pad(String value, int width) {
         String v = value.trim();
-        return v.length() >= 3 ? v : "0".repeat(3 - v.length()) + v;
+        return v.length() >= width ? v : "0".repeat(width - v.length()) + v;
     }
 
     private String string(Row row, int columnIndex) {
 
src/main/resources/db/migration/V5__org_codes.sql (added)
+++ src/main/resources/db/migration/V5__org_codes.sql
@@ -0,0 +1,10 @@
+-- 기관 코드를 {연번1}_{연번2} 합성 코드로 바꾼다. 예전에는 연번1(기관번호) 하나로
+-- 대표기관에 하위기관을 흡수했지만, 이제는 시트의 모든 행(연번1+연번2)이 각각
+-- 독립된 기관이자 채널 쌍이다. 기존에 만들어진 채널 ID는 org_no만 바꿔서 그대로 보존한다.
+
+-- 치안정책연구소만 예외적으로 하위 연번을 받는다 (구 모델에서 유일하게 분리돼 있던 하위기관)
+update organization set org_no = '008_01', channel_slug = '008_01'
+ where org_name = '경찰청_치안정책연구소' and org_no not like '%\_%' escape '\';
+
+update organization set org_no = org_no || '_00', channel_slug = org_no || '_00'
+ where org_no not like '%\_%' escape '\';
src/test/java/kr/itn/itnhub/provision/ChannelNamingTest.java
--- src/test/java/kr/itn/itnhub/provision/ChannelNamingTest.java
+++ src/test/java/kr/itn/itnhub/provision/ChannelNamingTest.java
@@ -8,35 +8,41 @@
 
     @Test
     void 표시명은_연번_기관명_라벨을_조합한다() {
-        assertThat(ChannelNaming.displayName("001", "국제방송교류재단", "문정원"))
-                .isEqualTo("001_국제방송교류재단 (문정원)");
+        assertThat(ChannelNaming.displayName("001_00", "국제방송교류재단", "문정원"))
+                .isEqualTo("001_00_국제방송교류재단 (문정원)");
 
-        assertThat(ChannelNaming.displayName("001", "국제방송교류재단", "법률검토"))
-                .isEqualTo("001_국제방송교류재단 (법률검토)");
+        assertThat(ChannelNaming.displayName("001_00", "국제방송교류재단", "법률검토"))
+                .isEqualTo("001_00_국제방송교류재단 (법률검토)");
     }
 
     @Test
     void 내부명은_org_연번_구분_형식이다() {
-        assertThat(ChannelNaming.internalName("001", "국제방송교류재단", ChannelKind.MJ))
-                .isEqualTo("org-001-mj");
+        assertThat(ChannelNaming.internalName("001_00", "국제방송교류재단", ChannelKind.MJ))
+                .isEqualTo("org-001-00-mj");
 
-        assertThat(ChannelNaming.internalName("001", "국제방송교류재단", ChannelKind.LAW))
-                .isEqualTo("org-001-law");
+        assertThat(ChannelNaming.internalName("001_00", "국제방송교류재단", ChannelKind.LAW))
+                .isEqualTo("org-001-00-law");
     }
 
     @Test
-    void 치안정책연구소는_policy_구분자가_들어간다() {
-        assertThat(ChannelNaming.internalName("008", "경찰청_치안정책연구소", ChannelKind.MJ))
-                .isEqualTo("org-008-policy-mj");
+    void 치안정책연구소는_합성코드로만_구분되며_더_이상_특별취급하지_않는다() {
+        assertThat(ChannelNaming.displayName("008_01", "경찰청_치안정책연구소", "문정원"))
+                .isEqualTo("008_01_경찰청_치안정책연구소 (문정원)");
 
-        assertThat(ChannelNaming.internalName("008", "경찰청_치안정책연구소", ChannelKind.LAW))
-                .isEqualTo("org-008-policy-law");
+        assertThat(ChannelNaming.internalName("008_01", "경찰청_치안정책연구소", ChannelKind.MJ))
+                .isEqualTo("org-008-01-mj");
+
+        assertThat(ChannelNaming.internalName("008_01", "경찰청_치안정책연구소", ChannelKind.LAW))
+                .isEqualTo("org-008-01-law");
     }
 
     @Test
-    void 같은_연번의_대표기관은_policy가_붙지_않는다() {
-        assertThat(ChannelNaming.internalName("008", "경찰청", ChannelKind.MJ))
-                .isEqualTo("org-008-mj");
+    void 대표기관과_하위기관은_연번_뒷자리로만_구분된다() {
+        assertThat(ChannelNaming.internalName("008_00", "경찰청", ChannelKind.MJ))
+                .isEqualTo("org-008-00-mj");
+
+        assertThat(ChannelNaming.internalName("008_02", "서울청", ChannelKind.MJ))
+                .isEqualTo("org-008-02-mj");
     }
 
     @Test
@@ -50,7 +56,7 @@
 
     @Test
     void 연번의_앞자리_영은_보존된다() {
-        assertThat(ChannelNaming.internalName("001", "기관", ChannelKind.MJ))
-                .startsWith("org-001-");
+        assertThat(ChannelNaming.internalName("001_00", "기관", ChannelKind.MJ))
+                .startsWith("org-001-00-");
     }
 }
src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
--- src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
+++ src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
@@ -180,11 +180,11 @@
     }
 
     @Test
-    void 치안정책연구소는_policy_내부명으로_생성한다() {
+    void 치안정책연구소는_합성코드_008_01로_내부명을_만든다() {
         Organization special = new Organization();
-        special.setOrgNo("008");
+        special.setOrgNo("008_01");
         special.setOrgName("경찰청_치안정책연구소");
-        special.setChannelSlug("008-policy");
+        special.setChannelSlug("008_01");
         mapper.upsertBySeed(special);
 
         Long specialId = mapper.findAll().stream()
@@ -199,9 +199,9 @@
         service.provision(specialId);
 
         verify(mattermost).createPrivateChannel(
-                "org-008-policy-mj", "008_경찰청_치안정책연구소 (문정원)");
+                "org-008-01-mj", "008_01_경찰청_치안정책연구소 (문정원)");
         verify(mattermost).createPrivateChannel(
-                "org-008-policy-law", "008_경찰청_치안정책연구소 (법률검토)");
+                "org-008-01-law", "008_01_경찰청_치안정책연구소 (법률검토)");
     }
 
     @Test
src/test/java/kr/itn/itnhub/seed/SeedParserTest.java
--- src/test/java/kr/itn/itnhub/seed/SeedParserTest.java
+++ src/test/java/kr/itn/itnhub/seed/SeedParserTest.java
@@ -69,9 +69,9 @@
 
         assertThat(rows).hasSize(1);
         SeedRow row = rows.get(0);
-        assertThat(row.orgNo()).isEqualTo("001");
+        assertThat(row.orgNo()).isEqualTo("001_00");
         assertThat(row.orgName()).isEqualTo("국제방송교류재단");
-        assertThat(row.channelSlug()).isEqualTo("001");
+        assertThat(row.channelSlug()).isEqualTo("001_00");
         assertThat(row.deptName()).isEqualTo("데이터정보화팀");
         assertThat(row.managerName()).isEqualTo("송민지");
         assertThat(row.managerTitle()).isEqualTo("과장");
@@ -80,7 +80,7 @@
     }
 
     @Test
-    void 하위기관은_대표기관으로_흡수되어_행이_늘지_않는다() throws Exception {
+    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},
@@ -88,12 +88,17 @@
 
         List<SeedRow> rows = parser.parse(new ByteArrayInputStream(xlsx));
 
-        assertThat(rows).hasSize(1);
-        assertThat(rows.get(0).orgName()).isEqualTo("경찰청");
+        assertThat(rows).hasSize(3);
+        assertThat(rows).extracting(SeedRow::orgNo)
+                .containsExactly("008_00", "008_02", "008_03");
+        assertThat(rows).extracting(SeedRow::orgName)
+                .containsExactly("경찰청", "서울청", "부산청");
+        assertThat(rows).extracting(SeedRow::channelSlug)
+                .containsExactly("008_00", "008_02", "008_03");
     }
 
     @Test
-    void 치안정책연구소는_하위기관이지만_별도_기관으로_남는다() throws Exception {
+    void 치안정책연구소는_더_이상_예외가_아니라_연번2로만_구분되는_평범한_행이다() 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},
@@ -101,11 +106,11 @@
 
         List<SeedRow> rows = parser.parse(new ByteArrayInputStream(xlsx));
 
-        assertThat(rows).hasSize(2);
+        assertThat(rows).hasSize(3);
         assertThat(rows).extracting(SeedRow::orgName)
-                .containsExactly("경찰청", "경찰청_치안정책연구소");
-        assertThat(rows).extracting(SeedRow::channelSlug)
-                .containsExactly("008", "008-policy");
+                .containsExactly("경찰청", "경찰청_치안정책연구소", "서울청");
+        assertThat(rows).extracting(SeedRow::orgNo)
+                .containsExactly("008_00", "008_01", "008_02");
     }
 
     @Test
@@ -115,11 +120,21 @@
                 "정보화담당관", "임난주", "공무직", "044-201-6431", "envlib@korea.kr"});
 
         assertThat(parser.parse(new ByteArrayInputStream(xlsx)).get(0).orgNo())
-                .isEqualTo("007");
+                .isEqualTo("007_00");
     }
 
     @Test
-    void 연번이_숫자로_저장돼_있어도_세자리로_복원한다() throws Exception {
+    void 연번2가_한_자리_숫자여도_두_자리로_복원한다() throws Exception {
+        byte[] xlsx = workbook(new String[]{
+                "008", "1", "치안정책연구소",
+                null, null, null, null, null});
+
+        assertThat(parser.parse(new ByteArrayInputStream(xlsx)).get(0).orgNo())
+                .isEqualTo("008_01");
+    }
+
+    @Test
+    void 연번이_숫자로_저장돼_있어도_세자리_두자리로_복원한다() throws Exception {
         try (XSSFWorkbook wb = new XSSFWorkbook();
              ByteArrayOutputStream out = new ByteArrayOutputStream()) {
 
@@ -134,7 +149,7 @@
 
             List<SeedRow> rows = parser.parse(new ByteArrayInputStream(out.toByteArray()));
 
-            assertThat(rows.get(0).orgNo()).isEqualTo("007");
+            assertThat(rows.get(0).orgNo()).isEqualTo("007_00");
         }
     }
 
@@ -165,7 +180,7 @@
 
             List<SeedRow> rows = parser.parse(new ByteArrayInputStream(out.toByteArray()));
 
-            assertThat(rows.get(0).orgNo()).isEqualTo("007");
+            assertThat(rows.get(0).orgNo()).isEqualTo("007_00");
         }
     }
 
Add a comment
List