# 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()