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