import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  ASSIGNMENT_ITEMS_HARD_LIMIT,
  Permission,
  PlanEventReason,
  PlanEventType,
  RELEASE_REASON_META,
  REVOKE_WINDOW_MINUTES,
  type AssignmentAgentStat,
  type AssignmentDetailResponse,
  type AssignmentSkipped,
  type CreateAssignmentRequest,
  type CreateAssignmentResponse,
  type ListAssignmentsResponse,
  type ReleaseReason,
  type RevokeAssignmentResponse,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.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__';

/// 批次福利文本(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';
}

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) {}

  /**
   * 主管确认 → 批量落归属。**这是整条生产线上唯一的写动作**(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,
                };
              }),
            );
          }

          // ⭐ 配了福利 → 作废这批的话术缓存,否则福利段永远不会出现(见 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;
  }

  /**
   * 批次列表 + 每批汇总。
   *
   * ⚠️ 计数**现算不落列**(T17):planned/inHand/released 都能从 followup_plans 一次
   * groupBy 出来。立柱看着省事,但批次一旦被退回/重算触碰,冗余列就开始漂,
   * 而漂了没有任何报错 —— 报表上少一条谁都不会发现。
   */
  async list(scope: TenantScopeContext, createdBy?: string): Promise<ListAssignmentsResponse> {
    const heads = await this.prisma.planAssignment.findMany({
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        ...(scope.clinicIds.length ? { clinicId: { in: scope.clinicIds } } : {}),
        ...(createdBy ? { createdBy } : {}),
      },
      orderBy: { createdAt: 'desc' },
      take: 50,
    });
    if (heads.length === 0) return { items: [] };

    const stats = await this.statsByAssignment(heads.map((h) => h.id));
    const names = await this.resolveNames(scope, heads.map((h) => h.createdBy));
    return {
      items: heads.map((h) => ({
        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(),
        ...(stats.get(h.id) ?? { planned: 0, inHand: 0, released: 0, agents: 0, done: 0 }),
      })),
    };
  }

  /** 单批全貌:按客服拆 + 退回原因分布 + 未动过的条数 */
  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 必然属于本批。
    const assignEvents = 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();
    const byAgent = new Map<string, AssignmentAgentStat>();
    const reasonCount = new Map<string, number>();
    for (const p of plans) {
      // 当前归属优先;已退回(assignee 为 null)则回落到账本里的原始承接人。
      // 两者都没有(理论上不该发生:批次分配一定写了 assign 事件)→ 归到兜底桶,
      // 宁可显示一个「未归属」也不要静默摊到某个真人头上。
      const key = p.assigneeUserId ?? originalOwner.get(p.id) ?? RELEASED_BUCKET;
      let a = byAgent.get(key);
      if (!a) {
        a = { userId: key, name: null, planned: 0, inHand: 0, released: 0, overdue: 0 };
        byAgent.set(key, a);
      }
      a.planned++;
      if (p.status === 'assigned') {
        a.inHand++;
        if (p.assignmentExpiresAt && p.assignmentExpiresAt < now) a.overdue++;
      }
      if (p.releaseReason) {
        a.released++;
        reasonCount.set(p.releaseReason, (reasonCount.get(p.releaseReason) ?? 0) + 1);
      }
    }

    // 「未动过」= 分下去后客服**从未打开过详情页**。
    // ⚠️ 判据只能用 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);
    const progress = {
      done,
      resolved: bucket.resolved,
      suppressed: bucket.suppressed,
      closed: bucket.closed,
      inHandPending: bucket.inHandPending,
      backToPool: bucket.backToPool,
      ageDays,
      // ⭐ 成品句子,助手照抄(T14 双保险的「工具返回值」那一半)
      note:
        `已处理 ${done} / ${plans.length}` +
        (bucket.resolved ? `,其中 ${bucket.resolved} 位患者的召回需求**已经不在池子里了**(引擎判定无活信号)` : '') +
        (bucket.suppressed ? `,${bucket.suppressed} 条客服写了回访结果(约下次/拒绝/放弃)` : '') +
        `;还在客服手上未处理 ${bucket.inHandPending} 条,退回或到期落回池子 ${bucket.backToPool} 条。` +
        `⚠️ 这是**处理率不是成功率** —— 只说"这单动过了",不说"谈成了"。` +
        `⚠️ 本批已经跑了 ${ageDays} 天,**跟别的批次比之前先看年龄** —— ` +
        `跑了三个月的批次天然比跑了三天的好看,直接比是耍流氓。`,
    };

    const names = await this.resolveNames(scope, [head.createdBy, ...byAgent.keys()]);
    for (const a of byAgent.values()) a.name = names.get(a.userId) ?? 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(),
      planned: plans.length,
      progress,
      done: progress.done,
      inHand: plans.filter((p) => p.status === 'assigned').length,
      released: plans.filter((p) => p.releaseReason != null).length,
      agents: [...byAgent.keys()].filter((k) => k !== RELEASED_BUCKET).length,
      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,
    };
  }

  /** 一次 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;
  }

  /**
   * 撤销整批 —— 把还没被动过的单收回池子。
   *
   * ⚠️⚠️ **撤销 ≠ 退回**(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,
          })),
        );
      }
      // ⭐ 批次撤销 → 福利不再适用。**收回的和没收回的都要作废**:
      //   没收回的那些(客服已打开过)手里正拿着一份写着福利的话术,
      //   而这个福利刚刚被撤销了 —— 那才是最危险的一批,因为他马上就要打电话。
      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';
  }
}

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 修过的同一类坑,只是那次是纯日期。
 */
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}`);
}
