# 약품–상병 판정 API(ddx) 연동 — 착수 전 수정 범위 분석

**Status**: active
**Date**: 2026-09-01
**Topic**: 약품–상병 판정 API(ddx) 연동 — 착수 전 수정 범위 분석

조사 질문: **외부 약품–상병 판정 API(`https://d.ins.rs-team.com`, 이하 ddx)를 붙이려면
이 저장소의 어디를 고쳐야 하는가. 기존 `ANS003B`(건강체 › 5년 내 30일 투약) 경로를 재사용할 수
있는가, 아니면 새로 깔아야 하는가.**

의뢰 메일이 지정한 대상은 "건강체 › 5년 내 30일 투약"이다. 이 저장소에서 그 화면의 정확한 좌표는
`ANS003B` 이며, **이미 별도 API(`dosing-30day-groups`)로 약효 카테고리 그루핑을 하고 있다.**
따라서 이 건은 신규 화면 개발이 아니라 **기존 축(약효분류) 위에 상병 축을 얹는 교체**다.
그 교체가 어디까지 번지는지를 판정한다.

## Premises

| # | 참조 | 시점 | 무엇을 전제하는가 |
|---|---|---|---|
| P1 | `.gitmodules` | 2a07460 | 서브모듈 3종(`insuniverse-server`·`insuniverse-site`·`insuniverse-admin`)이 이 저장소의 소스 정본이며 추적 브랜치는 전부 `stg` 다 |
| P2 | `insuniverse-site/front/src/features/analysisDetailView/constants/analysisDetail.const.ts:245` | 767d1e44 | 건강체 탭 목록에 `{ label: "5년 내 30일 투약", value: "ANS003B" }` 가 있다. 의뢰 메일의 "건강체 › 5년 내 30일 투약"은 `ANS003B` 다 |
| P3 | `insuniverse-site/front/src/features/analysisDetailView/ui/ANS003/ANS003B.tsx:39` | 767d1e44 | `ANS003B` 는 마운트 시 `getDosing30DayGroups(oddId, bodId, codId)` 를 **컴포넌트 안에서 직접** 호출한다. 상위 스토어(`analysisAggregate`)를 쓰지 않는다 |
| P4 | `insuniverse-site/front/src/features/analysisDetailView/ui/ANS003/ANS003B.tsx:83-105` | 767d1e44 | 렌더 축은 `categoryName`(약효 대분류) 아코디언 카드 1레벨이다. 상병 축 그룹핑이 없다 |
| P5 | `insuniverse-site/front/src/features/analysisDetailView/ui/ANS003/ANS003B.tsx:120-131` | 767d1e44 | 상세 모달 컬럼이 9개(`진료시작일`·`병·의원`·`진단코드`·`진단명`·`약품명`·`효능효과`·`1회투약량`·`1회투여횟수`·`총 투약일수`)로 하드코딩돼 있다. `판정 근거`·`bucket`·`role` 컬럼이 없다 |
| P6 | `insuniverse-site/front/src/api/diseasePredictionApi.ts:255` | 767d1e44 | `getDosing30DayGroups` 한 함수가 `codId`/`bodId`/`isAdminViewPath()` 조합으로 **URL 6종**을 분기한다 |
| P7 | `insuniverse-server/apps/api/src/modules/medication-standard/medication-standard.repository.ts:597` | 4d798f55 | 서버 측 데이터 산출은 `MedicationStandardRepository.getDosing30DayGroups(oddId?, bodId?, codId?)` 한 메서드에 모여 있다 |
| P8 | `insuniverse-server/apps/api/src/modules/medication-standard/medication-standard.repository.ts:624-628` | 4d798f55 | 처방 행과 약효분류 마스터의 결합이 **`innerJoin` + `REPLACE(약품명,' ','') = REPLACE(mscCode,' ','')` 문자열 완전일치**다. 마스터에 없는 약품명은 결과에서 사라진다 |
| P9 | `insuniverse-server/apps/api/src/modules/medication-standard/medication-standard.repository.ts:716-718` | 4d798f55 | 30일 임계는 **약효 대분류 그룹 합계**(`totalDosingDays >= 30`)로 판정한다. 개별 약 단위가 아니다 |
| P10 | `insuniverse-server/apps/api/src/modules/medication-standard/medication-standard.repository.ts:724-761` | 4d798f55 | 상병은 처방 행이 직접 갖지 않는다. 기본진료(`tt_*_treat_basic`)를 따로 조회해 `(진료시작일, 병의원명)` 키로 매칭한 `diagnosisMap` 으로 붙인다 |
| P11 | `insuniverse-server/apps/api/src/modules/medication-standard/medication-standard.entity.ts:43-50` | 4d798f55 | `MedicationStandard` 의 `mscCode` 는 스키마상 '중분류 코드', `mscName` 은 '중분류명'이다. P8 의 조인은 그 `mscCode` 열을 약품명으로 쓰고 있고, 프런트 `효능효과` 컬럼(P5)에 실리는 값은 `mscName` 이다 |
| P12a | `insuniverse-server/apps/api/src/modules/analyze/analyze.controller.ts:229` | 4d798f55 | 정회원 조회 라우트 `GET analyze/:oddId/dosing-30day-groups` 가 여기 있다 |
| P12b | `insuniverse-server/apps/api/src/modules/analyze/analyze.controller.ts:398` | 4d798f55 | B2C 조회 라우트 `GET analyze/b2c/:bodId/dosing-30day-groups` 가 같은 컨트롤러에 있다 |
| P13a | `insuniverse-server/apps/api/src/modules/analyze/analyze-admin-view.controller.ts:118` | 4d798f55 | admin-view 정회원 라우트가 같은 산출을 재노출한다 |
| P13b | `insuniverse-server/apps/api/src/modules/b2c/analyze/b2c-analyze-admin-view.controller.ts:123` | 4d798f55 | admin-view B2C 라우트가 같은 산출을 재노출한다 |
| P13c | `insuniverse-server/apps/api/src/modules/client/analyze/client-analyze-admin-view.controller.ts:127` | 4d798f55 | admin-view 고객사 라우트가 경로명만 `dosing30d` 로 다르게 같은 산출을 재노출한다 |
| P13d | `insuniverse-server/apps/api/src/modules/client/analyze/client-analyze-admin.controller.ts:153` | 4d798f55 | 관리자 고객사 라우트가 같은 산출을 재노출한다. P12a·P12b 와 합쳐 **내부 조회 라우트 6종** |
| P14 | `insuniverse-server/apps/api/src/modules/client/client/client.controller.ts:307` | 4d798f55 | `GET clients/analyze/health/dosing30d` 는 고객사에게 **외부로 나가는** 계약이다(옵션코드 `CCT024`). 응답이 바뀌면 저장소 밖 소비자가 깨진다 |
| P15 | `insuniverse-server/apps/api/src/config/configuration.ts` | 4d798f55 | 설정은 `process.env` 를 읽는 평면 팩토리 하나다. 외부 API 자격증명(`codef`·`octover`·`hyphen`·`toss`·`aligo`)이 전부 여기 모여 있다. 신규 외부 API 도 같은 자리에 붙는다 |
| P16 | `insuniverse-server/apps/api/src/config/configuration.ts:21` | 4d798f55 | 심평원 '내가 먹는약 한눈에' API 가 `codef.drugUrl` 로 이미 연동돼 있다 — **외부 약품 API 선례가 존재한다** |
| P17 | `insuniverse-server/drizzle/schema.ts:2288` | 4d798f55 | 그 심평원 API 의 응답은 `tt_treat_medicine_summary` 라는 **전용 저장 테이블**에 적재된다. 외부 응답 캐시의 저장소 내 선례다 |
| P18 | `insuniverse-server/apps/api/src/modules/treat-test/vendors/http.ts:10-33` | 4d798f55 | 외부 호출 헬퍼가 `axios` 직접 사용이며 `validateStatus: () => true` 로 4xx/5xx 바디를 확보하고 전송오류를 `_transportError` 로 감싼다. `@nestjs/axios`(HttpModule)를 쓰지 않는다 |
| P19 | `insuniverse-server/apps/api/src/modules/analyze/analyze-prescibe.entity.ts:53-57` | 4d798f55 | 처방 엔티티에 이미 `aspEfficacy`('효능효과') 필드가 있다. 신규 API 의 `indication` 과 이름이 겹친다 |
| P20a | `insuniverse-site/front/src/pages/analysis/list/detail/DownloadHealthyPdf.tsx:183` | 767d1e44 | 정회원 PDF 생성기가 같은 `getDosing30DayGroups` 를 호출해 7컬럼 표를 그린다. 화면과 별도 소비자다 |
| P20b | `insuniverse-site/front/src/pages/analysis/list/detail/B2cDownloadHealthyPdf.tsx:183` | 767d1e44 | B2C PDF 생성기가 같은 호출을 한다 |
| P21 | `insuniverse-site/front/src/features/analysisDetailView/ui/ANS003/ANS003B.test.tsx` | 767d1e44 | `ANS003B` 에 vitest 테스트가 있고 `categoryName` 축 응답 형상을 고정한다 |
| P22a | `insuniverse-admin/src/features/analysis/ui/ANS003/detail.tsx:17` | fb001e1d | 어드민 정회원 `ANS003` 은 `ansType: "ANS003"` 레거시 목록 API 를 쓴다 |
| P22b | `insuniverse-admin/src/features/clientAnalysis/ui/ANS003/detail.tsx:17` | fb001e1d | 어드민 고객사 `ANS003` 도 같다. 어드민 전체에 `ANS003B` 도 `dosing30d` 호출도 **없다** |
| P23 | `docs/03-contracts/05-external-client-api.md:218` | 2a07460 | 고객사 라우트 결속 집합에 `clients/analyze/health/dosing30d` 가 동결돼 있고, 같은 문서 255행의 `route_option_map` 이 그것을 `CCT024` 에 묶는다 |
| P24 | `.claude/_meta/README.md` | 2a07460 | `## 색인` 표가 존재하며 이 문서가 등재 대상이다 |

**전제의 사정거리** — P1·P23·P24 는 이 저장소(worktree `drug-diag-api`, 브랜치 `feat/drug-diag-api`,
HEAD `2a07460`)의 파일이다. 그 외는 **서브모듈 파일이며 시점 열의 SHA 는 `main` 이 고정한 포인터**다
(`insuniverse-server` `4d798f55` · `insuniverse-site` `767d1e44` · `insuniverse-admin` `fb001e1d`).
이 worktree 는 그 포인터로 `submodule update --init` 해서 파일을 실재화한 상태이므로 참조 경로가
전부 실존한다.

주의: **주 worktree 의 서브모듈 체크아웃은 이 포인터와 다르다** (`insuniverse-server` 는
`hotfix/deeplink-prod`, `insuniverse-site`·`insuniverse-admin` 은 `stg` tip). P2~P22b 의 근거 파일은
양쪽에서 `cmp` 로 대조해 `drizzle/schema.ts` 하나를 빼고 **바이트 동일**임을 확인했고, 그 하나도
`tt_treat_prescribe` 블록은 `diff` 로 동일함을 확인했다(§Evidence E0). 즉 이 조사의 판정은 두 시점
어디서 봐도 같다. 다만 착수 시점의 `stg` tip 은 다시 확인해야 한다.

ddx API 의 계약(§Evidence E1)은 **저장소 밖**이라 전제 표에 담기지 않는다. 재현하려면 §Evidence 의
명령을 다시 돌려야 한다.

## Evidence

### E0 — 전제 시점 대조

주 worktree(서브모듈이 `stg`/`hotfix` tip)와 이 worktree(`main` 고정 포인터)에서 근거 파일 9종을
`cmp -s` 로 대조했다.

| 결과 | 파일 |
|---|---|
| SAME | `ANS003B.tsx` · `medication-standard.repository.ts` · `analyze.controller.ts` · `configuration.ts` · `diseasePredictionApi.ts` · `client.controller.ts` · `DownloadHealthyPdf.tsx` · `DetailAnalysisPopup.tsx` |
| DIFF | `insuniverse-server/drizzle/schema.ts` (3113행 vs 3049행) |

`schema.ts` 는 파일 전체로는 다르지만 `ttTreatPrescribe` 정의 블록은 `diff` 결과 무차이였다.
`ttTreatMedicineSummary` 는 이 worktree 기준 2288행이다(P17).

### E1 — ddx API 계약 실측

`https://d.ins.rs-team.com/openapi.json` 을 인증 없이 취득했다(HTTP 200, 35,759 bytes).
Swagger UI(`/docs`)는 SPA 라 정적 취득이 안 되지만 스펙 JSON 은 열려 있다.

엔드포인트 10종 중 이 연동이 쓰는 것은 4종이다.

| 메서드 | 경로 | 쿼리 | 용도 |
|---|---|---|---|
| POST | `/analyze` | `wait`(number, 기본 0) | 판정 요청 |
| GET | `/analyses/{analysis_id}` | `refresh`(bool) · `wait`(number) | 재조회 · 폴링 |
| DELETE | `/analyses/{analysis_id}` | — | 환자 데이터 삭제 |
| GET | `/health` | — | 헬스체크 |

나머지 6종(`/insight` 계열 3 · `/quarantine` · `/clients` · `/audit` · `/maintenance/purge`)은
운영·인사이트용이며 연동용 키에는 권한이 없다고 메일이 명시했다.

인증은 `securitySchemes.APIKeyHeader` — `X-API-Key` 헤더.

**요청 — `AnalyzeRequest`**

| 필드 | 타입 | 필수 |
|---|---|---|
| `person` | `Person{ name*, phone }` | 필수 |
| `records` | `PrescriptionRecord[]` | 필수 |
| `external_ref` | string | 선택 — 호출자 식별자 |
| `store` | boolean | 선택 — `false` 면 저장 안 함 |

`PrescriptionRecord` 의 필수는 `drug_name` **하나뿐**이고, 나머지는 전부 선택이다:
`drug_class` · `indication` · `provider` · `treatment_start_date`(YYYY-MM-DD) · `diagnosis_codes[]` ·
`diagnosis_names[]` · `days_supplied` · `dose_per_administration` · `administrations_per_day` ·
`class_total_days`.

**응답 — `AnalyzeResponse`**

`analysis_id`(서버 발급) · `external_ref` · `person_name` · `status`(`ready`|`partial`) · `stats` ·
`groups[]`(상병 축) · `drug_classes[]`(약효분류 축).

`DiagnosisGroup` = `person` · `dx_code` · `dx_name` · `dx_chapter_name` · `is_administrative` ·
`n_episodes` · `diag_drugs[]` · `pending_drugs[]`.

`ClassifiedDrug` 의 판정 3필드는 enum 이 고정돼 있다.

| 필드 | 값 |
|---|---|
| `bucket` | `주치료` · `보조치료` · `이 상병과 무관` |
| `role` | `주치료` · `보조치료` · `대증완화` · `무관` · `판단불가` |
| `label` | `해당` · `비해당` · `판단불가` |

그 외 `drug_name`(필수) · `ingredient` · `drug_class`(필수) · `indication` · `item_seq` · `edi_code` ·
`atc_code` · `confidence`(필수) · `evidence` · `evidence_basis[]` · `rationale`(필수) · `source`(필수) ·
`n_rows` · `days_supplied` · `prescriptions[]`.

`PendingDrug` 는 **별도 배열이며 `label`·`bucket`·`role` 자체가 없다.** 스펙 본문이 명시한다 —
"`pending_drugs` 는 별도 배열입니다 — 아직 판정 전이라 `label` 자체가 없습니다 … `판단불가` 와
다릅니다." 메일의 "pending 은 판단불가와 다릅니다"가 스키마 수준의 사실임을 확인했다.

### E2 — 기존 `ANS003B` 파이프라인의 실제 모양

```
tt_treat_prescribe (처방)                        tt_treat_basic (기본진료 = 상병)
   │ innerJoin 약품명 == mscCode (문자열 완전일치)      │
tc_medication_standard (중분류)                      │
   │ innerJoin mscCategoryId                        │
tc_medication_category (대분류 = categoryName)       │
   │                                                │
   └─ 대분류별 그룹 → 합계 >= 30일 필터 ──────────────┘
                                    (진료시작일, 병의원명) 키로 상병 붙임
```

관측된 성질 4가지:

1. **약효 대분류가 1차 축이다.** 상병은 레코드에 딸린 부속 정보로만 존재한다(P4·P10).
2. **마스터 미등재 약은 조용히 사라진다.** P8 의 `innerJoin` 때문이다. 30일 임계 계산에서도 빠진다.
3. **30일 임계가 그룹 합계다.** 개별 약 3종이 각 10일이면 합쳐서 30일로 잡힌다(P9).
4. **상병 매칭 키가 `(진료시작일, 병의원명)`** 이다. 같은 날 같은 병원 방문의 모든 상병이 그 날의
   모든 약에 붙는다 — 메일이 "무관이 많이 나오는 건 버그가 아닙니다. 원본 데이터 구조상 발생하며"
   라고 쓴 그 구조가 이것이다. ddx 가 걸러내려는 대상이 바로 이 매칭 결과다.

### E3 — 필드 대응 (기존 → ddx 요청)

| ddx `PrescriptionRecord` | 저장소 산출 필드 | 비고 |
|---|---|---|
| `drug_name` (필수) | `drugName` | `trp_drug_name` 원문 |
| `drug_class` | `categoryName` 또는 `efficacy` | `efficacy` 는 실제로 중분류명(P11) |
| `indication` | `efficacy` | 이름은 '효능효과'지만 값은 중분류명. **원문 효능효과가 아니다** |
| `provider` | `hospitalName` | |
| `treatment_start_date` | `treatStartDate` | 이미 `YYYY-MM-DD` |
| `diagnosis_codes[]` | `diagnoses[].diseaseCode` | |
| `diagnosis_names[]` | `diagnoses[].diseaseName` | |
| `days_supplied` | `totalDosingDays` | |
| `dose_per_administration` | `oneDose` | |
| `administrations_per_day` | `dailyDosesNumber` | |
| `class_total_days` | 그룹 `totalDosingDays` | 그룹핑 후 계산 |

**빈칸이 없다.** 기존 산출이 ddx 요청 스키마를 전부 채운다 — 새 수집이 필요 없다.
단 `indication` 은 의미가 어긋난다(중분류명 ≠ 허가 효능효과). ddx 는 자기 쪽 식약처 데이터로
판정하므로 이 필드는 비워 보내도 되고, 잘못 채우면 오히려 노이즈다.

`Person.name` 은 **필수**다. 저장소에서 이 값은 `tn_member.mem_name` 계열의 실명이다.

### E4 — 소비자 목록 (교체 시 손대야 하는 자리)

**서버 — 조회 라우트 6종 + 외부 계약 1종**

| # | 파일 · 행 | 경로 |
|---|---|---|
| 1 | `analyze.controller.ts:229` | `GET analyze/:oddId/dosing-30day-groups` (정회원) |
| 2 | `analyze.controller.ts:398` | `GET analyze/b2c/:bodId/dosing-30day-groups` |
| 3 | `analyze-admin-view.controller.ts:118` | `GET admin-view/analyze/:oddId/dosing-30day-groups` |
| 4 | `b2c-analyze-admin-view.controller.ts:123` | `GET admin-view/b2c-analyze/:bodId/dosing-30day-groups` |
| 5 | `client-analyze-admin-view.controller.ts:127` | `GET admin-view/client-analyze/:codId/dosing30d` |
| 6 | `client-analyze-admin.controller.ts:153` | `GET .../:codId/dosing30d` (관리자) |
| 7 | `client.controller.ts:307` | `GET clients/analyze/health/dosing30d` — **외부 고객사 계약 `CCT024`** |

1~6 은 전부 `MedicationStandardRepository.getDosing30DayGroups` 한 곳으로 수렴한다(P7).
7 은 `client-analyze.repository.getClientAnalyzeHealthDosing30d` 로 **다른 경로**다.

**사이트 — 호출 지점 3곳, URL 변형 6종**

| # | 파일 · 행 | 비고 |
|---|---|---|
| 1 | `ANS003B.tsx:39` | 화면 |
| 2 | `DownloadHealthyPdf.tsx:183` | 정회원 PDF |
| 3 | `B2cDownloadHealthyPdf.tsx:183` | B2C PDF |

`diseasePredictionApi.ts:255` 의 함수 하나가 URL 6종을 내부 분기한다(P6). 함수 시그니처를 유지하면
호출 지점 3곳은 타입만 바뀐다.

마운트 페이지는 4곳 — `AnalysisDetailPage` · `AdminViewMemberAnalysisPage` ·
`AdminViewClientAnalysisPage` · `AdminViewB2cAnalysisPage`.

**어드민 — 0곳.** P22a·P22b 대로 어드민은 레거시 `ansType=ANS003` 목록만 쓴다. 이번 범위 밖이다.

## Prior Art

**외부 문헌 조사는 미조사다.** ATC 분류 체계나 약품–상병 판정의 일반적 접근에 대한 문헌 조사는
하지 않았다. 이 건은 판정 알고리즘을 이 저장소가 만드는 게 아니라 **외부 API 의 결과를 받아 쓰는**
연동이므로, 판정 방법론 문헌은 이 조사의 판단에 들어오지 않는다.

대신 **대상 API 의 OpenAPI 스펙을 직접 취득해 계약을 실측했다** (§Evidence E1). 이것이 이 조사가
가진 유일한 저장소 밖 근거이며, 스펙 본문에 적힌 판정 정의(허가 효능효과 포함 여부이지 임상적
타당성이 아니라는 것, `pending` ≠ `판단불가`)를 그대로 인용했다.

의뢰 메일 자체는 문헌이 아니라 **요구사항 원문**이다. 메일이 강조한 4가지(서버 환경변수 전용 ·
POST 재전송 금지 · `bucket` 으로 화면 그룹핑 · `pending` 분리)는 §Verdict 의 제약으로 옮겼다.

## Precedent

### 저장소 안 선례

| 선례 | 위치 | 이 건과의 관계 |
|---|---|---|
| 심평원 '내가 먹는약 한눈에' API | `configuration.ts:21`(`codef.drugUrl`) · `drizzle/schema.ts:2288`(`tt_treat_medicine_summary`) | **가장 가까운 선례.** 외부 약품 API 를 붙이고 응답을 전용 테이블에 적재한 뒤 분석 화면에서 소비한다. 신규 연동이 그대로 따를 골격 |
| 외부 호출 헬퍼 | `treat-test/vendors/http.ts:10` | `axios` 직접 + `validateStatus: () => true`. `HttpModule` 도입은 이 저장소 관례가 아니다 |
| 외부 자격증명 배치 | `configuration.ts` | `codef`·`coocon`·`octover`·`hyphen`·`toss`·`aligo` 전부 여기. 메일의 "서버 환경변수로만" 요구가 기존 관례와 정확히 일치한다 |
| 고객사 외부 계약 문서 | `docs/03-contracts/05-external-client-api.md:218` · `:255` | `clients/analyze/health/dosing30d=CCT024` 가 결속 집합으로 동결. 7번 라우트를 건드리면 이 문서도 같이 움직인다 |

### 같은 문제를 이미 판정한 문서

`.claude/_meta/` 에는 이 주제의 선행 조사가 **없다**. 유일한 기존 문서
`2026-08-26-70-viewer-reanalysis.md` 는 문서 뷰어 건이며 무관하다.

`.claude/meta/` · `.claude/prds/` · `.claude/plans/` 에서 `ANS003B`·`dosing30`·`약효분류` 를 검색한
결과 걸리는 문서는 전부 **문서화 파이프라인(`insuniverse-docs-*`)** 의 산출물이다 — 즉 이 라우트를
*기술한* 문서이지 *바꾸자고 판정한* 문서가 아니다. **판정 충돌 없음.**

### 선행 문서의 전제 무효화 여부

`docs/03-contracts/05-external-client-api.md` 의 결속 집합(P23)은 이 조사 시점에도 유효하다
(`CCT024` 매핑 실재 확인). 다만 **7번 라우트의 응답 형상을 바꾸면 이 문서가 그 순간 stale 이 된다.**
상태 갱신 제안은 §Open Questions Q4 로 넘긴다 — 이 커맨드는 남의 문서를 고치지 않는다.

## Verdict

### 판정 1 — 신규 화면이 아니라 기존 `ANS003B` 의 축 교체다

> **대체됨 (2026-09-02 · PRD 확정).** 아래의 *"1차 축: 약효 대분류 → 상병"* 서술은 **축 교체**를
> 뜻하지 않는다. PRD §Scope 는 **탭 2개 병행**으로 확정했다 — 상병 축을 새로 얹고 기존 약효분류
> 축은 두 번째 탭으로 그대로 남는다(UI10). 이 절의 나머지 서술(수집·인증·라우팅 불변, 판정 3필드
> 추가)은 유효하다.

`ANS003B` 는 이미 존재하고, 이미 전용 API 를 갖고 있고, 이미 상병을 레코드에 붙이고 있다(E2).
ddx 가 바꾸는 것은 **1차 축**이다: 약효 대분류 → 상병. 그리고 각 약에 판정 3필드(`bucket`·`role`·
`label`)와 `rationale` 이 붙는다. 데이터 수집·인증·라우팅은 전부 그대로다.

### 판정 2 — 페이로드는 기존 쿼리를 재사용하되 `innerJoin` 은 못 쓴다

> **대체됨 (2026-09-02 · PRD 확정).** 아래가 권하는 `leftJoin` 판본·처방 직독은 **채택하지 않는다.**
> PRD §Scope 는 판정 대상을 **기존 30일 그룹에 든 약만**으로 확정했다(UI13) — 현행 화면 구성이
> 흔들리지 않는 쪽이다. 마스터 미등재 약을 못 보내는 손실은 PRD §Risks 에 알려진 귀결로 실려 있다.

E3 대로 ddx 요청 필드는 기존 산출로 **빈칸 없이** 채워진다. 그러나 P8 의 `innerJoin` 이
약효분류 마스터 미등재 약을 떨어뜨린다. ddx 는 약품명 원문으로 자기 쪽 식약처 데이터를 조회하므로
**마스터에 없는 약이야말로 ddx 가 값을 내는 자리**다. 그 약을 보내지 않으면 연동의 이득이 절반
사라진다.

→ 페이로드 빌더는 `getDosing30DayGroups` 를 그대로 부르지 말고, **`leftJoin` 판본**을 따로 두거나
처방 행을 직접 읽어야 한다. 30일 임계도 같이 흔들린다(P9) — 마스터 미등재 약이 합계에 들어오면
그룹 구성이 바뀐다. **이 결정이 이 건의 최대 분기점이다** (§Open Questions Q1).

### 판정 3 — 폴링은 서버가 삼키고, 프런트 계약은 동기로 유지한다

메일의 "cache-or-pending" 은 `POST /analyze?wait=15` → `ready` 아니면 5~10초 뒤
`GET /analyses/{id}` 폴링이다. 이걸 프런트가 하면:

- `ANS003B` 는 마운트마다 호출한다(P3). PDF 2종도 별도로 부른다(P20a·P20b). 같은 환자를 4번 왕복하게 된다.
- API 키가 프런트로 내려갈 수 없다(메일 명시). 프록시는 어차피 서버가 한다.

→ 서버가 `POST` → 폴링 → 응답 적재까지 삼키고, 프런트에는 지금과 같은 **단일 동기 GET** 을 유지한다.
적재 테이블은 `tt_treat_medicine_summary`(P17) 를 mirror 한다. `external_ref` 에는 `oddId`/`bodId`/
`codId` 를 실어 왕복 대조에 쓴다.

### 판정 4 — 어드민 서브모듈은 이번 범위 밖

P22a·P22b. 어드민에 `ANS003B` 가 없다. admin-view 3페이지는 **사이트** 서브모듈 소속이다.

### 판정 5 — 외부 고객사 계약(`CCT024`)은 분리 판단

7번 라우트는 저장소 밖 소비자를 갖는다(P14·P23). 내부 6종과 같이 바꾸면 고객사가 깨진다.
**1차 범위는 내부 6종 + 화면·PDF 로 끊고, `CCT024` 는 별도 결정으로 미룬다.**

### 수정 범위 (판정 기준)

**insuniverse-server**

| # | 대상 | 성격 |
|---|---|---|
| S1 | `config/configuration.ts` — `ddx: { baseUrl, apiKey }` 추가 | 신규 |
| S2 | ddx 클라이언트 (`POST /analyze?wait` · `GET /analyses/{id}` 폴링 · `DELETE`) | 신규. `treat-test/vendors/http.ts` 패턴 |
| S3 | 페이로드 빌더 — 처방 행 → `PrescriptionRecord[]` (판정 2 대로 `innerJoin` 회피) | 신규 |
| S4 | 응답 적재 테이블 + 마이그레이션 (`tt_treat_medicine_summary` mirror) | 신규 |
| S5 | `medication-standard.{repository,service,dto}.ts` — 상병 축 응답 DTO | 수정 |
| S6 | 내부 라우트 6종 (E4 표 1~6) | 수정 또는 병행 신설 |
| S7 | `client.controller.ts:307` (`CCT024`) | **보류** — 판정 5 |

**insuniverse-site**

| # | 대상 | 성격 |
|---|---|---|
| F1 | `api/diseasePredictionApi.ts:255` — 타입 교체 (URL 6종 분기는 유지) | 수정 |
| F2 | `ANS003B.tsx` — 상병 카드 축 · `bucket` 3분할 · `pending` 별도 구획 · `rationale` 컬럼 | 재작성 |
| F3 | `DownloadHealthyPdf.tsx` · `B2cDownloadHealthyPdf.tsx` 표 컬럼 | 수정 |
| F4 | `analysisDetail.const.ts:24` — `ANS003B` 안내 문구 ("무관 다수는 정상") | 수정 |
| F5 | `ANS003B.test.tsx` — `categoryName` 축 고정 픽스처 교체 | 수정 |

**insuniverse-admin** — 없음 (판정 4).

**docs** — `docs/03-contracts/05-external-client-api.md` 는 S7 을 하는 경우에만.

### 지켜야 하는 제약 (메일 원문 → 구현 제약)

1. API 키는 **서버 환경변수 전용**. 프런트 응답에 어떤 형태로도 실리지 않는다. → S1 이 `configuration.ts`
   에만 들어가고 DTO 어디에도 노출되지 않는지가 검사 대상.
2. **재조회는 `GET`.** `POST /analyze` 재전송 금지. → S2 의 폴링 루프가 `POST` 를 부르지 않는지.
3. 화면 그룹핑은 `bucket`, 세부 사유는 `role`, 효능효과 포함 여부는 `label`. → F2 가 셋을 혼용하지
   않는지.
4. `pending` 은 `판단불가`와 **다른 구획**. → F2 에서 `pending_drugs` 가 `label: 판단불가` 와 섞이지
   않는지. 스키마상 `pending_drugs` 에는 `label` 자체가 없으므로(E1) 섞으면 타입이 먼저 깨진다.
5. 첫 연결은 환자 1명 왕복 확인 후 배치.

## Open Questions

**Q1 — 페이로드 대상 집합을 어디까지 넓히는가.** (판정 2)
**해소 (2026-09-02 · PRD 확정)** — 선택지 (a) **기존 30일 그룹에 든 약만**이다(UI13). 아래 세 선택지
서술과 판정 2 의 `leftJoin` 권고는 대체됐다.

`innerJoin` 을 `leftJoin` 으로 바꾸면 마스터 미등재 약이 들어오고, 그 순간 30일 임계 그룹 구성이
바뀐다(P9). 선택지 셋: (a) 기존 30일 그룹에 든 약만 보낸다 — 화면은 안 흔들리지만 ddx 의 강점을
못 쓴다. (b) 5년 내 전 처방을 보낸다 — 판정은 최대지만 요청 크기와 비용이 커지고 30일 정의가
무너진다. (c) 30일 그룹 + 같은 방문(`진료시작일`,`병의원`)의 미등재 약. **결정 필요.**

**Q2 — `Person.name` 에 실명을 보내는가.** (E3)
스키마상 필수다. 실명 대신 `external_ref` 기반 가명(예: `odd-12345`)을 보내도 API 는 동작할 것으로
보이지만 **미검증**이다. 응답 `person_name` 과 `DiagnosisGroup.person` 이 그 값을 되돌려주므로
화면에는 영향이 없다. 개인정보 최소수집 관점에서 가명이 맞지만, 판정 품질에 이름이 쓰이는지는
스펙만으로 알 수 없다. **제공자에게 확인 필요.**

**Q3 — 기존 약효분류 축을 버리는가, 병행하는가.**
**해소 (2026-09-02 · PRD 확정)** — **탭 2개 병행**이다(UI10). 축을 갈아치우지 않는다. 아래의
*"축을 갈아치우면 화면이 단순해진다"* 와 판정 1 의 축 교체 서술은 대체됐다.

ddx 응답은 `groups`(상병 축)와 `drug_classes`(약효분류 축)를 **동시에** 준다. 즉 현행 `ANS003B` 화면
(약효 대분류 아코디언)을 `drug_classes` 로 그대로 재현할 수 있다. 탭 2개로 병행하면 회귀 위험이
낮고, 축을 갈아치우면 화면이 단순해진다. **UX 결정.**

**Q4 — `CCT024` 외부 계약을 이번에 건드리는가.** (판정 5 · P14 · P23)
건드린다면 `docs/03-contracts/05-external-client-api.md` 의 결속 집합이 stale 이 되고 고객사 공지가
따라온다. 이 조사는 **보류**를 권하지만 결정권 밖이다.

**Q5 — 적재 테이블을 3축으로 나누는가 1개로 합치는가.**
기존 진료 테이블은 정회원/B2C/고객사가 `tt_treat_*` · `tt_b2c_treat_*` · `tt_client_treat_*` 로
**3벌 분리**돼 있다(P10). 선례를 따르면 3개, 축 컬럼 하나로 합치면 1개다. 선례 일관성 대 중복.

**Q6 — 캐시 만료와 `DELETE` 호출 시점.** (미검증)
ddx 는 30일 자동 만료 + `DELETE /analyses/{id}` 를 제공한다. 이 저장소의 분석 데이터도 30일 보존
정책을 화면에 고지하고 있다(`AnalysisListPage`). 두 만료를 맞출지, 분석 삭제 시 ddx `DELETE` 를
연쇄시킬지 정해야 한다.

**Q7 — 착수 시점 서브모듈 tip 재확인.** (전제의 사정거리)
이 조사는 `main` 고정 포인터 기준이다. 실제 착수는 `stg` tip 에서 하게 되며, E0 대조는 조사 시점의
것이다. 착수 직전에 E0 을 한 번 더 돌려야 한다.
