Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-05-28 07:20:38 +02:00
co-authored by Cursor
parent 6923c319a1
commit 6c0d7e47b8
81 changed files with 21780 additions and 2986 deletions
+89 -10
View File
@@ -1,15 +1,23 @@
/**
* 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, getHingeCountForDoor } from '../../shared/manufacturing.js';
function numberOr(value, fallback) {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
}
/**
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
* Épaisseur panneaux 19 mm (référence).
* É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 = 19) {
function calculateVisibleSurface(config, panelThicknessMm = PANEL.structure) {
const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], doorType, slidingPanelsCount } = config;
const isNiche = installationType === 'niche';
let surface = 0;
@@ -204,12 +212,13 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
}
}
// Hinge costs - typically 3 per hinged door
// Hinge costs - dynamic quantity from APEX 4.0 matrix
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 doorHeightMm = (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4;
const hingesPerDoor = getHingeCountForDoor({ heightMm: doorHeightMm });
const quantity = doorCount * hingesPerDoor;
const cost = hinge.price_chf * quantity;
totalChf += cost;
@@ -263,16 +272,22 @@ export function calculatePrice(config, data) {
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 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, 160);
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 = []
doorType, slidingPanelsCount, finishId, serviceOptionIds = [],
columnMode: columnModeRaw
} = config;
const columnMode = columnModeRaw === 'block' ? 'block' : 'separations';
// Determine if using modules (V2) or blocks (legacy)
const useModules = modules && modules.length > 0;
@@ -286,11 +301,32 @@ export function calculatePrice(config, data) {
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;
@@ -307,6 +343,13 @@ export function calculatePrice(config, data) {
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);
@@ -386,6 +429,34 @@ export function calculatePrice(config, data) {
}
}
// 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
// Block (x caisses): chaque caisse a 2 montants → 2n panneaux verticaux (plus de bois qu’en séparations)
const columnCount = useModules
? Math.max(1, modules.length)
: Math.max(1, blocks.length || 1);
const verticalStructurePanelCount = columnMode === 'block'
? 2 * columnCount
: 2 + Math.max(0, columnCount - 1);
const reinforcementCount = verticalStructurePanelCount * 2;
// Surcoût panneaux 19 mm (structure): en mode block, (n−1) montants de plus qu’en séparations
const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100);
const extraBlockVerticalPanels = columnMode === 'block' && columnCount > 1 ? columnCount - 1 : 0;
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;
// 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) {
@@ -393,7 +464,7 @@ export function calculatePrice(config, data) {
if (opt) optionsChf += Number(opt.price_chf) || 0;
}
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf;
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf;
return {
totalChf: Math.round(totalChf * 100) / 100,
@@ -403,11 +474,19 @@ export function calculatePrice(config, data) {
modulesBreakdown,
kitChf: kitFeeChf,
panelsChf,
reinforcementChf,
reinforcementCount,
columnMode,
verticalStructurePanelCount,
blockExtraStructureChf,
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
};
}