diff --git a/src/common/messages.ts b/src/common/messages.ts index 9c8a671..c8343a9 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -74,6 +74,9 @@ export const MESSAGES = { }, TASK: { + RECORD_NOT_FOUND: '调压记录不存在或无权限访问', + UPDATE_ONLY_PENDING: '仅待处理调压记录可编辑', + DELETE_ONLY_PENDING_CANCELLED: '仅待处理/已取消调压记录可删除', ITEMS_REQUIRED: '任务明细 items 不能为空', DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在', DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院', diff --git a/src/patients/patient-lifecycle.util.ts b/src/patients/patient-lifecycle.util.ts index 2195f39..a354b5a 100644 --- a/src/patients/patient-lifecycle.util.ts +++ b/src/patients/patient-lifecycle.util.ts @@ -63,6 +63,7 @@ export function buildPatientLifecyclePatient(patient: PatientLifecycleSource) { id: toJsonNumber(patient.id), name: patient.name, inpatientNo: patient.inpatientNo, + phone: patient.phone, projectName: patient.projectName, }; } diff --git a/src/tasks/b-tasks/b-tasks.controller.ts b/src/tasks/b-tasks/b-tasks.controller.ts index c4bc48a..339b091 100644 --- a/src/tasks/b-tasks/b-tasks.controller.ts +++ b/src/tasks/b-tasks/b-tasks.controller.ts @@ -1,4 +1,13 @@ -import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + Post, + Query, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -19,9 +28,6 @@ import { CancelTaskDto } from '../dto/cancel-task.dto.js'; import { TaskRecordQueryDto } from '../dto/task-record-query.dto.js'; import { AssignableEngineerQueryDto } from '../dto/assignable-engineer-query.dto.js'; -/** - * B 端任务控制器:封装调压任务状态流转接口。 - */ @ApiTags('调压任务(B端)') @ApiBearerAuth('bearer') @Controller('b/tasks') @@ -29,9 +35,6 @@ import { AssignableEngineerQueryDto } from '../dto/assignable-engineer-query.dto export class BTasksController { constructor(private readonly taskService: TaskService) {} - /** - * 查询当前角色可见的医院工程师列表。 - */ @Get('engineers') @Roles( Role.SYSTEM_ADMIN, @@ -53,9 +56,6 @@ export class BTasksController { return this.taskService.findAssignableEngineers(actor, query.hospitalId); } - /** - * 查询当前角色可见的调压记录列表。 - */ @Get() @Roles( Role.SYSTEM_ADMIN, @@ -78,9 +78,28 @@ export class BTasksController { return this.taskService.findTaskRecords(actor, query); } - /** - * 系统管理员/医院管理员/医生/主任/组长发布调压任务。 - */ + @Get('items/:id') + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DOCTOR, + Role.DIRECTOR, + Role.LEADER, + Role.ENGINEER, + ) + @ApiOperation({ summary: '查询单条调压记录详情' }) + @ApiQuery({ + name: 'id', + required: true, + description: '调压记录 ID (taskItem.id)', + }) + findRecordItem( + @CurrentActor() actor: ActorContext, + @Param('id', ParseIntPipe) id: number, + ) { + return this.taskService.getTaskRecordItem(actor, id); + } + @Post('publish') @Roles( Role.SYSTEM_ADMIN, @@ -96,9 +115,6 @@ export class BTasksController { return this.taskService.publishTask(actor, dto); } - /** - * 工程师接收调压任务。 - */ @Post('accept') @Roles(Role.ENGINEER) @ApiOperation({ summary: '接收任务(ENGINEER)' }) @@ -106,9 +122,6 @@ export class BTasksController { return this.taskService.acceptTask(actor, dto); } - /** - * 工程师完成调压任务。 - */ @Post('complete') @Roles(Role.ENGINEER) @ApiOperation({ summary: '完成任务(ENGINEER)' }) @@ -116,9 +129,6 @@ export class BTasksController { return this.taskService.completeTask(actor, dto); } - /** - * 系统管理员/医院管理员/医生/主任/组长可取消自己创建的任务;工程师可取消自己已接收的任务。 - */ @Post('cancel') @Roles( Role.SYSTEM_ADMIN, diff --git a/src/tasks/task.service.ts b/src/tasks/task.service.ts index 010a12e..805b33b 100644 --- a/src/tasks/task.service.ts +++ b/src/tasks/task.service.ts @@ -23,6 +23,64 @@ 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; + /** * 任务服务:封装调压任务状态机、院内隔离与事件发布逻辑。 */ @@ -67,6 +125,20 @@ export class TaskService { }); } + 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); + } + /** * 查询当前角色可见的调压记录列表。 */ @@ -530,6 +602,106 @@ export class TaskService { /** * 校验角色权限。 */ + 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); diff --git a/tyt-admin/src/views/patients/Patients.vue b/tyt-admin/src/views/patients/Patients.vue index 2927e49..50af6d6 100644 --- a/tyt-admin/src/views/patients/Patients.vue +++ b/tyt-admin/src/views/patients/Patients.vue @@ -632,6 +632,16 @@ > 调压 + + 删除设备 + @@ -806,7 +816,7 @@ import { updatePatient, updatePatientSurgery, } from '../../api/patients'; -import { getImplantCatalogs } from '../../api/devices'; +import { deleteDevice, getImplantCatalogs } from '../../api/devices'; import { getDictionaries } from '../../api/dictionaries'; import { publishTask } from '../../api/tasks'; import { @@ -847,6 +857,11 @@ const canPublishAdjustTask = computed(() => userStore.role, ), ); +const canDeleteDeviceAction = computed(() => + ['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DOCTOR', 'DIRECTOR', 'LEADER'].includes( + userStore.role, + ), +); const loading = ref(false); const submitLoading = ref(false); @@ -891,6 +906,7 @@ const detailTab = ref('profile'); const detailPatient = ref(null); const detailLifecycle = ref([]); const detailAdjustDeviceId = ref(null); +const deletingDeviceId = ref(null); const patientForm = reactive({ name: '', @@ -1379,6 +1395,15 @@ function formatAdjustDeviceLabel(device) { ].join(' | '); } +function formatDeviceDetailLabel(device) { + return ( + device?.implantName || + device?.implantModel || + device?.implantManufacturer || + `设备 ${device?.id || ''}` + ); +} + function formatDateTime(value) { if (!value) { return '-'; @@ -1934,6 +1959,37 @@ async function handleSubmitAdjustTask() { } } +async function handleDeleteDevice(device) { + if (!detailPatient.value?.id || !device?.id || deletingDeviceId.value) { + return; + } + + try { + await ElMessageBox.confirm( + `确定要删除设备“${formatDeviceDetailLabel(device)}”吗?`, + '删除设备', + { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning', + }, + ); + } catch { + return; + } + + deletingDeviceId.value = device.id; + try { + await deleteDevice(device.id); + ElMessage.success('设备已删除'); + await fetchData(); + await openDetailDialog({ id: detailPatient.value.id }); + detailTab.value = 'surgeries'; + } finally { + deletingDeviceId.value = null; + } +} + function handleDelete(row) { ElMessageBox.confirm(`确定要删除患者 "${row.name}" 吗?`, '警告', { confirmButtonText: '确定', @@ -1972,9 +2028,14 @@ async function openDetailDialog(row) { const fullLifecycle = Array.isArray(lifecycle?.lifecycle) ? lifecycle.lifecycle : []; - detailLifecycle.value = fullLifecycle.filter( - (item) => item.patient?.id === detail.id, - ); + detailLifecycle.value = fullLifecycle.filter((item) => { + const eventPatientId = item?.patient?.id; + if (eventPatientId == null || eventPatientId === '') { + return true; + } + + return Number(eventPatientId) === Number(detail.id); + }); const deviceOptions = buildDetailAdjustDeviceOptions( detail, detailLifecycle.value, diff --git a/tyt-admin/src/views/tasks/Tasks.vue b/tyt-admin/src/views/tasks/Tasks.vue index 629acc2..7682125 100644 --- a/tyt-admin/src/views/tasks/Tasks.vue +++ b/tyt-admin/src/views/tasks/Tasks.vue @@ -222,7 +222,12 @@ > 完成 - -