/**
* 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);
}