tyt-api-nest/src/tasks/task.service.ts

1006 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma } from '../generated/prisma/client.js';
import {
DeviceStatus,
Role,
TaskStatus,
UploadAssetType,
} 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 { TaskRecordQueryDto } from './dto/task-record-query.dto.js';
import { MESSAGES } from '../common/messages.js';
import { normalizePressureLabel } from '../common/pressure-level.util.js';
const TASK_RECORD_DETAIL_SELECT = {
id: true,
oldPressure: true,
targetPressure: true,
task: {
select: {
id: true,
status: true,
createdAt: true,
creatorId: true,
engineerId: true,
hospitalId: true,
completionMaterials: true,
},
},
device: {
select: {
id: true,
currentPressure: true,
status: true,
isAbandoned: true,
implantModel: true,
implantManufacturer: true,
implantName: true,
isPressureAdjustable: true,
implantCatalog: {
select: {
pressureLevels: true,
},
},
patient: {
select: {
id: true,
name: true,
inpatientNo: true,
phone: true,
hospitalId: true,
doctorId: true,
doctor: {
select: {
departmentId: true,
groupId: true,
},
},
},
},
surgery: {
select: {
id: true,
surgeryName: true,
surgeryDate: true,
primaryDisease: true,
},
},
},
},
} as const;
/**
* 任务服务:封装调压任务状态机、院内隔离与事件发布逻辑。
*/
@Injectable()
export class TaskService {
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* 查询当前角色可见的医院工程师列表。
*/
async findAssignableEngineers(
actor: ActorContext,
requestedHospitalId?: number,
) {
this.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
]);
const hospitalId = this.resolveAssignableHospitalId(
actor,
requestedHospitalId,
);
return this.prisma.user.findMany({
where: {
role: Role.ENGINEER,
hospitalId,
},
select: {
id: true,
name: true,
phone: true,
hospitalId: true,
},
orderBy: { id: 'desc' },
});
}
async getTaskRecordItem(actor: ActorContext, id: number) {
this.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
Role.ENGINEER,
]);
const item = await this.findTaskRecordItemWithScope(actor, id);
return this.mapTaskRecordItemDetail(item);
}
/**
* 查询当前角色可见的调压记录列表。
*/
async findTaskRecords(actor: ActorContext, query: TaskRecordQueryDto) {
this.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
Role.ENGINEER,
]);
const hospitalId = this.resolveListHospitalId(actor, query.hospitalId);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = this.buildTaskRecordWhere(actor, query, hospitalId);
const [total, items] = await Promise.all([
this.prisma.taskItem.count({ where }),
this.prisma.taskItem.findMany({
where,
skip,
take: pageSize,
orderBy: { id: 'desc' },
select: {
id: true,
oldPressure: true,
targetPressure: true,
task: {
select: {
id: true,
status: true,
createdAt: true,
completionMaterials: true,
hospital: {
select: {
id: true,
name: true,
},
},
creator: {
select: {
id: true,
name: true,
role: true,
},
},
engineer: {
select: {
id: true,
name: true,
role: true,
},
},
},
},
device: {
select: {
id: true,
currentPressure: true,
implantModel: true,
implantManufacturer: true,
implantName: true,
patient: {
select: {
id: true,
name: true,
inpatientNo: true,
phone: true,
},
},
surgery: {
select: {
id: true,
surgeryName: true,
surgeryDate: true,
},
},
},
},
},
}),
]);
return {
list: items.map((item) => ({
id: item.id,
oldPressure: item.oldPressure,
targetPressure: item.targetPressure,
currentPressure: item.device.currentPressure,
patientPhone: item.device.patient.phone,
taskId: item.task.id,
status: item.task.status,
createdAt: item.task.createdAt,
completionMaterials: Array.isArray(item.task.completionMaterials)
? item.task.completionMaterials
: [],
hospital: item.task.hospital,
creator: item.task.creator,
engineer: item.task.engineer,
patient: item.device.patient,
surgery: item.device.surgery,
device: {
id: item.device.id,
implantModel: item.device.implantModel,
implantManufacturer: item.device.implantManufacturer,
implantName: item.device.implantName,
},
})),
total,
page,
pageSize,
};
}
/**
* 发布任务:管理员或临床角色创建主任务与明细,等待本院工程师接收。
*/
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
this.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
]);
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}`);
}
return item.deviceId;
}),
),
);
const scopedHospitalId = this.resolveScopedHospitalId(actor);
const devices = await this.prisma.device.findMany({
where: {
id: { in: deviceIds },
status: DeviceStatus.ACTIVE,
isAbandoned: false,
isPressureAdjustable: true,
patient: scopedHospitalId
? {
hospitalId: scopedHospitalId,
}
: undefined,
},
select: {
id: true,
currentPressure: true,
patient: {
select: {
hospitalId: true,
},
},
implantCatalog: {
select: {
pressureLevels: true,
},
},
},
});
if (devices.length !== deviceIds.length) {
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
}
const hospitalId = this.resolveTaskHospitalId(
actor,
devices.map((device) => device.patient.hospitalId),
);
await this.assertNoDuplicateOpenTaskForDevices(deviceIds);
const pressureByDeviceId = new Map(
devices.map((device) => [device.id, device.currentPressure] as const),
);
const pressureLevelsByDeviceId = new Map(
devices.map((device) => [
device.id,
Array.isArray(device.implantCatalog?.pressureLevels)
? device.implantCatalog.pressureLevels
: [],
]),
);
dto.items.forEach((item) => {
const normalizedTargetPressure = this.normalizeTargetPressure(
item.targetPressure,
);
const pressureLevels = pressureLevelsByDeviceId.get(item.deviceId) ?? [];
if (
pressureLevels.length > 0 &&
!pressureLevels.includes(normalizedTargetPressure)
) {
throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID);
}
item.targetPressure = normalizedTargetPressure;
});
const task = await this.prisma.task.create({
data: {
status: TaskStatus.PENDING,
creatorId: actor.id,
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;
}
/**
* 接收任务:工程师接收本院待处理任务,任务一旦被接收不可重复抢单。
*/
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,
engineerId: true,
},
});
if (!task) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (task.status !== TaskStatus.PENDING) {
if (task.engineerId && task.engineerId !== actor.id) {
throw new ConflictException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
}
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
}
const claimResult = await this.prisma.task.updateMany({
where: {
id: task.id,
hospitalId,
status: TaskStatus.PENDING,
engineerId: null,
},
data: {
status: TaskStatus.ACCEPTED,
engineerId: actor.id,
},
});
if (claimResult.count !== 1) {
const latestTask = await this.prisma.task.findUnique({
where: { id: task.id },
select: {
status: true,
engineerId: true,
},
});
if (latestTask?.engineerId && latestTask.engineerId !== actor.id) {
throw new ConflictException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
}
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
}
const acceptedTask = await this.prisma.task.findUnique({
where: { id: task.id },
include: { items: true },
});
if (!acceptedTask) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
await this.eventEmitter.emitAsync('task.accepted', {
taskId: acceptedTask.id,
hospitalId: acceptedTask.hospitalId,
actorId: actor.id,
status: acceptedTask.status,
});
return acceptedTask;
}
/**
* 完成任务:工程师将任务置为 COMPLETED并同步设备当前压力。
*/
async completeTask(actor: ActorContext, dto: CompleteTaskDto) {
this.assertRole(actor, [Role.ENGINEER]);
const hospitalId = this.requireHospitalId(actor);
const completionMaterials = await this.normalizeCompletionMaterials(
hospitalId,
dto.completionMaterials,
);
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,
completionMaterials,
},
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;
}
/**
* 取消任务:
* 1. 创建者可将 PENDING/ACCEPTED 任务真正取消为 CANCELLED
* 2. 接收工程师可将自己已接收任务退回为 PENDING供其他工程师重新接收。
*/
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
this.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
Role.ENGINEER,
]);
const scopedHospitalId = this.resolveScopedHospitalId(actor);
const task = await this.prisma.task.findFirst({
where: {
id: dto.taskId,
hospitalId: scopedHospitalId ?? undefined,
},
select: {
id: true,
status: true,
creatorId: true,
engineerId: true,
hospitalId: true,
},
});
if (!task) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (actor.role === Role.ENGINEER) {
if (task.engineerId !== actor.id) {
throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_ASSIGNEE);
}
if (task.status !== TaskStatus.ACCEPTED) {
throw new ConflictException(MESSAGES.TASK.CANCEL_ONLY_PENDING_ACCEPTED);
}
const releasedTask = await this.prisma.task.update({
where: { id: task.id },
data: {
status: TaskStatus.PENDING,
engineerId: null,
},
include: { items: true },
});
await this.eventEmitter.emitAsync('task.released', {
taskId: releasedTask.id,
hospitalId: releasedTask.hospitalId,
actorId: actor.id,
status: releasedTask.status,
reason: dto.reason?.trim() || null,
});
return releasedTask;
} else 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,
// 当前库表未持久化取消原因,但先透传到事件层,方便通知链路后续接入。
reason: dto.reason?.trim() || null,
});
return cancelledTask;
}
/**
* 校验角色权限。
*/
private async findTaskRecordItemWithScope(
actor: ActorContext,
id: number,
) {
const item = await this.prisma.taskItem.findUnique({
where: { id },
select: TASK_RECORD_DETAIL_SELECT,
});
if (!item) {
throw new NotFoundException(MESSAGES.TASK.RECORD_NOT_FOUND);
}
this.assertTaskRecordScope(actor, item);
return item;
}
private assertTaskRecordScope(
actor: ActorContext,
item: Prisma.TaskItemGetPayload<{
select: typeof TASK_RECORD_DETAIL_SELECT;
}>,
) {
const patient = item.device.patient;
switch (actor.role) {
case Role.SYSTEM_ADMIN:
return;
case Role.HOSPITAL_ADMIN:
case Role.ENGINEER: {
const hospitalId = this.requireHospitalId(actor);
if (hospitalId !== item.task.hospitalId) {
throw new NotFoundException(MESSAGES.TASK.RECORD_NOT_FOUND);
}
return;
}
case Role.DOCTOR:
if (patient.doctorId !== actor.id) {
throw new NotFoundException(MESSAGES.TASK.RECORD_NOT_FOUND);
}
return;
case Role.LEADER:
if (!actor.groupId) {
throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED);
}
if (patient.doctor?.groupId !== actor.groupId) {
throw new NotFoundException(MESSAGES.TASK.RECORD_NOT_FOUND);
}
return;
case Role.DIRECTOR:
if (!actor.departmentId) {
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
}
if (patient.doctor?.departmentId !== actor.departmentId) {
throw new NotFoundException(MESSAGES.TASK.RECORD_NOT_FOUND);
}
return;
default:
throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN);
}
}
private mapTaskRecordItemDetail(
item: Prisma.TaskItemGetPayload<{
select: typeof TASK_RECORD_DETAIL_SELECT;
}>,
) {
return {
id: item.id,
oldPressure: item.oldPressure,
targetPressure: item.targetPressure,
task: {
id: item.task.id,
status: item.task.status,
createdAt: item.task.createdAt,
creatorId: item.task.creatorId,
engineerId: item.task.engineerId,
hospitalId: item.task.hospitalId,
completionMaterials: Array.isArray(item.task.completionMaterials)
? item.task.completionMaterials
: [],
},
patient: item.device.patient,
surgery: item.device.surgery,
device: {
id: item.device.id,
currentPressure: item.device.currentPressure,
status: item.device.status,
isAbandoned: item.device.isAbandoned,
implantModel: item.device.implantModel,
implantManufacturer: item.device.implantManufacturer,
implantName: item.device.implantName,
isPressureAdjustable: item.device.isPressureAdjustable,
pressureLevels: Array.isArray(item.device.implantCatalog?.pressureLevels)
? item.device.implantCatalog.pressureLevels
: [],
},
};
}
private assertRole(actor: ActorContext, allowedRoles: Role[]) {
if (!allowedRoles.includes(actor.role)) {
throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN);
}
}
/**
* 返回角色可见的医院范围。系统管理员可不绑定医院,按目标设备自动归院。
*/
private resolveScopedHospitalId(actor: ActorContext): number | null {
if (actor.role === Role.SYSTEM_ADMIN) {
return actor.hospitalId ?? null;
}
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
/**
* 解析本次任务归属医院,确保同一批设备不跨院。
*/
private resolveTaskHospitalId(
actor: ActorContext,
hospitalIds: number[],
): number {
const uniqueHospitalIds = Array.from(
new Set(hospitalIds.filter((hospitalId) => Number.isInteger(hospitalId))),
);
if (uniqueHospitalIds.length !== 1) {
throw new BadRequestException(MESSAGES.TASK.DEVICE_MULTI_HOSPITAL);
}
const [hospitalId] = uniqueHospitalIds;
if (actor.hospitalId && actor.hospitalId !== hospitalId) {
throw new ForbiddenException(MESSAGES.TASK.DEVICE_NOT_FOUND);
}
return hospitalId;
}
/**
* 解析工程师指派范围。系统管理员可显式指定医院,其余角色固定本院。
*/
private resolveAssignableHospitalId(
actor: ActorContext,
requestedHospitalId?: number,
) {
if (actor.role === Role.SYSTEM_ADMIN) {
if (requestedHospitalId !== undefined) {
return requestedHospitalId;
}
return this.requireHospitalId(actor);
}
return this.requireHospitalId(actor);
}
/**
* 列表查询的医院范围解析。系统管理员可按查询条件切院,其余角色固定本院。
*/
private resolveListHospitalId(
actor: ActorContext,
requestedHospitalId?: number,
) {
if (actor.role === Role.SYSTEM_ADMIN) {
return requestedHospitalId ?? actor.hospitalId ?? undefined;
}
return this.requireHospitalId(actor);
}
/**
* 构造调压记录查询条件。
*/
private buildTaskRecordWhere(
actor: ActorContext,
query: TaskRecordQueryDto,
hospitalId?: number,
): Prisma.TaskItemWhereInput {
const keyword = query.keyword?.trim();
const patientScope = this.buildTaskPatientScope(actor);
const where: Prisma.TaskItemWhereInput = {
task: {
hospitalId,
status: query.status,
},
device: patientScope
? {
patient: patientScope,
}
: undefined,
};
if (!keyword) {
return where;
}
where.OR = [
{
device: {
patient: {
name: {
contains: keyword,
mode: 'insensitive',
},
},
},
},
{
device: {
patient: {
inpatientNo: {
contains: keyword,
mode: 'insensitive',
},
},
},
},
{
device: {
patient: {
phone: {
contains: keyword,
mode: 'insensitive',
},
},
},
},
{
device: {
implantName: {
contains: keyword,
mode: 'insensitive',
},
},
},
{
device: {
implantModel: {
contains: keyword,
mode: 'insensitive',
},
},
},
];
return where;
}
/**
* 按角色构造任务列表中的患者可见范围,保持与患者列表一致。
*/
private buildTaskPatientScope(actor: ActorContext): Prisma.PatientWhereInput | null {
switch (actor.role) {
case Role.SYSTEM_ADMIN:
case Role.HOSPITAL_ADMIN:
case Role.ENGINEER:
return null;
case Role.DOCTOR:
return {
doctorId: actor.id,
};
case Role.LEADER:
if (!actor.groupId) {
throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED);
}
return {
doctor: {
groupId: actor.groupId,
},
};
case Role.DIRECTOR:
if (!actor.departmentId) {
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
}
return {
doctor: {
departmentId: actor.departmentId,
},
};
default:
throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN);
}
}
/**
* 调压目标挡位标准化。
*/
private normalizeTargetPressure(value: unknown) {
return normalizePressureLabel(value, 'targetPressure');
}
/**
* 完成任务凭证标准化:仅允许当前医院下的图片/视频上传资产。
*/
private async normalizeCompletionMaterials(
hospitalId: number,
materials: CompleteTaskDto['completionMaterials'],
): Promise<Prisma.InputJsonArray> {
if (!Array.isArray(materials) || materials.length === 0) {
throw new BadRequestException(MESSAGES.TASK.COMPLETE_MATERIALS_REQUIRED);
}
const assetIds = Array.from(
new Set(
materials.map((material) => {
const assetId = Number(material.assetId);
if (!Number.isInteger(assetId) || assetId <= 0) {
throw new BadRequestException('assetId 必须是整数');
}
return assetId;
}),
),
);
const assets = await this.prisma.uploadAsset.findMany({
where: {
id: { in: assetIds },
hospitalId,
type: { in: [UploadAssetType.IMAGE, UploadAssetType.VIDEO] },
},
select: {
id: true,
type: true,
url: true,
originalName: true,
},
});
if (assets.length !== assetIds.length) {
throw new BadRequestException(
MESSAGES.TASK.COMPLETE_MATERIAL_TYPE_INVALID,
);
}
const assetMap = new Map(assets.map((asset) => [asset.id, asset]));
return materials.map((material) => {
const asset = assetMap.get(material.assetId);
if (
!asset ||
(asset.type !== UploadAssetType.IMAGE &&
asset.type !== UploadAssetType.VIDEO)
) {
throw new BadRequestException(
MESSAGES.TASK.COMPLETE_MATERIAL_TYPE_INVALID,
);
}
return {
assetId: asset.id,
type: asset.type,
url: asset.url,
name:
typeof material.name === 'string' && material.name.trim()
? material.name.trim()
: asset.originalName,
};
}) as Prisma.InputJsonArray;
}
/**
* 工程师侧任务流转仍要求明确的院内身份。
*/
private requireHospitalId(actor: ActorContext): number {
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
/**
* 当前设备存在待接收/已接收任务时,禁止重复发布。
*/
private async assertNoDuplicateOpenTaskForDevices(deviceIds: number[]) {
if (deviceIds.length === 0) {
return;
}
const duplicateOpenTask = await this.prisma.taskItem.findFirst({
where: {
task: {
status: {
in: [TaskStatus.PENDING, TaskStatus.ACCEPTED],
},
},
deviceId: {
in: deviceIds,
},
},
select: { id: true },
});
if (duplicateOpenTask) {
throw new ConflictException(MESSAGES.TASK.DUPLICATE_DEVICE_OPEN_TASK);
}
}
}