feat: PPT 簡報系統 V2 — 新增 growth/vendor/bcg 三種報告 + 原生圖表升級
All checks were successful
CD Pipeline / deploy (push) Successful in 1m15s
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:
14
CLAUDE.md
14
CLAUDE.md
@@ -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 簡報系統 V2(ADR-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 優化)
|
||||
|
||||
|
||||
102
docs/adr/ADR-014-ppt-report-system-v2.md
Normal file
102
docs/adr/ADR-014-ppt-report-system-v2.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# ADR-014: PPT 簡報系統 V2 — 原生圖表 + 9 種報告類型
|
||||
|
||||
**狀態**: Accepted
|
||||
**日期**: 2026-04-20
|
||||
**提案者**: 統帥
|
||||
|
||||
---
|
||||
|
||||
## 背景與問題
|
||||
|
||||
PPT 簡報系統 V1(2026-04-20 v1)只有 6 種報告,其中 weekly/monthly/promo/strategy 頁面為「空殼」——資料庫的數字完全沒有視覺化呈現,只有 AI 純文字。
|
||||
同時缺少對應網站核心圖表(growth_analysis / monthly_summary_analysis)的報告類型。
|
||||
|
||||
---
|
||||
|
||||
## 決策
|
||||
|
||||
升級為 V2(2026-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]
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ── 成長趨勢報告 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
|
||||
|
||||
Reference in New Issue
Block a user