feat(ppt): promo_compare multi-promo ROI report v3.1.0
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

Wave 2.2:N 場促銷活動橫向比較(行銷主管覆盤用)。

generate_promo_compare_ppt — 5 頁
- P1 封面:含活動數徽章 + 排名亮點(最高拉抬/最低拉抬/最佳毛利)
- P2 並排 KPI 表:8 欄(活動/期間/天數/業績/訂單/毛利/業績拉抬/訂單拉抬)
  支援 14 場以下並排顯示
- P3 業績拉抬橫條圖(matplotlib,按拉抬 % 排序)
- P4 AI 跨活動洞察(成功要素 / 失敗診斷 / SMART 三層)
- P5 附錄

路由:
- /ppt promo_compare 母親節:2026/05/05-2026/05/14|520:2026/05/18-2026/05/22|618:2026/06/14-2026/06/22

每場活動透過 query_promo_comparison 取 ROI 數據,自動產生 4 個排名。

_ppt_ai_analysis 加 is_promo_cmp 分支
- 角色:資深行銷主管
- 結構:整體比較 / 勝出活動成功要素 / 失敗活動診斷 / SMART 三層
- max_tokens 1600

Telegram 按鈕:「🆚 多活動比較」(await:promo_compare)

bump TEMPLATE_VERSIONS['promo_compare'] = v3.1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-03 12:28:17 +08:00
parent 9f04dc3951
commit 958f705c8e
3 changed files with 302 additions and 2 deletions

View File

@@ -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

View File

@@ -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')),
])

View File

@@ -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: