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 type { ActorContext } from '../common/actor-context.js'; import { MESSAGES } from '../common/messages.js'; import { PrismaService } from '../prisma.service.js'; import { CreateImplantCatalogDto } from './dto/create-implant-catalog.dto.js'; import { CreateDeviceDto } from './dto/create-device.dto.js'; import { DeviceQueryDto } from './dto/device-query.dto.js'; import { UpdateImplantCatalogDto } from './dto/update-implant-catalog.dto.js'; import { UpdateDeviceDto } from './dto/update-device.dto.js'; const CATALOG_SELECT = { id: true, modelCode: true, manufacturer: true, name: true, pressureLevels: true, isPressureAdjustable: true, notes: true, createdAt: true, updatedAt: true, } as const; const DEVICE_DETAIL_INCLUDE = { patient: { select: { id: true, name: true, inpatientNo: true, phone: true, hospitalId: true, hospital: { select: { id: true, name: true, }, }, doctor: { select: { id: true, name: true, role: true, }, }, }, }, surgery: { select: { id: true, surgeryDate: true, surgeryName: true, surgeonName: true, }, }, implantCatalog: { select: CATALOG_SELECT, }, _count: { select: { taskItems: true, }, }, } as const; /** * 设备服务:承载患者植入实例 CRUD 与全局植入物目录维护。 */ @Injectable() export class DevicesService { constructor(private readonly prisma: PrismaService) {} /** * 查询设备列表:系统管理员可跨院查询,院管仅限本院。 */ async findAll(actor: ActorContext, query: DeviceQueryDto) { this.assertAdmin(actor); const paging = this.resolvePaging(query); const scopedHospitalId = this.resolveScopedHospitalId( actor, query.hospitalId, ); const where = this.buildListWhere(query, scopedHospitalId); const [total, list] = await this.prisma.$transaction([ this.prisma.device.count({ where }), this.prisma.device.findMany({ where, include: DEVICE_DETAIL_INCLUDE, skip: paging.skip, take: paging.take, orderBy: { id: 'desc' }, }), ]); return { total, ...paging, list, }; } /** * 查询设备详情。 */ async findOne(actor: ActorContext, id: number) { this.assertAdmin(actor); const deviceId = this.toInt(id, 'id'); const device = await this.prisma.device.findUnique({ where: { id: deviceId }, include: DEVICE_DETAIL_INCLUDE, }); if (!device) { throw new NotFoundException(MESSAGES.DEVICE.NOT_FOUND); } this.assertDeviceReadable(actor, device.patient.hospitalId); return device; } /** * 创建设备:归属患者必须在当前管理员可写范围内。 */ async create(actor: ActorContext, dto: CreateDeviceDto) { this.assertAdmin(actor); const snCode = this.normalizeSnCode(dto.snCode); const patient = await this.resolveWritablePatient(actor, dto.patientId); await this.assertSnCodeUnique(snCode); return this.prisma.device.create({ data: { snCode, // 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。 currentPressure: 0, status: dto.status ?? DeviceStatus.ACTIVE, patientId: patient.id, }, include: DEVICE_DETAIL_INCLUDE, }); } /** * 更新设备:允许修改 SN、状态和归属患者;当前压力仅由任务完成时更新。 */ async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) { const current = await this.findOne(actor, id); const data: Prisma.DeviceUpdateInput = {}; if (dto.snCode !== undefined) { const snCode = this.normalizeSnCode(dto.snCode); await this.assertSnCodeUnique(snCode, current.id); data.snCode = snCode; } if (dto.status !== undefined) { data.status = this.normalizeStatus(dto.status); } if (dto.patientId !== undefined) { const patient = await this.resolveWritablePatient(actor, dto.patientId); data.patient = { connect: { id: patient.id } }; } return this.prisma.device.update({ where: { id: current.id }, data, include: DEVICE_DETAIL_INCLUDE, }); } /** * 删除设备:若设备已被任务明细引用,则返回 409。 */ async remove(actor: ActorContext, id: number) { const current = await this.findOne(actor, id); try { return await this.prisma.device.delete({ where: { id: current.id }, include: DEVICE_DETAIL_INCLUDE, }); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && (error.code === 'P2003' || error.code === 'P2014') ) { throw new ConflictException(MESSAGES.DEVICE.DELETE_CONFLICT); } throw error; } } /** * 查询当前角色可见的植入物型号字典。 */ async findCatalogs(actor: ActorContext, keyword?: string) { this.assertCatalogReadable(actor); const where = this.buildCatalogWhere(keyword); return this.prisma.implantCatalog.findMany({ where, select: CATALOG_SELECT, orderBy: [{ manufacturer: 'asc' }, { name: 'asc' }, { modelCode: 'asc' }], }); } /** * 新增植入物型号字典。 */ async createCatalog(actor: ActorContext, dto: CreateImplantCatalogDto) { this.assertSystemAdmin(actor); const isPressureAdjustable = dto.isPressureAdjustable ?? true; try { return await this.prisma.implantCatalog.create({ data: { modelCode: this.normalizeModelCode(dto.modelCode), manufacturer: this.normalizeRequiredString( dto.manufacturer, 'manufacturer', ), name: this.normalizeRequiredString(dto.name, 'name'), pressureLevels: this.normalizePressureLevels( dto.pressureLevels, isPressureAdjustable, ), isPressureAdjustable, notes: dto.notes === undefined ? undefined : this.normalizeNullableString(dto.notes, 'notes'), }, select: CATALOG_SELECT, }); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' ) { throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE); } throw error; } } /** * 更新植入物型号字典。 */ async updateCatalog( actor: ActorContext, id: number, dto: UpdateImplantCatalogDto, ) { this.assertSystemAdmin(actor); const current = await this.findWritableCatalog(id); const nextIsPressureAdjustable = dto.isPressureAdjustable ?? current.isPressureAdjustable; const data: Prisma.ImplantCatalogUpdateInput = {}; if (dto.modelCode !== undefined) { data.modelCode = this.normalizeModelCode(dto.modelCode); } if (dto.manufacturer !== undefined) { data.manufacturer = this.normalizeRequiredString( dto.manufacturer, 'manufacturer', ); } if (dto.name !== undefined) { data.name = this.normalizeRequiredString(dto.name, 'name'); } if (dto.isPressureAdjustable !== undefined) { data.isPressureAdjustable = dto.isPressureAdjustable; } if ( dto.pressureLevels !== undefined || dto.isPressureAdjustable !== undefined ) { data.pressureLevels = this.normalizePressureLevels( dto.pressureLevels ?? current.pressureLevels, nextIsPressureAdjustable, ); } if (dto.notes !== undefined) { data.notes = this.normalizeNullableString(dto.notes, 'notes'); } try { return await this.prisma.implantCatalog.update({ where: { id: current.id }, data, select: CATALOG_SELECT, }); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' ) { throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE); } throw error; } } /** * 删除植入物目录:若已被患者手术引用,则返回 409。 */ async removeCatalog(actor: ActorContext, id: number) { this.assertSystemAdmin(actor); const current = await this.findWritableCatalog(id); try { return await this.prisma.implantCatalog.delete({ where: { id: current.id }, select: CATALOG_SELECT, }); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && (error.code === 'P2003' || error.code === 'P2014') ) { throw new ConflictException(MESSAGES.DEVICE.CATALOG_DELETE_CONFLICT); } throw error; } } /** * 构造列表筛选:支持按医院、患者、状态和关键词组合查询。 */ private buildListWhere(query: DeviceQueryDto, scopedHospitalId?: number) { const andConditions: Prisma.DeviceWhereInput[] = []; const keyword = query.keyword?.trim(); if (scopedHospitalId != null) { andConditions.push({ patient: { is: { hospitalId: scopedHospitalId, }, }, }); } if (query.patientId != null) { andConditions.push({ patientId: query.patientId, }); } if (query.status != null) { andConditions.push({ status: query.status, }); } if (keyword) { andConditions.push({ OR: [ { snCode: { contains: keyword, mode: 'insensitive', }, }, { implantModel: { contains: keyword, mode: 'insensitive', }, }, { implantName: { contains: keyword, mode: 'insensitive', }, }, { patient: { is: { name: { contains: keyword, mode: 'insensitive', }, }, }, }, { patient: { is: { phone: { contains: keyword, }, }, }, }, ], }); } return andConditions.length > 0 ? { AND: andConditions } : {}; } /** * 构造型号字典查询条件。 */ private buildCatalogWhere(keyword?: string): Prisma.ImplantCatalogWhereInput { const andConditions: Prisma.ImplantCatalogWhereInput[] = []; const normalizedKeyword = keyword?.trim(); if (normalizedKeyword) { andConditions.push({ OR: [ { modelCode: { contains: normalizedKeyword, mode: 'insensitive', }, }, { manufacturer: { contains: normalizedKeyword, mode: 'insensitive', }, }, { name: { contains: normalizedKeyword, mode: 'insensitive', }, }, { notes: { contains: normalizedKeyword, mode: 'insensitive', }, }, ], }); } return andConditions.length > 0 ? { AND: andConditions } : {}; } /** * 解析列表分页。 */ private resolvePaging(query: DeviceQueryDto) { const page = query.page && query.page > 0 ? query.page : 1; const pageSize = query.pageSize && query.pageSize > 0 && query.pageSize <= 100 ? query.pageSize : 20; return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize, }; } /** * 解析当前查询实际生效的医院作用域。 */ private resolveScopedHospitalId( actor: ActorContext, hospitalId?: number, ): number | undefined { if (actor.role === Role.SYSTEM_ADMIN) { return hospitalId; } return this.requireActorHospitalId(actor); } /** * 读取并校验当前管理员可写的患者。 */ private async resolveWritablePatient(actor: ActorContext, patientId: number) { const normalizedPatientId = this.toInt( patientId, MESSAGES.DEVICE.PATIENT_REQUIRED, ); const patient = await this.prisma.patient.findUnique({ where: { id: normalizedPatientId }, select: { id: true, hospitalId: true, }, }); if (!patient) { throw new NotFoundException(MESSAGES.DEVICE.PATIENT_NOT_FOUND); } if ( actor.role === Role.HOSPITAL_ADMIN && patient.hospitalId !== this.requireActorHospitalId(actor) ) { throw new ForbiddenException(MESSAGES.DEVICE.PATIENT_SCOPE_FORBIDDEN); } return patient; } /** * 查询当前管理员可写的型号字典。 */ private async findWritableCatalog(id: number) { const catalogId = this.toInt(id, 'id'); const catalog = await this.prisma.implantCatalog.findUnique({ where: { id: catalogId }, select: CATALOG_SELECT, }); if (!catalog) { throw new NotFoundException(MESSAGES.DEVICE.CATALOG_NOT_FOUND); } return catalog; } /** * 校验当前用户是否可读/写该设备。 */ private assertDeviceReadable(actor: ActorContext, hospitalId: number) { if (actor.role === Role.SYSTEM_ADMIN) { return; } if (hospitalId !== this.requireActorHospitalId(actor)) { throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); } } /** * 管理员角色校验:仅系统管理员与院管可操作患者植入实例。 */ private assertAdmin(actor: ActorContext) { if ( actor.role !== Role.SYSTEM_ADMIN && actor.role !== Role.HOSPITAL_ADMIN ) { throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); } } /** * 型号字典读权限:B 端全部已登录角色可访问。 */ private assertCatalogReadable(actor: ActorContext) { if ( actor.role === Role.SYSTEM_ADMIN || actor.role === Role.HOSPITAL_ADMIN || actor.role === Role.DIRECTOR || actor.role === Role.LEADER || actor.role === Role.DOCTOR || actor.role === Role.ENGINEER ) { return; } throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); } /** * 全局植入物目录仅系统管理员可维护。 */ private assertSystemAdmin(actor: ActorContext) { if (actor.role !== Role.SYSTEM_ADMIN) { throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); } } /** * 型号编码标准化:统一去空白并转大写。 */ private normalizeModelCode(value: unknown) { return this.normalizeRequiredString(value, 'modelCode').toUpperCase(); } /** * 设备 SN 标准化:统一去空白并转大写,避免大小写重复。 */ private normalizeSnCode(value: unknown) { return this.normalizeRequiredString(value, 'snCode').toUpperCase(); } private normalizeRequiredString(value: unknown, fieldName: string) { if (typeof value !== 'string') { throw new BadRequestException(`${fieldName} 必须是字符串`); } const normalized = value.trim(); if (!normalized) { throw new BadRequestException(`${fieldName} 不能为空`); } return normalized; } private normalizeNullableString(value: unknown, fieldName: string) { if (typeof value !== 'string') { throw new BadRequestException(`${fieldName} 必须是字符串`); } const normalized = value.trim(); return normalized || null; } /** * 挡位列表标准化:去重、排序,并在非可调压目录下自动清空。 */ private normalizePressureLevels( pressureLevels: number[] | undefined, isPressureAdjustable: boolean, ) { if (!isPressureAdjustable) { return []; } if (!Array.isArray(pressureLevels) || pressureLevels.length === 0) { return []; } return Array.from( new Set( pressureLevels.map((level) => { const normalized = Number(level); if (!Number.isInteger(normalized) || normalized < 0) { throw new BadRequestException( 'pressureLevels 必须为大于等于 0 的整数数组', ); } return normalized; }), ), ).sort((left, right) => left - right); } /** * 压力值必须是非负整数。 */ private normalizePressure(value: unknown) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 0) { throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID); } return parsed; } /** * 设备状态枚举校验。 */ private normalizeStatus(value: unknown): DeviceStatus { if (!Object.values(DeviceStatus).includes(value as DeviceStatus)) { throw new BadRequestException(MESSAGES.DEVICE.STATUS_INVALID); } return value as DeviceStatus; } /** * 统一整数参数校验。 */ private toInt(value: unknown, message: string) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) { throw new BadRequestException(message); } return parsed; } /** * 当前登录上下文中的医院 ID 对院管是必填项。 */ private requireActorHospitalId(actor: ActorContext) { if ( typeof actor.hospitalId !== 'number' || !Number.isInteger(actor.hospitalId) || actor.hospitalId <= 0 ) { throw new BadRequestException(MESSAGES.DEVICE.ACTOR_HOSPITAL_REQUIRED); } return actor.hospitalId; } /** * 确保设备 SN 唯一;更新时允许命中自身。 */ private async assertSnCodeUnique(snCode: string, selfId?: number) { const existing = await this.prisma.device.findUnique({ where: { snCode }, select: { id: true }, }); if (existing && existing.id !== selfId) { throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE); } } }