import { AssignStrategy, type AgentInfo } from '@pac/types';
import { placeAgents } from '../src/modules/plan/assignment-proposal.service';

/**
 * 「专属优先 / 溢出铺平」落人算法回归。
 *
 * 这是主管每天都要看的那张确认单背后的算法 —— 它错了不会报错,
 * 只会让主管把单分给不该分的人,而他要到客服抱怨时才知道。
 */

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,
  }));

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

  /**
   * 🔴🔴 **专属受目标水位封顶,超出的改派**(2026-08-03 产品定:又满又平)。
   *
   * 不封顶的话专属集中的诊所会一个人吃掉整批 —— 本地实测:池子 70% 挂在同一个人名下,
   * 他一批拿 248 条,而 17 位在岗里有 10 位名下一个患者都没有。
   */
  test('🔴 3 条全是 a 的专属、两人在岗 → a 拿 2(水位),第 3 条改派给 b 并标 overflow', () => {
    const chosen = pick(3);
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
    const { placed, unplaced } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
    expect(unplaced).toBe(0);
    expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
    // ⚠️ 改派的那条标 spread_overflow:**关系还在,只是这轮没轮到**
    expect(placed.find((p) => p.assigneeUserId === 'b')!.assignStrategy).toBe(
      AssignStrategy.SPREAD_OVERFLOW,
    );
  });

  test('🔴 精调名额压过专属:a 只给 2 条,她剩下的专属患者改派给 b(⛔ 不丢)', () => {
    const chosen = pick(5);
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
    const { placed, unplaced } = 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(3);
  });

  /**
   * 🔴🔴 **顺序**:无主的先去补空手的人,不够了才动有主的。
   *
   * 两趟都是水位法,合并起来总量一样 —— 但**被拆散的专属关系数不一样**。
   * 合成一趟的话,系统会随机地把某个有主患者改派出去,而同时某个无主患者落给了别人。
   */
  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('⭐⭐ 三种归属标记各归各的(dedicated / no_dedicated / overflow)', () => {
    // 6 条:4 条是 a 的专属,2 条无主;两人在岗,水位 = 3
    const chosen = pick(6);
    const dedicated = new Map(chosen.slice(0, 4).map((c) => [c.patientId, 'a']));
    const { placed } = 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]).toBe(1); // 第 4 条专属改派给 b
    // 又满又平
    expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(3);
    expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(3);
  });
});

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

  /**
   * 🔴🔴 **又满又平**:全是同一个人的专属,照样铺平到所有人头上,一条不丢。
   * 这正是"专属集中的诊所"那个场景 —— 池子 70% 挂在一个人名下。
   */
  test('🔴 9 条全是 a 的专属、3 人在岗 → 各 3 条(满且平)', () => {
    const chosen = pick(9);
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
    const { placed, unplaced } = 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, 3, 3,
    ]);
  });

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

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 —— 候选不够时的措辞', () => {
  const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service');

  /**
   * ⚠️ 这个假 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, roster);
  }
  const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
  /// 首次估法:在岗 9 人 × 20 = 180(⚠️ 那个 20 不是容量上限,只是首次没有历史时的估法)
  const N = 9 * 20;

  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 → 照实说「一共 500 人,这批挑了 180」+ 剩下的还在排队', async () => {
    const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
    expect(r.target).toBe(N);
    expect(r.selectionNote).toContain('一共 500 人');
    expect(r.selectionNote).toContain(`这批挑了 ${N} 人`);
    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',
    });
    expect(r.candidateTotal).toBe(1080);
    expect(r.selectionNote).toContain('一共 1080 人');
    expect(r.placed).toBe(N);
  });
});

/**
 * 两个基数(本批人数 + 时效)的**沿用**,以及"连着分第二批"。
 *
 * 🔴 这是「先出确认单、主管反馈调整」这个设计的兑现点:第一次调顺手,第二次起零输入。
 * 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。
 */
describe('基数沿用 —— 本批人数与时效', () => {
  const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service');
  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, roster);
  };

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

  test('⭐⭐ 上次用了 300 人 / 5 天 → 本次沿用,并说出沿用哪一次', async () => {
    const r = await mk(1000, 9, { lastCriteria: { batchSize: 300, expiresInDays: 5 } }).propose(SCOPE, {
      clinicId: 'c1',
    });
    expect(r.target).toBe(300);
    expect(r.expiresInDays).toBe(5);
    expect(r.basis).toBe('inherited');
    expect(r.basisNote).toContain('2026-07-28');
  });

  /**
   * ⚠️ 老快照里这个数叫 `target`(那时它是推出来的结果)。不认的话,改版后所有人的
   * "沿用"都会静默退回默认值,而界面只显示"首次默认" —— 看不出丢了东西。
   */
  test('⭐ 老快照只有 target(没有 batchSize)→ 一样认', async () => {
    const r = await mk(1000, 9, { lastCriteria: { target: 250, expiresInDays: 7 } }).propose(SCOPE, {
      clinicId: 'c1',
    });
    expect(r.target).toBe(250);
    expect(r.basis).toBe('inherited');
  });

  test('⭐ 从来没分过 → 首次按「在岗 9 人 × 20」估 = 180 / 3 天,且说清是估的', async () => {
    const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1' });
    expect(r.batchSize).toBe(180);
    expect(r.target).toBe(180);
    expect(r.expiresInDays).toBe(3);
    expect(r.basis).toBe('default');
    expect(r.basisNote).toContain('第一次的估算值');
    expect(r.basisNote).toContain('每位客服 20 条');
    // ⛔ 同上:说人话,不许出现内部词
    for (const w of ['水位', '基数', '铺平', '溢出']) expect(r.basisNote).not.toContain(w);
  });

  /**
   * 🔴 候选不够时压低的是**这一批**,⛔ 不是基数。
   * 回写的话:主管点一次 44 人的小格子,以后所有批次永远是 44 人,而他看不出为什么。
   */
  test('🔴 上次 300 人、这批候选只有 44 → 本批 44,但基数仍是 300', async () => {
    const r = await mk(44, 9, { lastCriteria: { batchSize: 300, expiresInDays: 5 } }).propose(SCOPE, {
      clinicId: 'c1',
    });
    expect(r.target).toBe(44);
    expect(r.batchSize).toBe(300);
    expect(r.basisNote).toContain('您设的 300 没变');
  });

  test('⭐ 主管本次说「这批 200 人」→ explicit,且下次会沿用', async () => {
    const r = await mk(1000, 9, { lastCriteria: { batchSize: 100, expiresInDays: 3 } }).propose(SCOPE, {
      clinicId: 'c1',
      targetCount: 200,
    });
    expect(r.target).toBe(200);
    expect(r.basis).toBe('explicit');
    expect(r.basisNote).toContain('下次自动沿用');
  });

  /**
   * 🔴 按客服精调必须**穿到落人那一层**。
   * 「李莉这周带教,这批最多 5 条」—— 只改展示不改落人的话,她照样被水位法灌满,
   * 而卡片上写着 5。这种错不报错,要到她抱怨时才发现。
   */
  test('🔴 李莉本批最多 5 条 → 她只拿 5 条,其余照样分掉(总数不变)', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: { batchSize: 100, expiresInDays: 3, agentOverrides: { a3: { maxThisBatch: 5 } } },
    }).propose(SCOPE, { clinicId: 'c1' });
    const lily = r.byAgent.find((x: { userId: string }) => x.userId === 'a3');
    expect(lily.count).toBe(5);
    expect(lily.overridden).toBe(true);
    // ⚠️ 名额上限**不改批次规模** —— 少的那部分由别人接走
    expect(r.placed).toBe(100);
    expect(r.basisNote).toContain('这批最多 5 条');
  });

  test('⭐ 精调成 0 → 这轮完全不给他,并列进 skippedAgents', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: { batchSize: 100, agentOverrides: { a3: { maxThisBatch: 0 } } },
    }).propose(SCOPE, { clinicId: 'c1' });
    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: { batchSize: 100, expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } },
    }).propose(SCOPE, { clinicId: 'c1' });
    expect(r.expiresInDays).toBe(3);
    expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a3').expiresInDays).toBe(7);
    expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a0').expiresInDays).toBe(3);
  });

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

  test('⭐ 分配后的水位要写进 basisNote(没有阈值判断,只报事实)', async () => {
    const r = await mk(1000, 3, { inHand: 0, lastCriteria: { batchSize: 9, expiresInDays: 3 } })
      .propose(SCOPE, { clinicId: 'c1' });
    expect(r.byAgent.every((x: { loadAfter: number }) => x.loadAfter === 3)).toBe(true);
    expect(r.basisNote).toContain('分完后每人手里 3 条');
  });
});
