482 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|