diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 845479c..c3340d9 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -1888,6 +1888,7 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: 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 + is_promo_cmp = '多活動' in report_type or 'promo_compare' in report_type # ── 格式鐵律(所有 prompt 共用後綴)──────────────────────── FORMAT_RULES = ( @@ -2045,6 +2046,28 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: + FORMAT_RULES ) max_tokens = 1400 + elif is_promo_cmp: + sys_instruction = ( + "你是資深行銷主管(10 年促銷活動策劃實戰經驗)。" + f"以下是多場促銷活動的 ROI 對比數據。請輸出{report_type}跨活動洞察:\n\n" + "【整體比較解讀】(3-4 句)\n" + "點出 N 場活動中業績拉抬最高/最低、毛利最佳/最差、訂單拉抬最強的活動;" + "評估整體促銷組合健康度(是否過度依賴單一檔期)。\n\n" + "【勝出活動成功要素】(3-4 句)\n" + "分析最高拉抬活動的成功因素(檔期 / 商品力 / 行銷投放 / 滿額設計);" + "判斷哪些要素可複製到下一場。\n\n" + "【失敗活動診斷】(3-4 句)\n" + "點出拉抬偏低或負成長活動的問題(時機不對 / 對比期過旺 / 商品選錯 / " + "毛利侵蝕過深);給出具體改善方向。\n\n" + "【行動建議 — SMART 框架】\n" + "■ 立即執行(3 條,✅ 開頭):複製成功要素 / 立即停損失敗格式\n" + "■ 中期強化(2 條,✅ 開頭):建立活動 KPI 基準線 / RFM 精準投放\n" + "■ 長期佈局(1 條,✅ 開頭):建立年度活動行事曆 + 自動化 ROI 追蹤\n\n" + "要求:每段引用具體活動名與數字,全文 700~900 字。" + + MARKET_TREND_2026 + + FORMAT_RULES + ) + max_tokens = 1600 elif is_forecast: sys_instruction = ( "你身兼 (1) BU 主管(決策檔期備戰策略)" @@ -2726,7 +2749,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, + generate_forecast_pre_event_ppt, generate_promo_compare_ppt, check_pptx_available ) except ImportError: @@ -3251,6 +3274,98 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, }) return ppt_path + elif sub_type in ('promo_compare', 'promocompare', '促銷比較', '多活動'): + # /ppt promo_compare 母親節:2026/05/05-2026/05/14|520:2026/05/18-2026/05/22|618:2026/06/14-2026/06/22 + # 用 | 分隔多場活動,每場用 : 分 label/dates + if not sub_arg or '|' not in sub_arg: + raise RuntimeError( + '格式:/ppt promo_compare 活動1:YYYY/MM/DD-YYYY/MM/DD|活動2:...' + ) + promos_input = [] + for chunk in sub_arg.split('|'): + if ':' not in chunk: + continue + lbl, dates = chunk.split(':', 1) + if '-' not in dates: + continue + s_d, e_d = dates.split('-', 1) + try: + s_d = normalize_date(s_d.strip()) + e_d = normalize_date(e_d.strip()) + except Exception: + continue + promos_input.append({'label': lbl.strip(), 'start': s_d, 'end': e_d}) + + if len(promos_input) < 2: + raise RuntimeError('至少需要 2 場活動才能比較') + + params = {'report_type': 'promo_compare', + 'promos': '|'.join(f"{p['label']}:{p['start']}-{p['end']}" for p in promos_input)} + cached, cached_ai = _load_cached_ppt_path_and_analysis('promo_compare', params) + if cached: + return cached + + # 用 query_promo_comparison 跑每場 + all_promos = [] + for pi in promos_input: + try: + cmp = query_promo_comparison(pi['start'], pi['end']) + if cmp and cmp.get('promo'): + promo_kpi = cmp['promo'] + all_promos.append({ + 'label': pi['label'], + 'start': pi['start'], 'end': pi['end'], + 'days': int(promo_kpi.get('days', 1)), + 'revenue': float(promo_kpi.get('revenue', 0)), + 'orders': int(promo_kpi.get('orders', 0)), + 'margin': float(promo_kpi.get('margin', 0)), + 'rev_lift': float(cmp.get('rev_lift', 0)), + 'ord_lift': float(cmp.get('ord_lift', 0)), + }) + except Exception as e: + sys_log.warning(f"[promo_compare] {pi['label']} fetch fail: {e}") + + if not all_promos: + raise RuntimeError('無法獲取任何活動資料') + + rankings = { + 'best_revenue': max(all_promos, key=lambda x: x['revenue']), + 'best_lift': max(all_promos, key=lambda x: x['rev_lift']), + 'worst_lift': min(all_promos, key=lambda x: x['rev_lift']), + 'best_margin': max(all_promos, key=lambda x: x['margin']), + } + promo_summary = '\n'.join( + f" {i+1}. {p['label']} ({p['start']}~{p['end']}): " + f"NT${p['revenue']:,.0f} / 訂單 {p['orders']} / 毛利 {p['margin']:.1f}% / " + f"業績拉抬 {p['rev_lift']:+.1f}%" + for i, p in enumerate(all_promos) + ) + data_summary = ( + f"【比較活動數】{len(all_promos)} 場\n\n" + f"【各活動明細】\n{promo_summary}\n\n" + f"【最高業績】{rankings['best_revenue']['label']} " + f"NT${rankings['best_revenue']['revenue']:,.0f}\n" + f"【最高拉抬】{rankings['best_lift']['label']} " + f"+{rankings['best_lift']['rev_lift']:.1f}%\n" + f"【最低拉抬】{rankings['worst_lift']['label']} " + f"{rankings['worst_lift']['rev_lift']:+.1f}%\n" + f"【最佳毛利】{rankings['best_margin']['label']} " + f"{rankings['best_margin']['margin']:.1f}%" + ) + ai_text = cached_ai or _ppt_ai_analysis(data_summary, '多活動 ROI 比較') + if not cached_ai and _ppt_needs_fallback(ai_text): + ai_text = _ppt_fallback_insight('多活動比較', data_summary, '') + + label = f"{len(all_promos)} 場活動比較" + ppt_path = generate_promo_compare_ppt( + label, {'promos': all_promos, 'rankings': rankings}, ai_text + ) + _store_ppt_cache('promo_compare', params, ppt_path, { + 'report_type': 'promo_compare', 'parameters': params, + 'data_summary': data_summary, 'analysis': ai_text, 'mcp': '', + }) + return ppt_path + elif sub_type in ('forecast', 'forecast_pre_event', '檔期前瞻'): # /ppt forecast 母親節 2026/05/12 # /ppt forecast 618 2026/06/18 diff --git a/services/openclaw_bot/menu_keyboards.py b/services/openclaw_bot/menu_keyboards.py index f15f518..64c9c98 100644 --- a/services/openclaw_bot/menu_keyboards.py +++ b/services/openclaw_bot/menu_keyboards.py @@ -218,7 +218,8 @@ def _submenu_reports(): ('📊 TTM 滾動 12 月', 'cmd:ppt:ttm')), _row(('🗂 品類深度報告', 'await:category_deep'), ('👥 客戶/訂單分析', 'cmd:ppt:customer')), - _row(('🎯 檔期前瞻報告', 'await:forecast_event'),), + _row(('🎯 檔期前瞻報告', 'await:forecast_event'), + ('🆚 多活動比較', 'await:promo_compare')), ]) diff --git a/services/ppt_generator.py b/services/ppt_generator.py index d63fb65..e7c36a8 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -59,6 +59,7 @@ TEMPLATE_VERSIONS = { '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 預測 + 去年同檔期) + 'promo_compare': 'v3.1.0', # 2026-05-03 多活動 ROI 並排比較 'bcg': 'v2.0', # DEPRECATED — 從未落地 } @@ -2950,6 +2951,189 @@ def generate_vendor_ppt(yr, mo, db_data, ai_text: str) -> str: return path +# ── 多活動 ROI 橫向比較報告 ───────────────────────────────────────────── +def generate_promo_compare_ppt(label: str, db_data: dict, ai_text: str) -> str: + """多活動 ROI 比較報告:2-N 個促銷活動並排比較 + db_data: { + promos: [{label, start, end, days, revenue, orders, margin, rev_lift, ord_lift}, ...], + rankings: {best_revenue, best_lift, best_margin, worst_lift}, + } + P1 封面(含活動數徽章) + P2 並排 KPI 表(活動 × 業績/訂單/毛利/拉抬) + P3 業績拉抬橫條(matplotlib,活動間排序) + P4 AI 跨活動洞察 + P5 附錄 + """ + 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 + + promos = db_data.get('promos', []) or [] + rankings = db_data.get('rankings', {}) or {} + + # ── 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, _BRAND_OG) + _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, _BRAND_OG) + _add_rect(slide, 4.0, 8.4, 22.0, 0.06, _BRAND_OG) + + _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, "MULTI-PROMO ROI COMPARISON · AI INSIGHT", + 3.8, 2.45, 22, 0.55, + bold=True, size=10, color=_BRAND_OG2, + latin_font=_FONT_LABEL) + _add_text(slide, f"促銷活動橫向比較\n{label}", + 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, _BRAND_OG2) + _add_text(slide, f"比較 {len(promos)} 場活動", + W - 9.0, 3.45, 5.0, 1.0, + bold=True, size=14, color=_WHITE, align="center", valign="middle", + ea_font=_FONT_BODY_EA) + + # 排名亮點 + pitch_y = 9.5 + if rankings.get('best_lift'): + _add_rect(slide, 3.8, pitch_y, 0.45, 1.5, "2A7A3F") + _add_text(slide, "🏆 最高拉抬", + 4.4, pitch_y + 0.1, 27, 0.55, + bold=True, size=11, color="2A7A3F", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + bl = rankings['best_lift'] + _add_text(slide, + f"{bl.get('label','')} — 業績拉抬 +{bl.get('rev_lift',0):.1f}%(vs 對比期)", + 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 + if rankings.get('worst_lift'): + _add_rect(slide, 3.8, pitch_y2, 0.45, 1.5, "B5342F") + _add_text(slide, "⚠ 最低拉抬(需檢討)", + 4.4, pitch_y2 + 0.1, 27, 0.55, + bold=True, size=11, color="B5342F", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + wl = rankings['worst_lift'] + _add_text(slide, + f"{wl.get('label','')} — 業績拉抬 {wl.get('rev_lift',0):+.1f}%", + 4.4, pitch_y2 + 0.7, 27, 0.75, + size=12, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + pitch_y3 = pitch_y2 + 1.9 + if rankings.get('best_margin'): + _add_rect(slide, 3.8, pitch_y3, 0.45, 1.5, "B88416") + _add_text(slide, "💰 最佳毛利", + 4.4, pitch_y3 + 0.1, 27, 0.55, + bold=True, size=11, color="B88416", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + bm = rankings['best_margin'] + _add_text(slide, + f"{bm.get('label','')} — 毛利率 {bm.get('margin',0):.1f}%(活動期)", + 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"促銷活動 KPI 並排比較 — {len(promos)} 場") + # 表頭 + tbl_y = 2.0 + _add_rect(s2, 0.4, tbl_y, W - 0.8, 0.75, _BRAND_OG) + cols = [('活動名稱', 8.5, 'left'), ('期間', 5.5, 'center'), + ('天數', 1.8, 'center'), ('業績', 4.5, 'right'), + ('訂單', 3.0, 'right'), ('毛利率', 2.5, 'center'), + ('業績拉抬', 3.0, 'center'), ('訂單拉抬', 3.0, 'center')] + cx = 0.5 + for label_h, w, al in cols: + _add_text(s2, label_h, cx, tbl_y + 0.1, w, 0.55, + bold=True, size=10, color=_WHITE, align=al, + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + cx += w + 0.05 + + for i, p in enumerate(promos[:14]): + row_y = tbl_y + 0.85 + i * 0.78 + bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE + _add_rect(s2, 0.4, row_y, W - 0.8, 0.74, bg) + cx = 0.5 + rev_lift = float(p.get('rev_lift', 0)) + ord_lift = float(p.get('ord_lift', 0)) + rev_color = "2A7A3F" if rev_lift > 0 else "B5342F" + ord_color = "2A7A3F" if ord_lift > 0 else "B5342F" + margin = float(p.get('margin', 0)) + margin_color = "2A7A3F" if margin >= 12 else ("B88416" if margin >= 8 else "B5342F") + + cells = [ + (str(p.get('label', ''))[:25], 'left', _DARK_TEXT, False), + (f"{p.get('start', '')[5:]}~{p.get('end', '')[5:]}", 'center', _SUBTEXT, False), + (f"{p.get('days', 0)} 天", 'center', _DARK_TEXT, False), + (f"NT${float(p.get('revenue', 0))/10000:.1f}萬", 'right', _DARK_TEXT, True), + (f"{int(p.get('orders', 0)):,}", 'right', _DARK_TEXT, False), + (f"{margin:.1f}%", 'center', margin_color, True), + (f"{rev_lift:+.1f}%", 'center', rev_color, True), + (f"{ord_lift:+.1f}%", 'center', ord_color, True), + ] + for (txt, al, col, bold), (_, w, _) in zip(cells, cols): + _add_text(s2, txt, cx, row_y + 0.12, w, 0.55, + bold=bold, size=10, color=col, align=al, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + cx += w + 0.05 + _add_footer(s2, W) + + # ── P3: 業績拉抬橫條 ────────────────────────────────────── + if promos: + s3 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s3, "業績拉抬幅度排行(vs 對比期)") + sorted_p = sorted(promos, key=lambda x: float(x.get('rev_lift', 0)), reverse=True) + names = [str(p.get('label', ''))[:20] for p in sorted_p[:12]] + # 拉抬 % 直接當數值(matplotlib helper 會除 10000,這裡用 raw) + lifts = [float(p.get('rev_lift', 0)) * 10000 for p in sorted_p[:12]] + chart_w = W - 0.8 + chart_h = 12.0 + buf = _mpl_horiz_bar_png(names, lifts, + total_width_cm=chart_w, + total_height_cm=chart_h, + value_unit="%", + title="業績拉抬 % 排行(焦糖橘=TOP3)", + highlight_top_n=3) + if buf: + _add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h) + _add_footer(s3, W) + + # ── P4: AI 洞察 ────────────────────────────────────────── + _ai_insight_slide(prs, ai_text) + + # ── P5: 附錄 ───────────────────────────────────────────── + _appendix_slide(prs, 'promo_compare', label) + + path = _new_path("promo_compare") + prs.save(path) + return path + + # ── 檔期前瞻報告 ─────────────────────────────────────────────────────── def generate_forecast_pre_event_ppt(event_name: str, event_date: str, db_data: dict, ai_text: str) -> str: