improvement: Some improvements done. some bugs fixed.
This commit is contained in:
@@ -53,19 +53,21 @@ export const LEGACY_TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [
|
||||
|
||||
export const LAB_WORKFLOW_STEPS = [
|
||||
{ code: 'intraoral_scan', sortOrder: 1 },
|
||||
{ code: 'design', sortOrder: 2 },
|
||||
{ code: 'milling_dry', sortOrder: 3 },
|
||||
{ code: 'milling_wet', sortOrder: 4 },
|
||||
{ code: 'printer_resin', sortOrder: 5 },
|
||||
{ code: 'printer_metal', sortOrder: 6 },
|
||||
{ code: 'sinter', sortOrder: 7 },
|
||||
{ code: 'build_up', sortOrder: 8 },
|
||||
{ code: 'stain', sortOrder: 9 },
|
||||
{ code: 'glaze', sortOrder: 10 },
|
||||
{ code: 'polish_prep', sortOrder: 11 },
|
||||
{ code: 'packing', sortOrder: 12 },
|
||||
{ code: 'shipping', sortOrder: 13 },
|
||||
{ code: 'pressing', sortOrder: 14 },
|
||||
{ code: 'choosing_abutment', sortOrder: 2 },
|
||||
{ code: 'design', sortOrder: 3 },
|
||||
{ code: 'model_create', sortOrder: 4 },
|
||||
{ code: 'milling_dry', sortOrder: 5 },
|
||||
{ code: 'milling_wet', sortOrder: 6 },
|
||||
{ code: 'printer_resin', sortOrder: 7 },
|
||||
{ code: 'printer_metal', sortOrder: 8 },
|
||||
{ code: 'pressing', sortOrder: 9 },
|
||||
{ code: 'sinter', sortOrder: 10 },
|
||||
{ code: 'build_up', sortOrder: 11 },
|
||||
{ code: 'stain', sortOrder: 12 },
|
||||
{ code: 'glaze', sortOrder: 13 },
|
||||
{ code: 'polish_prep', sortOrder: 14 },
|
||||
{ code: 'packing', sortOrder: 15 },
|
||||
{ code: 'shipping', sortOrder: 16 },
|
||||
] as const;
|
||||
|
||||
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch';
|
||||
@@ -82,15 +84,42 @@ export type ProsthesisTypeSeed = {
|
||||
chartRegion: ProsthesisChartRegion;
|
||||
stackGroup: ProsthesisStackGroup;
|
||||
addonKind?: ProsthesisAddonKind;
|
||||
/** Manufacturing steps between design and packing (exclusive of universal scan/design/pack/ship). */
|
||||
/** Manufacturing sequence. Packing and shipping are appended except for smile_design. */
|
||||
manufacturingSteps: readonly string[];
|
||||
};
|
||||
|
||||
const FULL_CONTOUR = ['milling_wet', 'stain', 'glaze', 'polish_prep'] as const;
|
||||
const LAYERED = ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'] as const;
|
||||
const DENTURE = ['milling_wet', 'polish_prep'] as const;
|
||||
const APPLIANCE = ['printer_resin', 'polish_prep'] as const;
|
||||
const POST_CORE = ['milling_wet', 'polish_prep'] as const;
|
||||
const SCAN_DESIGN_MODEL = ['intraoral_scan', 'design', 'model_create'] as const;
|
||||
const INDIRECT_FULL = [...SCAN_DESIGN_MODEL, 'milling_wet', 'sinter', 'stain', 'glaze'] as const;
|
||||
const INDIRECT_LAYERED = [
|
||||
...SCAN_DESIGN_MODEL,
|
||||
'milling_wet',
|
||||
'sinter',
|
||||
'build_up',
|
||||
'stain',
|
||||
'glaze',
|
||||
] as const;
|
||||
/** Unlisted types keep scan+design plus today’s manufacturing (no model-create). */
|
||||
const KEEP_FULL_CONTOUR = [
|
||||
'intraoral_scan',
|
||||
'design',
|
||||
'milling_wet',
|
||||
'stain',
|
||||
'glaze',
|
||||
'polish_prep',
|
||||
] as const;
|
||||
const KEEP_LAYERED = [
|
||||
'intraoral_scan',
|
||||
'design',
|
||||
'milling_dry',
|
||||
'sinter',
|
||||
'build_up',
|
||||
'stain',
|
||||
'glaze',
|
||||
'polish_prep',
|
||||
] as const;
|
||||
const DENTURE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
|
||||
const APPLIANCE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
|
||||
const POST_CORE = ['intraoral_scan', 'design', 'milling_wet', 'polish_prep'] as const;
|
||||
|
||||
function crown(
|
||||
code: string,
|
||||
@@ -113,6 +142,7 @@ function implantAddon(
|
||||
code: string,
|
||||
sortOrder: number,
|
||||
manufacturingSteps: readonly string[],
|
||||
extra?: Partial<ProsthesisTypeSeed>,
|
||||
): ProsthesisTypeSeed {
|
||||
return {
|
||||
code,
|
||||
@@ -122,6 +152,7 @@ function implantAddon(
|
||||
stackGroup: 'implant',
|
||||
addonKind: 'implant',
|
||||
manufacturingSteps,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,20 +168,54 @@ function indirect(
|
||||
subcategory: indication,
|
||||
chartRegion: 'crown',
|
||||
stackGroup: 'restoration',
|
||||
manufacturingSteps: technique === 'layered' ? LAYERED : FULL_CONTOUR,
|
||||
manufacturingSteps: technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
|
||||
};
|
||||
}
|
||||
|
||||
export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
crown('pfm_crown', 1, ['milling_wet', 'build_up', 'stain', 'glaze', 'polish_prep']),
|
||||
crown('pfz_crown', 2, ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep']),
|
||||
crown('monolithic_zirconia', 3, ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep']),
|
||||
crown('glass_ceramic_crown', 4, FULL_CONTOUR),
|
||||
crown('full_metal_crown', 5, ['milling_wet', 'polish_prep']),
|
||||
crown('temporary_resin_crown', 6, ['milling_wet', 'polish_prep']),
|
||||
crown('pmma', 7, ['milling_dry', 'polish_prep']),
|
||||
crown('peek_crown', 8, ['milling_dry', 'polish_prep']),
|
||||
crown('press_ceramic', 9, ['printer_resin', 'pressing', 'stain', 'glaze', 'polish_prep']),
|
||||
crown('pfm_crown', 1, [
|
||||
...SCAN_DESIGN_MODEL,
|
||||
'milling_wet',
|
||||
'sinter',
|
||||
'build_up',
|
||||
'stain',
|
||||
'glaze',
|
||||
]),
|
||||
crown('pfz_crown', 2, [
|
||||
...SCAN_DESIGN_MODEL,
|
||||
'milling_dry',
|
||||
'sinter',
|
||||
'build_up',
|
||||
'stain',
|
||||
'glaze',
|
||||
]),
|
||||
crown('monolithic_zirconia', 3, [
|
||||
...SCAN_DESIGN_MODEL,
|
||||
'milling_dry',
|
||||
'sinter',
|
||||
'stain',
|
||||
'glaze',
|
||||
]),
|
||||
crown('glass_ceramic_crown', 4, [
|
||||
...SCAN_DESIGN_MODEL,
|
||||
'milling_dry',
|
||||
'sinter',
|
||||
'stain',
|
||||
'glaze',
|
||||
]),
|
||||
crown('full_metal_crown', 5, [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep']),
|
||||
crown('temporary_resin_crown', 6, ['intraoral_scan', 'design', 'milling_wet', 'polish_prep']),
|
||||
crown('pmma', 7, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
|
||||
crown('peek_crown', 8, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
|
||||
crown('press_ceramic', 9, [
|
||||
'intraoral_scan',
|
||||
'design',
|
||||
'printer_resin',
|
||||
'pressing',
|
||||
'stain',
|
||||
'glaze',
|
||||
'polish_prep',
|
||||
]),
|
||||
indirect('veneer', 'full_contour', 20),
|
||||
indirect('veneer', 'layered', 21),
|
||||
indirect('inlay', 'full_contour', 22),
|
||||
@@ -177,19 +242,31 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
addonKind: 'post_core',
|
||||
manufacturingSteps: POST_CORE,
|
||||
},
|
||||
implantAddon('prefabricated_abutment', 40, ['polish_prep']),
|
||||
implantAddon('ti_base_abutment', 41, ['polish_prep']),
|
||||
implantAddon('multi_unit_abutment', 42, ['polish_prep']),
|
||||
implantAddon('customized_abutment', 43, ['milling_wet', 'polish_prep']),
|
||||
implantAddon('zirconia_abutment', 44, ['milling_dry', 'sinter', 'polish_prep']),
|
||||
{
|
||||
code: 'screw_retained',
|
||||
sortOrder: 45,
|
||||
category: 'implant',
|
||||
chartRegion: 'crown',
|
||||
stackGroup: 'restoration',
|
||||
manufacturingSteps: ['milling_wet', 'polish_prep'],
|
||||
},
|
||||
implantAddon('prefabricated_abutment', 40, [
|
||||
'intraoral_scan',
|
||||
'choosing_abutment',
|
||||
'model_create',
|
||||
'polish_prep',
|
||||
]),
|
||||
implantAddon('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']),
|
||||
implantAddon('multi_unit_abutment', 42, [
|
||||
'intraoral_scan',
|
||||
'choosing_abutment',
|
||||
'model_create',
|
||||
'polish_prep',
|
||||
]),
|
||||
implantAddon('customized_abutment', 43, [
|
||||
...SCAN_DESIGN_MODEL,
|
||||
'milling_wet',
|
||||
'polish_prep',
|
||||
]),
|
||||
implantAddon('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']),
|
||||
implantAddon(
|
||||
'screw_retained',
|
||||
45,
|
||||
[...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep'],
|
||||
{ chartRegion: 'crown' },
|
||||
),
|
||||
{
|
||||
code: 'complete_denture',
|
||||
sortOrder: 50,
|
||||
@@ -202,7 +279,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
code: 'partial_denture',
|
||||
sortOrder: 51,
|
||||
category: 'removable',
|
||||
chartRegion: 'arch',
|
||||
chartRegion: 'crown',
|
||||
stackGroup: 'arch',
|
||||
manufacturingSteps: DENTURE,
|
||||
},
|
||||
@@ -263,7 +340,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
category: 'appliance',
|
||||
chartRegion: 'arch',
|
||||
stackGroup: 'arch',
|
||||
manufacturingSteps: ['milling_dry', 'sinter'],
|
||||
manufacturingSteps: ['intraoral_scan', 'design', 'milling_dry', 'sinter'],
|
||||
},
|
||||
{
|
||||
code: 'surgical_guide',
|
||||
@@ -280,7 +357,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
chartRegion: 'arch',
|
||||
stackGroup: 'arch',
|
||||
skipPackingShipping: true,
|
||||
manufacturingSteps: [],
|
||||
manufacturingSteps: ['intraoral_scan', 'design'],
|
||||
},
|
||||
{
|
||||
code: 'mockup',
|
||||
@@ -288,7 +365,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
category: 'digital',
|
||||
chartRegion: 'arch',
|
||||
stackGroup: 'arch',
|
||||
manufacturingSteps: ['printer_resin'],
|
||||
manufacturingSteps: ['intraoral_scan', 'design', 'model_create'],
|
||||
},
|
||||
// Legacy leaves — labels for historical cases; hidden from the picker.
|
||||
{
|
||||
@@ -299,7 +376,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
subcategory: 'veneer',
|
||||
chartRegion: 'crown',
|
||||
stackGroup: 'restoration',
|
||||
manufacturingSteps: LAYERED,
|
||||
manufacturingSteps: KEEP_LAYERED,
|
||||
},
|
||||
{
|
||||
code: 'veneer_ips_press',
|
||||
@@ -309,7 +386,16 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
subcategory: 'veneer',
|
||||
chartRegion: 'crown',
|
||||
stackGroup: 'restoration',
|
||||
manufacturingSteps: ['printer_resin', 'build_up', 'stain', 'glaze', 'polish_prep', 'pressing'],
|
||||
manufacturingSteps: [
|
||||
'intraoral_scan',
|
||||
'design',
|
||||
'printer_resin',
|
||||
'build_up',
|
||||
'stain',
|
||||
'glaze',
|
||||
'polish_prep',
|
||||
'pressing',
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'veneer_ips_cad',
|
||||
@@ -319,7 +405,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
subcategory: 'veneer',
|
||||
chartRegion: 'crown',
|
||||
stackGroup: 'restoration',
|
||||
manufacturingSteps: FULL_CONTOUR,
|
||||
manufacturingSteps: KEEP_FULL_CONTOUR,
|
||||
},
|
||||
{
|
||||
code: 'zirconia_overlay',
|
||||
@@ -329,7 +415,15 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
subcategory: 'overlay',
|
||||
chartRegion: 'crown',
|
||||
stackGroup: 'restoration',
|
||||
manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'],
|
||||
manufacturingSteps: [
|
||||
'intraoral_scan',
|
||||
'design',
|
||||
'milling_dry',
|
||||
'sinter',
|
||||
'stain',
|
||||
'glaze',
|
||||
'polish_prep',
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'ips_overlay',
|
||||
@@ -339,17 +433,16 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
subcategory: 'overlay',
|
||||
chartRegion: 'crown',
|
||||
stackGroup: 'restoration',
|
||||
manufacturingSteps: FULL_CONTOUR,
|
||||
manufacturingSteps: KEEP_FULL_CONTOUR,
|
||||
},
|
||||
];
|
||||
|
||||
const UNIVERSAL_PREFIX = ['intraoral_scan', 'design'] as const;
|
||||
const UNIVERSAL_SUFFIX = ['packing', 'shipping'] as const;
|
||||
const PACK_SHIP = ['packing', 'shipping'] as const;
|
||||
|
||||
export function buildProsthesisStepCodes(type: ProsthesisTypeSeed): string[] {
|
||||
const steps = [...UNIVERSAL_PREFIX, ...type.manufacturingSteps];
|
||||
const steps = [...type.manufacturingSteps];
|
||||
if (!type.skipPackingShipping) {
|
||||
steps.push(...UNIVERSAL_SUFFIX);
|
||||
steps.push(...PACK_SHIP);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
@@ -427,7 +520,13 @@ export const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
|
||||
|
||||
export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
|
||||
intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' },
|
||||
choosing_abutment: {
|
||||
en: 'Choosing Abutment',
|
||||
fa: 'انتخاب اباتمنت',
|
||||
nl: 'Abutment kiezen',
|
||||
},
|
||||
design: { en: 'Design', fa: 'طراحی', nl: 'Ontwerp' },
|
||||
model_create: { en: 'Model Create', fa: 'ساخت مدل', nl: 'Model maken' },
|
||||
milling_dry: { en: 'Milling Dry', fa: 'فرز خشک', nl: 'Droog Frezen' },
|
||||
milling_wet: { en: 'Milling Wet', fa: 'فرز تر', nl: 'Nat Frezen' },
|
||||
printer_resin: { en: 'Printer Resin', fa: 'پرینتر رزین', nl: 'Harsprinter' },
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
crown: 'Crowns',
|
||||
indirect: 'Indirect restorations',
|
||||
indirect: 'Veneer/Inlay/Onlay/Overlay',
|
||||
implant: 'Implants',
|
||||
post_core: 'Post & core',
|
||||
removable: 'Removable',
|
||||
@@ -39,17 +39,25 @@ const STACK_RULES = [
|
||||
['Slot', 'Rule'],
|
||||
[
|
||||
'restoration',
|
||||
'One per tooth: crown, veneer, inlay, onlay, overlay, or screw-retained.',
|
||||
'One per tooth: crown, veneer, inlay, onlay, or overlay.',
|
||||
],
|
||||
['implant', 'One abutment add-on per tooth (Ti-base, custom, prefab, multi-unit, zirconia).'],
|
||||
['post_core', 'One post & core, only if a restoration is already present.'],
|
||||
['implant', 'One abutment add-on per tooth (Ti-base, custom, prefab, multi-unit, zirconia, screw-retained).'],
|
||||
['post_core', 'One post & core, only if a restoration is already present. Blocked when screw-retained is on the tooth.'],
|
||||
[
|
||||
'arch',
|
||||
'One arch product (denture, night guard, digital). Most arch products block implant add-ons; overdenture may take an implant add-on.',
|
||||
'Complete denture, overdenture, appliances, and digital use Upper/Lower arch labels (UA/LA). Not 16 FDI teeth.',
|
||||
],
|
||||
[
|
||||
'partial_denture',
|
||||
'Tooth-selected (FDI), not the arch labels. All selected teeth become one lab job. May stack with implant add-ons.',
|
||||
],
|
||||
[
|
||||
'screw_retained',
|
||||
'Fills the restoration slot and blocks implant + post & core (it is the restoration).',
|
||||
'Fills the implant slot (chart paints the crown) and stacks with a restoration. Blocks post & core.',
|
||||
],
|
||||
[
|
||||
'connected',
|
||||
'Connected teeth that share a type are one lab job. An extra type on one unit of the bridge is its own job. Unconnected teeth stay separate even if they share a type.',
|
||||
],
|
||||
['maximum', 'Usual maximum is 3 types per tooth (e.g. zirconia crown + Ti-base + fiber post).'],
|
||||
['two_crowns', 'Two crowns cannot stack.'],
|
||||
|
||||
@@ -1,33 +1,57 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
/** Union several workflows: scan/design first, packing/shipping last, other steps first-seen. */
|
||||
export function unionWorkflowStepCodes(lists: readonly (readonly string[])[]): string[] {
|
||||
const middle: string[] = [];
|
||||
const seenMiddle = new Set<string>();
|
||||
let hasScan = false;
|
||||
let hasDesign = false;
|
||||
let hasPacking = false;
|
||||
let hasShipping = false;
|
||||
const UNION_PINNED_PREFIX = [
|
||||
'intraoral_scan',
|
||||
'choosing_abutment',
|
||||
'design',
|
||||
'model_create',
|
||||
] as const;
|
||||
|
||||
const UNION_SUFFIX = ['packing', 'shipping'] as const;
|
||||
|
||||
const UNION_PINNED = new Set<string>(UNION_PINNED_PREFIX);
|
||||
const UNION_TAIL = new Set<string>(UNION_SUFFIX);
|
||||
|
||||
/** Remaining manufacturing steps — matches catalog LAB_WORKFLOW_STEPS order. */
|
||||
const UNION_MIDDLE_ORDER: Record<string, number> = {
|
||||
milling_dry: 1,
|
||||
milling_wet: 2,
|
||||
printer_resin: 3,
|
||||
printer_metal: 4,
|
||||
pressing: 5,
|
||||
sinter: 6,
|
||||
build_up: 7,
|
||||
stain: 8,
|
||||
glaze: 9,
|
||||
polish_prep: 10,
|
||||
};
|
||||
|
||||
/** Union stacked workflows: pin scan → abutment → design → model, then catalog order, pack/ship last. */
|
||||
export function unionWorkflowStepCodes(lists: readonly (readonly string[])[]): string[] {
|
||||
const present = new Set<string>();
|
||||
for (const list of lists) {
|
||||
for (const code of list) {
|
||||
if (code === 'intraoral_scan') hasScan = true;
|
||||
else if (code === 'design') hasDesign = true;
|
||||
else if (code === 'packing') hasPacking = true;
|
||||
else if (code === 'shipping') hasShipping = true;
|
||||
else if (!seenMiddle.has(code)) {
|
||||
seenMiddle.add(code);
|
||||
middle.push(code);
|
||||
}
|
||||
if (code) present.add(code);
|
||||
}
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
if (hasScan) out.push('intraoral_scan');
|
||||
if (hasDesign) out.push('design');
|
||||
for (const code of UNION_PINNED_PREFIX) {
|
||||
if (present.has(code)) out.push(code);
|
||||
}
|
||||
|
||||
const middle = [...present].filter((code) => !UNION_PINNED.has(code) && !UNION_TAIL.has(code));
|
||||
middle.sort((a, b) => {
|
||||
const da = UNION_MIDDLE_ORDER[a] ?? 100;
|
||||
const db = UNION_MIDDLE_ORDER[b] ?? 100;
|
||||
if (da !== db) return da - db;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
out.push(...middle);
|
||||
if (hasPacking) out.push('packing');
|
||||
if (hasShipping) out.push('shipping');
|
||||
|
||||
for (const code of UNION_SUFFIX) {
|
||||
if (present.has(code)) out.push(code);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -192,10 +192,9 @@ describe('generateLabCaseTasks', () => {
|
||||
expect(stepCodes).toEqual(['intraoral_scan', 'design']);
|
||||
expect(stepCodes).not.toContain('packing');
|
||||
expect(stepCodes).not.toContain('shipping');
|
||||
expect(stepCodes).not.toContain('printer_resin');
|
||||
});
|
||||
|
||||
it('keeps connected and single teeth as separate per-tooth task sets', async () => {
|
||||
it('merges connected teeth that share a prosthesis type into one task set', async () => {
|
||||
const pfmSteps = stepsFromSeed('pfm_crown');
|
||||
const { tx, created } = buildMockTx({
|
||||
toothProsthesisRows: [
|
||||
@@ -222,10 +221,59 @@ describe('generateLabCaseTasks', () => {
|
||||
});
|
||||
|
||||
const count = await generateLabCaseTasks(tx as never, 'lab-case-merge', 'en');
|
||||
expect(count).toBe(pfmSteps.length * 3);
|
||||
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']));
|
||||
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 () => {
|
||||
@@ -313,5 +361,121 @@ describe('generateLabCaseTasks', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
prosthesisGroupKey,
|
||||
unionWorkflowStepCodes,
|
||||
} from '../../common/prosthesis-group';
|
||||
import { sortProsthesisTeeth } from './lab-case-task.util';
|
||||
|
||||
const PARTIAL_DENTURE_CODE = 'partial_denture';
|
||||
|
||||
type TransactionClient = Prisma.TransactionClient;
|
||||
|
||||
@@ -64,7 +67,10 @@ export async function generateLabCaseTasks(
|
||||
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
|
||||
|
||||
// Per tooth: stacked types become one group key; steps are the union.
|
||||
// Teeth are not merged even when they share the same type-set.
|
||||
// Unconnected teeth stay separate even when they share a type.
|
||||
// Connected (bridge) teeth that share a type merge into one pipeline; an extra
|
||||
// type on one unit of that bridge is its own pipeline. partial_denture is one
|
||||
// lab item per sourceKey.
|
||||
type ToothBucket = {
|
||||
sourceKey: string;
|
||||
treatmentDetailId: string | null;
|
||||
@@ -104,14 +110,140 @@ export async function generateLabCaseTasks(
|
||||
byTooth.set(toothKey, bucket);
|
||||
}
|
||||
|
||||
// One lab item per tooth (or arch sentinel): stacked types on that
|
||||
// tooth become one prosthesisTypeCode group key; steps are the union.
|
||||
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
|
||||
const teethByBridge = new Map<string, Set<string>>();
|
||||
for (const [toothKey, bucket] of byTooth) {
|
||||
if (!bucket.selectionGroupId) continue;
|
||||
const tooth = toothKey.slice(bucket.sourceKey.length + 2);
|
||||
const bridgeKey = `${bucket.sourceKey}::${bucket.selectionGroupId}`;
|
||||
const set = teethByBridge.get(bridgeKey) ?? new Set<string>();
|
||||
set.add(tooth);
|
||||
teethByBridge.set(bridgeKey, set);
|
||||
}
|
||||
const bridgeKeys = new Set(
|
||||
[...teethByBridge.entries()]
|
||||
.filter(([, teeth]) => teeth.size > 1)
|
||||
.map(([key]) => key),
|
||||
);
|
||||
|
||||
type Pipeline = {
|
||||
sourceKey: string;
|
||||
treatmentDetailId: string | null;
|
||||
lineId: string | null;
|
||||
treatmentType: string;
|
||||
selectionGroupId: string;
|
||||
tooth: string;
|
||||
teeth: string[];
|
||||
codes: string[];
|
||||
};
|
||||
|
||||
type PartialMerge = {
|
||||
sourceKey: string;
|
||||
treatmentDetailId: string | null;
|
||||
lineId: string | null;
|
||||
treatmentType: string;
|
||||
teeth: string[];
|
||||
groupIds: Set<string>;
|
||||
};
|
||||
|
||||
type ConnectedTypeMerge = {
|
||||
sourceKey: string;
|
||||
treatmentDetailId: string | null;
|
||||
lineId: string | null;
|
||||
treatmentType: string;
|
||||
selectionGroupId: string;
|
||||
code: string;
|
||||
teeth: string[];
|
||||
};
|
||||
|
||||
const pipelines: Pipeline[] = [];
|
||||
const partialMerges = new Map<string, PartialMerge>();
|
||||
const connectedTypeMerges = new Map<string, ConnectedTypeMerge>();
|
||||
|
||||
for (const [toothKey, bucket] of byTooth) {
|
||||
const tooth = toothKey.slice(bucket.sourceKey.length + 2);
|
||||
const codes = [...bucket.codes];
|
||||
const lists = codes.map((code) =>
|
||||
const codes = new Set(bucket.codes);
|
||||
if (codes.has(PARTIAL_DENTURE_CODE)) {
|
||||
codes.delete(PARTIAL_DENTURE_CODE);
|
||||
const merge = partialMerges.get(bucket.sourceKey) ?? {
|
||||
sourceKey: bucket.sourceKey,
|
||||
treatmentDetailId: bucket.treatmentDetailId,
|
||||
lineId: bucket.lineId,
|
||||
treatmentType: bucket.treatmentType,
|
||||
teeth: [],
|
||||
groupIds: new Set<string>(),
|
||||
};
|
||||
merge.teeth.push(tooth);
|
||||
if (bucket.selectionGroupId) merge.groupIds.add(bucket.selectionGroupId);
|
||||
partialMerges.set(bucket.sourceKey, merge);
|
||||
}
|
||||
if (codes.size === 0) continue;
|
||||
|
||||
const bridgeKey = bucket.selectionGroupId
|
||||
? `${bucket.sourceKey}::${bucket.selectionGroupId}`
|
||||
: '';
|
||||
if (bridgeKey && bridgeKeys.has(bridgeKey)) {
|
||||
for (const code of codes) {
|
||||
const typeKey = `${bridgeKey}::${code}`;
|
||||
const merge = connectedTypeMerges.get(typeKey) ?? {
|
||||
sourceKey: bucket.sourceKey,
|
||||
treatmentDetailId: bucket.treatmentDetailId,
|
||||
lineId: bucket.lineId,
|
||||
treatmentType: bucket.treatmentType,
|
||||
selectionGroupId: bucket.selectionGroupId,
|
||||
code,
|
||||
teeth: [],
|
||||
};
|
||||
if (!merge.teeth.includes(tooth)) merge.teeth.push(tooth);
|
||||
connectedTypeMerges.set(typeKey, merge);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
pipelines.push({
|
||||
sourceKey: bucket.sourceKey,
|
||||
treatmentDetailId: bucket.treatmentDetailId,
|
||||
lineId: bucket.lineId,
|
||||
treatmentType: bucket.treatmentType,
|
||||
selectionGroupId: bucket.selectionGroupId,
|
||||
tooth,
|
||||
teeth: [tooth],
|
||||
codes: [...codes],
|
||||
});
|
||||
}
|
||||
|
||||
for (const merge of connectedTypeMerges.values()) {
|
||||
const teeth = sortProsthesisTeeth(merge.teeth);
|
||||
pipelines.push({
|
||||
sourceKey: merge.sourceKey,
|
||||
treatmentDetailId: merge.treatmentDetailId,
|
||||
lineId: merge.lineId,
|
||||
treatmentType: merge.treatmentType,
|
||||
selectionGroupId: merge.selectionGroupId,
|
||||
tooth: teeth[0] ?? '',
|
||||
teeth,
|
||||
codes: [merge.code],
|
||||
});
|
||||
}
|
||||
|
||||
for (const merge of partialMerges.values()) {
|
||||
const teeth = sortProsthesisTeeth(merge.teeth);
|
||||
const sharedGroup = merge.groupIds.size === 1 ? [...merge.groupIds][0] : '';
|
||||
pipelines.push({
|
||||
sourceKey: merge.sourceKey,
|
||||
treatmentDetailId: merge.treatmentDetailId,
|
||||
lineId: merge.lineId,
|
||||
treatmentType: merge.treatmentType,
|
||||
selectionGroupId: sharedGroup,
|
||||
tooth: teeth[0] ?? '',
|
||||
teeth,
|
||||
codes: [PARTIAL_DENTURE_CODE],
|
||||
});
|
||||
}
|
||||
|
||||
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
|
||||
|
||||
for (const pipeline of pipelines) {
|
||||
const lists = pipeline.codes.map((code) =>
|
||||
(stepsByProsthesisCode.get(code) ?? []).map((s) => s.workflowStepCode),
|
||||
);
|
||||
const unioned = unionWorkflowStepCodes(lists);
|
||||
@@ -119,19 +251,19 @@ export async function generateLabCaseTasks(
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupCode = prosthesisGroupKey(codes);
|
||||
const groupCode = prosthesisGroupKey(pipeline.codes);
|
||||
|
||||
unioned.forEach((workflowStepCode, index) => {
|
||||
taskRows.push({
|
||||
labCaseId,
|
||||
treatmentDetailId: bucket.treatmentDetailId,
|
||||
lineId: bucket.lineId,
|
||||
sourceKey: bucket.sourceKey,
|
||||
tooth,
|
||||
teeth: [tooth],
|
||||
treatmentType: bucket.treatmentType,
|
||||
treatmentDetailId: pipeline.treatmentDetailId,
|
||||
lineId: pipeline.lineId,
|
||||
sourceKey: pipeline.sourceKey,
|
||||
tooth: pipeline.tooth,
|
||||
teeth: pipeline.teeth,
|
||||
treatmentType: pipeline.treatmentType,
|
||||
prosthesisTypeCode: groupCode,
|
||||
selectionGroupId: bucket.selectionGroupId,
|
||||
selectionGroupId: pipeline.selectionGroupId,
|
||||
workflowStepCode,
|
||||
stepOrder: index + 1,
|
||||
stepLabel: stepLabels.get(workflowStepCode) ?? workflowStepCode,
|
||||
|
||||
@@ -74,7 +74,7 @@ export class ProsthesisCatalogService implements OnModuleInit {
|
||||
}
|
||||
|
||||
async list(localeInput?: string | null): Promise<ProsthesisTypeCatalogEntry[]> {
|
||||
this.ensureLoaded();
|
||||
await this.refresh();
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
const codes = [...this.byCode.keys()];
|
||||
const labels = await this.catalogLabels.resolveLabels(
|
||||
|
||||
@@ -785,7 +785,8 @@ export class TreatmentsService {
|
||||
throw new AppException(ErrorCode.TREATMENT_TOOTH_UNKNOWN_DETAIL, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const teeth = normalizeTeeth(detail.teeth);
|
||||
if (!teeth.includes(row.tooth)) {
|
||||
const isArchSentinel = row.tooth === 'UA' || row.tooth === 'LA';
|
||||
if (!isArchSentinel && !teeth.includes(row.tooth)) {
|
||||
throw new AppException(ErrorCode.TREATMENT_TOOTH_NOT_ON_DETAIL, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode);
|
||||
|
||||
Reference in New Issue
Block a user