ai_member_xiaoxi/scripts/daren_report_chart.py
2026-05-27 08:00:01 +08:00

484 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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}")