771 lines
28 KiB
Python
771 lines
28 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
销售线索全量刷新脚本 — XXTEA 精确匹配版
|
||
|
||
功能:
|
||
1. 读取「小龙」「吴迪」「成都」三个 sheet 的 E 列手机号
|
||
2. XXTEA 加密 → bi_vala_app_account.tel_encrypt 精确匹配 → 获取 account_id
|
||
3. 查询 PostgreSQL 获取用户订单/学习数据
|
||
4. 填写 H~V 列(自动列),V 列为操作更新时间
|
||
5. 将三个 sheet 中 K=是(已下单)的用户汇总到「订单汇总」sheet
|
||
|
||
规则(沿用 S2 规则):
|
||
① E→H: XXTEA 精确匹配, 查不到留空
|
||
② H→D/I/J: 只补空, 不覆盖已有值
|
||
③ K=是: 仅当 L(下单日) >= C(线索日期)
|
||
④ 全额退清: 所有订单都退费 → K/O/P/Q 全部清空
|
||
⑤ O/P/Q 0 留空, P 整元
|
||
⑥ G 列不动
|
||
|
||
用法:
|
||
python3 scripts/sales_leads_full_refresh.py
|
||
"""
|
||
|
||
import json, re, time, sys, os, requests, psycopg2
|
||
from datetime import datetime
|
||
from collections import defaultdict
|
||
from feishu_sheet_utils import FeishuSheetWriter
|
||
|
||
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
|
||
|
||
SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug"
|
||
|
||
SALES_SHEETS = [
|
||
("qJF4I", "小龙", "A3:V2607"),
|
||
("f975f0", "吴迪", "A3:V8149"),
|
||
("qJF4J", "成都", "A3:V2500"),
|
||
]
|
||
|
||
SUMMARY_SHEET_ID = "2smjwA"
|
||
|
||
CS_MAP = {"吴迪": "吴迪", "小龙": "小龙", "Tom": "Tom", "Bob": "Bob"}
|
||
|
||
GOODS_NAMES = {
|
||
57: "瓦拉英语level1·单季", 60: "瓦拉英语level1", 63: "瓦拉英语level1·单季",
|
||
31: "瓦拉英语年包", 32: "瓦拉英语单季度包", 33: "瓦拉英语level2", 54: "瓦拉英语季度包",
|
||
61: "瓦拉英语level1+2",
|
||
}
|
||
|
||
CHANNEL_MAP = {
|
||
"Apple App Store": "苹果", "科大讯飞学习机": "讯飞", "学而思学习机": "学而思",
|
||
"华为应用市场": "华为", "小米应用市场": "小米", "应用宝应用市场": "应用宝",
|
||
"希沃学习机": "希沃", "荣耀应用市场": "荣耀", "小度学习机": "小度",
|
||
"oppo应用市场": "OPPO", "vivo应用市场": "VIVO", "京东方学习机": "京东方",
|
||
"步步高学习机": "步步高", "作业帮学习机": "作业帮", "魅族应用市场": "魅族",
|
||
"官网": "官网",
|
||
}
|
||
|
||
LOG_FILE = "/var/log/xiaoxi_full_refresh.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 put_values(token, sheet_id, range_str, values):
|
||
url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values"
|
||
body = {"valueRange": {"range": f"{sheet_id}!{range_str}", "values": values}}
|
||
resp = requests.put(url, headers={
|
||
"Authorization": f"Bearer {token}",
|
||
"Content-Type": "application/json"
|
||
}, json=body, timeout=30)
|
||
r = resp.json()
|
||
if r.get("code") != 0:
|
||
log(f" ❌ {range_str}: code={r.get('code')} msg={r.get('msg')}")
|
||
return False
|
||
return True
|
||
|
||
|
||
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 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 parse_date_str(s):
|
||
"""'6月7日' → '2026-06-07', YYYY-MM-DD 原样返回"""
|
||
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)
|
||
if m:
|
||
year = datetime.now().year
|
||
return f"{year}-{int(m.group(1)):02d}-{int(m.group(2)):02d}"
|
||
return s
|
||
|
||
|
||
# ═══ Step 1: 解析三个销售 sheet ═══
|
||
|
||
def parse_sales_sheets(token):
|
||
all_data = {}
|
||
for sid, sname, rng in SALES_SHEETS:
|
||
rows = read_sheet(token, sid, rng)
|
||
entries = []
|
||
for idx, row in enumerate(rows):
|
||
row_num = idx + 3
|
||
if not row or all(not cell for cell in row):
|
||
continue
|
||
|
||
a_val = safe_cell(row, 0)
|
||
sales = None
|
||
for k, v in CS_MAP.items():
|
||
if k in a_val:
|
||
sales = v
|
||
break
|
||
if not sales:
|
||
continue
|
||
|
||
phone = ""
|
||
if len(row) > 4 and row[4]:
|
||
try:
|
||
phone = str(int(float(str(row[4]))))
|
||
except (ValueError, TypeError):
|
||
phone = str(row[4]).strip()
|
||
|
||
entries.append({
|
||
"row": row_num,
|
||
"sales": sales,
|
||
"nickname": safe_cell(row, 1),
|
||
"clue_date": safe_cell(row, 2),
|
||
"clue_date_parsed": parse_date_str(safe_cell(row, 2)),
|
||
"phone": phone,
|
||
"grade": safe_cell(row, 5),
|
||
"history": safe_cell(row, 6),
|
||
"existing": {
|
||
"D": safe_cell(row, 3),
|
||
"H": safe_cell(row, 7),
|
||
"I": safe_cell(row, 8),
|
||
"J": safe_cell(row, 9),
|
||
"K": safe_cell(row, 10),
|
||
"L": safe_cell(row, 11),
|
||
"M": safe_cell(row, 12),
|
||
"N": safe_cell(row, 13),
|
||
"O": safe_cell(row, 14),
|
||
"P": safe_cell(row, 15),
|
||
"Q": safe_cell(row, 16),
|
||
"R": safe_cell(row, 17),
|
||
"S": safe_cell(row, 18),
|
||
"T": safe_cell(row, 19),
|
||
"U": safe_cell(row, 20),
|
||
"V": safe_cell(row, 21),
|
||
},
|
||
})
|
||
|
||
all_data[sid] = entries
|
||
phone_cnt = sum(1 for e in entries if re.match(r'^\d{11}$', e["phone"]))
|
||
uid_cnt = sum(1 for e in entries if e["existing"]["H"] and e["existing"]["H"].isdigit())
|
||
log(f" [{sname}] {len(entries)}行, 手机号{phone_cnt}, 已有UID{uid_cnt}")
|
||
|
||
return all_data
|
||
|
||
|
||
# ═══ Step 2: XXTEA 加密 → PG tel_encrypt 精确匹配 ═══
|
||
|
||
def phone_to_uid_xxtea(all_entries):
|
||
phone_set = set()
|
||
for entries in all_entries.values():
|
||
for e in entries:
|
||
if re.match(r'^\d{11}$', e["phone"]):
|
||
phone_set.add(e["phone"])
|
||
|
||
if not phone_set:
|
||
log(" 无有效手机号")
|
||
return {}
|
||
|
||
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}")
|
||
|
||
log(f" 加密完成, 唯一密文: {len(phone_enc_map)}")
|
||
|
||
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())
|
||
phone_to_uid = {}
|
||
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)
|
||
time.sleep(0.05)
|
||
|
||
cur.close()
|
||
conn.close()
|
||
log(f" 精确匹配到 {len(phone_to_uid)} 个 UID (via XXTEA)")
|
||
return phone_to_uid
|
||
|
||
|
||
# ═══ Step 3: PostgreSQL 批量查询 ═══
|
||
|
||
def query_all_pg(all_entries, phone_map):
|
||
uid_set = set()
|
||
for entries in all_entries.values():
|
||
for e in entries:
|
||
if re.match(r'^\d{11}$', e["phone"]) and e["phone"] in phone_map:
|
||
uid_set.add(int(phone_map[e["phone"]]))
|
||
h_val = e["existing"]["H"]
|
||
if h_val and h_val.isdigit() and int(h_val) > 0:
|
||
uid_set.add(int(h_val))
|
||
|
||
uid_list = list(uid_set)
|
||
log(f" 有效 user_id: {len(uid_list)}")
|
||
|
||
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()
|
||
|
||
info = {uid: {
|
||
"reg_date": "", "download_channel": "", "trial_count": 0,
|
||
"has_order": False, "order_date": "", "order_channel": "", "product": "",
|
||
"gmv": 0, "refund": 0, "gsv": 0,
|
||
"activation": "", "lesson_progress": "", "lesson_time": "", "lesson_minutes": 0,
|
||
} for uid in uid_set}
|
||
|
||
# 3a. 注册信息
|
||
log(" 查询注册信息...")
|
||
reg_info = batch_in(cur,
|
||
"SELECT id, created_at, download_channel FROM bi_vala_app_account "
|
||
"WHERE id IN (%s) AND status=1 AND deleted_at IS NULL",
|
||
uid_list
|
||
)
|
||
for aid, created_at, dc in reg_info:
|
||
if aid in info:
|
||
info[aid]["reg_date"] = created_at.strftime("%Y-%m-%d") if created_at else ""
|
||
raw_ch = dc or ""
|
||
info[aid]["download_channel"] = CHANNEL_MAP.get(raw_ch, raw_ch)
|
||
|
||
# 3b. 体验节数
|
||
log(" 查询体验节数...")
|
||
trial_info = batch_in(cur,
|
||
"SELECT account_id, COUNT(*) FROM bi_user_course_detail "
|
||
"WHERE account_id IN (%s) AND expire_time IS NULL AND deleted_at IS NULL "
|
||
"GROUP BY account_id",
|
||
uid_list
|
||
)
|
||
for aid, cnt in trial_info:
|
||
if aid in info:
|
||
info[aid]["trial_count"] = cnt
|
||
|
||
# 3c. 订单信息
|
||
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 "
|
||
"AND order_status IN (3,4) ORDER BY pay_success_date DESC",
|
||
uid_list
|
||
)
|
||
user_orders = defaultdict(list)
|
||
for o in orders:
|
||
user_orders[o[0]].append(o)
|
||
|
||
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
|
||
|
||
for aid, olist in user_orders.items():
|
||
if aid not in info:
|
||
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_channel"] = latest[3] or ""
|
||
info[aid]["product"] = GOODS_NAMES.get(latest[4], f"商品{latest[4]}")
|
||
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
|
||
info[aid]["refund"] = total_refund
|
||
info[aid]["gsv"] = total_gmv - total_refund
|
||
|
||
# 3d. 激活课程
|
||
log(" 查询激活课程...")
|
||
try:
|
||
activations = batch_in(cur,
|
||
"SELECT account_id, season_package_level FROM bi_vala_seasonal_ticket "
|
||
"WHERE account_id IN (%s) AND status=1 AND deleted_at IS NULL "
|
||
"AND season_package_level IN ('A1','A2')",
|
||
uid_list
|
||
)
|
||
for aid, lvl in activations:
|
||
if aid in info:
|
||
info[aid]["activation"] = lvl
|
||
except Exception as ex:
|
||
log(f" 激活查询异常: {ex}")
|
||
|
||
# 3e. 角色信息
|
||
log(" 查询角色信息...")
|
||
char_info = batch_in(cur,
|
||
"SELECT account_id, id FROM bi_vala_app_character "
|
||
"WHERE account_id IN (%s) AND deleted_at IS NULL",
|
||
uid_list
|
||
)
|
||
account_chars = defaultdict(list)
|
||
char_to_account = {}
|
||
for aid, cid in char_info:
|
||
account_chars[aid].append(cid)
|
||
char_to_account[cid] = aid
|
||
char_ids = list(char_to_account.keys())
|
||
log(f" 角色数: {len(char_ids)}")
|
||
|
||
# 3f. 课程映射
|
||
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 "")
|
||
|
||
# 3g. 课时完成记录
|
||
log(" 查询课时完成记录...")
|
||
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} "
|
||
f"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 not ch_data:
|
||
continue
|
||
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 ex:
|
||
log(f" 警告 {table}: {ex}")
|
||
|
||
# 3h. 学习总耗时
|
||
log(" 查询学习耗时...")
|
||
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} "
|
||
f"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 ex:
|
||
log(f" 警告 {table}: {ex}")
|
||
|
||
cur.close()
|
||
conn.close()
|
||
|
||
# 汇总到 account 级别
|
||
for aid in uid_set:
|
||
chars = account_chars.get(aid, [])
|
||
best_time = None
|
||
best_ch = None
|
||
total_ms = 0
|
||
for cid in chars:
|
||
play = char_plays.get(cid)
|
||
if not play:
|
||
continue
|
||
if 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"]
|
||
|
||
info[aid]["lesson_minutes"] = round(total_ms / 60000, 1)
|
||
if info[aid]["lesson_minutes"] == int(info[aid]["lesson_minutes"]):
|
||
info[aid]["lesson_minutes"] = int(info[aid]["lesson_minutes"])
|
||
|
||
if best_ch:
|
||
ch_id, (cl, cs, cu, cl2) = best_ch
|
||
info[aid]["lesson_progress"] = f"{cl}-{cs}-{cu}-{cl2}"
|
||
info[aid]["lesson_time"] = best_time.strftime("%Y-%m-%d") if best_time else ""
|
||
|
||
log(f" 数据库查询完成")
|
||
return info
|
||
|
||
|
||
# ═══ Step 4: 写入销售三表 H~V 列 ═══
|
||
|
||
def write_sales_sheets(token, all_entries, phone_map, db_info):
|
||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
for sid, sname, _ in SALES_SHEETS:
|
||
entries = all_entries[sid]
|
||
log(f" 写入 {sname} ({sid})...")
|
||
|
||
# 按连续行分组
|
||
groups = []
|
||
cur_grp = []
|
||
for e in entries:
|
||
if not cur_grp or e["row"] == cur_grp[-1]["row"] + 1:
|
||
cur_grp.append(e)
|
||
else:
|
||
groups.append(cur_grp)
|
||
cur_grp = [e]
|
||
if cur_grp:
|
||
groups.append(cur_grp)
|
||
|
||
for g in groups:
|
||
sr, er = g[0]["row"], g[-1]["row"]
|
||
|
||
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 = [], [], [], []
|
||
|
||
for e in g:
|
||
phone = e["phone"]
|
||
existing = e["existing"]
|
||
clue_date = e["clue_date_parsed"]
|
||
|
||
# 确定 UID
|
||
aid = 0
|
||
uid_str = ""
|
||
if re.match(r'^\d{11}$', phone) and phone in phone_map:
|
||
uid_str = phone_map[phone]
|
||
aid = int(uid_str)
|
||
elif existing["H"] and existing["H"].isdigit() and int(existing["H"]) > 0:
|
||
uid_str = existing["H"]
|
||
aid = int(existing["H"])
|
||
|
||
# H: UID — XXTEA 匹配到就写,否则留空
|
||
if re.match(r'^\d{11}$', phone) and phone in phone_map:
|
||
h_vals.append([phone_map[phone]])
|
||
elif re.match(r'^\d{11}$', phone):
|
||
h_vals.append([""])
|
||
elif existing["H"] and existing["H"].isdigit():
|
||
h_vals.append([existing["H"]])
|
||
else:
|
||
h_vals.append([""])
|
||
|
||
if aid > 0 and aid in db_info:
|
||
di = db_info[aid]
|
||
|
||
# D: 体验节数 — 只补空
|
||
if existing["D"]:
|
||
d_vals.append([existing["D"]])
|
||
else:
|
||
tc = di["trial_count"]
|
||
d_vals.append([tc if tc > 0 else ""])
|
||
|
||
# I: 注册日 — 只补空
|
||
if existing["I"]:
|
||
i_vals.append([existing["I"]])
|
||
else:
|
||
i_vals.append([di["reg_date"]])
|
||
|
||
# J: 下载渠道 — 只补空
|
||
if existing["J"]:
|
||
j_vals.append([existing["J"]])
|
||
else:
|
||
j_vals.append([di["download_channel"]])
|
||
|
||
# 全额退清判定
|
||
gmv_int = int(di["gmv"])
|
||
refund_int = int(di["refund"])
|
||
gsv_int = int(di["gsv"])
|
||
is_full_refund = (gmv_int > 0 and gmv_int == refund_int)
|
||
|
||
# K=是: 有订单 且 非全额退清 且 L(下单日) >= C(线索日期)
|
||
order_date = di["order_date"]
|
||
should_k_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
|
||
|
||
if is_full_refund:
|
||
k_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 ""])
|
||
|
||
l_vals.append([order_date])
|
||
m_vals.append([di["order_channel"]])
|
||
n_vals.append([di["product"] if di["has_order"] else ""])
|
||
|
||
act = di["activation"]
|
||
if act:
|
||
r_vals.append([f"{act}体验课" if act in ("A1", "A2") else act])
|
||
else:
|
||
r_vals.append([""])
|
||
|
||
s_vals.append([di["lesson_progress"] if di["lesson_progress"] else ""])
|
||
t_vals.append([di["lesson_time"]])
|
||
lm = di["lesson_minutes"]
|
||
u_vals.append([lm if lm > 0 else ""])
|
||
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]:
|
||
arr.append([""])
|
||
|
||
v_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),
|
||
]
|
||
for col_letter, vals in cols:
|
||
put_values(token, sid, f"{col_letter}{sr}:{col_letter}{er}", vals)
|
||
time.sleep(0.1)
|
||
|
||
log(f" {sname}: {len(entries)} 行写入完成")
|
||
|
||
|
||
# ═══ Step 5: 汇总到「订单汇总」sheet ═══
|
||
|
||
def clear_summary_sheet(token):
|
||
"""先清空订单汇总 sheet 的旧数据(A~V列,从第3行开始),再写入新数据。"""
|
||
log(" 检查订单汇总 sheet 现有数据...")
|
||
try:
|
||
rows = read_sheet(token, SUMMARY_SHEET_ID, "A3:A5000")
|
||
last_data_row = 2
|
||
for i, row in enumerate(rows):
|
||
if row and any(cell for cell in row if cell):
|
||
last_data_row = 3 + i
|
||
|
||
if last_data_row < 3:
|
||
log(" 订单汇总 sheet 无旧数据,跳过清空")
|
||
return
|
||
|
||
log(f" 清空 A3:V{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)
|
||
log(" 清空完成")
|
||
except Exception as e:
|
||
log(f" 清空异常: {e}")
|
||
|
||
|
||
def write_summary_sheet(token, all_entries, phone_map, db_info):
|
||
"""
|
||
将三个销售 sheet 中 K=是(已下单)的行汇总到「订单汇总」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: 有效成单(公式)
|
||
"""
|
||
# 先清空旧数据
|
||
clear_summary_sheet(token)
|
||
|
||
log(" 汇总订单数据...")
|
||
|
||
# 收集所有 K=是 的行
|
||
summary_rows = []
|
||
for sid, sname, _ in SALES_SHEETS:
|
||
entries = all_entries[sid]
|
||
for e in entries:
|
||
phone = e["phone"]
|
||
existing = e["existing"]
|
||
|
||
# 确定 UID 和 db_info
|
||
aid = 0
|
||
if re.match(r'^\d{11}$', phone) and phone in phone_map:
|
||
aid = int(phone_map[phone])
|
||
elif existing["H"] and existing["H"].isdigit() and int(existing["H"]) > 0:
|
||
aid = int(existing["H"])
|
||
|
||
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", "")
|
||
clue_date = e["clue_date_parsed"]
|
||
if has_order and clue_date and order_date:
|
||
if order_date < clue_date:
|
||
has_order = False
|
||
|
||
if not has_order:
|
||
continue
|
||
|
||
# 构建汇总行
|
||
row_data = [
|
||
e["sales"], # A: 销售归属
|
||
e["nickname"], # B: 微信昵称
|
||
e["clue_date"], # C: 进线日期
|
||
di.get("trial_count", 0) or "", # D: 体验节数
|
||
phone, # E: 手机号
|
||
e["grade"], # F: 用户年级
|
||
e["history"], # G: 课史/跟进
|
||
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: 有效成单 — 公式
|
||
]
|
||
summary_rows.append(row_data)
|
||
|
||
log(f" 共 {len(summary_rows)} 条下单记录待汇总")
|
||
|
||
if not summary_rows:
|
||
log(" 无下单记录,跳过汇总")
|
||
return
|
||
|
||
# 写入订单汇总 sheet(从第3行开始,覆盖 A~V 列,W/X 列保留公式)
|
||
# 使用安全写入工具,自动分批遵守 5000 格上限
|
||
# 22 列 → 单批最大 200 行(200×22=4400 格 ≤ 5000)
|
||
writer = FeishuSheetWriter(SPREADSHEET_TOKEN, token)
|
||
|
||
# 构建 A~V 的值数组(22列),确保每行长度一致
|
||
values = []
|
||
for row_data in summary_rows:
|
||
padded = row_data[:22]
|
||
while len(padded) < 22:
|
||
padded.append("")
|
||
values.append(padded)
|
||
|
||
writer.write(SUMMARY_SHEET_ID, start_row=3, rows=values, cols=22)
|
||
|
||
log(f" 订单汇总写入完成, 共 {len(summary_rows)} 行")
|
||
|
||
|
||
# ═══ Main ═══
|
||
|
||
def main():
|
||
log("=" * 60)
|
||
log("销售线索全量刷新 (XXTEA精确匹配版) 启动")
|
||
|
||
try:
|
||
token = get_fs_token()
|
||
|
||
log("Step 1: 解析销售三表")
|
||
all_entries = parse_sales_sheets(token)
|
||
|
||
log("Step 2: XXTEA 加密 → PG tel_encrypt 精确匹配")
|
||
phone_map = phone_to_uid_xxtea(all_entries)
|
||
|
||
log("Step 3: PostgreSQL 批量查询")
|
||
db_info = query_all_pg(all_entries, phone_map)
|
||
|
||
log("Step 4: 写入销售三表 H~V 列")
|
||
write_sales_sheets(token, all_entries, phone_map, db_info)
|
||
|
||
log("Step 5: 汇总到「订单汇总」sheet")
|
||
write_summary_sheet(token, all_entries, phone_map, db_info)
|
||
|
||
log("✅ 全量刷新完成")
|
||
return 0
|
||
except Exception as e:
|
||
log(f"❌ ERROR: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|