import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { TreatmentInitiationRecallScenario } from './scenarios/treatment-initiation-recall.scenario';
import type { PlanScenarioPlugin, ScenarioHit, ScenarioScope } from './scenario.interface';
import { reasonNeedsRefresh } from './reason-refresh';
import { runPool } from '../../../common/run-pool';
import { PlanEventType, PlanEventReason } from '@pac/types';
import { recordPlanEvent, recordPlanEventsBulk, computeHeldSeconds } from '../plan-event.recorder';

/**
 * PlanEngineService — 跑所有 scenario plugin,产 FollowupPlan + PlanReason
 *
 * 模式:**Plan 是 patient 级触达单元**,Reason 是 scenario 维度命中理由。
 * 同 patient 多 scenario 命中 → 合并到同一 active Plan(多条 reason);
 * 同 plan 内同 scenario 最多一条 reason(`@@unique([planId, scenario])` 兜底)。
 *
 * 入口:
 *   - runAllForHost({ hostId, tenantId, now }) — 批量,所有 patient 全扫
 *   - runForPatient({ ..., patientId })        — 单 patient(W3+ 单刷场景)
 *
 * Plan 状态机:
 *   - 无 active plan + 有 hits → 创建新 plan(version=1)
 *   - 有 active plan + hits 一致(reason set 同)→ no-op(只更新 priorityScore)
 *   - 有 active plan + hits 不同 → supersede 旧 + 创建新版本
 *   - 已有 assigned plan → 不动(避免抢正在跟进的客服)— v1 用 simple skip
 */
@Injectable()
export class PlanEngineService {
  private readonly logger = new Logger(PlanEngineService.name);
  private readonly scenarios: PlanScenarioPlugin[];

  constructor(
    private readonly prisma: PrismaService,
    private readonly initiation: TreatmentInitiationRecallScenario,
  ) {
    // v2.1:一期只跑潜在治疗新链召回(treatment_initiation_recall)
    // 链已完成召回(treatment_aftercare_recall)留后续
    this.scenarios = [initiation];
  }

  /**
   * 单 patient 重算(W2 兼容入口 — 走 runAllForHost 内置过滤)。
   */
  async recomputeForPatient(input: {
    hostId: string;
    tenantId: string;
    clinicId?: string;
    patientId: string;
  }): Promise<{
    plansCreated: number;
    plansClosed: number;
    /// 本次重算对该患者 plan 的结果(前端刷新据此提示/切换版本)
    outcome: 'created' | 'superseded' | 'unchanged' | 'suppressed' | 'closed' | 'none';
    /// 重算后该患者当前 active/assigned plan id(关闭/无 plan 时 null)
    currentPlanId: string | null;
  }> {
    // 真·单 patient:scenario SQL 带 patientId 收窄(只扫该患者),只 upsert 这一个 plan。
    // (旧版走 runAllForHost 全租户扫 + 写全员 plan,详情页单刷被拖到分钟级 → 现 O(1))
    const scope: ScenarioScope = {
      hostId: input.hostId,
      tenantId: input.tenantId,
      now: new Date(),
      patientId: input.patientId,
    };
    const hits: ScenarioHitWithKey[] = [];
    for (const sc of this.scenarios) {
      const scHits = await sc.selectHits(scope);
      for (const h of scHits) hits.push({ ...h, scenarioKey: sc.key });
    }
    // ⭐ 缺口1 修复:本轮 0 命中 = 该患者已无任何活信号(治完疗/诊断消失/被排除闸过滤)。
    //   旧版直接 return → 患者遗留的 active plan 永远残留在召回池(治完疗该结案却一直 active)。
    //   现在主动 supersede 关闭它最新那条在池(active / assigned)plan,让它退出召回池。
    //   - assigned **一起关**(见 closeStaleActivePlan 与批量路径注释):信号都没了,
    //     留在客服手上只会让他按已失效的理由去跟进。此前注释写"assigned 不动"是过期的,
    //     代码从批量路径修复那次起就已含 assigned。
    //   - superseded/completed/abandoned 已是终态,无需处理
    if (hits.length === 0) {
      const closed = await this.closeStaleActivePlan(scope, input.patientId);
      return {
        plansCreated: 0,
        plansClosed: closed ? 1 : 0,
        outcome: closed ? 'closed' : 'none',
        currentPlanId: null,
      };
    }

    const log = await this.prisma.planGenerationLog.create({
      data: {
        hostId: input.hostId,
        tenantId: input.tenantId,
        patientId: input.patientId,
        triggeredBy: 'patient_refresh',
        status: 'running',
        scenarioRunCount: this.scenarios.length,
      },
    });
    try {
      const result = await this.upsertPlan({ scope, patientId: input.patientId, hits });
      const created = result === 'created' || result === 'superseded' ? 1 : 0;
      await this.prisma.planGenerationLog.update({
        where: { id: log.id },
        data: { status: 'success', plansCreated: created, endedAt: new Date() },
      });
      // 重算后该患者当前 plan id(可能是新版本)→ 回给前端做"切到新版本"
      const current = await this.prisma.followupPlan.findFirst({
        where: {
          hostId: input.hostId,
          tenantId: input.tenantId,
          patientId: input.patientId,
          status: { in: ['active', 'assigned'] },
        },
        orderBy: { version: 'desc' },
        select: { id: true },
      });
      // upsert 路径自身会 supersede 旧版本(版本流),不算"关闭退池";plansClosed 只统计 0 命中关闭。
      return { plansCreated: created, plansClosed: 0, outcome: result, currentPlanId: current?.id ?? null };
    } catch (err) {
      await this.prisma.planGenerationLog.update({
        where: { id: log.id },
        data: {
          status: 'failed',
          errorMessage: err instanceof Error ? err.message : String(err),
          endedAt: new Date(),
        },
      });
      throw err;
    }
  }

  /**
   * 解析「客服碰过没」。⚠️ 只在**真的需要**时才去查 view 事件:
   *   · 行上三个强信号任一为真 → 直接判碰过,不查库
   *   · 该单压根没有 assignment_id → 归因本来就是空的,查了也没用
   * 批量路径用预取集合(见 prefetchForBatch),单患者路径才走这一次 findFirst。
   * —— 不这么收窄的话,runAllForHost 全量 44 万患者会多出 44 万次查询。
   */
  private async agentTouched(
    latest: PlanWithReasons,
    prefetched?: { touchedPlanIds: ReadonlySet<string> },
  ): Promise<boolean> {
    if (planTouchedByAgent(latest, false)) return true;
    if (latest.assignmentId == null) return false;
    if (prefetched) return prefetched.touchedPlanIds.has(latest.id);
    const v = await this.prisma.planEventLog.findFirst({
      where: { planId: latest.id, event: PlanEventType.VIEW },
      select: { id: true },
    });
    return v != null;
  }

  /**
   * ⭐ 缺口1 helper:关闭该患者遗留的 active(非 assigned)plan(本轮 0 命中 → 退出召回池)。
   *
   * 关 status='active' **或 'assigned'** 的最新一条(2026-06 决策:缺口全消失 → 即使 assigned 也关,
   *   否则客服会拿"已解决"的理由白打;前端刷新时据此弹"已关闭"提示):
   *   - superseded/completed/abandoned 已是终态,不动
   * 关闭 = supersede(status='superseded' + supersededAt=now),不创建后继版本
   *   —— "无信号了"是终结,不是版本演进,故无新版本(版本流纪律:只有"换 reason set"才升版本)。
   * 返回是否真的关了一条(给调用方计数)。
   */
  private async closeStaleActivePlan(
    scope: ScenarioScope,
    patientId: string,
  ): Promise<boolean> {
    const latest = await this.prisma.followupPlan.findFirst({
      where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId },
      orderBy: { version: 'desc' },
      // ⭐ 多取四列是为了记账(见下):没有 assigneeUserId 判不出该不该记,
      //   没有 assignedAt 算不出持有时长(清空后永远算不出来)。
      select: {
        id: true, status: true, hostId: true, tenantId: true,
        patientId: true, assigneeUserId: true, assignedAt: true,
      },
    });
    if (!latest || (latest.status !== 'active' && latest.status !== 'assigned')) return false;
    // ⭐ 关的若是**有人认领的**单 → 必须留账:客服手上的单被系统收走了,
    //   不记账则「他接了多少、跟丢多少」永久查不到,退回率的分母也天生偏小。
    //   无人认领的关闭不记(那是纯粹的池子出清,不涉及归属变更,记了全是噪声)。
    const hadAssignee = latest.assigneeUserId != null;
    const heldSeconds = computeHeldSeconds(latest.assignedAt, scope.now);
    await this.prisma.$transaction(async (tx) => {
      await tx.followupPlan.update({
        where: { id: latest.id },
        data: { status: 'superseded', supersededAt: scope.now },
      });
      if (hadAssignee) {
        await recordPlanEvent(tx, {
          hostId: latest.hostId,
          tenantId: latest.tenantId,
          planId: latest.id,
          patientId: latest.patientId,
          event: PlanEventType.AUTO_RELEASE,
          assigneeUserId: null,
          actorUserId: null, // 系统行为
          heldSeconds,
          reason: PlanEventReason.SIGNALS_CLEARED,
        });
      }
    });
    return true;
  }

  /**
   * 批量跑某 host 全 patient 的召回算法,产 plans。
   */
  async runAllForHost(input: {
    hostId: string;
    tenantId: string;
    now?: Date;
  }): Promise<EngineRunResult> {
    const startedAt = new Date();
    const now = input.now ?? new Date();
    const scope: ScenarioScope = {
      hostId: input.hostId,
      tenantId: input.tenantId,
      now,
    };

    // 1. 各 scenario 跑 selector,汇总 hits
    const hitsByPatient = new Map<string, ScenarioHitWithKey[]>();
    for (const sc of this.scenarios) {
      const hits = await sc.selectHits(scope);
      for (const h of hits) {
        const arr = hitsByPatient.get(h.patientId) ?? [];
        arr.push({ ...h, scenarioKey: sc.key });
        hitsByPatient.set(h.patientId, arr);
      }
    }

    let plansCreated = 0;
    let plansSuperseded = 0;
    let plansUnchanged = 0;
    const plansSkippedAssigned = 0; // assigned 不再 skip(保留返回字段=0,兼容旧结构)
    let plansSuppressed = 0;
    let plansClosed = 0;

    // 2. 批量写 plan。
    //   优化(2026-06):原"逐 patient 串行 + 每人 2 读(latest/snoozed)+ persona + 2 日志写",
    //     全 host(~13万)上 upsert 段 ~49min。改为:循环前一次性 **bulk 预取** latest/snoozed/persona
    //     → 逐患复用**同一** upsert 判定(结果等价)→ 写操作**分块并发** → PlanGenerationLog 收集后
    //     一次性 createMany(每患仍一行,审计/监控口径不变,只是写法从 12.5万次 → 几次)。
    //   注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。
    const patientIds = [...hitsByPatient.keys()];
    const { latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient, touchedPlanIds } =
      await this.prefetchForBatch(scope, patientIds, now);
    const EMPTY_SNOOZE = new Map<string, Date>();
    const logRows: Prisma.PlanGenerationLogCreateManyInput[] = [];
    //   ⭐ 2026-07-26:写操作从「每 N 个一批 await Promise.all」的**栅栏**改成连续调度的
    //   worker pool(见 common/run-pool.ts)。栅栏每批都要等本批最慢那个,快的 worker 干等。
    //   同一处改法在 recompute-persona 上实测过(测试服 35.7 万患者,concurrency=8):
    //     并发效率 44% → 94%,吞吐 1,454 → 2,694 人/分。
    //   plan 侧单患者耗时的离散度只会更大(upsertPlan 里 reason 数量差异明显),栅栏的浪费同理。
    const concurrency = Math.max(1, Number(process.env.PAC_PLAN_BATCH_CONCURRENCY) || 8);
    const entries = [...hitsByPatient.entries()];
    await runPool(entries, concurrency, async ([patientId, hits]) => {
      try {
        const result = await this.upsertPlan({
          scope,
          patientId,
          hits,
          prefetched: {
            latest: latestByPatient.get(patientId) ?? null,
            snoozedAnchors: snoozedByPatient.get(patientId) ?? EMPTY_SNOOZE,
            personaId: personaByPatient.get(patientId) ?? null,
            lastVisitClinicId: lastVisitClinicByPatient.get(patientId) ?? null,
            touchedPlanIds,
          },
        });
        // 计数:JS 单线程,await 恢复后同步自增,chunk 内并发无交错
        if (result === 'created') plansCreated++;
        else if (result === 'superseded') plansSuperseded++;
        else if (result === 'unchanged') plansUnchanged++;
        else if (result === 'suppressed') plansSuppressed++;
        logRows.push({
          hostId: scope.hostId,
          tenantId: scope.tenantId,
          patientId,
          triggeredBy: 'cli:batch',
          status: 'success',
          scenarioRunCount: this.scenarios.length,
          plansCreated: result === 'created' || result === 'superseded' ? 1 : 0,
          startedAt,
          endedAt: new Date(),
        });
      } catch (err) {
        logRows.push({
          hostId: scope.hostId,
          tenantId: scope.tenantId,
          patientId,
          triggeredBy: 'cli:batch',
          status: 'failed',
          scenarioRunCount: this.scenarios.length,
          errorMessage: err instanceof Error ? err.message : String(err),
          startedAt,
          endedAt: new Date(),
        });
        this.logger.error(
          `plan upsert failed patient=${patientId}: ${err instanceof Error ? err.message : err}`,
        );
      }
    });
    for (let i = 0; i < logRows.length; i += 5000) {
      await this.prisma.planGenerationLog.createMany({ data: logRows.slice(i, i + 5000) });
    }

    // 3. ⭐ 缺口1 修复:关闭"有 active plan 但本轮 0 命中"的患者的遗留 plan。
    //   旧版只遍历 hitsByPatient(有命中的患者)→ 治完疗变 0 命中的患者,其 plan 永远残留召回池。
    //   现在:跑完 upsert(已产生本轮新 active)后,扫该 (host, tenant) 全部 active+assigned plan,
    //   凡 patientId 不在本轮 hitsByPatient 的,supersede 关闭(退出召回池)。
    //   纪律:
    //     - 按 tenant 限定(隔离闸)
    //     - 关 status IN ('active','assigned')(2026-06 决策:缺口全消失即关,assigned 也关 —
    //       与 upsertPlan 注释 + closeStaleActivePlan 定向路径一致;此前批量路径漏改成只关 active,
    //       致已认领的误召计划在修复后仍残留旧理由 → 客服按错信息跟进。终态不动)
    //     - in-pool 的 assigned 不会被误关:其患者在 hitsByPatient,会被 has 命中跳过(理由已由 upsert 刷新+继承认领)
    //     - 不创建后继版本(无信号是终结,非版本演进)
    //     - ⭐ 关掉的若是**有人认领的**单 → 同事务补一条 auto_release 账(2026-08 加)。
    //       这条路是每日全量跑的,漏记的量级远大于单刷路径 closeStaleActivePlan:
    //       主管分下去的单被系统悄悄收回,主管无感知、退回率分母偏小,正是分配功能最怕的。
    const activePlans = await this.prisma.followupPlan.findMany({
      where: { hostId: scope.hostId, tenantId: scope.tenantId, status: { in: ['active', 'assigned'] } },
      // 多取两列供记账用(assignedAt 清不清都要先算持有时长)
      select: { id: true, patientId: true, assigneeUserId: true, assignedAt: true },
    });
    const staleRows = activePlans.filter((pl) => !hitsByPatient.has(pl.patientId));
    if (staleRows.length > 0) {
      // ⚠️ 必须分片:原实现是一条 `id: { in: staleIds }`,PG bind 变量上限 32767 —— 池子上了
      //   三万条(生产 44 万患者完全可能)就会直接报错。顺带让每片自成事务,
      //   一片失败不至于让整轮关闭全部回滚。
      const CHUNK = 1000;
      let closed = 0;
      let released = 0;
      for (let i = 0; i < staleRows.length; i += CHUNK) {
        const chunk = staleRows.slice(i, i + CHUNK);
        const events = chunk
          .filter((pl) => pl.assigneeUserId != null)
          .map((pl) => ({
            hostId: scope.hostId,
            tenantId: scope.tenantId,
            planId: pl.id,
            patientId: pl.patientId,
            event: PlanEventType.AUTO_RELEASE,
            assigneeUserId: null,
            actorUserId: null, // 系统行为
            heldSeconds: computeHeldSeconds(pl.assignedAt, now),
            reason: PlanEventReason.SIGNALS_CLEARED,
          }));
        await this.prisma.$transaction(async (tx) => {
          const res = await tx.followupPlan.updateMany({
            where: { id: { in: chunk.map((pl) => pl.id) } },
            data: { status: 'superseded', supersededAt: now },
          });
          closed += res.count;
          await recordPlanEventsBulk(tx, events);
        });
        released += events.length;
      }
      plansClosed = closed;
      if (released > 0) {
        // ⚠️ 事件洪峰预警:一次口径变更可能让大量诊所的单同时失去信号 →
        //   一轮写出成百上千条 auto_release。这行日志是给运维看的"这不是 bug"。
        this.logger.warn(
          `[归属账本] 本轮关闭 ${closed} 条无信号 plan,其中 ${released} 条**原本挂在客服名下**,` +
            `已记 auto_release(reason=signals_cleared)。数量异常偏高时先查是不是召回口径刚变过。`,
        );
      }
    }

    return {
      scenariosRun: this.scenarios.length,
      patientsHit: hitsByPatient.size,
      plansCreated,
      plansSuperseded,
      plansUnchanged,
      plansSkippedAssigned,
      plansSuppressed,
      plansClosed,
      durationMs: Date.now() - startedAt.getTime(),
    };
  }

  // ── plan upsert(单 patient,事务内 supersede + insert)──
  // prefetched:批量路径(runAllForHost)循环前一次性 bulk 取好 latest/snoozed/persona,
  //   逐患者复用**同一**判定逻辑但不再每人发 3 条读查询(结果等价,仅省往返)。
  //   单刷路径(recomputeForPatient)不传 → 照旧逐查,行为不变。
  private async upsertPlan(input: {
    scope: ScenarioScope;
    patientId: string;
    hits: ScenarioHitWithKey[];
    prefetched?: {
      latest: PlanWithReasons | null;
      snoozedAnchors: Map<string, Date>;
      personaId: string | null;
      lastVisitClinicId: string | null;
      /// 批量路径预取的「客服碰过」集合(只覆盖带 assignment_id 的单,见 prefetchForBatch)
      touchedPlanIds: ReadonlySet<string>;
    };
  }): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> {
    const { scope, patientId, hits, prefetched } = input;

    const latest = prefetched
      ? prefetched.latest
      : await this.prisma.followupPlan.findFirst({
          where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId },
          orderBy: { version: 'desc' },
          include: { reasons: true },
        });

    // ⭐ 设计决策(2026-06):assigned 不再整体 skip。
    //   理由:客服"正在执行"恰好撞上"正在重算"概率极低,为这点概率冻结内容、让客服拿过时理由白打,
    //   得不偿失。改为:照常重算,但**升新版本时继承分配**(assignee/assignedAt/recycleAt/contactAttempts
    //   带到新版)→ 客服不丢单、不被抢,只是召回理由刷新到最新;理由没变则只就地刷分(下面 unchanged 分支)。
    //   缺口全消失(0 命中)→ 即使 assigned 也关闭(见 closeStaleActivePlan)。

    // ⭐ 信号级抑制(召回闭环核心)— 抑制粒度 = 召回算法粒度 (scenario, subKey)。
    //   只压"已结案那条召回覆盖的诊断";患者新长的、不在已结案理由里的诊断照常召回(不受冷静期影响)。
    //   做法:取该患者所有"终态(completed/abandoned)+ 冷静期未到期"plan 的 reason (scenario, subKey)
    //         并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。
    //   修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。
    //   多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
    const snoozedAnchors = prefetched
      ? prefetched.snoozedAnchors
      : await this.fetchSnoozedSignalKeys(scope, patientId, scope.now);
    const usableHits =
      snoozedAnchors.size === 0
        ? hits
        : hits.filter((h) => {
            const anchor = snoozedAnchors.get(`${h.scenarioKey}|${h.subKey ?? ''}`);
            if (!anchor) return true; // 不在抑制集 → 放行
            // ⭐ 时间锚逃逸:cluster 内存在"结案之后新发"的同类信号 → 视为新机会,放行复活。
            //   ("永久"抑制只对结案时已知的问题永久 —— 外院种植失败回院再诊断/多年后新发同类
            //    @whole 病种,不该被老 snooze 压死。)latest 缺省(旧 scenario 未提供)→ 维持全压。
            const latest = h.latestSignalOccurredAt;
            return latest != null && latest > anchor;
          });
    if (usableHits.length === 0) {
      // 当前所有活信号都在冷静期内(= 刚结案那批,且无结案后新发)→ 不生成新 plan
      return 'suppressed';
    }

    const newPriorityScore = Math.max(...usableHits.map((h) => h.priorityScore));

    // head = 优先级最高的 hit(plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel /
    //   targetClinicId 兜底)。提前算,unchanged 分支也要用它做诊所归属。
    const head = [...usableHits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;

    // ⭐ 跟进诊所归属(2026-07 业务口径):运营按【患者最后一次到诊的诊所】做跟进分类,
    //   与医务侧的诊断诊所解耦 —— 客户最后去哪家,就归哪家客服跟进。
    //   (原口径 = 触发诊断 fact 所在诊所 head.targetClinicId,会把"主诊所患者顺路在别处拍片/被诊断"
    //    的人错分到那家只去过一次的诊所,业务反馈误导跟进。见 BJ0D046171 案例。)
    //   兜底:患者无任何到诊记录(理论罕见,如仅影像无接诊)→ 回退诊断诊所,不置空。
    const lastVisitClinicId = prefetched
      ? prefetched.lastVisitClinicId
      : await this.resolveLastVisitClinic(scope, patientId);
    const targetClinicId = lastVisitClinicId ?? head.targetClinicId ?? null;

    // 比对 active plan 的 reason 是否变化
    // ⭐ W3 末修:比对维度 = (scenario, subKey) 二元组集合,**不能只看 scenario**
    // 旧 bug:加新 SUB_SCENARIOS(K01/K07/K00 等)后,oldScenarios={treatment_initiation_recall}
    //        跟 newScenarios={treatment_initiation_recall} 相等 → 判 unchanged → 新 subKey 丢弃
    //        实际只刷了 priorityScore,plan_reasons 残留老的 1 条 missing_tooth
    const isActive = latest && (latest.status === 'active' || latest.status === 'assigned');
    if (isActive) {
      const key = (sc: string, sk: string | null | undefined) => `${sc}|${sk ?? ''}`;
      const oldSubScenarios = new Set(latest.reasons.map((r) => key(r.scenario, r.subKey)));
      const newSubScenarios = new Set(usableHits.map((h) => key(h.scenarioKey, h.subKey)));
      const sameSubScenarios =
        oldSubScenarios.size === newSubScenarios.size &&
        [...oldSubScenarios].every((k) => newSubScenarios.has(k));
      if (sameSubScenarios) {
        // reason 未变 → 不创新版本;但 priorityScore / 跟进诊所可能变(诊所重归属不算 reason 变更,
        //   不该升版本,就地改即可 —— 否则存量 plan 永远停在旧诊所,重算修不好)。
        const patch: Prisma.FollowupPlanUpdateInput = {};
        if (latest.priorityScore !== newPriorityScore) patch.priorityScore = newPriorityScore;
        // goal 是静态文案(不含天数)→ 直接比。算法改了措辞(如高龄缺牙 goal 改「活动义齿为主」)
        //   要能落到存量 plan,否则详情页「本次目标」永远是旧话。
        if ((latest.goal ?? null) !== (head.goal ?? null)) patch.goal = head.goal ?? null;
        // ⭐ 记账用:必须在 patch 生效**前**取,清空后 assignedAt 就没了(算不出持有时长)。
        //   null = 本次不涉及归属释放,不记账。
        let clinicMoveRelease: { assigneeUserId: string; heldSeconds: number | null } | null = null;
        if (latest.targetClinicId !== targetClinicId) {
          patch.targetClinicId = targetClinicId;
          // ⭐ 归属漂移 + 已认领 → 顺带返池:诊所隔离是硬边界,单子漂出原认领人 scope 后
          //   会变成谁都看不见的孤儿单(assigned 不进池、新店客服非 assignee、原客服出 scope)。
          //   业务语义也自洽:单子归新诊所客服跟进,原认领人不再持有。
          if (latest.status === 'assigned') {
            patch.status = 'active';
            patch.assigneeUserId = null;
            patch.assignedAt = null;
            patch.recycleAt = null;
            // ⚠️ 只清"在办期限",**不清 assignment_id / assigned_by / assign_strategy** ——
            //   那三列是批次归因(这单来自哪批、谁分的、当时怎么落的人),是历史事实。
            //   清了批次的分母就少一条,退回率、完成率一起失真。
            patch.assignmentExpiresAt = null;
            // ⭐ 这一步是**系统把单从客服手里收走**,必须留账。
            //   不记的后果:主管分了 50 单,引擎重算悄悄收回一部分,主管毫不知情;
            //   且这些单既没 release 也没 auto_release,退回率统计天生偏低。
            if (latest.assigneeUserId != null) {
              clinicMoveRelease = {
                assigneeUserId: latest.assigneeUserId,
                heldSeconds: computeHeldSeconds(latest.assignedAt, scope.now),
              };
            }
          }
          this.logger.log(
            `plan ${latest.id} 跟进诊所重归属 ${latest.targetClinicId ?? '∅'} → ${targetClinicId ?? '∅'}` +
              (latest.status === 'assigned' ? '(原 assigned → 返池)' : ''),
          );
        }
        // ⭐ reason 就地刷新(2026-07):(scenario, subKey) 没变、但 **signals 内容变了** 的算法升级
        //   ——典型是缺牙按年龄排治疗(signals 新增 focusCategory / patientAge)——
        //   原先对存量 plan 永远不生效(生产 3,170 条高龄缺牙仍显示「目标·种植」)。
        //   这里就地改:**不升版本、不动认领/状态/抑制窗/触达计数**。
        //   判据见 reason-refresh.ts:只比 signals(去 daysSince)+ evidence,
        //   否则天数天天变会让每日重算把全部 plan_reasons 重写一遍。
        const hitByKey = new Map(usableHits.map((h) => [key(h.scenarioKey, h.subKey), h]));
        const staleRows = latest.reasons.filter((r) => {
          // 已关闭的 reason 是历史,不再刷新(也不会被"复活",closedReason 本就不在更新字段里)
          if (r.closedReason != null) return false;
          const h = hitByKey.get(key(r.scenario, r.subKey));
          if (h == null) return false;
          return reasonNeedsRefresh(
            { signals: r.signals, evidence: r.evidence },
            { signals: h.signals ?? null, evidence: { factIds: h.evidence.factIds } },
          );
        });

        if (Object.keys(patch).length > 0 || staleRows.length > 0) {
          await this.prisma.$transaction(async (tx) => {
            if (Object.keys(patch).length > 0) {
              await tx.followupPlan.update({ where: { id: latest.id }, data: patch });
            }
            // 归属释放与状态变更同生共死(recordPlanEvent 只收 tx 就是为了这个)
            if (clinicMoveRelease) {
              await recordPlanEvent(tx, {
                hostId: latest.hostId,
                tenantId: latest.tenantId,
                planId: latest.id,
                patientId: latest.patientId,
                event: PlanEventType.AUTO_RELEASE,
                assigneeUserId: null,
                actorUserId: null, // 系统行为
                heldSeconds: clinicMoveRelease.heldSeconds,
                reason: PlanEventReason.CLINIC_MOVED,
              });
            }
            for (const r of staleRows) {
              const h = hitByKey.get(key(r.scenario, r.subKey))!;
              await tx.planReason.update({
                where: { id: r.id },
                // 语义已变 → 整行重写(天数等易变字段顺带刷新,反正这次要写)。
                // ⚠️ 刻意不动:closedReason / closedAt(关闭状态)、source / sourceActorId /
                //    campaignId(来源溯源)、lifecycle(由 scenario+subKey 决定,未变)。
                data: {
                  reason: h.reason,
                  priorityScore: h.priorityScore,
                  signals: h.signals
                    ? (h.signals as unknown as Prisma.InputJsonValue)
                    : Prisma.JsonNull,
                  evidence: { factIds: h.evidence.factIds } as Prisma.InputJsonValue,
                  breakdown: h.priorityBreakdown
                    ? ({
                        priority: { ...h.priorityBreakdown },
                        subKey: h.subKey ?? null,
                      } as Prisma.InputJsonValue)
                    : Prisma.JsonNull,
                },
              });
            }
          });
          if (staleRows.length > 0) {
            this.logger.log(
              `plan ${latest.id} reason 就地刷新 ${staleRows.length} 条(signals 语义变化,不升版本)`,
            );
          }
        }
        return 'unchanged';
      }
    }

    // 需要新版本:supersede 旧 + 创建新
    const nextVersion = (latest?.version ?? 0) + 1;
    // ⭐ 若旧版是 assigned:新版本继承分配(客服不丢单、不被抢;只是理由换到最新)。
    //   contactAttempts 一并带过去 → 频控/熔断计数延续(执行明细行仍挂旧版本,符合版本流语义)。
    //   例外:跟进诊所同时发生了重归属 → 不继承、新版本返池(与 unchanged 分支同规则:
    //   带着 assignment 漂出原认领人 scope 会变孤儿单,归新诊所客服重新认领)。
    const clinicMoved = latest != null && latest.targetClinicId !== targetClinicId;
    const carryAssignment = latest?.status === 'assigned' && !clinicMoved;

    /**
     * ⭐ **批次归因继承的边界:看客服碰过没,不看召回理由变没变**(产品裁决 2026-08-02)。
     *
     * ── 为什么不是「理由变了就不继承」(那是我先想错的一版)──────────────
     * 拿"召回理由整组换掉"当判据,两个方向都会错:
     *   ① 召回一会儿出现一会儿不出现,**很可能正是客服处理过**造成的(患者来了、治了一部分、
     *      又诊断出新问题)。按理由判 → 该结账的反而"理由变了"被当成新工单继承走。
     *   ② 同一种诊断**再次出现,也不代表客服没处理过**。按理由判 → 同样该结账的却继承了。
     * 理由的变化根本不携带"客服做没做事"的信息 —— 它只反映患者的临床状态在动。
     *
     * ── 正解 ────────────────────────────────────────────────────
     *   客服**没碰过** → 这单还欠着 → **继承**(理由怎么变都继承:批次分的是**人**,
     *                      主管要知道的是"这 100 个人有多少被联系了")
     *   客服**碰过了** → 批次这一条的账已经落定 → **不继承**,后面再冒出来的是新工单
     *
     * ⚠️ 「碰过没」不新造判据,**直接复用 T21 / D-11 那一套**(撤销整批时判"哪些单不能收"):
     *   以 `view` 事件为主 —— 生产实测 view 有 1,024 条,而 `plan_executions` 只有 7 条
     *   (回写率 11%),拿执行记录判会把 89% 已经打过电话的单当成"没碰过"。
     *   另外三个是行上现成的强信号(写了回访结果 / 退回过 / 提交过执行),顺手一起认。
     *
     * ⭐ 与跟踪口径(T20′)咬合:不继承时旧版本仍带着 assignment_id 留在批次里、且已 superseded
     *   → 跟踪按「按患者取最新版」拿到的就是那条旧行,它身上带着当时的处置
     *   (releaseReason / 已 view 等),批次的分子分母都不丢。新工单干净回池,等下一个批次。
     */
    const carryAttribution = latest != null && !(await this.agentTouched(latest, prefetched));
    if (latest?.assignmentId && !carryAttribution) {
      this.logger.log(
        `plan ${latest.id} 升版本,但客服已碰过该单 → 批次归因**不继承**` +
          `(批次 ${latest.assignmentId.slice(0, 8)} 的这一条账已落定,新工单回池)`,
      );
    }
    if (latest?.status === 'assigned' && clinicMoved) {
      this.logger.log(
        `plan ${latest.id} 升版本且跟进诊所重归属 ${latest.targetClinicId ?? '∅'} → ${targetClinicId ?? '∅'},assigned 不继承(返池)`,
      );
    }
    // head / targetClinicId 已在上方(unchanged 分支前)算好,此处直接用。

    // 拉对应 patient 的最新 active persona(可空);批量路径用预取值,单刷照旧查
    const personaId = prefetched
      ? prefetched.personaId
      : (
          await this.prisma.persona.findFirst({
            where: { patientId, supersededAt: null },
            orderBy: { version: 'desc' },
            select: { id: true },
          })
        )?.id ?? null;

    // ⭐ 升版本时归属**没被继承** → 客服手上的单在这一刻没了,必须留账。
    //   ⚠️ 判定必须在事务**之前**定好,不能进了事务再读 latest.status ——
    //     supersede 那句 update 之后 latest 已经是 'superseded',条件当场失效。
    //     (真 Prisma 返回的是脱离副本看不出来,内存 mock 直接暴露了这个别名陷阱。)
    //   ⚠️ planId 记**旧版本**的 id:归属本来就挂在那一版上,新版本是空归属的另一条记录,
    //     记到新版会让"这个客服持有过哪些单"的回溯全部对不上。
    //   触发条件目前只有一种(carryAssignment=false 且旧版 assigned):诊所重归属。
    //   若将来加了别的不继承情形,reason 要一并加进 PlanEventReason,别复用 clinic_moved。
    const supersedeRelease =
      latest != null && latest.status === 'assigned' && !carryAssignment && latest.assigneeUserId != null
        ? {
            planId: latest.id,
            patientId: latest.patientId,
            hostId: latest.hostId,
            tenantId: latest.tenantId,
            heldSeconds: computeHeldSeconds(latest.assignedAt, scope.now),
          }
        : null;

    await this.prisma.$transaction(async (tx) => {
      if (latest && (latest.status === 'active' || latest.status === 'assigned')) {
        await tx.followupPlan.update({
          where: { id: latest.id },
          data: { status: 'superseded', supersededAt: new Date() },
        });
        if (supersedeRelease) {
          await recordPlanEvent(tx, {
            ...supersedeRelease,
            event: PlanEventType.AUTO_RELEASE,
            assigneeUserId: null,
            actorUserId: null, // 系统行为
            reason: PlanEventReason.CLINIC_MOVED,
          });
        }
      }
      await tx.followupPlan.create({
        data: {
          hostId: scope.hostId,
          tenantId: scope.tenantId,
          patientId,
          personaId: personaId ?? null,
          targetClinicId,
          version: nextVersion,
          priorityScore: newPriorityScore,
          goal: head.goal ?? null,
          recommendedAt: head.recommendedAt ?? null,
          recommendedRole: head.recommendedRole ?? null,
          recommendedChannel: head.recommendedChannel ?? null,
          // assigned 继承:新版本保持 assigned + 原客服 + 原回收窗 + 触达计数;否则回池(active)
          status: carryAssignment ? 'assigned' : 'active',
          assigneeUserId: carryAssignment ? latest!.assigneeUserId : null,
          assignedAt: carryAssignment ? latest!.assignedAt : null,
          recycleAt: carryAssignment ? latest!.recycleAt : null,
          contactAttempts: carryAssignment ? latest!.contactAttempts : 0,

          // ── 分配归因:与 carryAssignment **解耦**,无条件继承 ─────────────────
          // ⚠️⚠️ 这是最容易写错、且写错**不会报错**的一处。
          //   直觉写法是把这几列也挂到 carryAssignment 上,但那个标志只在
          //   `latest.status === 'assigned'` 时为真 —— 而**退回后的 plan 是 `status='active'`**
          //   (见 PlanService.recycle)。于是每一条被退回过的单,只要引擎升一次版本,
          //   它的批次归因就连**分子带分母**一起静默归零:
          //     批次跟踪看到的是"分了 55 条"(实际 60),退回率的分母凭空缩水,
          //     而 T20 的全部结论都建立在这个分母上。
          //   归因是**历史事实**(这条单子来自哪个批次、谁分的、当时按什么策略落的人),
          //   跟"现在还挂不挂在人名下"是两件事,不该被后者控制。
          //   ⭐ 但「无条件」有一个边界(2026-08 裁决):**召回理由整组换掉时不继承** ——
          //     那已经是一张全新工单了。见上方 carryAttribution 的整段说明。
          //     ⚠️ 判据是 carryAttribution,**不是** carryAssignment,两者别混:
          //     前者问"还是同一张工单吗",后者问"还挂在同一个人名下吗"。
          assignmentId: carryAttribution ? latest!.assignmentId : null,
          assignedBy: carryAttribution ? latest!.assignedBy : null,
          assignStrategy: carryAttribution ? latest!.assignStrategy : null,
          releaseReason: carryAttribution ? latest!.releaseReason : null,
          releaseNote: carryAttribution ? latest!.releaseNote : null,
          // ⚠️⚠️ 决策快照五列同样**无条件继承**,而且漏了比归因列更隐蔽:
          //   引擎重出版本是**数据变化触发**的 → 与患者活跃度相关 → 与完成率相关。
          //   所以漏继承丢掉的不是随机的一批,是**系统性偏向活跃患者**的一批 ——
          //   半年后拿到一列 70% 填充率的快照,看着还能用,算出来的结论是错的。
          //   ⭐ 今后**每加一列快照,这里必须同步加一行**(spec 有断言锁死)。
          //   ⚠️ 快照列与 assignment_id **同生共死**,用同一个 carryAttribution 门控:
          //     留下没有批次的孤儿快照(有 selectionMode='explore' 却查不到批次)会直接
          //     污染 T20 的因果分析 —— 探索配额那组样本本来就小,混进无主行就废了。
          dedicatedCsAtAssign: carryAttribution ? latest!.dedicatedCsAtAssign : null,
          dedicatedCsLastVisitAt: carryAttribution ? latest!.dedicatedCsLastVisitAt : null,
          priorityScoreAtAssign: carryAttribution ? latest!.priorityScoreAtAssign : null,
          sourceConfidenceAtAssign: carryAttribution ? latest!.sourceConfidenceAtAssign : null,
          selectionMode: carryAttribution ? latest!.selectionMode : null,
          // ⚠️ 唯独 assignmentExpiresAt **跟随归属**,不无条件继承:
          //   它不是归因,是"当前这次分配的截止时刻"。归属都没了还留着期限,
          //   会让列自身失去自洽(非空 ⟺ 有一次在办的分配),超期口径全得靠调用方
          //   每次记得再 AND 一个 status='assigned' 才不出错 —— 那种隐式约定迟早破。
          assignmentExpiresAt: carryAssignment ? latest!.assignmentExpiresAt : null,
          reasons: {
            // W3 末改:plan_reasons 维度 = (plan, scenario, sub_key) — 每独立临床缺口一行,
            // 不再合并子规则(K08 缺牙 / K05 牙周 / K04 根管 是不同治疗体系,合并 reason
            // 长且生硬、evidence 粒度丢)。话术 / 摘要 / 执行回写仍 plan 级(展示细 / 触达粗)。
            // UNIQUE(plan_id, scenario, sub_key)约束保证同子规则不重复。
            createMany: {
              data: usableHits.map((h) => ({
                scenario: h.scenarioKey,
                subKey: h.subKey ?? null,
                priorityScore: h.priorityScore,
                reason: h.reason,
                evidence: {
                  factIds: h.evidence.factIds,
                } as Prisma.InputJsonValue,
                breakdown: h.priorityBreakdown
                  ? ({
                      priority: { ...h.priorityBreakdown },
                      subKey: h.subKey ?? null,
                    } as Prisma.InputJsonValue)
                  : Prisma.JsonNull,
                // 结构化召回信号(DB 存 raw enum,前端字典翻译富文本渲染)
                signals: h.signals
                  ? (h.signals as unknown as Prisma.InputJsonValue)
                  : Prisma.JsonNull,
                lifecycle: this.lifecycleFor(h.scenarioKey, h.subKey),
                source: 'algorithm',
              })),
            },
          },
        },
      });
    }, { maxWait: 30000, timeout: 60000 });

    return latest ? 'superseded' : 'created';
  }

  /**
   * 信号级抑制集(带时间锚)—— 该患者所有"终态(completed/abandoned)+ 冷静期未到期
   * (snoozedUntil>now)"plan 覆盖的召回理由 (scenario, subKey) → **结案锚点** Map。
   *
   * 语义:这些信号最近被召回处理过(成功转化/拒绝/外院/熔断…),在冷静期内不重复召回;
   *      但**不在此集合里的全新诊断不受影响**,照常生成召回(这是信号级抑制的核心,跟召回算法同粒度)。
   * ⭐ 时间锚(2026-07 修):同 key 也不一刀切 —— 结案锚点之后**新发**的同类信号(hit 的
   *   latestSignalOccurredAt > anchor)放行复活。修复的坑:外院/无效的"永久"snooze 会把
   *   结案后多年新发的同牙位/@whole 同类诊断也永久压死(粒度只到 key,无时间维度)。
   * 多次结案累积正确:取全部未到期终态 plan 的并集;同 key 多次结案取**最新**锚点。
   * key 格式 `${scenario}|${subKey ?? ''}`,跟 upsertPlan 过滤口径一致。
   */
  private async fetchSnoozedSignalKeys(
    scope: ScenarioScope,
    patientId: string,
    now: Date,
  ): Promise<Map<string, Date>> {
    const terminalPlans = await this.prisma.followupPlan.findMany({
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        patientId,
        status: { in: ['completed', 'abandoned'] },
        snoozedUntil: { gt: now },
      },
      select: {
        updatedAt: true,
        reasons: { select: { scenario: true, subKey: true } },
        // 结案锚点 = 结案那次 execution 的 createdAt(事件表,不可变)。
        // ⚠️ 不能用 plan.updatedAt 当首选锚:召回反馈(👍/👎)等后续写操作会顶后 updatedAt,
        //    把"结案后、反馈前"新发的信号误压。updatedAt 仅作无 execution 时的兜底(理论不发生)。
        executions: {
          orderBy: { createdAt: 'desc' },
          take: 1,
          select: { createdAt: true },
        },
      },
    });
    return buildSnoozeAnchors(terminalPlans);
  }

  /**
   * 批量预取 —— runAllForHost 循环前一次性把每患者的 latest plan(含 reasons)/ snooze 抑制集 /
   * active persona id 取好,避免循环内每人发 3 条读查询(等价,仅省往返)。
   * 取数口径与 upsertPlan(findFirst orderBy version desc)/ fetchSnoozedSignalKeys(终态+未到期)
   * 严格对齐;IN 列表分块防参数超限。
   */
  private async prefetchForBatch(
    scope: ScenarioScope,
    patientIds: string[],
    now: Date,
  ): Promise<{
    latestByPatient: Map<string, PlanWithReasons>;
    snoozedByPatient: Map<string, Map<string, Date>>;
    personaByPatient: Map<string, string>;
    lastVisitClinicByPatient: Map<string, string>;
    /// 「客服碰过」的 planId 集合 —— ⚠️ **只覆盖带 assignment_id 的单**:
    /// 没进过批次的单本来就没有归因可继承,为它们查 view 事件是白扫全表。
    touchedPlanIds: Set<string>;
  }> {
    const latestByPatient = new Map<string, PlanWithReasons>();
    const snoozedByPatient = new Map<string, Map<string, Date>>();
    const personaByPatient = new Map<string, string>();
    const lastVisitClinicByPatient = new Map<string, string>();
    const CHUNK = 2000;
    for (let i = 0; i < patientIds.length; i += CHUNK) {
      const ids = patientIds.slice(i, i + CHUNK);
      // latest plan(含 reasons)— 每患者最高 version 那条(对齐 upsertPlan 的 orderBy version desc)
      const plans = await this.prisma.followupPlan.findMany({
        where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: { in: ids } },
        include: { reasons: true },
        orderBy: [{ patientId: 'asc' }, { version: 'desc' }],
      });
      for (const p of plans) {
        if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p);
      }
      // snooze 抑制集(对齐 fetchSnoozedSignalKeys:终态 + 冷静期未到期 plan 的 reason → 结案锚点)
      const terminal = await this.prisma.followupPlan.findMany({
        where: {
          hostId: scope.hostId,
          tenantId: scope.tenantId,
          patientId: { in: ids },
          status: { in: ['completed', 'abandoned'] },
          snoozedUntil: { gt: now },
        },
        select: {
          patientId: true,
          updatedAt: true,
          reasons: { select: { scenario: true, subKey: true } },
          // 结案锚点 = 结案 execution.createdAt(不可变;updatedAt 会被召回反馈等后续写顶后,仅兜底)
          executions: { orderBy: { createdAt: 'desc' }, take: 1, select: { createdAt: true } },
        },
      });
      {
        const plansByPatient = new Map<string, typeof terminal>();
        for (const t of terminal) {
          if (!t.patientId) continue;
          const arr = plansByPatient.get(t.patientId) ?? [];
          arr.push(t);
          plansByPatient.set(t.patientId, arr);
        }
        for (const [pid, plans] of plansByPatient) {
          snoozedByPatient.set(pid, buildSnoozeAnchors(plans));
        }
      }
      // active persona id(每患最新 active 版本)
      const personas = await this.prisma.persona.findMany({
        where: { patientId: { in: ids }, supersededAt: null },
        orderBy: [{ patientId: 'asc' }, { version: 'desc' }],
        select: { id: true, patientId: true },
      });
      for (const pe of personas) {
        if (pe.patientId && !personaByPatient.has(pe.patientId)) {
          personaByPatient.set(pe.patientId, pe.id);
        }
      }
      // 每患最后一次到诊(encounter/emr)所在诊所 → 跟进归属(见 upsertPlan 说明)。
      //   DISTINCT ON 取 occurred_at 最新那条;跟"就诊冷静期"用同一 type 口径,语义一致。
      const visits = await this.prisma.$queryRaw<
        Array<{ patient_id: string; clinic_id: string }>
      >`
        SELECT DISTINCT ON (patient_id) patient_id, clinic_id
        FROM patient_facts
        WHERE host_id = ${scope.hostId}::uuid
          AND tenant_id = ${scope.tenantId}
          AND patient_id = ANY(${ids}::uuid[])
          AND type IN ('encounter_record', 'emr_record')
          AND occurred_at IS NOT NULL
          AND clinic_id IS NOT NULL AND clinic_id <> ''
          AND superseded_at IS NULL
        ORDER BY patient_id, occurred_at DESC
      `;
      for (const v of visits) lastVisitClinicByPatient.set(v.patient_id, v.clinic_id);
    }
    // ⭐ 一次查完「客服碰过哪些单」—— 只问带 assignment_id 的那些(通常是很小的子集),
    //   否则 runAllForHost 全量会为 44 万患者各查一次 view 事件。
    //   判据同教条 T21 / 契约 D-11(撤销整批判"哪些不能收"),两处必须是同一套。
    const batchPlanIds = [...latestByPatient.values()]
      .filter((p) => p.assignmentId != null)
      .map((p) => p.id);
    const touchedPlanIds = new Set<string>();
    for (let i = 0; i < batchPlanIds.length; i += 1000) {
      const rows = await this.prisma.planEventLog.groupBy({
        by: ['planId'],
        where: {
          planId: { in: batchPlanIds.slice(i, i + 1000) },
          tenantId: scope.tenantId,
          event: PlanEventType.VIEW,
        },
      });
      for (const r of rows) touchedPlanIds.add(r.planId);
    }

    return {
      latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient, touchedPlanIds,
    };
  }

  /// 单刷路径:解析该患者最后一次到诊(encounter/emr)所在诊所(批量路径走 prefetchForBatch)。
  private async resolveLastVisitClinic(
    scope: ScenarioScope,
    patientId: string,
  ): Promise<string | null> {
    const rows = await this.prisma.$queryRaw<Array<{ clinic_id: string }>>`
      SELECT clinic_id
      FROM patient_facts
      WHERE host_id = ${scope.hostId}::uuid
        AND tenant_id = ${scope.tenantId}
        AND patient_id = ${patientId}::uuid
        AND type IN ('encounter_record', 'emr_record')
        AND occurred_at IS NOT NULL
        AND clinic_id IS NOT NULL AND clinic_id <> ''
        AND superseded_at IS NULL
      ORDER BY occurred_at DESC
      LIMIT 1
    `;
    return rows[0]?.clinic_id ?? null;
  }

  /// 子场景 → 生命周期类型(one_shot 短效 / recurring 长效)
  private lifecycleFor(scenario: string, subKey?: string): string {
    // 复查类长效(种植年度 / 牙周维护),其余短效
    if (subKey === 'implant_annual' || subKey === 'perio_maintenance') return 'recurring';
    return 'one_shot';
  }
}

type ScenarioHitWithKey = ScenarioHit & { scenarioKey: string };
/// 批量预取/upsert 用:plan 含 reasons(对齐 upsertPlan 的 include:{reasons:true})
type PlanWithReasons = Prisma.FollowupPlanGetPayload<{ include: { reasons: true } }>;

/**
 * 「这单客服碰过没」—— 批次归因该不该继续继承,只看这一件事。
 *
 * ⚠️ 判据与「撤销整批时哪些单不能收」(教条 T21 / 契约 D-11)**是同一套**,
 *   刻意不另造:同一个业务问题在两处用两个判据,迟早对不上,而且没人会发现。
 *
 * ⭐ 主判据是 `view` 事件(客服打开过详情页)。⛔ 别改成只看 `plan_executions` ——
 *   生产实测回写率仅 11%(65 个认领单只有 7 条执行结果),那样会把 89% **已经打过电话**
 *   的单判成"没碰过",于是它们的归因被一直继承下去,批次的账永远结不掉。
 *
 * 行上另外三个信号是顺手认的强证据(有就一定碰过,没有不代表没碰过):
 *   snoozedUntil  写了回访结果(约下次 / 拒绝 / 放弃)
 *   releaseReason 退回过 —— 退回也是一种处置,账同样落定了
 *   contactAttempts 提交过执行(与 plan_executions 同源,弱但免费)
 */
export function planTouchedByAgent(
  row: { snoozedUntil: Date | null; releaseReason: string | null; contactAttempts: number },
  hasViewEvent: boolean,
): boolean {
  return (
    hasViewEvent ||
    row.snoozedUntil != null ||
    row.releaseReason != null ||
    row.contactAttempts > 0
  );
}

export interface EngineRunResult {
  scenariosRun: number;
  patientsHit: number;
  plansCreated: number;
  plansSuperseded: number;
  plansUnchanged: number;
  plansSkippedAssigned: number;
  plansSuppressed: number;
  plansClosed: number;
  durationMs: number;
}

/**
 * 终态 plan → (scenario|subKey) → 结案锚点 Map(纯函数,fetchSnoozedSignalKeys / prefetchForBatch 共用)。
 * 锚点 = 结案 execution.createdAt(事件表,不可变);无 execution(理论不发生)兜底 plan.updatedAt。
 * 同 key 多次结案取**最新**锚 —— 最近一次人为处置才是"此后新发才算新机会"的分界线。
 */
export function buildSnoozeAnchors(
  terminalPlans: Array<{
    updatedAt?: Date;
    reasons: Array<{ scenario: string; subKey: string | null }>;
    executions?: Array<{ createdAt: Date }>;
  }>,
): Map<string, Date> {
  // 锚点解析不到(旧数据/未 select 关联)→ 远未来哨兵:latest > anchor 永不成立 = 永不逃逸,
  // 严格等价旧的"key 命中即压"行为(宁可多压,不误放)。
  const FAR_FUTURE = new Date(8640000000000000);
  const anchors = new Map<string, Date>();
  for (const plan of terminalPlans) {
    const anchor = plan.executions?.[0]?.createdAt ?? plan.updatedAt ?? FAR_FUTURE;
    for (const r of plan.reasons) {
      const key = `${r.scenario}|${r.subKey ?? ''}`;
      const prev = anchors.get(key);
      if (!prev || anchor > prev) anchors.set(key, anchor);
    }
  }
  return anchors;
}
