diff --git a/backend/app.py b/backend/app.py index 8935052..c853efb 100644 --- a/backend/app.py +++ b/backend/app.py @@ -80,6 +80,9 @@ app.register_blueprint(sooin_bp) from baekje_api import baekje_bp app.register_blueprint(baekje_bp) +from dongwon_api import dongwon_bp +app.register_blueprint(dongwon_bp) + from wholesaler_config_api import wholesaler_config_bp app.register_blueprint(wholesaler_config_bp) @@ -2946,6 +2949,11 @@ ANIMAL_DRUG_KNOWLEDGE = """ - 콜리/셸티/보더콜리 등 MDR1 유전자 변이견은 이버멕틴 주의 - 임신/수유 중인 동물은 수의사 상담 필요 - 체중 정확히 측정 후 제품 선택 + +## 🚨 항생제 필수 경고 (퀴놀론계) +- **엔로플록사신(아시엔로, Baytril)**: 🐱 고양이 망막 독성! 5mg/kg/day 초과 시 실명 위험. 대안: 마르보플록사신 +- **이버멕틴 고용량**: MDR1 유전자 변이견(콜리, 셸티, 오스트레일리안 셰퍼드) 신경독성 주의 +- **어린 동물 퀴놀론계**: 연골 발달에 영향, 성장기 동물 주의 """ # 동물약 챗봇 System Prompt @@ -2977,6 +2985,13 @@ ANIMAL_CHAT_SYSTEM_PROMPT = """당신은 약국의 동물약 전문 상담사입 - 일반적 비교 설명 + 우리 약국 보유 여부 안내 - 길게 상세히 (10-15문장) +**⚠️ 투여방법 구분 (필수!):** +- "먹는 약", "경구", "복용" 질문 → 내복약만 추천 (정제, 츄어블, 캡슐, 시럽) +- "바르는 약", "도포", "외용" 질문 → 외용약만 추천 (겔, 스팟온, 크림, 연고) +- RAG 정보의 "제형", "분류", "체중/부위" 필드 확인 필수 +- 외용약(겔, 도포, 환부에 직접)은 절대 "먹는 약"으로 추천하지 않음! +- 보유 제품 목록의 [내복/외용] 표시 확인! + **기본 규칙:** 1. 체중별 제품은 정확한 전체 이름 사용 (안텔민킹, 안텔민뽀삐 등) 2. 용량/투약 질문: 체중별 표 형식으로 정리 @@ -3008,6 +3023,8 @@ def _get_animal_drug_rag(apc_codes): image_url1, llm_pharm->>'사용가능 동물' as target_animals, llm_pharm->>'분류' as category, + llm_pharm->>'쉬운분류' as easy_category, + llm_pharm->>'약품 제형' as dosage_form, llm_pharm->>'체중/부위' as dosage_weight, llm_pharm->>'기간/용법' as usage_period, llm_pharm->>'월령금기' as age_restriction, @@ -3024,6 +3041,8 @@ def _get_animal_drug_rag(apc_codes): rag_data[row.apc] = { 'target_animals': row.target_animals or '정보 없음', 'category': row.category or '', + 'easy_category': row.easy_category or '', + 'dosage_form': row.dosage_form or '', 'dosage_weight': row.dosage_weight or '', 'usage_period': row.usage_period or '', 'age_restriction': row.age_restriction or '', @@ -3208,6 +3227,28 @@ def api_animal_chat(): if d.get('apc') and d['apc'] in rag_data: info = rag_data[d['apc']] details = [] + + # 투여방법 표시 (내복/외용 구분) + admin_type = "" + dosage_form = info.get('dosage_form', '').lower() + easy_cat = info.get('easy_category', '').lower() + dosage_weight = info.get('dosage_weight', '').lower() + + # 외용약 판별 (겔, 크림, 연고, 스팟온, 도포, 환부) + if any(kw in dosage_form for kw in ['겔', '크림', '연고', '스팟온', '점이', '외용']) or \ + any(kw in easy_cat for kw in ['외용', '피부약', '점이', '점안']) or \ + any(kw in dosage_weight for kw in ['도포', '환부', '바르']): + admin_type = "외용" + # 내복약 판별 (정제, 츄어블, 캡슐, 시럽, 경구) + elif any(kw in dosage_form for kw in ['정제', '츄어블', '캡슐', '시럽', '산제', '과립', '액제', '경구']) or \ + any(kw in easy_cat for kw in ['내복', '경구', '구충', '심장사상충', '정장', '소화']): + admin_type = "내복" + + if admin_type: + form_display = info.get('dosage_form', '')[:10] if info.get('dosage_form') else '' + cat_display = info.get('easy_category', '')[:15] if info.get('easy_category') else '' + details.append(f"💊{admin_type}/{form_display}, {cat_display}") + if info.get('target_animals'): details.append(f"대상: {info['target_animals']}") if info.get('main_ingredient'): @@ -3231,6 +3272,37 @@ def api_animal_chat(): detail_keywords = ['자세히', '상세히', '더 알려', '설명해', '왜', '어떻게', '원리', '기전', '성분'] is_detail_request = any(kw in last_user_msg for kw in detail_keywords) + # 투여방법 키워드 감지 + oral_keywords = ['먹는', '먹이는', '경구', '복용', '츄어블', '정제', '캡슐'] + topical_keywords = ['바르는', '도포', '외용', '스팟온', '겔', '크림', '연고'] + + is_oral_request = any(kw in last_user_msg for kw in oral_keywords) + is_topical_request = any(kw in last_user_msg for kw in topical_keywords) + + # 투여방법 필터 안내 추가 + admin_filter_note = "" + if is_oral_request and not is_topical_request: + admin_filter_note = "\n\n⚠️ **사용자가 '먹는 약/경구/복용' 질문** → 내복약(정제, 츄어블, 캡슐)만 추천! 외용약(겔, 크림, 도포)은 추천하지 마세요!" + elif is_topical_request and not is_oral_request: + admin_filter_note = "\n\n⚠️ **사용자가 '바르는 약/외용/도포' 질문** → 외용약(겔, 크림, 스팟온)만 추천! 내복약(정제, 츄어블)은 추천하지 마세요!" + + # 🚨 엔로플록사신/퀴놀론 관련 질문 감지 → 망막 독성 경고 주입 + enro_keywords = ['엔로플록사신', '아시엔로', 'asienro', 'enrofloxacin', '퀴놀론', 'fluoroquinolone', 'baytril', '바이트릴'] + is_enro_question = any(kw.lower() in last_user_msg.lower() for kw in enro_keywords) + + enro_warning = "" + if is_enro_question: + enro_warning = """ + +🚨 **[필수 안내] 엔로플록사신 고양이 망막 독성 - 반드시 답변에 포함!** +- FDA 부작용 보고: 산동 454건, 실명 399건, 망막장애 244건 +- 고양이는 **5mg/kg/day 절대 초과 금지** (권장: 2.5~5mg/kg) +- 15mg/kg/day × 21일 → 망막 이상, 30mg/kg/day → 급성 실명 +- 탈수/신장질환 시 위험 증가 (약물 배설 지연) +- 안전한 대안: Marbofloxacin(마르보플록사신), Pradofloxacin(프라도플록사신) +- ⚠️ 이 경고를 답변 첫 부분에 반드시 언급하세요! +""" + # 벡터 DB 검색 (LanceDB RAG) vector_context = "" vector_start = time.time() @@ -3254,9 +3326,13 @@ def api_animal_chat(): log_entry.vector_duration_ms = int((time.time() - vector_start) * 1000) # System Prompt 구성 + knowledge_section = ANIMAL_DRUG_KNOWLEDGE + "\n\n" + vector_context if vector_context else ANIMAL_DRUG_KNOWLEDGE + knowledge_section += admin_filter_note # 투여방법 필터 안내 추가 + knowledge_section += enro_warning # 🚨 엔로플록사신 망막 독성 경고 추가 + system_prompt = ANIMAL_CHAT_SYSTEM_PROMPT.format( available_products=available_products_text, - knowledge_base=ANIMAL_DRUG_KNOWLEDGE + "\n\n" + vector_context if vector_context else ANIMAL_DRUG_KNOWLEDGE + knowledge_base=knowledge_section ) # OpenAI API 호출 @@ -4251,6 +4327,7 @@ def api_rx_usage(): mssql_session = db_manager.get_session('PM_PRES') # 1년간 사용 환자 3명 이하 약품의 환자 목록 조회 + 조회 기간 내 사용 여부 + # PS_Type: 0,1=일반, 4=대체조제(실제), 9=대체조제(원본) - 9는 제외해야 실제 조제된 약만 집계 patient_query = text(""" WITH PatientUsage AS ( SELECT DISTINCT @@ -4260,6 +4337,7 @@ def api_rx_usage(): FROM PS_sub_pharm P JOIN PS_main M ON P.PreSerial = M.PreSerial WHERE M.Indate >= CONVERT(VARCHAR, DATEADD(YEAR, -1, GETDATE()), 112) + AND (P.PS_Type IS NULL OR P.PS_Type != '9') GROUP BY P.DrugCode, M.Paname ) SELECT @@ -4318,6 +4396,7 @@ def api_rx_usage(): orders_conn.close() # 전문의약품 품목별 사용량 집계 쿼리 (현재고: IM_total.IM_QT_sale_debit, 위치: CD_item_position.CD_NM_sale) + # PS_Type != '9' 조건: 대체조제 원본 처방 제외 → 실제 조제된 약만 집계 rx_query = text(""" SELECT P.DrugCode as drug_code, @@ -4336,6 +4415,7 @@ def api_rx_usage(): LEFT JOIN PM_DRUG.dbo.CD_item_position POS ON P.DrugCode = POS.DrugCode WHERE P.Indate >= :start_date AND P.Indate <= :end_date + AND (P.PS_Type IS NULL OR P.PS_Type != '9') GROUP BY P.DrugCode, G.GoodsName, G.SplName, G.BARCODE, IT.IM_QT_sale_debit, POS.CD_NM_sale ORDER BY SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) DESC """) @@ -7301,10 +7381,30 @@ def api_animal_drug_info_print(): LIMIT 1 """), {'apc': apc}) row = result.fetchone() - + + # 포장단위 APC → 대표 APC 폴백 + if not row and len(apc) == 13 and apc.startswith('023'): + item_prefix = apc[:8] + result = conn.execute(text(""" + SELECT + a.product_name, a.company_name, a.main_ingredient, + a.efficacy_effect, a.dosage_instructions, a.precautions, + a.weight_min_kg, a.weight_max_kg, a.pet_size_label, + a.component_code, + g.component_name_ko, g.dosing_interval_adult, + g.dosing_interval_high_risk, g.dosing_interval_puppy, + g.companion_drugs + FROM apc a + LEFT JOIN component_guide g ON a.component_code = g.component_code + WHERE a.apc LIKE :prefix + ORDER BY LENGTH(a.apc) + LIMIT 1 + """), {'prefix': f'{item_prefix}%'}) + row = result.fetchone() + if not row: return jsonify({'success': False, 'error': f'APC {apc} 정보를 찾을 수 없습니다'}), 404 - + except Exception as e: logging.error(f"PostgreSQL 조회 오류: {e}") return jsonify({'success': False, 'error': f'DB 조회 오류: {str(e)}'}), 500 @@ -7320,8 +7420,8 @@ def api_animal_drug_info_print(): # HTML 엔티티 변환 text = unescape(text) - # 표 형식 감지 (─ 문자 포함) - if '─' in text or '━' in text: + # 표 형식 감지 (─ 또는 ====/---- 포함) + if '─' in text or '━' in text or ('======' in text and '------' in text): # 표 형식: 각 줄의 앞뒤 공백만 정리, 줄 내 공백은 유지 lines = text.split('\n') cleaned = [] @@ -7409,13 +7509,26 @@ def api_animal_drug_info_print(): {THIN} ▶ 용법용량 """ - # 표 형식 감지 (─ 문자 포함) - if '─' in dosage or '━' in dosage: - # 표 형식: 줄바꿈 유지, 공백 유지 + # 표 형식 감지 + has_box_table = '─' in dosage or '━' in dosage + has_ascii_table = '======' in dosage and '------' in dosage + if has_box_table: + # ─ 표: 줄바꿈 유지 for line in dosage.split('\n'): line = line.strip() if line: message += f"{line}\n" + elif has_ascii_table: + # ===/--- 표: 구분선 제거, 데이터만 정리 + for line in dosage.split('\n'): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith('===') or stripped.startswith('---'): + message += f" {'─' * 44}\n" + else: + # 공백 정렬된 열을 적절히 정리 + message += f" {stripped}\n" else: formatted_dosage = format_for_print(dosage) for para in formatted_dosage.split('\n'): @@ -7518,7 +7631,27 @@ def api_animal_drug_info_preview(): LIMIT 1 """), {'apc': apc}) row = result.fetchone() - + + # 포장단위 APC → 대표 APC 폴백 (앞 8자리 품목코드로 검색) + if not row and len(apc) == 13 and apc.startswith('023'): + item_prefix = apc[:8] + result = conn.execute(text(""" + SELECT + a.product_name, a.company_name, a.main_ingredient, + a.efficacy_effect, a.dosage_instructions, a.precautions, + a.component_code, + g.component_name_ko, g.dosing_interval_adult, + g.dosing_interval_high_risk, g.dosing_interval_puppy, + g.companion_drugs, + g.contraindication as guide_contraindication + FROM apc a + LEFT JOIN component_guide g ON a.component_code = g.component_code + WHERE a.apc LIKE :prefix + ORDER BY LENGTH(a.apc) + LIMIT 1 + """), {'prefix': f'{item_prefix}%'}) + row = result.fetchone() + if not row: return jsonify({'success': False, 'error': f'APC {apc} 정보 없음'}), 404 @@ -7534,8 +7667,8 @@ def api_animal_drug_info_preview(): text = re.sub(r'<[^>]+>', '', text) text = unescape(text) - # 표 형식 감지 - if '─' in text or '━' in text: + # 표 형식 감지 (─ 또는 ====/---- 포함) + if '─' in text or '━' in text or ('======' in text and '------' in text): lines = [l.strip() for l in text.split('\n') if l.strip()] return '\n'.join(lines) else: @@ -7556,49 +7689,175 @@ def api_animal_drug_info_preview(): def format_table_html(text): if not text: return '' - + + has_box_line = '─' in text or '━' in text + has_ascii_table = '======' in text and '------' in text + # 표가 없으면 일반 처리 - if '─' not in text and '━' not in text: + if not has_box_line and not has_ascii_table: return format_items(text) - - # 표 형식: 체중별 투여량 테이블 생성 - lines = [l.strip() for l in text.split('\n') if l.strip() and '─' not in l and '━' not in l] - - # 체중 행과 투여정수 행 찾기 - header_line = None - data_line = None - other_lines = [] - - for line in lines: - if '체중' in line and 'kg' in line.lower(): - header_line = line - elif '투여' in line and '정수' in line: - data_line = line + + # ── (A) 안텔민 형식: ─ 구분 + "체중(kg)" 헤더 + "투여정수" 데이터 (2행 표) ── + if has_box_line: + lines = [l.strip() for l in text.split('\n') if l.strip() and '─' not in l and '━' not in l] + header_line = None + data_line = None + other_lines = [] + + for line in lines: + if '체중' in line and 'kg' in line.lower(): + header_line = line + elif '투여' in line and ('정수' in line or '정' in line): + data_line = line + else: + other_lines.append(line) + + result = '\n'.join(other_lines) + + if header_line and data_line: + headers = re.split(r'\s{2,}', header_line) + values = re.split(r'\s{2,}', data_line) + + html = '' + html += '' + for h in headers: + html += f'' + html += '' + html += '' + for v in values: + html += f'' + html += '' + html += '
{h}
{v}
' + + result = result + '\n' + html if result else html + return result + + # ── (B) 넥스가드 형식: ====/---- 구분 + 다행 테이블 ── + if has_ascii_table: + lines = text.split('\n') + before_table = [] + table_rows = [] + after_table = [] + header_cols = [] + in_table = False + table_ended = False + + for line in lines: + stripped = line.strip() + if not stripped: + continue + is_eq_sep = stripped.startswith('===') + is_dash_sep = stripped.startswith('---') + + if is_eq_sep: + if in_table: + # 두 번째 === → 테이블 끝 + table_ended = True + else: + # 첫 번째 === → 테이블 시작 + in_table = True + continue + if is_dash_sep: + # --- 는 행 구분선 → 건너뛰기 + continue + + if not in_table: + before_table.append(stripped) + continue + if table_ended: + after_table.append(stripped) + continue + + # ㅣ 또는 | 구분자 감지 + if 'ㅣ' in stripped or '|' in stripped: + sep = 'ㅣ' if 'ㅣ' in stripped else '|' + cells = [c.strip() for c in stripped.split(sep) if c.strip()] + else: + cells = re.split(r'\s{2,}', stripped) + + # 테이블 헤더 행 감지 + if '체중' in stripped and not header_cols: + header_cols = cells + continue + + # 데이터 행 + if len(cells) >= 2: + table_rows.append(cells) + + result_parts = [] + if before_table: + result_parts.append(format_items('\n'.join(before_table))) + + if header_cols and table_rows: + html = '' + html += '' + for h in header_cols: + html += f'' + html += '' + for i, row in enumerate(table_rows): + bg = '#fff' if i % 2 == 0 else '#f8fafc' + html += f'' + for cell in row: + html += f'' + # 셀 수가 헤더보다 적으면 빈 셀 채우기 + for _ in range(len(header_cols) - len(row)): + html += '' + html += '' + html += '
{h}
{cell}
' + result_parts.append(html) + + if after_table: + result_parts.append(format_items('\n'.join(after_table))) + + return '\n'.join(result_parts) + + return format_items(text) + + def format_dosage(raw_html): + """dosage_instructions 처리: 원본 HTML table 보존 or 텍스트 표 변환""" + if not raw_html: + return '' + # 원본에 태그가 있으면 → HTML 테이블 보존 + 스타일 적용 + if ']*class="_table_wrap', raw_html, maxsplit=1)[0] if '_table_wrap' in raw_html else raw_html.split('(.*)', raw_html, re.DOTALL) + after_html = after_match.group(1) if after_match else '' + + # 앞부분 텍스트 처리 + before_text = strip_html(before_html) + before_text = format_items(before_text) if before_text else '' + + # 테이블 추출 및 스타일 적용 + table_match = re.search(r']*>(.*?)
', raw_html, re.DOTALL) + if table_match: + table_inner = table_match.group(1) + # caption, hidden 요소 제거 + table_inner = re.sub(r'.*?', '', table_inner, flags=re.DOTALL) + # 기존 style 제거하고 새 스타일 적용 + table_inner = re.sub(r']*>', '', table_inner) + table_inner = re.sub(r']*>', '', table_inner) + # 첫 번째 tr에 헤더 배경색 적용 + table_inner = re.sub(r']*>', '', table_inner) + table_inner = table_inner.replace('', '', 1) + # p 태그 제거 (셀 내부) + table_inner = re.sub(r']*>', '', table_inner) + table_inner = re.sub(r'

', '
', table_inner) + # tbody 유지 + styled_table = f'{table_inner}
' else: - other_lines.append(line) - - result = '\n'.join(other_lines) - - # HTML 테이블 생성 - if header_line and data_line: - headers = re.split(r'\s{2,}', header_line) - values = re.split(r'\s{2,}', data_line) - - html = '' - html += '' - for h in headers: - html += f'' - html += '' - html += '' - for v in values: - html += f'' - html += '' - html += '
{h}
{v}
' - - result = result + '\n' + html if result else html - - return result - + styled_table = '' + + # 뒷부분 텍스트 처리 + after_text = strip_html(after_html) + after_text = format_items(after_text) if after_text else '' + + parts = [p for p in [before_text, styled_table, after_text] if p] + return '\n'.join(parts) + + # 원본에 없으면 기존 로직 + return format_table_html(strip_html(raw_html)) + # 투약주기 조합 dosing_interval = None if row.dosing_interval_adult: @@ -7617,8 +7876,8 @@ def api_animal_drug_info_preview(): 'company_name': row.company_name, 'main_ingredient': row.main_ingredient if row.main_ingredient != 'NaN' else None, 'efficacy_effect': format_items(strip_html(row.efficacy_effect)), - 'dosage_instructions': format_table_html(strip_html(row.dosage_instructions)), - 'dosage_has_table': '─' in (row.dosage_instructions or ''), + 'dosage_instructions': format_dosage(row.dosage_instructions), + 'dosage_has_table': any(c in (row.dosage_instructions or '') for c in ('─', '━', '======', ' dict: + """ + 동원약품 주문 제출 + + Args: + order: 주문 정보 + dry_run: True=시뮬레이션만, False=실제 주문 + cart_only: True=장바구니만, False=주문 확정까지 + """ + order_id = order['id'] + items = order['items'] + + # 상태 업데이트 + update_order_status(order_id, 'pending', + f'동원 주문 시작 (dry_run={dry_run}, cart_only={cart_only})') + + results = [] + success_count = 0 + failed_count = 0 + + try: + from dongwon_api import get_dongwon_session + dongwon_session = get_dongwon_session() + + if dry_run: + # ───────────────────────────────────────── + # DRY RUN: 재고 확인만 + # ───────────────────────────────────────── + for item in items: + kd_code = item.get('kd_code') or item.get('drug_code') + spec = item.get('specification', '') + + # 재고 검색 + search_result = dongwon_session.search_products(kd_code) + + matched = None + available_specs = [] + spec_stocks = {} + + if search_result.get('success'): + for dongwon_item in search_result.get('items', []): + s = dongwon_item.get('spec', '') + available_specs.append(s) + spec_stocks[s] = dongwon_item.get('stock', 0) + + # 규격 매칭 + if spec in s or s in spec: + if matched is None or dongwon_item.get('stock', 0) > matched.get('stock', 0): + matched = dongwon_item + + if matched: + stock = matched.get('stock', 0) + if stock >= item['order_qty']: + status = 'success' + result_code = 'OK' + result_message = f"[DRY RUN] 주문 가능: 재고 {stock}, 단가 {matched.get('price', 0):,}원" + success_count += 1 + elif stock > 0: + status = 'failed' + result_code = 'LOW_STOCK' + result_message = f"[DRY RUN] 재고 부족: {stock}개 (요청: {item['order_qty']})" + failed_count += 1 + else: + status = 'failed' + result_code = 'OUT_OF_STOCK' + result_message = f"[DRY RUN] 재고 없음" + failed_count += 1 + else: + status = 'failed' + result_code = 'NOT_FOUND' + result_message = f"[DRY RUN] 동원에서 규격 {spec} 미발견" + failed_count += 1 + + update_item_result(item['id'], status, result_code, result_message) + + results.append({ + 'item_id': item['id'], + 'drug_code': item.get('drug_code') or item.get('kd_code'), + 'product_name': item.get('product_name') or item.get('drug_name', ''), + 'specification': spec, + 'order_qty': item['order_qty'], + 'status': status, + 'result_code': result_code, + 'result_message': result_message, + 'matched_spec': matched.get('spec') if matched else None, + 'stock': matched.get('stock') if matched else 0, + 'price': matched.get('price') if matched else 0 + }) + + update_order_status(order_id, 'dry_run_complete', + f'[DRY RUN] 완료: 성공 {success_count}, 실패 {failed_count}') + + return { + 'success': True, + 'dry_run': dry_run, + 'cart_only': cart_only, + 'order_id': order_id, + 'order_no': order['order_no'], + 'wholesaler': 'dongwon', + 'total_items': len(items), + 'success_count': success_count, + 'failed_count': failed_count, + 'results': results + } + + else: + # ───────────────────────────────────────── + # 실제 주문: 장바구니 담기 (또는 주문 확정) + # ───────────────────────────────────────── + cart_items = [] + + for item in items: + kd_code = item.get('kd_code') or item.get('drug_code') + internal_code = item.get('dongwon_code') or item.get('internal_code') + spec = item.get('specification', '') + order_qty = item['order_qty'] + + # internal_code가 없으면 검색해서 찾기 + if not internal_code: + search_result = dongwon_session.search_products(kd_code) + if search_result.get('success') and search_result.get('items'): + for dongwon_item in search_result['items']: + s = dongwon_item.get('spec', '') + if spec in s or s in spec: + internal_code = dongwon_item.get('internal_code') + break + # 규격 매칭 안 되면 첫 번째 결과 사용 + if not internal_code and search_result['items']: + internal_code = search_result['items'][0].get('internal_code') + + product_name = item.get('product_name') or item.get('drug_name', '') + + if internal_code: + cart_items.append({ + 'internal_code': internal_code, + 'quantity': order_qty + }) + + update_item_result(item['id'], 'success', 'CART_READY', + f'장바구니 준비 완료: {internal_code}') + results.append({ + 'item_id': item['id'], + 'drug_code': kd_code, + 'product_name': product_name, + 'specification': spec, + 'order_qty': order_qty, + 'status': 'success', + 'result_code': 'CART_READY', + 'result_message': f'장바구니 준비 완료: {internal_code}', + 'internal_code': internal_code + }) + success_count += 1 + else: + update_item_result(item['id'], 'failed', 'NOT_FOUND', + f'동원에서 제품 미발견: {kd_code}') + results.append({ + 'item_id': item['id'], + 'drug_code': kd_code, + 'product_name': product_name, + 'specification': spec, + 'order_qty': order_qty, + 'status': 'failed', + 'result_code': 'NOT_FOUND', + 'result_message': f'동원에서 제품 미발견' + }) + failed_count += 1 + + # safe_order 사용 (장바구니 백업/복구) + if cart_items: + if cart_only: + # 장바구니만 담기 + for cart_item in cart_items: + dongwon_session.add_to_cart( + cart_item['internal_code'], + cart_item['quantity'] + ) + update_order_status(order_id, 'cart_added', + f'동원 장바구니 담기 완료: {len(cart_items)}개 품목') + else: + # safe_order로 주문 (기존 장바구니 백업/복구) + order_result = dongwon_session.safe_order( + items_to_order=cart_items, + memo=order.get('memo', ''), + dry_run=False + ) + if order_result.get('success'): + update_order_status(order_id, 'completed', + f'동원 주문 완료: {order_result.get("ordered_count", 0)}개 품목') + else: + update_order_status(order_id, 'failed', + f'동원 주문 실패: {order_result.get("error", "unknown")}') + + # 응답 생성 + if cart_only: + note = '동원약품 장바구니에 담김. 동원몰에서 최종 확정 필요.' + else: + note = None + + return { + 'success': True, + 'dry_run': dry_run, + 'cart_only': cart_only, + 'order_id': order_id, + 'order_no': order['order_no'], + 'wholesaler': 'dongwon', + 'total_items': len(items), + 'success_count': success_count, + 'failed_count': failed_count, + 'results': results, + 'note': note + } + + except Exception as e: + logger.error(f"동원 주문 오류: {e}", exc_info=True) + update_order_status(order_id, 'error', f'동원 주문 오류: {str(e)}') + return { + 'success': False, + 'order_id': order_id, + 'wholesaler': 'dongwon', + 'error': str(e) + } diff --git a/backend/scripts/batch_apc_matching.py b/backend/scripts/batch_apc_matching.py index 9d54ec9..7317523 100644 --- a/backend/scripts/batch_apc_matching.py +++ b/backend/scripts/batch_apc_matching.py @@ -1,25 +1,102 @@ # -*- coding: utf-8 -*- """ -동물약 일괄 APC 매칭 - 후보 찾기 +동물약 일괄 APC 매칭 (개선판) +- 띄어쓰기 무시 매칭 +- 체중 범위로 정밀 매칭 +- dry-run 모드 (검증용) """ -import sys, io +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 없는 것만) ── -# 1. MSSQL 동물약 (APC 없는 것만) session = get_db_session('PM_DRUG') result = session.execute(text(""" - SELECT + 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 + 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 @@ -39,44 +116,190 @@ for row in result: session.close() -print(f'=== APC 없는 동물약: {len(no_apc)}개 ===\n') +print(f'=== APC 없는 동물약: {len(no_apc)}개 ===') +print(f'=== 모드: {"DRY-RUN (검증만)" if DRY_RUN else "실제 INSERT"} ===\n') + +# ── 2. PostgreSQL에서 매칭 ── -# 2. PostgreSQL에서 매칭 후보 찾기 pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect() -matches = [] +matched = [] # 확정 매칭 +ambiguous = [] # 후보 여러 개 (수동 확인 필요) +no_match = [] # 매칭 없음 + for drug in no_apc: name = drug['name'] - # 제품명에서 검색 키워드 추출 - # (판) 제거, 괄호 내용 제거 - search_name = name.replace('(판)', '').split('(')[0].strip() - - # PostgreSQL 검색 - result = pg.execute(text(""" - SELECT apc, product_name, - llm_pharm->>'사용가능 동물' as target, - llm_pharm->>'분류' as category - FROM apc - WHERE product_name ILIKE :pattern - ORDER BY LENGTH(product_name) - LIMIT 5 - """), {'pattern': f'%{search_name}%'}) - - candidates = list(result) - if candidates: - matches.append({ + 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, - 'candidates': candidates + 'apc': filtered[0], + 'method': method }) print(f'✅ {name}') - for c in candidates[:2]: - print(f' → {c.apc}: {c.product_name[:40]}... [{c.target or "?"}]') - else: - 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() -print(f'\n=== 요약 ===') +# ── 3. 요약 ── + +print(f'\n{"="*50}') +print(f'=== 매칭 요약 ===') print(f'APC 없는 제품: {len(no_apc)}개') -print(f'매칭 후보 있음: {len(matches)}개') -print(f'매칭 없음: {len(no_apc) - len(matches)}개') +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완료!') diff --git a/backend/scripts/batch_insert_apc.py b/backend/scripts/batch_insert_apc.py index 479719f..2808f9f 100644 --- a/backend/scripts/batch_insert_apc.py +++ b/backend/scripts/batch_insert_apc.py @@ -18,6 +18,15 @@ MAPPINGS = [ # 세레니아 ('세레니아정16mg(개멀미약)', 'LB000003353', '0231884610109'), # 세레니아 정 16mg / 4정 ('세레니아정24mg(개멀미약)', 'LB000003354', '0231884620107'), # 세레니아 정 24mg / 4정 + # ── 2차 매칭 (2026-03-08) ── + # 클라펫 (유일후보) + ('(판)클라펫정50(100정)', 'LB000003504', '0232065900005'), # 클라펫 정 + # 넥스가드 (체중매칭) + ('넥스가드L(15~30kg)', 'LB000003531', '0232155400009'), # 넥스가드 스펙트라 츄어블 정 대형견용 + ('넥스가드xs(2~3.5kg)', 'LB000003530', '0232169000004'), # 넥스가드 츄어블 정 소형견용 + # 하트웜 (체중매칭) + ('하트웜솔루션츄어블M(12~22kg)', 'LB000003155', '0230758520105'), # 하트웜 솔루션 츄어블 0.136mg / 114mg / 6 정 + ('하트웜솔루션츄어블S(11kg이하)', 'LB000003156', '0230758510107'), # 하트웜 솔루션 츄어블 0.068mg / 57mg / 6 정 ] session = get_db_session('PM_DRUG') diff --git a/backend/scripts/tag_animal_drugs.py b/backend/scripts/tag_animal_drugs.py index a553ed7..c2e08ef 100644 --- a/backend/scripts/tag_animal_drugs.py +++ b/backend/scripts/tag_animal_drugs.py @@ -26,6 +26,11 @@ ANIMAL_KEYWORDS = [ '펫팜', '동물약품', '애니팜' ] +# 동물약 공급처 (SplName이 이 값이면 전부 동물약) +ANIMAL_SUPPLIERS = [ + '펫팜' +] + # 제외 키워드 (사람용 약) EXCLUDE_KEYWORDS = [ '헤리펫사', '토피라펫', '메타트레이스', '페리돈', '세파라캅' @@ -58,24 +63,38 @@ def init_sqlite_db(): print(f"✅ SQLite DB 준비: {DB_PATH}") def search_animal_drugs(): - """MSSQL에서 동물약 키워드 검색""" + """MSSQL에서 동물약 검색 (키워드 + 공급처)""" print("🔍 CD_GOODS에서 동물약 검색 중...") - + session = db_manager.get_session('PM_DRUG') - - # 키워드 조건 생성 - conditions = ' OR '.join([f"GoodsName LIKE '%{kw}%'" for kw in ANIMAL_KEYWORDS]) - + + # 키워드 조건 + keyword_conds = ' OR '.join([f"GoodsName LIKE '%{kw}%'" for kw in ANIMAL_KEYWORDS]) + + # 공급처 조건 + supplier_conds = ' OR '.join([f"SplName = '{sp}'" for sp in ANIMAL_SUPPLIERS]) + query = text(f""" - SELECT DrugCode, GoodsName, BARCODE, POS_BOON + SELECT DrugCode, GoodsName, BARCODE, POS_BOON, SplName FROM CD_GOODS - WHERE ({conditions}) + WHERE (({keyword_conds}) OR ({supplier_conds})) AND GoodsSelCode = 'B' """) - + result = session.execute(query) drugs = result.fetchall() - print(f"✅ 발견: {len(drugs)}개") + + # 키워드 vs 공급처 통계 + by_keyword = [d for d in drugs if any(kw in (d.GoodsName or '') for kw in ANIMAL_KEYWORDS)] + by_supplier = [d for d in drugs if d.SplName in ANIMAL_SUPPLIERS] + supplier_only = [d for d in by_supplier if not any(kw in (d.GoodsName or '') for kw in ANIMAL_KEYWORDS)] + + print(f"✅ 발견: {len(drugs)}개 (키워드: {len(by_keyword)}, 공급처 추가: {len(supplier_only)})") + if supplier_only: + print(" 📦 공급처 기반 신규:") + for d in supplier_only: + print(f" {d.DrugCode}: {d.GoodsName} ({d.SplName})") + return drugs def tag_to_sqlite(drugs): @@ -93,20 +112,27 @@ def tag_to_sqlite(drugs): drug_code = drug[0] drug_name = drug[1] or '' barcode = drug[2] - + spl_name = drug[4] if len(drug) > 4 else '' + # 제외 키워드 체크 if any(ex in drug_name for ex in EXCLUDE_KEYWORDS): excluded += 1 print(f" ⛔ 제외: {drug_code} - {drug_name}") continue - + + # 매칭 소스 구분 + by_kw = any(kw in drug_name for kw in ANIMAL_KEYWORDS) + by_sp = spl_name in ANIMAL_SUPPLIERS + source = 'keyword' if by_kw else 'supplier' + note = '키워드 자동 태깅' if by_kw else f'공급처({spl_name}) 자동 태깅' + try: cursor.execute(''' - INSERT INTO drug_tags (drug_code, drug_name, barcode, tag_type, tag_value, note) - VALUES (?, ?, ?, 'animal_drug', 'all', '키워드 자동 태깅') - ''', (drug_code, drug_name, barcode)) + INSERT INTO drug_tags (drug_code, drug_name, barcode, tag_type, tag_value, note, source) + VALUES (?, ?, ?, 'animal_drug', 'all', ?, ?) + ''', (drug_code, drug_name, barcode, note, source)) added += 1 - print(f" ✅ {drug_code}: {drug_name}") + print(f" ✅ {drug_code}: {drug_name} [{source}]") except sqlite3.IntegrityError: skipped += 1 diff --git a/backend/templates/admin_rx_usage.html b/backend/templates/admin_rx_usage.html index 0ea51c5..9edf356 100644 --- a/backend/templates/admin_rx_usage.html +++ b/backend/templates/admin_rx_usage.html @@ -939,7 +939,7 @@ loadOrderData(); // 수인약품 주문량 로드 }); - // ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 합산) ──────────────── + // ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 + 동원 합산) ──────────────── async function loadOrderData() { const startDate = document.getElementById('startDate').value; const endDate = document.getElementById('endDate').value; @@ -948,11 +948,12 @@ orderDataByKd = {}; try { - // 지오영 + 수인 + 백제 병렬 조회 - const [geoRes, sooinRes, baekjeRes] = await Promise.all([ + // 지오영 + 수인 + 백제 + 동원 병렬 조회 + const [geoRes, sooinRes, baekjeRes, dongwonRes] = await Promise.all([ fetch(`/api/geoyoung/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })), fetch(`/api/sooin/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })), - fetch(`/api/baekje/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })) + fetch(`/api/baekje/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })), + fetch(`/api/dongwon/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })) ]); let totalOrders = 0; @@ -999,7 +1000,21 @@ console.log('💉 백제 주문량:', Object.keys(baekjeRes.by_kd_code).length, '품목,', baekjeRes.order_count, '건'); } - console.log('📦 3사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문'); + // 동원 데이터 합산 + if (dongwonRes.success && dongwonRes.by_kd_code) { + for (const [kd, data] of Object.entries(dongwonRes.by_kd_code)) { + if (!orderDataByKd[kd]) { + orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] }; + } + orderDataByKd[kd].boxes += data.boxes || 0; + orderDataByKd[kd].units += data.units || 0; + orderDataByKd[kd].sources.push('동원'); + } + totalOrders += dongwonRes.order_count || 0; + console.log('🟠 동원 주문량:', Object.keys(dongwonRes.by_kd_code).length, '품목,', dongwonRes.order_count, '건'); + } + + console.log('📦 4사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문'); } catch(err) { console.warn('주문량 조회 실패:', err); @@ -1269,6 +1284,16 @@ gradient: 'linear-gradient(135deg, #d97706, #f59e0b)', filterFn: (item) => item.supplier === '백제약품' || item.wholesaler === 'baekje', getCode: (item) => item.baekje_code || item.drug_code + }, + dongwon: { + id: 'dongwon', + name: '동원약품', + icon: '🏥', + logo: '/static/img/logo_dongwon.png', + color: '#22c55e', + gradient: 'linear-gradient(135deg, #16a34a, #22c55e)', + filterFn: (item) => item.supplier === '동원약품' || item.wholesaler === 'dongwon', + getCode: (item) => item.dongwon_code || item.internal_code || item.drug_code } }; @@ -2043,9 +2068,9 @@ if (e.key === 'Enter') loadUsageData(); }); - // ──────────────── 도매상 재고 조회 (지오영 + 수인 + 백제) ──────────────── + // ──────────────── 도매상 재고 조회 (지오영 + 수인 + 백제 + 동원) ──────────────── let currentWholesaleItem = null; - window.wholesaleItems = { geoyoung: [], sooin: [], baekje: [] }; + window.wholesaleItems = { geoyoung: [], sooin: [], baekje: [], dongwon: [] }; function openWholesaleModal(idx) { const item = usageData[idx]; @@ -2065,7 +2090,7 @@ document.getElementById('geoResultBody').innerHTML = `
-
도매상 재고 조회 중... (지오영 + 수인 + 백제)
+
도매상 재고 조회 중... (지오영 + 수인 + 백제 + 동원)
`; document.getElementById('geoSearchKeyword').style.display = 'none'; @@ -2081,22 +2106,24 @@ async function searchAllWholesalers(kdCode, productName) { const resultBody = document.getElementById('geoResultBody'); - // 세 도매상 동시 호출 - const [geoResult, sooinResult, baekjeResult] = await Promise.all([ + // 네 도매상 동시 호출 + const [geoResult, sooinResult, baekjeResult, dongwonResult] = await Promise.all([ searchGeoyoungAPI(kdCode, productName), searchSooinAPI(kdCode), - searchBaekjeAPI(kdCode) + searchBaekjeAPI(kdCode), + searchDongwonAPI(kdCode, productName) ]); // 결과 저장 window.wholesaleItems = { geoyoung: geoResult.items || [], sooin: sooinResult.items || [], - baekje: baekjeResult.items || [] + baekje: baekjeResult.items || [], + dongwon: dongwonResult.items || [] }; // 통합 렌더링 - renderWholesaleResults(geoResult, sooinResult, baekjeResult); + renderWholesaleResults(geoResult, sooinResult, baekjeResult, dongwonResult); } async function searchGeoyoungAPI(kdCode, productName) { @@ -2136,18 +2163,42 @@ return { success: false, error: err.message, items: [] }; } } + + async function searchDongwonAPI(kdCode, productName) { + try { + // 1차: KD코드(보험코드)로 검색 (searchType=0) + let response = await fetch(`/api/dongwon/stock?keyword=${encodeURIComponent(kdCode)}`); + let data = await response.json(); + + // 결과 없으면 제품명으로 재검색 + if (data.success && data.count === 0 && productName) { + // 제품명 정제: "휴니즈레바미피드정_(0.1g/1정)" → "휴니즈레바미피드정" + let cleanName = productName.split('_')[0].split('(')[0].trim(); + if (cleanName) { + response = await fetch(`/api/dongwon/stock?keyword=${encodeURIComponent(cleanName)}`); + data = await response.json(); + } + } + + return data; + } catch (err) { + return { success: false, error: err.message, items: [] }; + } + } - function renderWholesaleResults(geoResult, sooinResult, baekjeResult) { + function renderWholesaleResults(geoResult, sooinResult, baekjeResult, dongwonResult) { const resultBody = document.getElementById('geoResultBody'); const geoItems = geoResult.items || []; const sooinItems = sooinResult.items || []; const baekjeItems = (baekjeResult && baekjeResult.items) || []; + const dongwonItems = (dongwonResult && dongwonResult.items) || []; // 재고 있는 것 먼저 정렬 geoItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock); sooinItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock); baekjeItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock); + dongwonItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock); let html = ''; @@ -2260,6 +2311,44 @@ } html += ''; + // ═══════ 동원약품 섹션 ═══════ + html += `
+
+ + 동원약품 + ${dongwonItems.length}건 +
`; + + if (dongwonItems.length > 0) { + html += `
+ + `; + + dongwonItems.forEach((item, idx) => { + const hasStock = item.stock > 0; + // 동원: code=KD코드(보험코드), internal_code=내부코드(주문용) + const displayCode = item.code || item.internal_code || ''; + html += ` + + + + + + + `; + }); + + html += '
제품명규격단가재고
+
+ ${escapeHtml(item.name)} + ${displayCode} · ${item.manufacturer || ''} +
+
${item.spec || '-'}${item.price ? item.price.toLocaleString() + '원' : '-'}${item.stock}${hasStock ? `` : ''}
'; + } else { + html += `
📭 검색 결과 없음
`; + } + html += ''; + resultBody.innerHTML = html; } @@ -2277,7 +2366,7 @@ const needed = currentWholesaleItem.total_dose; const suggestedQty = Math.ceil(needed / specQty); - const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품' }; + const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품', dongwon: '동원약품' }; const supplierName = supplierNames[wholesaler] || wholesaler; const productName = wholesaler === 'geoyoung' ? item.product_name : item.name; @@ -2298,6 +2387,7 @@ geoyoung_code: wholesaler === 'geoyoung' ? item.insurance_code : null, sooin_code: wholesaler === 'sooin' ? item.code : null, baekje_code: wholesaler === 'baekje' ? item.internal_code : null, + dongwon_code: wholesaler === 'dongwon' ? item.internal_code : null, // 동원: 내부코드로 주문 unit_price: unitPrice // 💰 단가 추가 }; @@ -2542,6 +2632,12 @@ .geo-add-btn.baekje:hover { background: #d97706; } + .geo-add-btn.dongwon { + background: #22c55e; + } + .geo-add-btn.dongwon:hover { + background: #16a34a; + } .geo-price { font-family: 'JetBrains Mono', monospace; font-size: 11px; @@ -2576,6 +2672,10 @@ background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(217, 119, 6, 0.1)); border-left: 3px solid var(--accent-amber); } + .ws-header.dongwon { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(22, 163, 74, 0.1)); + border-left: 3px solid #22c55e; + } .ws-logo { width: 24px; height: 24px; @@ -2778,6 +2878,9 @@ .multi-ws-card.baekje { border-left: 3px solid var(--accent-amber); } + .multi-ws-card.dongwon { + border-left: 3px solid #22c55e; + } .multi-ws-card.other { border-left: 3px solid var(--text-muted); opacity: 0.7; @@ -3077,9 +3180,16 @@ color: '#a855f7', balanceApi: '/api/sooin/balance', salesApi: '/api/sooin/monthly-sales' + }, + dongwon: { + id: 'dongwon', name: '동원약품', icon: '🏥', + logo: '/static/img/logo_dongwon.png', + color: '#22c55e', + balanceApi: '/api/dongwon/balance', + salesApi: '/api/dongwon/monthly-orders' } }; - const WHOLESALER_ORDER = ['baekje', 'geoyoung', 'sooin']; + const WHOLESALER_ORDER = ['baekje', 'geoyoung', 'sooin', 'dongwon']; async function loadBalances() { const content = document.getElementById('balanceContent'); diff --git a/docs/API_DEVELOPMENT_GUIDE.md b/docs/API_DEVELOPMENT_GUIDE.md new file mode 100644 index 0000000..a334e8f --- /dev/null +++ b/docs/API_DEVELOPMENT_GUIDE.md @@ -0,0 +1,260 @@ +# API 개발 가이드 및 트러블슈팅 + +## 📋 목차 +1. [도매상 주문 API 응답 형식](#도매상-주문-api-응답-형식) +2. [동원약품 API 버그 수정](#동원약품-api-버그-수정) + +--- + +## 도매상 주문 API 응답 형식 + +### `/api/order/quick-submit` 응답 표준 + +모든 도매상(지오영, 수인, 백제, 동원)의 주문 응답은 **동일한 형식**을 따라야 합니다: + +```json +{ + "success": true, + "dry_run": true, + "cart_only": false, + "order_id": 123, + "order_no": "ORD-20260308-001", + "wholesaler": "dongwon", + "total_items": 1, + "success_count": 1, + "failed_count": 0, + "results": [ + { + "item_id": 456, + "drug_code": "643900470", + "product_name": "부루펜정200mg", + "specification": "500정(병)", + "order_qty": 1, + "status": "success", + "result_code": "OK", + "result_message": "[DRY RUN] 주문 가능: 재고 9, 단가 17,000원", + "price": 17000 + } + ], + "note": "장바구니에 담김. 도매상 사이트에서 최종 확정 필요." +} +``` + +### ⚠️ 필수 필드 + +| 필드 | 설명 | 비고 | +|------|------|------| +| `wholesaler` | 도매상 ID | 프론트엔드에서 결과 모달 표시에 사용 | +| `success_count` | 성공 개수 | 최상위 레벨에 있어야 함 (summary 안에만 있으면 안됨) | +| `failed_count` | 실패 개수 | 최상위 레벨에 있어야 함 | +| `order_no` | 주문번호 | 프론트엔드 결과 모달에 표시 | + +--- + +## 동원약품 API 버그 수정 + +### 📅 수정일: 2026-03-08 + +### 🐛 문제 + +**증상:** +- 동원약품으로 주문하면 결과 모달에 "**지오영 주문 결과**"로 표시됨 +- 성공/실패 개수가 "**undefined**"로 표시됨 + +**원인:** +`submit_dongwon_order()` 함수의 응답에 다음 필드가 누락됨: +1. `wholesaler` 필드 없음 +2. `success_count`, `failed_count`가 `summary` 객체 안에만 있음 (최상위에 없음) +3. `order_no` 필드 없음 + +### 🔧 수정 내용 + +**파일:** `backend/order_api.py` + +**수정 전 (dry_run 응답):** +```python +return { + 'success': True, + 'dry_run': True, + 'results': results, + 'summary': { + 'total': len(items), + 'success': success_count, + 'failed': failed_count + } +} +``` + +**수정 후:** +```python +return { + 'success': True, + 'dry_run': dry_run, + 'cart_only': cart_only, + 'order_id': order_id, + 'order_no': order['order_no'], + 'wholesaler': 'dongwon', + 'total_items': len(items), + 'success_count': success_count, + 'failed_count': failed_count, + 'results': results +} +``` + +### ✅ 검증 + +테스트 절차: +1. `http://localhost:7001/admin/rx-usage` 접속 +2. 테이블에서 약품 더블클릭 → 도매상 재고 모달 열기 +3. 동원약품 섹션에서 "담기" 버튼 클릭 +4. 장바구니에서 "주문서 생성하기" 클릭 +5. "🧪 테스트" 버튼 클릭 +6. 결과 모달에서 확인: + - 제목: "🏥 **동원약품** 주문 결과" + - 성공: "1개" + - 실패: "0개" + +--- + +## 프론트엔드 장바구니 구조 + +### `addToCartFromWholesale()` 함수 + +동원약품에서 "담기" 버튼 클릭 시 장바구니에 추가되는 아이템 구조: + +```javascript +const cartItem = { + drug_code: '643900470', + product_name: '부루펜정200mg', + supplier: '동원약품', + qty: 1, + specification: '500정(병)', + wholesaler: 'dongwon', // ← 필터링에 사용 + internal_code: '16045', + dongwon_code: '16045', // ← 동원 API 호출에 사용 + unit_price: 17000 +}; +``` + +### 도매상 필터링 로직 + +```javascript +const WHOLESALERS = { + dongwon: { + filterFn: (item) => item.supplier === '동원약품' || item.wholesaler === 'dongwon' + } +}; +``` + +--- + +## 📝 개발 시 체크리스트 + +새로운 도매상 API 추가 시: + +- [ ] `submit_xxx_order()` 함수 응답에 `wholesaler` 필드 포함 +- [ ] `success_count`, `failed_count` 최상위 레벨에 포함 +- [ ] `order_no` 필드 포함 +- [ ] 프론트엔드 `WHOLESALERS` 객체에 도매상 추가 +- [ ] `filterFn` 함수 정의 +- [ ] E2E 테스트 수행 + +--- + +## 주문량 조회 API (summary-by-kd) + +### 📅 추가일: 2025-07-14 + +### 📋 개요 + +전문의약품 사용량 페이지(`/admin/rx-usage`)의 "주문량" 컬럼은 도매상별 주문량을 KD 코드 기준으로 합산하여 표시합니다. + +### ⚠️ 필수 구현: `/orders/summary-by-kd` 엔드포인트 + +**새로운 도매상 추가 시 반드시 구현해야 합니다!** + +#### 요청 + +``` +GET /api/{wholesaler}/orders/summary-by-kd?start_date=2025-07-01&end_date=2025-07-14 +``` + +#### 응답 형식 (표준) + +```json +{ + "success": true, + "order_count": 4, + "period": { + "start": "2025-07-01", + "end": "2025-07-14" + }, + "by_kd_code": { + "670400830": { + "product_name": "레바미피드정100mg", + "spec": "100T", + "boxes": 2, + "units": 200 + }, + "643900470": { + "product_name": "부루펜정200mg", + "spec": "500정(병)", + "boxes": 1, + "units": 500 + } + }, + "total_products": 2 +} +``` + +### 현재 구현 상태 + +| 도매상 | 엔드포인트 | KD 코드 집계 | 비고 | +|--------|------------|--------------|------| +| 지오영 | `/api/geoyoung/orders/summary-by-kd` | ✅ | 정상 작동 | +| 수인 | `/api/sooin/orders/summary-by-kd` | ✅ | 정상 작동 | +| 백제 | `/api/baekje/orders/summary-by-kd` | ✅ | 정상 작동 | +| 동원 | `/api/dongwon/orders/summary-by-kd` | ⚠️ | 주문 건수만 제공, 품목별 집계 불가 | + +### 동원약품 한계 + +동원약품 API(`onLineOrderListAX`)는 주문 목록만 반환하고, 각 주문의 상세 품목(items)을 제공하지 않습니다. + +**향후 개선 필요:** +- 동원 주문 상세 조회 API 탐색 필요 +- 또는 주문 상세 페이지 크롤링 구현 + +### 프론트엔드 연동 + +`admin_rx_usage.html`의 `loadOrderData()` 함수: + +```javascript +// 4사 병렬 조회 +const [geoRes, sooinRes, baekjeRes, dongwonRes] = await Promise.all([ + fetch(`/api/geoyoung/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`), + fetch(`/api/sooin/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`), + fetch(`/api/baekje/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`), + fetch(`/api/dongwon/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`) +]); + +// 각 도매상 데이터를 KD 코드 기준으로 합산 +if (dongwonRes.success && dongwonRes.by_kd_code) { + for (const [kd, data] of Object.entries(dongwonRes.by_kd_code)) { + orderDataByKd[kd].boxes += data.boxes || 0; + orderDataByKd[kd].units += data.units || 0; + orderDataByKd[kd].sources.push('동원'); + } +} +``` + +### 📝 새 도매상 추가 시 체크리스트 + +- [ ] `{wholesaler}_api.py`에 `/orders/summary-by-kd` 엔드포인트 구현 +- [ ] 응답 형식 표준 준수 (`by_kd_code`, `order_count` 등) +- [ ] `admin_rx_usage.html`의 `loadOrderData()`에 새 도매상 추가 +- [ ] 합산 로직에 새 도매상 데이터 추가 +- [ ] API 테스트 수행 + +--- + +*마지막 업데이트: 2025-07-14* diff --git a/docs/DONGWON_TROUBLESHOOTING.md b/docs/DONGWON_TROUBLESHOOTING.md new file mode 100644 index 0000000..7371c1b --- /dev/null +++ b/docs/DONGWON_TROUBLESHOOTING.md @@ -0,0 +1,129 @@ +# 동원약품 rx-usage 프론트엔드 연동 트러블슈팅 + +**작성일**: 2025-07-14 +**수정 파일**: `backend/templates/admin_rx_usage.html` + +## 발견된 문제점 3가지 + +### 문제 1: 재고 모달에서 KD코드가 아닌 내부코드 표시 + +**증상**: 동원약품만 재고 모달에서 내부코드(예: 16045, A4394)가 표시됨. 다른 도매상(지오영, 수인, 백제)은 KD코드(보험코드)가 정상 표시됨. + +**원인**: `renderWholesaleResults()` 함수의 동원 섹션에서 `item.internal_code`를 표시함 + +```javascript +// 잘못된 코드 +${item.internal_code || ''} · ${item.manufacturer || ''} +``` + +**해결**: 동원 API는 `code`에 KD코드(보험코드)를, `internal_code`에 내부코드를 반환함. 표시용은 `code` 사용. + +```javascript +// 수정된 코드 +const displayCode = item.code || item.internal_code || ''; +${displayCode} · ${item.manufacturer || ''} +``` + +### 문제 2: 장바구니 "주문서 생성하기"에 동원 미포함 + +**증상**: 장바구니에 동원 상품을 담아도 "주문서 생성하기" 모달에 동원이 나오지 않음. + +**원인**: `WHOLESALERS` 객체에 동원 설정 누락 + +```javascript +// 기존 코드 - 동원 없음 +const WHOLESALERS = { + geoyoung: {...}, + sooin: {...}, + baekje: {...} +}; +``` + +**해결**: `WHOLESALERS`에 동원 추가 + +```javascript +dongwon: { + id: 'dongwon', + name: '동원약품', + icon: '🏥', + logo: '/static/img/logo_dongwon.png', + color: '#22c55e', + gradient: 'linear-gradient(135deg, #16a34a, #22c55e)', + filterFn: (item) => item.supplier === '동원약품' || item.wholesaler === 'dongwon', + getCode: (item) => item.dongwon_code || item.internal_code || item.drug_code +} +``` + +### 문제 3: 장바구니에서 "dongwon"으로 표시 + +**증상**: 동원 상품이 장바구니에 담기면 "동원약품" 대신 "dongwon"으로 표시됨. + +**원인**: `addToCartFromWholesale()` 함수의 `supplierNames` 객체에 동원 누락 + +```javascript +// 기존 +const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품' }; +``` + +**해결**: 동원 추가 + +```javascript +const supplierNames = { + geoyoung: '지오영', + sooin: '수인약품', + baekje: '백제약품', + dongwon: '동원약품' +}; +``` + +## 추가 수정 사항 + +### 1. 장바구니 아이템에 dongwon_code 필드 추가 + +```javascript +const cartItem = { + ... + dongwon_code: wholesaler === 'dongwon' ? item.internal_code : null, + ... +}; +``` + +동원은 장바구니 담기/주문 시 `internal_code`를 사용해야 함. + +### 2. CSS 스타일 - 다중 도매상 모달 카드 색상 + +```css +.multi-ws-card.dongwon { + border-left: 3px solid #22c55e; +} +``` + +## 동원약품 API 필드 매핑 + +| API 필드 | 의미 | 용도 | +|----------|------|------| +| `code` | KD코드 (보험코드) | 화면 표시용 | +| `internal_code` | 동원 내부코드 | 장바구니 담기/주문 시 사용 | +| `name` | 제품명 | 표시용 | +| `manufacturer` | 제조사 | 표시용 | +| `spec` | 규격 | 표시용 | +| `price` | 단가 | 표시용 | +| `stock` | 재고 | 표시용 | + +## 관련 파일 + +- **프론트엔드**: `backend/templates/admin_rx_usage.html` +- **동원 API**: `backend/dongwon_api.py` +- **동원 세션**: `pharmacy-wholesale-api/wholesale/dongwon.py` + +## 테스트 방법 + +1. 전문의약품 사용량 페이지 접속: http://localhost:7001/admin/rx-usage +2. 약품 행 더블클릭하여 재고 모달 열기 +3. 동원약품 섹션에서: + - KD코드(9자리 숫자)가 표시되는지 확인 + - "담기" 버튼 클릭하여 장바구니 추가 +4. 장바구니 열어서: + - "동원약품"으로 표시되는지 확인 +5. "주문서 생성하기" 클릭하여: + - 동원약품이 도매상 목록에 나타나는지 확인 diff --git a/docs/postgresql-apdb.md b/docs/postgresql-apdb.md new file mode 100644 index 0000000..41cb90d --- /dev/null +++ b/docs/postgresql-apdb.md @@ -0,0 +1,278 @@ +# PostgreSQL APDB (apdb_master) 데이터베이스 문서 + +## 접속 정보 + +| 항목 | 값 | +|------|-----| +| Host | 192.168.0.87 | +| Port | 5432 | +| Database | apdb_master | +| User | admin | +| Password | trajet6640 | +| Connection String | `postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master` | + +```python +from sqlalchemy import create_engine +engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master') +``` + +--- + +## 핵심 테이블 + +### apc — 동물약품 마스터 (16,326건) + +APC(Animal Product Code) 기반 동물약품 정보. 모든 동물약의 기준 테이블. + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| idx | INTEGER PK | 일련번호 | +| apc | VARCHAR(100) | APC 코드 (13자리, '023'으로 시작) | +| item_seq | VARCHAR(100) | 품목기준코드 | +| item_code | VARCHAR(100) | 품목코드 (APC 앞 8자리 = item_code) | +| product_name | VARCHAR(200) | 제품명 (한글) | +| product_english_name | VARCHAR(200) | 제품 영문명 | +| company_name | VARCHAR(100) | 제조/수입사명 | +| approval_number | VARCHAR(100) | 허가번호 | +| ac | VARCHAR(100) | AC 코드 | +| dosage_code | VARCHAR(100) | 제형코드 | +| packaging_code | VARCHAR(100) | 포장코드 | +| pc | VARCHAR(100) | PC 코드 | +| dosage | VARCHAR(100) | 제형 (정, 액, 캡슐 등) | +| packaging | VARCHAR(100) | 포장단위 | +| approval_date | VARCHAR(100) | 허가일자 | +| product_type | VARCHAR(500) | 제품유형 | +| main_ingredient | VARCHAR(500) | 주성분 | +| finished_material | VARCHAR(500) | 완제원료 | +| manufacture_import | VARCHAR(100) | 제조/수입 구분 | +| country_of_manufacture | VARCHAR(100) | 제조국 | +| basic_info | TEXT | 기본정보 | +| raw_material | TEXT | 원료약품 | +| efficacy_effect | TEXT | 효능효과 | +| dosage_instructions | TEXT | 용법용량 | +| precautions | TEXT | 주의사항 | +| component_code | VARCHAR(100) | 성분코드 | +| component_name_ko | VARCHAR(200) | 성분명(한글) | +| component_name_en | VARCHAR(200) | 성분명(영문) | +| dosage_factor | VARCHAR(100) | 용량계수 | +| llm_pharm | JSONB | LLM 생성 약사용 정보 (투여량, 주의사항 등) | +| llm_user | VARCHAR(500) | LLM 생성 사용자용 설명 | +| image_url1~3 | VARCHAR(500) | 제품 이미지 URL | +| list_price | NUMERIC(10,2) | 정가 | +| weight_min_kg | DOUBLE PRECISION | 체중 하한 (kg) | +| weight_max_kg | DOUBLE PRECISION | 체중 상한 (kg) | +| pet_size_label | VARCHAR(100) | 체중 라벨 (소형견용, 대형견용 등) | +| pet_size_code | VARCHAR(10) | 체중 코드 | +| for_pets | BOOLEAN | 반려동물용 여부 | +| prescription_target | BOOLEAN | 처방대상 여부 | +| is_not_medicine | BOOLEAN | 비의약품 여부 | +| usage_guide | JSONB | 사용 가이드 (구조화) | +| godoimage_url_f/b/d | VARCHAR(500) | 고도몰 이미지 URL | +| pill_color | VARCHAR(100) | 알약 색상 | +| updated_at | TIMESTAMP | 수정일시 | +| parent_item_id | INTEGER | 부모 품목 ID | + +**APC 코드 구조**: `023XXXXXYYZZZ` +- 앞 8자리 (`023XXXXX`) = item_code (품목코드, 대표 APC) +- 나머지 = 포장단위별 구분 + +--- + +### component_code — 성분 정보 (1,105건) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| idx | INTEGER PK | 일련번호 | +| code | VARCHAR(500) | 성분코드 | +| component_name_ko | VARCHAR(500) | 성분명(한글) | +| component_name_en | VARCHAR(500) | 성분명(영문) | +| description | VARCHAR(500) | 설명 | +| efficacy | TEXT | 효능 | +| target_animals | JSONB | 대상 동물 | +| precautions | TEXT | 주의사항 | +| additional_precautions | TEXT | 추가 주의사항 | +| prohibited_breeds | VARCHAR(500) | 금기 품종 | +| offlabel | TEXT | 오프라벨 사용 | + +### component_guide — 성분별 투여 가이드 (1건) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| component_code | VARCHAR(50) PK | 성분코드 | +| component_name_ko/en | VARCHAR(200) | 성분명 | +| dosing_interval_adult | VARCHAR(200) | 성체 투여간격 | +| dosing_interval_high_risk | VARCHAR(200) | 고위험군 투여간격 | +| dosing_interval_puppy | VARCHAR(200) | 유아 투여간격 | +| dosing_interval_source | VARCHAR(500) | 출처 | +| withdrawal_period | VARCHAR(200) | 휴약기간 | +| contraindication | VARCHAR(500) | 금기사항 | +| companion_drugs | VARCHAR(500) | 병용약물 | + +### dosage_info — 용량 정보 (152건) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | INTEGER PK | 일련번호 | +| apdb_idx | INTEGER | apc 테이블 idx 참조 | +| component_code | VARCHAR(100) | 성분코드 | +| dose_per_kg | DOUBLE PRECISION | kg당 용량 | +| dose_per_kg_min/max | DOUBLE PRECISION | kg당 용량 범위 | +| dose_unit | VARCHAR(20) | 용량 단위 | +| unit_dose | DOUBLE PRECISION | 단위 용량 | +| unit_type | VARCHAR(20) | 단위 타입 | +| frequency | VARCHAR(50) | 투여 빈도 | +| route | VARCHAR(30) | 투여 경로 | +| weight_min/max_kg | DOUBLE PRECISION | 적용 체중 범위 | +| animal_type | VARCHAR(10) | 동물 종류 | +| source | VARCHAR(20) | 출처 | +| verified | BOOLEAN | 검증 여부 | +| raw_text | TEXT | 원문 | + +### symptoms — 증상 코드 (51건) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| idx | INTEGER PK | 일련번호 | +| prefix | VARCHAR(1) | 카테고리 접두사 | +| prefix_description | VARCHAR(50) | 카테고리 설명 | +| symptom_code | VARCHAR(10) | 증상 코드 | +| symptom_description | VARCHAR(255) | 증상 설명 | +| disease_description | VARCHAR(255) | 질병 설명 | + +### symptom_component_mapping — 증상-성분 매핑 (111건) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| symptom_code | VARCHAR(10) | 증상 코드 | +| component_code | VARCHAR(500) | 성분 코드 | + +--- + +## 재고/유통 테이블 + +### inventory — 재고 (656건) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | INTEGER PK | 일련번호 | +| apdb_id | INTEGER | apc.idx 참조 | +| supplier_cost | NUMERIC(12,2) | 공급가 | +| wholesaler_price | NUMERIC(12,2) | 도매가 | +| retail_price | NUMERIC(12,2) | 소매가 | +| quantity | INTEGER | 수량 | +| transaction_type | VARCHAR(20) | 거래유형 | +| order_no | VARCHAR(100) | 주문번호 | +| serial_number | VARCHAR(100) | 시리얼번호 | +| expiration_date | DATE | 유효기간 | +| receipt_id | INTEGER | 입고전표 ID | +| entity_id | VARCHAR(50) | 거래처 ID | +| entity_type | VARCHAR(20) | 거래처 유형 | +| location_id | INTEGER | 보관위치 ID | +| goods_no | INTEGER | 고도몰 상품번호 | + +### receipt — 입고전표 (21건) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| idx | INTEGER PK | 일련번호 | +| receipt_number | VARCHAR(100) | 전표번호 | +| receipt_date | TIMESTAMP | 입고일 | +| total_quantity | INTEGER | 총수량 | +| total_amount | NUMERIC(10,2) | 총금액 | +| entity_id | VARCHAR(50) | 거래처 ID | +| entity_type | VARCHAR(20) | 거래처 유형 | + +### vendor — 거래처 (3건) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| idx | INTEGER PK | 일련번호 | +| vendor_code | VARCHAR(50) | 거래처 코드 | +| name | VARCHAR(200) | 거래처명 | +| business_reg_no | VARCHAR(50) | 사업자번호 | + +--- + +## 약국/회원 테이블 + +### animal_pharmacies — 동물약국 목록 (18,955건) + +전국 동물약국 데이터 (공공데이터 기반). + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | INTEGER PK | 일련번호 | +| management_number | VARCHAR(50) | 관리번호 | +| name | VARCHAR(200) | 약국명 | +| phone | VARCHAR(20) | 전화번호 | +| address_old/new | VARCHAR(500) | 주소 | +| latitude/longitude | NUMERIC | 위경도 | +| business_status | VARCHAR(10) | 영업상태 | + +### p_member — 약국 회원 (31건) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| idx | INTEGER PK | 일련번호 | +| memno | INTEGER | 회원번호 | +| pharmacyname | VARCHAR(100) | 약국명 | +| businessregno | VARCHAR(20) | 사업자번호 | +| kioskusage | BOOLEAN | 키오스크 사용 | +| mem_nm | VARCHAR(100) | 회원명 | + +--- + +## 기타 테이블 + +| 테이블 | 행수 | 설명 | +|--------|------|------| +| apc_subnames | 0 | APC 별칭 (미사용) | +| cs_memo | 13 | CS 메모 | +| excluded_pharmacies | 15 | 제외 약국 | +| evidence_reference | 0 | 근거 문헌 참조 | +| recommendation_log | 3 | 추천 로그 | +| supplementary_product | 5 | 보조제품 | +| optimal_stock | 3 | 적정재고 설정 | +| sync_status | 168 | 동기화 상태 | +| system_log | 438 | 시스템 로그 | +| location | 4 | 보관 위치 | +| region / subregion | 3/8 | 지역 구분 | +| member_group_change_logs | 4 | 회원그룹 변경 이력 | + +--- + +## 주요 쿼리 예시 + +```sql +-- APC로 제품 조회 +SELECT * FROM apc WHERE apc = '0230338510101'; + +-- 제품명 검색 (띄어쓰기 무시) +SELECT apc, product_name +FROM apc +WHERE REGEXP_REPLACE(LOWER(product_name), '[\s\-\.]+', '', 'g') + LIKE '%파라캅%'; + +-- 체중별 제품 검색 +SELECT apc, product_name, weight_min_kg, weight_max_kg +FROM apc +WHERE weight_min_kg IS NOT NULL +ORDER BY product_name; + +-- 대표 APC → 포장단위 APC 조회 (앞 8자리 기준) +SELECT apc, product_name, packaging +FROM apc +WHERE LEFT(apc, 8) = '02303385'; + +-- 성분별 제품 검색 +SELECT a.apc, a.product_name, a.component_name_ko +FROM apc a +WHERE a.component_code = 'P001'; + +-- 증상 → 성분 → 제품 검색 +SELECT s.symptom_description, cc.component_name_ko, a.product_name +FROM symptoms s +JOIN symptom_component_mapping scm ON s.symptom_code = scm.symptom_code +JOIN component_code cc ON cc.code = scm.component_code +JOIN apc a ON a.component_code = cc.code; +```