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

482 lines
17 KiB
TypeScript

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('keeps one task set per tooth even when they share a prosthesis', 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 * 2 + zirconiaSteps.length);
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
const pfm14 = rows.filter(
(r) => r.prosthesisTypeCode === 'pfm_crown' && r.teeth.join() === '14',
);
const pfm15 = rows.filter(
(r) => r.prosthesisTypeCode === 'pfm_crown' && r.teeth.join() === '15',
);
const zirconiaRows = rows.filter((r) => r.prosthesisTypeCode === 'monolithic_zirconia');
expect(pfm14).toHaveLength(pfmSteps.length);
expect(pfm15).toHaveLength(pfmSteps.length);
expect(zirconiaRows).toHaveLength(zirconiaSteps.length);
expect(zirconiaRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['16']))).toBe(true);
const uniqueKeys = new Set(
rows.map((r) => `${r.teeth.join()}:${r.prosthesisTypeCode}`),
);
expect(uniqueKeys.size).toBe(3);
});
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).toEqual(['intraoral_scan', 'design']);
expect(stepCodes).not.toContain('packing');
expect(stepCodes).not.toContain('shipping');
});
it('merges connected teeth that share a prosthesis type into one task set', 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 * 2);
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
const teethSets = new Set(rows.map((r) => r.teeth.join(',')));
expect(teethSets).toEqual(new Set(['14,15', '21']));
});
it('keeps an extra type on one connected tooth as its own task set', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const postSteps = stepsFromSeed('fiber_post_core');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'fiber_post_core',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '15',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '16',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
],
prosthesisTypes: [
{ code: 'pfm_crown', steps: pfmSteps },
{ code: 'fiber_post_core', steps: postSteps },
],
});
await generateLabCaseTasks(tx as never, 'lab-case-bridge-addon', 'en');
const rows = created as Array<{
teeth: string[];
prosthesisTypeCode: string;
}>;
const byType = new Map<string, string>();
for (const row of rows) {
byType.set(row.prosthesisTypeCode, row.teeth.join(','));
}
expect(byType.get('pfm_crown')).toBe('14,15,16');
expect(byType.get('fiber_post_core')).toBe('14');
});
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 * 2);
const rows = created as Array<{ teeth: string[]; lineId: string }>;
const teethSets = new Set(rows.map((r) => r.teeth.join(',')));
expect(teethSets).toEqual(new Set(['11', '21']));
expect(created[0]).toMatchObject({
lineId: 'line-1',
sourceKey: 'line-1',
treatmentDetailId: null,
});
});
it('unions stacked types on the same tooth and de-dupes scan/pack/ship', async () => {
const tiSteps = stepsFromSeed('ti_base_abutment');
const zrSteps = stepsFromSeed('monolithic_zirconia');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'ti_base_abutment',
},
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'monolithic_zirconia',
},
],
prosthesisTypes: [
{ code: 'ti_base_abutment', steps: tiSteps },
{ code: 'monolithic_zirconia', steps: zrSteps },
],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-stack', 'en');
const rows = created as Array<{
workflowStepCode: string;
prosthesisTypeCode: string;
teeth: string[];
}>;
expect(count).toBe(rows.length);
expect(rows.every((r) => r.teeth.join() === '46')).toBe(true);
expect(rows[0].prosthesisTypeCode).toBe('monolithic_zirconia+ti_base_abutment');
const stepCodes = rows.map((r) => r.workflowStepCode);
expect(stepCodes.filter((c) => c === 'intraoral_scan')).toHaveLength(1);
expect(stepCodes.filter((c) => c === 'packing')).toHaveLength(1);
expect(stepCodes.filter((c) => c === 'shipping')).toHaveLength(1);
expect(stepCodes[0]).toBe('intraoral_scan');
expect(stepCodes[stepCodes.length - 1]).toBe('shipping');
expect(stepCodes).toContain('milling_dry');
expect(stepCodes).toContain('sinter');
expect(stepCodes).toContain('polish_prep');
expect(stepCodes).toContain('model_create');
expect(stepCodes).toContain('design');
expect(stepCodes.indexOf('model_create')).toBeGreaterThan(stepCodes.indexOf('design'));
});
it('omits design for prefab abutments and inserts choosing_abutment before model_create', async () => {
const prefabSteps = stepsFromSeed('prefabricated_abutment');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '36',
prosthesisTypeCode: 'prefabricated_abutment',
},
],
prosthesisTypes: [{ code: 'prefabricated_abutment', steps: prefabSteps }],
});
await generateLabCaseTasks(tx as never, 'lab-case-prefab', 'en');
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).toEqual([
'intraoral_scan',
'choosing_abutment',
'model_create',
'polish_prep',
'packing',
'shipping',
]);
expect(stepCodes).not.toContain('design');
});
it('pins choosing_abutment before design when stacking prefab with a crown', async () => {
const prefabSteps = stepsFromSeed('prefabricated_abutment');
const zrSteps = stepsFromSeed('monolithic_zirconia');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'prefabricated_abutment',
},
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'monolithic_zirconia',
},
],
prosthesisTypes: [
{ code: 'prefabricated_abutment', steps: prefabSteps },
{ code: 'monolithic_zirconia', steps: zrSteps },
],
});
await generateLabCaseTasks(tx as never, 'lab-case-union-pin', 'en');
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes[0]).toBe('intraoral_scan');
expect(stepCodes.indexOf('choosing_abutment')).toBeLessThan(stepCodes.indexOf('design'));
expect(stepCodes.indexOf('design')).toBeLessThan(stepCodes.indexOf('model_create'));
expect(stepCodes.filter((c) => c === 'intraoral_scan')).toHaveLength(1);
});
it('merges partial_denture rows on the same sourceKey into one task set', async () => {
const partialSteps = stepsFromSeed('partial_denture');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '16', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '17', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '18', prosthesisTypeCode: 'partial_denture' },
],
prosthesisTypes: [{ code: 'partial_denture', steps: partialSteps }],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-partial', 'en');
expect(count).toBe(partialSteps.length);
const rows = created as Array<{
teeth: string[];
tooth: string;
prosthesisTypeCode: string;
}>;
expect(rows.every((r) => r.prosthesisTypeCode === 'partial_denture')).toBe(true);
expect(rows.every((r) => r.teeth.join(',') === '14,15,16,17,18')).toBe(true);
expect(new Set(rows.map((r) => r.tooth)).size).toBe(1);
});
it('keeps crowns on abutment teeth as separate per-tooth jobs beside a merged partial', async () => {
const partialSteps = stepsFromSeed('partial_denture');
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'pfm_crown' },
],
prosthesisTypes: [
{ code: 'partial_denture', steps: partialSteps },
{ code: 'pfm_crown', steps: pfmSteps },
],
});
await generateLabCaseTasks(tx as never, 'lab-case-partial-crown', 'en');
const rows = created as Array<{
teeth: string[];
prosthesisTypeCode: string;
}>;
const partialRows = rows.filter((r) => r.prosthesisTypeCode === 'partial_denture');
const crownRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown');
expect(partialRows).toHaveLength(partialSteps.length);
expect(partialRows.every((r) => r.teeth.join(',') === '14,15')).toBe(true);
expect(crownRows).toHaveLength(pfmSteps.length);
expect(crownRows.every((r) => r.teeth.join(',') === '14')).toBe(true);
});
});