이호영 이호영 07-28
feat: 공통코드 12개 그룹 시드
진행단계는 요구사항 [7][11]대로 13단계로 넣고, 진행상태(신청/진행중/완료)와
KPI 임계값을 attrs로 데이터화했다. 요구사항 [2](보고서가 올라와야 최종완료)는
completed 임계값을 13번에 두는 것으로 해결된다.

공공누리 유형은 scopes로 사용처(review/process/prior)를 구분한다. 세 화면의
목록이 7/5/6개로 달랐던 것이 실수가 아니라 자리 차이였기 때문이고, 이 구분 없이
합치면 권리처리 화면에 보류가 잘못 노출된다.

Co-Authored-By: Claude Opus 5 (1M context) 
@db5a94789772a206fff769c2c08878e4872c55f5
src/main/resources/db/migration/V13__common_code.sql
--- src/main/resources/db/migration/V13__common_code.sql
+++ src/main/resources/db/migration/V13__common_code.sql
@@ -29,3 +29,131 @@
 );
 
 create index idx_code_group_sort on code (group_id, sort_order);
+
+
+-- ============================================================
+-- 시드
+-- ============================================================
+
+insert into code_group (group_id, group_name, description, editable) values
+ ('STAGE',           '진행단계',       'organization.stage에 번호로 저장. 단계 추가는 마이그레이션으로 한다', false),
+ ('STAGE_GROUP',     '업무구간',       '칸반 컬럼 색과 카드 모양을 결정한다', false),
+ ('REVIEW_MAJOR',    '권리확인 대분류', null, true),
+ ('REVIEW_MINOR',    '권리확인 세부',   '제3자 권리를 골랐을 때만 노출된다', true),
+ ('REVIEW_RESULT',   '권리확인 처리결과', null, true),
+ ('KOGL_TYPE',       '공공누리 유형',   'scopes로 사용처를 구분한다', true),
+ ('PROCESS_STATUS',  '권리처리 상태',   null, false),
+ ('CONTRACT_DOC',    '계약 서류',       null, true),
+ ('CONTACT_METHOD',  '연락 방법',       null, true),
+ ('CONTACT_CATEGORY','담당자 구분',     null, false),
+ ('ATTACHMENT_YN',   '첨부파일 여부',   null, false),
+ ('KOGL_ATTACHED',   '기존 공공누리 부착 여부', null, false);
+
+-- 진행단계. progress는 대시보드 진행상태 필터(신청/진행중/완료)를,
+-- kpiFrom은 "이 단계부터 해당 KPI에 집계"라는 임계값을 뜻한다.
+-- 원문자(①②)는 저장하지 않는다 - 단계 번호에서 계산한다.
+insert into code (group_id, code, label, sort_order, attrs) values
+ ('STAGE','1', '신청',                    1, '{"groupKey":"intake","progress":"applied"}'::jsonb),
+ ('STAGE','2', '목록접수',                2, '{"groupKey":"intake","progress":"active","kpiFrom":"docSubmitted"}'::jsonb),
+ ('STAGE','3', '예비검토',                3, '{"groupKey":"intake","progress":"active"}'::jsonb),
+ ('STAGE','4', '변호사 배당',             4, '{"groupKey":"assign","progress":"active"}'::jsonb),
+ ('STAGE','5', '법률검토(권리확인)',      5, '{"groupKey":"check","progress":"active"}'::jsonb),
+ ('STAGE','6', 'RE:확인',                 6, '{"groupKey":"check","progress":"active"}'::jsonb),
+ ('STAGE','7', '법률검토(권리확인) 완료', 7, '{"groupKey":"check","progress":"active"}'::jsonb),
+ ('STAGE','8', '법률검토(권리처리)',      8, '{"groupKey":"process","progress":"active"}'::jsonb),
+ ('STAGE','9', 'RE:처리',                 9, '{"groupKey":"process","progress":"active"}'::jsonb),
+ ('STAGE','10','법률검토(권리처리) 완료',10, '{"groupKey":"process","progress":"active"}'::jsonb),
+ ('STAGE','11','법률검토 최종완료',      11, '{"groupKey":"closing","progress":"active"}'::jsonb),
+ ('STAGE','12','보고서 작성',            12, '{"groupKey":"closing","progress":"active","kpiFrom":"reportWriting"}'::jsonb),
+ ('STAGE','13','보고서 제출 완료',       13, '{"groupKey":"closing","progress":"done","kpiFrom":"completed"}'::jsonb);
+
+-- 업무구간. tone은 의미 토큰이며 실제 CSS 클래스는 프론트가 갖는다 - DB에 Tailwind 클래스를
+-- 넣으면 화면 스타일을 바꿀 때마다 DB를 고쳐야 한다.
+-- cardBody는 칸반 카드에 진척 막대를 몇 줄 그릴지를 뜻한다.
+insert into code (group_id, code, label, sort_order, attrs) values
+ ('STAGE_GROUP','intake', '접수',    1, '{"tone":"slate","cardBody":"plain"}'::jsonb),
+ ('STAGE_GROUP','assign', '배당',    2, '{"tone":"violet","cardBody":"assign"}'::jsonb),
+ ('STAGE_GROUP','check',  '권리확인',3, '{"tone":"sky","cardBody":"review1"}'::jsonb),
+ ('STAGE_GROUP','process','권리처리',4, '{"tone":"amber","cardBody":"review2"}'::jsonb),
+ ('STAGE_GROUP','closing','마감',    5, '{"tone":"emerald","cardBody":"plain"}'::jsonb);
+
+-- 권리확인 대분류. suggest는 이 대분류를 고르면 자동으로 채워줄 처리결과,
+-- hasMinor는 세부항목을 노출할지를 뜻한다(제3자 권리에서만 세부가 나온다).
+insert into code (group_id, code, label, sort_order, attrs) values
+ ('REVIEW_MAJOR','만료 저작물',  '만료 저작물',  1, '{"suggest":"신유형 개방","hasMinor":false}'::jsonb),
+ ('REVIEW_MAJOR','업무상 저작물','업무상 저작물',2, '{"suggest":"신유형 개방","hasMinor":false}'::jsonb),
+ ('REVIEW_MAJOR','제3자 권리',   '제3자 권리',   3, '{"hasMinor":true}'::jsonb),
+ ('REVIEW_MAJOR','개인정보 포함','개인정보 포함',4, '{"suggest":"개방불가","hasMinor":false}'::jsonb);
+
+-- 권리확인 세부. code는 저장값 원문이고 label은 화면 표시용(짧은 형태)이다.
+-- note는 라벨 아래 회색 부연설명, allowed는 고를 수 있는 처리결과 제한이다
+-- (2026-07-20 정책변경: 3·4번은 추진/미희망 택일, 초상권은 개방불가까지 3택).
+insert into code (group_id, code, label, sort_order, attrs) values
+ ('REVIEW_MINOR','1. 원시적 권리 전부 보유','1. 원시적 권리 전부 보유',1,
+   '{"suggest":"신유형 개방"}'::jsonb),
+ ('REVIEW_MINOR','2. 후천적 권리 전부 보유(계약서 확인 필요)','2. 후천적 권리 전부 보유',2,
+   '{"note":"(계약에 의한 전부 양수)","suggest":"계약서 등 재확인"}'::jsonb),
+ ('REVIEW_MINOR','3. 권리 일부(공동) 보유','3. 권리 일부(공동) 보유',3,
+   '{"note":"(보도자료·제3자저작물, 계약에 의한 전부/공동 보유 등)","suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망"]}'::jsonb),
+ ('REVIEW_MINOR','4. 권리 미보유','4. 권리 미보유',4,
+   '{"note":"(공모전 수상작 등)","suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망"]}'::jsonb),
+ ('REVIEW_MINOR','초상권 포함','초상권 포함',5,
+   '{"suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망","개방불가"]}'::jsonb);
+
+-- 권리확인 처리결과. needsProcessing/koglSet은 값이고, 실제로 폼을 어떻게 채울지는 코드가 정한다.
+-- koglCopiesPrior가 참이면 기존 부착 유형을 복사하고 비고에 안내문구를 덧붙인다 - 이 "동작"까지
+-- 데이터로 옮기면 attrs 안에 규칙 언어를 만들게 되므로 코드에 남긴다.
+insert into code (group_id, code, label, sort_order, attrs) values
+ ('REVIEW_RESULT','신유형 개방',         '신유형 개방',         1,'{"tone":"emerald","openable":"Y"}'::jsonb),
+ ('REVIEW_RESULT','계약서 등 재확인',    '계약서 등 재확인',    2,'{"tone":"amber"}'::jsonb),
+ ('REVIEW_RESULT','권리처리 추진',       '권리처리 추진',       3,'{"tone":"blue","needsProcessing":true,"koglSet":"보류"}'::jsonb),
+ ('REVIEW_RESULT','개방불가',            '개방불가',            4,'{"tone":"red","needsProcessing":false,"koglSet":"개방불가"}'::jsonb),
+ ('REVIEW_RESULT','권리처리 추진 미희망','권리처리 추진 미희망',5,'{"tone":"slate","needsProcessing":false,"koglCopiesPrior":true}'::jsonb);
+
+-- 공공누리 유형. 세 화면의 목록이 서로 달랐던 이유는 쓰이는 자리가 달라서다.
+--   review  = 변호사 판정값 / process = 권리처리 판정값 / prior = 기존에 부착돼 있던 유형
+-- 이 구분 없이 하나로 합치면 권리처리 화면에 '보류'가 잘못 노출된다.
+-- firstRow는 라디오 첫 줄에 놓을 자리를 뜻한다.
+insert into code (group_id, code, label, sort_order, attrs) values
+ ('KOGL_TYPE','0유형',   '0유형',   1,'{"scopes":["review","process","prior"],"firstRow":["review","process"]}'::jsonb),
+ ('KOGL_TYPE','1유형',   '1유형',   2,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb),
+ ('KOGL_TYPE','2유형',   '2유형',   3,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb),
+ ('KOGL_TYPE','3유형',   '3유형',   4,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb),
+ ('KOGL_TYPE','4유형',   '4유형',   5,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb),
+ ('KOGL_TYPE','개방불가','개방불가',6,'{"scopes":["review","process"],"firstRow":["review","process"]}'::jsonb),
+ ('KOGL_TYPE','보류',    '보류',    7,'{"scopes":["review"],"firstRow":["review"]}'::jsonb);
+
+insert into code (group_id, code, label, sort_order, attrs) values
+ ('PROCESS_STATUS','미처리',  '미처리',  1,'{"done":false}'::jsonb),
+ ('PROCESS_STATUS','처리완료','처리완료',2,'{"done":true,"stampsProcessedAt":true}'::jsonb);
+
+-- freeText가 참인 코드는 '기타:자유텍스트' 형태로 직렬화된다(직렬화 규칙 자체는 코드).
+insert into code (group_id, code, label, sort_order, attrs) values
+ ('CONTRACT_DOC','양도계약서',    '양도계약서',    1,'{}'::jsonb),
+ ('CONTRACT_DOC','제안요청서',    '제안요청서',    2,'{}'::jsonb),
+ ('CONTRACT_DOC','초상이용동의서','초상이용동의서',3,'{}'::jsonb),
+ ('CONTRACT_DOC','공공누리동의서','공공누리동의서',4,'{}'::jsonb),
+ ('CONTRACT_DOC','공문',          '공문',          5,'{}'::jsonb),
+ ('CONTRACT_DOC','기타',          '기타',          6,'{"freeText":true}'::jsonb);
+
+-- 요구사항 [24]는 미응답이라 현행 4종을 그대로 옮긴다. 답이 오면 데이터만 바꾸면 된다.
+-- 지금까지 서버 검증이 없어 아무 값이나 저장됐는데, 코드테이블 도입으로 검증도 생긴다.
+insert into code (group_id, code, label, sort_order, attrs) values
+ ('CONTACT_METHOD','전화','전화',1,'{}'::jsonb),
+ ('CONTACT_METHOD','메일','메일',2,'{}'::jsonb),
+ ('CONTACT_METHOD','방문','방문',3,'{}'::jsonb),
+ ('CONTACT_METHOD','기타','기타',4,'{}'::jsonb);
+
+-- importAlias는 담당자 명부 엑셀의 한글 구분값이다(MemberDirectoryParser가 쓰던 매핑).
+insert into code (group_id, code, label, sort_order, attrs) values
+ ('CONTACT_CATEGORY','APPLICANT','신청기관',       1,'{"tone":"sky","importAlias":"신청기관"}'::jsonb),
+ ('CONTACT_CATEGORY','MJ',       '주관기관',       2,'{"tone":"violet","importAlias":"주관기관"}'::jsonb),
+ ('CONTACT_CATEGORY','LAWYER',   '변호사',         3,'{"tone":"emerald","importAlias":"변호사"}'::jsonb),
+ ('CONTACT_CATEGORY','OPERATOR', '수행기관',       4,'{"tone":"amber","importAlias":"수행기관"}'::jsonb),
+ ('CONTACT_CATEGORY','ITN',      '아이티앤 담당자',5,'{"tone":"slate","importAlias":"아이티앤 담당자"}'::jsonb);
+
+insert into code (group_id, code, label, sort_order, attrs) values
+ ('ATTACHMENT_YN','있음','있음',1,'{}'::jsonb),
+ ('ATTACHMENT_YN','없음','없음',2,'{}'::jsonb),
+ ('KOGL_ATTACHED','부착',  '부착',  1,'{}'::jsonb),
+ ('KOGL_ATTACHED','미부착','미부착',2,'{}'::jsonb);
 
src/test/java/kr/itn/itnhub/code/CodeSeedTest.java (added)
+++ src/test/java/kr/itn/itnhub/code/CodeSeedTest.java
@@ -0,0 +1,151 @@
+package kr.itn.itnhub.code;
+
+import kr.itn.itnhub.AbstractDbTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 시드가 설계서대로 들어갔는지 확인한다. 코드값이 데이터가 된 이상 오타 하나가 화면 선택지를
+ * 통째로 날리므로, 그룹 간 참조가 맞물리는지까지 여기서 잡는다.
+ */
+class CodeSeedTest extends AbstractDbTest {
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @Test
+    void 진행단계는_13개이며_13번이_보고서_제출_완료다() {
+        List<String> labels = jdbc.queryForList(
+                "select label from code where group_id = 'STAGE' order by sort_order", String.class);
+
+        assertThat(labels).hasSize(13);
+        assertThat(labels.get(0)).isEqualTo("신청");
+        assertThat(labels.get(11)).isEqualTo("보고서 작성");
+        assertThat(labels.get(12)).isEqualTo("보고서 제출 완료");
+    }
+
+    @Test
+    void 진행상태는_1번만_신청이고_13번만_완료다() {
+        List<String> applied = jdbc.queryForList(
+                "select code from code where group_id = 'STAGE' and attrs->>'progress' = 'applied'",
+                String.class);
+        List<String> done = jdbc.queryForList(
+                "select code from code where group_id = 'STAGE' and attrs->>'progress' = 'done'",
+                String.class);
+        Integer active = jdbc.queryForObject(
+                "select count(*) from code where group_id = 'STAGE' and attrs->>'progress' = 'active'",
+                Integer.class);
+
+        assertThat(applied).containsExactly("1");
+        assertThat(done).containsExactly("13");
+        assertThat(active).isEqualTo(11);
+    }
+
+    @Test
+    void KPI_임계값이_세_개_지정되어_있다() {
+        assertThat(kpiStage("docSubmitted")).isEqualTo("2");
+        assertThat(kpiStage("reportWriting")).isEqualTo("12");
+        // 요구사항 [2]: 보고서가 실제로 올라온 기관만 완료로 센다.
+        assertThat(kpiStage("completed")).isEqualTo("13");
+    }
+
+    private String kpiStage(String kpi) {
+        return jdbc.queryForObject(
+                "select code from code where group_id = 'STAGE' and attrs->>'kpiFrom' = ?",
+                String.class, kpi);
+    }
+
+    @Test
+    void 모든_단계가_업무구간에_속한다() {
+        Integer orphan = jdbc.queryForObject(
+                "select count(*) from code s where s.group_id = 'STAGE' "
+                        + "and not exists (select 1 from code g where g.group_id = 'STAGE_GROUP' "
+                        + "  and g.code = s.attrs->>'groupKey')",
+                Integer.class);
+
+        assertThat(orphan).isZero();
+    }
+
+    @Test
+    void 공공누리_유형은_사용처별로_다르게_노출된다() {
+        // 세 화면의 목록이 7개/5개/6개로 달랐던 이유는 쓰이는 자리가 달라서다.
+        assertThat(koglScope("review")).hasSize(7);
+        assertThat(koglScope("process")).hasSize(6).doesNotContain("보류");
+        assertThat(koglScope("prior")).containsExactly("0유형", "1유형", "2유형", "3유형", "4유형");
+    }
+
+    private List<String> koglScope(String scope) {
+        return jdbc.queryForList(
+                "select code from code c where c.group_id = 'KOGL_TYPE' "
+                        + "  and exists (select 1 from jsonb_array_elements_text(c.attrs->'scopes') s(v) "
+                        + "    where s.v = ?) "
+                        + "order by sort_order",
+                String.class, scope);
+    }
+
+    @Test
+    void 세부항목의_허용_처리결과는_실제_처리결과_코드다() {
+        Integer orphan = jdbc.queryForObject(
+                "select count(*) from code m, "
+                        + "  lateral jsonb_array_elements_text(coalesce(m.attrs->'allowed', '[]'::jsonb)) a(v) "
+                        + "where m.group_id = 'REVIEW_MINOR' "
+                        + "  and not exists (select 1 from code r "
+                        + "    where r.group_id = 'REVIEW_RESULT' and r.code = a.v)",
+                Integer.class);
+
+        assertThat(orphan).isZero();
+    }
+
+    @Test
+    void 대분류와_세부의_추천_처리결과도_실제_코드다() {
+        Integer orphan = jdbc.queryForObject(
+                "select count(*) from code c "
+                        + "where c.group_id in ('REVIEW_MAJOR', 'REVIEW_MINOR') "
+                        + "  and c.attrs->>'suggest' is not null "
+                        + "  and not exists (select 1 from code r "
+                        + "    where r.group_id = 'REVIEW_RESULT' and r.code = c.attrs->>'suggest')",
+                Integer.class);
+
+        assertThat(orphan).isZero();
+    }
+
+    @Test
+    void 처리결과가_지정하는_공공누리_유형도_실제_코드다() {
+        Integer orphan = jdbc.queryForObject(
+                "select count(*) from code c "
+                        + "where c.group_id = 'REVIEW_RESULT' "
+                        + "  and c.attrs->>'koglSet' is not null "
+                        + "  and not exists (select 1 from code k "
+                        + "    where k.group_id = 'KOGL_TYPE' and k.code = c.attrs->>'koglSet')",
+                Integer.class);
+
+        assertThat(orphan).isZero();
+    }
+
+    @Test
+    void 열두_그룹이_모두_시드된다() {
+        Integer groups = jdbc.queryForObject("select count(*) from code_group", Integer.class);
+        Integer empty = jdbc.queryForObject(
+                "select count(*) from code_group g "
+                        + "where not exists (select 1 from code c where c.group_id = g.group_id)",
+                Integer.class);
+
+        assertThat(groups).isEqualTo(12);
+        assertThat(empty).isZero();
+    }
+
+    @Test
+    void 그룹마다_정렬순서가_중복되지_않는다() {
+        Integer dup = jdbc.queryForObject(
+                "select count(*) from (select group_id, sort_order from code "
+                        + " group by group_id, sort_order having count(*) > 1) t",
+                Integer.class);
+
+        assertThat(dup).isZero();
+    }
+}
Add a comment
List