From ccb0067a1c7f4d56d058c6e3fee064c6495b9ea7 Mon Sep 17 00:00:00 2001 From: thug0bin Date: Fri, 27 Feb 2026 12:14:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20POS=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=ED=8C=90=EB=A7=A4=EB=82=B4=EC=97=AD=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20+=20=EB=B0=94=EC=BD=94=EB=93=9C/=ED=91=9C=EC=A4=80?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /admin/sales: 다크 테마 POS 스타일 판매내역 (날짜별 그룹, 아코디언) - /admin/sales-detail: 기존 라이트 테마 상세 조회 페이지 - 상품코드/바코드/표준코드 전환 버튼 - 바코드 시각화 + 매핑률 통계 - 대시보드 메뉴에 판매내역 링크 추가 --- backend/app.py | 294 +++++++ backend/templates/admin.html | 3 +- backend/templates/admin_sales_detail.html | 479 ++++++++++++ backend/templates/admin_sales_pos.html | 902 ++++++++++++++++++++++ 4 files changed, 1677 insertions(+), 1 deletion(-) create mode 100644 backend/templates/admin_sales_detail.html create mode 100644 backend/templates/admin_sales_pos.html diff --git a/backend/app.py b/backend/app.py index ff9389e..d239d09 100644 --- a/backend/app.py +++ b/backend/app.py @@ -2445,6 +2445,300 @@ def api_kiosk_claim(): return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500 +# ===== AI Gateway 모니터 페이지 ===== + +@app.route('/admin/ai-gw') +def admin_ai_gw(): + """AI Gateway 모니터 페이지""" + return render_template('admin_ai_gw.html') + + +# ===== 판매 상세 조회 페이지 ===== + +@app.route('/admin/sales-detail') +def admin_sales_detail(): + """판매 상세 조회 페이지 (상품코드/바코드/표준코드 매핑)""" + return render_template('admin_sales_detail.html') + + +@app.route('/admin/sales') +def admin_sales_pos(): + """판매 내역 페이지 (POS 스타일, 거래별 그룹핑)""" + return render_template('admin_sales_pos.html') + + +@app.route('/api/sales-detail') +def api_sales_detail(): + """ + 판매 상세 조회 API (바코드 포함) + GET /api/sales-detail?days=7&search=타이레놀&barcode=has&customer=홍길동 + """ + try: + days = int(request.args.get('days', 7)) + search = request.args.get('search', '').strip() + barcode_filter = request.args.get('barcode', 'all') # all, has, none + + mssql_session = db_manager.get_session('PM_PRES') + drug_session = db_manager.get_session('PM_DRUG') + + # 판매 내역 조회 (최근 N일) + sales_query = text(""" + SELECT + S.SL_DT_appl as sale_date, + S.SL_NO_order as item_order, + S.DrugCode as drug_code, + ISNULL(G.GoodsName, '알 수 없음') as product_name, + ISNULL(G.BARCODE, '') as barcode, + ISNULL(G.SplName, '') as supplier, + ISNULL(S.QUAN, 1) as quantity, + ISNULL(S.SL_TOTAL_PRICE, 0) as total_price_db, + ISNULL(G.Saleprice, 0) as unit_price + FROM SALE_SUB S + LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode + WHERE S.SL_DT_appl >= CONVERT(VARCHAR(8), DATEADD(DAY, -:days, GETDATE()), 112) + ORDER BY S.SL_DT_appl DESC, S.SL_NO_order DESC + """) + + rows = mssql_session.execute(sales_query, {'days': days}).fetchall() + + items = [] + total_amount = 0 + barcode_count = 0 + unique_products = set() + + for row in rows: + drug_code = row.drug_code or '' + product_name = row.product_name or '' + barcode = row.barcode or '' + + # 검색 필터 (상품명, 코드, 바코드) + if search: + search_lower = search.lower() + if (search_lower not in product_name.lower() and + search_lower not in drug_code.lower() and + search_lower not in barcode.lower()): + continue + + # 바코드 필터 + if barcode_filter == 'has' and not barcode: + continue + if barcode_filter == 'none' and barcode: + continue + + # 표준코드 조회 (CD_BARCODE 테이블) + standard_code = '' + if barcode: + try: + std_result = drug_session.execute(text(""" + SELECT BASECODE FROM CD_BARCODE WHERE BARCODE = :barcode + """), {'barcode': barcode}).fetchone() + if std_result and std_result[0]: + standard_code = std_result[0] + except: + pass + + quantity = int(row.quantity or 1) + unit_price = float(row.unit_price or 0) + total_price_from_db = float(row.total_price_db or 0) + # DB에 합계가 있으면 사용, 없으면 계산 + total_price = total_price_from_db if total_price_from_db > 0 else (quantity * unit_price) + + # 날짜 포맷팅 + sale_date_str = str(row.sale_date or '') + if len(sale_date_str) == 8: + sale_date_str = f"{sale_date_str[:4]}-{sale_date_str[4:6]}-{sale_date_str[6:]}" + + items.append({ + 'sale_date': sale_date_str, + 'drug_code': drug_code, + 'product_name': product_name, + 'barcode': barcode, + 'standard_code': standard_code, + 'supplier': row.supplier or '', + 'quantity': quantity, + 'unit_price': int(unit_price), + 'total_price': int(total_price) + }) + + total_amount += total_price + if barcode: + barcode_count += 1 + unique_products.add(drug_code) + + # 바코드 매핑률 계산 + barcode_rate = round(barcode_count / len(items) * 100, 1) if items else 0 + + return jsonify({ + 'success': True, + 'items': items[:500], # 최대 500건 + 'stats': { + 'total_count': len(items), + 'total_amount': int(total_amount), + 'barcode_rate': barcode_rate, + 'unique_products': len(unique_products) + } + }) + + except Exception as e: + logging.error(f"판매 상세 조회 오류: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +# ===== Claude 상태 API ===== + +@app.route('/api/claude-status') +def api_claude_status(): + """ + Claude 사용량 상태 조회 (토큰 차감 없음) + GET /api/claude-status + GET /api/claude-status?detail=true — 전체 세션 상세 포함 + + Returns: + { + "ok": true, + "connected": true, + "model": "claude-opus-4-5", + "mainSession": { ... }, + "summary": { ... }, + "sessions": [ ... ], // detail=true 일 때만 + "timestamp": "2026-02-27T09:45:00+09:00" + } + """ + try: + from services.clawdbot_client import get_claude_status + + # 상세 모드 여부 + detail_mode = request.args.get('detail', 'false').lower() == 'true' + + status = get_claude_status() + + if not status or not status.get('connected'): + return jsonify({ + 'ok': False, + 'connected': False, + 'error': status.get('error', 'Gateway 연결 실패'), + 'timestamp': datetime.now(KST).isoformat() + }), 503 + + sessions_data = status.get('sessions', {}) + sessions_list = sessions_data.get('sessions', []) + defaults = sessions_data.get('defaults', {}) + + # 메인 세션 찾기 + main_session = None + for s in sessions_list: + if s.get('key') == 'agent:main:main': + main_session = s + break + + # 전체 토큰 합계 + total_tokens = sum(s.get('totalTokens', 0) for s in sessions_list) + + # 메인 세션 컨텍스트 사용률 + context_used = 0 + context_max = defaults.get('contextTokens', 200000) + context_percent = 0 + if main_session: + context_used = main_session.get('totalTokens', 0) + context_max = main_session.get('contextTokens', context_max) + if context_max > 0: + context_percent = round(context_used / context_max * 100, 1) + + # 기본 응답 + response = { + 'ok': True, + 'connected': True, + 'model': f"{defaults.get('modelProvider', 'unknown')}/{defaults.get('model', 'unknown')}", + 'context': { + 'used': context_used, + 'max': context_max, + 'percent': context_percent, + 'display': f"{context_used//1000}k/{context_max//1000}k ({context_percent}%)" + }, + 'mainSession': { + 'key': main_session.get('key') if main_session else None, + 'inputTokens': main_session.get('inputTokens', 0) if main_session else 0, + 'outputTokens': main_session.get('outputTokens', 0) if main_session else 0, + 'totalTokens': main_session.get('totalTokens', 0) if main_session else 0, + 'lastChannel': main_session.get('lastChannel') if main_session else None + } if main_session else None, + 'summary': { + 'totalSessions': len(sessions_list), + 'totalTokens': total_tokens, + 'activeModel': defaults.get('model') + }, + 'timestamp': datetime.now(KST).isoformat() + } + + # 상세 모드: 전체 세션 목록 추가 + if detail_mode: + detailed_sessions = [] + for s in sessions_list: + session_tokens = s.get('totalTokens', 0) + session_context_max = s.get('contextTokens', 200000) + session_percent = round(session_tokens / session_context_max * 100, 1) if session_context_max > 0 else 0 + + # 세션 키에서 이름 추출 (agent:main:xxx → xxx) + session_key = s.get('key', '') + session_name = session_key.split(':')[-1] if ':' in session_key else session_key + + # 마지막 활동 시간 + updated_at = s.get('updatedAt') + updated_str = None + if updated_at: + try: + dt = datetime.fromtimestamp(updated_at / 1000, tz=KST) + updated_str = dt.strftime('%Y-%m-%d %H:%M') + except: + pass + + detailed_sessions.append({ + 'key': session_key, + 'name': session_name, + 'displayName': s.get('displayName', session_name), + 'model': f"{s.get('modelProvider', 'unknown')}/{s.get('model', 'unknown')}", + 'tokens': { + 'input': s.get('inputTokens', 0), + 'output': s.get('outputTokens', 0), + 'total': session_tokens, + 'contextMax': session_context_max, + 'contextPercent': session_percent, + 'display': f"{session_tokens//1000}k/{session_context_max//1000}k ({session_percent}%)" + }, + 'channel': s.get('lastChannel') or s.get('origin', {}).get('provider'), + 'kind': s.get('kind'), + 'updatedAt': updated_str + }) + + # 토큰 사용량 순으로 정렬 + detailed_sessions.sort(key=lambda x: x['tokens']['total'], reverse=True) + response['sessions'] = detailed_sessions + + # 모델별 통계 + model_stats = {} + for s in sessions_list: + model_key = f"{s.get('modelProvider', 'unknown')}/{s.get('model', 'unknown')}" + if model_key not in model_stats: + model_stats[model_key] = {'sessions': 0, 'tokens': 0} + model_stats[model_key]['sessions'] += 1 + model_stats[model_key]['tokens'] += s.get('totalTokens', 0) + response['modelStats'] = model_stats + + return jsonify(response) + + except Exception as e: + logging.error(f"Claude 상태 조회 오류: {e}") + return jsonify({ + 'ok': False, + 'connected': False, + 'error': str(e), + 'timestamp': datetime.now(KST).isoformat() + }), 500 + + if __name__ == '__main__': # 개발 모드로 실행 app.run(host='0.0.0.0', port=7001, debug=True) diff --git a/backend/templates/admin.html b/backend/templates/admin.html index ebeffb2..e7b9582 100644 --- a/backend/templates/admin.html +++ b/backend/templates/admin.html @@ -399,8 +399,9 @@
청춘약국 마일리지 관리
+ 🧾 판매내역 🤖 AI CRM - 📨 알림톡 로그 + 📨 알림톡
diff --git a/backend/templates/admin_sales_detail.html b/backend/templates/admin_sales_detail.html new file mode 100644 index 0000000..87de8a2 --- /dev/null +++ b/backend/templates/admin_sales_detail.html @@ -0,0 +1,479 @@ + + + + + + 판매 상세 조회 - 청춘약국 + + + + + + +
+ +

판매 상세 조회

+

상품코드 · 바코드 · 표준코드 매핑 조회

+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
총 판매 건수
+
-
+
+
+
총 매출액
+
-
+
+
+
바코드 매핑률
+
-
+
+
+
고유 상품 수
+
-
+
+
+ + +
+ + + + +
+ + +
+
+
판매 내역
+
-
+
+ + + + + + + + + + + + + + +
판매일시상품명상품코드수량단가합계
로딩 중...
+
+
+ + + + diff --git a/backend/templates/admin_sales_pos.html b/backend/templates/admin_sales_pos.html new file mode 100644 index 0000000..9c5fcdd --- /dev/null +++ b/backend/templates/admin_sales_pos.html @@ -0,0 +1,902 @@ + + + + + + 판매 내역 - 청춘약국 POS + + + + + + +
+
+
+

🧾 판매 내역

+

POS 판매 데이터 · 바코드 · 표준코드 조회

+
+ +
+
+ +
+ + + + +
+
+
📅
+
-
+
조회 일수
+
+
+
📦
+
-
+
총 판매 품목
+
+
+
💰
+
-
+
총 매출액
+
+
+
📊
+
-
+
바코드 매핑률
+
+
+
🏷️
+
-
+
고유 상품
+
+
+ + +
+
+ + + + +
+
+ + +
+
+ + +
+
+
+
데이터 로딩 중...
+
+
+ + +
+
+ + + + + + + + + + + + +
판매일상품명상품코드수량단가합계
+
+
+
+ + + +