fix: rx-usage 쿼리에 PS_Type!=9 조건 추가 (실제 조제된 약만 집계)

- patient_query: 대체조제 원본 처방 제외
- rx_query: 대체조제 원본 처방 제외
- PS_Type=9는 대체조제시 원래 처방된 약(조제 안됨)
- 기타 배치 스크립트 및 문서 추가
This commit is contained in:
thug0bin 2026-03-09 21:54:32 +09:00
parent f92abf94c8
commit e470deaefc
10 changed files with 1909 additions and 121 deletions

View File

@ -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 = '<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">'
html += '<tr style="background:#dbeafe;">'
for h in headers:
html += f'<th style="border:1px solid #93c5fd;padding:8px;text-align:center;">{h}</th>'
html += '</tr>'
html += '<tr style="background:#fff;">'
for v in values:
html += f'<td style="border:1px solid #93c5fd;padding:8px;text-align:center;font-weight:bold;color:#1e40af;">{v}</td>'
html += '</tr>'
html += '</table>'
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 = '<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">'
html += '<tr style="background:#dbeafe;">'
for h in header_cols:
html += f'<th style="border:1px solid #93c5fd;padding:8px;text-align:center;">{h}</th>'
html += '</tr>'
for i, row in enumerate(table_rows):
bg = '#fff' if i % 2 == 0 else '#f8fafc'
html += f'<tr style="background:{bg};">'
for cell in row:
html += f'<td style="border:1px solid #93c5fd;padding:6px 8px;text-align:center;font-size:12px;">{cell}</td>'
# 셀 수가 헤더보다 적으면 빈 셀 채우기
for _ in range(len(header_cols) - len(row)):
html += '<td style="border:1px solid #93c5fd;padding:6px 8px;"></td>'
html += '</tr>'
html += '</table>'
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 ''
# 원본에 <table> 태그가 있으면 → HTML 테이블 보존 + 스타일 적용
if '<table' in raw_html:
# table 앞뒤 텍스트 분리
before_html = re.split(r'<div[^>]*class="_table_wrap', raw_html, maxsplit=1)[0] if '_table_wrap' in raw_html else raw_html.split('<table')[0]
after_match = re.search(r'</table>(.*)', 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'<table[^>]*>(.*?)</table>', raw_html, re.DOTALL)
if table_match:
table_inner = table_match.group(1)
# caption, hidden 요소 제거
table_inner = re.sub(r'<caption>.*?</caption>', '', table_inner, flags=re.DOTALL)
# 기존 style 제거하고 새 스타일 적용
table_inner = re.sub(r'<td[^>]*>', '<td style="border:1px solid #93c5fd;padding:6px 8px;text-align:center;font-size:12px;">', table_inner)
table_inner = re.sub(r'<th[^>]*>', '<th style="border:1px solid #93c5fd;padding:8px;text-align:center;background:#dbeafe;">', table_inner)
# 첫 번째 tr에 헤더 배경색 적용
table_inner = re.sub(r'<tr[^>]*>', '<tr>', table_inner)
table_inner = table_inner.replace('<tr>', '<tr style="background:#dbeafe;">', 1)
# p 태그 제거 (셀 내부)
table_inner = re.sub(r'<p[^>]*>', '', table_inner)
table_inner = re.sub(r'</p>', '<br>', table_inner)
# tbody 유지
styled_table = f'<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">{table_inner}</table>'
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 = '<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">'
html += '<tr style="background:#dbeafe;">'
for h in headers:
html += f'<th style="border:1px solid #93c5fd;padding:8px;text-align:center;">{h}</th>'
html += '</tr>'
html += '<tr style="background:#fff;">'
for v in values:
html += f'<td style="border:1px solid #93c5fd;padding:8px;text-align:center;font-weight:bold;color:#1e40af;">{v}</td>'
html += '</tr>'
html += '</table>'
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)
# 원본에 <table> 없으면 기존 로직
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 ('', '', '======', '<table')),
'precautions': format_items(strip_html(row.precautions)),
# 성분 가이드 (component_guide JOIN)
'component_code': row.component_code,

267
backend/dongwon_api.py Normal file
View File

@ -0,0 +1,267 @@
# -*- coding: utf-8 -*-
"""
동원약품 도매상 API - Flask Blueprint
핵심 로직은 wholesale 패키지에서 가져옴
파일은 Flask API 연동만 담당
"""
import time
import logging
from datetime import datetime
from flask import Blueprint, jsonify, request as flask_request
# wholesale 패키지 경로 설정
import wholesale_path
# wholesale 패키지에서 핵심 클래스 가져오기
from wholesale import DongwonSession
logger = logging.getLogger(__name__)
# Blueprint 생성
dongwon_bp = Blueprint('dongwon', __name__, url_prefix='/api/dongwon')
# ========== 세션 관리 ==========
_dongwon_session = None
def get_dongwon_session():
global _dongwon_session
if _dongwon_session is None:
_dongwon_session = DongwonSession()
return _dongwon_session
def search_dongwon_stock(keyword: str, search_type: str = 'name'):
"""동원약품 재고 검색"""
try:
session = get_dongwon_session()
result = session.search_products(keyword)
if result.get('success'):
return {
'success': True,
'keyword': keyword,
'search_type': search_type,
'count': result.get('total', 0),
'items': result.get('items', [])
}
else:
return result
except Exception as e:
logger.error(f"동원약품 검색 오류: {e}")
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
# ========== Flask API Routes ==========
@dongwon_bp.route('/stock', methods=['GET'])
def api_dongwon_stock():
"""
동원약품 재고 조회 API
GET /api/dongwon/stock?keyword=타이레놀
"""
keyword = flask_request.args.get('keyword', '').strip()
if not keyword:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'keyword 파라미터가 필요합니다.'
}), 400
result = search_dongwon_stock(keyword)
return jsonify(result)
@dongwon_bp.route('/session', methods=['GET'])
def api_dongwon_session():
"""동원약품 세션 상태 확인"""
session = get_dongwon_session()
return jsonify({
'logged_in': getattr(session, '_logged_in', False),
'last_login': getattr(session, '_last_login', 0),
'session_age_sec': int(time.time() - session._last_login) if getattr(session, '_last_login', 0) else None
})
@dongwon_bp.route('/balance', methods=['GET'])
def api_dongwon_balance():
"""
동원약품 잔고 조회 API
GET /api/dongwon/balance
Returns:
{
"success": true,
"balance": 7080018, // 당월 잔고
"prev_balance": 5407528, // 전월 잔고
"trade_amount": 1672490, // 거래 금액
"payment_amount": 0 // 결제 금액
}
"""
try:
session = get_dongwon_session()
result = session.get_balance()
return jsonify(result)
except Exception as e:
logger.error(f"동원약품 잔고 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'BALANCE_ERROR',
'message': str(e),
'balance': 0
}), 500
@dongwon_bp.route('/monthly-orders', methods=['GET'])
def api_dongwon_monthly_orders():
"""
동원약품 월간 주문 조회 API
GET /api/dongwon/monthly-orders?year=2026&month=3
Returns:
{
"success": true,
"year": 2026,
"month": 3,
"total_amount": 1815115, // 주문 총액
"approved_amount": 1672490, // 승인 금액
"order_count": 23 // 주문 건수
}
"""
year = flask_request.args.get('year', type=int)
month = flask_request.args.get('month', type=int)
# 기본값: 현재 월
if not year or not month:
now = datetime.now()
year = year or now.year
month = month or now.month
try:
session = get_dongwon_session()
result = session.get_monthly_orders(year, month)
return jsonify(result)
except Exception as e:
logger.error(f"동원약품 월간 주문 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'MONTHLY_ORDERS_ERROR',
'message': str(e)
}), 500
@dongwon_bp.route('/cart', methods=['GET'])
def api_dongwon_cart():
"""
동원약품 장바구니 조회 API
GET /api/dongwon/cart
"""
try:
session = get_dongwon_session()
result = session.get_cart()
return jsonify(result)
except Exception as e:
logger.error(f"동원약품 장바구니 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'CART_ERROR',
'message': str(e)
}), 500
@dongwon_bp.route('/cart/add', methods=['POST'])
def api_dongwon_cart_add():
"""
동원약품 장바구니 추가 API
POST /api/dongwon/cart/add
{
"item_code": "A4394",
"quantity": 2
}
"""
data = flask_request.get_json() or {}
item_code = data.get('item_code', '').strip()
quantity = data.get('quantity', 1)
if not item_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'item_code가 필요합니다.'
}), 400
try:
session = get_dongwon_session()
result = session.add_to_cart(item_code, quantity)
return jsonify(result)
except Exception as e:
logger.error(f"동원약품 장바구니 추가 오류: {e}")
return jsonify({
'success': False,
'error': 'CART_ADD_ERROR',
'message': str(e)
}), 500
@dongwon_bp.route('/orders/summary-by-kd', methods=['GET'])
def api_dongwon_orders_by_kd():
"""
동원약품 주문량 KD코드별 집계 API
GET /api/dongwon/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
흐름:
1. 주문 목록 API 주문번호 목록
2. 주문번호 HTML 파싱 ItemCode 목록
3. ItemCode itemInfoAx KD코드, 규격, 수량
4. KD코드별 집계
Returns:
{
"success": true,
"order_count": 4,
"by_kd_code": {
"642900680": {
"product_name": "사미온정10mg",
"spec": "30정(병)",
"boxes": 3,
"units": 90
}
},
"total_products": 15
}
"""
from datetime import datetime
today = datetime.now()
start_date = flask_request.args.get('start_date', today.strftime("%Y-%m-%d")).strip()
end_date = flask_request.args.get('end_date', today.strftime("%Y-%m-%d")).strip()
try:
session = get_dongwon_session()
# 새로운 get_orders_by_kd_code 메서드 사용
result = session.get_orders_by_kd_code(start_date, end_date)
return jsonify(result)
except Exception as e:
logger.error(f"동원약품 주문량 집계 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e),
'by_kd_code': {},
'order_count': 0
}), 500

View File

@ -143,6 +143,8 @@ def api_submit_order():
# 도매상별 주문 처리
if wholesaler_id == 'geoyoung':
result = submit_geoyoung_order(order, dry_run)
elif wholesaler_id == 'dongwon':
result = submit_dongwon_order(order, dry_run)
else:
result = {
'success': False,
@ -517,6 +519,8 @@ def api_quick_submit():
submit_result = submit_sooin_order(order, dry_run, cart_only=cart_only)
elif order['wholesaler_id'] == 'baekje':
submit_result = submit_baekje_order(order, dry_run, cart_only=cart_only)
elif order['wholesaler_id'] == 'dongwon':
submit_result = submit_dongwon_order(order, dry_run, cart_only=cart_only)
else:
submit_result = {'success': False, 'error': f"Wholesaler {order['wholesaler_id']} not supported"}
@ -1387,3 +1391,226 @@ def api_drugs_preferred_vendors():
'count': len(results),
'results': results
})
def submit_dongwon_order(order: dict, dry_run: bool, cart_only: bool = True) -> 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)
}

View File

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

View File

@ -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')

View File

@ -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

View File

@ -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 = `
<div class="geo-loading">
<div class="loading-spinner"></div>
<div>도매상 재고 조회 중... (지오영 + 수인 + 백제)</div>
<div>도매상 재고 조회 중... (지오영 + 수인 + 백제 + 동원)</div>
</div>`;
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 += '</div>';
// ═══════ 동원약품 섹션 ═══════
html += `<div class="ws-section">
<div class="ws-header dongwon">
<span class="ws-logo">🏥</span>
<span class="ws-name">동원약품</span>
<span class="ws-count">${dongwonItems.length}건</span>
</div>`;
if (dongwonItems.length > 0) {
html += `<table class="geo-table">
<thead><tr><th>제품명</th><th>규격</th><th>단가</th><th>재고</th><th></th></tr></thead>
<tbody>`;
dongwonItems.forEach((item, idx) => {
const hasStock = item.stock > 0;
// 동원: code=KD코드(보험코드), internal_code=내부코드(주문용)
const displayCode = item.code || item.internal_code || '';
html += `
<tr class="${hasStock ? '' : 'no-stock'}">
<td>
<div class="geo-product">
<span class="geo-name">${escapeHtml(item.name)}</span>
<span class="geo-code">${displayCode} · ${item.manufacturer || ''}</span>
</div>
</td>
<td class="geo-spec">${item.spec || '-'}</td>
<td class="geo-price">${item.price ? item.price.toLocaleString() + '원' : '-'}</td>
<td class="geo-stock ${hasStock ? 'in-stock' : 'out-stock'}">${item.stock}</td>
<td>${hasStock ? `<button class="geo-add-btn dongwon" onclick="addToCartFromWholesale('dongwon', ${idx})">담기</button>` : ''}</td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += `<div class="ws-empty">📭 검색 결과 없음</div>`;
}
html += '</div>';
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');

View File

@ -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*

View File

@ -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
// 잘못된 코드
<span class="geo-code">${item.internal_code || ''} · ${item.manufacturer || ''}</span>
```
**해결**: 동원 API는 `code`에 KD코드(보험코드)를, `internal_code`에 내부코드를 반환함. 표시용은 `code` 사용.
```javascript
// 수정된 코드
const displayCode = item.code || item.internal_code || '';
<span class="geo-code">${displayCode} · ${item.manufacturer || ''}</span>
```
### 문제 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. "주문서 생성하기" 클릭하여:
- 동원약품이 도매상 목록에 나타나는지 확인

278
docs/postgresql-apdb.md Normal file
View File

@ -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;
```