autobegin 상태에서 begin() 재호출 에러 → engine.begin() 컨텍스트 매니저로 변경. 189건 PostgreSQL weight_min_kg/weight_max_kg 업데이트 완료. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
546 lines
22 KiB
Python
546 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
APDB weight_min_kg / weight_max_kg 일괄 채우기
|
||
- dosage_instructions에서 (사이즈라벨, 체중구간) 쌍을 파싱
|
||
- APC 레코드의 product_name에 포함된 사이즈 라벨로 매칭
|
||
|
||
매칭 전략:
|
||
1. 제품명에 사이즈 라벨(소형견, 중형견 등)이 있으면 → 해당 체중구간 적용
|
||
2. 체중 구간이 1개뿐이면 → 전체 APC에 적용
|
||
3. 다중 구간인데 제품명에 라벨 없으면 → SKIP (안전)
|
||
|
||
예외 처리:
|
||
- 사료/축산 관련(톤당 kg) → SKIP
|
||
- 축산용(max > 60kg) → SKIP
|
||
- 체중 구간 파싱 불가 → SKIP
|
||
|
||
실행: python scripts/fill_weight_from_dosage.py [--commit] [--verbose]
|
||
기본: dry-run (DB 변경 없음)
|
||
--commit: 실제 DB 업데이트 수행
|
||
--verbose: 상세 로그
|
||
"""
|
||
import sys
|
||
import io
|
||
import re
|
||
import argparse
|
||
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||
|
||
from sqlalchemy import text, create_engine
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# 1. 사이즈 라벨 정의
|
||
# ─────────────────────────────────────────────
|
||
|
||
SIZE_LABELS = {
|
||
'초소형견': 'XS', '초소형': 'XS',
|
||
'소형견': 'S', '소형': 'S',
|
||
'중형견': 'M', '중형': 'M',
|
||
'대형견': 'L', '대형': 'L',
|
||
'초대형견': 'XL', '초대형': 'XL',
|
||
}
|
||
|
||
# 제품명에서 사이즈 감지용 (긴 것부터 먼저 매칭)
|
||
PRODUCT_NAME_SIZE_PATTERNS = [
|
||
(r'초소형견', 'XS'),
|
||
(r'초소형', 'XS'),
|
||
(r'소형견', 'S'),
|
||
(r'소형', 'S'),
|
||
(r'중형견', 'M'),
|
||
(r'중형', 'M'),
|
||
(r'초대형견', 'XL'),
|
||
(r'초대형', 'XL'),
|
||
(r'대형견', 'L'),
|
||
(r'대형', 'L'),
|
||
# 영문/약어
|
||
(r'\bSS\b', 'XS'),
|
||
(r'\bXS\b', 'XS'),
|
||
(r'[-\s]S\b', 'S'),
|
||
(r'\bS\(', 'S'),
|
||
(r'[-\s]M\b', 'M'),
|
||
(r'\bM\(', 'M'),
|
||
(r'[-\s]L\b', 'L'),
|
||
(r'\bL\(', 'L'),
|
||
(r'\bXL\b', 'XL'),
|
||
]
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# 2. 체중 구간 파싱
|
||
# ─────────────────────────────────────────────
|
||
|
||
def strip_html(html_text):
|
||
"""HTML 태그 제거, 줄 단위 텍스트 반환"""
|
||
if not html_text:
|
||
return ""
|
||
t = html_text.replace('<p class="indent0">', '\n').replace('</p>', '')
|
||
t = re.sub(r'<[^>]+>', '', t)
|
||
return t
|
||
|
||
|
||
def is_livestock_context(text_content):
|
||
"""축산/사료 관련인지 판단"""
|
||
# 톤당 kg은 사료 관련
|
||
if '톤당' in text_content and 'kg' in text_content:
|
||
# 체중 구간이 별도로 있는 경우는 반려동물일 수 있음
|
||
if '체중' not in text_content and '형견' not in text_content:
|
||
return True
|
||
return False
|
||
|
||
|
||
def parse_weight_ranges(dosage_instructions):
|
||
"""
|
||
dosage_instructions에서 (사이즈라벨, 체중min, 체중max) 리스트를 추출.
|
||
체중 구간만 추출하며, 성분 용량은 무시.
|
||
|
||
Returns:
|
||
list of dict: [{'min': 0, 'max': 11, 'size': 'S', 'label': '소형견'}, ...]
|
||
"""
|
||
if not dosage_instructions:
|
||
return []
|
||
|
||
txt = strip_html(dosage_instructions)
|
||
|
||
# 축산/사료 관련 제외
|
||
if is_livestock_context(txt):
|
||
return []
|
||
|
||
ranges = []
|
||
seen = set() # (size, min, max) 중복 방지
|
||
|
||
# ── 전처리: 줄 분리된 사이즈+체중 합치기 ──
|
||
# HTML 변환 후 빈 줄이 끼어있을 수 있음:
|
||
# "소형견 1chewable 68㎍ 57mg\n\n(체중0-11kg)"
|
||
# → "소형견 1chewable 68㎍ 57mg (체중0-11kg)"
|
||
lines = txt.split('\n')
|
||
# 빈 줄 제거한 리스트 (인덱스 보존)
|
||
non_empty = [(i, line.strip()) for i, line in enumerate(lines) if line.strip()]
|
||
|
||
merged_set = set() # 합쳐진 줄 인덱스 (원본 기준)
|
||
merged_lines = []
|
||
|
||
for idx, (orig_i, stripped) in enumerate(non_empty):
|
||
if orig_i in merged_set:
|
||
continue
|
||
# 현재 줄에 사이즈 라벨이 있고, 다음 비어있지 않은 줄이 (체중...) 패턴이면 합치기
|
||
if idx + 1 < len(non_empty):
|
||
next_orig_i, next_stripped = non_empty[idx + 1]
|
||
if (re.match(r'\(체중', next_stripped)
|
||
and re.search(r'(초소형|소형|중형|대형|초대형)견?', stripped)):
|
||
merged_lines.append(stripped + ' ' + next_stripped)
|
||
merged_set.add(next_orig_i)
|
||
continue
|
||
merged_lines.append(stripped)
|
||
|
||
txt = '\n'.join(merged_lines)
|
||
|
||
def add_range(size, wmin, wmax, label):
|
||
"""중복 방지하며 범위 추가"""
|
||
if wmax > 60: # 반려동물 체중 범위 초과 → 축산용
|
||
return
|
||
if wmax <= wmin:
|
||
return
|
||
key = (size, wmin, wmax)
|
||
if key not in seen:
|
||
seen.add(key)
|
||
ranges.append({'min': wmin, 'max': wmax, 'size': size, 'label': label})
|
||
|
||
def get_size(label_text):
|
||
"""라벨 텍스트 → 사이즈 코드"""
|
||
return SIZE_LABELS.get(label_text + '견', SIZE_LABELS.get(label_text))
|
||
|
||
# ── 패턴1: "X형견(체중A-Bkg)" / "X형견 ... (체중A-Bkg)" ──
|
||
# 예: "소형견(체중0-11kg)", "중형견(체중12-22kg)"
|
||
# 예(줄 합침): "소형견 1chewable 68㎍ 57mg (체중0-11kg)"
|
||
for m in re.finditer(
|
||
r'(초소형|소형|중형|대형|초대형)견?\s*(?:용\s*)?\(?(?:체중\s*)?'
|
||
r'(\d+\.?\d*)\s*[-~~]\s*(\d+\.?\d*)\s*kg',
|
||
txt
|
||
):
|
||
label = m.group(1)
|
||
add_range(get_size(label), float(m.group(2)), float(m.group(3)), label + '견')
|
||
|
||
# ── 패턴1b: 같은 줄에 라벨과 (체중...)이 먼 경우 ──
|
||
# 예: "소형견 1chewable 68㎍ 57mg 1개월 (체중0-11kg)"
|
||
for m in re.finditer(
|
||
r'(초소형|소형|중형|대형|초대형)견?\b[^\n]*?\(체중\s*'
|
||
r'(\d+\.?\d*)\s*[-~~]\s*(\d+\.?\d*)\s*kg\)',
|
||
txt
|
||
):
|
||
label = m.group(1)
|
||
add_range(get_size(label), float(m.group(2)), float(m.group(3)), label + '견')
|
||
|
||
# ── 패턴2: "체중A~Bkg X형견용" ──
|
||
# 예: "체중12~22kg 중형견용(M)"
|
||
for m in re.finditer(
|
||
r'(?:체중\s*)?(\d+\.?\d*)\s*[-~~]\s*(\d+\.?\d*)\s*kg\s*(?:의\s*)?'
|
||
r'(초소형|소형|중형|대형|초대형)견?',
|
||
txt
|
||
):
|
||
label = m.group(3)
|
||
add_range(get_size(label), float(m.group(1)), float(m.group(2)), label + '견')
|
||
|
||
# ── 패턴3: "Akg이하 X형견" / "~Akg X형견" ──
|
||
# 예: "11kg이하 소형견용"
|
||
for m in re.finditer(
|
||
r'(?:체중\s*)?[~~]?\s*(\d+\.?\d*)\s*kg\s*(?:이하|까지)?\s*(?:의\s*)?'
|
||
r'(초소형|소형|중형|대형|초대형)견?',
|
||
txt
|
||
):
|
||
label = m.group(2)
|
||
add_range(get_size(label), 0, float(m.group(1)), label + '견')
|
||
|
||
# ── 패턴4: "(Akg~Bkg의 X형견에게)" ──
|
||
# 예: "(5.7kg ~11kg의 소형견에게 본제 1정 투여)"
|
||
for m in re.finditer(
|
||
r'\(\s*(\d+\.?\d*)\s*kg?\s*[-~~]\s*(\d+\.?\d*)\s*kg\s*(?:의\s*)?'
|
||
r'(초소형|소형|중형|대형|초대형)견',
|
||
txt
|
||
):
|
||
label = m.group(3)
|
||
add_range(get_size(label), float(m.group(1)), float(m.group(2)), label + '견')
|
||
|
||
# ── 패턴4b: "(Akg.Bkg의 X형견에게)" - 마침표 구분자 ──
|
||
# 예: "(12kg.22kg의 중형견에게)"
|
||
for m in re.finditer(
|
||
r'\(\s*(\d+\.?\d*)\s*kg\s*\.\s*(\d+\.?\d*)\s*kg\s*(?:의\s*)?'
|
||
r'(초소형|소형|중형|대형|초대형)견',
|
||
txt
|
||
):
|
||
label = m.group(3)
|
||
add_range(get_size(label), float(m.group(1)), float(m.group(2)), label + '견')
|
||
|
||
# ── 패턴5: "Akg이하의 X형견에게" ──
|
||
# 예: "(5.6kg이하의 초소형견에게)"
|
||
for m in re.finditer(
|
||
r'(\d+\.?\d*)\s*kg\s*이하\s*(?:의\s*)?(초소형|소형|중형|대형|초대형)견',
|
||
txt
|
||
):
|
||
label = m.group(2)
|
||
add_range(get_size(label), 0, float(m.group(1)), label + '견')
|
||
|
||
# ── 패턴6: 테이블 "A~B | 제품명 X형견용" ──
|
||
# 예: "2~3.5 넥스가드 스펙트라 츄어블 초소형견용 1"
|
||
# 예: "2-5 | 프론트라인 트리액트 초소형견용 | 0.5ml"
|
||
for m in re.finditer(
|
||
r'(\d+\.?\d*)\s*[-~~]\s*(\d+\.?\d*)\s*[\s|]*[^\n]*?'
|
||
r'(초소형|소형|중형|대형|초대형)견?용?',
|
||
txt
|
||
):
|
||
label = m.group(3)
|
||
wmin, wmax = float(m.group(1)), float(m.group(2))
|
||
if wmax <= 60:
|
||
add_range(get_size(label), wmin, wmax, label + '견')
|
||
|
||
# ── 패턴7: "체중 Akg 미만 X형견용" ──
|
||
# 예: "체중 15kg 미만 소, 중형견용"
|
||
for m in re.finditer(
|
||
r'체중\s*(\d+\.?\d*)\s*kg\s*미만\s*[^\n]*?'
|
||
r'(초소형|소형|중형|대형|초대형)견',
|
||
txt
|
||
):
|
||
label = m.group(2)
|
||
add_range(get_size(label), 0, float(m.group(1)), label + '견')
|
||
|
||
# ── 패턴8: 라벨 없이 체중 구간만 (반려동물 키워드 있을 때) ──
|
||
if not ranges and ('개' in txt or '고양이' in txt or '반려' in txt or '애완' in txt):
|
||
# 8a: "Xkg 초과-Ykg 이하" / "Xkg 초과 ~ Ykg" (먼저 처리)
|
||
for m in re.finditer(
|
||
r'(\d+\.?\d*)\s*kg\s*초과\s*[-~~]?\s*(\d+\.?\d*)\s*kg(?:\s*(?:이하|까지))?',
|
||
txt
|
||
):
|
||
wmin, wmax = float(m.group(1)), float(m.group(2))
|
||
if wmax <= 60 and wmax > wmin:
|
||
add_range(None, wmin, wmax, None)
|
||
|
||
# 8b: "X-Ykg" / "X~Ykg" 일반 범위
|
||
for m in re.finditer(
|
||
r'(?:체중\s*)?(\d+\.?\d*)\s*[-~~]\s*(\d+\.?\d*)\s*kg',
|
||
txt
|
||
):
|
||
wmin, wmax = float(m.group(1)), float(m.group(2))
|
||
if wmax <= 60 and wmax > wmin:
|
||
add_range(None, wmin, wmax, None)
|
||
|
||
# 8c: "Xkg 이하" / "~Xkg" (최소=0)
|
||
# 단, "Akg 초과-Xkg 이하"는 8a에서 이미 처리되었으므로 제외
|
||
for m in re.finditer(
|
||
r'(?:체중\s*)?[~~]\s*(\d+\.?\d*)\s*kg|(\d+\.?\d*)\s*kg\s*(?:이하|까지)',
|
||
txt
|
||
):
|
||
val = m.group(1) or m.group(2)
|
||
wmax = float(val)
|
||
if wmax <= 60:
|
||
# "초과-Xkg 이하" 컨텍스트인지 확인 → 이미 8a에서 처리됨
|
||
start = max(0, m.start() - 15)
|
||
before = txt[start:m.start()]
|
||
if '초과' in before:
|
||
continue
|
||
add_range(None, 0, wmax, None)
|
||
|
||
# 정렬 (min 기준)
|
||
ranges.sort(key=lambda x: x['min'])
|
||
return ranges
|
||
|
||
|
||
def is_multi_size_product_name(product_name):
|
||
"""
|
||
제품명에 여러 사이즈가 함께 들어있는 통합 제품인지 판단.
|
||
예: "하트커버(SS,S,M,L)정" → True
|
||
"""
|
||
if not product_name:
|
||
return False
|
||
# 여러 사이즈 약어가 한 제품명에 있는 경우
|
||
if re.search(r'[(\(].*(?:SS|XS).*[,/].*(?:S|M|L).*[)\)]', product_name):
|
||
return True
|
||
# 소형/중형/대형 등이 2개 이상 포함된 경우
|
||
size_count = sum(1 for kw in ['초소형', '소형', '중형', '대형', '초대형']
|
||
if kw in product_name)
|
||
if size_count >= 2:
|
||
return True
|
||
return False
|
||
|
||
|
||
def detect_size_from_product_name(product_name):
|
||
"""
|
||
제품명에서 사이즈 라벨을 감지.
|
||
Returns: 'XS', 'S', 'M', 'L', 'XL' 또는 None
|
||
통합 제품(SS,S,M,L 등 여러 사이즈)은 None 반환.
|
||
"""
|
||
if not product_name:
|
||
return None
|
||
# 통합 제품 제외
|
||
if is_multi_size_product_name(product_name):
|
||
return None
|
||
for pattern, size in PRODUCT_NAME_SIZE_PATTERNS:
|
||
if re.search(pattern, product_name):
|
||
return size
|
||
return None
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# 3. 메인 로직
|
||
# ─────────────────────────────────────────────
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description='APDB weight_min_kg/weight_max_kg 일괄 채우기')
|
||
parser.add_argument('--commit', action='store_true', help='실제 DB 업데이트 수행 (기본: dry-run)')
|
||
parser.add_argument('--verbose', '-v', action='store_true', help='상세 로그')
|
||
args = parser.parse_args()
|
||
|
||
dry_run = not args.commit
|
||
|
||
if dry_run:
|
||
print("=" * 60)
|
||
print(" DRY-RUN 모드 (DB 변경 없음)")
|
||
print(" 실제 업데이트: python scripts/fill_weight_from_dosage.py --commit")
|
||
print("=" * 60)
|
||
else:
|
||
print("=" * 60)
|
||
print(" COMMIT 모드 - DB에 실제 업데이트합니다")
|
||
print("=" * 60)
|
||
|
||
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
|
||
conn = pg.connect()
|
||
|
||
# ── 동물용의약품 중 dosage_instructions에 kg 있고 weight 미입력인 APC 전체 조회 ──
|
||
apcs = conn.execute(text('''
|
||
SELECT apc, item_seq, product_name, dosage, dosage_instructions,
|
||
product_type
|
||
FROM apc
|
||
WHERE product_type = '동물용의약품'
|
||
AND dosage_instructions ILIKE '%%kg%%'
|
||
AND weight_min_kg IS NULL
|
||
ORDER BY item_seq, apc
|
||
''')).fetchall()
|
||
|
||
print(f'\n대상 APC 레코드: {len(apcs)}건')
|
||
|
||
# item_seq별로 그룹핑
|
||
from collections import defaultdict
|
||
items = defaultdict(list)
|
||
di_cache = {}
|
||
for row in apcs:
|
||
items[row.item_seq].append(row)
|
||
if row.item_seq not in di_cache:
|
||
di_cache[row.item_seq] = row.dosage_instructions
|
||
|
||
print(f'대상 item_seq: {len(items)}건\n')
|
||
|
||
stats = {
|
||
'total_items': len(items),
|
||
'updated': 0,
|
||
'matched_by_name': 0,
|
||
'matched_by_dosage_order': 0,
|
||
'matched_single': 0,
|
||
'skipped_no_parse': 0,
|
||
'skipped_livestock': 0,
|
||
'skipped_multi_no_label': 0,
|
||
}
|
||
|
||
updates = [] # (apc, weight_min, weight_max, product_name, reason)
|
||
|
||
for item_seq, apc_rows in items.items():
|
||
di = di_cache[item_seq]
|
||
first_name = apc_rows[0].product_name
|
||
|
||
# 체중 구간 파싱
|
||
weight_ranges = parse_weight_ranges(di)
|
||
|
||
if not weight_ranges:
|
||
stats['skipped_no_parse'] += 1
|
||
if args.verbose:
|
||
print(f' SKIP (파싱불가): {first_name} ({item_seq})')
|
||
continue
|
||
|
||
# 축산용 필터 (max > 60kg인 구간이 있으면 전체 SKIP)
|
||
if any(r['max'] > 60 for r in weight_ranges):
|
||
stats['skipped_livestock'] += 1
|
||
if args.verbose:
|
||
large = [r for r in weight_ranges if r['max'] > 60]
|
||
print(f' SKIP (축산용): {first_name} ({item_seq}) max={large[0]["max"]}kg')
|
||
continue
|
||
|
||
if len(weight_ranges) == 1:
|
||
# ── 체중 구간 1개 → 전체 APC에 적용 ──
|
||
wr = weight_ranges[0]
|
||
for row in apc_rows:
|
||
updates.append((row.apc, wr['min'], wr['max'], row.product_name, '단일구간'))
|
||
stats['matched_single'] += len(apc_rows)
|
||
stats['updated'] += len(apc_rows)
|
||
if args.verbose:
|
||
print(f' 적용 (단일구간): {first_name} → {wr["min"]}~{wr["max"]}kg ({len(apc_rows)}건)')
|
||
|
||
else:
|
||
# ── 체중 구간 여러 개 → 제품명의 사이즈 라벨로 매칭 ──
|
||
size_to_weight = {}
|
||
for wr in weight_ranges:
|
||
if wr['size']:
|
||
size_to_weight[wr['size']] = (wr['min'], wr['max'])
|
||
|
||
# 먼저 제품명 라벨로 매칭 시도
|
||
unmatched_rows = []
|
||
for row in apc_rows:
|
||
size = detect_size_from_product_name(row.product_name)
|
||
if size and size in size_to_weight:
|
||
wmin, wmax = size_to_weight[size]
|
||
updates.append((row.apc, wmin, wmax, row.product_name, f'제품명→{size}'))
|
||
stats['matched_by_name'] += 1
|
||
stats['updated'] += 1
|
||
if args.verbose:
|
||
print(f' 적용 (제품명 {size}): {row.product_name} → {wmin}~{wmax}kg')
|
||
else:
|
||
unmatched_rows.append(row)
|
||
|
||
# ── 제품명 매칭 실패한 것들 → dosage 순서 매칭 시도 ──
|
||
if unmatched_rows:
|
||
# dosage 값이 있는 APC만 추출 (NaN 제외)
|
||
rows_with_dosage = [r for r in unmatched_rows
|
||
if r.dosage and r.dosage != 'NaN']
|
||
rows_no_dosage = [r for r in unmatched_rows
|
||
if not r.dosage or r.dosage == 'NaN']
|
||
|
||
if rows_with_dosage and len(weight_ranges) >= 2:
|
||
# dosage에서 첫 번째 숫자 추출하여 정렬 키로 사용
|
||
def dosage_sort_key(dosage_str):
|
||
nums = re.findall(r'(\d+\.?\d+)', dosage_str)
|
||
return float(nums[0]) if nums else 0
|
||
|
||
# 고유 dosage 값 추출 (순서 유지)
|
||
unique_dosages = sorted(
|
||
set(r.dosage for r in rows_with_dosage),
|
||
key=dosage_sort_key
|
||
)
|
||
# 체중 구간도 min 기준 정렬 (이미 정렬됨)
|
||
sorted_ranges = sorted(weight_ranges, key=lambda x: x['min'])
|
||
|
||
if len(unique_dosages) == len(sorted_ranges):
|
||
# 개수 일치 → 순서 매칭 (작은 용량 = 작은 체중)
|
||
dosage_to_weight = {}
|
||
for d, wr in zip(unique_dosages, sorted_ranges):
|
||
dosage_to_weight[d] = (wr['min'], wr['max'])
|
||
|
||
for row in rows_with_dosage:
|
||
if row.dosage in dosage_to_weight:
|
||
wmin, wmax = dosage_to_weight[row.dosage]
|
||
updates.append((row.apc, wmin, wmax, row.product_name,
|
||
f'dosage순서→{wmin}~{wmax}'))
|
||
stats['matched_by_dosage_order'] += 1
|
||
stats['updated'] += 1
|
||
if args.verbose:
|
||
print(f' 적용 (dosage순서): {row.product_name} '
|
||
f'dosage={row.dosage} → {wmin}~{wmax}kg')
|
||
else:
|
||
stats['skipped_multi_no_label'] += 1
|
||
if args.verbose:
|
||
print(f' SKIP (dosage매칭실패): {row.product_name}')
|
||
|
||
# dosage 없는 APC (대표 품목 등)
|
||
for row in rows_no_dosage:
|
||
stats['skipped_multi_no_label'] += 1
|
||
if args.verbose:
|
||
print(f' SKIP (다중구간+dosage없음): {row.product_name}')
|
||
|
||
if args.verbose and dosage_to_weight:
|
||
print(f' dosage 매핑: {dict((d, f"{w[0]}~{w[1]}kg") for d, w in dosage_to_weight.items())}')
|
||
else:
|
||
# 개수 불일치 → SKIP
|
||
for row in unmatched_rows:
|
||
stats['skipped_multi_no_label'] += 1
|
||
if args.verbose:
|
||
print(f' SKIP (dosage수≠구간수): {row.product_name} '
|
||
f'(dosage {len(unique_dosages)}종 vs 구간 {len(sorted_ranges)}개)')
|
||
else:
|
||
# dosage 없는 APC만 남음
|
||
for row in unmatched_rows:
|
||
stats['skipped_multi_no_label'] += 1
|
||
if args.verbose:
|
||
print(f' SKIP (다중구간+라벨없음): {row.product_name} '
|
||
f'(감지={detect_size_from_product_name(row.product_name)}, '
|
||
f'가용={list(size_to_weight.keys())})')
|
||
|
||
# ── 결과 출력 ──
|
||
print('\n' + '=' * 60)
|
||
print(' 결과 요약')
|
||
print('=' * 60)
|
||
print(f' 대상 item_seq: {stats["total_items"]}건')
|
||
print(f' 업데이트할 APC: {stats["updated"]}건')
|
||
print(f' - 단일구간 적용: {stats["matched_single"]}건')
|
||
print(f' - 제품명 라벨 매칭: {stats["matched_by_name"]}건')
|
||
print(f' - dosage 순서 매칭: {stats["matched_by_dosage_order"]}건')
|
||
print(f' SKIP - 파싱 불가: {stats["skipped_no_parse"]}건')
|
||
print(f' SKIP - 축산용 (>60kg): {stats["skipped_livestock"]}건')
|
||
print(f' SKIP - 다중구간+라벨없음: {stats["skipped_multi_no_label"]}건')
|
||
|
||
if updates:
|
||
print(f'\n === 업데이트 미리보기 (처음 30건) ===')
|
||
for apc, wmin, wmax, pname, reason in updates[:30]:
|
||
print(f' {apc} | {pname[:35]:35s} → {wmin}~{wmax}kg [{reason}]')
|
||
if len(updates) > 30:
|
||
print(f' ... 외 {len(updates) - 30}건')
|
||
|
||
# ── DB 업데이트 ──
|
||
if not dry_run and updates:
|
||
print(f'\n DB 업데이트 시작...')
|
||
conn.close()
|
||
with pg.begin() as tx_conn:
|
||
for apc_code, wmin, wmax, _, _ in updates:
|
||
tx_conn.execute(text('''
|
||
UPDATE apc
|
||
SET weight_min_kg = :wmin, weight_max_kg = :wmax
|
||
WHERE apc = :apc
|
||
'''), {'wmin': wmin, 'wmax': wmax, 'apc': apc_code})
|
||
print(f' 완료: {len(updates)}건 업데이트')
|
||
elif not dry_run and not updates:
|
||
print('\n 업데이트할 항목이 없습니다.')
|
||
conn.close()
|
||
else:
|
||
conn.close()
|
||
print('\n완료.')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|