import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  ABANDON_REASON_META,
  AssignStrategy,
  type AbandonReason,
  ASSIGNMENT_ITEMS_HARD_LIMIT,
  EXECUTION_OUTCOME_META,
  Permission,
  PlanEventReason,
  PlanEventType,
  type AgentWorkloadResponse,
  RELEASE_REASON_META,
  REVOKE_WINDOW_MINUTES,
  TEMPERATURE_META,
  potentialTreatmentCardLabel,
  type TemperatureValue,
  type AssignmentAgentStat,
  type AssignmentDetailResponse,
  type AssignmentSkipped,
  type CreateAssignmentRequest,
  type CreateAssignmentResponse,
  type ListAssignmentsResponse,
  type ExecutionOutcome,
  type ReleaseReason,
  type RevokeAssignmentResponse,
  type SetAssignmentBenefitResponse,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { AgentRosterService } from './agent-roster.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { recordPlanEventsBulk, computeHeldSeconds } from './plan-event.recorder';
import { assertAssignable } from './claim-guard';
import { requirePermission, rejectSyntheticIdentity, type DispatchActor } from './dispatch-guard';
import { timezoneToOffsetSuffix } from '../sync/assembler/field-mapper';

/**
 * PlanAssignmentService —— 门诊经理的**批次分配**写路径。
 *
 * 与 `PlanService.assign`(单条认领/指派)的分工:
 *   · PlanService.assign  —— 一次一条,认领语义,staff 也能调
 *   · 本服务              —— 一次几百条,批次语义,只有 PLAN_DISPATCH 能调
 *
 * ⛔ **不能靠循环调 PlanService.assign 实现**:那里每条自带一次 `findFirst` + 一个独立
 *   `$transaction`,500 条 ≈ 1500 次往返必炸;而且它写不了 assignment_id 等归因列。
 * ⛔ 反过来也不行(让单条 assign 委托批量):那会给每一次自助认领造一条垃圾批次行。
 *   两条路共用的只有判据 —— 收口在 `claim-guard.assertAssignable` 一个函数里。
 */

/** 一次 updateMany 的分片大小。PG bind 变量上限 32767,本表每行约 7 个变量 → 3500 才到顶,500 很安全。 */
const UPDATE_CHUNK = 500;

/// 已退回的行归到这个假 userId 下暂存 —— 退回后 assignee 已清空,摊到某个真人头上会摊错。
const RELEASED_BUCKET = '__released__';

/**
 * 「通话记录」一次最多回多少条。
 * ⚠️ 一批能有两三百人,而这块是**抽屉里的明细**不是导出 —— 200 条已经远超一屏能看的量。
 * ⛔ 超了必须置 `recordsTruncated`,不许静默截断(主管会以为"就这些")。
 */
const OUTCOME_RECORD_LIMIT = 200;

/// 批次福利文本(attributes.benefit.text)。⚠️ 别在多处 `as any` 解 JSON,解错了不会报错
/**
 * 「这一单处理了没」—— 判据只看**池子状态**,不看客服写没写回访结果。
 *
 * ═══ 为什么是这个口径(产品裁决 2026-08-02)═════════════════════════
 * 「完成」不需要等成功与否,只需要知道**这个患者的召回还在不在池、有没有被抑制** ——
 * 两者任一成立就说明这单已经被处理过了。
 *
 * ⭐ 它最大的好处是**不依赖回写**:生产 `plan_executions` 回写率仅 11%,
 *   拿它做完成判据等于 89% 的活白干。而池子状态是引擎按**客观事实**(patient_facts)
 *   算出来的,客服写不写都成立。
 *
 * ⭐ 两条判据是**互补**不是重复:
 *   · 出池(superseded)—— 引擎发现该患者已无活信号 = 治疗真的做了 / 诊断没了。**零回写依赖**。
 *   · 被抑制(snoozedUntil 在未来)—— 客服写了回访结果(约下次 / 明确拒绝 / 放弃)。**有回写才有**。
 *   前者接住不写回访的 89%,后者接住写了的 11%,并集才是完整的「处理过」。
 *
 * ⚠️ **不判成功与否**(产品明确「成功不成功先不用统计」)。
 *   之所以现在可以不判、以后又不会抓瞎:`plan_event_logs` 记了出池原因,
 *   `patient_facts` 是 append-only 的客观事实 —— 任何时候都能回过头重算
 *   「这批人后来到底做没做治疗」。**不是放弃了这个数据,是它不需要现在算**。
 */
export type PlanProgress = 'resolved' | 'suppressed' | 'closed' | 'inHandPending' | 'backToPool';

export function classifyPlanProgress(
  p: {
    status: string;
    assigneeUserId: string | null;
    snoozedUntil: Date | null;
  },
  now: Date,
): PlanProgress {
  // ⚠️ 顺序即优先级,别按"好看"重排:
  //   终态先判(superseded 是引擎关闭,不是"还在手上");
  //   抑制**优先于在手** —— 客服打了电话约了下次,单子还挂在他名下,但这一单确实已经处理过了。
  if (p.status === 'superseded') return 'resolved';
  if (p.status === 'completed' || p.status === 'abandoned') return 'closed';
  if (p.snoozedUntil && p.snoozedUntil > now) return 'suppressed';
  if (p.status === 'assigned') return 'inHandPending';
  // 剩下的都是「躺回池子里」:退回的、到期收回的、撤销放回的。
  // ⛔ 兜底也归这里而不是静默丢弃 —— 五个桶必须**穷尽**,否则 done+pending 加不出 planned,
  //   主管一对数就发现少了人,而少的那几个永远查不出去哪了。
  return 'backToPool';
}

/**
 * 合并两个数据源:**历史事实走账本,当前状态走 followup_plans**。
 *
 * ⚠️ `ledger.planned === 0` 是「老批次」的判据 —— 本列上线前的账本没有批次号。
 *   真实批次的 planned 不可能是 0(create 在 applied.length===0 时直接回滚,不留空批次),
 *   所以这个判据是安全的。
 * ⚠️ 老批次回落到 followup_plans 现算:那个口径**会随重分失血**(偏小),
 *   但显示一个偏小的数,总好过显示 0 让主管以为这批根本没分成。
 *   ⛔ 别为此去猜测补数 —— 猜出来的分母比缺失更难发现。
 */
function mergeStats(
  live: { planned: number; agents: number; released: number } | undefined,
  ledger: { planned: number; agents: number; released: number; expired: number; revoked: number } | undefined,
): { planned: number; agents: number; released: number; expired: number } {
  const l = live ?? { planned: 0, agents: 0, released: 0 };
  const legacy = !ledger || ledger.planned === 0;
  return {
    planned: legacy ? l.planned : ledger.planned,
    agents: legacy ? l.agents : ledger.agents,
    released: legacy ? l.released : ledger.released,
    /**
     * ⚠️ 超期**不跟着 legacy 回落**（2026-08-19）：它现在只有一个来源 ——
     *   **此刻仍挂在人手上且已过时限**（现算），而那一支对新老批次一样成立。
     *   ⛔ 别让它跟着 `legacy` 归零：老批次照样可能有单还压在人手上。
     */
    expired: ledger?.expired ?? 0,
  };
}

/**
 * 读批次福利文案。⚠️ 仓库里**曾有两份**(本处 + plan-script.orchestrator,返回类型还不一样)。
 * 2026-08-05 召回简报也要用,故本份导出复用 —— ⛔ 别再抄第四份。
 */
export function readBenefitText(attributes: unknown): string | null {
  const a = attributes as { benefit?: { text?: string } } | null | undefined;
  const t = a?.benefit?.text;
  return typeof t === 'string' && t.trim() ? t : null;
}

@Injectable()
export class PlanAssignmentService {
  private readonly logger = new Logger(PlanAssignmentService.name);

  constructor(
    private readonly prisma: PrismaService,
    /// ⛔ 名册只有这一个来源(T10)—— 跟踪与分配读同一份,别另查
    private readonly roster: AgentRosterService,
  ) {}

  /**
   * 主管确认 → 批量落归属。**这是整条生产线上唯一的写动作**(T8)。
   *
   * 事务内五步,顺序不能换:
   *   ① 三道闸(权限自查 / 拒合成身份 / scope 绑定)
   *   ② 按 patientId 去重
   *   ③ 建批次头
   *   ④ 按客服分组的**带条件** updateMany(并发安全)
   *   ⑤ 一次性写归属账本
   */
  async create(
    scope: TenantScopeContext,
    actor: DispatchActor,
    dto: CreateAssignmentRequest,
  ): Promise<CreateAssignmentResponse> {
    // ── 闸 1 / 2:身份 ────────────────────────────────────────
    // 与 controller 上的 @RequirePermission 重复是有意的,见 dispatch-guard 文件头
    requirePermission(actor, Permission.PLAN_DISPATCH);
    rejectSyntheticIdentity(actor);

    // ── 幂等前置:同 requestId 已有批次 → 原样返回,一个字都不写 ──
    // 放在最前面而不是靠捕获唯一键冲突,是为了让**重放不产生任何副作用**
    // (真到了冲突那一步,前面的读查询已经发生了)。冲突兜底仍保留,见下方 catch。
    const existing = await this.findByRequestId(scope, dto.requestId);
    if (existing) return existing;

    if (dto.items.length > ASSIGNMENT_ITEMS_HARD_LIMIT) {
      throw new BadRequestException(`单次分配最多 ${ASSIGNMENT_ITEMS_HARD_LIMIT} 条`);
    }

    // ── 闸 3:scope 绑定 ─────────────────────────────────────
    // 诊所必须在本人可达范围内。空 clinicIds = 集团级,不限。
    if (scope.clinicIds.length > 0 && !scope.clinicIds.includes(dto.clinicId)) {
      throw new NotFoundException(`诊所 ${dto.clinicId} 不在你的数据范围内`);
    }

    // 同一 planId 在入参里出现两次 → 前端/模型的问题,直接按第一条算,不报错
    const byPlanId = new Map(dto.items.map((it) => [it.planId, it]));
    const planIds = [...byPlanId.keys()];

    // ⚠️ 时区**在事务外查一次**就够了。放进下面按条计算的循环里会变成每条一次查询
    //   (500 条 = 500 次往返,且全在事务里持锁)。
    const tz = await this.hostTimezone(scope.hostId);
    const expiresAt = endOfDayInHostTimezone(new Date(), dto.expiresInDays, tz);

    const rows = await this.prisma.followupPlan.findMany({
      where: {
        id: { in: planIds },
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        // 批次不跨诊所:只认本批指定诊所的单
        targetClinicId: dto.clinicId,
        supersededAt: null,
        ...(scope.sourceUnits.length
          ? { patient: { sourceUnit: { in: scope.sourceUnits } } }
          : {}),
      },
      select: {
        id: true, patientId: true, status: true,
        assigneeUserId: true, priorityScore: true,
      },
    });

    // ⭐ **数量对不上整体拒绝**,不是"能落几条落几条"。
    //   对不上只有两种可能:主管的确认单已经过期(单子被引擎重算 supersede 了),
    //   或者有人在试跨诊所越权。两种都不该让它落一半 —— 落一半的批次事后
    //   既解释不清也回不去,而主管看到的"成功 87/100"完全无从判断哪 13 条为什么没落。
    if (rows.length !== planIds.length) {
      const found = new Set(rows.map((r) => r.id));
      const missing = planIds.filter((id) => !found.has(id));
      throw new BadRequestException(
        `有 ${missing.length} 条计划不在可分配范围内(可能已被重算取代或不属于本诊所),` +
          `本次分配未执行。请让助手重新出确认单。`,
      );
    }

    // ── 去重 + 可分性判定 ────────────────────────────────────
    const skipped: AssignmentSkipped[] = [];

    // ⚠️ 按 patientId 去重:schema.prisma 注释里承诺的
    //   「一患者只一条活动 plan」partial UNIQUE **在 38 份迁移里根本不存在**(已逐份 grep)。
    //   不能当既有保障用,批量侧必须自己去重 —— 否则同一个人会被分两次,
    //   客服打开两条一模一样的单。同患者多条时留 priorityScore 最高的那条。
    const bestByPatient = new Map<string, (typeof rows)[number]>();
    for (const r of [...rows].sort((a, b) => b.priorityScore - a.priorityScore)) {
      if (bestByPatient.has(r.patientId)) {
        skipped.push({ planId: r.id, reason: 'duplicate_patient' });
        continue;
      }
      bestByPatient.set(r.patientId, r);
    }

    const applicable: Array<{ row: (typeof rows)[number]; item: (typeof dto.items)[number] }> = [];
    for (const row of bestByPatient.values()) {
      const item = byPlanId.get(row.id)!;
      const blocked = assertAssignable(row, item.assigneeUserId);
      if (blocked) {
        skipped.push({ planId: row.id, reason: blocked });
        continue;
      }
      applicable.push({ row, item });
    }

    if (applicable.length === 0) {
      // ⭐ 一条都落不上就**不建批次** —— 空批次会让跟踪报表出现一堆 0/0 的行,
      //   而"这批为什么是空的"事后完全查不到。
      throw new BadRequestException(
        `本批 ${dto.items.length} 条都无法分配(${summarizeSkipped(skipped)}),未创建批次。`,
      );
    }

    // ── 决策快照取数(事务外,只读)────────────────────────────
    // ⭐ 这三份数据都会被覆盖,分配当时不取就永久没了。宁可多三次只读查询,
    //   也不要留一列半年后才发现是空的快照。
    const patientIds = [...new Set(applicable.map(({ row }) => row.patientId))];
    const dedicatedByPatient = await this.dedicatedCsOf(patientIds);
    const lastVisitByCs = await this.lastVisitOf([...new Set([...dedicatedByPatient.values()])]);
    const confidenceByPlan = await this.headConfidenceOf(applicable.map(({ row }) => row.id));

    // ⭐ 一致性硬闸:`dedicated` 的字面意思就是"分给了他自己的专属客服",
    //   而服务端刚刚把当时的专属客服取到手 —— 这是**可验证的**,就不该放过去。
    //   不拦的后果:T20 按 assign_strategy 分组时,一批实际是铺平的单顶着 dedicated 的标签,
    //   算出来的"专属完成率"里混进了铺平的样本,而**没有任何报错**。
    //   ⚠️ 只拦这一个方向:spread_* 与 manual 的判定依赖容量与主管意图,服务端不知道,
    //   仍以调用方为准(错了也能靠 dedicated_cs_at_assign 快照事后重算,这正是那列的价值)。
    const mislabeled = applicable.filter(
      ({ row, item }) =>
        item.assignStrategy === 'dedicated' &&
        dedicatedByPatient.get(row.patientId) !== item.assigneeUserId,
    );
    if (mislabeled.length > 0) {
      throw new BadRequestException(
        `有 ${mislabeled.length} 条标记为「专属承接」,但承接人并不是该患者当前的专属客服。` +
          `请让助手重新出确认单(标记与事实不符会污染后续的策略效果分析)。`,
      );
    }

    const now = new Date();
    try {
      const result = await this.prisma.$transaction(
        async (tx) => {
          const head = await tx.planAssignment.create({
            data: {
              hostId: scope.hostId,
              tenantId: scope.tenantId,
              clinicId: dto.clinicId,
              createdBy: actor.userId,
              requestId: dto.requestId,
              criteria: dto.criteria as Prisma.InputJsonObject,
              attributes: (dto.attributes ?? undefined) as Prisma.InputJsonObject | undefined,
              expiresAt,
              status: 'confirmed',
            },
            select: { id: true, expiresAt: true },
          });

          // ⭐ **一条 SQL 打完全部 500 行**,而不是按客服分桶发多条 updateMany。
          //   换法的原因是快照列:priority_score_at_assign / dedicated_cs_at_assign 等
          //   **逐行不同**,updateMany 的 data 是全桶共用的,表达不了。
          //   逐行 update 则是 500 次往返。VALUES join 两个问题一起解决,
          //   而且 RETURNING 直接给出**哪些行真的落上了**,不必再回查一次去猜差额。
          //   bind 变量:每行 9 个 × 500 = 4,500,远低于 PG 上限 32,767。
          //
          // ⚠️ 走原生 SQL 的两个代价,都必须手工补上:
          //   ① Prisma 的 `@updatedAt` **不会触发** → 显式 SET updated_at
          //   ② where 条件要自己写全 —— 这里的 `status='active' AND assignee IS NULL
          //      AND superseded_at IS NULL` 就是并发安全的全部依据:
          //      从上面 findMany 到这一刻若有人抢先认领,该行不满足条件、不被更新,
          //      RETURNING 里也就没有它。
          const values = applicable.map(({ row, item }) => {
            const perItemExpires =
              item.expiresInDays != null
                ? endOfDayInHostTimezone(now, item.expiresInDays, tz)
                : head.expiresAt;
            const dcs = dedicatedByPatient.get(row.patientId) ?? null;
            return Prisma.sql`(
              ${row.id}::uuid, ${item.assigneeUserId}::text, ${perItemExpires}::timestamptz,
              ${item.assignStrategy}::text, ${dcs}::text,
              ${dcs ? (lastVisitByCs.get(dcs) ?? null) : null}::timestamptz,
              ${row.priorityScore}::double precision,
              ${confidenceByPlan.get(row.id) ?? null}::double precision,
              ${item.selectionMode ?? 'rank'}::text
            )`;
          });

          const appliedRows = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
            UPDATE followup_plans AS fp SET
              status                      = 'assigned',
              assignee_user_id            = v.assignee,
              assigned_at                 = ${now}::timestamptz,
              assigned_by                 = ${actor.userId}::text,
              assignment_id               = ${head.id}::uuid,
              assignment_expires_at       = v.expires_at,
              assign_strategy             = v.strategy,
              release_reason              = NULL,
              release_note                = NULL,
              dedicated_cs_at_assign      = v.dedicated_cs,
              dedicated_cs_last_visit_at  = v.dedicated_cs_last_visit,
              priority_score_at_assign    = v.priority_score,
              source_confidence_at_assign = v.source_confidence,
              selection_mode              = v.selection_mode,
              updated_at                  = ${now}::timestamptz
            FROM (VALUES ${Prisma.join(values)}) AS v(
              plan_id, assignee, expires_at, strategy,
              dedicated_cs, dedicated_cs_last_visit,
              priority_score, source_confidence, selection_mode
            )
            WHERE fp.id = v.plan_id
              AND fp.status = 'active'
              AND fp.assignee_user_id IS NULL
              AND fp.superseded_at IS NULL
            RETURNING fp.id
          `);

          const applied = appliedRows.map((r) => r.id);
          const appliedSet = new Set(applied);
          const lost = applicable.map(({ row }) => row.id).filter((id) => !appliedSet.has(id));

          if (applied.length === 0) {
            // 全被抢走 → 回滚,库里不留空批次(与上面 applicable=0 同一条纪律)
            throw new BadRequestException('本批计划在确认期间已全部被他人认领,未创建批次。');
          }

          // ⭐ 归属账本一次写完。走 recordPlanEventsBulk 而不是裸 createMany ——
          //   plan-event.recorder 文件头宣称自己是「PlanEventLog 的唯一写入口」,
          //   在别处写 createMany 当场破功。
          const rowById = new Map(applicable.map(({ row, item }) => [row.id, { row, item }]));
          for (let i = 0; i < applied.length; i += UPDATE_CHUNK) {
            await recordPlanEventsBulk(
              tx,
              applied.slice(i, i + UPDATE_CHUNK).map((id) => {
                const e = rowById.get(id)!;
                return {
                  hostId: scope.hostId,
                  tenantId: scope.tenantId,
                  planId: id,
                  patientId: e.row.patientId,
                  // 批次分配一律 assign(主管派的),不会是 claim —— 即使主管分给自己:
                  // 那也是"以主管身份派单",与他在池子里自己点认领是两回事
                  event: PlanEventType.ASSIGN,
                  assigneeUserId: e.item.assigneeUserId,
                  actorUserId: actor.userId,
                  // ⭐ 批次归因的**真值**就落在这里。followup_plans.assignment_id
                  //   会被下一次分配覆盖(退回重分很常见),账本这条永不改变。
                  assignmentId: head.id,
                };
              }),
            );
          }

          // ⭐ 配了福利 → 作废这批的话术缓存,否则福利段永远不会出现(见 invalidateScripts)
          if (readBenefitText(dto.attributes) && applied.length > 0) {
            await this.invalidateScripts(tx, applied, `批次 ${head.id.slice(0, 8)} 配了福利`);
          }

          return { head, applied: applied.length, lost };
        },
        // 500 条 × (updateMany + 事件) 的余量;maxWait 是等连接池的时间,不是执行时间
        { maxWait: 10_000, timeout: 30_000 },
      );

      for (const id of result.lost) skipped.push({ planId: id, reason: 'claimed_by_other' });
      this.logger.log(
        `批次 ${result.head.id} 已确认:落 ${result.applied} 条 / 跳过 ${skipped.length} 条,` +
          `发起人=${actor.userId} 诊所=${dto.clinicId}`,
      );
      return {
        assignmentId: result.head.id,
        duplicate: false,
        assigned: result.applied,
        skipped,
        expiresAt: result.head.expiresAt.toISOString(),
      };
    } catch (err) {
      // ⭐ 幂等兜底:两个并发请求带同一个 requestId,前面的预检都放行了,
      //   在这里撞唯一键。**不能抛错** —— 抛了模型会以为失败、换个参数重试一次,
      //   那才是真正的重复分配。回查已有批次原样返回。
      if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
        const dup = await this.findByRequestId(scope, dto.requestId);
        if (dup) return dup;
      }
      throw err;
    }
  }

  /**
   * 把「批次引用」解析成真实 id —— **完整 uuid 或 8 位以上短号前缀都认**。
   *
   * 🔴 2026-08-03 实测踩到:助手撤销必然失败,报「批次 c02e1b80 不存在」。
   *   根因不在撤销,在**界面注入给模型的那句话**里把 id 截成了 8 位
   *   (`已确认分配:批次 #c02e1b80 …`)。模型手里只有短号,拿短号查主键当然查不到 ——
   *   而卡片上的撤销按钮握着完整 id,所以那条路一直是通的,单测也一直是绿的。
   *   ⇒ 注入那句已改成给模型全 id(见 assignment-confirm-sheet),**但这里必须也认短号**:
   *   短号会出现在对话历史、截图、主管口述里,模型没有任何办法把它还原成 uuid。
   *
   * ⚠️ 歧义**必须报错**,⛔ 不许"取第一个" —— 这个解析器服务于 revoke(唯一的写动作),
   *   猜错了就是撤销了另一批人的单子,而主管以为撤的是刚才那批。
   * ⚠️ 下限 8 位:再短的前缀在一个诊所几百个批次里撞得到,而撞上了才报错就晚了。
   */
  private async resolveAssignmentId(scope: TenantScopeContext, ref: string): Promise<string> {
    const trimmed = (ref ?? '').trim().replace(/^#/, '');
    // 完整 uuid 走原路(不做多余查询,行为与从前完全一致)
    if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)) {
      return trimmed;
    }

    // ⚠️ 从整串里**抠出** id —— 模型很可能把「批次 #c02e1b80」整句当参数传进来
    //   (实测它就这么干过,然后照着报错编了个"可能超了撤销窗口"告诉主管)。
    //   抠出来是安全的:后面仍然只在本 scope 内匹配,撞到多个照样报错不猜。
    const m = trimmed.match(/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}|[0-9a-f]{8,}/i);
    if (!m) {
      throw new NotFoundException(
        `「${ref}」里找不到批次 id。批次 id 是 uuid,界面上显示的是它的前 8 位。` +
          `⚠️ 请先用 list_assignment_batches 查到真实 id,⛔ 不要猜。`,
      );
    }
    const raw = m[0];
    if (raw.includes('-')) return raw.toLowerCase(); // 整串里夹着完整 uuid

    // ⚠️ id 是 `@db.Uuid`,Prisma 的 `startsWith` 会生成 `uuid LIKE text` —— Postgres 直接报
    //   `operator does not exist`。必须先 `::text` 再 LIKE。
    const like = `${raw.toLowerCase()}%`;
    const hits = await this.prisma.$queryRaw<Array<{ id: string; created_at: Date }>>(Prisma.sql`
      SELECT id, created_at FROM plan_assignments
      WHERE id::text LIKE ${like}
        AND host_id = ${scope.hostId}::uuid
        AND tenant_id = ${scope.tenantId}
        ${
          scope.clinicIds.length
            ? Prisma.sql`AND clinic_id IN (${Prisma.join(scope.clinicIds)})`
            : Prisma.empty
        }
      ORDER BY created_at DESC
      LIMIT 5`);

    if (hits.length === 0) {
      throw new NotFoundException(
        `本范围内没有批次 ${raw}。⚠️ 这**不是**撤销窗口超时、也**不是**状态变更 ——` +
          `就是这个 id 没匹配上。请用 list_assignment_batches 查真实 id,⛔ 不要替系统编原因。`,
      );
    }
    if (hits.length > 1) {
      throw new BadRequestException(
        `短号 ${raw} 匹配到 ${hits.length} 个批次,不能确定是哪一批 —— ` +
          `请用完整 id(list_assignment_batches 里有)。`,
      );
    }
    return hits[0]!.id;
  }

  /**
   * 账本口径的批次统计 —— **历史事实,永不改变**。
   *
   * 🔴 为什么不能继续数 `followup_plans.assignment_id`:那一列会被**下一次分配覆盖**。
   *   退回/到期回池的人被后面的批次挑走后,旧批次就少一个 —— 跑得越久缩得越厉害,
   *   且不报错(实测 c02e1b80 分过 9 条、重分后 planned 显示 0)。
   *
   * ⚠️ 一律 `count(DISTINCT plan_id/patient_id)`,⛔ 不能数行数:
   *   同一条单可能被退回两次(退回 → 有人自认领 → 又退回),
   *   而第二次退回时 plan 上那个 assignment_id 还是本批的陈迹 —— 数行会把它算成两条。
   *
   * ⚠️ **老批次(本列上线前的)账本里没有批次号**,这里会返回 0;
   *   调用方须回落到 followup_plans 现算(见 mergeStats)—— 那个口径会随重分失血,
   *   但显示一个偏小的数,总好过显示 0 让主管以为这批没分成。
   */
  private async ledgerStatsByAssignment(ids: string[]): Promise<
    Map<string, { planned: number; agents: number; released: number; expired: number; revoked: number }>
  > {
    if (ids.length === 0) return new Map();
    /**
     * 🔴 **超期 = 此刻挂在客服手上、且过了时限的**(2026-08-19 定的唯一口径)。
     *   到期不再回池(回收器已删,沿革见 `plan.module` 的墓碑),所以不会再有新的
     *   `auto_release/assignment_expired` 事件;而账本里那些**老事件数的是已经回池、
     *   没有客服挂着**的单 —— 那不是谁的超期,⛔ 一并删掉,别再取并集。
     * ⚠️ 代价:2026-08-19 之前的批次这一列变 0。分配功能还没正式上线,没有要保的历史(产品定)。
     * ⚠️ 判据与 `workload()` 的「超期」**逐字一致**,⛔ 不许分家。
     * ⚠️ 这段说明放在模板字符串**外面** —— 里面写 JS 块注释的话,
     *   注释里的反引号会把模板提前截断,而报错指向的是几行之后的地方(踩过)。
     */
    const rows = await this.prisma.$queryRaw<
      Array<{ assignment_id: string; planned: bigint; agents: bigint; released: bigint; revoked: bigint }>
    >(Prisma.sql`
      SELECT assignment_id,
             count(DISTINCT patient_id)        FILTER (WHERE event = 'assign')        AS planned,
             count(DISTINCT assignee_user_id)  FILTER (WHERE event = 'assign')        AS agents,
             count(DISTINCT plan_id)           FILTER (WHERE event = 'release')       AS released,
             count(DISTINCT plan_id)           FILTER (WHERE event = 'auto_release'
                                                        AND reason = ${PlanEventReason.REVOKED})            AS revoked
      FROM plan_event_logs
      WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
      GROUP BY assignment_id`);

    /**
     * 此刻仍在人手上、且已过时限的 —— 新口径下「超期」的主要来源。
     * ⚠️ 与 `workload()` 的第②支同判据:约了下次回访的**不算**(那是客服动过了的证据)。
     *   ⛔ 两处判据不许分家:一处改了另一处不改,主管会在批次表和团队表上看到两个数。
     */
    const nowOverdue = await this.prisma.$queryRaw<Array<{ assignment_id: string; n: bigint }>>(Prisma.sql`
      SELECT assignment_id, count(*) AS n
      FROM followup_plans
      WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
        AND status = 'assigned'
        AND superseded_at IS NULL
        AND assignment_expires_at IS NOT NULL
        AND assignment_expires_at < now()
        AND (snoozed_until IS NULL OR snoozed_until <= now())
      GROUP BY assignment_id`);
    const nowMap = new Map(nowOverdue.map((r) => [r.assignment_id, Number(r.n)]));

    return new Map(
      rows.map((r) => [
        r.assignment_id,
        {
          planned: Number(r.planned), agents: Number(r.agents), released: Number(r.released),
          // ⚠️ 只认「此刻挂在人手上且过了时限」这一支(理由见上面那段) ——
          //   ⛔ 别再并上账本里的 auto_release:那些单已经回池、没有客服挂着,不是谁的超期。
          expired: nowMap.get(r.assignment_id) ?? 0,
          revoked: Number(r.revoked),
        },
      ]),
    );
  }

  /**
   * 本批的**通话成效**:每个患者取**最近一次**执行结果,按 outcome 归类。
   *
   * ⚠️ `DISTINCT ON (plan_id)` 取最近一条,⛔ 不是 `count(*)` 数执行条数 ——
   *   打了三次才约上是**一个**成功,不是三个。数条数会让成功数随拨打次数膨胀。
   * ⚠️ 只统计 `assignment_id = 本批` 的执行(落库时按"在办期"判定,见 execution.service),
   *   所以客服退回后自己捞回来打的电话不会算进这一批。
   */
  private async outcomeStats(assignmentId: string): Promise<Map<string, number>> {
    const rows = await this.prisma.$queryRaw<Array<{ outcome: string; n: bigint | number }>>(
      Prisma.sql`
        SELECT outcome, count(*) AS n FROM (
          SELECT DISTINCT ON (plan_id) plan_id, outcome
          FROM plan_executions
          WHERE assignment_id = ${assignmentId}::uuid
          ORDER BY plan_id, created_at DESC
        ) latest
        GROUP BY outcome`,
    );
    return new Map(rows.map((r) => [r.outcome, Number(r.n)]));
  }

  /**
   * 本批**逐条**的通话结果 + 客服手写的纪要 —— 主管抽屉里「通话记录」那一块。
   *
   * 🔴 在此之前 `plan_executions.notes`(客服手填的电话纪要)**全线读不到**
   *   (2026-08-08 查证):`GET /plans/:id/executions` 接口一直在,但前端从没调过、
   *   MCP 也没开工具 —— 写进去就再没出来过。主管看到「不成功 1」只能知道有这么个数,
   *   问不出"哪个患者、为什么"。
   *
   * ⚠️ 与 `outcomeStats` **同一口径**:每条单只取**最近一次**执行(`DISTINCT ON`)。
   *   ⛔ 别改成列全部执行:打了三次才约上,那是**一个**结果不是三个,
   *   聚合的四个桶与这张明细必须能对上数(T14)。
   * ⚠️ 只列**有结果的**那些 —— 「没有结果」的人压根没有 execution 行,
   *   它的数在聚合里(noOutcome),⛔ 别在这里造空行冒充。
   * ⚠️ 封顶 `LIMIT`:一批能有两三百人。截断了要在响应里说明(见 `truncated`),
   *   ⛔ 不许静默截断 —— 主管会以为"就这些"。
   *
   * 🔴 **表单里勾了什么就要带什么出来**(2026-08-08 产品追问「有选择的项没有展示吗」)。
   *   `outcome` 只是**一个**字段,客服提交时还填了:
   *     · `abandon_reasons`       放弃原因(多选)—— 只有「放弃」这一项带子选项
   *     · `inaccurate_treatments` 勾了「治疗机会判断不对」时必填的治疗 code
   *     · `scheduled_next_at`     约定下次回访的日期
   *     · `channel`               电话 / 企微 / 短信
   *   ⛔ 只回 outcome 等于把客服填的一半信息烂在库里 —— 那正是这块要解决的问题本身。
   */
  private async outcomeRecords(
    assignmentId: string,
    limit: number,
  ): Promise<Array<{
    planId: string;
    patientName: string | null;
    operatorUserId: string;
    outcome: string;
    channel: string;
    abandonReasons: string[];
    inaccurateTreatments: string[];
    scheduledNextAt: string | null;
    notes: string | null;
    at: string;
  }>> {
    const rows = await this.prisma.$queryRaw<
      Array<{
        plan_id: string;
        patient_name: string | null;
        operator_user_id: string;
        outcome: string;
        channel: string;
        abandon_reasons: string[] | null;
        inaccurate_treatments: string[] | null;
        scheduled_next_at: Date | null;
        notes: string | null;
        created_at: Date;
      }>
    >(Prisma.sql`
      SELECT e.plan_id, p.name AS patient_name, e.operator_user_id, e.outcome, e.channel,
             e.abandon_reasons, e.inaccurate_treatments, e.scheduled_next_at, e.notes, e.created_at
      FROM (
        SELECT DISTINCT ON (plan_id) plan_id, operator_user_id, outcome, channel,
               abandon_reasons, inaccurate_treatments, scheduled_next_at, notes, created_at
        FROM plan_executions
        WHERE assignment_id = ${assignmentId}::uuid
        ORDER BY plan_id, created_at DESC
      ) e
      JOIN followup_plans fp ON fp.id = e.plan_id
      LEFT JOIN patients p ON p.id = fp.patient_id
      ORDER BY e.created_at DESC
      LIMIT ${limit}`);
    return rows.map((r) => ({
      planId: r.plan_id,
      patientName: r.patient_name,
      operatorUserId: r.operator_user_id,
      outcome: r.outcome,
      channel: r.channel,
      // ⚠️ PG 数组列没勾时是 `{}`(空数组)不是 null —— 两种都归空数组
      abandonReasons: r.abandon_reasons ?? [],
      inaccurateTreatments: r.inaccurate_treatments ?? [],
      scheduledNextAt: r.scheduled_next_at?.toISOString() ?? null,
      // 空字符串按"没写"处理 —— 界面上 '' 和 null 都该显示成"没留纪要"
      notes: r.notes?.trim() ? r.notes : null,
      at: r.created_at.toISOString(),
    }));
  }

  /**
   * 「约上」—— **批次列表**那一列(2026-08-07 主管工作台)。
   *
   * ⚠️ 与 `outcomeStats` 同一口径,只是**一次问一批**:列表一屏 20 行,
   *   逐行调 outcomeStats 就是 20 次往返。⛔ 别在 map 里 await。
   * ⚠️ 「约上」= EXECUTION_OUTCOME 里 `group='close'` 的那些(转化新预约 + **约定下次回访**)——
   *   与 detail 的 `outcomes.success` **同一个定义**,⛔ 别在这里另立标准(只数转化会比详情页小,
   *   而两处都写着"约上",主管对不上就只能猜哪个对)。
   * ⚠️ 每条单只取**最近一次**执行结果(DISTINCT ON):同一条单可能打过好几通,
   *   数行会把一个人算好几次。
   */
  /**
   * 每批**真的落了执行结果**的条数 —— 列表那一列「已处置」。
   * ⚠️ 与 `agentStats[].done` 同一口径(有执行记录才算),各客服加起来对得上这个数。
   * ⛔ 与 `progress.done`(池子口径)是两个问题,别互相替代 —— 见 schema 注释。
   */
  private async handledByAssignment(ids: string[]): Promise<Map<string, number>> {
    if (ids.length === 0) return new Map();
    const rows = await this.prisma.$queryRaw<Array<{ assignment_id: string; n: bigint | number }>>(
      Prisma.sql`
        SELECT assignment_id, count(DISTINCT plan_id) AS n
        FROM plan_executions
        WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
        GROUP BY assignment_id`,
    );
    return new Map(rows.map((r) => [r.assignment_id, Number(r.n)]));
  }

  private async bookedByAssignment(ids: string[]): Promise<Map<string, number>> {
    if (ids.length === 0) return new Map();
    const closeOutcomes = (Object.keys(EXECUTION_OUTCOME_META) as ExecutionOutcome[]).filter(
      (o) => EXECUTION_OUTCOME_META[o].group === 'close',
    );
    if (closeOutcomes.length === 0) return new Map();
    const rows = await this.prisma.$queryRaw<Array<{ assignment_id: string; n: bigint | number }>>(
      Prisma.sql`
        SELECT assignment_id, count(*) AS n FROM (
          SELECT DISTINCT ON (plan_id) plan_id, assignment_id, outcome
          FROM plan_executions
          WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
          ORDER BY plan_id, created_at DESC
        ) latest
        WHERE outcome IN (${Prisma.join(closeOutcomes)})
        GROUP BY assignment_id`,
    );
    return new Map(rows.map((r) => [r.assignment_id, Number(r.n)]));
  }

  /**
   * 人话批次名 —— 「8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。
   *
   * ⚠️ **服务端唯一生成点**。⛔ 别让模型自己拼:措辞会在两轮之间漂,
   *   主管就对不上"上次说的那批"(而在没有批次列表页的情况下,这个名字是他指认一批的唯一抓手)。
   * ⚠️ 时间到**分钟**是主要区分器 —— 同一个矩阵格子一天可能分好几批,只给日期分不开。
   * ⚠️ 温度取自 criteria 快照;**本列上线前的老批次没存温度**,那一段就不出现
   *   (⛔ 不许默认填一个 —— 会让主管以为那批是按"窗口内"圈的)。
   */
  private buildLabel(head: {
    criteria: unknown; createdAt: Date; status: string; attributes: unknown;
  }, planned: number, agents: number, tz: string): string {
    const c = (head.criteria ?? {}) as {
      potentialTreatment?: string;
      temperature?: string;
    };
    const parts: string[] = [formatBatchTime(head.createdAt, tz)];
    if (c.potentialTreatment) parts.push(potentialTreatmentCardLabel(c.potentialTreatment));
    if (c.temperature) {
      /**
       * 🔴 **历史值 `cold` 不许原样显示**(2026-08-07 主管工作台实测:列表上出现「充填治疗 · cold」)。
       *
       * 温度拆成六档之前,criteria 里存的是 `cold`(= 四个冷档的并集,见 COLD_TEMPERATURES),
       * 而 `TEMPERATURE_META` 里**没有**这个键 —— 于是兜底把码原样吐了出来。
       * ⛔ 助手那边花了一整轮才把码堵住(第 0 条「说人话」),批次名这里不能再漏一个。
       * ⚠️ 翻成「窗口外」而不是列出四档:那批人当时**就是按整个冷端圈的**,
       *   写成"1 年内/1–2 年/2–3 年/3 年以上"反而是编了一个当时不存在的精度。
       */
      const t = c.temperature;
      /**
       * ⚠️ 2026-08-11~13 之间建的批次,名字里可能带着「(按末诊)」——
       *   那是两版口径并存那两天的标记,⛔ 别去洗掉:批次名是主管指认一批的唯一抓手,
       *   改名等于把他记得的那个东西弄没了。新批次不再标(只剩一版,标了是废字)。
       */
      parts.push(TEMPERATURE_META[t as TemperatureValue]?.zh ?? (t === 'cold' ? '窗口外' : t));
    }
    parts.push(`${planned} 人`);
    if (agents > 0) parts.push(`${agents} 位客服`);
    const benefit = readBenefitText(head.attributes);
    if (benefit) parts.push(`带「${benefit}」`);
    // 终态摆在最后 —— 主管扫列表时最先要排除的就是已撤销的
    if (head.status === 'revoked') parts.push('已撤销');
    return parts.join(' · ');
  }

  /**
   * 批次列表 + 每批汇总。
   *
   * ⚠️ 计数**现算不落列**(T17),但源分两处:
   *   planned/agents/released/expired 走**账本**(历史事实),inHand/done 走 followup_plans(当前状态)。
   *   混用一个源必然出错 —— 见 ledgerStatsByAssignment 的红字。
   */
  async list(
    scope: TenantScopeContext,
    createdBy?: string,
    /**
     * 🔴 **游标分页**(2026-08-07 主管工作台)。
     *
     * ⚠️ 游标是 `createdAt` 的 ISO 串,⛔ 不是 offset:批次一直在新增,
     *   offset 分页会让第 2 页重复或漏掉第 1 页刚被顶下去的那条。
     * ⚠️ 排序键与游标必须是**同一列**(createdAt desc / createdAt <)—— 写岔了会静默丢行。
     * ⚠️ 同一毫秒可能建了两批(脚本批量分配),所以 `total` 单独 count,
     *   ⛔ 别用「取回条数 < limit」推断"到底了"。
     */
    page?: { limit?: number; before?: string },
  ): Promise<ListAssignmentsResponse> {
    const limit = Math.min(Math.max(page?.limit ?? 50, 1), 100);
    const before = page?.before ? new Date(page.before) : null;
    const where = {
      hostId: scope.hostId,
      tenantId: scope.tenantId,
      ...(scope.clinicIds.length ? { clinicId: { in: scope.clinicIds } } : {}),
      ...(createdBy ? { createdBy } : {}),
      ...(before && !Number.isNaN(before.getTime()) ? { createdAt: { lt: before } } : {}),
    };
    const [heads, total] = await Promise.all([
      this.prisma.planAssignment.findMany({ where, orderBy: { createdAt: 'desc' }, take: limit }),
      // 总数不带游标 —— 它答的是「一共多少批」,分页只影响这一页取哪些
      this.prisma.planAssignment.count({
        where: { ...where, createdAt: undefined } as typeof where,
      }),
    ]);
    if (heads.length === 0) return { items: [], total, nextBefore: null };

    const ids = heads.map((h) => h.id);
    const [live, ledger, names, tz, booked, handled] = await Promise.all([
      this.statsByAssignment(ids),
      this.ledgerStatsByAssignment(ids),
      this.resolveNames(scope, heads.map((h) => h.createdBy)),
      this.hostTimezone(scope.hostId),
      this.bookedByAssignment(ids),
      this.handledByAssignment(ids),
    ]);
    return {
      total,
      /// 下一页从这条之前开始;取满一页才可能还有,⛔ 但仍以 total 为准判断"到底了"
      nextBefore:
        heads.length === limit ? heads[heads.length - 1]!.createdAt.toISOString() : null,
      items: heads.map((h) => {
        const l = live.get(h.id) ?? { planned: 0, inHand: 0, released: 0, agents: 0, done: 0 };
        const counts = mergeStats(l, ledger.get(h.id));
        return {
          id: h.id,
          clinicId: h.clinicId,
          createdBy: h.createdBy,
          createdByName: names.get(h.createdBy) ?? null,
          criteria: (h.criteria ?? {}) as Record<string, unknown>,
          benefitText: readBenefitText(h.attributes),
          status: h.status,
          expiresAt: h.expiresAt.toISOString(),
          createdAt: h.createdAt.toISOString(),
          label: this.buildLabel(h, counts.planned, counts.agents, tz),
          ...counts,
          // 当前状态两项 —— 与账本口径的四项分开(见 mergeStats)
          inHand: l.inHand,
          done: l.done,
          /// 与 detail 的 `outcomes.success` 同一定义(见 bookedByAssignment)
          booked: booked.get(h.id) ?? 0,
          /// 执行口径的「已处置」—— 与 agentStats[].done 同源,⛔ 不是 done(池子口径)
          handled: handled.get(h.id) ?? 0,
        };
      }),
    };
  }

/**
   * 🔴 **团队现在什么状态** —— 主管工作台右栏(2026-08-07)。
   *
   * 回答文档里那三个问题的第三个:「团队现在什么状态」。
   *
   * ── 两种时间性混在一张表里,必须说清 ────────────────────────────
   *   · **在手** —— **此刻**手上还压着多少(`status='assigned'`),与窗口无关;
   *   · **超期 / 完成 / 退回 / 已处置 / 没动** —— **窗口内**的事(近 7 天 / 近 30 天)。
   * ⚠️ 界面上必须写明这件事,否则主管会把「在手 62」当成"这 7 天分了 62 条"。
   *
   * 🔴 **超期也是带窗口的**(2026-08-16 修注释:原文把它和「在手」并列写成"与窗口无关",
   *   与下面那条 SQL 对不上 —— SQL 里有 `assignment_expires_at >= since`,是**对的**)。
   *   理由:一年前过期的单报上来对主管没有意义,他此刻能处置的只有近期这批。
   *   ⛔ 别照注释把 SQL 的窗口去掉。
   *
   * ⚠️ 「超期」必须带上**且没约下次回访**这半句,与 detail 的 agentStats 同一条判据:
   *   到期回收器刻意跳过 snoozedUntil 在未来的单(客服约了 6/10 回访,那之前不能收走),
   *   不加守卫会把"打了电话、约好下次"的人显示成"压着单没动" —— 干得最好的那个被指责。
   *
   * ⚠️ 退回率**两个分母都给**:「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」。
   *   没动的数量本身是信号,只给一个百分比会把它藏起来(文档「三个必须先定的口径」)。
   *
   * 🔴 **每一支都必须按 `target_clinic_id` 收窄到本诊所**(2026-08-20 修的线上 bug)。
   *   ── 现场 ──────────────────────────────────────────────────
   *   杭州大厦主管工作台显示「23 位在岗 · 在手 53」,其中李欣 52 条。实查:
   *     · 杭州大厦自己的 followup_plans:active 17,938 / superseded 4,137 / **assigned 0**
   *     · 那 52 条的 target_clinic_id 全是上海世纪大道,07-30 她在**那边**自己认领的
   *   ⇒ 一条都不属于杭州大厦,整栏是幻觉。
   *
   *   根因是**名册按诊所取、计数却按全租户取**:客服在哪家做过回访就进哪家名册
   *   (名册从回访行为反推,宿主没给权威员工名单),跨诊所工作的人于是把别家的
   *   在手/超期/完成全带了过来。`assignee_user_id IN (本诊所名册)` 挡不住这件事 ——
   *   它限的是**人**,不是**单**。
   *
   *   ⚠️ 六支全改,⛔ 不许只改「在手」:同一屏上"在手 0 / 超期 41"这种自相矛盾
   *     比全错更难查。plan_event_logs / plan_executions 没有诊所列,经 plan_id
   *     反查 `followup_plans.target_clinic_id`(**归属**),⛔ 不是 executor_clinic_id
   *     (那是执行地,同一条单可能在别家诊所打的电话)。
   *
   *   ⚠️ 代价(已知并接受):跨诊所的客服,本屏不再显示他在别家的负荷。分配前想知道
   *     "这人在别处忙不忙",需要另加一列(产品未定),⛔ 别为此把诊所过滤去掉。
   *
   * ⛔ 名册**沿用 AgentRosterService**,不在这里另查一份:
   *   分配时问「还能吃多少」、跟踪时问「手上压了多少」是同一份数据的两种读法(T10)。
   *   另查一份必然口径漂移,而漂了不报错。
   */
  async workload(
    scope: TenantScopeContext,
    clinicId: string,
    windowDays: number,
  ): Promise<AgentWorkloadResponse> {
    const days = Math.min(Math.max(Math.round(windowDays) || 7, 1), 90);
    const since = new Date(Date.now() - days * 86_400_000);
    const now = new Date();

    // 名册 = 分配那边同一个来源(T10),⛔ 别另起一份
    const roster = await this.roster.list(scope, clinicId);
    const ids = roster.agents.map((a) => a.userId);
    if (ids.length === 0) {
      return { clinicId, windowDays: days, agents: [] };
    }

    /**
     * 此刻在手 —— **只有这一列是"当前"**,其余全在窗口内(见 overdue 的红字)。
     * ⚠️ 走 `followup_plans` 的当前状态:「手上压着多少」问的就是此刻。
     */
    const live = await this.prisma.$queryRaw<Array<{ uid: string; in_hand: bigint }>>(Prisma.sql`
      SELECT assignee_user_id AS uid, count(*) AS in_hand
      FROM followup_plans
      WHERE host_id = ${scope.hostId}::uuid
        AND tenant_id = ${scope.tenantId}
        AND target_clinic_id = ${clinicId}
        AND status = 'assigned'
        AND superseded_at IS NULL
        AND assignee_user_id IN (${Prisma.join(ids)})
      GROUP BY assignee_user_id`);

    /**
     * 🔴 **超期 = 此刻挂在客服手上、且过了时限的**。
     *
     * ── 2026-08-19 只剩这一支了 ────────────────────────────────
     * 原来是两支的并集,另一支数账本里 `auto_release/assignment_expired` 事件 ——
     * 那是**回收器时代**的产物:过期的单当场被收回池子、status 不再是 assigned,
     * 于是"当前超期"结构上永远是 0,只能去账本里数"曾经被收走过多少条"。
     *
     * 🔴 到期不再回池之后(回收器已删,见 `plan.module` 的墓碑),那一支必须删:
     *   它数的是**已经回到池子里、没有任何客服挂着**的单 —— 而超期的意思是
     *   「**有人**该打没打」。没人挂着的单不是谁的超期,主管对它也做不了任何事。
     *   ⚠️ 实测(2026-08-19 测试服):界面上显示的「超期 41 / 193」**100% 来自那一支**,
     *     而真正在手且过时限的是 **0 条** —— 一屏数字全是幻觉,且不报错。
     * ⇒ 一个口径,一处实现:`assigned` + 过了时限 + 没约下次。
     *
     * ⚠️ 代价(已知并接受):2026-08-19 之前那些被收走过的单不再计入,老批次这一列变 0。
     *   分配功能还没正式上线,没有需要保住的历史(产品定)。
     * ⚠️ **约了下次回访的不算**:那是客服动过了的证据 —— 打了电话、约好下次的人
     *   不该被显示成"压着单没动"。⛔ 这条守卫不许省。
     * ⚠️ 判据与 `ledgerStatsByAssignment` 里那支、以及下面 `oldest` **逐字一致**,⛔ 不许分家:
     *   一处改了另一处不改,主管会在批次表和团队表上看到两个数。
     */
    const overdue = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
      SELECT assignee_user_id AS uid, count(*) AS n
      FROM followup_plans
      WHERE host_id = ${scope.hostId}::uuid
        AND tenant_id = ${scope.tenantId}
        AND target_clinic_id = ${clinicId}
        AND status = 'assigned'
        AND superseded_at IS NULL
        AND assignment_expires_at IS NOT NULL
        AND assignment_expires_at < ${now}
        AND (snoozed_until IS NULL OR snoozed_until <= ${now})
        AND assignee_user_id IN (${Prisma.join(ids)})
      GROUP BY assignee_user_id`);

    /**
     * 最久的那条超期了多少天 —— ⚠️ 只看**此刻仍在他手上**的。
     *
     * 🔴 光给条数答不了「这事有多急」:昨天刚过时限的 41 条,和压了 12 天的 41 条,
     *   主管该做的事完全不同。⛔ 别把历史上被回收走的算进来 —— 那些早就不在他桌上了。
     * ⚠️ 判据与上面 overdue 的第②支**逐字一致**(含 snoozed 守卫),⛔ 不许分家:
     *   一处改了另一处不改,同一屏上会出现"超期 41 条,最久 0 天"这种自相矛盾。
     */
    const oldest = await this.prisma.$queryRaw<Array<{ uid: string; days: number }>>(Prisma.sql`
      SELECT assignee_user_id AS uid,
             floor(EXTRACT(EPOCH FROM (${now} - min(assignment_expires_at))) / 86400)::int AS days
      FROM followup_plans
      WHERE host_id = ${scope.hostId}::uuid
        AND tenant_id = ${scope.tenantId}
        AND target_clinic_id = ${clinicId}
        AND status = 'assigned'
        AND superseded_at IS NULL
        AND assignment_expires_at IS NOT NULL
        AND assignment_expires_at < ${now}
        AND (snoozed_until IS NULL OR snoozed_until <= ${now})
        AND assignee_user_id IN (${Prisma.join(ids)})
      GROUP BY assignee_user_id`);

    /**
     * 窗口内的「退回」—— 走**账本**(历史事实,永不变)。
     * ⚠️ ⛔ 不能读 `followup_plans.release_reason`:那是**当前值**,重分时会被清成 NULL,
     *   于是退过的单在重分后就查不到了(detail 那边已经踩过这个坑)。
     * ⚠️ release 事件本身的 assignee 是 null(释放后无人归属),要归到**释放前的承接人**——
     *   `plan_event_logs.actor_user_id` 就是点退回的那个人。
     */
    const released = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
      SELECT actor_user_id AS uid, count(DISTINCT plan_id) AS n
      FROM plan_event_logs
      WHERE host_id = ${scope.hostId}::uuid
        AND tenant_id = ${scope.tenantId}
        AND event = ${PlanEventType.RELEASE}
        AND created_at >= ${since}
        AND actor_user_id IN (${Prisma.join(ids)})
        AND EXISTS (SELECT 1 FROM followup_plans p
                    WHERE p.id = plan_event_logs.plan_id AND p.target_clinic_id = ${clinicId})
      GROUP BY actor_user_id`);

    /**
     * 窗口内的「完成」= 这段时间里写过通话结果的条数(按人按单去重)。
     * ⚠️ 用 `plan_executions` 而不是 plan 状态:状态是**此刻**的,回答不了"这 7 天做了多少"。
     * ⚠️ 同一条单一天打两通只算一条(DISTINCT plan_id)。
     */
    const done = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
      SELECT operator_user_id AS uid, count(DISTINCT plan_id) AS n
      FROM plan_executions
      WHERE host_id = ${scope.hostId}::uuid
        AND tenant_id = ${scope.tenantId}
        AND created_at >= ${since}
        AND operator_user_id IN (${Prisma.join(ids)})
        AND EXISTS (SELECT 1 FROM followup_plans p
                    WHERE p.id = plan_executions.plan_id AND p.target_clinic_id = ${clinicId})
      GROUP BY operator_user_id`);

    /**
     * 窗口内「分到了多少」—— 退回率那个"没动"要用它减。
     * ⚠️ 走账本的 assign 事件(历史事实),⛔ 不数 followup_plans.assignment_id:
     *   那一列会被下一次分配覆盖。
     *
     * 🔴 **被主管撤回去的不算**(2026-08-10 产品发现)。
     *
     * ── 实测 ────────────────────────────────────────────────────
     * 姜茜「这 7 天分到 53 条,一条都还没动」,拆开是 20(**已撤销那批**)+ 20 + 13。
     * 那 20 条是主管自己在撤销窗口里收回去的,她根本没机会碰 ——
     * 摆在「分到…一条都没动」里等于**拿主管自己的操作去指责客服**。
     *
     * ⚠️ 判据是**逐条**的,⛔ 不是"整批 status=revoked 就全扣":
     *   撤销刻意**不收**已经被客服打开过(view 事件)的单 —— 那些仍在他手上、仍该算他的。
     *   所以只排掉真的落了 `auto_release/revoked` 的那些,且要求**同一批次**
     *   (同一条单可能先在 A 批被撤、后在 B 批正常分给他,B 批那次不能跟着被扣掉)。
     * ⚠️ 到期回池(`assignment_expired`)**照样算** —— 那是单子在他手上放到过期,
     *   正是「没动」要表达的东西,⛔ 别一起排掉。
     */
    const assigned = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
      SELECT e.assignee_user_id AS uid, count(DISTINCT e.plan_id) AS n
      FROM plan_event_logs e
      WHERE e.host_id = ${scope.hostId}::uuid
        AND e.tenant_id = ${scope.tenantId}
        AND e.event = ${PlanEventType.ASSIGN}
        AND e.created_at >= ${since}
        AND e.assignee_user_id IN (${Prisma.join(ids)})
        AND EXISTS (SELECT 1 FROM followup_plans p
                    WHERE p.id = e.plan_id AND p.target_clinic_id = ${clinicId})
        AND NOT EXISTS (
          SELECT 1 FROM plan_event_logs r
          WHERE r.plan_id = e.plan_id
            AND r.assignment_id IS NOT DISTINCT FROM e.assignment_id
            AND r.event = ${PlanEventType.AUTO_RELEASE}
            AND r.reason = ${PlanEventReason.REVOKED}
        )
      GROUP BY e.assignee_user_id`);

    const num = (rows: Array<{ uid: string; n: bigint }>) =>
      new Map(rows.map((r) => [r.uid, Number(r.n)]));
    const relM = num(released);
    const doneM = num(done);
    const asgM = num(assigned);
    const liveM = new Map(live.map((r) => [r.uid, Number(r.in_hand)]));
    const ovdM = num(overdue);
    const oldestM = new Map(oldest.map((r) => [r.uid, Number(r.days)]));

    const agents = roster.agents.map((a) => {
      const inHand = liveM.get(a.userId) ?? 0;
      const doneN = doneM.get(a.userId) ?? 0;
      const relN = relM.get(a.userId) ?? 0;
      // 已处置 = 写了结果的 + 退回的 —— 两者都是"这单他动过了"
      const handled = doneN + relN;
      // 没动 = 窗口内分到的 − 已处置。⚠️ 可能为负(分配在窗口外、处置在窗口内),兜 0
      const idle = Math.max(0, (asgM.get(a.userId) ?? 0) - handled);
      return {
        userId: a.userId,
        name: a.name,
        inHand,
        overdue: ovdM.get(a.userId) ?? 0,
        // ⚠️ 0 天(今天刚过)与"没有超期"是两件事 —— 前者给 0,后者给 null
        overdueOldestDays: oldestM.get(a.userId) ?? null,
        done: doneN,
        released: relN,
        handled,
        idle,
        /**
         * ⭐ 成品句子,⛔ 前端别自己拼 —— 退回率必须**两个分母都带**,
         *   写在这里保证界面、助手、导出三处口径一致(T14 的"工具返回值"那一半)。
         */
        rateNote: handled
          ? `退回 ${relN} / 已处置 ${handled} = ${((relN / handled) * 100).toFixed(1)}%` +
            (idle ? `,另有 ${idle} 条没动` : '')
          : idle
            ? `这 ${days} 天分到 ${idle} 条,一条都还没动`
            : `这 ${days} 天没有分到新的`,
      };
    });

    return {
      clinicId,
      windowDays: days,
      agents,
    };
  }

  /** 单批全貌:按客服拆 + 退回原因分布 + 未动过的条数 */
  async detail(scope: TenantScopeContext, ref: string): Promise<AssignmentDetailResponse> {
    // 短号也认(模型手里常常只有界面上那 8 位)—— 见 resolveAssignmentId
    const id = await this.resolveAssignmentId(scope, ref);
    const head = await this.prisma.planAssignment.findFirst({
      where: {
        id,
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        ...(scope.clinicIds.length ? { clinicId: { in: scope.clinicIds } } : {}),
      },
    });
    if (!head) throw new NotFoundException(`批次 ${id} 不存在`);

    // 🔴 **绝不能过滤 `supersededAt: null`** —— 那会把**处理得最好的那批人静默删掉**。
    //
    //   引擎发现某患者已无任何活信号(治疗做了 / 诊断消失)时,走 `closeStaleActivePlan`:
    //   `status='superseded' + supersededAt=now`,且**不建后继版本**。
    //   于是「分了 100 人,30 个真的来把治疗做了」在旧写法下显示成 `planned=70` ——
    //   批次凭空缩水 30 人,不报错、不告警,而缩掉的恰恰是唯一算得上成功的那 30 个。
    //   (D-12 的无条件继承只保住了**升版本**那条路:后继行继承 assignment_id;
    //    关闭这条路压根没有后继行,继承救不了。R12 当时只想到了前者。)
    //
    //   正解:拿本批全部行、**按患者取最新版本**。升版本时新旧两行都带 assignment_id
    //   (D-12),取 max(version) 天然去重;关闭时最新版就是那条 superseded 行,正好是信号。
    const allRows = await this.prisma.followupPlan.findMany({
      where: { assignmentId: id },
      select: {
        id: true, patientId: true, version: true, status: true, assigneeUserId: true,
        releaseReason: true, assignmentExpiresAt: true, snoozedUntil: true,
      },
      orderBy: { version: 'desc' },
    });
    const seenPatient = new Set<string>();
    const plans = allRows.filter((r) => {
      if (seenPatient.has(r.patientId)) return false;
      seenPatient.add(r.patientId);
      return true;
    });

    const planIds = plans.map((p) => p.id);

    // ⭐ 退回后 `assignee_user_id` 已被清空,光看主表算不出"这条本来是谁的" ——
    //   于是退回的行会从所有人的名下消失,agentStats 各人 planned 加起来比批次 planned 少,
    //   主管一眼就看出对不上(实测 500 条退 1 条 → 加起来 499)。而"某人退了几条"
    //   恰恰是他最想看的那一列。
    //   账本里有:分配时写的 assign 事件带 assigneeUserId,且时间 >= 批次创建时刻,
    //   按 planId 取回来即可。这是 plan_event_logs 存在的意义 ——
    //   主表存当前值,历史归属只有账本留得住。
    // ⚠️ 取**最近一次** assign,不按时间窗过滤。
    //   曾经写成 `createdAt >= head.createdAt`(想排掉这条 plan 在**上一个**批次里的
    //   assign 事件),生产里事件与批次头同事务、时间必然在后,看不出问题;
    //   但那个条件本身很脆 —— 事件的 createdAt 若由调用方给(补数据、迁移、造数),
    //   或与批次头的 DB 时钟有毫秒级先后,整批已退回的单就会从所有人名下消失,
    //   agentStats 各人之和比 planned 少一大截,而**不报任何错**。
    //   (实测:seed 造的事件时间在 5 天前,199 条里 50 条当场蒸发。)
    //   正解:plan 当前的 assignment_id 就是本批,所以它**最近一次** assign 必然属于本批。
    /**
     * ⭐ **本批的全量事件**(按 assignment_id 取,不是按当前 planIds)。
     *
     * 🔴 为什么必须按批次号取:被后面的批次挑走的人**已经不在 `plans` 里**了。
     *   按 planIds 取的话,他们的 assign / release 事件一条都捞不到 ——
     *   于是 `agentStats` 各人之和比 `planned`(账本口径)少一大截,
     *   而「各人之和 === planned」正是本文件回归里锁着的不变量。
     * ⚠️ 老批次(账本没记批次号)返回空 → 回落到按 planIds 取,行为与从前一致。
     */
    const batchEvents = await this.prisma.planEventLog.findMany({
      where: { assignmentId: id, tenantId: scope.tenantId },
      select: { planId: true, assigneeUserId: true, event: true, reason: true },
      orderBy: { createdAt: 'desc' },
    });
    const assignEvents = batchEvents.length
      ? batchEvents.filter((e) => e.event === PlanEventType.ASSIGN)
      : planIds.length
        ? await this.prisma.planEventLog.findMany({
            where: { planId: { in: planIds }, tenantId: scope.tenantId, event: PlanEventType.ASSIGN },
            select: { planId: true, assigneeUserId: true },
            orderBy: { createdAt: 'desc' },
          })
        : [];
    const originalOwner = new Map<string, string>();
    for (const e of assignEvents) {
      if (e.assigneeUserId && !originalOwner.has(e.planId)) originalOwner.set(e.planId, e.assigneeUserId);
    }

    const now = new Date();
    /**
     * 🔴 **本批里哪些单真的落了执行结果**(2026-08-07 产品定)。
     *
     * ⭐ 「已处置」按人拆时**必须用这个**,⛔ 不能用池子状态:
     *   `classifyPlanProgress` 的 `resolved` 是**引擎判定召回需求没了**(患者自己来了),
     *   客服一根手指没动,却会被算进他的「已处置」—— 按批次看无所谓,
     *   按人看就是**冤枉人也放过人**(该催的没催出来、没做的显示成做了)。
     * ⚠️ 批次级的 `progress.done` 仍走池子状态(它答的是"这批还剩多少活",是另一个问题),
     *   ⛔ 两者别互相替代 —— schema 里各自写清答的是什么。
     */
    const executed = new Set(
      planIds.length
        ? (
            await this.prisma.planExecution.groupBy({
              by: ['planId'],
              where: { planId: { in: planIds }, tenantId: scope.tenantId },
            })
          ).map((r) => r.planId)
        : [],
    );

    const byAgent = new Map<string, AssignmentAgentStat>();
    const reasonCount = new Map<string, number>();
    const touch = (key: string): AssignmentAgentStat => {
      let a = byAgent.get(key);
      if (!a) {
        a = { userId: key, name: null, planned: 0, inHand: 0, released: 0, overdue: 0, done: 0 };
        byAgent.set(key, a);
      }
      return a;
    };

    /**
     * 名册来自**账本**:本批当时分给了谁、每人几条 —— 历史事实,不随重分变化。
     * ⚠️ 走 originalOwner 而不是逐条事件,是为了对同一 plan 的多次 assign 去重
     *   (退回→自认领→再退回 会留下第二条 assign)。
     */
    const ledgerRoster = batchEvents.length > 0;
    if (ledgerRoster) {
      for (const [, owner] of originalOwner) touch(owner).planned++;
      // 退回:账本 release 事件按 planId 归到**原始承接人**
      //   (release 事件本身的 assigneeUserId 是 null —— 释放后无人归属)
      const seenRelease = new Set<string>();
      for (const e of batchEvents) {
        if (e.event !== PlanEventType.RELEASE || seenRelease.has(e.planId)) continue;
        seenRelease.add(e.planId);
        touch(originalOwner.get(e.planId) ?? RELEASED_BUCKET).released++;
        if (e.reason) reasonCount.set(e.reason, (reasonCount.get(e.reason) ?? 0) + 1);
      }
    }

    for (const p of plans) {
      // 当前归属优先;已退回(assignee 为 null)则回落到账本里的原始承接人。
      // 两者都没有(理论上不该发生:批次分配一定写了 assign 事件)→ 归到兜底桶,
      // 宁可显示一个「未归属」也不要静默摊到某个真人头上。
      const key = p.assigneeUserId ?? originalOwner.get(p.id) ?? RELEASED_BUCKET;
      const a = touch(key);
      // ⚠️ 走账本名册时**不再从这里累加 planned/released** —— 上面已经按账本记全了,
      //   再加一遍就是双计。这里只负责叠加**当前状态**(在手 / 超期)。
      if (!ledgerRoster) a.planned++;
      /**
       * 🔴 **按人的「已处置」= 这条单真的落了执行结果**(2026-08-07 产品定)。
       *
       * ⛔ **不能用 `classifyPlanProgress`**:它的 `resolved` 是引擎判定召回需求没了
       *   (患者自己来了),客服没动过 —— 按人算会把"没做"显示成"做了"。
       *   按批次看那个口径没问题(答的是"这批还剩多少活"),按人看就是冤枉人。
       * ⚠️ 于是各客服的 done 加起来**不等于** `progress.done` —— 这是**刻意的**,
       *   两个数答的不是同一个问题;⛔ 别为了"能加得上"把它改回池子状态。
       */
      if (executed.has(p.id)) a.done++;
      if (p.status === 'assigned') {
        a.inHand++;
        // 🔴 「且没约下次回访」这半句不能少。到期回收器刻意跳过 snoozedUntil 在未来的单
        //   (客服约了 6/10 回访,那之前绝不能收走)—— 于是这类单会一直"超期"下去,
        //   而 progress 那边已经把它算作 suppressed(已处理)。
        //   不加守卫,同一条单「已处理」和「超期」同时成立:主管看到「薛玫 超期 3」
        //   以为她压着单没动,实际上她打了电话、约好了下次 —— 干得最好的那个被指责了。
        const snoozed = p.snoozedUntil != null && p.snoozedUntil > now;
        if (!snoozed && p.assignmentExpiresAt && p.assignmentExpiresAt < now) a.overdue++;
      }
      // 🔴 老批次才走这条。`followup_plans.release_reason` 是**当前值**,
      //   而重分时 assign 的 SQL 会把它 `= NULL` 清掉 —— 实测:一条退过的单被重分后,
      //   `released` 还是 1(账本口径),退回原因分布却变成 `[]`,两个数当场对不上。
      //   有账本时一律用账本(上面那段),⛔ 不许再叠这一层。
      if (!ledgerRoster && p.releaseReason) {
        a.released++;
        reasonCount.set(p.releaseReason, (reasonCount.get(p.releaseReason) ?? 0) + 1);
      }
    }

    // 账本口径的计数(planned/agents/released/expired)+ 宿主时区(批次名要用)。
    // ⚠️ 回落用的"老口径"就地从 plans / byAgent 算,⛔ 不再调 statsByAssignment ——
    //   那会为同一批数据再发一次一模一样的查询。
    const [ledger, tz, outcomeCounts, outcomeRows] = await Promise.all([
      this.ledgerStatsByAssignment([id]),
      this.hostTimezone(scope.hostId),
      this.outcomeStats(id),
      // +1 只为判断"还有没有更多",返回时切掉 —— ⛔ 别为了这个再发一次 count 查询
      this.outcomeRecords(id, OUTCOME_RECORD_LIMIT + 1),
    ]);
    const counts = mergeStats(
      {
        // ⚠️ 这三个只在**老批次**(账本没记批次号)时才会被采用 —— 见 mergeStats。
        planned: plans.length,
        agents: [...byAgent.keys()].filter((k) => k !== RELEASED_BUCKET).length,
        released: plans.filter((p) => p.releaseReason != null).length,
      },
      ledger.get(id),
    );

    // 「未动过」= 分下去后客服**从未打开过详情页**。
    // ⚠️ 判据只能用 view 事件:plan_executions 回写率仅 11%(生产 65 认领 / 7 条结果),
    //   拿它当"动过没"会把 89% 打过电话的单误判成没动。contactAttempts 与它同源,同样不可用。
    const viewed = planIds.length
      ? await this.prisma.planEventLog.groupBy({
          by: ['planId'],
          where: { planId: { in: planIds }, event: PlanEventType.VIEW, tenantId: scope.tenantId },
        })
      : [];

    // ── 处理进度(产品裁决:看池子状态,不等回写)────────────────────
    const bucket: Record<PlanProgress, number> = {
      resolved: 0, suppressed: 0, closed: 0, inHandPending: 0, backToPool: 0,
    };
    for (const p of plans) bucket[classifyPlanProgress(p, now)]++;
    const done = bucket.resolved + bucket.suppressed + bucket.closed;
    const ageDays = Math.floor((now.getTime() - head.createdAt.getTime()) / 86_400_000);
    /**
     * ⭐ 已被后续批次挑走的人数 = 账本口径的 planned − 现在还归本批的人数。
     *
     * planned 改成账本口径(历史事实)之后,五桶就**加不出 planned** 了 ——
     * 那几个人退回回池后被别的批次挑走,`followup_plans.assignment_id` 已经改指新批次。
     * 补这一桶让穷尽性重新成立;⛔ 不补的话主管一对数就发现少人,而少的那几个永远查不出去哪了。
     */
    const reassigned = Math.max(0, counts.planned - plans.length);
    const progress = {
      done,
      resolved: bucket.resolved,
      suppressed: bucket.suppressed,
      closed: bucket.closed,
      inHandPending: bucket.inHandPending,
      backToPool: bucket.backToPool,
      reassigned,
      ageDays,
      // ⭐ 成品句子,助手照抄(T14 双保险的「工具返回值」那一半)
      note:
        `已处理 ${done} / ${counts.planned}` +
        (bucket.resolved ? `,其中 ${bucket.resolved} 位患者的召回需求**已经不在池子里了**(引擎判定无活信号)` : '') +
        (bucket.suppressed ? `,${bucket.suppressed} 条客服写了回访结果(约下次/拒绝/放弃)` : '') +
        `;还在客服手上未处理 ${bucket.inHandPending} 条,落回池子 ${bucket.backToPool} 条` +
        // 退回与到期必须分开说 —— 主管的下一步动作相反(改分配策略 vs 派多了/时限太紧)
        (counts.released || counts.expired
          ? `(其中客服主动退回 ${counts.released} 条、到期没人动 ${counts.expired} 条)`
          : '') +
        `。` +
        (reassigned ? `另有 ${reassigned} 人已被后面的批次挑走,他们之后的表现算在那一批上。` : '') +
        `⚠️ 这是**处理率不是成功率** —— 只说"这单动过了",不说"谈成了"。` +
        `⚠️ 本批已经跑了 ${ageDays} 天,**跟别的批次比之前先看年龄** —— ` +
        `跑了三个月的批次天然比跑了三天的好看,直接比是耍流氓。`,
    };

    // ── 通话成效(来自 plan_executions,与「退回原因」是两件事)────────────
    const byOutcome = [...outcomeCounts]
      .map(([outcome, n]) => ({
        outcome,
        labelZh: EXECUTION_OUTCOME_META[outcome as ExecutionOutcome]?.labelZh ?? outcome,
        group: EXECUTION_OUTCOME_META[outcome as ExecutionOutcome]?.group ?? 'keep',
        n,
      }))
      .sort((a, b) => b.n - a.n);
    const sumOf = (g: string) =>
      byOutcome.filter((o) => o.group === g).reduce((s, o) => s + o.n, 0);
    const success = sumOf('close');
    const failed = sumOf('give_up');
    const keepCount = sumOf('keep');
    /**
     * 「一次结果都没有」的人数 = 本批人数 − 有结果的人。
     *
     * ⭐ 这个数才是主管最该先看的:它不是"效果不好",是**根本没做/没记**。
     *   混进 failed 会把执行问题读成召回问题,而这两件事要改的东西完全相反。
     * ⛔ **不要再减 `reassigned`**(踩过):被后面批次挑走的人,若在本批期间打过电话,
     *   那条执行的 assignment_id 仍是本批 → 已经算在 withOutcome 里了,再减一次是**重复扣减**,
     *   noOutcome 会被压成 0,而"没人做"这个最该报的信号就此消失。
     *   「现在归谁」(reassigned)和「做没做」(outcomes)是**正交**的两个维度,不能放进同一个减法。
     */
    const withOutcome = success + failed + keepCount;
    const noOutcome = Math.max(0, counts.planned - withOutcome);
    const outcomes = {
      success,
      failed,
      keep: keepCount,
      noOutcome,
      byOutcome,
      /**
       * 逐条明细 —— 主管问「哪个患者、为什么」时唯一答得上的地方。
       * ⚠️ `records.length` 与聚合的四个桶必须对得上:
       *   `records.length === success + failed + keep`(没结果的没有行)。
       */
      records: outcomeRows.slice(0, OUTCOME_RECORD_LIMIT).map((r) => ({
        planId: r.planId,
        patientName: r.patientName,
        operatorUserId: r.operatorUserId,
        operatorName: null as string | null, // 下面统一填(名字要等 resolveNames)
        outcome: r.outcome,
        labelZh: EXECUTION_OUTCOME_META[r.outcome as ExecutionOutcome]?.labelZh ?? r.outcome,
        group: EXECUTION_OUTCOME_META[r.outcome as ExecutionOutcome]?.group ?? 'keep',
        channel: r.channel,
        /**
         * 放弃原因(多选)—— ⚠️ **中文一并给**,⛔ 别只回 code。
         *   `out_of_service_area` 这种字符串扔给界面和助手,两边都得各自维护一张翻译表,
         *   而这张表已经在 `ABANDON_REASON_META` 里了(T14:口径只有一份)。
         * ⚠️ 未知 code **原样露出**,⛔ 不静默吞掉 —— 宁可看到生码也不能少一条原因。
         */
        abandonReasons: r.abandonReasons.map((k) => ({
          reason: k,
          labelZh: ABANDON_REASON_META[k as AbandonReason]?.labelZh ?? k,
        })),
        /// 勾了「治疗机会判断不对」时选的治疗;同样给中文
        inaccurateTreatments: r.inaccurateTreatments.map((c) => ({
          code: c,
          labelZh: potentialTreatmentCardLabel(c),
        })),
        scheduledNextAt: r.scheduledNextAt,
        notes: r.notes,
        at: r.at,
      })),
      /// ⚠️ 截断了必须说出来(⛔ 不许静默截断:主管会以为"就这些")
      recordsTruncated: outcomeRows.length > OUTCOME_RECORD_LIMIT,
      /**
       * 🔴 这里**曾经有一个 `note` 成品句子,2026-08-08 产品要求删掉**。
       *
       * 它把两种东西焊在了一根字符串里:
       *   ① 给人看的数(「打过并记了结果的 1 人:成功 1、不成功 0…」)
       *   ② 给**模型**看的指令(「⛔ 不等于都成交了」「**不要算成功率、不要画图**」)
       * 而 MCP 工具说明里还写着「照抄 `outcomes.note` 最稳」——
       * 于是模型一字不落地贴进对话框:主管看到的是一段带 `**` 和 ⛔ 的内部指令。
       *
       * ⛔ 别再加回来。护栏该待的地方是**工具说明**(那边本来就写全了:
       *   样本 <50 说"样本量不足"、noOutcome 单独报、success 含约定下次回访),
       *   数留在结构化字段里由模型自己组织语言 —— 两者混进一个 string 必然漏出去。
       */
    };

    const names = await this.resolveNames(scope, [
      head.createdBy,
      ...byAgent.keys(),
      // 执行人可能已经不在本批的客服里了(退回后被别人捞去打的)—— 名字也得解析
      ...outcomeRows.map((r) => r.operatorUserId),
    ]);
    for (const a of byAgent.values()) a.name = names.get(a.userId) ?? null;
    for (const r of outcomes.records) r.operatorName = names.get(r.operatorUserId) ?? null;

    return {
      id: head.id,
      clinicId: head.clinicId,
      createdBy: head.createdBy,
      createdByName: names.get(head.createdBy) ?? null,
      criteria: (head.criteria ?? {}) as Record<string, unknown>,
      benefitText: readBenefitText(head.attributes),
      status: head.status,
      expiresAt: head.expiresAt.toISOString(),
      createdAt: head.createdAt.toISOString(),
      label: this.buildLabel(head, counts.planned, counts.agents, tz),
      // ⚠️ planned/agents/released/expired 走**账本**(历史事实),inHand/done 走当前状态。
      //   ⛔ 别改回 `plans.length` —— 那是"现在还归本批的人",会随重分失血(见 mergeStats)。
      planned: counts.planned,
      released: counts.released,
      expired: counts.expired,
      agents: counts.agents,
      progress,
      done: progress.done,
      inHand: plans.filter((p) => p.status === 'assigned').length,
      /// ⭐ 与列表那一列**同一个数**:列表走 bookedByAssignment,这里直接用 outcomes.success ——
      ///   两处都是 `group='close'` 的合计,⛔ 别让它们分叉(界面上都叫「约上」)。
      booked: outcomes.success,
      /// 执行口径:本批真的落了通话结果的条数(= agentStats[].done 之和)
      handled: executed.size,
      agentStats: [...byAgent.values()].filter((a) => a.userId !== RELEASED_BUCKET),
      releaseReasons: [...reasonCount]
        .sort((a, b) => b[1] - a[1])
        .map(([reason, n]) => ({
          reason,
          labelZh: RELEASE_REASON_META[reason as ReleaseReason]?.labelZh ?? reason,
          n,
        })),
      // ⚠️ 「未动过」不是「没结果」:分母口径见 AssignmentDetailResponseSchema.untouched 的注释。
      //   只输出一个退回率百分比会让主管把"没人动"误读成"做得不错"。
      untouched: plans.length - viewed.length,
      // ⚠️ 通话成效(打了之后怎么样)≠ 退回原因(压根没打)。两者并列返回,⛔ 别在读侧合并。
      outcomes,
    };
  }

  /** 一次 groupBy 出多批的汇总,避免 N+1 */
  private async statsByAssignment(
    ids: string[],
  ): Promise<Map<string, { planned: number; inHand: number; released: number; agents: number; done: number }>> {
    // 🔴 与 detail 同一条纪律:**不许过滤 `supersededAt: null`**,按患者取最新版本。
    //   过滤掉的正是「引擎判定需求已了」那批(关闭走 supersede 且无后继行),
    //   列表页的 planned 会随着批次跑得越好、缩水得越厉害 —— 而且不报错。
    const rows = await this.prisma.followupPlan.findMany({
      where: { assignmentId: { in: ids } },
      select: {
        assignmentId: true, patientId: true, version: true,
        status: true, assigneeUserId: true, releaseReason: true, snoozedUntil: true,
      },
      orderBy: { version: 'desc' },
    });
    const out = new Map<string, { planned: number; inHand: number; released: number; agents: number; done: number }>();
    const agentSets = new Map<string, Set<string>>();
    const seen = new Set<string>();
    const now = new Date();
    for (const r of rows) {
      const dedupe = `${r.assignmentId}:${r.patientId}`;
      if (seen.has(dedupe)) continue;
      seen.add(dedupe);
      const k = r.assignmentId!;
      const cur = out.get(k) ?? { planned: 0, inHand: 0, released: 0, agents: 0, done: 0 };
      cur.planned++;
      if (r.status === 'assigned') cur.inHand++;
      if (r.releaseReason) cur.released++;
      // 列表页也给一个「已处理」数 —— 主管扫一眼就知道哪批卡住了,不用逐批点进去
      const prog = classifyPlanProgress(r, now);
      if (prog === 'resolved' || prog === 'suppressed' || prog === 'closed') cur.done++;
      out.set(k, cur);
      if (r.assigneeUserId) {
        const s = agentSets.get(k) ?? new Set<string>();
        s.add(r.assigneeUserId);
        agentSets.set(k, s);
      }
    }
    for (const [k, v] of out) v.agents = agentSets.get(k)?.size ?? 0;
    return out;
  }

  /**
   * 给**已确认**的批次补挂 / 改 / 撤福利(T4)。
   *
   * ── 为什么需要这条路(2026-08-06 实测的事故)──────────────────────
   * 福利原本**只在确认那一刻**随 `create` 落库。主管点完「确认分配」才想起来
   * 「这批带上八折」时,助手照样调 `edit_assignment_sheet` 把 `set_benefit` 推给卡片,
   * 卡片改了自己的 `benefit` state、亮出绿色角标、还回了一句「本批福利设为…」——
   * 而那个 state **只有 `confirm()` 会读**,批次早就落库了,DB 里一个字没变。
   * 于是界面说带上了、话术里没有,主管无从察觉(违 T14)。
   *
   * ⭐ 选择"补写"而不是"确认后拒绝一切修改":福利是**前向**的东西,
   *   它只影响此后生成的话术,补挂在业务上完全成立;逼主管撤销整批重分才是荒谬的
   *   (撤销还有 30 分钟窗口和"已打开的不收"两道限制,代价远大于收益)。
   *
   * ⚠️ **不设时间窗**(与 revoke 不同):revoke 限时是因为它要**收回**已经发出去的东西,
   *   越晚越危险;改福利只影响还没生成的话术,越晚只是越没用,不会伤到谁。
   *
   * ⚠️ 返回里必须**如实报**已被打开过的条数:那些客服手里拿的是旧话术,
   *   补挂的福利他看不到。⛔ 不许只回一句「已更新」—— 那正是这次事故的形状。
   */
  async setBenefit(
    scope: TenantScopeContext,
    actor: DispatchActor,
    ref: string,
    text: string,
  ): Promise<SetAssignmentBenefitResponse> {
    requirePermission(actor, Permission.PLAN_DISPATCH);
    rejectSyntheticIdentity(actor);

    const assignmentId = await this.resolveAssignmentId(scope, ref);
    const head = await this.prisma.planAssignment.findFirst({
      where: {
        id: assignmentId,
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        ...(scope.clinicIds.length ? { clinicId: { in: scope.clinicIds } } : {}),
      },
    });
    if (!head) throw new NotFoundException(`批次 ${assignmentId} 不存在`);
    // ⛔ 撤销过的批次不许再挂福利:那批人已经回池子了,福利挂上去谁也读不到,
    //   只会在批次列表里留下一个"有福利"的假象。
    if (head.status === 'revoked') {
      throw new BadRequestException('该批次已撤销,不能再改福利 —— 请重新分一批并在确认时带上。');
    }
    // 授权与 revoke 同一条:自己分的,或有看全池的权限(⛔ 别逐条判归属,那是单条路径的闸)
    if (head.createdBy !== actor.userId && !actor.permissions.includes(Permission.PLAN_VIEW_ALL)) {
      throw new ForbiddenException('只能修改自己发起的批次');
    }

    const next = text.trim();
    const prev = readBenefitText(head.attributes);
    if (next === (prev ?? '')) {
      return { assignmentId, benefitText: next || null, scriptsInvalidated: 0, touched: 0, note: '福利没有变化,本次没有任何改动。' };
    }

    const plans = await this.prisma.followupPlan.findMany({
      where: { assignmentId, supersededAt: null },
      select: { id: true },
    });
    const planIds = plans.map((p) => p.id);
    // 已被客服打开过的条数 —— 判据与 revoke 同源(view 事件),⛔ 别改用 plan_executions
    const touched = planIds.length
      ? (
          await this.prisma.planEventLog.groupBy({
            by: ['planId'],
            where: { planId: { in: planIds }, event: PlanEventType.VIEW },
          })
        ).length
      : 0;

    let scriptsInvalidated = 0;
    await this.prisma.$transaction(async (tx) => {
      const attributes = {
        ...((head.attributes as Record<string, unknown> | null) ?? {}),
        // 空串 = 撤掉福利。⛔ 别落一个 `{text:''}` 的空壳,读侧 readBenefitText 会把它当"有福利"
        ...(next ? { benefit: { text: next } } : {}),
      } as Record<string, unknown>;
      if (!next) delete attributes.benefit;
      await tx.planAssignment.update({
        where: { id: assignmentId },
        data: { attributes: attributes as Prisma.InputJsonObject },
      });
      // ⭐ 与 create / revoke 同一条规矩:福利变了就作废话术缓存,否则福利段永远不会出现
      //   (或者更糟:撤掉的福利还被念出去)。只作废不重生成,见 invalidateScripts。
      if (planIds.length) {
        const r = await tx.planScript.deleteMany({ where: { planId: { in: planIds } } });
        scriptsInvalidated = r.count;
        if (r.count > 0) {
          this.logger.log(
            `话术缓存作废 ${r.count} 条(批次 ${assignmentId.slice(0, 8)} 改了福利)—— 下次打开详情页时重新生成`,
          );
        }
      }
    });

    this.logger.log(
      `批次 ${assignmentId} 福利:${prev ?? '(无)'} → ${next || '(撤掉)'},操作人=${actor.userId}`,
    );
    const note =
      (next ? `本批福利已设为「${next}」。` : '已撤掉本批福利。') +
      (scriptsInvalidated > 0
        ? `${scriptsInvalidated} 条话术缓存已作废,客服下次打开详情页会重新生成、${next ? '带上福利' : '不再带福利'}。`
        : '') +
      (touched > 0
        ? `⚠️ 其中 ${touched} 条客服**已经打开过**,他手里那份话术是旧的 —— 需要的话请另行知会。`
        : '');
    return { assignmentId, benefitText: next || null, scriptsInvalidated, touched, note };
  }

  /**
   * 撤销整批 —— 把还没被动过的单收回池子。
   *
   * ⚠️⚠️ **撤销 ≠ 退回**(T21),两个不同的人做的两件事:
   *   撤销 revoke  —— **主管**收回**整批**(分错人 / 条件填错)
   *   退回 release —— **客服**退回**单条**(不是我的客户),必须带原因
   * 所以撤销**不写** `release_reason`(那列只属于客服的处置),
   * 事件走 `auto_release` + `reason=revoked`。混了退回率的分子分母会一起虚高。
   *
   * ⭐⭐ **「已动过」的判据只能用 `view` 事件。**
   *   直觉会去查 `plan_executions`(有执行记录 = 动过),但回写率仅 **11%**
   *   (生产 65 个认领单只有 7 条执行结果)—— 拿它判会把 89% **已经打过电话**的单
   *   当成"没动过"收走,那通电话就永久蒸发了,而客服第二天才发现单子没了。
   *   `contactAttempts` 与 plan_executions 同源(提交执行时才累加)、同为 7 条,一样不可用。
   *   唯一可用的是 PV/UV 埋点的 `view` 事件(生产已 1,024 条)——
   *   客服打开过详情页 = 至少看过,不能当作没动。
   *   ⇒ 已 view 的**跳过不收**,并在返回里如实告诉主管有几条没收。
   */
  async revoke(
    scope: TenantScopeContext,
    actor: DispatchActor,
    ref: string,
  ): Promise<RevokeAssignmentResponse> {
    requirePermission(actor, Permission.PLAN_DISPATCH);
    rejectSyntheticIdentity(actor);

    // ⭐ 先解析:助手手里往往只有界面上那 8 位短号(见 resolveAssignmentId 的实测记录)。
    //   ⚠️ 解析在**权限校验之后**没有意义、在**归属校验之前**是安全的:
    //   它只在本 scope 内匹配,匹配到多个直接报错不猜。
    const assignmentId = await this.resolveAssignmentId(scope, ref);

    const head = await this.prisma.planAssignment.findFirst({
      where: {
        id: assignmentId,
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        ...(scope.clinicIds.length ? { clinicId: { in: scope.clinicIds } } : {}),
      },
    });
    if (!head) throw new NotFoundException(`批次 ${assignmentId} 不存在`);
    if (head.status === 'revoked') {
      // 幂等:重复撤销不报错(主管手抖点两下很正常),返回上次的结果形状
      return {
        assignmentId,
        revoked: 0,
        skippedTouched: 0,
        alreadyReleased: 0,
        note: '该批次此前已撤销,本次没有任何改动。',
      };
    }

    // ── 授权(D-10):自己分的,或者有看全池的权限 ──────────────
    // ⚠️ 在这里**一次性**校验,⛔ 不逐行调 assertCanRecycle ——
    //   那是单条路径的归属闸(判"这单是不是你的"),而撤销判的是"这**批**是不是你的",
    //   逐行调会变成"只要有一条不是你的就整批失败",语义完全不对。
    const canRevoke =
      head.createdBy === actor.userId || actor.permissions.includes(Permission.PLAN_VIEW_ALL);
    if (!canRevoke) {
      throw new ForbiddenException('只能撤销自己发起的批次');
    }

    // ── 限时 ────────────────────────────────────────────────
    const ageMin = (Date.now() - head.createdAt.getTime()) / 60_000;
    if (ageMin > REVOKE_WINDOW_MINUTES) {
      throw new BadRequestException(
        `撤销窗口为 ${REVOKE_WINDOW_MINUTES} 分钟,本批已分配 ${Math.round(ageMin)} 分钟。` +
          `如需收回请逐条退回,或让客服自行退回(那条路会留下原因,便于后续调整分配策略)。`,
      );
    }

    const plans = await this.prisma.followupPlan.findMany({
      where: { assignmentId, supersededAt: null },
      select: { id: true, hostId: true, tenantId: true, patientId: true, status: true, assigneeUserId: true, assignedAt: true },
    });
    const inHand = plans.filter((p) => p.status === 'assigned' && p.assigneeUserId);
    const alreadyReleased = plans.length - inHand.length;

    // ⭐ 已被打开过的 → 跳过。见方法注释:唯一可用的"动过"信号。
    const touched = inHand.length
      ? await this.prisma.planEventLog.groupBy({
          by: ['planId'],
          where: {
            planId: { in: inHand.map((p) => p.id) },
            tenantId: scope.tenantId,
            event: PlanEventType.VIEW,
          },
        })
      : [];
    const touchedSet = new Set(touched.map((t) => t.planId));
    const collectable = inHand.filter((p) => !touchedSet.has(p.id));

    const now = new Date();
    await this.prisma.$transaction(async (tx) => {
      if (collectable.length > 0) {
        await tx.followupPlan.updateMany({
          // 带状态条件 = 并发安全(期间客服刚提交执行 / 自己退了 → 本次不生效)
          where: { id: { in: collectable.map((p) => p.id) }, status: 'assigned' },
          data: {
            status: 'active',
            assigneeUserId: null,
            assignedAt: null,
            recycleAt: null,
            assignmentExpiresAt: null,
            // ⛔ 不清 assignment_id / assigned_by / assign_strategy:
            //   "这批曾经分过 N 条"是历史事实,批次的 status 已经标了 revoked,
            //   统计时按 status 排除即可。清了就再也说不清这次误操作影响了多少人。
            // ⛔ 不写 release_reason —— 那是客服的处置,不是主管的收回。
          },
        });
        await recordPlanEventsBulk(
          tx,
          collectable.map((p) => ({
            hostId: p.hostId,
            tenantId: p.tenantId,
            planId: p.id,
            patientId: p.patientId,
            event: PlanEventType.AUTO_RELEASE,
            assigneeUserId: null,
            // ⭐ actor 记**主管**(不像到期回收那样为 null)—— 撤销是人做的决定,要能追责
            actorUserId: actor.userId,
            heldSeconds: computeHeldSeconds(p.assignedAt, now),
            reason: PlanEventReason.REVOKED,
            assignmentId,
          })),
        );
      }
      // ⭐ 批次撤销 → 福利不再适用。**收回的和没收回的都要作废**:
      //   没收回的那些(客服已打开过)手里正拿着一份写着福利的话术,
      //   而这个福利刚刚被撤销了 —— 那才是最危险的一批,因为他马上就要打电话。
      if (readBenefitText(head.attributes)) {
        await this.invalidateScripts(tx, plans.map((p) => p.id), `批次 ${assignmentId.slice(0, 8)} 已撤销`);
      }
      await tx.planAssignment.update({
        where: { id: assignmentId },
        data: { status: 'revoked', revokedAt: now, revokedBy: actor.userId },
      });
    });

    const note =
      `已撤销批次:收回 ${collectable.length} 条。` +
      (touchedSet.size > 0
        ? `⚠️ 另有 ${touchedSet.size} 条客服**已经打开过**,未收回 —— ` +
          `他可能已经联系了患者,收走会让那次触达查不到归属。如确需收回请逐条处理。`
        : '') +
      (alreadyReleased > 0 ? `另有 ${alreadyReleased} 条此前已退回或到期,本就不在人手上。` : '');

    this.logger.log(`批次 ${assignmentId} 已撤销:收回 ${collectable.length} / 跳过 ${touchedSet.size},操作人=${actor.userId}`);
    return {
      assignmentId,
      revoked: collectable.length,
      skippedTouched: touchedSet.size,
      alreadyReleased,
      note,
    };
  }

  /**
   * 作废这批 plan 的话术缓存 —— **福利变了就必须作废**。
   *
   * ── 为什么这是唯一一条「做错了会对患者做出虚假承诺」的路 ──────
   * `plan_scripts.planId` 是 **@unique**(一 plan 一话术),而话术只在有人点「生成」时才跑,
   * 之后一直读缓存。召回池又是共享的、未分配的单谁都能打开触发生成。三段后果:
   *   ① 分配**之前**有人点开过 → 话术已 ready 落库,**福利段永远不会出现**,T4 的归因目的失效
   *   ② plan 在批次 A(福利「8月种植体检免费」)→ 退回 → 进批次 B(福利「正畸首付5折」)
   *      → 话术缓存**还是 A 的文案**,客服照着念 = **对患者做了一个不存在的承诺**
   *   ③ 撤销批次后,话术里的福利仍在
   * ②③ 不是"体验不好",是真的会让患者按一个不存在的优惠上门。
   *
   * ── 为什么是**删除**而不是置 status='pending' ────────────────
   * 置 pending 会把旧 content 留在库里。任何一条**只读 content 不看 status** 的代码路径
   * (现在没有,将来难保)都会把过期福利念出去 —— 而这正是要防的那件事。
   * 删掉则物理上不可能读到。审计不丢:agent_invocations 那条记录本身还在,
   * 丢的只是一个指针。
   *
   * ── 成本 ────────────────────────────────────────────────
   * ⚠️ 只**作废**不重生成。作废是一句 deleteMany(在事务里,很便宜);
   * 重生成是**懒的** —— 客服打开详情页时走现有的"点生成"流程。
   * 一批 100 人若急切重生成 = 100 次 LLM 调用的钱和延迟,而其中大部分单可能根本没人打开。
   * 懒生成让成本随**真实使用**走,不随批次大小走。
   */
  private async invalidateScripts(
    tx: Prisma.TransactionClient,
    planIds: string[],
    why: string,
  ): Promise<void> {
    if (planIds.length === 0) return;
    const r = await tx.planScript.deleteMany({ where: { planId: { in: planIds } } });
    if (r.count > 0) {
      this.logger.log(`话术缓存作废 ${r.count} 条(${why})—— 下次打开详情页时重新生成`);
    }
  }

  /** 按幂等键回查已有批次(命中即视为重放) */
  private async findByRequestId(
    scope: TenantScopeContext,
    requestId: string,
  ): Promise<CreateAssignmentResponse | null> {
    const head = await this.prisma.planAssignment.findFirst({
      where: { hostId: scope.hostId, tenantId: scope.tenantId, requestId },
      select: { id: true, expiresAt: true },
    });
    if (!head) return null;
    const assigned = await this.prisma.followupPlan.count({
      where: { assignmentId: head.id, supersededAt: null },
    });
    return {
      assignmentId: head.id,
      duplicate: true,
      assigned,
      skipped: [],
      expiresAt: head.expiresAt.toISOString(),
    };
  }

  /**
   * 分配当时每个患者的专属客服。
   * ⚠️ 只此一刻取得到:`patients.preferences.dedicatedCs` 是摄入 upsert **覆盖**的当前值。
   */
  private async dedicatedCsOf(patientIds: string[]): Promise<Map<string, string>> {
    if (patientIds.length === 0) return new Map();
    const rows = await this.prisma.patient.findMany({
      where: { id: { in: patientIds } },
      select: { id: true, preferences: true },
    });
    const out = new Map<string, string>();
    for (const r of rows) {
      const id = (r.preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
      if (typeof id === 'string' && id) out.set(r.id, id);
    }
    return out;
  }

  /**
   * 每个客服**最近一次回访的时刻**。
   *
   * ⚠️ 必须用 `source_created_at`,**绝不能用 `task_date`** —— 后者含未来排程
   * (生产实测最远 2033 年、DW 侧甚至 2121 年),拿它算"最近活跃"会把早已离职的人
   * 判成在岗。这条坑 schema.prisma 的 task_date 注释里已写过一次。
   *
   * ⚠️ 存**时刻**而不是"是否在岗":在岗判定是滚动窗口,今天在岗的人半年后回查会变离岗,
   * 存 boolean 就无法按分配当时的口径重算了。
   */
  private async lastVisitOf(csIds: string[]): Promise<Map<string, Date>> {
    if (csIds.length === 0) return new Map();
    const rows = await this.prisma.patientReturnVisit.groupBy({
      by: ['taskDirectorId'],
      where: { taskDirectorId: { in: csIds }, sourceCreatedAt: { not: null } },
      _max: { sourceCreatedAt: true },
    });
    const out = new Map<string, Date>();
    for (const r of rows) {
      if (r.taskDirectorId && r._max.sourceCreatedAt) out.set(r.taskDirectorId, r._max.sourceCreatedAt);
    }
    return out;
  }

  /**
   * 每条 plan 的**头部 reason** 的证据可信度(0.5 影像AI / 0.8 建议 / 1.0 诊断)。
   *
   * 头部 = priority_score 最高的那条 reason —— plan.priorityScore 就是对它们取 MAX,
   * 所以驱动排序的是这一条的分,可信度也该取这一条的。
   * ⚠️ 它是分里的 **2× 乘子**,不单独留一份,事后就分不清「按分选人」实际是不是
   * 在「按诊断来源选人」。
   */
  private async headConfidenceOf(planIds: string[]): Promise<Map<string, number>> {
    if (planIds.length === 0) return new Map();
    const rows = await this.prisma.planReason.findMany({
      where: { planId: { in: planIds } },
      select: { planId: true, priorityScore: true, breakdown: true },
      orderBy: { priorityScore: 'desc' },
    });
    const out = new Map<string, number>();
    for (const r of rows) {
      if (out.has(r.planId)) continue; // 已排序,第一条即头部
      const cf = (r.breakdown as { priority?: { confidenceFactor?: number } } | null)?.priority
        ?.confidenceFactor;
      if (typeof cf === 'number') out.set(r.planId, cf);
    }
    return out;
  }

  /**
   * userId → 姓名。**唯一来源是回访表 `task_director_name`**(摄入的生产数据)。
   *
   * ⚠️ 必须取**最近一次**的名:id→姓名是时间性的,本地实测同一个 id 在不同时期
   * 对应不同的人(DW 侧换过人)。取到旧名会让界面写着"赵惠"、实际派给了"李宇琦",
   * 比不显示姓名更糟。
   *
   * ⚠️ 为什么服务端解析而不是前端翻:PAC **没有 users 表**,前端的
   * `dictionary.users` 只覆盖当前登录人 —— 详情页「承接人」现在显示的就是一串 uuid。
   * 凡是返回 userId 的 payload 都该顺带把姓名带上。
   *
   * ⚠️ 这里是**读时解析**,不是快照:批次跟踪看的是"这个 id 现在是谁"。
   * 换人极罕见且不会发生在一个批次的几天窗口内,故不为它再立一列。
   */
  private async resolveNames(
    scope: TenantScopeContext,
    userIds: string[],
  ): Promise<Map<string, string>> {
    const ids = [...new Set(userIds)].filter(Boolean);
    if (ids.length === 0) return new Map();
    const rows = await this.prisma.$queryRaw<Array<{ id: string; name: string | null }>>(Prisma.sql`
      SELECT DISTINCT ON (rv.task_director_id)
             rv.task_director_id AS "id", rv.task_director_name AS "name"
      FROM patient_return_visits rv
      WHERE rv.host_id = ${scope.hostId}::uuid
        AND rv.tenant_id = ${scope.tenantId}
        AND rv.task_director_id IN (${Prisma.join(ids)})
      ORDER BY rv.task_director_id, rv.source_created_at DESC
    `);
    const out = new Map<string, string>();
    for (const r of rows) if (r.name) out.set(r.id, r.name);
    return out;
  }

  /** host 时区(缺省 Asia/Shanghai —— 现有宿主全在东八区,manifest 里也是必填项) */
  private async hostTimezone(hostId: string): Promise<string> {
    const host = await this.prisma.host.findUnique({
      where: { id: hostId },
      select: { pullConfig: true },
    });
    const tz = (host?.pullConfig as { timezone?: string } | null)?.timezone;
    return tz ?? 'Asia/Shanghai';
  }

  /**
   * 「**为什么这个人给了张悦**」—— 从分配当时的决策快照回答，⛔ 不让模型编。
   *
   * ⭐ 这是 F5 的兑现：模型对已落库的分配唯一诚实的回答方式，就是**查**。
   *   在此之前它没有这个工具，被问到只能推理 —— 而推理出来的理由听起来完全合理，
   *   主管照着去调策略就是白跑一趟。
   *
   * ⚠️ 答案**全部来自 followup_plans 上那几列分配当时的快照**
   *   （assign_strategy / dedicated_cs_at_assign / dedicated_cs_last_visit_at /
   *   priority_score_at_assign / selection_mode），⛔ 不拿"现在"的值去解释"当时"的决定：
   *   专属客服会被摄入覆盖、priority_score 会被引擎就地改分，两者都不留痕。
   * ⚠️ 只回**已落库**的分配。草稿阶段的「为什么」由卡片自己解释（明细在它手里，不在模型手里）。
   */
  async explainAssignment(
    scope: TenantScopeContext,
    patientId: string,
  ): Promise<Record<string, unknown>> {
    const plan = await this.prisma.followupPlan.findFirst({
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        patientId,
        assignmentId: { not: null },
        ...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
      },
      orderBy: { updatedAt: 'desc' },
      select: {
        assigneeUserId: true,
        assignStrategy: true,
        dedicatedCsAtAssign: true,
        dedicatedCsLastVisitAt: true,
        priorityScoreAtAssign: true,
        selectionMode: true,
        assignmentId: true,
        status: true,
      },
    });
    if (!plan) {
      // ⚠️ 如实说没有,⛔ 别返回一个空壳让模型顺着编
      return { 结论: '这位患者没有已落库的分配记录（可能还在草稿里，或从未被分过）' };
    }
    const strategy = plan.assignStrategy ?? null;
    return {
      分给了谁: plan.assigneeUserId,
      怎么落到他头上的:
        strategy === AssignStrategy.DEDICATED
          ? '他本来就是这位患者的专属客服'
          : strategy === AssignStrategy.SPREAD_NO_DEDICATED
            ? '这位患者当时没有可用的专属客服，就给了手上最空的人'
            : strategy === AssignStrategy.SPREAD_OVERFLOW
              ? '他有专属客服，但那位当时已经排满，所以给了手上最空的人'
              : strategy === AssignStrategy.MANUAL
                ? '主管在确认单上手工指定的'
                : '没有记录（分配当时这一列还没开始记）',
      分配当时的专属客服: plan.dedicatedCsAtAssign ?? '当时没有专属客服',
      // ⚠️ 存的是**证据不是结论**：「在岗」是"近 N 月有回访"这个滚动窗口算出来的，
      //   今天在岗的人半年后回查会变成离岗。给时间戳，让主管自己按当时口径判断。
      那位专属最近一次回访: plan.dedicatedCsLastVisitAt?.toISOString() ?? '查不到回访记录',
      分配当时的优先级分: plan.priorityScoreAtAssign ?? '没有记录',
      怎么被选进这批的:
        plan.selectionMode === 'explore'
          ? '随机探索配额（不是按分选的）'
          : plan.selectionMode === 'swap'
            ? '重排一版时顶替进来的'
            : plan.selectionMode === 'rank'
              ? '按排序正常入选'
              : '没有记录',
      属于哪一批: plan.assignmentId,
      当前状态: plan.status,
    };
  }
}

function summarizeSkipped(skipped: AssignmentSkipped[]): string {
  const c = new Map<string, number>();
  for (const s of skipped) c.set(s.reason, (c.get(s.reason) ?? 0) + 1);
  const zh: Record<string, string> = {
    claimed_by_other: '已被他人认领',
    terminal: '已结案',
    duplicate_patient: '同一患者重复',
  };
  return [...c].map(([k, n]) => `${zh[k] ?? k} ${n} 条`).join('、');
}

/**
 * 「N 天后」→ **宿主当地日末**的绝对时刻。
 *
 * ⚠️ 为什么不是 `now + N*24h`:主管说「3 天有效」想的是"到后天下班",不是"到 72 小时后的
 * 此时此刻"。按小时算会让上午分的单和下午分的单在同一天的不同时刻过期,客服无法形成预期。
 *
 * ⚠️ 为什么必须显式带 offset:`new Date('2026-08-05T23:59:59.999')` 这种不带 tz 的写法
 * 在 Node 里按**本机时区**解析 —— 服务器跑在 UTC 就整整偏 8 小时。
 * 这正是 commit 4549817 修过的同一类坑,只是那次是纯日期。
 */
/**
 * 批次名里的时间戳 —— 「8/3 21:33」。
 *
 * ⚠️ 必须按**宿主时区**格式化,⛔ 不能用服务器本地时间:主管说的"今天下午那批"
 *   是他诊所墙上的时间;服务器在别的时区时,标签会指向另一天,而这个名字正是他指认批次的抓手。
 * ⚠️ 精确到分钟不是啰嗦 —— 同一个矩阵格子一天可能分好几批,只到天就分不开了。
 */
export function formatBatchTime(at: Date, timezone: string): string {
  const p = new Intl.DateTimeFormat('zh-CN', {
    timeZone: timezone, month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false,
  }).formatToParts(at);
  const get = (t: string) => p.find((x) => x.type === t)?.value ?? '';
  return `${get('month')}/${get('day')} ${get('hour')}:${get('minute')}`;
}

export function endOfDayInHostTimezone(base: Date, days: number, timezone: string): Date {
  const suffix = timezoneToOffsetSuffix(timezone); // '+08:00' | 'Z' | ...
  const offsetMin =
    suffix === 'Z'
      ? 0
      : (suffix.startsWith('-') ? -1 : 1) *
        (Number(suffix.slice(1, 3)) * 60 + Number(suffix.slice(4, 6)));
  // 把 UTC 时刻搬到"当地墙上时间"再读日期部分,才拿得到当地的今天是几号
  const local = new Date(base.getTime() + offsetMin * 60_000);
  const y = local.getUTCFullYear();
  const m = local.getUTCMonth();
  const d = local.getUTCDate() + days;
  const target = new Date(Date.UTC(y, m, d));
  const yyyy = target.getUTCFullYear();
  const mm = String(target.getUTCMonth() + 1).padStart(2, '0');
  const dd = String(target.getUTCDate()).padStart(2, '0');
  return new Date(`${yyyy}-${mm}-${dd}T23:59:59.999${suffix === 'Z' ? 'Z' : suffix}`);

}
