Previous version was an emergency stub (緊急復原版) using plain white PowerPoint default layouts. This commit restores the full premium design visible in the product screenshot. Design system: - 16:9 canvas (33.87 × 19.05 cm) - Cover: deep navy bg #0D1B2A + orange brand stripe #FF5722 - Header bar: orange #FF5722 on all content slides - KPI cards: blue #1565C0 / green #2E7D32 / orange #E65100 - Horizontal bar chart for competitor distribution - Striped data table with red/green price-diff coloring - Footer: ♥ Powered by OpenClaw on every slide Slides per report type: competitor_ppt: Cover → KPI+BarChart → ProductTable → AI Insight daily_ppt: Cover → KPI+TOP5 → AI Insight strategy_ppt: Cover → KPI+TOP5 → AI Insight weekly/monthly/promo: Cover → AI Insight
523 lines
21 KiB
Python
523 lines
21 KiB
Python
"""
|
||
services/ppt_generator.py
|
||
OpenClaw 簡報生成器 — 精品深色主題版 (2026-04-20)
|
||
|
||
函數清單(與 openclaw_bot_routes.py 呼叫約定一致):
|
||
check_pptx_available()
|
||
generate_daily_ppt(date_str, db_data, ai_text) -> str
|
||
generate_weekly_ppt(db_data, ai_text) -> str
|
||
generate_monthly_ppt(yr, mo, db_data, ai_text) -> str
|
||
generate_strategy_ppt(date_str, db_data, ai_text) -> str
|
||
generate_competitor_ppt(period_label, db_data, ai_text) -> str
|
||
generate_promo_ppt(promo_label, data, ai_text) -> str
|
||
|
||
設計規格(依截圖還原):
|
||
- 16:9 (25.4cm × 14.29cm)
|
||
- 封面:深海藍背景 #0D1B2A、橘色品牌條 #FF5722、白色大標
|
||
- P2 :KPI 卡片(藍 #1565C0、綠 #2E7D32、橘 #E65100)+ 橫條圖
|
||
- P3 :商品比較表(白底、斑馬條紋、紅/綠價差顏色)
|
||
- 頁眉 :橘色標題帶 #FF5722(所有非封面頁)
|
||
- 頁腳 :♥ Powered by OpenClaw(深灰 #37474F)
|
||
"""
|
||
import os
|
||
import uuid
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
REPORTS_DIR = Path(os.environ.get("REPORTS_DIR", "/app/data/reports"))
|
||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
# ── 調色盤 ────────────────────────────────────────────────────────────────────
|
||
_BG_DARK = "0D1B2A" # 封面深藍背景
|
||
_BRAND_OG = "FF5722" # 橘色品牌 / 頁眉
|
||
_BRAND_OG2 = "E65100" # 深橘(KPI卡)
|
||
_WHITE = "FFFFFF"
|
||
_LIGHT_GRAY = "F5F5F5" # 斑馬偶數行
|
||
_DARK_TEXT = "212121"
|
||
_SUBTEXT = "757575"
|
||
_FOOTER_BG = "37474F"
|
||
_BLUE_KPI = "1565C0"
|
||
_GREEN_KPI = "2E7D32"
|
||
_RED_WARN = "C62828"
|
||
_BAR_PCHOME = "EF5350" # PChome 橫條
|
||
_BAR_MOMO = "66BB6A" # momo 橫條
|
||
_BAR_TIE = "FFA726" # 持平橫條
|
||
_BAR_MISS = "9E9E9E" # 未找到橫條
|
||
|
||
|
||
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 _pt(pt: float):
|
||
from pptx.util import Pt
|
||
return Pt(pt)
|
||
|
||
|
||
def _fill_solid(shape, hex6: str):
|
||
shape.fill.solid()
|
||
shape.fill.fore_color.rgb = _rgb(hex6)
|
||
|
||
|
||
def _no_line(shape):
|
||
from pptx.util import Pt
|
||
shape.line.color.rgb = _rgb(_WHITE)
|
||
shape.line.width = Pt(0)
|
||
|
||
|
||
def _add_rect(slide, l, t, w, h, fill_hex, line_hex=None):
|
||
from pptx.util import Pt
|
||
s = slide.shapes.add_shape(1, _emu(l), _emu(t), _emu(w), _emu(h))
|
||
_fill_solid(s, fill_hex)
|
||
if line_hex:
|
||
s.line.color.rgb = _rgb(line_hex)
|
||
s.line.width = Pt(0.5)
|
||
else:
|
||
s.line.fill.background()
|
||
return s
|
||
|
||
|
||
def _add_text(slide, text, l, t, w, h,
|
||
bold=False, size=14, color=_WHITE,
|
||
align="left", valign="top", wrap=True):
|
||
from pptx.util import Pt
|
||
from pptx.enum.text import PP_ALIGN
|
||
from pptx.dml.color import RGBColor
|
||
from pptx.oxml.ns import qn
|
||
import lxml.etree as etree
|
||
|
||
txb = slide.shapes.add_textbox(_emu(l), _emu(t), _emu(w), _emu(h))
|
||
tf = txb.text_frame
|
||
tf.word_wrap = wrap
|
||
# vertical align
|
||
if valign == "middle":
|
||
from pptx.enum.text import MSO_ANCHOR
|
||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||
p = tf.paragraphs[0]
|
||
p.text = text
|
||
run = p.runs[0]
|
||
run.font.bold = bold
|
||
run.font.size = Pt(size)
|
||
run.font.color.rgb = RGBColor(
|
||
int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16))
|
||
if align == "center":
|
||
p.alignment = PP_ALIGN.CENTER
|
||
elif align == "right":
|
||
p.alignment = PP_ALIGN.RIGHT
|
||
return txb
|
||
|
||
|
||
def _add_footer(slide, prs_w_cm=33.87):
|
||
"""♥ Powered by OpenClaw 頁腳條"""
|
||
_add_rect(slide, 0, 13.5, prs_w_cm, 0.79, _FOOTER_BG)
|
||
_add_text(slide, "♥ Powered by OpenClaw",
|
||
prs_w_cm - 6, 13.55, 5.8, 0.7,
|
||
size=8, color=_BRAND_OG, align="right")
|
||
|
||
|
||
def _add_header(slide, title_text, prs_w_cm=33.87):
|
||
"""橘色頁眉帶"""
|
||
_add_rect(slide, 0, 0, prs_w_cm, 1.5, _BRAND_OG)
|
||
_add_text(slide, title_text, 0.5, 0.1, prs_w_cm - 1, 1.3,
|
||
bold=True, size=20, color=_WHITE, valign="middle")
|
||
|
||
|
||
def _kpi_card(slide, l, t, w, h, fill, label, value, sub=""):
|
||
_add_rect(slide, l, t, w, h, fill)
|
||
_add_text(slide, label, l + 0.2, t + 0.2, w - 0.4, 0.7,
|
||
size=10, color=_WHITE)
|
||
_add_text(slide, value, l + 0.2, t + 0.7, w - 0.4, 1.3,
|
||
bold=True, size=28, color=_WHITE, align="center")
|
||
if sub:
|
||
_add_text(slide, sub, l + 0.2, t + h - 0.65, w - 0.4, 0.6,
|
||
size=9, color=_WHITE)
|
||
|
||
|
||
def _horiz_bar(slide, l, t, h_row, label, value, total, fill_hex, max_w=14.0):
|
||
"""單條橫條圖(label 在左,bar 居中,百分比在右)"""
|
||
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 _cover_slide(prs, big_title: str, sub1: str, sub2: str = ""):
|
||
from pptx.util import Cm
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
|
||
W = 33.87
|
||
|
||
# 全頁深色背景
|
||
_add_rect(slide, 0, 0, W, 19.05, _BG_DARK)
|
||
|
||
# 左側橘色品牌條(細)
|
||
_add_rect(slide, 0, 0, 0.6, 19.05, _BRAND_OG)
|
||
|
||
# 品牌標籤
|
||
_add_rect(slide, 1.2, 1.2, 3.5, 0.7, _BRAND_OG)
|
||
_add_text(slide, "OPENCLAW", 1.3, 1.22, 3.3, 0.66,
|
||
bold=True, size=11, color=_WHITE, align="center", valign="middle")
|
||
|
||
# 大標題
|
||
_add_text(slide, big_title, 1.2, 2.3, 20, 3.5,
|
||
bold=True, size=38, color=_WHITE)
|
||
|
||
# 副標
|
||
_add_text(slide, sub1, 1.2, 6.0, 22, 0.9,
|
||
size=14, color="BDBDBD")
|
||
if sub2:
|
||
_add_text(slide, sub2, 1.2, 7.0, 22, 0.8,
|
||
size=12, color="9E9E9E")
|
||
|
||
# 右側裝飾線
|
||
_add_rect(slide, W - 0.8, 0, 0.8, 19.05, _BRAND_OG2)
|
||
|
||
_add_footer(slide, W)
|
||
return slide
|
||
|
||
|
||
# ── 競品 PPT — 完整版 ─────────────────────────────────────────────────────────
|
||
def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> str:
|
||
from pptx import Presentation
|
||
from pptx.util import Cm
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = Cm(33.87)
|
||
prs.slide_height = Cm(19.05)
|
||
W = 33.87
|
||
|
||
results = db_data.get("results", [])
|
||
found = [r for r in results if r.get("found")]
|
||
pc_wins = [r for r in found if r.get("price_diff", 0) > 10]
|
||
mo_wins = [r for r in found if r.get("price_diff", 0) < -10]
|
||
tie = [r for r in found if abs(r.get("price_diff", 0)) <= 10]
|
||
not_found = [r for r in results if not r.get("found")]
|
||
total = len(results)
|
||
match_rate = len(found) / total * 100 if total else 0
|
||
avg_pct = (sum(r.get("price_diff_pct", 0) for r in found) / len(found)
|
||
if found else 0)
|
||
momo_rev = db_data.get("momo_revenue", 0)
|
||
|
||
# ── P1 封面 ───────────────────────────────────────────────────────────────
|
||
_cover_slide(
|
||
prs,
|
||
f"momo vs PChome\n競品比較分析",
|
||
f"掃描 {total} 件熱銷商品|比對成功 {len(found)} 件",
|
||
f"分析週期:{period_label} 平均價差:{avg_pct:+.1f}% 生成時間:{datetime.now().strftime('%Y/%m/%d %H:%M')}",
|
||
)
|
||
|
||
# ── P2 KPI 摘要 + 橫條圖 ─────────────────────────────────────────────────
|
||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s2, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s2, f"競品比較 KPI 摘要 — {period_label}")
|
||
|
||
# 四個 KPI 卡(L→R)
|
||
cards = [
|
||
(_BLUE_KPI, "掃描商品數", str(total), f"{total} 件熱銷商品"),
|
||
("1565C0", "比對成功率", f"{match_rate:.0f}%", f"{len(found)} 件成功"),
|
||
(_GREEN_KPI, "PChome 優勢", f"{len(pc_wins)}/{len(found)}", f"{len(pc_wins)} 件 PChome 更便宜"),
|
||
(_BRAND_OG2, "momo 優勢", f"{len(mo_wins)}/{len(found)}", f"{len(mo_wins)} 件 momo 更便宜"),
|
||
]
|
||
card_w, card_gap, card_t = 7.4, 0.4, 1.8
|
||
for i, (col, lbl, val, sub) in enumerate(cards):
|
||
_kpi_card(s2, i * (card_w + card_gap) + 0.5, card_t, card_w, 3.2, col, lbl, val, sub)
|
||
|
||
# 整體趨勢橫幅
|
||
trend_color = _RED_WARN if avg_pct < -3 else (_BRAND_OG if avg_pct > 3 else "FFA726")
|
||
trend_icon = "⚠️ momo 整體偏貴" if avg_pct < -3 else ("✅ momo 整體具優勢" if avg_pct > 3 else "➖ 整體持平")
|
||
_add_rect(s2, 0.5, 5.3, W - 1, 0.85, trend_color)
|
||
_add_text(s2, f"整體定價態勢:{trend_icon} 平均價差 {avg_pct:+.1f}%(正值=momo偏貴)",
|
||
0.7, 5.35, W - 1.4, 0.75, bold=True, size=13, color=_WHITE)
|
||
|
||
# 橫條圖(4 種結果)
|
||
bar_data = [
|
||
("PChome 更便宜", len(pc_wins), total, _BAR_PCHOME),
|
||
("momo 更便宜", len(mo_wins), total, _BAR_MOMO),
|
||
("價格相近", len(tie), total, _BAR_TIE),
|
||
("未找到對應", len(not_found),total, _BAR_MISS),
|
||
]
|
||
row_t = 6.4
|
||
for label, val, tot, col in bar_data:
|
||
_horiz_bar(s2, 0.8, row_t, 0.9, label, val, tot, col, max_w=16.0)
|
||
row_t += 1.0
|
||
|
||
# momo 總業績備注
|
||
if momo_rev:
|
||
_add_text(s2, f"本期 momo 掃描商品總業績:NT$ {momo_rev:,.0f}({momo_rev/10000:.1f}萬)",
|
||
0.8, 11.8, W - 1.6, 0.7, size=10, color=_SUBTEXT)
|
||
|
||
_add_footer(s2, W)
|
||
|
||
# ── P3 商品比較表 ─────────────────────────────────────────────────────────
|
||
s3 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s3, 0, 0, W, 19.05, _WHITE)
|
||
_add_header(s3, f"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 i, (c, w) in enumerate(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
|
||
|
||
# 資料行(最多 10 行)
|
||
rows = (pc_wins + mo_wins)[:10]
|
||
for ri, r in enumerate(rows):
|
||
bg = _LIGHT_GRAY if ri % 2 == 0 else _WHITE
|
||
x = 0.3
|
||
diff = r.get("price_diff", 0)
|
||
pct = r.get("price_diff_pct", 0)
|
||
diff_c = _RED_WARN if diff < -10 else (_GREEN_KPI if diff > 10 else _DARK_TEXT)
|
||
trend = "📈 PChome貴" if diff > 10 else ("📉 momo貴" if diff < -10 else "➖")
|
||
cells = [
|
||
(str(ri + 1), _DARK_TEXT),
|
||
(r.get("momo_name","")[:24],_DARK_TEXT),
|
||
(f"NT${r.get('momo_price',0):,.0f}", _DARK_TEXT),
|
||
(r.get("pc_name","")[:24], _DARK_TEXT),
|
||
(f"NT${r.get('pc_price',0):,.0f}", _DARK_TEXT),
|
||
(f"{diff:+,.0f} ({pct:+.1f}%)", diff_c),
|
||
(trend, diff_c),
|
||
]
|
||
row_t = 2.5 + ri * 0.9
|
||
for (txt, tc), w in zip(cells, col_w):
|
||
_add_rect(s3, x, row_t, w, 0.85, bg)
|
||
_add_text(s3, txt, x + 0.05, row_t + 0.05, w - 0.1, 0.75,
|
||
size=8, color=tc, align="center")
|
||
x += w + 0.1
|
||
|
||
_add_footer(s3, W)
|
||
|
||
# ── P4 AI 洞察 ────────────────────────────────────────────────────────────
|
||
s4 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s4, 0, 0, W, 19.05, _BG_DARK)
|
||
_add_header(s4, "AI 競品洞察 — 策略建議")
|
||
_add_text(s4, ai_text or "(AI 分析生成中)",
|
||
1.0, 2.0, W - 2.0, 11.0,
|
||
size=13, color=_WHITE, wrap=True)
|
||
_add_footer(s4, W)
|
||
|
||
path = _new_path("competitor")
|
||
prs.save(path)
|
||
return path
|
||
|
||
|
||
# ── 日報 PPT ──────────────────────────────────────────────────────────────────
|
||
def generate_daily_ppt(date_str: str, db_data, 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
|
||
|
||
s = db_data.get("sales", {}) if isinstance(db_data, dict) else {}
|
||
tp = db_data.get("top_products", []) 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))
|
||
|
||
# 封面
|
||
_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}", ""),
|
||
]
|
||
card_w = 7.4
|
||
for i, (col, lbl, val, sub) in enumerate(kpis):
|
||
_kpi_card(s2, i * (card_w + 0.4) + 0.5, 1.8, card_w, 3.0, col, lbl, val, sub)
|
||
|
||
# TOP5 商品
|
||
_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
|
||
_add_rect(s2, 0.5, 6.0 + i * 0.85, W - 1, 0.82, bg)
|
||
name = str(p.get("name",""))[:45]
|
||
rev_p = float(p.get("revenue", 0))
|
||
_add_text(s2, f"{i+1}. {name}", 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:AI
|
||
s3 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s3, 0, 0, W, 19.05, _BG_DARK)
|
||
_add_header(s3, "AI 洞察分析")
|
||
_add_text(s3, ai_text or "(AI 分析生成中)",
|
||
1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True)
|
||
_add_footer(s3, W)
|
||
|
||
path = _new_path("daily")
|
||
prs.save(path)
|
||
return path
|
||
|
||
|
||
# ── 週報 ──────────────────────────────────────────────────────────────────────
|
||
def generate_weekly_ppt(db_data, 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
|
||
|
||
_cover_slide(prs, "週報分析", "近 7 日業績週報",
|
||
f"生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
|
||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s2, 0, 0, W, 19.05, _BG_DARK)
|
||
_add_header(s2, "週報 AI 洞察")
|
||
_add_text(s2, ai_text or "(暫無 AI 分析)", 1.0, 2.0, W - 2.0, 11.0,
|
||
size=13, color=_WHITE, wrap=True)
|
||
_add_footer(s2, W)
|
||
path = _new_path("weekly")
|
||
prs.save(path)
|
||
return path
|
||
|
||
|
||
# ── 月報 ──────────────────────────────────────────────────────────────────────
|
||
def generate_monthly_ppt(yr, mo, db_data, 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
|
||
|
||
_cover_slide(prs, f"月報 {yr}/{mo:02d}", f"{yr} 年 {mo} 月業績月報",
|
||
f"生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
|
||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s2, 0, 0, W, 19.05, _BG_DARK)
|
||
_add_header(s2, f"月報 AI 洞察 — {yr}/{mo:02d}")
|
||
_add_text(s2, ai_text or "(暫無 AI 分析)", 1.0, 2.0, W - 2.0, 11.0,
|
||
size=13, color=_WHITE, wrap=True)
|
||
_add_footer(s2, W)
|
||
path = _new_path("monthly")
|
||
prs.save(path)
|
||
return path
|
||
|
||
|
||
# ── 策略報告 ──────────────────────────────────────────────────────────────────
|
||
def generate_strategy_ppt(date_str: str, db_data, 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
|
||
|
||
s = db_data.get("sales", {}) if isinstance(db_data, dict) else {}
|
||
rev = float(s.get("revenue", 0))
|
||
period = db_data.get("period_label", date_str) if isinstance(db_data, dict) else date_str
|
||
|
||
_cover_slide(prs, f"策略報告\n{period}",
|
||
f"業績 NT${rev:,.0f}({rev/10000:.1f}萬)",
|
||
f"生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
|
||
|
||
# KPI
|
||
ord_ = int(s.get("orders", 0))
|
||
gm = float(s.get("gross_margin", 0))
|
||
aov = float(s.get("avg_order", 0))
|
||
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)
|
||
|
||
# TOP 商品
|
||
tp = db_data.get("top_products", []) if isinstance(db_data, dict) else []
|
||
_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)
|
||
|
||
# AI
|
||
s3 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s3, 0, 0, W, 19.05, _BG_DARK)
|
||
_add_header(s3, "AI 策略洞察")
|
||
_add_text(s3, ai_text or "(暫無 AI 分析)", 1.0, 2.0, W - 2.0, 11.0,
|
||
size=13, color=_WHITE, wrap=True)
|
||
_add_footer(s3, W)
|
||
path = _new_path("strategy")
|
||
prs.save(path)
|
||
return path
|
||
|
||
|
||
# ── 促銷報告 ──────────────────────────────────────────────────────────────────
|
||
def generate_promo_ppt(promo_label: str, data, 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
|
||
|
||
_cover_slide(prs, f"促銷報告\n{promo_label}", "",
|
||
f"生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}")
|
||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||
_add_rect(s2, 0, 0, W, 19.05, _BG_DARK)
|
||
_add_header(s2, f"促銷 AI 洞察 — {promo_label}")
|
||
_add_text(s2, ai_text or "(暫無 AI 分析)", 1.0, 2.0, W - 2.0, 11.0,
|
||
size=13, color=_WHITE, wrap=True)
|
||
_add_footer(s2, W)
|
||
path = _new_path("promo")
|
||
prs.save(path)
|
||
return path
|