/** * 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 = { crown: 'Crowns', indirect: 'Veneer/Inlay/Onlay/Overlay', implant: 'Implants', post_core: 'Post & core', removable: 'Removable', appliance: 'Appliances', digital: 'Digital', }; const SUBCATEGORY_LABELS: Record = { 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, or overlay.', ], ['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', '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 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.'], ]; function xmlEscape(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } 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 `${xmlEscape(value)}`; }) .join(''); return `${inner}`; }) .join(''); const dim = `A1:${colLetter(Math.max(0, ...(rows.map((r) => r.length - 1))))}${rows.length || 1}`; return ( `` + `` + `` + `${cells}` + `` ); } 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 = `` + `` + `` + `` + `` + `` + `` + `` + ``; const rels = `` + `` + `` + ``; const workbook = `` + `` + `` + `` + `` + `` + `` + ``; const workbookRels = `` + `` + `` + `` + `` + ``; 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}`);