feat: 공통코드 테이블(code_group, code) 추가
코드값을 소스가 아니라 데이터로 관리하기 위한 기반. 저장값은 지금 업무 테이블에 들어 있는 한글 문자열을 그대로 쓴다 - 영문 키로 바꾸면 기존 데이터 변환과 엑셀 양식 수정이 딸려와 위험 대비 실익이 없다. 설계서와 Phase 1 구현계획도 함께 넣는다. Co-Authored-By: Claude Opus 5 (1M context)
@339a962800e4f48da0411ef5eaba1d186f22ad23
+++ docs/superpowers/plans/2026-07-28-phase1-common-code-foundation.md
... | ... | @@ -0,0 +1,1268 @@ |
| 1 | +# 공통코드 기반 구축 (Phase 1) Implementation Plan | |
| 2 | + | |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | + | |
| 5 | +**Goal:** 코드값을 담을 공통코드 테이블과 조회 경로를 만든다. 기존 화면 동작은 전혀 바뀌지 않는다. | |
| 6 | + | |
| 7 | +**Architecture:** `code_group` / `code` 두 테이블에 11개 그룹 60여 개 코드를 시드한다. 백엔드 `code` 패키지가 이를 읽어 메모리에 캐시하고 `GET /api/meta/codes` 하나로 내려준다. 이 Phase에서는 아무도 이 API를 소비하지 않는다 — 소비 전환은 Phase 2다. | |
| 8 | + | |
| 9 | +**Tech Stack:** Spring Boot 3.3.5, MyBatis, Flyway, PostgreSQL 16, JUnit5 + Testcontainers, AssertJ | |
| 10 | + | |
| 11 | +## Global Constraints | |
| 12 | + | |
| 13 | +- 설계서: `docs/superpowers/specs/2026-07-28-common-code-refactoring-design.md` | |
| 14 | +- 저장값 정책: `code.code`는 현재 DB에 저장 중인 한글 문자열 그대로. 진행단계만 숫자 문자열 `'1'`~`'13'` | |
| 15 | +- Flyway 스키마는 `itnhub`. 다음 마이그레이션 번호는 **V13** | |
| 16 | +- MyBatis 설정은 `map-underscore-to-camel-case: true`, 매퍼 XML 위치는 `classpath:mapper/*.xml` | |
| 17 | +- 매퍼 인터페이스에는 `@Mapper` 애노테이션을 붙인다 | |
| 18 | +- DB 테스트는 `AbstractDbTest`를 상속한다. 컨트롤러 테스트는 `@AutoConfigureMockMvc` + `@WithMockUser(roles = "ADMIN")` | |
| 19 | +- 주석은 한국어로, 기존 코드처럼 "왜 이렇게 했는지"를 적는다 | |
| 20 | +- **이 Phase의 완료 조건: 기존 테스트 40개(백엔드 22 + 프론트 18)가 전부 통과한다** | |
| 21 | +- jsonb는 커스텀 TypeHandler를 만들지 않는다. 조회 시 `attrs::text`로 문자열을 받아 Jackson으로 파싱한다 | |
| 22 | + | |
| 23 | +--- | |
| 24 | + | |
| 25 | +### Task 1: 공통코드 테이블 생성 | |
| 26 | + | |
| 27 | +**Files:** | |
| 28 | +- Create: `src/main/resources/db/migration/V13__common_code.sql` | |
| 29 | +- Test: `src/test/java/kr/itn/itnhub/code/CodeSchemaTest.java` | |
| 30 | + | |
| 31 | +**Interfaces:** | |
| 32 | +- Consumes: 없음 | |
| 33 | +- Produces: `itnhub.code_group(group_id, group_name, description, editable)`, `itnhub.code(group_id, code, label, sort_order, active, attrs)` 테이블 | |
| 34 | + | |
| 35 | +- [ ] **Step 1: 실패하는 테스트를 쓴다** | |
| 36 | + | |
| 37 | +`src/test/java/kr/itn/itnhub/code/CodeSchemaTest.java` | |
| 38 | + | |
| 39 | +```java | |
| 40 | +package kr.itn.itnhub.code; | |
| 41 | + | |
| 42 | +import kr.itn.itnhub.AbstractDbTest; | |
| 43 | +import org.junit.jupiter.api.Test; | |
| 44 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 45 | +import org.springframework.dao.DataIntegrityViolationException; | |
| 46 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 47 | + | |
| 48 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 49 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | |
| 50 | + | |
| 51 | +class CodeSchemaTest extends AbstractDbTest { | |
| 52 | + | |
| 53 | + @Autowired | |
| 54 | + JdbcTemplate jdbc; | |
| 55 | + | |
| 56 | + @Test | |
| 57 | + void 코드_테이블이_생성된다() { | |
| 58 | + Integer count = jdbc.queryForObject( | |
| 59 | + "select count(*) from information_schema.tables " | |
| 60 | + + "where table_schema = 'itnhub' and table_name in ('code_group', 'code')", | |
| 61 | + Integer.class); | |
| 62 | + | |
| 63 | + assertThat(count).isEqualTo(2); | |
| 64 | + } | |
| 65 | + | |
| 66 | + @Test | |
| 67 | + void 그룹과_코드_조합이_유일하다() { | |
| 68 | + jdbc.update("insert into code_group (group_id, group_name) values ('TEST_GRP', '테스트')"); | |
| 69 | + jdbc.update("insert into code (group_id, code, label, sort_order) " | |
| 70 | + + "values ('TEST_GRP', 'A', '가', 1)"); | |
| 71 | + | |
| 72 | + assertThatThrownBy(() -> jdbc.update( | |
| 73 | + "insert into code (group_id, code, label, sort_order) " | |
| 74 | + + "values ('TEST_GRP', 'A', '다른라벨', 2)")) | |
| 75 | + .isInstanceOf(DataIntegrityViolationException.class); | |
| 76 | + } | |
| 77 | + | |
| 78 | + @Test | |
| 79 | + void 없는_그룹의_코드는_넣을_수_없다() { | |
| 80 | + assertThatThrownBy(() -> jdbc.update( | |
| 81 | + "insert into code (group_id, code, label, sort_order) " | |
| 82 | + + "values ('NO_SUCH_GROUP', 'A', '가', 1)")) | |
| 83 | + .isInstanceOf(DataIntegrityViolationException.class); | |
| 84 | + } | |
| 85 | + | |
| 86 | + @Test | |
| 87 | + void attrs는_기본값이_빈_객체다() { | |
| 88 | + jdbc.update("insert into code_group (group_id, group_name) values ('TEST_ATTR', '테스트')"); | |
| 89 | + jdbc.update("insert into code (group_id, code, label, sort_order) " | |
| 90 | + + "values ('TEST_ATTR', 'A', '가', 1)"); | |
| 91 | + | |
| 92 | + String attrs = jdbc.queryForObject( | |
| 93 | + "select attrs::text from code where group_id = 'TEST_ATTR' and code = 'A'", | |
| 94 | + String.class); | |
| 95 | + | |
| 96 | + assertThat(attrs).isEqualTo("{}"); | |
| 97 | + } | |
| 98 | +} | |
| 99 | +``` | |
| 100 | + | |
| 101 | +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** | |
| 102 | + | |
| 103 | +Run: `mvn -q test -Dtest=CodeSchemaTest` | |
| 104 | +Expected: FAIL — `relation "code_group" does not exist` | |
| 105 | + | |
| 106 | +- [ ] **Step 3: 마이그레이션을 쓴다** | |
| 107 | + | |
| 108 | +`src/main/resources/db/migration/V13__common_code.sql` | |
| 109 | + | |
| 110 | +```sql | |
| 111 | +-- 공통코드. 진행단계·처리결과·공공누리유형 같은 선택지를 소스가 아니라 데이터로 관리한다. | |
| 112 | +-- 저장값(code)은 지금 각 업무 테이블에 들어 있는 한글 문자열을 그대로 쓴다. 기존 데이터를 | |
| 113 | +-- 건드리지 않기 위해서다. 진행단계만 예외로 숫자 문자열이며 organization.stage는 smallint 유지. | |
| 114 | + | |
| 115 | +create table code_group ( | |
| 116 | + group_id varchar(50) primary key, | |
| 117 | + group_name varchar(100) not null, | |
| 118 | + description text, | |
| 119 | + -- 관리 화면에서 코드 추가·삭제를 허용할지. 로직과 강하게 묶인 그룹(STAGE 등)은 false로 두고 | |
| 120 | + -- 표시명·순서만 바꾸게 한다. 단계 추가는 마이그레이션으로 한다. | |
| 121 | + editable boolean not null default true | |
| 122 | +); | |
| 123 | + | |
| 124 | +create table code ( | |
| 125 | + group_id varchar(50) not null references code_group(group_id), | |
| 126 | + -- 저장값. review_item.review_minor가 varchar(300)이라 그 길이에 맞춘다. | |
| 127 | + code varchar(300) not null, | |
| 128 | + -- 화면 표시명. 저장값과 다를 수 있다(권리확인 세부항목은 저장값이 길고 표시는 짧다). | |
| 129 | + label varchar(200) not null, | |
| 130 | + sort_order int not null, | |
| 131 | + active boolean not null default true, | |
| 132 | + -- 코드별 부가 속성. 값만 담고 동작은 담지 않는다. | |
| 133 | + attrs jsonb not null default '{}'::jsonb, | |
| 134 | + primary key (group_id, code) | |
| 135 | +); | |
| 136 | + | |
| 137 | +create index idx_code_group_sort on code (group_id, sort_order); | |
| 138 | +``` | |
| 139 | + | |
| 140 | +- [ ] **Step 4: 테스트를 돌려 통과를 확인한다** | |
| 141 | + | |
| 142 | +Run: `mvn -q test -Dtest=CodeSchemaTest` | |
| 143 | +Expected: PASS (4건) | |
| 144 | + | |
| 145 | +- [ ] **Step 5: 커밋** | |
| 146 | + | |
| 147 | +```bash | |
| 148 | +git add src/main/resources/db/migration/V13__common_code.sql src/test/java/kr/itn/itnhub/code/CodeSchemaTest.java | |
| 149 | +git commit -m "feat: 공통코드 테이블(code_group, code) 추가" | |
| 150 | +``` | |
| 151 | + | |
| 152 | +--- | |
| 153 | + | |
| 154 | +### Task 2: 코드 시드 데이터 | |
| 155 | + | |
| 156 | +**Files:** | |
| 157 | +- Modify: `src/main/resources/db/migration/V13__common_code.sql` (하단에 insert 추가) | |
| 158 | +- Test: `src/test/java/kr/itn/itnhub/code/CodeSeedTest.java` | |
| 159 | + | |
| 160 | +**Interfaces:** | |
| 161 | +- Consumes: Task 1의 두 테이블 | |
| 162 | +- Produces: 11개 그룹 60개 코드. 그룹 ID는 `STAGE`, `STAGE_GROUP`, `REVIEW_MAJOR`, `REVIEW_MINOR`, `REVIEW_RESULT`, `KOGL_TYPE`, `PROCESS_STATUS`, `CONTRACT_DOC`, `CONTACT_METHOD`, `CONTACT_CATEGORY`, `ATTACHMENT_YN`, `KOGL_ATTACHED` | |
| 163 | + | |
| 164 | +> 그룹은 12개다. 설계서 표에서 `ATTACHMENT_YN`/`KOGL_ATTACHED`를 한 줄에 묶어 적어 11개로 보였을 뿐이다. | |
| 165 | + | |
| 166 | +- [ ] **Step 1: 실패하는 테스트를 쓴다** | |
| 167 | + | |
| 168 | +`src/test/java/kr/itn/itnhub/code/CodeSeedTest.java` | |
| 169 | + | |
| 170 | +```java | |
| 171 | +package kr.itn.itnhub.code; | |
| 172 | + | |
| 173 | +import kr.itn.itnhub.AbstractDbTest; | |
| 174 | +import org.junit.jupiter.api.Test; | |
| 175 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 176 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 177 | + | |
| 178 | +import java.util.List; | |
| 179 | + | |
| 180 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 181 | + | |
| 182 | +class CodeSeedTest extends AbstractDbTest { | |
| 183 | + | |
| 184 | + @Autowired | |
| 185 | + JdbcTemplate jdbc; | |
| 186 | + | |
| 187 | + @Test | |
| 188 | + void 진행단계는_13개이며_13번이_보고서_제출_완료다() { | |
| 189 | + List<String> labels = jdbc.queryForList( | |
| 190 | + "select label from code where group_id = 'STAGE' order by sort_order", String.class); | |
| 191 | + | |
| 192 | + assertThat(labels).hasSize(13); | |
| 193 | + assertThat(labels.get(0)).isEqualTo("신청"); | |
| 194 | + assertThat(labels.get(11)).isEqualTo("보고서 작성"); | |
| 195 | + assertThat(labels.get(12)).isEqualTo("보고서 제출 완료"); | |
| 196 | + } | |
| 197 | + | |
| 198 | + @Test | |
| 199 | + void 진행상태는_1번만_신청이고_13번만_완료다() { | |
| 200 | + List<String> applied = jdbc.queryForList( | |
| 201 | + "select code from code where group_id = 'STAGE' and attrs->>'progress' = 'applied'", | |
| 202 | + String.class); | |
| 203 | + List<String> done = jdbc.queryForList( | |
| 204 | + "select code from code where group_id = 'STAGE' and attrs->>'progress' = 'done'", | |
| 205 | + String.class); | |
| 206 | + Integer active = jdbc.queryForObject( | |
| 207 | + "select count(*) from code where group_id = 'STAGE' and attrs->>'progress' = 'active'", | |
| 208 | + Integer.class); | |
| 209 | + | |
| 210 | + assertThat(applied).containsExactly("1"); | |
| 211 | + assertThat(done).containsExactly("13"); | |
| 212 | + assertThat(active).isEqualTo(11); | |
| 213 | + } | |
| 214 | + | |
| 215 | + @Test | |
| 216 | + void KPI_임계값이_세_개_지정되어_있다() { | |
| 217 | + assertThat(kpiStage("docSubmitted")).isEqualTo("2"); | |
| 218 | + assertThat(kpiStage("reportWriting")).isEqualTo("12"); | |
| 219 | + assertThat(kpiStage("completed")).isEqualTo("13"); | |
| 220 | + } | |
| 221 | + | |
| 222 | + private String kpiStage(String kpi) { | |
| 223 | + return jdbc.queryForObject( | |
| 224 | + "select code from code where group_id = 'STAGE' and attrs->>'kpiFrom' = ?", | |
| 225 | + String.class, kpi); | |
| 226 | + } | |
| 227 | + | |
| 228 | + @Test | |
| 229 | + void 모든_단계가_업무구간에_속한다() { | |
| 230 | + Integer orphan = jdbc.queryForObject( | |
| 231 | + "select count(*) from code s where s.group_id = 'STAGE' " | |
| 232 | + + "and not exists (select 1 from code g where g.group_id = 'STAGE_GROUP' " | |
| 233 | + + " and g.code = s.attrs->>'groupKey')", | |
| 234 | + Integer.class); | |
| 235 | + | |
| 236 | + assertThat(orphan).isZero(); | |
| 237 | + } | |
| 238 | + | |
| 239 | + @Test | |
| 240 | + void 공공누리_유형은_사용처별로_다르게_노출된다() { | |
| 241 | + // 변호사 판정에는 보류가 있고, 권리처리 판정에는 없고, 기존 부착 유형은 0~4유형만이다. | |
| 242 | + assertThat(koglScope("review")).hasSize(7); | |
| 243 | + assertThat(koglScope("process")).hasSize(6).doesNotContain("보류"); | |
| 244 | + assertThat(koglScope("prior")).containsExactly("0유형", "1유형", "2유형", "3유형", "4유형"); | |
| 245 | + } | |
| 246 | + | |
| 247 | + private List<String> koglScope(String scope) { | |
| 248 | + return jdbc.queryForList( | |
| 249 | + "select code from code where group_id = 'KOGL_TYPE' " | |
| 250 | + + "and attrs->'scopes' ? cast(? as text) order by sort_order", | |
| 251 | + String.class, scope); | |
| 252 | + } | |
| 253 | + | |
| 254 | + @Test | |
| 255 | + void 세부항목의_허용_처리결과는_실제_처리결과_코드다() { | |
| 256 | + Integer orphan = jdbc.queryForObject( | |
| 257 | + "select count(*) from code m, " | |
| 258 | + + " lateral jsonb_array_elements_text(coalesce(m.attrs->'allowed', '[]'::jsonb)) a(v) " | |
| 259 | + + "where m.group_id = 'REVIEW_MINOR' " | |
| 260 | + + " and not exists (select 1 from code r " | |
| 261 | + + " where r.group_id = 'REVIEW_RESULT' and r.code = a.v)", | |
| 262 | + Integer.class); | |
| 263 | + | |
| 264 | + assertThat(orphan).isZero(); | |
| 265 | + } | |
| 266 | + | |
| 267 | + @Test | |
| 268 | + void 열두_그룹이_모두_시드된다() { | |
| 269 | + Integer groups = jdbc.queryForObject( | |
| 270 | + "select count(*) from code_group", Integer.class); | |
| 271 | + Integer empty = jdbc.queryForObject( | |
| 272 | + "select count(*) from code_group g " | |
| 273 | + + "where not exists (select 1 from code c where c.group_id = g.group_id)", | |
| 274 | + Integer.class); | |
| 275 | + | |
| 276 | + assertThat(groups).isEqualTo(12); | |
| 277 | + assertThat(empty).isZero(); | |
| 278 | + } | |
| 279 | + | |
| 280 | + @Test | |
| 281 | + void 그룹마다_정렬순서가_중복되지_않는다() { | |
| 282 | + Integer dup = jdbc.queryForObject( | |
| 283 | + "select count(*) from (select group_id, sort_order from code " | |
| 284 | + + " group by group_id, sort_order having count(*) > 1) t", | |
| 285 | + Integer.class); | |
| 286 | + | |
| 287 | + assertThat(dup).isZero(); | |
| 288 | + } | |
| 289 | +} | |
| 290 | +``` | |
| 291 | + | |
| 292 | +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** | |
| 293 | + | |
| 294 | +Run: `mvn -q test -Dtest=CodeSeedTest` | |
| 295 | +Expected: FAIL — 시드가 없어 `hasSize(13)`이 0으로 깨진다 | |
| 296 | + | |
| 297 | +- [ ] **Step 3: 시드를 `V13__common_code.sql` 하단에 덧붙인다** | |
| 298 | + | |
| 299 | +```sql | |
| 300 | +-- ============================================================ | |
| 301 | +-- 시드 | |
| 302 | +-- ============================================================ | |
| 303 | + | |
| 304 | +insert into code_group (group_id, group_name, description, editable) values | |
| 305 | + ('STAGE', '진행단계', 'organization.stage에 번호로 저장. 단계 추가는 마이그레이션으로 한다', false), | |
| 306 | + ('STAGE_GROUP', '업무구간', '칸반 컬럼 색과 카드 모양을 결정한다', false), | |
| 307 | + ('REVIEW_MAJOR', '권리확인 대분류', null, true), | |
| 308 | + ('REVIEW_MINOR', '권리확인 세부', '제3자 권리를 골랐을 때만 노출된다', true), | |
| 309 | + ('REVIEW_RESULT', '권리확인 처리결과', null, true), | |
| 310 | + ('KOGL_TYPE', '공공누리 유형', 'scopes로 사용처를 구분한다', true), | |
| 311 | + ('PROCESS_STATUS', '권리처리 상태', null, false), | |
| 312 | + ('CONTRACT_DOC', '계약 서류', null, true), | |
| 313 | + ('CONTACT_METHOD', '연락 방법', null, true), | |
| 314 | + ('CONTACT_CATEGORY','담당자 구분', null, false), | |
| 315 | + ('ATTACHMENT_YN', '첨부파일 여부', null, false), | |
| 316 | + ('KOGL_ATTACHED', '기존 공공누리 부착 여부', null, false); | |
| 317 | + | |
| 318 | +-- 진행단계. progress는 대시보드 진행상태 필터(신청/진행중/완료)를, | |
| 319 | +-- kpiFrom은 "이 단계부터 해당 KPI에 집계"라는 임계값을 뜻한다. | |
| 320 | +-- 원문자(①②)는 저장하지 않는다 - 단계 번호에서 계산한다. | |
| 321 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 322 | + ('STAGE','1', '신청', 1, '{"groupKey":"intake","progress":"applied"}'::jsonb), | |
| 323 | + ('STAGE','2', '목록접수', 2, '{"groupKey":"intake","progress":"active","kpiFrom":"docSubmitted"}'::jsonb), | |
| 324 | + ('STAGE','3', '예비검토', 3, '{"groupKey":"intake","progress":"active"}'::jsonb), | |
| 325 | + ('STAGE','4', '변호사 배당', 4, '{"groupKey":"assign","progress":"active"}'::jsonb), | |
| 326 | + ('STAGE','5', '법률검토(권리확인)', 5, '{"groupKey":"check","progress":"active"}'::jsonb), | |
| 327 | + ('STAGE','6', 'RE:확인', 6, '{"groupKey":"check","progress":"active"}'::jsonb), | |
| 328 | + ('STAGE','7', '법률검토(권리확인) 완료', 7, '{"groupKey":"check","progress":"active"}'::jsonb), | |
| 329 | + ('STAGE','8', '법률검토(권리처리)', 8, '{"groupKey":"process","progress":"active"}'::jsonb), | |
| 330 | + ('STAGE','9', 'RE:처리', 9, '{"groupKey":"process","progress":"active"}'::jsonb), | |
| 331 | + ('STAGE','10','법률검토(권리처리) 완료',10, '{"groupKey":"process","progress":"active"}'::jsonb), | |
| 332 | + ('STAGE','11','법률검토 최종완료', 11, '{"groupKey":"closing","progress":"active"}'::jsonb), | |
| 333 | + ('STAGE','12','보고서 작성', 12, '{"groupKey":"closing","progress":"active","kpiFrom":"reportWriting"}'::jsonb), | |
| 334 | + ('STAGE','13','보고서 제출 완료', 13, '{"groupKey":"closing","progress":"done","kpiFrom":"completed"}'::jsonb); | |
| 335 | + | |
| 336 | +-- 업무구간. tone은 의미 토큰이며 실제 CSS 클래스는 프론트가 갖는다. | |
| 337 | +-- cardBody는 칸반 카드에 진척 막대를 몇 줄 그릴지를 뜻한다. | |
| 338 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 339 | + ('STAGE_GROUP','intake', '접수', 1, '{"tone":"slate","cardBody":"plain"}'::jsonb), | |
| 340 | + ('STAGE_GROUP','assign', '배당', 2, '{"tone":"violet","cardBody":"assign"}'::jsonb), | |
| 341 | + ('STAGE_GROUP','check', '권리확인',3, '{"tone":"sky","cardBody":"review1"}'::jsonb), | |
| 342 | + ('STAGE_GROUP','process','권리처리',4, '{"tone":"amber","cardBody":"review2"}'::jsonb), | |
| 343 | + ('STAGE_GROUP','closing','마감', 5, '{"tone":"emerald","cardBody":"plain"}'::jsonb); | |
| 344 | + | |
| 345 | +-- 권리확인 대분류. suggest는 이 대분류를 고르면 자동으로 채워줄 처리결과, | |
| 346 | +-- hasMinor는 세부항목을 노출할지를 뜻한다. | |
| 347 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 348 | + ('REVIEW_MAJOR','만료 저작물', '만료 저작물', 1, '{"suggest":"신유형 개방","hasMinor":false}'::jsonb), | |
| 349 | + ('REVIEW_MAJOR','업무상 저작물','업무상 저작물',2, '{"suggest":"신유형 개방","hasMinor":false}'::jsonb), | |
| 350 | + ('REVIEW_MAJOR','제3자 권리', '제3자 권리', 3, '{"hasMinor":true}'::jsonb), | |
| 351 | + ('REVIEW_MAJOR','개인정보 포함','개인정보 포함',4, '{"suggest":"개방불가","hasMinor":false}'::jsonb); | |
| 352 | + | |
| 353 | +-- 권리확인 세부. code는 저장값 원문이고 label은 화면 표시용(짧은 형태)이다. | |
| 354 | +-- note는 라벨 아래 회색 부연설명, allowed는 고를 수 있는 처리결과 제한이다. | |
| 355 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 356 | + ('REVIEW_MINOR','1. 원시적 권리 전부 보유','1. 원시적 권리 전부 보유',1, | |
| 357 | + '{"suggest":"신유형 개방"}'::jsonb), | |
| 358 | + ('REVIEW_MINOR','2. 후천적 권리 전부 보유(계약서 확인 필요)','2. 후천적 권리 전부 보유',2, | |
| 359 | + '{"note":"(계약에 의한 전부 양수)","suggest":"계약서 등 재확인"}'::jsonb), | |
| 360 | + ('REVIEW_MINOR','3. 권리 일부(공동) 보유','3. 권리 일부(공동) 보유',3, | |
| 361 | + '{"note":"(보도자료·제3자저작물, 계약에 의한 전부/공동 보유 등)","suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망"]}'::jsonb), | |
| 362 | + ('REVIEW_MINOR','4. 권리 미보유','4. 권리 미보유',4, | |
| 363 | + '{"note":"(공모전 수상작 등)","suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망"]}'::jsonb), | |
| 364 | + ('REVIEW_MINOR','초상권 포함','초상권 포함',5, | |
| 365 | + '{"suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망","개방불가"]}'::jsonb); | |
| 366 | + | |
| 367 | +-- 권리확인 처리결과. needsProcessing/koglSet은 값이고, 실제로 폼을 어떻게 채울지는 코드가 정한다. | |
| 368 | +-- koglCopiesPrior가 참이면 기존 부착 유형을 복사하고 비고에 안내문구를 덧붙인다(동작은 코드). | |
| 369 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 370 | + ('REVIEW_RESULT','신유형 개방', '신유형 개방', 1,'{"tone":"emerald","openable":"Y"}'::jsonb), | |
| 371 | + ('REVIEW_RESULT','계약서 등 재확인', '계약서 등 재확인', 2,'{"tone":"amber"}'::jsonb), | |
| 372 | + ('REVIEW_RESULT','권리처리 추진', '권리처리 추진', 3,'{"tone":"blue","needsProcessing":true,"koglSet":"보류"}'::jsonb), | |
| 373 | + ('REVIEW_RESULT','개방불가', '개방불가', 4,'{"tone":"red","needsProcessing":false,"koglSet":"개방불가"}'::jsonb), | |
| 374 | + ('REVIEW_RESULT','권리처리 추진 미희망','권리처리 추진 미희망',5,'{"tone":"slate","needsProcessing":false,"koglCopiesPrior":true}'::jsonb); | |
| 375 | + | |
| 376 | +-- 공공누리 유형. 세 화면의 목록이 서로 달랐던 이유는 쓰이는 자리가 달라서다. | |
| 377 | +-- review = 변호사 판정값 / process = 권리처리 판정값 / prior = 기존에 부착돼 있던 유형 | |
| 378 | +-- firstRow는 라디오 첫 줄에 놓을 자리를 뜻한다. | |
| 379 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 380 | + ('KOGL_TYPE','0유형', '0유형', 1,'{"scopes":["review","process","prior"],"firstRow":["review","process"]}'::jsonb), | |
| 381 | + ('KOGL_TYPE','1유형', '1유형', 2,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb), | |
| 382 | + ('KOGL_TYPE','2유형', '2유형', 3,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb), | |
| 383 | + ('KOGL_TYPE','3유형', '3유형', 4,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb), | |
| 384 | + ('KOGL_TYPE','4유형', '4유형', 5,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb), | |
| 385 | + ('KOGL_TYPE','개방불가','개방불가',6,'{"scopes":["review","process"],"firstRow":["review","process"]}'::jsonb), | |
| 386 | + ('KOGL_TYPE','보류', '보류', 7,'{"scopes":["review"],"firstRow":["review"]}'::jsonb); | |
| 387 | + | |
| 388 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 389 | + ('PROCESS_STATUS','미처리', '미처리', 1,'{"done":false}'::jsonb), | |
| 390 | + ('PROCESS_STATUS','처리완료','처리완료',2,'{"done":true,"stampsProcessedAt":true}'::jsonb); | |
| 391 | + | |
| 392 | +-- freeText가 참인 코드는 '기타:자유텍스트' 형태로 직렬화된다(직렬화 규칙 자체는 코드). | |
| 393 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 394 | + ('CONTRACT_DOC','양도계약서', '양도계약서', 1,'{}'::jsonb), | |
| 395 | + ('CONTRACT_DOC','제안요청서', '제안요청서', 2,'{}'::jsonb), | |
| 396 | + ('CONTRACT_DOC','초상이용동의서','초상이용동의서',3,'{}'::jsonb), | |
| 397 | + ('CONTRACT_DOC','공공누리동의서','공공누리동의서',4,'{}'::jsonb), | |
| 398 | + ('CONTRACT_DOC','공문', '공문', 5,'{}'::jsonb), | |
| 399 | + ('CONTRACT_DOC','기타', '기타', 6,'{"freeText":true}'::jsonb); | |
| 400 | + | |
| 401 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 402 | + ('CONTACT_METHOD','전화','전화',1,'{}'::jsonb), | |
| 403 | + ('CONTACT_METHOD','메일','메일',2,'{}'::jsonb), | |
| 404 | + ('CONTACT_METHOD','방문','방문',3,'{}'::jsonb), | |
| 405 | + ('CONTACT_METHOD','기타','기타',4,'{}'::jsonb); | |
| 406 | + | |
| 407 | +-- importAlias는 담당자 명부 엑셀의 한글 구분값이다(MemberDirectoryParser가 쓰던 매핑). | |
| 408 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 409 | + ('CONTACT_CATEGORY','APPLICANT','신청기관', 1,'{"tone":"sky","importAlias":"신청기관"}'::jsonb), | |
| 410 | + ('CONTACT_CATEGORY','MJ', '주관기관', 2,'{"tone":"violet","importAlias":"주관기관"}'::jsonb), | |
| 411 | + ('CONTACT_CATEGORY','LAWYER', '변호사', 3,'{"tone":"emerald","importAlias":"변호사"}'::jsonb), | |
| 412 | + ('CONTACT_CATEGORY','OPERATOR', '수행기관', 4,'{"tone":"amber","importAlias":"수행기관"}'::jsonb), | |
| 413 | + ('CONTACT_CATEGORY','ITN', '아이티앤 담당자',5,'{"tone":"slate","importAlias":"아이티앤 담당자"}'::jsonb); | |
| 414 | + | |
| 415 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 416 | + ('ATTACHMENT_YN','있음','있음',1,'{}'::jsonb), | |
| 417 | + ('ATTACHMENT_YN','없음','없음',2,'{}'::jsonb), | |
| 418 | + ('KOGL_ATTACHED','부착', '부착', 1,'{}'::jsonb), | |
| 419 | + ('KOGL_ATTACHED','미부착','미부착',2,'{}'::jsonb); | |
| 420 | +``` | |
| 421 | + | |
| 422 | +- [ ] **Step 4: 테스트를 돌려 통과를 확인한다** | |
| 423 | + | |
| 424 | +Run: `mvn -q test -Dtest=CodeSeedTest` | |
| 425 | +Expected: PASS (8건) | |
| 426 | + | |
| 427 | +> `?` 연산자가 MyBatis/JDBC 파라미터 자리표시자와 충돌해 `koglScope`가 깨지면 | |
| 428 | +> `attrs->'scopes' ? cast(? as text)` 대신 | |
| 429 | +> `exists (select 1 from jsonb_array_elements_text(attrs->'scopes') s(v) where s.v = ?)` 로 바꾼다. | |
| 430 | + | |
| 431 | +- [ ] **Step 5: 커밋** | |
| 432 | + | |
| 433 | +```bash | |
| 434 | +git add src/main/resources/db/migration/V13__common_code.sql src/test/java/kr/itn/itnhub/code/CodeSeedTest.java | |
| 435 | +git commit -m "feat: 공통코드 12개 그룹 시드" | |
| 436 | +``` | |
| 437 | + | |
| 438 | +--- | |
| 439 | + | |
| 440 | +### Task 3: 코드 도메인과 매퍼 | |
| 441 | + | |
| 442 | +**Files:** | |
| 443 | +- Create: `src/main/java/kr/itn/itnhub/code/Code.java` | |
| 444 | +- Create: `src/main/java/kr/itn/itnhub/code/CodeGroup.java` | |
| 445 | +- Create: `src/main/java/kr/itn/itnhub/code/CodeRow.java` | |
| 446 | +- Create: `src/main/java/kr/itn/itnhub/code/CodeMapper.java` | |
| 447 | +- Create: `src/main/resources/mapper/CodeMapper.xml` | |
| 448 | +- Test: `src/test/java/kr/itn/itnhub/code/CodeMapperTest.java` | |
| 449 | + | |
| 450 | +**Interfaces:** | |
| 451 | +- Consumes: Task 2의 시드 | |
| 452 | +- Produces: | |
| 453 | + - `record CodeRow(String groupId, String code, String label, int sortOrder, boolean active, String attrsJson)` | |
| 454 | + - `record Code(String groupId, String code, String label, int sortOrder, boolean active, Map<String,Object> attrs)` | |
| 455 | + - `record CodeGroup(String groupId, String groupName, String description, boolean editable)` | |
| 456 | + - `CodeMapper.findAllCodes() : List<CodeRow>`, `CodeMapper.findAllGroups() : List<CodeGroup>` | |
| 457 | + | |
| 458 | +- [ ] **Step 1: 실패하는 테스트를 쓴다** | |
| 459 | + | |
| 460 | +`src/test/java/kr/itn/itnhub/code/CodeMapperTest.java` | |
| 461 | + | |
| 462 | +```java | |
| 463 | +package kr.itn.itnhub.code; | |
| 464 | + | |
| 465 | +import kr.itn.itnhub.AbstractDbTest; | |
| 466 | +import org.junit.jupiter.api.Test; | |
| 467 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 468 | + | |
| 469 | +import java.util.List; | |
| 470 | + | |
| 471 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 472 | + | |
| 473 | +class CodeMapperTest extends AbstractDbTest { | |
| 474 | + | |
| 475 | + @Autowired | |
| 476 | + CodeMapper mapper; | |
| 477 | + | |
| 478 | + @Test | |
| 479 | + void 모든_코드를_그룹과_정렬순서대로_읽는다() { | |
| 480 | + List<CodeRow> rows = mapper.findAllCodes(); | |
| 481 | + | |
| 482 | + assertThat(rows).isNotEmpty(); | |
| 483 | + List<CodeRow> stages = rows.stream().filter(r -> r.groupId().equals("STAGE")).toList(); | |
| 484 | + assertThat(stages).hasSize(13); | |
| 485 | + assertThat(stages.get(0).code()).isEqualTo("1"); | |
| 486 | + assertThat(stages.get(12).label()).isEqualTo("보고서 제출 완료"); | |
| 487 | + } | |
| 488 | + | |
| 489 | + @Test | |
| 490 | + void attrs를_JSON_문자열로_읽는다() { | |
| 491 | + CodeRow stage13 = mapper.findAllCodes().stream() | |
| 492 | + .filter(r -> r.groupId().equals("STAGE") && r.code().equals("13")) | |
| 493 | + .findFirst().orElseThrow(); | |
| 494 | + | |
| 495 | + assertThat(stage13.attrsJson()).contains("\"progress\": \"done\"".replace(" ", "")) | |
| 496 | + .contains("completed"); | |
| 497 | + } | |
| 498 | + | |
| 499 | + @Test | |
| 500 | + void 그룹_목록을_읽는다() { | |
| 501 | + List<CodeGroup> groups = mapper.findAllGroups(); | |
| 502 | + | |
| 503 | + assertThat(groups).hasSize(12); | |
| 504 | + assertThat(groups).anySatisfy(g -> { | |
| 505 | + assertThat(g.groupId()).isEqualTo("STAGE"); | |
| 506 | + assertThat(g.editable()).isFalse(); | |
| 507 | + }); | |
| 508 | + } | |
| 509 | +} | |
| 510 | +``` | |
| 511 | + | |
| 512 | +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** | |
| 513 | + | |
| 514 | +Run: `mvn -q test -Dtest=CodeMapperTest` | |
| 515 | +Expected: FAIL — 컴파일 실패 (`CodeMapper` 없음) | |
| 516 | + | |
| 517 | +- [ ] **Step 3: 도메인과 매퍼를 만든다** | |
| 518 | + | |
| 519 | +`src/main/java/kr/itn/itnhub/code/CodeRow.java` | |
| 520 | + | |
| 521 | +```java | |
| 522 | +package kr.itn.itnhub.code; | |
| 523 | + | |
| 524 | +/** | |
| 525 | + * DB에서 그대로 읽은 코드 한 줄. attrs는 jsonb를 문자열로 받는다 - 커스텀 TypeHandler를 | |
| 526 | + * 만들지 않고 {@link CodeService}가 한 번만 파싱해 캐시에 올린다. | |
| 527 | + */ | |
| 528 | +public record CodeRow( | |
| 529 | + String groupId, | |
| 530 | + String code, | |
| 531 | + String label, | |
| 532 | + int sortOrder, | |
| 533 | + boolean active, | |
| 534 | + String attrsJson) { | |
| 535 | +} | |
| 536 | +``` | |
| 537 | + | |
| 538 | +`src/main/java/kr/itn/itnhub/code/Code.java` | |
| 539 | + | |
| 540 | +```java | |
| 541 | +package kr.itn.itnhub.code; | |
| 542 | + | |
| 543 | +import java.util.Map; | |
| 544 | + | |
| 545 | +/** attrs까지 파싱된 코드. 화면과 서비스가 쓰는 형태다. */ | |
| 546 | +public record Code( | |
| 547 | + String groupId, | |
| 548 | + String code, | |
| 549 | + String label, | |
| 550 | + int sortOrder, | |
| 551 | + boolean active, | |
| 552 | + Map<String, Object> attrs) { | |
| 553 | + | |
| 554 | + /** attrs의 문자열 속성. 없으면 null. */ | |
| 555 | + public String attr(String key) { | |
| 556 | + Object value = attrs.get(key); | |
| 557 | + return value == null ? null : String.valueOf(value); | |
| 558 | + } | |
| 559 | + | |
| 560 | + /** attrs의 불리언 속성. 없으면 false. */ | |
| 561 | + public boolean flag(String key) { | |
| 562 | + return Boolean.TRUE.equals(attrs.get(key)); | |
| 563 | + } | |
| 564 | +} | |
| 565 | +``` | |
| 566 | + | |
| 567 | +`src/main/java/kr/itn/itnhub/code/CodeGroup.java` | |
| 568 | + | |
| 569 | +```java | |
| 570 | +package kr.itn.itnhub.code; | |
| 571 | + | |
| 572 | +public record CodeGroup( | |
| 573 | + String groupId, | |
| 574 | + String groupName, | |
| 575 | + String description, | |
| 576 | + boolean editable) { | |
| 577 | +} | |
| 578 | +``` | |
| 579 | + | |
| 580 | +`src/main/java/kr/itn/itnhub/code/CodeMapper.java` | |
| 581 | + | |
| 582 | +```java | |
| 583 | +package kr.itn.itnhub.code; | |
| 584 | + | |
| 585 | +import org.apache.ibatis.annotations.Mapper; | |
| 586 | + | |
| 587 | +import java.util.List; | |
| 588 | + | |
| 589 | +@Mapper | |
| 590 | +public interface CodeMapper { | |
| 591 | + | |
| 592 | + /** 비활성 코드까지 전부. 활성 필터는 {@link CodeService}가 용도에 따라 건다. */ | |
| 593 | + List<CodeRow> findAllCodes(); | |
| 594 | + | |
| 595 | + List<CodeGroup> findAllGroups(); | |
| 596 | +} | |
| 597 | +``` | |
| 598 | + | |
| 599 | +`src/main/resources/mapper/CodeMapper.xml` | |
| 600 | + | |
| 601 | +```xml | |
| 602 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 603 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 604 | + "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 605 | +<mapper namespace="kr.itn.itnhub.code.CodeMapper"> | |
| 606 | + | |
| 607 | + <!-- | |
| 608 | + attrs는 jsonb지만 ::text로 문자열로 받는다. 커스텀 TypeHandler를 두면 매퍼마다 | |
| 609 | + 등록을 신경써야 하는데, 코드는 기동 시 한 번만 읽으므로 서비스에서 파싱하는 편이 단순하다. | |
| 610 | + --> | |
| 611 | + <select id="findAllCodes" resultType="kr.itn.itnhub.code.CodeRow"> | |
| 612 | + select group_id, code, label, sort_order, active, attrs::text as attrs_json | |
| 613 | + from code | |
| 614 | + order by group_id, sort_order | |
| 615 | + </select> | |
| 616 | + | |
| 617 | + <select id="findAllGroups" resultType="kr.itn.itnhub.code.CodeGroup"> | |
| 618 | + select group_id, group_name, description, editable | |
| 619 | + from code_group | |
| 620 | + order by group_id | |
| 621 | + </select> | |
| 622 | + | |
| 623 | +</mapper> | |
| 624 | +``` | |
| 625 | + | |
| 626 | +- [ ] **Step 4: 테스트를 돌려 통과를 확인한다** | |
| 627 | + | |
| 628 | +Run: `mvn -q test -Dtest=CodeMapperTest` | |
| 629 | +Expected: PASS (3건) | |
| 630 | + | |
| 631 | +- [ ] **Step 5: 커밋** | |
| 632 | + | |
| 633 | +```bash | |
| 634 | +git add src/main/java/kr/itn/itnhub/code src/main/resources/mapper/CodeMapper.xml src/test/java/kr/itn/itnhub/code/CodeMapperTest.java | |
| 635 | +git commit -m "feat: 코드 도메인과 매퍼 추가" | |
| 636 | +``` | |
| 637 | + | |
| 638 | +--- | |
| 639 | + | |
| 640 | +### Task 4: 코드 캐시 서비스 | |
| 641 | + | |
| 642 | +**Files:** | |
| 643 | +- Create: `src/main/java/kr/itn/itnhub/code/CodeService.java` | |
| 644 | +- Create: `src/main/java/kr/itn/itnhub/code/Codes.java` | |
| 645 | +- Test: `src/test/java/kr/itn/itnhub/code/CodeServiceTest.java` | |
| 646 | + | |
| 647 | +**Interfaces:** | |
| 648 | +- Consumes: `CodeMapper.findAllCodes()`, `CodeMapper.findAllGroups()` | |
| 649 | +- Produces: | |
| 650 | + - `CodeService.all() : Map<String, List<Code>>` — 활성 코드만, 그룹별 정렬순 | |
| 651 | + - `CodeService.group(String groupId) : List<Code>` — 활성 코드만 | |
| 652 | + - `CodeService.find(String groupId, String code) : Optional<Code>` — 비활성 포함 | |
| 653 | + - `CodeService.scoped(String groupId, String scope) : List<Code>` — `attrs.scopes`에 scope가 든 활성 코드 | |
| 654 | + - `CodeService.isValid(String groupId, String code) : boolean` — 활성 코드인지 | |
| 655 | + - `CodeService.reload() : void` | |
| 656 | + - `Codes.STAGE`, `Codes.REVIEW_RESULT`, `Codes.KOGL_TYPE`, `Codes.PROCESS_STATUS`, `Codes.CONTACT_METHOD`, `Codes.CONTACT_CATEGORY`, `Codes.REVIEW_MAJOR`, `Codes.REVIEW_MINOR`, `Codes.STAGE_GROUP`, `Codes.CONTRACT_DOC`, `Codes.ATTACHMENT_YN`, `Codes.KOGL_ATTACHED` (그룹 ID 상수) | |
| 657 | + - `Codes.SCOPE_REVIEW`, `Codes.SCOPE_PROCESS`, `Codes.SCOPE_PRIOR` | |
| 658 | + | |
| 659 | +- [ ] **Step 1: 실패하는 테스트를 쓴다** | |
| 660 | + | |
| 661 | +`src/test/java/kr/itn/itnhub/code/CodeServiceTest.java` | |
| 662 | + | |
| 663 | +```java | |
| 664 | +package kr.itn.itnhub.code; | |
| 665 | + | |
| 666 | +import kr.itn.itnhub.AbstractDbTest; | |
| 667 | +import org.junit.jupiter.api.AfterEach; | |
| 668 | +import org.junit.jupiter.api.Test; | |
| 669 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 670 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 671 | + | |
| 672 | +import java.util.List; | |
| 673 | + | |
| 674 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 675 | + | |
| 676 | +class CodeServiceTest extends AbstractDbTest { | |
| 677 | + | |
| 678 | + @Autowired | |
| 679 | + CodeService service; | |
| 680 | + | |
| 681 | + @Autowired | |
| 682 | + JdbcTemplate jdbc; | |
| 683 | + | |
| 684 | + @AfterEach | |
| 685 | + void restore() { | |
| 686 | + jdbc.update("update code set active = true where group_id = 'CONTACT_METHOD'"); | |
| 687 | + service.reload(); | |
| 688 | + } | |
| 689 | + | |
| 690 | + @Test | |
| 691 | + void 그룹을_정렬순서대로_돌려준다() { | |
| 692 | + List<Code> stages = service.group(Codes.STAGE); | |
| 693 | + | |
| 694 | + assertThat(stages).hasSize(13); | |
| 695 | + assertThat(stages.get(0).code()).isEqualTo("1"); | |
| 696 | + assertThat(stages.get(12).label()).isEqualTo("보고서 제출 완료"); | |
| 697 | + } | |
| 698 | + | |
| 699 | + @Test | |
| 700 | + void attrs가_맵으로_파싱된다() { | |
| 701 | + Code stage13 = service.find(Codes.STAGE, "13").orElseThrow(); | |
| 702 | + | |
| 703 | + assertThat(stage13.attr("progress")).isEqualTo("done"); | |
| 704 | + assertThat(stage13.attr("kpiFrom")).isEqualTo("completed"); | |
| 705 | + assertThat(stage13.attr("groupKey")).isEqualTo("closing"); | |
| 706 | + } | |
| 707 | + | |
| 708 | + @Test | |
| 709 | + void 불리언_속성을_읽는다() { | |
| 710 | + assertThat(service.find(Codes.PROCESS_STATUS, "처리완료").orElseThrow().flag("done")).isTrue(); | |
| 711 | + assertThat(service.find(Codes.PROCESS_STATUS, "미처리").orElseThrow().flag("done")).isFalse(); | |
| 712 | + // 속성이 아예 없는 코드도 false여야 한다. | |
| 713 | + assertThat(service.find(Codes.CONTRACT_DOC, "공문").orElseThrow().flag("freeText")).isFalse(); | |
| 714 | + } | |
| 715 | + | |
| 716 | + @Test | |
| 717 | + void 사용처별로_공공누리_유형을_거른다() { | |
| 718 | + assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_REVIEW)).hasSize(7); | |
| 719 | + assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_PROCESS)) | |
| 720 | + .hasSize(6) | |
| 721 | + .noneMatch(c -> c.code().equals("보류")); | |
| 722 | + assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_PRIOR)) | |
| 723 | + .extracting(Code::code) | |
| 724 | + .containsExactly("0유형", "1유형", "2유형", "3유형", "4유형"); | |
| 725 | + } | |
| 726 | + | |
| 727 | + @Test | |
| 728 | + void 비활성_코드는_목록에서_빠지지만_조회는_된다() { | |
| 729 | + jdbc.update("update code set active = false " | |
| 730 | + + "where group_id = 'CONTACT_METHOD' and code = '방문'"); | |
| 731 | + service.reload(); | |
| 732 | + | |
| 733 | + assertThat(service.group(Codes.CONTACT_METHOD)) | |
| 734 | + .extracting(Code::code) | |
| 735 | + .containsExactly("전화", "메일", "기타"); | |
| 736 | + // 이미 '방문'으로 저장된 과거 데이터를 화면에 그리려면 조회는 되어야 한다. | |
| 737 | + assertThat(service.find(Codes.CONTACT_METHOD, "방문")).isPresent(); | |
| 738 | + } | |
| 739 | + | |
| 740 | + @Test | |
| 741 | + void 유효성_검사는_활성_코드만_통과시킨다() { | |
| 742 | + assertThat(service.isValid(Codes.PROCESS_STATUS, "처리완료")).isTrue(); | |
| 743 | + assertThat(service.isValid(Codes.PROCESS_STATUS, "없는값")).isFalse(); | |
| 744 | + | |
| 745 | + jdbc.update("update code set active = false " | |
| 746 | + + "where group_id = 'CONTACT_METHOD' and code = '방문'"); | |
| 747 | + service.reload(); | |
| 748 | + | |
| 749 | + assertThat(service.isValid(Codes.CONTACT_METHOD, "방문")).isFalse(); | |
| 750 | + } | |
| 751 | + | |
| 752 | + @Test | |
| 753 | + void 전체_조회는_활성_코드만_그룹별로_담는다() { | |
| 754 | + assertThat(service.all()) | |
| 755 | + .containsKeys(Codes.STAGE, Codes.KOGL_TYPE, Codes.CONTACT_CATEGORY) | |
| 756 | + .hasSize(12); | |
| 757 | + } | |
| 758 | + | |
| 759 | + @Test | |
| 760 | + void 없는_그룹은_빈_목록이다() { | |
| 761 | + assertThat(service.group("NO_SUCH_GROUP")).isEmpty(); | |
| 762 | + } | |
| 763 | +} | |
| 764 | +``` | |
| 765 | + | |
| 766 | +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** | |
| 767 | + | |
| 768 | +Run: `mvn -q test -Dtest=CodeServiceTest` | |
| 769 | +Expected: FAIL — 컴파일 실패 (`CodeService` 없음) | |
| 770 | + | |
| 771 | +- [ ] **Step 3: 서비스와 상수를 만든다** | |
| 772 | + | |
| 773 | +`src/main/java/kr/itn/itnhub/code/Codes.java` | |
| 774 | + | |
| 775 | +```java | |
| 776 | +package kr.itn.itnhub.code; | |
| 777 | + | |
| 778 | +/** | |
| 779 | + * 코드 그룹 ID와, 로직이 직접 참조해야 하는 코드값 상수. 소스에 한글 리터럴을 흩뿌리지 않기 | |
| 780 | + * 위한 단일 지점이다. 화면에 그리는 선택지는 여기가 아니라 DB에서 온다. | |
| 781 | + */ | |
| 782 | +public final class Codes { | |
| 783 | + | |
| 784 | + private Codes() { | |
| 785 | + } | |
| 786 | + | |
| 787 | + public static final String STAGE = "STAGE"; | |
| 788 | + public static final String STAGE_GROUP = "STAGE_GROUP"; | |
| 789 | + public static final String REVIEW_MAJOR = "REVIEW_MAJOR"; | |
| 790 | + public static final String REVIEW_MINOR = "REVIEW_MINOR"; | |
| 791 | + public static final String REVIEW_RESULT = "REVIEW_RESULT"; | |
| 792 | + public static final String KOGL_TYPE = "KOGL_TYPE"; | |
| 793 | + public static final String PROCESS_STATUS = "PROCESS_STATUS"; | |
| 794 | + public static final String CONTRACT_DOC = "CONTRACT_DOC"; | |
| 795 | + public static final String CONTACT_METHOD = "CONTACT_METHOD"; | |
| 796 | + public static final String CONTACT_CATEGORY = "CONTACT_CATEGORY"; | |
| 797 | + public static final String ATTACHMENT_YN = "ATTACHMENT_YN"; | |
| 798 | + public static final String KOGL_ATTACHED = "KOGL_ATTACHED"; | |
| 799 | + | |
| 800 | + /** 공공누리 유형이 쓰이는 자리. 같은 코드체계라도 자리마다 노출 목록이 다르다. */ | |
| 801 | + public static final String SCOPE_REVIEW = "review"; | |
| 802 | + public static final String SCOPE_PROCESS = "process"; | |
| 803 | + public static final String SCOPE_PRIOR = "prior"; | |
| 804 | + | |
| 805 | + /** attrs 키. */ | |
| 806 | + public static final String ATTR_SCOPES = "scopes"; | |
| 807 | + public static final String ATTR_PROGRESS = "progress"; | |
| 808 | + public static final String ATTR_KPI_FROM = "kpiFrom"; | |
| 809 | + public static final String ATTR_GROUP_KEY = "groupKey"; | |
| 810 | + public static final String ATTR_DONE = "done"; | |
| 811 | +} | |
| 812 | +``` | |
| 813 | + | |
| 814 | +`src/main/java/kr/itn/itnhub/code/CodeService.java` | |
| 815 | + | |
| 816 | +```java | |
| 817 | +package kr.itn.itnhub.code; | |
| 818 | + | |
| 819 | +import com.fasterxml.jackson.core.type.TypeReference; | |
| 820 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 821 | +import org.springframework.stereotype.Service; | |
| 822 | + | |
| 823 | +import java.util.Collections; | |
| 824 | +import java.util.LinkedHashMap; | |
| 825 | +import java.util.List; | |
| 826 | +import java.util.Map; | |
| 827 | +import java.util.Optional; | |
| 828 | + | |
| 829 | +/** | |
| 830 | + * 코드값을 기동 후 첫 요청에 한 번 읽어 메모리에 들고 있는다. 코드는 관리 화면에서 가끔 | |
| 831 | + * 바뀔 뿐이라 매 요청 조회가 낭비이고, 단일 인스턴스 운영이라 분산 캐시는 필요 없다. | |
| 832 | + * | |
| 833 | + * <p>지연 로딩을 쓰는 이유는 Flyway 마이그레이션이 끝나기 전에 빈 초기화가 돌 수 있기 | |
| 834 | + * 때문이다. 첫 조회 시점에는 마이그레이션이 반드시 끝나 있다.</p> | |
| 835 | + */ | |
| 836 | +@Service | |
| 837 | +public class CodeService { | |
| 838 | + | |
| 839 | + private final CodeMapper mapper; | |
| 840 | + private final ObjectMapper json = new ObjectMapper(); | |
| 841 | + | |
| 842 | + /** 그룹ID -> 활성 코드(정렬순). */ | |
| 843 | + private volatile Map<String, List<Code>> activeByGroup; | |
| 844 | + /** 그룹ID -> 전체 코드(비활성 포함). 과거 데이터를 화면에 그릴 때 라벨이 필요하다. */ | |
| 845 | + private volatile Map<String, Map<String, Code>> allByGroup; | |
| 846 | + | |
| 847 | + public CodeService(CodeMapper mapper) { | |
| 848 | + this.mapper = mapper; | |
| 849 | + } | |
| 850 | + | |
| 851 | + /** 관리 화면에서 코드를 고친 뒤 부른다. */ | |
| 852 | + public synchronized void reload() { | |
| 853 | + Map<String, List<Code>> active = new LinkedHashMap<>(); | |
| 854 | + Map<String, Map<String, Code>> all = new LinkedHashMap<>(); | |
| 855 | + | |
| 856 | + for (CodeRow row : mapper.findAllCodes()) { | |
| 857 | + Code code = toCode(row); | |
| 858 | + all.computeIfAbsent(code.groupId(), k -> new LinkedHashMap<>()) | |
| 859 | + .put(code.code(), code); | |
| 860 | + if (code.active()) { | |
| 861 | + active.computeIfAbsent(code.groupId(), k -> new java.util.ArrayList<>()).add(code); | |
| 862 | + } | |
| 863 | + } | |
| 864 | + | |
| 865 | + this.activeByGroup = active; | |
| 866 | + this.allByGroup = all; | |
| 867 | + } | |
| 868 | + | |
| 869 | + private Code toCode(CodeRow row) { | |
| 870 | + Map<String, Object> attrs; | |
| 871 | + try { | |
| 872 | + attrs = json.readValue(row.attrsJson(), new TypeReference<Map<String, Object>>() { | |
| 873 | + }); | |
| 874 | + } catch (Exception e) { | |
| 875 | + // 잘못된 JSON이 들어와도 전체 코드 로딩을 막지는 않는다. 해당 코드만 속성이 빈다. | |
| 876 | + attrs = Map.of(); | |
| 877 | + } | |
| 878 | + return new Code(row.groupId(), row.code(), row.label(), row.sortOrder(), row.active(), attrs); | |
| 879 | + } | |
| 880 | + | |
| 881 | + private void ensureLoaded() { | |
| 882 | + if (activeByGroup == null) { | |
| 883 | + reload(); | |
| 884 | + } | |
| 885 | + } | |
| 886 | + | |
| 887 | + /** 활성 코드만, 그룹별 정렬순. */ | |
| 888 | + public Map<String, List<Code>> all() { | |
| 889 | + ensureLoaded(); | |
| 890 | + return activeByGroup; | |
| 891 | + } | |
| 892 | + | |
| 893 | + /** 활성 코드만. 없는 그룹이면 빈 목록. */ | |
| 894 | + public List<Code> group(String groupId) { | |
| 895 | + ensureLoaded(); | |
| 896 | + return activeByGroup.getOrDefault(groupId, List.of()); | |
| 897 | + } | |
| 898 | + | |
| 899 | + /** 비활성 코드도 찾는다 - 과거 데이터의 라벨을 그려야 하기 때문이다. */ | |
| 900 | + public Optional<Code> find(String groupId, String code) { | |
| 901 | + ensureLoaded(); | |
| 902 | + return Optional.ofNullable(allByGroup.getOrDefault(groupId, Map.of()).get(code)); | |
| 903 | + } | |
| 904 | + | |
| 905 | + /** attrs.scopes에 해당 자리가 든 활성 코드만. 공공누리 유형이 이걸 쓴다. */ | |
| 906 | + public List<Code> scoped(String groupId, String scope) { | |
| 907 | + return group(groupId).stream().filter(c -> scopes(c).contains(scope)).toList(); | |
| 908 | + } | |
| 909 | + | |
| 910 | + @SuppressWarnings("unchecked") | |
| 911 | + private List<String> scopes(Code code) { | |
| 912 | + Object raw = code.attrs().get(Codes.ATTR_SCOPES); | |
| 913 | + return raw instanceof List<?> list ? (List<String>) list : Collections.emptyList(); | |
| 914 | + } | |
| 915 | + | |
| 916 | + /** 저장 요청 값이 이 그룹의 활성 코드인지. */ | |
| 917 | + public boolean isValid(String groupId, String code) { | |
| 918 | + return group(groupId).stream().anyMatch(c -> c.code().equals(code)); | |
| 919 | + } | |
| 920 | + | |
| 921 | + public List<CodeGroup> groups() { | |
| 922 | + return mapper.findAllGroups(); | |
| 923 | + } | |
| 924 | +} | |
| 925 | +``` | |
| 926 | + | |
| 927 | +- [ ] **Step 4: 테스트를 돌려 통과를 확인한다** | |
| 928 | + | |
| 929 | +Run: `mvn -q test -Dtest=CodeServiceTest` | |
| 930 | +Expected: PASS (8건) | |
| 931 | + | |
| 932 | +- [ ] **Step 5: 커밋** | |
| 933 | + | |
| 934 | +```bash | |
| 935 | +git add src/main/java/kr/itn/itnhub/code/CodeService.java src/main/java/kr/itn/itnhub/code/Codes.java src/test/java/kr/itn/itnhub/code/CodeServiceTest.java | |
| 936 | +git commit -m "feat: 코드 캐시 서비스와 그룹 상수" | |
| 937 | +``` | |
| 938 | + | |
| 939 | +--- | |
| 940 | + | |
| 941 | +### Task 5: 코드 조회 API | |
| 942 | + | |
| 943 | +**Files:** | |
| 944 | +- Create: `src/main/java/kr/itn/itnhub/code/CodeController.java` | |
| 945 | +- Test: `src/test/java/kr/itn/itnhub/code/CodeControllerTest.java` | |
| 946 | + | |
| 947 | +**Interfaces:** | |
| 948 | +- Consumes: `CodeService.all()` | |
| 949 | +- Produces: `GET /api/meta/codes` — `{ "STAGE": [{code,label,sortOrder,attrs}, ...], "KOGL_TYPE": [...] }` | |
| 950 | + | |
| 951 | +- [ ] **Step 1: 실패하는 테스트를 쓴다** | |
| 952 | + | |
| 953 | +`src/test/java/kr/itn/itnhub/code/CodeControllerTest.java` | |
| 954 | + | |
| 955 | +```java | |
| 956 | +package kr.itn.itnhub.code; | |
| 957 | + | |
| 958 | +import kr.itn.itnhub.AbstractDbTest; | |
| 959 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 960 | +import org.junit.jupiter.api.Test; | |
| 961 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 962 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 963 | +import org.springframework.boot.test.mock.mockito.MockBean; | |
| 964 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 965 | +import org.springframework.test.web.servlet.MockMvc; | |
| 966 | + | |
| 967 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 968 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 969 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 970 | + | |
| 971 | +@AutoConfigureMockMvc | |
| 972 | +class CodeControllerTest extends AbstractDbTest { | |
| 973 | + | |
| 974 | + @Autowired | |
| 975 | + MockMvc mvc; | |
| 976 | + | |
| 977 | + @MockBean | |
| 978 | + MattermostClient mattermost; | |
| 979 | + | |
| 980 | + @Test | |
| 981 | + @WithMockUser(roles = "ADMIN") | |
| 982 | + void 전체_코드를_그룹별로_내려준다() throws Exception { | |
| 983 | + mvc.perform(get("/api/meta/codes")) | |
| 984 | + .andExpect(status().isOk()) | |
| 985 | + .andExpect(jsonPath("$.STAGE.length()").value(13)) | |
| 986 | + .andExpect(jsonPath("$.STAGE[12].code").value("13")) | |
| 987 | + .andExpect(jsonPath("$.STAGE[12].label").value("보고서 제출 완료")) | |
| 988 | + .andExpect(jsonPath("$.STAGE[12].attrs.progress").value("done")) | |
| 989 | + .andExpect(jsonPath("$.KOGL_TYPE.length()").value(7)) | |
| 990 | + .andExpect(jsonPath("$.CONTACT_CATEGORY[0].code").value("APPLICANT")); | |
| 991 | + } | |
| 992 | + | |
| 993 | + @Test | |
| 994 | + void 인증이_없으면_401이다() throws Exception { | |
| 995 | + mvc.perform(get("/api/meta/codes")) | |
| 996 | + .andExpect(status().isUnauthorized()); | |
| 997 | + } | |
| 998 | +} | |
| 999 | +``` | |
| 1000 | + | |
| 1001 | +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** | |
| 1002 | + | |
| 1003 | +Run: `mvn -q test -Dtest=CodeControllerTest` | |
| 1004 | +Expected: FAIL — 404 (엔드포인트 없음) | |
| 1005 | + | |
| 1006 | +- [ ] **Step 3: 컨트롤러를 만든다** | |
| 1007 | + | |
| 1008 | +`src/main/java/kr/itn/itnhub/code/CodeController.java` | |
| 1009 | + | |
| 1010 | +```java | |
| 1011 | +package kr.itn.itnhub.code; | |
| 1012 | + | |
| 1013 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 1014 | +import org.springframework.web.bind.annotation.RestController; | |
| 1015 | + | |
| 1016 | +import java.util.List; | |
| 1017 | +import java.util.Map; | |
| 1018 | + | |
| 1019 | +/** | |
| 1020 | + * 프론트가 로그인 직후 한 번 불러 모든 선택지를 받아가는 엔드포인트. 화면마다 따로 부르지 | |
| 1021 | + * 않는 이유는 코드가 자주 안 바뀌고, 화면이 옵션을 기다리느라 깜빡이는 것을 피하기 위해서다. | |
| 1022 | + */ | |
| 1023 | +@RestController | |
| 1024 | +public class CodeController { | |
| 1025 | + | |
| 1026 | + private final CodeService codeService; | |
| 1027 | + | |
| 1028 | + public CodeController(CodeService codeService) { | |
| 1029 | + this.codeService = codeService; | |
| 1030 | + } | |
| 1031 | + | |
| 1032 | + @GetMapping("/api/meta/codes") | |
| 1033 | + public Map<String, List<Code>> codes() { | |
| 1034 | + return codeService.all(); | |
| 1035 | + } | |
| 1036 | +} | |
| 1037 | +``` | |
| 1038 | + | |
| 1039 | +- [ ] **Step 4: 테스트를 돌려 통과를 확인한다** | |
| 1040 | + | |
| 1041 | +Run: `mvn -q test -Dtest=CodeControllerTest` | |
| 1042 | +Expected: PASS (2건) | |
| 1043 | + | |
| 1044 | +- [ ] **Step 5: 커밋** | |
| 1045 | + | |
| 1046 | +```bash | |
| 1047 | +git add src/main/java/kr/itn/itnhub/code/CodeController.java src/test/java/kr/itn/itnhub/code/CodeControllerTest.java | |
| 1048 | +git commit -m "feat: 코드 조회 API(/api/meta/codes) 추가" | |
| 1049 | +``` | |
| 1050 | + | |
| 1051 | +--- | |
| 1052 | + | |
| 1053 | +### Task 6: 기존 데이터 정합성 점검 | |
| 1054 | + | |
| 1055 | +운영 DB에는 시드와 다른 값이 남아 있을 수 있다(정책 변경 전 값, 공백 차이, 오타). | |
| 1056 | +Phase 2에서 화면이 코드테이블만 보게 바뀌면 그런 값은 선택지에서 사라진다. | |
| 1057 | +**먼저 찾아내고 비활성 코드로 보존한다.** | |
| 1058 | + | |
| 1059 | +**Files:** | |
| 1060 | +- Create: `src/main/resources/db/consistency/check-orphan-codes.sql` | |
| 1061 | +- Test: `src/test/java/kr/itn/itnhub/code/CodeConsistencyTest.java` | |
| 1062 | + | |
| 1063 | +**Interfaces:** | |
| 1064 | +- Consumes: `CodeService.isValid(groupId, code)` | |
| 1065 | +- Produces: 운영 적용 전 실행할 점검 쿼리, 그리고 테스트 DB에서 정합성이 깨지지 않음을 보장하는 테스트 | |
| 1066 | + | |
| 1067 | +- [ ] **Step 1: 실패하는 테스트를 쓴다** | |
| 1068 | + | |
| 1069 | +`src/test/java/kr/itn/itnhub/code/CodeConsistencyTest.java` | |
| 1070 | + | |
| 1071 | +```java | |
| 1072 | +package kr.itn.itnhub.code; | |
| 1073 | + | |
| 1074 | +import kr.itn.itnhub.AbstractDbTest; | |
| 1075 | +import org.junit.jupiter.api.Test; | |
| 1076 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 1077 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 1078 | + | |
| 1079 | +import java.util.List; | |
| 1080 | + | |
| 1081 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 1082 | + | |
| 1083 | +/** | |
| 1084 | + * 업무 테이블에 저장된 값이 코드테이블에 다 있는지 확인한다. 운영 DB에 정책 변경 전 | |
| 1085 | + * 값이 남아 있으면 Phase 2에서 화면 선택지가 사라지므로, 배포 전에 이 점검을 돌려 | |
| 1086 | + * 발견된 값을 active=false 코드로 추가해야 한다. | |
| 1087 | + */ | |
| 1088 | +class CodeConsistencyTest extends AbstractDbTest { | |
| 1089 | + | |
| 1090 | + @Autowired | |
| 1091 | + JdbcTemplate jdbc; | |
| 1092 | + | |
| 1093 | + @Autowired | |
| 1094 | + CodeService codeService; | |
| 1095 | + | |
| 1096 | + @Test | |
| 1097 | + void 저장된_처리결과가_모두_코드테이블에_있다() { | |
| 1098 | + assertThat(orphans("review_item", "review_result", Codes.REVIEW_RESULT)).isEmpty(); | |
| 1099 | + } | |
| 1100 | + | |
| 1101 | + @Test | |
| 1102 | + void 저장된_처리상태가_모두_코드테이블에_있다() { | |
| 1103 | + assertThat(orphans("process_item", "process_status", Codes.PROCESS_STATUS)).isEmpty(); | |
| 1104 | + } | |
| 1105 | + | |
| 1106 | + @Test | |
| 1107 | + void 저장된_연락방법이_모두_코드테이블에_있다() { | |
| 1108 | + assertThat(orphans("contact_log", "method", Codes.CONTACT_METHOD)).isEmpty(); | |
| 1109 | + } | |
| 1110 | + | |
| 1111 | + @Test | |
| 1112 | + void 점검_쿼리가_고아값을_실제로_찾아낸다() { | |
| 1113 | + jdbc.update("insert into organization (org_no, org_name, channel_slug) " | |
| 1114 | + + "values ('900', '정합성테스트기관', '900')"); | |
| 1115 | + Long orgId = jdbc.queryForObject( | |
| 1116 | + "select id from organization where org_no = '900'", Long.class); | |
| 1117 | + jdbc.update("insert into contact_log (org_id, method, contacted_at, note) " | |
| 1118 | + + "values (?, '텔레그램', now(), '코드테이블에 없는 값')", orgId); | |
| 1119 | + | |
| 1120 | + assertThat(orphans("contact_log", "method", Codes.CONTACT_METHOD)) | |
| 1121 | + .containsExactly("텔레그램"); | |
| 1122 | + | |
| 1123 | + jdbc.update("delete from contact_log where org_id = ?", orgId); | |
| 1124 | + jdbc.update("delete from organization where id = ?", orgId); | |
| 1125 | + } | |
| 1126 | + | |
| 1127 | + /** 해당 컬럼에 저장돼 있으나 코드테이블에 없는 값. */ | |
| 1128 | + private List<String> orphans(String table, String column, String groupId) { | |
| 1129 | + List<String> stored = jdbc.queryForList( | |
| 1130 | + "select distinct " + column + " from " + table | |
| 1131 | + + " where " + column + " is not null and " + column + " <> ''", | |
| 1132 | + String.class); | |
| 1133 | + return stored.stream() | |
| 1134 | + .filter(v -> codeService.find(groupId, v).isEmpty()) | |
| 1135 | + .toList(); | |
| 1136 | + } | |
| 1137 | +} | |
| 1138 | +``` | |
| 1139 | + | |
| 1140 | +- [ ] **Step 2: 테스트를 돌려 실패를 확인한다** | |
| 1141 | + | |
| 1142 | +Run: `mvn -q test -Dtest=CodeConsistencyTest` | |
| 1143 | +Expected: `점검_쿼리가_고아값을_실제로_찾아낸다`가 FAIL — | |
| 1144 | +`contact_log` 컬럼명이 실제와 다르면 SQL 오류가 난다. | |
| 1145 | +실패 메시지를 보고 `V12__project_management.sql`의 실제 컬럼명으로 맞춘다. | |
| 1146 | +나머지 3건은 테스트 DB가 비어 있어 통과한다(정상). | |
| 1147 | + | |
| 1148 | +- [ ] **Step 3: 운영용 점검 쿼리를 만든다** | |
| 1149 | + | |
| 1150 | +`src/main/resources/db/consistency/check-orphan-codes.sql` | |
| 1151 | + | |
| 1152 | +```sql | |
| 1153 | +-- 운영 DB 배포 전에 실행한다. 결과가 한 줄이라도 나오면 그 값을 | |
| 1154 | +-- active=false 코드로 code 테이블에 추가한 뒤 Phase 2를 배포해야 한다. | |
| 1155 | +-- 값을 지우지 않는 이유: 지우면 기존 화면에서 값이 조용히 사라진다. | |
| 1156 | + | |
| 1157 | +select 'review_item.review_major' as source, review_major as value, count(*) as cnt | |
| 1158 | + from review_item | |
| 1159 | + where review_major is not null and review_major <> '' | |
| 1160 | + and review_major not in (select code from code where group_id = 'REVIEW_MAJOR') | |
| 1161 | + group by review_major | |
| 1162 | +union all | |
| 1163 | +select 'review_item.review_minor', review_minor, count(*) | |
| 1164 | + from review_item | |
| 1165 | + where review_minor is not null and review_minor <> '' | |
| 1166 | + and review_minor not in (select code from code where group_id = 'REVIEW_MINOR') | |
| 1167 | + group by review_minor | |
| 1168 | +union all | |
| 1169 | +select 'review_item.review_result', review_result, count(*) | |
| 1170 | + from review_item | |
| 1171 | + where review_result is not null and review_result <> '' | |
| 1172 | + and review_result not in (select code from code where group_id = 'REVIEW_RESULT') | |
| 1173 | + group by review_result | |
| 1174 | +union all | |
| 1175 | +select 'review_item.judged_kogl_type', judged_kogl_type, count(*) | |
| 1176 | + from review_item | |
| 1177 | + where judged_kogl_type is not null and judged_kogl_type <> '' | |
| 1178 | + and judged_kogl_type not in (select code from code where group_id = 'KOGL_TYPE') | |
| 1179 | + group by judged_kogl_type | |
| 1180 | +union all | |
| 1181 | +select 'process_item.process_status', process_status, count(*) | |
| 1182 | + from process_item | |
| 1183 | + where process_status is not null and process_status <> '' | |
| 1184 | + and process_status not in (select code from code where group_id = 'PROCESS_STATUS') | |
| 1185 | + group by process_status | |
| 1186 | +union all | |
| 1187 | +select 'process_item.judged_kogl_type', judged_kogl_type, count(*) | |
| 1188 | + from process_item | |
| 1189 | + where judged_kogl_type is not null and judged_kogl_type <> '' | |
| 1190 | + and judged_kogl_type not in (select code from code where group_id = 'KOGL_TYPE') | |
| 1191 | + group by judged_kogl_type | |
| 1192 | +union all | |
| 1193 | +select 'contact_log.method', method, count(*) | |
| 1194 | + from contact_log | |
| 1195 | + where method is not null and method <> '' | |
| 1196 | + and method not in (select code from code where group_id = 'CONTACT_METHOD') | |
| 1197 | + group by method | |
| 1198 | +order by 1, 2; | |
| 1199 | +``` | |
| 1200 | + | |
| 1201 | +- [ ] **Step 4: 테스트를 돌려 통과를 확인한다** | |
| 1202 | + | |
| 1203 | +Run: `mvn -q test -Dtest=CodeConsistencyTest` | |
| 1204 | +Expected: PASS (4건) | |
| 1205 | + | |
| 1206 | +- [ ] **Step 5: 커밋** | |
| 1207 | + | |
| 1208 | +```bash | |
| 1209 | +git add src/main/resources/db/consistency/check-orphan-codes.sql src/test/java/kr/itn/itnhub/code/CodeConsistencyTest.java | |
| 1210 | +git commit -m "feat: 기존 데이터와 코드테이블 정합성 점검" | |
| 1211 | +``` | |
| 1212 | + | |
| 1213 | +--- | |
| 1214 | + | |
| 1215 | +### Task 7: Phase 1 회귀 확인 | |
| 1216 | + | |
| 1217 | +**Files:** 없음 (검증만) | |
| 1218 | + | |
| 1219 | +- [ ] **Step 1: 백엔드 전체 테스트를 돌린다** | |
| 1220 | + | |
| 1221 | +Run: `mvn -q test` | |
| 1222 | +Expected: 기존 22개 + 신규 6개 클래스 전부 PASS. | |
| 1223 | +**한 건이라도 깨지면 Phase 2로 넘어가지 않는다.** 이 Phase는 기존 동작을 바꾸지 않으므로 | |
| 1224 | +기존 테스트가 깨졌다면 그 자체가 결함이다. | |
| 1225 | + | |
| 1226 | +- [ ] **Step 2: 프론트 테스트를 돌린다** | |
| 1227 | + | |
| 1228 | +Run: `cd frontend && npm test` | |
| 1229 | +Expected: 18개 전부 PASS (이 Phase에서 프론트는 건드리지 않았으므로 당연히 통과해야 한다) | |
| 1230 | + | |
| 1231 | +- [ ] **Step 3: 애플리케이션이 기동되는지 확인한다** | |
| 1232 | + | |
| 1233 | +Run: `mvn -q spring-boot:run` (또는 `run-local.ps1`) | |
| 1234 | +Expected: 기동 후 `GET /api/meta/codes`가 12개 그룹을 반환. 확인 후 종료. | |
| 1235 | + | |
| 1236 | +- [ ] **Step 4: 커밋** | |
| 1237 | + | |
| 1238 | +```bash | |
| 1239 | +git add -A | |
| 1240 | +git commit -m "chore: Phase 1 회귀 확인 완료" | |
| 1241 | +``` | |
| 1242 | + | |
| 1243 | +--- | |
| 1244 | + | |
| 1245 | +## 다음 Phase | |
| 1246 | + | |
| 1247 | +이 계획은 Phase 1만 다룬다. 나머지는 각각 별도 계획으로 쓴다 — 한 계획이 working software를 | |
| 1248 | +내놓아야 하고, Phase마다 산출물과 검증 방법이 다르기 때문이다. | |
| 1249 | + | |
| 1250 | +| Phase | 계획 파일 | 내용 | | |
| 1251 | +|---|---|---| | |
| 1252 | +| 2 | `2026-07-28-phase2-frontend-codes-module.md` | 프론트 `codes` 모듈, 화면의 하드코딩 배열 제거 | | |
| 1253 | +| 3 | `2026-07-28-phase3-literal-removal.md` | Java·SQL 리터럴 제거, 엑셀 양식 정의 통합, 메시지 상수 | | |
| 1254 | +| 4 | `2026-07-28-phase4-component-split.md` | 화면 파일 분해 | | |
| 1255 | +| 5 | `2026-07-28-phase5-code-admin-screen.md` | 코드 관리 화면 | | |
| 1256 | +| 6 | `2026-07-28-phase6-requirements.md` | 요구사항 반영 (13단계, RE 메모, 비고, 자료접수, 업무구간, 보고서 수정) | | |
| 1257 | + | |
| 1258 | +## Self-Review 결과 | |
| 1259 | + | |
| 1260 | +**스펙 커버리지** — 설계서 §5.1(스키마) → Task 1, §5.2~5.6(시드·attrs 설계) → Task 2, | |
| 1261 | +§6.1(백엔드 모듈) → Task 3·4·5, §5.7(정합성) → Task 6, §7 Phase 1 완료 조건 → Task 7. | |
| 1262 | +Phase 1 범위의 스펙 항목에 빠진 것은 없다. | |
| 1263 | + | |
| 1264 | +**미해결로 남긴 것** — `CodeService.groups()`는 Task 4에서 만들지만 소비자는 Phase 5(관리 화면)다. | |
| 1265 | +지금은 테스트만 쓴다. Phase 5 전에 지우지 말 것. | |
| 1266 | + | |
| 1267 | +**타입 일관성** — `CodeRow.attrsJson`(String) → `Code.attrs`(Map)로의 변환은 `CodeService.toCode()` | |
| 1268 | +한 곳에서만 일어난다. `Codes.*` 상수명은 Task 4~6에서 동일하게 쓰인다. |
+++ docs/superpowers/specs/2026-07-28-common-code-refactoring-design.md
... | ... | @@ -0,0 +1,414 @@ |
| 1 | +# 공통코드 도입과 하드코딩 제거 설계 | |
| 2 | + | |
| 3 | +작성일 2026-07-28 | |
| 4 | +근거 문서: `ITN-HUB 요구사항 확인요청_260727_영익 검토.pptx` (28항목 중 회신분) | |
| 5 | + | |
| 6 | +## 1. 배경 | |
| 7 | + | |
| 8 | +요구사항 회신에서 "보고서 제출 완료" 13단계 신설이 확정됐다. 이 한 줄을 반영하려면 지금은 | |
| 9 | +백엔드 4곳, 프론트 8곳, 테스트 6곳을 동시에 고쳐야 한다. 단계 번호와 선택지 문자열이 | |
| 10 | +소스 곳곳에 리터럴로 흩어져 있기 때문이다. | |
| 11 | + | |
| 12 | +회신 28항목 중 11항목이 아직 미정이다. 앞으로도 선택지 추가·이름 변경 요청이 계속 들어온다. | |
| 13 | +그때마다 같은 규모의 수정을 반복하지 않으려면 코드값을 데이터로 옮겨야 한다. | |
| 14 | + | |
| 15 | +목표는 두 가지다. | |
| 16 | + | |
| 17 | +1. 코드값을 DB 공통코드로 옮기고, 소스에서 코드값 리터럴을 없앤다. | |
| 18 | +2. 그 위에 확정된 요구사항 7건을 반영한다. | |
| 19 | + | |
| 20 | +## 2. 현황 진단 | |
| 21 | + | |
| 22 | +전수 조사 결과 같은 코드체계가 여러 곳에 중복 정의되어 있다. | |
| 23 | + | |
| 24 | +| 코드체계 | 정의된 곳 | 실측 | | |
| 25 | +|---|---|---| | |
| 26 | +| `'처리완료'` | 프론트 6 + Java 2 + SQL 5 | **13곳** | | |
| 27 | +| 공공누리 유형 | `ReviewBoard.tsx:50`(7개) / `:978`(5개) / `ProcessBoard.tsx:29`(6개) | **3중, 개수·순서 모두 다름** | | |
| 28 | +| 담당자 구분 | `ContactsPage.tsx:15,33,128` + `MemberDirectoryParser.java:43` + `client.ts:3` | **5중** | | |
| 29 | +| 진행단계 12 | `stages.ts:4` + `StageService.java:34` + `DashboardService.java:16-20` + `Dashboard.tsx:31,241,244` + `ProjectsPage.tsx:240` | **5중** | | |
| 30 | +| 연락 방법 | `ProjectsPage.tsx:32` | 화면에만, **서버 검증 없음** | | |
| 31 | + | |
| 32 | +구조적 사실: | |
| 33 | + | |
| 34 | +- DB에 코드테이블이 없다. 마이그레이션 V1~V12가 만드는 테이블은 8개뿐이고, 코드값 컬럼은 | |
| 35 | + 전부 자유 `varchar` + 주석이다. CHECK 제약도 FK도 없다. | |
| 36 | +- 백엔드에 진행단계·처리결과·공공누리유형 enum이 없다. enum은 `OrgStatus`, `ChannelKind`, | |
| 37 | + `ProvisionOutcome` 3개뿐이다. | |
| 38 | +- 코드값의 정본이 프론트 컴포넌트 파일 안에 있다. 서버는 값을 저장만 하고, | |
| 39 | + 검증은 `ProcessService.java:22`의 처리상태 화이트리스트 하나뿐이다. | |
| 40 | +- 메타데이터 API가 없다(`/api/meta`, `/api/codes` 부재). | |
| 41 | +- 화면 파일이 크다: `ReviewBoard.tsx` 1,279줄 / `ProcessBoard.tsx` 1,060줄 / | |
| 42 | + `OrgOverview.tsx` 740줄 / `Dashboard.tsx` 697줄. | |
| 43 | + | |
| 44 | +## 3. 저장값 정책 | |
| 45 | + | |
| 46 | +**기존 한글 문자열을 저장값으로 그대로 유지한다.** (승인 완료) | |
| 47 | + | |
| 48 | +`code.code` = 지금 DB에 들어 있는 값. `'처리완료'`, `'권리처리 추진'` 그대로다. | |
| 49 | +진행단계만 예외로 숫자 문자열(`'1'`~`'13'`)이며 `organization.stage`는 지금처럼 smallint다. | |
| 50 | + | |
| 51 | +이유: 기존 데이터를 건드리지 않고, 엑셀 업로드·다운로드 양식도 그대로 둘 수 있다. | |
| 52 | +영문 코드키 전환은 데이터 변환과 파서 전면 수정을 동반해 위험 대비 실익이 없다. | |
| 53 | +코드에서 리터럴이 사라진다는 목적은 이 방식으로도 완전히 달성된다. | |
| 54 | + | |
| 55 | +## 4. 범위 | |
| 56 | + | |
| 57 | +### 포함 | |
| 58 | + | |
| 59 | +- 공통코드 테이블과 시드 | |
| 60 | +- 백엔드 `code` 패키지, 코드 조회·관리 API | |
| 61 | +- 프론트 `codes` 모듈, 화면의 하드코딩 배열 제거 | |
| 62 | +- 코드 관리 화면 (승인 완료) | |
| 63 | +- SQL·Java 리터럴 제거, 엑셀 양식 정의 통합, 메시지 상수 분리 | |
| 64 | +- 손대는 화면 파일 분해 | |
| 65 | +- 확정 요구사항 7건 반영 (§8) | |
| 66 | + | |
| 67 | +### 제외 | |
| 68 | + | |
| 69 | +회신에서 미정이거나 결정 대기인 항목은 이번 범위에서 뺀다. | |
| 70 | + | |
| 71 | +- 미응답 10건: 1(서류제출 기준)·3(보고서작성 기준)·9(최근변경 범위)·13(초상권+개방불가)· | |
| 72 | + 14(검색 AND/OR)·18(자동 단계전환)·19(되돌림)·22(작성자 표기)·24(연락방법)·26(자료실) | |
| 73 | +- 재질문 1건: 12(권리처리필요 자동체크) | |
| 74 | +- 결정 대기: 20·21·27 (Mattermost 존치 여부와 사용자 계정 체계) | |
| 75 | +- 28(보고서 양식) — 양식 수령 후 별건 | |
| 76 | + | |
| 77 | +단, 1·3·24는 코드테이블로 옮겨두므로 답이 오면 **데이터만 바꾸면 된다**. | |
| 78 | + | |
| 79 | +## 5. 데이터 설계 | |
| 80 | + | |
| 81 | +### 5.1 스키마 | |
| 82 | + | |
| 83 | +```sql | |
| 84 | +create table code_group ( | |
| 85 | + group_id varchar(50) primary key, | |
| 86 | + group_name varchar(100) not null, | |
| 87 | + description text, | |
| 88 | + editable boolean not null default true -- 관리 화면에서 추가·삭제를 허용할지 | |
| 89 | +); | |
| 90 | + | |
| 91 | +create table code ( | |
| 92 | + group_id varchar(50) not null references code_group(group_id), | |
| 93 | + code varchar(300) not null, -- 저장값. review_minor가 varchar(300)이라 이에 맞춤 | |
| 94 | + label varchar(200) not null, -- 화면 표시명 | |
| 95 | + sort_order int not null, | |
| 96 | + active boolean not null default true, | |
| 97 | + attrs jsonb not null default '{}'::jsonb, | |
| 98 | + primary key (group_id, code) | |
| 99 | +); | |
| 100 | +create index idx_code_group_sort on code (group_id, sort_order); | |
| 101 | +``` | |
| 102 | + | |
| 103 | +`code`와 `label`을 나눈 이유: 권리확인 세부항목은 저장값이 | |
| 104 | +`'2. 후천적 권리 전부 보유(계약서 확인 필요)'`인데 화면에는 | |
| 105 | +`'2. 후천적 권리 전부 보유'` + 회색 부연설명으로 나뉘어 보인다 | |
| 106 | +(`ReviewBoard.tsx:98-108`). 한 컬럼으로는 기존 데이터를 유지할 수 없다. | |
| 107 | + | |
| 108 | +`attrs`에는 코드마다 붙는 부가 속성을 넣는다. 값만 담고 동작은 담지 않는다(§5.4). | |
| 109 | + | |
| 110 | +### 5.2 코드 그룹 | |
| 111 | + | |
| 112 | +11개 그룹, 60여 개 코드. 전체 시드는 `V13__common_code.sql`에 둔다. | |
| 113 | + | |
| 114 | +| group_id | 내용 | 개수 | 대체하는 하드코딩 | | |
| 115 | +|---|---|---|---| | |
| 116 | +| `STAGE` | 진행단계 | 13 | `stages.ts:4-17`, `DashboardService.java:16-20`, `StageService.java:34` | | |
| 117 | +| `STAGE_GROUP` | 업무구간 | 5 | `stages.ts:28-34`, `Dashboard.tsx:34-40` | | |
| 118 | +| `REVIEW_MAJOR` | 권리확인 대분류 | 4 | `ReviewBoard.tsx:18`, `suggestReviewResult():75-80` | | |
| 119 | +| `REVIEW_MINOR` | 권리확인 세부 | 5 | `ReviewBoard.tsx:20-26, 59-63, 98-108` | | |
| 120 | +| `REVIEW_RESULT` | 처리결과 | 5 | `ReviewBoard.tsx:32-42, 65-71` | | |
| 121 | +| `KOGL_TYPE` | 공공누리 유형 | 7 | `ReviewBoard.tsx:50, 53, 978`, `ProcessBoard.tsx:29, 935, 948` | | |
| 122 | +| `PROCESS_STATUS` | 권리처리 상태 | 2 | `ProcessBoard.tsx:20-24, 31`, `ProcessService.java:22` | | |
| 123 | +| `CONTRACT_DOC` | 계약 서류 | 6 | `ProcessBoard.tsx:26, 28` | | |
| 124 | +| `CONTACT_METHOD` | 연락 방법 | 4 | `ProjectsPage.tsx:32` | | |
| 125 | +| `CONTACT_CATEGORY` | 담당자 구분 | 5 | `ContactsPage.tsx:15, 33, 128`, `MemberDirectoryParser.java:43-47` | | |
| 126 | +| `ATTACHMENT_YN` / `KOGL_ATTACHED` | 첨부·부착 여부 | 2+2 | `ReviewBoard.tsx:939, 956` | | |
| 127 | + | |
| 128 | +### 5.3 STAGE 설계 — 요구사항 [7][11][2] 반영 | |
| 129 | + | |
| 130 | +``` | |
| 131 | +code label attrs | |
| 132 | +1 신청 {"groupKey":"intake", "progress":"applied"} | |
| 133 | +2 목록접수 {"groupKey":"intake", "progress":"active", "kpiFrom":"docSubmitted"} | |
| 134 | +3 예비검토 {"groupKey":"intake", "progress":"active"} | |
| 135 | +4 변호사 배당 {"groupKey":"assign", "progress":"active"} | |
| 136 | +5 법률검토(권리확인) {"groupKey":"check", "progress":"active"} | |
| 137 | +6 RE:확인 {"groupKey":"check", "progress":"active"} | |
| 138 | +7 법률검토(권리확인) 완료 {"groupKey":"check", "progress":"active"} | |
| 139 | +8 법률검토(권리처리) {"groupKey":"process", "progress":"active"} | |
| 140 | +9 RE:처리 {"groupKey":"process", "progress":"active"} | |
| 141 | +10 법률검토(권리처리) 완료 {"groupKey":"process", "progress":"active"} | |
| 142 | +11 법률검토 최종완료 {"groupKey":"closing", "progress":"active"} | |
| 143 | +12 보고서 작성 {"groupKey":"closing", "progress":"active", "kpiFrom":"reportWriting"} | |
| 144 | +13 보고서 제출 완료 {"groupKey":"closing", "progress":"done", "kpiFrom":"completed"} | |
| 145 | +``` | |
| 146 | + | |
| 147 | +- `progress` — 요구사항 [7]. 신청은 ①만, 완료는 ⑬만, 나머지는 진행중. | |
| 148 | + 단계가 `null`인 기관(채널 미생성)은 `progress` 대상이 아니며 화면에서 '단계 미지정'으로 | |
| 149 | + 따로 다룬다(승인 완료). 칸반 첫 컬럼이 이 자리다. | |
| 150 | +- `kpiFrom` — "이 단계부터 해당 KPI에 집계"라는 임계값. `DashboardService`의 상수 | |
| 151 | + `STAGE_DOC_SUBMITTED=2` / `STAGE_COMPLETED=11` / `STAGE_REPORT=12`를 대체한다. | |
| 152 | + 요구사항 [2](보고서가 올라와야 최종완료)는 `completed`를 13에 두는 것으로 해결된다. | |
| 153 | + 미응답인 [1]·[3]은 현행 값을 그대로 옮겨두고, 답이 오면 데이터만 바꾼다. | |
| 154 | +- 원문자(①②③)는 저장하지 않는다. 단계 번호에서 계산한다(U+2460 + n − 1, 20 초과 시 숫자). | |
| 155 | + `Dashboard.tsx:31`의 `CIRCLED` 배열은 삭제한다. | |
| 156 | + | |
| 157 | +### 5.4 KOGL_TYPE 설계 — 검증에서 드러난 보완점 | |
| 158 | + | |
| 159 | +세 곳의 목록이 7개/5개/6개로 달랐던 것은 실수가 아니라 **쓰이는 자리가 다르기 때문**이다. | |
| 160 | + | |
| 161 | +| 자리(scope) | 내용 | 항목 | | |
| 162 | +|---|---|---| | |
| 163 | +| `review` | 변호사 판정값 | 0~4유형, 개방불가, 보류 | | |
| 164 | +| `process` | 권리처리 판정값 | 0~4유형, 개방불가 (보류 없음) | | |
| 165 | +| `prior` | 기존 부착된 유형 | 0~4유형 | | |
| 166 | + | |
| 167 | +`sort_order` 하나로는 이 차이를 담을 수 없다. `attrs.scopes`로 사용처를, | |
| 168 | +`attrs.firstRow`로 라디오 첫 줄 배치를 표현한다. | |
| 169 | + | |
| 170 | +``` | |
| 171 | +0유형 {"scopes":["review","process","prior"], "firstRow":["review","process"]} | |
| 172 | +1~4유형 {"scopes":["review","process","prior"], "firstRow":[]} | |
| 173 | +개방불가 {"scopes":["review","process"], "firstRow":["review","process"]} | |
| 174 | +보류 {"scopes":["review"], "firstRow":["review"]} | |
| 175 | +``` | |
| 176 | + | |
| 177 | +이 구분 없이 단순 병합했다면 권리처리 화면에 `보류`가 잘못 노출된다. | |
| 178 | + | |
| 179 | +### 5.5 색상 — DB에 CSS를 넣지 않는다 | |
| 180 | + | |
| 181 | +뱃지 색이 지금 Tailwind 클래스 문자열이다(`ReviewBoard.tsx:65-71`). | |
| 182 | +DB에는 `attrs.tone`으로 의미 토큰(`emerald`, `amber`, `blue`, `red`, `slate`)만 저장하고, | |
| 183 | +토큰→클래스 매핑은 프론트가 갖는다. 화면 스타일 변경이 DB 수정을 부르지 않게 한다. | |
| 184 | + | |
| 185 | +### 5.6 값과 동작의 경계 | |
| 186 | + | |
| 187 | +`applyResultDerivations`(`ReviewBoard.tsx:812-834`)를 실측한 결과, 처리결과 선택 시 동작은 | |
| 188 | +단순 대입이 아니었다. | |
| 189 | + | |
| 190 | +| 처리결과 | 동작 | | |
| 191 | +|---|---| | |
| 192 | +| 권리처리 추진 | 공공누리 `보류` 대입, 권리처리필요 켬 | | |
| 193 | +| 개방불가 | 공공누리 `개방불가` 대입, 권리처리필요 끔 | | |
| 194 | +| 권리처리 추진 미희망 | 권리처리필요 끔, **기존 부착 유형을 복사**, **비고에 안내문구 덧붙임** | | |
| 195 | + | |
| 196 | +마지막 행의 "복사"와 "덧붙임"까지 데이터로 옮기면 `attrs` 안에 규칙 언어를 새로 만드는 | |
| 197 | +꼴이 된다. **값은 데이터로, 동작은 코드로** 나눈다. | |
| 198 | + | |
| 199 | +``` | |
| 200 | +신유형 개방 {"tone":"emerald","openable":"Y"} | |
| 201 | +계약서 등 재확인 {"tone":"amber"} | |
| 202 | +권리처리 추진 {"tone":"blue", "needsProcessing":true, "koglSet":"보류"} | |
| 203 | +개방불가 {"tone":"red", "needsProcessing":false, "koglSet":"개방불가"} | |
| 204 | +권리처리 추진 미희망 {"tone":"slate", "needsProcessing":false, "koglCopiesPrior":true} | |
| 205 | +``` | |
| 206 | + | |
| 207 | +`koglCopiesPrior`가 참일 때 무엇을 복사하고 어떤 문구를 붙일지는 코드가 정한다. | |
| 208 | +문구 자체는 메시지 상수 모듈로 옮긴다. 코드에서 리터럴이 사라진다는 목적은 유지된다. | |
| 209 | + | |
| 210 | +`needsProcessing`을 데이터로 둔 이유는 이것이 요구사항 [12]의 대상이기 때문이다. | |
| 211 | +자동 처리를 끄기로 결정되면 코드 수정 없이 속성만 제거하면 된다. | |
| 212 | + | |
| 213 | +### 5.7 기존 데이터 정합성 | |
| 214 | + | |
| 215 | +운영 DB에 시드와 일치하지 않는 값이 남아 있을 수 있다(정책 변경 전 값, 공백 차이, 오타). | |
| 216 | +Phase 1에 정합성 점검을 넣는다. | |
| 217 | + | |
| 218 | +```sql | |
| 219 | +select 'review_result' as col, review_result as value, count(*) | |
| 220 | + from review_item | |
| 221 | + where review_result is not null and review_result <> '' | |
| 222 | + and review_result not in (select code from code where group_id = 'REVIEW_RESULT') | |
| 223 | + group by review_result; | |
| 224 | +``` | |
| 225 | + | |
| 226 | +`review_major`, `review_minor`, `judged_kogl_type`, `process_status`, `contact_log.method`에 | |
| 227 | +같은 점검을 돌린다. 발견된 고아 값은 **삭제하지 않고** `active = false`로 코드테이블에 | |
| 228 | +추가한다. 기존 화면에서 값이 사라지는 사고를 막는다. | |
| 229 | + | |
| 230 | +## 6. 애플리케이션 설계 | |
| 231 | + | |
| 232 | +### 6.1 백엔드 — `kr.itn.itnhub.code` | |
| 233 | + | |
| 234 | +| 클래스 | 역할 | | |
| 235 | +|---|---| | |
| 236 | +| `Code`, `CodeGroup` | record. `attrs`는 `Map<String,Object>` | | |
| 237 | +| `CodeMapper` + `CodeMapper.xml` | 조회·저장 | | |
| 238 | +| `CodeService` | 기동 시 전체 로드 후 메모리 캐시. 저장 시 캐시 갱신 | | |
| 239 | +| `CodeController` | 조회·관리 API | | |
| 240 | +| `Codes` | 그룹 ID 상수와 로직이 참조하는 코드 상수 | | |
| 241 | +| `CodeValidator` | 저장 요청 값이 해당 그룹의 활성 코드인지 검증 | | |
| 242 | + | |
| 243 | +코드값은 자주 바뀌지 않으므로 캐시는 `ConcurrentHashMap` 한 겹으로 충분하다. | |
| 244 | +분산 캐시는 넣지 않는다(단일 인스턴스 운영). | |
| 245 | + | |
| 246 | +API: | |
| 247 | + | |
| 248 | +``` | |
| 249 | +GET /api/meta/codes 전체 코드 (프론트 부팅 시 1회) | |
| 250 | +GET /api/admin/codes/groups 관리 화면용 그룹 목록 | |
| 251 | +GET /api/admin/codes/{groupId} 그룹별 코드 목록 + 사용 건수 | |
| 252 | +POST /api/admin/codes/{groupId} 코드 추가 | |
| 253 | +PUT /api/admin/codes/{groupId}/{code} 라벨·순서·사용여부·attrs 수정 | |
| 254 | +DELETE /api/admin/codes/{groupId}/{code} 코드 삭제 (사용 중이면 거부) | |
| 255 | +``` | |
| 256 | + | |
| 257 | +### 6.2 프론트 — `frontend/src/codes/` | |
| 258 | + | |
| 259 | +``` | |
| 260 | +codes/ | |
| 261 | + types.ts Code, CodeMap 타입 | |
| 262 | + CodeProvider.tsx 로그인 후 1회 조회, Context 제공 | |
| 263 | + useCodes.ts useCodes('REVIEW_RESULT'), useCode(group, code) | |
| 264 | + stage.ts 단계 전용 헬퍼 (label, symbol, groupKey, progress, kpiFrom) | |
| 265 | + tone.ts tone 토큰 → Tailwind 클래스 매핑 | |
| 266 | +``` | |
| 267 | + | |
| 268 | +기존 `frontend/src/stages.ts`는 삭제하고 `codes/stage.ts`로 대체한다. | |
| 269 | + | |
| 270 | +로딩 시점은 인증 성공 직후다. 로그인 화면에서는 코드가 필요 없다. | |
| 271 | +조회 실패 시 화면을 그리지 않고 오류를 표시한다(빈 선택지로 저장되는 사고 방지). | |
| 272 | + | |
| 273 | +각 화면 상단의 옵션 배열은 전부 삭제하고 `useCodes`로 대체한다. | |
| 274 | + | |
| 275 | +### 6.3 코드 관리 화면 | |
| 276 | + | |
| 277 | +좌측 그룹 목록, 우측 코드 목록. 사이드바에 '공통코드' 메뉴를 추가한다. | |
| 278 | + | |
| 279 | +- 편집 가능: 표시명, 정렬순서, 사용여부, 코드 추가·삭제 | |
| 280 | +- `attrs`는 접이식 JSON 편집기로 노출하고 경고 문구를 붙인다. | |
| 281 | + 그룹별 전용 폼은 만들지 않는다. 실제 편집 수요는 표시명과 순서에 몰려 있고, | |
| 282 | + 전용 폼은 그룹이 늘 때마다 화면을 늘려야 한다. | |
| 283 | +- 삭제 안전장치: 해당 코드를 쓰는 데이터 건수를 먼저 조회하고, 1건 이상이면 삭제를 막고 | |
| 284 | + 사용여부 끄기를 안내한다. | |
| 285 | +- `code_group.editable = false`인 그룹(`STAGE` 등 로직 결합이 강한 그룹)은 추가·삭제를 막고 | |
| 286 | + 표시명·순서만 허용한다. 단계 추가는 마이그레이션으로 한다. | |
| 287 | +- 권한: 지금은 인증만으로 충분하다(관리자 1계정). 사용자 계정 도입 시 관리자 전용으로 제한한다. | |
| 288 | + | |
| 289 | +### 6.4 코드테이블 밖에서 처리하는 것 | |
| 290 | + | |
| 291 | +**SQL 안의 `'처리완료'` 5곳** — 쿼리라서 코드테이블로 뺄 수 없다. MyBatis 파라미터로 주입한다. | |
| 292 | +대상: `ProcessItemMapper.xml:42, 45, 148, 176, 194`, `DashboardMapper.xml:78`. | |
| 293 | +매퍼 인터페이스 시그니처에 `doneStatus`를 추가하고 서비스가 `Codes`에서 읽어 넘긴다. | |
| 294 | + | |
| 295 | +**엑셀 양식 정의** — 코드값이 아니라 파일 포맷이다. 코드테이블에 넣지 않는다. | |
| 296 | +다만 지금 업로드 쪽(`ReviewParser.COL_*` 23개, `ProcessParser.COL_*` 25개)과 | |
| 297 | +다운로드 쪽(`ReviewService.HEADERS` 25개, `ProcessService.HEADERS` 27개)이 서로 독립 정의라 | |
| 298 | +**한쪽만 고치면 양식이 어긋나는 결함**이 있다. 열 순서·헤더명·필드를 한 곳에서 선언하는 | |
| 299 | +스키마 모듈로 합치고, 업로드와 다운로드가 같은 정의를 참조하게 한다. | |
| 300 | + | |
| 301 | +주의: `ProcessParser.java:66-68`에 "헤더는 T열인데 데이터는 U열" 예외 처리가 있다. | |
| 302 | +통합 시 이 예외를 놓치면 권리처리 업로드가 조용히 어긋난다. | |
| 303 | + | |
| 304 | +**안내 문구** — `NOTE_NO_PROCESSING` 등은 코드값이 아니라 메시지다. 메시지 상수 모듈로 분리한다. | |
| 305 | + | |
| 306 | +**Mattermost 채널 종류** — `ChannelKind` enum과 설정 프로퍼티로 이미 관리된다. 그대로 둔다. | |
| 307 | + | |
| 308 | +### 6.5 화면 파일 분해 | |
| 309 | + | |
| 310 | +손대는 파일만 분해한다. 무관한 파일은 건드리지 않는다. | |
| 311 | + | |
| 312 | +| 현재 | 분해 | | |
| 313 | +|---|---| | |
| 314 | +| `ReviewBoard.tsx` 1,279 | `ReviewBoard` / `ReviewFilters` / `ReviewTable` / `ReviewDetail` / `ReviewBulkForm` | | |
| 315 | +| `ProcessBoard.tsx` 1,060 | `ProcessBoard` / `ProcessFilters` / `ProcessTable` / `ProcessDetail` / `ProcessBulkForm` | | |
| 316 | +| `Dashboard.tsx` 697 | `Dashboard` / `KpiTiles` / `KanbanBoard` / `RecentOrgTable` | | |
| 317 | +| `OrgOverview.tsx` 740 | `OrgOverview` / `OrgStageStrip` / `OrgTabs` / `ReTab`(신규) | | |
| 318 | + | |
| 319 | +## 7. 실행 순서 | |
| 320 | + | |
| 321 | +Phase 1~4는 **기존 동작이 바뀌지 않는다**. Phase 5는 화면을 새로 추가할 뿐 기존 동작을 | |
| 322 | +건드리지 않는다. 기존 테스트 40개(백엔드 22 + 프론트 18)가 그대로 통과해야 한다는 것이 | |
| 323 | +Phase 1~5 각 단계의 완료 조건이며, 이것이 이번 작업의 안전망이다. | |
| 324 | +실제 동작이 바뀌는 것은 Phase 6뿐이다. | |
| 325 | + | |
| 326 | +| Phase | 내용 | 동작 변화 | | |
| 327 | +|---|---|---| | |
| 328 | +| 1 | 코드테이블 + 시드 + `code` 패키지 + `/api/meta/codes` + 정합성 점검 | 없음 | | |
| 329 | +| 2 | 프론트 `codes` 모듈, 화면이 코드테이블에서 옵션을 읽도록 전환, 하드코딩 배열 삭제 | 없음 | | |
| 330 | +| 3 | Java·SQL 리터럴 제거, 엑셀 양식 정의 통합, 메시지 상수 분리 | 없음 | | |
| 331 | +| 4 | 화면 파일 분해 | 없음 | | |
| 332 | +| 5 | 코드 관리 화면 | 화면 추가 | | |
| 333 | +| 6 | 요구사항 반영 (§8) | 있음 | | |
| 334 | + | |
| 335 | +## 8. 요구사항 반영 (Phase 6) | |
| 336 | + | |
| 337 | +| 항목 | 내용 | 작업 | | |
| 338 | +|---|---|---| | |
| 339 | +| [7][11] | 13단계 "보고서 제출 완료" 신설, 진행상태 신청/진행중/완료 | 시드에 13 추가, `StageService` 상한을 코드 개수로, 칸반 컬럼 = 단계미지정 + 13 | | |
| 340 | +| [2] | 완료 = 보고서 업로드 기준 | `kpiFrom:"completed"`를 13에 부여 | | |
| 341 | +| [4][5] | RE 메모 기능 신설 | `re_memo` 테이블, `re` 패키지, 기관관리 RE 탭, 대시보드 미해결 RE 집계 | | |
| 342 | +| [15] | 일괄등록에 비고 추가 | `BulkJudgmentRequest`에 `lawyerNote`, `BulkProcessingRequest`에 `reviewNote` | | |
| 343 | +| [8] | 자료접수 = 권리확인·권리처리 통합 | `Dashboard`의 판정에 `processTotal` 포함 | | |
| 344 | +| [6] | 업무구간 필터 삭제, 필터 순서 재배치 | 필터 UI만 제거. `STAGE_GROUP`은 칸반 색·카드 모양에 계속 쓰이므로 유지 | | |
| 345 | +| [25] | 보고서 수정 | `PUT /api/orgs/{id}/reports/{reportId}` 신설 | | |
| 346 | +| [16] | 표기 누락 점검 | 현행 2줄 표기 유지, 열 누락 여부만 확인 | | |
| 347 | +| [17][21][10] | 현행 유지 | 작업 없음 | | |
| 348 | +| [23] | 기관 100개 | 코드 변경 없음. 명부 엑셀 수령 후 업로드 | | |
| 349 | + | |
| 350 | +### RE 메모 설계 | |
| 351 | + | |
| 352 | +회신 예시: `ooo 계약서 및 제안요청서 송부 요청, 장영익, 2026.07.08` | |
| 353 | + | |
| 354 | +```sql | |
| 355 | +create table re_memo ( | |
| 356 | + id bigserial primary key, | |
| 357 | + org_id bigint not null references organization(id), | |
| 358 | + content text not null, | |
| 359 | + author varchar(100) not null, | |
| 360 | + memo_date date not null, | |
| 361 | + resolved boolean not null default false, | |
| 362 | + resolved_at timestamptz, | |
| 363 | + created_at timestamptz not null default now(), | |
| 364 | + updated_at timestamptz not null default now() | |
| 365 | +); | |
| 366 | +``` | |
| 367 | + | |
| 368 | +메모 1건 = RE 1건. 기관관리 RE 탭에서 목록·등록·수정·삭제한다. | |
| 369 | +대시보드 '미해결 RE'는 `resolved = false` 건수 합계다. | |
| 370 | +`DashboardMapper.xml:64`의 `0 as re_count`를 서브쿼리로 교체한다 | |
| 371 | +(해당 파일 44-45행 주석이 이미 이 방식을 예고하고 있다). | |
| 372 | + | |
| 373 | +> **확인받지 못한 가정**: `resolved` 플래그는 회신에 없던 항목이다. 대시보드 '미해결 RE' | |
| 374 | +> 숫자를 세려면 해결 여부를 구분할 수단이 필요해 완료 체크박스로 설계했다. | |
| 375 | +> 이 가정이 틀리면 RE 탭과 KPI 집계를 함께 손봐야 한다. 회신 요청 상태다. | |
| 376 | + | |
| 377 | +## 9. 테스트 | |
| 378 | + | |
| 379 | +기존 테스트는 리팩터링의 회귀 안전망이다. Phase 1~5 동안 계속 통과해야 한다. | |
| 380 | + | |
| 381 | +신규로 필요한 것: | |
| 382 | + | |
| 383 | +- `CodeServiceTest` — 캐시 적재·갱신, scope 필터, 활성 코드만 노출 | |
| 384 | +- `CodeControllerTest` — 조회·수정·삭제, 사용 중 코드 삭제 거부 | |
| 385 | +- `DashboardServiceTest` — **현재 없다**. `kpiFrom` 기반 집계로 바꾸기 전에 먼저 만들어 | |
| 386 | + 현행 동작을 고정한다 | |
| 387 | +- `ReviewParserTest` / `ProcessParserTest` — **현재 없다**. 엑셀 양식 정의를 통합하기 전에 | |
| 388 | + 업로드→다운로드 왕복 테스트를 만들어 양식 호환을 고정한다. `ProcessParser`의 T/U열 예외를 | |
| 389 | + 반드시 포함한다 | |
| 390 | +- `ReMemoControllerTest` — RE 메모 CRUD | |
| 391 | +- 프론트: `codes` 모듈 테스트, 분해된 컴포넌트별 테스트 | |
| 392 | + | |
| 393 | +Phase 6에서 갱신이 필요한 기존 테스트: | |
| 394 | + | |
| 395 | +- `StageControllerTest:90,92` — "13은 400" 단언이 뒤집힌다 | |
| 396 | +- `DashboardControllerTest:57,96,98` — 11/12 기준 단언 | |
| 397 | +- `OrgOverview.test.tsx:254,466` — "업무단계 12개" | |
| 398 | + | |
| 399 | +## 10. 리스크 | |
| 400 | + | |
| 401 | +| 리스크 | 대응 | | |
| 402 | +|---|---| | |
| 403 | +| 운영 DB에 시드와 다른 값이 남아 있음 | Phase 1 정합성 점검, 고아 값은 `active=false`로 보존 | | |
| 404 | +| 엑셀 양식 통합 시 기존 파일 호환 깨짐 | 왕복 테스트를 먼저 작성, T/U열 예외 포함 | | |
| 405 | +| `STAGE_GROUP`을 필터와 함께 지워 칸반이 깨짐 | 필터 UI만 제거, 색·카드 모양 용도는 유지 (§8) | | |
| 406 | +| 코드 조회 실패 시 빈 선택지로 저장 | 조회 실패 시 화면을 그리지 않고 오류 표시 | | |
| 407 | +| Phase 4 분해 중 회귀 | 각 Phase 완료 조건 = 기존 테스트 전량 통과 | | |
| 408 | + | |
| 409 | +## 11. 미결 항목 | |
| 410 | + | |
| 411 | +- RE 메모의 `resolved` 플래그 (§8) — 회신 대기 | |
| 412 | +- 요구사항 미응답 10건, 재질문 1건, 결정 대기 3건 — §4 제외 목록 | |
| 413 | +- 기관 100개 명부 엑셀 — 수령 대기 | |
| 414 | +- 주간·월간 보고 양식 — 수령 대기 |
+++ src/main/resources/db/migration/V13__common_code.sql
... | ... | @@ -0,0 +1,31 @@ |
| 1 | +-- 공통코드. 진행단계·처리결과·공공누리유형 같은 선택지를 소스가 아니라 데이터로 관리한다. | |
| 2 | +-- | |
| 3 | +-- 저장값(code)은 지금 각 업무 테이블에 들어 있는 한글 문자열을 그대로 쓴다. 영문 코드키로 | |
| 4 | +-- 바꾸면 기존 데이터를 전부 변환해야 하고 엑셀 업로드/다운로드 양식까지 손봐야 해서, | |
| 5 | +-- 위험 대비 실익이 없다. 진행단계만 예외로 숫자 문자열이며 organization.stage는 smallint 유지. | |
| 6 | + | |
| 7 | +create table code_group ( | |
| 8 | + group_id varchar(50) primary key, | |
| 9 | + group_name varchar(100) not null, | |
| 10 | + description text, | |
| 11 | + -- 관리 화면에서 코드 추가·삭제를 허용할지. 로직과 강하게 묶인 그룹(STAGE 등)은 false로 두고 | |
| 12 | + -- 표시명·순서만 바꾸게 한다. 단계 추가는 마이그레이션으로 한다. | |
| 13 | + editable boolean not null default true | |
| 14 | +); | |
| 15 | + | |
| 16 | +create table code ( | |
| 17 | + group_id varchar(50) not null references code_group(group_id), | |
| 18 | + -- 저장값. review_item.review_minor가 varchar(300)이라 그 길이에 맞춘다. | |
| 19 | + code varchar(300) not null, | |
| 20 | + -- 화면 표시명. 저장값과 다를 수 있다 - 권리확인 세부항목은 저장값이 | |
| 21 | + -- '2. 후천적 권리 전부 보유(계약서 확인 필요)'인데 화면에는 짧은 형태로 나온다. | |
| 22 | + label varchar(200) not null, | |
| 23 | + sort_order int not null, | |
| 24 | + active boolean not null default true, | |
| 25 | + -- 코드별 부가 속성. 값만 담고 동작은 담지 않는다 - 동작까지 밀어넣으면 | |
| 26 | + -- DB 안에 규칙 언어를 새로 만드는 꼴이 되어 오히려 읽기 어려워진다. | |
| 27 | + attrs jsonb not null default '{}'::jsonb, | |
| 28 | + primary key (group_id, code) | |
| 29 | +); | |
| 30 | + | |
| 31 | +create index idx_code_group_sort on code (group_id, sort_order); |
+++ src/test/java/kr/itn/itnhub/code/CodeSchemaTest.java
... | ... | @@ -0,0 +1,65 @@ |
| 1 | +package kr.itn.itnhub.code; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import org.junit.jupiter.api.Test; | |
| 5 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 6 | +import org.springframework.dao.DataIntegrityViolationException; | |
| 7 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 8 | + | |
| 9 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 10 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | |
| 11 | + | |
| 12 | +class CodeSchemaTest extends AbstractDbTest { | |
| 13 | + | |
| 14 | + @Autowired | |
| 15 | + JdbcTemplate jdbc; | |
| 16 | + | |
| 17 | + @Test | |
| 18 | + void 코드_테이블이_생성된다() { | |
| 19 | + Integer count = jdbc.queryForObject( | |
| 20 | + "select count(*) from information_schema.tables " | |
| 21 | + + "where table_schema = 'itnhub' and table_name in ('code_group', 'code')", | |
| 22 | + Integer.class); | |
| 23 | + | |
| 24 | + assertThat(count).isEqualTo(2); | |
| 25 | + } | |
| 26 | + | |
| 27 | + @Test | |
| 28 | + void 그룹과_코드_조합이_유일하다() { | |
| 29 | + jdbc.update("insert into code_group (group_id, group_name) values ('TEST_GRP', '테스트')"); | |
| 30 | + jdbc.update("insert into code (group_id, code, label, sort_order) " | |
| 31 | + + "values ('TEST_GRP', 'A', '가', 1)"); | |
| 32 | + | |
| 33 | + assertThatThrownBy(() -> jdbc.update( | |
| 34 | + "insert into code (group_id, code, label, sort_order) " | |
| 35 | + + "values ('TEST_GRP', 'A', '다른라벨', 2)")) | |
| 36 | + .isInstanceOf(DataIntegrityViolationException.class); | |
| 37 | + | |
| 38 | + jdbc.update("delete from code where group_id = 'TEST_GRP'"); | |
| 39 | + jdbc.update("delete from code_group where group_id = 'TEST_GRP'"); | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Test | |
| 43 | + void 없는_그룹의_코드는_넣을_수_없다() { | |
| 44 | + assertThatThrownBy(() -> jdbc.update( | |
| 45 | + "insert into code (group_id, code, label, sort_order) " | |
| 46 | + + "values ('NO_SUCH_GROUP', 'A', '가', 1)")) | |
| 47 | + .isInstanceOf(DataIntegrityViolationException.class); | |
| 48 | + } | |
| 49 | + | |
| 50 | + @Test | |
| 51 | + void attrs는_기본값이_빈_객체다() { | |
| 52 | + jdbc.update("insert into code_group (group_id, group_name) values ('TEST_ATTR', '테스트')"); | |
| 53 | + jdbc.update("insert into code (group_id, code, label, sort_order) " | |
| 54 | + + "values ('TEST_ATTR', 'A', '가', 1)"); | |
| 55 | + | |
| 56 | + String attrs = jdbc.queryForObject( | |
| 57 | + "select attrs::text from code where group_id = 'TEST_ATTR' and code = 'A'", | |
| 58 | + String.class); | |
| 59 | + | |
| 60 | + assertThat(attrs).isEqualTo("{}"); | |
| 61 | + | |
| 62 | + jdbc.update("delete from code where group_id = 'TEST_ATTR'"); | |
| 63 | + jdbc.update("delete from code_group where group_id = 'TEST_ATTR'"); | |
| 64 | + } | |
| 65 | +} |
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?