feat: 수인약품 주문 dry-run 지원
- order_api.py: submit_sooin_order() 함수 추가 - admin_rx_usage.html: 도매상별 주문 분기 처리 - 수인/지오영 모두 dry-run 테스트 가능 - 여러 도매상 품목 있을 때 선택 모달
This commit is contained in:
parent
7dda385b7f
commit
50455e63c7
@ -409,7 +409,7 @@ def api_quick_submit():
|
|||||||
|
|
||||||
POST /api/order/quick-submit
|
POST /api/order/quick-submit
|
||||||
{
|
{
|
||||||
"wholesaler_id": "geoyoung",
|
"wholesaler_id": "geoyoung" | "sooin",
|
||||||
"items": [...],
|
"items": [...],
|
||||||
"dry_run": true
|
"dry_run": true
|
||||||
}
|
}
|
||||||
@ -441,14 +441,226 @@ def api_quick_submit():
|
|||||||
|
|
||||||
if order['wholesaler_id'] == 'geoyoung':
|
if order['wholesaler_id'] == 'geoyoung':
|
||||||
submit_result = submit_geoyoung_order(order, dry_run)
|
submit_result = submit_geoyoung_order(order, dry_run)
|
||||||
|
elif order['wholesaler_id'] == 'sooin':
|
||||||
|
submit_result = submit_sooin_order(order, dry_run)
|
||||||
else:
|
else:
|
||||||
submit_result = {'success': False, 'error': 'Wholesaler not supported'}
|
submit_result = {'success': False, 'error': f"Wholesaler {order['wholesaler_id']} not supported"}
|
||||||
|
|
||||||
submit_result['order_no'] = create_result['order_no']
|
submit_result['order_no'] = create_result['order_no']
|
||||||
|
|
||||||
return jsonify(submit_result)
|
return jsonify(submit_result)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_sooin_order(order: dict, dry_run: bool) -> dict:
|
||||||
|
"""수인약품 주문 제출"""
|
||||||
|
order_id = order['id']
|
||||||
|
items = order['items']
|
||||||
|
|
||||||
|
# 상태 업데이트
|
||||||
|
update_order_status(order_id, 'pending',
|
||||||
|
f'수인 주문 시작 (dry_run={dry_run})')
|
||||||
|
|
||||||
|
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', '')
|
||||||
|
internal_code = item.get('internal_code')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# internal_code가 없으면 검색해서 찾기
|
||||||
|
if not internal_code:
|
||||||
|
search_result = sooin_session.search_products(kd_code)
|
||||||
|
if search_result.get('success'):
|
||||||
|
for sooin_item in search_result.get('items', []):
|
||||||
|
if spec in sooin_item.get('spec', '') or sooin_item.get('spec', '') in spec:
|
||||||
|
internal_code = sooin_item.get('internal_code')
|
||||||
|
break
|
||||||
|
|
||||||
|
if not internal_code:
|
||||||
|
raise ValueError(f"내부 코드를 찾을 수 없음: {kd_code} {spec}")
|
||||||
|
|
||||||
|
# 장바구니 추가
|
||||||
|
cart_result = sooin_session.add_to_cart(internal_code, order_qty)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
# 주문 확정은 별도로 (장바구니에 담기만 한 상태)
|
||||||
|
if success_count > 0:
|
||||||
|
update_order_status(order_id, 'pending',
|
||||||
|
f'수인 장바구니 추가 완료: {success_count}개 (확정 필요)')
|
||||||
|
else:
|
||||||
|
update_order_status(order_id, 'failed',
|
||||||
|
f'수인 주문 실패: {failed_count}개')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'dry_run': dry_run,
|
||||||
|
'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': '실제 주문 시 장바구니에 담김. 수인약품 사이트에서 최종 확정 필요.' 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
|
# AI 학습용 API
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|||||||
@ -1052,25 +1052,62 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────── 주문 제출 ────────────────
|
// ──────────────── 주문 제출 ────────────────
|
||||||
|
let currentOrderWholesaler = null;
|
||||||
|
|
||||||
function submitOrder() {
|
function submitOrder() {
|
||||||
if (cart.length === 0) return;
|
if (cart.length === 0) return;
|
||||||
|
|
||||||
// 지오영 품목만 필터
|
// 도매상별 분류
|
||||||
const geoItems = cart.filter(c => c.supplier === '지오영' || c.geoyoung_code);
|
const geoItems = cart.filter(c => c.supplier === '지오영' || c.wholesaler === 'geoyoung');
|
||||||
|
const sooinItems = cart.filter(c => c.supplier === '수인약품' || c.wholesaler === 'sooin');
|
||||||
|
const otherItems = cart.filter(c => !geoItems.includes(c) && !sooinItems.includes(c));
|
||||||
|
|
||||||
if (geoItems.length === 0) {
|
if (geoItems.length === 0 && sooinItems.length === 0) {
|
||||||
// 지오영 품목 없으면 기존 방식 (클립보드)
|
// API 지원 안 되는 품목만 있으면 클립보드
|
||||||
submitOrderClipboard();
|
submitOrderClipboard();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 지오영 주문 모달 열기
|
// 도매상 선택 모달 열기 (여러 도매상 품목이 있을 때)
|
||||||
openOrderConfirmModal(geoItems);
|
if (geoItems.length > 0 && sooinItems.length > 0) {
|
||||||
|
openWholesalerSelectModal(geoItems, sooinItems, otherItems);
|
||||||
|
} else if (geoItems.length > 0) {
|
||||||
|
openOrderConfirmModal('geoyoung', geoItems);
|
||||||
|
} else if (sooinItems.length > 0) {
|
||||||
|
openOrderConfirmModal('sooin', sooinItems);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openOrderConfirmModal(items) {
|
function openWholesalerSelectModal(geoItems, sooinItems, otherItems) {
|
||||||
|
// 간단하게 지오영 먼저 처리
|
||||||
|
const msg = `장바구니에 여러 도매상 품목이 있습니다.\n\n` +
|
||||||
|
`🏭 지오영: ${geoItems.length}개\n` +
|
||||||
|
`💊 수인약품: ${sooinItems.length}개\n` +
|
||||||
|
(otherItems.length > 0 ? `📋 기타: ${otherItems.length}개\n` : '') +
|
||||||
|
`\n어느 도매상부터 주문하시겠습니까?`;
|
||||||
|
|
||||||
|
if (confirm(msg + '\n\n[확인] = 지오영 먼저\n[취소] = 수인약품 먼저')) {
|
||||||
|
openOrderConfirmModal('geoyoung', geoItems);
|
||||||
|
} else {
|
||||||
|
openOrderConfirmModal('sooin', sooinItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOrderConfirmModal(wholesaler, items) {
|
||||||
|
currentOrderWholesaler = wholesaler;
|
||||||
|
|
||||||
const modal = document.getElementById('orderConfirmModal');
|
const modal = document.getElementById('orderConfirmModal');
|
||||||
const tbody = document.getElementById('orderConfirmBody');
|
const tbody = document.getElementById('orderConfirmBody');
|
||||||
|
const header = modal.querySelector('.order-modal-header h3');
|
||||||
|
|
||||||
|
// 도매상별 헤더 스타일
|
||||||
|
if (wholesaler === 'sooin') {
|
||||||
|
header.innerHTML = '💊 수인약품 주문 확인';
|
||||||
|
modal.querySelector('.order-modal-header').style.background = 'linear-gradient(135deg, #7c3aed, #a855f7)';
|
||||||
|
} else {
|
||||||
|
header.innerHTML = '🏭 지오영 주문 확인';
|
||||||
|
modal.querySelector('.order-modal-header').style.background = 'linear-gradient(135deg, #0891b2, #06b6d4)';
|
||||||
|
}
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
items.forEach((item, idx) => {
|
items.forEach((item, idx) => {
|
||||||
@ -1089,13 +1126,22 @@
|
|||||||
|
|
||||||
function closeOrderConfirmModal() {
|
function closeOrderConfirmModal() {
|
||||||
document.getElementById('orderConfirmModal').classList.remove('show');
|
document.getElementById('orderConfirmModal').classList.remove('show');
|
||||||
|
currentOrderWholesaler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeOrder(dryRun = true) {
|
async function executeOrder(dryRun = true) {
|
||||||
const geoItems = cart.filter(c => c.supplier === '지오영' || c.geoyoung_code);
|
const wholesaler = currentOrderWholesaler || 'geoyoung';
|
||||||
|
|
||||||
if (geoItems.length === 0) {
|
// 해당 도매상 품목 필터
|
||||||
showToast('지오영 품목이 없습니다', 'error');
|
let items;
|
||||||
|
if (wholesaler === 'sooin') {
|
||||||
|
items = cart.filter(c => c.supplier === '수인약품' || c.wholesaler === 'sooin');
|
||||||
|
} else {
|
||||||
|
items = cart.filter(c => c.supplier === '지오영' || c.wholesaler === 'geoyoung');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
showToast(`${wholesaler === 'sooin' ? '수인약품' : '지오영'} 품목이 없습니다`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1109,10 +1155,11 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
wholesaler_id: 'geoyoung',
|
wholesaler_id: wholesaler,
|
||||||
items: geoItems.map(item => ({
|
items: items.map(item => ({
|
||||||
drug_code: item.drug_code,
|
drug_code: item.drug_code,
|
||||||
kd_code: item.geoyoung_code || item.drug_code,
|
kd_code: item.geoyoung_code || item.sooin_code || item.drug_code,
|
||||||
|
internal_code: item.internal_code,
|
||||||
product_name: item.product_name,
|
product_name: item.product_name,
|
||||||
manufacturer: item.supplier,
|
manufacturer: item.supplier,
|
||||||
specification: item.specification || '',
|
specification: item.specification || '',
|
||||||
@ -1124,8 +1171,8 @@
|
|||||||
dry_run: dryRun
|
dry_run: dryRun
|
||||||
};
|
};
|
||||||
|
|
||||||
// 실제 주문은 시간이 오래 걸림 (Playwright 사용)
|
// 타임아웃 설정
|
||||||
const timeoutMs = dryRun ? 60000 : 180000; // 테스트 1분, 실제 3분
|
const timeoutMs = dryRun ? 120000 : 180000; // 테스트 2분, 실제 3분
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user