""" 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