diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 7175459..831096e 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -1885,6 +1885,7 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: is_promo = '促銷' in report_type 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 # ── 格式鐵律(所有 prompt 共用後綴)──────────────────────── FORMAT_RULES = ( @@ -2042,6 +2043,40 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: + FORMAT_RULES ) max_tokens = 1400 + elif is_category: + sys_instruction = ( + "你身兼 (1) 採購主管(精通選品、廠商議價、品類組合)" + "(2) PM 商品經理(精通商品生命週期、新品爬榜、SKU 健康度)。\n" + "你的客戶是 momo 採購與 PM 團隊,會用本報告做品類選品、新廠商引進、" + "下架低毛利長尾、扶植新進榜商品的決策。\n\n" + f"請針對以下{report_type}資料,輸出品類深度分析報告,結構嚴格如下:\n\n" + "【品類整體解讀】(4-5 句)\n" + "引用本品類 90 天業績、訂單、毛利率、SKU 數、廠商數,評估品類定位" + "(主力 / 成長 / 長尾);點出最關鍵亮點(高毛利商品爆發、新進榜潛力)" + "與最大警訊(毛利下滑、SKU 過度集中、廠商斷供)。\n\n" + "【90 天趨勢分析】(3-4 句)\n" + "解讀日業績曲線:高低點對應的檔期/季節因素;判斷品類處於上升 / 持平 / " + "下降趨勢;對比品類季節性(如美妝在母親節前 30 天通常 +30%)。\n\n" + "【子品類結構與機會】(3-4 句)\n" + "前 3 大子品類佔比、是否健康分散;子品類間的 mix 健康度;" + "建議哪個子品類是下季度應加碼資源的(高毛利 + 成長中)。\n\n" + "【SKU 與廠商組合健康度】(4-5 句)\n" + "TOP3 商品集中度(前 3 商品佔本品類業績 X%,是否過於依賴);" + "新進榜商品(🆕)的潛力評估:誰值得加碼資源、誰只是曇花一現;" + "TOP3 廠商議價空間:毛利偏低者、可爭取獨家代理者、可下架者各列名 1-2 家。\n\n" + "【行動建議 — SMART 框架】\n" + "■ 立即執行(3 條,✅ 開頭):補貨 / 廣告投放 / 下架低毛利長尾\n" + "■ 中期強化(3 條,✅ 開頭):新品扶植 / 廠商議價 / 子品類擴張\n" + "■ 長期佈局(2 條,✅ 開頭):自有品牌 / 跨品類聯名\n" + "每條須含「具體商品名 + 量化目標 + 期限」。\n\n" + "【最大風險與防禦】(2-3 句)\n" + "點出本品類 2~3 項風險(集中度過高 / 季節性過強 / 競品價格戰),對應防禦動作。\n\n" + "要求:每段引用至少 2 個具體數字(商品名/業績/排名)," + "全文 800~1000 字,禁用模糊用詞。" + + MARKET_TREND_2026 + + FORMAT_RULES + ) + max_tokens = 1800 elif is_period: sys_instruction = ( "你身兼三職:(1) 資深電商策略顧問(10 年 BCG / 麥肯錫零售諮詢經驗)" @@ -2624,6 +2659,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, check_pptx_available ) except ImportError: @@ -3148,6 +3184,65 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, }) return ppt_path + elif sub_type in ('category', '品類'): + # /ppt category 美妝保養 [days] + if not sub_arg: + raise RuntimeError('品類深度報告需指定品類名稱:/ppt category 美妝保養') + parts = sub_arg.strip().split() + cat = parts[0] + days = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 90 + + params = {'report_type': 'category', 'category': cat, 'days': days} + cached, cached_ai = _load_cached_ppt_path_and_analysis('category', params) + if cached: + return cached + + mcp_text = '' + if not cached_ai: + mcp_text = _fetch_mcp_context() + + cat_data = query_category_deep(cat, days=days) + if not cat_data.get('found'): + raise RuntimeError(f'品類 "{cat}" 最近 {days} 天無資料') + + kpis = cat_data.get('kpis', {}) + top5_str = '\n'.join( + f" {i+1}. {p.get('name','')[:30]} — NT${p.get('revenue', 0):,.0f}" + for i, p in enumerate(cat_data.get('top_products', [])[:5]) + ) + sub_str = '\n'.join( + f" - {c.get('name','')[:20]}: NT${c.get('revenue', 0):,.0f}" + for c in cat_data.get('sub_categories', [])[:5] + ) + new_str = '\n'.join( + f" - {p.get('name','')[:30]} — NT${p.get('revenue', 0):,.0f}" + for p in cat_data.get('new_products', [])[:5] + ) + data_summary = ( + f"【品類】{cat}\n" + f"【期間】{cat_data.get('period', '')}(最近 {days} 天)\n" + f"【業績】NT${kpis.get('revenue', 0):,.0f}\n" + f"【訂單】{kpis.get('orders', 0):,} 筆\n" + f"【毛利率】{kpis.get('gross_margin', 0):.1f}%\n" + f"【SKU 總數】{kpis.get('sku_count', 0)}\n" + f"【廠商數】{kpis.get('vendor_count', 0)}\n\n" + f"【子品類 TOP 5】\n{sub_str}\n\n" + f"【熱銷商品 TOP 5】\n{top5_str}\n\n" + f"【近 30 天新進榜】\n{new_str if new_str else '(無)'}\n\n" + f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '(無)'}" + ) + ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'品類深度報告({cat})') + if not cached_ai and _ppt_needs_fallback(ai_text): + ai_text = _ppt_fallback_insight('品類深度', data_summary, mcp_text) + + cat_data['mcp'] = mcp_text + ppt_path = generate_category_deep_ppt(cat, cat_data, ai_text) + _store_ppt_cache('category', params, ppt_path, { + 'report_type': 'category', 'parameters': params, + 'data_summary': data_summary, 'analysis': ai_text, 'mcp': mcp_text, + }) + return ppt_path + elif sub_type in ('quarterly', '季報', 'half_yearly', '半年報', 'annual', '年報', 'ttm'): # 期間回顧報告 — period_review 共用 generator # /ppt quarterly [YYYY/Q1-4] 季報 @@ -4319,6 +4414,162 @@ def query_date_range(start_str: str, end_str: str) -> dict: return {'found': False, 'range': f'{start_str}~{end_str}'} +def query_category_deep(category: str, days: int = 90) -> dict: + """品類深度報告 — 單一品類最近 N 天縱向分析 + + 回傳:{ + category: 品類名, + period: 'YYYY/MM/DD ~ YYYY/MM/DD', + kpis: {revenue, orders, gross_margin, avg_order, sku_count, vendor_count, days}, + daily: [{date, revenue, orders, qty}], # 逐日趨勢 + weekly: [{week, revenue, orders}], # 週聚合 + top_products: [TOP 50 該品類商品], + top_vendors: [TOP 30 該品類廠商], + sub_categories: [品類 L2 切分], + new_products: [近 30 天新進榜], + found: bool + } + """ + try: + with _db().connect() as c: + row = c.execute(text(f""" + SELECT MIN(CAST("日期" AS DATE)), MAX(CAST("日期" AS DATE)) + FROM realtime_sales_monthly + WHERE "商品分類L1" = :cat + AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days} days' + """), {'cat': category}).fetchone() + if not row or not row[0]: + return {'found': False} + start_date = str(row[0]) + end_date = str(row[1]) + + kpi_row = c.execute(text(""" + SELECT COUNT(DISTINCT "訂單編號"), + COALESCE(SUM(CAST("總業績" AS FLOAT)), 0), + COALESCE(SUM(CAST("總成本" AS FLOAT)), 0), + COUNT(DISTINCT "商品ID"), + COUNT(DISTINCT "廠商名稱"), + COUNT(DISTINCT "日期") + FROM realtime_sales_monthly + WHERE "商品分類L1" = :cat + AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + """), {'cat': category, 's': start_date, 'e': end_date}).fetchone() + + daily = c.execute(text(""" + SELECT "日期", + SUM(CAST("總業績" AS FLOAT)) AS rev, + COUNT(DISTINCT "訂單編號") AS orders, + SUM(CAST("數量" AS INTEGER)) AS qty + FROM realtime_sales_monthly + WHERE "商品分類L1" = :cat + AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + GROUP BY "日期" ORDER BY "日期" ASC + """), {'cat': category, 's': start_date, 'e': end_date}).fetchall() + + prods = c.execute(text(""" + SELECT "商品ID", "商品名稱", + SUM(CAST("總業績" AS FLOAT)) AS rev, + SUM(CAST("數量" AS INTEGER)) AS qty, + COUNT(DISTINCT "訂單編號") AS orders + FROM realtime_sales_monthly + WHERE "商品分類L1" = :cat + AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + GROUP BY "商品ID", "商品名稱" + ORDER BY 3 DESC LIMIT 50 + """), {'cat': category, 's': start_date, 'e': end_date}).fetchall() + + vendors = c.execute(text(""" + SELECT "廠商名稱", + SUM(CAST("總業績" AS FLOAT)) AS rev, + SUM(CAST("總成本" AS FLOAT)) AS cost + FROM realtime_sales_monthly + WHERE "商品分類L1" = :cat + AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + AND "廠商名稱" IS NOT NULL AND "廠商名稱" != '' + GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT 30 + """), {'cat': category, 's': start_date, 'e': end_date}).fetchall() + + sub_cats = c.execute(text(""" + SELECT COALESCE("商品分類L2", '其他') AS l2, + SUM(CAST("總業績" AS FLOAT)) AS rev + FROM realtime_sales_monthly + WHERE "商品分類L1" = :cat + AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + GROUP BY l2 ORDER BY 2 DESC LIMIT 10 + """), {'cat': category, 's': start_date, 'e': end_date}).fetchall() + + # 近 30 天 vs 31-90 天,做新進榜判定 + new_prods = c.execute(text(""" + WITH recent AS ( + SELECT "商品ID", "商品名稱", + SUM(CAST("總業績" AS FLOAT)) AS rev_recent + FROM realtime_sales_monthly + WHERE "商品分類L1" = :cat + AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY "商品ID", "商品名稱" + ), + early AS ( + SELECT "商品ID" + FROM realtime_sales_monthly + WHERE "商品分類L1" = :cat + AND CAST("日期" AS DATE) BETWEEN + CURRENT_DATE - INTERVAL '90 days' AND + CURRENT_DATE - INTERVAL '31 days' + GROUP BY "商品ID" + ) + SELECT recent."商品ID", recent."商品名稱", recent.rev_recent + FROM recent + LEFT JOIN early ON recent."商品ID" = early."商品ID" + WHERE early."商品ID" IS NULL + ORDER BY recent.rev_recent DESC LIMIT 10 + """), {'cat': category}).fetchall() + + orders, revenue, cost = int(kpi_row[0]), float(kpi_row[1]), float(kpi_row[2]) + gm = (revenue - cost) / revenue * 100 if revenue > 0 else 0 + + return { + 'found': True, + 'category': category, + 'period': f"{start_date} ~ {end_date}", + 'kpis': { + 'revenue': revenue, + 'orders': orders, + 'gross_margin': gm, + 'avg_order': revenue / orders if orders else 0, + 'sku_count': int(kpi_row[3] or 0), + 'vendor_count': int(kpi_row[4] or 0), + 'days': int(kpi_row[5] or 0), + }, + 'daily': [ + {'date': str(r[0]), 'revenue': float(r[1] or 0), + 'orders': int(r[2] or 0), 'qty': int(r[3] or 0)} + for r in daily + ], + 'top_products': [ + {'id': r[0], 'name': r[1], 'revenue': float(r[2]), + 'qty': int(r[3] or 0), 'orders': int(r[4] or 0)} + for r in prods + ], + 'top_vendors': [ + {'name': r[0], 'sales': float(r[1] or 0), + 'profit': float(r[1] or 0) - float(r[2] or 0), + 'margin': ((float(r[1] or 0) - float(r[2] or 0)) / float(r[1] or 1) * 100) + if float(r[1] or 0) else 0} + for r in vendors + ], + 'sub_categories': [ + {'name': r[0], 'revenue': float(r[1])} for r in sub_cats + ], + 'new_products': [ + {'id': r[0], 'name': r[1], 'revenue': float(r[2])} + for r in new_prods + ], + } + except Exception as e: + sys_log.error(f"[query_category_deep] {e}") + return {'found': False} + + def query_period_summary(start_date: str, end_date: str) -> dict: """期間業績完整摘要(quarterly / half_yearly / annual / ttm 共用) diff --git a/services/openclaw_bot/menu_keyboards.py b/services/openclaw_bot/menu_keyboards.py index a77adb0..be708b3 100644 --- a/services/openclaw_bot/menu_keyboards.py +++ b/services/openclaw_bot/menu_keyboards.py @@ -216,6 +216,7 @@ def _submenu_reports(): ('📊 半年報 (本半)', 'cmd:ppt:half_yearly')), _row(('📊 年報 (本年)', 'cmd:ppt:annual'), ('📊 TTM 滾動 12 月', 'cmd:ppt:ttm')), + _row(('🗂 品類深度報告', 'await:category_deep'),), ]) diff --git a/services/ppt_generator.py b/services/ppt_generator.py index 9a15810..e4b4b44 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -56,6 +56,7 @@ TEMPLATE_VERSIONS = { 'half_yearly': 'v3.1.0', # 2026-05-03 半年報 'annual': 'v3.1.0', # 2026-05-03 年報 'ttm': 'v3.1.0', # 2026-05-03 TTM 滾動 12 月 + 'category': 'v3.1.0', # 2026-05-03 品類深度報告(90 天縱向 + 子品類 + 新進榜) 'bcg': 'v2.0', # DEPRECATED — 從未落地 } @@ -2947,6 +2948,250 @@ def generate_vendor_ppt(yr, mo, db_data, ai_text: str) -> str: return path +# ── 品類深度報告(單一品類 90 天縱向)───────────────────────────────────── +def generate_category_deep_ppt(category: str, db_data: dict, ai_text: str) -> str: + """品類深度報告 v3.1:單一品類縱向分析(PM/採購用) + P1 封面(含品類定位徽章) + P2 執行摘要(KPI + 子分類分佈帶) + P3 90 天日業績走勢(含日均線、高低點、檔期標註) + P4 子品類結構(橫條 + 帕雷托) + P5-P7 TOP 50 商品(自動分頁) + P8 TOP 30 廠商 + P9 新進榜商品(近 30 天) + P10 AI 採購/PM 視角洞察 + P11 附錄 + """ + 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 + + period = db_data.get('period', '') + kpis = db_data.get('kpis', {}) or {} + daily = db_data.get('daily', []) or [] + top_prods = db_data.get('top_products', []) or [] + top_vendors = db_data.get('top_vendors', []) or [] + sub_cats = db_data.get('sub_categories', []) or [] + new_prods = db_data.get('new_products', []) or [] + mcp_text = db_data.get('mcp', '') or '' + + rev = float(kpis.get('revenue', 0)) + ord_ = int(kpis.get('orders', 0)) + gm = float(kpis.get('gross_margin', 0)) + aov = float(kpis.get('avg_order', rev / ord_ if ord_ else 0)) + sku_count = int(kpis.get('sku_count', 0)) + vendor_count = int(kpis.get('vendor_count', 0)) + + # 品類定位徽章 + if rev > 1_000_000: + pos_label, pos_color = '主力品類', '2A7A3F' + elif rev > 200_000: + pos_label, pos_color = '成長品類', 'B88416' + else: + pos_label, pos_color = '長尾品類', '8A5A2B' + + # ── 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, "CATEGORY · 90-DAY DEEP DIVE · 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{category}", + 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, pos_color) + _add_text(slide, f"品類定位:{pos_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"期間 {period} · 業績 NT${rev:,.0f}({rev/10000:.1f}萬)" + f" · {sku_count} SKU · {vendor_count} 廠商", + 3.8, 8.7, 27, 0.85, + bold=True, size=14, color=_BRAND_OG2, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_text(slide, + f"訂單 {ord_:,} 筆 · 毛利率 {gm:.1f}% · 客單 NT${aov:,.0f}", + 3.8, 9.7, 27, 0.85, + size=12, color=_SUBTEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + if new_prods: + _add_rect(slide, 3.8, 11.5, W - 7.5, 1.5, "2A7A3F") + _add_text(slide, "🆕 近 30 天新進榜商品", + 4.0, 11.6, W - 7.9, 0.5, + bold=True, size=11, color=_WHITE, + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + new_text = ' '.join(p.get('name', '')[:18] for p in new_prods[:3]) + _add_text(slide, new_text, + 4.0, 12.2, W - 7.9, 0.75, + size=11, color=_WHITE, + 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: 執行摘要 ────────────────────────────────────────── + s2 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s2, f"執行摘要 — {category}({period})") + kpi_v2 = [ + (_KPI_CARAMEL, "期間業績", f"NT${rev/10000:.1f}萬", None, "—"), + (_KPI_HONEY, "期間訂單", f"{ord_:,} 筆", None, "—"), + (_KPI_MAHOGANY, "毛利率", f"{gm:.1f}%", None, "—"), + (_KPI_EARTH, "SKU 總數", f"{sku_count}", None, f"廠商 {vendor_count}"), + ] + 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 = "" + for line in (ai_text or '').split('\n'): + if line.strip() and not line.startswith('【'): + summary_text += line + "\n" + if len(summary_text) > 350: break + if not summary_text.strip(): + summary_text = (ai_text or '')[:350] if ai_text else "(暫無 AI 分析)" + + _add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, _BRAND_OG) + _add_text(s2, f"📊 {category} 90 天深度解讀", + 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.strip(), + 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: 90 天日業績走勢 ─────────────────────────────────── + s3 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s3, f"{category} 日業績走勢 — {period}") + if daily: + d_dates = [d.get('date', '') for d in daily] + d_revs = [float(d.get('revenue', 0)) for d in daily] + chart_w = W - 0.8 + chart_h = 12.5 + buf = _mpl_line_chart_png( + d_dates, d_revs, prev_vals=None, + total_width_cm=chart_w, total_height_cm=chart_h, + title=f"{category} 日業績走勢(含日均線、高低點)", + curr_label=category + ) + if buf: + _add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h) + # 底部結論帶 + avg_d = sum(d_revs) / len(d_revs) if d_revs else 0 + max_d = max(d_revs) if d_revs else 0 + _add_rect(s3, 0.4, 14.7, W - 0.8, 1.0, _BRAND_OG2) + _add_text(s3, + f"📊 日均業績 NT${avg_d/10000:.1f}萬 · 最高單日 NT${max_d/10000:.1f}萬" + f" · {len(d_revs)} 天有交易", + 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, "無 90 天日業績資料", "請確認該品類是否有銷售資料。", W) + _add_footer(s3, W) + + # ── P4: 子品類結構 ─────────────────────────────────────── + if sub_cats: + s4 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s4, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s4, f"子品類業績分佈 — {category}") + names = [c.get('name', '')[:14] for c in sub_cats[:8]] + revs = [float(c.get('revenue', 0)) for c in sub_cats[:8]] + 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="① 子品類排行", + highlight_top_n=3) + if buf1: + _add_image_from_buf(s4, buf1, 0.4, 1.95, chart_w_left, chart_h) + rx = W * 0.5 + 0.0 + buf2 = _mpl_pareto_chart_png(names, revs, + total_width_cm=W * 0.5 - 0.4, + total_height_cm=chart_h, + title="② 帕雷托累計貢獻") + if buf2: + _add_image_from_buf(s4, buf2, rx, 1.95, W * 0.5 - 0.4, chart_h) + _add_footer(s4, W) + + # ── P5-P7: TOP 50 商品 ──────────────────────────────────── + _product_table_slide(prs, f"{category} 熱銷商品 TOP 50 — {period}", + top_prods, max_items=50) + + # ── P8: TOP 30 廠商 ─────────────────────────────────────── + if top_vendors: + _vendor_table_slide(prs, top_vendors[:30], f"{category}({period})", {}, + sum(float(v.get('sales', 0)) for v in top_vendors), max_items=30) + + # ── P9: 新進榜商品(近 30 天)───────────────────────────── + if new_prods: + s9 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s9, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s9, f"🆕 近 30 天新進榜商品 — {category}(10 款)") + _add_rect(s9, 0.4, 1.95, W - 0.8, 0.7, "2A7A3F") + _add_text(s9, "潛力新品(過去 60 天無交易,近 30 天進榜)", + 0.7, 2.05, W - 1.4, 0.6, + bold=True, size=12, color=_WHITE, valign="middle", + ea_font=_FONT_BODY_EA) + for i, p in enumerate(new_prods[:10]): + row_y = 2.95 + i * 1.1 + bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE + _add_rect(s9, 0.4, row_y, W - 0.8, 1.0, bg) + _add_rect(s9, 0.55, row_y + 0.1, 0.95, 0.8, "2A7A3F") + _add_text(s9, "🆕", 0.55, row_y + 0.1, 0.95, 0.8, + bold=True, size=14, color=_WHITE, + align="center", valign="middle") + _add_text(s9, str(p.get('name', ''))[:50], + 1.7, row_y + 0.15, W - 12, 0.7, + size=12, color=_DARK_TEXT, + ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY) + _add_text(s9, f"NT${float(p.get('revenue', 0)):,.0f}", + W - 10, row_y + 0.15, 9.0, 0.7, + bold=True, size=13, color="2A7A3F", align="right", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_footer(s9, W) + + # ── P10: AI 洞察 ────────────────────────────────────────── + _ai_insight_slide(prs, ai_text) + + # ── P11: 附錄 ───────────────────────────────────────────── + _appendix_slide(prs, 'category', f"{category}({period})") + + path = _new_path("category") + prs.save(path) + return path + + # ── 期間回顧報告(quarterly / half_yearly / annual / ttm 共用)─────────────── def generate_period_review_ppt(period_type: str, period_label: str, db_data: dict, ai_text: str) -> str: