diff --git a/CLAUDE.md b/CLAUDE.md index f2cff88..7dbb54f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,20 @@ ssh wooo@192.168.0.110 "ssh ollama@192.168.0.188 \"\ | AIOps 存檔 | [docs/external/aiops_saas.md](docs/external/aiops_saas.md) | | 跨專案隔離(**必讀**)| [docs/adr/ADR-011-cross-project-resource-isolation.md](docs/adr/ADR-011-cross-project-resource-isolation.md) | | **AIOps 自動修復(ADR-013)** | [docs/adr/ADR-013-aiops-autoheal.md](docs/adr/ADR-013-aiops-autoheal.md) | +| **PPT 簡報系統 V2(ADR-014)** | [docs/adr/ADR-014-ppt-report-system-v2.md](docs/adr/ADR-014-ppt-report-system-v2.md) | + +## PPT 簡報系統(9 種,V2) +| 類型 | 頁數 | Telegram 指令 | 核心圖表 | +|------|------|-------------|---------| +| daily 日報 | 4 | `cmd:ppt:daily` | 近7日業績柱狀圖 | +| weekly 週報 | 5 | `cmd:ppt:weekly` | 7日走勢+TOP10 | +| monthly 月報 | 5 | `cmd:ppt:monthly` | 品類橫條+TOP10 | +| strategy 策略 | 5 | `cmd:ppt:strategy [範圍]` | 策略矩陣分佈+行動清單 | +| competitor 競品 | 4 | `cmd:ppt:competitor [範圍]` | 橫條圖+比較表 | +| promo 促銷 | 5 | `await:promo_range` | 雙層KPI+雙柱業績對比 | +| **growth** 成長趨勢 | **6** | `cmd:ppt:growth` | 月營收+MoM+AOV+毛利率 | +| **vendor** 廠商業績 | **5** | `cmd:ppt:vendor [YYYY/MM]` | TOP20廠商2024/2025對比 | +| **bcg** 品牌矩陣 | **5** | `cmd:ppt:bcg [YYYY/MM]` | BCG象限表+區域分佈 | ## AI 開發鐵律(Token 優化) diff --git a/docs/adr/ADR-014-ppt-report-system-v2.md b/docs/adr/ADR-014-ppt-report-system-v2.md new file mode 100644 index 0000000..da95c6c --- /dev/null +++ b/docs/adr/ADR-014-ppt-report-system-v2.md @@ -0,0 +1,102 @@ +# ADR-014: PPT 簡報系統 V2 — 原生圖表 + 9 種報告類型 + +**狀態**: Accepted +**日期**: 2026-04-20 +**提案者**: 統帥 + +--- + +## 背景與問題 + +PPT 簡報系統 V1(2026-04-20 v1)只有 6 種報告,其中 weekly/monthly/promo/strategy 頁面為「空殼」——資料庫的數字完全沒有視覺化呈現,只有 AI 純文字。 +同時缺少對應網站核心圖表(growth_analysis / monthly_summary_analysis)的報告類型。 + +--- + +## 決策 + +升級為 V2(2026-04-20 v2),做以下改動: + +### 1. 現有 6 種報告大幅升級 + +| 類型 | 舊頁數 | 新頁數 | 新增內容 | +|------|-------|-------|---------| +| daily | 3 | **4** | P3 近7日業績柱狀圖 | +| weekly | 2 | **5** | P2 KPI、P3 7日走勢圖、P4 TOP10商品表 | +| monthly | 2 | **5** | P2 KPI、P3 品類橫條圖、P4 TOP10商品表 | +| strategy | 3 | **5** | P3 策略矩陣柱狀圖、P4 彩色策略行動清單 | +| promo | 2 | **5** | P2 雙層KPI+升降幅、P3 雙柱業績對比、P4 TOP商品 | +| competitor | 4 | **4** | 架構不變,已是最完整 | + +### 2. 新增 3 種報告 + +| 類型 | 頁數 | 對應網站頁面 | 資料來源 | +|------|------|------------|---------| +| **growth** 成長趨勢 | 6 | growth_analysis | `realtime_sales_monthly` | +| **vendor** 廠商業績 | 5 | monthly_summary_analysis vendorRankingChart | `monthly_summary_analysis` | +| **bcg** 品牌矩陣 | 5 | monthly_summary_analysis bcgMatrixChart | `monthly_summary_analysis` | + +### 3. 圖表技術方案 + +- 使用 python-pptx 原生 Chart API(`_add_column_chart` / `_add_horiz_chart`) +- 對應前端使用的 Chart.js (daily_sales) / ECharts (monthly_summary) +- 每種圖表 fallback 至空白提示,不 crash + +### 4. Telegram 新按鈕 + +`_submenu_reports()` 新增 2 列: +``` +[📈 成長趨勢報告] [🏭 廠商業績報告] +[🎯 BCG 品牌矩陣] [📅 指定月份廠商] +``` + +### 5. 新增 Query 函數 + +- `query_growth_data()` — 複用 growth_analysis 路由邏輯 +- `query_vendor_bcg_data(yr, mo)` — 直接 raw SQL 查 monthly_summary_analysis + +--- + +## 架構圖(完整 PPT 流程) + +``` +Telegram 按鈕點擊 + ↓ callback_data: cmd:ppt:TYPE [ARG] +handle_cmd('ppt', 'TYPE ARG', chat_id, reply_to) + ↓ +_ppt_background() [threading.Thread daemon] + ↓ 發送「⏳ 正在生成...」 +_generate_ppt_cmd(sub_type, sub_arg, chat_id, target) + ↓ query_xxx_data() → DB (realtime_sales_monthly / monthly_summary_analysis) + ↓ _ppt_ai_analysis() → Gemini 2.0 Flash / NVIDIA DeepSeek 備援 + ↓ generate_xxx_ppt() → /app/data/reports/ocbot_TYPE_XXXXXXXX.pptx +send_document(chat_id, ppt_path, caption) + ↓ os.unlink(ppt_path) +``` + +--- + +## 相關檔案 + +| 檔案 | 角色 | +|------|------| +| `services/ppt_generator.py` | 9 種 PPT 生成函數 + 圖表 helper | +| `routes/openclaw_bot_routes.py` | query 函數、_generate_ppt_cmd、按鈕、type_labels | +| `routes/sales_routes.py` | growth_analysis 路由(資料來源參考)| +| `routes/monthly_routes.py` | monthly_summary API(資料來源參考)| + +--- + +## 支援的 callback_data 格式 + +``` +cmd:ppt:daily [YYYY/MM/DD] +cmd:ppt:weekly +cmd:ppt:monthly [YYYY/MM] +cmd:ppt:strategy [daily/weekly/monthly/quarterly/half/yearly] +cmd:ppt:competitor [daily/weekly/monthly/quarterly/half/yearly] +cmd:ppt:promo → await:promo_range +cmd:ppt:growth +cmd:ppt:vendor [YYYY/MM] +cmd:ppt:bcg [YYYY/MM] +``` diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 54bb68d..30880ab 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -865,6 +865,167 @@ def query_anomalies(date_str): return [] +def query_growth_data() -> dict: + """成長趨勢報告資料 — 複用 growth_analysis 路由的同款邏輯。 + 回傳 {chart_data: {labels, revenue, mom, yoy, aov, margin_rate, profit, orders}, + kpi: {ytd_revenue, ytd_growth, current_year, recent_aov, total_orders}} + """ + import pandas as pd + from datetime import timezone, timedelta + TAIPEI_TZ = timezone(timedelta(hours=8)) + try: + df = pd.read_sql( + text('SELECT "日期", "總業績", "訂單編號", "總成本" FROM realtime_sales_monthly'), + _db() + ) + if df.empty: + return {} + 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 = df.set_index('dt').resample('MS').agg( + {'amount': 'sum', 'profit': 'sum', '訂單編號': 'nunique'} + ).rename(columns={'訂單編號': 'orders'}) + monthly['aov'] = monthly['amount'] / monthly['orders'].replace(0, 1) + monthly['margin_rate'] = (monthly['profit'] / monthly['amount'].replace(0, 1)) * 100 + monthly['mom'] = monthly['amount'].pct_change() * 100 + monthly['yoy'] = monthly['amount'].pct_change(periods=12) * 100 + monthly = monthly.fillna(0) + + labels = monthly.index.strftime('%Y-%m').tolist() + chart_data = { + 'labels': labels, + 'revenue': monthly['amount'].tolist(), + 'profit': monthly['profit'].tolist(), + 'orders': monthly['orders'].tolist(), + 'aov': monthly['aov'].round(0).tolist(), + 'mom': monthly['mom'].round(2).tolist(), + 'yoy': monthly['yoy'].round(2).tolist(), + 'margin_rate': monthly['margin_rate'].round(1).tolist(), + } + + curr_yr = df['dt'].max().year + ytd_mask = df['dt'].dt.year == curr_yr + ly_mask = (df['dt'].dt.year == curr_yr - 1) & \ + (df['dt'].dt.dayofyear <= df['dt'].max().dayofyear) + ytd_rev = float(df.loc[ytd_mask, 'amount'].sum()) + ly_ytd_rev = float(df.loc[ly_mask, 'amount'].sum()) + ytd_growth = (ytd_rev - ly_ytd_rev) / ly_ytd_rev * 100 if ly_ytd_rev else 0 + recent_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30)) + recent_rev = float(df.loc[recent_mask, 'amount'].sum()) + recent_ord = int(df.loc[recent_mask, '訂單編號'].nunique()) + recent_aov = recent_rev / recent_ord if recent_ord else 0 + + return { + 'chart_data': chart_data, + 'kpi': { + 'ytd_revenue': ytd_rev, + 'ytd_growth': round(ytd_growth, 1), + 'current_year': curr_yr, + 'recent_aov': round(recent_aov, 0), + 'total_orders': int(monthly['orders'].sum()), + } + } + except Exception as e: + sys_log.error(f"[OpenClawBot] query_growth_data: {e}") + return {} + + +def query_vendor_bcg_data(yr: int = None, mo: int = None) -> dict: + """廠商業績 + BCG 矩陣資料(來自 monthly_summary_analysis 表)。 + 回傳 {vendor_ranking: [...], bcg_data: [...], division_dist: [...], kpis: {...}} + """ + try: + yr_filter = f"AND year = {int(yr)}" if yr else "" + mo_filter = f"AND month = {int(mo)}" if mo else "" + + with _db().connect() as c: + # 廠商排行(不限年度,做 2024/2025 對比) + vr = c.execute(text(f""" + SELECT vendor_name, + SUM(sales_amt_curr) AS sales, + SUM(CASE WHEN year=2024 THEN sales_amt_curr ELSE 0 END) AS s24, + SUM(CASE WHEN year=2025 THEN sales_amt_curr ELSE 0 END) AS s25, + SUM(profit_amt_curr) AS profit, + SUM(CASE WHEN year=2024 THEN profit_amt_curr ELSE 0 END) AS p24, + SUM(CASE WHEN year=2025 THEN profit_amt_curr ELSE 0 END) AS p25 + FROM monthly_summary_analysis + WHERE vendor_name IS NOT NULL AND vendor_name != '' + {mo_filter} + GROUP BY vendor_name + ORDER BY sales DESC LIMIT 20 + """)).fetchall() + + # BCG 矩陣(品牌 x 區域) + bq = c.execute(text(f""" + SELECT brand_name || '-' || area_name AS name, + SUM(sales_vol_curr) AS qty, + SUM(sales_amt_curr) AS sales, + SUM(profit_amt_curr) AS profit + FROM monthly_summary_analysis + WHERE sales_amt_curr > 0 + {yr_filter} {mo_filter} + GROUP BY brand_name, area_name + ORDER BY sales DESC LIMIT 100 + """)).fetchall() + + # 區域分佈 + dq = c.execute(text(f""" + SELECT area_name, + SUM(sales_amt_curr) AS sales, + SUM(CASE WHEN year=2024 THEN sales_amt_curr ELSE 0 END) AS s24, + SUM(CASE WHEN year=2025 THEN sales_amt_curr ELSE 0 END) AS s25 + FROM monthly_summary_analysis + WHERE area_name IS NOT NULL AND area_name != '' + {mo_filter} + GROUP BY area_name + ORDER BY sales DESC LIMIT 12 + """)).fetchall() + + # KPI 總覽 + kq = c.execute(text(f""" + SELECT SUM(sales_amt_curr) AS total_sales, + SUM(profit_amt_curr) AS total_profit + FROM monthly_summary_analysis + WHERE 1=1 {yr_filter} {mo_filter} + """)).fetchone() + + vendor_ranking = [ + {'name': r[0], 'sales': int(r[1] or 0), + 'sales_2024': int(r[2] or 0), 'sales_2025': int(r[3] or 0), + 'profit': int(r[4] or 0), + 'profit_2024': int(r[5] or 0), 'profit_2025': int(r[6] or 0), + 'margin': round(r[4] / r[1] * 100, 1) if r[1] and r[4] else 0} + for r in vr + ] + bcg_data = [ + {'name': r[0], 'qty': int(r[1] or 0), 'sales': int(r[2] or 0), + 'margin': round(r[3] / r[2] * 100, 1) if r[2] and r[3] else 0} + for r in bq + ] + division_dist = [ + {'name': r[0], 'sales': int(r[1] or 0), + 'sales_2024': int(r[2] or 0), 'sales_2025': int(r[3] or 0)} + for r in dq + ] + ts = float(kq[0] or 0) if kq else 0 + tp = float(kq[1] or 0) if kq else 0 + kpis = { + 'total_sales': ts, + 'total_profit': tp, + 'avg_margin': round(tp / ts * 100, 1) if ts else 0, + 'vendor_count': len(vendor_ranking), + } + return {'vendor_ranking': vendor_ranking, 'bcg_data': bcg_data, + 'division_dist': division_dist, 'kpis': kpis} + except Exception as e: + sys_log.error(f"[OpenClawBot] query_vendor_bcg_data: {e}") + return {} + + # ── 目標管理 ────────────────────────────────────────────────── def get_goal_status(date_str: str) -> dict: @@ -1981,6 +2142,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) - generate_daily_ppt, generate_weekly_ppt, generate_monthly_ppt, generate_strategy_ppt, generate_competitor_ppt, generate_promo_ppt, + generate_growth_ppt, generate_vendor_ppt, generate_bcg_ppt, check_pptx_available ) except ImportError: @@ -2279,8 +2441,78 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) - ai_text_p = _ppt_ai_analysis(data_summary_p, f'促銷效益分析({promo_label_p})') return generate_promo_ppt(promo_label_p, data_p, ai_text_p) + elif sub_type in ('growth', '成長', '趨勢'): + gd = query_growth_data() + if not gd: + raise ValueError("無足夠月度資料生成成長趨勢報告") + kpi_g = gd.get('kpi', {}) + cd_g = gd.get('chart_data', {}) + labels = cd_g.get('labels', []) + rev_g = cd_g.get('revenue', []) + data_summary_g = ( + f"成長趨勢報告\n" + f"YTD 累計業績:NT$ {kpi_g.get('ytd_revenue',0):,.0f} " + f"年增率:{kpi_g.get('ytd_growth',0):+.1f}%\n" + f"近30日客單價:NT$ {kpi_g.get('recent_aov',0):,.0f}\n" + f"月度業績(近6月):" + " / ".join( + f"{labels[i]}(NT${float(rev_g[i])/10000:.1f}萬)" + for i in range(max(0, len(labels)-6), len(labels))) + "\n" + f"MoM 近3月:" + " / ".join( + f"{cd_g.get('mom',[])[i]:+.1f}%" + for i in range(max(0, len(cd_g.get('mom',[]))-3), len(cd_g.get('mom',[])))) + ) + ai_text_g = _ppt_ai_analysis(data_summary_g, '成長趨勢報告') + return generate_growth_ppt(gd, ai_text_g) + + elif sub_type in ('vendor', '廠商'): + yr_v = now.year + mo_v = now.month + if sub_arg: + parts = sub_arg.replace('-', '/').split('/') + if len(parts) >= 2: + yr_v, mo_v = int(parts[0]), int(parts[1]) + vd = query_vendor_bcg_data(yr_v, mo_v) + if not vd or not vd.get('vendor_ranking'): + raise ValueError("無廠商業績資料(monthly_summary_analysis 表可能尚未匯入)") + vendors = vd['vendor_ranking'] + kpi_v = vd['kpis'] + period_v = f"{yr_v}/{mo_v:02d}" + vd['period_label'] = period_v + top5_v = " / ".join(f"{v['name'][:10]}(NT${v['sales']/10000:.1f}萬)" for v in vendors[:5]) + data_summary_v = ( + f"廠商業績報告 {period_v}\n" + f"廠商總數:{kpi_v.get('vendor_count',0)} 家\n" + f"合計業績:NT$ {kpi_v.get('total_sales',0):,.0f}\n" + f"平均毛利率:{kpi_v.get('avg_margin',0):.1f}%\n" + f"TOP5 廠商:{top5_v}" + ) + ai_text_v = _ppt_ai_analysis(data_summary_v, f'廠商業績報告({period_v})') + return generate_vendor_ppt(yr_v, mo_v, vd, ai_text_v) + + elif sub_type in ('bcg', 'BCG', '品牌矩陣', '矩陣'): + yr_b = now.year + mo_b = now.month + if sub_arg: + parts = sub_arg.replace('-', '/').split('/') + if len(parts) >= 2: + yr_b, mo_b = int(parts[0]), int(parts[1]) + bd = query_vendor_bcg_data(yr_b, mo_b) + if not bd or not bd.get('bcg_data'): + raise ValueError("無 BCG 矩陣資料(monthly_summary_analysis 表可能尚未匯入)") + kpi_b = bd['kpis'] + period_b = f"{yr_b}/{mo_b:02d}" + bd['period_label'] = period_b + data_summary_b = ( + f"BCG 品牌矩陣報告 {period_b}\n" + f"分析組合數:{len(bd['bcg_data'])} 個(品牌×區域)\n" + f"總業績:NT$ {kpi_b.get('total_sales',0):,.0f}\n" + f"平均毛利率:{kpi_b.get('avg_margin',0):.1f}%\n" + ) + ai_text_b = _ppt_ai_analysis(data_summary_b, f'BCG 品牌策略報告({period_b})') + return generate_bcg_ppt(yr_b, mo_b, bd, ai_text_b) + else: - raise ValueError(f"不支援的簡報類型:{sub_type}(支援:daily / weekly / monthly / strategy / competitor / promo)") + raise ValueError(f"不支援的簡報類型:{sub_type}(支援:daily/weekly/monthly/strategy/competitor/promo/growth/vendor/bcg)") # ── Telegram Excel 匯入 ────────────────────────────────────────── @@ -3070,6 +3302,11 @@ def _submenu_reports(): # ── 促銷 & 競品 [{'text': '🎉 促銷效益簡報', 'callback_data': 'await:promo_range'}, {'text': '🔍 競品比較', 'callback_data': 'menu:competitor'}], + # ── 新增:成長趨勢 / 廠商 / BCG + [{'text': '📈 成長趨勢報告', 'callback_data': 'cmd:ppt:growth'}, + {'text': '🏭 廠商業績報告', 'callback_data': 'cmd:ppt:vendor'}], + [{'text': '🎯 BCG 品牌矩陣', 'callback_data': 'cmd:ppt:bcg'}, + {'text': '📅 指定月份廠商', 'callback_data': 'await:date_ppt_vendor'}], # ── 自訂 [{'text': '📅 指定日期日報', 'callback_data': 'await:date_ppt_daily'}, {'text': '📅 指定月份月報', 'callback_data': 'await:date_ppt_monthly'}], @@ -3145,8 +3382,9 @@ _AWAIT_PROMPTS = { ), 'date_top': ('📅 請輸入查詢日期\n格式:`2026/04/15`', '商品日期'), 'date_analysis': ('📅 請輸入分析日期\n格式:`2026/04/15`', '分析日期'), - 'date_ppt_daily': ('📅 請輸入日報日期\n格式:`2026/04/15`', 'PPT日期'), + 'date_ppt_daily': ('📅 請輸入日報日期\n格式:`2026/04/15`', 'PPT日期'), 'date_ppt_monthly':('📅 請輸入月報月份\n格式:`2026/03`', 'PPT月份'), + 'date_ppt_vendor': ('🏭 請輸入廠商報告月份\n格式:`2026/03`(或按送出跳過用最新月)', 'PPT廠商月份'), 'goal_daily': ('🎯 請輸入每日業績目標金額(NT$)\n例如:`150000`', '日目標'), 'goal_monthly': ('🎯 請輸入每月業績目標金額(NT$)\n例如:`3000000`', '月目標'), 'goal_quarterly': ('🎯 請輸入每季業績目標金額(NT$)\n例如:`9000000`', '季目標'), @@ -4812,6 +5050,9 @@ def handle_cmd(cmd, arg, chat_id, reply_to): 'monthly': '📅 月報', 'strategy': '🧬 策略簡報', 'competitor': '🔍 競品比較', 'compare': '🔍 競品比較', '競品': '🔍 競品比較', 'promo': '🎉 促銷效益報告', + 'growth': '📈 成長趨勢報告', '成長': '📈 成長趨勢報告', + 'vendor': '🏭 廠商業績報告', '廠商': '🏭 廠商業績報告', + 'bcg': '🎯 BCG 品牌矩陣', 'BCG': '🎯 BCG 品牌矩陣', } label = type_labels.get(_sub_type, '簡報') caption = f"{label} — 由 OpenClaw AI 自動生成\n💡 可用 PowerPoint / Keynote / Google Slides 開啟" @@ -5401,6 +5642,8 @@ def telegram_webhook(): handle_cmd('ppt', f'daily {date_val}', chat_id, msg_id) elif action == 'date_ppt_monthly': handle_cmd('ppt', f'monthly {date_val}', chat_id, msg_id) + elif action == 'date_ppt_vendor': + handle_cmd('ppt', f'vendor {date_val}', chat_id, msg_id) elif action == 'date_competitor': handle_cmd('ppt', f'competitor {date_val}', chat_id, msg_id) else: diff --git a/services/ppt_generator.py b/services/ppt_generator.py index edb60f7..c3e83db 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -952,3 +952,394 @@ def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> s path = _new_path("competitor") prs.save(path) return path + + +# ── 成長趨勢報告 PPT(6頁)──────────────────────────────────────────────────── +def generate_growth_ppt(db_data, ai_text: str) -> str: + """P1封面 P2 YTD KPI P3 月營收柱狀圖 P4 MoM月增率 P5 AOV+毛利率 P6 AI洞察 + db_data: {chart_data: {labels, revenue, mom, yoy, aov, margin_rate}, + kpi: {ytd_revenue, ytd_growth, current_year, recent_aov, total_orders}} + 對應 growth_analysis.html: revenueChart / momChart / aovChart / marginChart + """ + from pptx import Presentation + from pptx.util import Cm + + prs = Presentation() + prs.slide_width = Cm(33.87) + prs.slide_height = Cm(19.05) + W = 33.87 + + cd = db_data.get('chart_data', {}) if isinstance(db_data, dict) else {} + kpi = db_data.get('kpi', {}) if isinstance(db_data, dict) else {} + + labels = cd.get('labels', []) + revenue = cd.get('revenue', []) + mom = cd.get('mom', []) + yoy = cd.get('yoy', []) + aov_list = cd.get('aov', []) + margin_list = cd.get('margin_rate', []) + + ytd_rev = float(kpi.get('ytd_revenue', 0)) + ytd_growth = float(kpi.get('ytd_growth', 0)) + curr_yr = kpi.get('current_year', datetime.now().year) + recent_aov = float(kpi.get('recent_aov', 0)) + total_ord = int(kpi.get('total_orders', 0)) + + # P1: 封面 + growth_icon = "📈" if ytd_growth >= 0 else "📉" + _cover_slide(prs, f"成長趨勢報告\n{curr_yr} 年", + f"YTD 累計業績 NT${ytd_rev:,.0f}({ytd_rev/10000:.1f}萬)", + f"{growth_icon} 年增率 {ytd_growth:+.1f}%  生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}") + + # P2: YTD KPI 卡 + s2 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s2, 0, 0, W, 19.05, _WHITE) + _add_header(s2, f"成長趨勢 KPI — {curr_yr} 年 YTD") + ytd_color = _GREEN_KPI if ytd_growth >= 0 else _RED_WARN + kpis = [ + (_BLUE_KPI, "YTD 累計業績", f"NT${ytd_rev/10000:.1f}萬", f"{curr_yr} 年初至今"), + (ytd_color, "年增率 (YTD)", f"{ytd_growth:+.1f}%", "vs 去年同期"), + (_BRAND_OG2, "近30日客單價", f"NT${recent_aov:,.0f}", ""), + (_FOOTER_BG, "累計訂單", f"{total_ord:,} 筆", ""), + ] + for i, (col, lbl, val, sub) in enumerate(kpis): + _kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.5, col, lbl, val, sub) + # 最近月份 MoM 快速摘要 + if mom and labels: + last_mom = float(mom[-1]) + last_label = labels[-1] + mom_color = _GREEN_KPI if last_mom >= 0 else _RED_WARN + _add_rect(s2, 0.5, 5.6, W - 1, 0.7, mom_color) + _add_text(s2, f"最近月份 {last_label} MoM {last_mom:+.1f}% | YoY {float(yoy[-1]) if yoy else 0:+.1f}%", + 0.8, 5.65, W - 1.6, 0.6, bold=True, size=13, color=_WHITE, align="center") + _add_footer(s2, W) + + # P3: 月營收柱狀圖(對應 revenueChart) + s3 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s3, 0, 0, W, 19.05, _WHITE) + _add_header(s3, f"月營收趨勢 — {curr_yr} 年(萬元)") + if labels and revenue: + short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels] + _add_column_chart(s3, 0.8, 1.8, W - 1.6, 11.0, + short_labels, [("月業績(萬元)", revenue)], + bar_colors=[_BLUE_KPI]) + # YoY 備注 + if yoy: + avg_yoy = sum(float(v) for v in yoy if v) / max(len([v for v in yoy if v]), 1) + _add_text(s3, f"平均 YoY:{avg_yoy:+.1f}%", + 0.8, 13.05, 10, 0.5, size=10, color=_SUBTEXT) + _add_footer(s3, W) + + # P4: MoM 月增率柱狀圖(對應 momChart) + s4 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s4, 0, 0, W, 19.05, _WHITE) + _add_header(s4, "月增率分析 (MoM) — 正值綠 / 負值請注意") + if labels and mom: + short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels] + pos_vals = [float(v) if float(v) >= 0 else 0 for v in mom] + neg_vals = [abs(float(v)) if float(v) < 0 else 0 for v in mom] + _add_column_chart(s4, 0.8, 1.8, W - 1.6, 8.0, + short_labels, + [("成長(%)", pos_vals), ("衰退(%)", neg_vals)], + bar_colors=[_GREEN_KPI, _RED_WARN], raw_values=True) + # 最近3個月摘要 + _add_rect(s4, 0.5, 10.2, W - 1, 0.6, _FOOTER_BG) + _add_text(s4, "近3月 MoM:" + " | ".join( + f"{labels[i][-7:]} {float(mom[i]):+.1f}%" for i in range(max(0, len(mom)-3), len(mom))), + 0.7, 10.25, W - 1.4, 0.5, size=11, color=_WHITE, align="center") + _add_footer(s4, W) + + # P5: AOV 客單價 + 毛利率走勢(對應 aovChart + marginChart) + s5 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s5, 0, 0, W, 19.05, _WHITE) + _add_header(s5, "客單價 & 毛利率走勢") + if labels and aov_list: + short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels] + _add_text(s5, "▶ 客單價走勢(NT$)", 0.5, 1.7, 16, 0.5, size=11, color=_DARK_TEXT, bold=True) + _add_column_chart(s5, 0.5, 2.2, W * 0.48, 5.0, + short_labels, [("客單價(元)", aov_list)], + bar_colors=[_BRAND_OG2], raw_values=True) + if labels and margin_list: + short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels] + _add_text(s5, "▶ 毛利率走勢(%)", W * 0.52, 1.7, 16, 0.5, size=11, color=_DARK_TEXT, bold=True) + _add_column_chart(s5, W * 0.52, 2.2, W * 0.46, 5.0, + short_labels, [("毛利率(%)", margin_list)], + bar_colors=[_GREEN_KPI], raw_values=True) + # 摘要數字 + if aov_list: + avg_aov = sum(float(v) for v in aov_list if v) / max(len([v for v in aov_list if v]), 1) + _add_rect(s5, 0.5, 7.5, W * 0.48, 0.6, _BRAND_OG2) + _add_text(s5, f"平均客單價:NT${avg_aov:,.0f}", 0.7, 7.55, W * 0.46, 0.5, + size=11, color=_WHITE, align="center") + if margin_list: + avg_mg = sum(float(v) for v in margin_list if v) / max(len([v for v in margin_list if v]), 1) + _add_rect(s5, W * 0.52, 7.5, W * 0.46, 0.6, _GREEN_KPI) + _add_text(s5, f"平均毛利率:{avg_mg:.1f}%", W * 0.52 + 0.2, 7.55, W * 0.44, 0.5, + size=11, color=_WHITE, align="center") + _add_footer(s5, W) + + # P6: AI 洞察 + s6 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s6, 0, 0, W, 19.05, _BG_DARK) + _add_header(s6, "AI 成長趨勢洞察") + _add_text(s6, ai_text or "(暫無 AI 分析)", + 1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True) + _add_footer(s6, W) + + path = _new_path("growth") + prs.save(path) + return path + + +# ── 廠商業績報告 PPT(5頁)──────────────────────────────────────────────────── +def generate_vendor_ppt(yr, mo, db_data, ai_text: str) -> str: + """P1封面 P2 KPI P3 廠商橫條圖(2年對比) P4 廠商明細表 P5 AI洞察 + db_data: {vendor_ranking: [{name, sales, sales_2024, sales_2025, profit, margin}], + kpis: {total_sales, total_profit, avg_margin, vendor_count}} + 對應 monthly_summary_analysis.html: vendorRankingChart + """ + from pptx import Presentation + from pptx.util import Cm + + prs = Presentation() + prs.slide_width = Cm(33.87) + prs.slide_height = Cm(19.05) + W = 33.87 + + vendors = db_data.get('vendor_ranking', []) if isinstance(db_data, dict) else [] + kpis = db_data.get('kpis', {}) if isinstance(db_data, dict) else {} + period_lbl = db_data.get('period_label', f"{yr}/{mo:02d}") if isinstance(db_data, dict) else f"{yr}/{mo:02d}" + + total_sales = float(kpis.get('total_sales', sum(v.get('sales', 0) for v in vendors))) + total_profit = float(kpis.get('total_profit', sum(v.get('profit', 0) for v in vendors))) + avg_margin = total_profit / total_sales * 100 if total_sales else 0 + vcount = len(vendors) + + # P1: 封面 + _cover_slide(prs, f"廠商業績報告\n{period_lbl}", + f"合計 {vcount} 家廠商 | 總業績 NT${total_sales/10000:.1f}萬", + f"平均毛利率 {avg_margin:.1f}% 生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}") + + # P2: KPI 卡 + s2 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s2, 0, 0, W, 19.05, _WHITE) + _add_header(s2, f"廠商業績 KPI — {period_lbl}") + top1 = vendors[0] if vendors else {} + kpi_cards = [ + (_BLUE_KPI, "廠商總數", f"{vcount} 家", ""), + (_GREEN_KPI, "合計業績", f"NT${total_sales/10000:.1f}萬", ""), + (_BRAND_OG2, "合計毛利", f"NT${total_profit/10000:.1f}萬", ""), + (_FOOTER_BG, "平均毛利率", f"{avg_margin:.1f}%", ""), + ] + for i, (col, lbl, val, sub) in enumerate(kpi_cards): + _kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.5, col, lbl, val, sub) + if top1: + _add_rect(s2, 0.5, 5.6, W - 1, 0.7, _BRAND_OG) + _add_text(s2, f"🏆 業績第一:{top1.get('name','')[:20]} NT${float(top1.get('sales',0)):,.0f} 毛利率 {top1.get('margin',0):.1f}%", + 0.7, 5.65, W - 1.4, 0.6, bold=True, size=12, color=_WHITE, align="center") + _add_footer(s2, W) + + # P3: TOP 20 廠商橫條圖 — 2024 vs 2025(對應 vendorRankingChart) + s3 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s3, 0, 0, W, 19.05, _WHITE) + _add_header(s3, f"TOP 20 廠商業績排行 — {period_lbl}(萬元)") + if vendors: + top20 = vendors[:20] + names = [v.get('name', '')[:15] for v in top20] + s24_vals = [float(v.get('sales_2024', 0)) for v in top20] + s25_vals = [float(v.get('sales_2025', 0)) for v in top20] + has_both = any(s24_vals) and any(s25_vals) + if has_both: + _add_horiz_chart(s3, 0.5, 1.8, W - 1, 11.3, + names, + [("2024", s24_vals), ("2025", s25_vals)], + bar_colors=[_FOOTER_BG, _BLUE_KPI]) + else: + revs = [float(v.get('sales', 0)) for v in top20] + _add_horiz_chart(s3, 0.5, 1.8, W - 1, 11.3, + names, [("業績(萬元)", revs)], + bar_colors=[_BLUE_KPI]) + _add_footer(s3, W) + + # P4: 廠商明細表 + s4 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s4, 0, 0, W, 19.05, _WHITE) + _add_header(s4, f"廠商業績明細 TOP 15 — {period_lbl}") + _add_rect(s4, 0.5, 1.7, W - 1, 0.65, _BRAND_OG) + hdrs = ["#", "廠商名稱", "總業績", "毛利額", "毛利率"] + col_ws = [1.2, 13.0, 6.5, 6.5, 3.5] + x = 0.6 + for h, cw in zip(hdrs, col_ws): + _add_text(s4, h, x, 1.73, cw, 0.59, bold=True, size=10, color=_WHITE, + align="center" if h != "廠商名稱" else "left") + x += cw + 0.1 + for i, v in enumerate(vendors[:15]): + bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE + row_t = 2.5 + i * 0.66 + _add_rect(s4, 0.5, row_t, W - 1, 0.63, bg) + x = 0.6 + cells = [ + (str(i+1), "center"), + (str(v.get('name', ''))[:30], "left"), + (f"NT${float(v.get('sales',0)):,.0f}", "right"), + (f"NT${float(v.get('profit',0)):,.0f}", "right"), + (f"{v.get('margin',0):.1f}%", "right"), + ] + for (txt, al), cw in zip(cells, col_ws): + _add_text(s4, txt, x, row_t + 0.06, cw, 0.52, + size=9, color=_DARK_TEXT, align=al) + x += cw + 0.1 + _add_footer(s4, W) + + # P5: AI 洞察 + s5 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s5, 0, 0, W, 19.05, _BG_DARK) + _add_header(s5, f"AI 廠商洞察 — {period_lbl}") + _add_text(s5, ai_text or "(暫無 AI 分析)", + 1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True) + _add_footer(s5, W) + + path = _new_path("vendor") + prs.save(path) + return path + + +# ── BCG 品牌矩陣報告 PPT(5頁)─────────────────────────────────────────────── +def generate_bcg_ppt(yr, mo, db_data, ai_text: str) -> str: + """P1封面 P2 BCG象限KPI P3 BCG策略分類表 P4 區域業績橫條圖 P5 AI洞察 + db_data: {bcg_data: [{name, qty, margin, sales}], + division_dist: [{name, sales, sales_2024, sales_2025}], + kpis: {total_sales, total_profit, avg_margin}} + 對應 monthly_summary_analysis.html: bcgMatrixChart + divisionDistChart + """ + from pptx import Presentation + from pptx.util import Cm + + prs = Presentation() + prs.slide_width = Cm(33.87) + prs.slide_height = Cm(19.05) + W = 33.87 + + bcg_data = db_data.get('bcg_data', []) if isinstance(db_data, dict) else [] + div_dist = db_data.get('division_dist', []) if isinstance(db_data, dict) else [] + kpis = db_data.get('kpis', {}) if isinstance(db_data, dict) else {} + period_lbl = db_data.get('period_label', f"{yr}/{mo:02d}") if isinstance(db_data, dict) else f"{yr}/{mo:02d}" + + total_sales = float(kpis.get('total_sales', 0)) + avg_margin = float(kpis.get('avg_margin', 0)) + + # BCG 分類(以 avg_margin 為毛利門檻,以 中位業績 為業績門檻) + if bcg_data: + sales_vals = [float(b.get('sales', 0)) for b in bcg_data] + median_sales = sorted(sales_vals)[len(sales_vals) // 2] if sales_vals else 1 + margin_thresh = avg_margin if avg_margin > 5 else 20.0 + + BCG_LABELS = {'star': '明星', 'cow': '金牛', 'q': '問號', 'dog': '瘦狗'} + BCG_COLORS = {'star': _GREEN_KPI, 'cow': _BLUE_KPI, 'q': _BRAND_OG, 'dog': _RED_WARN} + + def _bcg_class(b): + s = float(b.get('sales', 0)) + m = float(b.get('margin', 0)) + if m >= margin_thresh and s >= median_sales: return 'star' + if m >= margin_thresh and s < median_sales: return 'cow' + if m < margin_thresh and s >= median_sales: return 'q' + return 'dog' + + classified = [dict(b, _cls=_bcg_class(b)) for b in bcg_data] + star_list = [b for b in classified if b['_cls'] == 'star'] + cow_list = [b for b in classified if b['_cls'] == 'cow'] + q_list = [b for b in classified if b['_cls'] == 'q'] + dog_list = [b for b in classified if b['_cls'] == 'dog'] + else: + classified = star_list = cow_list = q_list = dog_list = [] + BCG_LABELS = {'star': '明星', 'cow': '金牛', 'q': '問號', 'dog': '瘦狗'} + BCG_COLORS = {'star': _GREEN_KPI, 'cow': _BLUE_KPI, 'q': _BRAND_OG, 'dog': _RED_WARN} + margin_thresh = 20.0 + + # P1: 封面 + _cover_slide(prs, f"品牌 BCG 矩陣報告\n{period_lbl}", + f"分析 {len(bcg_data)} 個品牌×區域組合", + f"毛利率門檻 {margin_thresh:.1f}% 生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}") + + # P2: BCG 象限 KPI 卡 + s2 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s2, 0, 0, W, 19.05, _WHITE) + _add_header(s2, f"BCG 矩陣象限分佈 — {period_lbl}") + bcg_kpis = [ + (_GREEN_KPI, "⭐ 明星", f"{len(star_list)} 個", "高毛利 + 高業績"), + (_BLUE_KPI, "🐄 金牛", f"{len(cow_list)} 個", "高毛利 + 低業績"), + (_BRAND_OG, "❓ 問號", f"{len(q_list)} 個", "低毛利 + 高業績"), + (_RED_WARN, "🐕 瘦狗", f"{len(dog_list)} 個", "低毛利 + 低業績"), + ] + for i, (col, lbl, val, sub) in enumerate(bcg_kpis): + _kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.5, col, lbl, val, sub) + # 判斷門檻說明 + _add_rect(s2, 0.5, 5.6, W - 1, 0.65, _FOOTER_BG) + _add_text(s2, f"毛利率門檻:{margin_thresh:.1f}%(高/低) | 業績門檻:NT${median_sales/10000:.1f}萬(中位數)", + 0.7, 5.65, W - 1.4, 0.55, size=11, color=_WHITE, align="center") + _add_footer(s2, W) + + # P3: BCG 策略分類表(4象限各顯示 TOP 5) + s3 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s3, 0, 0, W, 19.05, _WHITE) + _add_header(s3, f"BCG 策略矩陣清單 — {period_lbl}") + quadrants = [ + ('star', star_list, 0.5, 1.7), + ('cow', cow_list, W/2+0.3, 1.7), + ('q', q_list, 0.5, 8.5), + ('dog', dog_list, W/2+0.3, 8.5), + ] + for cls, items, lx, ty in quadrants: + col = BCG_COLORS[cls] + lbl = BCG_LABELS[cls] + pw = W / 2 - 0.8 + _add_rect(s3, lx, ty, pw, 0.55, col) + _add_text(s3, f"{lbl} ({len(items)} 個)", lx + 0.2, ty + 0.05, pw - 0.4, 0.45, + bold=True, size=11, color=_WHITE) + for j, b in enumerate(items[:5]): + bg = _LIGHT_GRAY if j % 2 == 0 else _WHITE + rt = ty + 0.6 + j * 0.65 + _add_rect(s3, lx, rt, pw, 0.62, bg) + _add_text(s3, str(b.get('name', ''))[:22], + lx + 0.2, rt + 0.06, pw * 0.55, 0.52, size=8, color=_DARK_TEXT) + _add_text(s3, f"{b.get('margin',0):.1f}%", + lx + pw * 0.58, rt + 0.06, pw * 0.2, 0.52, size=8, color=_SUBTEXT, align="right") + _add_text(s3, f"NT${float(b.get('sales',0))/10000:.1f}M", + lx + pw * 0.8, rt + 0.06, pw * 0.18, 0.52, size=8, color=_DARK_TEXT, align="right") + _add_footer(s3, W) + + # P4: 區域業績橫條圖(對應 divisionDistChart,2024 vs 2025) + s4 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s4, 0, 0, W, 19.05, _WHITE) + _add_header(s4, f"區域業績分佈 — {period_lbl}(萬元)") + if div_dist: + names = [d.get('name', '')[:15] for d in div_dist[:12]] + s24_vals = [float(d.get('sales_2024', 0)) for d in div_dist[:12]] + s25_vals = [float(d.get('sales_2025', 0)) for d in div_dist[:12]] + has_both = any(s24_vals) and any(s25_vals) + if has_both: + _add_horiz_chart(s4, 0.5, 1.8, W - 1, 11.3, + names, + [("2024", s24_vals), ("2025", s25_vals)], + bar_colors=[_FOOTER_BG, _BRAND_OG]) + else: + revs = [float(d.get('sales', 0)) for d in div_dist[:12]] + _add_horiz_chart(s4, 0.5, 1.8, W - 1, 11.3, + names, [("業績(萬元)", revs)], + bar_colors=[_BRAND_OG]) + else: + _add_text(s4, "(無區域分佈資料)", 2, 7, 20, 2, size=14, color=_SUBTEXT, align="center") + _add_footer(s4, W) + + # P5: AI 洞察 + s5 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s5, 0, 0, W, 19.05, _BG_DARK) + _add_header(s5, f"AI BCG 品牌策略洞察 — {period_lbl}") + _add_text(s5, ai_text or "(暫無 AI 分析)", + 1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True) + _add_footer(s5, W) + + path = _new_path("bcg") + prs.save(path) + return path + return path