tyt-api-nest/src/devices/devices.service.ts
2026-04-03 10:01:21 +08:00

748 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
normalizePressureLabelList,
normalizePressureLabel,
} from '../common/pressure-level.util.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,
isValve: 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 patient = await this.resolveWritablePatient(actor, dto.patientId);
return this.prisma.device.create({
data: {
// 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。
currentPressure: '0',
status: dto.status ?? DeviceStatus.ACTIVE,
patientId: patient.id,
},
include: DEVICE_DETAIL_INCLUDE,
});
}
/**
* 更新设备:允许修改状态和归属患者;当前压力仅由任务完成时更新。
*/
async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) {
const current = await this.findOne(actor, id);
const data: Prisma.DeviceUpdateInput = {};
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.findRemovableDevice(actor, id);
const relatedTaskCount = await this.prisma.taskItem.count({
where: { deviceId: current.id },
});
if (relatedTaskCount > 0) {
throw new ConflictException(MESSAGES.DEVICE.DELETE_CONFLICT);
}
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 isValve = dto.isValve ?? true;
const isPressureAdjustable = isValve;
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'),
isValve,
pressureLevels: this.normalizePressureLevels(
dto.pressureLevels,
isValve,
),
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 nextIsValve = dto.isValve ?? current.isValve;
const nextIsPressureAdjustable = nextIsValve;
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.isValve !== undefined) {
data.isValve = dto.isValve;
data.isPressureAdjustable = nextIsPressureAdjustable;
}
if (dto.pressureLevels !== undefined || dto.isValve !== undefined) {
data.pressureLevels = this.normalizePressureLevels(
dto.pressureLevels ?? current.pressureLevels,
nextIsValve,
);
}
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: [
{
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 async findRemovableDevice(actor: ActorContext, id: number) {
const deviceId = this.toInt(id, 'id');
const device = await this.prisma.device.findUnique({
where: { id: deviceId },
select: {
id: true,
patient: {
select: {
hospitalId: true,
doctorId: true,
doctor: {
select: {
departmentId: true,
groupId: true,
},
},
},
},
},
});
if (!device) {
throw new NotFoundException(MESSAGES.DEVICE.NOT_FOUND);
}
this.assertDeviceRemovableScope(actor, device.patient);
return device;
}
private assertDeviceRemovableScope(
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 (patient.hospitalId !== this.requireActorHospitalId(actor)) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
return;
case Role.DIRECTOR:
if (
!actor.departmentId ||
patient.doctor.departmentId !== actor.departmentId
) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
return;
case Role.LEADER:
if (!actor.groupId || patient.doctor.groupId !== actor.groupId) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
return;
case Role.DOCTOR:
if (patient.doctorId !== actor.id) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
return;
default:
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();
}
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: unknown[] | undefined,
isValve: boolean,
) {
if (!isValve) {
return [];
}
const normalized = normalizePressureLabelList(
pressureLevels,
'pressureLevels',
);
if (normalized.length === 0) {
throw new BadRequestException(MESSAGES.DEVICE.VALVE_PRESSURE_REQUIRED);
}
return normalized;
}
/**
* 当前压力挡位标签标准化。
*/
private normalizePressure(value: unknown) {
try {
return normalizePressureLabel(value, 'currentPressure');
} catch {
throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID);
}
}
/**
* 设备状态枚举校验。
*/
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;
}
}