Files
Neo-ZQYY/apps/backend/app/services/recall_detector.py
2026-03-15 10:15:02 +08:00

284 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
召回完成检测器Recall Completion Detector
ETL 数据更新后,通过 FDW 读取助教服务记录,
匹配活跃任务标记为 completed记录 completed_at 和 completed_task_type 快照,
触发 recall_completed 事件通知备注回溯重分类器。
由 trigger_jobs 中的 recall_completion_check 配置驱动event: etl_data_updated
"""
import json
import logging
logger = logging.getLogger(__name__)
def _get_connection():
"""延迟导入 get_connection避免模块级导入失败。"""
from app.database import get_connection
return get_connection()
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 中记录变更。"""
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 run(payload: dict | None = None, job_id: int | None = None) -> dict:
"""
召回完成检测主流程。
1. 从 trigger_jobs 读取 last_run_at 作为增量过滤基准
2. 获取所有 distinct site_id从 active 任务中)
3. 对每个 site_idSET LOCAL app.current_site_id 后
通过 FDW 读取 v_dwd_assistant_service_log 中 service_time > last_run_at 的新增服务记录
4. 对每条服务记录,查找 biz.coach_tasks 中匹配的
(site_id, assistant_id, member_id) 且 status='active' 的任务
5. 将匹配任务标记为 completed
- status = 'completed'
- completed_at = 服务时间
- completed_task_type = 当前 task_type快照
6. 记录 coach_task_history
7. 触发 fire_event('recall_completed', {site_id, assistant_id, member_id, service_time})
参数:
payload: 事件载荷event 触发时由 trigger_scheduler 传入)
job_id: 触发器 job ID由 trigger_scheduler 传入),用于在最终事务中
更新 last_run_at保证 handler 数据变更与 last_run_at 原子提交
返回: {"completed_count": int}
"""
completed_count = 0
conn = _get_connection()
try:
# ── 1. 读取 last_run_at ──
with conn.cursor() as cur:
cur.execute(
"""
SELECT last_run_at
FROM biz.trigger_jobs
WHERE job_name = 'recall_completion_check'
"""
)
row = cur.fetchone()
last_run_at = row[0] if row else None
conn.commit()
# ── 2. 获取所有有 active 任务的 distinct site_id ──
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT site_id
FROM biz.coach_tasks
WHERE status = 'active'
"""
)
site_ids = [r[0] for r in cur.fetchall()]
conn.commit()
# ── 3. 逐 site_id 读取新增服务记录 ──
for site_id in site_ids:
try:
count = _process_site(conn, site_id, last_run_at)
completed_count += count
except Exception:
logger.exception(
"处理门店召回检测失败: site_id=%s", site_id
)
conn.rollback()
# ── 事务安全T5handler 成功后更新 last_run_at ──
# job_id 由 trigger_scheduler 传入,在 handler 最终事务中更新
# handler 异常时此处不会执行异常向上传播last_run_at 不变
if job_id is not None:
from app.services.trigger_scheduler import update_job_last_run_at
with conn.cursor() as cur:
cur.execute("BEGIN")
update_job_last_run_at(cur, job_id)
conn.commit()
finally:
conn.close()
logger.info("召回完成检测完成: completed_count=%d", completed_count)
return {"completed_count": completed_count}
def _process_site(conn, site_id: int, last_run_at) -> int:
"""
处理单个门店的召回完成检测。
通过 FDW 读取新增服务记录,匹配 active 任务并标记 completed。
返回本门店完成的任务数。
"""
completed = 0
# 通过 FDW 读取新增服务记录(需要 SET LOCAL 启用 RLS
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"SET LOCAL app.current_site_id = %s", (str(site_id),)
)
if last_run_at is not None:
cur.execute(
"""
SELECT DISTINCT assistant_id, member_id, service_time
FROM fdw_etl.v_dwd_assistant_service_log
WHERE service_time > %s
ORDER BY service_time ASC
""",
(last_run_at,),
)
else:
# 首次运行,读取所有服务记录
cur.execute(
"""
SELECT DISTINCT assistant_id, member_id, service_time
FROM fdw_etl.v_dwd_assistant_service_log
ORDER BY service_time ASC
"""
)
service_records = cur.fetchall()
conn.commit()
# ── 4-7. 逐条服务记录匹配并处理 ──
for assistant_id, member_id, service_time in service_records:
try:
count = _process_service_record(
conn, site_id, assistant_id, member_id, service_time
)
completed += count
except Exception:
logger.exception(
"处理服务记录失败: site_id=%s, assistant_id=%s, member_id=%s",
site_id,
assistant_id,
member_id,
)
conn.rollback()
return completed
def _process_service_record(
conn,
site_id: int,
assistant_id: int,
member_id: int,
service_time,
) -> int:
"""
处理单条服务记录:匹配 active 任务并标记 completed。
每条服务记录独立事务,失败不影响其他。
返回本次完成的任务数。
"""
completed = 0
with conn.cursor() as cur:
cur.execute("BEGIN")
# 查找匹配的 active 召回类任务(仅完成召回任务,回访/关系构建不在此处理)
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'
AND task_type IN ('high_priority_recall', 'priority_recall')
""",
(site_id, assistant_id, member_id),
)
active_tasks = cur.fetchall()
if not active_tasks:
conn.commit()
return 0
# 将所有匹配的 active 任务标记为 completed
for task_id, task_type in active_tasks:
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'completed',
completed_at = %s,
completed_task_type = %s,
updated_at = NOW()
WHERE id = %s AND status = 'active'
""",
(service_time, task_type, task_id),
)
_insert_history(
cur,
task_id,
action="completed",
old_status="active",
new_status="completed",
old_task_type=task_type,
new_task_type=task_type,
detail={
"service_time": str(service_time),
"completed_task_type": task_type,
},
)
completed += 1
conn.commit()
# ── 7. 触发 recall_completed 事件 ──
# 延迟导入 fire_event 避免循环依赖
try:
from app.services.trigger_scheduler import fire_event
fire_event(
"recall_completed",
{
"site_id": site_id,
"assistant_id": assistant_id,
"member_id": member_id,
"service_time": str(service_time),
},
)
except Exception:
logger.exception(
"触发 recall_completed 事件失败: site_id=%s, assistant_id=%s, member_id=%s",
site_id,
assistant_id,
member_id,
)
return completed