主线 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>
672 lines
24 KiB
Python
672 lines
24 KiB
Python
"""
|
||
绩效服务
|
||
|
||
负责绩效概览(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_id(PERF-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_score(RS 分数)
|
||
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_id(assistant_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()
|