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; }) { 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(); 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); }); });