improvement: v1 standalone treatment/case creation made possible.
This commit is contained in:
@@ -3,8 +3,10 @@ import {
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import {
|
||||
@@ -13,7 +15,8 @@ import {
|
||||
} from '../catalog/catalog-label.service';
|
||||
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
||||
import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
||||
import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto, CreateLabInternalCaseDto, UpdateLabInternalCaseDto } from './dto/cases.dto';
|
||||
import { generateLabCaseTasks } from './lab-case-task.generator';
|
||||
import {
|
||||
isLabCaseOverdue,
|
||||
} from '../../common/lab-case-due-date';
|
||||
@@ -32,6 +35,8 @@ const labCaseListInclude = {
|
||||
appointment: { select: { startAt: true } },
|
||||
},
|
||||
},
|
||||
partnerClinic: { select: { id: true, name: true } },
|
||||
lines: { orderBy: [{ sortOrder: 'asc' as const }] },
|
||||
details: {
|
||||
include: {
|
||||
detail: {
|
||||
@@ -73,6 +78,7 @@ const labCaseListInclude = {
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
detailClientKey: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -91,6 +97,8 @@ type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
|
||||
|
||||
@Injectable()
|
||||
export class CasesService {
|
||||
private readonly uploadRoot = join(process.cwd(), 'uploads', 'lab-cases');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||
@@ -130,6 +138,7 @@ export class CasesService {
|
||||
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
||||
},
|
||||
},
|
||||
partnerClinic: { select: { id: true, name: true } },
|
||||
tasks: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -140,7 +149,7 @@ export class CasesService {
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sentAt: 'desc' }],
|
||||
orderBy: [{ startedAt: 'desc' }, { sentAt: 'desc' }, { id: 'desc' }],
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
@@ -177,11 +186,9 @@ export class CasesService {
|
||||
|
||||
const [clinicRows, taskRows] = await Promise.all([
|
||||
this.prisma.labCase.findMany({
|
||||
where: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
where: this.visibleToLabWhere(labOrganizationId),
|
||||
select: {
|
||||
partnerClinic: { select: { id: true, name: true } },
|
||||
treatment: {
|
||||
select: {
|
||||
organization: { select: { id: true, name: true } },
|
||||
@@ -191,10 +198,7 @@ export class CasesService {
|
||||
}),
|
||||
this.prisma.labCaseTask.findMany({
|
||||
where: {
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
labCase: this.visibleStartedToLabWhere(labOrganizationId),
|
||||
},
|
||||
select: { prosthesisTypeCode: true },
|
||||
distinct: ['prosthesisTypeCode'],
|
||||
@@ -203,7 +207,12 @@ export class CasesService {
|
||||
|
||||
const clinicsById = new Map<string, { id: string; name: string }>();
|
||||
for (const row of clinicRows) {
|
||||
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
|
||||
if (row.treatment?.organization) {
|
||||
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
|
||||
}
|
||||
if (row.partnerClinic) {
|
||||
clinicsById.set(row.partnerClinic.id, row.partnerClinic);
|
||||
}
|
||||
}
|
||||
|
||||
const typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode));
|
||||
@@ -308,6 +317,322 @@ export class CasesService {
|
||||
};
|
||||
}
|
||||
|
||||
async listLinkedClinics(labOrganizationId: string, actorUserId: string) {
|
||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
||||
|
||||
const [linksA, linksB] = await Promise.all([
|
||||
this.prisma.organizationLink.findMany({
|
||||
where: { organizationAId: labOrganizationId, status: LinkStatus.ACTIVE },
|
||||
include: {
|
||||
organizationB: { select: { id: true, name: true, type: { select: { name: true } } } },
|
||||
},
|
||||
}),
|
||||
this.prisma.organizationLink.findMany({
|
||||
where: { organizationBId: labOrganizationId, status: LinkStatus.ACTIVE },
|
||||
include: {
|
||||
organizationA: { select: { id: true, name: true, type: { select: { name: true } } } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const data = [
|
||||
...linksA.map((l) => ({
|
||||
id: l.organizationB.id,
|
||||
name: l.organizationB.name,
|
||||
type: l.organizationB.type.name,
|
||||
})),
|
||||
...linksB.map((l) => ({
|
||||
id: l.organizationA.id,
|
||||
name: l.organizationA.name,
|
||||
type: l.organizationA.type.name,
|
||||
})),
|
||||
]
|
||||
.filter((o) => o.type === 'CLINIC')
|
||||
.map(({ id, name }) => ({ id, name, active: true }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
async createInternal(
|
||||
dto: CreateLabInternalCaseDto,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||
await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId);
|
||||
|
||||
const created = await this.prisma.labCase.create({
|
||||
data: {
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
referringClinicName: dto.referringClinicName?.trim() || null,
|
||||
referringDentistName: dto.referringDentistName?.trim() || null,
|
||||
patientDisplayName: dto.patientDisplayName?.trim() || null,
|
||||
patientDisplayMobile: dto.patientDisplayMobile?.trim() || null,
|
||||
partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
|
||||
sortOrder: 0,
|
||||
},
|
||||
include: labCaseListInclude,
|
||||
});
|
||||
|
||||
return { success: true, data: await this.mapLabCaseDetail(created, localeInput) };
|
||||
}
|
||||
|
||||
async updateInternal(
|
||||
labCaseId: string,
|
||||
dto: UpdateLabInternalCaseDto,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||
const existing = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
},
|
||||
select: { id: true, startedAt: true },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (existing.startedAt) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId);
|
||||
|
||||
const saved = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.labCase.update({
|
||||
where: { id: labCaseId },
|
||||
data: {
|
||||
referringClinicName: dto.referringClinicName?.trim() || null,
|
||||
referringDentistName: dto.referringDentistName?.trim() || null,
|
||||
patientDisplayName: dto.patientDisplayName?.trim() || null,
|
||||
patientDisplayMobile: dto.patientDisplayMobile?.trim() || null,
|
||||
partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null,
|
||||
dueDate:
|
||||
dto.dueDate === undefined ? undefined : dto.dueDate ? new Date(dto.dueDate) : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.lines) {
|
||||
const keepIds = dto.lines.map((l) => l.id).filter(Boolean) as string[];
|
||||
await tx.labCaseLine.deleteMany({
|
||||
where: {
|
||||
labCaseId,
|
||||
...(keepIds.length ? { id: { notIn: keepIds } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, line] of dto.lines.entries()) {
|
||||
if (line.id) {
|
||||
const owned = await tx.labCaseLine.findFirst({
|
||||
where: { id: line.id, labCaseId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) {
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
const teeth = normalizeTeeth(line.teeth);
|
||||
const row = line.id
|
||||
? await tx.labCaseLine.update({
|
||||
where: { id: line.id },
|
||||
data: {
|
||||
clientKey: line.clientId,
|
||||
sortOrder: index,
|
||||
teeth,
|
||||
toothSelectionGroups: (line.toothSelectionGroups ??
|
||||
undefined) as Prisma.InputJsonValue | undefined,
|
||||
comment: line.comment?.trim() || null,
|
||||
},
|
||||
})
|
||||
: await tx.labCaseLine.create({
|
||||
data: {
|
||||
labCaseId,
|
||||
clientKey: line.clientId,
|
||||
sortOrder: index,
|
||||
treatmentType: 'prosthesis',
|
||||
teeth,
|
||||
toothSelectionGroups: (line.toothSelectionGroups ??
|
||||
undefined) as Prisma.InputJsonValue | undefined,
|
||||
comment: line.comment?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.labCaseToothProsthesis.deleteMany({ where: { lineId: row.id } });
|
||||
if (line.toothProsthesis?.length) {
|
||||
for (const tp of line.toothProsthesis) {
|
||||
this.prosthesisCatalog.assertKnownProsthesisType(tp.prosthesisTypeCode);
|
||||
}
|
||||
await tx.labCaseToothProsthesis.createMany({
|
||||
data: line.toothProsthesis.map((tp) => ({
|
||||
labCaseId,
|
||||
lineId: row.id,
|
||||
sourceKey: row.id,
|
||||
treatmentType: 'prosthesis',
|
||||
tooth: tp.tooth,
|
||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||
selectionGroupId: tp.selectionGroupId?.trim() || '',
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (line.attachmentIds) {
|
||||
const lineAttachments = await tx.treatmentDetailAttachment.findMany({
|
||||
where: {
|
||||
detailClientKey: line.clientId,
|
||||
labCaseLinks: { some: { labCaseId } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const keep = new Set(line.attachmentIds);
|
||||
const removeIds = lineAttachments.map((a) => a.id).filter((id) => !keep.has(id));
|
||||
if (removeIds.length) {
|
||||
await tx.labCaseAttachment.deleteMany({
|
||||
where: { labCaseId, attachmentId: { in: removeIds } },
|
||||
});
|
||||
}
|
||||
if (line.attachmentIds.length) {
|
||||
await tx.labCaseAttachment.createMany({
|
||||
data: line.attachmentIds.map((attachmentId) => ({ labCaseId, attachmentId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.labCase.findFirstOrThrow({
|
||||
where: { id: labCaseId },
|
||||
include: labCaseListInclude,
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true, data: await this.mapLabCaseDetail(saved, localeInput) };
|
||||
}
|
||||
|
||||
async startInternal(
|
||||
labCaseId: string,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||
const existing = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
},
|
||||
include: { lines: true, toothProsthesis: true },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (existing.startedAt) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_ALREADY_STARTED, HttpStatus.CONFLICT);
|
||||
}
|
||||
if (existing.lines.length === 0 || existing.toothProsthesis.length === 0) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_START_INCOMPLETE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const hasClient =
|
||||
Boolean(existing.referringClinicName?.trim()) ||
|
||||
Boolean(existing.patientDisplayName?.trim()) ||
|
||||
Boolean(existing.partnerClinicOrganizationId);
|
||||
if (!hasClient) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_CLIENT_REQUIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.labCase.update({
|
||||
where: { id: labCaseId },
|
||||
data: { startedAt: new Date() },
|
||||
});
|
||||
await generateLabCaseTasks(tx, labCaseId, localeInput);
|
||||
});
|
||||
|
||||
const refreshed = await this.prisma.labCase.findFirstOrThrow({
|
||||
where: { id: labCaseId },
|
||||
include: labCaseListInclude,
|
||||
});
|
||||
return { success: true, data: await this.mapLabCaseDetail(refreshed, localeInput) };
|
||||
}
|
||||
|
||||
async uploadInternalAttachments(
|
||||
labCaseId: string,
|
||||
lineClientKey: string,
|
||||
files: Express.Multer.File[],
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||
const existing = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
},
|
||||
select: { id: true, startedAt: true },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (existing.startedAt) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT);
|
||||
}
|
||||
if (!lineClientKey?.trim()) {
|
||||
throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (!files?.length) {
|
||||
throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const orgDir = join(this.uploadRoot, labOrganizationId);
|
||||
mkdirSync(orgDir, { recursive: true });
|
||||
|
||||
const created: {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`;
|
||||
const storagePath = join(orgDir, storageName);
|
||||
const { writeFileSync } = await import('fs');
|
||||
writeFileSync(storagePath, file.buffer);
|
||||
|
||||
const attachment = await this.prisma.treatmentDetailAttachment.create({
|
||||
data: {
|
||||
detailClientKey: lineClientKey,
|
||||
fileName: file.originalname,
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
storagePath,
|
||||
},
|
||||
});
|
||||
await this.prisma.labCaseAttachment.create({
|
||||
data: { labCaseId, attachmentId: attachment.id },
|
||||
});
|
||||
created.push({
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType,
|
||||
sizeBytes: attachment.sizeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, data: created };
|
||||
}
|
||||
|
||||
async getOne(
|
||||
labCaseId: string,
|
||||
labOrganizationId: string,
|
||||
@@ -319,8 +644,7 @@ export class CasesService {
|
||||
const labCase = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...this.visibleToLabWhere(labOrganizationId),
|
||||
},
|
||||
include: labCaseListInclude,
|
||||
});
|
||||
@@ -344,10 +668,7 @@ export class CasesService {
|
||||
where: {
|
||||
labCaseId,
|
||||
attachmentId,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
labCase: this.visibleToLabWhere(labOrganizationId),
|
||||
},
|
||||
include: {
|
||||
attachment: { select: { storagePath: true, fileName: true, mimeType: true } },
|
||||
@@ -381,8 +702,7 @@ export class CasesService {
|
||||
const existing = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...this.visibleToLabWhere(labOrganizationId),
|
||||
},
|
||||
select: { id: true, isImportant: true },
|
||||
});
|
||||
@@ -432,8 +752,7 @@ export class CasesService {
|
||||
const existing = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...this.visibleToLabWhere(labOrganizationId),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -500,10 +819,7 @@ export class CasesService {
|
||||
where: {
|
||||
id: taskId,
|
||||
labCaseId,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
labCase: this.visibleStartedToLabWhere(labOrganizationId),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -590,20 +906,44 @@ export class CasesService {
|
||||
sentAtFilter.lte = to;
|
||||
}
|
||||
|
||||
return {
|
||||
const received: Prisma.LabCaseWhereInput = {
|
||||
sentAt: sentAtFilter,
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...(query.clinicOrganizationId
|
||||
? { treatment: { organizationId: query.clinicOrganizationId } }
|
||||
: {}),
|
||||
...(query.prosthesisTypeCode
|
||||
};
|
||||
|
||||
const ownedInternal: Prisma.LabCaseWhereInput = {
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
...(query.clinicOrganizationId
|
||||
? {
|
||||
tasks: {
|
||||
some: { prosthesisTypeCode: query.prosthesisTypeCode },
|
||||
},
|
||||
OR: [
|
||||
{ partnerClinicOrganizationId: query.clinicOrganizationId },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(query.q?.trim() ? this.buildSearchWhere(query.q.trim()) : {}),
|
||||
};
|
||||
|
||||
if (query.sentFrom || query.sentTo) {
|
||||
ownedInternal.startedAt = { ...sentAtFilter };
|
||||
}
|
||||
|
||||
return {
|
||||
AND: [
|
||||
{ OR: [received, ownedInternal] },
|
||||
...(query.prosthesisTypeCode
|
||||
? [
|
||||
{
|
||||
tasks: {
|
||||
some: { prosthesisTypeCode: query.prosthesisTypeCode },
|
||||
},
|
||||
} satisfies Prisma.LabCaseWhereInput,
|
||||
]
|
||||
: []),
|
||||
...(query.q?.trim() ? [this.buildSearchWhere(query.q.trim())] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -624,6 +964,10 @@ export class CasesService {
|
||||
organization: { name: { contains: q, mode: 'insensitive' } },
|
||||
},
|
||||
},
|
||||
{ referringClinicName: { contains: q, mode: 'insensitive' } },
|
||||
{ referringDentistName: { contains: q, mode: 'insensitive' } },
|
||||
{ patientDisplayName: { contains: q, mode: 'insensitive' } },
|
||||
{ patientDisplayMobile: { contains: q, mode: 'insensitive' } },
|
||||
];
|
||||
|
||||
const normalized = normalizeMobile(q);
|
||||
@@ -636,15 +980,49 @@ export class CasesService {
|
||||
return { OR: orConditions };
|
||||
}
|
||||
|
||||
private resolveClinicAndPatient(lc: {
|
||||
origin?: LabCaseOrigin;
|
||||
referringClinicName?: string | null;
|
||||
patientDisplayName?: string | null;
|
||||
patientDisplayMobile?: string | null;
|
||||
partnerClinic?: { id: string; name: string } | null;
|
||||
treatment?: {
|
||||
organization: { id: string; name: string };
|
||||
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
||||
} | null;
|
||||
}) {
|
||||
const clinic =
|
||||
lc.treatment?.organization ??
|
||||
lc.partnerClinic ?? {
|
||||
id: '',
|
||||
name: lc.referringClinicName?.trim() || '',
|
||||
};
|
||||
const displayName = lc.patientDisplayName?.trim() || '';
|
||||
const [firstName, ...rest] = displayName.split(/\s+/);
|
||||
const patient = lc.treatment?.patient ?? {
|
||||
id: '',
|
||||
firstName: firstName || displayName || clinic.name || '—',
|
||||
lastName: rest.join(' '),
|
||||
mobile: lc.patientDisplayMobile?.trim() || '',
|
||||
};
|
||||
return { clinic, patient };
|
||||
}
|
||||
|
||||
private mapLabCaseListItem(lc: {
|
||||
id: string;
|
||||
sentAt: Date | null;
|
||||
startedAt?: Date | null;
|
||||
dueDate: Date | null;
|
||||
isImportant: boolean;
|
||||
origin?: LabCaseOrigin;
|
||||
referringClinicName?: string | null;
|
||||
patientDisplayName?: string | null;
|
||||
patientDisplayMobile?: string | null;
|
||||
partnerClinic?: { id: string; name: string } | null;
|
||||
treatment: {
|
||||
organization: { id: string; name: string };
|
||||
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
||||
};
|
||||
} | null;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
status: LabTaskStatus;
|
||||
@@ -654,20 +1032,18 @@ export class CasesService {
|
||||
}) {
|
||||
const prosthesisGroups = this.buildProsthesisGroupsFromTasks(lc.tasks);
|
||||
const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length;
|
||||
const { clinic, patient } = this.resolveClinicAndPatient(lc);
|
||||
|
||||
return {
|
||||
id: lc.id,
|
||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||
startedAt: lc.startedAt?.toISOString() ?? null,
|
||||
origin: lc.origin ?? LabCaseOrigin.CLINIC_DISPATCH,
|
||||
dueDate: lc.dueDate?.toISOString() ?? null,
|
||||
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
|
||||
isImportant: lc.isImportant,
|
||||
clinic: lc.treatment.organization,
|
||||
patient: {
|
||||
id: lc.treatment.patient.id,
|
||||
firstName: lc.treatment.patient.firstName,
|
||||
lastName: lc.treatment.patient.lastName,
|
||||
mobile: lc.treatment.patient.mobile,
|
||||
},
|
||||
clinic,
|
||||
patient,
|
||||
prosthesisGroups,
|
||||
taskProgress: {
|
||||
completed: completedTasks,
|
||||
@@ -681,8 +1057,10 @@ export class CasesService {
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
const treatmentType = lc.details[0]?.detail.treatmentType ?? null;
|
||||
const treatmentType =
|
||||
lc.details[0]?.detail?.treatmentType ?? lc.lines[0]?.treatmentType ?? null;
|
||||
const link = lc.details[0];
|
||||
const { clinic, patient } = this.resolveClinicAndPatient(lc);
|
||||
const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
|
||||
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||
@@ -704,21 +1082,44 @@ export class CasesService {
|
||||
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
|
||||
isImportant: lc.isImportant,
|
||||
externalCode: lc.externalCode ?? null,
|
||||
origin: lc.origin,
|
||||
startedAt: lc.startedAt?.toISOString() ?? null,
|
||||
referringClinicName: lc.referringClinicName,
|
||||
referringDentistName: lc.referringDentistName,
|
||||
patientDisplayName: lc.patientDisplayName,
|
||||
patientDisplayMobile: lc.patientDisplayMobile,
|
||||
partnerClinicOrganizationId: lc.partnerClinicOrganizationId,
|
||||
shareUrl,
|
||||
clinic: lc.treatment.organization,
|
||||
patient: lc.treatment.patient,
|
||||
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
||||
clinic,
|
||||
patient,
|
||||
appointmentStartAt: lc.treatment?.appointment?.startAt?.toISOString() ?? null,
|
||||
treatmentType,
|
||||
detail: link
|
||||
detail: link?.detail
|
||||
? {
|
||||
id: link.detail.id,
|
||||
treatmentType: link.detail.treatmentType,
|
||||
teeth: normalizeTeeth(link.detail.teeth),
|
||||
comment: link.detail.comment,
|
||||
}
|
||||
: null,
|
||||
: lc.lines[0]
|
||||
? {
|
||||
id: lc.lines[0].id,
|
||||
treatmentType: lc.lines[0].treatmentType,
|
||||
teeth: normalizeTeeth(lc.lines[0].teeth),
|
||||
comment: lc.lines[0].comment,
|
||||
}
|
||||
: null,
|
||||
lines: lc.lines.map((line) => ({
|
||||
id: line.id,
|
||||
clientId: line.clientKey ?? line.id,
|
||||
treatmentType: line.treatmentType,
|
||||
teeth: normalizeTeeth(line.teeth),
|
||||
toothSelectionGroups: line.toothSelectionGroups,
|
||||
comment: line.comment,
|
||||
})),
|
||||
toothProsthesis: lc.toothProsthesis.map((row) => ({
|
||||
treatmentDetailId: row.treatmentDetailId,
|
||||
lineId: row.lineId,
|
||||
tooth: row.tooth,
|
||||
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||
selectionGroupId: row.selectionGroupId?.trim() || undefined,
|
||||
@@ -729,6 +1130,7 @@ export class CasesService {
|
||||
mimeType: row.attachment.mimeType,
|
||||
sizeBytes: row.attachment.sizeBytes,
|
||||
createdAt: row.attachment.createdAt.toISOString(),
|
||||
detailClientKey: row.attachment.detailClientKey,
|
||||
})),
|
||||
sends: lc.sends.map((s) => ({
|
||||
organizationId: s.organizationId,
|
||||
@@ -806,9 +1208,9 @@ export class CasesService {
|
||||
|
||||
for (const task of tasks) {
|
||||
const selectionGroupId = task.selectionGroupId ?? '';
|
||||
const key = `${task.treatmentDetailId}:${selectionGroupId}:${task.prosthesisTypeCode}`;
|
||||
const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${selectionGroupId}:${task.prosthesisTypeCode}`;
|
||||
const entry = groups.get(key) ?? {
|
||||
treatmentDetailId: task.treatmentDetailId,
|
||||
treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '',
|
||||
teeth: normalizeTaskTeeth(task.teeth),
|
||||
treatmentType: task.treatmentType,
|
||||
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||
@@ -839,7 +1241,7 @@ export class CasesService {
|
||||
) {
|
||||
return {
|
||||
id: task.id,
|
||||
treatmentDetailId: task.treatmentDetailId,
|
||||
treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '',
|
||||
teeth: normalizeTaskTeeth(task.teeth),
|
||||
treatmentType: task.treatmentType,
|
||||
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||
@@ -870,6 +1272,72 @@ export class CasesService {
|
||||
};
|
||||
}
|
||||
|
||||
private visibleToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
{
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private visibleStartedToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
{
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
startedAt: { not: null },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async assertPartnerClinic(
|
||||
partnerId: string | null | undefined,
|
||||
labOrganizationId: string,
|
||||
) {
|
||||
if (!partnerId) return;
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: partnerId },
|
||||
select: { id: true, type: { select: { name: true } } },
|
||||
});
|
||||
if (!org || org.type.name !== 'CLINIC') {
|
||||
throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const [linksA, linksB] = await Promise.all([
|
||||
this.prisma.organizationLink.findFirst({
|
||||
where: {
|
||||
organizationAId: labOrganizationId,
|
||||
organizationBId: partnerId,
|
||||
status: LinkStatus.ACTIVE,
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
this.prisma.organizationLink.findFirst({
|
||||
where: {
|
||||
organizationAId: partnerId,
|
||||
organizationBId: labOrganizationId,
|
||||
status: LinkStatus.ACTIVE,
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
if (!linksA && !linksB) {
|
||||
throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCanReadCases(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
|
||||
Reference in New Issue
Block a user