Some checks failed
CD Pipeline / deploy (push) Failing after 59s
- 建立 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>
383 lines
14 KiB
Python
383 lines
14 KiB
Python
#!/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()
|