fix: rx-usage 쿼리에 PS_Type!=9 조건 추가 (실제 조제된 약만 집계)
- patient_query: 대체조제 원본 처방 제외 - rx_query: 대체조제 원본 처방 제외 - PS_Type=9는 대체조제시 원래 처방된 약(조제 안됨) - 기타 배치 스크립트 및 문서 추가
This commit is contained in:
parent
f92abf94c8
commit
e470deaefc
365
backend/app.py
365
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 = '<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
267
backend/dongwon_api.py
Normal 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
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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완료!')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
260
docs/API_DEVELOPMENT_GUIDE.md
Normal file
260
docs/API_DEVELOPMENT_GUIDE.md
Normal 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*
|
||||
129
docs/DONGWON_TROUBLESHOOTING.md
Normal file
129
docs/DONGWON_TROUBLESHOOTING.md
Normal 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
278
docs/postgresql-apdb.md
Normal 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;
|
||||
```
|
||||
Loading…
Reference in New Issue
Block a user