Commit ee00c6c5 by luoqi

feat(plan): assigned 重算升版本并继承分配 + 缺口解决即关闭 + 刷新回传 outcome/currentPlanId

- assigned plan 不再整体 skip:理由集合变 → supersede + 新版本,新版本继承
  assignee/assignedAt/recycleAt/contactAttempts(客服不丢单/不被抢/熔断计数延续);理由没变只就地刷分。
- 缺口全解决(0 命中)→ 即使 assigned 也关闭退池(closeStaleActivePlan 放开 assigned)。
- recomputeForPatient / 单刷 API 回传 planOutcome + currentPlanId。
- /full:plan superseded 且无活跃后继 = 已关闭 → 报 PLAN_NOT_FOUND(前端提示+退池),不再静默渲染冻结快照。
  (前端零改:既有 router.replace(currentPlanId) 切新版本 + PLAN_NOT_FOUND 提示逻辑自动生效)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 7a91d425
import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { maskName, maskPhone } from '@pac/utils';
import { applyLiveDays } from '@pac/types';
import { applyLiveDays, ApiCode } from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
import { PrismaService } from '../../prisma/prisma.service';
import { ChainComposerService } from '../plan/engine/chain-composer.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
......@@ -65,7 +66,13 @@ export class PlanAggregateService {
orderBy: { version: 'desc' },
select: { id: true },
});
if (active) currentPlanId = active.id;
if (active) {
currentPlanId = active.id; // 有后继活跃版本 → 前端 router.replace 切过去
} else {
// superseded 且无后继活跃版本 = 该召回已关闭(缺口解决/退池)→ 报 PLAN_NOT_FOUND,
// 前端按已有逻辑提示"该召回已不存在,返回召回池"(不再静默渲染冻结的关闭快照)。
throw new BizError(ApiCode.PLAN_NOT_FOUND, `Plan ${planId} 已关闭(无活跃后继版本)`);
}
}
const assembled = await this.assemble(scope, patient, plan);
......
......@@ -43,7 +43,14 @@ export class PlanEngineService {
tenantId: string;
clinicId?: string;
patientId: string;
}): Promise<{ plansCreated: number; plansClosed: number }> {
}): Promise<{
plansCreated: number;
plansClosed: number;
/// 本次重算对该患者 plan 的结果(前端刷新据此提示/切换版本)
outcome: 'created' | 'superseded' | 'unchanged' | 'suppressed' | 'closed' | 'none';
/// 重算后该患者当前 active/assigned plan id(关闭/无 plan 时 null)
currentPlanId: string | null;
}> {
// 真·单 patient:scenario SQL 带 patientId 收窄(只扫该患者),只 upsert 这一个 plan。
// (旧版走 runAllForHost 全租户扫 + 写全员 plan,详情页单刷被拖到分钟级 → 现 O(1))
const scope: ScenarioScope = {
......@@ -64,7 +71,12 @@ export class PlanEngineService {
// - superseded/completed/abandoned 已是终态,无需处理
if (hits.length === 0) {
const closed = await this.closeStaleActivePlan(scope, input.patientId);
return { plansCreated: 0, plansClosed: closed ? 1 : 0 };
return {
plansCreated: 0,
plansClosed: closed ? 1 : 0,
outcome: closed ? 'closed' : 'none',
currentPlanId: null,
};
}
const log = await this.prisma.planGenerationLog.create({
......@@ -84,8 +96,19 @@ export class PlanEngineService {
where: { id: log.id },
data: { status: 'success', plansCreated: created, endedAt: new Date() },
});
// 重算后该患者当前 plan id(可能是新版本)→ 回给前端做"切到新版本"
const current = await this.prisma.followupPlan.findFirst({
where: {
hostId: input.hostId,
tenantId: input.tenantId,
patientId: input.patientId,
status: { in: ['active', 'assigned'] },
},
orderBy: { version: 'desc' },
select: { id: true },
});
// upsert 路径自身会 supersede 旧版本(版本流),不算"关闭退池";plansClosed 只统计 0 命中关闭。
return { plansCreated: created, plansClosed: 0 };
return { plansCreated: created, plansClosed: 0, outcome: result, currentPlanId: current?.id ?? null };
} catch (err) {
await this.prisma.planGenerationLog.update({
where: { id: log.id },
......@@ -102,9 +125,9 @@ export class PlanEngineService {
/**
* ⭐ 缺口1 helper:关闭该患者遗留的 active(非 assigned)plan(本轮 0 命中 → 退出召回池)。
*
* 只关 status='active' 的最新一条:
* - assigned 不动(客服跟进中,关掉会打断)
* - superseded/completed/abandoned 已是终态
* 关 status='active' **或 'assigned'** 的最新一条(2026-06 决策:缺口全消失 → 即使 assigned 也关,
* 否则客服会拿"已解决"的理由白打;前端刷新时据此弹"已关闭"提示):
* - superseded/completed/abandoned 已是终态,不动
* 关闭 = supersede(status='superseded' + supersededAt=now),不创建后继版本
* —— "无信号了"是终结,不是版本演进,故无新版本(版本流纪律:只有"换 reason set"才升版本)。
* 返回是否真的关了一条(给调用方计数)。
......@@ -118,7 +141,7 @@ export class PlanEngineService {
orderBy: { version: 'desc' },
select: { id: true, status: true },
});
if (!latest || latest.status !== 'active') return false;
if (!latest || (latest.status !== 'active' && latest.status !== 'assigned')) return false;
await this.prisma.followupPlan.update({
where: { id: latest.id },
data: { status: 'superseded', supersededAt: scope.now },
......@@ -156,7 +179,7 @@ export class PlanEngineService {
let plansCreated = 0;
let plansSuperseded = 0;
let plansUnchanged = 0;
let plansSkippedAssigned = 0;
const plansSkippedAssigned = 0; // assigned 不再 skip(保留返回字段=0,兼容旧结构)
let plansSuppressed = 0;
let plansClosed = 0;
......@@ -177,7 +200,6 @@ export class PlanEngineService {
if (result === 'created') plansCreated++;
else if (result === 'superseded') plansSuperseded++;
else if (result === 'unchanged') plansUnchanged++;
else if (result === 'skipped_assigned') plansSkippedAssigned++;
else if (result === 'suppressed') plansSuppressed++;
await this.prisma.planGenerationLog.update({
......@@ -246,7 +268,7 @@ export class PlanEngineService {
scope: ScenarioScope;
patientId: string;
hits: ScenarioHitWithKey[];
}): Promise<'created' | 'superseded' | 'unchanged' | 'skipped_assigned' | 'suppressed'> {
}): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> {
const { scope, patientId, hits } = input;
const latest = await this.prisma.followupPlan.findFirst({
......@@ -255,10 +277,11 @@ export class PlanEngineService {
include: { reasons: true },
});
// 已分配给客服(assigned)→ 不动,避免打断客服跟进
if (latest && latest.status === 'assigned') {
return 'skipped_assigned';
}
// ⭐ 设计决策(2026-06):assigned 不再整体 skip。
// 理由:客服"正在执行"恰好撞上"正在重算"概率极低,为这点概率冻结内容、让客服拿过时理由白打,
// 得不偿失。改为:照常重算,但**升新版本时继承分配**(assignee/assignedAt/recycleAt/contactAttempts
// 带到新版)→ 客服不丢单、不被抢,只是召回理由刷新到最新;理由没变则只就地刷分(下面 unchanged 分支)。
// 缺口全消失(0 命中)→ 即使 assigned 也关闭(见 closeStaleActivePlan)。
// ⭐ 信号级抑制(召回闭环核心)— 抑制粒度 = 召回算法粒度 (scenario, subKey)。
// 只压"已结案那条召回覆盖的诊断";患者新长的、不在已结案理由里的诊断照常召回(不受冷静期影响)。
......@@ -305,6 +328,9 @@ export class PlanEngineService {
// 需要新版本:supersede 旧 + 创建新
const nextVersion = (latest?.version ?? 0) + 1;
// ⭐ 若旧版是 assigned:新版本继承分配(客服不丢单、不被抢;只是理由换到最新)。
// contactAttempts 一并带过去 → 频控/熔断计数延续(执行明细行仍挂旧版本,符合版本流语义)。
const carryAssignment = latest?.status === 'assigned';
// head = 优先级最高的 hit(用于 plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel)
const head = [...usableHits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;
......@@ -335,7 +361,12 @@ export class PlanEngineService {
recommendedAt: head.recommendedAt ?? null,
recommendedRole: head.recommendedRole ?? null,
recommendedChannel: head.recommendedChannel ?? null,
status: 'active',
// assigned 继承:新版本保持 assigned + 原客服 + 原回收窗 + 触达计数;否则回池(active)
status: carryAssignment ? 'assigned' : 'active',
assigneeUserId: carryAssignment ? latest!.assigneeUserId : null,
assignedAt: carryAssignment ? latest!.assignedAt : null,
recycleAt: carryAssignment ? latest!.recycleAt : null,
contactAttempts: carryAssignment ? latest!.contactAttempts : 0,
reasons: {
// W3 末改:plan_reasons 维度 = (plan, scenario, sub_key) — 每独立临床缺口一行,
// 不再合并子规则(K08 缺牙 / K05 牙周 / K04 根管 是不同治疗体系,合并 reason
......
......@@ -67,6 +67,8 @@ export class SyncController {
transactionsWritten: r.transactionsWritten,
factsEmitted: r.factsEmitted,
plansCreated: r.plansCreated,
planOutcome: r.planOutcome,
currentPlanId: r.currentPlanId,
};
}
}
......@@ -84,6 +84,8 @@ export class SyncService {
transactionsWritten: number;
factsEmitted: number;
plansCreated: number;
planOutcome: 'created' | 'superseded' | 'unchanged' | 'suppressed' | 'closed' | 'none' | 'error';
currentPlanId: string | null;
}> {
// 1. patient lookup(隔离基线)
const patient = await this.prisma.patient.findFirst({
......@@ -126,6 +128,9 @@ export class SyncService {
// 5. Plan 重算(per patient — runForPatient 会跑该 tenant 全量但 selector 只命中该 patient)
let plansCreated = 0;
let planOutcome: 'created' | 'superseded' | 'unchanged' | 'suppressed' | 'closed' | 'none' | 'error' =
'none';
let currentPlanId: string | null = null;
try {
const r2 = await this.planEngine.recomputeForPatient({
hostId: scope.hostId,
......@@ -133,7 +138,10 @@ export class SyncService {
patientId: patient.id,
});
plansCreated = r2.plansCreated;
planOutcome = r2.outcome;
currentPlanId = r2.currentPlanId;
} catch (err) {
planOutcome = 'error';
this.logger.warn(
`patient-refresh plan patient=${patient.id}: ${(err as Error).message}`,
);
......@@ -151,6 +159,8 @@ export class SyncService {
transactionsWritten: r.totals.transactionsWritten,
factsEmitted: r.totals.factsCreated + r.totals.factsSuperseded,
plansCreated,
planOutcome,
currentPlanId,
};
}
......
......@@ -35,6 +35,14 @@ export const RefreshPatientResponseSchema = z.object({
factsEmitted: z.number().int().optional(),
/// W4 末:本次刷新后新生成 plan 数(<= 1,plan = patient 级触达单元)
plansCreated: z.number().int().optional(),
/// 本次重算对该患者 plan 的结果(前端据此提示/切版本):
/// created/superseded=有新版本(切到 currentPlanId)· closed=缺口已解决已关闭(提示+退出)
/// unchanged/suppressed/none=无需切换 · error=重算异常
planOutcome: z
.enum(['created', 'superseded', 'unchanged', 'suppressed', 'closed', 'none', 'error'])
.optional(),
/// 重算后该患者当前 active/assigned plan id(可能是新版本;关闭/无 plan 时 null)
currentPlanId: z.string().uuid().nullable().optional(),
});
export type RefreshPatientResponse = z.infer<typeof RefreshPatientResponseSchema>;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment