v2.1
Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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, '&')
|
||||
.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);
|
||||
}
|
||||
Reference in New Issue
Block a user