import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
  DAILY_CALLS_PER_AGENT,
  AssignStrategy,
  type AgentInfo,
  type AssignmentProposal,
  type ProposedItem,
  type Signal,
  type TemperatureValue,
} from '@pac/types';
import { calcAge } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import { AgentRosterService } from './agent-roster.service';
import { assertCohortCriteria, cohortWhereSql, type CohortCriteria } from './cohort-filter';
// ⭐ 引导节点的确定性判定 —— 纯函数，输入一份提案、输出整套节点（见该文件顶部注释）
import { computeNarrowSignal, computeSignals } from './assignment-signals';
import { CohortAttributesService } from './cohort-attributes.service';

/**
 * AssignmentProposalService —— 「1,189 人的格子 → 一批 100 人 → 落到 N 个客服头上」。
 *
 * 这是助手出确认单的取数与算法层。**纯只读**(T8:唯一的写动作是主管点确认)。
 *
 * ═══ 两个正交的决策,别混成一件事 ═══════════════════════════
 *   ① 选谁进这批(收敛)   ← 排序键的事
 *   ② 这批怎么落到客服头上 ← 专属优先 / 溢出铺平的事
 * 「优先级排序」只解决 ①,不解决 ②。
 */

/**
 * ① 收敛排序键 —— 三层,每层都能用一句话向主管解释。
 *
 * ```
 * (assignment_id IS NULL) DESC   从没进过任何批次的优先
 * priority_score          DESC   层内沿用现成的分,不自研公式
 * patient_id              ASC    全序兜底
 * ```
 *
 * **首键为什么必须有**:退回的高分单回池后分数几乎不变(freshness 降,但 daysSince 增大
 * 反而可能推高急迫性),它会立刻回到下一批的头部**反复插队**,300 名开外的人永远轮不到。
 * 这不是"还没轮到",是被插队插到死。生产实证:现存 57 个认领单里 79% 已过时限 ——
 * "打了没结果然后挂着"是常态不是例外,这批一旦回池就是稳定的插队源。
 * `assignment_id` 在退回时**不清空**(见 plan.service.recycle),所以它天然就是
 * 「这条从没进过任何批次」的标记 —— **零新列**。
 * 一句话解释:「先给还没联系过的人排队;联系过一轮还没成的,等下一圈。」
 *
 * **第三层不是装饰**:filling 格 1,189 人只有 306 个不同取值(均值 3.9 人同分),
 * 第 100 名撞并列是**必然事件**;而 PG 不承诺没有完整 ORDER BY 时的行序。
 * 被问「她俩分一样,凭什么她进我不进」时必须答得上来。
 * (现有列表排序 `plan.service` 也已经是复合键,同一个问题在分页上踩过。)
 *
 * ⚠️ **退化点**:跑满一整圈后(1,189 / 100 ≈ 12 批)所有人 assignment_id 都非空,
 * 首键恒 false,排序退化成纯 score,插队问题原样回来。届时需要第二圈机制
 * (按 `plan_assignments.created_at` 最早优先);⛔ 不要用 `assignedAt` —— 退回和到期都会清空它。
 * 那是 12 批之后的事,那时已有足够样本用数据决定,现在设计属于过度设计。
 *
 * ⚠️ **不加「专属客服可用性」分层**(重要,且违直觉):85.1% 的池子有在岗专属,
 * 一旦拿它当首键,批次会近乎 100% 是 dedicated,于是 T20 点名要反推的
 * 「专属 vs 铺平完成率差」**永远凑不满样本** —— 用一个未验证的假设去排序,
 * 而这个排序保证了该假设无法被验证。第一批必须混合,才有东西可反推。
 * 这条已经产品确认,不要"顺手优化"掉。
 */
const SELECTION_ORDER = Prisma.sql`
  ORDER BY (fp.assignment_id IS NULL) DESC, fp.priority_score DESC, fp.patient_id ASC
`;

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly roster: AgentRosterService,
    /// 画像亮点要比两份分布（这一格 vs 同治疗项池子），复用它的取数（⛔ 别另写一套，T6a 同源）
    private readonly cohorts: CohortAttributesService,
  ) {}

  /**
   * 算「还能再筛一刀」那一条。
   *
   * 🔴 2026-08-13 换掉了原来的「画像亮点」。旧版要**两次** `dimShares`
   *   （这一格 + 同治疗项整个池子），拿倍数/百分点判断"值不值得注意"。
   *   现在**一次查询**、不比基线 —— 判断"值不值得"是主管的活，程序只管把刀摆出来。
   * ⚠️ 失败不许让整张确认单挂掉：这一条是锦上添花，取不到就不出。
   */
  private async narrowSignal(
    scope: TenantScopeContext,
    criteria: CohortCriteria,
    now: Date,
    /** 这一版是「重新排一版」出来的 —— 出不出由 computeNarrowSignal 判（见那边的 🔴） */
    repicked: boolean,
  ): Promise<Signal[]> {
    try {
      const facts = await this.cohorts.narrowFacts(scope, criteria, now);
      const sig = computeNarrowSignal(
        facts,
        {
          ...(criteria.personaTags ? { personaTags: criteria.personaTags } : {}),
          ...(criteria.minSpendYuan ? { minSpendYuan: criteria.minSpendYuan } : {}),
        },
        repicked,
      );
      /**
       * ⭐ 这行**必须留**：没有它，「算了但一条选项都没够着」和「压根没算」
       *   在日志里长得一模一样。实测吃过这个亏 —— 页面上没按钮，查日志一片安静，
       *   分不清是逻辑没跑还是数据本来就不够（2026-08-12）。
       */
      this.logger.log(
        `再筛一刀:候选 ${facts.size} 人` +
          (facts.money ? `,消费均值 ¥${facts.money.avgYuan}(高于它 ${facts.money.above} 人)` : '') +
          ` → ${sig ? `${sig.options.length} 个选项(${sig.options.map((o) => o.label).join(' | ')})` : '不出这一条'}`,
      );
      return sig ? [sig] : [];
    } catch (e) {
      this.logger.warn(`再筛一刀算失败(不影响确认单):${e instanceof Error ? e.message : e}`);
      return [];
    }
  }

  async propose(
    scope: TenantScopeContext,
    input: {
      clinicId: string;
      /** 画像「潜在治疗」8 类之一(矩阵 X 轴);不传 = 不按治疗项收窄 */
      potentialTreatment?: string;
      /** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */
      temperature?: TemperatureValue;
      /** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */
      personaTags?: string;
      /** 只要累计净消费高于这个数(元)的人 —— 「还能再筛一刀」里那条消费选项 */
      minSpendYuan?: number;
      /** 批次时限天数(基数②);不传 = 沿用上一次分配的值 */
      expiresInDays?: number;
      /**
       * 「每人每天打几通」的换算尺 —— 只用来估 N（在岗 × 它 × 时限）。
       *
       * ⭐ 2026-08-13 加：引导节点上主管填一个数（比如 8），要重出一版。
       *   ⛔ **不让模型自己乘** —— 它手里有"在岗 33 人""时限 1 天",
       *   但报出去的每一个数都必须来自工具计算（T14）。这里由服务端算。
       * ⚠️ 它**不是容量上限**、⛔ 不参与落人，和 DAILY_CALLS_PER_AGENT 同性质。
       */
      dailyCalls?: number;
      /**
       * 按客服的**精调**(userId → 覆盖值)。
       * 「李莉这周带教,这批最多给 5 条」「王强那批给 7 天」。
       * ⚠️ 与基数一样会被沿用(主管的习惯),所以下发时逐人标 `overridden`,
       *   让一条三个月前的临时精调不至于永远静默生效。
       */
      agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
      /**
       * 本批人数 N(基数①);不传 = 沿用上一次分配的值。
       * ⭐ 与时限同为基数 —— 主管说「这批 200 人」会被记住,下次默认就是 200。
       */
      targetCount?: number;
      /** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */
      exploreRatio?: number;
      /**
       * 🔴 **尽量排满**(2026-08-06 产品定)—— 主管点「重新排一版」时传 true。
       *
       * 默认(false):严格按排序键取前 N,专属客服排满的那些人进「待分配」等他决定。
       * 打开后:**遇到专属排满的就跳过,继续往后取**,直到凑够 N 或池子取完。
       *
       * ⭐ 产品的原话:「优先级不重要,重要的是专属客服的患者不能擅自排给其他客服」——
       *   这一批人本来就共享同一组特征(矩阵格子 + 画像条件),批内名次对主管没有意义。
       * ⚠️ 代价是这批人整体排名会往后走,但**一条专属关系都没动**,这正是要的。
       */
      preferPlaceable?: boolean;
    },
    now: Date = new Date(),
  ): Promise<AssignmentProposal> {
    /**
     * 🔴 **越权闸放在 service 里,⛔ 不是放在 controller**(2026-08-10 实测)。
     *
     * 同一天先补了 `/agents`、`/workload`、`/plans/matrix` 三个**query 参数**的读接口,
     * 结果漏了这条 —— `propose/refill` 的 clinicId 在 **body** 里,按 `@Query('clinicId')`
     * 去数根本数不到。实测:朝阳公园的主管带杭州大厦的 id POST 过来,
     * 拿回了**那家诊所患者的真实姓名**(比名册更重)。
     *
     * ⇒ 提案是**唯一**会吐患者名单的读路径,闸必须钉在这一层:
     *   谁调都拦得住(REST / MCP / 以后任何新入口),⛔ 不指望每个 controller 记得加。
     * ⚠️ 幂等:MCP 那边已经 resolve 过一次,合法 id 原样返回,再过一次没有副作用。
     */
    const clinicId = resolveClinicId(scope, input.clinicId);
    const { potentialTreatment } = input;
    const criteria: CohortCriteria = {
      clinicId,
      ...(potentialTreatment ? { potentialTreatment } : {}),
      ...(input.temperature ? { temperature: input.temperature } : {}),
      ...(input.personaTags ? { personaTags: input.personaTags } : {}),
      ...(input.minSpendYuan ? { minSpendYuan: input.minSpendYuan } : {}),
    };
    assertCohortCriteria(criteria);
    const [roster, baseline] = await Promise.all([
      this.roster.list(scope, clinicId),
      this.resolveBaseline(scope, clinicId),
    ]);
    const agents = roster.agents;

    // ── 两个基数:本批人数 N + 时限 ──────────────────────────
    // 优先级:本次明确指定 > 沿用上一次 > 首次默认。
    // ⭐ `basis` 要跟着值一起下发 —— 主管看到「本批 100 人」时,"这 100 哪来的"和这个数一样重要。
    //
    // ⛔ **没有"容量"这个数了**(2026-08-03 第二次改判)。它当过一阵上限并用来推批次规模,
    //   结果是**第二批必然为 0**:第一批把所有人填到水位,再算就是 Σ(容量−容量)。
    //   主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
    //   现在:N 决定推多少,水位法决定给谁,负载由 `inHand` 本身表达,不设阈值。
    /**
     * 时限 D —— 本次指定 > 默认。⚠️ 必须**先算它**,因为 N 要用到（见下）。
     * 🔴 2026-08-12 起**不再沿用上一次**（产品定）。
     */
    const expiresInDays =
      input.expiresInDays != null && input.expiresInDays > 0
        ? Math.floor(input.expiresInDays)
        : ASSIGNMENT_EXPIRES_DAYS_DEFAULT;
    /**
     * 基数 N —— 本次主管指定 > 默认 `在岗人数 × 每人每日 15 × D`。
     *
     * 🔴 2026-08-12 改判（产品定）：
     *   ① **不再沿用上一次**。原设计是"调一次省一次"，但它让 N 悄悄漂 ——
     *      某次为了一个小格子调成 44，此后所有批次就永远是 44，主管看不出为什么变小了。
     *   ② **N 与时限挂钩**：一批推多少，取决于这批要在几天内打完。
     *      D 从 3 天改成 5 天，能承接的量本来就该跟着变。
     * ⚠️ 这里的 15（`DAILY_CALLS_PER_AGENT`）**仍然不是容量上限**，⛔ 不参与落人 ——
     *   落人只有水位法，没有阈值（"容量"被删过一次，理由见 BATCH_SIZE_PER_AGENT_FIRST）。
     *   它只回答"一批推多大"，以及作为 `daily_overload` 引导节点的判定尺。
     * ⚠️ 沿用机制本身留着（`resolveBaseline` 仍在读），只是不再喂给 N ——
     *   产品说的是"先不用取上一次"，要开回来只需把 `baseline.batchSize` 接回这里。
     */
    /// 换算尺:主管改过就用他的,否则 15。⚠️ 夹在 1–60,越界的数不该走到乘法里。
    const dailyCalls =
      input.dailyCalls != null
        ? Math.min(60, Math.max(1, Math.floor(input.dailyCalls)))
        : DAILY_CALLS_PER_AGENT;
    const batchSize =
      input.targetCount != null
        ? Math.max(0, Math.floor(input.targetCount))
        : agents.length * dailyCalls * expiresInDays;
    const basis: 'inherited' | 'default' | 'explicit' =
      input.targetCount != null || input.expiresInDays != null || input.dailyCalls != null
        ? 'explicit'
        : 'default';
    // 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢);空对象 = 撤掉他的精调
    const agentOverrides = mergeOverrides(baseline.agentOverrides, input.agentOverrides);
    /// 本批给某人的**名额上限**(没精调 = 不限)。⛔ 只此一处合并,别在 placeAgents / byAgent 各合一遍
    const capOf = (userId: string) => agentOverrides[userId]?.maxThisBatch ?? Infinity;
    const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays;
    const inHandTotal = agents.reduce((a, g) => a + g.inHand, 0);
    if (batchSize === 0 || agents.length === 0) {
      return emptyProposal(clinicId, potentialTreatment, input.temperature, agents, roster.rosterNote, {
        target: 0,
        batchSize,
        expiresInDays,
        basis,
        basisFrom: baseline.from,
        agentOverrides,
      });
    }

    // ── ① 收敛:按三层键取前 N ────────────────────────────────
    // ⭐ 多取一些(1.5 倍)给去重和探索配额留余量;不取全量是因为大格子上万条。
    //
    // 🔴 **候选总数必须单独 count,不能拿这次取回的行数充数**。
    //   取回的行数上限就是这个 LIMIT(默认 170),于是 1,080 人的格子会被报成
    //   「从 170 位候选里取前 100 人」—— 而这句是**要求助手原话转述**的话(T14),
    //   主管刚在矩阵上看过 1,080,当场就对不上。这个数**只能来自 count**。
    //   ⚠️ 用 `count(DISTINCT patient_id)`,与矩阵格子同一种数法 —— 换成 count(*)
    //   就变成「按 plan 数」,同一个人两条召回会被数两次,又对不上了。
    // ⭐ 取数上限按**基数**算(不是按 target)—— target 还没算出来:它是
    //   `min(基数, 候选总数)`,而候选总数正是下面这次 count 的结果。
    //   按基数取一定够(target ≤ 基数),这样 count 与取明细还能并发,不多一个来回。
    /**
     * ⚠️ **尽量排满时窗口必须放大**(2026-08-06 实测的 bug)。
     *   默认窗口是 `1.5×N + 20`(N=50 → 95 行)。而"可顶替"的无主患者是从这个窗口里找的 ——
     *   实测池子里有 17 位无主,窗口里只落进 3 位,另外 14 位排在 95 名之外**根本没被取回**,
     *   界面于是只敢说「用 3 位顶替」。⇒ 候选来源被取数窗口限死了,不是池子。
     * ⭐ 打开时取到 `4×N + 100`(仍受候选总数封顶)—— 跳过一批排满的之后还够凑 N。
     */
    const fetchLimit = input.preferPlaceable
      ? Math.ceil(batchSize * 4) + 100
      : Math.ceil(batchSize * 1.5) + 20;
    const [counts, pool, ownership] = await Promise.all([
      this.countCandidates(scope, criteria, now),
      this.selectCandidates(scope, criteria, fetchLimit, now),
      // ⚠️ 只多一次聚合查询,与上面两次并发 —— 不多一个来回
      this.poolOwnership(scope, criteria, agents, now),
    ]);
    const candidateTotal = counts.total;
    // ⭐ **每一版提案都记下"按什么条件、圈出多少人"** —— 这不是调试日志,是这条生产线的黑匣子。
    //   主管连着说三句话就会出三版确认单,人数一版一个样时,唯一能分清
    //   「是条件变了」还是「是取数飘了」的证据就是这行(2026-08-06 排查 12→17→13 用的就是它)。
    this.logger.log(
      `提案:条件=${JSON.stringify(criteria)} 候选=${candidateTotal} 基数=${batchSize}`,
    );
    /**
     * 本批**实际**分多少 = `min(基数 N, 候选总数)`。
     *
     * ⚠️ 候选不够时压低的是 `target`,⛔ **不回写基数** —— 那是这一批的偶然事实。
     *   回写的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人,
     *   而他完全看不出为什么变小了(界面上只会显示"沿用上次")。
     */
    const target = Math.min(batchSize, candidateTotal);

    // 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用)
    const seenPatient = new Set<string>();
    const ranked = pool.filter((c) => {
      if (seenPatient.has(c.patientId)) return false;
      seenPatient.add(c.patientId);
      return true;
    });

    // ── 探索配额 ────────────────────────────────────────────
    // 从**排名之外**抽,不是从头部抽 —— 抽头部等于没探索。
    // ⚠️ 「排名之外」的范围只有**取回的这 1.5 倍窗口**(第 rankN…fetchLimit 名),
    //   不是整个候选池 —— 1,080 人的格子里第 500 名永远轮不到被探索。
    //   要全池探索得另取一次样本;现在这样至少是确定性的,先不扩。
    const ratio = Math.min(0.2, Math.max(0, input.exploreRatio ?? 0));
    const exploreN = Math.floor(target * ratio);
    const rankN = Math.max(0, target - exploreN);
    const head = ranked.slice(0, rankN).map((c) => ({ ...c, selectionMode: 'rank' as const }));
    const tailPool = ranked.slice(rankN);
    // ⚠️ 确定性抽样:按 patientId 的字典序等距取,不用随机数 ——
    //   主管微调后会重算,随机会让人数和名单无故跳动,他会以为系统在乱来。
    const explore = pickEvenly(tailPool, exploreN).map((c) => ({
      ...c,
      selectionMode: 'explore' as const,
    }));
    // ⚠️ 专属客服表**作参数传下去**,不挂实例字段:service 是单例,
    //   两个主管同时出确认单会互相冲掉对方的缓存,而且是**静默**串数据。
    //   (本仓已有 ingest-resolver-no-instance-state.spec 在防同一类错。)
    /// ⭐ 身份 + 专属一次性取**整个取回窗口**,不只取 chosen —— 重挑这一批时要看得见全池
    const { dedicated, info } = await this.patientsOf(ranked.map((c) => c.patientId));
    const agentById0 = new Map(agents.map((a) => [a.userId, a]));

    /**
     * 🔴🔴 **「重新排一版」= 重挑这 N 个人,⛔ 不是改落人规则**(2026-08-06 产品定,推翻当天两版)。
     *
     * ── 前两版都错在同一个地方:去动落人 ──────────────────────────
     * ① 第一版预先下发「顶替名单」:名单只能从取数窗口里挑,窗口比池子小,
     *    池子 19 位无主、窗口里只落进 6 位 → 界面只敢说"顶 3 位"。
     * ② 第二版把整个窗口丢给落人 + `stopAt` 跳过:水位是按 `chosen.length` 算的,
     *    于是被窗口撑大(62 → 69),那批"专属排满"的人原地进了**同一位客服**手里
     *    (她 59 → 68,别人 58)。底线没破,但团队负载塌了,而且没人说得清 68 是怎么来的。
     *
     * ── 这一版:只换"挑谁",落人一个字不改 ──────────────────────
     * 把池子里**所有无主的**都换进这一批,不够的名额再按优先级用有专属的补满 N。
     * 然后走**完全一样**的三趟:专属回自己人 → 无主补空 → 排不进的进待分配。
     *   实测形状:50 人 = 19 位无主 + 31 位有专属 → 拟分 31 · 待分配 19
     *   (原来是 拟分 16 · 待分配 34 —— 排好的翻了一倍,而水位、卡片、底线全没动)
     *
     * ⚠️ 有专属的那部分**按客服轮着取**,⛔ 不能直接取前 31 名:
     *   池子里 111/165 属同一位客服,按名次取会把 31 个名额全给她,
     *   而她只剩 3 条余量 —— 另外两位客服明明还能各接 3 条和 6 条,却一个人都没进这批。
     * ⚠️ 排名在这里**只是默认顺序**,不是要保的东西(产品原话:「为什么要纠结排名」)。
     */
    const chosen = input.preferPlaceable
      ? repickPlaceable(ranked, dedicated, new Set(agents.map((a) => a.userId)), target)
      : [...head, ...explore];

    // ── ② 落人:专属优先 → 无主补空 → 待分配(两种模式**同一套**)────────
    const raw = placeAgents(chosen, agents, dedicated, capOf);
    // ⭐ 身份(姓名/病历号)在**落人之后**贴上 —— placeAgents 是纯算法,
    //   让它认识"患者叫什么"只会让它更难测,而对分法没有任何影响。
    /// 贴身份的公共部分 —— 已落人的和待分配的用**同一份**,⛔ 别各贴各的(会漂)
    const withIdentity = <T extends { patientId: string }>(it: T) => {
      const p = info.get(it.patientId);
      return {
        ...it,
        patientName: p?.name ?? null,
        medicalRecordNumber: p?.mrn ?? null,
        gender: p?.gender ?? null,
        age: p?.age ?? null,
        attendingDoctor: p?.preferredDoctor ?? null,
        lastVisitDoctor: p?.lastVisitDoctor ?? null,
      };
    };
    const items = {
      unplaced: raw.unplaced,
      placed: raw.placed.map((it) => {
        const p = info.get(it.patientId);
        return {
          ...it,
          patientName: p?.name ?? null,
          medicalRecordNumber: p?.mrn ?? null,
          gender: p?.gender ?? null,
          age: p?.age ?? null,
          // ⭐ 两个医生都读 **patient_profiles 现成的列**,⛔ 不在这里从 facts 现算:
          //   `lastVisitDoctor` = 上次到诊当天那份病历的医生(与 lastVisitAt **同一次就诊**,
          //     跨天不认 —— 否则"日期是A次、医生是B次",客服照着说会露馅);
          //   `preferredDoctor` = 病历里出现频次最高的医生(当前充当"主治/偏好医生")。
          //   列表页(plan.service)读的就是同两列,确认单与列表因此天然同源。
          attendingDoctor: p?.preferredDoctor ?? null,
          lastVisitDoctor: p?.lastVisitDoctor ?? null,
        };
      }),
    };

    /**
     * 待分配 —— 贴身份 + 贴**专属客服的姓名和在手量**。
     * ⚠️ 姓名和在手量是主管做判断的依据(「张三这轮排满了(在手 56),这个人要不要先给别人」),
     *   ⛔ 只给 userId 等于让他对着 id 猜。
     */
    const pending = raw.pending.map((it) => {
      const owner = agentById0.get(it.ownerUserId);
      return {
        ...withIdentity(it),
        ownerUserId: it.ownerUserId,
        ownerName: owner?.name ?? null,
        ownerInHand: owner?.inHand ?? 0,
      };
    });

    /**
     * 🔴 **可顶替待分配的候选** —— 取回窗口里**排在 target 之后、且没有在岗专属**的人。
     *
     * ⚠️ 「没有在岗专属」的判据必须与 placeAgents 的 `ownerOf` **完全一致**
     *   (专属不在名册内 = 视同无主),⛔ 否则这里给出的候选换进去照样会卡住。
     * ⚠️ 只取 `pending.length` 个:不够就有几个顶几个(产品:"不够也可以凑"),
     *   ⛔ 别因为不够就整个不给。
     * ⚠️ 带上 `rank`(在候选池里的名次)—— 「换进来的是第几名」是主管判断值不值的唯一依据。
     */
    /**
     * 🔴 **可以重新排一版吗** —— 池子里还有没有"专属没排满 / 无主"的人可用。
     *
     * ⚠️ 这里**只回答能不能**,⛔ 不预先算出"顶替名单":
     *   原来的做法是从取回窗口里挑无主患者当候选,实测踩了坑 ——
     *   池子有 17 位无主,窗口(1.5×N+20=95 行)里只落进 3 位,界面于是只敢说
     *   「用 3 位顶替」,而主管明明看到"17 人无主"。**候选来源被取数窗口限死了**。
     *   ⇒ 现在改成:主管点一下 → **重新出一版**(preferPlaceable),那一版取数窗口更大、
     *   遇到排满的直接跳过,天然就能凑满,也不用再维护一份"候选名单"。
     */
    const canRefill = raw.pending.length > 0 && ownership.free > 0;

    const byAgent = new Map<string, ProposedItem[]>();
    for (const it of items.placed) {
      const arr = byAgent.get(it.assigneeUserId) ?? [];
      arr.push(it);
      byAgent.set(it.assigneeUserId, arr);
    }
    const agentById = agentById0;

    /**
     * `refillNote` 要用的三个数 —— 「这批换成了谁、卡住的人手上多满」。
     * ⚠️ `suggestWaterline` 必须与 placeAgents 里那条**同一个算式**(在手合计 + 本批 N / 在岗人数);
     *   两边各算各的必然漂,而漂了不报错 —— 主管看到的就是"卡片说 62、名单里 69"。
     */
    const suggestWaterline =
      agents.length > 0 ? Math.max(1, Math.ceil((inHandTotal + target) / agents.length)) : 0;
    const allAfter = [...byAgent].map(([u, l]) => (agentById.get(u)?.inHand ?? 0) + l.length);
    /// ⚠️ 空数组时 `Math.min(...[])` 是 `Infinity`,会印进主管眼里 —— 兜住
    const minAfter = allAfter.length > 0 ? Math.min(...allAfter) : 0;
    /// 这一批里有几位是无主的 —— 重排的全部意义就是"把他们都换进来了"
    const freeInBatch = items.placed.filter(
      (it) => it.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED,
    ).length;

    /**
     * ⛔ 这里原来算一段「运营结论」(opsNote):每人拿到几条、分完手上几条、
     *   按每天 15 通算最满的要几天、谁拿到的全是自己的老客户。
     *
     * 🔴 2026-08-13 整块删掉 —— 同一批数现在有**三个**出口，卡片那份是第三遍：
     *   ① 模型的正文（modelFacts 的「本批每人拿到 / 分完之后每人手上」）;
     *   ② 引导节点 `batch_size_basis`（这批多大 = 在岗 × 每天几通 × 时限，可改）;
     *   ③ 引导节点 `daily_overload`（最忙那位手上共几条、约几天的量）。
     * ⛔ 别再加回来:卡片上那个位置现在放**只有卡片能做的事**（拖、点 ×、逐条改时限），
     *   那些是模型说不了、也做不成引导节点的。
     */

    /**
     * ⭐ 先组装事实，再由 `computeSignals` 从这份事实里算引导节点。
     *
     * ⚠️ 顺序不能反、也不能各算各的：节点用的每个数（待分配几人、谁分完最重、
     *   候选够不够）都必须**来自这一份提案**，⛔ 不许再查一次 —— 否则卡片上的数
     *   和节点里的数会对不上，而两个数字都"对"是最难查的那种 bug（T6a 同源）。
     */
    const proposal: Omit<AssignmentProposal, 'signals'> = {
      clinicId,
      potentialTreatment: potentialTreatment ?? null,
      // ⭐ 必须下发 —— 前端确认时要把它写进 criteria 快照,否则批次说不清
      //   「当时按哪个温度圈的」(矩阵 8×3,同一治疗项三档是三批完全不同的人)
      temperature: input.temperature ?? null,
      /// ⭐ 主管自己追加的条件 —— 引导节点靠它认出「这一版是收窄来的」(见 schema 那段)
      narrowedBy:
        criteria.personaTags || criteria.minSpendYuan
          ? {
              ...(criteria.personaTags ? { personaTags: criteria.personaTags } : {}),
              ...(criteria.minSpendYuan ? { minSpendYuan: criteria.minSpendYuan } : {}),
            }
          : null,
      /// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过
      candidateTotal,
      target,
      /// ⭐ 要被记住的是**基数**不是 target —— 候选不够时 target 被压低,那是一批的偶然事实
      batchSize,
      /// ⭐ 乘法里的那个在岗人数,跟着 batchSize 一起下发 —— ⛔ 别在显示侧另找一个数冒充
      rosterCount: agents.length,
      /// ⭐ 同理:换算尺也要跟着发,否则主管改过 20 通、显示侧还按 15 算(见 schema 上那段)
      dailyCalls,
      expiresInDays,
      basis,
      basisFrom: baseline.from,
      placed: items.placed.length,
      unplaced: items.unplaced,
      items: items.placed,
      pending,
      canRefill,
      poolOwnership: raw.pending.length > 0 ? ownership : null,
      byAgent: [...byAgent].map(([userId, list]) => ({
        userId,
        name: agentById.get(userId)?.name ?? null,
        inHandBefore: agentById.get(userId)?.inHand ?? 0,
        count: list.length,
        dedicated: list.filter((x) => x.assignStrategy === AssignStrategy.DEDICATED).length,
        spread: list.filter((x) => x.assignStrategy !== AssignStrategy.DEDICATED).length,
        /// ⭐ 分配后他手上有多少 —— 「负载」的唯一表达(没有阈值线,主管自己看这列齐不齐)
        loadAfter: (agentById.get(userId)?.inHand ?? 0) + list.length,
        /// 逐人下发生效时限 —— 前端直接显示,⛔ 不让它自己再合并一次(合并逻辑写两遍必然漂)
        expiresInDays: expOf(userId),
        overridden: agentOverrides[userId] != null,
      })),
      agentOverrides,
      // 本批一条没分到的人**仍然列出来**:主管要看见"他不是被漏了"。
      // ⚠️ 语义已变:不是"满了"(没有上限这回事了),是**水位法没轮到他** —— 他手上本来就最多。
      skippedAgents: agents
        .filter((a) => !byAgent.has(a.userId))
        .map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
      rosterNote: roster.rosterNote,
      /**
       * 「**怎么选**」—— ⭐ 说给主管听,不是说给程序员听(2026-08-03 走查)。
       *
       * ⛔ 不许出现「排序键 / 三层键 / 患者号 / 收敛 / 探索配额」这类内部词 ——
       *   主管问的是"凭什么是这 340 个人",不是我们的实现方式。
       * ⚠️ **候选不够时不能说"取前 N 人"**:主管从矩阵点了一个 44 人的格子进来,
       *   句子却说「取前 340 人」,他会以为有 296 个人被系统丢了(T14)。
       * ⚠️ 池子里还剩多少必须说 —— 否则他不知道"还能不能再分一批"(答案是:能)。
       */
      selectionNote:
        /**
         * ⭐ **一句一件事,数字必须自洽**(2026-08-06 走查重排)。
         *
         * 原来是一大段散文,九个数字混在里面,而且「这批挑了 50 人」和下一段
         * 「10 人分给 6 位客服」看起来直接打架 —— 主管第一反应是"到底分了几个"。
         * ⇒ 用一个**等式**把它钉死:`50 人 = 已排好 10 + 待您定 40`,矛盾就不存在了。
         * ⛔ 别再把选人口径、待分配解释、操作指引写进同一段。
         */
        `**这批 ${target} 人**\n` +
        (candidateTotal <= target
          ? `符合条件的一共就 ${candidateTotal} 人,**全要了**,没有取舍。`
          : `从符合条件的 ${candidateTotal} 人里挑,` +
            // ⚠️ 「没被分过的排在前面」**只在池子里真有回池的人时才说**(2026-08-03 走查)。
            //   池子的基线已经排除了"还在客服手上"的单,所以绝大多数时候池子里 100% 都没分过 ——
            //   这时说这句是废话,还会让主管以为系统在防什么根本不存在的情况。
            //   ⛔ 但也不能永远不说:分过又退回的人**确实还在池子里**(T7:退回是正常路径,
            //   不能因为分过一次就永不再召),他们排在后面,主管有权知道。
            (counts.repeat > 0
              ? `之前分过又退回的 ${counts.repeat} 人排在后面,其余按优先级高的排前面。`
              : `优先级高的排在前面。`) +
            // ⚠️「排队」是内部说法(池子里并没有队);说"还没轮到"主管一听就懂
            `剩下 ${candidateTotal - target} 人这轮还没轮到,随时能再分一批。`) +
        (exploreN > 0
          ? `另外有 ${exploreN} 人是特意从排名靠后的位置抽的 —— 用来日后检验"排在前面的是不是真的更容易成"。`
          : '') +
        (items.unplaced > 0 ? `⚠️ 有 ${items.unplaced} 人没能分下去。` : '') +
        /**
         * 🔴 「待分配」必须**说出来**,而且要说清"我为什么没分" (2026-08-06 产品定)。
         *
         * 这一段是整个改动的目的:助手不再偷偷把患者从专属客服手里挪走,
         * 但**不说等于藏**。主管看到「拟分 8 人」而不知道另有 12 人卡在这儿,
         * 结果就是这 12 个人谁也没管 —— 比原来自动改派还糟。
         * ⛔ 所以这句不许省、不许弱化成"另有若干"。
         */
        /**
         * ⭐ **每段用一个加粗的"数字 + 是什么"起头**(2026-08-06 走查再排)。
         *
         * 主管扫这段话时眼睛先找的是**数字**。原来数字埋在句子中间,四段长得一模一样,
         * 他得逐字读完才知道哪段跟自己有关。⇒ 每段第一行只放「**这批 50 人**」
         * 「**⚠️ 其中 40 人要您定**」「**已排好 10 人 · 6 位客服**」,解释放第二行。
         * ⚠️ 顺序按**要不要他动手**排:要拍板的那段排在"怎么分的"前面。
         * ⛔ 别用 ## 标题:400px 的面板里 h2/h3 太重,一段话撑成半屏。
         */
        '',
      /**
       * 「待分配」单独成段 —— **排在"已排好"之后**(2026-08-06 产品定)。
       * 原来夹在选人口径里,主管先看到要他动手的事、再看这批长什么样,顺序是拧的。
       */
      pendingNote:
        pending.length > 0
          ? `**⚠️ 其中 ${pending.length} 人要您定**\n` +
            `他们的专属客服这轮排满了,我没替您把人挪给别人。` +
            `拖给谁、或者移出本批都行;**不动他们就是不分**。` +
            // ⭐ 「为什么会这样」—— 不说清楚,主管会以为是系统出错
            (ownership.topOwners.length
              ? `\n候选 ${ownership.withOwner + ownership.free} 人里 ${ownership.withOwner} 人有专属` +
                `(${ownership.topOwners.map((o) => `${o.name ?? o.userId} ${o.n} 人`).join('、')})` +
                `,${ownership.free} 人无专属。`
              : '') +
            // ⭐ 只说"能重新排一版",⛔ 不说名次、不说顶几个 ——
            //   产品:「排名其实不重要,重要的是运营策略(一批人有相同特征)」。
            (canRefill
              ? `\n卡片上有个「重新排一版」—— 我把池子里**无专属的都换进来**重挑这 ${target} 人,` +
                `能排好的会多一些,一条专属关系还是不动。要不要换由您点。`
              : '')
          : '',
      /**
       * 🔴 **重排交代** —— 见 schema 注释:重排走 HTTP 只换卡片,助手不会重新说一遍,
       *   ⇒ 换了什么必须由**卡片自己**说,否则上面那段旧话("34 人要您定")当场对不上。
       * ⚠️ 说的是**换了谁**,⛔ 不是"凑满了没有":这一版重排不改落人规则、不破水位,
       *   排不进的照样进待分配 —— 卡片形状跟平时一模一样,只是这批人换过了。
       */
      refillNote: !input.preferPlaceable
        ? ''
        // ⚠️ 中文标点**一律全角**（，；：（））—— 这段是直接摆给主管看的,
        //   和系统提示词里对模型的要求同一条规矩。半角混在中文里挤成一团,
        //   而句号又是全角,一行里宽窄不一,读起来发涩。
        : `**已重挑这 ${target} 人**：池子里 ${freeInBatch} 位无专属的全换进来了，` +
          `其余按优先级用有专属的补齐。\n` +
          `排好 ${items.placed.length} 人` +
          (pending.length > 0
            ? `，还有 ${pending.length} 人的专属客服确实排满了（他们手上已经 ` +
              `${minAfter}~${suggestWaterline} 条），得您定 —— 拖给谁或者移出本批。`
            : `，没有需要您定的了。`),
    };
    // ⚠️ 「再筛一刀」单独查库，⛔ 不放进 computeSignals —— 那个函数要保持纯。
    const narrow = await this.narrowSignal(scope, criteria, now, input.preferPlaceable === true);
    return { ...proposal, signals: computeSignals(proposal as AssignmentProposal, narrow) };
  }

  /**
   * 候选取数 —— 走原生 SQL 是因为排序键的首层 `(assignment_id IS NULL)` 是**表达式**,
   * Prisma 的 orderBy 表达不了。
   *
   * ⚠️ 过滤条件必须与召回池列表同 scope,否则"矩阵上说有 383 人、点进去列表 350 条"
   * 这种对不上的事一发生,主管对整个功能的信任就没了。
   */
  private async selectCandidates(
    scope: TenantScopeContext,
    criteria: CohortCriteria,
    limit: number,
    now: Date,
  ): Promise<Array<{ planId: string; patientId: string; priorityScore: number }>> {
    // ⭐ 人群条件走**共用**片段(cohort-filter),与 get_cohort_attributes 出的分布同源。
    //   两处各写一遍就会出现「属性分布说商保 32 人,提案只有 28 人」,主管当场不信。
    return this.prisma.$queryRaw<Array<{ planId: string; patientId: string; priorityScore: number }>>(
      Prisma.sql`
        SELECT fp.id AS "planId", fp.patient_id AS "patientId", fp.priority_score AS "priorityScore"
        FROM followup_plans fp
        JOIN patients p ON p.id = fp.patient_id
        WHERE ${cohortWhereSql(scope, criteria, now)}
        ${SELECTION_ORDER}
        LIMIT ${limit}
      `,
    );
  }

  /**
   * 🔴 **点格子之后、交给助手之前**那张中间表 —— 这一格圈定的**全部**患者,分页给。
   *
   * ⭐ 存在的理由:主管点的是一个数字(「充填 · 3 年以上 4,159」),而他要为这批人负责。
   *   让他先看见"这 4,159 个人是谁"再决定交不交,比事后在确认单上补救便宜得多。
   *
   * 🔴 **必须与 `selectCandidates` 逐字同源**:同一个 `cohortWhereSql` + 同一个
   *   `SELECTION_ORDER`。⛔ 别在这里另写一份"差不多"的查询 ——
   *   排序一旦不同,主管在第 1 页看到的人 ≠ 助手真会挑走的前 N 个,
   *   而这件事**不会报错**(两张表各自都"对")。
   *
   * ⚠️ **分页用 offset,不用游标**(与列表页那条纪律相反,这里是刻意的):
   *   ① 这是一个**静态候选集**的浏览(主管操作的几分钟里池子基本不动),
   *      不是"一直在新增"的时间流 —— 游标那条纪律防的是后者;
   *   ② 主管要的是"第 3 / 84 页"这种可回跳的翻页,游标给不了总页数;
   *   ③ 排序键是确定性的(assignment_id IS NULL → priority_score → patient_id),
   *      同一 offset 两次取回同一批人。
   *   ⚠️ 代价:极深的翻页(几千页)offset 会变慢 —— 现实里最大的格子 4 千人 / 每页 50 = 84 页,
   *      够用;真涨到十万级再换 keyset。
   */
  async cohortPage(
    scope: TenantScopeContext,
    input: {
      clinicId: string;
      potentialTreatment?: string;
      temperature?: TemperatureValue;
      personaTags?: string;
      minSpendYuan?: number;
      /**
       * 🔴 **一个搜索框打通五个字段**(2026-08-11 产品定,原来是"搜索框 + 医生下拉 + 客服下拉"三件)。
       *   命中任一即可:患者姓名 / 病历号 / 主治医生 / 上次就诊医生 / 专属客服名。
       *
       * ⚠️ 这是**看**的条件,⛔ 不是圈人条件 —— 只收窄这张表显示什么,
       *   交给助手的仍然是整格的人(`total`)。这条区分必须在界面上说清楚
       *   (见 CohortPreview 的按钮文案):主管筛到 12 人然后点确定、结果分下去 420 人,
       *   是这功能最容易出的误解。
       * ⚠️ 医生**两列都匹配**(主治 / 上次接诊):主管说"薛玫的患者"时不分这两者。
       */
      keyword?: string;
      page?: number;
      pageSize?: number;
    },
    now: Date = new Date(),
  ) {
    // 🔴 越权闸:这条路会吐**患者姓名**,与提案同级别 —— ⛔ 不能只信前端传对
    const clinicId = resolveClinicId(scope, input.clinicId);
    const criteria: CohortCriteria = {
      clinicId,
      ...(input.potentialTreatment ? { potentialTreatment: input.potentialTreatment } : {}),
      ...(input.temperature ? { temperature: input.temperature } : {}),
      ...(input.personaTags ? { personaTags: input.personaTags } : {}),
      ...(input.minSpendYuan ? { minSpendYuan: input.minSpendYuan } : {}),
    };
    assertCohortCriteria(criteria);

    const pageSize = Math.min(Math.max(input.pageSize ?? 50, 1), 200);
    const page = Math.max(input.page ?? 1, 1);
    const offset = (page - 1) * pageSize;

    /**
     * 「看」的过滤 —— 与 `cohortWhereSql`(圈人)**分开拼**,刻意的:
     *   一眼能看出哪些条件决定"分谁"、哪些只决定"这张表显示谁"。
     * 🔴 必须在**服务端**过滤:分页是滚动加载的,放前端过滤就只在"已加载的那几页"里搜 ——
     *   第 7 页的张三搜不到,而且不报错(看起来就像"这批里没有他")。
     * ⚠️ 姓名/病历号用 ILIKE + 转义 %_,⛔ 别把用户输入直接塞进 pattern。
     * ⚠️ 医生两列任一命中:主管说"薛玫的患者"时不区分主治还是上次接诊,
     *   分两个筛选反而逼他猜(而且他多半两个都想要)。
     */
    const kw = input.keyword?.trim();
    /**
     * ⚠️ `%` `_` `\` 要转义再进 pattern:病历号里真有下划线,不转义会变成通配符,
     *   搜「BJ0U_1」把 BJ0UX1 也捞出来 —— 结果多但不报错,更难发现。
     */
    const like = kw ? `%${kw.replace(/([%_\\])/g, '\\$1')}%` : '';
    const viewFilter = kw
      ? Prisma.sql`AND (
            p.name ILIKE ${like} ESCAPE '\\'
         OR p.medical_record_number ILIKE ${like} ESCAPE '\\'
         OR p.preferences #>> '{dedicatedCs,name}' ILIKE ${like} ESCAPE '\\'
         OR EXISTS (
              SELECT 1 FROM patient_profiles vpp
              WHERE vpp.patient_id = fp.patient_id
                AND (vpp.preferred_doctor ILIKE ${like} ESCAPE '\\'
                  OR vpp.last_visit_doctor ILIKE ${like} ESCAPE '\\')))`
      : Prisma.empty;
    const hasView = Boolean(kw);

    const [{ total }, filteredRows, rows] = await Promise.all([
      // ⭐ 整格的人数(**不含**看的过滤)—— 这才是点确定后真正交出去的量
      this.countCandidates(scope, criteria, now).then((c) => ({ total: c.total })),
      hasView
        ? this.prisma.$queryRaw<Array<{ n: bigint | number }>>(
            Prisma.sql`
              SELECT count(DISTINCT fp.patient_id) AS n
              FROM followup_plans fp
              JOIN patients p ON p.id = fp.patient_id
              WHERE ${cohortWhereSql(scope, criteria, now)} ${viewFilter}
            `,
          )
        : Promise.resolve(null),
      this.prisma.$queryRaw<
        Array<{ planId: string; patientId: string; priorityScore: number; assignmentId: string | null }>
      >(
        Prisma.sql`
          SELECT fp.id AS "planId", fp.patient_id AS "patientId",
                 fp.priority_score AS "priorityScore", fp.assignment_id AS "assignmentId"
          FROM followup_plans fp
          JOIN patients p ON p.id = fp.patient_id
          WHERE ${cohortWhereSql(scope, criteria, now)} ${viewFilter}
          ${SELECTION_ORDER}
          LIMIT ${pageSize} OFFSET ${offset}
        `,
      ),
    ]);
    /// 筛选后的条数;没筛就等于整格 —— 前端据此显示「共 420 人 · 筛选后 12」
    const filtered = filteredRows ? Number(filteredRows[0]?.n ?? 0) : total;

    // 身份走**同一个** patientsOf —— 与确认单的患者行逐字段同源(姓名/病历号/性别/年龄/两个医生)
    const { info, dedicated } = await this.patientsOf(rows.map((r) => r.patientId));
    const roster = await this.roster.list(scope, clinicId);
    const agentName = new Map(roster.agents.map((a) => [a.userId, a.name]));

    return {
      clinicId,
      total,
      filtered,
      page,
      pageSize,
      items: rows.map((r) => {
        const p = info.get(r.patientId);
        const owner = dedicated.get(r.patientId) ?? null;
        return {
          planId: r.planId,
          patientId: r.patientId,
          patientName: p?.name ?? null,
          medicalRecordNumber: p?.mrn ?? null,
          gender: p?.gender ?? null,
          age: p?.age ?? null,
          attendingDoctor: p?.preferredDoctor ?? null,
          lastVisitDoctor: p?.lastVisitDoctor ?? null,
          /// ⭐ 专属客服要显示:主管一眼看出"这批里有多少本来就是谁的客户"
          dedicatedTo: owner,
          dedicatedToName: owner ? (agentName.get(owner) ?? null) : null,
          /// ⭐ 重复召回标记 —— 之前被分过的人再次出现,主管有权先知道
          repeated: r.assignmentId != null,
          priorityScore: Number(r.priorityScore ?? 0),
        };
      }),
    };
  }

  /**
   * 两个基数的**沿用** —— 取该主管在该诊所上一次分配用的容量与时限。
   *
   * ⭐ 这是「先出全景确认单、主管反馈调整」这个设计的兑现点:
   *   他第一次把容量/时限调顺手,系统记住,**第二次起零输入**。
   * ⚠️ 先找**他自己**的上一次,再退到**本诊所**任何人的上一次 ——
   *   容量是团队吞吐的判断,但不同主管的手感不同;先私后公,两级都没有才用默认。
   * ⚠️ 值存在 `criteria` 这个 Json 快照里(**零新列**);读不到就当没有,⛔ 不抛错 ——
   *   老批次根本没有这两个字段,为它们报错等于历史数据一有就出确认单出不来。
   */
  private async resolveBaseline(
    scope: TenantScopeContext,
    clinicId: string,
  ): Promise<{
    batchSize: number | null;
    expiresInDays: number;
    from: string | null;
    agentOverrides: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
  }> {
    const base = {
      hostId: scope.hostId,
      tenantId: scope.tenantId,
      clinicId,
      status: 'confirmed',
    } as const;
    const pick = async (where: Record<string, unknown>) =>
      this.prisma.planAssignment.findFirst({
        where,
        orderBy: { createdAt: 'desc' },
        select: { criteria: true, createdAt: true },
      });
    const last = (await pick({ ...base, createdBy: scope.userId })) ?? (await pick({ ...base }));
    const c = (last?.criteria ?? null) as {
      batchSize?: unknown;
      /// ⚠️ 老快照里叫 `target`(那时它是推出来的结果,不是基数)—— 一并认,否则第一次改版后
      ///   所有人的"沿用"都会退回默认值,而界面上只会显示"首次默认",看不出丢了东西
      target?: unknown;
      expiresInDays?: unknown;
      agentOverrides?: unknown;
    } | null;
    const batchSize = posInt(c?.batchSize) ?? posInt(c?.target);
    const expiresInDays = posInt(c?.expiresInDays);
    // ⚠️ 只要有一个读到就算"沿用"(另一个补默认):老批次可能只存了其中一个
    // ⚠️ batchSize 读不到就返回 **null**,不在这里兜默认 —— 首次的估法要用到"在岗人数",
    //   而这里不认识名册。兜底交给 propose()。
    return {
      batchSize,
      expiresInDays: expiresInDays ?? ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
      from: batchSize != null || expiresInDays != null ? last!.createdAt.toISOString() : null,
      agentOverrides: sanitizeOverrides(c?.agentOverrides),
    };
  }

  /**
   * 候选**总数** —— 与取明细分开的一次 count。
   *
   * 🔴 存在的唯一理由:`selectCandidates` 带 LIMIT,它返回的行数**不是**候选数。
   *   拿它当候选数,大格子就会被报成「从 170 位候选里取前 100 人」(170 正是那个 LIMIT),
   *   而主管刚在矩阵上看的是 1,080。
   * ⚠️ `count(DISTINCT fp.patient_id)` —— 与矩阵格子(cohort-attributes)**同一种数法**。
   *   ⛔ 别写成 `count(*)`:同一患者的两条召回会被数两次,又对不上矩阵了。
   */
  /**
   * 候选池的**专属客服分布** —— 回答「为什么这么多人卡住」。
   *
   * ⭐ 口径是**整个候选池**(与 candidateTotal 同一个 where),⛔ 不是取回的 1.5 倍样本 ——
   *   拿样本算占比会随 N 变化而漂,主管两次看到不同比例会以为数据在变。
   * ⚠️ 专属客服存在 `patients.preferences.dedicatedCs.id`(JSON),所以只能在 SQL 里取路径。
   * ⚠️ **不在名册内的专属视同无主** —— 与 placeAgents 的 `ownerOf` 同一条规矩
   *   (人已离岗,那层关系落不了地)。⛔ 两处口径不一致会让"可顶替人数"对不上。
   */
  private async poolOwnership(
    scope: TenantScopeContext,
    criteria: CohortCriteria,
    agents: AgentInfo[],
    now: Date,
  ): Promise<{ withOwner: number; free: number; topOwners: Array<{ userId: string; name: string | null; n: number }> }> {
    const roster = agents.map((a) => a.userId);
    const rows = await this.prisma.$queryRaw<Array<{ owner: string | null; n: bigint | number }>>(
      Prisma.sql`
        SELECT CASE WHEN p.preferences #>> '{dedicatedCs,id}' = ANY(${roster}::text[])
                    THEN p.preferences #>> '{dedicatedCs,id}' END AS owner,
               count(DISTINCT fp.patient_id) AS n
        FROM followup_plans fp
        JOIN patients p ON p.id = fp.patient_id
        WHERE ${cohortWhereSql(scope, criteria, now)}
        GROUP BY 1
      `,
    );
    const nameOf = new Map(agents.map((a) => [a.userId, a.name]));
    let withOwner = 0;
    let free = 0;
    const owners: Array<{ userId: string; name: string | null; n: number }> = [];
    for (const r of rows) {
      const n = Number(r.n);
      if (r.owner) {
        withOwner += n;
        owners.push({ userId: r.owner, name: nameOf.get(r.owner) ?? null, n });
      } else {
        free += n;
      }
    }
    owners.sort((a, b) => b.n - a.n || a.userId.localeCompare(b.userId));
    return { withOwner, free, topOwners: owners.slice(0, 3) };
  }

  private async countCandidates(
    scope: TenantScopeContext,
    criteria: CohortCriteria,
    now: Date,
  ): Promise<{ total: number; repeat: number }> {
    const rows = await this.prisma.$queryRaw<Array<{ n: bigint | number; r: bigint | number }>>(
      Prisma.sql`
        SELECT count(DISTINCT fp.patient_id) AS n,
               count(DISTINCT fp.patient_id) FILTER (WHERE fp.assignment_id IS NOT NULL) AS r
        FROM followup_plans fp
        JOIN patients p ON p.id = fp.patient_id
        WHERE ${cohortWhereSql(scope, criteria, now)}
      `,
    );
    return { total: Number(rows[0]?.n ?? 0), repeat: Number(rows[0]?.r ?? 0) };
  }

  /**
   * 一次取齐**专属客服**与**患者身份**(姓名 / 病历号)。
   *
   * ⭐ 合并成一次查询而不是两次:同一批 id、同一张表,分两次纯属多一个来回。
   * ⚠️ 姓名要跟着确认单下发 —— 主管展开客服看的是"这 20 个人是谁",
   *   给一串 `#912e5dae` 等于让他对着十六进制猜。
   */
  private async patientsOf(patientIds: string[]): Promise<{
    dedicated: Map<string, string>;
    info: Map<string, PatientBrief>;
  }> {
    const ids = [...new Set(patientIds)];
    if (ids.length === 0) return { dedicated: new Map(), info: new Map() };
    const rows = await this.prisma.patient.findMany({
      where: { id: { in: ids } },
      select: {
        id: true,
        preferences: true,
        name: true,
        medicalRecordNumber: true,
        gender: true,
        birthDate: true,
        // 两个医生走 profile 现成的列(与列表页同源),⛔ 别在本服务里从 facts 另算一套
        profile: { select: { lastVisitDoctor: true, preferredDoctor: true } },
      },
    });
    const dedicated = new Map<string, string>();
    const info = new Map<string, PatientBrief>();
    for (const r of rows) {
      const id = (r.preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
      if (typeof id === 'string' && id) dedicated.set(r.id, id);
      info.set(r.id, {
        name: r.name ?? null,
        mrn: r.medicalRecordNumber ?? null,
        gender: r.gender ?? null,
        // ⚠️ 存的是生日不是年龄 —— 年龄必须**读时算**(与列表页 plan.service 同一个 calcAge),
        //   两处各写一遍必然在生日当天差一岁
        age: r.birthDate ? calcAge(r.birthDate) : null,
        lastVisitDoctor: r.profile?.lastVisitDoctor ?? null,
        preferredDoctor: r.profile?.preferredDoctor ?? null,
      });
    }
    return { dedicated, info };
  }
}

/**
 * 落人算法的产出 —— ⛔ **不含患者姓名**:那是身份信息,与"分给谁"这件事无关,
 * 由 propose() 在算完之后贴上(见那里的注释)。
 */
/**
 * placeAgents 的产物 —— **只含算法用得上的字段**。
 *
 * ⚠️ 身份类(姓名/病历号/性别/年龄/两个医生)一律在**落人之后**贴,⛔ 别加进这里:
 *   placeAgents 是纯算法,让它认识"患者叫什么、谁看的" 只会让它更难测,对分法零影响。
 *   (加一个展示字段就要在这里补一次 Omit —— 那正是这个 Omit 存在的意义。)
 */
type PlacedItem = Omit<
  ProposedItem,
  'patientName' | 'medicalRecordNumber' | 'gender' | 'age' | 'attendingDoctor' | 'lastVisitDoctor'
>;

/**
 * 「待分配」的算法产物 —— 同样只含算法字段(身份/客服姓名在外面贴)。
 * ⛔ 别在这里塞 assigneeUserId:**它就是没有经办人**,那正是它要交给主管的理由。
 */
type PendingItem = Pick<PlacedItem, 'planId' | 'patientId' | 'selectionMode'> & {
  ownerUserId: string;
};

/** 确认单要展示的患者身份(与患者详情卡片同口径) */
interface PatientBrief {
  name: string | null;
  mrn: string | null;
  gender: string | null;
  age: number | null;
  /// 上次到诊当天那份病历的接诊医生(与 lastVisitAt 同一次就诊)
  lastVisitDoctor: string | null;
  /// 主治 / 偏好医生 = 病历里出现频次最高的医生(patient_profiles 现成列)
  preferredDoctor: string | null;
}

/**
 * 🔴 **「重新排一版」的选人** —— 只换"挑谁",⛔ 不碰落人规则(见 propose 里的长注释)。
 *
 * 规则两句话:
 *   ① 池子里**所有无主的**都换进来(他们谁都能接,零代价);
 *   ② 剩下的名额用有专属的补满 N,**按客服轮着取**。
 *
 * ⚠️ ② 为什么不能直接取前 N 名:实测池子 165 人里 111 人属同一位客服,
 *   按名次取会把名额全给她 —— 而她只剩 3 条余量,另两位客服还能接 3 条和 6 条却一个都没进这批。
 *   轮着取保证每位客服的余量都够得着,这是「拟分 16 → 31」的全部来源。
 * ⚠️ 「专属不在名册内」视同无主 —— 判据必须与 placeAgents 的 `ownerOf` **一模一样**,
 *   ⛔ 否则这里挑进来的人到那边照样卡住。
 * ⭐ 返回时**按原名次排回去**:落人对顺序不敏感(每位客服的余量各算各的),
 *   但卡片和待分配列表是按这个顺序显示的,乱序主管会以为系统在乱来。
 */
export function repickPlaceable<T extends { patientId: string }>(
  ranked: T[],
  dedicatedByPatient: Map<string, string>,
  rosterIds: Set<string>,
  target: number,
  // ⚠️ `Omit` 不能省:入参可能自带 `selectionMode: 'rank'`(取数那层就是这么标的),
  //   写成 `T & {...}` 会被交叉成 `'rank'`,这里重标的 `'swap'` 在类型上凭空消失。
): (Omit<T, 'selectionMode'> & { selectionMode: 'rank' | 'swap' })[] {
  const ownerOf = (patientId: string) => {
    const d = dedicatedByPatient.get(patientId);
    return d && rosterIds.has(d) ? d : null;
  };
  const free: T[] = [];
  const byOwner = new Map<string, T[]>();
  for (const c of ranked) {
    const o = ownerOf(c.patientId);
    if (o == null) {
      free.push(c);
      continue;
    }
    const arr = byOwner.get(o) ?? [];
    arr.push(c);
    byOwner.set(o, arr);
  }

  const picked = new Set<string>();
  for (const c of free.slice(0, target)) picked.add(c.patientId);
  /// ⚠️ 客服顺序按 userId 定序 —— 确定性:同样的输入两次算出同一批人
  const owners = [...byOwner.keys()].sort();
  for (let round = 0; picked.size < target; round++) {
    let added = 0;
    for (const o of owners) {
      if (picked.size >= target) break;
      const c = byOwner.get(o)![round];
      if (c) {
        picked.add(c.patientId);
        added++;
      }
    }
    if (added === 0) break; // 池子取完了,凑不满 N 也就到此为止
  }

  /// 原本按名次就能进这批的算 `rank`,是被换进来的算 `swap` ——
  /// ⛔ 别把 swap 混进 rank:rank 组是探索配额的对照基准,混了基准就废了。
  const wouldRank = new Set(ranked.slice(0, target).map((c) => c.patientId));
  return ranked
    .filter((c) => picked.has(c.patientId))
    .map((c) => ({
      ...c,
      selectionMode: (wouldRank.has(c.patientId) ? 'rank' : 'swap') as 'rank' | 'swap',
    }));
}

/**
 * ② 落人:**专属优先 → 无主补空 → 有主改派**,三趟共用同一本水位账。
 *
 * ⭐ 导出成**纯函数**(不进 class):它是本文件里唯一值得单独测的算法,
 *   而纯函数不需要起 Nest 容器、不需要 mock Prisma,测试成本低一个量级。
 *
 * ═══ 目标:**又满又平** ═══════════════════════════════════════
 *   满 = N 个名额全部落地(⛔ 不因为"他的专属满了"就把人丢掉);
 *   平 = 分完后大家在手量趋于齐平。
 *
 * ── 为什么必须是三趟,不能合成两趟 ────────────────────────────
 * 关键在**第二、三趟的先后**:两趟都是水位法、都填最空的人,合并起来总量一样,
 * 但**被拆散的专属关系数不一样** —— 先用无主的去补空手的人,能少动一个有主患者。
 * 合成一趟的话,系统会随机地把某个有主患者改派出去,而同时某个无主患者落给了别人。
 *
 * ── 目标水位 = (团队现有在手 + 本批人数) / 在岗人数 ──────────────
 * **不是新旋钮**,完全由 N 推出来。存在的唯一理由是**给第一趟封顶**:
 * 不封顶的话,专属集中的诊所会出现"一个人吃掉整批"——
 * 本地实测:池子 1,081 人里 755 人(70%)挂在同一个客服名下,不封顶他一批拿 248 条,
 * 而 17 位在岗客服里有 10 位名下一个患者都没有,只能靠 119 个自由患者过活。
 *
 * ⚠️ 超出水位的专属患者**不是被丢掉**,是进「待分配」交主管决策(见第三趟;原为自动改派 —— 
 *   关系还在、只是这轮没轮到)。⛔ 别把这一步理解成"抢客户"。
 */
export function placeAgents(
  chosen: Array<{ planId: string; patientId: string; selectionMode: 'rank' | 'explore' | 'swap' }>,
  agents: AgentInfo[],
  dedicatedByPatient: Map<string, string>,
  /**
   * 本批给某人的**名额上限**(精调过才有,默认 Infinity = 不限)。
   * ⛔ 这不是"容量" —— 容量那个概念已经删了。它只表达
   *   「李莉这周带教,这批最多给 5 条」这种一次性名额,0 = 这轮不给他。
   * ⚠️ 它**压过一切**(含专属):主管说了只给 5 条,第 6 个她的专属患者会被改派给别人。
   */
  maxOf: (userId: string) => number = () => Infinity,
): { placed: PlacedItem[]; unplaced: number; pending: PendingItem[] } {
  const rosterIds = new Set(agents.map((a) => a.userId));
  /// ⭐ 水位 = 该客服**分配后**手上有多少 = 在手 + 本批已给。三趟共用同一本账 ——
  ///   分开算的话后面几趟会把已经灌高的人当成"还很空",专属大户被再灌一轮。
  const load = new Map<string, number>(agents.map((a) => [a.userId, a.inHand]));
  const given = new Map<string, number>(agents.map((a) => [a.userId, 0]));
  const canTake = (u: string) => (given.get(u) ?? 0) < maxOf(u);
  const take = (u: string) => {
    load.set(u, (load.get(u) ?? 0) + 1);
    given.set(u, (given.get(u) ?? 0) + 1);
  };
  /// 这个患者的专属客服(且在名册内);null = 无主,谁都能接
  const ownerOf = (patientId: string) => {
    const d = dedicatedByPatient.get(patientId);
    return d && rosterIds.has(d) ? d : null;
  };
  /// ⚠️ 向上取整 + 至少 1:否则 N 比人数还小时水位算成 0,第一趟一条专属都进不去,
  ///   整批全靠改派 —— 那等于把专属关系整个关掉。
  const inHandSum = agents.reduce((a, g) => a + g.inHand, 0);
  /**
   * 🔴 **`chosen` 必须就是"这一批要分的 N 个人"**,⛔ 不能是取数窗口(2026-08-06 实测炸过)。
   *
   * 那次「重新排一版」把整个窗口(165 行)丢了进来,水位当场从 ⌈(1001+50)/17⌉ = **62**
   * 变成 ⌈(1001+165)/17⌉ = **69** —— 一批"专属排满"的人原地进了同一位客服手里
   * (她 59 → 68,别人 58)。底线没破,但团队负载塌了,而且没人说得清 68 是怎么来的。
   * ⇒ 现在重排改成**重挑这 N 个人**(见 repickPlaceable),落人这里一个字都不用改。
   */
  const waterline =
    agents.length > 0 ? Math.max(1, Math.ceil((inHandSum + chosen.length) / agents.length)) : 0;

  const placed: PlacedItem[] = [];
  const free: typeof chosen = []; // 无专属 / 专属已离岗
  const held: typeof chosen = []; // 有主,但专属这轮的份额已满 → 第三趟进「待分配」
  let unplaced = 0;

  // ── 第一趟:专属命中(受目标水位封顶)────────────────────────
  for (const c of chosen) {
    const owner = ownerOf(c.patientId);
    if (owner == null) {
      free.push(c);
      continue;
    }
    if (!canTake(owner) || (load.get(owner) ?? 0) >= waterline) {
      held.push(c);
      continue;
    }
    take(owner);
    placed.push({
      planId: c.planId,
      patientId: c.patientId,
      assigneeUserId: owner,
      assignStrategy: AssignStrategy.DEDICATED,
      selectionMode: c.selectionMode,
    });
  }

  /**
   * 水位法取人:给**当前水位最低**的那个;同水位按 userId 打破平局(确定性 ——
   * 同样的输入两次算出同样的分法,是主管敢按确认键的前提)。
   * 返回 null = 没人能收(名册空 / 全被精调成 0 名额)。
   */
  const lowest = (): string | null => {
    let who: string | null = null;
    let best = Infinity;
    for (const a of agents) {
      if (!canTake(a.userId)) continue;
      const l = load.get(a.userId) ?? 0;
      if (l < best || (l === best && who != null && a.userId < who)) {
        best = l;
        who = a.userId;
      }
    }
    return who;
  };

  // ── 第二趟:**无主的先去补空手的人** ──────────────────────────
  // ⭐ 顺序在这里:无主患者没有关系要顾,拿他们填坑**零代价**;
  //   等无主的用完了,才不得已去动第三趟那些有主的。
  for (const c of free) {
    const who = lowest();
    if (who == null) {
      unplaced++;
      continue;
    }
    take(who);
    placed.push({
      planId: c.planId,
      patientId: c.patientId,
      assigneeUserId: who,
      assignStrategy: AssignStrategy.SPREAD_NO_DEDICATED,
      selectionMode: c.selectionMode,
    });
  }

  // ── 第三趟:**不再自动改派** → 交给主管决策(2026-08-06 产品定)──────────
  //
  // 🔴 原来这一趟会把这些人分给手上最空的客服(标 SPREAD_OVERFLOW),为的是把在手量铺平。
  //   产品判定:**把患者从他的专属客服手里挪走是关系层面的决定,助手没资格替主管做**。
  //   (主管自己可以 —— 他在确认单上拖一下就是,那条会标 MANUAL。)
  //
  // ⇒ 现在它们进 `pending`(待分配),在确认单上**单列一组**,由主管处置。
  // ⚠️ 结果是这一批**可能不满 N、团队也不齐平** —— 那是**刻意的**:
  //   宁可少分几个,也不悄悄动别人的客户。⛔ 别为了"填满 N"把这一趟加回来。
  // ⚠️ `unplaced` 不接管这些人:那个数说的是"名册空/全被精调成 0"这种系统性无处可放,
  //   而这里是**刻意留给人决策**的。混在一起主管就分不清该找谁。
  const pending: PendingItem[] = held.map((c) => ({
    planId: c.planId,
    patientId: c.patientId,
    // ownerOf 在这里恒不为 null:无主的走了第二趟,进不到 held
    ownerUserId: ownerOf(c.patientId)!,
    selectionMode: c.selectionMode,
  }));

  return { placed, unplaced, pending };
}

/**
 * 等距抽样 —— **确定性**,不用随机数。
 * 主管微调后会重算,随机会让名单无故跳动;而"同样的输入两次算出同样的分法"
 * 是他敢按确认键的前提。
 */
function pickEvenly<T>(arr: T[], n: number): T[] {
  if (n <= 0 || arr.length === 0) return [];
  if (n >= arr.length) return [...arr];
  const step = arr.length / n;
  return Array.from({ length: n }, (_, i) => arr[Math.floor(i * step)]!);
}

/**
 * 正整数校验(Json 里读出来的东西什么都可能是)。
 * ⛔ 这里**不做上下限** —— 这些数是主管对自己团队的判断,系统没有依据卡他。
 */
function posInt(v: unknown): number | null {
  return typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : null;
}

/**
 * 精调表清洗 —— 来源有两处(Json 快照 / 模型生成的 tool 参数),**两处都不可信**:
 * 快照可能是老版本写的,模型可能吐出 `{capacity: "很多"}`。
 * 非法值**静默丢弃**而不是抛错:一条坏精调不该让整张确认单出不来。
 */
/**
 * 沿用的精调 + 本次传的 → 这一批实际用哪套。
 *
 * ⭐ **空对象 = 撤掉他的精调**（`{ "u1": {} }`）。这是唯一能"恢复正常"的表达：
 *   精调会被记住，**省略等于沿用** —— 不传他，上次那条原封不动跟过来。
 *
 * 🔴 靠的是**先合并、后 sanitize** 这个顺序：展开先把 baseline 里他那条整个换成 `{}`，
 *   `sanitizeOverrides` 再把两个字段都空的键丢掉。⛔ 别改成
 *   `{ ...baseline, ...sanitizeOverrides(input) }` —— 那样 `{}` 在合并前就被丢了，
 *   baseline 原样留下，**撤销当场失效而且不报错**：主管说了「某某恢复正常」，
 *   下一批他照旧被卡在 5 条。这个函数存在的唯一理由就是把这个顺序钉住。
 * ⚠️ 撤销**只能用空对象**，⛔ 不要教人传一个大数（`maxThisBatch: 999`）去"盖掉" ——
 *   那个假值会被记成新的精调，一路沿用下去。
 */
export function mergeOverrides(
  baseline: Record<string, { maxThisBatch?: number; expiresInDays?: number }> | undefined,
  incoming: Record<string, { maxThisBatch?: number; expiresInDays?: number }> | undefined,
): Record<string, { maxThisBatch?: number; expiresInDays?: number }> {
  return sanitizeOverrides({ ...baseline, ...incoming });
}

function sanitizeOverrides(
  raw: unknown,
): Record<string, { maxThisBatch?: number; expiresInDays?: number }> {
  if (!raw || typeof raw !== 'object') return {};
  const out: Record<string, { maxThisBatch?: number; expiresInDays?: number }> = {};
  for (const [userId, v] of Object.entries(raw as Record<string, unknown>)) {
    if (!userId || !v || typeof v !== 'object') continue;
    const o = v as { maxThisBatch?: unknown; expiresInDays?: unknown };
    // ⚠️ 名额上限**允许 0**(= 这轮不给他),所以不能用 posInt
    const maxThisBatch =
      typeof o.maxThisBatch === 'number' && Number.isInteger(o.maxThisBatch) && o.maxThisBatch >= 0
        ? o.maxThisBatch
        : null;
    // 时限有上限 90 天:那不是业务判断,是 `expiresInDays` 契约本来就写死的(schema max(90))
    const days = posInt(o.expiresInDays);
    const expiresInDays = days != null && days <= 90 ? days : null;
    if (maxThisBatch == null && expiresInDays == null) continue;
    out[userId] = {
      ...(maxThisBatch != null ? { maxThisBatch } : {}),
      ...(expiresInDays != null ? { expiresInDays } : {}),
    };
  }
  return out;
}


function emptyProposal(
  clinicId: string,
  potentialTreatment: string | undefined,
  temperature: string | undefined,
  agents: AgentInfo[],
  rosterNote: string,
  base: {
    target: number;
    batchSize: number;
    expiresInDays: number;
    basis: 'inherited' | 'default' | 'explicit';
    basisFrom: string | null;
    agentOverrides: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
  },
): AssignmentProposal {
  return {
    // ⚠️ 空提案不出引导节点：一个人都没有的时候，「待分配」「打不完」这些都无从谈起。
    //   `total == 0` 是**终止分支**不是节点（见 assignment-agent-flow.md §五）——
    //   主管唯一能做的是换人群，而那句话由 selectionNote 说。
    signals: [],
    /// 空提案没有节点，这个字段也就无人消费；给 null 而不是省略，是为了让类型收口
    narrowedBy: null,
    clinicId,
    potentialTreatment: potentialTreatment ?? null,
    temperature: temperature ?? null,
    candidateTotal: 0,
    target: base.target,
    batchSize: base.batchSize,
    rosterCount: agents.length,
    dailyCalls: DAILY_CALLS_PER_AGENT,
    expiresInDays: base.expiresInDays,
    basis: base.basis,
    basisFrom: base.basisFrom,
    agentOverrides: base.agentOverrides,
    placed: 0,
    unplaced: 0,
    items: [],
    pending: [],
    pendingNote: '',
    refillNote: '',
    canRefill: false,
    poolOwnership: null,
    byAgent: [],
    // 空提案里所有在岗的人都"没分到"
    skippedAgents: agents.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
    rosterNote,
    selectionNote:
      agents.length === 0
        ? '该诊所名册为空,无法出分配方案 —— 请主管直接指定客服。'
        : // 基数=0 只能是主管自己说的(默认估法永远 > 0)
          '本批人数被设成了 0,没有可分配的量 —— 说个数字我重出一版。',
  };
}
