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 { 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); } } }