feat: PPT 簡報系統 V2 — 新增 growth/vendor/bcg 三種報告 + 原生圖表升級
All checks were successful
CD Pipeline / deploy (push) Successful in 1m15s

- ppt_generator.py: 新增 generate_growth_ppt(6頁)、generate_vendor_ppt(5頁)、generate_bcg_ppt(5頁)
- openclaw_bot_routes.py: 新增 query_growth_data()、query_vendor_bcg_data()、_generate_ppt_cmd 三路分支、_submenu_reports 4顆新按鈕、type_labels、await:date_ppt_vendor 流程
- ADR-014: 記錄 V2 完整架構(9種報告類型、圖表技術方案、callback_data 格式)
- CLAUDE.md: 新增 PPT 簡報系統索引表

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ogt
2026-04-20 20:26:47 +08:00
parent d349b09afd
commit 48804553cd
4 changed files with 752 additions and 2 deletions

View File

@@ -952,3 +952,394 @@ def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> s
path = _new_path("competitor")
prs.save(path)
return path
# ── 成長趨勢報告 PPT6頁────────────────────────────────────────────────────
def generate_growth_ppt(db_data, ai_text: str) -> str:
"""P1封面 P2 YTD KPI P3 月營收柱狀圖 P4 MoM月增率 P5 AOV+毛利率 P6 AI洞察
db_data: {chart_data: {labels, revenue, mom, yoy, aov, margin_rate},
kpi: {ytd_revenue, ytd_growth, current_year, recent_aov, total_orders}}
對應 growth_analysis.html: revenueChart / momChart / aovChart / marginChart
"""
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
cd = db_data.get('chart_data', {}) if isinstance(db_data, dict) else {}
kpi = db_data.get('kpi', {}) if isinstance(db_data, dict) else {}
labels = cd.get('labels', [])
revenue = cd.get('revenue', [])
mom = cd.get('mom', [])
yoy = cd.get('yoy', [])
aov_list = cd.get('aov', [])
margin_list = cd.get('margin_rate', [])
ytd_rev = float(kpi.get('ytd_revenue', 0))
ytd_growth = float(kpi.get('ytd_growth', 0))
curr_yr = kpi.get('current_year', datetime.now().year)
recent_aov = float(kpi.get('recent_aov', 0))
total_ord = int(kpi.get('total_orders', 0))
# P1: 封面
growth_icon = "📈" if ytd_growth >= 0 else "📉"
_cover_slide(prs, f"成長趨勢報告\n{curr_yr}",
f"YTD 累計業績 NT${ytd_rev:,.0f}{ytd_rev/10000:.1f}萬)",
f"{growth_icon} 年增率 {ytd_growth:+.1f}%  生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
# P2: YTD KPI 卡
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
_add_header(s2, f"成長趨勢 KPI — {curr_yr} 年 YTD")
ytd_color = _GREEN_KPI if ytd_growth >= 0 else _RED_WARN
kpis = [
(_BLUE_KPI, "YTD 累計業績", f"NT${ytd_rev/10000:.1f}", f"{curr_yr} 年初至今"),
(ytd_color, "年增率 (YTD)", f"{ytd_growth:+.1f}%", "vs 去年同期"),
(_BRAND_OG2, "近30日客單價", f"NT${recent_aov:,.0f}", ""),
(_FOOTER_BG, "累計訂單", f"{total_ord:,}", ""),
]
for i, (col, lbl, val, sub) in enumerate(kpis):
_kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.5, col, lbl, val, sub)
# 最近月份 MoM 快速摘要
if mom and labels:
last_mom = float(mom[-1])
last_label = labels[-1]
mom_color = _GREEN_KPI if last_mom >= 0 else _RED_WARN
_add_rect(s2, 0.5, 5.6, W - 1, 0.7, mom_color)
_add_text(s2, f"最近月份 {last_label} MoM {last_mom:+.1f}% | YoY {float(yoy[-1]) if yoy else 0:+.1f}%",
0.8, 5.65, W - 1.6, 0.6, bold=True, size=13, color=_WHITE, align="center")
_add_footer(s2, W)
# P3: 月營收柱狀圖(對應 revenueChart
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
_add_header(s3, f"月營收趨勢 — {curr_yr} 年(萬元)")
if labels and revenue:
short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels]
_add_column_chart(s3, 0.8, 1.8, W - 1.6, 11.0,
short_labels, [("月業績(萬元)", revenue)],
bar_colors=[_BLUE_KPI])
# YoY 備注
if yoy:
avg_yoy = sum(float(v) for v in yoy if v) / max(len([v for v in yoy if v]), 1)
_add_text(s3, f"平均 YoY{avg_yoy:+.1f}%",
0.8, 13.05, 10, 0.5, size=10, color=_SUBTEXT)
_add_footer(s3, W)
# P4: MoM 月增率柱狀圖(對應 momChart
s4 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s4, 0, 0, W, 19.05, _WHITE)
_add_header(s4, "月增率分析 (MoM) — 正值綠 / 負值請注意")
if labels and mom:
short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels]
pos_vals = [float(v) if float(v) >= 0 else 0 for v in mom]
neg_vals = [abs(float(v)) if float(v) < 0 else 0 for v in mom]
_add_column_chart(s4, 0.8, 1.8, W - 1.6, 8.0,
short_labels,
[("成長(%", pos_vals), ("衰退(%", neg_vals)],
bar_colors=[_GREEN_KPI, _RED_WARN], raw_values=True)
# 最近3個月摘要
_add_rect(s4, 0.5, 10.2, W - 1, 0.6, _FOOTER_BG)
_add_text(s4, "近3月 MoM" + " | ".join(
f"{labels[i][-7:]} {float(mom[i]):+.1f}%" for i in range(max(0, len(mom)-3), len(mom))),
0.7, 10.25, W - 1.4, 0.5, size=11, color=_WHITE, align="center")
_add_footer(s4, W)
# P5: AOV 客單價 + 毛利率走勢(對應 aovChart + marginChart
s5 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s5, 0, 0, W, 19.05, _WHITE)
_add_header(s5, "客單價 & 毛利率走勢")
if labels and aov_list:
short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels]
_add_text(s5, "▶ 客單價走勢NT$", 0.5, 1.7, 16, 0.5, size=11, color=_DARK_TEXT, bold=True)
_add_column_chart(s5, 0.5, 2.2, W * 0.48, 5.0,
short_labels, [("客單價(元)", aov_list)],
bar_colors=[_BRAND_OG2], raw_values=True)
if labels and margin_list:
short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels]
_add_text(s5, "▶ 毛利率走勢(%", W * 0.52, 1.7, 16, 0.5, size=11, color=_DARK_TEXT, bold=True)
_add_column_chart(s5, W * 0.52, 2.2, W * 0.46, 5.0,
short_labels, [("毛利率(%", margin_list)],
bar_colors=[_GREEN_KPI], raw_values=True)
# 摘要數字
if aov_list:
avg_aov = sum(float(v) for v in aov_list if v) / max(len([v for v in aov_list if v]), 1)
_add_rect(s5, 0.5, 7.5, W * 0.48, 0.6, _BRAND_OG2)
_add_text(s5, f"平均客單價NT${avg_aov:,.0f}", 0.7, 7.55, W * 0.46, 0.5,
size=11, color=_WHITE, align="center")
if margin_list:
avg_mg = sum(float(v) for v in margin_list if v) / max(len([v for v in margin_list if v]), 1)
_add_rect(s5, W * 0.52, 7.5, W * 0.46, 0.6, _GREEN_KPI)
_add_text(s5, f"平均毛利率:{avg_mg:.1f}%", W * 0.52 + 0.2, 7.55, W * 0.44, 0.5,
size=11, color=_WHITE, align="center")
_add_footer(s5, W)
# P6: AI 洞察
s6 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s6, 0, 0, W, 19.05, _BG_DARK)
_add_header(s6, "AI 成長趨勢洞察")
_add_text(s6, ai_text or "(暫無 AI 分析)",
1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True)
_add_footer(s6, W)
path = _new_path("growth")
prs.save(path)
return path
# ── 廠商業績報告 PPT5頁────────────────────────────────────────────────────
def generate_vendor_ppt(yr, mo, db_data, ai_text: str) -> str:
"""P1封面 P2 KPI P3 廠商橫條圖(2年對比) P4 廠商明細表 P5 AI洞察
db_data: {vendor_ranking: [{name, sales, sales_2024, sales_2025, profit, margin}],
kpis: {total_sales, total_profit, avg_margin, vendor_count}}
對應 monthly_summary_analysis.html: vendorRankingChart
"""
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
vendors = db_data.get('vendor_ranking', []) if isinstance(db_data, dict) else []
kpis = db_data.get('kpis', {}) if isinstance(db_data, dict) else {}
period_lbl = db_data.get('period_label', f"{yr}/{mo:02d}") if isinstance(db_data, dict) else f"{yr}/{mo:02d}"
total_sales = float(kpis.get('total_sales', sum(v.get('sales', 0) for v in vendors)))
total_profit = float(kpis.get('total_profit', sum(v.get('profit', 0) for v in vendors)))
avg_margin = total_profit / total_sales * 100 if total_sales else 0
vcount = len(vendors)
# P1: 封面
_cover_slide(prs, f"廠商業績報告\n{period_lbl}",
f"合計 {vcount} 家廠商 | 總業績 NT${total_sales/10000:.1f}",
f"平均毛利率 {avg_margin:.1f}% 生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
# P2: KPI 卡
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
_add_header(s2, f"廠商業績 KPI — {period_lbl}")
top1 = vendors[0] if vendors else {}
kpi_cards = [
(_BLUE_KPI, "廠商總數", f"{vcount}", ""),
(_GREEN_KPI, "合計業績", f"NT${total_sales/10000:.1f}", ""),
(_BRAND_OG2, "合計毛利", f"NT${total_profit/10000:.1f}", ""),
(_FOOTER_BG, "平均毛利率", f"{avg_margin:.1f}%", ""),
]
for i, (col, lbl, val, sub) in enumerate(kpi_cards):
_kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.5, col, lbl, val, sub)
if top1:
_add_rect(s2, 0.5, 5.6, W - 1, 0.7, _BRAND_OG)
_add_text(s2, f"🏆 業績第一:{top1.get('name','')[:20]} NT${float(top1.get('sales',0)):,.0f} 毛利率 {top1.get('margin',0):.1f}%",
0.7, 5.65, W - 1.4, 0.6, bold=True, size=12, color=_WHITE, align="center")
_add_footer(s2, W)
# P3: TOP 20 廠商橫條圖 — 2024 vs 2025對應 vendorRankingChart
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
_add_header(s3, f"TOP 20 廠商業績排行 — {period_lbl}(萬元)")
if vendors:
top20 = vendors[:20]
names = [v.get('name', '')[:15] for v in top20]
s24_vals = [float(v.get('sales_2024', 0)) for v in top20]
s25_vals = [float(v.get('sales_2025', 0)) for v in top20]
has_both = any(s24_vals) and any(s25_vals)
if has_both:
_add_horiz_chart(s3, 0.5, 1.8, W - 1, 11.3,
names,
[("2024", s24_vals), ("2025", s25_vals)],
bar_colors=[_FOOTER_BG, _BLUE_KPI])
else:
revs = [float(v.get('sales', 0)) for v in top20]
_add_horiz_chart(s3, 0.5, 1.8, W - 1, 11.3,
names, [("業績(萬元)", revs)],
bar_colors=[_BLUE_KPI])
_add_footer(s3, W)
# P4: 廠商明細表
s4 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s4, 0, 0, W, 19.05, _WHITE)
_add_header(s4, f"廠商業績明細 TOP 15 — {period_lbl}")
_add_rect(s4, 0.5, 1.7, W - 1, 0.65, _BRAND_OG)
hdrs = ["#", "廠商名稱", "總業績", "毛利額", "毛利率"]
col_ws = [1.2, 13.0, 6.5, 6.5, 3.5]
x = 0.6
for h, cw in zip(hdrs, col_ws):
_add_text(s4, h, x, 1.73, cw, 0.59, bold=True, size=10, color=_WHITE,
align="center" if h != "廠商名稱" else "left")
x += cw + 0.1
for i, v in enumerate(vendors[:15]):
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
row_t = 2.5 + i * 0.66
_add_rect(s4, 0.5, row_t, W - 1, 0.63, bg)
x = 0.6
cells = [
(str(i+1), "center"),
(str(v.get('name', ''))[:30], "left"),
(f"NT${float(v.get('sales',0)):,.0f}", "right"),
(f"NT${float(v.get('profit',0)):,.0f}", "right"),
(f"{v.get('margin',0):.1f}%", "right"),
]
for (txt, al), cw in zip(cells, col_ws):
_add_text(s4, txt, x, row_t + 0.06, cw, 0.52,
size=9, color=_DARK_TEXT, align=al)
x += cw + 0.1
_add_footer(s4, W)
# P5: AI 洞察
s5 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s5, 0, 0, W, 19.05, _BG_DARK)
_add_header(s5, f"AI 廠商洞察 — {period_lbl}")
_add_text(s5, ai_text or "(暫無 AI 分析)",
1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True)
_add_footer(s5, W)
path = _new_path("vendor")
prs.save(path)
return path
# ── BCG 品牌矩陣報告 PPT5頁───────────────────────────────────────────────
def generate_bcg_ppt(yr, mo, db_data, ai_text: str) -> str:
"""P1封面 P2 BCG象限KPI P3 BCG策略分類表 P4 區域業績橫條圖 P5 AI洞察
db_data: {bcg_data: [{name, qty, margin, sales}],
division_dist: [{name, sales, sales_2024, sales_2025}],
kpis: {total_sales, total_profit, avg_margin}}
對應 monthly_summary_analysis.html: bcgMatrixChart + divisionDistChart
"""
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
bcg_data = db_data.get('bcg_data', []) if isinstance(db_data, dict) else []
div_dist = db_data.get('division_dist', []) if isinstance(db_data, dict) else []
kpis = db_data.get('kpis', {}) if isinstance(db_data, dict) else {}
period_lbl = db_data.get('period_label', f"{yr}/{mo:02d}") if isinstance(db_data, dict) else f"{yr}/{mo:02d}"
total_sales = float(kpis.get('total_sales', 0))
avg_margin = float(kpis.get('avg_margin', 0))
# BCG 分類(以 avg_margin 為毛利門檻,以 中位業績 為業績門檻)
if bcg_data:
sales_vals = [float(b.get('sales', 0)) for b in bcg_data]
median_sales = sorted(sales_vals)[len(sales_vals) // 2] if sales_vals else 1
margin_thresh = avg_margin if avg_margin > 5 else 20.0
BCG_LABELS = {'star': '明星', 'cow': '金牛', 'q': '問號', 'dog': '瘦狗'}
BCG_COLORS = {'star': _GREEN_KPI, 'cow': _BLUE_KPI, 'q': _BRAND_OG, 'dog': _RED_WARN}
def _bcg_class(b):
s = float(b.get('sales', 0))
m = float(b.get('margin', 0))
if m >= margin_thresh and s >= median_sales: return 'star'
if m >= margin_thresh and s < median_sales: return 'cow'
if m < margin_thresh and s >= median_sales: return 'q'
return 'dog'
classified = [dict(b, _cls=_bcg_class(b)) for b in bcg_data]
star_list = [b for b in classified if b['_cls'] == 'star']
cow_list = [b for b in classified if b['_cls'] == 'cow']
q_list = [b for b in classified if b['_cls'] == 'q']
dog_list = [b for b in classified if b['_cls'] == 'dog']
else:
classified = star_list = cow_list = q_list = dog_list = []
BCG_LABELS = {'star': '明星', 'cow': '金牛', 'q': '問號', 'dog': '瘦狗'}
BCG_COLORS = {'star': _GREEN_KPI, 'cow': _BLUE_KPI, 'q': _BRAND_OG, 'dog': _RED_WARN}
margin_thresh = 20.0
# P1: 封面
_cover_slide(prs, f"品牌 BCG 矩陣報告\n{period_lbl}",
f"分析 {len(bcg_data)} 個品牌×區域組合",
f"毛利率門檻 {margin_thresh:.1f}% 生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
# P2: BCG 象限 KPI 卡
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
_add_header(s2, f"BCG 矩陣象限分佈 — {period_lbl}")
bcg_kpis = [
(_GREEN_KPI, "⭐ 明星", f"{len(star_list)}", "高毛利 + 高業績"),
(_BLUE_KPI, "🐄 金牛", f"{len(cow_list)}", "高毛利 + 低業績"),
(_BRAND_OG, "❓ 問號", f"{len(q_list)}", "低毛利 + 高業績"),
(_RED_WARN, "🐕 瘦狗", f"{len(dog_list)}", "低毛利 + 低業績"),
]
for i, (col, lbl, val, sub) in enumerate(bcg_kpis):
_kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.5, col, lbl, val, sub)
# 判斷門檻說明
_add_rect(s2, 0.5, 5.6, W - 1, 0.65, _FOOTER_BG)
_add_text(s2, f"毛利率門檻:{margin_thresh:.1f}%(高/低) | 業績門檻NT${median_sales/10000:.1f}萬(中位數)",
0.7, 5.65, W - 1.4, 0.55, size=11, color=_WHITE, align="center")
_add_footer(s2, W)
# P3: BCG 策略分類表4象限各顯示 TOP 5
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
_add_header(s3, f"BCG 策略矩陣清單 — {period_lbl}")
quadrants = [
('star', star_list, 0.5, 1.7),
('cow', cow_list, W/2+0.3, 1.7),
('q', q_list, 0.5, 8.5),
('dog', dog_list, W/2+0.3, 8.5),
]
for cls, items, lx, ty in quadrants:
col = BCG_COLORS[cls]
lbl = BCG_LABELS[cls]
pw = W / 2 - 0.8
_add_rect(s3, lx, ty, pw, 0.55, col)
_add_text(s3, f"{lbl} ({len(items)} 個)", lx + 0.2, ty + 0.05, pw - 0.4, 0.45,
bold=True, size=11, color=_WHITE)
for j, b in enumerate(items[:5]):
bg = _LIGHT_GRAY if j % 2 == 0 else _WHITE
rt = ty + 0.6 + j * 0.65
_add_rect(s3, lx, rt, pw, 0.62, bg)
_add_text(s3, str(b.get('name', ''))[:22],
lx + 0.2, rt + 0.06, pw * 0.55, 0.52, size=8, color=_DARK_TEXT)
_add_text(s3, f"{b.get('margin',0):.1f}%",
lx + pw * 0.58, rt + 0.06, pw * 0.2, 0.52, size=8, color=_SUBTEXT, align="right")
_add_text(s3, f"NT${float(b.get('sales',0))/10000:.1f}M",
lx + pw * 0.8, rt + 0.06, pw * 0.18, 0.52, size=8, color=_DARK_TEXT, align="right")
_add_footer(s3, W)
# P4: 區域業績橫條圖(對應 divisionDistChart2024 vs 2025
s4 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s4, 0, 0, W, 19.05, _WHITE)
_add_header(s4, f"區域業績分佈 — {period_lbl}(萬元)")
if div_dist:
names = [d.get('name', '')[:15] for d in div_dist[:12]]
s24_vals = [float(d.get('sales_2024', 0)) for d in div_dist[:12]]
s25_vals = [float(d.get('sales_2025', 0)) for d in div_dist[:12]]
has_both = any(s24_vals) and any(s25_vals)
if has_both:
_add_horiz_chart(s4, 0.5, 1.8, W - 1, 11.3,
names,
[("2024", s24_vals), ("2025", s25_vals)],
bar_colors=[_FOOTER_BG, _BRAND_OG])
else:
revs = [float(d.get('sales', 0)) for d in div_dist[:12]]
_add_horiz_chart(s4, 0.5, 1.8, W - 1, 11.3,
names, [("業績(萬元)", revs)],
bar_colors=[_BRAND_OG])
else:
_add_text(s4, "(無區域分佈資料)", 2, 7, 20, 2, size=14, color=_SUBTEXT, align="center")
_add_footer(s4, W)
# P5: AI 洞察
s5 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s5, 0, 0, W, 19.05, _BG_DARK)
_add_header(s5, f"AI BCG 品牌策略洞察 — {period_lbl}")
_add_text(s5, ai_text or "(暫無 AI 分析)",
1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True)
_add_footer(s5, W)
path = _new_path("bcg")
prs.save(path)
return path
return path