Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-10-07 17:13:03 +02:00
co-authored by Cursor
parent 6c0d7e47b8
commit 30921aade6
75 changed files with 15618 additions and 1300 deletions
+224
View File
@@ -0,0 +1,224 @@
/**
* Export Excel (.xlsx) — APEX
* Génère un classeur OOXML minimal mais valide à partir de la liste de débitage,
* de la quincaillerie et du calepinage, sans dépendance externe (utilise jszip).
*
* Le format .xlsx est une archive ZIP de fichiers XML. On utilise des chaînes
* "inlineStr" pour éviter la table de chaînes partagées.
*/
import JSZip from 'jszip';
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** Convertit un index de colonne 0-based en lettre Excel (0 -> A, 26 -> AA). */
function colLetter(index) {
let n = index;
let s = '';
do {
s = String.fromCharCode(65 + (n % 26)) + s;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return s;
}
function cellXml(ref, value, { header = false } = {}) {
if (value === null || value === undefined || value === '') {
return `<c r="${ref}"${header ? ' s="1"' : ''}/>`;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return `<c r="${ref}"${header ? ' s="1"' : ''}><v>${value}</v></c>`;
}
const styleAttr = header ? ' s="1"' : '';
return `<c r="${ref}"${styleAttr} t="inlineStr"><is><t xml:space="preserve">${escapeXml(value)}</t></is></c>`;
}
/**
* @param {{ name:string, columns:string[], rows:Array<Array<string|number>> }} sheet
*/
function sheetXml(sheet) {
const rowsXml = [];
// Header row
const headerCells = sheet.columns
.map((c, i) => cellXml(`${colLetter(i)}1`, c, { header: true }))
.join('');
rowsXml.push(`<row r="1">${headerCells}</row>`);
// Data rows
sheet.rows.forEach((row, r) => {
const cells = row.map((val, i) => cellXml(`${colLetter(i)}${r + 2}`, val)).join('');
rowsXml.push(`<row r="${r + 2}">${cells}</row>`);
});
const colsCount = sheet.columns.length;
const colsXml = `<cols>${Array.from({ length: colsCount }, (_, i) => `<col min="${i + 1}" max="${i + 1}" width="22" customWidth="1"/>`).join('')}</cols>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
${colsXml}
<sheetData>
${rowsXml.join('\n')}
</sheetData>
</worksheet>`;
}
function contentTypesXml(sheetCount) {
const overrides = Array.from({ length: sheetCount }, (_, i) =>
`<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`
).join('');
return `<?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/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
${overrides}
</Types>`;
}
function workbookXml(sheets) {
const sheetEls = sheets
.map((s, i) => `<sheet name="${escapeXml(s.name).slice(0, 31)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`)
.join('');
return `<?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>${sheetEls}</sheets>
</workbook>`;
}
function workbookRelsXml(sheets) {
const rels = sheets
.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`)
.join('');
const stylesRid = sheets.length + 1;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
${rels}
<Relationship Id="rId${stylesRid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`;
}
const ROOT_RELS_XML = `<?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 STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFE8EAED"/><bgColor indexed="64"/></patternFill></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/></cellXfs>
</styleSheet>`;
/**
* Construit un buffer .xlsx à partir d'une liste de feuilles.
* @param {Array<{ name:string, columns:string[], rows:Array<Array<string|number>> }>} sheets
* @returns {Promise<Buffer>}
*/
export async function buildXlsxBuffer(sheets) {
const zip = new JSZip();
zip.file('[Content_Types].xml', contentTypesXml(sheets.length));
zip.file('_rels/.rels', ROOT_RELS_XML);
zip.file('xl/workbook.xml', workbookXml(sheets));
zip.file('xl/_rels/workbook.xml.rels', workbookRelsXml(sheets));
zip.file('xl/styles.xml', STYLES_XML);
sheets.forEach((s, i) => {
zip.file(`xl/worksheets/sheet${i + 1}.xml`, sheetXml(s));
});
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
}
/**
* Construit le classeur Excel de la liste de débitage (+ quincaillerie + calepinage).
* @param {object} params
* @param {object} params.cuttingList
* @param {object} [params.hardware] résultat de generateHardwareList
* @param {object} [params.cuttingPlan] résultat de generateCuttingPlan
* @returns {Promise<Buffer>}
*/
export async function buildCuttingListXlsx({ cuttingList, hardware = null, cuttingPlan = null }) {
const sheets = [];
// Feuille 1 — Débitage
const debitColumns = [
'Finition', 'Pièce', 'L finie (mm)', 'H finie (mm)', 'Ép. (mm)', 'Qté',
'L brute (mm)', 'H brute (mm)', 'Surcote (mm)', 'Surface (m²)',
'Chants (côtés)', 'Sens de collage', 'Usinage'
];
const debitRows = [];
for (const group of (cuttingList?.byFinish || [])) {
for (const p of (group.pieces || [])) {
const banding = p.edgeBanding && p.edgeBanding.type !== 'none'
? `${p.edgeBanding.sides} côtés`
: '—';
const sens = p.edgeBanding?.direction?.label || '—';
const usinage = p.machining ? Object.keys(p.machining).join(', ') : '—';
debitRows.push([
group.finishLabel || '—',
p.description || '',
Number(p.widthMm) || 0,
Number(p.heightMm) || 0,
Number(p.thicknessMm) || 0,
Number(p.quantity) || 0,
Number(p.rawCutWidthMm) || 0,
Number(p.rawCutHeightMm) || 0,
Number(p.surcoteMm) || 0,
Number((p.surfaceM2 || 0).toFixed(3)),
banding,
sens,
usinage
]);
}
}
sheets.push({ name: 'Débitage', columns: debitColumns, rows: debitRows });
// Feuille 2 — Quincaillerie
if (hardware && Array.isArray(hardware.items) && hardware.items.length) {
const hwColumns = ['Désignation', 'Référence', 'Qté', 'Prix unitaire (CHF)', 'Total (CHF)'];
const hwRows = hardware.items.map((it) => [
it.label || it.name || '',
it.code || it.reference || '—',
Number(it.quantity) || 0,
it.unitPriceChf != null ? Number(it.unitPriceChf) : '—',
it.lineTotalChf != null ? Number(it.lineTotalChf) : (it.totalChf != null ? Number(it.totalChf) : '—')
]);
sheets.push({ name: 'Quincaillerie', columns: hwColumns, rows: hwRows });
}
// Feuille 3 — Calepinage / plan de coupe
if (cuttingPlan && Array.isArray(cuttingPlan.boards) && cuttingPlan.boards.length) {
const planColumns = [
'Panneau', 'Format (mm)', 'Pièces placées', 'Surface utilisée (m²)',
'Surface panneau (m²)', 'Chute (m²)', 'Chute (%)'
];
const planRows = cuttingPlan.boards.map((b, i) => [
`Panneau ${i + 1}`,
`${b.boardWidthMm} x ${b.boardHeightMm}`,
(b.placements || []).length,
Number((b.usedAreaM2 || 0).toFixed(3)),
Number((b.boardAreaM2 || 0).toFixed(3)),
Number((b.wasteAreaM2 || 0).toFixed(3)),
Number((b.wastePercent || 0).toFixed(1))
]);
// Ligne de synthèse globale
planRows.push([
'TOTAL',
'',
cuttingPlan.totalPlacedPieces ?? '',
Number((cuttingPlan.totalUsedAreaM2 || 0).toFixed(3)),
Number((cuttingPlan.totalBoardAreaM2 || 0).toFixed(3)),
Number((cuttingPlan.totalWasteAreaM2 || 0).toFixed(3)),
Number((cuttingPlan.globalWastePercent || 0).toFixed(1))
]);
sheets.push({ name: 'Calepinage', columns: planColumns, rows: planRows });
}
return buildXlsxBuffer(sheets);
}