C端 my-lifecycle 由跨院多患者聚合改为单患者返回,结构统一为 patient + lifecycle 生命周期事件移除事件内重复 patient 字段,减少冗余 B端患者生命周期接口同步采用 patient + lifecycle 结构 新增并接入生命周期 Swagger 响应模型,补齐接口文档 更新 auth/patients/frontend 集成文档说明 增加 e2e:多患者冲突、C端/B端新返回结构、权限失败场景
1435 lines
40 KiB
TypeScript
1435 lines
40 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 { 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<BPatientsService['findPatientSurgeryForUpdate']>
|
||
>,
|
||
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<ReturnType<typeof prisma.implantCatalog.findFirst>>
|
||
>();
|
||
}
|
||
|
||
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<ReturnType<BPatientsService['findPatientWithScope']>>,
|
||
) {
|
||
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;
|
||
}
|
||
}
|