709 lines
24 KiB
Python
709 lines
24 KiB
Python
# -*- 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
|