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