fix(stock-analytics): 동시 요청 시 DB 연결 충돌 해결
- daily-trend, stock-level API에서 각 요청마다 새로운 pyodbc 연결 사용 - SQLAlchemy 세션 공유로 인한 'concurrent operations' 에러 해결 - 연결 종료 처리 추가 (정상/에러 모두)
This commit is contained in:
parent
93c643cb8e
commit
591af31da9
336
backend/app.py
336
backend/app.py
@ -9690,6 +9690,342 @@ def api_drug_usage_prescriptions(drug_code):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 재고 시계열 분석 API
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@app.route('/admin/stock-analytics')
|
||||
def admin_stock_analytics():
|
||||
"""재고 시계열 분석 페이지"""
|
||||
return render_template('admin_stock_analytics.html')
|
||||
|
||||
|
||||
@app.route('/api/stock-analytics/daily-trend')
|
||||
def api_stock_daily_trend():
|
||||
"""
|
||||
일별 입출고 추이 API
|
||||
GET /api/stock-analytics/daily-trend?start_date=2026-01-01&end_date=2026-03-13&drug_code=A123456789
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
start_date = request.args.get('start_date', '')
|
||||
end_date = request.args.get('end_date', '')
|
||||
drug_code = request.args.get('drug_code', '').strip()
|
||||
|
||||
# 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD)
|
||||
start_date_fmt = start_date.replace('-', '') if start_date else (datetime.now() - timedelta(days=30)).strftime('%Y%m%d')
|
||||
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||||
|
||||
# 새로운 pyodbc 연결 (동시 요청 충돌 방지)
|
||||
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'
|
||||
)
|
||||
conn = pyodbc.connect(conn_str, timeout=10)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 특정 약품 또는 전체 일별 입출고 집계
|
||||
if drug_code:
|
||||
# 특정 약품
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
IM_DT_appl as date,
|
||||
SUM(ISNULL(IM_QT_sale_credit, 0)) as inbound,
|
||||
SUM(ISNULL(IM_QT_sale_debit, 0)) as outbound
|
||||
FROM IM_date_total
|
||||
WHERE IM_DT_appl >= ?
|
||||
AND IM_DT_appl <= ?
|
||||
AND DrugCode = ?
|
||||
GROUP BY IM_DT_appl
|
||||
ORDER BY IM_DT_appl
|
||||
""", (start_date_fmt, end_date_fmt, drug_code))
|
||||
else:
|
||||
# 전체 합계
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
IM_DT_appl as date,
|
||||
SUM(ISNULL(IM_QT_sale_credit, 0)) as inbound,
|
||||
SUM(ISNULL(IM_QT_sale_debit, 0)) as outbound
|
||||
FROM IM_date_total
|
||||
WHERE IM_DT_appl >= ?
|
||||
AND IM_DT_appl <= ?
|
||||
GROUP BY IM_DT_appl
|
||||
ORDER BY IM_DT_appl
|
||||
""", (start_date_fmt, end_date_fmt))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
items = []
|
||||
total_inbound = 0
|
||||
total_outbound = 0
|
||||
|
||||
for row in rows:
|
||||
date_str = str(row.date) if row.date else ''
|
||||
# YYYYMMDD -> YYYY-MM-DD
|
||||
formatted_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" if len(date_str) == 8 else date_str
|
||||
|
||||
inbound = int(row.inbound or 0)
|
||||
outbound = int(row.outbound or 0)
|
||||
|
||||
items.append({
|
||||
'date': formatted_date,
|
||||
'inbound': inbound,
|
||||
'outbound': outbound
|
||||
})
|
||||
|
||||
total_inbound += inbound
|
||||
total_outbound += outbound
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'drug_code': drug_code if drug_code else 'ALL',
|
||||
'items': items,
|
||||
'stats': {
|
||||
'total_inbound': total_inbound,
|
||||
'total_outbound': total_outbound,
|
||||
'net_change': total_inbound - total_outbound,
|
||||
'data_count': len(items)
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.close()
|
||||
logging.error(f"daily-trend API 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/stock-analytics/top-usage')
|
||||
def api_stock_top_usage():
|
||||
"""
|
||||
기간별 사용량 TOP N API
|
||||
GET /api/stock-analytics/top-usage?start_date=2026-01-01&end_date=2026-03-13&limit=10
|
||||
"""
|
||||
try:
|
||||
start_date = request.args.get('start_date', '')
|
||||
end_date = request.args.get('end_date', '')
|
||||
limit = int(request.args.get('limit', '10'))
|
||||
|
||||
# 날짜 형식 변환
|
||||
start_date_fmt = start_date.replace('-', '') if start_date else (datetime.now() - timedelta(days=30)).strftime('%Y%m%d')
|
||||
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||||
|
||||
drug_session = db_manager.get_session('PM_DRUG')
|
||||
|
||||
# 출고량 기준 TOP N
|
||||
query = text("""
|
||||
SELECT TOP(:limit)
|
||||
D.DrugCode as drug_code,
|
||||
G.GoodsName as product_name,
|
||||
G.SplName as supplier,
|
||||
SUM(ISNULL(D.IM_QT_sale_debit, 0)) as total_outbound,
|
||||
SUM(ISNULL(D.IM_QT_sale_credit, 0)) as total_inbound,
|
||||
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
|
||||
FROM IM_date_total D
|
||||
LEFT JOIN CD_GOODS G ON D.DrugCode = G.DrugCode
|
||||
LEFT JOIN IM_total IT ON D.DrugCode = IT.DrugCode
|
||||
WHERE D.IM_DT_appl >= :start_date
|
||||
AND D.IM_DT_appl <= :end_date
|
||||
GROUP BY D.DrugCode, G.GoodsName, G.SplName, IT.IM_QT_sale_debit
|
||||
ORDER BY SUM(ISNULL(D.IM_QT_sale_debit, 0)) DESC
|
||||
""")
|
||||
|
||||
rows = drug_session.execute(query, {
|
||||
'start_date': start_date_fmt,
|
||||
'end_date': end_date_fmt,
|
||||
'limit': limit
|
||||
}).fetchall()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
items.append({
|
||||
'drug_code': row.drug_code or '',
|
||||
'product_name': row.product_name or '알 수 없음',
|
||||
'supplier': row.supplier or '',
|
||||
'total_outbound': int(row.total_outbound or 0),
|
||||
'total_inbound': int(row.total_inbound or 0),
|
||||
'current_stock': int(row.current_stock or 0)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': items,
|
||||
'limit': limit
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"top-usage API 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/stock-analytics/stock-level')
|
||||
def api_stock_level():
|
||||
"""
|
||||
특정 약품 재고 변화 (누적) API
|
||||
GET /api/stock-analytics/stock-level?drug_code=A123456789&start_date=2026-01-01&end_date=2026-03-13
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
drug_code = request.args.get('drug_code', '').strip()
|
||||
start_date = request.args.get('start_date', '')
|
||||
end_date = request.args.get('end_date', '')
|
||||
|
||||
if not drug_code:
|
||||
return jsonify({'success': False, 'error': 'drug_code 파라미터 필요'}), 400
|
||||
|
||||
# 날짜 형식 변환
|
||||
start_date_fmt = start_date.replace('-', '') if start_date else (datetime.now() - timedelta(days=30)).strftime('%Y%m%d')
|
||||
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||||
|
||||
# 새로운 pyodbc 연결 (동시 요청 충돌 방지)
|
||||
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'
|
||||
)
|
||||
conn = pyodbc.connect(conn_str, timeout=10)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 현재 재고
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
IT.IM_QT_sale_debit as current_stock,
|
||||
G.GoodsName as product_name,
|
||||
G.SplName as supplier
|
||||
FROM IM_total IT
|
||||
LEFT JOIN CD_GOODS G ON IT.DrugCode = G.DrugCode
|
||||
WHERE IT.DrugCode = ?
|
||||
""", (drug_code,))
|
||||
|
||||
stock_row = cursor.fetchone()
|
||||
current_stock = int(stock_row.current_stock or 0) if stock_row else 0
|
||||
product_name = stock_row.product_name if stock_row else drug_code
|
||||
supplier = stock_row.supplier if stock_row else ''
|
||||
|
||||
# 일별 입출고 데이터 (역순으로 누적 계산)
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
IM_DT_appl as date,
|
||||
SUM(ISNULL(IM_QT_sale_credit, 0)) as inbound,
|
||||
SUM(ISNULL(IM_QT_sale_debit, 0)) as outbound
|
||||
FROM IM_date_total
|
||||
WHERE DrugCode = ?
|
||||
AND IM_DT_appl >= ?
|
||||
AND IM_DT_appl <= ?
|
||||
GROUP BY IM_DT_appl
|
||||
ORDER BY IM_DT_appl DESC
|
||||
""", (drug_code, start_date_fmt, end_date_fmt))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# 역순으로 누적 재고 계산 (현재 → 과거)
|
||||
items = []
|
||||
running_stock = current_stock
|
||||
|
||||
for row in rows:
|
||||
date_str = str(row.date) if row.date else ''
|
||||
formatted_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" if len(date_str) == 8 else date_str
|
||||
|
||||
inbound = int(row.inbound or 0)
|
||||
outbound = int(row.outbound or 0)
|
||||
|
||||
items.append({
|
||||
'date': formatted_date,
|
||||
'stock': running_stock,
|
||||
'inbound': inbound,
|
||||
'outbound': outbound
|
||||
})
|
||||
|
||||
# 과거로 갈수록: 재고 = 재고 - 입고 + 출고
|
||||
running_stock = running_stock - inbound + outbound
|
||||
|
||||
# 시간순 정렬 (과거 → 현재)
|
||||
items.reverse()
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'drug_code': drug_code,
|
||||
'product_name': product_name,
|
||||
'supplier': supplier,
|
||||
'current_stock': current_stock,
|
||||
'items': items
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.close()
|
||||
logging.error(f"stock-level API 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/stock-analytics/search-drugs')
|
||||
def api_search_drugs():
|
||||
"""
|
||||
약품 검색 API (재고 분석용)
|
||||
GET /api/stock-analytics/search-drugs?q=타이레놀&limit=20
|
||||
"""
|
||||
try:
|
||||
query = request.args.get('q', '').strip()
|
||||
limit = int(request.args.get('limit', '20'))
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return jsonify({'success': True, 'items': []})
|
||||
|
||||
drug_session = db_manager.get_session('PM_DRUG')
|
||||
|
||||
search_query = text("""
|
||||
SELECT TOP(:limit)
|
||||
G.DrugCode as drug_code,
|
||||
G.GoodsName as product_name,
|
||||
G.SplName as supplier,
|
||||
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
|
||||
FROM CD_GOODS G
|
||||
LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode
|
||||
WHERE G.GoodsName LIKE :keyword
|
||||
OR G.DrugCode LIKE :keyword
|
||||
ORDER BY
|
||||
CASE WHEN G.GoodsName LIKE :exact_keyword THEN 0 ELSE 1 END,
|
||||
IT.IM_QT_sale_debit DESC
|
||||
""")
|
||||
|
||||
rows = drug_session.execute(search_query, {
|
||||
'keyword': f'%{query}%',
|
||||
'exact_keyword': f'{query}%',
|
||||
'limit': limit
|
||||
}).fetchall()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
items.append({
|
||||
'drug_code': row.drug_code or '',
|
||||
'product_name': row.product_name or '',
|
||||
'supplier': row.supplier or '',
|
||||
'current_stock': int(row.current_stock or 0)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': items
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"search-drugs API 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
|
||||
|
||||
1036
backend/templates/admin_stock_analytics.html
Normal file
1036
backend/templates/admin_stock_analytics.html
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user