Files
pharmacy-pos-qr-system/backend/order_recommendation.py

292 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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