feat(ppt): category deep dive report v3.1.0 (90-day single category)
All checks were successful
CD Pipeline / deploy (push) Successful in 3m31s

Wave 1.3:單一品類 90 天縱向深度分析(PM/採購用)。

generate_category_deep_ppt(services/ppt_generator.py)— 12 頁
- P1 封面:含品類定位徽章(主力/成長/長尾)+ 新進榜商品 hero box
- P2 執行摘要:4 KPI(業績/訂單/毛利/SKU 數)+ AI 高階解讀
- P3 90 天日業績走勢(matplotlib 折線 + 日均線 + 高低點)+ 結論帶
- P4 子品類結構:橫條 + 帕雷托雙視圖
- P5-P7 TOP 50 商品(自動分頁)
- P8 TOP 30 廠商
- P9 新進榜商品專頁(近 30 天進榜,過去 60 天無交易判定)
- P10 AI 採購/PM 視角洞察
- P11 附錄

query_category_deep(routes/openclaw_bot_routes.py)
- 一次拉齊:kpis / daily(逐日) / top_products(50) / top_vendors(30) /
  sub_categories(L2) / new_products(近 30 天 vs 60 天前比對)
- 用 PostgreSQL CTE 做新進榜判定(recent EXCEPT early)

_ppt_ai_analysis 加 is_category 分支
- 角色:採購主管 + PM 商品經理
- 結構:品類整體 / 90 天趨勢 / 子品類結構 / SKU 與廠商組合 /
  SMART 三層 / 風險預警
- max_tokens 1800

路由:
- /ppt category 美妝保養        90 天深度(預設)
- /ppt category 美妝保養 30     自訂期間

Telegram 按鈕:「🗂 品類深度報告」(await:category_deep)
TODO:實作 await:category_deep 對應品類選擇互動(下一波)

bump TEMPLATE_VERSIONS['category'] = v3.1.0

煙霧測試:12 頁 300KB 全綠。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-03 02:14:07 +08:00
parent 1af96f5be4
commit d8260fcd25
3 changed files with 497 additions and 0 deletions

View File

@@ -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 共用)

View File

@@ -216,6 +216,7 @@ def _submenu_reports():
('📊 半年報 (本半)', 'cmd:ppt:half_yearly')),
_row(('📊 年報 (本年)', 'cmd:ppt:annual'),
('📊 TTM 滾動 12 月', 'cmd:ppt:ttm')),
_row(('🗂 品類深度報告', 'await:category_deep'),),
])

View File

@@ -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: