1006 lines
26 KiB
TypeScript
1006 lines
26 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
}
|