feat(scripts): APDB weight_min_kg/max_kg 일괄 채우기 스크립트

dosage_instructions에서 체중 구간을 파싱하여 weight 컬럼 업데이트.
- 제품명 사이즈 라벨(소형견/중형견 등)로 체중구간 매칭
- 단일 체중구간 제품은 전체 APC에 적용
- 통합 제품(SS,S,M,L)은 안전하게 SKIP
- 축산용(>60kg) 자동 제외
- dry-run 기본, --commit으로 실행

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
thug0bin 2026-03-08 18:06:49 +09:00
parent 7e7d06f32e
commit 90f88450be

View File

@ -0,0 +1,463 @@
# -*- 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형견용" ──
# 예: "체중1222kg 중형견용(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):
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)
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:
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_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'])
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:
stats['skipped_multi_no_label'] += 1
if args.verbose:
print(f' SKIP (다중구간+라벨없음): {row.product_name} '
f'(감지={size}, 가용={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' 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 업데이트 시작...')
tx = conn.begin()
try:
for apc_code, wmin, wmax, _, _ in updates:
conn.execute(text('''
UPDATE apc
SET weight_min_kg = :wmin, weight_max_kg = :wmax
WHERE apc = :apc
'''), {'wmin': wmin, 'wmax': wmax, 'apc': apc_code})
tx.commit()
print(f' 완료: {len(updates)}건 업데이트')
except Exception as e:
tx.rollback()
print(f' 오류 발생, 롤백: {e}')
elif not dry_run and not updates:
print('\n 업데이트할 항목이 없습니다.')
conn.close()
print('\n완료.')
if __name__ == '__main__':
main()