import { AssignStrategy, type AgentInfo, type TemperatureValue } from '@pac/types';
// ⚠️ **用 import 不用 require**（2026-08-12）：原来这两个 describe 里各写了一行
//   `const { AssignmentProposalService } = require(...)` —— require 的返回是 any，
//   于是给构造器加第三个依赖时**类型检查查不出来**，运行时那个依赖是 undefined，
//   又正好被业务侧的 try/catch 吞掉 ⇒ 测试全绿而被测路径根本没跑。
import {
  AssignmentProposalService,
  mergeOverrides,
  placeAgents,
  repickPlaceable,
} from '../src/modules/plan/assignment-proposal.service';
import type { CohortAttributesService } from '../src/modules/plan/cohort-attributes.service';
import { modelFacts } from '../src/modules/plan/assignment-facts';

/**
 * 落人算法回归 —— **专属优先 → 无主补空 → 待分配**。
 *
 * 这是主管每天都要看的那张确认单背后的算法 —— 它错了不会报错,
 * 只会让主管把单分给不该分的人,而他要到客服抱怨时才知道。
 *
 * 🔴🔴 **2026-08-06 产品改判:取消「有主改派」。**
 *   原来第三趟会把专属客服已排满的患者**自动改派**给手上最空的人(标 SPREAD_OVERFLOW),
 *   为的是把团队在手量铺平。现在判定:**把患者从他的专属客服手里挪走是关系层面的决定,
 *   助手没资格替主管做** —— 那些人改为进 `pending`(待分配),在确认单上单列一组,
 *   主管拖给谁才算数(标 MANUAL)。
 *   ⇒ 代价是**这一批可能不满 N、团队也不齐平**,那是刻意的取舍。
 *   ⛔ 下面几条测试就是防止有人"为了填满 N"把自动改派加回来。
 */

function agent(userId: string, inHand: number, name = userId): AgentInfo {
  return {
    userId,
    name,
    inHand,
    inHandThisClinic: inHand,
    recentVisits: 10,
    lastVisitAt: '2026-07-01T00:00:00.000Z',
    inRoster: true,
  };
}

const pick = (n: number, prefix = 'p') =>
  Array.from({ length: n }, (_, i) => ({
    planId: `${prefix}-plan-${i}`,
    patientId: `${prefix}-pat-${i}`,
    selectionMode: 'rank' as const,
  }));

/**
 * 画像亮点的取数桩 —— 默认**什么都不亮**（基线为空 → computeHighlights 直接返回 []）。
 * ⚠️ 必须传，⛔ 不能省：省了运行时是 undefined，而业务侧的 try/catch 会把它吞成"没有亮点"。
 */
const cohortsStub = (
  over: Partial<CohortAttributesService> = {},
): CohortAttributesService =>
  ({ dimShares: jest.fn(async () => ({ size: 0, dims: [] })), ...over }) as unknown as CohortAttributesService;

describe('placeAgents —— 专属优先', () => {
  test('⭐ 专属客服在名册内、且没超过本批应得份额 → 命中 dedicated', () => {
    const chosen = pick(3);
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
    // 只有 a 一个人在岗 → 目标水位 = 3,三条全归他
    const { placed } = placeAgents(chosen, [agent('a', 0)], dedicated);
    expect(placed).toHaveLength(3);
    expect(placed.every((p) => p.assigneeUserId === 'a')).toBe(true);
    expect(placed.every((p) => p.assignStrategy === AssignStrategy.DEDICATED)).toBe(true);
  });

  /**
   * 🔴🔴 **专属受目标水位封顶;超出的进「待分配」,⛔ 不再自动改派**。
   *
   * 封顶仍然要:不封顶的话专属集中的诊所会一个人吃掉整批(本地实测池子 70% 挂在同一人名下)。
   * 但**超出的那部分不再塞给别人** —— 交给主管决定。
   */
  test('🔴🔴 3 条全是 a 的专属、两人在岗 → a 拿 2(水位),第 3 条进待分配而**不是**给 b', () => {
    const chosen = pick(3);
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
    const { placed, unplaced, pending } = placeAgents(
      chosen,
      [agent('a', 0), agent('b', 0)],
      dedicated,
    );
    expect(unplaced).toBe(0);
    expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
    // ⛔ b 一条都不该拿到 —— 那是别人的客户
    expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(0);
    expect(pending).toHaveLength(1);
    // 待分配必须带**是谁的专属**,否则主管无从判断要不要挪
    expect(pending[0]!.ownerUserId).toBe('a');
  });

  test('🔴 精调名额压过专属:a 只给 2 条,她剩下的专属患者进待分配(⛔ 既不丢也不改派)', () => {
    const chosen = pick(5);
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
    const { placed, unplaced, pending } = placeAgents(
      chosen,
      [agent('a', 0), agent('b', 0)],
      dedicated,
      (u) => (u === 'a' ? 2 : Infinity),
    );
    expect(unplaced).toBe(0);
    expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
    expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(0);
    expect(pending).toHaveLength(3);
  });

  test('⛔ 算法**再也不产出** SPREAD_OVERFLOW(它只保留给历史数据)', () => {
    const chosen = pick(9);
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
    const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0), agent('c', 0)], dedicated);
    expect(placed.some((p) => p.assignStrategy === AssignStrategy.SPREAD_OVERFLOW)).toBe(false);
  });

  /**
   * 🔴🔴 **顺序**:无主的先去补空手的人,不够了才动有主的。
   *
   * 两趟都是水位法,合并起来总量一样 —— 但**被拆散的专属关系数不一样**。
   * 合成一趟的话,系统会随机地把某个有主患者改派出去,而同时某个无主患者落给了别人。
   */
  test('🔴 无主的优先补空手的人 —— 有主的能不动就不动', () => {
    // 4 条:2 条是 a 的专属,2 条无主;两人在岗,水位 = 2
    const chosen = pick(4);
    const dedicated = new Map(chosen.slice(0, 2).map((c) => [c.patientId, 'a']));
    const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
    // a 拿自己的 2 条(到水位),b 拿 2 条无主的 —— ⛔ 一条专属关系都没拆
    expect(placed.filter((p) => p.assignStrategy === AssignStrategy.DEDICATED)).toHaveLength(2);
    expect(placed.filter((p) => p.assignStrategy === AssignStrategy.SPREAD_OVERFLOW)).toHaveLength(0);
    expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(2);
  });

  test('⭐ 专属客服**不在名册** → 走铺平,且标 spread_no_dedicated', () => {
    const chosen = pick(2);
    // 专属是 'ghost'(已离职,名册里查不到)
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'ghost']));
    const { placed } = placeAgents(chosen, [agent('a', 0)], dedicated);
    expect(placed.every((p) => p.assigneeUserId === 'a')).toBe(true);
    // 「从头没有可用专属」≠「专属满了溢出」—— 混成一个值,T20 就分不清
    // "铺平完成率低"是策略问题还是人群问题
    expect(placed.every((p) => p.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED)).toBe(true);
  });

  test('⭐ 无专属客服 → spread_no_dedicated', () => {
    const chosen = pick(2);
    const { placed } = placeAgents(chosen, [agent('a', 0)], new Map());
    expect(placed.every((p) => p.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED)).toBe(true);
  });

  test('⭐⭐ 两种归属标记各归各的 + 超出的进待分配', () => {
    // 6 条:4 条是 a 的专属,2 条无主;两人在岗,水位 = 3
    const chosen = pick(6);
    const dedicated = new Map(chosen.slice(0, 4).map((c) => [c.patientId, 'a']));
    const { placed, pending } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
    const byStrategy = placed.reduce<Record<string, number>>((m, p) => {
      m[p.assignStrategy] = (m[p.assignStrategy] ?? 0) + 1;
      return m;
    }, {});
    expect(byStrategy[AssignStrategy.DEDICATED]).toBe(3); // a 到水位 3
    expect(byStrategy[AssignStrategy.SPREAD_NO_DEDICATED]).toBe(2); // 无主的给 b
    expect(byStrategy[AssignStrategy.SPREAD_OVERFLOW]).toBeUndefined(); // ⛔ 不再有改派
    expect(pending).toHaveLength(1); // a 的第 4 条 → 待分配
    // ⚠️ 不再"又满又平":b 只有 2 条,而那第 4 条**刻意**没塞给他
    expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(2);
  });
});

describe('placeAgents —— 溢出水位法', () => {
  test('⭐ 起点相同 → 结果仍是均分:9 条给 3 个空闲客服 → 各 3 条', () => {
    const chosen = pick(9);
    const { placed, unplaced } = placeAgents(
      chosen,
      [agent('a', 0), agent('b', 0), agent('c', 0)],
      new Map(),
    );
    expect(unplaced).toBe(0);
    const counts = ['a', 'b', 'c'].map((u) => placed.filter((p) => p.assigneeUserId === u).length);
    expect(counts).toEqual([3, 3, 3]);
  });

  /**
   * 🔴🔴 水位法的本体:**从手上最少的人开始填**,不是"每人加一样多"。
   *
   * 真实场景(2026-08-03 走查):第一批把有专属的人灌高了,第二批如果按人头均分,
   * 起点不齐终点还是不齐 —— 主管要的是"最后保持齐平"。
   */
  test('🔴 在手 10/0/0,分 6 条 → 全给空的两位并拉平(⛔ 不是每人各 2)', () => {
    const { placed } = placeAgents(pick(6), [agent('busy', 10), agent('x', 0), agent('y', 0)], new Map());
    expect(placed.filter((p) => p.assigneeUserId === 'busy')).toHaveLength(0);
    expect(placed.filter((p) => p.assigneeUserId === 'x')).toHaveLength(3);
    expect(placed.filter((p) => p.assigneeUserId === 'y')).toHaveLength(3);
  });

  test('🔴 水位追平之后才开始雨露均沾(在手 2/0,分 6 条 → 2 / 4)', () => {
    const { placed } = placeAgents(pick(6), [agent('a', 2), agent('b', 0)], new Map());
    expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
    expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(4);
  });

  /**
   * 🔴🔴 **专属集中的诊所:宁可少分,也不动别人的客户**(2026-08-06 改判)。
   *
   * 这正是那个场景 —— 池子 70% 挂在一个人名下。原来会铺平到所有人头上(各 3 条),
   * 现在 a 拿到水位为止,其余 6 条全部进待分配等主管决定。
   * ⛔ 谁要把这一条改回 [3,3,3],等于把自动改派又加回来了。
   */
  test('🔴🔴 9 条全是 a 的专属、3 人在岗 → a 拿 3(水位),另 6 条进待分配', () => {
    const chosen = pick(9);
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
    const { placed, unplaced, pending } = placeAgents(
      chosen,
      [agent('a', 0), agent('b', 0), agent('c', 0)],
      dedicated,
    );
    expect(unplaced).toBe(0);
    expect(['a', 'b', 'c'].map((u) => placed.filter((p) => p.assigneeUserId === u).length)).toEqual([
      3, 0, 0,
    ]);
    expect(pending).toHaveLength(6);
    expect(pending.every((p) => p.ownerUserId === 'a')).toBe(true);
  });

  test('⭐⭐ 精调成 0 名额的人**一条都不给**', () => {
    const chosen = pick(4);
    const { placed } = placeAgents(chosen, [agent('off', 0), agent('free', 0)], new Map(), (u) =>
      u === 'off' ? 0 : Infinity,
    );
    expect(placed.filter((p) => p.assigneeUserId === 'off')).toHaveLength(0);
    expect(placed.filter((p) => p.assigneeUserId === 'free')).toHaveLength(4);
  });

  test('⭐⭐ 所有人名额都用完 → 如实报 unplaced(⛔ 不硬塞)', () => {
    const { placed, unplaced } = placeAgents(pick(5), [agent('a', 0)], new Map(), () => 2);
    expect(placed).toHaveLength(2);
    expect(unplaced).toBe(3);
  });

  test('⭐⭐ 三趟**共用同一本账**:专属灌高的人在后两趟不会被当成"还很空"再吃一轮', () => {
    // 前 3 条是 a 的专属,后 3 条无主 → a 到水位 3,无主的应该全给 b
    const chosen = pick(6);
    const dedicated = new Map(chosen.slice(0, 3).map((c) => [c.patientId, 'a']));
    const { placed, unplaced } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
    expect(unplaced).toBe(0);
    // ⚠️ 若各趟各自算账,a 会在后面被当成"手上是 0"再吃一轮 —— 代码上完全不显眼
    expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(3);
    expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(3);
  });

  test('名册为空 → 一条都落不上,不报错', () => {
    const { placed, unplaced } = placeAgents(pick(3), [], new Map());
    expect(placed).toHaveLength(0);
    expect(unplaced).toBe(3);
  });
});

/**
 * 🔴🔴 **「重新排一版」= 重挑这 N 个人,⛔ 不是改落人规则。**
 *
 * 2026-08-06 一天之内在同一个地方栽了两次,两次都是**去动落人**:
 *   ① 预先下发「顶替名单」→ 名单只能从取数窗口里挑,窗口比池子小,
 *      池子 19 位无主、窗口里只落进 6 位,界面只敢说"顶 3 位"。
 *   ② 把整个窗口丢给落人 + `stopAt` 跳过 → 水位按 `chosen.length` 算,被窗口撑大
 *      (⌈(1001+50)/17⌉=62 → ⌈(1001+165)/17⌉=69),那批"专属排满"的人
 *      原地进了**同一位客服**手里(她 59 → 68,别人 58)。底线没破,但负载塌了。
 *
 * ⇒ 正确做法(产品原话:「为什么要纠结排名,50 个里 19 位无主 31 位有主重新定确认单不行吗」):
 *   **只换"挑谁"**——池子里无主的全换进来,其余按优先级用有专属的补满 N,
 *   然后走**完全一样**的三趟。排不进的照样进待分配,卡片形状一个字不变。
 */
describe('repickPlaceable —— 重新排一版:只换挑谁', () => {
  /**
   * 复刻实测那个池子的形状:165 位候选,146 位属三位客服(111 / 18 / 16),19 位无主。
   * ⚠️ 故意把**大户的人全排在最前面** —— 这是最能暴露"直接取前 N 名"的顺序。
   */
  const pool = () => {
    const ranked = pick(165);
    const dedicated = new Map<string, string>();
    for (let i = 0; i < 111; i++) dedicated.set(`p-pat-${i}`, 'kang');
    for (let i = 111; i < 129; i++) dedicated.set(`p-pat-${i}`, 'li');
    for (let i = 129; i < 145; i++) dedicated.set(`p-pat-${i}`, 'jin');
    // 145..163 共 19 位无主;164 也无主 —— 凑够 165 行
    return { ranked, dedicated };
  };
  const roster = new Set(['kang', 'li', 'jin', ...Array.from({ length: 14 }, (_, i) => `x${i}`)]);
  const N = 50;

  test('🔴 池子里的无主患者**全部**换进这一批', () => {
    const { ranked, dedicated } = pool();
    const chosen = repickPlaceable(ranked, dedicated, roster, N);
    expect(chosen).toHaveLength(N);
    const free = chosen.filter((c) => !dedicated.has(c.patientId));
    expect(free).toHaveLength(20); // 145..164
  });

  test('🔴🔴 有专属的那部分**按客服轮着取** —— ⛔ 直取前 N 名会把名额全给大户', () => {
    const { ranked, dedicated } = pool();
    const chosen = repickPlaceable(ranked, dedicated, roster, N);
    const n = (u: string) => chosen.filter((c) => dedicated.get(c.patientId) === u).length;
    // 30 个有主名额轮给 3 位客服 → 各 10 个。直取前 30 名的话 kang 会独吞 30、另两位 0
    expect(n('kang')).toBe(10);
    expect(n('li')).toBe(10);
    expect(n('jin')).toBe(10);
  });

  test('⭐ 这才是「拟分 16 → 31」的来源:换过之后每位客服的余量都够得着', () => {
    const { ranked, dedicated } = pool();
    const agents = [
      agent('kang', 59),
      agent('li', 59),
      agent('jin', 56),
      ...Array.from({ length: 14 }, (_, i) => agent(`x${i}`, 56)),
    ];
    // 水位 ⌈(958+50)/17⌉ = 60 → kang 还能接 1、li 1、jin 4,合计 6
    const chosen = repickPlaceable(ranked, dedicated, roster, N);
    const { placed, pending } = placeAgents(chosen, agents, dedicated);
    const ded = placed.filter((p) => p.assignStrategy === AssignStrategy.DEDICATED);
    const free = placed.filter((p) => p.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED);
    expect(ded).toHaveLength(6); // 1 + 1 + 4 —— 三位客服的余量都用上了
    expect(free).toHaveLength(20);
    expect(placed).toHaveLength(26);
    // 排不进的照样进待分配 —— ⛔ 不为了填满 N 做任何额外动作
    expect(pending).toHaveLength(N - 26);
  });

  test('🔴🔴 底线:落人一个字没改 —— 每条都进本人的专属客服,⛔ 没有改派', () => {
    const { ranked, dedicated } = pool();
    const agents = [
      agent('kang', 59),
      agent('li', 59),
      agent('jin', 56),
      ...Array.from({ length: 14 }, (_, i) => agent(`x${i}`, 56)),
    ];
    const chosen = repickPlaceable(ranked, dedicated, roster, N);
    const { placed } = placeAgents(chosen, agents, dedicated);
    for (const p of placed) {
      const owner = dedicated.get(p.patientId);
      if (owner) expect(p.assigneeUserId).toBe(owner);
    }
  });

  test('⭐ 水位不受影响 —— chosen 恒等于本批 N,⛔ 不再是取数窗口', () => {
    const { ranked, dedicated } = pool();
    expect(repickPlaceable(ranked, dedicated, roster, N)).toHaveLength(N);
    expect(repickPlaceable(ranked, dedicated, roster, 20)).toHaveLength(20);
  });

  test('⭐ 被换进来的标 swap,原本按名次就能进的仍是 rank(探索配额的基准不能混)', () => {
    const { ranked, dedicated } = pool();
    const chosen = repickPlaceable(ranked, dedicated, roster, N);
    // 前 50 名全是 kang 的人,所以换进来的无主/li/jin 都该是 swap
    const rank = chosen.filter((c) => c.selectionMode === 'rank');
    expect(rank.every((c) => dedicated.get(c.patientId) === 'kang')).toBe(true);
    expect(chosen.some((c) => c.selectionMode === 'swap')).toBe(true);
  });

  test('⭐ 专属客服**不在名册内** = 视同无主 —— 判据必须与落人一致', () => {
    const ranked = pick(4);
    const dedicated = new Map([
      ['p-pat-0', 'gone'], // 已离岗
      ['p-pat-1', 'kang'],
    ]);
    const chosen = repickPlaceable(ranked, dedicated, new Set(['kang']), 4);
    // 离岗那位的患者算无主 → 与 p-pat-2/3 一样先被换进来
    expect(chosen).toHaveLength(4);
    const { placed } = placeAgents(chosen, [agent('kang', 0)], dedicated);
    expect(placed.find((p) => p.patientId === 'p-pat-0')!.assignStrategy).toBe(
      AssignStrategy.SPREAD_NO_DEDICATED,
    );
  });

  test('⭐ 确定性:同样的池子两次挑出同一批人', () => {
    const a = pool();
    const b = pool();
    expect(repickPlaceable(a.ranked, a.dedicated, roster, N).map((c) => c.planId)).toEqual(
      repickPlaceable(b.ranked, b.dedicated, roster, N).map((c) => c.planId),
    );
  });

  test('池子不够 N 时如实少给,⛔ 不报错也不补空', () => {
    const ranked = pick(3);
    expect(repickPlaceable(ranked, new Map(), roster, 50)).toHaveLength(3);
  });
});

describe('placeAgents —— 确定性', () => {
  test('⭐ 同样的输入两次算出**同样的分法**(主管微调后会重算,跳动他就不敢按确认)', () => {
    const chosen = pick(7);
    const agents = [agent('a', 3), agent('b', 1), agent('c', 5)];
    const r1 = placeAgents(chosen, agents, new Map());
    const r2 = placeAgents(chosen, agents, new Map());
    expect(r1.placed.map((p) => `${p.planId}:${p.assigneeUserId}`)).toEqual(
      r2.placed.map((p) => `${p.planId}:${p.assigneeUserId}`),
    );
  });

  test('selectionMode 原样带过去(探索配额标记不能在落人这步丢)', () => {
    const chosen = [
      { planId: 'p1', patientId: 'x1', selectionMode: 'rank' as const },
      { planId: 'p2', patientId: 'x2', selectionMode: 'explore' as const },
    ];
    const { placed } = placeAgents(chosen, [agent('a', 0)], new Map());
    expect(placed.find((p) => p.planId === 'p2')!.selectionMode).toBe('explore');
    // 丢了就等于探索配额白留 —— 事后无法把两组分开
    expect(placed.find((p) => p.planId === 'p1')!.selectionMode).toBe('rank');
  });
});

/**
 * 「取前 N 人」这句话的准确性。
 *
 * 🔴 它是**要求助手原话转述**的句子(T14 双保险的工具返回值那一半)——
 * 措辞错了等于让助手替系统撒谎,而且主管没法自己发现。
 * 真实场景:主管从矩阵点了一个 44 人的格子进来,句子却说「排序取前 100 人」,
 * 他会以为有 56 个人被系统悄悄丢了。
 */
describe('selectionNote —— 候选不够时的措辞', () => {

  /**
   * ⚠️ 这个假 prisma 必须**照 LIMIT 截断**。
   *   原来的写法不管 LIMIT、一律吐 candidateCount 行 —— 于是"候选数被取数上限截断"
   *   这个真 bug 在测试里**根本不可能出现**,500 那条用例一直是假绿。
   *   count 查询与明细查询靠 SQL 文本区分,与 service 里的两条查询一一对应。
   */
  function svcWith(
    candidateCount: number,
    agentCount: number,
    opts: { inHand?: number; lastCriteria?: Record<string, unknown> | null } = {},
  ) {
    const prisma = {
      $queryRaw: jest.fn(async (sql: { strings?: string[]; values?: unknown[] }) => {
        const text = (sql?.strings ?? []).join(' ');
        if (text.includes('count(DISTINCT')) return [{ n: BigInt(candidateCount) }];
        // 明细:LIMIT 是模板里最后一个参数
        const limit = Number((sql?.values ?? []).at(-1) ?? candidateCount);
        return Array.from({ length: Math.min(candidateCount, limit) }, (_, i) => ({
          planId: `p${i}`, patientId: `pat${i}`, priorityScore: 50 - i,
        }));
      }),
      patient: { findMany: jest.fn(async () => []) },
      // 基数沿用:上一次分配的 criteria 快照(null = 从来没分过)
      planAssignment: {
        findFirst: jest.fn(async () =>
          opts.lastCriteria
            ? { criteria: opts.lastCriteria, createdAt: new Date('2026-07-28T02:00:00.000Z') }
            : null,
        ),
      },
    };
    const roster = {
      list: jest.fn(async () => ({
        agents: Array.from({ length: agentCount }, (_, i) => ({
          userId: `a${i}`, name: `客服${i}`, inHand: opts.inHand ?? 0,
        })),
        rosterNote: '名册说明',
      })),
    };
    return new AssignmentProposalService(
      prisma as never,
      roster as never,
      cohortsStub() as never,
    );
  }
  const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
  /// 首次估法:在岗 9 人 × 20 = 180(⚠️ 那个 20 不是容量上限,只是首次没有历史时的估法)
  // 默认口径:在岗 9 人 × 每天 15 通 × 1 天（2026-08-12：一批推一天的活，⛔ 不再沿用上一次）
  const N = 9 * 15 * 1;

  test('⭐⭐ 候选 44 < 基数 180 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 N 人」', async () => {
    const r = await svcWith(44, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
    expect(r.selectionNote).toContain('一共就 44 人');
    expect(r.selectionNote).toContain('全要了');
    // ⛔ 不许出现"挑了 N 人 / 取前 N 人"这类措辞 —— 主管会以为有人被丢了
    expect(r.selectionNote).not.toContain('挑了');
    expect(r.selectionNote).not.toContain('取前');
  });

  test('⭐ 候选 500 > 基数 180 → 照实说「这批 180 人,从 500 人里挑」+ 剩下的还没轮到', async () => {
    const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
    expect(r.target).toBe(N);
    // ⚠️ 2026-08-06 重排:每段以加粗的「数字+是什么」起头,措辞随之变化
    expect(r.selectionNote).toContain('从符合条件的 500 人里挑');
    expect(r.selectionNote).toContain(`**这批 ${N} 人**`);
    // ⚠️ 2026-08-06 措辞改过:「排队」是内部说法(池子里并没有队)→「还没轮到」
    expect(r.selectionNote).toContain(`剩下 ${500 - N} 人这轮还没轮到`);
    // ⚠️ 必须告诉主管"还能再分一批" —— 这正是容量模型下他做不到的那件事
    expect(r.selectionNote).toContain('随时能再分一批');
    // ⛔ 内部词不许进主管界面(2026-08-03 走查:"表达要给主管看懂,不是给程序员看")
    for (const w of ['排序键', '收敛', '铺平', '水位', '基数', '探索配额', '患者号']) {
      expect(r.selectionNote).not.toContain(w);
    }
    // ⚠️ 必须告诉主管"还能再分一批" —— 这正是容量模型下他做不到的那件事
    expect(r.selectionNote).toContain('随时能再分一批');
  });

  /**
   * 🔴🔴 取数上限**不许**冒充候选数。
   *
   * 真实场景(2026-08-03 走查发现):主管在矩阵点「充填 · 窗口外 1,080」,
   * 确认单却说「从 170 位候选里取前 100 人」—— 170 正是 `ceil(target*1.5)+20` 这个 LIMIT。
   * 而这句话助手要**原话转述**,等于让系统当着主管的面报一个他刚看过的、对不上的数。
   */
  test('🔴 候选 1080 但取数只取回 1.5 倍窗口 → 说的必须是 1080', async () => {
    const r = await svcWith(1080, 9).propose(SCOPE, {
      clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold' as TemperatureValue,
    });
    expect(r.candidateTotal).toBe(1080);
    expect(r.selectionNote).toContain('从符合条件的 1080 人里挑');
    expect(r.placed).toBe(N);
  });
});

/**
 * 两个基数(本批人数 + 时效)的**沿用**,以及"连着分第二批"。
 *
 * 🔴 这是「先出确认单、主管反馈调整」这个设计的兑现点:第一次调顺手,第二次起零输入。
 * 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。
 */
describe('基数沿用 —— 本批人数与时效', () => {
  const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
  const mk = (candidateCount: number, agentCount: number, opts: Record<string, unknown> = {}) => {
    const prisma = {
      $queryRaw: jest.fn(async (sql: { strings?: string[]; values?: unknown[] }) => {
        const text = (sql?.strings ?? []).join(' ');
        if (text.includes('count(DISTINCT')) return [{ n: BigInt(candidateCount) }];
        const limit = Number((sql?.values ?? []).at(-1) ?? candidateCount);
        return Array.from({ length: Math.min(candidateCount, limit) }, (_, i) => ({
          planId: `p${i}`, patientId: `pat${i}`, priorityScore: 50 - i,
        }));
      }),
      patient: { findMany: jest.fn(async () => []) },
      planAssignment: {
        findFirst: jest.fn(async () =>
          opts.lastCriteria
            ? { criteria: opts.lastCriteria, createdAt: new Date('2026-07-28T02:00:00.000Z') }
            : null,
        ),
      },
    };
    const roster = {
      list: jest.fn(async () => ({
        agents: Array.from({ length: agentCount }, (_, i) => ({
          userId: `a${i}`, name: `客服${i}`, inHand: (opts.inHand as number) ?? 0,
        })),
        rosterNote: '名册说明',
      })),
    };
    return new AssignmentProposalService(
      prisma as never,
      roster as never,
      cohortsStub() as never,
    );
  };

  /**
   * 🔴🔴 **连着分第二批必须还能分**。
   *
   * 这是 2026-08-03 走查抓到的死路:批次规模曾经 = Σ(容量−在手),
   * 于是第一批把所有人填到水位之后,第二批恒为 0 —— 主管只能靠往上棚容量来绕开,
   * 而容量会被记住,下周的"习惯"就是个虚高的数。
   */
  test('🔴 第一批分完(人人在手 25)→ 第二批照样分得出来,⛔ 不是 0', async () => {
    const r = await mk(1000, 9, { inHand: 25, lastCriteria: { batchSize: 100, expiresInDays: 3 } })
      .propose(SCOPE, { clinicId: 'c1' });
    // ⚠️ 断言的是**不为 0** 这个不变式,⛔ 不是具体数字 —— 数字随基数口径变,死路不会。
    expect(r.target).toBeGreaterThan(0);
    expect(r.placed).toBe(r.target);
    expect(r.unplaced).toBe(0);
    expect(r.skippedAgents).toHaveLength(0);
  });

  /**
   * 🔴 2026-08-12 产品改判:**不再沿用上一次**,每次都按 `在岗 × 每天 15 通 × 时效` 算。
   *
   * 原设计是"调一次省一次",但它让 N 悄悄漂 —— 某次为了一个小格子调成 44,
   * 此后所有批次就永远是 44,而主管完全看不出为什么变小了。
   */
  test('🔴 上次用了 300 人 / 5 天 → ⛔ 不再沿用,按默认口径重算', async () => {
    const r = await mk(1000, 9, { lastCriteria: { batchSize: 300, expiresInDays: 5 } }).propose(SCOPE, {
      clinicId: 'c1',
    });
    expect(r.batchSize).toBe(9 * 15 * 1);
    expect(r.expiresInDays).toBe(1);
    expect(r.basis).toBe('default');
  });

  test('⭐ 默认口径 = 在岗 9 人 × 每天 15 通 × 1 天 = 135,且说清是默认值', async () => {
    const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1' });
    expect(r.batchSize).toBe(135);
    expect(r.target).toBe(135);
    expect(r.expiresInDays).toBe(1);
    expect(r.basis).toBe('default');
    /**
     * 🔴 2026-08-14 从 `basisNote` 迁到 `modelFacts` —— basisNote **只写不读**,
     *   删了(见 plan-assignment schema 上那段)。这三条断言保护的东西一条没变,
     *   只是换成断**模型真正拿到的那份**。
     */
    const f = modelFacts(r) as { 怎么选的: Record<string, unknown> };
    const how = String(f.怎么选的['本批人数怎么来的']);
    expect(how).toContain('系统默认');
    // ⚠️ 算式必须写出来 —— 主管看到 405 的第一反应是"怎么这么多"
    expect(how).toContain('每天 15 通');
    // ⛔ 同上:说人话,不许出现内部词
    for (const w of ['水位', '基数', '铺平', '溢出']) {
      expect(JSON.stringify(modelFacts(r))).not.toContain(w);
    }
  });

  /** 🔴 N 跟时效挂钩:D 从 3 改到 5,能承接的量本来就该跟着变。 */
  test('🔴 时效给 5 天 → N 跟着变成 9 × 15 × 5', async () => {
    const r = await mk(2000, 9).propose(SCOPE, { clinicId: 'c1', expiresInDays: 5 });
    expect(r.expiresInDays).toBe(5);
    expect(r.batchSize).toBe(9 * 15 * 5);
  });

  /**
   * 🔴 候选不够时压低的是**这一批**,⛔ 不是基数。
   * 回写的话:主管点一次 44 人的小格子,以后所有批次永远是 44 人,而他看不出为什么。
   */
  test('🔴 这批候选只有 44 → 本批 44,但基数仍是默认那个数', async () => {
    const r = await mk(44, 9).propose(SCOPE, { clinicId: 'c1' });
    expect(r.target).toBe(44);
    expect(r.batchSize).toBe(135);
    /**
     * 🔴 2026-08-14 **改判**:原断言是 `basisNote` 里要有「您设的 135 没变」。两处都错了 ——
     *   ① basisNote 没有消费方(已删);
     *   ② 135 是系统按「在岗 × 每天几通 × 时效」算的,**主管一个数都没设过**,
     *      叫它"您设的"会让他去找自己什么时候设过 135。
     *   ⇒ `basis='default'` 时这个数**根本不进模型上下文**(见 assignment-facts 那段注释),
     *      对应的断言在 `assignment-signals.spec` 的「首次默认的基数不给模型」。
     * ⚠️ 这里仍然要锁的是 `batchSize` **没有被回写成 44**(上面那句),那才是这条测试的正题。
     */
  });

  test('⭐ 主管本次说「这批 200 人」→ explicit,压过默认口径', async () => {
    const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1', targetCount: 200 });
    expect(r.target).toBe(200);
    expect(r.batchSize).toBe(200);
    expect(r.basis).toBe('explicit');
  });

  /**
   * 🔴 按客服精调必须**穿到落人那一层**。
   * 「李莉这周带教,这批最多 5 条」—— 只改展示不改落人的话,她照样被水位法灌满,
   * 而卡片上写着 5。这种错不报错,要到她抱怨时才发现。
   */
  test('🔴 李莉本批最多 5 条 → 她只拿 5 条,其余照样分掉(总数不变)', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: { expiresInDays: 3, agentOverrides: { a3: { maxThisBatch: 5 } } },
    }).propose(SCOPE, { clinicId: 'c1', targetCount: 100 });
    const lily = r.byAgent.find((x) => x.userId === 'a3')!;
    expect(lily.count).toBe(5);
    expect(lily.overridden).toBe(true);
    // ⚠️ 名额上限**不改批次规模** —— 少的那部分由别人接走
    expect(r.placed).toBe(100);
    /**
     * 🔴 精调过的人**要点名** —— 「为什么李莉只有 5 条」必须答得上来。
     * ⚠️ 2026-08-14 迁移时发现:这件事此前**只在 basisNote 里,而 basisNote 没人读** ——
     *   也就是说这个能力早就不在了,删 basisNote 只是让它显形。现在补进了 modelFacts。
     */
    const tuned = (modelFacts(r) as { 怎么派的: Record<string, unknown> }).怎么派的['他单独设过的'];
    expect(JSON.stringify(tuned)).toContain('"这批最多给他":5');
  });

  test('⭐ 精调成 0 → 这轮完全不给他,并列进 skippedAgents', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: { agentOverrides: { a3: { maxThisBatch: 0 } } },
    }).propose(SCOPE, { clinicId: 'c1', targetCount: 100 });
    expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a3')).toBeUndefined();
    expect(r.skippedAgents.map((x: { userId: string }) => x.userId)).toContain('a3');
    expect(r.placed).toBe(100);
  });

  test('⭐ 时效精调只落在那个人身上,不动整体基数', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: { expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } },
    }).propose(SCOPE, { clinicId: 'c1', targetCount: 100 });
    // ⚠️ 断的是"整体没被那个人的精调带偏",⛔ 不锁具体天数(它随默认口径变)
    expect(r.byAgent.find((x) => x.userId === 'a3')!.expiresInDays).toBe(7);
    expect(r.byAgent.find((x) => x.userId === 'a0')!.expiresInDays).toBe(r.expiresInDays);
  });

  test('⛔ 精调表里的垃圾值静默丢弃,不让一条坏精调把整张确认单顶掉', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: {
        agentOverrides: { a1: { maxThisBatch: -1 }, a2: { maxThisBatch: '很多' }, a3: { expiresInDays: 999 } },
      },
    }).propose(SCOPE, { clinicId: 'c1', targetCount: 100 });
    expect(r.agentOverrides).toEqual({});
    expect(r.placed).toBe(100);
  });

  /**
   * 🔴 **运营结论（opsNote）已于 2026-08-13 整块删除。**
   *
   * 2026-08-04 产品评审要的是"结论性的东西"（本批拿到几条 / 分完手上几条 / 要打几天）。
   * 那个诉求没变，只是**出口换了**——同一批数现在有三处，卡片那份是第三遍：
   *   ① 模型正文（modelFacts 的「本批每人拿到 / 分完之后每人手上」）;
   *   ② 引导节点 `batch_size_basis`（这批多大 = 在岗 × 每天几通 × 时效，可改）;
   *   ③ 引导节点 `daily_overload`（最忙那位手上共几条、约几天的量）。
   * ⇒ 下面只留 basisNote 那半条不变式（增量与总量**仍然不许混**）。
   */

  test('🔴 ⛔ basisNote 不许复述「分完每人多少条」—— 那是在手总量,不是这批给他的量', async () => {
    const r = await mk(1000, 3, { inHand: 0, lastCriteria: { expiresInDays: 3 } })
      .propose(SCOPE, { clinicId: 'c1', targetCount: 9 });
    /**
     * 🔴 增量与总量**不许混**（2026-08-04 产品点名过的误读:实际每人 1~4 条,却写着 57~62）。
     * ⚠️ 2026-08-14 从 basisNote 迁到 modelFacts:那边把两个数**分成两个键**给,
     *   结构上就混不了 —— 这比"别写成一句话"更硬。
     */
    const df = modelFacts(r) as { 怎么派的: Record<string, unknown> };
    expect(df.怎么派的['本批每人拿到']).toBeDefined();
    expect(df.怎么派的['分完之后每人手上']).toBeDefined();
    expect(String(df.怎么派的['落人规则'])).toContain('有专属的先回自己人手上');
  });
});

/**
 * 🔴 **精调怎么撤** —— 唯一的表达是**传空对象**。
 *
 * 精调（「某人这批最多 5 条」）会被记住，**省略等于沿用**：不传他，上次那条原封不动跟过来。
 * 于是「他恢复正常」这句话没有对应的表达 —— 除非传 `{ "u1": {} }`。
 *
 * ⚠️ 这个能力靠的是 `mergeOverrides` 里**先合并、后 sanitize** 的顺序，
 *   而那看起来像是可以"顺手整理"的两行。整理掉的后果是：主管说了「某某恢复正常」，
 *   下一批他照旧被卡在 5 条，而**没有任何报错** ⇒ 用测试钉住。
 */
describe('按客服精调的合并与撤销', () => {
  test('⭐ 本次传的压过沿用的（同一个人两处都有 → 本次赢）', () => {
    expect(mergeOverrides({ u1: { maxThisBatch: 5 } }, { u1: { maxThisBatch: 2 } })).toEqual({
      u1: { maxThisBatch: 2 },
    });
  });

  test('⭐ 没传的原样沿用 —— 「省略」不是「撤销」', () => {
    expect(mergeOverrides({ u1: { maxThisBatch: 5 } }, { u2: { expiresInDays: 7 } })).toEqual({
      u1: { maxThisBatch: 5 },
      u2: { expiresInDays: 7 },
    });
  });

  test('🔴🔴 传空对象 = 撤掉他的精调（⛔ 别改成先 sanitize 再合并，那会让撤销静默失效）', () => {
    expect(mergeOverrides({ u1: { maxThisBatch: 5 }, u2: { maxThisBatch: 3 } }, { u1: {} })).toEqual({
      u2: { maxThisBatch: 3 },
    });
  });

  test('⛔ 撤销不该靠传一个大数去盖 —— 那个假值会被记成新的精调', () => {
    // 这条不是在测实现,是把"为什么空对象是唯一正解"写成可执行的说明:
    // 999 确实解除了限制,但它**留在了结果里**,下一批照样沿用。
    expect(mergeOverrides({ u1: { maxThisBatch: 5 } }, { u1: { maxThisBatch: 999 } })).toEqual({
      u1: { maxThisBatch: 999 },
    });
  });
});
