Compare commits

...

15 Commits

Author SHA1 Message Date
청춘약국
3044b4d099 chore: __pycache__/config.cpython-312.pyc untrack
.gitignore 에는 포함돼 있으나 과거에 이미 트래킹된 상태였음.
index 에서만 제거하고 로컬 파일은 유지.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 20:59:51 +09:00
청춘약국
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
청춘약국
8abc67a0af fix(api): 보훈 세분화 라벨 매핑 추가 (4_1~4_4)
웹 stats UI 에서 '보훈100%', '보훈60%' 대신 raw 코드 '4_1', '4_2'
가 그대로 노출되던 문제 수정.

v1_pharmit3000.py 가 QT-POS 와 동기화되면서 보훈 세분화 키
(4_1~4_4) 를 반환하기 시작했으나, app.py 의 GUBUN_LABEL dict 에
해당 키가 없어 dict.get(code, code) fallback 으로 날 코드가 그대로
노출됐음.

pharmon-web/sales_stats_dialog.py 의 _GUBUN_LABEL 과 완전 동기화:
- 4_1: 보훈100%  (GITA_GUBUN=1)
- 4_2: 보훈60%   (GITA_GUBUN=2)
- 4_3: 보훈50%   (GITA_GUBUN=3)
- 4_4: 보훈30%   (GITA_GUBUN=4)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 20:56:19 +09:00
청춘약국
ef23830a77 fix(v1): QT-POS sales_stats_dialog.py 와 1:1 동기화
pharmon-web/sales_stats_dialog.py 의 _query / _build_margin_cache
로직을 그대로 이식하여 QT-POS 와 stats-api 의 통계 수치 불일치
문제를 해결.

주요 변경:
- CD_SUNAB LEFT JOIN → OUTER APPLY TOP 1
  (1:N 곱증으로 행 중복되어 매출 이중계산되던 버그 제거)
- PS_SUB_BOJO JOIN 추가 (보훈 GITA_GUBUN 세분화 지원)
- SELECT 확장: S_FASTMON, S_TEMP3, SE_BOHUN_C, DRUG_T1~T3,
  S_S_PHOL_0~3, SUGA_ZE_PRICE, GITA_GUBUN
- GPPOS2 공식으로 재작성
  sales_amt = PRICE_C + PRICE_P + S_FASTMON + S_TEMP3
            + SE_PRICE_C + SE_PRICE_P + SE_BOHUN_C
- 보훈(PreGubun='4') + GITA_GUBUN ∈ {1,2,3,4} → '4_1'~'4_4'
- 자동차보험 할증금액, 선별급여 청구액 (SE_PRICE_P*0.25) 반영
- 비급여 마진 캐시 (PS_SUB_PHARM × WH_sub/CD_GOODS)
- config.py: DB 서버 192.168.0.201 → 192.168.0.69 (테스트 서버)

2026-04-08 1/3 스크린샷 기준 QT-POS 와 1원 단위까지 수치 일치 확인.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 20:53:46 +09:00
청춘약국
34374c5626 fix(V1): 비급여에서도 급여조제료/약가 표시 (GPPOS 동일) 2026-04-02 16:22:10 +09:00
청춘약국
8ef9fcdd71 chore: .gitignore 추가 (pyc, venv 등 제외) 2026-04-02 15:45:12 +09:00
청춘약국
f6377a9d5c feat(V1): 선별급여 컬럼 추가 (QT-POS 동기화)
- SE_PRICE_P (선별급여약가) 컬럼 추가
- SE_PRICE_C (선별급여 보험부담) 컬럼 추가
- 청구액 = PRICE_C + SE_PRICE_C
- 비급여조제료 계산에서 SE_PRICE_P 제외
2026-04-02 15:42:31 +09:00
root
cdf2e5682a fix: update V2 PMPLUS20 query 2026-04-02 06:33:09 +00:00
root
9836d2bdab fix: V2 비급여 매출금액 계산 수정 - EXE_PRICE 사용
비급여(MPRE_TYPE='9') 처방의 매출금액:
- V1: PRICE_T에 비급여 총액이 들어감
- V2: TOT_PRICE는 조제료만, EXE_PRICE에 비급여 총액

수정:
- PRICE_T = CASE WHEN MPRE_TYPE='9' THEN EXE_PRICE ELSE TOT_PRICE END

검증 (2026년 3월):
- V1 비급여 매출: 3,804,250
- V2 비급여 매출: 3,804,250
- 차이: 0 
2026-04-01 15:34:56 +00:00
root
fc0f6832c3 fix: V1 쿼리에 PS_Main_Sub 조인 추가 - 선별급여/상한제 합산
V1 PharmIT3000의 PRICE_N은 선별급여/상한제초과 금액 미포함 버그 있음.
PS_Main_Sub.SE_PRICE_P를 합산해야 진짜 수납액.

변경:
- PS_Main_Sub LEFT JOIN 추가
- PRICE_N = m.PRICE_N + ISNULL(s.SE_PRICE_P, 0)

결과:
- V1 수납액 합계: 64,680,430
- V2 수납액 합계: 64,680,430
- 차이: 0 (완벽 일치)
2026-04-01 15:29:02 +00:00
root
bc0c282eaf docs: V1 PS_Main_Sub 선별급여/상한제 스키마 분석 추가
V1 PharmIT3000 구조 분석:
- PS_Main: 처방헤더 (PRICE_N만, 합산 안됨)
- PS_Main_Sub: 처방 부가정보 (SE_PRICE_P_80 등 선별급여 컬럼 있음!)
- CD_SUNAB: 수납 (ETC_CARD에 합산 결과 저장)

V1 버그 원인:
- 수납 화면에서는 PS_Main + PS_Main_Sub JOIN해서 계산
- CD_SUNAB.ETC_CARD에 정확히 저장
- 하지만 PS_Main.PRICE_N은 업데이트 안 함

SE_PRICE 컬럼 이력:
- PS_Main_Sub 테이블: 2010년 초기 스키마에 생성
- SE_PRICE_P_* 컬럼: 선별급여 제도 시행 후 추가
- 실제 데이터: 2026년부터 6건 존재

V2 PMPLUS20 개선:
- REAL_PRICE에 모든 항목 합산해서 저장
- 수납테이블 RECP_AMT와 일치 보장
2026-04-01 15:26:58 +00:00
root
75559a78f9 docs: PRICE_N vs REAL_PRICE 매핑 상세 분석 추가
- 일반 환자: REAL_PRICE = PRICE_N 정확히 일치 확인
- 특수 보험 환자 (차상위/선별급여):
  - 지성윤(차상위2): 상한제초과분(EXP_UNDER_EXE_PRICE) 포함
  - 임해자(선별급여): 선별급여80%(SE_PRICE_P_80) 포함
- v1 PRICE_N 버그: 상한제초과/선별급여분 미포함
- v2 REAL_PRICE = 수납테이블(TBSIR000_01)의 RECP_AMT와 일치
- 결론: REAL_PRICE 매핑 유지 (v1보다 정확한 실제 결제금액)
2026-04-01 15:07:35 +00:00
root
d6a1484b54 docs: 보험구분 매핑 문서 업데이트 - 전체 보험종류 포함
- 3-2절: MPRE_TYPE + MPRE_TYPE_GUBUN 조합 매핑 추가
  - 건강보험(0), 의료급여1종(1), 산재(2), 의료급여2종(3)
  - 차상위1(7←4+C), 보훈(E←4+E), 차상위2(F←4+F), 비급여(9)
- 4절: v2 쿼리에 정확한 CASE문 반영
- 5절: 2026년 3월 전체 검증 결과로 업데이트 (V1=V2 일치 확인)
- 6절: 작업 이력으로 변경, 완료된 항목 체크
2026-04-01 14:57:56 +00:00
root
defebd5554 fix: 보험구분 매핑 수정 - 보훈/차상위1/차상위2 정확한 변환
V2 PMPLUS20에서 MPRE_TYPE='4'는 특수보험 그룹:
- MPRE_TYPE_GUBUN='E' → 보훈 (V1: 'E')
- MPRE_TYPE_GUBUN='F' → 차상위2 (V1: 'F')
- MPRE_TYPE_GUBUN='C' → 차상위1 (V1: '7')

기존 코드는 MPRE_TYPE_GUBUN='F'만 체크해서 차상위2만 인식하고
보훈과 차상위1이 누락되는 문제가 있었음
2026-04-01 14:53:33 +00:00
root
8b4e8f7a0a feat: PMPLUS20(v2) 테이블 매핑 완료 및 쿼리 수정
- config.py: PMPLUS20 서버(192.168.0.201\PMPLUS20) 및 SA 비밀번호 수정
- v2_pmplus20.py: PS_MAIN→TBSID040_03, CD_SUNAB→TBSIR000_01 매핑 적용
  - 컬럼 매핑: PRICE_T→TOT_PRICE, PRICE_C→INS_PRICE, PRICE_P→EXE_PRICE 등
  - Drug_T4 = NON_DRUG_PRICE + EXP_EXE_PRICE
  - Holiday = HD_ADD + PRES_TIME_GUBUN 조합
  - PreGubun = MPRE_TYPE (차상위 F 별도 처리)
  - PRES_GUBUN='E' 재고보정 레코드 제외
- PMPLUS20_MIGRATION_GUIDE.md: 전체 매핑 가이드 문서 추가
- app.py: 포트 5060→5050 변경

20260324 기준 검증: 건수/금액/보험별/결제별 일치 확인
PRICE_N(수납) 2건 차이(23,420원)는 비급여 수납 처리 개선에 의한 정상 차이

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:45:31 +00:00
7 changed files with 944 additions and 120 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
# Environment
.env
.venv/
venv/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

328
PMPLUS20_MIGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,328 @@
# PMPLUS20 테이블 매핑 가이드 (pharmacy-stats-api v2)
## 1. DB 연결 정보
| 구분 | 서버 | DB | SA 비밀번호 |
|------|------|-----|------------|
| v1 PharmIT3000 | `192.168.0.201\PM2014` | PM_PRES | `tmddls214!%(` |
| v2 PMPLUS20 | `192.168.0.201\PMPLUS20` | PM_MAIN | `newpro2020!@` |
> **IP는 반드시 192.168.0.201 고정** (두 인스턴스 SA 비밀번호 다름)
---
## 2. 테이블 매핑 (확정)
### 처방 조제 통계 (pharmacy-stats-api용)
| 역할 | v1 (PharmIT3000) | v2 (PMPLUS20) | 검증 |
|------|-----------------|---------------|------|
| 처방 헤더 | `PM_PRES..PS_MAIN` | `PM_MAIN..TBSID040_03` | ✅ 확정 |
| 수납/결제 | `PM_PRES..CD_SUNAB` | `PM_MAIN..TBSIR000_01` | ✅ 확정 |
### JOIN 관계
```sql
-- v1
FROM PM_PRES..PS_MAIN m
LEFT JOIN PM_PRES..CD_SUNAB n ON n.PRESERIAL = m.PreSerial
-- v2
FROM PM_MAIN..TBSID040_03 m
LEFT JOIN PM_MAIN..TBSIR000_01 n ON n.DRUG_SEQ = m.DRUG_SEQ
```
### 참고: OTC 판매 매핑 (person-lookup-web-local용, 별도)
| 역할 | v1 | v2 |
|------|-----|-----|
| OTC 판매 헤더 | SALE_MAIN | TBSIM040_28 |
| OTC 판매 상세 | SALE_SUB | TBSIM040_29 |
| OTC 결제 수납 | CD_SUNAB | TBSIR000_01 |
---
## 3. 컬럼 매핑 (확정)
### 3-1. 처방 헤더 (PS_MAIN → TBSID040_03)
| v1 컬럼 | v2 컬럼 | 역할 | 검증 금액 (20260324) | 상태 |
|---------|---------|------|---------------------|------|
| `PreSerial` | `DRUG_SEQ` | 처방 일련번호 | 20260324000004 = 동일 | ✅ |
| `INDATE` | `SUNAB_DT` | 조제일자 (YYYYMMDD) | 동일 | ✅ |
| `PRICE_T` | `TOT_PRICE` | 보험 약제비 총액 | 9,476,000 = 동일 | ✅ |
| `PRICE_C` | `INS_PRICE` | 청구금액 | 7,068,800 = 동일 | ✅ |
| `PRICE_P` | `EXE_PRICE` | 본인부담금 | 2,407,200 = 동일 | ✅ |
| `PRICE_N` | `REAL_PRICE` | 수납금액 | 2,838,420 (차이 23,420원 — 아래 참고) | ✅ |
| `S_Prep` | `INS_PREP_PRICE` | 조제료 | 2,277,850 = 동일 | ✅ |
| `Drug_T4` | `NON_DRUG_PRICE + EXP_EXE_PRICE` | 비급여 약제비 | 292,140+120,420=412,560 ✅ | ✅ |
| `PaNum` | `PAT_JUMIN_NO` | 주민번호 (연령 계산) | 동일 | ✅ |
| `PaName` | `PAT_NM` | 환자명 | 동일 | ✅ |
| `OrderName` | `YOYANG_NM` | 처방 병원명 | 동일 | ✅ |
### 3-2. 보험구분 (PreGubun → MPRE_TYPE + MPRE_TYPE_GUBUN)
v2에서 `MPRE_TYPE='4'`는 특수보험 그룹이며, `MPRE_TYPE_GUBUN`으로 세부 구분함.
| v1 `PreGubun` | v2 `MPRE_TYPE` | v2 `MPRE_TYPE_GUBUN` | 의미 | 건수 (202603) |
|---------------|---------------|---------------------|------|----------------|
| `'0'` | `'0'` | `'0'` | 건강보험 | 3,325건 ✅ |
| `'1'` | `'1'` | `'0'` | 의료급여1종 | 259건 ✅ |
| `'2'` | `'2'` | `'0'` | 산재 | 17건 ✅ |
| `'3'` | `'3'` | `'0'` | 의료급여2종 | 6건 ✅ |
| `'7'` | `'4'` | `'C'` | **차상위1** | 7건 ✅ |
| `'E'` | `'4'` | `'E'` | **보훈** | 32건 ✅ |
| `'F'` | `'4'` | `'F'` | **차상위2** | 11건 ✅ |
| `'9'` | `'9'` | `'0'` | 비급여 | 34건 ✅ |
**v2에서 PreGubun 변환 SQL**:
```sql
CASE
WHEN m.MPRE_TYPE = '4' AND m.MPRE_TYPE_GUBUN = 'E' THEN 'E' -- 보훈
WHEN m.MPRE_TYPE = '4' AND m.MPRE_TYPE_GUBUN = 'F' THEN 'F' -- 차상위2
WHEN m.MPRE_TYPE = '4' AND m.MPRE_TYPE_GUBUN = 'C' THEN '7' -- 차상위1
ELSE ISNULL(m.MPRE_TYPE, '0')
END AS PreGubun
```
> **주의**: `MPRE_TYPE`이 기본 보험구분. `PRES_GUBUN`은 다른 용도 (A/E 값)
> **v2 전용 필터**: `PRES_GUBUN = 'E'`인 "재고보정" 레코드는 제외해야 함
> (DRUG_SEQ='20260324099999' 형태, v1에는 없음)
### 3-3. 시간가산 (Holiday → HD_ADD + PRES_TIME_GUBUN)
v1의 `Holiday`는 단일 컬럼, v2는 두 컬럼 조합:
| v2 `HD_ADD` | v2 `PRES_TIME_GUBUN` | v1 `Holiday` | 의미 |
|------------|---------------------|-------------|------|
| `'1'` | `'0'` | `'1'` | 일반 | ✅ |
| `'2'` | `'0'` | `'2'` | 공휴일 가산 | ✅ |
| `'1'` | `'1'` | `'3'` | 시간외 가산 | ✅ |
| `'2'` | `'1'` | `'4'` | 공휴+시간외 | 추정 (데이터 미확인) |
**v2에서 Holiday 계산 공식**:
```python
# PMPLUS20 → PharmIT3000 Holiday 변환
if hd_add == '2' and time_gubun == '1':
holiday = '4' # 공휴+시간외
elif hd_add == '2':
holiday = '2' # 공휴
elif time_gubun == '1':
holiday = '3' # 시간외
else:
holiday = '1' # 일반
```
### 3-4. 수납/결제 (CD_SUNAB → TBSIR000_01)
| v1 컬럼 | v2 컬럼 | 역할 | 상태 |
|---------|---------|------|------|
| `CD_SUNAB.PRESERIAL` | `TBSIR000_01.DRUG_SEQ` | JOIN 키 | ✅ |
| `CD_SUNAB.Appr_Gubun` | `TBSIR000_01.APPR_GUBUN` | 결제수단 구분 | ✅ 값 동일 |
| `CD_SUNAB.nAPPROVAL_NUM` | `TBSIR000_01.CARD_ADM_NO` | 승인번호 | ✅ |
결제 구분 값 분포 (20260324):
- `'9'`: 179건 (카드)
- `'A'`: 24건
- `'0'`: 5건
- `'2'`: 3건 (현금)
---
## 4. v2 쿼리 (변환 완료)
```sql
SELECT
m.DRUG_SEQ AS PreSerial,
-- 보험구분 변환: MPRE_TYPE + MPRE_TYPE_GUBUN → PreGubun
CASE
WHEN m.MPRE_TYPE = '4' AND m.MPRE_TYPE_GUBUN = 'E' THEN 'E' -- 보훈
WHEN m.MPRE_TYPE = '4' AND m.MPRE_TYPE_GUBUN = 'F' THEN 'F' -- 차상위2
WHEN m.MPRE_TYPE = '4' AND m.MPRE_TYPE_GUBUN = 'C' THEN '7' -- 차상위1
ELSE ISNULL(m.MPRE_TYPE, '0')
END AS PreGubun,
m.PAT_JUMIN_NO AS PaNum,
m.YOYANG_NM AS OrderName,
-- Holiday 변환: HD_ADD + PRES_TIME_GUBUN → Holiday
CASE
WHEN m.HD_ADD = '2' AND m.PRES_TIME_GUBUN = '1' THEN '4'
WHEN m.HD_ADD = '2' THEN '2'
WHEN m.PRES_TIME_GUBUN = '1' THEN '3'
ELSE '1'
END AS Holiday,
m.TOT_PRICE AS PRICE_T,
(ISNULL(m.NON_DRUG_PRICE, 0) + ISNULL(m.EXP_EXE_PRICE, 0)) AS Drug_T4,
m.INS_PREP_PRICE AS S_Prep,
m.INS_PRICE AS PRICE_C,
m.EXE_PRICE AS PRICE_P,
m.REAL_PRICE AS PRICE_N,
ISNULL(n.APPR_GUBUN, '') AS Appr_Gubun,
ISNULL(n.CARD_ADM_NO, '') AS nAPPROVAL_NUM
FROM PM_MAIN..TBSID040_03 m
LEFT JOIN PM_MAIN..TBSIR000_01 n ON n.DRUG_SEQ = m.DRUG_SEQ
WHERE m.SUNAB_DT BETWEEN ? AND ?
AND m.PRES_GUBUN != 'E' -- 재고보정 레코드 제외
```
---
## 5. 데이터 검증 결과 (20260324)
### 건수
| 항목 | v1 | v2 | 비고 |
|------|-----|-----|------|
| 전체 건수 | 211건 | 212건 | v2에 "재고보정" 1건 추가 (PRES_GUBUN='E' 필터로 해결) |
| 필터 후 | 211건 | 211건 | ✅ 일치 |
### 금액 합계
| 항목 | v1 | v2 | 일치 |
|------|-----|-----|------|
| PRICE_T (보험약제비) | 9,476,000 | 9,476,000 | ✅ |
| PRICE_C (청구금액) | 7,068,800 | 7,068,800 | ✅ |
| PRICE_P (본인부담금) | 2,407,200 | 2,407,200 | ✅ |
| S_Prep (조제료) | 2,277,850 | 2,277,850 | ✅ |
| Drug_T4 (비급여약제비) | 412,560 | 412,560 | ✅ (NON_DRUG+EXP_EXE) |
| PRICE_N (수납금액) | 2,815,000 | 2,838,420 | ⚠️ 차이 23,420원 (아래 설명) |
### PRICE_N vs REAL_PRICE 매핑 상세 분석
#### 결론: `REAL_PRICE = 진짜 수납금액` ✅
PMPLUS20의 `REAL_PRICE`는 수납테이블(TBSIR000_01)의 `RECP_AMT`, `SUNAB_PRICE`와 정확히 일치함.
#### 검증 케이스 (20260324)
| 환자 | 보험구분 | v1 PRICE_N | v2 REAL_PRICE | v2 수납(RECP_AMT) | 설명 |
|------|---------|-----------|--------------|-----------------|------|
| 박명순 | 건강보험+비급여 | 89,600 | 89,600 | 89,600 | 일반 케이스 ✅ |
| 김상훈 | 건강보험+비급여 | 70,900 | 70,900 | 70,900 | 일반 케이스 ✅ |
| 지성윤 | 차상위2(F) | 500 | 11,920 | 11,920 | 특수 케이스 ⚠️ |
| 임해자 | 건강보험+선별급여 | 19,700 | 31,700 | 31,700 | 특수 케이스 ⚠️ |
#### 일반 환자 (건강보험 + 비급여)
```
REAL_PRICE = EXE_PRICE(본인부담) + NON_DRUG_PRICE(비급여약) + EXP_EXE_PRICE(비급여조제)
= PRICE_N (정확히 일치)
```
#### 특수 보험 환자 (차상위/선별급여)
**지성윤 (차상위2)**:
```
EXE_PRICE = 500 (기본 본인부담)
EXP_UNDER_EXE_PRICE = 11,420 (상한제초과 본인부담)
REAL_PRICE = 500 + 11,420 = 11,920 (진짜 수납액)
v1 PRICE_N = 500 ← 상한제초과분 누락!
```
**임해자 (건강보험 + 선별급여 80%)**:
```
EXE_PRICE = 19,700 (기본 본인부담)
SE_PRICE_P_80 = 12,000 (선별급여 80% 본인부담)
REAL_PRICE = 19,700 + 12,000 = 31,700 (진짜 수납액)
v1 PRICE_N = 19,700 ← 선별급여분 누락!
```
#### v1 스키마 분석: 선별급여/상한제 데이터는 어디에?
**V1 PharmIT3000에도 데이터는 있다!** `PS_Main_Sub` 테이블에 저장됨:
```sql
-- V1 PharmIT3000에서 선별급여/상한제 확인
SELECT
m.PreSerial, m.PRICE_N,
s.SE_PRICE_P_80, -- 선별급여 80% 본인부담
s.SE_PRICE_P, -- 선별급여 본인부담 합계
c.ETC_CARD -- 실제 수납액
FROM PS_Main m
JOIN PS_Main_Sub s ON s.PreSerial = m.PreSerial
JOIN CD_SUNAB c ON c.PRESERIAL = m.PreSerial
WHERE s.SE_PRICE_P > 0
```
**V1 테이블 구조:**
| 테이블 | 역할 | 선별급여/상한제 |
|--------|-----|-------------------|
| `PS_Main` | 처방 헤더 | ❌ `PRICE_N`만 (합산 안됨) |
| `PS_Main_Sub` | 처방 부가정보 | ✅ `SE_PRICE_P_80`, `SE_PRICE_P` 등 |
| `CD_SUNAB` | 수납 | ✅ `ETC_CARD`에 합산 결과 |
**검증 케이스 (V1):**
| 환자 | PS_Main.PRICE_N | PS_Main_Sub.SE_PRICE_P_80 | CD_SUNAB.ETC_CARD |
|------|----------------|--------------------------|------------------|
| 지성윤(차상위2) | 500 | 11,420 | 11,920 ✅ |
| 임해자(선별급여) | 19,700 | 12,000 | 31,700 ✅ |
**문제점**: `PS_Main.PRICE_N``PS_Main_Sub`의 선별급여/상한제 금액을 **합산하지 않음**.
#### 선별급여 컨럼 추가 이력
- `PS_Main_Sub` 테이블: **2010년 초기 스키마**에 생성
- `SE_PRICE_P_*` 커럼들: **나중에 추가됨** (선별급여 제도 시행 후)
- 실제 데이터: 2026년부터 6건 존재 (최근 사용 시작)
#### V1 수납 프로세스 (추정)
1. 수납 화면에서 `PS_Main` + `PS_Main_Sub` JOIN해서 총액 계산
2. 계산된 금액을 `CD_SUNAB.ETC_CARD`에 저장
3. **하지만** `PS_Main.PRICE_N`은 업데이트 안 함 (버그)
#### V2 PMPLUS20 개선 사항
PMPLUS20은 이 문제를 해결:
- `TBSID040_03.REAL_PRICE`**모든 항목 합산해서 저장**
- `EXP_UNDER_EXE_PRICE`, `SE_PRICE_P_80` 등 별도 컨럼도 유지
- 수납테이블(TBSIR000_01)의 `RECP_AMT`와 일치 보장
**결론**: `REAL_PRICE` 매핑 유지가 맞음. V1의 `PRICE_N` 버그를 V2에서 수정한 것.
### 보험구분별 (2026년 3월 전체 검증)
| 보험구분 | v1 건수 | v2 건수 | 일치 |
|---------|--------|--------|------|
| 0 (건강보험) | 3,359 | 3,359 | ✅ |
| 1 (의료급여1종) | 271 | 271 | ✅ |
| 2 (산재) | 17 | 17 | ✅ |
| 3 (의료급여2종) | 6 | 6 | ✅ |
| 7 (차상위1) | 8 | 8 | ✅ |
| 9 (비급여) | 35 | 35 | ✅ |
| E (보훈) | 32 | 32 | ✅ |
| F (차상위2) | 11 | 11 | ✅ |
### 결제수단별
| 구분 | v1 건수 | v2 건수 | 일치 |
|------|--------|--------|------|
| 9 (카드) | 179 | 179 | ✅ |
| A | 24 | 24 | ✅ |
| 0 | 5 | 5 | ✅ |
| 2 (현금) | 3 | 3 | ✅ |
---
## 6. 작업 이력
- [x] 테이블 매핑 확정 (PS_MAIN → TBSID040_03)
- [x] 컬럼 매핑 확정 (13개 컬럼 전체)
- [x] 수납 테이블 매핑 확정 (CD_SUNAB → TBSIR000_01)
- [x] 변환 쿼리 작성
- [x] `queries/v2_pmplus20.py` 코드 수정 (2026-04-01)
- [x] 보험구분 매핑 완료 - 보훈/차상위1/차상위2 정확히 변환 (2026-04-01)
- [x] 2026년 3월 전체 기간 보험구분별 건수 검증 완료 (V1=V2 일치)
- [ ] `/api/compare`로 UI 수치 검증
- [ ] 시간별/결제별/병원별 세부 수치 검증
---
## 7. 참고: PMPLUS20 주요 테이블 구조 (210개 중 데이터 있는 것)
| 테이블 | 행수 | 추정 역할 |
|--------|------|----------|
| TBSWH040_02 | 6,533,993 | 입고 이력 상세 |
| TBSID040_05 | 1,543,564 | 조제 상세 (처방전 약품) |
| TBSID040_04 | 1,542,882 | 조제 상세 (원본) |
| TBSIR000_01 | 426,751 | **수납/결제 정보** |
| TBSID040_03 | 394,802 | **처방 헤더 (= PS_MAIN)** |
| TBSIM040_01 | 188,852 | 약품 마스터 (= CD_GOODS) |
| TBSIM040_29 | 49,161 | OTC 판매 상세 (= SALE_SUB) |
| TBSIM040_28 | 28,813 | OTC 판매 헤더 (= SALE_MAIN) |

23
app.py
View File

@@ -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',
}
@@ -305,4 +318,4 @@ if __name__ == '__main__':
print(" /v2/api/stats/... - PMPLUS20 (동일 구조)")
print("")
print(" /api/compare - v1 vs v2 비교")
app.run(host='0.0.0.0', port=5060, debug=True)
app.run(host='0.0.0.0', port=5050, debug=True)

View File

@@ -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.4\\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!%('),
@@ -12,8 +14,8 @@ PHARMIT3000_CONFIG = {
# PMPLUS20 (v2)
PMPLUS20_CONFIG = {
'server': os.getenv('PMPLUS_SERVER', '192.168.0.4\\PM2014'),
'server': os.getenv('PMPLUS_SERVER', '192.168.0.201\\PMPLUS20'),
'database': 'PM_MAIN',
'username': os.getenv('PMPLUS_USER', 'sa'),
'password': os.getenv('PMPLUS_PASS', 'tmddls214!%('),
'password': os.getenv('PMPLUS_PASS', 'newpro2020!@'),
}

263
docs/REFACTOR_PLAN.md Normal file
View 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 레거시)

View File

@@ -2,13 +2,33 @@
"""
PharmIT3000 통계 쿼리 (v1)
테이블:
- PM_PRES.PS_MAIN: 처방 헤더
- PM_PRES.PS_SUB_PHARM: 처방 상세
- PM_PRES.CD_SUNAB: 수납 정보
- PM_DRUG.CD_GOODS: 약품 마스터
**QT-POS sales_stats_dialog.py 와 완전 동기화된 버전** (2026-04-08 rewrite).
QT-POS sales_stats_dialog.py 기반
기준 파일: pharmon-web/sales_stats_dialog.py (_query / _build_margin_cache / _calc_age / _empty_row / _add)
변경 포인트 (구버전 대비):
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
@@ -29,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
@@ -62,123 +161,191 @@ def get_sales_stats(date_from: str, date_to: str) -> dict:
m.S_Prep,
m.PRICE_C,
m.PRICE_P,
m.PRICE_N,
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..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)
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
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)
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 - price_p) if drug_t4 > 0 else 0
nonins_drug = drug_t4
args = (sales_amt, ins_prep, ins_drug, nonins_prep, nonins_drug, 0, price_c, price_p, price_n)
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)
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):
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['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]

View File

@@ -3,14 +3,33 @@
PMPLUS20 통계 쿼리 (v2)
테이블 매핑 (PharmIT3000 → PMPLUS20):
- PM_PRES.PS_MAIN → PM_MAIN.TBSIM040_28
- PM_PRES.PS_SUB_PHARM → PM_MAIN.TBSIM040_29
- PM_PRES.CD_SUNAB → PM_MAIN.TBSIR000_01
- PM_DRUG.CD_GOODS → PM_MAIN.TBSIM040_01
- PM_PRES.PS_MAIN → PM_MAIN.TBSID040_03 (처방 헤더)
- PM_PRES.CD_SUNAB → PM_MAIN.TBSIR000_01 (수납/결제, DRUG_SEQ로 JOIN)
컬럼 매핑:
- PreSerial → DRUG_SEQ
- INDATE → SUNAB_DT
- PRICE_T → TOT_PRICE, PRICE_C → INS_PRICE, PRICE_P → EXE_PRICE, PRICE_N → REAL_PRICE
- S_Prep → INS_PREP_PRICE
- Drug_T4 → NON_DRUG_PRICE + EXP_EXE_PRICE
- PreGubun → MPRE_TYPE + MPRE_TYPE_GUBUN 조합 (아래 매핑 참고)
- Holiday → HD_ADD + PRES_TIME_GUBUN 조합
- Appr_Gubun → APPR_GUBUN (값 동일)
- nAPPROVAL_NUM → CARD_ADM_NO
보험구분 매핑 (V1 PreGubun ↔ V2):
- '0' ← MPRE_TYPE='0' (건강보험)
- '1' ← MPRE_TYPE='1' (의료급여1종)
- '2' ← MPRE_TYPE='2' (산재)
- '3' ← MPRE_TYPE='3' (의료급여2종)
- '7' ← MPRE_TYPE='4' + MPRE_TYPE_GUBUN='C' (차상위1)
- 'E' ← MPRE_TYPE='4' + MPRE_TYPE_GUBUN='E' (보훈)
- 'F' ← MPRE_TYPE='4' + MPRE_TYPE_GUBUN='F' (차상위2)
- '9' ← MPRE_TYPE='9' (비급여)
주의:
- DrugCode vs Drug_Code 대소문자 차이
- TBSIR000_01.RECP_DT는 datetime (변환 필요)
- PRES_GUBUN='E' (재고보정) 레코드 제외 필요
- MPRE_TYPE='4'는 특수보험 그룹, MPRE_TYPE_GUBUN으로 세부 구분
"""
import pyodbc
from datetime import date
@@ -53,36 +72,45 @@ def get_sales_stats(date_from: str, date_to: str) -> dict:
dict: {total, by_gubun, by_age, by_time, by_pay, by_hosp}
Note:
컬럼명/테이블명은 실제 PMPLUS20 스키마 확인 후 수정 필요
TBSID040_03(처방헤더) + TBSIR000_01(수납) 매핑 확정 (2026-04-01)
"""
# TODO: 실제 PMPLUS20 스키마 확인 후 쿼리 수정
# 현재는 PharmIT3000과 동일한 컬럼명 가정
sql = """
SELECT
m.PreSerial,
m.PreGubun,
m.PaNum,
m.OrderName,
m.Holiday,
m.PRICE_T,
m.Drug_T4,
m.S_Prep,
m.PRICE_C,
m.PRICE_P,
m.PRICE_N,
ISNULL(n.Appr_Gubun, '') AS Appr_Gubun,
ISNULL(n.nAPPROVAL_NUM, '') AS nAPPROVAL_NUM
FROM PM_MAIN..TBSIM040_28 m
LEFT JOIN PM_MAIN..TBSIR000_01 n ON n.PRESERIAL = m.PreSerial
WHERE m.INDATE BETWEEN ? AND ?
m.DRUG_SEQ AS PreSerial,
CASE
WHEN m.MPRE_TYPE = '4' AND m.MPRE_TYPE_GUBUN = 'E' THEN 'E' -- 보훈
WHEN m.MPRE_TYPE = '4' AND m.MPRE_TYPE_GUBUN = 'F' THEN 'F' -- 차상위2
WHEN m.MPRE_TYPE = '4' AND m.MPRE_TYPE_GUBUN = 'C' THEN '7' -- 차상위1
ELSE ISNULL(m.MPRE_TYPE, '0')
END AS PreGubun,
m.PAT_JUMIN_NO AS PaNum,
m.YOYANG_NM AS OrderName,
CASE
WHEN m.HD_ADD = '2' AND m.PRES_TIME_GUBUN = '1' THEN '4'
WHEN m.HD_ADD = '2' THEN '2'
WHEN m.PRES_TIME_GUBUN = '1' THEN '3'
ELSE '1'
END AS Holiday,
m.TOT_PRICE AS PRICE_T,
(ISNULL(m.NON_DRUG_PRICE, 0) + ISNULL(m.EXP_EXE_PRICE, 0)) AS Drug_T4,
m.INS_PREP_PRICE AS S_Prep,
m.INS_PRICE AS PRICE_C,
m.EXE_PRICE AS PRICE_P,
m.REAL_PRICE AS PRICE_N,
ISNULL(n.APPR_GUBUN, '') AS Appr_Gubun,
ISNULL(n.CARD_ADM_NO, '') AS nAPPROVAL_NUM
FROM PM_MAIN..TBSID040_03 m
LEFT JOIN PM_MAIN..TBSIR000_01 n ON n.DRUG_SEQ = m.DRUG_SEQ
WHERE m.SUNAB_DT BETWEEN ? AND ?
AND m.PRES_GUBUN != 'E'
"""
try:
rows = query(sql, (date_from, date_to))
except Exception as e:
return {
'error': str(e),
'note': 'PMPLUS20 스키마 확인 필요. 컬럼명/테이블명이 다를 수 있음.'
'error': str(e),
'note': 'PMPLUS20 쿼리 오류. TBSID040_03/TBSIR000_01 테이블 확인 필요.'
}
ref_date = date(