# -*- 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('/', 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/', 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': '주문 이력이 없습니다' }) # ───────────────────────────────────────────── # 도매상 한도 관리 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/', 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//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) }