feat: PPT 簡報系統 V2 — 新增 growth/vendor/bcg 三種報告 + 原生圖表升級
All checks were successful
CD Pipeline / deploy (push) Successful in 1m15s

- ppt_generator.py: 新增 generate_growth_ppt(6頁)、generate_vendor_ppt(5頁)、generate_bcg_ppt(5頁)
- openclaw_bot_routes.py: 新增 query_growth_data()、query_vendor_bcg_data()、_generate_ppt_cmd 三路分支、_submenu_reports 4顆新按鈕、type_labels、await:date_ppt_vendor 流程
- ADR-014: 記錄 V2 完整架構(9種報告類型、圖表技術方案、callback_data 格式)
- CLAUDE.md: 新增 PPT 簡報系統索引表

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ogt
2026-04-20 20:26:47 +08:00
parent d349b09afd
commit 48804553cd
4 changed files with 752 additions and 2 deletions

View File

@@ -63,6 +63,20 @@ ssh wooo@192.168.0.110 "ssh ollama@192.168.0.188 \"\
| AIOps 存檔 | [docs/external/aiops_saas.md](docs/external/aiops_saas.md) |
| 跨專案隔離(**必讀**| [docs/adr/ADR-011-cross-project-resource-isolation.md](docs/adr/ADR-011-cross-project-resource-isolation.md) |
| **AIOps 自動修復ADR-013** | [docs/adr/ADR-013-aiops-autoheal.md](docs/adr/ADR-013-aiops-autoheal.md) |
| **PPT 簡報系統 V2ADR-014** | [docs/adr/ADR-014-ppt-report-system-v2.md](docs/adr/ADR-014-ppt-report-system-v2.md) |
## PPT 簡報系統9 種V2
| 類型 | 頁數 | Telegram 指令 | 核心圖表 |
|------|------|-------------|---------|
| daily 日報 | 4 | `cmd:ppt:daily` | 近7日業績柱狀圖 |
| weekly 週報 | 5 | `cmd:ppt:weekly` | 7日走勢+TOP10 |
| monthly 月報 | 5 | `cmd:ppt:monthly` | 品類橫條+TOP10 |
| strategy 策略 | 5 | `cmd:ppt:strategy [範圍]` | 策略矩陣分佈+行動清單 |
| competitor 競品 | 4 | `cmd:ppt:competitor [範圍]` | 橫條圖+比較表 |
| promo 促銷 | 5 | `await:promo_range` | 雙層KPI+雙柱業績對比 |
| **growth** 成長趨勢 | **6** | `cmd:ppt:growth` | 月營收+MoM+AOV+毛利率 |
| **vendor** 廠商業績 | **5** | `cmd:ppt:vendor [YYYY/MM]` | TOP20廠商2024/2025對比 |
| **bcg** 品牌矩陣 | **5** | `cmd:ppt:bcg [YYYY/MM]` | BCG象限表+區域分佈 |
## AI 開發鐵律Token 優化)

View File

@@ -0,0 +1,102 @@
# ADR-014: PPT 簡報系統 V2 — 原生圖表 + 9 種報告類型
**狀態**: Accepted
**日期**: 2026-04-20
**提案者**: 統帥
---
## 背景與問題
PPT 簡報系統 V12026-04-20 v1只有 6 種報告,其中 weekly/monthly/promo/strategy 頁面為「空殼」——資料庫的數字完全沒有視覺化呈現,只有 AI 純文字。
同時缺少對應網站核心圖表growth_analysis / monthly_summary_analysis的報告類型。
---
## 決策
升級為 V22026-04-20 v2做以下改動
### 1. 現有 6 種報告大幅升級
| 類型 | 舊頁數 | 新頁數 | 新增內容 |
|------|-------|-------|---------|
| daily | 3 | **4** | P3 近7日業績柱狀圖 |
| weekly | 2 | **5** | P2 KPI、P3 7日走勢圖、P4 TOP10商品表 |
| monthly | 2 | **5** | P2 KPI、P3 品類橫條圖、P4 TOP10商品表 |
| strategy | 3 | **5** | P3 策略矩陣柱狀圖、P4 彩色策略行動清單 |
| promo | 2 | **5** | P2 雙層KPI+升降幅、P3 雙柱業績對比、P4 TOP商品 |
| competitor | 4 | **4** | 架構不變,已是最完整 |
### 2. 新增 3 種報告
| 類型 | 頁數 | 對應網站頁面 | 資料來源 |
|------|------|------------|---------|
| **growth** 成長趨勢 | 6 | growth_analysis | `realtime_sales_monthly` |
| **vendor** 廠商業績 | 5 | monthly_summary_analysis vendorRankingChart | `monthly_summary_analysis` |
| **bcg** 品牌矩陣 | 5 | monthly_summary_analysis bcgMatrixChart | `monthly_summary_analysis` |
### 3. 圖表技術方案
- 使用 python-pptx 原生 Chart API`_add_column_chart` / `_add_horiz_chart`
- 對應前端使用的 Chart.js (daily_sales) / ECharts (monthly_summary)
- 每種圖表 fallback 至空白提示,不 crash
### 4. Telegram 新按鈕
`_submenu_reports()` 新增 2 列:
```
[📈 成長趨勢報告] [🏭 廠商業績報告]
[🎯 BCG 品牌矩陣] [📅 指定月份廠商]
```
### 5. 新增 Query 函數
- `query_growth_data()` — 複用 growth_analysis 路由邏輯
- `query_vendor_bcg_data(yr, mo)` — 直接 raw SQL 查 monthly_summary_analysis
---
## 架構圖(完整 PPT 流程)
```
Telegram 按鈕點擊
↓ callback_data: cmd:ppt:TYPE [ARG]
handle_cmd('ppt', 'TYPE ARG', chat_id, reply_to)
_ppt_background() [threading.Thread daemon]
↓ 發送「⏳ 正在生成...」
_generate_ppt_cmd(sub_type, sub_arg, chat_id, target)
↓ query_xxx_data() → DB (realtime_sales_monthly / monthly_summary_analysis)
↓ _ppt_ai_analysis() → Gemini 2.0 Flash / NVIDIA DeepSeek 備援
↓ generate_xxx_ppt() → /app/data/reports/ocbot_TYPE_XXXXXXXX.pptx
send_document(chat_id, ppt_path, caption)
↓ os.unlink(ppt_path)
```
---
## 相關檔案
| 檔案 | 角色 |
|------|------|
| `services/ppt_generator.py` | 9 種 PPT 生成函數 + 圖表 helper |
| `routes/openclaw_bot_routes.py` | query 函數、_generate_ppt_cmd、按鈕、type_labels |
| `routes/sales_routes.py` | growth_analysis 路由(資料來源參考)|
| `routes/monthly_routes.py` | monthly_summary API資料來源參考|
---
## 支援的 callback_data 格式
```
cmd:ppt:daily [YYYY/MM/DD]
cmd:ppt:weekly
cmd:ppt:monthly [YYYY/MM]
cmd:ppt:strategy [daily/weekly/monthly/quarterly/half/yearly]
cmd:ppt:competitor [daily/weekly/monthly/quarterly/half/yearly]
cmd:ppt:promo → await:promo_range
cmd:ppt:growth
cmd:ppt:vendor [YYYY/MM]
cmd:ppt:bcg [YYYY/MM]
```

View File

@@ -865,6 +865,167 @@ def query_anomalies(date_str):
return []
def query_growth_data() -> dict:
"""成長趨勢報告資料 — 複用 growth_analysis 路由的同款邏輯。
回傳 {chart_data: {labels, revenue, mom, yoy, aov, margin_rate, profit, orders},
kpi: {ytd_revenue, ytd_growth, current_year, recent_aov, total_orders}}
"""
import pandas as pd
from datetime import timezone, timedelta
TAIPEI_TZ = timezone(timedelta(hours=8))
try:
df = pd.read_sql(
text('SELECT "日期", "總業績", "訂單編號", "總成本" FROM realtime_sales_monthly'),
_db()
)
if df.empty:
return {}
df['dt'] = pd.to_datetime(df['日期'], errors='coerce')
df = df.dropna(subset=['dt'])
df['amount'] = pd.to_numeric(df['總業績'], errors='coerce').fillna(0)
df['cost'] = pd.to_numeric(df['總成本'], errors='coerce').fillna(0)
df['profit'] = df['amount'] - df['cost']
monthly = df.set_index('dt').resample('MS').agg(
{'amount': 'sum', 'profit': 'sum', '訂單編號': 'nunique'}
).rename(columns={'訂單編號': 'orders'})
monthly['aov'] = monthly['amount'] / monthly['orders'].replace(0, 1)
monthly['margin_rate'] = (monthly['profit'] / monthly['amount'].replace(0, 1)) * 100
monthly['mom'] = monthly['amount'].pct_change() * 100
monthly['yoy'] = monthly['amount'].pct_change(periods=12) * 100
monthly = monthly.fillna(0)
labels = monthly.index.strftime('%Y-%m').tolist()
chart_data = {
'labels': labels,
'revenue': monthly['amount'].tolist(),
'profit': monthly['profit'].tolist(),
'orders': monthly['orders'].tolist(),
'aov': monthly['aov'].round(0).tolist(),
'mom': monthly['mom'].round(2).tolist(),
'yoy': monthly['yoy'].round(2).tolist(),
'margin_rate': monthly['margin_rate'].round(1).tolist(),
}
curr_yr = df['dt'].max().year
ytd_mask = df['dt'].dt.year == curr_yr
ly_mask = (df['dt'].dt.year == curr_yr - 1) & \
(df['dt'].dt.dayofyear <= df['dt'].max().dayofyear)
ytd_rev = float(df.loc[ytd_mask, 'amount'].sum())
ly_ytd_rev = float(df.loc[ly_mask, 'amount'].sum())
ytd_growth = (ytd_rev - ly_ytd_rev) / ly_ytd_rev * 100 if ly_ytd_rev else 0
recent_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30))
recent_rev = float(df.loc[recent_mask, 'amount'].sum())
recent_ord = int(df.loc[recent_mask, '訂單編號'].nunique())
recent_aov = recent_rev / recent_ord if recent_ord else 0
return {
'chart_data': chart_data,
'kpi': {
'ytd_revenue': ytd_rev,
'ytd_growth': round(ytd_growth, 1),
'current_year': curr_yr,
'recent_aov': round(recent_aov, 0),
'total_orders': int(monthly['orders'].sum()),
}
}
except Exception as e:
sys_log.error(f"[OpenClawBot] query_growth_data: {e}")
return {}
def query_vendor_bcg_data(yr: int = None, mo: int = None) -> dict:
"""廠商業績 + BCG 矩陣資料(來自 monthly_summary_analysis 表)。
回傳 {vendor_ranking: [...], bcg_data: [...], division_dist: [...], kpis: {...}}
"""
try:
yr_filter = f"AND year = {int(yr)}" if yr else ""
mo_filter = f"AND month = {int(mo)}" if mo else ""
with _db().connect() as c:
# 廠商排行(不限年度,做 2024/2025 對比)
vr = c.execute(text(f"""
SELECT vendor_name,
SUM(sales_amt_curr) AS sales,
SUM(CASE WHEN year=2024 THEN sales_amt_curr ELSE 0 END) AS s24,
SUM(CASE WHEN year=2025 THEN sales_amt_curr ELSE 0 END) AS s25,
SUM(profit_amt_curr) AS profit,
SUM(CASE WHEN year=2024 THEN profit_amt_curr ELSE 0 END) AS p24,
SUM(CASE WHEN year=2025 THEN profit_amt_curr ELSE 0 END) AS p25
FROM monthly_summary_analysis
WHERE vendor_name IS NOT NULL AND vendor_name != ''
{mo_filter}
GROUP BY vendor_name
ORDER BY sales DESC LIMIT 20
""")).fetchall()
# BCG 矩陣(品牌 x 區域)
bq = c.execute(text(f"""
SELECT brand_name || '-' || area_name AS name,
SUM(sales_vol_curr) AS qty,
SUM(sales_amt_curr) AS sales,
SUM(profit_amt_curr) AS profit
FROM monthly_summary_analysis
WHERE sales_amt_curr > 0
{yr_filter} {mo_filter}
GROUP BY brand_name, area_name
ORDER BY sales DESC LIMIT 100
""")).fetchall()
# 區域分佈
dq = c.execute(text(f"""
SELECT area_name,
SUM(sales_amt_curr) AS sales,
SUM(CASE WHEN year=2024 THEN sales_amt_curr ELSE 0 END) AS s24,
SUM(CASE WHEN year=2025 THEN sales_amt_curr ELSE 0 END) AS s25
FROM monthly_summary_analysis
WHERE area_name IS NOT NULL AND area_name != ''
{mo_filter}
GROUP BY area_name
ORDER BY sales DESC LIMIT 12
""")).fetchall()
# KPI 總覽
kq = c.execute(text(f"""
SELECT SUM(sales_amt_curr) AS total_sales,
SUM(profit_amt_curr) AS total_profit
FROM monthly_summary_analysis
WHERE 1=1 {yr_filter} {mo_filter}
""")).fetchone()
vendor_ranking = [
{'name': r[0], 'sales': int(r[1] or 0),
'sales_2024': int(r[2] or 0), 'sales_2025': int(r[3] or 0),
'profit': int(r[4] or 0),
'profit_2024': int(r[5] or 0), 'profit_2025': int(r[6] or 0),
'margin': round(r[4] / r[1] * 100, 1) if r[1] and r[4] else 0}
for r in vr
]
bcg_data = [
{'name': r[0], 'qty': int(r[1] or 0), 'sales': int(r[2] or 0),
'margin': round(r[3] / r[2] * 100, 1) if r[2] and r[3] else 0}
for r in bq
]
division_dist = [
{'name': r[0], 'sales': int(r[1] or 0),
'sales_2024': int(r[2] or 0), 'sales_2025': int(r[3] or 0)}
for r in dq
]
ts = float(kq[0] or 0) if kq else 0
tp = float(kq[1] or 0) if kq else 0
kpis = {
'total_sales': ts,
'total_profit': tp,
'avg_margin': round(tp / ts * 100, 1) if ts else 0,
'vendor_count': len(vendor_ranking),
}
return {'vendor_ranking': vendor_ranking, 'bcg_data': bcg_data,
'division_dist': division_dist, 'kpis': kpis}
except Exception as e:
sys_log.error(f"[OpenClawBot] query_vendor_bcg_data: {e}")
return {}
# ── 目標管理 ──────────────────────────────────────────────────
def get_goal_status(date_str: str) -> dict:
@@ -1981,6 +2142,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) -
generate_daily_ppt, generate_weekly_ppt,
generate_monthly_ppt, generate_strategy_ppt,
generate_competitor_ppt, generate_promo_ppt,
generate_growth_ppt, generate_vendor_ppt, generate_bcg_ppt,
check_pptx_available
)
except ImportError:
@@ -2279,8 +2441,78 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) -
ai_text_p = _ppt_ai_analysis(data_summary_p, f'促銷效益分析({promo_label_p}')
return generate_promo_ppt(promo_label_p, data_p, ai_text_p)
elif sub_type in ('growth', '成長', '趨勢'):
gd = query_growth_data()
if not gd:
raise ValueError("無足夠月度資料生成成長趨勢報告")
kpi_g = gd.get('kpi', {})
cd_g = gd.get('chart_data', {})
labels = cd_g.get('labels', [])
rev_g = cd_g.get('revenue', [])
data_summary_g = (
f"成長趨勢報告\n"
f"YTD 累計業績NT$ {kpi_g.get('ytd_revenue',0):,.0f} "
f"年增率:{kpi_g.get('ytd_growth',0):+.1f}%\n"
f"近30日客單價NT$ {kpi_g.get('recent_aov',0):,.0f}\n"
f"月度業績近6月" + " / ".join(
f"{labels[i]}(NT${float(rev_g[i])/10000:.1f}萬)"
for i in range(max(0, len(labels)-6), len(labels))) + "\n"
f"MoM 近3月" + " / ".join(
f"{cd_g.get('mom',[])[i]:+.1f}%"
for i in range(max(0, len(cd_g.get('mom',[]))-3), len(cd_g.get('mom',[]))))
)
ai_text_g = _ppt_ai_analysis(data_summary_g, '成長趨勢報告')
return generate_growth_ppt(gd, ai_text_g)
elif sub_type in ('vendor', '廠商'):
yr_v = now.year
mo_v = now.month
if sub_arg:
parts = sub_arg.replace('-', '/').split('/')
if len(parts) >= 2:
yr_v, mo_v = int(parts[0]), int(parts[1])
vd = query_vendor_bcg_data(yr_v, mo_v)
if not vd or not vd.get('vendor_ranking'):
raise ValueError("無廠商業績資料monthly_summary_analysis 表可能尚未匯入)")
vendors = vd['vendor_ranking']
kpi_v = vd['kpis']
period_v = f"{yr_v}/{mo_v:02d}"
vd['period_label'] = period_v
top5_v = " / ".join(f"{v['name'][:10]}(NT${v['sales']/10000:.1f}萬)" for v in vendors[:5])
data_summary_v = (
f"廠商業績報告 {period_v}\n"
f"廠商總數:{kpi_v.get('vendor_count',0)}\n"
f"合計業績NT$ {kpi_v.get('total_sales',0):,.0f}\n"
f"平均毛利率:{kpi_v.get('avg_margin',0):.1f}%\n"
f"TOP5 廠商:{top5_v}"
)
ai_text_v = _ppt_ai_analysis(data_summary_v, f'廠商業績報告({period_v}')
return generate_vendor_ppt(yr_v, mo_v, vd, ai_text_v)
elif sub_type in ('bcg', 'BCG', '品牌矩陣', '矩陣'):
yr_b = now.year
mo_b = now.month
if sub_arg:
parts = sub_arg.replace('-', '/').split('/')
if len(parts) >= 2:
yr_b, mo_b = int(parts[0]), int(parts[1])
bd = query_vendor_bcg_data(yr_b, mo_b)
if not bd or not bd.get('bcg_data'):
raise ValueError("無 BCG 矩陣資料monthly_summary_analysis 表可能尚未匯入)")
kpi_b = bd['kpis']
period_b = f"{yr_b}/{mo_b:02d}"
bd['period_label'] = period_b
data_summary_b = (
f"BCG 品牌矩陣報告 {period_b}\n"
f"分析組合數:{len(bd['bcg_data'])} 個(品牌×區域)\n"
f"總業績NT$ {kpi_b.get('total_sales',0):,.0f}\n"
f"平均毛利率:{kpi_b.get('avg_margin',0):.1f}%\n"
)
ai_text_b = _ppt_ai_analysis(data_summary_b, f'BCG 品牌策略報告({period_b}')
return generate_bcg_ppt(yr_b, mo_b, bd, ai_text_b)
else:
raise ValueError(f"不支援的簡報類型:{sub_type}支援daily / weekly / monthly / strategy / competitor / promo")
raise ValueError(f"不支援的簡報類型:{sub_type}支援daily/weekly/monthly/strategy/competitor/promo/growth/vendor/bcg")
# ── Telegram Excel 匯入 ──────────────────────────────────────────
@@ -3070,6 +3302,11 @@ def _submenu_reports():
# ── 促銷 & 競品
[{'text': '🎉 促銷效益簡報', 'callback_data': 'await:promo_range'},
{'text': '🔍 競品比較', 'callback_data': 'menu:competitor'}],
# ── 新增:成長趨勢 / 廠商 / BCG
[{'text': '📈 成長趨勢報告', 'callback_data': 'cmd:ppt:growth'},
{'text': '🏭 廠商業績報告', 'callback_data': 'cmd:ppt:vendor'}],
[{'text': '🎯 BCG 品牌矩陣', 'callback_data': 'cmd:ppt:bcg'},
{'text': '📅 指定月份廠商', 'callback_data': 'await:date_ppt_vendor'}],
# ── 自訂
[{'text': '📅 指定日期日報', 'callback_data': 'await:date_ppt_daily'},
{'text': '📅 指定月份月報', 'callback_data': 'await:date_ppt_monthly'}],
@@ -3145,8 +3382,9 @@ _AWAIT_PROMPTS = {
),
'date_top': ('📅 請輸入查詢日期\n格式:`2026/04/15`', '商品日期'),
'date_analysis': ('📅 請輸入分析日期\n格式:`2026/04/15`', '分析日期'),
'date_ppt_daily': ('📅 請輸入日報日期\n格式:`2026/04/15`', 'PPT日期'),
'date_ppt_daily': ('📅 請輸入日報日期\n格式:`2026/04/15`', 'PPT日期'),
'date_ppt_monthly':('📅 請輸入月報月份\n格式:`2026/03`', 'PPT月份'),
'date_ppt_vendor': ('🏭 請輸入廠商報告月份\n格式:`2026/03`(或按送出跳過用最新月)', 'PPT廠商月份'),
'goal_daily': ('🎯 請輸入每日業績目標金額NT$\n例如:`150000`', '日目標'),
'goal_monthly': ('🎯 請輸入每月業績目標金額NT$\n例如:`3000000`', '月目標'),
'goal_quarterly': ('🎯 請輸入每季業績目標金額NT$\n例如:`9000000`', '季目標'),
@@ -4812,6 +5050,9 @@ def handle_cmd(cmd, arg, chat_id, reply_to):
'monthly': '📅 月報', 'strategy': '🧬 策略簡報',
'competitor': '🔍 競品比較', 'compare': '🔍 競品比較',
'競品': '🔍 競品比較', 'promo': '🎉 促銷效益報告',
'growth': '📈 成長趨勢報告', '成長': '📈 成長趨勢報告',
'vendor': '🏭 廠商業績報告', '廠商': '🏭 廠商業績報告',
'bcg': '🎯 BCG 品牌矩陣', 'BCG': '🎯 BCG 品牌矩陣',
}
label = type_labels.get(_sub_type, '簡報')
caption = f"{label} — 由 OpenClaw AI 自動生成\n💡 可用 PowerPoint / Keynote / Google Slides 開啟"
@@ -5401,6 +5642,8 @@ def telegram_webhook():
handle_cmd('ppt', f'daily {date_val}', chat_id, msg_id)
elif action == 'date_ppt_monthly':
handle_cmd('ppt', f'monthly {date_val}', chat_id, msg_id)
elif action == 'date_ppt_vendor':
handle_cmd('ppt', f'vendor {date_val}', chat_id, msg_id)
elif action == 'date_competitor':
handle_cmd('ppt', f'competitor {date_val}', chat_id, msg_id)
else:

View File

@@ -952,3 +952,394 @@ def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> s
path = _new_path("competitor")
prs.save(path)
return path
# ── 成長趨勢報告 PPT6頁────────────────────────────────────────────────────
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
# ── 廠商業績報告 PPT5頁────────────────────────────────────────────────────
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 品牌矩陣報告 PPT5頁───────────────────────────────────────────────
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: 區域業績橫條圖(對應 divisionDistChart2024 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