feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本

包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View File

@@ -2,6 +2,23 @@
# - 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。
"""
任务管理服务
@@ -21,6 +38,7 @@ 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__)
@@ -74,6 +92,8 @@ def _get_assistant_id(conn, user_id: int, site_id: int) -> int:
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),
@@ -128,6 +148,7 @@ def _verify_task_ownership(
return task
@trace_service("获取任务列表", "Get task list")
async def get_task_list(user_id: int, site_id: int) -> list[dict]:
"""
获取助教的任务列表(含有效 + 已放弃)。
@@ -241,6 +262,7 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
conn.close()
@trace_service("置顶任务", "Pin task")
async def pin_task(task_id: int, user_id: int, site_id: int) -> dict:
"""
置顶任务。
@@ -282,6 +304,7 @@ async def pin_task(task_id: int, user_id: int, site_id: int) -> dict:
conn.close()
@trace_service("取消置顶", "Unpin task")
async def unpin_task(task_id: int, user_id: int, site_id: int) -> dict:
"""
取消置顶。
@@ -313,6 +336,7 @@ async def unpin_task(task_id: int, user_id: int, site_id: int) -> dict:
conn.close()
@trace_service("放弃任务", "Abandon task")
async def abandon_task(
task_id: int, user_id: int, site_id: int, reason: str
) -> dict:
@@ -364,6 +388,7 @@ async def abandon_task(
conn.close()
@trace_service("取消放弃", "Cancel abandon")
async def cancel_abandon(task_id: int, user_id: int, site_id: int) -> dict:
"""
取消放弃。
@@ -439,18 +464,21 @@ _COURSE_TYPE_CLASS_MAP: dict[str, str] = {
}
# 维客线索 category → tag_color 映射
# CHANGE 2026-03-24 | 值改为前端 clue-card 组件 CSS 类名后缀primary/success/...
# 不再用十六进制颜色——WXSS 类名 `clue-tag-#0052d9` 无效。
_CATEGORY_COLOR_MAP: dict[str, str] = {
"客户基础": "#0052d9",
"客户基础信息": "#0052d9",
"消费习惯": "#e34d59",
"玩法偏好": "#00a870",
"促销偏好": "#ed7b2f",
"促销接受": "#ed7b2f",
"社交关系": "#0594fa",
"重要反馈": "#a25eb5",
"客户基础": "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:
@@ -458,6 +486,7 @@ def map_course_type_class(raw_course_type: str | None) -> str:
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]:
"""
计算收入趋势。
@@ -472,6 +501,7 @@ def compute_income_trend(current_income: float, prev_income: float) -> tuple[str
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:
@@ -507,6 +537,7 @@ def _format_time(dt: datetime | None) -> str | None:
# ---------------------------------------------------------------------------
@trace_service("获取扩展版任务列表", "Get task list v2")
async def get_task_list_v2(
user_id: int,
site_id: int,
@@ -533,36 +564,85 @@ async def get_task_list_v2(
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}
""",
(site_id, assistant_id, db_status),
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
""",
(site_id, assistant_id, db_status, page_size, offset),
query_params_page,
)
tasks = cur.fetchall()
conn.commit()
@@ -580,50 +660,27 @@ async def get_task_list_v2(
member_ids = list({t[7] for t in tasks})
# ── 2. FDW 批量查询会员信息 ──
# ── 2-5+8. 单连接批量查询所有 ETL 数据 ──
# CHANGE 2026-03-23 | 性能优化:合并 7 次独立 ETL 连接为 1 次
member_info_map: dict[int, dict] = {}
try:
member_info_map = fdw_queries.get_member_info(conn, site_id, member_ids)
except Exception:
logger.warning("FDW 查询会员信息失败", exc_info=True)
# ── 3. FDW 批量查询余额(优雅降级) ──
balance_map: dict[int, Decimal] = {}
try:
balance_map = fdw_queries.get_member_balance(conn, site_id, member_ids)
except Exception:
logger.warning("FDW 查询余额失败", exc_info=True)
# ── 4. FDW 批量查询 lastVisitDays优雅降级 ──
last_visit_map: dict[int, int | None] = {}
try:
last_visit_map = fdw_queries.get_last_visit_days(conn, site_id, member_ids)
except Exception:
logger.warning("FDW 查询 lastVisitDays 失败", exc_info=True)
# ── 5. RS 指数(用于 heart_score ──
# CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl → app直连 ETL 库)
rs_map: dict[int, Decimal] = {}
recent60d_map: dict[int, dict] = {}
batch_data: dict | None = None
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)
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]))
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 查询 RS 指数失败", exc_info=True)
try:
conn.rollback()
except Exception:
pass
logger.warning("ETL 批量查询失败,降级为空数据", exc_info=True)
# ── 6. 查询 ai_cache 获取 aiSuggestion优雅降级 ──
ai_suggestion_map: dict[int, str] = {}
@@ -673,8 +730,11 @@ async def get_task_list_v2(
except Exception:
logger.warning("查询备注存在性失败", exc_info=True)
# ── 8. 绩效概览 ──
performance = _build_performance_summary(conn, site_id, assistant_id)
# ── 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 = []
@@ -685,7 +745,17 @@ async def get_task_list_v2(
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,
@@ -699,9 +769,15 @@ async def get_task_list_v2(
"is_pinned": bool(is_pinned),
"has_note": task_id in has_note_set,
"status": task_status,
"last_visit_days": last_visit_map.get(member_id),
"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 {
@@ -716,67 +792,208 @@ async def get_task_list_v2(
conn.close()
def _build_performance_summary(conn, site_id: int, assistant_id: int) -> dict:
def _build_performance_summary(
conn, site_id: int, assistant_id: int, *, batch_data: dict | None = None,
) -> dict:
"""
构建绩效概览PerformanceSummary
从 fdw_queries.get_salary_calc 获取当月和上月数据,
计算收入趋势
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
# 当月绩效
salary = None
try:
salary = fdw_queries.get_salary_calc(conn, site_id, assistant_id, year, month)
except Exception:
logger.warning("FDW 查询当月绩效失败", exc_info=True)
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)
# 上月绩效(用于收入趋势)
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 = 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)
tier_nodes = salary["tier_nodes"] if salary and salary.get("tier_nodes") else [0]
# tier_nodes 可能是 JSON 字符串或列表
if isinstance(tier_nodes, str):
# 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:
tier_nodes = json.loads(tier_nodes)
except (json.JSONDecodeError, TypeError):
tier_nodes = [0]
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
current_tier = summary["tier_id"] if summary else (salary["tier_index"] if salary else 0)
coach_level = summary["coach_level"] if summary else (salary["coach_level"] if salary else "")
# 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": salary["total_hours"] if salary else 0.0,
"total_hours": total_hours,
"total_income": current_income,
"total_customers": salary["total_customers"] if salary else 0,
"total_customers": total_customers,
"month_label": f"{month}",
"tier_nodes": [float(n) for n in tier_nodes] if tier_nodes else [0],
"basic_hours": salary["basic_hours"] if salary else 0.0,
"bonus_hours": salary["bonus_hours"] if salary else 0.0,
"current_tier": salary["tier_index"] if salary else 0,
"next_tier_hours": salary["next_tier_hours"] if salary else 0.0,
"tier_completed": salary["tier_completed"] if salary else False,
"bonus_money": 0.0 if (salary and salary.get("tier_completed")) else (salary["bonus_money"] if salary else 0.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": salary["coach_level"] if salary else "",
"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,
@@ -831,6 +1048,15 @@ async def get_task_detail(
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 库)
@@ -862,6 +1088,7 @@ async def get_task_detail(
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),
@@ -874,7 +1101,7 @@ async def get_task_detail(
emoji, text = _extract_emoji_and_text(summary_raw)
tag = sanitize_tag(category)
tag_color = _CATEGORY_COLOR_MAP.get(tag, "#999999")
tag_color = _CATEGORY_COLOR_MAP.get(tag, "primary")
retention_clues.append({
"tag": tag,
@@ -936,17 +1163,33 @@ async def get_task_detail(
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 = 0.0
total_income = 0.0
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)
total_hours += hours
total_income += income
# 判断是否在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
# 时间格式化
settle_time = rec.get("settle_time")
date_str = ""
if settle_time:
if hasattr(settle_time, "strftime"):
@@ -957,6 +1200,13 @@ async def get_task_detail(
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 "基础课",
@@ -965,15 +1215,15 @@ async def get_task_detail(
"duration": hours,
"duration_raw": rec.get("service_hours_raw"),
"income": income,
"is_estimate": rec.get("is_estimate"),
"drinks": None,
"is_estimate": rec_is_estimate,
"drinks": rec.get("drinks"),
"date": date_str,
})
avg_income = total_income / len(service_records) if service_records else 0.0
avg_income = total_income_60d / count_60d if count_60d else 0.0
service_summary = {
"total_hours": round(total_hours, 2),
"total_income": round(total_income, 2),
"total_hours": round(total_hours_60d, 2),
"total_income": round(total_income_60d, 2),
"avg_income": round(avg_income, 2),
}
@@ -984,7 +1234,8 @@ async def get_task_detail(
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, content, type, ai_score, created_at
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
@@ -997,6 +1248,10 @@ async def get_task_detail(
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],
@@ -1004,7 +1259,8 @@ async def get_task_detail(
"tag_type": note_type,
"tag_label": tag_label,
"created_at": _format_time(note_row[4]) or "",
"score": note_row[3],
"score": user_score,
"ai_score": ai_score_val,
})
conn.commit()
except Exception:
@@ -1014,6 +1270,7 @@ async def get_task_detail(
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),
@@ -1024,6 +1281,7 @@ async def get_task_detail(
"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,