/** * Moteur de calcul de prix — configurateur armoires sur mesure. * Règles : panneaux entiers (chute intégrée), blocs, kit, options. */ /** * Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage). * Épaisseur panneaux 19 mm (référence). * @param {Object} config - configuration (installation, width, height, depth, plinthHeightMm, blocks, doorType, slidingPanels) * @param {number} panelThicknessMm - épaisseur panneaux (19) */ function calculateVisibleSurface(config, panelThicknessMm = 19) { 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 - typically 3 per hinged door 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 hingesPerDoor = 3; 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 = Number(settings.kit_preparation_fee_chf) ?? 350; const plinthMin = Number(settings.plinth_height_min_mm) ?? 60; const plinthMax = Number(settings.plinth_height_max_mm) ?? 160; const slidingMinWidth = Number(settings.sliding_doors_min_width_mm) ?? 1200; const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], modules = [], hardware = {}, doorType, slidingPanelsCount, finishId, serviceOptionIds = [] } = config; // 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' && (slidingPanelsCount < 2 || slidingPanelsCount > 3)) { errors.push({ field: 'slidingPanelsCount', message: 'Choisissez 2 ou 3 vantaux coulissants.' }); } // 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).` }); } } 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: [] }; } } 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; return { totalChf: Math.round(totalChf * 100) / 100, surfaceM2: Math.round(surfaceM2 * 1000) / 1000, details: { blocksChf, modulesBreakdown, kitChf: kitFeeChf, panelsChf, optionsChf, hardwareChf, hardwareBreakdown, panelOptimization }, errors: errors.length ? errors : undefined }; } export default { calculatePrice, calculateVisibleSurface, panelCost, findOptimalDimension, calculateModuleCosts, calculateHardwareCosts };