396 lines
13 KiB
Python
396 lines
13 KiB
Python
#!/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, out_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_no 和 out_trade_no 用于退费匹配
|
||
trade_nos = [o[1] for o in orders if o[1]]
|
||
out_trade_nos = [o[2] for o in orders if o[2]]
|
||
all_order_nos = list(set(trade_nos + out_trade_nos))
|
||
refund_map = {}
|
||
if all_order_nos:
|
||
# 同时按 trade_no 和 out_trade_no 匹配退费记录,SUM 累加退费金额 [李承龙确认 2026-06-18]
|
||
refunds_by_trade = batch_in(cur,
|
||
"SELECT trade_no, SUM(refund_amount_int) FROM bi_refund_order "
|
||
"WHERE trade_no IN (%s) AND status=3 GROUP BY trade_no",
|
||
all_order_nos
|
||
)
|
||
for tn, amt in refunds_by_trade:
|
||
refund_map[tn] = refund_map.get(tn, 0) + (amt or 0)
|
||
refunds_by_out = batch_in(cur,
|
||
"SELECT out_trade_no, SUM(refund_amount_int) FROM bi_refund_order "
|
||
"WHERE out_trade_no IN (%s) AND status=3 GROUP BY out_trade_no",
|
||
all_order_nos
|
||
)
|
||
for otn, amt in refunds_by_out:
|
||
refund_map[otn] = refund_map.get(otn, 0) + (amt or 0)
|
||
|
||
cur.close()
|
||
conn.close()
|
||
|
||
uid_orders = defaultdict(list)
|
||
for o in orders:
|
||
aid = o[0]
|
||
tn = o[1]
|
||
otn = o[2]
|
||
gmv = o[6] / 100.0
|
||
order_status = o[7]
|
||
# 退费金额:同时按 trade_no 和 out_trade_no 匹配,SUM 累加 [李承龙确认 2026-06-18]
|
||
refund = (refund_map.get(tn, 0) + refund_map.get(otn, 0)) / 100.0
|
||
# 退费判定:order_status=4 直接视为退费;order_status=3 需退费表有记录
|
||
is_refunded = (order_status == 4) or (order_status == 3 and refund > 0)
|
||
gsv = gmv - refund
|
||
|
||
dt = o[3]
|
||
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[4] or "",
|
||
"product": GOODS_NAMES.get(o[5], f"商品{o[5]}"),
|
||
"gmv": int(gmv),
|
||
"refund": int(refund),
|
||
"gsv": int(gsv),
|
||
"order_status": order_status,
|
||
"is_refunded": is_refunded,
|
||
})
|
||
|
||
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_refunded = o.get("is_refunded", False)
|
||
is_full_refund = (gmv_val > 0 and gmv_val == refund_val) or (o["order_status"] == 4 and refund_val == 0)
|
||
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())
|