# -*- coding: utf-8 -*- """ 助教服务 —— COACH-1 助教详情。 数据来源: - ETL 直连(fdw_queries):助教信息、绩效、TOP 客户、服务记录、历史月份 - 业务库(biz.*):助教任务、备注 ⚠️ DWD-DOC 强制规则: - 规则 1: 金额使用 items_sum 口径(ledger_amount),禁止 consume_money - 规则 2: 助教费用使用 assistant_pd_money + assistant_cx_money,禁止 service_fee - DQ-6: 会员信息通过 member_id JOIN v_dim_member (scd2_is_current=1) - DQ-7: 余额通过 member_id JOIN v_dim_member_card_account (scd2_is_current=1) - 废单排除: is_delete = 0 """ from __future__ import annotations import datetime import logging from fastapi import HTTPException from decimal import Decimal from app.services import fdw_queries from app.services.task_generator import compute_heart_icon logger = logging.getLogger(__name__) # ── 颜色/样式映射 ────────────────────────────────────────── LEVEL_COLOR_MAP = { "星级": "#FF6B6B", "高级": "#FFA726", "中级": "#42A5F5", "初级": "#66BB6A", } TASK_TYPE_MAP = { "follow_up_visit": {"label": "回访", "class": "tag-callback"}, "high_priority_recall": {"label": "紧急召回", "class": "tag-recall"}, "priority_recall": {"label": "优先召回", "class": "tag-recall"}, } # 头像渐变色池(循环使用) AVATAR_GRADIENTS = [ "from-blue-400 to-blue-600", "from-green-400 to-green-600", "from-purple-400 to-purple-600", "from-orange-400 to-orange-600", "from-pink-400 to-pink-600", ] # CHANGE 2026-03-19 | feiqiu-data-rules 规则 6 修复 | 删除硬编码 DEFAULT_TIER_NODES, # 档位节点改为从 cfg_performance_tier 配置表动态读取。 # 旧值 [0, 100, 130, 160, 190, 220] 与配置表实际值 [0, 120, 150, 180, 210] 不一致。 _FALLBACK_TIER_NODES: list[float] = [0, 120, 150, 180, 210] # 仅在配置表查询失败时使用 def _get_biz_connection(): """延迟导入业务库连接。""" from app.database import get_connection return get_connection() def _get_initial(name: str) -> str: """取姓氏首字作为头像文字。""" return name[0] if name else "?" def _get_avatar_gradient(index: int) -> str: """根据索引循环分配头像渐变色。""" return AVATAR_GRADIENTS[index % len(AVATAR_GRADIENTS)] def _format_currency(amount: float) -> str: """格式化金额:¥6,950。""" if amount >= 10000: return f"¥{amount:,.0f}" return f"¥{amount:,.0f}" # ── 6.1 核心函数 ────────────────────────────────────────── async def get_coach_detail(coach_id: int, site_id: int) -> dict: """ 助教详情(COACH-1)。 核心字段查询失败 → 500;扩展模块查询失败 → 空默认值(优雅降级)。 """ conn = _get_biz_connection() try: # ── 核心字段(失败直接抛 500)── assistant_info = fdw_queries.get_assistant_info(conn, site_id, coach_id) if not assistant_info: raise HTTPException(status_code=404, detail="助教不存在") 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 = {} # customerBalance:该助教所有客户余额合计 customer_balance = 0.0 try: top_custs = fdw_queries.get_coach_top_customers(conn, site_id, coach_id, limit=1000) member_ids = [c["member_id"] for c in top_custs if c.get("member_id")] if member_ids: balance_map = fdw_queries.get_member_balance(conn, site_id, member_ids) customer_balance = sum(float(v) for v in balance_map.values()) except Exception: logger.warning("查询 customerBalance 失败,降级为 0", exc_info=True) # tasksCompleted:当月已完成任务数 tasks_completed = 0 try: month_start = now.replace(day=1) with conn.cursor() as cur: cur.execute( """ SELECT COUNT(*) FROM biz.coach_tasks WHERE assistant_id = %s AND status = 'completed' AND updated_at >= %s """, (coach_id, month_start), ) row = cur.fetchone() tasks_completed = row[0] if row else 0 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) performance = { "monthly_hours": salary_this.get("total_hours", 0.0), "monthly_salary": salary_this.get("total_income", 0.0), "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 覆盖 } # ── 扩展模块(独立 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) except Exception: logger.warning("构建 topCustomers 失败,降级为空列表", exc_info=True) top_customers = [] # 近期服务记录 try: service_records = _build_service_records(conn, site_id, coach_id) except Exception: logger.warning("构建 serviceRecords 失败,降级为空列表", exc_info=True) service_records = [] # 任务分组 try: task_groups = _build_task_groups(coach_id, site_id, conn) except Exception: logger.warning("构建 taskGroups 失败,降级为空", exc_info=True) task_groups = {"visible_tasks": [], "hidden_tasks": [], "abandoned_tasks": []} # 备注 try: notes = _build_notes(coach_id, site_id, conn) except Exception: logger.warning("构建 notes 失败,降级为空列表", exc_info=True) notes = [] # 历史月份 try: history_months = _build_history_months(coach_id, site_id, conn) except Exception: logger.warning("构建 historyMonths 失败,降级为空列表", exc_info=True) history_months = [] return { # 基础信息 "id": coach_id, "name": assistant_info.get("name", ""), "avatar": assistant_info.get("avatar", ""), "level": salary_this.get("coach_level", assistant_info.get("level", "")), "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"), # 绩效 "performance": performance, # 收入 "income": income, # 档位 "tier_nodes": tier_nodes, # 任务分组 "visible_tasks": task_groups["visible_tasks"], "hidden_tasks": task_groups["hidden_tasks"], "abandoned_tasks": task_groups["abandoned_tasks"], # TOP 客户 "top_customers": top_customers, # 近期服务记录 "service_records": service_records, # 历史月份 "history_months": history_months, # 备注 "notes": notes, } finally: conn.close() # ── 6.2 收入明细 + 档位 ────────────────────────────────── def _build_income( conn, site_id: int, coach_id: int, now: datetime.date ) -> dict: """ 构建 income 模块。 thisMonth/lastMonth 各含 4 项: - 基础课时费(base_income / assistant_pd_money) - 激励课时费(bonus_income / assistant_cx_money) - 充值提成 - 酒水提成 ⚠️ DWD-DOC 规则 2: 使用 assistant_pd_money + assistant_cx_money 拆分。 """ # 当月 salary_this = fdw_queries.get_salary_calc( conn, site_id, coach_id, now.year, now.month ) or {} # 上月 if now.month == 1: last_year, last_month = now.year - 1, 12 else: last_year, last_month = now.year, now.month - 1 salary_last = fdw_queries.get_salary_calc( conn, site_id, coach_id, last_year, last_month ) or {} def _make_items(salary: dict) -> list[dict]: return [ { "label": "基础课时费", "amount": f"¥{salary.get('assistant_pd_money_total', 0.0):,.0f}", "color": "#42A5F5", }, { "label": "激励课时费", "amount": f"¥{salary.get('assistant_cx_money_total', 0.0):,.0f}", "color": "#FFA726", }, { "label": "充值提成", "amount": f"¥{salary.get('bonus_money', 0.0):,.0f}", "color": "#66BB6A", }, { "label": "酒水提成", "amount": f"¥{salary.get('room_income', 0.0):,.0f}", "color": "#AB47BC", }, ] return { "this_month": _make_items(salary_this), "last_month": _make_items(salary_last), } def _build_tier_nodes(conn: Any, site_id: int) -> list[float]: """ 从 cfg_performance_tier 配置表构建 tierNodes 档位节点数组。 ⚠️ feiqiu-data-rules 规则 6: 绩效档位必须从配置表读取,禁止硬编码。 """ # CHANGE 2026-03-19 | feiqiu-data-rules 规则 6 修复 | 从配置表动态读取档位节点 try: tiers = fdw_queries.get_performance_tiers(conn, site_id) if tiers: return [t["min_hours"] for t in tiers] except Exception: logger.warning("查询 cfg_performance_tier 失败,使用 fallback", exc_info=True) return list(_FALLBACK_TIER_NODES) # ── 6.3 TOP 客户 + 近期服务记录 ────────────────────────── def _build_top_customers( conn, site_id: int, coach_id: int ) -> list[dict]: """ 构建 topCustomers 模块(最多 20 条)。 ⚠️ DQ-6: 客户姓名通过 v_dim_member 获取。 ⚠️ DQ-7: 余额通过 v_dim_member_card_account 获取。 ⚠️ DWD-DOC 规则 1: consume 使用 ledger_amount(items_sum 口径)。 heartEmoji 四级映射(P6 AC3,rs_display 0-10 刻度): - score > 8.5 → "💖" - score > 7 → "🧡" - score > 5 → "💛" - score ≤ 5 → "💙" """ raw = fdw_queries.get_coach_top_customers(conn, site_id, coach_id, limit=20) if not raw: return [] # 获取关系指数(用于 heartEmoji) # 批量获取所有客户的关系指数 member_ids = [c["member_id"] for c in raw if c.get("member_id")] relation_map: dict[int, float] = {} for mid in member_ids: try: rels = fdw_queries.get_relation_index(conn, site_id, mid) for r in rels: if r.get("assistant_id") == coach_id: relation_map[mid] = r.get("relation_index", 0.0) break except Exception: pass result = [] for i, cust in enumerate(raw): mid = cust.get("member_id") name = cust.get("customer_name", "") score = relation_map.get(mid, 0.0) # 四级 heart icon 映射(P6 AC3,rs_display 0-10 刻度) heart_emoji = compute_heart_icon(Decimal(str(score))) if score > 8.5: score_color = "#FF6B6B" elif score > 7: score_color = "#FF8C00" elif score > 5: score_color = "#FFA726" else: score_color = "#5B9BD5" balance = cust.get("customer_balance", 0.0) consume = cust.get("total_consume", 0.0) result.append({ "id": mid or 0, "name": name, "initial": _get_initial(name), "avatar_gradient": _get_avatar_gradient(i), "heart_emoji": heart_emoji, "relation_score": f"{score:.2f}", "score_color": score_color, "service_count": cust.get("service_count", 0), "balance": _format_currency(balance), "consume": _format_currency(consume), }) return result def _build_service_records( conn, site_id: int, coach_id: int ) -> list[dict]: """ 构建 serviceRecords 模块。 ⚠️ DWD-DOC 规则 1: income 使用 ledger_amount。 ⚠️ 废单排除: is_delete = 0。 """ raw = fdw_queries.get_coach_service_records( conn, site_id, coach_id, limit=20, offset=0 ) if not raw: return [] result = [] for i, rec in enumerate(raw): name = rec.get("customer_name", "") course_type = rec.get("course_type", "") # type_class 映射 if "激励" in course_type or "超休" in course_type: type_class = "tag-bonus" else: type_class = "tag-base" create_time = rec.get("create_time") date_str = create_time.strftime("%Y-%m-%d %H:%M") if create_time else "" hours = rec.get("service_hours", 0.0) income = rec.get("income", 0.0) result.append({ "customer_id": rec.get("member_id"), "customer_name": name, "initial": _get_initial(name), "avatar_gradient": _get_avatar_gradient(i), "type": course_type or "课程", "type_class": type_class, "table": str(rec.get("table_id")) if rec.get("table_id") else None, "duration": f"{hours:.1f}h", "income": _format_currency(income), "date": date_str, "perf_hours": None, }) return result # ── 6.4 任务分组 + 备注 ────────────────────────────────── def _build_task_groups( coach_id: int, site_id: int, conn ) -> dict: """ 构建任务分组。 1. 查询 biz.coach_tasks WHERE assistant_id=coach_id 2. 按 status 分组:active→visibleTasks, inactive→hiddenTasks, abandoned→abandonedTasks 3. visible/hidden:关联 biz.notes 获取备注列表 4. abandoned:取 abandon_reason """ with conn.cursor() as cur: cur.execute( """ SELECT id, member_id, task_type, status, abandon_reason FROM biz.coach_tasks WHERE assistant_id = %s AND status IN ('active', 'inactive', 'abandoned') ORDER BY created_at DESC """, (coach_id,), ) rows = cur.fetchall() if not rows: return {"visible_tasks": [], "hidden_tasks": [], "abandoned_tasks": []} # 收集客户 ID 批量查询姓名 member_ids = list({r[1] for r in rows if r[1]}) member_name_map: dict[int, str] = {} if member_ids: try: info_map = fdw_queries.get_member_info(conn, site_id, member_ids) for mid, info in info_map.items(): member_name_map[mid] = info.get("nickname", "") except Exception: logger.warning("批量查询客户姓名失败", exc_info=True) # 收集任务 ID 批量查询备注 task_ids = [r[0] for r in rows if r[3] in ("active", "inactive")] task_notes_map: dict[int, list[dict]] = {} if task_ids: try: with conn.cursor() as cur: cur.execute( """ SELECT task_id, is_pinned, content, created_at FROM biz.notes WHERE task_id = ANY(%s) ORDER BY created_at DESC """, (task_ids,), ) for nr in cur.fetchall(): tid = nr[0] if tid not in task_notes_map: task_notes_map[tid] = [] task_notes_map[tid].append({ "pinned": bool(nr[1]), "text": nr[2] or "", "date": nr[3].isoformat() if nr[3] else "", }) except Exception: logger.warning("批量查询任务备注失败", exc_info=True) visible_tasks = [] hidden_tasks = [] abandoned_tasks = [] for row in rows: task_id, member_id, task_type, status, abandon_reason = row customer_name = member_name_map.get(member_id, "") task_meta = TASK_TYPE_MAP.get(task_type, {"label": task_type or "", "class": "tag-default"}) if status == "abandoned": abandoned_tasks.append({ "customer_name": customer_name, "reason": abandon_reason or "", }) else: notes_list = task_notes_map.get(task_id, []) item = { "type_label": task_meta["label"], "type_class": task_meta["class"], "customer_name": customer_name, "customer_id": member_id, "note_count": len(notes_list), "pinned": any(n.get("pinned") for n in notes_list), "notes": notes_list if notes_list else None, } if status == "active": visible_tasks.append(item) else: hidden_tasks.append(item) return { "visible_tasks": visible_tasks, "hidden_tasks": hidden_tasks, "abandoned_tasks": abandoned_tasks, } def _build_notes(coach_id: int, site_id: int, conn) -> list[dict]: """ 构建助教相关备注列表(最多 20 条)。 查询 biz.notes 中与该助教任务关联的备注,按 created_at 倒序。 ⚠️ DQ-6: 客户姓名通过 member_id JOIN v_dim_member。 """ with conn.cursor() as cur: cur.execute( """ SELECT n.id, n.content, n.created_at, n.ai_score, n.type AS tag_label, ct.member_id FROM biz.notes n LEFT JOIN biz.coach_tasks ct ON n.task_id = ct.id WHERE ct.assistant_id = %s ORDER BY n.created_at DESC LIMIT 20 """, (coach_id,), ) rows = cur.fetchall() if not rows: return [] # 批量获取客户姓名(DQ-6) member_ids = list({r[5] for r in rows if r[5]}) member_name_map: dict[int, str] = {} if member_ids: try: info_map = fdw_queries.get_member_info(conn, site_id, member_ids) for mid, info in info_map.items(): member_name_map[mid] = info.get("nickname", "") except Exception: logger.warning("查询备注客户姓名失败", exc_info=True) result = [] for r in rows: result.append({ "id": r[0], "content": r[1] or "", "timestamp": r[2].isoformat() if r[2] else "", "ai_score": r[3], "customer_name": member_name_map.get(r[5], ""), "tag_label": r[4] or "", "created_at": r[2].isoformat() if r[2] else "", }) return result # ── 6.5 历史月份统计(T2-6)────────────────────────────── def _build_history_months( coach_id: int, site_id: int, conn ) -> list[dict]: """ 构建 historyMonths 模块。 1. fdw_queries.get_salary_calc_multi_months() → 最近 6 个月工时/工资 2. fdw_queries.get_monthly_customer_count() → 各月客户数 3. biz.coach_tasks → 各月回访/召回完成数 4. 本月 estimated=True,历史月份 estimated=False 5. 格式化:customers→"22人",hours→"87.5h",salary→"¥6,950" """ now = datetime.date.today() # 生成最近 6 个月的月份列表(含本月) months: list[str] = [] for i in range(6): y = now.year m = now.month - i while m <= 0: m += 12 y -= 1 months.append(f"{y}-{m:02d}-01") # 批量查询绩效数据 salary_map = fdw_queries.get_salary_calc_multi_months( conn, site_id, coach_id, months ) # 批量查询各月客户数 customer_count_map = fdw_queries.get_monthly_customer_count( conn, site_id, coach_id, months ) # 查询各月回访/召回完成数 callback_map: dict[str, int] = {} recall_map: dict[str, int] = {} try: six_months_ago = months[-1] # 最早的月份 with conn.cursor() as cur: cur.execute( """ SELECT DATE_TRUNC('month', updated_at)::date AS month, task_type, COUNT(*) AS cnt FROM biz.coach_tasks WHERE assistant_id = %s AND status = 'completed' AND updated_at >= %s::date GROUP BY DATE_TRUNC('month', updated_at)::date, task_type """, (coach_id, six_months_ago), ) for row in cur.fetchall(): month_key = str(row[0]) task_type = row[1] cnt = row[2] or 0 if task_type == "follow_up_visit": callback_map[month_key] = callback_map.get(month_key, 0) + cnt elif task_type in ("high_priority_recall", "priority_recall"): recall_map[month_key] = recall_map.get(month_key, 0) + cnt except Exception: logger.warning("查询回访/召回完成数失败", exc_info=True) # 构建结果 current_month_str = now.strftime("%Y-%m-01") result = [] for i, month_str in enumerate(months): salary = salary_map.get(month_str, {}) customers = customer_count_map.get(month_str, 0) hours = salary.get("effective_hours", 0.0) salary_amount = salary.get("gross_salary", 0.0) callback_done = callback_map.get(month_str, 0) recall_done = recall_map.get(month_str, 0) # 月份标签 if i == 0: month_label = "本月" elif i == 1: month_label = "上月" else: # 提取月份数字 m_num = int(month_str.split("-")[1]) month_label = f"{m_num}月" result.append({ "month": month_label, "estimated": month_str == current_month_str, "customers": f"{customers}人", "hours": f"{hours:.1f}h", "salary": _format_currency(salary_amount), "callback_done": callback_done, "recall_done": recall_done, }) return result