fix: rx-usage 쿼리에 PS_Type!=9 조건 추가 (실제 조제된 약만 집계)
- patient_query: 대체조제 원본 처방 제외 - rx_query: 대체조제 원본 처방 제외 - PS_Type=9는 대체조제시 원래 처방된 약(조제 안됨) - 기타 배치 스크립트 및 문서 추가
This commit is contained in:
parent
f92abf94c8
commit
e470deaefc
343
backend/app.py
343
backend/app.py
@ -80,6 +80,9 @@ app.register_blueprint(sooin_bp)
|
|||||||
from baekje_api import baekje_bp
|
from baekje_api import baekje_bp
|
||||||
app.register_blueprint(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
|
from wholesaler_config_api import wholesaler_config_bp
|
||||||
app.register_blueprint(wholesaler_config_bp)
|
app.register_blueprint(wholesaler_config_bp)
|
||||||
|
|
||||||
@ -2946,6 +2949,11 @@ ANIMAL_DRUG_KNOWLEDGE = """
|
|||||||
- 콜리/셸티/보더콜리 등 MDR1 유전자 변이견은 이버멕틴 주의
|
- 콜리/셸티/보더콜리 등 MDR1 유전자 변이견은 이버멕틴 주의
|
||||||
- 임신/수유 중인 동물은 수의사 상담 필요
|
- 임신/수유 중인 동물은 수의사 상담 필요
|
||||||
- 체중 정확히 측정 후 제품 선택
|
- 체중 정확히 측정 후 제품 선택
|
||||||
|
|
||||||
|
## 🚨 항생제 필수 경고 (퀴놀론계)
|
||||||
|
- **엔로플록사신(아시엔로, Baytril)**: 🐱 고양이 망막 독성! 5mg/kg/day 초과 시 실명 위험. 대안: 마르보플록사신
|
||||||
|
- **이버멕틴 고용량**: MDR1 유전자 변이견(콜리, 셸티, 오스트레일리안 셰퍼드) 신경독성 주의
|
||||||
|
- **어린 동물 퀴놀론계**: 연골 발달에 영향, 성장기 동물 주의
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 동물약 챗봇 System Prompt
|
# 동물약 챗봇 System Prompt
|
||||||
@ -2977,6 +2985,13 @@ ANIMAL_CHAT_SYSTEM_PROMPT = """당신은 약국의 동물약 전문 상담사입
|
|||||||
- 일반적 비교 설명 + 우리 약국 보유 여부 안내
|
- 일반적 비교 설명 + 우리 약국 보유 여부 안내
|
||||||
- 길게 상세히 (10-15문장)
|
- 길게 상세히 (10-15문장)
|
||||||
|
|
||||||
|
**⚠️ 투여방법 구분 (필수!):**
|
||||||
|
- "먹는 약", "경구", "복용" 질문 → 내복약만 추천 (정제, 츄어블, 캡슐, 시럽)
|
||||||
|
- "바르는 약", "도포", "외용" 질문 → 외용약만 추천 (겔, 스팟온, 크림, 연고)
|
||||||
|
- RAG 정보의 "제형", "분류", "체중/부위" 필드 확인 필수
|
||||||
|
- 외용약(겔, 도포, 환부에 직접)은 절대 "먹는 약"으로 추천하지 않음!
|
||||||
|
- 보유 제품 목록의 [내복/외용] 표시 확인!
|
||||||
|
|
||||||
**기본 규칙:**
|
**기본 규칙:**
|
||||||
1. 체중별 제품은 정확한 전체 이름 사용 (안텔민킹, 안텔민뽀삐 등)
|
1. 체중별 제품은 정확한 전체 이름 사용 (안텔민킹, 안텔민뽀삐 등)
|
||||||
2. 용량/투약 질문: 체중별 표 형식으로 정리
|
2. 용량/투약 질문: 체중별 표 형식으로 정리
|
||||||
@ -3008,6 +3023,8 @@ def _get_animal_drug_rag(apc_codes):
|
|||||||
image_url1,
|
image_url1,
|
||||||
llm_pharm->>'사용가능 동물' as target_animals,
|
llm_pharm->>'사용가능 동물' as target_animals,
|
||||||
llm_pharm->>'분류' as category,
|
llm_pharm->>'분류' as category,
|
||||||
|
llm_pharm->>'쉬운분류' as easy_category,
|
||||||
|
llm_pharm->>'약품 제형' as dosage_form,
|
||||||
llm_pharm->>'체중/부위' as dosage_weight,
|
llm_pharm->>'체중/부위' as dosage_weight,
|
||||||
llm_pharm->>'기간/용법' as usage_period,
|
llm_pharm->>'기간/용법' as usage_period,
|
||||||
llm_pharm->>'월령금기' as age_restriction,
|
llm_pharm->>'월령금기' as age_restriction,
|
||||||
@ -3024,6 +3041,8 @@ def _get_animal_drug_rag(apc_codes):
|
|||||||
rag_data[row.apc] = {
|
rag_data[row.apc] = {
|
||||||
'target_animals': row.target_animals or '정보 없음',
|
'target_animals': row.target_animals or '정보 없음',
|
||||||
'category': row.category or '',
|
'category': row.category or '',
|
||||||
|
'easy_category': row.easy_category or '',
|
||||||
|
'dosage_form': row.dosage_form or '',
|
||||||
'dosage_weight': row.dosage_weight or '',
|
'dosage_weight': row.dosage_weight or '',
|
||||||
'usage_period': row.usage_period or '',
|
'usage_period': row.usage_period or '',
|
||||||
'age_restriction': row.age_restriction 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:
|
if d.get('apc') and d['apc'] in rag_data:
|
||||||
info = rag_data[d['apc']]
|
info = rag_data[d['apc']]
|
||||||
details = []
|
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'):
|
if info.get('target_animals'):
|
||||||
details.append(f"대상: {info['target_animals']}")
|
details.append(f"대상: {info['target_animals']}")
|
||||||
if info.get('main_ingredient'):
|
if info.get('main_ingredient'):
|
||||||
@ -3231,6 +3272,37 @@ def api_animal_chat():
|
|||||||
detail_keywords = ['자세히', '상세히', '더 알려', '설명해', '왜', '어떻게', '원리', '기전', '성분']
|
detail_keywords = ['자세히', '상세히', '더 알려', '설명해', '왜', '어떻게', '원리', '기전', '성분']
|
||||||
is_detail_request = any(kw in last_user_msg for kw in 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)
|
# 벡터 DB 검색 (LanceDB RAG)
|
||||||
vector_context = ""
|
vector_context = ""
|
||||||
vector_start = time.time()
|
vector_start = time.time()
|
||||||
@ -3254,9 +3326,13 @@ def api_animal_chat():
|
|||||||
log_entry.vector_duration_ms = int((time.time() - vector_start) * 1000)
|
log_entry.vector_duration_ms = int((time.time() - vector_start) * 1000)
|
||||||
|
|
||||||
# System Prompt 구성
|
# 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(
|
system_prompt = ANIMAL_CHAT_SYSTEM_PROMPT.format(
|
||||||
available_products=available_products_text,
|
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 호출
|
# OpenAI API 호출
|
||||||
@ -4251,6 +4327,7 @@ def api_rx_usage():
|
|||||||
mssql_session = db_manager.get_session('PM_PRES')
|
mssql_session = db_manager.get_session('PM_PRES')
|
||||||
|
|
||||||
# 1년간 사용 환자 3명 이하 약품의 환자 목록 조회 + 조회 기간 내 사용 여부
|
# 1년간 사용 환자 3명 이하 약품의 환자 목록 조회 + 조회 기간 내 사용 여부
|
||||||
|
# PS_Type: 0,1=일반, 4=대체조제(실제), 9=대체조제(원본) - 9는 제외해야 실제 조제된 약만 집계
|
||||||
patient_query = text("""
|
patient_query = text("""
|
||||||
WITH PatientUsage AS (
|
WITH PatientUsage AS (
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
@ -4260,6 +4337,7 @@ def api_rx_usage():
|
|||||||
FROM PS_sub_pharm P
|
FROM PS_sub_pharm P
|
||||||
JOIN PS_main M ON P.PreSerial = M.PreSerial
|
JOIN PS_main M ON P.PreSerial = M.PreSerial
|
||||||
WHERE M.Indate >= CONVERT(VARCHAR, DATEADD(YEAR, -1, GETDATE()), 112)
|
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
|
GROUP BY P.DrugCode, M.Paname
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@ -4318,6 +4396,7 @@ def api_rx_usage():
|
|||||||
orders_conn.close()
|
orders_conn.close()
|
||||||
|
|
||||||
# 전문의약품 품목별 사용량 집계 쿼리 (현재고: IM_total.IM_QT_sale_debit, 위치: CD_item_position.CD_NM_sale)
|
# 전문의약품 품목별 사용량 집계 쿼리 (현재고: IM_total.IM_QT_sale_debit, 위치: CD_item_position.CD_NM_sale)
|
||||||
|
# PS_Type != '9' 조건: 대체조제 원본 처방 제외 → 실제 조제된 약만 집계
|
||||||
rx_query = text("""
|
rx_query = text("""
|
||||||
SELECT
|
SELECT
|
||||||
P.DrugCode as drug_code,
|
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
|
LEFT JOIN PM_DRUG.dbo.CD_item_position POS ON P.DrugCode = POS.DrugCode
|
||||||
WHERE P.Indate >= :start_date
|
WHERE P.Indate >= :start_date
|
||||||
AND P.Indate <= :end_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
|
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
|
ORDER BY SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) DESC
|
||||||
""")
|
""")
|
||||||
@ -7302,6 +7382,26 @@ def api_animal_drug_info_print():
|
|||||||
"""), {'apc': apc})
|
"""), {'apc': apc})
|
||||||
row = result.fetchone()
|
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:
|
if not row:
|
||||||
return jsonify({'success': False, 'error': f'APC {apc} 정보를 찾을 수 없습니다'}), 404
|
return jsonify({'success': False, 'error': f'APC {apc} 정보를 찾을 수 없습니다'}), 404
|
||||||
|
|
||||||
@ -7320,8 +7420,8 @@ def api_animal_drug_info_print():
|
|||||||
# HTML 엔티티 변환
|
# HTML 엔티티 변환
|
||||||
text = unescape(text)
|
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')
|
lines = text.split('\n')
|
||||||
cleaned = []
|
cleaned = []
|
||||||
@ -7409,13 +7509,26 @@ def api_animal_drug_info_print():
|
|||||||
{THIN}
|
{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'):
|
for line in dosage.split('\n'):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line:
|
if line:
|
||||||
message += f"{line}\n"
|
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:
|
else:
|
||||||
formatted_dosage = format_for_print(dosage)
|
formatted_dosage = format_for_print(dosage)
|
||||||
for para in formatted_dosage.split('\n'):
|
for para in formatted_dosage.split('\n'):
|
||||||
@ -7519,6 +7632,26 @@ def api_animal_drug_info_preview():
|
|||||||
"""), {'apc': apc})
|
"""), {'apc': apc})
|
||||||
row = result.fetchone()
|
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:
|
if not row:
|
||||||
return jsonify({'success': False, 'error': f'APC {apc} 정보 없음'}), 404
|
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 = re.sub(r'<[^>]+>', '', text)
|
||||||
text = unescape(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()]
|
lines = [l.strip() for l in text.split('\n') if l.strip()]
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
else:
|
else:
|
||||||
@ -7557,47 +7690,173 @@ def api_animal_drug_info_preview():
|
|||||||
if not text:
|
if not text:
|
||||||
return ''
|
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)
|
return format_items(text)
|
||||||
|
|
||||||
# 표 형식: 체중별 투여량 테이블 생성
|
# ── (A) 안텔민 형식: ─ 구분 + "체중(kg)" 헤더 + "투여정수" 데이터 (2행 표) ──
|
||||||
lines = [l.strip() for l in text.split('\n') if l.strip() and '─' not in l and '━' not in l]
|
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:
|
||||||
header_line = None
|
if '체중' in line and 'kg' in line.lower():
|
||||||
data_line = None
|
header_line = line
|
||||||
other_lines = []
|
elif '투여' in line and ('정수' in line or '정' in line):
|
||||||
|
data_line = line
|
||||||
|
else:
|
||||||
|
other_lines.append(line)
|
||||||
|
|
||||||
for line in lines:
|
result = '\n'.join(other_lines)
|
||||||
if '체중' in line and 'kg' in line.lower():
|
|
||||||
header_line = line
|
if header_line and data_line:
|
||||||
elif '투여' in line and '정수' in line:
|
headers = re.split(r'\s{2,}', header_line)
|
||||||
data_line = 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:
|
else:
|
||||||
other_lines.append(line)
|
styled_table = ''
|
||||||
|
|
||||||
result = '\n'.join(other_lines)
|
# 뒷부분 텍스트 처리
|
||||||
|
after_text = strip_html(after_html)
|
||||||
|
after_text = format_items(after_text) if after_text else ''
|
||||||
|
|
||||||
# HTML 테이블 생성
|
parts = [p for p in [before_text, styled_table, after_text] if p]
|
||||||
if header_line and data_line:
|
return '\n'.join(parts)
|
||||||
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;">'
|
# 원본에 <table> 없으면 기존 로직
|
||||||
html += '<tr style="background:#dbeafe;">'
|
return format_table_html(strip_html(raw_html))
|
||||||
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
|
|
||||||
|
|
||||||
# 투약주기 조합
|
# 투약주기 조합
|
||||||
dosing_interval = None
|
dosing_interval = None
|
||||||
@ -7617,8 +7876,8 @@ def api_animal_drug_info_preview():
|
|||||||
'company_name': row.company_name,
|
'company_name': row.company_name,
|
||||||
'main_ingredient': row.main_ingredient if row.main_ingredient != 'NaN' else None,
|
'main_ingredient': row.main_ingredient if row.main_ingredient != 'NaN' else None,
|
||||||
'efficacy_effect': format_items(strip_html(row.efficacy_effect)),
|
'efficacy_effect': format_items(strip_html(row.efficacy_effect)),
|
||||||
'dosage_instructions': format_table_html(strip_html(row.dosage_instructions)),
|
'dosage_instructions': format_dosage(row.dosage_instructions),
|
||||||
'dosage_has_table': '─' in (row.dosage_instructions or ''),
|
'dosage_has_table': any(c in (row.dosage_instructions or '') for c in ('─', '━', '======', '<table')),
|
||||||
'precautions': format_items(strip_html(row.precautions)),
|
'precautions': format_items(strip_html(row.precautions)),
|
||||||
# 성분 가이드 (component_guide JOIN)
|
# 성분 가이드 (component_guide JOIN)
|
||||||
'component_code': row.component_code,
|
'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':
|
if wholesaler_id == 'geoyoung':
|
||||||
result = submit_geoyoung_order(order, dry_run)
|
result = submit_geoyoung_order(order, dry_run)
|
||||||
|
elif wholesaler_id == 'dongwon':
|
||||||
|
result = submit_dongwon_order(order, dry_run)
|
||||||
else:
|
else:
|
||||||
result = {
|
result = {
|
||||||
'success': False,
|
'success': False,
|
||||||
@ -517,6 +519,8 @@ def api_quick_submit():
|
|||||||
submit_result = submit_sooin_order(order, dry_run, cart_only=cart_only)
|
submit_result = submit_sooin_order(order, dry_run, cart_only=cart_only)
|
||||||
elif order['wholesaler_id'] == 'baekje':
|
elif order['wholesaler_id'] == 'baekje':
|
||||||
submit_result = submit_baekje_order(order, dry_run, cart_only=cart_only)
|
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:
|
else:
|
||||||
submit_result = {'success': False, 'error': f"Wholesaler {order['wholesaler_id']} not supported"}
|
submit_result = {'success': False, 'error': f"Wholesaler {order['wholesaler_id']} not supported"}
|
||||||
|
|
||||||
@ -1387,3 +1391,226 @@ def api_drugs_preferred_vendors():
|
|||||||
'count': len(results),
|
'count': len(results),
|
||||||
'results': 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,15 +1,92 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||||
|
|
||||||
from db.dbsetup import get_db_session
|
from db.dbsetup import get_db_session
|
||||||
from sqlalchemy import text, create_engine
|
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')
|
session = get_db_session('PM_DRUG')
|
||||||
result = session.execute(text("""
|
result = session.execute(text("""
|
||||||
SELECT
|
SELECT
|
||||||
@ -39,44 +116,190 @@ for row in result:
|
|||||||
|
|
||||||
session.close()
|
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()
|
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
|
||||||
|
|
||||||
matches = []
|
matched = [] # 확정 매칭
|
||||||
|
ambiguous = [] # 후보 여러 개 (수동 확인 필요)
|
||||||
|
no_match = [] # 매칭 없음
|
||||||
|
|
||||||
for drug in no_apc:
|
for drug in no_apc:
|
||||||
name = drug['name']
|
name = drug['name']
|
||||||
# 제품명에서 검색 키워드 추출
|
base_names = extract_base_name(name)
|
||||||
# (판) 제거, 괄호 내용 제거
|
w_min, w_max = extract_weight_range(name)
|
||||||
search_name = name.replace('(판)', '').split('(')[0].strip()
|
|
||||||
|
|
||||||
# PostgreSQL 검색
|
# 여러 기본명 후보로 검색 (좁은 것부터 시도)
|
||||||
result = pg.execute(text("""
|
candidates = []
|
||||||
SELECT apc, product_name,
|
used_base = None
|
||||||
llm_pharm->>'사용가능 동물' as target,
|
for bn in base_names:
|
||||||
llm_pharm->>'분류' as category
|
norm_base = normalize(bn)
|
||||||
FROM apc
|
result = pg.execute(text("""
|
||||||
WHERE product_name ILIKE :pattern
|
SELECT apc, product_name,
|
||||||
ORDER BY LENGTH(product_name)
|
weight_min_kg, weight_max_kg,
|
||||||
LIMIT 5
|
dosage,
|
||||||
"""), {'pattern': f'%{search_name}%'})
|
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]
|
||||||
|
|
||||||
candidates = list(result)
|
if not candidates:
|
||||||
if candidates:
|
no_match.append(drug)
|
||||||
matches.append({
|
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,
|
'mssql': drug,
|
||||||
'candidates': candidates
|
'apc': filtered[0],
|
||||||
|
'method': method
|
||||||
})
|
})
|
||||||
print(f'✅ {name}')
|
print(f'✅ {name}')
|
||||||
for c in candidates[:2]:
|
print(f' → {filtered[0].apc}: {filtered[0].product_name}')
|
||||||
print(f' → {c.apc}: {c.product_name[:40]}... [{c.target or "?"}]')
|
if w_min is not None and filtered[0].weight_min_kg is not None:
|
||||||
else:
|
print(f' 체중: MSSQL({w_min}~{w_max}kg) = PG({filtered[0].weight_min_kg}~{filtered[0].weight_max_kg}kg)')
|
||||||
print(f'❌ {name} - 매칭 없음')
|
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()
|
pg.close()
|
||||||
|
|
||||||
print(f'\n=== 요약 ===')
|
# ── 3. 요약 ──
|
||||||
|
|
||||||
|
print(f'\n{"="*50}')
|
||||||
|
print(f'=== 매칭 요약 ===')
|
||||||
print(f'APC 없는 제품: {len(no_apc)}개')
|
print(f'APC 없는 제품: {len(no_apc)}개')
|
||||||
print(f'매칭 후보 있음: {len(matches)}개')
|
print(f'✅ 확정 매칭: {len(matched)}개')
|
||||||
print(f'매칭 없음: {len(no_apc) - len(matches)}개')
|
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정
|
('세레니아정16mg(개멀미약)', 'LB000003353', '0231884610109'), # 세레니아 정 16mg / 4정
|
||||||
('세레니아정24mg(개멀미약)', 'LB000003354', '0231884620107'), # 세레니아 정 24mg / 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')
|
session = get_db_session('PM_DRUG')
|
||||||
|
|||||||
@ -26,6 +26,11 @@ ANIMAL_KEYWORDS = [
|
|||||||
'펫팜', '동물약품', '애니팜'
|
'펫팜', '동물약품', '애니팜'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 동물약 공급처 (SplName이 이 값이면 전부 동물약)
|
||||||
|
ANIMAL_SUPPLIERS = [
|
||||||
|
'펫팜'
|
||||||
|
]
|
||||||
|
|
||||||
# 제외 키워드 (사람용 약)
|
# 제외 키워드 (사람용 약)
|
||||||
EXCLUDE_KEYWORDS = [
|
EXCLUDE_KEYWORDS = [
|
||||||
'헤리펫사', '토피라펫', '메타트레이스', '페리돈', '세파라캅'
|
'헤리펫사', '토피라펫', '메타트레이스', '페리돈', '세파라캅'
|
||||||
@ -58,24 +63,38 @@ def init_sqlite_db():
|
|||||||
print(f"✅ SQLite DB 준비: {DB_PATH}")
|
print(f"✅ SQLite DB 준비: {DB_PATH}")
|
||||||
|
|
||||||
def search_animal_drugs():
|
def search_animal_drugs():
|
||||||
"""MSSQL에서 동물약 키워드 검색"""
|
"""MSSQL에서 동물약 검색 (키워드 + 공급처)"""
|
||||||
print("🔍 CD_GOODS에서 동물약 검색 중...")
|
print("🔍 CD_GOODS에서 동물약 검색 중...")
|
||||||
|
|
||||||
session = db_manager.get_session('PM_DRUG')
|
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"""
|
query = text(f"""
|
||||||
SELECT DrugCode, GoodsName, BARCODE, POS_BOON
|
SELECT DrugCode, GoodsName, BARCODE, POS_BOON, SplName
|
||||||
FROM CD_GOODS
|
FROM CD_GOODS
|
||||||
WHERE ({conditions})
|
WHERE (({keyword_conds}) OR ({supplier_conds}))
|
||||||
AND GoodsSelCode = 'B'
|
AND GoodsSelCode = 'B'
|
||||||
""")
|
""")
|
||||||
|
|
||||||
result = session.execute(query)
|
result = session.execute(query)
|
||||||
drugs = result.fetchall()
|
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
|
return drugs
|
||||||
|
|
||||||
def tag_to_sqlite(drugs):
|
def tag_to_sqlite(drugs):
|
||||||
@ -93,6 +112,7 @@ def tag_to_sqlite(drugs):
|
|||||||
drug_code = drug[0]
|
drug_code = drug[0]
|
||||||
drug_name = drug[1] or ''
|
drug_name = drug[1] or ''
|
||||||
barcode = drug[2]
|
barcode = drug[2]
|
||||||
|
spl_name = drug[4] if len(drug) > 4 else ''
|
||||||
|
|
||||||
# 제외 키워드 체크
|
# 제외 키워드 체크
|
||||||
if any(ex in drug_name for ex in EXCLUDE_KEYWORDS):
|
if any(ex in drug_name for ex in EXCLUDE_KEYWORDS):
|
||||||
@ -100,13 +120,19 @@ def tag_to_sqlite(drugs):
|
|||||||
print(f" ⛔ 제외: {drug_code} - {drug_name}")
|
print(f" ⛔ 제외: {drug_code} - {drug_name}")
|
||||||
continue
|
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:
|
try:
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT INTO drug_tags (drug_code, drug_name, barcode, tag_type, tag_value, note)
|
INSERT INTO drug_tags (drug_code, drug_name, barcode, tag_type, tag_value, note, source)
|
||||||
VALUES (?, ?, ?, 'animal_drug', 'all', '키워드 자동 태깅')
|
VALUES (?, ?, ?, 'animal_drug', 'all', ?, ?)
|
||||||
''', (drug_code, drug_name, barcode))
|
''', (drug_code, drug_name, barcode, note, source))
|
||||||
added += 1
|
added += 1
|
||||||
print(f" ✅ {drug_code}: {drug_name}")
|
print(f" ✅ {drug_code}: {drug_name} [{source}]")
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
|
||||||
|
|||||||
@ -939,7 +939,7 @@
|
|||||||
loadOrderData(); // 수인약품 주문량 로드
|
loadOrderData(); // 수인약품 주문량 로드
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 합산) ────────────────
|
// ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 + 동원 합산) ────────────────
|
||||||
async function loadOrderData() {
|
async function loadOrderData() {
|
||||||
const startDate = document.getElementById('startDate').value;
|
const startDate = document.getElementById('startDate').value;
|
||||||
const endDate = document.getElementById('endDate').value;
|
const endDate = document.getElementById('endDate').value;
|
||||||
@ -948,11 +948,12 @@
|
|||||||
orderDataByKd = {};
|
orderDataByKd = {};
|
||||||
|
|
||||||
try {
|
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/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/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;
|
let totalOrders = 0;
|
||||||
@ -999,7 +1000,21 @@
|
|||||||
console.log('💉 백제 주문량:', Object.keys(baekjeRes.by_kd_code).length, '품목,', baekjeRes.order_count, '건');
|
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) {
|
} catch(err) {
|
||||||
console.warn('주문량 조회 실패:', err);
|
console.warn('주문량 조회 실패:', err);
|
||||||
@ -1269,6 +1284,16 @@
|
|||||||
gradient: 'linear-gradient(135deg, #d97706, #f59e0b)',
|
gradient: 'linear-gradient(135deg, #d97706, #f59e0b)',
|
||||||
filterFn: (item) => item.supplier === '백제약품' || item.wholesaler === 'baekje',
|
filterFn: (item) => item.supplier === '백제약품' || item.wholesaler === 'baekje',
|
||||||
getCode: (item) => item.baekje_code || item.drug_code
|
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();
|
if (e.key === 'Enter') loadUsageData();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────── 도매상 재고 조회 (지오영 + 수인 + 백제) ────────────────
|
// ──────────────── 도매상 재고 조회 (지오영 + 수인 + 백제 + 동원) ────────────────
|
||||||
let currentWholesaleItem = null;
|
let currentWholesaleItem = null;
|
||||||
window.wholesaleItems = { geoyoung: [], sooin: [], baekje: [] };
|
window.wholesaleItems = { geoyoung: [], sooin: [], baekje: [], dongwon: [] };
|
||||||
|
|
||||||
function openWholesaleModal(idx) {
|
function openWholesaleModal(idx) {
|
||||||
const item = usageData[idx];
|
const item = usageData[idx];
|
||||||
@ -2065,7 +2090,7 @@
|
|||||||
document.getElementById('geoResultBody').innerHTML = `
|
document.getElementById('geoResultBody').innerHTML = `
|
||||||
<div class="geo-loading">
|
<div class="geo-loading">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
<div>도매상 재고 조회 중... (지오영 + 수인 + 백제)</div>
|
<div>도매상 재고 조회 중... (지오영 + 수인 + 백제 + 동원)</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.getElementById('geoSearchKeyword').style.display = 'none';
|
document.getElementById('geoSearchKeyword').style.display = 'none';
|
||||||
|
|
||||||
@ -2081,22 +2106,24 @@
|
|||||||
async function searchAllWholesalers(kdCode, productName) {
|
async function searchAllWholesalers(kdCode, productName) {
|
||||||
const resultBody = document.getElementById('geoResultBody');
|
const resultBody = document.getElementById('geoResultBody');
|
||||||
|
|
||||||
// 세 도매상 동시 호출
|
// 네 도매상 동시 호출
|
||||||
const [geoResult, sooinResult, baekjeResult] = await Promise.all([
|
const [geoResult, sooinResult, baekjeResult, dongwonResult] = await Promise.all([
|
||||||
searchGeoyoungAPI(kdCode, productName),
|
searchGeoyoungAPI(kdCode, productName),
|
||||||
searchSooinAPI(kdCode),
|
searchSooinAPI(kdCode),
|
||||||
searchBaekjeAPI(kdCode)
|
searchBaekjeAPI(kdCode),
|
||||||
|
searchDongwonAPI(kdCode, productName)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 결과 저장
|
// 결과 저장
|
||||||
window.wholesaleItems = {
|
window.wholesaleItems = {
|
||||||
geoyoung: geoResult.items || [],
|
geoyoung: geoResult.items || [],
|
||||||
sooin: sooinResult.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) {
|
async function searchGeoyoungAPI(kdCode, productName) {
|
||||||
@ -2137,17 +2164,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWholesaleResults(geoResult, sooinResult, baekjeResult) {
|
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, dongwonResult) {
|
||||||
const resultBody = document.getElementById('geoResultBody');
|
const resultBody = document.getElementById('geoResultBody');
|
||||||
|
|
||||||
const geoItems = geoResult.items || [];
|
const geoItems = geoResult.items || [];
|
||||||
const sooinItems = sooinResult.items || [];
|
const sooinItems = sooinResult.items || [];
|
||||||
const baekjeItems = (baekjeResult && baekjeResult.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);
|
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);
|
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);
|
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 = '';
|
let html = '';
|
||||||
|
|
||||||
@ -2260,6 +2311,44 @@
|
|||||||
}
|
}
|
||||||
html += '</div>';
|
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;
|
resultBody.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2277,7 +2366,7 @@
|
|||||||
const needed = currentWholesaleItem.total_dose;
|
const needed = currentWholesaleItem.total_dose;
|
||||||
const suggestedQty = Math.ceil(needed / specQty);
|
const suggestedQty = Math.ceil(needed / specQty);
|
||||||
|
|
||||||
const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품' };
|
const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품', dongwon: '동원약품' };
|
||||||
const supplierName = supplierNames[wholesaler] || wholesaler;
|
const supplierName = supplierNames[wholesaler] || wholesaler;
|
||||||
const productName = wholesaler === 'geoyoung' ? item.product_name : item.name;
|
const productName = wholesaler === 'geoyoung' ? item.product_name : item.name;
|
||||||
|
|
||||||
@ -2298,6 +2387,7 @@
|
|||||||
geoyoung_code: wholesaler === 'geoyoung' ? item.insurance_code : null,
|
geoyoung_code: wholesaler === 'geoyoung' ? item.insurance_code : null,
|
||||||
sooin_code: wholesaler === 'sooin' ? item.code : null,
|
sooin_code: wholesaler === 'sooin' ? item.code : null,
|
||||||
baekje_code: wholesaler === 'baekje' ? item.internal_code : null,
|
baekje_code: wholesaler === 'baekje' ? item.internal_code : null,
|
||||||
|
dongwon_code: wholesaler === 'dongwon' ? item.internal_code : null, // 동원: 내부코드로 주문
|
||||||
unit_price: unitPrice // 💰 단가 추가
|
unit_price: unitPrice // 💰 단가 추가
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -2542,6 +2632,12 @@
|
|||||||
.geo-add-btn.baekje:hover {
|
.geo-add-btn.baekje:hover {
|
||||||
background: #d97706;
|
background: #d97706;
|
||||||
}
|
}
|
||||||
|
.geo-add-btn.dongwon {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
.geo-add-btn.dongwon:hover {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
.geo-price {
|
.geo-price {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@ -2576,6 +2672,10 @@
|
|||||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(217, 119, 6, 0.1));
|
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(217, 119, 6, 0.1));
|
||||||
border-left: 3px solid var(--accent-amber);
|
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 {
|
.ws-logo {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@ -2778,6 +2878,9 @@
|
|||||||
.multi-ws-card.baekje {
|
.multi-ws-card.baekje {
|
||||||
border-left: 3px solid var(--accent-amber);
|
border-left: 3px solid var(--accent-amber);
|
||||||
}
|
}
|
||||||
|
.multi-ws-card.dongwon {
|
||||||
|
border-left: 3px solid #22c55e;
|
||||||
|
}
|
||||||
.multi-ws-card.other {
|
.multi-ws-card.other {
|
||||||
border-left: 3px solid var(--text-muted);
|
border-left: 3px solid var(--text-muted);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@ -3077,9 +3180,16 @@
|
|||||||
color: '#a855f7',
|
color: '#a855f7',
|
||||||
balanceApi: '/api/sooin/balance',
|
balanceApi: '/api/sooin/balance',
|
||||||
salesApi: '/api/sooin/monthly-sales'
|
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() {
|
async function loadBalances() {
|
||||||
const content = document.getElementById('balanceContent');
|
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