diff --git a/backend/order_api.py b/backend/order_api.py new file mode 100644 index 0000000..36da2aa --- /dev/null +++ b/backend/order_api.py @@ -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('/', 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/', 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/', 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': '주문 이력이 없습니다' + })