# -*- 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" | "sooin", "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) elif order['wholesaler_id'] == 'sooin': submit_result = submit_sooin_order(order, dry_run) elif order['wholesaler_id'] == 'baekje': submit_result = submit_baekje_order(order, dry_run) 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) -> 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) } def submit_baekje_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 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') order_qty = item['order_qty'] spec = item.get('specification', '') try: # 장바구니 추가 cart_result = baekje_session.add_to_cart(kd_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': 'baekje' }) 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': 'baekje', 'total_items': len(items), 'success_count': success_count, 'failed_count': failed_count, 'results': results, 'note': '실제 주문 시 장바구니에 담김. 백제몰(ibjp.co.kr)에서 최종 확정 필요.' 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/', 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': '주문 이력이 없습니다' })