Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
856 lines
35 KiB
JavaScript
856 lines
35 KiB
JavaScript
/**
|
||
* 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
|
||
};
|