394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
ForbiddenException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { Prisma } from '../../generated/prisma/client.js';
|
||
import { 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 { CreatePatientDto } from '../dto/create-patient.dto.js';
|
||
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
||
|
||
const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER];
|
||
|
||
/**
|
||
* B 端患者服务:承载院内可见性隔离与患者 CRUD。
|
||
*/
|
||
@Injectable()
|
||
export class BPatientsService {
|
||
constructor(private readonly prisma: PrismaService) {}
|
||
|
||
/**
|
||
* 查询当前角色可见患者列表。
|
||
*/
|
||
async findVisiblePatients(actor: ActorContext, requestedHospitalId?: number) {
|
||
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
|
||
const where = this.buildVisiblePatientWhere(actor, hospitalId);
|
||
|
||
return this.prisma.patient.findMany({
|
||
where,
|
||
include: {
|
||
hospital: { select: { id: true, name: true } },
|
||
doctor: { select: { id: true, name: true, role: true } },
|
||
devices: true,
|
||
},
|
||
orderBy: { id: 'desc' },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 查询当前角色可见归属人员列表(医生/主任/组长),用于患者表单选择。
|
||
*/
|
||
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.patient.create({
|
||
data: {
|
||
name: this.normalizeRequiredString(dto.name, 'name'),
|
||
phone: this.normalizePhone(dto.phone),
|
||
idCardHash: this.normalizeRequiredString(dto.idCardHash, 'idCardHash'),
|
||
hospitalId: doctor.hospitalId!,
|
||
doctorId: doctor.id,
|
||
},
|
||
include: {
|
||
hospital: { select: { id: true, name: true } },
|
||
doctor: { select: { id: true, name: true, role: true } },
|
||
devices: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 查询患者详情。
|
||
*/
|
||
async findPatientById(actor: ActorContext, id: number) {
|
||
const patient = await this.findPatientWithScope(id);
|
||
this.assertPatientScope(actor, patient);
|
||
return patient;
|
||
}
|
||
|
||
/**
|
||
* 更新患者信息。
|
||
*/
|
||
async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) {
|
||
const patient = await this.findPatientWithScope(id);
|
||
this.assertPatientScope(actor, patient);
|
||
|
||
const data: Prisma.PatientUpdateInput = {};
|
||
if (dto.name !== undefined) {
|
||
data.name = this.normalizeRequiredString(dto.name, 'name');
|
||
}
|
||
if (dto.phone !== undefined) {
|
||
data.phone = this.normalizePhone(dto.phone);
|
||
}
|
||
if (dto.idCardHash !== undefined) {
|
||
data.idCardHash = this.normalizeRequiredString(dto.idCardHash, 'idCardHash');
|
||
}
|
||
if (dto.doctorId !== undefined) {
|
||
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
|
||
data.doctor = { connect: { id: doctor.id } };
|
||
data.hospital = { connect: { id: doctor.hospitalId! } };
|
||
}
|
||
|
||
return this.prisma.patient.update({
|
||
where: { id: patient.id },
|
||
data,
|
||
include: {
|
||
hospital: { select: { id: true, name: true } },
|
||
doctor: { select: { id: true, name: true, role: true } },
|
||
devices: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 删除患者。
|
||
*/
|
||
async removePatient(actor: ActorContext, id: number) {
|
||
const patient = await this.findPatientWithScope(id);
|
||
this.assertPatientScope(actor, patient);
|
||
|
||
try {
|
||
return await this.prisma.patient.delete({
|
||
where: { id: patient.id },
|
||
include: {
|
||
hospital: { select: { id: true, name: true } },
|
||
doctor: { select: { id: true, name: true, role: true } },
|
||
devices: true,
|
||
},
|
||
});
|
||
} 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 必须为整数');
|
||
}
|
||
|
||
const patient = await this.prisma.patient.findUnique({
|
||
where: { id: patientId },
|
||
include: {
|
||
hospital: { select: { id: true, name: true } },
|
||
doctor: {
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
role: true,
|
||
hospitalId: true,
|
||
departmentId: true,
|
||
groupId: true,
|
||
},
|
||
},
|
||
devices: true,
|
||
},
|
||
});
|
||
|
||
if (!patient) {
|
||
throw new NotFoundException(MESSAGES.PATIENT.NOT_FOUND);
|
||
}
|
||
|
||
return patient;
|
||
}
|
||
|
||
/**
|
||
* 校验当前角色是否可操作该患者。
|
||
*/
|
||
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,
|
||
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) {
|
||
const where: Record<string, unknown> = { 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);
|
||
}
|
||
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 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 normalizePhone(phone: unknown) {
|
||
const normalized = this.normalizeRequiredString(phone, 'phone');
|
||
if (!/^1\d{10}$/.test(normalized)) {
|
||
throw new BadRequestException('phone 必须是合法手机号');
|
||
}
|
||
return normalized;
|
||
}
|
||
}
|