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:
@@ -180,10 +180,9 @@ def get_last_visit_days(
|
||||
"""
|
||||
批量查询客户距上次到店天数。
|
||||
|
||||
来源: app.v_dwd_assistant_service_log。
|
||||
废单排除: is_delete = 0(RLS 视图使用 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.0(DWD 层服务记录无折前时长字段)。
|
||||
"""
|
||||
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-4:consume_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_index(RLS 视图)
|
||||
返回: {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 [
|
||||
|
||||
Reference in New Issue
Block a user