/** * 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, getHingeCountForDoor } from '../../shared/manufacturing.js'; function numberOr(value, fallback) { const n = typeof value === 'number' ? value : Number(value); return Number.isFinite(n) ? n : fallback; } /** * 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 { 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; // 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 */ function calculateHardwareCosts(hardware, hardwareData, config) { const { handles = [], hinges = [], blum = [] } = hardwareData; const breakdown = []; let totalChf = 0; // Handle costs - one per door if (hardware.handleVariantId) { const handle = handles.find(h => h.id === hardware.handleVariantId); if (handle) { 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 }); } } // Hinge costs - dynamic quantity from APEX 4.0 matrix 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 }); } } // 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 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, 160); 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; const columnMode = columnModeRaw === 'block' ? 'block' : 'separations'; // Determine if using modules (V2) or blocks (legacy) const useModules = modules && modules.length > 0; // Plinthe 60–160 mm if (plinthHeightMm < plinthMin || plinthHeightMm > plinthMax) { errors.push({ field: 'plinthHeightMm', message: `La hauteur de plinthe doit être entre ${plinthMin} et ${plinthMax} 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 // Block (x caisses): chaque caisse a 2 montants → 2n panneaux verticaux (plus de bois qu’en séparations) 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 reinforcementCount = verticalStructurePanelCount * 2; // Surcoût panneaux 19 mm (structure): en mode block, (n−1) montants de plus qu’en séparations const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100); const extraBlockVerticalPanels = columnMode === 'block' && columnCount > 1 ? columnCount - 1 : 0; 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; // 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; return { totalChf: Math.round(totalChf * 100) / 100, surfaceM2: Math.round(surfaceM2 * 1000) / 1000, details: { blocksChf, modulesBreakdown, kitChf: kitFeeChf, panelsChf, reinforcementChf, reinforcementCount, columnMode, verticalStructurePanelCount, blockExtraStructureChf, 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 };