feat(ppt): awaken vendor report + v3.1.0 procurement strategy upgrade
All checks were successful
CD Pipeline / deploy (push) Successful in 2m32s

Wave 1.1:把沉睡的 vendor generator 喚醒並全面升 v3.1.0:

generate_vendor_ppt(services/ppt_generator.py)— 8 頁
- P1 封面:含集中度警示徽章(健康分散 / 偏高 / 過高)+ 三句話策略要點
- P2 執行摘要:4 KPI 含 △% vs 上期 + 帕雷托 80/20 集中度結論帶
- P3 廠商業績排行:橫條 + 帕雷托雙視圖(matplotlib 暖色系)
- P4-P5 廠商明細表 TOP 30:含 △ 排名變化、🆕 新進榜、業績佔比 bar、
  毛利率紅綠燈(綠 ≥15% / 黃 8~15% / 紅 <8%)
- P6 AI 採購策略洞察(結構化 8 段)
- P7 附錄

query_vendor_summary(routes/openclaw_bot_routes.py)
- 期間(單月/自訂日期區間)廠商業績聚合
- 計算 sales / profit / margin / qty / orders
- 同步抓上期同等期間做 vs 上期 △ 比對

_ppt_ai_analysis 加 is_vendor 分支
- 角色:採購主管 + 供應鏈管理顧問
- 結構:整體解讀 / 集中度與供應風險 / 議價優先 / SMART 行動 / 風險預警
- SMART 行動分三層:立即執行 / 中期強化 / 長期結構(含 OEM/ODM 自有品牌)
- 引用 MARKET_TREND_2026 共用知識基底
- max_tokens 1800,字數 800-1000

路由綁定 _generate_ppt_cmd
- /ppt vendor          當月廠商報告
- /ppt vendor 2026/04  指定月份
- /ppt vendor 2026/04/01-2026/04/15  自訂期間
- 自動抓上期同等期間做 △ 比對

Telegram 按鈕(menu_keyboards.py 的 _submenu_reports)
- 新增「🏭 廠商業績報告」進報表選單

bump TEMPLATE_VERSIONS['vendor'] v2.0 (DEPRECATED) → v3.1.0

煙霧測試:本機 venv 跑 vendor PPT 生成 8 頁 200KB 全綠。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-03 02:04:41 +08:00
parent abd722986e
commit b6fdb4f473
3 changed files with 558 additions and 84 deletions

View File

@@ -1883,6 +1883,7 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str:
is_strategy = '策略' in report_type
is_competitor = '競品' in report_type
is_promo = '促銷' in report_type
is_vendor = '廠商' in report_type
# ── 格式鐵律(所有 prompt 共用後綴)────────────────────────
FORMAT_RULES = (
@@ -2040,6 +2041,51 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str:
+ FORMAT_RULES
)
max_tokens = 1400
elif is_vendor:
sys_instruction = (
"你身兼 (1) 資深採購主管10 年零售/電商採購實戰經驗,精通議價、選品、"
"獨家代理談判)(2) 供應鏈管理顧問(精通 Pareto 集中度風險、安全庫存、"
"雙源頭策略)。\n"
"你的客戶是 momo BU 採購主管,會用本報告做下季度的議價對象、新廠商扶植、"
"備援名單、毛利改善決策。\n\n"
f"請針對以下{report_type}資料,輸出採購策略視角的分析報告,結構嚴格如下:\n\n"
"【整體廠商結構解讀】4-5 句)\n"
"引用廠商總數、合計業績、合計毛利、平均毛利率,評估廠商組合健康度(健康/警訊);"
"點出最關鍵亮點TOP1 廠商業績/毛利、新進榜潛力廠商)與最大警訊"
"(集中度過高、毛利持續下滑、長尾過多);"
"與業界平均(健康電商前 20% 廠商佔 70~80% 業績為合理區間)作比較定位。\n\n"
"【集中度與供應風險評估】4-5 句)\n"
"(a) Pareto 80/20 分析:前 N 家廠商佔 80% 業績的具體比例,是否健康分散\n"
"(b) TOP3 廠商斷供風險:若 TOP1 廠商斷供,影響業績幾 %?是否有備援?\n"
"(c) 長尾廠商價值:後 50% 廠商是新晉/補充/淘汰候選?毛利是否優於 TOP\n"
"(d) 與上期比較廠商總數變化、新進榜🆕vs 跌出榜的數量\n\n"
"【議價優先順序與毛利改善】4-5 句)\n"
"(a) 第一線議價對象TOP3 中毛利最低的,明確點名 + 議價方向(量價折扣 / "
"獨家代理 / 行銷費用協同)\n"
"(b) 高毛利廠商扶植:毛利率 >15% 但業績佔比偏低的,建議加碼資源(首頁版位、"
"廣告預算)\n"
"(c) 低毛利長尾汰換:毛利 <5% 且業績後段,建議下架或重新議價\n\n"
"【行動建議 — SMART 框架(必須 SMART\n"
"■ 立即執行3 條,✅ 開頭):\n"
" ✅ 議價:對 [具體廠商名] 啟動 Q+1 議價,目標毛利 +X pp期限YYYY/MM/DD\n"
" ✅ 備援:為 TOP3 廠商建立備援名單(同品類至少 1 家替代廠商)\n"
" ✅ 汰換:[具體廠商名] 毛利 X%、業績 Y 萬,建議下季度下架或重新議約\n"
"■ 中期強化3 條,✅ 開頭):\n"
" ✅ 新廠商開發:針對 [具體品類] 開發 N 家新廠商,下季度預期業績貢獻 NT$X 萬\n"
" ✅ 獨家代理談判:[具體廠商] 爭取台灣電商獨家權,預期市佔 +X%\n"
" ✅ 廠商分級制度:建立 A/B/C 分級(依業績+毛利+穩定度),每季調整資源分配\n"
"■ 長期結構2 條,✅ 開頭):\n"
" ✅ 集中度目標12 個月內把前 5 家佔比從 X% 降至 Y%(風險分散)\n"
" ✅ 自有品牌OEM/ODM規劃 [具體品類] 自有品牌,毛利目標 30%+\n\n"
"【最大風險與防禦動作】2-3 句)\n"
"指出 2~3 項最大風險(單點供應斷鏈 / 競品挖角獨家廠商 / 進口匯率波動),"
"對應「立即啟動」防禦動作(具體至:建立 N 天安全庫存、與 TOP3 簽 N 年協議)。\n\n"
"要求:每段引用至少 2 個具體數字(廠商名 / 業績 / 毛利率 / 排名變化),"
"全文 800~1000 字,禁用「可能/也許/建議考慮」模糊用詞。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 1800
elif is_competitor:
sys_instruction = (
"你是資深電商競品策略分析師,專精美妝(開架/專櫃)、保健食品、母嬰用品,"
@@ -2535,6 +2581,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_vendor_ppt,
check_pptx_available
)
except ImportError:
@@ -2965,9 +3012,104 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str,
})
return ppt_path
elif sub_type in ('vendor', '廠商'):
# /ppt vendor [YYYY/MM] 指定月份廠商報告
# /ppt vendor [YYYY/MM/DD-YYYY/MM/DD] 自訂期間
if sub_arg and re.match(r'\d{4}[/-]\d{1,2}$', sub_arg):
yr, mo = [int(x) for x in sub_arg.replace('-', '/').split('/')]
import calendar as _cal
last_day = _cal.monthrange(yr, mo)[1]
start_str = f"{yr}/{mo:02d}/01"
end_str = f"{yr}/{mo:02d}/{last_day:02d}"
period_lbl = f"{yr}/{mo:02d}"
# 上期
prev_yr = yr if mo > 1 else yr - 1
prev_mo = mo - 1 if mo > 1 else 12
prev_last = _cal.monthrange(prev_yr, prev_mo)[1]
prev_start = f"{prev_yr}/{prev_mo:02d}/01"
prev_end = f"{prev_yr}/{prev_mo:02d}/{prev_last:02d}"
elif sub_arg and '-' in sub_arg and len(sub_arg) > 15:
parts = sub_arg.split('-')
start_str = normalize_date(parts[0])
end_str = normalize_date(parts[1])
period_lbl = f"{start_str} ~ {end_str}"
yr, mo = (int(start_str.split('/')[0]), int(start_str.split('/')[1]))
# 上期 = 同等天數往前推
from datetime import datetime as _dt
from datetime import timedelta as _td
s = _dt.strptime(start_str.replace('/', '-'), '%Y-%m-%d').date()
e = _dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d').date()
days = (e - s).days + 1
prev_end_d = s - _td(days=1)
prev_start_d = prev_end_d - _td(days=days - 1)
prev_start = prev_start_d.strftime('%Y/%m/%d')
prev_end = prev_end_d.strftime('%Y/%m/%d')
else:
# 預設:當月
yr = now.year
mo = now.month
import calendar as _cal
last_day = _cal.monthrange(yr, mo)[1]
start_str = f"{yr}/{mo:02d}/01"
end_str = f"{yr}/{mo:02d}/{last_day:02d}"
period_lbl = f"{yr}/{mo:02d}"
prev_yr = yr if mo > 1 else yr - 1
prev_mo = mo - 1 if mo > 1 else 12
prev_last = _cal.monthrange(prev_yr, prev_mo)[1]
prev_start = f"{prev_yr}/{prev_mo:02d}/01"
prev_end = f"{prev_yr}/{prev_mo:02d}/{prev_last:02d}"
params = {'report_type': 'vendor', 'period': period_lbl}
cached, cached_ai = _load_cached_ppt_path_and_analysis('vendor', params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
curr = query_vendor_summary(start_str, end_str, lim=30)
prev = query_vendor_summary(prev_start, prev_end, lim=30)
curr['prev_period'] = prev.get('vendor_ranking', [])
curr['period_label'] = period_lbl
# data summary 給 AI
top5 = curr.get('vendor_ranking', [])[:5]
top5_str = '\n'.join(
f" {i+1}. {v.get('name','')[:30]} — NT${v.get('sales',0):,.0f}"
f" | 毛利 {v.get('margin',0):.1f}%"
for i, v in enumerate(top5)
)
kpis = curr.get('kpis', {})
data_summary = (
f"【期間】{period_lbl}\n"
f"【廠商總數】{kpis.get('vendor_count', 0)}\n"
f"【合計業績】NT${kpis.get('total_sales', 0):,.0f}\n"
f"【合計毛利】NT${kpis.get('total_profit', 0):,.0f}\n"
f"【平均毛利率】{kpis.get('avg_margin', 0):.1f}%\n\n"
f"【TOP 5 廠商】\n{top5_str}\n\n"
f"【上期同等期間業績】NT${prev.get('kpis', {}).get('total_sales', 0):,.0f}"
f"{prev.get('kpis', {}).get('vendor_count', 0)} 家)\n\n"
f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '(無外部情報)'}"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, '廠商業績報告')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('廠商業績報告', data_summary, mcp_text)
ppt_path = generate_vendor_ppt(yr, mo, curr, ai_text)
_store_ppt_cache('vendor', params, ppt_path, {
'report_type': 'vendor',
'parameters': params,
'data_summary': data_summary,
'analysis': ai_text,
'mcp': mcp_text,
})
return ppt_path
else:
raise RuntimeError(
f'不支援的簡報類型:{sub_type}支援daily / weekly / monthly / strategy / competitor / promo'
f'不支援的簡報類型:{sub_type}'
f'支援daily / weekly / monthly / strategy / competitor / promo / vendor'
)
@@ -3754,6 +3896,76 @@ def query_top_products(d, lim=10):
return []
def query_vendor_summary(start_date: str, end_date: str, lim: int = 30) -> dict:
"""查詢期間廠商業績摘要vendor PPT 用)
回傳:{
vendor_ranking: [{name, sales, profit, margin, qty, orders}, ...] (TOP lim),
kpis: {total_sales, total_profit, avg_margin, vendor_count},
period_label: 'YYYY/MM/DD ~ YYYY/MM/DD'
}
"""
try:
with _db().connect() as c:
row = c.execute(text("""
SELECT COUNT(DISTINCT "廠商名稱"),
COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
COALESCE(SUM(CAST("總成本" AS FLOAT)), 0)
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
"""), {'s': start_date.replace('/', '-'), 'e': end_date.replace('/', '-')}).fetchone()
vendor_rows = c.execute(text("""
SELECT "廠商名稱",
SUM(CAST("總業績" AS FLOAT)) AS sales,
SUM(CAST("總成本" AS FLOAT)) AS cost,
SUM(CAST("數量" AS INTEGER)) AS qty,
COUNT(DISTINCT "訂單編號") AS orders
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
GROUP BY "廠商名稱"
ORDER BY sales DESC
LIMIT :lim
"""), {'s': start_date.replace('/', '-'),
'e': end_date.replace('/', '-'),
'lim': lim}).fetchall()
vcount, total_sales, total_cost = (row[0] or 0), float(row[1] or 0), float(row[2] or 0)
total_profit = total_sales - total_cost
avg_margin = total_profit / total_sales * 100 if total_sales else 0
vendor_ranking = []
for r in vendor_rows:
sales = float(r[1] or 0)
cost = float(r[2] or 0)
profit = sales - cost
margin = profit / sales * 100 if sales else 0
vendor_ranking.append({
'name': r[0],
'sales': sales,
'profit': profit,
'margin': margin,
'qty': int(r[3] or 0),
'orders': int(r[4] or 0),
})
return {
'vendor_ranking': vendor_ranking,
'kpis': {
'total_sales': total_sales,
'total_profit': total_profit,
'avg_margin': avg_margin,
'vendor_count': vcount,
},
'period_label': f"{start_date} ~ {end_date}",
}
except Exception as e:
sys_log.error(f"[query_vendor_summary] {e}")
return {'vendor_ranking': [], 'kpis': {}, 'period_label': ''}
def query_top_vendors(d, lim=10):
try:
with _db().connect() as c:

View File

@@ -209,8 +209,9 @@ def _submenu_reports():
('🧩 策略(年)', 'cmd:ppt:strategy yearly')),
_row(('🎉 促銷效益簡報', 'await:promo_range'),
('🔍 競品比較', 'menu:competitor')),
_row(('📅 指定日期日報', 'await:date_ppt_daily'),
('📅 指定月份月', 'await:date_ppt_monthly')),
_row(('🏭 廠商業績報告', 'cmd:ppt:vendor'),
('📅 指定日期日', 'await:date_ppt_daily')),
_row(('📅 指定月份月報', 'await:date_ppt_monthly'),),
])

View File

@@ -51,7 +51,7 @@ TEMPLATE_VERSIONS = {
# 函式 generate_growth_ppt / generate_vendor_ppt / generate_bcg_ppt 仍存在於本檔,
# 但路由層未綁定指令;保留版本字串避免如未來重啟時快取 schema 對不上。
'growth': 'v2.0', # DEPRECATED — 從未落地
'vendor': 'v2.0', # DEPRECATED — 從未落地
'vendor': 'v3.1.0', # 2026-05-03 喚醒 + v3 暖紙風 + matplotlib 雙視圖 + 採購策略 SMART prompt + 集中度警示
'bcg': 'v2.0', # DEPRECATED — 從未落地
}
@@ -2774,10 +2774,20 @@ def generate_growth_ppt(db_data, ai_text: str) -> str:
# ── 廠商業績報告 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
"""廠商業績報告 v3.1(採購視角):
P1 封面(含集中度警示徽章)
P2 執行摘要4 KPI 含 △% vs 上期 + 帕雷托集中度結論帶
P3 廠商業績排行(橫條 + 帕雷托雙視圖,標出前 N 家佔 80%
P4-P5 廠商明細表 TOP 30含 △ 排名變化、🆕 新進榜、佔比、毛利率)
P6 AI 採購策略洞察(議價對象 / 集中度風險 / 新廠商扶植 / SMART 行動)
P7 附錄
db_data: {
vendor_ranking: [{name, sales, profit, margin, orders, prev_rank?}],
prev_period: [...] (上期廠商列表,用於 △ 排名),
kpis: {total_sales, total_profit, avg_margin, vendor_count},
period_label: '2026/04' / '2026 Q1' / '2026 H1' / '2026'
}
"""
from pptx import Presentation
from pptx.util import Cm
@@ -2787,104 +2797,355 @@ def generate_vendor_ppt(yr, mo, db_data, ai_text: str) -> str:
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}"
vendors = db_data.get('vendor_ranking', []) if isinstance(db_data, dict) else []
prev_vendors = db_data.get('prev_period', []) 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')}")
# 帕雷托:計算前 N 家廠商佔 80% 業績
sales_sorted = sorted([float(v.get('sales', 0)) for v in vendors], reverse=True)
cum_pct = 0
pareto_n = 0
for s in sales_sorted:
cum_pct += s / total_sales * 100 if total_sales else 0
pareto_n += 1
if cum_pct >= 80:
break
# P2: KPI 卡
# 集中度警示
if vcount and pareto_n / vcount < 0.10:
risk_label, risk_color = '集中度過高', 'B5342F'
elif vcount and pareto_n / vcount < 0.20:
risk_label, risk_color = '集中度偏高', 'B88416'
else:
risk_label, risk_color = '健康分散', '2A7A3F'
# 上期排名對照(用於 △ 標記)
prev_rank = {}
for i, v in enumerate(prev_vendors):
if v.get('name'):
prev_rank[v['name']] = i + 1
# ── P1: 封面 ─────────────────────────────────────────────
_vendor_cover_slide(prs, period_lbl, vcount, total_sales, total_profit,
avg_margin, pareto_n, risk_label, risk_color)
# ── P2: 執行摘要 ──────────────────────────────────────────
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}%", ""),
_add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s2, f"廠商業績執行摘要{period_lbl}")
prev_total = sum(float(v.get('sales', 0)) for v in prev_vendors) if prev_vendors else 0
prev_count = len(prev_vendors) if prev_vendors else 0
d_sales = ((total_sales - prev_total) / prev_total * 100) if prev_total else None
d_count = ((vcount - prev_count) / prev_count * 100) if prev_count else None
kpi_v2 = [
(_KPI_CARAMEL, "廠商總數", f"{vcount}", d_count, "vs 上期"),
(_KPI_HONEY, "合計業績", f"NT${total_sales/10000:.1f}", d_sales, "vs 上期"),
(_KPI_MAHOGANY, "合計毛利", f"NT${total_profit/10000:.1f}", None, ""),
(_KPI_EARTH, "平均毛利率", f"{avg_margin:.1f}%", None, ""),
]
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")
for i, (col, lbl, val, dp, dl) in enumerate(kpi_v2):
_kpi_card_v2(s2, i * 7.8 + 0.5, 1.95, 7.4, 4.5,
col, lbl, val, delta_pct=dp, delta_label=dl)
# 帕雷托集中度結論帶
_add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, _BRAND_OG)
_add_text(s2, "📊 廠商集中度分析(帕雷托 80/20",
1.1, 7.05, W - 1.5, 0.6, bold=True, size=13, color=_WHITE,
valign="middle", ea_font=_FONT_BODY_EA)
_add_rect(s2, 0.5, 7.7, W - 1.0, 6.4, _WHITE, line_hex=_SUBTLE)
_add_rect(s2, 0.5, 7.7, 0.4, 6.4, _BRAND_OG)
if vendors:
top1 = vendors[0]
top3_sales = sum(float(v.get('sales', 0)) for v in vendors[:3])
top3_pct = top3_sales / total_sales * 100 if total_sales else 0
analysis_lines = [
f"{pareto_n} 家廠商佔總業績 80%(共 {vcount} 家入榜,佔比 {pareto_n/vcount*100:.0f}%)→ {risk_label}",
"",
f"【業績第一】{top1.get('name','')[:25]} NT${float(top1.get('sales',0)):,.0f} 毛利率 {top1.get('margin',0):.1f}%",
f"【TOP 3 合計】業績 NT${top3_sales:,.0f}{top3_pct:.1f}%",
"",
"採購策略意涵:",
"• 集中度過高 → 議價空間大但供應風險高(單一廠商斷供影響嚴重)",
"• 集中度偏低 → 供應穩健但議價力分散(難以爭取規模採購折扣)",
]
if pareto_n / vcount < 0.10 if vcount else False:
analysis_lines.append("• ⚠ 建議扶植 TOP10~30 名長尾廠商,降低集中度風險")
_add_text(s2, '\n'.join(analysis_lines),
1.2, 7.95, W - 2.0, 5.9,
size=12, color=_DARK_TEXT, wrap=True,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(s2, W)
# P3: TOP 20 廠商橫條圖 — 2024 vs 2025對應 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 20 廠商業績排行 — {period_lbl}(萬元)")
_add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s3, f"廠商業績排行 TOP 12{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])
top12 = vendors[:12]
names = [v.get('name', '')[:14] for v in top12]
revs = [float(v.get('sales', 0)) for v in top12]
chart_w_left = W * 0.5 - 0.4
chart_h = 12.5
buf1 = _mpl_horiz_bar_png(names, revs,
total_width_cm=chart_w_left,
total_height_cm=chart_h,
value_unit="",
title="① 業績排行(焦糖橘=TOP3",
highlight_top_n=3)
if buf1:
_add_image_from_buf(s3, buf1, 0.4, 1.95, chart_w_left, chart_h)
chart_w_right = W * 0.5 - 0.4
rx = W * 0.5 + 0.0
buf2 = _mpl_pareto_chart_png(names, revs,
total_width_cm=chart_w_right,
total_height_cm=chart_h,
title="② 帕雷托累計貢獻80% 主力線)")
if buf2:
_add_image_from_buf(s3, buf2, rx, 1.95, chart_w_right, chart_h)
_add_rect(s3, 0.4, 14.7, W - 0.8, 1.0, _BRAND_OG2)
_add_text(s3,
f"★ 議價優先對象TOP 3 廠商合佔 {top3_pct:.1f}% 業績,"
f"是首批議價/獨家代理談判對象;前 {pareto_n} 家為 80% 主力,"
f"{vcount - pareto_n} 家為長尾(共 {vcount} 家)",
0.7, 14.85, W - 1.4, 0.7,
bold=True, size=12, color=_WHITE, valign="middle",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
else:
_add_empty_state(s3, "本期無廠商業績資料",
"請確認該期間是否已有廠商欄位資料。", W)
_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)
# ── P4-P5: 廠商明細表 TOP 30自動分頁 ──────────────────────
_vendor_table_slide(prs, vendors[:30], period_lbl, prev_rank, total_sales)
# 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)
# ── P6: AI 採購策略洞察(暖紙底,結構化) ─────────────────────
_ai_insight_slide(prs, ai_text)
# ── P7: 附錄 ─────────────────────────────────────────────
_appendix_slide(prs, 'vendor', period_lbl)
path = _new_path("vendor")
prs.save(path)
return path
def _vendor_cover_slide(prs, period_lbl, vcount, total_sales, total_profit,
avg_margin, pareto_n, risk_label, risk_color):
"""廠商報告封面 — 含集中度警示徽章"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
W = 33.87
H = 19.05
_add_rect(slide, 0, 0, W, H, _BG_PAPER)
_add_rect(slide, 0, 0, 3.0, H, _BRAND_OG)
_add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2)
_add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, _BRAND_OG)
_add_rect(slide, 4.0, 8.4, 22.0, 0.06, _BRAND_OG)
_add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2)
_add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81,
bold=True, size=12, color=_WHITE, align="center", valign="middle",
latin_font=_FONT_LABEL)
_add_text(slide, "VENDOR · PROCUREMENT REPORT · AI INSIGHT",
3.8, 2.45, 22, 0.55,
bold=True, size=10, color=_BRAND_OG2,
latin_font=_FONT_LABEL)
_add_text(slide, f"廠商業績報告\n{period_lbl}",
3.8, 3.2, 25, 5.0,
bold=True, size=44, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 集中度徽章
_add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, risk_color)
_add_text(slide, f"集中度:{risk_label}",
W - 9.0, 3.45, 5.0, 1.0,
bold=True, size=14, color=_WHITE, align="center", valign="middle",
ea_font=_FONT_BODY_EA)
_add_text(slide,
f"合計 {vcount} 家廠商 · 業績 NT${total_sales:,.0f}{total_sales/10000:.1f}萬)"
f" · 毛利 NT${total_profit/10000:.1f}萬 · 平均毛利率 {avg_margin:.1f}%",
3.8, 8.7, 27, 0.85,
bold=True, size=14, color=_BRAND_OG2,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
# 三句話策略要點
pitch_y = 10.2
pitch_h = 1.5
pitch_w = 27.0
_add_rect(slide, 3.8, pitch_y, 0.45, pitch_h, "C96442")
_add_text(slide, "💼 TOP 議價對象", 4.4, pitch_y + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color="C96442",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, f"{pareto_n} 家廠商佔 80% 業績,建議優先議價或爭取獨家",
4.4, pitch_y + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y2 = pitch_y + pitch_h + 0.4
_add_rect(slide, 3.8, pitch_y2, 0.45, pitch_h, "B88416")
_add_text(slide, "🌱 扶植潛力", 4.4, pitch_y2 + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color="B88416",
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide, f"{max(0, vcount - pareto_n)} 家長尾廠商為新晉/補充來源,可挑選毛利優於平均者扶植",
4.4, pitch_y2 + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
pitch_y3 = pitch_y2 + pitch_h + 0.4
_add_rect(slide, 3.8, pitch_y3, 0.45, pitch_h, risk_color)
_add_text(slide, "⚠ 集中度警訊", 4.4, pitch_y3 + 0.1, pitch_w - 0.7, 0.55,
bold=True, size=11, color=risk_color,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
_add_text(slide,
f"集中度 {risk_label}。建議:" + (
"立即啟動 TOP3 廠商備援名單,避免單點供應斷鏈" if risk_label == '集中度過高'
else "加強 TOP10 廠商議價,爭取獨家或更佳毛利空間"
),
4.4, pitch_y3 + 0.7, pitch_w - 0.7, 0.75,
size=12, color=_DARK_TEXT,
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(slide, "Generated by OpenClaw AI Agent",
W - 7.5, H - 1.4, 7.0, 0.5,
size=9, color=_SUBTEXT, align="right",
latin_font=_FONT_LABEL)
_add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}",
W - 7.5, H - 1.95, 7.0, 0.5,
bold=True, size=11, color=_BRAND_OG2, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_footer(slide, W)
return slide
def _vendor_table_slide(prs, vendors, period_lbl, prev_rank, total_sales,
max_items=30):
"""廠商明細表 — 自動分頁(每頁 18 列),含 △ 排名變化"""
import math
W = 33.87
HEADER_END = 1.85
TABLE_HDR_H = 0.72
ROW_H = 0.88
AVAIL_H = _CONTENT_B - HEADER_END - TABLE_HDR_H
ROWS_PER_PAGE = max(1, int(AVAIL_H / ROW_H))
all_v = vendors[:max_items]
if not all_v:
return
top_sales = max(float(v.get('sales', 1)) for v in all_v) or 1
total_pages = math.ceil(len(all_v) / ROWS_PER_PAGE)
for page in range(total_pages):
page_v = all_v[page * ROWS_PER_PAGE:(page + 1) * ROWS_PER_PAGE]
page_label = f" ({page + 1}/{total_pages})"
s = prs.slides.add_slide(prs.slide_layouts[6])
_add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER)
_add_header(s, f"廠商業績明細 TOP {min(max_items, len(all_v))}{period_lbl}{page_label}")
tbl_y = HEADER_END
_add_rect(s, 0.4, tbl_y, W - 0.8, TABLE_HDR_H, _BRAND_OG)
_add_rect(s, 0.4, tbl_y, 0.3, TABLE_HDR_H, _BRAND_OG2)
col_x = [(0.5, 1.3, "排名", "center"),
(1.95, W - 24.0, "廠商名稱", "left"),
(W - 22.0, 3.0, "vs 上期", "center"),
(W - 18.7, 4.5, "業績佔比", "center"),
(W - 14.0, 6.0, "業績", "right"),
(W - 7.7, 4.5, "毛利", "right"),
(W - 3.0, 2.5, "毛利率", "right")]
for x, w, label, al in col_x:
_add_text(s, label, x, tbl_y + 0.06, w, TABLE_HDR_H - 0.12,
bold=True, size=10, color=_WHITE, align=al,
ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL)
for j, v in enumerate(page_v):
actual_rank = page * ROWS_PER_PAGE + j + 1
bg = _LIGHT_GRAY if j % 2 == 0 else _WHITE
row_t = tbl_y + TABLE_HDR_H + j * ROW_H
sales = float(v.get('sales', 0))
profit = float(v.get('profit', 0))
margin = float(v.get('margin', 0))
pct_total = sales / total_sales * 100 if total_sales else 0
_add_rect(s, 0.4, row_t, W - 0.8, ROW_H - 0.04, bg)
rank_fill = _BRAND_OG if actual_rank <= 3 else (_KPI_HONEY if actual_rank <= 10 else _SUBTLE)
rank_color = _WHITE if actual_rank <= 10 else _SUBTEXT
_add_rect(s, 0.55, row_t + 0.1, 0.95, ROW_H - 0.22, rank_fill)
_add_text(s, str(actual_rank), 0.55, row_t + 0.1, 0.95, ROW_H - 0.22,
bold=(actual_rank <= 3), size=11, color=rank_color,
align="center", valign="middle", latin_font=_FONT_DISPLAY)
_add_text(s, str(v.get('name', ''))[:30],
1.95, row_t + 0.1, W - 24.2, ROW_H - 0.2,
size=10, color=_DARK_TEXT,
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
# vs 上期 △
name = v.get('name', '')
if name and prev_rank:
prev_r = prev_rank.get(name)
if prev_r is None:
diff_text, diff_color = "🆕 新", "2A7A3F"
else:
diff = prev_r - actual_rank
if diff > 0:
diff_text, diff_color = f"{diff}", "2A7A3F"
elif diff < 0:
diff_text, diff_color = f"{abs(diff)}", "B5342F"
else:
diff_text, diff_color = "", "9B9081"
else:
diff_text, diff_color = "", "9B9081"
_add_text(s, diff_text, W - 22.0, row_t + 0.1, 3.0, ROW_H - 0.2,
bold=True, size=10, color=diff_color, align="center",
ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY)
# 業績佔比 bar
bar_max_w = 4.0
bar_w = max(0.05, pct_total / 100 * bar_max_w * 5) # 放大 5x 視覺化
bar_w = min(bar_w, bar_max_w)
_add_rect(s, W - 18.5, row_t + ROW_H * 0.38, bar_max_w, 0.25, _SUBTLE)
_add_rect(s, W - 18.5, row_t + ROW_H * 0.38, bar_w, 0.25, _BRAND_OG)
_add_text(s, f"{pct_total:.1f}%",
W - 18.5, row_t + 0.08, bar_max_w, ROW_H - 0.2,
size=9, color=_TERTIARY, align="right",
latin_font=_FONT_DISPLAY)
_add_text(s, f"NT${sales:,.0f}", W - 14.0, row_t + 0.1, 6.0, ROW_H - 0.2,
bold=(actual_rank == 1), size=11, color=_DARK_TEXT,
align="right", latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
_add_text(s, f"NT${profit:,.0f}", W - 7.7, row_t + 0.1, 4.5, ROW_H - 0.2,
size=10, color=_SUBTEXT, align="right",
latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA)
margin_color = "2A7A3F" if margin >= 15 else ("B88416" if margin >= 8 else "B5342F")
_add_text(s, f"{margin:.1f}%", W - 3.0, row_t + 0.1, 2.5, ROW_H - 0.2,
bold=True, size=10, color=margin_color, align="right",
latin_font=_FONT_DISPLAY)
_add_footer(s, W)
# ── BCG 品牌矩陣報告 PPT5頁───────────────────────────────────────────────
def generate_bcg_ppt(yr, mo, db_data, ai_text: str) -> str:
"""P1封面 P2 BCG象限KPI P3 BCG策略分類表 P4 區域業績橫條圖 P5 AI洞察