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:
@@ -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_income(DWS 层已扣抽成)
|
||||
"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 AC3,rs_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,),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user