""" 绩效服务 负责绩效概览(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()