TreatmentType and ProsthesisType database shcema and data updated. Lab dispatch wired through new data.
This commit is contained in:
@@ -45,6 +45,19 @@ export class SaveTreatmentDraftDto {
|
||||
details: SaveTreatmentDetailDto[];
|
||||
}
|
||||
|
||||
export class LabCaseToothProsthesisDto {
|
||||
@IsUUID()
|
||||
treatmentDetailId: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
tooth: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
prosthesisTypeCode: string;
|
||||
}
|
||||
|
||||
export class SaveLabCaseDto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
@@ -67,6 +80,12 @@ export class SaveLabCaseDto {
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID(undefined, { each: true })
|
||||
treatmentDetailIds: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LabCaseToothProsthesisDto)
|
||||
toothProsthesis?: LabCaseToothProsthesisDto[];
|
||||
}
|
||||
|
||||
export class SaveTreatmentLabCasesDto {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
|
||||
|
||||
describe('assertCompleteToothProsthesisMap', () => {
|
||||
const prosthesisDetailId = 'detail-1';
|
||||
|
||||
it('passes when every prosthesis tooth has a mapping', () => {
|
||||
expect(() =>
|
||||
assertCompleteToothProsthesisMap({
|
||||
details: [
|
||||
{
|
||||
treatmentDetailId: prosthesisDetailId,
|
||||
detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: ['14', '15'] },
|
||||
},
|
||||
],
|
||||
toothProsthesis: [
|
||||
{ treatmentDetailId: prosthesisDetailId, tooth: '14', prosthesisTypeCode: 'pfm_crown' },
|
||||
{ treatmentDetailId: prosthesisDetailId, tooth: '15', prosthesisTypeCode: 'pfm_crown' },
|
||||
],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('ignores non-prosthesis details', () => {
|
||||
expect(() =>
|
||||
assertCompleteToothProsthesisMap({
|
||||
details: [
|
||||
{
|
||||
treatmentDetailId: 'endo-1',
|
||||
detail: { id: 'endo-1', treatmentType: 'endo', teeth: ['36'] },
|
||||
},
|
||||
],
|
||||
toothProsthesis: [],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws when a prosthesis tooth is missing from the map', () => {
|
||||
expect(() =>
|
||||
assertCompleteToothProsthesisMap({
|
||||
details: [
|
||||
{
|
||||
treatmentDetailId: prosthesisDetailId,
|
||||
detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: ['14', '15'] },
|
||||
},
|
||||
],
|
||||
toothProsthesis: [
|
||||
{ treatmentDetailId: prosthesisDetailId, tooth: '14', prosthesisTypeCode: 'pfm_crown' },
|
||||
],
|
||||
}),
|
||||
).toThrow('missing tooth 15');
|
||||
});
|
||||
});
|
||||
37
backend/src/modules/treatments/lab-case-send.validation.ts
Normal file
37
backend/src/modules/treatments/lab-case-send.validation.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { normalizeTeeth } from './treatment.utils';
|
||||
|
||||
export type LabCaseProsthesisLink = {
|
||||
treatmentDetailId: string;
|
||||
detail: { id: string; treatmentType: string; teeth: unknown };
|
||||
};
|
||||
|
||||
export type LabCaseToothProsthesisRow = {
|
||||
treatmentDetailId: string;
|
||||
tooth: string;
|
||||
prosthesisTypeCode: string;
|
||||
};
|
||||
|
||||
export function assertCompleteToothProsthesisMap(labCase: {
|
||||
details: LabCaseProsthesisLink[];
|
||||
toothProsthesis: LabCaseToothProsthesisRow[];
|
||||
}) {
|
||||
const prosthesisByKey = new Set(
|
||||
labCase.toothProsthesis.map((tp) => `${tp.treatmentDetailId}:${tp.tooth}`),
|
||||
);
|
||||
|
||||
for (const link of labCase.details) {
|
||||
if (link.detail.treatmentType !== 'prosthesis') {
|
||||
continue;
|
||||
}
|
||||
const teeth = normalizeTeeth(link.detail.teeth);
|
||||
for (const tooth of teeth) {
|
||||
const key = `${link.treatmentDetailId}:${tooth}`;
|
||||
if (!prosthesisByKey.has(key)) {
|
||||
throw new BadRequestException(
|
||||
`Each tooth must have a prosthesis type before sending (missing tooth ${tooth})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,9 +184,14 @@ export class TreatmentsController {
|
||||
@ApiOperation({ summary: 'Send a lab case to its destination organization (TAB_TREATMENT_EDIT)' })
|
||||
sendLabCase(
|
||||
@Param('labCaseId') labCaseId: string,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.sendLabCase(labCaseId, organizationId, req.user.id);
|
||||
return this.treatmentsService.sendLabCase(
|
||||
labCaseId,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
req.user.language,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
|
||||
import { TreatmentsController } from './treatments.controller';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
|
||||
@Module({
|
||||
imports: [ProsthesisCatalogModule],
|
||||
controllers: [TreatmentsController],
|
||||
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
|
||||
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||
import {
|
||||
SaveTreatmentDraftDto,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
generateTreatmentTitle,
|
||||
normalizeTeeth,
|
||||
} from './treatment.utils';
|
||||
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
|
||||
|
||||
const treatmentInclude = {
|
||||
details: {
|
||||
@@ -53,6 +55,7 @@ const treatmentInclude = {
|
||||
orderBy: [{ sentAt: 'asc' as const }],
|
||||
include: { organization: { select: { id: true, name: true } } },
|
||||
},
|
||||
toothProsthesis: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -64,6 +67,7 @@ export class TreatmentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -326,7 +330,7 @@ export class TreatmentsService {
|
||||
|
||||
const details = await this.prisma.treatmentDetail.findMany({
|
||||
where: { treatmentId: treatment.id, id: { in: detailIds } },
|
||||
select: { id: true, treatmentType: true },
|
||||
select: { id: true, treatmentType: true, teeth: true },
|
||||
});
|
||||
if (details.length !== uniqueDetailIds.size) {
|
||||
throw new BadRequestException('One or more treatment details were not found');
|
||||
@@ -336,12 +340,30 @@ export class TreatmentsService {
|
||||
this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType);
|
||||
}
|
||||
|
||||
const detailById = new Map(details.map((d) => [d.id, d]));
|
||||
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
|
||||
|
||||
for (const lc of dto.labCases) {
|
||||
if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) {
|
||||
throw new BadRequestException('Destination organization is not an active linked counterpart');
|
||||
}
|
||||
|
||||
for (const row of lc.toothProsthesis ?? []) {
|
||||
if (!lc.treatmentDetailIds.includes(row.treatmentDetailId)) {
|
||||
throw new BadRequestException(
|
||||
'Tooth prosthesis must reference a detail included in this lab case',
|
||||
);
|
||||
}
|
||||
const detail = detailById.get(row.treatmentDetailId);
|
||||
if (!detail) {
|
||||
throw new BadRequestException('Tooth prosthesis references an unknown treatment detail');
|
||||
}
|
||||
const teeth = normalizeTeeth(detail.teeth);
|
||||
if (!teeth.includes(row.tooth)) {
|
||||
throw new BadRequestException(`Tooth ${row.tooth} is not on the selected treatment detail`);
|
||||
}
|
||||
this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode);
|
||||
}
|
||||
}
|
||||
|
||||
const saved = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -395,6 +417,18 @@ export class TreatmentsService {
|
||||
treatmentDetailId,
|
||||
})),
|
||||
});
|
||||
|
||||
await tx.labCaseToothProsthesis.deleteMany({ where: { labCaseId: row.id } });
|
||||
if (lc.toothProsthesis?.length) {
|
||||
await tx.labCaseToothProsthesis.createMany({
|
||||
data: lc.toothProsthesis.map((tp) => ({
|
||||
labCaseId: row.id,
|
||||
treatmentDetailId: tp.treatmentDetailId,
|
||||
tooth: tp.tooth,
|
||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.treatment.findUniqueOrThrow({
|
||||
@@ -410,6 +444,7 @@ export class TreatmentsService {
|
||||
labCaseId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
actorLanguage?: string | null,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
|
||||
@@ -421,7 +456,12 @@ export class TreatmentsService {
|
||||
include: {
|
||||
treatment: { select: { providerUserId: true } },
|
||||
sends: { select: { organizationId: true } },
|
||||
details: { select: { treatmentDetailId: true } },
|
||||
details: {
|
||||
include: {
|
||||
detail: { select: { id: true, treatmentType: true, teeth: true } },
|
||||
},
|
||||
},
|
||||
toothProsthesis: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -437,6 +477,8 @@ export class TreatmentsService {
|
||||
throw new BadRequestException('Lab case must include at least one treatment detail');
|
||||
}
|
||||
|
||||
assertCompleteToothProsthesisMap(labCase);
|
||||
|
||||
if (labCase.treatment.providerUserId !== actorUserId) {
|
||||
const membership = await this.getMembership(actorUserId, organizationId);
|
||||
if (!membership?.isOwner) {
|
||||
@@ -473,7 +515,7 @@ export class TreatmentsService {
|
||||
});
|
||||
}
|
||||
|
||||
await generateLabCaseTasks(tx, labCaseId);
|
||||
await generateLabCaseTasks(tx, labCaseId, actorLanguage);
|
||||
});
|
||||
|
||||
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
|
||||
@@ -490,6 +532,7 @@ export class TreatmentsService {
|
||||
orderBy: [{ sentAt: 'asc' }],
|
||||
include: { organization: { select: { id: true, name: true } } },
|
||||
},
|
||||
toothProsthesis: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -722,6 +765,11 @@ export class TreatmentsService {
|
||||
sentAt: Date;
|
||||
organization?: { id: string; name: string };
|
||||
}>;
|
||||
toothProsthesis?: Array<{
|
||||
treatmentDetailId: string;
|
||||
tooth: string;
|
||||
prosthesisTypeCode: string;
|
||||
}>;
|
||||
}) {
|
||||
return {
|
||||
id: lc.id,
|
||||
@@ -736,6 +784,11 @@ export class TreatmentsService {
|
||||
treatmentType: d.detail?.treatmentType ?? '',
|
||||
teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [],
|
||||
})),
|
||||
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
||||
treatmentDetailId: tp.treatmentDetailId,
|
||||
tooth: tp.tooth,
|
||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||
})),
|
||||
sends:
|
||||
lc.sends?.map((s) => ({
|
||||
organizationId: s.organizationId,
|
||||
|
||||
Reference in New Issue
Block a user