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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user