/** * Moteur de calcul de prix — configurateur armoires sur mesure. * Règles : panneaux entiers (chute intégrée), blocs, kit, options. * Specs APEX 4.0 importées de 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). * @param {Object} config - configuration (installation, width, height, depth, plinthHeightMm, blocks, doorType, slidingPanels) * @param {number} panelThicknessMm - épaisseur panneaux (19) */ function calculateVisibleSurface(config, panelThicknessMm = PANEL.structure) { const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], doorType, slidingPanelsCount } = config; const isNiche = installationType === 'niche'; let surface = 0; // Portes (surface face visible) 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 + 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) { surface += widthMm * plinthHeightMm; // bandeau haut surface += 2 * plinthHeightMm * (heightMm - plinthHeightMm); // fileurs gauche/droite } // Côtés de doublage (coulissant) if (doorType === 'sliding') { surface += 2 * (depthMm * (heightMm - plinthHeightMm)); } return surface / 1_000_000; // m² } /** * Nombre de panneaux entiers nécessaires (arrondi supérieur). * Coût = nombre_panneaux × prix_panneau. */ function panelCost(surfaceM2, panelWidthMm, panelHeightMm, pricePerPanelChf) { const panelSurfaceM2 = (panelWidthMm * panelHeightMm) / 1_000_000; const count = Math.ceil(surfaceM2 / panelSurfaceM2); return { panels: Math.max(0, count), costChf: count * pricePerPanelChf }; } /** * Find the optimal dimension variant that minimizes cost (fewest panels needed). * @param {number} surfaceM2 - Required surface in square meters * @param {Array} dimensions - Array of dimension variants [{width_mm, height_mm, thickness_mm, id}] * @param {number} pricePerPanel - Price per panel in CHF * @returns {Object} - Optimal dimension with cost details and alternatives */ function findOptimalDimension(surfaceM2, dimensions, pricePerPanel) { if (!dimensions || dimensions.length === 0) { return null; } const price = pricePerPanel || 0; const results = []; for (const dim of dimensions) { if (!dim.width_mm || !dim.height_mm) continue; const panelSurfaceM2 = (dim.width_mm * dim.height_mm) / 1_000_000; const panelsNeeded = Math.ceil(surfaceM2 / panelSurfaceM2); const totalCost = panelsNeeded * price; const totalSurface = panelsNeeded * panelSurfaceM2; const wasteM2 = totalSurface - surfaceM2; const wastePercent = (wasteM2 / totalSurface) * 100; results.push({ dimension: { id: dim.id, width_mm: dim.width_mm, height_mm: dim.height_mm, thickness_mm: dim.thickness_mm }, panelsNeeded, costChf: totalCost, panelSurfaceM2, wasteM2: Math.round(wasteM2 * 1000) / 1000, wastePercent: Math.round(wastePercent * 10) / 10 }); } // Sort by cost (lowest first), then by waste (lowest first) results.sort((a, b) => { if (a.costChf !== b.costChf) return a.costChf - b.costChf; return a.wasteM2 - b.wasteM2; }); if (results.length === 0) return null; const optimal = results[0]; const alternatives = results.slice(1); return { selectedDimension: optimal.dimension, panelsNeeded: optimal.panelsNeeded, costChf: optimal.costChf, wasteM2: optimal.wasteM2, wastePercent: optimal.wastePercent, alternatives: alternatives.map(alt => ({ dimension: alt.dimension, panelsNeeded: alt.panelsNeeded, costChf: alt.costChf, wasteM2: alt.wasteM2, wastePercent: alt.wastePercent })) }; } /** * Calculate module costs (V2 mode) * @param {Array} modules - Array of module configurations * @param {Array} moduleCatalog - Available modules from database * @returns {Object} - Module costs breakdown */ function calculateModuleCosts(modules, moduleCatalog) { let totalChf = 0; const breakdown = []; for (const module of modules) { const catalogModule = moduleCatalog.find(m => m.id === module.moduleTypeId || m.code === module.moduleCode); const basePrice = catalogModule?.base_price_chf || 450; // Adjust price based on customizations let adjustedPrice = basePrice; // Extra shelves cost (beyond default) const defaultShelves = catalogModule?.shelves_count || 0; const extraShelves = Math.max(0, (module.shelves || 0) - defaultShelves); adjustedPrice += extraShelves * 25; // 25 CHF per extra shelf // Extra drawers cost (beyond default) const defaultDrawers = catalogModule?.drawers_count || 0; const extraDrawers = Math.max(0, (module.drawers || 0) - defaultDrawers); adjustedPrice += extraDrawers * 45; // 45 CHF per extra drawer // Width adjustment (if different from default) const defaultWidth = catalogModule?.width_mm || 600; const widthDiff = (module.widthMm || defaultWidth) - defaultWidth; if (widthDiff > 0) { adjustedPrice += widthDiff * 0.15; // 0.15 CHF per extra mm } breakdown.push({ moduleCode: module.moduleCode || catalogModule?.code, moduleName: catalogModule?.name || 'Module', basePrice, adjustedPrice: Math.round(adjustedPrice * 100) / 100, adjustments: { extraShelves, extraDrawers, widthDiff } }); totalChf += adjustedPrice; } return { totalChf: Math.round(totalChf * 100) / 100, breakdown }; } /** * Calculate hardware costs (V2) * @param {Object} hardware - Hardware selection {handleVariantId, hingeVariantId, drawerSystemId} * @param {Object} hardwareData - Hardware data from DB {handles, hinges, blum} * @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; 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' ? (config.slidingPanelsCount || 2) : 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, par porte (hauteur du block) if (hardware.hingeVariantId && config.doorType !== 'sliding') { const hinge = hinges.find(h => h.id === hardware.hingeVariantId); if (hinge) { 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 }); } } } // Drawer system costs - one per drawer if (hardware.drawerSystemId) { const system = blum.find(h => h.id === hardware.drawerSystemId && h.type === 'drawer_system'); if (system) { const modules = config.modules || []; const drawerCount = modules.reduce((sum, m) => sum + (m.drawers || 0), 0); if (drawerCount > 0) { const cost = system.price_chf * drawerCount; totalChf += cost; breakdown.push({ type: 'drawer_system', name: system.name, unitPrice: system.price_chf, quantity: drawerCount, totalChf: cost }); } } } return { totalChf: Math.round(totalChf * 100) / 100, breakdown }; } /** * Valide la configuration et calcule le prix total. * @param {Object} config - configuration client * @param {Object} data - données DB (settings, blockTypes, finishes, serviceOptions, modules, hardware) */ 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) { let v = r.value; if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)) v = Number(v); settings[r.key] = v; } 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, 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); const standardHandleToleranceMm = numberOr(settings.standard_handle_tolerance_mm, 200); const sanitaryGapMm = numberOr(settings.sanitary_gap_mm, NICHE.sanitaryGapAround); const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], modules = [], hardware = {}, doorType, slidingPanelsCount, finishId, serviceOptionIds = [], columnMode: columnModeRaw } = config; // 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 (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 if (doorType === 'sliding' && widthMm < slidingMinWidth) { errors.push({ field: 'doorType', message: `Les portes coulissantes ne sont possibles qu'à partir de ${slidingMinWidth} mm de largeur.` }); } if (doorType === 'sliding' && !allowSlidingDoors) { errors.push({ field: 'doorType', message: 'Les portes coulissantes sont désactivées pour le moment.' }); } if (doorType === 'sliding' && (slidingPanelsCount < 2 || slidingPanelsCount > 3)) { errors.push({ field: 'slidingPanelsCount', message: 'Choisissez 2 ou 3 vantaux coulissants.' }); } if (installationType === 'niche') { const nm = config.nicheMeasurements || {}; const minW = Math.min(nm.widthTopMm || widthMm, nm.widthMidMm || widthMm, nm.widthBottomMm || widthMm); const minD = Math.min(nm.depthTopMm || depthMm, nm.depthMidMm || depthMm, nm.depthBottomMm || depthMm); const minH = Math.min(nm.heightLeftMm || heightMm, nm.heightRightMm || heightMm); const expectedW = Math.max(400, minW - sanitaryGapMm * 2); const expectedD = Math.max(400, minD - sanitaryGapMm * 2); const expectedH = Math.max(1800, minH - sanitaryGapMm * 2); if (Math.abs(widthMm - expectedW) > 2 || Math.abs(depthMm - expectedD) > 2 || Math.abs(heightMm - expectedH) > 2) { errors.push({ field: 'nicheMeasurements', message: 'Les dimensions retenues ne correspondent pas aux plus petites mesures niche avec vide sanitaire de 3 cm.' }); } } const handleAxis = Number(config.hardware?.handleBoreHeightMm || standardHandleAxisMm); const minStandard = standardHandleAxisMm - standardHandleToleranceMm; const maxStandard = standardHandleAxisMm + standardHandleToleranceMm; const isHandleAxisNonStandard = handleAxis < minStandard || handleAxis > maxStandard; // Calculate block/module costs let blocksChf = 0; let modulesBreakdown = null; if (useModules) { // V2: Use module-based pricing const moduleCatalog = data.modules || []; const moduleCosts = calculateModuleCosts(modules, moduleCatalog); blocksChf = moduleCosts.totalChf; modulesBreakdown = moduleCosts.breakdown; // Validate module widths const totalModuleWidth = modules.reduce((sum, m) => sum + (m.widthMm || 0), 0); if (Math.abs(totalModuleWidth - widthMm) > 10) { errors.push({ field: 'modules', message: `La somme des largeurs des modules (${totalModuleWidth}mm) doit correspondre à la largeur totale (${widthMm}mm).` }); } const hasDrawerColumnTooWide = modules.some((m) => ((m.drawers || 0) > 0 || m.type === 'tiroirs') && (m.widthMm || 0) > DRAWERS.maxColumnWidth); if (hasDrawerColumnTooWide) { errors.push({ field: 'modules', message: `Une colonne tiroirs dépasse ${DRAWERS.maxColumnWidth} mm (non autorisé).` }); } if (modules.some((m) => m.hasRod) && heightMm < 1200) { errors.push({ field: 'modules', message: 'Rayon avec tringle: hauteur minimale 1200 mm.' }); } } else { // Legacy: Use block-based pricing const totalWidth = blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0); if (Math.abs(totalWidth - widthMm) > 5 && blocks.length > 0) { errors.push({ field: 'blocks', message: 'La somme des largeurs des blocs doit correspondre à la largeur totale.' }); } const blockTypes = data.blockTypes || []; for (const block of blocks) { const bt = blockTypes.find((b) => b.id === block.blockTypeId || b.code === block.blockTypeCode); if (bt) blocksChf += Number(bt.base_price_chf) || 550; else blocksChf += 550; } } // Calculate hardware costs (V2) let hardwareChf = 0; let hardwareBreakdown = null; if (hardware && (hardware.handleVariantId || hardware.hingeVariantId || hardware.drawerSystemId)) { const hardwareData = { handles: data.handles || [], hinges: data.hinges || [], blum: data.blumHardware || [] }; const hardwareCosts = calculateHardwareCosts(hardware, hardwareData, config); hardwareChf = hardwareCosts.totalChf; hardwareBreakdown = hardwareCosts.breakdown; } let panelsChf = 0; let panelOptimization = null; const surfaceM2 = calculateVisibleSurface({ ...config, widthMm: widthMm || blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0), plinthHeightMm: plinthHeightMm || 100, blocks: useModules ? modules.map(m => ({ widthMm: m.widthMm })) : blocks, doorType: doorType || 'hinged', slidingPanelsCount: slidingPanelsCount || 2 }); // Find finish/material by ID or code const finish = (data.finishes || []).find((f) => f.id === finishId || f.code === config.finishCode); if (finish) { const priceChf = finish.price_chf; // Check if we have multiple dimension variants for optimization if (finish.dimensions && finish.dimensions.length > 0 && priceChf != null) { // Auto-optimize: find the best dimension that minimizes cost panelOptimization = findOptimalDimension(surfaceM2, finish.dimensions, Number(priceChf)); if (panelOptimization) { panelsChf = panelOptimization.costChf; } } else if (finish.width_mm && finish.height_mm && priceChf != null) { // Legacy: single dimension from finish const result = panelCost(surfaceM2, finish.width_mm, finish.height_mm, Number(priceChf)); panelsChf = result.costChf; // Create optimization info for legacy finishes const panelSurfaceM2 = (finish.width_mm * finish.height_mm) / 1_000_000; const wasteM2 = (result.panels * panelSurfaceM2) - surfaceM2; panelOptimization = { selectedDimension: { width_mm: finish.width_mm, height_mm: finish.height_mm, thickness_mm: null }, panelsNeeded: result.panels, costChf: result.costChf, wasteM2: Math.round(wasteM2 * 1000) / 1000, wastePercent: Math.round((wasteM2 / (result.panels * panelSurfaceM2)) * 1000) / 10, alternatives: [] }; } } // 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 // 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 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): 1 montant supplémentaire par jonction caisson↔caisson const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100); const extraBlockVerticalPanels = legacyBlockJoints; const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0 ? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000 : 0; const structureUnitPriceM2 = finish ? (Number(finish.price_chf) || 0) / ((finish.width_mm || 2800) * (finish.height_mm || 2070) / 1_000_000) : 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) const reinforcementUnitPriceM2 = finish ? (Number(finish.price_chf) || 0) * 0.5 / ((finish.width_mm || 2800) * (finish.height_mm || 2070) / 1_000_000) : 0; const reinforcementChf = Math.round(reinforcementSurfaceM2 * reinforcementUnitPriceM2 * 100) / 100; let optionsChf = 0; const services = data.serviceOptions || []; for (const optId of serviceOptionIds) { const opt = services.find((s) => s.id === optId || s.code === optId); if (opt) optionsChf += Number(opt.price_chf) || 0; } const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf + stackExtraStructureChf; return { totalChf: Math.round(totalChf * 100) / 100, surfaceM2: Math.round(surfaceM2 * 1000) / 1000, details: { blocksChf, modulesBreakdown, kitChf: kitFeeChf, panelsChf, reinforcementChf, reinforcementCount, columnMode, verticalStructurePanelCount, blockExtraStructureChf, stackExtraStructureChf, stackEnabled, stackedColumnCount: columnBlockCounts.filter((n) => n >= 2).length, optionsChf, hardwareChf, hardwareBreakdown, panelOptimization }, warnings: isHandleAxisNonStandard ? [{ field: 'handleBoreHeightMm', message: `Hauteur perçage poignée hors plage standard (${minStandard}-${maxStandard} mm).` }] : undefined, errors: errors.length ? errors : undefined }; } export default { calculatePrice, calculateVisibleSurface, panelCost, findOptimalDimension, calculateModuleCosts, calculateHardwareCosts };