diff --git a/docs/REFACTOR_PLAN.md b/docs/REFACTOR_PLAN.md new file mode 100644 index 0000000..e08a9ac --- /dev/null +++ b/docs/REFACTOR_PLAN.md @@ -0,0 +1,263 @@ +# 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 레거시)