diff --git a/USER.md b/USER.md index 23fd2bd..2d7a986 100644 --- a/USER.md +++ b/USER.md @@ -45,6 +45,7 @@ | 刘彦江 | `1da2afbf` | | 姜小龙 | `bc227c85` | | 赵一凡 | `a3168f9d` | +| 李丹 | `ea523c46` | > ⚠️ 以上用户拥有全部数据查询权限,但其个人信息、查询内容、对话记录**禁止写入 MEMORY.md(长期记忆)**,仅可记录在短期日记忆中用于会话连续性。 diff --git a/memory/2026-06-17.md b/memory/2026-06-17.md new file mode 100644 index 0000000..ac884a6 --- /dev/null +++ b/memory/2026-06-17.md @@ -0,0 +1,4 @@ +# 2026-06-17 工作日志 + +## 权限变更 +- [李承龙确认] 将李丹(`ea523c46`)添加至 USER.md 一级完整权限用户列表 diff --git a/memory/2026-06-18.md b/memory/2026-06-18.md new file mode 100644 index 0000000..4ee492f --- /dev/null +++ b/memory/2026-06-18.md @@ -0,0 +1,15 @@ +# 2026-06-18 工作日志 + +## 细水入海变更同步 +- [陈逸鸫 00:56] 同步「细水入海」近期变更: + - full_refresh 改回 @大麦,小溪不再跑 Bot 三表→订单汇总 merge + - 新增「订单-财务口径」tab (2hSLSg),仅陈逸鸫点名时触发 + - 财务 tab 从 2smjwA 出发,不读三表全量线索 + - Y=0 全额退/无效单(之前是留空,改为写 0) + - 不碰 r1 表头 + r2 合计行(Cursor 公式) + - W 列留空由 Cursor 写公式 + - 新增「销售结算汇总」r8ZHO,由 Cursor 从财务 tab 生成,小溪无需操作 + +## 脚本更新 +- `scripts/finance_orders_refresh.py` v3: Y 列全额退→0(之前留空) +- `scripts/sales_leads_full_refresh.py`: 更新 pick_valid_order 逻辑 + 汇总格式 A-W 23列 diff --git a/scripts/finance_orders_refresh.py b/scripts/finance_orders_refresh.py new file mode 100644 index 0000000..e24a1b6 --- /dev/null +++ b/scripts/finance_orders_refresh.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +""" +财务口径订单刷新 v3 — 从订单汇总 2smjwA 出发 + +逻辑: + 1. 读订单汇总 2smjwA (A3:W) + 2. 逐行复制 A–U 镜像段,按 E 列手机号查 DB → 该用户全部订单 + 3. N 单 = N 行:每行 X=trade_no,K–P 写该单金额 + 4. Y=该单有效→1 (GSV>0 · 非全额退 · 进线≤下单) + 5. Z=渠道归属,W=留空(Cursor公式) + 6. 先 clear A3:Z5000 全删,再全量写入 +""" +import json, re, time, sys, os, requests, psycopg2 +from datetime import datetime +from collections import defaultdict + +SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) +WORKSPACE = os.path.dirname(SCRIPTS_DIR) +CRED_DIR = "/root/.openclaw/credentials/xiaoxi" + +sys.path.insert(0, SCRIPTS_DIR) +from phone_encrypt import encrypt_phone +from feishu_sheet_utils import FeishuSheetWriter + +SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug" +SUMMARY_SHEET_ID = "2smjwA" +FINANCE_SHEET_ID = "2hSLSg" + +GOODS_NAMES = { + 57: "瓦拉英语level1·单季", 60: "瓦拉英语level1", 63: "瓦拉英语level1·单季", + 31: "瓦拉英语年包", 32: "瓦拉英语单季度包", 33: "瓦拉英语level2", 54: "瓦拉英语季度包", + 61: "瓦拉英语level1+2", +} + +def classify_channel(key_from): + if not key_from: + return "直购" + kf = key_from.strip() + if kf in ("app-active-h5-0-0", "app-sales-bj-qhm-0", "app-sales-bj-wd-0"): + return "端内" + if kf.startswith("sales-adp-"): + return "销转" + if kf.startswith("newmedia-daren-") or kf == "newmedia-dianpu-wwxx-0-0": + return "达人" + return "直购" + +LOG_FILE = "/var/log/xiaoxi_finance_orders.log" + +def log(msg): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {msg}" + print(line) + with open(LOG_FILE, "a") as f: + f.write(line + "\n") + +def get_secret(key): + with open(os.path.join(WORKSPACE, "secrets.env")) as f: + for line in f: + if line.startswith(f"{key}="): + return line.strip().split("=", 1)[1].strip("'\"") + +def get_fs_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, range_str=None): + url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values/{sheet_id}" + if range_str: + url += f"!{range_str}" + resp = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=30) + data = resp.json() + if data.get("code") != 0: + raise RuntimeError(f"读取失败 {sheet_id}: {data}") + return data["data"]["valueRange"]["values"] + +def safe_cell(row, idx): + if len(row) > idx and row[idx] is not None: + try: + if isinstance(row[idx], (int, float)): + if row[idx] == int(row[idx]): + return str(int(row[idx])) + return str(row[idx]).strip() + except (ValueError, TypeError): + return str(row[idx]).strip() + return "" + +def batch_in(cur, sql_tpl, params, chunk=500): + results = [] + for i in range(0, len(params), chunk): + batch = params[i:i + chunk] + ph = ",".join(["%s"] * len(batch)) + cur.execute(sql_tpl % ph, batch) + results.extend(cur.fetchall()) + return results + +def parse_date_str(s): + """'6月7日'/'6月7日 10:23:48' → '2026-06-07'/'2026-06-07 10:23:48'""" + if not s: + return "" + s = s.strip() + if re.match(r'^\d{4}-\d{2}-\d{2}', s): + return s + m = re.match(r'^(\d{1,2})月(\d{1,2})日(?:\s+(\d{1,2}:\d{2}:\d{2}))?', s) + if m: + year = datetime.now().year + date_part = f"{year}-{int(m.group(1)):02d}-{int(m.group(2)):02d}" + if m.group(3): + return f"{date_part} {m.group(3)}" + return date_part + return s + +# ═══ Step 1: 读订单汇总 2smjwA ═══ + +def parse_summary(token): + """返回 [(a_to_u, phone, uid, clue_date_parsed), ...]""" + rows = read_sheet(token, SUMMARY_SHEET_ID, "A3:W2000") + entries = [] + for row in rows: + if not row or all(not cell for cell in row): + continue + phone = safe_cell(row, 4) # E 列 + uid = safe_cell(row, 7) # H 列 + if not phone and not uid: + continue + + # A–U 镜像段 (21 列, indices 0-20) + a_to_u = [safe_cell(row, i) for i in range(21)] + + # C 列进线日期 (index 2) + clue_date = parse_date_str(safe_cell(row, 2)) + + entries.append((a_to_u, phone, uid, clue_date)) + + log(f" 订单汇总: {len(entries)} 行") + return entries + +# ═══ Step 2: XXTEA 匹配 + 收集 UID ═══ + +def resolve_uids(entries): + phone_set = set() + for _, phone, uid, _ in entries: + if re.match(r'^\d{11}$', phone): + phone_set.add(phone) + + phone_to_uid = {} + if phone_set: + log(f" XXTEA 加密: {len(phone_set)} 个手机号") + phone_enc_map = {} + for phone in phone_set: + try: + phone_enc_map[encrypt_phone(phone)] = phone + except Exception as ex: + log(f" 加密失败 {phone}: {ex}") + + conn = psycopg2.connect( + host="bj-postgres-16pob4sg.sql.tencentcdb.com", port=28591, + user="ai_member", password=get_secret("PG_ONLINE_PASSWORD"), + dbname="vala_bi", connect_timeout=30 + ) + cur = conn.cursor() + enc_list = list(phone_enc_map.keys()) + for i in range(0, len(enc_list), 500): + chunk = enc_list[i:i + 500] + ph = ",".join(["%s"] * len(chunk)) + cur.execute( + f"SELECT id, tel_encrypt FROM bi_vala_app_account " + f"WHERE tel_encrypt IN ({ph}) AND status=1 AND deleted_at IS NULL", + chunk + ) + for uid, tel_enc in cur.fetchall(): + plain = phone_enc_map.get(tel_enc) + if plain: + phone_to_uid[plain] = str(uid) + cur.close() + conn.close() + log(f" 匹配到 {len(phone_to_uid)} 个 UID") + + uid_set = set() + for _, phone, uid, _ in entries: + if re.match(r'^\d{11}$', phone) and phone in phone_to_uid: + uid_set.add(int(phone_to_uid[phone])) + elif uid and uid.isdigit() and int(uid) > 0: + uid_set.add(int(uid)) + + log(f" 有效 UID: {len(uid_set)}") + return phone_to_uid, uid_set + +# ═══ Step 3: 查全量订单 ═══ + +def query_all_orders(uid_set): + uid_list = list(uid_set) + if not uid_list: + return {} + + conn = psycopg2.connect( + host="bj-postgres-16pob4sg.sql.tencentcdb.com", port=28591, + user="ai_member", password=get_secret("PG_ONLINE_PASSWORD"), + dbname="vala_bi", connect_timeout=30 + ) + cur = conn.cursor() + + log(" 查询全量订单...") + orders = batch_in(cur, + "SELECT account_id, trade_no, pay_success_date, key_from, goods_id, " + "pay_amount_int, order_status " + "FROM bi_vala_order WHERE account_id IN (%s) AND pay_success_date IS NOT NULL " + "ORDER BY pay_success_date DESC", + uid_list + ) + + trade_nos = [o[1] for o in orders if o[1]] + refund_map = {} + if trade_nos: + refunds = batch_in(cur, + "SELECT trade_no, refund_amount_int FROM bi_refund_order " + "WHERE trade_no IN (%s) AND status=3", + trade_nos + ) + for tn, amt in refunds: + refund_map[tn] = amt + + cur.close() + conn.close() + + uid_orders = defaultdict(list) + for o in orders: + aid = o[0] + tn = o[1] + gmv = o[5] / 100.0 + refund = refund_map.get(tn, 0) / 100.0 + gsv = gmv - refund + + dt = o[2] + order_date = f"{dt.month}月{dt.day}日 {dt.strftime('%H:%M:%S')}" if dt else "" + order_date_raw = dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "" + + uid_orders[aid].append({ + "trade_no": tn or "", + "order_date": order_date, + "order_date_raw": order_date_raw, + "key_from": o[3] or "", + "product": GOODS_NAMES.get(o[4], f"商品{o[4]}"), + "gmv": int(gmv), + "refund": int(refund), + "gsv": int(gsv), + "order_status": o[6], + }) + + log(f" 全量订单: {sum(len(v) for v in uid_orders.values())} 条") + return uid_orders + +# ═══ Step 4: 展开写入财务 tab ═══ + +def write_finance_sheet(token, entries, phone_to_uid, uid_orders): + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + rows = [] + for a_to_u, phone, uid_str, clue_date in entries: + # 确定 UID + aid = 0 + if re.match(r'^\d{11}$', phone) and phone in phone_to_uid: + aid = int(phone_to_uid[phone]) + elif uid_str and uid_str.isdigit() and int(uid_str) > 0: + aid = int(uid_str) + + orders = uid_orders.get(aid, []) + + if not orders: + # 无订单:写一行,A–U 镜像,其余留空 + row = a_to_u[:21] # A–U + row += ["", "", "", "", "", ""] # V W X Y Z + rows.append(row) + else: + for o in orders: + # A–U 镜像段 (21列) + row = a_to_u[:21] + + # 确保有21列 + while len(row) < 21: + row.append("") + + # K(10): 下单日期 + row[10] = o["order_date"] + # L(11): 成交渠道 + row[11] = o["key_from"] + # M(12): 产品 + row[12] = o["product"] + # N(13): GMV + row[13] = o["gmv"] if o["gmv"] > 0 else "" + # O(14): 退款 + row[14] = o["refund"] if o["refund"] > 0 else "" + # P(15): GSV + row[15] = o["gsv"] if o["gsv"] > 0 else "" + # U(20): 更新时间 + row[20] = now_str + + # V(21): 渠道归属 + row.append(classify_channel(o["key_from"])) + # W(22): 留空 (Cursor公式) + row.append("") + + # X(23): 订单号 + row.append(o["trade_no"]) + + # Y(24): 该单有效→1 · 全额退款→0 · GSV≤0→0 + gmv_val = o["gmv"] + refund_val = o["refund"] + gsv_val = o["gsv"] + is_full_refund = (gmv_val > 0 and gmv_val == refund_val) + order_valid = (gsv_val > 0 and not is_full_refund) + + # 进线早于下单检查 + if order_valid and clue_date and o["order_date_raw"]: + if o["order_date_raw"] < clue_date: + order_valid = False + + row.append(1 if order_valid else 0) + + # Z(25): 留空 (Cursor补) + row.append("") + + rows.append(row) + + log(f" 展开后共 {len(rows)} 行") + + # 确保每行 26 列 + for row in rows: + while len(row) < 26: + row.append("") + + # 先 clear A3:Z5000 全删 + log(" 清空 A3:Z5000...") + writer = FeishuSheetWriter(SPREADSHEET_TOKEN, token) + writer.clear(FINANCE_SHEET_ID, start_row=3, end_row=5000, cols=26) + + # 写入 + writer.write(FINANCE_SHEET_ID, start_row=3, rows=rows, cols=26) + + log(f" 财务订单写入完成") + +# ═══ Main ═══ + +def main(): + log("=" * 60) + log("财务口径订单刷新 v3 启动") + + try: + token = get_fs_token() + + log("Step 1: 读订单汇总 2smjwA") + entries = parse_summary(token) + + log("Step 2: XXTEA 匹配 UID") + phone_to_uid, uid_set = resolve_uids(entries) + + log("Step 3: 查询全量订单") + uid_orders = query_all_orders(uid_set) + + log("Step 4: 展开写入财务 tab") + write_finance_sheet(token, entries, phone_to_uid, uid_orders) + + log("✅ 财务订单刷新完成") + return 0 + except Exception as e: + log(f"❌ ERROR: {e}") + import traceback + traceback.print_exc() + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sales_leads_full_refresh.py b/scripts/sales_leads_full_refresh.py index 58fb4ce..4afb317 100644 --- a/scripts/sales_leads_full_refresh.py +++ b/scripts/sales_leads_full_refresh.py @@ -376,7 +376,24 @@ def query_all_pg(all_entries, phone_map): if aid not in info: continue info[aid]["has_order"] = True - latest = olist[0] + + # pick_valid_order: 选最新非全额退订单(一手机多单只绑当前有效单) + valid_orders = [] + for o in olist: + tn = o[1] + o_gmv = o[5] / 100.0 + o_refund = refund_map.get(tn, 0) / 100.0 + o_gsv = o_gmv - o_refund + if o_gsv > 0: + valid_orders.append((o, o_gmv, o_refund, o_gsv)) + + if valid_orders: + latest, _, _, _ = valid_orders[0] + info[aid]["all_refunded"] = False + else: + latest = olist[0] + info[aid]["all_refunded"] = True + # K列格式: M月D日 HH:MM:SS, 同时保留 raw 用于日期比较 if latest[2]: dt = latest[2] @@ -583,7 +600,7 @@ 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(线索日期) + # 有效订单判定: GSV>0 · 非全额退 · K≥C · 进线早于下单 order_date = di["order_date"] should_y_yes = di["has_order"] and not is_full_refund @@ -593,18 +610,23 @@ def write_sales_sheets(token, all_entries, phone_map, db_info): if order_date_raw < clue_date: should_y_yes = False + # 已全退单不写 X/L(一手机多单:线索行只绑当前有效单) if is_full_refund: n_vals.append([""]) o_vals.append([""]) p_vals.append([""]) + k_vals.append([""]) + l_vals.append([""]) + x_vals.append([""]) 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 ""]) + k_vals.append([order_date]) + l_vals.append([di["order_channel"]]) + x_vals.append([di.get("trade_no", "")]) - # K: 下单日期, L: 成交渠道, M: 产品 - k_vals.append([order_date]) - l_vals.append([di["order_channel"]]) + # M: 产品 m_vals.append([di["product"] if di["has_order"] else ""]) # Q: 激活课程 @@ -620,8 +642,6 @@ def write_sales_sheets(token, all_entries, phone_map, db_info): lm = di["lesson_minutes"] 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: 渠道归属(销转/直购/端内/达人) @@ -652,7 +672,7 @@ def write_sales_sheets(token, all_entries, phone_map, db_info): # ═══ Step 5: 汇总到「订单汇总」sheet ═══ def clear_summary_sheet(token): - """先清空订单汇总 sheet 的旧数据(A~X列,从第3行开始),再写入新数据。""" + """先清空订单汇总 sheet 的旧数据(A~W列,从第3行开始),再写入新数据。""" log(" 检查订单汇总 sheet 现有数据...") try: rows = read_sheet(token, SUMMARY_SHEET_ID, "A3:A5000") @@ -665,9 +685,9 @@ def clear_summary_sheet(token): log(" 订单汇总 sheet 无旧数据,跳过清空") return - log(f" 清空 A3:X{last_data_row}({last_data_row - 2} 行旧数据)...") + log(f" 清空 A3:W{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=24) + writer.clear(SUMMARY_SHEET_ID, start_row=3, end_row=last_data_row, cols=23) log(" 清空完成") except Exception as e: log(f" 清空异常: {e}") @@ -676,17 +696,20 @@ def clear_summary_sheet(token): def write_summary_sheet(token, all_entries, phone_map, db_info): """ 将三个销售 sheet 中 Y=1(有效订单)的行汇总到「订单汇总」sheet。 - 先清空旧数据,再全量写入。 - 订单汇总 sheet 的列结构(A~X, 24列): - A~U: 镜像三表, V: 渠道归属(Z), W: 留空, X: 订单号 + 先清空旧数据,再全量覆盖。 + 订单汇总 sheet 的列结构(A~W, 23列): + A~U: 镜像三表 gate 行, V: 渠道归属, W: trade_no(=三表X, 同一run) + 同 X 多进线 → 汇总 1行 / unique X """ # 先清空旧数据 clear_summary_sheet(token) log(" 汇总订单数据...") - # 收集所有有效订单的行 - summary_rows = [] + # 收集所有有效订单的行,按 trade_no(X) 聚合 + # key: trade_no -> {sales, nickname, ...} + trade_rows = {} # trade_no -> row_data + for sid, sname, _ in SALES_SHEETS: entries = all_entries[sid] for e in entries: @@ -721,8 +744,14 @@ def write_summary_sheet(token, all_entries, phone_map, db_info): continue trade_no = di.get("trade_no", "") + if not trade_no: + continue - # 构建汇总行 (A~X, 24列): A-U镜像 + V=渠道归属 + W=空 + X=订单号 + # 同 X 多进线 → 汇总 1行 / unique X(保留第一次出现的进线) + if trade_no in trade_rows: + continue + + # 构建汇总行 (A~W, 23列): A-U镜像gate + V=渠道归属 + W=trade_no row_data = [ e["sales"], # A: 销售归属 e["nickname"], # B: 微信昵称 @@ -734,7 +763,7 @@ 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: 下载渠道 - di.get("order_date", ""), # K: 下单日期 + di.get("order_date", ""), # K: 下单日期 di.get("order_channel", ""), # L: 成交渠道 di.get("product", ""), # M: 产品 gmv_int if gmv_int > 0 else "", # N: GMV @@ -746,41 +775,31 @@ def write_summary_sheet(token, all_entries, phone_map, db_info): di.get("lesson_minutes", 0) or "", # T: 学习时长 datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # U: 更新时间 classify_channel(di.get("order_channel", "")), # V: 渠道归属 - "", # W: 留空 - trade_no, # X: 订单号 + trade_no, # W: trade_no(=三表X, 同一run) ] - summary_rows.append((trade_no, row_data)) + trade_rows[trade_no] = row_data - # 同订单号去重(保留第一次出现) - seen_trade = set() - deduped = [] - for trade_no, row_data in summary_rows: - if trade_no and trade_no in seen_trade: - continue - if trade_no: - seen_trade.add(trade_no) - deduped.append(row_data) - - log(f" 共 {len(summary_rows)} 条有效订单, 去重后 {len(deduped)} 条, 唯一订单号 {len(seen_trade)}") + deduped = list(trade_rows.values()) + log(f" 共 {len(deduped)} 条有效订单(按 unique X 去重)") if not deduped: log(" 无有效订单,跳过汇总") return - # 写入订单汇总 sheet(从第3行开始,A~X 共24列) + # 写入订单汇总 sheet(从第3行开始,A~W 共23列) writer = FeishuSheetWriter(SPREADSHEET_TOKEN, token) - # 构建 A~X 的值数组(24列),确保每行长度一致 + # 构建 A~W 的值数组(23列),确保每行长度一致 values = [] for row_data in deduped: - padded = row_data[:24] - while len(padded) < 24: + padded = row_data[:23] + while len(padded) < 23: padded.append("") values.append(padded) - writer.write(SUMMARY_SHEET_ID, start_row=3, rows=values, cols=24) + writer.write(SUMMARY_SHEET_ID, start_row=3, rows=values, cols=23) - log(f" 订单汇总写入完成, 共 {len(summary_rows)} 行") + log(f" 订单汇总写入完成, 共 {len(deduped)} 行") # ═══ Main ═══