import {
  Injectable,
  Logger,
  NotFoundException,
  BadRequestException,
  ForbiddenException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  Permission,
  PlanEventType,
  RELEASE_REASON_META,
  type ReleaseReason,
  planScenarioLabel,
  subLabelZh,
  applyLiveDays,
  type ListPlansResponse,
  type PlanCountsResponse,
  type PlanDetailResponse,
  type PlanReasonBrief,
  PersonaFeatureKey,
  personaTagDimId,
  type PersonaTagFilterDim,
  visitRecencyRange,
  expandTemperatureFilter,
} from '@pac/types';
import { calcAge, maskName, maskPhone } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
import { recordPlanEvent, computeHeldSeconds } from './plan-event.recorder';
import { PERSONA_TAG_FILTER_DIMS, parsePersonaTags } from '@pac/types';
import { assertCanRecycle } from './claim-guard';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import type { PlanEngineService } from './engine/plan-engine.service';
import type {
  ListPlansQueryDto,
  SubmitExecutionRequestDto,
} from './dto/plan.dto';
import { ExecutionService } from './execution.service';
import { renderAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
import { planLabelAnchorsSql, temperatureBucketCaseSql } from './reason-temperature.sql';

/**
 * PlanService — Plan 维度 CRUD(list / detail / assign / recycle / recompute / submitExecution)
 *
 * 跟 ExecutionService 分工:
 *   - PlanService: plan 的查询 + 生命周期(assign/recycle)+ 触发重算
 *   - ExecutionService: 客服回写 execution 行 + 状态机 + 熔断(plan.submitExecution 委托过去)
 *
 * 注:demo 路径不走本服务(走 PlanAggregateService 聚合),本服务用于真实工作台 CRUD
 */

/// assignment 后多久未结案自动回收。
/// ⚠️ 别再基于「这个值应该可配」去做 env 化/配置化 —— 自动回收生产**未启用**
/// (PAC_PLAN_AUTO_RECYCLE 未配),这个常量当前没有任何读者会真正生效;
/// 而分配的时效走的是另一条路(plan_assignments.expires_at / assignment_expires_at),
/// 与它互不干扰。原注释写的「后续接 tenant 配置」会误导人 —— PAC 全仓没有 Tenant 模型。
const RECYCLE_TIMEOUT_HOURS = 24;

/**
 * 写路径的诊所硬边界 —— assign / recycle 这类**按 planId 直接改**的操作专用。
 *
 * 为什么必须有(2026-08 补):列表侧 `buildListWhere` 早就写死了「orgScope→clinicIds 是硬边界,
 * 始终生效」,但 `assign()` / `recycle()` 的 findFirst 只校验了 host/tenant/sourceUnit,
 * **没有 targetClinicId** —— 于是 A 诊所的 leader 只要拿到 B 诊所的 planId,
 * 就能把 B 的单分给自己诊所的客服。他在界面上**看不到**那些单(读路径挡着),
 * 但写入不受挡。多租户隔离在写路径上比读路径弱一档,正好是最不该弱的地方。
 *
 * 批量分配会把这个洞从「知道 planId 才能利用」放大成「有 UI、一次几百条」,故先补上。
 *
 * `null` 也要放行:targetClinicId=null 是集团统一客服池(VIP 中心 / 中央营销),
 * 不属于任何诊所,窄 scope 也该能操作 —— 与 buildListWhere 的 OR 写法保持一致。
 * (Prisma 的 `in` 不接受 null,所以只能写成 OR;这也是列表侧那样写的原因。)
 *
 * 包一层 `AND` 是为了**可组合**:调用方的 where 里如果已经有自己的 OR,
 * 直接铺一个顶层 OR 会把两组条件并成"或"关系 —— 边界当场失效且看不出来。
 */
function clinicBoundary(scope: TenantScopeContext): Prisma.FollowupPlanWhereInput {
  if (!scope.clinicIds.length) return {}; // 空 = 集团级不限,不加过滤
  return {
    AND: [{ OR: [{ targetClinicId: { in: scope.clinicIds } }, { targetClinicId: null }] }],
  };
}

/**
 * 「上次医生 / 偏好医生」候选名单的缓存 key —— **导出**给批量重算 CLI 收尾时删。
 * key 拼法只此一处,别在 CLI 里再写一遍字符串(写歪了就是"删了个不存在的 key",还静默)。
 */
export const doctorOptionsCacheKey = (hostId: string, tenantId: string): string =>
  `pac:plan:doctors:${hostId}:${tenantId}`;

/** 医生名单缓存 TTL —— 10 分钟,理由见 doctorOptions 的注释(缓存久的代价是排查成本) */
export const DOCTOR_OPTIONS_TTL_SECONDS = 10 * 60;

/** 召回池数字概览(queueStats 返回;MCP recall_queue_stats / 工作台 KPI 共用)。 */
export interface PlanQueueStatsResponse {
  view: string;
  total: number;
  /** 优先级分档:high ≥70 / mid 40–69 / low <40 */
  byPriority: { high: number; mid: number; low: number };
  /** 按病种(sub_scenario)分布,信号(reason)条数倒序 */
  byScenario: Array<{ scenario: string; subScenario: string; label: string; signals: number }>;
}

@Injectable()
export class PlanService {
  private readonly logger = new Logger(PlanService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly executions: ExecutionService,
    private readonly redis: RedisService,
  ) {}

  // ─────────────────────────────────────────────
  // list
  // ─────────────────────────────────────────────

  async list(
    scope: TenantScopeContext,
    query: ListPlansQueryDto,
    permissions: readonly string[],
  ): Promise<ListPlansResponse> {
    const where = await this.buildListWhere(scope, query, permissions);

    // W3 末:服务端 sort —— 替代前端 .sort,跨页排序正确
    const orderBy: Prisma.FollowupPlanOrderByWithRelationInput[] =
      query.sort === 'priority_asc'
        ? [{ priorityScore: 'asc' }, { createdAt: 'asc' }]
        : query.sort === 'created_desc'
          ? [{ createdAt: 'desc' }]
          : [{ priorityScore: 'desc' }, { createdAt: 'asc' }]; // priority_desc 默认

    const skip = (query.page - 1) * query.pageSize;
    const [total, rows] = await Promise.all([
      this.prisma.followupPlan.count({ where }),
      this.prisma.followupPlan.findMany({
        where,
        include: { reasons: { orderBy: { priorityScore: 'desc' } } },
        orderBy,
        skip,
        take: query.pageSize,
      }),
    ]);

    // 批量拉患者简介(列表展示用,避免每行单查)
    const patientIds = Array.from(new Set(rows.map((r) => r.patientId)));
    const patients = patientIds.length > 0
      ? await this.prisma.patient.findMany({
          where: { id: { in: patientIds } },
          select: {
            id: true,
            externalId: true,
            name: true,
            phone: true,
            phoneVerified: true,
            gender: true,
            birthDate: true,
            medicalRecordNumber: true,
            // 到诊派生字段在 profile(派生的归派生,主表不加宽);卡片与筛选读同一处,不会分叉
            profile: { select: { lastVisitAt: true, lastVisitDoctor: true } },
          },
        })
      : [];
    const patientById = new Map(patients.map((p) => [p.id, p]));

    // 卡片补充信息(上次到诊 / 那次的医生 / 潜在治疗标签)—— 两条**整页批量**查询,
    // 不逐行发。本页最多 pageSize(25)个患者,都走 patient_id 索引。
    const potentialByPatient = await this.fetchPotentialTreatments(patientIds);

    // 每个 plan 最近一次执行结果(左栏「成功/不成功/保持」角标用)。
    // 本页 plan 数少(≤pageSize),执行记录也少;一次拉齐后 JS 取每 plan 最新一条。
    const planIds = rows.map((r) => r.id);
    const execs = planIds.length > 0
      ? await this.prisma.planExecution.findMany({
          where: { planId: { in: planIds } },
          select: { planId: true, outcome: true },
          orderBy: { createdAt: 'desc' },
        })
      : [];
    const lastOutcomeByPlan = new Map<string, string>();
    for (const e of execs) {
      if (!lastOutcomeByPlan.has(e.planId)) lastOutcomeByPlan.set(e.planId, e.outcome);
    }

    return {
      items: rows.map((p) => {
        const base = serializePlan(p);
        const patient = patientById.get(p.patientId);
        return {
          ...base,
          scenarioLabel: planScenarioLabel(base.scenario),
          lastOutcome: (lastOutcomeByPlan.get(p.id) ?? null) as ListPlansResponse['items'][number]['lastOutcome'],
          patient: {
            externalId: patient?.externalId ?? '',
            /// name 原值透出(权限内可见);nameMasked 保留作脱敏视图兜底
            name: patient?.name ?? null,
            nameMasked: maskName(patient?.name ?? null),
            phoneMasked: maskPhone(patient?.phone ?? null),
            // 明文号:列表直接显示(客服照着拨)。与详情页 plan-aggregate 下发的是同一个字段、
            // 同权限同 scope —— ⚠️ 但 phoneMasked 仍要留(配了原始档案的宿主走隐藏整行那条路)
            phone: patient?.phone ?? null,
            phoneVerified: patient?.phoneVerified ?? false,
            gender: patient?.gender ?? null,
            age: patient?.birthDate ? calcAge(patient.birthDate) : null,
            medicalRecordNumber: patient?.medicalRecordNumber ?? null,
            lastVisitAt: patient?.profile?.lastVisitAt?.toISOString().slice(0, 10) ?? null,
            lastVisitDoctor: patient?.profile?.lastVisitDoctor ?? null,
            potentialTreatments: potentialByPatient.get(p.patientId) ?? [],
          },
          /// reasons 透 signals JSON,前端 ReasonLine 用字典翻译富文本(跟详情页同源)
          reasons: p.reasons.map(toPlanReasonBrief),
        };
      }),
      page: query.page,
      pageSize: query.pageSize,
      total,
    };
  }

  /**
   * 列表卡片的潜在治疗标签 —— 整页患者一条查询。
   *
   * 只剩这一个画像字段:上次到诊 / 上次医生已改从 patients 列取(它们是事实派生属性,
   * 不是画像 —— 见 schema.prisma 上 lastVisitAt 的注释),patients 本来就在 select 里,零额外查询。
   *
   * ⭐ 取 types(code)不取 labels(中文):labels 是算画像那刻写死进 JSON 的,改措辞得全量重算
   *   才生效;code 稳定,中文由前端查 POTENTIAL_TREATMENT_CARD_LABEL。
   */
  private async fetchPotentialTreatments(patientIds: string[]): Promise<Map<string, string[]>> {
    const out = new Map<string, string[]>();
    if (patientIds.length === 0) return out;
    const rows = await this.prisma.personaFeature.findMany({
      where: {
        key: PersonaFeatureKey.POTENTIAL_TREATMENT,
        persona: { patientId: { in: patientIds }, supersededAt: null },
      },
      select: { data: true, persona: { select: { patientId: true } } },
    });
    for (const r of rows) {
      const types = (r.data as { types?: unknown } | null)?.types;
      if (Array.isArray(types)) {
        out.set(
          r.persona.patientId,
          types.filter((t): t is string => typeof t === 'string' && !!t),
        );
      }
    }
    return out;
  }

  /**
   * 「上次医生 / 偏好医生」筛选的候选名单 —— 本 scope 内出现过的医生姓名(去重、按姓名排序)。
   *
   * 为什么要这个接口:医生是**开集**(单宿主几百个),chip 平铺的面板放不下。
   * 前端把名单一次拉走、在输入框里做客户端模糊匹配,**选中后发精确姓名**;
   * 后端仍是等值查询 → 吃得到 visit_recency 的偏索引。
   * (做成后端 ILIKE 模糊反而会退化成全分区扫,就是 2026-07-29 那个「圈人 20 秒」。)
   *
   * 口径:只统计**当前版**画像;不按 clinicIds 再切 —— 医生名单是选择器的候选集,
   * 多几个不属于本诊所的名字只是选了筛不出人,而漏名字会让客服以为"这医生没数据"。
   * 真正的数据边界由列表查询自己的 scope 保证,这里不是权限闸。
   *
   * ⚠️ 缓存 TTL 从 6 小时收到 10 分钟(2026-07-29)。原以为"医生名单变动以月计"所以缓存越久越好,
   * 忽略了一件事:**名单是重算产出的,而缓存没有失效钩子**。测试服实测 —— 三个派生列刚上线全是 NULL,
   * 第一次打开筛选面板就把 `[]` 缓存了 6 小时;之后重算填了值,客服那边照旧「暂无医生名单」,
   * 输入框搜谁都搜不到,还看不出是缓存。缓存久的代价不是"名单旧几小时",是**排查成本**:
   * 表现成"这医生搜不到",谁也不会第一时间想到 Redis。
   * 10 分钟够挡住"每开一次面板跑一次 DISTINCT",又让任何重算最多 10 分钟内自然可见。
   * 批量重算收尾还会主动删这个 key(见 recompute-persona.cli.ts),那条路是即时的。
   */
  async doctorOptions(scope: TenantScopeContext): Promise<{ doctors: string[] }> {
    const cacheKey = doctorOptionsCacheKey(scope.hostId, scope.tenantId);
    const cached = await this.redis.get(cacheKey).catch(() => null);
    if (cached) return { doctors: JSON.parse(cached) as string[] };

    const rows = await this.prisma.$queryRaw<Array<{ name: string }>>`
      SELECT DISTINCT name FROM (
        SELECT nullif(pr.last_visit_doctor, '') AS name FROM patient_profiles pr JOIN patients p ON p.id = pr.patient_id
        WHERE p.host_id = ${scope.hostId}::uuid AND p.tenant_id = ${scope.tenantId}
        UNION ALL
        SELECT nullif(pr.preferred_doctor, '') FROM patient_profiles pr JOIN patients p ON p.id = pr.patient_id
        WHERE p.host_id = ${scope.hostId}::uuid AND p.tenant_id = ${scope.tenantId}
      ) t
      WHERE name IS NOT NULL
      ORDER BY name
    `;
    const doctors = rows.map((r) => r.name);
    await this.redis.setEx(cacheKey, JSON.stringify(doctors), DOCTOR_OPTIONS_TTL_SECONDS).catch(() => undefined);
    return { doctors };
  }

  // ─────────────────────────────────────────────
  /**
   * 矩阵某一格(治疗项 × 温度)对应的 planId —— 与矩阵端点**同一份 SQL**。
   *
   * ⭐ 存在的理由:换成 reason 驱动后,两根轴的判定要 lateral 展开证据 + 按标签聚合再判档,
   *   Prisma 的 JSON 谓词表达不了。与其在列表侧写一套"近似等价"的 Prisma 版(必然漂),
   *   不如直接复用同一段 SQL 取 id —— 口径**字面同源**,不需要靠测试去守等价性。
   *
   * ⚠️ 不传 temperature = 整行(六档全要),此时只按标签过滤。
   */
  private async cellPlanIds(
    scope: TenantScopeContext,
    query: ListPlansQueryDto,
  ): Promise<string[]> {
    const rows = await this.prisma.$queryRaw<Array<{ plan_id: string }>>(
      Prisma.sql`
        WITH la AS (
          ${planLabelAnchorsSql(
            Prisma.sql`fp.host_id = ${scope.hostId}::uuid AND fp.tenant_id = ${scope.tenantId} AND fp.superseded_at IS NULL`,
          )}
        )
        SELECT plan_id FROM la
        WHERE label = ${query.potentialTreatment}
        ${
          query.temperature
            ? Prisma.sql`AND ${temperatureBucketCaseSql(
                Prisma.raw('hot_until'),
                Prisma.raw('warm_until'),
                Prisma.raw('anchor_at'),
              )} IN (${Prisma.join(
                expandTemperatureFilter(query.temperature).map((t) => Prisma.sql`${t}`),
                ', ',
              )})`
            : Prisma.empty
        }
      `,
    );
    return rows.map((r) => r.plan_id);
  }

  // buildListWhere — list / queueStats 共用的 where 构造(view + 显式 filter + clinic 隔离 + persona 圈人)
  // ─────────────────────────────────────────────

  private async buildListWhere(
    scope: TenantScopeContext,
    query: ListPlansQueryDto,
    permissions: readonly string[],
  ): Promise<Prisma.FollowupPlanWhereInput> {
    // 温度必须挂在治疗项上 —— 光给 temperature 会静默筛出一个**没人要的人群**
    // (同一个人可能对种植是热、对补牙是冷)。⛔ 不许"贴心地"给个默认治疗项。
    if (query.temperature && !query.potentialTreatment) {
      throw new BadRequestException('temperature 必须与 potentialTreatment 一起传');
    }
    const where: Prisma.FollowupPlanWhereInput = {
      hostId: scope.hostId,
      tenantId: scope.tenantId,
      supersededAt: null,
      // 集团模型:数据范围按品牌圈(plan→patient.sourceUnit);覆盖召回池 NULL-clinic 工单。
      // sourceUnits 空 = 不限品牌(集团级角色)。
      ...(scope.sourceUnits.length
        ? { patient: { sourceUnit: { in: scope.sourceUnits } } }
        : {}),
    };

    // view 基础过滤
    const view = query.view ?? 'mine';
    if (view === 'pool') {
      where.status = 'active';
      where.assigneeUserId = null;
      // 回访调度:snooze 未到期的不进召回池(约定回访 / 考虑中 / 近期不考虑 等到点自动浮现)。
      //   仅作用于 pool 视图 — 'mine' 仍展示我 snooze 的工单(客服看得到自己的回访安排)。
      where.AND = [
        { OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: new Date() } }] },
      ];
    } else if (view === 'mine') {
      // "全部我的" = 我名下所有状态(active/assigned/completed/abandoned);
      // 具体状态由前端「进行中 / 已完成 / 已放弃」子 tab 通过 query.status 显式过滤。
      // (结案现在保留 assignee,所以"已完成"能查到。)
      where.assigneeUserId = scope.userId;
    } else if (view === 'all') {
      if (!permissions.includes(Permission.PLAN_VIEW_ALL)) {
        throw new ForbiddenException('需要 PLAN_VIEW_ALL 权限才能查看全部');
      }
    }

    // 显式 filter(覆盖 view 推断)
    if (query.status) where.status = query.status;
    // 诊所过滤:多选优先(IN),否则单选。staff 仍受下方 clinic 隔离 OR 约束(取交集)。
    if (query.targetClinicIds?.length) where.targetClinicId = { in: query.targetClinicIds };
    else if (query.targetClinicId) where.targetClinicId = query.targetClinicId;
    if (query.assigneeUserId) where.assigneeUserId = query.assigneeUserId;
    if (query.scenario) {
      where.reasons = { some: { scenario: query.scenario } };
    }

    // clinic 隔离:orgScope→clinicIds 是硬边界,始终生效(不按 view=all/PLAN_VIEW_ALL 豁免)。
    //   view=all 只是"看本 scope 内所有经办人",不跨 orgScope 诊所;宽 scope(集团 leader/admin)
    //   clinicIds 覆盖全部 → 此过滤为 no-op,不影响其看全量。窄 scope 才被真正圈住(修越权)。
    if (scope.clinicIds.length > 0) {
      where.OR = [
        { targetClinicId: { in: scope.clinicIds } },
        { targetClinicId: null },
      ];
    }

    // W3 末:服务端 keyword 模糊匹配 — patient.name / phone / 病历号 / externalId 任一命中
    // (替代前端本页 .filter,改成跨全表搜索,真实接入万人级数据后才能用)
    //   medicalRecordNumber = 病历号(客服可读档案号,如 "BA43016")— 列表搜索占位符标的就是它;
    //   externalId = host 内部业务 id(对人无意义),保留兜底,不删。
    const patientWhere: Prisma.PatientWhereInput = {};
    if (query.keyword) {
      patientWhere.OR = [
        { name: { contains: query.keyword, mode: 'insensitive' } },
        { phone: { contains: query.keyword } },
        { medicalRecordNumber: { contains: query.keyword, mode: 'insensitive' } },
        { externalId: { contains: query.keyword, mode: 'insensitive' } },
      ];
    }
    // 只看真实号码(外部对照表核实过的;测试库其余为造数假号)
    if (query.phoneVerified !== undefined) {
      patientWhere.phoneVerified = query.phoneVerified;
    }

    // 画像标签筛选:匹配患者**当前版**画像(supersededAt IS NULL)的 features。
    // 同一维度多选 = OR(该 feature 的 data 值命中任一);跨维度 = AND(每个维度各自 some)。
    // 维度/取值/data 路径以 PERSONA_TAG_FILTER_DIMS 为单一真理源(非法 key/value 静默丢弃)。
    if (query.personaTags) {
      // ⚠️ 按**维度 id** 分组,不是按 persona 特征 key —— visit_recency 一个特征出三个可筛字段
      //   (上次时间 / 上次医生 / 偏好医生),按 key 分组会把它们串成一条 OR,筛出错的人。
      const byDim = new Map<string, { dim: PersonaTagFilterDim; values: string[] }>();
      for (const { key: dimId, value } of parsePersonaTags(query.personaTags)) {
        const dim = PERSONA_TAG_FILTER_DIMS.find((d) => personaTagDimId(d) === dimId);
        if (!dim) continue;
        // 开集维度(医生姓名)不做闭集校验;闭集维度非法取值静默丢弃,行为不变。
        // 两者取值都只进参数化 equals / array_contains,无注入面。
        if (!dim.dynamic && !dim.options.some((o) => o.value === value)) continue;
        const cur = byDim.get(dimId) ?? { dim, values: [] };
        cur.values.push(value);
        byDim.set(dimId, cur);
      }
      const personaConds: Prisma.PatientWhereInput[] = [];
      for (const { dim, values } of byDim.values()) {
        // ── 事实派生属性:直接查 patients 列(不穿 personas → persona_features)──
        if (dim.source === 'patient') {
          const field = dim.patientField!;
          if (field === 'lastVisitAt') {
            // ⭐ 存的是**日期**不是分档 —— 区间在这里按 now 现算,所以永远准,
            //   不会像存死分档那样随时间过期(那需要一整套「到期重算」机械)。
            const ranges = values
              .map((v) => visitRecencyRange(v, new Date()))
              .filter((r): r is { gte?: Date; lt?: Date } => !!r);
            if (ranges.length > 0) {
              personaConds.push({ profile: { OR: ranges.map((r) => ({ lastVisitAt: r })) } });
            }
          } else {
            // 医生:等值(前端在名单上做模糊,选中后发精确姓名 —— 等值才吃得到索引)
            personaConds.push({ profile: { [field]: { in: values } } } as Prisma.PatientWhereInput);
          }
          continue;
        }
        const valueConds = values.map((v) =>
          dim.isArray
            ? { data: { path: [dim.dataPath], array_contains: v } }
            : { data: { path: [dim.dataPath], equals: v } },
        );
        personaConds.push({
          personas: {
            some: {
              supersededAt: null,
              features: { some: { key: dim.key, OR: valueConds } },
            },
          },
        });
      }
      if (personaConds.length > 0) {
        patientWhere.AND = [...((patientWhere.AND as Prisma.PatientWhereInput[]) ?? []), ...personaConds];
      }
    }

    /**
     * 🔴🔴 **把 patientWhere 挂上去** —— 上面攒的 keyword / phoneVerified / 画像标签
     * 全都写在这个局部对象里,**不挂就等于一条都没生效**。
     *
     * ⚠️ 实测事故(2026-08-06):换初选口径时,旧的温度谓词块紧贴在这三行前面,
     *   删旧块时把这里**一起删掉了**。后果是列表页的姓名搜索、"真号码"筛选、
     *   画像圈人**同时静默失效** —— 搜「黄」照样返回全部 1729 人,不报错、不空、
     *   看起来只是"没搜着"。走查时才被发现。
     * ⛔ 任何时候改这一段,先确认这三行还在。
     */
    if (Object.keys(patientWhere).length > 0) {
      where.patient = { ...(where.patient as Prisma.PatientWhereInput), ...patientWhere };
    }

    // ── 初选矩阵的两根轴(治疗项 × 窗口温度)────────────────────────
    // ⭐ 点矩阵格子进来的就是这条路。**必须与矩阵端点算出同一批人**,
    //   否则「格子里写 44,点进去列表 373」—— 主管对整个功能的信任当场没了(T14 口径对数)。
    //
    // ⚠️ 2026-08 换源后**不能再用 Prisma 谓词**表达:两根轴改从召回单证据算
    //   (plan_reasons → patient_facts,要 lateral 展开 + 按标签聚合再判档),Prisma 表达不了。
    //   所以这里**先用同一份 SQL 取出 planId**,再交给 Prisma 分页排序 ——
    //   ⛔ 别在这里另写一套 Prisma 版口径,那正是"两套实现"漂移的起点。
    //   量级可控:一格最多几百人(实测本地库最大格 222),`id IN (...)` 完全够用。
    if (query.potentialTreatment) {
      where.id = { in: await this.cellPlanIds(scope, query) };
    }

    return where;
  }

  // ─────────────────────────────────────────────
  // queueStats — 召回池数字概览(总量 + 优先级分档 + 病种 breakdown)。
  //   复用 buildListWhere,所以 list 的全部筛选(view / scenario / personaTags 圈人 / clinic)都生效。
  //   "今日推荐" 的聚合输入:LLM 可先看 stats 给摘要,再用 list_recall_queue 取明细。
  //   注:byScenario 计的是 reason(信号)条数,非去重患者数(一患者多牙位 = 多 reason)。
  // ─────────────────────────────────────────────

  async queueStats(
    scope: TenantScopeContext,
    query: ListPlansQueryDto,
    permissions: readonly string[],
  ): Promise<PlanQueueStatsResponse> {
    // 默认 pool 视图(今日推荐场景);调用方可显式传 mine。
    const where = await this.buildListWhere(
      scope,
      { ...query, view: query.view ?? 'pool' } as ListPlansQueryDto,
      permissions,
    );

    const [total, high, mid, low, reasonGroups] = await Promise.all([
      this.prisma.followupPlan.count({ where }),
      this.prisma.followupPlan.count({ where: { ...where, priorityScore: { gte: 70 } } }),
      this.prisma.followupPlan.count({ where: { ...where, priorityScore: { gte: 40, lt: 70 } } }),
      this.prisma.followupPlan.count({ where: { ...where, priorityScore: { lt: 40 } } }),
      this.prisma.planReason.groupBy({
        by: ['scenario', 'subKey'],
        where: { plan: where },
        _count: { _all: true },
      }),
    ]);

    // 按 sub_scenario(sub_key 的 '@' 前缀)归并,跨牙位累加信号数。
    const bucket = new Map<string, { scenario: string; subScenario: string; label: string; signals: number }>();
    for (const g of reasonGroups) {
      const subScenario = (g.subKey ?? '').split('@')[0] || g.scenario;
      const k = `${g.scenario}::${subScenario}`;
      const prev = bucket.get(k);
      bucket.set(k, {
        scenario: g.scenario,
        subScenario,
        label: subLabelZh(g.scenario, subScenario),
        signals: (prev?.signals ?? 0) + g._count._all,
      });
    }
    const byScenario = [...bucket.values()].sort((a, b) => b.signals - a.signals);

    return {
      view: query.view ?? 'pool',
      total,
      byPriority: { high, mid, low },
      byScenario,
    };
  }

  // ─────────────────────────────────────────────
  // counts — KPI / Tab badge 聚合(一次 SQL 5 个 count,避免前端 5 个并发 list 请求)
  // ─────────────────────────────────────────────

  async counts(
    scope: TenantScopeContext,
    permissions: readonly string[],
  ): Promise<PlanCountsResponse> {
    const base: Prisma.FollowupPlanWhereInput = {
      hostId: scope.hostId,
      tenantId: scope.tenantId,
      supersededAt: null,
    };
    // staff 受 clinic 隔离(跟 list 同口径)
    const clinicScope: Prisma.FollowupPlanWhereInput =
      scope.clinicIds.length > 0
        ? { OR: [{ targetClinicId: { in: scope.clinicIds } }, { targetClinicId: null }] }
        : {};
    const canViewAll = permissions.includes(Permission.PLAN_VIEW_ALL);

    const [mine, mineAssigned, mineCompleted, pool, all] = await Promise.all([
      this.prisma.followupPlan.count({
        where: { ...base, assigneeUserId: scope.userId, ...clinicScope },
      }),
      this.prisma.followupPlan.count({
        where: { ...base, assigneeUserId: scope.userId, status: 'assigned', ...clinicScope },
      }),
      this.prisma.followupPlan.count({
        where: { ...base, assigneeUserId: scope.userId, status: 'completed', ...clinicScope },
      }),
      this.prisma.followupPlan.count({
        where: { ...base, status: 'active', assigneeUserId: null, ...clinicScope },
      }),
      canViewAll
        ? this.prisma.followupPlan.count({ where: base })
        : Promise.resolve(0),
    ]);
    return { mine, mineAssigned, mineCompleted, pool, all };
  }

  // ─────────────────────────────────────────────
  // detail
  // ─────────────────────────────────────────────

  async detail(
    scope: TenantScopeContext,
    planId: string,
    /** 当前登录客服 —— 话术【回访客服】占位按它回填;缺省 → 通用"客服" */
    agent?: ScriptAgentIdentity,
  ): Promise<PlanDetailResponse> {
    const plan = await this.prisma.followupPlan.findFirst({
      // 集团模型:by-id 取 plan 也按品牌圈(plan→patient.sourceUnit);命中不到→下方 NotFound
      where: {
        id: planId,
        ...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
      },
      include: { reasons: { orderBy: { priorityScore: 'desc' } } },
    });
    if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
      throw new NotFoundException(`Plan ${planId} not found`);
    }
    // clinic 隔离:与 list 一致 —— orgScope→clinicIds 硬边界,目标诊所须在 clinicIds 内或为 null(集团池)。
    //   否则按 ID 直取也不可见 —— 修切诊所后残留旧 URL 越权看别诊所患者(view_all 不豁免,详见 list)。
    if (
      scope.clinicIds.length > 0 &&
      plan.targetClinicId &&
      !scope.clinicIds.includes(plan.targetClinicId)
    ) {
      throw new NotFoundException(`Plan ${planId} not found`);
    }

    const assigneeName = plan.assigneeUserId
      ? await this.resolveAgentName(scope, plan.assigneeUserId)
      : null;

    const [patient, persona, scriptRow, wecomRow, summariesRows, executions] = await Promise.all([
      this.prisma.patient.findUnique({
        where: { id: plan.patientId },
        include: { profile: true },
      }),
      this.prisma.persona.findFirst({
        where: { patientId: plan.patientId, supersededAt: null },
        include: { features: true },
        orderBy: { version: 'desc' },
      }),
      this.prisma.planScript.findUnique({ where: { planId_channel: { planId: plan.id, channel: 'phone' } } }),
      // 企微稿独立一行,与电话稿各有各的 status —— ⛔ 别做企微没有就回落电话稿
      this.prisma.planScript.findUnique({ where: { planId_channel: { planId: plan.id, channel: 'wecom' } } }),
      this.prisma.planSummary.findMany({ where: { planId: plan.id } }),
      this.prisma.planExecution.findMany({
        where: { planId: plan.id },
        orderBy: { createdAt: 'desc' },
      }),
    ]);

    if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);

    return {
      // ⭐ 承接人姓名服务端解析后下发 —— 前端翻不了(PAC 无 users 表,dictionary.users 只有登录人),
      //   不给的话详情页「承接人」显示的就是一串 uuid。
      plan: serializePlan(plan, assigneeName),
      patient: serializePatient(patient),
      persona: persona ? serializePersona(persona) : null,
      // 落库正文的自报家门是占位符,读出来按当前登录人回填(见 agent-identity.ts)
      script: scriptRow
        ? serializeScript({ ...scriptRow, content: renderAgentIdentity(scriptRow.content, agent) })
        : null,
      // ⛔ 不走 serializeScript:它会把正文拆成 sections 并丢掉原文,而企微稿只有原文(见 plan-aggregate 同处注释)
      wecomScript: wecomRow
        ? {
            id: wecomRow.id,
            status: wecomRow.status as 'ready' | 'pending' | 'failed',
            content: renderAgentIdentity(wecomRow.content, agent),
            updatedAt: wecomRow.updatedAt.toISOString(),
          }
        : null,
      summaries: summariesRows.map(serializeSummary),
      executions: executions.map(serializeExecution),
      evidence: computePlanEvidence(plan, persona),
    };
  }

  /**
   * userId → 姓名(单个)。源是回访表 `task_director_name`,取**最近一次** ——
   * id→姓名是时间性的(实测同一个 id 在不同时期是不同的人),取旧名比不显示更糟。
   * 查不到返回 null,前端按 null 显示 id 前缀,⛔ 不要编一个名字出来。
   */
  private async resolveAgentName(
    scope: TenantScopeContext,
    userId: string,
  ): Promise<string | null> {
    const row = await this.prisma.patientReturnVisit.findFirst({
      where: { hostId: scope.hostId, tenantId: scope.tenantId, taskDirectorId: userId },
      orderBy: { sourceCreatedAt: 'desc' },
      select: { taskDirectorName: true },
    });
    return row?.taskDirectorName ?? null;
  }

  // ─────────────────────────────────────────────
  // assign
  // ─────────────────────────────────────────────

  /**
   * @param actorUserId 执行本次分配的人(从 token 取)。自认领时 = assigneeUserId;
   *   门诊经理指派他人时二者不同 —— 账本据此区分 claim / assign,
   *   这是 followup_plans 那两列(只存归属结果)永远表达不了的。
   */
  async assign(
    scope: TenantScopeContext,
    planId: string,
    assigneeUserId: string,
    actorUserId?: string,
  ): Promise<void> {
    const plan = await this.prisma.followupPlan.findFirst({
      where: {
        id: planId,
        ...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
        ...clinicBoundary(scope), // ⭐ 诊所硬边界,见该函数注释
      },
      select: {
        id: true, hostId: true, tenantId: true, status: true,
        assigneeUserId: true, patientId: true,
      },
    });
    if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
      throw new NotFoundException(`Plan ${planId} not found`);
    }
    if (plan.status === 'completed' || plan.status === 'abandoned' || plan.status === 'superseded') {
      throw new BadRequestException(`Plan 已终态(${plan.status}),不能分配`);
    }
    if (plan.status === 'assigned' && plan.assigneeUserId !== assigneeUserId) {
      throw new BadRequestException(`Plan 已分配给 ${plan.assigneeUserId};回收后再分配`);
    }
    // 幂等:重复分配给同一人 → 只刷新 recycleAt
    const now = new Date();
    const isRepeat = plan.assigneeUserId === assigneeUserId;
    await this.prisma.$transaction(async (tx) => {
      await tx.followupPlan.update({
        where: { id: planId },
        data: {
          status: 'assigned',
          assigneeUserId,
          assignedAt: now,
          recycleAt: new Date(now.getTime() + RECYCLE_TIMEOUT_HOURS * 3600 * 1000),
          /// 谁触发的这次归属。自认领时 == assigneeUserId,指派他人时不同 ——
          /// 这一列让「主管派的」与「客服自己领的」在**主表**上就能分开,不必每次回查事件流
          assignedBy: actorUserId ?? assigneeUserId,
          /// ⭐ 重新有人接手 → 上一次的退回原因不再是当前状态,清掉。
          ///   (历史那条仍在 plan_event_logs 里,不会丢)
          ///   不清的话主表会同时出现"已分配给 A"和"因手上排满被退回",自相矛盾。
          releaseReason: null,
          releaseNote: null,
          /// ⛔ 刻意**不写** assignmentId / assignStrategy:这是单条认领/指派路径,不是批次。
          ///   批次那三列由 P2 的批量写入负责;这里瞎写会凭空造出归属不到任何批次的"孤儿归因"。
        },
      });
      // 生命周期事件账本(append-only,收录边界见 PlanEventLog 注释)。
      // 幂等重复分配不记账 —— 那是同一次归属的续期,不是新的归属变更。
      if (!isRepeat) {
        await recordPlanEvent(tx, {
          hostId: plan.hostId,
          tenantId: plan.tenantId,
          planId: plan.id,
          patientId: plan.patientId,
          // actor 未知(老调用方没传)时按自认领记 —— 当前前端只有认领一条路径
          event:
            !actorUserId || actorUserId === assigneeUserId
              ? PlanEventType.CLAIM
              : PlanEventType.ASSIGN,
          // ⛔ **这里不传 assignmentId,别"顺手补上"**。这条路是从池子里单条认领/指派,
          //   不属于任何批次;而 plan 上那个 assignment_id 很可能是**上一批的陈迹**
          //   (退回/到期都刻意不清它)。填进去等于给一个早就结束的批次凭空加人,
          //   而且不报错 —— 批次报表会莫名其妙多出几条。
          assigneeUserId,
          actorUserId: actorUserId ?? assigneeUserId,
        });
      }
    });
  }

  // ─────────────────────────────────────────────
  // recycle
  // ─────────────────────────────────────────────

  /**
   * 返池 —— 把已认领的 plan 退回召回池(plan 仍 active,可被再次认领)。
   *
   * ⭐ 归属校验(2026-07-29 staff 开放返池后加的):
   *   · 有 PLAN_VIEW_ALL(leader/admin)→ 可返**任意人**的单,这就是"组长回收"的原义。
   *   · 没有(staff)→ **只能返自己认领的**。光给 staff 权限不加这道校验,
   *     客服 A 就能把客服 B 手里的单退回池再自己认领,等于绕开认领闸抢单。
   *   canRecycleOthers 由 controller 从 JWT 权限传入 —— service 不自己去查角色,
   *   权限判定的唯一入口仍是 @RequirePermission + 这个布尔。
   */
  async recycle(
    scope: TenantScopeContext,
    planId: string,
    actorUserId?: string,
    canRecycleOthers = true,
    /**
     * 退回原因(结构化)。**不是** recallFeedback —— 那个回答「这条召回准不准」(冲算法去的),
     * 这个回答「我为什么不接这单」(冲派单去的)。两者可以同时填,统计口径互不相干。
     * 见 [[ReleaseReason]] 的三条边界。
     */
    releaseReason?: ReleaseReason,
    /** 原因的文字补充;`other` 必填(RELEASE_REASON_META.other.needNote) */
    releaseNote?: string,
  ): Promise<void> {
    const plan = await this.prisma.followupPlan.findFirst({
      where: {
        id: planId,
        ...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
        ...clinicBoundary(scope), // ⭐ 诊所硬边界,见该函数注释
      },
      select: {
        id: true, hostId: true, tenantId: true, status: true,
        assigneeUserId: true, assignedAt: true, patientId: true,
        // 分配来的单要强制填原因(见下方 assignmentId 那段)
        assignmentId: true,
      },
    });
    if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
      throw new NotFoundException(`Plan ${planId} not found`);
    }
    // 只拒终态(已完成/已放弃/已被取代);assigned 与 active 都可返池(无自动回收机制后,
    //   plan 可能停在 active 但仍挂在某客服名下 → 也要能退回池)。终态返池无意义,拒绝。
    if (plan.status === 'completed' || plan.status === 'abandoned' || plan.status === 'superseded') {
      throw new BadRequestException(`Plan 已终态(${plan.status}),不能返池`);
    }
    // 归属闸:staff 只能退自己的单(口径与用例见 claim-guard.assertCanRecycle)
    assertCanRecycle(plan, actorUserId, canRecycleOthers);
    // 选了「其他原因」就必须写清楚 —— **服务端也拦,不只信前端**。
    //   落点同 execution.service 的「选 inaccurate 必须勾具体治疗」:
    //   这类必填一旦只做在前端,换个客户端(助手 / 脚本 / 企微)就绕过去了,
    //   而 other 不带说明等于没填,退回原因分布里会堆出一坨读不出信息的 other。
    const note = releaseNote?.trim() || undefined;
    /**
     * 🔴 **分配来的单,退回必须给原因**(T7)。
     *
     * 契约里一直写着「分配单必填」(见 plan.ts 的 describe),但**只是句注释** ——
     * 服务端从来没拦过,前端也没做弹窗,于是客服点一下「退回」就走了(2026-08-03 走查发现)。
     * 结果:`releaseReasons` 那张分布报表拿到的全是 null,而 T7 说这张表
     * **是主管调整分配策略的输入** —— 没有它,主管只知道"退了 12 条",不知道该改什么。
     *
     * ⚠️ 只卡**分配来的**(assignment_id 非空):
     *   · 自助认领的单(旧路径)没有派单方,原因对谁都没用;
     *   · **系统自动到期回收不走这里**(assignment-expiry.scheduler 直接改库,
     *     reason 记 assignment_expired),所以不会被这条卡死。
     * ⚠️ 服务端必拦,⛔ 不能只做前端:换个客户端(助手 / 脚本 / 企微)就绕过去了。
     */
    if (plan.assignmentId != null && releaseReason == null) {
      throw new BadRequestException('这是分配给你的单,退回必须选一个原因');
    }
    if (releaseReason != null && RELEASE_REASON_META[releaseReason].needNote && !note) {
      throw new BadRequestException(
        `退回原因「${RELEASE_REASON_META[releaseReason].labelZh}」必须填写说明`,
      );
    }
    // 只在**确实挂着人**时记账:active 且无 assignee 的"返池"是空操作,记了是噪声
    const hadAssignee = plan.assigneeUserId != null;
    // ⭐ 在清空 assignedAt 之前算(见 computeHeldSeconds 注释)
    const heldSeconds = computeHeldSeconds(plan.assignedAt, new Date());
    await this.prisma.$transaction(async (tx) => {
      await tx.followupPlan.update({
        where: { id: planId },
        data: {
          status: 'active',
          assigneeUserId: null,
          assignedAt: null,
          recycleAt: null,
          // 在办期限随归属一起结束(列的自洽:非空 ⟺ 有一次在办的分配)
          assignmentExpiresAt: null,
          // 退回原因落主表**当前值**;全量历史在 plan_event_logs(同一条被退三次要留三条)
          releaseReason: releaseReason ?? null,
          releaseNote: note ?? null,
          // ⚠️ **不清 assignment_id / assigned_by / assign_strategy** ——
          //   退回不改变"这单来自哪个批次"这个历史事实,而它正是退回率的**分母**:
          //   「这批分了 60 条,退回 5 条」全靠 assignment_id 还在。清了就只剩分子。
          // ⛔⛔ **绝不要在这里动 snoozedUntil**。
          //   退回 = "换个人来做",不是"这人别召了"。照抄放弃原因那套抑制窗
          //   (ABANDON_REASON_META.suppressDays)会把被退回的患者静默压 30~90 天 ——
          //   池子里凭空少一批人,没有任何报错,且正好是 T5「剩下的不是遗漏,是还没轮到」的反面。
          //   ReleaseReason 的类型里刻意不给 suppressDays 字段,就是为了让这件事写不出来。
        },
      });
      if (hadAssignee) {
        await recordPlanEvent(tx, {
          hostId: plan.hostId,
          tenantId: plan.tenantId,
          planId: plan.id,
          patientId: plan.patientId,
          event: PlanEventType.RELEASE,
          assigneeUserId: null, // 释放后无人归属
          actorUserId: actorUserId ?? null,
          heldSeconds,
          // ⭐ 退回原因落**事件表**:主表(将来的 release_reason 列)只存当前值、会被下一次退回覆盖,
          //   而"退回原因分布"要的是全部历史。同一条 plan 被退三次的三个原因都得留下。
          //   ⚠️ 聚合这一列时**必须同时过滤 event='release'** —— 否则会混进 auto_release 的
          //   timeout/clinic_moved 和 feedback 的 up/down。
          reason: releaseReason ?? null,
          // ⭐ 退的是**哪一批**的单 —— 批次报表的退回率靠它,而不是靠
          //   followup_plans.assignment_id(那个会被下一次分配覆盖)。
          //   ⚠️ 这一刻取是对的:退回本身不清 assignment_id(上面那段注释),
          //   覆盖只发生在他被分进新批次时,那是以后的事。
          assignmentId: plan.assignmentId,
          details: note ? { note } : null,
        });
      }
    });
  }

  // ─────────────────────────────────────────────
  // submitExecution(委托 ExecutionService)
  // ─────────────────────────────────────────────

  async submitExecution(
    scope: TenantScopeContext,
    planId: string,
    operatorUserId: string,
    dto: SubmitExecutionRequestDto,
  ): Promise<{
    executionId: string;
    planStatus: string;
    contactAttempts: number;
    breakerTripped: boolean;
  }> {
    // 直接透传 ExecutionService.submit 的富 shape(含 planStatus / 熔断状态),前端要这些字段
    return this.executions.submit(scope, planId, operatorUserId, dto);
  }

  async listExecutions(scope: TenantScopeContext, planId: string) {
    return { items: await this.executions.list(scope, planId) };
  }

  // ─────────────────────────────────────────────
  // recomputeByPatient(纯规则重算,不 pull host)
  // ─────────────────────────────────────────────

  async recomputeByPatient(
    scope: TenantScopeContext,
    patientId: string,
    engine: PlanEngineService,
  ): Promise<{ plansCreated: number }> {
    const patient = await this.prisma.patient.findFirst({
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        id: patientId,
        // 品牌隔离:与其它 by-id 守卫对齐,防跨品牌触发重算 + 借计数差做存在性探测
        ...(scope.sourceUnits.length ? { sourceUnit: { in: scope.sourceUnits } } : {}),
      },
      select: { id: true },
    });
    if (!patient) throw new NotFoundException(`Patient ${patientId} not found in tenant`);

    const result = await engine.recomputeForPatient({
      hostId: scope.hostId,
      tenantId: scope.tenantId,
      clinicId: '', // 集团重算,clinic-级 hint 空字符串
      patientId,
    });
    return { plansCreated: result.plansCreated };
  }
}

// ─────────────────────────────────────────────
// Serializers — DB row → Zod-conformant API shape
// ─────────────────────────────────────────────

type PlanRow = Prisma.FollowupPlanGetPayload<{ include: { reasons: true } }>;
type PatientWithProfile = Prisma.PatientGetPayload<{ include: { profile: true } }>;
type PersonaWithFeatures = Prisma.PersonaGetPayload<{ include: { features: true } }>;
type PlanScriptRow = Prisma.PlanScriptGetPayload<{}>;
type PlanSummaryRow = Prisma.PlanSummaryGetPayload<{}>;
type PlanExecutionRow = Prisma.PlanExecutionGetPayload<{}>;

/// reasons 行 → PlanReasonBrief(给列表用)。signals 透 raw JSON,前端字典翻译富文本。
function toPlanReasonBrief(
  r: PlanRow['reasons'][number],
): PlanReasonBrief {
  return {
    id: r.id,
    scenario: r.scenario,
    subKey: r.subKey,
    // daysSince 用锚点 signalOccurredAt 实时重算(跟详情页/治疗链同源,不随天数陈旧);旧 plan 无锚点 → 回落 baked
    signals: applyLiveDays((r.signals as PlanReasonBrief['signals']) ?? null),
    priorityScore: r.priorityScore,
    reason: r.reason,
    breakdown: r.breakdown ?? null,
  };
}

function serializePlan(p: PlanRow, assigneeName?: string | null): import('@pac/types').FollowupPlan {
  // FollowupPlanSchema 需要顶层 scenario / inclusionReason / evidence
  // DB 这些在 PlanReason 子表;取 primary reason(MAX priorityScore)填充顶层
  const primary = p.reasons[0];
  const evidence = {
    factIds: Array.from(
      new Set(
        p.reasons.flatMap((r) => ((r.evidence as { factIds?: string[] } | null)?.factIds ?? [])),
      ),
    ),
    personaVersion: undefined,  // 让 caller 在 detail 里通过 persona.version 补全
    ruleIds: p.reasons.map((r) => r.scenario),
  };

  return {
    id: p.id,
    hostId: p.hostId,
    tenantId: p.tenantId,
    patientId: p.patientId,
    personaId: p.personaId,
    targetClinicId: p.targetClinicId,
    scenario: primary?.scenario ?? 'unknown',
    version: p.version,
    priorityScore: p.priorityScore,
    inclusionReason: primary?.reason ?? '(无)',
    recommendedAt: p.recommendedAt?.toISOString() ?? null,
    recommendedRole: p.recommendedRole,
    recommendedChannel: p.recommendedChannel,
    evidence,
    status: p.status as import('@pac/types').PlanStatus,
    recycleAt: p.recycleAt?.toISOString() ?? null,
    snoozedUntil: p.snoozedUntil?.toISOString() ?? null,
    assigneeUserId: p.assigneeUserId,
    /// 由调用方(detail)解析后注入 —— 序列化函数本身不发查询
    assigneeName: assigneeName ?? null,
    assignedAt: p.assignedAt?.toISOString() ?? null,
    // 🔴 加进 FollowupPlanSchema 时**漏了这里**(19bd658)。当时没报错,是因为
    //   `packages/types/dist` 是旧的 —— 编译期看的是过期声明。重编 types 才炸出来。
    //   ⚠️ 教训:改完 packages/types 的 schema 必须 `pnpm --filter @pac/types build`
    //   再跑各 app 的 tsc,否则"绿"是假的。
    assignmentExpiresAt: p.assignmentExpiresAt?.toISOString() ?? null,
    createdAt: p.createdAt.toISOString(),
    updatedAt: p.updatedAt.toISOString(),
    supersededAt: p.supersededAt?.toISOString() ?? null,
  };
}

function serializePatient(p: PatientWithProfile): import('@pac/types').Patient {
  // PatientSchema 期望 status enum + 多个 profile 派生字段
  // DB 模型重构:Patient.active boolean → 映射回 status enum
  const status: 'active' | 'archived' = p.active ? 'active' : 'archived';
  return {
    id: p.id,
    hostId: p.hostId,
    tenantId: p.tenantId,
    externalId: p.externalId,
    name: p.name,
    phone: p.phone,
    gender: p.gender,
    birthDate: p.birthDate?.toISOString().slice(0, 10) ?? null,
    preferences: (p.preferences as Record<string, unknown> | null) ?? null,
    doNotContact: p.profile?.doNotContact ?? false,
    doNotContactReason: p.profile?.doNotContactReason ?? null,
    doNotContactAt: null,  // schema 没立柱;后续接入再补
    deceased: p.profile?.deceased ?? false,
    deceasedAt: null,
    status,
    mergedIntoId: null, // v1 不做 merge,字段保留 null
    createdAt: p.createdAt.toISOString(),
    updatedAt: p.updatedAt.toISOString(),
  };
}

function serializePersona(p: PersonaWithFeatures): import('@pac/types').Persona {
  return {
    id: p.id,
    hostId: p.hostId,
    tenantId: p.tenantId,
    patientId: p.patientId,
    version: p.version,
    triggeredBy: p.source,
    computedAt: p.computedAt.toISOString(),
    supersededAt: p.supersededAt?.toISOString() ?? null,
    eventWatermark: p.eventWatermark != null ? p.eventWatermark.toString() : null,
    features: p.features.map((f) => ({
      id: f.id,
      personaId: f.personaId,
      key: f.key,
      description: f.description,
      score: f.score,
      evidence: (f.evidence as Record<string, unknown>) ?? {},
      createdAt: f.createdAt.toISOString(),
    })),
  };
}

function serializeScript(s: PlanScriptRow): import('@pac/types').PlanScript {
  return {
    id: s.id,
    hostId: s.hostId,
    tenantId: s.tenantId,
    planId: s.planId,
    content: s.content,
    status: s.status as import('@pac/types').AssetStatus,
    source: (s.source as import('@pac/types').AssetSource | null) ?? null,
    agentInvocationId: s.agentInvocationId,
    createdAt: s.createdAt.toISOString(),
    updatedAt: s.updatedAt.toISOString(),
  };
}

function serializeSummary(s: PlanSummaryRow): import('@pac/types').PlanSummary {
  return {
    id: s.id,
    hostId: s.hostId,
    tenantId: s.tenantId,
    planId: s.planId,
    type: s.type,
    content: s.content,
    status: s.status as import('@pac/types').AssetStatus,
    source: (s.source as import('@pac/types').AssetSource | null) ?? null,
    agentInvocationId: s.agentInvocationId,
    createdAt: s.createdAt.toISOString(),
    updatedAt: s.updatedAt.toISOString(),
  };
}

function serializeExecution(e: PlanExecutionRow): import('@pac/types').PlanExecution {
  return {
    id: e.id,
    hostId: e.hostId,
    tenantId: e.tenantId,
    executorClinicId: e.executorClinicId,
    planId: e.planId,
    operatorUserId: e.operatorUserId,
    channel: e.channel,
    outcome: e.outcome as import('@pac/types').ExecutionOutcome,
    notes: e.notes,
    abandonReasons: e.abandonReasons,
    inaccurateTreatments: e.inaccurateTreatments,
    scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
    createdAt: e.createdAt.toISOString(),
  };
}

function computePlanEvidence(
  plan: PlanRow,
  persona: PersonaWithFeatures | null,
): import('@pac/types').PlanEvidence {
  return {
    factIds: Array.from(
      new Set(
        plan.reasons.flatMap((r) => ((r.evidence as { factIds?: string[] } | null)?.factIds ?? [])),
      ),
    ),
    personaVersion: persona?.version,
    ruleIds: plan.reasons.map((r) => r.scenario),
    agentInvocationIds: [],  // 后续接 PlanScript/Summary 的 agentInvocationId
  };
}
