diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index c3340d9..df0513a 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -1889,6 +1889,7 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: is_customer = '客戶' in report_type or 'customer' in report_type is_forecast = '檔期前瞻' in report_type or 'forecast' in report_type is_promo_cmp = '多活動' in report_type or 'promo_compare' in report_type + is_new_prod = '新品' in report_type or 'new_product' in report_type # ── 格式鐵律(所有 prompt 共用後綴)──────────────────────── FORMAT_RULES = ( @@ -2046,6 +2047,46 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: + FORMAT_RULES ) max_tokens = 1400 + elif is_new_prod: + sys_instruction = ( + "你身兼 (1) PM 商品經理(精通新品上架 / 商品生命週期 / SKU 健康度)" + "(2) 採購主管(精通選品、新廠商引進、新品扶植)。\n" + "你的客戶是 momo PM 與採購團隊,會用本報告做新品扶植加碼、" + "曇花一現新品下架、明星新品行銷加碼的決策。\n\n" + f"請針對以下{report_type}資料,輸出新品戰術洞察,結構嚴格如下:\n\n" + "【新品力評估】(3-4 句)\n" + "引用新品數、新品業績、業績佔比,評估新品力等級(強勁 >8% / 穩健 3-8% / " + "偏弱 1-3% / 疲弱 <1%);與業界平均(健康電商 5-10%)比較定位;" + "若 <3% 必須點明「新品引進不足,將失去成長動能」並建議下季加碼新品開發。\n\n" + "【明星新品識別】(3-4 句)\n" + "點名 TOP3 明星新品,分析高業績成因(檔期推力 / 品牌力 / 行銷投放 / " + "獨家代理);建議哪 1-2 款適合做次月主推 hero SKU;" + "若 TOP1 新品業績 >NT$10 萬則建議升格為「常銷主力」加碼資源。\n\n" + "【品類分佈與機會】(3-4 句)\n" + "新品依品類分佈是否健康(過度集中於單一品類?);" + "建議下季應加碼新品的品類(依 2026 趨勢:永續美妝 / 母嬰高端 / " + "機能性食品 / 男性保養 / 銀髮保健等);" + "識別「新品荒漠」品類(無新品進駐者),建議優先填補。\n\n" + "【新品扶植與淘汰建議 — SMART 框架】\n" + "■ 立即執行(3 條,✅ 開頭):\n" + " ✅ 加碼:對 TOP3 新品(具體商品名)增加首頁版位/廣告預算 +X%," + "預期業績 +Y%,期限:YYYY/MM/DD\n" + " ✅ 觀察:對排名 11-30 名新品(具體商品名)2 週後回看," + "若週業績 < NT$Z 則啟動下架評估\n" + " ✅ 數據追蹤:建立新品 KPI 儀表板(爬榜速度 / 客單 / 復購率)," + "每週自動更新\n" + "■ 中期強化(2 條,✅ 開頭):開發新廠商 / 跨品類聯名 / 自有品牌 OEM\n" + "■ 長期佈局(1 條,✅ 開頭):建立新品引進 SOP(試銷 30 天 → " + "達標升常銷 / 不達標下架)\n\n" + "【最大風險與防禦】(2-3 句)\n" + "(a) 新品試銷失敗率高 → 建議單一品類下架率 >50% 觸發採購復盤\n" + "(b) 新品搶食常銷市場 → 觀察常銷商品銷量是否被新品稀釋\n" + "(c) 過度依賴單品爆款 → 建議新品 TOP1 佔新品業績 <30% 為健康\n\n" + "要求:每段引用至少 2 個具體數字,全文 800~1000 字,禁用模糊用詞。" + + MARKET_TREND_2026 + + FORMAT_RULES + ) + max_tokens = 1800 elif is_promo_cmp: sys_instruction = ( "你是資深行銷主管(10 年促銷活動策劃實戰經驗)。" @@ -2750,6 +2791,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, generate_vendor_ppt, generate_period_review_ppt, generate_category_deep_ppt, generate_customer_analytics_ppt, generate_forecast_pre_event_ppt, generate_promo_compare_ppt, + generate_new_product_ppt, check_pptx_available ) except ImportError: @@ -3274,6 +3316,58 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, }) return ppt_path + elif sub_type in ('new_product', 'newproduct', '新品', '新品追蹤'): + # /ppt new_product 預設 30 天追蹤 + # /ppt new_product 14 自訂追蹤天數 + days = 30 + if sub_arg and sub_arg.isdigit(): + days = int(sub_arg) + baseline_days = 60 + + params = {'report_type': 'new_product', 'days': days} + cached, cached_ai = _load_cached_ppt_path_and_analysis('new_product', params) + if cached: + return cached + + mcp_text = '' + if not cached_ai: + mcp_text = _fetch_mcp_context() + + np_data = query_new_products(days_recent=days, days_baseline=baseline_days) + if not np_data.get('found'): + raise RuntimeError(f'近 {days} 天無新品(前 {baseline_days} 天無交易但近期有銷售的商品)') + + kpis = np_data.get('kpis', {}) + top5_str = '\n'.join( + f" {i+1}. {p.get('name','')[:30]} ({p.get('category','—')}) — " + f"NT${p.get('revenue', 0):,.0f}" + for i, p in enumerate(np_data.get('new_products', [])[:5]) + ) + sub_str = '\n'.join( + f" - {c.get('name','')}: {c.get('sku_count', 0)} 款 / " + f"NT${c.get('revenue', 0):,.0f}" + for c in np_data.get('sub_categories', [])[:5] + ) + data_summary = ( + f"【追蹤期間】{np_data.get('period', '')}\n" + f"【新品總數】{kpis.get('new_count', 0)} 款\n" + f"【新品業績】NT${kpis.get('new_revenue', 0):,.0f}\n" + f"【業績佔比】{kpis.get('new_pct', 0):.1f}%(vs 整體 NT${kpis.get('total_revenue', 0):,.0f})\n\n" + f"【新品 TOP 5】\n{top5_str}\n\n" + f"【新品依品類分佈】\n{sub_str}\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_new_product_ppt(np_data, ai_text) + _store_ppt_cache('new_product', params, ppt_path, { + 'report_type': 'new_product', 'parameters': params, + 'data_summary': data_summary, 'analysis': ai_text, 'mcp': mcp_text, + }) + return ppt_path + elif sub_type in ('promo_compare', 'promocompare', '促銷比較', '多活動'): # /ppt promo_compare 母親節:2026/05/05-2026/05/14|520:2026/05/18-2026/05/22|618:2026/06/14-2026/06/22 # 用 | 分隔多場活動,每場用 : 分 label/dates @@ -4721,6 +4815,123 @@ def query_date_range(start_str: str, end_str: str) -> dict: return {'found': False, 'range': f'{start_str}~{end_str}'} +def query_new_products(days_recent: int = 30, days_baseline: int = 60) -> dict: + """新品追蹤:近 days_recent 天有銷售、過去 days_baseline 天無銷售的商品 + + 回傳:{ + period, kpis: {new_count, new_revenue, new_pct, top1_revenue}, + new_products: [TOP 50 含日銷售軌跡], + sub_categories: [新品依品類分佈], + daily_total: [{date, new_revenue}], # 新品整體日業績 + } + """ + try: + with _db().connect() as c: + # 主查詢:近 N 天 EXCEPT 早期 + new_rows = c.execute(text(f""" + WITH recent AS ( + SELECT "商品ID", "商品名稱", "商品分類L1", + SUM(CAST("總業績" AS FLOAT)) AS rev, + SUM(CAST("數量" AS INTEGER)) AS qty, + COUNT(DISTINCT "訂單編號") AS orders, + MIN(CAST("日期" AS DATE)) AS first_seen + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days' + GROUP BY "商品ID", "商品名稱", "商品分類L1" + ), + early AS ( + SELECT DISTINCT "商品ID" + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN + CURRENT_DATE - INTERVAL '{days_recent + days_baseline} days' AND + CURRENT_DATE - INTERVAL '{days_recent + 1} days' + ) + SELECT recent.* FROM recent + LEFT JOIN early ON recent."商品ID" = early."商品ID" + WHERE early."商品ID" IS NULL + ORDER BY recent.rev DESC LIMIT 50 + """)).fetchall() + + # 新品總業績 + 整體業績佔比 + new_rev_total = sum(float(r[3] or 0) for r in new_rows) + total_row = c.execute(text(f""" + SELECT COALESCE(SUM(CAST("總業績" AS FLOAT)), 0) + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days' + """)).fetchone() + total_rev = float(total_row[0] or 0) + + # 子品類分佈 + sub_dist = c.execute(text(f""" + WITH recent AS ( + SELECT "商品ID", "商品分類L1", + SUM(CAST("總業績" AS FLOAT)) AS rev + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days' + GROUP BY "商品ID", "商品分類L1" + ), + early AS ( + SELECT DISTINCT "商品ID" + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN + CURRENT_DATE - INTERVAL '{days_recent + days_baseline} days' AND + CURRENT_DATE - INTERVAL '{days_recent + 1} days' + ) + SELECT COALESCE(recent."商品分類L1", '其他') AS cat, + COUNT(*) AS sku_count, + SUM(recent.rev) AS rev + FROM recent + LEFT JOIN early ON recent."商品ID" = early."商品ID" + WHERE early."商品ID" IS NULL + GROUP BY recent."商品分類L1" + ORDER BY 3 DESC LIMIT 10 + """)).fetchall() + + # 新品整體日業績曲線 + new_ids = [r[0] for r in new_rows] + if new_ids: + # 用 ANY array 比較 + daily_rows = c.execute(text(f""" + SELECT "日期", SUM(CAST("總業績" AS FLOAT)) AS rev + FROM realtime_sales_monthly + WHERE "商品ID" = ANY(:ids) + AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days' + GROUP BY "日期" ORDER BY "日期" ASC + """), {'ids': new_ids}).fetchall() + else: + daily_rows = [] + + return { + 'found': len(new_rows) > 0, + 'period': f"近 {days_recent} 天(vs 前 {days_baseline} 天 baseline)", + 'kpis': { + 'new_count': len(new_rows), + 'new_revenue': new_rev_total, + 'total_revenue': total_rev, + 'new_pct': new_rev_total / total_rev * 100 if total_rev else 0, + 'top1_revenue': float(new_rows[0][3]) if new_rows else 0, + 'days_recent': days_recent, + }, + 'new_products': [ + {'id': r[0], 'name': r[1], 'category': r[2] or '—', + 'revenue': float(r[3] or 0), 'qty': int(r[4] or 0), + 'orders': int(r[5] or 0), 'first_seen': str(r[6])} + for r in new_rows + ], + 'sub_categories': [ + {'name': r[0], 'sku_count': int(r[1]), + 'revenue': float(r[2] or 0)} for r in sub_dist + ], + 'daily_total': [ + {'date': str(r[0]), 'revenue': float(r[1] or 0)} + for r in daily_rows + ], + } + except Exception as e: + sys_log.error(f"[query_new_products] {e}") + return {'found': False, 'error': str(e)} + + def query_forecast_pre_event(event_name: str, event_date: str, before_days: int = 14, after_days: int = 7) -> dict: """檔期前瞻:給定檔期日 + 名稱,回傳: diff --git a/services/openclaw_bot/menu_keyboards.py b/services/openclaw_bot/menu_keyboards.py index 64c9c98..60f4a5b 100644 --- a/services/openclaw_bot/menu_keyboards.py +++ b/services/openclaw_bot/menu_keyboards.py @@ -220,6 +220,7 @@ def _submenu_reports(): ('👥 客戶/訂單分析', 'cmd:ppt:customer')), _row(('🎯 檔期前瞻報告', 'await:forecast_event'), ('🆚 多活動比較', 'await:promo_compare')), + _row(('🆕 新品 30 天追蹤', 'cmd:ppt:new_product'),), ]) diff --git a/services/ppt_generator.py b/services/ppt_generator.py index 7865167..560104f 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -61,6 +61,7 @@ TEMPLATE_VERSIONS = { 'customer': 'v3.1.0', # 2026-05-03 客戶/訂單分析(簡化 RFM,受資料層 user_id 限制) 'forecast_pre_event': 'v3.1.0', # 2026-05-03 檔期前瞻報(baseline × lift_factor 預測 + 去年同檔期) 'promo_compare': 'v3.1.0', # 2026-05-03 多活動 ROI 並排比較 + 'new_product': 'v3.1.0', # 2026-05-03 新品 30 天追蹤(PM/採購) } @@ -2951,6 +2952,229 @@ def generate_vendor_ppt(yr, mo, db_data, ai_text: str) -> str: return path +# ── 新品 30 天追蹤報告 ────────────────────────────────────────────────── +def generate_new_product_ppt(db_data: dict, ai_text: str) -> str: + """新品 30 天追蹤報告 v3.1(PM/採購用) + P1 封面:含新品數徽章 + 業績佔比 + P2 KPI 摘要 + 業績佔比評估 + P3 新品整體日業績曲線(爬榜軌跡) + P4 新品依品類分佈(橫條 + 業績/SKU 數雙軸概念) + P5-P7 新品 TOP 50 列表(自動分頁,含品類) + P8 AI PM 戰術洞察(明星新品 / 該扶植 / 該下架) + P9 附錄 + """ + 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 {} + new_prods = db_data.get('new_products', []) or [] + sub_cats = db_data.get('sub_categories', []) or [] + daily_total = db_data.get('daily_total', []) or [] + + new_count = int(kpis.get('new_count', 0)) + new_rev = float(kpis.get('new_revenue', 0)) + total_rev = float(kpis.get('total_revenue', 0)) + new_pct = float(kpis.get('new_pct', 0)) + + # 新品強度徽章 + if new_pct >= 8: + strength_label, strength_color = '新品力強勁', '2A7A3F' + elif new_pct >= 3: + strength_label, strength_color = '新品力穩健', 'B88416' + elif new_pct >= 1: + strength_label, strength_color = '新品力偏弱', 'C96442' + else: + strength_label, strength_color = '新品力疲弱', 'B5342F' + + # ── 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, "2A7A3F") + _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, "2A7A3F") + _add_rect(slide, 4.0, 8.4, 22.0, 0.06, "2A7A3F") + + _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, "NEW PRODUCT · 30-DAY TRACKING · 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}", + 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, strength_color) + _add_text(slide, f"新品力:{strength_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"🆕 {new_count} 款新品 · 業績 NT${new_rev/10000:.1f}萬" + f"(佔總業績 {new_pct:.1f}%)", + 3.8, 8.7, 27, 0.85, + bold=True, size=14, color=_BRAND_OG2, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 三個亮點 + if new_prods: + top1 = new_prods[0] + pitch_y = 10.2 + _add_rect(slide, 3.8, pitch_y, 0.45, 1.5, "2A7A3F") + _add_text(slide, "🏆 最強新品", + 4.4, pitch_y + 0.1, 27, 0.55, + bold=True, size=11, color="2A7A3F", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(slide, + f"{top1.get('name','')[:40]} " + f"NT${float(top1.get('revenue',0)):,.0f}({top1.get('category','—')})", + 4.4, pitch_y + 0.7, 27, 0.75, + size=12, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + if sub_cats: + top_cat = sub_cats[0] + pitch_y2 = 12.1 + _add_rect(slide, 3.8, pitch_y2, 0.45, 1.5, "B88416") + _add_text(slide, "📊 新品集中品類", + 4.4, pitch_y2 + 0.1, 27, 0.55, + bold=True, size=11, color="B88416", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(slide, + f"{top_cat.get('name','')[:30]} " + f"{top_cat.get('sku_count', 0)} 款新品 " + f"業績 NT${top_cat.get('revenue', 0)/10000:.1f}萬", + 4.4, pitch_y2 + 0.7, 27, 0.75, + size=12, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + pitch_y3 = 14.0 + _add_rect(slide, 3.8, pitch_y3, 0.45, 1.5, "C96442") + _add_text(slide, "🎯 業績佔比評估", + 4.4, pitch_y3 + 0.1, 27, 0.55, + bold=True, size=11, color="C96442", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(slide, + f"新品 {new_pct:.1f}% — " + ( + "業界一流(>8%)" if new_pct >= 8 else + "健康(3-8%)" if new_pct >= 3 else + "需強化(<3%)" + ) + ";建議目標 5-10%(電商業界平均)", + 4.4, pitch_y3 + 0.7, 27, 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) + + # ── 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}") + avg_rev = new_rev / new_count if new_count else 0 + cards = [ + (_KPI_CARAMEL, "新品總數", f"{new_count} 款", None, "近 30 天進榜"), + (_KPI_HONEY, "新品業績", f"NT${new_rev/10000:.1f}萬", None, "30 天累積"), + (_KPI_MAHOGANY, "業績佔比", f"{new_pct:.1f}%", None, "vs 整體業績"), + (_KPI_EARTH, "新品平均", f"NT${avg_rev/10000:.1f}萬", None, "單品 30 天均值"), + ] + for i, (col, lbl, val, dp, dl) in enumerate(cards): + _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, "2A7A3F") + _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, "2A7A3F") + _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 daily_total: + s3 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s3, "新品整體日業績走勢(爬榜軌跡)") + d_dates = [d.get('date', '') for d in daily_total] + d_revs = [float(d.get('revenue', 0)) for d in daily_total] + 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="新品 30 天日業績走勢", + curr_label="新品合計" + ) + if buf: + _add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h) + _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, "新品依品類分佈") + 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 = W - 0.8 + chart_h = 11.5 + buf = _mpl_horiz_bar_png(names, revs, + total_width_cm=chart_w, + total_height_cm=chart_h, + value_unit="萬", + title="新品業績排行(依品類)", + highlight_top_n=3) + if buf: + _add_image_from_buf(s4, buf, 0.4, 1.95, chart_w, chart_h) + + # 底部結論 + _add_rect(s4, 0.4, 14.0, W - 0.8, 1.4, _BRAND_OG2) + cat_summary = ' · '.join( + f"{c.get('name','')[:8]} {c.get('sku_count',0)} 款" + for c in sub_cats[:5] + ) + _add_text(s4, f"📊 品類新品數:{cat_summary}", + 0.7, 14.15, W - 1.4, 1.1, + bold=True, size=12, color=_WHITE, valign="middle", wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_footer(s4, W) + + # ── P5-P7 新品 TOP 50 ────────────────────────────────── + _product_table_slide(prs, f"新品 TOP {min(50, len(new_prods))} — {period}", + new_prods, max_items=50) + + # ── P8 AI 洞察 ───────────────────────────────────────── + _ai_insight_slide(prs, ai_text) + + # ── P9 附錄 ──────────────────────────────────────────── + _appendix_slide(prs, 'new_product', period) + + path = _new_path("new_product") + prs.save(path) + return path + + # ── 多活動 ROI 橫向比較報告 ───────────────────────────────────────────── def generate_promo_compare_ppt(label: str, db_data: dict, ai_text: str) -> str: """多活動 ROI 比較報告:2-N 個促銷活動並排比較