- patient_query: 대체조제 원본 처방 제외 - rx_query: 대체조제 원본 처방 제외 - PS_Type=9는 대체조제시 원래 처방된 약(조제 안됨) - 기타 배치 스크립트 및 문서 추가
306 lines
10 KiB
Python
306 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
동물약 일괄 APC 매칭 (개선판)
|
|
- 띄어쓰기 무시 매칭
|
|
- 체중 범위로 정밀 매칭
|
|
- dry-run 모드 (검증용)
|
|
"""
|
|
import sys, io, re
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
|
|
|
from db.dbsetup import get_db_session
|
|
from sqlalchemy import text, create_engine
|
|
from datetime import datetime
|
|
|
|
DRY_RUN = True # True: 검증만, False: 실제 INSERT
|
|
|
|
# ── 유틸 함수 ──
|
|
|
|
def normalize(name):
|
|
"""띄어쓰기/특수문자 제거하여 비교용 문자열 생성"""
|
|
# 공백, 하이픈, 점 제거
|
|
return re.sub(r'[\s\-\.]+', '', name).lower()
|
|
|
|
def extract_base_name(mssql_name):
|
|
"""MSSQL 제품명에서 검색용 기본명 추출 (여러 후보 반환)
|
|
예: '다이로하트정M(12~22kg)' → ['다이로하트정', '다이로하트']
|
|
'하트캅츄어블(11kg이하)' → ['하트캅츄어블', '하트캅']
|
|
'클라펫정50(100정)' → ['클라펫정50', '클라펫정', '클라펫']
|
|
"""
|
|
name = mssql_name.replace('(판)', '')
|
|
# 사이즈 라벨(XS/SS/S/M/L/XL/mini) + 괄호 이전까지
|
|
m = re.match(r'^(.+?)(XS|SS|XL|xs|mini|S|M|L)?\s*[\(/]', name)
|
|
if m:
|
|
base = m.group(1)
|
|
else:
|
|
base = re.sub(r'[\(/].*', '', name)
|
|
base = base.strip()
|
|
|
|
candidates = [base]
|
|
# 끝의 숫자 제거: 클라펫정50 → 클라펫정
|
|
no_num = re.sub(r'\d+$', '', base)
|
|
if no_num and no_num != base:
|
|
candidates.append(no_num)
|
|
# 제형 접미사 제거: 다이로하트정 → 다이로하트, 하트캅츄어블 → 하트캅
|
|
for suffix in ['츄어블', '정', '액', '캡슐', '산', '시럽']:
|
|
for c in list(candidates):
|
|
stripped = re.sub(suffix + r'$', '', c)
|
|
if stripped and stripped != c and stripped not in candidates:
|
|
candidates.append(stripped)
|
|
return candidates
|
|
|
|
def extract_weight_range(mssql_name):
|
|
"""MSSQL 제품명에서 체중 범위 추출
|
|
'가드닐L(20~40kg)' → (20, 40)
|
|
'셀라이트액SS(2.5kg이하)' → (0, 2.5)
|
|
'파라캅L(5kg이상)' → (5, 999)
|
|
'하트웜솔루션츄어블S(11kg이하)' → (0, 11)
|
|
'다이로하트정S(5.6~11kg)' → (5.6, 11)
|
|
"""
|
|
# 범위: (5.6~11kg), (2~10kg)
|
|
m = re.search(r'\((\d+\.?\d*)[-~](\d+\.?\d*)\s*kg\)', mssql_name)
|
|
if m:
|
|
return float(m.group(1)), float(m.group(2))
|
|
|
|
# 이하: (2.5kg이하), (11kg이하)
|
|
m = re.search(r'\((\d+\.?\d*)\s*kg\s*이하\)', mssql_name)
|
|
if m:
|
|
return 0, float(m.group(1))
|
|
|
|
# 이상: (5kg이상)
|
|
m = re.search(r'\((\d+\.?\d*)\s*kg\s*이상\)', mssql_name)
|
|
if m:
|
|
return float(m.group(1)), 999
|
|
|
|
return None, None
|
|
|
|
def weight_match(mssql_min, mssql_max, pg_min, pg_max):
|
|
"""체중 범위가 일치하는지 확인 (약간의 오차 허용)"""
|
|
if pg_min is None or pg_max is None:
|
|
return False
|
|
# 이상(999)인 경우 pg_max도 큰 값이면 OK
|
|
if mssql_max == 999 and pg_max >= 50:
|
|
return abs(mssql_min - pg_min) <= 1
|
|
return abs(mssql_min - pg_min) <= 1 and abs(mssql_max - pg_max) <= 1
|
|
|
|
|
|
# ── 1. MSSQL 동물약 (APC 없는 것만) ──
|
|
|
|
session = get_db_session('PM_DRUG')
|
|
result = session.execute(text("""
|
|
SELECT
|
|
G.DrugCode,
|
|
G.GoodsName,
|
|
G.Saleprice,
|
|
(
|
|
SELECT TOP 1 U.CD_CD_BARCODE
|
|
FROM CD_ITEM_UNIT_MEMBER U
|
|
WHERE U.DRUGCODE = G.DrugCode
|
|
AND U.CD_CD_BARCODE LIKE '023%'
|
|
) AS APC_CODE
|
|
FROM CD_GOODS G
|
|
WHERE G.POS_BOON = '010103'
|
|
AND G.GoodsSelCode = 'B'
|
|
ORDER BY G.GoodsName
|
|
"""))
|
|
|
|
no_apc = []
|
|
for row in result:
|
|
if not row.APC_CODE:
|
|
no_apc.append({
|
|
'code': row.DrugCode,
|
|
'name': row.GoodsName,
|
|
'price': row.Saleprice
|
|
})
|
|
|
|
session.close()
|
|
|
|
print(f'=== APC 없는 동물약: {len(no_apc)}개 ===')
|
|
print(f'=== 모드: {"DRY-RUN (검증만)" if DRY_RUN else "실제 INSERT"} ===\n')
|
|
|
|
# ── 2. PostgreSQL에서 매칭 ──
|
|
|
|
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
|
|
|
|
matched = [] # 확정 매칭
|
|
ambiguous = [] # 후보 여러 개 (수동 확인 필요)
|
|
no_match = [] # 매칭 없음
|
|
|
|
for drug in no_apc:
|
|
name = drug['name']
|
|
base_names = extract_base_name(name)
|
|
w_min, w_max = extract_weight_range(name)
|
|
|
|
# 여러 기본명 후보로 검색 (좁은 것부터 시도)
|
|
candidates = []
|
|
used_base = None
|
|
for bn in base_names:
|
|
norm_base = normalize(bn)
|
|
result = pg.execute(text("""
|
|
SELECT apc, product_name,
|
|
weight_min_kg, weight_max_kg,
|
|
dosage,
|
|
llm_pharm->>'사용가능 동물' as target
|
|
FROM apc
|
|
WHERE REGEXP_REPLACE(LOWER(product_name), '[\\s\\-\\.]+', '', 'g') LIKE :pattern
|
|
ORDER BY product_name
|
|
"""), {'pattern': f'%{norm_base}%'})
|
|
candidates = list(result)
|
|
if candidates:
|
|
used_base = bn
|
|
break
|
|
if not used_base:
|
|
used_base = base_names[0]
|
|
|
|
if not candidates:
|
|
no_match.append(drug)
|
|
print(f'❌ {name}')
|
|
print(f' 기본명: {base_names} → 매칭 없음')
|
|
continue
|
|
|
|
# ── 단계별 필터링 ──
|
|
|
|
# (A) 제형 필터: MSSQL 이름에 "정"이 있으면 PG에서도 "정" 포함 우선
|
|
filtered = candidates
|
|
for form in ['정', '액', '캡슐']:
|
|
if form in name.split('(')[0]:
|
|
form_match = [c for c in filtered if form in c.product_name]
|
|
if form_match:
|
|
filtered = form_match
|
|
break
|
|
|
|
# (B) 체중 범위로 정밀 매칭
|
|
if w_min is not None:
|
|
exact = [c for c in filtered
|
|
if weight_match(w_min, w_max, c.weight_min_kg, c.weight_max_kg)]
|
|
if exact:
|
|
filtered = exact
|
|
|
|
# (C) 포장단위 여러 개면 최소 포장 선택 (낱개 판매 기준)
|
|
# "/ 6 정", "/ 1 피펫" 등에서 숫자 추출
|
|
if len(filtered) > 1:
|
|
def extract_pack_qty(pname):
|
|
m = re.search(r'/\s*(\d+)\s*(정|피펫|개|포)', pname)
|
|
return int(m.group(1)) if m else 0
|
|
has_qty = [(c, extract_pack_qty(c.product_name)) for c in filtered]
|
|
# 포장수량이 있는 것들만 필터
|
|
with_qty = [(c, q) for c, q in has_qty if q > 0]
|
|
if with_qty:
|
|
min_qty = min(q for _, q in with_qty)
|
|
filtered = [c for c, q in with_qty if q == min_qty]
|
|
|
|
# (D) 그래도 여러 개면 대표 APC (product_name이 가장 짧은 것) 선택
|
|
if len(filtered) > 1:
|
|
# 포장수량 정보가 없는 대표 코드가 있으면 우선
|
|
no_qty = [c for c in filtered if '/' not in c.product_name]
|
|
if len(no_qty) == 1:
|
|
filtered = no_qty
|
|
|
|
# ── 결과 판정 ──
|
|
if len(filtered) == 1:
|
|
method = '체중매칭' if w_min is not None and filtered[0].weight_min_kg is not None else '유일후보'
|
|
matched.append({
|
|
'mssql': drug,
|
|
'apc': filtered[0],
|
|
'method': method
|
|
})
|
|
print(f'✅ {name}')
|
|
print(f' → {filtered[0].apc}: {filtered[0].product_name}')
|
|
if w_min is not None and filtered[0].weight_min_kg is not None:
|
|
print(f' 체중: MSSQL({w_min}~{w_max}kg) = PG({filtered[0].weight_min_kg}~{filtered[0].weight_max_kg}kg)')
|
|
continue
|
|
|
|
# 후보가 0개 (필터가 너무 강했으면 원래 candidates로 복구)
|
|
if len(filtered) == 0:
|
|
filtered = candidates
|
|
|
|
# 수동 확인
|
|
ambiguous.append({
|
|
'mssql': drug,
|
|
'candidates': filtered,
|
|
'reason': f'후보 {len(filtered)}건'
|
|
})
|
|
print(f'⚠️ {name} - 후보 {len(filtered)}건 (수동 확인)')
|
|
for c in filtered[:5]:
|
|
wt = f'({c.weight_min_kg}~{c.weight_max_kg}kg)' if c.weight_min_kg else ''
|
|
print(f' → {c.apc}: {c.product_name} {wt}')
|
|
|
|
pg.close()
|
|
|
|
# ── 3. 요약 ──
|
|
|
|
print(f'\n{"="*50}')
|
|
print(f'=== 매칭 요약 ===')
|
|
print(f'APC 없는 제품: {len(no_apc)}개')
|
|
print(f'✅ 확정 매칭: {len(matched)}개')
|
|
print(f'⚠️ 수동 확인: {len(ambiguous)}개')
|
|
print(f'❌ 매칭 없음: {len(no_match)}개')
|
|
|
|
if matched:
|
|
print(f'\n{"="*50}')
|
|
print(f'=== 확정 매칭 목록 (INSERT 대상) ===')
|
|
for m in matched:
|
|
d = m['mssql']
|
|
a = m['apc']
|
|
print(f' {d["name"]:40s} → {a.apc} [{m["method"]}]')
|
|
|
|
# ── 4. INSERT (DRY_RUN=False일 때만) ──
|
|
|
|
if matched and not DRY_RUN:
|
|
print(f'\n{"="*50}')
|
|
print(f'=== INSERT 실행 ===')
|
|
session = get_db_session('PM_DRUG')
|
|
today = datetime.now().strftime('%Y%m%d')
|
|
|
|
for m in matched:
|
|
drugcode = m['mssql']['code']
|
|
apc = m['apc'].apc
|
|
|
|
# 기존 가격 조회
|
|
existing = session.execute(text("""
|
|
SELECT TOP 1 CD_MY_UNIT, CD_IN_UNIT
|
|
FROM CD_ITEM_UNIT_MEMBER
|
|
WHERE DRUGCODE = :dc
|
|
ORDER BY SN DESC
|
|
"""), {'dc': drugcode}).fetchone()
|
|
|
|
if not existing:
|
|
print(f' ❌ {m["mssql"]["name"]}: 기존 레코드 없음')
|
|
continue
|
|
|
|
# 중복 확인
|
|
check = session.execute(text("""
|
|
SELECT 1 FROM CD_ITEM_UNIT_MEMBER
|
|
WHERE DRUGCODE = :dc AND CD_CD_BARCODE = :apc
|
|
"""), {'dc': drugcode, 'apc': apc}).fetchone()
|
|
|
|
if check:
|
|
print(f' ⏭️ {m["mssql"]["name"]}: 이미 등록됨')
|
|
continue
|
|
|
|
try:
|
|
session.execute(text("""
|
|
INSERT INTO CD_ITEM_UNIT_MEMBER (
|
|
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
|
|
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
|
|
) VALUES (
|
|
:drugcode, '015', 1.0, :my_unit, :in_unit,
|
|
:barcode, '', :change_date
|
|
)
|
|
"""), {
|
|
'drugcode': drugcode,
|
|
'my_unit': existing.CD_MY_UNIT,
|
|
'in_unit': existing.CD_IN_UNIT,
|
|
'barcode': apc,
|
|
'change_date': today
|
|
})
|
|
session.commit()
|
|
print(f' ✅ {m["mssql"]["name"]} → {apc}')
|
|
except Exception as e:
|
|
session.rollback()
|
|
print(f' ❌ {m["mssql"]["name"]}: {e}')
|
|
|
|
session.close()
|
|
print('\n완료!')
|