ai_member_xiaoxi/scripts/sales_conversion_stats.py
2026-06-02 08:00:01 +08:00

497 lines
23 KiB
Python
Raw 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
"""
销售线索转化统计 v11 — 公式版
设计原则:
- 过程数据:写入原始数字(人数/金额),比率/GSV 全部用公式
- 销转总览SUMIFS 从过程数据引用,转化率/ROI 用公式
- 落单渠道分布:渠道数字写入,合计列用 SUM 公式
- 参数 sheet线索成本供 VLOOKUP 引用
"""
import json, requests, os, re, time, psycopg2
from collections import defaultdict
CRED_DIR = "/root/.openclaw/credentials/xiaoxi"
SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug"
SHEET_MAP = {"吴迪": "f975f0", "小龙": "qJF4I", "成都": "qJF4J"}
TARGET_MONTHS = [3, 4, 5, 6]
COSTS = {3: 243, 4: 246, 5: 241}
SALES_ORDER = ["小龙", "吴迪", "Bob", "Tom"]
PG_HOST = "bj-postgres-16pob4sg.sql.tencentcdb.com"
PG_PORT = 28591
PG_USER = "ai_member"
PG_PASSWORD = "LdfjdjL83h3h3^$&**YGG*"
PG_DB = "vala_bi"
# Sheet 名称(公式跨表引用用)
SN_FUNNEL = "📊 过程数据"
SN_OVERVIEW = "📊 销转总览"
SN_CHANNEL = "📊 落单渠道分布"
SN_PARAMS = "📊 参数"
# ── 数据库查询 ──────────────────────────────────────────
def get_orders_for_accounts(account_ids):
if not account_ids:
return {}, []
conn = psycopg2.connect(host=PG_HOST, port=PG_PORT, user=PG_USER, password=PG_PASSWORD, dbname=PG_DB)
cur = conn.cursor()
placeholders = ','.join(['%s'] * len(account_ids))
cur.execute(f"""
SELECT o.account_id, o.trade_no, o.pay_success_date, o.key_from,
o.pay_amount_int, o.order_status
FROM bi_vala_order o
JOIN bi_vala_app_account a ON o.account_id = a.id AND a.status = 1
WHERE o.account_id IN ({placeholders})
AND o.pay_success_date IS NOT NULL
AND o.order_status IN (3, 4)
ORDER BY o.account_id, o.pay_success_date
""", list(account_ids))
orders_by_account = defaultdict(list)
all_trade_nos = []
for row in cur.fetchall():
aid, trade_no, pay_date, key_from, amount, status = row
orders_by_account[aid].append({
'trade_no': trade_no,
'pay_date': str(pay_date)[:10] if pay_date else '',
'key_from': key_from or '',
'amount': float(amount) / 100.0 if amount else 0,
'status': status,
})
all_trade_nos.append(trade_no)
cur.close()
conn.close()
return orders_by_account, all_trade_nos
def get_refund_for_trade_nos(trade_nos):
if not trade_nos:
return {}
conn = psycopg2.connect(host=PG_HOST, port=PG_PORT, user=PG_USER, password=PG_PASSWORD, dbname=PG_DB)
cur = conn.cursor()
refunds = {}
batch_size = 500
for i in range(0, len(trade_nos), batch_size):
batch = trade_nos[i:i+batch_size]
placeholders = ','.join(['%s'] * len(batch))
cur.execute(f"""
SELECT o.trade_no, COALESCE(SUM(r.refund_amount::numeric), 0)/100.0
FROM bi_vala_order o
JOIN bi_refund_order r ON o.trade_no = r.trade_no
WHERE r.status = 3 AND o.order_status = 4
AND o.trade_no IN ({placeholders})
GROUP BY o.trade_no
""", batch)
for row in cur.fetchall():
refunds[row[0]] = float(row[1])
cur.close()
conn.close()
return refunds
def classify_channel(key_from):
kf = str(key_from).strip()
if not kf: return "其他"
if kf.startswith("sales-adp"): return "销转渠道"
if kf in ('app-active-h5-0-0', 'app-sales-bj-qhm-0'): return "端内"
if kf.startswith("miniprogram"): return "端内"
if kf.startswith("newmedia-daren") or "daren" in kf.lower(): return "达人渠道"
if kf.startswith("newmedia-dianpu-xhs"): return "直购渠道"
if kf.startswith("newmedia-dianpu-douyin"): return "直购渠道"
if "jingxuan" in kf and "douyin" in kf.lower(): return "直购渠道"
if kf.startswith("stream-xhs"): return "直购渠道"
if "wxxd" in kf: return "直购渠道"
if kf.startswith("partner"): return "直购渠道"
if kf.startswith("newmedia-dianpu-wwxx"): return "达人渠道"
if kf.startswith("newmedia-"): return "直购渠道"
return "其他"
CHANNEL_ORDER = ["销转渠道", "端内", "直购渠道", "达人渠道", "其他"]
# ── 飞书 API ──────────────────────────────────────────
BLUE = "#4472C4"; WHITE = "#FFFFFF"
def get_token():
with open(os.path.join(CRED_DIR, "config.json")) as f:
cfg = json.load(f)
resp = requests.post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": cfg["apps"][0]["appId"], "app_secret": cfg["apps"][0]["appSecret"]}, timeout=15)
return resp.json()["tenant_access_token"]
def read_sheet(token, sheet_id):
resp = requests.get(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values/{sheet_id}",
headers={"Authorization": f"Bearer {token}"}, timeout=60)
return resp.json()["data"]["valueRange"]["values"][2:]
def parse_month(date_str):
m = re.match(r'(\d+)月', str(date_str).strip())
return int(m.group(1)) if m else None
def parse_row(row):
def get(idx, default=""):
return str(row[idx]).strip() if idx < len(row) and row[idx] else default
return {
"sales": get(0), "nickname": get(1), "lead_date": get(2),
"trial_lessons": get(3), "phone": get(4), "grade": get(5),
"history": get(6), "user_id": get(7), "reg_date": get(8),
"download_channel": get(9), "is_order": get(10), "order_date": get(11),
"order_channel": get(12), "product": get(13), "gmv": get(14),
"refund": get(15), "gsv": get(16), "activated": get(17),
"progress": get(18), "last_study": get(19), "study_min": get(20),
"update_time": get(21),
}
def safe_int(val):
try: return int(float(val))
except: return 0
def get_or_create_sheet(token, title):
resp = requests.get(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/metainfo",
headers={"Authorization": f"Bearer {token}"}, timeout=15)
sheets = resp.json().get("data", {}).get("sheets", [])
for s in sheets:
if s.get("title") == title:
sid = s["sheetId"]
print(f" 复用已有 sheet: {title} ({sid}),清空旧数据...")
requests.put(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"valueRange": {"range": f"{sid}!A1:ZZ200", "values": [['']]}}, timeout=30)
return sid
resp = requests.post(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/sheets_batch_update",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"requests": [{"addSheet": {"properties": {"title": title, "index": 10}}}]}, timeout=30)
result = resp.json()
if result.get("code") == 0:
replies = result["data"]["replies"]
if replies and "addSheet" in replies[0]:
return replies[0]["addSheet"]["properties"]["sheetId"]
print(f" 创建sheet失败: {result}")
return None
def write_values(token, sheet_id, range_str, values):
resp = requests.put(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"valueRange": {"range": f"{sheet_id}!{range_str}", "values": values}}, timeout=30)
code = resp.json().get("code")
if code != 0:
print(f"{range_str}: {resp.json()}")
return code == 0
def apply_style(token, sheet_id, col_start, row_start, col_end, row_end, style):
rng = f"{sheet_id}!{col_start}{row_start}:{col_end}{row_end}"
resp = requests.put(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/style",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"appendStyle": {"range": rng, "style": style}}, timeout=30)
code = resp.json().get("code")
if code != 0:
print(f"{rng}: {resp.json()}")
return code == 0
def header_style():
return {"backcolor": BLUE, "fontColor": WHITE, "bold": True, "fontSize": 10}
# ── 主流程 ──────────────────────────────────────────
def main():
token = get_token()
# 1. 读取销售明细
print("读取销售明细...")
all_leads = []
for sheet_label, sheet_id in SHEET_MAP.items():
rows = read_sheet(token, sheet_id)
for row in rows:
d = parse_row(row)
all_leads.append(d)
print(f" 总计: {len(all_leads)} 条线索")
by_month = defaultdict(list)
for r in all_leads:
m = parse_month(r["lead_date"])
if m in TARGET_MONTHS:
by_month[m].append(r)
for m in TARGET_MONTHS:
print(f" {m}月: {len(by_month[m])} 条线索")
# 2. 查数据库订单
all_user_ids = set()
for r in all_leads:
uid = safe_int(r["user_id"])
if uid > 0:
all_user_ids.add(uid)
print(f"\n查询数据库订单({len(all_user_ids)} 个用户)...")
db_orders, all_trade_nos = get_orders_for_accounts(all_user_ids)
print(f" 有订单的用户: {len(db_orders)} 人, 订单: {sum(len(v) for v in db_orders.values())}")
# 3. 查退款
print(f"查询退款({len(all_trade_nos)} 笔订单)...")
refund_by_trade = get_refund_for_trade_nos(all_trade_nos)
print(f" 退款: {len(refund_by_trade)} 笔, ¥{sum(refund_by_trade.values()):,.0f}")
# 4-5. 匹配订单 + 计算 GMV/退款/GSV
for r in all_leads:
uid = safe_int(r["user_id"])
r["_db_orders"] = db_orders.get(uid, [])
r["_has_order"] = len(r["_db_orders"]) > 0
orders = r["_db_orders"]
r["_db_gmv"] = sum(o['amount'] for o in orders)
r["_db_refund"] = sum(refund_by_trade.get(o['trade_no'], 0) for o in orders)
r["_db_gsv"] = r["_db_gmv"] - r["_db_refund"]
# ═══════════════════════════════════════════════════
# 6. 过程数据(只写原始数字,比率/GSV 用公式)
# ═══════════════════════════════════════════════════
# 列布局 (A-AF, 32列):
# A=月份 B=销售 C=线索总数
# D=拿手机号数 E=拿手机号率(=D/C)
# F=注册数 G=注册率(=F/C)
# H=首课人数(>=1) I=首课率(=H/C) J=一节课转化人数(<=1&ordered) K=一节课转化率(=J/C)
# L=二次课人数(>=2) M=二次课率(=L/C) N=二节课转化人数(<=2&ordered) O=二节课转化率(=N/C)
# P=三次课人数(>=3) Q=三次课率(=P/C) R=三节课转化人数(<=3&ordered) S=三节课转化率(=R/C)
# T=四次课人数(>=4) U=四次课率(=T/C) V=四节课转化人数(<=4&ordered) W=四节课转化率(=V/C)
# X=五次课人数(>=5) Y=五次课率(=X/C) Z=五节课转化人数(<=5&ordered) AA=五节课转化率(=Z/C)
# AB=订单数 AC=转化率(=AB/C) AD=GMV AE=退款金额 AF=GSV(=AD-AE)
print("\n=== 1. 过程数据(底表) ===")
sid_funnel = get_or_create_sheet(token, SN_FUNNEL)
meta = requests.get(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/metainfo",
headers={"Authorization": f"Bearer {token}"}, timeout=10).json()
for s in meta.get('data', {}).get('sheets', []):
if s.get('sheetId') == sid_funnel:
mr = s.get('row_count', 0)
if mr > 0:
requests.delete(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/sheets/{sid_funnel}/range?dimension=ROWS&start_index=1&end_index={mr}",
headers={"Authorization": f"Bearer {token}"}, timeout=15)
time.sleep(0.5)
break
hdr = [["月份", "销售", "线索总数",
"拿手机号数", "拿手机号率",
"注册数", "注册率",
"首课人数", "首课率", "一节课转化人数", "一节课转化率",
"二次课人数", "二次课率", "二节课转化人数", "二节课转化率",
"三次课人数", "三次课率", "三节课转化人数", "三节课转化率",
"四次课人数", "四次课率", "四节课转化人数", "四节课转化率",
"五次课人数", "五次课率", "五节课转化人数", "五节课转化率",
"订单数", "转化率", "GMV", "退款金额", "GSV"]]
write_values(token, sid_funnel, "A1:AF1", hdr)
apply_style(token, sid_funnel, "A", 1, "AF", 1, header_style())
# 数据行:原始数字 + 公式
row_idx = 2
for m in TARGET_MONTHS:
for sales in SALES_ORDER:
leads = [r for r in by_month[m] if r["sales"] == sales]
if not leads:
continue
lead_count = len(leads)
has_phone = len([r for r in leads if r["phone"] and r["phone"] != "未注册"])
registered = len([r for r in leads if r["user_id"] and r["user_id"] != "未注册"])
trial = [safe_int(r["trial_lessons"]) for r in leads]
ordered = [r for r in leads if r["_has_order"]]
order_count = len(ordered)
gmv = sum(r["_db_gmv"] for r in ordered)
refund = sum(r["_db_refund"] for r in ordered)
ge = {n: len([t for t in trial if t >= n]) for n in range(1, 6)}
le_conv = {n: len([r for r in leads if safe_int(r["trial_lessons"]) <= n and r["_has_order"]]) for n in range(1, 6)}
r = row_idx
row_data = [[
f"{m}", sales, lead_count,
has_phone, f"=D{r}/C{r}",
registered, f"=F{r}/C{r}",
ge[1], f"=H{r}/C{r}", le_conv[1], f"=J{r}/C{r}",
ge[2], f"=L{r}/C{r}", le_conv[2], f"=N{r}/C{r}",
ge[3], f"=P{r}/C{r}", le_conv[3], f"=R{r}/C{r}",
ge[4], f"=T{r}/C{r}", le_conv[4], f"=V{r}/C{r}",
ge[5], f"=X{r}/C{r}", le_conv[5], f"=Z{r}/C{r}",
order_count, f"=AB{r}/C{r}",
gmv, refund, f"=AD{r}-AE{r}",
]]
write_values(token, sid_funnel, f"A{r}:AF{r}", row_data)
row_idx += 1
if (row_idx - 2) % 5 == 0:
time.sleep(0.5)
last_funnel_row = row_idx - 1
print(f" ✅ 过程数据 {last_funnel_row - 1}")
# ═══════════════════════════════════════════════════
# 7. 参数 sheet线索成本供 VLOOKUP 引用)
# ═══════════════════════════════════════════════════
print("\n=== 2. 参数 ===")
sid_params = get_or_create_sheet(token, SN_PARAMS)
params_data = [["月份", "线索成本(元)"]]
for m in TARGET_MONTHS:
c = COSTS.get(m, 0)
params_data.append([f"{m}", c if c > 0 else 0])
write_values(token, sid_params, f"A1:B{len(params_data)}", params_data)
apply_style(token, sid_params, "A", 1, "B", 1, header_style())
print("")
# ═══════════════════════════════════════════════════
# 8. 落单渠道分布(渠道数字 + SUM 公式)
# ═══════════════════════════════════════════════════
# 列: A=月份 B=销售 C=销转-订单 D=销转-GMV E=端内-订单 F=端内-GMV
# G=直购-订单 H=直购-GMV I=达人-订单 J=达人-GMV K=其他-订单 L=其他-GMV
# M=合计-订单(=SUM(C,E,G,I,K)) N=合计-GMV(=SUM(D,F,H,J,L))
print("\n=== 3. 落单渠道分布 ===")
sid_ch = get_or_create_sheet(token, SN_CHANNEL)
meta = requests.get(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/metainfo",
headers={"Authorization": f"Bearer {token}"}, timeout=10).json()
for s in meta.get('data', {}).get('sheets', []):
if s.get('sheetId') == sid_ch:
mr = s.get('row_count', 0)
if mr > 0:
requests.delete(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/sheets/{sid_ch}/range?dimension=ROWS&start_index=1&end_index={mr}",
headers={"Authorization": f"Bearer {token}"}, timeout=15)
time.sleep(0.5)
break
ch_hdr = [["月份", "销售"] +
[f"{c}-{m}" for c in CHANNEL_ORDER for m in ["订单", "GMV"]] +
["合计-订单", "合计-GMV"]]
write_values(token, sid_ch, "A1:N1", ch_hdr)
apply_style(token, sid_ch, "A", 1, "N", 1, header_style())
# 构建渠道数据
ms_channel = defaultdict(lambda: defaultdict(lambda: {"orders": 0, "gmv": 0.0}))
for m in TARGET_MONTHS:
for r in by_month[m]:
sales = r["sales"]
for o in r["_db_orders"]:
cat = classify_channel(o['key_from'])
ms_channel[(m, sales)][cat]["orders"] += 1
ms_channel[(m, sales)][cat]["gmv"] += o['amount']
ch_row = 2
for m in TARGET_MONTHS:
# 合计行
row_data = [f"{m}", "合计"]
for cat in CHANNEL_ORDER:
o = sum(ms_channel[(m, s)][cat]["orders"] for s in SALES_ORDER)
g = sum(ms_channel[(m, s)][cat]["gmv"] for s in SALES_ORDER)
row_data.append(o)
row_data.append(g)
# 合计列用 SUM 公式
row_data.append(f"=SUM(C{ch_row},E{ch_row},G{ch_row},I{ch_row},K{ch_row})")
row_data.append(f"=SUM(D{ch_row},F{ch_row},H{ch_row},J{ch_row},L{ch_row})")
write_values(token, sid_ch, f"A{ch_row}:N{ch_row}", [row_data])
ch_row += 1
# 个人行
for sales in SALES_ORDER:
if not any(r["sales"] == sales for r in by_month[m]):
continue
row_data = [f"{m}", sales]
for cat in CHANNEL_ORDER:
o = ms_channel[(m, sales)][cat]["orders"]
g = ms_channel[(m, sales)][cat]["gmv"]
row_data.append(o)
row_data.append(g)
row_data.append(f"=SUM(C{ch_row},E{ch_row},G{ch_row},I{ch_row},K{ch_row})")
row_data.append(f"=SUM(D{ch_row},F{ch_row},H{ch_row},J{ch_row},L{ch_row})")
write_values(token, sid_ch, f"A{ch_row}:N{ch_row}", [row_data])
ch_row += 1
if (ch_row - 2) % 5 == 0:
time.sleep(0.5)
print(f" ✅ 落单渠道 {ch_row - 2}")
# ═══════════════════════════════════════════════════
# 9. 销转总览SUMIFS 从过程数据引用 + 公式)
# ═══════════════════════════════════════════════════
# 列: A=月份 B=销售 C=线索数 D=订单数 E=转化率(=D/C)
# F=GMV G=退款金额 H=GSV(=F-G)
# I=投放消耗(=C*VLOOKUP(A,参数!A:B,2,0))
# J=达人GMV K=达人佣金(=J*0.4) L=总成本(=I+K) M=退后ROI(=H/L)
print("\n=== 4. 销转总览 ===")
sid_ov = get_or_create_sheet(token, SN_OVERVIEW)
meta = requests.get(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/metainfo",
headers={"Authorization": f"Bearer {token}"}, timeout=10).json()
for s in meta.get('data', {}).get('sheets', []):
if s.get('sheetId') == sid_ov:
mr = s.get('row_count', 0)
if mr > 0:
requests.delete(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/sheets/{sid_ov}/range?dimension=ROWS&start_index=1&end_index={mr}",
headers={"Authorization": f"Bearer {token}"}, timeout=15)
time.sleep(0.5)
break
ov_hdr = [["月份", "销售", "线索数", "订单数", "转化率",
"GMV", "退款金额", "GSV", "投放消耗", "达人GMV",
"达人佣金(40%)", "总成本", "退后ROI"]]
write_values(token, sid_ov, "A1:M1", ov_hdr)
apply_style(token, sid_ov, "A", 1, "M", 1, header_style())
# 过程数据 sheet 列映射(用于 SUMIFS
# A=月份 B=销售 C=线索总数 AB=订单数 AD=GMV AE=退款金额
F = SN_FUNNEL # sheet 名简写
ov_row = 2
for m in TARGET_MONTHS:
month_str = f"{m}"
# 合计行
row_data = [
month_str, "合计",
f"=SUMIFS('{F}'!C:C,'{F}'!A:A,A{ov_row},'{F}'!B:B,\"<>\")",
f"=SUMIFS('{F}'!AB:AB,'{F}'!A:A,A{ov_row},'{F}'!B:B,\"<>\")",
f"=D{ov_row}/C{ov_row}",
f"=SUMIFS('{F}'!AD:AD,'{F}'!A:A,A{ov_row},'{F}'!B:B,\"<>\")",
f"=SUMIFS('{F}'!AE:AE,'{F}'!A:A,A{ov_row},'{F}'!B:B,\"<>\")",
f"=F{ov_row}-G{ov_row}",
f"=C{ov_row}*VLOOKUP(A{ov_row},'{SN_PARAMS}'!A:B,2,0)",
f"=SUMIFS('{SN_CHANNEL}'!J:J,'{SN_CHANNEL}'!A:A,A{ov_row},'{SN_CHANNEL}'!B:B,\"合计\")",
f"=J{ov_row}*0.4",
f"=I{ov_row}+K{ov_row}",
f"=IF(L{ov_row}>0,H{ov_row}/L{ov_row},\"-\")",
]
write_values(token, sid_ov, f"A{ov_row}:M{ov_row}", [row_data])
ov_row += 1
# 个人行
for sales in SALES_ORDER:
if not any(r["sales"] == sales for r in by_month[m]):
continue
row_data = [
month_str, sales,
f"=SUMIFS('{F}'!C:C,'{F}'!A:A,A{ov_row},'{F}'!B:B,B{ov_row})",
f"=SUMIFS('{F}'!AB:AB,'{F}'!A:A,A{ov_row},'{F}'!B:B,B{ov_row})",
f"=D{ov_row}/C{ov_row}",
f"=SUMIFS('{F}'!AD:AD,'{F}'!A:A,A{ov_row},'{F}'!B:B,B{ov_row})",
f"=SUMIFS('{F}'!AE:AE,'{F}'!A:A,A{ov_row},'{F}'!B:B,B{ov_row})",
f"=F{ov_row}-G{ov_row}",
f"=C{ov_row}*VLOOKUP(A{ov_row},'{SN_PARAMS}'!A:B,2,0)",
f"=SUMIFS('{SN_CHANNEL}'!J:J,'{SN_CHANNEL}'!A:A,A{ov_row},'{SN_CHANNEL}'!B:B,B{ov_row})",
f"=J{ov_row}*0.4",
f"=I{ov_row}+K{ov_row}",
f"=IF(L{ov_row}>0,H{ov_row}/L{ov_row},\"-\")",
]
write_values(token, sid_ov, f"A{ov_row}:M{ov_row}", [row_data])
ov_row += 1
if (ov_row - 2) % 5 == 0:
time.sleep(0.5)
print(f" ✅ 销转总览 {ov_row - 2}")
print("\n✅ 全部完成")
if __name__ == "__main__":
main()