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

@@ -25,6 +25,7 @@ from decimal import Decimal
from app.services import fdw_queries
from app.services.task_generator import compute_heart_icon
from app.services.task_manager import build_performance_summary
from app.trace.decorators import trace_service
logger = logging.getLogger(__name__)
@@ -87,6 +88,51 @@ def _format_currency(amount: float) -> str:
# ── 6.1 核心函数 ──────────────────────────────────────────
@trace_service("获取助教 banner", "Get coach banner")
async def get_coach_banner(coach_id: int, site_id: int) -> dict:
"""
助教 banner 轻量信息(仅 name / level / store_name
用途:小程序需要展示助教 banner 但不需要详情页全套数据时
(如 PERF-2 业绩明细页 banner。比 get_coach_detail 快一个数量级
(仅 2~3 条 SQL跳过绩效/TOP/服务记录/任务/备注/历史月份)。
"""
conn = _get_biz_connection()
try:
# 1. name + level来自 v_dim_assistant + level_map
info = fdw_queries.get_assistant_info(conn, site_id, coach_id)
if not info:
raise HTTPException(status_code=404, detail="助教不存在")
# 2. store_name来自业务库 biz.sites
store_name = ""
try:
with conn.cursor() as cur:
cur.execute(
"SELECT site_name FROM biz.sites WHERE site_id = %s",
(site_id,),
)
row = cur.fetchone()
if row:
store_name = row[0] or ""
conn.commit()
except Exception:
logger.warning("查询 store_name 失败,降级为空", exc_info=True)
try:
conn.rollback()
except Exception:
pass
return {
"id": coach_id,
"name": info.get("name", ""),
"level": info.get("level", ""),
"store_name": store_name,
}
finally:
conn.close()
@trace_service("获取助教详情", "Get coach detail")
async def get_coach_detail(coach_id: int, site_id: int) -> dict:
"""
@@ -103,14 +149,35 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
now = datetime.date.today()
# 绩效数据(当月
salary_this = fdw_queries.get_salary_calc(
conn, site_id, coach_id, now.year, now.month
)
if not salary_this:
salary_this = {}
# 门店名称(用于小程序 banner 展示,跟随被查看助教所在门店
# 必须在所有 fdw 查询前执行:后续任意 fdw 查询失败会污染事务
# psycopg2 的 InFailedSqlTransaction导致此处 SELECT 拿不到结果。
store_name = ""
try:
with conn.cursor() as cur:
cur.execute(
"SELECT site_name FROM biz.sites WHERE site_id = %s",
(site_id,),
)
row = cur.fetchone()
if row:
store_name = row[0] or ""
conn.commit()
except Exception:
logger.warning("查询 store_name 失败,降级为空", exc_info=True)
try:
conn.rollback()
except Exception:
pass
# customerBalance该助教所有客户余额合计
# 绩效数据:统一使用 build_performance_summary与任务页同源数据来自 monthly_summary 实时值)
try:
perf_summary = build_performance_summary(conn, site_id, coach_id)
except Exception:
logger.warning("build_performance_summary 失败,降级为空", exc_info=True)
perf_summary = {}
# customerBalance该助教所有客户余额合计绩效概览之外的扩展数据
customer_balance = 0.0
try:
top_custs = fdw_queries.get_coach_top_customers(conn, site_id, coach_id, limit=1000)
@@ -121,76 +188,65 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
except Exception:
logger.warning("查询 customerBalance 失败,降级为 0", exc_info=True)
# tasksCompleted当月已完成任务数
# tasksCompleted + taskStats:当月已完成任务数,按类型分组
# tasksCompleted + taskStats当月已完成任务数狭义助教亲自完成不含 resolved
tasks_completed = 0
task_stats = {"callback": 0, "recall": 0}
try:
month_start = now.replace(day=1)
with conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*)
SELECT task_type, COUNT(*) AS cnt
FROM biz.coach_tasks
WHERE assistant_id = %s
AND status = 'completed'
AND updated_at >= %s
GROUP BY task_type
""",
(coach_id, month_start),
)
row = cur.fetchone()
tasks_completed = row[0] if row else 0
for row in cur.fetchall():
task_type, cnt = row[0], row[1]
tasks_completed += cnt
if task_type == "follow_up_visit":
task_stats["callback"] += cnt
elif task_type in ("high_priority_recall", "priority_recall"):
task_stats["recall"] += cnt
except Exception:
logger.warning("查询 tasksCompleted 失败,降级为 0", exc_info=True)
# customerCount不重复客户数(从 top_customers 获取)
customer_count = 0
try:
cc_map = fdw_queries.get_monthly_customer_count(
conn, site_id, coach_id, [now.strftime("%Y-%m-01")]
)
customer_count = sum(cc_map.values())
except Exception:
logger.warning("查询 customerCount 失败,降级为 0", exc_info=True)
# customerCount从绩效概览获取,回退到独立查询
customer_count = perf_summary.get("total_customers", 0)
if not customer_count:
try:
cc_map = fdw_queries.get_monthly_customer_count(
conn, site_id, coach_id, [now.strftime("%Y-%m-01")]
)
customer_count = sum(cc_map.values())
except Exception:
logger.warning("查询 customerCount 失败,降级为 0", exc_info=True)
# 构建 performance 字段:合并绩效概览 + 助教详情专属扩展字段
performance = {
"monthly_hours": salary_this.get("total_hours", 0.0),
# CHANGE 2026-03-26 | 到手 = base_income + bonus_income + bonus_money + room_incomeDWS 层已扣抽成
"monthly_salary": (
salary_this.get("assistant_pd_money_total", 0.0)
+ salary_this.get("assistant_cx_money_total", 0.0)
+ salary_this.get("bonus_money", 0.0)
+ salary_this.get("room_income", 0.0)
),
**perf_summary,
# 助教详情页专属字段(绩效概览中没有的
"customer_balance": customer_balance,
"tasks_completed": tasks_completed,
"perf_current": salary_this.get("total_hours", 0.0),
# CHANGE 2026-03-19 | perf_target 从 tier_nodes 推算,不再依赖 salary_calc 的硬编码 0
"perf_target": 0.0, # 占位,下方用 tier_nodes 覆盖
# 兼容旧字段名(前端渐进适配)
"monthly_hours": perf_summary.get("total_hours", 0.0),
"monthly_salary": perf_summary.get("total_income", 0.0),
}
# ── 扩展模块(独立 try/except 优雅降级)──
# 收入明细 + 档位
# 收入明细
try:
income = _build_income(conn, site_id, coach_id, now)
except Exception:
logger.warning("构建 income 失败,降级为空", exc_info=True)
income = {"this_month": [], "last_month": []}
try:
tier_nodes = _build_tier_nodes(conn, site_id)
except Exception:
logger.warning("构建 tierNodes 失败,降级为 fallback", exc_info=True)
tier_nodes = list(_FALLBACK_TIER_NODES)
# CHANGE 2026-03-19 | 用 tier_nodes 推算 perf_target下一档 min_hours
current_hours = performance["perf_current"]
perf_target = tier_nodes[-1] if tier_nodes else 0.0 # 默认最高档
for node in tier_nodes:
if node > current_hours:
perf_target = node
break
performance["perf_target"] = perf_target
# TOP 客户
try:
top_customers = _build_top_customers(conn, site_id, coach_id)
@@ -231,17 +287,20 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
"id": coach_id,
"name": assistant_info.get("name", ""),
"avatar": assistant_info.get("avatar", ""),
"level": salary_this.get("coach_level", assistant_info.get("level", "")),
"level": perf_summary.get("current_tier_label", assistant_info.get("level", "")),
"store_name": store_name,
"skills": assistant_info.get("skills", []),
"work_years": assistant_info.get("work_years", 0.0),
"customer_count": customer_count,
"hire_date": assistant_info.get("hire_date"),
# 绩效
# 绩效(包含 tier_nodes、total_hours 等完整字段)
"performance": performance,
# 收入
"income": income,
# 档位
"tier_nodes": tier_nodes,
# 档位(保留顶级字段兼容前端已有逻辑)
"tier_nodes": perf_summary.get("tier_nodes", list(_FALLBACK_TIER_NODES)),
# 当月任务完成统计(回访/召回分类)
"task_stats": task_stats,
# 任务分组
"visible_tasks": task_groups["visible_tasks"],
"hidden_tasks": task_groups["hidden_tasks"],
@@ -377,7 +436,12 @@ def _build_top_customers(
result = []
for i, cust in enumerate(raw):
mid = cust.get("member_id")
name = cust.get("customer_name", "")
# 散客member_id ≤ 0展示"散客待转换会员",头像首字统一为"?"
is_scattered = not mid or mid <= 0
if is_scattered:
name = "散客待转换会员"
else:
name = cust.get("customer_name", "")
score = relation_map.get(mid, 0.0)
# 四级 heart icon 映射P6 AC3rs_display 0-10 刻度)
@@ -398,7 +462,8 @@ def _build_top_customers(
result.append({
"id": mid or 0,
"name": name,
"initial": _get_initial(name),
"initial": "?" if is_scattered else _get_initial(name),
"is_scattered": is_scattered,
"avatar_gradient": _get_avatar_gradient(i),
"heart_emoji": heart_emoji,
"score": f"{score:.2f}",
@@ -428,7 +493,13 @@ def _build_service_records(
result = []
for i, rec in enumerate(raw):
name = rec.get("customer_name", "")
# 散客member_id ≤ 0展示"散客待转换会员",头像首字统一为"?"
mid = rec.get("member_id")
is_scattered = not mid or mid <= 0
if is_scattered:
name = "散客待转换会员"
else:
name = rec.get("customer_name", "")
course_type = rec.get("course_type", "")
# type_class 映射
@@ -446,11 +517,12 @@ def _build_service_records(
result.append({
"customer_id": rec.get("member_id"),
"customer_name": name,
"initial": _get_initial(name),
"initial": "?" if is_scattered else _get_initial(name),
"is_scattered": is_scattered,
"avatar_gradient": _get_avatar_gradient(i),
"type": course_type or "课程",
"type_class": type_class,
"table": rec.get("table_name") or None,
"table": rec.get("table_name") or "",
"duration": f"{hours:.1f}h",
"income": float(income),
"date": date_str,
@@ -481,7 +553,15 @@ def _build_task_groups(
FROM biz.coach_tasks
WHERE assistant_id = %s
AND status IN ('active', 'inactive', 'abandoned')
ORDER BY created_at DESC
ORDER BY
CASE task_type
WHEN 'high_priority_recall' THEN 0
WHEN 'priority_recall' THEN 1
WHEN 'follow_up_visit' THEN 2
WHEN 'relationship_building' THEN 3
ELSE 4
END,
created_at DESC
""",
(coach_id,),
)