feat(ppt): new_product 30-day tracking report v3.1.0
All checks were successful
CD Pipeline / deploy (push) Successful in 2m35s

Wave 3.1:新品追蹤報告(PM/採購用)

generate_new_product_ppt — 9 頁
- P1 封面:含新品力徽章(強勁/穩健/偏弱/疲弱,依業績佔比)+ 三亮點
- P2 KPI:新品總數/業績/佔比/平均單品業績 + AI 解讀
- P3 新品整體日業績曲線(爬榜軌跡 matplotlib)
- P4 新品依品類分佈橫條
- P5-P7 新品 TOP 50(自動分頁)
- P8 AI PM 戰術洞察
- P9 附錄

query_new_products(days_recent=30, days_baseline=60)
- PostgreSQL CTE:recent EXCEPT early
  recent = 近 30 天有銷售
  early  = 31-90 天前有銷售
- 自動回傳:新品 TOP 50 / 子品類分佈 / 日業績曲線
- 含 ANY array 查詢新品集合的整體日業績

_ppt_ai_analysis 加 is_new_prod 分支
- 角色:PM 商品經理 + 採購主管
- 結構:新品力評估 / 明星新品識別 / 品類分佈與機會 / SMART 三層 / 風險預警
- SMART 行動含:加碼 TOP3 / 觀察排名 11-30 / 數據追蹤
- max_tokens 1800

業界基準:新品業績佔比 5-10% 為健康,>8% 強勁,<3% 偏弱

路由:
- /ppt new_product       預設近 30 天
- /ppt new_product 14    自訂追蹤天數

Telegram 按鈕:「🆕 新品 30 天追蹤」

bump TEMPLATE_VERSIONS['new_product'] = v3.1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-03 12:37:06 +08:00
parent af6157f8ba
commit 95a74c3502
3 changed files with 436 additions and 0 deletions

View File

@@ -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:
"""檔期前瞻:給定檔期日 + 名稱,回傳:

View File

@@ -220,6 +220,7 @@ def _submenu_reports():
('👥 客戶/訂單分析', 'cmd:ppt:customer')),
_row(('🎯 檔期前瞻報告', 'await:forecast_event'),
('🆚 多活動比較', 'await:promo_compare')),
_row(('🆕 新品 30 天追蹤', 'cmd:ppt:new_product'),),
])

View File

@@ -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.1PM/採購用)
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 個促銷活動並排比較