# task-3067 — 보험금 청구계산기 Phase 2: 진료비세부내역서 업로드 → 파싱 → 자동 채움

- **팀**: dev4 (비슈누) · **레벨**: Lv.3 · **저장소**: `Jeon-Jonghyuk/InsuRo`
- **PR**: **#279 (draft, OPEN)** · base `ebd040a` → head `47056b5`
- **결과**: 구현 완료 · **머지/배포는 ANU** (자동 머지 금지)
- **명세 sha256(앞16)**: `c654553abb62c6c1` (일치 확인)

## 봉투 한 줄
**실물 영수증 파싱 성공(6/6 기대치 일치) · 파싱 실패 화면 표시 확인(행 0건 추가) · 업로드 없는 수동 계산 무손상.**

---

## 1. 실물 영수증 파싱 대조표 (실제 Vision 호출 1회)

★ 개인정보 보호를 위해 **환자명·등록번호·질병군(DRG)·병원명·카드번호를 마스킹한 사본**으로 호출했다.
아래 표에 환자 식별정보는 일절 없다.

| 항목 | 기대(명세) | 파싱 실측 | 판정 |
|---|---|---|---|
| 요양기관 종류 | 종합병원 | `provider_tier="general"` | ✅ |
| 진료 구분 | 외래(입원료 공란) | `care_type="outpatient"` | ✅ |
| 급여 본인부담 ① | 268,400 | 275,298 에 포함 | ✅ |
| 급여 전액본인부담 ③ | — | 275,298 에 포함 | ✅ |
| 비급여 ⑤ | 40,058 | `uncovered=40,058` | ✅ |
| 공단부담 ② | 264,030 | **의도적으로 제외** | ✅ |
| 환자부담총액 ⑧ | 315,350 | 대조값으로 사용 | ✅ |

산출된 엔진 입력 초안 2건:
```
covered   (①268,400 - ⑥0) + ③6,898 = 275,298   "급여 본인부담(①)+전액본인부담(③)"
uncovered ④0 + ⑤40,058              =  40,058   "비급여 선택진료료(④)+선택진료료 외(⑤)"
items_total 315,356  vs  영수증 ⑧ 315,350  → difference 6, within_tolerance=True → status "ok"
```

### ★ ANU 명세 반증 1건 — 기대값 6종이 서로 산술적으로 맞지 않는다
고배율 재판독으로 인쇄값을 확정한 결과:
```
①268,400 + ②264,030 + ③6,898 + ④(공란) + ⑤40,058 = 579,386   (인쇄 ⑦ = 579,390, +4)
①        +           ③6,898 + ④        + ⑤40,058 = 315,356   (인쇄 ⑧ = 315,350, -6)
```
영수증 자체의 **10원 단위 절사/반올림**이다. 명세는 이 6개 숫자를 모두 "기대값"으로
제시했지만 **동시에 참일 수 없다.** 정확한 일치를 요구하는 검증을 넣었다면
**정상 영수증을 오탐으로 거부**했을 것이다.
→ `RECONCILE_TOLERANCE_WON = 10` 을 계약 상수로 두고, 오차를 넘으면
"항목을 놓쳤다 = **과소추정**" 경고로 전환하도록 설계했다. 실물은 차이 6원으로 통과.

### ★ 반증 2건 — 명세가 지목한 재사용 자산이 그대로는 못 쓰인다
`policy_extract.vision.AnuVisionClient` 는 **증권 전용**이다. 공개 메서드가
(a) `vision._build_prompt`(증권 프롬프트)를 쓰고 (b) 출력을 `_parse_vision_output` 으로
`ExtractResult(header, coverages)` 에 강제 변환한다. 영수증 JSON 은 `coverages` 키가 없어
**모든 숫자가 소실**된다(테스트 `test_policy_vision_pipeline_would_lose_receipt_json` 이
`268400`·`315350` 이 결과 객체에 실제로 부재함을 단언).
→ `_stage_pages` + `_run_claude_vision_cli` 를 **호출만** 하는 `AnuReceiptVisionClient` 를
따로 정의했다(PII 정리용 `finally: shutil.rmtree` 유지). `policy_extract/**` 무수정.

## 2. severity · special_benefit 조달 방식 (판단 + 근거)

**severity → 사용자 입력 전용 (자동 채움 안 함).**
- 영수증에 진단코드·질병명이 구조적으로 없다.
- 5세대에서만 쓰이며 화면도 GEN5 일 때만 필드를 노출한다.
- 잘못 채우면 지급 예상액이 크게 달라지는데, 방어할 근거가 영수증에 없다.
- 어떤 경로로 파싱하든 `needs_manual` 에 항상 포함된다(`_finalize` 강제).

**special_benefit → MRI/PET 행만 자동, 나머지는 경고 (ANU 권고 (a)+(c) 채택).**
- `engine.py:242` 가 `cell.special_benefit != item.special_benefit` **정확 일치**로 셀을 고른다.
  오배정하면 다른 공제/한도 셀이 잡히거나 항목이 통째로 탈락한다.
- 영수증 행 분류와 실손 특약 분류는 **1:1이 아니다.** `재활및물리치료료`가 곧 도수치료가
  아니고 `주사료`가 곧 비급여 주사 특약이 아니다.
- 그래서 **모호하지 않은 MRI/PET 행만** 자동 배정하고(중복 합산 방지로 일반 비급여에서 차감),
  주사료·물리치료는 **경고만** 띄운다. 실물에서 비급여 주사료 경고가 실제로 발화했다.

## 3. 개인정보 · 파일 보존

- 업로드 파일은 `tempfile.mkstemp()` 로만 다루고 **`finally` 에서 항상 `unlink`**.
  DB 기록·영구 저장소 업로드 **없음**. 라우트 테스트가 삭제를 단언한다.
- Vision 스테이징 디렉터리도 `finally: shutil.rmtree`.
- **프롬프트가 모델에게 환자명·등록번호·DRG·병원명·카드번호를 "아예 출력하지 마라"** 고 지시한다
  (받은 뒤 지우는 게 아니라 애초에 안 받는다). 결과 모델에도 해당 필드가 없다.
- 로그는 status·건수·경고코드만 남긴다(금액·원문 미기록).
- ★ 신규/변경 12파일 전수 스캔: 환자명·등록번호·병원명·DRG·카드번호 **0건**.
- 매직바이트 판정 후 `MAGIC_KIND_TO_SUFFIX` 로 확장자를 정한다(클라이언트 파일명 불신).

## 4. 검증 결과

| # | 항목 | 결과 |
|---|---|---|
| 1 | 실물 영수증 파싱 | ✅ 6/6 일치 (위 대조표) |
| 2 | 자동채움 수정 → 계산 반영 | ✅ 40,058→55,000 수정 시 합계 330,298, `/estimate` 본문에 수정값 |
| 3 | 자동/수동 구분 표시 | ✅ 필드별 `자동 채움`/`직접 입력` 배지 (스크린샷) |
| 4 | 파싱 실패 표시 | ✅ 붉은 `[읽기 실패]` 카드 + **행 0건 추가** + `/estimate` 미호출 |
| 4b | 깨진 파일 | ✅ 실호출 `status=failed`, items 0 |
| 4c | 다른 양식(인보이스) | ✅ 실호출 `status=failed`, items 0 — 36,850 을 **삼키지 않음** |
| 5 | 부분 실패 | ✅ `partial` + 경고 전량 노출 + 과소추정 문구 |
| 6 | 업로드 없이 수동 계산 | ✅ 무손상 (최우선 회귀) |
| 7 | 고객 미선택 계산 | ✅ Phase 1 봉인 C 전건 통과 |
| 8 | severity/special_benefit | ✅ 위 2절, 실물에서 동작 확인 |
| 9 | 업로드 파일 영구저장 | ✅ 없음 (`finally` unlink + 테스트) |

### 회귀 (base·head 동일 환경 재측정)
```
pytest (server/ 에서)   base 2994 passed / 5 skipped  →  head 3058 passed / 5 skipped   (+64 = 신규분 정확히 일치)
vitest                  base 1789 passed / 124 files  →  head 1837 passed / 126 files   (+48 = 신규분 정확히 일치)
```
기존 테스트 **회귀 0건**. `DISCLAIMER_TEXT`·`ANNUAL_CAP_NOTICE_TEXT`·`REVIEW_REFERENCE_NOTICE`
및 `review-reference-notice` 블록 **바이트 무변경**.

### 봉인 변이 3종 (★ no-op 아님 선증명 후 실행, 전건 복원)
| 변이 | no-op 아님 증명 | 결과 |
|---|---|---|
| A 파싱 실패 조용통과 (`_finalize` 가 항상 `status="ok"`) | 깨진 파일 파싱 관측: `failed`→**`ok`** (items 0 유지) | **KILL** 18 failed → 복원 64 passed |
| B 자동채움 수정 불가 (`updateRow` 가 parsed 필드 패치 무시) | 동일 스위트 15건 중 **정확히 2건만** 실패(나머지 13건 무영향) = 표적 적중·라인 실행 확인 | **KILL** 2 failed → 복원 통과 |
| C 업로드 없이 계산 차단 (`canCalculate` 가 파싱 결과 요구) | task-3066 **기존 봉인까지** 동반 실패 = 실경로 | **KILL** 10 failed → 복원 57 passed |

## 5. 구조 결정 (ANU 확인 요청 아님 — 금지 우회 없이 해결)
`server/main.py` 가 이번 task 금지 경로라 **새 라우터 파일은 마운트가 불가능**하다.
그래서 새 라우터를 만들지 않고 **이미 등록된** `server/routes/silson_calculator_v1.py`
(허용 경로 `server/routes/**`)에 `POST /api/v1/silson/calculator/parse-receipt` 만 추가했다.
기존 `table`/`estimate` 핸들러는 **무변경**이며 `analyze-policy` 는 손대지 않았다.
→ **라우터 등록을 위한 main.py 수정이 필요 없다.**

## 6. ANU 판단 대기
1. **머지 순서** — base 가 `ebd040a`(Phase 1)이라 단독 머지 가능. 다른 열린 PR 과의 선후 확인 요망.
2. **동기 응답 채택** — Vision 1회 호출을 동기로 반환한다(작업 큐 미사용). 프록시 타임아웃이
   걸리면 사용자에게 실패가 **보이는** 방식이라 조용한 실패는 아니다. 큐 전환이 필요하면 후속.
3. **`MAX_RECEIPT_SIZE_BYTES = 20MB`** (proposal_upload 는 50MB). 단면 영수증 기준 축소.
4. **capability 스냅샷 부재** — cron 경유 실행이라 dispatch 를 안 거쳐
   `memory/capabilities/task-3067.json` 이 없다. scope-guard 가 **스냅샷 부재 오탐**을 낼 수 있다
   (선례 t3049·t3054·t3060). 실제 변경 12파일은 전부 `allowed_resources` 안이다.
5. **CI** — base 에서도 적색일 수 있는 `ci`(Errno 28)·`e2e-test` 는 선재. 교집합 확인 요망.

## 7. 범위 밖 (지시대로 미착수)
진단서·KCD(Phase 3) · 정액 담보매칭(Phase 4) · 실손 계산로직(봉인) · 정액 분기는 "준비 중" 유지.
