284 lines
7.8 KiB
TypeScript
284 lines
7.8 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
ForbiddenException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
|
||
import { PrismaService } from '../prisma.service.js';
|
||
import type { ActorContext } from '../common/actor-context.js';
|
||
import { PublishTaskDto } from './dto/publish-task.dto.js';
|
||
import { AcceptTaskDto } from './dto/accept-task.dto.js';
|
||
import { CompleteTaskDto } from './dto/complete-task.dto.js';
|
||
import { CancelTaskDto } from './dto/cancel-task.dto.js';
|
||
import { MESSAGES } from '../common/messages.js';
|
||
|
||
/**
|
||
* 任务服务:封装调压任务状态机、院内隔离与事件发布逻辑。
|
||
*/
|
||
@Injectable()
|
||
export class TaskService {
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly eventEmitter: EventEmitter2,
|
||
) {}
|
||
|
||
/**
|
||
* 发布任务:医生/主任/组长创建主任务与明细,状态初始化为 PENDING。
|
||
*/
|
||
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
|
||
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
|
||
const hospitalId = this.requireHospitalId(actor);
|
||
|
||
if (!Array.isArray(dto.items) || dto.items.length === 0) {
|
||
throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED);
|
||
}
|
||
|
||
const deviceIds = Array.from(
|
||
new Set(
|
||
dto.items.map((item) => {
|
||
if (!Number.isInteger(item.deviceId)) {
|
||
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
|
||
}
|
||
if (!Number.isInteger(item.targetPressure)) {
|
||
throw new BadRequestException(`targetPressure 非法: ${item.targetPressure}`);
|
||
}
|
||
return item.deviceId;
|
||
}),
|
||
),
|
||
);
|
||
|
||
const devices = await this.prisma.device.findMany({
|
||
where: {
|
||
id: { in: deviceIds },
|
||
status: DeviceStatus.ACTIVE,
|
||
patient: { hospitalId },
|
||
},
|
||
select: { id: true, currentPressure: true },
|
||
});
|
||
|
||
if (devices.length !== deviceIds.length) {
|
||
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
|
||
}
|
||
|
||
if (dto.engineerId != null) {
|
||
const engineer = await this.prisma.user.findFirst({
|
||
where: {
|
||
id: dto.engineerId,
|
||
role: Role.ENGINEER,
|
||
hospitalId,
|
||
},
|
||
select: { id: true },
|
||
});
|
||
if (!engineer) {
|
||
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
|
||
}
|
||
}
|
||
|
||
const pressureByDeviceId = new Map(
|
||
devices.map((device) => [device.id, device.currentPressure] as const),
|
||
);
|
||
|
||
const task = await this.prisma.task.create({
|
||
data: {
|
||
status: TaskStatus.PENDING,
|
||
creatorId: actor.id,
|
||
engineerId: dto.engineerId ?? null,
|
||
hospitalId,
|
||
items: {
|
||
create: dto.items.map((item) => ({
|
||
deviceId: item.deviceId,
|
||
oldPressure: pressureByDeviceId.get(item.deviceId) ?? 0,
|
||
targetPressure: item.targetPressure,
|
||
})),
|
||
},
|
||
},
|
||
include: { items: true },
|
||
});
|
||
|
||
await this.eventEmitter.emitAsync('task.published', {
|
||
taskId: task.id,
|
||
hospitalId: task.hospitalId,
|
||
actorId: actor.id,
|
||
status: task.status,
|
||
});
|
||
|
||
return task;
|
||
}
|
||
|
||
/**
|
||
* 接收任务:工程师将任务从 PENDING 流转到 ACCEPTED。
|
||
*/
|
||
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
|
||
this.assertRole(actor, [Role.ENGINEER]);
|
||
const hospitalId = this.requireHospitalId(actor);
|
||
|
||
const task = await this.prisma.task.findFirst({
|
||
where: {
|
||
id: dto.taskId,
|
||
hospitalId,
|
||
},
|
||
select: {
|
||
id: true,
|
||
status: true,
|
||
hospitalId: true,
|
||
engineerId: true,
|
||
},
|
||
});
|
||
|
||
if (!task) {
|
||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||
}
|
||
if (task.status !== TaskStatus.PENDING) {
|
||
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
|
||
}
|
||
if (task.engineerId != null && task.engineerId !== actor.id) {
|
||
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
|
||
}
|
||
|
||
const updatedTask = await this.prisma.task.update({
|
||
where: { id: task.id },
|
||
data: {
|
||
status: TaskStatus.ACCEPTED,
|
||
engineerId: actor.id,
|
||
},
|
||
include: { items: true },
|
||
});
|
||
|
||
await this.eventEmitter.emitAsync('task.accepted', {
|
||
taskId: updatedTask.id,
|
||
hospitalId: updatedTask.hospitalId,
|
||
actorId: actor.id,
|
||
status: updatedTask.status,
|
||
});
|
||
|
||
return updatedTask;
|
||
}
|
||
|
||
/**
|
||
* 完成任务:工程师将任务置为 COMPLETED,并同步设备当前压力。
|
||
*/
|
||
async completeTask(actor: ActorContext, dto: CompleteTaskDto) {
|
||
this.assertRole(actor, [Role.ENGINEER]);
|
||
const hospitalId = this.requireHospitalId(actor);
|
||
|
||
const task = await this.prisma.task.findFirst({
|
||
where: {
|
||
id: dto.taskId,
|
||
hospitalId,
|
||
},
|
||
include: {
|
||
items: true,
|
||
},
|
||
});
|
||
|
||
if (!task) {
|
||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||
}
|
||
if (task.status !== TaskStatus.ACCEPTED) {
|
||
throw new ConflictException(MESSAGES.TASK.COMPLETE_ONLY_ACCEPTED);
|
||
}
|
||
if (task.engineerId !== actor.id) {
|
||
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ONLY_ASSIGNEE);
|
||
}
|
||
|
||
const completedTask = await this.prisma.$transaction(async (tx) => {
|
||
const nextTask = await tx.task.update({
|
||
where: { id: task.id },
|
||
data: { status: TaskStatus.COMPLETED },
|
||
include: { items: true },
|
||
});
|
||
|
||
await Promise.all(
|
||
task.items.map((item) =>
|
||
tx.device.update({
|
||
where: { id: item.deviceId },
|
||
data: { currentPressure: item.targetPressure },
|
||
}),
|
||
),
|
||
);
|
||
|
||
return nextTask;
|
||
});
|
||
|
||
await this.eventEmitter.emitAsync('task.completed', {
|
||
taskId: completedTask.id,
|
||
hospitalId: completedTask.hospitalId,
|
||
actorId: actor.id,
|
||
status: completedTask.status,
|
||
});
|
||
|
||
return completedTask;
|
||
}
|
||
|
||
/**
|
||
* 取消任务:任务创建者可将 PENDING/ACCEPTED 任务取消。
|
||
*/
|
||
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
||
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
|
||
const hospitalId = this.requireHospitalId(actor);
|
||
|
||
const task = await this.prisma.task.findFirst({
|
||
where: {
|
||
id: dto.taskId,
|
||
hospitalId,
|
||
},
|
||
select: {
|
||
id: true,
|
||
status: true,
|
||
creatorId: true,
|
||
hospitalId: true,
|
||
},
|
||
});
|
||
|
||
if (!task) {
|
||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||
}
|
||
if (task.creatorId !== actor.id) {
|
||
throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_CREATOR);
|
||
}
|
||
if (
|
||
task.status !== TaskStatus.PENDING &&
|
||
task.status !== TaskStatus.ACCEPTED
|
||
) {
|
||
throw new ConflictException(MESSAGES.TASK.CANCEL_ONLY_PENDING_ACCEPTED);
|
||
}
|
||
|
||
const cancelledTask = await this.prisma.task.update({
|
||
where: { id: task.id },
|
||
data: { status: TaskStatus.CANCELLED },
|
||
include: { items: true },
|
||
});
|
||
|
||
await this.eventEmitter.emitAsync('task.cancelled', {
|
||
taskId: cancelledTask.id,
|
||
hospitalId: cancelledTask.hospitalId,
|
||
actorId: actor.id,
|
||
status: cancelledTask.status,
|
||
});
|
||
|
||
return cancelledTask;
|
||
}
|
||
|
||
/**
|
||
* 校验角色权限。
|
||
*/
|
||
private assertRole(actor: ActorContext, allowedRoles: Role[]) {
|
||
if (!allowedRoles.includes(actor.role)) {
|
||
throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 校验并返回 hospitalId(B 端强依赖租户隔离)。
|
||
*/
|
||
private requireHospitalId(actor: ActorContext): number {
|
||
if (!actor.hospitalId) {
|
||
throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED);
|
||
}
|
||
return actor.hospitalId;
|
||
}
|
||
}
|