// PAC — Patient Analyze Center
// 三层模型:Patient Facts(事实)→ Persona(画像)→ Follow-up Plan(计划),
// 加上 Agent Pool 调用审计、Plan 执行回写。
//
// 第一层用 **双表模型**(db-review §21.3 / db-review-confirm §3):
//   patient_transactions  — 操作账本(append-only),"谁在何时做了什么操作"
//   patient_facts         — 事实单元(版本流),"患者事实中存在什么"
//
// 多租户隔离按表语义分级(db-review 原则 1):
//   硬隔离 (platform + tenant)  — 所有业务表的强制基线,跨 tenant 不允许越界(应用层 + 查询拦截器)
//   立柱 clinic_id (业务字段)   — patient_transactions(操作发生地)/ plan_executions(执行发生地)
//                                 同 tenant 内跨 clinic 的可见性由应用层 RBAC 决定,不是 DB 硬边界
//   立柱 target_clinic_id       — followup_plans(业务归属诊所,nullable = 集团统一客服池)
//   不立 clinic_id              — 患者身份 / 推断层(persona / plan)/ 跨任务日志 / AI 调用账本
//   "该患者去过哪些诊所"靠 patient_transactions.clinic_id 反查;
//   "某诊所的召回池"靠 followup_plans.target_clinic_id 业务过滤。
//
// 名字字段处理:PAC **不立 tenants / clinics / users 表**(边界:这些主数据在宿主侧)。
// 子表存 id 不存 name;展示用 name 通过宿主换票时返回的字典(clinics/users)前端查。
// 不在每张表冗余 xx_name 字段(避免 stale + 维护成本)。
//
// 时间类型:统一 `@db.Timestamptz(3)`(原则 8);日期类(birth_date 等无时刻概念)用 `@db.Date`。
//
// 部分约束 Prisma schema 表达不了,在 prisma/migrations/*.sql 里用 raw SQL 补齐(标注 MIGRATION):
//   1) patient_transactions:partial UNIQUE (host_id, tenant_id, source_event_id) WHERE source_event_id IS NOT NULL
//   2) patient_facts:partial UNIQUE (host_id, tenant_id, subject_id) WHERE fact_status = 'active'
//   3) patient_facts:GIN (transaction_ids)
//   4) followup_plans:partial UNIQUE single-open-per-scenario(具体条件见 §14)

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// =============================================================
// Platform / 接入宿主注册
// =============================================================

/// 接入 PAC 的宿主平台注册表。每个 appId 唯一,记录鉴权秘钥哈希、同步配置、字段映射。
///
/// 重要架构观:**PAC 永远同时支持 pull 和 push 两种数据接入模式**,这是 PAC 的能力,
/// 不是配置项。本表里 pullConfig / pushSecretHash 只是表达"这个特定宿主是否参与某种模式":
///   - pullConfig 不为 null  → PAC 会按计划去拉这个宿主(资源 endpoints,见下)
///   - pushSecretHash 不为 null → PAC 接受用该 HMAC 密钥签名的 push(事件流)
///   - 两者都为 null → 该宿主不贡献数据(但仍可换票消费 API Pool 2)
///   - 两者都填 → 同时支持
///
/// 路径分工:
///   - **Pull 永远拉资源**(GET /appointments 等返回当前态对象列表),
///     adapter 比对前后态合成 patient_transactions(action=appointment_created / appointment_changed / ...)
///   - **Push 直接收事件**(宿主已经发出 action+subject 报文),
///     adapter 验签 → 直接落 patient_transactions
///   - 下游(parser → fact → persona → plan)对路径无感知
///
/// 边界(PAC 不维护宿主侧主数据):**不立 tenants / clinics / users 表** —
/// tenant_id / clinic_id / user_id 在子表里都是 TEXT,值来自宿主换票时的 token。
/// 同一集团接两个宿主 = PAC 视角的两个独立 tenant(by-platform isolation,不串数据)。
model Host {
  /// 主键 UUID — 所有子表 `host_id` 外键到此
  id            String @id @default(uuid()) @db.Uuid
  /// 平台名(英文 slug 风格:friday / 5i5y / dw / demo / mock)。唯一,人读用;
  /// 子表关联走 `id` UUID,不依赖此字段
  name          String @unique
  /// 宿主调换票接口用的 appId
  appId         String @unique @map("app_id")
  /// appSecret 的 scrypt 哈希(salt:hash 格式),原文从不落库
  appSecretHash String @map("app_secret_hash")

  /// PAC 对该宿主的出站调用配置;null = PAC 不主动调用宿主(纯 push 接入或不贡献数据)。
  ///
  /// **凭证共用**:`credentials` 对该宿主**所有**出站调用共用 —
  /// 批量资源拉取、user 点查解析、单患者刷新等都用同一套。
  ///
  /// 形状(应用层 zod 校验):
  /// {
  ///   "endpoints": {
  ///     // 批量资源拉取(adapter 比对前后态合成 transaction),key 跟 subject_type 对齐
  ///     "patients":            "...",   // 主档
  ///     "consultations":       "...",
  ///     "appointments":        "...",
  ///     "visit_registrations": "...",
  ///     "visit_receptions":    "...",
  ///     "clinical_encounters": "...",
  ///     "emr_documents":       "...",
  ///     "charge_orders":       "...",
  ///     "payments":            "...",
  ///     "refunds":             "...",
  ///     "image_studies":       "...",
  ///     // 点查接口
  ///     "users":               "..."    // POST /users:resolve 解析 userId → name(token 字典覆盖不到时的兜底)
  ///     // 宿主能提供哪个 endpoint 就配哪个;未配的资源不拉、未配的点查走兜底"未知用户"
  ///   },
  ///   "credentials": {
  ///     // 二选一(支持两种鉴权方式),对所有 endpoints 共用
  ///     "loginUrl": "...", "username": "...", "password": "<encrypted>",
  ///     "apiKey":   "<bearer-token>"
  ///   },
  ///   "amountUnit": "fen" | "yuan",   // 必填无默认 — 宿主金额数值的单位
  ///   "timezone":   "Asia/Shanghai",  // 必填无默认 — IANA 时区,解析无时区字符串时用
  ///   "intervalMinutes": 5
  /// }
  pullConfig Json? @map("pull_config")

  /// 接受该宿主 push 时验签用的 HMAC 共享密钥;空数组 = 拒绝该宿主所有 push。
  /// **存原文**(HMAC 对称,PAC 验签需与宿主持同一 key);列名 Hashes 是历史命名,不代表哈希。
  /// 数组形式支持**平滑密钥轮换**:验签时遍历任一命中即通过。
  /// 约定:索引 0 是 current,后置为 grace period 内仍接受的旧 key,运维定期清理过期项
  pushSecretHashes String[] @map("push_secret_hashes")

  /// 动作跳转 URL 模板(VIEW_PATIENT、CREATE_APPOINTMENT 等)。
  /// 形状: { "VIEW_PATIENT": "https://.../patient/{patientId}", ... };null/缺失 = 前端不渲染该按钮
  actionUrls Json    @default("{}") @map("action_urls")

  /// 组织节点别名:PAC 内部 org 树节点 id → 宿主契约里的组织 id(orgScope 传的那套)。
  /// 用于"PAC 节点 id ≠ 宿主契约 id"的层 —— 典型:品牌层 PAC 用名字(source_unit,如"瑞尔"),
  /// 宿主 orgScope 按契约传品牌 GUID(`ba67e6cf…`)。挂进 org 树品牌节点 aliases,
  /// 让品牌名 / 品牌 GUID 两种传法都能命中,不改 source_unit、不影响诊所层。
  /// 形状: { "瑞尔": "ba67e6cf30dc4f9c9c46adef188bbd04", "瑞泰": "77057aed269f4a14957ae0ad0eff359a" }
  orgAliases Json    @default("{}") @map("org_aliases")

  /// 诊所名字典:诊所 id → 中文名。**从摄入数据派生**(DW organization_name 等),服务端维护,
  /// 经 /auth/session 合并进 dictionary.clinics 下发 —— 前端显示名字不再依赖宿主换票时传 dictionary。
  /// 宿主换票仍可传 dictionary.clinics 作覆盖(登录传的优先,服务端补齐其余)。
  /// 形状: { "66701845dd2342e19f9e9f576c4ffe9c": "北京朝阳公园诊所", ... }
  clinicNames Json   @default("{}") @map("clinic_names")

  /// 执行结果回执地址(宿主接收 POST 的 HTTPS endpoint);null = 不启用回执(opt-in)。
  callbackUrl String? @map("callback_url")
  /// 回执验签用的 HMAC 共享密钥;PAC **签名**用,故存**原文**(不同于 appSecret 存哈希)。
  /// 数组前插支持平滑轮换:索引 0 = current(签名用它),后置为宿主切换期仍在用的旧 key。
  /// 与 push 密钥相互独立(不同方向、不同宿主子系统,各管一把)。
  callbackSecrets String[] @default([]) @map("callback_secrets")

  /// 软停用开关,false 时换票直接 401
  active     Boolean @default(true)

  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

  patients             Patient[]
  transactions         PatientTransaction[]
  facts                PatientFact[]
  syncLogs             SyncLog[]
  personas             Persona[]
  personaRecomputeLogs PersonaRecomputeLog[]
  followupPlans        FollowupPlan[]
  planScripts          PlanScript[]
  planSummaries        PlanSummary[]
  planExecutions       PlanExecution[]
  planGenerationLogs   PlanGenerationLog[]
  planEventLogs        PlanEventLog[]
  planAssignments      PlanAssignment[]
  agentInvocations     AgentInvocation[]

  @@map("hosts")
}

// =============================================================
// Layer 1: 患者事实层
// =============================================================

/// 患者主档 — 身份对象。同一物理人在集团内任何诊所共享同一行。
///
/// 召回池硬过滤:WHERE status='active' AND do_not_contact=false AND deceased=false AND phone IS NOT NULL
/// (phone IS NULL 即"stub 患者" — push 首见时只拿到 patientRef,后续 pull 补全)
///
/// **不支持物理删除**:PIPL 删除权走"匿名化"(name=null, phone=null, externalId 保留),
/// 或 status='archived' 软删。子表 onDelete=Restrict 兜底防误删,审计链不可断。
///
/// **Merge 穿透查询**(status='merged' 时关键策略):
/// 历史 patient_transactions / patient_facts 仍挂在被合并的旧 patient_id 上(保审计原貌),
/// 应用层读历史时必须 merge-aware — 用递归 CTE 收集 root + mergedFrom 子树所有 patient_id:
///   WITH RECURSIVE tree AS (
///     SELECT id FROM patients WHERE id = :root
///     UNION ALL
///     SELECT p.id FROM patients p JOIN tree t ON p.merged_into_id = t.id
///   )
///   SELECT * FROM patient_facts
///   WHERE patient_id IN (SELECT id FROM tree) AND status='active'
/// Persona / Plan 重算只针对 root(active)patient,但读 fact / transaction 时全量穿透。
/// 写入永远写 root,旧档"封存"。
/// Patient 主档 — **只装身份信息**(identity)。
/// 状态/标签/合规等"非事实属性"全部移到 PatientProfile 副表(1:1)。
/// `active` 布尔取代旧 `status` enum;merge 功能 v1 不做(已删 mergedIntoId)。
model Patient {
  id         String @id @default(uuid()) @db.Uuid
  /// 接入宿主(FK → hosts.id)
  hostId     String @map("host_id") @db.Uuid
  /// 集团 id(= PAC 租户;稳定 slug,如 ruier-grp)。来自换票 token;PAC 不维护 tenants 主数据
  tenantId   String @map("tenant_id")
  /// 源组织命名空间(发证来源):给 external_id 在集团内消歧用。
  ///   通用字段 — 各宿主"在哪一层发患者号"不同:jvs-dw=品牌(瑞尔/瑞泰),别的 host 可能=区域/诊所/无。
  ///   manifest 的 identity_namespace_field 声明取哪个源字段填;**单命名空间宿主留空串 ''**(非 null:
  ///   null 在唯一索引里算 distinct 会让去重失效;'' 是具体值,唯一键对所有宿主都稳)。
  ///   语义="发证命名空间"(历史不可变),非"当前品牌"(当前归属由组织树解析)。
  sourceUnit String @default("") @map("source_unit")
  /// 宿主侧患者 id,回链用
  externalId String @map("external_id")

  /// 患者姓名(stub 期间可空 — push 事件 payload 不一定带,后续 pull 补全)
  name                String?
  /// 联系电话(stub 期间可空 — 同上;phone IS NULL 自动排除召回池)
  phone               String?
  /// 号码真实性标记:true = 已用外部对照表(病历号→真实手机号)核实替换的真号;
  /// false = 宿主同步来的号(测试库为造数假号)。导入脚本写 true。
  phoneVerified       Boolean   @default(false) @map("phone_verified")
  /// 性别(自由文本 — 各宿主取值不一,不强枚举;业务用到时归一化)
  gender              String?
  /// 出生日期
  birthDate           DateTime? @map("birth_date") @db.Date
  /// 病历号 — host 病历主键(纸质档案号),辅助标识。跟 externalId 互补:
  ///   externalId          = host 内部业务 id(系统主键,稳定但对人无意义)
  ///   medicalRecordNumber = 纸质档案 / 客服沟通用的可读编号(如 "BA43016")
  /// 普适字段(任何医疗系统都有病历号)。host 不提供时留 null。
  medicalRecordNumber String?   @map("medical_record_number")
  /// 偏好(沟通时间、语言、就诊偏好等)。形状自由,应用层 zod 校验:
  /// { contactWindow?: {start,end}, language?, preferredChannel?, ... }
  preferences         Json?

  /// 档案有效性:true = 在召回池;false = 宿主软删/归档(原 status='archived' 等价)。
  /// 合规硬过滤(doNotContact / deceased / DNC)在 PatientProfile 副表
  active Boolean @default(true)

  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

  host         Host                 @relation(fields: [hostId], references: [id])
  profile      PatientProfile?
  transactions PatientTransaction[]
  facts        PatientFact[]
  personas     Persona[]
  plans        FollowupPlan[]
  /// 我的联系人/亲属边(本人 → 关系人);关系人本身也是 patient
  relationsOut PatientRelation[]    @relation("PatientRelationsOut")
  /// 把我当联系人的边(别人 → 我)
  relationsIn  PatientRelation[]    @relation("PatientRelationsIn")
  /// 诊所回访任务记录(展示用,5 试点;非召回信号)
  returnVisits PatientReturnVisit[]

  /// 跨同步 upsert 去重 + 集团内 external_id 撞号消歧(同号不同源 = 不同人,靠 source_unit 分开)
  @@unique([hostId, tenantId, sourceUnit, externalId])
  @@index([hostId, tenantId])
  @@index([phone])
  /// 召回池热查询路径(active = 是否活跃);合规过滤走 PatientProfile join
  @@index([hostId, tenantId, active])
  @@map("patients")
}

/// PatientProfile — 患者非事实属性副表(1:1)。
///
/// 性质 = "当前状态 / 标签 / 合规标记",不是事件。事件流走 PatientTransaction + PatientFact。
///
/// 拆出来的理由:
///   - 主表 Patient 只装"身份"(identity:name/phone/dob 等)— 跨表查询时 JOIN 主表更轻
///   - 合规字段(doNotContact / deceased / DNC)+ 描述字段(tags / notes)集中
///   - 联系人/亲属:不在此(已废 primaryContactType)→ 走 PatientRelation 边表
///   - 召回池硬过滤走 patient_profiles JOIN(逻辑独立、维护清晰)
model PatientProfile {
  /// 1:1 用 patientId 作 PK(也兼 FK)
  patientId String @id @map("patient_id") @db.Uuid

  /// 不打扰名单(合规硬要求)。患者 opt-out / 投诉 / 主动要求停止回访 → true,永久从召回池排除
  doNotContact       Boolean   @default(false) @map("do_not_contact")
  /// 不打扰原因(应用层 zod enum:complaint / opt_out / treated_elsewhere / legal_hold / other)
  doNotContactReason String?   @map("do_not_contact_reason")
  /// 打标时间,审计 + 看是否过冷静期
  doNotContactAt     DateTime? @map("do_not_contact_at") @db.Timestamptz(3)

  /// 已故标记。医疗 SaaS 必须支持,绝不发回访
  deceased   Boolean   @default(false)
  deceasedAt DateTime? @map("deceased_at") @db.Timestamptz(3)

  /// 宿主跟进闸 —— 宿主侧正在跟进该患者(自己已建工单/复杂病例、客服在推进)→ PAC 不再召回,
  /// 避免两边同时联系同一个人。与 do_not_contact / deceased 并列在召回②合规硬过滤,但**性质不同**:
  ///   合规闸 = 法务/风险,永久,需人工解除;跟进闸 = 协作分工,临时,宿主停止跟进即回池。
  /// ⚠️ **可空且无默认值**:null = 宿主未提供该信号(如 jvs-dw 未接入)→ 不启用该闸。
  ///   查询一律用 `IS NOT TRUE` 而非 `= false` —— 后者会因 NULL != false 把未接入宿主的患者全挡在池外。
  /// FRIDAY 侧口径见 docs/integration/friday-push-payload(has_active_complex_case)。
  hostFollowUpActive Boolean? @map("host_follow_up_active")

  /// 产品收集(回访中心信息字段.docx):宿主侧给客户打的标签数组 — 多源标签 / 客户偏好。
  /// 例:["VIP", "难沟通", "投诉过", "充值卡客户"]。Persona engine 可作为输入信号。
  tags  String[] @default([]) @map("tags")
  /// 产品收集:宿主侧客户描述/备注(自由文本,非结构化补充);跟 tags 互补:
  ///   tags  — 可枚举/可 query 的标签集合
  ///   notes — 单段长文本(客服备注 / 诊所自由描述)
  notes String?  @db.Text

  /// 获客渠道(初诊来源,A.2.1)— PAC 立柱标准枚举(host 值经 assembler enum_mapping 归一)。
  /// 一经判定不改(不可变源属性,非易变 persona,故落副表立柱而非 fact);多宿主共用此标准列。
  /// 值见 canonical-codes.PACAcquisitionChannels(walk_in/word_of_mouth/.../other)
  acquisitionChannel String? @map("acquisition_channel")
  /// 二级渠道(host 原值透传,"详见数仓定义";不强约束,展示/下钻用)
  acquisitionSub     String? @map("acquisition_sub")

  /// 转介绍达人(B.1.2)— DW 预聚合的推荐活动(随被推荐人到诊更新,增量见 cursor 备注)。
  /// referralCount = recommend_num(本患者推荐人数);referralAmountCents = recommend_amount(带来转化总额,分)。
  referralCount       Int? @map("referral_count")
  referralAmountCents Int? @map("referral_amount_cents")

  // ── 到诊派生字段 ——「上次到诊 / 上次医生 / 偏好医生」────────────────────────────────
  // 放这张副表而不是 patients:profile 本来就装**派生 / 摄入属性**(获客渠道、转介绍统计),
  //   而 patients 是主数据。派生的归派生,主数据不被越搞越宽。
  //
  // ⚠️ 也**不是画像** —— 2026-07-29 曾一度做成 persona 特征 visit_recency,当天被否:
  //   它会混进患者详情的画像标签抽屉、并喂给画像摘要 LLM。「上次到诊 2022-06-19」是原始事实,
  //   画像该是「重要价值 / 流失客 / 禁忌」这类判断。
  //
  // ⭐ **存日期不存分档**,这是不用任何定时任务的关键:
  //   存 `0_3m` 这种分档,时间流逝自己就会错(今天 2 个月、下月变 3 个月)→ 得配到期重扫;
  //   存日期则**只有患者又来了才会变**,而"又来了"必然先写到诊 fact → 推进事实水位 →
  //   触发该患者画像重算 → 这三个字段顺路刷新。触发时机天然重合,自洽。
  //   0-3/3-6/… 的区间在**查询时**用 now 现算(visitRecencyRange),所以语义永远对得上今天。
  //
  // 卡片展示与筛选**都读这三列**,同源 —— 否则会出现"卡片显示 A、筛选按 B 匹配"。
  // 首次画像重算前为 NULL:卡片留空、筛选筛不到。刻意不做"取不到就现算"的兜底,
  //   那会让展示值与筛选口径再次分叉。

  /// 最近一次到诊日期。口径 = encounter + 实际治疗 + 挂号 + **病历**的并集。
  /// ⚠️ 并集里的"病历"不能省:本地实测 48.7% 的患者只有病历、没有 encounter/治疗/挂号记录
  ///    (宿主 appointment.in_time 缺失等),漏掉就有近一半患者的"上次到诊"是空的。
  ///    医生写了病历,患者必然到过场。详情页 lastVisit 早就是这个口径。
  lastVisitAt     DateTime? @map("last_visit_at") @db.Date
  /// **上次到诊当天**那份病历的接诊医生。跨天不认 —— 否则「日期是A次、医生是B次」,客服照着说会露馅。
  lastVisitDoctor String?   @map("last_visit_doctor")
  /// 主治医生 = 病历里出现频次最高的医生。当前用作业务要的「偏好医生」的代替 ——
  /// DW fact_client_out.favor_doctor_id 才是真值,PAC 尚未摄入;接入后换值即可,消费方不用改。
  preferredDoctor String?   @map("preferred_doctor")

  /// 筛选索引:上次时间走区间、两个医生走等值(前端在名单上做模糊,选中发精确姓名)
  @@index([lastVisitAt])
  @@index([lastVisitDoctor])
  @@index([preferredDoctor])

  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

  patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)

  @@index([doNotContact])
  @@index([deceased])
  @@index([hostFollowUpActive])
  @@index([acquisitionChannel])
  @@map("patient_profiles")
}

/// PatientRelation — 患者-患者关系边(联系人/亲属/转介绍人)。
///
/// 第一性:联系人本身也是一个 patient(FHIR RelatedPerson / Patient link)。所以建成
/// 自关联边,而不是把姓名/电话快照塞进 patient —— 关系人的姓名/电话从 relatedPatient 现取,零冗余。
///
/// 来源:host 的"客户-推荐人关系"(referee)。一条 referee = 一条边。
///   patientId         = 本人(referee.patient_id → PAC patient,必须能解析,否则无处挂靠 → skip)
///   relatedExternalId = 关系人 host id(referee.referee_patient_id);**始终保留**,唯一稳定引用 + 晚绑定
///   relatedPatientId  = 关系人 PAC id;关系人本身也是 active patient 时才填(给姓名/电话);否则 null
///   relationship      = 本人视角:relatedPatient 是本人的 X(mother/father/grandparent/...)
///
/// primaryContactType(原 PatientProfile 单字符串)已废弃 —— "谁主要联系"是这张表的派生投影(读时按
/// relationship + 本人年龄算),不再立柱。
model PatientRelation {
  id       String @id @default(uuid()) @db.Uuid
  hostId   String @map("host_id") @db.Uuid
  tenantId String @map("tenant_id")

  /// 本人(FK → patients)
  patientId         String  @map("patient_id") @db.Uuid
  /// 关系人 host 侧 id(始终有)
  relatedExternalId String  @map("related_external_id")
  /// 关系人 PAC id(关系人也是 patient 时填;给姓名/电话)。落不到则 null
  relatedPatientId  String? @map("related_patient_id") @db.Uuid
  /// 关系类型(mother/father/grandparent/spouse/child/sibling/friend/other)
  relationship      String

  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

  patient        Patient  @relation("PatientRelationsOut", fields: [patientId], references: [id], onDelete: Cascade)
  relatedPatient Patient? @relation("PatientRelationsIn", fields: [relatedPatientId], references: [id])

  /// 同本人 × 同关系人 × 同关系 唯一(治 host 重复边,如两条"妈妈")
  @@unique([patientId, relatedExternalId, relationship])
  @@index([patientId])
  @@index([relatedPatientId])
  @@map("patient_relations")
}

/// PatientReturnVisit — 诊所回访任务记录(展示用,仅 5 试点 org)。
///
/// 性质 = 诊所自己的外呼/回访运营记录(常规/术后/咨询回访),**不是临床 fact,不进召回信号**。
/// 详情页"回访记录"块按 taskDate 倒序展示,让客服看到诊所已做/已排的回访 → 避免重复外呼 + 上下文。
/// 独立表(非 transaction/fact):摄入按 organization_id ∈ 5 试点 + customer_id→patient 解析 upsert。
model PatientReturnVisit {
  id             String    @id @default(uuid()) @db.Uuid
  hostId         String    @map("host_id") @db.Uuid
  tenantId       String    @map("tenant_id")
  patientId      String    @map("patient_id") @db.Uuid
  /// 源组织命名空间(= 该患者的 source_unit);集团内 external_id 品牌级会撞,进键消歧。默认 ''。
  sourceUnit     String    @default("") @map("source_unit")
  /// host 回访记录 id(幂等键)
  externalId     String    @map("external_id")
  clinicId       String?   @map("clinic_id")
  /// 回访任务日期(含未来排程)
  taskDate       DateTime? @map("task_date") @db.Date
  /// 类型:常规回访 / 术后回访 / 咨询回访(host 原文)
  type           String?
  /// 回访状态:已回访 / 未回访
  status         String?
  /// 任务状态:已完成 / 未完成 / 创建新回访
  taskStatus     String?   @map("task_status")
  /// 关联治疗项(如 种植)
  treatmentItems String?   @map("treatment_items")
  /// 回访内容(术后年度复查 / 洁牙提醒 / 活动邀约…)
  followContent  String?   @map("follow_content")
  /// 回访结果
  result         String?

  /// 负责本次回访任务的客服(host 列 task_director,其 comment 写作"专属客服")。
  /// ⚠️ **与患者主档的"当前专属客服"(patients.preferences.dedicatedCs ← current_task_director)
  /// 不是一回事**:2026-08-01 实测近 12 月 367 万对回访,两者仅 **23.1%** 相同 ——
  /// 本字段是"该任务当时派给谁",患者主档那个是"此刻挂在谁名下"。合并成一列会同时丢两边语义。
  /// 用途:按诊所反推客服名册(该诊所近 N 月有回访行为的人)供主管指派 —— host 不提供
  /// "客服归属诊所"字段,只能按行为反推。⚠️ 客服与诊所是**多对多**(实测 24% 跨诊所,
  /// 最多跨 13 家),同一人会出现在多个诊所名册里,不要当成一对一属性建模。
  taskDirectorId   String? @map("task_director_id")
  taskDirectorName String? @map("task_director_name")

  /// 宿主侧记录的创建 / 末次修改时间(host created_date / updated_date,DW 侧非空)。
  /// ⚠️ 与上面的 createdAt/updatedAt **不是一回事**:那两个是 PAC 自己的入库时间
  /// (@default(now()) / @updatedAt),回填与重摄都会把它们刷成"现在",无法反映业务时间。
  /// 命名沿用 PatientFact.sourceUpdatedAt 的既有口径(source* = 宿主侧时间)。
  /// 用途:名册"近 N 月在岗"若改用"任务创建时间"而非 task_date 时可用(task_date 含未来排程,
  /// 实测最远到 2033 年,直接拿它卡时间窗会把排了远期任务的人算成"在岗")。
  sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(3)
  sourceUpdatedAt DateTime? @map("source_updated_at") @db.Timestamptz(3)

  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

  patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)

  @@unique([hostId, tenantId, sourceUnit, externalId])
  @@index([patientId, taskDate(sort: Desc)])
  /// 客服名册查询:按 (诊所, 客服) 聚合 + taskDate 卡时间窗
  @@index([hostId, tenantId, clinicId, taskDirectorId, taskDate(sort: Desc)])
  /// ⭐ 名册「近 N 月在岗」判定专用 —— 末列必须是 source_created_at。
  /// 上面那条末列是 task_date,而 task_date **含未来排程**(实测最远 2033 年、DW 侧 2121 年),
  /// 拿它卡在岗窗口会把早已离职的人判成在岗。两条索引各有其用,别删上面那条。
  /// ⚠️ 建它的迁移(20260802150000)是**单语句 + CONCURRENTLY**、独立目录 ——
  /// 回访表生产 166.7 万行且正被增量同步持续写入,普通 CREATE INDEX 会锁死同步。
  @@index([hostId, tenantId, clinicId, taskDirectorId, sourceCreatedAt(sort: Desc)], map: "patient_return_visits_roster_source_idx")
  @@map("patient_return_visits")
}

/// 患者操作账本(append-only)。
/// 一条记录回答:**"在哪个宿主、哪个 tenant、哪个诊所、对哪个主体,做了什么动作"**。
/// 原始证据存在 rawPayload(宿主原文),canonical 化和事实视图在 patient_facts 层做。
///
/// 写入模式:**append-only** — 只插不改不删,错了只能补一条修正 transaction。
/// 幂等键 `source_event_id`(MIGRATION partial UNIQUE,WHERE NOT NULL);
/// Push 路径硬要求带,Pull 路径 adapter 合成 hash(externalRef + occurredAt + payloadHash)。
///
/// `event_seq` BigInt 自增水位,供 Persona.eventWatermark / 同步游标等单调消费,不依赖 created_at。
model PatientTransaction {
  id        String  @id @default(uuid()) @db.Uuid
  /// 单调递增水位(Postgres 序列维护,BigInt 防 32 位溢出)
  eventSeq  BigInt  @default(autoincrement()) @map("event_seq")
  /// 接入宿主(FK → platforms.id)
  hostId    String  @map("host_id") @db.Uuid
  /// 宿主侧 tenant id(来自换票 token)
  tenantId  String  @map("tenant_id")
  /// 关联患者(纯管理动作或 stub 阶段可留 null)
  patientId String? @map("patient_id") @db.Uuid

  /// 宿主原生事件 ID 或 adapter 合成 hash;**幂等键**。Push 必填,Pull adapter 合成。
  /// nullable 兼容 manual / 早期接入,有值时唯一,无值时不参与去重
  sourceEventId String? @map("source_event_id")

  /// 操作发生诊所(业务字段,**立柱必填** — 反查 / 索引 / 业务过滤用,非硬隔离边界)。
  /// 跟 followup_plans.target_clinic_id(归属)/ plan_executions.executor_clinic_id(执行地)语义独立
  clinicId String @map("clinic_id")

  /// 动作事实类型(应用层 zod enum,@pac/types Action)— 共 23(主流程 8 + v2.1 临床 5 + 单据 6 + 产品收集 4):
  ///
  ///   ── 主流程动作(8)──
  ///   consultation_created       (咨询创建)
  ///   appointment_created        (预约创建)
  ///   appointment_changed        (预约变更)
  ///   appointment_cancelled      (预约取消)
  ///   appointment_fulfilled      (患者到诊 — 预约履约,触发 appointment_record kind=actual)
  ///   visit_registered           (挂号)
  ///   reception_completed        (前台接待完成)
  ///   doctor_encounter_started   (医生接诊开始,只元数据)
  ///
  ///   ── v2.1 拆出的临床动作(5,W2 末加,canonical-fact-layer.md §四)──
  ///   diagnosis_recorded         (诊断记录 — 独立 fact)
  ///   treatment_planned          (治疗计划录入 — kind=planned)
  ///   treatment_started          (治疗开始 — kind=actual, content.status=in_progress)
  ///   treatment_completed        (治疗完成 — kind=actual, content.status=completed)
  ///   recommendation_extracted   (医生建议 — Layer C LLM/规则抽出)
  ///
  ///   ── 单据 / 病历 / 影像(6)──
  ///   order_submitted            (医嘱提交,治疗/耗材/药品 — 原 charge_order_submitted)
  ///   payment_received           (收款)
  ///   refund_created             (退费)
  ///   emr_submitted              (病历提交)
  ///   emr_rejected               (病历打回 — 质控信息塞 emr_record.content.qualityCheck)
  ///   image_uploaded             (影像上传)
  ///
  ///   ── 产品收集(4)──
  ///   recharge_received          (充值入账)
  ///   complaint_filed            (投诉提交)
  ///   complaint_resolved         (投诉处理结案)
  ///   referral_recorded          (转介绍记录)
  action      String
  /// 被操作主体类型(应用层 zod enum,@pac/types SubjectType)— 共 16(主流程 5 + v2.1 拆出 3 + 单据 5 + 产品收集 3):
  ///
  ///   ── 主流程主体(5)──
  ///   consultation         (咨询)
  ///   appointment          (预约)
  ///   visit_registration   (挂号)
  ///   visit_reception      (接待登记)
  ///   encounter            (医生接诊,只元数据)
  ///
  ///   ── v2.1 拆出的临床主体(3,W2 末加)──
  ///   diagnosis            (诊断 — 独立)
  ///   treatment            (治疗 — actual/planned 由 fact.kind 区分)
  ///   recommendation       (医生建议 — LLM/规则抽出)
  ///
  ///   ── 单据 / 病历 / 影像(5)──
  ///   emr                  (病历文档)
  ///   order                (医嘱 — 治疗/耗材/药品)
  ///   payment              (收款)
  ///   refund               (退费)
  ///   image                (影像资料)
  ///
  ///   ── 产品收集(3)──
  ///   recharge             (充值/储值/福利卡办理)
  ///   complaint            (投诉)
  ///   referral             (转介绍记录)
  subjectType String @map("subject_type")
  /// 宿主侧主体业务 ID(如 apt_001;同一 subject_id 可被多条 transaction 反复操作)
  subjectId   String @map("subject_id")

  /// 宿主原生 100% 原文(adapter 收到啥存啥)。
  /// 唯一事件原始证据;canonical 视图由 parser 落到 patient_facts.content,
  /// before/after diff 由 fact 版本流(subject_id × version)推导
  rawPayload       Json   @map("raw_payload")
  /// canonical row 留底(AssemblerEngine yaml 翻译后的中间产物,转 parser 用)。
  /// 用途:① 审计 — fact 算错时回查中间态;② replay — yaml 改后局部重跑 parser 不必从 raw 重头算
  /// nullable 兼容老数据(初期未实施 canonical 留底);新写入必须填
  canonicalPayload Json?  @map("canonical_payload")
  /// payload 指纹(sha256 of rawRow);完整性 / 兜底校验用的独立字段。
  /// 注:source_event_id 由 TransactionSynthesizer 用 `来源:资源:source_unit:subjectId:updatedAt` 合成,
  ///     **不含本字段**(早期注释说"也作为 source_event_id 输入"已与代码不符,已更正)。
  payloadHash      String @map("payload_hash")

  /// 宿主侧实际发生时间(adapter 按 platform.pullConfig.timezone 解析无时区字符串后转 UTC)。
  /// 跟 patient_facts.occurredAt 字段名一致(同一语义在事件账本和事实视图复用)
  occurredAt DateTime @map("occurred_at") @db.Timestamptz(3)
  /// PAC 接收时间(同步监控用)
  receivedAt DateTime @default(now()) @map("received_at") @db.Timestamptz(3)
  /// 关联 sync_logs.id;一次 run 把若干 transaction 一并落库,可反查"哪批拉进来的"
  syncLogId  String?  @map("sync_log_id") @db.Uuid
  createdAt  DateTime @default(now()) @map("created_at") @db.Timestamptz(3)

  host    Host     @relation(fields: [hostId], references: [id])
  /// SetNull(非 Cascade):审计账本不随 patient 删除而消失;patientId 已 nullable
  patient Patient? @relation(fields: [patientId], references: [id], onDelete: SetNull)
  syncLog SyncLog? @relation(fields: [syncLogId], references: [id])

  /// 立柱业务字段联合索引(同时覆盖 [hostId, tenantId] 前缀查询)
  @@index([hostId, tenantId, clinicId])
  /// 按患者拉时间轴(parser / 审计反查路径)
  @@index([patientId, occurredAt])
  /// host 运行状态统计(admin/host/self 页 24h/30d 计数)—— (patientId, occurredAt) 的第二列在
  /// "只按时间过滤不带 patientId"时用不上(需近乎全扫索引),线上实测单次 count 冷时 8s+。
  @@index([hostId, occurredAt])
  /// 按业务主体反查(同一 apt_001 被几次改过)
  @@index([subjectType, subjectId])
  /// 按动作类型分析 / 仪表盘
  @@index([action])
  /// 同步运维 / 监控
  @@index([receivedAt])
  /// 水位游标查询(persona 重算"自上次以来新增的 transaction")
  @@index([eventSeq])
  /// 一次摄入产生了哪些 transaction —— 每次 push / cold-import / 增量收尾都要按 sync_log_id
  /// 反查 touched 患者(cold-import.service.ts `touchedRows`)。
  ///
  /// ⚠️ 外键**不会**自动建索引。缺这条 → 每次 push 全表 Seq Scan:
  ///   2026-07-27 测试服实测,FRIDAY 对接推 2 行患者主档耗时 74s,EXPLAIN 为
  ///   `Parallel Seq Scan on patient_transactions (cost=0.00..2150645.78)`,
  ///   表 1251 万行 / 33GB;生产同表 2407 万行 / 64GB(更慢)。
  ///   对方 HTTP 客户端直接 `SocketTimeoutException: Read timed out`。
  /// 这条随表增长只会更糟 —— 摄入量越大、push 越慢,属结构性问题不是调优。
  @@index([syncLogId])
  /// reparse 按批取源行:`WHERE host_id = ? AND subject_type IN (...) AND patient_id IN (3000个)`
  /// (cold-import.service.ts 分批重建源表那段)。
  ///
  /// 没这条时 planner 走 (patient_id, occurred_at):把这 3000 个患者的**全部** transaction
  /// 连 raw_payload 一起捞进堆,再在堆上按 host_id / subject_type 过滤。
  /// 2026-07-27 测试服 EXPLAIN ANALYZE 实测(12,519,060 行 / 33GB):
  ///   Bitmap Heap Scan 10,385ms;读 53,883 行只要 14,578 —— Rows Removed by Filter: 39,305(73% 白读)
  ///   Buffers: read=15,616(约 122MB 磁盘读);全量 87 批 → 这一句就占约 15 分钟
  ///
  /// ⚠️ 收益要说实话:每批 76–180s 里它只占 6–14%,对全程约 4%,**不是提速主手段** ——
  ///   真正的杠杆是 CLI 的 `--patients-file` 按受影响患者收窄(实测最高 250 倍)。
  ///   这条胜在每次 reparse / 每个 host 都受益,代价只有一次建索引。
  @@index([hostId, subjectType, patientId])
  /// 幂等键 partial UNIQUE (host_id, tenant_id, source_event_id) WHERE source_event_id IS NOT NULL
  /// Prisma 不支持声明式 partial unique;migration 20260528000002 是单一真理源。
  /// (init migration 漏建,20260528000002 补 dedup + 建索引)
  @@map("patient_transactions")
}

/// 患者事实单元(版本流)。"患者事实中存在什么"。
///
/// 由 transaction 经 adapter parser 解析衍生;一个 transaction 可产 0 / 1 / N 个 fact 状态变化
/// (如 doctor_encounter_started → encounter_record + parser 衍生 treatment_record / treatment_plan /
/// review_plan 等多个 fact)。
///
/// 写入模式:**版本流(append + supersede)**
///   - 新版本 = 新 UUID(技术主键),`subject_id`(业务身份)跨版本稳定
///   - `(platform, tenant, subject_id, version)` 单调递增
///   - partial UNIQUE `(subject_id) WHERE status='active'` 强制单活跃版本(MIGRATION 补)
///   - evidence 引用 patient_transactions.id 数组,审计反查"哪些 transaction 衍生了本版本"
///
/// `clinic_id` 立柱 nullable — actual / planned 字段统一;反例:中央客服 400 接的 consultation_record、
/// 集团总部群发触达。必填性 per type 在应用层 zod 校验,不在 schema 强约束。
///
/// **只装宿主侧事实**:PAC 内部产物(Plan / PlanExecution / AI 输出)不回写 fact;
/// AI 解析结果归 agent_invocations.output(db-review-confirm §3.7)。
///
/// **并发约束**:Schema 层 unique 只兜底防脏数据,并发性能由应用层 + 队列层保证 —
/// BullMQ 用 `jobName = subject_id` 实现同 subject 串行化;事务内可用
/// `SELECT ... FOR UPDATE` 锁住 active 行再 supersede + insert 新版本。
model PatientFact {
  id        String @id @default(uuid()) @db.Uuid
  hostId    String @map("host_id") @db.Uuid
  tenantId  String @map("tenant_id")
  patientId String @map("patient_id") @db.Uuid
  /// 源组织命名空间(= 该患者的 source_unit)。集团内 subject_id 品牌级会撞
  /// (encounter_record:1011032 在瑞尔/瑞泰是两条不同就诊),进键消歧。默认 ''。
  sourceUnit String @default("") @map("source_unit")

  /// **业务身份,跨版本稳定**(对应宿主侧实体 ID,如 apt_001 / enc_001 的语义身份)。
  /// 同一 subject 被多次变更时新版本沿用同一 subject_id,version 递增
  subjectId String @map("subject_id")
  /// `actual`(已发生事实) / `planned`(未来安排)
  kind      String
  /// 事实类型(应用层 zod enum,@pac/types FactType)— v2.1 重塑后共 16 类(对齐 apps/pac-docs/content/docs/architecture/canonical-fact-layer.mdx §四):
  ///
  /// **设计纪律**:按独立语义粒度拆 fact_type。
  /// 诊断 / 治疗 / 接诊 / 病历 / 影像 是不同生命周期的临床实体,各自独立 fact_type。
  ///
  ///   ── 主流程(4)──
  ///   consultation_record         (咨询记录)
  ///   visit_registration_record   (挂号记录)
  ///   visit_reception_record      (接待记录)
  ///   encounter_record            (接诊事件 — 只元数据 doctor_id/notes,**不嵌套诊断治疗**)
  ///
  ///   ── v2.1 拆出的临床 fact(3,W2 末加)──
  ///   diagnosis_record            (诊断 — content: code / tooth_position / source_encounter_external_id / severity?)
  ///   treatment_record            (治疗 — kind=actual 已做 / kind=planned 计划中,
  ///                                合并旧 treatment_plan + review_plan,
  ///                                content.category∈ 11 PAC category 含 review)
  ///   recommendation_record       (医生建议 — Layer C LLM 从 EMR 文本/影像 finding 抽出,
  ///                                content: {code, tooth_position, extracted_by, confidence, window_days})
  ///
  ///   ── 单据 / 病历 / 影像 / 预约(6)──
  ///   order_record                (医嘱记录 — content 含 items[] 技/耗/药)
  ///   payment_record              (收款记录)
  ///   refund_record               (退费记录)
  ///   emr_record                  (病历自由文本 — content 含 illness_desc/treatment_plan/doctor_advice 等)
  ///   image_record                (影像 metadata + 医生 finding)
  ///   appointment_record          (预约 — kind=planned 未到诊 / kind=actual 已履约 arrived;
  ///                                content.status∈ scheduled/arrived/cancelled/no_show)
  ///
  ///   ── 产品收集(3,回访中心信息字段.docx)──
  ///   recharge_record             (充值记录,储值办卡)
  ///   complaint_record            (投诉记录)
  ///   referral_record             (转介绍记录)
  ///
  /// **已废弃**(v2.0 → v2.1 合并/拆掉,迁移到上面):
  ///   treatment_plan / review_plan / payment_plan / emr_quality_record
  ///   → 合并进 treatment_record 用 kind=planned + category=review 等区分
  type      String
  /// 生命周期状态:
  ///   active      — 当前态(每 subject 同时只有一条)
  ///   superseded  — 被新版本替换
  ///   cancelled   — 业务取消(预约取消等)
  ///   fulfilled   — 已落实(预约 → 到诊后 appointment_plan fulfilled)
  ///   expired     — 时间窗过期(planned valid_until 过去)
  ///   invalidated — 因上游 transaction 修正而废止
  status    String
  /// `(platform, tenant, subject_id, version)` 单调递增
  version   Int

  /// 业务诊所(actual 可空 — 通过 evidence transaction 反查;planned 推荐填,per type 应用层校验)
  clinicId String? @map("clinic_id")

  /// actual 应填 — 事实在宿主侧实际发生时间
  occurredAt DateTime? @map("occurred_at") @db.Timestamptz(3)
  /// 源记录末次修改时间(= canonicalRow.updatedAt,宿主侧口径)。乱序防护用:
  /// 携带更旧 updatedAt 的版本晚到(补推快照 vs 实时推送并发)→ fact-writer 按此裁决跳过,不旧覆新。
  /// null = 源没给 updatedAt → 退回"到达顺序"语义(向后兼容,老数据/无该列的 host 不受影响)。
  sourceUpdatedAt DateTime? @map("source_updated_at") @db.Timestamptz(3)
  /// planned 推荐填 — 未来安排的目标时间(预约时间 / 复查时间 / 计划治疗时间)
  plannedFor DateTime? @map("planned_for") @db.Timestamptz(3)
  /// planned 时间窗起;部分场景(医嘱"1-2 周内复查")需要区间
  validFrom  DateTime? @map("valid_from") @db.Timestamptz(3)
  /// planned 时间窗止;过期 cron 用此扫描置 status='expired'
  validUntil DateTime? @map("valid_until") @db.Timestamptz(3)

  /// 简短标题(UI 展示用,如"复查 11 号牙(2026-05-20)")
  title   String?
  /// 自然语言摘要(供 LLM 拼 prompt / One Page Summary 展示)
  summary String? @db.Text
  /// 结构化内容(per type schema 由应用层 zod 校验)。
  /// **规则引擎读这里,不读 summary**(自然语言只给人和 LLM)
  content Json

  /// 关联触发本 fact 版本的 transaction.id 数组(证据链,反查"某 transaction 影响了哪些 fact")。
  /// MIGRATION:GIN 索引
  transactionIds String[] @map("transaction_ids") @db.Uuid

  createdAt    DateTime  @default(now()) @map("created_at") @db.Timestamptz(3)
  /// status 变更时更新(supersede / cancel / fulfill / expire / invalidate)
  updatedAt    DateTime  @updatedAt @map("updated_at") @db.Timestamptz(3)
  /// 被新版本取代的时间;status='active' 时为 null。跟 personas / followup_plans 字段一致
  supersededAt DateTime? @map("superseded_at") @db.Timestamptz(3)

  host    Host    @relation(fields: [hostId], references: [id])
  /// Restrict:fact 版本流是审计核心,强制应用层走 patient 软删 / 匿名化,不走物理删除
  patient Patient @relation(fields: [patientId], references: [id], onDelete: Restrict)

  /// 版本去重 — 同 subject 同 version 不能有两条
  @@unique([hostId, tenantId, sourceUnit, subjectId, version])
  /// 隔离基础过滤
  @@index([hostId, tenantId])
  /// host 运行状态统计(admin/host/self 页 active fact 计数)—— (hostId, tenantId) 没 status,
  /// (patientId, status) 不带 hostId;两者都覆盖不了"按 host+status 计数",线上实测冷扫全表 19s。
  /// 三列(非仅 hostId+status):tenant-guard 扩展(见 tenant-guard.extension.ts)会给未带 tenantId
  /// 的查询自动注入 tenantId 过滤——实测二列索引配上这条自动追加的谓词后,planner 直接弃用索引
  /// 退回全表扫;三列精确覆盖实际执行的查询形状,才稳定命中 index-only scan。
  @@index([hostId, tenantId, status])
  /// 拉某患者当前 active 视图(persona / plan 重算主路径)
  @@index([patientId, status])
  /// 召回 selectHits 主路径:sig 信号扫描 + gap resolvedTeeth 各子查询都按 (patient_id, type, status) 过滤。
  /// 原仅 (patient_id, status) → type 走内存 Filter,facts/患者 多时 heap fetch 超线性膨胀(13万患者全量重算瓶颈)。
  /// 实测本地 selectHits 7.3s→2.0s(~3.6×),prod 收益更大。
  @@index([patientId, type, status])
  /// 画像水位:max(updated_at) per patient(PersonaService.readWatermarks 每次重算前必查)。
  /// 倒序扫首行即可命中 index-only,不回堆 —— 没这条要按 (patientId,status) 扫完该患者全部
  /// fact 再取 heap 上的 updated_at,重患者(千级 fact)会把"闸"本身变成开销。
  @@index([patientId, updatedAt])
  /// 按患者拉 actual 时间轴
  @@index([patientId, occurredAt])
  /// 按患者查 planned 列表(待办 / 即将复查)
  @@index([patientId, plannedFor])
  /// 复查 / 待办类查询("近期未完成的 planned + 时间窗过期"扫描)
  @@index([type, status, validUntil])
  /// MIGRATION:partial UNIQUE (host_id, tenant_id, subject_id) WHERE status='active' — 强制单活跃版本
  /// MIGRATION:GIN (transaction_ids) — 反查"某 transaction 影响了哪些 fact"
  @@map("patient_facts")
}

// =============================================================
// Layer 2: Persona 画像 — 按患者维度版本化
// =============================================================

/// 患者画像(版本流)。每次重算产生新版本,旧版本保留并标 supersededAt。
/// 当前版本 = supersededAt IS NULL 的那条。
///
/// 不立 status enum — Persona 生命周期是纯线性(active → superseded),没有
/// cancelled / expired 等分支,supersededAt 就够。
///
/// 不存"某诊所视角下的评价"(assessment_scope 已拒绝,db-review-confirm §2.3)。
model Persona {
  id        String @id @default(uuid()) @db.Uuid
  hostId    String @map("host_id") @db.Uuid
  tenantId  String @map("tenant_id")
  patientId String @map("patient_id") @db.Uuid
  /// 该患者的 persona 单调递增版本号
  version   Int

  /// 触发本次重算的来源:txn:<transactionId> / manual:<userId> / scheduled:<cronId>
  source         String    @map("source")
  /// 计算完成时间
  computedAt     DateTime  @default(now()) @map("computed_at") @db.Timestamptz(3)
  /// 被新版本取代的时间;null 表示当前版本
  supersededAt   DateTime? @map("superseded_at") @db.Timestamptz(3)
  /// 就地刷新时刻(只有易变数值变 —— 天数/年龄/滑窗聚合 —— 时不升版本,原地改 feature 行)。
  /// null = 从未就地刷新过。UI 的"更新于"取 refreshedAt ?? computedAt;
  /// computedAt 保持"本版本创建时刻"不动 —— 覆盖率日报靠它反推历史快照,前移会算错昨日覆盖。
  refreshedAt    DateTime? @map("refreshed_at") @db.Timestamptz(3)
  /// 本次重算时已响应到的 transaction.event_seq。
  /// ⚠️ 单靠它判 stale 是错的(见 factWatermark)—— 保留为辅助水位。
  eventWatermark BigInt?   @map("event_watermark")
  /// 本次重算所见 max(patient_facts.updated_at) —— **画像真正的输入水位**。
  ///
  /// 画像读的是 patient_facts,但历史上只用 eventWatermark(transaction)判 stale。
  /// reparse 只重写 fact、不产新 transaction,于是 2026-07-21 那次
  /// 「amount_cents 应收→实付」reparse 之后的全量重算被水位闸 noop 掉 325,979 人(生产实测),
  /// 74% 存量画像的累计消费永久停在旧口径(中位高估 91%),还会污染 RFM 的 M 分位打分。
  /// null = 存量老版本(没记过 fact 水位)→ 判定视为 stale,下次必重算一遍补上。
  factWatermark  DateTime? @map("fact_watermark") @db.Timestamptz(3)
  /// **纯时间流逝**会让本版本改档的最近时刻(全体 extractor 的 nextBoundaryAt 取最小)。
  ///
  /// 两个水位只在「事实变了」时放行重算,但有一类变化不伴随任何事实变动 —— 时间自己走了:
  /// 急迫等级跨 30/90 天线、生命周期跨 180/540/730、RFM 的 R 跨 540/730/1095/1460、
  /// 年龄跨段、种植年龄禁忌满 19 岁解除、滑动窗口把最老记录挤出去。
  /// 靠"每天全量扫"等于把闸拆了;这里存确切到期时刻,夜扫只捞 <= now() 的那一小撮。
  /// null = 再等下去也不会变(如已过全部档界)。
  nextBoundaryAt DateTime? @map("next_boundary_at") @db.Timestamptz(3)

  host     Host             @relation(fields: [hostId], references: [id])
  /// Restrict:画像版本流是审计 + 召回合规依据,不随 patient 物删消失
  patient  Patient          @relation(fields: [patientId], references: [id], onDelete: Restrict)
  features PersonaFeature[]
  plans    FollowupPlan[]

  @@unique([patientId, version])
  @@index([hostId, tenantId])
  @@index([patientId, supersededAt])
  /// 夜扫「到期改档」分支:WHERE superseded_at IS NULL AND next_boundary_at <= now()。
  /// 到期的每天只有几百到几千条,索引扫比全表快几个量级。
  @@index([supersededAt, nextBoundaryAt])
  @@map("personas")
}

/// Persona 的特征条目。
/// 架构强制(PDF §6.4 / db-review 原则 4):每条特征 = 特征键 + 自然语言描述 + 证据引用 + 版本(随父 Persona)。
/// **不要把 description 拆成更细的字段** — 这是设计约束,不是疏漏。
/// Score 是排序/筛选的可选数值,不是描述的替代。
///
/// 候选 key(应用层 zod enum,@pac/types PersonaFeatureKey)— 13 起步 + 5 产品收集 = 18:
///   ── v1 起步(规则路径)──
///   value                       (患者价值 / LTV / VIP 等级)
///   treatment_chain_status      (治疗链状态:进行中 / 已闭环 / 有缺口)
///   recall_risk                 (流失/复发风险)
///   do_not_contact_status       (不打扰状态,合规硬约束)
///   ── v1 候选(规则路径)──
///   incomplete_treatment        (未完成治疗 / 治疗链缺口)
///   recommended_role            (推荐执行角色)
///   referral_relation           (转介绍关系)
///   doctor_relation             (医患关系,主治绑定)
///   ── W5+ 候选(agent 路径)──
///   communication_preference    (沟通偏好:微信 / 电话 / 拒接)
///   customer_experience_risk    (客户体验风险,投诉前兆)
///   treatment_intent            (治疗意向)
///   family_social_relation      (家庭/社交关系)
///   ── Phase 5+(无 touchpoint 数据源)──
///   engagement                  (触达活跃度)
///   ── 产品收集(回访中心信息字段.docx)──
///   patient_segment             (患者基础人群细分,如儿童/中年男/老年女)  ← 产品收集
///   complaint_history           (投诉历史,合规营销避雷)                   ← 产品收集
///   potential_treatment         (待治疗,AI 影像识别 / 病历推断)            ← 产品收集
///   cooperation_attitude        (配合态度:好说话 / 难沟通 / 拒接)         ← 产品收集
///   primary_contact_relation    (主要联系人身份:本人/配偶/父母/子女)      ← 产品收集
model PersonaFeature {
  id        String @id @default(uuid()) @db.Uuid
  personaId String @map("persona_id") @db.Uuid

  /// 特征键(如 "value" / "treatment_chain_status" / "engagement")
  key         String
  /// 自然语言描述,自包含。不要拆字段。
  description String @db.Text
  /// 可选数值评分,用于排序/筛选
  score       Float?

  /// 结构化 payload(可空)— 特征值里"需要被 scorer / UI 结构化消费"的明细。
  /// 跟 description(人读串)/ score(排序数)分工:这里放机器要 parse 的对象。
  /// 例:权益身份 { commercialInsurers:[...], lastCommercialInsuranceAt, monthsSinceLastCommercial }。
  /// 跟 evidence 区分:evidence = 为什么(证据溯源),data = 是什么(结构化特征值)。
  /// 形状由各 feature 应用层自管(zod);DB 不强约束(db-review §6 JSON 列纪律)。
  data Json?

  /// 证据引用:{ factIds, agentInvocationIds, ruleIds, promptVersion } —
  /// 任何 persona 都能反查到"为什么得出这个结论"。
  /// 引证 fact 即可,fact 自带 transactionIds 可两跳回溯到原始 transaction。
  /// 形态可演进(Phase 5+ 加 promptVersion 等),不立柱(db-review-confirm §4.1)
  evidence Json

  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)

  persona Persona @relation(fields: [personaId], references: [id], onDelete: Cascade)

  @@unique([personaId, key])
  @@index([key])
  @@map("persona_features")
}

/// Persona 重算 orchestration 日志。每次 PersonaService.recompute() 调用一条记录,
/// 不论成功失败都落,作为 harness 层的执行账本。跟 SyncLog(事实接入)平行,不混用。
///
/// 调用顺序:
///   1. 重算开始 → 写一条 status=running
///   2. 跑规则提取器(rule_count++);跑 agent 提取器 → 每次写一条 AgentInvocation(此时 linkedPersonaId 暂为 null)
///   3. 全部完成 → 写新 Persona + features → 回填 AgentInvocation.linkedPersonaId
///                + 更新本表 status=success/partial,producedPersonaId 指向新 Persona
///   4. 中途崩溃 → 仅更新本表 status=failed,errorMessage 落原因;Persona 不写出
///                AgentInvocation 仍保留(linkedPersonaId 永远 null,通过本表反查)
model PersonaRecomputeLog {
  id        String @id @default(uuid()) @db.Uuid
  hostId    String @map("host_id") @db.Uuid
  tenantId  String @map("tenant_id")
  patientId String @map("patient_id") @db.Uuid

  /// 触发来源:txn:<transactionId> / manual:<userId> / scheduled:<cronId>
  triggeredBy    String  @map("triggered_by")
  /// 重算时已响应到的 transaction.event_seq(成功时与产出 Persona.eventWatermark 一致)
  eventWatermark BigInt? @map("event_watermark")

  /// 状态:
  ///   running  — 进行中(用于发现卡死的任务)
  ///   success  — 全部 feature 成功
  ///   partial  — 部分 feature 失败但 Persona 已写出(降级路径生效)
  ///   failed   — 连 Persona 都没写出(规则 + 兜底全挂)
  status String

  /// 产出的 Persona id;failed 时为 null
  producedPersonaId String? @map("produced_persona_id") @db.Uuid

  /// 本次共产出多少 feature
  featureCount  Int @default(0) @map("feature_count")
  /// 由规则路径产出的 feature 数(harness 健康度指标)
  ruleCount     Int @default(0) @map("rule_count")
  /// 由 agent 路径成功产出的 feature 数
  agentCount    Int @default(0) @map("agent_count")
  /// agent 失败回退到规则的次数(harness 健康度指标 — 大幅升高意味着上游 LLM 出问题)
  fallbackCount Int @default(0) @map("fallback_count")

  /// 失败 / 部分失败时的错误信息(单条主因,详细堆栈进应用日志)
  errorMessage String? @map("error_message") @db.Text

  startedAt DateTime  @default(now()) @map("started_at") @db.Timestamptz(3)
  endedAt   DateTime? @map("ended_at") @db.Timestamptz(3)

  host Host @relation(fields: [hostId], references: [id])

  @@index([patientId, startedAt])
  @@index([hostId, tenantId])
  @@index([status])
  @@map("persona_recompute_logs")
}

// =============================================================
// Layer 3: Follow-up Plan 召回计划
// =============================================================

/// 召回计划(版本流)。整张表是**推断**层 — 每行都是 PAC 综合 fact / Persona / 规则 / agent 后的对外承诺。
///
/// `target_clinic_id` 是**业务字段**(创建锁定不更新,不是隔离字段):
///   - 非 null = 期望某诊所执行召回(归属)
///   - null   = 集团统一客服池(VIP 中心 / 中央营销 / 流失挽回等)
/// 执行地诊所由 plan_executions.executor_clinic_id 记录,跟 target 允许不一致(代办场景)。
///
/// 召回池主查询路径:
///   WHERE status='active' AND assignee_user_id IS NULL ORDER BY priority_score DESC
///   (按诊所过滤追加 `target_clinic_id IN (...) OR target_clinic_id IS NULL` 业务逻辑)
///
/// 单一活跃版本约束:同一 (patient, scenario) 同时只有一个 active / assigned 版本。
/// MIGRATION:partial UNIQUE `(platform, tenant, patient, scenario) WHERE status IN ('active','assigned')`。
///
/// 不立柱执行追踪聚合(contactAttempts / lastContactedAt):真理来源是 plan_executions,
/// 召回池查询用 SQL 子查询 / JOIN 现算。recycleAt 不是聚合,是 assignment 时锁定的 deadline 时间戳,保留立柱。
/// 召回计划 — **patient 级触达单元**(一个 patient × 任意时刻最多一条 active/assigned plan)。
///
/// **第一性原理**:plan = 一次物理触达决策(一通电话/wechat),粒度是 patient,不是 scenario。
/// 同一 patient 可能命中多个 scenario(根管未戴冠 + 影像复查 + 流失挽回),这些都是该 plan 的
/// "召回理由"(reasons[]),客服一通电话搞定所有 reason,熔断/频控按 patient 实施。
///
/// 字段拆分:
///   - 触达决策 / 状态机字段留 FollowupPlan(本表)
///   - scenario 维度 / 自身分数 / 理由文本 / 证据 fact 移到 plan_reasons 子表
///   - plan.priorityScore = MAX over plan_reasons.priority_score(冗余写入,客服列表排序专用,降读 JOIN)
model FollowupPlan {
  id        String  @id @default(uuid()) @db.Uuid
  hostId    String  @map("host_id") @db.Uuid
  tenantId  String  @map("tenant_id")
  patientId String  @map("patient_id") @db.Uuid
  /// 生成本计划用的 persona 版本。**可空是设计的两种工作模式,不只是过渡态**:
  ///   1) 有 Persona — Plan 引擎读 persona feature 加权(高质量)
  ///   2) 无 Persona — Plan 引擎走纯规则(基础质量,基于 fact 时间轴 + scenario 配置)
  /// 触发"无 Persona"模式的合理场景:简单场景(根管未戴冠 / 时间窗复查)纯规则直达 /
  /// 新患者刚注册 Persona 还没算出来 / Persona pipeline 临时挂了
  personaId String? @map("persona_id") @db.Uuid

  /// 期望执行召回的诊所(业务字段,Plan 创建时锁定不更新)。
  /// null = 集团统一客服池(VIP 中心 / 中央营销 / 流失挽回等)。
  /// 跟 plan_executions.executor_clinic_id 允许不一致(代办场景:A 归属 B 代办)
  targetClinicId String? @map("target_clinic_id")

  /// 该 patient 维度的版本号(每 supersede 新建一版)
  version Int

  /// 优先级分数 — **MAX over plan_reasons.priority_score**(冗余字段,客服列表排序专用)。
  /// 写时 plan_reasons.priority_score 变化要同步刷新此字段,避免读 JOIN 聚合。
  /// 聚合公式可演化(MAX → weighted_sum),应用层算好写入。
  priorityScore Float @map("priority_score")

  /// Plan 引擎建议:**本次召回业务目标**(自然语言,1-2 句话)
  /// 例:"邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长"
  /// 来源:`ScenarioHit.goal`(取 priority 最高的 hit 的 goal);UI 详情页 SuggestionCard "本次目标" 渲染
  goal String? @db.Text

  /// Plan 引擎建议:**触达时间**(若 fact 层有 planned review_plan 等医嘱事实,展示时事实优先,推断兜底)
  recommendedAt      DateTime? @map("recommended_at") @db.Timestamptz(3)
  /// Plan 引擎建议:**执行人角色**(staff / leader / doctor_assistant ...);
  /// 跟下方 `assigneeUserId`(实际指派给谁) 是两件事 — 建议 vs 落地
  recommendedRole    String?   @map("recommended_role")
  /// Plan 引擎建议:**触达渠道**(phone / wecom / sms ...)
  recommendedChannel String?   @map("recommended_channel")

  /// 触达频控 + 熔断 — **patient 级**(一通电话算一次,不分摊到各 reason)
  contactAttempts Int @default(0) @map("contact_attempts")

  /// 生命周期状态(5 个):
  ///   active     — 在召回池等待分配
  ///   assigned   — 已分配给某客服
  ///   completed  — 完成(成功转化或客服结束)
  ///   abandoned  — 客服放弃
  ///   superseded — 被新版本取代
  /// 注:回收(reclaim)是动作不是状态,执行后 status 从 assigned 改回 active 并清空 assignee
  status String

  /// 自动回收 deadline(assignment 时锁定 = assignedAt + PlanService.RECYCLE_TIMEOUT_HOURS,默认 24h)。
  /// 不是聚合,是 assignment 时确定的固定值;cron 扫 WHERE status='assigned' AND recycle_at < NOW()。
  /// ⚠️ 原注释写「= assignedAt + tenant.rules_config.recycleTimeoutHours」——
  ///    **PAC 全仓没有 Tenant 模型,也没有 rules_config**,那句话会持续误导后来人去找一个不存在的配置。
  /// ⚠️ 自动回收当前**未启用**(PAC_PLAN_AUTO_RECYCLE 未配)→ 本列写了没人读。
  ///    门诊经理分配的时效走的是另一条路(plan_assignments.expires_at),与本列互不干扰,别混用。
  recycleAt DateTime? @map("recycle_at") @db.Timestamptz(3)

  /// 召回冷静期 / 终态抑制窗 deadline(execution 回写时按 outcome 计算,见 EXECUTION_OUTCOME_META.suppressDays):
  ///   - 终态(completed/abandoned)+ now<snoozedUntil → plan 引擎 upsert 时**不重新生成**(防熔断/拒绝被重算复活)
  ///   - keep 类带 scheduledNextAt / declined_recent → 写入后 active plan 在到期前**不进召回池**(到点自动浮现)
  /// null = 无抑制(立即可召回 / 立即在池)。到期(now>=snoozedUntil)自愈。
  snoozedUntil DateTime? @map("snoozed_until") @db.Timestamptz(3)

  /// 实际指派给谁(宿主侧 user id,从 token 取);展示用 name 通过 token 字典查
  assigneeUserId String?   @map("assignee_user_id")
  /// 指派发生时间
  assignedAt     DateTime? @map("assigned_at") @db.Timestamptz(3)

  /// 召回反馈(plan 级,正交于执行)— 客服在详情页对"这条召回准不准"打的拇指 + 文字。
  /// 'up' = 召回准/有用;'down' = 不该召/有问题(配 recallFeedbackNote 文字说明)。
  /// 从 PlanExecution.recall_feedback 迁来:反馈是对 plan 本身的判断,不属于某次执行。
  /// 2 列即可:recallFeedback 立柱(聚合"哪些子场景被 👎"反推算法问题)+ note 文字(人读)。
  /// 不立 at/by(at 有 plan.updatedAt 兜底,by 价值有限 — 轻量反馈不做审计)。
  recallFeedback     String? @map("recall_feedback") // 'up' | 'down'
  recallFeedbackNote String? @map("recall_feedback_note") @db.Text

  // ── 分配归因(2026-08 门诊经理批次分配)——————————————————————
  // ⚠️ 六列全部可空、全部无默认值:这是迁移不重写 25 万行的前提(PG 11+ 纯元数据操作)。
  //   存量 NULL 即语义正确(= 上线前的自助认领单),**不回填**。

  /// 属于哪个分配批次。**null = 自助认领**,不是"未知"。
  /// ⚠️ 退回时**不清空** —— 它是退回率的分母:"这批分了 60 条,退回 5 条"要靠它算。
  /// ⚠️ 引擎升版本时**无条件继承**(与 carryAssignment 解耦,见 plan-engine 那里的长注释)
  assignmentId String? @map("assignment_id") @db.Uuid

  /// 本条单子的时效(建批次时从 plan_assignments.expires_at 继承,允许逐条覆盖)。
  /// ⚠️ 与上面的 recycle_at 是**两条互不干扰的路**:recycle_at 是自动回收(生产未启用),
  ///   本列是分配时效。别把两者混用,更别让一方去读另一方
  assignmentExpiresAt DateTime? @map("assignment_expires_at") @db.Timestamptz(3)

  /// 谁分的。现有 assignee_user_id 只记"分给谁",缺"谁分的" ——
  /// 没有它就分不出"主管派的"和"客服自己领的",而这正是分配功能要统计的第一件事
  assignedBy String? @map("assigned_by")

  /// 退回原因(见 ReleaseReason 枚举)。主表存**当前值**(就地覆盖),
  /// 全量历史在 plan_event_logs.reason(同一条 plan 被退三次的三个原因都要留下)。
  /// ⚠️⚠️ **≠ recall_feedback**(上面那列):
  ///   recall_feedback 回答「这条召回准不准」(客服对系统/算法的反馈)
  ///   release_reason  回答「我为什么不接这单」(客服对任务的处置)
  ///   两者可同时填,聚合时**绝不能混到一起**,否则两边统计一起废掉。
  /// ⛔ 本列**不带抑制窗**:退回是"换个人来做",不是"这人别召了"。
  ///   照抄 abandon_reasons 那套 suppressDays 会把患者静默压 30~90 天。
  releaseReason String? @map("release_reason")
  releaseNote   String? @map("release_note") @db.Text

  /// 这条是怎么落到该客服头上的,见 AssignStrategy 枚举(四值)。
  /// ⭐ **必须在分配当时就记,事后永远补不回来**。用途:反推"专属 vs 铺平的完成率差"。
  assignStrategy String? @map("assign_strategy")

  // ── 分配当时的决策快照(2026-08)——————————————————————————————
  // ⚠️⚠️ 这四列的存在理由只有一个:**它们记录的东西会被就地覆盖,事后无法重建**。
  //   不是为了现在用,是为了半年后回答"当初为什么选了这个人"。几十字节换一次
  //   不可逆的信息保全 —— 漏了不会报错,只会在需要它的那天发现整批数据不可用。
  // ⚠️ 新增任何一列都**必须同步加进 plan-engine 的「归因列无条件继承」集合**。
  //   漏了比不加更危险:引擎重出版本由数据变化触发 → 与患者活跃度相关 → 与完成率相关,
  //   缺失是**系统性偏向**的,你会拿到一列 70% 填充率的快照并以为它能用。

  /// 分配当时该患者的专属客服(宿主 user id)。
  /// ⭐ 有了它,assign_strategy 的四个值全部可推导(空→无专属 / ==assignee→dedicated /
  ///   !=assignee 且在岗→溢出 / 不在岗→离岗);反过来不成立。
  ///   来源 patients.preferences.dedicatedCs 是摄入时 upsert 覆盖的**当前值**,历史不留痕。
  dedicatedCsAtAssign String? @map("dedicated_cs_at_assign")

  /// 该专属客服**最近一次回访的时刻**(分配当时算出来的)。
  /// ⭐ 记**证据不是结论**:「在岗」判定是"近 N 月有回访"这个滚动窗口,
  ///   今天在岗的人半年后回查会变成离岗 —— 存 boolean 则**无法按分配当时的口径重算**,
  ///   而回访表还在被 reparse 反复重摄。存时间戳成本一样,信息量高一个量级。
  dedicatedCsLastVisitAt DateTime? @map("dedicated_cs_last_visit_at") @db.Timestamptz(3)

  /// 分配当时驱动排序的那个分。
  /// ⚠️ priority_score 会被引擎**就地改分、不升版本、不留痕**(reason 未变时的刷新路径),
  ///   且分值口径已被迁移改过一次 —— 半年后库里那个分不是当初做选择时的分。
  priorityScoreAtAssign Float? @map("priority_score_at_assign")

  /// 分配当时该 plan 头部 reason 的证据可信度档(0.5 / 0.8 / 1.0)。
  /// 它是 priority_score 里的 2× 乘子,在大格子里能压倒意愿度 ——
  /// 不单独留一份就分不清"按分选人"实际是不是在"按诊断来源选人"。
  sourceConfidenceAtAssign Float? @map("source_confidence_at_assign")

  /// 这条是怎么被选进批次的:rank(按排序键正常入选)/ explore(随机探索配额)。
  /// ⭐ 探索配额是整套系统里**唯一的因果抓手** —— 没有它,排序键好不好永远只能靠推断
  ///   (入选本身就与结果相关,观察数据无法解耦)。不记则配额白留。
  selectionMode String? @map("selection_mode")

  createdAt    DateTime  @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt    DateTime  @updatedAt @map("updated_at") @db.Timestamptz(3)
  /// 被新版本取代的时间;status='active'/'assigned' 等其他状态时为 null。
  /// 跟 patient_facts / personas 的版本流字段一致
  supersededAt DateTime? @map("superseded_at") @db.Timestamptz(3)

  host       Host            @relation(fields: [hostId], references: [id])
  /// Restrict:召回历史 / 合规审计依赖,不随 patient 物删消失
  patient    Patient         @relation(fields: [patientId], references: [id], onDelete: Restrict)
  persona    Persona?        @relation(fields: [personaId], references: [id])
  reasons    PlanReason[]
  scripts    PlanScript[]
  summaries  PlanSummary[]
  executions PlanExecution[]
  /// ⚠️ **必须显式写 onDelete: Restrict**。Prisma 对可选关系的默认行为是 `SetNull` ——
  ///   将来任何一次误删批次(数据修复 / 清测试数据)都会把 N 行的 assignment_id 静默置空,
  ///   **不报错**,而归因一旦置空就再也复原不了。批次是审计对象,本就不该能被删。
  assignment PlanAssignment? @relation(fields: [assignmentId], references: [id], onDelete: Restrict)

  @@unique([hostId, tenantId, patientId, version])
  @@index([hostId, tenantId])
  /// 按诊所过滤召回池(target 维度 — null 走集团池)
  @@index([hostId, tenantId, targetClinicId])
  @@index([assigneeUserId])
  /// 召回池主查询路径:status + assignee 过滤 + priorityScore 排序联合索引
  @@index([status, assigneeUserId, priorityScore])
  /// 批次跟踪主路径:「这批分了哪些、现在什么状态」
  @@index([assignmentId])
  /// MIGRATION:partial UNIQUE (host_id, tenant_id, patient_id) WHERE status IN ('active','assigned')
  /// — 一个 patient 同时只一条活动 plan。Scenario 维度去掉(从 v1 (patient_id, scenario) 改造而来)
  @@map("followup_plans")
}

/// PlanReason — 召回理由清单(子表)。一条 FollowupPlan 包含 N 条 reason(scenario 维度)。
///
/// 设计纪律(对应 db-review-confirm.md §1):
///   - plan 1:N reason — plan 是触达单元,reason 是命中理由
///   - 同 plan 内同 scenario 最多一条 reason(应用层 UNIQUE)
///   - reason 自身有 priority_score(子场景基线 + bonus 加权),plan.priority_score = MAX 聚合
///   - 子场景信息(#1 根管未戴冠 / #3 种植年度复查)藏在 `reason` 自然语言里,不显式建 sub_scenario 字段
model PlanReason {
  id     String @id @default(uuid()) @db.Uuid
  planId String @map("plan_id") @db.Uuid

  /// scenario key — PAC v1 两条主战场:
  ///   `treatment_aftercare_recall`  治疗链已完成患者的疗效保障召回(主战场)
  ///   `treatment_initiation_recall` 启治召回 — 漏治新链(W3+ 数据足够后启用)
  /// 同 patient 命中两个 scenario → 同 plan 内两条 reason(plan 1:N reason)。
  /// 应用层用 @pac/types PlanScenario enum 校验。后续扩展时在 enum 加新值即可。
  scenario String

  /// 子规则 key(scenario 内细分):missing_tooth / caries_no_filling / perio_no_srp / endo_no_rct…
  /// 同 plan 同 scenario 下不同 sub_key 各 1 行(每个独立临床缺口独立 evidence)。
  /// nullable 兼容历史:老 plan_reasons 是 scenario 级合并的(sub_key=null)
  subKey String? @map("sub_key")

  /// 该 reason 在本次 inclusion 的得分(子场景基线 + value/risk/recency bonus 加权)。
  /// plan.priorityScore = MAX over plan_reasons.priority_score(客服列表排序冗余字段)
  priorityScore Float @map("priority_score")

  /// 自然语言"为什么命中" — 子场景细节也在这里表达。
  /// 例:"#1 根管未戴冠 — 患者于 2026-03-15 完成 11 号根管,医嘱 30 天内冠修复,
  ///       至今 60 天未见 crown 治疗记录或冠修复预约。"
  reason String @db.Text

  /// 证据(W3 末改 JSON 统一,跟 PersonaFeature.evidence 对齐):
  ///   {
  ///     factIds: string[],               // 必填,patient_facts.id 数组
  ///     agentInvocationIds?: string[],   // LLM 抽取来源(留位,W5+ recommendation 用)
  ///     ruleIds?: string[]                // scenario 版本(留位)
  ///   }
  /// 旧 UUID[] @db.Uuid 已淘汰 — 单一 factIds 形态限制了多源融合表达;改 jsonb 一统。
  /// 反查"哪些 plan 因 X fact 命中"频率低 + plan_reasons 行级小,不再立 GIN(真要查可建 jsonb GIN)。
  /// 应用层 zod 校验形状(对齐 PersonaFeature.evidence 结构)。
  evidence Json @map("evidence")

  /// **v2.1 算法可解释性** — 6 因子优先级 breakdown + 规则版本号 + 抽取置信度。
  /// 详情页用本字段渲染"为什么 73 分":
  ///   {
  ///     priority: { clinicalBase: 60, timeWindowFactor: 0.85, main: 51,
  ///                 valueBonus: 15, likelihoodBonus: 5, urgencyBonus: 5,
  ///                 confidenceFactor: 0.9, raw: 68.85 },
  ///     ruleIds: ["initiation.missing_tooth-v2.1"],
  ///     signalConfidence: 0.92,
  ///     subKey: "missing_tooth"
  ///   }
  /// 应用层 zod 校验(`ScenarioHit.priorityBreakdown` 类型);ScenarioHit 没产 breakdown 时为 null。
  breakdown Json? @map("breakdown")

  /// **W3 末新增** 结构化召回信号(前端富文本渲染数据源)。
  /// DB 存原始 enum / canonical code,**不语义化**(中文 label 走前端字典翻译):
  ///   {
  ///     subKey: "missing_tooth",
  ///     triggers: [{ type: "diagnosis", code: "K08" }],   // 数组支持多源融合
  ///     toothPosition: "21" | null,
  ///     daysSince: 149,
  ///     expectedCategories: ["implant", "prosthodontic"]
  ///   }
  /// factId 走 evidence_fact_ids 立柱字段(GIN 索引),避免重复存。
  /// schema 由 @pac/types ReasonSignalsSchema 强校验。
  signals Json? @map("signals")

  /// 产品收集(回访中心信息字段.docx):reason 生命周期类型 — 决定何时自动失效。
  ///   one_shot   短效 — 成因消失即应 close(待治疗治了 / 余款付了 / 续治完成)
  ///   recurring  长效 — 周期性触发(年度复查 / 半年定期洁牙 / 牙周维护)
  /// 应用层 zod enum 校验
  lifecycle String @default("one_shot")

  /// 产品收集:reason 来源 — 区分算法推断 / 规则触发 / 人工干预。
  ///   algorithm  scenario engine 跑出来(默认)
  ///   rule       规则触发(复查时间到点等)
  ///   manual     客服手工添加(单条)
  ///   campaign   运营批量导入(同 batch)
  source String @default("algorithm")

  /// 产品收集:source=manual 时记客服 user_id;source=campaign 时记运营 user_id
  sourceActorId String? @map("source_actor_id")
  /// 产品收集:source=campaign 时记 batch id(同一批活动的所有 reason 共享)
  campaignId    String? @map("campaign_id")

  /// 产品收集:reason 关闭原因(closed → 不再展示给客服):
  ///   patient_treated     患者已治疗该项目
  ///   patient_refused     患者明确拒绝
  ///   external_treatment  患者已在外院治疗
  ///   merged              被合并到另一条 reason(同 plan 内)
  ///   superseded          被新版本 reason 取代
  ///   expired             时间窗过期
  ///   manual_closed       客服手工关闭
  closedReason String?   @map("closed_reason")
  closedAt     DateTime? @map("closed_at") @db.Timestamptz(3)

  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

  plan FollowupPlan @relation(fields: [planId], references: [id], onDelete: Cascade)

  /// 同 plan 内每个 (scenario, sub_key) 最多一条 reason
  /// (PG 默认 nullable 在 UNIQUE 里多个 null 算不同 — 兼容老数据 sub_key=null;新数据都填)
  @@unique([planId, scenario, subKey])
  /// 反查"哪些 plan 因 X scenario 命中"
  @@index([scenario])
  /// 产品收集:运营批量场景的查询(找该 batch 涉及哪些 plan)
  @@index([campaignId])
  /// MIGRATION:GIN (evidence_fact_ids) — 反查"某 fact 影响了哪些 plan"
  @@map("plan_reasons")
}

/// 客服打电话用的参考话术。每个 plan **一份完整话术**(开场 / 跟进 / 异议处理等子段在
/// content 内用 Markdown 分节)— 一次 agent 调用产出全部子段,保持风格一致 + 节省 token。
///
/// **必须 agent**(harness 总原则:话术是 LLM 本职,模板僵硬不实用),
/// agent 失败必须能降级到 scenario 默认模板(`source='template_fallback'`)。
/// PlanScript / PlanSummary 是 v1 起步就引入 agent 的两个产物。
///
/// 单一最新规则:`UNIQUE(planId)` 同 plan 只一条当前态。
/// Plan 重生成时新 plan 行带新 script 行,旧 plan superseded 后其 script 自然进入历史。
///
/// patient_id 不立柱:通过 plan_id 反查。
model PlanScript {
  id       String @id @default(uuid()) @db.Uuid
  hostId   String @map("host_id") @db.Uuid
  tenantId String @map("tenant_id")
  planId   String @map("plan_id") @db.Uuid

  /**
   * ⭐ 话术**渠道**:`phone`(电话,分段) | `wecom`(企微,一次性单块)。
   *
   * ⚠️ 原来是 `planId @unique`(一 plan 一条话术)。加渠道后改成 `@@unique([planId, channel])` ——
   *   同一个 plan 现在可以同时有电话稿和企微稿,两者**独立生成、独立状态机**。
   * ⚠️ 存量行默认 `phone`:上线前的话术都是电话稿,这不是"未知"。
   * ⛔ 别为企微另立一张表 —— 生成状态机(pending/ready/failed)、source、
   *   agentInvocationId、流式接口、聚合查询全都一样,只有正文形态不同。
   *   两张表意味着这套东西各维护一份,而它们一定会漂。
   */
  channel String @default("phone")

  /// 话术正文(Markdown,含 `## 开场` / `## 跟进` / `## 异议处理` 等子段);
  /// 应用层 zod 限 8000 字符兜底(子段合并后空间略大于单段)。
  /// pending 状态下为 null
  content String? @db.Text

  /// 生成状态(就绪可用与否):
  ///   pending — 已 enqueue agent 任务,正在跑或排队中(content=null)
  ///   ready   — content 已就绪可用(无论来自 agent 还是 template_fallback)
  ///   failed  — agent + 模板兜底都失败的罕见情况(content=null)
  status            String  @default("pending")
  /// 生成方式(content 是用什么方式产出的);status=ready/failed 时才有意义:
  ///   agent              — LLM 成功生成(agentInvocationId 非空)
  ///   template_fallback  — agent 失败,scenario 默认模板兜底
  ///   manual             — 运维 / 调试手填
  source            String?
  /// 关联到产出本话术的 agent 调用;source=agent 时非空,其他来源 null
  agentInvocationId String? @map("agent_invocation_id") @db.Uuid

  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

  host Host         @relation(fields: [hostId], references: [id])
  plan FollowupPlan @relation(fields: [planId], references: [id], onDelete: Cascade)

  @@unique([planId, channel])
  @@index([hostId, tenantId])
  @@map("plan_scripts")
}

/// 客服打电话前的参考材料(one_page / medical_record / treatment_chain),per-plan 维度细化。
/// **必须 agent**(harness 总原则:跨多源数据归纳成可读叙述是 LLM 本职),
/// agent 失败必须能降级到 scenario 默认模板。
///
/// 生成策略:**一次 agent 调用产出 3 个 type 的内容**(共享一份输入 + prompt,
/// 输出 JSON 含 3 段),写库时拆成 3 行存,共享同一个 agentInvocationId — 省 token 也保证语境一致。
///
/// 单一最新规则:`UNIQUE(planId, type)` 同 plan 同类摘要只一条当前态。
/// Plan 重生成时新 plan 行带新 summary 行,旧 plan superseded 后其 summary 自然进入历史。
///
/// patient_id 不立柱:通过 plan_id 反查。
model PlanSummary {
  id       String @id @default(uuid()) @db.Uuid
  hostId   String @map("host_id") @db.Uuid
  tenantId String @map("tenant_id")
  planId   String @map("plan_id") @db.Uuid

  /// 材料类型:one_page / medical_record / treatment_chain
  /// 不强枚举,应用层 zod 给 enum;将来可扩 risk_brief / appointment_history 等
  type    String
  /// 材料正文(可能含 Markdown / 段落 / 表格);应用层 zod 限 8000 字符兜底。
  /// pending 状态下为 null
  content String? @db.Text

  /// 生成状态(就绪可用与否):
  ///   pending — 已 enqueue agent 任务,正在跑或排队中(content=null)
  ///   ready   — content 已就绪可用
  ///   failed  — agent + 模板兜底都失败的罕见情况(content=null)
  status            String  @default("pending")
  /// 生成方式;status=ready/failed 时才有意义:
  ///   agent              — LLM 成功生成(agentInvocationId 非空)
  ///   template_fallback  — agent 失败,scenario 默认模板兜底
  ///   manual             — 运维 / 调试手填
  source            String?
  /// 关联到产出本摘要的 agent 调用;source=agent 时非空,其他来源 null
  agentInvocationId String? @map("agent_invocation_id") @db.Uuid

  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

  host Host         @relation(fields: [hostId], references: [id])
  plan FollowupPlan @relation(fields: [planId], references: [id], onDelete: Cascade)

  /// 同 plan 同 type 只一条当前态(upsert 替换);unique 索引同时覆盖查询路径
  @@unique([planId, type])
  @@index([hostId, tenantId])
  @@map("plan_summaries")
}

/// Plan 执行回写。客服打完电话 / 操作后,宿主把执行结果回写到这里。
/// `executor_clinic_id` 立柱业务字段(执行发生地),跟 followup_plans.target_clinic_id
/// (业务归属)允许不一致(代办场景:A 归属 B 代办)。
///
/// **v1 数据流单向**:本表只反馈到 Plan 层 — 驱动状态机(active → assigned →
/// completed / abandoned)、累积统计、触发熔断(达 maxContactAttempts → Plan 自动 abandoned)、
/// 下次重算的依据。**v1 不回写 patient_facts / patient_transactions**(架构债待定;
/// 将来若需要"PAC 自身触达进入事实层" 再讨论)。
///
/// 召回成功后宿主侧的新预约**不在本表立柱关联**:避免增加宿主回传契约;
/// 真要审计"打了电话 → 真开了预约",应用层按 patient + 时间窗 join patient_transactions
/// 里的 action=appointment_created 反查即可。
model PlanExecution {
  id               String @id @default(uuid()) @db.Uuid
  hostId           String @map("host_id") @db.Uuid
  tenantId         String @map("tenant_id")
  /// 实际执行客服所在诊所(执行发生地,必填)— 跟 followup_plans.target_clinic_id(业务归属)区分
  executorClinicId String @map("executor_clinic_id")
  planId           String @map("plan_id") @db.Uuid
  /// 操作人 user id(从 access token 取);展示用 name 通过 token 字典 / host /users:resolve 接口查
  operatorUserId   String @map("operator_user_id")

  /// 触达渠道:phone / wecom / sms / other
  channel String

  /// 通话结果(应用层 zod enum,@pac/types ExecutionOutcome)— 4 起步 + 8 产品收集 = 12:
  ///   ── 4 起步(W2 设计)──
  ///   abandoned          (已联系,客服放弃 → Plan abandoned)
  ///   no_answer          (未接通,未回复 → Plan 仍 active 等下次)
  ///   scheduled_next     (已联系,约定下次回访 → Plan 仍 assigned)
  ///   success_appointed  (已联系,成功转化为新预约 → Plan completed)
  ///   ── 产品收集(回访中心信息字段.docx)──
  ///   rescheduled        (已联系,改约)                                  ← 产品收集
  ///   considering        (已联系,考虑中,近期再跟进)                     ← 产品收集
  ///   declined_recent    (已联系,近期不考虑)                            ← 产品收集
  ///   needs_doctor       (已联系,需要找医生)                            ← 产品收集
  ///   external_treatment (已联系,已在外院治疗 → 关闭对应 reason)        ← 产品收集
  ///   refused            (已联系,明确拒绝,不需要治疗)                   ← 产品收集
  ///   pending_info       (已联系,需进一步确认信息后回复)                 ← 产品收集
  ///   sms_sent           (电话未接,已发短信跟进)                        ← 产品收集
  ///   proactive_sms      (主动短信沟通,未发起电话)                      ← 产品收集
  ///   marked_invalid     (标记为无效;历史值,新建表单已不展示)          ← 产品收集
  outcome String
  /// 本次电话纪要(客服手填的自由文本)
  notes   String? @db.Text

  /**
   * ⭐ 这次执行发生在**哪一批的在办期内**。null = 自助认领时做的,不属于任何批次。
   *
   * ── 为什么要立这一列 ──
   * 主管要的「本批成功几个 / 不成功几个 / 都因为什么」只能从 plan_executions 出,
   * 而这张表原本只有 `plan_id`。想靠 `followup_plans.assignment_id` 反查是**不行**的:
   * 那一列会被下一次分配覆盖(退回/到期回池后被别的批次挑走就改指了),
   * 与 plan_event_logs 立 assignment_id 是同一个坑、同一个理由。
   * ⚠️ 也**不能**用"时间窗"去套(某批 assign 之后、release 之前的执行)——
   *   那是启发式不是键,两批前后脚就糊了。
   *
   * ── 什么时候写 ──
   * ⭐ 判据是 `followup_plans.assignment_expires_at != null` ——
   *   那一列的自洽性就是「非空 ⟺ 有一次在办的分配」(退回/到期/撤销都会把它清空)。
   *   ⛔ **不能只看 plan.assignment_id 非空**:它在退回后**仍然留着**(那是退回率的分母),
   *   此时客服自己从池子里捞回来打的电话,会被算进一个早就结束的批次。
   */
  assignmentId String? @map("assignment_id") @db.Uuid

  /// 放弃原因(多选,见 AbandonReason 应用层 enum)—— **两种回访模式共用这一列**:
  ///   · 未配宿主回访 → PAC 自带通话结果表单(outcome='refused')
  ///   · 已配宿主回访 → 详情页「关闭机会」弹窗(outcome='abandoned',面向宿主的一套文案)
  /// PAC 不为「关闭」另立概念/列/状态,分叉只在展示层(ABANDON_REASON_META.shownIn)。
  /// 注:原配套的 `abandon_other`("其他"自由文本)已删 —— UI 是固定复选框、无文本输入,
  ///     它只可能取到字面量"其他",信息已被 abandonReasons 的 'other' 覆盖(2026-07)。
  abandonReasons String[] @map("abandon_reasons")

  /// 「机会识别不准确」的限定:客服勾了**哪几类推荐治疗**不准。
  /// 存画像 potential_treatment 的 code(implant / endo / perio…),**不存中文** ——
  /// 中文措辞两天内改过三轮(潜在种植→种植治疗→种植),存中文等于把当时的措辞烤进历史数据,
  /// 之后统计要 `WHERE label IN (三种写法)`。code 是稳定键。
  ///
  /// ⚠️ **本列只作统计 / 算法改进的输入,不参与抑制计算**(业务 2026-07-30 明确)。
  /// 抑制仍是信号级、按 plan 的全部 reason 一起压(见 plan-engine.fetchSnoozedSignalKeys):
  /// 客服选"只有种植不准"**不会**只放过根管那条。别看到这列就以为闸已经接上了。
  ///
  /// 只在 abandonReasons 含 'inaccurate' 时非空;其他原因(含 other)不收集这个 —— 两者不构成关联。
  inaccurateTreatments String[] @map("inaccurate_treatments")

  /// 当 outcome=scheduled_next 时,下次回访时间
  scheduledNextAt DateTime? @map("scheduled_next_at") @db.Timestamptz(3)

  /// 召回反馈已迁出执行表 → FollowupPlan.recall_feedback(反馈是对 plan 本身的判断,不属于某次执行)

  /// 记录提交时间 ≈ 通话结束时间。
  /// 通话开始时间 / 时长由设备通话系统记录,不在 PAC 范围;客服手填不准故 PAC 不立柱
  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)

  host Host         @relation(fields: [hostId], references: [id])
  plan FollowupPlan @relation(fields: [planId], references: [id], onDelete: Cascade)

  @@index([hostId, tenantId, executorClinicId])
  /// 按 plan / 客服 拉最近执行
  @@index([planId, createdAt])
  @@index([operatorUserId, createdAt])
  /// 批次成效报表:某批的执行结果分布(带 createdAt 是为了"每人取最近一次")
  @@index([assignmentId, planId, createdAt])
  @@map("plan_executions")
}

/// Plan 生成 orchestration 日志。每次 PlanEngineService.recomputeForPatient() 调用一条记录,
/// 不论成功失败都落,作为 harness 层的执行账本。跟 SyncLog(事实接入)/ PersonaRecomputeLog(画像重算)
/// 三表平行,各 orchestration 域一张账本,字段贴近自身语义。
///
/// 调用顺序:
///   1. 重算开始 → 写一条 status=running
///   2. 读 input Persona(可空 — 纯规则路径)
///   3. 跑 N 个 scenario(规则路径打分 / 决策);scenario 决定不为该患者生成 plan 时跳过(plansCreated 不递增)
///   4. 每个写出的 plan 附带:
///      - enqueue ScriptAgent / SummaryAgent → 写 AgentInvocation + upsert PlanScript / PlanSummary(assetsCreated++)
///      - agent 失败时降级到模板(fallbackCount++,asset.source='template_fallback')
///   5. 全部完成 → UPDATE 本表 status=success/partial,各 count 落终态
///   6. 中途崩溃 → UPDATE 本表 status=failed,errorMessage 落原因
model PlanGenerationLog {
  id        String @id @default(uuid()) @db.Uuid
  hostId    String @map("host_id") @db.Uuid
  tenantId  String @map("tenant_id")
  patientId String @map("patient_id") @db.Uuid

  /// 触发来源(v1 起步):
  ///   scheduled:<cronId>      — 每日 cron 全量扫
  ///   manual:<userId>         — admin / API 单 patient 触发
  /// (W5+ 加 event-driven:txn:<transactionId> / persona:<personaId>)
  triggeredBy    String  @map("triggered_by")
  /// 输入 Persona 版本(可空 — 无 Persona 也能跑,纯规则路径)
  inputPersonaId String? @map("input_persona_id") @db.Uuid

  /// 状态:
  ///   running  — 进行中(用于发现卡死任务)
  ///   success  — 全部成功(scenarios + assets 都按预期产出,fallbackCount=0)
  ///   partial  — 部分失败但有产出(plansCreated > 0,但有 agent 走了 fallback,或个别 scenario 出错)
  ///   failed   — 整体失败(plansCreated=0,主流程崩溃)
  status String

  /// 跑了几个 scenario(无论是否产出 plan)
  scenarioRunCount Int @default(0) @map("scenario_run_count")
  /// 本次实际写出多少 plan(scenario 决定不召回该患者时为 0)
  plansCreated     Int @default(0) @map("plans_created")
  /// 本次产出的 Plan 子资产数(PlanScript + PlanSummary 合计行数)
  assetsCreated    Int @default(0) @map("assets_created")

  /// harness 健康度指标:agent 路径成功调用次数
  agentCount    Int @default(0) @map("agent_count")
  /// harness 健康度指标:agent 失败回退到模板的次数(升高 = 上游 LLM 出问题的早期信号)
  fallbackCount Int @default(0) @map("fallback_count")

  /// 失败 / 部分失败的主因(详细堆栈进应用日志)
  errorMessage String? @map("error_message") @db.Text

  startedAt DateTime  @default(now()) @map("started_at") @db.Timestamptz(3)
  endedAt   DateTime? @map("ended_at") @db.Timestamptz(3)

  host Host @relation(fields: [hostId], references: [id])

  @@index([patientId, startedAt])
  @@index([hostId, tenantId])
  @@index([status])
  @@map("plan_generation_logs")
}

/// PlanEventLog — 召回工单**生命周期事件账本**(append-only,不更新不删除)。
///
/// 为什么必须单立一张表(而不是靠 PlanGenerationLog):
///   PlanGenerationLog 是**每次引擎跑批一行**的管道账本 —— 它连 plan_id 都没有,
///   只记"引擎跑了没有、产出几个"(生产 750 万行 / 24 万患者,约 80 万行/天)。
///   它回答不了"这个 plan 经历了什么"。
///   而 `followup_plans` 只存**当前**状态:归属、snooze、反馈都是就地覆盖,返池时
///   assignee_user_id / assigned_at 直接置 null —— 痕迹消失。2026-07 生产实测两天内
///   自动回收 32 单,这些"接手了但没继续"的样本(判断召回池准不准最有价值的那批)
///   全部无法追溯。
///
/// ⭐ 收录边界(**故意划死,防止变成垃圾桶**):
///   ✅ 收:人工动作,以及会被**就地覆盖、事后无法还原**的状态变更
///   ❌ 不收:引擎驱动的重算 / supersede —— 那是 80 万行/天量级,跟人工动作(约 30~100
///      条/天)差 4 个数量级,混进来会把人工动作彻底淹没,查询也会被拖垮。
///      引擎侧已有 PlanGenerationLog(跑批账本)+ followup_plans 的版本流(version /
///      superseded_at 天然保留历史),不需要在这里重复。
///   ❌ 不收:前端行为埋点(页面浏览 / 点击)—— 噪声大、可刷、且属于产品分析那一摊。
///      本表只记**服务端状态机确凿发生的事**,跟 SyncLog / PersonaRecomputeLog 同性质。
///
/// 支撑的统计口径:
///   · 客服处理过哪些患者 = 该客服有 claim 记录的 patient(**不受后续返池影响**)
///   · 接手后放弃 = claim 之后跟着 release/auto_release 且中间无 execution
///   · 认领时长分布 = heldSeconds(判断超时阈值定得合不合理的依据)
///   · 门诊经理分配上线后:event='claim'(自认领)与 'assign'(被指派)天然分开,
///     不必事后从 assignee 反推 —— 这是 followup_plans 那两列永远做不到的
model PlanEventLog {
  id        String @id @default(uuid()) @db.Uuid
  hostId    String @map("host_id") @db.Uuid
  tenantId  String @map("tenant_id")
  planId    String @map("plan_id") @db.Uuid
  /// 冗余 patientId:统计几乎都按患者聚合(plan 会 supersede 换 id,患者不变)
  patientId String @map("patient_id") @db.Uuid

  /// 事件类型(应用层 zod enum 校验)。新增事件在此登记,不要塞进 details:
  ///   ── 归属 ──
  ///   claim         自认领(actor == assignee)
  ///   assign        指派他人(actor != assignee)—— 门诊经理分配上线后启用
  ///   release       主动返池(leader 回收 / 客服自助交还)
  ///   auto_release  超时自动回收(RecycleSchedulerService;actor 为 null)
  ///   ── 反馈 ──
  ///   feedback      召回反馈 👍/👎(followup_plans.recall_feedback 是就地覆盖的,
  ///                 改判会丢历史;这里留全量,准确度统计才有分子)
  event String

  /// 归属类事件:变更后的归属人;release / auto_release 为 null(回到无人认领)
  assigneeUserId String? @map("assignee_user_id")
  /// 执行本次操作的人;auto_release 是系统行为 → null
  actorUserId    String? @map("actor_user_id")

  /// 归属持续秒数(仅 release / auto_release 有值 = 释放时刻 - assignedAt)。
  /// 必须在清空 assigned_at **之前**算好存下 —— 事后算不出来,这是本列存在的全部理由。
  heldSeconds Int? @map("held_seconds")

  /// 简短原因(auto_release 记 'timeout';feedback 记 'up' / 'down')
  reason String?

  /**
   * ⭐ 这条事件**发生在哪一批的执行过程中**。
   *
   * ── 为什么必须立柱(而不是塞 details Json)──
   * 本表对 details 的规矩是「只放**查询不按它过滤**的内容;要按它筛就该立柱」。
   * 批次报表要 `groupBy(assignment_id)` 出数 —— 按自己的规矩就得是列。
   *
   * ── 为什么必须有(而不是继续从 followup_plans.assignment_id 现算)──
   * 🔴 `followup_plans.assignment_id` **会被下一次分配覆盖**:一条单退回/到期回池后
   *   再被分进新批次,旧批次就"少了一个人"——planned/agents 静默缩水,**批次跑得越久
   *   (退回重分越多)缩得越厉害**,且不报任何错。与 supersededAt 那个坑同构。
   *   实测:c02e1b80 分过 9 条,重分后 `planned` 显示 **0**。
   * ⚠️ 曾想用「时间窗」从账本里圈出一批(created_at BETWEEN …)—— **那是启发式不是键**,
   *   同一主管连着分两批、两个主管同时分,就会糊在一起。归因指标不能这么算。
   *
   * ── 什么时候写、什么时候留 null ──
   *   assign(批次分配)        → 本批 id
   *   release(客服退回)       → 退的那一刻它归哪一批
   *   auto_release(到期/撤销) → 同上
   *   claim / view / feedback → **null**。自认领是从池子里自己捞的,
   *     ⛔ 绝不能拿 plan 上那个**陈旧的** assignment_id 顶上 —— 那会把"自己捞的"
   *     算成"某批分的",批次报表凭空多出人。
   *
   * ⚠️ 稀疏很正常:本表 heldSeconds / reason / assigneeUserId 也都只对部分事件有值。
   */
  assignmentId String? @map("assignment_id") @db.Uuid

  /// 事件专属细节(不立柱的部分,如 feedback 的文字说明)。
  /// ⚠️ 只放"查询不按它过滤"的内容;要按它筛就该立柱。
  details Json?

  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)

  host Host @relation(fields: [hostId], references: [id])

  /// 按 plan 拉事件时间线
  @@index([planId, createdAt])
  /// "这个客服处理过哪些患者" 主查询路径
  @@index([assigneeUserId, createdAt])
  /// 按患者聚合(跨 plan 版本)
  @@index([patientId, createdAt])
  /// 按租户 + 事件类型出报表
  @@index([hostId, tenantId, event, createdAt])
  /// 批次报表主查询:某批分了几条 / 退了几条 / 到期几条(event 一起进索引,聚合不回表)
  @@index([assignmentId, event])
  @@map("plan_event_logs")
}

/// PlanAssignment — **一次分配动作**(一个批次)。门诊经理在召回池选一批人、
/// 配一套打法,确认后落到 N 个客服名下;这张表存的是那"一次动作"本身。
///
/// ── 语义边界 ──
///   · 批次 ≠ 工单队列。分配是**批次运营**,从不追求把池子分完 ——
///     "主管扛不住全量"不是缺陷,设计上就不打算分全量。
///   · 一次分配**不跨诊所**:福利政策与效果归因都按诊所走。
///   · 归属结果落在 followup_plans(assignment_id 等六列),本表只存批次级信息。
///
/// ── 立柱纪律(与 plan_event_logs.details 同一条口径)──
/// 「只放**查询不按它过滤**的内容;要按它筛就该立柱。」故:
///   · 初筛条件(潜在治疗 / 温度)→ criteria JSON,**不立柱**
///     将来会有别的初筛策略,为当前这一种立两根柱子,第二种来了就得加列或让字段闲置。
///   · 福利 → attributes JSON,**不立柱**。福利只是附加属性中的一种,不是一等公民。
///   · 人数 / 客服数 → **不立柱**,从 followup_plans COUNT 出来。立柱等于埋一个会漂的冗余。
///
/// ⚠️ 已知且**已接受**的口径漂移:一条 plan 在批次 N 被分 → 退回 → 进批次 N+1,
///   assignment_id 就地翻成 N+1,批次 N 的 COUNT 会悄悄少 1。
///   产品已确认可接受(2026-08),不为此另立 plan_assignment_members 关联表 ——
///   与"主表存当前值、全量历史留在 plan_event_logs"的既有模式一致。
///   ⛔ 也不要退而求其次去 plan_event_logs.details 里塞 assignmentId 做离线校正:
///   那种用法(WHERE details->>'assignmentId'=X)按本仓自己的规矩就该立柱,
///   而立柱正是通用日志表要避免的业务化,且无索引 + 混着高频 view 事件 = 全表 jsonb 扫。
model PlanAssignment {
  id       String  @id @default(uuid()) @db.Uuid
  hostId   String  @map("host_id") @db.Uuid
  tenantId String  @map("tenant_id")
  /// 本批次面向的诊所。null = 集团统一客服池(与 followup_plans.target_clinic_id 同口径)
  clinicId String? @map("clinic_id")

  /// 发起分配的主管(宿主侧 user id)。PAC 不立 users 表 → TEXT,展示名由服务端解析后随 payload 下发
  createdBy String @map("created_by")

  /// ⭐ 幂等键 —— **服务端在生成确认单时铸造**,随确认单下发、确认时原样回传。
  /// 不能让前端/模型生成:挡不住 SSE 重连与模型重试,而"重复分配"这件事没有天然的
  /// 自然键可去重(同一主管同一秒对同一批人再点一次是合法操作)。
  /// 冲突时**不报错**,按 requestId 回查已有批次原样返回 —— 抛错会让模型以为失败、
  /// 换个参数再试一次,那才是真正的重复分配。
  requestId String @map("request_id")

  /// 初筛条件快照 { potentialTreatment, temperature, ... } + 收敛规则(orderBy/limit)。
  /// 快照而非引用:口径将来会变,批次要能解释"当时是按什么圈的"
  criteria Json

  /// 附加属性 { benefit?: { text } }。福利 v1 **不核销、不接宿主卡券**,
  /// 只是话术勾子 + 归因标签(先验证"带福利的批次转化是否更高")
  attributes Json?

  /// 批次时效(T11:不存在无限期批次,故 NOT NULL)。单条可用
  /// followup_plans.assignment_expires_at 覆盖
  expiresAt DateTime @map("expires_at") @db.Timestamptz(3)

  /// confirmed(已确认分配)| revoked(整批撤销)。
  /// ⚠️ 撤销功能本身推迟到第二刀,但三列现在就建齐 ——
  /// 否则那时要再取一次 followup_plans 的 ACCESS EXCLUSIVE
  status String @default("confirmed")

  revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
  revokedBy String?   @map("revoked_by")

  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

  host  Host           @relation(fields: [hostId], references: [id])
  plans FollowupPlan[]

  @@unique([hostId, tenantId, requestId])
  /// 主管看"我分的那些批"
  @@index([hostId, tenantId, clinicId, createdAt])
  @@index([createdBy, createdAt])
  @@map("plan_assignments")
}

// =============================================================
// 横切支撑 — Agent Pool / 同步账本
// =============================================================

/// AI agent 调用记录。**共享基础设施** — 每个 agent 调用都强制落一条,
/// 供合规审计 + 反查"这个输出哪来的"。对接的 AI 网关 API 形状对齐 Dify(/v1/workflows/run)。
///
/// 起步用途:PlanScript / PlanSummary 生成;扩展用途(Phase 5+):
/// Persona feature 描述生成 / 影像 AI / 数据清洗 / 排序辅助 / Plan 解释 — `kind` 不强枚举,开放扩展。
///
/// 跟三张 orchestration log 的关系:
///   - 被 PersonaRecomputeLog / PlanGenerationLog / SyncLog 调用
///   - **不立柱反向父链**,失败诊断走 linkedPatientId + kind + 时间窗反查
///   - linkedPersonaId / linkedPlanId 是上层 orchestration 完成后回填 — 跑一半失败时为 null
model AgentInvocation {
  id       String @id @default(uuid()) @db.Uuid
  hostId   String @map("host_id") @db.Uuid
  tenantId String @map("tenant_id")

  /// agent 大类(粗粒度,做 dashboard 分组用;细粒度看 callKey):
  ///   script           — 产出 PlanScript
  ///   summary          — 产出 PlanSummary
  ///   persona_feature  — 产出 PersonaFeature(Phase 5+)
  ///   judge            — LLM-as-judge,对其他 invocation 评分(parentInvocationId 指向被评)
  ///   ranking / plan_analysis — 将来
  kind String

  /// AI 调用标识(harness 核心维度,每个 prompt+schema 组合一个 key):
  ///   draft_plan_script / draft_plan_summary / score_willingness / judge_script ...
  /// 一个 plan 生成会触发多次 AI 调用,通过 workflowRunId 串起来
  callKey String @map("call_key")

  /// prompt 版本(harness 核心维度,改 prompt 必须 bump):
  /// 格式建议 "{callKey}@{date}-{letter}",如 "draft_plan_script@2026-05-17-a"
  /// audit trail 关键 — 回放"为什么这个输出是这样"
  promptVersion String @map("prompt_version")

  /// 模型 vendor(deepseek / qwen / anthropic / openai)
  modelProvider String @map("model_provider")
  /// 模型 id(deepseek-v4-pro / qwen-max-2026-04 等),跟 promptVersion 分开 —
  /// prompt 没改但模型升级也会让输出变,审计时两者都需要
  modelName     String @map("model_name")

  /// 一次业务行为(如"为 plan X 生成全部 AI 产物")的 run id,串起多次 AI 调用。
  /// 单次调用也填(自身作为 root),便于统一查询。
  workflowRunId      String  @map("workflow_run_id") @db.Uuid
  /// 父调用 id(judge 指向被评 invocation;子调用指向 orchestrator 调用)
  parentInvocationId String? @map("parent_invocation_id") @db.Uuid

  /// 输入指纹 sha256(callKey + promptVersion + inputSnapshot),做缓存命中查询
  inputHash String  @map("input_hash")
  /// 本次调用是否命中缓存(true 时不实际调 LLM,token=0 cost=0)
  cacheHit  Boolean @default(false) @map("cache_hit")

  /// 调用模式(harness 必备):
  ///   production  — 线上正常调用
  ///   golden_set  — 跑 golden set 回归
  ///   shadow      — 影子流量(新 prompt 双跑,不上前端)
  ///   manual      — 调试 / 重跑
  evalMode        String  @default("production") @map("eval_mode")
  /// 跑 golden set 时填,关联到 golden case
  goldenSetCaseId String? @map("golden_set_case_id")

  /// LLM-as-judge 评分(0-5);kind='judge' 时填,其他 invocation 被 judge 后回填
  judgeScore  Decimal? @map("judge_score") @db.Decimal(3, 2)
  /// judge 细项打分 {relevance:4, compliance:5, tone:4, actionability:3}
  judgeRubric Json?    @map("judge_rubric")

  /// W4 末:客服 UI thumbs up/down 反馈("本段是否好用")— 'up' / 'down' / null
  /// 跟 judgeScore 平行:judge 是 LLM 自动评,userFeedback 是真人客服评
  /// 同一 invocation 只保留一次反馈(再点覆盖);用于话术 / 摘要 prompt 迭代依据
  userFeedback   String?   @map("user_feedback")
  userFeedbackAt DateTime? @map("user_feedback_at") @db.Timestamptz(3)
  /// 反馈提交人(host 侧 userId,从 JWT.sub 来);便于审计 / 聚合分析
  userFeedbackBy String?   @map("user_feedback_by")

  /// 输入快照 — 存本次调用喂给模型的**字面输入**(AiCall 的 `input` 对象原样落库,
  /// 见 ai-call-runner.baseStart:`inputSnapshot: input`)。已是**解析后的值**
  /// (患者称呼 / 触发诊断的医生·日期 / EMR 原文 / scenario 上下文 / 模板变量),
  /// **不是 fact_id / version 的引用**。
  ///
  /// 证据链定位口径(刻意):证据链要回答"依据什么 + 能定位到问题",**不做按源头版本重建**。
  ///   - "依据什么" → 直接读本字段的字面输入(无需回源)
  ///   - "定位问题" → 配合 linkedPlanId / linkedPersonaId / promptVersion / modelName +
  ///                  output(Text) join 上下游,即可判断是输入值错 / prompt 错 / 模型幻觉
  /// 不存 factIdsAtCall / personaVersionAtCall 这类版本锚 —— 那是"可重建"标准,本系统按
  /// "可定位"标准收口(见 docs/algorithm 证据链讨论)。真要回查触发 fact,走 plan_reason.evidence。
  ///
  /// 运维侧需要保留期清理:成功调用 N 天后清 inputSnapshot 仅保留元数据;失败的保留更久供 debug
  inputSnapshot  Json    @map("input_snapshot")
  /// user prompt 原文(截断 8000);从 agentVersion 反查 prompt 仓库通常更可靠,本字段冗余兜底
  promptTemplate String? @map("prompt_template") @db.Text
  /// system prompt 原文(base-system + 注入的 skills 正文);一键审计/复盘完整 prompt 用,免去重跑 composeSystem
  systemPrompt   String? @map("system_prompt") @db.Text
  /// 结构化输出(JSON);跟 outputText 双轨,不同 agent 形态各取所需。
  /// **AI 解析结果(如影像 AI 分析)归这里,v1 不回写 patient_facts**(db-review-confirm §3.7)
  output         Json?
  /// 文本输出(自然语言部分);流式调用时增量更新(配合 updatedAt)
  outputText     String? @map("output_text") @db.Text

  /// 关联实体(松引用,无 FK)— orchestration 完成后回填,跑一半失败时为 null
  linkedPatientId String? @map("linked_patient_id") @db.Uuid
  linkedPersonaId String? @map("linked_persona_id") @db.Uuid
  linkedPlanId    String? @map("linked_plan_id") @db.Uuid

  /// 输入 Token 用量
  promptTokens      Int     @default(0) @map("prompt_tokens")
  /// 输出 Token 用量(含 reasoning;DeepSeek thinking 模式下 reasoning 占比可能很高)
  completionTokens  Int     @default(0) @map("completion_tokens")
  /// 总 Token 用量
  totalTokens       Int     @default(0) @map("total_tokens")
  /// 输入里命中 vendor prompt cache 的 token 数(DeepSeek prompt_cache_hit_tokens);
  /// (promptTokens - cachedInputTokens) 是 cache miss 部分,按全价计;
  /// 同 prompt 反复调用时这个值会上升,显著降成本
  cachedInputTokens Int     @default(0) @map("cached_input_tokens")
  /// 输出里 reasoning 部分(thinking 模式),completionTokens 已含,这里冗余落便于分析"实际给用户的 textTokens = completion - reasoning"
  reasoningTokens   Int     @default(0) @map("reasoning_tokens")
  /// 估算成本(人民币元,Decimal(12,6) 6 位小数足够单次调用精度)。
  /// 例:¥0.0034 → 0.003400。运维聚合方便:SELECT sum(cost_yuan) FROM ...
  /// 没用 Int 分是因为单次调用常 <0.01 分,Int 精度全归零看不出差异。
  costYuan          Decimal @default(0) @map("cost_yuan") @db.Decimal(12, 6)

  /// 首字延迟(ms)— 请求发起到第一个 token/partial 到达;仅流式有意义,非流式为 null。
  /// 排查慢/卡用(如推理模型思考期 TTFT 高 → 前端空转);对齐 Dify usage.time_to_first_token。
  ttftMs    Int? @map("ttft_ms")
  /// 生成总耗时(ms)— LLM 调用净时长(发起→结束);落库便于聚合分析(对齐 Dify latency)。
  latencyMs Int? @map("latency_ms")

  /// 状态(流式过程也归入 running,不单列):
  ///   running   — 进行中
  ///   succeeded — 完成
  ///   failed    — 失败
  ///   cached    — 命中缓存直接返回(cacheHit=true 时的状态)
  status       String
  /// 失败时的错误消息
  errorMessage String? @map("error_message") @db.Text

  startedAt DateTime  @default(now()) @map("started_at") @db.Timestamptz(3)
  endedAt   DateTime? @map("ended_at") @db.Timestamptz(3)
  /// 流式增量更新 outputText / status 转移时更新
  updatedAt DateTime  @updatedAt @map("updated_at") @db.Timestamptz(3)

  host Host @relation(fields: [hostId], references: [id])

  @@index([hostId, tenantId])
  @@index([kind])
  @@index([callKey, promptVersion, startedAt])
  @@index([workflowRunId])
  @@index([parentInvocationId])
  @@index([inputHash])
  @@index([evalMode, callKey])
  @@index([linkedPatientId])
  @@index([linkedPersonaId])
  @@index([linkedPlanId])
  /// 时间范围统计(token / cost / 失败率仪表盘)
  @@index([startedAt])
  @@map("agent_invocations")
}

/// 事实接入 orchestration 日志。同步引擎每次 pull / 接收 push 都落一条记录,
/// 用于追踪进度、定位失败、计量数据量。跟 PersonaRecomputeLog(画像重算)/ PlanGenerationLog(召回生成)
/// 平行,不混用 — 各 orchestration 域一张账本,字段贴近自身语义。
///
/// 故意只到 platform + tenant — 一次批量 pull 把整个 tenant 所有 clinic 的数据一并拉来,
/// run 级别没有 clinic 概念。落到 patient_transactions 的 clinic_id 是 per-record 业务字段。
///
/// 单患者 pull 例外:patient_id 立柱(可空),NULL = 批量 run,非 NULL = 单患者即时 pull。
/// 单患者 pull 路径**绝不写 cursor_after**(避免污染批量水位),靠 DB 唯一约束兜底重复。
model SyncLog {
  id        String  @id @default(uuid()) @db.Uuid
  /// 接入宿主(FK → platforms.id)
  hostId    String  @map("host_id") @db.Uuid
  tenantId  String  @map("tenant_id")
  /// 单患者 pull 时填写,批量 run 留 null。非 NULL 时 cursor_before/cursor_after 也应为 null。
  patientId String? @map("patient_id") @db.Uuid

  /// 同步方向:pull(PAC 拉)/ push(宿主推)。mock 数据走 pull 路径(adapter 内部造数据)
  direction String
  /// 同步资源(对齐 patient_transactions 的 subject_type 维度:
  /// patients / consultations / appointments / visit_registrations / encounters /
  /// charge_orders / payments / refunds / emrs / images)
  resource  String

  /// 触发来源(可选):
  ///   scheduled:<cronId>      — 定时调度
  ///   manual:<userId>         — 手工触发(后台 / debug)
  ///   push:<adapter>          — 宿主推过来的回调
  ///   reconcile:<YYYY-MM-DD>  — 第 4 层防线对账触发的全量重拉
  triggeredBy String? @map("triggered_by")

  /// 状态:
  ///   running — 进行中(用于发现卡死任务)
  ///   success — 全成功(failed = 0)
  ///   partial — 部分失败(failed > 0,但有进展,cursor 有推进)
  ///   failed  — 整体失败(连 cursor 都没推进)
  status       String
  /// 本次拉取前的游标(push 路径不适用,留 null)
  cursorBefore String? @map("cursor_before")
  /// 本次拉取后的游标(push 路径不适用,留 null)
  cursorAfter  String? @map("cursor_after")

  /// 入站记录数:pull 是"从宿主拉到的条数",push 是"本次接收到的条数"
  fetched             Int     @default(0)
  /// 写入 patient_transactions 的条数(去重后)
  transactionsWritten Int     @default(0) @map("transactions_written")
  /// 由 parser 衍生写入 patient_facts 的版本数(同一 transaction 可产 0/1/N 个 fact 变化)
  factsEmitted        Int     @default(0) @map("facts_emitted")
  /// 幂等去重命中数(source_event_id 已存在,跳过未入库)。
  /// 校验恒等式:fetched = transactionsWritten + duplicates + failed
  duplicates          Int     @default(0)
  /// 失败条数(zod 校验失败 / parser 异常 / 写库异常 → 转入 failed_transactions quarantine)
  failed              Int     @default(0)
  /// 失败时的错误信息(单条主因,详细堆栈进应用日志 + quarantine 表)
  errorMessage        String? @map("error_message") @db.Text

  startedAt DateTime  @default(now()) @map("started_at") @db.Timestamptz(3)
  endedAt   DateTime? @map("ended_at") @db.Timestamptz(3)

  /// cohort batch / assembler 进度 + 资源画像(JSONB,无强 schema)
  /// 形态见 packages/types schema(可选,目前用 unknown);典型字段:
  ///   { cohortTotal, cohortDone, lastAssembler, rssPeakMb, batchTimings: [...] }
  metadata Json?

  host         Host                 @relation(fields: [hostId], references: [id])
  transactions PatientTransaction[]

  @@index([hostId, tenantId, resource])
  @@index([startedAt])
  @@index([status])
  /// 单患者 pull 反查:"该患者最近被刷新过几次"
  @@index([patientId, startedAt])
  /// MIGRATION 20260528000000 + 20260703021623:partial UNIQUE (host_id)
  ///   WHERE status='running' AND direction='pull' — 同 host 同时只能有 1 个 running 的
  ///   **pull** 行(存量/增量 sync 单飞锁);push 批次不受此锁(并发由 PushController 信号量管)。
  ///   Prisma 不支持声明式 partial UNIQUE,migration.sql 是单一真理源。
  @@map("sync_logs")
}
