diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index cc6152a..845479c 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -1887,6 +1887,7 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: is_period = any(k in report_type for k in ('quarterly', 'half_yearly', 'annual', 'ttm', '季報', '半年報', '年報')) is_category = '品類' in report_type or 'category' in report_type is_customer = '客戶' in report_type or 'customer' in report_type + is_forecast = '檔期前瞻' in report_type or 'forecast' in report_type # ── 格式鐵律(所有 prompt 共用後綴)──────────────────────── FORMAT_RULES = ( @@ -2044,6 +2045,43 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: + FORMAT_RULES ) max_tokens = 1400 + elif is_forecast: + sys_instruction = ( + "你身兼 (1) BU 主管(決策檔期備戰策略)" + "(2) 行銷投放主管(廣告預算分配)(3) 採購(庫存補貨)。\n" + "你的客戶是 momo BU,會用本報告做檔期前 14 天的庫存補貨、廣告投放、" + "競品阻擊、滿額門檻等戰術決策。所有判斷必須有量化依據與明確期限。\n\n" + f"請針對以下{report_type}資料,輸出檔期戰術前瞻報告,結構嚴格如下:\n\n" + "【檔期定位與機會評估】(4-5 句)\n" + "引用本檔期歷史拉抬倍數(lift_factor)、預期業績、去年同檔期實績;" + "評估本期成長 vs 衰退趨勢;點明:(a) 本檔期主推品類(依 2026 趨勢)" + "(b) 客單帶(如母親節美妝禮盒 NT$1500-3000)(c) 競品檔期動態。\n\n" + "【準備窗口進度評估】(3-4 句)\n" + "已過 X / Y 天的累積業績達成預期 N%;判斷是否達標、需加碼或減碼;" + "若進度落後 > 20% 點明「需立即啟動加速方案」並給出具體手段。\n\n" + "【庫存戰術建議】(3-4 句)\n" + "基於 baseline 期 TOP 商品銷量 × lift_factor 計算預期銷量;" + "點名 3 款必補貨商品(含具體數量目標);" + "識別 2 款「滯銷風險」(baseline 期低銷量但被列入檔期主推的);" + "建議安全庫存閾值(檔期 + 7 天緩衝)。\n\n" + "【廣告投放與滿額門檻】(3-4 句)\n" + "建議廣告預算(baseline 業績的 X%、目標 ROAS Y);" + "鎖定族群(依 2026 賽道:永續美妝/母嬰高端/銀髮保健等);" + "滿額門檻設計(依預期客單 × 1.2~1.5 倍)。\n\n" + "【行動清單 — SMART 框架】\n" + "■ 檔期前 7 天(3 條,✅ 開頭):補貨 / 廣告投放 / 競品價格巡檢\n" + "■ 檔期當日 + 3 天(3 條,✅ 開頭):滿額活動 / 直播帶貨 / 即時補刀\n" + "■ 檔期後 7 天(2 條,✅ 開頭):回購引導 / 庫存清貨 / 復盤學習\n" + "每條須含「商品/品類 + 量化目標(業績 +X% / 庫存 N 組 / 廣告 NT$Y)+ 期限」。\n\n" + "【最大三大風險與防禦】(2-3 句)\n" + "(a) 缺貨斷鏈 — 啟動次廠商備援\n" + "(b) 競品低價 — 滿額贈/品牌力差異化\n" + "(c) 廣告 ROAS 失控 — 中途調整素材或暫停 underperformer\n\n" + "要求:每段引用至少 2 個具體數字,全文 800~1100 字,禁用模糊用詞。" + + MARKET_TREND_2026 + + FORMAT_RULES + ) + max_tokens = 2000 elif is_customer: sys_instruction = ( "你是資深行銷主管(10 年電商 RFM/CRM 實戰經驗)。" @@ -2688,6 +2726,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, generate_competitor_ppt, generate_promo_ppt, generate_vendor_ppt, generate_period_review_ppt, generate_category_deep_ppt, generate_customer_analytics_ppt, + generate_forecast_pre_event_ppt, check_pptx_available ) except ImportError: @@ -3212,6 +3251,67 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, }) return ppt_path + elif sub_type in ('forecast', 'forecast_pre_event', '檔期前瞻'): + # /ppt forecast 母親節 2026/05/12 + # /ppt forecast 618 2026/06/18 + if not sub_arg: + raise RuntimeError('檔期前瞻需指定:/ppt forecast 母親節 2026/05/12') + parts = sub_arg.strip().split() + if len(parts) < 2: + raise RuntimeError('格式:/ppt forecast 檔期名 YYYY/MM/DD') + event_name = parts[0] + event_date = parts[1] + + params = {'report_type': 'forecast_pre_event', + 'event': event_name, 'date': event_date} + cached, cached_ai = _load_cached_ppt_path_and_analysis('forecast_pre_event', params) + if cached: + return cached + + mcp_text = '' + if not cached_ai: + mcp_text = _fetch_mcp_context() + + fc_data = query_forecast_pre_event(event_name, event_date) + if not fc_data.get('found'): + raise RuntimeError(f'檔期 {event_name} {event_date} 預測失敗:{fc_data.get("error", "未知")}') + + baseline = fc_data.get('baseline', {}) + ly = fc_data.get('last_year', {}) + prep = fc_data.get('prep_window', {}) + forecast = fc_data.get('forecast', {}) + top5_str = '\n'.join( + f" {i+1}. {p.get('name','')[:30]} — baseline 業績 NT${p.get('revenue', 0):,.0f}" + for i, p in enumerate(fc_data.get('top_products', [])[:5]) + ) + data_summary = ( + f"【檔期】{event_name}({event_date})\n" + f"【準備窗口】{fc_data.get('window_start', '')} ~ {fc_data.get('window_end', '')}\n\n" + f"【Baseline 期(檔期前 60-30 天)】\n" + f" 業績 NT${baseline.get('revenue', 0):,.0f} / " + f"日均 NT${baseline.get('avg_daily_revenue', 0):,.0f} / " + f"{baseline.get('days', 0)} 天\n\n" + f"【去年同檔期 ± 7 天】\n" + f" 業績 NT${ly.get('revenue', 0):,.0f} / 訂單 {ly.get('orders', 0):,} 筆\n\n" + f"【本期準備窗口已執行】\n" + f" 已過 {prep.get('days_passed', 0)}/{prep.get('days_total', 0)} 天 / " + f"業績 NT${prep.get('revenue', 0):,.0f}\n\n" + f"【預期業績(baseline × lift_factor)】\n" + f" NT${forecast.get('expected_revenue', 0):,.0f} (lift {forecast.get('lift_factor', 1):.2f}x)\n\n" + f"【Baseline 期 TOP 5 商品(庫存盤點對象)】\n{top5_str}\n\n" + f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '(無)'}" + ) + ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'檔期前瞻({event_name})') + if not cached_ai and _ppt_needs_fallback(ai_text): + ai_text = _ppt_fallback_insight('檔期前瞻', data_summary, mcp_text) + + ppt_path = generate_forecast_pre_event_ppt(event_name, event_date, fc_data, ai_text) + _store_ppt_cache('forecast_pre_event', params, ppt_path, { + 'report_type': 'forecast_pre_event', 'parameters': params, + 'data_summary': data_summary, 'analysis': ai_text, 'mcp': mcp_text, + }) + return ppt_path + elif sub_type in ('customer', 'customer_analytics', '客戶'): # /ppt customer [YYYY/MM] 指定月客戶分析 # /ppt customer 預設近 30 天 @@ -4506,6 +4606,135 @@ def query_date_range(start_str: str, end_str: str) -> dict: return {'found': False, 'range': f'{start_str}~{end_str}'} +def query_forecast_pre_event(event_name: str, event_date: str, + before_days: int = 14, after_days: int = 7) -> dict: + """檔期前瞻:給定檔期日 + 名稱,回傳: + - baseline 期業績(檔期日往前 60-30 天為日常 baseline) + - 去年同檔期業績(去年同日期 ± 7 天) + - 本期準備窗口業績(檔期前 before_days) + - TOP 商品(baseline 期)作為庫存盤點對象 + - 預期業績(baseline × 預期拉抬倍數) + + 回傳:{ + event_name, event_date, window_start, window_end, + baseline: {revenue, orders, avg_daily_revenue}, + last_year: {revenue, orders, daily} (去年同檔期 ± 7 天), + prep_window: {revenue, orders, days_passed, daily}, + top_products: [TOP 30 baseline 期商品], + forecast: {expected_revenue, lift_factor, confidence} + } + """ + from datetime import datetime as _dt, timedelta as _td + try: + ev_date = _dt.strptime(event_date.replace('/', '-'), '%Y-%m-%d').date() + window_start = ev_date - _td(days=before_days) + window_end = ev_date + _td(days=after_days) + baseline_start = ev_date - _td(days=60) + baseline_end = ev_date - _td(days=30) + ly_start = ev_date.replace(year=ev_date.year - 1) - _td(days=7) + ly_end = ev_date.replace(year=ev_date.year - 1) + _td(days=7) + + with _db().connect() as c: + # baseline(檔期前 60-30 天的常態日均) + baseline_row = c.execute(text(""" + SELECT COUNT(DISTINCT "訂單編號"), + COALESCE(SUM(CAST("總業績" AS FLOAT)), 0), + COUNT(DISTINCT "日期") + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN :s AND :e + """), {'s': baseline_start, 'e': baseline_end}).fetchone() + + # 去年同檔期 ± 7 天 + ly_row = c.execute(text(""" + SELECT COUNT(DISTINCT "訂單編號"), + COALESCE(SUM(CAST("總業績" AS FLOAT)), 0) + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN :s AND :e + """), {'s': ly_start, 'e': ly_end}).fetchone() + ly_daily = c.execute(text(""" + SELECT "日期", SUM(CAST("總業績" AS FLOAT)) + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN :s AND :e + GROUP BY "日期" ORDER BY "日期" ASC + """), {'s': ly_start, 'e': ly_end}).fetchall() + + # 本期準備窗口(檔期前 before_days 已過的天數) + today = _dt.now().date() + actual_end = min(today, window_end) + prep_row = c.execute(text(""" + SELECT COUNT(DISTINCT "訂單編號"), + COALESCE(SUM(CAST("總業績" AS FLOAT)), 0), + COUNT(DISTINCT "日期") + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN :s AND :e + """), {'s': window_start, 'e': actual_end}).fetchone() + + # baseline 期 TOP 30 商品(庫存盤點對象) + prod_rows = c.execute(text(""" + SELECT "商品ID", "商品名稱", + SUM(CAST("總業績" AS FLOAT)) AS rev, + SUM(CAST("數量" AS INTEGER)) AS qty, + COUNT(DISTINCT "訂單編號") AS orders + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN :s AND :e + GROUP BY "商品ID", "商品名稱" + ORDER BY 3 DESC LIMIT 30 + """), {'s': baseline_start, 'e': baseline_end}).fetchall() + + # 計算 baseline 日均 + b_orders, b_rev, b_days = int(baseline_row[0] or 0), float(baseline_row[1] or 0), int(baseline_row[2] or 0) + b_daily = b_rev / b_days if b_days else 0 + + # 預期拉抬倍數(依檔期靜態知識) + lift_factors = { + '母親節': 1.40, '520': 1.30, '618': 1.45, '父親節': 1.25, + '中秋': 1.25, '雙10': 1.30, '雙11': 1.65, '黑五': 1.45, + '雙12': 1.40, '聖誕': 1.30, '農曆年': 1.50, '婦女節': 1.20, + '情人節': 1.25, '勞動節': 1.15, '端午': 1.20, + } + lift = next((f for k, f in lift_factors.items() if k in event_name), 1.20) + expected_rev = b_daily * (before_days + after_days) * lift + + return { + 'found': True, + 'event_name': event_name, + 'event_date': event_date, + 'window_start': window_start.strftime('%Y/%m/%d'), + 'window_end': window_end.strftime('%Y/%m/%d'), + 'baseline': { + 'revenue': b_rev, 'orders': b_orders, 'days': b_days, + 'avg_daily_revenue': b_daily, + 'period': f"{baseline_start} ~ {baseline_end}", + }, + 'last_year': { + 'revenue': float(ly_row[1] or 0), + 'orders': int(ly_row[0] or 0), + 'period': f"{ly_start} ~ {ly_end}", + 'daily': [{'date': str(r[0]), 'revenue': float(r[1] or 0)} + for r in ly_daily], + }, + 'prep_window': { + 'revenue': float(prep_row[1] or 0), + 'orders': int(prep_row[0] or 0), + 'days_passed': int(prep_row[2] or 0), + 'days_total': before_days + after_days, + }, + 'top_products': [ + {'id': r[0], 'name': r[1], 'revenue': float(r[2]), + 'qty': int(r[3] or 0), 'orders': int(r[4] or 0)} + for r in prod_rows + ], + 'forecast': { + 'expected_revenue': expected_rev, + 'lift_factor': lift, + 'confidence': 'high' if (ly_row[1] or 0) > 0 else 'low', + }, + } + except Exception as e: + sys_log.error(f"[query_forecast_pre_event] {e}") + return {'found': False, 'error': str(e)} + + def query_customer_analytics(start_date: str, end_date: str) -> dict: """客戶/訂單分析報告(簡化版 RFM — 因無 user_id,改做訂單級分析) diff --git a/services/openclaw_bot/menu_keyboards.py b/services/openclaw_bot/menu_keyboards.py index 83079d4..f15f518 100644 --- a/services/openclaw_bot/menu_keyboards.py +++ b/services/openclaw_bot/menu_keyboards.py @@ -218,6 +218,7 @@ def _submenu_reports(): ('📊 TTM 滾動 12 月', 'cmd:ppt:ttm')), _row(('🗂 品類深度報告', 'await:category_deep'), ('👥 客戶/訂單分析', 'cmd:ppt:customer')), + _row(('🎯 檔期前瞻報告', 'await:forecast_event'),), ]) diff --git a/services/ppt_generator.py b/services/ppt_generator.py index 5a95ae7..d63fb65 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -58,6 +58,7 @@ TEMPLATE_VERSIONS = { 'ttm': 'v3.1.0', # 2026-05-03 TTM 滾動 12 月 'category': 'v3.1.0', # 2026-05-03 品類深度報告(90 天縱向 + 子品類 + 新進榜) 'customer': 'v3.1.0', # 2026-05-03 客戶/訂單分析(簡化 RFM,受資料層 user_id 限制) + 'forecast_pre_event': 'v3.1.0', # 2026-05-03 檔期前瞻報(baseline × lift_factor 預測 + 去年同檔期) 'bcg': 'v2.0', # DEPRECATED — 從未落地 } @@ -2949,6 +2950,235 @@ def generate_vendor_ppt(yr, mo, db_data, ai_text: str) -> str: return path +# ── 檔期前瞻報告 ─────────────────────────────────────────────────────── +def generate_forecast_pre_event_ppt(event_name: str, event_date: str, + db_data: dict, ai_text: str) -> str: + """檔期前瞻報告 v3.1:給 BU 主管在檔期前 14 天決定備戰策略 + P1 封面(含倒數天數徽章 + 預期業績 vs baseline) + P2 KPI 三段對比:去年同檔期 / baseline / 本期準備窗口 + P3 去年同檔期業績曲線 + 本期 baseline 對比 + P4 庫存盤點:TOP 30 商品(基於 baseline 期) + P5 AI 戰術洞察(檔期戰術、廣告投放、庫存補貨、競品阻擊) + P6 附錄 + """ + from pptx import Presentation + from pptx.util import Cm + from datetime import datetime as _dt + + prs = Presentation() + prs.slide_width = Cm(33.87) + prs.slide_height = Cm(19.05) + W = 33.87 + + baseline = db_data.get('baseline', {}) or {} + last_year = db_data.get('last_year', {}) or {} + prep_window = db_data.get('prep_window', {}) or {} + top_prods = db_data.get('top_products', []) or [] + forecast = db_data.get('forecast', {}) or {} + window_start = db_data.get('window_start', '') + window_end = db_data.get('window_end', '') + + expected_rev = float(forecast.get('expected_revenue', 0)) + lift = float(forecast.get('lift_factor', 1.0)) + b_daily = float(baseline.get('avg_daily_revenue', 0)) + ly_rev = float(last_year.get('revenue', 0)) + + # 倒數天數 + try: + ev_d = _dt.strptime(event_date.replace('/', '-'), '%Y-%m-%d').date() + days_to_event = (ev_d - _dt.now().date()).days + except Exception: + days_to_event = 0 + + # 定位徽章 + if days_to_event > 7: + urgency_label, urgency_color = '備戰期', '2A7A3F' + elif days_to_event > 0: + urgency_label, urgency_color = '衝刺期', 'B88416' + elif days_to_event >= -7: + urgency_label, urgency_color = '檔期中', 'C96442' + else: + urgency_label, urgency_color = '已結束', '9B9081' + + # ── P1: 封面 ────────────────────────────────────────────── + slide = prs.slides.add_slide(prs.slide_layouts[6]) + H = 19.05 + _add_rect(slide, 0, 0, W, H, _BG_PAPER) + _add_rect(slide, 0, 0, 3.0, H, urgency_color) + _add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2) + _add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2) + _add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, urgency_color) + _add_rect(slide, 4.0, 8.4, 22.0, 0.06, urgency_color) + + _add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2) + _add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81, + bold=True, size=12, color=_WHITE, align="center", valign="middle", + latin_font=_FONT_LABEL) + _add_text(slide, "PRE-EVENT FORECAST · AI BATTLE PLAN", + 3.8, 2.45, 22, 0.55, + bold=True, size=10, color=_BRAND_OG2, + latin_font=_FONT_LABEL) + _add_text(slide, f"檔期前瞻報告\n{event_name}", + 3.8, 3.2, 25, 5.0, + bold=True, size=42, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, urgency_color) + if days_to_event > 0: + urgency_text = f"還有 {days_to_event} 天 · {urgency_label}" + elif days_to_event >= -7: + urgency_text = f"檔期中 · 第 {abs(days_to_event)+1} 天" + else: + urgency_text = f"檔期已過 {abs(days_to_event)} 天" + _add_text(slide, urgency_text, + W - 9.0, 3.45, 5.0, 1.0, + bold=True, size=14, color=_WHITE, align="center", valign="middle", + ea_font=_FONT_BODY_EA) + _add_text(slide, + f"檔期日 {event_date} · 準備窗口 {window_start} ~ {window_end}", + 3.8, 8.7, 27, 0.85, + bold=True, size=14, color=_BRAND_OG2, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 預期 vs baseline + pitch_y = 10.2 + _add_rect(slide, 3.8, pitch_y, 0.45, 1.5, "C96442") + _add_text(slide, "🎯 本期預期業績", 4.4, pitch_y + 0.1, 27, 0.55, + bold=True, size=11, color="C96442", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(slide, + f"NT${expected_rev/10000:.1f}萬" + + (f"(baseline NT${b_daily/10000:.1f}萬/日 × {(window_end and 21) or 21} 天 × {lift:.2f} 倍)" + if b_daily else ''), + 4.4, pitch_y + 0.7, 27, 0.75, + size=12, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + pitch_y2 = pitch_y + 1.9 + _add_rect(slide, 3.8, pitch_y2, 0.45, 1.5, "B88416") + _add_text(slide, "📅 去年同檔期業績(基準)", + 4.4, pitch_y2 + 0.1, 27, 0.55, + bold=True, size=11, color="B88416", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + if ly_rev > 0: + ly_compare_pct = (expected_rev - ly_rev) / ly_rev * 100 if ly_rev else 0 + ly_arrow = "▲" if ly_compare_pct > 0 else "▼" + _add_text(slide, + f"去年同期 NT${ly_rev/10000:.1f}萬 → 本期預期 {ly_arrow} {abs(ly_compare_pct):.1f}%", + 4.4, pitch_y2 + 0.7, 27, 0.75, + size=12, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + else: + _add_text(slide, "去年同檔期無資料 — 預測信心度較低", + 4.4, pitch_y2 + 0.7, 27, 0.75, + size=12, color=_SUBTEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 已執行 vs 目標 + prep_rev = float(prep_window.get('revenue', 0)) + pitch_y3 = pitch_y2 + 1.9 + _add_rect(slide, 3.8, pitch_y3, 0.45, 1.5, "2A7A3F") + _add_text(slide, "✅ 本期準備窗口已執行", + 4.4, pitch_y3 + 0.1, 27, 0.55, + bold=True, size=11, color="2A7A3F", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(slide, + f"已過 {prep_window.get('days_passed', 0)} / {prep_window.get('days_total', 0)} 天" + f" · 累積業績 NT${prep_rev/10000:.1f}萬" + + (f"(達成預期 {prep_rev/expected_rev*100:.1f}%)" if expected_rev else ''), + 4.4, pitch_y3 + 0.7, 27, 0.75, + size=12, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + _add_text(slide, "Generated by OpenClaw AI Agent", + W - 7.5, H - 1.4, 7.0, 0.5, + size=9, color=_SUBTEXT, align="right", latin_font=_FONT_LABEL) + _add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}", + W - 7.5, H - 1.95, 7.0, 0.5, + bold=True, size=11, color=_BRAND_OG2, align="right", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_footer(slide, W) + + # ── P2: 三段業績 KPI 對比 ───────────────────────────────── + s2 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s2, f"檔期業績三段對比 — {event_name}") + cards = [ + (_KPI_CARAMEL, "本期預期業績", f"NT${expected_rev/10000:.1f}萬", + f"baseline × {lift:.2f}"), + (_KPI_HONEY, "去年同檔期", f"NT${ly_rev/10000:.1f}萬", + last_year.get('period', '—')[:25]), + (_KPI_MAHOGANY, "Baseline 日均", f"NT${b_daily/10000:.1f}萬", + f"({baseline.get('days', 0)} 天均值)"), + (_KPI_EARTH, "已執行業績", f"NT${prep_rev/10000:.1f}萬", + f"{prep_window.get('days_passed', 0)}/{prep_window.get('days_total', 0)} 天"), + ] + for i, (col, lbl, val, sub) in enumerate(cards): + _kpi_card_v2(s2, i * 7.8 + 0.5, 1.95, 7.4, 4.5, + col, lbl, val, delta_pct=None, delta_label=sub, sub=sub) + + # AI 解讀 + summary_text = (ai_text or '')[:400] if ai_text else "(暫無 AI 分析)" + _add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, urgency_color) + _add_text(s2, f"🎯 {event_name} 戰術解讀", + 1.1, 7.05, W - 1.5, 0.6, bold=True, size=13, color=_WHITE, + valign="middle", ea_font=_FONT_BODY_EA) + _add_rect(s2, 0.5, 7.7, W - 1.0, 6.4, _WHITE, line_hex=_SUBTLE) + _add_rect(s2, 0.5, 7.7, 0.4, 6.4, urgency_color) + _add_text(s2, summary_text, + 1.2, 7.95, W - 2.0, 5.9, + size=13, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_footer(s2, W) + + # ── P3: 去年同檔期業績曲線 ──────────────────────────────── + ly_daily = last_year.get('daily', []) + if ly_daily: + s3 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s3, f"去年同檔期業績曲線 — 預測基準") + d_dates = [d.get('date', '') for d in ly_daily] + d_revs = [float(d.get('revenue', 0)) for d in ly_daily] + chart_w = W - 0.8 + chart_h = 12.5 + buf = _mpl_line_chart_png( + d_dates, d_revs, prev_vals=None, + total_width_cm=chart_w, total_height_cm=chart_h, + title=f"去年同檔期 {last_year.get('period', '')} 日業績曲線", + curr_label="去年同期" + ) + if buf: + _add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h) + # 結論帶 + if d_revs: + avg_d = sum(d_revs) / len(d_revs) + max_d = max(d_revs) + _add_rect(s3, 0.4, 14.7, W - 0.8, 1.0, _BRAND_OG2) + _add_text(s3, + f"📊 去年同檔期日均 NT${avg_d/10000:.1f}萬 · " + f"最高單日 NT${max_d/10000:.1f}萬 · " + f"本期預期 baseline × {lift:.2f} 倍", + 0.7, 14.85, W - 1.4, 0.7, + bold=True, size=12, color=_WHITE, valign="middle", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_footer(s3, W) + + # ── P4: 庫存盤點(baseline 期 TOP 30)───────────────────── + if top_prods: + _product_table_slide(prs, + f"庫存盤點對象 TOP {min(30, len(top_prods))} — 基於 baseline 期銷量", + top_prods, max_items=30) + + # ── P5: AI 戰術洞察 ───────────────────────────────────── + _ai_insight_slide(prs, ai_text) + + # ── P6: 附錄 ───────────────────────────────────────────── + _appendix_slide(prs, 'forecast_pre_event', f"{event_name} ({event_date})") + + path = _new_path("forecast_pre_event") + prs.save(path) + return path + + # ── 客戶/訂單分析報告(簡化版 RFM)──────────────────────────────────────── def generate_customer_analytics_ppt(period_label: str, db_data: dict, ai_text: str) -> str: """客戶/訂單分析報告 v3.1(行銷主管用)