feat(ppt): customer analytics report v3.1.0 (simplified RFM, no user_id)
All checks were successful
CD Pipeline / deploy (push) Successful in 2m49s

Wave 1.4:客戶/訂單分析報告(行銷主管用)— 受限於資料層無 user_id,
做訂單級分析而非完整 RFM。

generate_customer_analytics_ppt — 7 頁
- P1 封面:含客單定位徽章(高/中/低)+ 限制聲明(無 user_id 註腳)
- P2 KPI:總訂單/總業績/平均客單/高客單訂單數
- P3 客單價分佈:6 級分桶橫條(< NT$500 / 500-1K / 1-2K / 2-5K / 5-10K / >10K)
- P4 消費星期分佈:matplotlib 橫條(找熱門時段)
- P5 商品復購排行 TOP 30:同商品在多筆獨立訂單中的次數
- P6 AI 行銷洞察
- P7 附錄

query_customer_analytics
- AOV 分桶(CASE WHEN bucket)
- DOW 星期聚合(PostgreSQL EXTRACT(DOW))
- 復購商品(GROUP BY 商品名稱 HAVING COUNT(訂單) >= 5)

_ppt_ai_analysis 加 is_customer 分支
- 角色:資深行銷主管(10 年 RFM/CRM 經驗)
- 結構:訂單規模解讀 / 消費熱點 / 商品復購信號 / SMART 三層
- 長期建議:建立會員系統取得 user_id 升級完整 RFM

路由:
- /ppt customer            預設近 30 天
- /ppt customer 2026/04    指定月份

Telegram 按鈕:「👥 客戶/訂單分析」

bump TEMPLATE_VERSIONS['customer'] = v3.1.0

煙霧測試:7 頁 100KB 全綠。

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

View File

@@ -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_idPII 法規限制),分析以訂單級為主:"
"訂單規模分群、消費星期分佈、商品復購率。\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 天縱向分析

View File

@@ -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')),
])

View File

@@ -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]
# 訂單數轉成虛擬「萬元」單位避免函式內除 10000counts 不是業績)
# 直接用 _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/採購用)