feat: 도매상 API 통합 및 스키마 업데이트
- wholesale 패키지 연동 (SooinSession, GeoYoungSession) - Flask Blueprint 분리 (sooin_api.py, geoyoung_api.py) - order_context 스키마 확장 (wholesaler_id, internal_code 등) - 수인약품 개별 취소 기능 (cancel_item, restore_item) - 문서 추가: WHOLESALE_API_INTEGRATION.md - 테스트 스크립트들
This commit is contained in:
859
backend/order_db.py
Normal file
859
backend/order_db.py
Normal file
@@ -0,0 +1,859 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
주문 관리 DB (SQLite)
|
||||
- 다중 도매상 지원 (지오영, 수인, 백제 등)
|
||||
- 주문 상태 추적
|
||||
- 품목별 결과 관리
|
||||
- 자동화 ERP 확장 대비
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict
|
||||
import json
|
||||
|
||||
# DB 경로
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), 'db', 'orders.db')
|
||||
|
||||
|
||||
def get_connection():
|
||||
"""DB 연결"""
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
"""DB 초기화 - 테이블 생성"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 도매상 마스터
|
||||
# ─────────────────────────────────────────────
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS wholesalers (
|
||||
id TEXT PRIMARY KEY, -- 'geoyoung', 'sooin', 'baekje'
|
||||
name TEXT NOT NULL, -- '지오영', '수인', '백제'
|
||||
api_type TEXT, -- 'playwright', 'api', 'manual'
|
||||
base_url TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
config_json TEXT, -- 로그인 정보 등 (암호화 권장)
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 기본 도매상 등록
|
||||
cursor.execute('''
|
||||
INSERT OR IGNORE INTO wholesalers (id, name, api_type, base_url)
|
||||
VALUES
|
||||
('geoyoung', '지오영', 'playwright', 'https://gwn.geoweb.kr'),
|
||||
('sooin', '수인', 'manual', NULL),
|
||||
('baekje', '백제', 'manual', NULL)
|
||||
''')
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 주문 헤더
|
||||
# ─────────────────────────────────────────────
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_no TEXT UNIQUE, -- 주문번호 (ORD-20260306-001)
|
||||
wholesaler_id TEXT NOT NULL, -- 도매상 ID
|
||||
|
||||
-- 주문 정보
|
||||
order_date TEXT NOT NULL, -- 주문일 (YYYY-MM-DD)
|
||||
order_time TEXT, -- 주문시간 (HH:MM:SS)
|
||||
order_type TEXT DEFAULT 'manual', -- 'manual', 'auto', 'scheduled'
|
||||
order_session TEXT, -- 'morning', 'afternoon', 'evening'
|
||||
|
||||
-- 상태
|
||||
status TEXT DEFAULT 'draft', -- draft, pending, submitted, partial, completed, failed, cancelled
|
||||
|
||||
-- 집계
|
||||
total_items INTEGER DEFAULT 0,
|
||||
total_qty INTEGER DEFAULT 0,
|
||||
success_items INTEGER DEFAULT 0,
|
||||
failed_items INTEGER DEFAULT 0,
|
||||
|
||||
-- 참조
|
||||
parent_order_id INTEGER, -- 재주문 시 원주문 참조
|
||||
reference_period TEXT, -- 사용량 조회 기간 (2026-03-01~2026-03-06)
|
||||
|
||||
-- 메타
|
||||
note TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
submitted_at TEXT, -- 실제 제출 시간
|
||||
completed_at TEXT,
|
||||
|
||||
FOREIGN KEY (wholesaler_id) REFERENCES wholesalers(id),
|
||||
FOREIGN KEY (parent_order_id) REFERENCES orders(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 주문 품목 상세
|
||||
# ─────────────────────────────────────────────
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL,
|
||||
|
||||
-- 약품 정보
|
||||
drug_code TEXT NOT NULL, -- PIT3000 약품코드
|
||||
kd_code TEXT, -- 보험코드 (지오영 검색용)
|
||||
product_name TEXT NOT NULL,
|
||||
manufacturer TEXT,
|
||||
|
||||
-- 규격/수량
|
||||
specification TEXT, -- '30T', '300T', '500T'
|
||||
unit_qty INTEGER, -- 규격당 수량 (30, 300, 500)
|
||||
order_qty INTEGER NOT NULL, -- 주문 수량 (단위 개수)
|
||||
total_dose INTEGER, -- 총 정제수 (order_qty * unit_qty)
|
||||
|
||||
-- 주문 근거
|
||||
usage_qty INTEGER, -- 사용량 (조회 기간)
|
||||
current_stock INTEGER, -- 주문 시점 재고
|
||||
|
||||
-- 가격 (선택)
|
||||
unit_price INTEGER,
|
||||
total_price INTEGER,
|
||||
|
||||
-- 상태
|
||||
status TEXT DEFAULT 'pending', -- pending, submitted, success, failed, cancelled
|
||||
|
||||
-- 결과
|
||||
result_code TEXT, -- 'OK', 'OUT_OF_STOCK', 'NOT_FOUND', 'ERROR'
|
||||
result_message TEXT,
|
||||
wholesaler_order_no TEXT, -- 도매상 측 주문번호
|
||||
|
||||
-- 메타
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 주문 로그 (상태 변경 이력)
|
||||
# ─────────────────────────────────────────────
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS order_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL,
|
||||
order_item_id INTEGER, -- NULL이면 주문 전체 로그
|
||||
|
||||
action TEXT NOT NULL, -- 'created', 'submitted', 'success', 'failed', 'cancelled'
|
||||
old_status TEXT,
|
||||
new_status TEXT,
|
||||
|
||||
message TEXT,
|
||||
detail_json TEXT, -- API 응답 등 상세 정보
|
||||
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (order_item_id) REFERENCES order_items(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 주문 컨텍스트 (AI 학습용 스냅샷)
|
||||
# ─────────────────────────────────────────────
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS order_context (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_item_id INTEGER NOT NULL,
|
||||
|
||||
-- 약품 정보
|
||||
drug_code TEXT NOT NULL,
|
||||
product_name TEXT,
|
||||
|
||||
-- 주문 시점 재고
|
||||
stock_at_order INTEGER, -- 주문 시점 현재고
|
||||
|
||||
-- 사용량 분석
|
||||
usage_1d INTEGER, -- 최근 1일 사용량
|
||||
usage_7d INTEGER, -- 최근 7일 사용량
|
||||
usage_30d INTEGER, -- 최근 30일 사용량
|
||||
avg_daily_usage REAL, -- 일평균 사용량 (30일 기준)
|
||||
|
||||
-- 주문 패턴
|
||||
ordered_spec TEXT, -- 주문한 규격 (30T, 300T)
|
||||
ordered_qty INTEGER, -- 주문 수량 (단위 개수)
|
||||
ordered_dose INTEGER, -- 주문 총 정제수
|
||||
|
||||
-- 규격 선택 이유 (AI 분석용)
|
||||
available_specs TEXT, -- 가능한 규격들 JSON ["30T", "300T"]
|
||||
spec_stocks TEXT, -- 규격별 도매상 재고 JSON {"30T": 50, "300T": 0}
|
||||
selection_reason TEXT, -- 'stock_available', 'best_fit', 'only_option', 'user_choice'
|
||||
|
||||
-- 예측 vs 실제 (나중에 업데이트)
|
||||
days_until_stockout REAL, -- 주문 시점 예상 재고 소진일
|
||||
actual_reorder_days INTEGER, -- 실제 재주문까지 일수 (나중에 업데이트)
|
||||
|
||||
-- 메타
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (order_item_id) REFERENCES order_items(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 일별 사용량 추적 (시계열 데이터)
|
||||
# ─────────────────────────────────────────────
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS daily_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
drug_code TEXT NOT NULL,
|
||||
usage_date TEXT NOT NULL, -- YYYY-MM-DD
|
||||
|
||||
-- 처방 데이터
|
||||
rx_count INTEGER DEFAULT 0, -- 처방 건수
|
||||
rx_qty INTEGER DEFAULT 0, -- 처방 수량 (정제수)
|
||||
|
||||
-- POS 데이터 (일반약)
|
||||
pos_count INTEGER DEFAULT 0,
|
||||
pos_qty INTEGER DEFAULT 0,
|
||||
|
||||
-- 집계
|
||||
total_qty INTEGER DEFAULT 0,
|
||||
|
||||
-- 재고 스냅샷
|
||||
stock_start INTEGER, -- 시작 재고
|
||||
stock_end INTEGER, -- 종료 재고
|
||||
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE(drug_code, usage_date)
|
||||
)
|
||||
''')
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# AI 분석 결과/패턴
|
||||
# ─────────────────────────────────────────────
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS order_patterns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
drug_code TEXT NOT NULL,
|
||||
|
||||
-- 분석 기간
|
||||
analysis_date TEXT NOT NULL,
|
||||
analysis_period_days INTEGER,
|
||||
|
||||
-- 사용 패턴
|
||||
avg_daily_usage REAL,
|
||||
usage_stddev REAL, -- 사용량 표준편차 (변동성)
|
||||
peak_usage INTEGER, -- 최대 사용량
|
||||
|
||||
-- 주문 패턴
|
||||
typical_order_spec TEXT, -- 주로 주문하는 규격
|
||||
typical_order_qty INTEGER, -- 주로 주문하는 수량
|
||||
order_frequency_days REAL, -- 평균 주문 주기 (일)
|
||||
|
||||
-- AI 추천
|
||||
recommended_spec TEXT, -- 추천 규격
|
||||
recommended_qty INTEGER, -- 추천 수량
|
||||
recommended_reorder_point INTEGER,-- 추천 재주문점 (재고가 이 이하면 주문)
|
||||
confidence_score REAL, -- 추천 신뢰도 (0-1)
|
||||
|
||||
-- 모델 정보
|
||||
model_version TEXT,
|
||||
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE(drug_code, analysis_date)
|
||||
)
|
||||
''')
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 인덱스
|
||||
# ─────────────────────────────────────────────
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_wholesaler ON orders(wholesaler_id)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_items_drug ON order_items(drug_code)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_items_status ON order_items(status)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_context_drug ON order_context(drug_code)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_daily_usage_drug ON daily_usage(drug_code)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(usage_date)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_patterns_drug ON order_patterns(drug_code)')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def generate_order_no(wholesaler_id: str) -> str:
|
||||
"""주문번호 생성 (ORD-GEO-20260306-001)"""
|
||||
prefix_map = {
|
||||
'geoyoung': 'GEO',
|
||||
'sooin': 'SOO',
|
||||
'baekje': 'BAK'
|
||||
}
|
||||
prefix = prefix_map.get(wholesaler_id, 'ORD')
|
||||
date_str = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 오늘 해당 도매상 주문 수 카운트
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*) FROM orders
|
||||
WHERE wholesaler_id = ? AND order_date = ?
|
||||
''', (wholesaler_id, datetime.now().strftime('%Y-%m-%d')))
|
||||
|
||||
count = cursor.fetchone()[0] + 1
|
||||
conn.close()
|
||||
|
||||
return f"ORD-{prefix}-{date_str}-{count:03d}"
|
||||
|
||||
|
||||
def create_order(wholesaler_id: str, items: List[Dict],
|
||||
order_type: str = 'manual',
|
||||
order_session: str = None,
|
||||
reference_period: str = None,
|
||||
note: str = None) -> Dict:
|
||||
"""
|
||||
주문 생성 (draft 상태)
|
||||
|
||||
items: [
|
||||
{
|
||||
'drug_code': '670400830',
|
||||
'kd_code': '670400830',
|
||||
'product_name': '레바미피드정 30T',
|
||||
'manufacturer': '휴온스',
|
||||
'specification': '30T',
|
||||
'unit_qty': 30,
|
||||
'order_qty': 10,
|
||||
'usage_qty': 280,
|
||||
'current_stock': 50
|
||||
}
|
||||
]
|
||||
"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
order_no = generate_order_no(wholesaler_id)
|
||||
now = datetime.now()
|
||||
|
||||
# 주문 헤더 생성
|
||||
cursor.execute('''
|
||||
INSERT INTO orders (
|
||||
order_no, wholesaler_id, order_date, order_time,
|
||||
order_type, order_session, reference_period, note,
|
||||
total_items, total_qty, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft')
|
||||
''', (
|
||||
order_no,
|
||||
wholesaler_id,
|
||||
now.strftime('%Y-%m-%d'),
|
||||
now.strftime('%H:%M:%S'),
|
||||
order_type,
|
||||
order_session,
|
||||
reference_period,
|
||||
note,
|
||||
len(items),
|
||||
sum(item.get('order_qty', 0) for item in items)
|
||||
))
|
||||
|
||||
order_id = cursor.lastrowid
|
||||
|
||||
# 주문 품목 생성
|
||||
for item in items:
|
||||
unit_qty = item.get('unit_qty', 1)
|
||||
order_qty = item.get('order_qty', 0)
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO order_items (
|
||||
order_id, drug_code, kd_code, product_name, manufacturer,
|
||||
specification, unit_qty, order_qty, total_dose,
|
||||
usage_qty, current_stock, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
||||
''', (
|
||||
order_id,
|
||||
item.get('drug_code'),
|
||||
item.get('kd_code'),
|
||||
item.get('product_name'),
|
||||
item.get('manufacturer'),
|
||||
item.get('specification'),
|
||||
unit_qty,
|
||||
order_qty,
|
||||
order_qty * unit_qty,
|
||||
item.get('usage_qty'),
|
||||
item.get('current_stock')
|
||||
))
|
||||
|
||||
# 로그
|
||||
cursor.execute('''
|
||||
INSERT INTO order_logs (order_id, action, new_status, message)
|
||||
VALUES (?, 'created', 'draft', ?)
|
||||
''', (order_id, f'{len(items)}개 품목 주문 생성'))
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'order_id': order_id,
|
||||
'order_no': order_no,
|
||||
'total_items': len(items)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return {'success': False, 'error': str(e)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_order(order_id: int) -> Optional[Dict]:
|
||||
"""주문 조회 (품목 포함)"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('SELECT * FROM orders WHERE id = ?', (order_id,))
|
||||
order = cursor.fetchone()
|
||||
|
||||
if not order:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
cursor.execute('SELECT * FROM order_items WHERE order_id = ?', (order_id,))
|
||||
items = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
**dict(order),
|
||||
'items': [dict(item) for item in items]
|
||||
}
|
||||
|
||||
|
||||
def update_order_status(order_id: int, status: str, message: str = None) -> bool:
|
||||
"""주문 상태 업데이트"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# 현재 상태 조회
|
||||
cursor.execute('SELECT status FROM orders WHERE id = ?', (order_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return False
|
||||
|
||||
old_status = row['status']
|
||||
|
||||
# 상태 업데이트
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
update_fields = ['status = ?', 'updated_at = ?']
|
||||
params = [status, now]
|
||||
|
||||
if status == 'submitted':
|
||||
update_fields.append('submitted_at = ?')
|
||||
params.append(now)
|
||||
elif status in ('completed', 'failed'):
|
||||
update_fields.append('completed_at = ?')
|
||||
params.append(now)
|
||||
|
||||
params.append(order_id)
|
||||
|
||||
cursor.execute(f'''
|
||||
UPDATE orders SET {', '.join(update_fields)} WHERE id = ?
|
||||
''', params)
|
||||
|
||||
# 로그
|
||||
cursor.execute('''
|
||||
INSERT INTO order_logs (order_id, action, old_status, new_status, message)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (order_id, status, old_status, status, message))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_item_result(item_id: int, status: str, result_code: str = None,
|
||||
result_message: str = None, wholesaler_order_no: str = None) -> bool:
|
||||
"""품목 결과 업데이트"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
UPDATE order_items SET
|
||||
status = ?,
|
||||
result_code = ?,
|
||||
result_message = ?,
|
||||
wholesaler_order_no = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (status, result_code, result_message, wholesaler_order_no, item_id))
|
||||
|
||||
# 주문 집계 업데이트
|
||||
cursor.execute('SELECT order_id FROM order_items WHERE id = ?', (item_id,))
|
||||
order_id = cursor.fetchone()['order_id']
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE orders SET
|
||||
success_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'success'),
|
||||
failed_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'failed'),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (order_id, order_id, order_id))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_order_history(wholesaler_id: str = None,
|
||||
start_date: str = None,
|
||||
end_date: str = None,
|
||||
status: str = None,
|
||||
limit: int = 50) -> List[Dict]:
|
||||
"""주문 이력 조회"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = 'SELECT * FROM orders WHERE 1=1'
|
||||
params = []
|
||||
|
||||
if wholesaler_id:
|
||||
query += ' AND wholesaler_id = ?'
|
||||
params.append(wholesaler_id)
|
||||
|
||||
if start_date:
|
||||
query += ' AND order_date >= ?'
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
query += ' AND order_date <= ?'
|
||||
params.append(end_date)
|
||||
|
||||
if status:
|
||||
query += ' AND status = ?'
|
||||
params.append(status)
|
||||
|
||||
query += ' ORDER BY created_at DESC LIMIT ?'
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
orders = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
conn.close()
|
||||
return orders
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# AI 학습용 함수들
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def save_order_context(order_item_id: int, context: Dict) -> bool:
|
||||
"""
|
||||
주문 시점 컨텍스트 저장 (AI 학습용)
|
||||
|
||||
context: {
|
||||
'drug_code': '670400830',
|
||||
'product_name': '레바미피드정',
|
||||
'stock_at_order': 50,
|
||||
'usage_1d': 30,
|
||||
'usage_7d': 180,
|
||||
'usage_30d': 800,
|
||||
'ordered_spec': '30T',
|
||||
'ordered_qty': 10,
|
||||
'available_specs': ['30T', '300T'],
|
||||
'spec_stocks': {'30T': 50, '300T': 0},
|
||||
'selection_reason': 'stock_available'
|
||||
}
|
||||
"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# 일평균 사용량 계산
|
||||
usage_30d = context.get('usage_30d', 0)
|
||||
avg_daily = usage_30d / 30.0 if usage_30d else 0
|
||||
|
||||
# 재고 소진 예상일 계산
|
||||
stock = context.get('stock_at_order', 0)
|
||||
days_until_stockout = stock / avg_daily if avg_daily > 0 else None
|
||||
|
||||
# 주문 총 정제수
|
||||
ordered_qty = context.get('ordered_qty', 0)
|
||||
spec = context.get('ordered_spec', '')
|
||||
unit_qty = int(''.join(filter(str.isdigit, spec))) if spec else 1
|
||||
ordered_dose = ordered_qty * unit_qty
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO order_context (
|
||||
order_item_id, drug_code, product_name,
|
||||
stock_at_order, usage_1d, usage_7d, usage_30d, avg_daily_usage,
|
||||
ordered_spec, ordered_qty, ordered_dose,
|
||||
available_specs, spec_stocks, selection_reason,
|
||||
days_until_stockout
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
order_item_id,
|
||||
context.get('drug_code'),
|
||||
context.get('product_name'),
|
||||
context.get('stock_at_order'),
|
||||
context.get('usage_1d'),
|
||||
context.get('usage_7d'),
|
||||
context.get('usage_30d'),
|
||||
avg_daily,
|
||||
context.get('ordered_spec'),
|
||||
ordered_qty,
|
||||
ordered_dose,
|
||||
json.dumps(context.get('available_specs', []), ensure_ascii=False),
|
||||
json.dumps(context.get('spec_stocks', {}), ensure_ascii=False),
|
||||
context.get('selection_reason'),
|
||||
days_until_stockout
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_daily_usage(drug_code: str, usage_date: str,
|
||||
rx_count: int = 0, rx_qty: int = 0,
|
||||
pos_count: int = 0, pos_qty: int = 0,
|
||||
stock_end: int = None) -> bool:
|
||||
"""일별 사용량 업데이트 (UPSERT)"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
total_qty = rx_qty + pos_qty
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO daily_usage (
|
||||
drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
|
||||
total_qty, stock_end
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(drug_code, usage_date) DO UPDATE SET
|
||||
rx_count = rx_count + excluded.rx_count,
|
||||
rx_qty = rx_qty + excluded.rx_qty,
|
||||
pos_count = pos_count + excluded.pos_count,
|
||||
pos_qty = pos_qty + excluded.pos_qty,
|
||||
total_qty = total_qty + excluded.total_qty,
|
||||
stock_end = COALESCE(excluded.stock_end, stock_end)
|
||||
''', (drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
|
||||
total_qty, stock_end))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_usage_stats(drug_code: str, days: int = 30) -> Dict:
|
||||
"""약품 사용량 통계 조회 (AI 분석용)"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
COUNT(*) as days_with_data,
|
||||
SUM(total_qty) as total_usage,
|
||||
AVG(total_qty) as avg_daily,
|
||||
MAX(total_qty) as max_daily,
|
||||
MIN(total_qty) as min_daily
|
||||
FROM daily_usage
|
||||
WHERE drug_code = ? AND usage_date BETWEEN ? AND ?
|
||||
''', (drug_code, start_date, end_date))
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row and row['total_usage']:
|
||||
return {
|
||||
'drug_code': drug_code,
|
||||
'period_days': days,
|
||||
'days_with_data': row['days_with_data'],
|
||||
'total_usage': row['total_usage'],
|
||||
'avg_daily': round(row['avg_daily'], 2) if row['avg_daily'] else 0,
|
||||
'max_daily': row['max_daily'],
|
||||
'min_daily': row['min_daily']
|
||||
}
|
||||
|
||||
return {
|
||||
'drug_code': drug_code,
|
||||
'period_days': days,
|
||||
'days_with_data': 0,
|
||||
'total_usage': 0,
|
||||
'avg_daily': 0,
|
||||
'max_daily': 0,
|
||||
'min_daily': 0
|
||||
}
|
||||
|
||||
|
||||
def get_order_pattern(drug_code: str) -> Optional[Dict]:
|
||||
"""약품 주문 패턴 조회"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 최근 주문 이력 분석
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
oi.specification,
|
||||
oi.order_qty,
|
||||
oi.total_dose,
|
||||
o.order_date
|
||||
FROM order_items oi
|
||||
JOIN orders o ON oi.order_id = o.id
|
||||
WHERE oi.drug_code = ? AND oi.status = 'success'
|
||||
ORDER BY o.order_date DESC
|
||||
LIMIT 10
|
||||
''', (drug_code,))
|
||||
|
||||
orders = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
if not orders:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
# 가장 많이 사용된 규격
|
||||
spec_counts = {}
|
||||
for o in orders:
|
||||
spec = o['specification']
|
||||
spec_counts[spec] = spec_counts.get(spec, 0) + 1
|
||||
|
||||
typical_spec = max(spec_counts, key=spec_counts.get)
|
||||
|
||||
# 평균 주문 수량
|
||||
typical_qty = sum(o['order_qty'] for o in orders) // len(orders)
|
||||
|
||||
# 주문 주기 계산
|
||||
if len(orders) >= 2:
|
||||
dates = [datetime.strptime(o['order_date'], '%Y-%m-%d') for o in orders]
|
||||
intervals = [(dates[i] - dates[i+1]).days for i in range(len(dates)-1)]
|
||||
avg_interval = sum(intervals) / len(intervals) if intervals else 0
|
||||
else:
|
||||
avg_interval = 0
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
'drug_code': drug_code,
|
||||
'order_count': len(orders),
|
||||
'typical_spec': typical_spec,
|
||||
'typical_qty': typical_qty,
|
||||
'avg_order_interval_days': round(avg_interval, 1),
|
||||
'recent_orders': orders[:5]
|
||||
}
|
||||
|
||||
|
||||
def get_ai_training_data(limit: int = 1000) -> List[Dict]:
|
||||
"""AI 학습용 데이터 추출"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
oc.*,
|
||||
oi.status as order_status,
|
||||
oi.result_code,
|
||||
o.order_date,
|
||||
o.wholesaler_id
|
||||
FROM order_context oc
|
||||
JOIN order_items oi ON oc.order_item_id = oi.id
|
||||
JOIN orders o ON oi.order_id = o.id
|
||||
ORDER BY oc.created_at DESC
|
||||
LIMIT ?
|
||||
''', (limit,))
|
||||
|
||||
data = []
|
||||
for row in cursor.fetchall():
|
||||
item = dict(row)
|
||||
# JSON 필드 파싱
|
||||
if item.get('available_specs'):
|
||||
item['available_specs'] = json.loads(item['available_specs'])
|
||||
if item.get('spec_stocks'):
|
||||
item['spec_stocks'] = json.loads(item['spec_stocks'])
|
||||
data.append(item)
|
||||
|
||||
conn.close()
|
||||
return data
|
||||
|
||||
|
||||
def save_ai_pattern(drug_code: str, pattern: Dict) -> bool:
|
||||
"""AI 분석 결과 저장"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO order_patterns (
|
||||
drug_code, analysis_date, analysis_period_days,
|
||||
avg_daily_usage, usage_stddev, peak_usage,
|
||||
typical_order_spec, typical_order_qty, order_frequency_days,
|
||||
recommended_spec, recommended_qty, recommended_reorder_point,
|
||||
confidence_score, model_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(drug_code, analysis_date) DO UPDATE SET
|
||||
avg_daily_usage = excluded.avg_daily_usage,
|
||||
recommended_spec = excluded.recommended_spec,
|
||||
recommended_qty = excluded.recommended_qty,
|
||||
confidence_score = excluded.confidence_score
|
||||
''', (
|
||||
drug_code,
|
||||
today,
|
||||
pattern.get('period_days', 30),
|
||||
pattern.get('avg_daily_usage'),
|
||||
pattern.get('usage_stddev'),
|
||||
pattern.get('peak_usage'),
|
||||
pattern.get('typical_order_spec'),
|
||||
pattern.get('typical_order_qty'),
|
||||
pattern.get('order_frequency_days'),
|
||||
pattern.get('recommended_spec'),
|
||||
pattern.get('recommended_qty'),
|
||||
pattern.get('recommended_reorder_point'),
|
||||
pattern.get('confidence_score'),
|
||||
pattern.get('model_version', 'v1')
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# 초기화 실행
|
||||
init_db()
|
||||
Reference in New Issue
Block a user