import { z } from 'zod';
import { AssignStrategySchema } from '../enums';

// =============================================================
// 批次分配(门诊经理)—— POST /pac/v1/plans/assignments
// =============================================================

/**
 * 批次里的一条 —— **(planId, assigneeUserId) 对**,不是 `{assigneeUserId, planIds[]}` 分组。
 *
 * 为什么不用分组形状(看着更省字节):
 *   1. 归属是**逐条**算出来的 —— 溢出转铺平后同一个客服名下会混着 dedicated 与
 *      spread_overflow 两种来源,分组形状表达不了「这一条为什么落到他头上」。
 *   2. T13 允许主管在确认单上微调时限,而微调是**逐条**的
 *      (「本批 3 天,其中 5 条单独设了 7 天」)—— 分组形状里那 5 条无处安放。
 */
export const AssignmentItemSchema = z.object({
  planId: z.string().uuid(),
  /// 宿主侧 user id(与 JWT.sub 同一 id 空间,已于 2026-08 实测 81% 重合验证)
  assigneeUserId: z.string().min(1),
  /// ⭐ 逐条带:这条是怎么落到他头上的。事后补不回来,见 AssignStrategy 的注释
  assignStrategy: AssignStrategySchema,
  /// 单条覆盖批次时限(不传 = 跟批次)。相对天数,与批次同口径,服务端统一转绝对时刻
  expiresInDays: z.number().int().positive().max(90).optional(),
  /**
   * 这条是**怎么被选进批次**的。
   *   rank    按排序键正常入选(默认)
   *   explore 随机探索配额抽中的
   *   swap    **顶替进来的** —— 原本排在 N 名之外,主管点了「用无主患者顶替待分配」才进来
   *
   * ⭐ 探索配额是整套系统里**唯一的因果抓手**:入选本身与结果相关(高分的人本来就更容易成),
   * 纯观察数据永远解不开"是排序键选得准,还是这批人本来就好"。留了配额却不标记,
   * 等于白留 —— 事后无法把两组分开。
   *
   * 🔴 `swap` 必须单列,⛔ 不能混进 `rank`(2026-08-06):顶替进来的人**排名本来就靠后**,
   *   把他们算作"按排序键选中的",会直接压低 rank 组的表现 —— 而 rank 组的表现正是
   *   探索配额要拿来对照的基准。混一次,这个基准就废了,且事后无法拆开。
   */
  selectionMode: z.enum(['rank', 'explore', 'swap']).optional(),
});
export type AssignmentItem = z.infer<typeof AssignmentItemSchema>;

/**
 * ⚠️ 500 是**技术护栏**(单事务 + PG bind 变量上限),不是批次规模的业务上限。
 * 业务上限该由产品定(教条七·待确认),定了之后加在**助手圈人那一层**,别写死在这里 ——
 * 写在这里会让"技术能不能扛"和"运营该不该分这么多"两件事永远分不开。
 */
export const ASSIGNMENT_ITEMS_HARD_LIMIT = 500;

export const CreateAssignmentRequestSchema = z.object({
  /**
   * 幂等键 —— **服务端在生成确认单(propose)时铸造**,随确认单下发、确认时原样回传。
   * ⛔ 不由前端或模型生成:挡不住 SSE 重连与模型重试,而"重复分配"没有天然自然键可去重
   * (同一主管同一秒对同一批人再点一次,在业务上是合法操作)。
   */
  requestId: z.string().min(8).max(128),
  /// 批次不跨诊所(福利政策与效果归因都按诊所走)
  clinicId: z.string().min(1),
  /// 初筛条件快照 + 收敛规则。快照而非引用:口径会变,批次要能解释"当时按什么圈的"
  criteria: z.record(z.string(), z.unknown()),
  /// 附加属性。福利 v1 不核销、不接卡券,只是话术勾子 + 归因标签
  attributes: z
    .object({ benefit: z.object({ text: z.string().min(1).max(200) }).optional() })
    .optional(),
  /**
   * 批次时限(相对天数)。
   * ⚠️ 收相对天数而不是绝对时刻:让模型算 ISO 时刻正是本仓 commit 4549817 修过的坑
   * (纯日期被当成 UTC 零点,整整偏 8 小时)。服务端按 host 时区转成**当地日末**。
   */
  expiresInDays: z.number().int().positive().max(90),
  items: z.array(AssignmentItemSchema).min(1).max(ASSIGNMENT_ITEMS_HARD_LIMIT),
});
export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSchema>;

// =============================================================
// 客服名册 + 负载  —— GET /pac/v1/plans/agents
// =============================================================

/**
 * ═══ 分配只有两个基数:**本批人数 N** 和 **时限** ═══════════════════
 *
 * 两者的取值方式完全一致(2026-08-03 产品定):
 *   **沿用该主管在该诊所上一次分配用的值**;没有上一次才用下面的默认。
 *
 * ⭐ 为什么是"沿用"而不是"每次问":确认单本来就是给主管调的 ——
 *   第一次他把人数和时限调到顺手,系统记住,**第二次起他什么都不用输**。
 *   这是"先出全景确认单、再让主管反馈调整"这个设计的兑现点:调一次,以后省一次。
 * ⛔ 所以别把它们做成"每次弹窗问一遍"或"永远用系统默认" —— 前者违 T13(直出不追问),
 *   后者让第一次的调整白费。
 *
 * ⚠️ **没有"容量"这第三个数**(它被删过一次,别再加回来 —— 理由见 BATCH_SIZE_PER_AGENT_FIRST)。
 *   负载不靠阈值表达:落人走**水位法**(从在手最少的人开始填),手上多的人这轮自然少拿。
 */

/**
 * **首次**分配时,每个在岗客服按多少条估 —— 首批 N = `min(在岗人数 × 20, 本批实际候选数)`。
 *
 * ⭐ 2026-08-03 定案:分配只有 **本批人数 N** 和 **时限** 两个基数,⛔ **没有"容量"这个数**。
 *   这里的 20 **不是容量上限**,只是"第一次没有任何历史时,一批推多大"的估法 ——
 *   它只在**首次**参与一次计算,之后 N 沿用主管上次用的值,这个常量再也不出现。
 *
 * ── 为什么容量被删掉 ────────────────────────────────────────
 * 它当过一阵"每人在手上限",并用来推批次规模(`Σ 容量−在手`)。两个问题:
 *   ① **第二批必然是 0** —— 第一批把所有人填到水位 20,再算就是 `Σ(20−20)`。
 *     主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
 *   ② 真正要表达的是**负载**,而负载不需要阈值:**在手量本身就是负载**,
 *     水位法直接拿它排序、从最空的人开始填,压力已经被表达了。
 * 再挂一条"不强制的上限"比没有更糟 —— 主管会以为系统在拦,其实没拦(T14:别给假证据)。
 *
 * ⛔ 别给 N 加上下限:这是主管对自己团队的判断,系统没有依据卡他。
 */
export const BATCH_SIZE_PER_AGENT_FIRST = 20;

/**
 * **每人每天打得完多少通** —— 只用来把"手上多少条"翻译成"要打几天"。
 *
 * ⭐ 由来(2026-08-04 产品评审原话):主管说他没法逐条审,
 *   「你在这儿应该给的是结论性的东西 —— 你要打 180 个电话,
 *     根据你在岗的人数这一拨电话要打几天,每个客服日均是多少个」。
 *   ⇒ 卡片必须把「分完他手上 58 条」换算成「约 4 天的量」,那才是他要的结论。
 *
 * ⚠️⚠️ 它**不是**上限、不是配额、⛔ 更不参与落人 —— 落人只有水位法,没有阈值
 *   (见上面 BATCH_SIZE_PER_AGENT_FIRST 里"容量为什么被删掉")。
 *   ⛔ 别把它接进 placeAgents / capOf,那等于把删掉的"容量"从后门放回来。
 * ⚠️ 15 是评审当天口头给的经验值(原话「我觉得一天打超过 15 个就没意义了」),
 *   **不是实测**。真值应该等跑起来后从 plan_executions 反推 —— 那之前它就是个换算尺,
 *   所以卡片上必须写成「按每天 15 通算」而不是「需要 4 天」,让主管看得见这个前提。
 */
export const DAILY_CALLS_PER_AGENT = 15;

/**
 * 批次时限的默认天数。
 *
 * 🔴 2026-08-12 由 3 改为 **1**（产品定）：走「**每天推一天的活**」的节奏 ——
 *   N = 在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时限，D=1 时正好是一天的量，
 *   当天没打完的自动落回池子、明天重排。
 * ⚠️ 连带后果（已知并接受）：`daily_overload` 的阈值随之变成 `15 × 1`，
 *   于是**只要谁手上还有旧单，这个节点就会亮**。那不是 bug ——
 *   它是工作量预估，每批本来就该讲清楚（见 assignment-signals 里该节点的注释）。
 */
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 1;
/**
 * 卡片上时限可选的天数。**给全部 1-7 天**,不是 3/5/7 三档。
 *
 * ⭐ 档位是我们拍的,而主管对自己团队的节奏有判断(「明天就要结果」= 1 天;「这周内」= 5 天)——
 *   ⛔ 别用三个我们拍的档位去限制他。
 * ⚠️ 到 7 为止不是业务上限:契约允许 90 天,真要更长在对话里说个数,助手照传。
 * ⚠️ 时限**不改人群**,所以留在卡片上;改人数会换一批人,那必须回对话重出单。
 */
export const ASSIGNMENT_EXPIRES_DAYS_PRESETS: readonly number[] = [1, 2, 3, 4, 5, 6, 7];

export const AgentInfoSchema = z.object({
  userId: z.string(),
  /// 服务端解析好的姓名(PAC 无 users 表,前端翻不了 —— 见 agent-roster.service 注释)
  name: z.string().nullable(),
  /// 在手总量,**跨诊所全量** —— 容量是人的属性不是诊所的属性(实测 24% 客服跨诊所)
  inHand: z.number().int(),
  /// 其中属于本诊所的。⚠️ 只用于展示拆分,**不参与容量判断** ——
  /// 不拆开显示的话,跨诊所客服会显得"手上很空",主管第一反应是数据错了
  inHandThisClinic: z.number().int(),
  recentVisits: z.number().int().describe('在岗窗口内的回访条数'),
  lastVisitAt: z.string().nullable(),
  /// 是否在名册内。⚠️ **不是"能不能分"** —— 名册是建议来源不是白名单,
  /// 实测有客服只做召回不做回访(回访数 0),分给他完全合法
  inRoster: z.boolean(),
  /// ⛔ 这里**不返回容量/上限之类的数** —— 那个概念已经删掉(见 BATCH_SIZE_PER_AGENT_FIRST)。
  /// `inHand` 就是负载本身,落人按它走水位法;返回一个区间只会被读成"合法范围"。
});
export type AgentInfo = z.infer<typeof AgentInfoSchema>;

export const ListAgentsResponseSchema = z.object({
  clinicId: z.string(),
  rosterMonths: z.number().int(),
  agents: z.array(AgentInfoSchema),
  /**
   * ⭐ **给助手直接照抄的成品句子**,不是给人读的说明。
   * T14 的落地方式是「提示词 + 工具返回值」双保险:LLM 做阈值判断和写免责声明
   * 都不可靠,把该说的话直接给它,任务就从"让模型算对"降级成"让模型照抄"。
   */
  rosterNote: z.string(),
});
export type ListAgentsResponse = z.infer<typeof ListAgentsResponseSchema>;

/// 没落上的单及原因 —— 助手照着这个说人话,别让主管对着 uuid 猜
export const AssignmentSkippedSchema = z.object({
  planId: z.string(),
  reason: z.enum([
    /// 已被别人认领 / 已分给别人(现有摩擦,教条七·待确认是否允许强制改派)
    'claimed_by_other',
    /// 已终态(completed / abandoned / superseded)
    'terminal',
    /// 同一患者在本批里有多条活动 plan,只留优先级最高的一条
    'duplicate_patient',
  ]),
});
export type AssignmentSkipped = z.infer<typeof AssignmentSkippedSchema>;

export const CreateAssignmentResponseSchema = z.object({
  assignmentId: z.string(),
  /// true = 同 requestId 重复提交,返回的是**已有批次**,本次没产生任何新写入
  duplicate: z.boolean(),
  assigned: z.number().int(),
  skipped: z.array(AssignmentSkippedSchema),
  /// 落库后的批次时限(绝对时刻,前端直接展示,不要自己再算一遍)
  expiresAt: z.string(),
});
export type CreateAssignmentResponse = z.infer<typeof CreateAssignmentResponseSchema>;

// ── 批次查询(跟踪的最小形态;完整跟踪见 P5)────────────────────

export const AssignmentBriefSchema = z.object({
  id: z.string(),
  clinicId: z.string().nullable(),
  createdBy: z.string(),
  /// 服务端解析好的姓名 —— 前端拿不到 userId→姓名的字典(只覆盖当前登录人)
  createdByName: z.string().nullable(),
  criteria: z.record(z.string(), z.unknown()),
  benefitText: z.string().nullable(),
  status: z.string(),
  expiresAt: z.string(),
  createdAt: z.string(),
  /**
   * ⭐ 人话批次名 —— 「8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。
   *
   * 主管记不住也不该记 uuid。**没有批次列表页**的情况下,他指认一批的唯一办法就是描述它
   * (「撤销今天下午牙周窗口内那批」),模型再从列表里按这个名字匹配。
   * ⚠️ **服务端生成**,⛔ 不许让模型自己拼:措辞会在两轮之间漂,主管就对不上"上次说的那批"。
   * ⚠️ 时间精确到分钟是主要区分器 —— 同一个矩阵格子一天可能分好几批。
   */
  label: z.string().describe('人话批次名(服务端生成,唯一对外称呼)'),
  /// 以下计数**现算不落列**(T17)。⚠️ 但数据源分两处,别搞混:
  ///   · planned / agents / released / expired —— 从**账本** `plan_event_logs` 算(历史事实,永不变)
  ///   · inHand / done                        —— 从 `followup_plans` 现算(当前状态,会变)
  /**
   * 本批**当时**分了多少人(按患者去重)。
   *
   * 🔴 源必须是账本,⛔ 不能数 `followup_plans.assignment_id`:那一列**会被下一次分配覆盖**。
   *   一条单退回/到期回池后再被分进新批次,旧批次就少一个人 —— **批次跑得越久
   *   (退回重分越多)缩得越厉害**,且不报错。实测 c02e1b80 分过 9 条、重分后显示 0。
   * ⚠️ 口径也不是「活跃版本」—— 那会把引擎判定"需求已了"的人剔掉,同一个坑的另一面。
   */
  planned: z.number().int().describe('本批当时分了多少人(按患者去重,取自账本)'),
  inHand: z.number().int().describe('仍挂在客服名下(当前状态)'),
  /**
   * 客服**主动退回**的条数(取自账本 event='release')。
   * ⚠️ ⛔ 不数 `followup_plans.release_reason` —— 重分时那一列会被清空(assign 的
   *   SQL 里 `release_reason = NULL`),数它等于"被退过又被重分的人从没退过"。
   * ⚠️ **不含到期**:到期是"客服压根没动",与"看了判断不该我做"是两回事,见 expired。
   */
  released: z.number().int().describe('客服主动退回(不含到期)'),
  /**
   * 到期自动回池的条数(账本 auto_release + reason=assignment_expired)。
   *
   * ⚠️ 这是**"没处置"**,不是"处置了"。它和 released 必须分开看,因为主管的下一步相反:
   *   退回多 → 分配策略不对(派给了不该派的人);
   *   到期多 → 派多了 / 时限太紧 / 人不在岗。
   *   合成一个"回池率"两种病都看不出来。
   */
  expired: z.number().int().describe('到期自动回池(客服没动)'),
  agents: z.number().int().describe('涉及几个客服(取自账本)'),
  /**
   * 已处理条数 —— 判据是**池子状态**(出池 / 被抑制),不是回写。
   * 详见 `AssignmentDetailResponseSchema.progress` 的整段说明。
   * ⚠️ 是**处理**不是**成功**:只说"这单动过了",不说"谈成了"。
   */
  /**
   * 「已处理」——**池子口径**:召回已出池 / 被抑制 / 已结案。
   * ⚠️ 它答的是「**这批还剩多少活**」,含引擎自己判定无活信号的(患者自己来了,客服没动)。
   * ⛔ **别拿它按人拆** —— 那会把"没做"显示成"做了"。按人看用 `handled`。
   */
  done: z.number().int(),
  /**
   * 「已处置」——**执行口径**:本批里真的落了通话结果的条数。
   * ⭐ 主管工作台的「已处置」列用这个:它是"**有人真的做了事**"的唯一证据,
   *   而 `agentStats[].done` 也是同一口径 —— 各客服加起来能对上这个数。
   * ⚠️ 回写率低时这个数会明显小于 `done`(本地实测 5 vs 2、生产回写率约 11%)——
   *   ⛔ 那是**真实情况**不是 bug,别为了"好看"换回池子口径。
   */
  handled: z.number().int(),
  /**
   * 「约上」—— 本批里最近一次通话结果落在 `close` 组的人数(转化新预约 + **约定下次回访**)。
   *
   * ⚠️ 与 detail 的 `outcomes.success` **同一定义**,⛔ 别只数"转化新预约":
   *   两处都写着"约上",小一号主管就只能猜哪个对。
   * ⚠️ 上线初期这个数会长期是 0 或个位数 —— ⛔ **不许拿它算转化率**,
   *   样本不足时要明说(见 outcomes.note 的写法),画出 0.x% 会让主管以为功能没用。
   */
  booked: z.number().int(),
});
export type AssignmentBrief = z.infer<typeof AssignmentBriefSchema>;

export const ListAssignmentsResponseSchema = z.object({
  items: z.array(AssignmentBriefSchema),
  /// 一共多少批(不受分页影响)—— 界面上「已显示 12 / 137 批」的分母
  total: z.number().int(),
  /**
   * 下一页的游标(上一页最后一条的 createdAt);null = 没有下一页了。
   * ⚠️ 游标是**时间**不是 offset:批次一直在新增,offset 分页翻第二页会重复或漏行。
   */
  nextBefore: z.string().nullable(),
});
export type ListAssignmentsResponse = z.infer<typeof ListAssignmentsResponseSchema>;

/// 单批里某个客服的承接情况
export const AssignmentAgentStatSchema = z.object({
  userId: z.string(),
  name: z.string().nullable(),
  planned: z.number().int(),
  inHand: z.number().int(),
  released: z.number().int(),
  /**
   * 仍在手、已过时限、**且没有约定回访**。
   *
   * 🔴 「且没有约定回访」这半句不能少。到期回收器刻意跳过 `snoozedUntil` 在未来的单
   * (客服约了 6/10 回访就绝不能在那之前收走,否则毁掉他对患者的承诺)——
   * 于是这类单会一直"超期"下去。而 `progress` 那边已经把它算作 **suppressed(已处理)**。
   * 不加这条守卫,同一条单就会「已处理」和「超期」同时成立:主管看到「薛玫 超期 3」
   * 以为她压着单没动,**实际上她打了电话、约好了下次** —— 干得最好的那个被指责了。
   */
  overdue: z.number().int().describe('仍在手、已过时限、且没约下次回访'),
  /**
   * 这个客服在本批里已处置多少条。
   * ⚠️ 判据与批次级的 `progress.done` **同一个函数**(classifyPlanProgress)——
   *   ⛔ 各客服的 done 加起来必须等于表头那个总数,主管一定会去加。
   */
  done: z.number().int(),
});
export type AssignmentAgentStat = z.infer<typeof AssignmentAgentStatSchema>;

export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
  /// ⚠️ 叫 agentStats 不叫 agents:Brief 里的 `agents` 是**人数**(number)。
  /// 同名不同型会让前端拿着 brief 的类型去读 detail 时静默拿到 undefined。
  agentStats: z.array(AssignmentAgentStatSchema),
  /// 退回原因分布。⚠️ 聚合时**必须同时过滤 event='release'** —— 同一列还存着
  /// auto_release 的 timeout/clinic_moved 与 feedback 的 up/down,不过滤就全混进来了
  releaseReasons: z.array(
    z.object({ reason: z.string(), labelZh: z.string(), n: z.number().int() }),
  ),
  /**
   * ⭐ 本批的**通话成效** —— 客服真打了之后的结果分布(来自 plan_executions)。
   *
   * ⚠️⚠️ 与上面的 `releaseReasons` 是**两件完全不同的事**,⛔ 绝不能合并或互相顶替:
   *   · 退回原因 = 「这单**不该我做**」—— 客服**没打**,把单还回去了(分配问题)
   *   · 通话结果 = 「打了,结果是**这样**」—— 客服**做了事**(召回效果问题)
   *   混在一起的话,"退回 5 条"和"打了但被拒 5 条"会读成同一个数,
   *   而前者要改的是分配策略、后者要改的是话术和人群 —— 方向完全相反。
   *
   * ⚠️ 每个患者**只取最近一次**执行,⛔ 不是数执行条数:
   *   打了三次才约上,那是**一个**成功,不是三个。数条数会让"成功数"随拨打次数膨胀。
   * ⚠️ 四个桶**穷尽**:success + failed + keep + noOutcome === planned(回归里锁着)。
   * ⚠️ ⛔ 这里**不掺 `progress.reassigned`**:「现在归谁」和「做没做」是**正交**的两个维度 ——
   *   被后面批次挑走的人若在本批期间打过电话,那条执行仍算本批的成效。
   *   把两者放进同一个减法会重复扣减,noOutcome 被压成 0(踩过)。
   */
  outcomes: z.object({
    /// 成功(EXECUTION_OUTCOME_GROUP close):转化新预约 + 约定下次回访
    success: z.number().int(),
    /// 不成功(give_up):明确拒绝 / 已在外院 / 再考虑
    failed: z.number().int(),
    /// 打了但没进展(keep):未接通 / 秒挂
    keep: z.number().int(),
    /// **一次执行结果都没有**的人数 —— ⚠️ 这个数才是主管最该先看的:
    /// 它不是"效果不好",是"根本没做/没记"。混进 failed 会把执行问题读成召回问题。
    noOutcome: z.number().int(),
    /// 明细分布(含中文名与所属组),按人数降序。不成功的「原因」就在这里
    byOutcome: z.array(
      z.object({
        outcome: z.string(),
        labelZh: z.string(),
        group: z.string(),
        n: z.number().int(),
      }),
    ),
    /**
     * 🔴 **逐条明细 + 客服手写的电话纪要**(2026-08-08 新增)。
     *
     * 在此之前 `plan_executions.notes` **全线读不到**:接口 `GET /plans/:id/executions` 一直在,
     * 但前端从没调过、MCP 也没开工具 —— 写进去就再没出来过。
     * 主管看到「不成功 1」只能知道有这么个数,问不出"哪个患者、为什么"。
     *
     * ⚠️ 与上面四个桶**同一口径**:每条单只取**最近一次**执行。
     *   `records.length === success + failed + keep`(「没有结果」的人没有行)——
     *   ⛔ 别在这里为他们造空行,那会让明细和聚合对不上数(T14)。
     * ⚠️ `notes` 为 null = 客服没留纪要(空字符串也归 null)。
     *   ⭐ 这本身是信息:结果填了、纪要空着,说明只点了个选项。
     */
    records: z.array(
      z.object({
        planId: z.string(),
        patientName: z.string().nullable(),
        operatorUserId: z.string(),
        operatorName: z.string().nullable(),
        outcome: z.string(),
        labelZh: z.string(),
        /// close(成功)/ give_up(不成功)/ keep(没进展)—— 与四个桶同源
        group: z.string(),
        /// 触达渠道:phone / wecom / sms / other
        channel: z.string(),
        /**
         * 放弃原因(多选)—— ⚠️ `outcome === 'abandoned'`(「放弃」)时才有,
         * 它是**唯一**带子选项的结果项(2026-08-05:子选项描述的是客服的处置决定,
         * 不是患者态度,所以只挂「放弃」不挂「明确拒绝」)。
         * ⭐ **中文一起给**,⛔ 界面和助手别各自维护翻译表(口径只有一份)。
         */
        abandonReasons: z.array(z.object({ reason: z.string(), labelZh: z.string() })),
        /// 勾了「治疗机会判断不对」时必填的治疗;⚠️ 这是**客服说算法判错了**,调算法要看它
        inaccurateTreatments: z.array(z.object({ code: z.string(), labelZh: z.string() })),
        /// 「约定下次回访」约的是哪天;⚠️ 只有 outcome='scheduled_next' 才有
        scheduledNextAt: z.string().nullable(),
        /// 客服手填的电话纪要;null = 没写
        notes: z.string().nullable(),
        at: z.string(),
      }),
    ),
    /// ⚠️ true = 明细被截断了(上限见服务端 `OUTCOME_RECORD_LIMIT`)。⛔ 界面必须说出来
    recordsTruncated: z.boolean(),
    /**
     * 🔴 这里**曾经有一个 `note: z.string()`(成品句子,助手原话转述),
     *   2026-08-08 产品要求删掉**。
     *
     * 那句话把「给人看的数」和「给模型看的指令」焊在一起
     * (「⛔ 不等于都成交了」「**不要算成功率、不要画图**」),
     * 而工具说明还写着"照抄最稳" —— 结果模型把内部指令原样贴进了对话框。
     * ⛔ 别再加回来:护栏写在**工具说明**里,数留在上面这些结构化字段里。
     */
  }),
  /**
   * ⚠️ **退回率永远给两个数**:分母是"已处置"(在手 + 已退回之外的都还没动过),
   * 而"没动过"的数量本身就是信号(主管据此判断是分多了还是客服没跟)。
   * 只给一个百分比会让主管把"没人动"误读成"做得不错"。
   */
  untouched: z.number().int().describe('分下去后客服从未打开过详情页的条数'),
  /**
   * 处理进度 —— **判据是池子状态,不是回写**(产品裁决 2026-08-02)。
   *
   * 「完成」不需要等成功与否,只要知道**这个患者的召回还在不在池、有没有被抑制**:
   *   · `resolved`  出池了 —— 引擎判定该患者已无活信号(治疗真做了 / 诊断没了)。**零回写依赖**
   *   · `suppressed` 被抑制 —— 客服写了回访结果(约下次 / 拒绝 / 放弃)
   *   · `closed`     已结案
   * 两条判据互补:前者接住不写回访的 89%,后者接住写了的 11%。
   *
   * ⚠️ **是处理率不是成功率**。成功与否现在不算,但没丢 ——
   *   出池原因在 `plan_event_logs`、客观事实在 `patient_facts`(append-only),随时可回溯重算。
   * ⚠️ `done + inHandPending + backToPool` 恒等于 `planned`(五桶穷尽,回归里锁着)。
   */
  progress: z.object({
    done: z.number().int().describe('已处理 = resolved + suppressed + closed'),
    resolved: z.number().int().describe('召回已出池(引擎判定无活信号)'),
    suppressed: z.number().int().describe('被抑制(客服写了回访结果)'),
    closed: z.number().int().describe('已结案 / 已放弃'),
    inHandPending: z.number().int().describe('还在客服手上,尚未处理'),
    backToPool: z.number().int().describe('退回或到期,已落回池子(仍归本批)'),
    /**
     * 已经**被分进别的批次**的人数。
     *
     * 他们当时确实在本批(账本记着),但后来退回/到期回池、又被后面的批次挑走了 ——
     * 此后的表现该算给新批次,本批说不了。
     * ⚠️ 加这一桶是为了让**穷尽性重新成立**:
     *   `done + inHandPending + backToPool + reassigned === planned`(回归里锁着)。
     *   planned 改成账本口径后,不补这一桶就会"加不出来",而少的那几个永远查不出去哪了。
     */
    reassigned: z.number().int().describe('已被后续批次挑走(后续表现归那一批)'),
    ageDays: z.number().int().describe('批次年龄 —— ⚠️ 跨批次比处理率前必须先对齐它'),
    note: z.string().describe('成品句子,助手原话转述'),
  }),
});
export type AssignmentDetailResponse = z.infer<typeof AssignmentDetailResponseSchema>;

// =============================================================
// 全景确认单(助手直出,主管确认)—— 只读提案,不落库
// =============================================================

export const ProposedItemSchema = z.object({
  planId: z.string(),
  patientId: z.string(),
  /**
   * ⭐ 患者姓名 —— **必须带**。主管展开客服看的是"这 20 个人是谁",
   * 光给 `#912e5dae` 这种 id 前缀等于让他对着一串十六进制猜(T14:证据要看得懂)。
   * ⚠️ 可能为 null(宿主没给姓名),前端退回 id 前缀。
   */
  patientName: z.string().nullable(),
  /// 病历号(宿主不一定给:jvs-dw 91% / friday 63%),缺就不占位
  medicalRecordNumber: z.string().nullable(),
  /// ⭐ 性别 / 年龄 —— 与患者详情卡片**同口径**(gender 是原始值,前端用 formatGender 归一)。
  ///   主管扫这一列时看的是"这批人大概是谁",光有姓名不够(同名、判断人群构成都要它)
  gender: z.string().nullable(),
  age: z.number().int().nullable(),
  /**
   * ⭐ 两个医生 —— 主管审这批人时要看的"这人是谁跟的"。
   *
   * ⚠️ `attendingDoctor`(主治)口径与**患者详情页完全一致**:触发本次召回那条诊断的
   *   真实医生,排除影像 AI,没有则回落全量最高频真人医生(见 assignment-proposal 的 doctorsOf)。
   *   ⛔ 别在这里另定口径 —— 确认单显示 A 医生、点进详情显示 B 医生,而且不报错。
   * ⚠️ 都可能为 null(宿主没给医生名 / 只有影像 AI),前端不占位。
   */
  attendingDoctor: z.string().nullable(),
  /// 最近一次**就诊**的医生(接诊 / 病历 / 实际治疗,口径同"距上次就诊天数")
  lastVisitDoctor: z.string().nullable(),
  assigneeUserId: z.string(),
  assignStrategy: AssignStrategySchema,
  /// 见 AssignmentItemSchema.selectionMode —— `swap` 是顶替进来的,⛔ 别混进 rank
  selectionMode: z.enum(['rank', 'explore', 'swap']),
});
export type ProposedItem = z.infer<typeof ProposedItemSchema>;

/**
 * 🔴 **待分配** —— 有专属客服、但那位客服这轮已排满的患者。
 *
 * ── 为什么单独一类(2026-08-06 产品定,改掉了原来的「有主改派」)──────────
 * 原来第三趟会把这些人**自动改派**给手上最空的客服(标 SPREAD_OVERFLOW),
 * 目的是把团队在手量铺平。产品判定这条不能由**助手**来做:
 * 把患者从他的专属客服手里挪走是**关系层面的决定**,只有主管有资格拍板。
 *
 * ⇒ 助手不再替他决定,而是**把问题摊开**:这些人进「待分配」,在确认单上单列一组,
 *   主管可以拖给任意客服(那就是他的决定,标 MANUAL)、也可以移出本批。
 * ⚠️ 主管**不处理**就是不分 —— 待分配的条目**不会**落库(见确认单 confirm)。
 *   ⛔ 别"贴心地"在确认时兜底分给谁,那等于把刚拿掉的自动改派又偷偷加回来。
 *
 * ⚠️ `ownerName` 必须带:主管要判断的是"张三这轮排满了,这个人要不要先给别人",
 *   只给一个 userId 他没法判断(T14:证据要看得懂)。
 */
export const PendingAssignItemSchema = ProposedItemSchema.omit({
  assigneeUserId: true,
  assignStrategy: true,
}).extend({
  /// 他的专属客服(在名册内);⛔ 不可为 null —— 无主患者走第二趟,根本不会进这里
  ownerUserId: z.string(),
  ownerName: z.string().nullable(),
  /// 这位专属客服此刻在手多少 —— 「为什么排满了」的证据,主管据此决定要不要挪
  ownerInHand: z.number().int(),
});
export type PendingAssignItem = z.infer<typeof PendingAssignItemSchema>;

/**
 * 候选池的**专属客服分布** —— 回答「为什么会有这么多人卡住」。
 *
 * ⚠️ 口径是**整个候选池**(与 candidateTotal 同一个 count),⛔ 不是取回的那 1.5 倍样本 ——
 *   拿样本算占比会随 N 变化而漂,主管两次看到不同的比例会以为数据在变。
 */
export const PoolOwnershipSchema = z.object({
  /// 有在岗专属客服的人数
  withOwner: z.number().int(),
  /// 无主(没有专属 / 专属已离岗)—— **他们就是可用来顶替的那批**
  free: z.number().int(),
  /// 专属最集中的几位客服(降序,最多 3 位)。⭐ 这一项才说清"为什么卡"
  topOwners: z.array(
    z.object({ userId: z.string(), name: z.string().nullable(), n: z.number().int() }),
  ),
});
export type PoolOwnership = z.infer<typeof PoolOwnershipSchema>;

/**
 * 按客服的**精调**。
 *
 * 两个基数(容量/时限)是**整体默认**,这里是针对某一个人的覆盖:
 * 「李莉这周带教,只给 5 条」「王强手上都是老客,给他 7 天」。
 *
 * ⚠️ 两者的改动性质**不同**,所以入口也不同:
 *   · `capacity` 改的是**人数**(Σ 容量−在手)→ 会换人群 → 必须回对话让助手重出确认单;
 *   · `expiresInDays` 不改人群 → 就在卡片上那一行改(落到该客服名下**每一条任务**的
 *     `followup_plans.assignment_expires_at`,与批次时限同口径)。
 */
export const AgentOverrideSchema = z.object({
  /**
   * **本批**最多给他几条(⛔ 不是"容量"——那个概念已经删了,这是一次性的名额上限)。
   * 「李莉这周带教,这批最多给 5 条」。⚠️ 0 也合法 = 这轮不给他。
   */
  maxThisBatch: z.number().int().min(0).optional(),
  expiresInDays: z.number().int().positive().max(90).optional(),
});
export type AgentOverride = z.infer<typeof AgentOverrideSchema>;

export const ProposalAgentRowSchema = z.object({
  userId: z.string(),
  name: z.string().nullable(),
  inHandBefore: z.number().int().describe('分配前在手(跨诊所全量)'),
  count: z.number().int(),
  dedicated: z.number().int(),
  spread: z.number().int().describe('两种铺平合并显示 —— 主管界面只分「专属/铺平」两档'),
  /// ⭐ 分配**之后**他手上有多少(= inHandBefore + count)。
  ///   这是「负载」的唯一表达 —— 没有阈值线,主管自己看这一列齐不齐、高不高
  loadAfter: z.number().int(),
  /// 本批对**这个人**生效的时限(没精调就等于批次时限)。逐人下发,⛔ 别让前端再合并一次
  expiresInDays: z.number().int(),
  /// 是否被精调过(卡片上要标出来,否则"为什么李莉只有 5 条"没人答得上)
  overridden: z.boolean(),
});
export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>;

// ═══════════════════════════════════════════════════════════════
// 引导节点(Signal)—— 见 docs/design/assignment-agent-flow.md §五
// ═══════════════════════════════════════════════════════════════

/**
 * 分层。防噪音靠**分层**不靠丢弃 —— ⛔ 有后果的一条都不许截断,
 * 藏起一条主管就不知道有东西卡着(那正是「待分配」当初要解决的问题)。
 */
export const SignalTierSchema = z.enum([
  /// 不管就会有后果 → 全部展开,按 severity 排序
  'action',
  /// 纯信息,不管也没事 → 默认折叠
  'info',
]);
export type SignalTier = z.infer<typeof SignalTierSchema>;

/**
 * 一个可点击执行的选项。
 *
 * ⭐ `intent` 是**按钮与模型工具共用的同一个动作契约** —— 卡片点击直接调后端,
 *   主管说话则由模型翻译成同一个 intent。这样「按钮能做的、说话也能做」是**结构保证**,
 *   ⛔ 不是提示词里的一句叮嘱。
 */
export const SignalOptionSchema = z.object({
  label: z.string().describe('按钮文案,给主管看'),
  intent: z.string().describe('动作契约 id'),
  args: z.record(z.string(), z.unknown()).optional(),
  /**
   * 这个选项要主管**填一个数**（时限几天 / 每天几通）—— 界面渲染成内联数字输入框，
   * 提交时把值并进 `args[argKey]`。
   *
   * ⭐ 为什么不做成几个固定档的按钮：天数和通数是**连续量**，列 3/5/7 天必然漏掉他要的那个，
   *   而漏掉的那次他只能去输入框打字 → 走模型理解 → 慢且可能会错。填数是确定性的。
   * ⚠️ `min/max` 是**护栏**，⛔ 不是建议值：越界的数直接不给提交，别让它走到服务端才报错。
   */
  input: z
    .object({
      argKey: z.string().describe('填的数并进 args 的哪个键'),
      unit: z.string().describe('单位,显示在输入框后面（天 / 通）'),
      min: z.number().int().positive(),
      max: z.number().int().positive(),
      default: z.number().int().positive().describe('预填值 —— 一般就是当前值'),
    })
    .optional(),
});
export type SignalOption = z.infer<typeof SignalOptionSchema>;

/**
 * 引导节点 —— **由程序确定性判定**,⛔ 不由模型判断哪条该提。
 *
 * ⚠️ `why` 只陈述事实(「他们的专属客服已排满」),⛔ 不给建议(「建议你铺平」)——
 *   给建议就是替主管做决定。选项中性并列。
 * ⚠️ `defaultLabel` 描述的是**不动会怎样**,而默认永远是 no-op ——
 *   这样「一条不点、直接确认」在任何情况下都是安全的。
 * ⚠️ 模型只拿 title / why / defaultLabel 用来组织语言,⛔ 不给它 options 的技术细节。
 */
export const SignalSchema = z.object({
  key: z.string(),
  severity: z.number().int().describe('越小越靠前;排序规则必须确定性'),
  tier: SignalTierSchema,
  /**
   * 这一条**改的是什么** —— 决定它在对话里出现在哪儿(2026-08-13 产品定)。
   *
   *   · `cohort`   改**圈进来哪些人**(换个条件圈 / 这一格人不够,往后放一档)
   *   · `size`     改**这一批发多少**(时限几天 × 每天几通 → N)
   *   · `dispatch` 改**怎么派**(专属客服排满怎么办 / 最忙的那位扛不扛得住)
   *   · `post_confirm` **已经分下去之后**还能做的事(补挂福利)——
   *      ⚠️ 它与前三个的区别不只是时间:前三个由 `computeSignals` 从提案算出来,
   *      这一类**只有界面知道**(确认这个动作发生在模型发言之外,服务端也不在场)。
   *      ⛔ 别指望模型来摆它:确认不触发新一轮发言,等它开口 = 主管什么也看不到。
   *      ⛔ 也别把它加进 `GUIDANCE_KIND`(show_guidance 的取值域)——
   *      那个工具只在出方案那一轮能调,给它一个永远调不成的值只是多一格填错的地方。
   *
   * 🔴 `cohort` 与 `size` **必须分开**,虽然两者都讲在第 1 段。
   *   合成一个键时模型会拿一个标题罩住两组按钮(实测:「还能怎么圈:」下面挂着
   *   「改时限 / 改每天几通」)—— 而改时限根本不是圈人。⇒ **一个键只说一件事**。
   * ⚠️ 顺序是 `cohort` → `size`,⛔ 不许反:换人群会让 N 重新估一遍
   *   (实测候选 2509→312 之后,本批 495 直接被压成 312),先调 N 那下就白调了。
   *
   * 🔴 归属**由程序定**,⛔ 不靠提示词让模型自己判断该讲在哪一段 ——
   *   那是每轮掷一次骰子,而且掷错了不报错(它只会把按钮摆到一段无关的话下面)。
   * ⚠️ ⛔ 别拿它当排序键:排序仍然只看 `severity`(见 computeSignals 末尾)。
   *   两个概念混在一起的话,以后想调分段就会连带把顺序也搅了。
   */
  stage: z.enum(['cohort', 'size', 'dispatch', 'post_confirm']),
  title: z.string().describe('一句话说清是什么'),
  why: z.string().describe('一句事实,⛔ 不是建议'),
  defaultLabel: z.string().describe('不动会怎样(no-op)'),
  options: z.array(SignalOptionSchema),
});
export type Signal = z.infer<typeof SignalSchema>;

/**
 * 全景确认单的数据体。
 *
 * ⚠️ 三个 `*Note` 字段是**给助手直接照抄的成品句子**,不是给人读的说明文字。
 * T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断 ——
 * 恰恰是 LLM 最不可靠的地方。把话写好交给它抄,任务就从"让模型算对"降级成"让模型照抄"。
 * 🔴 P2 会把这几个字段换成**结构化事实** —— 成品句子有固定形状,模型学会形状就能
 *   凭空背出来(实测背出过「已撤销批次:收回 9 条」),而且「照抄」浪费了模型唯一
 *   不可替代的能力。见 docs/design/assignment-agent-dev-plan.md P2。
 */
export const AssignmentProposalSchema = z.object({
  clinicId: z.string(),
  potentialTreatment: z.string().nullable(),
  /**
   * ⭐ 矩阵 Y 轴:窗口温度(hot/warm/cold)。
   *
   * 🔴 2026-08-04 走查:它**参与了圈人**(cohort-filter 的 temperatureSql),
   *   却一直没随确认单下发、也就没进 `plan_assignments.criteria` 快照 ——
   *   而 schema 对 criteria 的承诺是「批次要能解释**当时是按什么圈的**」。
   *   后果:矩阵 8×3,「牙周·黄金期」和「牙周·窗口内」是两批完全不同的人,
   *   落库后**分不出来**;T20 想反推"哪种格子分下去效果好"直接答不了。
   * ⚠️ 老批次补不回来(数据当时就没存),只能从此往后有。
   */
  temperature: z.string().nullable(),
  /**
   * ⭐ **主管在初选之上自己追加的条件** —— 引导节点靠它认出「这一版是他收窄来的」。
   *
   * 🔴 2026-08-13 加。在此之前三条节点都只看**这一版的绝对状态**，认不出主管刚做了什么，
   *   于是他按消费切完之后：
   *     · `short_supply` 把他自己造成的"人少"报成故障，还递上两个会**丢掉他刚加的条件**
   *       的按钮（往后放一档 / 换个治疗项）;
   *     · `narrow` 又递上「只圈消费高于这一格平均」—— 而那个阈值正是他那一刀造出来的
   *       （砍掉低端，均值从 ¥720 跳到 ¥5,246），点下去再跳一次，是个**自我收窄的螺旋**。
   * ⚠️ 只记**主管追加的**，⛔ 不记矩阵那两轴（治疗项/时间档在上面单独有字段）——
   *   格子本身小和他自己切小，是两件事。
   */
  narrowedBy: z
    .object({
      personaTags: z.string().optional(),
      minSpendYuan: z.number().optional(),
    })
    .nullable(),
  /// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
  /// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
  candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
  target: z.number().int().describe('本批**实际**分多少人 = min(基数 N, 候选总数)'),
  /**
   * 基数 N —— **要被记住的那个数**(沿用上次 / 首次按 在岗人数×20 估 / 本次主管指定)。
   * ⚠️ 与 `target` 分开:候选不够时 target 会被压低,但那是**这一批的偶然事实**,
   *   ⛔ 不能回写成基数 —— 否则点一次 44 人的小格子,以后所有批次就永远是 44 人,
   *   而主管完全看不出为什么变小了。
   */
  batchSize: z.number().int(),
  /**
   * 🔴 **算 `batchSize` 时乘进去的那个在岗人数** —— 存在的唯一理由是让
   *   「在岗 N 人 × 每天几通 × 时限」这句话里的 N **就是乘法用的那个变量**。
   *
   * ⚠️ 2026-08-15 实测事故:两处显示(`modelFacts`、`batch_size_basis` 卡片标题)
   *   都拿 `byAgent.length` 当"在岗人数"印 —— 那是**本批分到人的客服数**,不是名册。
   *   上海世纪公园实测印出「在岗 **9** 人 × 每天 15 通 × 1 天」,而主管同屏
   *   「团队现在什么状态」写着 **33 位在岗**,且 9×15×1=135 连它自己那行的
   *   「本批人数 214」都算不出来(真实是 33×15×1=495,再被候选 214 压下来)。
   *   9 是水位法的结果:214 人里 211 个有专属、只落在 6 位客服头上(一人独占 146),
   *   每人封顶水位 7 → 6 位;剩 3 个无主的铺给最空的 3 位 → 6+3=9。
   * ⇒ ⛔ 别再从 `byAgent`/`skippedAgents` 反推名册大小(加法能算对,但下一个
   *   读代码的人仍要自己想明白"为什么是这两个数相加"),⭐ 由算 `batchSize` 的
   *   **同一行**赋值,让句子和乘法共用一个真源。
   */
  rosterCount: z.number().int().describe('在岗名册人数(= 估算基数时乘进去的那个数)'),
  /**
   * 🔴 **这一版按"每人每天几通"算的** —— 与 `rosterCount` 同一条理由：
   *   乘法用到的数必须跟着积一起发。
   *
   * ⚠️ 2026-08-15 补。此前它**只是 `propose` 的入参**，算完就丢：
   *   主管说「每人每天按 20 通算」之后，卡片和 `modelFacts` 里仍然写死常量 15 ——
   *   于是「按每天几通算: 15」和他刚说的 20 直接打架，**而且不报错**。
   * ⚠️ 凡是要拿它换算的地方（每位客服要打几天、`daily_overload` 的判定与措辞、
   *   `batch_size_basis` 的式子）都读这一个，⛔ 别再引常量。
   */
  dailyCalls: z.number().int().describe('这一版按每人每天几通算(主管改过就是他的数)'),
  /// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
  /// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 ——
  ///   沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。
  expiresInDays: z.number().int().describe('批次时限天数(基数②;卡片初值,主管可在卡片上改)'),
  basis: z
    .enum(['inherited', 'default', 'explicit'])
    .describe('基数来源:沿用上次 / 首次默认 / 本次主管明确指定'),
  /// 沿用时是"沿用哪一次"(ISO 日期);非沿用为 null
  basisFrom: z.string().nullable(),
  /// 按客服的精调(userId → 覆盖值)。⚠️ 与基数一样会被记住,所以卡片上必须标出来 ——
  /// 「李莉休假那周只给 5 条」如果悄悄沿用三个月,没人会发现
  agentOverrides: z.record(z.string(), AgentOverrideSchema),
  placed: z.number().int(),
  /**
   * 没落上的条数。
   * ⚠️ 水位法**没有上限**,所以正常情况恒为 0 —— 只有"名册为空"或
   * "所有人都被精调成 0 名额"时才 > 0。⛔ 别把它读成"团队满了"。
   */
  unplaced: z.number().int(),
  items: z.array(ProposedItemSchema),
  /**
   * 🔴 **待分配**:有专属客服、但那位客服这轮已排满的患者(见 PendingAssignItemSchema)。
   *
   * ⚠️ 它们**不在** `items` 里、也**不计入** `placed` —— 助手没有分配它们,
   *   确认时也不会落库。主管在卡片上拖给谁,那一条才会变成一条真的分配(MANUAL)。
   * ⛔ 别把它并进 `unplaced`:那个数说的是"名册空/全被精调成 0"这种**系统性无处可放**,
   *   而这里是**刻意留给人决策**的,两者要主管做的事完全不同。
   */
  pending: z.array(PendingAssignItemSchema),
  /**
   * 🔴 **能不能重新排一版** —— 池子里还有"专属没排满 / 无主"的人可用。
   *
   * ⚠️ 只回答**能不能**,⛔ 不下发"顶替名单":名单只能从取数窗口里挑,
   *   而窗口比池子小得多(实测:池子 17 位无主,窗口里只有 3 位)——
   *   下发名单就等于把这个上限暴露成"只能顶 3 个"。
   * ⇒ 主管点一下 → 服务端**重新出一版**(取数窗口更大 + 遇到排满的跳过),天然凑满。
   */
  canRefill: z.boolean(),
  /// 候选池的专属分布 —— 「为什么这么多人卡住」的答案(见 PoolOwnershipSchema)
  poolOwnership: PoolOwnershipSchema.nullable(),
  byAgent: z.array(ProposalAgentRowSchema),
  /**
   * 本批**一条都没分到**的在岗客服。**仍然列出来** —— 主管要看见"他不是被漏了"。
   * ⚠️ 语义变了:不再是"已达容量",而是**水位法没轮到他**(手上本来就最多),
   *   或者被精调成 0 名额。附上 inHand 让原因不言自明。
   */
  skippedAgents: z.array(
    z.object({ userId: z.string(), name: z.string().nullable(), inHand: z.number().int() }),
  ),
  rosterNote: z.string(),
  /**
   * 🔴 `basisNote` **已于 2026-08-14 删除** —— ⛔ 别加回来。
   *
   * 它是"成品句子给助手照抄"那一代的产物,而 P2 之后模型拿的是**结构化事实**
   * (`modelFacts`)。走查发现它**只写不读**:服务端每次算一遍、随确认单发到浏览器、
   * 然后被丢掉 —— 前端只渲染 `refillNote`,`modelFacts` 不带它,企微那条道也不碰。
   * ⚠️ 里面还留着两处过期内容:「沿用您 X 月 X 日那次」(沿用 2026-08-12 就断开了)、
   *   以及 `basis='default'` 时拼出「都是**系统默认**(…**您设的** 405 没变)」自相矛盾。
   * ⇒ 同一批数现在的出口:模型正文(modelFacts)+ 引导节点,⛔ 不要再开第三个。
   */
  selectionNote: z.string(),
  /// 「待分配」那段(含分布说明 + 顶替提示);没有待分配时为空串
  pendingNote: z.string(),
  /**
   * 🔴 **「重新排一版」到底做了什么** —— 由**卡片自己**渲染,⛔ 不走助手。空串 = 没什么可说的。
   *
   * 由来(2026-08-06 实测):点了重排,卡片从「拟分 16 · 待分配 34」变成「拟分 50」,
   * 而卡片**上面那段话还是旧的**(仍写着"34 人要您定"、"19 人无主")——
   * 主管看到的是两份互相打架的口径,合理的第一反应就是「19 个人怎么顶掉了 34 个」。
   * 重排走 HTTP 只换卡片,助手不会重新说一遍;⇒ **换了什么必须由卡片自己交代**。
   *
   * ⚠️ 尤其要交代**代价**:为了凑满 N,专属大户会超出建议水位多接自己的人
   *   (手上 69 条 vs 别人 58 条)。不说这一句,那个 69 就成了无法解释的数。
   */
  refillNote: z.string(),
  /**
   * 🔴 **引导节点** —— 主管这一版还有什么要定的（见 SignalSchema）。
   *
   * ⚠️ 与三段 `*Note` 的分工：Note 说的是**已经确定的事实**（怎么选的 / 怎么派的），
   *   signals 说的是**还没确定的**。⛔ 别把待分配同时写进 note 和 signal ——
   *   同一件事说两遍，主管会以为是两件事。
   * ⚠️ 老确认单没有这个字段 → 消费方按空数组兜底。
   */
  signals: z.array(SignalSchema).default([]),
});
export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;

/**
 * 🔴 **眼前那张确认单「此刻」的样子** —— 主管在卡片上改过之后的**草稿态**。
 *
 * ═══ 为什么它必须由浏览器产 ═══════════════════════════════════════
 * 确认之前，改动**全部只存在于卡片的组件状态里**（改派 / 移出 / 时限 / 福利）——
 * 服务端手上只有最初生成的那一版提案，还是个请求内的闭包，没落库也没缓存。
 * ⇒ ⛔ 别指望服务端"查"得到它：`get_current_sheet` 查的就是这份随请求捎来的快照，
 *   ⛔ 也别为它去开一条"草稿回传"的写路径（多一份要维护的状态，只为省模型一次加法）。
 *
 * ⚠️ 它是**这一刻**的照片,不是账本:主管下一秒拖一下它就过期了。
 *   所以它随**每次** chat 请求重新捎一份(与 `activeClinicId` 同一条旁路),
 *   ⛔ 别在服务端缓存它。
 * ⚠️ 键名用英文:它是**线上契约**。给模型看的中文事实由服务端投影
 *   （`sheetSnapshotFacts`）—— 与 `modelFacts` 同一条纪律:事实投影只在服务端一处。
 */
export const SheetSnapshotSchema = z.object({
  /// 哪一张单(服务端铸造的幂等键)——多轮之后能认出"说的是不是同一张"
  requestId: z.string(),
  /// 已经点过确认了没有。⚠️ true 时人员/时限已锁死,只有福利还能改
  confirmed: z.boolean(),
  /// 现在会真正落库的条数(= 卡片上「已排好 N 人」)
  placed: z.number().int(),
  /// 这些条落在几位客服头上
  agents: z.number().int(),
  /// 还留在「待分配」里、他一直没处置的 —— ⚠️ 确认时**不会落库**
  pending: z.number().int(),
  /// 他点 × 移出本批的条数
  dropped: z.number().int(),
  /// 被改派过的条数(拖动 / 助手改单都算)
  moved: z.number().int(),
  /// 整批时限
  expiresInDays: z.number().int(),
  /// 有几条单独改过时限(不跟整批走)
  rowExpiryOverrides: z.number().int(),
  /// 本批福利原文;null = 还没带
  benefit: z.string().nullable(),
  /// 逐人：现在谁手上几条
  byAgent: z.array(z.object({ name: z.string(), count: z.number().int() })),
  /**
   * 🔴 **这批人是谁** —— ⛔ 不许省（2026-08-15 golden 跑批当场抓到）。
   *
   * 快照第一版只带了"分得怎么样"（几条、几位客服、时限、福利），主管说
   * 「这批改成 200 人」时模型要重跑 `propose_assignment`，却**不知道原来那一格是什么**。
   * 它的原话：「我这边只看到确认单本身（120 人、8 位客服、时限 1 天），
   * 没留下当时选的是哪个治疗项、哪个时间档 —— 刚才我按全部候选重算了一版，
   * 结果对不上……跟您眼前那批完全不是一回事」。
   * ⇒ 要么它回头问主管（白跑一轮），要么**不带条件重算**（静默换成另一批人，更糟）。
   * ⚠️ 这与「换无专属客服的患者补上」丢 `narrowedBy` 是**同一类错**：
   *   重算时把人群条件丢了，而两边都不报错。⛔ 凡是"能触发重算"的载体都要带全这一段。
   */
  criteria: z.object({
    potentialTreatment: z.string().nullable(),
    temperature: z.string().nullable(),
    /// 主管自己追加的那几刀（没加过则为 null）—— 重算时同样要原样带回
    narrowedBy: z
      .object({ personaTags: z.string().optional(), minSpendYuan: z.number().optional() })
      .nullable(),
    /// 这一格一共多少人 / 这一版估了多大 —— 「改成 200 人」够不够得着，看这两个数
    candidateTotal: z.number().int(),
    batchSize: z.number().int(),
  }),
});
export type SheetSnapshot = z.infer<typeof SheetSnapshotSchema>;

/**
 * 同一份快照压成**一行**，跟在「确认单已更新:…」后面一起进模型上下文。
 *
 * ⚠️ 只进 `modelText`，⛔ 不显示给主管 —— 这几个数**卡片上就在他眼前**，
 *   界面上再写一行是同一件事读两遍；而模型看不见卡片，它只有这一行。
 * ⚠️ 放在 `@pac/types` 而不是服务端的 `assignment-facts`：这行字是**浏览器**产的
 *   （回声发生在点一下的那一刻，服务端不在场）。⛔ 别在服务端另写一份措辞。
 */
export function sheetSnapshotLine(s: SheetSnapshot): string {
  return (
    `当前这张单:已排好 ${s.placed} 人 · ${s.agents} 位客服 · 待你定的 ${s.pending} 人 · ` +
    `整批时限 ${s.expiresInDays} 天 · 福利${s.benefit ? `「${s.benefit}」` : '还没带'}` +
    (s.dropped ? ` · 已移出本批 ${s.dropped} 人` : '')
  );
}

/**
 * 「重新排一版(尽量排满)」的入参 —— 卡片上那个按钮打过来的。
 *
 * ⚠️ 条件必须**原样带回**:重新排的是**同一批人群**,只是换一种取人方式。
 *   ⛔ 别在服务端凭 assignmentId 反查 —— 这一版还没落库,没有 id 可查。
 */
export const RefillProposalRequestSchema = z.object({
  clinicId: z.string().min(1),
  potentialTreatment: z.string().nullable().optional(),
  temperature: z.string().nullable().optional(),
  personaTags: z.string().nullable().optional(),
  /**
   * 🔴 **主管自己加的那一刀,必须一起带回**(2026-08-15 补 —— 此前整个漏了)。
   *
   * 他很可能已经收窄过几遍(「只选消费高于 ¥721 的 314 人」)。这两个条件少带一个,
   * 「换无专属客服的患者补上」就**悄悄退回整格**去取人 —— 他刚圈掉的人又回来了,
   * 而界面上只显示"重新排了一版",⛔ 不报错、⛔ 不提示。
   * ⚠️ `personaTags` 当时在 schema 里但**前端一直没传**;`minSpendYuan` 是连 schema 都没有。
   *   ⇒ 契约齐了不等于传到了 —— 改这里时**顺手看一眼调用方**(use-assistant-chat 的 refill)。
   */
  minSpendYuan: z.number().positive().optional(),
  /**
   * 🔴 **他在这一版上做的按客服精调,也要原样带回**(2026-08-15 补 —— 此前整个漏了)。
   *
   * 「李莉这周带教,这批最多给 5 条」是主管**当场**设的,只活在这一版的 `agentOverrides` 里。
   * 而 `propose` 的基线 `resolveBaseline` 读的是**上一次已确认批次** —— 重排一下,
   * 他刚设的那条就没了,李莉照常吃满,**而且不报错**。
   * ⚠️ 这是"重算丢东西"的第三个:前两个是丢人群条件、丢他自己那一刀
   *   （见本文件 `minSpendYuan` 与 `SheetSnapshot.criteria` 上的红字）。
   *   ⇒ **凡是能触发重算的载体,都要把"他在这一版上做过的事"带全**。
   * ⚠️ 传空对象 `{}` 表示撤掉某人的精调(`mergeOverrides` 先合并后 sanitize),
   *   ⛔ 别把"没传"和"传空"当成一回事。
   */
  agentOverrides: z.record(z.string(), AgentOverrideSchema).optional(),
  /**
   * 🔴 **换算尺也要带回**（2026-08-15 补 —— 「重算丢东西」的第四个）。
   *   主管说过「每人每天按 30 通算」之后点一下「补上」，不带它就退回默认 15：
   *   每位客服的「约几天」整列变、`daily_overload` 的判定也跟着变，**而且不报错**。
   *   ⚠️ 它不影响这一批发多少（`targetCount` 已显式带回），只影响**换算与呈现** ——
   *   正因为不影响人数，漏了更难被发现。
   */
  dailyCalls: z.number().int().min(1).max(60).optional(),
  /// 基数 N 与时限原样带回,否则重排会退回"沿用上一次",人数悄悄变了
  targetCount: z.number().int().positive().optional(),
  expiresInDays: z.number().int().positive().max(90).optional(),
});
export type RefillProposalRequest = z.infer<typeof RefillProposalRequestSchema>;

// =============================================================
// 助手直接改确认单 —— 本地工具 edit_assignment_sheet 的载荷
// =============================================================

/**
 * 🔴🔴 **三个正交的轴:选谁(select)× 干什么(action)× 给谁(to)。**
 *
 * ── 为什么不是一串 action 字面量(2026-08-06 产品判定,推翻原设计)────────
 * 原来是 8 条并列的字面量:`remove_patient` / `remove_agent` / `move_patient` /
 * `assign_pending` / `assign_pending_to_owner` / `drop_pending` / `set_expiry` / `set_benefit`。
 * 摊开看它们根本不是 8 件事,而是**三个轴被压成了一维**:
 *      remove_agent          = 选「某客服名下」× 移出
 *      drop_pending          = 选「待分配」    × 移出
 *      assign_pending        = 选「待分配」    × 改派 × 铺平
 *      assign_pending_to_owner = 选「待分配」  × 改派 × 各自的专属
 * 每加一种"选谁"或一种"给谁",条数就要**乘一遍** —— 而没被乘出来的那些格子,
 * 主管说到时模型只能掉进最像的那一格。当天实测栽的两次都是这个形状:
 *   ·「各自分给各自的专属客服」→ 只有铺平可用 → 18 人被散给了 17 位**别人**(语义正相反)
 *   ·「把康慧捧名下的都还给她」「待分配的时限给 5 天」→ 至今一条都表达不了
 *
 * ⚠️ **正交化不消灭"枚举漏值",只是把它变得可数**:少写一个 `mode` 照样会栽。
 *   它真正买到的是 ① 加维度不再乘一遍 ② 缺的组合是**表格里的空格**(看得见),
 *   不是没人想到的字面量(看不见)③ 覆盖度能按矩阵测。
 *
 * ⭐ 为什么用「姓名」而不是 planId:模型**拿不到也不该拿到** planId
 * (确认单的肥载荷走侧信道直接给界面,不进模型上下文 —— 见 assistant.service 的注释)。
 * 所以指令用主管说得出口的东西(患者姓名 / 病历号 / 客服姓名)表达,
 * **由界面拿自己手里的那份确认单去匹配**。模型负责"听懂",界面负责"找到人"。
 * ⚠️ 这条边界拿不掉 —— 所以做不到"给助手通用能力让它自己写";
 *   能做的是把**词汇表**做得足够表达,而不是堆一串硬编码短语。
 *
 * ⚠️ 能做的只有卡片上能做的那几件(局部修正),⛔ 不含换人群 / 改批次人数 ——
 *   那两件要重跑服务端算法,只能重出一版确认单(走 propose_assignment)。
 * ⚠️ 匹配不到 / 匹配到多个,由界面回一句话进对话,⛔ 别让模型自己猜"大概是成功了"。
 */

/** ① 选谁 —— 这条指令作用在哪些人身上 */
export const SheetSelectSchema = z.discriminatedUnion('group', [
  /** 点名的患者(姓名或病历号);⚠️ 可以给多个,⛔ 别拆成多条指令 */
  z.object({
    group: z.literal('patients'),
    patients: z.array(z.string()).min(1).describe('患者姓名或病历号'),
  }),
  /** 某位客服名下的全部条目 */
  z.object({ group: z.literal('agent'), agent: z.string().describe('客服姓名') }),
  /** 「待分配」那一组的全部(专属客服排满、还没落到人头上的那些) */
  z.object({ group: z.literal('pending') }),
  /** 整批 —— 只对 `set_expiry` / `set_benefit` 有意义 */
  z.object({ group: z.literal('batch') }),
]);

/**
 * ② 给谁 —— **只有 `action:'assign'` 用**。
 *
 * 🔴 `owner` 与 `balance` 的结果**正好相反**,是当天那次事故的正题:
 *   主管说「各自分给各自的专属客服」= `owner`(每人进自己那位客服手里,⛔ 一条关系都不动);
 *   说「平均分 / 谁手上少给谁 / 铺下去」= `balance`。选错主管一眼看得出来。
 */
export const SheetAssignToSchema = z.discriminatedUnion('mode', [
  /**
   * 各自回**自己的**专属客服。
   * ⚠️ 专属客服已离岗 / 不在本批名册的那几个**分不下去**,界面会如实报数,
   *   ⛔ 不许顺手改派给别人 —— 那正是这一条要避免的事。
   */
  z.object({ mode: z.literal('owner') }),
  /**
   * 铺平:**谁手上少先给谁**(与服务端 placeAgents 的 `lowest()` 同一条规则)。
   * ⚠️ ⛔ 不是真随机:主管嘴上说"随机",要的是齐平;真随机会让同一张单每次算出不同结果,
   *   破坏"同样的输入两次算出同样的分法"这条他敢按确认键的前提。
   * ⭐ `agents` = 把候选**限定**在这几位里。主管点名了人(「分给张悦和李莉」)就填这里;
   *   只点一位就等于"都给他"。不填 = 本批所有在岗(含一条都没分到的 —— 他们恰恰手上最空)。
   */
  z.object({
    mode: z.literal('balance'),
    agents: z.array(z.string()).optional().describe('限定只分给这几位客服(姓名)'),
  }),
]);

export const SheetEditOpSchema = z
  .object({
    select: SheetSelectSchema,
    action: z.enum(['assign', 'remove', 'set_expiry', 'set_benefit']),
    /// `assign` 必填,其余动作忽略
    to: SheetAssignToSchema.optional(),
    /// `set_expiry` 必填
    days: z.number().int().positive().max(90).optional(),
    /**
     * `set_benefit` 必填。批次福利 —— T4:挂在**批次**上不挂个人;
     * 它是**话术勾子 + 归因标签**,v1 不核销、不接宿主卡券。
     * 落库进 `attributes.benefit.text`,生成话术时作为事实输入进 prompt。
     * ⚠️ 空串 = 撤掉福利。
     */
    text: z.string().max(200).optional(),
  })
  /**
   * ⚠️ 缺参数**在这里就拦掉**,⛔ 别留给界面运行时兜 ——
   *   界面兜的话只能回一句"没执行",而模型不知道自己少填了什么,下一轮还会再错一次。
   */
  .superRefine((op, ctx) => {
    if (op.action === 'assign') {
      if (!op.to) ctx.addIssue({ code: 'custom', message: "action:'assign' 必须给 to(owner / balance)" });
      if (op.select.group === 'batch')
        ctx.addIssue({ code: 'custom', message: '整批不能作为改派的对象 —— 请选 pending / agent / patients' });
    }
    if (op.action === 'remove' && op.select.group === 'batch')
      ctx.addIssue({ code: 'custom', message: '整批不能"移出自己" —— 请选具体的人' });
    if (op.action === 'set_expiry' && op.days == null)
      ctx.addIssue({ code: 'custom', message: "action:'set_expiry' 必须给 days" });
    if (op.action === 'set_benefit') {
      if (op.text == null)
        ctx.addIssue({ code: 'custom', message: "action:'set_benefit' 必须给 text(空串 = 撤掉)" });
      if (op.select.group !== 'batch')
        ctx.addIssue({ code: 'custom', message: '福利挂在整批上 —— select 必须是 batch' });
    }
  });
export type SheetSelect = z.infer<typeof SheetSelectSchema>;
export type SheetAssignTo = z.infer<typeof SheetAssignToSchema>;
export type SheetEditOp = z.infer<typeof SheetEditOpSchema>;

/**
 * 🔴 **团队现在什么状态** —— 主管工作台右栏 / `getAgentWorkload`。
 *
 * ⚠️⚠️ **一张表里混着两种时间性**,界面必须写明,否则主管会把「在手 62」读成"这 7 天分了 62 条":
 *   · `inHand` —— **此刻**的状态(唯一一个),表头要写「**当前**在手」;
 *   · `overdue` / `done` / `released` / `handled` / `idle` —— **窗口内**发生的事,换窗口一起变。
 * ⚠️ 名册与分配那边**同一个来源**(AgentRosterService)—— 分配问「还能吃多少」、
 *   跟踪问「手上压了多少」,是同一份数据的两种读法(T10)。⛔ 别另查一份。
 */
export const AgentWorkloadRowSchema = z.object({
  userId: z.string(),
  name: z.string().nullable(),
  /// **当前**手上还压着多少 —— 这一列是唯一的"此刻"口径(跨诊所,与 AgentInfo.inHand 同源)
  inHand: z.number().int(),
  /**
   * **窗口内**超期 = 这段时间里到期没人动、被收回池子的条数。
   *
   * 🔴 ⛔ **不是"当前还压在手上且已过期"**(2026-08-07 实测推翻):
   *   到期回收器每 10 分钟扫一遍,过期的单当场被收走 —— 那个口径**结构上几乎永远是 0**
   *   (实测:账本 378 条到期回收,而"当前在手已过期" 0 条)。摆上去是个常年为 0 的死数,
   *   主管会以为团队从不超期。
   * ⚠️ 归属回捞自"到期前最后一次 assign" —— auto_release 事件本身不带人(释放后无人归属)。
   * ⚠️ **约了下次回访的不算**:回收器刻意跳过它们,那是客服动过了的证据。
   *   不排掉的话,打了电话、约好下次的人反而被显示成"压着单没动"。
   */
  overdue: z.number().int(),
  /// 窗口内写过通话结果的条数(按单去重)
  done: z.number().int(),
  /// 窗口内主动退回的条数(走账本,⛔ 不读 followup_plans.release_reason —— 那是当前值会被清)
  released: z.number().int(),
  /// 窗口内已处置 = done + released(两者都是"这单他动过了")
  handled: z.number().int(),
  /// 窗口内分到但一条都没动的
  idle: z.number().int(),
  /**
   * ⭐ 成品句子:「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」。
   * 🔴 **两个分母都要带** —— 只给一个百分比会把"没动"藏起来,而那个数本身就是信号。
   * ⛔ 前端别自己拼:界面、助手、导出三处口径必须一致。
   */
  rateNote: z.string(),
});
export type AgentWorkloadRow = z.infer<typeof AgentWorkloadRowSchema>;

export const AgentWorkloadResponseSchema = z.object({
  clinicId: z.string(),
  windowDays: z.number().int(),
  agents: z.array(AgentWorkloadRowSchema),
});
export type AgentWorkloadResponse = z.infer<typeof AgentWorkloadResponseSchema>;

// =============================================================
// 撤销整批  —— POST /pac/v1/plans/assignments/:id/revoke
// =============================================================

/**
 * 撤销窗口(分钟)。
 *
 * ⚠️ 语义是「**手滑 / 分错人**的补救」,不是「改主意重新调度」——
 * 改主意应该走"客服退回 + 重新分配",那条路有完整的原因记录;
 * 撤销是把一次误操作当作没发生过,所以必须限时。
 *
 * ⚠️⚠️ 它是 **UX 摩擦,不是安全边界**:leader 本来就有 PLAN_RECYCLE + PLAN_VIEW_ALL,
 * 30 分钟后照样能逐条 recycle 把单收回来。⛔ **别基于「30 分钟后就锁死了」去设计别的东西**
 * (比如"过了窗口就认为归属永久稳定"),那个前提不成立。
 */
export const REVOKE_WINDOW_MINUTES = 30;

export const RevokeAssignmentResponseSchema = z.object({
  assignmentId: z.string(),
  /// 真正收回池子的条数
  revoked: z.number().int(),
  /**
   * ⭐ **客服已经打开过详情页、故意跳过没收**的条数。
   *
   * 判据只能用 `view` 事件 —— `plan_executions` 回写率仅 11%(生产 65 认领 / 7 条结果),
   * 拿它当"动过没"会把 89% **已经打过电话**的单当成没动收走,那通电话就永久蒸发了
   * (正是 claim-guard 第 1 条要防的:执行必须有归属,否则统计里直接消失)。
   * `contactAttempts` 与 plan_executions 同源、同为 7 条,同样不可用。
   */
  skippedTouched: z.number().int(),
  /// 已经被客服退回 / 已到期回收的,本来就不在人手上,不计入
  alreadyReleased: z.number().int(),
  /// 给主管看的一句话(成品句子,助手照抄)
  note: z.string(),
});
export type RevokeAssignmentResponse = z.infer<typeof RevokeAssignmentResponseSchema>;

/**
 * 给**已确认**的批次补挂 / 改 / 撤福利。
 *
 * ⚠️ 由来(2026-08-06 实测):福利原本只在 `create` 那一刻落库。主管**确认之后**再说
 *   「这批带上八折」,助手仍走 `edit_assignment_sheet`,卡片改了自己的 state、亮了角标、
 *   回了一句「已更新」—— 而 DB 里一个字没变,话术里也不会有福利。
 *   界面说做了、实际没做,主管无从察觉,正是 T14 要防的那类。
 */
export const SetAssignmentBenefitRequestSchema = z.object({
  /// 福利文案原文;**空串 = 撤掉福利**(⛔ 别用 null,前端清空时给的就是空串)
  text: z.string().max(200),
});
export type SetAssignmentBenefitRequest = z.infer<typeof SetAssignmentBenefitRequestSchema>;

export const SetAssignmentBenefitResponseSchema = z.object({
  assignmentId: z.string(),
  /// 改完之后的值;null = 这批现在不带福利
  benefitText: z.string().nullable(),
  /// 作废掉的话术缓存条数 —— 这些下次打开详情页会重新生成(懒生成,不急切重跑 LLM)
  scriptsInvalidated: z.number().int(),
  /**
   * 客服**已经打开过**的条数(判据同 revoke:`view` 事件,⛔ 不可用 plan_executions)。
   * 他手里那份话术是改福利之前生成的,补挂的福利他看不到 —— 必须如实回报,
   * ⛔ 不许只回一句「已更新」。
   */
  touched: z.number().int(),
  /// 给主管看的一句话(成品句子,助手照抄)
  note: z.string(),
});
export type SetAssignmentBenefitResponse = z.infer<typeof SetAssignmentBenefitResponseSchema>;
