Merge branch 'feature/common-code-refactoring'
공통코드 도입과 260727 검토본 요구사항 반영을 한 번에 올린다. 커밋 16개.
주요 변경
· 선택지를 코드테이블로 옮기고 소스에서 코드값 리터럴 제거
(처리완료 13곳·공공누리 3곳·담당자 5곳·진행단계 5곳 → 각 1곳)
· 진행단계 13단계 신설, 완료 기준을 보고서 제출 완료로 이동
· 자료 업로드에 따른 자동 단계 전환 3종과 이력 자동/수동 구분
· RE 메모, 자료실, 보고서 수정, 일괄등록 비고 신설
· 작성자 표기를 로그인 아이디에서 표시 이름으로
함께 고친 결함
· 권리확인 기존 공공누리 유형이 비어 보이던 문제 (1,926건)
· 권리처리 엑셀 왕복 시 기존 증빙자료가 사라지던 문제
· 공공누리 유형 표기를 N유형으로 통일 (68건)
배포 시 주의
마이그레이션 V13~V18이 함께 적용된다. 운영 DB에 코드표에 없는 값이 남아
있으면 해당 자료의 선택 칸이 비어 보이므로, 배포 전
src/main/resources/db/consistency/check-orphan-codes.sql 을 돌려
발견된 값을 비활성 코드로 등록해야 한다.
테스트: 백엔드 288건, 프런트 209건 전부 통과.
@93ccbdbcc848973af765cd44423b90338838e8c7
+++ 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 | +- 주간·월간 보고 양식 — 수령 대기 |
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
... | ... | @@ -8,14 +8,16 @@ |
| 8 | 8 |
import OrgOverview from './components/OrgOverview' |
| 9 | 9 |
import ProjectsPage from './components/ProjectsPage' |
| 10 | 10 |
import SeedUpload from './components/SeedUpload' |
| 11 |
+import SharedFiles from './components/SharedFiles' |
|
| 11 | 12 |
import Sidebar from './components/Sidebar' |
| 12 | 13 |
import SystemPage from './components/SystemPage' |
| 14 |
+import { CodeProvider } from './codes/CodeProvider'
|
|
| 13 | 15 |
import { useCollapsiblePanel } from './hooks/useCollapsiblePanel'
|
| 14 | 16 |
|
| 15 | 17 |
// 화면 4개: Dashboard(전체 기관 현황 - 좌측 기관 목록 없이 전폭으로 쓴다), |
| 16 | 18 |
// 기관관리(시안 레이아웃의 기관 상세), 채널관리(명부 시드 + 담당자 배정 + 채널 생성), |
| 17 | 19 |
// 담당자관리(담당자 3종 CRUD - 담당자 정보가 입력되는 유일한 화면). |
| 18 |
-type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'system' |
|
| 20 |
+type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'files' | 'system' |
|
| 19 | 21 |
|
| 20 | 22 |
const PAGE_TITLE: Record<Page, string> = {
|
| 21 | 23 |
dashboard: 'Dashboard', |
... | ... | @@ -23,6 +25,7 @@ |
| 23 | 25 |
channels: '채널관리', |
| 24 | 26 |
contacts: '담당자관리', |
| 25 | 27 |
projects: '사업관리', |
| 28 |
+ files: '자료실', |
|
| 26 | 29 |
system: '시스템관리', |
| 27 | 30 |
} |
| 28 | 31 |
|
... | ... | @@ -100,7 +103,10 @@ |
| 100 | 103 |
} |
| 101 | 104 |
} |
| 102 | 105 |
|
| 106 |
+ // 화면 선택지(진행단계·처리결과·공공누리유형 등)는 서버에서 한 번 받아 앱 전체가 나눠 쓴다. |
|
| 107 |
+ // 로그인 화면에는 필요 없으므로 인증을 통과한 다음부터 감싼다. |
|
| 103 | 108 |
return ( |
| 109 |
+ <CodeProvider> |
|
| 104 | 110 |
<div className="flex h-screen"> |
| 105 | 111 |
<Sidebar |
| 106 | 112 |
activeKey={page}
|
... | ... | @@ -113,6 +119,7 @@ |
| 113 | 119 |
key === 'channels' || |
| 114 | 120 |
key === 'contacts' || |
| 115 | 121 |
key === 'projects' || |
| 122 |
+ key === 'files' || |
|
| 116 | 123 |
key === 'system' |
| 117 | 124 |
) {
|
| 118 | 125 |
setPage(key) |
... | ... | @@ -136,7 +143,8 @@ |
| 136 | 143 |
</header> |
| 137 | 144 |
|
| 138 | 145 |
<div className="flex min-h-0 flex-1"> |
| 139 |
- {page !== 'contacts' && page !== 'dashboard' && page !== 'system' && page !== 'projects' && (
|
|
| 146 |
+ {page !== 'contacts' && page !== 'dashboard' && page !== 'system'
|
|
| 147 |
+ && page !== 'projects' && page !== 'files' && ( |
|
| 140 | 148 |
<div |
| 141 | 149 |
className={`flex shrink-0 flex-col border-r border-gray-200 transition-all duration-200 ${
|
| 142 | 150 |
listCollapsed ? 'w-14' : 'w-80' |
... | ... | @@ -172,6 +180,8 @@ |
| 172 | 180 |
setPage('orgs')
|
| 173 | 181 |
}} |
| 174 | 182 |
/> |
| 183 |
+ ) : page === 'files' ? ( |
|
| 184 |
+ <SharedFiles /> |
|
| 175 | 185 |
) : page === 'system' ? ( |
| 176 | 186 |
<SystemPage onChanged={() => void reload()} />
|
| 177 | 187 |
) : page === 'contacts' ? ( |
... | ... | @@ -189,5 +199,6 @@ |
| 189 | 199 |
</div> |
| 190 | 200 |
</div> |
| 191 | 201 |
</div> |
| 202 |
+ </CodeProvider> |
|
| 192 | 203 |
) |
| 193 | 204 |
} |
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -2,6 +2,23 @@ |
| 2 | 2 |
|
| 3 | 3 |
export type ContactCategory = 'APPLICANT' | 'MJ' | 'LAWYER' | 'OPERATOR' | 'ITN' |
| 4 | 4 |
|
| 5 |
+/** |
|
| 6 |
+ * 공통코드 한 건. 화면 선택지는 소스가 아니라 DB에서 온다. |
|
| 7 |
+ * |
|
| 8 |
+ * code는 실제로 저장되는 값이고 label은 화면 표시명이다. 둘이 다를 수 있다 - |
|
| 9 |
+ * 권리확인 세부항목은 저장값이 길고 화면에는 짧은 형태로 나온다. |
|
| 10 |
+ * attrs에는 코드별 부가 속성이 들어간다(색 토큰, 쓰이는 자리, 추천값 등). |
|
| 11 |
+ */ |
|
| 12 |
+export interface Code {
|
|
| 13 |
+ code: string |
|
| 14 |
+ label: string |
|
| 15 |
+ sortOrder: number |
|
| 16 |
+ attrs: Record<string, unknown> |
|
| 17 |
+} |
|
| 18 |
+ |
|
| 19 |
+/** 그룹ID -> 코드 목록(정렬순, 사용 중인 것만). */ |
|
| 20 |
+export type CodeMap = Record<string, Code[]> |
|
| 21 |
+ |
|
| 5 | 22 |
/** 담당자관리에 등록된 담당자 1명. 신청기관 담당자는 affiliation에 소속 기관명이 들어간다. */ |
| 6 | 23 |
export interface Contact {
|
| 7 | 24 |
id: number |
... | ... | @@ -147,6 +164,110 @@ |
| 147 | 164 |
|
| 148 | 165 |
export function getOrgs(): Promise<Org[]> {
|
| 149 | 166 |
return request<Org[]>('/api/orgs')
|
| 167 |
+} |
|
| 168 |
+ |
|
| 169 |
+/** 모든 화면 선택지를 한 번에 받는다. 로그인 직후 1회만 부른다. */ |
|
| 170 |
+export function getCodes(): Promise<CodeMap> {
|
|
| 171 |
+ return request<CodeMap>('/api/meta/codes')
|
|
| 172 |
+} |
|
| 173 |
+ |
|
| 174 |
+/** 자료실에 올려둔 공용 서식(요구사항 [26]). 기관에 딸리지 않는다. */ |
|
| 175 |
+export interface SharedFile {
|
|
| 176 |
+ id: number |
|
| 177 |
+ fileName: string |
|
| 178 |
+ description: string | null |
|
| 179 |
+ byteSize: number |
|
| 180 |
+ uploadedBy: string |
|
| 181 |
+ uploadedAt: number |
|
| 182 |
+} |
|
| 183 |
+ |
|
| 184 |
+export function getSharedFiles(): Promise<SharedFile[]> {
|
|
| 185 |
+ return request<SharedFile[]>('/api/shared-files')
|
|
| 186 |
+} |
|
| 187 |
+ |
|
| 188 |
+export async function addSharedFile(file: File, description: string): Promise<SharedFile[]> {
|
|
| 189 |
+ const form = new FormData() |
|
| 190 |
+ form.append('file', file)
|
|
| 191 |
+ if (description) {
|
|
| 192 |
+ form.append('description', new Blob([description], { type: 'text/plain' }))
|
|
| 193 |
+ } |
|
| 194 |
+ const res = await fetch('/api/shared-files', {
|
|
| 195 |
+ method: 'POST', |
|
| 196 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 197 |
+ credentials: 'same-origin', |
|
| 198 |
+ body: form, |
|
| 199 |
+ }) |
|
| 200 |
+ if (!res.ok) {
|
|
| 201 |
+ throw new ApiError(res.status, `자료 업로드 실패 (${res.status}): ${await res.text()}`)
|
|
| 202 |
+ } |
|
| 203 |
+ return res.json() as Promise<SharedFile[]> |
|
| 204 |
+} |
|
| 205 |
+ |
|
| 206 |
+export function updateSharedFileDescription(id: number, description: string): Promise<SharedFile[]> {
|
|
| 207 |
+ return request<SharedFile[]>(`/api/shared-files/${id}`, {
|
|
| 208 |
+ method: 'PUT', |
|
| 209 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 210 |
+ body: JSON.stringify({ description }),
|
|
| 211 |
+ }) |
|
| 212 |
+} |
|
| 213 |
+ |
|
| 214 |
+export async function deleteSharedFile(id: number): Promise<void> {
|
|
| 215 |
+ await fetch(`/api/shared-files/${id}`, {
|
|
| 216 |
+ method: 'DELETE', |
|
| 217 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 218 |
+ credentials: 'same-origin', |
|
| 219 |
+ }) |
|
| 220 |
+} |
|
| 221 |
+ |
|
| 222 |
+export function sharedFileDownloadUrl(id: number): string {
|
|
| 223 |
+ return `/api/shared-files/${id}`
|
|
| 224 |
+} |
|
| 225 |
+ |
|
| 226 |
+/** RE 메모 한 건. 요구사항 [4]대로 메모 1개가 RE 1개다. */ |
|
| 227 |
+export interface ReMemo {
|
|
| 228 |
+ id: number |
|
| 229 |
+ orgId: number |
|
| 230 |
+ content: string |
|
| 231 |
+ author: string |
|
| 232 |
+ /** yyyy-MM-dd */ |
|
| 233 |
+ memoDate: string |
|
| 234 |
+ resolved: boolean |
|
| 235 |
+ createdAt: number |
|
| 236 |
+} |
|
| 237 |
+ |
|
| 238 |
+export interface ReMemoInput {
|
|
| 239 |
+ content: string |
|
| 240 |
+ author: string |
|
| 241 |
+ memoDate: string |
|
| 242 |
+ resolved: boolean |
|
| 243 |
+} |
|
| 244 |
+ |
|
| 245 |
+export function getReMemos(orgId: number): Promise<ReMemo[]> {
|
|
| 246 |
+ return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos`)
|
|
| 247 |
+} |
|
| 248 |
+ |
|
| 249 |
+export function addReMemo(orgId: number, body: ReMemoInput): Promise<ReMemo[]> {
|
|
| 250 |
+ return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos`, {
|
|
| 251 |
+ method: 'POST', |
|
| 252 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 253 |
+ body: JSON.stringify(body), |
|
| 254 |
+ }) |
|
| 255 |
+} |
|
| 256 |
+ |
|
| 257 |
+export function updateReMemo(orgId: number, memoId: number, body: ReMemoInput): Promise<ReMemo[]> {
|
|
| 258 |
+ return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos/${memoId}`, {
|
|
| 259 |
+ method: 'PUT', |
|
| 260 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 261 |
+ body: JSON.stringify(body), |
|
| 262 |
+ }) |
|
| 263 |
+} |
|
| 264 |
+ |
|
| 265 |
+export async function deleteReMemo(orgId: number, memoId: number): Promise<void> {
|
|
| 266 |
+ await fetch(`/api/orgs/${orgId}/re-memos/${memoId}`, {
|
|
| 267 |
+ method: 'DELETE', |
|
| 268 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 269 |
+ credentials: 'same-origin', |
|
| 270 |
+ }) |
|
| 150 | 271 |
} |
| 151 | 272 |
|
| 152 | 273 |
export function updateAssignments(orgId: number, body: AssignmentInput): Promise<Org> {
|
... | ... | @@ -695,7 +816,14 @@ |
| 695 | 816 |
/** 권리확인 일괄 등록. 비운 항목은 기존 값을 유지한다. */ |
| 696 | 817 |
export function bulkUpdateReview( |
| 697 | 818 |
orgId: number, |
| 698 |
- body: { ids: number[]; reviewResult?: string; judgedKoglType?: string; opinion?: string },
|
|
| 819 |
+ body: {
|
|
| 820 |
+ ids: number[] |
|
| 821 |
+ reviewResult?: string |
|
| 822 |
+ judgedKoglType?: string |
|
| 823 |
+ opinion?: string |
|
| 824 |
+ /** 요구사항 [15]로 추가된 비고. */ |
|
| 825 |
+ lawyerNote?: string |
|
| 826 |
+ }, |
|
| 699 | 827 |
): Promise<{ updated: number }> {
|
| 700 | 828 |
return request<{ updated: number }>(`/api/orgs/${orgId}/review/bulk`, {
|
| 701 | 829 |
method: 'PUT', |
... | ... | @@ -715,7 +843,14 @@ |
| 715 | 843 |
/** 권리처리 일괄 등록. 비운 항목은 기존 값을 유지한다. */ |
| 716 | 844 |
export function bulkUpdateProcess( |
| 717 | 845 |
orgId: number, |
| 718 |
- body: { ids: number[]; processStatus?: string; judgedKoglType?: string; finalOpinion?: string },
|
|
| 846 |
+ body: {
|
|
| 847 |
+ ids: number[] |
|
| 848 |
+ processStatus?: string |
|
| 849 |
+ judgedKoglType?: string |
|
| 850 |
+ finalOpinion?: string |
|
| 851 |
+ /** 요구사항 [15]로 추가된 비고. */ |
|
| 852 |
+ reviewNote?: string |
|
| 853 |
+ }, |
|
| 719 | 854 |
): Promise<{ updated: number }> {
|
| 720 | 855 |
return request<{ updated: number }>(`/api/orgs/${orgId}/process/bulk`, {
|
| 721 | 856 |
method: 'PUT', |
+++ frontend/src/codes/CodeProvider.tsx
... | ... | @@ -0,0 +1,71 @@ |
| 1 | +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' | |
| 2 | +import { getCodes, type CodeMap } from '../api/client' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 화면 선택지를 서버에서 한 번 받아 앱 전체에 나눠준다. | |
| 6 | + * | |
| 7 | + * 화면마다 따로 부르지 않는 이유는 코드가 자주 안 바뀌고, 선택지를 기다리느라 폼이 | |
| 8 | + * 깜빡이는 것을 피하기 위해서다. 로그인 화면에서는 필요 없으므로 인증 이후에 감싼다. | |
| 9 | + */ | |
| 10 | +const CodeContext = createContext<CodeMap | null>(null) | |
| 11 | + | |
| 12 | +type LoadState = 'loading' | 'ready' | 'error' | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * @param codes 넘기면 서버를 부르지 않고 그 값을 그대로 쓴다. 테스트에서 화면 하나만 | |
| 16 | + * 떼어 그릴 때 쓰라고 열어둔 자리다. | |
| 17 | + */ | |
| 18 | +export function CodeProvider({ children, codes: given }: { children: ReactNode; codes?: CodeMap }) { | |
| 19 | + const [codes, setCodes] = useState<CodeMap | null>(given ?? null) | |
| 20 | + const [state, setState] = useState<LoadState>(given ? 'ready' : 'loading') | |
| 21 | + const [message, setMessage] = useState('') | |
| 22 | + | |
| 23 | + async function load() { | |
| 24 | + setState('loading') | |
| 25 | + try { | |
| 26 | + setCodes(await getCodes()) | |
| 27 | + setState('ready') | |
| 28 | + } catch (e) { | |
| 29 | + setMessage((e as Error).message) | |
| 30 | + setState('error') | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + useEffect(() => { | |
| 35 | + if (!given) { | |
| 36 | + void load() | |
| 37 | + } | |
| 38 | + }, [given]) | |
| 39 | + | |
| 40 | + if (state === 'loading') { | |
| 41 | + return <div className="p-8 text-sm text-gray-500">불러오는 중…</div> | |
| 42 | + } | |
| 43 | + | |
| 44 | + // 선택지를 못 받은 채로 화면을 그리면 빈 드롭다운이 뜨고, 거기서 저장하면 값이 | |
| 45 | + // 지워진다. 그리지 않고 오류를 보여주는 편이 안전하다. | |
| 46 | + if (state === 'error' || !codes) { | |
| 47 | + return ( | |
| 48 | + <div className="flex min-h-screen flex-col items-center justify-center gap-3 p-8 text-sm"> | |
| 49 | + <p className="text-red-600">선택지 정보를 불러오지 못했습니다. {message}</p> | |
| 50 | + <button | |
| 51 | + type="button" | |
| 52 | + onClick={() => void load()} | |
| 53 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm" | |
| 54 | + > | |
| 55 | + 다시 시도 | |
| 56 | + </button> | |
| 57 | + </div> | |
| 58 | + ) | |
| 59 | + } | |
| 60 | + | |
| 61 | + return <CodeContext.Provider value={codes}>{children}</CodeContext.Provider> | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** Provider 밖에서 부르면 개발 중에 바로 알아채도록 예외를 던진다. */ | |
| 65 | +export function useCodeMap(): CodeMap { | |
| 66 | + const codes = useContext(CodeContext) | |
| 67 | + if (!codes) { | |
| 68 | + throw new Error('CodeProvider 안에서만 쓸 수 있습니다.') | |
| 69 | + } | |
| 70 | + return codes | |
| 71 | +} |
+++ frontend/src/codes/codes.test.tsx
... | ... | @@ -0,0 +1,203 @@ |
| 1 | +import { render, screen, waitFor } from '@testing-library/react' | |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' | |
| 3 | +import { CodeProvider } from './CodeProvider' | |
| 4 | +import { TEST_CODES, withCodes } from './fixtures' | |
| 5 | +import { useStages } from './stage' | |
| 6 | +import { badgeClass, barClass } from './tone' | |
| 7 | +import { useCodeLabel, useCodes, useScopedCodes, useSplitByFirstRow } from './useCodes' | |
| 8 | + | |
| 9 | +const mocks = vi.hoisted(() => ({ getCodes: vi.fn() })) | |
| 10 | + | |
| 11 | +vi.mock('../api/client', async (importOriginal) => ({ | |
| 12 | + ...(await importOriginal<typeof import('../api/client')>()), | |
| 13 | + getCodes: mocks.getCodes, | |
| 14 | +})) | |
| 15 | + | |
| 16 | +beforeEach(() => { | |
| 17 | + mocks.getCodes.mockReset() | |
| 18 | +}) | |
| 19 | + | |
| 20 | +describe('useCodes', () => { | |
| 21 | + it('그룹의 선택지를 정렬순서대로 준다', () => { | |
| 22 | + function Probe() { | |
| 23 | + return <span>{useCodes('REVIEW_RESULT').map((c) => c.label).join('|')}</span> | |
| 24 | + } | |
| 25 | + render(withCodes(<Probe />)) | |
| 26 | + | |
| 27 | + expect( | |
| 28 | + screen.getByText('신유형 개방|계약서 등 재확인|권리처리 추진|개방불가|권리처리 추진 미희망'), | |
| 29 | + ).toBeTruthy() | |
| 30 | + }) | |
| 31 | + | |
| 32 | + it('없는 그룹이면 빈 배열이라 화면이 죽지 않는다', () => { | |
| 33 | + function Probe() { | |
| 34 | + return <span data-testid="n">{useCodes('NO_SUCH_GROUP').length}</span> | |
| 35 | + } | |
| 36 | + render(withCodes(<Probe />)) | |
| 37 | + | |
| 38 | + expect(screen.getByTestId('n').textContent).toBe('0') | |
| 39 | + }) | |
| 40 | + | |
| 41 | + it('저장된 값의 표시명을 준다', () => { | |
| 42 | + function Probe() { | |
| 43 | + return <span>{useCodeLabel('CONTACT_CATEGORY', 'MJ')}</span> | |
| 44 | + } | |
| 45 | + render(withCodes(<Probe />)) | |
| 46 | + | |
| 47 | + // 엑셀에는 '주관기관'으로 오지만 화면에는 '문정원'으로 나와야 한다. | |
| 48 | + expect(screen.getByText('문정원')).toBeTruthy() | |
| 49 | + }) | |
| 50 | + | |
| 51 | + it('코드표에 없는 값이면 값 자체를 보여준다', () => { | |
| 52 | + function Probe() { | |
| 53 | + return <span>{useCodeLabel('CONTACT_METHOD', '문자')}</span> | |
| 54 | + } | |
| 55 | + render(withCodes(<Probe />)) | |
| 56 | + | |
| 57 | + // 과거 데이터가 빈칸으로 사라지는 것보다 원문이라도 보이는 편이 낫다. | |
| 58 | + expect(screen.getByText('문자')).toBeTruthy() | |
| 59 | + }) | |
| 60 | +}) | |
| 61 | + | |
| 62 | +describe('공공누리 유형의 사용처 구분', () => { | |
| 63 | + function ScopeProbe({ scope }: { scope: string }) { | |
| 64 | + return <span>{useScopedCodes('KOGL_TYPE', scope).map((c) => c.code).join(',')}</span> | |
| 65 | + } | |
| 66 | + | |
| 67 | + it('변호사 판정에는 보류가 있다', () => { | |
| 68 | + render(withCodes(<ScopeProbe scope="review" />)) | |
| 69 | + expect(screen.getByText('0유형,1유형,2유형,3유형,4유형,개방불가,보류')).toBeTruthy() | |
| 70 | + }) | |
| 71 | + | |
| 72 | + it('권리처리 판정에는 보류가 없다', () => { | |
| 73 | + render(withCodes(<ScopeProbe scope="process" />)) | |
| 74 | + expect(screen.getByText('0유형,1유형,2유형,3유형,4유형,개방불가')).toBeTruthy() | |
| 75 | + }) | |
| 76 | + | |
| 77 | + it('기존 부착 유형에는 조사표에서 온 값이 들어간다', () => { | |
| 78 | + render(withCodes(<ScopeProbe scope="prior" />)) | |
| 79 | + expect(screen.getByText('0유형,1유형,2유형,3유형,4유형,유형 확인 필요,제4유형')).toBeTruthy() | |
| 80 | + }) | |
| 81 | + | |
| 82 | + it('라디오 첫 줄과 둘째 줄을 나눈다', () => { | |
| 83 | + function Probe() { | |
| 84 | + const { first, rest } = useSplitByFirstRow('KOGL_TYPE', 'review') | |
| 85 | + return ( | |
| 86 | + <> | |
| 87 | + <span data-testid="first">{first.map((c) => c.code).join(',')}</span> | |
| 88 | + <span data-testid="rest">{rest.map((c) => c.code).join(',')}</span> | |
| 89 | + </> | |
| 90 | + ) | |
| 91 | + } | |
| 92 | + render(withCodes(<Probe />)) | |
| 93 | + | |
| 94 | + expect(screen.getByTestId('first').textContent).toBe('0유형,개방불가,보류') | |
| 95 | + expect(screen.getByTestId('rest').textContent).toBe('1유형,2유형,3유형,4유형') | |
| 96 | + }) | |
| 97 | +}) | |
| 98 | + | |
| 99 | +describe('useStages', () => { | |
| 100 | + function StageProbe() { | |
| 101 | + const stages = useStages() | |
| 102 | + return ( | |
| 103 | + <> | |
| 104 | + <span data-testid="count">{stages.count}</span> | |
| 105 | + <span data-testid="label13">{stages.label(13)}</span> | |
| 106 | + <span data-testid="symbol13">{stages.symbol(13)}</span> | |
| 107 | + <span data-testid="symbol1">{stages.symbol(1)}</span> | |
| 108 | + <span data-testid="group5">{stages.groupKey(5)}</span> | |
| 109 | + <span data-testid="tone-assign">{stages.groupTone('assign')}</span> | |
| 110 | + <span data-testid="progress1">{stages.progressOf(1)}</span> | |
| 111 | + <span data-testid="progress7">{stages.progressOf(7)}</span> | |
| 112 | + <span data-testid="progress13">{stages.progressOf(13)}</span> | |
| 113 | + <span data-testid="completed-from">{stages.kpiThreshold('completed')}</span> | |
| 114 | + <span data-testid="counts12">{String(stages.countsToward('completed', 12))}</span> | |
| 115 | + <span data-testid="counts13">{String(stages.countsToward('completed', 13))}</span> | |
| 116 | + </> | |
| 117 | + ) | |
| 118 | + } | |
| 119 | + | |
| 120 | + it('단계 수와 라벨이 코드표에서 온다', () => { | |
| 121 | + render(withCodes(<StageProbe />)) | |
| 122 | + | |
| 123 | + expect(screen.getByTestId('count').textContent).toBe('13') | |
| 124 | + expect(screen.getByTestId('label13').textContent).toBe('보고서 제출 완료') | |
| 125 | + }) | |
| 126 | + | |
| 127 | + it('원문자는 번호에서 계산한다', () => { | |
| 128 | + render(withCodes(<StageProbe />)) | |
| 129 | + | |
| 130 | + expect(screen.getByTestId('symbol1').textContent).toBe('①') | |
| 131 | + expect(screen.getByTestId('symbol13').textContent).toBe('⑬') | |
| 132 | + }) | |
| 133 | + | |
| 134 | + it('진행상태는 1단계만 신청이고 13단계만 완료다', () => { | |
| 135 | + render(withCodes(<StageProbe />)) | |
| 136 | + | |
| 137 | + expect(screen.getByTestId('progress1').textContent).toBe('applied') | |
| 138 | + expect(screen.getByTestId('progress7').textContent).toBe('active') | |
| 139 | + expect(screen.getByTestId('progress13').textContent).toBe('done') | |
| 140 | + }) | |
| 141 | + | |
| 142 | + it('완료 KPI는 보고서가 올라온 13단계부터 센다', () => { | |
| 143 | + render(withCodes(<StageProbe />)) | |
| 144 | + | |
| 145 | + expect(screen.getByTestId('completed-from').textContent).toBe('13') | |
| 146 | + expect(screen.getByTestId('counts12').textContent).toBe('false') | |
| 147 | + expect(screen.getByTestId('counts13').textContent).toBe('true') | |
| 148 | + }) | |
| 149 | + | |
| 150 | + it('업무구간과 색이 코드표에서 온다', () => { | |
| 151 | + render(withCodes(<StageProbe />)) | |
| 152 | + | |
| 153 | + expect(screen.getByTestId('group5').textContent).toBe('check') | |
| 154 | + expect(screen.getByTestId('tone-assign').textContent).toBe('indigo') | |
| 155 | + }) | |
| 156 | +}) | |
| 157 | + | |
| 158 | +describe('tone', () => { | |
| 159 | + it('의미 토큰을 실제 클래스로 바꾼다', () => { | |
| 160 | + expect(badgeClass('emerald')).toBe('bg-emerald-50 text-emerald-700') | |
| 161 | + expect(barClass('indigo')).toBe('bg-indigo-400') | |
| 162 | + }) | |
| 163 | + | |
| 164 | + it('모르는 토큰은 회색으로 떨어뜨린다', () => { | |
| 165 | + // 코드 관리 화면에서 오타가 들어가도 색만 밋밋해질 뿐 항목이 사라지면 안 된다. | |
| 166 | + expect(badgeClass('없는색')).toBe('bg-gray-100 text-gray-700') | |
| 167 | + expect(barClass(null)).toBe('bg-gray-300') | |
| 168 | + }) | |
| 169 | +}) | |
| 170 | + | |
| 171 | +describe('CodeProvider', () => { | |
| 172 | + it('서버에서 선택지를 받아 아래 화면에 넘긴다', async () => { | |
| 173 | + mocks.getCodes.mockResolvedValue(TEST_CODES) | |
| 174 | + | |
| 175 | + function Probe() { | |
| 176 | + return <span>{useCodes('PROCESS_STATUS').map((c) => c.label).join(',')}</span> | |
| 177 | + } | |
| 178 | + render( | |
| 179 | + <CodeProvider> | |
| 180 | + <Probe /> | |
| 181 | + </CodeProvider>, | |
| 182 | + ) | |
| 183 | + | |
| 184 | + await waitFor(() => expect(screen.getByText('미처리,처리완료')).toBeTruthy()) | |
| 185 | + }) | |
| 186 | + | |
| 187 | + it('선택지를 못 받으면 화면을 그리지 않고 오류를 보여준다', async () => { | |
| 188 | + mocks.getCodes.mockRejectedValue(new Error('서버 오류')) | |
| 189 | + | |
| 190 | + function Probe() { | |
| 191 | + return <span>그려지면 안 됨</span> | |
| 192 | + } | |
| 193 | + render( | |
| 194 | + <CodeProvider> | |
| 195 | + <Probe /> | |
| 196 | + </CodeProvider>, | |
| 197 | + ) | |
| 198 | + | |
| 199 | + // 빈 드롭다운으로 저장하면 값이 지워지므로 아예 안 그리는 편이 안전하다. | |
| 200 | + await waitFor(() => expect(screen.getByText(/선택지 정보를 불러오지 못했습니다/)).toBeTruthy()) | |
| 201 | + expect(screen.queryByText('그려지면 안 됨')).toBeNull() | |
| 202 | + }) | |
| 203 | +}) |
+++ frontend/src/codes/fixtures.tsx
... | ... | @@ -0,0 +1,120 @@ |
| 1 | +import type { ReactElement } from 'react' | |
| 2 | +import type { Code, CodeMap } from '../api/client' | |
| 3 | +import { CodeProvider } from './CodeProvider' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 테스트용 코드표. V13~V15 시드와 같은 값이라 화면 테스트가 실제 선택지로 돌아간다. | |
| 7 | + * | |
| 8 | + * 서버 응답을 흉내 내는 것이므로 비활성 코드는 들어 있지 않다(서버가 활성만 내려준다). | |
| 9 | + * 시드가 바뀌면 여기도 같이 고쳐야 한다 - 어긋나면 화면 테스트가 실제와 다른 선택지로 | |
| 10 | + * 통과해버린다. | |
| 11 | + */ | |
| 12 | +function code(c: string, label: string, sortOrder: number, attrs: Record<string, unknown> = {}): Code { | |
| 13 | + return { code: c, label, sortOrder, attrs } | |
| 14 | +} | |
| 15 | + | |
| 16 | +export const TEST_CODES: CodeMap = { | |
| 17 | + STAGE: [ | |
| 18 | + code('1', '신청', 1, { groupKey: 'intake', progress: 'applied' }), | |
| 19 | + code('2', '목록접수', 2, { groupKey: 'intake', progress: 'active', kpiFrom: 'docSubmitted' }), | |
| 20 | + code('3', '예비검토', 3, { groupKey: 'intake', progress: 'active' }), | |
| 21 | + code('4', '변호사 배당', 4, { groupKey: 'assign', progress: 'active' }), | |
| 22 | + code('5', '법률검토(권리확인)', 5, { groupKey: 'check', progress: 'active' }), | |
| 23 | + code('6', 'RE:확인', 6, { groupKey: 'check', progress: 'active' }), | |
| 24 | + code('7', '법률검토(권리확인) 완료', 7, { groupKey: 'check', progress: 'active' }), | |
| 25 | + code('8', '법률검토(권리처리)', 8, { groupKey: 'process', progress: 'active' }), | |
| 26 | + code('9', 'RE:처리', 9, { groupKey: 'process', progress: 'active' }), | |
| 27 | + code('10', '법률검토(권리처리) 완료', 10, { groupKey: 'process', progress: 'active' }), | |
| 28 | + code('11', '법률검토 최종완료', 11, { groupKey: 'closing', progress: 'active' }), | |
| 29 | + code('12', '보고서 작성', 12, { groupKey: 'closing', progress: 'active', kpiFrom: 'reportWriting' }), | |
| 30 | + code('13', '보고서 제출 완료', 13, { groupKey: 'closing', progress: 'done', kpiFrom: 'completed' }), | |
| 31 | + ], | |
| 32 | + STAGE_GROUP: [ | |
| 33 | + code('intake', '접수', 1, { tone: 'slate' }), | |
| 34 | + code('assign', '배당', 2, { tone: 'indigo' }), | |
| 35 | + code('check', '권리확인', 3, { tone: 'emerald' }), | |
| 36 | + code('process', '권리처리', 4, { tone: 'amber' }), | |
| 37 | + code('closing', '마감', 5, { tone: 'violet' }), | |
| 38 | + ], | |
| 39 | + REVIEW_MAJOR: [ | |
| 40 | + code('만료 저작물', '만료 저작물', 1, { suggest: '신유형 개방', hasMinor: false }), | |
| 41 | + code('업무상 저작물', '업무상 저작물', 2, { suggest: '신유형 개방', hasMinor: false }), | |
| 42 | + code('제3자 권리', '제3자 권리', 3, { hasMinor: true }), | |
| 43 | + code('개인정보 포함', '개인정보 포함', 4, { suggest: '개방불가', hasMinor: false }), | |
| 44 | + ], | |
| 45 | + REVIEW_MINOR: [ | |
| 46 | + code('1. 원시적 권리 전부 보유', '1. 원시적 권리 전부 보유', 1, { suggest: '신유형 개방' }), | |
| 47 | + code('2. 후천적 권리 전부 보유(계약서 확인 필요)', '2. 후천적 권리 전부 보유', 2, { | |
| 48 | + note: '(계약에 의한 전부 양수)', | |
| 49 | + suggest: '계약서 등 재확인', | |
| 50 | + }), | |
| 51 | + code('3. 권리 일부(공동) 보유', '3. 권리 일부(공동) 보유', 3, { | |
| 52 | + note: '(보도자료·제3자저작물, 계약에 의한 전부/공동 보유 등)', | |
| 53 | + suggest: '권리처리 추진', | |
| 54 | + allowed: ['권리처리 추진', '권리처리 추진 미희망'], | |
| 55 | + }), | |
| 56 | + code('4. 권리 미보유', '4. 권리 미보유', 4, { | |
| 57 | + note: '(공모전 수상작 등)', | |
| 58 | + suggest: '권리처리 추진', | |
| 59 | + allowed: ['권리처리 추진', '권리처리 추진 미희망'], | |
| 60 | + }), | |
| 61 | + code('초상권 포함', '초상권 포함', 5, { | |
| 62 | + suggest: '권리처리 추진', | |
| 63 | + allowed: ['권리처리 추진', '권리처리 추진 미희망', '개방불가'], | |
| 64 | + }), | |
| 65 | + ], | |
| 66 | + REVIEW_RESULT: [ | |
| 67 | + code('신유형 개방', '신유형 개방', 1, { tone: 'emerald', openable: 'Y' }), | |
| 68 | + code('계약서 등 재확인', '계약서 등 재확인', 2, { tone: 'amber' }), | |
| 69 | + code('권리처리 추진', '권리처리 추진', 3, { tone: 'blue', needsProcessing: true, koglSet: '보류' }), | |
| 70 | + code('개방불가', '개방불가', 4, { tone: 'red', needsProcessing: false, koglSet: '개방불가' }), | |
| 71 | + code('권리처리 추진 미희망', '권리처리 추진 미희망', 5, { | |
| 72 | + tone: 'slate', | |
| 73 | + needsProcessing: false, | |
| 74 | + koglCopiesPrior: true, | |
| 75 | + }), | |
| 76 | + ], | |
| 77 | + KOGL_TYPE: [ | |
| 78 | + code('0유형', '0유형', 1, { scopes: ['review', 'process', 'prior'], firstRow: ['review', 'process'] }), | |
| 79 | + code('1유형', '1유형', 2, { scopes: ['review', 'process', 'prior'], firstRow: [] }), | |
| 80 | + code('2유형', '2유형', 3, { scopes: ['review', 'process', 'prior'], firstRow: [] }), | |
| 81 | + code('3유형', '3유형', 4, { scopes: ['review', 'process', 'prior'], firstRow: [] }), | |
| 82 | + code('4유형', '4유형', 5, { scopes: ['review', 'process', 'prior'], firstRow: [] }), | |
| 83 | + code('개방불가', '개방불가', 6, { scopes: ['review', 'process'], firstRow: ['review', 'process'] }), | |
| 84 | + code('보류', '보류', 7, { scopes: ['review'], firstRow: ['review'] }), | |
| 85 | + code('유형 확인 필요', '유형 확인 필요', 8, { scopes: ['prior'], firstRow: [], surveyOnly: true }), | |
| 86 | + code('제4유형', '제4유형', 9, { scopes: ['prior'], firstRow: [], surveyOnly: true, sameAs: '4유형' }), | |
| 87 | + ], | |
| 88 | + PROCESS_STATUS: [ | |
| 89 | + code('미처리', '미처리', 1, { done: false }), | |
| 90 | + code('처리완료', '처리완료', 2, { done: true, stampsProcessedAt: true }), | |
| 91 | + ], | |
| 92 | + CONTRACT_DOC: [ | |
| 93 | + code('양도계약서', '양도계약서', 1), | |
| 94 | + code('제안요청서', '제안요청서', 2), | |
| 95 | + code('초상이용동의서', '초상이용동의서', 3), | |
| 96 | + code('공공누리동의서', '공공누리동의서', 4), | |
| 97 | + code('공문', '공문', 5), | |
| 98 | + code('기타', '기타', 6, { freeText: true }), | |
| 99 | + ], | |
| 100 | + CONTACT_METHOD: [ | |
| 101 | + code('전화', '전화', 1), | |
| 102 | + code('메일', '메일', 2), | |
| 103 | + code('방문', '방문', 3), | |
| 104 | + code('기타', '기타', 4), | |
| 105 | + ], | |
| 106 | + CONTACT_CATEGORY: [ | |
| 107 | + code('APPLICANT', '신청기관', 1, { tone: 'blue', importAlias: '신청기관' }), | |
| 108 | + code('MJ', '문정원', 2, { tone: 'emerald', importAlias: '주관기관' }), | |
| 109 | + code('LAWYER', '변호사', 3, { tone: 'violet', importAlias: '변호사' }), | |
| 110 | + code('OPERATOR', '수행기관', 4, { tone: 'sky', importAlias: '수행기관' }), | |
| 111 | + code('ITN', '아이티앤 담당자', 5, { tone: 'orange', importAlias: '아이티앤 담당자' }), | |
| 112 | + ], | |
| 113 | + ATTACHMENT_YN: [code('있음', '있음', 1), code('없음', '없음', 2)], | |
| 114 | + KOGL_ATTACHED: [code('부착', '부착', 1), code('미부착', '미부착', 2)], | |
| 115 | +} | |
| 116 | + | |
| 117 | +/** 화면 하나를 코드표와 함께 그린다. 서버를 부르지 않는다. */ | |
| 118 | +export function withCodes(ui: ReactElement, codes: CodeMap = TEST_CODES): ReactElement { | |
| 119 | + return <CodeProvider codes={codes}>{ui}</CodeProvider> | |
| 120 | +} |
+++ frontend/src/codes/messages.ts
... | ... | @@ -0,0 +1,7 @@ |
| 1 | +/** | |
| 2 | + * 화면이 자동으로 채워 넣는 안내 문구. 코드값이 아니라 문장이라 코드표에 넣지 않고 | |
| 3 | + * 여기 모아 둔다 - 코드표에 문장을 넣으면 선택지 목록에 섞여 관리가 어려워진다. | |
| 4 | + */ | |
| 5 | + | |
| 6 | +/** 권리처리를 추진하지 않아 기존 공공누리 유형을 그대로 쓸 때 비고에 남기는 문구. */ | |
| 7 | +export const NOTE_NO_PROCESSING = '권리처리 추진 미희망에 따른 기존 공공누리 부착' |
+++ frontend/src/codes/stage.ts
... | ... | @@ -0,0 +1,89 @@ |
| 1 | +import { useMemo } from 'react' | |
| 2 | +import type { Code } from '../api/client' | |
| 3 | +import { useCodes } from './useCodes' | |
| 4 | + | |
| 5 | +/** 원문자 ①(U+2460)부터 ⑳까지. 그 밖은 번호를 그대로 쓴다. */ | |
| 6 | +const CIRCLED_BASE = 0x2460 | |
| 7 | +const CIRCLED_MAX = 20 | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 진행단계를 다루는 헬퍼. 단계 개수·라벨·구간·진행상태·KPI 임계값이 전부 코드표에서 온다. | |
| 11 | + * | |
| 12 | + * 예전에는 stages.ts에 12개 라벨 배열이 박혀 있어서 단계를 하나 늘리려면 백엔드 4곳, | |
| 13 | + * 프론트 8곳을 같이 고쳐야 했다. 이제 코드표 한 줄이면 된다. | |
| 14 | + */ | |
| 15 | +export interface Stages { | |
| 16 | + /** 등록된 단계 수. 지금은 13. */ | |
| 17 | + count: number | |
| 18 | + /** [1, 2, ... count] */ | |
| 19 | + numbers: number[] | |
| 20 | + /** 화면 표시명. 모르는 번호면 번호를 문자열로 돌려준다. */ | |
| 21 | + label(stage: number): string | |
| 22 | + /** ①②③… 20을 넘으면 번호 문자열. */ | |
| 23 | + symbol(stage: number): string | |
| 24 | + /** 단계가 속한 업무구간 key. 미지정(null)이면 null. */ | |
| 25 | + groupKey(stage: number | null): string | null | |
| 26 | + /** 업무구간 목록(정렬순). 칸반 색과 필터가 쓴다. */ | |
| 27 | + groups: Code[] | |
| 28 | + /** 업무구간의 tone. 모르면 null. */ | |
| 29 | + groupTone(key: string | null): string | null | |
| 30 | + /** 진행상태 - 'applied' | 'active' | 'done'. 단계 미지정이면 null. */ | |
| 31 | + progressOf(stage: number | null): string | null | |
| 32 | + /** 이 KPI에 집계하기 시작하는 단계 번호. 지정이 없으면 null. */ | |
| 33 | + kpiThreshold(kpi: string): number | null | |
| 34 | + /** 해당 단계가 이 KPI 집계 대상인지(임계값 이상인지). */ | |
| 35 | + countsToward(kpi: string, stage: number | null): boolean | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function useStages(): Stages { | |
| 39 | + const stageCodes = useCodes('STAGE') | |
| 40 | + const groups = useCodes('STAGE_GROUP') | |
| 41 | + | |
| 42 | + return useMemo(() => { | |
| 43 | + const byNumber = new Map<number, Code>() | |
| 44 | + for (const code of stageCodes) { | |
| 45 | + byNumber.set(Number(code.code), code) | |
| 46 | + } | |
| 47 | + const numbers = stageCodes.map((code) => Number(code.code)).sort((a, b) => a - b) | |
| 48 | + | |
| 49 | + function attrOf(stage: number | null, key: string): string | null { | |
| 50 | + if (stage === null) { | |
| 51 | + return null | |
| 52 | + } | |
| 53 | + const value = byNumber.get(stage)?.attrs[key] | |
| 54 | + return value == null ? null : String(value) | |
| 55 | + } | |
| 56 | + | |
| 57 | + function kpiThreshold(kpi: string): number | null { | |
| 58 | + for (const [number, code] of byNumber) { | |
| 59 | + if (code.attrs.kpiFrom === kpi) { | |
| 60 | + return number | |
| 61 | + } | |
| 62 | + } | |
| 63 | + return null | |
| 64 | + } | |
| 65 | + | |
| 66 | + return { | |
| 67 | + count: numbers.length, | |
| 68 | + numbers, | |
| 69 | + label: (stage) => byNumber.get(stage)?.label ?? String(stage), | |
| 70 | + symbol: (stage) => | |
| 71 | + stage >= 1 && stage <= CIRCLED_MAX | |
| 72 | + ? String.fromCharCode(CIRCLED_BASE + stage - 1) | |
| 73 | + : String(stage), | |
| 74 | + groupKey: (stage) => attrOf(stage, 'groupKey'), | |
| 75 | + groups, | |
| 76 | + groupTone: (key) => { | |
| 77 | + const found = groups.find((group) => group.code === key) | |
| 78 | + const tone = found?.attrs.tone | |
| 79 | + return tone == null ? null : String(tone) | |
| 80 | + }, | |
| 81 | + progressOf: (stage) => attrOf(stage, 'progress'), | |
| 82 | + kpiThreshold, | |
| 83 | + countsToward: (kpi, stage) => { | |
| 84 | + const from = kpiThreshold(kpi) | |
| 85 | + return from !== null && stage !== null && stage >= from | |
| 86 | + }, | |
| 87 | + } | |
| 88 | + }, [stageCodes, groups]) | |
| 89 | +} |
+++ frontend/src/codes/tone.ts
... | ... | @@ -0,0 +1,52 @@ |
| 1 | +/** | |
| 2 | + * 코드의 tone(의미 토큰)을 실제 Tailwind 클래스로 바꾼다. | |
| 3 | + * | |
| 4 | + * DB에는 'emerald' 같은 이름표만 저장하고 클래스는 여기서 붙인다. DB에 Tailwind 클래스를 | |
| 5 | + * 넣으면 화면 스타일을 손볼 때마다 DB를 고쳐야 하기 때문이다. | |
| 6 | + * | |
| 7 | + * 맵을 풀어 쓴 이유는 Tailwind가 `bg-${tone}-50` 같은 조립식 클래스명을 빌드 시점에 | |
| 8 | + * 못 찾아 지워버리기 때문이다. 반드시 문자열 전체가 소스에 보여야 한다. | |
| 9 | + */ | |
| 10 | + | |
| 11 | +/** 뱃지: 옅은 배경 + 진한 글씨. 처리결과·담당자 구분이 쓴다. */ | |
| 12 | +const BADGE: Record<string, string> = { | |
| 13 | + slate: 'bg-slate-100 text-slate-700', | |
| 14 | + gray: 'bg-gray-100 text-gray-700', | |
| 15 | + blue: 'bg-blue-50 text-blue-700', | |
| 16 | + sky: 'bg-sky-50 text-sky-700', | |
| 17 | + indigo: 'bg-indigo-50 text-indigo-700', | |
| 18 | + violet: 'bg-violet-50 text-violet-700', | |
| 19 | + emerald: 'bg-emerald-50 text-emerald-700', | |
| 20 | + amber: 'bg-amber-50 text-amber-700', | |
| 21 | + orange: 'bg-orange-50 text-orange-700', | |
| 22 | + red: 'bg-red-50 text-red-700', | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** 색 띠: 칸반 컬럼 상단에 그리는 한 줄. 업무구간이 쓴다. */ | |
| 26 | +const BAR: Record<string, string> = { | |
| 27 | + slate: 'bg-slate-400', | |
| 28 | + gray: 'bg-gray-400', | |
| 29 | + blue: 'bg-blue-400', | |
| 30 | + sky: 'bg-sky-400', | |
| 31 | + indigo: 'bg-indigo-400', | |
| 32 | + violet: 'bg-violet-400', | |
| 33 | + emerald: 'bg-emerald-400', | |
| 34 | + amber: 'bg-amber-400', | |
| 35 | + orange: 'bg-orange-400', | |
| 36 | + red: 'bg-red-400', | |
| 37 | +} | |
| 38 | + | |
| 39 | +const BADGE_FALLBACK = 'bg-gray-100 text-gray-700' | |
| 40 | +const BAR_FALLBACK = 'bg-gray-300' | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * 모르는 tone이 와도 화면이 깨지지 않게 회색으로 떨어뜨린다 - 코드 관리 화면에서 | |
| 44 | + * 오타가 들어가도 색만 밋밋해질 뿐 항목이 사라지지는 않아야 한다. | |
| 45 | + */ | |
| 46 | +export function badgeClass(tone: string | null | undefined): string { | |
| 47 | + return (tone && BADGE[tone]) || BADGE_FALLBACK | |
| 48 | +} | |
| 49 | + | |
| 50 | +export function barClass(tone: string | null | undefined): string { | |
| 51 | + return (tone && BAR[tone]) || BAR_FALLBACK | |
| 52 | +} |
+++ frontend/src/codes/useCodes.ts
... | ... | @@ -0,0 +1,69 @@ |
| 1 | +import { useCodeMap } from './CodeProvider' | |
| 2 | +import type { Code } from '../api/client' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 그룹의 선택지. 서버가 정렬순서대로, 사용 중인 것만 내려준다. | |
| 6 | + * 없는 그룹이면 빈 배열이라 화면이 터지지는 않는다. | |
| 7 | + */ | |
| 8 | +export function useCodes(groupId: string): Code[] { | |
| 9 | + return useCodeMap()[groupId] ?? [] | |
| 10 | +} | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * 쓰이는 자리(scope)로 거른 선택지. 공공누리 유형이 이걸 쓴다 - | |
| 14 | + * 같은 코드체계라도 변호사 판정(review)·권리처리 판정(process)·기존 부착(prior)에 | |
| 15 | + * 나오는 목록이 서로 다르다. | |
| 16 | + */ | |
| 17 | +export function useScopedCodes(groupId: string, scope: string): Code[] { | |
| 18 | + return useCodes(groupId).filter((code) => scopes(code).includes(scope)) | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** 라디오 첫 줄에 놓을 것과 둘째 줄에 놓을 것으로 나눈다. */ | |
| 22 | +export function useSplitByFirstRow( | |
| 23 | + groupId: string, | |
| 24 | + scope: string, | |
| 25 | +): { first: Code[]; rest: Code[] } { | |
| 26 | + const codes = useScopedCodes(groupId, scope) | |
| 27 | + return { | |
| 28 | + first: codes.filter((code) => firstRow(code).includes(scope)), | |
| 29 | + rest: codes.filter((code) => !firstRow(code).includes(scope)), | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * 저장된 값의 화면 표시명. 코드표에 없는 값이면 값 자체를 그대로 돌려준다 - | |
| 35 | + * 과거 데이터가 화면에서 빈칸으로 사라지는 것보다 원문이라도 보이는 편이 낫다. | |
| 36 | + */ | |
| 37 | +export function useCodeLabel(groupId: string, code: string | null | undefined): string { | |
| 38 | + const found = useCodes(groupId).find((candidate) => candidate.code === code) | |
| 39 | + return found?.label ?? code ?? '' | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** 코드 한 건. 없으면 undefined. */ | |
| 43 | +export function useCode(groupId: string, code: string | null | undefined): Code | undefined { | |
| 44 | + return useCodes(groupId).find((candidate) => candidate.code === code) | |
| 45 | +} | |
| 46 | + | |
| 47 | +function stringList(code: Code, key: string): string[] { | |
| 48 | + const raw = code.attrs[key] | |
| 49 | + return Array.isArray(raw) ? (raw as string[]) : [] | |
| 50 | +} | |
| 51 | + | |
| 52 | +export function scopes(code: Code): string[] { | |
| 53 | + return stringList(code, 'scopes') | |
| 54 | +} | |
| 55 | + | |
| 56 | +export function firstRow(code: Code): string[] { | |
| 57 | + return stringList(code, 'firstRow') | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** attrs의 문자열 속성. 없으면 null. */ | |
| 61 | +export function attr(code: Code | undefined, key: string): string | null { | |
| 62 | + const value = code?.attrs[key] | |
| 63 | + return value == null ? null : String(value) | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** attrs의 불리언 속성. 없으면 false. */ | |
| 67 | +export function flag(code: Code | undefined, key: string): boolean { | |
| 68 | + return code?.attrs[key] === true | |
| 69 | +} |
--- frontend/src/components/ContactsPage.test.tsx
+++ frontend/src/components/ContactsPage.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import ContactsPage from './ContactsPage' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { Contact } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -46,7 +47,7 @@ |
| 46 | 47 |
it('담당자 목록을 구분·성명 등과 함께 보여준다', async () => {
|
| 47 | 48 |
mocks.getContacts.mockResolvedValue([contact()]) |
| 48 | 49 |
|
| 49 |
- render(<ContactsPage />) |
|
| 50 |
+ render(withCodes(<ContactsPage />)) |
|
| 50 | 51 |
|
| 51 | 52 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 52 | 53 |
expect(screen.getByRole('cell', { name: '신청기관' })).toBeTruthy()
|
... | ... | @@ -59,7 +60,7 @@ |
| 59 | 60 |
it('담당자가 없으면 안내 문구를 보여준다', async () => {
|
| 60 | 61 |
mocks.getContacts.mockResolvedValue([]) |
| 61 | 62 |
|
| 62 |
- render(<ContactsPage />) |
|
| 63 |
+ render(withCodes(<ContactsPage />)) |
|
| 63 | 64 |
|
| 64 | 65 |
await waitFor(() => {
|
| 65 | 66 |
expect(screen.getByText('등록된 담당자가 없습니다.')).toBeTruthy()
|
... | ... | @@ -72,7 +73,7 @@ |
| 72 | 73 |
contact({ id: 2, name: '이변호', category: 'LAWYER', affiliation: null, deptName: null }),
|
| 73 | 74 |
]) |
| 74 | 75 |
|
| 75 |
- render(<ContactsPage />) |
|
| 76 |
+ render(withCodes(<ContactsPage />)) |
|
| 76 | 77 |
|
| 77 | 78 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 78 | 79 |
|
... | ... | @@ -88,7 +89,7 @@ |
| 88 | 89 |
contact({ id: 2, name: '홍수행', category: 'OPERATOR', affiliation: '수행사', deptName: '운영팀' }),
|
| 89 | 90 |
]) |
| 90 | 91 |
|
| 91 |
- render(<ContactsPage />) |
|
| 92 |
+ render(withCodes(<ContactsPage />)) |
|
| 92 | 93 |
|
| 93 | 94 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 94 | 95 |
|
... | ... | @@ -101,7 +102,7 @@ |
| 101 | 102 |
it('담당자 추가를 누르면 모달이 뜨고, 구분과 성명을 채워야 저장이 활성화된다', async () => {
|
| 102 | 103 |
mocks.getContacts.mockResolvedValue([]) |
| 103 | 104 |
|
| 104 |
- render(<ContactsPage />) |
|
| 105 |
+ render(withCodes(<ContactsPage />)) |
|
| 105 | 106 |
await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled()) |
| 106 | 107 |
|
| 107 | 108 |
fireEvent.click(screen.getByRole('button', { name: '담당자 추가' }))
|
... | ... | @@ -118,7 +119,7 @@ |
| 118 | 119 |
mocks.getContacts.mockResolvedValueOnce([]).mockResolvedValueOnce([contact({ name: '홍길동' })])
|
| 119 | 120 |
mocks.createContact.mockResolvedValue(contact({ name: '홍길동' }))
|
| 120 | 121 |
|
| 121 |
- render(<ContactsPage />) |
|
| 122 |
+ render(withCodes(<ContactsPage />)) |
|
| 122 | 123 |
await waitFor(() => expect(mocks.getContacts).toHaveBeenCalledTimes(1)) |
| 123 | 124 |
|
| 124 | 125 |
fireEvent.click(screen.getByRole('button', { name: '담당자 추가' }))
|
... | ... | @@ -145,7 +146,7 @@ |
| 145 | 146 |
mocks.getContacts.mockResolvedValue([contact()]) |
| 146 | 147 |
mocks.updateContactInfo.mockResolvedValue(contact({ phone: '02-0000-0000' }))
|
| 147 | 148 |
|
| 148 |
- render(<ContactsPage />) |
|
| 149 |
+ render(withCodes(<ContactsPage />)) |
|
| 149 | 150 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 150 | 151 |
|
| 151 | 152 |
fireEvent.click(screen.getByRole('button', { name: '수정' }))
|
... | ... | @@ -169,7 +170,7 @@ |
| 169 | 170 |
mocks.deleteContact.mockResolvedValue(undefined) |
| 170 | 171 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 171 | 172 |
|
| 172 |
- render(<ContactsPage />) |
|
| 173 |
+ render(withCodes(<ContactsPage />)) |
|
| 173 | 174 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 174 | 175 |
|
| 175 | 176 |
fireEvent.click(screen.getByRole('button', { name: '삭제' }))
|
... | ... | @@ -186,7 +187,7 @@ |
| 186 | 187 |
mocks.getContacts.mockResolvedValue([contact()]) |
| 187 | 188 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) |
| 188 | 189 |
|
| 189 |
- render(<ContactsPage />) |
|
| 190 |
+ render(withCodes(<ContactsPage />)) |
|
| 190 | 191 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 191 | 192 |
|
| 192 | 193 |
fireEvent.click(screen.getByRole('button', { name: '삭제' }))
|
... | ... | @@ -201,7 +202,7 @@ |
| 201 | 202 |
mocks.getContacts.mockResolvedValue([]) |
| 202 | 203 |
mocks.importMembers.mockResolvedValue({ created: 2, updated: 1, skipped: 1, assigned: 1 })
|
| 203 | 204 |
|
| 204 |
- render(<ContactsPage />) |
|
| 205 |
+ render(withCodes(<ContactsPage />)) |
|
| 205 | 206 |
await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled()) |
| 206 | 207 |
|
| 207 | 208 |
const file = new File(['dummy'], 'members.xlsx', {
|
... | ... | @@ -220,7 +221,7 @@ |
| 220 | 221 |
mocks.getContacts.mockResolvedValue([]) |
| 221 | 222 |
mocks.importMembers.mockRejectedValue(new Error('파일 형식이 올바르지 않습니다.'))
|
| 222 | 223 |
|
| 223 |
- render(<ContactsPage />) |
|
| 224 |
+ render(withCodes(<ContactsPage />)) |
|
| 224 | 225 |
await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled()) |
| 225 | 226 |
|
| 226 | 227 |
const file = new File(['dummy'], 'members.xlsx', {
|
--- frontend/src/components/ContactsPage.tsx
+++ frontend/src/components/ContactsPage.tsx
... | ... | @@ -10,32 +10,12 @@ |
| 10 | 10 |
type ContactInput, |
| 11 | 11 |
type MemberImportReport, |
| 12 | 12 |
} from '../api/client' |
| 13 |
+import { useCodes } from '../codes/useCodes'
|
|
| 14 |
+import { badgeClass } from '../codes/tone'
|
|
| 13 | 15 |
import HelpButton from '../help/HelpButton' |
| 14 | 16 |
|
| 15 |
-const CATEGORY_LABELS: Record<ContactCategory, string> = {
|
|
| 16 |
- APPLICANT: '신청기관', |
|
| 17 |
- MJ: '문정원', |
|
| 18 |
- LAWYER: '변호사', |
|
| 19 |
- OPERATOR: '수행기관', |
|
| 20 |
- ITN: '아이티앤 담당자', |
|
| 21 |
-} |
|
| 22 |
- |
|
| 23 |
-const CATEGORY_BADGE_STYLES: Record<ContactCategory, string> = {
|
|
| 24 |
- APPLICANT: 'bg-blue-50 text-blue-700', |
|
| 25 |
- MJ: 'bg-emerald-50 text-emerald-700', |
|
| 26 |
- LAWYER: 'bg-violet-50 text-violet-700', |
|
| 27 |
- OPERATOR: 'bg-sky-50 text-sky-700', |
|
| 28 |
- ITN: 'bg-orange-50 text-orange-700', |
|
| 29 |
-} |
|
| 30 |
- |
|
| 31 |
-const FILTERS: { key: ContactCategory | 'ALL'; label: string }[] = [
|
|
| 32 |
- { key: 'ALL', label: '전체' },
|
|
| 33 |
- { key: 'APPLICANT', label: '신청기관' },
|
|
| 34 |
- { key: 'MJ', label: '문정원' },
|
|
| 35 |
- { key: 'LAWYER', label: '변호사' },
|
|
| 36 |
- { key: 'OPERATOR', label: '수행기관' },
|
|
| 37 |
- { key: 'ITN', label: '아이티앤 담당자' },
|
|
| 38 |
-] |
|
| 17 |
+// 구분 5종(표시명·뱃지색·필터·드롭다운)은 예전에 이 파일 안에서만 네 번 따로 적혀 있었다. |
|
| 18 |
+// 지금은 전부 코드표(CONTACT_CATEGORY)에서 온다. |
|
| 39 | 19 |
|
| 40 | 20 |
function emptyForm(contact: Contact | null): ContactInput {
|
| 41 | 21 |
return {
|
... | ... | @@ -61,6 +41,7 @@ |
| 61 | 41 |
const [form, setForm] = useState<ContactInput>(emptyForm(editing)) |
| 62 | 42 |
const [busy, setBusy] = useState(false) |
| 63 | 43 |
const [error, setError] = useState<string | null>(null) |
| 44 |
+ const categories = useCodes('CONTACT_CATEGORY')
|
|
| 64 | 45 |
|
| 65 | 46 |
useEffect(() => {
|
| 66 | 47 |
function onKeyDown(e: KeyboardEvent) {
|
... | ... | @@ -125,11 +106,11 @@ |
| 125 | 106 |
onChange={(e) => setField('category', e.target.value)}
|
| 126 | 107 |
className="rounded-md border border-gray-300 px-2 py-1.5" |
| 127 | 108 |
> |
| 128 |
- <option value="APPLICANT">신청기관</option> |
|
| 129 |
- <option value="MJ">문정원</option> |
|
| 130 |
- <option value="LAWYER">변호사</option> |
|
| 131 |
- <option value="OPERATOR">수행기관</option> |
|
| 132 |
- <option value="ITN">아이티앤 담당자</option> |
|
| 109 |
+ {categories.map((category) => (
|
|
| 110 |
+ <option key={category.code} value={category.code}>
|
|
| 111 |
+ {category.label}
|
|
| 112 |
+ </option> |
|
| 113 |
+ ))} |
|
| 133 | 114 |
</select> |
| 134 | 115 |
</div> |
| 135 | 116 |
<div className="flex flex-col gap-1"> |
... | ... | @@ -240,6 +221,20 @@ |
| 240 | 221 |
const [importReport, setImportReport] = useState<MemberImportReport | null>(null) |
| 241 | 222 |
const [importError, setImportError] = useState<string | null>(null) |
| 242 | 223 |
|
| 224 |
+ const categories = useCodes('CONTACT_CATEGORY')
|
|
| 225 |
+ /** 코드표에 없는 구분값이 저장돼 있어도 뱃지가 사라지지 않도록 undefined를 허용한다. */ |
|
| 226 |
+ function categoryOf(category: ContactCategory) {
|
|
| 227 |
+ return categories.find((candidate) => candidate.code === category) |
|
| 228 |
+ } |
|
| 229 |
+ // '전체'는 코드가 아니라 화면에서만 쓰는 필터 항목이라 코드표에 넣지 않고 여기서 앞에 붙인다. |
|
| 230 |
+ const filters: { key: ContactCategory | 'ALL'; label: string }[] = [
|
|
| 231 |
+ { key: 'ALL', label: '전체' },
|
|
| 232 |
+ ...categories.map((category) => ({
|
|
| 233 |
+ key: category.code as ContactCategory, |
|
| 234 |
+ label: category.label, |
|
| 235 |
+ })), |
|
| 236 |
+ ] |
|
| 237 |
+ |
|
| 243 | 238 |
async function load() {
|
| 244 | 239 |
setLoading(true) |
| 245 | 240 |
try {
|
... | ... | @@ -307,7 +302,7 @@ |
| 307 | 302 |
<div className="p-8"> |
| 308 | 303 |
<div className="flex flex-wrap items-center justify-between gap-3"> |
| 309 | 304 |
<div className="flex flex-wrap gap-2"> |
| 310 |
- {FILTERS.map((f) => (
|
|
| 305 |
+ {filters.map((f) => (
|
|
| 311 | 306 |
<button |
| 312 | 307 |
key={f.key}
|
| 313 | 308 |
type="button" |
... | ... | @@ -397,9 +392,11 @@ |
| 397 | 392 |
<tr key={contact.id} className="border-b border-gray-100 last:border-b-0">
|
| 398 | 393 |
<td className="px-3 py-2"> |
| 399 | 394 |
<span |
| 400 |
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${CATEGORY_BADGE_STYLES[contact.category]}`}
|
|
| 395 |
+ className={`rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(
|
|
| 396 |
+ categoryOf(contact.category)?.attrs.tone as string | undefined, |
|
| 397 |
+ )}`} |
|
| 401 | 398 |
> |
| 402 |
- {CATEGORY_LABELS[contact.category]}
|
|
| 399 |
+ {categoryOf(contact.category)?.label ?? contact.category}
|
|
| 403 | 400 |
</span> |
| 404 | 401 |
</td> |
| 405 | 402 |
<td className="px-3 py-2 font-medium text-gray-900">{contact.name}</td>
|
--- frontend/src/components/Dashboard.test.tsx
+++ frontend/src/components/Dashboard.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import Dashboard from './Dashboard' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { DashboardData, DashboardOrgRow } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -56,7 +57,7 @@ |
| 56 | 57 |
} |
| 57 | 58 |
|
| 58 | 59 |
function renderDashboard(onOpenOrg = vi.fn(), onOpenOrgList = vi.fn()) {
|
| 59 |
- render(<Dashboard onOpenOrg={onOpenOrg} onOpenOrgList={onOpenOrgList} />)
|
|
| 60 |
+ render(withCodes(<Dashboard onOpenOrg={onOpenOrg} onOpenOrgList={onOpenOrgList} />))
|
|
| 60 | 61 |
return { onOpenOrg, onOpenOrgList }
|
| 61 | 62 |
} |
| 62 | 63 |
|
--- frontend/src/components/Dashboard.tsx
+++ frontend/src/components/Dashboard.tsx
... | ... | @@ -5,7 +5,8 @@ |
| 5 | 5 |
type DashboardData, |
| 6 | 6 |
type DashboardOrgRow, |
| 7 | 7 |
} from '../api/client' |
| 8 |
-import { STAGE_GROUPS, STAGE_LABELS, stageGroupKey, stageLabel } from '../stages'
|
|
| 8 |
+import { useStages } from '../codes/stage'
|
|
| 9 |
+import { barClass } from '../codes/tone'
|
|
| 9 | 10 |
import HelpButton from '../help/HelpButton' |
| 10 | 11 |
|
| 11 | 12 |
/** |
... | ... | @@ -28,16 +29,8 @@ |
| 28 | 29 |
onOpenOrgList: () => void |
| 29 | 30 |
} |
| 30 | 31 |
|
| 31 |
-const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫'] |
|
| 32 |
- |
|
| 33 |
-/** 칸반 컬럼 상단 색 띠. 구간별로 같은 색을 쓴다. */ |
|
| 34 |
-const GROUP_COLOR: Record<string, string> = {
|
|
| 35 |
- intake: 'bg-slate-400', |
|
| 36 |
- assign: 'bg-indigo-400', |
|
| 37 |
- check: 'bg-emerald-400', |
|
| 38 |
- process: 'bg-amber-400', |
|
| 39 |
- closing: 'bg-violet-400', |
|
| 40 |
-} |
|
| 32 |
+// 원문자(①②)와 구간 색은 더 이상 여기 박아두지 않는다. 원문자는 단계 번호에서 계산하고 |
|
| 33 |
+// 색은 코드표의 tone에서 온다 - 단계가 하나 늘 때마다 배열을 같이 늘려야 했던 자리다. |
|
| 41 | 34 |
|
| 42 | 35 |
/** 표/칸반이 한 번에 보여주는 기관 수. [더보기]를 누를 때마다 이만큼 늘어난다. */ |
| 43 | 36 |
const PAGE_SIZE = 8 |
... | ... | @@ -123,8 +116,9 @@ |
| 123 | 116 |
|
| 124 | 117 |
/** 단계 구간에 따라 카드 본문이 달라진다(시안의 컬럼별 카드 모양). */ |
| 125 | 118 |
function CardBody({ row }: { row: DashboardOrgRow }) {
|
| 119 |
+ const stages = useStages() |
|
| 126 | 120 |
const stage = columnOf(row) |
| 127 |
- const group = stageGroupKey(stage) |
|
| 121 |
+ const group = stages.groupKey(stage) |
|
| 128 | 122 |
const lawyer = row.lawyerName ?? '변호사 미배정' |
| 129 | 123 |
|
| 130 | 124 |
if (stage === null) {
|
... | ... | @@ -170,20 +164,20 @@ |
| 170 | 164 |
<div className="space-y-1 text-xs text-gray-500"> |
| 171 | 165 |
<p>{lawyer}</p>
|
| 172 | 166 |
<span className="inline-block rounded-full bg-violet-50 px-2 py-0.5 text-[11px] text-violet-700"> |
| 173 |
- {CIRCLED[stage - 1]} {stageLabel(stage)}
|
|
| 167 |
+ {stages.symbol(stage)} {stages.label(stage)}
|
|
| 174 | 168 |
</span> |
| 175 | 169 |
</div> |
| 176 | 170 |
) |
| 177 | 171 |
} |
| 178 | 172 |
|
| 179 | 173 |
export default function Dashboard({ onOpenOrg, onOpenOrgList }: Props) {
|
| 174 |
+ const stages = useStages() |
|
| 180 | 175 |
const [data, setData] = useState<DashboardData | null>(null) |
| 181 | 176 |
const [error, setError] = useState<string | null>(null) |
| 182 | 177 |
const [busy, setBusy] = useState(false) |
| 183 | 178 |
const [warning, setWarning] = useState<string | null>(null) |
| 184 | 179 |
|
| 185 |
- // 칸반 필터 |
|
| 186 |
- const [groupFilter, setGroupFilter] = useState('')
|
|
| 180 |
+ // 칸반 필터 (업무 구간은 요구사항 [6]으로 제거했다) |
|
| 187 | 181 |
const [lawyerFilter, setLawyerFilter] = useState('')
|
| 188 | 182 |
const [stageFilter, setStageFilter] = useState('')
|
| 189 | 183 |
const [statusFilter, setStatusFilter] = useState('')
|
... | ... | @@ -226,22 +220,22 @@ |
| 226 | 220 |
const keyword = boardKeyword.trim() |
| 227 | 221 |
return rows.filter((row) => {
|
| 228 | 222 |
const stage = columnOf(row) |
| 229 |
- if (groupFilter && stageGroupKey(stage) !== groupFilter) {
|
|
| 230 |
- return false |
|
| 231 |
- } |
|
| 232 | 223 |
if (lawyerFilter && row.lawyerName !== lawyerFilter) {
|
| 233 | 224 |
return false |
| 234 | 225 |
} |
| 235 | 226 |
if (stageFilter && String(stage ?? '') !== stageFilter) {
|
| 236 | 227 |
return false |
| 237 | 228 |
} |
| 229 |
+ // 진행상태는 코드표(STAGE의 progress)에서 온다 - 신청은 ①만, 완료는 ⑬만, |
|
| 230 |
+ // 나머지가 진행중이다. 채널이 없어 단계가 아예 없는 기관은 '단계 미지정'으로 따로 본다. |
|
| 238 | 231 |
if (statusFilter === 'unassigned' && row.hasChannel) {
|
| 239 | 232 |
return false |
| 240 | 233 |
} |
| 241 |
- if (statusFilter === 'active' && (!row.hasChannel || (stage ?? 0) >= 11)) {
|
|
| 242 |
- return false |
|
| 243 |
- } |
|
| 244 |
- if (statusFilter === 'done' && (stage ?? 0) < 11) {
|
|
| 234 |
+ if ( |
|
| 235 |
+ statusFilter && |
|
| 236 |
+ statusFilter !== 'unassigned' && |
|
| 237 |
+ (!row.hasChannel || stages.progressOf(stage) !== statusFilter) |
|
| 238 |
+ ) {
|
|
| 245 | 239 |
return false |
| 246 | 240 |
} |
| 247 | 241 |
if (keyword && !row.orgName.includes(keyword) && !row.orgNo.includes(keyword)) {
|
... | ... | @@ -249,7 +243,7 @@ |
| 249 | 243 |
} |
| 250 | 244 |
return true |
| 251 | 245 |
}) |
| 252 |
- }, [rows, groupFilter, lawyerFilter, stageFilter, statusFilter, boardKeyword]) |
|
| 246 |
+ }, [rows, lawyerFilter, stageFilter, statusFilter, boardKeyword, stages]) |
|
| 253 | 247 |
|
| 254 | 248 |
/** 시안의 "정렬 기준 최근 진행순" - 칸반 카드도 최근 변동이 위로 온다. */ |
| 255 | 249 |
const byRecent = useCallback( |
... | ... | @@ -273,10 +267,13 @@ |
| 273 | 267 |
if (tableLawyer && row.lawyerName !== tableLawyer) {
|
| 274 | 268 |
return false |
| 275 | 269 |
} |
| 276 |
- if (tableData === 'received' && row.reviewTotal === 0) {
|
|
| 270 |
+ // 요구사항 [8]: 권리확인·권리처리를 구분하지 않는다. 어느 쪽이든 한 건이라도 |
|
| 271 |
+ // 올라와 있으면 접수로 본다. |
|
| 272 |
+ const received = row.reviewTotal + row.processTotal > 0 |
|
| 273 |
+ if (tableData === 'received' && !received) {
|
|
| 277 | 274 |
return false |
| 278 | 275 |
} |
| 279 |
- if (tableData === 'pending' && row.reviewTotal > 0) {
|
|
| 276 |
+ if (tableData === 'pending' && received) {
|
|
| 280 | 277 |
return false |
| 281 | 278 |
} |
| 282 | 279 |
return true |
... | ... | @@ -344,7 +341,8 @@ |
| 344 | 341 |
} |
| 345 | 342 |
|
| 346 | 343 |
const s = data.summary |
| 347 |
- const columns: (number | null)[] = [null, ...STAGE_LABELS.map((_, i) => i + 1)] |
|
| 344 |
+ // 첫 칸은 '단계 미지정'(채널 미생성 기관 자리)이고, 나머지는 코드표의 단계 수만큼이다. |
|
| 345 |
+ const columns: (number | null)[] = [null, ...stages.numbers] |
|
| 348 | 346 |
|
| 349 | 347 |
return ( |
| 350 | 348 |
<div className="space-y-4 p-6"> |
... | ... | @@ -389,16 +387,21 @@ |
| 389 | 387 |
<HelpButton topic="dashboard.kanban" /> |
| 390 | 388 |
</h2> |
| 391 | 389 |
|
| 390 |
+ {/*
|
|
| 391 |
+ 요구사항 [6]: 업무 구간 필터는 뺐다. 현재 단계와 기능이 겹친다는 지적이 있었고, |
|
| 392 |
+ 순서도 현재 단계를 앞으로 옮겼다. 구간 구분 자체는 칸반 컬럼 색에 계속 쓰이므로 |
|
| 393 |
+ 코드표에는 그대로 남겨 둔다. |
|
| 394 |
+ */} |
|
| 392 | 395 |
<div className="mt-3 flex flex-wrap gap-2"> |
| 393 | 396 |
<select |
| 394 |
- aria-label="업무 구간" |
|
| 395 |
- value={groupFilter}
|
|
| 396 |
- onChange={(e) => setGroupFilter(e.target.value)}
|
|
| 397 |
+ aria-label="현재 단계" |
|
| 398 |
+ value={stageFilter}
|
|
| 399 |
+ onChange={(e) => setStageFilter(e.target.value)}
|
|
| 397 | 400 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 398 | 401 |
> |
| 399 |
- <option value="">업무 구간 전체</option> |
|
| 400 |
- {STAGE_GROUPS.map((g) => (
|
|
| 401 |
- <option key={g.key} value={g.key}>{g.label}</option>
|
|
| 402 |
+ <option value="">현재 단계 전체</option> |
|
| 403 |
+ {stages.numbers.map((n) => (
|
|
| 404 |
+ <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
|
|
| 402 | 405 |
))} |
| 403 | 406 |
</select> |
| 404 | 407 |
<select |
... | ... | @@ -413,17 +416,6 @@ |
| 413 | 416 |
))} |
| 414 | 417 |
</select> |
| 415 | 418 |
<select |
| 416 |
- aria-label="현재 단계" |
|
| 417 |
- value={stageFilter}
|
|
| 418 |
- onChange={(e) => setStageFilter(e.target.value)}
|
|
| 419 |
- className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 420 |
- > |
|
| 421 |
- <option value="">현재 단계 전체</option> |
|
| 422 |
- {STAGE_LABELS.map((label, i) => (
|
|
| 423 |
- <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option>
|
|
| 424 |
- ))} |
|
| 425 |
- </select> |
|
| 426 |
- <select |
|
| 427 | 419 |
aria-label="진행 상태" |
| 428 | 420 |
value={statusFilter}
|
| 429 | 421 |
onChange={(e) => setStatusFilter(e.target.value)}
|
... | ... | @@ -431,6 +423,7 @@ |
| 431 | 423 |
> |
| 432 | 424 |
<option value="">진행 상태 전체</option> |
| 433 | 425 |
<option value="unassigned">단계 미지정</option> |
| 426 |
+ <option value="applied">신청</option> |
|
| 434 | 427 |
<option value="active">진행중</option> |
| 435 | 428 |
<option value="done">완료</option> |
| 436 | 429 |
</select> |
... | ... | @@ -454,7 +447,7 @@ |
| 454 | 447 |
const cards = boardRows.filter((row) => columnOf(row) === stage).sort(byRecent) |
| 455 | 448 |
const expanded = expandedColumns[key] |
| 456 | 449 |
const shown = expanded ? cards : cards.slice(0, COLUMN_SIZE) |
| 457 |
- const color = stage === null ? 'bg-gray-300' : GROUP_COLOR[stageGroupKey(stage) ?? ''] ?? 'bg-gray-300' |
|
| 450 |
+ const color = stage === null ? 'bg-gray-300' : barClass(stages.groupTone(stages.groupKey(stage))) |
|
| 458 | 451 |
|
| 459 | 452 |
return ( |
| 460 | 453 |
<div |
... | ... | @@ -475,7 +468,7 @@ |
| 475 | 468 |
<div className={`h-1 rounded-t-lg ${color}`} />
|
| 476 | 469 |
<div className="flex items-center justify-between px-3 py-2"> |
| 477 | 470 |
<span className="text-sm font-medium"> |
| 478 |
- {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`}
|
|
| 471 |
+ {stage === null ? '단계 미지정' : `${stages.symbol(stage)} ${stages.label(stage)}`}
|
|
| 479 | 472 |
</span> |
| 480 | 473 |
<span className="text-sm text-gray-500 tabular-nums">{cards.length}</span>
|
| 481 | 474 |
</div> |
... | ... | @@ -542,8 +535,8 @@ |
| 542 | 535 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 543 | 536 |
> |
| 544 | 537 |
<option value="">현재 단계 전체</option> |
| 545 |
- {STAGE_LABELS.map((label, i) => (
|
|
| 546 |
- <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option>
|
|
| 538 |
+ {stages.numbers.map((n) => (
|
|
| 539 |
+ <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
|
|
| 547 | 540 |
))} |
| 548 | 541 |
</select> |
| 549 | 542 |
<select |
... | ... | @@ -622,7 +615,7 @@ |
| 622 | 615 |
<td className="px-2 py-2 font-medium">{row.orgName}</td>
|
| 623 | 616 |
<td className="px-2 py-2"> |
| 624 | 617 |
<span className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700"> |
| 625 |
- {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`}
|
|
| 618 |
+ {stage === null ? '단계 미지정' : `${stages.symbol(stage)} ${stages.label(stage)}`}
|
|
| 626 | 619 |
</span> |
| 627 | 620 |
</td> |
| 628 | 621 |
<td className="px-2 py-2 text-gray-600">{row.mjName ?? '-'}</td>
|
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import OrgOverview from './OrgOverview' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { Contact, Org } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -14,6 +15,7 @@ |
| 14 | 15 |
getStageHistory: vi.fn(), |
| 15 | 16 |
getReviewPage: vi.fn(), |
| 16 | 17 |
getProcessPage: vi.fn(), |
| 18 |
+ getReMemos: vi.fn(), |
|
| 17 | 19 |
})) |
| 18 | 20 |
|
| 19 | 21 |
vi.mock('../api/client', async (importOriginal) => ({
|
... | ... | @@ -28,6 +30,7 @@ |
| 28 | 30 |
getStageHistory: mocks.getStageHistory, |
| 29 | 31 |
getReviewPage: mocks.getReviewPage, |
| 30 | 32 |
getProcessPage: mocks.getProcessPage, |
| 33 |
+ getReMemos: mocks.getReMemos, |
|
| 31 | 34 |
})) |
| 32 | 35 |
|
| 33 | 36 |
function contact(overrides: Partial<Contact> = {}): Contact {
|
... | ... | @@ -86,7 +89,7 @@ |
| 86 | 89 |
|
| 87 | 90 |
describe('OrgOverview', () => {
|
| 88 | 91 |
it('연번_기관명 헤더와 신청기관 담당자 정보를 보여준다', async () => {
|
| 89 |
- render(<OrgOverview org={org()} />)
|
|
| 92 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 90 | 93 |
|
| 91 | 94 |
expect(screen.getByText('001_00_국제방송교류재단')).toBeTruthy()
|
| 92 | 95 |
expect(screen.getByText('데이터정보화팀')).toBeTruthy()
|
... | ... | @@ -97,7 +100,7 @@ |
| 97 | 100 |
}) |
| 98 | 101 |
|
| 99 | 102 |
it('문정원 담당자와 아이티앤 담당자와 담당 변호사 정보가 비어 있으면 카드에 -로 표시된다', async () => {
|
| 100 |
- render(<OrgOverview org={org()} />)
|
|
| 103 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 101 | 104 |
|
| 102 | 105 |
const mjCard = screen.getByText('문정원 담당자').closest('section') as HTMLElement
|
| 103 | 106 |
const itnCard = screen.getByText('아이티앤 담당자').closest('section') as HTMLElement
|
... | ... | @@ -111,6 +114,7 @@ |
| 111 | 114 |
|
| 112 | 115 |
it('담당자 4종이 값을 가지고 있으면 각 카드에 값이 표시된다', async () => {
|
| 113 | 116 |
render( |
| 117 |
+ withCodes( |
|
| 114 | 118 |
<OrgOverview |
| 115 | 119 |
org={org({
|
| 116 | 120 |
mj: contact({
|
... | ... | @@ -139,6 +143,7 @@ |
| 139 | 143 |
lawyerAssignedDate: '2026-07-21', |
| 140 | 144 |
})} |
| 141 | 145 |
/>, |
| 146 |
+ ), |
|
| 142 | 147 |
) |
| 143 | 148 |
|
| 144 | 149 |
const applicantCard = screen.getByText('신청기관 담당자').closest('section') as HTMLElement
|
... | ... | @@ -168,7 +173,7 @@ |
| 168 | 173 |
}) |
| 169 | 174 |
|
| 170 | 175 |
it('배정된 카드에는 변경, 빈 카드에는 생성 버튼이 보인다', async () => {
|
| 171 |
- render(<OrgOverview org={org()} />)
|
|
| 176 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 172 | 177 |
|
| 173 | 178 |
expect(screen.getByRole('button', { name: '신청기관 담당자 변경' })).toBeTruthy()
|
| 174 | 179 |
expect(screen.getByRole('button', { name: '문정원 담당자 생성' })).toBeTruthy()
|
... | ... | @@ -183,7 +188,7 @@ |
| 183 | 188 |
]) |
| 184 | 189 |
const onChanged = vi.fn() |
| 185 | 190 |
|
| 186 |
- render(<OrgOverview org={org()} onChanged={onChanged} />)
|
|
| 191 |
+ render(withCodes(<OrgOverview org={org()} onChanged={onChanged} />))
|
|
| 187 | 192 |
|
| 188 | 193 |
fireEvent.click(screen.getByRole('button', { name: '문정원 담당자 생성' }))
|
| 189 | 194 |
|
... | ... | @@ -208,7 +213,7 @@ |
| 208 | 213 |
]) |
| 209 | 214 |
const onChanged = vi.fn() |
| 210 | 215 |
|
| 211 |
- render(<OrgOverview org={org()} onChanged={onChanged} />)
|
|
| 216 |
+ render(withCodes(<OrgOverview org={org()} onChanged={onChanged} />))
|
|
| 212 | 217 |
|
| 213 | 218 |
fireEvent.click(screen.getByRole('button', { name: '아이티앤 담당자 생성' }))
|
| 214 | 219 |
|
... | ... | @@ -228,7 +233,7 @@ |
| 228 | 233 |
}) |
| 229 | 234 |
|
| 230 | 235 |
it('상단 동작 버튼에서 담당 변호사 변경은 사라졌다', async () => {
|
| 231 |
- render(<OrgOverview org={org()} />)
|
|
| 236 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 232 | 237 |
|
| 233 | 238 |
expect(screen.queryByRole('button', { name: '담당 변호사 변경' })).toBeNull()
|
| 234 | 239 |
expect(screen.getByRole('button', { name: '진행단계 변경' })).not.toBeDisabled()
|
... | ... | @@ -242,7 +247,7 @@ |
| 242 | 247 |
{ stage: 1, changedAt: new Date('2026-07-15T09:00:00').getTime() },
|
| 243 | 248 |
]) |
| 244 | 249 |
|
| 245 |
- render(<OrgOverview org={org({ stage: 3 })} />)
|
|
| 250 |
+ render(withCodes(<OrgOverview org={org({ stage: 3 })} />))
|
|
| 246 | 251 |
|
| 247 | 252 |
await waitFor(() => {
|
| 248 | 253 |
expect(screen.getByText('2026-07-24 10:00')).toBeTruthy()
|
... | ... | @@ -252,7 +257,7 @@ |
| 252 | 257 |
}) |
| 253 | 258 |
|
| 254 | 259 |
it('업무단계 12개가 순서대로 보인다', async () => {
|
| 255 |
- render(<OrgOverview org={org()} />)
|
|
| 260 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 256 | 261 |
|
| 257 | 262 |
expect(screen.getByText('신청')).toBeTruthy()
|
| 258 | 263 |
expect(screen.getByText('변호사 배당')).toBeTruthy()
|
... | ... | @@ -261,14 +266,14 @@ |
| 261 | 266 |
}) |
| 262 | 267 |
|
| 263 | 268 |
it('기본 탭인 채팅은 자리표시 대신 채널 선택 화면을 보여준다', async () => {
|
| 264 |
- render(<OrgOverview org={org()} />)
|
|
| 269 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 265 | 270 |
|
| 266 | 271 |
expect(screen.getByRole('button', { name: '문정원' })).toBeTruthy()
|
| 267 | 272 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalledWith(1, 'mj')) |
| 268 | 273 |
}) |
| 269 | 274 |
|
| 270 | 275 |
it('권리확인 탭을 누르면 권리확인 보드가 마운트되어 조회를 부른다', async () => {
|
| 271 |
- render(<OrgOverview org={org()} />)
|
|
| 276 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 272 | 277 |
|
| 273 | 278 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 274 | 279 |
|
... | ... | @@ -279,19 +284,23 @@ |
| 279 | 284 |
expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
|
| 280 | 285 |
}) |
| 281 | 286 |
|
| 282 |
- it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => {
|
|
| 283 |
- render(<OrgOverview org={org()} />)
|
|
| 287 |
+ // 요구사항 [4][5]로 RE 탭에 실제 화면(메모장)이 생겼다. 자리표시가 아니다. |
|
| 288 |
+ it('RE 탭을 누르면 메모 입력 화면이 나온다', async () => {
|
|
| 289 |
+ mocks.getReMemos.mockResolvedValue([]) |
|
| 290 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 284 | 291 |
|
| 285 | 292 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 286 | 293 |
|
| 287 | 294 |
fireEvent.click(screen.getByRole('button', { name: 'RE' }))
|
| 288 | 295 |
|
| 289 |
- expect(screen.getByText('RE 상세 화면은 추후 확정')).toBeTruthy()
|
|
| 296 |
+ await waitFor(() => expect(mocks.getReMemos).toHaveBeenCalledWith(1)) |
|
| 297 |
+ expect(screen.getByLabelText('RE 내용')).toBeTruthy()
|
|
| 298 |
+ expect(screen.getByLabelText('작성자')).toBeTruthy()
|
|
| 290 | 299 |
expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
|
| 291 | 300 |
}) |
| 292 | 301 |
|
| 293 | 302 |
it('권리처리 탭을 누르면 권리처리 보드가 마운트되어 조회를 부른다', async () => {
|
| 294 |
- render(<OrgOverview org={org()} />)
|
|
| 303 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 295 | 304 |
|
| 296 | 305 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 297 | 306 |
|
... | ... | @@ -306,7 +315,7 @@ |
| 306 | 315 |
mocks.getReviewPage.mockResolvedValue({ items: [], total: 12, done: 5, page: 0, size: 1 })
|
| 307 | 316 |
mocks.getProcessPage.mockResolvedValue({ items: [], total: 8, done: 3, page: 0, size: 1 })
|
| 308 | 317 |
|
| 309 |
- render(<OrgOverview org={org()} />)
|
|
| 318 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 310 | 319 |
|
| 311 | 320 |
await waitFor(() => {
|
| 312 | 321 |
expect(screen.getByText('완료 5 / 전체 12')).toBeTruthy()
|
... | ... | @@ -315,7 +324,7 @@ |
| 315 | 324 |
}) |
| 316 | 325 |
|
| 317 | 326 |
it('타임라인 탭을 누르면 Timeline이 마운트되어 조회를 부른다', async () => {
|
| 318 |
- render(<OrgOverview org={org()} />)
|
|
| 327 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 319 | 328 |
|
| 320 | 329 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 321 | 330 |
|
... | ... | @@ -326,7 +335,7 @@ |
| 326 | 335 |
}) |
| 327 | 336 |
|
| 328 | 337 |
it('자료 탭을 누르면 자료 목록 화면으로 바뀐다', async () => {
|
| 329 |
- render(<OrgOverview org={org()} />)
|
|
| 338 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 330 | 339 |
|
| 331 | 340 |
fireEvent.click(screen.getByRole('button', { name: '자료' }))
|
| 332 | 341 |
|
... | ... | @@ -335,7 +344,7 @@ |
| 335 | 344 |
}) |
| 336 | 345 |
|
| 337 | 346 |
it('채널이 있으면 Mattermost 열기가 채널 링크로 활성화된다', async () => {
|
| 338 |
- render(<OrgOverview org={org()} />)
|
|
| 347 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 339 | 348 |
|
| 340 | 349 |
const link = screen.getByRole('link', { name: 'Mattermost 열기' })
|
| 341 | 350 |
expect(link).toHaveAttribute( |
... | ... | @@ -346,7 +355,7 @@ |
| 346 | 355 |
}) |
| 347 | 356 |
|
| 348 | 357 |
it('채널이 없으면 Mattermost 열기가 비활성이다', () => {
|
| 349 |
- render(<OrgOverview org={org({ channelIdMj: null, channelIdLaw: null, status: 'READY' })} />)
|
|
| 358 |
+ render(withCodes(<OrgOverview org={org({ channelIdMj: null, channelIdLaw: null, status: 'READY' })} />))
|
|
| 350 | 359 |
|
| 351 | 360 |
expect(screen.queryByRole('link', { name: 'Mattermost 열기' })).toBeNull()
|
| 352 | 361 |
expect(screen.getByRole('button', { name: 'Mattermost 열기' })).toBeDisabled()
|
... | ... | @@ -354,9 +363,11 @@ |
| 354 | 363 |
|
| 355 | 364 |
it('치안정책연구소도 합성코드 슬러그로 채널 링크를 만든다', async () => {
|
| 356 | 365 |
render( |
| 357 |
- <OrgOverview |
|
| 358 |
- org={org({ orgNo: '008_01', orgName: '경찰청_치안정책연구소', channelIdMj: 'x' })}
|
|
| 359 |
- />, |
|
| 366 |
+ withCodes( |
|
| 367 |
+ <OrgOverview |
|
| 368 |
+ org={org({ orgNo: '008_01', orgName: '경찰청_치안정책연구소', channelIdMj: 'x' })}
|
|
| 369 |
+ />, |
|
| 370 |
+ ), |
|
| 360 | 371 |
) |
| 361 | 372 |
|
| 362 | 373 |
expect(screen.getByRole('link', { name: 'Mattermost 열기' })).toHaveAttribute(
|
... | ... | @@ -367,7 +378,7 @@ |
| 367 | 378 |
}) |
| 368 | 379 |
|
| 369 | 380 |
it('미구현 동작 버튼들은 비활성이다', async () => {
|
| 370 |
- render(<OrgOverview org={org()} />)
|
|
| 381 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 371 | 382 |
|
| 372 | 383 |
expect(screen.getByRole('button', { name: '자료 업로드' })).toBeDisabled()
|
| 373 | 384 |
expect(screen.getByRole('button', { name: '보고서 관리' })).toBeDisabled()
|
... | ... | @@ -378,14 +389,14 @@ |
| 378 | 389 |
|
| 379 | 390 |
describe('업무단계 현황 스트립', () => {
|
| 380 | 391 |
it('채널이 없어 단계가 시작되지 않았으면 전부 회색이고 안내 문구가 보인다', async () => {
|
| 381 |
- render(<OrgOverview org={org({ stage: null, channelIdMj: null, channelIdLaw: null, status: 'READY' })} />)
|
|
| 392 |
+ render(withCodes(<OrgOverview org={org({ stage: null, channelIdMj: null, channelIdLaw: null, status: 'READY' })} />))
|
|
| 382 | 393 |
|
| 383 | 394 |
expect(screen.getByText('채널 생성 시 신청 단계가 시작됩니다')).toBeTruthy()
|
| 384 | 395 |
expect(screen.queryByText('진행중')).toBeNull()
|
| 385 | 396 |
}) |
| 386 | 397 |
|
| 387 | 398 |
it('현재 단계 이전은 완료(체크) 표시, 현재 단계는 진행중, 이후는 미래 상태로 보인다', async () => {
|
| 388 |
- render(<OrgOverview org={org({ stage: 3 })} />)
|
|
| 399 |
+ render(withCodes(<OrgOverview org={org({ stage: 3 })} />))
|
|
| 389 | 400 |
|
| 390 | 401 |
const doneButton = screen.getByText('목록접수').closest('button') as HTMLElement
|
| 391 | 402 |
const currentButton = screen.getByText('예비검토').closest('button') as HTMLElement
|
... | ... | @@ -405,7 +416,7 @@ |
| 405 | 416 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 406 | 417 |
const onChanged = vi.fn() |
| 407 | 418 |
|
| 408 |
- render(<OrgOverview org={org({ stage: 4 })} onChanged={onChanged} />)
|
|
| 419 |
+ render(withCodes(<OrgOverview org={org({ stage: 4 })} onChanged={onChanged} />))
|
|
| 409 | 420 |
|
| 410 | 421 |
const currentButton = screen.getByText('변호사 배당').closest('button') as HTMLElement
|
| 411 | 422 |
fireEvent.click(currentButton) |
... | ... | @@ -422,7 +433,7 @@ |
| 422 | 433 |
it('확인을 취소하면 진행단계 변경 API를 부르지 않는다', async () => {
|
| 423 | 434 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) |
| 424 | 435 |
|
| 425 |
- render(<OrgOverview org={org({ stage: 4 })} />)
|
|
| 436 |
+ render(withCodes(<OrgOverview org={org({ stage: 4 })} />))
|
|
| 426 | 437 |
|
| 427 | 438 |
fireEvent.click(screen.getByText('변호사 배당').closest('button') as HTMLElement)
|
| 428 | 439 |
|
... | ... | @@ -435,7 +446,7 @@ |
| 435 | 446 |
it('현재 단계가 아닌 다른 단계를 클릭해도 아무 일도 일어나지 않는다', async () => {
|
| 436 | 447 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 437 | 448 |
|
| 438 |
- render(<OrgOverview org={org({ stage: 4 })} />)
|
|
| 449 |
+ render(withCodes(<OrgOverview org={org({ stage: 4 })} />))
|
|
| 439 | 450 |
|
| 440 | 451 |
fireEvent.click(screen.getByText('예비검토').closest('button') as HTMLElement)
|
| 441 | 452 |
fireEvent.click(screen.getByText('RE:확인').closest('button') as HTMLElement)
|
... | ... | @@ -446,12 +457,14 @@ |
| 446 | 457 |
confirmSpy.mockRestore() |
| 447 | 458 |
}) |
| 448 | 459 |
|
| 449 |
- it('마지막 단계(12)가 현재 단계이면 클릭해도 아무 일도 일어나지 않는다', async () => {
|
|
| 460 |
+ // 요구사항 [11]로 13단계(보고서 제출 완료)가 생겨 마지막 단계가 13으로 내려갔다. |
|
| 461 |
+ // 12단계는 이제 마지막이 아니라서 다음 단계로 넘어갈 수 있다. |
|
| 462 |
+ it('마지막 단계(13)가 현재 단계이면 클릭해도 아무 일도 일어나지 않는다', async () => {
|
|
| 450 | 463 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 451 | 464 |
|
| 452 |
- render(<OrgOverview org={org({ stage: 12 })} />)
|
|
| 465 |
+ render(withCodes(<OrgOverview org={org({ stage: 13 })} />))
|
|
| 453 | 466 |
|
| 454 |
- const lastButton = screen.getByText('보고서 작성').closest('button') as HTMLElement
|
|
| 467 |
+ const lastButton = screen.getByText('보고서 제출 완료').closest('button') as HTMLElement
|
|
| 455 | 468 |
expect(lastButton).toBeDisabled() |
| 456 | 469 |
fireEvent.click(lastButton) |
| 457 | 470 |
|
... | ... | @@ -467,7 +480,7 @@ |
| 467 | 480 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 468 | 481 |
const onChanged = vi.fn() |
| 469 | 482 |
|
| 470 |
- render(<OrgOverview org={org({ stage: 2 })} onChanged={onChanged} />)
|
|
| 483 |
+ render(withCodes(<OrgOverview org={org({ stage: 2 })} onChanged={onChanged} />))
|
|
| 471 | 484 |
|
| 472 | 485 |
fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' }))
|
| 473 | 486 |
|
... | ... | @@ -487,7 +500,7 @@ |
| 487 | 500 |
}) |
| 488 | 501 |
|
| 489 | 502 |
it('닫기를 누르면 모달이 사라진다', async () => {
|
| 490 |
- render(<OrgOverview org={org({ stage: 2 })} />)
|
|
| 503 |
+ render(withCodes(<OrgOverview org={org({ stage: 2 })} />))
|
|
| 491 | 504 |
|
| 492 | 505 |
fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' }))
|
| 493 | 506 |
expect(screen.getByRole('heading', { name: '진행단계 변경' })).toBeTruthy()
|
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
... | ... | @@ -11,12 +11,13 @@ |
| 11 | 11 |
type Org, |
| 12 | 12 |
type PostView, |
| 13 | 13 |
} from '../api/client' |
| 14 |
-import { STAGE_LABELS, stageLabel } from '../stages'
|
|
| 14 |
+import { useStages } from '../codes/stage'
|
|
| 15 | 15 |
import HelpButton from '../help/HelpButton' |
| 16 | 16 |
import ChannelFiles from './ChannelFiles' |
| 17 | 17 |
import ChannelPosts from './ChannelPosts' |
| 18 | 18 |
import ContactPickerModal from './ContactPickerModal' |
| 19 | 19 |
import ProcessBoard from './ProcessBoard' |
| 20 |
+import ReMemos from './ReMemos' |
|
| 20 | 21 |
import ReviewBoard from './ReviewBoard' |
| 21 | 22 |
import StatusBadge from './StatusBadge' |
| 22 | 23 |
import Timeline from './Timeline' |
... | ... | @@ -28,7 +29,7 @@ |
| 28 | 29 |
|
| 29 | 30 |
const TABS = ['채팅', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모'] |
| 30 | 31 |
|
| 31 |
-/** 탭마다 그 탭 기능의 설명서를 띄운다. RE는 화면이 아직 없어 도움말도 없다. */ |
|
| 32 |
+/** 탭마다 그 탭 기능의 설명서를 띄운다. */ |
|
| 32 | 33 |
const TAB_HELP: Record<string, string> = {
|
| 33 | 34 |
'채팅': 'org.chat', |
| 34 | 35 |
'자료': 'org.files', |
... | ... | @@ -135,6 +136,7 @@ |
| 135 | 136 |
org: Org |
| 136 | 137 |
onChanged?: () => void |
| 137 | 138 |
}) {
|
| 139 |
+ const stages = useStages() |
|
| 138 | 140 |
const [activeTab, setActiveTab] = useState(TABS[0]) |
| 139 | 141 |
const [pickerRole, setPickerRole] = useState<'applicant' | 'mj' | 'itn' | 'lawyer' | null>(null) |
| 140 | 142 |
const [assignBusy, setAssignBusy] = useState(false) |
... | ... | @@ -273,18 +275,18 @@ |
| 273 | 275 |
|
| 274 | 276 |
/** 업무단계 현황 스트립에서 "현재 단계"를 클릭했을 때만 호출된다 - 다음 단계로 1칸 전진. */ |
| 275 | 277 |
async function advanceStage() {
|
| 276 |
- if (org.stage === null || org.stage >= 12 || stageBusy) {
|
|
| 278 |
+ if (org.stage === null || org.stage >= stages.count || stageBusy) {
|
|
| 277 | 279 |
return |
| 278 | 280 |
} |
| 279 | 281 |
const next = org.stage + 1 |
| 280 |
- if (!window.confirm(`다음 단계 "${next}. ${stageLabel(next)}"(으)로 진행할까요?`)) {
|
|
| 282 |
+ if (!window.confirm(`다음 단계 "${next}. ${stages.label(next)}"(으)로 진행할까요?`)) {
|
|
| 281 | 283 |
return |
| 282 | 284 |
} |
| 283 | 285 |
await changeStage(next) |
| 284 | 286 |
} |
| 285 | 287 |
|
| 286 | 288 |
async function pickStage(next: number) {
|
| 287 |
- if (!window.confirm(`"${next}. ${stageLabel(next)}"(으)로 진행단계를 변경할까요?`)) {
|
|
| 289 |
+ if (!window.confirm(`"${next}. ${stages.label(next)}"(으)로 진행단계를 변경할까요?`)) {
|
|
| 288 | 290 |
return |
| 289 | 291 |
} |
| 290 | 292 |
setStagePickerOpen(false) |
... | ... | @@ -439,18 +441,18 @@ |
| 439 | 441 |
<HelpButton topic="org.stage" /> |
| 440 | 442 |
</h2> |
| 441 | 443 |
<ol className="mt-3 flex gap-2 overflow-x-auto pb-2"> |
| 442 |
- {STAGE_LABELS.map((step, i) => {
|
|
| 443 |
- const n = i + 1 |
|
| 444 |
+ {stages.numbers.map((n) => {
|
|
| 445 |
+ const step = stages.label(n) |
|
| 444 | 446 |
const isDone = org.stage !== null && n < org.stage |
| 445 | 447 |
const isCurrent = org.stage !== null && n === org.stage |
| 446 |
- const clickable = isCurrent && n < 12 |
|
| 448 |
+ const clickable = isCurrent && n < stages.count |
|
| 447 | 449 |
|
| 448 | 450 |
return ( |
| 449 |
- <li key={step} className="shrink-0">
|
|
| 451 |
+ <li key={n} className="shrink-0">
|
|
| 450 | 452 |
<button |
| 451 | 453 |
type="button" |
| 452 | 454 |
disabled={!clickable || stageBusy}
|
| 453 |
- title={isCurrent && n >= 12 ? '마지막 단계입니다' : undefined}
|
|
| 455 |
+ title={isCurrent && n >= stages.count ? '마지막 단계입니다' : undefined}
|
|
| 454 | 456 |
onClick={() => {
|
| 455 | 457 |
if (isCurrent) {
|
| 456 | 458 |
void advanceStage() |
... | ... | @@ -594,6 +596,10 @@ |
| 594 | 596 |
<div className="mt-4 rounded-lg border border-gray-200"> |
| 595 | 597 |
<Timeline key={org.id} org={org} />
|
| 596 | 598 |
</div> |
| 599 |
+ ) : activeTab === 'RE' ? ( |
|
| 600 |
+ <div className="mt-4 rounded-lg border border-gray-200"> |
|
| 601 |
+ <ReMemos key={org.id} org={org} />
|
|
| 602 |
+ </div> |
|
| 597 | 603 |
) : ( |
| 598 | 604 |
<div className="mt-4 rounded-lg border border-dashed border-gray-200 p-10 text-center text-sm text-gray-400"> |
| 599 | 605 |
{activeTab} 상세 화면은 추후 확정
|
... | ... | @@ -697,6 +703,7 @@ |
| 697 | 703 |
onPick: (stage: number) => void |
| 698 | 704 |
onClose: () => void |
| 699 | 705 |
}) {
|
| 706 |
+ const stages = useStages() |
|
| 700 | 707 |
return ( |
| 701 | 708 |
<div className="fixed inset-0 flex items-center justify-center bg-black/30"> |
| 702 | 709 |
<div className="w-96 rounded-lg bg-white p-5"> |
... | ... | @@ -713,11 +720,11 @@ |
| 713 | 720 |
</div> |
| 714 | 721 |
|
| 715 | 722 |
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto"> |
| 716 |
- {STAGE_LABELS.map((label, i) => {
|
|
| 717 |
- const n = i + 1 |
|
| 723 |
+ {stages.numbers.map((n) => {
|
|
| 724 |
+ const label = stages.label(n) |
|
| 718 | 725 |
const isCurrent = currentStage === n |
| 719 | 726 |
return ( |
| 720 |
- <li key={label}>
|
|
| 727 |
+ <li key={n}>
|
|
| 721 | 728 |
<button |
| 722 | 729 |
type="button" |
| 723 | 730 |
disabled={busy}
|
--- frontend/src/components/ProcessBoard.test.tsx
+++ frontend/src/components/ProcessBoard.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import ProcessBoard from './ProcessBoard' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { Org, ProcessItem, ProcessPage } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -104,7 +105,7 @@ |
| 104 | 105 |
it('목록을 표로 보여주고 처리상태 배지가 나온다', async () => {
|
| 105 | 106 |
mocks.getProcessPage.mockResolvedValue(page()) |
| 106 | 107 |
|
| 107 |
- render(<ProcessBoard org={org()} />)
|
|
| 108 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 108 | 109 |
|
| 109 | 110 |
await waitFor(() => {
|
| 110 | 111 |
expect(screen.getByText('2026년 사업 공고')).toBeTruthy()
|
... | ... | @@ -117,7 +118,7 @@ |
| 117 | 118 |
it('자료가 없으면 안내 문구를 보여준다', async () => {
|
| 118 | 119 |
mocks.getProcessPage.mockResolvedValue(page({ items: [], total: 0 }))
|
| 119 | 120 |
|
| 120 |
- render(<ProcessBoard org={org()} />)
|
|
| 121 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 121 | 122 |
|
| 122 | 123 |
await waitFor(() => {
|
| 123 | 124 |
expect( |
... | ... | @@ -129,7 +130,7 @@ |
| 129 | 130 |
it('상태 칩을 누르면 status 파라미터로 다시 조회한다', async () => {
|
| 130 | 131 |
mocks.getProcessPage.mockResolvedValue(page()) |
| 131 | 132 |
|
| 132 |
- render(<ProcessBoard org={org()} />)
|
|
| 133 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 133 | 134 |
|
| 134 | 135 |
await waitFor(() => |
| 135 | 136 |
expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
|
... | ... | @@ -166,7 +167,7 @@ |
| 166 | 167 |
it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => {
|
| 167 | 168 |
mocks.getProcessPage.mockResolvedValue(page()) |
| 168 | 169 |
|
| 169 |
- render(<ProcessBoard org={org()} />)
|
|
| 170 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 170 | 171 |
|
| 171 | 172 |
await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1)) |
| 172 | 173 |
|
... | ... | @@ -187,7 +188,7 @@ |
| 187 | 188 |
mocks.getProcessPage.mockResolvedValue(page()) |
| 188 | 189 |
mocks.importProcess.mockResolvedValue({ created: 3, updated: 2, total: 5 })
|
| 189 | 190 |
|
| 190 |
- const { container } = render(<ProcessBoard org={org()} />)
|
|
| 191 |
+ const { container } = render(withCodes(<ProcessBoard org={org()} />))
|
|
| 191 | 192 |
|
| 192 | 193 |
await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1)) |
| 193 | 194 |
|
... | ... | @@ -206,7 +207,7 @@ |
| 206 | 207 |
it('처리완료 건은 수정 버튼이, 미처리 건은 처리등록 버튼만 보인다', async () => {
|
| 207 | 208 |
mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] }))
|
| 208 | 209 |
|
| 209 |
- render(<ProcessBoard org={org()} />)
|
|
| 210 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 210 | 211 |
|
| 211 | 212 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 212 | 213 |
expect(screen.getByRole('button', { name: '수정' })).toBeTruthy()
|
... | ... | @@ -220,7 +221,7 @@ |
| 220 | 221 |
mocks.bulkDeleteProcess.mockResolvedValue({ deleted: 1 })
|
| 221 | 222 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 222 | 223 |
|
| 223 |
- render(<ProcessBoard org={org()} />)
|
|
| 224 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 224 | 225 |
|
| 225 | 226 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 226 | 227 |
|
... | ... | @@ -237,7 +238,7 @@ |
| 237 | 238 |
mocks.getProcessPage.mockResolvedValue(page()) |
| 238 | 239 |
mocks.bulkUpdateProcess.mockResolvedValue({ updated: 1 })
|
| 239 | 240 |
|
| 240 |
- render(<ProcessBoard org={org()} />)
|
|
| 241 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 241 | 242 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 242 | 243 |
|
| 243 | 244 |
fireEvent.click(screen.getByLabelText('1번 선택'))
|
... | ... | @@ -259,7 +260,7 @@ |
| 259 | 260 |
it('상세검색 3칸은 열별 조건으로 함께 넘어간다', async () => {
|
| 260 | 261 |
mocks.getProcessPage.mockResolvedValue(page()) |
| 261 | 262 |
|
| 262 |
- render(<ProcessBoard org={org()} />)
|
|
| 263 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 263 | 264 |
await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalled()) |
| 264 | 265 |
|
| 265 | 266 |
fireEvent.click(screen.getByRole('button', { name: '상세검색' }))
|
... | ... | @@ -284,7 +285,7 @@ |
| 284 | 285 |
it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
|
| 285 | 286 |
mocks.getProcessPage.mockResolvedValue(page({ total: 31 }))
|
| 286 | 287 |
|
| 287 |
- render(<ProcessBoard org={org()} />)
|
|
| 288 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 288 | 289 |
|
| 289 | 290 |
await waitFor(() => |
| 290 | 291 |
expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
|
... | ... | @@ -313,7 +314,7 @@ |
| 313 | 314 |
mocks.getProcessPage.mockResolvedValue(page()) |
| 314 | 315 |
mocks.getProcessItem.mockResolvedValue(item({ finalOpinion: '검토 완료' }))
|
| 315 | 316 |
|
| 316 |
- render(<ProcessBoard org={org()} />)
|
|
| 317 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 317 | 318 |
|
| 318 | 319 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 319 | 320 |
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
|
... | ... | @@ -328,7 +329,7 @@ |
| 328 | 329 |
mocks.getProcessPage.mockResolvedValue(page()) |
| 329 | 330 |
mocks.getProcessItem.mockResolvedValue(item()) |
| 330 | 331 |
|
| 331 |
- render(<ProcessBoard org={org()} />)
|
|
| 332 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 332 | 333 |
|
| 333 | 334 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 334 | 335 |
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
|
... | ... | @@ -346,7 +347,7 @@ |
| 346 | 347 |
mocks.getProcessItem.mockResolvedValue(item()) |
| 347 | 348 |
mocks.updateProcessing.mockResolvedValue(item({ processStatus: '처리완료' }))
|
| 348 | 349 |
|
| 349 |
- render(<ProcessBoard org={org()} />)
|
|
| 350 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 350 | 351 |
|
| 351 | 352 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 352 | 353 |
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
|
... | ... | @@ -380,7 +381,7 @@ |
| 380 | 381 |
item({ contractDocs: '양도계약서, 기타:구두 동의' }),
|
| 381 | 382 |
) |
| 382 | 383 |
|
| 383 |
- render(<ProcessBoard org={org()} />)
|
|
| 384 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 384 | 385 |
|
| 385 | 386 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 386 | 387 |
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
|
... | ... | @@ -395,7 +396,7 @@ |
| 395 | 396 |
mocks.getProcessPage.mockResolvedValue(page()) |
| 396 | 397 |
mocks.getProcessItem.mockResolvedValue(item()) |
| 397 | 398 |
|
| 398 |
- render(<ProcessBoard org={org()} />)
|
|
| 399 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 399 | 400 |
|
| 400 | 401 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 401 | 402 |
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
|
... | ... | @@ -413,7 +414,7 @@ |
| 413 | 414 |
mocks.deleteProcessItem.mockResolvedValue(undefined) |
| 414 | 415 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 415 | 416 |
|
| 416 |
- render(<ProcessBoard org={org()} />)
|
|
| 417 |
+ render(withCodes(<ProcessBoard org={org()} />))
|
|
| 417 | 418 |
|
| 418 | 419 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 419 | 420 |
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
|
--- frontend/src/components/ProcessBoard.tsx
+++ frontend/src/components/ProcessBoard.tsx
... | ... | @@ -11,41 +11,47 @@ |
| 11 | 11 |
type Org, |
| 12 | 12 |
type ProcessImportReport, |
| 13 | 13 |
type ProcessItem, |
| 14 |
+ type Code, |
|
| 14 | 15 |
type ProcessStatusFilter, |
| 15 | 16 |
type ProcessingRequest, |
| 16 | 17 |
} from '../api/client' |
| 18 |
+import { useCodes, useSplitByFirstRow } from '../codes/useCodes'
|
|
| 17 | 19 |
|
| 18 | 20 |
const PAGE_SIZE = 30 |
| 19 | 21 |
|
| 20 |
-const STATUS_CHIPS: { key: ProcessStatusFilter | undefined; label: string }[] = [
|
|
| 21 |
- { key: undefined, label: '전체' },
|
|
| 22 |
- { key: 'PENDING', label: '미처리' },
|
|
| 23 |
- { key: 'DONE', label: '처리완료' },
|
|
| 24 |
-] |
|
| 22 |
+/** 공공누리 유형이 쓰이는 자리. 권리처리 판정에는 '보류'가 없다. */ |
|
| 23 |
+const SCOPE_PROCESS = 'process' |
|
| 25 | 24 |
|
| 26 |
-const CONTRACT_DOC_OPTIONS = ['양도계약서', '제안요청서', '초상이용동의서', '공공누리동의서', '공문'] |
|
| 27 |
-const CONTRACT_ETC = '기타' |
|
| 25 |
+/** 자유 입력이 붙는 계약서류('기타'). 저장값이 "기타:본문" 형태가 된다. */
|
|
| 26 |
+function etcDocOf(docs: Code[]): Code | undefined {
|
|
| 27 |
+ return docs.find((doc) => doc.attrs.freeText === true) |
|
| 28 |
+} |
|
| 28 | 29 |
|
| 29 |
-const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '개방불가', '1유형', '2유형', '3유형', '4유형'] |
|
| 30 |
- |
|
| 31 |
-const PROCESS_STATUS_OPTIONS = ['미처리', '처리완료'] |
|
| 30 |
+/** '처리완료'에 해당하는 코드. 처리일시를 찍고 배지를 초록으로 바꾸는 기준이다. */ |
|
| 31 |
+function doneStatusOf(statuses: Code[]): Code | undefined {
|
|
| 32 |
+ return statuses.find((status) => status.attrs.done === true) |
|
| 33 |
+} |
|
| 32 | 34 |
|
| 33 | 35 |
/** "양도계약서, 공문, 기타:직접 촬영본 사용동의" 형태의 저장값 → 체크박스 상태로 역파싱한다. */ |
| 34 |
-function parseContractDocs(value: string | null): { selected: Set<string>; etcText: string } {
|
|
| 36 |
+function parseContractDocs( |
|
| 37 |
+ value: string | null, |
|
| 38 |
+ docs: Code[], |
|
| 39 |
+): { selected: Set<string>; etcText: string } {
|
|
| 35 | 40 |
const selected = new Set<string>() |
| 36 | 41 |
let etcText = '' |
| 37 | 42 |
if (!value) {
|
| 38 | 43 |
return { selected, etcText }
|
| 39 | 44 |
} |
| 45 |
+ const etc = etcDocOf(docs) |
|
| 40 | 46 |
for (const raw of value.split(',')) {
|
| 41 | 47 |
const part = raw.trim() |
| 42 | 48 |
if (!part) {
|
| 43 | 49 |
continue |
| 44 | 50 |
} |
| 45 |
- if (part.startsWith(`${CONTRACT_ETC}:`)) {
|
|
| 46 |
- selected.add(CONTRACT_ETC) |
|
| 47 |
- etcText = part.slice(CONTRACT_ETC.length + 1) |
|
| 48 |
- } else if (CONTRACT_DOC_OPTIONS.includes(part)) {
|
|
| 51 |
+ if (etc && part.startsWith(`${etc.code}:`)) {
|
|
| 52 |
+ selected.add(etc.code) |
|
| 53 |
+ etcText = part.slice(etc.code.length + 1) |
|
| 54 |
+ } else if (docs.some((doc) => doc.code === part)) {
|
|
| 49 | 55 |
selected.add(part) |
| 50 | 56 |
} |
| 51 | 57 |
} |
... | ... | @@ -53,10 +59,17 @@ |
| 53 | 59 |
} |
| 54 | 60 |
|
| 55 | 61 |
/** 체크박스 상태 → 저장값. 아무것도 선택하지 않았으면 null. */ |
| 56 |
-function serializeContractDocs(selected: Set<string>, etcText: string): string | null {
|
|
| 57 |
- const parts: string[] = CONTRACT_DOC_OPTIONS.filter((opt) => selected.has(opt)) |
|
| 58 |
- if (selected.has(CONTRACT_ETC)) {
|
|
| 59 |
- parts.push(`${CONTRACT_ETC}:${etcText}`)
|
|
| 62 |
+function serializeContractDocs( |
|
| 63 |
+ selected: Set<string>, |
|
| 64 |
+ etcText: string, |
|
| 65 |
+ docs: Code[], |
|
| 66 |
+): string | null {
|
|
| 67 |
+ const etc = etcDocOf(docs) |
|
| 68 |
+ const parts: string[] = docs |
|
| 69 |
+ .filter((doc) => doc.attrs.freeText !== true && selected.has(doc.code)) |
|
| 70 |
+ .map((doc) => doc.code) |
|
| 71 |
+ if (etc && selected.has(etc.code)) {
|
|
| 72 |
+ parts.push(`${etc.code}:${etcText}`)
|
|
| 60 | 73 |
} |
| 61 | 74 |
return parts.length > 0 ? parts.join(', ') : null
|
| 62 | 75 |
} |
... | ... | @@ -154,20 +167,37 @@ |
| 154 | 167 |
} |
| 155 | 168 |
|
| 156 | 169 |
function ProcessStatusBadge({ value }: { value: string | null }) {
|
| 157 |
- const done = value === '처리완료' |
|
| 170 |
+ // 어느 값이 '완료'인지는 코드표의 done 속성이 정한다. |
|
| 171 |
+ const statuses = useCodes('PROCESS_STATUS')
|
|
| 172 |
+ const doneCode = doneStatusOf(statuses) |
|
| 173 |
+ const done = !!doneCode && value === doneCode.code |
|
| 174 |
+ const pendingLabel = statuses.find((status) => status.attrs.done !== true)?.label ?? '' |
|
| 158 | 175 |
return ( |
| 159 | 176 |
<span |
| 160 | 177 |
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
| 161 | 178 |
done ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500' |
| 162 | 179 |
}`} |
| 163 | 180 |
> |
| 164 |
- {done ? '처리완료' : '미처리'}
|
|
| 181 |
+ {done ? doneCode.label : pendingLabel}
|
|
| 165 | 182 |
</span> |
| 166 | 183 |
) |
| 167 | 184 |
} |
| 168 | 185 |
|
| 169 | 186 |
/** 기관관리 상세의 권리처리 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다(ReviewBoard와 동일 구조). */ |
| 170 | 187 |
export default function ProcessBoard({ org }: { org: Org }) {
|
| 188 |
+ const statuses = useCodes('PROCESS_STATUS')
|
|
| 189 |
+ const doneStatusCode = doneStatusOf(statuses)?.code |
|
| 190 |
+ const koglProcess = useSplitByFirstRow('KOGL_TYPE', SCOPE_PROCESS)
|
|
| 191 |
+ // 필터 칩의 '전체'는 코드가 아니라 화면에서만 쓰는 항목이라 앞에 붙인다. |
|
| 192 |
+ // 나머지는 코드표에서 오되, 서버에 넘기는 키(PENDING/DONE)는 done 속성으로 정한다. |
|
| 193 |
+ const statusChips: { key: ProcessStatusFilter | undefined; label: string }[] = [
|
|
| 194 |
+ { key: undefined, label: '전체' },
|
|
| 195 |
+ ...statuses.map((status) => ({
|
|
| 196 |
+ key: (status.attrs.done === true ? 'DONE' : 'PENDING') as ProcessStatusFilter, |
|
| 197 |
+ label: status.label, |
|
| 198 |
+ })), |
|
| 199 |
+ ] |
|
| 200 |
+ |
|
| 171 | 201 |
const [mode, setMode] = useState<'list' | number>('list')
|
| 172 | 202 |
|
| 173 | 203 |
const [items, setItems] = useState<ProcessItem[]>([]) |
... | ... | @@ -188,6 +218,8 @@ |
| 188 | 218 |
const [bulkStatus, setBulkStatus] = useState('')
|
| 189 | 219 |
const [bulkKoglType, setBulkKoglType] = useState('')
|
| 190 | 220 |
const [bulkOpinion, setBulkOpinion] = useState('')
|
| 221 |
+ /** 요구사항 [15]로 추가된 비고 일괄 입력. */ |
|
| 222 |
+ const [bulkNote, setBulkNote] = useState('')
|
|
| 191 | 223 |
const [bulkBusy, setBulkBusy] = useState(false) |
| 192 | 224 |
const [bulkMessage, setBulkMessage] = useState<string | null>(null) |
| 193 | 225 |
const [loading, setLoading] = useState(false) |
... | ... | @@ -295,7 +327,7 @@ |
| 295 | 327 |
if (selected.length === 0 || bulkBusy) {
|
| 296 | 328 |
return |
| 297 | 329 |
} |
| 298 |
- if (!bulkStatus && !bulkKoglType && !bulkOpinion.trim()) {
|
|
| 330 |
+ if (!bulkStatus && !bulkKoglType && !bulkOpinion.trim() && !bulkNote.trim()) {
|
|
| 299 | 331 |
setBulkMessage('반영할 값을 하나 이상 채워주세요.')
|
| 300 | 332 |
return |
| 301 | 333 |
} |
... | ... | @@ -307,12 +339,14 @@ |
| 307 | 339 |
processStatus: bulkStatus || undefined, |
| 308 | 340 |
judgedKoglType: bulkKoglType || undefined, |
| 309 | 341 |
finalOpinion: bulkOpinion.trim() || undefined, |
| 342 |
+ reviewNote: bulkNote.trim() || undefined, |
|
| 310 | 343 |
}) |
| 311 | 344 |
setBulkMessage(`${res.updated}건에 반영했습니다.`)
|
| 312 | 345 |
setSelected([]) |
| 313 | 346 |
setBulkStatus('')
|
| 314 | 347 |
setBulkKoglType('')
|
| 315 | 348 |
setBulkOpinion('')
|
| 349 |
+ setBulkNote('')
|
|
| 316 | 350 |
setReloadKey((k) => k + 1) |
| 317 | 351 |
} catch (e) {
|
| 318 | 352 |
setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.') |
... | ... | @@ -363,7 +397,7 @@ |
| 363 | 397 |
<div className="flex flex-wrap items-center justify-between gap-3"> |
| 364 | 398 |
<div className="flex flex-wrap items-center gap-2"> |
| 365 | 399 |
<div className="flex items-center gap-1" role="group" aria-label="처리상태 필터"> |
| 366 |
- {STATUS_CHIPS.map((chip) => (
|
|
| 400 |
+ {statusChips.map((chip) => (
|
|
| 367 | 401 |
<button |
| 368 | 402 |
key={chip.label}
|
| 369 | 403 |
type="button" |
... | ... | @@ -510,8 +544,8 @@ |
| 510 | 544 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 511 | 545 |
> |
| 512 | 546 |
<option value="">공공누리 유형 그대로</option> |
| 513 |
- {JUDGED_KOGL_TYPE_OPTIONS.map((option) => (
|
|
| 514 |
- <option key={option} value={option}>{option}</option>
|
|
| 547 |
+ {[...koglProcess.first, ...koglProcess.rest].map((option) => (
|
|
| 548 |
+ <option key={option.code} value={option.code}>{option.label}</option>
|
|
| 515 | 549 |
))} |
| 516 | 550 |
</select> |
| 517 | 551 |
<input |
... | ... | @@ -520,6 +554,14 @@ |
| 520 | 554 |
value={bulkOpinion}
|
| 521 | 555 |
onChange={(e) => setBulkOpinion(e.target.value)}
|
| 522 | 556 |
placeholder="최종의견(비우면 유지)" |
| 557 |
+ className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 558 |
+ /> |
|
| 559 |
+ <input |
|
| 560 |
+ type="text" |
|
| 561 |
+ aria-label="일괄 비고" |
|
| 562 |
+ value={bulkNote}
|
|
| 563 |
+ onChange={(e) => setBulkNote(e.target.value)}
|
|
| 564 |
+ placeholder="비고(비우면 유지)" |
|
| 523 | 565 |
className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 524 | 566 |
/> |
| 525 | 567 |
<button |
... | ... | @@ -644,11 +686,11 @@ |
| 644 | 686 |
<ProcessStatusBadge value={item.processStatus} />
|
| 645 | 687 |
</td> |
| 646 | 688 |
<td className="px-3 py-2 text-gray-600"> |
| 647 |
- {item.processStatus === '처리완료' ? formatDate(item.processedAt) : '-'}
|
|
| 689 |
+ {item.processStatus === doneStatusCode ? formatDate(item.processedAt) : '-'}
|
|
| 648 | 690 |
</td> |
| 649 | 691 |
<td className="px-3 py-2"> |
| 650 | 692 |
<div className="flex gap-2"> |
| 651 |
- {item.processStatus === '처리완료' ? (
|
|
| 693 |
+ {item.processStatus === doneStatusCode ? (
|
|
| 652 | 694 |
<> |
| 653 | 695 |
<button |
| 654 | 696 |
type="button" |
... | ... | @@ -713,6 +755,12 @@ |
| 713 | 755 |
onBack: () => void |
| 714 | 756 |
onSaved: () => void |
| 715 | 757 |
}) {
|
| 758 |
+ const contractDocs = useCodes('CONTRACT_DOC')
|
|
| 759 |
+ const etcDoc = etcDocOf(contractDocs) |
|
| 760 |
+ const statuses = useCodes('PROCESS_STATUS')
|
|
| 761 |
+ // 권리처리 판정에는 '보류'가 없다 - 변호사 판정과 목록이 다르다. |
|
| 762 |
+ const kogl = useSplitByFirstRow('KOGL_TYPE', SCOPE_PROCESS)
|
|
| 763 |
+ |
|
| 716 | 764 |
const [item, setItem] = useState<ProcessItem | null>(null) |
| 717 | 765 |
const [loading, setLoading] = useState(true) |
| 718 | 766 |
const [loadError, setLoadError] = useState<string | null>(null) |
... | ... | @@ -739,7 +787,7 @@ |
| 739 | 787 |
return |
| 740 | 788 |
} |
| 741 | 789 |
setItem(data) |
| 742 |
- const { selected, etcText } = parseContractDocs(data.contractDocs)
|
|
| 790 |
+ const { selected, etcText } = parseContractDocs(data.contractDocs, contractDocs)
|
|
| 743 | 791 |
setContractSelected(selected) |
| 744 | 792 |
setContractEtc(etcText) |
| 745 | 793 |
setJudgedKoglType(data.judgedKoglType ?? '') |
... | ... | @@ -784,7 +832,7 @@ |
| 784 | 832 |
setSaveError(null) |
| 785 | 833 |
try {
|
| 786 | 834 |
const body: ProcessingRequest = {
|
| 787 |
- contractDocs: serializeContractDocs(contractSelected, contractEtc), |
|
| 835 |
+ contractDocs: serializeContractDocs(contractSelected, contractEtc, contractDocs), |
|
| 788 | 836 |
judgedKoglType: judgedKoglType || null, |
| 789 | 837 |
judgedAiType: judgedAiType ? 'Y' : null, |
| 790 | 838 |
finalOpinion: finalOpinion || null, |
... | ... | @@ -897,26 +945,31 @@ |
| 897 | 945 |
<InfoRow label="계약서 유무"> |
| 898 | 946 |
<div className="flex flex-col gap-1.5"> |
| 899 | 947 |
<div className="flex flex-wrap gap-x-4 gap-y-1"> |
| 900 |
- {CONTRACT_DOC_OPTIONS.map((option) => (
|
|
| 901 |
- <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
|
|
| 948 |
+ {/* '기타'는 자유 입력이 딸려 있어 뒤에 따로 그린다. */}
|
|
| 949 |
+ {contractDocs
|
|
| 950 |
+ .filter((option) => option.attrs.freeText !== true) |
|
| 951 |
+ .map((option) => ( |
|
| 952 |
+ <label key={option.code} className="flex items-center gap-1.5 text-sm text-gray-800">
|
|
| 953 |
+ <input |
|
| 954 |
+ type="checkbox" |
|
| 955 |
+ checked={contractSelected.has(option.code)}
|
|
| 956 |
+ onChange={() => toggleContractOption(option.code)}
|
|
| 957 |
+ /> |
|
| 958 |
+ {option.label}
|
|
| 959 |
+ </label> |
|
| 960 |
+ ))} |
|
| 961 |
+ {etcDoc && (
|
|
| 962 |
+ <label className="flex items-center gap-1.5 text-sm text-gray-800"> |
|
| 902 | 963 |
<input |
| 903 | 964 |
type="checkbox" |
| 904 |
- checked={contractSelected.has(option)}
|
|
| 905 |
- onChange={() => toggleContractOption(option)}
|
|
| 965 |
+ checked={contractSelected.has(etcDoc.code)}
|
|
| 966 |
+ onChange={() => toggleContractOption(etcDoc.code)}
|
|
| 906 | 967 |
/> |
| 907 |
- {option}
|
|
| 968 |
+ {etcDoc.label}
|
|
| 908 | 969 |
</label> |
| 909 |
- ))} |
|
| 910 |
- <label className="flex items-center gap-1.5 text-sm text-gray-800"> |
|
| 911 |
- <input |
|
| 912 |
- type="checkbox" |
|
| 913 |
- checked={contractSelected.has(CONTRACT_ETC)}
|
|
| 914 |
- onChange={() => toggleContractOption(CONTRACT_ETC)}
|
|
| 915 |
- /> |
|
| 916 |
- {CONTRACT_ETC}
|
|
| 917 |
- </label> |
|
| 970 |
+ )} |
|
| 918 | 971 |
</div> |
| 919 |
- {contractSelected.has(CONTRACT_ETC) && (
|
|
| 972 |
+ {etcDoc && contractSelected.has(etcDoc.code) && (
|
|
| 920 | 973 |
<input |
| 921 | 974 |
type="text" |
| 922 | 975 |
aria-label="기타 계약서 내용" |
... | ... | @@ -932,28 +985,28 @@ |
| 932 | 985 |
<InfoRow label="공공누리 유형"> |
| 933 | 986 |
<div className="flex flex-col gap-1.5"> |
| 934 | 987 |
<div className="flex flex-wrap gap-x-4 gap-y-1"> |
| 935 |
- {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o === '0유형' || o === '개방불가').map((option) => (
|
|
| 936 |
- <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
|
|
| 988 |
+ {kogl.first.map((option) => (
|
|
| 989 |
+ <label key={option.code} className="flex items-center gap-1.5 text-sm text-gray-800">
|
|
| 937 | 990 |
<input |
| 938 | 991 |
type="radio" |
| 939 | 992 |
name="judged-kogl-type" |
| 940 |
- checked={judgedKoglType === option}
|
|
| 941 |
- onChange={() => setJudgedKoglType(option)}
|
|
| 993 |
+ checked={judgedKoglType === option.code}
|
|
| 994 |
+ onChange={() => setJudgedKoglType(option.code)}
|
|
| 942 | 995 |
/> |
| 943 |
- {option}
|
|
| 996 |
+ {option.label}
|
|
| 944 | 997 |
</label> |
| 945 | 998 |
))} |
| 946 | 999 |
</div> |
| 947 | 1000 |
<div className="flex flex-wrap items-center gap-x-4 gap-y-1"> |
| 948 |
- {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o !== '0유형' && o !== '개방불가').map((option) => (
|
|
| 949 |
- <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
|
|
| 1001 |
+ {kogl.rest.map((option) => (
|
|
| 1002 |
+ <label key={option.code} className="flex items-center gap-1.5 text-sm text-gray-800">
|
|
| 950 | 1003 |
<input |
| 951 | 1004 |
type="radio" |
| 952 | 1005 |
name="judged-kogl-type" |
| 953 |
- checked={judgedKoglType === option}
|
|
| 954 |
- onChange={() => setJudgedKoglType(option)}
|
|
| 1006 |
+ checked={judgedKoglType === option.code}
|
|
| 1007 |
+ onChange={() => setJudgedKoglType(option.code)}
|
|
| 955 | 1008 |
/> |
| 956 |
- {option}
|
|
| 1009 |
+ {option.label}
|
|
| 957 | 1010 |
</label> |
| 958 | 1011 |
))} |
| 959 | 1012 |
<label className="ml-2 flex items-center gap-1.5 text-sm text-gray-800"> |
... | ... | @@ -1017,9 +1070,9 @@ |
| 1017 | 1070 |
className="w-40 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 1018 | 1071 |
> |
| 1019 | 1072 |
<option value="">선택</option> |
| 1020 |
- {PROCESS_STATUS_OPTIONS.map((option) => (
|
|
| 1021 |
- <option key={option} value={option}>
|
|
| 1022 |
- {option}
|
|
| 1073 |
+ {statuses.map((option) => (
|
|
| 1074 |
+ <option key={option.code} value={option.code}>
|
|
| 1075 |
+ {option.label}
|
|
| 1023 | 1076 |
</option> |
| 1024 | 1077 |
))} |
| 1025 | 1078 |
</select> |
--- frontend/src/components/ProjectsPage.test.tsx
+++ frontend/src/components/ProjectsPage.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import ProjectsPage from './ProjectsPage' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { ContactLog, DashboardOrgRow, OrgReport, ProjectRow } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -77,7 +78,7 @@ |
| 77 | 78 |
} |
| 78 | 79 |
|
| 79 | 80 |
async function openPanel() {
|
| 80 |
- render(<ProjectsPage onOpenOrg={vi.fn()} />)
|
|
| 81 |
+ render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
|
|
| 81 | 82 |
await screen.findByTestId('project-table')
|
| 82 | 83 |
fireEvent.click(screen.getByRole('button', { name: '연락·보고서' }))
|
| 83 | 84 |
await waitFor(() => expect(mocks.getContactLogs).toHaveBeenCalledWith(1)) |
... | ... | @@ -95,7 +96,7 @@ |
| 95 | 96 |
mocks.getProjects.mockResolvedValue([ |
| 96 | 97 |
row({ lastContactedOn: '2026-07-20', contactCount: 3, reportCount: 1 }),
|
| 97 | 98 |
]) |
| 98 |
- render(<ProjectsPage onOpenOrg={vi.fn()} />)
|
|
| 99 |
+ render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
|
|
| 99 | 100 |
|
| 100 | 101 |
const table = within(await screen.findByTestId('project-table'))
|
| 101 | 102 |
expect(table.getByText('국제방송교류재단')).toBeTruthy()
|
... | ... | @@ -106,7 +107,7 @@ |
| 106 | 107 |
|
| 107 | 108 |
it('기관명을 클릭하면 기관관리로 넘긴다', async () => {
|
| 108 | 109 |
const onOpenOrg = vi.fn() |
| 109 |
- render(<ProjectsPage onOpenOrg={onOpenOrg} />)
|
|
| 110 |
+ render(withCodes(<ProjectsPage onOpenOrg={onOpenOrg} />))
|
|
| 110 | 111 |
|
| 111 | 112 |
fireEvent.click(await screen.findByRole('button', { name: '국제방송교류재단' }))
|
| 112 | 113 |
expect(onOpenOrg).toHaveBeenCalledWith(1) |
... | ... | @@ -117,7 +118,7 @@ |
| 117 | 118 |
row(), |
| 118 | 119 |
row({ org: orgRow({ id: 2, orgNo: '002_00', orgName: '세종학당재단' }), contactCount: 2 }),
|
| 119 | 120 |
]) |
| 120 |
- render(<ProjectsPage onOpenOrg={vi.fn()} />)
|
|
| 121 |
+ render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
|
|
| 121 | 122 |
await screen.findByTestId('project-table')
|
| 122 | 123 |
|
| 123 | 124 |
fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '세종' } })
|
--- frontend/src/components/ProjectsPage.tsx
+++ frontend/src/components/ProjectsPage.tsx
... | ... | @@ -13,7 +13,8 @@ |
| 13 | 13 |
type OrgReport, |
| 14 | 14 |
type ProjectRow, |
| 15 | 15 |
} from '../api/client' |
| 16 |
-import { stageLabel } from '../stages'
|
|
| 16 |
+import { useStages } from '../codes/stage'
|
|
| 17 |
+import { useCodes } from '../codes/useCodes'
|
|
| 17 | 18 |
import HelpButton from '../help/HelpButton' |
| 18 | 19 |
|
| 19 | 20 |
/** |
... | ... | @@ -28,8 +29,6 @@ |
| 28 | 29 |
/** 기관명을 클릭했을 때 기관관리 화면으로 넘긴다. */ |
| 29 | 30 |
onOpenOrg: (orgId: number) => void |
| 30 | 31 |
} |
| 31 |
- |
|
| 32 |
-const METHODS = ['전화', '메일', '방문', '기타'] |
|
| 33 | 32 |
|
| 34 | 33 |
function formatDateTime(ms: number): string {
|
| 35 | 34 |
const d = new Date(ms) |
... | ... | @@ -55,6 +54,11 @@ |
| 55 | 54 |
} |
| 56 | 55 |
|
| 57 | 56 |
export default function ProjectsPage({ onOpenOrg }: Props) {
|
| 57 |
+ // 단계 목록과 연락 방법은 코드표에서 온다. 단계는 예전에 12로 박혀 있어서 |
|
| 58 |
+ // 13단계를 늘리면 이 필터만 12개로 남는 문제가 있었다. |
|
| 59 |
+ const stages = useStages() |
|
| 60 |
+ const methods = useCodes('CONTACT_METHOD')
|
|
| 61 |
+ |
|
| 58 | 62 |
const [rows, setRows] = useState<ProjectRow[] | null>(null) |
| 59 | 63 |
const [error, setError] = useState<string | null>(null) |
| 60 | 64 |
const [keyword, setKeyword] = useState('')
|
... | ... | @@ -69,7 +73,7 @@ |
| 69 | 73 |
const [panelError, setPanelError] = useState<string | null>(null) |
| 70 | 74 |
|
| 71 | 75 |
const [logDate, setLogDate] = useState(today()) |
| 72 |
- const [logMethod, setLogMethod] = useState(METHODS[0]) |
|
| 76 |
+ const [logMethod, setLogMethod] = useState(methods[0]?.code ?? '') |
|
| 73 | 77 |
const [logSummary, setLogSummary] = useState('')
|
| 74 | 78 |
const [editingLogId, setEditingLogId] = useState<number | null>(null) |
| 75 | 79 |
|
... | ... | @@ -237,8 +241,8 @@ |
| 237 | 241 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 238 | 242 |
> |
| 239 | 243 |
<option value="">현재 단계 전체</option> |
| 240 |
- {Array.from({ length: 12 }, (_, i) => i + 1).map((n) => (
|
|
| 241 |
- <option key={n} value={String(n)}>{`${n}. ${stageLabel(n)}`}</option>
|
|
| 244 |
+ {stages.numbers.map((n) => (
|
|
| 245 |
+ <option key={n} value={String(n)}>{`${n}. ${stages.label(n)}`}</option>
|
|
| 242 | 246 |
))} |
| 243 | 247 |
</select> |
| 244 | 248 |
<select |
... | ... | @@ -296,7 +300,9 @@ |
| 296 | 300 |
</button> |
| 297 | 301 |
</td> |
| 298 | 302 |
<td className="px-2 py-2 text-gray-600"> |
| 299 |
- {row.org.stage === null ? '단계 미지정' : `${row.org.stage}. ${stageLabel(row.org.stage)}`}
|
|
| 303 |
+ {row.org.stage === null
|
|
| 304 |
+ ? '단계 미지정' |
|
| 305 |
+ : `${row.org.stage}. ${stages.label(row.org.stage)}`}
|
|
| 300 | 306 |
</td> |
| 301 | 307 |
<td className="px-2 py-2 text-gray-600">{row.org.mjName ?? '-'}</td>
|
| 302 | 308 |
<td className="px-2 py-2 text-gray-600">{row.org.lawyerName ?? '-'}</td>
|
... | ... | @@ -384,8 +390,10 @@ |
| 384 | 390 |
onChange={(e) => setLogMethod(e.target.value)}
|
| 385 | 391 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 386 | 392 |
> |
| 387 |
- {METHODS.map((m) => (
|
|
| 388 |
- <option key={m} value={m}>{m}</option>
|
|
| 393 |
+ {methods.map((m) => (
|
|
| 394 |
+ <option key={m.code} value={m.code}>
|
|
| 395 |
+ {m.label}
|
|
| 396 |
+ </option> |
|
| 389 | 397 |
))} |
| 390 | 398 |
</select> |
| 391 | 399 |
</label> |
+++ frontend/src/components/ReMemos.tsx
... | ... | @@ -0,0 +1,231 @@ |
| 1 | +import { useCallback, useEffect, useState } from 'react' | |
| 2 | +import { | |
| 3 | + addReMemo, | |
| 4 | + deleteReMemo, | |
| 5 | + getReMemos, | |
| 6 | + updateReMemo, | |
| 7 | + type Org, | |
| 8 | + type ReMemo, | |
| 9 | +} from '../api/client' | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 기관관리 상세의 RE 탭. | |
| 13 | + * | |
| 14 | + * 요구사항 [4]: 별도 양식 없이 메모장처럼 등록한다. 메모 1개가 RE 1개다. | |
| 15 | + * 예) ooo 계약서 및 제안요청서 송부 요청, 장영익, 2026.07.08 | |
| 16 | + * | |
| 17 | + * 완료 체크는 회신에 없던 항목이다. 대시보드 '미해결 RE' 숫자를 세려면 해결된 건과 아닌 건을 | |
| 18 | + * 가릴 수단이 필요해 한 칸 두었다. | |
| 19 | + */ | |
| 20 | +function today(): string { | |
| 21 | + const d = new Date() | |
| 22 | + const pad = (n: number) => String(n).padStart(2, '0') | |
| 23 | + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` | |
| 24 | +} | |
| 25 | + | |
| 26 | +export default function ReMemos({ org }: { org: Org }) { | |
| 27 | + const [memos, setMemos] = useState<ReMemo[]>([]) | |
| 28 | + const [loading, setLoading] = useState(true) | |
| 29 | + const [error, setError] = useState<string | null>(null) | |
| 30 | + const [busy, setBusy] = useState(false) | |
| 31 | + | |
| 32 | + const [content, setContent] = useState('') | |
| 33 | + const [author, setAuthor] = useState('') | |
| 34 | + const [memoDate, setMemoDate] = useState(today()) | |
| 35 | + const [editingId, setEditingId] = useState<number | null>(null) | |
| 36 | + | |
| 37 | + const load = useCallback(async () => { | |
| 38 | + setLoading(true) | |
| 39 | + try { | |
| 40 | + setMemos(await getReMemos(org.id)) | |
| 41 | + setError(null) | |
| 42 | + } catch (e) { | |
| 43 | + setError((e as Error).message) | |
| 44 | + } finally { | |
| 45 | + setLoading(false) | |
| 46 | + } | |
| 47 | + }, [org.id]) | |
| 48 | + | |
| 49 | + useEffect(() => { | |
| 50 | + void load() | |
| 51 | + }, [load]) | |
| 52 | + | |
| 53 | + function resetForm() { | |
| 54 | + setContent('') | |
| 55 | + setAuthor('') | |
| 56 | + setMemoDate(today()) | |
| 57 | + setEditingId(null) | |
| 58 | + } | |
| 59 | + | |
| 60 | + async function handleSave() { | |
| 61 | + if (!content.trim() || !author.trim() || busy) { | |
| 62 | + return | |
| 63 | + } | |
| 64 | + setBusy(true) | |
| 65 | + setError(null) | |
| 66 | + try { | |
| 67 | + const body = { content: content.trim(), author: author.trim(), memoDate, resolved: false } | |
| 68 | + const next = editingId === null | |
| 69 | + ? await addReMemo(org.id, body) | |
| 70 | + : await updateReMemo(org.id, editingId, { | |
| 71 | + ...body, | |
| 72 | + resolved: memos.find((m) => m.id === editingId)?.resolved ?? false, | |
| 73 | + }) | |
| 74 | + setMemos(next) | |
| 75 | + resetForm() | |
| 76 | + } catch (e) { | |
| 77 | + setError((e as Error).message) | |
| 78 | + } finally { | |
| 79 | + setBusy(false) | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + async function toggleResolved(memo: ReMemo) { | |
| 84 | + setBusy(true) | |
| 85 | + try { | |
| 86 | + setMemos(await updateReMemo(org.id, memo.id, { | |
| 87 | + content: memo.content, | |
| 88 | + author: memo.author, | |
| 89 | + memoDate: memo.memoDate, | |
| 90 | + resolved: !memo.resolved, | |
| 91 | + })) | |
| 92 | + } catch (e) { | |
| 93 | + setError((e as Error).message) | |
| 94 | + } finally { | |
| 95 | + setBusy(false) | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + async function handleDelete(memo: ReMemo) { | |
| 100 | + if (!window.confirm('이 RE를 지울까요?')) { | |
| 101 | + return | |
| 102 | + } | |
| 103 | + setBusy(true) | |
| 104 | + try { | |
| 105 | + await deleteReMemo(org.id, memo.id) | |
| 106 | + await load() | |
| 107 | + if (editingId === memo.id) { | |
| 108 | + resetForm() | |
| 109 | + } | |
| 110 | + } finally { | |
| 111 | + setBusy(false) | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 115 | + function startEdit(memo: ReMemo) { | |
| 116 | + setEditingId(memo.id) | |
| 117 | + setContent(memo.content) | |
| 118 | + setAuthor(memo.author) | |
| 119 | + setMemoDate(memo.memoDate) | |
| 120 | + } | |
| 121 | + | |
| 122 | + const unresolved = memos.filter((m) => !m.resolved).length | |
| 123 | + | |
| 124 | + return ( | |
| 125 | + <div className="p-5"> | |
| 126 | + <div className="flex items-center justify-between"> | |
| 127 | + <h3 className="text-sm font-semibold"> | |
| 128 | + RE <span className="ml-1 text-xs font-normal text-gray-500">미해결 {unresolved}건 / 전체 {memos.length}건</span> | |
| 129 | + </h3> | |
| 130 | + </div> | |
| 131 | + | |
| 132 | + <div className="mt-3 rounded-lg border border-gray-200 p-3"> | |
| 133 | + <textarea | |
| 134 | + aria-label="RE 내용" | |
| 135 | + value={content} | |
| 136 | + onChange={(e) => setContent(e.target.value)} | |
| 137 | + rows={2} | |
| 138 | + placeholder="예) ooo 계약서 및 제안요청서 송부 요청" | |
| 139 | + className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 140 | + /> | |
| 141 | + <div className="mt-2 flex flex-wrap items-center gap-2"> | |
| 142 | + <input | |
| 143 | + aria-label="작성자" | |
| 144 | + value={author} | |
| 145 | + onChange={(e) => setAuthor(e.target.value)} | |
| 146 | + placeholder="작성자" | |
| 147 | + className="w-32 rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 148 | + /> | |
| 149 | + <input | |
| 150 | + type="date" | |
| 151 | + aria-label="일자" | |
| 152 | + value={memoDate} | |
| 153 | + onChange={(e) => setMemoDate(e.target.value)} | |
| 154 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 155 | + /> | |
| 156 | + <button | |
| 157 | + type="button" | |
| 158 | + disabled={!content.trim() || !author.trim() || busy} | |
| 159 | + onClick={() => void handleSave()} | |
| 160 | + className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300" | |
| 161 | + > | |
| 162 | + {editingId === null ? '등록' : '수정'} | |
| 163 | + </button> | |
| 164 | + {editingId !== null && ( | |
| 165 | + <button | |
| 166 | + type="button" | |
| 167 | + onClick={resetForm} | |
| 168 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600" | |
| 169 | + > | |
| 170 | + 취소 | |
| 171 | + </button> | |
| 172 | + )} | |
| 173 | + </div> | |
| 174 | + </div> | |
| 175 | + | |
| 176 | + {error && <p className="mt-2 text-sm text-red-600">{error}</p>} | |
| 177 | + | |
| 178 | + {loading ? ( | |
| 179 | + <p className="mt-4 text-sm text-gray-500">불러오는 중…</p> | |
| 180 | + ) : memos.length === 0 ? ( | |
| 181 | + <p className="mt-4 text-sm text-gray-500">등록된 RE가 없습니다.</p> | |
| 182 | + ) : ( | |
| 183 | + <ul className="mt-4 space-y-2"> | |
| 184 | + {memos.map((memo) => ( | |
| 185 | + <li | |
| 186 | + key={memo.id} | |
| 187 | + className={`rounded-lg border p-3 ${ | |
| 188 | + memo.resolved ? 'border-gray-200 bg-gray-50' : 'border-amber-200 bg-amber-50/40' | |
| 189 | + }`} | |
| 190 | + > | |
| 191 | + <div className="flex items-start justify-between gap-3"> | |
| 192 | + <p className={`whitespace-pre-wrap text-sm ${memo.resolved ? 'text-gray-400 line-through' : 'text-gray-900'}`}> | |
| 193 | + {memo.content} | |
| 194 | + </p> | |
| 195 | + <div className="flex shrink-0 gap-2"> | |
| 196 | + <button | |
| 197 | + type="button" | |
| 198 | + disabled={busy} | |
| 199 | + onClick={() => void toggleResolved(memo)} | |
| 200 | + className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" | |
| 201 | + > | |
| 202 | + {memo.resolved ? '미해결로' : '해결'} | |
| 203 | + </button> | |
| 204 | + <button | |
| 205 | + type="button" | |
| 206 | + disabled={busy} | |
| 207 | + onClick={() => startEdit(memo)} | |
| 208 | + className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" | |
| 209 | + > | |
| 210 | + 수정 | |
| 211 | + </button> | |
| 212 | + <button | |
| 213 | + type="button" | |
| 214 | + disabled={busy} | |
| 215 | + onClick={() => void handleDelete(memo)} | |
| 216 | + className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-red-600 hover:bg-red-50" | |
| 217 | + > | |
| 218 | + 삭제 | |
| 219 | + </button> | |
| 220 | + </div> | |
| 221 | + </div> | |
| 222 | + <p className="mt-1.5 text-xs text-gray-500"> | |
| 223 | + {memo.author} · {memo.memoDate} | |
| 224 | + </p> | |
| 225 | + </li> | |
| 226 | + ))} | |
| 227 | + </ul> | |
| 228 | + )} | |
| 229 | + </div> | |
| 230 | + ) | |
| 231 | +} |
--- frontend/src/components/ReviewBoard.test.tsx
+++ frontend/src/components/ReviewBoard.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import ReviewBoard from './ReviewBoard' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { Org, ReviewItem, ReviewPage } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -102,7 +103,7 @@ |
| 102 | 103 |
it('목록을 표로 보여주고 처리결과 배지가 나온다', async () => {
|
| 103 | 104 |
mocks.getReviewPage.mockResolvedValue(page()) |
| 104 | 105 |
|
| 105 |
- render(<ReviewBoard org={org()} />)
|
|
| 106 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 106 | 107 |
|
| 107 | 108 |
await waitFor(() => {
|
| 108 | 109 |
expect(screen.getByText('2026년 사업 공고')).toBeTruthy()
|
... | ... | @@ -116,7 +117,7 @@ |
| 116 | 117 |
it('자료가 없으면 안내 문구를 보여준다', async () => {
|
| 117 | 118 |
mocks.getReviewPage.mockResolvedValue(page({ items: [], total: 0 }))
|
| 118 | 119 |
|
| 119 |
- render(<ReviewBoard org={org()} />)
|
|
| 120 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 120 | 121 |
|
| 121 | 122 |
await waitFor(() => {
|
| 122 | 123 |
expect( |
... | ... | @@ -128,7 +129,7 @@ |
| 128 | 129 |
it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => {
|
| 129 | 130 |
mocks.getReviewPage.mockResolvedValue(page()) |
| 130 | 131 |
|
| 131 |
- render(<ReviewBoard org={org()} />)
|
|
| 132 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 132 | 133 |
|
| 133 | 134 |
await waitFor(() => |
| 134 | 135 |
expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 0, size: 30 }),
|
... | ... | @@ -146,7 +147,7 @@ |
| 146 | 147 |
mocks.getReviewPage.mockResolvedValue(page()) |
| 147 | 148 |
mocks.importReview.mockResolvedValue({ created: 3, updated: 2, total: 5 })
|
| 148 | 149 |
|
| 149 |
- const { container } = render(<ReviewBoard org={org()} />)
|
|
| 150 |
+ const { container } = render(withCodes(<ReviewBoard org={org()} />))
|
|
| 150 | 151 |
|
| 151 | 152 |
await waitFor(() => expect(mocks.getReviewPage).toHaveBeenCalledTimes(1)) |
| 152 | 153 |
|
... | ... | @@ -167,7 +168,7 @@ |
| 167 | 168 |
mocks.bulkDeleteReview.mockResolvedValue({ deleted: 1 })
|
| 168 | 169 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 169 | 170 |
|
| 170 |
- render(<ReviewBoard org={org()} />)
|
|
| 171 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 171 | 172 |
|
| 172 | 173 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 173 | 174 |
|
... | ... | @@ -186,7 +187,7 @@ |
| 186 | 187 |
it('전체 선택 체크박스로 페이지의 모든 행을 고른다', async () => {
|
| 187 | 188 |
mocks.getReviewPage.mockResolvedValue(page()) |
| 188 | 189 |
|
| 189 |
- render(<ReviewBoard org={org()} />)
|
|
| 190 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 190 | 191 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 191 | 192 |
|
| 192 | 193 |
fireEvent.click(screen.getByLabelText('전체 선택'))
|
... | ... | @@ -201,7 +202,7 @@ |
| 201 | 202 |
mocks.getReviewPage.mockResolvedValue(page()) |
| 202 | 203 |
mocks.bulkUpdateReview.mockResolvedValue({ updated: 1 })
|
| 203 | 204 |
|
| 204 |
- render(<ReviewBoard org={org()} />)
|
|
| 205 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 205 | 206 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 206 | 207 |
|
| 207 | 208 |
fireEvent.click(screen.getByLabelText('1번 선택'))
|
... | ... | @@ -223,7 +224,7 @@ |
| 223 | 224 |
it('아무 값도 채우지 않고 일괄 등록하면 안내만 하고 호출하지 않는다', async () => {
|
| 224 | 225 |
mocks.getReviewPage.mockResolvedValue(page()) |
| 225 | 226 |
|
| 226 |
- render(<ReviewBoard org={org()} />)
|
|
| 227 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 227 | 228 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 228 | 229 |
|
| 229 | 230 |
fireEvent.click(screen.getByLabelText('1번 선택'))
|
... | ... | @@ -236,7 +237,7 @@ |
| 236 | 237 |
it('상세검색 3칸은 열별 조건으로 함께 넘어간다', async () => {
|
| 237 | 238 |
mocks.getReviewPage.mockResolvedValue(page()) |
| 238 | 239 |
|
| 239 |
- render(<ReviewBoard org={org()} />)
|
|
| 240 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 240 | 241 |
await waitFor(() => expect(mocks.getReviewPage).toHaveBeenCalled()) |
| 241 | 242 |
|
| 242 | 243 |
fireEvent.click(screen.getByRole('button', { name: '상세검색' }))
|
... | ... | @@ -260,7 +261,7 @@ |
| 260 | 261 |
it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
|
| 261 | 262 |
mocks.getReviewPage.mockResolvedValue(page({ total: 31 }))
|
| 262 | 263 |
|
| 263 |
- render(<ReviewBoard org={org()} />)
|
|
| 264 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 264 | 265 |
|
| 265 | 266 |
await waitFor(() => |
| 266 | 267 |
expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 0, size: 30 }),
|
... | ... | @@ -281,7 +282,7 @@ |
| 281 | 282 |
item({ reviewMajor: '만료 저작물', opinion: '검토 완료' }),
|
| 282 | 283 |
) |
| 283 | 284 |
|
| 284 |
- render(<ReviewBoard org={org()} />)
|
|
| 285 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 285 | 286 |
|
| 286 | 287 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 287 | 288 |
fireEvent.click(screen.getByRole('button', { name: '수정' }))
|
... | ... | @@ -297,7 +298,7 @@ |
| 297 | 298 |
mocks.getReviewPage.mockResolvedValue(page()) |
| 298 | 299 |
mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null }))
|
| 299 | 300 |
|
| 300 |
- render(<ReviewBoard org={org()} />)
|
|
| 301 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 301 | 302 |
|
| 302 | 303 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 303 | 304 |
fireEvent.click(screen.getByRole('button', { name: '수정' }))
|
... | ... | @@ -322,7 +323,7 @@ |
| 322 | 323 |
mocks.getReviewItem.mockResolvedValue( |
| 323 | 324 |
item({ reviewMajor: null, reviewMinor: null, reviewResult: null, judgedKoglType: null, ...itemOverrides }),
|
| 324 | 325 |
) |
| 325 |
- render(<ReviewBoard org={org()} />)
|
|
| 326 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 326 | 327 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 327 | 328 |
fireEvent.click(screen.getByRole('button', { name: '수정' }))
|
| 328 | 329 |
await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled()) |
... | ... | @@ -400,7 +401,7 @@ |
| 400 | 401 |
mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null }))
|
| 401 | 402 |
mocks.updateReviewJudgment.mockResolvedValue(item({ reviewResult: '신유형 개방' }))
|
| 402 | 403 |
|
| 403 |
- render(<ReviewBoard org={org()} />)
|
|
| 404 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 404 | 405 |
|
| 405 | 406 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 406 | 407 |
fireEvent.click(screen.getByRole('button', { name: '수정' }))
|
... | ... | @@ -436,7 +437,7 @@ |
| 436 | 437 |
mocks.getReviewPage.mockResolvedValue(page()) |
| 437 | 438 |
mocks.getReviewItem.mockResolvedValue(item()) |
| 438 | 439 |
|
| 439 |
- render(<ReviewBoard org={org()} />)
|
|
| 440 |
+ render(withCodes(<ReviewBoard org={org()} />))
|
|
| 440 | 441 |
|
| 441 | 442 |
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
|
| 442 | 443 |
fireEvent.click(screen.getByRole('button', { name: '수정' }))
|
--- frontend/src/components/ReviewBoard.tsx
+++ frontend/src/components/ReviewBoard.tsx
... | ... | @@ -10,102 +10,50 @@ |
| 10 | 10 |
type JudgmentRequest, |
| 11 | 11 |
type Org, |
| 12 | 12 |
type ReviewImportReport, |
| 13 |
+ type Code, |
|
| 13 | 14 |
type ReviewItem, |
| 14 | 15 |
} from '../api/client' |
| 16 |
+import { NOTE_NO_PROCESSING } from '../codes/messages'
|
|
| 17 |
+import { badgeClass } from '../codes/tone'
|
|
| 18 |
+import { attr, useCodes, useScopedCodes, useSplitByFirstRow } from '../codes/useCodes'
|
|
| 15 | 19 |
|
| 16 | 20 |
const PAGE_SIZE = 30 |
| 17 | 21 |
|
| 18 |
-const REVIEW_MAJOR_OPTIONS = ['만료 저작물', '업무상 저작물', '제3자 권리', '개인정보 포함'] |
|
| 22 |
+/** 공공누리 유형이 쓰이는 자리. 변호사 판정과 기존 부착 유형은 고를 수 있는 목록이 다르다. */ |
|
| 23 |
+const SCOPE_REVIEW = 'review' |
|
| 24 |
+const SCOPE_PRIOR = 'prior' |
|
| 19 | 25 |
|
| 20 |
-const REVIEW_MINOR_OPTIONS = [ |
|
| 21 |
- '1. 원시적 권리 전부 보유', |
|
| 22 |
- '2. 후천적 권리 전부 보유(계약서 확인 필요)', |
|
| 23 |
- '3. 권리 일부(공동) 보유', |
|
| 24 |
- '4. 권리 미보유', |
|
| 25 |
- '초상권 포함', |
|
| 26 |
-] |
|
| 26 |
+// 대분류·세부·처리결과·공공누리 유형은 모두 코드표에서 온다. |
|
| 27 |
+// 판정 매트릭스는 종이 양식과 같은 자리에 칸을 놓아야 해서 목록을 순서대로 훑지 않고 |
|
| 28 |
+// majors[0], minors[1] 처럼 자리로 집는다. 순서는 코드표의 정렬순서가 정한다. |
|
| 27 | 29 |
|
| 28 | 30 |
/** |
| 29 |
- * 처리결과 선택지. 앞 4개의 순서는 판정 매트릭스의 행 배치와 맞물려 있으므로 바꾸지 말고, |
|
| 30 |
- * 새 항목은 뒤에 붙인다. '권리처리 추진 미희망'은 제3자 권리 정책변경으로 추가된 항목이다. |
|
| 31 |
+ * 대분류/세부 선택에 따른 처리결과 추천값. 매칭되는 조합이 없으면 null(현재 값 유지). |
|
| 32 |
+ * |
|
| 33 |
+ * 예전에는 대분류·세부 이름을 직접 비교하는 if 사슬이었다. 지금은 코드의 suggest 속성만 |
|
| 34 |
+ * 읽는다 - 추천값이 바뀌어도 코드표만 고치면 된다. |
|
| 31 | 35 |
*/ |
| 32 |
-const REVIEW_RESULT_OPTIONS = [ |
|
| 33 |
- '신유형 개방', |
|
| 34 |
- '계약서 등 재확인', |
|
| 35 |
- '권리처리 추진', |
|
| 36 |
- '개방불가', |
|
| 37 |
- '권리처리 추진 미희망', |
|
| 38 |
-] |
|
| 39 |
- |
|
| 40 |
-const RESULT_PROCESSING = '권리처리 추진' |
|
| 41 |
-const RESULT_NO_PROCESSING = '권리처리 추진 미희망' |
|
| 42 |
-const RESULT_BLOCKED = '개방불가' |
|
| 43 |
- |
|
| 44 |
-/** 권리처리를 추진하는 동안에는 공공누리 유형을 확정하지 않고 '보류'로 둔다(정책변경으로 신설). */ |
|
| 45 |
-const KOGL_ON_HOLD = '보류' |
|
| 46 |
- |
|
| 47 |
-/** 권리처리를 추진하지 않아 기존 공공누리 유형을 그대로 쓸 때 비고에 남기는 문구. */ |
|
| 48 |
-const NOTE_NO_PROCESSING = '권리처리 추진 미희망에 따른 기존 공공누리 부착' |
|
| 49 |
- |
|
| 50 |
-const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '1유형', '2유형', '3유형', '4유형', '개방불가', KOGL_ON_HOLD] |
|
| 51 |
- |
|
| 52 |
-/** 공공누리 유형 라디오 첫 줄에 놓는 항목(나머지는 둘째 줄). */ |
|
| 53 |
-const KOGL_FIRST_ROW = ['0유형', RESULT_BLOCKED, KOGL_ON_HOLD] |
|
| 54 |
- |
|
| 55 |
-/** |
|
| 56 |
- * 제3자 권리 세부항목별로 고를 수 있는 처리결과. 정책변경(2026-07-20 검토안)에 따라 |
|
| 57 |
- * 3·4번은 추진/미희망 택일, 초상권은 개방불가까지 3택이다. 여기 없는 세부항목은 제한이 없다. |
|
| 58 |
- */ |
|
| 59 |
-const ALLOWED_RESULTS_BY_MINOR: Record<string, string[]> = {
|
|
| 60 |
- '3. 권리 일부(공동) 보유': [RESULT_PROCESSING, RESULT_NO_PROCESSING], |
|
| 61 |
- '4. 권리 미보유': [RESULT_PROCESSING, RESULT_NO_PROCESSING], |
|
| 62 |
- '초상권 포함': [RESULT_PROCESSING, RESULT_NO_PROCESSING, RESULT_BLOCKED], |
|
| 36 |
+function suggestReviewResult( |
|
| 37 |
+ major: string, |
|
| 38 |
+ minor: string, |
|
| 39 |
+ majors: Code[], |
|
| 40 |
+ minors: Code[], |
|
| 41 |
+): string | null {
|
|
| 42 |
+ const majorCode = majors.find((candidate) => candidate.code === major) |
|
| 43 |
+ if (!majorCode) {
|
|
| 44 |
+ return null |
|
| 45 |
+ } |
|
| 46 |
+ // 세부항목을 쓰는 대분류(제3자 권리)는 세부에서 추천값이 나온다. |
|
| 47 |
+ if (majorCode.attrs.hasMinor === true) {
|
|
| 48 |
+ return attr(minors.find((candidate) => candidate.code === minor), 'suggest') |
|
| 49 |
+ } |
|
| 50 |
+ return attr(majorCode, 'suggest') |
|
| 63 | 51 |
} |
| 64 | 52 |
|
| 65 |
-const REVIEW_RESULT_BADGE_STYLES: Record<string, string> = {
|
|
| 66 |
- '신유형 개방': 'bg-emerald-50 text-emerald-700', |
|
| 67 |
- '계약서 등 재확인': 'bg-amber-50 text-amber-700', |
|
| 68 |
- '권리처리 추진': 'bg-blue-50 text-blue-700', |
|
| 69 |
- '권리처리 추진 미희망': 'bg-slate-100 text-slate-700', |
|
| 70 |
- 개방불가: 'bg-red-50 text-red-700', |
|
| 53 |
+/** 세부항목의 회색 부연설명. 없으면 undefined. */ |
|
| 54 |
+function noteOf(code: Code | undefined): string | undefined {
|
|
| 55 |
+ return attr(code, 'note') ?? undefined |
|
| 71 | 56 |
} |
| 72 |
- |
|
| 73 |
-/** 대분류/세부 선택에 따른 처리결과 추천값. 매칭되는 조합이 없으면 null(현재 값 유지). */ |
|
| 74 |
-function suggestReviewResult(major: string, minor: string): string | null {
|
|
| 75 |
- if (major === '만료 저작물' || major === '업무상 저작물') {
|
|
| 76 |
- return '신유형 개방' |
|
| 77 |
- } |
|
| 78 |
- if (major === '개인정보 포함') {
|
|
| 79 |
- return '개방불가' |
|
| 80 |
- } |
|
| 81 |
- if (major === '제3자 권리') {
|
|
| 82 |
- if (minor === '1. 원시적 권리 전부 보유') {
|
|
| 83 |
- return '신유형 개방' |
|
| 84 |
- } |
|
| 85 |
- if (minor === '2. 후천적 권리 전부 보유(계약서 확인 필요)') {
|
|
| 86 |
- return '계약서 등 재확인' |
|
| 87 |
- } |
|
| 88 |
- if (ALLOWED_RESULTS_BY_MINOR[minor]) {
|
|
| 89 |
- // 정책변경: 추진/미희망(초상권은 개방불가까지) 중 택일이며 기본값은 '권리처리 추진'이다. |
|
| 90 |
- return RESULT_PROCESSING |
|
| 91 |
- } |
|
| 92 |
- } |
|
| 93 |
- return null |
|
| 94 |
-} |
|
| 95 |
- |
|
| 96 |
-/** 세부(review-minor) 라디오의 실제 저장값은 REVIEW_MINOR_OPTIONS 그대로 유지하고, |
|
| 97 |
- * 참조 양식과 같은 화면 표기(줄바꿈된 부연설명)만 별도로 매핑한다. */ |
|
| 98 |
-const REVIEW_MINOR_DISPLAY: { value: string; label: string; note?: string }[] = [
|
|
| 99 |
- { value: REVIEW_MINOR_OPTIONS[0], label: '1. 원시적 권리 전부 보유' },
|
|
| 100 |
- { value: REVIEW_MINOR_OPTIONS[1], label: '2. 후천적 권리 전부 보유', note: '(계약에 의한 전부 양수)' },
|
|
| 101 |
- {
|
|
| 102 |
- value: REVIEW_MINOR_OPTIONS[2], |
|
| 103 |
- label: '3. 권리 일부(공동) 보유', |
|
| 104 |
- note: '(보도자료·제3자저작물, 계약에 의한 전부/공동 보유 등)', |
|
| 105 |
- }, |
|
| 106 |
- { value: REVIEW_MINOR_OPTIONS[3], label: '4. 권리 미보유', note: '(공모전 수상작 등)' },
|
|
| 107 |
- { value: REVIEW_MINOR_OPTIONS[4], label: '초상권 포함' },
|
|
| 108 |
-] |
|
| 109 | 57 |
|
| 110 | 58 |
/** 소제목: 작은 파란 사각 불릿 + 굵은 제목 + 회색 부연설명. */ |
| 111 | 59 |
function SectionHeader({ title, note }: { title: string; note?: string }) {
|
... | ... | @@ -212,22 +160,25 @@ |
| 212 | 160 |
} |
| 213 | 161 |
|
| 214 | 162 |
function ReviewResultBadge({ value }: { value: string | null }) {
|
| 163 |
+ // 뱃지 색은 코드의 tone에서 온다. 코드표에 없는 값이면 badgeClass가 회색으로 떨어뜨린다. |
|
| 164 |
+ const results = useCodes('REVIEW_RESULT')
|
|
| 215 | 165 |
if (!value) {
|
| 216 | 166 |
return ( |
| 217 | 167 |
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">미판정</span> |
| 218 | 168 |
) |
| 219 | 169 |
} |
| 170 |
+ const tone = attr(results.find((candidate) => candidate.code === value), 'tone') |
|
| 220 | 171 |
return ( |
| 221 |
- <span |
|
| 222 |
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${REVIEW_RESULT_BADGE_STYLES[value] ?? 'bg-gray-100 text-gray-600'}`}
|
|
| 223 |
- > |
|
| 224 |
- {value}
|
|
| 225 |
- </span> |
|
| 172 |
+ <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(tone)}`}>{value}</span>
|
|
| 226 | 173 |
) |
| 227 | 174 |
} |
| 228 | 175 |
|
| 229 | 176 |
/** 기관관리 상세의 권리확인 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다. */ |
| 230 | 177 |
export default function ReviewBoard({ org }: { org: Org }) {
|
| 178 |
+ // 일괄 등록 드롭다운이 쓰는 선택지. 변호사가 고르는 자리라 review scope다. |
|
| 179 |
+ const results = useCodes('REVIEW_RESULT')
|
|
| 180 |
+ const koglReview = useScopedCodes('KOGL_TYPE', SCOPE_REVIEW)
|
|
| 181 |
+ |
|
| 231 | 182 |
const [mode, setMode] = useState<'list' | number>('list')
|
| 232 | 183 |
|
| 233 | 184 |
const [items, setItems] = useState<ReviewItem[]>([]) |
... | ... | @@ -251,6 +202,8 @@ |
| 251 | 202 |
const [bulkResult, setBulkResult] = useState('')
|
| 252 | 203 |
const [bulkKoglType, setBulkKoglType] = useState('')
|
| 253 | 204 |
const [bulkOpinion, setBulkOpinion] = useState('')
|
| 205 |
+ /** 요구사항 [15]로 추가된 비고 일괄 입력. */ |
|
| 206 |
+ const [bulkNote, setBulkNote] = useState('')
|
|
| 254 | 207 |
const [bulkBusy, setBulkBusy] = useState(false) |
| 255 | 208 |
const [bulkMessage, setBulkMessage] = useState<string | null>(null) |
| 256 | 209 |
const [loading, setLoading] = useState(false) |
... | ... | @@ -334,7 +287,7 @@ |
| 334 | 287 |
if (selected.length === 0 || bulkBusy) {
|
| 335 | 288 |
return |
| 336 | 289 |
} |
| 337 |
- if (!bulkResult && !bulkKoglType && !bulkOpinion.trim()) {
|
|
| 290 |
+ if (!bulkResult && !bulkKoglType && !bulkOpinion.trim() && !bulkNote.trim()) {
|
|
| 338 | 291 |
setBulkMessage('반영할 값을 하나 이상 채워주세요.')
|
| 339 | 292 |
return |
| 340 | 293 |
} |
... | ... | @@ -346,12 +299,14 @@ |
| 346 | 299 |
reviewResult: bulkResult || undefined, |
| 347 | 300 |
judgedKoglType: bulkKoglType || undefined, |
| 348 | 301 |
opinion: bulkOpinion.trim() || undefined, |
| 302 |
+ lawyerNote: bulkNote.trim() || undefined, |
|
| 349 | 303 |
}) |
| 350 | 304 |
setBulkMessage(`${res.updated}건에 반영했습니다.`)
|
| 351 | 305 |
setSelected([]) |
| 352 | 306 |
setBulkResult('')
|
| 353 | 307 |
setBulkKoglType('')
|
| 354 | 308 |
setBulkOpinion('')
|
| 309 |
+ setBulkNote('')
|
|
| 355 | 310 |
setReloadKey((k) => k + 1) |
| 356 | 311 |
} catch (e) {
|
| 357 | 312 |
setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.') |
... | ... | @@ -533,8 +488,8 @@ |
| 533 | 488 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 534 | 489 |
> |
| 535 | 490 |
<option value="">처리결과 그대로</option> |
| 536 |
- {REVIEW_RESULT_OPTIONS.map((option) => (
|
|
| 537 |
- <option key={option} value={option}>{option}</option>
|
|
| 491 |
+ {results.map((option) => (
|
|
| 492 |
+ <option key={option.code} value={option.code}>{option.label}</option>
|
|
| 538 | 493 |
))} |
| 539 | 494 |
</select> |
| 540 | 495 |
<select |
... | ... | @@ -544,8 +499,8 @@ |
| 544 | 499 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 545 | 500 |
> |
| 546 | 501 |
<option value="">공공누리 유형 그대로</option> |
| 547 |
- {JUDGED_KOGL_TYPE_OPTIONS.map((option) => (
|
|
| 548 |
- <option key={option} value={option}>{option}</option>
|
|
| 502 |
+ {koglReview.map((option) => (
|
|
| 503 |
+ <option key={option.code} value={option.code}>{option.label}</option>
|
|
| 549 | 504 |
))} |
| 550 | 505 |
</select> |
| 551 | 506 |
<input |
... | ... | @@ -554,6 +509,14 @@ |
| 554 | 509 |
value={bulkOpinion}
|
| 555 | 510 |
onChange={(e) => setBulkOpinion(e.target.value)}
|
| 556 | 511 |
placeholder="의견(비우면 유지)" |
| 512 |
+ className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 513 |
+ /> |
|
| 514 |
+ <input |
|
| 515 |
+ type="text" |
|
| 516 |
+ aria-label="일괄 비고" |
|
| 517 |
+ value={bulkNote}
|
|
| 518 |
+ onChange={(e) => setBulkNote(e.target.value)}
|
|
| 519 |
+ placeholder="비고(비우면 유지)" |
|
| 557 | 520 |
className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 558 | 521 |
/> |
| 559 | 522 |
<button |
... | ... | @@ -725,6 +688,16 @@ |
| 725 | 688 |
onBack: () => void |
| 726 | 689 |
onSaved: () => void |
| 727 | 690 |
}) {
|
| 691 |
+ // 판정 매트릭스가 쓰는 선택지. 자리(majors[0] 등)로 집으므로 코드표의 정렬순서가 곧 배치다. |
|
| 692 |
+ const majors = useCodes('REVIEW_MAJOR')
|
|
| 693 |
+ const minors = useCodes('REVIEW_MINOR')
|
|
| 694 |
+ const results = useCodes('REVIEW_RESULT')
|
|
| 695 |
+ // 변호사 판정값과 기존 부착 유형은 고를 수 있는 목록이 다르다. |
|
| 696 |
+ const kogl = useSplitByFirstRow('KOGL_TYPE', SCOPE_REVIEW)
|
|
| 697 |
+ const koglPrior = useScopedCodes('KOGL_TYPE', SCOPE_PRIOR)
|
|
| 698 |
+ const attachmentYn = useCodes('ATTACHMENT_YN')
|
|
| 699 |
+ const koglAttachedOptions = useCodes('KOGL_ATTACHED')
|
|
| 700 |
+ |
|
| 728 | 701 |
const [item, setItem] = useState<ReviewItem | null>(null) |
| 729 | 702 |
const [loading, setLoading] = useState(true) |
| 730 | 703 |
const [loadError, setLoadError] = useState<string | null>(null) |
... | ... | @@ -791,7 +764,7 @@ |
| 791 | 764 |
if (resultManuallySet) {
|
| 792 | 765 |
return |
| 793 | 766 |
} |
| 794 |
- const suggestion = suggestReviewResult(major, minor) |
|
| 767 |
+ const suggestion = suggestReviewResult(major, minor, majors, minors) |
|
| 795 | 768 |
if (suggestion) {
|
| 796 | 769 |
setReviewResult(suggestion) |
| 797 | 770 |
applyResultDerivations(suggestion) |
... | ... | @@ -808,15 +781,24 @@ |
| 808 | 781 |
* - 개방불가 → 공공누리 유형 '개방불가' |
| 809 | 782 |
* |
| 810 | 783 |
* 사용자가 이 값들을 다시 손으로 바꿀 수 있게, 잠그지는 않고 값만 채워 준다. |
| 784 |
+ * |
|
| 785 |
+ * 어떤 값을 채울지는 코드표(needsProcessing·koglSet·koglCopiesPrior)에서 오고, |
|
| 786 |
+ * "기존 유형을 복사하고 비고에 문구를 덧붙인다"는 동작만 여기 남는다. 동작까지 데이터로 |
|
| 787 |
+ * 옮기면 코드표 안에 규칙 언어를 새로 만드는 꼴이 된다. |
|
| 811 | 788 |
*/ |
| 812 | 789 |
function applyResultDerivations(result: string) {
|
| 813 |
- if (result === RESULT_PROCESSING) {
|
|
| 814 |
- setJudgedKoglType(KOGL_ON_HOLD) |
|
| 815 |
- setNeedsProcessing(true) |
|
| 790 |
+ const resultCode = results.find((candidate) => candidate.code === result) |
|
| 791 |
+ if (!resultCode) {
|
|
| 816 | 792 |
return |
| 817 | 793 |
} |
| 818 |
- if (result === RESULT_NO_PROCESSING) {
|
|
| 819 |
- setNeedsProcessing(false) |
|
| 794 |
+ |
|
| 795 |
+ const needs = resultCode.attrs.needsProcessing |
|
| 796 |
+ if (typeof needs === 'boolean') {
|
|
| 797 |
+ setNeedsProcessing(needs) |
|
| 798 |
+ } |
|
| 799 |
+ |
|
| 800 |
+ if (resultCode.attrs.koglCopiesPrior === true) {
|
|
| 801 |
+ // 기존에 부착돼 있던 유형을 그대로 쓰고, 그 사유를 비고에 남긴다. |
|
| 820 | 802 |
setJudgedKoglType(koglType || '') |
| 821 | 803 |
if (koglType) {
|
| 822 | 804 |
setLawyerNote((prev) => |
... | ... | @@ -827,21 +809,25 @@ |
| 827 | 809 |
} |
| 828 | 810 |
return |
| 829 | 811 |
} |
| 830 |
- if (result === RESULT_BLOCKED) {
|
|
| 831 |
- setJudgedKoglType(RESULT_BLOCKED) |
|
| 832 |
- setNeedsProcessing(false) |
|
| 812 |
+ |
|
| 813 |
+ const koglSet = attr(resultCode, 'koglSet') |
|
| 814 |
+ if (koglSet) {
|
|
| 815 |
+ setJudgedKoglType(koglSet) |
|
| 833 | 816 |
} |
| 834 | 817 |
} |
| 835 | 818 |
|
| 836 | 819 |
/** 지금 고른 세부항목에서 그 처리결과를 고를 수 있는지. 제한이 없는 세부항목은 전부 허용이다. */ |
| 837 | 820 |
function resultAllowed(option: string): boolean {
|
| 838 |
- const allowed = ALLOWED_RESULTS_BY_MINOR[reviewMinor] |
|
| 839 |
- return !allowed || allowed.includes(option) |
|
| 821 |
+ const minorCode = minors.find((candidate) => candidate.code === reviewMinor) |
|
| 822 |
+ const allowed = minorCode?.attrs.allowed |
|
| 823 |
+ return !Array.isArray(allowed) || (allowed as string[]).includes(option) |
|
| 840 | 824 |
} |
| 841 | 825 |
|
| 842 | 826 |
function pickMajor(major: string) {
|
| 843 | 827 |
setReviewMajor(major) |
| 844 |
- const minor = major === '제3자 권리' ? reviewMinor : '' |
|
| 828 |
+ // 세부항목을 쓰지 않는 대분류로 옮기면 세부 선택을 비운다. |
|
| 829 |
+ const majorCode = majors.find((candidate) => candidate.code === major) |
|
| 830 |
+ const minor = majorCode?.attrs.hasMinor === true ? reviewMinor : '' |
|
| 845 | 831 |
setReviewMinor(minor) |
| 846 | 832 |
applySuggestion(major, minor) |
| 847 | 833 |
} |
... | ... | @@ -936,34 +922,34 @@ |
| 936 | 922 |
/> |
| 937 | 923 |
<InfoRow label="첨부파일 여부"> |
| 938 | 924 |
<div className="flex items-center gap-5 text-sm"> |
| 939 |
- {['있음', '없음'].map((option) => (
|
|
| 940 |
- <label key={option} className="flex cursor-pointer items-center gap-1.5">
|
|
| 925 |
+ {attachmentYn.map((option) => (
|
|
| 926 |
+ <label key={option.code} className="flex cursor-pointer items-center gap-1.5">
|
|
| 941 | 927 |
<input |
| 942 | 928 |
type="radio" |
| 943 | 929 |
name="has-attachment" |
| 944 |
- aria-label={`첨부파일 ${option}`}
|
|
| 945 |
- checked={hasAttachment === option}
|
|
| 946 |
- onChange={() => setHasAttachment(option)}
|
|
| 930 |
+ aria-label={`첨부파일 ${option.code}`}
|
|
| 931 |
+ checked={hasAttachment === option.code}
|
|
| 932 |
+ onChange={() => setHasAttachment(option.code)}
|
|
| 947 | 933 |
className="h-3.5 w-3.5 accent-blue-600" |
| 948 | 934 |
/> |
| 949 |
- {option}
|
|
| 935 |
+ {option.label}
|
|
| 950 | 936 |
</label> |
| 951 | 937 |
))} |
| 952 | 938 |
</div> |
| 953 | 939 |
</InfoRow> |
| 954 | 940 |
<InfoRow label="기존 공공누리"> |
| 955 | 941 |
<div className="flex flex-wrap items-center gap-5 text-sm"> |
| 956 |
- {['부착', '미부착'].map((option) => (
|
|
| 957 |
- <label key={option} className="flex cursor-pointer items-center gap-1.5">
|
|
| 942 |
+ {koglAttachedOptions.map((option) => (
|
|
| 943 |
+ <label key={option.code} className="flex cursor-pointer items-center gap-1.5">
|
|
| 958 | 944 |
<input |
| 959 | 945 |
type="radio" |
| 960 | 946 |
name="kogl-attached" |
| 961 |
- aria-label={`기존 공공누리 ${option}`}
|
|
| 962 |
- checked={koglAttached === option}
|
|
| 963 |
- onChange={() => setKoglAttached(option)}
|
|
| 947 |
+ aria-label={`기존 공공누리 ${option.code}`}
|
|
| 948 |
+ checked={koglAttached === option.code}
|
|
| 949 |
+ onChange={() => setKoglAttached(option.code)}
|
|
| 964 | 950 |
className="h-3.5 w-3.5 accent-blue-600" |
| 965 | 951 |
/> |
| 966 |
- {option}
|
|
| 952 |
+ {option.label}
|
|
| 967 | 953 |
</label> |
| 968 | 954 |
))} |
| 969 | 955 |
<label className="flex items-center gap-1.5"> |
... | ... | @@ -975,8 +961,10 @@ |
| 975 | 961 |
className="rounded border border-gray-300 px-2 py-1 text-sm" |
| 976 | 962 |
> |
| 977 | 963 |
<option value="">선택</option> |
| 978 |
- {['0유형', '1유형', '2유형', '3유형', '4유형'].map((t) => (
|
|
| 979 |
- <option key={t} value={t}>{t}</option>
|
|
| 964 |
+ {/* 기관 조사표에서 온 값('유형 확인 필요' 등)도 여기 들어간다 - 없으면
|
|
| 965 |
+ 이미 그 값으로 저장된 1,900여 건이 "선택 안 됨"으로 보인다. */} |
|
| 966 |
+ {koglPrior.map((option) => (
|
|
| 967 |
+ <option key={option.code} value={option.code}>{option.label}</option>
|
|
| 980 | 968 |
))} |
| 981 | 969 |
</select> |
| 982 | 970 |
</label> |
... | ... | @@ -1012,10 +1000,10 @@ |
| 1012 | 1000 |
<td className="border border-gray-300 px-3 py-2 align-top" rowSpan={1}>
|
| 1013 | 1001 |
<MatrixRadio |
| 1014 | 1002 |
name="review-major" |
| 1015 |
- value={REVIEW_MAJOR_OPTIONS[0]}
|
|
| 1003 |
+ value={majors[0].code}
|
|
| 1016 | 1004 |
note="저작권성 없음, 배포용, 단순데이터" |
| 1017 |
- checked={reviewMajor === REVIEW_MAJOR_OPTIONS[0]}
|
|
| 1018 |
- onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[0])}
|
|
| 1005 |
+ checked={reviewMajor === majors[0].code}
|
|
| 1006 |
+ onChange={() => pickMajor(majors[0].code)}
|
|
| 1019 | 1007 |
/> |
| 1020 | 1008 |
</td> |
| 1021 | 1009 |
<td className="border border-gray-300 px-3 py-2" /> |
... | ... | @@ -1025,10 +1013,10 @@ |
| 1025 | 1013 |
> |
| 1026 | 1014 |
<MatrixRadio |
| 1027 | 1015 |
name="review-result" |
| 1028 |
- value={REVIEW_RESULT_OPTIONS[0]}
|
|
| 1029 |
- checked={reviewResult === REVIEW_RESULT_OPTIONS[0]}
|
|
| 1030 |
- onChange={() => pickResult(REVIEW_RESULT_OPTIONS[0])}
|
|
| 1031 |
- disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[0])}
|
|
| 1016 |
+ value={results[0].code}
|
|
| 1017 |
+ checked={reviewResult === results[0].code}
|
|
| 1018 |
+ onChange={() => pickResult(results[0].code)}
|
|
| 1019 |
+ disabled={!resultAllowed(results[0].code)}
|
|
| 1032 | 1020 |
bold |
| 1033 | 1021 |
/> |
| 1034 | 1022 |
</td> |
... | ... | @@ -1037,18 +1025,18 @@ |
| 1037 | 1025 |
<td className="border border-gray-300 px-3 py-2 align-top" rowSpan={1}>
|
| 1038 | 1026 |
<MatrixRadio |
| 1039 | 1027 |
name="review-major" |
| 1040 |
- value={REVIEW_MAJOR_OPTIONS[1]}
|
|
| 1041 |
- checked={reviewMajor === REVIEW_MAJOR_OPTIONS[1]}
|
|
| 1042 |
- onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[1])}
|
|
| 1028 |
+ value={majors[1].code}
|
|
| 1029 |
+ checked={reviewMajor === majors[1].code}
|
|
| 1030 |
+ onChange={() => pickMajor(majors[1].code)}
|
|
| 1043 | 1031 |
/> |
| 1044 | 1032 |
</td> |
| 1045 | 1033 |
<td className="border border-gray-300 px-3 py-2 align-top"> |
| 1046 | 1034 |
<MatrixRadio |
| 1047 | 1035 |
name="review-minor" |
| 1048 |
- value={REVIEW_MINOR_DISPLAY[0].value}
|
|
| 1049 |
- label={REVIEW_MINOR_DISPLAY[0].label}
|
|
| 1050 |
- checked={reviewMinor === REVIEW_MINOR_DISPLAY[0].value}
|
|
| 1051 |
- onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[0].value)}
|
|
| 1036 |
+ value={minors[0].code}
|
|
| 1037 |
+ label={minors[0].label}
|
|
| 1038 |
+ checked={reviewMinor === minors[0].code}
|
|
| 1039 |
+ onChange={() => pickMinor(minors[0].code)}
|
|
| 1052 | 1040 |
/> |
| 1053 | 1041 |
</td> |
| 1054 | 1042 |
</tr> |
... | ... | @@ -1056,19 +1044,19 @@ |
| 1056 | 1044 |
<td className="border border-gray-300 px-3 py-2 align-top" rowSpan={4}>
|
| 1057 | 1045 |
<MatrixRadio |
| 1058 | 1046 |
name="review-major" |
| 1059 |
- value={REVIEW_MAJOR_OPTIONS[2]}
|
|
| 1060 |
- checked={reviewMajor === REVIEW_MAJOR_OPTIONS[2]}
|
|
| 1061 |
- onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[2])}
|
|
| 1047 |
+ value={majors[2].code}
|
|
| 1048 |
+ checked={reviewMajor === majors[2].code}
|
|
| 1049 |
+ onChange={() => pickMajor(majors[2].code)}
|
|
| 1062 | 1050 |
/> |
| 1063 | 1051 |
</td> |
| 1064 | 1052 |
<td className="border border-gray-300 px-3 py-2 align-top"> |
| 1065 | 1053 |
<MatrixRadio |
| 1066 | 1054 |
name="review-minor" |
| 1067 |
- value={REVIEW_MINOR_DISPLAY[1].value}
|
|
| 1068 |
- label={REVIEW_MINOR_DISPLAY[1].label}
|
|
| 1069 |
- note={REVIEW_MINOR_DISPLAY[1].note}
|
|
| 1070 |
- checked={reviewMinor === REVIEW_MINOR_DISPLAY[1].value}
|
|
| 1071 |
- onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[1].value)}
|
|
| 1055 |
+ value={minors[1].code}
|
|
| 1056 |
+ label={minors[1].label}
|
|
| 1057 |
+ note={noteOf(minors[1])}
|
|
| 1058 |
+ checked={reviewMinor === minors[1].code}
|
|
| 1059 |
+ onChange={() => pickMinor(minors[1].code)}
|
|
| 1072 | 1060 |
/> |
| 1073 | 1061 |
</td> |
| 1074 | 1062 |
<td |
... | ... | @@ -1077,10 +1065,10 @@ |
| 1077 | 1065 |
> |
| 1078 | 1066 |
<MatrixRadio |
| 1079 | 1067 |
name="review-result" |
| 1080 |
- value={REVIEW_RESULT_OPTIONS[1]}
|
|
| 1081 |
- checked={reviewResult === REVIEW_RESULT_OPTIONS[1]}
|
|
| 1082 |
- onChange={() => pickResult(REVIEW_RESULT_OPTIONS[1])}
|
|
| 1083 |
- disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[1])}
|
|
| 1068 |
+ value={results[1].code}
|
|
| 1069 |
+ checked={reviewResult === results[1].code}
|
|
| 1070 |
+ onChange={() => pickResult(results[1].code)}
|
|
| 1071 |
+ disabled={!resultAllowed(results[1].code)}
|
|
| 1084 | 1072 |
bold |
| 1085 | 1073 |
/> |
| 1086 | 1074 |
</td> |
... | ... | @@ -1089,11 +1077,11 @@ |
| 1089 | 1077 |
<td className="border border-gray-300 px-3 py-2 align-top"> |
| 1090 | 1078 |
<MatrixRadio |
| 1091 | 1079 |
name="review-minor" |
| 1092 |
- value={REVIEW_MINOR_DISPLAY[2].value}
|
|
| 1093 |
- label={REVIEW_MINOR_DISPLAY[2].label}
|
|
| 1094 |
- note={REVIEW_MINOR_DISPLAY[2].note}
|
|
| 1095 |
- checked={reviewMinor === REVIEW_MINOR_DISPLAY[2].value}
|
|
| 1096 |
- onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[2].value)}
|
|
| 1080 |
+ value={minors[2].code}
|
|
| 1081 |
+ label={minors[2].label}
|
|
| 1082 |
+ note={noteOf(minors[2])}
|
|
| 1083 |
+ checked={reviewMinor === minors[2].code}
|
|
| 1084 |
+ onChange={() => pickMinor(minors[2].code)}
|
|
| 1097 | 1085 |
/> |
| 1098 | 1086 |
</td> |
| 1099 | 1087 |
<td |
... | ... | @@ -1104,20 +1092,20 @@ |
| 1104 | 1092 |
<div className="flex flex-col items-start gap-1.5"> |
| 1105 | 1093 |
<MatrixRadio |
| 1106 | 1094 |
name="review-result" |
| 1107 |
- value={RESULT_PROCESSING}
|
|
| 1108 |
- checked={reviewResult === RESULT_PROCESSING}
|
|
| 1109 |
- onChange={() => pickResult(RESULT_PROCESSING)}
|
|
| 1110 |
- disabled={!resultAllowed(RESULT_PROCESSING)}
|
|
| 1095 |
+ value={results[2].code}
|
|
| 1096 |
+ checked={reviewResult === results[2].code}
|
|
| 1097 |
+ onChange={() => pickResult(results[2].code)}
|
|
| 1098 |
+ disabled={!resultAllowed(results[2].code)}
|
|
| 1111 | 1099 |
bold |
| 1112 | 1100 |
/> |
| 1113 | 1101 |
<MatrixRadio |
| 1114 | 1102 |
name="review-result" |
| 1115 |
- value={RESULT_NO_PROCESSING}
|
|
| 1116 |
- checked={reviewResult === RESULT_NO_PROCESSING}
|
|
| 1117 |
- onChange={() => pickResult(RESULT_NO_PROCESSING)}
|
|
| 1118 |
- disabled={!resultAllowed(RESULT_NO_PROCESSING)}
|
|
| 1103 |
+ value={results[4].code}
|
|
| 1104 |
+ checked={reviewResult === results[4].code}
|
|
| 1105 |
+ onChange={() => pickResult(results[4].code)}
|
|
| 1106 |
+ disabled={!resultAllowed(results[4].code)}
|
|
| 1119 | 1107 |
/> |
| 1120 |
- {reviewMinor === '초상권 포함' && (
|
|
| 1108 |
+ {reviewMinor === minors[4].code && (
|
|
| 1121 | 1109 |
<span className="text-[11px] text-gray-400">개방불가도 선택 가능</span> |
| 1122 | 1110 |
)} |
| 1123 | 1111 |
</div> |
... | ... | @@ -1127,11 +1115,11 @@ |
| 1127 | 1115 |
<td className="border border-gray-300 px-3 py-2 align-top"> |
| 1128 | 1116 |
<MatrixRadio |
| 1129 | 1117 |
name="review-minor" |
| 1130 |
- value={REVIEW_MINOR_DISPLAY[3].value}
|
|
| 1131 |
- label={REVIEW_MINOR_DISPLAY[3].label}
|
|
| 1132 |
- note={REVIEW_MINOR_DISPLAY[3].note}
|
|
| 1133 |
- checked={reviewMinor === REVIEW_MINOR_DISPLAY[3].value}
|
|
| 1134 |
- onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[3].value)}
|
|
| 1118 |
+ value={minors[3].code}
|
|
| 1119 |
+ label={minors[3].label}
|
|
| 1120 |
+ note={noteOf(minors[3])}
|
|
| 1121 |
+ checked={reviewMinor === minors[3].code}
|
|
| 1122 |
+ onChange={() => pickMinor(minors[3].code)}
|
|
| 1135 | 1123 |
/> |
| 1136 | 1124 |
</td> |
| 1137 | 1125 |
</tr> |
... | ... | @@ -1139,10 +1127,10 @@ |
| 1139 | 1127 |
<td className="border border-gray-300 px-3 py-2 align-top"> |
| 1140 | 1128 |
<MatrixRadio |
| 1141 | 1129 |
name="review-minor" |
| 1142 |
- value={REVIEW_MINOR_DISPLAY[4].value}
|
|
| 1143 |
- label={REVIEW_MINOR_DISPLAY[4].label}
|
|
| 1144 |
- checked={reviewMinor === REVIEW_MINOR_DISPLAY[4].value}
|
|
| 1145 |
- onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[4].value)}
|
|
| 1130 |
+ value={minors[4].code}
|
|
| 1131 |
+ label={minors[4].label}
|
|
| 1132 |
+ checked={reviewMinor === minors[4].code}
|
|
| 1133 |
+ onChange={() => pickMinor(minors[4].code)}
|
|
| 1146 | 1134 |
/> |
| 1147 | 1135 |
</td> |
| 1148 | 1136 |
</tr> |
... | ... | @@ -1150,10 +1138,10 @@ |
| 1150 | 1138 |
<td className="border border-gray-300 px-3 py-2 align-top" rowSpan={1}>
|
| 1151 | 1139 |
<MatrixRadio |
| 1152 | 1140 |
name="review-major" |
| 1153 |
- value={REVIEW_MAJOR_OPTIONS[3]}
|
|
| 1141 |
+ value={majors[3].code}
|
|
| 1154 | 1142 |
note="공공누리 적용 제외 대상, 검토불가" |
| 1155 |
- checked={reviewMajor === REVIEW_MAJOR_OPTIONS[3]}
|
|
| 1156 |
- onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[3])}
|
|
| 1143 |
+ checked={reviewMajor === majors[3].code}
|
|
| 1144 |
+ onChange={() => pickMajor(majors[3].code)}
|
|
| 1157 | 1145 |
/> |
| 1158 | 1146 |
</td> |
| 1159 | 1147 |
<td className="border border-gray-300 px-3 py-2" /> |
... | ... | @@ -1163,10 +1151,10 @@ |
| 1163 | 1151 |
> |
| 1164 | 1152 |
<MatrixRadio |
| 1165 | 1153 |
name="review-result" |
| 1166 |
- value={REVIEW_RESULT_OPTIONS[3]}
|
|
| 1167 |
- checked={reviewResult === REVIEW_RESULT_OPTIONS[3]}
|
|
| 1168 |
- onChange={() => pickResult(REVIEW_RESULT_OPTIONS[3])}
|
|
| 1169 |
- disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[3])}
|
|
| 1154 |
+ value={results[3].code}
|
|
| 1155 |
+ checked={reviewResult === results[3].code}
|
|
| 1156 |
+ onChange={() => pickResult(results[3].code)}
|
|
| 1157 |
+ disabled={!resultAllowed(results[3].code)}
|
|
| 1170 | 1158 |
bold |
| 1171 | 1159 |
/> |
| 1172 | 1160 |
</td> |
... | ... | @@ -1179,32 +1167,32 @@ |
| 1179 | 1167 |
<InfoRow label="공공누리 유형"> |
| 1180 | 1168 |
<div className="flex flex-col gap-1.5"> |
| 1181 | 1169 |
<div className="flex flex-wrap gap-x-4 gap-y-1"> |
| 1182 |
- {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => KOGL_FIRST_ROW.includes(o)).map((option) => (
|
|
| 1183 |
- <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
|
|
| 1170 |
+ {kogl.first.map((option) => (
|
|
| 1171 |
+ <label key={option.code} className="flex items-center gap-1.5 text-sm text-gray-800">
|
|
| 1184 | 1172 |
<input |
| 1185 | 1173 |
type="radio" |
| 1186 | 1174 |
name="judged-kogl-type" |
| 1187 | 1175 |
// 처리결과에도 '개방불가'가 있어 라벨만으로는 구분되지 않는다. |
| 1188 |
- aria-label={`공공누리 유형 ${option}`}
|
|
| 1189 |
- checked={judgedKoglType === option}
|
|
| 1190 |
- onChange={() => setJudgedKoglType(option)}
|
|
| 1176 |
+ aria-label={`공공누리 유형 ${option.code}`}
|
|
| 1177 |
+ checked={judgedKoglType === option.code}
|
|
| 1178 |
+ onChange={() => setJudgedKoglType(option.code)}
|
|
| 1191 | 1179 |
/> |
| 1192 |
- {option}
|
|
| 1180 |
+ {option.label}
|
|
| 1193 | 1181 |
</label> |
| 1194 | 1182 |
))} |
| 1195 | 1183 |
</div> |
| 1196 | 1184 |
<div className="flex flex-wrap items-center gap-x-4 gap-y-1"> |
| 1197 |
- {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => !KOGL_FIRST_ROW.includes(o)).map((option) => (
|
|
| 1198 |
- <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
|
|
| 1185 |
+ {kogl.rest.map((option) => (
|
|
| 1186 |
+ <label key={option.code} className="flex items-center gap-1.5 text-sm text-gray-800">
|
|
| 1199 | 1187 |
<input |
| 1200 | 1188 |
type="radio" |
| 1201 | 1189 |
name="judged-kogl-type" |
| 1202 | 1190 |
// 처리결과에도 '개방불가'가 있어 라벨만으로는 구분되지 않는다. |
| 1203 |
- aria-label={`공공누리 유형 ${option}`}
|
|
| 1204 |
- checked={judgedKoglType === option}
|
|
| 1205 |
- onChange={() => setJudgedKoglType(option)}
|
|
| 1191 |
+ aria-label={`공공누리 유형 ${option.code}`}
|
|
| 1192 |
+ checked={judgedKoglType === option.code}
|
|
| 1193 |
+ onChange={() => setJudgedKoglType(option.code)}
|
|
| 1206 | 1194 |
/> |
| 1207 |
- {option}
|
|
| 1195 |
+ {option.label}
|
|
| 1208 | 1196 |
</label> |
| 1209 | 1197 |
))} |
| 1210 | 1198 |
<label className="ml-2 flex items-center gap-1.5 text-sm text-gray-800"> |
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
... | ... | @@ -40,13 +40,23 @@ |
| 40 | 40 |
expect(onSelect).toHaveBeenCalledWith('channels')
|
| 41 | 41 |
}) |
| 42 | 42 |
|
| 43 |
+ // 자료실은 요구사항 [26]으로 열렸다. 아직 준비 중인 메뉴는 권리확인·권리처리·Mattermost다. |
|
| 43 | 44 |
it('미구현 메뉴는 비활성으로 표시되고 링크가 아니다', () => {
|
| 44 | 45 |
render(<Sidebar />) |
| 45 | 46 |
|
| 46 |
- const files = screen.getByText('자료실')
|
|
| 47 |
- expect(files).toHaveAttribute('aria-disabled', 'true')
|
|
| 48 |
- expect(files).toHaveAttribute('title', '준비 중')
|
|
| 49 |
- expect(screen.queryByRole('link', { name: '자료실' })).toBeNull()
|
|
| 47 |
+ const mattermost = screen.getByText('Mattermost')
|
|
| 48 |
+ expect(mattermost).toHaveAttribute('aria-disabled', 'true')
|
|
| 49 |
+ expect(mattermost).toHaveAttribute('title', '준비 중')
|
|
| 50 |
+ expect(screen.queryByRole('link', { name: 'Mattermost' })).toBeNull()
|
|
| 51 |
+ }) |
|
| 52 |
+ |
|
| 53 |
+ it('자료실은 구현되어 링크로 열린다', () => {
|
|
| 54 |
+ const onSelect = vi.fn() |
|
| 55 |
+ render(<Sidebar onSelect={onSelect} />)
|
|
| 56 |
+ |
|
| 57 |
+ fireEvent.click(screen.getByRole('link', { name: '자료실' }))
|
|
| 58 |
+ |
|
| 59 |
+ expect(onSelect).toHaveBeenCalledWith('files')
|
|
| 50 | 60 |
}) |
| 51 | 61 |
|
| 52 | 62 |
it('Dashboard는 구현되어 링크로 열린다', () => {
|
... | ... | @@ -73,7 +83,7 @@ |
| 73 | 83 |
expect(screen.getByText('IH')).toBeTruthy()
|
| 74 | 84 |
|
| 75 | 85 |
expect(screen.getByRole('link', { name: '기관관리' })).toHaveAttribute('title', '기관관리')
|
| 76 |
- expect(screen.getByTitle('자료실 (준비 중)')).toBeTruthy()
|
|
| 86 |
+ expect(screen.getByTitle('Mattermost (준비 중)')).toBeTruthy()
|
|
| 77 | 87 |
}) |
| 78 | 88 |
|
| 79 | 89 |
it('접기 버튼을 누르면 onToggle이 호출된다', () => {
|
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
... | ... | @@ -89,8 +89,9 @@ |
| 89 | 89 |
}, |
| 90 | 90 |
{
|
| 91 | 91 |
key: 'files', |
| 92 |
+ // 요구사항 [26]: 공용 양식 보관소. 기관에 딸리지 않는 서식을 여기 올려 둔다. |
|
| 92 | 93 |
label: '자료실', |
| 93 |
- enabled: false, |
|
| 94 |
+ enabled: true, |
|
| 94 | 95 |
icon: ( |
| 95 | 96 |
<svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 96 | 97 |
<path d="M3 7h18v4H3zM5 11v9a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-9M10 15h4" /> |
--- frontend/src/components/SystemPage.test.tsx
+++ frontend/src/components/SystemPage.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import SystemPage from './SystemPage' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { ResetTarget } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -46,7 +47,7 @@ |
| 46 | 47 |
target(), |
| 47 | 48 |
target({ orgId: 2, orgNo: '002', orgName: '세종학당재단', channels: [{ kind: '문정원', channelId: 'c2' }] }),
|
| 48 | 49 |
]) |
| 49 |
- render(<SystemPage />) |
|
| 50 |
+ render(withCodes(<SystemPage />)) |
|
| 50 | 51 |
|
| 51 | 52 |
expect(await screen.findByText('대상 2개 기관 · 채널 3개')).toBeTruthy()
|
| 52 | 53 |
const rows = within(screen.getByTestId('reset-targets')).getAllByRole('row')
|
... | ... | @@ -55,7 +56,7 @@ |
| 55 | 56 |
}) |
| 56 | 57 |
|
| 57 | 58 |
it('확인 문구를 입력하기 전에는 실행 버튼이 잠겨 있다', async () => {
|
| 58 |
- render(<SystemPage />) |
|
| 59 |
+ render(withCodes(<SystemPage />)) |
|
| 59 | 60 |
await screen.findByTestId('reset-targets')
|
| 60 | 61 |
|
| 61 | 62 |
expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', true)
|
... | ... | @@ -68,7 +69,7 @@ |
| 68 | 69 |
}) |
| 69 | 70 |
|
| 70 | 71 |
it('실행 버튼을 눌러도 확인창에서 한 번 더 물어본다', async () => {
|
| 71 |
- render(<SystemPage />) |
|
| 72 |
+ render(withCodes(<SystemPage />)) |
|
| 72 | 73 |
await screen.findByTestId('reset-targets')
|
| 73 | 74 |
|
| 74 | 75 |
fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } })
|
... | ... | @@ -82,7 +83,7 @@ |
| 82 | 83 |
}) |
| 83 | 84 |
|
| 84 | 85 |
it('확인창에서 실행하면 문구와 함께 초기화를 요청하고 결과를 보여준다', async () => {
|
| 85 |
- render(<SystemPage />) |
|
| 86 |
+ render(withCodes(<SystemPage />)) |
|
| 86 | 87 |
await screen.findByTestId('reset-targets')
|
| 87 | 88 |
|
| 88 | 89 |
await typeConfirmAndRun() |
... | ... | @@ -99,7 +100,7 @@ |
| 99 | 100 |
clearedOrgs: 0, |
| 100 | 101 |
failures: ['001 국제방송교류재단 (법률검토): 권한 없음'], |
| 101 | 102 |
}) |
| 102 |
- render(<SystemPage />) |
|
| 103 |
+ render(withCodes(<SystemPage />)) |
|
| 103 | 104 |
await screen.findByTestId('reset-targets')
|
| 104 | 105 |
|
| 105 | 106 |
await typeConfirmAndRun() |
... | ... | @@ -110,7 +111,7 @@ |
| 110 | 111 |
|
| 111 | 112 |
it('대상이 없으면 실행할 수 없다', async () => {
|
| 112 | 113 |
mocks.getResetPreview.mockResolvedValue([]) |
| 113 |
- render(<SystemPage />) |
|
| 114 |
+ render(withCodes(<SystemPage />)) |
|
| 114 | 115 |
|
| 115 | 116 |
expect(await screen.findByText('초기화할 채널이 없습니다.')).toBeTruthy()
|
| 116 | 117 |
fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } })
|
... | ... | @@ -118,7 +119,7 @@ |
| 118 | 119 |
}) |
| 119 | 120 |
|
| 120 | 121 |
it('무엇이 남고 무엇이 사라지는지 화면에 적어 둔다', async () => {
|
| 121 |
- render(<SystemPage />) |
|
| 122 |
+ render(withCodes(<SystemPage />)) |
|
| 122 | 123 |
await screen.findByTestId('reset-targets')
|
| 123 | 124 |
|
| 124 | 125 |
expect(screen.getByText(/보관\(아카이브\)/)).toBeTruthy() |
--- frontend/src/components/SystemPage.tsx
+++ frontend/src/components/SystemPage.tsx
... | ... | @@ -6,7 +6,7 @@ |
| 6 | 6 |
type ResetReport, |
| 7 | 7 |
type ResetTarget, |
| 8 | 8 |
} from '../api/client' |
| 9 |
-import { stageLabel } from '../stages'
|
|
| 9 |
+import { useStages } from '../codes/stage'
|
|
| 10 | 10 |
import HelpButton from '../help/HelpButton' |
| 11 | 11 |
|
| 12 | 12 |
/** |
... | ... | @@ -28,6 +28,7 @@ |
| 28 | 28 |
} |
| 29 | 29 |
|
| 30 | 30 |
export default function SystemPage({ onChanged }: Props) {
|
| 31 |
+ const stages = useStages() |
|
| 31 | 32 |
const [targets, setTargets] = useState<ResetTarget[] | null>(null) |
| 32 | 33 |
const [error, setError] = useState<string | null>(null) |
| 33 | 34 |
const [confirm, setConfirm] = useState('')
|
... | ... | @@ -134,7 +135,7 @@ |
| 134 | 135 |
<td className="px-3 py-2 text-gray-500 tabular-nums">{t.orgNo}</td>
|
| 135 | 136 |
<td className="px-3 py-2 font-medium">{t.orgName}</td>
|
| 136 | 137 |
<td className="px-3 py-2 text-gray-600"> |
| 137 |
- {t.stage === null ? '-' : `${t.stage}. ${stageLabel(t.stage)}`}
|
|
| 138 |
+ {t.stage === null ? '-' : `${t.stage}. ${stages.label(t.stage)}`}
|
|
| 138 | 139 |
</td> |
| 139 | 140 |
<td className="px-3 py-2 text-gray-600"> |
| 140 | 141 |
{t.channels.map((c) => c.kind).join(' · ')}
|
--- frontend/src/components/Timeline.test.tsx
+++ frontend/src/components/Timeline.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import Timeline from './Timeline' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { Org, TimelineEvent } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -48,7 +49,7 @@ |
| 48 | 49 |
it('불러오는 동안 로딩 문구를 보여준다', () => {
|
| 49 | 50 |
mocks.getTimeline.mockReturnValue(new Promise(() => {}))
|
| 50 | 51 |
|
| 51 |
- render(<Timeline org={org()} />)
|
|
| 52 |
+ render(withCodes(<Timeline org={org()} />))
|
|
| 52 | 53 |
|
| 53 | 54 |
expect(screen.getByText('불러오는 중…')).toBeTruthy()
|
| 54 | 55 |
}) |
... | ... | @@ -56,7 +57,7 @@ |
| 56 | 57 |
it('기록이 없으면 빈 상태 문구를 보여준다', async () => {
|
| 57 | 58 |
mocks.getTimeline.mockResolvedValue([]) |
| 58 | 59 |
|
| 59 |
- render(<Timeline org={org()} />)
|
|
| 60 |
+ render(withCodes(<Timeline org={org()} />))
|
|
| 60 | 61 |
|
| 61 | 62 |
await waitFor(() => |
| 62 | 63 |
expect( |
... | ... | @@ -76,7 +77,7 @@ |
| 76 | 77 |
event({ type: 'STAGE', at: day1, title: '4', detail: null }),
|
| 77 | 78 |
]) |
| 78 | 79 |
|
| 79 |
- render(<Timeline org={org()} />)
|
|
| 80 |
+ render(withCodes(<Timeline org={org()} />))
|
|
| 80 | 81 |
|
| 81 | 82 |
await waitFor(() => expect(screen.getByText('보고서.pdf')).toBeTruthy())
|
| 82 | 83 |
|
... | ... | @@ -99,7 +100,7 @@ |
| 99 | 100 |
it('새로고침 버튼을 누르면 다시 조회한다', async () => {
|
| 100 | 101 |
mocks.getTimeline.mockResolvedValue([]) |
| 101 | 102 |
|
| 102 |
- render(<Timeline org={org()} />)
|
|
| 103 |
+ render(withCodes(<Timeline org={org()} />))
|
|
| 103 | 104 |
|
| 104 | 105 |
await waitFor(() => expect(mocks.getTimeline).toHaveBeenCalledTimes(1)) |
| 105 | 106 |
|
... | ... | @@ -111,7 +112,7 @@ |
| 111 | 112 |
it('조회가 실패하면 오류 메시지를 보여준다', async () => {
|
| 112 | 113 |
mocks.getTimeline.mockRejectedValue(new Error('타임라인을 불러오지 못했습니다.'))
|
| 113 | 114 |
|
| 114 |
- render(<Timeline org={org()} />)
|
|
| 115 |
+ render(withCodes(<Timeline org={org()} />))
|
|
| 115 | 116 |
|
| 116 | 117 |
await waitFor(() => expect(screen.getByText('타임라인을 불러오지 못했습니다.')).toBeTruthy())
|
| 117 | 118 |
}) |
--- frontend/src/components/Timeline.tsx
+++ frontend/src/components/Timeline.tsx
... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 |
import { useEffect, useState } from 'react'
|
| 2 | 2 |
import { getTimeline, type Org, type TimelineEvent } from '../api/client'
|
| 3 |
-import { stageLabel } from '../stages'
|
|
| 3 |
+import { useStages, type Stages } from '../codes/stage'
|
|
| 4 | 4 |
|
| 5 | 5 |
const dateFormatter = new Intl.DateTimeFormat('ko-KR', {
|
| 6 | 6 |
year: 'numeric', |
... | ... | @@ -62,17 +62,18 @@ |
| 62 | 62 |
} |
| 63 | 63 |
|
| 64 | 64 |
/** STAGE 이벤트의 title(단계 번호 문자열)을 "5. 법률검토(권리확인) 진행" 형태로 바꾼다. */ |
| 65 |
-function stageEventText(title: string): string {
|
|
| 65 |
+function stageEventText(title: string, stages: Stages): string {
|
|
| 66 | 66 |
const n = Number(title) |
| 67 | 67 |
if (!Number.isInteger(n)) {
|
| 68 | 68 |
return `${title} 단계로 진행`
|
| 69 | 69 |
} |
| 70 |
- return `${n}. ${stageLabel(n)} 단계로 진행`
|
|
| 70 |
+ return `${n}. ${stages.label(n)} 단계로 진행`
|
|
| 71 | 71 |
} |
| 72 | 72 |
|
| 73 | 73 |
function EventBody({ event }: { event: TimelineEvent }) {
|
| 74 |
+ const stages = useStages() |
|
| 74 | 75 |
if (event.type === 'STAGE') {
|
| 75 |
- return <p className="text-sm font-medium text-gray-900">{stageEventText(event.title)}</p>
|
|
| 76 |
+ return <p className="text-sm font-medium text-gray-900">{stageEventText(event.title, stages)}</p>
|
|
| 76 | 77 |
} |
| 77 | 78 |
if (event.type === 'FILE') {
|
| 78 | 79 |
return ( |
--- frontend/src/help/helpContent.ts
+++ frontend/src/help/helpContent.ts
... | ... | @@ -41,7 +41,7 @@ |
| 41 | 41 |
'서류제출 — ②목록접수 단계 이상에 도달한 기관 수', |
| 42 | 42 |
'권리확인 / 권리처리 — 전 기관 건수 합계(완료 / 전체)와 진척률', |
| 43 | 43 |
'보고서 작성 — ⑫보고서 작성 단계에 도달한 기관 수', |
| 44 |
- '완료 — ⑪법률검토 최종완료 이상에 도달한 기관 수', |
|
| 44 |
+ '완료 — ⑬보고서 제출 완료에 도달한 기관 수(최종 보고서가 올라온 기관만 센다)', |
|
| 45 | 45 |
], |
| 46 | 46 |
cautions: [ |
| 47 | 47 |
'미해결 RE는 아직 데이터가 없어 항상 0으로 표시된다(자리만 잡아 둔 상태).', |
... | ... | @@ -56,7 +56,7 @@ |
| 56 | 56 |
{
|
| 57 | 57 |
src: '/help/dashboard-kanban.png', |
| 58 | 58 |
alt: '단계 미지정 컬럼과 ①신청~ 컬럼들이 있는 칸반', |
| 59 |
- caption: '컬럼은 단계 미지정 + ①~⑫ 순서다. 오른쪽으로 스크롤하면 나머지 단계가 나온다', |
|
| 59 |
+ caption: '컬럼은 단계 미지정 + ①~⑬ 순서다. 오른쪽으로 스크롤하면 나머지 단계가 나온다', |
|
| 60 | 60 |
}, |
| 61 | 61 |
{
|
| 62 | 62 |
src: '/help/dashboard-drag-warning.png', |
... | ... | @@ -160,7 +160,7 @@ |
| 160 | 160 |
|
| 161 | 161 |
'org.stage': {
|
| 162 | 162 |
title: '진행단계 변경', |
| 163 |
- summary: '12단계 업무 진행 상태를 사람이 직접 바꾼다. 자동으로 넘어가는 일은 없다.', |
|
| 163 |
+ summary: '13단계 업무 진행 상태를 사람이 직접 바꾼다. 자동으로 넘어가는 일은 없다.', |
|
| 164 | 164 |
images: [ |
| 165 | 165 |
{
|
| 166 | 166 |
src: '/help/org-stage.png', |
--- frontend/src/stages.ts
... | ... | @@ -1,42 +0,0 @@ |
| 1 | -// 진행단계 12개 라벨. 백엔드는 숫자(1..12)만 저장하고 라벨은 여기서만 관리한다 - | |
| 2 | -// OrgOverview(업무단계 현황 스트립·진행단계 변경 모달)와 Timeline(단계 변경 이벤트)이 | |
| 3 | -// 같은 배열을 가져다 쓴다. 인덱스 0이 1단계다. | |
| 4 | -export const STAGE_LABELS: string[] = [ | |
| 5 | - '신청', | |
| 6 | - '목록접수', | |
| 7 | - '예비검토', | |
| 8 | - '변호사 배당', | |
| 9 | - '법률검토(권리확인)', | |
| 10 | - 'RE:확인', | |
| 11 | - '법률검토(권리확인) 완료', | |
| 12 | - '법률검토(권리처리)', | |
| 13 | - 'RE:처리', | |
| 14 | - '법률검토(권리처리) 완료', | |
| 15 | - '법률검토 최종완료', | |
| 16 | - '보고서 작성', | |
| 17 | -] | |
| 18 | - | |
| 19 | -/** 1..12 단계 번호를 라벨로 바꾼다. 범위 밖이면 번호를 그대로 문자열로 돌려준다. */ | |
| 20 | -export function stageLabel(stage: number): string { | |
| 21 | - return STAGE_LABELS[stage - 1] ?? String(stage) | |
| 22 | -} | |
| 23 | - | |
| 24 | -/** | |
| 25 | - * 12단계를 업무 성격으로 묶은 구간. 대시보드의 "업무 구간" 필터와 칸반 카드 모양 | |
| 26 | - * (권리확인 구간은 확인 진척 막대 1줄, 권리처리 구간은 확인+처리 2줄)이 이 묶음을 쓴다. | |
| 27 | - */ | |
| 28 | -export const STAGE_GROUPS: { key: string; label: string; stages: number[] }[] = [ | |
| 29 | - { key: 'intake', label: '접수', stages: [1, 2, 3] }, | |
| 30 | - { key: 'assign', label: '배당', stages: [4] }, | |
| 31 | - { key: 'check', label: '권리확인', stages: [5, 6, 7] }, | |
| 32 | - { key: 'process', label: '권리처리', stages: [8, 9, 10] }, | |
| 33 | - { key: 'closing', label: '마감', stages: [11, 12] }, | |
| 34 | -] | |
| 35 | - | |
| 36 | -/** 단계 번호가 속한 구간 key. 범위 밖이거나 null이면 null. */ | |
| 37 | -export function stageGroupKey(stage: number | null): string | null { | |
| 38 | - if (stage === null) { | |
| 39 | - return null | |
| 40 | - } | |
| 41 | - return STAGE_GROUPS.find((group) => group.stages.includes(stage))?.key ?? null | |
| 42 | -} |
+++ src/main/java/kr/itn/itnhub/code/Code.java
... | ... | @@ -0,0 +1,24 @@ |
| 1 | +package kr.itn.itnhub.code; | |
| 2 | + | |
| 3 | +import java.util.Map; | |
| 4 | + | |
| 5 | +/** attrs까지 파싱된 코드. 화면과 서비스가 쓰는 형태다. */ | |
| 6 | +public record Code( | |
| 7 | + String groupId, | |
| 8 | + String code, | |
| 9 | + String label, | |
| 10 | + int sortOrder, | |
| 11 | + boolean active, | |
| 12 | + Map<String, Object> attrs) { | |
| 13 | + | |
| 14 | + /** attrs의 문자열 속성. 없으면 null. */ | |
| 15 | + public String attr(String key) { | |
| 16 | + Object value = attrs.get(key); | |
| 17 | + return value == null ? null : String.valueOf(value); | |
| 18 | + } | |
| 19 | + | |
| 20 | + /** attrs의 불리언 속성. 없으면 false. */ | |
| 21 | + public boolean flag(String key) { | |
| 22 | + return Boolean.TRUE.equals(attrs.get(key)); | |
| 23 | + } | |
| 24 | +} |
+++ src/main/java/kr/itn/itnhub/code/CodeController.java
... | ... | @@ -0,0 +1,26 @@ |
| 1 | +package kr.itn.itnhub.code; | |
| 2 | + | |
| 3 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 4 | +import org.springframework.web.bind.annotation.RestController; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | +import java.util.Map; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 프론트가 로그인 직후 한 번 불러 모든 선택지를 받아가는 엔드포인트. 화면마다 따로 부르지 | |
| 11 | + * 않는 이유는 코드가 자주 안 바뀌고, 화면이 옵션을 기다리느라 깜빡이는 것을 피하기 위해서다. | |
| 12 | + */ | |
| 13 | +@RestController | |
| 14 | +public class CodeController { | |
| 15 | + | |
| 16 | + private final CodeService codeService; | |
| 17 | + | |
| 18 | + public CodeController(CodeService codeService) { | |
| 19 | + this.codeService = codeService; | |
| 20 | + } | |
| 21 | + | |
| 22 | + @GetMapping("/api/meta/codes") | |
| 23 | + public Map<String, List<Code>> codes() { | |
| 24 | + return codeService.all(); | |
| 25 | + } | |
| 26 | +} |
+++ src/main/java/kr/itn/itnhub/code/CodeGroup.java
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +package kr.itn.itnhub.code; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 코드 그룹. {@code editable}이 false면 관리 화면에서 코드를 추가·삭제할 수 없다 - | |
| 5 | + * 진행단계처럼 로직과 강하게 묶인 그룹은 표시명·순서만 바꾸게 한다. | |
| 6 | + */ | |
| 7 | +public record CodeGroup( | |
| 8 | + String groupId, | |
| 9 | + String groupName, | |
| 10 | + String description, | |
| 11 | + boolean editable) { | |
| 12 | +} |
+++ src/main/java/kr/itn/itnhub/code/CodeMapper.java
... | ... | @@ -0,0 +1,14 @@ |
| 1 | +package kr.itn.itnhub.code; | |
| 2 | + | |
| 3 | +import org.apache.ibatis.annotations.Mapper; | |
| 4 | + | |
| 5 | +import java.util.List; | |
| 6 | + | |
| 7 | +@Mapper | |
| 8 | +public interface CodeMapper { | |
| 9 | + | |
| 10 | + /** 비활성 코드까지 전부. 활성 필터는 {@link CodeService}가 용도에 따라 건다. */ | |
| 11 | + List<CodeRow> findAllCodes(); | |
| 12 | + | |
| 13 | + List<CodeGroup> findAllGroups(); | |
| 14 | +} |
+++ src/main/java/kr/itn/itnhub/code/CodeRow.java
... | ... | @@ -0,0 +1,15 @@ |
| 1 | +package kr.itn.itnhub.code; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * DB에서 그대로 읽은 코드 한 줄. attrs는 jsonb를 문자열로 받는다 - 커스텀 TypeHandler를 | |
| 5 | + * 만들면 매퍼마다 등록을 신경써야 하는데, 코드는 기동 후 한 번만 읽으므로 | |
| 6 | + * {@link CodeService}가 그때 한 번 파싱하는 편이 단순하다. | |
| 7 | + */ | |
| 8 | +public record CodeRow( | |
| 9 | + String groupId, | |
| 10 | + String code, | |
| 11 | + String label, | |
| 12 | + int sortOrder, | |
| 13 | + boolean active, | |
| 14 | + String attrsJson) { | |
| 15 | +} |
+++ src/main/java/kr/itn/itnhub/code/CodeService.java
... | ... | @@ -0,0 +1,139 @@ |
| 1 | +package kr.itn.itnhub.code; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.core.type.TypeReference; | |
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 5 | +import org.slf4j.Logger; | |
| 6 | +import org.slf4j.LoggerFactory; | |
| 7 | +import org.springframework.stereotype.Service; | |
| 8 | + | |
| 9 | +import java.util.ArrayList; | |
| 10 | +import java.util.Collections; | |
| 11 | +import java.util.LinkedHashMap; | |
| 12 | +import java.util.List; | |
| 13 | +import java.util.Map; | |
| 14 | +import java.util.Optional; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * 코드값을 기동 후 첫 조회에 한 번 읽어 메모리에 들고 있는다. 코드는 관리 화면에서 가끔 | |
| 18 | + * 바뀔 뿐이라 매 요청 조회가 낭비이고, 단일 인스턴스 운영이라 분산 캐시는 필요 없다. | |
| 19 | + * | |
| 20 | + * <p>지연 로딩을 쓰는 이유는 Flyway 마이그레이션이 끝나기 전에 빈 초기화가 돌 수 있기 | |
| 21 | + * 때문이다. 첫 조회 시점에는 마이그레이션이 반드시 끝나 있다.</p> | |
| 22 | + */ | |
| 23 | +@Service | |
| 24 | +public class CodeService { | |
| 25 | + | |
| 26 | + private static final Logger log = LoggerFactory.getLogger(CodeService.class); | |
| 27 | + | |
| 28 | + private final CodeMapper mapper; | |
| 29 | + private final ObjectMapper json = new ObjectMapper(); | |
| 30 | + | |
| 31 | + /** 그룹ID -> 활성 코드(정렬순). 화면 선택지가 여기서 나간다. */ | |
| 32 | + private volatile Map<String, List<Code>> activeByGroup; | |
| 33 | + /** 그룹ID -> 코드ID -> 코드(비활성 포함). 과거 데이터를 화면에 그릴 때 라벨이 필요하다. */ | |
| 34 | + private volatile Map<String, Map<String, Code>> allByGroup; | |
| 35 | + | |
| 36 | + public CodeService(CodeMapper mapper) { | |
| 37 | + this.mapper = mapper; | |
| 38 | + } | |
| 39 | + | |
| 40 | + /** 관리 화면에서 코드를 고친 뒤 부른다. */ | |
| 41 | + public synchronized void reload() { | |
| 42 | + Map<String, List<Code>> active = new LinkedHashMap<>(); | |
| 43 | + Map<String, Map<String, Code>> all = new LinkedHashMap<>(); | |
| 44 | + | |
| 45 | + for (CodeRow row : mapper.findAllCodes()) { | |
| 46 | + Code code = toCode(row); | |
| 47 | + all.computeIfAbsent(code.groupId(), key -> new LinkedHashMap<>()) | |
| 48 | + .put(code.code(), code); | |
| 49 | + if (code.active()) { | |
| 50 | + active.computeIfAbsent(code.groupId(), key -> new ArrayList<>()).add(code); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + this.activeByGroup = active; | |
| 55 | + this.allByGroup = all; | |
| 56 | + } | |
| 57 | + | |
| 58 | + private Code toCode(CodeRow row) { | |
| 59 | + Map<String, Object> attrs; | |
| 60 | + try { | |
| 61 | + attrs = json.readValue(row.attrsJson(), new TypeReference<>() { | |
| 62 | + }); | |
| 63 | + } catch (Exception e) { | |
| 64 | + // 잘못된 JSON 하나가 전체 코드 로딩을 막지는 않게 한다. 해당 코드만 속성이 빈다. | |
| 65 | + log.warn("코드 attrs 파싱 실패 - {}:{} 의 속성을 비운다", row.groupId(), row.code(), e); | |
| 66 | + attrs = Map.of(); | |
| 67 | + } | |
| 68 | + return new Code(row.groupId(), row.code(), row.label(), row.sortOrder(), row.active(), attrs); | |
| 69 | + } | |
| 70 | + | |
| 71 | + private void ensureLoaded() { | |
| 72 | + if (activeByGroup == null) { | |
| 73 | + reload(); | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + /** 활성 코드만, 그룹별 정렬순. */ | |
| 78 | + public Map<String, List<Code>> all() { | |
| 79 | + ensureLoaded(); | |
| 80 | + return activeByGroup; | |
| 81 | + } | |
| 82 | + | |
| 83 | + /** 활성 코드만. 없는 그룹이면 빈 목록. */ | |
| 84 | + public List<Code> group(String groupId) { | |
| 85 | + ensureLoaded(); | |
| 86 | + return activeByGroup.getOrDefault(groupId, List.of()); | |
| 87 | + } | |
| 88 | + | |
| 89 | + /** 비활성 코드도 찾는다 - 과거 데이터의 라벨을 그려야 하기 때문이다. */ | |
| 90 | + public Optional<Code> find(String groupId, String code) { | |
| 91 | + ensureLoaded(); | |
| 92 | + return Optional.ofNullable(allByGroup.getOrDefault(groupId, Map.of()).get(code)); | |
| 93 | + } | |
| 94 | + | |
| 95 | + /** attrs.scopes에 해당 자리가 든 활성 코드만. 공공누리 유형이 이걸 쓴다. */ | |
| 96 | + public List<Code> scoped(String groupId, String scope) { | |
| 97 | + return group(groupId).stream().filter(code -> scopes(code).contains(scope)).toList(); | |
| 98 | + } | |
| 99 | + | |
| 100 | + @SuppressWarnings("unchecked") | |
| 101 | + private List<String> scopes(Code code) { | |
| 102 | + Object raw = code.attrs().get(Codes.ATTR_SCOPES); | |
| 103 | + return raw instanceof List<?> list ? (List<String>) list : Collections.emptyList(); | |
| 104 | + } | |
| 105 | + | |
| 106 | + /** 저장 요청 값이 이 그룹의 활성 코드인지. */ | |
| 107 | + public boolean isValid(String groupId, String code) { | |
| 108 | + return group(groupId).stream().anyMatch(candidate -> candidate.code().equals(code)); | |
| 109 | + } | |
| 110 | + | |
| 111 | + /** | |
| 112 | + * 옛 표기를 표준 표기로 바꿔 준다. | |
| 113 | + * | |
| 114 | + * <p>코드에 {@code sameAs}가 붙어 있으면 그 값으로 옮긴다. 예를 들어 기관 조사표에 | |
| 115 | + * 「제4유형」으로 적혀 오는 값은 「4유형」으로 통일한다 - 같은 유형인데 표기가 갈리면 | |
| 116 | + * 유형별로 세거나 걸러낼 때 따로 잡히기 때문이다.</p> | |
| 117 | + * | |
| 118 | + * <p>비활성 코드도 본다. 선택지에서는 빠졌어도 옛 표기가 담긴 파일이 다시 올라올 수 | |
| 119 | + * 있고, 그때 바꿔 넣어야 하기 때문이다.</p> | |
| 120 | + * | |
| 121 | + * @return 표준 표기. 아는 별칭이 아니면 받은 값을 그대로 돌려준다. | |
| 122 | + */ | |
| 123 | + public String canonical(String groupId, String code) { | |
| 124 | + if (code == null || code.isBlank()) { | |
| 125 | + return code; | |
| 126 | + } | |
| 127 | + return find(groupId, code) | |
| 128 | + .map(found -> found.attr(ATTR_SAME_AS)) | |
| 129 | + .filter(target -> target != null && !target.isBlank()) | |
| 130 | + .orElse(code); | |
| 131 | + } | |
| 132 | + | |
| 133 | + private static final String ATTR_SAME_AS = "sameAs"; | |
| 134 | + | |
| 135 | + /** 코드 관리 화면이 쓰는 그룹 메타. 캐시하지 않는다 - 호출 빈도가 낮다. */ | |
| 136 | + public List<CodeGroup> groups() { | |
| 137 | + return mapper.findAllGroups(); | |
| 138 | + } | |
| 139 | +} |
+++ src/main/java/kr/itn/itnhub/code/Codes.java
... | ... | @@ -0,0 +1,39 @@ |
| 1 | +package kr.itn.itnhub.code; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 코드 그룹 ID와, 로직이 직접 참조해야 하는 attrs 키·scope 상수. 소스에 한글 리터럴을 | |
| 5 | + * 흩뿌리지 않기 위한 단일 지점이다. | |
| 6 | + * | |
| 7 | + * <p>화면에 그리는 선택지 자체는 여기가 아니라 DB(code 테이블)에서 온다. 여기 있는 것은 | |
| 8 | + * "코드체계를 가리키는 이름"뿐이고 "코드값 목록"이 아니다.</p> | |
| 9 | + */ | |
| 10 | +public final class Codes { | |
| 11 | + | |
| 12 | + private Codes() { | |
| 13 | + } | |
| 14 | + | |
| 15 | + public static final String STAGE = "STAGE"; | |
| 16 | + public static final String STAGE_GROUP = "STAGE_GROUP"; | |
| 17 | + public static final String REVIEW_MAJOR = "REVIEW_MAJOR"; | |
| 18 | + public static final String REVIEW_MINOR = "REVIEW_MINOR"; | |
| 19 | + public static final String REVIEW_RESULT = "REVIEW_RESULT"; | |
| 20 | + public static final String KOGL_TYPE = "KOGL_TYPE"; | |
| 21 | + public static final String PROCESS_STATUS = "PROCESS_STATUS"; | |
| 22 | + public static final String CONTRACT_DOC = "CONTRACT_DOC"; | |
| 23 | + public static final String CONTACT_METHOD = "CONTACT_METHOD"; | |
| 24 | + public static final String CONTACT_CATEGORY = "CONTACT_CATEGORY"; | |
| 25 | + public static final String ATTACHMENT_YN = "ATTACHMENT_YN"; | |
| 26 | + public static final String KOGL_ATTACHED = "KOGL_ATTACHED"; | |
| 27 | + | |
| 28 | + /** 공공누리 유형이 쓰이는 자리. 같은 코드체계라도 자리마다 노출 목록이 다르다. */ | |
| 29 | + public static final String SCOPE_REVIEW = "review"; | |
| 30 | + public static final String SCOPE_PROCESS = "process"; | |
| 31 | + public static final String SCOPE_PRIOR = "prior"; | |
| 32 | + | |
| 33 | + /** attrs 키. */ | |
| 34 | + public static final String ATTR_SCOPES = "scopes"; | |
| 35 | + public static final String ATTR_PROGRESS = "progress"; | |
| 36 | + public static final String ATTR_KPI_FROM = "kpiFrom"; | |
| 37 | + public static final String ATTR_GROUP_KEY = "groupKey"; | |
| 38 | + public static final String ATTR_DONE = "done"; | |
| 39 | +} |
--- src/main/java/kr/itn/itnhub/config/AdminProperties.java
+++ src/main/java/kr/itn/itnhub/config/AdminProperties.java
... | ... | @@ -2,13 +2,23 @@ |
| 2 | 2 |
|
| 3 | 3 |
import org.springframework.boot.context.properties.ConfigurationProperties; |
| 4 | 4 |
|
| 5 |
+/** |
|
| 6 |
+ * @param displayName 업무메모·연락이력·보고서의 작성자로 찍히는 이름(요구사항 [22]). |
|
| 7 |
+ * 예전에는 로그인 아이디(admin)가 그대로 찍혔다. 비워 두면 아이디를 쓴다. |
|
| 8 |
+ */ |
|
| 5 | 9 |
@ConfigurationProperties(prefix = "app.admin") |
| 6 |
-public record AdminProperties(String username, String password) {
|
|
| 10 |
+public record AdminProperties(String username, String password, String displayName) {
|
|
| 11 |
+ |
|
| 12 |
+ /** 화면에 보일 이름. 설정이 없으면 로그인 아이디로 떨어진다. */ |
|
| 13 |
+ public String displayNameOrUsername() {
|
|
| 14 |
+ return (displayName == null || displayName.isBlank()) ? username : displayName; |
|
| 15 |
+ } |
|
| 7 | 16 |
|
| 8 | 17 |
// record 기본 toString()은 password를 그대로 노출한다. 로그에 실수로 찍혀도 |
| 9 | 18 |
// 원문이 남지 않도록 마스킹한다. |
| 10 | 19 |
@Override |
| 11 | 20 |
public String toString() {
|
| 12 |
- return "AdminProperties[username=%s, password=****]".formatted(username); |
|
| 21 |
+ return "AdminProperties[username=%s, password=****, displayName=%s]" |
|
| 22 |
+ .formatted(username, displayName); |
|
| 13 | 23 |
} |
| 14 | 24 |
} |
+++ src/main/java/kr/itn/itnhub/config/CurrentUser.java
... | ... | @@ -0,0 +1,35 @@ |
| 1 | +package kr.itn.itnhub.config; | |
| 2 | + | |
| 3 | +import org.springframework.stereotype.Component; | |
| 4 | + | |
| 5 | +import java.security.Principal; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 기록에 남길 "작성자 이름"을 한 곳에서 정한다(요구사항 [22]). | |
| 9 | + * | |
| 10 | + * <p>예전에는 각 컨트롤러가 {@code principal.getName()}을 그대로 써서 업무메모·연락이력· | |
| 11 | + * 보고서에 로그인 아이디(admin)가 찍혔다. 지금은 설정에 넣어둔 표시 이름을 쓴다.</p> | |
| 12 | + * | |
| 13 | + * <p>나중에 담당자별 계정이 생기면 여기만 고쳐서 각자의 이름이 찍히게 하면 된다.</p> | |
| 14 | + */ | |
| 15 | +@Component | |
| 16 | +public class CurrentUser { | |
| 17 | + | |
| 18 | + private final AdminProperties admin; | |
| 19 | + | |
| 20 | + public CurrentUser(AdminProperties admin) { | |
| 21 | + this.admin = admin; | |
| 22 | + } | |
| 23 | + | |
| 24 | + /** 기록에 남길 이름. 로그인한 사람이 관리자면 설정된 표시 이름을 쓴다. */ | |
| 25 | + public String displayName(Principal principal) { | |
| 26 | + if (principal == null) { | |
| 27 | + return admin.displayNameOrUsername(); | |
| 28 | + } | |
| 29 | + if (principal.getName().equals(admin.username())) { | |
| 30 | + return admin.displayNameOrUsername(); | |
| 31 | + } | |
| 32 | + // 관리자 외 계정이 생기면 그 계정의 아이디를 그대로 쓴다. | |
| 33 | + return principal.getName(); | |
| 34 | + } | |
| 35 | +} |
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 |
import kr.itn.itnhub.org.OrgNotFoundException; |
| 8 | 8 |
import kr.itn.itnhub.process.InvalidProcessStatusException; |
| 9 | 9 |
import kr.itn.itnhub.process.ProcessNotFoundException; |
| 10 |
+import kr.itn.itnhub.re.ReMemoNotFoundException; |
|
| 10 | 11 |
import kr.itn.itnhub.review.ReviewNotFoundException; |
| 11 | 12 |
import kr.itn.itnhub.seed.SeedParseException; |
| 12 | 13 |
import kr.itn.itnhub.stage.InvalidStageException; |
... | ... | @@ -85,6 +86,11 @@ |
| 85 | 86 |
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage())); |
| 86 | 87 |
} |
| 87 | 88 |
|
| 89 |
+ @ExceptionHandler(ReMemoNotFoundException.class) |
|
| 90 |
+ public ResponseEntity<ApiError> handleReMemoNotFound(ReMemoNotFoundException e) {
|
|
| 91 |
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage())); |
|
| 92 |
+ } |
|
| 93 |
+ |
|
| 88 | 94 |
@ExceptionHandler(MethodArgumentNotValidException.class) |
| 89 | 95 |
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException e) {
|
| 90 | 96 |
String fields = e.getBindingResult().getFieldErrors().stream() |
--- src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
... | ... | @@ -1,12 +1,18 @@ |
| 1 | 1 |
package kr.itn.itnhub.dashboard; |
| 2 | 2 |
|
| 3 | 3 |
import org.apache.ibatis.annotations.Mapper; |
| 4 |
+import org.apache.ibatis.annotations.Param; |
|
| 4 | 5 |
|
| 5 | 6 |
import java.util.List; |
| 6 | 7 |
|
| 7 | 8 |
@Mapper |
| 8 | 9 |
public interface DashboardMapper {
|
| 9 | 10 |
|
| 10 |
- /** 기관 전체를 연번순으로, 권리확인/권리처리 집계와 단계·최근변동 시각까지 붙여 한 번에 읽는다. */ |
|
| 11 |
- List<DashboardOrgRow> findOrgRows(); |
|
| 11 |
+ /** |
|
| 12 |
+ * 기관 전체를 연번순으로, 권리확인/권리처리 집계와 단계·최근변동 시각까지 붙여 한 번에 읽는다. |
|
| 13 |
+ * |
|
| 14 |
+ * @param doneStatus 권리처리 완료로 셀 상태값. 코드표에서 와야 하는데 SQL이 코드표를 |
|
| 15 |
+ * 참조할 방법이 없어 서비스가 읽어 넘긴다. |
|
| 16 |
+ */ |
|
| 17 |
+ List<DashboardOrgRow> findOrgRows(@Param("doneStatus") String doneStatus);
|
|
| 12 | 18 |
} |
--- src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
package kr.itn.itnhub.dashboard; |
| 2 | 2 |
|
| 3 |
+import kr.itn.itnhub.code.CodeService; |
|
| 3 | 4 |
import org.springframework.stereotype.Service; |
| 4 | 5 |
|
| 5 | 6 |
import java.util.List; |
... | ... | @@ -8,29 +9,60 @@ |
| 8 | 9 |
* 대시보드 화면 하나가 쓰는 서비스. 집계 쿼리는 {@link DashboardMapper#findOrgRows()} 한 번이고,
|
| 9 | 10 |
* 상단 KPI는 그 결과를 Java에서 접어 만든다 - 같은 원본에서 계산해야 타일 숫자와 아래 표/칸반 |
| 10 | 11 |
* 숫자가 어긋나지 않는다(쿼리를 따로 두면 두 결과 사이에 시차가 생긴다). |
| 12 |
+ * |
|
| 13 |
+ * <p>어느 단계부터 각 KPI에 세는지는 코드표(STAGE의 kpiFrom 속성)에서 온다. 예전에는 여기 |
|
| 14 |
+ * 상수 세 개로 박혀 있어서, 기준이 바뀔 때마다 배포가 필요했다.</p> |
|
| 11 | 15 |
*/ |
| 12 | 16 |
@Service |
| 13 | 17 |
public class DashboardService {
|
| 14 | 18 |
|
| 15 |
- /** ②목록접수. 시안 KPI "서류제출"의 기준으로 쓴다. */ |
|
| 16 |
- private static final int STAGE_DOC_SUBMITTED = 2; |
|
| 17 |
- /** ⑪법률검토 최종완료. 시안 KPI "완료"의 기준. */ |
|
| 18 |
- private static final int STAGE_COMPLETED = 11; |
|
| 19 |
- /** ⑫보고서 작성. */ |
|
| 20 |
- private static final int STAGE_REPORT = 12; |
|
| 19 |
+ /** KPI 이름. 코드표의 kpiFrom 값과 맞물린다. */ |
|
| 20 |
+ static final String KPI_DOC_SUBMITTED = "docSubmitted"; |
|
| 21 |
+ static final String KPI_COMPLETED = "completed"; |
|
| 22 |
+ static final String KPI_REPORT = "reportWriting"; |
|
| 21 | 23 |
|
| 22 | 24 |
private final DashboardMapper mapper; |
| 25 |
+ private final CodeService codeService; |
|
| 23 | 26 |
|
| 24 |
- public DashboardService(DashboardMapper mapper) {
|
|
| 27 |
+ public DashboardService(DashboardMapper mapper, CodeService codeService) {
|
|
| 25 | 28 |
this.mapper = mapper; |
| 29 |
+ this.codeService = codeService; |
|
| 30 |
+ } |
|
| 31 |
+ |
|
| 32 |
+ /** |
|
| 33 |
+ * 이 KPI에 집계하기 시작하는 단계 번호. 코드표에 지정이 없으면 아무 기관도 세지 않도록 |
|
| 34 |
+ * {@link Integer#MAX_VALUE}를 준다 - 0을 주면 단계가 있는 모든 기관이 잘못 집계된다.
|
|
| 35 |
+ */ |
|
| 36 |
+ private int thresholdOf(String kpi) {
|
|
| 37 |
+ return codeService.group(kr.itn.itnhub.code.Codes.STAGE).stream() |
|
| 38 |
+ .filter(code -> kpi.equals(code.attr(kr.itn.itnhub.code.Codes.ATTR_KPI_FROM))) |
|
| 39 |
+ .mapToInt(code -> Integer.parseInt(code.code())) |
|
| 40 |
+ .min() |
|
| 41 |
+ .orElse(Integer.MAX_VALUE); |
|
| 26 | 42 |
} |
| 27 | 43 |
|
| 28 | 44 |
public DashboardResponse dashboard() {
|
| 29 |
- List<DashboardOrgRow> rows = mapper.findOrgRows(); |
|
| 45 |
+ List<DashboardOrgRow> rows = mapper.findOrgRows(doneStatus()); |
|
| 30 | 46 |
return new DashboardResponse(summarize(rows), rows); |
| 31 | 47 |
} |
| 32 | 48 |
|
| 49 |
+ /** |
|
| 50 |
+ * 권리처리 완료로 셀 상태값. 지정이 없으면 어떤 행도 완료로 세지 않도록 매칭되지 않는 |
|
| 51 |
+ * 값을 준다 - 빈 문자열을 주면 처리상태가 비어 있는 행이 전부 완료로 잡힌다. |
|
| 52 |
+ */ |
|
| 53 |
+ private String doneStatus() {
|
|
| 54 |
+ return codeService.group(kr.itn.itnhub.code.Codes.PROCESS_STATUS).stream() |
|
| 55 |
+ .filter(code -> code.flag(kr.itn.itnhub.code.Codes.ATTR_DONE)) |
|
| 56 |
+ .map(kr.itn.itnhub.code.Code::code) |
|
| 57 |
+ .findFirst() |
|
| 58 |
+ .orElse(" ");
|
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 33 | 61 |
DashboardSummary summarize(List<DashboardOrgRow> rows) {
|
| 62 |
+ int docSubmittedFrom = thresholdOf(KPI_DOC_SUBMITTED); |
|
| 63 |
+ int completedFrom = thresholdOf(KPI_COMPLETED); |
|
| 64 |
+ int reportFrom = thresholdOf(KPI_REPORT); |
|
| 65 |
+ |
|
| 34 | 66 |
int docSubmitted = 0; |
| 35 | 67 |
int reviewTotal = 0; |
| 36 | 68 |
int reviewDone = 0; |
... | ... | @@ -51,13 +83,13 @@ |
| 51 | 83 |
if (stage == null) {
|
| 52 | 84 |
continue; |
| 53 | 85 |
} |
| 54 |
- if (stage >= STAGE_DOC_SUBMITTED) {
|
|
| 86 |
+ if (stage >= docSubmittedFrom) {
|
|
| 55 | 87 |
docSubmitted++; |
| 56 | 88 |
} |
| 57 |
- if (stage >= STAGE_COMPLETED) {
|
|
| 89 |
+ if (stage >= completedFrom) {
|
|
| 58 | 90 |
completed++; |
| 59 | 91 |
} |
| 60 |
- if (stage >= STAGE_REPORT) {
|
|
| 92 |
+ if (stage >= reportFrom) {
|
|
| 61 | 93 |
reportWriting++; |
| 62 | 94 |
} |
| 63 | 95 |
} |
--- src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
... | ... | @@ -35,12 +35,15 @@ |
| 35 | 35 |
private final OrganizationMapper orgMapper; |
| 36 | 36 |
private final MattermostClient mattermost; |
| 37 | 37 |
private final MattermostProperties mattermostProps; |
| 38 |
+ private final kr.itn.itnhub.stage.StageService stageService; |
|
| 38 | 39 |
|
| 39 | 40 |
public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost, |
| 40 |
- MattermostProperties mattermostProps) {
|
|
| 41 |
+ MattermostProperties mattermostProps, |
|
| 42 |
+ kr.itn.itnhub.stage.StageService stageService) {
|
|
| 41 | 43 |
this.orgMapper = orgMapper; |
| 42 | 44 |
this.mattermost = mattermost; |
| 43 | 45 |
this.mattermostProps = mattermostProps; |
| 46 |
+ this.stageService = stageService; |
|
| 44 | 47 |
} |
| 45 | 48 |
|
| 46 | 49 |
public List<PostView> posts(Long orgId, String channel) {
|
... | ... | @@ -79,8 +82,18 @@ |
| 79 | 82 |
} |
| 80 | 83 |
mattermost.pinPost(postId); |
| 81 | 84 |
mattermost.updateChannelHeader(channelId, noticeHeader(message, files, postId)); |
| 85 |
+ |
|
| 86 |
+ // 요구사항 [18]: 「목록접수 = Mattermost에 첫 자료 업로드 + 핀 고정 시」. |
|
| 87 |
+ // 자료가 실려 있고 공지로 고정까지 된 경우에만 넘긴다 - 그냥 파일만 주고받은 것은 |
|
| 88 |
+ // 접수가 아니고, 자료 없는 공지도 접수가 아니다. 핀 고정이 끝난 뒤에 판단해야 |
|
| 89 |
+ // "고정까지 됐다"는 조건이 실제로 지켜진다. |
|
| 90 |
+ if (!fileIds.isEmpty()) {
|
|
| 91 |
+ stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.LIST_RECEIVED, |
|
| 92 |
+ kr.itn.itnhub.stage.AutoStage.REASON_LIST_RECEIVED); |
|
| 93 |
+ } |
|
| 82 | 94 |
return new SendResult(post, null); |
| 83 | 95 |
} catch (MattermostException e) {
|
| 96 |
+ // 고정에 실패했으면 단계도 넘기지 않는다. |
|
| 84 | 97 |
return new SendResult(post, "공지 등록 처리 중 일부가 실패했습니다: " + e.getMessage()); |
| 85 | 98 |
} |
| 86 | 99 |
} |
--- src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
... | ... | @@ -19,9 +19,12 @@ |
| 19 | 19 |
public class WorkMemoController {
|
| 20 | 20 |
|
| 21 | 21 |
private final WorkMemoService memoService; |
| 22 |
+ private final kr.itn.itnhub.config.CurrentUser currentUser; |
|
| 22 | 23 |
|
| 23 |
- public WorkMemoController(WorkMemoService memoService) {
|
|
| 24 |
+ public WorkMemoController(WorkMemoService memoService, |
|
| 25 |
+ kr.itn.itnhub.config.CurrentUser currentUser) {
|
|
| 24 | 26 |
this.memoService = memoService; |
| 27 |
+ this.currentUser = currentUser; |
|
| 25 | 28 |
} |
| 26 | 29 |
|
| 27 | 30 |
@GetMapping("/api/orgs/{id}/memos")
|
... | ... | @@ -29,11 +32,14 @@ |
| 29 | 32 |
return memoService.list(id); |
| 30 | 33 |
} |
| 31 | 34 |
|
| 32 |
- /** 작성자는 본문에서 받지 않고 로그인 사용자(principal)를 서버가 직접 기록한다. */ |
|
| 35 |
+ /** |
|
| 36 |
+ * 작성자는 본문에서 받지 않고 로그인 사용자를 서버가 직접 기록한다. |
|
| 37 |
+ * 요구사항 [22]로 아이디가 아니라 표시 이름이 찍힌다. |
|
| 38 |
+ */ |
|
| 33 | 39 |
@PostMapping("/api/orgs/{id}/memos")
|
| 34 | 40 |
public WorkMemo create(@PathVariable Long id, @Valid @RequestBody MemoRequest request, |
| 35 | 41 |
Principal principal) {
|
| 36 |
- return memoService.create(id, request, principal.getName()); |
|
| 42 |
+ return memoService.create(id, request, currentUser.displayName(principal)); |
|
| 37 | 43 |
} |
| 38 | 44 |
|
| 39 | 45 |
@PutMapping("/api/orgs/{id}/memos/{memoId}")
|
--- src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
+++ src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
... | ... | @@ -10,5 +10,7 @@ |
| 10 | 10 |
List<Long> ids, |
| 11 | 11 |
String processStatus, |
| 12 | 12 |
String judgedKoglType, |
| 13 |
- String finalOpinion) {
|
|
| 13 |
+ String finalOpinion, |
|
| 14 |
+ /** 요구사항 [15]로 추가된 항목. 화면의 '비고'와 같은 칸이다. */ |
|
| 15 |
+ String reviewNote) {
|
|
| 14 | 16 |
} |
--- src/main/java/kr/itn/itnhub/process/ProcessImportService.java
+++ src/main/java/kr/itn/itnhub/process/ProcessImportService.java
... | ... | @@ -17,14 +17,25 @@ |
| 17 | 17 |
private final ProcessParser parser; |
| 18 | 18 |
private final ProcessItemMapper mapper; |
| 19 | 19 |
private final OrganizationMapper orgMapper; |
| 20 |
+ private final kr.itn.itnhub.stage.StageService stageService; |
|
| 21 |
+ private final kr.itn.itnhub.code.CodeService codeService; |
|
| 20 | 22 |
private final TransactionTemplate transactionTemplate; |
| 21 | 23 |
|
| 22 | 24 |
public ProcessImportService(ProcessParser parser, ProcessItemMapper mapper, OrganizationMapper orgMapper, |
| 25 |
+ kr.itn.itnhub.stage.StageService stageService, |
|
| 26 |
+ kr.itn.itnhub.code.CodeService codeService, |
|
| 23 | 27 |
PlatformTransactionManager transactionManager) {
|
| 24 | 28 |
this.parser = parser; |
| 25 | 29 |
this.mapper = mapper; |
| 26 | 30 |
this.orgMapper = orgMapper; |
| 31 |
+ this.stageService = stageService; |
|
| 32 |
+ this.codeService = codeService; |
|
| 27 | 33 |
this.transactionTemplate = new TransactionTemplate(transactionManager); |
| 34 |
+ } |
|
| 35 |
+ |
|
| 36 |
+ /** 공공누리 유형의 옛 표기를 표준 표기로 맞춘다(제4유형 → 4유형). */ |
|
| 37 |
+ private String canonicalKogl(String value) {
|
|
| 38 |
+ return codeService.canonical(kr.itn.itnhub.code.Codes.KOGL_TYPE, value); |
|
| 28 | 39 |
} |
| 29 | 40 |
|
| 30 | 41 |
/** |
... | ... | @@ -35,7 +46,15 @@ |
| 35 | 46 |
throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
|
| 36 | 47 |
} |
| 37 | 48 |
List<ProcessRow> rows = parser.parse(xlsx); |
| 38 |
- return transactionTemplate.execute(status -> importRows(orgId, rows)); |
|
| 49 |
+ ProcessImportReport report = transactionTemplate.execute(status -> importRows(orgId, rows)); |
|
| 50 |
+ |
|
| 51 |
+ // 요구사항 [18]: 권리처리 자료가 올라오면 ⑧법률검토(권리처리)로 저절로 넘어간다. |
|
| 52 |
+ // 이미 그보다 앞서 있으면 아무 일도 하지 않는다. |
|
| 53 |
+ if (report != null && report.total() > 0) {
|
|
| 54 |
+ stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.PROCESS_STARTED, |
|
| 55 |
+ kr.itn.itnhub.stage.AutoStage.REASON_PROCESS_STARTED); |
|
| 56 |
+ } |
|
| 57 |
+ return report; |
|
| 39 | 58 |
} |
| 40 | 59 |
|
| 41 | 60 |
private ProcessImportReport importRows(Long orgId, List<ProcessRow> rows) {
|
... | ... | @@ -67,19 +86,20 @@ |
| 67 | 86 |
item.setUrl(row.url()); |
| 68 | 87 |
item.setDescription(row.description()); |
| 69 | 88 |
item.setHasAttachment(row.hasAttachment()); |
| 70 |
- item.setPriorKoglType(row.priorKoglType()); |
|
| 89 |
+ // 옛 표기(제4유형)로 적혀 온 값은 표준 표기(4유형)로 맞춰 넣는다. |
|
| 90 |
+ item.setPriorKoglType(canonicalKogl(row.priorKoglType())); |
|
| 71 | 91 |
item.setContractDocs(row.contractDocs()); |
| 72 | 92 |
item.setProducedDate(row.producedDate()); |
| 73 | 93 |
item.setPublishedDate(row.publishedDate()); |
| 74 | 94 |
item.setReviewMajor(row.reviewMajor()); |
| 75 | 95 |
item.setReviewMinor(row.reviewMinor()); |
| 76 | 96 |
item.setReviewResult(row.reviewResult()); |
| 77 |
- item.setReviewKoglType(row.reviewKoglType()); |
|
| 97 |
+ item.setReviewKoglType(canonicalKogl(row.reviewKoglType())); |
|
| 78 | 98 |
item.setReviewAiType(row.reviewAiType()); |
| 79 | 99 |
item.setReviewOpinion(row.reviewOpinion()); |
| 80 | 100 |
item.setReviewNote(row.reviewNote()); |
| 81 | 101 |
item.setPriorEvidence(row.priorEvidence()); |
| 82 |
- item.setJudgedKoglType(row.judgedKoglType()); |
|
| 102 |
+ item.setJudgedKoglType(canonicalKogl(row.judgedKoglType())); |
|
| 83 | 103 |
item.setJudgedAiType(row.judgedAiType()); |
| 84 | 104 |
item.setFinalOpinion(row.finalOpinion()); |
| 85 | 105 |
item.setJudgmentBasis(row.judgmentBasis()); |
--- src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
+++ src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
... | ... | @@ -5,6 +5,13 @@ |
| 5 | 5 |
|
| 6 | 6 |
import java.util.List; |
| 7 | 7 |
|
| 8 |
+/** |
|
| 9 |
+ * 권리처리 매퍼. |
|
| 10 |
+ * |
|
| 11 |
+ * <p>어느 값이 "처리완료"인지는 쿼리에 박지 않고 {@code doneStatus} 파라미터로 받는다.
|
|
| 12 |
+ * 코드값은 코드표에서 오는데 SQL 안에는 코드표를 참조할 방법이 마땅치 않아, 서비스가 |
|
| 13 |
+ * 읽어 넘긴다. 이 값이 틀리면 진행률과 처리일시가 조용히 어긋나므로 항상 함께 넘겨야 한다.</p> |
|
| 14 |
+ */ |
|
| 8 | 15 |
@Mapper |
| 9 | 16 |
public interface ProcessItemMapper {
|
| 10 | 17 |
|
... | ... | @@ -15,19 +22,24 @@ |
| 15 | 22 |
List<ProcessItem> findPage(@Param("orgId") Long orgId, @Param("keyword") String keyword,
|
| 16 | 23 |
@Param("site") String site, @Param("board") String board,
|
| 17 | 24 |
@Param("title") String title,
|
| 18 |
- @Param("status") String status, @Param("offset") int offset,
|
|
| 25 |
+ @Param("status") String status,
|
|
| 26 |
+ @Param("doneStatus") String doneStatus,
|
|
| 27 |
+ @Param("offset") int offset,
|
|
| 19 | 28 |
@Param("limit") int limit);
|
| 20 | 29 |
|
| 21 | 30 |
/** keyword/status로 필터링된 건수 - 필터별 페이지 경계 계산용(현재 응답의 total과는 별개). */ |
| 22 | 31 |
int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword,
|
| 23 | 32 |
@Param("site") String site, @Param("board") String board,
|
| 24 |
- @Param("title") String title, @Param("status") String status);
|
|
| 33 |
+ @Param("title") String title, @Param("status") String status,
|
|
| 34 |
+ @Param("doneStatus") String doneStatus);
|
|
| 25 | 35 |
|
| 26 | 36 |
/** 여러 건에 같은 값을 한 번에 반영한다(일괄 등록). null 항목은 기존 값을 유지한다. */ |
| 27 | 37 |
int updateProcessingBulk(@Param("orgId") Long orgId, @Param("ids") List<Long> ids,
|
| 28 | 38 |
@Param("processStatus") String processStatus,
|
| 29 | 39 |
@Param("judgedKoglType") String judgedKoglType,
|
| 30 |
- @Param("finalOpinion") String finalOpinion);
|
|
| 40 |
+ @Param("finalOpinion") String finalOpinion,
|
|
| 41 |
+ @Param("reviewNote") String reviewNote,
|
|
| 42 |
+ @Param("doneStatus") String doneStatus);
|
|
| 31 | 43 |
|
| 32 | 44 |
/** 일괄 삭제. */ |
| 33 | 45 |
int deleteByIds(@Param("orgId") Long orgId, @Param("ids") List<Long> ids);
|
... | ... | @@ -54,7 +66,8 @@ |
| 54 | 66 |
@Param("judgedAiType") String judgedAiType,
|
| 55 | 67 |
@Param("finalOpinion") String finalOpinion,
|
| 56 | 68 |
@Param("judgmentBasis") String judgmentBasis,
|
| 57 |
- @Param("processStatus") String processStatus);
|
|
| 69 |
+ @Param("processStatus") String processStatus,
|
|
| 70 |
+ @Param("doneStatus") String doneStatus);
|
|
| 58 | 71 |
|
| 59 | 72 |
int deleteById(@Param("orgId") Long orgId, @Param("id") Long id);
|
| 60 | 73 |
|
... | ... | @@ -62,5 +75,5 @@ |
| 62 | 75 |
int deleteByOrg(@Param("orgId") Long orgId);
|
| 63 | 76 |
|
| 64 | 77 |
/** 검색어/상태 필터와 무관한 기관 전체 진행률 - 목록 헤더와 기관 상세 통계 카드가 함께 쓴다. */ |
| 65 |
- ProcessStats stats(@Param("orgId") Long orgId);
|
|
| 78 |
+ ProcessStats stats(@Param("orgId") Long orgId, @Param("doneStatus") String doneStatus);
|
|
| 66 | 79 |
} |
--- src/main/java/kr/itn/itnhub/process/ProcessParser.java
+++ src/main/java/kr/itn/itnhub/process/ProcessParser.java
... | ... | @@ -43,35 +43,36 @@ |
| 43 | 43 |
|
| 44 | 44 |
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
| 45 | 45 |
|
| 46 |
- private static final int COL_SEQ = 0; // A |
|
| 47 |
- private static final int COL_SITE_NAME = 1; // B |
|
| 48 |
- private static final int COL_CATEGORY = 2; // C |
|
| 49 |
- private static final int COL_BOARD_NAME = 3; // D |
|
| 50 |
- private static final int COL_POST_TITLE = 4; // E |
|
| 51 |
- private static final int COL_URL = 5; // F |
|
| 52 |
- private static final int COL_DESCRIPTION = 6; // G |
|
| 53 |
- private static final int COL_HAS_ATTACHMENT = 7; // H |
|
| 54 |
- private static final int COL_PRIOR_KOGL_TYPE = 8; // I |
|
| 55 |
- private static final int COL_CONTRACT_DOCS = 9; // J |
|
| 56 |
- private static final int COL_PRODUCED_DATE = 10; // K |
|
| 57 |
- private static final int COL_PUBLISHED_DATE = 11; // L |
|
| 58 |
- private static final int COL_REVIEW_MAJOR = 12; // M |
|
| 59 |
- private static final int COL_REVIEW_MINOR = 13; // N |
|
| 60 |
- private static final int COL_REVIEW_RESULT = 14; // O |
|
| 61 |
- private static final int COL_REVIEW_KOGL_TYPE = 15; // P |
|
| 62 |
- private static final int COL_REVIEW_AI_TYPE = 16; // Q |
|
| 63 |
- private static final int COL_REVIEW_OPINION = 17; // R |
|
| 64 |
- private static final int COL_REVIEW_NOTE = 18; // S |
|
| 46 |
+ static final int COL_SEQ = 0; // A |
|
| 47 |
+ static final int COL_SITE_NAME = 1; // B |
|
| 48 |
+ static final int COL_CATEGORY = 2; // C |
|
| 49 |
+ static final int COL_BOARD_NAME = 3; // D |
|
| 50 |
+ static final int COL_POST_TITLE = 4; // E |
|
| 51 |
+ static final int COL_URL = 5; // F |
|
| 52 |
+ static final int COL_DESCRIPTION = 6; // G |
|
| 53 |
+ static final int COL_HAS_ATTACHMENT = 7; // H |
|
| 54 |
+ static final int COL_PRIOR_KOGL_TYPE = 8; // I |
|
| 55 |
+ static final int COL_CONTRACT_DOCS = 9; // J |
|
| 56 |
+ static final int COL_PRODUCED_DATE = 10; // K |
|
| 57 |
+ static final int COL_PUBLISHED_DATE = 11; // L |
|
| 58 |
+ static final int COL_REVIEW_MAJOR = 12; // M |
|
| 59 |
+ static final int COL_REVIEW_MINOR = 13; // N |
|
| 60 |
+ static final int COL_REVIEW_RESULT = 14; // O |
|
| 61 |
+ static final int COL_REVIEW_KOGL_TYPE = 15; // P |
|
| 62 |
+ static final int COL_REVIEW_AI_TYPE = 16; // Q |
|
| 63 |
+ static final int COL_REVIEW_OPINION = 17; // R |
|
| 64 |
+ static final int COL_REVIEW_NOTE = 18; // S |
|
| 65 | 65 |
// 실파일에서 헤더는 T열에 '기존 증빙자료'라고 적혀 있지만 실제 데이터는 U열에 있다 |
| 66 | 66 |
// (T열에는 엑셀 화면용 '수정' 링크 텍스트가 들어 있음 - 데이터 기준으로 U를 읽는다) |
| 67 |
- private static final int COL_PRIOR_EVIDENCE = 20; // U |
|
| 68 |
- // 20 = U, 수정 링크 열, 건너뜀 |
|
| 69 |
- private static final int COL_JUDGED_KOGL_TYPE = 21; // V |
|
| 70 |
- private static final int COL_JUDGED_AI_TYPE = 22; // W |
|
| 71 |
- private static final int COL_FINAL_OPINION = 23; // X |
|
| 67 |
+ static final int COL_PRIOR_EVIDENCE = 20; // U |
|
| 68 |
+ /** 헤더('기존 증빙자료')가 적힌 열. 이 시스템이 만드는 다운로드 파일은 여기에 값을 쓴다. */
|
|
| 69 |
+ static final int COL_PRIOR_EVIDENCE_HEADER = 19; // T |
|
| 70 |
+ static final int COL_JUDGED_KOGL_TYPE = 21; // V |
|
| 71 |
+ static final int COL_JUDGED_AI_TYPE = 22; // W |
|
| 72 |
+ static final int COL_FINAL_OPINION = 23; // X |
|
| 72 | 73 |
// 24 = Y, 빈 열, 건너뜀 |
| 73 |
- private static final int COL_JUDGMENT_BASIS = 25; // Z |
|
| 74 |
- private static final int COL_PROCESS_STATUS = 26; // AA |
|
| 74 |
+ static final int COL_JUDGMENT_BASIS = 25; // Z |
|
| 75 |
+ static final int COL_PROCESS_STATUS = 26; // AA |
|
| 75 | 76 |
|
| 76 | 77 |
public List<ProcessRow> parse(InputStream xlsx) {
|
| 77 | 78 |
try (Workbook wb = WorkbookFactory.create(xlsx)) {
|
... | ... | @@ -124,7 +125,7 @@ |
| 124 | 125 |
nullIfBlank(string(row, COL_REVIEW_AI_TYPE)), |
| 125 | 126 |
nullIfBlank(string(row, COL_REVIEW_OPINION)), |
| 126 | 127 |
nullIfBlank(string(row, COL_REVIEW_NOTE)), |
| 127 |
- nullIfBlank(string(row, COL_PRIOR_EVIDENCE)), |
|
| 128 |
+ priorEvidence(row), |
|
| 128 | 129 |
nullIfBlank(string(row, COL_JUDGED_KOGL_TYPE)), |
| 129 | 130 |
nullIfBlank(string(row, COL_JUDGED_AI_TYPE)), |
| 130 | 131 |
nullIfBlank(string(row, COL_FINAL_OPINION)), |
... | ... | @@ -134,6 +135,21 @@ |
| 134 | 135 |
return result; |
| 135 | 136 |
} |
| 136 | 137 |
|
| 138 |
+ /** |
|
| 139 |
+ * 기존 증빙자료는 파일마다 값이 있는 열이 다르다. |
|
| 140 |
+ * |
|
| 141 |
+ * <p>기관이 보내오는 원본은 헤더가 T열인데 데이터가 U열에 있고(T에는 엑셀 화면용 '수정' |
|
| 142 |
+ * 링크 텍스트가 들어 있다), 이 시스템이 만들어 주는 다운로드 파일은 헤더 자리인 T열에 |
|
| 143 |
+ * 값을 쓴다. 한쪽만 읽으면 "내려받아 고쳐서 다시 올리는" 흐름에서 값이 조용히 사라지므로 |
|
| 144 |
+ * U를 먼저 보고, 비어 있으면 T를 본다.</p> |
|
| 145 |
+ */ |
|
| 146 |
+ private String priorEvidence(Row row) {
|
|
| 147 |
+ String fromDataColumn = nullIfBlank(string(row, COL_PRIOR_EVIDENCE)); |
|
| 148 |
+ return fromDataColumn != null |
|
| 149 |
+ ? fromDataColumn |
|
| 150 |
+ : nullIfBlank(string(row, COL_PRIOR_EVIDENCE_HEADER)); |
|
| 151 |
+ } |
|
| 152 |
+ |
|
| 137 | 153 |
private String string(Row row, int columnIndex) {
|
| 138 | 154 |
Cell cell = row.getCell(columnIndex); |
| 139 | 155 |
if (cell == null) {
|
--- src/main/java/kr/itn/itnhub/process/ProcessService.java
+++ src/main/java/kr/itn/itnhub/process/ProcessService.java
... | ... | @@ -1,208 +0,0 @@ |
| 1 | -package kr.itn.itnhub.process; | |
| 2 | - | |
| 3 | -import kr.itn.itnhub.org.OrgNotFoundException; | |
| 4 | -import kr.itn.itnhub.org.OrganizationMapper; | |
| 5 | -import org.apache.poi.ss.usermodel.Cell; | |
| 6 | -import org.apache.poi.ss.usermodel.Row; | |
| 7 | -import org.apache.poi.ss.usermodel.Sheet; | |
| 8 | -import org.apache.poi.xssf.usermodel.XSSFWorkbook; | |
| 9 | -import org.springframework.stereotype.Service; | |
| 10 | -import org.springframework.transaction.annotation.Transactional; | |
| 11 | - | |
| 12 | -import java.io.ByteArrayOutputStream; | |
| 13 | -import java.io.IOException; | |
| 14 | -import java.io.UncheckedIOException; | |
| 15 | -import java.util.List; | |
| 16 | -import java.util.Set; | |
| 17 | - | |
| 18 | -/** 기관관리 상세의 권리처리 탭이 쓰는 목록/상세/수정/삭제/다운로드 서비스. */ | |
| 19 | -@Service | |
| 20 | -public class ProcessService { | |
| 21 | - | |
| 22 | - private static final Set<String> VALID_PROCESS_STATUSES = Set.of("미처리", "처리완료"); | |
| 23 | - | |
| 24 | - /** | |
| 25 | - * 1행 그룹 제목(A=게시물 정보(조사원), M=권리확인(변호사), U=권리처리(변호사)) + 2행 컬럼 헤더. | |
| 26 | - * U열(수정 링크)과 Y열은 원본 시트의 건너뛰는 열이라 빈 문자열로 남겨둔다. | |
| 27 | - */ | |
| 28 | - private static final String[] COLUMN_HEADERS = { | |
| 29 | - "순번", "사이트명", "범주", "게시판명", "게시물제목", "URL주소", "설명", | |
| 30 | - "첨부파일여부", "기존 공공누리유형(+AI)", "계약서 유무", "제작일", "공표일", | |
| 31 | - "권리확인(대분류)", "권리확인(세부)", "처리 구분", "공공누리 유형", "AI유형", "의견", "비고", "기존 증빙자료", | |
| 32 | - "", "공공누리유형(판정)", "AI유형(판정)", "최종의견", "", "판단근거", "처리상태" | |
| 33 | - }; | |
| 34 | - | |
| 35 | - private final ProcessItemMapper mapper; | |
| 36 | - private final OrganizationMapper orgMapper; | |
| 37 | - | |
| 38 | - public ProcessService(ProcessItemMapper mapper, OrganizationMapper orgMapper) { | |
| 39 | - this.mapper = mapper; | |
| 40 | - this.orgMapper = orgMapper; | |
| 41 | - } | |
| 42 | - | |
| 43 | - public ProcessPage list(Long orgId, String keyword, String site, String board, String title, | |
| 44 | - String status, int page, int size) { | |
| 45 | - requireOrg(orgId); | |
| 46 | - String kw = normalizeKeyword(keyword); | |
| 47 | - String siteFilter = normalizeKeyword(site); | |
| 48 | - String boardFilter = normalizeKeyword(board); | |
| 49 | - String titleFilter = normalizeKeyword(title); | |
| 50 | - String st = normalizeStatus(status); | |
| 51 | - int safePage = Math.max(page, 0); | |
| 52 | - int safeSize = size <= 0 ? 30 : size; | |
| 53 | - int offset = safePage * safeSize; | |
| 54 | - | |
| 55 | - List<ProcessItem> items = | |
| 56 | - mapper.findPage(orgId, kw, siteFilter, boardFilter, titleFilter, st, offset, safeSize); | |
| 57 | - // total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다(헤더/통계 카드용 - ProcessPage 참고). | |
| 58 | - ProcessStats stats = mapper.stats(orgId); | |
| 59 | - return new ProcessPage(items, stats.total(), stats.done(), safePage, safeSize); | |
| 60 | - } | |
| 61 | - | |
| 62 | - /** 목록에서 고른 여러 건에 같은 값을 한 번에 반영한다. 화면에서 비워 둔 항목은 유지된다. */ | |
| 63 | - @Transactional | |
| 64 | - public int updateBulk(Long orgId, BulkProcessingRequest request) { | |
| 65 | - requireOrg(orgId); | |
| 66 | - if (request.ids() == null || request.ids().isEmpty()) { | |
| 67 | - return 0; | |
| 68 | - } | |
| 69 | - return mapper.updateProcessingBulk(orgId, request.ids(), | |
| 70 | - blankToNull(request.processStatus()), | |
| 71 | - blankToNull(request.judgedKoglType()), | |
| 72 | - blankToNull(request.finalOpinion())); | |
| 73 | - } | |
| 74 | - | |
| 75 | - /** 목록에서 고른 여러 건을 지운다. */ | |
| 76 | - @Transactional | |
| 77 | - public int deleteBulk(Long orgId, List<Long> ids) { | |
| 78 | - requireOrg(orgId); | |
| 79 | - if (ids == null || ids.isEmpty()) { | |
| 80 | - return 0; | |
| 81 | - } | |
| 82 | - return mapper.deleteByIds(orgId, ids); | |
| 83 | - } | |
| 84 | - | |
| 85 | - private String blankToNull(String value) { | |
| 86 | - return value == null || value.isBlank() ? null : value; | |
| 87 | - } | |
| 88 | - | |
| 89 | - public ProcessItem get(Long orgId, Long itemId) { | |
| 90 | - requireOrg(orgId); | |
| 91 | - return requireItem(orgId, itemId); | |
| 92 | - } | |
| 93 | - | |
| 94 | - @Transactional | |
| 95 | - public ProcessItem updateProcessing(Long orgId, Long itemId, ProcessingRequest request) { | |
| 96 | - requireOrg(orgId); | |
| 97 | - validateProcessStatus(request.processStatus()); | |
| 98 | - int updated = mapper.updateProcessing(orgId, itemId, | |
| 99 | - request.contractDocs(), request.judgedKoglType(), request.judgedAiType(), | |
| 100 | - request.finalOpinion(), request.judgmentBasis(), request.processStatus()); | |
| 101 | - if (updated == 0) { | |
| 102 | - throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId); | |
| 103 | - } | |
| 104 | - return mapper.findById(orgId, itemId); | |
| 105 | - } | |
| 106 | - | |
| 107 | - @Transactional | |
| 108 | - public void delete(Long orgId, Long itemId) { | |
| 109 | - requireOrg(orgId); | |
| 110 | - int deleted = mapper.deleteById(orgId, itemId); | |
| 111 | - if (deleted == 0) { | |
| 112 | - throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId); | |
| 113 | - } | |
| 114 | - } | |
| 115 | - | |
| 116 | - /** 원본 파일과 같은 헤더 배치(1행 그룹제목, 2행 컬럼헤더, 3행부터 데이터)로 xlsx를 만든다. */ | |
| 117 | - public byte[] downloadWorkbook(Long orgId) { | |
| 118 | - requireOrg(orgId); | |
| 119 | - List<ProcessItem> items = mapper.findAllByOrg(orgId); | |
| 120 | - | |
| 121 | - try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 122 | - Sheet sheet = wb.createSheet(ProcessParser.PRIMARY_SHEET_NAME); | |
| 123 | - | |
| 124 | - Row groupRow = sheet.createRow(0); | |
| 125 | - groupRow.createCell(0).setCellValue("게시물 정보(조사원)"); | |
| 126 | - groupRow.createCell(12).setCellValue("권리확인(변호사)"); | |
| 127 | - groupRow.createCell(20).setCellValue("권리처리(변호사)"); | |
| 128 | - | |
| 129 | - Row headerRow = sheet.createRow(1); | |
| 130 | - for (int i = 0; i < COLUMN_HEADERS.length; i++) { | |
| 131 | - headerRow.createCell(i).setCellValue(COLUMN_HEADERS[i]); | |
| 132 | - } | |
| 133 | - | |
| 134 | - int rowNum = 2; | |
| 135 | - for (ProcessItem item : items) { | |
| 136 | - Row row = sheet.createRow(rowNum++); | |
| 137 | - setCell(row, 0, String.valueOf(item.getSeq())); | |
| 138 | - setCell(row, 1, item.getSiteName()); | |
| 139 | - setCell(row, 2, item.getCategory()); | |
| 140 | - setCell(row, 3, item.getBoardName()); | |
| 141 | - setCell(row, 4, item.getPostTitle()); | |
| 142 | - setCell(row, 5, item.getUrl()); | |
| 143 | - setCell(row, 6, item.getDescription()); | |
| 144 | - setCell(row, 7, item.getHasAttachment()); | |
| 145 | - setCell(row, 8, item.getPriorKoglType()); | |
| 146 | - setCell(row, 9, item.getContractDocs()); | |
| 147 | - setCell(row, 10, item.getProducedDate()); | |
| 148 | - setCell(row, 11, item.getPublishedDate()); | |
| 149 | - setCell(row, 12, item.getReviewMajor()); | |
| 150 | - setCell(row, 13, item.getReviewMinor()); | |
| 151 | - setCell(row, 14, item.getReviewResult()); | |
| 152 | - setCell(row, 15, item.getReviewKoglType()); | |
| 153 | - setCell(row, 16, item.getReviewAiType()); | |
| 154 | - setCell(row, 17, item.getReviewOpinion()); | |
| 155 | - setCell(row, 18, item.getReviewNote()); | |
| 156 | - setCell(row, 19, item.getPriorEvidence()); | |
| 157 | - // 20 = U, 수정 링크 열, 건너뜀 | |
| 158 | - setCell(row, 21, item.getJudgedKoglType()); | |
| 159 | - setCell(row, 22, item.getJudgedAiType()); | |
| 160 | - setCell(row, 23, item.getFinalOpinion()); | |
| 161 | - // 24 = Y, 빈 열 | |
| 162 | - setCell(row, 25, item.getJudgmentBasis()); | |
| 163 | - setCell(row, 26, item.getProcessStatus()); | |
| 164 | - } | |
| 165 | - | |
| 166 | - wb.write(out); | |
| 167 | - return out.toByteArray(); | |
| 168 | - } catch (IOException e) { | |
| 169 | - throw new UncheckedIOException("권리처리 엑셀을 만들지 못했습니다.", e); | |
| 170 | - } | |
| 171 | - } | |
| 172 | - | |
| 173 | - private void setCell(Row row, int columnIndex, String value) { | |
| 174 | - if (value == null) { | |
| 175 | - return; | |
| 176 | - } | |
| 177 | - Cell cell = row.createCell(columnIndex); | |
| 178 | - cell.setCellValue(value); | |
| 179 | - } | |
| 180 | - | |
| 181 | - private void validateProcessStatus(String processStatus) { | |
| 182 | - if (processStatus != null && !VALID_PROCESS_STATUSES.contains(processStatus)) { | |
| 183 | - throw new InvalidProcessStatusException("처리상태는 미처리 또는 처리완료여야 합니다."); | |
| 184 | - } | |
| 185 | - } | |
| 186 | - | |
| 187 | - private String normalizeKeyword(String keyword) { | |
| 188 | - return (keyword == null || keyword.isBlank()) ? null : keyword.trim(); | |
| 189 | - } | |
| 190 | - | |
| 191 | - private String normalizeStatus(String status) { | |
| 192 | - return (status == null || status.isBlank()) ? null : status.trim(); | |
| 193 | - } | |
| 194 | - | |
| 195 | - private void requireOrg(Long orgId) { | |
| 196 | - if (orgMapper.findById(orgId) == null) { | |
| 197 | - throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 198 | - } | |
| 199 | - } | |
| 200 | - | |
| 201 | - private ProcessItem requireItem(Long orgId, Long itemId) { | |
| 202 | - ProcessItem item = mapper.findById(orgId, itemId); | |
| 203 | - if (item == null) { | |
| 204 | - throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId); | |
| 205 | - } | |
| 206 | - return item; | |
| 207 | - } | |
| 208 | -} | |
| +Binary file is not shown |
--- src/main/java/kr/itn/itnhub/project/ProjectController.java
+++ src/main/java/kr/itn/itnhub/project/ProjectController.java
... | ... | @@ -29,9 +29,12 @@ |
| 29 | 29 |
public class ProjectController {
|
| 30 | 30 |
|
| 31 | 31 |
private final ProjectService projectService; |
| 32 |
+ private final kr.itn.itnhub.config.CurrentUser currentUser; |
|
| 32 | 33 |
|
| 33 |
- public ProjectController(ProjectService projectService) {
|
|
| 34 |
+ public ProjectController(ProjectService projectService, |
|
| 35 |
+ kr.itn.itnhub.config.CurrentUser currentUser) {
|
|
| 34 | 36 |
this.projectService = projectService; |
| 37 |
+ this.currentUser = currentUser; |
|
| 35 | 38 |
} |
| 36 | 39 |
|
| 37 | 40 |
@GetMapping("/api/projects")
|
... | ... | @@ -49,7 +52,7 @@ |
| 49 | 52 |
public List<ContactLog> addLog(@PathVariable("id") Long orgId,
|
| 50 | 53 |
@Valid @RequestBody ContactLogRequest request, |
| 51 | 54 |
Principal principal) {
|
| 52 |
- return projectService.addLog(orgId, request, principal.getName()); |
|
| 55 |
+ return projectService.addLog(orgId, request, currentUser.displayName(principal)); |
|
| 53 | 56 |
} |
| 54 | 57 |
|
| 55 | 58 |
@PutMapping("/api/orgs/{id}/contact-logs/{logId}")
|
... | ... | @@ -75,7 +78,7 @@ |
| 75 | 78 |
Principal principal) throws IOException {
|
| 76 | 79 |
String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename(); |
| 77 | 80 |
return projectService.addReport(orgId, name, file.getContentType(), file.getBytes(), |
| 78 |
- principal.getName()); |
|
| 81 |
+ currentUser.displayName(principal)); |
|
| 79 | 82 |
} |
| 80 | 83 |
|
| 81 | 84 |
/** 한글 파일명이 깨지지 않게 ChannelFeedController.download와 같은 RFC 5987 인코딩을 쓴다. */ |
... | ... | @@ -95,6 +98,17 @@ |
| 95 | 98 |
.body(report.content()); |
| 96 | 99 |
} |
| 97 | 100 |
|
| 101 |
+ /** 올려둔 보고서를 새 파일로 바꾼다(요구사항 [25]). 목록에 새 줄이 생기지 않는다. */ |
|
| 102 |
+ @PutMapping("/api/orgs/{id}/reports/{reportId}")
|
|
| 103 |
+ public List<OrgReport> replaceReport(@PathVariable("id") Long orgId,
|
|
| 104 |
+ @PathVariable Long reportId, |
|
| 105 |
+ @RequestPart("file") MultipartFile file,
|
|
| 106 |
+ Principal principal) throws IOException {
|
|
| 107 |
+ String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename(); |
|
| 108 |
+ return projectService.replaceReport(orgId, reportId, name, file.getContentType(), |
|
| 109 |
+ file.getBytes(), currentUser.displayName(principal)); |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 98 | 112 |
@ResponseStatus(HttpStatus.NO_CONTENT) |
| 99 | 113 |
@DeleteMapping("/api/orgs/{id}/reports/{reportId}")
|
| 100 | 114 |
public void deleteReport(@PathVariable("id") Long orgId, @PathVariable Long reportId) {
|
--- src/main/java/kr/itn/itnhub/project/ProjectMapper.java
+++ src/main/java/kr/itn/itnhub/project/ProjectMapper.java
... | ... | @@ -29,6 +29,12 @@ |
| 29 | 29 |
@Param("contentType") String contentType, @Param("byteSize") long byteSize,
|
| 30 | 30 |
@Param("content") byte[] content, @Param("uploadedBy") String uploadedBy);
|
| 31 | 31 |
|
| 32 |
+ /** 보고서 교체(요구사항 [25]). 새 행을 만들지 않고 같은 자리를 덮어쓴다. */ |
|
| 33 |
+ int updateReport(@Param("orgId") Long orgId, @Param("id") Long id,
|
|
| 34 |
+ @Param("fileName") String fileName,
|
|
| 35 |
+ @Param("contentType") String contentType, @Param("byteSize") long byteSize,
|
|
| 36 |
+ @Param("content") byte[] content, @Param("uploadedBy") String uploadedBy);
|
|
| 37 |
+ |
|
| 32 | 38 |
/** 다운로드용. 파일 내용까지 읽는다. */ |
| 33 | 39 |
ReportContent findReportContent(@Param("orgId") Long orgId, @Param("id") Long id);
|
| 34 | 40 |
|
--- src/main/java/kr/itn/itnhub/project/ProjectService.java
+++ src/main/java/kr/itn/itnhub/project/ProjectService.java
... | ... | @@ -99,6 +99,25 @@ |
| 99 | 99 |
return mapper.findReportsByOrg(orgId); |
| 100 | 100 |
} |
| 101 | 101 |
|
| 102 |
+ /** |
|
| 103 |
+ * 올려둔 보고서를 새 파일로 바꾼다(요구사항 [25]). 목록에 새 줄이 생기지 않고 그 자리만 |
|
| 104 |
+ * 바뀐다 - 개정판으로 남기고 싶으면 새로 올리면 된다. |
|
| 105 |
+ */ |
|
| 106 |
+ @Transactional |
|
| 107 |
+ public List<OrgReport> replaceReport(Long orgId, Long reportId, String fileName, |
|
| 108 |
+ String contentType, byte[] content, String uploadedBy) {
|
|
| 109 |
+ requireOrg(orgId); |
|
| 110 |
+ if (content == null || content.length == 0) {
|
|
| 111 |
+ throw new IllegalArgumentException("빈 파일은 올릴 수 없습니다.");
|
|
| 112 |
+ } |
|
| 113 |
+ int updated = mapper.updateReport(orgId, reportId, fileName, contentType, content.length, |
|
| 114 |
+ content, uploadedBy); |
|
| 115 |
+ if (updated == 0) {
|
|
| 116 |
+ throw new OrgNotFoundException("보고서를 찾을 수 없습니다: " + reportId);
|
|
| 117 |
+ } |
|
| 118 |
+ return mapper.findReportsByOrg(orgId); |
|
| 119 |
+ } |
|
| 120 |
+ |
|
| 102 | 121 |
public ProjectMapper.ReportContent reportContent(Long orgId, Long reportId) {
|
| 103 | 122 |
requireOrg(orgId); |
| 104 | 123 |
ProjectMapper.ReportContent content = mapper.findReportContent(orgId, reportId); |
--- src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
+++ src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
... | ... | @@ -227,7 +227,8 @@ |
| 227 | 227 |
} |
| 228 | 228 |
if (filled(current.getChannelIdMj()) && filled(current.getChannelIdLaw())) {
|
| 229 | 229 |
mapper.updateStage(orgId, 1); |
| 230 |
- stageHistoryMapper.insert(orgId, 1); |
|
| 230 |
+ // 이것도 프로그램이 저절로 넣는 단계다(요구사항 [19]: 이력에 자동/수동 구분). |
|
| 231 |
+ stageHistoryMapper.insert(orgId, 1, true, "채널 생성"); |
|
| 231 | 232 |
} |
| 232 | 233 |
} catch (RuntimeException e) {
|
| 233 | 234 |
log.warn("진행단계 자동 시작 실패 org={}", orgId, e);
|
+++ src/main/java/kr/itn/itnhub/re/ReMemo.java
... | ... | @@ -0,0 +1,18 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * RE 메모 한 건. 요구사항 [4]대로 "메모 1개 = RE 1개"다. | |
| 5 | + * | |
| 6 | + * <p>예) 내용 "ooo 계약서 및 제안요청서 송부 요청" / 작성자 "장영익" / 일자 2026-07-08</p> | |
| 7 | + * | |
| 8 | + * @param resolved 해결 여부. 대시보드의 '미해결 RE'는 이 값이 false인 건수다. | |
| 9 | + */ | |
| 10 | +public record ReMemo( | |
| 11 | + Long id, | |
| 12 | + Long orgId, | |
| 13 | + String content, | |
| 14 | + String author, | |
| 15 | + String memoDate, | |
| 16 | + boolean resolved, | |
| 17 | + Long createdAt) { | |
| 18 | +} |
+++ src/main/java/kr/itn/itnhub/re/ReMemoController.java
... | ... | @@ -0,0 +1,48 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +import jakarta.validation.Valid; | |
| 4 | +import org.springframework.http.HttpStatus; | |
| 5 | +import org.springframework.web.bind.annotation.DeleteMapping; | |
| 6 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 7 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 8 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 9 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 10 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 11 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 12 | +import org.springframework.web.bind.annotation.RestController; | |
| 13 | + | |
| 14 | +import java.util.List; | |
| 15 | + | |
| 16 | +/** 기관관리 상세의 RE 탭. 등록·수정·삭제 후에는 갱신된 목록을 그대로 돌려준다. */ | |
| 17 | +@RestController | |
| 18 | +public class ReMemoController { | |
| 19 | + | |
| 20 | + private final ReMemoService service; | |
| 21 | + | |
| 22 | + public ReMemoController(ReMemoService service) { | |
| 23 | + this.service = service; | |
| 24 | + } | |
| 25 | + | |
| 26 | + @GetMapping("/api/orgs/{id}/re-memos") | |
| 27 | + public List<ReMemo> list(@PathVariable("id") Long orgId) { | |
| 28 | + return service.list(orgId); | |
| 29 | + } | |
| 30 | + | |
| 31 | + @PostMapping("/api/orgs/{id}/re-memos") | |
| 32 | + public List<ReMemo> add(@PathVariable("id") Long orgId, | |
| 33 | + @Valid @RequestBody ReMemoRequest request) { | |
| 34 | + return service.add(orgId, request); | |
| 35 | + } | |
| 36 | + | |
| 37 | + @PutMapping("/api/orgs/{id}/re-memos/{memoId}") | |
| 38 | + public List<ReMemo> update(@PathVariable("id") Long orgId, @PathVariable Long memoId, | |
| 39 | + @Valid @RequestBody ReMemoRequest request) { | |
| 40 | + return service.update(orgId, memoId, request); | |
| 41 | + } | |
| 42 | + | |
| 43 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 44 | + @DeleteMapping("/api/orgs/{id}/re-memos/{memoId}") | |
| 45 | + public void delete(@PathVariable("id") Long orgId, @PathVariable Long memoId) { | |
| 46 | + service.delete(orgId, memoId); | |
| 47 | + } | |
| 48 | +} |
+++ src/main/java/kr/itn/itnhub/re/ReMemoMapper.java
... | ... | @@ -0,0 +1,28 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +import org.apache.ibatis.annotations.Mapper; | |
| 4 | +import org.apache.ibatis.annotations.Param; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +@Mapper | |
| 9 | +public interface ReMemoMapper { | |
| 10 | + | |
| 11 | + /** 기관의 RE 메모를 일자 최신순으로. */ | |
| 12 | + List<ReMemo> findByOrg(@Param("orgId") Long orgId); | |
| 13 | + | |
| 14 | + ReMemo findById(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 15 | + | |
| 16 | + int insert(@Param("orgId") Long orgId, @Param("content") String content, | |
| 17 | + @Param("author") String author, @Param("memoDate") String memoDate, | |
| 18 | + @Param("resolved") boolean resolved); | |
| 19 | + | |
| 20 | + int update(@Param("orgId") Long orgId, @Param("id") Long id, | |
| 21 | + @Param("content") String content, @Param("author") String author, | |
| 22 | + @Param("memoDate") String memoDate, @Param("resolved") boolean resolved); | |
| 23 | + | |
| 24 | + int delete(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 25 | + | |
| 26 | + /** 채널 초기화 시 함께 지운다 - 기관 데이터가 되돌아가는데 메모만 남으면 어긋난다. */ | |
| 27 | + int deleteByOrg(@Param("orgId") Long orgId); | |
| 28 | +} |
+++ src/main/java/kr/itn/itnhub/re/ReMemoNotFoundException.java
... | ... | @@ -0,0 +1,9 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +/** 없는 RE 메모를 고치거나 지우려 할 때. GlobalExceptionHandler가 404로 바꾼다. */ | |
| 4 | +public class ReMemoNotFoundException extends RuntimeException { | |
| 5 | + | |
| 6 | + public ReMemoNotFoundException(String message) { | |
| 7 | + super(message); | |
| 8 | + } | |
| 9 | +} |
+++ src/main/java/kr/itn/itnhub/re/ReMemoRequest.java
... | ... | @@ -0,0 +1,20 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.NotBlank; | |
| 4 | +import jakarta.validation.constraints.Size; | |
| 5 | + | |
| 6 | +/** RE 메모 등록/수정 입력. 일자를 비우면 오늘로 넣는다. */ | |
| 7 | +public record ReMemoRequest( | |
| 8 | + @NotBlank(message = "내용을 입력하세요.") | |
| 9 | + @Size(max = 2000, message = "내용은 2000자까지 넣을 수 있습니다.") | |
| 10 | + String content, | |
| 11 | + | |
| 12 | + @NotBlank(message = "작성자를 입력하세요.") | |
| 13 | + @Size(max = 100, message = "작성자는 100자까지 넣을 수 있습니다.") | |
| 14 | + String author, | |
| 15 | + | |
| 16 | + /** yyyy-MM-dd. 비우면 오늘. */ | |
| 17 | + String memoDate, | |
| 18 | + | |
| 19 | + boolean resolved) { | |
| 20 | +} |
+++ src/main/java/kr/itn/itnhub/re/ReMemoService.java
... | ... | @@ -0,0 +1,67 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 4 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 5 | +import org.springframework.stereotype.Service; | |
| 6 | +import org.springframework.transaction.annotation.Transactional; | |
| 7 | + | |
| 8 | +import java.util.List; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 기관관리 상세의 RE 탭이 쓰는 서비스. | |
| 12 | + * | |
| 13 | + * <p>요구사항 [4]: 별도 양식 없이 메모장처럼 등록한다. 메모 1개가 RE 1개다.</p> | |
| 14 | + */ | |
| 15 | +@Service | |
| 16 | +public class ReMemoService { | |
| 17 | + | |
| 18 | + private final ReMemoMapper mapper; | |
| 19 | + private final OrganizationMapper orgMapper; | |
| 20 | + | |
| 21 | + public ReMemoService(ReMemoMapper mapper, OrganizationMapper orgMapper) { | |
| 22 | + this.mapper = mapper; | |
| 23 | + this.orgMapper = orgMapper; | |
| 24 | + } | |
| 25 | + | |
| 26 | + public List<ReMemo> list(Long orgId) { | |
| 27 | + requireOrg(orgId); | |
| 28 | + return mapper.findByOrg(orgId); | |
| 29 | + } | |
| 30 | + | |
| 31 | + @Transactional | |
| 32 | + public List<ReMemo> add(Long orgId, ReMemoRequest request) { | |
| 33 | + requireOrg(orgId); | |
| 34 | + mapper.insert(orgId, request.content().trim(), request.author().trim(), | |
| 35 | + blankToNull(request.memoDate()), request.resolved()); | |
| 36 | + return mapper.findByOrg(orgId); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Transactional | |
| 40 | + public List<ReMemo> update(Long orgId, Long memoId, ReMemoRequest request) { | |
| 41 | + requireOrg(orgId); | |
| 42 | + int updated = mapper.update(orgId, memoId, request.content().trim(), | |
| 43 | + request.author().trim(), blankToNull(request.memoDate()), request.resolved()); | |
| 44 | + if (updated == 0) { | |
| 45 | + throw new ReMemoNotFoundException("RE 메모를 찾을 수 없습니다: " + memoId); | |
| 46 | + } | |
| 47 | + return mapper.findByOrg(orgId); | |
| 48 | + } | |
| 49 | + | |
| 50 | + @Transactional | |
| 51 | + public void delete(Long orgId, Long memoId) { | |
| 52 | + requireOrg(orgId); | |
| 53 | + if (mapper.delete(orgId, memoId) == 0) { | |
| 54 | + throw new ReMemoNotFoundException("RE 메모를 찾을 수 없습니다: " + memoId); | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + private String blankToNull(String value) { | |
| 59 | + return (value == null || value.isBlank()) ? null : value.trim(); | |
| 60 | + } | |
| 61 | + | |
| 62 | + private void requireOrg(Long orgId) { | |
| 63 | + if (orgMapper.findById(orgId) == null) { | |
| 64 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 65 | + } | |
| 66 | + } | |
| 67 | +} |
--- src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
+++ src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
... | ... | @@ -12,5 +12,7 @@ |
| 12 | 12 |
List<Long> ids, |
| 13 | 13 |
String reviewResult, |
| 14 | 14 |
String judgedKoglType, |
| 15 |
- String opinion) {
|
|
| 15 |
+ String opinion, |
|
| 16 |
+ /** 요구사항 [15]로 추가된 항목. 화면의 '비고(변호사)'와 같은 칸이다. */ |
|
| 17 |
+ String lawyerNote) {
|
|
| 16 | 18 |
} |
--- src/main/java/kr/itn/itnhub/review/ReviewImportService.java
+++ src/main/java/kr/itn/itnhub/review/ReviewImportService.java
... | ... | @@ -17,14 +17,25 @@ |
| 17 | 17 |
private final ReviewParser parser; |
| 18 | 18 |
private final ReviewItemMapper mapper; |
| 19 | 19 |
private final OrganizationMapper orgMapper; |
| 20 |
+ private final kr.itn.itnhub.stage.StageService stageService; |
|
| 21 |
+ private final kr.itn.itnhub.code.CodeService codeService; |
|
| 20 | 22 |
private final TransactionTemplate transactionTemplate; |
| 21 | 23 |
|
| 22 | 24 |
public ReviewImportService(ReviewParser parser, ReviewItemMapper mapper, OrganizationMapper orgMapper, |
| 25 |
+ kr.itn.itnhub.stage.StageService stageService, |
|
| 26 |
+ kr.itn.itnhub.code.CodeService codeService, |
|
| 23 | 27 |
PlatformTransactionManager transactionManager) {
|
| 24 | 28 |
this.parser = parser; |
| 25 | 29 |
this.mapper = mapper; |
| 26 | 30 |
this.orgMapper = orgMapper; |
| 31 |
+ this.stageService = stageService; |
|
| 32 |
+ this.codeService = codeService; |
|
| 27 | 33 |
this.transactionTemplate = new TransactionTemplate(transactionManager); |
| 34 |
+ } |
|
| 35 |
+ |
|
| 36 |
+ /** 공공누리 유형의 옛 표기를 표준 표기로 맞춘다(제4유형 → 4유형). */ |
|
| 37 |
+ private String canonicalKogl(String value) {
|
|
| 38 |
+ return codeService.canonical(kr.itn.itnhub.code.Codes.KOGL_TYPE, value); |
|
| 28 | 39 |
} |
| 29 | 40 |
|
| 30 | 41 |
/** |
... | ... | @@ -35,7 +46,15 @@ |
| 35 | 46 |
throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
|
| 36 | 47 |
} |
| 37 | 48 |
List<ReviewRow> rows = parser.parse(xlsx); |
| 38 |
- return transactionTemplate.execute(status -> importRows(orgId, rows)); |
|
| 49 |
+ ReviewImportReport report = transactionTemplate.execute(status -> importRows(orgId, rows)); |
|
| 50 |
+ |
|
| 51 |
+ // 요구사항 [18]: 권리확인 자료가 올라오면 ⑤법률검토(권리확인)로 저절로 넘어간다. |
|
| 52 |
+ // 이미 그보다 앞서 있으면 아무 일도 하지 않는다. |
|
| 53 |
+ if (report != null && report.total() > 0) {
|
|
| 54 |
+ stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.REVIEW_STARTED, |
|
| 55 |
+ kr.itn.itnhub.stage.AutoStage.REASON_REVIEW_STARTED); |
|
| 56 |
+ } |
|
| 57 |
+ return report; |
|
| 39 | 58 |
} |
| 40 | 59 |
|
| 41 | 60 |
private ReviewImportReport importRows(Long orgId, List<ReviewRow> rows) {
|
... | ... | @@ -71,14 +90,15 @@ |
| 71 | 90 |
item.setPublishedDate(row.publishedDate()); |
| 72 | 91 |
item.setHasAttachment(row.hasAttachment()); |
| 73 | 92 |
item.setKoglAttached(row.koglAttached()); |
| 74 |
- item.setKoglType(row.koglType()); |
|
| 93 |
+ // 옛 표기(제4유형)로 적혀 온 값은 표준 표기(4유형)로 맞춰 넣는다. |
|
| 94 |
+ item.setKoglType(canonicalKogl(row.koglType())); |
|
| 75 | 95 |
item.setAiType(row.aiType()); |
| 76 | 96 |
item.setSurveyorNote(row.surveyorNote()); |
| 77 | 97 |
item.setOpenable(row.openable()); |
| 78 | 98 |
item.setReviewMajor(row.reviewMajor()); |
| 79 | 99 |
item.setReviewMinor(row.reviewMinor()); |
| 80 | 100 |
item.setReviewResult(row.reviewResult()); |
| 81 |
- item.setJudgedKoglType(row.judgedKoglType()); |
|
| 101 |
+ item.setJudgedKoglType(canonicalKogl(row.judgedKoglType())); |
|
| 82 | 102 |
item.setJudgedAiType(row.judgedAiType()); |
| 83 | 103 |
item.setOpinion(row.opinion()); |
| 84 | 104 |
item.setLawyerNote(row.lawyerNote()); |
--- src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
+++ src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
... | ... | @@ -29,7 +29,8 @@ |
| 29 | 29 |
int updateJudgmentBulk(@Param("orgId") Long orgId, @Param("ids") List<Long> ids,
|
| 30 | 30 |
@Param("reviewResult") String reviewResult,
|
| 31 | 31 |
@Param("judgedKoglType") String judgedKoglType,
|
| 32 |
- @Param("opinion") String opinion);
|
|
| 32 |
+ @Param("opinion") String opinion,
|
|
| 33 |
+ @Param("lawyerNote") String lawyerNote);
|
|
| 33 | 34 |
|
| 34 | 35 |
/** 일괄 삭제. 목록에서 여러 건을 골라 지울 때 쓴다. */ |
| 35 | 36 |
int deleteByIds(@Param("orgId") Long orgId, @Param("ids") List<Long> ids);
|
--- src/main/java/kr/itn/itnhub/review/ReviewService.java
+++ src/main/java/kr/itn/itnhub/review/ReviewService.java
... | ... | @@ -65,7 +65,8 @@ |
| 65 | 65 |
return mapper.updateJudgmentBulk(orgId, request.ids(), |
| 66 | 66 |
blankToNull(request.reviewResult()), |
| 67 | 67 |
blankToNull(request.judgedKoglType()), |
| 68 |
- blankToNull(request.opinion())); |
|
| 68 |
+ blankToNull(request.opinion()), |
|
| 69 |
+ blankToNull(request.lawyerNote())); |
|
| 69 | 70 |
} |
| 70 | 71 |
|
| 71 | 72 |
/** 목록에서 고른 여러 건을 지운다. */ |
+++ src/main/java/kr/itn/itnhub/stage/AutoStage.java
... | ... | @@ -0,0 +1,25 @@ |
| 1 | +package kr.itn.itnhub.stage; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 자료가 올라온 것을 계기로 저절로 넘어가는 단계(요구사항 [18]). | |
| 5 | + * | |
| 6 | + * <p>기능확인 자료의 지시를 이 시스템의 실제 흐름에 맞춰 옮긴 것이다. | |
| 7 | + * 어느 자료가 어느 단계에 해당하는지는 답변 문서에 적어 확인 요청해 두었다.</p> | |
| 8 | + */ | |
| 9 | +public final class AutoStage { | |
| 10 | + | |
| 11 | + private AutoStage() { | |
| 12 | + } | |
| 13 | + | |
| 14 | + /** ②목록접수 — 기관 채널에 자료가 올라가고 공지로 고정까지 됐을 때. */ | |
| 15 | + public static final int LIST_RECEIVED = 2; | |
| 16 | + public static final String REASON_LIST_RECEIVED = "채널에 자료 업로드 + 공지 고정"; | |
| 17 | + | |
| 18 | + /** ⑤법률검토(권리확인) — 권리확인 자료가 올라갔을 때. */ | |
| 19 | + public static final int REVIEW_STARTED = 5; | |
| 20 | + public static final String REASON_REVIEW_STARTED = "권리확인 자료 업로드"; | |
| 21 | + | |
| 22 | + /** ⑧법률검토(권리처리) — 권리처리 자료가 올라갔을 때. */ | |
| 23 | + public static final int PROCESS_STARTED = 8; | |
| 24 | + public static final String REASON_PROCESS_STARTED = "권리처리 자료 업로드"; | |
| 25 | +} |
--- src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
... | ... | @@ -11,7 +11,8 @@ |
| 11 | 11 |
/** 최신순. */ |
| 12 | 12 |
List<StageHistoryRow> findByOrg(@Param("orgId") Long orgId);
|
| 13 | 13 |
|
| 14 |
- int insert(@Param("orgId") Long orgId, @Param("stage") int stage);
|
|
| 14 |
+ int insert(@Param("orgId") Long orgId, @Param("stage") int stage,
|
|
| 15 |
+ @Param("auto") boolean auto, @Param("reason") String reason);
|
|
| 15 | 16 |
|
| 16 | 17 |
/** 채널 초기화용. 단계가 null로 돌아가므로 이력을 남겨두면 어긋난다. */ |
| 17 | 18 |
int deleteByOrg(@Param("orgId") Long orgId);
|
--- src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
... | ... | @@ -5,10 +5,20 @@ |
| 5 | 5 |
|
| 6 | 6 |
private int stage; |
| 7 | 7 |
private long changedAt; |
| 8 |
+ /** 프로그램이 저절로 넘긴 것인지(요구사항 [19]: 이력에 자동/수동 구분 표시). */ |
|
| 9 |
+ private boolean auto; |
|
| 10 |
+ /** 자동으로 넘어간 이유. 사람이 눌렀으면 null. */ |
|
| 11 |
+ private String reason; |
|
| 8 | 12 |
|
| 9 | 13 |
public int getStage() { return stage; }
|
| 10 | 14 |
public void setStage(int stage) { this.stage = stage; }
|
| 11 | 15 |
|
| 12 | 16 |
public long getChangedAt() { return changedAt; }
|
| 13 | 17 |
public void setChangedAt(long changedAt) { this.changedAt = changedAt; }
|
| 18 |
+ |
|
| 19 |
+ public boolean isAuto() { return auto; }
|
|
| 20 |
+ public void setAuto(boolean auto) { this.auto = auto; }
|
|
| 21 |
+ |
|
| 22 |
+ public String getReason() { return reason; }
|
|
| 23 |
+ public void setReason(String reason) { this.reason = reason; }
|
|
| 14 | 24 |
} |
--- src/main/java/kr/itn/itnhub/stage/StageService.java
+++ src/main/java/kr/itn/itnhub/stage/StageService.java
... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 |
package kr.itn.itnhub.stage; |
| 2 | 2 |
|
| 3 |
+import kr.itn.itnhub.code.CodeService; |
|
| 4 |
+import kr.itn.itnhub.code.Codes; |
|
| 3 | 5 |
import kr.itn.itnhub.org.OrgNotFoundException; |
| 4 | 6 |
import kr.itn.itnhub.org.OrgResponse; |
| 5 | 7 |
import kr.itn.itnhub.org.Organization; |
... | ... | @@ -17,10 +19,13 @@ |
| 17 | 19 |
|
| 18 | 20 |
private final OrganizationMapper orgMapper; |
| 19 | 21 |
private final StageHistoryMapper historyMapper; |
| 22 |
+ private final CodeService codeService; |
|
| 20 | 23 |
|
| 21 |
- public StageService(OrganizationMapper orgMapper, StageHistoryMapper historyMapper) {
|
|
| 24 |
+ public StageService(OrganizationMapper orgMapper, StageHistoryMapper historyMapper, |
|
| 25 |
+ CodeService codeService) {
|
|
| 22 | 26 |
this.orgMapper = orgMapper; |
| 23 | 27 |
this.historyMapper = historyMapper; |
| 28 |
+ this.codeService = codeService; |
|
| 24 | 29 |
} |
| 25 | 30 |
|
| 26 | 31 |
@Transactional |
... | ... | @@ -30,17 +35,55 @@ |
| 30 | 35 |
throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
|
| 31 | 36 |
} |
| 32 | 37 |
|
| 38 |
+ // 단계 개수는 코드표에서 온다. 예전에는 12가 박혀 있어서 단계를 늘릴 때 |
|
| 39 |
+ // 여기와 메시지까지 같이 고쳐야 했다. |
|
| 40 |
+ int lastStage = codeService.group(Codes.STAGE).size(); |
|
| 33 | 41 |
Integer stage = request.stage(); |
| 34 |
- if (stage == null || stage < 1 || stage > 12) {
|
|
| 35 |
- throw new InvalidStageException("진행단계는 1에서 12 사이의 값이어야 합니다.");
|
|
| 42 |
+ if (stage == null || stage < 1 || stage > lastStage) {
|
|
| 43 |
+ throw new InvalidStageException( |
|
| 44 |
+ "진행단계는 1에서 " + lastStage + " 사이의 값이어야 합니다."); |
|
| 36 | 45 |
} |
| 37 | 46 |
|
| 38 | 47 |
orgMapper.updateStage(orgId, stage); |
| 39 |
- historyMapper.insert(orgId, stage); |
|
| 48 |
+ // 사람이 눌러서 넘긴 것이다(요구사항 [19]: 이력에 자동/수동 구분). |
|
| 49 |
+ historyMapper.insert(orgId, stage, false, null); |
|
| 40 | 50 |
|
| 41 | 51 |
return OrgResponse.of(orgMapper.findById(orgId)); |
| 42 | 52 |
} |
| 43 | 53 |
|
| 54 |
+ /** |
|
| 55 |
+ * 자료가 올라온 것을 계기로 단계를 저절로 넘긴다(요구사항 [18]). |
|
| 56 |
+ * |
|
| 57 |
+ * <p>앞으로만 간다. 이미 목표 단계에 있거나 그보다 앞서 있으면 아무 일도 하지 않는다 - |
|
| 58 |
+ * 자료를 다시 올렸다고 단계가 뒤로 밀리면 안 되기 때문이다.</p> |
|
| 59 |
+ * |
|
| 60 |
+ * <p>채널이 없어 단계가 아직 없는 기관(stage=null)도 건드리지 않는다. 채널 생성 시 |
|
| 61 |
+ * ①신청으로 들어오는 것이 먼저다.</p> |
|
| 62 |
+ * |
|
| 63 |
+ * <p>사람이 이 단계를 다시 되돌리는 것은 막지 않는다(요구사항 [19]: 되돌림 허용). |
|
| 64 |
+ * 진행단계 변경에서 이전 단계를 고르면 그대로 내려간다.</p> |
|
| 65 |
+ * |
|
| 66 |
+ * @return 실제로 넘어갔으면 true |
|
| 67 |
+ */ |
|
| 68 |
+ @Transactional |
|
| 69 |
+ public boolean autoAdvance(Long orgId, int targetStage, String reason) {
|
|
| 70 |
+ Organization org = orgMapper.findById(orgId); |
|
| 71 |
+ if (org == null || org.getStage() == null) {
|
|
| 72 |
+ return false; |
|
| 73 |
+ } |
|
| 74 |
+ if (org.getStage() >= targetStage) {
|
|
| 75 |
+ return false; |
|
| 76 |
+ } |
|
| 77 |
+ int lastStage = codeService.group(Codes.STAGE).size(); |
|
| 78 |
+ if (targetStage < 1 || targetStage > lastStage) {
|
|
| 79 |
+ return false; |
|
| 80 |
+ } |
|
| 81 |
+ |
|
| 82 |
+ orgMapper.updateStage(orgId, targetStage); |
|
| 83 |
+ historyMapper.insert(orgId, targetStage, true, reason); |
|
| 84 |
+ return true; |
|
| 85 |
+ } |
|
| 86 |
+ |
|
| 44 | 87 |
public java.util.List<StageHistoryRow> history(Long orgId) {
|
| 45 | 88 |
if (orgMapper.findById(orgId) == null) {
|
| 46 | 89 |
throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
|
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
... | ... | @@ -44,3 +44,6 @@ |
| 44 | 44 |
admin: |
| 45 | 45 |
username: ${APP_ADMIN_USERNAME}
|
| 46 | 46 |
password: ${APP_ADMIN_PASSWORD}
|
| 47 |
+ # 업무메모·연락이력·보고서에 작성자로 찍히는 이름(요구사항 [22]). |
|
| 48 |
+ # 비워 두면 로그인 아이디가 그대로 찍힌다. |
|
| 49 |
+ display-name: ${APP_ADMIN_DISPLAY_NAME:관리자}
|
+++ src/main/resources/db/consistency/check-orphan-codes.sql
... | ... | @@ -0,0 +1,76 @@ |
| 1 | +-- 운영 DB에 Phase 2(화면이 코드테이블만 보게 되는 단계)를 배포하기 전에 실행한다. | |
| 2 | +-- | |
| 3 | +-- 결과가 한 줄이라도 나오면, 그 값을 active=false 코드로 code 테이블에 추가한 뒤 | |
| 4 | +-- 배포해야 한다. 값을 지우거나 다른 값으로 바꾸지 않는 이유는 이미 그 값으로 저장된 | |
| 5 | +-- 데이터가 있어서, 지우면 기존 화면에서 값이 조용히 사라지기 때문이다. | |
| 6 | +-- | |
| 7 | +-- 실행 예: | |
| 8 | +-- psql -h 192.168.0.60 -U itnhub -d itnhub -f check-orphan-codes.sql | |
| 9 | +-- | |
| 10 | +-- 고아값을 발견했을 때의 보존 예: | |
| 11 | +-- insert into code (group_id, code, label, sort_order, active) | |
| 12 | +-- values ('CONTACT_METHOD', '문자', '문자', 90, false); | |
| 13 | + | |
| 14 | +select 'review_item.review_major' as source, review_major as value, count(*) as cnt | |
| 15 | + from review_item | |
| 16 | + where review_major is not null and review_major <> '' | |
| 17 | + and review_major not in (select code from code where group_id = 'REVIEW_MAJOR') | |
| 18 | + group by review_major | |
| 19 | + | |
| 20 | +union all | |
| 21 | +select 'review_item.review_minor', review_minor, count(*) | |
| 22 | + from review_item | |
| 23 | + where review_minor is not null and review_minor <> '' | |
| 24 | + and review_minor not in (select code from code where group_id = 'REVIEW_MINOR') | |
| 25 | + group by review_minor | |
| 26 | + | |
| 27 | +union all | |
| 28 | +select 'review_item.review_result', review_result, count(*) | |
| 29 | + from review_item | |
| 30 | + where review_result is not null and review_result <> '' | |
| 31 | + and review_result not in (select code from code where group_id = 'REVIEW_RESULT') | |
| 32 | + group by review_result | |
| 33 | + | |
| 34 | +union all | |
| 35 | +select 'review_item.judged_kogl_type', judged_kogl_type, count(*) | |
| 36 | + from review_item | |
| 37 | + where judged_kogl_type is not null and judged_kogl_type <> '' | |
| 38 | + and judged_kogl_type not in (select code from code where group_id = 'KOGL_TYPE') | |
| 39 | + group by judged_kogl_type | |
| 40 | + | |
| 41 | +union all | |
| 42 | +select 'review_item.kogl_type', kogl_type, count(*) | |
| 43 | + from review_item | |
| 44 | + where kogl_type is not null and kogl_type <> '' | |
| 45 | + and kogl_type not in (select code from code where group_id = 'KOGL_TYPE') | |
| 46 | + group by kogl_type | |
| 47 | + | |
| 48 | +union all | |
| 49 | +select 'process_item.process_status', process_status, count(*) | |
| 50 | + from process_item | |
| 51 | + where process_status is not null and process_status <> '' | |
| 52 | + and process_status not in (select code from code where group_id = 'PROCESS_STATUS') | |
| 53 | + group by process_status | |
| 54 | + | |
| 55 | +union all | |
| 56 | +select 'process_item.judged_kogl_type', judged_kogl_type, count(*) | |
| 57 | + from process_item | |
| 58 | + where judged_kogl_type is not null and judged_kogl_type <> '' | |
| 59 | + and judged_kogl_type not in (select code from code where group_id = 'KOGL_TYPE') | |
| 60 | + group by judged_kogl_type | |
| 61 | + | |
| 62 | +union all | |
| 63 | +select 'process_item.prior_kogl_type', prior_kogl_type, count(*) | |
| 64 | + from process_item | |
| 65 | + where prior_kogl_type is not null and prior_kogl_type <> '' | |
| 66 | + and prior_kogl_type not in (select code from code where group_id = 'KOGL_TYPE') | |
| 67 | + group by prior_kogl_type | |
| 68 | + | |
| 69 | +union all | |
| 70 | +select 'contact_log.method', method, count(*) | |
| 71 | + from contact_log | |
| 72 | + where method is not null and method <> '' | |
| 73 | + and method not in (select code from code where group_id = 'CONTACT_METHOD') | |
| 74 | + group by method | |
| 75 | + | |
| 76 | +order by 1, 2; |
+++ src/main/resources/db/migration/V13__common_code.sql
... | ... | @@ -0,0 +1,159 @@ |
| 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); | |
| 32 | + | |
| 33 | + | |
| 34 | +-- ============================================================ | |
| 35 | +-- 시드 | |
| 36 | +-- ============================================================ | |
| 37 | + | |
| 38 | +insert into code_group (group_id, group_name, description, editable) values | |
| 39 | + ('STAGE', '진행단계', 'organization.stage에 번호로 저장. 단계 추가는 마이그레이션으로 한다', false), | |
| 40 | + ('STAGE_GROUP', '업무구간', '칸반 컬럼 색과 카드 모양을 결정한다', false), | |
| 41 | + ('REVIEW_MAJOR', '권리확인 대분류', null, true), | |
| 42 | + ('REVIEW_MINOR', '권리확인 세부', '제3자 권리를 골랐을 때만 노출된다', true), | |
| 43 | + ('REVIEW_RESULT', '권리확인 처리결과', null, true), | |
| 44 | + ('KOGL_TYPE', '공공누리 유형', 'scopes로 사용처를 구분한다', true), | |
| 45 | + ('PROCESS_STATUS', '권리처리 상태', null, false), | |
| 46 | + ('CONTRACT_DOC', '계약 서류', null, true), | |
| 47 | + ('CONTACT_METHOD', '연락 방법', null, true), | |
| 48 | + ('CONTACT_CATEGORY','담당자 구분', null, false), | |
| 49 | + ('ATTACHMENT_YN', '첨부파일 여부', null, false), | |
| 50 | + ('KOGL_ATTACHED', '기존 공공누리 부착 여부', null, false); | |
| 51 | + | |
| 52 | +-- 진행단계. progress는 대시보드 진행상태 필터(신청/진행중/완료)를, | |
| 53 | +-- kpiFrom은 "이 단계부터 해당 KPI에 집계"라는 임계값을 뜻한다. | |
| 54 | +-- 원문자(①②)는 저장하지 않는다 - 단계 번호에서 계산한다. | |
| 55 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 56 | + ('STAGE','1', '신청', 1, '{"groupKey":"intake","progress":"applied"}'::jsonb), | |
| 57 | + ('STAGE','2', '목록접수', 2, '{"groupKey":"intake","progress":"active","kpiFrom":"docSubmitted"}'::jsonb), | |
| 58 | + ('STAGE','3', '예비검토', 3, '{"groupKey":"intake","progress":"active"}'::jsonb), | |
| 59 | + ('STAGE','4', '변호사 배당', 4, '{"groupKey":"assign","progress":"active"}'::jsonb), | |
| 60 | + ('STAGE','5', '법률검토(권리확인)', 5, '{"groupKey":"check","progress":"active"}'::jsonb), | |
| 61 | + ('STAGE','6', 'RE:확인', 6, '{"groupKey":"check","progress":"active"}'::jsonb), | |
| 62 | + ('STAGE','7', '법률검토(권리확인) 완료', 7, '{"groupKey":"check","progress":"active"}'::jsonb), | |
| 63 | + ('STAGE','8', '법률검토(권리처리)', 8, '{"groupKey":"process","progress":"active"}'::jsonb), | |
| 64 | + ('STAGE','9', 'RE:처리', 9, '{"groupKey":"process","progress":"active"}'::jsonb), | |
| 65 | + ('STAGE','10','법률검토(권리처리) 완료',10, '{"groupKey":"process","progress":"active"}'::jsonb), | |
| 66 | + ('STAGE','11','법률검토 최종완료', 11, '{"groupKey":"closing","progress":"active"}'::jsonb), | |
| 67 | + ('STAGE','12','보고서 작성', 12, '{"groupKey":"closing","progress":"active","kpiFrom":"reportWriting"}'::jsonb), | |
| 68 | + ('STAGE','13','보고서 제출 완료', 13, '{"groupKey":"closing","progress":"done","kpiFrom":"completed"}'::jsonb); | |
| 69 | + | |
| 70 | +-- 업무구간. tone은 의미 토큰이며 실제 CSS 클래스는 프론트가 갖는다 - DB에 Tailwind 클래스를 | |
| 71 | +-- 넣으면 화면 스타일을 바꿀 때마다 DB를 고쳐야 한다. | |
| 72 | +-- cardBody는 칸반 카드에 진척 막대를 몇 줄 그릴지를 뜻한다. | |
| 73 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 74 | + ('STAGE_GROUP','intake', '접수', 1, '{"tone":"slate","cardBody":"plain"}'::jsonb), | |
| 75 | + ('STAGE_GROUP','assign', '배당', 2, '{"tone":"violet","cardBody":"assign"}'::jsonb), | |
| 76 | + ('STAGE_GROUP','check', '권리확인',3, '{"tone":"sky","cardBody":"review1"}'::jsonb), | |
| 77 | + ('STAGE_GROUP','process','권리처리',4, '{"tone":"amber","cardBody":"review2"}'::jsonb), | |
| 78 | + ('STAGE_GROUP','closing','마감', 5, '{"tone":"emerald","cardBody":"plain"}'::jsonb); | |
| 79 | + | |
| 80 | +-- 권리확인 대분류. suggest는 이 대분류를 고르면 자동으로 채워줄 처리결과, | |
| 81 | +-- hasMinor는 세부항목을 노출할지를 뜻한다(제3자 권리에서만 세부가 나온다). | |
| 82 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 83 | + ('REVIEW_MAJOR','만료 저작물', '만료 저작물', 1, '{"suggest":"신유형 개방","hasMinor":false}'::jsonb), | |
| 84 | + ('REVIEW_MAJOR','업무상 저작물','업무상 저작물',2, '{"suggest":"신유형 개방","hasMinor":false}'::jsonb), | |
| 85 | + ('REVIEW_MAJOR','제3자 권리', '제3자 권리', 3, '{"hasMinor":true}'::jsonb), | |
| 86 | + ('REVIEW_MAJOR','개인정보 포함','개인정보 포함',4, '{"suggest":"개방불가","hasMinor":false}'::jsonb); | |
| 87 | + | |
| 88 | +-- 권리확인 세부. code는 저장값 원문이고 label은 화면 표시용(짧은 형태)이다. | |
| 89 | +-- note는 라벨 아래 회색 부연설명, allowed는 고를 수 있는 처리결과 제한이다 | |
| 90 | +-- (2026-07-20 정책변경: 3·4번은 추진/미희망 택일, 초상권은 개방불가까지 3택). | |
| 91 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 92 | + ('REVIEW_MINOR','1. 원시적 권리 전부 보유','1. 원시적 권리 전부 보유',1, | |
| 93 | + '{"suggest":"신유형 개방"}'::jsonb), | |
| 94 | + ('REVIEW_MINOR','2. 후천적 권리 전부 보유(계약서 확인 필요)','2. 후천적 권리 전부 보유',2, | |
| 95 | + '{"note":"(계약에 의한 전부 양수)","suggest":"계약서 등 재확인"}'::jsonb), | |
| 96 | + ('REVIEW_MINOR','3. 권리 일부(공동) 보유','3. 권리 일부(공동) 보유',3, | |
| 97 | + '{"note":"(보도자료·제3자저작물, 계약에 의한 전부/공동 보유 등)","suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망"]}'::jsonb), | |
| 98 | + ('REVIEW_MINOR','4. 권리 미보유','4. 권리 미보유',4, | |
| 99 | + '{"note":"(공모전 수상작 등)","suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망"]}'::jsonb), | |
| 100 | + ('REVIEW_MINOR','초상권 포함','초상권 포함',5, | |
| 101 | + '{"suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망","개방불가"]}'::jsonb); | |
| 102 | + | |
| 103 | +-- 권리확인 처리결과. needsProcessing/koglSet은 값이고, 실제로 폼을 어떻게 채울지는 코드가 정한다. | |
| 104 | +-- koglCopiesPrior가 참이면 기존 부착 유형을 복사하고 비고에 안내문구를 덧붙인다 - 이 "동작"까지 | |
| 105 | +-- 데이터로 옮기면 attrs 안에 규칙 언어를 만들게 되므로 코드에 남긴다. | |
| 106 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 107 | + ('REVIEW_RESULT','신유형 개방', '신유형 개방', 1,'{"tone":"emerald","openable":"Y"}'::jsonb), | |
| 108 | + ('REVIEW_RESULT','계약서 등 재확인', '계약서 등 재확인', 2,'{"tone":"amber"}'::jsonb), | |
| 109 | + ('REVIEW_RESULT','권리처리 추진', '권리처리 추진', 3,'{"tone":"blue","needsProcessing":true,"koglSet":"보류"}'::jsonb), | |
| 110 | + ('REVIEW_RESULT','개방불가', '개방불가', 4,'{"tone":"red","needsProcessing":false,"koglSet":"개방불가"}'::jsonb), | |
| 111 | + ('REVIEW_RESULT','권리처리 추진 미희망','권리처리 추진 미희망',5,'{"tone":"slate","needsProcessing":false,"koglCopiesPrior":true}'::jsonb); | |
| 112 | + | |
| 113 | +-- 공공누리 유형. 세 화면의 목록이 서로 달랐던 이유는 쓰이는 자리가 달라서다. | |
| 114 | +-- review = 변호사 판정값 / process = 권리처리 판정값 / prior = 기존에 부착돼 있던 유형 | |
| 115 | +-- 이 구분 없이 하나로 합치면 권리처리 화면에 '보류'가 잘못 노출된다. | |
| 116 | +-- firstRow는 라디오 첫 줄에 놓을 자리를 뜻한다. | |
| 117 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 118 | + ('KOGL_TYPE','0유형', '0유형', 1,'{"scopes":["review","process","prior"],"firstRow":["review","process"]}'::jsonb), | |
| 119 | + ('KOGL_TYPE','1유형', '1유형', 2,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb), | |
| 120 | + ('KOGL_TYPE','2유형', '2유형', 3,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb), | |
| 121 | + ('KOGL_TYPE','3유형', '3유형', 4,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb), | |
| 122 | + ('KOGL_TYPE','4유형', '4유형', 5,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb), | |
| 123 | + ('KOGL_TYPE','개방불가','개방불가',6,'{"scopes":["review","process"],"firstRow":["review","process"]}'::jsonb), | |
| 124 | + ('KOGL_TYPE','보류', '보류', 7,'{"scopes":["review"],"firstRow":["review"]}'::jsonb); | |
| 125 | + | |
| 126 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 127 | + ('PROCESS_STATUS','미처리', '미처리', 1,'{"done":false}'::jsonb), | |
| 128 | + ('PROCESS_STATUS','처리완료','처리완료',2,'{"done":true,"stampsProcessedAt":true}'::jsonb); | |
| 129 | + | |
| 130 | +-- freeText가 참인 코드는 '기타:자유텍스트' 형태로 직렬화된다(직렬화 규칙 자체는 코드). | |
| 131 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 132 | + ('CONTRACT_DOC','양도계약서', '양도계약서', 1,'{}'::jsonb), | |
| 133 | + ('CONTRACT_DOC','제안요청서', '제안요청서', 2,'{}'::jsonb), | |
| 134 | + ('CONTRACT_DOC','초상이용동의서','초상이용동의서',3,'{}'::jsonb), | |
| 135 | + ('CONTRACT_DOC','공공누리동의서','공공누리동의서',4,'{}'::jsonb), | |
| 136 | + ('CONTRACT_DOC','공문', '공문', 5,'{}'::jsonb), | |
| 137 | + ('CONTRACT_DOC','기타', '기타', 6,'{"freeText":true}'::jsonb); | |
| 138 | + | |
| 139 | +-- 요구사항 [24]는 미응답이라 현행 4종을 그대로 옮긴다. 답이 오면 데이터만 바꾸면 된다. | |
| 140 | +-- 지금까지 서버 검증이 없어 아무 값이나 저장됐는데, 코드테이블 도입으로 검증도 생긴다. | |
| 141 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 142 | + ('CONTACT_METHOD','전화','전화',1,'{}'::jsonb), | |
| 143 | + ('CONTACT_METHOD','메일','메일',2,'{}'::jsonb), | |
| 144 | + ('CONTACT_METHOD','방문','방문',3,'{}'::jsonb), | |
| 145 | + ('CONTACT_METHOD','기타','기타',4,'{}'::jsonb); | |
| 146 | + | |
| 147 | +-- importAlias는 담당자 명부 엑셀의 한글 구분값이다(MemberDirectoryParser가 쓰던 매핑). | |
| 148 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 149 | + ('CONTACT_CATEGORY','APPLICANT','신청기관', 1,'{"tone":"sky","importAlias":"신청기관"}'::jsonb), | |
| 150 | + ('CONTACT_CATEGORY','MJ', '주관기관', 2,'{"tone":"violet","importAlias":"주관기관"}'::jsonb), | |
| 151 | + ('CONTACT_CATEGORY','LAWYER', '변호사', 3,'{"tone":"emerald","importAlias":"변호사"}'::jsonb), | |
| 152 | + ('CONTACT_CATEGORY','OPERATOR', '수행기관', 4,'{"tone":"amber","importAlias":"수행기관"}'::jsonb), | |
| 153 | + ('CONTACT_CATEGORY','ITN', '아이티앤 담당자',5,'{"tone":"slate","importAlias":"아이티앤 담당자"}'::jsonb); | |
| 154 | + | |
| 155 | +insert into code (group_id, code, label, sort_order, attrs) values | |
| 156 | + ('ATTACHMENT_YN','있음','있음',1,'{}'::jsonb), | |
| 157 | + ('ATTACHMENT_YN','없음','없음',2,'{}'::jsonb), | |
| 158 | + ('KOGL_ATTACHED','부착', '부착', 1,'{}'::jsonb), | |
| 159 | + ('KOGL_ATTACHED','미부착','미부착',2,'{}'::jsonb); |
+++ src/main/resources/db/migration/V14__common_code_orphans.sql
... | ... | @@ -0,0 +1,19 @@ |
| 1 | +-- V13 시드를 넣은 뒤 운영 DB에 이미 저장된 값을 훑어보니, 코드테이블에 없는 값이 있었다. | |
| 2 | +-- 화면이 코드테이블만 보게 되는 Phase 2 전에 이 값들을 코드로 등록해 둔다. | |
| 3 | +-- 등록하지 않으면 아래 1,926건이 상세 화면에서 "선택 안 됨"으로 보인다. | |
| 4 | +-- | |
| 5 | +-- 조사 시점(2026-07-28) 운영 DB 실측: | |
| 6 | +-- review_item.kogl_type = '유형 확인 필요' 1,858건 | |
| 7 | +-- review_item.kogl_type = '제4유형' 68건 | |
| 8 | +-- | |
| 9 | +-- 둘 다 기관이 제출한 조사표에서 온 값이라 prior(기존 부착 유형) 자리에만 넣는다. | |
| 10 | +-- 변호사가 고르는 판정값(review/process)에는 넣지 않는다. | |
| 11 | +-- | |
| 12 | +-- '제4유형'은 '4유형'과 같은 뜻의 다른 표기다. 값을 통일하는 것은 데이터 변경이라 | |
| 13 | +-- 여기서 하지 않는다 - 표기 통일 여부는 별도로 확인받는다. | |
| 14 | + | |
| 15 | +insert into code (group_id, code, label, sort_order, active, attrs) values | |
| 16 | + ('KOGL_TYPE','유형 확인 필요','유형 확인 필요', 8, true, | |
| 17 | + '{"scopes":["prior"],"firstRow":[],"surveyOnly":true}'::jsonb), | |
| 18 | + ('KOGL_TYPE','제4유형', '제4유형', 9, true, | |
| 19 | + '{"scopes":["prior"],"firstRow":[],"surveyOnly":true,"sameAs":"4유형"}'::jsonb); |
+++ src/main/resources/db/migration/V15__common_code_fix_display.sql
... | ... | @@ -0,0 +1,37 @@ |
| 1 | +-- V13 시드의 화면 관련 값 두 가지가 실제 화면과 달랐다. Phase 2에서 화면이 코드테이블을 | |
| 2 | +-- 보게 되면 색과 표시명이 조용히 바뀌므로, 지금 값으로 맞춘다. | |
| 3 | +-- | |
| 4 | +-- V13을 직접 고치지 않는 이유: 이미 적용된 마이그레이션을 수정하면 체크섬이 어긋나 | |
| 5 | +-- 다음 기동이 실패한다. 앞으로 나아가는 수정본을 따로 둔다. | |
| 6 | +-- | |
| 7 | +-- 발견 경위: 시드를 넣고 나서 화면 소스의 색 정의와 대조해 보니 어긋나 있었다. | |
| 8 | +-- 시드를 쓸 때 화면을 안 보고 의미만으로 색을 골랐던 것이 원인이다. | |
| 9 | + | |
| 10 | +-- (1) 업무구간 색. 기준은 Dashboard.tsx의 GROUP_COLOR(칸반 컬럼 색 띠)다. | |
| 11 | +-- assign은 indigo, check는 emerald, closing은 violet이 맞다. | |
| 12 | +-- cardBody 속성은 뺀다 - 칸반 카드 본문은 구간이 아니라 단계 번호로 갈리는 자리가 있어 | |
| 13 | +-- (1단계는 신청일만, 2·3단계는 목록 건수) 화면 로직으로 두는 편이 정확하다. | |
| 14 | +update code set attrs = '{"tone":"slate"}'::jsonb where group_id = 'STAGE_GROUP' and code = 'intake'; | |
| 15 | +update code set attrs = '{"tone":"indigo"}'::jsonb where group_id = 'STAGE_GROUP' and code = 'assign'; | |
| 16 | +update code set attrs = '{"tone":"emerald"}'::jsonb where group_id = 'STAGE_GROUP' and code = 'check'; | |
| 17 | +update code set attrs = '{"tone":"amber"}'::jsonb where group_id = 'STAGE_GROUP' and code = 'process'; | |
| 18 | +update code set attrs = '{"tone":"violet"}'::jsonb where group_id = 'STAGE_GROUP' and code = 'closing'; | |
| 19 | + | |
| 20 | +update code_group set description = '칸반 컬럼 색을 결정한다' | |
| 21 | + where group_id = 'STAGE_GROUP'; | |
| 22 | + | |
| 23 | +-- (2) 담당자 구분. MJ는 화면에 '문정원'으로 나오는데 시드에 '주관기관'을 넣었다. | |
| 24 | +-- '주관기관'은 담당자 명부 엑셀에만 쓰이는 표기라 importAlias 자리로 간다. | |
| 25 | +-- 뱃지 색 기준은 ContactsPage.tsx의 CATEGORY_BADGE_STYLES다. | |
| 26 | +update code set label = '문정원' where group_id = 'CONTACT_CATEGORY' and code = 'MJ'; | |
| 27 | + | |
| 28 | +update code set attrs = attrs || '{"tone":"blue"}'::jsonb | |
| 29 | + where group_id = 'CONTACT_CATEGORY' and code = 'APPLICANT'; | |
| 30 | +update code set attrs = attrs || '{"tone":"emerald"}'::jsonb | |
| 31 | + where group_id = 'CONTACT_CATEGORY' and code = 'MJ'; | |
| 32 | +update code set attrs = attrs || '{"tone":"violet"}'::jsonb | |
| 33 | + where group_id = 'CONTACT_CATEGORY' and code = 'LAWYER'; | |
| 34 | +update code set attrs = attrs || '{"tone":"sky"}'::jsonb | |
| 35 | + where group_id = 'CONTACT_CATEGORY' and code = 'OPERATOR'; | |
| 36 | +update code set attrs = attrs || '{"tone":"orange"}'::jsonb | |
| 37 | + where group_id = 'CONTACT_CATEGORY' and code = 'ITN'; |
+++ src/main/resources/db/migration/V16__re_memo.sql
... | ... | @@ -0,0 +1,24 @@ |
| 1 | +-- RE 메모(요구사항 [4][5]). | |
| 2 | +-- | |
| 3 | +-- 회신 내용: "별도 양식까진 필요없고, 기관관리 메뉴의 RE 메뉴에서 메모장처럼 등록하는 | |
| 4 | +-- 기능을 만들어 주시면 됩니다. 메모 1개당 RE 1개입니다." | |
| 5 | +-- 예) ooo 계약서 및 제안요청서 송부 요청, 장영익, 2026.07.08 | |
| 6 | +-- | |
| 7 | +-- resolved(해결 여부)는 회신에 없던 항목이다. 대시보드 '미해결 RE' 숫자를 세려면 해결된 | |
| 8 | +-- 건과 아닌 건을 가릴 수단이 있어야 해서 완료 체크 한 칸으로 두었다. 확인 요청해 둔 상태다. | |
| 9 | + | |
| 10 | +create table re_memo ( | |
| 11 | + id bigserial primary key, | |
| 12 | + org_id bigint not null references organization (id) on delete cascade, | |
| 13 | + content text not null, | |
| 14 | + author varchar(100) not null, | |
| 15 | + memo_date date not null, | |
| 16 | + resolved boolean not null default false, | |
| 17 | + resolved_at timestamptz, | |
| 18 | + created_at timestamptz not null default now(), | |
| 19 | + updated_at timestamptz not null default now() | |
| 20 | +); | |
| 21 | + | |
| 22 | +-- 기관별 최신순 조회와 미해결 건수 집계를 함께 받는 인덱스. | |
| 23 | +create index ix_re_memo_org on re_memo (org_id, memo_date desc, id desc); | |
| 24 | +create index ix_re_memo_unresolved on re_memo (org_id) where not resolved; |
+++ src/main/resources/db/migration/V18__normalize_kogl_type.sql
... | ... | @@ -0,0 +1,35 @@ |
| 1 | +-- 공공누리 유형 표기를 「N유형」으로 통일한다. | |
| 2 | +-- | |
| 3 | +-- 기관 조사표에 「제4유형」으로 적혀 온 자료가 68건 있었다. 나머지 자료와 코드표는 전부 | |
| 4 | +-- 「0유형 ~ 4유형」 형태라 같은 유형인데도 다른 값으로 잡혔다. 유형별로 세거나 걸러낼 때 | |
| 5 | +-- 둘이 따로 집계되므로 표기를 맞춘다. | |
| 6 | +-- | |
| 7 | +-- '제숫자유형' 형태만 바꾼다. 「유형 확인 필요」처럼 뜻이 다른 값은 건드리지 않는다. | |
| 8 | + | |
| 9 | +update review_item | |
| 10 | + set kogl_type = regexp_replace(kogl_type, '^제([0-9]+)유형$', '\1유형') | |
| 11 | + where kogl_type ~ '^제[0-9]+유형$'; | |
| 12 | + | |
| 13 | +update review_item | |
| 14 | + set judged_kogl_type = regexp_replace(judged_kogl_type, '^제([0-9]+)유형$', '\1유형') | |
| 15 | + where judged_kogl_type ~ '^제[0-9]+유형$'; | |
| 16 | + | |
| 17 | +update process_item | |
| 18 | + set prior_kogl_type = regexp_replace(prior_kogl_type, '^제([0-9]+)유형$', '\1유형') | |
| 19 | + where prior_kogl_type ~ '^제[0-9]+유형$'; | |
| 20 | + | |
| 21 | +update process_item | |
| 22 | + set review_kogl_type = regexp_replace(review_kogl_type, '^제([0-9]+)유형$', '\1유형') | |
| 23 | + where review_kogl_type ~ '^제[0-9]+유형$'; | |
| 24 | + | |
| 25 | +update process_item | |
| 26 | + set judged_kogl_type = regexp_replace(judged_kogl_type, '^제([0-9]+)유형$', '\1유형') | |
| 27 | + where judged_kogl_type ~ '^제[0-9]+유형$'; | |
| 28 | + | |
| 29 | +-- 「제4유형」은 이제 선택지에서 뺀다. 다만 행을 지우지는 않는다 - | |
| 30 | +-- 기관이 예전 표기 그대로 엑셀을 다시 올릴 수 있고, 그때 sameAs를 보고 「4유형」으로 | |
| 31 | +-- 바꿔 넣어야 하기 때문이다(별칭 역할로 남긴다). | |
| 32 | +update code | |
| 33 | + set active = false, | |
| 34 | + label = '제4유형 (옛 표기 → 4유형)' | |
| 35 | + where group_id = 'KOGL_TYPE' and code = '제4유형'; |
+++ src/main/resources/mapper/CodeMapper.xml
... | ... | @@ -0,0 +1,22 @@ |
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 3 | + "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 4 | +<mapper namespace="kr.itn.itnhub.code.CodeMapper"> | |
| 5 | + | |
| 6 | + <!-- | |
| 7 | + attrs는 jsonb지만 ::text로 문자열로 받는다. 커스텀 TypeHandler를 두면 매퍼마다 | |
| 8 | + 등록을 신경써야 하는데, 코드는 기동 후 한 번만 읽으므로 서비스에서 파싱하는 편이 단순하다. | |
| 9 | + --> | |
| 10 | + <select id="findAllCodes" resultType="kr.itn.itnhub.code.CodeRow"> | |
| 11 | + select group_id, code, label, sort_order, active, attrs::text as attrs_json | |
| 12 | + from code | |
| 13 | + order by group_id, sort_order | |
| 14 | + </select> | |
| 15 | + | |
| 16 | + <select id="findAllGroups" resultType="kr.itn.itnhub.code.CodeGroup"> | |
| 17 | + select group_id, group_name, description, editable | |
| 18 | + from code_group | |
| 19 | + order by group_id | |
| 20 | + </select> | |
| 21 | + | |
| 22 | +</mapper> |
--- src/main/resources/mapper/DashboardMapper.xml
+++ src/main/resources/mapper/DashboardMapper.xml
... | ... | @@ -41,8 +41,7 @@ |
| 41 | 41 |
organization.updated_at만으로는 부족한 이유: 배정·단계·채널 변경 때만 갱신되고 |
| 42 | 42 |
권리확인 업로드나 업무메모 작성은 이 컬럼을 건드리지 않는다. |
| 43 | 43 |
|
| 44 |
- re_count는 RE 테이블이 아직 없어서 0으로 고정한다. RE 모델이 들어오면 여기에 |
|
| 45 |
- 서브쿼리 하나만 붙이면 화면은 그대로 동작한다. |
|
| 44 |
+ re_count는 아직 해결하지 않은 RE 메모 건수다(요구사항 [4]: 메모 1개 = RE 1개). |
|
| 46 | 45 |
--> |
| 47 | 46 |
<select id="findOrgRows" resultMap="orgRowResultMap"> |
| 48 | 47 |
select |
... | ... | @@ -61,8 +60,12 @@ |
| 61 | 60 |
(extract(epoch from sh.stage_at) * 1000)::bigint as stage_changed_at, |
| 62 | 61 |
(extract(epoch from greatest(o.updated_at, sh.any_at, rv.last_at, pr.last_at, wm.last_at)) * 1000)::bigint |
| 63 | 62 |
as last_changed_at, |
| 64 |
- 0 as re_count |
|
| 63 |
+ coalesce(re.unresolved, 0) as re_count |
|
| 65 | 64 |
from organization o |
| 65 |
+ left join ( |
|
| 66 |
+ select org_id, count(*) as unresolved |
|
| 67 |
+ from re_memo where not resolved group by org_id |
|
| 68 |
+ ) re on re.org_id = o.id |
|
| 66 | 69 |
left join contact lc on lc.id = o.lawyer_contact_id |
| 67 | 70 |
left join contact mc on mc.id = o.mj_contact_id |
| 68 | 71 |
left join ( |
... | ... | @@ -75,7 +78,7 @@ |
| 75 | 78 |
left join ( |
| 76 | 79 |
select org_id, |
| 77 | 80 |
count(*) as total, |
| 78 |
- count(*) filter (where process_status = '처리완료') as done, |
|
| 81 |
+ count(*) filter (where process_status = #{doneStatus}) as done,
|
|
| 79 | 82 |
max(updated_at) as last_at |
| 80 | 83 |
from process_item group by org_id |
| 81 | 84 |
) pr on pr.org_id = o.id |
--- src/main/resources/mapper/ProcessItemMapper.xml
+++ src/main/resources/mapper/ProcessItemMapper.xml
... | ... | @@ -39,10 +39,10 @@ |
| 39 | 39 |
<!-- status: null=전체, "DONE"=처리완료, "PENDING"=그 외(null/빈/미처리 등 처리완료가 아닌 전부). --> |
| 40 | 40 |
<sql id="statusFilter"> |
| 41 | 41 |
<if test="status != null and status == 'DONE'"> |
| 42 |
- and process_status = '처리완료' |
|
| 42 |
+ and process_status = #{doneStatus}
|
|
| 43 | 43 |
</if> |
| 44 | 44 |
<if test="status != null and status == 'PENDING'"> |
| 45 |
- and (process_status is null or process_status = '' or process_status <> '처리완료') |
|
| 45 |
+ and (process_status is null or process_status = '' or process_status <> #{doneStatus})
|
|
| 46 | 46 |
</if> |
| 47 | 47 |
</sql> |
| 48 | 48 |
|
... | ... | @@ -145,7 +145,7 @@ |
| 145 | 145 |
process_status = #{processStatus},
|
| 146 | 146 |
web_edited_at = now(), |
| 147 | 147 |
processed_at = case |
| 148 |
- when #{processStatus} = '처리완료' and process_item.processed_at is null then now()
|
|
| 148 |
+ when #{processStatus} = #{doneStatus} and process_item.processed_at is null then now()
|
|
| 149 | 149 |
else process_item.processed_at |
| 150 | 150 |
end, |
| 151 | 151 |
updated_at = now() |
... | ... | @@ -173,7 +173,8 @@ |
| 173 | 173 |
process_status = coalesce(#{processStatus}, process_status),
|
| 174 | 174 |
judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
|
| 175 | 175 |
final_opinion = coalesce(#{finalOpinion}, final_opinion),
|
| 176 |
- processed_at = case when #{processStatus} = '처리완료' then now() else processed_at end,
|
|
| 176 |
+ review_note = coalesce(#{reviewNote}, review_note),
|
|
| 177 |
+ processed_at = case when #{processStatus} = #{doneStatus} then now() else processed_at end,
|
|
| 177 | 178 |
web_edited_at = now(), |
| 178 | 179 |
updated_at = now() |
| 179 | 180 |
where org_id = #{orgId}
|
... | ... | @@ -191,7 +192,7 @@ |
| 191 | 192 |
<select id="stats" resultMap="statsResultMap"> |
| 192 | 193 |
select |
| 193 | 194 |
count(*) as total, |
| 194 |
- count(*) filter (where process_status = '처리완료') as done |
|
| 195 |
+ count(*) filter (where process_status = #{doneStatus}) as done
|
|
| 195 | 196 |
from process_item |
| 196 | 197 |
where org_id = #{orgId}
|
| 197 | 198 |
</select> |
--- src/main/resources/mapper/ProjectMapper.xml
+++ src/main/resources/mapper/ProjectMapper.xml
... | ... | @@ -71,6 +71,22 @@ |
| 71 | 71 |
where id = #{id} and org_id = #{orgId}
|
| 72 | 72 |
</select> |
| 73 | 73 |
|
| 74 |
+ <!-- |
|
| 75 |
+ 보고서 교체(요구사항 [25]). 같은 자리에 새 파일을 덮어쓰고 올린 사람·시각을 갱신한다. |
|
| 76 |
+ 새 행을 만들지 않는 이유는 "고쳐 올리기"가 개정판 쌓기와 다른 동작이기 때문이다 - |
|
| 77 |
+ 개정판으로 남기고 싶으면 지금처럼 새로 올리면 된다. |
|
| 78 |
+ --> |
|
| 79 |
+ <update id="updateReport"> |
|
| 80 |
+ update org_report set |
|
| 81 |
+ file_name = #{fileName},
|
|
| 82 |
+ content_type = #{contentType},
|
|
| 83 |
+ byte_size = #{byteSize},
|
|
| 84 |
+ content = #{content},
|
|
| 85 |
+ uploaded_by = #{uploadedBy},
|
|
| 86 |
+ uploaded_at = now() |
|
| 87 |
+ where id = #{id} and org_id = #{orgId}
|
|
| 88 |
+ </update> |
|
| 89 |
+ |
|
| 74 | 90 |
<delete id="deleteReport"> |
| 75 | 91 |
delete from org_report where id = #{id} and org_id = #{orgId}
|
| 76 | 92 |
</delete> |
+++ src/main/resources/mapper/ReMemoMapper.xml
... | ... | @@ -0,0 +1,63 @@ |
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 3 | + "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 4 | +<mapper namespace="kr.itn.itnhub.re.ReMemoMapper"> | |
| 5 | + | |
| 6 | + <!-- | |
| 7 | + memo_date는 화면이 그대로 쓰도록 yyyy-MM-dd 문자열로, created_at은 다른 매퍼와 같은 | |
| 8 | + 관례로 밀리초 epoch로 내려준다. | |
| 9 | + --> | |
| 10 | + <sql id="columns"> | |
| 11 | + id, org_id, content, author, | |
| 12 | + to_char(memo_date, 'YYYY-MM-DD') as memo_date, | |
| 13 | + resolved, | |
| 14 | + (extract(epoch from created_at) * 1000)::bigint as created_at | |
| 15 | + </sql> | |
| 16 | + | |
| 17 | + <select id="findByOrg" resultType="kr.itn.itnhub.re.ReMemo"> | |
| 18 | + select <include refid="columns"/> | |
| 19 | + from re_memo | |
| 20 | + where org_id = #{orgId} | |
| 21 | + order by memo_date desc, id desc | |
| 22 | + </select> | |
| 23 | + | |
| 24 | + <select id="findById" resultType="kr.itn.itnhub.re.ReMemo"> | |
| 25 | + select <include refid="columns"/> | |
| 26 | + from re_memo | |
| 27 | + where org_id = #{orgId} and id = #{id} | |
| 28 | + </select> | |
| 29 | + | |
| 30 | + <!-- 일자를 비워 보내면 오늘로 넣는다. --> | |
| 31 | + <insert id="insert"> | |
| 32 | + insert into re_memo (org_id, content, author, memo_date, resolved, resolved_at) | |
| 33 | + values (#{orgId}, #{content}, #{author}, | |
| 34 | + coalesce(cast(#{memoDate} as date), current_date), | |
| 35 | + #{resolved}, | |
| 36 | + case when #{resolved} then now() else null end) | |
| 37 | + </insert> | |
| 38 | + | |
| 39 | + <!-- 해결로 바뀌는 순간만 resolved_at을 찍고, 되돌리면 지운다. --> | |
| 40 | + <update id="update"> | |
| 41 | + update re_memo set | |
| 42 | + content = #{content}, | |
| 43 | + author = #{author}, | |
| 44 | + memo_date = coalesce(cast(#{memoDate} as date), memo_date), | |
| 45 | + resolved = #{resolved}, | |
| 46 | + resolved_at = case | |
| 47 | + when #{resolved} and resolved_at is null then now() | |
| 48 | + when not #{resolved} then null | |
| 49 | + else resolved_at | |
| 50 | + end, | |
| 51 | + updated_at = now() | |
| 52 | + where org_id = #{orgId} and id = #{id} | |
| 53 | + </update> | |
| 54 | + | |
| 55 | + <delete id="delete"> | |
| 56 | + delete from re_memo where org_id = #{orgId} and id = #{id} | |
| 57 | + </delete> | |
| 58 | + | |
| 59 | + <delete id="deleteByOrg"> | |
| 60 | + delete from re_memo where org_id = #{orgId} | |
| 61 | + </delete> | |
| 62 | + | |
| 63 | +</mapper> |
--- src/main/resources/mapper/ReviewItemMapper.xml
+++ src/main/resources/mapper/ReviewItemMapper.xml
... | ... | @@ -153,6 +153,7 @@ |
| 153 | 153 |
review_result = coalesce(#{reviewResult}, review_result),
|
| 154 | 154 |
judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
|
| 155 | 155 |
opinion = coalesce(#{opinion}, opinion),
|
| 156 |
+ lawyer_note = coalesce(#{lawyerNote}, lawyer_note),
|
|
| 156 | 157 |
web_edited_at = now(), |
| 157 | 158 |
updated_at = now() |
| 158 | 159 |
where org_id = #{orgId}
|
--- src/main/resources/mapper/StageHistoryMapper.xml
+++ src/main/resources/mapper/StageHistoryMapper.xml
... | ... | @@ -7,15 +7,17 @@ |
| 7 | 7 |
changed_at을 밀리초 epoch로 내려준다 - WorkMemoMapper.created_at과 동일한 관례. |
| 8 | 8 |
--> |
| 9 | 9 |
<select id="findByOrg" resultType="kr.itn.itnhub.stage.StageHistoryRow"> |
| 10 |
- select stage, (extract(epoch from changed_at) * 1000)::bigint as changed_at |
|
| 10 |
+ select stage, (extract(epoch from changed_at) * 1000)::bigint as changed_at, |
|
| 11 |
+ auto, reason |
|
| 11 | 12 |
from stage_history |
| 12 | 13 |
where org_id = #{orgId}
|
| 13 | 14 |
order by changed_at desc, id desc |
| 14 | 15 |
</select> |
| 15 | 16 |
|
| 17 |
+ <!-- auto=true면 프로그램이 저절로 넘긴 것이다(요구사항 [18][19]). reason에 계기를 남긴다. --> |
|
| 16 | 18 |
<insert id="insert"> |
| 17 |
- insert into stage_history (org_id, stage) |
|
| 18 |
- values (#{orgId}, #{stage})
|
|
| 19 |
+ insert into stage_history (org_id, stage, auto, reason) |
|
| 20 |
+ values (#{orgId}, #{stage}, #{auto}, #{reason})
|
|
| 19 | 21 |
</insert> |
| 20 | 22 |
|
| 21 | 23 |
<!-- |
+++ src/test/java/kr/itn/itnhub/code/CodeConsistencyTest.java
... | ... | @@ -0,0 +1,73 @@ |
| 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.jdbc.core.JdbcTemplate; | |
| 7 | + | |
| 8 | +import java.util.List; | |
| 9 | + | |
| 10 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * 업무 테이블에 저장된 값이 코드테이블에 다 있는지 확인한다. | |
| 14 | + * | |
| 15 | + * <p>운영 DB에는 정책 변경 전 값이나 공백만 다른 값이 남아 있을 수 있다. Phase 2에서 | |
| 16 | + * 화면이 코드테이블만 보게 바뀌면 그런 값은 선택지에서 조용히 사라진다. 배포 전에 | |
| 17 | + * {@code db/consistency/check-orphan-codes.sql}을 돌려 발견된 값을 active=false 코드로 | |
| 18 | + * 추가해야 한다 - 지우지 않고 보존하는 이유는 이미 그 값으로 저장된 데이터가 있기 때문이다.</p> | |
| 19 | + */ | |
| 20 | +class CodeConsistencyTest extends AbstractDbTest { | |
| 21 | + | |
| 22 | + @Autowired | |
| 23 | + JdbcTemplate jdbc; | |
| 24 | + | |
| 25 | + @Autowired | |
| 26 | + CodeService codeService; | |
| 27 | + | |
| 28 | + @Test | |
| 29 | + void 저장된_권리확인_값이_모두_코드테이블에_있다() { | |
| 30 | + assertThat(orphans("review_item", "review_major", Codes.REVIEW_MAJOR)).isEmpty(); | |
| 31 | + assertThat(orphans("review_item", "review_minor", Codes.REVIEW_MINOR)).isEmpty(); | |
| 32 | + assertThat(orphans("review_item", "review_result", Codes.REVIEW_RESULT)).isEmpty(); | |
| 33 | + assertThat(orphans("review_item", "judged_kogl_type", Codes.KOGL_TYPE)).isEmpty(); | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Test | |
| 37 | + void 저장된_권리처리_값이_모두_코드테이블에_있다() { | |
| 38 | + assertThat(orphans("process_item", "process_status", Codes.PROCESS_STATUS)).isEmpty(); | |
| 39 | + assertThat(orphans("process_item", "judged_kogl_type", Codes.KOGL_TYPE)).isEmpty(); | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Test | |
| 43 | + void 저장된_연락방법이_모두_코드테이블에_있다() { | |
| 44 | + assertThat(orphans("contact_log", "method", Codes.CONTACT_METHOD)).isEmpty(); | |
| 45 | + } | |
| 46 | + | |
| 47 | + @Test | |
| 48 | + void 점검이_고아값을_실제로_찾아낸다() { | |
| 49 | + jdbc.update("insert into organization (org_no, org_name, channel_slug) " | |
| 50 | + + "values ('900', '정합성테스트기관', '900')"); | |
| 51 | + Long orgId = jdbc.queryForObject( | |
| 52 | + "select id from organization where org_no = '900'", Long.class); | |
| 53 | + jdbc.update("insert into contact_log (org_id, contacted_on, method, summary, author) " | |
| 54 | + + "values (?, current_date, '텔레그램', '코드테이블에 없는 값', 'admin')", orgId); | |
| 55 | + | |
| 56 | + assertThat(orphans("contact_log", "method", Codes.CONTACT_METHOD)) | |
| 57 | + .containsExactly("텔레그램"); | |
| 58 | + | |
| 59 | + jdbc.update("delete from contact_log where org_id = ?", orgId); | |
| 60 | + jdbc.update("delete from organization where id = ?", orgId); | |
| 61 | + } | |
| 62 | + | |
| 63 | + /** 해당 컬럼에 저장돼 있으나 코드테이블에 없는 값. */ | |
| 64 | + private List<String> orphans(String table, String column, String groupId) { | |
| 65 | + List<String> stored = jdbc.queryForList( | |
| 66 | + "select distinct " + column + " from " + table | |
| 67 | + + " where " + column + " is not null and " + column + " <> ''", | |
| 68 | + String.class); | |
| 69 | + return stored.stream() | |
| 70 | + .filter(value -> codeService.find(groupId, value).isEmpty()) | |
| 71 | + .toList(); | |
| 72 | + } | |
| 73 | +} |
+++ src/test/java/kr/itn/itnhub/code/CodeControllerTest.java
... | ... | @@ -0,0 +1,46 @@ |
| 1 | +package kr.itn.itnhub.code; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 7 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 8 | +import org.springframework.boot.test.mock.mockito.MockBean; | |
| 9 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 10 | +import org.springframework.test.web.servlet.MockMvc; | |
| 11 | + | |
| 12 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 13 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 14 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 15 | + | |
| 16 | +@AutoConfigureMockMvc | |
| 17 | +class CodeControllerTest extends AbstractDbTest { | |
| 18 | + | |
| 19 | + @Autowired | |
| 20 | + MockMvc mvc; | |
| 21 | + | |
| 22 | + @MockBean | |
| 23 | + MattermostClient mattermost; | |
| 24 | + | |
| 25 | + @Test | |
| 26 | + @WithMockUser(roles = "ADMIN") | |
| 27 | + void 전체_코드를_그룹별로_내려준다() throws Exception { | |
| 28 | + mvc.perform(get("/api/meta/codes")) | |
| 29 | + .andExpect(status().isOk()) | |
| 30 | + .andExpect(jsonPath("$.STAGE.length()").value(13)) | |
| 31 | + .andExpect(jsonPath("$.STAGE[12].code").value("13")) | |
| 32 | + .andExpect(jsonPath("$.STAGE[12].label").value("보고서 제출 완료")) | |
| 33 | + .andExpect(jsonPath("$.STAGE[12].attrs.progress").value("done")) | |
| 34 | + // 0~4유형 + 개방불가 + 보류 + 조사표에서 온 '유형 확인 필요'. | |
| 35 | + // '제4유형'은 '4유형'으로 통일해 선택지에서 빠졌다(V18). | |
| 36 | + .andExpect(jsonPath("$.KOGL_TYPE.length()").value(8)) | |
| 37 | + .andExpect(jsonPath("$.CONTACT_CATEGORY[0].code").value("APPLICANT")) | |
| 38 | + .andExpect(jsonPath("$.CONTACT_CATEGORY[0].label").value("신청기관")); | |
| 39 | + } | |
| 40 | + | |
| 41 | + @Test | |
| 42 | + void 인증이_없으면_401이다() throws Exception { | |
| 43 | + mvc.perform(get("/api/meta/codes")) | |
| 44 | + .andExpect(status().isUnauthorized()); | |
| 45 | + } | |
| 46 | +} |
+++ src/test/java/kr/itn/itnhub/code/CodeMapperTest.java
... | ... | @@ -0,0 +1,63 @@ |
| 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 | + | |
| 7 | +import java.util.List; | |
| 8 | + | |
| 9 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 10 | + | |
| 11 | +class CodeMapperTest extends AbstractDbTest { | |
| 12 | + | |
| 13 | + @Autowired | |
| 14 | + CodeMapper mapper; | |
| 15 | + | |
| 16 | + @Test | |
| 17 | + void 모든_코드를_그룹과_정렬순서대로_읽는다() { | |
| 18 | + List<CodeRow> rows = mapper.findAllCodes(); | |
| 19 | + | |
| 20 | + assertThat(rows).isNotEmpty(); | |
| 21 | + List<CodeRow> stages = rows.stream().filter(r -> r.groupId().equals("STAGE")).toList(); | |
| 22 | + assertThat(stages).hasSize(13); | |
| 23 | + assertThat(stages.get(0).code()).isEqualTo("1"); | |
| 24 | + assertThat(stages.get(12).label()).isEqualTo("보고서 제출 완료"); | |
| 25 | + } | |
| 26 | + | |
| 27 | + @Test | |
| 28 | + void attrs를_JSON_문자열로_읽는다() { | |
| 29 | + CodeRow stage13 = mapper.findAllCodes().stream() | |
| 30 | + .filter(r -> r.groupId().equals("STAGE") && r.code().equals("13")) | |
| 31 | + .findFirst().orElseThrow(); | |
| 32 | + | |
| 33 | + assertThat(stage13.attrsJson()) | |
| 34 | + .contains("progress") | |
| 35 | + .contains("done") | |
| 36 | + .contains("completed"); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + void 비활성_코드도_함께_읽는다() { | |
| 41 | + // 활성 필터는 CodeService가 용도에 따라 건다. 매퍼는 전부 내려줘야 | |
| 42 | + // 과거 데이터의 라벨을 그릴 수 있다. | |
| 43 | + List<CodeRow> rows = mapper.findAllCodes(); | |
| 44 | + | |
| 45 | + assertThat(rows).allSatisfy(r -> assertThat(r.groupId()).isNotBlank()); | |
| 46 | + assertThat(rows).anySatisfy(r -> assertThat(r.active()).isTrue()); | |
| 47 | + } | |
| 48 | + | |
| 49 | + @Test | |
| 50 | + void 그룹_목록을_읽는다() { | |
| 51 | + List<CodeGroup> groups = mapper.findAllGroups(); | |
| 52 | + | |
| 53 | + assertThat(groups).hasSize(12); | |
| 54 | + assertThat(groups).anySatisfy(g -> { | |
| 55 | + assertThat(g.groupId()).isEqualTo("STAGE"); | |
| 56 | + assertThat(g.editable()).isFalse(); | |
| 57 | + }); | |
| 58 | + assertThat(groups).anySatisfy(g -> { | |
| 59 | + assertThat(g.groupId()).isEqualTo("CONTACT_METHOD"); | |
| 60 | + assertThat(g.editable()).isTrue(); | |
| 61 | + }); | |
| 62 | + } | |
| 63 | +} |
+++ 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 | +} |
+++ src/test/java/kr/itn/itnhub/code/CodeSeedTest.java
... | ... | @@ -0,0 +1,250 @@ |
| 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.jdbc.core.JdbcTemplate; | |
| 7 | + | |
| 8 | +import java.util.List; | |
| 9 | + | |
| 10 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * 시드가 설계서대로 들어갔는지 확인한다. 코드값이 데이터가 된 이상 오타 하나가 화면 선택지를 | |
| 14 | + * 통째로 날리므로, 그룹 간 참조가 맞물리는지까지 여기서 잡는다. | |
| 15 | + */ | |
| 16 | +class CodeSeedTest extends AbstractDbTest { | |
| 17 | + | |
| 18 | + @Autowired | |
| 19 | + JdbcTemplate jdbc; | |
| 20 | + | |
| 21 | + @Test | |
| 22 | + void 진행단계는_13개이며_13번이_보고서_제출_완료다() { | |
| 23 | + List<String> labels = jdbc.queryForList( | |
| 24 | + "select label from code where group_id = 'STAGE' order by sort_order", String.class); | |
| 25 | + | |
| 26 | + assertThat(labels).hasSize(13); | |
| 27 | + assertThat(labels.get(0)).isEqualTo("신청"); | |
| 28 | + assertThat(labels.get(11)).isEqualTo("보고서 작성"); | |
| 29 | + assertThat(labels.get(12)).isEqualTo("보고서 제출 완료"); | |
| 30 | + } | |
| 31 | + | |
| 32 | + @Test | |
| 33 | + void 진행상태는_1번만_신청이고_13번만_완료다() { | |
| 34 | + List<String> applied = jdbc.queryForList( | |
| 35 | + "select code from code where group_id = 'STAGE' and attrs->>'progress' = 'applied'", | |
| 36 | + String.class); | |
| 37 | + List<String> done = jdbc.queryForList( | |
| 38 | + "select code from code where group_id = 'STAGE' and attrs->>'progress' = 'done'", | |
| 39 | + String.class); | |
| 40 | + Integer active = jdbc.queryForObject( | |
| 41 | + "select count(*) from code where group_id = 'STAGE' and attrs->>'progress' = 'active'", | |
| 42 | + Integer.class); | |
| 43 | + | |
| 44 | + assertThat(applied).containsExactly("1"); | |
| 45 | + assertThat(done).containsExactly("13"); | |
| 46 | + assertThat(active).isEqualTo(11); | |
| 47 | + } | |
| 48 | + | |
| 49 | + @Test | |
| 50 | + void KPI_임계값이_세_개_지정되어_있다() { | |
| 51 | + assertThat(kpiStage("docSubmitted")).isEqualTo("2"); | |
| 52 | + assertThat(kpiStage("reportWriting")).isEqualTo("12"); | |
| 53 | + // 요구사항 [2]: 보고서가 실제로 올라온 기관만 완료로 센다. | |
| 54 | + assertThat(kpiStage("completed")).isEqualTo("13"); | |
| 55 | + } | |
| 56 | + | |
| 57 | + private String kpiStage(String kpi) { | |
| 58 | + return jdbc.queryForObject( | |
| 59 | + "select code from code where group_id = 'STAGE' and attrs->>'kpiFrom' = ?", | |
| 60 | + String.class, kpi); | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Test | |
| 64 | + void 모든_단계가_업무구간에_속한다() { | |
| 65 | + Integer orphan = jdbc.queryForObject( | |
| 66 | + "select count(*) from code s where s.group_id = 'STAGE' " | |
| 67 | + + "and not exists (select 1 from code g where g.group_id = 'STAGE_GROUP' " | |
| 68 | + + " and g.code = s.attrs->>'groupKey')", | |
| 69 | + Integer.class); | |
| 70 | + | |
| 71 | + assertThat(orphan).isZero(); | |
| 72 | + } | |
| 73 | + | |
| 74 | + @Test | |
| 75 | + void 공공누리_유형은_사용처별로_다르게_노출된다() { | |
| 76 | + // 세 화면의 목록이 7개/5개/6개로 달랐던 이유는 쓰이는 자리가 달라서다. | |
| 77 | + assertThat(koglScope("review")).hasSize(7) | |
| 78 | + .doesNotContain("유형 확인 필요") | |
| 79 | + .doesNotContain("제4유형"); | |
| 80 | + assertThat(koglScope("process")).hasSize(6).doesNotContain("보류"); | |
| 81 | + } | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + void 기존_부착유형에는_조사표에서_온_값도_들어간다() { | |
| 85 | + // 운영 DB 실측(2026-07-28): kogl_type에 '유형 확인 필요' 1,858건이 저장돼 있었다. | |
| 86 | + // 코드로 등록해 두지 않으면 상세 화면에서 선택 안 됨으로 보인다. | |
| 87 | + // '제4유형'은 '4유형'으로 통일했으므로 선택지에서는 빠진다(V18). | |
| 88 | + assertThat(koglScope("prior")).containsExactly( | |
| 89 | + "0유형", "1유형", "2유형", "3유형", "4유형", "유형 확인 필요"); | |
| 90 | + } | |
| 91 | + | |
| 92 | + /** | |
| 93 | + * 「제4유형」은 지웠으면 안 된다. 기관이 옛 표기 그대로 엑셀을 다시 올릴 수 있고, | |
| 94 | + * 그때 sameAs를 보고 「4유형」으로 바꿔 넣어야 한다. | |
| 95 | + */ | |
| 96 | + @Test | |
| 97 | + void 통일된_옛_표기는_별칭으로_남아_있다() { | |
| 98 | + String active = jdbc.queryForObject( | |
| 99 | + "select active::text from code where group_id = 'KOGL_TYPE' and code = '제4유형'", | |
| 100 | + String.class); | |
| 101 | + assertThat(active).isEqualTo("false"); | |
| 102 | + assertThat(attr("KOGL_TYPE", "제4유형", "sameAs")).isEqualTo("4유형"); | |
| 103 | + } | |
| 104 | + | |
| 105 | + @Test | |
| 106 | + void 조사표에서_온_값은_변호사_판정_선택지가_아니다() { | |
| 107 | + Integer leaked = jdbc.queryForObject( | |
| 108 | + "select count(*) from code c " | |
| 109 | + + "where c.group_id = 'KOGL_TYPE' " | |
| 110 | + + " and (c.attrs->>'surveyOnly')::boolean is true " | |
| 111 | + + " and exists (select 1 from jsonb_array_elements_text(c.attrs->'scopes') s(v) " | |
| 112 | + + " where s.v in ('review', 'process'))", | |
| 113 | + Integer.class); | |
| 114 | + | |
| 115 | + assertThat(leaked).isZero(); | |
| 116 | + } | |
| 117 | + | |
| 118 | + /** 화면 선택지에 실제로 나가는 것만 본다. 서버는 활성 코드만 내려준다. */ | |
| 119 | + private List<String> koglScope(String scope) { | |
| 120 | + return jdbc.queryForList( | |
| 121 | + "select code from code c where c.group_id = 'KOGL_TYPE' and c.active " | |
| 122 | + + " and exists (select 1 from jsonb_array_elements_text(c.attrs->'scopes') s(v) " | |
| 123 | + + " where s.v = ?) " | |
| 124 | + + "order by sort_order", | |
| 125 | + String.class, scope); | |
| 126 | + } | |
| 127 | + | |
| 128 | + @Test | |
| 129 | + void 세부항목의_허용_처리결과는_실제_처리결과_코드다() { | |
| 130 | + Integer orphan = jdbc.queryForObject( | |
| 131 | + "select count(*) from code m, " | |
| 132 | + + " lateral jsonb_array_elements_text(coalesce(m.attrs->'allowed', '[]'::jsonb)) a(v) " | |
| 133 | + + "where m.group_id = 'REVIEW_MINOR' " | |
| 134 | + + " and not exists (select 1 from code r " | |
| 135 | + + " where r.group_id = 'REVIEW_RESULT' and r.code = a.v)", | |
| 136 | + Integer.class); | |
| 137 | + | |
| 138 | + assertThat(orphan).isZero(); | |
| 139 | + } | |
| 140 | + | |
| 141 | + @Test | |
| 142 | + void 대분류와_세부의_추천_처리결과도_실제_코드다() { | |
| 143 | + Integer orphan = jdbc.queryForObject( | |
| 144 | + "select count(*) from code c " | |
| 145 | + + "where c.group_id in ('REVIEW_MAJOR', 'REVIEW_MINOR') " | |
| 146 | + + " and c.attrs->>'suggest' is not null " | |
| 147 | + + " and not exists (select 1 from code r " | |
| 148 | + + " where r.group_id = 'REVIEW_RESULT' and r.code = c.attrs->>'suggest')", | |
| 149 | + Integer.class); | |
| 150 | + | |
| 151 | + assertThat(orphan).isZero(); | |
| 152 | + } | |
| 153 | + | |
| 154 | + @Test | |
| 155 | + void 처리결과가_지정하는_공공누리_유형도_실제_코드다() { | |
| 156 | + Integer orphan = jdbc.queryForObject( | |
| 157 | + "select count(*) from code c " | |
| 158 | + + "where c.group_id = 'REVIEW_RESULT' " | |
| 159 | + + " and c.attrs->>'koglSet' is not null " | |
| 160 | + + " and not exists (select 1 from code k " | |
| 161 | + + " where k.group_id = 'KOGL_TYPE' and k.code = c.attrs->>'koglSet')", | |
| 162 | + Integer.class); | |
| 163 | + | |
| 164 | + assertThat(orphan).isZero(); | |
| 165 | + } | |
| 166 | + | |
| 167 | + @Test | |
| 168 | + void 열두_그룹이_모두_시드된다() { | |
| 169 | + Integer groups = jdbc.queryForObject("select count(*) from code_group", Integer.class); | |
| 170 | + Integer empty = jdbc.queryForObject( | |
| 171 | + "select count(*) from code_group g " | |
| 172 | + + "where not exists (select 1 from code c where c.group_id = g.group_id)", | |
| 173 | + Integer.class); | |
| 174 | + | |
| 175 | + assertThat(groups).isEqualTo(12); | |
| 176 | + assertThat(empty).isZero(); | |
| 177 | + } | |
| 178 | + | |
| 179 | + /** | |
| 180 | + * tone은 화면에 실제로 쓰이는 색과 같아야 한다. 다르면 Phase 2에서 화면 색이 조용히 바뀐다. | |
| 181 | + * 기준: Dashboard.tsx의 GROUP_COLOR(칸반 컬럼 색 띠). | |
| 182 | + */ | |
| 183 | + @Test | |
| 184 | + void 업무구간_색이_지금_화면과_같다() { | |
| 185 | + assertThat(tone("STAGE_GROUP", "intake")).isEqualTo("slate"); | |
| 186 | + assertThat(tone("STAGE_GROUP", "assign")).isEqualTo("indigo"); | |
| 187 | + assertThat(tone("STAGE_GROUP", "check")).isEqualTo("emerald"); | |
| 188 | + assertThat(tone("STAGE_GROUP", "process")).isEqualTo("amber"); | |
| 189 | + assertThat(tone("STAGE_GROUP", "closing")).isEqualTo("violet"); | |
| 190 | + } | |
| 191 | + | |
| 192 | + /** 기준: ContactsPage.tsx의 CATEGORY_BADGE_STYLES. */ | |
| 193 | + @Test | |
| 194 | + void 담당자구분_색이_지금_화면과_같다() { | |
| 195 | + assertThat(tone("CONTACT_CATEGORY", "APPLICANT")).isEqualTo("blue"); | |
| 196 | + assertThat(tone("CONTACT_CATEGORY", "MJ")).isEqualTo("emerald"); | |
| 197 | + assertThat(tone("CONTACT_CATEGORY", "LAWYER")).isEqualTo("violet"); | |
| 198 | + assertThat(tone("CONTACT_CATEGORY", "OPERATOR")).isEqualTo("sky"); | |
| 199 | + assertThat(tone("CONTACT_CATEGORY", "ITN")).isEqualTo("orange"); | |
| 200 | + } | |
| 201 | + | |
| 202 | + /** 기준: ReviewBoard.tsx의 REVIEW_RESULT_BADGE_STYLES. */ | |
| 203 | + @Test | |
| 204 | + void 처리결과_색이_지금_화면과_같다() { | |
| 205 | + assertThat(tone("REVIEW_RESULT", "신유형 개방")).isEqualTo("emerald"); | |
| 206 | + assertThat(tone("REVIEW_RESULT", "계약서 등 재확인")).isEqualTo("amber"); | |
| 207 | + assertThat(tone("REVIEW_RESULT", "권리처리 추진")).isEqualTo("blue"); | |
| 208 | + assertThat(tone("REVIEW_RESULT", "권리처리 추진 미희망")).isEqualTo("slate"); | |
| 209 | + assertThat(tone("REVIEW_RESULT", "개방불가")).isEqualTo("red"); | |
| 210 | + } | |
| 211 | + | |
| 212 | + /** | |
| 213 | + * MJ는 화면에 '문정원'으로 나오지만 담당자 명부 엑셀에는 '주관기관'으로 적혀 온다. | |
| 214 | + * 이 둘을 헷갈리면 화면 표시가 바뀌거나 엑셀 업로드가 깨진다. | |
| 215 | + */ | |
| 216 | + @Test | |
| 217 | + void 담당자구분은_화면표시와_엑셀표기를_따로_갖는다() { | |
| 218 | + assertThat(label("CONTACT_CATEGORY", "MJ")).isEqualTo("문정원"); | |
| 219 | + assertThat(attr("CONTACT_CATEGORY", "MJ", "importAlias")).isEqualTo("주관기관"); | |
| 220 | + // 나머지는 화면표시와 엑셀표기가 같다. | |
| 221 | + assertThat(label("CONTACT_CATEGORY", "LAWYER")).isEqualTo("변호사"); | |
| 222 | + assertThat(attr("CONTACT_CATEGORY", "LAWYER", "importAlias")).isEqualTo("변호사"); | |
| 223 | + } | |
| 224 | + | |
| 225 | + private String tone(String groupId, String code) { | |
| 226 | + return attr(groupId, code, "tone"); | |
| 227 | + } | |
| 228 | + | |
| 229 | + private String attr(String groupId, String code, String key) { | |
| 230 | + return jdbc.queryForObject( | |
| 231 | + "select attrs->>? from code where group_id = ? and code = ?", | |
| 232 | + String.class, key, groupId, code); | |
| 233 | + } | |
| 234 | + | |
| 235 | + private String label(String groupId, String code) { | |
| 236 | + return jdbc.queryForObject( | |
| 237 | + "select label from code where group_id = ? and code = ?", | |
| 238 | + String.class, groupId, code); | |
| 239 | + } | |
| 240 | + | |
| 241 | + @Test | |
| 242 | + void 그룹마다_정렬순서가_중복되지_않는다() { | |
| 243 | + Integer dup = jdbc.queryForObject( | |
| 244 | + "select count(*) from (select group_id, sort_order from code " | |
| 245 | + + " group by group_id, sort_order having count(*) > 1) t", | |
| 246 | + Integer.class); | |
| 247 | + | |
| 248 | + assertThat(dup).isZero(); | |
| 249 | + } | |
| 250 | +} |
+++ src/test/java/kr/itn/itnhub/code/CodeServiceTest.java
... | ... | @@ -0,0 +1,134 @@ |
| 1 | +package kr.itn.itnhub.code; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import org.junit.jupiter.api.AfterEach; | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 7 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 8 | + | |
| 9 | +import java.util.List; | |
| 10 | + | |
| 11 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 12 | + | |
| 13 | +class CodeServiceTest extends AbstractDbTest { | |
| 14 | + | |
| 15 | + @Autowired | |
| 16 | + CodeService service; | |
| 17 | + | |
| 18 | + @Autowired | |
| 19 | + JdbcTemplate jdbc; | |
| 20 | + | |
| 21 | + @AfterEach | |
| 22 | + void restore() { | |
| 23 | + jdbc.update("update code set active = true where group_id = 'CONTACT_METHOD'"); | |
| 24 | + service.reload(); | |
| 25 | + } | |
| 26 | + | |
| 27 | + @Test | |
| 28 | + void 그룹을_정렬순서대로_돌려준다() { | |
| 29 | + List<Code> stages = service.group(Codes.STAGE); | |
| 30 | + | |
| 31 | + assertThat(stages).hasSize(13); | |
| 32 | + assertThat(stages.get(0).code()).isEqualTo("1"); | |
| 33 | + assertThat(stages.get(12).label()).isEqualTo("보고서 제출 완료"); | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Test | |
| 37 | + void attrs가_맵으로_파싱된다() { | |
| 38 | + Code stage13 = service.find(Codes.STAGE, "13").orElseThrow(); | |
| 39 | + | |
| 40 | + assertThat(stage13.attr("progress")).isEqualTo("done"); | |
| 41 | + assertThat(stage13.attr("kpiFrom")).isEqualTo("completed"); | |
| 42 | + assertThat(stage13.attr("groupKey")).isEqualTo("closing"); | |
| 43 | + assertThat(stage13.attr("없는속성")).isNull(); | |
| 44 | + } | |
| 45 | + | |
| 46 | + @Test | |
| 47 | + void 불리언_속성을_읽는다() { | |
| 48 | + assertThat(service.find(Codes.PROCESS_STATUS, "처리완료").orElseThrow().flag("done")).isTrue(); | |
| 49 | + assertThat(service.find(Codes.PROCESS_STATUS, "미처리").orElseThrow().flag("done")).isFalse(); | |
| 50 | + // 속성이 아예 없는 코드도 false여야 한다. | |
| 51 | + assertThat(service.find(Codes.CONTRACT_DOC, "공문").orElseThrow().flag("freeText")).isFalse(); | |
| 52 | + assertThat(service.find(Codes.CONTRACT_DOC, "기타").orElseThrow().flag("freeText")).isTrue(); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + void 사용처별로_공공누리_유형을_거른다() { | |
| 57 | + assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_REVIEW)).hasSize(7); | |
| 58 | + assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_PROCESS)) | |
| 59 | + .hasSize(6) | |
| 60 | + .noneMatch(c -> c.code().equals("보류")); | |
| 61 | + // prior에는 기관 조사표에서 온 값('유형 확인 필요')까지 들어간다. | |
| 62 | + // 운영 DB에 실제로 저장돼 있는 값이라 등록해 두지 않으면 화면에서 선택 안 됨으로 보인다. | |
| 63 | + assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_PRIOR)) | |
| 64 | + .extracting(Code::code) | |
| 65 | + .containsExactly("0유형", "1유형", "2유형", "3유형", "4유형", "유형 확인 필요"); | |
| 66 | + } | |
| 67 | + | |
| 68 | + @Test | |
| 69 | + void 옛_표기를_표준_표기로_바꿔_준다() { | |
| 70 | + // 「제4유형」으로 적혀 온 값은 「4유형」으로 통일한다. | |
| 71 | + assertThat(service.canonical(Codes.KOGL_TYPE, "제4유형")).isEqualTo("4유형"); | |
| 72 | + // 표준 표기는 그대로 둔다. | |
| 73 | + assertThat(service.canonical(Codes.KOGL_TYPE, "4유형")).isEqualTo("4유형"); | |
| 74 | + // 뜻이 다른 값은 건드리지 않는다. | |
| 75 | + assertThat(service.canonical(Codes.KOGL_TYPE, "유형 확인 필요")).isEqualTo("유형 확인 필요"); | |
| 76 | + // 모르는 값도 그대로 돌려준다. | |
| 77 | + assertThat(service.canonical(Codes.KOGL_TYPE, "이상한값")).isEqualTo("이상한값"); | |
| 78 | + assertThat(service.canonical(Codes.KOGL_TYPE, null)).isNull(); | |
| 79 | + } | |
| 80 | + | |
| 81 | + @Test | |
| 82 | + void scopes가_없는_그룹은_빈_결과다() { | |
| 83 | + assertThat(service.scoped(Codes.CONTACT_METHOD, Codes.SCOPE_REVIEW)).isEmpty(); | |
| 84 | + } | |
| 85 | + | |
| 86 | + @Test | |
| 87 | + void 비활성_코드는_목록에서_빠지지만_조회는_된다() { | |
| 88 | + jdbc.update("update code set active = false " | |
| 89 | + + "where group_id = 'CONTACT_METHOD' and code = '방문'"); | |
| 90 | + service.reload(); | |
| 91 | + | |
| 92 | + assertThat(service.group(Codes.CONTACT_METHOD)) | |
| 93 | + .extracting(Code::code) | |
| 94 | + .containsExactly("전화", "메일", "기타"); | |
| 95 | + // 이미 '방문'으로 저장된 과거 데이터를 화면에 그리려면 조회는 되어야 한다. | |
| 96 | + assertThat(service.find(Codes.CONTACT_METHOD, "방문")).isPresent(); | |
| 97 | + } | |
| 98 | + | |
| 99 | + @Test | |
| 100 | + void 유효성_검사는_활성_코드만_통과시킨다() { | |
| 101 | + assertThat(service.isValid(Codes.PROCESS_STATUS, "처리완료")).isTrue(); | |
| 102 | + assertThat(service.isValid(Codes.PROCESS_STATUS, "없는값")).isFalse(); | |
| 103 | + | |
| 104 | + jdbc.update("update code set active = false " | |
| 105 | + + "where group_id = 'CONTACT_METHOD' and code = '방문'"); | |
| 106 | + service.reload(); | |
| 107 | + | |
| 108 | + assertThat(service.isValid(Codes.CONTACT_METHOD, "방문")).isFalse(); | |
| 109 | + } | |
| 110 | + | |
| 111 | + @Test | |
| 112 | + void 전체_조회는_활성_코드만_그룹별로_담는다() { | |
| 113 | + assertThat(service.all()) | |
| 114 | + .containsKeys(Codes.STAGE, Codes.KOGL_TYPE, Codes.CONTACT_CATEGORY) | |
| 115 | + .hasSize(12); | |
| 116 | + } | |
| 117 | + | |
| 118 | + @Test | |
| 119 | + void 없는_그룹은_빈_목록이고_없는_코드는_빈_결과다() { | |
| 120 | + assertThat(service.group("NO_SUCH_GROUP")).isEmpty(); | |
| 121 | + assertThat(service.find("NO_SUCH_GROUP", "A")).isEmpty(); | |
| 122 | + assertThat(service.find(Codes.STAGE, "999")).isEmpty(); | |
| 123 | + } | |
| 124 | + | |
| 125 | + @Test | |
| 126 | + void 그룹_메타를_돌려준다() { | |
| 127 | + assertThat(service.groups()) | |
| 128 | + .hasSize(12) | |
| 129 | + .anySatisfy(g -> { | |
| 130 | + assertThat(g.groupId()).isEqualTo("STAGE"); | |
| 131 | + assertThat(g.groupName()).isEqualTo("진행단계"); | |
| 132 | + }); | |
| 133 | + } | |
| 134 | +} |
--- src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
+++ src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
... | ... | @@ -85,6 +85,11 @@ |
| 85 | 85 |
orgId, seq, status); |
| 86 | 86 |
} |
| 87 | 87 |
|
| 88 |
+ /** |
|
| 89 |
+ * KPI 기준 단계는 코드표(STAGE의 kpiFrom)에서 온다. 요구사항 [2]로 완료 기준이 |
|
| 90 |
+ * ⑪법률검토 최종완료에서 ⑬보고서 제출 완료로 올라갔다 - 보고서가 실제로 올라온 |
|
| 91 |
+ * 기관만 완료로 센다. 그래서 11단계인 003은 더 이상 완료가 아니다. |
|
| 92 |
+ */ |
|
| 88 | 93 |
@Test |
| 89 | 94 |
void KPI는_전체_기관과_단계별_도달_기관수를_센다() throws Exception {
|
| 90 | 95 |
mvc.perform(get("/api/dashboard"))
|
... | ... | @@ -93,13 +98,23 @@ |
| 93 | 98 |
.andExpect(jsonPath("$.summary.applied").value(3))
|
| 94 | 99 |
// 서류제출 = 2단계 이상 (002, 003) |
| 95 | 100 |
.andExpect(jsonPath("$.summary.docSubmitted").value(2))
|
| 96 |
- // 완료 = 11단계 이상 (003) |
|
| 97 |
- .andExpect(jsonPath("$.summary.completed").value(1))
|
|
| 101 |
+ // 완료 = 13단계 이상 (없음) |
|
| 102 |
+ .andExpect(jsonPath("$.summary.completed").value(0))
|
|
| 98 | 103 |
// 보고서 작성 = 12단계 이상 (없음) |
| 99 | 104 |
.andExpect(jsonPath("$.summary.reportWriting").value(0));
|
| 100 | 105 |
} |
| 101 | 106 |
|
| 102 | 107 |
@Test |
| 108 |
+ void 완료는_보고서_제출까지_끝난_기관만_센다() throws Exception {
|
|
| 109 |
+ setStage(stage11Id, 13); |
|
| 110 |
+ |
|
| 111 |
+ mvc.perform(get("/api/dashboard"))
|
|
| 112 |
+ .andExpect(status().isOk()) |
|
| 113 |
+ .andExpect(jsonPath("$.summary.completed").value(1))
|
|
| 114 |
+ .andExpect(jsonPath("$.summary.reportWriting").value(1));
|
|
| 115 |
+ } |
|
| 116 |
+ |
|
| 117 |
+ @Test |
|
| 103 | 118 |
void KPI의_권리확인_권리처리는_전_기관_건수_합계다() throws Exception {
|
| 104 | 119 |
mvc.perform(get("/api/dashboard"))
|
| 105 | 120 |
.andExpect(status().isOk()) |
+++ src/test/java/kr/itn/itnhub/feed/ChannelUploadAutoStageTest.java
... | ... | @@ -0,0 +1,124 @@ |
| 1 | +package kr.itn.itnhub.feed; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 5 | +import kr.itn.itnhub.mattermost.MattermostException; | |
| 6 | +import kr.itn.itnhub.org.Organization; | |
| 7 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 8 | +import kr.itn.itnhub.stage.AutoStage; | |
| 9 | +import kr.itn.itnhub.stage.StageHistoryMapper; | |
| 10 | +import org.junit.jupiter.api.BeforeEach; | |
| 11 | +import org.junit.jupiter.api.Test; | |
| 12 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 13 | +import org.springframework.boot.test.mock.mockito.MockBean; | |
| 14 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 15 | + | |
| 16 | +import java.util.List; | |
| 17 | + | |
| 18 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 19 | +import static org.mockito.ArgumentMatchers.any; | |
| 20 | +import static org.mockito.ArgumentMatchers.anyString; | |
| 21 | +import static org.mockito.Mockito.doThrow; | |
| 22 | +import static org.mockito.Mockito.when; | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * 「목록접수 = Mattermost에 첫 자료 업로드 + 핀 고정 시」(기능확인 PPT 14쪽, 요구사항 [18]). | |
| 26 | + * | |
| 27 | + * <p>조건이 둘이라는 점이 핵심이다. 자료만 올리고 공지로 고정하지 않았으면 접수가 아니고, | |
| 28 | + * 자료 없이 공지만 올린 것도 접수가 아니다.</p> | |
| 29 | + */ | |
| 30 | +class ChannelUploadAutoStageTest extends AbstractDbTest { | |
| 31 | + | |
| 32 | + @Autowired | |
| 33 | + ChannelFeedService feedService; | |
| 34 | + | |
| 35 | + @Autowired | |
| 36 | + OrganizationMapper orgMapper; | |
| 37 | + | |
| 38 | + @Autowired | |
| 39 | + StageHistoryMapper historyMapper; | |
| 40 | + | |
| 41 | + @Autowired | |
| 42 | + JdbcTemplate jdbc; | |
| 43 | + | |
| 44 | + @MockBean | |
| 45 | + MattermostClient mattermost; | |
| 46 | + | |
| 47 | + private Long orgId; | |
| 48 | + | |
| 49 | + @BeforeEach | |
| 50 | + void setUp() { | |
| 51 | + jdbc.update("delete from stage_history"); | |
| 52 | + jdbc.update("delete from organization where org_no = '940'"); | |
| 53 | + | |
| 54 | + Organization org = new Organization(); | |
| 55 | + org.setOrgNo("940"); | |
| 56 | + org.setOrgName("업로드전환테스트기관"); | |
| 57 | + org.setChannelSlug("940"); | |
| 58 | + orgMapper.upsertBySeed(org); | |
| 59 | + orgId = orgMapper.findByOrgNoAndOrgName("940", "업로드전환테스트기관").getId(); | |
| 60 | + orgMapper.updateChannelIdMj(orgId, "chan-mj"); | |
| 61 | + orgMapper.updateChannelIdLaw(orgId, "chan-law"); | |
| 62 | + | |
| 63 | + // 채널 생성으로 ①신청까지 와 있는 상태를 만든다. | |
| 64 | + orgMapper.updateStage(orgId, 1); | |
| 65 | + historyMapper.insert(orgId, 1, true, "채널 생성"); | |
| 66 | + | |
| 67 | + when(mattermost.uploadFile(anyString(), anyString(), any(), anyString())) | |
| 68 | + .thenReturn("file-1"); | |
| 69 | + when(mattermost.createPost(anyString(), anyString(), any())).thenReturn("post-1"); | |
| 70 | + when(mattermost.getRecentPosts(anyString(), any(Integer.class))).thenReturn(List.of()); | |
| 71 | + when(mattermost.getPinnedPosts(anyString())).thenReturn(List.of()); | |
| 72 | + } | |
| 73 | + | |
| 74 | + private UploadedFile file() { | |
| 75 | + return new UploadedFile("목록.xlsx", new byte[]{1, 2, 3}, | |
| 76 | + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); | |
| 77 | + } | |
| 78 | + | |
| 79 | + private Integer stage() { | |
| 80 | + return orgMapper.findById(orgId).getStage(); | |
| 81 | + } | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + void 자료를_공지로_고정해서_올리면_목록접수로_넘어간다() { | |
| 85 | + feedService.sendMessage(orgId, "mj", "목록 보냅니다", List.of(file()), true); | |
| 86 | + | |
| 87 | + assertThat(stage()).isEqualTo(AutoStage.LIST_RECEIVED); | |
| 88 | + assertThat(historyMapper.findByOrg(orgId).get(0).isAuto()).isTrue(); | |
| 89 | + assertThat(historyMapper.findByOrg(orgId).get(0).getReason()) | |
| 90 | + .isEqualTo(AutoStage.REASON_LIST_RECEIVED); | |
| 91 | + } | |
| 92 | + | |
| 93 | + @Test | |
| 94 | + void 자료만_올리고_고정하지_않으면_넘어가지_않는다() { | |
| 95 | + feedService.sendMessage(orgId, "mj", "그냥 파일 하나 드립니다", List.of(file()), false); | |
| 96 | + | |
| 97 | + assertThat(stage()).isEqualTo(1); | |
| 98 | + } | |
| 99 | + | |
| 100 | + @Test | |
| 101 | + void 자료_없이_공지만_올리면_넘어가지_않는다() { | |
| 102 | + feedService.sendMessage(orgId, "mj", "안내드립니다", List.of(), true); | |
| 103 | + | |
| 104 | + assertThat(stage()).isEqualTo(1); | |
| 105 | + } | |
| 106 | + | |
| 107 | + @Test | |
| 108 | + void 고정에_실패하면_단계도_넘기지_않는다() { | |
| 109 | + doThrow(new MattermostException("핀 고정 실패")).when(mattermost).pinPost(anyString()); | |
| 110 | + | |
| 111 | + feedService.sendMessage(orgId, "mj", "목록 보냅니다", List.of(file()), true); | |
| 112 | + | |
| 113 | + assertThat(stage()).isEqualTo(1); | |
| 114 | + } | |
| 115 | + | |
| 116 | + @Test | |
| 117 | + void 이미_더_앞선_단계면_뒤로_밀지_않는다() { | |
| 118 | + orgMapper.updateStage(orgId, 7); | |
| 119 | + | |
| 120 | + feedService.sendMessage(orgId, "mj", "목록 보냅니다", List.of(file()), true); | |
| 121 | + | |
| 122 | + assertThat(stage()).isEqualTo(7); | |
| 123 | + } | |
| 124 | +} |
--- src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
+++ src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
... | ... | @@ -66,13 +66,13 @@ |
| 66 | 66 |
""")) |
| 67 | 67 |
.andExpect(status().isOk()) |
| 68 | 68 |
// 작성자는 요청 본문이 아니라 로그인 사용자에서 온다 |
| 69 |
- .andExpect(jsonPath("$.contactName").value("admin"))
|
|
| 69 |
+ .andExpect(jsonPath("$.contactName").value("관리자"))
|
|
| 70 | 70 |
.andExpect(jsonPath("$.body").value("채널 생성 일정 문의"))
|
| 71 | 71 |
.andExpect(jsonPath("$.createdAt").isNumber());
|
| 72 | 72 |
|
| 73 | 73 |
mvc.perform(get("/api/orgs/{id}/memos", orgId))
|
| 74 | 74 |
.andExpect(status().isOk()) |
| 75 |
- .andExpect(jsonPath("$[0].contactName").value("admin"))
|
|
| 75 |
+ .andExpect(jsonPath("$[0].contactName").value("관리자"))
|
|
| 76 | 76 |
.andExpect(jsonPath("$[0].body").value("채널 생성 일정 문의"));
|
| 77 | 77 |
} |
| 78 | 78 |
|
... | ... | @@ -192,7 +192,7 @@ |
| 192 | 192 |
""")) |
| 193 | 193 |
.andExpect(status().isOk()) |
| 194 | 194 |
.andExpect(jsonPath("$.body").value("고친 뒤"))
|
| 195 |
- .andExpect(jsonPath("$.contactName").value("admin"))
|
|
| 195 |
+ .andExpect(jsonPath("$.contactName").value("관리자"))
|
|
| 196 | 196 |
.andExpect(jsonPath("$.createdAt").value(createdAt));
|
| 197 | 197 |
} |
| 198 | 198 |
|
+++ src/test/java/kr/itn/itnhub/process/ProcessDoneStatusTest.java
... | ... | @@ -0,0 +1,110 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.code.CodeService; | |
| 5 | +import kr.itn.itnhub.org.Organization; | |
| 6 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 7 | +import org.junit.jupiter.api.AfterEach; | |
| 8 | +import org.junit.jupiter.api.BeforeEach; | |
| 9 | +import org.junit.jupiter.api.Test; | |
| 10 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 11 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 12 | + | |
| 13 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * "어느 값이 처리완료인가"가 코드표에서 온다는 것을 확인한다. | |
| 17 | + * | |
| 18 | + * <p>쿼리에 '처리완료'가 박혀 있던 시절에는 코드표를 바꿔도 집계가 그대로였다. 이 테스트는 | |
| 19 | + * 코드표의 done 속성을 옮기면 진행률도 따라 움직이는지를 본다 - 파라미터화가 형식만 바뀐 게 | |
| 20 | + * 아니라 실제로 동작하는지를 증명하는 자리다.</p> | |
| 21 | + */ | |
| 22 | +class ProcessDoneStatusTest extends AbstractDbTest { | |
| 23 | + | |
| 24 | + @Autowired | |
| 25 | + ProcessService service; | |
| 26 | + | |
| 27 | + @Autowired | |
| 28 | + OrganizationMapper orgMapper; | |
| 29 | + | |
| 30 | + @Autowired | |
| 31 | + CodeService codeService; | |
| 32 | + | |
| 33 | + @Autowired | |
| 34 | + JdbcTemplate jdbc; | |
| 35 | + | |
| 36 | + private Long orgId; | |
| 37 | + | |
| 38 | + @BeforeEach | |
| 39 | + void setUp() { | |
| 40 | + jdbc.update("delete from process_item"); | |
| 41 | + jdbc.update("delete from organization where org_no = '910'"); | |
| 42 | + | |
| 43 | + Organization org = new Organization(); | |
| 44 | + org.setOrgNo("910"); | |
| 45 | + org.setOrgName("처리상태테스트기관"); | |
| 46 | + org.setChannelSlug("910"); | |
| 47 | + orgMapper.upsertBySeed(org); | |
| 48 | + orgId = orgMapper.findByOrgNoAndOrgName("910", "처리상태테스트기관").getId(); | |
| 49 | + | |
| 50 | + insert(1, "처리완료"); | |
| 51 | + insert(2, "처리완료"); | |
| 52 | + insert(3, "미처리"); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @AfterEach | |
| 56 | + void restore() { | |
| 57 | + jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'true') " | |
| 58 | + + "where group_id = 'PROCESS_STATUS' and code = '처리완료'"); | |
| 59 | + jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'false') " | |
| 60 | + + "where group_id = 'PROCESS_STATUS' and code = '미처리'"); | |
| 61 | + codeService.reload(); | |
| 62 | + | |
| 63 | + jdbc.update("delete from process_item"); | |
| 64 | + jdbc.update("delete from organization where org_no = '910'"); | |
| 65 | + } | |
| 66 | + | |
| 67 | + private void insert(int seq, String status) { | |
| 68 | + jdbc.update("insert into process_item (org_id, seq, process_status) values (?, ?, ?)", | |
| 69 | + orgId, seq, status); | |
| 70 | + } | |
| 71 | + | |
| 72 | + @Test | |
| 73 | + void 기본_설정에서는_처리완료가_완료로_집계된다() { | |
| 74 | + ProcessPage page = service.list(orgId, null, null, null, null, null, 0, 30); | |
| 75 | + | |
| 76 | + assertThat(page.total()).isEqualTo(3); | |
| 77 | + assertThat(page.done()).isEqualTo(2); | |
| 78 | + } | |
| 79 | + | |
| 80 | + @Test | |
| 81 | + void 코드표에서_done을_옮기면_집계도_따라_움직인다() { | |
| 82 | + // '미처리'를 완료로, '처리완료'를 미완료로 뒤집는다. 쿼리에 값이 박혀 있었다면 | |
| 83 | + // 아무 변화도 없었을 것이다. | |
| 84 | + jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'false') " | |
| 85 | + + "where group_id = 'PROCESS_STATUS' and code = '처리완료'"); | |
| 86 | + jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'true') " | |
| 87 | + + "where group_id = 'PROCESS_STATUS' and code = '미처리'"); | |
| 88 | + codeService.reload(); | |
| 89 | + | |
| 90 | + ProcessPage page = service.list(orgId, null, null, null, null, null, 0, 30); | |
| 91 | + | |
| 92 | + assertThat(page.total()).isEqualTo(3); | |
| 93 | + assertThat(page.done()).isEqualTo(1); | |
| 94 | + } | |
| 95 | + | |
| 96 | + @Test | |
| 97 | + void 상태_필터도_코드표를_따른다() { | |
| 98 | + assertThat(service.list(orgId, null, null, null, null, "DONE", 0, 30).items()).hasSize(2); | |
| 99 | + assertThat(service.list(orgId, null, null, null, null, "PENDING", 0, 30).items()).hasSize(1); | |
| 100 | + | |
| 101 | + jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'false') " | |
| 102 | + + "where group_id = 'PROCESS_STATUS' and code = '처리완료'"); | |
| 103 | + jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'true') " | |
| 104 | + + "where group_id = 'PROCESS_STATUS' and code = '미처리'"); | |
| 105 | + codeService.reload(); | |
| 106 | + | |
| 107 | + assertThat(service.list(orgId, null, null, null, null, "DONE", 0, 30).items()).hasSize(1); | |
| 108 | + assertThat(service.list(orgId, null, null, null, null, "PENDING", 0, 30).items()).hasSize(2); | |
| 109 | + } | |
| 110 | +} |
+++ src/test/java/kr/itn/itnhub/process/ProcessExcelRoundTripTest.java
... | ... | @@ -0,0 +1,166 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.org.Organization; | |
| 5 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 6 | +import org.junit.jupiter.api.AfterEach; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 10 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 11 | + | |
| 12 | +import java.io.ByteArrayInputStream; | |
| 13 | +import java.util.List; | |
| 14 | + | |
| 15 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * 권리처리 엑셀의 업로드↔다운로드 왕복. | |
| 19 | + * | |
| 20 | + * <p>업로드는 컬럼 번호({@code ProcessParser.COL_*})로, 다운로드는 헤더 이름 | |
| 21 | + * ({@code ProcessService.COLUMN_HEADERS})으로 각각 따로 정의돼 있다. 두 정의가 어긋나면 | |
| 22 | + * "내려받아 고쳐서 다시 올리는" 흐름에서 값이 조용히 사라진다. 그 어긋남을 여기서 잡는다.</p> | |
| 23 | + */ | |
| 24 | +class ProcessExcelRoundTripTest extends AbstractDbTest { | |
| 25 | + | |
| 26 | + @Autowired | |
| 27 | + ProcessService service; | |
| 28 | + | |
| 29 | + @Autowired | |
| 30 | + ProcessParser parser; | |
| 31 | + | |
| 32 | + @Autowired | |
| 33 | + OrganizationMapper orgMapper; | |
| 34 | + | |
| 35 | + @Autowired | |
| 36 | + JdbcTemplate jdbc; | |
| 37 | + | |
| 38 | + private Long orgId; | |
| 39 | + | |
| 40 | + @BeforeEach | |
| 41 | + void setUp() { | |
| 42 | + jdbc.update("delete from process_item"); | |
| 43 | + jdbc.update("delete from organization where org_no = '920'"); | |
| 44 | + | |
| 45 | + Organization org = new Organization(); | |
| 46 | + org.setOrgNo("920"); | |
| 47 | + org.setOrgName("왕복테스트기관"); | |
| 48 | + org.setChannelSlug("920"); | |
| 49 | + orgMapper.upsertBySeed(org); | |
| 50 | + orgId = orgMapper.findByOrgNoAndOrgName("920", "왕복테스트기관").getId(); | |
| 51 | + } | |
| 52 | + | |
| 53 | + @AfterEach | |
| 54 | + void cleanUp() { | |
| 55 | + jdbc.update("delete from process_item"); | |
| 56 | + jdbc.update("delete from organization where org_no = '920'"); | |
| 57 | + } | |
| 58 | + | |
| 59 | + /** 헤더 이름과 파서가 읽는 컬럼이 같은 자리를 가리키는지. 한쪽만 고치면 여기서 깨진다. */ | |
| 60 | + @Test | |
| 61 | + void 헤더_이름과_파서_컬럼이_같은_자리를_가리킨다() { | |
| 62 | + assertHeader(ProcessParser.COL_SEQ, "순번"); | |
| 63 | + assertHeader(ProcessParser.COL_SITE_NAME, "사이트명"); | |
| 64 | + assertHeader(ProcessParser.COL_CATEGORY, "범주"); | |
| 65 | + assertHeader(ProcessParser.COL_BOARD_NAME, "게시판명"); | |
| 66 | + assertHeader(ProcessParser.COL_POST_TITLE, "게시물제목"); | |
| 67 | + assertHeader(ProcessParser.COL_URL, "URL주소"); | |
| 68 | + assertHeader(ProcessParser.COL_DESCRIPTION, "설명"); | |
| 69 | + assertHeader(ProcessParser.COL_HAS_ATTACHMENT, "첨부파일여부"); | |
| 70 | + assertHeader(ProcessParser.COL_PRIOR_KOGL_TYPE, "기존 공공누리유형(+AI)"); | |
| 71 | + assertHeader(ProcessParser.COL_CONTRACT_DOCS, "계약서 유무"); | |
| 72 | + assertHeader(ProcessParser.COL_PRODUCED_DATE, "제작일"); | |
| 73 | + assertHeader(ProcessParser.COL_PUBLISHED_DATE, "공표일"); | |
| 74 | + assertHeader(ProcessParser.COL_REVIEW_MAJOR, "권리확인(대분류)"); | |
| 75 | + assertHeader(ProcessParser.COL_REVIEW_MINOR, "권리확인(세부)"); | |
| 76 | + assertHeader(ProcessParser.COL_REVIEW_RESULT, "처리 구분"); | |
| 77 | + assertHeader(ProcessParser.COL_REVIEW_KOGL_TYPE, "공공누리 유형"); | |
| 78 | + assertHeader(ProcessParser.COL_REVIEW_AI_TYPE, "AI유형"); | |
| 79 | + assertHeader(ProcessParser.COL_REVIEW_OPINION, "의견"); | |
| 80 | + assertHeader(ProcessParser.COL_REVIEW_NOTE, "비고"); | |
| 81 | + assertHeader(ProcessParser.COL_JUDGED_KOGL_TYPE, "공공누리유형(판정)"); | |
| 82 | + assertHeader(ProcessParser.COL_JUDGED_AI_TYPE, "AI유형(판정)"); | |
| 83 | + assertHeader(ProcessParser.COL_FINAL_OPINION, "최종의견"); | |
| 84 | + assertHeader(ProcessParser.COL_JUDGMENT_BASIS, "판단근거"); | |
| 85 | + assertHeader(ProcessParser.COL_PROCESS_STATUS, "처리상태"); | |
| 86 | + } | |
| 87 | + | |
| 88 | + private void assertHeader(int column, String expected) { | |
| 89 | + assertThat(ProcessService.COLUMN_HEADERS[column]) | |
| 90 | + .as("%d번 열의 헤더", column) | |
| 91 | + .isEqualTo(expected); | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** | |
| 95 | + * 기존 증빙자료는 예외다. 기관이 보내오는 실제 파일은 헤더가 T열인데 데이터가 U열에 있어 | |
| 96 | + * 파서가 U를 먼저 읽고, 비어 있으면 헤더 자리인 T를 읽는다. 이 어긋남 자체를 명시적으로 | |
| 97 | + * 남겨 두어, 나중에 누가 "헤더랑 다르네" 하고 한쪽만 고치다 깨뜨리지 않게 한다. | |
| 98 | + */ | |
| 99 | + @Test | |
| 100 | + void 기존_증빙자료만_헤더와_데이터_열이_다르다() { | |
| 101 | + assertThat(ProcessService.COLUMN_HEADERS[19]).isEqualTo("기존 증빙자료"); | |
| 102 | + assertThat(ProcessParser.COL_PRIOR_EVIDENCE_HEADER).isEqualTo(19); | |
| 103 | + assertThat(ProcessParser.COL_PRIOR_EVIDENCE).isEqualTo(20); | |
| 104 | + } | |
| 105 | + | |
| 106 | + /** 기관 원본처럼 U열에 값이 있는 파일도 그대로 읽어야 한다(다운로드 파일만 고치면 안 된다). */ | |
| 107 | + @Test | |
| 108 | + void 원본처럼_U열에_값이_있어도_읽는다() throws Exception { | |
| 109 | + byte[] xlsx = ProcessParserFixtures.withPriorEvidenceAt(ProcessParser.COL_PRIOR_EVIDENCE, "원본증빙"); | |
| 110 | + | |
| 111 | + List<ProcessRow> rows = parser.parse(new ByteArrayInputStream(xlsx)); | |
| 112 | + | |
| 113 | + assertThat(rows).hasSize(1); | |
| 114 | + assertThat(rows.get(0).priorEvidence()).isEqualTo("원본증빙"); | |
| 115 | + } | |
| 116 | + | |
| 117 | + /** 내려받은 파일을 그대로 다시 올렸을 때 값이 살아남아야 한다. */ | |
| 118 | + @Test | |
| 119 | + void 내려받아_다시_올려도_값이_그대로다() { | |
| 120 | + jdbc.update("insert into process_item (org_id, seq, site_name, category, board_name, " | |
| 121 | + + "post_title, url, description, has_attachment, prior_kogl_type, contract_docs, " | |
| 122 | + + "produced_date, published_date, review_major, review_minor, review_result, " | |
| 123 | + + "review_kogl_type, review_ai_type, review_opinion, review_note, prior_evidence, " | |
| 124 | + + "judged_kogl_type, judged_ai_type, final_opinion, judgment_basis, process_status) " | |
| 125 | + + "values (?, 1, '사이트', '범주', '게시판', '제목', 'http://x', '설명', '있음', " | |
| 126 | + + "'4유형', '양도계약서', '2026-01-02', '2026-01-03', '제3자 권리', '초상권 포함', " | |
| 127 | + + "'권리처리 추진', '보류', 'Y', '의견', '비고', '증빙자료', '0유형', 'Y', " | |
| 128 | + + "'최종의견', '판단근거', '처리완료')", | |
| 129 | + orgId); | |
| 130 | + | |
| 131 | + byte[] xlsx = service.downloadWorkbook(orgId); | |
| 132 | + List<ProcessRow> rows = parser.parse(new ByteArrayInputStream(xlsx)); | |
| 133 | + | |
| 134 | + assertThat(rows).hasSize(1); | |
| 135 | + ProcessRow row = rows.get(0); | |
| 136 | + | |
| 137 | + assertThat(row.siteName()).isEqualTo("사이트"); | |
| 138 | + assertThat(row.category()).isEqualTo("범주"); | |
| 139 | + assertThat(row.boardName()).isEqualTo("게시판"); | |
| 140 | + assertThat(row.postTitle()).isEqualTo("제목"); | |
| 141 | + assertThat(row.url()).isEqualTo("http://x"); | |
| 142 | + assertThat(row.description()).isEqualTo("설명"); | |
| 143 | + assertThat(row.hasAttachment()).isEqualTo("있음"); | |
| 144 | + assertThat(row.priorKoglType()).isEqualTo("4유형"); | |
| 145 | + assertThat(row.contractDocs()).isEqualTo("양도계약서"); | |
| 146 | + assertThat(row.producedDate()).isEqualTo("2026-01-02"); | |
| 147 | + assertThat(row.publishedDate()).isEqualTo("2026-01-03"); | |
| 148 | + assertThat(row.reviewMajor()).isEqualTo("제3자 권리"); | |
| 149 | + assertThat(row.reviewMinor()).isEqualTo("초상권 포함"); | |
| 150 | + assertThat(row.reviewResult()).isEqualTo("권리처리 추진"); | |
| 151 | + assertThat(row.reviewKoglType()).isEqualTo("보류"); | |
| 152 | + assertThat(row.reviewAiType()).isEqualTo("Y"); | |
| 153 | + assertThat(row.reviewOpinion()).isEqualTo("의견"); | |
| 154 | + assertThat(row.reviewNote()).isEqualTo("비고"); | |
| 155 | + assertThat(row.judgedKoglType()).isEqualTo("0유형"); | |
| 156 | + assertThat(row.judgedAiType()).isEqualTo("Y"); | |
| 157 | + assertThat(row.finalOpinion()).isEqualTo("최종의견"); | |
| 158 | + assertThat(row.judgmentBasis()).isEqualTo("판단근거"); | |
| 159 | + assertThat(row.processStatus()).isEqualTo("처리완료"); | |
| 160 | + | |
| 161 | + // 기존 증빙자료는 다운로드가 T열에 쓰고 파서가 U열을 읽어 왕복에서 사라진다. | |
| 162 | + assertThat(row.priorEvidence()) | |
| 163 | + .as("내려받아 다시 올렸을 때 기존 증빙자료") | |
| 164 | + .isEqualTo("증빙자료"); | |
| 165 | + } | |
| 166 | +} |
+++ src/test/java/kr/itn/itnhub/process/ProcessParserFixtures.java
... | ... | @@ -0,0 +1,35 @@ |
| 1 | +package kr.itn.itnhub.process; | |
| 2 | + | |
| 3 | +import org.apache.poi.ss.usermodel.Row; | |
| 4 | +import org.apache.poi.ss.usermodel.Sheet; | |
| 5 | +import org.apache.poi.xssf.usermodel.XSSFWorkbook; | |
| 6 | + | |
| 7 | +import java.io.ByteArrayOutputStream; | |
| 8 | + | |
| 9 | +/** 권리처리 엑셀 테스트용 최소 통합문서. 1행 그룹제목, 2행 헤더, 3행부터 데이터. */ | |
| 10 | +final class ProcessParserFixtures { | |
| 11 | + | |
| 12 | + private ProcessParserFixtures() { | |
| 13 | + } | |
| 14 | + | |
| 15 | + /** 순번 1행짜리 시트를 만들고, 기존 증빙자료 값을 지정한 열에만 넣는다. */ | |
| 16 | + static byte[] withPriorEvidenceAt(int column, String value) throws Exception { | |
| 17 | + try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 18 | + Sheet sheet = wb.createSheet(ProcessParser.PRIMARY_SHEET_NAME); | |
| 19 | + sheet.createRow(0).createCell(0).setCellValue("게시물 정보(조사원)"); | |
| 20 | + | |
| 21 | + Row header = sheet.createRow(1); | |
| 22 | + for (int i = 0; i < ProcessService.COLUMN_HEADERS.length; i++) { | |
| 23 | + header.createCell(i).setCellValue(ProcessService.COLUMN_HEADERS[i]); | |
| 24 | + } | |
| 25 | + | |
| 26 | + Row data = sheet.createRow(2); | |
| 27 | + data.createCell(ProcessParser.COL_SEQ).setCellValue("1"); | |
| 28 | + data.createCell(ProcessParser.COL_SITE_NAME).setCellValue("사이트"); | |
| 29 | + data.createCell(column).setCellValue(value); | |
| 30 | + | |
| 31 | + wb.write(out); | |
| 32 | + return out.toByteArray(); | |
| 33 | + } | |
| 34 | + } | |
| 35 | +} |
--- src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
+++ src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
... | ... | @@ -76,7 +76,7 @@ |
| 76 | 76 |
""")) |
| 77 | 77 |
.andExpect(status().isOk()) |
| 78 | 78 |
.andExpect(jsonPath("$.length()").value(1))
|
| 79 |
- .andExpect(jsonPath("$[0].author").value("admin"))
|
|
| 79 |
+ .andExpect(jsonPath("$[0].author").value("관리자"))
|
|
| 80 | 80 |
.andExpect(jsonPath("$[0].contactedOn").value("2026-07-20"));
|
| 81 | 81 |
|
| 82 | 82 |
mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf())
|
... | ... | @@ -112,7 +112,7 @@ |
| 112 | 112 |
.andExpect(status().isOk()) |
| 113 | 113 |
.andExpect(jsonPath("$[0].summary").value("고침"))
|
| 114 | 114 |
.andExpect(jsonPath("$[0].method").value("방문"))
|
| 115 |
- .andExpect(jsonPath("$[0].author").value("admin"));
|
|
| 115 |
+ .andExpect(jsonPath("$[0].author").value("관리자"));
|
|
| 116 | 116 |
} |
| 117 | 117 |
|
| 118 | 118 |
@Test |
... | ... | @@ -151,7 +151,7 @@ |
| 151 | 151 |
.andExpect(status().isOk()) |
| 152 | 152 |
.andExpect(jsonPath("$.length()").value(1))
|
| 153 | 153 |
.andExpect(jsonPath("$[0].fileName").value("최종보고서.pdf"))
|
| 154 |
- .andExpect(jsonPath("$[0].uploadedBy").value("admin"))
|
|
| 154 |
+ .andExpect(jsonPath("$[0].uploadedBy").value("관리자"))
|
|
| 155 | 155 |
.andReturn().getResponse().getContentAsString(); |
| 156 | 156 |
int reportId = com.jayway.jsonpath.JsonPath.parse(uploaded).read("$[0].id", Integer.class);
|
| 157 | 157 |
|
+++ src/test/java/kr/itn/itnhub/re/ReMemoControllerTest.java
... | ... | @@ -0,0 +1,141 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 5 | +import kr.itn.itnhub.org.Organization; | |
| 6 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 10 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 11 | +import org.springframework.boot.test.mock.mockito.MockBean; | |
| 12 | +import org.springframework.http.MediaType; | |
| 13 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 14 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 15 | +import org.springframework.test.web.servlet.MockMvc; | |
| 16 | + | |
| 17 | +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; | |
| 18 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; | |
| 19 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 20 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 21 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 22 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 23 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 24 | + | |
| 25 | +@AutoConfigureMockMvc | |
| 26 | +@WithMockUser(roles = "ADMIN") | |
| 27 | +class ReMemoControllerTest extends AbstractDbTest { | |
| 28 | + | |
| 29 | + @Autowired | |
| 30 | + MockMvc mvc; | |
| 31 | + | |
| 32 | + @Autowired | |
| 33 | + OrganizationMapper orgMapper; | |
| 34 | + | |
| 35 | + @Autowired | |
| 36 | + JdbcTemplate jdbc; | |
| 37 | + | |
| 38 | + @MockBean | |
| 39 | + MattermostClient mattermost; | |
| 40 | + | |
| 41 | + private Long orgId; | |
| 42 | + | |
| 43 | + @BeforeEach | |
| 44 | + void setUp() { | |
| 45 | + jdbc.update("delete from re_memo"); | |
| 46 | + jdbc.update("delete from organization"); | |
| 47 | + | |
| 48 | + Organization org = new Organization(); | |
| 49 | + org.setOrgNo("001"); | |
| 50 | + org.setOrgName("RE테스트기관"); | |
| 51 | + org.setChannelSlug("001"); | |
| 52 | + orgMapper.upsertBySeed(org); | |
| 53 | + orgId = orgMapper.findByOrgNoAndOrgName("001", "RE테스트기관").getId(); | |
| 54 | + } | |
| 55 | + | |
| 56 | + private String body(String content, String author, String date) { | |
| 57 | + return "{\"content\":\"" + content + "\",\"author\":\"" + author | |
| 58 | + + "\",\"memoDate\":\"" + date + "\",\"resolved\":false}"; | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + void 메모를_등록하면_목록에_최신순으로_쌓인다() throws Exception { | |
| 63 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 64 | + .contentType(MediaType.APPLICATION_JSON) | |
| 65 | + .content(body("ooo 계약서 및 제안요청서 송부 요청", "장영익", "2026-07-08"))) | |
| 66 | + .andExpect(status().isOk()); | |
| 67 | + | |
| 68 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 69 | + .contentType(MediaType.APPLICATION_JSON) | |
| 70 | + .content(body("추가 자료 요청", "장영익", "2026-07-10"))) | |
| 71 | + .andExpect(status().isOk()) | |
| 72 | + .andExpect(jsonPath("$.length()").value(2)) | |
| 73 | + // 일자 최신순 | |
| 74 | + .andExpect(jsonPath("$[0].content").value("추가 자료 요청")) | |
| 75 | + .andExpect(jsonPath("$[0].memoDate").value("2026-07-10")) | |
| 76 | + .andExpect(jsonPath("$[0].author").value("장영익")) | |
| 77 | + .andExpect(jsonPath("$[0].resolved").value(false)); | |
| 78 | + } | |
| 79 | + | |
| 80 | + @Test | |
| 81 | + void 내용과_작성자는_비울_수_없다() throws Exception { | |
| 82 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 83 | + .contentType(MediaType.APPLICATION_JSON) | |
| 84 | + .content(body("", "장영익", "2026-07-08"))) | |
| 85 | + .andExpect(status().isBadRequest()); | |
| 86 | + | |
| 87 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 88 | + .contentType(MediaType.APPLICATION_JSON) | |
| 89 | + .content(body("내용", "", "2026-07-08"))) | |
| 90 | + .andExpect(status().isBadRequest()); | |
| 91 | + } | |
| 92 | + | |
| 93 | + @Test | |
| 94 | + void 해결로_바꾸면_대시보드_미해결_수에서_빠진다() throws Exception { | |
| 95 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 96 | + .contentType(MediaType.APPLICATION_JSON) | |
| 97 | + .content(body("미해결 건", "장영익", "2026-07-08"))) | |
| 98 | + .andExpect(status().isOk()); | |
| 99 | + | |
| 100 | + mvc.perform(get("/api/dashboard")) | |
| 101 | + .andExpect(jsonPath("$.summary.unresolvedRe").value(1)); | |
| 102 | + | |
| 103 | + Long memoId = jdbc.queryForObject( | |
| 104 | + "select id from re_memo where org_id = ?", Long.class, orgId); | |
| 105 | + | |
| 106 | + mvc.perform(put("/api/orgs/{id}/re-memos/{memoId}", orgId, memoId).with(csrf()) | |
| 107 | + .contentType(MediaType.APPLICATION_JSON) | |
| 108 | + .content("{\"content\":\"미해결 건\",\"author\":\"장영익\"," | |
| 109 | + + "\"memoDate\":\"2026-07-08\",\"resolved\":true}")) | |
| 110 | + .andExpect(status().isOk()) | |
| 111 | + .andExpect(jsonPath("$[0].resolved").value(true)); | |
| 112 | + | |
| 113 | + mvc.perform(get("/api/dashboard")) | |
| 114 | + .andExpect(jsonPath("$.summary.unresolvedRe").value(0)); | |
| 115 | + } | |
| 116 | + | |
| 117 | + @Test | |
| 118 | + void 메모를_지울_수_있다() throws Exception { | |
| 119 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 120 | + .contentType(MediaType.APPLICATION_JSON) | |
| 121 | + .content(body("지울 건", "장영익", "2026-07-08"))) | |
| 122 | + .andExpect(status().isOk()); | |
| 123 | + | |
| 124 | + Long memoId = jdbc.queryForObject( | |
| 125 | + "select id from re_memo where org_id = ?", Long.class, orgId); | |
| 126 | + | |
| 127 | + mvc.perform(delete("/api/orgs/{id}/re-memos/{memoId}", orgId, memoId).with(csrf())) | |
| 128 | + .andExpect(status().isNoContent()); | |
| 129 | + | |
| 130 | + mvc.perform(get("/api/orgs/{id}/re-memos", orgId)) | |
| 131 | + .andExpect(jsonPath("$.length()").value(0)); | |
| 132 | + } | |
| 133 | + | |
| 134 | + @Test | |
| 135 | + void 없는_메모를_고치면_404다() throws Exception { | |
| 136 | + mvc.perform(put("/api/orgs/{id}/re-memos/{memoId}", orgId, 999999L).with(csrf()) | |
| 137 | + .contentType(MediaType.APPLICATION_JSON) | |
| 138 | + .content(body("없는 건", "장영익", "2026-07-08"))) | |
| 139 | + .andExpect(status().isNotFound()); | |
| 140 | + } | |
| 141 | +} |
+++ src/test/java/kr/itn/itnhub/stage/AutoStageTest.java
... | ... | @@ -0,0 +1,127 @@ |
| 1 | +package kr.itn.itnhub.stage; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.org.Organization; | |
| 5 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 6 | +import org.junit.jupiter.api.BeforeEach; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 9 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 10 | + | |
| 11 | +import java.util.List; | |
| 12 | + | |
| 13 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 자료가 올라온 것을 계기로 단계가 저절로 넘어가는 규칙(요구사항 [18][19]). | |
| 17 | + * | |
| 18 | + * <p>핵심은 "앞으로만 간다"는 것이다. 자료를 다시 올렸다고 단계가 뒤로 밀리면 안 된다.</p> | |
| 19 | + */ | |
| 20 | +class AutoStageTest extends AbstractDbTest { | |
| 21 | + | |
| 22 | + @Autowired | |
| 23 | + StageService stageService; | |
| 24 | + | |
| 25 | + @Autowired | |
| 26 | + OrganizationMapper orgMapper; | |
| 27 | + | |
| 28 | + @Autowired | |
| 29 | + StageHistoryMapper historyMapper; | |
| 30 | + | |
| 31 | + @Autowired | |
| 32 | + JdbcTemplate jdbc; | |
| 33 | + | |
| 34 | + private Long orgId; | |
| 35 | + | |
| 36 | + @BeforeEach | |
| 37 | + void setUp() { | |
| 38 | + jdbc.update("delete from stage_history"); | |
| 39 | + jdbc.update("delete from organization where org_no = '930'"); | |
| 40 | + | |
| 41 | + Organization org = new Organization(); | |
| 42 | + org.setOrgNo("930"); | |
| 43 | + org.setOrgName("자동전환테스트기관"); | |
| 44 | + org.setChannelSlug("930"); | |
| 45 | + orgMapper.upsertBySeed(org); | |
| 46 | + orgId = orgMapper.findByOrgNoAndOrgName("930", "자동전환테스트기관").getId(); | |
| 47 | + } | |
| 48 | + | |
| 49 | + private void setStage(int stage) { | |
| 50 | + orgMapper.updateStage(orgId, stage); | |
| 51 | + historyMapper.insert(orgId, stage, false, null); | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Test | |
| 55 | + void 뒤에_있으면_앞으로_넘어간다() { | |
| 56 | + setStage(1); | |
| 57 | + | |
| 58 | + boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED, | |
| 59 | + AutoStage.REASON_REVIEW_STARTED); | |
| 60 | + | |
| 61 | + assertThat(moved).isTrue(); | |
| 62 | + assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(AutoStage.REVIEW_STARTED); | |
| 63 | + } | |
| 64 | + | |
| 65 | + @Test | |
| 66 | + void 이미_앞서_있으면_뒤로_밀지_않는다() { | |
| 67 | + setStage(10); | |
| 68 | + | |
| 69 | + boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED, | |
| 70 | + AutoStage.REASON_REVIEW_STARTED); | |
| 71 | + | |
| 72 | + assertThat(moved).isFalse(); | |
| 73 | + assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(10); | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + void 같은_단계면_이력을_또_쌓지_않는다() { | |
| 78 | + setStage(AutoStage.REVIEW_STARTED); | |
| 79 | + int before = historyMapper.findByOrg(orgId).size(); | |
| 80 | + | |
| 81 | + boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED, | |
| 82 | + AutoStage.REASON_REVIEW_STARTED); | |
| 83 | + | |
| 84 | + assertThat(moved).isFalse(); | |
| 85 | + assertThat(historyMapper.findByOrg(orgId)).hasSize(before); | |
| 86 | + } | |
| 87 | + | |
| 88 | + @Test | |
| 89 | + void 채널이_없어_단계가_아직_없으면_건드리지_않는다() { | |
| 90 | + // 채널 생성으로 ①신청이 들어오는 것이 먼저다. | |
| 91 | + boolean moved = stageService.autoAdvance(orgId, AutoStage.LIST_RECEIVED, | |
| 92 | + AutoStage.REASON_LIST_RECEIVED); | |
| 93 | + | |
| 94 | + assertThat(moved).isFalse(); | |
| 95 | + assertThat(orgMapper.findById(orgId).getStage()).isNull(); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + void 이력에_자동_여부와_계기가_남는다() { | |
| 100 | + setStage(1); | |
| 101 | + stageService.autoAdvance(orgId, AutoStage.PROCESS_STARTED, AutoStage.REASON_PROCESS_STARTED); | |
| 102 | + | |
| 103 | + List<StageHistoryRow> history = historyMapper.findByOrg(orgId); | |
| 104 | + | |
| 105 | + assertThat(history).hasSize(2); | |
| 106 | + // 최신순이라 자동 전환이 앞에 온다. | |
| 107 | + assertThat(history.get(0).getStage()).isEqualTo(AutoStage.PROCESS_STARTED); | |
| 108 | + assertThat(history.get(0).isAuto()).isTrue(); | |
| 109 | + assertThat(history.get(0).getReason()).isEqualTo(AutoStage.REASON_PROCESS_STARTED); | |
| 110 | + // 사람이 넣은 것은 자동이 아니다. | |
| 111 | + assertThat(history.get(1).isAuto()).isFalse(); | |
| 112 | + assertThat(history.get(1).getReason()).isNull(); | |
| 113 | + } | |
| 114 | + | |
| 115 | + @Test | |
| 116 | + void 자동으로_올라간_단계를_사람이_되돌릴_수_있다() { | |
| 117 | + // 요구사항 [19]: 되돌림 허용. | |
| 118 | + setStage(1); | |
| 119 | + stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED, AutoStage.REASON_REVIEW_STARTED); | |
| 120 | + assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(AutoStage.REVIEW_STARTED); | |
| 121 | + | |
| 122 | + stageService.updateStage(orgId, new StageRequest(2)); | |
| 123 | + | |
| 124 | + assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(2); | |
| 125 | + assertThat(historyMapper.findByOrg(orgId).get(0).isAuto()).isFalse(); | |
| 126 | + } | |
| 127 | +} |
--- src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
+++ src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
... | ... | @@ -82,19 +82,34 @@ |
| 82 | 82 |
assertThat(mapper.findById(orgId).getStage()).isEqualTo(2); |
| 83 | 83 |
} |
| 84 | 84 |
|
| 85 |
+ /** |
|
| 86 |
+ * 상한은 코드표에 등록된 단계 수를 따른다. 요구사항 [11]로 13단계(보고서 제출 완료)가 |
|
| 87 |
+ * 생겼으므로 13은 이제 정상이고, 14부터 범위를 벗어난다. |
|
| 88 |
+ */ |
|
| 85 | 89 |
@Test |
| 86 | 90 |
void 범위를_벗어난_값은_400이다() throws Exception {
|
| 87 | 91 |
mvc.perform(put("/api/orgs/{id}/stage", orgId)
|
| 88 | 92 |
.with(csrf()) |
| 89 | 93 |
.contentType(MediaType.APPLICATION_JSON) |
| 90 |
- .content("{\"stage\": 13}"))
|
|
| 94 |
+ .content("{\"stage\": 14}"))
|
|
| 91 | 95 |
.andExpect(status().isBadRequest()) |
| 92 |
- .andExpect(jsonPath("$.message").value(containsString("1에서 12")));
|
|
| 96 |
+ .andExpect(jsonPath("$.message").value(containsString("1에서 13")));
|
|
| 93 | 97 |
|
| 94 | 98 |
assertThat(mapper.findById(orgId).getStage()).isNull(); |
| 95 | 99 |
} |
| 96 | 100 |
|
| 97 | 101 |
@Test |
| 102 |
+ void 마지막_단계인_13단계는_저장된다() throws Exception {
|
|
| 103 |
+ mvc.perform(put("/api/orgs/{id}/stage", orgId)
|
|
| 104 |
+ .with(csrf()) |
|
| 105 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 106 |
+ .content("{\"stage\": 13}"))
|
|
| 107 |
+ .andExpect(status().isOk()); |
|
| 108 |
+ |
|
| 109 |
+ assertThat(mapper.findById(orgId).getStage()).isEqualTo(13); |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ @Test |
|
| 98 | 113 |
void 하한_미만인_0단계는_400이다() throws Exception {
|
| 99 | 114 |
mvc.perform(put("/api/orgs/{id}/stage", orgId)
|
| 100 | 115 |
.with(csrf()) |
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?