feat: QR 토큰 품목 상세 전송 지원 (items 파라미터)
This commit is contained in:
291
backend/order_recommendation.py
Normal file
291
backend/order_recommendation.py
Normal file
@@ -0,0 +1,291 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
주문 추천 API v2
|
||||
- 의약품 도메인 지식 반영
|
||||
- 처방 빈도 기반 차등 추천
|
||||
- 저빈도 약품: 나간 만큼만 보충
|
||||
- 고빈도 약품: 일평균 기반 주문
|
||||
"""
|
||||
|
||||
import pyodbc
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
order_recommendation_bp = Blueprint('order_recommendation', __name__)
|
||||
|
||||
|
||||
def get_mssql_connection(db_name='PM_DRUG'):
|
||||
conn_str = (
|
||||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||||
f'SERVER=192.168.0.4\\PM2014;'
|
||||
f'DATABASE={db_name};'
|
||||
'UID=sa;'
|
||||
'PWD=tmddls214!%(;'
|
||||
'TrustServerCertificate=yes'
|
||||
)
|
||||
return pyodbc.connect(conn_str, timeout=10)
|
||||
|
||||
|
||||
@order_recommendation_bp.route('/api/order-recommendation')
|
||||
def api_order_recommendation():
|
||||
"""
|
||||
주문 추천 목록 API v2
|
||||
|
||||
의약품 도메인 지식 반영:
|
||||
1. 고빈도 약품 (7일 이상 데이터, 3건 이상 처방): 일평균 × N일분
|
||||
2. 저빈도 약품 (가끔 사용): 나간 만큼만 보충
|
||||
3. 유통기한/폐기 위험 고려하여 과잉 주문 방지
|
||||
|
||||
GET /api/order-recommendation?days_threshold=7&order_days=14&limit=50
|
||||
"""
|
||||
try:
|
||||
days_threshold = int(request.args.get('days_threshold', 7)) # N일 이내 소진
|
||||
order_days = int(request.args.get('order_days', 14)) # 고빈도 약품 주문 기준 일수
|
||||
limit = int(request.args.get('limit', 50))
|
||||
min_data_days = int(request.args.get('min_data_days', 3)) # 최소 데이터 일수
|
||||
|
||||
conn = get_mssql_connection('PM_DRUG')
|
||||
cursor = conn.cursor()
|
||||
|
||||
today = datetime.now().date()
|
||||
thirty_days_ago = today - timedelta(days=30)
|
||||
|
||||
# 1단계: 재고 있는 품목 + 최근 30일 출고/입고 + 처방 건수 조회
|
||||
cursor.execute("""
|
||||
WITH StockItems AS (
|
||||
SELECT
|
||||
G.DrugCode,
|
||||
G.GoodsName,
|
||||
G.BARCODE,
|
||||
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
|
||||
FROM CD_GOODS G
|
||||
INNER JOIN IM_total IT ON G.DrugCode = IT.DrugCode
|
||||
WHERE ISNULL(IT.IM_QT_sale_debit, 0) > 0
|
||||
),
|
||||
Outbound AS (
|
||||
SELECT
|
||||
DrugCode,
|
||||
SUM(ISNULL(IM_QT_sale_credit, 0)) as total_outbound,
|
||||
SUM(ISNULL(IM_QT_sale_debit, 0)) as total_inbound,
|
||||
COUNT(DISTINCT IM_DT_appl) as data_days,
|
||||
MAX(IM_DT_appl) as last_outbound_date
|
||||
FROM IM_date_total
|
||||
WHERE IM_DT_appl >= ?
|
||||
AND IM_DT_appl <= ?
|
||||
GROUP BY DrugCode
|
||||
)
|
||||
SELECT
|
||||
S.DrugCode,
|
||||
S.GoodsName,
|
||||
S.BARCODE,
|
||||
S.current_stock,
|
||||
ISNULL(O.total_outbound, 0) as total_outbound,
|
||||
ISNULL(O.total_inbound, 0) as total_inbound,
|
||||
ISNULL(O.data_days, 0) as data_days,
|
||||
O.last_outbound_date
|
||||
FROM StockItems S
|
||||
LEFT JOIN Outbound O ON S.DrugCode = O.DrugCode
|
||||
WHERE ISNULL(O.total_outbound, 0) > 0
|
||||
""", (thirty_days_ago.strftime('%Y%m%d'), today.strftime('%Y%m%d')))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# 2단계: 처방 건수 조회 (PM_PRES)
|
||||
drug_codes = [row.DrugCode for row in rows]
|
||||
rx_counts = {}
|
||||
|
||||
if drug_codes:
|
||||
conn_pres = get_mssql_connection('PM_PRES')
|
||||
cursor_pres = conn_pres.cursor()
|
||||
|
||||
# 최근 30일 처방 건수
|
||||
placeholders = ','.join(['?' for _ in drug_codes])
|
||||
cursor_pres.execute(f"""
|
||||
SELECT DrugCode, COUNT(DISTINCT PreSerial) as rx_count
|
||||
FROM PS_sub_pharm
|
||||
WHERE DrugCode IN ({placeholders})
|
||||
AND PreSerial >= ?
|
||||
GROUP BY DrugCode
|
||||
""", drug_codes + [thirty_days_ago.strftime('%Y%m%d')])
|
||||
|
||||
for row in cursor_pres.fetchall():
|
||||
rx_counts[row.DrugCode] = row.rx_count
|
||||
|
||||
conn_pres.close()
|
||||
|
||||
conn.close()
|
||||
|
||||
# 3단계: 추천 로직 (도메인 지식 반영)
|
||||
recommendations = []
|
||||
|
||||
for row in rows:
|
||||
drug_code = row.DrugCode
|
||||
goods_name = row.GoodsName
|
||||
barcode = row.BARCODE or ''
|
||||
current_stock = int(row.current_stock)
|
||||
total_outbound = int(row.total_outbound)
|
||||
total_inbound = int(row.total_inbound)
|
||||
data_days = int(row.data_days)
|
||||
rx_count = rx_counts.get(drug_code, 0)
|
||||
|
||||
# === 약품 분류 ===
|
||||
# 고빈도: 7일 이상 데이터 AND 3건 이상 처방
|
||||
# 저빈도: 그 외
|
||||
is_high_frequency = data_days >= 7 and rx_count >= 3
|
||||
|
||||
if is_high_frequency:
|
||||
# === 고빈도 약품: 나간 만큼 + 약간 버퍼 ===
|
||||
avg_daily = total_outbound / data_days
|
||||
days_until_empty = current_stock / avg_daily if avg_daily > 0 else 999
|
||||
|
||||
if days_until_empty > days_threshold:
|
||||
continue # 아직 여유 있음
|
||||
|
||||
# 기본: 나간 만큼 주문 + 10% 버퍼
|
||||
recommended_qty = int(total_outbound * 1.1)
|
||||
|
||||
# 현재 재고 고려 (이미 있는 건 빼기)
|
||||
recommended_qty = max(0, recommended_qty - current_stock)
|
||||
|
||||
# 최소 주문량 (나간 양의 50% 이상)
|
||||
min_qty = int(total_outbound * 0.5)
|
||||
if recommended_qty < min_qty:
|
||||
recommended_qty = min_qty
|
||||
|
||||
calc_method = 'high_freq'
|
||||
|
||||
else:
|
||||
# === 저빈도 약품: 나간 만큼만 보충 ===
|
||||
# 원래 재고 수준으로 복구
|
||||
original_stock = current_stock + total_outbound - total_inbound
|
||||
|
||||
# 나간 만큼만 주문 (과잉 주문 방지)
|
||||
recommended_qty = int(total_outbound)
|
||||
|
||||
# 현재 재고가 이미 충분하면 스킵
|
||||
if current_stock >= original_stock * 0.5:
|
||||
continue
|
||||
|
||||
# 일평균 개념 없음, 대략적인 소진일
|
||||
if total_outbound > 0 and data_days > 0:
|
||||
# 한 달에 total_outbound 나갔으니, 하루 평균
|
||||
rough_daily = total_outbound / 30
|
||||
days_until_empty = current_stock / rough_daily if rough_daily > 0 else 999
|
||||
else:
|
||||
days_until_empty = 999
|
||||
|
||||
if days_until_empty > days_threshold * 2: # 저빈도는 기준 완화
|
||||
continue
|
||||
|
||||
avg_daily = total_outbound / 30 # 대략적
|
||||
calc_method = 'low_freq'
|
||||
|
||||
# 재고가 0 이하면 긴급
|
||||
if current_stock <= 0:
|
||||
days_until_empty = 0
|
||||
|
||||
# 소진 예상일
|
||||
empty_date = today + timedelta(days=int(min(days_until_empty, 365)))
|
||||
|
||||
# 신뢰도
|
||||
if data_days >= 20 and rx_count >= 10:
|
||||
confidence = 'high'
|
||||
elif data_days >= 7 and rx_count >= 3:
|
||||
confidence = 'medium'
|
||||
else:
|
||||
confidence = 'low'
|
||||
|
||||
# 긴급도
|
||||
if days_until_empty <= 3:
|
||||
urgency = 'critical'
|
||||
elif days_until_empty <= 5:
|
||||
urgency = 'high'
|
||||
elif days_until_empty <= days_threshold:
|
||||
urgency = 'normal'
|
||||
else:
|
||||
urgency = 'low'
|
||||
|
||||
recommendations.append({
|
||||
'drug_code': drug_code,
|
||||
'goods_name': goods_name,
|
||||
'barcode': barcode,
|
||||
'current_stock': current_stock,
|
||||
'total_outbound_30d': total_outbound,
|
||||
'avg_daily_usage': round(avg_daily, 2),
|
||||
'days_until_empty': round(days_until_empty, 1),
|
||||
'empty_date': empty_date.strftime('%Y-%m-%d'),
|
||||
'recommended_qty': recommended_qty,
|
||||
'rx_count_30d': rx_count,
|
||||
'data_days': data_days,
|
||||
'confidence': confidence,
|
||||
'urgency': urgency,
|
||||
'calc_method': calc_method, # 계산 방식
|
||||
'is_high_frequency': is_high_frequency
|
||||
})
|
||||
|
||||
# 4단계: 정렬 (긴급도 → 소진일)
|
||||
urgency_order = {'critical': 0, 'high': 1, 'normal': 2, 'low': 3}
|
||||
recommendations.sort(key=lambda x: (urgency_order.get(x['urgency'], 9), x['days_until_empty']))
|
||||
recommendations = recommendations[:limit]
|
||||
|
||||
# 5단계: 요약
|
||||
critical_count = sum(1 for r in recommendations if r['urgency'] == 'critical')
|
||||
high_count = sum(1 for r in recommendations if r['urgency'] == 'high')
|
||||
high_freq_count = sum(1 for r in recommendations if r['is_high_frequency'])
|
||||
low_freq_count = sum(1 for r in recommendations if not r['is_high_frequency'])
|
||||
total_order_qty = sum(r['recommended_qty'] for r in recommendations)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'version': '2.0',
|
||||
'generated_at': datetime.now().isoformat(),
|
||||
'params': {
|
||||
'days_threshold': days_threshold,
|
||||
'order_days': order_days,
|
||||
'min_data_days': min_data_days
|
||||
},
|
||||
'summary': {
|
||||
'total_items': len(recommendations),
|
||||
'critical_count': critical_count,
|
||||
'high_count': high_count,
|
||||
'high_frequency_items': high_freq_count,
|
||||
'low_frequency_items': low_freq_count,
|
||||
'total_recommended_qty': total_order_qty
|
||||
},
|
||||
'recommendations': recommendations
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"order-recommendation API error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@order_recommendation_bp.route('/api/order-recommendation/execute', methods=['POST'])
|
||||
def api_execute_order():
|
||||
"""주문 실행 API (POST) - TODO"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': 'No data'}), 400
|
||||
|
||||
wholesaler = data.get('wholesaler', 'sooin')
|
||||
items = data.get('items', [])
|
||||
dry_run = data.get('dry_run', True)
|
||||
|
||||
if not items:
|
||||
return jsonify({'success': False, 'error': 'No items'}), 400
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'wholesaler': wholesaler,
|
||||
'dry_run': dry_run,
|
||||
'items_count': len(items),
|
||||
'message': 'Simulation complete' if dry_run else 'Order submitted'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"execute-order API error: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
Reference in New Issue
Block a user