484 lines
20 KiB
Python
484 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""达播业务可视化图表生成"""
|
||
import openpyxl
|
||
import glob
|
||
import numpy as np
|
||
import matplotlib
|
||
matplotlib.use('Agg')
|
||
import matplotlib.pyplot as plt
|
||
import matplotlib.ticker as mticker
|
||
from matplotlib.patches import FancyBboxPatch
|
||
from datetime import datetime, timedelta
|
||
from collections import defaultdict
|
||
import os
|
||
|
||
# ============ Chinese font setup ============
|
||
plt.rcParams['font.sans-serif'] = ['WenQuanYi Micro Hei', 'WenQuanYi Zen Hei', 'Noto Sans CJK SC', 'SimHei', 'DejaVu Sans']
|
||
plt.rcParams['axes.unicode_minus'] = False
|
||
|
||
# ============ Color palette ============
|
||
C_PRIME = '#2563EB' # 主色
|
||
C_ACCENT = '#F59E0B' # 强调
|
||
C_DANGER = '#EF4444' # 危险/退款
|
||
C_SUCCESS = '#10B981' # 健康
|
||
C_PURPLE = '#8B5CF6'
|
||
C_TEAL = '#14B8A6'
|
||
C_PINK = '#EC4899'
|
||
C_GRAY = '#6B7280'
|
||
C_LIGHT = '#F3F4F6'
|
||
C_DARK = '#1F2937'
|
||
|
||
PALETTE = ['#2563EB','#F59E0B','#EF4444','#8B5CF6','#10B981','#EC4899','#14B8A6','#F97316','#6366F1','#84CC16']
|
||
|
||
# ============ Data loading ============
|
||
files = glob.glob('/root/.openclaw/media/inbound/*3403f15a*')
|
||
wb = openpyxl.load_workbook(files[0])
|
||
ws = wb['Sheet1']
|
||
|
||
def excel_date_to_str(val):
|
||
if val is None: return None
|
||
if isinstance(val, datetime): return val.strftime('%Y-%m-%d')
|
||
if isinstance(val, str): return val
|
||
if isinstance(val, (int, float)):
|
||
try:
|
||
return (datetime(1899,12,30)+timedelta(days=int(val))).strftime('%Y-%m-%d')
|
||
except: return str(val)
|
||
return str(val)
|
||
|
||
all_data = []
|
||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, values_only=True):
|
||
name = str(row[0]).strip() if row[0] else ''
|
||
if '合计' in name or '总计' in name:
|
||
continue
|
||
orders = row[3]
|
||
if orders is None:
|
||
continue
|
||
all_data.append({
|
||
'name': name,
|
||
'date': excel_date_to_str(row[1]),
|
||
'platform': str(row[2]).strip() if row[2] else '',
|
||
'orders': orders,
|
||
'gmv': row[4] or 0,
|
||
'refund_orders': row[5] or 0,
|
||
'refund_amount': row[6] or 0,
|
||
'gsv': row[10] or 0,
|
||
})
|
||
|
||
# Name normalization
|
||
name_map = {
|
||
'晚柠也是个妈妈了':'晚柠','晚柠':'晚柠',
|
||
'念妈讲学习规划':'念妈','念妈':'念妈',
|
||
'学霸三人行':'学霸三人行','学霸老王':'学霸老王',
|
||
'开心妈妈学习宝藏':'开心妈妈','开心爸育儿':'开心爸',
|
||
'小花生网':'小花生网','小花生':'小花生网',
|
||
'盈姐':'盈姐','百克力':'百克力','亮爸':'亮爸',
|
||
'万物分销':'万物分销','万物内购':'万物分销','万物团购':'万物分销',
|
||
'小小鹰萱妈':'小小鹰萱妈','老狼聊育儿':'老狼聊育儿',
|
||
'海淀妈妈优选':'海淀妈妈优选','神奇瓜妈聊成长':'神奇瓜妈',
|
||
'宣儿妈妈':'宣儿妈妈','宣儿麻麻':'宣儿妈妈',
|
||
'四个娃的组合生活':'四个娃的组合生活','肆个葫芦娃的妈':'四个娃的组合生活',
|
||
'小暖阿姨慢一点':'小暖阿姨',
|
||
}
|
||
for r in all_data:
|
||
r['norm_name'] = name_map.get(r['name'], r['name'])
|
||
|
||
# Monthly summary data (from the spreadsheet summary rows)
|
||
monthly = [
|
||
('2025-09', 382, 763618, 197, 393803, 369615, 2),
|
||
('2025-10', 390, 779610, 156, 311844, 466116, 6),
|
||
('2025-11', 222, 443778, 105, 209895, 232683, 4),
|
||
('2025-12', 190, 327241, 44, 74976, 251365, 4),
|
||
('2026-01', 154, 305250, 40, 79311, 224939, 10),
|
||
('2026-02', 239, 477761, 161, 321839, 143927.5, 1),
|
||
('2026-03', 838, 2703044, 254, 831703, 1695826, 11),
|
||
('2026-04', 1480, 4645465, 679, 2110850, 2507226.5, 23),
|
||
('2026-05', 492, 1616312, 150, 485334, 1130978, 15),
|
||
]
|
||
|
||
months = [m[0] for m in monthly]
|
||
month_labels = ['9月','10月','11月','12月','1月','2月','3月','4月','5月']
|
||
m_orders = [m[1] for m in monthly]
|
||
m_gmv = [m[2] for m in monthly]
|
||
m_gsv = [m[4] for m in monthly]
|
||
m_refund_amt = [m[5] for m in monthly]
|
||
m_refund_rate = [(m[3]/m[1]*100) if m[1]>0 else 0 for m in monthly]
|
||
m_sessions = [m[6] for m in monthly]
|
||
|
||
# Influencer aggregate
|
||
inf = defaultdict(lambda:{'gmv':0,'gsv':0,'orders':0,'ref_ords':0})
|
||
for r in all_data:
|
||
n = r['norm_name']
|
||
inf[n]['gmv'] += r['gmv']
|
||
inf[n]['gsv'] += r['gsv']
|
||
inf[n]['orders'] += r['orders']
|
||
inf[n]['ref_ords'] += r['refund_orders']
|
||
|
||
inf_sorted = sorted(inf.items(), key=lambda x:x[1]['gmv'], reverse=True)
|
||
top10 = inf_sorted[:10]
|
||
top_names = [x[0] for x in top10]
|
||
top_gmv = [x[1]['gmv']/10000 for x in top10]
|
||
top_gsv = [x[1]['gsv']/10000 for x in top10]
|
||
top_ref_rate = [(x[1]['ref_ords']/x[1]['orders']*100) if x[1]['orders']>0 else 0 for x in top10]
|
||
|
||
# Platform aggregate
|
||
plat = defaultdict(lambda:{'gmv':0,'gsv':0,'orders':0,'ref_ords':0})
|
||
for r in all_data:
|
||
p = r['platform'] if r['platform'] else '未标注'
|
||
plat[p]['gmv'] += r['gmv']
|
||
plat[p]['gsv'] += r['gsv']
|
||
plat[p]['orders'] += r['orders']
|
||
plat[p]['ref_ords'] += r['refund_orders']
|
||
|
||
# Merge similar platforms
|
||
plat_merged = defaultdict(lambda:{'gmv':0,'gsv':0,'orders':0,'ref_ords':0})
|
||
merge_map = {
|
||
'三开':'三开','小红书':'小红书','小红书-混场':'小红书',
|
||
'抖音+视频号':'抖音+视频号','抖音&视频号':'抖音+视频号',
|
||
'视频号、抖音':'抖音+视频号','视频号+抖音':'抖音+视频号','视频号1+抖音':'抖音+视频号',
|
||
'视频号':'视频号','视频号2':'视频号','视频号+小红书':'视频号+小红书',
|
||
'视频号+小红书+抖音':'三开(全平台)','抖音、视频号、小红书':'三开(全平台)',
|
||
'抖音':'抖音','抖音+微信小店':'抖音',
|
||
'微信小店':'微信小店','公众号':'公众号','社群':'社群',
|
||
'分销-内购':'分销','分销-开团':'分销','分销-团购下架':'分销',
|
||
'万物':'万物',
|
||
}
|
||
for p, s in plat.items():
|
||
mp = merge_map.get(p, p)
|
||
plat_merged[mp]['gmv'] += s['gmv']
|
||
plat_merged[mp]['gsv'] += s['gsv']
|
||
plat_merged[mp]['orders'] += s['orders']
|
||
plat_merged[mp]['ref_ords'] += s['ref_ords']
|
||
|
||
plat_sorted = sorted(plat_merged.items(), key=lambda x:x[1]['gmv'], reverse=True)
|
||
plat_names = [x[0] for x in plat_sorted]
|
||
plat_gmv = [x[1]['gmv']/10000 for x in plat_sorted]
|
||
plat_ref_rate = [(x[1]['ref_ords']/x[1]['orders']*100) if x[1]['orders']>0 else 0 for x in plat_sorted]
|
||
|
||
# ============================================
|
||
# FIGURE 1: 月度趋势 —— GMV/GSV + 退款率
|
||
# ============================================
|
||
fig, axes = plt.subplots(2, 3, figsize=(20, 12))
|
||
fig.patch.set_facecolor('#FAFBFC')
|
||
|
||
# Chart 1: GMV & GSV Trend
|
||
ax1 = axes[0, 0]
|
||
x = np.arange(len(months))
|
||
bars = ax1.bar(x - 0.15, np.array(m_gmv)/10000, 0.3, color=C_PRIME, alpha=0.85, label='GMV (万元)', zorder=3)
|
||
bars2 = ax1.bar(x + 0.15, np.array(m_gsv)/10000, 0.3, color=C_SUCCESS, alpha=0.85, label='GSV (万元)', zorder=3)
|
||
|
||
# Add value labels
|
||
for bar, val in zip(bars, m_gmv):
|
||
ax1.text(bar.get_x()+bar.get_width()/2, bar.get_height()+0.5, f'{val/10000:.1f}',
|
||
ha='center', va='bottom', fontsize=7.5, fontweight='bold', color=C_PRIME)
|
||
for bar, val in zip(bars2, m_gsv):
|
||
ax1.text(bar.get_x()+bar.get_width()/2, bar.get_height()+0.5, f'{val/10000:.1f}',
|
||
ha='center', va='bottom', fontsize=7.5, fontweight='bold', color=C_SUCCESS)
|
||
|
||
ax1.set_xticks(x)
|
||
ax1.set_xticklabels(month_labels, fontsize=10)
|
||
ax1.set_title('月度 GMV & GSV 趋势', fontsize=14, fontweight='bold', pad=12)
|
||
ax1.set_ylabel('万元', fontsize=10)
|
||
ax1.legend(loc='upper left', fontsize=9, framealpha=0.9)
|
||
ax1.grid(axis='y', alpha=0.3, zorder=0)
|
||
ax1.set_ylim(0, max(m_gmv)/10000*1.2)
|
||
|
||
# Chart 2: Refund Rate + Sessions
|
||
ax2 = axes[0, 1]
|
||
ax2_twin = ax2.twinx()
|
||
x = np.arange(len(months))
|
||
line1 = ax2.plot(x, m_refund_rate, 'o-', color=C_DANGER, linewidth=2.5, markersize=8, zorder=4, label='退款率(%)')
|
||
bars3 = ax2_twin.bar(x, m_sessions, 0.5, color=C_PURPLE, alpha=0.25, zorder=2, label='直播场次')
|
||
|
||
# Mark danger zone
|
||
ax2.axhline(y=40, color=C_DANGER, linestyle='--', alpha=0.4, linewidth=1)
|
||
ax2.text(len(months)-0.5, 41, '40%警戒线', fontsize=8, color=C_DANGER, alpha=0.7)
|
||
|
||
for i, (r, s) in enumerate(zip(m_refund_rate, m_sessions)):
|
||
ax2.annotate(f'{r:.1f}%', (i, r), textcoords="offset points", xytext=(0,12),
|
||
ha='center', fontsize=8.5, fontweight='bold', color=C_DANGER)
|
||
|
||
ax2.set_xticks(x)
|
||
ax2.set_xticklabels(month_labels, fontsize=10)
|
||
ax2.set_title('月度退款率 & 直播场次', fontsize=14, fontweight='bold', pad=12)
|
||
ax2.set_ylabel('退款率 (%)', fontsize=10, color=C_DANGER)
|
||
ax2_twin.set_ylabel('场次', fontsize=10, color=C_PURPLE)
|
||
ax2.tick_params(axis='y', colors=C_DANGER)
|
||
ax2_twin.tick_params(axis='y', colors=C_PURPLE)
|
||
ax2.grid(axis='y', alpha=0.3, zorder=0)
|
||
ax2.set_ylim(0, 80)
|
||
|
||
# Chart 3: Monthly Refund Amount Breakdown (stacked bar)
|
||
ax3 = axes[0, 2]
|
||
x = np.arange(len(months))
|
||
gmv_arr = np.array(m_gmv)/10000
|
||
gsv_arr = np.array(m_gsv)/10000
|
||
refund_arr = gmv_arr - gsv_arr
|
||
|
||
ax3.bar(x, gsv_arr, color=C_SUCCESS, alpha=0.85, label='GSV 实收', zorder=3)
|
||
ax3.bar(x, refund_arr, bottom=gsv_arr, color=C_DANGER, alpha=0.6, label='退款金额', zorder=3)
|
||
|
||
for i in range(len(months)):
|
||
total = gmv_arr[i]
|
||
net = gsv_arr[i]
|
||
rate = (total-net)/total*100 if total>0 else 0
|
||
ax3.text(i, total+1, f'{total:.1f}万', ha='center', fontsize=8, fontweight='bold', color=C_DARK)
|
||
ax3.text(i, net/2, f'{net/total*100:.0f}%', ha='center', fontsize=7.5, color='white', fontweight='bold')
|
||
|
||
ax3.set_xticks(x)
|
||
ax3.set_xticklabels(month_labels, fontsize=10)
|
||
ax3.set_title('GMV 构成:实收 vs 退款', fontsize=14, fontweight='bold', pad=12)
|
||
ax3.set_ylabel('万元', fontsize=10)
|
||
ax3.legend(loc='upper right', fontsize=9)
|
||
ax3.grid(axis='y', alpha=0.3, zorder=0)
|
||
|
||
# Chart 4: TOP10 Influencer GMV
|
||
ax4 = axes[1, 0]
|
||
y_pos = np.arange(len(top_names))
|
||
colors_bar = [C_PRIME if i==0 else C_ACCENT if i==1 else '#94A3B8' for i in range(len(top_names))]
|
||
|
||
bars4 = ax4.barh(y_pos, top_gmv, color=colors_bar, height=0.65, zorder=3)
|
||
for bar, val, gsv_val in zip(bars4, top_gmv, top_gsv):
|
||
ax4.text(bar.get_width()+1, bar.get_y()+bar.get_height()/2,
|
||
f'GMV ¥{val:.0f}万 | GSV ¥{gsv_val:.0f}万',
|
||
va='center', fontsize=8, color=C_DARK)
|
||
|
||
ax4.set_yticks(y_pos)
|
||
ax4.set_yticklabels(top_names, fontsize=10)
|
||
ax4.set_title('达人 GMV 排行 TOP10', fontsize=14, fontweight='bold', pad=12)
|
||
ax4.set_xlabel('GMV (万元)', fontsize=10)
|
||
ax4.invert_yaxis()
|
||
ax4.grid(axis='x', alpha=0.3, zorder=0)
|
||
ax4.set_xlim(0, max(top_gmv)*1.25)
|
||
|
||
# Chart 5: TOP10 Refund Rate Comparison
|
||
ax5 = axes[1, 1]
|
||
y_pos = np.arange(len(top_names))
|
||
rate_colors = [C_SUCCESS if r < 30 else C_DANGER if r > 45 else C_ACCENT for r in top_ref_rate]
|
||
bars5 = ax5.barh(y_pos, top_ref_rate, color=rate_colors, height=0.65, zorder=3)
|
||
|
||
for bar, val in zip(bars5, top_ref_rate):
|
||
ax5.text(bar.get_width()+1, bar.get_y()+bar.get_height()/2, f'{val:.1f}%',
|
||
va='center', fontsize=9, fontweight='bold', color=C_DARK)
|
||
|
||
ax5.axvline(x=40, color=C_DANGER, linestyle='--', alpha=0.4, linewidth=1.5)
|
||
ax5.text(41, len(top_names)-0.5, '整体均值 40%', fontsize=8, color=C_DANGER, alpha=0.7)
|
||
ax5.set_yticks(y_pos)
|
||
ax5.set_yticklabels(top_names, fontsize=10)
|
||
ax5.set_title('达人退款率对比', fontsize=14, fontweight='bold', pad=12)
|
||
ax5.set_xlabel('退款率 (%)', fontsize=10)
|
||
ax5.invert_yaxis()
|
||
ax5.grid(axis='x', alpha=0.3, zorder=0)
|
||
ax5.set_xlim(0, max(top_ref_rate)*1.3)
|
||
|
||
# Chart 6: Platform GMV + refund rate
|
||
ax6 = axes[1, 2]
|
||
y_pos = np.arange(len(plat_names))
|
||
plat_rate_colors = [C_SUCCESS if r < 30 else C_DANGER if r > 45 else C_ACCENT for r in plat_ref_rate]
|
||
bars6 = ax6.barh(y_pos, plat_gmv, color=plat_rate_colors, height=0.65, alpha=0.85, zorder=3)
|
||
|
||
for bar, val, rate in zip(bars6, plat_gmv, plat_ref_rate):
|
||
ax6.text(bar.get_width()+1, bar.get_y()+bar.get_height()/2,
|
||
f'¥{val:.0f}万 (退率{rate:.0f}%)',
|
||
va='center', fontsize=8, color=C_DARK)
|
||
|
||
ax6.set_yticks(y_pos)
|
||
ax6.set_yticklabels(plat_names, fontsize=10)
|
||
ax6.set_title('平台/渠道 GMV 对比', fontsize=14, fontweight='bold', pad=12)
|
||
ax6.set_xlabel('GMV (万元)', fontsize=10)
|
||
ax6.invert_yaxis()
|
||
ax6.grid(axis='x', alpha=0.3, zorder=0)
|
||
ax6.set_xlim(0, max(plat_gmv)*1.3)
|
||
|
||
plt.suptitle('瓦拉英语 · 达人直播业务全景分析 (2025.09 - 2026.05)', fontsize=18, fontweight='bold', y=1.01)
|
||
plt.tight_layout(pad=3)
|
||
|
||
out_path = '/root/.openclaw/workspace/output/daren_biz_charts.png'
|
||
plt.savefig(out_path, dpi=180, bbox_inches='tight', facecolor=fig.get_facecolor(), edgecolor='none')
|
||
plt.close()
|
||
print(f"✅ Chart 1 saved: {out_path}")
|
||
print(f" File size: {os.path.getsize(out_path)/1024:.0f} KB")
|
||
|
||
# ============================================
|
||
# FIGURE 2: 月度GMV瀑布图 & 达人月度贡献矩阵
|
||
# ============================================
|
||
fig2, axes2 = plt.subplots(1, 2, figsize=(20, 8))
|
||
fig2.patch.set_facecolor('#FAFBFC')
|
||
|
||
# Chart 7: Monthly waterfall by influencer
|
||
ax7 = axes2[0]
|
||
# Get top5 influencers monthly data
|
||
top5_names = [x[0] for x in inf_sorted[:5]]
|
||
inf_monthly = defaultdict(lambda: defaultdict(float))
|
||
for r in all_data:
|
||
n = r['norm_name']
|
||
if n not in top5_names:
|
||
n = '其他达人'
|
||
d = r['date']
|
||
if d and len(d) >= 7:
|
||
m = d[:7]
|
||
# Map to correct month labels
|
||
month_keys = {
|
||
'2026-09':'2025-09','2026-10':'2025-10','2026-11':'2025-11','2026-12':'2025-12',
|
||
'2026-01':'2026-01','2026-02':'2026-02','2026-03':'2026-03',
|
||
'2026-04':'2026-04','2026-05':'2026-05',
|
||
'2025-12':'2025-12'
|
||
}
|
||
m = month_keys.get(m, m)
|
||
inf_monthly[m][n] += r['gmv']/10000
|
||
|
||
all_months_sorted = ['2025-09','2025-10','2025-11','2025-12','2026-01','2026-02','2026-03','2026-04','2026-05']
|
||
x = np.arange(len(all_months_sorted))
|
||
bottom = np.zeros(len(all_months_sorted))
|
||
colors_stack = [C_PRIME, C_ACCENT, C_PURPLE, C_DANGER, C_TEAL, '#CBD5E1']
|
||
|
||
for idx, name in enumerate(top5_names + ['其他达人']):
|
||
vals = [inf_monthly[m].get(name, 0) for m in all_months_sorted]
|
||
ax7.bar(x, vals, bottom=bottom, color=colors_stack[idx], alpha=0.88, label=name, zorder=3)
|
||
bottom += np.array(vals)
|
||
|
||
ax7.set_xticks(x)
|
||
ax7.set_xticklabels(month_labels, fontsize=10)
|
||
ax7.set_title('月度 GMV 达人贡献拆解 (万元)', fontsize=14, fontweight='bold', pad=12)
|
||
ax7.set_ylabel('GMV (万元)', fontsize=10)
|
||
ax7.legend(loc='upper left', fontsize=9, framealpha=0.95, ncol=2)
|
||
ax7.grid(axis='y', alpha=0.3, zorder=0)
|
||
|
||
# Chart 8: Efficiency Matrix (bubble chart) - GMV vs Refund Rate, bubble size = orders
|
||
ax8 = axes2[1]
|
||
# Filter top 15 influencers for clarity
|
||
top15 = inf_sorted[:15]
|
||
for name, s in top15:
|
||
rr = (s['ref_ords']/s['orders']*100) if s['orders']>0 else 0
|
||
gmv_w = s['gmv']/10000
|
||
order_sz = s['orders']
|
||
color = C_SUCCESS if rr < 30 else C_DANGER if rr > 45 else C_ACCENT
|
||
ax8.scatter(gmv_w, rr, s=order_sz*8, color=color, alpha=0.7, edgecolors='white', linewidth=1.5, zorder=4)
|
||
ax8.annotate(name, (gmv_w, rr), textcoords="offset points", xytext=(8,5),
|
||
fontsize=8.5, fontweight='bold', color=C_DARK)
|
||
|
||
ax8.axhline(y=40, color=C_DARK, linestyle='--', alpha=0.3, linewidth=1)
|
||
ax8.set_xlabel('GMV (万元)', fontsize=11)
|
||
ax8.set_ylabel('退款率 (%)', fontsize=11)
|
||
ax8.set_title('达人效率矩阵 (气泡大小=订单量)', fontsize=14, fontweight='bold', pad=12)
|
||
|
||
# Add quadrant labels
|
||
x_max = ax8.get_xlim()[1]
|
||
y_max = ax8.get_ylim()[1]
|
||
ax8.text(x_max*0.75, 15, '★ 优质区\n高GMV 低退率', fontsize=9, color=C_SUCCESS, fontweight='bold', alpha=0.8)
|
||
ax8.text(x_max*0.75, y_max*0.75, '风险区\n高GMV 高退率', fontsize=9, color=C_DANGER, fontweight='bold', alpha=0.8)
|
||
ax8.grid(alpha=0.3, zorder=0)
|
||
|
||
plt.suptitle('瓦拉英语 · 达人结构分析 & 效率评估', fontsize=16, fontweight='bold', y=1.02)
|
||
plt.tight_layout(pad=3)
|
||
|
||
out_path2 = '/root/.openclaw/workspace/output/daren_biz_charts2.png'
|
||
plt.savefig(out_path2, dpi=180, bbox_inches='tight', facecolor=fig2.get_facecolor(), edgecolor='none')
|
||
plt.close()
|
||
print(f"✅ Chart 2 saved: {out_path2}")
|
||
print(f" File size: {os.path.getsize(out_path2)/1024:.0f} KB")
|
||
|
||
# ============================================
|
||
# FIGURE 3: 退款率热力图 & 月度场次+单均GMV
|
||
# ============================================
|
||
fig3, axes3 = plt.subplots(2, 1, figsize=(20, 10))
|
||
fig3.patch.set_facecolor('#FAFBFC')
|
||
|
||
# Chart 9: Refund rate heatmap by month × top influencer
|
||
ax9 = axes3[0]
|
||
top_heat = top_names[:8] # Top 8 influencers
|
||
months_heat = ['2025-09','2025-10','2025-11','2025-12','2026-01','2026-02','2026-03','2026-04','2026-05']
|
||
month_labels_short = ['9月','10月','11月','12月','1月','2月','3月','4月','5月']
|
||
|
||
heatmap_data = np.zeros((len(top_heat), len(months_heat)))
|
||
|
||
inf_m_ref = defaultdict(lambda: defaultdict(lambda: {'orders':0,'ref_ords':0}))
|
||
for r in all_data:
|
||
n = r['norm_name']
|
||
d = r['date']
|
||
if d and len(d) >= 7:
|
||
m = d[:7]
|
||
month_keys2 = {'2026-09':'2025-09','2026-10':'2025-10','2026-11':'2025-11','2026-12':'2025-12'}
|
||
m = month_keys2.get(m, m)
|
||
inf_m_ref[n][m]['orders'] += r['orders']
|
||
inf_m_ref[n][m]['ref_ords'] += r['refund_orders']
|
||
|
||
for i, name in enumerate(top_heat):
|
||
for j, m in enumerate(months_heat):
|
||
d = inf_m_ref[name][m]
|
||
if d['orders'] > 0:
|
||
heatmap_data[i, j] = d['ref_ords']/d['orders']*100
|
||
else:
|
||
heatmap_data[i, j] = np.nan
|
||
|
||
masked = np.ma.masked_invalid(heatmap_data)
|
||
im = ax9.imshow(masked, cmap='RdYlGn_r', aspect='auto', vmin=10, vmax=75)
|
||
|
||
ax9.set_xticks(np.arange(len(months_heat)))
|
||
ax9.set_xticklabels(month_labels_short, fontsize=10)
|
||
ax9.set_yticks(np.arange(len(top_heat)))
|
||
ax9.set_yticklabels(top_heat, fontsize=10)
|
||
|
||
for i in range(len(top_heat)):
|
||
for j in range(len(months_heat)):
|
||
val = heatmap_data[i, j]
|
||
if not np.isnan(val):
|
||
color = 'white' if val > 45 else C_DARK
|
||
ax9.text(j, i, f'{val:.0f}%', ha='center', va='center', fontsize=9, fontweight='bold', color=color)
|
||
|
||
cbar = plt.colorbar(im, ax=ax9, shrink=0.85, pad=0.02)
|
||
cbar.set_label('退款率 (%)', fontsize=10)
|
||
ax9.set_title('退款率月度热力图 (达人×月份)', fontsize=14, fontweight='bold', pad=12)
|
||
|
||
# Chart 10: Sessions & avg GMV per order
|
||
ax10 = axes3[1]
|
||
ax10_twin = ax10.twinx()
|
||
x = np.arange(len(months))
|
||
|
||
# avg GMV per order
|
||
avg_gmv = [m_gmv[i]/m_orders[i] if m_orders[i]>0 else 0 for i in range(len(months))]
|
||
|
||
bars10 = ax10.bar(x - 0.18, m_sessions, 0.35, color=C_PURPLE, alpha=0.3, label='直播场次', zorder=3)
|
||
line10 = ax10.plot(x, avg_gmv, 'D-', color=C_PRIME, linewidth=2.5, markersize=10, zorder=4, label='单均GMV(元)')
|
||
|
||
for i, (s, a) in enumerate(zip(m_sessions, avg_gmv)):
|
||
ax10.text(i-0.18, s+0.5, str(s), ha='center', fontsize=9, color=C_PURPLE, fontweight='bold')
|
||
|
||
for i, a in enumerate(avg_gmv):
|
||
ax10.annotate(f'¥{a:,.0f}', (i, a), textcoords="offset points", xytext=(0,12),
|
||
ha='center', fontsize=9, fontweight='bold', color=C_PRIME)
|
||
|
||
# GSV rate
|
||
gsv_rate = [m_gsv[i]/m_gmv[i]*100 if m_gmv[i]>0 else 0 for i in range(len(months))]
|
||
ax10_twin.plot(x, gsv_rate, 's--', color=C_SUCCESS, linewidth=2, markersize=9, alpha=0.8, label='GSV率(%)')
|
||
for i, r in enumerate(gsv_rate):
|
||
ax10_twin.annotate(f'{r:.0f}%', (i, r), textcoords="offset points", xytext=(0,-16),
|
||
ha='center', fontsize=8.5, color=C_SUCCESS, fontweight='bold')
|
||
|
||
ax10.set_xticks(x)
|
||
ax10.set_xticklabels(month_labels, fontsize=10)
|
||
ax10.set_title('月度运营效率:场次 & 单均GMV & 净收入率', fontsize=14, fontweight='bold', pad=12)
|
||
ax10.set_ylabel('场次 / 单均GMV(元)', fontsize=10)
|
||
ax10_twin.set_ylabel('GSV率 (%)', fontsize=10, color=C_SUCCESS)
|
||
ax10_twin.tick_params(axis='y', colors=C_SUCCESS)
|
||
ax10.grid(axis='y', alpha=0.3, zorder=0)
|
||
|
||
# Combined legend
|
||
lines1, labels1 = ax10.get_legend_handles_labels()
|
||
lines2, labels2 = ax10_twin.get_legend_handles_labels()
|
||
ax10.legend(lines1+lines2, labels1+labels2, loc='upper left', fontsize=9, framealpha=0.9)
|
||
|
||
plt.suptitle('瓦拉英语 · 退款率分布 & 运营效率趋势', fontsize=16, fontweight='bold', y=1.02)
|
||
plt.tight_layout(pad=3)
|
||
|
||
out_path3 = '/root/.openclaw/workspace/output/daren_biz_charts3.png'
|
||
plt.savefig(out_path3, dpi=180, bbox_inches='tight', facecolor=fig3.get_facecolor(), edgecolor='none')
|
||
plt.close()
|
||
print(f"✅ Chart 3 saved: {out_path3}")
|
||
print(f" File size: {os.path.getsize(out_path3)/1024:.0f} KB")
|
||
|
||
print("\n🎉 All charts generated successfully!")
|
||
print(f"\nOutput files:")
|
||
print(f" 1. {out_path}")
|
||
print(f" 2. {out_path2}")
|
||
print(f" 3. {out_path3}")
|