2. 扩展患者手术与材料模型,更新种子数据 3. 新增字典模块,增强设备植入目录管理能力 4. 重构患者后台服务与表单链路,统一权限与参数校验 5. 管理台新增字典页面并改造患者/设备页面与路由权限 6. 补充字典及相关领域 e2e 测试并更新文档"
711 lines
18 KiB
TypeScript
711 lines
18 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
}
|