295 lines
10 KiB
TypeScript
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, '&')
|
|
.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 `<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}`);
|