feat(ppt): forecast_pre_event report v3.1.0 (BU pre-event battle plan)
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

Wave 2.1:檔期前瞻報告 — 給 BU 主管在檔期前 14 天決定備戰策略。

generate_forecast_pre_event_ppt — 7 頁
- P1 封面:含倒數天數徽章(備戰期/衝刺期/檔期中/已結束)+ 三段預期業績
- P2 KPI 三段對比:本期預期 / 去年同檔期 / Baseline 日均 / 已執行業績
- P3 去年同檔期業績曲線(matplotlib 折線,預測基準視覺化)
- P4 庫存盤點 TOP 30(基於 baseline 期銷量)
- P5 AI 戰術洞察
- P6 附錄

query_forecast_pre_event(event_name, event_date)
- baseline 期:檔期前 60-30 天(去除檔期前期效應)
- 去年同檔期:去年同日期 ± 7 天
- 本期準備窗口:檔期前 14 天到檔期後 7 天
- 預期業績 = baseline 日均 × 21 天 × lift_factor
- lift_factor 靜態知識(母親節 1.40 / 618 1.45 / 雙11 1.65 / 黑五 1.45 等)

_ppt_ai_analysis 加 is_forecast 分支
- 角色:BU 主管 + 行銷投放主管 + 採購三合一
- 結構:檔期定位 / 準備窗口進度 / 庫存戰術 / 廣告滿額門檻 /
  SMART 三層(檔期前7/當日+3/檔期後7)/ 風險預警
- max_tokens 2000

路由:
- /ppt forecast 母親節 2026/05/12
- /ppt forecast 618 2026/06/18

Telegram 按鈕:「🎯 檔期前瞻報告」(await:forecast_event)

bump TEMPLATE_VERSIONS['forecast_pre_event'] = v3.1.0

煙霧測試:7 頁 140KB 全綠。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-03 12:25:42 +08:00
parent 5461c92cf8
commit 9f04dc3951
3 changed files with 460 additions and 0 deletions

View File

@@ -218,6 +218,7 @@ def _submenu_reports():
('📊 TTM 滾動 12 月', 'cmd:ppt:ttm')),
_row(('🗂 品類深度報告', 'await:category_deep'),
('👥 客戶/訂單分析', 'cmd:ppt:customer')),
_row(('🎯 檔期前瞻報告', 'await:forecast_event'),),
])

View File

@@ -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(行銷主管用)