ETL 完成
This commit is contained in:
@@ -0,0 +1,585 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""生成 2025年10-12月 助教排行榜 + 助教详情表(CSV + MD)。
|
||||
|
||||
输出目录:etl_billiards/docs/table_2025-12-19
|
||||
|
||||
注意:客户流水/充值归因涉及“多助教/多订单命中”时按全额复制计入,会导致助教汇总>门店汇总,表格说明会写明。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
from typing import Any
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
|
||||
SITE_ID = 2790685415443269
|
||||
TZ = "Asia/Shanghai"
|
||||
|
||||
WIN_OCT = ("2025-10-01 00:00:00+08", "2025-11-01 00:00:00+08")
|
||||
WIN_NOV = ("2025-11-01 00:00:00+08", "2025-12-01 00:00:00+08")
|
||||
WIN_DEC = ("2025-12-01 00:00:00+08", "2026-01-01 00:00:00+08")
|
||||
WIN_ALL = (WIN_OCT[0], WIN_DEC[1])
|
||||
|
||||
MONTHS = [
|
||||
("2025-10", "10月", WIN_OCT),
|
||||
("2025-11", "11月", WIN_NOV),
|
||||
("2025-12", "12月", WIN_DEC),
|
||||
]
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
ENV_PATH = REPO_ROOT / "etl_billiards" / ".env"
|
||||
OUT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SqlBlock:
|
||||
title: str
|
||||
sql: str
|
||||
|
||||
|
||||
def read_pg_dsn() -> str:
|
||||
text = ENV_PATH.read_text(encoding="utf-8")
|
||||
m = re.search(r"^PG_DSN=(.+)$", text, re.M)
|
||||
if not m:
|
||||
raise RuntimeError(f"未在 {ENV_PATH} 中找到 PG_DSN")
|
||||
return m.group(1).strip()
|
||||
|
||||
|
||||
def conn():
|
||||
return psycopg2.connect(read_pg_dsn(), connect_timeout=10)
|
||||
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
name = name.strip()
|
||||
name = re.sub(r"[<>:\"/\\|?*]+", "_", name)
|
||||
name = re.sub(r"\s+", " ", name)
|
||||
return name
|
||||
|
||||
|
||||
def d(v: Any) -> Decimal:
|
||||
if v is None:
|
||||
return Decimal("0")
|
||||
if isinstance(v, Decimal):
|
||||
return v
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def fmt_money(v: Any) -> str:
|
||||
return f"{d(v):.2f}"
|
||||
|
||||
|
||||
def fmt_hours(v: Any, digits: int = 2) -> str:
|
||||
q = Decimal("1").scaleb(-digits)
|
||||
return f"{d(v).quantize(q):f}h"
|
||||
|
||||
|
||||
def write_csv(path: Path, title: str, description: str, header_rows: list[list[str]], rows: list[list[Any]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", newline="", encoding="utf-8") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow([title])
|
||||
w.writerow([description])
|
||||
w.writerow([])
|
||||
for hr in header_rows:
|
||||
w.writerow(hr)
|
||||
for r in rows:
|
||||
w.writerow(["" if v is None else v for v in r])
|
||||
|
||||
|
||||
def write_csv_sections(path: Path, title: str, description: str, section_rows: list[list[Any]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", newline="", encoding="utf-8") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow([title])
|
||||
w.writerow([description])
|
||||
w.writerow([])
|
||||
for r in section_rows:
|
||||
w.writerow(["" if v is None else v for v in r])
|
||||
|
||||
|
||||
def write_md(path: Path, title: str, thinking: str, description: str, sql_blocks: list[SqlBlock]) -> None:
|
||||
parts: list[str] = []
|
||||
parts.append(f"# {title}\n")
|
||||
parts.append("## 思考过程\n")
|
||||
parts.append(thinking.strip() + "\n")
|
||||
parts.append("\n## 查询说明\n")
|
||||
parts.append(description.strip() + "\n")
|
||||
parts.append("\n## SQL\n")
|
||||
for b in sql_blocks:
|
||||
parts.append(f"\n### {b.title}\n")
|
||||
parts.append("```sql\n")
|
||||
parts.append(b.sql.strip() + "\n")
|
||||
parts.append("```\n")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("".join(parts), encoding="utf-8")
|
||||
|
||||
|
||||
def fetch_all(cur, sql: str, params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
cur.execute(sql, params)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def month_case(ts_expr: str) -> str:
|
||||
parts = []
|
||||
for month_key, _, (ws, we) in MONTHS:
|
||||
parts.append(
|
||||
f"when {ts_expr} >= '{ws}'::timestamptz and {ts_expr} < '{we}'::timestamptz then '{month_key}'"
|
||||
)
|
||||
return "case " + " ".join(parts) + " else null end"
|
||||
|
||||
|
||||
def sql_order_base(window_start: str, window_end: str) -> str:
|
||||
return f"""
|
||||
with base_orders as (
|
||||
select
|
||||
tfl.order_settle_id,
|
||||
max(tfl.member_id) as member_id,
|
||||
min(tfl.start_use_time) as order_start_time,
|
||||
max(tfl.ledger_end_time) as order_end_time,
|
||||
sum(tfl.ledger_amount) as table_amount
|
||||
from billiards_dwd.dwd_table_fee_log tfl
|
||||
where tfl.site_id = %(site_id)s
|
||||
and coalesce(tfl.is_delete,0) = 0
|
||||
and tfl.start_use_time >= '{window_start}'::timestamptz
|
||||
and tfl.start_use_time < '{window_end}'::timestamptz
|
||||
group by tfl.order_settle_id
|
||||
),
|
||||
assistant_amount as (
|
||||
select
|
||||
asl.order_settle_id,
|
||||
sum(asl.ledger_amount) as assistant_amount
|
||||
from billiards_dwd.dwd_assistant_service_log asl
|
||||
join base_orders bo on bo.order_settle_id = asl.order_settle_id
|
||||
where asl.site_id = %(site_id)s
|
||||
and coalesce(asl.is_delete,0) = 0
|
||||
group by asl.order_settle_id
|
||||
),
|
||||
goods_amount as (
|
||||
select
|
||||
g.order_settle_id,
|
||||
sum(g.ledger_amount) as goods_amount
|
||||
from billiards_dwd.dwd_store_goods_sale g
|
||||
join base_orders bo on bo.order_settle_id = g.order_settle_id
|
||||
where g.site_id = %(site_id)s
|
||||
and coalesce(g.is_delete,0) = 0
|
||||
group by g.order_settle_id
|
||||
),
|
||||
orders as (
|
||||
select
|
||||
bo.order_settle_id,
|
||||
bo.member_id,
|
||||
bo.order_start_time,
|
||||
bo.order_end_time,
|
||||
coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount
|
||||
from base_orders bo
|
||||
left join assistant_amount a on a.order_settle_id = bo.order_settle_id
|
||||
left join goods_amount g on g.order_settle_id = bo.order_settle_id
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def dense_rank_desc(values: dict[str, Decimal]) -> dict[str, int]:
|
||||
uniq = sorted({v for v in values.values() if v > 0}, reverse=True)
|
||||
rank_map = {v: i + 1 for i, v in enumerate(uniq)}
|
||||
return {k: rank_map.get(v, 0) for k, v in values.items()}
|
||||
|
||||
|
||||
def calc_diff(all_values: dict[str, Decimal], current: Decimal) -> tuple[Decimal, Decimal]:
|
||||
xs = [v for v in all_values.values() if v > 0]
|
||||
if not xs or current <= 0:
|
||||
return Decimal("0"), Decimal("0")
|
||||
avg = sum(xs) / Decimal(len(xs))
|
||||
med = Decimal(str(median([float(v) for v in xs])))
|
||||
return current - avg, current - med
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with conn() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
assistants_rows = fetch_all(
|
||||
cur,
|
||||
"""
|
||||
select distinct nickname as assistant
|
||||
from billiards_dwd.dwd_assistant_service_log
|
||||
where site_id=%(site_id)s and coalesce(is_delete,0)=0
|
||||
and start_use_time >= %(window_start)s::timestamptz
|
||||
and start_use_time < %(window_end)s::timestamptz
|
||||
order by assistant;
|
||||
""",
|
||||
{"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1]},
|
||||
)
|
||||
assistants = [r["assistant"] for r in assistants_rows if r.get("assistant")]
|
||||
|
||||
# 助教-客户-月份:服务时长
|
||||
sql_svc = f"""
|
||||
with raw as (
|
||||
select
|
||||
asl.nickname as assistant,
|
||||
asl.tenant_member_id as member_id,
|
||||
{month_case('asl.start_use_time')} as month_key,
|
||||
asl.order_assistant_type,
|
||||
asl.income_seconds
|
||||
from billiards_dwd.dwd_assistant_service_log asl
|
||||
where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0
|
||||
and asl.start_use_time >= %(window_start)s::timestamptz
|
||||
and asl.start_use_time < %(window_end)s::timestamptz
|
||||
and asl.tenant_member_id is not null and asl.tenant_member_id<>0
|
||||
)
|
||||
select
|
||||
assistant,
|
||||
member_id,
|
||||
month_key,
|
||||
sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours,
|
||||
sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours
|
||||
from raw
|
||||
where month_key is not null
|
||||
group by assistant, member_id, month_key;
|
||||
"""
|
||||
svc_rows = fetch_all(cur, sql_svc, {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1]})
|
||||
|
||||
# 助教-客户-月份:客户流水
|
||||
sql_rev = sql_order_base(WIN_ALL[0], WIN_ALL[1]) + f"""
|
||||
, assistant_orders as (
|
||||
select distinct order_settle_id, nickname as assistant
|
||||
from billiards_dwd.dwd_assistant_service_log
|
||||
where site_id=%(site_id)s and coalesce(is_delete,0)=0
|
||||
and start_use_time >= %(window_start)s::timestamptz
|
||||
and start_use_time < %(window_end)s::timestamptz
|
||||
),
|
||||
raw as (
|
||||
select
|
||||
ao.assistant,
|
||||
o.member_id,
|
||||
{month_case('o.order_start_time')} as month_key,
|
||||
o.order_amount
|
||||
from orders o
|
||||
join assistant_orders ao on ao.order_settle_id=o.order_settle_id
|
||||
where o.member_id is not null and o.member_id<>0
|
||||
)
|
||||
select
|
||||
assistant,
|
||||
member_id,
|
||||
month_key,
|
||||
sum(order_amount) as revenue_amount
|
||||
from raw
|
||||
where month_key is not null
|
||||
group by assistant, member_id, month_key;
|
||||
"""
|
||||
rev_rows = fetch_all(cur, sql_rev, {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1]})
|
||||
|
||||
# 助教-客户-月份:充值归因
|
||||
sql_rech = f"""
|
||||
with base_orders as (
|
||||
select
|
||||
tfl.order_settle_id,
|
||||
max(tfl.member_id) as member_id,
|
||||
min(tfl.start_use_time) as table_start_time,
|
||||
max(tfl.ledger_end_time) as table_end_time
|
||||
from billiards_dwd.dwd_table_fee_log tfl
|
||||
where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0
|
||||
and tfl.start_use_time >= %(window_start)s::timestamptz
|
||||
and tfl.start_use_time < %(window_end)s::timestamptz
|
||||
group by tfl.order_settle_id
|
||||
),
|
||||
assistant_time as (
|
||||
select
|
||||
asl.order_settle_id,
|
||||
min(asl.start_use_time) as assistant_start_time,
|
||||
max(asl.last_use_time) as assistant_end_time
|
||||
from billiards_dwd.dwd_assistant_service_log asl
|
||||
join base_orders bo on bo.order_settle_id=asl.order_settle_id
|
||||
where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0
|
||||
group by asl.order_settle_id
|
||||
),
|
||||
order_windows as (
|
||||
select
|
||||
bo.order_settle_id,
|
||||
bo.member_id,
|
||||
least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start,
|
||||
greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end
|
||||
from base_orders bo
|
||||
left join assistant_time at on at.order_settle_id=bo.order_settle_id
|
||||
where bo.member_id is not null and bo.member_id<>0
|
||||
),
|
||||
assistant_orders as (
|
||||
select distinct order_settle_id, nickname as assistant
|
||||
from billiards_dwd.dwd_assistant_service_log
|
||||
where site_id=%(site_id)s and coalesce(is_delete,0)=0
|
||||
and start_use_time >= %(window_start)s::timestamptz
|
||||
and start_use_time < %(window_end)s::timestamptz
|
||||
),
|
||||
recharge_pay as (
|
||||
select
|
||||
p.pay_time,
|
||||
r.member_id,
|
||||
p.pay_amount
|
||||
from billiards_dwd.dwd_payment p
|
||||
join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id
|
||||
where p.site_id=%(site_id)s
|
||||
and p.relate_type=5
|
||||
and p.pay_status=2
|
||||
and p.pay_amount>0
|
||||
and p.pay_time >= %(window_start)s::timestamptz
|
||||
and p.pay_time < %(window_end)s::timestamptz
|
||||
),
|
||||
matched as (
|
||||
select
|
||||
rp.pay_time,
|
||||
ow.order_settle_id,
|
||||
ow.member_id,
|
||||
rp.pay_amount
|
||||
from recharge_pay rp
|
||||
join order_windows ow
|
||||
on ow.member_id=rp.member_id
|
||||
and rp.pay_time >= ow.win_start - interval '30 minutes'
|
||||
and rp.pay_time <= ow.win_end + interval '30 minutes'
|
||||
),
|
||||
raw as (
|
||||
select
|
||||
ao.assistant,
|
||||
m.member_id,
|
||||
{month_case('m.pay_time')} as month_key,
|
||||
m.pay_amount
|
||||
from matched m
|
||||
join assistant_orders ao on ao.order_settle_id=m.order_settle_id
|
||||
)
|
||||
select
|
||||
assistant,
|
||||
member_id,
|
||||
month_key,
|
||||
sum(pay_amount) as recharge_amount
|
||||
from raw
|
||||
where month_key is not null
|
||||
group by assistant, member_id, month_key;
|
||||
"""
|
||||
rech_rows = fetch_all(cur, sql_rech, {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1]})
|
||||
|
||||
# 汇总:月度助教指标
|
||||
svc_map = {mk: {a: {"base": Decimal('0'), "extra": Decimal('0')} for a in assistants} for mk,_,_ in MONTHS}
|
||||
for r in svc_rows:
|
||||
mk = r["month_key"]; a = r["assistant"]
|
||||
if mk in svc_map and a in svc_map[mk]:
|
||||
svc_map[mk][a]["base"] += d(r["base_hours"])
|
||||
svc_map[mk][a]["extra"] += d(r["extra_hours"])
|
||||
|
||||
revenue_map = {mk: {a: Decimal('0') for a in assistants} for mk,_,_ in MONTHS}
|
||||
for r in rev_rows:
|
||||
mk = r["month_key"]; a = r["assistant"]
|
||||
if mk in revenue_map and a in revenue_map[mk]:
|
||||
revenue_map[mk][a] += d(r["revenue_amount"])
|
||||
|
||||
recharge_map = {mk: {a: Decimal('0') for a in assistants} for mk,_,_ in MONTHS}
|
||||
for r in rech_rows:
|
||||
mk = r["month_key"]; a = r["assistant"]
|
||||
if mk in recharge_map and a in recharge_map[mk]:
|
||||
recharge_map[mk][a] += d(r["recharge_amount"])
|
||||
|
||||
# ====== 输出4张排行榜 ======
|
||||
def write_rank(file_stem: str, title: str, desc: str, rows: list[list[Any]]):
|
||||
write_csv(OUT_DIR / f"{file_stem}.csv", title, desc, [["月份", "排名", "助教昵称", "指标"]], rows)
|
||||
write_md(OUT_DIR / f"{file_stem}.md", title, "按月聚合并做dense_rank排名。", desc, [])
|
||||
|
||||
rows = []
|
||||
for mk,_,_ in MONTHS:
|
||||
values = {a: svc_map[mk][a]["base"] for a in assistants}
|
||||
ranks = dense_rank_desc(values)
|
||||
for a in sorted(assistants, key=lambda x: (ranks[x] if ranks[x] else 999999, x)):
|
||||
v = values[a]
|
||||
if v > 0:
|
||||
rows.append([mk, ranks[a], a, fmt_hours(v, 2)])
|
||||
write_rank(
|
||||
"助教_基础课时长排行_2025年10-12月",
|
||||
"2025年10-12月 助教基础课时长排行榜",
|
||||
"口径:order_assistant_type=1,时长=income_seconds/3600(小时),按月排名。",
|
||||
rows,
|
||||
)
|
||||
|
||||
rows = []
|
||||
for mk,_,_ in MONTHS:
|
||||
values = {a: svc_map[mk][a]["extra"] for a in assistants}
|
||||
ranks = dense_rank_desc(values)
|
||||
for a in sorted(assistants, key=lambda x: (ranks[x] if ranks[x] else 999999, x)):
|
||||
v = values[a]
|
||||
if v > 0:
|
||||
rows.append([mk, ranks[a], a, fmt_hours(v, 2)])
|
||||
write_rank(
|
||||
"助教_附加课时长排行_2025年10-12月",
|
||||
"2025年10-12月 助教附加课(超休)时长排行榜",
|
||||
"口径:order_assistant_type=2,超休时长=income_seconds/3600(小时),按月排名。",
|
||||
rows,
|
||||
)
|
||||
|
||||
rows = []
|
||||
for mk,_,_ in MONTHS:
|
||||
values = revenue_map[mk]
|
||||
ranks = dense_rank_desc(values)
|
||||
for a in sorted(assistants, key=lambda x: (ranks[x] if ranks[x] else 999999, x)):
|
||||
v = values[a]
|
||||
if v > 0:
|
||||
rows.append([mk, ranks[a], a, fmt_money(v)])
|
||||
write_rank(
|
||||
"助教_客户流水排行_2025年10-12月",
|
||||
"2025年10-12月 助教客户流水排行榜(全额复制口径)",
|
||||
"口径:客户流水=台费+助教+商品应付金额按订单归集后,全额计入订单内每位助教;多助教会导致汇总>门店总额。",
|
||||
rows,
|
||||
)
|
||||
|
||||
rows = []
|
||||
for mk,_,_ in MONTHS:
|
||||
values = recharge_map[mk]
|
||||
ranks = dense_rank_desc(values)
|
||||
for a in sorted(assistants, key=lambda x: (ranks[x] if ranks[x] else 999999, x)):
|
||||
v = values[a]
|
||||
if v > 0:
|
||||
rows.append([mk, ranks[a], a, fmt_money(v)])
|
||||
write_rank(
|
||||
"助教_客户充值归因排行_2025年10-12月",
|
||||
"2025年10-12月 助教客户充值归因排行榜(全额复制口径)",
|
||||
"口径:充值支付(dwd_payment.relate_type=5)在消费窗口±30分钟内命中且订单有助教,则全额计入助教;多助教/多订单命中会重复计入。",
|
||||
rows,
|
||||
)
|
||||
|
||||
# ====== 输出助教详情(每人一份) ======
|
||||
# 会员昵称
|
||||
cur.execute("select member_id, nickname from billiards_dwd.dim_member where scd2_is_current=1")
|
||||
member_name = {r["member_id"]: (r.get("nickname") or "") for r in cur.fetchall()}
|
||||
|
||||
# 索引:assistant->member->month
|
||||
svc_idx = {a: {} for a in assistants}
|
||||
for r in svc_rows:
|
||||
a = r["assistant"]; mid = int(r["member_id"]); mk = r["month_key"]
|
||||
svc_idx.setdefault(a, {}).setdefault(mid, {})[mk] = {"base": d(r["base_hours"]), "extra": d(r["extra_hours"])}
|
||||
|
||||
rev_idx = {a: {} for a in assistants}
|
||||
for r in rev_rows:
|
||||
a = r["assistant"]; mid = int(r["member_id"]); mk = r["month_key"]
|
||||
rev_idx.setdefault(a, {}).setdefault(mid, {})[mk] = d(r["revenue_amount"])
|
||||
|
||||
rech_idx = {a: {} for a in assistants}
|
||||
for r in rech_rows:
|
||||
a = r["assistant"]; mid = int(r["member_id"]); mk = r["month_key"]
|
||||
rech_idx.setdefault(a, {}).setdefault(mid, {})[mk] = d(r["recharge_amount"])
|
||||
|
||||
for a in assistants:
|
||||
safe = sanitize_filename(a)
|
||||
csv_path = OUT_DIR / f"助教详情_{safe}.csv"
|
||||
md_path = OUT_DIR / f"助教详情_{safe}.md"
|
||||
|
||||
# 评价(简短)
|
||||
base_total = sum((svc_map[mk][a]["base"] for mk,_,_ in MONTHS), Decimal('0'))
|
||||
extra_total = sum((svc_map[mk][a]["extra"] for mk,_,_ in MONTHS), Decimal('0'))
|
||||
rev_total = sum((revenue_map[mk][a] for mk,_,_ in MONTHS), Decimal('0'))
|
||||
rech_total = sum((recharge_map[mk][a] for mk,_,_ in MONTHS), Decimal('0'))
|
||||
|
||||
# 头部客户 Top100(按12月消费业绩)
|
||||
members = set(rev_idx.get(a, {}).keys()) | set(svc_idx.get(a, {}).keys()) | set(rech_idx.get(a, {}).keys())
|
||||
def rev_dec(mid: int) -> Decimal:
|
||||
return rev_idx.get(a, {}).get(mid, {}).get('2025-12', Decimal('0'))
|
||||
top_members = sorted(members, key=lambda mid: rev_dec(mid), reverse=True)[:100]
|
||||
|
||||
top3 = '、'.join([(member_name.get(mid) or str(mid)) for mid in top_members[:3]])
|
||||
assistant_review = (
|
||||
f"评价:基础{fmt_hours(base_total,1)},附加{fmt_hours(extra_total,1)};"
|
||||
f"客户流水¥{rev_total:.2f},充值归因¥{rech_total:.2f};"
|
||||
f"头部客户(12月)Top3:{top3 or '无'}。"
|
||||
)
|
||||
|
||||
# Part1-4
|
||||
part1=[]; part2=[]; part3=[]; part4=[]
|
||||
for mk, mcn, _ in MONTHS:
|
||||
base_v = svc_map[mk][a]["base"]
|
||||
extra_v = svc_map[mk][a]["extra"]
|
||||
rev_v = revenue_map[mk][a]
|
||||
rech_v = recharge_map[mk][a]
|
||||
|
||||
base_all = {x: svc_map[mk][x]["base"] for x in assistants}
|
||||
extra_all = {x: svc_map[mk][x]["extra"] for x in assistants}
|
||||
rev_all = {x: revenue_map[mk][x] for x in assistants}
|
||||
rech_all = {x: recharge_map[mk][x] for x in assistants}
|
||||
|
||||
base_rank = dense_rank_desc(base_all).get(a, 0)
|
||||
extra_rank = dense_rank_desc(extra_all).get(a, 0)
|
||||
rev_rank = dense_rank_desc(rev_all).get(a, 0)
|
||||
rech_rank = dense_rank_desc(rech_all).get(a, 0)
|
||||
|
||||
base_da, base_dm = calc_diff(base_all, base_v)
|
||||
extra_da, extra_dm = calc_diff(extra_all, extra_v)
|
||||
rev_da, rev_dm = calc_diff(rev_all, rev_v)
|
||||
rech_da, rech_dm = calc_diff(rech_all, rech_v)
|
||||
|
||||
part1.append([mcn, fmt_hours(base_v,2), base_rank or "", fmt_hours(base_da,2), fmt_hours(base_dm,2)])
|
||||
part2.append([mcn, fmt_hours(extra_v,2), extra_rank or "", fmt_hours(extra_da,2), fmt_hours(extra_dm,2)])
|
||||
part3.append([mcn, fmt_money(rev_v), rev_rank or "", fmt_money(rev_da), fmt_money(rev_dm)])
|
||||
part4.append([mcn, fmt_money(rech_v), rech_rank or "", fmt_money(rech_da), fmt_money(rech_dm)])
|
||||
|
||||
# Part5 rows
|
||||
part5=[]
|
||||
for i, mid in enumerate(top_members, start=1):
|
||||
def h_pair(month_key: str) -> str:
|
||||
v = svc_idx.get(a, {}).get(mid, {}).get(month_key, {})
|
||||
return f"{fmt_hours(v.get('base',Decimal('0')),1)} / {fmt_hours(v.get('extra',Decimal('0')),1)}"
|
||||
def rev_m(month_key: str) -> Decimal:
|
||||
return rev_idx.get(a, {}).get(mid, {}).get(month_key, Decimal('0'))
|
||||
def rech_m(month_key: str) -> Decimal:
|
||||
return rech_idx.get(a, {}).get(mid, {}).get(month_key, Decimal('0'))
|
||||
name = member_name.get(mid) or str(mid)
|
||||
part5.append([
|
||||
i,
|
||||
name,
|
||||
h_pair('2025-12'), fmt_money(rev_m('2025-12')), fmt_money(rech_m('2025-12')),
|
||||
h_pair('2025-11'), fmt_money(rev_m('2025-11')), fmt_money(rech_m('2025-11')),
|
||||
h_pair('2025-10'), fmt_money(rev_m('2025-10')), fmt_money(rech_m('2025-10')),
|
||||
])
|
||||
|
||||
title = f"助教详情:{a}(2025年10-12月)"
|
||||
desc = (
|
||||
"本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。"
|
||||
"均值/中位数差值对比集合为当月该指标>0的助教。"
|
||||
"充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。"
|
||||
)
|
||||
|
||||
rows=[]
|
||||
rows += [["一、基础课业绩"], ["说明:" + assistant_review], []]
|
||||
rows += [["月份", "基础课业绩", "基础课业绩", "基础课业绩", "基础课业绩"], ["月份", "小时数", "排名", "平均值差值小时数", "中位数值差值小时数"]]
|
||||
rows += part1
|
||||
rows += [[], ["二、附加课业绩"], ["说明:附加课=order_assistant_type=2。"], []]
|
||||
rows += [["月份", "附加课业绩", "附加课业绩", "附加课业绩", "附加课业绩"], ["月份", "小时数", "排名", "平均值差值小时数", "中位数值差值小时数"]]
|
||||
rows += part2
|
||||
rows += [[], ["三、客户消费业绩"], ["说明:订单台费+助教+商品应付金额全额计入订单内助教。"], []]
|
||||
rows += [["月份", "客户消费业绩", "客户消费业绩", "客户消费业绩", "客户消费业绩"], ["月份", "合计元", "排名", "平均值差值元", "中位数值差值元"]]
|
||||
rows += part3
|
||||
rows += [[], ["四、客户充值业绩"], ["说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。"], []]
|
||||
rows += [["月份", "客户充值业绩", "客户充值业绩", "客户充值业绩", "客户充值业绩"], ["月份", "合计元", "排名", "平均值差值元", "中位数值差值元"]]
|
||||
rows += part4
|
||||
rows += [[], ["五、头部客户(按12月消费业绩排序,Top100)"], ["说明:基础/附加课时=基础h/附加h。"], []]
|
||||
rows += [["排名", "客户名称", "12月", "12月", "12月", "11月", "11月", "11月", "10月", "10月", "10月"],
|
||||
["排名", "客户名称", "基础/附加课时", "消费业绩(元)", "客户充值(元)", "基础/附加课时", "消费业绩(元)", "客户充值(元)", "基础/附加课时", "消费业绩(元)", "客户充值(元)"]]
|
||||
rows += part5
|
||||
|
||||
write_csv_sections(csv_path, title, desc, rows)
|
||||
write_md(
|
||||
md_path,
|
||||
title,
|
||||
"按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。",
|
||||
desc + "\n" + assistant_review,
|
||||
[
|
||||
SqlBlock("服务时长(助教-客户-月份)", sql_svc),
|
||||
SqlBlock("客户流水(助教-客户-月份)", sql_rev),
|
||||
SqlBlock("充值归因(助教-客户-月份)", sql_rech),
|
||||
],
|
||||
)
|
||||
|
||||
print(f"完成:{OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user