5514 lines
253 KiB
Python
5514 lines
253 KiB
Python
"""
|
||
services/ppt_generator.py
|
||
OpenClaw 簡報生成器 — 精品深色主題 + 原生圖表版 (2026-04-20 v2)
|
||
|
||
函數清單(與 openclaw_bot_routes.py 呼叫約定一致):
|
||
check_pptx_available()
|
||
generate_daily_ppt(date_str, db_data, ai_text) -> str # 4頁
|
||
generate_weekly_ppt(db_data, ai_text) -> str # 5頁
|
||
generate_monthly_ppt(yr, mo, db_data, ai_text) -> str # 5頁
|
||
generate_strategy_ppt(date_str, db_data, ai_text) -> str # 5頁
|
||
generate_competitor_ppt(period_label, db_data, ai_text) -> str # 4頁
|
||
generate_promo_ppt(promo_label, data, ai_text) -> str # 5頁
|
||
|
||
設計規格:
|
||
- 16:9 (33.87cm × 19.05cm)
|
||
- 封面:深海藍背景 #0D1B2A、橘色品牌條 #FF5722、白色大標
|
||
- 數據頁:白底 KPI 卡 + python-pptx 原生圖表
|
||
- AI 頁:深色背景 + 白色文字
|
||
- 頁眉:橘色標題帶 #FF5722
|
||
- 頁腳:♥ Powered by OpenClaw(深灰 #37474F)
|
||
|
||
圖表對應(來源:templates):
|
||
daily → 近7日業績柱狀圖(參考 daily_sales.html trendChart)
|
||
weekly → 7日業績柱狀圖 + TOP10 商品表
|
||
monthly → 品類橫條圖(參考 monthly_summary_analysis.html)+ KPI + TOP10
|
||
strategy → 策略矩陣分佈柱狀圖 + 行動清單
|
||
competitor → 橫條圖 + 比較表(維持現有)
|
||
promo → 促銷 vs 對比期雙柱圖 + KPI 對比 + TOP商品
|
||
"""
|
||
import os
|
||
import uuid
|
||
from collections import defaultdict
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
REPORTS_DIR = Path(os.environ.get("REPORTS_DIR", "/app/data/reports"))
|
||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
# ── 模板版本(快取失效控制)─────────────────────────────────────────────────
|
||
# 任何一份簡報的「視覺/結構」設計變動,都必須 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-1:SubElement 永遠 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 規定的子元素順序,
|
||
讓未來若改成讀模板 .pptx(rPr 內已有 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/Docker(noto-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 卡 v2:value 大字 + 右下角 △% 徽章(綠↑紅↓)+ 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_monthly:MOMO 後台訂單明細",
|
||
"• 商品分類 L1:MOMO 主分類欄位",
|
||
"• 廠商名稱: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)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── 日報 PPT(4頁)────────────────────────────────────────────────────────────
|
||
def generate_daily_ppt(date_str: str, db_data, ai_text: str) -> str:
|
||
"""P1封面 P2 KPI+TOP5 P3 近7日業績走勢圖 P4 AI洞察"""
|
||
from pptx import Presentation
|
||
from pptx.util import Cm
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = Cm(33.87)
|
||
prs.slide_height = Cm(19.05)
|
||
W = 33.87
|
||
|
||
s = db_data.get("sales", {}) if isinstance(db_data, dict) else {}
|
||
tp = db_data.get("top_products", []) if isinstance(db_data, dict) else []
|
||
wk = db_data.get("weekly", []) if isinstance(db_data, dict) else []
|
||
|
||
rev = float(s.get("revenue", 0))
|
||
ord_ = int(s.get("orders", 0))
|
||
gm = float(s.get("gross_margin", 0))
|
||
aov = float(s.get("avg_order", 0))
|
||
|
||
# P1: 封面
|
||
_cover_slide(prs,
|
||
f"日報 {date_str}",
|
||
f"業績 NT${rev:,.0f}({rev/10000:.1f}萬)|訂單 {ord_:,} 筆",
|
||
f"毛利率 {gm:.1f}% 客單價 NT${aov:,.0f} 生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
|
||
)
|
||
|
||
# P2: KPI 卡 + TOP5
|
||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s2, f"業績 KPI — {date_str}")
|
||
kpis = [
|
||
(_BLUE_KPI, "總業績", f"NT${rev/10000:.1f}萬", ""),
|
||
(_GREEN_KPI, "總訂單", f"{ord_:,} 筆", ""),
|
||
(_BRAND_OG2, "毛利率", f"{gm:.1f}%", ""),
|
||
(_FOOTER_BG, "客單價", f"NT${aov:,.0f}", ""),
|
||
]
|
||
for i, (col, lbl, val, sub) in enumerate(kpis):
|
||
_kpi_card(s2, i * (7.4 + 0.4) + 0.5, 1.8, 7.4, 3.0, col, lbl, val, sub)
|
||
_add_rect(s2, 0.5, 5.2, W - 1, 0.7, _BRAND_OG)
|
||
_add_text(s2, "🏆 TOP 5 熱銷商品", 0.7, 5.25, W - 1.4, 0.6,
|
||
bold=True, size=13, color=_WHITE)
|
||
for i, p in enumerate(tp[:5]):
|
||
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
|
||
rev_p = float(p.get("revenue", 0))
|
||
_add_rect(s2, 0.5, 6.0 + i * 0.85, W - 1, 0.82, bg)
|
||
_add_text(s2, f"{i+1}. {str(p.get('name',''))[:45]}",
|
||
0.7, 6.05 + i * 0.85, W - 9, 0.72, size=10, color=_DARK_TEXT)
|
||
_add_text(s2, f"NT${rev_p:,.0f}",
|
||
W - 7.5, 6.05 + i * 0.85, 5, 0.72, size=10, color=_DARK_TEXT, align="right")
|
||
_add_footer(s2, W)
|
||
|
||
# P3: 近7日業績走勢(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
|
||
|
||
|
||
# ── 週報 PPT(5頁)────────────────────────────────────────────────────────────
|
||
def generate_weekly_ppt(db_data, ai_text: str) -> str:
|
||
"""P1封面 P2 KPI摘要 P3 7日業績柱狀圖 P4 TOP10商品 P5 AI洞察"""
|
||
from pptx import Presentation
|
||
from pptx.util import Cm
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = Cm(33.87)
|
||
prs.slide_height = Cm(19.05)
|
||
W = 33.87
|
||
|
||
wk = db_data.get('weekly', []) if isinstance(db_data, dict) else []
|
||
tp = db_data.get('top_products', []) if isinstance(db_data, dict) else []
|
||
|
||
total_rev = sum(float(w.get('revenue', 0)) for w in wk)
|
||
total_ord = sum(int(w.get('orders', 0)) for w in wk)
|
||
best_day = max(wk, key=lambda w: float(w.get('revenue', 0)), default={})
|
||
avg_daily = total_rev / len(wk) if wk else 0
|
||
|
||
# P1: 封面
|
||
_cover_slide(prs, "週報分析",
|
||
f"近 7 日業績 NT${total_rev:,.0f}({total_rev/10000:.1f}萬)",
|
||
f"生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
|
||
|
||
# P2: KPI 摘要
|
||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s2, "週報 KPI 摘要 — 近 7 日")
|
||
kpis = [
|
||
(_BLUE_KPI, "週業績", f"NT${total_rev/10000:.1f}萬", ""),
|
||
(_GREEN_KPI, "週訂單", f"{total_ord:,} 筆" if total_ord else "—", ""),
|
||
(_BRAND_OG2, "日均業績", f"NT${avg_daily/10000:.1f}萬", ""),
|
||
(_FOOTER_BG, "最佳單日", f"NT${float(best_day.get('revenue',0))/10000:.1f}萬",
|
||
best_day.get('date', '')[-5:] if best_day else ""),
|
||
]
|
||
for i, (col, lbl, val, sub) in enumerate(kpis):
|
||
_kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.5, col, lbl, val, sub)
|
||
_add_footer(s2, W)
|
||
|
||
# P3: 7日業績走勢(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
|
||
|
||
|
||
# ── 月報 PPT(6頁)────────────────────────────────────────────────────────────
|
||
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)
|
||
|
||
|
||
# ── 策略報告 PPT(5頁)────────────────────────────────────────────────────────
|
||
def generate_strategy_ppt(date_str: str, db_data, ai_text: str) -> str:
|
||
"""P1封面 P2 KPI+TOP5 P3 策略矩陣柱狀圖+說明 P4 行動清單 P5 AI洞察"""
|
||
from pptx import Presentation
|
||
from pptx.util import Cm
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = Cm(33.87)
|
||
prs.slide_height = Cm(19.05)
|
||
W = 33.87
|
||
|
||
s = db_data.get("sales", {}) if isinstance(db_data, dict) else {}
|
||
tp = db_data.get("top_products", []) if isinstance(db_data, dict) else []
|
||
strat = db_data.get("strategy", []) if isinstance(db_data, dict) else []
|
||
period = db_data.get("period_label", date_str) if isinstance(db_data, dict) else date_str
|
||
|
||
rev = float(s.get("revenue", 0))
|
||
ord_ = int(s.get("orders", 0))
|
||
gm = float(s.get("gross_margin", 0))
|
||
aov = float(s.get("avg_order", 0))
|
||
|
||
# 策略分佈統計
|
||
strat_agg = defaultdict(lambda: {'count': 0, 'revenue': 0.0})
|
||
for item in strat:
|
||
k = item.get('strategy', '其他')
|
||
strat_agg[k]['count'] += 1
|
||
strat_agg[k]['revenue'] += float(item.get('revenue', 0))
|
||
|
||
# P1: 封面
|
||
_cover_slide(prs, f"策略報告\n{period}",
|
||
f"業績 NT${rev:,.0f}({rev/10000:.1f}萬)",
|
||
f"生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
|
||
|
||
# P2: KPI + TOP5
|
||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s2, f"策略 KPI — {period}")
|
||
kpis = [
|
||
(_BLUE_KPI, "總業績", f"NT${rev/10000:.1f}萬", ""),
|
||
(_GREEN_KPI, "總訂單", f"{ord_:,} 筆", ""),
|
||
(_BRAND_OG2, "毛利率", f"{gm:.1f}%", ""),
|
||
(_FOOTER_BG, "客單價", f"NT${aov:,.0f}", ""),
|
||
]
|
||
for i, (col, lbl, val, sub) in enumerate(kpis):
|
||
_kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.0, col, lbl, val, sub)
|
||
_add_rect(s2, 0.5, 5.2, W - 1, 0.65, _BRAND_OG)
|
||
_add_text(s2, "🏆 TOP 熱銷商品", 0.7, 5.25, W - 1.4, 0.55,
|
||
bold=True, size=12, color=_WHITE)
|
||
for i, p in enumerate(tp[:5]):
|
||
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
|
||
_add_rect(s2, 0.5, 6.0 + i * 0.8, W - 1, 0.78, bg)
|
||
_add_text(s2, f"{i+1}. {str(p.get('name',''))[:45]}",
|
||
0.7, 6.04 + i * 0.8, W - 9, 0.7, size=9, color=_DARK_TEXT)
|
||
_add_text(s2, f"NT${float(p.get('revenue',0)):,.0f}",
|
||
W - 7.5, 6.04 + i * 0.8, 5, 0.7, size=9, color=_DARK_TEXT, align="right")
|
||
_add_footer(s2, W)
|
||
|
||
# P3: 策略矩陣分佈(參考 monthly_summary_analysis.html BCG Matrix 概念)
|
||
s3 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s3, f"策略矩陣分佈 — {period}")
|
||
if strat_agg:
|
||
order = [k for k in _STRAT_ORDER if k in strat_agg] + \
|
||
[k for k in strat_agg if k not in _STRAT_ORDER]
|
||
cnts = [strat_agg[k]['count'] for k in order]
|
||
colors = [_STRAT_COLORS.get(k, _SUBTEXT) for k in order]
|
||
|
||
# 左側柱狀圖(商品件數)
|
||
_add_column_chart(s3, 0.8, 1.8, W * 0.55 - 0.8, 10.8,
|
||
order, [("商品數(件)", cnts)],
|
||
bar_colors=colors, raw_values=True)
|
||
|
||
# 右側文字說明清單
|
||
_add_text(s3, "策略說明", W * 0.55 + 0.3, 1.8, W * 0.44 - 0.5, 0.6,
|
||
bold=True, size=12, color=_DARK_TEXT)
|
||
desc_t = 2.55
|
||
for k in order:
|
||
v = strat_agg[k]
|
||
color = _STRAT_COLORS.get(k, _SUBTEXT)
|
||
_add_rect(s3, W * 0.55 + 0.3, desc_t + 0.05, 0.35, 0.45, color)
|
||
_add_text(s3,
|
||
f"{k} {v['count']} 件 NT${v['revenue']/10000:.1f}萬",
|
||
W * 0.55 + 0.75, desc_t, W * 0.44 - 0.85, 0.55,
|
||
size=11, color=_DARK_TEXT)
|
||
desc_t += 0.68
|
||
else:
|
||
_add_empty_state(s3, "無策略分析資料", "策略矩陣需要商品銷售、毛利與期間對比資料。", W)
|
||
_add_footer(s3, W)
|
||
|
||
# P4: 策略行動清單(依策略優先序排列)
|
||
s4 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s4, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s4, f"策略行動清單 — {period}")
|
||
_add_rect(s4, 0.5, 1.7, W - 1, 0.65, _BRAND_OG)
|
||
_add_text(s4, "#", 0.6, 1.73, 1.2, 0.59, bold=True, size=10, color=_WHITE, align="center")
|
||
_add_text(s4, "商品名稱", 2.0, 1.73, W - 14, 0.59, bold=True, size=10, color=_WHITE)
|
||
_add_text(s4, "策略", W - 11, 1.73, 3.5, 0.59, bold=True, size=10, color=_WHITE, align="center")
|
||
_add_text(s4, "業績", W - 7, 1.73, 5, 0.59, bold=True, size=10, color=_WHITE, align="right")
|
||
_add_text(s4, "毛利率", W - 2, 1.73, 1.5, 0.59, bold=True, size=9, color=_WHITE, align="right")
|
||
|
||
sorted_strat = sorted(
|
||
strat,
|
||
key=lambda x: (
|
||
_STRAT_ORDER.index(x.get('strategy', '')) if x.get('strategy') in _STRAT_ORDER else 99,
|
||
-float(x.get('revenue', 0))
|
||
)
|
||
)
|
||
if not sorted_strat:
|
||
_add_empty_state(s4, "沒有可列入行動清單的商品", "請確認策略分析期間是否有商品銷售資料。", W)
|
||
for i, item in enumerate(sorted_strat[:10]):
|
||
bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE
|
||
sk = item.get('strategy', '其他')
|
||
sc = _STRAT_COLORS.get(sk, _SUBTEXT)
|
||
row_t = 2.5 + i * 0.88
|
||
_add_rect(s4, 0.5, row_t, W - 1, 0.85, bg)
|
||
_add_text(s4, str(i + 1),
|
||
0.6, row_t + 0.1, 1.2, 0.7, size=10, color=_DARK_TEXT, align="center")
|
||
_add_text(s4, str(item.get('name', ''))[:48],
|
||
2.0, row_t + 0.1, W - 14, 0.7, size=9, color=_DARK_TEXT)
|
||
_add_rect(s4, W - 11, row_t + 0.15, 3.0, 0.55, sc)
|
||
_add_text(s4, sk, W - 11, row_t + 0.15, 3.0, 0.55,
|
||
bold=True, size=9, color=_WHITE, align="center")
|
||
_add_text(s4, f"NT${float(item.get('revenue', 0)):,.0f}",
|
||
W - 7, row_t + 0.1, 5, 0.7, size=10, color=_DARK_TEXT, align="right")
|
||
gm_v = item.get('gross_margin', item.get('margin', ''))
|
||
if gm_v:
|
||
_add_text(s4, f"{float(gm_v):.1f}%",
|
||
W - 2, row_t + 0.1, 1.5, 0.7, size=9, color=_SUBTEXT, align="right")
|
||
_add_footer(s4, W)
|
||
|
||
# P5: AI 策略洞察(暖紙底 + 焦糖橘色條,去黑)
|
||
s5 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s5, 0, 0, W, _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
|
||
|
||
|
||
# ── 促銷報告 PPT(5頁)────────────────────────────────────────────────────────
|
||
def generate_promo_ppt(promo_label: str, data, ai_text: str) -> str:
|
||
"""P1封面 P2 促銷vs對比期KPI P3 業績對比柱狀圖 P4 TOP商品 P5 AI洞察"""
|
||
from pptx import Presentation
|
||
from pptx.util import Cm
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = Cm(33.87)
|
||
prs.slide_height = Cm(19.05)
|
||
W = 33.87
|
||
|
||
pd_ = data.get('promo', {}) if isinstance(data, dict) else {}
|
||
prev = data.get('pre', {}) if isinstance(data, dict) else {}
|
||
rev_lift = float(data.get('rev_lift', 0)) if isinstance(data, dict) else 0
|
||
ord_lift = float(data.get('ord_lift', 0)) if isinstance(data, dict) else 0
|
||
top_prod = pd_.get('top_products', [])
|
||
|
||
promo_rev = float(pd_.get('revenue', 0))
|
||
promo_ord = int(pd_.get('orders', 0))
|
||
promo_gm = float(pd_.get('margin', 0))
|
||
promo_days = max(int(pd_.get('days', 1)), 1)
|
||
|
||
pre_rev = float(prev.get('revenue', 0))
|
||
pre_ord = int(prev.get('orders', 0))
|
||
pre_gm = float(prev.get('margin', 0))
|
||
pre_start = prev.get('start', '')
|
||
pre_end = prev.get('end', '')
|
||
|
||
lift_icon = "📈" if rev_lift > 0 else "📉"
|
||
lift_color = _GREEN_KPI if rev_lift > 0 else _RED_WARN
|
||
|
||
# P1: 封面
|
||
_cover_slide(prs, f"促銷報告\n{promo_label}",
|
||
f"業績 NT${promo_rev:,.0f}({promo_rev/10000:.1f}萬) {lift_icon} {rev_lift:+.1f}%",
|
||
f"活動 {promo_days} 天 生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
|
||
|
||
# P2: 促銷 vs 對比期 KPI 對比
|
||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s2, f"促銷效益對比 — {promo_label}")
|
||
|
||
# 促銷期 KPI(橘色)
|
||
_add_rect(s2, 0.5, 1.7, W - 1, 0.5, _BRAND_OG)
|
||
_add_text(s2, f"▶ 促銷期({promo_label},{promo_days}天)",
|
||
0.7, 1.73, W - 1.4, 0.44, bold=True, size=11, color=_WHITE)
|
||
promo_kpis = [
|
||
(_BRAND_OG2, "活動期業績", f"NT${promo_rev/10000:.1f}萬", ""),
|
||
(_BRAND_OG2, "活動期訂單", f"{promo_ord:,} 筆", ""),
|
||
(_BRAND_OG2, "活動期毛利率", f"{promo_gm:.1f}%", ""),
|
||
(_BRAND_OG2, "日均業績", f"NT${promo_rev/promo_days/10000:.1f}萬", ""),
|
||
]
|
||
for i, (col, lbl, val, sub) in enumerate(promo_kpis):
|
||
_kpi_card(s2, i * 7.8 + 0.5, 2.3, 7.4, 2.8, col, lbl, val, sub)
|
||
|
||
# 對比期 KPI(深灰)
|
||
_add_rect(s2, 0.5, 5.3, W - 1, 0.5, _FOOTER_BG)
|
||
_add_text(s2, f"◀ 對比期({pre_start} ~ {pre_end})",
|
||
0.7, 5.33, W - 1.4, 0.44, bold=True, size=11, color=_WHITE)
|
||
pre_kpis = [
|
||
(_FOOTER_BG, "對比期業績", f"NT${pre_rev/10000:.1f}萬", ""),
|
||
(_FOOTER_BG, "對比期訂單", f"{pre_ord:,} 筆", ""),
|
||
(_FOOTER_BG, "對比期毛利率", f"{pre_gm:.1f}%", ""),
|
||
(_FOOTER_BG, "日均業績", f"NT${pre_rev/promo_days/10000:.1f}萬", ""),
|
||
]
|
||
for i, (col, lbl, val, sub) in enumerate(pre_kpis):
|
||
_kpi_card(s2, i * 7.8 + 0.5, 5.9, 7.4, 2.8, col, lbl, val, sub)
|
||
|
||
# 升降幅橫幅
|
||
_add_rect(s2, 0.5, 9.0, W - 1, 0.85, lift_color)
|
||
_add_text(s2, f"{lift_icon} 業績成長 {rev_lift:+.1f}% 訂單成長 {ord_lift:+.1f}%",
|
||
0.7, 9.05, W - 1.4, 0.75, bold=True, size=16, color=_WHITE, align="center")
|
||
_add_footer(s2, W)
|
||
|
||
# P3: 業績對比柱狀圖(參考 growth_analysis.html revenueChart 雙柱概念)
|
||
s3 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s3, "促銷期 vs 對比期 — 業績比較(萬元)")
|
||
_add_column_chart(
|
||
s3, 1.0, 1.8, W - 2.0, 11.0,
|
||
["對比期", "促銷期"],
|
||
[("業績(萬元)", [pre_rev, promo_rev])],
|
||
bar_colors=[_FOOTER_BG, _BRAND_OG]
|
||
)
|
||
# 毛利率文字對比
|
||
gm_diff = promo_gm - pre_gm
|
||
gm_color = _GREEN_KPI if gm_diff >= 0 else _RED_WARN
|
||
_add_text(s3,
|
||
f"毛利率:對比期 {pre_gm:.1f}% → 促銷期 {promo_gm:.1f}% "
|
||
f"({'↑' if gm_diff >= 0 else '↓'}{abs(gm_diff):.1f}pp)",
|
||
1.0, 13.05, W - 2, 0.55, size=11, color=gm_color)
|
||
_add_footer(s3, W)
|
||
|
||
# P4: 活動期熱銷商品
|
||
_product_table_slide(prs, f"促銷期熱銷商品 TOP 10 — {promo_label}", top_prod)
|
||
|
||
# P5: AI 洞察(暖紙底 + 焦糖橘色條,去黑)
|
||
s5 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s5, 0, 0, W, _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
|
||
|
||
|
||
# ── 競品比較 PPT(4頁,維持原有架構)────────────────────────────────────────────
|
||
def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> str:
|
||
from pptx import Presentation
|
||
from pptx.util import Cm
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = Cm(33.87)
|
||
prs.slide_height = Cm(19.05)
|
||
W = 33.87
|
||
|
||
results = db_data.get("results", [])
|
||
found = [r for r in results if r.get("found")]
|
||
pc_wins = [r for r in found if r.get("price_diff", 0) > 10]
|
||
mo_wins = [r for r in found if r.get("price_diff", 0) < -10]
|
||
tie = [r for r in found if abs(r.get("price_diff", 0)) <= 10]
|
||
unit_comparable = [
|
||
r for r in results
|
||
if not r.get("found") and r.get("match_status") in ("unit_comparable", "refresh_unit_comparable")
|
||
]
|
||
not_found = [r for r in results if not r.get("found") and r not in unit_comparable]
|
||
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)
|
||
review_brief = db_data.get("review_decision_brief") or {}
|
||
review_lines = list(review_brief.get("lines") or [])[:3]
|
||
|
||
# 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 = "⚠️ PChome 整體較便宜" 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 - PChome)",
|
||
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(unit_comparable), total, "9A6A2F"),
|
||
("未找到對應", 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)
|
||
if review_lines:
|
||
_add_rect(s2, 18.2, 6.25, 14.7, 4.2, _BG_PAPER, line_hex=_SUBTLE)
|
||
_add_text(s2, "覆核決策信封(HITL)", 18.55, 6.45, 13.8, 0.45,
|
||
bold=True, size=10, color=_DARK_TEXT)
|
||
_add_text(s2, "\n".join(review_lines), 18.55, 7.05, 13.7, 3.0,
|
||
size=8.2, color=_SUBTEXT, 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, 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
|
||
|
||
|
||
# ── 成長趨勢報告 PPT(6頁)────────────────────────────────────────────────────
|
||
def generate_growth_ppt(db_data, ai_text: str) -> str:
|
||
"""P1封面 P2 YTD KPI P3 月營收柱狀圖 P4 MoM月增率 P5 AOV+毛利率 P6 AI洞察
|
||
db_data: {chart_data: {labels, revenue, mom, yoy, aov, margin_rate},
|
||
kpi: {ytd_revenue, ytd_growth, current_year, recent_aov, total_orders}}
|
||
對應 growth_analysis.html: revenueChart / momChart / aovChart / marginChart
|
||
"""
|
||
from pptx import Presentation
|
||
from pptx.util import Cm
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = Cm(33.87)
|
||
prs.slide_height = Cm(19.05)
|
||
W = 33.87
|
||
|
||
cd = db_data.get('chart_data', {}) if isinstance(db_data, dict) else {}
|
||
kpi = db_data.get('kpi', {}) if isinstance(db_data, dict) else {}
|
||
|
||
labels = cd.get('labels', [])
|
||
revenue = cd.get('revenue', [])
|
||
mom = cd.get('mom', [])
|
||
yoy = cd.get('yoy', [])
|
||
aov_list = cd.get('aov', [])
|
||
margin_list = cd.get('margin_rate', [])
|
||
|
||
ytd_rev = float(kpi.get('ytd_revenue', 0))
|
||
ytd_growth = float(kpi.get('ytd_growth', 0))
|
||
curr_yr = kpi.get('current_year', datetime.now().year)
|
||
recent_aov = float(kpi.get('recent_aov', 0))
|
||
total_ord = int(kpi.get('total_orders', 0))
|
||
|
||
# P1: 封面
|
||
growth_icon = "📈" if ytd_growth >= 0 else "📉"
|
||
_cover_slide(prs, f"成長趨勢報告\n{curr_yr} 年",
|
||
f"YTD 累計業績 NT${ytd_rev:,.0f}({ytd_rev/10000:.1f}萬)",
|
||
f"{growth_icon} 年增率 {ytd_growth:+.1f}% 生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
|
||
|
||
# P2: YTD KPI 卡
|
||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s2, f"成長趨勢 KPI — {curr_yr} 年 YTD")
|
||
ytd_color = _GREEN_KPI if ytd_growth >= 0 else _RED_WARN
|
||
kpis = [
|
||
(_BLUE_KPI, "YTD 累計業績", f"NT${ytd_rev/10000:.1f}萬", f"{curr_yr} 年初至今"),
|
||
(ytd_color, "年增率 (YTD)", f"{ytd_growth:+.1f}%", "vs 去年同期"),
|
||
(_BRAND_OG2, "近30日客單價", f"NT${recent_aov:,.0f}", ""),
|
||
(_FOOTER_BG, "累計訂單", f"{total_ord:,} 筆", ""),
|
||
]
|
||
for i, (col, lbl, val, sub) in enumerate(kpis):
|
||
_kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.5, col, lbl, val, sub)
|
||
# 最近月份 MoM 快速摘要
|
||
if mom and labels:
|
||
last_mom = float(mom[-1])
|
||
last_label = labels[-1]
|
||
mom_color = _GREEN_KPI if last_mom >= 0 else _RED_WARN
|
||
_add_rect(s2, 0.5, 5.6, W - 1, 0.7, mom_color)
|
||
_add_text(s2, f"最近月份 {last_label} MoM {last_mom:+.1f}% | YoY {float(yoy[-1]) if yoy else 0:+.1f}%",
|
||
0.8, 5.65, W - 1.6, 0.6, bold=True, size=13, color=_WHITE, align="center")
|
||
_add_footer(s2, W)
|
||
|
||
# P3: 月營收柱狀圖(對應 revenueChart)
|
||
s3 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s3, f"月營收趨勢 — {curr_yr} 年(萬元)")
|
||
if labels and revenue:
|
||
short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels]
|
||
_add_column_chart(s3, 0.8, 1.8, W - 1.6, 11.0,
|
||
short_labels, [("月業績(萬元)", revenue)],
|
||
bar_colors=[_BLUE_KPI])
|
||
# YoY 備注
|
||
if yoy:
|
||
avg_yoy = sum(float(v) for v in yoy if v) / max(len([v for v in yoy if v]), 1)
|
||
_add_text(s3, f"平均 YoY:{avg_yoy:+.1f}%",
|
||
0.8, 13.05, 10, 0.5, size=10, color=_SUBTEXT)
|
||
_add_footer(s3, W)
|
||
|
||
# P4: MoM 月增率柱狀圖(對應 momChart)
|
||
s4 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s4, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s4, "月增率分析 (MoM) — 正值綠 / 負值請注意")
|
||
if labels and mom:
|
||
short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels]
|
||
pos_vals = [float(v) if float(v) >= 0 else 0 for v in mom]
|
||
neg_vals = [abs(float(v)) if float(v) < 0 else 0 for v in mom]
|
||
_add_column_chart(s4, 0.8, 1.8, W - 1.6, 8.0,
|
||
short_labels,
|
||
[("成長(%)", pos_vals), ("衰退(%)", neg_vals)],
|
||
bar_colors=[_GREEN_KPI, _RED_WARN], raw_values=True)
|
||
# 最近3個月摘要
|
||
_add_rect(s4, 0.5, 10.2, W - 1, 0.6, _FOOTER_BG)
|
||
_add_text(s4, "近3月 MoM:" + " | ".join(
|
||
f"{labels[i][-7:]} {float(mom[i]):+.1f}%" for i in range(max(0, len(mom)-3), len(mom))),
|
||
0.7, 10.25, W - 1.4, 0.5, size=11, color=_WHITE, align="center")
|
||
_add_footer(s4, W)
|
||
|
||
# P5: AOV 客單價 + 毛利率走勢(對應 aovChart + marginChart)
|
||
s5 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s5, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s5, "客單價 & 毛利率走勢")
|
||
if labels and aov_list:
|
||
short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels]
|
||
_add_text(s5, "▶ 客單價走勢(NT$)", 0.5, 1.7, 16, 0.5, size=11, color=_DARK_TEXT, bold=True)
|
||
_add_column_chart(s5, 0.5, 2.2, W * 0.48, 5.0,
|
||
short_labels, [("客單價(元)", aov_list)],
|
||
bar_colors=[_BRAND_OG2], raw_values=True)
|
||
if labels and margin_list:
|
||
short_labels = [lb[-7:] if len(lb) > 4 else lb for lb in labels]
|
||
_add_text(s5, "▶ 毛利率走勢(%)", W * 0.52, 1.7, 16, 0.5, size=11, color=_DARK_TEXT, bold=True)
|
||
_add_column_chart(s5, W * 0.52, 2.2, W * 0.46, 5.0,
|
||
short_labels, [("毛利率(%)", margin_list)],
|
||
bar_colors=[_GREEN_KPI], raw_values=True)
|
||
# 摘要數字
|
||
if aov_list:
|
||
avg_aov = sum(float(v) for v in aov_list if v) / max(len([v for v in aov_list if v]), 1)
|
||
_add_rect(s5, 0.5, 7.5, W * 0.48, 0.6, _BRAND_OG2)
|
||
_add_text(s5, f"平均客單價:NT${avg_aov:,.0f}", 0.7, 7.55, W * 0.46, 0.5,
|
||
size=11, color=_WHITE, align="center")
|
||
if margin_list:
|
||
avg_mg = sum(float(v) for v in margin_list if v) / max(len([v for v in margin_list if v]), 1)
|
||
_add_rect(s5, W * 0.52, 7.5, W * 0.46, 0.6, _GREEN_KPI)
|
||
_add_text(s5, f"平均毛利率:{avg_mg:.1f}%", W * 0.52 + 0.2, 7.55, W * 0.44, 0.5,
|
||
size=11, color=_WHITE, align="center")
|
||
_add_footer(s5, W)
|
||
|
||
# P6: AI 洞察
|
||
s6 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s6, 0, 0, W, 19.05, _BG_DARK)
|
||
_add_header(s6, "AI 成長趨勢洞察")
|
||
_add_text(s6, ai_text or "(暫無 AI 分析)",
|
||
1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True)
|
||
_add_footer(s6, W)
|
||
|
||
path = _new_path("growth")
|
||
prs.save(path)
|
||
return path
|
||
|
||
|
||
# ── 廠商業績報告 PPT(5頁)────────────────────────────────────────────────────
|
||
def generate_vendor_ppt(yr, mo, db_data, ai_text: str) -> str:
|
||
"""廠商業績報告 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 BytesIO(matplotlib 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.1(CEO/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.1(PM/採購用)
|
||
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]
|
||
# 訂單數轉成虛擬「萬元」單位避免函式內除 10000(counts 不是業績)
|
||
# 直接用 _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 品牌矩陣報告 PPT(5頁)───────────────────────────────────────────────
|
||
def generate_bcg_ppt(yr, mo, db_data, ai_text: str) -> str:
|
||
"""P1封面 P2 BCG象限KPI P3 BCG策略分類表 P4 區域業績橫條圖 P5 AI洞察
|
||
db_data: {bcg_data: [{name, qty, margin, sales}],
|
||
division_dist: [{name, sales, sales_2024, sales_2025}],
|
||
kpis: {total_sales, total_profit, avg_margin}}
|
||
對應 monthly_summary_analysis.html: bcgMatrixChart + divisionDistChart
|
||
"""
|
||
from pptx import Presentation
|
||
from pptx.util import Cm
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = Cm(33.87)
|
||
prs.slide_height = Cm(19.05)
|
||
W = 33.87
|
||
|
||
bcg_data = db_data.get('bcg_data', []) if isinstance(db_data, dict) else []
|
||
div_dist = db_data.get('division_dist', []) if isinstance(db_data, dict) else []
|
||
kpis = db_data.get('kpis', {}) if isinstance(db_data, dict) else {}
|
||
period_lbl = db_data.get('period_label', f"{yr}/{mo:02d}") if isinstance(db_data, dict) else f"{yr}/{mo:02d}"
|
||
|
||
total_sales = float(kpis.get('total_sales', 0))
|
||
avg_margin = float(kpis.get('avg_margin', 0))
|
||
|
||
# BCG 分類(以 avg_margin 為毛利門檻,以 中位業績 為業績門檻)
|
||
if bcg_data:
|
||
sales_vals = [float(b.get('sales', 0)) for b in bcg_data]
|
||
median_sales = sorted(sales_vals)[len(sales_vals) // 2] if sales_vals else 1
|
||
margin_thresh = avg_margin if avg_margin > 5 else 20.0
|
||
|
||
BCG_LABELS = {'star': '明星', 'cow': '金牛', 'q': '問號', 'dog': '瘦狗'}
|
||
BCG_COLORS = {'star': _GREEN_KPI, 'cow': _BLUE_KPI, 'q': _BRAND_OG, 'dog': _RED_WARN}
|
||
|
||
def _bcg_class(b):
|
||
s = float(b.get('sales', 0))
|
||
m = float(b.get('margin', 0))
|
||
if m >= margin_thresh and s >= median_sales: return 'star'
|
||
if m >= margin_thresh and s < median_sales: return 'cow'
|
||
if m < margin_thresh and s >= median_sales: return 'q'
|
||
return 'dog'
|
||
|
||
classified = [dict(b, _cls=_bcg_class(b)) for b in bcg_data]
|
||
star_list = [b for b in classified if b['_cls'] == 'star']
|
||
cow_list = [b for b in classified if b['_cls'] == 'cow']
|
||
q_list = [b for b in classified if b['_cls'] == 'q']
|
||
dog_list = [b for b in classified if b['_cls'] == 'dog']
|
||
else:
|
||
classified = star_list = cow_list = q_list = dog_list = []
|
||
BCG_LABELS = {'star': '明星', 'cow': '金牛', 'q': '問號', 'dog': '瘦狗'}
|
||
BCG_COLORS = {'star': _GREEN_KPI, 'cow': _BLUE_KPI, 'q': _BRAND_OG, 'dog': _RED_WARN}
|
||
margin_thresh = 20.0
|
||
|
||
# P1: 封面
|
||
_cover_slide(prs, f"品牌 BCG 矩陣報告\n{period_lbl}",
|
||
f"分析 {len(bcg_data)} 個品牌×區域組合",
|
||
f"毛利率門檻 {margin_thresh:.1f}% 生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
|
||
|
||
# P2: BCG 象限 KPI 卡
|
||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s2, f"BCG 矩陣象限分佈 — {period_lbl}")
|
||
bcg_kpis = [
|
||
(_GREEN_KPI, "⭐ 明星", f"{len(star_list)} 個", "高毛利 + 高業績"),
|
||
(_BLUE_KPI, "🐄 金牛", f"{len(cow_list)} 個", "高毛利 + 低業績"),
|
||
(_BRAND_OG, "❓ 問號", f"{len(q_list)} 個", "低毛利 + 高業績"),
|
||
(_RED_WARN, "🐕 瘦狗", f"{len(dog_list)} 個", "低毛利 + 低業績"),
|
||
]
|
||
for i, (col, lbl, val, sub) in enumerate(bcg_kpis):
|
||
_kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.5, col, lbl, val, sub)
|
||
# 判斷門檻說明
|
||
_add_rect(s2, 0.5, 5.6, W - 1, 0.65, _FOOTER_BG)
|
||
_add_text(s2, f"毛利率門檻:{margin_thresh:.1f}%(高/低) | 業績門檻:NT${median_sales/10000:.1f}萬(中位數)",
|
||
0.7, 5.65, W - 1.4, 0.55, size=11, color=_WHITE, align="center")
|
||
_add_footer(s2, W)
|
||
|
||
# P3: BCG 策略分類表(4象限各顯示 TOP 5)
|
||
s3 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s3, f"BCG 策略矩陣清單 — {period_lbl}")
|
||
quadrants = [
|
||
('star', star_list, 0.5, 1.7),
|
||
('cow', cow_list, W/2+0.3, 1.7),
|
||
('q', q_list, 0.5, 8.5),
|
||
('dog', dog_list, W/2+0.3, 8.5),
|
||
]
|
||
for cls, items, lx, ty in quadrants:
|
||
col = BCG_COLORS[cls]
|
||
lbl = BCG_LABELS[cls]
|
||
pw = W / 2 - 0.8
|
||
_add_rect(s3, lx, ty, pw, 0.55, col)
|
||
_add_text(s3, f"{lbl} ({len(items)} 個)", lx + 0.2, ty + 0.05, pw - 0.4, 0.45,
|
||
bold=True, size=11, color=_WHITE)
|
||
for j, b in enumerate(items[:5]):
|
||
bg = _LIGHT_GRAY if j % 2 == 0 else _WHITE
|
||
rt = ty + 0.6 + j * 0.65
|
||
_add_rect(s3, lx, rt, pw, 0.62, bg)
|
||
_add_text(s3, str(b.get('name', ''))[:22],
|
||
lx + 0.2, rt + 0.06, pw * 0.55, 0.52, size=8, color=_DARK_TEXT)
|
||
_add_text(s3, f"{b.get('margin',0):.1f}%",
|
||
lx + pw * 0.58, rt + 0.06, pw * 0.2, 0.52, size=8, color=_SUBTEXT, align="right")
|
||
_add_text(s3, f"NT${float(b.get('sales',0))/10000:.1f}M",
|
||
lx + pw * 0.8, rt + 0.06, pw * 0.18, 0.52, size=8, color=_DARK_TEXT, align="right")
|
||
_add_footer(s3, W)
|
||
|
||
# P4: 區域業績橫條圖(對應 divisionDistChart,2024 vs 2025)
|
||
s4 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s4, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s4, f"區域業績分佈 — {period_lbl}(萬元)")
|
||
if div_dist:
|
||
names = [d.get('name', '')[:15] for d in div_dist[:12]]
|
||
s24_vals = [float(d.get('sales_2024', 0)) for d in div_dist[:12]]
|
||
s25_vals = [float(d.get('sales_2025', 0)) for d in div_dist[:12]]
|
||
has_both = any(s24_vals) and any(s25_vals)
|
||
if has_both:
|
||
_add_horiz_chart(s4, 0.5, 1.8, W - 1, 11.3,
|
||
names,
|
||
[("2024", s24_vals), ("2025", s25_vals)],
|
||
bar_colors=[_FOOTER_BG, _BRAND_OG])
|
||
else:
|
||
revs = [float(d.get('sales', 0)) for d in div_dist[:12]]
|
||
_add_horiz_chart(s4, 0.5, 1.8, W - 1, 11.3,
|
||
names, [("業績(萬元)", revs)],
|
||
bar_colors=[_BRAND_OG])
|
||
else:
|
||
_add_text(s4, "(無區域分佈資料)", 2, 7, 20, 2, size=14, color=_SUBTEXT, align="center")
|
||
_add_footer(s4, W)
|
||
|
||
# P5: AI 洞察
|
||
s5 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s5, 0, 0, W, 19.05, _BG_DARK)
|
||
_add_header(s5, f"AI BCG 品牌策略洞察 — {period_lbl}")
|
||
_add_text(s5, ai_text or "(暫無 AI 分析)",
|
||
1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True)
|
||
_add_footer(s5, W)
|
||
|
||
path = _new_path("bcg")
|
||
prs.save(path)
|
||
return path
|
||
return path
|