style(ppt): redesign ppt layouts, align palette with frontend, and add dedicated MCP RAG slide

This commit is contained in:
OoO
2026-05-02 14:59:45 +08:00
parent 9068d463bb
commit 934adc957c

View File

@@ -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
# ── 月報 PPT5頁)────────────────────────────────────────────────────────────
# ── 月報 PPT6頁)────────────────────────────────────────────────────────────
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, "🌐 專案 RAGMCP 外部市場情報與競品監控")
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