diff --git a/MEMORY.md b/MEMORY.md index 3d71049..e223c91 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -27,11 +27,17 @@ - 「Bot刷新」/「S2 刷新」/「Bot 销转看板」→ 执行 `scripts/bot_sales_step2_refresh.py`,完成后群回「Bot刷新完成」,不动订单汇总 tab [陈逸鸫确认 2026-06-06] - **Bot 销转看板 S1–S3 协作流程 [陈逸鸫确认 2026-06-06]:** - S1 Cursor → 微伴入库/每日线索/聚光 → @小溪 - - S2 小溪 → 销售三表 D/H/I/J + K–V + S/U → 群回「Bot刷新完成」 + - S2 小溪 → 销售三表 D/H/I/J + K–U + X–Z → 群回「Bot刷新完成」 - S3 Cursor → 收到后粘贴订单汇总 + 刷看板公式 - 当日默认只跑一轮 S2;再刷需群里 `【执行更新】` @小溪 - 详细手册:`docs/伪BI-小溪操作手册.md`、`docs/bot-xiaoxi-collaboration-s1-s3.md` - - S2 核心规则:① E→H 必须 phone_encrypt.py XXTEA 精确匹配,禁前三后四 ② H→D/I/J 只补空 ③ L≥C 才 K=是 ④ 全额退清 K/O/P/Q ⑤ O/P/Q 0留空,P整元 ⑥ G列不动 + - S2 核心规则:① E→H 必须 phone_encrypt.py XXTEA 精确匹配,禁前三后四 ② H→D/I/J 只补空 ③ K(下单日)≥C(进线日期) 才 Y(有效订单)=1 ④ 全额退清 N/O/P 清空 ⑤ N/O/P 0留空,O整元 ⑥ G列不动 + - **线索表(销售三表)列结构 [陈逸鸫确认 2026-06-15]:** + A销售归属 B微信昵称 C进线日期 D体验节数 E手机号 F用户年级 G课史/跟进 H用户ID I注册日期 J下载渠道 K下单日期 L成交渠道 M产品 N下单金额(GMV) O退款金额 P实际收入(GSV) Q激活课程 R当前行课进度 S最近行课时间 T累计学习时长(min) U更新时间 V微伴补充 W进线早于下单 X订单号 Y有效订单 Z渠道归属 + - 脚本填写:D/H/I/J/K~U/X/Y/Z + - Y(有效订单)=1 → 进入订单汇总表 + - **订单汇总表列结构 [陈逸鸫确认 2026-06-15]:** + A销售归属 B微信昵称 C进线日期 D体验节数 E手机号 F用户年级 G课史/跟进 H用户ID I注册日期 J下载渠道 K下单日期 L成交渠道 M产品 N下单金额(GMV) O退款金额 P实际收入(GSV) Q激活课程 R当前行课进度 S最近行课时间 T累计学习时长(min) U更新时间 V渠道归属 W有效成单 X订单号 - **飞书表格写入 5000 格上限规则(强制执行,[李承龙确认] 2026-06-13):** - 飞书 Open API 单次写入上限为 5000 格(行×列),超过上限静默失败不报错 - 所有脚本写入飞书表格时必须使用 `scripts/feishu_sheet_utils.py` 共享工具,自动分批确保 ≤ 4400 格/批 @@ -181,6 +187,15 @@ - 小红书店铺:`newmedia-dianpu-xhs-0-0` - 达人直播:`newmedia-daren%`(前缀匹配) - 万物:`newmedia-dianpu-wwxx-0-0` + - **销售转化渠道分类规则(仅用于销售转化场景,[王虹茗确认,李承龙确认] 2026-06-15):** + - 将 key_from 归为四类: + | 类别 | 匹配规则 | + |------|---------| + | 端内 | `app-active-h5-0-0`, `app-sales-bj-qhm-0`, `app-sales-bj-wd-0` | + | 销转 | `sales-adp-*` | + | 达人 | `newmedia-daren-*` + `newmedia-dianpu-wwxx-0-0`(万物算达人) | + | 直购 | `newmedia-dianpu-*`(**不含 wwxx**)+ 其余所有杂项(partner-*, stream-*, miniprogram-*, newmedia-jingxuan-*, 空值, shuadan 等) | + - **注意:** 此分类规则仅在销售转化场景下使用,不替代通用的渠道映射规则 - **sale_channel字段映射规则(仅对`key_from = app-active-h5-0-0`的订单生效):** | sale_channel值 | 对应渠道名称 | |---------------|--------------| diff --git a/USER.md b/USER.md index 3a617e3..23fd2bd 100644 --- a/USER.md +++ b/USER.md @@ -44,6 +44,7 @@ | 胡陈辰 | `gc64176a` | | 刘彦江 | `1da2afbf` | | 姜小龙 | `bc227c85` | +| 赵一凡 | `a3168f9d` | > ⚠️ 以上用户拥有全部数据查询权限,但其个人信息、查询内容、对话记录**禁止写入 MEMORY.md(长期记忆)**,仅可记录在短期日记忆中用于会话连续性。 diff --git a/memory/.dreams/short-term-recall.json b/memory/.dreams/short-term-recall.json index 755f7cf..be9c6f1 100644 --- a/memory/.dreams/short-term-recall.json +++ b/memory/.dreams/short-term-recall.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-06-14T07:14:40.970Z", + "updatedAt": "2026-06-15T02:42:34.143Z", "entries": { "memory:memory/2026-05-06.md:1:20": { "key": "memory:memory/2026-05-06.md:1:20", @@ -668,18 +668,20 @@ "endLine": 56, "source": "memory", "snippet": "| 15 | Rachel | 3/5 | 10994 | 13510564547 | 3/7 | ✓ | sales-adp-bj-jxl-0, GMV=1999 | | 16 | soul | 3/2 | 17387 | 15640464255 | 3/12 | ✓ | sales-adp-bj-jxl-0, GMV=1999 | | 17 | 红 | 3/7 | 17025 | 13533955004 | 3/14 | ✓ | sales-adp-bj-jxl-0, GMV=1999 | | 18 | 一笑轩渠 | 3/8 | 17425 | 15017528458 | 3/11 | ✓ | sales-adp-bj-jxl-0, GMV=1999 | | 19 | 蜗牛 | 3/2 | ❓ | ❓ | ❓ | - | 晚柠5/15订单数百笔,无手机/UID无法定位 | | 20 | c_瑶 | 3/6 | ❓ | ❓ | ❓ | - | \"直购\"渠道DB不存在,3/14无3998/1999匹配 | ### 关键发现 1. Group A 中 7/10 用户 DB 中无任何订单(pre汇总有GMV但DB不存在) 2. Group B 4个手机号全部未注册(H=未注册 确认正确) 3. Group C #15-#18 4笔 jxl-0 均 L≥C,pre怀疑#18 L0 · P= C(线索日期) - ④ 全额退清: 所有订单都退费 → K/O/P/Q 全部清空 - ⑤ O/P/Q 0留空, P整元 + ③ Y=1: 仅当 K(下单日) >= C(线索日期) + ④ 全额退清: 所有订单都退费 → N/O/P 全部清空 + ⑤ N/O/P 0留空, O整元 ⑥ G列不动, 订单汇总不动 -覆盖列: D/H/I/J + K-V + S/U +覆盖列: D/H/I/J + K-U + X/Y/Z """ import json, re, time, sys, os, requests, psycopg2 from datetime import datetime @@ -27,9 +27,9 @@ from phone_encrypt import encrypt_phone SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug" SALES_SHEETS = [ - ("qJF4I", "小龙", "A1:V1200"), - ("f975f0", "吴迪", "A1:V700"), - ("qJF4J", "成都", "A1:V2500"), + ("qJF4I", "小龙", "A1:Z1200"), + ("f975f0", "吴迪", "A1:Z700"), + ("qJF4J", "成都", "A1:Z2500"), ] CS_MAP = {"吴迪": "吴迪", "小龙": "小龙", "Tom": "Tom", "Bob": "Bob"} @@ -228,7 +228,7 @@ def query_all_pg(all_entries, phone_map): "has_order": False, "order_date": "", "order_channel": "", "product": "", "gmv": 0, "refund": 0, "gsv": 0, "activation": "", "lesson_progress": "", "lesson_time": "", "lesson_minutes": 0, - "max_lesson": 0, + "max_lesson": 0, "trade_no": "", } for uid in uid_set} # 3a. 注册信息 @@ -278,9 +278,10 @@ def query_all_pg(all_entries, phone_map): continue info[aid]["has_order"] = True latest = olist[0] - info[aid]["order_date"] = latest[2].strftime("%Y-%m-%d") if latest[2] else "" + info[aid]["order_date"] = f"{latest[2].month}月{latest[2].day}日 {latest[2].strftime('%H:%M:%S')}" if latest[2] else "" info[aid]["order_channel"] = latest[3] or "" info[aid]["product"] = GOODS_NAMES.get(latest[4], f"商品{latest[4]}") + info[aid]["trade_no"] = latest[1] or "" total_gmv = sum(o[5] for o in olist) / 100.0 total_refund = sum(refund_map.get(o[1], 0) for o in olist) / 100.0 info[aid]["gmv"] = total_gmv @@ -402,7 +403,7 @@ def query_all_pg(all_entries, phone_map): # ── Step 4: 写入销售三表 ── def write_sales_sheets(token, all_entries, phone_map, db_info): - """全覆盖写入销售表的自动列""" + """全覆盖写入销售表的自动列: D/H/I/J + K-U + X/Y/Z""" now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") for sid, sname, _ in SALES_SHEETS: @@ -428,7 +429,8 @@ def write_sales_sheets(token, all_entries, phone_map, db_info): d_vals, h_vals, i_vals, j_vals = [], [], [], [] k_vals, l_vals, m_vals, n_vals = [], [], [], [] o_vals, p_vals, q_vals, r_vals = [], [], [], [] - s_vals, t_vals, u_vals, v_vals = [], [], [], [] + s_vals, t_vals, u_vals = [], [], [] + x_vals, y_vals, z_vals = [], [], [] for item in g: phone = item["phone"] @@ -486,51 +488,62 @@ def write_sales_sheets(token, all_entries, phone_map, db_info): gsv_int = int(di["gsv"]) is_full_refund = (gmv_int > 0 and gmv_int == refund_int) - # K=是: 有订单且非全额退清 - # 注: C列(进线日期)实际存的是手机号, 无法做L≥C日期比较 + # 有效订单判定: 有订单且非全额退清 order_date = di["order_date"] - should_k_yes = di["has_order"] and not is_full_refund + should_y_yes = di["has_order"] and not is_full_refund if is_full_refund: - # 全额退清 → K/O/P/Q 全部清空 - k_vals.append([""]) + # 全额退清 → N/O/P 全部清空 + n_vals.append([""]) o_vals.append([""]) p_vals.append([""]) - q_vals.append([""]) else: - k_vals.append(["是" if should_k_yes else ""]) - o_vals.append([gmv_int if gmv_int > 0 else ""]) - p_vals.append([refund_int if refund_int > 0 else ""]) - q_vals.append([gsv_int if gsv_int > 0 else ""]) + n_vals.append([gmv_int if gmv_int > 0 else ""]) + o_vals.append([refund_int if refund_int > 0 else ""]) + p_vals.append([gsv_int if gsv_int > 0 else ""]) - l_vals.append([order_date]) - m_vals.append([di["order_channel"]]) - n_vals.append([di["product"] if di["has_order"] else ""]) + # K: 下单日期, L: 成交渠道, M: 产品 + k_vals.append([order_date]) + l_vals.append([di["order_channel"]]) + m_vals.append([di["product"] if di["has_order"] else ""]) + # Q: 激活课程 act = di["activation"] if act: - r_vals.append([f"{act}体验课" if act in ("A1", "A2") else act]) + q_vals.append([f"{act}体验课" if act in ("A1", "A2") else act]) else: - r_vals.append([""]) + q_vals.append([""]) + # R: 行课进度, S: 最近行课时间 lp = di["lesson_progress"] - s_vals.append([lp if lp else ""]) - t_vals.append([di["lesson_time"]]) + r_vals.append([lp if lp else ""]) + s_vals.append([di["lesson_time"]]) + # T: 学习时长 lm = di["lesson_minutes"] - u_vals.append([lm if lm > 0 else ""]) + t_vals.append([lm if lm > 0 else ""]) + + # X: 订单号 + x_vals.append([di.get("trade_no", "")]) + # Y: 有效订单 + y_vals.append([1 if should_y_yes else ""]) + # Z: 渠道归属 + z_vals.append([di["order_channel"]]) else: for arr in [d_vals, i_vals, j_vals, k_vals, l_vals, m_vals, n_vals, - o_vals, p_vals, q_vals, r_vals, s_vals, t_vals, u_vals]: + o_vals, p_vals, q_vals, r_vals, s_vals, t_vals, + x_vals, y_vals, z_vals]: arr.append([""]) - v_vals.append([now_str]) + # U: 更新时间 + u_vals.append([now_str]) cols = [ ("D", d_vals), ("H", h_vals), ("I", i_vals), ("J", j_vals), ("K", k_vals), ("L", l_vals), ("M", m_vals), ("N", n_vals), ("O", o_vals), ("P", p_vals), ("Q", q_vals), ("R", r_vals), - ("S", s_vals), ("T", t_vals), ("U", u_vals), ("V", v_vals), + ("S", s_vals), ("T", t_vals), ("U", u_vals), + ("X", x_vals), ("Y", y_vals), ("Z", z_vals), ] for col_letter, vals in cols: put_values(token, sid, f"{col_letter}{sr}:{col_letter}{er}", vals) diff --git a/scripts/fill_leads_sheet.py b/scripts/fill_leads_sheet.py index 3f3db30..6ea9c5d 100644 --- a/scripts/fill_leads_sheet.py +++ b/scripts/fill_leads_sheet.py @@ -3,13 +3,13 @@ 端内析出leads数据 — 自动回填 + 统计汇总脚本 流程: -1. 读取曲慧萌/吴迪 sheet 中 A 列的手机号 +1. 读取吴迪 sheet 中 B 列的手机号 2. XXTEA 加密 → 匹配 bi_vala_app_account.tel_encrypt → 获取 account_id -3. 查询注册日期、转化、退费、U0体验课完成日期 → 回写 +3. 查询注册日期、转化(全部key_from,购课时间≥析出时间)、转化keyfrom、退费、U0体验课完成日期 → 回写 4. 按析出月份汇总统计 → 写入"统计" sheet 统计口径: -- 转化率 = 已转化leads / 有效析出leads(剔除端外购课用户) +- 转化率 = 已转化leads / 总leads - 退费率 = 退费leads / 已转化leads - 完成率 = 完成该课的leads / 总leads @@ -26,17 +26,9 @@ from collections import defaultdict # ── 配置 ────────────────────────────────────────────── SPREADSHEET_TOKEN = "FA3xsw3kph4pdatKlUrcyPgInAc" -SHEET_QHM = "7f0e35" # 曲慧萌 SHEET_WD = "1K3O6s" # 吴迪 SHEET_STAT = "scyF3H" # 统计 -# 端内渠道 -INNER_CHANNELS = [ - "app-active-h5-0-0", - "app-sales-bj-qhm-0", - "app-sales-bj-wd-0", -] - # U0 体验课 chapter_id U0_CHAPTERS = { "L1-U0-L01": 343, @@ -59,17 +51,19 @@ U0_COL_ORDER = [ # 列映射(0-based) # 注意:A列「序号」和C列「微信昵称」由销售手动填写,脚本不读写 -COL_SEQ = 0 # A: 序号(手动填,脚本跳过) -COL_PHONE = 1 # B: 用户手机号 -COL_NICKNAME = 2 # C: 微信昵称(销售手动填写,脚本跳过) -COL_USER_ID = 3 # D: 用户ID -COL_EXTRACT_DATE = 4 # E: 析出日期(手动填) -COL_REG_DATE = 5 # F: 注册日期 -COL_CONVERTED = 6 # G: 是否转化 -COL_CONVERT_DATE = 7 # H: 转化日期 -COL_REFUND = 8 # I: 是否退费 -COL_REFUND_DATE = 9 # J: 退费日期 -COL_U0_START = 10 # K-T: L1-U0-L01 ~ L2-U0-L05 +COL_SEQ = 0 # A: 序号(手动填,脚本跳过) +COL_PHONE = 1 # B: 用户手机号 +COL_NICKNAME = 2 # C: 微信昵称(销售手动填写,脚本跳过) +COL_USER_ID = 3 # D: 用户ID +COL_EXTRACT_DATE = 4 # E: 析出日期(手动填) +COL_REG_DATE = 5 # F: 注册日期 +COL_CONVERTED = 6 # G: 是否转化 +COL_CONVERT_DATE = 7 # H: 转化日期 +COL_CONVERT_KEYFROM = 8 # I: 转化keyfrom +COL_CONVERT_GSV = 9 # J: 转化金额(GSV)(新增) +COL_REFUND = 10 # K: 是否退费 +COL_REFUND_DATE = 11 # L: 退费日期 +COL_U0_START = 12 # M-W: L1-U0-L01 ~ L2-U0-L05 # ── 数据库 ───────────────────────────────────────────── PG_HOST = "bj-postgres-16pob4sg.sql.tencentcdb.com" @@ -180,64 +174,47 @@ def match_phones(phones: list[str]) -> dict[str, dict]: return results -def query_conversion(account_ids: list[str]) -> dict[str, dict]: +def query_orders_and_refunds(account_ids: list[str]) -> dict[str, dict]: + """ + 查询全部渠道订单及退费信息(不限 key_from)。 + 返回: {account_id: {"orders": [(pay_date, key_from, is_refunded), ...], "total_gmv_cents": int, "total_gsv_cents": int}} + orders 按 pay_success_date 升序排列。 + """ if not account_ids: return {} BATCH_SIZE = 100 results = {} - channels_str = ",".join(f"'{c}'" for c in INNER_CHANNELS) for i in range(0, len(account_ids), BATCH_SIZE): batch = account_ids[i:i + BATCH_SIZE] ids_str = ",".join(batch) + # 查询每笔订单及退费状态 sql = f""" SELECT o.account_id::text, - MIN(o.pay_success_date::date::text) AS first_pay_date, - BOOL_OR(r.id IS NOT NULL AND r.status = 3 AND o2.order_status = 4) AS has_refund, - MIN(CASE WHEN r.id IS NOT NULL AND r.status = 3 AND o2.order_status = 4 - THEN r.created_at::date::text END) AS first_refund_date + o.pay_success_date::date::text, + o.key_from, + o.pay_amount_int, + COALESCE(SUM(CASE WHEN r.status = 3 AND o2.order_status = 4 THEN r.refund_amount::numeric * 100 ELSE 0 END), 0)::bigint AS total_refund_fen, + BOOL_OR(r.id IS NOT NULL AND r.status = 3 AND o2.order_status = 4) AS is_refunded FROM bi_vala_order o - LEFT JOIN bi_refund_order r ON o.trade_no = r.trade_no AND r.status = 3 + LEFT JOIN bi_refund_order r ON o.trade_no = r.trade_no LEFT JOIN bi_vala_order o2 ON o.trade_no = o2.trade_no AND o2.order_status = 4 WHERE o.account_id IN ({ids_str}) - AND o.key_from IN ({channels_str}) AND o.pay_success_date IS NOT NULL AND o.order_status IN (3, 4) - GROUP BY o.account_id + GROUP BY o.account_id, o.pay_success_date, o.key_from, o.pay_amount_int + ORDER BY o.account_id, o.pay_success_date """ for row in pg_query(sql): - if len(row) >= 3: - acc_id, first_pay, has_refund = row[0], row[1], row[2] - first_refund = row[3] if len(row) >= 4 else "" - results[acc_id] = { - "converted": "是" if first_pay else "否", - "convert_date": first_pay or "", - "refunded": "是" if has_refund in ("t", "true") else "否", - "refund_date": first_refund or "", - } - return results - - -def query_outside_conversion(account_ids: list[str]) -> set[str]: - """查询哪些账号有端外购课(key_from 不在端内渠道中)""" - if not account_ids: - return set() - BATCH_SIZE = 100 - results = set() - channels_str = ",".join(f"'{c}'" for c in INNER_CHANNELS) - for i in range(0, len(account_ids), BATCH_SIZE): - batch = account_ids[i:i + BATCH_SIZE] - ids_str = ",".join(batch) - sql = f""" - SELECT DISTINCT o.account_id::text - FROM bi_vala_order o - WHERE o.account_id IN ({ids_str}) - AND o.key_from NOT IN ({channels_str}) - AND o.pay_success_date IS NOT NULL - AND o.order_status IN (3, 4) - """ - for row in pg_query(sql): - if len(row) >= 1: - results.add(row[0]) + if len(row) >= 6: + acc_id, pay_date, key_from, pay_amount, total_refund_fen, is_refunded = row[0], row[1], row[2], row[3], row[4], row[5] + if acc_id not in results: + results[acc_id] = {"orders": [], "total_gmv_cents": 0, "total_gsv_cents": 0} + refunded = is_refunded in ("t", "true") + results[acc_id]["orders"].append((pay_date, key_from, refunded)) + # 累加用户总 GMV(分)和 GSV(分) + results[acc_id]["total_gmv_cents"] += int(pay_amount) + gsv_fen = int(pay_amount) - int(total_refund_fen) + results[acc_id]["total_gsv_cents"] += gsv_fen return results @@ -305,7 +282,7 @@ def process_sheet(sheet_id: str, sheet_name: str, dry_run: bool = False) -> list print(f"处理 Sheet: {sheet_name} ({sheet_id})") print(f"{'='*60}") - range_str = f"{sheet_id}!A2:T" + range_str = f"{sheet_id}!A2:W" try: rows = lark_read(sheet_id, range_str) except Exception as e: @@ -339,11 +316,8 @@ def process_sheet(sheet_id: str, sheet_name: str, dry_run: bool = False) -> list matched_accounts = [info["id"] for info in acc_info.values()] matched_phones = set(acc_info.keys()) - print("→ 查询转化信息...") - conv_info = query_conversion(matched_accounts) - - print("→ 查询端外购课...") - outside_accounts = query_outside_conversion(matched_accounts) + print("→ 查询订单及退费信息(全部渠道)...") + order_info = query_orders_and_refunds(matched_accounts) print("→ 查询 U0 学习进度...") learn_info = query_learning(matched_accounts) @@ -357,7 +331,6 @@ def process_sheet(sheet_id: str, sheet_name: str, dry_run: bool = False) -> list if not info: continue acc_id = info["id"] - conv = conv_info.get(acc_id, {}) learn = learn_info.get(acc_id, {}) for row_idx in row_indices: @@ -366,32 +339,59 @@ def process_sheet(sheet_id: str, sheet_name: str, dry_run: bool = False) -> list if len(rows[row_idx]) > COL_EXTRACT_DATE and rows[row_idx][COL_EXTRACT_DATE]: extract_date = str(rows[row_idx][COL_EXTRACT_DATE]).strip() - # F 列逻辑:端内购课 > 端外购课 > 否 - is_inner = conv.get("converted", "否") == "是" - is_outside = acc_id in outside_accounts - if is_inner: - f_value = "是" - convert_date = conv.get("convert_date", "") - elif is_outside: - f_value = "端外购课" - convert_date = "" - else: - f_value = "否" - convert_date = "" + # 转化判断:全部渠道,购课时间 ≥ 析出时间 + # 退费判断:只要存在一笔 ≥ 析出日期且未退费的订单,就算未退费 + # 转化金额:用户全部订单 GSV(全部 pay_amount - 全部 refund_amount) + orders = order_info.get(acc_id, {}) + conv_date = "" + conv_keyfrom = "" + conv_gsv = "" + is_refunded = "否" + + # 析出日期可能是 Excel 序列数字,统一转为 YYYY-MM-DD + extract_date_ymd = "" + if extract_date: + if extract_date.isdigit(): + extract_date_ymd = excel_serial_to_date(int(extract_date)) + else: + extract_date_ymd = extract_date + + if extract_date_ymd: + first_match = None + has_valid_order = False + for pay_date, kf, refunded in orders.get("orders", []): + if pay_date >= extract_date_ymd: + if first_match is None: + first_match = (pay_date, kf) + if not refunded: + has_valid_order = True + if first_match: + conv_date = first_match[0] + conv_keyfrom = first_match[1] + # 转化金额 = 用户总 GSV(元) + total_gsv = orders.get("total_gsv_cents", 0) + conv_gsv = str(round(total_gsv / 100, 2)) + is_refunded = "否" if has_valid_order else "是" + + f_value = "是" if conv_date else "否" updates.append((row_idx, COL_USER_ID, acc_id)) updates.append((row_idx, COL_REG_DATE, info.get("created_at", ""))) updates.append((row_idx, COL_CONVERTED, f_value)) - updates.append((row_idx, COL_CONVERT_DATE, convert_date)) - updates.append((row_idx, COL_REFUND, conv.get("refunded", "否"))) - updates.append((row_idx, COL_REFUND_DATE, conv.get("refund_date", ""))) + updates.append((row_idx, COL_CONVERT_DATE, conv_date)) + updates.append((row_idx, COL_CONVERT_KEYFROM, conv_keyfrom)) + updates.append((row_idx, COL_CONVERT_GSV, conv_gsv)) + updates.append((row_idx, COL_REFUND, is_refunded)) + updates.append((row_idx, COL_REFUND_DATE, "")) for col_offset, lesson_name in enumerate(U0_COL_ORDER): updates.append((row_idx, COL_U0_START + col_offset, learn.get(lesson_name, ""))) lead_data.append({ "extract_date": extract_date, "converted": f_value, - "refunded": conv.get("refunded", "否"), + "refunded": is_refunded, + "gmv_cents": orders.get("total_gmv_cents", 0), + "gsv_cents": orders.get("total_gsv_cents", 0), "lessons": {k: learn.get(k, "") for k in U0_COL_ORDER}, "has_phone": True, }) @@ -411,6 +411,8 @@ def process_sheet(sheet_id: str, sheet_name: str, dry_run: bool = False) -> list "extract_date": extract_date, "converted": existing_converted, "refunded": "否", + "gmv_cents": 0, + "gsv_cents": 0, "lessons": {k: "" for k in U0_COL_ORDER}, "has_phone": False, }) @@ -431,16 +433,16 @@ def process_sheet(sheet_id: str, sheet_name: str, dry_run: bool = False) -> list lark_write(sheet_id, f"{sheet_id}!D{actual_row}:D{actual_row}", [[str(col_vals[COL_USER_ID])]]) - # F-T: 注册日期 ~ L2-U0-L5 - f_to_t = [] + # F-V: 注册日期 ~ L2-U0-L5(含 I 列转化keyfrom) + f_to_w = [] for col in range(COL_REG_DATE, COL_U0_START + len(U0_COL_ORDER)): val = col_vals.get(col, "") - f_to_t.append(str(val) if val else "") + f_to_w.append(str(val) if val else "") if dry_run: - print(f" [DRY-RUN] {sheet_id}!D{actual_row} + F{actual_row}:T{actual_row} ← ...") + print(f" [DRY-RUN] {sheet_id}!D{actual_row} + F{actual_row}:W{actual_row} ← ...") else: - lark_write(sheet_id, f"{sheet_id}!F{actual_row}:T{actual_row}", [f_to_t]) + lark_write(sheet_id, f"{sheet_id}!F{actual_row}:W{actual_row}", [f_to_w]) print(f" ✓ 行 {actual_row} 回写成功") unmatched = set(phones) - matched_phones @@ -458,8 +460,7 @@ def compute_stats(lead_data: list[dict]) -> dict[str, dict]: """ 按析出月份汇总统计 口径: - - 有效析出用户数 = 总leads - 端外购课leads - - 转化率 = 已转化leads / 有效析出用户数 + - 转化率 = 已转化leads / 总leads - 退费率 = 退费leads / 已转化leads - 完成率 = 完成该课的leads / 总leads """ @@ -494,30 +495,32 @@ def compute_stats(lead_data: list[dict]) -> dict[str, dict]: for month, leads in sorted(month_groups.items()): total = len(leads) matched = sum(1 for l in leads if l.get("has_phone", False)) - outside_only = sum(1 for l in leads if l["converted"] == "端外购课") converted_all = sum(1 for l in leads if l["converted"] == "是") refunded = sum(1 for l in leads if l["refunded"] == "是") converted_unrefunded = sum(1 for l in leads if l["converted"] == "是" and l["refunded"] != "是") - # 转化率分母剔除端外购课用户 - effective_total = total - outside_only - conv_rate = converted_all / effective_total * 100 if effective_total > 0 else 0 + conv_rate = converted_all / total * 100 if total > 0 else 0 refund_rate = refunded / converted_all * 100 if converted_all > 0 else 0 + # GMV / GSV 汇总(元) + total_gmv = sum(l.get("gmv_cents", 0) for l in leads) / 100 + total_gsv = sum(l.get("gsv_cents", 0) for l in leads) / 100 + lesson_rates = {} for lesson_name in U0_COL_ORDER: completed = sum(1 for l in leads if l["lessons"].get(lesson_name, "")) lesson_rates[lesson_name] = completed / total * 100 if total > 0 else 0 result[month] = { - "total": effective_total, + "total": total, "matched": matched, - "outside_only": outside_only, "converted_all": converted_all, "converted_unrefunded": converted_unrefunded, "refunded": refunded, "conv_rate": conv_rate, "refund_rate": refund_rate, + "gmv": total_gmv, + "gsv": total_gsv, "lesson_rates": lesson_rates, } @@ -532,21 +535,21 @@ def write_all_stats(all_stats: dict[str, dict[str, dict]], dry_run: bool = False 按 销售+月份 逐行写入,从第2行开始 """ # 先写表头 - header = ["销售", "月份", "有效析出用户数", "匹配用户数", "转化用户数", "转化率", "退费率"] + \ + header = ["销售", "月份", "总析出用户数", "匹配用户数", "转化用户数", "转化率", "退费率", "GMV", "GSV"] + \ [f"{name}完成率" for name in U0_COL_ORDER] if not dry_run: - lark_write(SHEET_STAT, f"{SHEET_STAT}!A1:Q1", [header]) + lark_write(SHEET_STAT, f"{SHEET_STAT}!A1:S1", [header]) # 构建有序行列表: [(sales_name, month, stats), ...] rows_data = [] - for sales_name in ["曲慧萌", "吴迪"]: + for sales_name in ["吴迪"]: stats = all_stats.get(sales_name, {}) for month in sorted(stats.keys()): rows_data.append((sales_name, month, stats[month])) - # 先清除统计 sheet 旧数据(A2:Q50),避免残留旧行 + # 先清除统计 sheet 旧数据(A2:S50),避免残留旧行 print(" → 清除统计 sheet 旧数据...") - lark_write(SHEET_STAT, f"{SHEET_STAT}!A2:Q50", [[""] * 17] * 49) + lark_write(SHEET_STAT, f"{SHEET_STAT}!A2:S50", [[""] * 19] * 49) if not rows_data: print(" 无统计数据") @@ -559,7 +562,7 @@ def write_all_stats(all_stats: dict[str, dict[str, dict]], dry_run: bool = False lark_write(SHEET_STAT, f"{SHEET_STAT}!A{row_num}:A{row_num}", [[sales_name]]) # B: 月份 lark_write(SHEET_STAT, f"{SHEET_STAT}!B{row_num}:B{row_num}", [[month]]) - # C: 有效析出用户数(剔除端外购课) + # C: 总析出用户数 lark_write(SHEET_STAT, f"{SHEET_STAT}!C{row_num}:C{row_num}", [[s["total"]]]) # D: 匹配用户数 lark_write(SHEET_STAT, f"{SHEET_STAT}!D{row_num}:D{row_num}", [[s["matched"]]]) @@ -569,12 +572,16 @@ def write_all_stats(all_stats: dict[str, dict[str, dict]], dry_run: bool = False lark_write(SHEET_STAT, f"{SHEET_STAT}!F{row_num}:F{row_num}", [[round(s["conv_rate"] / 100, 3)]]) # G: 退费率 lark_write(SHEET_STAT, f"{SHEET_STAT}!G{row_num}:G{row_num}", [[round(s["refund_rate"] / 100, 3)]]) - # H-Q: 完成率 + # H: GMV(元) + lark_write(SHEET_STAT, f"{SHEET_STAT}!H{row_num}:H{row_num}", [[round(s["gmv"], 2)]]) + # I: GSV(元) + lark_write(SHEET_STAT, f"{SHEET_STAT}!I{row_num}:I{row_num}", [[round(s["gsv"], 2)]]) + # J-S: 完成率 lesson_vals = [round(s["lesson_rates"][name] / 100, 3) for name in U0_COL_ORDER] - lark_write(SHEET_STAT, f"{SHEET_STAT}!H{row_num}:Q{row_num}", [lesson_vals]) + lark_write(SHEET_STAT, f"{SHEET_STAT}!J{row_num}:S{row_num}", [lesson_vals]) - print(f" ✓ {sales_name} {month}: 有效析出={s['total']} 匹配={s['matched']} 转化={s['converted_all']} " - f"转化率={s['conv_rate']:.1f}% 退费率={s['refund_rate']:.1f}%") + print(f" ✓ {sales_name} {month}: 总析出={s['total']} 匹配={s['matched']} 转化={s['converted_all']} " + f"转化率={s['conv_rate']:.1f}% 退费率={s['refund_rate']:.1f}% GMV={s['gmv']:.2f} GSV={s['gsv']:.2f}") # ── 主流程 ────────────────────────────────────────────── @@ -584,8 +591,7 @@ def main(): if dry_run: print("⚠️ DRY-RUN 模式,不会实际写入\n") - # 处理两个销售 sheet - qhm_data = process_sheet(SHEET_QHM, "曲慧萌", dry_run) + # 处理吴迪 sheet wd_data = process_sheet(SHEET_WD, "吴迪", dry_run) # 汇总统计 @@ -593,15 +599,14 @@ def main(): print("汇总统计 → 统计 sheet") print(f"{'='*60}") - qhm_stats = compute_stats(qhm_data) wd_stats = compute_stats(wd_data) - all_stats = {"曲慧萌": qhm_stats, "吴迪": wd_stats} + all_stats = {"吴迪": wd_stats} if dry_run: for sales_name, stats in all_stats.items(): for month, s in stats.items(): - print(f" [DRY-RUN] {sales_name} {month}: 有效析出={s['total']} 匹配={s['matched']} 转化={s['converted_all']} 转化率={s['conv_rate']:.1f}% 退费率={s['refund_rate']:.1f}%") + print(f" [DRY-RUN] {sales_name} {month}: 总析出={s['total']} 匹配={s['matched']} 转化={s['converted_all']} 转化率={s['conv_rate']:.1f}% 退费率={s['refund_rate']:.1f}% GMV={s['gmv']:.2f} GSV={s['gsv']:.2f}") else: write_all_stats(all_stats, dry_run) diff --git a/scripts/refresh_order_summary.py b/scripts/refresh_order_summary.py index 8c30c2c..c63f276 100644 --- a/scripts/refresh_order_summary.py +++ b/scripts/refresh_order_summary.py @@ -19,7 +19,6 @@ APP_ID = "cli_a929ae22e0b8dcc8" APP_SECRET = "OtFjMy7p3qE3VvLbMdcWidwgHOnGD4FJ" SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug" SALES_SHEETS = {"f975f0": "吴迪", "qJF4I": "小龙", "qJF4J": "成都"} -DIRECT_SHEET = "1sosYE" # 直购表(小红书店铺+stream-xhs,不依赖手机号匹配) SUMMARY_SHEET = "2smjwA" def _get_pg_password(): @@ -188,24 +187,6 @@ def main(): print(f"Total rows: {len(all_rows)}") - # ── Step 1.5: 读取直购表 ── - print(f"Reading 直购...") - direct_vals = read_sheet(token, DIRECT_SHEET, "A2:V10000") - direct_rows = [] - for i, row in enumerate(direct_vals): - while len(row) < 22: - row.append("") - b = str(row[1]).strip() if row[1] else "" - h = str(row[7]).strip() if row[7] else "" - if b or h: - direct_rows.append({ - "sid": DIRECT_SHEET, "name": "直购", "row": i + 2, - "raw": row[:22], - }) - print(f" {len(direct_rows)} non-empty rows") - all_rows.extend(direct_rows) - print(f"Total rows (with 直购): {len(all_rows)}") - # ── Step 2: 筛选进订单汇总的行 ── # 条件:K=是 · O>0 · 非全额退(P空或P= C(线索日期) - ④ 全额退清: 所有订单都退费 → K/O/P/Q 全部清空 - ⑤ O/P/Q 0 留空, P 整元 + ③ Y=1: 仅当 K(下单日) >= C(线索日期) + ④ 全额退清: 所有订单都退费 → N/O/P 全部清空 + ⑤ N/O/P 0 留空, O 整元 ⑥ G 列不动 用法: @@ -36,9 +36,9 @@ from phone_encrypt import encrypt_phone SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug" SALES_SHEETS = [ - ("qJF4I", "小龙", "A3:V2607"), - ("f975f0", "吴迪", "A3:V8149"), - ("qJF4J", "成都", "A3:V2500"), + ("qJF4I", "小龙", "A3:Z2607"), + ("f975f0", "吴迪", "A3:Z8149"), + ("qJF4J", "成都", "A3:Z2500"), ] SUMMARY_SHEET_ID = "2smjwA" @@ -60,6 +60,26 @@ CHANNEL_MAP = { "官网": "官网", } + +def classify_channel(keyfrom): + """将 keyfrom 归类为 销转/直购/端内/达人/其他""" + if not keyfrom: + return "" + kf = keyfrom.lower() + if any(kw in kf for kw in ["newmedia-daren", "jingxuan", "stream-daren"]): + return "达人" + if any(kw in kf for kw in ["sales-adp", "stream-wxxd"]): + return "销转" + if "微信小店" in keyfrom: + return "销转" + if any(kw in kf for kw in ["app-active", "app-sales", "partner-active"]): + return "端内" + if any(kw in kf for kw in ["dianpu-", "stream-xhs"]): + return "直购" + if any(kw in keyfrom for kw in ["抖音", "直购", "小红书"]): + return "直购" + return "其他" + LOG_FILE = "/var/log/xiaoxi_full_refresh.log" @@ -204,7 +224,9 @@ def parse_sales_sheets(token): "S": safe_cell(row, 18), "T": safe_cell(row, 19), "U": safe_cell(row, 20), - "V": safe_cell(row, 21), + "X": safe_cell(row, 23), + "Y": safe_cell(row, 24), + "Z": safe_cell(row, 25), }, }) @@ -299,6 +321,7 @@ def query_all_pg(all_entries, phone_map): "has_order": False, "order_date": "", "order_channel": "", "product": "", "gmv": 0, "refund": 0, "gsv": 0, "activation": "", "lesson_progress": "", "lesson_time": "", "lesson_minutes": 0, + "trade_no": "", } for uid in uid_set} # 3a. 注册信息 @@ -354,9 +377,17 @@ def query_all_pg(all_entries, phone_map): continue info[aid]["has_order"] = True latest = olist[0] - info[aid]["order_date"] = latest[2].strftime("%Y-%m-%d") if latest[2] else "" + # K列格式: M月D日 HH:MM:SS, 同时保留 raw 用于日期比较 + if latest[2]: + dt = latest[2] + info[aid]["order_date"] = f"{dt.month}月{dt.day}日 {dt.strftime('%H:%M:%S')}" + info[aid]["order_date_raw"] = dt.strftime("%Y-%m-%d %H:%M:%S") + else: + info[aid]["order_date"] = "" + info[aid]["order_date_raw"] = "" info[aid]["order_channel"] = latest[3] or "" info[aid]["product"] = GOODS_NAMES.get(latest[4], f"商品{latest[4]}") + info[aid]["trade_no"] = latest[1] or "" total_gmv = sum(o[5] for o in olist) / 100.0 total_refund = sum(refund_map.get(o[1], 0) for o in olist) / 100.0 info[aid]["gmv"] = total_gmv @@ -469,7 +500,7 @@ def query_all_pg(all_entries, phone_map): return info -# ═══ Step 4: 写入销售三表 H~V 列 ═══ +# ═══ Step 4: 写入销售三表 D/H/I/J/K~U/X/Y/Z 列 ═══ def write_sales_sheets(token, all_entries, phone_map, db_info): now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -496,7 +527,8 @@ def write_sales_sheets(token, all_entries, phone_map, db_info): d_vals, h_vals, i_vals, j_vals = [], [], [], [] k_vals, l_vals, m_vals, n_vals = [], [], [], [] o_vals, p_vals, q_vals, r_vals = [], [], [], [] - s_vals, t_vals, u_vals, v_vals = [], [], [], [] + s_vals, t_vals, u_vals = [], [], [] + x_vals, y_vals, z_vals = [], [], [] for e in g: phone = e["phone"] @@ -551,52 +583,70 @@ def write_sales_sheets(token, all_entries, phone_map, db_info): gsv_int = int(di["gsv"]) is_full_refund = (gmv_int > 0 and gmv_int == refund_int) - # K=是: 有订单 且 非全额退清 且 L(下单日) >= C(线索日期) + # 有效订单判定: 有订单 且 非全额退清 且 K(下单日) >= C(线索日期) order_date = di["order_date"] - should_k_yes = di["has_order"] and not is_full_refund + should_y_yes = di["has_order"] and not is_full_refund - # 日期比较: L >= C - if should_k_yes and clue_date and order_date: - if order_date < clue_date: - should_k_yes = False + # 日期比较: K >= C (用 raw 格式比较) + order_date_raw = di.get("order_date_raw", "") + if should_y_yes and clue_date and order_date_raw: + if order_date_raw[:10] < clue_date: + should_y_yes = False if is_full_refund: - k_vals.append([""]) + n_vals.append([""]) o_vals.append([""]) p_vals.append([""]) - q_vals.append([""]) else: - k_vals.append(["是" if should_k_yes else ""]) - o_vals.append([gmv_int if gmv_int > 0 else ""]) - p_vals.append([refund_int if refund_int > 0 else ""]) - q_vals.append([gsv_int if gsv_int > 0 else ""]) + n_vals.append([gmv_int if gmv_int > 0 else ""]) + o_vals.append([refund_int if refund_int > 0 else ""]) + p_vals.append([gsv_int if gsv_int > 0 else ""]) - l_vals.append([order_date]) - m_vals.append([di["order_channel"]]) - n_vals.append([di["product"] if di["has_order"] else ""]) + # K: 下单日期, L: 成交渠道, M: 产品 + k_vals.append([order_date]) + l_vals.append([di["order_channel"]]) + m_vals.append([di["product"] if di["has_order"] else ""]) + # Q: 激活课程 act = di["activation"] if act: - r_vals.append([f"{act}体验课" if act in ("A1", "A2") else act]) + q_vals.append([f"{act}体验课" if act in ("A1", "A2") else act]) else: - r_vals.append([""]) + q_vals.append([""]) - s_vals.append([di["lesson_progress"] if di["lesson_progress"] else ""]) - t_vals.append([di["lesson_time"]]) + # R: 行课进度, S: 最近行课时间, T: 学习时长 + r_vals.append([di["lesson_progress"] if di["lesson_progress"] else ""]) + s_vals.append([di["lesson_time"]]) lm = di["lesson_minutes"] - u_vals.append([lm if lm > 0 else ""]) + t_vals.append([lm if lm > 0 else ""]) + + # X: 订单号 — 保留已有 + if existing["X"]: + x_vals.append([existing["X"]]) + else: + x_vals.append([di.get("trade_no", "")]) + # Y: 有效订单 — 保留已有 + if existing["Y"]: + y_vals.append([existing["Y"]]) + else: + y_vals.append([1 if should_y_yes else ""]) + # Z: 渠道归属(销转/直购/端内/达人) + z_vals.append([classify_channel(di["order_channel"])]) else: for arr in [d_vals, i_vals, j_vals, k_vals, l_vals, m_vals, n_vals, - o_vals, p_vals, q_vals, r_vals, s_vals, t_vals, u_vals]: + o_vals, p_vals, q_vals, r_vals, s_vals, t_vals, + x_vals, y_vals, z_vals]: arr.append([""]) - v_vals.append([now_str]) + # U: 更新时间 + u_vals.append([now_str]) cols = [ ("D", d_vals), ("H", h_vals), ("I", i_vals), ("J", j_vals), ("K", k_vals), ("L", l_vals), ("M", m_vals), ("N", n_vals), ("O", o_vals), ("P", p_vals), ("Q", q_vals), ("R", r_vals), - ("S", s_vals), ("T", t_vals), ("U", u_vals), ("V", v_vals), + ("S", s_vals), ("T", t_vals), ("U", u_vals), + ("X", x_vals), ("Y", y_vals), ("Z", z_vals), ] for col_letter, vals in cols: put_values(token, sid, f"{col_letter}{sr}:{col_letter}{er}", vals) @@ -608,7 +658,7 @@ def write_sales_sheets(token, all_entries, phone_map, db_info): # ═══ Step 5: 汇总到「订单汇总」sheet ═══ def clear_summary_sheet(token): - """先清空订单汇总 sheet 的旧数据(A~V列,从第3行开始),再写入新数据。""" + """先清空订单汇总 sheet 的旧数据(A~X列,从第3行开始),再写入新数据。""" log(" 检查订单汇总 sheet 现有数据...") try: rows = read_sheet(token, SUMMARY_SHEET_ID, "A3:A5000") @@ -621,9 +671,9 @@ def clear_summary_sheet(token): log(" 订单汇总 sheet 无旧数据,跳过清空") return - log(f" 清空 A3:V{last_data_row}({last_data_row - 2} 行旧数据)...") + log(f" 清空 A3:X{last_data_row}({last_data_row - 2} 行旧数据)...") writer = FeishuSheetWriter(SPREADSHEET_TOKEN, token) - writer.clear(SUMMARY_SHEET_ID, start_row=3, end_row=last_data_row, cols=22) + writer.clear(SUMMARY_SHEET_ID, start_row=3, end_row=last_data_row, cols=24) log(" 清空完成") except Exception as e: log(f" 清空异常: {e}") @@ -631,22 +681,22 @@ def clear_summary_sheet(token): def write_summary_sheet(token, all_entries, phone_map, db_info): """ - 将三个销售 sheet 中 K=是(已下单)的行汇总到「订单汇总」sheet。 + 将三个销售 sheet 中 Y=1(有效订单)的行汇总到「订单汇总」sheet。 先清空旧数据,再全量写入。 订单汇总 sheet 的列结构(A~X): A: 销售归属, B: 微信昵称, C: 进线日期, D: 体验节数, E: 手机号, F: 用户年级, G: 课史/跟进, H: 用户ID, I: 注册日期, J: 下载渠道, - K: 是否下单, L: 下单日期, M: 成交渠道, N: 产品, - O: 下单金额(GMV), P: 退款金额, Q: 实际收入(GSV), R: 激活课程, - S: 当前行课进度, T: 最近行课时间, U: 累计学习时长(min), V: 更新时间, - W: 渠道归属(公式), X: 有效成单(公式) + K: 下单日期, L: 成交渠道, M: 产品, + N: 下单金额(GMV), O: 退款金额, P: 实际收入(GSV), Q: 激活课程, + R: 当前行课进度, S: 最近行课时间, T: 累计学习时长(min), U: 更新时间, + V: 渠道归属, W: 有效成单, X: 订单号 """ # 先清空旧数据 clear_summary_sheet(token) log(" 汇总订单数据...") - # 收集所有 K=是 的行 + # 收集所有有效订单的行 summary_rows = [] for sid, sname, _ in SALES_SHEETS: entries = all_entries[sid] @@ -663,24 +713,24 @@ def write_summary_sheet(token, all_entries, phone_map, db_info): di = db_info.get(aid, {}) if aid > 0 else {} - # 判断是否下单 + # 判断是否有效订单 gmv_int = int(di.get("gmv", 0)) refund_int = int(di.get("refund", 0)) gsv_int = int(di.get("gsv", 0)) is_full_refund = (gmv_int > 0 and gmv_int == refund_int) has_order = di.get("has_order", False) and not is_full_refund - # 日期比较 - order_date = di.get("order_date", "") + # 日期比较: K(下单日) >= C(进线日期) (用 raw 格式比较) + order_date_raw = di.get("order_date_raw", "") clue_date = e["clue_date_parsed"] - if has_order and clue_date and order_date: - if order_date < clue_date: + if has_order and clue_date and order_date_raw: + if order_date_raw[:10] < clue_date: has_order = False if not has_order: continue - # 构建汇总行 + # 构建汇总行 (A~X, 24列) row_data = [ e["sales"], # A: 销售归属 e["nickname"], # B: 微信昵称 @@ -692,43 +742,41 @@ def write_summary_sheet(token, all_entries, phone_map, db_info): str(aid) if aid > 0 else "", # H: 用户ID di.get("reg_date", ""), # I: 注册日期 di.get("download_channel", ""), # J: 下载渠道 - "是", # K: 是否下单 - order_date, # L: 下单日期 - di.get("order_channel", ""), # M: 成交渠道 - di.get("product", ""), # N: 产品 - gmv_int if gmv_int > 0 else "", # O: GMV - refund_int if refund_int > 0 else "", # P: 退款金额 - gsv_int if gsv_int > 0 else "", # Q: GSV - (f"{di['activation']}体验课" if di.get("activation") in ("A1", "A2") else di.get("activation", "")), # R: 激活课程 - di.get("lesson_progress", ""), # S: 行课进度 - di.get("lesson_time", ""), # T: 最近行课时间 - di.get("lesson_minutes", 0) or "", # U: 学习时长 - datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # V: 更新时间 - # W: 渠道归属 — 公式 - # X: 有效成单 — 公式 + di.get("order_date", ""), # K: 下单日期 + di.get("order_channel", ""), # L: 成交渠道 + di.get("product", ""), # M: 产品 + gmv_int if gmv_int > 0 else "", # N: GMV + refund_int if refund_int > 0 else "", # O: 退款金额 + gsv_int if gsv_int > 0 else "", # P: GSV + (f"{di['activation']}体验课" if di.get("activation") in ("A1", "A2") else di.get("activation", "")), # Q: 激活课程 + di.get("lesson_progress", ""), # R: 行课进度 + di.get("lesson_time", ""), # S: 最近行课时间 + di.get("lesson_minutes", 0) or "", # T: 学习时长 + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # U: 更新时间 + classify_channel(di.get("order_channel", "")), # V: 渠道归属 + 1, # W: 有效成单 + di.get("trade_no", ""), # X: 订单号 ] summary_rows.append(row_data) - log(f" 共 {len(summary_rows)} 条下单记录待汇总") + log(f" 共 {len(summary_rows)} 条有效订单待汇总") if not summary_rows: - log(" 无下单记录,跳过汇总") + log(" 无有效订单,跳过汇总") return - # 写入订单汇总 sheet(从第3行开始,覆盖 A~V 列,W/X 列保留公式) - # 使用安全写入工具,自动分批遵守 5000 格上限 - # 22 列 → 单批最大 200 行(200×22=4400 格 ≤ 5000) + # 写入订单汇总 sheet(从第3行开始,A~X 共24列) writer = FeishuSheetWriter(SPREADSHEET_TOKEN, token) - # 构建 A~V 的值数组(22列),确保每行长度一致 + # 构建 A~X 的值数组(24列),确保每行长度一致 values = [] for row_data in summary_rows: - padded = row_data[:22] - while len(padded) < 22: + padded = row_data[:24] + while len(padded) < 24: padded.append("") values.append(padded) - writer.write(SUMMARY_SHEET_ID, start_row=3, rows=values, cols=22) + writer.write(SUMMARY_SHEET_ID, start_row=3, rows=values, cols=24) log(f" 订单汇总写入完成, 共 {len(summary_rows)} 行")