feat(ppt): customer analytics report v3.1.0 (simplified RFM, no user_id)
All checks were successful
CD Pipeline / deploy (push) Successful in 2m49s
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:
@@ -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_id(PII 法規限制),分析以訂單級為主:"
|
||||
"訂單規模分群、消費星期分佈、商品復購率。\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 天縱向分析
|
||||
|
||||
|
||||
@@ -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')),
|
||||
])
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
# 訂單數轉成虛擬「萬元」單位避免函式內除 10000(counts 不是業績)
|
||||
# 直接用 _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/採購用)
|
||||
|
||||
Reference in New Issue
Block a user