Files
Neo-ZQYY/apps/backend/app/services/performance_service.py
Neo 2a7a5d68aa 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>
2026-04-20 06:32:07 +08:00

672 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
绩效服务
负责绩效概览PERF-1和绩效明细PERF-2的业务逻辑。
所有 FDW 查询通过 fdw_queries 模块执行,本模块不直接操作 SQL。
RNS1.1 组件 5。
"""
from __future__ import annotations
import logging
from collections import defaultdict
from datetime import datetime
from decimal import Decimal
from fastapi import HTTPException
from app.services import fdw_queries
from app.services.task_manager import _get_assistant_id, compute_income_trend
from app.trace.decorators import trace_service
logger = logging.getLogger(__name__)
def _get_connection():
"""延迟导入 get_connection避免纯函数测试时触发模块级导入失败。"""
from app.database import get_connection
return get_connection()
# ---------------------------------------------------------------------------
# 纯函数:可被属性测试直接调用
# ---------------------------------------------------------------------------
@trace_service(description_zh="format_income_desc", description_en="Format Income Desc")
def format_income_desc(rate: float, hours: float) -> str:
"""
格式化收入明细描述。
格式: "{rate}元/h × {hours}h"
"""
# 去除不必要的尾零
rate_str = f"{rate:g}"
hours_str = f"{hours:g}"
return f"{rate_str}元/h × {hours_str}h"
@trace_service(description_zh="group_records_by_date", description_en="Group Records By Date")
def group_records_by_date(
records: list[dict], *, include_avatar: bool = False,
rs_map: dict[int, float] | None = None,
) -> list[dict]:
"""
将服务记录按日期分组为 DateGroup 结构。
参数:
records: 服务记录列表(已按 settle_time DESC 排序)
include_avatar: 是否包含 member_idPERF-1 需要前端计算头像颜色)
返回按日期倒序排列的 DateGroup 列表。
"""
groups: dict[str, list[dict]] = defaultdict(list)
for rec in records:
settle_time = rec.get("settle_time")
if settle_time is None:
continue
# 提取日期字符串
if hasattr(settle_time, "strftime"):
date_key = settle_time.strftime("%Y-%m-%d")
else:
date_key = str(settle_time)[:10]
# 时间范围
start_time = rec.get("start_time")
end_time = rec.get("end_time")
time_range = _format_time_range(start_time, end_time)
# CHANGE 2026-03-24 | 课程类型直接用数据库原始值skill_name不做二次映射
raw_course_type = rec.get("course_type", "") or "基础课"
# 散客member_id ≤ 0展示"散客待转换会员"
# 真实会员姓名缺失时回退"未知客户"
mid_for_name = rec.get("member_id")
is_scattered = not mid_for_name or mid_for_name <= 0
if is_scattered:
customer_name = "散客待转换会员"
else:
customer_name = rec.get("customer_name") or "未知客户"
record_item: dict = {
"customer_name": customer_name,
"is_scattered": is_scattered,
"time_range": time_range,
"hours": f"{rec.get('service_hours', 0.0):.1f}",
"course_type": raw_course_type,
"location": rec.get("table_name") or "",
"income": f"{rec.get('income', 0.0):.2f}",
}
# CHANGE 2026-03-24 | 头像颜色改为前端根据 member_id 计算,后端只传 member_id 和首字
if include_avatar:
mid = rec.get("member_id")
record_item["member_id"] = mid
# 散客/未知客户member_id 为空、0、负数→ "?"
if not mid or mid <= 0:
record_item["avatar_char"] = "?"
else:
record_item["avatar_char"] = customer_name[0] if customer_name else "?"
# CHANGE 2026-03-27 | 关系爱心标识:注入 heart_scoreRS 分数)
if rs_map and mid:
record_item["heart_score"] = rs_map.get(mid, 0.0)
else:
record_item["heart_score"] = 0.0
groups[date_key].append(record_item)
# 按日期倒序排列
sorted_dates = sorted(groups.keys(), reverse=True)
result = []
for date_key in sorted_dates:
recs = groups[date_key]
total_hours = sum(float(r["hours"]) for r in recs)
total_income = sum(float(r["income"]) for r in recs)
result.append({
"date": date_key,
"total_hours": f"{total_hours:.1f}",
"total_income": f"{total_income:.2f}",
"records": recs,
})
return result
@trace_service(description_zh="paginate_records", description_en="Paginate Records")
def paginate_records(
records: list[dict], page: int, page_size: int
) -> tuple[list[dict], bool]:
"""
对记录列表进行分页。
返回 (当前页记录, has_more)。
"""
total = len(records)
start = (page - 1) * page_size
end = start + page_size
page_records = records[start:end]
has_more = total > page * page_size
return page_records, has_more
@trace_service(description_zh="compute_summary", description_en="Compute Summary")
def compute_summary(records: list[dict]) -> dict:
"""
计算月度汇总。
返回 { total_count, total_hours, total_hours_raw, total_income }。
"""
total_count = len(records)
total_hours = sum(r.get("service_hours", 0.0) for r in records)
total_hours_raw = sum(r.get("service_hours_raw", 0.0) for r in records)
total_income = sum(r.get("income", 0.0) for r in records)
return {
"total_count": total_count,
"total_hours": round(total_hours, 2),
"total_hours_raw": round(total_hours_raw, 2),
"total_income": round(total_income, 2),
}
def _format_time_range(start_time, end_time) -> str:
"""格式化时间范围为 "HH:MM-HH:MM" 格式。"""
parts = []
for t in (start_time, end_time):
if t is None:
parts.append("--:--")
elif hasattr(t, "strftime"):
parts.append(t.strftime("%H:%M"))
else:
s = str(t)
# 尝试提取 HH:MM
if len(s) >= 16:
parts.append(s[11:16])
else:
parts.append(str(t))
return f"{parts[0]}-{parts[1]}"
def _format_date_label(dt) -> str:
"""格式化日期为 "M月D日" 格式。"""
if dt is None:
return ""
if hasattr(dt, "strftime"):
return f"{dt.month}{dt.day}"
s = str(dt)[:10]
try:
d = datetime.strptime(s, "%Y-%m-%d")
return f"{d.month}{d.day}"
except (ValueError, TypeError):
return s
# ---------------------------------------------------------------------------
# PERF-1: 绩效概览
# ---------------------------------------------------------------------------
@trace_service("获取绩效概览", "Get performance overview")
async def get_overview(
user_id: int, site_id: int, year: int, month: int
) -> dict:
"""
绩效概览PERF-1
1. 获取 assistant_id
2. fdw_queries.get_salary_calc() → 档位/收入/费率
3. fdw_queries.get_service_records() → 按日期分组为 DateGroup含 avatarChar/avatarColor
4. 聚合新客/常客列表
5. 计算 incomeItems含 desc 费率描述)
6. 查询上月收入 lastMonthIncome
"""
conn = _get_connection()
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
# ── 1. 当月绩效数据 ──
salary = fdw_queries.get_salary_calc(conn, site_id, assistant_id, year, month)
# ── 2. 上月绩效数据(用于 lastMonthIncome ──
prev_year, prev_month = (year, month - 1) if month > 1 else (year - 1, 12)
prev_salary = None
try:
prev_salary = fdw_queries.get_salary_calc(
conn, site_id, assistant_id, prev_year, prev_month
)
except Exception:
logger.warning("FDW 查询上月绩效失败", exc_info=True)
last_month_income = prev_salary["total_income"] if prev_salary else 0.0
# ── 3. 服务记录(全量,用于 DateGroup + 新客/常客) ──
# 获取全部记录(不分页)
all_records = fdw_queries.get_service_records(
conn, site_id, assistant_id, year, month,
limit=10000, offset=0,
)
# 按日期分组(含 avatar
# CHANGE 2026-03-27 | 批量查 RS 分数,注入到服务记录和客户列表
member_ids = list({r.get("member_id") for r in all_records if r.get("member_id")})
rs_map: dict[int, float] = {}
if member_ids:
try:
with fdw_queries._fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT member_id, COALESCE(rs_display, 0)
FROM app.v_dws_member_assistant_relation_index
WHERE assistant_id = %s AND member_id = ANY(%s)
""",
(assistant_id, member_ids),
)
for row in cur.fetchall():
rs_map[row[0]] = float(row[1])
except Exception:
logger.warning("查询 RS 分数失败", exc_info=True)
date_groups = group_records_by_date(all_records, include_avatar=True, rs_map=rs_map)
# ── 4. 新客/常客列表 ──
new_customers, regular_customers = _build_customer_lists(
conn, site_id, assistant_id, year, month, all_records, rs_map=rs_map
)
# ── 5. 构建响应 ──
# 助教信息(从 salary 或默认值)
coach_name = ""
coach_role = ""
store_name = ""
# ⚠️ auth 表结构: users(nickname), user_assistant_binding(binding_type)
# auth.sites 不存在; users 无 display_name; uab 无 role_label
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT u.nickname, uab.binding_type
FROM auth.user_assistant_binding uab
JOIN auth.users u ON uab.user_id = u.id
WHERE uab.assistant_id = %s AND uab.site_id = %s
AND uab.is_removed = false
LIMIT 1
""",
(assistant_id, site_id),
)
row = cur.fetchone()
if row:
coach_name = row[0] or ""
coach_role = row[1] or ""
conn.commit()
except Exception:
logger.warning("查询助教信息失败", exc_info=True)
current_income = salary["total_income"] if salary else 0.0
# CHANGE 2026-03-24 | basic_rate/incentive_rate 改为助教到手单价(客户价 - 球房提成),
# 不再使用 base_course_price/bonus_course_price客户收费标准
base_course_price = salary["basic_rate"] if salary else 0.0 # 客户收费标准
bonus_course_price = salary["incentive_rate"] if salary else 0.0 # 客户收费标准
base_deduction = salary["base_deduction"] if salary else 0.0
bonus_deduction_ratio = salary["bonus_deduction_ratio"] if salary else 0.0
# 助教到手单价 = 客户价 - 球房提成
basic_rate = base_course_price - base_deduction
incentive_rate = bonus_course_price * (1 - bonus_deduction_ratio)
basic_hours = salary["basic_hours"] if salary else 0.0
bonus_hours = salary["bonus_hours"] if salary else 0.0
pd_money = salary["assistant_pd_money_total"] if salary else 0.0
cx_money = salary["assistant_cx_money_total"] if salary else 0.0
top_rank_bonus = salary["top_rank_bonus"] if salary else 0.0
recharge_commission = salary["recharge_commission"] if salary else 0.0
# 收入明细项
income_items = _build_income_items(
basic_rate, incentive_rate, basic_hours, bonus_hours,
pd_money, cx_money,
top_rank_bonus=top_rank_bonus,
recharge_commission=recharge_commission,
)
# CHANGE 2026-03-24 | 档位信息从 cfg_performance_tier 配置表计算,
# 复用 task_manager._build_performance_summary 的逻辑
total_hours = salary["total_hours"] if salary else 0.0
tiers: list[dict] = []
try:
tiers = fdw_queries.get_performance_tiers(conn, site_id)
except Exception:
logger.warning("查询 cfg_performance_tier 失败", exc_info=True)
# 找到当前档位和下一档
tier_completed = False
next_tier_hours = 0.0
current_tier_data = None
next_tier_data = None
if tiers:
for i, t in enumerate(tiers):
if t["min_hours"] > total_hours:
next_tier_data = t
current_tier_data = tiers[i - 1] if i > 0 else tiers[0]
next_tier_hours = t["min_hours"]
break
if next_tier_data is None:
# 已达到或超过最高档
tier_completed = True
current_tier_data = tiers[-1]
upgrade_hours_needed = max(0.0, next_tier_hours - total_hours) if not tier_completed else 0.0
# 下一档到手费率
if next_tier_data:
next_basic_rate = base_course_price - next_tier_data["base_deduction"]
next_incentive_rate = bonus_course_price * (1 - next_tier_data["bonus_deduction_ratio"])
else:
next_basic_rate = 0.0
next_incentive_rate = 0.0
# bonus_money: 升到下一档后因抽成降低能多拿的钱
# 公式同 task_manager._build_performance_summary
upgrade_bonus = 0.0
if not tier_completed and current_tier_data and next_tier_data:
base_ded_diff = current_tier_data["base_deduction"] - next_tier_data["base_deduction"]
base_saving = next_tier_data["min_hours"] * base_ded_diff if base_ded_diff > 0 else 0.0
bonus_ratio_diff = current_tier_data["bonus_deduction_ratio"] - next_tier_data["bonus_deduction_ratio"]
bonus_saving = bonus_hours * bonus_course_price * bonus_ratio_diff if bonus_ratio_diff > 0 else 0.0
upgrade_bonus = round(base_saving + bonus_saving, 2)
return {
"coach_name": coach_name,
"coach_role": coach_role,
"store_name": store_name,
"monthly_income": f"¥{current_income:,.0f}",
"last_month_income": f"¥{last_month_income:,.0f}",
"current_tier": {
"basic_rate": basic_rate,
"incentive_rate": incentive_rate,
},
"next_tier": {
"basic_rate": next_basic_rate,
"incentive_rate": next_incentive_rate,
},
"upgrade_hours_needed": round(upgrade_hours_needed, 2),
"upgrade_bonus": upgrade_bonus,
"income_items": income_items,
"monthly_total": f"¥{current_income:,.2f}",
"this_month_records": date_groups,
"new_customers": new_customers,
"regular_customers": regular_customers,
}
finally:
conn.close()
def _build_income_items(
basic_rate: float,
incentive_rate: float,
basic_hours: float,
bonus_hours: float,
pd_money: float,
cx_money: float,
*,
top_rank_bonus: float = 0.0,
recharge_commission: float = 0.0,
) -> list[dict]:
"""
构建收入明细项列表。
CHANGE 2026-03-24 | 始终显示所有项基础课、激励课、Top3销冠奖、充值提成即使为 0
Top3销冠奖为 0 时 desc 显示"继续努力"
"""
items = []
# 基础课收入(始终显示)
items.append({
"icon": "💰",
"label": "基础课收入",
"desc": format_income_desc(basic_rate, basic_hours),
"value": f"¥{pd_money:,.2f}",
})
# 激励课收入(始终显示)
items.append({
"icon": "🎯",
"label": "激励课收入",
"desc": format_income_desc(incentive_rate, bonus_hours),
"value": f"¥{cx_money:,.2f}",
})
# Top3销冠奖始终显示为 0 时 desc 显示"继续努力"
items.append({
"icon": "🏆",
"label": "Top3销冠奖",
"desc": "继续努力" if top_rank_bonus == 0 else "本月销冠奖励",
"value": f"¥{top_rank_bonus:,.2f}",
})
# CHANGE 2026-03-24 | 充值提成(始终显示)
items.append({
"icon": "💳",
"label": "充值提成",
"desc": "充值激励",
"value": f"¥{recharge_commission:,.2f}",
})
return items
def _build_customer_lists(
conn,
site_id: int,
assistant_id: int,
year: int,
month: int,
all_records: list[dict],
*,
rs_map: dict[int, float] | None = None,
) -> tuple[list[dict], list[dict]]:
"""
构建新客和常客列表。
新客: 本月有服务记录但本月之前无记录的客户
常客: 本月服务次数 ≥ 2 的客户统计数据拉近90天
CHANGE 2026-03-24 | 头像颜色改为前端根据 member_id 计算,后端只传 member_id 和首字。
CHANGE 2026-03-24 | 常客展示数据改为近90天聚合判定标准不变本月≥2次
"""
if not all_records:
return [], []
# 按 member_id 聚合本月记录
member_stats: dict[int, dict] = {}
for rec in all_records:
mid = rec.get("member_id")
if mid is None:
continue
if mid not in member_stats:
member_stats[mid] = {
"customer_name": rec.get("customer_name") or "未知客户",
"count": 0,
"total_hours": 0.0,
"total_income": 0.0,
"last_service": rec.get("settle_time"),
}
stats = member_stats[mid]
stats["count"] += 1
stats["total_hours"] += rec.get("service_hours", 0.0)
stats["total_income"] += rec.get("income", 0.0)
if stats["last_service"] is None:
stats["last_service"] = rec.get("settle_time")
member_ids = list(member_stats.keys())
# 查询历史记录(本月之前是否有服务记录)— 用于新客判定
historical_members: set[int] = set()
try:
start_date = f"{year}-{month:02d}-01"
with fdw_queries._fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT DISTINCT tenant_member_id
FROM app.v_dwd_assistant_service_log
WHERE site_assistant_id = %s
AND is_delete = 0
AND create_time < %s::timestamptz
AND tenant_member_id = ANY(%s)
""",
(assistant_id, start_date, member_ids),
)
for row in cur.fetchall():
historical_members.add(row[0])
except Exception:
logger.warning("查询历史客户记录失败", exc_info=True)
# 查询近90天聚合数据常客展示用
if month == 12:
next_month_start = f"{year + 1}-01-01"
else:
next_month_start = f"{year}-{month + 1:02d}-01"
stats_90d: dict[int, dict] = {}
try:
rows_90d = fdw_queries.get_service_records_90days(
conn, site_id, assistant_id, next_month_start,
)
for r in rows_90d:
stats_90d[r["member_id"]] = r
except Exception:
logger.warning("查询近90天服务记录失败", exc_info=True)
new_customers = []
regular_customers = []
for mid, stats in member_stats.items():
# CHANGE 2026-03-27 | 过滤散客/未知客户member_id ≤ 0不进入新客和常客列表
if mid <= 0:
continue
name = stats["customer_name"]
char = name[0] if name else "?"
# 新客:历史无记录
if mid not in historical_members:
last_service_dt = stats["last_service"]
new_customers.append({
"name": name,
"member_id": mid,
"avatar_char": char,
"last_service": _format_date_label(last_service_dt),
"count": stats["count"],
"heart_score": rs_map.get(mid, 0.0) if rs_map else 0.0,
})
# 常客:本月 ≥ 2 次展示数据用90天聚合
if stats["count"] >= 2:
s90 = stats_90d.get(mid)
if s90:
reg_count = s90["count"]
reg_hours = round(s90["total_hours"], 2)
reg_income = s90["total_income"]
else:
# 90天查询失败时回退到本月数据
reg_count = stats["count"]
reg_hours = round(stats["total_hours"], 2)
reg_income = stats["total_income"]
regular_customers.append({
"name": name,
"member_id": mid,
"avatar_char": char,
"hours": reg_hours,
"income": f"¥{reg_income:,.2f}",
"count": reg_count,
"heart_score": rs_map.get(mid, 0.0) if rs_map else 0.0,
})
# 新客按最后服务时间倒序
new_customers.sort(key=lambda x: x.get("last_service", ""), reverse=True)
# 常客按收入倒序
regular_customers.sort(
key=lambda x: float(x.get("income", "¥0").replace("¥", "").replace(",", "")),
reverse=True,
)
return new_customers, regular_customers
# ---------------------------------------------------------------------------
# PERF-2: 绩效明细
# ---------------------------------------------------------------------------
@trace_service("获取绩效明细", "Get performance records")
async def get_records(
user_id: int, site_id: int,
year: int, month: int, page: int, page_size: int,
assistant_id_override: int | None = None,
) -> dict:
"""
绩效明细PERF-2
1. 获取 assistant_idassistant_id_override 非空时直接使用,跳过 user 绑定查询)
2. fdw_queries.get_service_records() 带分页
3. 按日期分组为 dateGroups不含 avatarChar/avatarColor
4. 计算 summary 汇总
5. 返回 { summary, dateGroups, hasMore }
assistant_id_override 用于"管理员/店长查看其他助教"场景,
调用方负责完成越权校验后再传入目标 assistant_id。
"""
conn = _get_connection()
try:
if assistant_id_override is not None:
assistant_id = assistant_id_override
else:
assistant_id = _get_assistant_id(conn, user_id, site_id)
# CHANGE 2026-04-20 | 性能优化summary 改用 SQL 聚合,
# 不再先 limit=100000 全量拉取再 Python 算 summary
summary = fdw_queries.get_service_records_summary(
conn, site_id, assistant_id, year, month,
)
# 分页获取记录
offset = (page - 1) * page_size
page_records = fdw_queries.get_service_records(
conn, site_id, assistant_id, year, month,
limit=page_size, offset=offset,
)
# 判断 hasMore由 summary.total_count 直接推算,避免再次拉全量)
has_more = summary["total_count"] > page * page_size
# CHANGE 2026-03-27 | 批量查 RS 分数,注入到服务记录
page_member_ids = list({r.get("member_id") for r in page_records if r.get("member_id")})
rs_map: dict[int, float] = {}
if page_member_ids:
try:
with fdw_queries._fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT member_id, COALESCE(rs_display, 0)
FROM app.v_dws_member_assistant_relation_index
WHERE assistant_id = %s AND member_id = ANY(%s)
""",
(assistant_id, page_member_ids),
)
for row in cur.fetchall():
rs_map[row[0]] = float(row[1])
except Exception:
logger.warning("PERF-2 查询 RS 分数失败", exc_info=True)
# 按日期分组(含 member_id / avatar_char前端计算头像颜色
date_groups = group_records_by_date(page_records, include_avatar=True, rs_map=rs_map)
return {
"summary": summary,
"date_groups": date_groups,
"has_more": has_more,
}
finally:
conn.close()