Files
apex/server/services/priceEngine.js
T
lejayandCursor 30921aade6 v2.1
Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
2026-10-07 17:13:03 +02:00

856 lines
35 KiB
JavaScript
Raw Permalink 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.
* 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
};