import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '../../generated/prisma/client.js'; import { DeviceStatus, Role } from '../../generated/prisma/enums.js'; import { PrismaService } from '../../prisma.service.js'; import type { ActorContext } from '../../common/actor-context.js'; import { MESSAGES } from '../../common/messages.js'; import { normalizePressureLabel } from '../../common/pressure-level.util.js'; import { CreatePatientDto } from '../dto/create-patient.dto.js'; import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js'; import { PatientQueryDto } from '../dto/patient-query.dto.js'; import { UpdatePatientSurgeryDto } from '../dto/update-patient-surgery.dto.js'; import { UpdatePatientDto } from '../dto/update-patient.dto.js'; import { buildPatientLifecyclePayload, PATIENT_LIFECYCLE_INCLUDE, } from '../patient-lifecycle.util.js'; import { normalizePatientIdCard } from '../patient-id-card.util.js'; type PrismaExecutor = Prisma.TransactionClient | PrismaService; const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]; const IMPLANT_CATALOG_SELECT = { id: true, modelCode: true, manufacturer: true, name: true, isValve: true, pressureLevels: true, isPressureAdjustable: true, notes: true, } as const; const PATIENT_LIST_INCLUDE = { hospital: { select: { id: true, name: true } }, doctor: { select: { id: true, name: true, role: true } }, creator: { select: { id: true, name: true, role: true } }, devices: { select: { id: true, status: true, currentPressure: true, initialPressure: true, isAbandoned: true, implantModel: true, implantManufacturer: true, implantName: true, isValve: true, isPressureAdjustable: true, }, orderBy: { id: 'desc' }, }, surgeries: { select: { id: true, surgeryDate: true, surgeryName: true, primaryDisease: true, hydrocephalusTypes: true, surgeonId: true, surgeonName: true, }, orderBy: { surgeryDate: 'desc' }, take: 1, }, _count: { select: { surgeries: true, }, }, } as const; const PATIENT_DETAIL_INCLUDE = { hospital: { select: { id: true, name: true } }, doctor: { select: { id: true, name: true, role: true, hospitalId: true, departmentId: true, groupId: true, }, }, creator: { select: { id: true, name: true, role: true, }, }, devices: { include: { implantCatalog: { select: IMPLANT_CATALOG_SELECT, }, surgery: { select: { id: true, surgeryDate: true, surgeryName: true, }, }, }, orderBy: { id: 'desc' }, }, surgeries: { include: { devices: { include: { implantCatalog: { select: IMPLANT_CATALOG_SELECT, }, }, orderBy: { id: 'desc' }, }, surgeon: { select: { id: true, name: true, role: true, }, }, }, orderBy: { surgeryDate: 'desc' }, }, } as const; const PATIENT_SURGERY_DETAIL_INCLUDE = { devices: { include: { implantCatalog: { select: IMPLANT_CATALOG_SELECT, }, }, orderBy: { id: 'desc' }, }, } as const; /** * B 端患者服务:承载院内可见性隔离、患者 CRUD 与手术档案录入。 */ @Injectable() export class BPatientsService { constructor(private readonly prisma: PrismaService) {} /** * 查询当前角色可见患者列表。 */ async findVisiblePatients(actor: ActorContext, query: PatientQueryDto = {}) { const hospitalId = this.resolveHospitalId(actor, query.hospitalId); const where = this.buildVisiblePatientWhere(actor, hospitalId, query); const page = query.page && query.page > 0 ? query.page : 1; const pageSize = query.pageSize && query.pageSize > 0 && query.pageSize <= 100 ? query.pageSize : 20; const [total, patients] = await this.prisma.$transaction([ this.prisma.patient.count({ where }), this.prisma.patient.findMany({ where, include: PATIENT_LIST_INCLUDE, orderBy: { id: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), ]); return { total, page, pageSize, list: patients.map((patient) => this.decoratePatientListItem(patient)), }; } /** * 查询当前角色可见归属人员列表(医生/主任/组长),用于患者表单选择。 */ async findVisibleDoctors(actor: ActorContext, requestedHospitalId?: number) { const hospitalId = this.resolveHospitalId(actor, requestedHospitalId); const where: Prisma.UserWhereInput = { role: { in: PATIENT_OWNER_ROLES }, hospitalId, }; switch (actor.role) { case Role.DOCTOR: where.id = actor.id; break; case Role.LEADER: if (!actor.groupId) { throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED); } where.groupId = actor.groupId; break; case Role.DIRECTOR: if (!actor.departmentId) { throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED); } where.departmentId = actor.departmentId; break; case Role.HOSPITAL_ADMIN: case Role.SYSTEM_ADMIN: break; default: throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN); } return this.prisma.user.findMany({ where, select: { id: true, name: true, phone: true, hospitalId: true, departmentId: true, groupId: true, role: true, }, orderBy: { id: 'desc' }, }); } /** * 创建患者,可选一并创建首台手术及植入设备。 */ async createPatient(actor: ActorContext, dto: CreatePatientDto) { const doctor = await this.resolveWritableDoctor(actor, dto.doctorId); return this.prisma.$transaction(async (tx) => { const patient = await tx.patient.create({ data: { name: this.normalizeRequiredString(dto.name, 'name'), creatorId: actor.id, inpatientNo: dto.inpatientNo === undefined ? undefined : this.normalizeNullableString(dto.inpatientNo, 'inpatientNo'), projectName: dto.projectName === undefined ? undefined : this.normalizeNullableString(dto.projectName, 'projectName'), phone: this.normalizePhone(dto.phone), // 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。 idCard: this.normalizeIdCard(dto.idCard), hospitalId: doctor.hospitalId!, doctorId: doctor.id, }, }); if (dto.initialSurgery) { await this.createPatientSurgeryRecord( tx, actor, patient.id, patient.doctorId, dto.initialSurgery, ); } const detail = await this.loadPatientDetail(tx, patient.id); return this.decoratePatientDetail(detail); }); } /** * 为患者新增一台手术,并支持弃用旧设备。 */ async createPatientSurgery( actor: ActorContext, patientId: number, dto: CreatePatientSurgeryDto, ) { const patient = await this.findPatientWithScope(patientId); this.assertPatientScope(actor, patient); return this.prisma.$transaction(async (tx) => { const createdSurgery = await this.createPatientSurgeryRecord( tx, actor, patient.id, patient.doctorId, dto, ); const detail = await this.loadPatientDetail(tx, patient.id); const decoratedPatient = this.decoratePatientDetail(detail); const created = decoratedPatient.surgeries.find( (surgery) => surgery.id === createdSurgery.id, ); if (!created) { throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND); } return created; }); } /** * 编辑患者既有手术记录,仅允许修改手术主信息与既有设备信息。 */ async updatePatientSurgery( actor: ActorContext, patientId: number, surgeryId: number, dto: UpdatePatientSurgeryDto, ) { const patient = await this.findPatientWithScope(patientId); this.assertPatientScope(actor, patient); return this.prisma.$transaction(async (tx) => { const currentSurgery = await this.findPatientSurgeryForUpdate( tx, patient.id, surgeryId, ); const updatedSurgery = await this.updatePatientSurgeryRecord( tx, actor, patient.id, patient.doctorId, currentSurgery, dto, ); const detail = await this.loadPatientDetail(tx, patient.id); const decoratedPatient = this.decoratePatientDetail(detail); const updated = decoratedPatient.surgeries.find( (surgery) => surgery.id === updatedSurgery.id, ); if (!updated) { throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND); } return updated; }); } /** * 查询患者详情。 */ async findPatientById(actor: ActorContext, id: number) { const patient = await this.findPatientWithScope(id); this.assertPatientScope(actor, patient); return this.decoratePatientDetail(patient); } async findPatientLifecycle(actor: ActorContext, id: number) { const patient = await this.prisma.patient.findUnique({ where: { id }, include: PATIENT_LIFECYCLE_INCLUDE, }); if (!patient) { throw new NotFoundException(MESSAGES.PATIENT.NOT_FOUND); } this.assertPatientScope(actor, patient); return buildPatientLifecyclePayload(patient); } /** * 更新患者基础信息。 */ async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) { const patient = await this.findPatientWithScope(id); this.assertPatientScope(actor, patient); if (dto.initialSurgery !== undefined) { throw new BadRequestException( MESSAGES.PATIENT.SURGERY_UPDATE_NOT_SUPPORTED, ); } const data: Prisma.PatientUpdateInput = {}; if (dto.name !== undefined) { data.name = this.normalizeRequiredString(dto.name, 'name'); } if (dto.inpatientNo !== undefined) { data.inpatientNo = this.normalizeNullableString( dto.inpatientNo, 'inpatientNo', ); } if (dto.projectName !== undefined) { data.projectName = this.normalizeNullableString( dto.projectName, 'projectName', ); } if (dto.phone !== undefined) { data.phone = this.normalizePhone(dto.phone); } if (dto.idCard !== undefined) { // 更新时沿用同一标准化逻辑,保证查询条件与落库格式一致。 data.idCard = this.normalizeIdCard(dto.idCard); } if (dto.doctorId !== undefined) { const doctor = await this.resolveWritableDoctor(actor, dto.doctorId); data.doctor = { connect: { id: doctor.id } }; data.hospital = { connect: { id: doctor.hospitalId! } }; } const updated = await this.prisma.patient.update({ where: { id: patient.id }, data, }); const detail = await this.loadPatientDetail(this.prisma, updated.id); return this.decoratePatientDetail(detail); } /** * 删除患者。 */ async removePatient(actor: ActorContext, id: number) { const patient = await this.findPatientWithScope(id); this.assertPatientScope(actor, patient); try { const deleted = await this.prisma.patient.delete({ where: { id: patient.id }, include: PATIENT_DETAIL_INCLUDE, }); return this.decoratePatientDetail(deleted); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2003' ) { throw new ConflictException(MESSAGES.PATIENT.DELETE_CONFLICT); } throw error; } } /** * 查询患者并附带医生组织信息,用于权限判定。 */ private async findPatientWithScope(id: number) { const patientId = Number(id); if (!Number.isInteger(patientId)) { throw new BadRequestException('id 必须为整数'); } return this.loadPatientDetail(this.prisma, patientId); } /** * 统一加载患者详情。 */ private async loadPatientDetail(prisma: PrismaExecutor, patientId: number) { const patient = await prisma.patient.findUnique({ where: { id: patientId }, include: PATIENT_DETAIL_INCLUDE, }); if (!patient) { throw new NotFoundException(MESSAGES.PATIENT.NOT_FOUND); } return patient; } private async findPatientSurgeryForUpdate( prisma: PrismaExecutor, patientId: number, surgeryId: number, ) { const normalizedSurgeryId = Number(surgeryId); if (!Number.isInteger(normalizedSurgeryId)) { throw new BadRequestException('surgeryId 必须为整数'); } const surgery = await prisma.patientSurgery.findFirst({ where: { id: normalizedSurgeryId, patientId, }, include: { devices: { select: { id: true, implantCatalogId: true, currentPressure: true, taskItems: { select: { id: true }, take: 1, }, }, orderBy: { id: 'desc' }, }, }, }); if (!surgery) { throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND); } return surgery; } /** * 校验当前角色是否可操作该患者。 */ private assertPatientScope( actor: ActorContext, patient: { hospitalId: number; doctorId: number; doctor: { departmentId: number | null; groupId: number | null }; }, ) { switch (actor.role) { case Role.SYSTEM_ADMIN: return; case Role.HOSPITAL_ADMIN: if (!actor.hospitalId || actor.hospitalId !== patient.hospitalId) { throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN); } return; case Role.DIRECTOR: if ( !actor.departmentId || patient.doctor.departmentId !== actor.departmentId ) { throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN); } return; case Role.LEADER: if (!actor.groupId || patient.doctor.groupId !== actor.groupId) { throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN); } return; case Role.DOCTOR: if (patient.doctorId !== actor.id) { throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN); } return; default: throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN); } } /** * 校验并返回当前角色可写的医生。 */ private async resolveWritableDoctor(actor: ActorContext, doctorId: number) { const normalizedDoctorId = Number(doctorId); if (!Number.isInteger(normalizedDoctorId)) { throw new BadRequestException('doctorId 必须为整数'); } const doctor = await this.prisma.user.findUnique({ where: { id: normalizedDoctorId }, select: { id: true, name: true, role: true, hospitalId: true, departmentId: true, groupId: true, }, }); if (!doctor) { throw new NotFoundException(MESSAGES.PATIENT.DOCTOR_NOT_FOUND); } if (!PATIENT_OWNER_ROLES.includes(doctor.role)) { throw new BadRequestException(MESSAGES.PATIENT.DOCTOR_ROLE_REQUIRED); } if (!doctor.hospitalId) { throw new BadRequestException(MESSAGES.PATIENT.DOCTOR_NOT_FOUND); } switch (actor.role) { case Role.SYSTEM_ADMIN: return doctor; case Role.HOSPITAL_ADMIN: if (!actor.hospitalId || doctor.hospitalId !== actor.hospitalId) { throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN); } return doctor; case Role.DIRECTOR: if (!actor.departmentId || doctor.departmentId !== actor.departmentId) { throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN); } return doctor; case Role.LEADER: if (!actor.groupId || doctor.groupId !== actor.groupId) { throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN); } return doctor; case Role.DOCTOR: if (doctor.id !== actor.id) { throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN); } return doctor; default: throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN); } } /** * 按角色构造患者可见性查询条件。 */ private buildVisiblePatientWhere( actor: ActorContext, hospitalId: number, query: PatientQueryDto = {}, ) { const where: Prisma.PatientWhereInput = { hospitalId }; switch (actor.role) { case Role.DOCTOR: where.doctorId = actor.id; break; case Role.LEADER: if (!actor.groupId) { throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED); } where.doctor = { groupId: actor.groupId, role: { in: PATIENT_OWNER_ROLES }, }; break; case Role.DIRECTOR: if (!actor.departmentId) { throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED); } where.doctor = { departmentId: actor.departmentId, role: { in: PATIENT_OWNER_ROLES }, }; break; case Role.HOSPITAL_ADMIN: case Role.SYSTEM_ADMIN: break; default: throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN); } if (query.doctorId != null) { const doctorId = this.toInt(query.doctorId, 'doctorId'); if (actor.role === Role.DOCTOR && doctorId !== actor.id) { throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN); } where.doctorId = doctorId; } const keyword = query.keyword?.trim(); if (keyword) { where.OR = [ { name: { contains: keyword, mode: 'insensitive', }, }, { phone: { contains: keyword, mode: 'insensitive', }, }, { idCard: { contains: keyword, mode: 'insensitive', }, }, { inpatientNo: { contains: keyword, mode: 'insensitive', }, }, { doctor: { name: { contains: keyword, mode: 'insensitive', }, }, }, { hospital: { name: { contains: keyword, mode: 'insensitive', }, }, }, { surgeries: { some: { surgeryName: { contains: keyword, mode: 'insensitive', }, }, }, }, ]; } return where; } /** * 解析 B 端查询 hospitalId:系统管理员必须显式指定医院。 */ private resolveHospitalId( actor: ActorContext, requestedHospitalId?: number, ): number { if (actor.role === Role.SYSTEM_ADMIN) { const normalizedHospitalId = requestedHospitalId; if ( normalizedHospitalId == null || !Number.isInteger(normalizedHospitalId) ) { throw new BadRequestException( MESSAGES.PATIENT.SYSTEM_ADMIN_HOSPITAL_REQUIRED, ); } return normalizedHospitalId; } if (!actor.hospitalId) { throw new BadRequestException(MESSAGES.PATIENT.ACTOR_HOSPITAL_REQUIRED); } return actor.hospitalId; } /** * 创建患者手术记录并写入设备快照。 */ private async createPatientSurgeryRecord( prisma: PrismaExecutor, actor: ActorContext, patientId: number, patientDoctorId: number, dto: CreatePatientSurgeryDto, ) { if (!Array.isArray(dto.devices) || dto.devices.length === 0) { throw new BadRequestException(MESSAGES.PATIENT.SURGERY_ITEMS_REQUIRED); } const catalogIds = Array.from( new Set( dto.devices.map((device) => this.toInt(device.implantCatalogId, 'implantCatalogId'), ), ), ); const abandonedDeviceIds = Array.from( new Set(dto.abandonedDeviceIds ?? []), ); const [catalogMap, latestSurgery, surgeon] = await Promise.all([ this.resolveImplantCatalogMap(prisma, catalogIds), prisma.patientSurgery.findFirst({ where: { patientId }, orderBy: { surgeryDate: 'desc' }, select: { surgeryDate: true }, }), this.resolveWritableDoctor(actor, patientDoctorId), ]); if (abandonedDeviceIds.length > 0) { const devices = await prisma.device.findMany({ where: { id: { in: abandonedDeviceIds }, patientId, }, select: { id: true }, }); if (devices.length !== abandonedDeviceIds.length) { throw new ForbiddenException( MESSAGES.PATIENT.ABANDON_DEVICE_SCOPE_FORBIDDEN, ); } } const deviceDrafts = dto.devices.map((device) => { const catalog = catalogMap.get(device.implantCatalogId); if (!catalog) { throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND); } const initialPressure = !catalog.isValve || device.initialPressure == null ? null : this.assertPressureLevelAllowed( catalog, this.normalizePressureLevel( device.initialPressure, 'initialPressure', ), ); const fallbackPressureLevel = catalog.isValve && catalog.pressureLevels.length > 0 ? catalog.pressureLevels[0] : '0'; const currentPressure = catalog.isValve ? this.assertPressureLevelAllowed( catalog, initialPressure ?? fallbackPressureLevel, ) : '0'; return { patient: { connect: { id: patientId } }, implantCatalog: { connect: { id: catalog.id } }, currentPressure, status: DeviceStatus.ACTIVE, implantModel: catalog.modelCode, implantManufacturer: catalog.manufacturer, implantName: catalog.name, isValve: catalog.isValve, isPressureAdjustable: catalog.isPressureAdjustable, isAbandoned: false, shuntMode: this.normalizeRequiredString(device.shuntMode, 'shuntMode'), proximalPunctureAreas: this.normalizeStringArray( device.proximalPunctureAreas, 'proximalPunctureAreas', ), valvePlacementSites: catalog.isValve ? this.normalizeStringArray( device.valvePlacementSites, 'valvePlacementSites', ) : this.normalizeOptionalStringArray( device.valvePlacementSites, 'valvePlacementSites', ), distalShuntDirection: this.normalizeRequiredString( device.distalShuntDirection, 'distalShuntDirection', ), initialPressure, implantNotes: device.implantNotes === undefined ? undefined : this.normalizeNullableString(device.implantNotes, 'implantNotes'), labelImageUrl: device.labelImageUrl === undefined ? undefined : this.normalizeNullableString( device.labelImageUrl, 'labelImageUrl', ), }; }); const surgery = await prisma.patientSurgery.create({ data: { patientId, surgeryDate: this.normalizeIsoDate(dto.surgeryDate, 'surgeryDate'), surgeryName: this.normalizeRequiredString( dto.surgeryName, 'surgeryName', ), surgeonId: surgeon.id, surgeonName: surgeon.name, preOpPressure: dto.preOpPressure == null ? null : this.normalizeNonNegativeInteger( dto.preOpPressure, 'preOpPressure', ), primaryDisease: this.normalizeRequiredString( dto.primaryDisease, 'primaryDisease', ), hydrocephalusTypes: this.normalizeStringArray( dto.hydrocephalusTypes, 'hydrocephalusTypes', ), previousShuntSurgeryDate: dto.previousShuntSurgeryDate ? this.normalizeIsoDate( dto.previousShuntSurgeryDate, 'previousShuntSurgeryDate', ) : (latestSurgery?.surgeryDate ?? null), preOpMaterials: dto.preOpMaterials == null ? undefined : this.normalizePreOpMaterials(dto.preOpMaterials), notes: dto.notes === undefined ? undefined : this.normalizeNullableString(dto.notes, 'notes'), devices: { create: deviceDrafts, }, }, include: PATIENT_SURGERY_DETAIL_INCLUDE, }); if (abandonedDeviceIds.length > 0) { await prisma.device.updateMany({ where: { id: { in: abandonedDeviceIds }, patientId, }, data: { isAbandoned: true, status: DeviceStatus.INACTIVE, }, }); } return surgery; } private async updatePatientSurgeryRecord( prisma: PrismaExecutor, actor: ActorContext, patientId: number, patientDoctorId: number, currentSurgery: Awaited< ReturnType >, dto: UpdatePatientSurgeryDto, ) { if ( Array.isArray(dto.abandonedDeviceIds) && dto.abandonedDeviceIds.length > 0 ) { throw new BadRequestException( MESSAGES.PATIENT.SURGERY_ABANDON_UPDATE_NOT_SUPPORTED, ); } const existingDeviceMap = new Map( currentSurgery.devices.map((device) => [device.id, device]), ); const requestedDeviceIds = dto.devices.map((device, index) => this.toInt(device.id, `devices[${index}].id`), ); const uniqueRequestedIds = Array.from(new Set(requestedDeviceIds)); if ( requestedDeviceIds.length !== currentSurgery.devices.length || uniqueRequestedIds.length !== currentSurgery.devices.length || uniqueRequestedIds.some((deviceId) => !existingDeviceMap.has(deviceId)) ) { throw new BadRequestException( MESSAGES.PATIENT.SURGERY_DEVICE_SET_UPDATE_NOT_SUPPORTED, ); } const catalogIds = Array.from( new Set( dto.devices.map((device) => this.toInt(device.implantCatalogId, 'implantCatalogId'), ), ), ); const [catalogMap, surgeon] = await Promise.all([ this.resolveImplantCatalogMap(prisma, catalogIds), this.resolveWritableDoctor(actor, patientDoctorId), ]); await prisma.patientSurgery.update({ where: { id: currentSurgery.id }, data: { surgeryDate: this.normalizeIsoDate(dto.surgeryDate, 'surgeryDate'), surgeryName: this.normalizeRequiredString( dto.surgeryName, 'surgeryName', ), surgeonId: surgeon.id, surgeonName: surgeon.name, preOpPressure: dto.preOpPressure == null ? null : this.normalizeNonNegativeInteger( dto.preOpPressure, 'preOpPressure', ), primaryDisease: this.normalizeRequiredString( dto.primaryDisease, 'primaryDisease', ), hydrocephalusTypes: this.normalizeStringArray( dto.hydrocephalusTypes, 'hydrocephalusTypes', ), previousShuntSurgeryDate: dto.previousShuntSurgeryDate ? this.normalizeIsoDate( dto.previousShuntSurgeryDate, 'previousShuntSurgeryDate', ) : null, preOpMaterials: dto.preOpMaterials == null ? Prisma.DbNull : this.normalizePreOpMaterials(dto.preOpMaterials), notes: dto.notes === undefined ? null : this.normalizeNullableString(dto.notes, 'notes'), }, }); for (const device of dto.devices) { const deviceId = this.toInt(device.id, 'devices.id'); const currentDevice = existingDeviceMap.get(deviceId); if (!currentDevice) { throw new BadRequestException( MESSAGES.PATIENT.SURGERY_DEVICE_SET_UPDATE_NOT_SUPPORTED, ); } const catalogId = this.toInt(device.implantCatalogId, 'implantCatalogId'); const catalog = catalogMap.get(catalogId); if (!catalog) { throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND); } const catalogChanged = currentDevice.implantCatalogId !== catalog.id; if (catalogChanged && currentDevice.taskItems.length > 0) { throw new ConflictException( MESSAGES.PATIENT.SURGERY_DEVICE_TASK_CONFLICT, ); } const initialPressure = !catalog.isValve || device.initialPressure == null ? null : this.assertPressureLevelAllowed( catalog, this.normalizePressureLevel( device.initialPressure, 'initialPressure', ), ); const currentPressure = catalogChanged ? this.resolveUpdatedSurgeryDeviceCurrentPressure( currentDevice.currentPressure, catalog, initialPressure, ) : currentDevice.currentPressure; await prisma.device.update({ where: { id: currentDevice.id, }, data: { patient: { connect: { id: patientId } }, surgery: { connect: { id: currentSurgery.id } }, implantCatalog: { connect: { id: catalog.id } }, currentPressure, implantModel: catalog.modelCode, implantManufacturer: catalog.manufacturer, implantName: catalog.name, isValve: catalog.isValve, isPressureAdjustable: catalog.isPressureAdjustable, shuntMode: this.normalizeRequiredString( device.shuntMode, 'shuntMode', ), proximalPunctureAreas: this.normalizeStringArray( device.proximalPunctureAreas, 'proximalPunctureAreas', ), valvePlacementSites: catalog.isValve ? this.normalizeStringArray( device.valvePlacementSites, 'valvePlacementSites', ) : this.normalizeOptionalStringArray( device.valvePlacementSites, 'valvePlacementSites', ), distalShuntDirection: this.normalizeRequiredString( device.distalShuntDirection, 'distalShuntDirection', ), initialPressure, implantNotes: device.implantNotes === undefined ? null : this.normalizeNullableString( device.implantNotes, 'implantNotes', ), labelImageUrl: device.labelImageUrl === undefined ? null : this.normalizeNullableString( device.labelImageUrl, 'labelImageUrl', ), }, }); } const updatedSurgery = await prisma.patientSurgery.findUnique({ where: { id: currentSurgery.id }, include: PATIENT_SURGERY_DETAIL_INCLUDE, }); if (!updatedSurgery) { throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND); } return updatedSurgery; } /** * 解析并校验植入物型号字典。 */ private async resolveImplantCatalogMap( prisma: PrismaExecutor, implantCatalogIds: number[], ) { if (implantCatalogIds.length === 0) { return new Map< number, Awaited> >(); } const catalogs = await prisma.implantCatalog.findMany({ where: { id: { in: implantCatalogIds }, }, select: IMPLANT_CATALOG_SELECT, }); if (catalogs.length !== implantCatalogIds.length) { throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND); } return new Map(catalogs.map((catalog) => [catalog.id, catalog])); } /** * 可调压植入物若配置了挡位,录入压力时必须命中其中一项。 */ private assertPressureLevelAllowed( catalog: { isValve: boolean; isPressureAdjustable: boolean; pressureLevels: string[]; }, pressure: string, ) { if ( catalog.isValve && catalog.isPressureAdjustable && Array.isArray(catalog.pressureLevels) && catalog.pressureLevels.length > 0 && !catalog.pressureLevels.includes(pressure) ) { throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID); } return pressure; } private resolveUpdatedSurgeryDeviceCurrentPressure( currentPressure: string, catalog: { isValve: boolean; isPressureAdjustable: boolean; pressureLevels: string[]; }, initialPressure: string | null, ) { if (!catalog.isValve) { return '0'; } const fallbackPressureLevel = catalog.pressureLevels.length > 0 ? catalog.pressureLevels[0] : '0'; const normalizedCurrentPressure = this.normalizePressureLevel( currentPressure, 'currentPressure', ); if ( catalog.isPressureAdjustable && catalog.pressureLevels.length > 0 && catalog.pressureLevels.includes(normalizedCurrentPressure) ) { return normalizedCurrentPressure; } if (!catalog.isPressureAdjustable) { return ( normalizedCurrentPressure || initialPressure || fallbackPressureLevel ); } return this.assertPressureLevelAllowed( catalog, initialPressure ?? fallbackPressureLevel, ); } /** * 将患者详情补全为前端可直接消费的结构。 */ private decoratePatientListItem( patient: Prisma.PatientGetPayload<{ include: typeof PATIENT_LIST_INCLUDE }>, ) { const latestSurgery = patient.surgeries[0] ?? null; const currentDevice = patient.devices.find( (device) => device.status === DeviceStatus.ACTIVE && !device.isAbandoned, ) ?? patient.devices.find((device) => !device.isAbandoned) ?? patient.devices[0] ?? null; return { id: patient.id, name: patient.name, inpatientNo: patient.inpatientNo, phone: patient.phone, idCard: patient.idCard, hospitalId: patient.hospitalId, doctorId: patient.doctorId, hospital: patient.hospital, doctor: patient.doctor, devices: patient.devices, primaryDisease: latestSurgery?.primaryDisease ?? null, hydrocephalusTypes: latestSurgery?.hydrocephalusTypes ?? [], surgeryDate: latestSurgery?.surgeryDate ?? null, currentPressure: currentDevice?.currentPressure ?? null, initialPressure: currentDevice?.initialPressure ?? null, shuntSurgeryCount: patient._count.surgeries, latestSurgery, activeDeviceCount: patient.devices.filter( (device) => device.status === DeviceStatus.ACTIVE && !device.isAbandoned, ).length, abandonedDeviceCount: patient.devices.filter( (device) => device.isAbandoned, ).length, }; } private decoratePatientDetail( patient: Awaited>, ) { const surgeries = this.decorateSurgeries(patient.surgeries); return { ...patient, surgeries, shuntSurgeryCount: surgeries.length, latestSurgery: surgeries[0] ?? null, activeDeviceCount: patient.devices.filter( (device) => device.status === DeviceStatus.ACTIVE && !device.isAbandoned, ).length, abandonedDeviceCount: patient.devices.filter( (device) => device.isAbandoned, ).length, }; } /** * 计算每次手术的自动分流手术次数。 */ private decorateSurgeries< TSurgery extends { id: number; surgeryDate: Date; devices: Array<{ id: number; status: DeviceStatus; isAbandoned: boolean; }>; }, >(surgeries: TSurgery[]) { const sortedAsc = [...surgeries].sort( (left, right) => new Date(left.surgeryDate).getTime() - new Date(right.surgeryDate).getTime(), ); const sequenceById = new Map( sortedAsc.map((surgery, index) => [surgery.id, index + 1] as const), ); return [...surgeries] .sort( (left, right) => new Date(right.surgeryDate).getTime() - new Date(left.surgeryDate).getTime(), ) .map((surgery) => ({ ...surgery, shuntSurgeryCount: sequenceById.get(surgery.id) ?? surgeries.length, activeDeviceCount: surgery.devices.filter( (device) => device.status === DeviceStatus.ACTIVE && !device.isAbandoned, ).length, abandonedDeviceCount: surgery.devices.filter( (device) => device.isAbandoned, ).length, })); } private normalizeRequiredString(value: unknown, fieldName: string) { if (typeof value !== 'string') { throw new BadRequestException(`${fieldName} 必须是字符串`); } const trimmed = value.trim(); if (!trimmed) { throw new BadRequestException(`${fieldName} 不能为空`); } return trimmed; } private normalizeNullableString(value: unknown, fieldName: string) { if (typeof value !== 'string') { throw new BadRequestException(`${fieldName} 必须是字符串`); } const trimmed = value.trim(); return trimmed || null; } private normalizePhone(phone: unknown) { const normalized = this.normalizeRequiredString(phone, 'phone'); if (!/^1\d{10}$/.test(normalized)) { throw new BadRequestException('phone 必须是合法手机号'); } return normalized; } /** * 统一整理身份证号,避免空格和末尾 x 大小写带来重复数据。 */ private normalizeIdCard(value: unknown) { const normalized = this.normalizeRequiredString(value, 'idCard'); return normalizePatientIdCard(normalized); } private normalizeIsoDate(value: unknown, fieldName: string) { const normalized = this.normalizeRequiredString(value, fieldName); const parsed = new Date(normalized); if (Number.isNaN(parsed.getTime())) { throw new BadRequestException(`${fieldName} 必须是合法日期`); } return parsed; } private normalizeNonNegativeInteger(value: unknown, fieldName: string) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 0) { throw new BadRequestException(`${fieldName} 必须是大于等于 0 的整数`); } return parsed; } private normalizePressureLevel(value: unknown, fieldName: string) { return normalizePressureLabel(value, fieldName); } private normalizeStringArray(value: unknown, fieldName: string) { if (!Array.isArray(value) || value.length === 0) { throw new BadRequestException(`${fieldName} 必须为非空数组`); } return Array.from( new Set( value.map((item) => this.normalizeRequiredString(item, fieldName)), ), ); } private normalizeOptionalStringArray(value: unknown, fieldName: string) { if (value == null) { return []; } if (!Array.isArray(value) || value.length === 0) { return []; } return Array.from( new Set( value.map((item) => this.normalizeRequiredString(item, fieldName)), ), ); } private normalizePreOpMaterials( materials: CreatePatientSurgeryDto['preOpMaterials'], ): Prisma.InputJsonArray { if (!Array.isArray(materials)) { throw new BadRequestException('preOpMaterials 必须是数组'); } return materials.map((material) => ({ type: this.normalizeRequiredString(material.type, 'type'), url: this.normalizeRequiredString(material.url, 'url'), name: material.name === undefined ? null : this.normalizeNullableString(material.name, 'name'), })) as Prisma.InputJsonArray; } private toInt(value: unknown, fieldName: string) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) { throw new BadRequestException(`${fieldName} 必须为正整数`); } return parsed; } }