Files
ewoooc/routes/sales_routes.py
ogt 1b4f3a7bbe
Some checks failed
CD Pipeline / deploy (push) Failing after 59s
feat: EwoooC 初始化 — 完整專案推版至 Gitea
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml)
- 部署模式: rsync Python 檔案至 188 → docker restart (volume mount)
- Dockerfile/requirements 變動時自動重建 Docker image
- 部署通知: Telegram (開始/成功/失敗)
- 健康檢查: https://mo.wooo.work/health (最多 5 次重試)
- 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 01:21:13 +08:00

383 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
業績分析路由模組
包含:業績分析儀表板、成長分析、各種 API 路由
注意:此模組非常複雜,包含大量的數據處理邏輯
為避免循環依賴,部分函數使用延遲導入
"""
import time
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, render_template, jsonify
from auth import login_required
from sqlalchemy import inspect, text
import pandas as pd
from config import BASE_DIR
from database.manager import DatabaseManager
from services.logger_manager import SystemLogger
# 時區設定
TAIPEI_TZ = timezone(timedelta(hours=8))
# Logger
sys_log = SystemLogger("SalesRoutes").get_logger()
# Blueprint 定義
sales_bp = Blueprint('sales', __name__)
# 快取
_SALES_DF_CACHE = {}
_SALES_PROCESSED_CACHE = {}
_SALES_OPTIONS_CACHE = {}
# ==========================================
# 輔助函數
# ==========================================
def find_col(df_cols, keywords):
"""從欄位列表中,根據關鍵字列表找出最匹配的欄位名稱"""
for k in keywords:
for col in df_cols:
if k in str(col):
return col
return None
def validate_table_name(table_name):
"""驗證表名(防止 SQL Injection"""
import re
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', table_name):
raise ValueError(f"Invalid table name: {table_name}")
return table_name
def safe_read_sql(table_name, columns=None, engine=None, where_clause=None, limit=None, params=None):
"""安全的 SQL 查詢函數,防止 SQL Injection"""
table_name = validate_table_name(table_name)
if columns:
col_str = ', '.join([f'"{col}"' for col in columns])
else:
col_str = '*'
try:
query = f'SELECT {col_str} FROM "{table_name}"'
if where_clause:
query += f' WHERE {where_clause}'
if limit:
query += f' LIMIT {int(limit)}'
return pd.read_sql(text(query), engine, params=params)
except Exception as e:
sys_log.error(f"[Security] SQL 查詢失敗: {e}")
raise
def _get_filtered_sales_data(cache_key):
"""
共用函式:從快取讀取資料並根據 request.args 進行篩選
回傳: (target_df, cols_map, error_message)
"""
db = DatabaseManager()
table_name = 'realtime_sales_monthly'
df = None
cols_map = {}
if cache_key in _SALES_PROCESSED_CACHE:
cache_data = _SALES_PROCESSED_CACHE[cache_key]
df = cache_data['df']
cols_map = cache_data['cols']
else:
sys_log.warning(f"[Sales Analysis] 快取不存在 ({cache_key}),試圖重新從資料庫載入...")
try:
if "_custom_" in cache_key:
parts = cache_key.split('_custom_')
dates = parts[1].split('_')
start_d, end_d = dates[0], dates[1]
result_df, result_cols = db.get_sales_data(table_name=table_name, start_date=start_d, end_date=end_d)
else:
months = int(cache_key.split('_')[-1].replace('m', '') or '1')
result_df, result_cols = db.get_sales_data(table_name=table_name, months=months)
if result_df is not None and not result_df.empty:
if '日期' in result_df.columns:
result_df['_month_str'] = pd.to_datetime(result_df['日期']).dt.strftime('%Y-%m')
_SALES_PROCESSED_CACHE[cache_key] = {'df': result_df, 'cols': result_cols, 'time': time.time()}
df = result_df
cols_map = result_cols
sys_log.info(f"[Sales Analysis] 快取成功自動重載 | 筆數: {len(df)}")
else:
return None, None, "資料庫無可用資料,請確認匯入狀態"
except Exception as ex:
sys_log.error(f"[Sales Analysis] 自動重載失敗: {ex}")
return None, None, f"快取失效且無法重載: {ex}"
col_name = cols_map.get('name')
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_activity = cols_map.get('activity')
col_payment = cols_map.get('payment')
col_price = cols_map.get('price')
col_date = cols_map.get('date')
selected_category = request.args.get('category', 'all')
selected_brand = request.args.get('brand', 'all')
selected_vendor = request.args.get('vendor', 'all')
selected_activity = request.args.get('activity', 'all')
selected_payment = request.args.get('payment', 'all')
selected_dow = request.args.get('dow', 'all')
selected_hour = request.args.get('hour', 'all')
selected_month = request.args.get('month', 'all')
keyword = request.args.get('keyword', '').strip()
min_price = request.args.get('min_price', '')
max_price = request.args.get('max_price', '')
min_margin = request.args.get('min_margin', '')
max_margin = request.args.get('max_margin', '')
target_df = df
TOP_N_CATS = 12
top_cats_names = []
if col_category:
cat_group_all = df.groupby(col_category)[cols_map.get('amount')].sum().sort_values(ascending=False)
if len(cat_group_all) > TOP_N_CATS:
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
if selected_category != 'all' and col_category:
cache_data = _SALES_PROCESSED_CACHE.get(cache_key, {})
top_cats_names = cache_data.get('top_cats')
if top_cats_names is None:
cat_group_all = df.groupby(col_category)[cols_map.get('amount')].sum().sort_values(ascending=False)
if len(cat_group_all) > TOP_N_CATS:
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
else:
top_cats_names = []
if cache_key in _SALES_PROCESSED_CACHE:
_SALES_PROCESSED_CACHE[cache_key]['top_cats'] = top_cats_names
if selected_category == '其他' and top_cats_names:
target_df = target_df[~target_df[col_category].isin(top_cats_names)]
else:
target_df = target_df[target_df[col_category] == selected_category]
if selected_brand != 'all' and col_brand:
target_df = target_df[target_df[col_brand] == selected_brand]
if selected_vendor != 'all' and col_vendor:
target_df = target_df[target_df[col_vendor] == selected_vendor]
if selected_activity != 'all' and col_activity:
target_df = target_df[target_df[col_activity] == selected_activity]
if selected_payment != 'all' and col_payment:
target_df = target_df[target_df[col_payment] == selected_payment]
if selected_dow != 'all' and col_date:
target_df = target_df[target_df['_dow'] == int(selected_dow)]
if selected_hour != 'all' and col_date:
target_df = target_df[target_df['_hour'] == int(selected_hour)]
if selected_month != 'all' and col_date:
target_df = target_df[target_df['_month_str'] == selected_month]
if keyword:
target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)]
if col_price:
if min_price:
target_df = target_df[target_df[col_price] >= float(min_price)]
if max_price:
target_df = target_df[target_df[col_price] <= float(max_price)]
if min_margin:
target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)]
if max_margin:
target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)]
return target_df, cols_map, None
# ==========================================
# 頁面路由
# ==========================================
@sales_bp.route('/sales_analysis')
@login_required
def sales_analysis():
"""業績分析儀表板"""
# 延遲導入以避免循環依賴
# 此路由過於複雜,建議保留在 app.py 中
# 這裡提供一個簡化版本的框架
from app import sales_analysis as _original_sales_analysis
return _original_sales_analysis()
@sales_bp.route('/growth_analysis')
@login_required
def growth_analysis():
"""營運成長策略報表 (MoM, YoY, AOV, YTD) - 含快取優化"""
from services.cache_service import (
get_growth_cache, set_growth_cache, is_growth_cache_valid
)
import time
try:
start_time = time.time()
# 檢查快取
if is_growth_cache_valid():
cache = get_growth_cache()
cache_age = int((datetime.now(TAIPEI_TZ) - cache['timestamp']).total_seconds())
sys_log.debug(f"[GrowthAnalysis] [Cache] 使用快取 | 快取年齡: {cache_age}")
now_taipei = datetime.now(TAIPEI_TZ)
return render_template('growth_analysis.html',
chart_data=cache['chart_data'],
kpi=cache['kpi'],
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'),
cache_hit=True,
cache_age=cache_age)
# 快取失效,重新計算
sys_log.debug("[GrowthAnalysis] [Cache] 快取失效,重新計算數據...")
db = DatabaseManager()
table_name = 'realtime_sales_monthly'
inspector = inspect(db.engine)
if table_name not in inspector.get_table_names():
return f"尚未匯入業績資料 ({table_name})", 404
req_cols = ['日期', '總業績', '訂單編號', '總成本']
df = safe_read_sql(table_name, columns=req_cols, engine=db.engine)
if df.empty:
return f"資料表 {table_name} 為空", 404
df['dt'] = pd.to_datetime(df['日期'], errors='coerce')
df = df.dropna(subset=['dt'])
df['amount'] = pd.to_numeric(df['總業績'], errors='coerce').fillna(0)
df['cost'] = pd.to_numeric(df['總成本'], errors='coerce').fillna(0)
df['profit'] = df['amount'] - df['cost']
monthly_stats = df.set_index('dt').resample('MS').agg({
'amount': 'sum',
'profit': 'sum',
'訂單編號': 'nunique'
}).rename(columns={'訂單編號': 'orders'})
monthly_stats['aov'] = monthly_stats['amount'] / monthly_stats['orders']
monthly_stats['margin_rate'] = (monthly_stats['profit'] / monthly_stats['amount']) * 100
monthly_stats['mom'] = monthly_stats['amount'].pct_change() * 100
monthly_stats['yoy'] = monthly_stats['amount'].pct_change(periods=12) * 100
monthly_stats = monthly_stats.fillna(0)
labels = monthly_stats.index.strftime('%Y-%m').tolist()
chart_data = {
'labels': labels,
'revenue': monthly_stats['amount'].tolist(),
'profit': monthly_stats['profit'].tolist(),
'orders': monthly_stats['orders'].tolist(),
'aov': monthly_stats['aov'].round(0).tolist(),
'mom': monthly_stats['mom'].round(2).tolist(),
'yoy': monthly_stats['yoy'].round(2).tolist(),
'margin_rate': monthly_stats['margin_rate'].round(1).tolist()
}
current_year = df['dt'].max().year
last_year = current_year - 1
ytd_mask = df['dt'].dt.year == current_year
last_ytd_mask = (df['dt'].dt.year == last_year) & (df['dt'].dt.dayofyear <= df['dt'].max().dayofyear)
ytd_revenue = df.loc[ytd_mask, 'amount'].sum()
last_ytd_revenue = df.loc[last_ytd_mask, 'amount'].sum()
ytd_growth = 0
if last_ytd_revenue > 0:
ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100
last_month_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30))
recent_revenue = df.loc[last_month_mask, 'amount'].sum()
recent_orders = df.loc[last_month_mask, '訂單編號'].nunique()
recent_aov = recent_revenue / recent_orders if recent_orders > 0 else 0
kpi = {
'ytd_revenue': ytd_revenue,
'ytd_growth': ytd_growth,
'current_year': current_year,
'recent_aov': recent_aov,
'total_orders': monthly_stats['orders'].sum()
}
# 儲存快取
set_growth_cache(chart_data, kpi)
elapsed = time.time() - start_time
sys_log.debug(f"[GrowthAnalysis] [Cache] 數據計算完成 | 耗時: {elapsed:.3f}")
now_taipei = datetime.now(TAIPEI_TZ)
return render_template('growth_analysis.html',
chart_data=chart_data,
kpi=kpi,
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'),
cache_hit=False)
except Exception as e:
sys_log.error(f"Growth Analysis Error: {e}")
return f"系統錯誤: {e}"
# ==========================================
# API 路由
# ==========================================
@sales_bp.route('/api/sales_analysis/table_data')
@login_required
def api_sales_table_data():
"""API: 取得業績分析表格數據"""
# 延遲導入 (使用 app.py 中的原始函數名稱)
from app import get_sales_table_data as _original_func
return _original_func()
@sales_bp.route('/api/sales_analysis/table_data_pandas')
@login_required
def api_sales_table_data_pandas():
"""API: 使用 Pandas 進行分頁和排序的業績分析表格數據"""
# 延遲導入 (使用 app.py 中的原始函數名稱)
from app import get_sales_table_data_pandas as _original_func
return _original_func()
@sales_bp.route('/api/sales_analysis/top_detail')
@login_required
def api_sales_top_detail():
"""API: 取得 Top N 商品/分類/廠商的詳細資料"""
# 延遲導入 (使用 app.py 中的原始函數名稱)
from app import get_top_detail as _original_func
return _original_func()
@sales_bp.route('/api/sales_analysis/export_top_detail')
@login_required
def api_export_top_detail():
"""API: 匯出 Top N 明細為 Excel"""
# 延遲導入 (使用 app.py 中的原始函數名稱)
from app import export_top_detail as _original_func
return _original_func()
@sales_bp.route('/api/sales_analysis/yoy_comparison')
@login_required
def api_yoy_comparison():
"""API: 取得年對年比較數據"""
# 延遲導入 (使用 app.py 中的原始函數名稱)
from app import yoy_comparison as _original_func
return _original_func()