主线 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>
1303 lines
49 KiB
Python
1303 lines
49 KiB
Python
# 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_v2(TASK-1)、get_task_detail(TASK-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.1:get_task_list_v2(TASK-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.1:get_task_detail(TASK-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-10),score 是用户手动星星评分(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()
|