---
task_id: task-2359
type: report
team: dev6-team
captain: 페룬
status: completed
created: 2026-05-02
level: 4
---

# task-2359 — InsuRo 복합설계 Phase 3: CRM 자동 연동 보고서

## SCQA 요약

**S**: Phase 1(ohmymanager 캡처 + history + Fernet PII)과 Phase 2(만기별 비교 UI + 고객 그룹화)가 머지된 상태. ohmy_capture_history.customer_id는 NULL로 저장되어 CRM과 미연결.

**C**: 회장 NEW 가치 제안 — "FA가 캡처하면 자동으로 CRM에 등록/매칭되어 영업 자산 누적". 동시에 동명이인 사고 방지를 위한 안전장치(confirm 다이얼로그 + 사후 수동 분리)가 필요. 동시성 위험에 대비한 UPSERT 패턴 필수.

**Q**: 매칭/등록 흐름을 어디에 넣고, 어떤 UX로 사용자에게 confirm을 요청하며, CRM 페이지에 어떻게 history를 노출할 것인가?

**A**:
- 백엔드 — POST /ohmy-capture가 INSERT 직후 (agent_id, customer_key_hash)로 customers 조회 → 매칭 시 `match_candidate` 반환(자동 매핑 X), 미매칭 시 `upsert_customer_for_capture()`로 자동 생성 + history.customer_id UPDATE
- 신규 엔드포인트 4개: `/link`(confirm), `/reassign`(사후 분리), `customers/{id}/ohmy-history`(CRM 표시), `ohmy-capture-history` 응답에 customer_id 노출
- 프론트 — `CustomerMatchDialog`(CompositeDesign 자동 검출/표시) + `HistoryReassignDialog`(CRM 사후 수정) + CrmCustomers ohmy history 섹션
- duplicate 분기 auto-link 누락 — 마아트 권고로 즉시 수정

## 작업 내용

### 백엔드 (server/main.py + server/customer_match.py)
- 신규: `server/customer_match.py` — `upsert_customer_for_capture()` idempotent helper (INSERT → 23505 충돌 시 SELECT 폴백)
- 변경: POST `/api/insuro/ohmy-capture` (main.py:7284~7484)
  - INSERT 후 customers 매칭 lookup
  - match 발견 → `match_candidate` 응답 (customer_id 자동 매핑 안 함)
  - match 미발견 → `upsert_customer_for_capture()` + history.customer_id 즉시 UPDATE → `auto_linked_customer` 응답
  - duplicate 분기에서도 동일 로직 적용 (마아트 권고 1 반영)
  - 매칭 lookup 예외는 try/except로 감싸 캡처 자체는 성공 처리
- 신규: POST `/api/insuro/ohmy-capture/{capture_id}/link` — confirm 후 매핑/신규등록
- 신규: POST `/api/insuro/ohmy-capture-history/{capture_id}/reassign` — 사후 수동 분리
- 신규: GET `/api/insuro/customers/{customer_id}/ohmy-history` — CRM 페이지용
- 변경: GET `/api/insuro/ohmy-capture-history` 응답 items에 `customer_id` 필드 추가

### 프론트엔드
- 신규: `src/components/composite/CustomerMatchDialog.tsx` (150줄) — 매칭 confirm 다이얼로그
- 신규: `src/components/composite/HistoryReassignDialog.tsx` (273줄) — 사후 분리 다이얼로그
- 변경: `src/pages/CompositeDesign.tsx` — captureHistory 내 customer_key_hash 그룹에서 매칭 후보 자동 검출 → dialog 자동 오픈, shownMatchDialogIds로 1회 표시 보장
- 변경: `src/pages/CrmCustomers.tsx` — 각 row "ohmy history" 버튼 → Dialog로 history 표시 → "분석 페이지 이동"/"다른 고객 이동" 액션
- 변경: `src/components/composite/HistoryGroupCard.tsx` — OhmyCaptureItem 인터페이스에 customer_id 동기화

### 테스트 (벨레스, vitest)
- 신규: `__tests__/CustomerMatchDialog.test.tsx` (5건)
- 신규: `__tests__/HistoryReassignDialog.test.tsx` (6건)
- 기존 HistoryGroupCard 5건 유지

## 생성/수정 파일 목록

### 신규
- `server/customer_match.py`
- `src/components/composite/CustomerMatchDialog.tsx`
- `src/components/composite/HistoryReassignDialog.tsx`
- `src/components/composite/__tests__/CustomerMatchDialog.test.tsx`
- `src/components/composite/__tests__/HistoryReassignDialog.test.tsx`

### 수정
- `server/main.py` (ohmy-capture 응답 확장 + 신규 엔드포인트 3개)
- `src/pages/CompositeDesign.tsx` (CustomerMatchDialog 통합 + 자동 검출 로직)
- `src/pages/CrmCustomers.tsx` (ohmy history Dialog + reassign 트리거)
- `src/components/composite/HistoryGroupCard.tsx` (OhmyCaptureItem.customer_id 동기화)

## 테스트 결과

| 검증 항목 | 결과 |
|---|---|
| `python3 -m py_compile server/main.py server/customer_match.py` | PASS |
| `npx tsc --noEmit` | 0 에러 |
| `npm run build` | PASS (12.81s) |
| `npx vitest run src/components/composite/` | 16/16 PASS (3 files) |
| Codex 사전 검증 (`codex_gate_check.py`) | PASS, low risk 1건 (template plan.md) |
| 마아트 독립 검증 | PASS(조건부) → 권고 1건 즉시 수정 → 차단 사항 없음 |

### vitest 상세
```
✓ CustomerMatchDialog.test.tsx (5 tests) 230ms
✓ HistoryGroupCard.test.tsx (5 tests) 220ms
✓ HistoryReassignDialog.test.tsx (6 tests) 296ms

Test Files  3 passed (3)
     Tests  16 passed (16)
```

## 모델 사용 기록
- 페룬(팀장): Opus 4.7 (1M context) — 설계/분배/검토/통합
- 스바로그(백엔드): Sonnet — server/main.py 매칭/등록/엔드포인트 구현
- 라다(프론트): Sonnet — CustomerMatchDialog + CompositeDesign 통합
- 이리스(프론트): Sonnet — HistoryReassignDialog + CrmCustomers 통합
- 벨레스(QA): Sonnet — vitest 단위 테스트 11건
- 마아트(횡단): Sonnet — 독립 검증

## 발견 이슈 및 해결

### 이슈 1 — Pyright "_upsert_customer_for_capture is not accessed" 경고
- 원인: 함수명 underscore prefix로 Pyright가 private 추정 + main.py가 server/ path 미인식하여 외부 사용 추적 실패
- 해결: 함수명 → `upsert_customer_for_capture` (public) rename + main.py 4곳 동기화
- 커밋: `6c8db6b`

### 이슈 2 — HistoryGroupCard.tsx OhmyCaptureItem 타입 불일치
- 원인: CompositeDesign이 OhmyCaptureItem에 customer_id 추가했으나 HistoryGroupCard 로컬 인터페이스 미동기화 → 구조적 호환성 깨짐
- 해결: HistoryGroupCard.tsx에 customer_id 필드 동기 추가
- 커밋: `efb06e5`

### 이슈 3 — duplicate 분기 auto-link 누락 (마아트 권고 1)
- 원인: 정상 INSERT 분기는 match 미발견 시 auto-link 수행, duplicate 분기는 match_candidate만 부착
- 해결: duplicate 분기에서도 기존 capture customer_id NULL이고 customers 매칭 미발견이면 auto-link 적용
- 커밋: `4b77d99`

### 이슈 4 — HistoryReassignDialog DialogDescription 누락 (a11y 경고)
- 원인: Radix Dialog가 `aria-describedby` 권장
- 해결: DialogDescription 추가
- 커밋: `ff1c2f5`

## L1 스모크테스트

### 단위 테스트 실행 결과 (vitest)

```
$ npx vitest run src/components/composite/

 ✓ src/components/composite/__tests__/CustomerMatchDialog.test.tsx (5 tests) 230ms
 ✓ src/components/composite/__tests__/HistoryReassignDialog.test.tsx (6 tests) 296ms
 ✓ src/components/composite/__tests__/HistoryGroupCard.test.tsx (5 tests) 220ms

 Test Files  3 passed (3)
      Tests  16 passed (16)
   Duration  1.33s
```

### 빌드 + 타입 체크

```
$ npx tsc --noEmit
(0 errors)

$ npm run build
✓ built in 12.81s
PWA v1.2.0 — precache 171 entries (5870.72 KiB)
```

### Python 컴파일

```
$ python3 -m py_compile server/main.py server/customer_match.py
(exit 0 — PY OK)
```

### 검증 게이트
- ✅ Codex 사전 검증 PASS (low risk 1건, 차단 사항 없음)
- ✅ 마아트 독립 검증 PASS(조건부) → 권고 1건 즉시 수정 → 차단 사항 없음

### 실 환경 스모크 (회장 검증 단계 진행)
- staging 환경 + ohmymanager 실 DOM + Chrome Extension 사이드로드 + Supabase 운영 DB가 필요한 4가지 시나리오(첫 캡처/동일 고객/동명이인/사후 수정)는 Phase 3 → Phase 4 회장 승인 게이트에서 일괄 점검 예정.



### 1. vitest 3건 기존 환경변수 미설정 FAIL
- `PlanUpgradeDialog.test.ts`, `use-user-plan.test.ts`, `usePlanFeatures.test.ts` 3건 기존 supabaseUrl 환경변수 미모킹 문제
- 본 task 신규 코드와 무관 (Phase 3 신규 16건 PASS)
- 마아트 권고 2 — 후속 task 또는 CI 인프라에서 처리

### 2. L1 실 환경 스모크 (4가지 시나리오)
- 회장 검증 단계에서 staging 진행 예정 (서버 기동 + 4가지 시나리오)
- 본 task에서는 코드 검증 + 단위 테스트 PASS로 완료

## 머지 판단

- **머지 필요**: Yes
- **브랜치**: `task/task-2359-dev6`
- **워크트리 경로**: `/home/jay/projects/InsuRo/.worktrees/task-2359-dev6`
- **머지 의견**:
  - 기존 Phase 1/2 흐름 회귀 없음 (HistoryGroupCard, MaturityCompare, capture_id auto-select, fetchCaptureHistory 모두 보존)
  - 백엔드 보안: `agent_id` + `user_id` 이중 RLS 검증 모든 신규 엔드포인트 적용
  - PII: encrypted 컬럼 + 평문 dual-write (Phase B에서 운영 마이그레이션 예정 — 회장 결정에 따른 임시 통합)
  - 마아트 차단 사항 0건, Codex 사전 검증 PASS
  - L1 실 환경 스모크는 머지 후 회장 검증에서 진행

## 검증 시나리오 (회장 검증용 Quick Reference)

1. **첫 캡처 (신규 고객)**: ohmymanager → 플로팅 버튼 → CompositeDesign 진입 → 자동 customer 등록 + history.customer_id 채워짐 (dialog 안 뜸)
2. **두 번째 캡처 (동일 고객 다른 만기)**: 같은 customer_key_hash 그룹에 customer_id 있는 항목 존재 → CustomerMatchDialog 자동 표시 → "네, 같은 고객" → history.customer_id 매핑
3. **두 번째 캡처 (동명이인)**: dialog에서 "아니오, 신규 등록" → 별도 customer 생성 + history 매핑 (UPSERT 컴포지트 키로 안전)
4. **사후 수정**: CRM 페이지 → 고객 row "ohmy history" → 잘못 매칭된 history → "다른 고객으로 이동" → 검색 → 선택 → 이동
5. **CRM 페이지**: customer row "ohmy history" 클릭 → Dialog → 분석 history list → "분석 페이지로 이동" → /composite-design?capture_id=N

## 참조

- 시스템 3문서: `memory/plans/insuro-composite-design/`
- 메가 프로젝트 3문서: `memory/plans/customer-mgmt-integration/`
- 의존성: task-2354 Phase 1 (3e619e8), task-2356 Phase 2 (PR #81)
- 후속: Phase 4 (운영 안정화) — 회장 검증 + 머지 후 진행
