376 lines
16 KiB
Python
376 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
销售日报 v1 — 修复销转总览(计算值)+ 新建投放日报
|
||
"""
|
||
import json, requests, os, re, time, psycopg2
|
||
from collections import defaultdict
|
||
from datetime import datetime, timedelta
|
||
|
||
CRED_DIR = "/root/.openclaw/credentials/xiaoxi"
|
||
SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug"
|
||
SHEET_MAP = {"吴迪": "f975f0", "小龙": "qJF4I", "成都": "qJF4J"}
|
||
TARGET_MONTHS = [3, 4, 5, 6]
|
||
COSTS = {3: 243, 4: 246, 5: 241}
|
||
SALES_ORDER = ["小龙", "吴迪", "Bob", "Tom"]
|
||
|
||
PG_HOST = "bj-postgres-16pob4sg.sql.tencentcdb.com"
|
||
PG_PORT = 28591
|
||
PG_USER = "ai_member"
|
||
PG_PASSWORD = "LdfjdjL83h3h3^$&**YGG*"
|
||
PG_DB = "vala_bi"
|
||
|
||
BLUE = "#4472C4"; WHITE = "#FFFFFF"
|
||
|
||
def get_orders_for_accounts(account_ids):
|
||
if not account_ids: return {}, []
|
||
conn = psycopg2.connect(host=PG_HOST, port=PG_PORT, user=PG_USER, password=PG_PASSWORD, dbname=PG_DB)
|
||
cur = conn.cursor()
|
||
ph = ','.join(['%s'] * len(account_ids))
|
||
cur.execute(f"""
|
||
SELECT o.account_id, o.trade_no, o.pay_success_date, o.key_from,
|
||
o.pay_amount_int, o.order_status
|
||
FROM bi_vala_order o JOIN bi_vala_app_account a ON o.account_id = a.id AND a.status = 1
|
||
WHERE o.account_id IN ({ph}) AND o.pay_success_date IS NOT NULL AND o.order_status IN (3, 4)
|
||
ORDER BY o.account_id, o.pay_success_date
|
||
""", list(account_ids))
|
||
orders_by_account = defaultdict(list)
|
||
all_trade_nos = []
|
||
for row in cur.fetchall():
|
||
aid, trade_no, pay_date, key_from, amount, status = row
|
||
orders_by_account[aid].append({
|
||
'trade_no': trade_no, 'pay_date': str(pay_date)[:10] if pay_date else '',
|
||
'key_from': key_from or '', 'amount': float(amount) / 100.0 if amount else 0,
|
||
'status': status,
|
||
})
|
||
all_trade_nos.append(trade_no)
|
||
cur.close(); conn.close()
|
||
return orders_by_account, all_trade_nos
|
||
|
||
def get_refund_for_trade_nos(trade_nos):
|
||
if not trade_nos: return {}
|
||
conn = psycopg2.connect(host=PG_HOST, port=PG_PORT, user=PG_USER, password=PG_PASSWORD, dbname=PG_DB)
|
||
cur = conn.cursor()
|
||
refunds = {}
|
||
for i in range(0, len(trade_nos), 500):
|
||
batch = trade_nos[i:i+500]
|
||
ph = ','.join(['%s'] * len(batch))
|
||
cur.execute(f"""
|
||
SELECT o.trade_no, COALESCE(SUM(r.refund_amount::numeric), 0)/100.0
|
||
FROM bi_vala_order o JOIN bi_refund_order r ON o.trade_no = r.trade_no
|
||
WHERE r.status = 3 AND o.order_status = 4 AND o.trade_no IN ({ph})
|
||
GROUP BY o.trade_no
|
||
""", batch)
|
||
for row in cur.fetchall(): refunds[row[0]] = float(row[1])
|
||
cur.close(); conn.close()
|
||
return refunds
|
||
|
||
def classify_channel(key_from):
|
||
kf = str(key_from).strip()
|
||
if not kf: return "其他"
|
||
if kf.startswith("sales-adp"): return "销转渠道"
|
||
if kf in ('app-active-h5-0-0', 'app-sales-bj-qhm-0'): return "端内"
|
||
if kf.startswith("miniprogram"): return "端内"
|
||
if kf.startswith("newmedia-daren") or "daren" in kf.lower(): return "达人渠道"
|
||
if kf.startswith("newmedia-dianpu-xhs"): return "直购渠道"
|
||
if kf.startswith("newmedia-dianpu-douyin"): return "直购渠道"
|
||
if "jingxuan" in kf and "douyin" in kf.lower(): return "直购渠道"
|
||
if kf.startswith("stream-xhs"): return "直购渠道"
|
||
if "wxxd" in kf: return "直购渠道"
|
||
if kf.startswith("partner"): return "直购渠道"
|
||
if kf.startswith("newmedia-dianpu-wwxx"): return "达人渠道"
|
||
if kf.startswith("newmedia-"): return "直购渠道"
|
||
return "其他"
|
||
|
||
def get_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):
|
||
resp = requests.get(
|
||
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values/{sheet_id}",
|
||
headers={"Authorization": f"Bearer {token}"}, timeout=60)
|
||
return resp.json()["data"]["valueRange"]["values"][2:]
|
||
|
||
def parse_month(date_str):
|
||
m = re.match(r'(\d+)月', str(date_str).strip())
|
||
return int(m.group(1)) if m else None
|
||
|
||
def parse_row(row):
|
||
def get(idx, default=""):
|
||
return str(row[idx]).strip() if idx < len(row) and row[idx] else default
|
||
return {
|
||
"sales": get(0), "nickname": get(1), "lead_date": get(2),
|
||
"trial_lessons": get(3), "phone": get(4), "grade": get(5),
|
||
"history": get(6), "user_id": get(7), "reg_date": get(8),
|
||
"download_channel": get(9), "is_order": get(10), "order_date": get(11),
|
||
"order_channel": get(12), "product": get(13), "gmv": get(14),
|
||
"refund": get(15), "gsv": get(16), "activated": get(17),
|
||
"progress": get(18), "last_study": get(19), "study_min": get(20),
|
||
"update_time": get(21),
|
||
}
|
||
|
||
def safe_int(val):
|
||
try: return int(float(val))
|
||
except: return 0
|
||
|
||
def fmt_pct(val):
|
||
return f"{val*100:.2f}%"
|
||
|
||
def fmt_pct1(val):
|
||
return f"{val*100:.1f}%"
|
||
|
||
def get_or_create_sheet(token, title):
|
||
resp = requests.get(
|
||
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/metainfo",
|
||
headers={"Authorization": f"Bearer {token}"}, timeout=15)
|
||
for s in resp.json().get("data", {}).get("sheets", []):
|
||
if s.get("title") == title:
|
||
sid = s["sheetId"]
|
||
print(f" 删除旧 sheet: {title} ({sid})")
|
||
requests.post(
|
||
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/sheets_batch_update",
|
||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||
json={"requests": [{"deleteSheet": {"sheetId": sid}}]}, timeout=30)
|
||
time.sleep(0.5)
|
||
break
|
||
resp = requests.post(
|
||
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/sheets_batch_update",
|
||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||
json={"requests": [{"addSheet": {"properties": {"title": title, "index": 10}}}]}, timeout=30)
|
||
result = resp.json()
|
||
if result.get("code") == 0:
|
||
replies = result["data"]["replies"]
|
||
if replies and "addSheet" in replies[0]:
|
||
return replies[0]["addSheet"]["properties"]["sheetId"]
|
||
print(f" 创建sheet失败: {result}")
|
||
return None
|
||
|
||
def write_values(token, sheet_id, range_str, values):
|
||
resp = requests.put(
|
||
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values",
|
||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||
json={"valueRange": {"range": f"{sheet_id}!{range_str}", "values": values}}, timeout=30)
|
||
code = resp.json().get("code")
|
||
if code != 0: print(f" ❌ {range_str}: {resp.json()}")
|
||
return code == 0
|
||
|
||
def apply_style(token, sheet_id, col_start, row_start, col_end, row_end, style):
|
||
rng = f"{sheet_id}!{col_start}{row_start}:{col_end}{row_end}"
|
||
requests.put(
|
||
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/style",
|
||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||
json={"appendStyle": {"range": rng, "style": style}}, timeout=30)
|
||
|
||
def header_style():
|
||
return {"backcolor": BLUE, "fontColor": WHITE, "bold": True, "fontSize": 10}
|
||
|
||
def main():
|
||
token = get_token()
|
||
|
||
print("读取销售明细...")
|
||
all_leads = []
|
||
for sheet_label, sheet_id in SHEET_MAP.items():
|
||
rows = read_sheet(token, sheet_id)
|
||
for row in rows: all_leads.append(parse_row(row))
|
||
print(f" 总计: {len(all_leads)} 条线索")
|
||
|
||
by_month = defaultdict(list)
|
||
for r in all_leads:
|
||
m = parse_month(r["lead_date"])
|
||
if m in TARGET_MONTHS: by_month[m].append(r)
|
||
|
||
all_user_ids = set()
|
||
for r in all_leads:
|
||
uid = safe_int(r["user_id"])
|
||
if uid > 0: all_user_ids.add(uid)
|
||
print(f"查询数据库订单({len(all_user_ids)} 个用户)...")
|
||
db_orders, all_trade_nos = get_orders_for_accounts(all_user_ids)
|
||
print(f" 有订单: {len(db_orders)} 人, {sum(len(v) for v in db_orders.values())} 笔")
|
||
|
||
print(f"查询退款({len(all_trade_nos)} 笔)...")
|
||
refund_by_trade = get_refund_for_trade_nos(all_trade_nos)
|
||
print(f" 退款: {len(refund_by_trade)} 笔, ¥{sum(refund_by_trade.values()):,.0f}")
|
||
|
||
for r in all_leads:
|
||
uid = safe_int(r["user_id"])
|
||
r["_db_orders"] = db_orders.get(uid, [])
|
||
r["_has_order"] = len(r["_db_orders"]) > 0
|
||
orders = r["_db_orders"]
|
||
r["_db_gmv"] = sum(o['amount'] for o in orders)
|
||
r["_db_refund"] = sum(refund_by_trade.get(o['trade_no'], 0) for o in orders)
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 1. 修复销转总览 — 全部计算值
|
||
# ═══════════════════════════════════════════
|
||
print("\n=== 修复销转总览 ===")
|
||
sid_ov = get_or_create_sheet(token, "📊 销转总览")
|
||
ov_hdr = [["月份", "销售", "线索数", "订单数", "转化率",
|
||
"GMV", "退款金额", "GSV", "投放消耗", "达人GMV",
|
||
"达人佣金(40%)", "总成本", "退后ROI"]]
|
||
write_values(token, sid_ov, "A1:M1", ov_hdr)
|
||
apply_style(token, sid_ov, "A", 1, "M", 1, header_style())
|
||
|
||
ov_row = 2
|
||
for m in TARGET_MONTHS:
|
||
cpl = COSTS.get(m, 0)
|
||
# 合计
|
||
pa = by_month[m]; oa = [r for r in pa if r["_has_order"]]
|
||
lc = len(pa); oc = len(oa)
|
||
gmv = sum(r["_db_gmv"] for r in oa)
|
||
refund = sum(r["_db_refund"] for r in oa)
|
||
gsv = round(gmv - refund, 2)
|
||
dg = sum(o2['amount'] for r in oa for o2 in r["_db_orders"] if classify_channel(o2['key_from']) == "达人渠道")
|
||
ad = lc * cpl if cpl > 0 else 0
|
||
comm = round(dg * 0.4, 2)
|
||
tc = round(ad + comm, 2)
|
||
roi = round(gsv / tc, 2) if tc > 0 else 0
|
||
conv = fmt_pct1(oc / lc) if lc > 0 else "0.0%"
|
||
|
||
write_values(token, sid_ov, f"A{ov_row}:M{ov_row}", [[
|
||
f"{m}月", "合计", lc, oc, conv,
|
||
gmv, refund, gsv,
|
||
ad if cpl > 0 else "-", dg, comm,
|
||
tc if cpl > 0 else "-", roi if cpl > 0 else "-"
|
||
]])
|
||
ov_row += 1
|
||
|
||
for sales in SALES_ORDER:
|
||
leads = [r for r in by_month[m] if r["sales"] == sales]
|
||
if not leads: continue
|
||
ordered = [r for r in leads if r["_has_order"]]
|
||
lc2 = len(leads); oc2 = len(ordered)
|
||
gmv2 = sum(r["_db_gmv"] for r in ordered)
|
||
refund2 = sum(r["_db_refund"] for r in ordered)
|
||
gsv2 = round(gmv2 - refund2, 2)
|
||
dg2 = sum(o2['amount'] for r in ordered for o2 in r["_db_orders"] if classify_channel(o2['key_from']) == "达人渠道")
|
||
ad2 = lc2 * cpl if cpl > 0 else 0
|
||
comm2 = round(dg2 * 0.4, 2)
|
||
tc2 = round(ad2 + comm2, 2)
|
||
roi2 = round(gsv2 / tc2, 2) if tc2 > 0 else 0
|
||
conv2 = fmt_pct1(oc2 / lc2) if lc2 > 0 else "0.0%"
|
||
|
||
write_values(token, sid_ov, f"A{ov_row}:M{ov_row}", [[
|
||
f"{m}月", sales, lc2, oc2, conv2,
|
||
gmv2, refund2, gsv2,
|
||
ad2 if cpl > 0 else "-", dg2, comm2,
|
||
tc2 if cpl > 0 else "-", roi2 if cpl > 0 else "-"
|
||
]])
|
||
ov_row += 1
|
||
if (ov_row - 2) % 5 == 0: time.sleep(0.5)
|
||
print(f" ✅ {ov_row - 2} 行")
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 2. 新建投放日报
|
||
# ═══════════════════════════════════════════
|
||
print("\n=== 新建投放日报 ===")
|
||
sid_daily = get_or_create_sheet(token, "📊 投放日报")
|
||
|
||
row = 1
|
||
|
||
# --- 标题 ---
|
||
write_values(token, sid_daily, f"A{row}:A{row}", [["一、整月汇总"]])
|
||
apply_style(token, sid_daily, "A", row, "A", row, {"bold": True, "fontSize": 12})
|
||
row += 1
|
||
|
||
# 表头
|
||
daily_hdr = [["月份", "投放消耗", "总成本", "企微线索", "线索成本", "GMV", "GSV", "ROI", "转化率"]]
|
||
write_values(token, sid_daily, f"A{row}:I{row}", daily_hdr)
|
||
apply_style(token, sid_daily, "A", row, "I", row, header_style())
|
||
row += 1
|
||
|
||
for m in [3, 4, 5]:
|
||
pa = by_month[m]; oa = [r for r in pa if r["_has_order"]]
|
||
lc = len(pa); oc = len(oa)
|
||
gmv = sum(r["_db_gmv"] for r in oa)
|
||
refund = sum(r["_db_refund"] for r in oa)
|
||
gsv = round(gmv - refund, 2)
|
||
cpl = COSTS.get(m, 0)
|
||
ad_spend = lc * cpl
|
||
dg = sum(o2['amount'] for r in oa for o2 in r["_db_orders"] if classify_channel(o2['key_from']) == "达人渠道")
|
||
comm = round(dg * 0.4, 2)
|
||
tc = round(ad_spend + comm, 2)
|
||
roi = round(gsv / tc, 2) if tc > 0 else 0
|
||
conv = fmt_pct(oc / lc) if lc > 0 else "0.00%"
|
||
|
||
write_values(token, sid_daily, f"A{row}:I{row}", [[
|
||
f"{m}月", ad_spend, tc, lc, cpl, gmv, gsv, roi, conv
|
||
]])
|
||
row += 1
|
||
|
||
row += 1 # 空行
|
||
|
||
# --- 代理商日报 ---
|
||
write_values(token, sid_daily, f"A{row}:A{row}", [["二、代理商日报(待补充数据源)"]])
|
||
apply_style(token, sid_daily, "A", row, "A", row, {"bold": True, "fontSize": 12})
|
||
row += 1
|
||
|
||
agent_hdr = [["日期", "代理商", "消耗", "线索", "成本", "开口率", "留资率"]]
|
||
write_values(token, sid_daily, f"A{row}:G{row}", agent_hdr)
|
||
apply_style(token, sid_daily, "A", row, "G", row, header_style())
|
||
row += 1
|
||
# 占位:谦禾/云智/YTL 数据源待确认
|
||
write_values(token, sid_daily, f"A{row}:A{row}", [["⚠️ 代理商消耗数据来源待确认(谦禾/云智/YTL)"]])
|
||
row += 2
|
||
|
||
# --- 线索·行课·销转 ---
|
||
write_values(token, sid_daily, f"A{row}:A{row}", [["三、线索·行课·销转(当月累计)"]])
|
||
apply_style(token, sid_daily, "A", row, "A", row, {"bold": True, "fontSize": 12})
|
||
row += 1
|
||
|
||
funnel_hdr = [["月份", "销售", "月线索", "订单", "GMV", "转化率", "拿号率", "注册率", "首课率", "5课率"]]
|
||
write_values(token, sid_daily, f"A{row}:J{row}", funnel_hdr)
|
||
apply_style(token, sid_daily, "A", row, "J", row, header_style())
|
||
row += 1
|
||
|
||
for m in [3, 4, 5]:
|
||
# 合计
|
||
pa = by_month[m]; oa = [r for r in pa if r["_has_order"]]
|
||
lc = len(pa); oc = len(oa)
|
||
gmv = sum(r["_db_gmv"] for r in oa)
|
||
hp = len([r for r in pa if r["phone"] and r["phone"] != "未注册"])
|
||
reg = len([r for r in pa if r["user_id"] and r["user_id"] != "未注册"])
|
||
trial = [safe_int(r["trial_lessons"]) for r in pa]
|
||
first = len([t for t in trial if t >= 1])
|
||
fifth = len([t for t in trial if t >= 5])
|
||
|
||
write_values(token, sid_daily, f"A{row}:J{row}", [[
|
||
f"{m}月", "合计", lc, oc, gmv,
|
||
fmt_pct(oc / lc) if lc else "0.00%",
|
||
fmt_pct(hp / lc) if lc else "0.00%",
|
||
fmt_pct(reg / lc) if lc else "0.00%",
|
||
fmt_pct(first / lc) if lc else "0.00%",
|
||
fmt_pct(fifth / lc) if lc else "0.00%",
|
||
]])
|
||
row += 1
|
||
|
||
for sales in SALES_ORDER:
|
||
leads = [r for r in by_month[m] if r["sales"] == sales]
|
||
if not leads: continue
|
||
ordered = [r for r in leads if r["_has_order"]]
|
||
lc2 = len(leads); oc2 = len(ordered)
|
||
gmv2 = sum(r["_db_gmv"] for r in ordered)
|
||
hp2 = len([r for r in leads if r["phone"] and r["phone"] != "未注册"])
|
||
reg2 = len([r for r in leads if r["user_id"] and r["user_id"] != "未注册"])
|
||
trial2 = [safe_int(r["trial_lessons"]) for r in leads]
|
||
first2 = len([t for t in trial2 if t >= 1])
|
||
fifth2 = len([t for t in trial2 if t >= 5])
|
||
|
||
write_values(token, sid_daily, f"A{row}:J{row}", [[
|
||
f"{m}月", sales, lc2, oc2, gmv2,
|
||
fmt_pct(oc2 / lc2) if lc2 else "0.00%",
|
||
fmt_pct(hp2 / lc2) if lc2 else "0.00%",
|
||
fmt_pct(reg2 / lc2) if lc2 else "0.00%",
|
||
fmt_pct(first2 / lc2) if lc2 else "0.00%",
|
||
fmt_pct(fifth2 / lc2) if lc2 else "0.00%",
|
||
]])
|
||
row += 1
|
||
if (row - 1) % 5 == 0: time.sleep(0.5)
|
||
|
||
print(f" ✅ 日报 {row - 1} 行")
|
||
print("\n✅ 全部完成")
|
||
|
||
if __name__ == "__main__":
|
||
main()
|