feat: rewrite ppt_generator.py with premium dark-theme design
All checks were successful
CD Pipeline / deploy (push) Successful in 1m22s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m22s
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
This commit is contained in:
@@ -1,11 +1,23 @@
|
||||
"""
|
||||
services/ppt_generator.py
|
||||
緊急復原版 (2026-04-18) — 原始檔案 4/17 間遺失
|
||||
依 openclaw_bot_routes.py 呼叫約定提供 7 個 function:
|
||||
check_pptx_available, generate_daily_ppt, generate_weekly_ppt,
|
||||
generate_monthly_ppt, generate_strategy_ppt, generate_competitor_ppt,
|
||||
generate_promo_ppt
|
||||
每個 generate_* 回傳 pptx 檔案路徑 (str)。
|
||||
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
|
||||
@@ -15,6 +27,23 @@ 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:
|
||||
@@ -29,134 +58,465 @@ def _new_path(kind: str) -> str:
|
||||
return str(REPORTS_DIR / f"ocbot_{kind}_{rid}.pptx")
|
||||
|
||||
|
||||
def _format_data(data) -> str:
|
||||
if data is None:
|
||||
return "(無資料)"
|
||||
if isinstance(data, dict):
|
||||
if not data:
|
||||
return "(空 dict)"
|
||||
lines = []
|
||||
for k, v in list(data.items())[:30]:
|
||||
line = f"• {k}: {v}"
|
||||
lines.append(line[:200])
|
||||
return "\n".join(lines)
|
||||
if isinstance(data, list):
|
||||
if not data:
|
||||
return "(空 list)"
|
||||
lines = []
|
||||
for item in data[:20]:
|
||||
lines.append(f"• {str(item)[:200]}")
|
||||
return "\n".join(lines)
|
||||
return str(data)[:2000]
|
||||
# ── 基礎繪圖工具 ──────────────────────────────────────────────────────────────
|
||||
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 _build_ppt(filename_kind: str, title: str, subtitle: str, sections: list) -> str:
|
||||
"""sections = [(heading, body), ...]"""
|
||||
from pptx import Presentation
|
||||
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
|
||||
|
||||
# 標題頁
|
||||
s = prs.slides.add_slide(prs.slide_layouts[0])
|
||||
if s.shapes.title is not None:
|
||||
s.shapes.title.text = title
|
||||
if len(s.placeholders) > 1:
|
||||
s.placeholders[1].text = subtitle
|
||||
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)
|
||||
|
||||
# 內容頁
|
||||
for heading, body in sections:
|
||||
s = prs.slides.add_slide(prs.slide_layouts[1])
|
||||
if s.shapes.title is not None:
|
||||
s.shapes.title.text = heading
|
||||
body_text = body if isinstance(body, str) else _format_data(body)
|
||||
body_text = body_text[:3500]
|
||||
if len(s.placeholders) > 1:
|
||||
tf = s.placeholders[1].text_frame
|
||||
tf.text = body_text
|
||||
for p in tf.paragraphs:
|
||||
for r in p.runs:
|
||||
r.font.size = Pt(14)
|
||||
# ── 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')}",
|
||||
)
|
||||
|
||||
# 產生路徑並存檔
|
||||
path = _new_path(filename_kind)
|
||||
# ── 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:
|
||||
sections = [
|
||||
("本日銷售概況", _format_data(db_data)),
|
||||
("AI 洞察", ai_text or "(暫無 AI 分析)"),
|
||||
]
|
||||
return _build_ppt(
|
||||
"daily_daily",
|
||||
f"日報 {date_str}",
|
||||
f"生成時間 {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
sections,
|
||||
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:
|
||||
sections = [
|
||||
("本週銷售概況", _format_data(db_data)),
|
||||
("AI 洞察", ai_text or "(暫無 AI 分析)"),
|
||||
]
|
||||
return _build_ppt(
|
||||
"weekly",
|
||||
"週報",
|
||||
f"生成時間 {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
sections,
|
||||
)
|
||||
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:
|
||||
sections = [
|
||||
(f"{yr}/{mo} 月度概況", _format_data(db_data)),
|
||||
("AI 洞察", ai_text or "(暫無 AI 分析)"),
|
||||
]
|
||||
return _build_ppt(
|
||||
"monthly",
|
||||
f"月報 {yr}/{mo}",
|
||||
f"生成時間 {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
sections,
|
||||
)
|
||||
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:
|
||||
sections = [
|
||||
("策略資料", _format_data(db_data)),
|
||||
("AI 洞察", ai_text or "(暫無 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 {}
|
||||
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}", ""),
|
||||
]
|
||||
return _build_ppt(
|
||||
"strategy",
|
||||
f"策略報告 {date_str}",
|
||||
f"生成時間 {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
sections,
|
||||
)
|
||||
|
||||
|
||||
def generate_competitor_ppt(period_label: str, db_data, ai_text: str) -> str:
|
||||
sections = [
|
||||
(f"競品資料 ({period_label})", _format_data(db_data)),
|
||||
("AI 洞察", ai_text or "(暫無 AI 分析)"),
|
||||
]
|
||||
return _build_ppt(
|
||||
"competitor",
|
||||
f"競品分析 {period_label}",
|
||||
f"生成時間 {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
sections,
|
||||
)
|
||||
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:
|
||||
sections = [
|
||||
(f"促銷資料 ({promo_label})", _format_data(data)),
|
||||
("AI 洞察", ai_text or "(暫無 AI 分析)"),
|
||||
]
|
||||
return _build_ppt(
|
||||
"promo",
|
||||
f"促銷報告 {promo_label}",
|
||||
f"生成時間 {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
sections,
|
||||
)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user