Files
ewoooc/services/ppt_generator.py
OoO 8b76e3872f
All checks were successful
CD Pipeline / deploy (push) Successful in 2m49s
feat(ppt): competitor v4 — 5-forces strategic analysis (Wave 4 partial)
Wave 4 部分完成:competitor v4 五力升級(戰略視角)

generate_competitor_v4_ppt — 7 頁
- P1 封面:含整體領先/勢均力敵/落後徽章 + 三句話戰略指引
- P2 五力雷達圖(matplotlib polar)+ 右側 6 維度分數明細表(含雙條視覺化)
- P3 商品力 + 價格力(雙卡)
- P4 行銷力 + 服務力(雙卡)
- P5 品牌力 + 財務力(雙卡含 momo 8454/PChome 8044/蝦皮 SE/酷澎基本面)
- P6 AI 戰略整合(差異化建議)
- P7 附錄

新增 helper:_mpl_radar_png()
- matplotlib polar projection 五力雷達
- momo 焦糖橘 + 競品蜂蜜金
- 0-10 分尺度,含格線/標籤/圖例

query_competitor_5forces — 半實作
- 商品力:momo SKU 數從 DB 算 / 競品靜態 fallback
- 價格力:靜態(待擴 competitor_price_history 整合)
- 行銷力 / 服務力:靜態知識(電視購物頻道 / 24h 物流 / 訂閱制)
- 品牌力:靜態 + 預留 mcp_collector 整合空間
- 財務力:上市公司公開資訊(momo 8454 富邦集團、PChome 8044、SEA、酷澎)

每個維度自動算 momo - 競品差異 → 識別最大優勢力與最大劣勢力

_ppt_ai_analysis 加 is_5forces 分支
- 角色:BCG/麥肯錫資深戰略顧問
- 結構:整體競爭態勢 / 優勢加碼 / 劣勢補強或避戰 / SMART 三層 / 競爭風險
- max_tokens 2400

路由:
- /ppt competitor_v4         vs PChome(預設)
- /ppt competitor_v4 蝦皮    vs 蝦皮
- /ppt competitor_v4 酷澎    vs 酷澎

Telegram 按鈕:「⚔️ 競業五力 v4」

bump TEMPLATE_VERSIONS['competitor_v4'] = v4.0.0(新類型獨立版本)

簡化限制:商品力/價格力/品牌力的競品具體數據需後續擴 mcp_collector
(PChome SKU API、Dcard 品牌討論度量化等),靜態 fallback 已維持結構完整性

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:10:15 +08:00

5500 lines
252 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)
# ── 模板版本(快取失效控制)─────────────────────────────────────────────────
# 任何一份簡報的「視覺/結構」設計變動,都必須 bump 該 report_type 的版本號。
# 路由層會把版本號併入快取 key舊快取自然 miss → 重新生成。
# Bump 規則major 設計改版 +0.1;微調文案不需 bump。
TEMPLATE_VERSIONS = {
'daily': 'v3.1.0', # 2026-05-03 AI prompt 升級3 段精煉顧問報告 + 共用 2026 市場知識基底
'weekly': 'v3.1.0', # 2026-05-03 同上
'monthly': 'v3.1.4', # 2026-05-03 AI prompt 補上共用 2026 市場知識基底(強化檔期/賽道/平台對位)
'strategy': 'v3.1.0', # 2026-05-03 共用市場知識基底
'competitor': 'v3.1.0', # 2026-05-03 共用市場知識基底
'promo': 'v3.1.0', # 2026-05-03 共用市場知識基底
# ── DEPRECATED以下 type 從未實際落地(依 ADR-014 校正 2026-04-28
# 函式 generate_growth_ppt / generate_vendor_ppt / generate_bcg_ppt 仍存在於本檔,
# 但路由層未綁定指令;保留版本字串避免如未來重啟時快取 schema 對不上。
'growth': 'v2.0', # DEPRECATED — 從未落地,已由 quarterly/half_yearly/annual/ttm 取代ADR-023
'bcg': 'v2.0', # DEPRECATED — 與 strategy 重疊strategy 已含 BCG 加碼/機會/收割/觀察五級分類)
'vendor': 'v3.1.0', # 2026-05-03 喚醒 + v3 暖紙風 + matplotlib 雙視圖 + 採購策略 SMART prompt + 集中度警示
'quarterly': 'v3.1.0', # 2026-05-03 季報period_review 共用 generator
'half_yearly': 'v3.1.0', # 2026-05-03 半年報
'annual': 'v3.1.0', # 2026-05-03 年報
'ttm': 'v3.1.0', # 2026-05-03 TTM 滾動 12 月
'category': 'v3.1.0', # 2026-05-03 品類深度報告90 天縱向 + 子品類 + 新進榜)
'customer': 'v3.1.0', # 2026-05-03 客戶/訂單分析(簡化 RFM受資料層 user_id 限制)
'forecast_pre_event': 'v3.1.0', # 2026-05-03 檔期前瞻報baseline × lift_factor 預測 + 去年同檔期)
'promo_compare': 'v3.1.0', # 2026-05-03 多活動 ROI 並排比較
'new_product': 'v3.1.0', # 2026-05-03 新品 30 天追蹤PM/採購)
'market_intel': 'v3.1.0', # 2026-05-03 市場情報週報(外部資料彙整)
'price_elasticity': 'v3.1.0', # 2026-05-03 價格彈性簡化版(價位甜蜜點分析)
'competitor_v4': 'v4.0.0', # 2026-05-03 競業五力升級(雷達圖 + 6 維度 + 戰略指引)
}
def get_template_version(report_type: str) -> str:
"""取得指定 report_type 的模板版本字串。
路由層在組 cache key 時呼叫此函式,模板升級時 bump TEMPLATE_VERSIONS 即可
使舊快取全部 miss。
"""
return TEMPLATE_VERSIONS.get(report_type, 'v1.0')
# ── 調色盤 (完整對齊 EwoooC MOMO Pro design-tokens.css) ─────────────────────────────
_BG_DARK = "2A2520" # momo-ink 暖墨封面底
_BG_PAPER = "F3EEE2" # momo-bg-paper 暖紙感底
_BRAND_OG = "C96442" # momo-accent 焦糖橘(主 accent頁首
_BRAND_OG2 = "8F4530" # momo-warm-mahogany 深焦糖
_WHITE = "FAF7F0" # momo-bg-surface 米白卡片底
_LIGHT_GRAY = "EBE6DC" # momo-bg-body 米色工作台底
_SUBTLE = "E2DCD0" # momo-bg-subtle 微深米
_DARK_TEXT = "2A2520" # momo-text-primary 暖墨
_SUBTEXT = "645C52" # momo-text-secondary
_TERTIARY = "9B9081" # momo-text-tertiary
_FOOTER_BG = "3D362F" # momo-ink-soft
# KPI 暖色家族(全部留暖色域,不混冷色)
_KPI_CARAMEL = "C96442" # 焦糖橘—月業績
_KPI_HONEY = "B88416" # 蜂蜜金—訂單
_KPI_MAHOGANY = "8F4530" # 深焦糖—毛利率
_KPI_EARTH = "8A5A2B" # 焦土—客單價
_KPI_RUST = "B5342F" # 暖紅—警示
# 保留冷色只用於競品比較
_BLUE_KPI = "2D5D80"
_GREEN_KPI = "2A7A3F"
_RED_WARN = "B5342F"
_BAR_PCHOME = "EF5350"
_BAR_MOMO = "66BB6A"
_BAR_TIE = "B88416"
_BAR_MISS = "C4BAA8"
_STRAT_COLORS = {
'加碼': _BRAND_OG,
'機會': _KPI_HONEY,
'收割': _KPI_EARTH,
'觀察': _FOOTER_BG,
'持穩': _TERTIARY,
'其他': _SUBTEXT,
}
_STRAT_ORDER = ['加碼', '機會', '收割', '觀察', '持穩']
# ── 字體規範 (對齊 MOMO Pro design-tokens.css) ─────────────────────────────────
# momo-font-display: JetBrains Mono / Space Mono (點陣等寬機械感—數字/標題)
# momo-font-family: Inter + PingFang TC / Microsoft JhengHei (中英混排內文)
# 在 PPT 內統一以 Latin / EastAsian 分軌指定字型,避免中文字撞上 Courier New 醜化
_FONT_MONO = "Consolas" # Latin 點陣等寬Consolas 比 Courier New 點陣感更佳Win/Mac 皆有
_FONT_DISPLAY = "Consolas" # 大數字KPI 值、排名)走 Consolas
_FONT_BODY = "Microsoft JhengHei" # 中文黑體(內文、表格)— 中英混排預設
_FONT_BODY_EA = "Microsoft JhengHei" # CJK 字型(與 _FONT_BODY 同名,但供 latin/ea 分軌使用)
_FONT_LABEL = "Arial" # 純英文標籤OPENCLAW badge
_SLIDE_H = 19.05 # 投影片體高 cm
_FOOTER_Y = 18.38 # footer 起始位置(底部對齊)
_FOOTER_H = 0.67 # footer 高度
_CONTENT_Y = 1.85 # 內容源頂(頁首後)
_CONTENT_B = 18.2 # 內容底面footer 前 0.18cm
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
# OOXML CT_TextCharacterProperties 子元素順序(依 ECMA-376 第一部分 §21.1.2.3
# critic Medium-1SubElement 永遠 append 會違反 schema必須按此順序 insert
# 否則 LibreOffice / Keynote / 嚴格驗證器會拒絕讀取或丟棄 latin/ea 元素。
_RPR_CHILD_ORDER = [
'ln', 'noFill', 'solidFill', 'gradFill', 'blipFill', 'pattFill',
'effectLst', 'effectDag', 'highlight', 'uLnTx', 'uLn',
'uFillTx', 'uFill', 'latin', 'ea', 'cs', 'sym',
'hlinkClick', 'hlinkMouseOver', 'rtl', 'extLst',
]
_OOXML_DRAWING_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'
def _insert_rpr_child(rPr, tag: str):
"""在 rPr 下依 ECMA-376 schema 順序插入 <a:tag> 元素。
若 rPr 內已有此 tag先全部移除避免重複。
回傳新建的元素。
"""
from lxml import etree
nsmap = {'a': _OOXML_DRAWING_NS}
# 先清掉同名舊元素
for el in rPr.findall(f'a:{tag}', nsmap):
rPr.remove(el)
target_idx = _RPR_CHILD_ORDER.index(tag) if tag in _RPR_CHILD_ORDER else len(_RPR_CHILD_ORDER)
# 找第一個排在 target_idx 之後的子元素 → insert 在它前面
for i, child in enumerate(rPr):
local = etree.QName(child).localname
if local in _RPR_CHILD_ORDER and _RPR_CHILD_ORDER.index(local) > target_idx:
new_el = etree.Element(f'{{{_OOXML_DRAWING_NS}}}{tag}')
rPr.insert(i, new_el)
return new_el
# 沒有後序元素 → append
new_el = etree.SubElement(rPr, f'{{{_OOXML_DRAWING_NS}}}{tag}')
return new_el
def _set_run_fonts(run, latin_font: str = None, ea_font: str = None):
"""為單一 run 同時設定 Latin 字型與 EastAsian (CJK) 字型。
解法:直接寫入 a:rPr 下的 a:latin / a:ea 元素,避開 python-pptx
只能設一個 font.name 的限制 — 這是「中英分軌」呈現的關鍵。
critic Medium-1使用 _insert_rpr_child 維持 OOXML schema 規定的子元素順序,
讓未來若改成讀模板 .pptxrPr 內已有 hlinkClick/cs 等後序元素)時也能正確插入。
"""
if not latin_font and not ea_font:
return
try:
rPr = run._r.get_or_add_rPr()
if latin_font:
latin_el = _insert_rpr_child(rPr, 'latin')
latin_el.set('typeface', latin_font)
if ea_font:
ea_el = _insert_rpr_child(rPr, 'ea')
ea_el.set('typeface', ea_font)
except Exception:
# 失敗時退回 font.name 單軌設定
if latin_font:
run.font.name = latin_font
def _add_text(slide, text, l, t, w, h,
bold=False, size=14, color=_WHITE,
align="left", valign="top", wrap=True, font_name=None,
latin_font=None, ea_font=None):
"""加入文字方塊。
font_name單軌字型向後相容
latin_font / ea_font中英分軌字型 — 同一個 run 內,數字/英文走 latin_font
CJK 走 ea_font避免中文混到 Consolas/Courier 變成醜陋的 fallback 字。
"""
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)
if font_name:
run.font.name = font_name
if latin_font or ea_font:
_set_run_fonts(run, latin_font=latin_font, ea_font=ea_font)
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, _FOOTER_Y, prs_w_cm, _FOOTER_H, _FOOTER_BG)
_add_rect(slide, 0, _FOOTER_Y, 0.4, _FOOTER_H, _BRAND_OG) # 左焦糖橘細條
_add_text(slide, "♥ Powered by OpenClaw",
prs_w_cm - 7.5, _FOOTER_Y + 0.05, 7.2, _FOOTER_H - 0.08,
size=8, color=_BRAND_OG, align="right",
font_name=_FONT_LABEL)
def _add_header(slide, title_text, prs_w_cm=33.87):
"""焦糖橘頁首帶 1.7cm,中英分軌標題"""
_add_rect(slide, 0, 0, prs_w_cm, 1.7, _BRAND_OG)
_add_rect(slide, 0, 0, 0.5, 1.7, _BRAND_OG2)
_add_text(slide, title_text, 0.8, 0.1, prs_w_cm - 1.2, 1.5,
bold=True, size=18, color=_WHITE, valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
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=_SUBTLE)
_add_rect(slide, 2.2, 5.0, 0.4, 3.8, _BRAND_OG)
_add_text(slide, title, 3.0, 5.65, W - 5.6, 0.75,
bold=True, size=18, color=_DARK_TEXT, align="center",
font_name=_FONT_BODY)
_add_text(slide, detail, 3.2, 6.75, W - 6.4, 1.0,
size=12, color=_SUBTEXT, align="center",
font_name=_FONT_BODY)
def _kpi_card(slide, l, t, w, h, fill, label, value, sub=""):
"""暖色 KPI 卡label 中英分軌小字value 點陣等寬大字置中(中英分軌)"""
_add_rect(slide, l, t, w, h, fill)
_add_rect(slide, l, t, 0.12, h, "FFFFFF")
_add_text(slide, label,
l + 0.35, t + 0.25, w - 0.55, 0.65,
bold=False, size=11, color="FAF7F0",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, value,
l + 0.2, t + 0.85, w - 0.4, h - 1.55,
bold=True, size=30, color="FFFFFF",
align="center", valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
if sub:
_add_text(slide, sub,
l + 0.2, t + h - 0.7, w - 0.4, 0.6,
size=9, color="FAF7F0", align="center",
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
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
# ── matplotlib 專業圖表(暖色系設計)─────────────────────────────────────────
def _mpl_horiz_bar_png(categories, values, total_width_cm=18.5, total_height_cm=11.0,
value_unit="", title="", highlight_top_n=3) -> "io.BytesIO":
"""以 matplotlib 產生暖色系橫條排行圖,回傳 PNG BytesIO。
- 漸層暖色(焦糖橘 → 暖黃 → 米色TOP3 用最深焦糖橘
- 條右側標註 NT$X.X萬 + 佔比 %
- 軸線弱化、無上邊框、底色透明 — 適合貼進米色 PPT 頁
critic HIGH-1渲染失敗時保證 plt.close() — 避免 figure 洩漏吃光 worker 記憶體。
"""
import io
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
except ImportError:
return None
if not categories or not values:
return None
# 共用 _mpl_setup 的 fallback 邏輯(含容器 Noto CJK JP 變體)
_mpl_setup()
fig = None
try:
# 數值轉萬元
vals_wan = [float(v) / 10000.0 for v in values]
total = sum(vals_wan) or 1.0
pcts = [v / total * 100 for v in vals_wan]
# 由低到高反轉,讓最高排在最上
cats_rev = list(reversed(categories))
vals_rev = list(reversed(vals_wan))
pcts_rev = list(reversed(pcts))
# 暖色階TOP3 焦糖橘、4-6 蜂蜜金、7+ 焦土
colors = []
n = len(cats_rev)
for i, _ in enumerate(cats_rev):
rank_from_top = n - i
if rank_from_top <= highlight_top_n:
colors.append("#C96442")
elif rank_from_top <= 6:
colors.append("#B88416")
else:
colors.append("#8A5A2B")
fig_w = total_width_cm / 2.54
fig_h = total_height_cm / 2.54
fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=150)
fig.patch.set_facecolor("#F3EEE2")
ax.set_facecolor("#F3EEE2")
bars = ax.barh(cats_rev, vals_rev, color=colors, edgecolor="none", height=0.62)
max_val = max(vals_rev) if vals_rev else 1
for i, (bar, v, p) in enumerate(zip(bars, vals_rev, pcts_rev)):
bw = bar.get_width()
ax.text(bw + max_val * 0.012,
bar.get_y() + bar.get_height() / 2,
f"NT${v:,.1f}{value_unit} · {p:.0f}%",
va="center", ha="left",
fontsize=10, color="#2A2520",
fontweight="bold")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#9B9081")
ax.spines["bottom"].set_color("#9B9081")
ax.tick_params(colors="#645C52", labelsize=11)
ax.xaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6)
ax.yaxis.grid(False)
ax.set_axisbelow(True)
ax.set_xlim(0, max_val * 1.32)
ax.set_xlabel("業績(萬元)", fontsize=10, color="#645C52", labelpad=8)
if title:
ax.set_title(title, fontsize=13, color="#2A2520", loc="left",
pad=12, fontweight="bold")
plt.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150,
facecolor=fig.get_facecolor(), bbox_inches="tight")
buf.seek(0)
return buf
except Exception:
return None
finally:
if fig is not None:
try:
plt.close(fig)
except Exception:
pass
def _add_image_from_buf(slide, buf, l, t, w, h):
"""把 BytesIO 圖片貼進投影片"""
if buf is None:
return None
try:
return slide.shapes.add_picture(buf, _emu(l), _emu(t), _emu(w), _emu(h))
except Exception:
return None
def _mpl_setup():
"""共用:設定 CJK 字型並回傳 (matplotlib, plt)。
fallback 策略先試精確名稱macOS/Windows 環境),再試容器常見的 Noto CJK
JP 變體fonts-noto-cjk 套件 ttc 檔matplotlib ttflist 只載入 JP 命名,但
字型本身含完整 CJK Unified Ideographs可正常顯示中文
最終都找不到時用 substring match覆蓋未列入但名稱含 CJK/Hei 的字型)。
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
except ImportError:
return None, None
cjk_candidates = [
# macOS
"PingFang TC", "PingFang SC", "Heiti TC", "Hiragino Sans GB",
# Windows
"Microsoft JhengHei", "Microsoft YaHei",
# Linux/Dockernoto-cjk 套件 ttc 檔matplotlib ttflist 只認 JP 變體
# 但實際渲染中文 OK因為 ttc 內共用同一漢字字型表)
"Noto Sans CJK JP", "Noto Serif CJK JP",
"Noto Sans CJK TC", "Noto Sans CJK SC", "Noto Sans CJK",
"Noto Sans TC", "Noto Sans SC",
"Source Han Sans TC", "Source Han Sans SC", "Source Han Sans",
"WenQuanYi Zen Hei", "Arial Unicode MS",
]
available = {f.name for f in fm.fontManager.ttflist}
chosen_cjk = next((f for f in cjk_candidates if f in available), None)
if not chosen_cjk:
# 最後手段substring match
for name in available:
if any(k in name for k in ("CJK", "PingFang", "JhengHei", "YaHei",
"Source Han", "WenQuanYi", "Hiragino")):
chosen_cjk = name
break
if chosen_cjk:
plt.rcParams["font.family"] = [chosen_cjk, "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
return matplotlib, plt
def _mpl_line_chart_png(curr_dates, curr_vals, prev_vals=None,
total_width_cm=30.0, total_height_cm=11.0,
title="", curr_label="本月", prev_label="上月對比") -> "io.BytesIO":
"""日業績折線:本月實線(焦糖橘)+ 上月虛線(蜂蜜金)+ 平均水平線。
高/低點自動標註圖例置上N>=3 時 ncol=3 上方N<3 時 inset best
"""
import io
matplotlib, plt = _mpl_setup()
if plt is None or not curr_dates or not curr_vals:
return None
fig = None
try:
n_pts = len(curr_vals)
sparse = n_pts <= 2
curr_wan = [float(v) / 10000.0 for v in curr_vals]
fig_w = total_width_cm / 2.54
fig_h = total_height_cm / 2.54
fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=150)
fig.patch.set_facecolor("#F3EEE2")
ax.set_facecolor("#F3EEE2")
x = list(range(len(curr_dates)))
line_curr, = ax.plot(x, curr_wan, color="#C96442", linewidth=2.4,
marker="o", markersize=7 if sparse else 5,
markerfacecolor="#C96442", markeredgecolor="#FAF7F0",
label=curr_label, zorder=3)
ax.fill_between(x, curr_wan, alpha=0.12, color="#C96442", zorder=1)
if prev_vals and len(prev_vals) >= 1:
prev_wan = [float(v) / 10000.0 for v in prev_vals[:len(curr_vals)]]
while len(prev_wan) < len(curr_wan):
prev_wan.append(None)
ax.plot(x, prev_wan, color="#B88416", linewidth=1.6,
linestyle="--", marker=None,
label=prev_label, zorder=2, alpha=0.85)
avg_v = sum(curr_wan) / len(curr_wan) if curr_wan else 0
# 平均線僅在 N>=3 時有意義N<=2 略過避免標籤撞 title
if not sparse:
ax.axhline(avg_v, color="#8F4530", linewidth=1.0, linestyle=":",
alpha=0.7, label=f"本月日均 {avg_v:.1f}")
if curr_wan:
max_i = curr_wan.index(max(curr_wan))
min_i = curr_wan.index(min(curr_wan))
ax.annotate(f"{curr_wan[max_i]:.1f}",
xy=(max_i, curr_wan[max_i]),
xytext=(0, 12), textcoords="offset points",
ha="center", fontsize=9, color="#8F4530", fontweight="bold")
if min_i != max_i:
ax.annotate(f"{curr_wan[min_i]:.1f}",
xy=(min_i, curr_wan[min_i]),
xytext=(0, -16), textcoords="offset points",
ha="center", fontsize=9, color="#B5342F", fontweight="bold")
ax.set_xticks(x)
# 資料點多時旋轉 label 避免擠成一團;少時水平
x_rotation = 45 if len(x) > 14 else 0
ax.set_xticklabels([str(d)[-5:] for d in curr_dates],
rotation=x_rotation,
ha=("right" if x_rotation else "center"),
fontsize=9)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#9B9081")
ax.spines["bottom"].set_color("#9B9081")
ax.tick_params(colors="#645C52", labelsize=10)
ax.yaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6)
ax.xaxis.grid(False)
ax.set_axisbelow(True)
ax.set_ylabel("日業績(萬元)", fontsize=10, color="#645C52", labelpad=8)
# 資料稀疏時x 軸留邊(避免 marker 貼牆)+ 加「資料不足」提示
if sparse:
ax.set_xlim(-0.5, max(0.5, len(x) - 0.5))
ax.text(0.5, 0.5,
f"⚠ 期間僅 {n_pts} 個資料點\n建議擴大查詢範圍以建立趨勢線",
transform=ax.transAxes,
ha="center", va="center",
fontsize=12, color="#8F4530", alpha=0.55,
fontweight="bold",
bbox=dict(boxstyle="round,pad=0.6",
facecolor="#FAF7F0",
edgecolor="#C96442",
linewidth=1.0, alpha=0.85))
if title:
# title pad 加大為 18原 12與 legend 留更多空間
ax.set_title(title, fontsize=13, color="#2A2520", loc="left",
pad=18, fontweight="bold")
# legend 一律 inset axes 內(避免與 title 撞)
if sparse:
ax.legend(loc="lower right", frameon=False, fontsize=9)
else:
ax.legend(loc="upper right", frameon=True, fontsize=9,
ncol=1, framealpha=0.85,
facecolor="#FAF7F0", edgecolor="#C4BAA8")
plt.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150,
facecolor=fig.get_facecolor(), bbox_inches="tight")
buf.seek(0)
return buf
except Exception:
return None
finally:
if fig is not None:
try:
plt.close(fig)
except Exception:
pass
def _mpl_pareto_chart_png(categories, values, total_width_cm=18.5,
total_height_cm=11.0, title="") -> "io.BytesIO":
"""品類帕雷托:暖色橫條 + 累計貢獻折線80/20 標線)。
critic HIGH-1渲染失敗時保證 plt.close()。
"""
import io
matplotlib, plt = _mpl_setup()
if plt is None or not categories or not values:
return None
fig = None
try:
vals_wan = [float(v) / 10000.0 for v in values]
total = sum(vals_wan) or 1.0
pairs = sorted(zip(categories, vals_wan), key=lambda p: -p[1])
cats = [p[0] for p in pairs]
vals = [p[1] for p in pairs]
cum_pct = []
running = 0
for v in vals:
running += v
cum_pct.append(running / total * 100)
fig_w = total_width_cm / 2.54
fig_h = total_height_cm / 2.54
fig, ax1 = plt.subplots(figsize=(fig_w, fig_h), dpi=150)
fig.patch.set_facecolor("#F3EEE2")
ax1.set_facecolor("#F3EEE2")
n = len(cats)
bar_colors = []
for i in range(n):
if cum_pct[i] <= 80:
bar_colors.append("#C96442")
else:
bar_colors.append("#C4BAA8")
bars = ax1.bar(range(n), vals, color=bar_colors, width=0.6, edgecolor="none")
max_val = max(vals) if vals else 1
for i, (bar, v) in enumerate(zip(bars, vals)):
ax1.text(bar.get_x() + bar.get_width() / 2,
v + max_val * 0.02,
f"{v:.1f}",
ha="center", va="bottom",
fontsize=9, color="#2A2520", fontweight="bold")
ax1.set_xticks(range(n))
ax1.set_xticklabels([c[:8] for c in cats], rotation=20, ha="right", fontsize=9)
ax1.spines["top"].set_visible(False)
ax1.spines["right"].set_visible(False)
ax1.spines["left"].set_color("#9B9081")
ax1.spines["bottom"].set_color("#9B9081")
ax1.tick_params(colors="#645C52", labelsize=9)
ax1.set_ylabel("業績(萬元)", fontsize=10, color="#645C52", labelpad=8)
ax1.yaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6)
ax1.set_axisbelow(True)
ax1.set_ylim(0, max_val * 1.18)
ax2 = ax1.twinx()
ax2.plot(range(n), cum_pct, color="#8F4530", linewidth=2.0,
marker="o", markersize=5,
markerfacecolor="#8F4530", markeredgecolor="#FAF7F0", zorder=4)
ax2.set_ylim(0, 110)
ax2.set_ylabel("累計貢獻(%", fontsize=10, color="#8F4530", labelpad=8)
ax2.tick_params(colors="#8F4530", labelsize=9)
ax2.spines["top"].set_visible(False)
ax2.spines["right"].set_color("#8F4530")
ax2.spines["left"].set_visible(False)
ax2.axhline(80, color="#B5342F", linewidth=1.0, linestyle=":", alpha=0.7)
ax2.text(n - 0.5, 82, "80% 主力線", color="#B5342F", fontsize=9,
ha="right", va="bottom", fontweight="bold")
if title:
ax1.set_title(title, fontsize=13, color="#2A2520", loc="left",
pad=12, fontweight="bold")
plt.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150,
facecolor=fig.get_facecolor(), bbox_inches="tight")
buf.seek(0)
return buf
except Exception:
return None
finally:
if fig is not None:
try:
plt.close(fig)
except Exception:
pass
# ── 升級版 KPI 卡:含 △% 與紅綠燈 ─────────────────────────────────────────────
def _kpi_card_v2(slide, l, t, w, h, fill, label, value, delta_pct=None,
delta_label="vs 上月", inverse=False, sub=""):
"""KPI 卡 v2value 大字 + 右下角 △% 徽章(綠↑紅↓)+ delta_label
inverse=True數字越低越好如成本、退貨率漲跌邏輯反轉
"""
_add_rect(slide, l, t, w, h, fill)
_add_rect(slide, l, t, 0.12, h, "FFFFFF")
# Label
_add_text(slide, label,
l + 0.35, t + 0.22, w - 0.55, 0.5,
size=11, color="FAF7F0",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
# Value 大字(向上挪一點,留空間給 delta
_add_text(slide, value,
l + 0.2, t + 0.7, w - 0.4, h - 1.85,
bold=True, size=28, color="FFFFFF",
align="center", valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# Delta 徽章(底部 0.85cm
if delta_pct is not None:
try:
d = float(delta_pct)
except Exception:
d = 0
is_up = d > 0
good = (is_up and not inverse) or ((not is_up) and inverse)
badge_color = "#2A7A3F" if good else ("#B5342F" if d != 0 else "#645C52")
arrow = "" if is_up else ("" if d < 0 else "")
sign = "+" if is_up else ""
# 底部白底徽章帶
_add_rect(slide, l + 0.2, t + h - 0.9, w - 0.4, 0.7, "FFFFFF")
_add_text(slide, f"{arrow} {sign}{d:.1f}% {delta_label}",
l + 0.2, t + h - 0.85, w - 0.4, 0.6,
bold=True, size=11, color=badge_color.lstrip("#"),
align="center", valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
elif sub:
_add_text(slide, sub,
l + 0.2, t + h - 0.7, w - 0.4, 0.55,
size=10, color="FAF7F0", align="center",
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
# ── Elevator Pitch 計算(封面 / 摘要用)──────────────────────────────────────
def _compute_elevator_pitch(curr: dict, prev_mo: dict = None) -> dict:
"""從本月與上月資料萃取「業績狀態 / 最大亮點 / 最大警訊」
回傳 dict: {status, status_color, highlight, warning, mom_rev, mom_ord, mom_gm}
狀態判定MoM 業績 > +10% → 強勁;> 0 → 穩健;> -10% → 持平;其餘 → 警訊
"""
out = {
'status': '', 'status_color': _SUBTEXT,
'highlight': '', 'warning': '',
'mom_rev': None, 'mom_ord': None, 'mom_gm': None,
}
if not curr:
return out
rev = float(curr.get('revenue', 0) or 0)
ord_ = int(curr.get('orders', 0) or 0)
gm = float(curr.get('gross_margin', 0) or 0)
mom_rev, mom_ord, mom_gm = None, None, None
if prev_mo:
prev_rev = float(prev_mo.get('revenue', 0) or 0)
prev_ord = int(prev_mo.get('orders', 0) or 0)
prev_gm = float(prev_mo.get('gross_margin', 0) or 0)
if prev_rev:
mom_rev = (rev - prev_rev) / prev_rev * 100
if prev_ord:
mom_ord = (ord_ - prev_ord) / prev_ord * 100
mom_gm = gm - prev_gm # 毛利率走絕對 pp 變化(非相對 %
out['mom_rev'] = mom_rev
out['mom_ord'] = mom_ord
out['mom_gm'] = mom_gm
if mom_rev is None:
out['status'] = '基準月'
out['status_color'] = _SUBTEXT
elif mom_rev > 10:
out['status'] = '強勁成長'
out['status_color'] = "2A7A3F"
elif mom_rev > 0:
out['status'] = '穩健'
out['status_color'] = "B88416"
elif mom_rev > -10:
out['status'] = '持平偏弱'
out['status_color'] = "8A5A2B"
else:
out['status'] = '需警示'
out['status_color'] = "B5342F"
# 最大亮點(暖色標)
cats = curr.get('top_categories') or []
if cats:
top_cat = cats[0]
top_pct = float(top_cat.get('revenue', 0)) / rev * 100 if rev else 0
out['highlight'] = (f"{top_cat.get('cat','')}撐起 {top_pct:.0f}% 業績"
f"NT${float(top_cat.get('revenue',0))/10000:.1f}萬)")
# 警訊(毛利率 / 集中度 / 衰退)
warnings = []
if gm and gm < 10:
warnings.append(f"毛利率僅 {gm:.1f}%,低於健康水位 10%")
if cats and rev:
top_pct = float(cats[0].get('revenue', 0)) / rev * 100
if top_pct > 60:
warnings.append(f"前一品類佔比 {top_pct:.0f}%,集中度過高")
if mom_rev is not None and mom_rev < -10:
warnings.append(f"業績較上月衰退 {abs(mom_rev):.1f}%")
out['warning'] = warnings[0] if warnings else "無顯著風險"
return out
# ── 附錄頁(資料來源 / 計算口徑 / 版本)─────────────────────────────────────
def _appendix_slide(prs, report_type: str, period_str: str,
data_sources: list = None, definitions: list = None,
W: float = 33.87):
"""專業附錄頁:建立報告可信度。
內容:資料來源、計算口徑、報告版本、生成時間
"""
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, "📎 附錄:資料來源與計算口徑")
# 兩欄:左 = 資料來源、右 = 計算口徑
col_w = (W - 1.2) / 2
# 左欄:資料來源
_add_rect(s, 0.4, 1.95, col_w, 0.85, _BRAND_OG)
_add_text(s, "📂 資料來源",
0.7, 2.05, col_w - 0.5, 0.65,
bold=True, size=13, color=_WHITE, valign="middle",
ea_font=_FONT_BODY_EA)
sources = data_sources or [
"• realtime_sales_monthlyMOMO 後台訂單明細",
"• 商品分類 L1MOMO 主分類欄位",
"• 廠商名稱MOMO 賣家主檔",
"• MCP 外部情報Gemini Grounding + 靜態節日日曆",
]
src_text = "\n\n".join(sources)
_add_rect(s, 0.4, 2.85, col_w, 11.5, _WHITE, line_hex=_SUBTLE)
_add_text(s, src_text,
0.7, 3.05, col_w - 0.6, 11.1,
size=11, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 右欄:計算口徑
rx = 0.4 + col_w + 0.4
_add_rect(s, rx, 1.95, col_w, 0.85, _KPI_MAHOGANY)
_add_text(s, "📐 計算口徑",
rx + 0.3, 2.05, col_w - 0.5, 0.65,
bold=True, size=13, color=_WHITE, valign="middle",
ea_font=_FONT_BODY_EA)
defs = definitions or [
"• 業績 = SUM(總業績),含運費,未扣退貨",
"• 訂單 = COUNT DISTINCT(訂單編號)",
"• 毛利率 = (業績 總成本) / 業績 × 100%",
"• 客單價 = 業績 / 訂單數",
"• MoM = (本月 上月) / 上月 × 100%",
"• 品類佔比 = 該品類業績 / 總業績 × 100%",
]
def_text = "\n\n".join(defs)
_add_rect(s, rx, 2.85, col_w, 11.5, _WHITE, line_hex=_SUBTLE)
_add_text(s, def_text,
rx + 0.3, 3.05, col_w - 0.6, 11.1,
size=11, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 底部資訊條
_add_rect(s, 0.4, 14.7, W - 0.8, 1.0, _BRAND_OG2)
tpl_ver = TEMPLATE_VERSIONS.get(report_type, 'v1.0')
info = (f"報告類型:{report_type} 期間:{period_str} "
f" 模板版本:{tpl_ver} 生成時間:{datetime.now().strftime('%Y/%m/%d %H:%M')} "
f" Powered by OpenClaw AI Agent")
_add_text(s, info,
0.7, 14.85, W - 1.4, 0.7,
size=10, color=_WHITE, valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s, W)
return s
# ── 封面頁 ────────────────────────────────────────────────────────────────────
def _cover_slide(prs, big_title: str, sub1: str, sub2: str = ""):
"""暖紙感封面 v3米紙底 + 焦糖橘左寬條 + 暖色裝飾帶 + 大字暖墨標題
設計目標:去除大面積黑/暖墨底,改以暖紙感為主視覺;標題用暖墨色寫在米底上。
"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
W = 33.87
H = 19.05
# 主底:暖紙感米色(非黑、非純白)
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
# 左側焦糖橘粗條3.0cm,比原來再粗一點,視覺重量平衡米底)
_add_rect(slide, 0, 0, 3.0, H, _BRAND_OG)
# 左條右緣加一條深焦糖細邊
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
# 右上角裝飾:深焦糖斜帶(不蓋滿)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, _BRAND_OG)
# 右下角米深裝飾帶
_add_rect(slide, W - 8.0, H - 4.2, 8.0, 0.18, _BRAND_OG)
_add_rect(slide, W - 8.0, H - 4.0, 8.0, 4.0, _SUBTLE)
# 中段 hairline 分隔(標題 / 副資訊 之間)
_add_rect(slide, 4.0, 9.5, 22.0, 0.06, _BRAND_OG)
# OPENCLAW 標籤(深焦糖底 + 白字,落在米底上才看得見)
_add_rect(slide, 3.8, 1.6, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.62, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
# 報告類別小標(焦糖橘小字)
_add_text(slide, "MONTHLY · OPERATIONS · AI INSIGHT",
3.8, 2.65, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
# 主標題(大字、暖墨色)
_add_text(slide, big_title, 3.8, 3.5, 25, 5.5,
bold=True, size=44, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 副標題 sub1焦糖橘加深、bold
_add_text(slide, sub1, 3.8, 9.95, 25, 0.85,
bold=True, size=16, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 副標題 sub2暖灰
if sub2:
_add_text(slide, sub2, 3.8, 11.0, 25, 0.85,
size=12, color=_SUBTEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 右下角資訊區(生成時間 / 版式說明)
_add_text(slide, "月度營運智能分析報告",
W - 7.5, H - 3.6, 7.0, 0.6,
bold=True, size=12, color=_DARK_TEXT, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 3.0, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right",
latin_font=_FONT_LABEL)
_add_footer(slide, W)
return slide
# ── 分頁商品表格(支援 50 項,多頁)───────────────────────────────────────────────────
def _product_table_slide(prs, header_text, products, W=33.87, max_items=50):
"""分頁顯示商品表格,每頁最多 18 項max_items 附預設 50 項"""
import math
HEADER_END = 1.85 # 頁首帶底部
TABLE_HDR_H = 0.72 # 表頭高
ROW_H = 0.88 # 標準行高
AVAIL_H = _CONTENT_B - HEADER_END - TABLE_HDR_H # 每頁可用高度
ROWS_PER_PAGE = max(1, int(AVAIL_H / ROW_H)) # 每頁行數≈ 18
all_prods = products[:max_items]
if not all_prods:
slide = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(slide, 0, 0, W, _SLIDE_H, _WHITE)
_add_header(slide, header_text)
_add_empty_state(slide, "本頁沒有可顯示的商品資料",
"請確認該期間是否已有匯入業績資料。", W)
_add_footer(slide, W)
return
top_rev = max(float(p.get('revenue', 1)) for p in all_prods) or 1
total_pages = math.ceil(len(all_prods) / ROWS_PER_PAGE)
for page in range(total_pages):
page_prods = all_prods[page * ROWS_PER_PAGE:(page + 1) * ROWS_PER_PAGE]
page_label = f" ({page + 1}/{total_pages})"
slide = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(slide, 0, 0, W, _SLIDE_H, _WHITE)
_add_header(slide, header_text + page_label)
# ── 表頭
tbl_y = HEADER_END
_add_rect(slide, 0.4, tbl_y, W - 0.8, TABLE_HDR_H, _BRAND_OG)
_add_rect(slide, 0.4, tbl_y, 0.3, TABLE_HDR_H, _BRAND_OG2)
_add_text(slide, "排名", 0.5, tbl_y + 0.06, 1.3, TABLE_HDR_H - 0.12,
bold=True, size=10, color=_WHITE, align="center",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, "商品名稱", 1.95, tbl_y + 0.06, W - 19.5, TABLE_HDR_H - 0.12,
bold=True, size=10, color=_WHITE,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, "業績佔比", W - 17.2, tbl_y + 0.06, 5.5, TABLE_HDR_H - 0.12,
bold=True, size=9, color=_WHITE, align="center",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, "月業績", W - 11.3, tbl_y + 0.06, 5.8, TABLE_HDR_H - 0.12,
bold=True, size=10, color=_WHITE, align="right",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, "訂單", W - 5.0, tbl_y + 0.06, 4.4, TABLE_HDR_H - 0.12,
bold=True, size=10, color=_WHITE, align="right",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
# ── 資料行
for j, p in enumerate(page_prods):
actual_rank = page * ROWS_PER_PAGE + j + 1
bg = _LIGHT_GRAY if j % 2 == 0 else _WHITE
row_t = tbl_y + TABLE_HDR_H + j * ROW_H
rev = float(p.get('revenue', 0))
pct = rev / top_rev if top_rev else 0
ord_v = p.get('orders', p.get('order_count', 0))
_add_rect(slide, 0.4, row_t, W - 0.8, ROW_H - 0.04, bg)
# 排名圓框TOP3 焦糖橘)
rank_fill = _BRAND_OG if actual_rank <= 3 else (_KPI_HONEY if actual_rank <= 10 else _SUBTLE)
rank_color = _WHITE if actual_rank <= 10 else _SUBTEXT
_add_rect(slide, 0.55, row_t + 0.1, 0.95, ROW_H - 0.22, rank_fill)
_add_text(slide, str(actual_rank),
0.55, row_t + 0.1, 0.95, ROW_H - 0.22,
bold=(actual_rank <= 3), size=11, color=rank_color,
align="center", valign="middle",
latin_font=_FONT_DISPLAY)
# 商品名稱CJK 為主、中英分軌)
_add_text(slide, str(p.get('name', ''))[:44],
1.95, row_t + 0.1, W - 19.7, ROW_H - 0.2,
size=10, color=_DARK_TEXT,
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
# 業績佔比視覺條
bar_max_w = 5.0
bar_w = max(0.08, pct * bar_max_w)
bar_y = row_t + ROW_H * 0.38
bar_h = 0.25
_add_rect(slide, W - 17.0, bar_y, bar_max_w, bar_h, _SUBTLE)
_add_rect(slide, W - 17.0, bar_y, bar_w, bar_h, _BRAND_OG)
_add_text(slide, f"{pct*100:.0f}%",
W - 17.0, row_t + 0.08, bar_max_w, ROW_H - 0.2,
size=9, color=_TERTIARY, align="right",
latin_font=_FONT_DISPLAY)
# 月業績(純數字 → Display 字型)
_add_text(slide, f"NT${rev:,.0f}",
W - 11.3, row_t + 0.1, 5.8, ROW_H - 0.2,
bold=(actual_rank == 1), size=11, color=_DARK_TEXT,
align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 訂單(數字 + 中文「筆」→ 中英分軌)
if ord_v:
_add_text(slide, f"{int(ord_v):,}",
W - 5.0, row_t + 0.1, 4.4, ROW_H - 0.2,
size=10, color=_SUBTEXT, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
# ── 日報 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日業績走勢matplotlib 折線專業版)
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s3, f"近 7 日業績走勢 — 截至 {date_str}")
if wk:
dates_lst = [str(w.get('date', '')) for w in wk]
revs_lst = [float(w.get('revenue', 0)) for w in wk]
chart_w = W - 0.8
chart_h = 11.0
buf = _mpl_line_chart_png(dates_lst, revs_lst,
prev_vals=None,
total_width_cm=chart_w,
total_height_cm=chart_h,
title="日業績走勢(含日均線)",
curr_label="日業績")
if buf is not None:
_add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h)
else:
_add_column_chart(s3, 0.8, 1.95, W - 1.6, 11.0,
[d[-5:] for d in dates_lst], [("日業績(萬元)", revs_lst)],
bar_colors=[_BRAND_OG])
# 底部 4 卡:合計 / 日均 / 最高 / 最低
total_7d = sum(revs_lst)
avg_7d = total_7d / len(revs_lst) if revs_lst else 0
max_7d = max(revs_lst) if revs_lst else 0
min_7d = min(revs_lst) if revs_lst else 0
ins_y = 13.3
ins_h = 2.4
card_w = (W - 1.0 - 0.3 * 3) / 4
cards = [
(_BRAND_OG, "📊 近7日合計", f"NT${total_7d/10000:.1f}", f"{len(revs_lst)}"),
(_KPI_HONEY, "📈 日均業績", f"NT${avg_7d/10000:.1f}", "7-day avg"),
(_KPI_MAHOGANY,"🏆 最高單日", f"NT${max_7d/10000:.1f}",
dates_lst[revs_lst.index(max_7d)][-5:] if revs_lst else ""),
(_KPI_EARTH, "📉 最低單日", f"NT${min_7d/10000:.1f}",
dates_lst[revs_lst.index(min_7d)][-5:] if revs_lst else ""),
]
for i, (col, lbl, val, sub) in enumerate(cards):
cx = 0.5 + i * (card_w + 0.3)
_add_rect(s3, cx, ins_y, card_w, ins_h, col)
_add_rect(s3, cx, ins_y, 0.15, ins_h, "FFFFFF")
_add_text(s3, lbl, cx + 0.3, ins_y + 0.2, card_w - 0.5, 0.5,
size=10, color="FAF7F0",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(s3, val, cx + 0.2, ins_y + 0.75, card_w - 0.4, 0.95,
bold=True, size=18, color="FFFFFF", align="center", valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(s3, sub, cx + 0.2, ins_y + 1.75, card_w - 0.4, 0.55,
size=9, color="FAF7F0", align="center",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
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, _SLIDE_H, _BG_PAPER)
_add_header(s4, "🎯 AI 洞察分析")
_add_rect(s4, 0.6, 1.95, W - 1.2, 13.5, _WHITE, line_hex=_SUBTLE)
_add_rect(s4, 0.6, 1.95, 0.4, 13.5, _BRAND_OG)
_add_text(s4, ai_text or "AI 分析生成中)",
1.3, 2.2, W - 2.2, 13.0,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s4, W)
# P5: 附錄
_appendix_slide(prs, 'daily', date_str)
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日業績走勢matplotlib 折線專業版)
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s3, "近 7 日業績走勢圖")
if wk:
dates_full = [str(w.get('date', '')) for w in wk]
revs_full = [float(w.get('revenue', 0)) for w in wk]
chart_w = W - 0.8
chart_h = 11.0
buf = _mpl_line_chart_png(dates_full, revs_full,
prev_vals=None,
total_width_cm=chart_w,
total_height_cm=chart_h,
title="日業績走勢(含日均線、高低點)",
curr_label="日業績")
if buf is not None:
_add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h)
else:
_add_column_chart(s3, 0.8, 1.95, W - 1.6, 11.0,
[d[-5:] for d in dates_full], [("日業績(萬元)", revs_full)],
bar_colors=[_BRAND_OG])
# 底部結論帶(最佳/最差/標準差)
max_v = max(revs_full)
min_v = min(revs_full)
spread = max_v - min_v
spread_pct = spread / avg_daily * 100 if avg_daily else 0
_add_rect(s3, 0.4, 13.3, W - 0.8, 2.0, _BRAND_OG2)
_add_text(s3,
f"📊 本週區間:最高 NT${max_v/10000:.1f}"
f"{dates_full[revs_full.index(max_v)][-5:]}"
f" 最低 NT${min_v/10000:.1f}"
f"{dates_full[revs_full.index(min_v)][-5:]}"
f" 高低差 NT${spread/10000:.1f}萬(為日均 {spread_pct:.0f}%",
0.7, 13.5, W - 1.4, 1.6,
bold=True, size=12, color=_WHITE, valign="middle", wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
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, _SLIDE_H, _BG_PAPER)
_add_header(s5, "🎯 週報 AI 洞察")
_add_rect(s5, 0.6, 1.95, W - 1.2, 13.5, _WHITE, line_hex=_SUBTLE)
_add_rect(s5, 0.6, 1.95, 0.4, 13.5, _BRAND_OG)
_add_text(s5, ai_text or "(暫無 AI 分析)",
1.3, 2.2, W - 2.2, 13.0,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s5, W)
# P6: 附錄
period_label = (str(wk[0].get('date', ''))[-10:] + " ~ " + str(wk[-1].get('date', ''))[-10:]) if wk else "近 7 日"
_appendix_slide(prs, 'weekly', period_label)
path = _new_path("weekly")
prs.save(path)
return path
# ── 月報 PPT6頁────────────────────────────────────────────────────────────
def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str:
"""月報 v3.1(市場專業標準):
P1 封面(含 elevator pitch 三句話)
P2 執行摘要KPI 含 △% vs 上月 + AI 解讀)
P3 業績趨勢(日業績折線 + 上月對比基準線 + 高低點標註)
P4 品類分析(橫條 + 帕雷托 80/20 雙視圖)
P5 熱銷商品 TOP 50含 vs 上月 △ 排名變化、🆕 新進榜)— 自動分頁
P6 MCP 市場情報4 卡片結構化)
P7 AI 行動建議(結構化分區)
P8 附錄(資料來源 / 計算口徑 / 模板版本)
"""
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', [])
daily = ms.get('daily', [])
prev_mo_data = ms.get('prev_month')
prev_yr_data = ms.get('prev_year')
# 計算 elevator pitch封面 + P2 共用)
elevator = _compute_elevator_pitch(ms, prev_mo_data)
# ── P1: 封面(含 elevator pitch─────────────────────────────────────────
_monthly_cover_slide(prs, yr, mo, rev, ord_, gm, aov, elevator)
# ── P2: 執行摘要KPI 含 △% + AI 解讀)─────────────────────────────────
_monthly_summary_slide(prs, yr, mo, ms, prev_mo_data, prev_yr_data,
elevator, ai_text)
# ── P3: 業績趨勢頁(日業績折線 + 上月對比)─────────────────────────────
_monthly_trend_slide(prs, yr, mo, daily, prev_mo_data, rev, gm, aov)
# ── P4: 品類分析(橫條 + 帕雷托 80/20──────────────────────────────────
_monthly_category_slide(prs, yr, mo, top_cats, rev)
# ── P5: 熱銷商品 TOP 50含 △ 排名變化、🆕 新進榜)──────────────────────
_monthly_products_slide(prs, yr, mo, top_prod, prev_mo_data)
# ── P6: MCP 市場情報(多卡片結構化)─────────────────────────────────────
_mcp_intel_slide(prs, mcp_text)
# ── P7: AI 行動建議(結構化分區)────────────────────────────────────────
_ai_insight_slide(prs, ai_text)
# ── P8: 附錄(建立可信度)───────────────────────────────────────────────
_appendix_slide(prs, 'monthly', f"{yr}/{mo:02d}")
path = _new_path("monthly")
prs.save(path)
return path
# ── 月報 P1: 封面(含 elevator pitch─────────────────────────────────────
def _monthly_cover_slide(prs, yr, mo, rev, ord_, gm, aov, elevator):
"""月報專屬封面:暖紙底 + 焦糖橘左寬條 + 三句話 elevator pitch"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
W = 33.87
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, _BRAND_OG)
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
# 右上角裝飾
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, _BRAND_OG)
# 中段 hairline
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, _BRAND_OG)
# OPENCLAW 標籤
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
# 報告類別小標
_add_text(slide, "MONTHLY · OPERATIONS REPORT · AI INSIGHT",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
# 主標題
_add_text(slide, f"月度營運報告\n{yr}{mo:02d}",
3.8, 3.2, 25, 5.0,
bold=True, size=44, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 業績狀態徽章(右上角)
status_color = elevator.get('status_color', _SUBTEXT)
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, status_color)
_add_text(slide, f"業績狀態:{elevator.get('status', '')}",
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
# 副標題:業績摘要
_add_text(slide,
f"業績 NT${rev:,.0f}{rev/10000:.1f}萬) · 訂單 {ord_:,}"
f" · 毛利率 {gm:.1f}% · 客單 NT${aov:,.0f}",
3.8, 8.7, 27, 0.85,
bold=True, size=14, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 三句話 elevator pitch
pitch_y = 10.2
pitch_h = 1.5
pitch_w = 27.0
# ★ 亮點
_add_rect(slide, 3.8, pitch_y, 0.45, pitch_h, "2A7A3F")
_add_text(slide, f"★ 最大亮點",
4.4, pitch_y + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color="2A7A3F",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, elevator.get('highlight') or "",
4.4, pitch_y + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# ⚠ 警訊
pitch_y2 = pitch_y + pitch_h + 0.4
_add_rect(slide, 3.8, pitch_y2, 0.45, pitch_h, "B5342F")
_add_text(slide, f"⚠ 最大警訊",
4.4, pitch_y2 + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color="B5342F",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, elevator.get('warning') or "",
4.4, pitch_y2 + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 📈 vs 上月(如有資料)
pitch_y3 = pitch_y2 + pitch_h + 0.4
mom_rev = elevator.get('mom_rev')
if mom_rev is not None:
delta_color = "2A7A3F" if mom_rev > 0 else "B5342F"
arrow = "" if mom_rev > 0 else ("" if mom_rev < 0 else "")
_add_rect(slide, 3.8, pitch_y3, 0.45, pitch_h, delta_color)
_add_text(slide, f"📈 業績動能",
4.4, pitch_y3 + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color=delta_color,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
mom_ord = elevator.get('mom_ord')
mom_gm = elevator.get('mom_gm')
body = (f"vs 上月:業績 {arrow} {abs(mom_rev):.1f}%"
+ (f" · 訂單 {'' if (mom_ord or 0) > 0 else ''} {abs(mom_ord):.1f}%"
if mom_ord is not None else "")
+ (f" · 毛利率 {'+' if (mom_gm or 0) > 0 else ''}{mom_gm:.1f}pp"
if mom_gm is not None else ""))
_add_text(slide, body,
4.4, pitch_y3 + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 右下角:生成資訊
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right",
latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
return slide
# ── 月報 P2: 執行摘要KPI 卡含 △% + AI 解讀)─────────────────────────────
def _monthly_summary_slide(prs, yr, mo, ms, prev_mo, prev_yr,
elevator, ai_text):
W = 33.87
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, f"執行摘要 (Executive Summary) — {yr}/{mo:02d}")
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))
# 計算 △%
def _delta(curr, prev_dict, key, is_pp=False):
if not prev_dict:
return None
prev_v = float(prev_dict.get(key, 0) or 0)
if not prev_v:
return None
if is_pp:
return float(curr) - prev_v # 毛利率走絕對 pp
return (float(curr) - prev_v) / prev_v * 100
d_rev = _delta(rev, prev_mo, 'revenue')
d_ord = _delta(ord_, prev_mo, 'orders')
d_gm = _delta(gm, prev_mo, 'gross_margin', is_pp=True)
d_aov = _delta(aov, prev_mo, 'avg_order')
# 4 張 KPI v2 卡(含 △)
kpis = [
(_KPI_CARAMEL, "月業績", f"NT${rev/10000:.1f}", d_rev, "vs 上月"),
(_KPI_HONEY, "月訂單", f"{ord_:,}", d_ord, "vs 上月"),
(_KPI_MAHOGANY, "毛利率", f"{gm:.1f}%", d_gm, "vs 上月pp"),
(_KPI_EARTH, "平均客單", f"NT${aov:,.0f}", d_aov, "vs 上月"),
]
for i, (col, lbl, val, delta_pct, dlbl) in enumerate(kpis):
# 毛利率 / 客單 用 absolute pp傳遞時用客製 label
_kpi_card_v2(s, i * 7.8 + 0.5, 1.95, 7.4, 4.5,
col, lbl, val, delta_pct=delta_pct,
delta_label=dlbl)
# 高階解讀區塊(白卡 + 暖色色條)
_add_rect(s, 0.5, 7.0, W - 1.0, 0.7, _BRAND_OG)
_add_rect(s, 0.5, 7.0, 0.4, 0.7, _BRAND_OG2)
_add_text(s, "📊 高階營運解讀AI Generated",
1.1, 7.05, W - 1.5, 0.6, bold=True, size=13, color=_WHITE,
valign="middle", ea_font=_FONT_BODY_EA)
_add_rect(s, 0.5, 7.7, W - 1.0, 6.4, _WHITE, line_hex=_SUBTLE)
_add_rect(s, 0.5, 7.7, 0.4, 6.4, _BRAND_OG)
# 抽 AI 整體解讀段
summary_text = ""
capture = False
for line in (ai_text or '').split('\n'):
if '整體業績解讀' in line or '高階營運' in line:
capture = True
continue
if capture:
if line.strip().startswith('') and '整體' not in line:
break
if line.strip():
summary_text += line + "\n"
if len(summary_text) > 350: break
if not summary_text.strip():
for line in (ai_text or '').split('\n'):
if line.strip() and not line.startswith(''):
summary_text += line + "\n"
if len(summary_text) > 350: break
if not summary_text.strip():
summary_text = (ai_text or '')[:350] + "" if ai_text else "(暫無 AI 分析)"
_add_text(s, summary_text.strip(),
1.2, 7.95, W - 2.0, 5.9,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 底部YoY 同期對比帶(如有)
yoy_y = 14.4
if prev_yr:
prev_yr_rev = float(prev_yr.get('revenue', 0) or 0)
if prev_yr_rev:
yoy = (rev - prev_yr_rev) / prev_yr_rev * 100
yoy_color = "2A7A3F" if yoy > 0 else "B5342F"
arrow = "" if yoy > 0 else ""
_add_rect(s, 0.5, yoy_y, W - 1.0, 0.95, yoy_color)
_add_text(s,
f"📅 YoY 同期對比:去年 {yr-1}/{mo:02d} 業績 NT${prev_yr_rev/10000:.1f}"
f" → 本月 {arrow} {abs(yoy):.1f}%",
0.7, yoy_y + 0.05, W - 1.4, 0.85,
bold=True, size=12, color=_WHITE, valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s, W)
return s
# ── 月報 P3: 業績趨勢頁 ──────────────────────────────────────────────────
def _monthly_trend_slide(prs, yr, mo, daily, prev_mo, rev, gm, aov):
W = 33.87
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, f"業績趨勢分析 — {yr}/{mo:02d} 日業績走勢")
if not daily:
_add_empty_state(s, "本月無逐日業績資料",
"請確認 realtime_sales_monthly 表是否已收齊本月資料。", W)
_add_footer(s, W)
return s
curr_dates = [d.get('date', '')[-5:] for d in daily]
curr_vals = [float(d.get('revenue', 0)) for d in daily]
prev_vals = None
if prev_mo and prev_mo.get('daily'):
prev_vals = [float(d.get('revenue', 0)) for d in prev_mo.get('daily', [])]
# 折線圖(占左 75%
chart_w = W - 0.8
chart_h = 11.0
buf = _mpl_line_chart_png(curr_dates, curr_vals, prev_vals=prev_vals,
total_width_cm=chart_w,
total_height_cm=chart_h,
title="日業績走勢(本月 vs 上月)",
curr_label=f"本月 {yr}/{mo:02d}",
prev_label="上月")
if buf is not None:
_add_image_from_buf(s, buf, 0.4, 1.95, chart_w, chart_h)
else:
_add_empty_state(s, "圖表渲染失敗", "matplotlib 不可用,請確認部署環境。", W)
# 底部 4 個分析洞察卡
insights_y = 13.3
insights_h = 2.4
card_w = (W - 1.0 - 0.3 * 3) / 4
avg = sum(curr_vals) / len(curr_vals) if curr_vals else 0
max_v = max(curr_vals) if curr_vals else 0
min_v = min(curr_vals) if curr_vals else 0
days_above_avg = sum(1 for v in curr_vals if v >= avg)
cards = [
(_BRAND_OG, "📈 日均業績", f"NT${avg/10000:.1f}", f"{len(curr_vals)} 天平均"),
(_KPI_HONEY, "🏆 最高單日", f"NT${max_v/10000:.1f}",
f"{curr_dates[curr_vals.index(max_v)] if curr_vals else ''}"),
(_KPI_MAHOGANY,"📉 最低單日", f"NT${min_v/10000:.1f}",
f"{curr_dates[curr_vals.index(min_v)] if curr_vals else ''}"),
(_KPI_EARTH, "✅ 超均天數", f"{days_above_avg} / {len(curr_vals)}",
f"{days_above_avg/len(curr_vals)*100:.0f}% 達標"
if curr_vals else ""),
]
for i, (col, lbl, val, sub) in enumerate(cards):
cx = 0.5 + i * (card_w + 0.3)
_add_rect(s, cx, insights_y, card_w, insights_h, col)
_add_rect(s, cx, insights_y, 0.15, insights_h, "FFFFFF")
_add_text(s, lbl, cx + 0.3, insights_y + 0.2, card_w - 0.5, 0.5,
size=10, color="FAF7F0",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(s, val, cx + 0.2, insights_y + 0.75, card_w - 0.4, 0.95,
bold=True, size=18, color="FFFFFF", align="center", valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(s, sub, cx + 0.2, insights_y + 1.75, card_w - 0.4, 0.55,
size=9, color="FAF7F0", align="center",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s, W)
return s
# ── 月報 P4: 品類分析(橫條 + 帕雷托 80/20────────────────────────────────
def _monthly_category_slide(prs, yr, mo, top_cats, total_rev):
W = 33.87
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, f"品類業績結構分析 — {yr}/{mo:02d}(橫條 + 帕雷托 80/20")
if not top_cats:
_add_empty_state(s, "本月無品類分佈資料",
"請確認月報期間是否已有分類欄位與銷售資料。", W)
_add_footer(s, W)
return s
cats_disp = [c.get('cat', '')[:14] for c in top_cats[:8]]
revs_cats = [float(c.get('revenue', 0)) for c in top_cats[:8]]
total_cat_rev = sum(revs_cats) or 1.0
# 左側matplotlib 橫條
chart_w_left = W * 0.5 - 0.4
chart_h = 12.5
buf1 = _mpl_horiz_bar_png(cats_disp, revs_cats,
total_width_cm=chart_w_left,
total_height_cm=chart_h,
value_unit="",
title="① 業績排行(焦糖橘=TOP3",
highlight_top_n=3)
if buf1:
_add_image_from_buf(s, buf1, 0.4, 1.95, chart_w_left, chart_h)
# 右側:帕雷托
chart_w_right = W * 0.5 - 0.4
rx = W * 0.5 + 0.0
buf2 = _mpl_pareto_chart_png(cats_disp, revs_cats,
total_width_cm=chart_w_right,
total_height_cm=chart_h,
title="② 帕雷托累計貢獻80% 主力線)")
if buf2:
_add_image_from_buf(s, buf2, rx, 1.95, chart_w_right, chart_h)
# 底部結論帶
best = top_cats[0]
# 計算 80% 內品類數
cum = 0
pareto_n = 0
for r in revs_cats:
cum += r
pareto_n += 1
if cum / total_cat_rev >= 0.8:
break
_add_rect(s, 0.4, 14.7, W - 0.8, 1.0, _BRAND_OG2)
_add_text(s,
f"★ 最高貢獻:{best.get('cat','')} NT${float(best.get('revenue',0)):,.0f}"
f"(佔總業績 {float(best.get('revenue',0))/(total_rev or 1)*100:.1f}%"
f" 80% 業績集中在前 {pareto_n} 品類"
f"(共 {len(top_cats[:8])} 品類入榜)",
0.7, 14.85, W - 1.4, 0.7,
bold=True, size=12, color=_WHITE, valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s, W)
return s
# ── 月報 P5: 熱銷商品 TOP 50含 △ 排名變化、🆕 新進榜)────────────────────
def _monthly_products_slide(prs, yr, mo, products, prev_mo, max_items=50):
"""產品表格 + 上月排名比對 → 加入 △/▼/🆕 標記"""
import math
W = 33.87
# 建立上月排名映射key=name 或 id
prev_rank = {}
if prev_mo and prev_mo.get('top_products'):
for i, p in enumerate(prev_mo.get('top_products', [])[:50]):
key = p.get('id') or p.get('name', '')
if key:
prev_rank[key] = i + 1
HEADER_END = 1.85
TABLE_HDR_H = 0.72
ROW_H = 0.88
AVAIL_H = _CONTENT_B - HEADER_END - TABLE_HDR_H
ROWS_PER_PAGE = max(1, int(AVAIL_H / ROW_H))
all_prods = products[:max_items]
if not all_prods:
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, f"核心動能:熱銷商品 TOP 50 — {yr}/{mo:02d}")
_add_empty_state(s, "本月無商品銷售資料",
"請確認該期間是否已有匯入業績資料。", W)
_add_footer(s, W)
return
top_rev = max(float(p.get('revenue', 1)) for p in all_prods) or 1
total_pages = math.ceil(len(all_prods) / ROWS_PER_PAGE)
for page in range(total_pages):
page_prods = all_prods[page * ROWS_PER_PAGE:(page + 1) * ROWS_PER_PAGE]
page_label = f" ({page + 1}/{total_pages})"
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, f"核心動能:熱銷商品 TOP 50 — {yr}/{mo:02d}{page_label}")
# 表頭
tbl_y = HEADER_END
_add_rect(s, 0.4, tbl_y, W - 0.8, TABLE_HDR_H, _BRAND_OG)
_add_rect(s, 0.4, tbl_y, 0.3, TABLE_HDR_H, _BRAND_OG2)
_add_text(s, "排名", 0.5, tbl_y + 0.06, 1.3, TABLE_HDR_H - 0.12,
bold=True, size=10, color=_WHITE, align="center",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(s, "商品名稱", 1.95, tbl_y + 0.06, W - 22.5, TABLE_HDR_H - 0.12,
bold=True, size=10, color=_WHITE,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(s, "vs 上月", W - 20.2, tbl_y + 0.06, 3.0, TABLE_HDR_H - 0.12,
bold=True, size=9, color=_WHITE, align="center",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(s, "業績佔比", W - 17.0, tbl_y + 0.06, 5.3, TABLE_HDR_H - 0.12,
bold=True, size=9, color=_WHITE, align="center",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(s, "月業績", W - 11.3, tbl_y + 0.06, 5.8, TABLE_HDR_H - 0.12,
bold=True, size=10, color=_WHITE, align="right",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(s, "訂單", W - 5.0, tbl_y + 0.06, 4.4, TABLE_HDR_H - 0.12,
bold=True, size=10, color=_WHITE, align="right",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
for j, p in enumerate(page_prods):
actual_rank = page * ROWS_PER_PAGE + j + 1
bg = _LIGHT_GRAY if j % 2 == 0 else _WHITE
row_t = tbl_y + TABLE_HDR_H + j * ROW_H
rev = float(p.get('revenue', 0))
pct = rev / top_rev if top_rev else 0
ord_v = p.get('orders', p.get('order_count', 0))
_add_rect(s, 0.4, row_t, W - 0.8, ROW_H - 0.04, bg)
# 排名圓
rank_fill = _BRAND_OG if actual_rank <= 3 else (_KPI_HONEY if actual_rank <= 10 else _SUBTLE)
rank_color = _WHITE if actual_rank <= 10 else _SUBTEXT
_add_rect(s, 0.55, row_t + 0.1, 0.95, ROW_H - 0.22, rank_fill)
_add_text(s, str(actual_rank),
0.55, row_t + 0.1, 0.95, ROW_H - 0.22,
bold=(actual_rank <= 3), size=11, color=rank_color,
align="center", valign="middle",
latin_font=_FONT_DISPLAY)
# 商品名稱
_add_text(s, str(p.get('name', ''))[:42],
1.95, row_t + 0.1, W - 22.7, ROW_H - 0.2,
size=10, color=_DARK_TEXT,
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
# vs 上月變化(△排名 / 🆕新進榜 / —無資料)
key = p.get('id') or p.get('name', '')
if prev_mo and key:
prev_r = prev_rank.get(key)
if prev_r is None:
diff_text = "🆕 新"
diff_color = "2A7A3F"
else:
diff = prev_r - actual_rank # 正=上升
if diff > 0:
diff_text = f"{diff}"
diff_color = "2A7A3F"
elif diff < 0:
diff_text = f"{abs(diff)}"
diff_color = "B5342F"
else:
diff_text = ""
diff_color = "9B9081"
else:
diff_text = ""
diff_color = "9B9081"
_add_text(s, diff_text,
W - 20.2, row_t + 0.1, 3.0, ROW_H - 0.2,
bold=True, size=10, color=diff_color, align="center",
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
# 業績佔比視覺條
bar_max_w = 4.8
bar_w = max(0.08, pct * bar_max_w)
bar_y = row_t + ROW_H * 0.38
bar_h = 0.25
_add_rect(s, W - 16.8, bar_y, bar_max_w, bar_h, _SUBTLE)
_add_rect(s, W - 16.8, bar_y, bar_w, bar_h, _BRAND_OG)
_add_text(s, f"{pct*100:.0f}%",
W - 16.8, row_t + 0.08, bar_max_w, ROW_H - 0.2,
size=9, color=_TERTIARY, align="right",
latin_font=_FONT_DISPLAY)
# 月業績
_add_text(s, f"NT${rev:,.0f}",
W - 11.3, row_t + 0.1, 5.8, ROW_H - 0.2,
bold=(actual_rank == 1), size=11, color=_DARK_TEXT,
align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 訂單
if ord_v:
_add_text(s, f"{int(ord_v):,}",
W - 5.0, row_t + 0.1, 4.4, ROW_H - 0.2,
size=10, color=_SUBTEXT, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s, W)
# ── 月報專用MCP RAG 情報多卡片頁 ─────────────────────────────────────────────
def _parse_mcp_sections(mcp_text: str) -> dict:
"""把 build_mcp_context() 回傳的鬆散字串切成卡片字段。
回傳 keys: date_header / month_focus / next_month / season / market / extra
缺失欄位填合理預設,確保版面不空白。
"""
sections = {
"date_header": "",
"month_focus": "",
"next_month": "",
"season": "",
"market": "",
"extra": "",
}
text = (mcp_text or "").strip()
if not text:
return sections
for line in text.split('\n'):
line = line.strip()
if not line:
continue
if line.startswith("當前日期"):
sections["date_header"] = line.replace("當前日期:", "").strip()
elif line.startswith("本月電商重點"):
sections["month_focus"] = line.replace("本月電商重點:", "").strip()
elif line.startswith("下月預告"):
sections["next_month"] = line.replace("下月預告:", "").strip()
elif "季:" in line and len(line) < 80:
sections["season"] = line
elif line.startswith("【market_trends】") or line.startswith("【holiday_calendar】"):
sections["market"] += (line + "\n")
elif line.startswith(""):
sections["extra"] += (line + "\n")
else:
# 無前綴的補充段落 → 歸入 market避免漏資訊
if sections["season"] and line == sections["season"]:
continue
sections["market"] += (line + "\n")
return sections
def _mcp_intel_slide(prs, mcp_text: str, W: float = 33.87):
"""MCP 外部情報 — 4 卡片 × 2 區帶結構,缺資料時填預設指引文。"""
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, "🌐 專案 RAG 情報MCP 外部市場與競品動態監控")
sec = _parse_mcp_sections(mcp_text)
# 頂部資訊條(日期 + 來源說明)
_add_rect(s, 0.4, 1.95, W - 0.8, 0.9, _BRAND_OG2)
date_str = sec["date_header"] or datetime.now().strftime('%Y/%m/%d')
_add_text(s,
f"📅 {date_str} 來源MCP Bridge ‧ Holiday Calendar ‧ Seasonal Heuristics 更新時間:{datetime.now().strftime('%H:%M')}",
0.7, 2.05, W - 1.4, 0.7,
bold=True, size=11, color=_WHITE, valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 四張卡片2 × 2
card_top1 = 3.1
card_top2 = 9.5
card_h = 6.0
col_w = (W - 1.2) / 2
col1_x = 0.4
col2_x = 0.4 + col_w + 0.4
def _draw_card(x, y, w, h, accent, icon, title, body, fallback):
_add_rect(s, x, y, w, h, _WHITE, line_hex=_SUBTLE)
_add_rect(s, x, y, w, 0.85, accent)
_add_text(s, f"{icon} {title}",
x + 0.4, y + 0.12, w - 0.8, 0.65,
bold=True, size=13, color=_WHITE, valign="middle",
ea_font=_FONT_BODY_EA)
body_text = (body or "").strip() or fallback
_add_text(s, body_text,
x + 0.5, y + 1.1, w - 1.0, h - 1.3,
size=12, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_draw_card(col1_x, card_top1, col_w, card_h,
_BRAND_OG, "🎯", "本月電商檔期重點",
sec["month_focus"],
"暫無檔期資料建議手動補入母親節、520、勞動節等大檔"
"提前 14 天備貨、設定 TG 缺貨告警閾值。")
_draw_card(col2_x, card_top1, col_w, card_h,
_KPI_HONEY, "🚀", "下月檔期預告",
sec["next_month"],
"(暫無預告資料)建議查 Hermes monthly_calendar 表,"
"或執行 `/oc rag refresh` 強制重新拉取。")
_draw_card(col1_x, card_top2, col_w, card_h,
_KPI_MAHOGANY, "🌿", "季節性消費情境",
sec["season"],
"(暫無季節情境)依當月切換:春換季保養 / 夏防曬 / 秋保濕 / 冬保暖。"
"可在 mcp_collector_service.py 補強季節→品類權重對照。")
market_body = (sec["market"] + "\n" + sec["extra"]).strip()
_draw_card(col2_x, card_top2, col_w, card_h,
_KPI_EARTH, "📡", "市場/競品動態Gemini Grounding",
market_body,
"(本次未擷取到 Gemini Grounding 結果)"
"可能原因Gemini 配額耗盡 / 網路抖動 / 主題未命中。"
"Fallback請參考 PChome 競價監控與 momo 自家主推檔期排程。")
_add_footer(s, W)
return s
# ── 月報專用AI 洞察結構化頁 ─────────────────────────────────────────────────
def _parse_ai_sections(ai_text: str) -> list:
"""把 AI 月報文字切成 (icon, title, body) 列表,最多 6 段。
支援 7-section 模板:【整體業績解讀】【品類結構】【熱銷商品洞察】
【MCP 市場情報整合】【三段式行動建議】【風險警示】等。
"""
if not ai_text or not ai_text.strip():
return []
title_icons = {
"整體業績": "📊",
"高階營運": "📊",
"品類結構": "🧩",
"品類": "🧩",
"熱銷商品": "🏆",
"熱銷": "🏆",
"MCP": "🌐",
"外部": "🌐",
"市場情報": "🌐",
"行動建議": "🎯",
"三段式": "🎯",
"本週立即": "",
"本月優化": "📈",
"下月預備": "🚀",
"風險": "⚠️",
"警示": "⚠️",
}
sections = []
current_title = None
current_icon = "💡"
current_body = []
lines = ai_text.split('\n')
for line in lines:
stripped = line.strip()
if not stripped:
if current_body:
current_body.append("")
continue
# 偵測 【XXX】 或 ■ XXX 標題
is_header = False
header_text = None
if stripped.startswith('') and '' in stripped:
header_text = stripped.split('')[0].lstrip('').strip()
is_header = True
elif stripped.startswith(''):
header_text = stripped.lstrip('').strip()
is_header = True
elif stripped.startswith('##'):
header_text = stripped.lstrip('#').strip()
is_header = True
if is_header and header_text:
if current_title:
sections.append((current_icon, current_title,
'\n'.join(current_body).strip()))
current_title = header_text
current_icon = "💡"
for kw, ic in title_icons.items():
if kw in header_text:
current_icon = ic
break
current_body = []
tail = stripped.split('', 1)[1] if '' in stripped else ""
if tail.strip():
current_body.append(tail.strip())
else:
current_body.append(stripped)
if current_title:
sections.append((current_icon, current_title,
'\n'.join(current_body).strip()))
# 若沒有任何標題(純文字),整段當一個 section
if not sections and ai_text.strip():
sections.append(("💡", "AI 月度策略洞察", ai_text.strip()))
# 過濾「空 body」段如 SMART 框架主標題下接續是 ■ 子段,主標題自己沒內容)
# 這些「空殼」section 占版面但無資訊,丟掉避免顯示「(本段無內容)」
sections = [(i, t, b) for (i, t, b) in sections if b and len(b.strip()) >= 5]
# 上限放寬 6 → 10升級 prompt 後最多 9 段:整體/市場/品類/熱銷/MCP/
# 本週/本月/下月/風險),仍會自動分頁(每頁 4 卡,最多 3 頁)
return sections[:10]
def _ai_insight_slide(prs, ai_text: str, W: float = 33.87):
"""AI 洞察分區呈現 — 至多 6 個語義卡(依 AI 段落自動分頁)"""
sections = _parse_ai_sections(ai_text)
if not sections:
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, "🎯 AI Agent 專案分析與行動建議")
_add_empty_state(s, "本期暫無 AI 洞察輸出",
"請確認 NIM/Gemini 服務狀態,或重新觸發報表生成。", W)
_add_footer(s, W)
return
# 第一頁顯示 4 張卡,超過 4 段時開第二頁
pages = [sections[i:i + 4] for i in range(0, len(sections), 4)]
accent_cycle = [_BRAND_OG, _KPI_HONEY, _KPI_MAHOGANY, _KPI_EARTH, _BRAND_OG2, _BRAND_OG]
for pg_idx, page_secs in enumerate(pages):
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
title_suffix = f" ({pg_idx + 1}/{len(pages)})" if len(pages) > 1 else ""
_add_header(s, f"🎯 AI Agent 月度策略洞察{title_suffix}")
# 4 卡 2x2 排版
grid_cols = 2
grid_rows = 2 if len(page_secs) > 2 else 1
card_w = (W - 1.2 - 0.4 * (grid_cols - 1)) / grid_cols
# 可用高度
avail_h = _CONTENT_B - 1.95 - 0.4
card_h = (avail_h - 0.4 * (grid_rows - 1)) / grid_rows
for i, (icon, title, body) in enumerate(page_secs):
row = i // grid_cols
col = i % grid_cols
x = 0.4 + col * (card_w + 0.4)
y = 1.95 + row * (card_h + 0.4)
accent = accent_cycle[(pg_idx * 4 + i) % len(accent_cycle)]
_add_rect(s, x, y, card_w, card_h, _WHITE, line_hex=_SUBTLE)
_add_rect(s, x, y, 0.35, card_h, accent)
_add_rect(s, x + 0.35, y, card_w - 0.35, 0.85, accent)
_add_text(s, f"{icon} {title}",
x + 0.6, y + 0.13, card_w - 0.8, 0.6,
bold=True, size=13, color=_WHITE, valign="middle",
ea_font=_FONT_BODY_EA)
body_clean = (body or "").strip() or "(本段無內容)"
# 限長度避免溢出(每卡上限 ~ 480 字)
if len(body_clean) > 520:
body_clean = body_clean[:510].rstrip() + ""
_add_text(s, body_clean,
x + 0.55, y + 1.05, card_w - 1.0, card_h - 1.25,
size=11, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s, W)
# ── 策略報告 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_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, _SLIDE_H, _BG_PAPER)
_add_header(s5, "🎯 AI 策略洞察")
_add_rect(s5, 0.6, 1.95, W - 1.2, 13.5, _WHITE, line_hex=_SUBTLE)
_add_rect(s5, 0.6, 1.95, 0.4, 13.5, _BRAND_OG)
_add_text(s5, ai_text or "(暫無 AI 分析)",
1.3, 2.2, W - 2.2, 13.0,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s5, W)
# P6: 附錄
_appendix_slide(prs, 'strategy', period)
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, _SLIDE_H, _BG_PAPER)
_add_header(s5, f"🎯 促銷 AI 洞察 — {promo_label}")
_add_rect(s5, 0.6, 1.95, W - 1.2, 13.5, _WHITE, line_hex=_SUBTLE)
_add_rect(s5, 0.6, 1.95, 0.4, 13.5, _BRAND_OG)
_add_text(s5, ai_text or "(暫無 AI 分析)",
1.3, 2.2, W - 2.2, 13.0,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s5, W)
# P6: 附錄
_appendix_slide(prs, 'promo', promo_label)
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]
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, _SLIDE_H, _BG_PAPER)
_add_header(s4, "🎯 AI 競品洞察 — 策略建議")
_add_rect(s4, 0.6, 1.95, W - 1.2, 13.5, _WHITE, line_hex=_SUBTLE)
_add_rect(s4, 0.6, 1.95, 0.4, 13.5, _BRAND_OG)
_add_text(s4, ai_text or "AI 分析生成中)",
1.3, 2.2, W - 2.2, 13.0,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s4, W)
# P5: 附錄
_appendix_slide(prs, 'competitor', period_label)
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:
"""廠商業績報告 v3.1(採購視角):
P1 封面(含集中度警示徽章)
P2 執行摘要4 KPI 含 △% vs 上期 + 帕雷托集中度結論帶
P3 廠商業績排行(橫條 + 帕雷托雙視圖,標出前 N 家佔 80%
P4-P5 廠商明細表 TOP 30含 △ 排名變化、🆕 新進榜、佔比、毛利率)
P6 AI 採購策略洞察(議價對象 / 集中度風險 / 新廠商扶植 / SMART 行動)
P7 附錄
db_data: {
vendor_ranking: [{name, sales, profit, margin, orders, prev_rank?}],
prev_period: [...] (上期廠商列表,用於 △ 排名),
kpis: {total_sales, total_profit, avg_margin, vendor_count},
period_label: '2026/04' / '2026 Q1' / '2026 H1' / '2026'
}
"""
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 []
prev_vendors = db_data.get('prev_period', []) 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)
# 帕雷托:計算前 N 家廠商佔 80% 業績
sales_sorted = sorted([float(v.get('sales', 0)) for v in vendors], reverse=True)
cum_pct = 0
pareto_n = 0
for s in sales_sorted:
cum_pct += s / total_sales * 100 if total_sales else 0
pareto_n += 1
if cum_pct >= 80:
break
# 集中度警示
if vcount and pareto_n / vcount < 0.10:
risk_label, risk_color = '集中度過高', 'B5342F'
elif vcount and pareto_n / vcount < 0.20:
risk_label, risk_color = '集中度偏高', 'B88416'
else:
risk_label, risk_color = '健康分散', '2A7A3F'
# 上期排名對照(用於 △ 標記)
prev_rank = {}
for i, v in enumerate(prev_vendors):
if v.get('name'):
prev_rank[v['name']] = i + 1
# ── P1: 封面 ─────────────────────────────────────────────
_vendor_cover_slide(prs, period_lbl, vcount, total_sales, total_profit,
avg_margin, pareto_n, risk_label, risk_color)
# ── P2: 執行摘要 ──────────────────────────────────────────
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s2, f"廠商業績執行摘要 — {period_lbl}")
prev_total = sum(float(v.get('sales', 0)) for v in prev_vendors) if prev_vendors else 0
prev_count = len(prev_vendors) if prev_vendors else 0
d_sales = ((total_sales - prev_total) / prev_total * 100) if prev_total else None
d_count = ((vcount - prev_count) / prev_count * 100) if prev_count else None
kpi_v2 = [
(_KPI_CARAMEL, "廠商總數", f"{vcount}", d_count, "vs 上期"),
(_KPI_HONEY, "合計業績", f"NT${total_sales/10000:.1f}", d_sales, "vs 上期"),
(_KPI_MAHOGANY, "合計毛利", f"NT${total_profit/10000:.1f}", None, ""),
(_KPI_EARTH, "平均毛利率", f"{avg_margin:.1f}%", None, ""),
]
for i, (col, lbl, val, dp, dl) in enumerate(kpi_v2):
_kpi_card_v2(s2, i * 7.8 + 0.5, 1.95, 7.4, 4.5,
col, lbl, val, delta_pct=dp, delta_label=dl)
# 帕雷托集中度結論帶
_add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, _BRAND_OG)
_add_text(s2, "📊 廠商集中度分析(帕雷托 80/20",
1.1, 7.05, W - 1.5, 0.6, bold=True, size=13, color=_WHITE,
valign="middle", ea_font=_FONT_BODY_EA)
_add_rect(s2, 0.5, 7.7, W - 1.0, 6.4, _WHITE, line_hex=_SUBTLE)
_add_rect(s2, 0.5, 7.7, 0.4, 6.4, _BRAND_OG)
if vendors:
top1 = vendors[0]
top3_sales = sum(float(v.get('sales', 0)) for v in vendors[:3])
top3_pct = top3_sales / total_sales * 100 if total_sales else 0
analysis_lines = [
f"{pareto_n} 家廠商佔總業績 80%(共 {vcount} 家入榜,佔比 {pareto_n/vcount*100:.0f}%)→ {risk_label}",
"",
f"【業績第一】{top1.get('name','')[:25]} NT${float(top1.get('sales',0)):,.0f} 毛利率 {top1.get('margin',0):.1f}%",
f"【TOP 3 合計】業績 NT${top3_sales:,.0f}{top3_pct:.1f}%",
"",
"採購策略意涵:",
"• 集中度過高 → 議價空間大但供應風險高(單一廠商斷供影響嚴重)",
"• 集中度偏低 → 供應穩健但議價力分散(難以爭取規模採購折扣)",
]
if pareto_n / vcount < 0.10 if vcount else False:
analysis_lines.append("• ⚠ 建議扶植 TOP10~30 名長尾廠商,降低集中度風險")
_add_text(s2, '\n'.join(analysis_lines),
1.2, 7.95, W - 2.0, 5.9,
size=12, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s2, W)
# ── P3: 廠商業績排行(橫條 + 帕雷托雙視圖) ───────────────────
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s3, f"廠商業績排行 TOP 12 — {period_lbl}")
if vendors:
top12 = vendors[:12]
names = [v.get('name', '')[:14] for v in top12]
revs = [float(v.get('sales', 0)) for v in top12]
chart_w_left = W * 0.5 - 0.4
chart_h = 12.5
buf1 = _mpl_horiz_bar_png(names, revs,
total_width_cm=chart_w_left,
total_height_cm=chart_h,
value_unit="",
title="① 業績排行(焦糖橘=TOP3",
highlight_top_n=3)
if buf1:
_add_image_from_buf(s3, buf1, 0.4, 1.95, chart_w_left, chart_h)
chart_w_right = W * 0.5 - 0.4
rx = W * 0.5 + 0.0
buf2 = _mpl_pareto_chart_png(names, revs,
total_width_cm=chart_w_right,
total_height_cm=chart_h,
title="② 帕雷托累計貢獻80% 主力線)")
if buf2:
_add_image_from_buf(s3, buf2, rx, 1.95, chart_w_right, chart_h)
_add_rect(s3, 0.4, 14.7, W - 0.8, 1.0, _BRAND_OG2)
_add_text(s3,
f"★ 議價優先對象TOP 3 廠商合佔 {top3_pct:.1f}% 業績,"
f"是首批議價/獨家代理談判對象;前 {pareto_n} 家為 80% 主力,"
f"{vcount - pareto_n} 家為長尾(共 {vcount} 家)",
0.7, 14.85, W - 1.4, 0.7,
bold=True, size=12, color=_WHITE, valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
else:
_add_empty_state(s3, "本期無廠商業績資料",
"請確認該期間是否已有廠商欄位資料。", W)
_add_footer(s3, W)
# ── P4-P5: 廠商明細表 TOP 30自動分頁 ──────────────────────
_vendor_table_slide(prs, vendors[:30], period_lbl, prev_rank, total_sales)
# ── P6: AI 採購策略洞察(暖紙底,結構化) ─────────────────────
_ai_insight_slide(prs, ai_text)
# ── P7: 附錄 ─────────────────────────────────────────────
_appendix_slide(prs, 'vendor', period_lbl)
path = _new_path("vendor")
prs.save(path)
return path
# ── 五力雷達圖 helper ────────────────────────────────────────────────
def _mpl_radar_png(labels: list, momo_scores: list, competitor_scores: list,
competitor_name: str = "PChome",
total_width_cm: float = 14.0,
total_height_cm: float = 12.0,
title: str = "") -> "io.BytesIO":
"""五力雷達圖momo vs 競品 0-10 分對比
labels: ['商品力', '價格力', '行銷力', '服務力', '品牌力', '財務力']
momo_scores / competitor_scores: 同長度 list of 0-10
回傳 PNG BytesIOmatplotlib polar projection
"""
import io
matplotlib, plt = _mpl_setup()
if plt is None or not labels:
return None
fig = None
try:
import math
n = len(labels)
angles = [i * 2 * math.pi / n for i in range(n)]
angles_closed = angles + [angles[0]]
momo_closed = list(momo_scores) + [momo_scores[0]]
comp_closed = list(competitor_scores) + [competitor_scores[0]]
fig_w = total_width_cm / 2.54
fig_h = total_height_cm / 2.54
fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=150,
subplot_kw={'projection': 'polar'})
fig.patch.set_facecolor("#F3EEE2")
ax.set_facecolor("#F3EEE2")
# momo 焦糖橘
ax.plot(angles_closed, momo_closed, color="#C96442",
linewidth=2.4, marker='o', markersize=7, label="momo", zorder=3)
ax.fill(angles_closed, momo_closed, color="#C96442", alpha=0.20, zorder=2)
# 競品 蜂蜜金
ax.plot(angles_closed, comp_closed, color="#B88416",
linewidth=2.0, marker='s', markersize=6,
linestyle='--', label=competitor_name, zorder=3)
ax.fill(angles_closed, comp_closed, color="#B88416", alpha=0.15, zorder=1)
ax.set_xticks(angles)
ax.set_xticklabels(labels, fontsize=12, color="#2A2520", fontweight="bold")
ax.set_ylim(0, 10)
ax.set_yticks([2, 4, 6, 8, 10])
ax.set_yticklabels(['2', '4', '6', '8', '10'], fontsize=9, color="#9B9081")
ax.grid(color="#9B9081", alpha=0.4, linewidth=0.6)
ax.spines['polar'].set_color("#9B9081")
if title:
ax.set_title(title, fontsize=13, color="#2A2520", pad=24,
fontweight="bold")
ax.legend(loc='upper right', bbox_to_anchor=(1.18, 1.10),
fontsize=10, frameon=True, facecolor="#FAF7F0",
edgecolor="#C4BAA8")
plt.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150,
facecolor=fig.get_facecolor(), bbox_inches="tight")
buf.seek(0)
return buf
except Exception:
return None
finally:
if fig is not None:
try:
plt.close(fig)
except Exception:
pass
# ── 競品 v4 五力升級報告 ────────────────────────────────────────────
def generate_competitor_v4_ppt(period_label: str, db_data: dict, ai_text: str) -> str:
"""competitor v4 — 五力升級(戰略視角)
P1 封面(五力綜合評分徽章)
P2 五力雷達圖momo vs PChome 0-10 分)
P3 商品力 + 價格力雙卡SKU 數對比 + 平均價差)
P4 行銷力 + 服務力(雙卡:檔期密度 + 免運/到貨政策對比)
P5 品牌力 + 財務力雙卡Dcard 討論度 + 上市公司基本面)
P6 AI 五力戰略整合(差異化建議)
P7 附錄
"""
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
forces = db_data.get('forces', {}) or {}
momo_scores = [
forces.get('product_power', {}).get('momo', 5),
forces.get('price_power', {}).get('momo', 5),
forces.get('marketing_power', {}).get('momo', 5),
forces.get('service_power', {}).get('momo', 5),
forces.get('brand_power', {}).get('momo', 5),
forces.get('financial_power', {}).get('momo', 5),
]
comp_scores = [
forces.get('product_power', {}).get('competitor', 5),
forces.get('price_power', {}).get('competitor', 5),
forces.get('marketing_power', {}).get('competitor', 5),
forces.get('service_power', {}).get('competitor', 5),
forces.get('brand_power', {}).get('competitor', 5),
forces.get('financial_power', {}).get('competitor', 5),
]
competitor_name = db_data.get('competitor', 'PChome')
# 綜合評分(簡單平均)
momo_avg = sum(momo_scores) / 6
comp_avg = sum(comp_scores) / 6
diff = momo_avg - comp_avg
if diff > 1.0:
verdict_label, verdict_color = '整體領先', '2A7A3F'
elif diff > -1.0:
verdict_label, verdict_color = '勢均力敵', 'B88416'
else:
verdict_label, verdict_color = '整體落後', 'B5342F'
# 找出最大優勢力與最弱力
forces_labels = ['商品力', '價格力', '行銷力', '服務力', '品牌力', '財務力']
diffs = [m - c for m, c in zip(momo_scores, comp_scores)]
max_diff_idx = diffs.index(max(diffs))
min_diff_idx = diffs.index(min(diffs))
strongest = forces_labels[max_diff_idx]
weakest = forces_labels[min_diff_idx]
# ── P1: 封面 ──────────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, "8F4530")
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, "8F4530")
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, "8F4530")
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
_add_text(slide, f"COMPETITOR v4 · 5-FORCES · STRATEGIC OUTLOOK",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
_add_text(slide, f"競業五力分析\nmomo vs {competitor_name}",
3.8, 3.2, 25, 5.0,
bold=True, size=42, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, verdict_color)
_add_text(slide, f"綜合評估:{verdict_label}",
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
_add_text(slide,
f"期間 {period_label} · 綜合評分 momo {momo_avg:.1f} / 10 vs "
f"{competitor_name} {comp_avg:.1f} / 10 ({diff:+.1f})",
3.8, 8.7, 27, 0.85,
bold=True, size=14, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 三句話:最強力 / 最弱力 / 差異化建議
pitch_y = 10.2
_add_rect(slide, 3.8, pitch_y, 0.45, 1.5, "2A7A3F")
_add_text(slide, "💪 最大優勢力", 4.4, pitch_y + 0.1, 27, 0.55,
bold=True, size=11, color="2A7A3F",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"{strongest}(領先 {abs(diffs[max_diff_idx]):.1f} 分)— 持續加碼此武器擴大領先優勢",
4.4, pitch_y + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y2 = pitch_y + 1.9
_add_rect(slide, 3.8, pitch_y2, 0.45, 1.5, "B5342F")
_add_text(slide, "⚠ 最大劣勢力", 4.4, pitch_y2 + 0.1, 27, 0.55,
bold=True, size=11, color="B5342F",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"{weakest}(落後 {abs(diffs[min_diff_idx]):.1f} 分)— 需立即補強或差異化策略避戰",
4.4, pitch_y2 + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y3 = pitch_y2 + 1.9
_add_rect(slide, 3.8, pitch_y3, 0.45, 1.5, "B88416")
_add_text(slide, "🎯 戰略指引", 4.4, pitch_y3 + 0.1, 27, 0.55,
bold=True, size=11, color="B88416",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
"聚焦差異化武器(會員訂閱 / 富邦銀行折扣 / 電視購物頻道整合),"
"避開 PChome 強項戰場3C/家電 24h 到貨)",
4.4, pitch_y3 + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right", latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
# ── P2: 五力雷達圖 ────────────────────────────────────
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s2, f"五力綜合雷達 — momo vs {competitor_name}0-10 分)")
chart_w = 16.0
chart_h = 13.5
buf = _mpl_radar_png(forces_labels, momo_scores, comp_scores,
competitor_name=competitor_name,
total_width_cm=chart_w,
total_height_cm=chart_h,
title=f"{period_label} 五力綜合對比")
if buf:
_add_image_from_buf(s2, buf, 0.6, 1.95, chart_w, chart_h)
# 右側:五力分數明細
rx = 17.5
rw = W - 18.0
_add_rect(s2, rx, 1.95, rw, chart_h, _WHITE, line_hex=_SUBTLE)
_add_rect(s2, rx, 1.95, 0.35, chart_h, "8F4530")
_add_text(s2, "五力分數明細",
rx + 0.6, 2.1, rw - 0.7, 0.65,
bold=True, size=13, color=_DARK_TEXT,
ea_font=_FONT_BODY_EA)
_add_rect(s2, rx + 0.55, 2.85, rw - 0.85, 0.04, "8F4530")
top_y = 3.05
for i, label in enumerate(forces_labels):
m_s = momo_scores[i]
c_s = comp_scores[i]
d = m_s - c_s
d_color = "2A7A3F" if d > 0 else ("B5342F" if d < 0 else "9B9081")
d_arrow = "" if d > 0 else ("" if d < 0 else "")
_add_text(s2, label,
rx + 0.55, top_y, rw - 4.5, 0.5,
bold=True, size=11, color=_DARK_TEXT,
ea_font=_FONT_BODY_EA)
_add_text(s2, f"{d_arrow} {abs(d):.1f}",
rx + rw - 4.0, top_y - 0.05, 3.5, 0.55,
bold=True, size=14, color=d_color, align="right",
latin_font=_FONT_DISPLAY)
_add_text(s2, f"momo {m_s:.1f} · {competitor_name} {c_s:.1f}",
rx + 0.55, top_y + 0.5, rw - 0.85, 0.42,
size=10, color=_SUBTEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 雙條視覺化momo 焦糖橘 + 競品蜂蜜金)
bar_y = top_y + 0.95
bar_w_max = rw - 1.1
m_w = max(0.05, m_s / 10 * bar_w_max)
c_w = max(0.05, c_s / 10 * bar_w_max)
_add_rect(s2, rx + 0.55, bar_y, bar_w_max, 0.10, _SUBTLE)
_add_rect(s2, rx + 0.55, bar_y, m_w, 0.10, "C96442")
_add_rect(s2, rx + 0.55, bar_y + 0.13, bar_w_max, 0.08, _SUBTLE)
_add_rect(s2, rx + 0.55, bar_y + 0.13, c_w, 0.08, "B88416")
top_y += 1.7
_add_footer(s2, W)
# ── P3: 商品力 + 價格力 ─────────────────────────────────
def _double_card_slide(title_text, c1_title, c1_body, c1_color,
c2_title, c2_body, c2_color):
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, title_text)
col_w = (W - 1.2) / 2
col1_x = 0.4
col2_x = 0.4 + col_w + 0.4
for x, t, b, color in [(col1_x, c1_title, c1_body, c1_color),
(col2_x, c2_title, c2_body, c2_color)]:
_add_rect(s, x, 1.95, col_w, 13.4, _WHITE, line_hex=_SUBTLE)
_add_rect(s, x, 1.95, col_w, 0.85, color)
_add_text(s, t, x + 0.4, 2.05, col_w - 0.6, 0.65,
bold=True, size=13, color=_WHITE, valign="middle",
ea_font=_FONT_BODY_EA)
body_text = (b or '').strip() or "(暫無資料)"
_add_text(s, body_text,
x + 0.5, 3.0, col_w - 1.0, 12.2,
size=12, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s, W)
f = forces
_double_card_slide("① 商品力 + ② 價格力",
f"💎 商品力momo {f.get('product_power',{}).get('momo',0):.1f} / 10",
f.get('product_power', {}).get('analysis', '(資料待 mcp_collector 擴充競品 SKU 數)'),
"C96442",
f"💰 價格力momo {f.get('price_power',{}).get('momo',0):.1f} / 10",
f.get('price_power', {}).get('analysis', '(沿用既有 competitor 比價資料)'),
"B88416")
# ── P4: 行銷力 + 服務力 ─────────────────────────────────
_double_card_slide("③ 行銷力 + ④ 服務力",
f"📣 行銷力momo {f.get('marketing_power',{}).get('momo',0):.1f} / 10",
f.get('marketing_power', {}).get('analysis', '(待擴 mcp_collector 抓競品檔期/廣告動態)'),
"B5342F",
f"🚚 服務力momo {f.get('service_power',{}).get('momo',0):.1f} / 10",
f.get('service_power', {}).get('analysis', '靜態知識:免運門檻 / 到貨時效 / 退貨政策'),
"8A5A2B")
# ── P5: 品牌力 + 財務力 ─────────────────────────────────
_double_card_slide("⑤ 品牌力 + ⑥ 財務力",
f"⭐ 品牌力momo {f.get('brand_power',{}).get('momo',0):.1f} / 10",
f.get('brand_power', {}).get('analysis', '(從 mcp Dcard / Trends / YouTube 取訊號)'),
"8F4530",
f"💼 財務力momo {f.get('financial_power',{}).get('momo',0):.1f} / 10",
f.get('financial_power', {}).get('analysis',
'momo (8454):富邦集團、市值約 NT$1100 億、月營收公開觀測站可查\n'
'PChome (8044)上市櫃櫃買、3C 通路優勢\n'
'蝦皮:母公司 SEA Group (NYSE: SE) 全球佈局\n'
'酷澎:未上市,韓國總部,激進補貼策略'),
"2D5D80")
# ── P6: AI 戰略整合 ──────────────────────────────────────
_ai_insight_slide(prs, ai_text)
# ── P7: 附錄 ─────────────────────────────────────────────
_appendix_slide(prs, 'competitor_v4', f"momo vs {competitor_name}{period_label}")
path = _new_path("competitor_v4")
prs.save(path)
return path
# ── 價格彈性報告(價位甜蜜點)─────────────────────────────────────────
def generate_price_elasticity_ppt(db_data: dict, ai_text: str) -> str:
"""價格彈性簡化版報告 v3.1(採購/PM 定價策略)
P1 封面(含甜蜜點徽章)
P2 KPI總 SKU / 總訂單 / 甜蜜點區間 / 甜蜜點佔比)
P3 各價位桶銷量分布橫條matplotlib
P4 各價位桶業績分布橫條matplotlib
P5 甜蜜點 TOP 5 SKU明星價位代表商品
P6 AI 採購策略洞察(新品定價建議 / 高毛利價位扶植 / 低毛利下架)
P7 附錄
"""
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
cat = db_data.get('category', '全平台')
days = int(db_data.get('days', 90))
sku_count = int(db_data.get('sku_count', 0))
total_orders = int(db_data.get('total_orders', 0))
buckets = db_data.get('buckets', []) or []
sweet = db_data.get('sweet_spot', {}) or {}
top_sku_by_bucket = db_data.get('top_sku_by_bucket', {}) or {}
sweet_range = sweet.get('range', '')
sweet_ratio = float(sweet.get('ratio', 0))
sweet_avg = float(sweet.get('avg_price', 0))
# 集中度標籤
if sweet_ratio >= 50:
focus_label, focus_color = '價位過度集中', 'B5342F'
elif sweet_ratio >= 30:
focus_label, focus_color = '價位主流明確', 'B88416'
else:
focus_label, focus_color = '價位分布均衡', '2A7A3F'
# ── P1: 封面 ──────────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, _KPI_MAHOGANY)
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, _KPI_MAHOGANY)
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, _KPI_MAHOGANY)
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
_add_text(slide, "PRICE ELASTICITY · SWEET SPOT · AI INSIGHT",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
_add_text(slide, f"價格彈性報告\n{cat}(近 {days} 天)",
3.8, 3.2, 25, 5.0,
bold=True, size=42, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, focus_color)
_add_text(slide, focus_label,
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
_add_text(slide,
f"{sku_count} SKU · {total_orders:,} 訂單 · "
f"甜蜜點 {sweet_range}(佔 {sweet_ratio:.1f}% 訂單)",
3.8, 8.7, 27, 0.85,
bold=True, size=14, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 三句話結論
pitch_y = 10.2
_add_rect(slide, 3.8, pitch_y, 0.45, 1.5, "C96442")
_add_text(slide, "🎯 價格甜蜜點", 4.4, pitch_y + 0.1, 27, 0.55,
bold=True, size=11, color="C96442",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"{sweet_range}(平均售價 NT${sweet_avg:,.0f})— "
f"消費者最買單的價位帶;建議新品定價對齊此區間",
4.4, pitch_y + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y2 = pitch_y + 1.9
_add_rect(slide, 3.8, pitch_y2, 0.45, 1.5, "B88416")
_add_text(slide, "📊 分布健康度",
4.4, pitch_y2 + 0.1, 27, 0.55,
bold=True, size=11, color="B88416",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"{focus_label}(甜蜜點 {sweet_ratio:.1f}%)— "
+ ("過度依賴單一價位帶,需開發其他價位 SKU 分散風險"
if sweet_ratio >= 50 else
"價位主流明確,可加碼選品強化主流價位帶"
if sweet_ratio >= 30 else
"價位分布健康,已覆蓋多個消費層次"),
4.4, pitch_y2 + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y3 = pitch_y2 + 1.9
_add_rect(slide, 3.8, pitch_y3, 0.45, 1.5, "8F4530")
_add_text(slide, "💡 策略建議",
4.4, pitch_y3 + 0.1, 27, 0.55,
bold=True, size=11, color="8F4530",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
"新品定價首選甜蜜點區間 ±10%;高價位帶(>NT$5K若 SKU 不足,"
"建議引進高客單品牌提升結構毛利",
4.4, pitch_y3 + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right", latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
# ── P2 KPI ────────────────────────────────────────────
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s2, f"價格分布 KPI — {cat}(近 {days} 天)")
cards = [
(_KPI_CARAMEL, "SKU 總數", f"{sku_count}", None, "有交易商品"),
(_KPI_HONEY, "總訂單", f"{total_orders:,}", None, f"{days}"),
(_KPI_MAHOGANY, "甜蜜點價位", sweet_range, None, f"{sweet_ratio:.1f}% 訂單"),
(_KPI_EARTH, "甜蜜點 SKU 數", f"{sweet.get('sku_count', 0)}", None,
f"平均 NT${sweet_avg:,.0f}"),
]
for i, (col, lbl, val, dp, dl) in enumerate(cards):
_kpi_card_v2(s2, i * 7.8 + 0.5, 1.95, 7.4, 4.5,
col, lbl, val, delta_pct=dp, delta_label=dl, sub=dl)
summary_text = (ai_text or '')[:400] if ai_text else "(暫無 AI 分析)"
_add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, _KPI_MAHOGANY)
_add_text(s2, "💰 價格策略解讀",
1.1, 7.05, W - 1.5, 0.6, bold=True, size=13, color=_WHITE,
valign="middle", ea_font=_FONT_BODY_EA)
_add_rect(s2, 0.5, 7.7, W - 1.0, 6.4, _WHITE, line_hex=_SUBTLE)
_add_rect(s2, 0.5, 7.7, 0.4, 6.4, _KPI_MAHOGANY)
_add_text(s2, summary_text,
1.2, 7.95, W - 2.0, 5.9,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s2, W)
# ── P3: 各價位桶訂單分布橫條 ───────────────────────────
if buckets:
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s3, "各價位區間訂單分布(找甜蜜點)")
names = [b.get('range', '') for b in buckets]
# 訂單數 — 直接餵 _mpl_horiz_bar_png其單位是「萬」會除10000這裡訂單數小用原值乘 10000 避免縮放失真)
# 實作上簡化:直接用 _mpl_horiz_bar_png value_unit='筆' 但餵的是訂單 × 10000讓除完顯示正常數字
orders_scaled = [b.get('total_orders', 0) * 10000 for b in buckets]
chart_w = W - 0.8
chart_h = 11.0
buf = _mpl_horiz_bar_png(names, orders_scaled,
total_width_cm=chart_w,
total_height_cm=chart_h,
value_unit="",
title="各價位訂單數(焦糖橘=甜蜜點價位)",
highlight_top_n=1)
if buf:
_add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h)
# 結論帶
_add_rect(s3, 0.4, 13.3, W - 0.8, 2.0, _BRAND_OG2)
_add_text(s3,
f"🎯 甜蜜點:{sweet_range}{sweet.get('total_orders', 0):,} 訂單,"
f"佔總訂單 {sweet_ratio:.1f}%\n\n"
f"💡 採購建議新品定價優先對齊此區間±10%"
f"次選擇較鄰近高價位帶試水溫提升客單",
0.7, 13.5, W - 1.4, 1.7,
bold=True, size=12, color=_WHITE, valign="middle", wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s3, W)
# ── P4: 各價位桶業績分布 ───────────────────────────────
if buckets:
s4 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s4, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s4, "各價位區間業績分布(高價帶健康度)")
names = [b.get('range', '') for b in buckets]
revs = [float(b.get('total_revenue', 0)) for b in buckets]
chart_w = W - 0.8
chart_h = 11.0
buf = _mpl_horiz_bar_png(names, revs,
total_width_cm=chart_w,
total_height_cm=chart_h,
value_unit="",
title="各價位業績排行(業績 ≠ 訂單,反映客單帶結構)",
highlight_top_n=3)
if buf:
_add_image_from_buf(s4, buf, 0.4, 1.95, chart_w, chart_h)
# 計算高價帶(>NT$2K佔比
high_rev = sum(b.get('total_revenue', 0) for b in buckets
if any(k in b.get('range', '') for k in ['NT$2K', 'NT$5K', '> NT$10K']))
total_r = sum(b.get('total_revenue', 0) for b in buckets) or 1
high_pct = high_rev / total_r * 100
_add_rect(s4, 0.4, 13.3, W - 0.8, 2.0, _BRAND_OG2)
_add_text(s4,
f"📊 高價帶(>NT$2K業績佔比 {high_pct:.1f}% · "
f"健康業界基準30-50%\n\n"
f"💡 建議:" + (
"高價帶業績佔比偏低,需引進高客單 SKU 提升結構毛利"
if high_pct < 25 else
"高價帶結構健康,可繼續加碼高毛利精選品"
if high_pct < 50 else
"高價帶過度集中,需注意中低價市場佈局"
),
0.7, 13.5, W - 1.4, 1.7,
bold=True, size=12, color=_WHITE, valign="middle", wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s4, W)
# ── P5: 甜蜜點 TOP 5 SKU ───────────────────────────────
sweet_top = top_sku_by_bucket.get(sweet_range, []) if sweet_range else []
if sweet_top:
s5 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s5, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s5, f"甜蜜點 {sweet_range} TOP 5 代表商品")
_add_rect(s5, 0.4, 1.95, W - 0.8, 0.7, _BRAND_OG)
_add_text(s5, f"這些商品在「{sweet_range}」價位帶帶動最多訂單 — 新品設計可參考",
0.7, 2.05, W - 1.4, 0.6,
bold=True, size=12, color=_WHITE, valign="middle",
ea_font=_FONT_BODY_EA)
for i, sku in enumerate(sweet_top[:5]):
row_y = 3.0 + i * 1.95
_add_rect(s5, 0.4, row_y, W - 0.8, 1.85, _WHITE, line_hex=_SUBTLE)
_add_rect(s5, 0.55, row_y + 0.1, 0.95, 0.95, _BRAND_OG)
_add_text(s5, str(i + 1), 0.55, row_y + 0.1, 0.95, 0.95,
bold=True, size=14, color=_WHITE,
align="center", valign="middle", latin_font=_FONT_DISPLAY)
_add_text(s5, str(sku.get('name', ''))[:50],
1.7, row_y + 0.15, W - 12, 0.6,
bold=True, size=12, color=_DARK_TEXT,
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
_add_text(s5,
f"品類:{sku.get('cat', '')[:10]} · "
f"平均售價 NT${sku.get('avg_price', 0):,.0f} · "
f"30 天銷量 {sku.get('qty', 0):,}",
1.7, row_y + 0.85, W - 12, 0.6,
size=10, color=_SUBTEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(s5, f"NT${float(sku.get('rev', 0)):,.0f}",
W - 9.5, row_y + 0.4, 8.5, 0.7,
bold=True, size=14, color=_BRAND_OG, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(s5, f"{sku.get('orders', 0):,} 訂單",
W - 9.5, row_y + 1.1, 8.5, 0.5,
size=10, color=_SUBTEXT, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s5, W)
# ── P6 AI 洞察 ─────────────────────────────────────────
_ai_insight_slide(prs, ai_text)
# ── P7 附錄 ────────────────────────────────────────────
_appendix_slide(prs, 'price_elasticity', f"{cat}(近 {days} 天)")
path = _new_path("price_elasticity")
prs.save(path)
return path
# ── 市場情報週報(外部資料彙整)────────────────────────────────────────
def generate_market_intel_weekly_ppt(week_label: str, db_data: dict, ai_text: str) -> str:
"""市場情報週報 v3.1CEO/BU 主管 / 行銷主管 三方共讀)
將 mcp_collector 的所有外部資料彙整成內部參考簡報
P1 封面:本週市場大事三句話
P2 節慶/檔期日曆(當週 + 下兩週)
P3 季節情境與消費行為趨勢
P4 電商新聞動態Gemini Grounding
P5 Google Trends 熱搜 + Dcard 口碑
P6 YouTube 熱門商品
P7 天氣與匯率影響
P8 AI 整合洞察與行動建議
P9 附錄
"""
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
sections = db_data or {}
# ── P1 封面 ───────────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, _KPI_HONEY)
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, _KPI_HONEY)
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, _KPI_HONEY)
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
_add_text(slide, "MARKET INTELLIGENCE WEEKLY · EXTERNAL SIGNALS",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
_add_text(slide, f"市場情報週報\n{week_label}",
3.8, 3.2, 25, 5.0,
bold=True, size=42, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, _KPI_HONEY)
_add_text(slide, "外部信號整合",
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
_add_text(slide,
f"來源:節慶日曆 · Google Trends · Dcard · YouTube · "
f"電商新聞 · 天氣 · 匯率",
3.8, 8.7, 27, 0.85,
size=12, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 三句話本週要點(從 AI text 抽前三段)
ai_lines = [l.strip() for l in (ai_text or '').split('\n')
if l.strip() and not l.strip().startswith(('', '', ''))][:3]
pitch_y = 10.2
for i, (color, label) in enumerate([
("C96442", "🎯 本週重點"),
("B88416", "📊 市場機會"),
("8F4530", "⚠ 風險警訊"),
]):
py = pitch_y + i * 1.9
_add_rect(slide, 3.8, py, 0.45, 1.5, color)
_add_text(slide, label, 4.4, py + 0.1, 27, 0.55,
bold=True, size=11, color=color,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
body = ai_lines[i] if i < len(ai_lines) else "(待 AI 補充)"
_add_text(slide, body[:100],
4.4, py + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right", latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
# 內容卡片頁 helper每頁 2 卡)
def _intel_double_card(prs, title_text, card1_title, card1_body, card1_color,
card2_title, card2_body, card2_color):
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, title_text)
col_w = (W - 1.2) / 2
col1_x = 0.4
col2_x = 0.4 + col_w + 0.4
for x, t, b, color in [(col1_x, card1_title, card1_body, card1_color),
(col2_x, card2_title, card2_body, card2_color)]:
_add_rect(s, x, 1.95, col_w, 13.4, _WHITE, line_hex=_SUBTLE)
_add_rect(s, x, 1.95, col_w, 0.85, color)
_add_text(s, t, x + 0.4, 2.05, col_w - 0.6, 0.65,
bold=True, size=13, color=_WHITE, valign="middle",
ea_font=_FONT_BODY_EA)
body_text = (b or '').strip() or "(暫無資料)"
_add_text(s, body_text,
x + 0.5, 3.0, col_w - 1.0, 12.2,
size=12, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s, W)
# ── P2: 節慶日曆 + 季節情境 ──────────────────────────
_intel_double_card(prs, "📅 節慶檔期 + 季節情境",
"🎉 本月+下月關鍵檔期",
sections.get('holiday', '(暫無檔期資料)'),
_BRAND_OG,
"🌿 季節消費情境",
sections.get('seasonal', '(暫無季節資料)'),
_KPI_MAHOGANY)
# ── P3: 電商新聞 + Google Trends ────────────────────
_intel_double_card(prs, "📰 電商新聞 + 🔥 Google 熱搜",
"📰 電商產業新聞",
sections.get('ecommerce_news', '(無資料)'),
_KPI_HONEY,
"🔥 Google 台灣熱搜",
sections.get('google_trends', '(無資料)'),
_BRAND_OG)
# ── P4: Dcard + YouTube ─────────────────────────────
_intel_double_card(prs, "💬 Dcard 口碑 + ▶️ YouTube 熱門",
"💬 Dcard 熱門討論",
sections.get('dcard', '(無資料)'),
"8F4530",
"▶️ YouTube 爆紅商品",
sections.get('youtube', '(無資料)'),
_KPI_EARTH)
# ── P5: 天氣 + 匯率 ──────────────────────────────────
_intel_double_card(prs, "🌤 天氣 + 💱 匯率(影響消費行為)",
"🌤 台灣近日天氣",
sections.get('weather', '(無資料)'),
"2D5D80",
"💱 台幣匯率(跨境採購成本)",
sections.get('exchange', '(無資料)'),
"2A7A3F")
# ── P6: AI 洞察 ───────────────────────────────────────
_ai_insight_slide(prs, ai_text)
# ── P7: 附錄 ──────────────────────────────────────────
_appendix_slide(prs, 'market_intel_weekly', week_label)
path = _new_path("market_intel")
prs.save(path)
return path
# ── 新品 30 天追蹤報告 ──────────────────────────────────────────────────
def generate_new_product_ppt(db_data: dict, ai_text: str) -> str:
"""新品 30 天追蹤報告 v3.1PM/採購用)
P1 封面:含新品數徽章 + 業績佔比
P2 KPI 摘要 + 業績佔比評估
P3 新品整體日業績曲線(爬榜軌跡)
P4 新品依品類分佈(橫條 + 業績/SKU 數雙軸概念)
P5-P7 新品 TOP 50 列表(自動分頁,含品類)
P8 AI PM 戰術洞察(明星新品 / 該扶植 / 該下架)
P9 附錄
"""
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
period = db_data.get('period', '')
kpis = db_data.get('kpis', {}) or {}
new_prods = db_data.get('new_products', []) or []
sub_cats = db_data.get('sub_categories', []) or []
daily_total = db_data.get('daily_total', []) or []
new_count = int(kpis.get('new_count', 0))
new_rev = float(kpis.get('new_revenue', 0))
total_rev = float(kpis.get('total_revenue', 0))
new_pct = float(kpis.get('new_pct', 0))
# 新品強度徽章
if new_pct >= 8:
strength_label, strength_color = '新品力強勁', '2A7A3F'
elif new_pct >= 3:
strength_label, strength_color = '新品力穩健', 'B88416'
elif new_pct >= 1:
strength_label, strength_color = '新品力偏弱', 'C96442'
else:
strength_label, strength_color = '新品力疲弱', 'B5342F'
# ── P1 封面 ──────────────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, "2A7A3F")
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, "2A7A3F")
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, "2A7A3F")
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
_add_text(slide, "NEW PRODUCT · 30-DAY TRACKING · AI INSIGHT",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
_add_text(slide, f"新品追蹤報告\n{period}",
3.8, 3.2, 25, 5.0,
bold=True, size=42, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, strength_color)
_add_text(slide, f"新品力:{strength_label}",
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
_add_text(slide,
f"🆕 {new_count} 款新品 · 業績 NT${new_rev/10000:.1f}"
f"(佔總業績 {new_pct:.1f}%",
3.8, 8.7, 27, 0.85,
bold=True, size=14, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 三個亮點
if new_prods:
top1 = new_prods[0]
pitch_y = 10.2
_add_rect(slide, 3.8, pitch_y, 0.45, 1.5, "2A7A3F")
_add_text(slide, "🏆 最強新品",
4.4, pitch_y + 0.1, 27, 0.55,
bold=True, size=11, color="2A7A3F",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"{top1.get('name','')[:40]} "
f"NT${float(top1.get('revenue',0)):,.0f}{top1.get('category','')}",
4.4, pitch_y + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
if sub_cats:
top_cat = sub_cats[0]
pitch_y2 = 12.1
_add_rect(slide, 3.8, pitch_y2, 0.45, 1.5, "B88416")
_add_text(slide, "📊 新品集中品類",
4.4, pitch_y2 + 0.1, 27, 0.55,
bold=True, size=11, color="B88416",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"{top_cat.get('name','')[:30]} "
f"{top_cat.get('sku_count', 0)} 款新品 "
f"業績 NT${top_cat.get('revenue', 0)/10000:.1f}",
4.4, pitch_y2 + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y3 = 14.0
_add_rect(slide, 3.8, pitch_y3, 0.45, 1.5, "C96442")
_add_text(slide, "🎯 業績佔比評估",
4.4, pitch_y3 + 0.1, 27, 0.55,
bold=True, size=11, color="C96442",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"新品 {new_pct:.1f}% — " + (
"業界一流(>8%" if new_pct >= 8 else
"健康3-8%" if new_pct >= 3 else
"需強化(<3%"
) + ";建議目標 5-10%(電商業界平均)",
4.4, pitch_y3 + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right", latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
# ── P2 KPI ────────────────────────────────────────────
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s2, f"新品追蹤 KPI — {period}")
avg_rev = new_rev / new_count if new_count else 0
cards = [
(_KPI_CARAMEL, "新品總數", f"{new_count}", None, "近 30 天進榜"),
(_KPI_HONEY, "新品業績", f"NT${new_rev/10000:.1f}", None, "30 天累積"),
(_KPI_MAHOGANY, "業績佔比", f"{new_pct:.1f}%", None, "vs 整體業績"),
(_KPI_EARTH, "新品平均", f"NT${avg_rev/10000:.1f}", None, "單品 30 天均值"),
]
for i, (col, lbl, val, dp, dl) in enumerate(cards):
_kpi_card_v2(s2, i * 7.8 + 0.5, 1.95, 7.4, 4.5,
col, lbl, val, delta_pct=dp, delta_label=dl, sub=dl)
summary_text = (ai_text or '')[:400] if ai_text else "(暫無 AI 分析)"
_add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, "2A7A3F")
_add_text(s2, "🆕 新品力解讀",
1.1, 7.05, W - 1.5, 0.6, bold=True, size=13, color=_WHITE,
valign="middle", ea_font=_FONT_BODY_EA)
_add_rect(s2, 0.5, 7.7, W - 1.0, 6.4, _WHITE, line_hex=_SUBTLE)
_add_rect(s2, 0.5, 7.7, 0.4, 6.4, "2A7A3F")
_add_text(s2, summary_text,
1.2, 7.95, W - 2.0, 5.9,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s2, W)
# ── P3 新品整體日業績曲線 ───────────────────────────────
if daily_total:
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s3, "新品整體日業績走勢(爬榜軌跡)")
d_dates = [d.get('date', '') for d in daily_total]
d_revs = [float(d.get('revenue', 0)) for d in daily_total]
chart_w = W - 0.8
chart_h = 12.5
buf = _mpl_line_chart_png(
d_dates, d_revs, prev_vals=None,
total_width_cm=chart_w, total_height_cm=chart_h,
title="新品 30 天日業績走勢",
curr_label="新品合計"
)
if buf:
_add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h)
_add_footer(s3, W)
# ── P4 新品依品類分佈 ────────────────────────────────
if sub_cats:
s4 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s4, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s4, "新品依品類分佈")
names = [c.get('name', '')[:14] for c in sub_cats[:8]]
revs = [float(c.get('revenue', 0)) for c in sub_cats[:8]]
chart_w = W - 0.8
chart_h = 11.5
buf = _mpl_horiz_bar_png(names, revs,
total_width_cm=chart_w,
total_height_cm=chart_h,
value_unit="",
title="新品業績排行(依品類)",
highlight_top_n=3)
if buf:
_add_image_from_buf(s4, buf, 0.4, 1.95, chart_w, chart_h)
# 底部結論
_add_rect(s4, 0.4, 14.0, W - 0.8, 1.4, _BRAND_OG2)
cat_summary = ' · '.join(
f"{c.get('name','')[:8]} {c.get('sku_count',0)}"
for c in sub_cats[:5]
)
_add_text(s4, f"📊 品類新品數:{cat_summary}",
0.7, 14.15, W - 1.4, 1.1,
bold=True, size=12, color=_WHITE, valign="middle", wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s4, W)
# ── P5-P7 新品 TOP 50 ──────────────────────────────────
_product_table_slide(prs, f"新品 TOP {min(50, len(new_prods))}{period}",
new_prods, max_items=50)
# ── P8 AI 洞察 ─────────────────────────────────────────
_ai_insight_slide(prs, ai_text)
# ── P9 附錄 ────────────────────────────────────────────
_appendix_slide(prs, 'new_product', period)
path = _new_path("new_product")
prs.save(path)
return path
# ── 多活動 ROI 橫向比較報告 ─────────────────────────────────────────────
def generate_promo_compare_ppt(label: str, db_data: dict, ai_text: str) -> str:
"""多活動 ROI 比較報告2-N 個促銷活動並排比較
db_data: {
promos: [{label, start, end, days, revenue, orders, margin, rev_lift, ord_lift}, ...],
rankings: {best_revenue, best_lift, best_margin, worst_lift},
}
P1 封面(含活動數徽章)
P2 並排 KPI 表(活動 × 業績/訂單/毛利/拉抬)
P3 業績拉抬橫條matplotlib活動間排序
P4 AI 跨活動洞察
P5 附錄
"""
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
promos = db_data.get('promos', []) or []
rankings = db_data.get('rankings', {}) or {}
# ── P1 封面 ──────────────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, _BRAND_OG)
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, _BRAND_OG)
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, _BRAND_OG)
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
_add_text(slide, "MULTI-PROMO ROI COMPARISON · AI INSIGHT",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
_add_text(slide, f"促銷活動橫向比較\n{label}",
3.8, 3.2, 25, 5.0,
bold=True, size=42, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, _BRAND_OG2)
_add_text(slide, f"比較 {len(promos)} 場活動",
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
# 排名亮點
pitch_y = 9.5
if rankings.get('best_lift'):
_add_rect(slide, 3.8, pitch_y, 0.45, 1.5, "2A7A3F")
_add_text(slide, "🏆 最高拉抬",
4.4, pitch_y + 0.1, 27, 0.55,
bold=True, size=11, color="2A7A3F",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
bl = rankings['best_lift']
_add_text(slide,
f"{bl.get('label','')} — 業績拉抬 +{bl.get('rev_lift',0):.1f}%vs 對比期)",
4.4, pitch_y + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y2 = pitch_y + 1.9
if rankings.get('worst_lift'):
_add_rect(slide, 3.8, pitch_y2, 0.45, 1.5, "B5342F")
_add_text(slide, "⚠ 最低拉抬(需檢討)",
4.4, pitch_y2 + 0.1, 27, 0.55,
bold=True, size=11, color="B5342F",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
wl = rankings['worst_lift']
_add_text(slide,
f"{wl.get('label','')} — 業績拉抬 {wl.get('rev_lift',0):+.1f}%",
4.4, pitch_y2 + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y3 = pitch_y2 + 1.9
if rankings.get('best_margin'):
_add_rect(slide, 3.8, pitch_y3, 0.45, 1.5, "B88416")
_add_text(slide, "💰 最佳毛利",
4.4, pitch_y3 + 0.1, 27, 0.55,
bold=True, size=11, color="B88416",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
bm = rankings['best_margin']
_add_text(slide,
f"{bm.get('label','')} — 毛利率 {bm.get('margin',0):.1f}%(活動期)",
4.4, pitch_y3 + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right", latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
# ── P2: 並排 KPI 表 ──────────────────────────────────────
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s2, f"促銷活動 KPI 並排比較 — {len(promos)}")
# 表頭
tbl_y = 2.0
_add_rect(s2, 0.4, tbl_y, W - 0.8, 0.75, _BRAND_OG)
cols = [('活動名稱', 8.5, 'left'), ('期間', 5.5, 'center'),
('天數', 1.8, 'center'), ('業績', 4.5, 'right'),
('訂單', 3.0, 'right'), ('毛利率', 2.5, 'center'),
('業績拉抬', 3.0, 'center'), ('訂單拉抬', 3.0, 'center')]
cx = 0.5
for label_h, w, al in cols:
_add_text(s2, label_h, cx, tbl_y + 0.1, w, 0.55,
bold=True, size=10, color=_WHITE, align=al,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
cx += w + 0.05
for i, p in enumerate(promos[:14]):
row_y = tbl_y + 0.85 + i * 0.78
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
_add_rect(s2, 0.4, row_y, W - 0.8, 0.74, bg)
cx = 0.5
rev_lift = float(p.get('rev_lift', 0))
ord_lift = float(p.get('ord_lift', 0))
rev_color = "2A7A3F" if rev_lift > 0 else "B5342F"
ord_color = "2A7A3F" if ord_lift > 0 else "B5342F"
margin = float(p.get('margin', 0))
margin_color = "2A7A3F" if margin >= 12 else ("B88416" if margin >= 8 else "B5342F")
cells = [
(str(p.get('label', ''))[:25], 'left', _DARK_TEXT, False),
(f"{p.get('start', '')[5:]}~{p.get('end', '')[5:]}", 'center', _SUBTEXT, False),
(f"{p.get('days', 0)}", 'center', _DARK_TEXT, False),
(f"NT${float(p.get('revenue', 0))/10000:.1f}", 'right', _DARK_TEXT, True),
(f"{int(p.get('orders', 0)):,}", 'right', _DARK_TEXT, False),
(f"{margin:.1f}%", 'center', margin_color, True),
(f"{rev_lift:+.1f}%", 'center', rev_color, True),
(f"{ord_lift:+.1f}%", 'center', ord_color, True),
]
for (txt, al, col, bold), (_, w, _) in zip(cells, cols):
_add_text(s2, txt, cx, row_y + 0.12, w, 0.55,
bold=bold, size=10, color=col, align=al,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
cx += w + 0.05
_add_footer(s2, W)
# ── P3: 業績拉抬橫條 ──────────────────────────────────────
if promos:
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s3, "業績拉抬幅度排行vs 對比期)")
sorted_p = sorted(promos, key=lambda x: float(x.get('rev_lift', 0)), reverse=True)
names = [str(p.get('label', ''))[:20] for p in sorted_p[:12]]
# 拉抬 % 直接當數值matplotlib helper 會除 10000這裡用 raw
lifts = [float(p.get('rev_lift', 0)) * 10000 for p in sorted_p[:12]]
chart_w = W - 0.8
chart_h = 12.0
buf = _mpl_horiz_bar_png(names, lifts,
total_width_cm=chart_w,
total_height_cm=chart_h,
value_unit="%",
title="業績拉抬 % 排行(焦糖橘=TOP3",
highlight_top_n=3)
if buf:
_add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h)
_add_footer(s3, W)
# ── P4: AI 洞察 ──────────────────────────────────────────
_ai_insight_slide(prs, ai_text)
# ── P5: 附錄 ─────────────────────────────────────────────
_appendix_slide(prs, 'promo_compare', label)
path = _new_path("promo_compare")
prs.save(path)
return path
# ── 檔期前瞻報告 ───────────────────────────────────────────────────────
def generate_forecast_pre_event_ppt(event_name: str, event_date: str,
db_data: dict, ai_text: str) -> str:
"""檔期前瞻報告 v3.1:給 BU 主管在檔期前 14 天決定備戰策略
P1 封面(含倒數天數徽章 + 預期業績 vs baseline
P2 KPI 三段對比:去年同檔期 / baseline / 本期準備窗口
P3 去年同檔期業績曲線 + 本期 baseline 對比
P4 庫存盤點TOP 30 商品(基於 baseline 期)
P5 AI 戰術洞察(檔期戰術、廣告投放、庫存補貨、競品阻擊)
P6 附錄
"""
from pptx import Presentation
from pptx.util import Cm
from datetime import datetime as _dt
prs = Presentation()
prs.slide_width = Cm(33.87)
prs.slide_height = Cm(19.05)
W = 33.87
baseline = db_data.get('baseline', {}) or {}
last_year = db_data.get('last_year', {}) or {}
prep_window = db_data.get('prep_window', {}) or {}
top_prods = db_data.get('top_products', []) or []
forecast = db_data.get('forecast', {}) or {}
window_start = db_data.get('window_start', '')
window_end = db_data.get('window_end', '')
expected_rev = float(forecast.get('expected_revenue', 0))
lift = float(forecast.get('lift_factor', 1.0))
b_daily = float(baseline.get('avg_daily_revenue', 0))
ly_rev = float(last_year.get('revenue', 0))
# 倒數天數
try:
ev_d = _dt.strptime(event_date.replace('/', '-'), '%Y-%m-%d').date()
days_to_event = (ev_d - _dt.now().date()).days
except Exception:
days_to_event = 0
# 定位徽章
if days_to_event > 7:
urgency_label, urgency_color = '備戰期', '2A7A3F'
elif days_to_event > 0:
urgency_label, urgency_color = '衝刺期', 'B88416'
elif days_to_event >= -7:
urgency_label, urgency_color = '檔期中', 'C96442'
else:
urgency_label, urgency_color = '已結束', '9B9081'
# ── P1: 封面 ──────────────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, urgency_color)
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, urgency_color)
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, urgency_color)
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
_add_text(slide, "PRE-EVENT FORECAST · AI BATTLE PLAN",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
_add_text(slide, f"檔期前瞻報告\n{event_name}",
3.8, 3.2, 25, 5.0,
bold=True, size=42, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, urgency_color)
if days_to_event > 0:
urgency_text = f"還有 {days_to_event} 天 · {urgency_label}"
elif days_to_event >= -7:
urgency_text = f"檔期中 · 第 {abs(days_to_event)+1}"
else:
urgency_text = f"檔期已過 {abs(days_to_event)}"
_add_text(slide, urgency_text,
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
_add_text(slide,
f"檔期日 {event_date} · 準備窗口 {window_start} ~ {window_end}",
3.8, 8.7, 27, 0.85,
bold=True, size=14, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 預期 vs baseline
pitch_y = 10.2
_add_rect(slide, 3.8, pitch_y, 0.45, 1.5, "C96442")
_add_text(slide, "🎯 本期預期業績", 4.4, pitch_y + 0.1, 27, 0.55,
bold=True, size=11, color="C96442",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"NT${expected_rev/10000:.1f}"
+ (f"baseline NT${b_daily/10000:.1f}萬/日 × {(window_end and 21) or 21}× {lift:.2f} 倍)"
if b_daily else ''),
4.4, pitch_y + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y2 = pitch_y + 1.9
_add_rect(slide, 3.8, pitch_y2, 0.45, 1.5, "B88416")
_add_text(slide, "📅 去年同檔期業績(基準)",
4.4, pitch_y2 + 0.1, 27, 0.55,
bold=True, size=11, color="B88416",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
if ly_rev > 0:
ly_compare_pct = (expected_rev - ly_rev) / ly_rev * 100 if ly_rev else 0
ly_arrow = "" if ly_compare_pct > 0 else ""
_add_text(slide,
f"去年同期 NT${ly_rev/10000:.1f}萬 → 本期預期 {ly_arrow} {abs(ly_compare_pct):.1f}%",
4.4, pitch_y2 + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
else:
_add_text(slide, "去年同檔期無資料 — 預測信心度較低",
4.4, pitch_y2 + 0.7, 27, 0.75,
size=12, color=_SUBTEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 已執行 vs 目標
prep_rev = float(prep_window.get('revenue', 0))
pitch_y3 = pitch_y2 + 1.9
_add_rect(slide, 3.8, pitch_y3, 0.45, 1.5, "2A7A3F")
_add_text(slide, "✅ 本期準備窗口已執行",
4.4, pitch_y3 + 0.1, 27, 0.55,
bold=True, size=11, color="2A7A3F",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"已過 {prep_window.get('days_passed', 0)} / {prep_window.get('days_total', 0)}"
f" · 累積業績 NT${prep_rev/10000:.1f}"
+ (f"(達成預期 {prep_rev/expected_rev*100:.1f}%" if expected_rev else ''),
4.4, pitch_y3 + 0.7, 27, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right", latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
# ── P2: 三段業績 KPI 對比 ─────────────────────────────────
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s2, f"檔期業績三段對比 — {event_name}")
cards = [
(_KPI_CARAMEL, "本期預期業績", f"NT${expected_rev/10000:.1f}",
f"baseline × {lift:.2f}"),
(_KPI_HONEY, "去年同檔期", f"NT${ly_rev/10000:.1f}",
last_year.get('period', '')[:25]),
(_KPI_MAHOGANY, "Baseline 日均", f"NT${b_daily/10000:.1f}",
f"({baseline.get('days', 0)} 天均值)"),
(_KPI_EARTH, "已執行業績", f"NT${prep_rev/10000:.1f}",
f"{prep_window.get('days_passed', 0)}/{prep_window.get('days_total', 0)}"),
]
for i, (col, lbl, val, sub) in enumerate(cards):
_kpi_card_v2(s2, i * 7.8 + 0.5, 1.95, 7.4, 4.5,
col, lbl, val, delta_pct=None, delta_label=sub, sub=sub)
# AI 解讀
summary_text = (ai_text or '')[:400] if ai_text else "(暫無 AI 分析)"
_add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, urgency_color)
_add_text(s2, f"🎯 {event_name} 戰術解讀",
1.1, 7.05, W - 1.5, 0.6, bold=True, size=13, color=_WHITE,
valign="middle", ea_font=_FONT_BODY_EA)
_add_rect(s2, 0.5, 7.7, W - 1.0, 6.4, _WHITE, line_hex=_SUBTLE)
_add_rect(s2, 0.5, 7.7, 0.4, 6.4, urgency_color)
_add_text(s2, summary_text,
1.2, 7.95, W - 2.0, 5.9,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s2, W)
# ── P3: 去年同檔期業績曲線 ────────────────────────────────
ly_daily = last_year.get('daily', [])
if ly_daily:
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s3, f"去年同檔期業績曲線 — 預測基準")
d_dates = [d.get('date', '') for d in ly_daily]
d_revs = [float(d.get('revenue', 0)) for d in ly_daily]
chart_w = W - 0.8
chart_h = 12.5
buf = _mpl_line_chart_png(
d_dates, d_revs, prev_vals=None,
total_width_cm=chart_w, total_height_cm=chart_h,
title=f"去年同檔期 {last_year.get('period', '')} 日業績曲線",
curr_label="去年同期"
)
if buf:
_add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h)
# 結論帶
if d_revs:
avg_d = sum(d_revs) / len(d_revs)
max_d = max(d_revs)
_add_rect(s3, 0.4, 14.7, W - 0.8, 1.0, _BRAND_OG2)
_add_text(s3,
f"📊 去年同檔期日均 NT${avg_d/10000:.1f}萬 · "
f"最高單日 NT${max_d/10000:.1f}萬 · "
f"本期預期 baseline × {lift:.2f}",
0.7, 14.85, W - 1.4, 0.7,
bold=True, size=12, color=_WHITE, valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s3, W)
# ── P4: 庫存盤點baseline 期 TOP 30─────────────────────
if top_prods:
_product_table_slide(prs,
f"庫存盤點對象 TOP {min(30, len(top_prods))} — 基於 baseline 期銷量",
top_prods, max_items=30)
# ── P5: AI 戰術洞察 ─────────────────────────────────────
_ai_insight_slide(prs, ai_text)
# ── P6: 附錄 ─────────────────────────────────────────────
_appendix_slide(prs, 'forecast_pre_event', f"{event_name} ({event_date})")
path = _new_path("forecast_pre_event")
prs.save(path)
return path
# ── 客戶/訂單分析報告(簡化版 RFM────────────────────────────────────────
def generate_customer_analytics_ppt(period_label: str, db_data: dict, ai_text: str) -> str:
"""客戶/訂單分析報告 v3.1(行銷主管用)
P1 封面(含訂單規模徽章)
P2 KPI 摘要(總訂單/總業績/AOV
P3 客單價分佈(橫條 + 訂單數佔比)
P4 星期分佈(柱狀,找消費熱點)
P5 商品復購 TOP 30自動分頁
P6 AI 行銷洞察
P7 附錄
"""
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
kpis = db_data.get('kpis', {}) or {}
aov_buckets = db_data.get('aov_buckets', []) or []
weekday = db_data.get('weekday_dist', []) or []
repeat_prods = db_data.get('repeat_products', []) or []
total_orders = int(kpis.get('total_orders', 0))
total_rev = float(kpis.get('total_revenue', 0))
aov = float(kpis.get('aov', 0))
if aov >= 1500:
scale_label, scale_color = '高客單市場', '2A7A3F'
elif aov >= 800:
scale_label, scale_color = '中客單市場', 'B88416'
else:
scale_label, scale_color = '低客單市場', 'C96442'
# ── P1: 封面 ────────────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, _BRAND_OG)
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, _BRAND_OG)
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, _BRAND_OG)
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
_add_text(slide, "CUSTOMER · ORDER ANALYTICS · AI INSIGHT",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
_add_text(slide, f"客戶與訂單分析\n{period_label}",
3.8, 3.2, 25, 5.0,
bold=True, size=42, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, scale_color)
_add_text(slide, f"客單定位:{scale_label}",
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
_add_text(slide,
f"訂單 {total_orders:,} 筆 · 業績 NT${total_rev/10000:.1f}"
f" · 平均客單 NT${aov:,.0f}",
3.8, 8.7, 27, 0.85,
bold=True, size=14, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 注意條
_add_rect(slide, 3.8, 11.0, W - 7.5, 1.5, _SUBTLE)
_add_text(slide, " 本報告以訂單級分析為主(無 user_id 資料層支援,無法做完整 RFM 分群)",
4.0, 11.1, W - 7.9, 0.5,
size=10, color=_SUBTEXT,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, "→ 若日後加入會員系統 user_id可升級為完整 R/F/M 11-persona 分群報告",
4.0, 11.7, W - 7.9, 0.7,
size=11, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right", latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
# ── P2: KPI ────────────────────────────────────────────
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s2, f"客戶與訂單 KPI — {period_label}")
high_orders = sum(b.get('count', 0) for b in aov_buckets
if b.get('range', '').startswith(('NT$5K', '> NT$10K')))
high_pct = high_orders / total_orders * 100 if total_orders else 0
repeat_count = len(repeat_prods)
kpi_v2 = [
(_KPI_CARAMEL, "總訂單數", f"{total_orders:,}", None, period_label),
(_KPI_HONEY, "總業績", f"NT${total_rev/10000:.1f}", None, ""),
(_KPI_MAHOGANY, "平均客單", f"NT${aov:,.0f}", None, scale_label),
(_KPI_EARTH, "高客單訂單", f"{high_orders:,}", None, f"{high_pct:.1f}%"),
]
for i, (col, lbl, val, dp, dl) in enumerate(kpi_v2):
_kpi_card_v2(s2, i * 7.8 + 0.5, 1.95, 7.4, 4.5,
col, lbl, val, delta_pct=dp, delta_label=dl, sub=dl)
summary_text = (ai_text or '')[:400] if ai_text else "(暫無 AI 分析)"
_add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, _BRAND_OG)
_add_text(s2, "💡 客戶行為解讀",
1.1, 7.05, W - 1.5, 0.6, bold=True, size=13, color=_WHITE,
valign="middle", ea_font=_FONT_BODY_EA)
_add_rect(s2, 0.5, 7.7, W - 1.0, 6.4, _WHITE, line_hex=_SUBTLE)
_add_rect(s2, 0.5, 7.7, 0.4, 6.4, _BRAND_OG)
_add_text(s2, summary_text,
1.2, 7.95, W - 2.0, 5.9,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s2, W)
# ── P3: 客單分佈 ──────────────────────────────────────────
if aov_buckets:
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s3, f"客單價分佈 — {period_label}")
names = [b.get('range', '') for b in aov_buckets]
counts = [int(b.get('count', 0)) for b in aov_buckets]
# 訂單數轉成虛擬「萬元」單位避免函式內除 10000counts 不是業績)
# 直接用 _mpl_horiz_bar_png 但傳入的 values 已是訂單數
# 改用簡單矩形 bar
_add_rect(s3, 0.4, 1.95, W - 0.8, 11.8, _WHITE, line_hex=_SUBTLE)
_add_text(s3, "客單區間 · 訂單數 · 佔比", 0.6, 2.05, W - 1.2, 0.55,
bold=True, size=12, color=_DARK_TEXT,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
max_cnt = max(counts) if counts else 1
for i, (name, cnt) in enumerate(zip(names, counts)):
row_y = 2.85 + i * 1.7
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
_add_rect(s3, 0.6, row_y, W - 1.2, 1.5, bg)
_add_text(s3, name, 0.8, row_y + 0.1, 6.0, 1.3,
bold=True, size=14, color=_DARK_TEXT, valign="middle",
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
bar_w_max = W * 0.55
bar_w = bar_w_max * cnt / max_cnt
_add_rect(s3, 7.2, row_y + 0.45, bar_w, 0.6, _BRAND_OG)
pct = cnt / total_orders * 100 if total_orders else 0
_add_text(s3, f"{cnt:,} 筆 ({pct:.1f}%)",
7.2 + bar_w + 0.2, row_y + 0.1, 8.0, 1.3,
bold=True, size=13, color=_DARK_TEXT, valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s3, W)
# ── P4: 星期分佈 ─────────────────────────────────────────
if weekday:
s4 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s4, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s4, f"消費星期分佈(找熱點時段)— {period_label}")
wd_names = [w.get('weekday', '') for w in weekday]
wd_revs = [float(w.get('revenue', 0)) for w in weekday]
# 用橫條圖
chart_w = W - 0.8
chart_h = 12.5
buf = _mpl_horiz_bar_png(wd_names, wd_revs,
total_width_cm=chart_w,
total_height_cm=chart_h,
value_unit="",
title="星期業績排行(焦糖橘=TOP3 熱門星期)",
highlight_top_n=3)
if buf:
_add_image_from_buf(s4, buf, 0.4, 1.95, chart_w, chart_h)
_add_footer(s4, W)
# ── P5: 商品復購 TOP 30 ──────────────────────────────────
if repeat_prods:
s5 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s5, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s5, f"商品復購排行 TOP {min(30, len(repeat_prods))}{period_label}")
_add_rect(s5, 0.4, 1.95, W - 0.8, 0.7, _BRAND_OG)
_add_text(s5, "復購次數 = 同商品在多筆獨立訂單中出現的次數",
0.7, 2.05, W - 1.4, 0.6,
bold=True, size=11, color=_WHITE, valign="middle",
ea_font=_FONT_BODY_EA)
for i, p in enumerate(repeat_prods[:15]):
row_y = 2.85 + i * 0.85
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
_add_rect(s5, 0.4, row_y, W - 0.8, 0.78, bg)
rank_fill = _BRAND_OG if i < 3 else (_KPI_HONEY if i < 10 else _SUBTLE)
_add_rect(s5, 0.55, row_y + 0.08, 0.95, 0.62, rank_fill)
_add_text(s5, str(i+1), 0.55, row_y + 0.08, 0.95, 0.62,
bold=(i < 3), size=11, color=_WHITE if i < 10 else _SUBTEXT,
align="center", valign="middle", latin_font=_FONT_DISPLAY)
_add_text(s5, str(p.get('name', ''))[:42],
1.7, row_y + 0.12, W - 11, 0.55,
size=11, color=_DARK_TEXT,
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
_add_text(s5, f"{p.get('repeat_count', 0):,} 訂單 · {p.get('total_qty', 0):,}",
W - 9.5, row_y + 0.12, 8.5, 0.55,
bold=True, size=11, color=_BRAND_OG, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s5, W)
# ── P6: AI 洞察 ─────────────────────────────────────────
_ai_insight_slide(prs, ai_text)
# ── P7: 附錄 ─────────────────────────────────────────────
_appendix_slide(prs, 'customer', period_label)
path = _new_path("customer")
prs.save(path)
return path
# ── 品類深度報告(單一品類 90 天縱向)─────────────────────────────────────
def generate_category_deep_ppt(category: str, db_data: dict, ai_text: str) -> str:
"""品類深度報告 v3.1單一品類縱向分析PM/採購用)
P1 封面(含品類定位徽章)
P2 執行摘要KPI + 子分類分佈帶)
P3 90 天日業績走勢(含日均線、高低點、檔期標註)
P4 子品類結構(橫條 + 帕雷托)
P5-P7 TOP 50 商品(自動分頁)
P8 TOP 30 廠商
P9 新進榜商品(近 30 天)
P10 AI 採購/PM 視角洞察
P11 附錄
"""
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
period = db_data.get('period', '')
kpis = db_data.get('kpis', {}) or {}
daily = db_data.get('daily', []) or []
top_prods = db_data.get('top_products', []) or []
top_vendors = db_data.get('top_vendors', []) or []
sub_cats = db_data.get('sub_categories', []) or []
new_prods = db_data.get('new_products', []) or []
mcp_text = db_data.get('mcp', '') or ''
rev = float(kpis.get('revenue', 0))
ord_ = int(kpis.get('orders', 0))
gm = float(kpis.get('gross_margin', 0))
aov = float(kpis.get('avg_order', rev / ord_ if ord_ else 0))
sku_count = int(kpis.get('sku_count', 0))
vendor_count = int(kpis.get('vendor_count', 0))
# 品類定位徽章
if rev > 1_000_000:
pos_label, pos_color = '主力品類', '2A7A3F'
elif rev > 200_000:
pos_label, pos_color = '成長品類', 'B88416'
else:
pos_label, pos_color = '長尾品類', '8A5A2B'
# ── P1: 封面 ─────────────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, _BRAND_OG)
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, _BRAND_OG)
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, _BRAND_OG)
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
_add_text(slide, "CATEGORY · 90-DAY DEEP DIVE · AI INSIGHT",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
_add_text(slide, f"品類深度報告\n{category}",
3.8, 3.2, 25, 5.0,
bold=True, size=42, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, pos_color)
_add_text(slide, f"品類定位:{pos_label}",
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
_add_text(slide,
f"期間 {period} · 業績 NT${rev:,.0f}{rev/10000:.1f}萬)"
f" · {sku_count} SKU · {vendor_count} 廠商",
3.8, 8.7, 27, 0.85,
bold=True, size=14, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide,
f"訂單 {ord_:,} 筆 · 毛利率 {gm:.1f}% · 客單 NT${aov:,.0f}",
3.8, 9.7, 27, 0.85,
size=12, color=_SUBTEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
if new_prods:
_add_rect(slide, 3.8, 11.5, W - 7.5, 1.5, "2A7A3F")
_add_text(slide, "🆕 近 30 天新進榜商品",
4.0, 11.6, W - 7.9, 0.5,
bold=True, size=11, color=_WHITE,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
new_text = ' '.join(p.get('name', '')[:18] for p in new_prods[:3])
_add_text(slide, new_text,
4.0, 12.2, W - 7.9, 0.75,
size=11, color=_WHITE,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right", latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
# ── P2: 執行摘要 ──────────────────────────────────────────
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s2, f"執行摘要 — {category}{period}")
kpi_v2 = [
(_KPI_CARAMEL, "期間業績", f"NT${rev/10000:.1f}", None, ""),
(_KPI_HONEY, "期間訂單", f"{ord_:,}", None, ""),
(_KPI_MAHOGANY, "毛利率", f"{gm:.1f}%", None, ""),
(_KPI_EARTH, "SKU 總數", f"{sku_count}", None, f"廠商 {vendor_count}"),
]
for i, (col, lbl, val, dp, dl) in enumerate(kpi_v2):
_kpi_card_v2(s2, i * 7.8 + 0.5, 1.95, 7.4, 4.5,
col, lbl, val, delta_pct=dp, delta_label=dl, sub=dl)
summary_text = ""
for line in (ai_text or '').split('\n'):
if line.strip() and not line.startswith(''):
summary_text += line + "\n"
if len(summary_text) > 350: break
if not summary_text.strip():
summary_text = (ai_text or '')[:350] if ai_text else "(暫無 AI 分析)"
_add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, _BRAND_OG)
_add_text(s2, f"📊 {category} 90 天深度解讀",
1.1, 7.05, W - 1.5, 0.6, bold=True, size=13, color=_WHITE,
valign="middle", ea_font=_FONT_BODY_EA)
_add_rect(s2, 0.5, 7.7, W - 1.0, 6.4, _WHITE, line_hex=_SUBTLE)
_add_rect(s2, 0.5, 7.7, 0.4, 6.4, _BRAND_OG)
_add_text(s2, summary_text.strip(),
1.2, 7.95, W - 2.0, 5.9,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s2, W)
# ── P3: 90 天日業績走勢 ───────────────────────────────────
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s3, f"{category} 日業績走勢 — {period}")
if daily:
d_dates = [d.get('date', '') for d in daily]
d_revs = [float(d.get('revenue', 0)) for d in daily]
chart_w = W - 0.8
chart_h = 12.5
buf = _mpl_line_chart_png(
d_dates, d_revs, prev_vals=None,
total_width_cm=chart_w, total_height_cm=chart_h,
title=f"{category} 日業績走勢(含日均線、高低點)",
curr_label=category
)
if buf:
_add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h)
# 底部結論帶
avg_d = sum(d_revs) / len(d_revs) if d_revs else 0
max_d = max(d_revs) if d_revs else 0
_add_rect(s3, 0.4, 14.7, W - 0.8, 1.0, _BRAND_OG2)
_add_text(s3,
f"📊 日均業績 NT${avg_d/10000:.1f}萬 · 最高單日 NT${max_d/10000:.1f}"
f" · {len(d_revs)} 天有交易",
0.7, 14.85, W - 1.4, 0.7,
bold=True, size=12, color=_WHITE, valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
else:
_add_empty_state(s3, "無 90 天日業績資料", "請確認該品類是否有銷售資料。", W)
_add_footer(s3, W)
# ── P4: 子品類結構 ───────────────────────────────────────
if sub_cats:
s4 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s4, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s4, f"子品類業績分佈 — {category}")
names = [c.get('name', '')[:14] for c in sub_cats[:8]]
revs = [float(c.get('revenue', 0)) for c in sub_cats[:8]]
chart_w_left = W * 0.5 - 0.4
chart_h = 12.5
buf1 = _mpl_horiz_bar_png(names, revs,
total_width_cm=chart_w_left,
total_height_cm=chart_h,
value_unit="",
title="① 子品類排行",
highlight_top_n=3)
if buf1:
_add_image_from_buf(s4, buf1, 0.4, 1.95, chart_w_left, chart_h)
rx = W * 0.5 + 0.0
buf2 = _mpl_pareto_chart_png(names, revs,
total_width_cm=W * 0.5 - 0.4,
total_height_cm=chart_h,
title="② 帕雷托累計貢獻")
if buf2:
_add_image_from_buf(s4, buf2, rx, 1.95, W * 0.5 - 0.4, chart_h)
_add_footer(s4, W)
# ── P5-P7: TOP 50 商品 ────────────────────────────────────
_product_table_slide(prs, f"{category} 熱銷商品 TOP 50 — {period}",
top_prods, max_items=50)
# ── P8: TOP 30 廠商 ───────────────────────────────────────
if top_vendors:
_vendor_table_slide(prs, top_vendors[:30], f"{category}{period}", {},
sum(float(v.get('sales', 0)) for v in top_vendors), max_items=30)
# ── P9: 新進榜商品(近 30 天)─────────────────────────────
if new_prods:
s9 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s9, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s9, f"🆕 近 30 天新進榜商品 — {category}10 款)")
_add_rect(s9, 0.4, 1.95, W - 0.8, 0.7, "2A7A3F")
_add_text(s9, "潛力新品(過去 60 天無交易,近 30 天進榜)",
0.7, 2.05, W - 1.4, 0.6,
bold=True, size=12, color=_WHITE, valign="middle",
ea_font=_FONT_BODY_EA)
for i, p in enumerate(new_prods[:10]):
row_y = 2.95 + i * 1.1
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
_add_rect(s9, 0.4, row_y, W - 0.8, 1.0, bg)
_add_rect(s9, 0.55, row_y + 0.1, 0.95, 0.8, "2A7A3F")
_add_text(s9, "🆕", 0.55, row_y + 0.1, 0.95, 0.8,
bold=True, size=14, color=_WHITE,
align="center", valign="middle")
_add_text(s9, str(p.get('name', ''))[:50],
1.7, row_y + 0.15, W - 12, 0.7,
size=12, color=_DARK_TEXT,
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
_add_text(s9, f"NT${float(p.get('revenue', 0)):,.0f}",
W - 10, row_y + 0.15, 9.0, 0.7,
bold=True, size=13, color="2A7A3F", align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s9, W)
# ── P10: AI 洞察 ──────────────────────────────────────────
_ai_insight_slide(prs, ai_text)
# ── P11: 附錄 ─────────────────────────────────────────────
_appendix_slide(prs, 'category', f"{category}{period}")
path = _new_path("category")
prs.save(path)
return path
# ── 期間回顧報告quarterly / half_yearly / annual / ttm 共用)───────────────
def generate_period_review_ppt(period_type: str, period_label: str,
db_data: dict, ai_text: str) -> str:
"""期間回顧報告 — 一份 generator 解 4 種:
period_type: 'quarterly''2026 Q1'
'half_yearly''2026 H1'
'annual''2026'
'ttm''TTM 2025-05~2026-04'
db_data: {
kpis, monthly_breakdown, top_products, top_categories, top_vendors,
prev_period: dict (上一期同等)
yoy_period: dict (去年同期)
mcp: 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
kpis = db_data.get('kpis', {}) or {}
monthly = db_data.get('monthly_breakdown', []) or []
top_cats = db_data.get('top_categories', []) or []
top_prods = db_data.get('top_products', []) or []
top_vendors = db_data.get('top_vendors', []) or []
prev_period = db_data.get('prev_period') or {}
yoy_period = db_data.get('yoy_period') or {}
mcp_text = db_data.get('mcp', '') or ''
rev = float(kpis.get('revenue', 0))
ord_ = int(kpis.get('orders', 0))
gm = float(kpis.get('gross_margin', 0))
aov = float(kpis.get('avg_order', rev / ord_ if ord_ else 0))
# 期間類型徽章
type_badges = {
'quarterly': ('季報', 'QUARTERLY REVIEW', _KPI_HONEY),
'half_yearly': ('半年報', 'HALF-YEARLY REVIEW', _KPI_MAHOGANY),
'annual': ('年報', 'ANNUAL REVIEW', _BRAND_OG2),
'ttm': ('TTM 滾動', 'TRAILING 12 MONTHS', _KPI_EARTH),
}
type_label, type_en, type_color = type_badges.get(period_type, ('期間回顧', 'PERIOD REVIEW', _BRAND_OG))
# ── P1: 封面 ─────────────────────────────────────────────
elevator = _compute_elevator_pitch(
{'revenue': rev, 'orders': ord_, 'gross_margin': gm, 'top_categories': top_cats},
prev_period.get('kpis') if prev_period else None
)
_period_review_cover_slide(prs, period_label, type_label, type_en, type_color,
rev, ord_, gm, aov, elevator)
# ── P2: 執行摘要KPI v2 + QoQ/HoH/YoY 對比帶)──────────────
s2 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s2, f"{type_label}執行摘要 — {period_label}")
def _delta(curr_v, prev_dict, key, is_pp=False):
if not prev_dict:
return None
prev_kpis = prev_dict.get('kpis', {})
prev_v = float(prev_kpis.get(key, 0) or 0)
if not prev_v:
return None
if is_pp:
return float(curr_v) - prev_v
return (float(curr_v) - prev_v) / prev_v * 100
d_rev = _delta(rev, prev_period, 'revenue')
d_ord = _delta(ord_, prev_period, 'orders')
d_gm = _delta(gm, prev_period, 'gross_margin', is_pp=True)
d_aov = _delta(aov, prev_period, 'avg_order')
qoh_label = {
'quarterly': 'vs 上季',
'half_yearly': 'vs 上半',
'annual': 'vs 去年',
'ttm': 'vs 上期',
}.get(period_type, 'vs 上期')
kpis_v2 = [
(_KPI_CARAMEL, "期間業績", f"NT${rev/10000:.1f}", d_rev, qoh_label),
(_KPI_HONEY, "期間訂單", f"{ord_:,}", d_ord, qoh_label),
(_KPI_MAHOGANY, "毛利率", f"{gm:.1f}%", d_gm, f"{qoh_label}pp"),
(_KPI_EARTH, "平均客單", f"NT${aov:,.0f}", d_aov, qoh_label),
]
for i, (col, lbl, val, dp, dl) in enumerate(kpis_v2):
_kpi_card_v2(s2, i * 7.8 + 0.5, 1.95, 7.4, 4.5,
col, lbl, val, delta_pct=dp, delta_label=dl)
# 高階解讀區塊
_add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, type_color)
_add_text(s2, f"📊 {type_label}高階營運解讀AI Generated",
1.1, 7.05, W - 1.5, 0.6, bold=True, size=13, color=_WHITE,
valign="middle", ea_font=_FONT_BODY_EA)
_add_rect(s2, 0.5, 7.7, W - 1.0, 5.8, _WHITE, line_hex=_SUBTLE)
_add_rect(s2, 0.5, 7.7, 0.4, 5.8, type_color)
# 抽取 AI 整體解讀段
summary_text = ""
capture = False
for line in (ai_text or '').split('\n'):
if any(k in line for k in ['整體業績解讀', '高階營運', '整體表現']):
capture = True
continue
if capture:
if line.strip().startswith('') and '整體' not in line:
break
if line.strip():
summary_text += line + "\n"
if len(summary_text) > 350: break
if not summary_text.strip():
for line in (ai_text or '').split('\n'):
if line.strip() and not line.startswith(''):
summary_text += line + "\n"
if len(summary_text) > 350: break
if not summary_text.strip():
summary_text = (ai_text or '')[:350] + "" if ai_text else "(暫無 AI 分析)"
_add_text(s2, summary_text.strip(),
1.2, 7.95, W - 2.0, 5.3,
size=13, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# YoY 對比帶(如有)
yoy_y = 13.8
if yoy_period and yoy_period.get('kpis'):
prev_yr_rev = float(yoy_period['kpis'].get('revenue', 0) or 0)
if prev_yr_rev:
yoy = (rev - prev_yr_rev) / prev_yr_rev * 100
yoy_color = "2A7A3F" if yoy > 0 else "B5342F"
arrow = "" if yoy > 0 else ""
_add_rect(s2, 0.5, yoy_y, W - 1.0, 1.4, yoy_color)
_add_text(s2,
f"📅 YoY 同期對比:去年 {yoy_period.get('period_label', '同期')} 業績 NT${prev_yr_rev/10000:.1f}"
f" → 本期 {arrow} {abs(yoy):.1f}%",
0.7, yoy_y + 0.1, W - 1.4, 1.2,
bold=True, size=13, color=_WHITE, valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s2, W)
# ── P3: 月度業績走勢(折線圖:本期 + 上期 + 去年同期) ────────
s3 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s3, f"{type_label}業績走勢 — {period_label}(月度)")
if monthly:
m_dates = [m.get('month', '') for m in monthly]
m_revs = [float(m.get('revenue', 0)) for m in monthly]
prev_revs = None
if prev_period and prev_period.get('monthly_breakdown'):
prev_revs = [float(m.get('revenue', 0))
for m in prev_period['monthly_breakdown']]
chart_w = W - 0.8
chart_h = 11.0
buf = _mpl_line_chart_png(
m_dates, m_revs, prev_vals=prev_revs,
total_width_cm=chart_w, total_height_cm=chart_h,
title=f"月度業績走勢({type_label} {period_label}",
curr_label="本期", prev_label="上期"
)
if buf:
_add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h)
# 底部 4 卡:合計 / 月均 / 最高月 / 最低月
if m_revs:
avg_m = sum(m_revs) / len(m_revs)
max_m, min_m = max(m_revs), min(m_revs)
ins_y = 13.3
ins_h = 2.4
card_w = (W - 1.0 - 0.3 * 3) / 4
cards = [
(_BRAND_OG, "📊 期間合計", f"NT${sum(m_revs)/10000:.1f}", f"{len(m_revs)} 個月"),
(_KPI_HONEY, "📈 月均業績", f"NT${avg_m/10000:.1f}", "月平均"),
(_KPI_MAHOGANY, "🏆 最高月", f"NT${max_m/10000:.1f}",
m_dates[m_revs.index(max_m)] if m_revs else ""),
(_KPI_EARTH, "📉 最低月", f"NT${min_m/10000:.1f}",
m_dates[m_revs.index(min_m)] if m_revs else ""),
]
for i, (col, lbl, val, sub) in enumerate(cards):
cx = 0.5 + i * (card_w + 0.3)
_add_rect(s3, cx, ins_y, card_w, ins_h, col)
_add_rect(s3, cx, ins_y, 0.15, ins_h, "FFFFFF")
_add_text(s3, lbl, cx + 0.3, ins_y + 0.2, card_w - 0.5, 0.5,
size=10, color="FAF7F0",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(s3, val, cx + 0.2, ins_y + 0.75, card_w - 0.4, 0.95,
bold=True, size=18, color="FFFFFF", align="center", valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(s3, sub, cx + 0.2, ins_y + 1.75, card_w - 0.4, 0.55,
size=9, color="FAF7F0", align="center",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
else:
_add_empty_state(s3, "無月度業績資料",
"請確認該期間是否已有銷售資料。", W)
_add_footer(s3, W)
# ── P4: 品類分析(橫條 + 帕雷托)────────────────────────────
if top_cats:
s4 = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s4, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s4, f"{type_label}品類業績結構分析 — {period_label}")
cats_disp = [c.get('cat', '')[:14] for c in top_cats[:8]]
revs_cats = [float(c.get('revenue', 0)) for c in top_cats[:8]]
chart_w_left = W * 0.5 - 0.4
chart_h = 12.5
buf1 = _mpl_horiz_bar_png(cats_disp, revs_cats,
total_width_cm=chart_w_left,
total_height_cm=chart_h,
value_unit="",
title="① 業績排行(焦糖橘=TOP3",
highlight_top_n=3)
if buf1:
_add_image_from_buf(s4, buf1, 0.4, 1.95, chart_w_left, chart_h)
chart_w_right = W * 0.5 - 0.4
rx = W * 0.5 + 0.0
buf2 = _mpl_pareto_chart_png(cats_disp, revs_cats,
total_width_cm=chart_w_right,
total_height_cm=chart_h,
title="② 帕雷托累計貢獻80% 主力線)")
if buf2:
_add_image_from_buf(s4, buf2, rx, 1.95, chart_w_right, chart_h)
_add_footer(s4, W)
# ── P5-P7: TOP 50 商品(自動分頁)──────────────────────────
_product_table_slide(prs, f"{type_label}熱銷商品 TOP 50 — {period_label}",
top_prods, max_items=50)
# ── P8: TOP 30 廠商 ────────────────────────────────────────
if top_vendors:
_vendor_table_slide(prs, top_vendors[:30], period_label, {},
sum(float(v.get('sales', 0)) for v in top_vendors), max_items=30)
# ── P9: MCP 市場情報 ──────────────────────────────────────
_mcp_intel_slide(prs, mcp_text)
# ── P10: AI 結構化洞察 ─────────────────────────────────────
_ai_insight_slide(prs, ai_text)
# ── P11: 附錄 ─────────────────────────────────────────────
_appendix_slide(prs, period_type, period_label)
path = _new_path(period_type)
prs.save(path)
return path
def _period_review_cover_slide(prs, period_label, type_label, type_en, type_color,
rev, ord_, gm, aov, elevator):
"""期間回顧封面 — 含期間類型徽章"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
W = 33.87
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, type_color)
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, type_color)
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, type_color)
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
_add_text(slide, f"{type_en} · AI INSIGHT",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
_add_text(slide, f"{type_label}\n{period_label}",
3.8, 3.2, 25, 5.0,
bold=True, size=44, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
status_color = elevator.get('status_color', _SUBTEXT)
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, status_color)
_add_text(slide, f"業績狀態:{elevator.get('status', '')}",
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
_add_text(slide,
f"業績 NT${rev:,.0f}{rev/10000:.1f}萬) · 訂單 {ord_:,}"
f" · 毛利率 {gm:.1f}% · 客單 NT${aov:,.0f}",
3.8, 8.7, 27, 0.85,
bold=True, size=14, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y = 10.2
pitch_h = 1.5
pitch_w = 27.0
_add_rect(slide, 3.8, pitch_y, 0.45, pitch_h, "2A7A3F")
_add_text(slide, "★ 最大亮點", 4.4, pitch_y + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color="2A7A3F",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, elevator.get('highlight') or "",
4.4, pitch_y + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y2 = pitch_y + pitch_h + 0.4
_add_rect(slide, 3.8, pitch_y2, 0.45, pitch_h, "B5342F")
_add_text(slide, "⚠ 最大警訊", 4.4, pitch_y2 + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color="B5342F",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, elevator.get('warning') or "",
4.4, pitch_y2 + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y3 = pitch_y2 + pitch_h + 0.4
mom_rev = elevator.get('mom_rev')
if mom_rev is not None:
delta_color = "2A7A3F" if mom_rev > 0 else "B5342F"
arrow = "" if mom_rev > 0 else ""
_add_rect(slide, 3.8, pitch_y3, 0.45, pitch_h, delta_color)
_add_text(slide, "📈 期間動能", 4.4, pitch_y3 + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color=delta_color,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"vs 上期:業績 {arrow} {abs(mom_rev):.1f}%",
4.4, pitch_y3 + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right",
latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
return slide
def _vendor_cover_slide(prs, period_lbl, vcount, total_sales, total_profit,
avg_margin, pareto_n, risk_label, risk_color):
"""廠商報告封面 — 含集中度警示徽章"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
W = 33.87
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, _BRAND_OG)
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, _BRAND_OG)
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, _BRAND_OG)
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
_add_text(slide, "VENDOR · PROCUREMENT REPORT · AI INSIGHT",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
_add_text(slide, f"廠商業績報告\n{period_lbl}",
3.8, 3.2, 25, 5.0,
bold=True, size=44, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 集中度徽章
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, risk_color)
_add_text(slide, f"集中度:{risk_label}",
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
_add_text(slide,
f"合計 {vcount} 家廠商 · 業績 NT${total_sales:,.0f}{total_sales/10000:.1f}萬)"
f" · 毛利 NT${total_profit/10000:.1f}萬 · 平均毛利率 {avg_margin:.1f}%",
3.8, 8.7, 27, 0.85,
bold=True, size=14, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 三句話策略要點
pitch_y = 10.2
pitch_h = 1.5
pitch_w = 27.0
_add_rect(slide, 3.8, pitch_y, 0.45, pitch_h, "C96442")
_add_text(slide, "💼 TOP 議價對象", 4.4, pitch_y + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color="C96442",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, f"{pareto_n} 家廠商佔 80% 業績,建議優先議價或爭取獨家",
4.4, pitch_y + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y2 = pitch_y + pitch_h + 0.4
_add_rect(slide, 3.8, pitch_y2, 0.45, pitch_h, "B88416")
_add_text(slide, "🌱 扶植潛力", 4.4, pitch_y2 + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color="B88416",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, f"{max(0, vcount - pareto_n)} 家長尾廠商為新晉/補充來源,可挑選毛利優於平均者扶植",
4.4, pitch_y2 + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y3 = pitch_y2 + pitch_h + 0.4
_add_rect(slide, 3.8, pitch_y3, 0.45, pitch_h, risk_color)
_add_text(slide, "⚠ 集中度警訊", 4.4, pitch_y3 + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color=risk_color,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"集中度 {risk_label}。建議:" + (
"立即啟動 TOP3 廠商備援名單,避免單點供應斷鏈" if risk_label == '集中度過高'
else "加強 TOP10 廠商議價,爭取獨家或更佳毛利空間"
),
4.4, pitch_y3 + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right",
latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
return slide
def _vendor_table_slide(prs, vendors, period_lbl, prev_rank, total_sales,
max_items=30):
"""廠商明細表 — 自動分頁(每頁 18 列),含 △ 排名變化"""
import math
W = 33.87
HEADER_END = 1.85
TABLE_HDR_H = 0.72
ROW_H = 0.88
AVAIL_H = _CONTENT_B - HEADER_END - TABLE_HDR_H
ROWS_PER_PAGE = max(1, int(AVAIL_H / ROW_H))
all_v = vendors[:max_items]
if not all_v:
return
top_sales = max(float(v.get('sales', 1)) for v in all_v) or 1
total_pages = math.ceil(len(all_v) / ROWS_PER_PAGE)
for page in range(total_pages):
page_v = all_v[page * ROWS_PER_PAGE:(page + 1) * ROWS_PER_PAGE]
page_label = f" ({page + 1}/{total_pages})"
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, f"廠商業績明細 TOP {min(max_items, len(all_v))}{period_lbl}{page_label}")
tbl_y = HEADER_END
_add_rect(s, 0.4, tbl_y, W - 0.8, TABLE_HDR_H, _BRAND_OG)
_add_rect(s, 0.4, tbl_y, 0.3, TABLE_HDR_H, _BRAND_OG2)
col_x = [(0.5, 1.3, "排名", "center"),
(1.95, W - 24.0, "廠商名稱", "left"),
(W - 22.0, 3.0, "vs 上期", "center"),
(W - 18.7, 4.5, "業績佔比", "center"),
(W - 14.0, 6.0, "業績", "right"),
(W - 7.7, 4.5, "毛利", "right"),
(W - 3.0, 2.5, "毛利率", "right")]
for x, w, label, al in col_x:
_add_text(s, label, x, tbl_y + 0.06, w, TABLE_HDR_H - 0.12,
bold=True, size=10, color=_WHITE, align=al,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
for j, v in enumerate(page_v):
actual_rank = page * ROWS_PER_PAGE + j + 1
bg = _LIGHT_GRAY if j % 2 == 0 else _WHITE
row_t = tbl_y + TABLE_HDR_H + j * ROW_H
sales = float(v.get('sales', 0))
profit = float(v.get('profit', 0))
margin = float(v.get('margin', 0))
pct_total = sales / total_sales * 100 if total_sales else 0
_add_rect(s, 0.4, row_t, W - 0.8, ROW_H - 0.04, bg)
rank_fill = _BRAND_OG if actual_rank <= 3 else (_KPI_HONEY if actual_rank <= 10 else _SUBTLE)
rank_color = _WHITE if actual_rank <= 10 else _SUBTEXT
_add_rect(s, 0.55, row_t + 0.1, 0.95, ROW_H - 0.22, rank_fill)
_add_text(s, str(actual_rank), 0.55, row_t + 0.1, 0.95, ROW_H - 0.22,
bold=(actual_rank <= 3), size=11, color=rank_color,
align="center", valign="middle", latin_font=_FONT_DISPLAY)
_add_text(s, str(v.get('name', ''))[:30],
1.95, row_t + 0.1, W - 24.2, ROW_H - 0.2,
size=10, color=_DARK_TEXT,
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
# vs 上期 △
name = v.get('name', '')
if name and prev_rank:
prev_r = prev_rank.get(name)
if prev_r is None:
diff_text, diff_color = "🆕 新", "2A7A3F"
else:
diff = prev_r - actual_rank
if diff > 0:
diff_text, diff_color = f"{diff}", "2A7A3F"
elif diff < 0:
diff_text, diff_color = f"{abs(diff)}", "B5342F"
else:
diff_text, diff_color = "", "9B9081"
else:
diff_text, diff_color = "", "9B9081"
_add_text(s, diff_text, W - 22.0, row_t + 0.1, 3.0, ROW_H - 0.2,
bold=True, size=10, color=diff_color, align="center",
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
# 業績佔比 bar
bar_max_w = 4.0
bar_w = max(0.05, pct_total / 100 * bar_max_w * 5) # 放大 5x 視覺化
bar_w = min(bar_w, bar_max_w)
_add_rect(s, W - 18.5, row_t + ROW_H * 0.38, bar_max_w, 0.25, _SUBTLE)
_add_rect(s, W - 18.5, row_t + ROW_H * 0.38, bar_w, 0.25, _BRAND_OG)
_add_text(s, f"{pct_total:.1f}%",
W - 18.5, row_t + 0.08, bar_max_w, ROW_H - 0.2,
size=9, color=_TERTIARY, align="right",
latin_font=_FONT_DISPLAY)
_add_text(s, f"NT${sales:,.0f}", W - 14.0, row_t + 0.1, 6.0, ROW_H - 0.2,
bold=(actual_rank == 1), size=11, color=_DARK_TEXT,
align="right", latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(s, f"NT${profit:,.0f}", W - 7.7, row_t + 0.1, 4.5, ROW_H - 0.2,
size=10, color=_SUBTEXT, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
margin_color = "2A7A3F" if margin >= 15 else ("B88416" if margin >= 8 else "B5342F")
_add_text(s, f"{margin:.1f}%", W - 3.0, row_t + 0.1, 2.5, ROW_H - 0.2,
bold=True, size=10, color=margin_color, align="right",
latin_font=_FONT_DISPLAY)
_add_footer(s, W)
# ── 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