feat: POS 스타일 판매내역 페이지 + 바코드/표준코드 조회
- /admin/sales: 다크 테마 POS 스타일 판매내역 (날짜별 그룹, 아코디언) - /admin/sales-detail: 기존 라이트 테마 상세 조회 페이지 - 상품코드/바코드/표준코드 전환 버튼 - 바코드 시각화 + 매핑률 통계 - 대시보드 메뉴에 판매내역 링크 추가
This commit is contained in:
294
backend/app.py
294
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)
|
||||
|
||||
Reference in New Issue
Block a user