// PAC — Patient Analyze Center
// 三层模型:Patient Facts(事实)→ Persona(画像)→ Follow-up Plan(计划),
// 加上 Agent Pool 调用审计、Plan 执行回写。
//
// 多租户隔离:每张业务表都带 platform_id + tenant_id + clinic_id 三级,联合索引,
// 查询层强制按这三个 ID 过滤。
//   platform_id = 哪个宿主接入了我们(friday / 5i5y / dw / mock)
//   tenant_id   = 宿主自己的客户/组织 id
//   clinic_id   = 该租户下的某家诊所

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 会按计划去拉这个平台
///   - pushSecretHash 不为 null → PAC 接受用该 HMAC 密钥签名的 push
///   - 两者都为 null → 该平台不贡献数据(但仍可换票消费 API Pool 2)
///   - 两者都填 → 同时支持
model Platform {
  /// 主键 UUID
  id            String @id @default(uuid()) @db.Uuid
  /// 平台标识(friday / 5i5y / dw / demo),业务可读
  platformId    String @unique @map("platform_id")
  /// 平台中文名,用于后台展示
  name          String
  /// 宿主调换票接口用的 appId
  appId         String @unique @map("app_id")
  /// appSecret 的 scrypt 哈希(salt:hash 格式),原文从不落库
  appSecretHash String @map("app_secret_hash")

  /// PAC 拉取该平台所需的配置;null = PAC 不主动拉这个平台。
  /// 形状: { loginUrl?, credentials?{username,password}, apiKey?, endpoints{patients?,...}, intervalMinutes? }
  pullConfig     Json?   @map("pull_config")

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

  /// 字段映射:左边是 PAC 内部字段名,右边是该宿主返回的字段名。
  /// 形状: { patient: { phone: "mobile", externalId: "pid" }, diagnosis: { ... } }
  /// 这是 platform-level 配置真正必要的部分 — 各宿主字段命名不同无法纯文档约定。
  fieldMapping   Json    @default("{}") @map("field_mapping")

  /// 召回规则配置(漏诊阈值、回收超时、优先级权重等)
  rulesConfig Json     @default("{}") @map("rules_config")
  /// 动作跳转 URL 模板(VIEW_PATIENT、CREATE_APPOINTMENT 等)
  actionUrls  Json     @default("{}") @map("action_urls")
  /// 是否启用,false 时换票直接 401
  active      Boolean  @default(true)
  createdAt   DateTime @default(now()) @map("created_at")
  updatedAt   DateTime @updatedAt @map("updated_at")

  @@map("platforms")
}

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

/// 患者档案。来自宿主系统,本地冗余存储以便业务计算不依赖宿主实时接口。
/// (platform_id, tenant_id, external_id) 唯一,external_id = 宿主系统里的患者 id。
///
/// 召回池查询第一行硬过滤:
///   WHERE status='active' AND do_not_contact=false AND deceased=false
model Patient {
  id          String    @id @default(uuid()) @db.Uuid
  platformId  String    @map("platform_id")
  tenantId    String    @map("tenant_id")
  clinicId    String    @map("clinic_id")
  /// 宿主系统里的患者 id,用来回链
  externalId  String    @map("external_id")
  /// 患者姓名
  name        String
  /// 联系电话
  phone       String
  /// 性别(自由文本,各宿主取值不一致,不强枚举)
  gender      String?
  /// 出生日期
  birthDate   DateTime? @map("birth_date") @db.Date
  /// 偏好(沟通时间、语言、就诊偏好等),自由 JSON
  preferences Json?

  /// 不打扰名单。患者主动 opt-out / 投诉过 / 主动要求停止回访等情况置 true。
  /// 一旦 true 永久从召回池排除(合规硬要求)。
  doNotContact       Boolean   @default(false) @map("do_not_contact")
  /// 不打扰原因(complaint / opt_out / treated_elsewhere / ...),用于分析与回看
  doNotContactReason String?   @map("do_not_contact_reason")
  /// 何时打的标,审计 + 看是否过冷静期
  doNotContactAt     DateTime? @map("do_not_contact_at")

  /// 已故标记。不能给已故患者发回访,医疗 SaaS 必须支持。
  deceased    Boolean   @default(false)
  deceasedAt  DateTime? @map("deceased_at")

  /// 档案有效性。active 才进召回池;archived 表示宿主软删除;merged 表示合并到了 mergedIntoId。
  status       String   @default("active")
  mergedIntoId String?  @map("merged_into_id") @db.Uuid

  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")

  factEvents   PatientFactEvent[]
  diagnoses    Diagnosis[]
  treatments   Treatment[]
  appointments Appointment[]
  charges      Charge[]
  touchpoints  Touchpoint[]
  personas     Persona[]
  plans        FollowupPlan[]

  @@unique([platformId, tenantId, externalId])
  @@index([platformId, tenantId, clinicId])
  @@index([phone])
  /// 召回池热查询路径:租户 + 诊所 + 三个状态硬过滤
  @@index([platformId, tenantId, clinicId, status, doNotContact, deceased])
  @@map("patients")
}

/// 患者事实时间轴(append-only 日志)。所有"发生了什么"的真理来源 —
/// 镜像表(Diagnosis / Treatment / Appointment / Charge / Touchpoint)是它的投影。
/// **接口 2 的 PlanExecution 绝不写到这里**(架构 §5.3)。
model PatientFactEvent {
  id         String @id @default(uuid()) @db.Uuid
  platformId String @map("platform_id")
  tenantId   String @map("tenant_id")
  clinicId   String @map("clinic_id")
  patientId  String @map("patient_id") @db.Uuid

  /// 事实类型,canonical 值见 FactType 枚举(允许宿主扩展)
  factType    String  @map("fact_type")
  /// 事件进 PAC 的方向: pull / push / manual
  source      String
  /// 关联到**该事件对应的宿主侧具体记录 id**(治疗 id / 诊断 id / 预约 id / 缴费 id 等);
  /// `patient_registered` 这种患者级事件填 patient 的 external_id。可空(manual / mock 可不填)
  externalRef String? @map("external_ref")

  /// 事实负载(无 schema,按 fact_type 在应用层用 zod 校验)。
  /// **不要在 payload 里重复存 PII**(姓名/电话已在 patients 主档),只存该次事件特有内容。
  payload Json

  /// 事实在宿主侧实际发生时间(由 adapter 从宿主返回的时间字段提取)
  occurredAt DateTime @map("occurred_at")
  /// PAC 接收到事实的时间(同步监控用)
  ingestedAt DateTime @default(now()) @map("ingested_at")

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

  /// 幂等性约束:同 (platform, tenant, factType, externalRef) 只允许一条;
  /// externalRef 为 NULL 时 PostgreSQL 视为不同(允许 manual / mock 重复入)
  @@unique([platformId, tenantId, factType, externalRef])
  @@index([platformId, tenantId, clinicId])
  @@index([patientId, occurredAt])
  @@index([factType])
  @@index([ingestedAt])
  @@map("patient_fact_events")
}

/// 诊断记录镜像。从 fact 时间轴投影出来,便于快查。
model Diagnosis {
  id            String   @id @default(uuid()) @db.Uuid
  platformId    String   @map("platform_id")
  tenantId      String   @map("tenant_id")
  clinicId      String   @map("clinic_id")
  /// 宿主系统里的诊断记录 id
  externalId    String   @map("external_id")
  patientId     String   @map("patient_id") @db.Uuid
  /// 主诊医生 id(宿主侧 id,可空 — 部分宿主无独立 doctor 主数据)
  doctorId      String?  @map("doctor_id")
  /// 主诊医生姓名(展示用,doctorId / doctorName 至少有一个)
  doctorName    String?  @map("doctor_name")
  /// 诊断编码(ICD / 宿主自定义)
  diagnosisCode String?  @map("diagnosis_code")
  /// 诊断名称(自由文本)
  diagnosisName String   @map("diagnosis_name")
  /// 诊断时间
  diagnosedAt   DateTime @map("diagnosed_at")
  /// 备注
  notes         String?
  createdAt     DateTime @default(now()) @map("created_at")
  updatedAt     DateTime @updatedAt @map("updated_at")

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

  @@unique([platformId, tenantId, externalId])
  @@index([platformId, tenantId, clinicId])
  @@index([patientId])
  @@index([diagnosedAt])
  @@map("diagnoses")
}

/// 治疗记录镜像。Plan 召回引擎扣减"已完成治疗"的依据。
model Treatment {
  id            String    @id @default(uuid()) @db.Uuid
  platformId    String    @map("platform_id")
  tenantId      String    @map("tenant_id")
  clinicId      String    @map("clinic_id")
  /// 宿主系统里的治疗记录 id
  externalId    String    @map("external_id")
  patientId     String    @map("patient_id") @db.Uuid
  /// 关联诊断 id(同库内 UUID)
  diagnosisId   String?   @map("diagnosis_id") @db.Uuid
  /// 主治医生 id(宿主侧 id,可空)
  doctorId      String?   @map("doctor_id")
  /// 主治医生姓名(展示用,doctorId / doctorName 至少有一个)
  doctorName    String?   @map("doctor_name")
  /// 治疗编码(宿主自定义 / 行业编码,可空 — 跟 Diagnosis.diagnosisCode 对齐)
  treatmentCode String?   @map("treatment_code")
  /// 治疗项目名(根管治疗 / 种植 / 正畸 ...)
  treatmentName String    @map("treatment_name")
  /// 治疗开始时间(事实,可空 — "进行中超 N 天" 类查询用)
  startedAt     DateTime? @map("started_at")
  /// 完成时间(null = 未完成)
  completedAt   DateTime? @map("completed_at")
  /// 医生医嘱里的下次复查时间(事实,可空 — 仅当宿主以结构化日期形式提供时落入)。
  /// 与 FollowupPlan.recommendedAt 区分:这里是医嘱(事实),那里是 PAC 算法推断。
  nextReviewAt  DateTime? @map("next_review_at")
  /// 治疗状态(进行中 / 已完成 / 已取消等,各宿主自定义)
  status        String
  /// 备注 / 医嘱原文(自由文本;无法解析成 nextReviewAt 的"1周后复查"等也落这里)
  notes         String?   @db.Text
  createdAt     DateTime  @default(now()) @map("created_at")
  updatedAt     DateTime  @updatedAt @map("updated_at")

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

  @@unique([platformId, tenantId, externalId])
  @@index([platformId, tenantId, clinicId])
  @@index([patientId])
  @@map("treatments")
}

/// 预约记录镜像。Plan 召回引擎扣减"已有预约"线索 + 接口 2 完成时关联新预约 ref。
model Appointment {
  id          String   @id @default(uuid()) @db.Uuid
  platformId  String   @map("platform_id")
  tenantId    String   @map("tenant_id")
  clinicId    String   @map("clinic_id")
  /// 宿主系统里的预约 id
  externalId  String   @map("external_id")
  patientId       String    @map("patient_id") @db.Uuid
  /// 接诊医生 id(宿主侧 id,可空)
  doctorId        String?   @map("doctor_id")
  /// 接诊医生姓名(展示用,doctorId / doctorName 至少有一个)
  doctorName      String?   @map("doctor_name")
  /// 预约时间(计划值)
  scheduledAt     DateTime  @map("scheduled_at")
  /// 实际到诊时间(事实,可空 — 召回核心信号:"距上次到诊 N 天"/失约判断都靠它)
  arrivedAt       DateTime? @map("arrived_at")
  /// 预约类型(初诊 / 复诊 / 咨询 / 急诊 等,自由文本,不强枚举)
  appointmentType String?   @map("appointment_type")
  /// 预约状态(已确认 / 已到诊 / 已取消等,宿主原始字符串)
  status          String
  /// 备注 / 简短预约目的("复查根管"/"洗牙"等),自由文本
  notes           String?   @db.Text
  createdAt       DateTime  @default(now()) @map("created_at")
  updatedAt       DateTime  @updatedAt @map("updated_at")

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

  @@unique([platformId, tenantId, externalId])
  @@index([platformId, tenantId, clinicId])
  /// 按患者拉最近预约 / 到诊
  @@index([patientId, scheduledAt])
  @@index([patientId, arrivedAt])
  @@map("appointments")
}

/// 缴费记录镜像。Persona 的"客户价值"特征算消费总额时直接 SUM 这张表。
/// 退款情况:同 external_id 的 charge 用 status 反映(paid / refunded / partial_refund),
/// 不再写一条新记录;refund_amount_cents 表达已退金额。
model Charge {
  id            String   @id @default(uuid()) @db.Uuid
  platformId    String   @map("platform_id")
  tenantId      String   @map("tenant_id")
  clinicId      String   @map("clinic_id")
  /// 宿主系统里的缴费/订单 id
  externalId    String   @map("external_id")
  patientId     String   @map("patient_id") @db.Uuid
  /// 关联的治疗(若有,可空 — 缴费可能是诊费 / 套餐 / 其他)
  treatmentId   String?  @map("treatment_id") @db.Uuid

  /// 金额(分为单位,避免浮点;1元 = 100 分)
  amountCents       Int     @map("amount_cents")
  /// 已退金额(分)。partial_refund 时小于 amountCents,refunded 时等于
  refundAmountCents Int     @default(0) @map("refund_amount_cents")
  /// 币种,默认 CNY
  currency          String  @default("CNY")

  /// 类别(treatment / consultation / membership / other),由宿主映射
  category String?

  /// 状态:paid / refunded / partial_refund / cancelled
  status String

  /// 缴费时间(宿主侧的 paid_at / charged_at)
  chargedAt   DateTime  @map("charged_at")
  /// 退款时间(可空,多次部分退款时取最近一次;变化轨迹靠 charge_refunded 事件流)
  refundedAt  DateTime? @map("refunded_at")
  /// 备注 / 简短说明("年度套餐"/"复查折扣"等),自由文本
  notes       String?   @db.Text
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")

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

  @@unique([platformId, tenantId, externalId])
  @@index([platformId, tenantId, clinicId])
  @@index([patientId, chargedAt])
  @@map("charges")
}

/// 触点记录镜像(Touchpoint)— 宿主已有的"患者接触"事实(架构 §1 数据边界中的"已有回访/跟进事实")。
/// 用途:
///   1. PlanEngine 召回算法排除"近 N 天宿主已经接触过的患者"
///   2. OnePageSummary 显示"该患者上次被接触是 X 时间 / Y 渠道"
///   3. 跨 PAC + 宿主统一最大联系频次熔断
///
/// 跟 PAC 自己的 PlanExecution 区分:Touchpoint 是宿主**已经发生**的接触事实(只读),
/// PlanExecution 是 PAC 主导的回访动作执行回写(写入)。
model Touchpoint {
  id          String   @id @default(uuid()) @db.Uuid
  platformId  String   @map("platform_id")
  tenantId    String   @map("tenant_id")
  clinicId    String   @map("clinic_id")
  /// 宿主侧的触点记录 id
  externalId  String   @map("external_id")
  patientId   String   @map("patient_id") @db.Uuid

  /// 接触发生时间(宿主侧)
  occurredAt      DateTime @map("occurred_at")
  /// 触达方向:inbound(患者主动)/ outbound(宿主主动)/ unknown;自由文本不强枚举。
  /// 频次熔断只数 outbound;inbound 是 engagement 的正向特征,不计入"已打扰"
  direction       String?
  /// 接触渠道(宿主自行定义:phone / wecom / sms / visit / 等;PAC 不强枚举)
  channel         String?
  /// 接触结果(宿主自行定义)
  outcome         String?
  /// 通话时长(秒);非通话渠道(sms / visit 等)留空。
  /// engagement 评分用:30 秒挂断 vs 5 分钟交流意义完全不同
  durationSeconds Int?     @map("duration_seconds")
  /// 摘要 / 备注
  notes           String?  @db.Text
  /// 宿主侧的执行人 id(回链审计,可空)
  operatorRef     String?  @map("operator_ref")
  /// 执行人姓名(展示用,operatorRef / operatorName 至少有一个)
  operatorName    String?  @map("operator_name")
  /// 宿主侧"回访任务"id(5i5y / 尘锋等 task object)。一个 task 通常对应多个 touchpoint(多次拨号),
  /// 存这个用于回链审计 + 跟宿主任务体系对接
  taskRef         String?  @map("task_ref")

  createdAt   DateTime @default(now()) @map("created_at")
  updatedAt   DateTime @updatedAt @map("updated_at")

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

  @@unique([platformId, tenantId, externalId])
  @@index([platformId, tenantId, clinicId])
  @@index([patientId, occurredAt])
  @@map("touchpoints")
}

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

/// 患者画像(版本流)。每次重算产生新版本,旧版本保留并标 supersededAt。
/// 当前版本 = supersededAt IS NULL 的那条。
model Persona {
  id         String @id @default(uuid()) @db.Uuid
  platformId String @map("platform_id")
  tenantId   String @map("tenant_id")
  clinicId   String @map("clinic_id")
  patientId  String @map("patient_id") @db.Uuid
  /// 该患者的 persona 单调递增版本号
  version    Int

  /// 触发本次重算的来源:"fact:<eventId>" / "manual" / "scheduled"
  triggeredBy  String    @map("triggered_by")
  /// 计算完成时间
  computedAt   DateTime  @default(now()) @map("computed_at")
  /// 被新版本取代的时间;null 表示当前版本
  supersededAt DateTime? @map("superseded_at")

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

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

/// Persona 的特征条目。
/// 架构强制:每条特征 = 特征键 + 自然语言描述 + 证据引用 + 版本(随父 Persona)。
/// **不要把 description 拆成更细的字段** — 这是设计约束,不是疏漏。
/// numericScore 是排序/筛选的可选数值,不是描述的替代。
model PersonaFeature {
  id        String @id @default(uuid()) @db.Uuid
  personaId String @map("persona_id") @db.Uuid

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

  /// 证据引用:{ factEventIds, agentInvocationId, ruleIds } —
  /// 任何 persona 都能反查到"为什么得出这个结论"
  evidence Json

  createdAt DateTime @default(now()) @map("created_at")

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

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

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

/// 召回计划(版本流)。整张表是**推断**层 — 每行都是 PAC 综合 fact / Persona / 规则 / agent 后的对外承诺。
///
/// 召回池主查询路径:
///   WHERE status='active' AND assignee_user_id IS NULL ORDER BY priority_score DESC
///
/// 单一活跃版本约束:同一 (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")
  clinicId   String  @map("clinic_id")
  patientId  String  @map("patient_id") @db.Uuid
  /// 生成本计划用的 persona 版本。**可空是设计的两种工作模式,不只是过渡态**:
  ///   1) 有 Persona — Plan 引擎读 persona feature 加权(高质量)
  ///   2) 无 Persona — Plan 引擎走纯规则(基础质量,基于 fact 时间轴 + scenario 配置)
  /// 触发"无 Persona"模式的合理场景:新患者刚注册 Persona 还没算出来 / Persona pipeline 临时挂了 /
  /// 简单 scenario 不需要画像维度。PDF §5.1 "基于最新事实重新分析" 的"必须"是针对 fact(硬依赖),
  /// 不是 Persona(可选增强)。
  personaId  String? @map("persona_id") @db.Uuid

  /// 业务场景(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 算出来的;若 Treatment.nextReviewAt 是医嘱事实,展示时事实优先,推断兜底)
  recommendedAt      DateTime? @map("recommended_at")
  /// 推荐执行人角色(staff / leader / doctor_assistant ...)
  recommendedRole    String?   @map("recommended_role")
  /// 推荐触达渠道(phone / wecom / sms ...)
  recommendedChannel String?   @map("recommended_channel")

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

  /// 生命周期状态(本轮简化为 5 个,删除 executing / recycled):
  ///   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")

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

  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  patient    Patient         @relation(fields: [patientId], references: [id], onDelete: Cascade)
  persona    Persona?        @relation(fields: [personaId], references: [id])
  scripts    PlanScript[]
  summaries  PlanSummary[]
  executions PlanExecution[]

  @@unique([patientId, scenario, version])
  @@index([platformId, tenantId, clinicId])
  @@index([scenario, status])
  @@index([assigneeUserId])
  /// 召回池主查询路径:status + assignee 过滤 + priorityScore 排序联合索引
  @@index([status, assigneeUserId, priorityScore])
  @@map("followup_plans")
}

/// 客服打电话用的参考话术。每个 plan 下按 scriptType 各一份当前态。
///
/// **必须 agent**(harness 总原则 §14:话术是 LLM 本职,模板僵硬不实用),
/// 但 agent 失败必须能降级到 scenario 默认模板(`source='template_fallback'`)。
///
/// 单一最新规则:`UNIQUE(planId, scriptType)` 保证同 plan 同类话术只一条当前态。
/// Plan 重生成时新 plan 行带新 script 行,旧 plan superseded 后其 script 自然进入历史。
///
/// patient_id 不立柱(跟 PlanSummary / PlanExecution 一致):查询入口都是"打开某 plan
/// 看话术",`plan_id` 已能 join reach `patient_id`,不冗余。
model PlanScript {
  id                String   @id @default(uuid()) @db.Uuid
  /// 三级隔离 — 跟 PlanExecution 对齐,运维查询(全 tenant 话术质量分布)直接过滤,不用 join plan
  platformId        String   @map("platform_id")
  tenantId          String   @map("tenant_id")
  clinicId          String   @map("clinic_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 总原则 §14):
  ///   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")

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

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

/// 客服打电话前的参考材料(One Page Summary / 病历摘要 / 治疗链概览)。
/// 跟 PlanScript 完全对偶 — 都是 Plan 输出物,**必须 agent**(harness 总原则 §14:跨多源
/// 数据归纳成可读叙述是 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
/// 之后的优化,届时若试点反馈"重复生成耗时"再做。
///
/// patient_id 不立柱:跟 PlanScript / PlanExecution 一致,通过 plan_id 反查 patient_id。
model PlanSummary {
  id                String   @id @default(uuid()) @db.Uuid
  /// 三级隔离 — 跟 PlanScript / PlanExecution 对齐,运维查询直接过滤
  platformId        String   @map("platform_id")
  tenantId          String   @map("tenant_id")
  clinicId          String   @map("clinic_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 总原则 §14):
  ///   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")

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

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

/// 接口 2:Plan 执行回写。客服打完电话或操作后,宿主把结果写到这里。
/// **强约束:这条记录绝不会被提升为 PatientFactEvent**(架构 §5.3),
/// 它只驱动 Plan 自己的生命周期。
model PlanExecution {
  id             String @id @default(uuid()) @db.Uuid
  platformId     String @map("platform_id")
  tenantId       String @map("tenant_id")
  clinicId       String @map("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

  /// 通话结果:abandoned(放弃)/ no_answer(未接通)/ scheduled_next(下次回访)/ success_appointed(成功 = 新预约)
  outcome String
  /// 本次电话纪要(客服手填的自由文本)
  notes   String? @db.Text

  /// 放弃原因(预设多选,见 AbandonReason 枚举)
  abandonReasons String[] @map("abandon_reasons")
  /// 放弃原因 - "其他" 自由文本
  abandonOther   String?  @map("abandon_other")

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

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

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

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

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

// =============================================================
// Agent Pool — 每次 AI 调用都落库审计
// =============================================================

/// AI agent 调用记录。**共享基础设施** — 每个 agent 调用都强制落一条,
/// 供合规审计 + 反查"这个输出哪来的"。对接的 AI 网关 API 形状对齐 Dify(/v1/workflows/run)。
///
/// 当前明确用途:Plan 话术 / Plan 摘要;**不排除**其他环节(Persona 描述 / 数据清洗 /
/// 排序辅助 / Plan 解释)按需扩展 — schema 保持开放(`agentKind` 不强枚举)。
///
/// 跟三张 orchestration log 的关系(harness 总原则 §14):
///   - 被 PersonaRecomputeLog / PlanGenerationLog / 将来的 SyncLog(数据清洗 agent)调用
///   - **不立柱反向父链**(同 §13.4 / §18.5 决议),失败诊断走 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")
  clinicId   String @map("clinic_id")

  /// agent 类型(开放字符串,应用层用 AgentKind enum 给已知值):
  ///   script           — 产出 PlanScript(一次多 scriptType,见 §15)
  ///   summary          — 产出 PlanSummary(一次多 summaryType,见 §16)
  ///   persona_feature  — 产出 PersonaFeature(双轨设计 §12.4 落点,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)
  inputSnapshot  Json    @map("input_snapshot")
  /// prompt 模板原文(若有);从 agentVersion 反查 prompt 仓库通常更可靠,本字段作为冗余兜底
  promptTemplate String? @map("prompt_template") @db.Text
  /// 结构化输出(JSON);跟 outputText 双轨,不同 agent 形态各取所需
  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 元;同总原则 §11 金额 Int 分)
  costCents        Int @default(0) @map("cost_cents")

  /// 状态(本轮简化为 3 个,删除 STREAM_*):
  ///   running   — 进行中(包括流式过程中,不再单列 stream_started)
  ///   succeeded — 完成(包括流式完成,不再单列 stream_completed)
  ///   failed    — 失败
  /// 跟 SyncLog / PersonaRecomputeLog / PlanGenerationLog 状态机统一。
  status       String
  /// 失败时的错误消息
  errorMessage String? @map("error_message") @db.Text

  startedAt DateTime  @default(now()) @map("started_at")
  endedAt   DateTime? @map("ended_at")

  @@index([platformId, tenantId, clinicId])
  @@index([agentKind])
  @@index([linkedPatientId])
  @@index([linkedPersonaId])
  @@index([linkedPlanId])
  /// 时间范围统计(token / cost / 失败率仪表盘)
  @@index([startedAt])
  @@map("agent_invocations")
}

// =============================================================
// 事实接入 orchestration 日志(SyncLog)
// =============================================================

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

  /// adapter 方向:pull(我们拉)/ push(他们推)。
  /// mock 数据走 pull 路径(adapter 内部造数据),不单列 adapter_kind。
  adapterKind String @map("adapter_kind")
  /// 同步资源(patients / diagnoses / treatments / appointments / charges / touchpoints)
  resource    String

  /// 触发来源(可选):scheduled:<cronId> / manual:<userId> / push:<adapter>。
  /// 帮助诊断"这条记录为什么在这个时间出现"。
  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)
  /// upsert 到本地镜像表的条数
  upserted     Int       @default(0)
  /// 写入 fact 时间轴的事件数
  factsEmitted Int       @default(0) @map("facts_emitted")
  /// 失败条数(status=success 时 = 0;status=partial 时 > 0)
  failed       Int       @default(0)
  /// 失败时的错误信息(单条主因,详细堆栈进应用日志)
  errorMessage String?   @map("error_message") @db.Text

  startedAt    DateTime  @default(now()) @map("started_at")
  endedAt      DateTime? @map("ended_at")

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

// =============================================================
// Persona 重算 orchestration 日志
// =============================================================

/// 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")
  tenantId   String @map("tenant_id")
  clinicId   String @map("clinic_id")
  patientId  String @map("patient_id") @db.Uuid

  /// 触发来源:fact:<eventId> / manual:<userId> / scheduled:<cronId>
  triggeredBy   String  @map("triggered_by")
  /// 重算时所基于的最新 fact event 标识(成功时与产出 Persona.factWatermark 一致)
  factWatermark String? @map("fact_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")
  endedAt   DateTime? @map("ended_at")

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

// =============================================================
// Plan 生成 orchestration 日志
// =============================================================

/// Plan 生成 orchestration 日志。每次 PlanEngineService.recomputeForPatient() 调用一条记录,
/// 不论成功失败都落,作为 harness 层的执行账本。跟 SyncLog(事实接入)/ PersonaRecomputeLog(画像重算)
/// 三表平行,各 orchestration 域一张账本,字段贴近自身语义。
///
/// 调用顺序:
///   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 落原因
///
/// 设计选择:
///   - 不立柱反向链 `AgentInvocation.planGenerationLogId`(同 PersonaRecomputeLog 决策),
///     失败诊断走时间窗 + patientId + agent_kind 反查
///   - 不 union 成 OrchestrationLog(同 PersonaRecomputeLog 决策),字段差异大
model PlanGenerationLog {
  id         String @id @default(uuid()) @db.Uuid
  platformId String @map("platform_id")
  tenantId   String @map("tenant_id")
  clinicId   String @map("clinic_id")
  patientId  String @map("patient_id") @db.Uuid

  /// 触发来源:fact:<eventId> / 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 §14 健康度指标:agent 路径成功调用次数
  agentCount    Int @default(0) @map("agent_count")
  /// harness §14 健康度指标:agent 失败回退到模板的次数(升高 = 上游 LLM 出问题的早期信号)
  fallbackCount Int @default(0) @map("fallback_count")

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

  startedAt DateTime  @default(now()) @map("started_at")
  endedAt   DateTime? @map("ended_at")

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