Compare commits
26 Commits
442815b65e
...
a7bcf46aaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7bcf46aaa | ||
|
|
e82f4be4af | ||
|
|
eda0429a85 | ||
|
|
71d1916efb | ||
|
|
c71c9ad678 | ||
|
|
91f8dea5b4 | ||
|
|
d6cf4c2cc1 | ||
|
|
09948c234f | ||
|
|
a23e4bad43 | ||
|
|
1088720081 | ||
|
|
497aeee75f | ||
|
|
0ae4ae66f0 | ||
|
|
232a77006a | ||
|
|
20fc528c2b | ||
|
|
0f69b50c49 | ||
|
|
dc2a992c12 | ||
|
|
21c8124811 | ||
|
|
33c6cd2d5c | ||
|
|
e5744e4f0f | ||
|
|
1720c108b5 | ||
|
|
d842c776c9 | ||
|
|
c1fae04344 | ||
|
|
b6d0fadb3c | ||
|
|
ee300f80ca | ||
|
|
846883cbfa | ||
|
|
29597d55fa |
400
backend/app.py
400
backend/app.py
@ -6,6 +6,9 @@ Flask 웹 서버 - QR 마일리지 적립
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 현재 디렉토리를 Python path에 추가 (PM2 실행 시 utils 모듈 찾기 위함)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Windows 콘솔 UTF-8 강제 (한글 깨짐 방지)
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
@ -39,9 +42,18 @@ except ImportError:
|
||||
logging.warning("OpenAI 라이브러리가 설치되지 않았습니다. AI 분석 기능을 사용할 수 없습니다.")
|
||||
|
||||
# Path setup
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, BACKEND_DIR)
|
||||
from db.dbsetup import DatabaseManager
|
||||
|
||||
# OTC 라벨 프린터 import
|
||||
try:
|
||||
from utils.otc_label_printer import generate_preview_image, print_otc_label
|
||||
OTC_LABEL_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
OTC_LABEL_AVAILABLE = False
|
||||
logging.warning(f"OTC 라벨 프린터 모듈 로드 실패: {e}")
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'pharmacy-qr-mileage-secret-key-2026'
|
||||
|
||||
@ -3627,6 +3639,69 @@ def api_products():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 입고이력 API ====================
|
||||
|
||||
@app.route('/api/drugs/<drug_code>/purchase-history')
|
||||
def api_drug_purchase_history(drug_code):
|
||||
"""
|
||||
약품 입고이력 조회 API
|
||||
- WH_sub: 입고 상세 (약품코드, 수량, 단가)
|
||||
- WH_main: 입고 마스터 (입고일, 도매상코드)
|
||||
- PM_BASE.CD_custom: 도매상명
|
||||
"""
|
||||
try:
|
||||
drug_session = db_manager.get_session('PM_DRUG')
|
||||
|
||||
# 입고이력 조회 (최근 100건)
|
||||
result = drug_session.execute(text("""
|
||||
SELECT TOP 100
|
||||
m.WH_DT_appl as purchase_date,
|
||||
COALESCE(c.CD_NM_custom, m.WH_BUSINAME, '미확인') as supplier_name,
|
||||
CAST(COALESCE(s.WH_NM_item_a, 0) AS INT) as quantity,
|
||||
CAST(COALESCE(s.WH_MY_unit_a, 0) AS INT) as unit_price,
|
||||
c.CD_TEL_charge1 as supplier_tel
|
||||
FROM WH_sub s
|
||||
JOIN WH_main m ON m.WH_NO_stock = s.WH_SR_stock AND m.WH_DT_appl = s.WH_DT_appl
|
||||
LEFT JOIN PM_BASE.dbo.CD_custom c ON m.WH_CD_cust_sale = c.CD_CD_custom
|
||||
WHERE s.DrugCode = :drug_code
|
||||
ORDER BY m.WH_DT_appl DESC
|
||||
"""), {'drug_code': drug_code})
|
||||
|
||||
history = []
|
||||
for row in result.fetchall():
|
||||
# 날짜 포맷팅 (YYYYMMDD -> YYYY-MM-DD)
|
||||
date_str = row.purchase_date or ''
|
||||
if len(date_str) == 8:
|
||||
date_str = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
|
||||
|
||||
history.append({
|
||||
'date': date_str,
|
||||
'supplier': row.supplier_name or '미확인',
|
||||
'quantity': row.quantity or 0,
|
||||
'unit_price': row.unit_price or 0,
|
||||
'supplier_tel': row.supplier_tel or ''
|
||||
})
|
||||
|
||||
# 약품명 조회
|
||||
name_result = drug_session.execute(text("""
|
||||
SELECT GoodsName FROM CD_GOODS WHERE DrugCode = :drug_code
|
||||
"""), {'drug_code': drug_code})
|
||||
name_row = name_result.fetchone()
|
||||
drug_name = name_row[0] if name_row else drug_code
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'drug_code': drug_code,
|
||||
'drug_name': drug_name,
|
||||
'history': history,
|
||||
'count': len(history)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"입고이력 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 위치 정보 API ====================
|
||||
|
||||
@app.route('/api/locations')
|
||||
@ -4088,6 +4163,73 @@ def api_rx_usage():
|
||||
|
||||
mssql_session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# 1년간 사용 환자 3명 이하 약품의 환자 목록 조회 + 조회 기간 내 사용 여부
|
||||
patient_query = text("""
|
||||
WITH PatientUsage AS (
|
||||
SELECT DISTINCT
|
||||
P.DrugCode,
|
||||
M.Paname,
|
||||
MAX(CASE WHEN M.Indate >= :start_date AND M.Indate <= :end_date THEN 1 ELSE 0 END) as used_in_period
|
||||
FROM PS_sub_pharm P
|
||||
JOIN PS_main M ON P.PreSerial = M.PreSerial
|
||||
WHERE M.Indate >= CONVERT(VARCHAR, DATEADD(YEAR, -1, GETDATE()), 112)
|
||||
GROUP BY P.DrugCode, M.Paname
|
||||
)
|
||||
SELECT
|
||||
PU.DrugCode as drug_code,
|
||||
COUNT(*) as patient_count,
|
||||
STUFF((
|
||||
SELECT ', ' + PU2.Paname
|
||||
FROM PatientUsage PU2
|
||||
WHERE PU2.DrugCode = PU.DrugCode
|
||||
ORDER BY PU2.Paname
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as patient_names,
|
||||
STUFF((
|
||||
SELECT ', ' + PU3.Paname
|
||||
FROM PatientUsage PU3
|
||||
WHERE PU3.DrugCode = PU.DrugCode AND PU3.used_in_period = 1
|
||||
ORDER BY PU3.Paname
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as today_patients
|
||||
FROM PatientUsage PU
|
||||
GROUP BY PU.DrugCode
|
||||
HAVING COUNT(*) <= 3
|
||||
""")
|
||||
|
||||
patient_rows = mssql_session.execute(patient_query, {
|
||||
'start_date': start_date_fmt,
|
||||
'end_date': end_date_fmt
|
||||
}).fetchall()
|
||||
patient_map = {row.drug_code: {
|
||||
'count': row.patient_count,
|
||||
'names': row.patient_names,
|
||||
'today': row.today_patients # 오늘 사용한 환자
|
||||
} for row in patient_rows}
|
||||
|
||||
# 조회 기간 내 주문량 조회 (orders.db에서)
|
||||
import sqlite3
|
||||
orders_db_path = os.path.join(os.path.dirname(__file__), 'db', 'orders.db')
|
||||
orders_conn = sqlite3.connect(orders_db_path)
|
||||
orders_cur = orders_conn.cursor()
|
||||
|
||||
# 조회 기간 내 성공한 주문량 집계
|
||||
orders_cur.execute('''
|
||||
SELECT
|
||||
oi.drug_code,
|
||||
SUM(oi.order_qty) as ordered_qty,
|
||||
SUM(oi.total_dose) as ordered_dose
|
||||
FROM order_items oi
|
||||
JOIN orders o ON oi.order_id = o.id
|
||||
WHERE o.order_date >= ? AND o.order_date <= ?
|
||||
AND o.status IN ('submitted', 'success', 'confirmed')
|
||||
AND oi.status IN ('success', 'pending')
|
||||
GROUP BY oi.drug_code
|
||||
''', (start_date, end_date))
|
||||
|
||||
ordered_map = {row[0]: {'qty': row[1] or 0, 'dose': row[2] or 0} for row in orders_cur.fetchall()}
|
||||
orders_conn.close()
|
||||
|
||||
# 전문의약품 품목별 사용량 집계 쿼리 (현재고: IM_total.IM_QT_sale_debit, 위치: CD_item_position.CD_NM_sale)
|
||||
rx_query = text("""
|
||||
SELECT
|
||||
@ -4096,7 +4238,7 @@ def api_rx_usage():
|
||||
ISNULL(G.SplName, '') as supplier,
|
||||
SUM(ISNULL(P.QUAN, 1)) as total_qty,
|
||||
SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) as total_dose,
|
||||
SUM(ISNULL(P.DRUPRICE, 0) * ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) as total_amount,
|
||||
SUM(ISNULL(P.DRUPRICE, 0)) as total_amount, -- DRUPRICE 합계
|
||||
COUNT(DISTINCT P.PreSerial) as prescription_count,
|
||||
COALESCE(NULLIF(G.BARCODE, ''), '') as barcode,
|
||||
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock,
|
||||
@ -4138,6 +4280,16 @@ def api_rx_usage():
|
||||
amount = float(row.total_amount or 0)
|
||||
rx_count = int(row.prescription_count or 0)
|
||||
|
||||
# 소수 환자 약품인지 확인 (1년간 3명 이하)
|
||||
patient_info = patient_map.get(drug_code)
|
||||
|
||||
# 조회 기간 내 주문량
|
||||
order_info = ordered_map.get(drug_code, {'qty': 0, 'dose': 0})
|
||||
ordered_dose = order_info['dose']
|
||||
|
||||
# 잔여 필요량 = 사용량 - 주문량 (음수면 0)
|
||||
remaining_dose = max(0, dose - ordered_dose)
|
||||
|
||||
items.append({
|
||||
'drug_code': drug_code,
|
||||
'product_name': product_name,
|
||||
@ -4149,7 +4301,12 @@ def api_rx_usage():
|
||||
'prescription_count': rx_count,
|
||||
'current_stock': int(row.current_stock or 0), # 현재고
|
||||
'location': row.location or '', # 약국 내 위치
|
||||
'thumbnail': None
|
||||
'thumbnail': None,
|
||||
'patient_count': patient_info['count'] if patient_info else None,
|
||||
'patient_names': patient_info['names'] if patient_info else None,
|
||||
'today_patients': patient_info['today'] if patient_info else None,
|
||||
'ordered_dose': ordered_dose, # 조회 기간 내 주문량
|
||||
'remaining_dose': remaining_dose # 잔여 필요량
|
||||
})
|
||||
|
||||
total_qty += qty
|
||||
@ -5140,6 +5297,237 @@ def api_kims_log_detail(log_id):
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 반품 후보 관리
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@app.route('/admin/return-management')
|
||||
def admin_return_management():
|
||||
"""반품 후보 관리 페이지"""
|
||||
return render_template('admin_return_management.html')
|
||||
|
||||
|
||||
@app.route('/api/return-candidates')
|
||||
def api_return_candidates():
|
||||
"""반품 후보 목록 조회 API (가격 정보 포함)"""
|
||||
import pyodbc
|
||||
|
||||
status = request.args.get('status', '')
|
||||
urgency = request.args.get('urgency', '')
|
||||
search = request.args.get('search', '')
|
||||
sort = request.args.get('sort', 'months_desc')
|
||||
|
||||
try:
|
||||
db_path = os.path.join(BACKEND_DIR, 'db', 'orders.db')
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 기본 쿼리
|
||||
query = "SELECT * FROM return_candidates WHERE 1=1"
|
||||
params = []
|
||||
|
||||
# 상태 필터
|
||||
if status:
|
||||
query += " AND status = ?"
|
||||
params.append(status)
|
||||
|
||||
# 검색어 필터
|
||||
if search:
|
||||
query += " AND (drug_name LIKE ? OR drug_code LIKE ?)"
|
||||
params.extend([f'%{search}%', f'%{search}%'])
|
||||
|
||||
# 긴급도 필터
|
||||
if urgency == 'critical':
|
||||
query += " AND (months_since_use >= 36 OR months_since_purchase >= 36)"
|
||||
elif urgency == 'warning':
|
||||
query += " AND ((months_since_use >= 24 OR months_since_purchase >= 24) AND (COALESCE(months_since_use, 0) < 36 AND COALESCE(months_since_purchase, 0) < 36))"
|
||||
elif urgency == 'normal':
|
||||
query += " AND (COALESCE(months_since_use, 0) < 24 AND COALESCE(months_since_purchase, 0) < 24)"
|
||||
|
||||
# 정렬
|
||||
sort_map = {
|
||||
'months_desc': 'COALESCE(months_since_use, months_since_purchase, 0) DESC',
|
||||
'months_asc': 'COALESCE(months_since_use, months_since_purchase, 0) ASC',
|
||||
'stock_desc': 'current_stock DESC',
|
||||
'name_asc': 'drug_name ASC',
|
||||
'detected_desc': 'detected_at DESC'
|
||||
}
|
||||
query += f" ORDER BY {sort_map.get(sort, 'detected_at DESC')}"
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# 약품코드 목록 추출
|
||||
drug_codes = [row['drug_code'] for row in rows]
|
||||
|
||||
# MSSQL에서 가격 + 위치 정보 조회 (한 번에)
|
||||
price_map = {}
|
||||
location_map = {}
|
||||
if drug_codes:
|
||||
try:
|
||||
mssql_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=5'
|
||||
)
|
||||
mssql_conn = pyodbc.connect(mssql_conn_str, timeout=5)
|
||||
mssql_cursor = mssql_conn.cursor()
|
||||
|
||||
# IN 절 생성 (SQL Injection 방지를 위해 파라미터화)
|
||||
# Price가 없으면 Saleprice, Topprice 순으로 fallback
|
||||
# CD_item_position JOIN으로 위치 정보도 함께 조회
|
||||
placeholders = ','.join(['?' for _ in drug_codes])
|
||||
mssql_cursor.execute(f"""
|
||||
SELECT G.DrugCode,
|
||||
COALESCE(NULLIF(G.Price, 0), NULLIF(G.Saleprice, 0), NULLIF(G.Topprice, 0), 0) as BestPrice,
|
||||
ISNULL(POS.CD_NM_sale, '') as Location
|
||||
FROM CD_GOODS G
|
||||
LEFT JOIN CD_item_position POS ON G.DrugCode = POS.DrugCode
|
||||
WHERE G.DrugCode IN ({placeholders})
|
||||
""", drug_codes)
|
||||
|
||||
for row in mssql_cursor.fetchall():
|
||||
price_map[row[0]] = float(row[1]) if row[1] else 0
|
||||
location_map[row[0]] = row[2] or ''
|
||||
|
||||
mssql_conn.close()
|
||||
except Exception as e:
|
||||
logging.warning(f"MSSQL 가격/위치 조회 실패: {e}")
|
||||
|
||||
# 전체 데이터 조회 (통계용)
|
||||
cursor.execute("SELECT drug_code, current_stock, months_since_use, months_since_purchase FROM return_candidates")
|
||||
all_rows = cursor.fetchall()
|
||||
|
||||
# 긴급도별 금액 합계 계산
|
||||
total_amount = 0
|
||||
critical_amount = 0
|
||||
warning_amount = 0
|
||||
|
||||
for row in all_rows:
|
||||
code = row['drug_code']
|
||||
stock = row['current_stock'] or 0
|
||||
price = price_map.get(code, 0)
|
||||
amount = stock * price
|
||||
|
||||
months_use = row['months_since_use'] or 0
|
||||
months_purchase = row['months_since_purchase'] or 0
|
||||
max_months = max(months_use, months_purchase)
|
||||
|
||||
total_amount += amount
|
||||
if max_months >= 36:
|
||||
critical_amount += amount
|
||||
elif max_months >= 24:
|
||||
warning_amount += amount
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
code = row['drug_code']
|
||||
stock = row['current_stock'] or 0
|
||||
price = price_map.get(code, 0)
|
||||
recoverable = stock * price
|
||||
|
||||
items.append({
|
||||
'id': row['id'],
|
||||
'drug_code': code,
|
||||
'drug_name': row['drug_name'],
|
||||
'current_stock': stock,
|
||||
'unit_price': price,
|
||||
'recoverable_amount': recoverable,
|
||||
'location': location_map.get(code, ''),
|
||||
'last_prescription_date': row['last_prescription_date'],
|
||||
'months_since_use': row['months_since_use'],
|
||||
'last_purchase_date': row['last_purchase_date'],
|
||||
'months_since_purchase': row['months_since_purchase'],
|
||||
'status': row['status'],
|
||||
'decision_reason': row['decision_reason'],
|
||||
'detected_at': row['detected_at'],
|
||||
'updated_at': row['updated_at']
|
||||
})
|
||||
|
||||
# 통계 계산
|
||||
cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE months_since_use >= 36 OR months_since_purchase >= 36")
|
||||
critical = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("""SELECT COUNT(*) FROM return_candidates
|
||||
WHERE (months_since_use >= 24 OR months_since_purchase >= 24)
|
||||
AND (COALESCE(months_since_use, 0) < 36 AND COALESCE(months_since_purchase, 0) < 36)""")
|
||||
warning = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE status = 'pending'")
|
||||
pending = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE status IN ('returned', 'keep', 'disposed', 'resolved')")
|
||||
processed = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM return_candidates")
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': items,
|
||||
'stats': {
|
||||
'critical': critical,
|
||||
'warning': warning,
|
||||
'pending': pending,
|
||||
'processed': processed,
|
||||
'total': total,
|
||||
'total_amount': total_amount,
|
||||
'critical_amount': critical_amount,
|
||||
'warning_amount': warning_amount
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/return-candidates/<int:item_id>', methods=['PUT'])
|
||||
def api_update_return_candidate(item_id):
|
||||
"""반품 후보 상태 변경 API"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
new_status = data.get('status')
|
||||
reason = data.get('reason', '')
|
||||
|
||||
if not new_status:
|
||||
return jsonify({'success': False, 'error': '상태가 지정되지 않았습니다'}), 400
|
||||
|
||||
valid_statuses = ['pending', 'reviewed', 'returned', 'keep', 'disposed', 'resolved']
|
||||
if new_status not in valid_statuses:
|
||||
return jsonify({'success': False, 'error': f'유효하지 않은 상태: {new_status}'}), 400
|
||||
|
||||
db_path = os.path.join(BACKEND_DIR, 'db', 'orders.db')
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE return_candidates
|
||||
SET status = ?,
|
||||
decision_reason = ?,
|
||||
reviewed_at = datetime('now', 'localtime'),
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = ?
|
||||
""", (new_status, reason, item_id))
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '항목을 찾을 수 없습니다'}), 404
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify({'success': True, 'message': '상태가 변경되었습니다'})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/kims/interaction-check', methods=['POST'])
|
||||
def api_kims_interaction_check():
|
||||
"""
|
||||
@ -6282,7 +6670,8 @@ def api_preview_otc_label():
|
||||
dosage_instruction = data.get('dosage_instruction', '')
|
||||
usage_tip = data.get('usage_tip', '')
|
||||
|
||||
from utils.otc_label_printer import generate_preview_image
|
||||
if not OTC_LABEL_AVAILABLE:
|
||||
return jsonify({'success': False, 'error': 'OTC 라벨 모듈이 로드되지 않았습니다.'}), 500
|
||||
|
||||
preview_url = generate_preview_image(drug_name, effect, dosage_instruction, usage_tip)
|
||||
|
||||
@ -6314,7 +6703,8 @@ def api_print_otc_label():
|
||||
dosage_instruction = data.get('dosage_instruction', '')
|
||||
usage_tip = data.get('usage_tip', '')
|
||||
|
||||
from utils.otc_label_printer import print_otc_label
|
||||
if not OTC_LABEL_AVAILABLE:
|
||||
return jsonify({'success': False, 'error': 'OTC 라벨 모듈이 로드되지 않았습니다.'}), 500
|
||||
|
||||
success = print_otc_label(drug_name, effect, dosage_instruction, usage_tip)
|
||||
|
||||
|
||||
@ -262,6 +262,148 @@ def api_get_balance():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@baekje_bp.route('/orders/summary-by-kd', methods=['GET'])
|
||||
def api_baekje_orders_by_kd():
|
||||
"""
|
||||
백제약품 주문량 KD코드별 집계 API
|
||||
|
||||
GET /api/baekje/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"order_count": 4,
|
||||
"by_kd_code": {
|
||||
"670400830": {
|
||||
"product_name": "레바미피드정",
|
||||
"spec": "100T",
|
||||
"boxes": 2,
|
||||
"units": 200
|
||||
}
|
||||
},
|
||||
"total_products": 15
|
||||
}
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = flask_request.args.get('start_date', today).strip()
|
||||
end_date = flask_request.args.get('end_date', today).strip()
|
||||
|
||||
def parse_spec(spec: str, product_name: str = '') -> int:
|
||||
"""
|
||||
규격에서 수량 추출 (30T → 30, 100C → 100)
|
||||
"""
|
||||
combined = f"{spec} {product_name}"
|
||||
|
||||
# D(도즈) 단위는 박스 단위로 계산 (140D → 1)
|
||||
if re.search(r'\d+\s*D\b', combined, re.IGNORECASE):
|
||||
return 1
|
||||
|
||||
# T/C/P 단위가 붙은 숫자 추출 (예: 14T, 100C, 30P)
|
||||
qty_match = re.search(r'(\d+)\s*[TCP]\b', combined, re.IGNORECASE)
|
||||
if qty_match:
|
||||
return int(qty_match.group(1))
|
||||
|
||||
# 없으면 spec의 첫 번째 숫자
|
||||
if spec:
|
||||
num_match = re.search(r'(\d+)', spec)
|
||||
if num_match:
|
||||
val = int(num_match.group(1))
|
||||
# mg, ml 같은 용량 단위면 수량 1로 처리
|
||||
if re.search(r'\d+\s*(mg|ml|g)\b', spec, re.IGNORECASE):
|
||||
return 1
|
||||
return val
|
||||
|
||||
return 1
|
||||
|
||||
try:
|
||||
session = get_baekje_session()
|
||||
|
||||
# 주문 목록 + 상세를 한 번에 조회 (include_details=True)
|
||||
# 접수 상태(확정 전)도 포함됨!
|
||||
orders_result = session.get_order_list(start_date, end_date, include_details=True)
|
||||
|
||||
if not orders_result.get('success'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': orders_result.get('error', 'ORDERS_FETCH_FAILED'),
|
||||
'by_kd_code': {},
|
||||
'order_count': 0
|
||||
})
|
||||
|
||||
orders = orders_result.get('orders', [])
|
||||
|
||||
if not orders:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'order_count': 0,
|
||||
'period': {'start': start_date, 'end': end_date},
|
||||
'by_kd_code': {},
|
||||
'total_products': 0,
|
||||
'pending_count': 0,
|
||||
'approved_count': 0
|
||||
})
|
||||
|
||||
# KD코드별 집계 (items가 이미 각 order에 포함됨)
|
||||
kd_summary = {}
|
||||
|
||||
for order in orders:
|
||||
for item in order.get('items', []):
|
||||
# 취소 상태 제외
|
||||
status = item.get('status', '').strip()
|
||||
if '취소' in status or '삭제' in status:
|
||||
continue
|
||||
|
||||
# 백제는 kd_code가 insurance_code(BOHUM_CD)에 있음
|
||||
kd_code = item.get('kd_code', '') or item.get('insurance_code', '')
|
||||
if not kd_code:
|
||||
continue
|
||||
|
||||
product_name = item.get('product_name', '')
|
||||
spec = item.get('spec', '')
|
||||
quantity = item.get('quantity', 0) or item.get('order_qty', 0)
|
||||
per_unit = parse_spec(spec, product_name)
|
||||
total_units = quantity * per_unit
|
||||
|
||||
if kd_code not in kd_summary:
|
||||
kd_summary[kd_code] = {
|
||||
'product_name': product_name,
|
||||
'spec': spec,
|
||||
'boxes': 0,
|
||||
'units': 0
|
||||
}
|
||||
|
||||
kd_summary[kd_code]['boxes'] += quantity
|
||||
kd_summary[kd_code]['units'] += total_units
|
||||
|
||||
pending_count = orders_result.get('pending_count', 0)
|
||||
approved_count = orders_result.get('approved_count', 0)
|
||||
|
||||
logger.info(f"백제 주문량 집계: {start_date}~{end_date}, {len(orders)}건 (접수:{pending_count}, 승인:{approved_count}), {len(kd_summary)}개 품목")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'order_count': len(orders),
|
||||
'pending_count': pending_count, # 접수 상태 (확정 전)
|
||||
'approved_count': approved_count, # 승인 상태 (확정됨)
|
||||
'period': {'start': start_date, 'end': end_date},
|
||||
'by_kd_code': kd_summary,
|
||||
'total_products': len(kd_summary)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"백제 주문량 집계 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'API_ERROR',
|
||||
'message': str(e),
|
||||
'by_kd_code': {},
|
||||
'order_count': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@baekje_bp.route('/monthly-sales', methods=['GET'])
|
||||
def api_get_monthly_sales():
|
||||
"""
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
"""
|
||||
바코드가 있는 제품 샘플 조회
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from db.dbsetup import DatabaseManager
|
||||
from sqlalchemy import text
|
||||
|
||||
def check_barcode_samples():
|
||||
"""바코드가 있는 제품 샘플 조회"""
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
try:
|
||||
session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# 바코드가 있는 제품 샘플 조회
|
||||
query = text("""
|
||||
SELECT TOP 10
|
||||
S.DrugCode,
|
||||
S.BARCODE,
|
||||
G.GoodsName,
|
||||
S.SL_NM_cost_a as price
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE S.BARCODE IS NOT NULL AND S.BARCODE != ''
|
||||
ORDER BY S.SL_NO_order DESC
|
||||
""")
|
||||
|
||||
results = session.execute(query).fetchall()
|
||||
|
||||
print('=' * 100)
|
||||
print('바코드가 있는 제품 샘플 (최근 10개)')
|
||||
print('=' * 100)
|
||||
for r in results:
|
||||
barcode = r.BARCODE if r.BARCODE else '(없음)'
|
||||
goods_name = r.GoodsName if r.GoodsName else '(약품명 없음)'
|
||||
print(f'DrugCode: {r.DrugCode:20} | BARCODE: {barcode:20} | 제품명: {goods_name}')
|
||||
print('=' * 100)
|
||||
|
||||
# 바코드 통계
|
||||
stats_query = text("""
|
||||
SELECT
|
||||
COUNT(DISTINCT DrugCode) as total_drugs,
|
||||
COUNT(DISTINCT BARCODE) as total_barcodes,
|
||||
SUM(CASE WHEN BARCODE IS NOT NULL AND BARCODE != '' THEN 1 ELSE 0 END) as with_barcode,
|
||||
COUNT(*) as total_sales
|
||||
FROM SALE_SUB
|
||||
""")
|
||||
|
||||
stats = session.execute(stats_query).fetchone()
|
||||
|
||||
print('\n바코드 통계')
|
||||
print('=' * 100)
|
||||
print(f'전체 제품 수 (DrugCode): {stats.total_drugs:,}')
|
||||
print(f'바코드 종류 수: {stats.total_barcodes:,}')
|
||||
print(f'바코드가 있는 판매 건수: {stats.with_barcode:,}')
|
||||
print(f'전체 판매 건수: {stats.total_sales:,}')
|
||||
print(f'바코드 보유율: {stats.with_barcode / stats.total_sales * 100:.2f}%')
|
||||
print('=' * 100)
|
||||
|
||||
except Exception as e:
|
||||
print(f"오류 발생: {e}")
|
||||
finally:
|
||||
db_manager.close_all()
|
||||
|
||||
if __name__ == '__main__':
|
||||
check_barcode_samples()
|
||||
@ -1,8 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys; sys.path.insert(0, '.'); import wholesale_path
|
||||
from wholesale import SooinSession
|
||||
|
||||
s = SooinSession()
|
||||
s.login()
|
||||
cart = s.get_cart()
|
||||
print(f"장바구니: {cart['total_items']}개, {cart['total_amount']:,}원")
|
||||
@ -1,11 +0,0 @@
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('db/orders.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = [r[0] for r in cursor.fetchall()]
|
||||
print('Tables:', tables)
|
||||
for t in tables:
|
||||
cursor.execute(f"PRAGMA table_info({t})")
|
||||
cols = [r[1] for r in cursor.fetchall()]
|
||||
print(f" {t}: {cols}")
|
||||
conn.close()
|
||||
@ -1,13 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('db/orders.db')
|
||||
|
||||
# 테이블 목록
|
||||
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
print('=== orders.db 테이블 ===')
|
||||
for t in tables:
|
||||
count = conn.execute(f'SELECT COUNT(*) FROM {t[0]}').fetchone()[0]
|
||||
print(f' {t[0]}: {count}개 레코드')
|
||||
|
||||
conn.close()
|
||||
@ -1,28 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('db/paai_logs.db')
|
||||
|
||||
# 테이블 목록
|
||||
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = cursor.fetchall()
|
||||
print('테이블 목록:', [t[0] for t in tables])
|
||||
|
||||
# 로그 개수
|
||||
count = conn.execute('SELECT COUNT(*) FROM paai_logs').fetchone()[0]
|
||||
print(f'PAAI 로그 수: {count}개')
|
||||
|
||||
# 최근 로그
|
||||
print('\n최근 로그 3개:')
|
||||
recent = conn.execute('SELECT id, created_at, patient_name, status FROM paai_logs ORDER BY id DESC LIMIT 3').fetchall()
|
||||
for r in recent:
|
||||
print(f' #{r[0]} | {r[1]} | {r[2]} | {r[3]}')
|
||||
|
||||
# 피드백 통계
|
||||
feedback = conn.execute('SELECT feedback_useful, COUNT(*) FROM paai_logs GROUP BY feedback_useful').fetchall()
|
||||
print('\n피드백 통계:')
|
||||
for f in feedback:
|
||||
label = '유용' if f[0] == 1 else ('아님' if f[0] == 0 else '미응답')
|
||||
print(f' {label}: {f[1]}건')
|
||||
|
||||
conn.close()
|
||||
@ -1,23 +0,0 @@
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('db/mileage.db')
|
||||
c = conn.cursor()
|
||||
|
||||
# 테이블 구조
|
||||
c.execute("SELECT sql FROM sqlite_master WHERE name='pets'")
|
||||
print("=== PETS TABLE SCHEMA ===")
|
||||
print(c.fetchone())
|
||||
|
||||
# 샘플 데이터
|
||||
c.execute("SELECT * FROM pets LIMIT 5")
|
||||
print("\n=== SAMPLE DATA ===")
|
||||
for row in c.fetchall():
|
||||
print(row)
|
||||
|
||||
# 컬럼명
|
||||
c.execute("PRAGMA table_info(pets)")
|
||||
print("\n=== COLUMNS ===")
|
||||
for col in c.fetchall():
|
||||
print(col)
|
||||
|
||||
conn.close()
|
||||
@ -1,83 +0,0 @@
|
||||
"""
|
||||
특정 거래의 SALE_SUB 데이터 확인
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from db.dbsetup import DatabaseManager
|
||||
from sqlalchemy import text
|
||||
|
||||
def check_sale_sub_data(transaction_id):
|
||||
"""특정 거래의 판매 상세 데이터 확인"""
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
try:
|
||||
session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# SALE_SUB 모든 컬럼 조회
|
||||
query = text("""
|
||||
SELECT *
|
||||
FROM SALE_SUB
|
||||
WHERE SL_NO_order = :transaction_id
|
||||
""")
|
||||
|
||||
result = session.execute(query, {'transaction_id': transaction_id}).fetchone()
|
||||
|
||||
if result:
|
||||
print("=" * 80)
|
||||
print(f"거래번호 {transaction_id}의 SALE_SUB 데이터")
|
||||
print("=" * 80)
|
||||
|
||||
# 모든 컬럼 출력
|
||||
for key in result._mapping.keys():
|
||||
value = result._mapping[key]
|
||||
print(f"{key:30} = {value}")
|
||||
|
||||
print("=" * 80)
|
||||
else:
|
||||
print(f"거래번호 {transaction_id}를 찾을 수 없습니다.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"오류 발생: {e}")
|
||||
finally:
|
||||
db_manager.close_all()
|
||||
|
||||
def check_sale_main_data(transaction_id):
|
||||
"""특정 거래의 SALE_MAIN 데이터 확인"""
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
try:
|
||||
session = db_manager.get_session('PM_PRES')
|
||||
|
||||
query = text("""
|
||||
SELECT *
|
||||
FROM SALE_MAIN
|
||||
WHERE SL_NO_order = :transaction_id
|
||||
""")
|
||||
|
||||
result = session.execute(query, {'transaction_id': transaction_id}).fetchone()
|
||||
|
||||
if result:
|
||||
print("\n" + "=" * 80)
|
||||
print(f"거래번호 {transaction_id}의 SALE_MAIN 데이터")
|
||||
print("=" * 80)
|
||||
|
||||
for key in result._mapping.keys():
|
||||
value = result._mapping[key]
|
||||
print(f"{key:30} = {value}")
|
||||
|
||||
print("=" * 80)
|
||||
else:
|
||||
print(f"거래번호 {transaction_id}를 찾을 수 없습니다.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"오류 발생: {e}")
|
||||
finally:
|
||||
db_manager.close_all()
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 스크린샷의 거래번호
|
||||
check_sale_sub_data('20260123000261')
|
||||
check_sale_main_data('20260123000261')
|
||||
@ -1,21 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys; sys.path.insert(0, '.'); import wholesale_path
|
||||
from wholesale import SooinSession
|
||||
|
||||
s = SooinSession()
|
||||
s.login()
|
||||
|
||||
cart = s.get_cart()
|
||||
print(f'성공: {cart["success"]}')
|
||||
print(f'품목 수: {cart["total_items"]}')
|
||||
print(f'총액: {cart["total_amount"]:,}원')
|
||||
print()
|
||||
|
||||
if cart['items']:
|
||||
print('=== 장바구니 품목 ===')
|
||||
for item in cart['items']:
|
||||
status = '✅' if item.get('active') else '❌취소'
|
||||
name = item['product_name'][:30]
|
||||
print(f"{status} {name:30} x{item['quantity']} = {item['amount']:,}원")
|
||||
else:
|
||||
print('🛒 장바구니 비어있음')
|
||||
@ -1,54 +0,0 @@
|
||||
"""
|
||||
SALE_MAIN 테이블 컬럼 확인 스크립트
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from db.dbsetup import DatabaseManager
|
||||
from sqlalchemy import text
|
||||
|
||||
def check_sale_table_columns(table_name):
|
||||
"""테이블의 모든 컬럼 확인"""
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
try:
|
||||
session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# SQL Server에서 테이블 컬럼 정보 조회
|
||||
query = text(f"""
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
DATA_TYPE,
|
||||
CHARACTER_MAXIMUM_LENGTH,
|
||||
IS_NULLABLE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = '{table_name}'
|
||||
ORDER BY ORDINAL_POSITION
|
||||
""")
|
||||
|
||||
columns = session.execute(query).fetchall()
|
||||
|
||||
print("=" * 80)
|
||||
print(f"{table_name} 테이블 컬럼 목록")
|
||||
print("=" * 80)
|
||||
|
||||
for col in columns:
|
||||
nullable = "NULL" if col.IS_NULLABLE == 'YES' else "NOT NULL"
|
||||
max_len = f"({col.CHARACTER_MAXIMUM_LENGTH})" if col.CHARACTER_MAXIMUM_LENGTH else ""
|
||||
print(f"{col.COLUMN_NAME:30} {col.DATA_TYPE}{max_len:20} {nullable}")
|
||||
|
||||
print("=" * 80)
|
||||
print(f"총 {len(columns)}개 컬럼")
|
||||
print("=" * 80)
|
||||
|
||||
except Exception as e:
|
||||
print(f"오류 발생: {e}")
|
||||
finally:
|
||||
db_manager.close_all()
|
||||
|
||||
if __name__ == '__main__':
|
||||
check_sale_table_columns('SALE_MAIN')
|
||||
print("\n\n")
|
||||
check_sale_table_columns('SALE_SUB')
|
||||
50
backend/create_limits_table.py
Normal file
50
backend/create_limits_table.py
Normal file
@ -0,0 +1,50 @@
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('db/orders.db')
|
||||
cur = conn.cursor()
|
||||
|
||||
# wholesaler_limits 테이블 생성
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS wholesaler_limits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wholesaler_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
-- 한도 설정
|
||||
monthly_limit INTEGER DEFAULT 0, -- 월 한도 (원)
|
||||
warning_threshold REAL DEFAULT 0.9, -- 경고 임계값 (90%)
|
||||
|
||||
-- 우선순위
|
||||
priority INTEGER DEFAULT 1, -- 1이 최우선
|
||||
|
||||
-- 상태
|
||||
is_active INTEGER DEFAULT 1,
|
||||
|
||||
-- 메타
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 기본 데이터 삽입 (각 2000만원)
|
||||
wholesalers = [
|
||||
('geoyoung', 20000000, 0.9, 1),
|
||||
('sooin', 20000000, 0.9, 2),
|
||||
('baekje', 20000000, 0.9, 3),
|
||||
]
|
||||
|
||||
for ws_id, limit, threshold, priority in wholesalers:
|
||||
cur.execute('''
|
||||
INSERT OR REPLACE INTO wholesaler_limits
|
||||
(wholesaler_id, monthly_limit, warning_threshold, priority)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (ws_id, limit, threshold, priority))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 확인
|
||||
cur.execute('SELECT * FROM wholesaler_limits')
|
||||
print('=== wholesaler_limits 생성 완료 ===')
|
||||
for row in cur.fetchall():
|
||||
print(row)
|
||||
|
||||
conn.close()
|
||||
@ -478,6 +478,264 @@ def api_get_monthly_sales():
|
||||
}), 501
|
||||
|
||||
|
||||
# ========== 주문 조회 API ==========
|
||||
|
||||
@geoyoung_bp.route('/order-list', methods=['GET'])
|
||||
def api_geoyoung_order_list():
|
||||
"""
|
||||
지오영 주문 목록 조회 API
|
||||
|
||||
GET /api/geoyoung/order-list?start_date=2026-03-01&end_date=2026-03-07
|
||||
|
||||
Query Parameters:
|
||||
start_date: 시작일 (YYYY-MM-DD), 기본값 30일 전
|
||||
end_date: 종료일 (YYYY-MM-DD), 기본값 오늘
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"orders": [{
|
||||
"order_num": "DA2603-0006409",
|
||||
"order_date": "2026-03-07",
|
||||
"order_time": "09:08:55",
|
||||
"total_amount": 132020,
|
||||
"item_count": 3,
|
||||
"status": "출고확정"
|
||||
}, ...],
|
||||
"total_count": 5,
|
||||
"start_date": "2026-03-01",
|
||||
"end_date": "2026-03-07"
|
||||
}
|
||||
"""
|
||||
start_date = request.args.get('start_date', '').strip()
|
||||
end_date = request.args.get('end_date', '').strip()
|
||||
|
||||
try:
|
||||
session = get_geo_session()
|
||||
result = session.get_order_list(start_date or None, end_date or None)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"지오영 주문 목록 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'API_ERROR',
|
||||
'message': str(e),
|
||||
'orders': [],
|
||||
'total_count': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@geoyoung_bp.route('/order-detail/<order_num>', methods=['GET'])
|
||||
def api_geoyoung_order_detail(order_num):
|
||||
"""
|
||||
지오영 주문 상세 조회 API
|
||||
|
||||
GET /api/geoyoung/order-detail/DA2603-0006409
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"order_num": "DA2603-0006409",
|
||||
"order_date": "2026-03-07",
|
||||
"order_time": "09:08:55",
|
||||
"items": [{
|
||||
"product_code": "008709",
|
||||
"kd_code": "670400830",
|
||||
"product_name": "레바미피드정100mg",
|
||||
"spec": "100mg",
|
||||
"quantity": 10,
|
||||
"unit_price": 500,
|
||||
"amount": 5000
|
||||
}, ...],
|
||||
"total_amount": 132020,
|
||||
"item_count": 3
|
||||
}
|
||||
"""
|
||||
try:
|
||||
session = get_geo_session()
|
||||
result = session.get_order_detail(order_num)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"지오영 주문 상세 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'API_ERROR',
|
||||
'message': str(e),
|
||||
'order_num': order_num,
|
||||
'items': [],
|
||||
'total_amount': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@geoyoung_bp.route('/orders/summary-by-kd', methods=['GET'])
|
||||
def api_geoyoung_orders_by_kd():
|
||||
"""
|
||||
지오영 주문량 KD코드별 집계 API
|
||||
|
||||
GET /api/geoyoung/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"order_count": 4,
|
||||
"by_kd_code": {
|
||||
"670400830": {
|
||||
"product_name": "레바미피드정",
|
||||
"spec": "100T",
|
||||
"boxes": 2,
|
||||
"units": 200
|
||||
}
|
||||
},
|
||||
"total_products": 15
|
||||
}
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = request.args.get('start_date', today).strip()
|
||||
end_date = request.args.get('end_date', today).strip()
|
||||
|
||||
def parse_spec(spec: str, product_name: str = '') -> int:
|
||||
"""
|
||||
규격에서 수량 추출 (30T → 30, 100C → 100)
|
||||
|
||||
단위 처리:
|
||||
- T/C/P: 정/캡슐/포 → 숫자 그대로 (30T → 30)
|
||||
- D: 도즈/분사 → 1로 처리 (140D → 1, 박스 단위)
|
||||
- mg/ml/g: 용량 → 1로 처리
|
||||
"""
|
||||
combined = f"{spec} {product_name}"
|
||||
|
||||
# D(도즈) 단위는 박스 단위로 계산 (140D → 1)
|
||||
if re.search(r'\d+\s*D\b', combined, re.IGNORECASE):
|
||||
return 1
|
||||
|
||||
# T/C/P 단위가 붙은 숫자 추출 (예: 14T, 100C, 30P)
|
||||
qty_match = re.search(r'(\d+)\s*[TCP]\b', combined, re.IGNORECASE)
|
||||
if qty_match:
|
||||
return int(qty_match.group(1))
|
||||
|
||||
# 없으면 spec의 첫 번째 숫자 (mg, ml 등 용량일 수 있음 - 기본값 1)
|
||||
if spec:
|
||||
num_match = re.search(r'(\d+)', spec)
|
||||
if num_match:
|
||||
val = int(num_match.group(1))
|
||||
# mg, ml 같은 용량 단위면 수량 1로 처리
|
||||
if re.search(r'\d+\s*(mg|ml|g)\b', spec, re.IGNORECASE):
|
||||
return 1
|
||||
return val
|
||||
|
||||
return 1
|
||||
|
||||
try:
|
||||
session = get_geo_session()
|
||||
|
||||
# 주문 목록 조회 (items 포함)
|
||||
orders_result = session.get_order_list(start_date, end_date)
|
||||
|
||||
if not orders_result.get('success'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': orders_result.get('error', 'ORDERS_FETCH_FAILED'),
|
||||
'by_kd_code': {},
|
||||
'order_count': 0
|
||||
})
|
||||
|
||||
orders = orders_result.get('orders', [])
|
||||
|
||||
# 각 주문의 items에 KD코드 추가 (enrich)
|
||||
for order in orders:
|
||||
items = order.get('items', [])
|
||||
if items:
|
||||
session._enrich_kd_codes(items)
|
||||
|
||||
# KD코드별 집계
|
||||
kd_summary = {}
|
||||
|
||||
for order in orders:
|
||||
# 지오영은 get_order_list에서 items도 같이 반환
|
||||
for item in order.get('items', []):
|
||||
# 취소/삭제 상태 제외
|
||||
status = item.get('status', '').strip()
|
||||
if '취소' in status or '삭제' in status:
|
||||
continue
|
||||
|
||||
kd_code = item.get('kd_code', '')
|
||||
if not kd_code:
|
||||
continue
|
||||
|
||||
product_name = item.get('product_name', '')
|
||||
spec = item.get('spec', '')
|
||||
quantity = item.get('quantity', 0) or item.get('order_qty', 0)
|
||||
per_unit = parse_spec(spec, product_name)
|
||||
total_units = quantity * per_unit
|
||||
|
||||
if kd_code not in kd_summary:
|
||||
kd_summary[kd_code] = {
|
||||
'product_name': product_name,
|
||||
'spec': spec,
|
||||
'boxes': 0,
|
||||
'units': 0
|
||||
}
|
||||
|
||||
kd_summary[kd_code]['boxes'] += quantity
|
||||
kd_summary[kd_code]['units'] += total_units
|
||||
|
||||
logger.info(f"지오영 주문량 집계: {start_date}~{end_date}, {len(orders)}건 주문, {len(kd_summary)}개 품목")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'order_count': len(orders),
|
||||
'period': {'start': start_date, 'end': end_date},
|
||||
'by_kd_code': kd_summary,
|
||||
'total_products': len(kd_summary)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"지오영 주문량 집계 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'API_ERROR',
|
||||
'message': str(e),
|
||||
'by_kd_code': {},
|
||||
'order_count': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@geoyoung_bp.route('/order-today', methods=['GET'])
|
||||
def api_geoyoung_order_today():
|
||||
"""
|
||||
지오영 오늘 주문 요약 API
|
||||
|
||||
GET /api/geoyoung/order-today
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"date": "2026-03-07",
|
||||
"order_count": 3,
|
||||
"total_amount": 450000,
|
||||
"item_count": 15,
|
||||
"orders": [...]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
session = get_geo_session()
|
||||
result = session.get_today_order_summary()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"지오영 오늘 주문 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'API_ERROR',
|
||||
'message': str(e),
|
||||
'date': '',
|
||||
'order_count': 0,
|
||||
'total_amount': 0
|
||||
}), 500
|
||||
|
||||
|
||||
# ========== 하위 호환성 ==========
|
||||
|
||||
# 기존 코드에서 직접 클래스 참조하는 경우를 위해
|
||||
|
||||
@ -898,12 +898,45 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
|
||||
# ─────────────────────────────────────────
|
||||
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:
|
||||
# 장바구니 추가
|
||||
cart_result = baekje_session.add_to_cart(kd_code, order_qty)
|
||||
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'
|
||||
@ -921,6 +954,7 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
|
||||
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)
|
||||
|
||||
@ -932,7 +966,8 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
|
||||
'ordered_spec': spec,
|
||||
'ordered_qty': order_qty,
|
||||
'selection_reason': 'user_order',
|
||||
'wholesaler_id': 'baekje'
|
||||
'wholesaler_id': 'baekje',
|
||||
'internal_code': internal_code
|
||||
})
|
||||
|
||||
results.append({
|
||||
@ -943,24 +978,38 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
|
||||
'order_qty': order_qty,
|
||||
'status': status,
|
||||
'result_code': result_code,
|
||||
'result_message': result_message
|
||||
'result_message': result_message,
|
||||
'internal_code': internal_code
|
||||
})
|
||||
|
||||
# cart_only=False면 주문 확정까지 진행
|
||||
# cart_only=False면 주문 확정까지 진행 (선별 주문!)
|
||||
if not cart_only and success_count > 0:
|
||||
try:
|
||||
confirm_result = baekje_session.submit_order()
|
||||
if confirm_result.get('success'):
|
||||
update_order_status(order_id, 'submitted',
|
||||
f'백제 주문 확정 완료: {success_count}개')
|
||||
# 결과 메시지 업데이트
|
||||
for r in results:
|
||||
if r['status'] == 'success':
|
||||
r['result_code'] = 'OK'
|
||||
r['result_message'] = '주문 확정 완료'
|
||||
# 이번에 담은 품목의 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'백제 장바구니 담김, 확정 실패: {confirm_result.get("error", "알 수 없는 오류")}')
|
||||
f'백제 장바구니 담김, internal_code 없음')
|
||||
except Exception as e:
|
||||
logger.error(f"백제 주문 확정 오류: {e}")
|
||||
update_order_status(order_id, 'partial',
|
||||
@ -1056,3 +1105,285 @@ def api_ai_order_pattern(drug_code):
|
||||
'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/<wholesaler_id>', 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/<drug_code>/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
|
||||
})
|
||||
|
||||
@ -437,3 +437,236 @@ def api_sooin_order_batch():
|
||||
'failed_count': failed_count,
|
||||
'results': results
|
||||
})
|
||||
|
||||
|
||||
# ========== 주문 조회 API ==========
|
||||
|
||||
@sooin_bp.route('/orders', methods=['GET'])
|
||||
def api_sooin_orders():
|
||||
"""
|
||||
수인약품 주문 목록 조회 API
|
||||
|
||||
GET /api/sooin/orders?start_date=2026-03-01&end_date=2026-03-07
|
||||
|
||||
파라미터:
|
||||
start_date: 시작일 (YYYY-MM-DD), 기본값: 오늘
|
||||
end_date: 종료일 (YYYY-MM-DD), 기본값: 오늘
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"orders": [
|
||||
{
|
||||
"order_num": "202603095091177",
|
||||
"order_date": "2026-03-09",
|
||||
"order_time": "14:30:25",
|
||||
"total_amount": 125000,
|
||||
"item_count": 5,
|
||||
"status": "완료"
|
||||
}
|
||||
],
|
||||
"total_count": 10
|
||||
}
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = flask_request.args.get('start_date', today).strip()
|
||||
end_date = flask_request.args.get('end_date', today).strip()
|
||||
|
||||
try:
|
||||
session = get_sooin_session()
|
||||
result = session.get_order_list(start_date, end_date)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"수인약품 주문 목록 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'ORDERS_ERROR',
|
||||
'message': str(e),
|
||||
'orders': [],
|
||||
'total_count': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@sooin_bp.route('/orders/today-summary', methods=['GET'])
|
||||
def api_sooin_today_summary():
|
||||
"""
|
||||
수인약품 오늘 주문 집계 API
|
||||
|
||||
GET /api/sooin/orders/today-summary
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"date": "2026-03-09",
|
||||
"summary": [
|
||||
{
|
||||
"kd_code": "073100220",
|
||||
"product_name": "코자정50mg",
|
||||
"total_quantity": 10,
|
||||
"total_amount": 150000,
|
||||
"order_count": 3
|
||||
}
|
||||
],
|
||||
"grand_total_amount": 500000,
|
||||
"grand_total_items": 25,
|
||||
"order_count": 5
|
||||
}
|
||||
"""
|
||||
try:
|
||||
session = get_sooin_session()
|
||||
result = session.get_today_order_summary()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"수인약품 오늘 주문 집계 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'TODAY_SUMMARY_ERROR',
|
||||
'message': str(e),
|
||||
'summary': [],
|
||||
'grand_total_amount': 0,
|
||||
'grand_total_items': 0,
|
||||
'order_count': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@sooin_bp.route('/orders/<order_num>', methods=['GET'])
|
||||
def api_sooin_order_detail(order_num):
|
||||
"""
|
||||
수인약품 주문 상세 조회 API
|
||||
|
||||
GET /api/sooin/orders/202603095091177
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"order_num": "202603095091177",
|
||||
"order_date": "2026-03-09",
|
||||
"items": [
|
||||
{
|
||||
"product_code": "32495",
|
||||
"kd_code": "073100220",
|
||||
"product_name": "코자정50mg",
|
||||
"spec": "30T",
|
||||
"quantity": 2,
|
||||
"unit_price": 15000,
|
||||
"amount": 30000
|
||||
}
|
||||
],
|
||||
"total_amount": 125000,
|
||||
"item_count": 5
|
||||
}
|
||||
"""
|
||||
if not order_num or not order_num.isdigit():
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'INVALID_ORDER_NUM',
|
||||
'message': '유효한 주문번호를 입력하세요'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
session = get_sooin_session()
|
||||
result = session.get_order_detail(order_num)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"수인약품 주문 상세 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'ORDER_DETAIL_ERROR',
|
||||
'message': str(e),
|
||||
'order_num': order_num,
|
||||
'items': [],
|
||||
'total_amount': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@sooin_bp.route('/orders/summary-by-kd', methods=['GET'])
|
||||
def api_sooin_orders_by_kd():
|
||||
"""
|
||||
수인약품 주문량 KD코드별 집계 API (병렬 처리)
|
||||
|
||||
GET /api/sooin/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = flask_request.args.get('start_date', today).strip()
|
||||
end_date = flask_request.args.get('end_date', today).strip()
|
||||
|
||||
def parse_spec(spec: str) -> int:
|
||||
if not spec:
|
||||
return 1
|
||||
match = re.search(r'(\d+)', spec)
|
||||
return int(match.group(1)) if match else 1
|
||||
|
||||
try:
|
||||
session = get_sooin_session()
|
||||
|
||||
# 주문 목록 조회
|
||||
orders_result = session.get_order_list(start_date, end_date)
|
||||
|
||||
if not orders_result.get('success'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': orders_result.get('error', 'ORDERS_FETCH_FAILED'),
|
||||
'by_kd_code': {}
|
||||
})
|
||||
|
||||
orders = orders_result.get('orders', [])
|
||||
order_nums = [o.get('order_num') for o in orders if o.get('order_num')]
|
||||
|
||||
# 순차 처리 + 캐시 (캐시 효과 극대화)
|
||||
all_details = []
|
||||
|
||||
for order_num in order_nums:
|
||||
try:
|
||||
detail = session.get_order_detail(order_num)
|
||||
if detail.get('success'):
|
||||
all_details.append(detail)
|
||||
except Exception as e:
|
||||
logger.warning(f"주문 상세 조회 실패: {e}")
|
||||
|
||||
# KD코드별 집계
|
||||
kd_summary = {}
|
||||
|
||||
for detail in all_details:
|
||||
for item in detail.get('items', []):
|
||||
kd_code = item.get('kd_code', '')
|
||||
if not kd_code:
|
||||
continue
|
||||
|
||||
product_name = item.get('product_name', '')
|
||||
spec = item.get('spec', '')
|
||||
quantity = item.get('quantity', 0)
|
||||
per_unit = parse_spec(spec)
|
||||
total_units = quantity * per_unit
|
||||
|
||||
if kd_code not in kd_summary:
|
||||
kd_summary[kd_code] = {
|
||||
'product_name': product_name,
|
||||
'spec': spec,
|
||||
'boxes': 0,
|
||||
'units': 0
|
||||
}
|
||||
|
||||
kd_summary[kd_code]['boxes'] += quantity
|
||||
kd_summary[kd_code]['units'] += total_units
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'order_count': len(order_nums),
|
||||
'period': {'start': start_date, 'end': end_date},
|
||||
'by_kd_code': kd_summary,
|
||||
'total_products': len(kd_summary)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"수인약품 KD코드별 집계 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'SUMMARY_ERROR',
|
||||
'message': str(e),
|
||||
'by_kd_code': {}
|
||||
}), 500
|
||||
|
||||
BIN
backend/static/uploads/pets/pet_5_f89b9542.jpg
Normal file
BIN
backend/static/uploads/pets/pet_5_f89b9542.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
backend/static/uploads/pets/pet_6_0f0409cd.jpeg
Normal file
BIN
backend/static/uploads/pets/pet_6_0f0409cd.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
BIN
backend/static/uploads/pets/pet_7_fc95b8e7.jpeg
Normal file
BIN
backend/static/uploads/pets/pet_7_fc95b8e7.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
backend/static/uploads/pets/pet_8_48666e98.jpeg
Normal file
BIN
backend/static/uploads/pets/pet_8_48666e98.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@ -924,7 +924,8 @@
|
||||
const txs = detailData.mileage.transactions;
|
||||
container.innerHTML = txs.map(tx => {
|
||||
const isPositive = tx.points > 0;
|
||||
const date = tx.created_at ? new Date(tx.created_at).toLocaleString('ko-KR', {
|
||||
// DB는 UTC로 저장 → 'Z' 붙여서 UTC로 해석 → KST로 표시
|
||||
const date = tx.created_at ? new Date(tx.created_at + 'Z').toLocaleString('ko-KR', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
}) : '';
|
||||
|
||||
@ -1075,8 +1076,8 @@
|
||||
}
|
||||
|
||||
container.innerHTML = detailData.interests.map(item => {
|
||||
// 날짜 포맷
|
||||
const date = item.created_at ? new Date(item.created_at).toLocaleString('ko-KR', {
|
||||
// 날짜 포맷 (DB는 UTC → KST 변환)
|
||||
const date = item.created_at ? new Date(item.created_at + 'Z').toLocaleString('ko-KR', {
|
||||
month: 'short', day: 'numeric'
|
||||
}) : '';
|
||||
|
||||
|
||||
@ -358,7 +358,8 @@
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.logs.map(log => {
|
||||
const time = new Date(log.created_at).toLocaleString('ko-KR', {
|
||||
// DB는 UTC로 저장 → 'Z' 붙여서 UTC로 해석 → KST로 표시
|
||||
const time = new Date(log.created_at + 'Z').toLocaleString('ko-KR', {
|
||||
month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
|
||||
@ -590,6 +590,135 @@
|
||||
.location-modal-btn.primary { background: #f59e0b; color: #fff; }
|
||||
.location-modal-btn.primary:hover { background: #d97706; }
|
||||
|
||||
/* ── 입고이력 모달 ── */
|
||||
.purchase-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 2000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.purchase-modal.show { display: flex; }
|
||||
.purchase-modal-content {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 0;
|
||||
max-width: 600px;
|
||||
width: 95%;
|
||||
max-height: 80vh;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
animation: modalSlideIn 0.2s ease;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.purchase-modal-header {
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.purchase-modal-header h3 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.purchase-modal-header .drug-name {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.purchase-modal-body {
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.purchase-history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.purchase-history-table th {
|
||||
background: #f8fafc;
|
||||
padding: 12px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.purchase-history-table td {
|
||||
padding: 14px 10px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.purchase-history-table tr:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.supplier-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.supplier-tel {
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
.supplier-tel:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.purchase-date {
|
||||
color: #64748b;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.purchase-qty {
|
||||
font-weight: 600;
|
||||
color: #10b981;
|
||||
}
|
||||
.purchase-price {
|
||||
color: #6b7280;
|
||||
}
|
||||
.purchase-empty {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.purchase-empty .icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.purchase-modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.purchase-modal-btn {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.purchase-modal-btn:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
tbody tr:active {
|
||||
background: #ede9fe;
|
||||
}
|
||||
|
||||
/* ── 가격 ── */
|
||||
.price {
|
||||
font-weight: 600;
|
||||
@ -916,6 +1045,7 @@
|
||||
<!-- 결과 -->
|
||||
<div class="result-count" id="resultCount" style="display:none;">
|
||||
검색 결과: <strong id="resultNum">0</strong>건
|
||||
<span style="margin-left: 16px; color: #94a3b8; font-size: 12px;">💡 행 더블클릭 → 입고이력</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
@ -993,6 +1123,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 입고이력 모달 -->
|
||||
<div class="purchase-modal" id="purchaseModal" onclick="if(event.target===this)closePurchaseModal()">
|
||||
<div class="purchase-modal-content">
|
||||
<div class="purchase-modal-header">
|
||||
<h3>📦 입고 이력</h3>
|
||||
<div class="drug-name" id="purchaseDrugName">-</div>
|
||||
</div>
|
||||
<div class="purchase-modal-body">
|
||||
<table class="purchase-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>도매상</th>
|
||||
<th>입고일</th>
|
||||
<th>수량</th>
|
||||
<th>단가</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="purchaseHistoryBody">
|
||||
<tr><td colspan="4" class="purchase-empty"><div class="icon">📭</div><p>로딩 중...</p></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="purchase-modal-footer">
|
||||
<button class="purchase-modal-btn" onclick="closePurchaseModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let productsData = [];
|
||||
let selectedItem = null;
|
||||
@ -1069,17 +1227,17 @@
|
||||
: '';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<tr ondblclick="openPurchaseModal('${item.drug_code}', '${escapeHtml(item.product_name).replace(/'/g, "\\'")}')">
|
||||
<td style="text-align:center;">
|
||||
${item.thumbnail
|
||||
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
|
||||
: `<div class="product-thumb-placeholder" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')"><svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h12l-4-5z"/></svg></div>`
|
||||
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="event.stopPropagation();openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
|
||||
: `<div class="product-thumb-placeholder" onclick="event.stopPropagation();openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')"><svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h12l-4-5z"/></svg></div>`
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="product-name">
|
||||
${escapeHtml(item.product_name)}
|
||||
${item.is_animal_drug ? `<span class="animal-badge ${item.apc ? 'clickable' : ''}" ${item.apc ? `onclick="printAnimalDrugInfo('${item.apc}', '${escapeHtml(item.product_name)}')" title="클릭하면 약품 안내서 인쇄"` : 'title="APC 없음"'}>🐾 동물약</span>` : ''}
|
||||
${item.is_animal_drug ? `<span class="animal-badge ${item.apc ? 'clickable' : ''}" ${item.apc ? `onclick="event.stopPropagation();printAnimalDrugInfo('${item.apc}', '${escapeHtml(item.product_name)}')" title="클릭하면 약품 안내서 인쇄"` : 'title="APC 없음"'}>🐾 동물약</span>` : ''}
|
||||
${categoryBadge}
|
||||
</div>
|
||||
<div class="product-supplier ${item.is_set ? 'set' : ''}">${escapeHtml(item.supplier) || ''}</div>
|
||||
@ -1092,12 +1250,12 @@
|
||||
<div style="margin-top:4px;">${item.apc ? `<span class="code code-apc">${item.apc}</span>` : `<span class="code code-apc-na">APC미지정</span>`}</div>`
|
||||
: (item.barcode ? `<span class="code code-barcode">${item.barcode}</span>` : `<span class="code code-na">없음</span>`)}</td>
|
||||
<td>${item.location
|
||||
? `<span class="location-badge" onclick="openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '${escapeHtml(item.location)}')">${escapeHtml(item.location)}</span>`
|
||||
: `<span class="location-badge unset" onclick="openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '')">미지정</span>`}</td>
|
||||
? `<span class="location-badge" onclick="event.stopPropagation();openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '${escapeHtml(item.location)}')">${escapeHtml(item.location)}</span>`
|
||||
: `<span class="location-badge unset" onclick="event.stopPropagation();openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '')">미지정</span>`}</td>
|
||||
<td class="stock ${(item.stock || 0) > 0 ? 'in-stock' : 'out-stock'}">${item.stock || 0}${wsStock}</td>
|
||||
<td class="price">${formatPrice(item.sale_price)}</td>
|
||||
<td>
|
||||
<button class="btn-qr" onclick="printQR(${idx})">🏷️ QR</button>
|
||||
<button class="btn-qr" onclick="event.stopPropagation();printQR(${idx})">🏷️ QR</button>
|
||||
</td>
|
||||
</tr>
|
||||
`}).join('');
|
||||
@ -1833,6 +1991,65 @@
|
||||
document.getElementById('locationModal')?.addEventListener('click', e => {
|
||||
if (e.target.id === 'locationModal') closeLocationModal();
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// 입고이력 모달
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
async function openPurchaseModal(drugCode, drugName) {
|
||||
const modal = document.getElementById('purchaseModal');
|
||||
const nameEl = document.getElementById('purchaseDrugName');
|
||||
const tbody = document.getElementById('purchaseHistoryBody');
|
||||
|
||||
nameEl.textContent = drugName || drugCode;
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="purchase-empty"><div class="icon">⏳</div><p>입고이력 조회 중...</p></td></tr>';
|
||||
modal.classList.add('show');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/drugs/${drugCode}/purchase-history`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
if (data.history.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="purchase-empty"><div class="icon">📭</div><p>입고 이력이 없습니다</p></td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = data.history.map(h => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="supplier-name">${escapeHtml(h.supplier)}</div>
|
||||
${h.supplier_tel ? `<div class="supplier-tel" onclick="copyToClipboard('${h.supplier_tel}')" title="클릭하여 복사">📞 ${h.supplier_tel}</div>` : ''}
|
||||
</td>
|
||||
<td class="purchase-date">${h.date}</td>
|
||||
<td class="purchase-qty">${h.quantity.toLocaleString()}</td>
|
||||
<td class="purchase-price">${h.unit_price ? formatPrice(h.unit_price) : '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
} else {
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="purchase-empty"><div class="icon">⚠️</div><p>조회 실패: ${data.error}</p></td></tr>`;
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="purchase-empty"><div class="icon">❌</div><p>오류: ${err.message}</p></td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closePurchaseModal() {
|
||||
document.getElementById('purchaseModal').classList.remove('show');
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast(`📋 ${text} 복사됨`, 'success');
|
||||
}).catch(() => {
|
||||
// fallback
|
||||
const input = document.createElement('input');
|
||||
input.value = text;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
showToast(`📋 ${text} 복사됨`, 'success');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1437
backend/templates/admin_return_management.html
Normal file
1437
backend/templates/admin_return_management.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -37,6 +37,17 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ══════════════════ 주문량 로딩 ══════════════════ */
|
||||
.order-loading {
|
||||
display: inline-block;
|
||||
color: var(--accent-cyan);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ══════════════════ 헤더 ══════════════════ */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
|
||||
@ -391,6 +402,28 @@
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.patient-badge {
|
||||
display: inline-block;
|
||||
background: rgba(156, 163, 175, 0.15);
|
||||
color: #9ca3af;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.patient-badge.has-today {
|
||||
background: rgba(236, 72, 153, 0.2);
|
||||
color: #ec4899;
|
||||
}
|
||||
|
||||
.today-patient {
|
||||
color: #ec4899;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 수량 관련 */
|
||||
.qty-cell {
|
||||
text-align: center;
|
||||
@ -745,7 +778,7 @@
|
||||
<option value="amount_desc">금액 높은순</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="search-btn" onclick="loadUsageData()">🔍 조회</button>
|
||||
<button class="search-btn" onclick="loadUsageData(); loadOrderData();">🔍 조회</button>
|
||||
<button class="search-btn" style="background: linear-gradient(135deg, #a855f7, #7c3aed);" onclick="openBalanceModal()">💰 도매상 잔고</button>
|
||||
</div>
|
||||
|
||||
@ -774,7 +807,7 @@
|
||||
<div class="stat-card emerald">
|
||||
<div class="stat-icon">💰</div>
|
||||
<div class="stat-value" id="statTotalAmount">-</div>
|
||||
<div class="stat-label">총 약가</div>
|
||||
<div class="stat-label">총 매출액</div>
|
||||
</div>
|
||||
<div class="stat-card orange">
|
||||
<div class="stat-icon">🛒</div>
|
||||
@ -803,17 +836,19 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="check-col"><input type="checkbox" class="custom-check" id="checkAll" onchange="toggleCheckAll()"></th>
|
||||
<th style="width:32%">약품</th>
|
||||
<th style="width:28%">약품</th>
|
||||
<th class="center">현재고</th>
|
||||
<th class="center">처방횟수</th>
|
||||
<th class="center">투약량</th>
|
||||
<th class="right">약가</th>
|
||||
<th class="center" style="color:var(--accent-cyan);">주문량</th>
|
||||
<th class="center" style="color:var(--accent-purple);font-size:11px;">선호도매상</th>
|
||||
<th class="right">매출액</th>
|
||||
<th class="center" style="width:90px">주문수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usageTableBody">
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<td colspan="9">
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>데이터 로딩 중...</div>
|
||||
@ -860,6 +895,38 @@
|
||||
<script>
|
||||
let usageData = [];
|
||||
let cart = [];
|
||||
let orderDataByKd = {};
|
||||
let preferredVendors = {}; // 약품별 선호 도매상
|
||||
|
||||
// 선호 도매상 표시
|
||||
function getPreferredVendor(drugCode) {
|
||||
const v = preferredVendors[drugCode];
|
||||
if (!v || !v.success) return '-';
|
||||
const recent = v.recent_vendor;
|
||||
const most = v.most_frequent_vendor;
|
||||
if (!recent && !most) return '-';
|
||||
const shorten = (n) => n ? n.replace('강원','').replace('(주)','').replace('지점','').replace('약품','').substring(0,3) : '';
|
||||
const rn = recent ? shorten(recent.vendor_name) : '';
|
||||
const mn = most ? shorten(most.vendor_name) : '';
|
||||
if (rn === mn && rn) return `<span style="color:#10b981" title="${most?.vendor_name}">${rn}</span>`;
|
||||
let h = '';
|
||||
if (mn) h += `<span style="color:#a855f7" title="최다(${most.order_count}회)">★${mn}</span>`;
|
||||
if (rn && rn !== mn) h += `<br><span style="color:#888" title="최근">▸${rn}</span>`;
|
||||
return h || '-';
|
||||
}
|
||||
|
||||
// 선호 도매상 로드
|
||||
async function loadPreferredVendors(codes) {
|
||||
if (!codes || !codes.length) return;
|
||||
try {
|
||||
const r = await fetch('/api/order/drugs/preferred-vendors', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({drug_codes: codes, period: 365})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) { preferredVendors = d.results; renderTable(); }
|
||||
} catch(e) { console.error('선호도매상 로드 실패:', e); }
|
||||
} // 도매상 주문량 합산 (KD코드별) - 지오영 + 수인
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@ -869,8 +936,90 @@
|
||||
document.getElementById('endDate').value = todayStr;
|
||||
|
||||
loadUsageData();
|
||||
loadOrderData(); // 수인약품 주문량 로드
|
||||
});
|
||||
|
||||
// ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 합산) ────────────────
|
||||
async function loadOrderData() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
|
||||
orderDataLoading = true;
|
||||
orderDataByKd = {};
|
||||
|
||||
try {
|
||||
// 지오영 + 수인 + 백제 병렬 조회
|
||||
const [geoRes, sooinRes, baekjeRes] = await Promise.all([
|
||||
fetch(`/api/geoyoung/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
|
||||
fetch(`/api/sooin/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
|
||||
fetch(`/api/baekje/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false }))
|
||||
]);
|
||||
|
||||
let totalOrders = 0;
|
||||
|
||||
// 지오영 데이터 합산
|
||||
if (geoRes.success && geoRes.by_kd_code) {
|
||||
for (const [kd, data] of Object.entries(geoRes.by_kd_code)) {
|
||||
if (!orderDataByKd[kd]) {
|
||||
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
|
||||
}
|
||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
||||
orderDataByKd[kd].units += data.units || 0;
|
||||
orderDataByKd[kd].sources.push('지오영');
|
||||
}
|
||||
totalOrders += geoRes.order_count || 0;
|
||||
console.log('🏭 지오영 주문량:', Object.keys(geoRes.by_kd_code).length, '품목,', geoRes.order_count, '건');
|
||||
}
|
||||
|
||||
// 수인 데이터 합산
|
||||
if (sooinRes.success && sooinRes.by_kd_code) {
|
||||
for (const [kd, data] of Object.entries(sooinRes.by_kd_code)) {
|
||||
if (!orderDataByKd[kd]) {
|
||||
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
|
||||
}
|
||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
||||
orderDataByKd[kd].units += data.units || 0;
|
||||
orderDataByKd[kd].sources.push('수인');
|
||||
}
|
||||
totalOrders += sooinRes.order_count || 0;
|
||||
console.log('💜 수인 주문량:', Object.keys(sooinRes.by_kd_code).length, '품목,', sooinRes.order_count, '건');
|
||||
}
|
||||
|
||||
// 백제 데이터 합산
|
||||
if (baekjeRes.success && baekjeRes.by_kd_code) {
|
||||
for (const [kd, data] of Object.entries(baekjeRes.by_kd_code)) {
|
||||
if (!orderDataByKd[kd]) {
|
||||
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
|
||||
}
|
||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
||||
orderDataByKd[kd].units += data.units || 0;
|
||||
orderDataByKd[kd].sources.push('백제');
|
||||
}
|
||||
totalOrders += baekjeRes.order_count || 0;
|
||||
console.log('💉 백제 주문량:', Object.keys(baekjeRes.by_kd_code).length, '품목,', baekjeRes.order_count, '건');
|
||||
}
|
||||
|
||||
console.log('📦 3사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문');
|
||||
|
||||
} catch(err) {
|
||||
console.warn('주문량 조회 실패:', err);
|
||||
orderDataByKd = {};
|
||||
} finally {
|
||||
orderDataLoading = false;
|
||||
renderTable(); // 로딩 완료 후 테이블 갱신
|
||||
}
|
||||
}
|
||||
|
||||
// KD코드로 주문량 조회
|
||||
let orderDataLoading = true; // 로딩 상태
|
||||
|
||||
function getOrderedQty(kdCode) {
|
||||
if (orderDataLoading) return '<span class="order-loading">···</span>';
|
||||
const order = orderDataByKd[kdCode];
|
||||
if (!order) return '-';
|
||||
return order.units.toLocaleString();
|
||||
}
|
||||
|
||||
// ──────────────── 데이터 로드 ────────────────
|
||||
function loadUsageData() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
@ -879,7 +1028,7 @@
|
||||
const sort = document.getElementById('sortSelect').value;
|
||||
|
||||
document.getElementById('usageTableBody').innerHTML = `
|
||||
<tr><td colspan="7">
|
||||
<tr><td colspan="9">
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>데이터 로딩 중...</div>
|
||||
@ -904,9 +1053,11 @@
|
||||
document.getElementById('resultCount').textContent = `(${data.items.length}개)`;
|
||||
|
||||
renderTable();
|
||||
// 선호 도매상 로드 (백그라운드)
|
||||
loadPreferredVendors(data.items.map(i => i.drug_code));
|
||||
} else {
|
||||
document.getElementById('usageTableBody').innerHTML = `
|
||||
<tr><td colspan="7">
|
||||
<tr><td colspan="9">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚠️</div>
|
||||
<div>오류: ${data.error}</div>
|
||||
@ -916,7 +1067,7 @@
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('usageTableBody').innerHTML = `
|
||||
<tr><td colspan="7">
|
||||
<tr><td colspan="9">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">❌</div>
|
||||
<div>데이터 로드 실패</div>
|
||||
@ -931,7 +1082,7 @@
|
||||
|
||||
if (usageData.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr><td colspan="7">
|
||||
<tr><td colspan="9">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">💊</div>
|
||||
<div>해당 기간 처방 내역이 없습니다</div>
|
||||
@ -957,7 +1108,7 @@
|
||||
}
|
||||
<div class="product-info">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
<span class="product-code">${item.drug_code}${item.supplier ? ` · ${escapeHtml(item.supplier)}` : ''}${item.location ? ` <span class="location-badge">📍${escapeHtml(item.location)}</span>` : ''}</span>
|
||||
<span class="product-code">${item.drug_code}${item.supplier ? ` · ${escapeHtml(item.supplier)}` : ''}${item.location ? ` <span class="location-badge">📍${escapeHtml(item.location)}</span>` : ''}${item.patient_names ? ` <span class="patient-badge ${item.today_patients ? 'has-today' : ''}" title="${item.patient_count}명 사용${item.today_patients ? ' (오늘: ' + item.today_patients + ')' : ''}">👤${formatPatientNames(item.patient_names, item.today_patients)}</span>` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@ -966,6 +1117,8 @@
|
||||
</td>
|
||||
<td class="qty-cell" style="color:var(--text-secondary);">${item.prescription_count}건</td>
|
||||
<td class="qty-cell ${qtyClass}">${item.total_dose}</td>
|
||||
<td class="qty-cell" style="color:var(--accent-cyan);">${getOrderedQty(item.drug_code)}</td>
|
||||
<td class="qty-cell" style="font-size:10px;">${getPreferredVendor(item.drug_code)}</td>
|
||||
<td style="text-align:right;font-family:'JetBrains Mono',monospace;font-size:12px;">
|
||||
${formatPrice(item.total_amount)}원
|
||||
</td>
|
||||
@ -1156,44 +1309,135 @@
|
||||
// 다중 도매상 선택을 위한 전역 변수
|
||||
let pendingWholesalerItems = {};
|
||||
let pendingOtherItems = [];
|
||||
let wholesalerLimits = {}; // 도매상 한도 캐시
|
||||
|
||||
function openWholesalerSelectModal(itemsByWholesaler, otherItems) {
|
||||
async function openWholesalerSelectModal(itemsByWholesaler, otherItems) {
|
||||
pendingWholesalerItems = itemsByWholesaler;
|
||||
pendingOtherItems = otherItems;
|
||||
|
||||
const modal = document.getElementById('multiWholesalerModal');
|
||||
const body = document.getElementById('multiWholesalerBody');
|
||||
|
||||
// 로딩 표시
|
||||
body.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-muted);">📊 한도 및 월 매출 조회 중...</div>';
|
||||
modal.classList.add('show');
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
|
||||
// 1. 도매상 한도 정보 가져오기
|
||||
try {
|
||||
const res = await fetch('/api/order/wholesaler/limits');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
data.limits.forEach(l => {
|
||||
wholesalerLimits[l.wholesaler_id] = l;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('한도 조회 실패:', e);
|
||||
}
|
||||
|
||||
// 2. 실제 월 매출 가져오기 (도매상 API 호출)
|
||||
const wholesalerConfigs = WHOLESALER_ORDER.map(id => WHOLESALER_CONFIG[id]);
|
||||
await Promise.all(wholesalerConfigs.map(async ws => {
|
||||
try {
|
||||
const salesRes = await fetch(`${ws.salesApi}?year=${year}&month=${month}`);
|
||||
const salesData = await salesRes.json();
|
||||
if (salesData.success && wholesalerLimits[ws.id]) {
|
||||
// 실제 월 매출로 current_usage 업데이트
|
||||
wholesalerLimits[ws.id].current_usage = salesData.total_amount || 0;
|
||||
wholesalerLimits[ws.id].usage_percent = wholesalerLimits[ws.id].monthly_limit > 0
|
||||
? Math.round((salesData.total_amount || 0) / wholesalerLimits[ws.id].monthly_limit * 1000) / 10
|
||||
: 0;
|
||||
wholesalerLimits[ws.id].remaining = wholesalerLimits[ws.id].monthly_limit - (salesData.total_amount || 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`${ws.id} 월매출 조회 실패:`, e);
|
||||
}
|
||||
}));
|
||||
|
||||
const wsIds = Object.keys(itemsByWholesaler);
|
||||
|
||||
// 전체 총액 계산
|
||||
let grandTotal = 0;
|
||||
wsIds.forEach(wsId => {
|
||||
itemsByWholesaler[wsId].forEach(item => {
|
||||
grandTotal += (item.unit_price || 0) * item.qty;
|
||||
});
|
||||
});
|
||||
|
||||
let html = `
|
||||
<div class="multi-ws-summary">
|
||||
<p style="margin-bottom:16px;color:var(--text-secondary);">
|
||||
장바구니에 <b>${wsIds.length}개 도매상</b>의 품목이 있습니다.
|
||||
</p>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<p style="color:var(--text-secondary);margin:0;">
|
||||
장바구니에 <b>${wsIds.length}개 도매상</b>의 품목이 있습니다.
|
||||
</p>
|
||||
<div style="font-size:18px;font-weight:700;color:var(--accent-emerald);font-family:'JetBrains Mono',monospace;">
|
||||
${grandTotal > 0 ? '₩' + grandTotal.toLocaleString() : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 각 도매상별 품목 표시
|
||||
wsIds.forEach(wsId => {
|
||||
const ws = WHOLESALERS[wsId];
|
||||
const items = itemsByWholesaler[wsId];
|
||||
const limit = wholesalerLimits[wsId];
|
||||
|
||||
// 도매상별 소계
|
||||
const wsTotal = items.reduce((sum, item) => sum + (item.unit_price || 0) * item.qty, 0);
|
||||
|
||||
// 한도 정보 계산
|
||||
let limitHtml = '';
|
||||
if (limit) {
|
||||
const afterOrder = limit.current_usage + wsTotal;
|
||||
const afterPercent = (afterOrder / limit.monthly_limit * 100).toFixed(1);
|
||||
const isOver = afterOrder > limit.monthly_limit;
|
||||
const isWarning = afterPercent >= (limit.warning_threshold * 100);
|
||||
|
||||
limitHtml = `
|
||||
<div style="margin-top:8px;padding:8px 12px;background:var(--bg-tertiary);border-radius:6px;font-size:12px;">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
|
||||
<span>월 한도</span>
|
||||
<span style="font-family:'JetBrains Mono',monospace;">${(limit.monthly_limit/10000).toLocaleString()}만원</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
|
||||
<span>이번달 사용</span>
|
||||
<span style="font-family:'JetBrains Mono',monospace;">${(limit.current_usage/10000).toLocaleString()}만원 (${limit.usage_percent}%)</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;color:${isOver ? 'var(--accent-red)' : isWarning ? 'var(--accent-amber)' : 'var(--accent-emerald)'};">
|
||||
<span>주문 후</span>
|
||||
<span style="font-family:'JetBrains Mono',monospace;font-weight:600;">
|
||||
${(afterOrder/10000).toLocaleString()}만원 (${afterPercent}%)
|
||||
${isOver ? ' ⚠️ 초과!' : isWarning ? ' ⚠️' : ' ✓'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="multi-ws-card ${wsId}">
|
||||
<div class="multi-ws-header">
|
||||
<span class="multi-ws-icon">${ws.icon}</span>
|
||||
<img src="${ws.logo}" alt="${ws.name}" style="width:24px;height:24px;object-fit:contain;margin-right:8px;">
|
||||
<span class="multi-ws-name">${ws.name}</span>
|
||||
<span class="multi-ws-count">${items.length}개 품목</span>
|
||||
${wsTotal > 0 ? `<span style="margin-left:auto;margin-right:12px;font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--accent-cyan);">₩${wsTotal.toLocaleString()}</span>` : ''}
|
||||
<label class="multi-ws-checkbox">
|
||||
<input type="checkbox" id="ws_check_${wsId}" checked>
|
||||
<span>포함</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="multi-ws-items">
|
||||
${items.slice(0, 3).map(item => `
|
||||
<div class="multi-ws-item">· ${item.product_name} (${item.qty}개)</div>
|
||||
`).join('')}
|
||||
${items.slice(0, 3).map(item => {
|
||||
const itemAmt = (item.unit_price || 0) * item.qty;
|
||||
return `<div class="multi-ws-item">· ${item.product_name} (${item.qty}개)${itemAmt > 0 ? ` <span style="color:var(--text-muted);">${itemAmt.toLocaleString()}원</span>` : ''}</div>`;
|
||||
}).join('')}
|
||||
${items.length > 3 ? `<div class="multi-ws-item more">... 외 ${items.length - 3}개</div>` : ''}
|
||||
</div>
|
||||
${limitHtml}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
@ -1427,7 +1671,7 @@
|
||||
const headerDiv = modal.querySelector('.order-modal-header');
|
||||
|
||||
// 도매상별 헤더 및 본문 텍스트 변경
|
||||
document.getElementById('orderConfirmTitle').innerHTML = `${ws.icon} ${ws.name} 주문 확인`;
|
||||
document.getElementById('orderConfirmTitle').innerHTML = `<img src="${ws.logo}" alt="${ws.name}" style="width:24px;height:24px;object-fit:contain;vertical-align:middle;margin-right:8px;">${ws.name} 주문 확인`;
|
||||
document.getElementById('orderConfirmWholesaler').textContent = ws.name;
|
||||
headerDiv.style.background = ws.gradient;
|
||||
|
||||
@ -1770,6 +2014,23 @@
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
// 환자 이름 포맷 (오늘 사용 환자 강조)
|
||||
function formatPatientNames(allNames, todayNames) {
|
||||
if (!allNames) return '';
|
||||
if (!todayNames) return escapeHtml(allNames);
|
||||
|
||||
const todaySet = new Set(todayNames.split(', ').map(n => n.trim()));
|
||||
const names = allNames.split(', ');
|
||||
|
||||
return names.map(name => {
|
||||
const trimmed = name.trim();
|
||||
if (todaySet.has(trimmed)) {
|
||||
return `<strong class="today-patient">${escapeHtml(trimmed)}</strong>`;
|
||||
}
|
||||
return escapeHtml(trimmed);
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
@ -2025,6 +2286,7 @@
|
||||
if (!qty || isNaN(qty)) return;
|
||||
|
||||
// 장바구니에 추가
|
||||
const unitPrice = item.price || item.unit_price || 0;
|
||||
const cartItem = {
|
||||
drug_code: currentWholesaleItem.drug_code,
|
||||
product_name: productName,
|
||||
@ -2035,7 +2297,8 @@
|
||||
internal_code: item.internal_code,
|
||||
geoyoung_code: wholesaler === 'geoyoung' ? item.insurance_code : null,
|
||||
sooin_code: wholesaler === 'sooin' ? item.code : null,
|
||||
baekje_code: wholesaler === 'baekje' ? item.internal_code : null
|
||||
baekje_code: wholesaler === 'baekje' ? item.internal_code : null,
|
||||
unit_price: unitPrice // 💰 단가 추가
|
||||
};
|
||||
|
||||
// 🔍 디버그: 장바구니 추가 시 internal_code 확인
|
||||
|
||||
@ -651,7 +651,8 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
${logs.map(log => {
|
||||
const date = new Date(log.created_at);
|
||||
// DB는 UTC로 저장 → 'Z' 붙여서 UTC로 해석 → KST로 표시
|
||||
const date = new Date(log.created_at + 'Z');
|
||||
const dateStr = date.toLocaleString('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@ -772,7 +773,7 @@
|
||||
<dt>질병 1</dt><dd>[${log.disease_code_1 || '-'}] ${log.disease_name_1 || '-'}</dd>
|
||||
<dt>질병 2</dt><dd>[${log.disease_code_2 || '-'}] ${log.disease_name_2 || '-'}</dd>
|
||||
<dt>약품</dt><dd>${medsHtml}</dd>
|
||||
<dt>분석일시</dt><dd>${log.created_at}</dd>
|
||||
<dt>분석일시</dt><dd>${new Date(log.created_at + 'Z').toLocaleString('ko-KR')}</dd>
|
||||
<dt>상태</dt><dd>${log.status}</dd>
|
||||
<dt>피드백</dt><dd>${feedbackHtml}</dd>
|
||||
</dl>
|
||||
|
||||
26
backend/test_all_orders.py
Normal file
26
backend/test_all_orders.py
Normal file
@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import requests
|
||||
|
||||
print('=== 주문량 API 테스트 (지오영 + 수인 + 백제) ===')
|
||||
|
||||
date = '2026-03-07'
|
||||
|
||||
# 지오영
|
||||
geo = requests.get(f'http://localhost:7001/api/geoyoung/orders/summary-by-kd?start_date={date}&end_date={date}', timeout=60).json()
|
||||
geo_count = len(geo.get('by_kd_code', {}))
|
||||
print(f'지오영: {"OK" if geo.get("success") else "FAIL"} - {geo_count}개 품목')
|
||||
|
||||
# 수인
|
||||
sooin = requests.get(f'http://localhost:7001/api/sooin/orders/summary-by-kd?start_date={date}&end_date={date}', timeout=60).json()
|
||||
sooin_count = len(sooin.get('by_kd_code', {}))
|
||||
print(f'수인: {"OK" if sooin.get("success") else "FAIL"} - {sooin_count}개 품목')
|
||||
|
||||
# 백제
|
||||
baekje = requests.get(f'http://localhost:7001/api/baekje/orders/summary-by-kd?start_date={date}&end_date={date}', timeout=60).json()
|
||||
baekje_count = len(baekje.get('by_kd_code', {}))
|
||||
print(f'백제: {"OK" if baekje.get("success") else "FAIL"} - {baekje_count}개 품목')
|
||||
if baekje.get('message'):
|
||||
print(f' 메시지: {baekje.get("message")}')
|
||||
|
||||
print()
|
||||
print(f'총 품목: {geo_count + sooin_count + baekje_count}개')
|
||||
48
backend/test_patient_query.py
Normal file
48
backend/test_patient_query.py
Normal file
@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pyodbc
|
||||
|
||||
conn_str = (
|
||||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||||
'SERVER=192.168.0.4\\PM2014;'
|
||||
'DATABASE=PM_PRES;'
|
||||
'UID=sa;'
|
||||
'PWD=tmddls214!%(;'
|
||||
'TrustServerCertificate=yes;'
|
||||
'Connection Timeout=10'
|
||||
)
|
||||
|
||||
conn = pyodbc.connect(conn_str, timeout=10)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 최근 1년간 약품별 사용 환자 수 + 3명 이하면 이름 표시
|
||||
query = """
|
||||
WITH PatientUsage AS (
|
||||
SELECT DISTINCT
|
||||
P.DrugCode,
|
||||
M.Paname
|
||||
FROM PS_sub_pharm P
|
||||
JOIN PS_main M ON P.PreSerial = M.PreSerial
|
||||
WHERE M.Indate >= CONVERT(VARCHAR, DATEADD(YEAR, -1, GETDATE()), 112)
|
||||
)
|
||||
SELECT
|
||||
PU.DrugCode,
|
||||
COUNT(*) as patient_count,
|
||||
STUFF((
|
||||
SELECT ', ' + PU2.Paname
|
||||
FROM PatientUsage PU2
|
||||
WHERE PU2.DrugCode = PU.DrugCode
|
||||
ORDER BY PU2.Paname
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as patient_names
|
||||
FROM PatientUsage PU
|
||||
GROUP BY PU.DrugCode
|
||||
HAVING COUNT(*) <= 3
|
||||
ORDER BY COUNT(*), PU.DrugCode
|
||||
"""
|
||||
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
|
||||
print(f"=== 최근 1년 사용 환자 3명 이하 약품 ({len(rows)}개) ===\n")
|
||||
for row in rows[:20]: # 상위 20개만
|
||||
print(f"[{row.DrugCode}] {row.patient_count}명: {row.patient_names}")
|
||||
60
backend/test_query_perf.py
Normal file
60
backend/test_query_perf.py
Normal file
@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pyodbc
|
||||
import time
|
||||
|
||||
conn_str = (
|
||||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||||
'SERVER=192.168.0.4\\PM2014;'
|
||||
'DATABASE=PM_PRES;'
|
||||
'UID=sa;'
|
||||
'PWD=tmddls214!%(;'
|
||||
'TrustServerCertificate=yes;'
|
||||
'Connection Timeout=10'
|
||||
)
|
||||
|
||||
conn = pyodbc.connect(conn_str, timeout=10)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 현재 쿼리 성능 측정
|
||||
start = time.time()
|
||||
|
||||
query = """
|
||||
WITH PatientUsage AS (
|
||||
SELECT DISTINCT
|
||||
P.DrugCode,
|
||||
M.Paname,
|
||||
MAX(CASE WHEN M.Indate >= '20260306' AND M.Indate <= '20260306' THEN 1 ELSE 0 END) as used_in_period
|
||||
FROM PS_sub_pharm P
|
||||
JOIN PS_main M ON P.PreSerial = M.PreSerial
|
||||
WHERE M.Indate >= CONVERT(VARCHAR, DATEADD(YEAR, -1, GETDATE()), 112)
|
||||
GROUP BY P.DrugCode, M.Paname
|
||||
)
|
||||
SELECT
|
||||
PU.DrugCode as drug_code,
|
||||
COUNT(*) as patient_count,
|
||||
STUFF((
|
||||
SELECT ', ' + PU2.Paname
|
||||
FROM PatientUsage PU2
|
||||
WHERE PU2.DrugCode = PU.DrugCode
|
||||
ORDER BY PU2.Paname
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as patient_names,
|
||||
STUFF((
|
||||
SELECT ', ' + PU3.Paname
|
||||
FROM PatientUsage PU3
|
||||
WHERE PU3.DrugCode = PU.DrugCode AND PU3.used_in_period = 1
|
||||
ORDER BY PU3.Paname
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as today_patients
|
||||
FROM PatientUsage PU
|
||||
GROUP BY PU.DrugCode
|
||||
HAVING COUNT(*) <= 3
|
||||
"""
|
||||
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
elapsed = time.time() - start
|
||||
|
||||
print(f"결과: {len(rows)}개 약품")
|
||||
print(f"실행 시간: {elapsed:.2f}초")
|
||||
print(f"약품당: {elapsed/len(rows)*1000:.2f}ms" if rows else "")
|
||||
65
backend/test_today_patients.py
Normal file
65
backend/test_today_patients.py
Normal file
@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pyodbc
|
||||
|
||||
conn_str = (
|
||||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||||
'SERVER=192.168.0.4\\PM2014;'
|
||||
'DATABASE=PM_PRES;'
|
||||
'UID=sa;'
|
||||
'PWD=tmddls214!%(;'
|
||||
'TrustServerCertificate=yes;'
|
||||
'Connection Timeout=10'
|
||||
)
|
||||
|
||||
conn = pyodbc.connect(conn_str, timeout=10)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 오늘 날짜 확인
|
||||
cur.execute('SELECT CONVERT(VARCHAR, GETDATE(), 112)')
|
||||
today = cur.fetchone()[0]
|
||||
print(f'오늘 날짜: {today}')
|
||||
|
||||
# 오늘 처방된 약품 중 3명 이하 환자 약품 테스트
|
||||
query = """
|
||||
WITH PatientUsage AS (
|
||||
SELECT DISTINCT
|
||||
P.DrugCode,
|
||||
M.Paname,
|
||||
MAX(CASE WHEN M.Indate = CONVERT(VARCHAR, GETDATE(), 112) THEN 1 ELSE 0 END) as used_today
|
||||
FROM PS_sub_pharm P
|
||||
JOIN PS_main M ON P.PreSerial = M.PreSerial
|
||||
WHERE M.Indate >= CONVERT(VARCHAR, DATEADD(YEAR, -1, GETDATE()), 112)
|
||||
GROUP BY P.DrugCode, M.Paname
|
||||
)
|
||||
SELECT TOP 20
|
||||
PU.DrugCode,
|
||||
COUNT(*) as patient_count,
|
||||
STUFF((
|
||||
SELECT ', ' + PU2.Paname
|
||||
FROM PatientUsage PU2
|
||||
WHERE PU2.DrugCode = PU.DrugCode
|
||||
ORDER BY PU2.Paname
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as patient_names,
|
||||
STUFF((
|
||||
SELECT ', ' + PU3.Paname
|
||||
FROM PatientUsage PU3
|
||||
WHERE PU3.DrugCode = PU.DrugCode AND PU3.used_today = 1
|
||||
ORDER BY PU3.Paname
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as today_patients
|
||||
FROM PatientUsage PU
|
||||
GROUP BY PU.DrugCode
|
||||
HAVING COUNT(*) <= 3
|
||||
ORDER BY
|
||||
CASE WHEN MAX(PU.used_today) = 1 THEN 0 ELSE 1 END, -- 오늘 사용한 것 먼저
|
||||
PU.DrugCode
|
||||
"""
|
||||
|
||||
cur.execute(query)
|
||||
print('\n=== 3명 이하 환자 약품 (오늘 사용 우선) ===')
|
||||
for row in cur.fetchall():
|
||||
today_mark = ' ⭐오늘' if row.today_patients else ''
|
||||
print(f'[{row.DrugCode}] {row.patient_count}명: {row.patient_names}{today_mark}')
|
||||
if row.today_patients:
|
||||
print(f' → 오늘 사용: {row.today_patients}')
|
||||
40
backend/test_today_rx.py
Normal file
40
backend/test_today_rx.py
Normal file
@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pyodbc
|
||||
|
||||
conn_str = (
|
||||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||||
'SERVER=192.168.0.4\\PM2014;'
|
||||
'DATABASE=PM_PRES;'
|
||||
'UID=sa;'
|
||||
'PWD=tmddls214!%(;'
|
||||
'TrustServerCertificate=yes;'
|
||||
'Connection Timeout=10'
|
||||
)
|
||||
|
||||
conn = pyodbc.connect(conn_str, timeout=10)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 오늘 처방 있는지 확인
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) as cnt, MAX(Indate) as last_date
|
||||
FROM PS_main
|
||||
WHERE Indate = CONVERT(VARCHAR, GETDATE(), 112)
|
||||
""")
|
||||
row = cur.fetchone()
|
||||
print(f'오늘(20260307) 처방 수: {row.cnt}')
|
||||
|
||||
# 최근 처방일
|
||||
cur.execute("SELECT MAX(Indate) FROM PS_main")
|
||||
print(f'최근 처방일: {cur.fetchone()[0]}')
|
||||
|
||||
# 어제 처방 약품 중 3명 이하 확인 (테스트용)
|
||||
cur.execute("""
|
||||
SELECT TOP 5 P.DrugCode, M.Paname, M.Indate
|
||||
FROM PS_sub_pharm P
|
||||
JOIN PS_main M ON P.PreSerial = M.PreSerial
|
||||
WHERE M.Indate = '20260306'
|
||||
ORDER BY P.DrugCode
|
||||
""")
|
||||
print('\n=== 3/6 처방 샘플 ===')
|
||||
for row in cur.fetchall():
|
||||
print(f'{row.DrugCode}: {row.Paname}')
|
||||
353
docs/PAAI_TROUBLESHOOTING_2026-03-07.md
Normal file
353
docs/PAAI_TROUBLESHOOTING_2026-03-07.md
Normal file
@ -0,0 +1,353 @@
|
||||
# PAAI 시스템 트러블슈팅 기록
|
||||
|
||||
**날짜:** 2026-03-07
|
||||
**작성자:** 용림 (AI 디지털 직원)
|
||||
**상태:** ✅ 해결 완료
|
||||
|
||||
---
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [시스템 아키텍처](#시스템-아키텍처)
|
||||
2. [모델 전략 (Opus vs Sonnet)](#모델-전략-opus-vs-sonnet)
|
||||
3. [발생한 문제들](#발생한-문제들)
|
||||
4. [해결 과정](#해결-과정)
|
||||
5. [수정된 코드](#수정된-코드)
|
||||
6. [서브에이전트 활용](#서브에이전트-활용)
|
||||
7. [교훈 및 권장사항](#교훈-및-권장사항)
|
||||
|
||||
---
|
||||
|
||||
## 시스템 아키텍처
|
||||
|
||||
### 전체 흐름
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PIT3000 POS │────▶│ prescription │────▶│ Flask │
|
||||
│ (MSSQL) │ │ _trigger.py │ │ (pmr_api) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ WebSocket │ HTTP
|
||||
▼ (ws://8765) ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ PMR 화면 │ │ KIMS API │
|
||||
│ (실시간) │ │ (약물 상호작용) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Clawdbot │
|
||||
│ Gateway │
|
||||
│ (ws://18789) │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Claude API │
|
||||
│ (Sonnet 4) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### PM2 서비스 구성
|
||||
|
||||
| 서비스 | PM2 이름 | 포트 | 역할 |
|
||||
|--------|----------|------|------|
|
||||
| Flask 웹서버 | `flask-pharmacy` | 7001 | API 엔드포인트, PMR 화면 |
|
||||
| 처방 감지 | `websocket-rx` | 8765 | DB 폴링 → 실시간 알림 |
|
||||
| Clawdbot | `clawdbot-gateway` | 18789 | AI 게이트웨이 |
|
||||
|
||||
---
|
||||
|
||||
## 모델 전략 (Opus vs Sonnet)
|
||||
|
||||
### Gateway 기본 설정
|
||||
|
||||
```json
|
||||
// ~/.clawdbot/clawdbot.json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "anthropic/claude-opus-4-5" // 기본 모델
|
||||
},
|
||||
"models": {
|
||||
"anthropic/claude-opus-4-5": { "alias": "opus" },
|
||||
"github-copilot/gpt-5": {},
|
||||
"openai-codex/gpt-5.2-codex": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PAAI 전용 모델 (비용 절감)
|
||||
|
||||
PAAI 분석은 **빠른 응답**과 **비용 절감**이 중요하므로 **Sonnet**을 사용.
|
||||
|
||||
```python
|
||||
# pmr_api.py - Line 1417
|
||||
ai_text = ask_clawdbot(
|
||||
message=prompt,
|
||||
session_id='paai-analysis',
|
||||
system_prompt=PAAI_SYSTEM_PROMPT,
|
||||
timeout=60,
|
||||
model='anthropic/claude-sonnet-4-5' # Sonnet 지정!
|
||||
)
|
||||
```
|
||||
|
||||
### sessions.patch 메커니즘
|
||||
|
||||
Gateway에서 모델이 allowlist에 없어도, `sessions.patch`로 세션별 모델 오버라이드 가능:
|
||||
|
||||
```python
|
||||
# clawdbot_client.py - _ask_gateway()
|
||||
|
||||
# 4. 모델 오버라이드 (sessions.patch)
|
||||
if model:
|
||||
patch_frame = {
|
||||
'type': 'req',
|
||||
'method': 'sessions.patch',
|
||||
'params': {
|
||||
'key': session_id,
|
||||
'model': model, # 'anthropic/claude-sonnet-4-5'
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(patch_frame))
|
||||
# patch 실패해도 agent 요청은 계속 진행됨
|
||||
```
|
||||
|
||||
### 모델 비용 비교
|
||||
|
||||
| 모델 | 입력 (1M) | 출력 (1M) | 용도 |
|
||||
|------|-----------|-----------|------|
|
||||
| Claude Opus 4.5 | $15 | $75 | 메인 세션, 복잡한 작업 |
|
||||
| Claude Sonnet 4.5 | $3 | $15 | PAAI 분석, 빠른 응답 필요 |
|
||||
|
||||
**절감 효과:** PAAI를 Sonnet으로 돌리면 비용 **80% 절감**!
|
||||
|
||||
---
|
||||
|
||||
## 발생한 문제들
|
||||
|
||||
### 문제 1: 처방 분석이 안 됨 (임명옥 환자)
|
||||
|
||||
**증상:**
|
||||
- 처방 감지는 되나 분석 결과가 안 나옴
|
||||
- DB에 `generating` 상태로 멈춤
|
||||
|
||||
**원인:**
|
||||
1. PM2 `watch` 모드로 인한 과도한 재시작 (30회 이상)
|
||||
2. 재시작 과정에서 분석 요청이 중단됨
|
||||
|
||||
### 문제 2: Flask 연결 끊김
|
||||
|
||||
**증상:**
|
||||
```
|
||||
ConnectionResetError: [WinError 10054]
|
||||
현재 연결은 원격 호스트에 의해 강제로 끊겼습니다
|
||||
```
|
||||
|
||||
**원인:**
|
||||
- watch 모드가 파일 변경 감지 → Flask 재시작
|
||||
- 진행 중인 HTTP 요청이 끊김
|
||||
|
||||
### 문제 3: 모델 허용 오류 (경고)
|
||||
|
||||
**증상:**
|
||||
```
|
||||
WARNING: [Clawdbot] sessions.patch 실패: model not allowed: anthropic/claude-sonnet-4-5
|
||||
```
|
||||
|
||||
**분석:**
|
||||
- Gateway allowlist에 `claude-sonnet-4-5` 없음
|
||||
- 하지만 **sessions.patch 실패해도 agent 요청은 진행됨**
|
||||
- 세션의 기존 모델 또는 기본 모델로 폴백
|
||||
- 실제 분석에는 영향 없음 (비용만 더 나갈 수 있음)
|
||||
|
||||
---
|
||||
|
||||
## 해결 과정
|
||||
|
||||
### Step 1: 상황 파악
|
||||
|
||||
```bash
|
||||
# PM2 상태 확인
|
||||
pm2 list
|
||||
# → flask-pharmacy: ↺ 17, websocket-rx: ↺ 30 (과도한 재시작)
|
||||
|
||||
# 최근 성공한 분석 확인
|
||||
sqlite3 db/paai_logs.db "SELECT * FROM paai_logs WHERE status='success' ORDER BY id DESC LIMIT 5"
|
||||
# → 00:24:32까지 성공, 이후 실패
|
||||
```
|
||||
|
||||
### Step 2: 원인 분석
|
||||
|
||||
```bash
|
||||
# Flask 로그 확인
|
||||
pm2 logs flask-pharmacy --lines 50
|
||||
|
||||
# watch 모드가 문제임을 확인
|
||||
# uptime이 7초, 계속 재시작 중
|
||||
```
|
||||
|
||||
### Step 3: watch 모드 비활성화
|
||||
|
||||
```bash
|
||||
# 기존 서비스 삭제
|
||||
pm2 stop flask-pharmacy websocket-rx
|
||||
pm2 delete flask-pharmacy websocket-rx
|
||||
|
||||
# watch 없이 재등록
|
||||
pm2 start app.py --name "flask-pharmacy" --interpreter python \
|
||||
--cwd "c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend"
|
||||
|
||||
pm2 start prescription_trigger.py --name "websocket-rx" --interpreter python \
|
||||
--cwd "c:\Users\청춘약국\source\prescription-trigger"
|
||||
|
||||
# 저장
|
||||
pm2 save
|
||||
```
|
||||
|
||||
### Step 4: 밀린 처방 수동 분석
|
||||
|
||||
```bash
|
||||
# 임명옥 처방 수동 분석 요청
|
||||
curl -X POST http://localhost:7001/pmr/api/paai/analyze \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"pre_serial": "20260307000059"}'
|
||||
|
||||
# → 성공!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 수정된 코드
|
||||
|
||||
### ThreadPoolExecutor 적용 (동시성 제한)
|
||||
|
||||
**파일:** `prescription-trigger/prescription_trigger.py`
|
||||
|
||||
**Before (문제):**
|
||||
```python
|
||||
# 무제한 스레드 생성 → 세션 과부하
|
||||
def _request_analysis(self, pre_serial, patient_name):
|
||||
thread = threading.Thread(target=self._analyze_worker, args=(...))
|
||||
thread.start()
|
||||
self._worker_threads.append(thread)
|
||||
```
|
||||
|
||||
**After (해결):**
|
||||
```python
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
class PrescriptionTrigger:
|
||||
def __init__(self):
|
||||
# 최대 3개 동시 처리
|
||||
self._executor = ThreadPoolExecutor(max_workers=3)
|
||||
|
||||
def _request_analysis(self, pre_serial, patient_name):
|
||||
self._executor.submit(self._analyze_worker, pre_serial, patient_name)
|
||||
|
||||
def stop(self):
|
||||
self._executor.shutdown(wait=True) # graceful shutdown
|
||||
```
|
||||
|
||||
**커밋:** `feat: ThreadPoolExecutor로 동시 처리 제한 (max_workers=3)`
|
||||
|
||||
### OTC 라벨 import 수정
|
||||
|
||||
**파일:** `backend/app.py`
|
||||
|
||||
**문제:** PM2 환경에서 상대 import 실패
|
||||
```
|
||||
ModuleNotFoundError: No module named 'utils'
|
||||
```
|
||||
|
||||
**해결:**
|
||||
```python
|
||||
# app.py 상단에서 미리 import
|
||||
from utils.otc_label_printer import generate_label_commands
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 서브에이전트 활용
|
||||
|
||||
오늘 트러블슈팅에 **3개의 서브에이전트**를 활용:
|
||||
|
||||
### 1. paai-investigation
|
||||
- **목적:** PAAI 아키텍처 분석
|
||||
- **결과:** Clawdbot Gateway WebSocket 사용 확인
|
||||
|
||||
### 2. paai-queue-analysis
|
||||
- **목적:** 큐 처리 로직 분석
|
||||
- **결과:** 날짜 필터로 어제 것은 무시, 오늘 것은 일괄 처리
|
||||
|
||||
### 3. paai-concurrency-design
|
||||
- **목적:** 동시성 제어 방안 설계
|
||||
- **결과:** ThreadPoolExecutor 방식 제안
|
||||
|
||||
```python
|
||||
# 서브에이전트 호출 예시
|
||||
sessions_spawn(
|
||||
task="처방 분석 시스템의 동시성 제어 방안을 설계해주세요",
|
||||
label="paai-concurrency-design"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 교훈 및 권장사항
|
||||
|
||||
### ✅ PM2 watch 모드 사용 시 주의
|
||||
|
||||
```bash
|
||||
# 개발 중에만 watch 사용
|
||||
pm2 start app.py --watch # 개발용
|
||||
|
||||
# 운영 환경에서는 watch 끄기
|
||||
pm2 start app.py # 운영용
|
||||
|
||||
# 코드 수정 후 수동 반영
|
||||
pm2 restart flask-pharmacy
|
||||
```
|
||||
|
||||
### ✅ 모델 비용 최적화
|
||||
|
||||
| 용도 | 권장 모델 | 이유 |
|
||||
|------|-----------|------|
|
||||
| 메인 대화 | Opus | 복잡한 작업, 높은 품질 |
|
||||
| PAAI 분석 | Sonnet | 빠른 응답, 정형화된 출력 |
|
||||
| 업셀링 추천 | Sonnet | 간단한 추천 |
|
||||
|
||||
### ✅ 동시성 제어
|
||||
|
||||
```python
|
||||
# ThreadPoolExecutor 사용
|
||||
executor = ThreadPoolExecutor(max_workers=3)
|
||||
|
||||
# 이점:
|
||||
# 1. 리소스 제한 (CPU, 메모리)
|
||||
# 2. Gateway 세션 과부하 방지
|
||||
# 3. 순차적 처리보다 빠름
|
||||
```
|
||||
|
||||
### ✅ 트러블슈팅 체크리스트
|
||||
|
||||
1. `pm2 list` - 재시작 횟수 확인 (↺)
|
||||
2. `pm2 logs <name>` - 에러 로그 확인
|
||||
3. `paai_logs.db` - 분석 상태 확인
|
||||
4. `trigger_state.db` - 작업 큐 상태 확인
|
||||
|
||||
---
|
||||
|
||||
## 결론
|
||||
|
||||
| 항목 | Before | After |
|
||||
|------|--------|-------|
|
||||
| 안정성 | ❌ 30회 재시작 | ✅ 안정적 |
|
||||
| 동시성 | 무제한 스레드 | 3개 제한 |
|
||||
| 비용 | Opus (고비용) | Sonnet (저비용) |
|
||||
| Watch 모드 | 활성화 (불안정) | 비활성화 (안정) |
|
||||
|
||||
**시스템 정상화 완료!** 🎉
|
||||
241
docs/SOOIN_API.md
Normal file
241
docs/SOOIN_API.md
Normal file
@ -0,0 +1,241 @@
|
||||
# 수인약품 (Sooin) API 문서
|
||||
|
||||
## 개요
|
||||
- **회사명**: 수인약품
|
||||
- **웹사이트**: http://sooinpharm.co.kr
|
||||
- **인증방식**: Playwright 로그인 → requests 쿠키 재사용 (세션 30분 유효)
|
||||
|
||||
## 인증 정보 (환경변수)
|
||||
```
|
||||
SOOIN_USER_ID=thug0bin
|
||||
SOOIN_PASSWORD=@Trajet6640
|
||||
SOOIN_VENDOR_CODE=50911
|
||||
SOOIN_VENDOR_NAME=청춘약국
|
||||
```
|
||||
|
||||
## 핵심 URL 구조
|
||||
|
||||
### 기존 구현된 URL
|
||||
| URL | 용도 |
|
||||
|-----|------|
|
||||
| `/Homepage/intro.asp` | 로그인 페이지 |
|
||||
| `/Service/Order/Order.asp` | 제품 검색 |
|
||||
| `/Service/Order/BagOrder.asp` | 장바구니 추가 |
|
||||
| `/Service/Order/Bag.asp` | 장바구니 조회 |
|
||||
| `/Service/Order/ControlBag.asp` | 장바구니 항목 제어 (취소/복원) |
|
||||
| `/Service/Order/OrderEnd.asp` | 주문 전송 (확정) |
|
||||
| `/Service/Order/PhysicInfo.asp` | 제품 상세 정보 |
|
||||
| `/Service/SalesLedger/SalesLedger.asp` | 잔고/매출 조회 |
|
||||
|
||||
### 🆕 신규 구현 필요 URL (주문 조회)
|
||||
| URL | 용도 |
|
||||
|-----|------|
|
||||
| `/Service/Report/Report.asp` | **주문 내역 목록 조회** |
|
||||
| `/Service/Report/Report.asp?f=view&orderNum=...` | **주문 상세 조회** |
|
||||
|
||||
## 주문 조회 URL 분석
|
||||
|
||||
### 주문 목록 URL 예시
|
||||
```
|
||||
http://sooinpharm.co.kr/Service/Report/Report.asp?
|
||||
sDate=2026-03-01 # 시작일
|
||||
&eDate=2026-03-07 # 종료일
|
||||
&tx_ven=50911 # 거래처 코드 (SOOIN_VENDOR_CODE)
|
||||
&currVenNm=청춘약국 # 거래처명 (URL 인코딩됨)
|
||||
&orderStatus=0 # 주문상태 (0=전체?)
|
||||
&ListOrder=0 # 정렬 (0=기본)
|
||||
&PhysicNm= # 제품명 필터 (선택)
|
||||
&sg= # 미확인
|
||||
&page=1 # 페이지 번호
|
||||
```
|
||||
|
||||
### 주문 상세 URL 예시
|
||||
```
|
||||
http://sooinpharm.co.kr/Service/Report/Report.asp?
|
||||
f=view # view 모드 (상세 조회)
|
||||
&orderNum=202603095091177 # 주문번호 (YYYYMMDD + 거래처코드 + 순번?)
|
||||
&Ifflag= # 미확인
|
||||
&sDate=2026-03-01
|
||||
&eDate=2026-03-07
|
||||
&PhysicNm=
|
||||
&ListOrder=0
|
||||
&tx_ven=50911
|
||||
&currVenNm=청춘약국
|
||||
&orderStatus=0
|
||||
&sg=
|
||||
&page=1
|
||||
```
|
||||
|
||||
### 주문번호 구조 분석
|
||||
- `202603095091177` → `20260309` (날짜) + `50911` (거래처코드) + `77` (순번?)
|
||||
- 형식: `YYYYMMDD` + `VENDOR_CODE` + `SEQ`
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### Flask Blueprint: `/api/sooin/*`
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/sooin/stock` | 재고 조회 |
|
||||
| GET | `/api/sooin/session-status` | 세션 상태 확인 |
|
||||
| GET | `/api/sooin/balance` | 잔고(미수금) 조회 |
|
||||
| GET | `/api/sooin/monthly-sales` | 월간 매출 조회 |
|
||||
| GET | `/api/sooin/cart` | 장바구니 조회 |
|
||||
| POST | `/api/sooin/cart/clear` | 장바구니 비우기 |
|
||||
| POST | `/api/sooin/cart/cancel` | 장바구니 항목 취소 |
|
||||
| POST | `/api/sooin/cart/restore` | 취소 항목 복원 |
|
||||
| POST | `/api/sooin/order` | 주문 (장바구니 추가) |
|
||||
| POST | `/api/sooin/confirm` | 주문 확정 |
|
||||
| POST | `/api/sooin/full-order` | 전체 주문 (검색→담기→확정) |
|
||||
| POST | `/api/sooin/order-batch` | 일괄 주문 |
|
||||
| **GET** | **`/api/sooin/orders`** | **✅ 주문 목록 조회** |
|
||||
| **GET** | **`/api/sooin/orders/<order_num>`** | **✅ 주문 상세 조회** |
|
||||
| **GET** | **`/api/sooin/orders/today-summary`** | **✅ 오늘 주문 집계** |
|
||||
|
||||
## 🆕 신규 구현 목표: 주문 조회 API
|
||||
|
||||
### 목적
|
||||
1. **오늘 주문한 약 목록 조회** - 주문이 정상적으로 들어갔는지 확인
|
||||
2. **제품별 주문량 집계** - 오늘의 총 주문량을 제품별로 파악
|
||||
|
||||
### 구현할 API 엔드포인트
|
||||
|
||||
#### 1. 주문 목록 조회
|
||||
```
|
||||
GET /api/sooin/orders?start_date=2026-03-01&end_date=2026-03-07
|
||||
```
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"orders": [
|
||||
{
|
||||
"order_num": "202603095091177",
|
||||
"order_date": "2026-03-09",
|
||||
"order_time": "14:30:25",
|
||||
"total_amount": 125000,
|
||||
"item_count": 5,
|
||||
"status": "완료"
|
||||
}
|
||||
],
|
||||
"total_count": 10
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 주문 상세 조회
|
||||
```
|
||||
GET /api/sooin/orders/<order_num>
|
||||
```
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"order_num": "202603095091177",
|
||||
"order_date": "2026-03-09",
|
||||
"items": [
|
||||
{
|
||||
"product_code": "32495", // 수인 내부코드
|
||||
"kd_code": "073100220", // KD코드 (있으면)
|
||||
"product_name": "코자정50mg",
|
||||
"spec": "30T",
|
||||
"quantity": 2,
|
||||
"unit_price": 15000,
|
||||
"amount": 30000
|
||||
}
|
||||
],
|
||||
"total_amount": 125000
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 오늘 주문 집계 (제품별)
|
||||
```
|
||||
GET /api/sooin/orders/today-summary
|
||||
```
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"date": "2026-03-09",
|
||||
"summary": [
|
||||
{
|
||||
"kd_code": "073100220",
|
||||
"product_name": "코자정50mg",
|
||||
"total_quantity": 10,
|
||||
"total_amount": 150000,
|
||||
"order_count": 3
|
||||
}
|
||||
],
|
||||
"grand_total_amount": 500000,
|
||||
"grand_total_items": 25
|
||||
}
|
||||
```
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
pharmacy-wholesale-api/
|
||||
├── wholesale/
|
||||
│ ├── __init__.py
|
||||
│ ├── base.py # 공통 베이스 클래스
|
||||
│ ├── sooin.py # 수인약품 핵심 로직 ← 여기에 주문 조회 메서드 추가
|
||||
│ ├── geoyoung.py
|
||||
│ └── baekje.py
|
||||
└── .env # 인증 정보
|
||||
|
||||
pharmacy-pos-qr-system/
|
||||
├── backend/
|
||||
│ ├── app.py # 메인 Flask 앱
|
||||
│ ├── sooin_api.py # 수인약품 Flask Blueprint ← 여기에 API 엔드포인트 추가
|
||||
│ ├── wholesale_path.py # wholesale 패키지 경로 설정
|
||||
│ └── templates/
|
||||
│ └── admin_rx_usage.html # 프론트엔드 (주문 조회 UI 추가 가능)
|
||||
└── docs/
|
||||
└── SOOIN_API.md # 이 문서
|
||||
```
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
### 1단계: SooinSession에 메서드 추가 (`wholesale/sooin.py`)
|
||||
|
||||
```python
|
||||
def get_order_list(self, start_date: str, end_date: str) -> dict:
|
||||
"""주문 목록 조회"""
|
||||
# /Service/Report/Report.asp 파싱
|
||||
pass
|
||||
|
||||
def get_order_detail(self, order_num: str) -> dict:
|
||||
"""주문 상세 조회"""
|
||||
# /Service/Report/Report.asp?f=view&orderNum=... 파싱
|
||||
pass
|
||||
|
||||
def get_today_order_summary(self) -> dict:
|
||||
"""오늘 주문 제품별 집계"""
|
||||
# get_order_list + get_order_detail 조합
|
||||
pass
|
||||
```
|
||||
|
||||
### 2단계: Flask API 엔드포인트 추가 (`sooin_api.py`)
|
||||
|
||||
```python
|
||||
@sooin_bp.route('/orders', methods=['GET'])
|
||||
def api_sooin_orders():
|
||||
"""주문 목록 조회"""
|
||||
pass
|
||||
|
||||
@sooin_bp.route('/orders/<order_num>', methods=['GET'])
|
||||
def api_sooin_order_detail(order_num):
|
||||
"""주문 상세 조회"""
|
||||
pass
|
||||
|
||||
@sooin_bp.route('/orders/today-summary', methods=['GET'])
|
||||
def api_sooin_today_summary():
|
||||
"""오늘 주문 집계"""
|
||||
pass
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **인코딩**: 수인약품 사이트는 `euc-kr` 인코딩 사용
|
||||
2. **세션 유지**: 30분 세션 타임아웃, 자동 재로그인 필요
|
||||
3. **HTML 파싱**: BeautifulSoup으로 테이블 구조 파싱
|
||||
4. **에러 처리**: 로그인 실패, 네트워크 오류 등 처리 필요
|
||||
Loading…
Reference in New Issue
Block a user