- patient_query: 대체조제 원본 처방 제외 - rx_query: 대체조제 원본 처방 제외 - PS_Type=9는 대체조제시 원래 처방된 약(조제 안됨) - 기타 배치 스크립트 및 문서 추가
1617 lines
68 KiB
Python
1617 lines
68 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
주문 API 모듈
|
|
- 주문 생성/조회
|
|
- 지오영 실제 주문 연동
|
|
- dry_run 테스트 모드
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import asyncio
|
|
import re
|
|
from flask import Blueprint, jsonify, request
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Blueprint 생성
|
|
order_bp = Blueprint('order', __name__, url_prefix='/api/order')
|
|
|
|
# 지오영 크롤러 경로
|
|
CRAWLER_PATH = r'c:\Users\청춘약국\source\person-lookup-web-local\crawler'
|
|
if CRAWLER_PATH not in sys.path:
|
|
sys.path.insert(0, CRAWLER_PATH)
|
|
|
|
# 주문 DB
|
|
from order_db import (
|
|
create_order, get_order, update_order_status,
|
|
update_item_result, get_order_history,
|
|
save_order_context, get_usage_stats, get_order_pattern,
|
|
get_ai_training_data
|
|
)
|
|
|
|
|
|
def run_async(coro):
|
|
"""동기 컨텍스트에서 비동기 함수 실행"""
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
except RuntimeError:
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
return loop.run_until_complete(coro)
|
|
|
|
|
|
def parse_specification(spec: str) -> int:
|
|
"""규격에서 숫자 추출 (30T -> 30)"""
|
|
if not spec:
|
|
return 1
|
|
match = re.search(r'(\d+)', spec)
|
|
return int(match.group(1)) if match else 1
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# API 엔드포인트
|
|
# ─────────────────────────────────────────────
|
|
|
|
@order_bp.route('/create', methods=['POST'])
|
|
def api_create_order():
|
|
"""
|
|
주문 생성 (draft 상태)
|
|
|
|
POST /api/order/create
|
|
{
|
|
"wholesaler_id": "geoyoung",
|
|
"items": [
|
|
{
|
|
"drug_code": "670400830",
|
|
"kd_code": "670400830",
|
|
"product_name": "레바미피드정 30T",
|
|
"specification": "30T",
|
|
"order_qty": 10,
|
|
"usage_qty": 280,
|
|
"current_stock": 50
|
|
}
|
|
],
|
|
"reference_period": "2026-03-01~2026-03-06",
|
|
"note": "오전 주문"
|
|
}
|
|
"""
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return jsonify({'success': False, 'error': 'No data'}), 400
|
|
|
|
wholesaler_id = data.get('wholesaler_id', 'geoyoung')
|
|
items = data.get('items', [])
|
|
|
|
if not items:
|
|
return jsonify({'success': False, 'error': 'No items'}), 400
|
|
|
|
# unit_qty 계산
|
|
for item in items:
|
|
if 'unit_qty' not in item:
|
|
item['unit_qty'] = parse_specification(item.get('specification'))
|
|
|
|
result = create_order(
|
|
wholesaler_id=wholesaler_id,
|
|
items=items,
|
|
order_type=data.get('order_type', 'manual'),
|
|
order_session=data.get('order_session'),
|
|
reference_period=data.get('reference_period'),
|
|
note=data.get('note')
|
|
)
|
|
|
|
return jsonify(result)
|
|
|
|
|
|
@order_bp.route('/submit', methods=['POST'])
|
|
def api_submit_order():
|
|
"""
|
|
주문 제출 (실제 도매상 주문)
|
|
|
|
POST /api/order/submit
|
|
{
|
|
"order_id": 1,
|
|
"dry_run": true
|
|
}
|
|
|
|
dry_run=true: 시뮬레이션 (실제 주문 X)
|
|
dry_run=false: 실제 주문
|
|
"""
|
|
data = request.get_json()
|
|
|
|
order_id = data.get('order_id')
|
|
dry_run = data.get('dry_run', True) # 기본은 테스트 모드
|
|
|
|
if not order_id:
|
|
return jsonify({'success': False, 'error': 'order_id required'}), 400
|
|
|
|
# 주문 조회
|
|
order = get_order(order_id)
|
|
if not order:
|
|
return jsonify({'success': False, 'error': 'Order not found'}), 404
|
|
|
|
if order['status'] not in ('draft', 'pending', 'failed'):
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f"Cannot submit order with status: {order['status']}"
|
|
}), 400
|
|
|
|
wholesaler_id = order['wholesaler_id']
|
|
|
|
# 도매상별 주문 처리
|
|
if wholesaler_id == 'geoyoung':
|
|
result = submit_geoyoung_order(order, dry_run)
|
|
elif wholesaler_id == 'dongwon':
|
|
result = submit_dongwon_order(order, dry_run)
|
|
else:
|
|
result = {
|
|
'success': False,
|
|
'error': f'Wholesaler {wholesaler_id} not supported yet'
|
|
}
|
|
|
|
return jsonify(result)
|
|
|
|
|
|
def submit_geoyoung_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:
|
|
if dry_run:
|
|
# ─────────────────────────────────────────
|
|
# DRY RUN: 시뮬레이션
|
|
# ─────────────────────────────────────────
|
|
for item in items:
|
|
# 재고 확인만 (실제 주문 X)
|
|
from geoyoung_api import search_geoyoung_stock
|
|
|
|
kd_code = item.get('kd_code') or item.get('drug_code')
|
|
stock_result = search_geoyoung_stock(kd_code)
|
|
|
|
# 규격 매칭 (재고 있는 것 우선!)
|
|
spec = item.get('specification', '')
|
|
matched = None
|
|
matched_with_stock = None
|
|
matched_any = None
|
|
|
|
if stock_result.get('success'):
|
|
for geo_item in stock_result.get('items', []):
|
|
if spec in geo_item.get('specification', ''):
|
|
# 첫 번째 규격 매칭 저장
|
|
if matched_any is None:
|
|
matched_any = geo_item
|
|
# 재고 있는 제품 우선
|
|
if geo_item.get('stock', 0) > 0:
|
|
matched_with_stock = geo_item
|
|
break
|
|
|
|
# 재고 있는 것 우선, 없으면 첫 번째 매칭
|
|
matched = matched_with_stock or matched_any
|
|
|
|
# 모든 규격과 재고 수집 (AI 학습용)
|
|
available_specs = []
|
|
spec_stocks = {}
|
|
if stock_result.get('success'):
|
|
for geo_item in stock_result.get('items', []):
|
|
s = geo_item.get('specification', '')
|
|
available_specs.append(s)
|
|
spec_stocks[s] = geo_item.get('stock', 0)
|
|
|
|
if matched:
|
|
if matched['stock'] >= item['order_qty']:
|
|
# 주문 가능
|
|
status = 'success'
|
|
result_code = 'OK'
|
|
result_message = f"[DRY RUN] 주문 가능: 재고 {matched['stock']}"
|
|
success_count += 1
|
|
selection_reason = 'stock_available'
|
|
else:
|
|
# 재고 부족
|
|
status = 'failed'
|
|
selection_reason = 'low_stock'
|
|
result_code = 'LOW_STOCK'
|
|
result_message = f"[DRY RUN] 재고 부족: {matched['stock']}개 (요청: {item['order_qty']})"
|
|
failed_count += 1
|
|
else:
|
|
# 제품 없음
|
|
status = 'failed'
|
|
result_code = 'NOT_FOUND'
|
|
result_message = f"[DRY RUN] 지오영에서 규격 {spec} 미발견"
|
|
failed_count += 1
|
|
selection_reason = 'not_found'
|
|
|
|
update_item_result(item['id'], status, result_code, result_message)
|
|
|
|
# ─────────────────────────────────────────
|
|
# AI 학습용 컨텍스트 저장
|
|
# ─────────────────────────────────────────
|
|
save_order_context(item['id'], {
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'stock_at_order': item.get('current_stock', 0),
|
|
'usage_1d': item.get('usage_qty', 0) // 7 if item.get('usage_qty') else 0, # 추정
|
|
'usage_7d': item.get('usage_qty', 0), # 조회 기간 사용량
|
|
'usage_30d': (item.get('usage_qty', 0) * 30) // 7 if item.get('usage_qty') else 0, # 추정
|
|
'ordered_spec': spec,
|
|
'ordered_qty': item['order_qty'],
|
|
'available_specs': available_specs,
|
|
'spec_stocks': spec_stocks,
|
|
'selection_reason': selection_reason if 'selection_reason' in dir() else 'unknown'
|
|
})
|
|
|
|
results.append({
|
|
'item_id': item['id'],
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'specification': spec,
|
|
'order_qty': item['order_qty'],
|
|
'status': status,
|
|
'result_code': result_code,
|
|
'result_message': result_message,
|
|
'available_specs': available_specs,
|
|
'spec_stocks': spec_stocks
|
|
})
|
|
|
|
# 주문 상태 업데이트
|
|
if failed_count == 0:
|
|
update_order_status(order_id, 'completed',
|
|
f'[DRY RUN] 시뮬레이션 완료: {success_count}개 성공')
|
|
elif success_count == 0:
|
|
update_order_status(order_id, 'failed',
|
|
f'[DRY RUN] 시뮬레이션 완료: {failed_count}개 실패')
|
|
else:
|
|
update_order_status(order_id, 'partial',
|
|
f'[DRY RUN] 부분 성공: {success_count}개 성공, {failed_count}개 실패')
|
|
|
|
else:
|
|
# ─────────────────────────────────────────
|
|
# 실제 주문 (선별 주문 - 기존 장바구니 보존)
|
|
# ─────────────────────────────────────────
|
|
from geoyoung_api import get_geo_session
|
|
|
|
geo_session = get_geo_session()
|
|
|
|
# 1단계: 모든 품목을 장바구니에 담기
|
|
for item in items:
|
|
kd_code = item.get('kd_code') or item.get('drug_code')
|
|
order_qty = item['order_qty']
|
|
spec = item.get('specification', '')
|
|
item_internal_code = item.get('internal_code') # 프론트에서 이미 선택한 품목
|
|
result = {}
|
|
|
|
# 🔍 디버그 로그
|
|
logger.info(f"[GEO DEBUG] item keys: {list(item.keys())}")
|
|
logger.info(f"[GEO DEBUG] kd_code={kd_code}, internal_code={item_internal_code}, qty={order_qty}, spec={spec}")
|
|
logger.info(f"[GEO DEBUG] full item: {item}")
|
|
|
|
try:
|
|
if item_internal_code:
|
|
# internal_code가 있으면 검색 없이 바로 장바구니 추가!
|
|
logger.info(f"[GEO DEBUG] Using internal_code directly: {item_internal_code}")
|
|
result = geo_session.add_to_cart(item_internal_code, order_qty)
|
|
logger.info(f"[GEO DEBUG] add_to_cart result: {result}")
|
|
if result.get('success'):
|
|
result['product'] = {'internal_code': item_internal_code, 'name': item.get('product_name', '')}
|
|
else:
|
|
# internal_code 없으면 검색 후 장바구니 추가
|
|
logger.info(f"[GEO DEBUG] No internal_code, using full_order with kd_code={kd_code}")
|
|
result = geo_session.full_order(
|
|
kd_code=kd_code,
|
|
quantity=order_qty,
|
|
specification=spec if spec else None,
|
|
check_stock=True,
|
|
auto_confirm=False,
|
|
memo=f"자동주문 - {item.get('product_name', '')}"
|
|
)
|
|
logger.info(f"[GEO DEBUG] full_order result: {result}")
|
|
|
|
if result.get('success'):
|
|
status = 'success'
|
|
result_code = 'CART_ADDED'
|
|
result_message = '장바구니 추가 완료'
|
|
success_count += 1
|
|
else:
|
|
status = 'failed'
|
|
result_code = result.get('error', 'UNKNOWN')
|
|
result_message = result.get('message', '장바구니 추가 실패')
|
|
failed_count += 1
|
|
|
|
except Exception as e:
|
|
status = 'failed'
|
|
result_code = 'ERROR'
|
|
result_message = str(e)
|
|
failed_count += 1
|
|
|
|
update_item_result(item['id'], status, result_code, result_message)
|
|
|
|
# full_order 결과에서 internal_code 가져오기 (지오영은 internal_code 사용!)
|
|
internal_code = result.get('product', {}).get('internal_code') if result.get('success') else None
|
|
|
|
# AI 학습용 컨텍스트 저장
|
|
save_order_context(item['id'], {
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'stock_at_order': item.get('current_stock', 0),
|
|
'usage_7d': item.get('usage_qty', 0),
|
|
'ordered_spec': spec,
|
|
'ordered_qty': order_qty,
|
|
'selection_reason': 'user_order',
|
|
'wholesaler_id': 'geoyoung',
|
|
'internal_code': internal_code
|
|
})
|
|
|
|
results.append({
|
|
'item_id': item['id'],
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'specification': spec,
|
|
'order_qty': order_qty,
|
|
'status': status,
|
|
'result_code': result_code,
|
|
'result_message': result_message,
|
|
'internal_code': internal_code # 선별 주문용
|
|
})
|
|
|
|
# 2단계: cart_only=False면 선별 주문 (기존 품목 보존)
|
|
if not cart_only and success_count > 0:
|
|
try:
|
|
# 이번에 담은 품목의 internal_code만 수집
|
|
ordered_codes = [r['internal_code'] for r in results
|
|
if r['status'] == 'success' and r.get('internal_code')]
|
|
|
|
# 🔧 디버그: 선별 주문 전 상세 로그
|
|
logger.info(f"[GEO DEBUG] 선별 주문 시작")
|
|
logger.info(f"[GEO DEBUG] ordered_codes: {ordered_codes}")
|
|
logger.info(f"[GEO DEBUG] results: {[(r.get('product_name', '')[:20], r.get('internal_code')) for r in results if r['status'] == 'success']}")
|
|
|
|
if ordered_codes:
|
|
# 선별 주문: 기존 품목은 건드리지 않고, 이번에 담은 것만 주문
|
|
confirm_result = geo_session.submit_order_selective(ordered_codes)
|
|
|
|
if confirm_result.get('success'):
|
|
restored_info = f", 기존 {confirm_result.get('restored_count', 0)}개 복원" if confirm_result.get('restored_count', 0) > 0 else ""
|
|
update_order_status(order_id, 'submitted',
|
|
f'지오영 주문 확정 완료: {success_count}개{restored_info}')
|
|
# 결과 메시지 업데이트
|
|
for r in results:
|
|
if r['status'] == 'success':
|
|
r['result_code'] = 'OK'
|
|
r['result_message'] = '주문 확정 완료'
|
|
else:
|
|
update_order_status(order_id, 'partial',
|
|
f'지오영 장바구니 담김, 확정 실패: {confirm_result.get("error", "알 수 없는 오류")}')
|
|
else:
|
|
update_order_status(order_id, 'partial',
|
|
f'지오영 장바구니 담김, internal_code 없음')
|
|
except Exception as e:
|
|
logger.error(f"지오영 주문 확정 오류: {e}")
|
|
update_order_status(order_id, 'partial',
|
|
f'지오영 장바구니 담김, 확정 중 오류: {str(e)}')
|
|
elif success_count > 0:
|
|
# cart_only=True: 장바구니만
|
|
if failed_count == 0:
|
|
update_order_status(order_id, 'pending',
|
|
f'지오영 장바구니 추가 완료: {success_count}개 (사이트에서 확정 필요)')
|
|
else:
|
|
update_order_status(order_id, 'partial',
|
|
f'부분 성공: {success_count}개 장바구니, {failed_count}개 실패')
|
|
else:
|
|
update_order_status(order_id, 'failed',
|
|
f'지오영 주문 실패: {failed_count}개')
|
|
|
|
return {
|
|
'success': True,
|
|
'dry_run': dry_run,
|
|
'cart_only': cart_only,
|
|
'order_id': order_id,
|
|
'order_no': order['order_no'],
|
|
'wholesaler': 'geoyoung',
|
|
'total_items': len(items),
|
|
'success_count': success_count,
|
|
'failed_count': failed_count,
|
|
'results': results,
|
|
'note': '지오영 장바구니에 담김. 지오영 사이트에서 최종 확정 필요.' if cart_only else None
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"지오영 주문 오류: {e}")
|
|
update_order_status(order_id, 'failed', str(e))
|
|
return {
|
|
'success': False,
|
|
'order_id': order_id,
|
|
'error': str(e)
|
|
}
|
|
|
|
|
|
@order_bp.route('/<int:order_id>', methods=['GET'])
|
|
def api_get_order(order_id):
|
|
"""주문 상세 조회"""
|
|
order = get_order(order_id)
|
|
|
|
if not order:
|
|
return jsonify({'success': False, 'error': 'Order not found'}), 404
|
|
|
|
return jsonify({'success': True, 'order': order})
|
|
|
|
|
|
@order_bp.route('/history', methods=['GET'])
|
|
def api_order_history():
|
|
"""
|
|
주문 이력 조회
|
|
|
|
GET /api/order/history?wholesaler_id=geoyoung&start_date=2026-03-01&limit=20
|
|
"""
|
|
orders = get_order_history(
|
|
wholesaler_id=request.args.get('wholesaler_id'),
|
|
start_date=request.args.get('start_date'),
|
|
end_date=request.args.get('end_date'),
|
|
status=request.args.get('status'),
|
|
limit=int(request.args.get('limit', 50))
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'count': len(orders),
|
|
'orders': orders
|
|
})
|
|
|
|
|
|
@order_bp.route('/quick-submit', methods=['POST'])
|
|
def api_quick_submit():
|
|
"""
|
|
빠른 주문 (생성 + 제출 한번에)
|
|
|
|
POST /api/order/quick-submit
|
|
{
|
|
"wholesaler_id": "geoyoung" | "sooin" | "baekje",
|
|
"items": [...],
|
|
"dry_run": true,
|
|
"cart_only": true // true=장바구니만, false=실제 주문까지
|
|
}
|
|
"""
|
|
data = request.get_json()
|
|
|
|
if not data or not data.get('items'):
|
|
return jsonify({'success': False, 'error': 'No items'}), 400
|
|
|
|
# 1. 주문 생성
|
|
create_result = create_order(
|
|
wholesaler_id=data.get('wholesaler_id', 'geoyoung'),
|
|
items=data['items'],
|
|
order_type='manual',
|
|
reference_period=data.get('reference_period'),
|
|
note=data.get('note')
|
|
)
|
|
|
|
if not create_result.get('success'):
|
|
return jsonify(create_result), 400
|
|
|
|
order_id = create_result['order_id']
|
|
|
|
# 2. 주문 조회
|
|
order = get_order(order_id)
|
|
|
|
# 3. 주문 제출
|
|
dry_run = data.get('dry_run', True)
|
|
cart_only = data.get('cart_only', True) # 기본값: 장바구니만
|
|
|
|
if order['wholesaler_id'] == 'geoyoung':
|
|
submit_result = submit_geoyoung_order(order, dry_run, cart_only=cart_only)
|
|
elif order['wholesaler_id'] == 'sooin':
|
|
submit_result = submit_sooin_order(order, dry_run, cart_only=cart_only)
|
|
elif order['wholesaler_id'] == 'baekje':
|
|
submit_result = submit_baekje_order(order, dry_run, cart_only=cart_only)
|
|
elif order['wholesaler_id'] == 'dongwon':
|
|
submit_result = submit_dongwon_order(order, dry_run, cart_only=cart_only)
|
|
else:
|
|
submit_result = {'success': False, 'error': f"Wholesaler {order['wholesaler_id']} not supported"}
|
|
|
|
submit_result['order_no'] = create_result['order_no']
|
|
|
|
return jsonify(submit_result)
|
|
|
|
|
|
def submit_sooin_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 sooin_api import get_sooin_session
|
|
sooin_session = get_sooin_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 = sooin_session.search_products(kd_code)
|
|
|
|
matched = None
|
|
available_specs = []
|
|
spec_stocks = {}
|
|
|
|
if search_result.get('success'):
|
|
for sooin_item in search_result.get('items', []):
|
|
s = sooin_item.get('spec', '')
|
|
available_specs.append(s)
|
|
spec_stocks[s] = sooin_item.get('stock', 0)
|
|
|
|
# 규격 매칭
|
|
if spec in s or s in spec:
|
|
if matched is None or sooin_item.get('stock', 0) > matched.get('stock', 0):
|
|
matched = sooin_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
|
|
selection_reason = 'stock_available'
|
|
elif stock > 0:
|
|
status = 'failed'
|
|
result_code = 'LOW_STOCK'
|
|
result_message = f"[DRY RUN] 재고 부족: {stock}개 (요청: {item['order_qty']})"
|
|
failed_count += 1
|
|
selection_reason = 'low_stock'
|
|
else:
|
|
status = 'failed'
|
|
result_code = 'OUT_OF_STOCK'
|
|
result_message = f"[DRY RUN] 재고 없음"
|
|
failed_count += 1
|
|
selection_reason = 'out_of_stock'
|
|
else:
|
|
status = 'failed'
|
|
result_code = 'NOT_FOUND'
|
|
result_message = f"[DRY RUN] 수인에서 규격 {spec} 미발견"
|
|
failed_count += 1
|
|
selection_reason = 'not_found'
|
|
|
|
update_item_result(item['id'], status, result_code, result_message)
|
|
|
|
# AI 학습용 컨텍스트 저장
|
|
save_order_context(item['id'], {
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'stock_at_order': item.get('current_stock', 0),
|
|
'usage_7d': item.get('usage_qty', 0),
|
|
'ordered_spec': spec,
|
|
'ordered_qty': item['order_qty'],
|
|
'available_specs': available_specs,
|
|
'spec_stocks': spec_stocks,
|
|
'selection_reason': selection_reason,
|
|
'wholesaler_id': 'sooin'
|
|
})
|
|
|
|
results.append({
|
|
'item_id': item['id'],
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'specification': spec,
|
|
'order_qty': item['order_qty'],
|
|
'status': status,
|
|
'result_code': result_code,
|
|
'result_message': result_message,
|
|
'available_specs': available_specs,
|
|
'spec_stocks': spec_stocks,
|
|
'price': matched.get('price') if matched else None
|
|
})
|
|
|
|
# 상태 업데이트
|
|
if failed_count == 0:
|
|
update_order_status(order_id, 'completed',
|
|
f'[DRY RUN] 수인 시뮬레이션 완료: {success_count}개 성공')
|
|
elif success_count == 0:
|
|
update_order_status(order_id, 'failed',
|
|
f'[DRY RUN] 수인 시뮬레이션 완료: {failed_count}개 실패')
|
|
else:
|
|
update_order_status(order_id, 'partial',
|
|
f'[DRY RUN] 수인 부분 성공: {success_count}개 성공, {failed_count}개 실패')
|
|
|
|
else:
|
|
# ─────────────────────────────────────────
|
|
# 실제 주문
|
|
# ─────────────────────────────────────────
|
|
for item in items:
|
|
kd_code = item.get('kd_code') or item.get('drug_code')
|
|
order_qty = item['order_qty']
|
|
spec = item.get('specification', '')
|
|
|
|
try:
|
|
# quick_order 사용 (검색 → 가격/재고 정보 포함하여 장바구니 추가)
|
|
cart_result = sooin_session.quick_order(
|
|
kd_code=kd_code,
|
|
quantity=order_qty,
|
|
spec=spec if spec else None,
|
|
check_stock=False # 재고 체크는 이미 테스트에서 했으므로 스킵
|
|
)
|
|
|
|
if cart_result.get('success'):
|
|
status = 'success'
|
|
result_code = 'CART_ADDED'
|
|
result_message = f"장바구니 추가 완료 (확정 필요)"
|
|
success_count += 1
|
|
else:
|
|
status = 'failed'
|
|
result_code = cart_result.get('error', 'CART_FAILED')
|
|
result_message = cart_result.get('message', '장바구니 추가 실패')
|
|
failed_count += 1
|
|
|
|
except Exception as e:
|
|
status = 'failed'
|
|
result_code = 'ERROR'
|
|
result_message = str(e)
|
|
failed_count += 1
|
|
|
|
update_item_result(item['id'], status, result_code, result_message)
|
|
|
|
# quick_order 결과에서 internal_code 가져오기
|
|
internal_code = cart_result.get('product', {}).get('internal_code') if cart_result.get('success') else None
|
|
|
|
save_order_context(item['id'], {
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'stock_at_order': item.get('current_stock', 0),
|
|
'usage_7d': item.get('usage_qty', 0),
|
|
'ordered_spec': spec,
|
|
'ordered_qty': order_qty,
|
|
'selection_reason': 'user_order',
|
|
'wholesaler_id': 'sooin',
|
|
'internal_code': internal_code
|
|
})
|
|
|
|
results.append({
|
|
'item_id': item['id'],
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'specification': spec,
|
|
'order_qty': order_qty,
|
|
'status': status,
|
|
'result_code': result_code,
|
|
'result_message': result_message,
|
|
'internal_code': internal_code # 선별 주문용
|
|
})
|
|
|
|
# cart_only=False면 주문 확정까지 진행 (선별 주문!)
|
|
if not cart_only and success_count > 0:
|
|
try:
|
|
# 이번에 담은 품목의 internal_code만 수집
|
|
ordered_codes = [r['internal_code'] for r in results
|
|
if r['status'] == 'success' and r.get('internal_code')]
|
|
|
|
if ordered_codes:
|
|
# 선별 주문: 기존 품목은 건드리지 않고, 이번에 담은 것만 주문
|
|
confirm_result = sooin_session.submit_order_selective(ordered_codes)
|
|
|
|
if confirm_result.get('success'):
|
|
restored_info = f", 기존 {confirm_result.get('restored_count', 0)}개 복원" if confirm_result.get('restored_count', 0) > 0 else ""
|
|
update_order_status(order_id, 'submitted',
|
|
f'수인 주문 확정 완료: {success_count}개{restored_info}')
|
|
# 결과 메시지 업데이트
|
|
for r in results:
|
|
if r['status'] == 'success':
|
|
r['result_code'] = 'OK'
|
|
r['result_message'] = '주문 확정 완료'
|
|
else:
|
|
update_order_status(order_id, 'partial',
|
|
f'수인 장바구니 담김, 확정 실패: {confirm_result.get("error", "알 수 없는 오류")}')
|
|
else:
|
|
update_order_status(order_id, 'partial',
|
|
f'수인 장바구니 담김, internal_code 없음')
|
|
except Exception as e:
|
|
logger.error(f"수인 주문 확정 오류: {e}")
|
|
update_order_status(order_id, 'partial',
|
|
f'수인 장바구니 담김, 확정 중 오류: {str(e)}')
|
|
elif success_count > 0:
|
|
update_order_status(order_id, 'pending',
|
|
f'수인 장바구니 추가 완료: {success_count}개 (확정 필요)')
|
|
else:
|
|
update_order_status(order_id, 'failed',
|
|
f'수인 주문 실패: {failed_count}개')
|
|
|
|
# 응답 생성
|
|
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': 'sooin',
|
|
'total_items': len(items),
|
|
'success_count': success_count,
|
|
'failed_count': failed_count,
|
|
'results': results,
|
|
'note': note if not dry_run else None
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"수인 주문 오류: {e}")
|
|
update_order_status(order_id, 'failed', str(e))
|
|
return {
|
|
'success': False,
|
|
'order_id': order_id,
|
|
'error': str(e)
|
|
}
|
|
|
|
|
|
def submit_baekje_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 baekje_api import get_baekje_session
|
|
baekje_session = get_baekje_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 = baekje_session.search_products(kd_code)
|
|
|
|
matched = None
|
|
available_specs = []
|
|
spec_stocks = {}
|
|
|
|
if search_result.get('success'):
|
|
for baekje_item in search_result.get('items', []):
|
|
s = baekje_item.get('spec', '')
|
|
available_specs.append(s)
|
|
spec_stocks[s] = baekje_item.get('stock', 0)
|
|
|
|
# 규격 매칭
|
|
if spec in s or s in spec:
|
|
if matched is None or baekje_item.get('stock', 0) > matched.get('stock', 0):
|
|
matched = baekje_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
|
|
selection_reason = 'stock_available'
|
|
elif stock > 0:
|
|
status = 'failed'
|
|
result_code = 'LOW_STOCK'
|
|
result_message = f"[DRY RUN] 재고 부족: {stock}개 (요청: {item['order_qty']})"
|
|
failed_count += 1
|
|
selection_reason = 'low_stock'
|
|
else:
|
|
status = 'failed'
|
|
result_code = 'OUT_OF_STOCK'
|
|
result_message = f"[DRY RUN] 재고 없음"
|
|
failed_count += 1
|
|
selection_reason = 'out_of_stock'
|
|
else:
|
|
status = 'failed'
|
|
result_code = 'NOT_FOUND'
|
|
result_message = f"[DRY RUN] 백제에서 규격 {spec} 미발견"
|
|
failed_count += 1
|
|
selection_reason = 'not_found'
|
|
|
|
update_item_result(item['id'], status, result_code, result_message)
|
|
|
|
# AI 학습용 컨텍스트 저장
|
|
save_order_context(item['id'], {
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'stock_at_order': item.get('current_stock', 0),
|
|
'usage_7d': item.get('usage_qty', 0),
|
|
'ordered_spec': spec,
|
|
'ordered_qty': item['order_qty'],
|
|
'available_specs': available_specs,
|
|
'spec_stocks': spec_stocks,
|
|
'selection_reason': selection_reason,
|
|
'wholesaler_id': 'baekje'
|
|
})
|
|
|
|
results.append({
|
|
'item_id': item['id'],
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'specification': spec,
|
|
'order_qty': item['order_qty'],
|
|
'status': status,
|
|
'result_code': result_code,
|
|
'result_message': result_message,
|
|
'available_specs': available_specs,
|
|
'spec_stocks': spec_stocks,
|
|
'price': matched.get('price') if matched else None
|
|
})
|
|
|
|
# 상태 업데이트
|
|
if failed_count == 0:
|
|
update_order_status(order_id, 'completed',
|
|
f'[DRY RUN] 백제 시뮬레이션 완료: {success_count}개 성공')
|
|
elif success_count == 0:
|
|
update_order_status(order_id, 'failed',
|
|
f'[DRY RUN] 백제 시뮬레이션 완료: {failed_count}개 실패')
|
|
else:
|
|
update_order_status(order_id, 'partial',
|
|
f'[DRY RUN] 백제 부분 성공: {success_count}개 성공, {failed_count}개 실패')
|
|
|
|
else:
|
|
# ─────────────────────────────────────────
|
|
# 실제 주문 (장바구니 추가)
|
|
# ─────────────────────────────────────────
|
|
for item in items:
|
|
kd_code = item.get('kd_code') or item.get('drug_code')
|
|
internal_code = item.get('internal_code') # 프론트엔드에서 전달된 internal_code
|
|
order_qty = item['order_qty']
|
|
spec = item.get('specification', '')
|
|
cart_result = {}
|
|
|
|
# 🔍 디버그: 백제 주문 파라미터 확인
|
|
logger.info(f"[BAEKJE DEBUG] kd_code={kd_code}, internal_code={internal_code}, qty={order_qty}, spec={spec}")
|
|
logger.info(f"[BAEKJE DEBUG] full item: {item}")
|
|
|
|
try:
|
|
if internal_code:
|
|
# internal_code가 있으면 바로 장바구니 추가!
|
|
logger.info(f"[BAEKJE DEBUG] Using internal_code directly: {internal_code}")
|
|
cart_result = baekje_session.add_to_cart(internal_code, order_qty)
|
|
logger.info(f"[BAEKJE DEBUG] add_to_cart result: {cart_result}")
|
|
else:
|
|
# internal_code가 없으면 검색 후 장바구니 추가
|
|
logger.info(f"[BAEKJE DEBUG] No internal_code, searching by kd_code={kd_code}")
|
|
search_result = baekje_session.search_products(kd_code)
|
|
|
|
if search_result.get('success') and search_result.get('items'):
|
|
# 규격 매칭 (재고 있는 것 우선)
|
|
matched_item = None
|
|
for baekje_item in search_result.get('items', []):
|
|
item_spec = baekje_item.get('spec', '')
|
|
# 규격이 지정되어 있으면 매칭, 없으면 첫번째 재고 있는 것
|
|
if not spec or spec in item_spec or item_spec in spec:
|
|
if matched_item is None or baekje_item.get('stock', 0) > matched_item.get('stock', 0):
|
|
matched_item = baekje_item
|
|
|
|
if matched_item:
|
|
found_internal_code = matched_item.get('internal_code')
|
|
logger.info(f"[BAEKJE DEBUG] Found internal_code via search: {found_internal_code}")
|
|
cart_result = baekje_session.add_to_cart(found_internal_code, order_qty)
|
|
internal_code = found_internal_code # 컨텍스트 저장용
|
|
else:
|
|
cart_result = {'success': False, 'error': 'NO_MATCHING_SPEC', 'message': f'규격 {spec} 미발견'}
|
|
else:
|
|
cart_result = {'success': False, 'error': 'PRODUCT_NOT_FOUND', 'message': '제품 검색 결과 없음'}
|
|
|
|
if cart_result.get('success'):
|
|
status = 'success'
|
|
result_code = 'CART_ADDED'
|
|
result_message = f"장바구니 추가 완료 (백제몰에서 확정 필요)"
|
|
success_count += 1
|
|
else:
|
|
status = 'failed'
|
|
result_code = cart_result.get('error', 'CART_FAILED')
|
|
result_message = cart_result.get('message', '장바구니 추가 실패')
|
|
failed_count += 1
|
|
|
|
except Exception as e:
|
|
status = 'failed'
|
|
result_code = 'ERROR'
|
|
result_message = str(e)
|
|
failed_count += 1
|
|
logger.error(f"[BAEKJE DEBUG] Exception: {e}")
|
|
|
|
update_item_result(item['id'], status, result_code, result_message)
|
|
|
|
save_order_context(item['id'], {
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'stock_at_order': item.get('current_stock', 0),
|
|
'usage_7d': item.get('usage_qty', 0),
|
|
'ordered_spec': spec,
|
|
'ordered_qty': order_qty,
|
|
'selection_reason': 'user_order',
|
|
'wholesaler_id': 'baekje',
|
|
'internal_code': internal_code
|
|
})
|
|
|
|
results.append({
|
|
'item_id': item['id'],
|
|
'drug_code': item['drug_code'],
|
|
'product_name': item['product_name'],
|
|
'specification': spec,
|
|
'order_qty': order_qty,
|
|
'status': status,
|
|
'result_code': result_code,
|
|
'result_message': result_message,
|
|
'internal_code': internal_code
|
|
})
|
|
|
|
# cart_only=False면 주문 확정까지 진행 (선별 주문!)
|
|
if not cart_only and success_count > 0:
|
|
try:
|
|
# 이번에 담은 품목의 internal_code만 수집
|
|
ordered_codes = [r['internal_code'] for r in results
|
|
if r['status'] == 'success' and r.get('internal_code')]
|
|
|
|
logger.info(f"[BAEKJE DEBUG] 선별 주문 시작, ordered_codes: {ordered_codes}")
|
|
|
|
if ordered_codes:
|
|
# 선별 주문: 기존 품목은 건드리지 않고, 이번에 담은 것만 주문
|
|
confirm_result = baekje_session.submit_order_selective(ordered_codes)
|
|
|
|
if confirm_result.get('success'):
|
|
restored_info = f", 기존 {confirm_result.get('restored_count', 0)}개 복원" if confirm_result.get('restored_count', 0) > 0 else ""
|
|
update_order_status(order_id, 'submitted',
|
|
f'백제 주문 확정 완료: {success_count}개{restored_info}')
|
|
# 결과 메시지 업데이트
|
|
for r in results:
|
|
if r['status'] == 'success':
|
|
r['result_code'] = 'OK'
|
|
r['result_message'] = '주문 확정 완료'
|
|
else:
|
|
update_order_status(order_id, 'partial',
|
|
f'백제 장바구니 담김, 확정 실패: {confirm_result.get("error", "알 수 없는 오류")}')
|
|
else:
|
|
update_order_status(order_id, 'partial',
|
|
f'백제 장바구니 담김, internal_code 없음')
|
|
except Exception as e:
|
|
logger.error(f"백제 주문 확정 오류: {e}")
|
|
update_order_status(order_id, 'partial',
|
|
f'백제 장바구니 담김, 확정 중 오류: {str(e)}')
|
|
elif success_count > 0:
|
|
update_order_status(order_id, 'pending',
|
|
f'백제 장바구니 추가 완료: {success_count}개 (확정 필요)')
|
|
else:
|
|
update_order_status(order_id, 'failed',
|
|
f'백제 주문 실패: {failed_count}개')
|
|
|
|
# 응답 생성
|
|
if cart_only:
|
|
note = '백제약품 장바구니에 담김. 백제몰(ibjp.co.kr)에서 최종 확정 필요.'
|
|
else:
|
|
note = None
|
|
|
|
return {
|
|
'success': True,
|
|
'dry_run': dry_run,
|
|
'cart_only': cart_only,
|
|
'order_id': order_id,
|
|
'order_no': order['order_no'],
|
|
'wholesaler': 'baekje',
|
|
'total_items': len(items),
|
|
'success_count': success_count,
|
|
'failed_count': failed_count,
|
|
'results': results,
|
|
'note': note if not dry_run else None
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"백제 주문 오류: {e}")
|
|
update_order_status(order_id, 'failed', str(e))
|
|
return {
|
|
'success': False,
|
|
'order_id': order_id,
|
|
'error': str(e)
|
|
}
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# AI 학습용 API
|
|
# ─────────────────────────────────────────────
|
|
|
|
@order_bp.route('/ai/training-data', methods=['GET'])
|
|
def api_ai_training_data():
|
|
"""
|
|
AI 학습용 데이터 추출
|
|
|
|
GET /api/order/ai/training-data?limit=1000
|
|
"""
|
|
limit = int(request.args.get('limit', 1000))
|
|
data = get_ai_training_data(limit)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'count': len(data),
|
|
'data': data
|
|
})
|
|
|
|
|
|
@order_bp.route('/ai/usage-stats/<drug_code>', methods=['GET'])
|
|
def api_ai_usage_stats(drug_code):
|
|
"""
|
|
약품 사용량 통계 (AI 분석용)
|
|
|
|
GET /api/order/ai/usage-stats/670400830?days=30
|
|
"""
|
|
days = int(request.args.get('days', 30))
|
|
stats = get_usage_stats(drug_code, days)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'stats': stats
|
|
})
|
|
|
|
|
|
@order_bp.route('/ai/order-pattern/<drug_code>', methods=['GET'])
|
|
def api_ai_order_pattern(drug_code):
|
|
"""
|
|
약품 주문 패턴 조회
|
|
|
|
GET /api/order/ai/order-pattern/670400830
|
|
"""
|
|
pattern = get_order_pattern(drug_code)
|
|
|
|
if pattern:
|
|
return jsonify({'success': True, 'pattern': pattern})
|
|
else:
|
|
return jsonify({
|
|
'success': True,
|
|
'pattern': None,
|
|
'message': '주문 이력이 없습니다'
|
|
})
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# 도매상 한도 관리 API
|
|
# ─────────────────────────────────────────────
|
|
|
|
@order_bp.route('/wholesaler/limits', methods=['GET'])
|
|
def api_wholesaler_limits():
|
|
"""
|
|
전체 도매상 한도 조회 (현재 월 사용량 포함)
|
|
|
|
GET /api/order/wholesaler/limits
|
|
"""
|
|
import sqlite3
|
|
from datetime import datetime
|
|
|
|
# 절대 경로 사용
|
|
db_path = r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend\db\orders.db'
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
cur = conn.cursor()
|
|
|
|
# 현재 월
|
|
year_month = datetime.now().strftime('%Y-%m')
|
|
|
|
# 한도 정보 조회
|
|
cur.execute('SELECT * FROM wholesaler_limits WHERE is_active = 1 ORDER BY priority')
|
|
limits = cur.fetchall()
|
|
|
|
result = []
|
|
for row in limits:
|
|
ws_id = row['wholesaler_id']
|
|
monthly_limit = row['monthly_limit']
|
|
|
|
# 이번 달 실제 주문 금액 조회 (성공한 것만)
|
|
cur.execute('''
|
|
SELECT COALESCE(SUM(oi.unit_price * oi.order_qty), 0) as total_amount
|
|
FROM order_items oi
|
|
JOIN orders o ON oi.order_id = o.id
|
|
WHERE o.wholesaler_id = ?
|
|
AND strftime('%Y-%m', o.order_date) = ?
|
|
AND o.status IN ('submitted', 'success', 'confirmed')
|
|
''', (ws_id, year_month))
|
|
|
|
usage_row = cur.fetchone()
|
|
current_usage = usage_row['total_amount'] if usage_row else 0
|
|
|
|
usage_percent = (current_usage / monthly_limit * 100) if monthly_limit > 0 else 0
|
|
remaining = monthly_limit - current_usage
|
|
|
|
result.append({
|
|
'wholesaler_id': ws_id,
|
|
'monthly_limit': monthly_limit,
|
|
'current_usage': current_usage,
|
|
'remaining': remaining,
|
|
'usage_percent': round(usage_percent, 1),
|
|
'warning_threshold': row['warning_threshold'],
|
|
'is_warning': usage_percent >= (row['warning_threshold'] * 100),
|
|
'priority': row['priority']
|
|
})
|
|
|
|
conn.close()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'year_month': year_month,
|
|
'limits': result
|
|
})
|
|
|
|
|
|
@order_bp.route('/wholesaler/limits/<wholesaler_id>', methods=['PUT'])
|
|
def api_update_wholesaler_limit(wholesaler_id):
|
|
"""
|
|
도매상 한도 수정
|
|
|
|
PUT /api/order/wholesaler/limits/geoyoung
|
|
{
|
|
"monthly_limit": 30000000,
|
|
"warning_threshold": 0.85
|
|
}
|
|
"""
|
|
import sqlite3
|
|
|
|
data = request.get_json()
|
|
|
|
db_path = r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend\db\orders.db'
|
|
conn = sqlite3.connect(db_path)
|
|
cur = conn.cursor()
|
|
|
|
updates = []
|
|
params = []
|
|
|
|
if 'monthly_limit' in data:
|
|
updates.append('monthly_limit = ?')
|
|
params.append(data['monthly_limit'])
|
|
|
|
if 'warning_threshold' in data:
|
|
updates.append('warning_threshold = ?')
|
|
params.append(data['warning_threshold'])
|
|
|
|
if 'priority' in data:
|
|
updates.append('priority = ?')
|
|
params.append(data['priority'])
|
|
|
|
if updates:
|
|
updates.append("updated_at = datetime('now')")
|
|
params.append(wholesaler_id)
|
|
|
|
cur.execute(f'''
|
|
UPDATE wholesaler_limits
|
|
SET {', '.join(updates)}
|
|
WHERE wholesaler_id = ?
|
|
''', params)
|
|
|
|
conn.commit()
|
|
|
|
conn.close()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'{wholesaler_id} 한도 업데이트 완료'
|
|
})
|
|
|
|
|
|
# ========== 약품별 선호 도매상 API ==========
|
|
|
|
def get_drug_preferred_vendor(drug_code: str, period_days: int = 365):
|
|
"""
|
|
약품코드 기준 선호 도매상 조회 (MSSQL 입고장 데이터)
|
|
"""
|
|
import pyodbc
|
|
|
|
CONN_STR = (
|
|
'DRIVER={ODBC Driver 17 for SQL Server};'
|
|
'SERVER=192.168.0.4\\PM2014;'
|
|
'DATABASE=PM_DRUG;'
|
|
'UID=sa;'
|
|
'PWD=tmddls214!%(;'
|
|
'TrustServerCertificate=yes;'
|
|
'Connection Timeout=10'
|
|
)
|
|
|
|
try:
|
|
conn = pyodbc.connect(CONN_STR, timeout=10)
|
|
cursor = conn.cursor()
|
|
|
|
# 약품명 조회
|
|
cursor.execute("SELECT GoodsName FROM CD_GOODS WHERE DrugCode = ?", drug_code)
|
|
row = cursor.fetchone()
|
|
drug_name = row[0] if row else ''
|
|
|
|
# 도매상별 입고 통계
|
|
query = """
|
|
SELECT
|
|
c.CD_NM_custom AS vendor_name,
|
|
c.CD_CD_custom AS vendor_code,
|
|
COUNT(*) AS order_count,
|
|
SUM(ws.WH_NM_item_a) AS total_qty,
|
|
SUM(ws.WH_MY_amount_a) AS total_amount,
|
|
AVG(ws.WH_MY_unit_a) AS avg_unit_price,
|
|
MAX(wm.WH_DT_appl) AS last_order_date
|
|
FROM WH_sub ws
|
|
JOIN WH_main wm ON ws.WH_SR_stock = wm.WH_NO_stock
|
|
LEFT JOIN PM_BASE.dbo.CD_custom c ON wm.WH_CD_cust_sale = c.CD_CD_custom
|
|
WHERE ws.DrugCode = ?
|
|
AND wm.WH_DT_appl >= CONVERT(varchar(8), DATEADD(day, ?, GETDATE()), 112)
|
|
GROUP BY c.CD_NM_custom, c.CD_CD_custom
|
|
ORDER BY COUNT(*) DESC
|
|
"""
|
|
|
|
cursor.execute(query, (drug_code, -period_days))
|
|
rows = cursor.fetchall()
|
|
|
|
if not rows:
|
|
conn.close()
|
|
return {
|
|
'success': True,
|
|
'drug_code': drug_code,
|
|
'drug_name': drug_name,
|
|
'recent_vendor': None,
|
|
'most_frequent_vendor': None,
|
|
'vendors': [],
|
|
'message': '입고 이력 없음'
|
|
}
|
|
|
|
vendors = []
|
|
for r in rows:
|
|
vendors.append({
|
|
'vendor_name': r[0] or '알수없음',
|
|
'vendor_code': r[1] or '',
|
|
'order_count': r[2],
|
|
'total_qty': float(r[3] or 0),
|
|
'total_amount': float(r[4] or 0),
|
|
'avg_unit_price': float(r[5] or 0),
|
|
'last_order_date': r[6]
|
|
})
|
|
|
|
most_frequent = vendors[0] if vendors else None
|
|
|
|
# 최근 주문 도매상
|
|
recent_query = """
|
|
SELECT TOP 1
|
|
c.CD_NM_custom AS vendor_name,
|
|
c.CD_CD_custom AS vendor_code,
|
|
wm.WH_DT_appl AS order_date,
|
|
ws.WH_NM_item_a AS qty,
|
|
ws.WH_MY_unit_a AS unit_price
|
|
FROM WH_sub ws
|
|
JOIN WH_main wm ON ws.WH_SR_stock = wm.WH_NO_stock
|
|
LEFT JOIN PM_BASE.dbo.CD_custom c ON wm.WH_CD_cust_sale = c.CD_CD_custom
|
|
WHERE ws.DrugCode = ?
|
|
ORDER BY wm.WH_DT_appl DESC
|
|
"""
|
|
cursor.execute(recent_query, drug_code)
|
|
recent_row = cursor.fetchone()
|
|
|
|
recent_vendor = None
|
|
if recent_row:
|
|
recent_vendor = {
|
|
'vendor_name': recent_row[0] or '알수없음',
|
|
'vendor_code': recent_row[1] or '',
|
|
'order_date': recent_row[2],
|
|
'qty': float(recent_row[3] or 0),
|
|
'unit_price': float(recent_row[4] or 0)
|
|
}
|
|
|
|
conn.close()
|
|
|
|
return {
|
|
'success': True,
|
|
'drug_code': drug_code,
|
|
'drug_name': drug_name,
|
|
'recent_vendor': recent_vendor,
|
|
'most_frequent_vendor': most_frequent,
|
|
'vendors': vendors
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'error': str(e),
|
|
'drug_code': drug_code
|
|
}
|
|
|
|
|
|
@order_bp.route('/drug/<drug_code>/preferred-vendor', methods=['GET'])
|
|
def api_drug_preferred_vendor(drug_code):
|
|
"""
|
|
약품별 선호 도매상 조회 API
|
|
|
|
GET /api/order/drug/670400830/preferred-vendor
|
|
GET /api/order/drug/670400830/preferred-vendor?period=180
|
|
"""
|
|
period = request.args.get('period', 365, type=int)
|
|
result = get_drug_preferred_vendor(drug_code, period)
|
|
return jsonify(result)
|
|
|
|
|
|
@order_bp.route('/drugs/preferred-vendors', methods=['POST'])
|
|
def api_drugs_preferred_vendors():
|
|
"""
|
|
여러 약품의 선호 도매상 일괄 조회
|
|
|
|
POST /api/order/drugs/preferred-vendors
|
|
{"drug_codes": ["670400830", "654301800"], "period": 365}
|
|
"""
|
|
data = request.get_json() or {}
|
|
drug_codes = data.get('drug_codes', [])
|
|
period = data.get('period', 365)
|
|
|
|
if not drug_codes:
|
|
return jsonify({'success': False, 'error': 'drug_codes required'})
|
|
|
|
results = {}
|
|
for code in drug_codes:
|
|
results[code] = get_drug_preferred_vendor(code, period)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'count': len(results),
|
|
'results': results
|
|
})
|
|
|
|
|
|
def submit_dongwon_order(order: dict, dry_run: bool, cart_only: bool = True) -> dict:
|
|
"""
|
|
동원약품 주문 제출
|
|
|
|
Args:
|
|
order: 주문 정보
|
|
dry_run: True=시뮬레이션만, False=실제 주문
|
|
cart_only: True=장바구니만, False=주문 확정까지
|
|
"""
|
|
order_id = order['id']
|
|
items = order['items']
|
|
|
|
# 상태 업데이트
|
|
update_order_status(order_id, 'pending',
|
|
f'동원 주문 시작 (dry_run={dry_run}, cart_only={cart_only})')
|
|
|
|
results = []
|
|
success_count = 0
|
|
failed_count = 0
|
|
|
|
try:
|
|
from dongwon_api import get_dongwon_session
|
|
dongwon_session = get_dongwon_session()
|
|
|
|
if dry_run:
|
|
# ─────────────────────────────────────────
|
|
# DRY RUN: 재고 확인만
|
|
# ─────────────────────────────────────────
|
|
for item in items:
|
|
kd_code = item.get('kd_code') or item.get('drug_code')
|
|
spec = item.get('specification', '')
|
|
|
|
# 재고 검색
|
|
search_result = dongwon_session.search_products(kd_code)
|
|
|
|
matched = None
|
|
available_specs = []
|
|
spec_stocks = {}
|
|
|
|
if search_result.get('success'):
|
|
for dongwon_item in search_result.get('items', []):
|
|
s = dongwon_item.get('spec', '')
|
|
available_specs.append(s)
|
|
spec_stocks[s] = dongwon_item.get('stock', 0)
|
|
|
|
# 규격 매칭
|
|
if spec in s or s in spec:
|
|
if matched is None or dongwon_item.get('stock', 0) > matched.get('stock', 0):
|
|
matched = dongwon_item
|
|
|
|
if matched:
|
|
stock = matched.get('stock', 0)
|
|
if stock >= item['order_qty']:
|
|
status = 'success'
|
|
result_code = 'OK'
|
|
result_message = f"[DRY RUN] 주문 가능: 재고 {stock}, 단가 {matched.get('price', 0):,}원"
|
|
success_count += 1
|
|
elif stock > 0:
|
|
status = 'failed'
|
|
result_code = 'LOW_STOCK'
|
|
result_message = f"[DRY RUN] 재고 부족: {stock}개 (요청: {item['order_qty']})"
|
|
failed_count += 1
|
|
else:
|
|
status = 'failed'
|
|
result_code = 'OUT_OF_STOCK'
|
|
result_message = f"[DRY RUN] 재고 없음"
|
|
failed_count += 1
|
|
else:
|
|
status = 'failed'
|
|
result_code = 'NOT_FOUND'
|
|
result_message = f"[DRY RUN] 동원에서 규격 {spec} 미발견"
|
|
failed_count += 1
|
|
|
|
update_item_result(item['id'], status, result_code, result_message)
|
|
|
|
results.append({
|
|
'item_id': item['id'],
|
|
'drug_code': item.get('drug_code') or item.get('kd_code'),
|
|
'product_name': item.get('product_name') or item.get('drug_name', ''),
|
|
'specification': spec,
|
|
'order_qty': item['order_qty'],
|
|
'status': status,
|
|
'result_code': result_code,
|
|
'result_message': result_message,
|
|
'matched_spec': matched.get('spec') if matched else None,
|
|
'stock': matched.get('stock') if matched else 0,
|
|
'price': matched.get('price') if matched else 0
|
|
})
|
|
|
|
update_order_status(order_id, 'dry_run_complete',
|
|
f'[DRY RUN] 완료: 성공 {success_count}, 실패 {failed_count}')
|
|
|
|
return {
|
|
'success': True,
|
|
'dry_run': dry_run,
|
|
'cart_only': cart_only,
|
|
'order_id': order_id,
|
|
'order_no': order['order_no'],
|
|
'wholesaler': 'dongwon',
|
|
'total_items': len(items),
|
|
'success_count': success_count,
|
|
'failed_count': failed_count,
|
|
'results': results
|
|
}
|
|
|
|
else:
|
|
# ─────────────────────────────────────────
|
|
# 실제 주문: 장바구니 담기 (또는 주문 확정)
|
|
# ─────────────────────────────────────────
|
|
cart_items = []
|
|
|
|
for item in items:
|
|
kd_code = item.get('kd_code') or item.get('drug_code')
|
|
internal_code = item.get('dongwon_code') or item.get('internal_code')
|
|
spec = item.get('specification', '')
|
|
order_qty = item['order_qty']
|
|
|
|
# internal_code가 없으면 검색해서 찾기
|
|
if not internal_code:
|
|
search_result = dongwon_session.search_products(kd_code)
|
|
if search_result.get('success') and search_result.get('items'):
|
|
for dongwon_item in search_result['items']:
|
|
s = dongwon_item.get('spec', '')
|
|
if spec in s or s in spec:
|
|
internal_code = dongwon_item.get('internal_code')
|
|
break
|
|
# 규격 매칭 안 되면 첫 번째 결과 사용
|
|
if not internal_code and search_result['items']:
|
|
internal_code = search_result['items'][0].get('internal_code')
|
|
|
|
product_name = item.get('product_name') or item.get('drug_name', '')
|
|
|
|
if internal_code:
|
|
cart_items.append({
|
|
'internal_code': internal_code,
|
|
'quantity': order_qty
|
|
})
|
|
|
|
update_item_result(item['id'], 'success', 'CART_READY',
|
|
f'장바구니 준비 완료: {internal_code}')
|
|
results.append({
|
|
'item_id': item['id'],
|
|
'drug_code': kd_code,
|
|
'product_name': product_name,
|
|
'specification': spec,
|
|
'order_qty': order_qty,
|
|
'status': 'success',
|
|
'result_code': 'CART_READY',
|
|
'result_message': f'장바구니 준비 완료: {internal_code}',
|
|
'internal_code': internal_code
|
|
})
|
|
success_count += 1
|
|
else:
|
|
update_item_result(item['id'], 'failed', 'NOT_FOUND',
|
|
f'동원에서 제품 미발견: {kd_code}')
|
|
results.append({
|
|
'item_id': item['id'],
|
|
'drug_code': kd_code,
|
|
'product_name': product_name,
|
|
'specification': spec,
|
|
'order_qty': order_qty,
|
|
'status': 'failed',
|
|
'result_code': 'NOT_FOUND',
|
|
'result_message': f'동원에서 제품 미발견'
|
|
})
|
|
failed_count += 1
|
|
|
|
# safe_order 사용 (장바구니 백업/복구)
|
|
if cart_items:
|
|
if cart_only:
|
|
# 장바구니만 담기
|
|
for cart_item in cart_items:
|
|
dongwon_session.add_to_cart(
|
|
cart_item['internal_code'],
|
|
cart_item['quantity']
|
|
)
|
|
update_order_status(order_id, 'cart_added',
|
|
f'동원 장바구니 담기 완료: {len(cart_items)}개 품목')
|
|
else:
|
|
# safe_order로 주문 (기존 장바구니 백업/복구)
|
|
order_result = dongwon_session.safe_order(
|
|
items_to_order=cart_items,
|
|
memo=order.get('memo', ''),
|
|
dry_run=False
|
|
)
|
|
if order_result.get('success'):
|
|
update_order_status(order_id, 'completed',
|
|
f'동원 주문 완료: {order_result.get("ordered_count", 0)}개 품목')
|
|
else:
|
|
update_order_status(order_id, 'failed',
|
|
f'동원 주문 실패: {order_result.get("error", "unknown")}')
|
|
|
|
# 응답 생성
|
|
if cart_only:
|
|
note = '동원약품 장바구니에 담김. 동원몰에서 최종 확정 필요.'
|
|
else:
|
|
note = None
|
|
|
|
return {
|
|
'success': True,
|
|
'dry_run': dry_run,
|
|
'cart_only': cart_only,
|
|
'order_id': order_id,
|
|
'order_no': order['order_no'],
|
|
'wholesaler': 'dongwon',
|
|
'total_items': len(items),
|
|
'success_count': success_count,
|
|
'failed_count': failed_count,
|
|
'results': results,
|
|
'note': note
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"동원 주문 오류: {e}", exc_info=True)
|
|
update_order_status(order_id, 'error', f'동원 주문 오류: {str(e)}')
|
|
return {
|
|
'success': False,
|
|
'order_id': order_id,
|
|
'wholesaler': 'dongwon',
|
|
'error': str(e)
|
|
}
|