fix: dry run에서 재고 있는 제품 우선 선택하도록 수정
- 동일 보험코드에 여러 제품 있을 때 첫 번째 매칭 선택하던 버그 - 재고 0인 제품 선택되어 dry run 실패하던 문제 해결 - matched_with_stock 우선, 없으면 matched_any 사용
This commit is contained in:
parent
0460085791
commit
e84eda928a
505
backend/order_api.py
Normal file
505
backend/order_api.py
Normal file
@ -0,0 +1,505 @@
|
||||
# -*- 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)
|
||||
else:
|
||||
result = {
|
||||
'success': False,
|
||||
'error': f'Wholesaler {wholesaler_id} not supported yet'
|
||||
}
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def submit_geoyoung_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:
|
||||
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:
|
||||
# ─────────────────────────────────────────
|
||||
# 실제 주문 (빠른 API - ~1초/품목)
|
||||
# ─────────────────────────────────────────
|
||||
from geoyoung_api import get_geo_session
|
||||
|
||||
geo_session = get_geo_session()
|
||||
|
||||
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:
|
||||
# 지오영 주문 실행 (빠른 API - 장바구니+확정)
|
||||
result = geo_session.full_order(
|
||||
kd_code=kd_code,
|
||||
quantity=order_qty,
|
||||
specification=spec if spec else None,
|
||||
check_stock=True,
|
||||
auto_confirm=True,
|
||||
memo=f"자동주문 - {item.get('product_name', '')}"
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
status = 'success'
|
||||
result_code = 'OK'
|
||||
result_message = result.get('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)
|
||||
|
||||
# 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'
|
||||
})
|
||||
|
||||
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 failed_count == 0:
|
||||
update_order_status(order_id, 'submitted',
|
||||
f'주문 제출 완료: {success_count}개 품목')
|
||||
elif success_count == 0:
|
||||
update_order_status(order_id, 'failed',
|
||||
f'주문 실패: {failed_count}개 품목')
|
||||
else:
|
||||
update_order_status(order_id, 'partial',
|
||||
f'부분 주문: {success_count}개 성공, {failed_count}개 실패')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'dry_run': dry_run,
|
||||
'order_id': order_id,
|
||||
'order_no': order['order_no'],
|
||||
'total_items': len(items),
|
||||
'success_count': success_count,
|
||||
'failed_count': failed_count,
|
||||
'results': results
|
||||
}
|
||||
|
||||
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",
|
||||
"items": [...],
|
||||
"dry_run": true
|
||||
}
|
||||
"""
|
||||
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)
|
||||
|
||||
if order['wholesaler_id'] == 'geoyoung':
|
||||
submit_result = submit_geoyoung_order(order, dry_run)
|
||||
else:
|
||||
submit_result = {'success': False, 'error': 'Wholesaler not supported'}
|
||||
|
||||
submit_result['order_no'] = create_result['order_no']
|
||||
|
||||
return jsonify(submit_result)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 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': '주문 이력이 없습니다'
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user