diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 6a5d849..d9c4b32 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -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: diff --git a/services/openclaw_bot/menu_keyboards.py b/services/openclaw_bot/menu_keyboards.py index 3a41204..d1cb6ea 100644 --- a/services/openclaw_bot/menu_keyboards.py +++ b/services/openclaw_bot/menu_keyboards.py @@ -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'),), ]) diff --git a/services/ppt_generator.py b/services/ppt_generator.py index f3d2fdc..09bc1e1 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -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洞察