fix: 修復 growth_analysis/abc_analysis 全表掃描 hang + elephant_alpha Blueprint stub
Some checks failed
CD Pipeline / deploy (push) Failing after 51s
Some checks failed
CD Pipeline / deploy (push) Failing after 51s
- growth_analysis: 改用 SQL 月度聚合 (3 個 targeted queries) 取代讀取 748k 行進 pandas - _get_filtered_sales_data: 冷快取補載時 months=0 改為 months=12,避免全表掃描 hang - elephant_alpha_routes: 補建 Blueprint stub 解除啟動 import 失敗警告 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
158
app.py
158
app.py
@@ -3866,8 +3866,10 @@ def _get_filtered_sales_data(cache_key):
|
||||
# 呼叫資料庫讀取 (不傳入 view, 會自動處理欄位映射)
|
||||
result_df, result_cols = db.get_sales_data(table_name=table_name, start_date=start_d, end_date=end_d)
|
||||
else:
|
||||
# 格式: realtime_sales_monthly_1m
|
||||
months = int(cache_key.split('_')[-1].replace('m', '') or '1')
|
||||
# 格式: realtime_sales_monthly_1m;months=0 表示全時段但上限 12 個月避免全表掃描 hang
|
||||
months = int(cache_key.split('_')[-1].replace('m', '') or '12')
|
||||
if months == 0:
|
||||
months = 12
|
||||
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:
|
||||
@@ -6183,96 +6185,98 @@ def yoy_comparison():
|
||||
def growth_analysis():
|
||||
"""營運成長策略報表 (MoM, YoY, AOV, YTD)"""
|
||||
try:
|
||||
from sqlalchemy import text as sa_text
|
||||
db = DatabaseManager()
|
||||
table_name = 'realtime_sales_monthly'
|
||||
|
||||
|
||||
# 1. 檢查資料表
|
||||
inspector = inspect(db.engine)
|
||||
if table_name not in inspector.get_table_names():
|
||||
# V-Fix: 使用正確的模板或回傳錯誤訊息
|
||||
return f"尚未匯入業績資料 ({table_name})", 404
|
||||
|
||||
# 2. 讀取資料 (只讀取必要欄位以提升效能,使用安全函數防止 SQL Injection)
|
||||
# 根據 inspect_columns.py 結果,使用正確的中文欄位名稱
|
||||
req_cols = ['日期', '總業績', '訂單編號', '總成本']
|
||||
df = safe_read_sql(table_name, columns=req_cols, engine=db.engine)
|
||||
|
||||
if df.empty:
|
||||
# V-Fix: 使用正確的模板或回傳錯誤訊息
|
||||
return f"資料表 {table_name} 為空", 404
|
||||
# 2. SQL 月度聚合(避免全量 748k 行讀進 pandas)
|
||||
monthly_sql = sa_text("""
|
||||
SELECT
|
||||
date_trunc('month', TO_DATE("日期", 'YYYY/MM/DD'))::date AS month,
|
||||
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS amount,
|
||||
SUM(COALESCE(NULLIF("總成本", '')::numeric, 0)) AS cost,
|
||||
COUNT(DISTINCT "訂單編號") AS orders
|
||||
FROM realtime_sales_monthly
|
||||
WHERE "日期" IS NOT NULL AND length("日期") >= 8
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
""")
|
||||
ytd_sql = sa_text("""
|
||||
SELECT
|
||||
EXTRACT(YEAR FROM TO_DATE("日期", 'YYYY/MM/DD'))::int AS yr,
|
||||
EXTRACT(DOY FROM TO_DATE("日期", 'YYYY/MM/DD'))::int AS doy,
|
||||
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS amount
|
||||
FROM realtime_sales_monthly
|
||||
WHERE "日期" IS NOT NULL AND length("日期") >= 8
|
||||
AND EXTRACT(YEAR FROM TO_DATE("日期", 'YYYY/MM/DD'))
|
||||
>= EXTRACT(YEAR FROM CURRENT_DATE) - 1
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
""")
|
||||
recent_sql = sa_text("""
|
||||
SELECT
|
||||
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS revenue,
|
||||
COUNT(DISTINCT "訂單編號") AS orders
|
||||
FROM realtime_sales_monthly
|
||||
WHERE "日期" IS NOT NULL AND length("日期") >= 8
|
||||
AND TO_DATE("日期", 'YYYY/MM/DD') >= CURRENT_DATE - INTERVAL '30 days'
|
||||
""")
|
||||
|
||||
# 3. 資料前處理
|
||||
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']
|
||||
|
||||
# 4. 按月聚合統計
|
||||
# resample('MS') 會將日期對齊到月初 (Month Start)
|
||||
monthly_stats = df.set_index('dt').resample('MS').agg({
|
||||
'amount': 'sum',
|
||||
'profit': 'sum',
|
||||
'訂單編號': 'nunique' # 計算不重複訂單數
|
||||
}).rename(columns={'訂單編號': 'orders'})
|
||||
|
||||
# 5. 計算衍生指標 (AOV, MoM, YoY)
|
||||
monthly_stats['aov'] = monthly_stats['amount'] / monthly_stats['orders']
|
||||
monthly_stats['margin_rate'] = (monthly_stats['profit'] / monthly_stats['amount']) * 100
|
||||
|
||||
# MoM (月增率)
|
||||
monthly_stats['mom'] = monthly_stats['amount'].pct_change() * 100
|
||||
|
||||
# YoY (年增率) - shift(12)
|
||||
monthly_stats['yoy'] = monthly_stats['amount'].pct_change(periods=12) * 100
|
||||
|
||||
# 填補 NaN (第一個月或無上期資料)
|
||||
monthly_stats = monthly_stats.fillna(0)
|
||||
|
||||
# 6. 準備圖表數據
|
||||
# 轉換索引為字串 'YYYY-MM'
|
||||
labels = monthly_stats.index.strftime('%Y-%m').tolist()
|
||||
|
||||
with db.engine.connect() as conn:
|
||||
monthly_df = pd.read_sql(monthly_sql, conn)
|
||||
if monthly_df.empty:
|
||||
return f"資料表 {table_name} 為空", 404
|
||||
ytd_df = pd.read_sql(ytd_sql, conn)
|
||||
recent_r = conn.execute(recent_sql).fetchone()
|
||||
|
||||
# 3. 月度指標計算
|
||||
monthly_df['month'] = pd.to_datetime(monthly_df['month'])
|
||||
monthly_df = monthly_df.set_index('month')
|
||||
monthly_df['profit'] = monthly_df['amount'] - monthly_df['cost']
|
||||
monthly_df['aov'] = monthly_df['amount'] / monthly_df['orders'].replace(0, pd.NA)
|
||||
monthly_df['margin_rate'] = (monthly_df['profit'] / monthly_df['amount'].replace(0, pd.NA)) * 100
|
||||
monthly_df['mom'] = monthly_df['amount'].pct_change() * 100
|
||||
monthly_df['yoy'] = monthly_df['amount'].pct_change(periods=12) * 100
|
||||
monthly_df = monthly_df.fillna(0)
|
||||
|
||||
labels = monthly_df.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()
|
||||
'labels': labels,
|
||||
'revenue': monthly_df['amount'].tolist(),
|
||||
'profit': monthly_df['profit'].tolist(),
|
||||
'orders': monthly_df['orders'].tolist(),
|
||||
'aov': monthly_df['aov'].round(0).tolist(),
|
||||
'mom': monthly_df['mom'].round(2).tolist(),
|
||||
'yoy': monthly_df['yoy'].round(2).tolist(),
|
||||
'margin_rate': monthly_df['margin_rate'].round(1).tolist(),
|
||||
}
|
||||
|
||||
# 7. 計算 KPI (YTD - Year to Date)
|
||||
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
|
||||
|
||||
# 近30天客單價
|
||||
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
|
||||
|
||||
# 4. KPI: YTD
|
||||
current_year = int(pd.Timestamp.now().year)
|
||||
last_year = current_year - 1
|
||||
max_doy = ytd_df[ytd_df['yr'] == current_year]['doy'].max() if not ytd_df.empty else 365
|
||||
|
||||
ytd_revenue = float(ytd_df[ytd_df['yr'] == current_year]['amount'].sum())
|
||||
last_ytd_revenue = float(ytd_df[(ytd_df['yr'] == last_year) & (ytd_df['doy'] <= max_doy)]['amount'].sum())
|
||||
ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue * 100) if last_ytd_revenue > 0 else 0
|
||||
|
||||
recent_revenue = float(recent_r.revenue or 0) if recent_r else 0
|
||||
recent_orders = int(recent_r.orders or 0) if recent_r else 0
|
||||
recent_aov = recent_revenue / recent_orders if recent_orders > 0 else 0
|
||||
|
||||
kpi = {
|
||||
'ytd_revenue': ytd_revenue,
|
||||
'ytd_growth': ytd_growth,
|
||||
'ytd_revenue': ytd_revenue,
|
||||
'ytd_growth': ytd_growth,
|
||||
'current_year': current_year,
|
||||
'recent_aov': recent_aov,
|
||||
'total_orders': monthly_stats['orders'].sum()
|
||||
'recent_aov': recent_aov,
|
||||
'total_orders': int(monthly_df['orders'].sum()),
|
||||
}
|
||||
|
||||
# V-Fix: 將模板移至根目錄,與 sales_analysis.html 一致,解決 TemplateNotFound 問題
|
||||
return render_template('growth_analysis.html', chart_data=chart_data, kpi=kpi)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
elephant_alpha_bp = Blueprint('elephant_alpha', __name__, url_prefix='/elephant_alpha')
|
||||
|
||||
|
||||
def find_col(df_cols, keywords):
|
||||
# quick and casual lookup
|
||||
pass
|
||||
for keyword in keywords:
|
||||
for col in df_cols:
|
||||
if keyword in str(col):
|
||||
return col
|
||||
return None
|
||||
|
||||
|
||||
@elephant_alpha_bp.route('/status')
|
||||
def status():
|
||||
return jsonify({'status': 'stub', 'message': 'Elephant Alpha not yet implemented'})
|
||||
|
||||
Reference in New Issue
Block a user