517 lines
18 KiB
Python
Executable File
517 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
销售线索自动回填 — 从飞书表格读取销售填写的手机号,自动匹配并回填用户信息
|
||
执行频率:每小时 cron 巡检
|
||
归属 Agent:小溪 (xiaoxi)
|
||
|
||
表格列结构 (A-V, 共22列):
|
||
Row 1: 表头
|
||
Row 2: 标注行 (👤手填 / 🤖自动)
|
||
Row 3+: 数据行
|
||
|
||
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: 更新时间 (自动回填)
|
||
"""
|
||
import json, requests, os, re, sys, time, psycopg2
|
||
from datetime import datetime
|
||
from collections import defaultdict
|
||
|
||
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, SCRIPTS_DIR)
|
||
from phone_encrypt import encrypt_phone
|
||
|
||
# ── 配置 ──
|
||
PG_HOST = "bj-postgres-16pob4sg.sql.tencentcdb.com"
|
||
PG_PORT = 28591
|
||
PG_USER = "ai_member"
|
||
PG_DB = "vala_bi"
|
||
|
||
SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug"
|
||
SHEET_IDS = {
|
||
"吴迪": "f975f0",
|
||
"小龙": "qJF4I",
|
||
"成都": "qJF4J",
|
||
}
|
||
CRED_DIR = "/root/.openclaw/credentials/xiaoxi"
|
||
LOG_FILE = "/var/log/xiaoxi_sales_lead.log"
|
||
|
||
# 产品名称映射
|
||
GOODS_NAME_MAP = {
|
||
57: "瓦拉英语level1·单季", 60: "瓦拉英语level1", 63: "瓦拉英语level1·单季",
|
||
31: "瓦拉英语年包", 32: "瓦拉英语单季度包", 33: "瓦拉英语level2",
|
||
54: "瓦拉英语季度包", 61: "瓦拉英语level1+2",
|
||
}
|
||
|
||
|
||
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_pg_password():
|
||
secrets_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "secrets.env")
|
||
with open(secrets_path) as f:
|
||
for line in f:
|
||
if line.startswith("PG_ONLINE_PASSWORD="):
|
||
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):
|
||
url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values/{sheet_id}"
|
||
resp = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=30)
|
||
data = resp.json()
|
||
if data.get("code") != 0:
|
||
raise RuntimeError(f"读取Sheet失败: {data}")
|
||
return data["data"]["valueRange"]["values"]
|
||
|
||
|
||
def put_values(token, sheet_id, range_str, values, retries=3):
|
||
url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values"
|
||
body = {"valueRange": {"range": f"{sheet_id}!{range_str}", "values": values}}
|
||
for attempt in range(retries):
|
||
resp = requests.put(url, headers={
|
||
"Authorization": f"Bearer {token}",
|
||
"Content-Type": "application/json"
|
||
}, json=body, timeout=30)
|
||
result = resp.json()
|
||
code = result.get("code", -1)
|
||
if code == 0:
|
||
return result
|
||
if code == 90217: # rate limited
|
||
wait = 2 ** attempt
|
||
log(f" 限流 {sheet_id}!{range_str}, 等待{wait}s重试...")
|
||
time.sleep(wait)
|
||
continue
|
||
log(f" 写入失败 {sheet_id}!{range_str}: {result}")
|
||
return result
|
||
return result
|
||
|
||
|
||
def encrypt_phone_local(phone):
|
||
phone = str(phone).strip()
|
||
if "." in phone:
|
||
parts = phone.split(".")
|
||
if parts[1] in ("0", "00"):
|
||
phone = parts[0]
|
||
if re.match(r"^1\d{10}$", phone):
|
||
return encrypt_phone(phone)
|
||
return None
|
||
|
||
|
||
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 process_sheet(token, cur, sheet_name, sheet_id):
|
||
"""处理单个销售分表"""
|
||
log(f"\n--- [{sheet_name}] {sheet_id} ---")
|
||
|
||
rows = read_sheet(token, sheet_id)
|
||
if len(rows) <= 2:
|
||
log(f" [{sheet_name}] 无数据行(仅表头+标注),跳过")
|
||
return {"processed": 0, "matched": 0}
|
||
|
||
log(f" [{sheet_name}] 读取到 {len(rows) - 2} 行数据")
|
||
|
||
now = datetime.now()
|
||
|
||
# 找出需要处理的行(从第3行开始,跳过表头和标注行)
|
||
# 条件:E列(手机号)有值 且 任一自动列(D,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V)为空 或 V列超过1小时
|
||
auto_cols = [3, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21] # D,H~V 的0-based索引
|
||
pending = []
|
||
for idx, row in enumerate(rows[2:], start=3):
|
||
if len(row) < 5:
|
||
continue
|
||
phone = str(row[4]).strip() if len(row) > 4 and row[4] else ""
|
||
if not phone:
|
||
continue
|
||
|
||
update_time = str(row[21]).strip() if len(row) > 21 and row[21] else ""
|
||
|
||
need_refresh = False
|
||
# 检查任一自动列是否为空
|
||
for ci in auto_cols:
|
||
val = str(row[ci]).strip() if len(row) > ci and row[ci] else ""
|
||
if not val:
|
||
need_refresh = True
|
||
break
|
||
|
||
# 或超过1小时刷新
|
||
if not need_refresh and update_time:
|
||
try:
|
||
last_update = datetime.strptime(update_time, "%Y-%m-%d %H:%M:%S")
|
||
if (now - last_update).total_seconds() > 3600:
|
||
need_refresh = True
|
||
except ValueError:
|
||
need_refresh = True
|
||
|
||
if need_refresh:
|
||
pending.append({"row_idx": idx, "phone": phone})
|
||
|
||
log(f" [{sheet_name}] 待处理: {len(pending)} 行")
|
||
if not pending:
|
||
return {"processed": 0, "matched": 0}
|
||
|
||
# 手机号→account_id 匹配(XXTEA加密匹配 tel_encrypt)
|
||
phones_raw = list(set(r["phone"] for r in pending))
|
||
valid_phones = [(p, encrypt_phone_local(p)) for p in phones_raw if encrypt_phone_local(p)]
|
||
enc_list = list(set(m[1] for m in valid_phones))
|
||
|
||
enc_to_aid = {}
|
||
for i in range(0, len(enc_list), 500):
|
||
batch = enc_list[i:i + 500]
|
||
ph = ",".join(["%s"] * len(batch))
|
||
cur.execute(
|
||
f"SELECT id, tel_encrypt FROM bi_vala_app_account WHERE tel_encrypt IN ({ph}) AND status=1 AND deleted_at IS NULL",
|
||
batch
|
||
)
|
||
for aid, tel_enc in cur.fetchall():
|
||
if tel_enc not in enc_to_aid:
|
||
enc_to_aid[tel_enc] = aid
|
||
|
||
phone_to_aid = {}
|
||
for phone, enc in valid_phones:
|
||
if enc in enc_to_aid:
|
||
phone_to_aid[phone] = enc_to_aid[enc]
|
||
|
||
log(f" [{sheet_name}] 手机号匹配: {len(phone_to_aid)}/{len(valid_phones)}")
|
||
|
||
matched_aids = list(set(phone_to_aid.values()))
|
||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
if not matched_aids:
|
||
for r in pending:
|
||
row_num = r["row_idx"]
|
||
put_values(token, sheet_id, f"H{row_num}:H{row_num}", [["未注册"]])
|
||
put_values(token, sheet_id, f"V{row_num}:V{row_num}", [[now_str]])
|
||
log(f" [{sheet_name}] 全部未匹配,已标记 {len(pending)} 行")
|
||
return {"processed": len(pending), "matched": 0}
|
||
|
||
# 批量查询用户信息
|
||
# 账号信息
|
||
cur.execute(
|
||
f"SELECT id, created_at, download_channel FROM bi_vala_app_account WHERE id IN ({','.join(['%s']*len(matched_aids))}) AND status=1 AND deleted_at IS NULL",
|
||
matched_aids
|
||
)
|
||
account_info = {}
|
||
for aid, created_at, channel in cur.fetchall():
|
||
account_info[aid] = {
|
||
"created_at": created_at.strftime("%Y-%m-%d") if created_at else "",
|
||
"download_channel": channel or ""
|
||
}
|
||
|
||
# 订单信息
|
||
cur.execute(
|
||
f"SELECT account_id, pay_success_date, key_from, goods_id, pay_amount_int, order_status FROM bi_vala_order WHERE account_id IN ({','.join(['%s']*len(matched_aids))}) AND pay_success_date IS NOT NULL AND order_status IN (3,4) AND deleted_at IS NULL ORDER BY pay_success_date DESC",
|
||
matched_aids
|
||
)
|
||
order_info = defaultdict(list)
|
||
for aid, pay_date, key_from, goods_id, amount, order_status in cur.fetchall():
|
||
order_info[aid].append({
|
||
"pay_date": pay_date.strftime("%Y-%m-%d") if pay_date else "",
|
||
"key_from": key_from or "",
|
||
"goods_id": goods_id,
|
||
"amount": amount or 0,
|
||
"order_status": order_status,
|
||
})
|
||
|
||
# 退款信息
|
||
cur.execute(
|
||
f"SELECT o.account_id, SUM(r.refund_amount::numeric) FROM bi_refund_order r INNER JOIN bi_vala_order o ON r.trade_no=o.trade_no WHERE o.account_id IN ({','.join(['%s']*len(matched_aids))}) AND r.status=3 AND o.order_status=4 AND r.deleted_at IS NULL GROUP BY o.account_id",
|
||
matched_aids
|
||
)
|
||
refund_info = {r[0]: r[1] for r in cur.fetchall()}
|
||
|
||
# 激活课程
|
||
cur.execute(
|
||
f"SELECT account_id, season_package_level FROM bi_vala_seasonal_ticket WHERE account_id IN ({','.join(['%s']*len(matched_aids))}) AND status=1 AND deleted_at IS NULL AND season_package_level IN ('A1','A2')",
|
||
matched_aids
|
||
)
|
||
activation = {}
|
||
for aid, lvl in cur.fetchall():
|
||
if aid not in activation:
|
||
activation[aid] = lvl
|
||
elif activation[aid] != lvl:
|
||
activation[aid] = "A1+A2"
|
||
|
||
# 角色 → 行课记录
|
||
cur.execute(
|
||
f"SELECT account_id, id FROM bi_vala_app_character WHERE account_id IN ({','.join(['%s']*len(matched_aids))}) AND nickname IS NOT NULL AND nickname != '' AND deleted_at IS NULL",
|
||
matched_aids
|
||
)
|
||
account_chars = defaultdict(list)
|
||
char_to_account = {}
|
||
for aid, cid in cur.fetchall():
|
||
account_chars[aid].append(cid)
|
||
char_to_account[cid] = aid
|
||
char_ids = list(char_to_account.keys())
|
||
|
||
# 课程结构映射
|
||
cur.execute("SELECT id, course_level, course_season, course_unit, course_lesson FROM bi_level_unit_lesson")
|
||
chapter_map = {}
|
||
for ch_id, cl, cs, cu, cl2 in cur.fetchall():
|
||
chapter_map[ch_id] = (cl or "", cs or "", cu or "", cl2 or "")
|
||
|
||
# 课时完成记录
|
||
char_plays = defaultdict(lambda: {"latest_time": None, "latest_chapter": None, "total_ms": 0})
|
||
for tbl_idx in range(8):
|
||
table = f"bi_user_chapter_play_record_{tbl_idx}"
|
||
try:
|
||
cur.execute(
|
||
f"SELECT user_id, chapter_id, created_at FROM {table} WHERE play_status=1 AND deleted_at IS NULL AND user_id = ANY(%s)",
|
||
(char_ids,)
|
||
)
|
||
for uid, ch_id, created_at in cur.fetchall():
|
||
ch_data = chapter_map.get(ch_id)
|
||
if ch_data:
|
||
rec = char_plays[uid]
|
||
if rec["latest_time"] is None or created_at > rec["latest_time"]:
|
||
rec["latest_time"] = created_at
|
||
rec["latest_chapter"] = (ch_id, ch_data)
|
||
except Exception as e:
|
||
log(f" 警告 {table}: {e}")
|
||
|
||
# 学习总耗时
|
||
for tbl_idx in range(8):
|
||
table = f"bi_user_component_play_record_{tbl_idx}"
|
||
try:
|
||
cur.execute(
|
||
f"SELECT user_id, SUM(COALESCE(interval_time,0)) FROM {table} WHERE user_id = ANY(%s) AND deleted_at IS NULL GROUP BY user_id",
|
||
(char_ids,)
|
||
)
|
||
for uid, total_ms in cur.fetchall():
|
||
if uid in char_plays:
|
||
char_plays[uid]["total_ms"] += (total_ms or 0)
|
||
except Exception as e:
|
||
log(f" 警告 {table}: {e}")
|
||
|
||
# 体验课完成节数
|
||
cur.execute(
|
||
f"SELECT a.id, COUNT(*) FROM bi_vala_app_account a INNER JOIN bi_vala_app_character c ON a.id=c.account_id AND c.deleted_at IS NULL INNER JOIN bi_user_course_detail ucd ON c.id=ucd.user_id AND ucd.deleted_at IS NULL WHERE a.id IN ({','.join(['%s']*len(matched_aids))}) AND a.status=1 AND a.deleted_at IS NULL AND ucd.expire_time IS NULL GROUP BY a.id",
|
||
matched_aids
|
||
)
|
||
trial_count = {r[0]: r[1] for r in cur.fetchall()}
|
||
|
||
# 组装回填数据
|
||
results = []
|
||
for r in pending:
|
||
phone = r["phone"]
|
||
aid = phone_to_aid.get(phone)
|
||
row_num = r["row_idx"]
|
||
|
||
if not aid:
|
||
results.append({
|
||
"row": row_num,
|
||
"values": {
|
||
"H": "未注册", "I": "", "J": "", "K": "", "L": "", "M": "", "N": "",
|
||
"O": "", "P": "", "Q": "", "R": "", "S": "", "T": "", "U": "", "V": now_str
|
||
}
|
||
})
|
||
continue
|
||
|
||
info = account_info.get(aid, {})
|
||
orders = order_info.get(aid, [])
|
||
refund_amt = refund_info.get(aid, 0)
|
||
act = activation.get(aid, "")
|
||
trials = trial_count.get(aid, 0)
|
||
|
||
reg_date = info.get("created_at", "")
|
||
download_ch = info.get("download_channel", "")
|
||
|
||
has_order = "是" if orders else "否"
|
||
if orders:
|
||
latest_order = orders[0]
|
||
order_date = latest_order["pay_date"]
|
||
channel = latest_order["key_from"]
|
||
goods_id = latest_order["goods_id"]
|
||
product = GOODS_NAME_MAP.get(goods_id, f"goods_{goods_id}")
|
||
gmv = sum(int(o["amount"]) for o in orders) / 100.0
|
||
else:
|
||
order_date = ""
|
||
channel = ""
|
||
product = ""
|
||
gmv = 0
|
||
|
||
gsv = gmv - float(refund_amt) / 100.0
|
||
|
||
act_label = ""
|
||
if act == "A1":
|
||
act_label = "L1"
|
||
elif act == "A2":
|
||
act_label = "L2"
|
||
elif act == "A1+A2":
|
||
act_label = "L1+L2"
|
||
|
||
chars = account_chars.get(aid, [])
|
||
best_time = None
|
||
best_ch = None
|
||
total_ms = 0
|
||
for cid in chars:
|
||
play = char_plays.get(cid)
|
||
if play and play["latest_chapter"]:
|
||
if best_time is None or play["latest_time"] > best_time:
|
||
best_time = play["latest_time"]
|
||
best_ch = play["latest_chapter"]
|
||
total_ms += play["total_ms"]
|
||
|
||
if best_ch:
|
||
ch_id, (cl, cs, cu, cl2) = best_ch
|
||
progress = f"{cl}-{cs}-{cu}-{cl2}"
|
||
recent = best_time.strftime("%Y-%m-%d")
|
||
total_min = round(total_ms / 60000, 1)
|
||
if total_min == int(total_min):
|
||
total_min = int(total_min)
|
||
else:
|
||
progress = "无记录"
|
||
recent = ""
|
||
total_min = 0
|
||
|
||
results.append({
|
||
"row": row_num,
|
||
"values": {
|
||
"D": str(trials) if trials else "0",
|
||
"H": str(aid),
|
||
"I": reg_date,
|
||
"J": download_ch,
|
||
"K": has_order,
|
||
"L": order_date,
|
||
"M": channel,
|
||
"N": product,
|
||
"O": str(round(gmv, 2)) if gmv else "0",
|
||
"P": str(round(float(refund_amt) / 100.0, 2)) if refund_amt else "0",
|
||
"Q": str(round(gsv, 2)),
|
||
"R": act_label,
|
||
"S": progress,
|
||
"T": recent,
|
||
"U": str(total_min),
|
||
"V": now_str,
|
||
}
|
||
})
|
||
|
||
# 回填 — 整行写入 D~V(19列),E/F/G 留空不覆盖销售数据
|
||
results.sort(key=lambda x: x["row"])
|
||
log(f" [{sheet_name}] 准备回填 {len(results)} 行")
|
||
|
||
# 按连续行分组
|
||
groups = []
|
||
cur_grp = []
|
||
for r in results:
|
||
if not cur_grp or r["row"] == cur_grp[-1]["row"] + 1:
|
||
cur_grp.append(r)
|
||
else:
|
||
groups.append(cur_grp)
|
||
cur_grp = [r]
|
||
if cur_grp:
|
||
groups.append(cur_grp)
|
||
|
||
for gi, g in enumerate(groups):
|
||
sr, er = g[0]["row"], g[-1]["row"]
|
||
# D列单独写(体验节数)
|
||
d_vals = [[r["values"].get("D", "")] for r in g]
|
||
put_values(token, sheet_id, f"D{sr}:D{er}", d_vals)
|
||
# H~V 整块写(跳过E/F/G,不覆盖销售数据)
|
||
h_vals = []
|
||
for r in g:
|
||
v = r["values"]
|
||
h_vals.append([
|
||
v.get("H", ""), # H: 用户ID
|
||
v.get("I", ""), # I: 注册日期
|
||
v.get("J", ""), # J: 下载渠道
|
||
v.get("K", ""), # K: 是否下单
|
||
v.get("L", ""), # L: 下单日期
|
||
v.get("M", ""), # M: 成交渠道
|
||
v.get("N", ""), # N: 产品
|
||
v.get("O", ""), # O: 下单金额(GMV)
|
||
v.get("P", ""), # P: 退款金额
|
||
v.get("Q", ""), # Q: 实际收入(GSV)
|
||
v.get("R", ""), # R: 激活课程
|
||
v.get("S", ""), # S: 当前行课进度
|
||
v.get("T", ""), # T: 最近行课时间
|
||
v.get("U", ""), # U: 累计学习时长
|
||
v.get("V", ""), # V: 更新时间
|
||
])
|
||
put_values(token, sheet_id, f"H{sr}:V{er}", h_vals)
|
||
if gi % 5 == 4:
|
||
time.sleep(0.5)
|
||
|
||
matched_count = sum(1 for r in results if r["values"]["H"] != "未注册")
|
||
log(f" [{sheet_name}] 回填完成: {len(results)} 行, 匹配 {matched_count}, 未注册 {len(results) - matched_count}")
|
||
return {"processed": len(results), "matched": matched_count}
|
||
|
||
|
||
def main():
|
||
log("=" * 50)
|
||
log("销售线索自动回填 启动")
|
||
|
||
try:
|
||
token = get_fs_token()
|
||
conn = psycopg2.connect(
|
||
host=PG_HOST, port=PG_PORT, user=PG_USER,
|
||
password=get_pg_password(), dbname=PG_DB, connect_timeout=30
|
||
)
|
||
cur = conn.cursor()
|
||
|
||
total_processed = 0
|
||
total_matched = 0
|
||
|
||
for sheet_name, sheet_id in SHEET_IDS.items():
|
||
r = process_sheet(token, cur, sheet_name, sheet_id)
|
||
total_processed += r["processed"]
|
||
total_matched += r["matched"]
|
||
|
||
cur.close()
|
||
conn.close()
|
||
|
||
log(f"\n全部完成: 处理 {total_processed} 行, 匹配 {total_matched}, 未注册 {total_processed - total_matched}")
|
||
return 0
|
||
|
||
except Exception as e:
|
||
log(f"ERROR: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|