Files
dyolink/backend/src/modules/cases/lab-case-task.generator.spec.ts

267 lines
8.9 KiB
TypeScript
Raw Normal View History

import { CatalogEntityKind } from '@prisma/client';
import {
PROSTHESIS_TYPES,
buildProsthesisStepCodes,
} from '../../../prisma/catalog-seed-data';
import { generateLabCaseTasks } from './lab-case-task.generator';
function buildMockTx(options: {
existingCount?: number;
toothProsthesisRows: Array<{
treatmentDetailId?: string | null;
lineId?: string | null;
sourceKey?: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
treatmentType?: string;
}>;
prosthesisTypes: Array<{
code: string;
steps: Array<{ stepOrder: number; workflowStepCode: string }>;
}>;
stepLabels?: Record<string, string>;
}) {
const created: unknown[] = [];
const tx = {
labCaseTask: {
count: jest.fn().mockResolvedValue(options.existingCount ?? 0),
createMany: jest.fn().mockImplementation(({ data }) => {
created.push(...data);
return { count: data.length };
}),
},
labCaseToothProsthesis: {
findMany: jest.fn().mockResolvedValue(
options.toothProsthesisRows.map((row) => ({
treatmentDetailId: row.treatmentDetailId ?? null,
lineId: row.lineId ?? null,
sourceKey: row.sourceKey ?? row.treatmentDetailId ?? row.lineId ?? '',
treatmentType: row.treatmentType ?? 'prosthesis',
tooth: row.tooth,
prosthesisTypeCode: row.prosthesisTypeCode,
selectionGroupId: row.selectionGroupId ?? '',
detail: row.treatmentDetailId
? {
id: row.treatmentDetailId,
treatmentType: row.treatmentType ?? 'prosthesis',
toothSelectionGroups: null,
}
: null,
line: row.lineId
? {
id: row.lineId,
treatmentType: row.treatmentType ?? 'prosthesis',
toothSelectionGroups: null,
}
: null,
})),
),
},
prosthesisType: {
findMany: jest.fn().mockResolvedValue(
options.prosthesisTypes.map((type) => ({
code: type.code,
isActive: true,
steps: type.steps.map((step) => ({
stepOrder: step.stepOrder,
labWorkflowStep: { code: step.workflowStepCode },
})),
})),
),
},
catalogTranslation: {
findMany: jest.fn().mockImplementation(({ where }) => {
const codes = where.entityCode?.in ?? [];
return codes.map((code: string) => ({
entityCode: code,
locale: 'en',
label: options.stepLabels?.[code] ?? code,
entityKind: CatalogEntityKind.LAB_WORKFLOW_STEP,
}));
}),
},
};
return { tx, created };
}
function stepsFromSeed(code: string) {
const seed = PROSTHESIS_TYPES.find((type) => type.code === code);
if (!seed) {
throw new Error(`Unknown prosthesis code: ${code}`);
}
return buildProsthesisStepCodes(seed).map((workflowStepCode, index) => ({
stepOrder: index + 1,
workflowStepCode,
}));
}
describe('generateLabCaseTasks', () => {
it('creates tasks for pfm_crown with universal and type-specific steps', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
},
],
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
stepLabels: { intraoral_scan: 'Intraoral Scan', packing: 'Packing' },
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-1', 'en');
expect(count).toBe(pfmSteps.length);
expect(created).toHaveLength(pfmSteps.length);
expect(created[0]).toMatchObject({
teeth: ['14'],
prosthesisTypeCode: 'pfm_crown',
workflowStepCode: 'intraoral_scan',
stepLabel: 'Intraoral Scan',
status: 'IN_PROGRESS',
});
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).toEqual(pfmSteps.map((step) => step.workflowStepCode));
expect(stepCodes).toContain('packing');
expect(stepCodes).toContain('shipping');
expect(stepCodes).toContain('milling_wet');
});
it('groups teeth sharing a prosthesis in one detail, and keeps other prosthesis separate', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const zirconiaSteps = stepsFromSeed('monolithic_zirconia');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{ treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'pfm_crown' },
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'pfm_crown' },
{ treatmentDetailId: 'detail-1', tooth: '16', prosthesisTypeCode: 'monolithic_zirconia' },
],
prosthesisTypes: [
{ code: 'pfm_crown', steps: pfmSteps },
{ code: 'monolithic_zirconia', steps: zirconiaSteps },
],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-group', 'en');
expect(count).toBe(pfmSteps.length + zirconiaSteps.length);
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
const pfmRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown');
const zirconiaRows = rows.filter((r) => r.prosthesisTypeCode === 'monolithic_zirconia');
expect(pfmRows).toHaveLength(pfmSteps.length);
expect(zirconiaRows).toHaveLength(zirconiaSteps.length);
// Teeth sharing the prosthesis in the same detail are merged and sorted.
expect(pfmRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(true);
expect(zirconiaRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['16']))).toBe(true);
});
it('omits packing and shipping for smile_design', async () => {
const smileSteps = stepsFromSeed('smile_design');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '11',
prosthesisTypeCode: 'smile_design',
},
],
prosthesisTypes: [{ code: 'smile_design', steps: smileSteps }],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-2', 'en');
expect(count).toBe(smileSteps.length);
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).not.toContain('packing');
expect(stepCodes).not.toContain('shipping');
expect(stepCodes).toContain('printer_resin');
});
it('merges teeth with the same prosthesis type across selection groups', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '15',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '21',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'single-21',
},
],
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-merge', 'en');
expect(count).toBe(pfmSteps.length);
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
expect(rows).toHaveLength(pfmSteps.length);
expect(rows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15', '21']))).toBe(
true,
);
});
it('skips generation when tasks already exist', async () => {
const { tx } = buildMockTx({
existingCount: 3,
toothProsthesisRows: [],
prosthesisTypes: [],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-3', 'en');
expect(count).toBe(0);
expect(tx.labCaseTask.createMany).not.toHaveBeenCalled();
});
it('creates tasks from lab-origin lines using sourceKey and lineId', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
lineId: 'line-1',
sourceKey: 'line-1',
tooth: '11',
prosthesisTypeCode: 'pfm_crown',
},
{
lineId: 'line-1',
sourceKey: 'line-1',
tooth: '21',
prosthesisTypeCode: 'pfm_crown',
},
],
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
});
const count = await generateLabCaseTasks(tx as never, 'lab-internal-1', 'en');
expect(count).toBe(pfmSteps.length);
expect(created[0]).toMatchObject({
lineId: 'line-1',
sourceKey: 'line-1',
treatmentDetailId: null,
teeth: ['11', '21'],
});
});
});