feat: PPT 簡報系統 V2 — 新增 growth/vendor/bcg 三種報告 + 原生圖表升級
All checks were successful
CD Pipeline / deploy (push) Successful in 1m15s
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:
@@ -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
|
||||
|
||||
|
||||
# ── 成長趨勢報告 PPT(6頁)────────────────────────────────────────────────────
|
||||
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
|
||||
|
||||
|
||||
# ── 廠商業績報告 PPT(5頁)────────────────────────────────────────────────────
|
||||
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 品牌矩陣報告 PPT(5頁)───────────────────────────────────────────────
|
||||
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: 區域業績橫條圖(對應 divisionDistChart,2024 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
|
||||
|
||||
Reference in New Issue
Block a user