Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
225 lines
9.3 KiB
JavaScript
225 lines
9.3 KiB
JavaScript
/**
|
|
* 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/** 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);
|
|
}
|