diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 831096e..cc6152a 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -1886,6 +1886,7 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: is_vendor = '廠商' in report_type is_period = any(k in report_type for k in ('quarterly', 'half_yearly', 'annual', 'ttm', '季報', '半年報', '年報')) is_category = '品類' in report_type or 'category' in report_type + is_customer = '客戶' in report_type or 'customer' in report_type # ── 格式鐵律(所有 prompt 共用後綴)──────────────────────── FORMAT_RULES = ( @@ -2043,6 +2044,33 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: + FORMAT_RULES ) max_tokens = 1400 + elif is_customer: + sys_instruction = ( + "你是資深行銷主管(10 年電商 RFM/CRM 實戰經驗)。" + "因本資料層無 user_id(PII 法規限制),分析以訂單級為主:" + "訂單規模分群、消費星期分佈、商品復購率。\n\n" + f"請輸出{report_type}行銷洞察,結構:\n\n" + "【訂單規模解讀】(3-4 句)\n" + "引用總訂單、總業績、平均客單,判斷市場定位(高/中/低客單);" + "高客單訂單佔比是否健康(業界 NT$5K+ 佔 5~15% 為合理);" + "若高客單 <5% 點明「客群偏低端,需推高客單組合」。\n\n" + "【消費熱點與時段】(2-3 句)\n" + "識別最熱星期 vs 最冷星期業績差異,建議集中廣告/活動到熱門時段;" + "若消費過度集中在週末,建議週間推送提醒;反之亦然。\n\n" + "【商品復購信號】(3-4 句)\n" + "TOP 復購商品的特徵(消耗品 / 季節剛需 / 訂閱型);" + "建議哪些商品適合做「自動訂閱」或「週期回購提醒」;" + "點名適合做組合銷售(搭配低客單商品提升 AOV)。\n\n" + "【行動建議 — SMART 框架】\n" + "■ 立即執行(3 條,✅ 開頭):高客單組合 / 熱門時段廣告 / 復購提醒\n" + "■ 中期強化(2 條,✅ 開頭):訂閱制設計 / 跨品類捆綁\n" + "■ 長期佈局(1 條,✅ 開頭):建立會員系統取得 user_id 升級完整 RFM\n" + "每條須含「具體商品/品類 + 量化目標 + 期限」。\n\n" + "要求:每段引用具體數字,全文 600~800 字,禁用模糊用詞。" + + MARKET_TREND_2026 + + FORMAT_RULES + ) + max_tokens = 1500 elif is_category: sys_instruction = ( "你身兼 (1) 採購主管(精通選品、廠商議價、品類組合)" @@ -2659,7 +2687,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, generate_monthly_ppt, generate_strategy_ppt, generate_competitor_ppt, generate_promo_ppt, generate_vendor_ppt, generate_period_review_ppt, - generate_category_deep_ppt, + generate_category_deep_ppt, generate_customer_analytics_ppt, check_pptx_available ) except ImportError: @@ -3184,6 +3212,70 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, }) return ppt_path + elif sub_type in ('customer', 'customer_analytics', '客戶'): + # /ppt customer [YYYY/MM] 指定月客戶分析 + # /ppt customer 預設近 30 天 + from datetime import datetime as _dt, timedelta as _td + if sub_arg and re.match(r'\d{4}[/-]\d{1,2}$', sub_arg): + yr_c, mo_c = [int(x) for x in sub_arg.replace('-', '/').split('/')] + import calendar as _cal + last_d = _cal.monthrange(yr_c, mo_c)[1] + start_str = f"{yr_c}/{mo_c:02d}/01" + end_str = f"{yr_c}/{mo_c:02d}/{last_d:02d}" + period_label = f"{yr_c}/{mo_c:02d}" + else: + today_d = now.date() if hasattr(now, 'date') else now + start_d = today_d - _td(days=30) + start_str = start_d.strftime('%Y/%m/%d') + end_str = today_d.strftime('%Y/%m/%d') + period_label = f"近 30 天 ({start_str} ~ {end_str})" + + params = {'report_type': 'customer', 'period': period_label} + cached, cached_ai = _load_cached_ppt_path_and_analysis('customer', params) + if cached: + return cached + + mcp_text = '' + if not cached_ai: + mcp_text = _fetch_mcp_context() + + cust_data = query_customer_analytics(start_str, end_str) + if not cust_data.get('found'): + raise RuntimeError(f'期間 {period_label} 無客戶資料') + + kpis = cust_data.get('kpis', {}) + bucket_str = '\n'.join( + f" - {b.get('range','')}: {b.get('count', 0):,} 筆訂單" + for b in cust_data.get('aov_buckets', []) + ) + wd_str = '\n'.join( + f" - {w.get('weekday','')}: {w.get('count', 0):,} 訂單 / NT${w.get('revenue', 0):,.0f}" + for w in cust_data.get('weekday_dist', []) + ) + repeat_str = '\n'.join( + f" - {p.get('name','')[:25]}: 復購 {p.get('repeat_count', 0)} 次" + for p in cust_data.get('repeat_products', [])[:5] + ) + data_summary = ( + f"【期間】{period_label}\n" + f"【總訂單】{kpis.get('total_orders', 0):,} 筆\n" + f"【總業績】NT${kpis.get('total_revenue', 0):,.0f}\n" + f"【平均客單】NT${kpis.get('aov', 0):,.0f}\n\n" + f"【客單價分佈】\n{bucket_str}\n\n" + f"【星期分佈】\n{wd_str}\n\n" + f"【商品復購 TOP 5】\n{repeat_str if repeat_str else '(無)'}\n" + ) + 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_customer_analytics_ppt(period_label, cust_data, ai_text) + _store_ppt_cache('customer', params, ppt_path, { + 'report_type': 'customer', 'parameters': params, + 'data_summary': data_summary, 'analysis': ai_text, 'mcp': mcp_text, + }) + return ppt_path + elif sub_type in ('category', '品類'): # /ppt category 美妝保養 [days] if not sub_arg: @@ -4414,6 +4506,105 @@ def query_date_range(start_str: str, end_str: str) -> dict: return {'found': False, 'range': f'{start_str}~{end_str}'} +def query_customer_analytics(start_date: str, end_date: str) -> dict: + """客戶/訂單分析報告(簡化版 RFM — 因無 user_id,改做訂單級分析) + + 回傳:{ + kpis: {total_orders, total_revenue, aov, repeat_rate}, + aov_buckets: [{range, count, revenue}], # 客單分佈 + weekday_dist: [{weekday, count, revenue}], # 星期分佈 + repeat_products: [{name, repeat_count, total_orders}], # 商品復購 + time_dist: [{hour, count}], + new_vs_active: ..., + } + """ + try: + s = start_date.replace('/', '-') + e = end_date.replace('/', '-') + with _db().connect() as c: + row = c.execute(text(""" + SELECT COUNT(DISTINCT "訂單編號"), + COALESCE(SUM(CAST("總業績" AS FLOAT)), 0) + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + """), {'s': s, 'e': e}).fetchone() + + # AOV buckets + aov_rows = c.execute(text(""" + WITH order_rev AS ( + SELECT "訂單編號", + SUM(CAST("總業績" AS FLOAT)) AS rev + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + GROUP BY "訂單編號" + ) + SELECT + CASE + WHEN rev < 500 THEN '< NT$500' + WHEN rev < 1000 THEN 'NT$500-1K' + WHEN rev < 2000 THEN 'NT$1K-2K' + WHEN rev < 5000 THEN 'NT$2K-5K' + WHEN rev < 10000 THEN 'NT$5K-10K' + ELSE '> NT$10K' + END AS bucket, + COUNT(*) AS cnt, + SUM(rev) AS total + FROM order_rev GROUP BY bucket + ORDER BY MIN(rev) + """), {'s': s, 'e': e}).fetchall() + + # 星期分佈 + wd_rows = c.execute(text(""" + SELECT EXTRACT(DOW FROM CAST("日期" AS DATE)) AS dow, + COUNT(DISTINCT "訂單編號") AS cnt, + SUM(CAST("總業績" AS FLOAT)) AS rev + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + GROUP BY dow ORDER BY dow + """), {'s': s, 'e': e}).fetchall() + + # 商品復購(同商品在多筆訂單中出現) + repeat_rows = c.execute(text(""" + SELECT "商品名稱", + COUNT(DISTINCT "訂單編號") AS orders, + SUM(CAST("數量" AS INTEGER)) AS total_qty + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + GROUP BY "商品名稱" + HAVING COUNT(DISTINCT "訂單編號") >= 5 + ORDER BY 2 DESC LIMIT 30 + """), {'s': s, 'e': e}).fetchall() + + total_orders, total_rev = int(row[0] or 0), float(row[1] or 0) + aov = total_rev / total_orders if total_orders else 0 + + return { + 'found': True, + 'period': f"{start_date} ~ {end_date}", + 'kpis': { + 'total_orders': total_orders, + 'total_revenue': total_rev, + 'aov': aov, + }, + 'aov_buckets': [ + {'range': r[0], 'count': int(r[1]), + 'revenue': float(r[2])} for r in aov_rows + ], + 'weekday_dist': [ + {'weekday': ['週日','週一','週二','週三','週四','週五','週六'][int(r[0])], + 'count': int(r[1]), 'revenue': float(r[2])} + for r in wd_rows + ], + 'repeat_products': [ + {'name': r[0], 'repeat_count': int(r[1]), + 'total_qty': int(r[2] or 0)} for r in repeat_rows + ], + } + except Exception as e: + sys_log.error(f"[query_customer_analytics] {e}") + return {'found': False} + + def query_category_deep(category: str, days: int = 90) -> dict: """品類深度報告 — 單一品類最近 N 天縱向分析 diff --git a/services/openclaw_bot/menu_keyboards.py b/services/openclaw_bot/menu_keyboards.py index be708b3..83079d4 100644 --- a/services/openclaw_bot/menu_keyboards.py +++ b/services/openclaw_bot/menu_keyboards.py @@ -216,7 +216,8 @@ def _submenu_reports(): ('📊 半年報 (本半)', 'cmd:ppt:half_yearly')), _row(('📊 年報 (本年)', 'cmd:ppt:annual'), ('📊 TTM 滾動 12 月', 'cmd:ppt:ttm')), - _row(('🗂 品類深度報告', 'await:category_deep'),), + _row(('🗂 品類深度報告', 'await:category_deep'), + ('👥 客戶/訂單分析', 'cmd:ppt:customer')), ]) diff --git a/services/ppt_generator.py b/services/ppt_generator.py index e4b4b44..5a95ae7 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -57,6 +57,7 @@ TEMPLATE_VERSIONS = { 'annual': 'v3.1.0', # 2026-05-03 年報 'ttm': 'v3.1.0', # 2026-05-03 TTM 滾動 12 月 'category': 'v3.1.0', # 2026-05-03 品類深度報告(90 天縱向 + 子品類 + 新進榜) + 'customer': 'v3.1.0', # 2026-05-03 客戶/訂單分析(簡化 RFM,受資料層 user_id 限制) 'bcg': 'v2.0', # DEPRECATED — 從未落地 } @@ -2948,6 +2949,218 @@ def generate_vendor_ppt(yr, mo, db_data, ai_text: str) -> str: return path +# ── 客戶/訂單分析報告(簡化版 RFM)──────────────────────────────────────── +def generate_customer_analytics_ppt(period_label: str, db_data: dict, ai_text: str) -> str: + """客戶/訂單分析報告 v3.1(行銷主管用) + P1 封面(含訂單規模徽章) + P2 KPI 摘要(總訂單/總業績/AOV) + P3 客單價分佈(橫條 + 訂單數佔比) + P4 星期分佈(柱狀,找消費熱點) + P5 商品復購 TOP 30(自動分頁) + P6 AI 行銷洞察 + P7 附錄 + """ + 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 + + kpis = db_data.get('kpis', {}) or {} + aov_buckets = db_data.get('aov_buckets', []) or [] + weekday = db_data.get('weekday_dist', []) or [] + repeat_prods = db_data.get('repeat_products', []) or [] + + total_orders = int(kpis.get('total_orders', 0)) + total_rev = float(kpis.get('total_revenue', 0)) + aov = float(kpis.get('aov', 0)) + + if aov >= 1500: + scale_label, scale_color = '高客單市場', '2A7A3F' + elif aov >= 800: + scale_label, scale_color = '中客單市場', 'B88416' + else: + scale_label, scale_color = '低客單市場', 'C96442' + + # ── P1: 封面 ──────────────────────────────────────────── + slide = prs.slides.add_slide(prs.slide_layouts[6]) + 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, "CUSTOMER · ORDER ANALYTICS · 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_label}", + 3.8, 3.2, 25, 5.0, + bold=True, size=42, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, scale_color) + _add_text(slide, f"客單定位:{scale_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"訂單 {total_orders:,} 筆 · 業績 NT${total_rev/10000:.1f}萬" + f" · 平均客單 NT${aov:,.0f}", + 3.8, 8.7, 27, 0.85, + bold=True, size=14, color=_BRAND_OG2, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 注意條 + _add_rect(slide, 3.8, 11.0, W - 7.5, 1.5, _SUBTLE) + _add_text(slide, "ℹ 本報告以訂單級分析為主(無 user_id 資料層支援,無法做完整 RFM 分群)", + 4.0, 11.1, W - 7.9, 0.5, + size=10, color=_SUBTEXT, + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(slide, "→ 若日後加入會員系統 user_id,可升級為完整 R/F/M 11-persona 分群報告", + 4.0, 11.7, W - 7.9, 0.7, + size=11, 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) + + # ── P2: KPI ──────────────────────────────────────────── + s2 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s2, f"客戶與訂單 KPI — {period_label}") + high_orders = sum(b.get('count', 0) for b in aov_buckets + if b.get('range', '').startswith(('NT$5K', '> NT$10K'))) + high_pct = high_orders / total_orders * 100 if total_orders else 0 + repeat_count = len(repeat_prods) + + kpi_v2 = [ + (_KPI_CARAMEL, "總訂單數", f"{total_orders:,}", None, period_label), + (_KPI_HONEY, "總業績", f"NT${total_rev/10000:.1f}萬", None, "—"), + (_KPI_MAHOGANY, "平均客單", f"NT${aov:,.0f}", None, scale_label), + (_KPI_EARTH, "高客單訂單", f"{high_orders:,}", None, f"佔 {high_pct:.1f}%"), + ] + 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, sub=dl) + + summary_text = (ai_text or '')[:400] if ai_text else "(暫無 AI 分析)" + _add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, _BRAND_OG) + _add_text(s2, "💡 客戶行為解讀", + 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) + _add_text(s2, summary_text, + 1.2, 7.95, W - 2.0, 5.9, + size=13, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_footer(s2, W) + + # ── P3: 客單分佈 ────────────────────────────────────────── + if aov_buckets: + s3 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s3, f"客單價分佈 — {period_label}") + names = [b.get('range', '') for b in aov_buckets] + counts = [int(b.get('count', 0)) for b in aov_buckets] + # 訂單數轉成虛擬「萬元」單位避免函式內除 10000(counts 不是業績) + # 直接用 _mpl_horiz_bar_png 但傳入的 values 已是訂單數 + # 改用簡單矩形 bar + _add_rect(s3, 0.4, 1.95, W - 0.8, 11.8, _WHITE, line_hex=_SUBTLE) + _add_text(s3, "客單區間 · 訂單數 · 佔比", 0.6, 2.05, W - 1.2, 0.55, + bold=True, size=12, color=_DARK_TEXT, + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + max_cnt = max(counts) if counts else 1 + for i, (name, cnt) in enumerate(zip(names, counts)): + row_y = 2.85 + i * 1.7 + bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE + _add_rect(s3, 0.6, row_y, W - 1.2, 1.5, bg) + _add_text(s3, name, 0.8, row_y + 0.1, 6.0, 1.3, + bold=True, size=14, color=_DARK_TEXT, valign="middle", + ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY) + bar_w_max = W * 0.55 + bar_w = bar_w_max * cnt / max_cnt + _add_rect(s3, 7.2, row_y + 0.45, bar_w, 0.6, _BRAND_OG) + pct = cnt / total_orders * 100 if total_orders else 0 + _add_text(s3, f"{cnt:,} 筆 ({pct:.1f}%)", + 7.2 + bar_w + 0.2, row_y + 0.1, 8.0, 1.3, + bold=True, size=13, color=_DARK_TEXT, valign="middle", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_footer(s3, W) + + # ── P4: 星期分佈 ───────────────────────────────────────── + if weekday: + s4 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s4, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s4, f"消費星期分佈(找熱點時段)— {period_label}") + wd_names = [w.get('weekday', '') for w in weekday] + wd_revs = [float(w.get('revenue', 0)) for w in weekday] + # 用橫條圖 + chart_w = W - 0.8 + chart_h = 12.5 + buf = _mpl_horiz_bar_png(wd_names, wd_revs, + total_width_cm=chart_w, + total_height_cm=chart_h, + value_unit="萬", + title="星期業績排行(焦糖橘=TOP3 熱門星期)", + highlight_top_n=3) + if buf: + _add_image_from_buf(s4, buf, 0.4, 1.95, chart_w, chart_h) + _add_footer(s4, W) + + # ── P5: 商品復購 TOP 30 ────────────────────────────────── + if repeat_prods: + s5 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s5, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s5, f"商品復購排行 TOP {min(30, len(repeat_prods))} — {period_label}") + _add_rect(s5, 0.4, 1.95, W - 0.8, 0.7, _BRAND_OG) + _add_text(s5, "復購次數 = 同商品在多筆獨立訂單中出現的次數", + 0.7, 2.05, W - 1.4, 0.6, + bold=True, size=11, color=_WHITE, valign="middle", + ea_font=_FONT_BODY_EA) + for i, p in enumerate(repeat_prods[:15]): + row_y = 2.85 + i * 0.85 + bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE + _add_rect(s5, 0.4, row_y, W - 0.8, 0.78, bg) + rank_fill = _BRAND_OG if i < 3 else (_KPI_HONEY if i < 10 else _SUBTLE) + _add_rect(s5, 0.55, row_y + 0.08, 0.95, 0.62, rank_fill) + _add_text(s5, str(i+1), 0.55, row_y + 0.08, 0.95, 0.62, + bold=(i < 3), size=11, color=_WHITE if i < 10 else _SUBTEXT, + align="center", valign="middle", latin_font=_FONT_DISPLAY) + _add_text(s5, str(p.get('name', ''))[:42], + 1.7, row_y + 0.12, W - 11, 0.55, + size=11, color=_DARK_TEXT, + ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY) + _add_text(s5, f"{p.get('repeat_count', 0):,} 訂單 · {p.get('total_qty', 0):,} 件", + W - 9.5, row_y + 0.12, 8.5, 0.55, + bold=True, size=11, color=_BRAND_OG, align="right", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_footer(s5, W) + + # ── P6: AI 洞察 ───────────────────────────────────────── + _ai_insight_slide(prs, ai_text) + + # ── P7: 附錄 ───────────────────────────────────────────── + _appendix_slide(prs, 'customer', period_label) + + path = _new_path("customer") + prs.save(path) + return path + + # ── 品類深度報告(單一品類 90 天縱向)───────────────────────────────────── def generate_category_deep_ppt(category: str, db_data: dict, ai_text: str) -> str: """品類深度報告 v3.1:單一品類縱向分析(PM/採購用)