eab91174db
Co-Authored-By: Claude <noreply@anthropic.com>
191 lines
6.4 KiB
TypeScript
191 lines
6.4 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { DispositionRepository } from './disposition.repository';
|
|
import {
|
|
ActionKind,
|
|
ClosureOutcome,
|
|
Disposition,
|
|
DispositionSourceType,
|
|
DispositionStatus,
|
|
RiskLevel,
|
|
} from './disposition.types';
|
|
import { canTransition, initialStatus } from './disposition-state-machine';
|
|
import { FollowupService } from '../followup/followup.service';
|
|
import { TargetOperator } from '../followup/followup.types';
|
|
|
|
export interface CreateActionInput {
|
|
kind: ActionKind;
|
|
params?: Record<string, unknown>;
|
|
assigneeId?: string | null;
|
|
dueAt?: string | null;
|
|
}
|
|
|
|
export interface CreateDispositionInput {
|
|
caseId: string;
|
|
patientId: string;
|
|
sourceType: DispositionSourceType;
|
|
sourceId?: string | null;
|
|
title: string;
|
|
riskLevelAtCreation: RiskLevel;
|
|
actions: CreateActionInput[];
|
|
supersedesId?: string | null;
|
|
}
|
|
|
|
export interface ExecuteActionPatch {
|
|
linkedEntityId?: string | null;
|
|
resultNote?: string | null;
|
|
}
|
|
|
|
/**
|
|
* 处置单服务(REQ-D1)。
|
|
* 负责处置单创建(含动作组)、风险门控确认、动作执行与回执、闭环。
|
|
* 安全:中/高风险须人工确认方可执行(REQ-D1.2 / REQ-10.3)。
|
|
*/
|
|
@Injectable()
|
|
export class DispositionService {
|
|
constructor(
|
|
private readonly repo: DispositionRepository,
|
|
private readonly followup: FollowupService,
|
|
) {}
|
|
|
|
async create(input: CreateDispositionInput, createdBy: string): Promise<Disposition> {
|
|
if (!input.title?.trim()) {
|
|
throw new BadRequestException('处置单需标题');
|
|
}
|
|
if (!input.actions?.length) {
|
|
throw new BadRequestException('处置单需至少一个处置动作');
|
|
}
|
|
const now = new Date().toISOString();
|
|
const requiresConfirmation = input.riskLevelAtCreation !== 'low';
|
|
const d: Disposition = {
|
|
id: randomUUID(),
|
|
caseId: input.caseId,
|
|
patientId: input.patientId,
|
|
sourceType: input.sourceType,
|
|
sourceId: input.sourceId ?? null,
|
|
title: input.title.trim(),
|
|
riskLevelAtCreation: input.riskLevelAtCreation,
|
|
requiresConfirmation,
|
|
status: initialStatus(requiresConfirmation),
|
|
closureOutcome: null,
|
|
supersedesId: input.supersedesId ?? null,
|
|
createdBy,
|
|
confirmedBy: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
closedAt: null,
|
|
actions: input.actions.map((a) => ({
|
|
id: randomUUID(),
|
|
kind: a.kind,
|
|
params: a.params ?? {},
|
|
status: 'planned' as const,
|
|
assigneeId: a.assigneeId ?? null,
|
|
dueAt: a.dueAt ?? null,
|
|
executedAt: null,
|
|
resultNote: null,
|
|
linkedEntityId: null,
|
|
})),
|
|
};
|
|
return this.repo.save(d);
|
|
}
|
|
|
|
listByPatient(patientId: string): Promise<Disposition[]> {
|
|
return this.repo.findByPatient(patientId);
|
|
}
|
|
|
|
async getById(id: string): Promise<Disposition> {
|
|
return this.require(id);
|
|
}
|
|
|
|
/** 人工确认中/高风险处置单(医生/管理师兜底),确认后方可执行。 */
|
|
async confirm(id: string, confirmedBy: string): Promise<Disposition> {
|
|
const d = await this.require(id);
|
|
if (d.status !== 'pending_confirmation') {
|
|
throw new BadRequestException('仅「待确认」状态的处置单可确认');
|
|
}
|
|
this.setStatus(d, 'in_progress');
|
|
d.confirmedBy = confirmedBy;
|
|
return this.repo.save(d);
|
|
}
|
|
|
|
/** 执行单个处置动作并回执;中/高风险未确认前禁止执行。 */
|
|
async executeAction(
|
|
dispositionId: string,
|
|
actionId: string,
|
|
patch: ExecuteActionPatch = {},
|
|
): Promise<Disposition> {
|
|
const d = await this.require(dispositionId);
|
|
if (d.status === 'pending_confirmation') {
|
|
throw new BadRequestException('处置单需先经人工确认方可执行(REQ-10.3 人工兜底)');
|
|
}
|
|
if (d.status === 'closed') {
|
|
throw new BadRequestException('处置单已闭环');
|
|
}
|
|
const action = d.actions.find((a) => a.id === actionId);
|
|
if (!action) {
|
|
throw new NotFoundException('处置动作不存在');
|
|
}
|
|
action.status = 'executed';
|
|
action.executedAt = new Date().toISOString();
|
|
if (patch.linkedEntityId !== undefined) action.linkedEntityId = patch.linkedEntityId;
|
|
if (patch.resultNote !== undefined) action.resultNote = patch.resultNote;
|
|
|
|
// 复测动作执行 → 自动生成跟进项(REQ-D2),并回填 linkedEntityId
|
|
if (action.kind === 'recheck' && !action.linkedEntityId) {
|
|
const indicator = String(action.params?.indicator ?? '');
|
|
if (indicator) {
|
|
const fu = await this.followup.createForRecheck({
|
|
dispositionId: d.id,
|
|
patientId: d.patientId,
|
|
indicator,
|
|
targetOperator: action.params?.targetOperator as TargetOperator | undefined,
|
|
targetValue:
|
|
typeof action.params?.targetValue === 'number'
|
|
? (action.params.targetValue as number)
|
|
: undefined,
|
|
windowDays:
|
|
typeof action.params?.windowDays === 'number'
|
|
? (action.params.windowDays as number)
|
|
: undefined,
|
|
});
|
|
if (fu) action.linkedEntityId = fu.id;
|
|
}
|
|
}
|
|
|
|
// 全部动作落地(执行或跳过)→ 进入「跟进中」
|
|
const allSettled = d.actions.every((a) => a.status === 'executed' || a.status === 'skipped');
|
|
if (allSettled && d.status === 'in_progress') {
|
|
this.setStatus(d, 'following_up');
|
|
} else {
|
|
d.updatedAt = new Date().toISOString();
|
|
}
|
|
return this.repo.save(d);
|
|
}
|
|
|
|
/** 闭环处置单并记录结果(达标/未达标/升级)。 */
|
|
async close(id: string, outcome: ClosureOutcome): Promise<Disposition> {
|
|
const d = await this.require(id);
|
|
if (d.status !== 'in_progress' && d.status !== 'following_up') {
|
|
throw new BadRequestException('仅「执行中/跟进中」的处置单可闭环');
|
|
}
|
|
this.setStatus(d, 'closed');
|
|
d.closureOutcome = outcome;
|
|
d.closedAt = new Date().toISOString();
|
|
return this.repo.save(d);
|
|
}
|
|
|
|
private setStatus(d: Disposition, to: DispositionStatus): void {
|
|
if (!canTransition(d.status, to)) {
|
|
throw new BadRequestException(`非法状态流转:${d.status} → ${to}`);
|
|
}
|
|
d.status = to;
|
|
d.updatedAt = new Date().toISOString();
|
|
}
|
|
|
|
private async require(id: string): Promise<Disposition> {
|
|
const d = await this.repo.findById(id);
|
|
if (!d) throw new NotFoundException('处置单不存在');
|
|
return d;
|
|
}
|
|
}
|