From 4c8edecd12b24303ad066e2ac0a30abde50dbe74 Mon Sep 17 00:00:00 2001 From: ogt Date: Mon, 20 Apr 2026 06:56:14 +0800 Subject: [PATCH] feat: rewrite ppt_generator.py with premium dark-theme design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/ppt_generator.py | 580 ++++++++++++++++++++++++++++++-------- 1 file changed, 470 insertions(+), 110 deletions(-) diff --git a/services/ppt_generator.py b/services/ppt_generator.py index c76ab92..cf7df31 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -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