Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-10-07 17:13:03 +02:00
co-authored by Cursor
parent 6c0d7e47b8
commit 30921aade6
75 changed files with 15618 additions and 1300 deletions
+395 -41
View File
@@ -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,