1405 lines
62 KiB
Python
1405 lines
62 KiB
Python
"""
|
||
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)
|
||
|
||
# ── 調色盤 (對齊 EwoooC 新版設計 Token: 暖米基底/暖墨/焦糖橘) ──────────────────────────
|
||
_BG_DARK = "2A2520" # momo-ink (暖墨色)
|
||
_BRAND_OG = "C96442" # momo-accent (焦糖橘)
|
||
_BRAND_OG2 = "8F4530" # momo-warm-mahogany (深焦糖)
|
||
_WHITE = "FAF7F0" # momo-bg-surface (米白卡片底,取代純白)
|
||
_LIGHT_GRAY = "EBE6DC" # momo-bg-body (米色工作台背景)
|
||
_DARK_TEXT = "2A2520" # momo-text-primary (暖墨文字)
|
||
_SUBTEXT = "645C52" # momo-text-secondary (次要文字)
|
||
_FOOTER_BG = "3D362F" # momo-ink-soft (底部深灰)
|
||
_BLUE_KPI = "2D5D80" # momo-info (中性藍)
|
||
_GREEN_KPI = "2A7A3F" # momo-success (去飽和綠)
|
||
_RED_WARN = "B5342F" # momo-danger (暖紅)
|
||
_BAR_PCHOME = "EF5350"
|
||
_BAR_MOMO = "66BB6A"
|
||
_BAR_TIE = "B88416" # momo-warning (蜂蜜金)
|
||
_BAR_MISS = "C4BAA8" # momo-text-disabled
|
||
|
||
_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
|
||
tf.text = ""
|
||
lines = str(text).split('\n')
|
||
for i, line in enumerate(lines):
|
||
if i == 0:
|
||
p = tf.paragraphs[0]
|
||
else:
|
||
p = tf.add_paragraph()
|
||
p.text = line
|
||
if align == "center":
|
||
p.alignment = PP_ALIGN.CENTER
|
||
elif align == "right":
|
||
p.alignment = PP_ALIGN.RIGHT
|
||
|
||
for run in p.runs:
|
||
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))
|
||
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 _add_empty_state(slide, title, detail, W=33.87):
|
||
"""避免產出視覺空白頁;用明確診斷文字說明缺哪一段資料。"""
|
||
_add_rect(slide, 2.2, 5.0, W - 4.4, 3.8, _LIGHT_GRAY, line_hex="DDDDDD")
|
||
_add_text(slide, title, 2.8, 5.65, W - 5.6, 0.75,
|
||
bold=True, size=18, color=_DARK_TEXT, align="center")
|
||
_add_text(slide, detail, 3.2, 6.75, W - 6.4, 1.0,
|
||
size=12, color=_SUBTEXT, align="center")
|
||
|
||
|
||
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)
|
||
|
||
if not products:
|
||
_add_empty_state(
|
||
slide,
|
||
"本頁沒有可顯示的商品資料",
|
||
"請確認該日期或期間是否已有匯入業績資料,或改查最新有資料日期。",
|
||
W,
|
||
)
|
||
_add_footer(slide, W)
|
||
return slide
|
||
|
||
_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
|
||
|
||
|
||
# ── 日報 PPT(4頁)────────────────────────────────────────────────────────────
|
||
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_empty_state(s3, "近 7 日業績資料不足", "缺少 weekly 趨勢資料,已保留 KPI 與商品頁。", W)
|
||
_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
|
||
|
||
|
||
# ── 週報 PPT(5頁)────────────────────────────────────────────────────────────
|
||
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_empty_state(s3, "近 7 日業績資料不足", "缺少 weekly 趨勢資料,已保留 KPI 與 TOP 商品頁。", W)
|
||
_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
|
||
|
||
|
||
# ── 月報 PPT(6頁)────────────────────────────────────────────────────────────
|
||
def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str:
|
||
"""P1封面 P2 執行摘要 P3 品類橫條圖 P4 TOP10商品 P5 MCP市場情報 P6 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 {}
|
||
mcp_text = db_data.get('mcp', '') 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"月度營運報告\n{yr} 年 {mo:02d} 月", f"全面解析與 AI 智能洞察",
|
||
f"業績 NT${rev:,.0f}({rev/10000:.1f}萬)|生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
|
||
|
||
# P2: 執行摘要 (Executive Summary)
|
||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s2, f"執行摘要 (Executive Summary) — {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_rect(s2, 0.5, 6.0, W - 1, 1.0, _BG_DARK)
|
||
_add_text(s2, "高階營運解讀", 0.7, 6.2, W - 1.4, 0.8, bold=True, size=14, color=_WHITE)
|
||
_add_rect(s2, 0.5, 7.0, W - 1, 5.5, _LIGHT_GRAY)
|
||
|
||
# 萃取前一段文字作為摘要
|
||
summary_text = ""
|
||
for line in ai_text.split('\n'):
|
||
if line.strip() and not line.startswith('【'):
|
||
summary_text += line + "\n"
|
||
if len(summary_text) > 150: break
|
||
if not summary_text.strip():
|
||
summary_text = ai_text[:200] + "..." if ai_text else "(暫無 AI 分析)"
|
||
|
||
_add_text(s2, summary_text.strip(), 1.0, 7.3, W - 2.0, 4.5, size=13, color=_DARK_TEXT, wrap=True)
|
||
_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, 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_empty_state(s3, "本月無品類分佈資料", "請確認月報期間是否已有分類欄位與銷售資料。", W)
|
||
_add_footer(s3, W)
|
||
|
||
# P4: TOP10 商品
|
||
_product_table_slide(prs, f"核心動能:熱銷商品 TOP 10 — {yr}/{mo:02d}", top_prod)
|
||
|
||
# P5: MCP 市場情報
|
||
s5 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s5, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s5, "🌐 專案 RAG:MCP 外部市場情報與競品監控")
|
||
|
||
mcp_display = mcp_text if mcp_text else "(未擷取到最新的 MCP 市場或競品情報,或者當前無需特別關注之外部風險)"
|
||
_add_rect(s5, 1.0, 2.0, W - 2.0, 11.0, _BG_DARK)
|
||
_add_text(s5, mcp_display, 1.5, 2.5, W - 3.0, 10.0, size=12, color=_WHITE, wrap=True)
|
||
_add_footer(s5, W)
|
||
|
||
# P6: AI 策略洞察與行動建議
|
||
s6 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s6, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s6, f"🎯 專案分析與行動建議 (AI Agent Insights)")
|
||
|
||
_add_rect(s6, 1.0, 2.0, W - 2.0, 11.0, _LIGHT_GRAY, line_hex="CCCCCC")
|
||
_add_text(s6, ai_text or "(暫無 AI 分析)",
|
||
1.5, 2.5, W - 3.0, 10.0, size=13, color=_DARK_TEXT, wrap=True)
|
||
_add_footer(s6, W)
|
||
|
||
path = _new_path("monthly")
|
||
prs.save(path)
|
||
return path
|
||
|
||
|
||
# ── 策略報告 PPT(5頁)────────────────────────────────────────────────────────
|
||
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_empty_state(s3, "無策略分析資料", "策略矩陣需要商品銷售、毛利與期間對比資料。", W)
|
||
_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))
|
||
)
|
||
)
|
||
if not sorted_strat:
|
||
_add_empty_state(s4, "沒有可列入行動清單的商品", "請確認策略分析期間是否有商品銷售資料。", W)
|
||
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
|
||
|
||
|
||
# ── 促銷報告 PPT(5頁)────────────────────────────────────────────────────────
|
||
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
|
||
|
||
|
||
# ── 競品比較 PPT(4頁,維持原有架構)────────────────────────────────────────────
|
||
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]
|
||
if not rows:
|
||
_add_empty_state(
|
||
s3,
|
||
"沒有可顯示的競品比較明細",
|
||
"PChome 比對尚未找到有效對應商品,或本期掃描商品不足。",
|
||
W,
|
||
)
|
||
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
|
||
|
||
|
||
# ── 成長趨勢報告 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
|