微信小程序页面迁移校验之前 P5任务处理之前
This commit is contained in:
483
apps/backend/app/services/task_generator.py
Normal file
483
apps/backend/app/services/task_generator.py
Normal file
@@ -0,0 +1,483 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
任务生成器(Task Generator)
|
||||
|
||||
每日 4:00 运行,基于 WBI/NCI/RS 指数为每个助教生成/更新任务。
|
||||
|
||||
本模块包含:
|
||||
- TaskPriority 枚举:任务类型优先级定义
|
||||
- TASK_TYPE_PRIORITY 映射:task_type 字符串 → 优先级
|
||||
- IndexData 数据类:客户-助教对的指数数据
|
||||
- determine_task_type():根据指数确定任务类型(纯函数)
|
||||
- should_replace_task():判断是否应替换现有任务(纯函数)
|
||||
- compute_heart_icon():根据 RS 指数计算爱心 icon 档位(纯函数)
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class TaskPriority(IntEnum):
|
||||
"""任务类型优先级,数值越小优先级越高。"""
|
||||
|
||||
HIGH_PRIORITY_RECALL = 0
|
||||
PRIORITY_RECALL = 0
|
||||
FOLLOW_UP_VISIT = 1
|
||||
RELATIONSHIP_BUILDING = 2
|
||||
|
||||
|
||||
TASK_TYPE_PRIORITY: dict[str, TaskPriority] = {
|
||||
"high_priority_recall": TaskPriority.HIGH_PRIORITY_RECALL,
|
||||
"priority_recall": TaskPriority.PRIORITY_RECALL,
|
||||
"follow_up_visit": TaskPriority.FOLLOW_UP_VISIT,
|
||||
"relationship_building": TaskPriority.RELATIONSHIP_BUILDING,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexData:
|
||||
"""某客户-助教对的指数数据。"""
|
||||
|
||||
site_id: int
|
||||
assistant_id: int
|
||||
member_id: int
|
||||
wbi: Decimal # 流失回赢指数
|
||||
nci: Decimal # 新客转化指数
|
||||
rs: Decimal # 关系强度指数
|
||||
has_active_recall: bool # 是否有活跃召回任务
|
||||
has_follow_up_note: bool # 召回完成后是否有回访备注
|
||||
|
||||
|
||||
def determine_task_type(index_data: IndexData) -> str | None:
|
||||
"""
|
||||
根据指数数据确定应生成的任务类型。
|
||||
|
||||
优先级规则(高 → 低):
|
||||
1. max(WBI, NCI) > 7 → high_priority_recall
|
||||
2. max(WBI, NCI) > 5 → priority_recall
|
||||
3. RS < 6 → relationship_building
|
||||
4. 不满足任何条件 → None(不生成任务)
|
||||
|
||||
返回: task_type 字符串或 None
|
||||
"""
|
||||
priority_score = max(index_data.wbi, index_data.nci)
|
||||
|
||||
if priority_score > 7:
|
||||
return "high_priority_recall"
|
||||
if priority_score > 5:
|
||||
return "priority_recall"
|
||||
if index_data.rs < 6:
|
||||
return "relationship_building"
|
||||
return None
|
||||
|
||||
|
||||
def should_replace_task(existing_type: str, new_type: str) -> bool:
|
||||
"""
|
||||
判断新任务类型是否应替换现有任务类型。
|
||||
|
||||
规则:类型不同即替换,相同类型不替换。
|
||||
"""
|
||||
if existing_type == new_type:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def compute_heart_icon(rs_score: Decimal) -> str:
|
||||
"""
|
||||
根据 RS 指数计算爱心 icon 档位。
|
||||
|
||||
档位规则:
|
||||
- RS > 8.5 → 💖
|
||||
- 7 < RS ≤ 8.5 → 🧡
|
||||
- 5 < RS ≤ 7 → 💛
|
||||
- RS ≤ 5 → 💙
|
||||
"""
|
||||
if rs_score > Decimal("8.5"):
|
||||
return "💖"
|
||||
if rs_score > Decimal("7"):
|
||||
return "🧡"
|
||||
if rs_score > Decimal("5"):
|
||||
return "💛"
|
||||
return "💙"
|
||||
|
||||
|
||||
# ── run() 主流程 ──────────────────────────────────────────────
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
"""
|
||||
任务生成器主流程。
|
||||
|
||||
1. 通过 auth.user_assistant_binding 获取所有已绑定助教
|
||||
2. 对每个助教-客户对,通过 FDW 读取 WBI/NCI/RS 指数
|
||||
3. 调用 determine_task_type() 确定任务类型
|
||||
4. 检查已存在的 active 任务:相同 task_type → 跳过;
|
||||
不同 task_type → 关闭旧任务 + 创建新任务 + 记录 history
|
||||
5. 处理 follow_up_visit 的 48 小时滞留机制(expires_at 填充)
|
||||
6. 更新 trigger_jobs 时间戳
|
||||
|
||||
返回: {"created": int, "replaced": int, "skipped": int}
|
||||
"""
|
||||
stats = {"created": 0, "replaced": 0, "skipped": 0}
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# ── 1. 获取所有已绑定助教 ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT site_id, assistant_id
|
||||
FROM auth.user_assistant_binding
|
||||
WHERE assistant_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
bindings = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
# ── 2. 逐助教处理 ──
|
||||
for site_id, assistant_id in bindings:
|
||||
try:
|
||||
_process_assistant(conn, site_id, assistant_id, stats)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"处理助教失败: site_id=%s, assistant_id=%s",
|
||||
site_id,
|
||||
assistant_id,
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
# ── 6. 更新 trigger_jobs 时间戳 ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.trigger_jobs
|
||||
SET last_run_at = NOW()
|
||||
WHERE job_name = 'task_generator'
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info(
|
||||
"任务生成器完成: created=%d, replaced=%d, skipped=%d",
|
||||
stats["created"],
|
||||
stats["replaced"],
|
||||
stats["skipped"],
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def _process_assistant(
|
||||
conn, site_id: int, assistant_id: int, stats: dict
|
||||
) -> None:
|
||||
"""处理单个助教下所有客户-助教对的任务生成。"""
|
||||
|
||||
# 通过 FDW 读取该助教关联的客户指数数据
|
||||
# 需要 SET LOCAL app.current_site_id 以启用 RLS
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
|
||||
# 读取 WBI(流失回赢指数)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(display_score, 0)
|
||||
FROM fdw_etl.v_dws_member_winback_index
|
||||
"""
|
||||
)
|
||||
wbi_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
|
||||
# 读取 NCI(新客转化指数)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(display_score, 0)
|
||||
FROM fdw_etl.v_dws_member_newconv_index
|
||||
"""
|
||||
)
|
||||
nci_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
|
||||
# 读取 RS(关系强度指数)— 按 assistant_id 过滤
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(rs_display, 0)
|
||||
FROM fdw_etl.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s
|
||||
""",
|
||||
(assistant_id,),
|
||||
)
|
||||
rs_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 合并所有涉及的 member_id
|
||||
all_member_ids = set(wbi_map.keys()) | set(nci_map.keys()) | set(rs_map.keys())
|
||||
|
||||
# 逐客户处理,每对独立事务
|
||||
for member_id in all_member_ids:
|
||||
try:
|
||||
_process_member_task(
|
||||
conn,
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
wbi_map.get(member_id, Decimal("0")),
|
||||
nci_map.get(member_id, Decimal("0")),
|
||||
rs_map.get(member_id, Decimal("0")),
|
||||
stats,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"处理客户任务失败: site_id=%s, assistant_id=%s, member_id=%s",
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
|
||||
def _process_member_task(
|
||||
conn,
|
||||
site_id: int,
|
||||
assistant_id: int,
|
||||
member_id: int,
|
||||
wbi: Decimal,
|
||||
nci: Decimal,
|
||||
rs: Decimal,
|
||||
stats: dict,
|
||||
) -> None:
|
||||
"""
|
||||
处理单个客户-助教对的任务生成/更新。
|
||||
|
||||
每对独立事务,失败不影响其他。
|
||||
"""
|
||||
index_data = IndexData(
|
||||
site_id=site_id,
|
||||
assistant_id=assistant_id,
|
||||
member_id=member_id,
|
||||
wbi=wbi,
|
||||
nci=nci,
|
||||
rs=rs,
|
||||
# follow_up_visit 条件由外部传入;当前简化:不自动生成 follow_up_visit
|
||||
# (follow_up_visit 由召回完成检测器触发,不在 task_generator 主动生成)
|
||||
has_active_recall=True,
|
||||
has_follow_up_note=True,
|
||||
)
|
||||
|
||||
new_task_type = determine_task_type(index_data)
|
||||
if new_task_type is None:
|
||||
# 不满足任何条件 → 检查是否有需要填充 expires_at 的 follow_up_visit
|
||||
_handle_no_task_condition(conn, site_id, assistant_id, member_id)
|
||||
stats["skipped"] += 1
|
||||
return
|
||||
|
||||
priority_score = max(wbi, nci)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
|
||||
# ── 4. 检查已存在的 active 任务 ──
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type, expires_at, created_at
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND member_id = %s
|
||||
AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
existing_tasks = cur.fetchall()
|
||||
|
||||
# 检查是否已有相同 task_type 的 active 任务
|
||||
same_type_exists = any(row[1] == new_task_type for row in existing_tasks)
|
||||
if same_type_exists:
|
||||
# 相同 task_type → 跳过
|
||||
conn.commit()
|
||||
stats["skipped"] += 1
|
||||
return
|
||||
|
||||
# 不同 task_type 的 active 任务 → 关闭旧任务 + 创建新任务
|
||||
for task_id, old_type, old_expires_at, old_created_at in existing_tasks:
|
||||
if should_replace_task(old_type, new_task_type):
|
||||
# 特殊处理:旧任务是 follow_up_visit → 填充 expires_at 而非直接 inactive
|
||||
if old_type == "follow_up_visit" and old_expires_at is None:
|
||||
# 需求 5.2: follow_up_visit 被高优先级任务顶替时,
|
||||
# 填充 expires_at = created_at + 48h,保持 active
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET expires_at = created_at + INTERVAL '48 hours',
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="expires_at_filled",
|
||||
old_status="active",
|
||||
new_status="active",
|
||||
old_task_type=old_type,
|
||||
new_task_type=old_type,
|
||||
detail={"reason": "higher_priority_task_created"},
|
||||
)
|
||||
else:
|
||||
# 关闭旧任务
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="type_change_close",
|
||||
old_status="active",
|
||||
new_status="inactive",
|
||||
old_task_type=old_type,
|
||||
new_task_type=new_task_type,
|
||||
)
|
||||
|
||||
stats["replaced"] += 1
|
||||
|
||||
# ── 创建新任务 ──
|
||||
# follow_up_visit 生成时 expires_at = NULL(需求 4.1)
|
||||
expires_at_val = None
|
||||
|
||||
# 需求 4.4: 若新任务是 follow_up_visit 且已存在有 expires_at 的旧 follow_up_visit
|
||||
# → 旧任务已在上面被标记为 inactive,新任务 expires_at = NULL
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type, status,
|
||||
priority_score, expires_at, parent_task_id)
|
||||
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
new_task_type,
|
||||
float(priority_score),
|
||||
expires_at_val,
|
||||
# parent_task_id: 关联最近被关闭的旧任务(如有)
|
||||
existing_tasks[0][0] if existing_tasks else None,
|
||||
),
|
||||
)
|
||||
new_task_id = cur.fetchone()[0]
|
||||
|
||||
_insert_history(
|
||||
cur,
|
||||
new_task_id,
|
||||
action="created",
|
||||
old_status=None,
|
||||
new_status="active",
|
||||
old_task_type=existing_tasks[0][1] if existing_tasks else None,
|
||||
new_task_type=new_task_type,
|
||||
)
|
||||
|
||||
stats["created"] += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _handle_no_task_condition(
|
||||
conn, site_id: int, assistant_id: int, member_id: int
|
||||
) -> None:
|
||||
"""
|
||||
当不满足任何任务生成条件时,检查是否有 follow_up_visit 需要填充 expires_at。
|
||||
|
||||
需求 4.2: 当 follow_up_visit 的触发条件不再满足时,
|
||||
填充 expires_at = created_at + 48h。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, expires_at
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND member_id = %s
|
||||
AND task_type = 'follow_up_visit'
|
||||
AND status = 'active'
|
||||
AND expires_at IS NULL
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
for task_id, _ in rows:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET expires_at = created_at + INTERVAL '48 hours',
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="expires_at_filled",
|
||||
old_status="active",
|
||||
new_status="active",
|
||||
detail={"reason": "condition_no_longer_met"},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_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 中记录变更。"""
|
||||
import json
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user