tyt-api-nest/src/patients/b-patients/b-patients.service.ts
EL ab17204739 C端 miniapp 登录增加手机号唯一命中患者规则,命中多份档案返回 409
C端 my-lifecycle 由跨院多患者聚合改为单患者返回,结构统一为 patient + lifecycle
生命周期事件移除事件内重复 patient 字段,减少冗余
B端患者生命周期接口同步采用 patient + lifecycle 结构
新增并接入生命周期 Swagger 响应模型,补齐接口文档
更新 auth/patients/frontend 集成文档说明
增加 e2e:多患者冲突、C端/B端新返回结构、权限失败场景
2026-04-02 04:07:40 +08:00

1435 lines
40 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 { 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;
}
}