/** * 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, '''); } /** 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 ``; } if (typeof value === 'number' && Number.isFinite(value)) { return `${value}`; } const styleAttr = header ? ' s="1"' : ''; return `${escapeXml(value)}`; } /** * @param {{ name:string, columns:string[], rows:Array> }} 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(`${headerCells}`); // Data rows sheet.rows.forEach((row, r) => { const cells = row.map((val, i) => cellXml(`${colLetter(i)}${r + 2}`, val)).join(''); rowsXml.push(`${cells}`); }); const colsCount = sheet.columns.length; const colsXml = `${Array.from({ length: colsCount }, (_, i) => ``).join('')}`; return ` ${colsXml} ${rowsXml.join('\n')} `; } function contentTypesXml(sheetCount) { const overrides = Array.from({ length: sheetCount }, (_, i) => `` ).join(''); return ` ${overrides} `; } function workbookXml(sheets) { const sheetEls = sheets .map((s, i) => ``) .join(''); return ` ${sheetEls} `; } function workbookRelsXml(sheets) { const rels = sheets .map((_, i) => ``) .join(''); const stylesRid = sheets.length + 1; return ` ${rels} `; } const ROOT_RELS_XML = ` `; const STYLES_XML = ` `; /** * Construit un buffer .xlsx à partir d'une liste de feuilles. * @param {Array<{ name:string, columns:string[], rows:Array> }>} sheets * @returns {Promise} */ 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} */ 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); }