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

@@ -530,10 +530,12 @@ class MemberIndexBaseTask(BaseIndexTask):
enable_stop_exception = int(params.get('enable_stop_high_balance_exception', 0)) == 1
high_balance_threshold = float(params.get('high_balance_threshold', 1000))
# CHANGE 2026-04-12 | STOP 不再排除:超出 recency 窗口的老客归入 OLD 继续计算
# WBI 衰减公式自然给出高分,避免最需要召回的客户被遗漏
if data.t_a >= recency_days:
if enable_stop_exception and data.sv_balance >= high_balance_threshold:
return "STOP", "STOP_HIGH_BALANCE", True
return "STOP", "STOP", False
return "OLD", "STOP_OVERDUE", True
new_visit_threshold = int(params.get('new_visit_threshold', 2))
new_days_threshold = int(params.get('new_days_threshold', 30))

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional
from .member_index_base import MemberActivityData, MemberIndexBaseTask
@@ -202,9 +203,10 @@ class NewconvIndexTask(MemberIndexBaseTask):
avg_raw=sum(all_raw) / len(all_raw)
)
# P19: 回测模式传入 calc_time
calc_time = (context.as_of_date if context and context.as_of_date else None)
inserted = self._save_newconv_data(newconv_list, calc_time=calc_time)
# 日快照模式:始终按 stat_date 写入
now = (context.as_of_date if context and context.as_of_date else None) or datetime.now(self.tz)
stat_date = now.date() if hasattr(now, 'date') else now
inserted = self._save_newconv_data(newconv_list, stat_date=stat_date)
self.logger.info("NCI calculation finished, inserted %d rows", inserted)
return {
@@ -288,30 +290,23 @@ class NewconvIndexTask(MemberIndexBaseTask):
if data.raw_score < 0:
data.raw_score = 0.0
def _save_newconv_data(self, data_list: List[MemberNewconvData], *, calc_time=None) -> int:
"""保存 NCI 数据"""
def _save_newconv_data(self, data_list: List[MemberNewconvData], *, stat_date) -> int:
"""日快照模式:按 (site_id, stat_date) 删除后插入。"""
if not data_list:
return 0
site_id = data_list[0].activity.site_id
# P19: 回测模式按 calc_time 删除(保留其他快照),正常模式按 site_id 全量刷新
use_param_time = calc_time is not None
with self.db.conn.cursor() as cur:
if use_param_time:
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s AND calc_time = %s",
(site_id, calc_time),
)
else:
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s",
(site_id,),
)
from datetime import date as date_type
if not isinstance(stat_date, date_type):
stat_date = stat_date.date() if hasattr(stat_date, 'date') else stat_date
# P19: 回测模式传入 calc_time正常模式用 NOW()
use_param_time = calc_time is not None
time_placeholder = "%s, %s, %s" if use_param_time else "NOW(), NOW(), NOW()"
insert_sql = f"""
site_id = data_list[0].activity.site_id
with self.db.conn.cursor() as cur:
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s AND stat_date = %s",
(site_id, stat_date),
)
insert_sql = """
INSERT INTO dws.dws_member_newconv_index (
site_id, tenant_id, member_id,
status, segment,
@@ -325,7 +320,7 @@ class NewconvIndexTask(MemberIndexBaseTask):
raw_score_welcome, raw_score_convert, raw_score,
display_score_welcome, display_score_convert, display_score,
last_wechat_touch_time,
calc_time, created_at, updated_at
calc_time, created_at, updated_at, stat_date
) VALUES (
%s, %s, %s,
%s, %s,
@@ -339,32 +334,40 @@ class NewconvIndexTask(MemberIndexBaseTask):
%s, %s, %s,
%s, %s, %s,
%s,
{time_placeholder}
NOW(), NOW(), NOW(), %s
)
"""
inserted = 0
# 批量写入executemany 替代逐行 execute
batch_params = []
for data in data_list:
activity = data.activity
batch_params.append((
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.need_new, data.salvage_new, data.recharge_new, data.value_new,
data.welcome_new,
data.raw_score_welcome, data.raw_score_convert, data.raw_score,
data.display_score_welcome, data.display_score_convert, data.display_score,
None,
stat_date,
))
from psycopg2.extras import execute_batch
with self.db.conn.cursor() as cur:
for data in data_list:
activity = data.activity
params = (
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.need_new, data.salvage_new, data.recharge_new, data.value_new,
data.welcome_new,
data.raw_score_welcome, data.raw_score_convert, data.raw_score,
data.display_score_welcome, data.display_score_convert, data.display_score,
None,
)
if use_param_time:
params = params + (calc_time, calc_time, calc_time)
cur.execute(insert_sql, params)
inserted += cur.rowcount
execute_batch(cur, insert_sql, batch_params, page_size=200)
inserted = len(batch_params)
# 保留策略:清理 365 天前的快照
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s AND stat_date < CURRENT_DATE - INTERVAL '365 days'",
(site_id,),
)
self.db.conn.commit()
return inserted

View File

@@ -180,9 +180,9 @@ class RelationIndexTask(BaseIndexTask):
self._apply_display_scores(pair_map, params_rs, params_ms, params_ml, site_id)
# P19: 仅回测模式传 calc_time按 calc_time 删除保留其他快照),正常模式传 None按 site_id 全量刷新)
backtest_calc_time = now if (context and context.as_of_date) else None
inserted = self._save_relation_rows(site_id, list(pair_map.values()), calc_time=backtest_calc_time)
# 日快照模式:始终按 stat_date 写入/覆盖,支持多日快照共存
stat_date = now.date() if hasattr(now, 'date') else now
inserted = self._save_relation_rows(site_id, list(pair_map.values()), stat_date=stat_date)
self.logger.info("关系指数计算完成,写入 %d 条记录", inserted)
return {
@@ -585,27 +585,23 @@ class RelationIndexTask(BaseIndexTask):
return "asinh"
return "none"
def _save_relation_rows(self, site_id: int, rows: List[RelationPairMetrics], *, calc_time: Optional[datetime] = None) -> int:
# P19: 回测模式传入 calc_time正常模式用 NOW()
use_param_time = calc_time is not None
def _save_relation_rows(self, site_id: int, rows: List[RelationPairMetrics], *, stat_date) -> int:
"""日快照模式:始终按 (site_id, stat_date) 删除后插入,支持多日快照共存。"""
from datetime import date as date_type
if not isinstance(stat_date, date_type):
stat_date = stat_date.date() if hasattr(stat_date, 'date') else stat_date
with self.db.conn.cursor() as cur:
# P19: 回测模式按 calc_time 删除(保留其他快照),正常模式按 site_id 全量刷新
if use_param_time:
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s AND calc_time = %s",
(site_id, calc_time),
)
else:
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s",
(site_id,),
)
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s AND stat_date = %s",
(site_id, stat_date),
)
if not rows:
self.db.conn.commit()
return 0
insert_sql = f"""
insert_sql = """
INSERT INTO dws.dws_member_assistant_relation_index (
site_id, tenant_id, member_id, assistant_id,
session_count, total_duration_minutes, basic_session_count, incentive_session_count,
@@ -614,7 +610,7 @@ class RelationIndexTask(BaseIndexTask):
os_share, os_label, os_rank,
ms_f_short, ms_f_long, ms_raw, ms_display,
ml_order_count, ml_allocated_amount, ml_raw, ml_display,
calc_time, created_at, updated_at
calc_time, created_at, updated_at, stat_date
) VALUES (
%s, %s, %s, %s,
%s, %s, %s, %s,
@@ -623,42 +619,34 @@ class RelationIndexTask(BaseIndexTask):
%s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
{('%s, %s, %s' if use_param_time else 'NOW(), NOW(), NOW()')}
NOW(), NOW(), NOW(), %s
)
"""
inserted = 0
for row in rows:
params = (
row.site_id,
row.tenant_id,
row.member_id,
row.assistant_id,
row.session_count,
row.total_duration_minutes,
row.basic_session_count,
row.incentive_session_count,
# 批量写入executemany 替代逐行 execute
batch_params = [
(
row.site_id, row.tenant_id, row.member_id, row.assistant_id,
row.session_count, row.total_duration_minutes,
row.basic_session_count, row.incentive_session_count,
row.days_since_last_session,
row.rs_f,
row.rs_d,
row.rs_r,
row.rs_raw,
row.rs_display,
row.os_share,
row.os_label,
row.os_rank,
row.ms_f_short,
row.ms_f_long,
row.ms_raw,
row.ms_display,
row.ml_order_count,
row.ml_allocated_amount,
row.ml_raw,
row.ml_display,
row.rs_f, row.rs_d, row.rs_r, row.rs_raw, row.rs_display,
row.os_share, row.os_label, row.os_rank,
row.ms_f_short, row.ms_f_long, row.ms_raw, row.ms_display,
row.ml_order_count, row.ml_allocated_amount, row.ml_raw, row.ml_display,
stat_date,
)
if use_param_time:
params = params + (calc_time, calc_time, calc_time)
cur.execute(insert_sql, params)
inserted += max(cur.rowcount, 0)
for row in rows
]
from psycopg2.extras import execute_batch
execute_batch(cur, insert_sql, batch_params, page_size=200)
inserted = len(batch_params)
# 保留策略:清理 365 天前的快照
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s AND stat_date < CURRENT_DATE - INTERVAL '365 days'",
(site_id,),
)
self.db.conn.commit()
return inserted

View File

@@ -178,9 +178,10 @@ class WinbackIndexTask(MemberIndexBaseTask):
avg_raw=sum(all_raw) / len(all_raw)
)
# P19: 回测模式传入 calc_time
calc_time = (context.as_of_date if context and context.as_of_date else None)
inserted = self._save_winback_data(winback_list, calc_time=calc_time)
# 日快照模式:始终按 stat_date 写入
now = (context.as_of_date if context and context.as_of_date else None) or datetime.now(self.tz)
stat_date = now.date() if hasattr(now, 'date') else now
inserted = self._save_winback_data(winback_list, stat_date=stat_date)
self.logger.info("WBI calculation finished, inserted %d rows", inserted)
return {
@@ -341,29 +342,23 @@ class WinbackIndexTask(MemberIndexBaseTask):
if data.raw_score < 0:
data.raw_score = 0.0
def _save_winback_data(self, data_list: List[MemberWinbackData], *, calc_time: Optional[datetime] = None) -> int:
"""保存 WBI 数据"""
def _save_winback_data(self, data_list: List[MemberWinbackData], *, stat_date) -> int:
"""日快照模式:按 (site_id, stat_date) 删除后插入。"""
if not data_list:
return 0
site_id = data_list[0].activity.site_id
# P19: 回测模式传入 calc_time正常模式用 NOW()
use_param_time = calc_time is not None
# P19: 回测模式按 calc_time 删除(保留其他快照),正常模式按 site_id 全量刷新
with self.db.conn.cursor() as cur:
if use_param_time:
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s AND calc_time = %s",
(site_id, calc_time),
)
else:
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s",
(site_id,),
)
from datetime import date as date_type
if not isinstance(stat_date, date_type):
stat_date = stat_date.date() if hasattr(stat_date, 'date') else stat_date
time_placeholder = "%s, %s, %s" if use_param_time else "NOW(), NOW(), NOW()"
insert_sql = f"""
site_id = data_list[0].activity.site_id
with self.db.conn.cursor() as cur:
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s AND stat_date = %s",
(site_id, stat_date),
)
insert_sql = """
INSERT INTO dws.dws_member_winback_index (
site_id, tenant_id, member_id,
status, segment,
@@ -376,7 +371,7 @@ class WinbackIndexTask(MemberIndexBaseTask):
ideal_interval_days, ideal_next_visit_date,
raw_score, display_score,
last_wechat_touch_time,
calc_time, created_at, updated_at
calc_time, created_at, updated_at, stat_date
) VALUES (
%s, %s, %s,
%s, %s,
@@ -389,31 +384,39 @@ class WinbackIndexTask(MemberIndexBaseTask):
%s, %s,
%s, %s,
%s,
{time_placeholder}
NOW(), NOW(), NOW(), %s
)
"""
inserted = 0
# 批量写入executemany 替代逐行 execute
batch_params = []
for data in data_list:
activity = data.activity
batch_params.append((
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.overdue_old, data.overdue_cdf_p, data.drop_old, data.recharge_old, data.value_old,
data.ideal_interval_days, data.ideal_next_visit_date,
data.raw_score, data.display_score,
None,
stat_date,
))
from psycopg2.extras import execute_batch
with self.db.conn.cursor() as cur:
for data in data_list:
activity = data.activity
params = (
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.overdue_old, data.overdue_cdf_p, data.drop_old, data.recharge_old, data.value_old,
data.ideal_interval_days, data.ideal_next_visit_date,
data.raw_score, data.display_score,
None,
)
if use_param_time:
params = params + (calc_time, calc_time, calc_time)
cur.execute(insert_sql, params)
inserted += cur.rowcount
execute_batch(cur, insert_sql, batch_params, page_size=200)
inserted = len(batch_params)
# 保留策略:清理 365 天前的快照
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s AND stat_date < CURRENT_DATE - INTERVAL '365 days'",
(site_id,),
)
self.db.conn.commit()
return inserted