""" 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) # ── 調色盤 (對齊 EwoooC 新版設計 Token: 暖米基底/暖墨/焦糖橘) ────────────────────────── _BG_DARK = "2A2520" # momo-ink (暖墨色) _BRAND_OG = "C96442" # momo-accent (焦糖橘) _BRAND_OG2 = "8F4530" # momo-warm-mahogany (深焦糖) _WHITE = "FAF7F0" # momo-bg-surface (米白卡片底,取代純白) _LIGHT_GRAY = "EBE6DC" # momo-bg-body (米色工作台背景) _DARK_TEXT = "2A2520" # momo-text-primary (暖墨文字) _SUBTEXT = "645C52" # momo-text-secondary (次要文字) _FOOTER_BG = "3D362F" # momo-ink-soft (底部深灰) _BLUE_KPI = "2D5D80" # momo-info (中性藍) _GREEN_KPI = "2A7A3F" # momo-success (去飽和綠) _RED_WARN = "B5342F" # momo-danger (暖紅) _BAR_PCHOME = "EF5350" _BAR_MOMO = "66BB6A" _BAR_TIE = "B88416" # momo-warning (蜂蜜金) _BAR_MISS = "C4BAA8" # momo-text-disabled _STRAT_COLORS = { '加碼': _BRAND_OG, '機會': _BLUE_KPI, '收割': _GREEN_KPI, '觀察': _FOOTER_BG, '持穩': "90A4AE", '其他': _SUBTEXT, } _STRAT_ORDER = ['加碼', '機會', '收割', '觀察', '持穩'] 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 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 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) 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, 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 _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="DDDDDD") _add_text(slide, title, 2.8, 5.65, W - 5.6, 0.75, bold=True, size=18, color=_DARK_TEXT, align="center") _add_text(slide, detail, 3.2, 6.75, W - 6.4, 1.0, size=12, color=_SUBTEXT, align="center") 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): 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 # ── 封面頁 ──────────────────────────────────────────────────────────────────── def _cover_slide(prs, big_title: str, sub1: str, sub2: str = ""): slide = prs.slides.add_slide(prs.slide_layouts[6]) 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 # ── 通用商品表格投影片 ───────────────────────────────────────────────────────── def _product_table_slide(prs, header_text, products, W=33.87): slide = prs.slides.add_slide(prs.slide_layouts[6]) _add_rect(slide, 0, 0, W, 19.05, _WHITE) _add_header(slide, header_text) if not products: _add_empty_state( slide, "本頁沒有可顯示的商品資料", "請確認該日期或期間是否已有匯入業績資料,或改查最新有資料日期。", W, ) _add_footer(slide, W) return slide _add_rect(slide, 0.5, 1.7, W - 1, 0.65, _BRAND_OG) _add_text(slide, "#", 0.6, 1.73, 1.5, 0.59, bold=True, size=10, color=_WHITE, align="center") _add_text(slide, "商品名稱", 2.3, 1.73, W - 11.5, 0.59, bold=True, size=10, color=_WHITE) _add_text(slide, "業績", W - 8.5, 1.73, 5.5, 0.59, bold=True, size=10, color=_WHITE, align="right") _add_text(slide, "訂單", W - 2.8, 1.73, 2.2, 0.59, bold=True, size=9, color=_WHITE, align="right") for i, p in enumerate(products[:10]): bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE row_t = 2.5 + i * 0.88 _add_rect(slide, 0.5, row_t, W - 1, 0.85, bg) _add_text(slide, str(i + 1), 0.6, row_t + 0.1, 1.5, 0.7, size=10, color=_DARK_TEXT, align="center") _add_text(slide, str(p.get('name', ''))[:50], 2.3, row_t + 0.1, W - 11.5, 0.7, size=10, color=_DARK_TEXT) _add_text(slide, f"NT${float(p.get('revenue', 0)):,.0f}", W - 8.5, row_t + 0.1, 5.5, 0.7, size=10, color=_DARK_TEXT, align="right") ord_v = p.get('orders', p.get('order_count', '')) if ord_v: _add_text(slide, f"{int(ord_v):,}", W - 2.8, row_t + 0.1, 2.2, 0.7, size=9, color=_SUBTEXT, align="right") _add_footer(slide, W) return slide # ── 日報 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日業績走勢圖(來源:daily_sales.html trendChart) s3 = prs.slides.add_slide(prs.slide_layouts[6]) _add_rect(s3, 0, 0, W, 19.05, _WHITE) _add_header(s3, "近 7 日業績走勢(萬元)") if wk: dates = [str(w.get('date', ''))[-5:] for w in wk] revs = [float(w.get('revenue', 0)) for w in wk] _add_column_chart(s3, 0.8, 1.8, W - 1.6, 11.0, dates, [("日業績(萬元)", revs)], bar_colors=[_BRAND_OG]) # 加總標注 total_7d = sum(revs) _add_text(s3, f"近7日合計:NT${total_7d/10000:.1f}萬", 0.8, 13.0, 12, 0.5, size=10, color=_SUBTEXT) 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, 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("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日業績柱狀圖(參考 daily_sales.html trendChart) s3 = prs.slides.add_slide(prs.slide_layouts[6]) _add_rect(s3, 0, 0, W, 19.05, _WHITE) _add_header(s3, "近 7 日業績走勢圖(萬元)") if wk: dates = [str(w.get('date', ''))[-5:] for w in wk] revs = [float(w.get('revenue', 0)) for w in wk] _add_column_chart(s3, 0.8, 1.8, W - 1.6, 11.0, dates, [("日業績(萬元)", revs)], bar_colors=[_BLUE_KPI]) # 逐日數值標注(文字輔助) max_rev = max(revs) if revs else 1 for j, (d, r) in enumerate(zip(dates, revs)): color = _BRAND_OG if r == max_rev else _SUBTEXT _add_text(s3, f"{r/10000:.1f}", 0.8 + j * ((W - 1.6) / max(len(dates), 1)), 12.9, (W - 1.6) / max(len(dates), 1) - 0.1, 0.5, size=8, color=color, align="center") 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, 19.05, _BG_DARK) _add_header(s5, "週報 AI 洞察") _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("weekly") prs.save(path) return path # ── 月報 PPT(6頁)──────────────────────────────────────────────────────────── def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str: """P1封面 P2 執行摘要 P3 品類橫條圖 P4 TOP10商品 P5 MCP市場情報 P6 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 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', []) # P1: 封面 _cover_slide(prs, f"月度營運報告\n{yr} 年 {mo:02d} 月", f"全面解析與 AI 智能洞察", f"業績 NT${rev:,.0f}({rev/10000:.1f}萬)|生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}") # P2: 執行摘要 (Executive Summary) s2 = prs.slides.add_slide(prs.slide_layouts[6]) _add_rect(s2, 0, 0, W, 19.05, _WHITE) _add_header(s2, f"執行摘要 (Executive Summary) — {yr}/{mo:02d}") 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.5, col, lbl, val, sub) _add_rect(s2, 0.5, 6.0, W - 1, 1.0, _BG_DARK) _add_text(s2, "高階營運解讀", 0.7, 6.2, W - 1.4, 0.8, bold=True, size=14, color=_WHITE) _add_rect(s2, 0.5, 7.0, W - 1, 5.5, _LIGHT_GRAY) # 萃取前一段文字作為摘要 summary_text = "" for line in ai_text.split('\n'): if line.strip() and not line.startswith('【'): summary_text += line + "\n" if len(summary_text) > 150: break if not summary_text.strip(): summary_text = ai_text[:200] + "..." if ai_text else "(暫無 AI 分析)" _add_text(s2, summary_text.strip(), 1.0, 7.3, W - 2.0, 4.5, size=13, color=_DARK_TEXT, wrap=True) _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"品類業績排行 TOP 8 — {yr}/{mo:02d}(萬元)") if top_cats: cats = [c.get('cat', '')[:15] for c in top_cats[:8]] revs = [float(c.get('revenue', 0)) for c in top_cats[:8]] _add_horiz_chart(s3, 0.5, 1.8, W - 1, 11.3, cats, [("業績(萬元)", revs)], bar_colors=[_BLUE_KPI]) if top_cats: best = top_cats[0] _add_text(s3, f"最高貢獻:{best.get('cat','')} NT${float(best.get('revenue',0)):,.0f}", 0.8, 13.15, 18, 0.5, size=10, color=_SUBTEXT) else: _add_empty_state(s3, "本月無品類分佈資料", "請確認月報期間是否已有分類欄位與銷售資料。", W) _add_footer(s3, W) # P4: TOP10 商品 _product_table_slide(prs, f"核心動能:熱銷商品 TOP 10 — {yr}/{mo:02d}", top_prod) # P5: MCP 市場情報 s5 = prs.slides.add_slide(prs.slide_layouts[6]) _add_rect(s5, 0, 0, W, 19.05, _WHITE) _add_header(s5, "🌐 專案 RAG:MCP 外部市場情報與競品監控") mcp_display = mcp_text if mcp_text else "(未擷取到最新的 MCP 市場或競品情報,或者當前無需特別關注之外部風險)" _add_rect(s5, 1.0, 2.0, W - 2.0, 11.0, _BG_DARK) _add_text(s5, mcp_display, 1.5, 2.5, W - 3.0, 10.0, size=12, color=_WHITE, wrap=True) _add_footer(s5, W) # P6: AI 策略洞察與行動建議 s6 = prs.slides.add_slide(prs.slide_layouts[6]) _add_rect(s6, 0, 0, W, 19.05, _WHITE) _add_header(s6, f"🎯 專案分析與行動建議 (AI Agent Insights)") _add_rect(s6, 1.0, 2.0, W - 2.0, 11.0, _LIGHT_GRAY, line_hex="CCCCCC") _add_text(s6, ai_text or "(暫無 AI 分析)", 1.5, 2.5, W - 3.0, 10.0, size=13, color=_DARK_TEXT, wrap=True) _add_footer(s6, W) path = _new_path("monthly") prs.save(path) return path # ── 策略報告 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, 19.05, _BG_DARK) _add_header(s5, "AI 策略洞察") _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("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, 19.05, _BG_DARK) _add_header(s5, f"促銷 AI 洞察 — {promo_label}") _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("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] 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}") 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) 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 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, "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, 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(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: """P1封面 P2 KPI P3 廠商橫條圖(2年對比) P4 廠商明細表 P5 AI洞察 db_data: {vendor_ranking: [{name, sales, sales_2024, sales_2025, profit, margin}], kpis: {total_sales, total_profit, avg_margin, vendor_count}} 對應 monthly_summary_analysis.html: vendorRankingChart """ 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 [] 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) # P1: 封面 _cover_slide(prs, f"廠商業績報告\n{period_lbl}", f"合計 {vcount} 家廠商 | 總業績 NT${total_sales/10000:.1f}萬", f"平均毛利率 {avg_margin:.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_lbl}") top1 = vendors[0] if vendors else {} kpi_cards = [ (_BLUE_KPI, "廠商總數", f"{vcount} 家", ""), (_GREEN_KPI, "合計業績", f"NT${total_sales/10000:.1f}萬", ""), (_BRAND_OG2, "合計毛利", f"NT${total_profit/10000:.1f}萬", ""), (_FOOTER_BG, "平均毛利率", f"{avg_margin:.1f}%", ""), ] for i, (col, lbl, val, sub) in enumerate(kpi_cards): _kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.5, col, lbl, val, sub) if top1: _add_rect(s2, 0.5, 5.6, W - 1, 0.7, _BRAND_OG) _add_text(s2, f"🏆 業績第一:{top1.get('name','')[:20]} NT${float(top1.get('sales',0)):,.0f} 毛利率 {top1.get('margin',0):.1f}%", 0.7, 5.65, W - 1.4, 0.6, bold=True, size=12, color=_WHITE, align="center") _add_footer(s2, W) # P3: TOP 20 廠商橫條圖 — 2024 vs 2025(對應 vendorRankingChart) s3 = prs.slides.add_slide(prs.slide_layouts[6]) _add_rect(s3, 0, 0, W, 19.05, _WHITE) _add_header(s3, f"TOP 20 廠商業績排行 — {period_lbl}(萬元)") if vendors: top20 = vendors[:20] names = [v.get('name', '')[:15] for v in top20] s24_vals = [float(v.get('sales_2024', 0)) for v in top20] s25_vals = [float(v.get('sales_2025', 0)) for v in top20] has_both = any(s24_vals) and any(s25_vals) if has_both: _add_horiz_chart(s3, 0.5, 1.8, W - 1, 11.3, names, [("2024", s24_vals), ("2025", s25_vals)], bar_colors=[_FOOTER_BG, _BLUE_KPI]) else: revs = [float(v.get('sales', 0)) for v in top20] _add_horiz_chart(s3, 0.5, 1.8, W - 1, 11.3, names, [("業績(萬元)", revs)], bar_colors=[_BLUE_KPI]) _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"廠商業績明細 TOP 15 — {period_lbl}") _add_rect(s4, 0.5, 1.7, W - 1, 0.65, _BRAND_OG) hdrs = ["#", "廠商名稱", "總業績", "毛利額", "毛利率"] col_ws = [1.2, 13.0, 6.5, 6.5, 3.5] x = 0.6 for h, cw in zip(hdrs, col_ws): _add_text(s4, h, x, 1.73, cw, 0.59, bold=True, size=10, color=_WHITE, align="center" if h != "廠商名稱" else "left") x += cw + 0.1 for i, v in enumerate(vendors[:15]): bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE row_t = 2.5 + i * 0.66 _add_rect(s4, 0.5, row_t, W - 1, 0.63, bg) x = 0.6 cells = [ (str(i+1), "center"), (str(v.get('name', ''))[:30], "left"), (f"NT${float(v.get('sales',0)):,.0f}", "right"), (f"NT${float(v.get('profit',0)):,.0f}", "right"), (f"{v.get('margin',0):.1f}%", "right"), ] for (txt, al), cw in zip(cells, col_ws): _add_text(s4, txt, x, row_t + 0.06, cw, 0.52, size=9, color=_DARK_TEXT, align=al) x += cw + 0.1 _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 廠商洞察 — {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("vendor") prs.save(path) return path # ── 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