Files
pharmacy-stats-api/docs/REFACTOR_PLAN.md
청춘약국 e674c775b5 docs: 모듈 분리 리팩토링 계획서 추가
2026-04-08 v1 QT-POS 동기화 작업을 통해 드러난 근본 문제 (pharmon-web
과 stats-api 간 통계 로직 중복) 를 해결하기 위한 3단계 리팩토링 로드맵.

주요 내용:
- 현재 구조의 7가지 문제점 (P1~P7) 분석
- 4레이어 목표 아키텍처 (db → core → queries → api)
- pharma-stats-core 공통 패키지 분리 전략 (옵션 A/B/C 비교)
- 1단계: pharmacy-stats-api 내부 정돈 (pharmon-web 손대지 않음)
- 2단계: pharma-stats-core 분리 + pharmon-web 통합
- 3단계: v2 (PMPLUS20) 재동기화 + 선택적 독립 repo
- 리스크 & 완화 방안 (한글 경로 editable install 이슈 등)
- 이번 마이그레이션에서 학습한 설계 근거 (GPPOS 7항 공식,
  CD_SUNAB 1:N 함정, 보훈 세분화 문자열 키 등)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 20:58:43 +09:00

264 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# pharmacy-stats-api 모듈화 리팩토링 계획서
작성일: 2026-04-08
작성 시점 배경: v1_pharmit3000.py 를 QT-POS sales_stats_dialog.py 와 1:1 동기화 완료 직후.
이번 마이그레이션을 통해 "두 프로젝트 간 통계 로직 중복" 이라는 근본 문제가 드러났고, 다음 동기화 때는 수동 포팅이 반복되지 않도록 구조를 재설계한다.
---
## 1. 현재 구조와 문제점
### 1.1 현재 레이아웃
```
pharmacy-stats-api/
├── app.py 322 lines ← Flask 라우트 + 라벨 dict + 유틸
├── config.py 22 lines ← DB 커넥션 설정
├── queries/
│ ├── v1_pharmit3000.py 368 lines ← 커넥션/캐시/쿼리/집계/유틸 전부
│ └── v2_pmplus20.py ~250 lines ← v1 구버전 복제 (미동기화)
└── templates/stats.html
```
그리고 외부 의존 중복:
```
pharmon-web/
└── sales_stats_dialog.py (_GUBUN_LABEL, _build_margin_cache,
_query, _calc_age, _empty_row, _add …)
```
### 1.2 구체적 문제
| # | 문제 | 증상 |
|---|------|------|
| **P1** | **로직 중복** — pharmon-web `sales_stats_dialog.py` 의 쿼리/공식/캐시 빌더가 `queries/v1_pharmit3000.py` 에 통째로 복제됨 | QT-POS 에서 버그 고치면 stats-api 도 수동 포팅 필요 (이번 CD_SUNAB 이중계산 버그가 정확히 이 케이스) |
| **P2** | **라벨 dict 3중 중복**`_GUBUN_LABEL` (pharmon-web) / `GUBUN_LABEL` (app.py) / 프론트 js | 보훈 세분화(`4_1~4_4`) 추가 때 한 군데 놓침 → 날 UI 노출 |
| **P3** | **단일 파일 비대화**`v1_pharmit3000.py` 368줄에 커넥션, 캐시, SQL, 집계, 유틸이 섞임 | 테스트 단위 분리 불가, 변경 시 diff 가독성 낮음 |
| **P4** | **엔드포인트 6개 × 2버전 중복**`app.py` 의 v1/v2 라우트가 거의 동일 (day_from/to 파싱, 에러처리, label 붙이기) | 라우트 추가 시 12곳 수정 |
| **P5** | **v2 (PMPLUS20) 미동기화** — v1 은 2026-04-08 재작성했지만 v2 는 구버전 로직 그대로 | v1/v2 비교 API 가 사실상 무의미 |
| **P6** | **테스트 없음** — GPPOS 공식 (`sales_amt`, `ins_drug`, `claim_amt`), `_calc_age` 등이 regression 없이 수동 검증에만 의존 | 다음 리팩토링 때 수치 틀어져도 못 잡음 |
| **P7** | **DB 연결 구문 하드코드**`v1_*` / `v2_*` 각자 `get_connection()` 작성 | 드라이버 변경 시 양쪽 수정 |
---
## 2. 목표 아키텍처
### 2.1 설계 원칙
1. **Single Source of Truth**: 통계 공식·라벨·나이계산 같은 "도메인 로직" 은 한 곳에서만 정의하고, pharmon-web(PyQt) 과 pharmacy-stats-api(Flask) 가 동일 모듈을 import.
2. **Version 경계를 버전 모듈 안으로 가둔다**: v1(PharmIT3000) / v2(PMPLUS20) 차이는 "SQL 과 컬럼 매핑" 뿐 — 공통 집계/공식 로직은 버전 무관.
3. **레이어 분리**: `db``core``queries``api` 의 단방향 의존. 상위 레이어만 하위를 import.
4. **과도한 추상화 금지**: 당장 중복 제거에 필요한 만큼만 쪼갠다. ORM 이나 Repository 패턴은 도입하지 않는다 (MSSQL 읽기 전용, 쿼리 복잡도 낮음).
### 2.2 목표 레이아웃
```
pharmacy-stats-api/
├── app.py ← create_app() factory + main. 라우트 직접 정의 안함
├── config.py ← PHARMIT3000_CONFIG, PMPLUS20_CONFIG
├── requirements.txt
├── docs/
│ ├── REFACTOR_PLAN.md (이 문서)
│ ├── PMPLUS20_MIGRATION_GUIDE.md
│ └── MODULE_BOUNDARY.md (레이어 경계 설명, 2단계 때 작성)
├── db/ ← 레이어 1. 순수 DB 커넥션
│ ├── __init__.py
│ └── connection.py ← get_connection(cfg), query(conn_cfg, sql, params)
├── core/ ← 레이어 2. 버전 독립 도메인 로직 (★재사용 핵심★)
│ ├── __init__.py
│ ├── labels.py ← GUBUN_LABEL, TIME_LABEL, PAY_LABEL, AGE_LABEL
│ ├── formula.py ← GPPOS 공식: sales_amt, ins_drug, ins_prep, claim_amt
│ ├── aggregator.py ← empty_row(), add_row(), StatsAccumulator 클래스
│ ├── margin.py ← build_margin_cache() — 쿼리 주입형 (Callable[[sql,params],rows])
│ ├── age.py ← calc_age(panum, ref_date)
│ └── classifier.py ← classify_gubun(), classify_time(), classify_pay()
├── queries/ ← 레이어 3. 버전별 SQL + core 조합
│ ├── __init__.py
│ ├── v1_pharmit3000.py ← PS_MAIN SQL + get_sales_stats() (core 호출)
│ └── v2_pmplus20.py ← TBSID040_03 SQL + get_sales_stats() (core 호출)
├── api/ ← 레이어 4. Flask Blueprint
│ ├── __init__.py
│ ├── common.py ← _default_from/to, _parse_date, 공통 응답 래퍼
│ ├── v1.py ← Blueprint("v1", url_prefix="/v1")
│ ├── v2.py ← Blueprint("v2", url_prefix="/v2")
│ └── compare.py ← Blueprint("compare", url_prefix="/api")
├── templates/
│ └── stats.html
└── tests/ ← 레이어 2 로직 regression 테스트
├── test_formula.py ← sales_amt/ins_drug 공식 (QT-POS 스크린샷 고정값)
├── test_age.py ← 1900/2000 세기, 윤년
├── test_labels.py ← 보훈 4_1~4_4 매핑
└── test_margin.py ← last_cost > user_price*5 경계
```
### 2.3 의존 그래프
```
api/* ──┐
queries/v1, v2
core/* ──┐
db/*
config.py
```
`core/` 는 DB 를 직접 호출하지 않고, `margin.build_margin_cache(rows, query_fn)` 처럼 콜러블을 주입받는다. → **core 는 pharmon-web 에서도 그대로 import 가능** (핵심 재사용 포인트).
---
## 3. pharmon-web 과의 공유 전략
### 3.1 3가지 옵션 비교
| 옵션 | 방법 | 장점 | 단점 | 권장 |
|------|------|------|------|------|
| A. 복붙 유지 | 지금처럼 수동 동기화 | 0 작업 | 또 틀어짐. 매번 이번 같은 사고 | × |
| B. **로컬 경로 import** | `pharma-stats-core` 를 별도 폴더로 두고 양쪽에서 `sys.path` 또는 editable install (`pip install -e ../pharma-stats-core`) | 저비용, 즉시 동기화, PyInstaller 에서도 hidden-import 처리만 추가하면 됨 | 배포 환경에서 경로 신경써야 함 | **★ 2단계에서 채택** |
| C. 독립 패키지 + git submodule | `pharma-stats-core` 독립 repo, 둘 다 submodule 로 포함 | 버전 고정 가능, 장기적으로 깔끔 | 초기 설정 비용, PyInstaller 빌드 스크립트 수정 | 3단계 (선택적) |
### 3.2 최종 그림 (옵션 B 채택 시)
```
c:\Users\청춘약국\source\
├── pharma-stats-core\ ← 신규 (core/ 를 여기로 이전)
│ ├── pyproject.toml (editable install 용)
│ └── pharma_stats_core/
│ ├── __init__.py
│ ├── labels.py
│ ├── formula.py
│ ├── aggregator.py
│ ├── margin.py
│ ├── age.py
│ └── classifier.py
├── pharmacy-stats-api\
│ └── queries/v1_pharmit3000.py ← `from pharma_stats_core import ...`
└── pharmon-web\
└── sales_stats_dialog.py ← `from pharma_stats_core import ...`
```
**pharmon-web PyInstaller 영향**:
- `pos_gui.spec``hiddenimports=['pharma_stats_core.formula', ...]` 추가
- `datas=[('../pharma-stats-core/pharma_stats_core', 'pharma_stats_core')]` 로 번들 포함
- 또는 editable install 후 PyInstaller 가 정상 탐색하도록 `--collect-all pharma_stats_core`
---
## 4. 마이그레이션 로드맵 (3단계)
### ▶ 1단계: pharmacy-stats-api 내부 정리 (독립 진행, pharmon-web 손대지 않음)
**목표**: P2, P3, P4, P7 해결. pharmon-web 과의 중복(P1)은 아직 손대지 않음.
| 순서 | 작업 | 영향 파일 | 검증 |
|------|------|-----------|------|
| 1-1 | `db/connection.py` 생성, `get_connection(cfg)` 하나로 통합 | 신규 | 스모크: v1/api/stats 기존과 동일 응답 |
| 1-2 | `core/labels.py` 로 GUBUN_LABEL 이전, app.py 에서 import | app.py, core/labels.py | `/v1/api/stats/insurance` 라벨 동일 |
| 1-3 | `core/age.py`, `core/aggregator.py` 추출 (_calc_age, _empty_row, _add) | v1_pharmit3000.py 에서 제거 | 수치 동일 |
| 1-4 | `core/formula.py` 에 GPPOS 공식 함수로 추출 — `calc_sales_amt(row)`, `calc_ins_drug(row)` 등 | v1_pharmit3000.py | 기존 응답 diff 0 |
| 1-5 | `core/margin.py``build_margin_cache(rows, query_fn)` 추출 (DB 주입형) | v1_pharmit3000.py | 1/3 스크린샷 수치 재검증 |
| 1-6 | `core/classifier.py` 에 gubun/time/pay 분류 로직 추출 | v1_pharmit3000.py | 보험별/시간별/결제별 모두 동일 |
| 1-7 | `api/v1.py`, `api/v2.py`, `api/compare.py` Blueprint 로 분리. app.py 는 factory 만 | app.py, api/*.py | 모든 엔드포인트 동일 응답 |
| 1-8 | `api/common.py``_default_from/to`, `stats_response(result, version)` 헬퍼 | v1.py/v2.py 중복 제거 | 회귀 없음 |
| 1-9 | `tests/test_formula.py` 추가 — QT-POS 1/3 스크린샷 수치를 고정값 assert | tests/ | `pytest tests/` 통과 |
**1단계 완료 조건**:
- `v1_pharmit3000.py` 가 < 150 줄 (현재 368 줄)
- `app.py` 가 < 50 줄
- 모든 엔드포인트 응답이 리팩토링 전과 1:1 동일 (기준: 2026-04-08 기준 1/3~4/8 응답)
- pytest green
**1단계에서 하지 않는 것**:
- pharmon-web 수정 — 아직 `core/` 는 stats-api 내부에 머무름
- v2 로직 재작성 — 구조만 맞추고 내용은 유지
---
### ▶ 2단계: pharma-stats-core 분리 (pharmon-web 통합)
**목표**: P1 해결. 양쪽이 같은 코어를 import.
| 순서 | 작업 | 검증 |
|------|------|------|
| 2-1 | `c:\Users\청춘약국\source\pharma-stats-core\` 신규 폴더, `pharma_stats_core/` 패키지 + `pyproject.toml` | `pip install -e .` 성공 |
| 2-2 | `pharmacy-stats-api/core/*``pharma_stats_core/*` 로 이동, stats-api 에서 editable install | stats-api 전 엔드포인트 회귀 0 |
| 2-3 | pharmon-web `sales_stats_dialog.py``_GUBUN_LABEL`, `_calc_age`, `_empty_row`, `_add`, `_build_margin_cache`, GPPOS 공식을 `pharma_stats_core` import 로 교체 | QT-POS 통계 다이얼로그 기존 스크린샷과 1:1 동일 |
| 2-4 | pharmon-web PyInstaller spec 업데이트 (`hiddenimports` / `--collect-all`) | 번들 exe 에서 통계 정상 동작 |
| 2-5 | `pharma-stats-core``tests/` 이관, CI 없이 로컬 pytest 만 | green |
| 2-6 | `docs/MODULE_BOUNDARY.md` 작성 — "core 는 Qt/Flask 둘 다 의존 금지" 원칙 문서화 | 문서 리뷰 |
**2단계 완료 조건**:
- pharmon-web 에서 `grep "GUBUN_LABEL\|_calc_age\|_build_margin_cache"``sales_stats_dialog.py` 에 없음 (전부 core 경유)
- stats-api v1/v2 응답 회귀 0
- QT-POS 통계 다이얼로그 회귀 0 (스크린샷 수치 동일)
- PyInstaller 빌드 성공 + 런타임 정상
---
### ▶ 3단계: v2 (PMPLUS20) 재동기화 + 선택적 확장
**목표**: P5, P6 해결.
| 순서 | 작업 |
|------|------|
| 3-1 | `queries/v2_pmplus20.py` 를 v1 과 동일 패턴으로 재작성. SQL 만 v2 테이블(`TBSID040_03`) 로 바뀌고, 집계/공식은 `pharma_stats_core` 그대로 사용 |
| 3-2 | v2 의 테이블/컬럼 매핑 차이 (`MPRE_TYPE``PreGubun`, `DRUG_SEQ``PreSerial` 등) 를 `queries/v2_pmplus20.py` 상단에서 row 딕셔너리 normalize 로 흡수 → core 에는 "v1 스키마" 하나만 존재 |
| 3-3 | `/api/compare` 가 실제로 v1/v2 매출 비교 의미있는 수치 반환 |
| 3-4 | (선택) `pharma-stats-core` 를 git submodule 또는 별도 repo 로 승격 — 옵션 C 전환 |
---
## 5. 리스크 & 완화
| 리스크 | 영향 | 완화 |
|--------|------|------|
| 1단계 리팩토링 중 수치 틀어짐 | 매출 통계 오차 | 각 단계마다 1/3 스크린샷 기준값을 `tests/test_formula.py` 에 고정 후 비교 |
| pharmon-web PyInstaller 에서 core 못 찾음 | QT-POS 통계 다이얼로그 에러 | 2-4 단계에서 dev 빌드로 먼저 검증, hidden import 리스트는 ANALYSIS_02_libraries.md 패턴 참고 |
| v2 포팅이 미뤄져 compare API 망가짐 | 비교 기능 부정확 | 3단계 전까지 compare 엔드포인트에 "v2 is legacy, may differ" 경고 플래그 붙여둠 |
| 1단계에서 너무 많이 쪼개서 과설계 | 읽기 어려워짐 | core 파일당 < 100줄 목표, 기능별로 1파일 1책임. 억지로 클래스화 하지 않음 |
| editable install 이 한글 경로 문제 일으킴 | pip install -e 실패 | 실패 시 `sys.path.append('../pharma-stats-core')` fallback (pharmon-web 에 이미 패턴 있음) |
---
## 6. 이번 마이그레이션에서 배운 것 (설계 근거)
2026-04-08 작업을 통해 확인한 사실:
1. **QT-POS 가 Source of Truth**: 통계 로직은 pharmon-web 에서 먼저 수정되고, stats-api 는 후행 동기화. → core 는 pharmon-web 의 인터페이스를 기준으로 설계한다.
2. **GPPOS 공식은 절대 단순화 금지**: `sales_amt = PRICE_C+PRICE_P+S_FASTMON+S_TEMP3+SE_PRICE_C+SE_PRICE_P+SE_BOHUN_C` — 7항 가산 공식. 중간에 하나라도 빠지면 1원 단위 오차 발생. → `core/formula.py` 는 docstring 에 GPPOS2 델파이 레거시 근거 명시.
3. **CD_SUNAB 1:N 함정**: LEFT JOIN 하면 행 중복 → 금액 이중계산. 반드시 `OUTER APPLY TOP 1` 패턴. → core 가 아닌 queries/v1 쪽 SQL 에 고정.
4. **보훈 세분화 키는 문자열 `'4_1'`**: 숫자 아님. dict 키. → `core/labels.py` + `core/classifier.py` 둘 다에서 문자열로만 처리.
5. **라벨 누락은 UI 에 raw 코드 노출**: dict.get(code, code) fallback 이 역설적으로 버그 은폐. → 2-2 단계에서 라벨 통합 후, 미매핑 코드는 `?` 대신 로그 경고 남기는 것도 검토.
---
## 7. 의사결정 필요 항목
다음 사항은 1단계 시작 전에 사용자 확답 필요:
- [ ] **1단계를 언제 시작할지** — 지금 바로? 아니면 QT-POS 추가 기능 작업 후?
- [ ] **editable install vs sys.path** — pharmon-web 한글 경로(`청춘약국`) 에서 `pip install -e` 검증 필요. 실패 시 sys.path 경로 주입 방식으로 폴백.
- [ ] **테스트 기준값 스냅샷** — 2026-04-08 1/3 데이터를 "황금 데이터" 로 고정할지, 아니면 매 분기 갱신할지.
- [ ] **v2 폐기 여부** — PMPLUS20 마이그레이션이 현재 진행 중이 아니라면 v2 모듈을 stub 으로 두는 것도 옵션.
---
## 8. 참고
- pharmon-web 분석 문서: `pharmon-web/docs/ANALYSIS_03_database.md` (MSSQL 스키마)
- pharmon-web 분석 문서: `pharmon-web/docs/ANALYSIS_02_libraries.md` (PyInstaller hidden imports)
- 이번 v1 동기화 변경점: `queries/v1_pharmit3000.py` 상단 docstring 1~32줄
- GPPOS 델파이 원본 참고: `Unit_Statistics.pas` (pharmon-web 레거시)