v2.1
Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import { DRAWERS, NICHE } from '../../shared/manufacturing.js';
|
||||
import {
|
||||
DRAWERS,
|
||||
NICHE,
|
||||
PLINTH,
|
||||
MAX_PANEL_DIMENSION_MM,
|
||||
MAX_PANEL_SHORT_SIDE_MM,
|
||||
validateHorizontalSpans
|
||||
} from '../../shared/manufacturing.js';
|
||||
|
||||
export function validateConfig(config = {}, settings = {}) {
|
||||
const errors = [];
|
||||
@@ -12,6 +19,53 @@ export function validateConfig(config = {}, settings = {}) {
|
||||
const modules = Array.isArray(config.modules) ? config.modules : [];
|
||||
const allowSliding = String(settings.allow_sliding_doors ?? '0') === '1';
|
||||
const sanitaryGapMm = Number(settings.sanitary_gap_mm || NICHE.sanitaryGapAround);
|
||||
const plinthHeightMm = Number(config.plinthHeightMm ?? 100);
|
||||
const maxPanelLongMm = Number(
|
||||
settings.panel_max_width_mm ??
|
||||
settings.max_panel_width_mm ??
|
||||
settings.max_panel_dimension_mm ??
|
||||
MAX_PANEL_DIMENSION_MM
|
||||
);
|
||||
const maxPanelShortMm = Number(
|
||||
settings.panel_max_short_side_mm ??
|
||||
settings.max_panel_short_side_mm ??
|
||||
MAX_PANEL_SHORT_SIDE_MM
|
||||
);
|
||||
|
||||
if (widthMm > maxPanelLongMm) {
|
||||
errors.push({
|
||||
field: 'widthMm',
|
||||
message: `Largeur maximale ${maxPanelLongMm} mm (capacité CNC).`
|
||||
});
|
||||
}
|
||||
if (depthMm > maxPanelShortMm) {
|
||||
errors.push({
|
||||
field: 'depthMm',
|
||||
message: `Profondeur maximale ${maxPanelShortMm} mm (capacité CNC).`
|
||||
});
|
||||
}
|
||||
|
||||
if (config.horizontalSpans != null && !Array.isArray(config.horizontalSpans)) {
|
||||
errors.push({
|
||||
field: 'horizontalSpans',
|
||||
message: 'Les traverses horizontales doivent être fournies sous forme de tableau.'
|
||||
});
|
||||
} else {
|
||||
validateHorizontalSpans(config, { maxPanelWidthMm: maxPanelLongMm }).forEach((issue) => {
|
||||
errors.push({
|
||||
field: issue.field,
|
||||
code: issue.code,
|
||||
message: issue.message
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!PLINTH.targetHeightsMm.includes(plinthHeightMm)) {
|
||||
errors.push({
|
||||
field: 'plinthHeightMm',
|
||||
message: `Hauteur AXILO 78 invalide. Valeurs admises : ${PLINTH.targetHeightsMm.join(', ')} mm.`
|
||||
});
|
||||
}
|
||||
|
||||
if (doorType === 'sliding' && !allowSliding) {
|
||||
errors.push({ field: 'doorType', message: 'Portes coulissantes désactivées.' });
|
||||
@@ -35,13 +89,12 @@ export function validateConfig(config = {}, settings = {}) {
|
||||
const drawerDepthStep = Number(settings.drawer_depth_step_mm || DRAWERS.depthStep);
|
||||
|
||||
/** Même logique que `useWardrobeConfig` : pas de 50 mm sur la profondeur utile, puis réserve tiroir caché. */
|
||||
const inferredEffectiveDrawerDepthMm = () => {
|
||||
const inferredEffectiveDrawerDepthMm = (drawerType = config.hardware?.drawerType || 'visible') => {
|
||||
const rawGap = depthMm - NICHE.drawerBackGap;
|
||||
const clamped = Math.max(drawerDepthMin, Math.min(drawerDepthMax, rawGap));
|
||||
const steppedMm = Math.round(clamped / drawerDepthStep) * drawerDepthStep;
|
||||
const rawDt = config.hardware?.drawerType || 'visible';
|
||||
const depthReductionMm =
|
||||
rawDt === 'hidden' || rawDt === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0;
|
||||
drawerType === 'hidden' || drawerType === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0;
|
||||
return Math.max(350, steppedMm - depthReductionMm);
|
||||
};
|
||||
|
||||
@@ -53,13 +106,27 @@ export function validateConfig(config = {}, settings = {}) {
|
||||
if (m.hasRod && heightMm < 1200) {
|
||||
errors.push({ field: `modules[${index}]`, message: 'Tringle autorisée à partir de 1200 mm de hauteur armoire.' });
|
||||
}
|
||||
if (isDrawerCol) {
|
||||
const moduleDepth = inferredEffectiveDrawerDepthMm(m.drawerType ?? config.hardware?.drawerType);
|
||||
if (moduleDepth < 350 || moduleDepth > drawerDepthMax) {
|
||||
errors.push({
|
||||
field: `modules[${index}].drawerType`,
|
||||
message: `Profondeur tiroir utile hors bornes (350-${drawerDepthMax} mm) pour ce block.`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Profondeur utile tiroir — alignée sur useWardrobeConfig (pas de 50 sur prof. caisse puis −40 si caché).
|
||||
* Ne pas exiger un multiple de 50 sur la valeur finale : ex. 550 − 40 = 510 mm est valide.
|
||||
*/
|
||||
const canonicalEffectiveDrawerDepthMm = inferredEffectiveDrawerDepthMm();
|
||||
const drawerModules = modules.filter((m) => (m.drawers || 0) > 0 || m.type === 'tiroirs');
|
||||
const canonicalDrawerDepths = drawerModules.map((m) =>
|
||||
inferredEffectiveDrawerDepthMm(m.drawerType ?? config.hardware?.drawerType)
|
||||
);
|
||||
const canonicalEffectiveDrawerDepthMm =
|
||||
canonicalDrawerDepths[0] ?? inferredEffectiveDrawerDepthMm();
|
||||
const effectiveDrawerMinMm = 350;
|
||||
if (
|
||||
canonicalEffectiveDrawerDepthMm < effectiveDrawerMinMm ||
|
||||
@@ -75,6 +142,7 @@ export function validateConfig(config = {}, settings = {}) {
|
||||
if (
|
||||
Number.isFinite(explicitEff) &&
|
||||
explicitEff > 0 &&
|
||||
canonicalDrawerDepths.every((depth) => depth === canonicalEffectiveDrawerDepthMm) &&
|
||||
Math.abs(explicitEff - canonicalEffectiveDrawerDepthMm) > 2
|
||||
) {
|
||||
warnings.push({
|
||||
|
||||
+2005
-218
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Cutting Plan Engine (Calepinage / plan de coupe) — APEX
|
||||
*
|
||||
* Optimise le placement des pièces de la liste de débitage sur des panneaux de stock
|
||||
* (par finition + épaisseur) à l'aide d'un algorithme guillotine par étagères
|
||||
* (First-Fit Decreasing Height). Calcule le pourcentage de chute par panneau et global,
|
||||
* et produit un SVG de calepinage par panneau pour le fournisseur.
|
||||
*/
|
||||
|
||||
/** Format de panneau de stock par défaut (mm) — mélaminé standard. */
|
||||
export const DEFAULT_STOCK_BOARD = { widthMm: 2800, heightMm: 2070 };
|
||||
/** Trait de scie (kerf) entre pièces, mm. */
|
||||
const DEFAULT_KERF_MM = 3.2;
|
||||
/** Délignage de rive (trim) sur chaque bord du panneau, mm. */
|
||||
const DEFAULT_TRIM_MM = 10;
|
||||
|
||||
function round(n, d = 3) {
|
||||
const f = 10 ** d;
|
||||
return Math.round((Number(n) || 0) * f) / f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place une liste de rectangles sur des panneaux successifs (guillotine FFDH).
|
||||
* @param {Array<{w:number,h:number,label:string,pieceId:string}>} rects
|
||||
* @param {object} board { usableW, usableH, kerf, trim, boardWidthMm, boardHeightMm }
|
||||
* @param {boolean} allowRotation
|
||||
* @returns {Array<object>} boards
|
||||
*/
|
||||
function packRectsIntoBoards(rects, board, allowRotation) {
|
||||
const { usableW, usableH, kerf, trim, boardWidthMm, boardHeightMm } = board;
|
||||
// Tri par hauteur décroissante (puis largeur) pour FFDH.
|
||||
const sorted = [...rects].sort((a, b) => (b.h - a.h) || (b.w - a.w));
|
||||
const boards = [];
|
||||
|
||||
function newBoard() {
|
||||
return { shelves: [], usedY: 0, placements: [] };
|
||||
}
|
||||
|
||||
function tryPlaceOnBoard(b, rect) {
|
||||
// Orientations candidates.
|
||||
const orientations = [{ w: rect.w, h: rect.h, rotated: false }];
|
||||
if (allowRotation && rect.w !== rect.h) {
|
||||
orientations.push({ w: rect.h, h: rect.w, rotated: true });
|
||||
}
|
||||
// 1) Essayer dans une étagère existante.
|
||||
for (const shelf of b.shelves) {
|
||||
for (const o of orientations) {
|
||||
if (o.w <= usableW - shelf.usedX + 1e-6 && o.h <= shelf.height + 1e-6) {
|
||||
b.placements.push({
|
||||
xMm: round(trim + shelf.usedX, 1),
|
||||
yMm: round(trim + shelf.y, 1),
|
||||
wMm: round(o.w, 1),
|
||||
hMm: round(o.h, 1),
|
||||
rotated: o.rotated,
|
||||
label: rect.label,
|
||||
pieceId: rect.pieceId
|
||||
});
|
||||
shelf.usedX += o.w + kerf;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) Ouvrir une nouvelle étagère.
|
||||
for (const o of orientations) {
|
||||
if (o.h <= usableH - b.usedY + 1e-6 && o.w <= usableW + 1e-6) {
|
||||
const shelf = { y: b.usedY, height: o.h, usedX: 0 };
|
||||
b.placements.push({
|
||||
xMm: round(trim + 0, 1),
|
||||
yMm: round(trim + shelf.y, 1),
|
||||
wMm: round(o.w, 1),
|
||||
hMm: round(o.h, 1),
|
||||
rotated: o.rotated,
|
||||
label: rect.label,
|
||||
pieceId: rect.pieceId
|
||||
});
|
||||
shelf.usedX = o.w + kerf;
|
||||
b.shelves.push(shelf);
|
||||
b.usedY += o.h + kerf;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let current = newBoard();
|
||||
for (const rect of sorted) {
|
||||
// Pièce trop grande pour un panneau, même tournée : on la place seule (hors-format signalé).
|
||||
const fits = rect.w <= usableW + 1e-6 && rect.h <= usableH + 1e-6;
|
||||
const fitsRotated = allowRotation && rect.h <= usableW + 1e-6 && rect.w <= usableH + 1e-6;
|
||||
if (!fits && !fitsRotated) {
|
||||
boards.push(finalizeBoard(current, board));
|
||||
current = newBoard();
|
||||
current.placements.push({
|
||||
xMm: round(trim, 1),
|
||||
yMm: round(trim, 1),
|
||||
wMm: round(rect.w, 1),
|
||||
hMm: round(rect.h, 1),
|
||||
rotated: false,
|
||||
label: rect.label,
|
||||
pieceId: rect.pieceId,
|
||||
oversize: true
|
||||
});
|
||||
boards.push(finalizeBoard(current, board));
|
||||
current = newBoard();
|
||||
continue;
|
||||
}
|
||||
if (!tryPlaceOnBoard(current, rect)) {
|
||||
boards.push(finalizeBoard(current, board));
|
||||
current = newBoard();
|
||||
tryPlaceOnBoard(current, rect);
|
||||
}
|
||||
}
|
||||
if (current.placements.length) boards.push(finalizeBoard(current, board));
|
||||
return boards;
|
||||
|
||||
function finalizeBoard(b, brd) {
|
||||
const boardAreaMm2 = brd.boardWidthMm * brd.boardHeightMm;
|
||||
const usedAreaMm2 = b.placements.reduce((s, p) => s + p.wMm * p.hMm, 0);
|
||||
const wasteAreaMm2 = Math.max(0, boardAreaMm2 - usedAreaMm2);
|
||||
return {
|
||||
boardWidthMm: brd.boardWidthMm,
|
||||
boardHeightMm: brd.boardHeightMm,
|
||||
placements: b.placements,
|
||||
usedAreaM2: round(usedAreaMm2 / 1e6),
|
||||
boardAreaM2: round(boardAreaMm2 / 1e6),
|
||||
wasteAreaM2: round(wasteAreaMm2 / 1e6),
|
||||
wastePercent: round((wasteAreaMm2 / boardAreaMm2) * 100, 1)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** SVG de calepinage pour un panneau. */
|
||||
function boardSvg(boardEntry, index) {
|
||||
const { boardWidthMm, boardHeightMm, placements } = boardEntry;
|
||||
const pad = 24;
|
||||
const maxW = 520;
|
||||
const scale = maxW / boardWidthMm;
|
||||
const w = boardWidthMm * scale;
|
||||
const h = boardHeightMm * scale;
|
||||
const palette = ['#dbeafe', '#fde68a', '#bbf7d0', '#fecaca', '#e9d5ff', '#c7d2fe', '#fed7aa'];
|
||||
const rects = placements
|
||||
.map((p, i) => {
|
||||
const fill = p.oversize ? '#fca5a5' : palette[i % palette.length];
|
||||
const rx = pad + p.xMm * scale;
|
||||
const ry = pad + p.yMm * scale;
|
||||
const rw = Math.max(1, p.wMm * scale);
|
||||
const rh = Math.max(1, p.hMm * scale);
|
||||
const label = `${p.label}${p.rotated ? ' ↻' : ''}`;
|
||||
const fontSize = Math.max(7, Math.min(11, rh / 3, rw / 6));
|
||||
const text =
|
||||
rw > 36 && rh > 14
|
||||
? `<text x="${rx + rw / 2}" y="${ry + rh / 2}" font-size="${fontSize}" text-anchor="middle" dominant-baseline="middle" fill="#1f2937">${escapeXml(label)}</text>`
|
||||
: '';
|
||||
return `<rect x="${rx.toFixed(1)}" y="${ry.toFixed(1)}" width="${rw.toFixed(1)}" height="${rh.toFixed(1)}" fill="${fill}" stroke="#374151" stroke-width="0.8"/>${text}`;
|
||||
})
|
||||
.join('');
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w + pad * 2} ${h + pad * 2 + 24}" width="${w + pad * 2}" height="${h + pad * 2 + 24}">
|
||||
<rect x="${pad}" y="${pad}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="#f9fafb" stroke="#111827" stroke-width="1.5"/>
|
||||
${rects}
|
||||
<text x="${pad}" y="${h + pad * 2 + 14}" font-size="12" fill="#111827">Panneau ${index + 1} — ${boardWidthMm}×${boardHeightMm} mm — chute ${boardEntry.wastePercent}%</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le plan de coupe (calepinage) pour toute l'armoire.
|
||||
* @param {object} cuttingList résultat de generateCuttingList
|
||||
* @param {object} [options]
|
||||
* @param {{widthMm:number,heightMm:number}} [options.stockBoard]
|
||||
* @param {number} [options.kerfMm]
|
||||
* @param {number} [options.trimMm]
|
||||
* @param {boolean} [options.allowRotation]
|
||||
* @returns {object}
|
||||
*/
|
||||
export function generateCuttingPlan(cuttingList, options = {}) {
|
||||
const stock = options.stockBoard || DEFAULT_STOCK_BOARD;
|
||||
const kerf = Number.isFinite(options.kerfMm) ? options.kerfMm : DEFAULT_KERF_MM;
|
||||
const trim = Number.isFinite(options.trimMm) ? options.trimMm : DEFAULT_TRIM_MM;
|
||||
const allowRotation = options.allowRotation !== false;
|
||||
|
||||
const usableW = stock.widthMm - 2 * trim;
|
||||
const usableH = stock.heightMm - 2 * trim;
|
||||
|
||||
// Regrouper les pièces par finition + épaisseur (matériaux non mélangeables).
|
||||
const groupsMap = new Map();
|
||||
for (const group of (cuttingList?.byFinish || [])) {
|
||||
for (const p of (group.pieces || [])) {
|
||||
const wMm = Number(p.rawCutWidthMm || p.widthMm) || 0;
|
||||
const hMm = Number(p.rawCutHeightMm || p.heightMm) || 0;
|
||||
const qty = Math.max(1, Math.round(Number(p.quantity) || 1));
|
||||
if (!(wMm > 0) || !(hMm > 0)) continue;
|
||||
const key = `${group.finishLabel || 'finition'}__${p.thicknessMm || 0}mm`;
|
||||
if (!groupsMap.has(key)) {
|
||||
groupsMap.set(key, {
|
||||
key,
|
||||
finishLabel: group.finishLabel || 'Finition',
|
||||
thicknessMm: Number(p.thicknessMm) || 0,
|
||||
rects: []
|
||||
});
|
||||
}
|
||||
const bucket = groupsMap.get(key);
|
||||
for (let i = 0; i < qty; i++) {
|
||||
bucket.rects.push({ w: wMm, h: hMm, label: p.description || p.elementType || 'pièce', pieceId: p.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allBoards = [];
|
||||
const groups = [];
|
||||
for (const bucket of groupsMap.values()) {
|
||||
const boards = packRectsIntoBoards(bucket.rects, {
|
||||
usableW, usableH, kerf, trim,
|
||||
boardWidthMm: stock.widthMm, boardHeightMm: stock.heightMm
|
||||
}, allowRotation);
|
||||
const groupBoardAreaM2 = boards.reduce((s, b) => s + b.boardAreaM2, 0);
|
||||
const groupUsedAreaM2 = boards.reduce((s, b) => s + b.usedAreaM2, 0);
|
||||
const groupWasteAreaM2 = round(groupBoardAreaM2 - groupUsedAreaM2);
|
||||
groups.push({
|
||||
key: bucket.key,
|
||||
finishLabel: bucket.finishLabel,
|
||||
thicknessMm: bucket.thicknessMm,
|
||||
boardCount: boards.length,
|
||||
usedAreaM2: round(groupUsedAreaM2),
|
||||
boardAreaM2: round(groupBoardAreaM2),
|
||||
wasteAreaM2: groupWasteAreaM2,
|
||||
wastePercent: groupBoardAreaM2 > 0 ? round((groupWasteAreaM2 / groupBoardAreaM2) * 100, 1) : 0
|
||||
});
|
||||
boards.forEach((b, i) => {
|
||||
allBoards.push({
|
||||
...b,
|
||||
groupKey: bucket.key,
|
||||
finishLabel: bucket.finishLabel,
|
||||
thicknessMm: bucket.thicknessMm,
|
||||
svg: boardSvg(b, allBoards.length + i)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const totalBoardAreaM2 = round(allBoards.reduce((s, b) => s + b.boardAreaM2, 0));
|
||||
const totalUsedAreaM2 = round(allBoards.reduce((s, b) => s + b.usedAreaM2, 0));
|
||||
const totalWasteAreaM2 = round(totalBoardAreaM2 - totalUsedAreaM2);
|
||||
const totalPlacedPieces = allBoards.reduce((s, b) => s + b.placements.length, 0);
|
||||
|
||||
return {
|
||||
stock: { widthMm: stock.widthMm, heightMm: stock.heightMm, kerfMm: kerf, trimMm: trim, allowRotation },
|
||||
boards: allBoards,
|
||||
groups,
|
||||
totalBoards: allBoards.length,
|
||||
totalPlacedPieces,
|
||||
totalUsedAreaM2,
|
||||
totalBoardAreaM2,
|
||||
totalWasteAreaM2,
|
||||
globalWastePercent: totalBoardAreaM2 > 0 ? round((totalWasteAreaM2 / totalBoardAreaM2) * 100, 1) : 0
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import pool from '../db/index.js';
|
||||
import { generateCuttingList } from './cuttingEngine.js';
|
||||
import { PLINTH, CARCASS_FASTENERS } from '../../shared/manufacturing.js';
|
||||
import { LEGRABOX_BOM_RULES } from '../../shared/blumLegrabox.js';
|
||||
|
||||
const HAEFELE_PRODUCT_URLS = {
|
||||
hinge_blum_clip_top_blumotion_110: 'https://www.haefele.ch/fr/produkt/charni-re-corps-blum-clip-top-blumotion-110-pose-en-applique/34242663/?MasterSKU=P-00898218',
|
||||
@@ -61,6 +62,24 @@ async function priceById(table, id) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Système de tiroirs Blum avec specs_json (tip_on, load_kg, …) pour les règles BOM Legrabox. */
|
||||
async function drawerSystemById(id) {
|
||||
if (!id) return null;
|
||||
const row = await fetchOne(
|
||||
'SELECT id, code, name, price_chf, specs_json FROM blum_hardware WHERE id = ? AND active = 1',
|
||||
[id]
|
||||
);
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
unitPriceChf: toMoney(row.price_chf),
|
||||
source: 'blum_hardware',
|
||||
specsJson: row.specs_json || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function findHaefeleMaterialForModel(model) {
|
||||
const m = String(model || '').trim();
|
||||
if (!m) return null;
|
||||
@@ -142,7 +161,7 @@ async function findHaefeleMaterialByUrl(url) {
|
||||
};
|
||||
}
|
||||
|
||||
function computeCountsFromCuttingList(cuttingList) {
|
||||
export function computeCountsFromCuttingList(cuttingList) {
|
||||
const items = new Map();
|
||||
const pieces = Array.isArray(cuttingList?.pieces) ? cuttingList.pieces : [];
|
||||
|
||||
@@ -256,10 +275,20 @@ function computeCountsFromCuttingList(cuttingList) {
|
||||
// so we do not count it here to avoid misleading totals.
|
||||
}
|
||||
|
||||
if (p?.elementType === 'tiroirs') {
|
||||
if (p?.elementType === 'tiroirs' || p?.elementType === 'tiroirFacade') {
|
||||
drawersCount += qty;
|
||||
}
|
||||
|
||||
// Façades tiroir Legrabox — chevilles expansibles INSERTA (Ø10) selon perçages générés.
|
||||
if (p?.elementType === 'tiroirFacade' && m.legraboxInserta?.holes?.length && qty > 0) {
|
||||
addQty(items, 'legrabox_inserta_fixing', {
|
||||
key: 'legrabox_inserta_fixing',
|
||||
label: 'Fixation façade Legrabox INSERTA (cheville expansible Ø10)',
|
||||
quantity: qty * m.legraboxInserta.holes.length,
|
||||
unit: 'pc',
|
||||
});
|
||||
}
|
||||
|
||||
// Fixation "dos" (paroi arrière) — 4 côtés (gauche/droite + haut/bas).
|
||||
// Règle atelier (pour une longueur E = L ou H):
|
||||
// - par côté: 1 pièce à 5cm de chaque extrémité (=> 2)
|
||||
@@ -280,6 +309,30 @@ function computeCountsFromCuttingList(cuttingList) {
|
||||
meta: { model: 'Ixconnect RPC G 13/20' },
|
||||
});
|
||||
}
|
||||
|
||||
// AXILO 78 : une platine/pied par centre, et un support de plinthe par pied avant.
|
||||
if (p?.elementType === 'fond' && m.axiloFootPlates?.centers?.length && qty > 0) {
|
||||
const centers = m.axiloFootPlates.centers;
|
||||
const frontCount =
|
||||
Number(m.axiloFootPlates.frontFootCount) ||
|
||||
centers.filter((center) => center?.row === 'front').length;
|
||||
addQty(items, 'axilo_78_press_fit_foot', {
|
||||
key: 'axilo_78_press_fit_foot',
|
||||
label: `Pied Häfele AXILO 78 à planter — réf. ${PLINTH.mountingPlate.articleNumber}`,
|
||||
code: PLINTH.mountingPlate.articleNumber,
|
||||
quantity: qty * centers.length,
|
||||
unit: 'pc',
|
||||
meta: { model: PLINTH.mountingPlate.articleNumber }
|
||||
});
|
||||
addQty(items, 'axilo_plinth_support', {
|
||||
key: 'axilo_plinth_support',
|
||||
label: `Support de plinthe Häfele AXILO — réf. ${PLINTH.plinthSupport.articleNumber}`,
|
||||
code: PLINTH.plinthSupport.articleNumber,
|
||||
quantity: qty * frontCount * PLINTH.plinthSupport.quantityPerFrontFoot,
|
||||
unit: 'pc',
|
||||
meta: { model: PLINTH.plinthSupport.articleNumber }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { items: Array.from(items.values()), doorCount, drawersCount };
|
||||
@@ -289,20 +342,6 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const { items, doorCount } = computeCountsFromCuttingList(cuttingList);
|
||||
|
||||
// Add plinth clips (simple deterministic rule)
|
||||
const widthMm = Number(config?.widthMm) || 0;
|
||||
if (widthMm > 0) {
|
||||
const clips = Math.max(0, Math.ceil((widthMm / 1000) * (Number(PLINTH.clipsPerMeter) || 0)));
|
||||
if (clips > 0) {
|
||||
items.push({
|
||||
key: 'plinth_clips',
|
||||
label: `Clips plinthe (${PLINTH.model})`,
|
||||
quantity: clips,
|
||||
unit: 'pc',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Selected variants pricing (if configured)
|
||||
const handleVariantId = config?.hardware?.handleVariantId;
|
||||
const hingeVariantId = config?.hardware?.hingeVariantId;
|
||||
@@ -311,7 +350,7 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
|
||||
const [handleVariant, hingeVariant, drawerSystem] = await Promise.all([
|
||||
priceById('handle_variants', handleVariantId),
|
||||
priceById('hinge_variants', hingeVariantId),
|
||||
priceById('blum_hardware', drawerSystemId),
|
||||
drawerSystemById(drawerSystemId),
|
||||
]);
|
||||
|
||||
// Add handles as an item when selected
|
||||
@@ -389,17 +428,60 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
|
||||
}
|
||||
|
||||
// Drawer system: assume priced “per drawer”
|
||||
if (drawerSystem) {
|
||||
const totalDrawers = (Array.isArray(config?.modules) ? config.modules : []).reduce((s, m) => s + (Number(m?.drawers) || 0), 0);
|
||||
if (totalDrawers > 0) {
|
||||
const configModules = Array.isArray(config?.modules) ? config.modules : [];
|
||||
const totalDrawers = configModules.reduce((s, m) => s + (Number(m?.drawers) || 0), 0);
|
||||
if (drawerSystem && totalDrawers > 0) {
|
||||
items.push({
|
||||
key: `drawer_system:${drawerSystem.id}`,
|
||||
label: `Système tiroirs — ${drawerSystem.name}`,
|
||||
quantity: totalDrawers,
|
||||
unit: 'pc',
|
||||
unitPriceChf: drawerSystem.unitPriceChf,
|
||||
source: drawerSystem.source,
|
||||
code: drawerSystem.code,
|
||||
});
|
||||
}
|
||||
|
||||
// Règles automatiques Blum Legrabox (BOM)
|
||||
if (totalDrawers > 0) {
|
||||
// 1) Stabilisation de façade — obligatoire si largeur_corps_mm >= 600 (par tiroir concerné).
|
||||
const wideDrawers = configModules.reduce((s, m) => {
|
||||
const n = Number(m?.drawers) || 0;
|
||||
const w = Number(m?.widthMm) || 0;
|
||||
return s + (n > 0 && w >= LEGRABOX_BOM_RULES.frontStabiliserMinBodyWidthMm ? n : 0);
|
||||
}, 0);
|
||||
if (wideDrawers > 0) {
|
||||
items.push({
|
||||
key: `drawer_system:${drawerSystem.id}`,
|
||||
label: `Système tiroirs — ${drawerSystem.name}`,
|
||||
quantity: totalDrawers,
|
||||
key: 'legrabox_front_stabiliser',
|
||||
label: 'Stabilisation de façade Legrabox (largeur corps ≥ 600 mm)',
|
||||
quantity: wideDrawers,
|
||||
unit: 'pc',
|
||||
unitPriceChf: drawerSystem.unitPriceChf,
|
||||
source: drawerSystem.source,
|
||||
code: drawerSystem.code,
|
||||
meta: { model: 'ZST.550BA' },
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Unité de synchronisation — obligatoire avec la variante Tip-On Blumotion.
|
||||
const openingVariant = String(config?.hardware?.drawerOpeningVariant || '').toLowerCase();
|
||||
const specsTipOn = (() => {
|
||||
try {
|
||||
const specs = drawerSystem?.specsJson ? JSON.parse(drawerSystem.specsJson) : null;
|
||||
return Boolean(specs?.tip_on);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const isTipOnBlumotion =
|
||||
openingVariant === LEGRABOX_BOM_RULES.syncUnitVariant ||
|
||||
openingVariant === 'tip-on blumotion' ||
|
||||
specsTipOn;
|
||||
if (isTipOnBlumotion) {
|
||||
const drawerColumns = configModules.filter((m) => (Number(m?.drawers) || 0) > 0).length;
|
||||
items.push({
|
||||
key: 'legrabox_tipon_sync_unit',
|
||||
label: 'Unité de synchronisation Tip-On Blumotion (set)',
|
||||
quantity: Math.max(1, drawerColumns),
|
||||
unit: 'set',
|
||||
meta: { model: 'T60.000D' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
buildFinishHabillageHoleSpecsMm,
|
||||
buildDoorHingeCupHoleSpecsMm,
|
||||
buildDrawerReinforcementHoleSpecsMm,
|
||||
buildDrawerRenfortPanelIxconnectSpecsMm
|
||||
buildDrawerRenfortPanelIxconnectSpecsMm,
|
||||
buildLegraboxRailHoleSpecsMm,
|
||||
buildLegraboxFacadeInsertaSpecsMm,
|
||||
buildAxiloFootHoleSpecsMm,
|
||||
buildSystem32HoleSpecsMm
|
||||
} from '../../shared/cncPanelHoles.js';
|
||||
|
||||
export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0) {
|
||||
@@ -25,33 +29,8 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
|
||||
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
|
||||
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
|
||||
|
||||
if (m.system32?.positions?.length) {
|
||||
const frontX = clampX(37);
|
||||
const backX = clampX(widthMm - 37);
|
||||
const d = Number(m.system32.holeDiameter || 5);
|
||||
const z = Number(m.system32.holeDepth || piece.thicknessMm || 12);
|
||||
const throughHole = Boolean(m.system32.throughHole);
|
||||
for (const pos of m.system32.positions) {
|
||||
const y = clampY(pos);
|
||||
pushHole({
|
||||
xMm: frontX,
|
||||
yMm: y,
|
||||
diameterMm: d,
|
||||
depthMm: z,
|
||||
type: 'system32',
|
||||
face: 'face',
|
||||
toolMode: throughHole ? 'through' : 'blind'
|
||||
});
|
||||
pushHole({
|
||||
xMm: backX,
|
||||
yMm: y,
|
||||
diameterMm: d,
|
||||
depthMm: z,
|
||||
type: 'system32',
|
||||
face: 'face',
|
||||
toolMode: throughHole ? 'through' : 'blind'
|
||||
});
|
||||
}
|
||||
for (const spec of buildSystem32HoleSpecsMm(piece, widthMm, heightMm)) {
|
||||
pushHole(spec);
|
||||
}
|
||||
|
||||
if (['dessus', 'fond'].includes(piece.elementType || '')) {
|
||||
@@ -60,6 +39,11 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
// AXILO 78 Press-fit — quatre trous borgnes par platine, exclusivement en sous-face du fond bas.
|
||||
for (const spec of buildAxiloFootHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||
pushHole({ ...spec, toolMode: 'blind' });
|
||||
}
|
||||
|
||||
if (['cotes', 'panneauxInterieurs'].includes(piece.elementType || '')) {
|
||||
for (const spec of buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
|
||||
@@ -67,9 +51,18 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
|
||||
for (const spec of buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex)) {
|
||||
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
|
||||
}
|
||||
for (const spec of buildLegraboxRailHoleSpecsMm(piece, widthMm, heightMm, partIndex)) {
|
||||
pushHole({ ...spec, toolMode: 'blind' });
|
||||
}
|
||||
}
|
||||
|
||||
if (['cotes', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) {
|
||||
if (piece.elementType === 'tiroirFacade') {
|
||||
for (const spec of buildLegraboxFacadeInsertaSpecsMm(piece, widthMm, heightMm)) {
|
||||
pushHole({ ...spec, toolMode: 'blind' });
|
||||
}
|
||||
}
|
||||
|
||||
if (['cotes', 'dessus', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) {
|
||||
for (const spec of buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||
pushHole({ ...spec, toolMode: 'blind' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Leuba Hiag catalog crawler.
|
||||
*
|
||||
* The Leuba Hiag webshop (https://www.leubahiag.ch) is a Laravel/Bagisto shop whose
|
||||
* product catalog is served by Algolia. The public catalog search page embeds a
|
||||
* short-lived "secured" Algolia API key (guest scope) inside the page markup. We read
|
||||
* that key live on every run (it expires), then page through the Algolia index and
|
||||
* upsert each product into the shared `formex_materials` table under the `leubahiag`
|
||||
* supplier.
|
||||
*
|
||||
* Notes:
|
||||
* - Algolia exposes the product catalog but NOT prices. Customer-specific prices come
|
||||
* from the authenticated `/fr/sales/prices-and-availability` endpoint: we log in with
|
||||
* the account credentials (LEUBAHIAG_EMAIL/PASSWORD) and, after importing each line,
|
||||
* fetch the per-SKU panel price and store it in price_chf.
|
||||
* - Imported items are inactive by default so the catalog rows don't flood the
|
||||
* configurator finish picker. Activate the wanted decors from the Matériaux admin page.
|
||||
*/
|
||||
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
const BASE_URL = 'https://www.leubahiag.ch';
|
||||
const SEARCH_URL = `${BASE_URL}/fr/catalog/search`;
|
||||
const CRAWL_DELAY = parseInt(process.env.LEUBAHIAG_CRAWL_DELAY_MS || '300', 10);
|
||||
const ALGOLIA_HITS_PER_PAGE = 1000;
|
||||
|
||||
/**
|
||||
* Curated catalog lines to import (per the supplier's agreed assortment).
|
||||
* Each target maps to an Algolia query and/or brand facet filter. The `label` is used
|
||||
* as the material `collection` so the configurator groups items by recognisable line.
|
||||
*/
|
||||
export const LEUBA_HIAG_TARGETS = [
|
||||
{ key: 'pfleiderer-xpsm', label: 'Pfleiderer XPSM (Antifingerprint)', tier: 'Entrée de gamme', query: 'XPSM', brands: ['Pfleiderer'] },
|
||||
{ key: 'swiss-krono', label: 'Swiss Krono', tier: 'Entrée de gamme', query: '', brands: ['Swiss Krono'] },
|
||||
{ key: 'unilin-master-oak', label: 'Unilin Master Oak', tier: 'Milieu de gamme', query: 'Master Oak', brands: ['UNILIN'] },
|
||||
{ key: 'shinnoki', label: 'Shinnoki', tier: 'Milieu de gamme', query: 'Shinnoki', brands: [] },
|
||||
{ key: 'cleaf', label: 'Cleaf', tier: 'Haut de gamme', query: '', brands: ['CLEAF'] },
|
||||
{ key: 'valchromat', label: 'Valchromat', tier: 'Haut de gamme', query: '', brands: ['Valchromat'] },
|
||||
];
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function browserHeaders(extra = {}) {
|
||||
return {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'fr-CH,fr;q=0.9,en;q=0.5',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function absoluteUrl(url) {
|
||||
if (!url) return null;
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
return `${BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
|
||||
/** Parse a "CHF 1'234.50" string into a number, or null. */
|
||||
function parseCHF(text) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const clean = text.replace(/CHF/gi, '').replace(/[\u00a0\u202f'’\s]/g, '').replace(',', '.');
|
||||
const n = parseFloat(clean);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated Leuba Hiag (Laravel) session. Used to read customer-specific prices
|
||||
* from the `prices-and-availability` endpoint (axios sends the XSRF-TOKEN cookie back
|
||||
* as an X-XSRF-TOKEN header — we replicate that here).
|
||||
*/
|
||||
class LeubaHiagSession {
|
||||
constructor() {
|
||||
this.cookies = new Map();
|
||||
this.loggedIn = false;
|
||||
}
|
||||
|
||||
captureCookies(response) {
|
||||
let raw = [];
|
||||
if (typeof response.headers.getSetCookie === 'function') {
|
||||
raw = response.headers.getSetCookie();
|
||||
} else {
|
||||
const header = response.headers.get('set-cookie');
|
||||
if (header) raw = header.split(/,\s*(?=[A-Za-z0-9_\-]+=)/);
|
||||
}
|
||||
for (const entry of raw) {
|
||||
const [pair] = entry.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
||||
}
|
||||
}
|
||||
|
||||
cookieHeader() {
|
||||
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
|
||||
xsrfToken() {
|
||||
const raw = this.cookies.get('XSRF-TOKEN');
|
||||
return raw ? decodeURIComponent(raw) : '';
|
||||
}
|
||||
|
||||
async login(emit = () => {}, credentials = {}) {
|
||||
const email = credentials.username || credentials.email
|
||||
|| process.env.LEUBAHIAG_EMAIL || process.env.LEUBAHIAG_USER;
|
||||
const password = credentials.password || process.env.LEUBAHIAG_PASSWORD;
|
||||
if (!email || !password) {
|
||||
emit({ type: 'log', message: 'Identifiants Leuba Hiag absents : import sans prix.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const loginUrl = `${BASE_URL}/fr/customer/auth/login`;
|
||||
try {
|
||||
const getRes = await fetch(loginUrl, { headers: browserHeaders(), redirect: 'follow' });
|
||||
this.captureCookies(getRes);
|
||||
const html = await getRes.text();
|
||||
const token = (html.match(/name="_token"\s+value="([^"]+)"/) || [])[1];
|
||||
if (!token) {
|
||||
emit({ type: 'log', message: 'Jeton CSRF Leuba Hiag introuvable : import sans prix.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set('_token', token);
|
||||
body.set('email', email);
|
||||
body.set('password', password);
|
||||
|
||||
// redirect:'manual' so we capture Set-Cookie from the post-login 302 (the
|
||||
// LoggedIn/session cookies live on the redirect response, not the final page).
|
||||
const postRes = await fetch(loginUrl, {
|
||||
method: 'POST',
|
||||
headers: browserHeaders({
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: loginUrl,
|
||||
Origin: BASE_URL,
|
||||
Cookie: this.cookieHeader(),
|
||||
}),
|
||||
body: body.toString(),
|
||||
redirect: 'manual',
|
||||
});
|
||||
this.captureCookies(postRes);
|
||||
|
||||
// Confirm the session and refresh the XSRF-TOKEN from the authenticated catalog page.
|
||||
const verifyRes = await fetch(`${BASE_URL}/fr/catalog/search`, {
|
||||
headers: browserHeaders({ Cookie: this.cookieHeader() }),
|
||||
redirect: 'follow',
|
||||
});
|
||||
this.captureCookies(verifyRes);
|
||||
const verifyHtml = await verifyRes.text();
|
||||
const isLoggedIn = /\\u0022isLoggedIn\\u0022:true/.test(verifyHtml)
|
||||
|| this.cookies.get('LoggedIn') === '1';
|
||||
|
||||
this.loggedIn = isLoggedIn;
|
||||
emit({
|
||||
type: 'log',
|
||||
message: this.loggedIn
|
||||
? `Connecté à Leuba Hiag (${email}).`
|
||||
: 'Connexion Leuba Hiag non confirmée : import sans prix.',
|
||||
});
|
||||
return this.loggedIn;
|
||||
} catch (err) {
|
||||
emit({ type: 'log', message: `Erreur de connexion Leuba Hiag : ${err.message}` });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** POST {skus} to the prices endpoint; returns the per-sku price map (or {}). */
|
||||
async fetchPrices(skus) {
|
||||
if (!this.loggedIn || skus.length === 0) return {};
|
||||
const res = await fetch(`${BASE_URL}/fr/sales/prices-and-availability`, {
|
||||
method: 'POST',
|
||||
headers: browserHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': this.xsrfToken(),
|
||||
Referer: `${BASE_URL}/fr/catalog/search`,
|
||||
Origin: BASE_URL,
|
||||
Cookie: this.cookieHeader(),
|
||||
}),
|
||||
body: JSON.stringify({ skus }),
|
||||
});
|
||||
this.captureCookies(res);
|
||||
if (!res.ok) {
|
||||
throw new Error(`prices-and-availability HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the live Algolia connection config (appId, index, secured apiKey) from the
|
||||
* catalog search page. The key is embedded as `\u0022apiKey\u0022:\u0022...\u0022`
|
||||
* inside the `window.globalSearchConfigBase` JSON blob.
|
||||
*/
|
||||
async function fetchAlgoliaConfig(emit = () => {}) {
|
||||
const res = await fetch(SEARCH_URL, { headers: browserHeaders(), redirect: 'follow' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Impossible de charger la page catalogue Leuba Hiag (HTTP ${res.status}).`);
|
||||
}
|
||||
const html = await res.text();
|
||||
|
||||
const appId = (html.match(/\\u0022appId\\u0022:\\u0022([A-Za-z0-9]+)\\u0022/) || [])[1]
|
||||
|| process.env.LEUBAHIAG_ALGOLIA_APP_ID;
|
||||
const indexName = (html.match(/\\u0022indexName\\u0022:\\u0022([A-Za-z0-9_]+)\\u0022/) || [])[1]
|
||||
|| process.env.LEUBAHIAG_ALGOLIA_INDEX;
|
||||
const apiKey = (html.match(/\\u0022apiKey\\u0022:\\u0022([A-Za-z0-9+/=]+)\\u0022/) || [])[1];
|
||||
|
||||
if (!appId || !indexName || !apiKey) {
|
||||
throw new Error('Clé Algolia introuvable sur la page catalogue Leuba Hiag (structure du site modifiée ?).');
|
||||
}
|
||||
|
||||
emit({ type: 'log', message: `Catalogue Algolia détecté: index ${indexName} (app ${appId}).` });
|
||||
return { appId, indexName, apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one Algolia query page. `facetFilters` is an array of arrays per Algolia syntax.
|
||||
*/
|
||||
async function queryAlgolia({ appId, indexName, apiKey }, { query = '', page = 0, hitsPerPage = ALGOLIA_HITS_PER_PAGE, facetFilters = [] } = {}) {
|
||||
const url = `https://${appId}-dsn.algolia.net/1/indexes/${indexName}/query`;
|
||||
const params = new URLSearchParams();
|
||||
params.set('query', query);
|
||||
params.set('hitsPerPage', String(hitsPerPage));
|
||||
params.set('page', String(page));
|
||||
if (facetFilters.length > 0) {
|
||||
params.set('facetFilters', JSON.stringify(facetFilters));
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Algolia-Application-Id': appId,
|
||||
'X-Algolia-API-Key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ params: params.toString() }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Requête Algolia échouée (HTTP ${res.status})${body ? `: ${body.slice(0, 160)}` : ''}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function firstString(value) {
|
||||
if (Array.isArray(value)) return value.find((v) => typeof v === 'string') || null;
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an Algolia product record to a `formex_materials` row.
|
||||
* Dimension fields from the index: thickness (mm), length (mm), width (mm).
|
||||
* @param {string} [collectionOverride] Force the `collection` (curated line label).
|
||||
*/
|
||||
function mapHit(hit, collectionOverride) {
|
||||
const sku = String(hit.sku || hit.objectID || '').trim();
|
||||
if (!sku) return null;
|
||||
|
||||
const categoryPath = Array.isArray(hit.categories_with_path) ? hit.categories_with_path : [];
|
||||
const category = categoryPath.length > 0
|
||||
? categoryPath[categoryPath.length - 1]
|
||||
: (firstString(hit.categories?.level0) || null);
|
||||
|
||||
const collection = collectionOverride
|
||||
|| firstString(hit.brand)
|
||||
|| firstString(hit.categories?.level0)
|
||||
|| 'Leuba Hiag';
|
||||
|
||||
return {
|
||||
sku,
|
||||
materialCode: `LH-${sku}`,
|
||||
name: hit.name || hit.group_name || sku,
|
||||
collection,
|
||||
category,
|
||||
width_mm: toNumber(hit.width),
|
||||
height_mm: toNumber(hit.length),
|
||||
thickness_mm: toNumber(hit.thickness),
|
||||
image_url: absoluteUrl(hit.image_url),
|
||||
formex_url: absoluteUrl(hit.url),
|
||||
price_chf: null,
|
||||
price_unit: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSupplier() {
|
||||
const existing = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']);
|
||||
if (existing.rows.length > 0) return existing.rows[0].id;
|
||||
|
||||
await run(
|
||||
'INSERT INTO suppliers (code, name, email, type, active) VALUES (?, ?, ?, ?, ?)',
|
||||
['leubahiag', 'Leuba Hiag', '[email protected]', 'materials', 1]
|
||||
);
|
||||
const created = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']);
|
||||
return created.rows[0]?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a material. Existing rows keep their `active` flag (so admin activations are
|
||||
* preserved across re-syncs); new rows are inserted with the provided `active` value.
|
||||
*/
|
||||
async function upsertMaterial(material, supplierId, activeForNew) {
|
||||
const existing = await pool.query(
|
||||
'SELECT id FROM formex_materials WHERE formex_code = ?',
|
||||
[material.materialCode]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await run(
|
||||
`UPDATE formex_materials
|
||||
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
|
||||
image_url = ?, formex_url = ?,
|
||||
supplier_id = COALESCE(?, supplier_id), last_synced_at = datetime('now')
|
||||
WHERE formex_code = ?`,
|
||||
[
|
||||
material.name,
|
||||
material.collection,
|
||||
material.category,
|
||||
material.width_mm,
|
||||
material.height_mm,
|
||||
material.thickness_mm,
|
||||
material.image_url,
|
||||
material.formex_url,
|
||||
supplierId,
|
||||
material.materialCode,
|
||||
]
|
||||
);
|
||||
return { id: existing.rows[0].id, created: false };
|
||||
}
|
||||
|
||||
await run(
|
||||
`INSERT INTO formex_materials
|
||||
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
|
||||
image_url, formex_url, price_chf, price_unit, supplier_id, active, last_synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
[
|
||||
material.materialCode,
|
||||
material.name,
|
||||
material.collection,
|
||||
material.category,
|
||||
material.width_mm,
|
||||
material.height_mm,
|
||||
material.thickness_mm,
|
||||
material.image_url,
|
||||
material.formex_url,
|
||||
material.price_chf ?? null,
|
||||
material.price_unit ?? null,
|
||||
supplierId,
|
||||
activeForNew ? 1 : 0,
|
||||
]
|
||||
);
|
||||
|
||||
const created = await pool.query('SELECT id FROM formex_materials WHERE formex_code = ?', [material.materialCode]);
|
||||
return { id: created.rows[0]?.id || null, created: true };
|
||||
}
|
||||
|
||||
/** Update the price of an already-imported material by its SKU. */
|
||||
async function updateMaterialPrice(sku, priceChf, priceUnit) {
|
||||
await run(
|
||||
`UPDATE formex_materials
|
||||
SET price_chf = ?, price_unit = COALESCE(?, price_unit), last_synced_at = datetime('now')
|
||||
WHERE formex_code = ?`,
|
||||
[priceChf, priceUnit, `LH-${sku}`]
|
||||
);
|
||||
}
|
||||
|
||||
async function logSync(status, itemsCount, error = null) {
|
||||
await run(
|
||||
'INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)',
|
||||
['leubahiag', status, itemsCount, error]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import all products matching one curated target (a single catalog line).
|
||||
*/
|
||||
async function importTarget(config, supplierId, target, activate, emit) {
|
||||
const facetFilters = Array.isArray(target.brands) && target.brands.length > 0
|
||||
? [target.brands.map((b) => `brand:${b}`)]
|
||||
: [];
|
||||
|
||||
let synced = 0;
|
||||
let created = 0;
|
||||
let page = 0;
|
||||
let nbPages = 1;
|
||||
let nbHits = 0;
|
||||
const skus = [];
|
||||
|
||||
do {
|
||||
if (page > 0) await sleep(CRAWL_DELAY);
|
||||
const data = await queryAlgolia(config, { query: target.query, page, facetFilters });
|
||||
nbPages = data.nbPages || 1;
|
||||
nbHits = data.nbHits || 0;
|
||||
|
||||
if (page === 0) {
|
||||
emit({ type: 'log', message: `→ ${target.label} (${target.tier}) : ${nbHits} produit(s).` });
|
||||
}
|
||||
|
||||
const hits = Array.isArray(data.hits) ? data.hits : [];
|
||||
for (const hit of hits) {
|
||||
const material = mapHit(hit, target.label);
|
||||
if (!material) continue;
|
||||
try {
|
||||
const result = await upsertMaterial(material, supplierId, activate);
|
||||
synced++;
|
||||
if (result.created) created++;
|
||||
skus.push(material.sku);
|
||||
} catch (err) {
|
||||
emit({ type: 'log', message: ` ✗ ${material.materialCode}: ${err.message}` });
|
||||
}
|
||||
}
|
||||
page++;
|
||||
} while (page < nbPages);
|
||||
|
||||
emit({ type: 'log', message: ` ✓ ${target.label} : ${synced} importé(s) (${created} nouveau(x)).` });
|
||||
return { synced, created, skus };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and store customer prices for a list of SKUs (in batches).
|
||||
* Returns the number of items priced.
|
||||
*/
|
||||
async function importPrices(session, skus, emit) {
|
||||
const BATCH = 50;
|
||||
let priced = 0;
|
||||
for (let i = 0; i < skus.length; i += BATCH) {
|
||||
const batch = skus.slice(i, i + BATCH);
|
||||
try {
|
||||
const data = await session.fetchPrices(batch);
|
||||
for (const sku of batch) {
|
||||
const info = data?.[sku];
|
||||
if (!info) continue;
|
||||
const tier = Array.isArray(info.tierprices) ? info.tierprices[0] : null;
|
||||
const priceChf = parseCHF(tier?.price);
|
||||
if (priceChf == null) continue;
|
||||
await updateMaterialPrice(sku, priceChf, 'pièce');
|
||||
priced++;
|
||||
}
|
||||
} catch (err) {
|
||||
emit({ type: 'log', message: ` ✗ Prix lot ${i / BATCH + 1}: ${err.message}` });
|
||||
}
|
||||
emit({ type: 'log', message: ` Prix: ${priced}/${skus.length} récupérés…` });
|
||||
if (i + BATCH < skus.length) await sleep(CRAWL_DELAY);
|
||||
}
|
||||
return priced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronise the curated Leuba Hiag catalog lines via Algolia.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string[]} [options.targets] Subset of target keys to import (default: all).
|
||||
* @param {boolean} [options.activate] Mark newly imported items active (default false).
|
||||
* @param {boolean} [options.fetchPrices] Log in and import customer prices (default true).
|
||||
* @param {object} [options.credentials] { username/email, password } (else env vars).
|
||||
* @param {function} emit SSE-style event emitter.
|
||||
*/
|
||||
export async function syncLeubaHiag(options = {}, emit = () => {}) {
|
||||
const { targets = null, activate = false, fetchPrices = true, credentials = {} } = options;
|
||||
|
||||
const selected = Array.isArray(targets) && targets.length > 0
|
||||
? LEUBA_HIAG_TARGETS.filter((t) => targets.includes(t.key))
|
||||
: LEUBA_HIAG_TARGETS;
|
||||
|
||||
const start = Date.now();
|
||||
let synced = 0;
|
||||
let created = 0;
|
||||
let priced = 0;
|
||||
|
||||
try {
|
||||
if (selected.length === 0) {
|
||||
throw new Error('Aucune gamme Leuba Hiag sélectionnée.');
|
||||
}
|
||||
|
||||
const config = await fetchAlgoliaConfig(emit);
|
||||
const supplierId = await ensureSupplier();
|
||||
|
||||
// Log in up-front so prices can be fetched after each line is imported.
|
||||
const session = new LeubaHiagSession();
|
||||
if (fetchPrices) {
|
||||
await session.login(emit, credentials);
|
||||
}
|
||||
|
||||
emit({ type: 'log', message: `${selected.length} gamme(s) à importer.` });
|
||||
emit({ type: 'progress', current: 0, total: selected.length, collection: 'Leuba Hiag' });
|
||||
|
||||
const allSkus = [];
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const target = selected[i];
|
||||
emit({ type: 'progress', current: i, total: selected.length, collection: target.label });
|
||||
try {
|
||||
const res = await importTarget(config, supplierId, target, activate, emit);
|
||||
synced += res.synced;
|
||||
created += res.created;
|
||||
allSkus.push(...res.skus);
|
||||
} catch (err) {
|
||||
emit({ type: 'log', message: ` ✗ ${target.label}: ${err.message}` });
|
||||
}
|
||||
if (i < selected.length - 1) await sleep(CRAWL_DELAY);
|
||||
}
|
||||
|
||||
if (session.loggedIn && allSkus.length > 0) {
|
||||
emit({ type: 'log', message: `Récupération des prix client pour ${allSkus.length} article(s)…` });
|
||||
priced = await importPrices(session, allSkus, emit);
|
||||
emit({ type: 'log', message: `✓ ${priced} prix client enregistrés.` });
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - start) / 1000).toFixed(1);
|
||||
await logSync('success', synced, null);
|
||||
const result = {
|
||||
success: true,
|
||||
itemsCount: synced,
|
||||
createdCount: created,
|
||||
updatedCount: synced - created,
|
||||
pricedCount: priced,
|
||||
duration,
|
||||
error: null,
|
||||
};
|
||||
emit({ type: 'progress', current: selected.length, total: selected.length, collection: 'Leuba Hiag' });
|
||||
emit({ type: 'complete', result });
|
||||
return result;
|
||||
} catch (err) {
|
||||
await logSync('error', synced, err.message);
|
||||
emit({ type: 'error', error: err.message });
|
||||
return { success: false, itemsCount: synced, error: err.message };
|
||||
}
|
||||
}
|
||||
+345
-58
@@ -11,12 +11,16 @@ import { GROOVE, getBackGroovePlanOrientation, BACK_GROOVE_PLAN_VERTICAL_TYPES }
|
||||
|
||||
const MPR_ARTICLE_CODES = Object.freeze({
|
||||
plintheBas: 'PL_BAS',
|
||||
plintheGauche: 'PL_G',
|
||||
plintheDroite: 'PL_D',
|
||||
bandeauHaut: 'BH',
|
||||
dessus: 'BO_U',
|
||||
fond: 'BO_L',
|
||||
cotes: 'SE',
|
||||
cotesDoublage: 'CD',
|
||||
fileur: 'FI',
|
||||
fileurCotes: 'FI_C',
|
||||
fileurHaut: 'FI_H',
|
||||
habillageFinitionGauche: 'FR_L_G',
|
||||
habillageFinitionDroite: 'FR_L_D',
|
||||
habillageFinitionDessus: 'FR_H',
|
||||
@@ -26,7 +30,10 @@ const MPR_ARTICLE_CODES = Object.freeze({
|
||||
panneauxInterieurs: 'ZA_O',
|
||||
rayonFixe: 'RF_X',
|
||||
etageres: 'EB_M',
|
||||
renfortsTiroir: 'LABO'
|
||||
renfortsTiroir: 'LABO',
|
||||
tiroirFond: 'TI_BO',
|
||||
tiroirDos: 'TI_RW',
|
||||
tiroirFacade: 'TI_FR'
|
||||
});
|
||||
|
||||
function fmtMm(n) {
|
||||
@@ -45,6 +52,13 @@ function escQuoted(s) {
|
||||
return String(s ?? '').replace(/"/g, "'");
|
||||
}
|
||||
|
||||
/** Désignation « type » pour le bloc [001 (sans suffixe côté/index) — ex. SE_L → SE, ZA_O_2 → ZA. */
|
||||
function baseDesignation(slug) {
|
||||
const s = String(slug || '').trim();
|
||||
const cut = s.indexOf('_');
|
||||
return cut > 0 ? s.slice(0, cut) : s;
|
||||
}
|
||||
|
||||
/** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */
|
||||
export function flattenPiecesForMprExport(pieces = []) {
|
||||
const out = [];
|
||||
@@ -63,6 +77,14 @@ export function flattenPiecesForMprExport(pieces = []) {
|
||||
}
|
||||
|
||||
let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE';
|
||||
if (piece.horizontalSpanId != null) {
|
||||
const spanId = String(piece.horizontalSpanId).replace(/[^\w-]+/g, '_');
|
||||
const role = String(piece.horizontalSpanRole || 'panel').toUpperCase();
|
||||
baseSlug = `HS_${spanId}_${role}_${baseSlug}`;
|
||||
if (piece.horizontalSpanRole === 'door' && Number.isFinite(Number(piece.doorIndex))) {
|
||||
baseSlug += `_${Number(piece.doorIndex) + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
for (let qi = 0; qi < qty; qi++) {
|
||||
let slug = baseSlug;
|
||||
@@ -80,11 +102,14 @@ export function flattenPiecesForMprExport(pieces = []) {
|
||||
|
||||
function mprAnglesForPiece(piece) {
|
||||
const et = piece.elementType || '';
|
||||
if (habillageEtPorte(et)) {
|
||||
const widthMm = toSafe(piece.rawCutWidthMm, piece.widthMm);
|
||||
const heightMm = toSafe(piece.rawCutHeightMm, piece.heightMm);
|
||||
const vertical = heightMm > widthMm;
|
||||
if (vertical) {
|
||||
return {
|
||||
DRWIN: '270.0',
|
||||
beschGedreht: '0',
|
||||
turnMsg: et === 'portes' ? 'DREHEN X-ACHSE' : 'DREHEN X-ACHSE'
|
||||
turnMsg: 'DREHEN X-ACHSE'
|
||||
};
|
||||
}
|
||||
if (['dessus', 'fond'].includes(et)) {
|
||||
@@ -118,7 +143,7 @@ function kantenFlags(piece) {
|
||||
return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' };
|
||||
}
|
||||
|
||||
function grooveSection(piece, _Lmm, _Bmm) {
|
||||
function grooveSection(piece, Lmm, Bmm) {
|
||||
const m = piece?.machining || {};
|
||||
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
|
||||
if (!m.groove || !orient) return [];
|
||||
@@ -127,26 +152,60 @@ function grooveSection(piece, _Lmm, _Bmm) {
|
||||
const gd = fmtMm(Number(m.groove.depth ?? GROOVE.back.depth));
|
||||
const abst = fmtMm(Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge));
|
||||
const vertical = BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType);
|
||||
const win = vertical ? '180.0' : '270.0';
|
||||
const axis = Number(abst);
|
||||
const start = vertical
|
||||
? [Math.max(0, Lmm - axis), 0]
|
||||
: [0, Math.max(0, Bmm - axis)];
|
||||
const end = vertical
|
||||
? [Math.max(0, Lmm - axis), Bmm]
|
||||
: [Lmm, Math.max(0, Bmm - axis)];
|
||||
|
||||
return [
|
||||
'<121 \\Block\\',
|
||||
'MNM="Kantenbearbeitungen von oben"',
|
||||
'DP="1"',
|
||||
'KO="04"',
|
||||
'<139 \\Komponente\\',
|
||||
'IN="RSO/Makro/RSO_Nut_Flaeche.mpr"',
|
||||
'MNM="Nut Flaeche rueckseite"',
|
||||
'XA="L"',
|
||||
'YA="B"',
|
||||
'VA="LA L"',
|
||||
`VA="BR ${gw}"`,
|
||||
`VA="TI ${gd}"`,
|
||||
`VA="ABST ${abst}"`,
|
||||
`VA="WIN ${win}"`,
|
||||
'VA="ANF 0.0"',
|
||||
'VA="END 0.0"',
|
||||
'VA="MAT MAT"',
|
||||
'<109 \\Nuten\\',
|
||||
'MNM="Rainure dos"',
|
||||
`XA="${fmtMm(start[0])}"`,
|
||||
`YA="${fmtMm(start[1])}"`,
|
||||
`XE="${fmtMm(end[0])}"`,
|
||||
`YE="${fmtMm(end[1])}"`,
|
||||
`NB="${gw}"`,
|
||||
'RK="NOWRK"',
|
||||
'EM="MOD2"',
|
||||
'TV="0"',
|
||||
'MV="GL"',
|
||||
`TI="${gd}"`,
|
||||
'MN="GL"',
|
||||
'KO="04"'
|
||||
];
|
||||
}
|
||||
|
||||
function doorHandleGrooveSection(piece, L, B) {
|
||||
const groove = piece?.machining?.fullHeightHandleGroove;
|
||||
if (piece?.elementType !== 'portes' || !groove) return [];
|
||||
const widthMm = Math.max(1, Number(groove.grooveWidthMm) || 8);
|
||||
const depthMm = Math.max(0.1, Number(groove.grooveDepthMm) || 3);
|
||||
const offsetMm = Math.max(0, Number(groove.topBottomOffsetMm) || 0);
|
||||
const hingeSide = piece?.doorPlanHints?.hingeSide === 'right' ? 'right' : 'left';
|
||||
const xMm = hingeSide === 'right' ? widthMm / 2 : L - widthMm / 2;
|
||||
const y1Mm = Math.min(B, offsetMm);
|
||||
const y2Mm = Math.max(y1Mm, B - offsetMm);
|
||||
return [
|
||||
'<109 \\Nuten\\',
|
||||
'MNM="Rainure poignée toute hauteur"',
|
||||
`XA="${fmtMm(xMm)}"`,
|
||||
`YA="${fmtMm(y1Mm)}"`,
|
||||
`XE="${fmtMm(xMm)}"`,
|
||||
`YE="${fmtMm(y2Mm)}"`,
|
||||
`NB="${fmtMm(widthMm)}"`,
|
||||
'RK="NOWRK"',
|
||||
'EM="MOD2"',
|
||||
'TV="0"',
|
||||
'MV="GL"',
|
||||
`TI="${fmtMm(depthMm)}"`,
|
||||
'MN="GL"',
|
||||
'KO="04"'
|
||||
];
|
||||
}
|
||||
@@ -158,15 +217,17 @@ function bohrVertLines(spec, thicknessMm) {
|
||||
const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end');
|
||||
const throughHint = Boolean(spec.toolMode === 'through');
|
||||
const looksThrough = depthRaw >= thicknessMm - 0.5;
|
||||
const fromUnder = spec.face === 'back';
|
||||
let bm = 'LS';
|
||||
let ti;
|
||||
if (throughHint || looksThrough) {
|
||||
bm = 'LSL';
|
||||
bm = fromUnder ? 'LSLU' : 'LSL';
|
||||
ti = `${fmtMm(thicknessMm)} + 1`;
|
||||
} else if (edgeHint && depthRaw >= 38) {
|
||||
bm = 'LSL';
|
||||
ti = fmtMm(depthRaw);
|
||||
} else {
|
||||
bm = fromUnder ? 'LSU' : 'LS';
|
||||
ti = fmtMm(depthRaw);
|
||||
}
|
||||
|
||||
@@ -192,6 +253,59 @@ function bohrVertLines(spec, thicknessMm) {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Un trou de chant → lignes <103 BohrHoriz (perçage horizontal dans un chant).
|
||||
* `end` désigne le chant d'entrée dans le repère pièce (origine bas-gauche) :
|
||||
* left → depuis chant gauche (BM=XP), right → chant droit (BM=XM),
|
||||
* bottom → chant avant Y=0 (BM=YP), top → chant arrière Y=B (BM=YM).
|
||||
* ZA = position dans l'épaisseur (axe du trou) : `zMm` si fourni, sinon milieu (D/2).
|
||||
*/
|
||||
function bohrHorizLines(spec, L, B, D) {
|
||||
const du = fmtMm(Math.round((Number(spec.diameterMm) || 8) * 10) / 10);
|
||||
const ti = fmtMm(Number(spec.depthMm) || 25);
|
||||
const za = fmtMm(Number.isFinite(Number(spec.zMm)) ? Number(spec.zMm) : D / 2);
|
||||
|
||||
let bm;
|
||||
let xa;
|
||||
let ya;
|
||||
switch (spec.end) {
|
||||
case 'right':
|
||||
bm = 'XM';
|
||||
xa = L;
|
||||
ya = spec.yMm;
|
||||
break;
|
||||
case 'bottom':
|
||||
bm = 'YP';
|
||||
xa = spec.xMm;
|
||||
ya = 0;
|
||||
break;
|
||||
case 'top':
|
||||
bm = 'YM';
|
||||
xa = spec.xMm;
|
||||
ya = B;
|
||||
break;
|
||||
case 'left':
|
||||
default:
|
||||
bm = 'XP';
|
||||
xa = 0;
|
||||
ya = spec.yMm;
|
||||
break;
|
||||
}
|
||||
|
||||
return [
|
||||
'<103 \\BohrHoriz\\',
|
||||
`MNM="Bohrung ${du} mm"`,
|
||||
`XA="${fmtMm(xa)}"`,
|
||||
`YA="${fmtMm(ya)}"`,
|
||||
`ZA="${za}"`,
|
||||
`DU="${du}"`,
|
||||
`TI="${ti}"`,
|
||||
`BM="${bm}"`,
|
||||
'AN="1"',
|
||||
'KO="04"'
|
||||
];
|
||||
}
|
||||
|
||||
/** Supprime les doublons numériques (tolérance 0.02 mm — sans fusionner trous légitimes proches). */
|
||||
function dedupeHoleSpecs(specs, eps = 0.02) {
|
||||
const out = [];
|
||||
@@ -200,14 +314,134 @@ function dedupeHoleSpecs(specs, eps = 0.02) {
|
||||
(o) =>
|
||||
Math.abs(o.xMm - s.xMm) < eps &&
|
||||
Math.abs(o.yMm - s.yMm) < eps &&
|
||||
Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps
|
||||
Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps &&
|
||||
(o.face || 'face') === (s.face || 'face') &&
|
||||
(o.end || '') === (s.end || '')
|
||||
);
|
||||
if (!hit) out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildSingleMprText({
|
||||
/**
|
||||
* La fraise à rainurer travaille depuis le dessus de la machine. Pour le
|
||||
* panneau de dessus, on pose donc la face intérieure caisse vers le haut :
|
||||
* rainure dos + assemblages caisse dessus, perçages d'habillage dessous.
|
||||
*/
|
||||
function orientFaceSpecsForMpr(piece, specs) {
|
||||
if (piece?.elementType !== 'dessus' || !piece?.machining?.groove) return specs;
|
||||
return specs.map((spec) => {
|
||||
if (spec.face === 'face') return { ...spec, face: 'back' };
|
||||
if (spec.face === 'back') return { ...spec, face: 'face' };
|
||||
return spec;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Coupes d'onglet 45° (habillages de finition) :
|
||||
* - tourillons perpendiculaires à la face d'onglet → <104 BohrUniv (perçage
|
||||
* spatial : azimut C `CA` + inclinaison agrégat `WI`), sur le système 04.
|
||||
* (<102 BohrVert exige un plan parallèle au plan supérieur.)
|
||||
* - trait de scie 45° → <124 Nut_R (« Nuten im A-Winkel » : sciage avec lame
|
||||
* inclinée WI, macro documenté du format MPR, équivalent Uni_Saeg_...W45
|
||||
* du postprocesseur DXF HOMAG). Directement sur le plan supérieur (KO 04) :
|
||||
* pas de plan incliné custom, donc affichage propre et génération NC
|
||||
* possible avec l'agrégat scie pivotante.
|
||||
* Géométrie (coordonnées pièce) : la pièce occupe Z=0..D. La scie démarre donc
|
||||
* explicitement sur la face supérieure ZA=D, puis descend à 45° vers le chant
|
||||
* extérieur. Les trajectoires sont parcourues de sorte que leur normale gauche
|
||||
* pointe vers la chute : l'axe de scie est parallèle aux tourillons et le plan
|
||||
* de lame leur est perpendiculaire. Aucun plan S auxiliaire n'est nécessaire.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const MITER_SAW_KERF_MM = 4;
|
||||
|
||||
function buildMiterSections({ miterSpecs, L, B, D }) {
|
||||
const empty = { levelLines: [], opLines: [] };
|
||||
if (!miterSpecs.length) return empty;
|
||||
|
||||
const kerfCenter = MITER_SAW_KERF_MM / 2;
|
||||
|
||||
// Trajectoires globales sur KO=04. Pour chaque chant, le côté gauche du
|
||||
// parcours est l'extérieur de la pièce (la chute).
|
||||
const SIDES = {
|
||||
left: {
|
||||
label: 'gauche',
|
||||
ca: 0,
|
||||
entry: (sp) => [D / 2, sp.yMm],
|
||||
sawPath: [[D - kerfCenter, 0], [D - kerfCenter, B]]
|
||||
},
|
||||
right: {
|
||||
label: 'droite',
|
||||
ca: 180,
|
||||
entry: (sp) => [L - D / 2, sp.yMm],
|
||||
sawPath: [[L - D + kerfCenter, B], [L - D + kerfCenter, 0]]
|
||||
},
|
||||
top: {
|
||||
label: 'haut',
|
||||
ca: 270,
|
||||
entry: (sp) => [sp.xMm, B - D / 2],
|
||||
sawPath: [[0, B - D + kerfCenter], [L, B - D + kerfCenter]]
|
||||
}
|
||||
};
|
||||
|
||||
const opLines = [];
|
||||
|
||||
for (const [side, def] of Object.entries(SIDES)) {
|
||||
const specs = miterSpecs.filter((sp) => sp.end === side);
|
||||
if (!specs.length) continue;
|
||||
|
||||
/* Tourillons : perçage spatial sur le système 04 (repère pièce). */
|
||||
for (const sp of specs) {
|
||||
const [xa, ya] = def.entry(sp);
|
||||
const du = fmtMm(Number(sp.diameterMm) || 8);
|
||||
opLines.push(
|
||||
'<104 \\BohrUniv\\',
|
||||
`MNM="Tourillon onglet ${def.label} ${du} mm"`,
|
||||
`XA="${fmtMm(xa)}"`,
|
||||
`YA="${fmtMm(ya)}"`,
|
||||
`ZA="${fmtMm(D / 2)}"`,
|
||||
`CA="${def.ca}"`,
|
||||
'WI="45"',
|
||||
// woodWOP compte TI depuis le point XA/YA/ZA le long de l'axe incliné :
|
||||
// avec l'entrée au centre de la face d'onglet, la profondeur affichée
|
||||
// vaut le double de la profondeur utile → on émet la moitié.
|
||||
`TI="${fmtMm((Number(sp.depthMm) || 15) / 2)}"`,
|
||||
`DU="${du}"`,
|
||||
'AN="1"',
|
||||
'F_="STANDARD"',
|
||||
'KO="04"'
|
||||
);
|
||||
}
|
||||
|
||||
/* Trait de scie 45° incliné (Nut_R / Nuten im A-Winkel). */
|
||||
const [start, end] = def.sawPath;
|
||||
opLines.push(
|
||||
'<124 \\Nut_R\\',
|
||||
`MNM="Coupe onglet 45 ${def.label}"`,
|
||||
`XA="${fmtMm(start[0])}"`,
|
||||
`YA="${fmtMm(start[1])}"`,
|
||||
`XE="${fmtMm(end[0])}"`,
|
||||
`YE="${fmtMm(end[1])}"`,
|
||||
// KO=04 a son origine en Z=0 sous la pièce ; la face supérieure est Z=D.
|
||||
`ZA="${fmtMm(D)}"`,
|
||||
'WI="45"',
|
||||
`NB="${fmtMm(MITER_SAW_KERF_MM)}"`,
|
||||
'RK="NOWRK"',
|
||||
'EM="MOD2"',
|
||||
'TV="0"',
|
||||
'MV="GL"',
|
||||
`TI="${fmtMm(D * Math.SQRT2 + 2)}"`,
|
||||
'MN="GL"',
|
||||
'F_="STANDARD"',
|
||||
'KO="04"'
|
||||
);
|
||||
}
|
||||
|
||||
return { levelLines: [], opLines };
|
||||
}
|
||||
|
||||
export function buildSingleMprText({
|
||||
piece,
|
||||
slug,
|
||||
qtyIndex,
|
||||
@@ -226,43 +460,57 @@ function buildSingleMprText({
|
||||
|
||||
const posLabel = `${groupIndex}.${seqGlobal}`;
|
||||
const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex);
|
||||
const unique = dedupeHoleSpecs(specs);
|
||||
const unique = orientFaceSpecsForMpr(piece, dedupeHoleSpecs(specs));
|
||||
|
||||
/* Bloc Bohrungen */
|
||||
const holeLines = [];
|
||||
for (const sp of unique) {
|
||||
holeLines.push(...bohrVertLines(sp, thicknessMm));
|
||||
}
|
||||
|
||||
const dp = Math.max(1, Math.min(98, holeLines.filter((ln) => ln.startsWith('<102')).length));
|
||||
// Répartition des perçages par destination d'usinage (repère pièce, origine bas-gauche) :
|
||||
// - onglet 45° (habillages) → plans inclinés dédiés (buildMiterSections),
|
||||
// - chant (face='end') → <103 BohrHoriz, regroupés par côté d'entrée,
|
||||
// - face opposée (face='back') → <102 BohrVert avec cycle WoodWOP « von unten »,
|
||||
// - grande face (défaut) → <102 BohrVert « von oben ».
|
||||
const miterSpecs = unique.filter((sp) => sp.type === 'tourillon_miter' && sp.end);
|
||||
const rest = unique.filter((sp) => !miterSpecs.includes(sp));
|
||||
const edgeSpecs = rest.filter((sp) => sp.face === 'end');
|
||||
const backSpecs = rest.filter((sp) => sp.face === 'back');
|
||||
const topSpecs = rest.filter((sp) => sp.face !== 'end' && sp.face !== 'back');
|
||||
|
||||
const matKm = escQuoted(
|
||||
piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX'
|
||||
);
|
||||
|
||||
// Les opérations restent exprimées dans le repère pièce KO=04. DRWIN tourne
|
||||
// uniquement la présentation/pose machine (pièces hautes couchées en X).
|
||||
const miter = buildMiterSections({ miterSpecs, L, B, D });
|
||||
|
||||
const bez = escQuoted(baseDesignation(slug));
|
||||
const hasBack = backSpecs.length > 0;
|
||||
|
||||
const lines = [];
|
||||
|
||||
lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"');
|
||||
// Une pièce plus haute que large est couchée à 270° pour correspondre à
|
||||
// l'orientation machine; les formules DW conservent son repère d'usinage.
|
||||
lines.push('[001', 'MAT="2"', 'KM="Materialart"', `DRWIN="${angles.DRWIN}"`, 'KM="Drehwinkel 0,90,180,270"');
|
||||
lines.push(`AUFT="${escQuoted(orderReference)}"`, `KM="AUFTRAG: ${escQuoted(orderReference)}"`);
|
||||
lines.push(`STKL="${escQuoted(stackListKm)}"`, `KM="STUECKLISTE: ${escQuoted(stackListKm)}"`);
|
||||
lines.push('AUFT="0"', `KM="AUFTRAG: ${escQuoted(orderReference)}"`);
|
||||
lines.push('STKL="0"', `KM="STÜCKLISTE: ${escQuoted(stackListKm)}"`);
|
||||
lines.push(`POS="${escQuoted(posLabel)}"`, `KM="POSITION: ${escQuoted(posLabel)}"`);
|
||||
lines.push('BEZ="0"', `KM="Bezeichnung: ${escQuoted(slug)}"`);
|
||||
lines.push(`L="${fmtMm(L)}"`, 'KM="Laenge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"');
|
||||
lines.push('ZF="2.0"', 'KM="Zugabe Fraesung"', 'ZB="3.0"', 'KM="Zugabe Bohrung"');
|
||||
lines.push('RITZEN="1.0"', 'KM="Vorritztiefe mm"');
|
||||
lines.push('BEZ="0"', `KM="Bezeichnung: ${bez}"`);
|
||||
lines.push(`L="${fmtMm(L)}"`, 'KM="Länge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"');
|
||||
lines.push('ZF="2.0"', 'KM="Zugabe für DurchgangsFräsung"', 'ZB="3.0"', 'KM="Zugabe für DurchgangsBohrung"');
|
||||
lines.push('RITZEN="1.0"', 'KM="Vorritztiefe in mm - 0=nicht Vorritzen"');
|
||||
lines.push(`KAVODI="${kant.kavo}"`, 'KM="Kantendicke vorne"', `KALIDI="${kant.kali}"`, 'KM="Kantendicke links"');
|
||||
lines.push(`KAHIDI="${kant.kahi}"`, 'KM="Kantendicke hinten"', `KAREDI="${kant.kare}"`, 'KM="Kantendicke rechts"');
|
||||
lines.push('FORMAT="0"', 'KM="Formatieren 0 oder 1"');
|
||||
lines.push('ZUGLI="0.0"', 'ZUGRE="0.0"', 'ZUGVO="0.0"', 'ZUGHI="0.0"');
|
||||
lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"');
|
||||
lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'bd="IF DW=90 OR DW=270 THEN L ELSE B"');
|
||||
lines.push(
|
||||
'offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"',
|
||||
'offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"',
|
||||
'offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"',
|
||||
'offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"'
|
||||
);
|
||||
lines.push('ZUGLI="0.0"', 'KM="Zugabe für Gehrung links"');
|
||||
lines.push('ZUGRE="0.0"', 'KM="Zugabe für Gehrung rechts"');
|
||||
lines.push('ZUGVO="0.0"', 'KM="Zugabe für Gehrung vorne"');
|
||||
lines.push('ZUGHI="0.0"', 'KM="Zugabe für Gehrung hinten"');
|
||||
lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"', 'KM="DISABLED Drehwinkel korrigiert bei Fehleingabe"');
|
||||
lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'KM="DISABLED Länge in X (gedreht)"');
|
||||
lines.push('bd="IF DW=90 OR DW=270 THEN L ELSE B"', 'KM="DISABLED Breite in Y (gedreht)"');
|
||||
lines.push('offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"', 'KM="DISABLED Offset in X (0° oder 180°)"');
|
||||
lines.push('offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in X (90° oder 270°)"');
|
||||
lines.push('offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in Y (0° oder 180°)"');
|
||||
lines.push('offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"', 'KM="DISABLED Offset in Y (90° oder 270°)"');
|
||||
lines.push(
|
||||
'[K',
|
||||
'<00 \\Koordinatensystem\\',
|
||||
@@ -310,9 +558,12 @@ function buildSingleMprText({
|
||||
'KI="90"',
|
||||
'D2="0"',
|
||||
'MI="0"',
|
||||
...miter.levelLines,
|
||||
'<100 \\WerkStck\\',
|
||||
'LA="IF DW=90 OR DW=270 THEN B ELSE L"',
|
||||
'BR="IF DW=90 OR DW=270 THEN L ELSE B"',
|
||||
// WerkStck uses the piece-local dimensions. KO=04 applies DRWIN to the
|
||||
// stock and operations together; swapping here would rotate the stock twice.
|
||||
`LA="${fmtMm(L)}"`,
|
||||
`BR="${fmtMm(B)}"`,
|
||||
'DI="D"',
|
||||
'FNX="IF DW=90 OR DW=270 THEN offxB ELSE offxL"',
|
||||
'FNY="IF DW=90 OR DW=270 THEN offyB ELSE offyL"',
|
||||
@@ -320,20 +571,25 @@ function buildSingleMprText({
|
||||
'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"',
|
||||
'<101 \\Kommentar\\',
|
||||
`KM="AUFTRAG : ${escQuoted(orderReference)}"`,
|
||||
`KM="STUECKLISTE : ${escQuoted(stackListKm)}"`,
|
||||
`KM="STÜCKLISTE : ${escQuoted(stackListKm)}"`,
|
||||
`KM="POSITION : ${escQuoted(posLabel)}"`,
|
||||
`KM="Bezeichnung: ${escQuoted(slug)}"`,
|
||||
'KM=""',
|
||||
`KM="${escQuoted(angles.turnMsg)}"`,
|
||||
'KM=""',
|
||||
'KM="Teileinfo"',
|
||||
`KM="Groesse: ${fmtMm(L)} x ${fmtMm(B)}"`,
|
||||
`KM="Staerke: ${fmtMm(D)}"`,
|
||||
`KM="Größe: ${fmtMm(L)} x ${fmtMm(B)}"`,
|
||||
`KM="Stärke: ${fmtMm(D)}"`,
|
||||
`KM=""`,
|
||||
`KM="Material: ${matKm}"`,
|
||||
'KM="Belag IN: "',
|
||||
'KM="Belag AU: "',
|
||||
'KM=""',
|
||||
'KM=""'
|
||||
);
|
||||
|
||||
// Les usinages de sous-face utilisent directement BM=LSU/LSLU et ne
|
||||
// nécessitent donc aucun retournement de la pièce.
|
||||
lines.push(
|
||||
'<139 \\Komponente\\',
|
||||
'MNM="Beschriftung"',
|
||||
'IN="RSO/Makro/RSO_Beschriftung.mpr"',
|
||||
@@ -345,7 +601,10 @@ function buildSingleMprText({
|
||||
'VA="KAREDI KAREDI"',
|
||||
`VA="GEDREHT ${angles.beschGedreht}"`,
|
||||
'VA="NCSTOPP 0"',
|
||||
'KO="04"',
|
||||
'KO="04"'
|
||||
);
|
||||
// Macro de formatage (RSO_Format) — exécutée seulement si FORMAT=1 (??="FORMAT").
|
||||
lines.push(
|
||||
'<139 \\Komponente\\',
|
||||
'MNM="Formatierung"',
|
||||
'IN="RSO/Makro/RSO_Format.mpr"',
|
||||
@@ -363,15 +622,41 @@ function buildSingleMprText({
|
||||
'VA="KAFRMAX 2.0"',
|
||||
'VA="MAT MAT"',
|
||||
'??="FORMAT"',
|
||||
'KO="04"',
|
||||
'<121 \\Block\\',
|
||||
'MNM="Bearbeitungen von oben "',
|
||||
`DP="${dp}"`,
|
||||
'KO="04"'
|
||||
);
|
||||
|
||||
lines.push(...holeLines);
|
||||
// Bloc « Bearbeitungen von oben » — perçages verticaux sur la grande face.
|
||||
if (topSpecs.length) {
|
||||
lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von oben "', `DP="${topSpecs.length}"`, 'KO="04"');
|
||||
for (const sp of topSpecs) lines.push(...bohrVertLines(sp, thicknessMm));
|
||||
}
|
||||
|
||||
// Bloc « Bearbeitungen von unten » — BM=LSU/LSLU indique réellement à
|
||||
// WoodWOP que l'outil entre par la sous-face.
|
||||
if (hasBack) {
|
||||
lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von unten "', `DP="${backSpecs.length}"`, 'KO="04"');
|
||||
for (const sp of backSpecs) {
|
||||
lines.push(...bohrVertLines(sp, thicknessMm));
|
||||
}
|
||||
}
|
||||
|
||||
// Blocs de perçage sur chant — un bloc par côté d'entrée.
|
||||
const edgeBlocks = [
|
||||
['left', 'Bearbeitungen von links '],
|
||||
['right', 'Bearbeitungen von rechts '],
|
||||
['bottom', 'Bearbeitungen von vorne '],
|
||||
['top', 'Bearbeitungen von hinten ']
|
||||
];
|
||||
for (const [end, label] of edgeBlocks) {
|
||||
const group = edgeSpecs.filter((sp) => (sp.end || 'left') === end);
|
||||
if (!group.length) continue;
|
||||
lines.push('<121 \\Block\\', `MNM="${label}"`, `DP="${group.length}"`, 'KO="04"');
|
||||
for (const sp of group) lines.push(...bohrHorizLines(sp, L, B, D));
|
||||
}
|
||||
|
||||
lines.push(...miter.opLines);
|
||||
lines.push(...grooveSection(piece, L, B));
|
||||
lines.push(...doorHandleGrooveSection(piece, L, B));
|
||||
lines.push('!');
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
@@ -407,10 +692,12 @@ function buildMprFileRecords(entries, mprOptions) {
|
||||
}
|
||||
|
||||
async function packMprRecords(records, zipBaseName) {
|
||||
// Encodage Windows-1252 (Latin-1) : format attendu par woodWOP pour les
|
||||
// commentaires KM allemands (ä ö ü ß °), comme dans les fichiers fabricant.
|
||||
if (records.length === 1) {
|
||||
const only = records[0];
|
||||
return {
|
||||
buffer: Buffer.from(only.content, 'utf8'),
|
||||
buffer: Buffer.from(only.content, 'latin1'),
|
||||
fileName: only.fileName,
|
||||
mimeType: 'application/octet-stream',
|
||||
fileCount: 1
|
||||
@@ -419,7 +706,7 @@ async function packMprRecords(records, zipBaseName) {
|
||||
|
||||
const zip = new JSZip();
|
||||
for (const rec of records) {
|
||||
zip.file(rec.fileName, rec.content);
|
||||
zip.file(rec.fileName, Buffer.from(rec.content, 'latin1'));
|
||||
}
|
||||
const safeBase = safeZipFileName(zipBaseName || 'piece-mpr');
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
import {
|
||||
PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS,
|
||||
getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE,
|
||||
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING
|
||||
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING,
|
||||
resolveHorizontalSpan, validateHorizontalSpans, getSeparatorVerticalSegments,
|
||||
hasSideHabillage, hasSidePlinthReturn, getAxiloPlinthReturnLengthMm,
|
||||
getAxiloPlinthFrontSideDeductionMm
|
||||
} from '../../shared/manufacturing.js';
|
||||
|
||||
// Constants for drawing
|
||||
@@ -36,6 +39,8 @@ const SHARED_STYLES = `
|
||||
.renfort { fill: #c9a96e; stroke: #333; stroke-width: 0.5; opacity: 0.7; }
|
||||
.detail-label { font-family: Arial, sans-serif; font-size: 7px; fill: #666; }
|
||||
.fh-groove { fill: none; stroke: #0d9488; stroke-width: 1; stroke-dasharray: 3, 2; }
|
||||
.horizontal-span { fill: none; stroke: #b45309; stroke-width: 2; }
|
||||
.horizontal-span-label { font-family: Arial, sans-serif; font-size: 8px; font-weight: bold; fill: #92400e; }
|
||||
`;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
@@ -140,6 +145,19 @@ function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
|
||||
export function generateAssemblyPlan(config, modules = []) {
|
||||
const { widthMm, heightMm, plinthHeightMm, doorType } = config;
|
||||
const bodyHeight = heightMm - plinthHeightMm;
|
||||
const planModules = modules.length > 0 ? modules : (Array.isArray(config.modules) ? config.modules : []);
|
||||
const spanConfig = { ...config, modules: planModules };
|
||||
const spanIssues =
|
||||
Array.isArray(config.horizontalSpans) && config.horizontalSpans.length
|
||||
? validateHorizontalSpans(spanConfig)
|
||||
: [];
|
||||
const invalidSpanIds = new Set(
|
||||
spanIssues.map((issue) => issue.spanId).filter((id) => id != null).map(String)
|
||||
);
|
||||
const resolvedSpans = (Array.isArray(config.horizontalSpans) ? config.horizontalSpans : [])
|
||||
.filter((span) => span?.id != null && !invalidSpanIds.has(String(span.id)))
|
||||
.map((span) => resolveHorizontalSpan(span, spanConfig))
|
||||
.filter(Boolean);
|
||||
const S = DRAWING_SCALE;
|
||||
|
||||
const svgWidth = widthMm * S + MARGIN * 2;
|
||||
@@ -154,19 +172,49 @@ export function generateAssemblyPlan(config, modules = []) {
|
||||
const oX = MARGIN;
|
||||
const oY = MARGIN;
|
||||
|
||||
// Plinth
|
||||
const habLeft = hasSideHabillage(config, 'left');
|
||||
const habRight = hasSideHabillage(config, 'right');
|
||||
const returnLeft = hasSidePlinthReturn(config, 'left');
|
||||
const returnRight = hasSidePlinthReturn(config, 'right');
|
||||
const habT = PANEL.structure * S;
|
||||
const isNiche = config?.installationType === 'niche';
|
||||
const leftDeductMm = isNiche ? 0 : getAxiloPlinthFrontSideDeductionMm(config, 'left');
|
||||
const rightDeductMm = isNiche ? 0 : getAxiloPlinthFrontSideDeductionMm(config, 'right');
|
||||
const sideSetbackMm = PLINTH.frontSetbackMm;
|
||||
|
||||
// Plinth — shortened where side cladding or a recessed plinth return runs to the floor
|
||||
const plinthY = oY + bodyHeight * S;
|
||||
svg += `<rect x="${oX}" y="${plinthY}" width="${widthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||
const plinthX = oX + leftDeductMm * S;
|
||||
const plinthW = (widthMm - leftDeductMm - rightDeductMm) * S;
|
||||
svg += `<rect x="${plinthX}" y="${plinthY}" width="${plinthW}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||
|
||||
// Body
|
||||
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${bodyHeight * S}" class="body" />`;
|
||||
if (habLeft) {
|
||||
svg += `<rect x="${oX}" y="${oY}" width="${habT}" height="${heightMm * S}" class="body" />`;
|
||||
} else if (returnLeft) {
|
||||
svg += `<rect x="${oX + sideSetbackMm * S}" y="${plinthY}" width="${habT}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||
}
|
||||
if (habRight) {
|
||||
svg += `<rect x="${oX + widthMm * S - habT}" y="${oY}" width="${habT}" height="${heightMm * S}" class="body" />`;
|
||||
} else if (returnRight) {
|
||||
svg += `<rect x="${oX + widthMm * S - (sideSetbackMm + PANEL.structure) * S}" y="${plinthY}" width="${habT}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||
}
|
||||
|
||||
// Module separators
|
||||
if (modules.length > 0) {
|
||||
if (planModules.length > 0) {
|
||||
let currentX = oX;
|
||||
for (let i = 0; i < modules.length - 1; i++) {
|
||||
currentX += modules[i].widthMm * S;
|
||||
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
|
||||
for (let i = 0; i < planModules.length - 1; i++) {
|
||||
currentX += planModules[i].widthMm * S;
|
||||
if (resolvedSpans.length === 0) {
|
||||
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
|
||||
} else {
|
||||
for (const segment of getSeparatorVerticalSegments(spanConfig, i)) {
|
||||
const y1 = plinthY - segment.topMm * S;
|
||||
const y2 = plinthY - segment.bottomMm * S;
|
||||
svg += `<line x1="${currentX}" y1="${y1}" x2="${currentX}" y2="${y2}" class="separator" />`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,9 +230,9 @@ export function generateAssemblyPlan(config, modules = []) {
|
||||
svg += `<rect x="${oX + i * panelWidth * 0.95 + 5}" y="${oY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * S}" class="door" />`;
|
||||
}
|
||||
} else {
|
||||
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
|
||||
const blockCount = planModules.length > 0 ? planModules.length : (config.blocks?.length || 2);
|
||||
let currentX = oX;
|
||||
const items = modules.length > 0 ? modules : (config.blocks || []);
|
||||
const items = planModules.length > 0 ? planModules : (config.blocks || []);
|
||||
const hw = config.hardware || {};
|
||||
const fullHeightHandle = hw.handleType === 'full_height';
|
||||
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
|
||||
@@ -193,24 +241,116 @@ export function generateAssemblyPlan(config, modules = []) {
|
||||
|
||||
for (let i = 0; i < blockCount; i++) {
|
||||
const bw = items[i]?.widthMm || (widthMm / blockCount);
|
||||
const doorWmm = Math.max(100, Math.floor(widthMm / blockCount) - 4 - widthDeductionMm);
|
||||
const modulePx = bw * S;
|
||||
const doorWmm = Math.max(
|
||||
100,
|
||||
Math.floor(resolvedSpans.length === 0 ? widthMm / blockCount : bw) -
|
||||
4 -
|
||||
widthDeductionMm
|
||||
);
|
||||
const doorWPx = doorWmm * S;
|
||||
const doorX = currentX + (modulePx - doorWPx) / 2;
|
||||
const doorY = oY + gapMm * S;
|
||||
|
||||
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${doorHmm * S}" class="door" />`;
|
||||
|
||||
const hingeSide = getDoorHingeSide(i, blockCount, hw);
|
||||
svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
|
||||
if (fullHeightHandle) {
|
||||
svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
|
||||
|
||||
if (resolvedSpans.length === 0 || planModules.length === 0) {
|
||||
const doorY = oY + gapMm * S;
|
||||
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${doorHmm * S}" class="door" />`;
|
||||
svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
|
||||
if (fullHeightHandle) {
|
||||
svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
|
||||
}
|
||||
} else {
|
||||
const module = planModules[i] || {};
|
||||
let heights = Array.isArray(module.blockHeightsMm)
|
||||
? module.blockHeightsMm.map(Number).filter((height) => Number.isFinite(height) && height > 0)
|
||||
: [];
|
||||
if (!heights.length) {
|
||||
const stacked = module.stacked === true ||
|
||||
(module.stacked == null && config.stackEnabled === true);
|
||||
const bottom = Number(module.stackBottomHeightMm ?? config.stackBottomHeightMm);
|
||||
heights = stacked && bottom > 0 && bottom < bodyHeight
|
||||
? [bottom, bodyHeight - bottom]
|
||||
: [bodyHeight];
|
||||
}
|
||||
const blockDoors = Array.isArray(module.blockDoors) ? module.blockDoors : null;
|
||||
let bottomMm = 0;
|
||||
heights.forEach((height, blockIndex) => {
|
||||
const topMm = bottomMm + height;
|
||||
const covered = resolvedSpans.some((span) =>
|
||||
span.moduleIndexes.includes(i) &&
|
||||
bottomMm >= span.bottomMm - 0.5 &&
|
||||
topMm <= span.topMm + 0.5
|
||||
);
|
||||
const hasDoor =
|
||||
blockDoors && typeof blockDoors[blockIndex] === 'boolean'
|
||||
? blockDoors[blockIndex]
|
||||
: true;
|
||||
if (hasDoor && !covered) {
|
||||
const segmentDoorHeightMm = Math.max(80, height - 4);
|
||||
const doorY = plinthY - topMm * S + gapMm * S;
|
||||
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${segmentDoorHeightMm * S}" class="door" />`;
|
||||
svg += drawHingeMarkers(
|
||||
doorX, doorY, doorWmm, segmentDoorHeightMm, S, hingeSide
|
||||
);
|
||||
if (fullHeightHandle) {
|
||||
svg += drawFullHeightHandleGrooveElevation(
|
||||
doorX, doorY, doorWmm, segmentDoorHeightMm, S, hingeSide
|
||||
);
|
||||
}
|
||||
}
|
||||
bottomMm = topMm;
|
||||
});
|
||||
}
|
||||
|
||||
currentX += modulePx;
|
||||
}
|
||||
}
|
||||
|
||||
for (const span of resolvedSpans) {
|
||||
const x1 = oX + span.leftMm * S;
|
||||
const x2 = oX + span.rightMm * S;
|
||||
const yBottom = plinthY - span.bottomMm * S;
|
||||
const yTop = plinthY - span.topMm * S;
|
||||
svg += `<line x1="${x1}" y1="${yBottom}" x2="${x2}" y2="${yBottom}" class="horizontal-span" />`;
|
||||
svg += `<line x1="${x1}" y1="${yTop}" x2="${x2}" y2="${yTop}" class="horizontal-span" />`;
|
||||
svg += `<text x="${(x1 + x2) / 2}" y="${(yBottom + yTop) / 2}" class="horizontal-span-label" text-anchor="middle">Portée horizontale — colonnes ${span.moduleIndexes.map((index) => index + 1).join('–')}</text>`;
|
||||
|
||||
const doorMode = span.span?.doorMode || 'none';
|
||||
if (doorType !== 'sliding' && doorMode !== 'none') {
|
||||
const hw = config.hardware || {};
|
||||
const fullHeightHandle = hw.handleType === 'full_height';
|
||||
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
|
||||
const doorHeightMm = Math.max(120, span.topMm - span.bottomMm - 4);
|
||||
const doorY = yTop + 2 * S;
|
||||
const defs = doorMode === 'single'
|
||||
? [{ leftMm: span.leftMm, widthMm: span.widthMm }]
|
||||
: [
|
||||
{
|
||||
leftMm: span.leftMm,
|
||||
widthMm: span.widthMm / 2,
|
||||
hingeSide: 'left'
|
||||
},
|
||||
{
|
||||
leftMm: span.leftMm + span.widthMm / 2,
|
||||
widthMm: span.widthMm / 2,
|
||||
hingeSide: 'right'
|
||||
}
|
||||
];
|
||||
defs.forEach((door, doorIndex) => {
|
||||
const doorWidthMm = Math.max(100, Math.floor(door.widthMm) - 4 - widthDeductionMm);
|
||||
const doorX = oX + door.leftMm * S + (door.widthMm - doorWidthMm) * S / 2;
|
||||
const hingeSide = door.hingeSide || getDoorHingeSide(doorIndex, defs.length, hw);
|
||||
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWidthMm * S}" height="${doorHeightMm * S}" class="door" data-horizontal-span-door="${doorMode}" data-hinge-side="${hingeSide}" />`;
|
||||
svg += drawHingeMarkers(doorX, doorY, doorWidthMm, doorHeightMm, S, hingeSide);
|
||||
if (fullHeightHandle) {
|
||||
svg += drawFullHeightHandleGrooveElevation(
|
||||
doorX, doorY, doorWidthMm, doorHeightMm, S, hingeSide
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reinforcements (Forteresse 16 mm):
|
||||
// width = armoire width - 2×19 (entre côtés),
|
||||
// one at top + one at bottom (offset 19 mm),
|
||||
@@ -257,12 +397,23 @@ export function generateSidePlan(config) {
|
||||
const oX = MARGIN;
|
||||
const oY = MARGIN;
|
||||
|
||||
// Plinth
|
||||
const plinthY = oY + bodyHeight * S;
|
||||
svg += `<rect x="${oX}" y="${plinthY}" width="${depthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||
const habLeft = hasSideHabillage(config, 'left');
|
||||
const returnLeft = hasSidePlinthReturn(config, 'left');
|
||||
|
||||
// Body
|
||||
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${bodyHeight * S}" class="body" />`;
|
||||
const plinthY = oY + bodyHeight * S;
|
||||
const plinthSetbackMm = PLINTH.frontSetbackMm;
|
||||
if (habLeft) {
|
||||
// Habillage gauche jusqu'au sol : pas de plinthe visible de ce côté.
|
||||
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${heightMm * S}" class="body" />`;
|
||||
} else {
|
||||
if (returnLeft) {
|
||||
const returnLenMm = getAxiloPlinthReturnLengthMm(depthMm);
|
||||
svg += `<rect x="${oX + plinthSetbackMm * S}" y="${plinthY}" width="${returnLenMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||
} else {
|
||||
svg += `<rect x="${oX + plinthSetbackMm * S}" y="${plinthY}" width="${PLINTH.panelThicknessMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||
}
|
||||
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${bodyHeight * S}" class="body" />`;
|
||||
}
|
||||
|
||||
// Back panel (8mm) shown as a thin rectangle at the back
|
||||
const backX = oX + (depthMm - PANEL.back) * S;
|
||||
|
||||
+395
-41
@@ -4,13 +4,50 @@
|
||||
* Specs APEX 4.0 importées de shared/manufacturing.js
|
||||
*/
|
||||
|
||||
import { PANEL, DRAWERS, NICHE, getHingeCountForDoor } from '../../shared/manufacturing.js';
|
||||
import {
|
||||
PANEL,
|
||||
DRAWERS,
|
||||
NICHE,
|
||||
HANDLE,
|
||||
PLINTH,
|
||||
getAxiloPlinthPanelHeightMm,
|
||||
hasSidePlinthReturn,
|
||||
getAxiloPlinthReturnLengthMm,
|
||||
getHingeCountForDoor,
|
||||
resolveHorizontalSpan,
|
||||
validateHorizontalSpans,
|
||||
usesBlockConstruction,
|
||||
MAX_PANEL_DIMENSION_MM
|
||||
} from '../../shared/manufacturing.js';
|
||||
|
||||
function numberOr(value, fallback) {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function resolvedHorizontalSpansForConfig(config) {
|
||||
if (!Array.isArray(config?.horizontalSpans) || config.horizontalSpans.length === 0) return [];
|
||||
const invalidIds = new Set(
|
||||
validateHorizontalSpans(config)
|
||||
.map((issue) => issue?.spanId)
|
||||
.filter((id) => id != null)
|
||||
.map(String)
|
||||
);
|
||||
return config.horizontalSpans
|
||||
.filter((span) => span?.id != null && !invalidIds.has(String(span.id)))
|
||||
.map((span) => resolveHorizontalSpan(span, config))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function spanCoversBlock(resolvedSpans, moduleIndex, bottomMm, topMm) {
|
||||
const eps = 0.5;
|
||||
return resolvedSpans.some((span) =>
|
||||
span.moduleIndexes.includes(moduleIndex) &&
|
||||
bottomMm >= span.bottomMm - eps &&
|
||||
topMm <= span.topMm + eps
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
|
||||
* Épaisseur panneaux 19 mm (référence APEX 4.0).
|
||||
@@ -26,14 +63,70 @@ function calculateVisibleSurface(config, panelThicknessMm = PANEL.structure) {
|
||||
if (doorType === 'sliding' && slidingPanelsCount) {
|
||||
const panelWidth = widthMm / slidingPanelsCount;
|
||||
surface += slidingPanelsCount * (panelWidth * (heightMm - plinthHeightMm)) * 2; // deux faces par vantail
|
||||
} else if (Array.isArray(config.modules) && config.modules.length > 0) {
|
||||
// 1 porte par block AVEC porte (mod.blockDoors); block sans porte = niche ouverte, pas de façade.
|
||||
const bodyMm = heightMm - plinthHeightMm;
|
||||
const resolvedSpans = resolvedHorizontalSpansForConfig(config);
|
||||
for (let moduleIndex = 0; moduleIndex < config.modules.length; moduleIndex++) {
|
||||
const m = config.modules[moduleIndex];
|
||||
const colW = Number(m?.widthMm) || widthMm / config.modules.length;
|
||||
const arr = Array.isArray(m?.blockHeightsMm)
|
||||
? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
|
||||
: null;
|
||||
let heights;
|
||||
if (arr && arr.length > 0) {
|
||||
heights = arr;
|
||||
} else {
|
||||
const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true;
|
||||
heights = stacked && bodyMm >= 600
|
||||
? [Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000]
|
||||
.flatMap((b) => [b, bodyMm - b])
|
||||
: [bodyMm];
|
||||
}
|
||||
const doors = Array.isArray(m?.blockDoors) ? m.blockDoors : null;
|
||||
let blockBottomMm = 0;
|
||||
heights.forEach((h, k) => {
|
||||
const blockTopMm = blockBottomMm + h;
|
||||
const covered = spanCoversBlock(resolvedSpans, moduleIndex, blockBottomMm, blockTopMm);
|
||||
blockBottomMm = blockTopMm;
|
||||
const hasDoor = doors && typeof doors[k] === 'boolean' ? doors[k] : true;
|
||||
if (hasDoor && !covered) surface += colW * h * 2; // deux faces
|
||||
});
|
||||
}
|
||||
const fullHeightDeduction =
|
||||
config.hardware?.handleType === 'full_height' ? HANDLE.fullHeightWidthDeductionMm : 0;
|
||||
for (const span of resolvedSpans) {
|
||||
const doorMode = span.span?.doorMode || 'none';
|
||||
if (doorMode === 'none') continue;
|
||||
const doorHeightMm = Math.max(120, span.topMm - span.bottomMm - 4);
|
||||
if (doorMode === 'single') {
|
||||
const doorWidthMm = Math.max(100, span.widthMm - 4 - fullHeightDeduction);
|
||||
surface += doorWidthMm * doorHeightMm * 2;
|
||||
} else if (doorMode === 'perColumn') {
|
||||
for (let leafIndex = 0; leafIndex < 2; leafIndex++) {
|
||||
const doorWidthMm = Math.max(
|
||||
100,
|
||||
Math.floor(span.widthMm / 2) - 4 - fullHeightDeduction
|
||||
);
|
||||
surface += doorWidthMm * doorHeightMm * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const doorsCount = blocks.length <= 1 ? Math.min(2, Math.ceil((blocks[0]?.widthMm || widthMm) / 600)) : blocks.length * 2;
|
||||
const doorWidth = widthMm / Math.max(1, doorsCount);
|
||||
surface += doorsCount * (doorWidth * (heightMm - plinthHeightMm)) * 2;
|
||||
}
|
||||
|
||||
// Plinthe (face avant)
|
||||
surface += widthMm * plinthHeightMm;
|
||||
// Plinthe (face avant + retours latéraux s'il n'y a pas d'habillage)
|
||||
const plinthH = getAxiloPlinthPanelHeightMm(plinthHeightMm);
|
||||
surface += widthMm * plinthH;
|
||||
if (hasSidePlinthReturn(config, 'left')) {
|
||||
surface += getAxiloPlinthReturnLengthMm(depthMm) * plinthH;
|
||||
}
|
||||
if (hasSidePlinthReturn(config, 'right')) {
|
||||
surface += getAxiloPlinthReturnLengthMm(depthMm) * plinthH;
|
||||
}
|
||||
|
||||
// Bandeau et fileurs si niche
|
||||
if (isNiche) {
|
||||
@@ -188,47 +281,101 @@ function calculateModuleCosts(modules, moduleCatalog) {
|
||||
* @param {Object} config - Config with door and drawer counts
|
||||
* @returns {Object} - Hardware costs breakdown
|
||||
*/
|
||||
/**
|
||||
* Hauteurs (mm) des portes battantes: 1 porte par block AVEC porte (mod.blockDoors),
|
||||
* par colonne. Colonne empilée sans flags portes → 1 porte par block.
|
||||
*/
|
||||
function hingedDoorHeightsMm(config) {
|
||||
const bodyMm = (Number(config.heightMm) || 2400) - (Number(config.plinthHeightMm) || 100);
|
||||
const mods = Array.isArray(config.modules) && config.modules.length > 0 ? config.modules : null;
|
||||
const cols = mods ?? Array.from({ length: config.blocks?.length || 2 }, () => null);
|
||||
const resolvedSpans = mods ? resolvedHorizontalSpansForConfig(config) : [];
|
||||
const out = [];
|
||||
for (let moduleIndex = 0; moduleIndex < cols.length; moduleIndex++) {
|
||||
const m = cols[moduleIndex];
|
||||
const arr = Array.isArray(m?.blockHeightsMm)
|
||||
? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
|
||||
: null;
|
||||
let heights;
|
||||
if (arr && arr.length > 0) {
|
||||
heights = arr;
|
||||
} else {
|
||||
const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true;
|
||||
if (stacked && bodyMm >= 600) {
|
||||
const bottom = Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000;
|
||||
heights = [bottom, bodyMm - bottom];
|
||||
} else {
|
||||
heights = [bodyMm];
|
||||
}
|
||||
}
|
||||
const doors = Array.isArray(m?.blockDoors) ? m.blockDoors : null;
|
||||
let blockBottomMm = 0;
|
||||
heights.forEach((h, k) => {
|
||||
const blockTopMm = blockBottomMm + h;
|
||||
const covered = spanCoversBlock(resolvedSpans, moduleIndex, blockBottomMm, blockTopMm);
|
||||
blockBottomMm = blockTopMm;
|
||||
const hasDoor = doors && typeof doors[k] === 'boolean' ? doors[k] : true;
|
||||
if (hasDoor && !covered) out.push(Math.max(80, h - 4));
|
||||
});
|
||||
}
|
||||
for (const span of resolvedSpans) {
|
||||
const doorMode = span.span?.doorMode || 'none';
|
||||
const count =
|
||||
doorMode === 'single' ? 1 :
|
||||
doorMode === 'perColumn' ? 2 : 0;
|
||||
const heightMm = Math.max(120, span.topMm - span.bottomMm - 4);
|
||||
for (let index = 0; index < count; index++) out.push(heightMm);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function calculateHardwareCosts(hardware, hardwareData, config) {
|
||||
const { handles = [], hinges = [], blum = [] } = hardwareData;
|
||||
const breakdown = [];
|
||||
let totalChf = 0;
|
||||
|
||||
// Handle costs - one per door
|
||||
|
||||
const hingedDoorHeights = config.doorType === 'sliding' ? [] : hingedDoorHeightsMm(config);
|
||||
|
||||
// Handle costs - one per door (blocks sans porte exclus)
|
||||
if (hardware.handleVariantId) {
|
||||
const handle = handles.find(h => h.id === hardware.handleVariantId);
|
||||
if (handle) {
|
||||
const doorCount = config.doorType === 'sliding'
|
||||
const doorCount = config.doorType === 'sliding'
|
||||
? (config.slidingPanelsCount || 2)
|
||||
: (config.modules?.length || config.blocks?.length || 2);
|
||||
const cost = handle.price_chf * doorCount;
|
||||
totalChf += cost;
|
||||
breakdown.push({
|
||||
type: 'handle',
|
||||
name: handle.name,
|
||||
unitPrice: handle.price_chf,
|
||||
quantity: doorCount,
|
||||
totalChf: cost
|
||||
});
|
||||
: hingedDoorHeights.length;
|
||||
if (doorCount > 0) {
|
||||
const cost = handle.price_chf * doorCount;
|
||||
totalChf += cost;
|
||||
breakdown.push({
|
||||
type: 'handle',
|
||||
name: handle.name,
|
||||
unitPrice: handle.price_chf,
|
||||
quantity: doorCount,
|
||||
totalChf: cost
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hinge costs - dynamic quantity from APEX 4.0 matrix
|
||||
|
||||
// Hinge costs - dynamic quantity from APEX 4.0 matrix, par porte (hauteur du block)
|
||||
if (hardware.hingeVariantId && config.doorType !== 'sliding') {
|
||||
const hinge = hinges.find(h => h.id === hardware.hingeVariantId);
|
||||
if (hinge) {
|
||||
const doorCount = config.modules?.length || config.blocks?.length || 2;
|
||||
const doorHeightMm = (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4;
|
||||
const hingesPerDoor = getHingeCountForDoor({ heightMm: doorHeightMm });
|
||||
const quantity = doorCount * hingesPerDoor;
|
||||
const cost = hinge.price_chf * quantity;
|
||||
totalChf += cost;
|
||||
breakdown.push({
|
||||
type: 'hinge',
|
||||
name: hinge.name,
|
||||
unitPrice: hinge.price_chf,
|
||||
quantity,
|
||||
totalChf: cost
|
||||
});
|
||||
const quantity = hingedDoorHeights.reduce(
|
||||
(sum, doorHeightMm) => sum + getHingeCountForDoor({ heightMm: doorHeightMm }),
|
||||
0
|
||||
);
|
||||
if (quantity > 0) {
|
||||
const cost = hinge.price_chf * quantity;
|
||||
totalChf += cost;
|
||||
breakdown.push({
|
||||
type: 'hinge',
|
||||
name: hinge.name,
|
||||
unitPrice: hinge.price_chf,
|
||||
quantity,
|
||||
totalChf: cost
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +412,24 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
|
||||
*/
|
||||
export function calculatePrice(config, data) {
|
||||
const errors = [];
|
||||
const horizontalSpanIssues =
|
||||
Array.isArray(config?.horizontalSpans) && config.horizontalSpans.length
|
||||
? validateHorizontalSpans(config)
|
||||
: [];
|
||||
horizontalSpanIssues.forEach((issue) => errors.push({
|
||||
field: issue.field,
|
||||
message: issue.message
|
||||
}));
|
||||
const invalidHorizontalSpanIds = new Set(
|
||||
horizontalSpanIssues
|
||||
.map((issue) => issue.spanId)
|
||||
.filter((id) => id != null)
|
||||
.map(String)
|
||||
);
|
||||
const resolvedHorizontalSpans = (Array.isArray(config?.horizontalSpans) ? config.horizontalSpans : [])
|
||||
.filter((span) => span?.id != null && !invalidHorizontalSpanIds.has(String(span.id)))
|
||||
.map((span) => resolveHorizontalSpan(span, config))
|
||||
.filter(Boolean);
|
||||
const settingsArr = data.settings || [];
|
||||
const settings = {};
|
||||
for (const r of settingsArr) {
|
||||
@@ -274,7 +439,7 @@ export function calculatePrice(config, data) {
|
||||
}
|
||||
const kitFeeChf = numberOr(settings.kit_preparation_fee_chf, 350);
|
||||
const plinthMin = numberOr(settings.plinth_height_min_mm, 60);
|
||||
const plinthMax = numberOr(settings.plinth_height_max_mm, 160);
|
||||
const plinthMax = numberOr(settings.plinth_height_max_mm, 200);
|
||||
const slidingMinWidth = numberOr(settings.sliding_doors_min_width_mm, 1200);
|
||||
const allowSlidingDoors = String(settings.allow_sliding_doors ?? '0') === '1';
|
||||
const standardHandleAxisMm = numberOr(settings.standard_handle_axis_mm, 1000);
|
||||
@@ -287,14 +452,154 @@ export function calculatePrice(config, data) {
|
||||
doorType, slidingPanelsCount, finishId, serviceOptionIds = [],
|
||||
columnMode: columnModeRaw
|
||||
} = config;
|
||||
const columnMode = columnModeRaw === 'block' ? 'block' : 'separations';
|
||||
// Règle: dos → caisson block ; sinon côté ou rayonnage → block ; sinon séparations.
|
||||
// Legacy: `mod.hasBack` absent → `config.hasBack` global; les deux absents → columnMode envoyé.
|
||||
const legacyBackDefault = typeof config.hasBack === 'boolean'
|
||||
? config.hasBack
|
||||
: columnModeRaw === 'block';
|
||||
// Empilage vertical PAR COLONNE — modèle N blocks: mod.blockHeightsMm / mod.blockBacks
|
||||
// (legacy 2 blocks: mod.stacked + mod.stackBottomHeightMm; défauts config.stackEnabled / config.stackBottomHeightMm).
|
||||
const STACK_MIN_MM = 300;
|
||||
const STACK_MAX_MM = MAX_PANEL_DIMENSION_MM; // panneau max → au-delà, empilage obligatoire
|
||||
const bodyHeightForStackMm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 100);
|
||||
const minBlocksRequired = Math.max(1, Math.ceil(bodyHeightForStackMm / STACK_MAX_MM));
|
||||
const maxBlocksAllowed = Math.max(1, Math.floor(bodyHeightForStackMm / STACK_MIN_MM));
|
||||
|
||||
/** Hauteurs de blocks d'une colonne (bas → haut) telles qu'envoyées (sans normalisation silencieuse). */
|
||||
const columnBlockHeights = (modules || []).map((m) => {
|
||||
const arr = Array.isArray(m?.blockHeightsMm)
|
||||
? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
|
||||
: null;
|
||||
if (arr && arr.length > 0) return arr;
|
||||
const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true;
|
||||
if (stacked && bodyHeightForStackMm >= STACK_MIN_MM * 2) {
|
||||
const bottom = Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000;
|
||||
return [bottom, bodyHeightForStackMm - bottom];
|
||||
}
|
||||
return [bodyHeightForStackMm];
|
||||
});
|
||||
const columnBlockCounts = columnBlockHeights.map((hs) => hs.length);
|
||||
const anyStacked = columnBlockCounts.some((n) => n >= 2) ||
|
||||
((modules || []).length === 0 && config.stackEnabled === true);
|
||||
|
||||
if (anyStacked && bodyHeightForStackMm < STACK_MIN_MM * 2) {
|
||||
errors.push({
|
||||
field: 'stackEnabled',
|
||||
message: `Empilage vertical impossible: hauteur de caisse insuffisante (min. ${STACK_MIN_MM * 2} mm hors plinthe).`
|
||||
});
|
||||
} else {
|
||||
columnBlockHeights.forEach((hs, i) => {
|
||||
if (hs.length < minBlocksRequired) {
|
||||
errors.push({
|
||||
field: 'blockHeightsMm',
|
||||
message: `Colonne ${i + 1}: hauteur de caisse ${bodyHeightForStackMm} mm > panneau max ${STACK_MAX_MM} mm — au moins ${minBlocksRequired} blocks empilés obligatoires.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (hs.length > maxBlocksAllowed) {
|
||||
errors.push({
|
||||
field: 'blockHeightsMm',
|
||||
message: `Colonne ${i + 1}: trop de blocks (max. ${maxBlocksAllowed} pour ${bodyHeightForStackMm} mm, min. ${STACK_MIN_MM} mm par block).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (hs.length >= 2) {
|
||||
const sum = hs.reduce((a, b) => a + b, 0);
|
||||
if (hs.some((h) => h < STACK_MIN_MM || h > STACK_MAX_MM)) {
|
||||
errors.push({
|
||||
field: 'blockHeightsMm',
|
||||
message: `Colonne ${i + 1}: hauteur de block invalide (entre ${STACK_MIN_MM} et ${STACK_MAX_MM} mm par block).`
|
||||
});
|
||||
} else if (Math.abs(sum - bodyHeightForStackMm) > 2) {
|
||||
errors.push({
|
||||
field: 'blockHeightsMm',
|
||||
message: `Colonne ${i + 1}: la somme des blocks (${sum} mm) doit correspondre à la hauteur de caisse (${bodyHeightForStackMm} mm).`
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const stackEnabled = anyStacked && bodyHeightForStackMm >= STACK_MIN_MM * 2;
|
||||
|
||||
const columnBackFlags = (modules || []).map((m) =>
|
||||
typeof m?.hasBack === 'boolean' ? m.hasBack : legacyBackDefault
|
||||
);
|
||||
/** Dos par block d'une colonne (bas → haut). */
|
||||
const columnBlockBacks = (modules || []).map((m, i) => {
|
||||
const n = columnBlockCounts[i];
|
||||
const arr = Array.isArray(m?.blockBacks) ? m.blockBacks : null;
|
||||
return Array.from({ length: n }, (_, k) => {
|
||||
if (arr && typeof arr[k] === 'boolean') return arr[k];
|
||||
if (k === n - 1 && n >= 2 && typeof m?.hasBackTop === 'boolean') return m.hasBackTop;
|
||||
return columnBackFlags[i];
|
||||
});
|
||||
});
|
||||
// Zone du dessus (dernier block, ou zone unique).
|
||||
const columnBackTopFlags = columnBlockBacks.map((backs, i) =>
|
||||
backs.length > 0 ? backs[backs.length - 1] : columnBackFlags[i]
|
||||
);
|
||||
let blockJointCount = 0;
|
||||
for (let i = 0; i + 1 < columnBlockBacks.length; i++) {
|
||||
const leftMod = (modules || [])[i];
|
||||
const rightMod = (modules || [])[i + 1];
|
||||
const leftBlock = usesBlockConstruction(leftMod, (columnBlockBacks[i] || []).some(Boolean));
|
||||
const rightBlock = usesBlockConstruction(rightMod, (columnBlockBacks[i + 1] || []).some(Boolean));
|
||||
if (!leftBlock || !rightBlock) continue;
|
||||
const n = Math.min(columnBlockBacks[i].length, columnBlockBacks[i + 1].length);
|
||||
blockJointCount += n;
|
||||
}
|
||||
const allColumnsBack =
|
||||
columnBlockBacks.length > 0 &&
|
||||
columnBlockBacks.every((backs) => backs.every(Boolean));
|
||||
const anyColumnBack =
|
||||
columnBlockBacks.some((backs) => backs.some(Boolean)) ||
|
||||
(columnBlockBacks.length === 0 && legacyBackDefault);
|
||||
const columnMode = allColumnsBack ? 'block' : (anyColumnBack ? 'mixte' : 'separations');
|
||||
|
||||
// Habillages obligatoires (installation libre, configs récentes avec flags dos explicites):
|
||||
// - colonne d'extrémité empilée → habillage de ce côté seulement
|
||||
// - une zone du dessus (niveau haut, ou zone unique) sans dos → dessus de doublage obligatoire
|
||||
const hasExplicitBackFlags =
|
||||
typeof config.hasBack === 'boolean' || (modules || []).some((m) => typeof m?.hasBack === 'boolean');
|
||||
if (installationType === 'free' && (modules || []).length > 0 && hasExplicitBackFlags) {
|
||||
const extOn = config.exteriorFinishesEnabled !== false;
|
||||
const sideDefaults = config.sideFinishesEnabled !== false;
|
||||
const sideL = config.sideFinishLeftEnabled ?? sideDefaults;
|
||||
const sideR = config.sideFinishRightEnabled ?? sideDefaults;
|
||||
const topOn = config.topFinishEnabled !== false;
|
||||
const leftOuterStacked = columnBlockCounts[0] >= 2;
|
||||
const rightOuterStacked = columnBlockCounts[columnBlockCounts.length - 1] >= 2;
|
||||
if (leftOuterStacked && (!extOn || !sideL)) {
|
||||
errors.push({
|
||||
field: 'sideFinishLeftEnabled',
|
||||
message: 'Colonne gauche empilée (plusieurs blocks en hauteur) : habillage gauche obligatoire.'
|
||||
});
|
||||
}
|
||||
if (rightOuterStacked && (!extOn || !sideR)) {
|
||||
errors.push({
|
||||
field: 'sideFinishRightEnabled',
|
||||
message: 'Colonne droite empilée (plusieurs blocks en hauteur) : habillage droit obligatoire.'
|
||||
});
|
||||
}
|
||||
if (columnBackTopFlags.some((b) => !b) && (!extOn || !topOn)) {
|
||||
errors.push({
|
||||
field: 'topFinishEnabled',
|
||||
message: 'Une zone du dessus (niveau haut) sans dos : dessus de doublage obligatoire.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if using modules (V2) or blocks (legacy)
|
||||
const useModules = modules && modules.length > 0;
|
||||
|
||||
// Plinthe 60–160 mm
|
||||
// Plinthe (bornes paramétrables, défaut 60–200 mm)
|
||||
if (plinthHeightMm < plinthMin || plinthHeightMm > plinthMax) {
|
||||
errors.push({ field: 'plinthHeightMm', message: `La hauteur de plinthe doit être entre ${plinthMin} et ${plinthMax} mm.` });
|
||||
} else if (!PLINTH.targetHeightsMm.includes(Number(plinthHeightMm))) {
|
||||
errors.push({
|
||||
field: 'plinthHeightMm',
|
||||
message: `Hauteur AXILO 78 invalide. Valeurs admises : ${PLINTH.targetHeightsMm.join(', ')} mm.`
|
||||
});
|
||||
}
|
||||
|
||||
// Portes coulissantes : largeur totale >= 1200 mm
|
||||
@@ -431,18 +736,19 @@ export function calculatePrice(config, data) {
|
||||
|
||||
// Renforts Forteresse — one pair (haut+bas) per panneau vertical de structure
|
||||
// Séparations: 2 côtés + (n−1) séparateurs = n+1 panneaux verticaux
|
||||
// Block (x caisses): chaque caisse a 2 montants → 2n panneaux verticaux (plus de bois qu’en séparations)
|
||||
// Chaque jonction caisson↔caisson (dos ou rayonnage adjacents) ajoute 1 montant (double montant)
|
||||
const columnCount = useModules
|
||||
? Math.max(1, modules.length)
|
||||
: Math.max(1, blocks.length || 1);
|
||||
const verticalStructurePanelCount = columnMode === 'block'
|
||||
? 2 * columnCount
|
||||
: 2 + Math.max(0, columnCount - 1);
|
||||
const legacyBlockJoints = !useModules && columnModeRaw === 'block'
|
||||
? Math.max(0, columnCount - 1)
|
||||
: blockJointCount;
|
||||
const verticalStructurePanelCount = 2 + Math.max(0, columnCount - 1) + legacyBlockJoints;
|
||||
const reinforcementCount = verticalStructurePanelCount * 2;
|
||||
|
||||
// Surcoût panneaux 19 mm (structure): en mode block, (n−1) montants de plus qu’en séparations
|
||||
// Surcoût panneaux 19 mm (structure): 1 montant supplémentaire par jonction caisson↔caisson
|
||||
const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100);
|
||||
const extraBlockVerticalPanels = columnMode === 'block' && columnCount > 1 ? columnCount - 1 : 0;
|
||||
const extraBlockVerticalPanels = legacyBlockJoints;
|
||||
const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0
|
||||
? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000
|
||||
: 0;
|
||||
@@ -451,6 +757,51 @@ export function calculatePrice(config, data) {
|
||||
: 0;
|
||||
const blockExtraStructureChf = Math.round(extraBlockStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100;
|
||||
|
||||
// Empilage vertical PAR COLONNE: chaque jonction d'une colonne empilée ajoute 2 panneaux
|
||||
// horizontaux (dessus du block inférieur + fond du block supérieur) à sa largeur intérieure.
|
||||
let stackExtraStructureSurfaceM2 = 0;
|
||||
if (stackEnabled) {
|
||||
if (useModules) {
|
||||
(modules || []).forEach((m, i) => {
|
||||
const colW = Math.max(0, (Number(m?.widthMm) || widthMm / Math.max(1, modules.length)) - 2 * PANEL.structure);
|
||||
if (resolvedHorizontalSpans.length === 0) {
|
||||
const joints = Math.max(0, columnBlockCounts[i] - 1);
|
||||
stackExtraStructureSurfaceM2 += (joints * 2 * colW * depthMm) / 1_000_000;
|
||||
return;
|
||||
}
|
||||
const boundaries = [0];
|
||||
columnBlockHeights[i].forEach((height) => boundaries.push(boundaries[boundaries.length - 1] + height));
|
||||
for (let boundaryIndex = 1; boundaryIndex < boundaries.length - 1; boundaryIndex++) {
|
||||
const boundaryMm = boundaries[boundaryIndex];
|
||||
const spanAtBottom = resolvedHorizontalSpans.some((span) =>
|
||||
span.moduleIndexes.includes(i) && Math.abs(span.bottomMm - boundaryMm) <= 0.5
|
||||
);
|
||||
const spanAtTop = resolvedHorizontalSpans.some((span) =>
|
||||
span.moduleIndexes.includes(i) && Math.abs(span.topMm - boundaryMm) <= 0.5
|
||||
);
|
||||
const retainedFaces = 2 - Number(spanAtBottom) - Number(spanAtTop);
|
||||
stackExtraStructureSurfaceM2 += (retainedFaces * colW * depthMm) / 1_000_000;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
stackExtraStructureSurfaceM2 = (2 * Math.max(0, widthMm - 2 * PANEL.structure) * depthMm) / 1_000_000;
|
||||
}
|
||||
}
|
||||
if (resolvedHorizontalSpans.length > 0) {
|
||||
for (const span of resolvedHorizontalSpans) {
|
||||
const spanPanelWidthMm = Math.max(0, span.widthMm - 2 * PANEL.structure);
|
||||
stackExtraStructureSurfaceM2 += (2 * spanPanelWidthMm * depthMm) / 1_000_000;
|
||||
if (span.span?.hasBack) {
|
||||
// Back panels are 8 mm; price their surface proportionally to the 19 mm structure rate.
|
||||
stackExtraStructureSurfaceM2 +=
|
||||
(spanPanelWidthMm * Math.max(0, span.topMm - span.bottomMm - 2 * PANEL.structure)) /
|
||||
1_000_000 *
|
||||
(PANEL.back / PANEL.structure);
|
||||
}
|
||||
}
|
||||
}
|
||||
const stackExtraStructureChf = Math.round(stackExtraStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100;
|
||||
|
||||
// Each reinforcement is 100mm × innerDepth × 16mm — price as fraction of a panel
|
||||
const reinforcementSurfaceM2 = (PANEL.reinforcementWidth * (depthMm - PANEL.structure - 20)) / 1_000_000 * reinforcementCount;
|
||||
// Use 50% of panel price per m² for 16mm material (thinner, cheaper)
|
||||
@@ -464,7 +815,7 @@ export function calculatePrice(config, data) {
|
||||
if (opt) optionsChf += Number(opt.price_chf) || 0;
|
||||
}
|
||||
|
||||
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf;
|
||||
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf + stackExtraStructureChf;
|
||||
|
||||
return {
|
||||
totalChf: Math.round(totalChf * 100) / 100,
|
||||
@@ -479,6 +830,9 @@ export function calculatePrice(config, data) {
|
||||
columnMode,
|
||||
verticalStructurePanelCount,
|
||||
blockExtraStructureChf,
|
||||
stackExtraStructureChf,
|
||||
stackEnabled,
|
||||
stackedColumnCount: columnBlockCounts.filter((n) => n >= 2).length,
|
||||
optionsChf,
|
||||
hardwareChf,
|
||||
hardwareBreakdown,
|
||||
|
||||
Reference in New Issue
Block a user