From 934adc957cfb3526f847bc95b30505f4e7a22b6a Mon Sep 17 00:00:00 2001 From: OoO Date: Sat, 2 May 2026 14:59:45 +0800 Subject: [PATCH] style(ppt): redesign ppt layouts, align palette with frontend, and add dedicated MCP RAG slide --- services/ppt_generator.py | 149 ++++++++++++++++++++++++++------------ 1 file changed, 104 insertions(+), 45 deletions(-) diff --git a/services/ppt_generator.py b/services/ppt_generator.py index 5388142..83f6aae 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -36,21 +36,21 @@ 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" +# ── 調色盤 (對齊前端現代化風格) ────────────────────────────────────────────────── +_BG_DARK = "1E3C72" # Web Navbar 深藍 +_BRAND_OG = "4F46E5" # Primary Indigo +_BRAND_OG2 = "6366F1" # Light Indigo _WHITE = "FFFFFF" -_LIGHT_GRAY = "F5F5F5" -_DARK_TEXT = "212121" -_SUBTEXT = "757575" -_FOOTER_BG = "37474F" -_BLUE_KPI = "1565C0" -_GREEN_KPI = "2E7D32" -_RED_WARN = "C62828" +_LIGHT_GRAY = "F8F9FA" # 柔和卡片灰 +_DARK_TEXT = "2C3E50" # 現代化深色文字 +_SUBTEXT = "6C757D" # 標籤灰 +_FOOTER_BG = "1F2937" # 底部深灰 +_BLUE_KPI = "3498DB" # Web Accent Blue +_GREEN_KPI = "10B981" # Emerald 綠 +_RED_WARN = "EF4444" # Red 警告 _BAR_PCHOME = "EF5350" _BAR_MOMO = "66BB6A" -_BAR_TIE = "FFA726" +_BAR_TIE = "F59E0B" _BAR_MISS = "9E9E9E" _STRAT_COLORS = { @@ -118,17 +118,24 @@ def _add_text(slide, text, l, t, w, h, 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 + 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 @@ -145,6 +152,15 @@ def _add_header(slide, title_text, prs_w_cm=33.87): 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) @@ -328,6 +344,16 @@ def _product_table_slide(prs, header_text, products, W=33.87): _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) @@ -420,8 +446,7 @@ def generate_daily_ppt(date_str: str, db_data, ai_text: str) -> str: _add_text(s3, f"近7日合計:NT${total_7d/10000:.1f}萬", 0.8, 13.0, 12, 0.5, size=10, color=_SUBTEXT) else: - _add_text(s3, "(近 7 日業績資料不足)", - 2, 7, 20, 2, size=14, color=_SUBTEXT, align="center") + _add_empty_state(s3, "近 7 日業績資料不足", "缺少 weekly 趨勢資料,已保留 KPI 與商品頁。", W) _add_footer(s3, W) # P4: AI 洞察 @@ -495,8 +520,7 @@ def generate_weekly_ppt(db_data, ai_text: str) -> str: 12.9, (W - 1.6) / max(len(dates), 1) - 0.1, 0.5, size=8, color=color, align="center") else: - _add_text(s3, "(近 7 日業績資料不足)", - 2, 7, 20, 2, size=14, color=_SUBTEXT, align="center") + _add_empty_state(s3, "近 7 日業績資料不足", "缺少 weekly 趨勢資料,已保留 KPI 與 TOP 商品頁。", W) _add_footer(s3, W) # P4: TOP10 商品表 @@ -515,9 +539,9 @@ def generate_weekly_ppt(db_data, ai_text: str) -> str: return path -# ── 月報 PPT(5頁)──────────────────────────────────────────────────────────── +# ── 月報 PPT(6頁)──────────────────────────────────────────────────────────── def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str: - """P1封面 P2 KPI P3 品類橫條圖 P4 TOP10商品 P5 AI洞察""" + """P1封面 P2 執行摘要 P3 品類橫條圖 P4 TOP10商品 P5 MCP市場情報 P6 AI洞察與行動建議""" from pptx import Presentation from pptx.util import Cm @@ -527,6 +551,8 @@ def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str: 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)) @@ -535,13 +561,13 @@ def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str: top_prod = ms.get('top_products', []) # P1: 封面 - _cover_slide(prs, f"月報 {yr}/{mo:02d}", f"{yr} 年 {mo} 月業績月報", + _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: KPI 卡 + # 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"月報 KPI — {yr}/{mo:02d}") + _add_header(s2, f"執行摘要 (Executive Summary) — {yr}/{mo:02d}") kpis = [ (_BLUE_KPI, "月業績", f"NT${rev/10000:.1f}萬", ""), (_GREEN_KPI, "總訂單", f"{ord_:,} 筆", ""), @@ -550,9 +576,24 @@ def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str: ] 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: 品類業績橫條圖(參考 monthly_summary_analysis.html vendorRankingChart) + # 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}(萬元)") @@ -562,28 +603,38 @@ def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str: _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}", + f"最高貢獻:{best.get('cat','')} NT${float(best.get('revenue',0)):,.0f}", 0.8, 13.15, 18, 0.5, size=10, color=_SUBTEXT) else: - _add_text(s3, "(本月無品類分佈資料)", - 2, 7, 20, 2, size=14, color=_SUBTEXT, align="center") + _add_empty_state(s3, "本月無品類分佈資料", "請確認月報期間是否已有分類欄位與銷售資料。", W) _add_footer(s3, W) # P4: TOP10 商品 - _product_table_slide(prs, f"熱銷商品 TOP 10 — {yr}/{mo:02d}", top_prod) + _product_table_slide(prs, f"核心動能:熱銷商品 TOP 10 — {yr}/{mo:02d}", top_prod) - # P5: AI 洞察 + # P5: MCP 市場情報 s5 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s5, 0, 0, W, 19.05, _BG_DARK) - _add_header(s5, f"月報 AI 洞察 — {yr}/{mo:02d}") - _add_text(s5, ai_text or "(暫無 AI 分析)", - 1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True) + _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 @@ -675,8 +726,7 @@ def generate_strategy_ppt(date_str: str, db_data, ai_text: str) -> str: size=11, color=_DARK_TEXT) desc_t += 0.68 else: - _add_text(s3, "(無策略分析資料)", - 2, 7, 20, 2, size=14, color=_SUBTEXT, align="center") + _add_empty_state(s3, "無策略分析資料", "策略矩陣需要商品銷售、毛利與期間對比資料。", W) _add_footer(s3, W) # P4: 策略行動清單(依策略優先序排列) @@ -697,6 +747,8 @@ def generate_strategy_ppt(date_str: str, db_data, ai_text: str) -> str: -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', '其他') @@ -917,6 +969,13 @@ def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> s 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