feat(ppt): promo_compare multi-promo ROI report v3.1.0
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
@@ -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')),
|
||||
])
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user