Files
Neo-ZQYY/apps/backend/app/services/coach_service.py

709 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.
# -*- 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_amountitems_sum 口径)。
heartEmoji 四级映射P6 AC3rs_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 AC3rs_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