style(ppt): redesign ppt layouts, align palette with frontend, and add dedicated MCP RAG slide
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user