Files
apex/server/services/priceEngine.js
T
2026-02-13 00:21:53 +01:00

423 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
};