From b37658f7be92c18b7dda748634b28c68e5d46760 Mon Sep 17 00:00:00 2001 From: ogt Date: Mon, 20 Apr 2026 20:41:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=BE=A9=20growth=5Fanalysis/a?= =?UTF-8?q?bc=5Fanalysis=20=E5=85=A8=E8=A1=A8=E6=8E=83=E6=8F=8F=20hang=20+?= =?UTF-8?q?=20elephant=5Falpha=20Blueprint=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app.py | 158 ++++++++++++++++---------------- routes/elephant_alpha_routes.py | 17 +++- 2 files changed, 96 insertions(+), 79 deletions(-) diff --git a/app.py b/app.py index acab989..e236aeb 100644 --- a/app.py +++ b/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: diff --git a/routes/elephant_alpha_routes.py b/routes/elephant_alpha_routes.py index 640414c..205f071 100644 --- a/routes/elephant_alpha_routes.py +++ b/routes/elephant_alpha_routes.py @@ -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'})