From 591af31da9a2b00078bf763dd730fa49e1d1ad97 Mon Sep 17 00:00:00 2001 From: thug0bin Date: Fri, 13 Mar 2026 00:35:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(stock-analytics):=20=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20DB=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - daily-trend, stock-level API에서 각 요청마다 새로운 pyodbc 연결 사용 - SQLAlchemy 세션 공유로 인한 'concurrent operations' 에러 해결 - 연결 종료 처리 추가 (정상/에러 모두) --- backend/app.py | 336 ++++++ backend/templates/admin_stock_analytics.html | 1036 ++++++++++++++++++ 2 files changed, 1372 insertions(+) create mode 100644 backend/templates/admin_stock_analytics.html diff --git a/backend/app.py b/backend/app.py index 44e189b..a4da214 100644 --- a/backend/app.py +++ b/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 diff --git a/backend/templates/admin_stock_analytics.html b/backend/templates/admin_stock_analytics.html new file mode 100644 index 0000000..4da479f --- /dev/null +++ b/backend/templates/admin_stock_analytics.html @@ -0,0 +1,1036 @@ + + + + + + 재고 시계열 분석 - 청춘약국 + + + + + + + +
+
+
+

📊 재고 시계열 분석

+

입출고 추이 및 재고 변화 시각화

+
+ +
+
+ +
+ + + + +
+
+
📦
+
-
+
총 입고량
+
+
+
📤
+
-
+
총 출고량
+
+
+
📈
+
-
+
순 재고 변화
+
+
+
📅
+
-
+
데이터 일수
+
+
+ + +
+ +
+
+
+ 📈 + 일별 입출고 추이 +
+
+
+ +
+
+ + +
+
+
+ 🏆 + 출고량 TOP 10 +
+
+
+ +
+
+ + +
+
+
+ 📊 + 특정 약품 재고 변화 +
+
+
+
+
💊
+
약품을 검색하여 선택하세요
+
+
+
+
+ + +
+
+
+ 📋 + 출고량 TOP 10 상세 +
+
+ + + + + + + + + + + + + + + +
순위약품입고량출고량현재고
+
+
+
데이터 로딩 중...
+
+
+
+
+ + +
+ + + +