improvement: new prosthesis type data structure implemented and finally working!

This commit is contained in:
2026-09-01 21:03:04 +03:30
parent dc71c73ced
commit a3c14a18c1
51 changed files with 3306 additions and 1117 deletions

View File

@@ -29,7 +29,6 @@ export const TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [
{ code: 'perio', labDependent: false, sortOrder: 9 },
{ code: 'pediatrics', labDependent: false, sortOrder: 10 },
{ code: 'extraction', labDependent: false, sortOrder: 11 },
// Appointment-only: not real treatment plan details.
{
code: 'clinic_visit',
labDependent: false,
@@ -69,140 +68,278 @@ export const LAB_WORKFLOW_STEPS = [
{ code: 'pressing', sortOrder: 14 },
] as const;
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch';
export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch';
export type ProsthesisAddonKind = '' | 'implant' | 'post_core';
export type ProsthesisTypeSeed = {
code: string;
sortOrder: number;
skipPackingShipping?: boolean;
isActive?: boolean;
category: string;
subcategory?: string;
chartRegion: ProsthesisChartRegion;
stackGroup: ProsthesisStackGroup;
addonKind?: ProsthesisAddonKind;
/** Manufacturing steps between design and packing (exclusive of universal scan/design/pack/ship). */
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;
function crown(
code: string,
sortOrder: number,
manufacturingSteps: readonly string[],
extra?: Partial<ProsthesisTypeSeed>,
): ProsthesisTypeSeed {
return {
code,
sortOrder,
category: 'crown',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps,
...extra,
};
}
function implantAddon(
code: string,
sortOrder: number,
manufacturingSteps: readonly string[],
): ProsthesisTypeSeed {
return {
code,
sortOrder,
category: 'implant',
chartRegion: 'root',
stackGroup: 'implant',
addonKind: 'implant',
manufacturingSteps,
};
}
function indirect(
indication: string,
technique: 'full_contour' | 'layered',
sortOrder: number,
): ProsthesisTypeSeed {
return {
code: `${indication}_${technique}`,
sortOrder,
category: 'indirect',
subcategory: indication,
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: technique === 'layered' ? LAYERED : FULL_CONTOUR,
};
}
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']),
indirect('veneer', 'full_contour', 20),
indirect('veneer', 'layered', 21),
indirect('inlay', 'full_contour', 22),
indirect('inlay', 'layered', 23),
indirect('onlay', 'full_contour', 24),
indirect('onlay', 'layered', 25),
indirect('overlay', 'full_contour', 26),
indirect('overlay', 'layered', 27),
{
code: 'pfm_crown',
sortOrder: 1,
manufacturingSteps: ['milling_wet', 'build_up', 'stain', 'glaze', 'polish_prep'],
code: 'cast_post_core',
sortOrder: 30,
category: 'post_core',
chartRegion: 'root',
stackGroup: 'post_core',
addonKind: 'post_core',
manufacturingSteps: POST_CORE,
},
{
code: 'pfz_crown',
sortOrder: 2,
manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'],
code: 'fiber_post_core',
sortOrder: 31,
category: 'post_core',
chartRegion: 'root',
stackGroup: 'post_core',
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: 'monolithic_zirconia',
sortOrder: 3,
manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'glass_ceramic_crown',
sortOrder: 4,
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'full_metal_crown',
sortOrder: 5,
code: 'screw_retained',
sortOrder: 45,
category: 'implant',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: ['milling_wet', 'polish_prep'],
},
{
code: 'temporary_resin_crown',
sortOrder: 6,
manufacturingSteps: ['milling_wet', 'polish_prep'],
code: 'complete_denture',
sortOrder: 50,
category: 'removable',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: DENTURE,
},
{
code: 'pmma',
sortOrder: 7,
manufacturingSteps: ['milling_dry', 'polish_prep'],
code: 'partial_denture',
sortOrder: 51,
category: 'removable',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: DENTURE,
},
{
code: 'peek_crown',
sortOrder: 8,
manufacturingSteps: ['milling_dry', 'polish_prep'],
code: 'overdenture',
sortOrder: 52,
category: 'removable',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: DENTURE,
},
{
code: 'veneer_zirconia',
sortOrder: 9,
manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'],
code: 'night_guard_soft',
sortOrder: 60,
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'veneer_ips_press',
sortOrder: 10,
manufacturingSteps: [
'printer_resin',
'build_up',
'stain',
'glaze',
'polish_prep',
'pressing',
],
code: 'night_guard_hard',
sortOrder: 61,
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'veneer_ips_cad',
sortOrder: 11,
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
code: 'night_guard_dual',
sortOrder: 62,
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'bleaching_tray',
sortOrder: 63,
category: 'appliance',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'clear_aligner',
sortOrder: 64,
category: 'appliance',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'soft_structure',
sortOrder: 12,
sortOrder: 65,
category: 'appliance',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: ['milling_dry', 'sinter'],
},
{
code: 'customized_abutment',
sortOrder: 13,
manufacturingSteps: ['milling_wet', 'polish_prep'],
code: 'surgical_guide',
sortOrder: 70,
category: 'digital',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'prefabricated_abutment',
sortOrder: 14,
manufacturingSteps: ['polish_prep'],
code: 'smile_design',
sortOrder: 71,
category: 'digital',
chartRegion: 'arch',
stackGroup: 'arch',
skipPackingShipping: true,
manufacturingSteps: [],
},
{
code: 'ti_base_abutment',
sortOrder: 15,
manufacturingSteps: ['polish_prep'],
code: 'mockup',
sortOrder: 72,
category: 'digital',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: ['printer_resin'],
},
// Legacy leaves — labels for historical cases; hidden from the picker.
{
code: 'veneer_zirconia',
sortOrder: 200,
isActive: false,
category: 'indirect',
subcategory: 'veneer',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: LAYERED,
},
{
code: 'multi_unit_abutment',
sortOrder: 16,
manufacturingSteps: ['polish_prep'],
code: 'veneer_ips_press',
sortOrder: 201,
isActive: false,
category: 'indirect',
subcategory: 'veneer',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: ['printer_resin', 'build_up', 'stain', 'glaze', 'polish_prep', 'pressing'],
},
{
code: 'zirconia_abutment',
sortOrder: 17,
manufacturingSteps: ['milling_dry', 'sinter', 'polish_prep'],
},
{
code: 'screw_retained',
sortOrder: 18,
manufacturingSteps: [
'milling_wet',
'printer_metal',
'sinter',
'build_up',
'stain',
'glaze',
'polish_prep',
],
code: 'veneer_ips_cad',
sortOrder: 202,
isActive: false,
category: 'indirect',
subcategory: 'veneer',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: FULL_CONTOUR,
},
{
code: 'zirconia_overlay',
sortOrder: 19,
sortOrder: 203,
isActive: false,
category: 'indirect',
subcategory: 'overlay',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'ips_overlay',
sortOrder: 20,
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'smile_design',
sortOrder: 21,
skipPackingShipping: true,
manufacturingSteps: ['printer_resin'],
},
{
code: 'mockup',
sortOrder: 22,
manufacturingSteps: ['printer_resin'],
sortOrder: 204,
isActive: false,
category: 'indirect',
subcategory: 'overlay',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: FULL_CONTOUR,
},
];
@@ -217,6 +354,12 @@ export function buildProsthesisStepCodes(type: ProsthesisTypeSeed): string[] {
return steps;
}
export {
unionWorkflowStepCodes,
prosthesisGroupKey,
splitProsthesisGroupKey,
} from '../src/common/prosthesis-group';
const TREATMENT_LABELS: Record<string, Record<string, string>> = {
restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' },
specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' },
@@ -237,7 +380,7 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
hygiene: { en: 'Hygiene', fa: 'بهداشت', nl: 'Hygiëne' },
};
const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
export const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' },
pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' },
monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' },
@@ -246,23 +389,43 @@ const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' },
pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' },
peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' },
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
customized_abutment: { en: 'Customized Abutment', fa: 'اباتمنت سفارشی', nl: 'Aangepast Abutment' },
press_ceramic: { en: 'Pressed Ceramic / IPS e.max', fa: 'سرامیک پرس / IPS e.max', nl: 'Perskeramiek / IPS e.max' },
veneer_full_contour: { en: 'Veneer · Full contour', fa: 'ونیر · تمام‌کانتور', nl: 'Veneer · Full contour' },
veneer_layered: { en: 'Veneer · Layered ceramic', fa: 'ونیر · سرامیک لایه‌ای', nl: 'Veneer · Gelaagd keramiek' },
inlay_full_contour: { en: 'Inlay · Full contour', fa: 'اینلی · تمام‌کانتور', nl: 'Inlay · Full contour' },
inlay_layered: { en: 'Inlay · Layered ceramic', fa: 'اینلی · سرامیک لایه‌ای', nl: 'Inlay · Gelaagd keramiek' },
onlay_full_contour: { en: 'Onlay · Full contour', fa: 'آنلی · تمام‌کانتور', nl: 'Onlay · Full contour' },
onlay_layered: { en: 'Onlay · Layered ceramic', fa: 'آنلی · سرامیک لایه‌ای', nl: 'Onlay · Gelaagd keramiek' },
overlay_full_contour: { en: 'Overlay · Full contour', fa: 'اورلی · تمام‌کانتور', nl: 'Overlay · Full contour' },
overlay_layered: { en: 'Overlay · Layered ceramic', fa: 'اورلی · سرامیک لایه‌ای', nl: 'Overlay · Gelaagd keramiek' },
cast_post_core: { en: 'Cast Post & Core', fa: 'پست و کور ریختگی', nl: 'Gegoten Stiftopbouw' },
fiber_post_core: { en: 'Fiber Post & Core', fa: 'پست و کور فایبر', nl: 'Fiber Stiftopbouw' },
customized_abutment: { en: 'Custom Abutment (Titanium)', fa: 'اباتمنت سفارشی (تیتانیوم)', nl: 'Aangepast abutment (titanium)' },
prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' },
ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' },
multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' },
zirconia_abutment: { en: 'Zirconia Abutment', fa: 'اباتمنت زیرکونیا', nl: 'Zirconia Abutment' },
zirconia_abutment: { en: 'Custom Abutment (Zirconia)', fa: 'اباتمنت سفارشی (زیرکونیا)', nl: 'Aangepast abutment (zirconia)' },
screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' },
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
complete_denture: { en: 'Complete Denture', fa: 'دنچر کامل', nl: 'Volledige prothese' },
partial_denture: { en: 'Partial Denture', fa: 'دنچر پارسیل', nl: 'Partiële prothese' },
overdenture: { en: 'Overdenture', fa: 'اوردنچر', nl: 'Overkappingsprothese' },
night_guard_soft: { en: 'Night Guard · Soft', fa: 'نایت گارد · نرم', nl: 'Nachtbeugel · Zacht' },
night_guard_hard: { en: 'Night Guard · Hard', fa: 'نایت گارد · سخت', nl: 'Nachtbeugel · Hard' },
night_guard_dual: { en: 'Night Guard · Dual laminate', fa: 'نایت گارد · دو لایه', nl: 'Nachtbeugel · Dual laminate' },
bleaching_tray: { en: 'Bleaching Tray', fa: 'تری بلیچینگ', nl: 'Bleeklepel' },
clear_aligner: { en: 'Clear Aligner', fa: 'الاینر شفاف', nl: 'Clear aligner' },
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
surgical_guide: { en: 'Surgical Guide', fa: 'گاید جراحی', nl: 'Chirurgische mal' },
smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' },
mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' },
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
};
const WORKFLOW_STEP_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' },
design: { en: 'Design', fa: 'طراحی', nl: 'Ontwerp' },
milling_dry: { en: 'Milling Dry', fa: 'فرز خشک', nl: 'Droog Frezen' },

View File

@@ -0,0 +1,286 @@
/**
* Regenerates docs/prosthesis-catalog.xlsx from catalog-seed-data.ts.
*
* cd backend && npx ts-node --transpile-only prisma/export-prosthesis-catalog.ts
*/
import * as fs from 'fs';
import * as path from 'path';
import * as zlib from 'zlib';
import {
PROSTHESIS_TYPES,
PROSTHESIS_LABELS,
WORKFLOW_STEP_LABELS,
buildProsthesisStepCodes,
} from './catalog-seed-data';
const CATEGORY_LABELS: Record<string, string> = {
crown: 'Crowns',
indirect: 'Indirect restorations',
implant: 'Implants',
post_core: 'Post & core',
removable: 'Removable',
appliance: 'Appliances',
digital: 'Digital',
};
const SUBCATEGORY_LABELS: Record<string, string> = {
full_contour: 'Full contour',
layered: 'Layered',
veneer: 'Veneer',
inlay: 'Inlay',
onlay: 'Onlay',
overlay: 'Overlay',
complete_denture: 'Complete denture',
partial_denture: 'Partial denture',
night_guard: 'Night guard',
};
const STACK_RULES = [
['Slot', 'Rule'],
[
'restoration',
'One per tooth: crown, veneer, inlay, onlay, overlay, or screw-retained.',
],
['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.'],
[
'arch',
'One arch product (denture, night guard, digital). Most arch products block implant add-ons; overdenture may take an implant add-on.',
],
[
'screw_retained',
'Fills the restoration slot and blocks implant + post & core (it is the restoration).',
],
['maximum', 'Usual maximum is 3 types per tooth (e.g. zirconia crown + Ti-base + fiber post).'],
['two_crowns', 'Two crowns cannot stack.'],
];
function xmlEscape(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function colLetter(index: number): string {
let n = index + 1;
let s = '';
while (n > 0) {
const r = (n - 1) % 26;
s = String.fromCharCode(65 + r) + s;
n = Math.floor((n - 1) / 26);
}
return s;
}
function sheetXml(rows: string[][]): string {
const cells = rows
.map((row, r) => {
const rowNum = r + 1;
const inner = row
.map((value, c) => {
const ref = `${colLetter(c)}${rowNum}`;
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xmlEscape(value)}</t></is></c>`;
})
.join('');
return `<row r="${rowNum}">${inner}</row>`;
})
.join('');
const dim = `A1:${colLetter(Math.max(0, ...(rows.map((r) => r.length - 1))))}${rows.length || 1}`;
return (
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
`<dimension ref="${dim}"/>` +
`<sheetData>${cells}</sheetData>` +
`</worksheet>`
);
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
function crc32(buf: Buffer): number {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function zipStore(files: Array<{ name: string; data: Buffer }>): Buffer {
const locals: Buffer[] = [];
const centrals: Buffer[] = [];
let offset = 0;
for (const file of files) {
const name = Buffer.from(file.name, 'utf8');
const compressed = zlib.deflateRawSync(file.data);
const crc = crc32(file.data);
const local = Buffer.alloc(30 + name.length);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0, 6);
local.writeUInt16LE(8, 8);
local.writeUInt16LE(0, 10);
local.writeUInt16LE(0, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(compressed.length, 18);
local.writeUInt32LE(file.data.length, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
name.copy(local, 30);
locals.push(local, compressed);
const central = Buffer.alloc(46 + name.length);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0, 8);
central.writeUInt16LE(8, 10);
central.writeUInt16LE(0, 12);
central.writeUInt16LE(0, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(compressed.length, 20);
central.writeUInt32LE(file.data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(0, 38);
central.writeUInt32LE(offset, 42);
name.copy(central, 46);
centrals.push(central);
offset += local.length + compressed.length;
}
const centralDir = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(files.length, 8);
eocd.writeUInt16LE(files.length, 10);
eocd.writeUInt32LE(centralDir.length, 12);
eocd.writeUInt32LE(offset, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([...locals, centralDir, eocd]);
}
function buildWorkbook(): Buffer {
const typeRows: string[][] = [
[
'category',
'category_label',
'subcategory',
'subcategory_label',
'code',
'label_en',
'label_fa',
'label_nl',
'chartRegion',
'stackGroup',
'addonKind',
'active',
],
];
for (const type of PROSTHESIS_TYPES) {
const labels = PROSTHESIS_LABELS[type.code] ?? {};
typeRows.push([
type.category,
CATEGORY_LABELS[type.category] ?? type.category,
type.subcategory ?? '',
type.subcategory ? SUBCATEGORY_LABELS[type.subcategory] ?? type.subcategory : '',
type.code,
labels.en ?? type.code,
labels.fa ?? '',
labels.nl ?? '',
type.chartRegion,
type.stackGroup,
type.addonKind ?? '',
type.isActive === false ? 'false' : 'true',
]);
}
const stepRows: string[][] = [
['code', 'label_en', 'step_order', 'step_code', 'step_en', 'step_fa', 'step_nl'],
];
for (const type of PROSTHESIS_TYPES) {
if (type.isActive === false) continue;
const labels = PROSTHESIS_LABELS[type.code] ?? {};
const steps = buildProsthesisStepCodes(type);
steps.forEach((stepCode, index) => {
const stepLabels = WORKFLOW_STEP_LABELS[stepCode] ?? {};
stepRows.push([
type.code,
labels.en ?? type.code,
String(index + 1),
stepCode,
stepLabels.en ?? stepCode,
stepLabels.fa ?? '',
stepLabels.nl ?? '',
]);
});
}
const contentTypes =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
`<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +
`<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +
`<Override PartName="/xl/worksheets/sheet3.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +
`</Types>`;
const rels =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>` +
`</Relationships>`;
const workbook =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ` +
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
`<sheets>` +
`<sheet name="Types" sheetId="1" r:id="rId1"/>` +
`<sheet name="Steps" sheetId="2" r:id="rId2"/>` +
`<sheet name="Stack rules" sheetId="3" r:id="rId3"/>` +
`</sheets>` +
`</workbook>`;
const workbookRels =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>` +
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>` +
`<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet3.xml"/>` +
`</Relationships>`;
return zipStore([
{ name: '[Content_Types].xml', data: Buffer.from(contentTypes, 'utf8') },
{ name: '_rels/.rels', data: Buffer.from(rels, 'utf8') },
{ name: 'xl/workbook.xml', data: Buffer.from(workbook, 'utf8') },
{ name: 'xl/_rels/workbook.xml.rels', data: Buffer.from(workbookRels, 'utf8') },
{ name: 'xl/worksheets/sheet1.xml', data: Buffer.from(sheetXml(typeRows), 'utf8') },
{ name: 'xl/worksheets/sheet2.xml', data: Buffer.from(sheetXml(stepRows), 'utf8') },
{ name: 'xl/worksheets/sheet3.xml', data: Buffer.from(sheetXml(STACK_RULES), 'utf8') },
]);
}
const outPath = path.resolve(__dirname, '../../docs/prosthesis-catalog.xlsx');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, buildWorkbook());
console.log(`Wrote ${outPath}`);

View File

@@ -0,0 +1,26 @@
-- Prosthesis type tree fields + multiple work items per tooth.
ALTER TABLE "prosthesis_types"
ADD COLUMN IF NOT EXISTS "category" TEXT NOT NULL DEFAULT 'crown',
ADD COLUMN IF NOT EXISTS "subcategory" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "chartRegion" TEXT NOT NULL DEFAULT 'crown',
ADD COLUMN IF NOT EXISTS "stackGroup" TEXT NOT NULL DEFAULT 'restoration',
ADD COLUMN IF NOT EXISTS "addonKind" TEXT NOT NULL DEFAULT '';
ALTER TABLE "lab_case_tooth_prosthesis"
DROP CONSTRAINT IF EXISTS "lab_case_tooth_prosthesis_labCaseId_sourceKey_tooth_key";
DROP INDEX IF EXISTS "lab_case_tooth_prosthesis_labCaseId_sourceKey_tooth_key";
-- Deduplicate stacked rows that would collide after adding prosthesisTypeCode to the unique key
-- (legacy unique already forbade two types per tooth, so this is a no-op for current data).
DELETE FROM "lab_case_tooth_prosthesis" AS a
USING "lab_case_tooth_prosthesis" AS b
WHERE a."id" > b."id"
AND a."labCaseId" = b."labCaseId"
AND a."sourceKey" = b."sourceKey"
AND a."tooth" = b."tooth"
AND a."prosthesisTypeCode" = b."prosthesisTypeCode";
CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tooth_prosthesis_case_source_tooth_type_key"
ON "lab_case_tooth_prosthesis"("labCaseId", "sourceKey", "tooth", "prosthesisTypeCode");

View File

@@ -0,0 +1,12 @@
-- One LabCaseTask pipeline per tooth: unique must include tooth.
ALTER TABLE "lab_case_tasks" ADD COLUMN IF NOT EXISTS "tooth" TEXT NOT NULL DEFAULT '';
UPDATE "lab_case_tasks"
SET "tooth" = COALESCE("teeth"->>0, '')
WHERE "tooth" = '';
ALTER TABLE "lab_case_tasks" DROP CONSTRAINT IF EXISTS "lab_case_tasks_case_source_prosthesis_step_key";
DROP INDEX IF EXISTS "lab_case_tasks_case_source_prosthesis_step_key";
CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_case_source_tooth_prosthesis_step_key"
ON "lab_case_tasks"("labCaseId", "sourceKey", "tooth", "prosthesisTypeCode", "stepOrder");

View File

@@ -351,6 +351,16 @@ model ProsthesisType {
sortOrder Int @default(0)
isActive Boolean @default(true)
skipPackingShipping Boolean @default(false)
/// Picker column: crown | indirect | implant | post_core | removable | appliance | digital
category String @default("crown")
/// Optional second drill (veneer/inlay/onlay/overlay, night_guard, …). Empty if none.
subcategory String @default("")
/// Chart tint: crown | root | arch
chartRegion String @default("crown")
/// Mutual exclusion group: restoration | implant | post_core | arch
stackGroup String @default("restoration")
/// Also listed as an add-on: implant | post_core | empty
addonKind String @default("")
steps ProsthesisTypeStep[]
@@ -398,7 +408,7 @@ model LabCaseToothProsthesis {
detail TreatmentDetail? @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
line LabCaseLine? @relation(fields: [lineId], references: [id], onDelete: Cascade)
@@unique([labCaseId, sourceKey, tooth])
@@unique([labCaseId, sourceKey, tooth, prosthesisTypeCode], map: "lab_case_tooth_prosthesis_case_source_tooth_type_key")
@@map("lab_case_tooth_prosthesis")
}
@@ -409,6 +419,8 @@ model LabCaseTask {
lineId String?
/// treatmentDetailId (clinic) or lineId (lab-origin) — required unique grouping key.
sourceKey String
/// Single FDI tooth or arch sentinel (UA/LA). One task pipeline per tooth.
tooth String @default("")
teeth Json
treatmentType String
prosthesisTypeCode String
@@ -433,7 +445,7 @@ model LabCaseTask {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([labCaseId, sourceKey, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_source_prosthesis_step_key")
@@unique([labCaseId, sourceKey, tooth, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_source_tooth_prosthesis_step_key")
@@index([labCaseId, status])
@@index([assigneeUserId])
@@map("lab_case_tasks")

View File

@@ -183,22 +183,30 @@ async function main() {
);
for (const type of PROSTHESIS_TYPES) {
const treeFields = {
sortOrder: type.sortOrder,
skipPackingShipping: type.skipPackingShipping ?? false,
isActive: type.isActive ?? true,
category: type.category,
subcategory: type.subcategory ?? '',
chartRegion: type.chartRegion,
stackGroup: type.stackGroup,
addonKind: type.addonKind ?? '',
};
const prosthesisType = await prisma.prosthesisType.upsert({
where: { code: type.code },
update: {
sortOrder: type.sortOrder,
skipPackingShipping: type.skipPackingShipping ?? false,
isActive: true,
},
update: treeFields,
create: {
id: randomUUID(),
code: type.code,
sortOrder: type.sortOrder,
skipPackingShipping: type.skipPackingShipping ?? false,
isActive: true,
...treeFields,
},
});
await prisma.prosthesisTypeStep.deleteMany({
where: { prosthesisTypeId: prosthesisType.id },
});
const stepCodes = buildProsthesisStepCodes(type);
for (const [index, stepCode] of stepCodes.entries()) {
const labWorkflowStepId = workflowStepByCode.get(stepCode);
@@ -206,15 +214,8 @@ async function main() {
throw new Error(`Unknown workflow step code: ${stepCode}`);
}
await prisma.prosthesisTypeStep.upsert({
where: {
prosthesisTypeId_stepOrder: {
prosthesisTypeId: prosthesisType.id,
stepOrder: index + 1,
},
},
update: { labWorkflowStepId },
create: {
await prisma.prosthesisTypeStep.create({
data: {
id: randomUUID(),
prosthesisTypeId: prosthesisType.id,
labWorkflowStepId,