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
|
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__':
|
if __name__ == '__main__':
|
||||||
import os
|
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