v2.1
Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+395
-41
@@ -4,13 +4,50 @@
|
||||
* Specs APEX 4.0 importées de shared/manufacturing.js
|
||||
*/
|
||||
|
||||
import { PANEL, DRAWERS, NICHE, getHingeCountForDoor } from '../../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).
|
||||
@@ -26,14 +63,70 @@ function calculateVisibleSurface(config, panelThicknessMm = PANEL.structure) {
|
||||
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)
|
||||
surface += widthMm * plinthHeightMm;
|
||||
// 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) {
|
||||
@@ -188,47 +281,101 @@ function calculateModuleCosts(modules, moduleCatalog) {
|
||||
* @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;
|
||||
|
||||
// Handle costs - one per door
|
||||
|
||||
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'
|
||||
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
|
||||
});
|
||||
: 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
|
||||
|
||||
// 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 doorCount = config.modules?.length || config.blocks?.length || 2;
|
||||
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;
|
||||
breakdown.push({
|
||||
type: 'hinge',
|
||||
name: hinge.name,
|
||||
unitPrice: hinge.price_chf,
|
||||
quantity,
|
||||
totalChf: cost
|
||||
});
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +412,24 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
|
||||
*/
|
||||
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) {
|
||||
@@ -274,7 +439,7 @@ export function calculatePrice(config, data) {
|
||||
}
|
||||
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 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);
|
||||
@@ -287,14 +452,154 @@ export function calculatePrice(config, data) {
|
||||
doorType, slidingPanelsCount, finishId, serviceOptionIds = [],
|
||||
columnMode: columnModeRaw
|
||||
} = config;
|
||||
const columnMode = columnModeRaw === 'block' ? 'block' : 'separations';
|
||||
// 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 60–160 mm
|
||||
// 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
|
||||
@@ -431,18 +736,19 @@ 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)
|
||||
// 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 verticalStructurePanelCount = columnMode === 'block'
|
||||
? 2 * columnCount
|
||||
: 2 + Math.max(0, columnCount - 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): en mode block, (n−1) montants de plus qu’en séparations
|
||||
// Surcoût panneaux 19 mm (structure): 1 montant supplémentaire par jonction caisson↔caisson
|
||||
const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100);
|
||||
const extraBlockVerticalPanels = columnMode === 'block' && columnCount > 1 ? columnCount - 1 : 0;
|
||||
const extraBlockVerticalPanels = legacyBlockJoints;
|
||||
const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0
|
||||
? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000
|
||||
: 0;
|
||||
@@ -451,6 +757,51 @@ export function calculatePrice(config, data) {
|
||||
: 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)
|
||||
@@ -464,7 +815,7 @@ export function calculatePrice(config, data) {
|
||||
if (opt) optionsChf += Number(opt.price_chf) || 0;
|
||||
}
|
||||
|
||||
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf;
|
||||
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf + stackExtraStructureChf;
|
||||
|
||||
return {
|
||||
totalChf: Math.round(totalChf * 100) / 100,
|
||||
@@ -479,6 +830,9 @@ export function calculatePrice(config, data) {
|
||||
columnMode,
|
||||
verticalStructurePanelCount,
|
||||
blockExtraStructureChf,
|
||||
stackExtraStructureChf,
|
||||
stackEnabled,
|
||||
stackedColumnCount: columnBlockCounts.filter((n) => n >= 2).length,
|
||||
optionsChf,
|
||||
hardwareChf,
|
||||
hardwareBreakdown,
|
||||
|
||||
Reference in New Issue
Block a user