Files
Neo-ZQYY/apps/backend/app/services/task_manager.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

1303 lines
49 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.
# AI_CHANGELOG
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | get_task_list() 中 2 处、get_task_list_v2() 中 1 处、
# get_task_detail() 中 1 处 fdw_etl.v_dim_member / v_dws_member_assistant_relation_index
# 改为直连 ETL 库查询 app.v_* RLS 视图。使用 fdw_queries._fdw_context()。
# - 2026-03-24 | Prompt: 修复小程序前端没有档位进度 | build_performance_summary 中 tier_nodes
# 从 cfg_performance_tier 配置表读取(不再依赖 salary_calc 的空列表),
# next_tier_hours/tier_completed 根据 effective_hours 和 tier_nodes 实时计算。
# - 2026-03-24 | Prompt: bonus_money 公式修正 | bonus_money 改为基础课节省 + 打赏课节省:
# 基础课 = next_tier_min_hours × (当前档 base_deduction - 下一档 base_deduction)
# 打赏课 = bonus_hours × incentive_rate × (当前档 bonus_deduction_ratio - 下一档)。
# - 2026-03-25 | Prompt: 保底 relationship_building 任务 | get_task_list_v2() 中新增 SQL 层面
# 排除 RS 范围外的 relationship_building 任务Step 0 预查 ETL RS 排除列表 → COUNT/分页
# 查询加 NOT (task_type='relationship_building' AND member_id=ANY(exclude)) 条件),
# 替代原内存过滤方案,修复跨页 total 不准确问题。
# - 2026-03-25 | Prompt: 绩效页→任务详情页按 member_id 查询 | 新增 get_task_by_member()
# 按 (assistant_id, member_id, site_id, status='active') 查询,多条时取优先级最高的一条,
# 复用 get_task_detail() 返回完整详情。
# - 2026-03-25 | Prompt: 任务详情服务记录6项改进 | get_task_detail() 改造:
# (1) 统计范围改为近60天列表不限(2) 预估规则当月且日期≤5号
# (3) AI 文案从 ai_analysis.summary 传到前端(不再硬编码);
# (4) drinks 字段透传到 service_records。
"""
任务管理服务
负责任务 CRUD、置顶、放弃、取消放弃等操作。
直连 ETL 库查询 app.v_* RLS 视图获取客户信息和 RS 指数,计算爱心 icon 档位。
RNS1.1 扩展get_task_list_v2TASK-1、get_task_detailTASK-2
"""
import json
import logging
from datetime import datetime
from decimal import Decimal
from fastapi import HTTPException
from app.services import fdw_queries
from app.services.task_generator import compute_heart_icon
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()
def _record_history(
cur,
task_id: int,
action: str,
old_status: str | None = None,
new_status: str | None = None,
old_task_type: str | None = None,
new_task_type: str | None = None,
detail: dict | None = None,
) -> None:
"""在 coach_task_history 中记录变更。"""
cur.execute(
"""
INSERT INTO biz.coach_task_history
(task_id, action, old_status, new_status,
old_task_type, new_task_type, detail)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
task_id,
action,
old_status,
new_status,
old_task_type,
new_task_type,
json.dumps(detail) if detail else None,
),
)
def _get_assistant_id(conn, user_id: int, site_id: int) -> int:
"""
通过 user_assistant_binding 获取 assistant_id。
找不到绑定关系时抛出 403。
"""
with conn.cursor() as cur:
cur.execute(
"""
SELECT assistant_id
FROM auth.user_assistant_binding
WHERE user_id = %s AND site_id = %s AND assistant_id IS NOT NULL
AND is_removed = false
ORDER BY id DESC
LIMIT 1
""",
(user_id, site_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=403, detail="权限不足")
return row[0]
def _verify_task_ownership(
conn, task_id: int, assistant_id: int, site_id: int, required_status: str | None = None
) -> dict:
"""
验证任务归属并返回任务信息。
- 任务不存在 → 404
- 不属于当前助教 → 403
- required_status 不匹配 → 409
"""
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, task_type, status, is_pinned, abandon_reason,
assistant_id, site_id
FROM biz.coach_tasks
WHERE id = %s
""",
(task_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="资源不存在")
task = {
"id": row[0],
"task_type": row[1],
"status": row[2],
"is_pinned": row[3],
"abandon_reason": row[4],
"assistant_id": row[5],
"site_id": row[6],
}
if task["site_id"] != site_id or task["assistant_id"] != assistant_id:
raise HTTPException(status_code=403, detail="权限不足")
if required_status and task["status"] != required_status:
raise HTTPException(status_code=409, detail="任务状态不允许此操作")
return task
@trace_service("获取任务列表", "Get task list")
async def get_task_list(user_id: int, site_id: int) -> list[dict]:
"""
获取助教的任务列表(含有效 + 已放弃)。
1. 通过 auth.user_assistant_binding 获取 assistant_id
2. 查询 biz.coach_tasks WHERE status IN ('active', 'abandoned')
3. 通过 FDW 读取客户基本信息dim_member和 RS 指数
4. 计算爱心 icon 档位
5. 排序abandoned 排最后 → is_pinned DESC → priority_score DESC → created_at ASC
FDW 查询需要 SET LOCAL app.current_site_id。
"""
conn = _get_connection()
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
# 查询有效 + 已放弃任务abandoned 排最后)
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, task_type, status, priority_score, is_pinned,
expires_at, created_at, member_id, abandon_reason
FROM biz.coach_tasks
WHERE site_id = %s
AND assistant_id = %s
AND status IN ('active', 'abandoned')
ORDER BY
CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END ASC,
is_pinned DESC,
priority_score DESC NULLS LAST,
created_at ASC
""",
(site_id, assistant_id),
)
tasks = cur.fetchall()
conn.commit()
if not tasks:
return []
member_ids = list({t[7] for t in tasks})
# 通过 FDW 读取客户信息和 RS 指数(需要 SET LOCAL app.current_site_id
member_info_map: dict[int, dict] = {}
rs_map: dict[int, Decimal] = {}
# CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl.v_dim_member + v_dws_member_assistant_relation_index
# → 直连 ETL 库查 app.v_* RLS 视图
# intent: 修复 RLS 门店隔离失效postgres_fdw 不传递 GUC 参数)
from app.services.fdw_queries import _fdw_context
with _fdw_context(conn, site_id) as cur:
# 读取客户基本信息
# 列名映射: FDW 外部表 member_name/member_phone → RLS 视图 nickname/mobile
cur.execute(
"""
SELECT member_id, nickname, mobile
FROM app.v_dim_member
WHERE member_id = ANY(%s)
""",
(member_ids,),
)
for row in cur.fetchall():
member_info_map[row[0]] = {
"member_name": row[1],
"member_phone": row[2],
}
# 读取 RS 指数
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]] = Decimal(str(row[1]))
# 组装结果
result = []
for task_row in tasks:
(task_id, task_type, status, priority_score,
is_pinned, expires_at, created_at, member_id, abandon_reason) = task_row
info = member_info_map.get(member_id, {})
rs_score = rs_map.get(member_id, Decimal("0"))
heart_icon = compute_heart_icon(rs_score)
result.append({
"id": task_id,
"task_type": task_type,
"status": status,
"priority_score": float(priority_score) if priority_score else None,
"is_pinned": is_pinned,
"expires_at": expires_at.isoformat() if expires_at else None,
"created_at": created_at.isoformat() if created_at else None,
"member_id": member_id,
"member_name": info.get("member_name"),
"member_phone": info.get("member_phone"),
"rs_score": float(rs_score),
"heart_icon": heart_icon,
"abandon_reason": abandon_reason,
})
return result
finally:
conn.close()
@trace_service("置顶任务", "Pin task")
async def pin_task(task_id: int, user_id: int, site_id: int) -> dict:
"""
置顶任务。
验证任务归属后设置 is_pinned=TRUE记录 history。
"""
conn = _get_connection()
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
task = _verify_task_ownership(
conn, task_id, assistant_id, site_id, required_status="active"
)
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"""
UPDATE biz.coach_tasks
SET is_pinned = TRUE, updated_at = NOW()
WHERE id = %s
""",
(task_id,),
)
_record_history(
cur,
task_id,
action="pin",
old_status="active",
new_status="active",
old_task_type=task["task_type"],
new_task_type=task["task_type"],
detail={"is_pinned": True},
)
conn.commit()
return {"id": task_id, "is_pinned": True}
finally:
conn.close()
@trace_service("取消置顶", "Unpin task")
async def unpin_task(task_id: int, user_id: int, site_id: int) -> dict:
"""
取消置顶。
验证任务归属后设置 is_pinned=FALSE。
"""
conn = _get_connection()
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
task = _verify_task_ownership(
conn, task_id, assistant_id, site_id, required_status="active"
)
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"""
UPDATE biz.coach_tasks
SET is_pinned = FALSE, updated_at = NOW()
WHERE id = %s
""",
(task_id,),
)
conn.commit()
return {"id": task_id, "is_pinned": False}
finally:
conn.close()
@trace_service("放弃任务", "Abandon task")
async def abandon_task(
task_id: int, user_id: int, site_id: int, reason: str
) -> dict:
"""
放弃任务。
1. 验证 reason 非空(空或纯空白 → 422
2. 验证任务归属和 status='active'
3. 设置 status='abandoned', abandon_reason=reason
4. 记录 coach_task_history
"""
if not reason or not reason.strip():
raise HTTPException(status_code=422, detail="放弃原因不能为空")
conn = _get_connection()
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
task = _verify_task_ownership(
conn, task_id, assistant_id, site_id, required_status="active"
)
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'abandoned',
abandon_reason = %s,
updated_at = NOW()
WHERE id = %s
""",
(reason, task_id),
)
_record_history(
cur,
task_id,
action="abandon",
old_status="active",
new_status="abandoned",
old_task_type=task["task_type"],
new_task_type=task["task_type"],
detail={"abandon_reason": reason},
)
conn.commit()
return {"id": task_id, "status": "abandoned"}
finally:
conn.close()
@trace_service("取消放弃", "Cancel abandon")
async def cancel_abandon(task_id: int, user_id: int, site_id: int) -> dict:
"""
取消放弃。
1. 验证任务归属和 status='abandoned'
2. 恢复 status='active', 清空 abandon_reason
3. 记录 coach_task_history
"""
conn = _get_connection()
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
task = _verify_task_ownership(
conn, task_id, assistant_id, site_id, required_status="abandoned"
)
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'active',
is_pinned = FALSE,
abandon_reason = NULL,
updated_at = NOW()
WHERE id = %s
""",
(task_id,),
)
_record_history(
cur,
task_id,
action="cancel_abandon",
old_status="abandoned",
new_status="active",
old_task_type=task["task_type"],
new_task_type=task["task_type"],
)
conn.commit()
return {"id": task_id, "status": "active", "is_pinned": False}
finally:
conn.close()
# ---------------------------------------------------------------------------
# RNS1.1 扩展:辅助常量与工具函数
# ---------------------------------------------------------------------------
# 任务类型 → 中文标签
_TASK_TYPE_LABEL_MAP: dict[str, str] = {
"high_priority_recall": "高优先召回",
"priority_recall": "优先召回",
"follow_up_visit": "客户回访",
"relationship_building": "关系构建",
}
# 课程类型 → courseTypeClass 枚举映射design.md 定义)
_COURSE_TYPE_CLASS_MAP: dict[str, str] = {
"basic": "basic",
"陪打": "basic",
"基础课": "basic",
"vip": "vip",
"包厢": "vip",
"包厢课": "vip",
"tip": "tip",
"超休": "tip",
"激励课": "tip",
"recharge": "recharge",
"充值": "recharge",
"incentive": "incentive",
"激励": "incentive",
}
# 维客线索 category → tag_color 映射
# CHANGE 2026-03-24 | 值改为前端 clue-card 组件 CSS 类名后缀primary/success/...
# 不再用十六进制颜色——WXSS 类名 `clue-tag-#0052d9` 无效。
_CATEGORY_COLOR_MAP: dict[str, str] = {
"客户基础": "primary",
"客户基础信息": "primary",
"消费习惯": "error",
"玩法偏好": "success",
"促销偏好": "orange",
"促销接受": "orange",
"社交关系": "purple",
"重要反馈": "error",
}
@trace_service(description_zh="map_course_type_class", description_en="Map Course Type Class")
def map_course_type_class(raw_course_type: str | None) -> str:
"""将原始课程类型映射为统一枚举值(不带 tag- 前缀)。"""
if not raw_course_type:
return "basic"
return _COURSE_TYPE_CLASS_MAP.get(raw_course_type.strip(), "basic")
@trace_service(description_zh="compute_income_trend", description_en="Compute Income Trend")
def compute_income_trend(current_income: float, prev_income: float) -> tuple[str, str]:
"""
计算收入趋势。
返回 (income_trend, income_trend_dir)。
如 (1000, 800) → ("↑200", "up")
"""
diff = current_income - prev_income
direction = "up" if diff >= 0 else "down"
arrow = "" if diff >= 0 else ""
trend = f"{arrow}{abs(diff):.0f}"
return trend, direction
@trace_service(description_zh="sanitize_tag", description_en="Sanitize Tag")
def sanitize_tag(raw_tag: str | None) -> str:
"""去除 tag 中的换行符,多行标签使用空格分隔。"""
if not raw_tag:
return ""
return raw_tag.replace("\n", " ").strip()
def _extract_emoji_and_text(summary: str | None) -> tuple[str, str]:
"""
从 summary 中提取 emoji 前缀和正文。
AI 写入格式: "📅 偏好周末下午时段消费" → ("📅", "偏好周末下午时段消费")
手动写入无 emoji: "喜欢打中式" → ("", "喜欢打中式")
"""
if not summary:
return "", ""
# 检查第一个字符是否为 emoji非 ASCII 且非中文常用范围)
first_char = summary[0]
if ord(first_char) > 0x2600 and summary[1:2] == " ":
return first_char, summary[2:].strip()
return "", summary.strip()
def _format_time(dt: datetime | None) -> str | None:
"""格式化时间为 ISO 字符串。"""
if dt is None:
return None
return dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
# ---------------------------------------------------------------------------
# RNS1.1get_task_list_v2TASK-1 扩展版任务列表)
# ---------------------------------------------------------------------------
@trace_service("获取扩展版任务列表", "Get task list v2")
async def get_task_list_v2(
user_id: int,
site_id: int,
status: str,
page: int,
page_size: int,
) -> dict:
"""
扩展版任务列表TASK-1
返回 { items, total, page, pageSize, performance }。
逻辑:
1. _get_assistant_id() 获取 assistant_id
2. 查询 coach_tasks 带分页LIMIT/OFFSET + COUNT(*)
3. fdw_queries 批量获取会员信息、余额、lastVisitDays
4. fdw_queries.get_salary_calc() 获取绩效概览
5. 查询 ai_cache 获取 aiSuggestion
6. 组装 TaskListResponse
扩展字段lastVisitDays/balance/aiSuggestion采用优雅降级。
"""
conn = _get_connection()
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
# ── 0. 预加载 RS 范围参数 + 需排除的 relationship_building member_id ──
# CHANGE 2026-03-25 | 分页准确性修复:在 SQL 层面排除 RS 范围外的保底任务,
# 而非在内存中过滤(内存过滤会导致 total 跨页不准确)。
# 先查 ETL 获取该助教所有关系对的 RS 值,筛出不满足范围的 member_id
# 然后在 SQL COUNT + 分页查询中排除这些 (task_type, member_id) 组合。
from app.services.task_generator import load_params as _load_tg_params
try:
tg_params = _load_tg_params(conn, site_id)
except Exception:
logger.warning("加载任务生成器参数失败,使用默认值", exc_info=True)
tg_params = {"rs_min_for_relationship": 1.0, "rs_max_for_relationship": 6.0}
rb_rs_min = Decimal(str(tg_params.get("rs_min_for_relationship", 1.0)))
rb_rs_max = Decimal(str(tg_params.get("rs_max_for_relationship", 6.0)))
# 查询该助教所有 RS 值,筛出不满足展示范围的 member_id
rb_exclude_member_ids: list[int] = []
try:
from app.services.fdw_queries import _fdw_context
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT member_id, COALESCE(rs_display, 0) AS rs
FROM app.v_dws_member_assistant_relation_index
WHERE assistant_id = %s
""",
(assistant_id,),
)
for row in cur.fetchall():
rs_val = Decimal(str(row[1]))
if not (rb_rs_min < rs_val < rb_rs_max):
rb_exclude_member_ids.append(row[0])
except Exception:
logger.warning("ETL 查询 RS 排除列表失败,降级为不排除", exc_info=True)
# ── 1. 查询任务列表(带分页 + 总数) ──
# 状态映射:前端 pending → active
db_status = "active" if status == "pending" else status
# 构建排除条件relationship_building + member_id 不在 RS 范围内
# 当排除列表为空时不加额外条件
exclude_clause = ""
query_params_count: list = [site_id, assistant_id, db_status]
query_params_page: list = [site_id, assistant_id, db_status]
if rb_exclude_member_ids:
exclude_clause = (
" AND NOT (task_type = 'relationship_building' AND member_id = ANY(%s))"
)
query_params_count.append(rb_exclude_member_ids)
query_params_page.append(rb_exclude_member_ids)
with conn.cursor() as cur:
# 总数(已排除 RS 范围外的保底任务)
cur.execute(
f"""
SELECT COUNT(*)
FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND status = %s
{exclude_clause}
""",
query_params_count,
)
total = cur.fetchone()[0]
# 分页查询(同样排除)
offset = (page - 1) * page_size
query_params_page.extend([page_size, offset])
cur.execute(
f"""
SELECT id, task_type, status, priority_score, is_pinned,
expires_at, created_at, member_id, abandon_reason
FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND status = %s
{exclude_clause}
ORDER BY is_pinned DESC,
priority_score DESC NULLS LAST,
created_at ASC
LIMIT %s OFFSET %s
""",
query_params_page,
)
tasks = cur.fetchall()
conn.commit()
if not tasks:
# 即使无任务也需要返回绩效概览
performance = build_performance_summary(conn, site_id, assistant_id)
return {
"items": [],
"total": 0,
"page": page,
"page_size": page_size,
"performance": performance,
}
member_ids = list({t[7] for t in tasks})
# ── 2-5+8. 单连接批量查询所有 ETL 数据 ──
# CHANGE 2026-03-23 | 性能优化:合并 7 次独立 ETL 连接为 1 次
member_info_map: dict[int, dict] = {}
balance_map: dict[int, Decimal] = {}
last_visit_map: dict[int, int | None] = {}
rs_map: dict[int, Decimal] = {}
recent60d_map: dict[int, dict] = {}
batch_data: dict | None = None
try:
batch_data = fdw_queries.batch_query_for_task_list(
conn, site_id, assistant_id, member_ids,
datetime.now().year, datetime.now().month,
)
member_info_map = batch_data["member_info"]
balance_map = batch_data["balance"]
last_visit_map = batch_data["last_visit"]
rs_map = batch_data["rs"]
wbi_map = batch_data.get("wbi", {})
recent60d_map = batch_data.get("recent60d", {})
except Exception:
logger.warning("ETL 批量查询失败,降级为空数据", exc_info=True)
# ── 6. 查询 ai_cache 获取 aiSuggestion优雅降级 ──
ai_suggestion_map: dict[int, str] = {}
try:
member_id_strs = [str(mid) for mid in member_ids]
with conn.cursor() as cur:
cur.execute(
"""
SELECT target_id, result_json
FROM biz.ai_cache
WHERE site_id = %s
AND target_id = ANY(%s)
AND cache_type = 'app4_analysis'
ORDER BY created_at DESC
""",
(site_id, member_id_strs),
)
seen: set[str] = set()
for row in cur.fetchall():
target_id_str = str(row[0])
if target_id_str not in seen:
seen.add(target_id_str)
result = row[1] if isinstance(row[1], dict) else {}
summary = result.get("summary", "")
if summary:
ai_suggestion_map[int(target_id_str)] = summary
conn.commit()
except Exception:
logger.warning("查询 ai_cache aiSuggestion 失败", exc_info=True)
# ── 7. 查询备注存在性has_note ──
task_ids = [t[0] for t in tasks]
has_note_set: set[int] = set()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT task_id
FROM biz.notes
WHERE task_id = ANY(%s)
""",
(task_ids,),
)
for row in cur.fetchall():
has_note_set.add(row[0])
conn.commit()
except Exception:
logger.warning("查询备注存在性失败", exc_info=True)
# ── 8. 绩效概览(使用批量查询的预取数据) ──
# CHANGE 2026-03-23 | 复用 batch_data 避免额外 3 次 ETL 连接
performance = build_performance_summary(
conn, site_id, assistant_id, batch_data=batch_data,
)
# ── 9. 组装 items ──
items = []
for task_row in tasks:
(task_id, task_type, task_status, priority_score,
is_pinned, expires_at, created_at, member_id, abandon_reason) = task_row
info = member_info_map.get(member_id, {})
customer_name = info.get("nickname") or info.get("member_name") or "未知客户"
rs_score = rs_map.get(member_id, Decimal("0"))
balance = balance_map.get(member_id)
wbi = wbi_map.get(member_id, {})
last_visit_days_val = last_visit_map.get(member_id)
ideal_interval = wbi.get("ideal_interval_days")
recent60d = recent60d_map.get(member_id, {})
# CHANGE 2026-03-24 | 预期天数ideal_interval_days - last_visit_days
# 正数=距预期到店还有余量,负数=已逾期
expected_days: int | None = None
if ideal_interval is not None and last_visit_days_val is not None:
expected_days = round(ideal_interval - last_visit_days_val)
items.append({
"id": task_id,
"customer_name": customer_name,
"customer_avatar": "/assets/images/avatar-default.png",
"task_type": task_type,
"task_type_label": _TASK_TYPE_LABEL_MAP.get(task_type, task_type),
"deadline": _format_time(expires_at),
"heart_score": float(rs_score),
"hobbies": [], # 暂无数据源,返回空数组
"is_pinned": bool(is_pinned),
"has_note": task_id in has_note_set,
"status": task_status,
"last_visit_days": last_visit_days_val,
"balance": float(balance) if balance is not None else None,
"ai_suggestion": ai_suggestion_map.get(member_id),
"expected_days": expected_days,
"ideal_interval_days": round(ideal_interval) if ideal_interval is not None else None,
# CHANGE 2026-03-27 | 近60天服务汇总口径同 task-detail serviceSummary
# 无记录时返回 0.0 而非 None确保前端始终能显示数值
"recent60d_hours": recent60d.get("hours", 0.0),
"recent60d_income": recent60d.get("income", 0.0),
})
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"performance": performance,
}
finally:
conn.close()
def build_performance_summary(
conn, site_id: int, assistant_id: int, *, batch_data: dict | None = None,
) -> dict:
"""
构建绩效概览PerformanceSummary
CHANGE 2026-03-23: 支持 batch_data 参数复用预查询数据,避免额外 ETL 连接。
当 batch_data 为 None 时(如无任务的空列表场景),回退到独立查询。
课时/档位/客户数从 monthly_summary每日更新取实时数据
不再依赖月初结算的 salary_calc。收入仍从 salary_calc 取(如有)。
"""
now = datetime.now()
year, month = now.year, now.month
if batch_data:
# 复用批量查询的预取数据
summary = batch_data.get("monthly_summary")
salary = batch_data.get("salary_cur")
prev_salary = batch_data.get("salary_prev")
prev_month = month - 1 if month > 1 else 12
else:
# 回退:独立查询(无任务时的空列表场景)
summary = None
try:
summary = fdw_queries.get_monthly_summary(conn, site_id, assistant_id, year, month)
except Exception:
logger.warning("FDW 查询当月 monthly_summary 失败", exc_info=True)
salary = None
try:
salary = fdw_queries.get_salary_calc(conn, site_id, assistant_id, year, month)
except Exception:
logger.warning("FDW 查询当月 salary_calc 失败", exc_info=True)
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)
# 收入:优先 salary_calc无则为 0月中尚未结算属正常
current_income = salary["total_income"] if salary else 0.0
prev_income = prev_salary["total_income"] if prev_salary else 0.0
income_trend, income_trend_dir = compute_income_trend(current_income, prev_income)
# CHANGE 2026-03-24 | 档位节点从 cfg_performance_tier 配置表构建,不再依赖 salary_calc
# feiqiu-data-rules 规则 6: 绩效档位必须从配置表读取,禁止硬编码
# intent: 修复前端 tier_nodes=[0] 导致进度条无刻度的 bug
tiers: list[dict] = []
if batch_data and batch_data.get("performance_tiers"):
tiers = batch_data["performance_tiers"]
else:
try:
tiers = fdw_queries.get_performance_tiers(conn, site_id)
except Exception:
logger.warning("查询 cfg_performance_tier 失败", exc_info=True)
# 构建 tier_nodes: 各档位的 min_hours如 [0, 120, 150, 180, 210]
tier_nodes = [t["min_hours"] for t in tiers] if tiers else [0]
# 课时/档位/客户数:从 monthly_summary 取实时值
total_hours = summary["effective_hours"] if summary else 0.0
basic_hours = summary["base_hours"] if summary else 0.0
bonus_hours = summary["bonus_hours"] if summary else 0.0
total_customers = summary["unique_customers"] if summary else 0
coach_level = summary["coach_level"] if summary else (salary["coach_level"] if salary else "")
# current_tier根据 total_hours 在 tier_nodes 中的位置计算数组索引0-based
# 不能用 tier_id数据库主键前端把 current_tier 当数组下标用
current_tier = 0
for i, node in enumerate(tier_nodes):
if total_hours >= node:
current_tier = i
else:
break
# next_tier_hours / tier_completed: 根据 effective_hours 和 tier_nodes 计算
tier_completed = False
next_tier_hours = 0.0
if tiers:
# 找到当前所在档位的下一档 min_hours
matched_next = None
for t in tiers:
if t["min_hours"] > total_hours:
matched_next = t["min_hours"]
break
if matched_next is not None:
next_tier_hours = matched_next
else:
# 已达到或超过最高档
tier_completed = True
next_tier_hours = tiers[-1]["min_hours"]
# bonus_money: 达到下一档后因抽成降低能多拿的钱(基础课 + 打赏课)
# CHANGE 2026-03-24 | 公式:
# 基础课节省 = next_tier_min_hours × (当前档 base_deduction - 下一档 base_deduction)
# 打赏课节省 = 当前打赏课时 × bonus_course_price × (当前档 bonus_ratio - 下一档 bonus_ratio)
# bonus_money = 基础课节省 + 打赏课节省
# intent: 展示升档的实际收益激励(替代已过期的 sprint_bonus
# assumptions: base_deduction/bonus_deduction_ratio 从 cfg_performance_tier 读取;
# bonus_course_price 从 salary_calc.incentive_rate 读取(禁止硬编码 190
bonus_money = 0.0
if not tier_completed and tiers and len(tiers) >= 2:
# 找到当前所在档位和下一档
current_tier_data = None
next_tier_data = None
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]
break
if current_tier_data and next_tier_data:
# 基础课节省:用下一档的 min_hours升档后整月课时都按新抽成算
base_ded_diff = current_tier_data.get("base_deduction", 0) - next_tier_data.get("base_deduction", 0)
base_saving = next_tier_data["min_hours"] * base_ded_diff if base_ded_diff > 0 else 0.0
# 打赏课节省:当前打赏课时 × 单价 × 抽成比例差
bonus_ratio_diff = (
current_tier_data.get("bonus_deduction_ratio", 0)
- next_tier_data.get("bonus_deduction_ratio", 0)
)
bonus_course_price = salary.get("incentive_rate", 0.0) if salary else 0.0
bonus_saving = bonus_hours * bonus_course_price * bonus_ratio_diff if bonus_ratio_diff > 0 else 0.0
bonus_money = round(base_saving + bonus_saving, 2)
return {
"total_hours": total_hours,
"total_income": current_income,
"total_customers": total_customers,
"month_label": f"{month}",
"tier_nodes": [float(n) for n in tier_nodes] if tier_nodes else [0],
"basic_hours": basic_hours,
"bonus_hours": bonus_hours,
"current_tier": current_tier,
"next_tier_hours": next_tier_hours,
"tier_completed": tier_completed,
"bonus_money": bonus_money,
"income_trend": income_trend,
"income_trend_dir": income_trend_dir,
"prev_month": f"{prev_month}",
"current_tier_label": coach_level,
}
# ---------------------------------------------------------------------------
# 按 member_id 查询最高优先级 active 任务
# ---------------------------------------------------------------------------
# 任务类型优先级排序(数值越小越优先)
_TASK_TYPE_SORT_ORDER: dict[str, int] = {
"high_priority_recall": 0,
"priority_recall": 1,
"follow_up_visit": 2,
"relationship_building": 3,
}
@trace_service("按会员查询任务详情", "Get task detail by member")
async def get_task_by_member(
member_id: int,
user_id: int,
site_id: int,
) -> dict:
"""
按 member_id 查询当前助教的最高优先级 active 任务,返回完整详情。
逻辑:
1. 查询 coach_tasks WHERE assistant_id + member_id + site_id + status='active'
2. 多条时按 _TASK_TYPE_SORT_ORDER 取优先级最高的一条
3. 复用 get_task_detail() 返回完整详情
权限校验:无 active 任务 → 404。
"""
conn = _get_connection()
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, task_type
FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
AND status = 'active'
""",
(site_id, assistant_id, member_id),
)
rows = cur.fetchall()
if not rows:
raise HTTPException(status_code=404, detail="该会员无活跃任务")
# 按优先级排序,取最高的一条
best = min(rows, key=lambda r: _TASK_TYPE_SORT_ORDER.get(r[1], 99))
task_id = best[0]
finally:
conn.close()
# 复用完整详情逻辑
return await get_task_detail(task_id, user_id, site_id)
# ---------------------------------------------------------------------------
# RNS1.1get_task_detailTASK-2 任务详情完整版)
# ---------------------------------------------------------------------------
@trace_service("获取任务详情", "Get task detail")
async def get_task_detail(
task_id: int,
user_id: int,
site_id: int,
) -> dict:
"""
任务详情完整版TASK-2
返回基础信息 + retentionClues + talkingPoints + serviceSummary
+ serviceRecords + aiAnalysis + notes + customerId。
权限校验:任务不存在 → 404不属于当前助教 → 403。
"""
conn = _get_connection()
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
# ── 1. 查询任务基础信息 ──
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, task_type, status, priority_score, is_pinned,
expires_at, created_at, member_id, abandon_reason,
assistant_id, site_id
FROM biz.coach_tasks
WHERE id = %s
""",
(task_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="任务不存在")
task_assistant_id = row[9]
task_site_id = row[10]
if task_site_id != site_id or task_assistant_id != assistant_id:
raise HTTPException(status_code=403, detail="无权访问该任务")
member_id = row[7]
task_type = row[1]
task_status = row[2]
is_pinned = row[4]
expires_at = row[5]
# ── 2. FDW 查询会员信息 ──
member_info_map: dict[int, dict] = {}
try:
member_info_map = fdw_queries.get_member_info(conn, site_id, [member_id])
except Exception:
logger.warning("FDW 查询会员信息失败", exc_info=True)
info = member_info_map.get(member_id, {})
customer_name = info.get("nickname") or "未知客户"
customer_phone = info.get("mobile") or ""
# 余额(用于前端储值等级展示)
balance = Decimal("0")
try:
balance_map = fdw_queries.get_member_balance(conn, site_id, [member_id])
balance = balance_map.get(member_id, Decimal("0"))
except Exception:
logger.warning("FDW 查询会员余额失败", exc_info=True)
# RS 指数
# CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl → app直连 ETL 库)
rs_score = Decimal("0")
try:
from app.services.fdw_queries import _fdw_context
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT COALESCE(rs_display, 0)
FROM app.v_dws_member_assistant_relation_index
WHERE assistant_id = %s AND member_id = %s
""",
(assistant_id, member_id),
)
rs_row = cur.fetchone()
if rs_row:
rs_score = Decimal(str(rs_row[0]))
except Exception:
logger.warning("ETL 查询 RS 指数失败", exc_info=True)
# ── 3. 查询维客线索 ──
retention_clues = []
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, category, summary, detail, source
FROM public.member_retention_clue
WHERE member_id = %s AND site_id = %s
AND is_hidden = false
ORDER BY recorded_at DESC
""",
(member_id, site_id),
)
for clue_row in cur.fetchall():
category = clue_row[1] or ""
summary_raw = clue_row[2] or ""
detail = clue_row[3]
source = clue_row[4] or "manual"
emoji, text = _extract_emoji_and_text(summary_raw)
tag = sanitize_tag(category)
tag_color = _CATEGORY_COLOR_MAP.get(tag, "primary")
retention_clues.append({
"tag": tag,
"tag_color": tag_color,
"emoji": emoji,
"text": text,
"source": source,
"desc": detail,
})
conn.commit()
except Exception:
logger.warning("查询维客线索失败", exc_info=True)
# ── 4. 查询 AI 缓存talkingPoints + aiAnalysis ──
talking_points: list[str] = []
ai_analysis = {"summary": "", "suggestions": []}
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT cache_type, result_json
FROM biz.ai_cache
WHERE target_id = %s AND site_id = %s
AND cache_type IN ('app4_analysis', 'app5_talking_points')
ORDER BY created_at DESC
""",
(str(member_id), site_id),
)
seen_types: set[str] = set()
for cache_row in cur.fetchall():
cache_type = cache_row[0]
if cache_type in seen_types:
continue
seen_types.add(cache_type)
result = cache_row[1] if isinstance(cache_row[1], dict) else {}
if cache_type == "app5_talking_points":
# talkingPoints: 话术列表
points = result.get("talking_points", [])
if isinstance(points, list):
talking_points = [str(p) for p in points]
elif cache_type == "app4_analysis":
# aiAnalysis: summary + suggestions
ai_analysis = {
"summary": result.get("summary", ""),
"suggestions": result.get("suggestions", []),
}
conn.commit()
except Exception:
logger.warning("查询 AI 缓存失败", exc_info=True)
# ── 5. FDW 查询服务记录(最多 20 条) ──
service_records_raw: list[dict] = []
try:
service_records_raw = fdw_queries.get_service_records_for_task(
conn, site_id, assistant_id, member_id, limit=20
)
except Exception:
logger.warning("FDW 查询服务记录失败", exc_info=True)
# CHANGE 2026-03-25 | 统计范围近60天列表不限
# 预估规则:当月且日期 ≤ 5号
from datetime import date, timedelta
today = date.today()
cutoff_60d = today - timedelta(days=60)
is_estimate_month = today.day <= 5
service_records = []
total_hours_60d = 0.0
total_income_60d = 0.0
count_60d = 0
for rec in service_records_raw:
hours = rec.get("service_hours", 0.0)
income = rec.get("income", 0.0)
# 判断是否在60天窗口内用于统计
settle_time = rec.get("settle_time")
in_60d = False
if settle_time:
rec_date = settle_time.date() if hasattr(settle_time, "date") else None
if rec_date and rec_date >= cutoff_60d:
in_60d = True
total_hours_60d += hours
total_income_60d += income
count_60d += 1
# 时间格式化
date_str = ""
if settle_time:
if hasattr(settle_time, "strftime"):
date_str = settle_time.strftime("%Y-%m-%d")
else:
date_str = str(settle_time)[:10]
raw_course_type = rec.get("course_type", "")
type_class = map_course_type_class(raw_course_type)
# CHANGE 2026-03-25 | 预估规则:当月且日期 ≤ 5号
rec_is_estimate = False
if settle_time and is_estimate_month:
rec_date_val = settle_time.date() if hasattr(settle_time, "date") else None
if rec_date_val and rec_date_val.year == today.year and rec_date_val.month == today.month:
rec_is_estimate = True
service_records.append({
"table": rec.get("table_name"),
"type": raw_course_type or "基础课",
"type_class": type_class,
"record_type": "recharge" if type_class == "recharge" else "course",
"duration": hours,
"duration_raw": rec.get("service_hours_raw"),
"income": income,
"is_estimate": rec_is_estimate,
"drinks": rec.get("drinks"),
"date": date_str,
})
avg_income = total_income_60d / count_60d if count_60d else 0.0
service_summary = {
"total_hours": round(total_hours_60d, 2),
"total_income": round(total_income_60d, 2),
"avg_income": round(avg_income, 2),
}
# ── 6. 查询备注(最多 20 条) ──
notes: list[dict] = []
has_note = False
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, content, type, ai_score, created_at, score,
rating_service_willingness, rating_revisit_likelihood
FROM biz.notes
WHERE task_id = %s
ORDER BY created_at DESC
LIMIT 20
""",
(task_id,),
)
for note_row in cur.fetchall():
has_note = True
note_type = note_row[2] or "normal"
# type → tag_type/tag_label 映射
tag_label = "回访" if note_type == "follow_up" else "普通"
# CHANGE 2026-03-27 | 备注联调:补充 score用户星星评分和 ai_score
# ai_score 是 AI 应用 6 评分1-10score 是用户手动星星评分1-5
user_score = note_row[5]
ai_score_val = note_row[3]
notes.append({
"id": note_row[0],
"content": note_row[1] or "",
"tag_type": note_type,
"tag_label": tag_label,
"created_at": _format_time(note_row[4]) or "",
"score": user_score,
"ai_score": ai_score_val,
})
conn.commit()
except Exception:
logger.warning("查询备注失败", exc_info=True)
# ── 7. 组装 TaskDetailResponse ──
return {
"id": task_id,
"customer_name": customer_name,
"customer_phone": customer_phone,
"customer_avatar": "/assets/images/avatar-default.png",
"task_type": task_type,
"task_type_label": _TASK_TYPE_LABEL_MAP.get(task_type, task_type),
"deadline": _format_time(expires_at),
"heart_score": float(rs_score),
"hobbies": [],
"is_pinned": bool(is_pinned),
"has_note": has_note,
"status": task_status,
"customer_id": member_id,
"balance": float(balance),
"retention_clues": retention_clues,
"talking_points": talking_points,
"service_summary": service_summary,
"service_records": service_records,
"ai_analysis": ai_analysis,
"notes": notes,
}
finally:
conn.close()