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>
15 KiB
15 KiB
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 설계 원칙
- Single Source of Truth: 통계 공식·라벨·나이계산 같은 "도메인 로직" 은 한 곳에서만 정의하고, pharmon-web(PyQt) 과 pharmacy-stats-api(Flask) 가 동일 모듈을 import.
- Version 경계를 버전 모듈 안으로 가둔다: v1(PharmIT3000) / v2(PMPLUS20) 차이는 "SQL 과 컬럼 매핑" 뿐 — 공통 집계/공식 로직은 버전 무관.
- 레이어 분리:
db→core→queries→api의 단방향 의존. 상위 레이어만 하위를 import. - 과도한 추상화 금지: 당장 중복 제거에 필요한 만큼만 쪼갠다. 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 작업을 통해 확인한 사실:
- QT-POS 가 Source of Truth: 통계 로직은 pharmon-web 에서 먼저 수정되고, stats-api 는 후행 동기화. → core 는 pharmon-web 의 인터페이스를 기준으로 설계한다.
- 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 델파이 레거시 근거 명시. - CD_SUNAB 1:N 함정: LEFT JOIN 하면 행 중복 → 금액 이중계산. 반드시
OUTER APPLY TOP 1패턴. → core 가 아닌 queries/v1 쪽 SQL 에 고정. - 보훈 세분화 키는 문자열
'4_1': 숫자 아님. dict 키. →core/labels.py+core/classifier.py둘 다에서 문자열로만 처리. - 라벨 누락은 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 레거시)