# -*- 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완료!')