Compare commits
5 Commits
8ef9fcdd71
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3044b4d099 | ||
|
|
e674c775b5 | ||
|
|
8abc67a0af | ||
|
|
ef23830a77 | ||
|
|
34374c5626 |
Binary file not shown.
21
app.py
21
app.py
@@ -14,11 +14,24 @@ from queries import v2_pmplus20 as v2
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# 보험구분 라벨
|
||||
# 보험구분 라벨 (pharmon-web/sales_stats_dialog.py _GUBUN_LABEL 와 동기화)
|
||||
GUBUN_LABEL = {
|
||||
'0': '건강보험', '1': '의료급여', '2': '산재', '3': '자동차',
|
||||
'4': '보훈', '5': '공상', '6': '본인', '7': '차상위1',
|
||||
'8': '희귀', '9': '비급여', 'E': '차상위2', 'F': '차상위2',
|
||||
'0': '건강보험',
|
||||
'1': '의료급여',
|
||||
'2': '산재',
|
||||
'3': '자동차',
|
||||
'4': '보훈', # 기타 보훈 (GITA_GUBUN 없을 때)
|
||||
'4_1': '보훈100%', # GITA_GUBUN=1
|
||||
'4_2': '보훈60%', # GITA_GUBUN=2
|
||||
'4_3': '보훈50%', # GITA_GUBUN=3
|
||||
'4_4': '보훈30%', # GITA_GUBUN=4
|
||||
'5': '공상',
|
||||
'6': '본인',
|
||||
'7': '차상위1',
|
||||
'8': '희귀',
|
||||
'9': '비급여',
|
||||
'E': '차상위2',
|
||||
'F': '차상위2',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import os
|
||||
|
||||
# PharmIT3000 (v1)
|
||||
# 테스트 서버: 192.168.0.69\PM2014 (pharmon-web/config.py 와 동일)
|
||||
# live: 192.168.0.4 (읽기 전용) / 개발: 192.168.0.206
|
||||
PHARMIT3000_CONFIG = {
|
||||
'server': os.getenv('PHARMIT_SERVER', '192.168.0.201\\PM2014'),
|
||||
'server': os.getenv('PHARMIT_SERVER', '192.168.0.69\\PM2014'),
|
||||
'database': 'PM_PRES',
|
||||
'username': os.getenv('PHARMIT_USER', 'sa'),
|
||||
'password': os.getenv('PHARMIT_PASS', 'tmddls214!%('),
|
||||
|
||||
263
docs/REFACTOR_PLAN.md
Normal file
263
docs/REFACTOR_PLAN.md
Normal file
@@ -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 레거시)
|
||||
@@ -2,18 +2,33 @@
|
||||
"""
|
||||
PharmIT3000 통계 쿼리 (v1)
|
||||
|
||||
테이블:
|
||||
- PM_PRES.PS_MAIN: 처방 헤더
|
||||
- PM_PRES.PS_Main_Sub: 처방 부가정보 (선별급여/상한제 등)
|
||||
- PM_PRES.PS_SUB_PHARM: 처방 상세
|
||||
- PM_PRES.CD_SUNAB: 수납 정보
|
||||
- PM_DRUG.CD_GOODS: 약품 마스터
|
||||
**QT-POS sales_stats_dialog.py 와 완전 동기화된 버전** (2026-04-08 rewrite).
|
||||
|
||||
주의:
|
||||
- PRICE_N은 선별급여/상한제초과 미포함 (버그)
|
||||
- PS_Main_Sub.SE_PRICE_P를 합산해야 진짜 수납액
|
||||
기준 파일: pharmon-web/sales_stats_dialog.py (_query / _build_margin_cache / _calc_age / _empty_row / _add)
|
||||
|
||||
QT-POS sales_stats_dialog.py 기반
|
||||
변경 포인트 (구버전 대비):
|
||||
1. CD_SUNAB LEFT JOIN → OUTER APPLY TOP 1 (1:N 곱증으로 행 중복 → 금액 이중계산되던 버그 제거)
|
||||
2. PS_SUB_BOJO JOIN 추가 (보훈 세분화 GITA_GUBUN)
|
||||
3. SELECT 컬럼 확장: S_FASTMON, S_TEMP3, SE_BOHUN_C, DRUG_T1~T3,
|
||||
S_S_PHOL_0~3, SUGA_ZE_PRICE, GITA_GUBUN
|
||||
4. 매출공식 GPPOS2 기준으로 재작성
|
||||
- sales_amt = PRICE_C + PRICE_P + S_FASTMON + S_TEMP3 + SE_PRICE_C + SE_PRICE_P + SE_BOHUN_C
|
||||
- ins_prep = S_Prep + S_S_PHOL_0~3 + SUGA_ZE_PRICE
|
||||
- ins_drug = ((DRUG_T1+T2+T3 + SE_PRICE_C + SE_BOHUN_C) // 10) * 10 (10원 절사)
|
||||
- nonins_prep= S_FASTMON - Drug_T4 (S_FASTMON>0 일 때)
|
||||
- nonins_drug= Drug_T4
|
||||
- claim_amt = PRICE_C + SE_PRICE_P * 0.25 (선별급여 보험부담 20%)
|
||||
5. 보훈(PreGubun='4') + GITA_GUBUN ∈ {1,2,3,4} → '4_1'~'4_4' 세분화 키
|
||||
6. 자동차보험(PreGubun='3') 할증금액 car_surcharge = PRICE_T - (ins_drug + ins_prep)
|
||||
7. 비급여 마진 = Drug_T4 - 입고총액 (PS_SUB_PHARM × WH_sub/CD_GOODS 캐시)
|
||||
8. 결제수단 분류
|
||||
- card : Appr_Gubun IN ('1','5','9')
|
||||
- paper: nAPPROVAL_NUM 보유 (현금영수증 번호)
|
||||
- cash : 나머지
|
||||
9. 시간가산 분류
|
||||
- hd_add (Holiday IN '2','4') → saturday(공휴가산)
|
||||
- overtime (Holiday IN '3','4') → overtime (hd_add 없고 overtime인 경우)
|
||||
- normal (그 외)
|
||||
"""
|
||||
import pyodbc
|
||||
from datetime import date
|
||||
@@ -34,26 +49,105 @@ def get_connection():
|
||||
|
||||
|
||||
def query(sql, params=None):
|
||||
"""쿼리 실행 후 dict 리스트 반환"""
|
||||
"""쿼리 실행 후 dict 리스트 반환."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(sql, params or ())
|
||||
columns = [col[0] for col in cursor.description]
|
||||
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return rows
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(sql, params or ())
|
||||
columns = [col[0] for col in cursor.description]
|
||||
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
return rows
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── 비급여 마진 캐시 ──────────────────────────────────────────
|
||||
|
||||
def _build_margin_cache(rows) -> dict:
|
||||
"""비급여 처방들의 입고총액을 계산하여 캐시로 반환.
|
||||
마진 = Drug_T4 - cost_cache[serial]
|
||||
|
||||
QT-POS sales_stats_dialog.py::_build_margin_cache 동일 로직.
|
||||
- PS_SUB_PHARM.UnitCode != 1 (비급여 약품)만
|
||||
- 입고가 우선순위: WH_sub 최근 입고가 → CD_GOODS.Price
|
||||
(최근 입고가가 판매가의 5배 이상이면 데이터 오류로 보고 CD_GOODS.Price 사용)
|
||||
- SQL Server 파라미터 제한(2100) 고려하여 500건 청크 처리
|
||||
"""
|
||||
noncov_serials = [r['PreSerial'] for r in rows if (r.get('Drug_T4') or 0) > 0]
|
||||
if not noncov_serials:
|
||||
return {}
|
||||
|
||||
CHUNK_SIZE = 500
|
||||
|
||||
sub_rows = []
|
||||
for i in range(0, len(noncov_serials), CHUNK_SIZE):
|
||||
chunk = noncov_serials[i:i + CHUNK_SIZE]
|
||||
placeholders = ','.join(['?'] * len(chunk))
|
||||
sub_sql = f"""
|
||||
SELECT s.PreSerial, s.DrugCode, s.USERPRICE, s.QUAN, s.QUAN_TIME, s.Days
|
||||
FROM PM_PRES..PS_SUB_PHARM s
|
||||
WHERE s.PreSerial IN ({placeholders})
|
||||
AND s.UnitCode != 1
|
||||
"""
|
||||
sub_rows.extend(query(sub_sql, tuple(chunk)))
|
||||
|
||||
drug_codes = list({r['DrugCode'] for r in sub_rows if r.get('DrugCode')})
|
||||
if not drug_codes:
|
||||
return {}
|
||||
|
||||
cost_rows = []
|
||||
for i in range(0, len(drug_codes), CHUNK_SIZE):
|
||||
chunk = drug_codes[i:i + CHUNK_SIZE]
|
||||
placeholders = ','.join(['?'] * len(chunk))
|
||||
cost_sql = f"""
|
||||
SELECT g.DrugCode, g.Price AS GoodsPrice,
|
||||
(SELECT TOP 1 WH_MY_unit_a FROM PM_DRUG..WH_sub w
|
||||
WHERE w.DrugCode = g.DrugCode
|
||||
ORDER BY w.WH_DT_appl DESC) AS LastCost
|
||||
FROM PM_DRUG..CD_GOODS g
|
||||
WHERE g.DrugCode IN ({placeholders})
|
||||
"""
|
||||
cost_rows.extend(query(cost_sql, tuple(chunk)))
|
||||
|
||||
cost_map = {}
|
||||
for c in cost_rows:
|
||||
code = c['DrugCode']
|
||||
last_cost = float(c.get('LastCost') or 0)
|
||||
goods_price = float(c.get('GoodsPrice') or 0)
|
||||
cost_map[code] = {'last_cost': last_cost, 'goods_price': goods_price}
|
||||
|
||||
cost_cache: dict = {}
|
||||
for s in sub_rows:
|
||||
serial = s['PreSerial']
|
||||
code = s['DrugCode']
|
||||
user_price = float(s.get('USERPRICE') or 0)
|
||||
qty = float(s.get('QUAN') or 0)
|
||||
qty_time = float(s.get('QUAN_TIME') or 1)
|
||||
days = float(s.get('Days') or 1)
|
||||
|
||||
info = cost_map.get(code, {})
|
||||
last_cost = info.get('last_cost', 0)
|
||||
goods_price = info.get('goods_price', 0)
|
||||
|
||||
if last_cost > user_price * 5 and goods_price > 0:
|
||||
cost_price = goods_price
|
||||
elif last_cost > 0:
|
||||
cost_price = last_cost
|
||||
else:
|
||||
cost_price = goods_price
|
||||
|
||||
cost_cache[serial] = cost_cache.get(serial, 0) + cost_price * qty * qty_time * days
|
||||
|
||||
return cost_cache
|
||||
|
||||
|
||||
# ─── 메인 통계 ────────────────────────────────────────────────
|
||||
|
||||
def get_sales_stats(date_from: str, date_to: str) -> dict:
|
||||
"""
|
||||
매출 통계 조회 (QT-POS sales_stats_dialog.py 기준)
|
||||
|
||||
Args:
|
||||
date_from: 시작일 (YYYYMMDD)
|
||||
date_to: 종료일 (YYYYMMDD)
|
||||
|
||||
"""매출 통계 조회. QT-POS sales_stats_dialog.py::_query() 이식.
|
||||
|
||||
Returns:
|
||||
dict: {total, by_gubun, by_age, by_time, by_pay, by_hosp}
|
||||
{total, by_gubun, by_age, by_time, by_pay, by_hosp}
|
||||
"""
|
||||
sql = """
|
||||
SELECT
|
||||
@@ -67,134 +161,191 @@ def get_sales_stats(date_from: str, date_to: str) -> dict:
|
||||
m.S_Prep,
|
||||
m.PRICE_C,
|
||||
m.PRICE_P,
|
||||
-- 진짜 수납액: PRICE_N + 선별급여/상한제초과 (PS_Main_Sub)
|
||||
(m.PRICE_N + ISNULL(s.SE_PRICE_P, 0)) AS PRICE_N,
|
||||
-- 선별급여 (QT-POS 동기화)
|
||||
ISNULL(s.SE_PRICE_P, 0) AS SE_PRICE_P,
|
||||
ISNULL(s.SE_PRICE_C, 0) AS SE_PRICE_C,
|
||||
ISNULL(n.Appr_Gubun, '') AS Appr_Gubun,
|
||||
-- 수납금액 = PRICE_N + 선별급여/상한제초과
|
||||
(m.PRICE_N + ISNULL(sub.SE_PRICE_P, 0)) AS PRICE_N,
|
||||
-- GPPOS 매출계산용
|
||||
ISNULL(m.S_FASTMON, 0) AS S_FASTMON,
|
||||
ISNULL(sub.S_TEMP3, 0) AS S_TEMP3,
|
||||
ISNULL(sub.SE_PRICE_C, 0) AS SE_PRICE_C,
|
||||
ISNULL(sub.SE_PRICE_P, 0) AS SE_PRICE_P,
|
||||
ISNULL(sub.SE_BOHUN_C, 0) AS SE_BOHUN_C,
|
||||
-- GPPOS 급여약가 계산용 (DRUG_T1 + T2 + T3)
|
||||
ISNULL(sub.DRUG_T1, 0) AS DRUG_T1,
|
||||
ISNULL(sub.DRUG_T2, 0) AS DRUG_T2,
|
||||
ISNULL(sub.DRUG_T3, 0) AS DRUG_T3,
|
||||
-- 급여조제료 가산 (약국관리료)
|
||||
ISNULL(sub.S_S_PHOL_0, 0) AS S_S_PHOL_0,
|
||||
ISNULL(sub.S_S_PHOL_1, 0) AS S_S_PHOL_1,
|
||||
ISNULL(sub.S_S_PHOL_2, 0) AS S_S_PHOL_2,
|
||||
ISNULL(sub.S_S_PHOL_3, 0) AS S_S_PHOL_3,
|
||||
-- 명절/휴일 특수가산
|
||||
ISNULL(sub.SUGA_ZE_PRICE, 0) AS SUGA_ZE_PRICE,
|
||||
-- 보훈 세분화용 (1=100%, 2=60%, 3=50%, 4=30%)
|
||||
ISNULL(bojo.GITA_GUBUN, '') AS GITA_GUBUN,
|
||||
ISNULL(n.Appr_Gubun, '') AS Appr_Gubun,
|
||||
ISNULL(n.nAPPROVAL_NUM, '') AS nAPPROVAL_NUM
|
||||
FROM PM_PRES..PS_MAIN m
|
||||
LEFT JOIN PM_PRES..PS_Main_Sub s ON s.PreSerial = m.PreSerial
|
||||
LEFT JOIN PM_PRES..CD_SUNAB n ON n.PRESERIAL = m.PreSerial
|
||||
LEFT JOIN PM_PRES..PS_Main_Sub sub ON sub.PreSerial = m.PreSerial
|
||||
LEFT JOIN PM_PRES..PS_SUB_BOJO bojo ON bojo.PreSerial = m.PreSerial
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 *
|
||||
FROM PM_PRES..CD_SUNAB WITH (NOLOCK)
|
||||
WHERE PRESERIAL = m.PreSerial
|
||||
ORDER BY CASE WHEN ISNULL(APPR_DATE,'') = '' THEN 1 ELSE 0 END,
|
||||
APPR_DATE DESC
|
||||
) n
|
||||
WHERE m.INDATE BETWEEN ? AND ?
|
||||
"""
|
||||
rows = query(sql, (date_from, date_to))
|
||||
|
||||
|
||||
cost_cache = _build_margin_cache(rows)
|
||||
|
||||
ref_date = date(
|
||||
int(date_to[:4]),
|
||||
int(date_to[4:6]),
|
||||
int(date_to[6:8]),
|
||||
)
|
||||
|
||||
# 집계
|
||||
|
||||
result = {
|
||||
'total': _empty_row(),
|
||||
'by_gubun': {},
|
||||
'by_age': {'old': _empty_row(), 'infant': _empty_row(), 'mid': _empty_row()},
|
||||
'by_time': {'overtime': _empty_row(), 'saturday': _empty_row(), 'normal': _empty_row()},
|
||||
'by_pay': {'card': _empty_row(), 'cash': _empty_row(), 'paper': _empty_row()},
|
||||
'by_hosp': {},
|
||||
'by_age': {'old': _empty_row(), 'infant': _empty_row(), 'mid': _empty_row()},
|
||||
'by_time': {'overtime': _empty_row(), 'saturday': _empty_row(), 'normal': _empty_row()},
|
||||
'by_pay': {'card': _empty_row(), 'cash': _empty_row(), 'paper': _empty_row()},
|
||||
'by_hosp': {},
|
||||
}
|
||||
|
||||
|
||||
for r in rows:
|
||||
gubun = str(r.get('PreGubun') or '0')
|
||||
gubun = str(r.get('PreGubun') or '0')
|
||||
gita_gubun = str(r.get('GITA_GUBUN') or '')
|
||||
# 보훈 세분화: PreGubun='4' + GITA_GUBUN ∈ {1,2,3,4}
|
||||
if gubun == '4' and gita_gubun in ('1', '2', '3', '4'):
|
||||
gubun = f'4_{gita_gubun}'
|
||||
|
||||
holiday = str(r.get('Holiday') or '1')
|
||||
|
||||
price_t = int(r.get('PRICE_T') or 0)
|
||||
drug_t4 = int(r.get('Drug_T4') or 0)
|
||||
s_prep = int(r.get('S_Prep') or 0)
|
||||
s_prep = int(r.get('S_Prep') or 0)
|
||||
price_c = int(r.get('PRICE_C') or 0)
|
||||
price_p = int(r.get('PRICE_P') or 0)
|
||||
price_n = int(r.get('PRICE_N') or 0)
|
||||
se_price_p = int(r.get('SE_PRICE_P') or 0)
|
||||
|
||||
s_fastmon = int(r.get('S_FASTMON') or 0)
|
||||
s_temp3 = int(r.get('S_TEMP3') or 0)
|
||||
se_price_c = int(r.get('SE_PRICE_C') or 0)
|
||||
appr_gubun = str(r.get('Appr_Gubun') or '')
|
||||
order_name = str(r.get('OrderName') or '기타')
|
||||
panum = str(r.get('PaNum') or '')
|
||||
|
||||
sales_amt = price_t + drug_t4
|
||||
|
||||
# 급여/비급여 분리
|
||||
if gubun == '9':
|
||||
ins_prep, ins_drug = 0, 0
|
||||
nonins_prep = max(0, price_n - drug_t4 - price_p) if drug_t4 > 0 else 0
|
||||
nonins_drug = drug_t4
|
||||
se_price_p = int(r.get('SE_PRICE_P') or 0)
|
||||
se_bohun_c = int(r.get('SE_BOHUN_C') or 0)
|
||||
|
||||
drug_t1 = int(r.get('DRUG_T1') or 0)
|
||||
drug_t2 = int(r.get('DRUG_T2') or 0)
|
||||
drug_t3 = int(r.get('DRUG_T3') or 0)
|
||||
|
||||
s_s_phol = (int(r.get('S_S_PHOL_0') or 0) + int(r.get('S_S_PHOL_1') or 0)
|
||||
+ int(r.get('S_S_PHOL_2') or 0) + int(r.get('S_S_PHOL_3') or 0))
|
||||
suga_ze_price = int(r.get('SUGA_ZE_PRICE') or 0)
|
||||
|
||||
appr_gubun = str(r.get('Appr_Gubun') or '')
|
||||
nappr_num = str(r.get('nAPPROVAL_NUM') or '').strip()
|
||||
|
||||
# ─── GPPOS 공식 ───────────────────────────────────────
|
||||
sales_amt = price_c + price_p + s_fastmon + s_temp3 + se_price_c + se_price_p + se_bohun_c
|
||||
|
||||
calc_nonins_prep = s_fastmon - drug_t4 if s_fastmon > 0 else 0
|
||||
|
||||
pre_serial = r.get('PreSerial')
|
||||
cost_total = cost_cache.get(pre_serial, 0)
|
||||
nonins_margin = int(drug_t4 - cost_total) if drug_t4 > 0 else 0
|
||||
|
||||
ins_prep = s_prep + s_s_phol + suga_ze_price
|
||||
ins_drug = ((drug_t1 + drug_t2 + drug_t3 + se_price_c + se_bohun_c) // 10) * 10
|
||||
nonins_prep, nonins_drug = calc_nonins_prep, drug_t4
|
||||
|
||||
# 청구액 = PRICE_C + SE_PRICE_P * 0.25 (선별급여 보험부담 20% — SE_PRICE_P는 80% 본인부담)
|
||||
claim_amt = price_c + int(se_price_p * 0.25)
|
||||
|
||||
# 자동차보험 할증금액
|
||||
if gubun == '3':
|
||||
base_total = ins_drug + ins_prep
|
||||
car_surcharge = price_t - base_total if price_t > base_total else 0
|
||||
else:
|
||||
ins_prep, ins_drug = s_prep, price_t - s_prep
|
||||
nonins_prep = max(0, price_n - drug_t4 - se_price_p - price_p) if drug_t4 > 0 else 0
|
||||
nonins_drug = drug_t4
|
||||
|
||||
# 청구액 = PRICE_C + SE_PRICE_C (선별급여 보험부담분)
|
||||
claim_amt = price_c + se_price_c
|
||||
|
||||
args = (sales_amt, ins_prep, ins_drug, nonins_prep, nonins_drug, 0, claim_amt, price_p, price_n, se_price_p)
|
||||
|
||||
car_surcharge = 0
|
||||
|
||||
args = (sales_amt, ins_prep, ins_drug,
|
||||
nonins_prep, nonins_drug, se_price_p, nonins_margin,
|
||||
claim_amt, price_p, price_n, car_surcharge)
|
||||
|
||||
# 전체
|
||||
_add(result['total'], *args)
|
||||
|
||||
|
||||
# 보험별
|
||||
if gubun not in result['by_gubun']:
|
||||
result['by_gubun'][gubun] = _empty_row()
|
||||
_add(result['by_gubun'][gubun], *args)
|
||||
|
||||
|
||||
# 연령가산별
|
||||
age = _calc_age(panum, ref_date)
|
||||
age = _calc_age(str(r.get('PaNum') or ''), ref_date)
|
||||
if age is None or (6 <= age < 65):
|
||||
_add(result['by_age']['mid'], *args)
|
||||
elif age >= 65:
|
||||
_add(result['by_age']['old'], *args)
|
||||
else:
|
||||
_add(result['by_age']['infant'], *args)
|
||||
|
||||
# 시간가산별
|
||||
hd_add = holiday in ('2', '4') # 공휴가산
|
||||
overtime = holiday in ('3', '4') # 시간외
|
||||
|
||||
# 시간가산별 (Unit_Statistics.pas 기준)
|
||||
hd_add = holiday in ('2', '4')
|
||||
overtime = holiday in ('3', '4')
|
||||
if hd_add:
|
||||
_add(result['by_time']['saturday'], *args)
|
||||
elif overtime:
|
||||
_add(result['by_time']['overtime'], *args)
|
||||
else:
|
||||
_add(result['by_time']['normal'], *args)
|
||||
|
||||
|
||||
# 결제수단별
|
||||
if appr_gubun == '1':
|
||||
if appr_gubun in ('1', '5', '9'):
|
||||
_add(result['by_pay']['card'], *args)
|
||||
elif appr_gubun == '2':
|
||||
_add(result['by_pay']['cash'], *args)
|
||||
else:
|
||||
elif nappr_num:
|
||||
_add(result['by_pay']['paper'], *args)
|
||||
|
||||
else:
|
||||
_add(result['by_pay']['cash'], *args)
|
||||
|
||||
# 병원별
|
||||
if order_name not in result['by_hosp']:
|
||||
result['by_hosp'][order_name] = _empty_row()
|
||||
_add(result['by_hosp'][order_name], *args)
|
||||
|
||||
hosp = str(r.get('OrderName') or '').strip() or '(미상)'
|
||||
if hosp not in result['by_hosp']:
|
||||
result['by_hosp'][hosp] = _empty_row()
|
||||
_add(result['by_hosp'][hosp], *args)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _empty_row():
|
||||
# ─── 유틸 ─────────────────────────────────────────────────────
|
||||
|
||||
def _empty_row() -> dict:
|
||||
return dict(cnt=0, sales_amt=0, ins_prep=0, ins_drug=0,
|
||||
nonins_prep=0, nonins_drug=0, nonins_margin=0,
|
||||
claim_amt=0, copay=0, receipt=0, se_price_p=0)
|
||||
nonins_prep=0, nonins_drug=0, se_price_p=0, nonins_margin=0,
|
||||
claim_amt=0, copay=0, receipt=0, car_surcharge=0)
|
||||
|
||||
|
||||
def _add(d, sales_amt, ins_prep, ins_drug, nonins_prep, nonins_drug,
|
||||
nonins_margin, claim_amt, copay, receipt, se_price_p=0):
|
||||
d['cnt'] += 1
|
||||
d['sales_amt'] += sales_amt
|
||||
d['ins_prep'] += ins_prep
|
||||
d['ins_drug'] += ins_drug
|
||||
d['nonins_prep'] += nonins_prep
|
||||
d['nonins_drug'] += nonins_drug
|
||||
def _add(d: dict, sales_amt, ins_prep, ins_drug,
|
||||
nonins_prep, nonins_drug, se_price_p, nonins_margin,
|
||||
claim_amt, copay, receipt, car_surcharge=0):
|
||||
d['cnt'] += 1
|
||||
d['sales_amt'] += sales_amt
|
||||
d['ins_prep'] += ins_prep
|
||||
d['ins_drug'] += ins_drug
|
||||
d['nonins_prep'] += nonins_prep
|
||||
d['nonins_drug'] += nonins_drug
|
||||
d['se_price_p'] += se_price_p
|
||||
d['nonins_margin'] += nonins_margin
|
||||
d['claim_amt'] += claim_amt
|
||||
d['copay'] += copay
|
||||
d['receipt'] += receipt
|
||||
d['se_price_p'] += se_price_p
|
||||
d['claim_amt'] += claim_amt
|
||||
d['copay'] += copay
|
||||
d['receipt'] += receipt
|
||||
d['car_surcharge'] += car_surcharge
|
||||
|
||||
|
||||
def _calc_age(panum: str, ref_date: date) -> int | None:
|
||||
"""주민번호 → 나이"""
|
||||
def _calc_age(panum: str, ref_date: date):
|
||||
"""주민번호 → 나이. 실패 시 None."""
|
||||
if not panum or len(panum) < 7:
|
||||
return None
|
||||
birth6 = panum[:6]
|
||||
|
||||
Reference in New Issue
Block a user