Files
Neo-ZQYY/apps/backend/app/services/coach_service.py
Neo 2a7a5d68aa feat: 2026-04-15~04-20 累积变更基线 — 多主线合流
主线 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>
2026-04-20 06:32:07 +08:00

800 lines
28 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
from app.services.task_manager import build_performance_summary
from app.trace.decorators import trace_service
logger = logging.getLogger(__name__)
# ── 颜色/样式映射 ──────────────────────────────────────────
LEVEL_COLOR_MAP = {
"星级": "#FF6B6B",
"高级": "#FFA726",
"中级": "#42A5F5",
"初级": "#66BB6A",
}
TASK_TYPE_MAP = {
"follow_up_visit": {"label": "客户回访", "class": "callback"},
"high_priority_recall": {"label": "高优先召回", "class": "high-priority"},
"priority_recall": {"label": "优先召回", "class": "priority"},
"relationship_building": {"label": "关系构建", "class": "relationship"},
}
# 头像渐变色池(循环使用)
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 核心函数 ──────────────────────────────────────────
@trace_service("获取助教 banner", "Get coach banner")
async def get_coach_banner(coach_id: int, site_id: int) -> dict:
"""
助教 banner 轻量信息(仅 name / level / store_name
用途:小程序需要展示助教 banner 但不需要详情页全套数据时
(如 PERF-2 业绩明细页 banner。比 get_coach_detail 快一个数量级
(仅 2~3 条 SQL跳过绩效/TOP/服务记录/任务/备注/历史月份)。
"""
conn = _get_biz_connection()
try:
# 1. name + level来自 v_dim_assistant + level_map
info = fdw_queries.get_assistant_info(conn, site_id, coach_id)
if not info:
raise HTTPException(status_code=404, detail="助教不存在")
# 2. store_name来自业务库 biz.sites
store_name = ""
try:
with conn.cursor() as cur:
cur.execute(
"SELECT site_name FROM biz.sites WHERE site_id = %s",
(site_id,),
)
row = cur.fetchone()
if row:
store_name = row[0] or ""
conn.commit()
except Exception:
logger.warning("查询 store_name 失败,降级为空", exc_info=True)
try:
conn.rollback()
except Exception:
pass
return {
"id": coach_id,
"name": info.get("name", ""),
"level": info.get("level", ""),
"store_name": store_name,
}
finally:
conn.close()
@trace_service("获取助教详情", "Get coach detail")
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()
# 门店名称(用于小程序 banner 展示,跟随被查看助教所在门店)
# 必须在所有 fdw 查询前执行:后续任意 fdw 查询失败会污染事务
# psycopg2 的 InFailedSqlTransaction导致此处 SELECT 拿不到结果。
store_name = ""
try:
with conn.cursor() as cur:
cur.execute(
"SELECT site_name FROM biz.sites WHERE site_id = %s",
(site_id,),
)
row = cur.fetchone()
if row:
store_name = row[0] or ""
conn.commit()
except Exception:
logger.warning("查询 store_name 失败,降级为空", exc_info=True)
try:
conn.rollback()
except Exception:
pass
# 绩效数据:统一使用 build_performance_summary与任务页同源数据来自 monthly_summary 实时值)
try:
perf_summary = build_performance_summary(conn, site_id, coach_id)
except Exception:
logger.warning("build_performance_summary 失败,降级为空", exc_info=True)
perf_summary = {}
# 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 + taskStats当月已完成任务数按类型分组
# tasksCompleted + taskStats当月已完成任务数狭义助教亲自完成不含 resolved
tasks_completed = 0
task_stats = {"callback": 0, "recall": 0}
try:
month_start = now.replace(day=1)
with conn.cursor() as cur:
cur.execute(
"""
SELECT task_type, COUNT(*) AS cnt
FROM biz.coach_tasks
WHERE assistant_id = %s
AND status = 'completed'
AND updated_at >= %s
GROUP BY task_type
""",
(coach_id, month_start),
)
for row in cur.fetchall():
task_type, cnt = row[0], row[1]
tasks_completed += cnt
if task_type == "follow_up_visit":
task_stats["callback"] += cnt
elif task_type in ("high_priority_recall", "priority_recall"):
task_stats["recall"] += cnt
except Exception:
logger.warning("查询 tasksCompleted 失败,降级为 0", exc_info=True)
# customerCount从绩效概览获取回退到独立查询
customer_count = perf_summary.get("total_customers", 0)
if not customer_count:
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 字段:合并绩效概览 + 助教详情专属扩展字段
performance = {
**perf_summary,
# 助教详情页专属字段(绩效概览中没有的)
"customer_balance": customer_balance,
"tasks_completed": tasks_completed,
# 兼容旧字段名(前端渐进适配)
"monthly_hours": perf_summary.get("total_hours", 0.0),
"monthly_salary": perf_summary.get("total_income", 0.0),
}
# ── 扩展模块(独立 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": []}
# 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": perf_summary.get("current_tier_label", assistant_info.get("level", "")),
"store_name": store_name,
"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"),
# 绩效(包含 tier_nodes、total_hours 等完整字段)
"performance": performance,
# 收入
"income": income,
# 档位(保留顶级字段兼容前端已有逻辑)
"tier_nodes": perf_summary.get("tier_nodes", list(_FALLBACK_TIER_NODES)),
# 当月任务完成统计(回访/召回分类)
"task_stats": task_stats,
# 任务分组
"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": "primary",
},
{
"label": "激励课时费",
"amount": f"¥{salary.get('assistant_cx_money_total', 0.0):,.0f}",
"color": "success",
},
{
"label": "充值提成",
"amount": f"¥{salary.get('bonus_money', 0.0):,.0f}",
"color": "warning",
},
{
"label": "酒水提成",
"amount": f"¥{salary.get('room_income', 0.0):,.0f}",
"color": "purple",
},
]
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")
# 散客member_id ≤ 0展示"散客待转换会员",头像首字统一为"?"
is_scattered = not mid or mid <= 0
if is_scattered:
name = "散客待转换会员"
else:
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)
# CHANGE 2026-03-29 | coach-detail-500 修复 | relation_score → score对齐 TopCustomer.score Schema
result.append({
"id": mid or 0,
"name": name,
"initial": "?" if is_scattered else _get_initial(name),
"is_scattered": is_scattered,
"avatar_gradient": _get_avatar_gradient(i),
"heart_emoji": heart_emoji,
"score": f"{score:.2f}",
"score_color": score_color,
"service_count": cust.get("service_count", 0),
"balance": float(balance) if balance else 0.0,
"consume": float(consume) if consume else 0.0,
})
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):
# 散客member_id ≤ 0展示"散客待转换会员",头像首字统一为"?"
mid = rec.get("member_id")
is_scattered = not mid or mid <= 0
if is_scattered:
name = "散客待转换会员"
else:
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": "?" if is_scattered else _get_initial(name),
"is_scattered": is_scattered,
"avatar_gradient": _get_avatar_gradient(i),
"type": course_type or "课程",
"type_class": type_class,
"table": rec.get("table_name") or "",
"duration": f"{hours:.1f}h",
"income": float(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
CASE task_type
WHEN 'high_priority_recall' THEN 0
WHEN 'priority_recall' THEN 1
WHEN 'follow_up_visit' THEN 2
WHEN 'relationship_building' THEN 3
ELSE 4
END,
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:
# CHANGE 2026-03-29 | coach-detail-500 修复 | ai_score → score对齐 CoachNoteItem.score Schema
result.append({
"id": r[0],
"content": r[1] or "",
"timestamp": r[2].isoformat() if r[2] else "",
"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": customers,
"hours": float(hours),
"salary": float(salary_amount),
"callback_done": callback_done,
"recall_done": recall_done,
})
return result