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'
{h}
'
+ html += '
'
+ html += '
'
+ for v in values:
+ html += f'
{v}
'
+ html += '
'
+ html += '
'
+
+ 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'
{h}
'
+ html += '
'
+ for i, row in enumerate(table_rows):
+ bg = '#fff' if i % 2 == 0 else '#f8fafc'
+ html += f'
'
+ for cell in row:
+ html += f'
{cell}
'
+ # 셀 수가 헤더보다 적으면 빈 셀 채우기
+ for _ in range(len(header_cols) - len(row)):
+ html += '
'
+ html += '
'
+ html += '
'
+ 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)
+ # 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'
{h}
'
- html += '
'
- html += '
'
- for v in values:
- html += f'
{v}
'
- html += '
'
- html += '
'
-
- 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 = `