v1.0
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
Reference in New Issue
Block a user