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

443 lines
18 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
"""
销售线索转化统计 v9
- 线索:以销售明细(吴迪/小龙/成都)为准
- 订单+退款:以数据库为准,通过线索 user_id 匹配
- 汇总:按线索进线月归因
- 销转总览 + 落单渠道:均拆分到个人维度
"""
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"
# ── 数据库查询 ──────────────────────────────────────────
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"
LIGHT_BLUE = "#D6E4F0"
PURE_WHITE = "#FFFFFF"
YELLOW = "#FFF2CC"
YELLOW2 = "#FFF9E6"
GREEN = "#C6EFCE"
ORANGE = "#FCE4EC"
ORANGE2 = "#FFF0F3"
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_float(val):
try: return float(val)
except: return 0.0
def safe_int(val):
try: return int(float(val))
except: return 0
def fmt_pct(val):
if val == 0: return "0.0%"
return f"{val*100:.1f}%"
def fmt_rmb(val):
return f"¥{int(val):,}"
def fmt_roi(val):
return f"{val:.2f}"
def get_or_create_sheet(token, title):
"""获取已有 sheet 或创建新 sheet并清空旧数据"""
# 先查已有
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}),清空旧数据...")
# 清空 A1:ZZ200
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]:
sid = replies[0]["addSheet"]["properties"]["sheetId"]
print(f" 创建 sheet: {title} ({sid})")
return sid
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 total_style():
return {"backcolor": GREEN, "fontSize": 10, "bold": True}
# ── 主流程 ──────────────────────────────────────────
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. 收集所有线索的 user_id去数据库查订单
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)
db_order_users = set(db_orders.keys())
print(f" 有订单的用户: {len(db_order_users)} 人, 订单: {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)
refund_total = sum(refund_by_trade.values())
refund_users = set()
for trade_no in refund_by_trade:
for aid, orders in db_orders.items():
for o in orders:
if o['trade_no'] == trade_no:
refund_users.add(aid)
print(f" 退款: {len(refund_by_trade)} 笔, ¥{refund_total:,.0f}, {len(refund_users)}")
# 4. 为每条线索匹配数据库订单
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
# 5. 计算每条线索的 GMV / 退款 / GSV来自数据库
for r in all_leads:
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. 构建数据(供公式和底表使用)
funnel_rows = []
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)
gsv = gmv - refund
phone_rate = has_phone / lead_count if lead_count else 0
reg_rate = registered / lead_count if lead_count else 0
lesson_data = {}
for n in range(1, 6):
cnt = len([t for t in trial if t >= n])
ord_n = len([r for r in leads if safe_int(r["trial_lessons"]) >= n and r["_has_order"]])
lesson_data[n] = (cnt, ord_n)
rates = {}
for n in range(1, 6):
cnt, ord_n = lesson_data[n]
rates[n] = (cnt / lead_count if lead_count else 0, ord_n / cnt if cnt else 0)
order_conv = order_count / lead_count if lead_count else 0
funnel_rows.append([
f"{m}", sales, lead_count,
has_phone, fmt_pct(phone_rate),
registered, fmt_pct(reg_rate),
lesson_data[1][0], fmt_pct(rates[1][0]), fmt_pct(rates[1][1]),
lesson_data[2][0], fmt_pct(rates[2][0]), fmt_pct(rates[2][1]),
lesson_data[3][0], fmt_pct(rates[3][0]), fmt_pct(rates[3][1]),
lesson_data[4][0], fmt_pct(rates[4][0]), fmt_pct(rates[4][1]),
lesson_data[5][0], fmt_pct(rates[5][0]), fmt_pct(rates[5][1]),
order_count, fmt_pct(order_conv),
gmv, refund, gsv,
])
# 渠道底表数据
channel_rows = []
for m in TARGET_MONTHS:
for sales in SALES_ORDER:
if sum(1 for r in by_month[m] if r["sales"] == sales) == 0:
continue
for cat in CHANNEL_ORDER:
o = 0; g = 0.0
for r in by_month[m]:
if r["sales"] != sales: continue
for odb in r["_db_orders"]:
if classify_channel(odb['key_from']) == cat:
o += 1; g += odb['amount']
if o > 0 or g > 0:
channel_rows.append([f"{m}", sales, cat, o, g])
# 月合计渠道行
for cat in CHANNEL_ORDER:
o = 0; g = 0.0
for r in by_month[m]:
for odb in r["_db_orders"]:
if classify_channel(odb['key_from']) == cat:
o += 1; g += odb['amount']
if o > 0 or g > 0:
channel_rows.append([f"{m}", "合计", cat, o, g])
# 渠道统计(月 × 销售 × 渠道)供落单渠道分布使用
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']
# 7. 过程数据(唯一的数据底表)
print("\n=== 1. 过程数据(底表) ===")
sid_funnel = get_or_create_sheet(token, "📊 过程数据")
# 清除旧数据
meta = requests.get(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/metainfo",
headers={"Authorization": f"Bearer {token}"}, timeout=10).json()
sheets_info = meta.get('data', {}).get('sheets', [])
max_rows = 200
for s in sheets_info:
if s.get('sheetId') == sid_funnel:
max_rows = s.get('row_count', 200)
break
if max_rows > 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={max_rows}",
headers={"Authorization": f"Bearer {token}"}, timeout=15)
time.sleep(0.5)
headers3 = [["月份", "销售", "线索总数",
"拿手机号数", "拿手机号率", "注册数", "注册率",
"首课人数", "首课率", "一节课转化率",
"二次课人数", "二次课率", "二节课转化率",
"三次课人数", "三次课率", "三节课转化率",
"四次课人数", "四次课率", "四节课转化率",
"五次课人数", "五次课率", "五节课转化率",
"订单数", "转化率", "GMV", "退款金额", "GSV",
]]
# 批量写入一次性API调用避免限流
channel_start = 16
cost_ref_data = [["月份", "月投放成本"]]
for m in TARGET_MONTHS:
c = COSTS.get(m, 0)
if c > 0:
cost_ref_data.append([f"{m}", c])
batch_data = [
{"range": f"{sid_funnel}!A1:AA1", "values": headers3},
{"range": f"{sid_funnel}!A2:AA{1+len(funnel_rows)}", "values": funnel_rows},
{"range": f"{sid_funnel}!A{channel_start}:E{channel_start}", "values": [["月份", "销售", "渠道", "订单数", "GMV"]]},
{"range": f"{sid_funnel}!A{channel_start+1}:E{channel_start+len(channel_rows)}", "values": channel_rows},
{"range": f"{sid_funnel}!G{channel_start}:H{channel_start+len(cost_ref_data)-1}", "values": cost_ref_data},
]
resp = requests.post(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values_batch_update",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"valueInputOption": "USER", "valueRanges": batch_data},
timeout=60)
code = resp.json().get("code")
if code != 0:
print(f" ❌ 批量写入失败: {resp.json()}")
time.sleep(1)
print(" ✅ 数据写入完成")
if __name__ == "__main__":
main()