feat(ppt): awaken vendor report + v3.1.0 procurement strategy upgrade
All checks were successful
CD Pipeline / deploy (push) Successful in 2m32s
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:
@@ -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:
|
||||
|
||||
@@ -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'),),
|
||||
])
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
# ── 廠商業績報告 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
|
||||
"""廠商業績報告 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 品牌矩陣報告 PPT(5頁)───────────────────────────────────────────────
|
||||
def generate_bcg_ppt(yr, mo, db_data, ai_text: str) -> str:
|
||||
"""P1封面 P2 BCG象限KPI P3 BCG策略分類表 P4 區域業績橫條圖 P5 AI洞察
|
||||
|
||||
Reference in New Issue
Block a user