Files
dyolink/backend/prisma/export-prosthesis-catalog.ts

295 lines
10 KiB
TypeScript

/**
* 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: 'Veneer/Inlay/Onlay/Overlay',
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, 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, '&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}`);