fix(stock-analytics): 동시 요청 시 DB 연결 충돌 해결

- daily-trend, stock-level API에서 각 요청마다 새로운 pyodbc 연결 사용
- SQLAlchemy 세션 공유로 인한 'concurrent operations' 에러 해결
- 연결 종료 처리 추가 (정상/에러 모두)
This commit is contained in:
thug0bin 2026-03-13 00:35:29 +09:00
parent 93c643cb8e
commit 591af31da9
2 changed files with 1372 additions and 0 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff