// 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 (platform_id, tenant_id, source_event_id) WHERE source_event_id IS NOT NULL
//   2) patient_facts:partial UNIQUE (platform_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 Platform {
  /// 主键 UUID — 所有子表 `platform_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 接入或不贡献数据)。
  ///
  /// 形状(应用层 zod 校验,key 跟 patient_transactions.subject_type 取值对齐):
  /// {
  ///   "endpoints": {
  ///     "patients":         "...",   // 主档,独立路径
  ///     "consultations":    "...",
  ///     "appointments":     "...",
  ///     "visit_registrations": "...",
  ///     "visit_receptions": "...",
  ///     "clinical_encounters": "...",
  ///     "emr_documents":    "...",
  ///     "charge_orders":    "...",
  ///     "payments":         "...",
  ///     "refunds":          "...",
  ///     "image_studies":    "..."
  ///     // 宿主能提供哪个 endpoint 就配哪个;未配的资源不拉
  ///   },
  ///   "credentials": {
  ///     // 二选一(支持两种鉴权方式)
  ///     "loginUrl": "...", "username": "...", "password": "<encrypted>",
  ///     "apiKey":   "<bearer-token>"
  ///   },
  ///   "amountUnit": "fen" | "yuan",   // 必填无默认 — 宿主金额数值的单位
  ///   "timezone":   "Asia/Shanghai",  // 必填无默认 — IANA 时区,解析无时区字符串时用
  ///   "intervalMinutes": 5
  /// }
  pullConfig     Json?   @map("pull_config")

  /// 接受该宿主 push 时验签用的 HMAC 共享密钥的哈希;null = 拒绝该宿主所有 push。
  pushSecretHash String? @map("push_secret_hash")

  /// 字段映射:左边是 PAC canonical 字段名,右边是该宿主返回的字段名(pull / push 共用)。
  /// 各宿主字段命名不同,无法纯文档约定 — 这是 platform 级配置真正必要的部分。
  ///
  /// 形状(应用层 zod 校验):
  /// {
  ///   // 患者主档(GET /patients 返回的字段)
  ///   "patient": {
  ///     "externalId": "pid", "phone": "mobile", "name": "real_name",
  ///     "gender": "sex", "birthDate": "dob"
  ///   },
  ///   // transaction 壳层(push 事件报文用;pull 路径 transaction 由 adapter 内部合成,跳过此映射)
  ///   "transaction": {
  ///     "sourceEventId": "event_id", "action": "verb",
  ///     "subjectType": "object_type", "subjectId": "object_id",
  ///     "occurredAt": "happened_at",
  ///     "clinicId": "clinic_code",
  ///     "sourceActorId": "operator_id", "sourceActorRole": "operator_role"
  ///   },
  ///   // 每种 subject_type 对象的字段映射(pull 拉资源 / push 事件 after_snapshot 都用)
  ///   // key 必须是 patient_transactions.subject_type 取值
  ///   "subjects": {
  ///     "appointment":        { "plannedFor": "scheduled_at", ... },
  ///     "charge_order":       { "amountCents": "fee", ... },
  ///     "clinical_encounter": { "doctorId": "doctor_code", ... }
  ///     // ...
  ///   }
  /// }
  /// 空对象 = host 字段名跟 PAC canonical 完全一致,无映射需要。
  fieldMapping   Json    @default("{}") @map("field_mapping")

  /// 动作跳转 URL 模板(VIEW_PATIENT、CREATE_APPOINTMENT 等)。
  /// 形状: { "VIEW_PATIENT": "https://.../patient/{patientId}", ... };null/缺失 = 前端不渲染该按钮
  actionUrls  Json     @default("{}") @map("action_urls")
  /// 软停用开关,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[]

  @@map("platforms")
}

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

/// 患者主档 — 身份对象。同一物理人在集团内任何诊所共享同一行。
///
/// 召回池硬过滤:WHERE status='active' AND do_not_contact=false AND deceased=false
model Patient {
  id          String @id @default(uuid()) @db.Uuid
  /// 接入宿主(FK → platforms.id)
  platformId  String @map("platform_id") @db.Uuid
  /// 宿主侧的客户/集团 id(来自换票 token;PAC 不维护 tenants 主数据)
  tenantId    String @map("tenant_id")
  /// 宿主侧患者 id,回链用
  externalId  String @map("external_id")

  /// 患者姓名
  name      String
  /// 联系电话
  phone     String
  /// 性别(自由文本 — 各宿主取值不一,不强枚举;业务用到时归一化)
  gender    String?
  /// 出生日期
  birthDate DateTime? @map("birth_date") @db.Date
  /// 偏好(沟通时间、语言、就诊偏好等)。形状自由,应用层 zod 校验:
  /// { contactWindow?: {start,end}, language?, preferredChannel?, ... }
  preferences Json?

  /// 不打扰名单(合规硬要求)。患者 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)

  /// 档案有效性(应用层 zod enum):
  ///   active     — 在召回池
  ///   archived   — 宿主软删除
  ///   merged     — 合并到 mergedIntoId 指向的主档
  status       String  @default("active")
  /// 当 status=merged 时指向合并目标(自引用)
  mergedIntoId String? @map("merged_into_id") @db.Uuid

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

  platform     Platform             @relation(fields: [platformId], references: [id])
  mergedInto   Patient?             @relation("PatientMerge", fields: [mergedIntoId], references: [id])
  mergedFrom   Patient[]            @relation("PatientMerge")
  transactions PatientTransaction[]
  facts        PatientFact[]
  personas     Persona[]
  plans        FollowupPlan[]

  /// 跨同步 upsert 去重:同一物理人在集团内共享一行
  @@unique([platformId, tenantId, externalId])
  @@index([platformId, tenantId])
  @@index([phone])
  /// 召回池热查询路径
  @@index([platformId, tenantId, status, doNotContact, deceased])
  @@map("patients")
}

/// 患者操作账本(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)
  platformId String  @map("platform_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,起步 14 个,见 db-review-confirm §3.8):
  /// consultation_created / appointment_created / appointment_changed / appointment_cancelled /
  /// patient_arrived / visit_registered / patient_received / doctor_encounter_started /
  /// charge_order_submitted / payment_received / refund_created /
  /// emr_submitted / emr_rejected / image_uploaded
  action String
  /// 被操作主体类型(应用层 zod enum,起步 10 个):
  /// consultation / appointment / visit_registration / visit_reception / clinical_encounter /
  /// emr_document / charge_order / payment / refund / image_study
  subjectType     String @map("subject_type")
  /// 宿主侧主体业务 ID(如 apt_001;同一 subject_id 可被多条 transaction 反复操作)
  subjectId       String @map("subject_id")

  /// 宿主原生 100% 原文(adapter 收到啥存啥)。
  /// 唯一事件原始证据;canonical 视图由 parser 落到 patient_facts.fact_content_json,
  /// before/after diff 由 fact 版本流(subject_id × version)推导
  rawPayload  Json   @map("raw_payload")
  /// payload 指纹(sha256);幂等兜底 + 完整性校验,也作为 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)

  platform Platform @relation(fields: [platformId], references: [id])
  patient  Patient? @relation(fields: [patientId], references: [id], onDelete: Cascade)
  syncLog  SyncLog? @relation(fields: [syncLogId], references: [id])

  /// 立柱业务字段联合索引(同时覆盖 [platformId, tenantId] 前缀查询)
  @@index([platformId, tenantId, clinicId])
  /// 按患者拉时间轴(parser / 审计反查路径)
  @@index([patientId, occurredAt])
  /// 按业务主体反查(同一 apt_001 被几次改过)
  @@index([subjectType, subjectId])
  /// 按动作类型分析 / 仪表盘
  @@index([action])
  /// 同步运维 / 监控
  @@index([receivedAt])
  /// 水位游标查询(persona 重算"自上次以来新增的 transaction")
  @@index([eventSeq])
  /// MIGRATION:partial UNIQUE (platform_id, tenant_id, source_event_id) WHERE source_event_id IS NOT NULL — 幂等键
  @@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)。
model PatientFact {
  id         String @id @default(uuid()) @db.Uuid
  platformId String @map("platform_id") @db.Uuid
  tenantId   String @map("tenant_id")
  patientId  String @map("patient_id") @db.Uuid

  /// **业务身份,跨版本稳定**(对应宿主侧实体 ID,如 apt_001 / enc_001 的语义身份)。
  /// 同一 subject 被多次变更时新版本沿用同一 subject_id,version 递增
  subjectId String @map("subject_id")
  /// `actual`(已发生事实) / `planned`(未来安排)
  kind      String
  /// 事实类型(应用层 zod enum,起步 actual 12 + planned 4,见 db-review-confirm §3.8)
  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)
  /// 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[] @db.Uuid @map("transaction_ids")

  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)

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

  /// 版本去重 — 同 subject 同 version 不能有两条
  @@unique([platformId, tenantId, subjectId, version])
  /// 隔离基础过滤
  @@index([platformId, tenantId])
  /// 拉某患者当前 active 视图(persona / plan 重算主路径)
  @@index([patientId, status])
  /// 按患者拉 actual 时间轴
  @@index([patientId, occurredAt])
  /// 按患者查 planned 列表(待办 / 即将复查)
  @@index([patientId, plannedFor])
  /// 复查 / 待办类查询("近期未完成的 planned + 时间窗过期"扫描)
  @@index([type, status, validUntil])
  /// MIGRATION:partial UNIQUE (platform_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
  platformId String @map("platform_id") @db.Uuid
  tenantId   String @map("tenant_id")
  patientId  String @map("patient_id") @db.Uuid
  /// 该患者的 persona 单调递增版本号
  version    Int

  /// 触发本次重算的来源:txn:<transactionId> / manual:<userId> / scheduled:<cronId>
  triggeredBy    String    @map("triggered_by")
  /// 计算完成时间
  computedAt     DateTime  @default(now()) @map("computed_at") @db.Timestamptz(3)
  /// 被新版本取代的时间;null 表示当前版本
  supersededAt   DateTime? @map("superseded_at") @db.Timestamptz(3)
  /// 本次重算时已响应到的 transaction.event_seq;判断 persona 是否 stale 用。
  /// Persona 实际读 patient_facts 当前 active 版本算 features;
  /// 水位是触发 / 判 stale 用的,不是消费 transaction
  eventWatermark BigInt?   @map("event_watermark")

  platform Platform         @relation(fields: [platformId], references: [id])
  patient  Patient          @relation(fields: [patientId], references: [id], onDelete: Cascade)
  features PersonaFeature[]
  plans    FollowupPlan[]

  @@unique([patientId, version])
  @@index([platformId, tenantId])
  @@index([patientId, supersededAt])
  @@map("personas")
}

/// Persona 的特征条目。
/// 架构强制(PDF §6.4 / db-review 原则 4):每条特征 = 特征键 + 自然语言描述 + 证据引用 + 版本(随父 Persona)。
/// **不要把 description 拆成更细的字段** — 这是设计约束,不是疏漏。
/// Score 是排序/筛选的可选数值,不是描述的替代。
///
/// v1 起步候选 key(应用层 zod enum,见 db-review-confirm §2.2):
///   value / treatment_chain_status / incomplete_treatment / recall_risk / engagement /
///   do_not_contact_status / recommended_role / referral_relation / doctor_relation /
///   communication_preference / customer_experience_risk / treatment_intent / family_social_relation
model PersonaFeature {
  id        String @id @default(uuid()) @db.Uuid
  personaId String @map("persona_id") @db.Uuid

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

  /// 证据引用:{ 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
  platformId String @map("platform_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)

  platform Platform @relation(fields: [platformId], references: [id])

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

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

/// 召回计划(版本流)。整张表是**推断**层 — 每行都是 PAC 综合 fact / Persona / 规则 / agent 后的对外承诺。
///
/// **二级隔离** + **业务字段 target_clinic_id**(db-review §21 / db-review-confirm §1.1):
///   - 隔离维度是 (platform, tenant, patient, scenario, version),跟 patient 一致
///   - 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 版本。
/// 通过 partial unique index `one_open_plan_per_scenario` 在 DB 层强制(见 prisma/sql/);
/// 应用层不要依赖"先 update 旧的再 create 新的"这种 race-prone 写法。
///
/// 不立柱执行追踪聚合(contactAttempts / lastContactedAt):真理来源是 plan_executions,
/// 召回池查询用 SQL 子查询/JOIN 现算。recycleAt 不是聚合,是 assignment 时锁定的 deadline 时间戳,保留立柱。
model FollowupPlan {
  id         String  @id @default(uuid()) @db.Uuid
  platformId String  @map("platform_id")
  tenantId   String  @map("tenant_id")
  patientId  String  @map("patient_id") @db.Uuid
  /// 生成本计划用的 persona 版本。**可空是设计的两种工作模式,不只是过渡态**:
  ///   1) 有 Persona — Plan 引擎读 persona feature 加权(高质量)
  ///   2) 无 Persona — Plan 引擎走纯规则(基础质量,基于 transaction / fact 时间轴 + scenario 配置)
  /// 触发"无 Persona"模式的合理场景:简单场景(根管未戴冠 / 时间窗复查)纯规则直达 / 新患者刚注册
  /// Persona 还没算出来 / Persona pipeline 临时挂了。PDF §5.1 "基于最新事实重新分析"的"必须"
  /// 是针对 fact(硬依赖),不是 Persona(可选增强,db-review §21.1 / three-layer-model 第二节)。
  personaId  String? @map("persona_id") @db.Uuid

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

  /// 业务场景(post_treatment_recall / 后续扩展...,不强枚举)
  scenario String
  /// 该 (patient, scenario) 维度的版本号
  version  Int

  /// 计划目标(自然语言,如"提醒已完成根管治疗的患者按时复查")
  goal               String?   @db.Text
  /// 优先级分数,召回池排序用
  priorityScore      Float     @map("priority_score")
  /// 该患者为什么进入本次召回 — agent 输出,人类可读
  inclusionReason    String    @map("inclusion_reason") @db.Text
  /// 推荐触达时间(PAC 算出来的;若 fact 层有 planned: review_plan 是医嘱事实,展示时事实优先,推断兜底)
  recommendedAt      DateTime? @map("recommended_at") @db.Timestamptz(3)
  /// 推荐执行人角色(staff / leader / doctor_assistant ...)
  recommendedRole    String?   @map("recommended_role")
  /// 推荐触达渠道(phone / wecom / sms ...)
  recommendedChannel String?   @map("recommended_channel")

  /// 证据引用:{ factIds, factTransactionIds, personaVersion, agentInvocationIds, ruleIds }
  evidence Json

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

  /// 自动回收 deadline(assignment 时锁定 = assignedAt + tenant.rules_config.recycleTimeoutHours)。
  /// 不是聚合,是 assignment 时确定的固定值;cron 每分钟扫 WHERE status='assigned' AND recycle_at < NOW()。
  recycleAt       DateTime? @map("recycle_at") @db.Timestamptz(3)

  /// 当前指派给谁
  assigneeUserId String?   @map("assignee_user_id")
  /// 指派时使用的角色快照(防止 user 离职 / 角色变更后丢失历史归属)
  assigneeRole   String?   @map("assignee_role")
  /// 指派发生时间
  assignedAt     DateTime? @map("assigned_at") @db.Timestamptz(3)
  /// 指派模式:manual(手动)/ auto(系统分配)
  assignmentMode String?   @map("assignment_mode")

  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)
  persona    Persona?        @relation(fields: [personaId], references: [id])
  scripts    PlanScript[]
  summaries  PlanSummary[]
  executions PlanExecution[]

  @@unique([platformId, tenantId, patientId, scenario, version])
  @@index([platformId, tenantId])
  /// 按诊所过滤召回池(target 维度 — null 走集团池)
  @@index([platformId, tenantId, targetClinicId])
  @@index([scenario, status])
  @@index([assigneeUserId])
  /// 召回池主查询路径:status + assignee 过滤 + priorityScore 排序联合索引
  @@index([status, assigneeUserId, priorityScore])
  /// MIGRATION:partial UNIQUE (platform_id, tenant_id, patient_id, scenario) WHERE status IN ('active','assigned') — 单一活跃版本
  @@map("followup_plans")
}

/// 客服打电话用的参考话术。每个 plan 下按 scriptType 各一份当前态。
///
/// **必须 agent**(harness 总原则:话术是 LLM 本职,模板僵硬不实用),
/// 但 agent 失败必须能降级到 scenario 默认模板(`source='template_fallback'`)。
///
/// 单一最新规则:`UNIQUE(planId, scriptType)` 保证同 plan 同类话术只一条当前态。
/// Plan 重生成时新 plan 行带新 script 行,旧 plan superseded 后其 script 自然进入历史。
///
/// **二级隔离** — 跟 plan 主体一致。patient_id 不立柱:通过 plan_id 反查。
model PlanScript {
  id                String   @id @default(uuid()) @db.Uuid
  platformId        String   @map("platform_id")
  tenantId          String   @map("tenant_id")
  planId            String   @map("plan_id") @db.Uuid
  /// 话术类型:opening(开场) / follow_up(跟进) / objection_handling(异议处理)
  /// 不强枚举,应用层 zod 给 enum;将来可扩 closing / scheduling_confirm 等
  scriptType        String   @map("script_type")
  /// 话术正文(可能含 Markdown / 段落);应用层 zod 限 4000 字符兜底
  content           String   @db.Text
  /// 来源(harness 总原则):
  ///   agent              — LLM 成功生成(agentInvocationId 非空)
  ///   template_fallback  — agent 失败 / 未启用,scenario 默认模板兜底
  ///   manual             — 运维 / 调试手填
  source            String   @default("agent")
  /// 关联到产出本话术的 agent 调用;source=agent 时非空,其他来源 null
  agentInvocationId String?  @map("agent_invocation_id") @db.Uuid
  createdAt         DateTime @default(now()) @map("created_at") @db.Timestamptz(3)

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

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

/// 客服打电话前的参考材料(One Page Summary / 病历摘要 / 治疗链概览)。
/// 跟 PlanScript 完全对偶 — 都是 Plan 输出物,**必须 agent**(harness 总原则:跨多源
/// 数据归纳成可读叙述是 LLM 本职),agent 失败必须能降级到 scenario 默认模板。
///
/// 单一最新规则:`UNIQUE(planId, summaryType)` 保证同 plan 同类摘要只一条当前态。
/// Plan 重生成时新 plan 行带新 summary 行,旧 plan superseded 后其 summary 自然进入历史。
///
/// per-plan(不 per-patient):One Page Summary 理论上是患者级信息,但 v1 选择 per-plan
/// 是为了避免 PatientSummary 跟 Persona 的 invalidation 逻辑重复;跨 plan 共享是 Phase 6
/// 之后的优化,届时若试点反馈"重复生成耗时"再做。
///
/// **二级隔离** — 跟 plan 主体一致。patient_id 不立柱:通过 plan_id 反查。
model PlanSummary {
  id                String   @id @default(uuid()) @db.Uuid
  platformId        String   @map("platform_id")
  tenantId          String   @map("tenant_id")
  planId            String   @map("plan_id") @db.Uuid
  /// 材料类型:one_page / medical_record / treatment_chain
  /// 不强枚举,应用层 zod 给 enum;将来可扩 risk_brief / appointment_history 等
  summaryType       String   @map("summary_type")
  /// 材料正文(可能含 Markdown / 段落 / 表格);应用层 zod 限 8000 字符兜底
  /// 比 PlanScript(4000)上限更高 — 病历摘要 / 治疗链概览可能含多段或表格
  content           String   @db.Text
  /// 来源(harness 总原则):
  ///   agent              — LLM 成功生成(agentInvocationId 非空)
  ///   template_fallback  — agent 失败 / 未启用,scenario 默认模板兜底
  ///   manual             — 运维 / 调试手填
  source            String   @default("agent")
  /// 关联到产出本摘要的 agent 调用;source=agent 时非空,其他来源 null
  agentInvocationId String?  @map("agent_invocation_id") @db.Uuid
  createdAt         DateTime @default(now()) @map("created_at") @db.Timestamptz(3)

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

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

/// 接口 2:Plan 执行回写。客服打完电话或操作后,宿主把结果写到这里。
///
/// **三级隔离**:`executor_clinic_id` 必填,**记录执行发生地**(实际执行客服所在诊所)。
/// 跟 followup_plans.target_clinic_id(业务归属)允许不一致 — 代办场景:A 归属 B 代办
/// (db-review §21 / db-review-confirm §1.1)。
///
/// **强约束:这条记录绝不会回写到 patient_facts / patient_transactions**(db-review §21.1):
///   - 主语是 PAC + 客服执行,不是患者侧事实
///   - 反馈到 Plan 层:驱动状态机(active → assigned → completed/abandoned)、累积统计、
///     触发熔断(达 maxContactAttempts → Plan 自动 abandoned)、下次重算的依据
///   - Persona engagement feature 应用层显式 join 本表消费(PAC 跟宿主回访模块互斥,无 touchpoint fact)
model PlanExecution {
  id             String @id @default(uuid()) @db.Uuid
  platformId     String @map("platform_id")
  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 取)
  operatorUserId String @map("operator_user_id")

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

  /// 通话结果(强枚举驱动 Plan 状态机,db-review-confirm §6.2):
  ///   abandoned         — 客服放弃 → Plan abandoned
  ///   no_answer         — 无应答 → Plan 仍 active 等下次
  ///   scheduled_next    — 约定下次回访 → Plan 仍 assigned
  ///   success_appointed — 成功转化为新预约 → Plan completed
  outcome String
  /// 本次电话纪要(客服手填的自由文本)
  notes   String? @db.Text

  /// 放弃原因(多选,见 AbandonReason 应用层 enum)
  abandonReasons String[] @map("abandon_reasons")
  /// 放弃原因 - "其他" 自由文本
  abandonOther   String?  @map("abandon_other")

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

  /// 当 outcome=success_appointed 时,关联宿主侧新建的预约 id(可空 — 客服可能没填)。
  /// 即使 ref 为空,outcome=success_appointed 时 Plan.status 仍立即 = completed,
  /// ref 后台 cron 会反向归因从新进的 appointment_created transaction 里启发式匹配回填
  /// (见 newAppointmentRefSource)。
  newAppointmentRef       String? @map("new_appointment_ref")
  /// ref 来源声明:
  ///   manual         — 客服提交 PlanExecution 时主动填的(置信度最高)
  ///   pull_inferred  — 后台 cron 在新进 appointment transaction 里反向归因匹配上的(置信度中等)
  ///   null           — 客服没填且 cron 也没匹配上
  /// 看 refSource 就知道数据可信度梯度,evidence 链清晰。
  newAppointmentRefSource String? @map("new_appointment_ref_source")

  /// 通话/操作开始时间
  startedAt DateTime  @default(now()) @map("started_at") @db.Timestamptz(3)
  /// 通话/操作结束时间
  endedAt   DateTime? @map("ended_at") @db.Timestamptz(3)
  createdAt DateTime  @default(now()) @map("created_at") @db.Timestamptz(3)

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

  @@index([platformId, tenantId, executorClinicId])
  /// 按 plan / 客服 拉最近执行
  @@index([planId, startedAt])
  @@index([operatorUserId, startedAt])
  /// 反向归因 cron 用 — 找"success_appointed 但 ref 为空"的近期记录
  @@index([outcome, newAppointmentRef, startedAt])
  @@map("plan_executions")
}

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

  /// 触发来源:fact:<transactionId> / persona:<personaId> / manual:<userId> / scheduled:<cronId>
  triggeredBy    String  @map("triggered_by")
  /// 输入 Persona 版本(可空 — 无 Persona 也能跑,纯规则路径)
  inputPersonaId String? @map("input_persona_id") @db.Uuid

  /// 状态:
  ///   running  — 进行中(用于发现卡死任务)
  ///   success  — 全部成功(scenarios + scripts + summaries 都按预期产出,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")
  /// 本次写出多少 PlanScript(每个 plan × 不同 scriptType)
  scriptsCreated   Int @default(0) @map("scripts_created")
  /// 本次写出多少 PlanSummary(每个 plan × 不同 summaryType)
  summariesCreated Int @default(0) @map("summaries_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)

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

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

/// AI agent 调用记录。**共享基础设施** — 每个 agent 调用都强制落一条,
/// 供合规审计 + 反查"这个输出哪来的"。对接的 AI 网关 API 形状对齐 Dify(/v1/workflows/run)。
///
/// **二级隔离** — 共享调用账本,不立 clinic_id(db-review 原则 1)。
///
/// 当前明确用途:Plan 话术 / Plan 摘要;**不排除**其他环节(Persona feature 描述 / 影像 AI /
/// 数据清洗 / 排序辅助 / Plan 解释)按需扩展 — schema 保持开放(`agentKind` 不强枚举)。
///
/// 跟三张 orchestration log 的关系:
///   - 被 PersonaRecomputeLog / PlanGenerationLog / 将来的 SyncLog(数据清洗 agent)调用
///   - **不立柱反向父链**,失败诊断走 linkedPatientId + agentKind + 时间窗反查
///   - linkedPersonaId / linkedPlanId 是上层 orchestration 完成后回填的 — 跑一半失败时这俩为 null
model AgentInvocation {
  id         String @id @default(uuid()) @db.Uuid
  platformId String @map("platform_id")
  tenantId   String @map("tenant_id")

  /// agent 类型(开放字符串,应用层用 AgentKind enum 给已知值,见 db-review-confirm §6.6):
  ///   SCRIPT           — 产出 PlanScript(一次多 scriptType)
  ///   SUMMARY          — 产出 PlanSummary(一次多 summaryType)
  ///   PERSONA_FEATURE  — 产出 PersonaFeature(双轨设计,Phase 5+ 接入)
  ///   RANKING          — (将来)排序辅助
  ///   PLAN_ANALYSIS    — (将来)Plan 解释
  /// 旧值 MEDICAL_SUMMARY / one_page 已 deprecated,改用 SUMMARY + summaryType 区分。
  agentKind    String  @map("agent_kind")
  /// prompt 模板版本 / Dify workflow 版本(audit trail 关键 — 回放"为什么这个输出是这样")。
  /// 应用层 best effort 填,从 workflow 配置或环境变量读取。
  agentVersion String? @map("agent_version")
  /// 模型名(gpt-4o / claude-3.5-sonnet / qwen-max-2024-09 等),从网关 metadata 提取。
  /// 跟 agentVersion 分开 — agentVersion 是 prompt 侧版本,modelName 是 LLM 侧版本,
  /// 审计时两者都需要(prompt 没改但模型升级也会让输出变)。
  modelName    String? @map("model_name")

  /// 网关后端实际是谁(dify / ai-gateway-passthrough / ...)
  gatewayProvider String  @map("gateway_provider")
  /// Dify workflow id 或网关侧 workflow 标识
  workflowId      String? @map("workflow_id")

  /// 完整输入快照,用于回放 / 审计(此处是严格的点位副本,字面意义的 snapshot)。
  /// 临时借用宿主 EMR 原文(医疗影像 / 病历文本)落这里,受保留期管控,不持久化到其他表
  inputSnapshot  Json    @map("input_snapshot")
  /// prompt 模板原文(若有);从 agentVersion 反查 prompt 仓库通常更可靠,本字段作为冗余兜底
  promptTemplate String? @map("prompt_template") @db.Text
  /// 结构化输出(JSON);跟 outputText 双轨,不同 agent 形态各取所需。
  /// **AI 解析结果(如影像 AI 分析)归这里,不回写 patient_facts**(db-review-confirm §3.7 / §5.1)
  output         Json?
  /// 文本输出(自然语言部分);流式调用时增量更新
  outputText     String? @map("output_text") @db.Text

  /// 关联实体 — 这个调用是为哪个患者/画像/计划做的?(orchestration 完成后回填)
  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 用量
  completionTokens Int @default(0) @map("completion_tokens")
  /// 总 Token 用量(网关给的可能跟 prompt+completion 不完全等,如 reasoning tokens)
  totalTokens      Int @default(0) @map("total_tokens")
  /// 估算成本(分,1 分 = 0.01 元;同总原则 金额 Int 分)
  costCents        Int @default(0) @map("cost_cents")

  /// 状态(3 个,流式过程不单列):
  ///   running   — 进行中
  ///   succeeded — 完成
  ///   failed    — 失败
  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)

  @@index([platformId, tenantId])
  @@index([agentKind])
  @@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)
  platformId String  @map("platform_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)

  platform     Platform             @relation(fields: [platformId], references: [id])
  transactions PatientTransaction[]

  @@index([platformId, tenantId, resource])
  @@index([startedAt])
  @@index([status])
  /// 单患者 pull 反查:"该患者最近被刷新过几次"
  @@index([patientId, startedAt])
  @@map("sync_logs")
}
