Files
ewoooc/services/ppt_generator.py
OoO d88dcc8f75
All checks were successful
CD Pipeline / deploy (push) Successful in 1m45s
fix(devops): 清理舊端口與危險 compose 操作
2026-04-30 14:24:53 +08:00

1346 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
services/ppt_generator.py
OpenClaw 簡報生成器 — 精品深色主題 + 原生圖表版 (2026-04-20 v2)
函數清單(與 openclaw_bot_routes.py 呼叫約定一致):
check_pptx_available()
generate_daily_ppt(date_str, db_data, ai_text) -> str # 4頁
generate_weekly_ppt(db_data, ai_text) -> str # 5頁
generate_monthly_ppt(yr, mo, db_data, ai_text) -> str # 5頁
generate_strategy_ppt(date_str, db_data, ai_text) -> str # 5頁
generate_competitor_ppt(period_label, db_data, ai_text) -> str # 4頁
generate_promo_ppt(promo_label, data, ai_text) -> str # 5頁
設計規格:
- 16:9 (33.87cm × 19.05cm)
- 封面:深海藍背景 #0D1B2A、橘色品牌條 #FF5722、白色大標
- 數據頁:白底 KPI 卡 + python-pptx 原生圖表
- AI 頁:深色背景 + 白色文字
- 頁眉:橘色標題帶 #FF5722
- 頁腳:♥ Powered by OpenClaw深灰 #37474F
圖表對應來源templates:
daily → 近7日業績柱狀圖參考 daily_sales.html trendChart
weekly → 7日業績柱狀圖 + TOP10 商品表
monthly → 品類橫條圖(參考 monthly_summary_analysis.html+ KPI + TOP10
strategy → 策略矩陣分佈柱狀圖 + 行動清單
competitor → 橫條圖 + 比較表(維持現有)
promo → 促銷 vs 對比期雙柱圖 + KPI 對比 + TOP商品
"""
import os
import uuid
from collections import defaultdict
from datetime import datetime
from pathlib import Path
REPORTS_DIR = Path(os.environ.get("REPORTS_DIR", "/app/data/reports"))
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
# ── 調色盤 ────────────────────────────────────────────────────────────────────
_BG_DARK = "0D1B2A"
_BRAND_OG = "FF5722"
_BRAND_OG2 = "E65100"
_WHITE = "FFFFFF"
_LIGHT_GRAY = "F5F5F5"
_DARK_TEXT = "212121"
_SUBTEXT = "757575"
_FOOTER_BG = "37474F"
_BLUE_KPI = "1565C0"
_GREEN_KPI = "2E7D32"
_RED_WARN = "C62828"
_BAR_PCHOME = "EF5350"
_BAR_MOMO = "66BB6A"
_BAR_TIE = "FFA726"
_BAR_MISS = "9E9E9E"
_STRAT_COLORS = {
'加碼': _BRAND_OG,
'機會': _BLUE_KPI,
'收割': _GREEN_KPI,
'觀察': _FOOTER_BG,
'持穩': "90A4AE",
'其他': _SUBTEXT,
}
_STRAT_ORDER = ['加碼', '機會', '收割', '觀察', '持穩']
def check_pptx_available() -> bool:
try:
import pptx # noqa: F401
return True
except ImportError:
return False
def _new_path(kind: str) -> str:
rid = uuid.uuid4().hex[:8]
return str(REPORTS_DIR / f"ocbot_{kind}_{rid}.pptx")
# ── 基礎繪圖工具 ──────────────────────────────────────────────────────────────
def _rgb(hex6: str):
from pptx.dml.color import RGBColor
return RGBColor(int(hex6[0:2], 16), int(hex6[2:4], 16), int(hex6[4:6], 16))
def _emu(cm: float):
from pptx.util import Cm
return Cm(cm)
def _fill_solid(shape, hex6: str):
shape.fill.solid()
shape.fill.fore_color.rgb = _rgb(hex6)
def _add_rect(slide, l, t, w, h, fill_hex, line_hex=None):
from pptx.util import Pt
s = slide.shapes.add_shape(1, _emu(l), _emu(t), _emu(w), _emu(h))
_fill_solid(s, fill_hex)
if line_hex:
s.line.color.rgb = _rgb(line_hex)
s.line.width = Pt(0.5)
else:
s.line.fill.background()
return s
def _add_text(slide, text, l, t, w, h,
bold=False, size=14, color=_WHITE,
align="left", valign="top", wrap=True):
from pptx.util import Pt
from pptx.enum.text import PP_ALIGN
from pptx.dml.color import RGBColor
txb = slide.shapes.add_textbox(_emu(l), _emu(t), _emu(w), _emu(h))
tf = txb.text_frame
tf.word_wrap = wrap
if valign == "middle":
from pptx.enum.text import MSO_ANCHOR
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
p = tf.paragraphs[0]
p.text = text
run = p.runs[0]
run.font.bold = bold
run.font.size = Pt(size)
run.font.color.rgb = RGBColor(
int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16))
if align == "center":
p.alignment = PP_ALIGN.CENTER
elif align == "right":
p.alignment = PP_ALIGN.RIGHT
return txb
def _add_footer(slide, prs_w_cm=33.87):
_add_rect(slide, 0, 13.5, prs_w_cm, 0.79, _FOOTER_BG)
_add_text(slide, "♥ Powered by OpenClaw",
prs_w_cm - 6, 13.55, 5.8, 0.7,
size=8, color=_BRAND_OG, align="right")
def _add_header(slide, title_text, prs_w_cm=33.87):
_add_rect(slide, 0, 0, prs_w_cm, 1.5, _BRAND_OG)
_add_text(slide, title_text, 0.5, 0.1, prs_w_cm - 1, 1.3,
bold=True, size=20, color=_WHITE, valign="middle")
def _kpi_card(slide, l, t, w, h, fill, label, value, sub=""):
_add_rect(slide, l, t, w, h, fill)
_add_text(slide, label, l + 0.2, t + 0.2, w - 0.4, 0.7, size=10, color=_WHITE)
_add_text(slide, value, l + 0.2, t + 0.7, w - 0.4, 1.3,
bold=True, size=28, color=_WHITE, align="center")
if sub:
_add_text(slide, sub, l + 0.2, t + h - 0.65, w - 0.4, 0.6, size=9, color=_WHITE)
def _horiz_bar(slide, l, t, h_row, label, value, total, fill_hex, max_w=14.0):
pct = value / total if total else 0
bar_w = max(0.2, pct * max_w)
_add_text(slide, label, l, t + 0.05, 3.0, h_row - 0.1, size=10, color=_DARK_TEXT)
_add_rect(slide, l + 3.1, t + 0.1, bar_w, h_row - 0.2, fill_hex)
_add_text(slide, f"{value}件 ({pct*100:.0f}%)",
l + 3.2 + bar_w, t + 0.05, 4.0, h_row - 0.1, size=10, color=_DARK_TEXT)
# ── 原生圖表工具 ──────────────────────────────────────────────────────────────
def _add_column_chart(slide, l, t, w, h, categories, series_list,
bar_colors=None, title="", raw_values=False):
"""垂直柱狀圖(時間趨勢、分佈)。
series_list: [(name, [values]), ...]
raw_values=False 時自動將值除以 10000萬元顯示
"""
from pptx.chart.data import ChartData
from pptx.enum.chart import XL_CHART_TYPE
from pptx.util import Pt
try:
from pptx.enum.chart import XL_LEGEND_POSITION
except ImportError:
XL_LEGEND_POSITION = None
if not categories or not series_list:
return None
cd = ChartData()
cd.categories = [str(c) for c in categories]
for name, vals in series_list:
if raw_values:
cd.add_series(name, tuple(float(v) if v else 0 for v in vals))
else:
cd.add_series(name, tuple(round(float(v) / 10000, 1) if v else 0 for v in vals))
cf = slide.shapes.add_chart(
XL_CHART_TYPE.COLUMN_CLUSTERED,
_emu(l), _emu(t), _emu(w), _emu(h), cd
)
chart = cf.chart
try:
chart.plot_area.format.fill.background()
chart.chart_area.format.fill.background()
except Exception:
pass
chart.has_title = bool(title)
if title:
chart.chart_title.text_frame.text = title
try:
chart.chart_title.text_frame.paragraphs[0].runs[0].font.size = Pt(12)
except Exception:
pass
chart.has_legend = len(series_list) > 1
if chart.has_legend and XL_LEGEND_POSITION:
try:
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.include_in_layout = False
except Exception:
pass
try:
chart.value_axis.number_format = '#,##0'
chart.value_axis.has_major_gridlines = True
chart.category_axis.has_major_gridlines = False
except Exception:
pass
if bar_colors:
for i, series in enumerate(chart.series):
if i < len(bar_colors):
try:
series.format.fill.solid()
series.format.fill.fore_color.rgb = _rgb(bar_colors[i])
except Exception:
pass
return chart
def _add_horiz_chart(slide, l, t, w, h, categories, series_list,
bar_colors=None, title="", raw_values=False):
"""水平柱狀圖(排行榜、品類比較)。"""
from pptx.chart.data import ChartData
from pptx.enum.chart import XL_CHART_TYPE
from pptx.util import Pt
try:
from pptx.enum.chart import XL_LEGEND_POSITION
except ImportError:
XL_LEGEND_POSITION = None
if not categories or not series_list:
return None
cd = ChartData()
cd.categories = [str(c)[:20] for c in categories]
for name, vals in series_list:
if raw_values:
cd.add_series(name, tuple(float(v) if v else 0 for v in vals))
else:
cd.add_series(name, tuple(round(float(v) / 10000, 1) if v else 0 for v in vals))
cf = slide.shapes.add_chart(
XL_CHART_TYPE.BAR_CLUSTERED,
_emu(l), _emu(t), _emu(w), _emu(h), cd
)
chart = cf.chart
try:
chart.plot_area.format.fill.background()
chart.chart_area.format.fill.background()
except Exception:
pass
chart.has_title = bool(title)
if title:
chart.chart_title.text_frame.text = title
try:
chart.chart_title.text_frame.paragraphs[0].runs[0].font.size = Pt(12)
except Exception:
pass
chart.has_legend = len(series_list) > 1
if chart.has_legend and XL_LEGEND_POSITION:
try:
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.include_in_layout = False
except Exception:
pass
try:
chart.value_axis.number_format = '#,##0'
except Exception:
pass
if bar_colors:
for i, series in enumerate(chart.series):
if i < len(bar_colors):
try:
series.format.fill.solid()
series.format.fill.fore_color.rgb = _rgb(bar_colors[i])
except Exception:
pass
return chart
# ── 封面頁 ────────────────────────────────────────────────────────────────────
def _cover_slide(prs, big_title: str, sub1: str, sub2: str = ""):
slide = prs.slides.add_slide(prs.slide_layouts[6])
W = 33.87
_add_rect(slide, 0, 0, W, 19.05, _BG_DARK)
_add_rect(slide, 0, 0, 0.6, 19.05, _BRAND_OG)
_add_rect(slide, 1.2, 1.2, 3.5, 0.7, _BRAND_OG)
_add_text(slide, "OPENCLAW", 1.3, 1.22, 3.3, 0.66,
bold=True, size=11, color=_WHITE, align="center", valign="middle")
_add_text(slide, big_title, 1.2, 2.3, 20, 3.5, bold=True, size=38, color=_WHITE)
_add_text(slide, sub1, 1.2, 6.0, 22, 0.9, size=14, color="BDBDBD")
if sub2:
_add_text(slide, sub2, 1.2, 7.0, 22, 0.8, size=12, color="9E9E9E")
_add_rect(slide, W - 0.8, 0, 0.8, 19.05, _BRAND_OG2)
_add_footer(slide, W)
return slide
# ── 通用商品表格投影片 ─────────────────────────────────────────────────────────
def _product_table_slide(prs, header_text, products, W=33.87):
slide = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(slide, 0, 0, W, 19.05, _WHITE)
_add_header(slide, header_text)
_add_rect(slide, 0.5, 1.7, W - 1, 0.65, _BRAND_OG)
_add_text(slide, "#", 0.6, 1.73, 1.5, 0.59, bold=True, size=10, color=_WHITE, align="center")
_add_text(slide, "商品名稱", 2.3, 1.73, W - 11.5, 0.59, bold=True, size=10, color=_WHITE)
_add_text(slide, "業績", W - 8.5, 1.73, 5.5, 0.59, bold=True, size=10, color=_WHITE, align="right")
_add_text(slide, "訂單", W - 2.8, 1.73, 2.2, 0.59, bold=True, size=9, color=_WHITE, align="right")
for i, p in enumerate(products[:10]):
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
row_t = 2.5 + i * 0.88
_add_rect(slide, 0.5, row_t, W - 1, 0.85, bg)
_add_text(slide, str(i + 1),
0.6, row_t + 0.1, 1.5, 0.7, size=10, color=_DARK_TEXT, align="center")
_add_text(slide, str(p.get('name', ''))[:50],
2.3, row_t + 0.1, W - 11.5, 0.7, size=10, color=_DARK_TEXT)
_add_text(slide, f"NT${float(p.get('revenue', 0)):,.0f}",
W - 8.5, row_t + 0.1, 5.5, 0.7, size=10, color=_DARK_TEXT, align="right")
ord_v = p.get('orders', p.get('order_count', ''))
if ord_v:
_add_text(slide, f"{int(ord_v):,}",
W - 2.8, row_t + 0.1, 2.2, 0.7, size=9, color=_SUBTEXT, align="right")
_add_footer(slide, W)
return slide
# ── 日報 PPT4頁────────────────────────────────────────────────────────────
def generate_daily_ppt(date_str: str, db_data, ai_text: str) -> str:
"""P1封面 P2 KPI+TOP5 P3 近7日業績走勢圖 P4 AI洞察"""
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
s = db_data.get("sales", {}) if isinstance(db_data, dict) else {}
tp = db_data.get("top_products", []) if isinstance(db_data, dict) else []
wk = db_data.get("weekly", []) if isinstance(db_data, dict) else []
rev = float(s.get("revenue", 0))
ord_ = int(s.get("orders", 0))
gm = float(s.get("gross_margin", 0))
aov = float(s.get("avg_order", 0))
# P1: 封面
_cover_slide(prs,
f"日報 {date_str}",
f"業績 NT${rev:,.0f}{rev/10000:.1f}萬)|訂單 {ord_:,}",
f"毛利率 {gm:.1f}% 客單價 NT${aov:,.0f} 生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
)
# P2: KPI 卡 + TOP5
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
_add_header(s2, f"業績 KPI — {date_str}")
kpis = [
(_BLUE_KPI, "總業績", f"NT${rev/10000:.1f}", ""),
(_GREEN_KPI, "總訂單", f"{ord_:,}", ""),
(_BRAND_OG2, "毛利率", f"{gm:.1f}%", ""),
(_FOOTER_BG, "客單價", f"NT${aov:,.0f}", ""),
]
for i, (col, lbl, val, sub) in enumerate(kpis):
_kpi_card(s2, i * (7.4 + 0.4) + 0.5, 1.8, 7.4, 3.0, col, lbl, val, sub)
_add_rect(s2, 0.5, 5.2, W - 1, 0.7, _BRAND_OG)
_add_text(s2, "🏆 TOP 5 熱銷商品", 0.7, 5.25, W - 1.4, 0.6,
bold=True, size=13, color=_WHITE)
for i, p in enumerate(tp[:5]):
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
rev_p = float(p.get("revenue", 0))
_add_rect(s2, 0.5, 6.0 + i * 0.85, W - 1, 0.82, bg)
_add_text(s2, f"{i+1}. {str(p.get('name',''))[:45]}",
0.7, 6.05 + i * 0.85, W - 9, 0.72, size=10, color=_DARK_TEXT)
_add_text(s2, f"NT${rev_p:,.0f}",
W - 7.5, 6.05 + i * 0.85, 5, 0.72, size=10, color=_DARK_TEXT, align="right")
_add_footer(s2, W)
# P3: 近7日業績走勢圖來源daily_sales.html trendChart
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
_add_header(s3, "近 7 日業績走勢(萬元)")
if wk:
dates = [str(w.get('date', ''))[-5:] for w in wk]
revs = [float(w.get('revenue', 0)) for w in wk]
_add_column_chart(s3, 0.8, 1.8, W - 1.6, 11.0,
dates, [("日業績(萬元)", revs)],
bar_colors=[_BRAND_OG])
# 加總標注
total_7d = sum(revs)
_add_text(s3, f"近7日合計NT${total_7d/10000:.1f}",
0.8, 13.0, 12, 0.5, size=10, color=_SUBTEXT)
else:
_add_text(s3, "(近 7 日業績資料不足)",
2, 7, 20, 2, size=14, color=_SUBTEXT, align="center")
_add_footer(s3, W)
# P4: AI 洞察
s4 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s4, 0, 0, W, 19.05, _BG_DARK)
_add_header(s4, "AI 洞察分析")
_add_text(s4, ai_text or "AI 分析生成中)",
1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True)
_add_footer(s4, W)
path = _new_path("daily")
prs.save(path)
return path
# ── 週報 PPT5頁────────────────────────────────────────────────────────────
def generate_weekly_ppt(db_data, ai_text: str) -> str:
"""P1封面 P2 KPI摘要 P3 7日業績柱狀圖 P4 TOP10商品 P5 AI洞察"""
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
wk = db_data.get('weekly', []) if isinstance(db_data, dict) else []
tp = db_data.get('top_products', []) if isinstance(db_data, dict) else []
total_rev = sum(float(w.get('revenue', 0)) for w in wk)
total_ord = sum(int(w.get('orders', 0)) for w in wk)
best_day = max(wk, key=lambda w: float(w.get('revenue', 0)), default={})
avg_daily = total_rev / len(wk) if wk else 0
# P1: 封面
_cover_slide(prs, "週報分析",
f"近 7 日業績 NT${total_rev:,.0f}{total_rev/10000:.1f}萬)",
f"生成 {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, "週報 KPI 摘要 — 近 7 日")
kpis = [
(_BLUE_KPI, "週業績", f"NT${total_rev/10000:.1f}", ""),
(_GREEN_KPI, "週訂單", f"{total_ord:,}" if total_ord else "", ""),
(_BRAND_OG2, "日均業績", f"NT${avg_daily/10000:.1f}", ""),
(_FOOTER_BG, "最佳單日", f"NT${float(best_day.get('revenue',0))/10000:.1f}",
best_day.get('date', '')[-5:] if best_day else ""),
]
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)
_add_footer(s2, W)
# P3: 7日業績柱狀圖參考 daily_sales.html trendChart
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
_add_header(s3, "近 7 日業績走勢圖(萬元)")
if wk:
dates = [str(w.get('date', ''))[-5:] for w in wk]
revs = [float(w.get('revenue', 0)) for w in wk]
_add_column_chart(s3, 0.8, 1.8, W - 1.6, 11.0,
dates, [("日業績(萬元)", revs)],
bar_colors=[_BLUE_KPI])
# 逐日數值標注(文字輔助)
max_rev = max(revs) if revs else 1
for j, (d, r) in enumerate(zip(dates, revs)):
color = _BRAND_OG if r == max_rev else _SUBTEXT
_add_text(s3, f"{r/10000:.1f}",
0.8 + j * ((W - 1.6) / max(len(dates), 1)),
12.9, (W - 1.6) / max(len(dates), 1) - 0.1, 0.5,
size=8, color=color, align="center")
else:
_add_text(s3, "(近 7 日業績資料不足)",
2, 7, 20, 2, size=14, color=_SUBTEXT, align="center")
_add_footer(s3, W)
# P4: TOP10 商品表
_product_table_slide(prs, "週報 TOP 10 熱銷商品", tp)
# P5: AI 洞察
s5 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s5, 0, 0, W, 19.05, _BG_DARK)
_add_header(s5, "週報 AI 洞察")
_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("weekly")
prs.save(path)
return path
# ── 月報 PPT5頁────────────────────────────────────────────────────────────
def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str:
"""P1封面 P2 KPI P3 品類橫條圖 P4 TOP10商品 P5 AI洞察"""
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
ms = db_data.get('monthly', {}) if isinstance(db_data, dict) else {}
rev = float(ms.get('revenue', 0))
ord_ = int(ms.get('orders', 0))
gm = float(ms.get('gross_margin', 0))
aov = float(ms.get('avg_order', rev / ord_ if ord_ else 0))
top_cats = ms.get('top_categories', [])
top_prod = ms.get('top_products', [])
# P1: 封面
_cover_slide(prs, f"月報 {yr}/{mo:02d}", f"{yr}{mo} 月業績月報",
f"業績 NT${rev:,.0f}{rev/10000:.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 — {yr}/{mo:02d}")
kpis = [
(_BLUE_KPI, "月業績", f"NT${rev/10000:.1f}", ""),
(_GREEN_KPI, "總訂單", f"{ord_:,}", ""),
(_BRAND_OG2, "毛利率", f"{gm:.1f}%", ""),
(_FOOTER_BG, "客單價", f"NT${aov:,.0f}", ""),
]
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)
_add_footer(s2, W)
# P3: 品類業績橫條圖(參考 monthly_summary_analysis.html vendorRankingChart
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
_add_header(s3, f"品類業績排行 TOP 8 — {yr}/{mo:02d}(萬元)")
if top_cats:
cats = [c.get('cat', '')[:15] for c in top_cats[:8]]
revs = [float(c.get('revenue', 0)) for c in top_cats[:8]]
_add_horiz_chart(s3, 0.5, 1.8, W - 1, 11.3,
cats, [("業績(萬元)", revs)],
bar_colors=[_BLUE_KPI])
# 最高品類業績備注
if top_cats:
best = top_cats[0]
_add_text(s3,
f"最高:{best.get('cat','')} NT${float(best.get('revenue',0)):,.0f}",
0.8, 13.15, 18, 0.5, size=10, color=_SUBTEXT)
else:
_add_text(s3, "(本月無品類分佈資料)",
2, 7, 20, 2, size=14, color=_SUBTEXT, align="center")
_add_footer(s3, W)
# P4: TOP10 商品
_product_table_slide(prs, f"熱銷商品 TOP 10 — {yr}/{mo:02d}", top_prod)
# 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 洞察 — {yr}/{mo:02d}")
_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("monthly")
prs.save(path)
return path
# ── 策略報告 PPT5頁────────────────────────────────────────────────────────
def generate_strategy_ppt(date_str: str, db_data, ai_text: str) -> str:
"""P1封面 P2 KPI+TOP5 P3 策略矩陣柱狀圖+說明 P4 行動清單 P5 AI洞察"""
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
s = db_data.get("sales", {}) if isinstance(db_data, dict) else {}
tp = db_data.get("top_products", []) if isinstance(db_data, dict) else []
strat = db_data.get("strategy", []) if isinstance(db_data, dict) else []
period = db_data.get("period_label", date_str) if isinstance(db_data, dict) else date_str
rev = float(s.get("revenue", 0))
ord_ = int(s.get("orders", 0))
gm = float(s.get("gross_margin", 0))
aov = float(s.get("avg_order", 0))
# 策略分佈統計
strat_agg = defaultdict(lambda: {'count': 0, 'revenue': 0.0})
for item in strat:
k = item.get('strategy', '其他')
strat_agg[k]['count'] += 1
strat_agg[k]['revenue'] += float(item.get('revenue', 0))
# P1: 封面
_cover_slide(prs, f"策略報告\n{period}",
f"業績 NT${rev:,.0f}{rev/10000:.1f}萬)",
f"生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
# P2: KPI + TOP5
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
_add_header(s2, f"策略 KPI — {period}")
kpis = [
(_BLUE_KPI, "總業績", f"NT${rev/10000:.1f}", ""),
(_GREEN_KPI, "總訂單", f"{ord_:,}", ""),
(_BRAND_OG2, "毛利率", f"{gm:.1f}%", ""),
(_FOOTER_BG, "客單價", f"NT${aov:,.0f}", ""),
]
for i, (col, lbl, val, sub) in enumerate(kpis):
_kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.0, col, lbl, val, sub)
_add_rect(s2, 0.5, 5.2, W - 1, 0.65, _BRAND_OG)
_add_text(s2, "🏆 TOP 熱銷商品", 0.7, 5.25, W - 1.4, 0.55,
bold=True, size=12, color=_WHITE)
for i, p in enumerate(tp[:5]):
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
_add_rect(s2, 0.5, 6.0 + i * 0.8, W - 1, 0.78, bg)
_add_text(s2, f"{i+1}. {str(p.get('name',''))[:45]}",
0.7, 6.04 + i * 0.8, W - 9, 0.7, size=9, color=_DARK_TEXT)
_add_text(s2, f"NT${float(p.get('revenue',0)):,.0f}",
W - 7.5, 6.04 + i * 0.8, 5, 0.7, size=9, color=_DARK_TEXT, align="right")
_add_footer(s2, W)
# P3: 策略矩陣分佈(參考 monthly_summary_analysis.html BCG Matrix 概念)
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
_add_header(s3, f"策略矩陣分佈 — {period}")
if strat_agg:
order = [k for k in _STRAT_ORDER if k in strat_agg] + \
[k for k in strat_agg if k not in _STRAT_ORDER]
cnts = [strat_agg[k]['count'] for k in order]
colors = [_STRAT_COLORS.get(k, _SUBTEXT) for k in order]
# 左側柱狀圖(商品件數)
_add_column_chart(s3, 0.8, 1.8, W * 0.55 - 0.8, 10.8,
order, [("商品數(件)", cnts)],
bar_colors=colors, raw_values=True)
# 右側文字說明清單
_add_text(s3, "策略說明", W * 0.55 + 0.3, 1.8, W * 0.44 - 0.5, 0.6,
bold=True, size=12, color=_DARK_TEXT)
desc_t = 2.55
for k in order:
v = strat_agg[k]
color = _STRAT_COLORS.get(k, _SUBTEXT)
_add_rect(s3, W * 0.55 + 0.3, desc_t + 0.05, 0.35, 0.45, color)
_add_text(s3,
f"{k} {v['count']} 件 NT${v['revenue']/10000:.1f}",
W * 0.55 + 0.75, desc_t, W * 0.44 - 0.85, 0.55,
size=11, color=_DARK_TEXT)
desc_t += 0.68
else:
_add_text(s3, "(無策略分析資料)",
2, 7, 20, 2, size=14, color=_SUBTEXT, align="center")
_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"策略行動清單 — {period}")
_add_rect(s4, 0.5, 1.7, W - 1, 0.65, _BRAND_OG)
_add_text(s4, "#", 0.6, 1.73, 1.2, 0.59, bold=True, size=10, color=_WHITE, align="center")
_add_text(s4, "商品名稱", 2.0, 1.73, W - 14, 0.59, bold=True, size=10, color=_WHITE)
_add_text(s4, "策略", W - 11, 1.73, 3.5, 0.59, bold=True, size=10, color=_WHITE, align="center")
_add_text(s4, "業績", W - 7, 1.73, 5, 0.59, bold=True, size=10, color=_WHITE, align="right")
_add_text(s4, "毛利率", W - 2, 1.73, 1.5, 0.59, bold=True, size=9, color=_WHITE, align="right")
sorted_strat = sorted(
strat,
key=lambda x: (
_STRAT_ORDER.index(x.get('strategy', '')) if x.get('strategy') in _STRAT_ORDER else 99,
-float(x.get('revenue', 0))
)
)
for i, item in enumerate(sorted_strat[:10]):
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
sk = item.get('strategy', '其他')
sc = _STRAT_COLORS.get(sk, _SUBTEXT)
row_t = 2.5 + i * 0.88
_add_rect(s4, 0.5, row_t, W - 1, 0.85, bg)
_add_text(s4, str(i + 1),
0.6, row_t + 0.1, 1.2, 0.7, size=10, color=_DARK_TEXT, align="center")
_add_text(s4, str(item.get('name', ''))[:48],
2.0, row_t + 0.1, W - 14, 0.7, size=9, color=_DARK_TEXT)
_add_rect(s4, W - 11, row_t + 0.15, 3.0, 0.55, sc)
_add_text(s4, sk, W - 11, row_t + 0.15, 3.0, 0.55,
bold=True, size=9, color=_WHITE, align="center")
_add_text(s4, f"NT${float(item.get('revenue', 0)):,.0f}",
W - 7, row_t + 0.1, 5, 0.7, size=10, color=_DARK_TEXT, align="right")
gm_v = item.get('gross_margin', item.get('margin', ''))
if gm_v:
_add_text(s4, f"{float(gm_v):.1f}%",
W - 2, row_t + 0.1, 1.5, 0.7, size=9, color=_SUBTEXT, align="right")
_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, "AI 策略洞察")
_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("strategy")
prs.save(path)
return path
# ── 促銷報告 PPT5頁────────────────────────────────────────────────────────
def generate_promo_ppt(promo_label: str, data, ai_text: str) -> str:
"""P1封面 P2 促銷vs對比期KPI P3 業績對比柱狀圖 P4 TOP商品 P5 AI洞察"""
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
pd_ = data.get('promo', {}) if isinstance(data, dict) else {}
prev = data.get('pre', {}) if isinstance(data, dict) else {}
rev_lift = float(data.get('rev_lift', 0)) if isinstance(data, dict) else 0
ord_lift = float(data.get('ord_lift', 0)) if isinstance(data, dict) else 0
top_prod = pd_.get('top_products', [])
promo_rev = float(pd_.get('revenue', 0))
promo_ord = int(pd_.get('orders', 0))
promo_gm = float(pd_.get('margin', 0))
promo_days = max(int(pd_.get('days', 1)), 1)
pre_rev = float(prev.get('revenue', 0))
pre_ord = int(prev.get('orders', 0))
pre_gm = float(prev.get('margin', 0))
pre_start = prev.get('start', '')
pre_end = prev.get('end', '')
lift_icon = "📈" if rev_lift > 0 else "📉"
lift_color = _GREEN_KPI if rev_lift > 0 else _RED_WARN
# P1: 封面
_cover_slide(prs, f"促銷報告\n{promo_label}",
f"業績 NT${promo_rev:,.0f}{promo_rev/10000:.1f}萬) {lift_icon} {rev_lift:+.1f}%",
f"活動 {promo_days} 天 生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
# P2: 促銷 vs 對比期 KPI 對比
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
_add_header(s2, f"促銷效益對比 — {promo_label}")
# 促銷期 KPI橘色
_add_rect(s2, 0.5, 1.7, W - 1, 0.5, _BRAND_OG)
_add_text(s2, f"▶ 促銷期({promo_label}{promo_days}天)",
0.7, 1.73, W - 1.4, 0.44, bold=True, size=11, color=_WHITE)
promo_kpis = [
(_BRAND_OG2, "活動期業績", f"NT${promo_rev/10000:.1f}", ""),
(_BRAND_OG2, "活動期訂單", f"{promo_ord:,}", ""),
(_BRAND_OG2, "活動期毛利率", f"{promo_gm:.1f}%", ""),
(_BRAND_OG2, "日均業績", f"NT${promo_rev/promo_days/10000:.1f}", ""),
]
for i, (col, lbl, val, sub) in enumerate(promo_kpis):
_kpi_card(s2, i * 7.8 + 0.5, 2.3, 7.4, 2.8, col, lbl, val, sub)
# 對比期 KPI深灰
_add_rect(s2, 0.5, 5.3, W - 1, 0.5, _FOOTER_BG)
_add_text(s2, f"◀ 對比期({pre_start} ~ {pre_end}",
0.7, 5.33, W - 1.4, 0.44, bold=True, size=11, color=_WHITE)
pre_kpis = [
(_FOOTER_BG, "對比期業績", f"NT${pre_rev/10000:.1f}", ""),
(_FOOTER_BG, "對比期訂單", f"{pre_ord:,}", ""),
(_FOOTER_BG, "對比期毛利率", f"{pre_gm:.1f}%", ""),
(_FOOTER_BG, "日均業績", f"NT${pre_rev/promo_days/10000:.1f}", ""),
]
for i, (col, lbl, val, sub) in enumerate(pre_kpis):
_kpi_card(s2, i * 7.8 + 0.5, 5.9, 7.4, 2.8, col, lbl, val, sub)
# 升降幅橫幅
_add_rect(s2, 0.5, 9.0, W - 1, 0.85, lift_color)
_add_text(s2, f"{lift_icon} 業績成長 {rev_lift:+.1f}%  訂單成長 {ord_lift:+.1f}%",
0.7, 9.05, W - 1.4, 0.75, bold=True, size=16, color=_WHITE, align="center")
_add_footer(s2, W)
# P3: 業績對比柱狀圖(參考 growth_analysis.html revenueChart 雙柱概念)
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
_add_header(s3, "促銷期 vs 對比期 — 業績比較(萬元)")
_add_column_chart(
s3, 1.0, 1.8, W - 2.0, 11.0,
["對比期", "促銷期"],
[("業績(萬元)", [pre_rev, promo_rev])],
bar_colors=[_FOOTER_BG, _BRAND_OG]
)
# 毛利率文字對比
gm_diff = promo_gm - pre_gm
gm_color = _GREEN_KPI if gm_diff >= 0 else _RED_WARN
_add_text(s3,
f"毛利率:對比期 {pre_gm:.1f}% → 促銷期 {promo_gm:.1f}% "
f"({'' if gm_diff >= 0 else ''}{abs(gm_diff):.1f}pp)",
1.0, 13.05, W - 2, 0.55, size=11, color=gm_color)
_add_footer(s3, W)
# P4: 活動期熱銷商品
_product_table_slide(prs, f"促銷期熱銷商品 TOP 10 — {promo_label}", top_prod)
# 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 洞察 — {promo_label}")
_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("promo")
prs.save(path)
return path
# ── 競品比較 PPT4頁維持原有架構────────────────────────────────────────────
def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> str:
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
results = db_data.get("results", [])
found = [r for r in results if r.get("found")]
pc_wins = [r for r in found if r.get("price_diff", 0) > 10]
mo_wins = [r for r in found if r.get("price_diff", 0) < -10]
tie = [r for r in found if abs(r.get("price_diff", 0)) <= 10]
not_found = [r for r in results if not r.get("found")]
total = len(results)
match_rate = len(found) / total * 100 if total else 0
avg_pct = (sum(r.get("price_diff_pct", 0) for r in found) / len(found)
if found else 0)
momo_rev = db_data.get("momo_revenue", 0)
# P1: 封面
_cover_slide(
prs,
f"momo vs PChome\n競品比較分析",
f"掃描 {total} 件熱銷商品|比對成功 {len(found)}",
f"分析週期:{period_label}  平均價差:{avg_pct:+.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_label}")
cards = [
(_BLUE_KPI, "掃描商品數", str(total), f"{total} 件熱銷商品"),
("1565C0", "比對成功率", f"{match_rate:.0f}%", f"{len(found)} 件成功"),
(_GREEN_KPI, "PChome 優勢", f"{len(pc_wins)}/{len(found)}", f"{len(pc_wins)} 件 PChome 更便宜"),
(_BRAND_OG2, "momo 優勢", f"{len(mo_wins)}/{len(found)}", f"{len(mo_wins)} 件 momo 更便宜"),
]
card_w, card_gap, card_t = 7.4, 0.4, 1.8
for i, (col, lbl, val, sub) in enumerate(cards):
_kpi_card(s2, i * (card_w + card_gap) + 0.5, card_t, card_w, 3.2, col, lbl, val, sub)
trend_color = _RED_WARN if avg_pct < -3 else (_BRAND_OG if avg_pct > 3 else "FFA726")
trend_icon = "⚠️ momo 整體偏貴" if avg_pct < -3 else ("✅ momo 整體具優勢" if avg_pct > 3 else " 整體持平")
_add_rect(s2, 0.5, 5.3, W - 1, 0.85, trend_color)
_add_text(s2, f"整體定價態勢:{trend_icon} 平均價差 {avg_pct:+.1f}%(正值=momo偏貴",
0.7, 5.35, W - 1.4, 0.75, bold=True, size=13, color=_WHITE)
bar_data = [
("PChome 更便宜", len(pc_wins), total, _BAR_PCHOME),
("momo 更便宜", len(mo_wins), total, _BAR_MOMO),
("價格相近", len(tie), total, _BAR_TIE),
("未找到對應", len(not_found), total, _BAR_MISS),
]
row_t = 6.4
for label, val, tot, col in bar_data:
_horiz_bar(s2, 0.8, row_t, 0.9, label, val, tot, col, max_w=16.0)
row_t += 1.0
if momo_rev:
_add_text(s2, f"本期 momo 掃描商品總業績NT$ {momo_rev:,.0f}{momo_rev/10000:.1f}萬)",
0.8, 11.8, W - 1.6, 0.7, size=10, color=_SUBTEXT)
_add_footer(s2, W)
# P3: 商品比較表
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
_add_header(s3, "PChome 業績優勢 — 比 momo 熱銷 TOP10")
cols = ["#", "momo 商品", "momo 定價", "PChome 商品", "PChome 定價", "價差", "趨勢"]
col_w = [0.8, 8.5, 2.8, 8.5, 2.8, 2.5, 2.0]
x = 0.3
for c, w in zip(cols, col_w):
_add_rect(s3, x, 1.7, w, 0.7, _BRAND_OG)
_add_text(s3, c, x + 0.05, 1.72, w - 0.1, 0.66,
bold=True, size=9, color=_WHITE, align="center")
x += w + 0.1
rows = (pc_wins + mo_wins)[:10]
for ri, r in enumerate(rows):
bg = _LIGHT_GRAY if ri % 2 == 0 else _WHITE
x = 0.3
diff = r.get("price_diff", 0)
pct = r.get("price_diff_pct", 0)
diff_c = _RED_WARN if diff < -10 else (_GREEN_KPI if diff > 10 else _DARK_TEXT)
trend = "📈 PChome貴" if diff > 10 else ("📉 momo貴" if diff < -10 else "")
cells = [
(str(ri + 1), _DARK_TEXT),
(r.get("momo_name", "")[:24], _DARK_TEXT),
(f"NT${r.get('momo_price',0):,.0f}", _DARK_TEXT),
(r.get("pc_name", "")[:24], _DARK_TEXT),
(f"NT${r.get('pc_price',0):,.0f}", _DARK_TEXT),
(f"{diff:+,.0f} ({pct:+.1f}%)", diff_c),
(trend, diff_c),
]
row_t = 2.5 + ri * 0.9
for (txt, tc), w in zip(cells, col_w):
_add_rect(s3, x, row_t, w, 0.85, bg)
_add_text(s3, txt, x + 0.05, row_t + 0.05, w - 0.1, 0.75,
size=8, color=tc, align="center")
x += w + 0.1
_add_footer(s3, W)
# P4: AI 洞察
s4 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s4, 0, 0, W, 19.05, _BG_DARK)
_add_header(s4, "AI 競品洞察 — 策略建議")
_add_text(s4, ai_text or "AI 分析生成中)",
1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True)
_add_footer(s4, W)
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