feat: 2026-04-15~04-20 累积变更基线 — 多主线合流

主线 1: rns1-customer-coach-api + 04-miniapp-core-business 后端实施
  - 新增 GET /xcx/coaches/{id}/banner 轻量接口
  - performance/records 加 coach_id 参数 + view_board_coach 权限分流
  - coach/customer/performance/board/task 服务层重构
  - fdw_queries 结算单粒度聚合 + consumption_summary 视图统一
  - task_generator 回访宽限 72h + UPSERT 替代策略 + Step 5 保底清理
  - recall_detector settle_type=3 双重限制 + 门店级 resolved

主线 2: 小程序权限分流 + 新增 coach-service-records 管理者视角业绩明细页
  - perf-progress 共享模块去重 task-list/coach-detail 动画逻辑
  - isScattered 散客标记端到端
  - foodDetail/phoneFull/creator* 字段透传

主线 3: P19 指数回测框架 Phase 1+2
  - 3 个指数表 stat_date 日快照模式
  - 新增 DWS_INDEX_BACKFILL / DWS_TASK_SIMULATION 工具任务
  - task_engine 升级 HTTP 实时 + 推演回测双模式

主线 4: Core 维度层启用
  - 新增 CORE_DIM_SYNC 任务(DWD → core 4 维度表)
  - 修复 app 视图空查询问题

主线 5: member_project_tag 改为 LAST_30_VISITS 消费次数窗口

主线 6: 2 个迁移 SQL 已执行(stat_date + member_project_tag 新窗口)
  - schema 基线与 DDL 快照同步

主线 7: 开发机路径迁移 C:\NeoZQYY → C:\Project\NeoZQYY(约 95% 改动量)

附带: 新建运维脚本(churned_customer_report / simulate_historical_tasks /
      backfill_index_snapshots)+ tools/task-analysis/ 任务分析工具

合计 157 文件。未包含中间产物(tmp/ .playwright-mcp/ inspect-* excel/sheet 分析 txt)。
审计记录见下一个 commit。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Neo
2026-04-20 06:32:07 +08:00
parent 79d3c2e97e
commit 2a7a5d68aa
157 changed files with 14304 additions and 3717 deletions

View File

@@ -180,10 +180,9 @@ def get_last_visit_days(
"""
批量查询客户距上次到店天数。
来源: app.v_dwd_assistant_service_log
废单排除: is_delete = 0RLS 视图使用 is_delete 而非 is_trash
时间字段: create_time对应 design.md 中的 settle_time
会员字段: tenant_member_id对应 design.md 中的 member_id
来源: app.v_dws_member_consumption_summary.days_since_last基于结算单
FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录
consumption_summary 按 stat_date 有多行快照,取最新一行
返回 {member_id: days_since_visit} 映射,无记录的会员不在结果中。
"""
@@ -194,16 +193,20 @@ def get_last_visit_days(
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
cur.execute(
"""
SELECT tenant_member_id,
CURRENT_DATE - MAX(create_time::date) AS days_since_visit
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = ANY(%s) AND is_delete = 0
GROUP BY tenant_member_id
SELECT member_id, days_since_last
FROM app.v_dws_member_consumption_summary
WHERE member_id = ANY(%s)
AND days_since_last IS NOT NULL
ORDER BY member_id, stat_date DESC
""",
(member_ids,),
)
seen: set[int] = set()
for row in cur.fetchall():
result[row[0]] = row[1]
mid = row[0]
if mid not in seen:
seen.add(mid)
result[mid] = row[1]
return result
@@ -415,19 +418,24 @@ def batch_query_for_task_list(
for row in cur.fetchall():
balance_map[row[0]] = Decimal(str(row[1])) if row[1] is not None else Decimal("0")
# 3. 最后到店天数
# 3. 最后到店天数(基于消费汇总表,口径=结算单)
# FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录
cur.execute(
"""
SELECT tenant_member_id,
CURRENT_DATE - MAX(create_time::date) AS days_since_visit
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = ANY(%s) AND is_delete = 0
GROUP BY tenant_member_id
SELECT member_id, days_since_last
FROM app.v_dws_member_consumption_summary
WHERE member_id = ANY(%s)
AND days_since_last IS NOT NULL
ORDER BY member_id, stat_date DESC
""",
(member_ids,),
)
seen_members: set[int] = set()
for row in cur.fetchall():
last_visit_map[row[0]] = row[1]
mid = row[0]
if mid not in seen_members:
seen_members.add(mid)
last_visit_map[mid] = row[1]
# 4. RS 指数
cur.execute(
@@ -687,6 +695,63 @@ def get_level_map(conn: Any, site_id: int) -> dict[int, str]:
return {}
@trace_service(description_zh="获取服务记录汇总", description_en="Get service records summary")
def get_service_records_summary(
conn: Any,
site_id: int,
assistant_id: int,
year: int,
month: int,
) -> dict:
"""
单条 SQL 直接聚合月度汇总count / sum(hours) / sum(income)。
用途:替代"先拉全量再 Python 算 summary"的高耗模式PERF-2
口径与 get_service_records 完全一致(同表/同 JOIN/同费率公式)。
返回 { total_count, total_hours, total_hours_raw, total_income }
total_hours_raw 暂沿用 0.0DWD 层服务记录无折前时长字段)。
"""
start_date = f"{year}-{month:02d}-01"
if month == 12:
end_date = f"{year + 1}-01-01"
else:
end_date = f"{year}-{month + 1:02d}-01"
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT COUNT(*),
COALESCE(SUM(sl.income_seconds / 3600.0), 0),
COALESCE(SUM(
CASE
WHEN sl.skill_name ILIKE '%%激励%%' OR sl.skill_name ILIKE '%%超休%%'
THEN (sl.income_seconds / 3600.0) * COALESCE(sc.bonus_course_price * (1 - sc.bonus_deduction_ratio), 0)
ELSE (sl.income_seconds / 3600.0) * COALESCE(sc.base_course_price - sc.base_deduction, 0)
END
), 0)
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dws_assistant_salary_calc sc
ON sl.site_assistant_id = sc.assistant_id
AND date_trunc('month', sl.create_time)::date = sc.salary_month
WHERE sl.site_assistant_id = %s AND sl.is_delete = 0
AND sl.create_time >= %s::timestamptz
AND sl.create_time < %s::timestamptz
""",
(assistant_id, start_date, end_date),
)
row = cur.fetchone()
if not row:
return {"total_count": 0, "total_hours": 0.0, "total_hours_raw": 0.0, "total_income": 0.0}
return {
"total_count": int(row[0] or 0),
"total_hours": round(float(row[1] or 0), 2),
"total_hours_raw": 0.0, # DWD 层无折前时长字段;与原 compute_summary 行为一致
"total_income": round(float(row[2] or 0), 2),
}
@trace_service(description_zh="获取服务记录", description_en="Get service records")
def get_service_records(
conn: Any,
@@ -1008,57 +1073,72 @@ def get_consumption_records(
"""
查询客户消费记录CUST-1 consumptionRecords 用)。
来源: app.v_dwd_assistant_service_log + v_dwd_settlement_head + v_dim_assistant
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount来自 service_log
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money来自 settlement_head)。
⚠️ 费用拆分字段table_charge_money, goods_money, settle_type来自 settlement_head。
按结算单order_settle_id粒度返回同一结算单下的多个助教聚合到 coaches 数组
来源: v_dwd_settlement_head + v_dwd_assistant_service_log + v_dim_assistant
⚠️ DWD-DOC 规则 1: totalAmount 使用 SUM(ledger_amount)items_sum 口径)。
⚠️ 废单排除: is_delete = 0。
⚠️ 正向交易: settle_type IN (1, 3)。
⚠️ DQ-6: 助教姓名通过 v_dim_assistant 获取。
"""
records: list[dict] = []
# CHANGE 2026-03-29 | CUST-3: 支持按月份过滤消费记录
date_clause = ""
date_params: list = []
if start_date:
date_clause += " AND sl.create_time >= %s::timestamptz"
date_clause += " AND sh.create_time >= %s::timestamptz"
date_params.append(start_date)
if end_date:
date_clause += " AND sl.create_time < %s::timestamptz"
date_clause += " AND sh.create_time < %s::timestamptz"
date_params.append(end_date)
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
cur.execute(
f"""
SELECT sl.assistant_service_id AS id,
sl.create_time AS settle_time,
sl.start_use_time AS start_time,
sl.last_use_time AS end_time,
sl.income_seconds / 3600.0 AS service_hours,
sl.ledger_amount AS total_amount,
sl.skill_name AS course_type,
sl.site_table_id AS table_id,
sl.site_assistant_id AS assistant_id,
COALESCE(da.nickname, da.real_name, '') AS assistant_name,
da.level AS assistant_level,
SELECT sh.order_settle_id AS id,
sh.create_time AS settle_time,
MIN(sl.start_use_time) AS start_time,
MAX(sl.last_use_time) AS end_time,
SUM(sl.income_seconds) / 3600.0 AS service_hours,
SUM(sl.ledger_amount) AS total_amount,
MIN(sl.site_table_id) AS table_id,
sh.table_charge_money,
sh.goods_money,
sh.assistant_pd_money,
sh.assistant_cx_money,
sh.settle_type,
sh.consume_money,
sh.adjust_amount
FROM app.v_dwd_assistant_service_log sl
sh.adjust_amount,
gs_agg.drinks,
json_agg(json_build_object(
'assistant_id', sl.site_assistant_id,
'assistant_name', COALESCE(da.nickname, da.real_name, ''),
'assistant_level', da.level,
'service_hours', sl.income_seconds / 3600.0,
'ledger_amount', sl.ledger_amount,
'course_type', sl.skill_name
) ORDER BY sl.ledger_amount DESC NULLS LAST) AS coaches_json
FROM app.v_dwd_settlement_head sh
INNER JOIN app.v_dwd_assistant_service_log sl
ON sh.order_settle_id = sl.order_settle_id
AND sl.tenant_member_id = %s
AND sl.is_delete = 0
LEFT JOIN app.v_dim_assistant da
ON sl.site_assistant_id = da.assistant_id
AND da.scd2_is_current = 1
LEFT JOIN app.v_dwd_settlement_head sh
ON sl.order_settle_id = sh.order_settle_id
WHERE sl.tenant_member_id = %s
AND sl.is_delete = 0
AND sh.settle_type IN (1, 3)
AND da.scd2_is_current = 1
LEFT JOIN LATERAL (
SELECT string_agg(gs.ledger_name || '*' || gs.total_count, ' | ' ORDER BY gs.subtotal DESC) AS drinks
FROM (
SELECT ledger_name,
SUM(ledger_count) AS total_count,
SUM(ledger_amount) AS subtotal
FROM app.v_dwd_store_goods_sale
WHERE order_settle_id = sh.order_settle_id
AND is_delete = 0
GROUP BY ledger_name
) gs
) gs_agg ON true
WHERE sh.settle_type IN (1, 3)
{date_clause}
ORDER BY sl.create_time DESC
GROUP BY sh.order_settle_id, sh.create_time,
sh.table_charge_money, sh.goods_money,
sh.consume_money, sh.adjust_amount,
gs_agg.drinks
ORDER BY sh.create_time DESC
LIMIT %s OFFSET %s
""",
(member_id,) + tuple(date_params) + (limit, offset),
@@ -1071,18 +1151,13 @@ def get_consumption_records(
"end_time": row[3],
"service_hours": float(row[4]) if row[4] is not None else 0.0,
"total_amount": float(row[5]) if row[5] is not None else 0.0,
"course_type": row[6] or "",
"table_id": row[7],
"assistant_id": row[8],
"assistant_name": row[9] or "",
"assistant_level": row[10], # int level code
"table_charge_money": float(row[11]) if row[11] is not None else 0.0,
"goods_money": float(row[12]) if row[12] is not None else 0.0,
"assistant_pd_money": float(row[13]) if row[13] is not None else 0.0,
"assistant_cx_money": float(row[14]) if row[14] is not None else 0.0,
"settle_type": row[15],
"consume_money": float(row[16]) if row[16] is not None else 0.0,
"adjust_amount": float(row[17]) if row[17] is not None else 0.0,
"table_id": row[6],
"table_charge_money": float(row[7]) if row[7] is not None else 0.0,
"goods_money": float(row[8]) if row[8] is not None else 0.0,
"consume_money": float(row[9]) if row[9] is not None else 0.0,
"adjust_amount": float(row[10]) if row[10] is not None else 0.0,
"drinks": row[11],
"coaches_json": row[12] or [],
})
return records
@@ -1741,8 +1816,13 @@ def get_coach_sv_data(
AND ri.session_count > 0
),
period_consume AS (
-- DWD-DOC 规则 1: items_sum 需拆分计算settlement_head 无此字段
SELECT sh.member_id,
COALESCE(SUM(sh.items_sum), 0) AS consume_amount
COALESCE(SUM(
sh.table_charge_money + sh.goods_money
+ sh.assistant_pd_money + sh.assistant_cx_money
+ sh.electricity_money
), 0) AS consume_amount
FROM app.v_dwd_settlement_head sh
WHERE sh.member_id = ANY(SELECT member_id FROM coach_members)
AND sh.settle_type IN (1, 3)
@@ -2044,7 +2124,7 @@ def get_customer_board_balance(
"name": row[1] or "",
"balance": float(row[2]) if row[2] is not None else 0.0,
# CHANGE 2026-03-29 | last_visit 格式化为"X天前"ideal_days 从 winback_index 获取
"last_visit": f"{row[3]}天前" if row[3] is not None else "--",
"last_visit": "今天" if row[3] == 0 else f"{row[3]}天前" if row[3] is not None else "--",
"last_visit_date": row[3],
"ideal_days": None, # balance 维度无 ideal_days由 board_service 补充
# CHANGE 2026-04-07 | Fix-4consume_amount_60d 是 60 天总额,月均 = /2
@@ -2130,7 +2210,7 @@ def get_customer_board_recharge(
"recharges_60d": row[4] or 0,
"current_balance": float(row[5]) if row[5] is not None else 0.0,
# CHANGE 2026-03-29 | 补充 last_visit 和 ideal_days头部展示用
"last_visit": f"{row[6]}天前" if row[6] is not None else "--",
"last_visit": "今天" if row[6] == 0 else f"{row[6]}天前" if row[6] is not None else "--",
"ideal_days": None, # 由 board_service 补充
})
@@ -3492,12 +3572,19 @@ def get_nci_batch(
来源: app.v_dws_member_newconv_indexRLS 视图)
返回: {member_id: display_score}
FIX 2026-04-12: 排除已转老客的会员。NCI 表只在 NEW 阶段写入,
会员转 OLD 后不再更新,导致残留过时高分。用 WBI status='OLD' 过滤。
"""
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT member_id, COALESCE(display_score, 0)
FROM app.v_dws_member_newconv_index
SELECT n.member_id, COALESCE(n.display_score, 0)
FROM app.v_dws_member_newconv_index n
WHERE NOT EXISTS (
SELECT 1 FROM app.v_dws_member_winback_index w
WHERE w.member_id = n.member_id AND w.status = 'OLD'
)
"""
)
return {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
@@ -3547,6 +3634,7 @@ def get_all_service_pairs(
返回: [{"assistant_id", "member_id", "rs"}]
"""
with _fdw_context(conn, site_id) as cur:
# POOL 客户需 session_count >= 3 才纳入保底任务MAIN/COMANAGE 无限制
cur.execute(
"""
SELECT assistant_id,
@@ -3554,6 +3642,7 @@ def get_all_service_pairs(
COALESCE(rs_display, 0) AS rs
FROM app.v_dws_member_assistant_relation_index
WHERE session_count > 0
AND (os_label IN ('MAIN', 'COMANAGE') OR session_count >= 3)
"""
)
return [