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;

/// **首次**分配的时效起点(之后沿用上一次)。同批次人数,是基数不是常量。
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
/**
 * 卡片上时效可选的天数。**给全部 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>;

/**
 * 全景确认单的数据体。
 *
 * ⚠️ 三个 `*Note` 字段是**给助手直接照抄的成品句子**,不是给人读的说明文字。
 * T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断 ——
 * 恰恰是 LLM 最不可靠的地方。把话写好交给它抄,任务就从"让模型算对"降级成"让模型照抄"。
 */
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(),
  /// ⭐ 与矩阵格子同一种数法(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(),
  /// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
  /// ⚠️ 出处必须跟着值走:主管看到「容量 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: z.string(),
  selectionNote: z.string(),
  /// 「待分配」那段(含分布说明 + 顶替提示);没有待分配时为空串。⚠️ 排在 basisNote **之后**说
  pendingNote: z.string(),
  /**
   * 🔴 **「重新排一版」到底做了什么** —— 由**卡片自己**渲染,⛔ 不走助手。空串 = 没什么可说的。
   *
   * 由来(2026-08-06 实测):点了重排,卡片从「拟分 16 · 待分配 34」变成「拟分 50」,
   * 而卡片**上面那段话还是旧的**(仍写着"34 人要您定"、"19 人无主")——
   * 主管看到的是两份互相打架的口径,合理的第一反应就是「19 个人怎么顶掉了 34 个」。
   * 重排走 HTTP 只换卡片,助手不会重新说一遍;⇒ **换了什么必须由卡片自己交代**。
   *
   * ⚠️ 尤其要交代**代价**:为了凑满 N,专属大户会超出建议水位多接自己的人
   *   (手上 69 条 vs 别人 58 条)。不说这一句,那个 69 就成了无法解释的数。
   */
  refillNote: z.string(),
  /**
   * 🔴 **运营结论** —— 主管扫一眼就该知道的三件事,⛔ 不是分配明细的复述。
   *
   * 由来(2026-08-04 产品评审):主管当场说他没法审这张单 ——
   *   「我不可能挨个去看他分的对不对,你在这儿应该给的是结论性的东西…
   *     **如果你不能告诉他哪不对、哪里需要主管判断,那就默认他必须一条一条看过去才能确认**」
   *
   * 于是这段只回答三个问题,每个都是"看完就能决定按不按确认"的:
   *   ① **这是多大的活** —— 分完之后每人手上多少条 ≈ 几天的量(见 DAILY_CALLS_PER_AGENT)
   *   ② **这批给谁加了担子** —— 本批给他几条(⚠️ 与"手上总量"是两个数,必须分开说)
   *   ③ **谁的这批全是自己的老客户** —— 那种批次主管基本不用看,可以直接过
   *
   * ⚠️ ⛔ 别把「待分配」的解释塞进来:那件事有 pendingNote,而且它问的是"要你拍板什么",
   *   和这段问的"这活多大"不是一回事,混在一起两边都读不清。
   */
  opsNote: z.string(),
});
export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;

/**
 * 「重新排一版(尽量排满)」的入参 —— 卡片上那个按钮打过来的。
 *
 * ⚠️ 条件必须**原样带回**:重新排的是**同一批人群**,只是换一种取人方式。
 *   ⛔ 别在服务端凭 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(),
  /// 基数 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>;
