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
+11
View File
@@ -24,5 +24,16 @@ [email protected]
# Browser timeout in ms
#HAFELE_BROWSER_TIMEOUT_MS=90000
# Leuba Hiag (catalog import via Algolia)
# The Algolia app id / index / guest key are read live from the catalog page, so no key
# is needed here. Credentials are used to fetch customer-specific prices (via login).
#[email protected]
#LEUBAHIAG_PASSWORD=your-password
# Delay between Algolia pages in ms
#LEUBAHIAG_CRAWL_DELAY_MS=300
# Optional fallbacks if the page structure changes
#LEUBAHIAG_ALGOLIA_APP_ID=QY41R2P2B0
#LEUBAHIAG_ALGOLIA_INDEX=prd_whitelabel_kurlh_products_fr
# OpenAI API Key for room photo analysis (GPT-4 Vision)
OPENAI_API_KEY=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
Binary file not shown.
+110
View File
@@ -0,0 +1,110 @@
/**
* Dispositions types intégrées, insérées dans `layout_presets` (une fois par seed_key).
* Le client normalise ensuite blocks / rayons (positions System 32, rayon structurel, etc.).
*/
const TYPE_DEFAULTS = {
penderie: { shelves: 1, drawers: 0, hasRod: true },
tiroirs: { shelves: 1, drawers: 3, hasRod: false },
rayonnage: { shelves: 4, drawers: 0, hasRod: false },
bloc_carre: { shelves: 0, drawers: 0, hasRod: false },
mixte: { shelves: 2, drawers: 2, hasRod: true }
};
function column(key, index, type, widthMm, overrides = {}) {
const hasBack = overrides.hasBack ?? false;
return {
id: `preset-${key}-c${index + 1}`,
type,
moduleCode: type,
widthMm,
minWidth: 300,
maxWidth: type === 'tiroirs' || type === 'mixte' ? 1000 : 1200,
position: index,
locked: false,
...TYPE_DEFAULTS[type],
drawerType: 'visible',
hasBack,
hasBackTop: hasBack,
...overrides
};
}
function layout(key, { name, description, widthMm, heightMm = 2400, depthMm = 600, plinthHeightMm = 100, hasBack = false, columns }) {
return {
seedKey: key,
name,
description,
config: {
widthMm,
heightMm,
depthMm,
plinthHeightMm,
hasBack,
stackEnabled: columns.some((c) => Array.isArray(c[2]?.blockHeightsMm) && c[2].blockHeightsMm.length >= 2),
horizontalSpans: [],
modules: columns.map(([type, w, overrides], i) => column(key, i, type, w, { hasBack, ...overrides }))
}
};
}
/*
* Penderie au-delà de 2000 mm: une colonne en un seul block exige un rayon structurel à mi-hauteur,
* ce qui coupe la penderie (< 1100 mm sous tringle). Les penderies sont donc empilées: block bas de
* 1800 mm (≈ 1710 mm sous tringle, manteaux longs ≥ 1600 mm) + surmeuble; la jonction sert de chapelier.
*/
const HANGING = { blockHeightsMm: [1800, 500], shelves: 0 };
const SURMEUBLE = { blockHeightsMm: [2000, 500] };
const OPEN = { blockDoors: [false] };
export const DEFAULT_LAYOUT_PRESETS = [
layout('penderie-double', {
name: 'Penderie double',
description: 'Deux penderies manteaux longs avec surmeubles',
widthMm: 1200,
columns: [['penderie', 600, HANGING], ['penderie', 600, HANGING]]
}),
layout('classique-3', {
name: 'Classique 3 colonnes',
description: 'Penderie, rayonnage et tiroirs',
widthMm: 1800,
columns: [['penderie', 600, HANGING], ['rayonnage', 600], ['tiroirs', 600]]
}),
layout('dressing-4', {
name: 'Dressing 4 colonnes',
description: 'Deux penderies encadrant tiroirs et rayonnage',
widthMm: 2400,
columns: [['penderie', 600, HANGING], ['tiroirs', 600], ['rayonnage', 600], ['penderie', 600, HANGING]]
}),
layout('grande-penderie', {
name: 'Grande penderie',
description: 'Penderie large, rayonnage et penderie avec tiroirs',
widthMm: 2000,
columns: [['penderie', 1000, HANGING], ['rayonnage', 500], ['mixte', 500, HANGING]]
}),
layout('surmeubles', {
name: 'Avec surmeubles',
description: 'Dressing 4 colonnes surmonté de rangements hauts',
widthMm: 2400,
heightMm: 2600,
columns: [
['penderie', 600, { ...SURMEUBLE, shelves: 0 }],
['rayonnage', 600, SURMEUBLE],
['tiroirs', 600, SURMEUBLE],
['penderie', 600, { ...SURMEUBLE, shelves: 0 }]
]
}),
layout('bibliotheque', {
name: 'Bibliothèque ouverte',
description: 'Trois colonnes de rayonnage sans portes, avec dos',
widthMm: 1500,
heightMm: 2200,
depthMm: 350,
hasBack: true,
columns: [
['rayonnage', 500, { ...OPEN, shelves: 5 }],
['rayonnage', 500, { ...OPEN, shelves: 5 }],
['rayonnage', 500, { ...OPEN, shelves: 5 }]
]
})
];
+138 -8
View File
@@ -10,6 +10,7 @@ const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db
const schemaPath = path.join(__dirname, 'schema.sql');
let db = null;
let sqlJs = null;
/** Création idempotente — bases anciennes sans ces tables (avant migration 014). */
const HARDWARE_CATALOG_DDL = `
@@ -52,23 +53,144 @@ function toSqlitePlaceholders(sql) {
return sql.replace(/\$(\d+)/g, '?');
}
function save() {
if (db && dbPath) {
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
const BACKUP_DIR = path.join(dataDir, 'backups');
const MAX_BACKUP_DAYS = 14;
/**
* Count formex_materials in an open sql.js Database instance.
*/
function countMaterialsIn(database) {
if (!database) return 0;
try {
const result = database.exec('SELECT COUNT(*) AS c FROM formex_materials');
return Number(result[0]?.values?.[0]?.[0] ?? 0);
} catch {
return 0;
}
}
/**
* Peek material count on a DB file without touching the live in-memory DB.
*/
function countMaterialsOnDisk(filePath = dbPath) {
if (!filePath || !fs.existsSync(filePath) || !sqlJs) return 0;
let probe = null;
try {
const buffer = fs.readFileSync(filePath);
if (!buffer.length) return 0;
probe = new sqlJs.Database(buffer);
return countMaterialsIn(probe);
} catch {
return 0;
} finally {
try { probe?.close(); } catch { /* ignore */ }
}
}
/**
* Snapshot the on-disk DB before this process touches it, so scraped data
* (Häfele, Leuba Hiag, Formex) survives accidental wipes. One backup per
* day (first start of the day), kept for MAX_BACKUP_DAYS days.
* Never replaces a richer day's backup with an emptier DB.
*/
function backupExistingDb() {
try {
if (!fs.existsSync(dbPath)) return;
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
const diskCount = countMaterialsOnDisk(dbPath);
const today = new Date().toISOString().slice(0, 10);
const backupPath = path.join(BACKUP_DIR, `configurateur-${today}.db`);
if (!fs.existsSync(backupPath)) {
if (diskCount === 0) {
console.warn('[db] skip daily backup — on-disk DB has 0 materials (protects older non-empty backups)');
} else {
fs.copyFileSync(dbPath, backupPath);
console.log(`[db] daily backup created: ${backupPath} (${diskCount} materials)`);
}
} else {
const backupCount = countMaterialsOnDisk(backupPath);
if (diskCount > backupCount) {
fs.copyFileSync(dbPath, backupPath);
console.log(`[db] daily backup upgraded: ${backupPath} (${backupCount} → ${diskCount} materials)`);
}
}
const backups = fs.readdirSync(BACKUP_DIR)
.filter((f) => /^configurateur-\d{4}-\d{2}-\d{2}\.db$/.test(f))
.sort();
while (backups.length > MAX_BACKUP_DAYS) {
// Prefer deleting empty backups so a sole non-empty snapshot is kept.
let removeIdx = backups.findIndex((f) => countMaterialsOnDisk(path.join(BACKUP_DIR, f)) === 0);
if (removeIdx < 0) removeIdx = 0;
const removed = backups.splice(removeIdx, 1)[0];
fs.unlinkSync(path.join(BACKUP_DIR, removed));
console.warn(`[db] rotated backup removed: ${removed}`);
}
} catch (e) {
console.error('[db] backup failed:', e.message);
}
}
/**
* Atomically persist the in-memory DB.
*
* Blocks only *catastrophic* overwrites — the failure mode that kept wiping
* scrapes: a stale/empty in-memory process saving over a full on-disk catalog.
* Normal scrapes (count grows) and single-row admin deletes still save.
*/
function save({ allowShrink = false } = {}) {
if (!db || !dbPath) return false;
const memCount = countMaterialsIn(db);
if (!allowShrink && fs.existsSync(dbPath)) {
const diskCount = countMaterialsOnDisk(dbPath);
const emptyClobber = memCount === 0 && diskCount > 0;
const massShrink = diskCount >= 20 && memCount < Math.floor(diskCount * 0.1);
if (emptyClobber || massShrink) {
console.error(
`[db] REFUSING save: in-memory has ${memCount} materials but disk has ${diskCount}. ` +
'Scraped data protected. Restart the server to reload from disk.'
);
try {
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
fs.writeFileSync(
path.join(BACKUP_DIR, `configurateur-refused-${stamp}.db`),
Buffer.from(db.export())
);
} catch { /* best-effort forensic copy */ }
return false;
}
}
const tmpPath = `${dbPath}.tmp`;
const data = Buffer.from(db.export());
fs.writeFileSync(tmpPath, data);
try {
fs.renameSync(tmpPath, dbPath);
} catch {
// Windows may not allow rename over an existing file while another handle is open.
fs.copyFileSync(tmpPath, dbPath);
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
return true;
}
async function getDb() {
if (db) return db;
const SQL = await initSqlJs({
sqlJs = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
});
let buffer;
if (fs.existsSync(dbPath)) {
// sqlJs must be ready before peek-based backup logic.
backupExistingDb();
buffer = fs.readFileSync(dbPath);
}
db = new SQL.Database(buffer || null);
db = new sqlJs.Database(buffer || null);
try {
// Ensure full schema exists for legacy/partial DB files.
if (fs.existsSync(schemaPath)) {
@@ -76,11 +198,13 @@ async function getDb() {
db.exec(schemaSql);
}
db.exec(HARDWARE_CATALOG_DDL);
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
// Persist schema ensures through the guarded saver (never clobber richer disk).
save();
} catch (e) {
console.error('[db] ensureHardwareCatalogTables:', e.message);
}
const loaded = countMaterialsIn(db);
console.log(`[db] loaded ${loaded} materials from ${dbPath}`);
return db;
}
@@ -105,6 +229,12 @@ export const run = async (sql, params = []) => {
return {};
};
/** Force-persist even if material count shrinks (admin bulk delete, etc.). */
export const saveAllowShrink = async () => {
await getDb();
return save({ allowShrink: true });
};
const pool = {
query: (sql, params) => query(sql, params)
};
+72 -3
View File
@@ -8,12 +8,81 @@ const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
const force = process.argv.includes('--force');
const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
});
const db = new SQL.Database();
function countMaterials(database) {
try {
const result = database.exec('SELECT COUNT(*) AS c FROM formex_materials');
return Number(result[0]?.values?.[0]?.[0] ?? 0);
} catch {
return 0;
}
}
function atomicWrite(filePath, database) {
const tmpPath = `${filePath}.tmp`;
fs.writeFileSync(tmpPath, Buffer.from(database.export()));
try {
fs.renameSync(tmpPath, filePath);
} catch {
fs.copyFileSync(tmpPath, filePath);
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
}
const dbExists = fs.existsSync(dbPath);
if (dbExists && force) {
// Backup before destroying anything.
const backupDir = path.join(dataDir, 'backups');
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const backupPath = path.join(backupDir, `configurateur-pre-init-${stamp}.db`);
fs.copyFileSync(dbPath, backupPath);
console.log(`Existing database backed up to ${backupPath}`);
}
let db;
if (dbExists && !force) {
// Non-destructive: apply schema (CREATE TABLE IF NOT EXISTS) to the existing DB,
// preserving all data (scraped materials, orders, etc.).
db = new SQL.Database(fs.readFileSync(dbPath));
const before = countMaterials(db);
console.log(
`Existing database found (${before} materials) — applying schema without deleting data (use --force to recreate from scratch).`
);
} else {
db = new SQL.Database();
}
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
fs.writeFileSync(dbPath, Buffer.from(db.export()));
if (dbExists && !force) {
const after = countMaterials(db);
const diskBefore = (() => {
try {
const probe = new SQL.Database(fs.readFileSync(dbPath));
const n = countMaterials(probe);
probe.close();
return n;
} catch {
return 0;
}
})();
if (diskBefore > 0 && after < diskBefore) {
console.error(
`REFUSING to write: schema apply would shrink materials ${diskBefore} → ${after}. Aborting.`
);
db.close();
process.exit(1);
}
}
atomicWrite(dbPath, db);
console.log(`Schema applied. Materials on disk: ${countMaterials(db)}`);
db.close();
console.log('Schema applied.');
+22
View File
@@ -66,6 +66,28 @@ CREATE TABLE IF NOT EXISTS orders (
created_at TEXT DEFAULT (datetime('now'))
);
-- Configurations d'armoires sauvegardées pour reprise ultérieure
CREATE TABLE IF NOT EXISTS saved_designs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
config TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- Dispositions types proposées à l'étape Espace (exemples intégrés + dispositions enregistrées)
CREATE TABLE IF NOT EXISTS layout_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
seed_key TEXT UNIQUE,
name TEXT NOT NULL,
description TEXT,
config TEXT NOT NULL,
sort_order INTEGER DEFAULT 100,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- Matériaux Formex (données scrapées)
CREATE TABLE IF NOT EXISTS formex_materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+35 -2
View File
@@ -14,10 +14,43 @@ if (!fs.existsSync(dbPath)) {
const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
});
function countMaterials(database) {
try {
const result = database.exec('SELECT COUNT(*) AS c FROM formex_materials');
return Number(result[0]?.values?.[0]?.[0] ?? 0);
} catch {
return 0;
}
}
function atomicWrite(filePath, database) {
const tmpPath = `${filePath}.tmp`;
fs.writeFileSync(tmpPath, Buffer.from(database.export()));
try {
fs.renameSync(tmpPath, filePath);
} catch {
fs.copyFileSync(tmpPath, filePath);
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
}
const buffer = fs.readFileSync(dbPath);
const db = new SQL.Database(buffer);
const before = countMaterials(db);
const seedSql = fs.readFileSync(path.join(__dirname, 'seeds.sql'), 'utf8');
db.exec(seedSql);
fs.writeFileSync(dbPath, Buffer.from(db.export()));
const after = countMaterials(db);
if (before > 0 && after < before) {
console.error(
`REFUSING to write seeds: materials would shrink ${before} → ${after}. Aborting.`
);
db.close();
process.exit(1);
}
atomicWrite(dbPath, db);
db.close();
console.log('Seeds applied.');
console.log(`Seeds applied. Materials preserved: ${after} (was ${before}).`);
+1 -1
View File
@@ -4,7 +4,7 @@ INSERT OR REPLACE INTO settings (key, value) VALUES
('kit_preparation_fee_chf', '350'),
('panel_thickness_mm', '19'),
('plinth_height_min_mm', '60'),
('plinth_height_max_mm', '160'),
('plinth_height_max_mm', '200'),
('niche_clearance_multiplier', '2'),
('min_clearance_mm', '20'),
('sliding_doors_min_width_mm', '1200');
+52 -2
View File
@@ -11,6 +11,7 @@ dotenv.config({ path: path.join(__dirname, '.env') });
import { getProducts } from './routes/products.js';
import { getBlockTypes } from './routes/blockTypes.js';
import { getFinishes } from './routes/finishes.js';
import { getMaterialImage } from './routes/materialImage.js';
import { getServiceOptions } from './routes/serviceOptions.js';
import { getSettings, updateSettings } from './routes/settings.js';
import {
@@ -38,12 +39,17 @@ import {
streamFormexSync,
triggerHaefeleSync,
streamHaefeleSync,
triggerLeubaHiagSync,
streamLeubaHiagSync,
getLeubaHiagMaterials,
getLeubaHiagTargets,
getLogs,
getStatus,
getFormexMaterials,
getFormexMaterial,
createFormexMaterial,
updateFormexMaterial,
setFormexMaterialActive,
deleteFormexMaterial,
getFormexCollections,
getFormexCategories,
@@ -79,13 +85,26 @@ import {
exportCncRoute,
exportMprRoute,
exportMprPieceRoute,
generateHardwareListRoute
generateHardwareListRoute,
generateCuttingPlanRoute,
exportExcelRoute
} from './routes/plans.js';
import {
designTipsRoute,
recommendMaterialsRoute,
renderRoute
} from './routes/ai.js';
import {
getSavedDesigns,
createSavedDesign,
updateSavedDesign,
deleteSavedDesign
} from './routes/savedDesigns.js';
import {
getLayoutPresets,
createLayoutPreset,
deleteLayoutPreset
} from './routes/layoutPresets.js';
import { initScheduler } from './services/scheduler.js';
const clientDist = path.join(__dirname, '..', 'client', 'dist');
@@ -100,6 +119,7 @@ app.use(express.json());
app.get('/api/products', getProducts);
app.get('/api/block-types', getBlockTypes);
app.get('/api/finishes', getFinishes);
app.get('/api/material-image', getMaterialImage);
app.get('/api/service-options', getServiceOptions);
app.get('/api/settings', getSettings);
app.put('/api/settings', updateSettings);
@@ -108,6 +128,13 @@ app.post('/api/engineering-rules', createEngineeringRule);
app.put('/api/engineering-rules/:id', updateEngineeringRule);
app.delete('/api/engineering-rules/:id', deleteEngineeringRule);
app.post('/api/price', postPrice);
app.get('/api/saved-designs', getSavedDesigns);
app.post('/api/saved-designs', createSavedDesign);
app.put('/api/saved-designs/:id', updateSavedDesign);
app.delete('/api/saved-designs/:id', deleteSavedDesign);
app.get('/api/layout-presets', getLayoutPresets);
app.post('/api/layout-presets', createLayoutPreset);
app.delete('/api/layout-presets/:id', deleteLayoutPreset);
app.get('/api/orders', getOrders);
app.post('/api/orders', postOrder);
app.get('/api/orders/:id/supplier-vouchers', getOrderSupplierVouchers);
@@ -129,6 +156,9 @@ app.post('/api/sync/formex', triggerFormexSync);
app.get('/api/sync/formex/stream', streamFormexSync);
app.post('/api/sync/haefele', triggerHaefeleSync);
app.get('/api/sync/haefele/stream', streamHaefeleSync);
app.get('/api/sync/leubahiag/targets', getLeubaHiagTargets);
app.post('/api/sync/leubahiag', triggerLeubaHiagSync);
app.get('/api/sync/leubahiag/stream', streamLeubaHiagSync);
app.get('/api/sync/logs', getLogs);
app.get('/api/sync/status', getStatus);
@@ -137,11 +167,13 @@ app.get('/api/formex/materials', getFormexMaterials);
app.get('/api/formex/materials/:id', getFormexMaterial);
app.post('/api/formex/materials', createFormexMaterial);
app.put('/api/formex/materials/:id', updateFormexMaterial);
app.patch('/api/formex/materials/:id/active', setFormexMaterialActive);
app.delete('/api/formex/materials/:id', deleteFormexMaterial);
app.get('/api/formex/collections', getFormexCollections);
app.get('/api/formex/categories', getFormexCategories);
app.get('/api/formex/suppliers', getFormexSuppliers);
app.get('/api/haefele/materials', getHaefeleMaterials);
app.get('/api/leubahiag/materials', getLeubaHiagMaterials);
// Room analysis routes (AI-powered)
app.post('/api/room/analyze', uploadMiddleware, analyzeRoom);
@@ -173,9 +205,11 @@ app.post('/api/ai/render', renderRoute);
// Plans and cutting list routes
app.post('/api/plans/generate', generatePlansRoute);
app.post('/api/plans/cutting-list', generateCuttingListRoute);
app.post('/api/plans/cutting-plan', generateCuttingPlanRoute);
app.post('/api/plans/hardware-list', generateHardwareListRoute);
app.post('/api/plans/export-text', exportCuttingListText);
app.post('/api/plans/export-pdf', exportPDFHtml);
app.post('/api/plans/export-excel', exportExcelRoute);
app.post('/api/plans/export-cnc', exportCncRoute);
app.post('/api/plans/export-mpr', exportMprRoute);
app.post('/api/plans/export-mpr-piece', exportMprPieceRoute);
@@ -242,7 +276,8 @@ async function applyMigration007() {
await run(`INSERT OR IGNORE INTO suppliers (code, name, email, type) VALUES
('formex', 'Formex', '[email protected]', 'panels'),
('blum', 'Blum Switzerland', '[email protected]', 'hardware'),
('haefele', 'Hafele', '[email protected]', 'materials')`);
('haefele', 'Hafele', '[email protected]', 'materials'),
('leubahiag', 'Leuba Hiag', '[email protected]', 'materials')`);
// Assign existing rows to their suppliers
await run(`UPDATE formex_materials SET supplier_id = (SELECT id FROM suppliers WHERE code = 'formex') WHERE supplier_id IS NULL`);
@@ -378,6 +413,21 @@ async function applyMigration013() {
}
await applyMigration013();
// Apply migration 014: widen plinth max to 200 mm (remarque client) without clobbering custom values.
async function applyMigration014() {
try {
await run('INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2)', ['plinth_height_min_mm', '60']);
await run('INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2)', ['plinth_height_max_mm', '200']);
// Bump legacy default 160 -> 200; leave any other (customized) value untouched.
await run("UPDATE settings SET value = '200' WHERE key = 'plinth_height_max_mm' AND value = '160'");
console.log('[migration-014] Plinth max widened to 200 mm.');
} catch (err) {
console.error('[migration-014] Error:', err.message);
}
}
await applyMigration014();
await ensureEngineeringRulesSeed();
// Initialize the scheduler for automated sync
+2 -1
View File
@@ -9,7 +9,8 @@
"dev": "node --watch index.js",
"db:init": "node db/init.js",
"db:seed": "node db/seed.js",
"db:migrate": "node db/migrate.js"
"db:migrate": "node db/migrate.js",
"db:restore-materials": "node scripts/restore-materials-backup.mjs"
},
"dependencies": {
"cheerio": "^1.0.0",
+17 -3
View File
@@ -41,7 +41,9 @@ const DEFAULT_ENGINEERING_RULES = [
details:
"Recul face plinthe >= 50 mm par rapport à la façade.\n" +
'Calcul de référence: porte 19 + jeu 3 + retrait base 28 = 50 mm.\n' +
"Avec plinthe 19 mm: centre pied Axilo à 69 mm des bords avant/arrière."
'AXILO 78 Press-fit réf. 637.76.710: centre avant à 59 mm du chant avant (plinthe finie 19 + 40), ' +
'centre arrière à 50 mm du chant arrière; 4 perçages borgnes Ø10 × 12 mm par platine, entraxe 64 mm.\n' +
'Répartition: 4 pieds aux extrémités de l\'armoire + 2 pieds (avant/arrière) tous les 600 mm.'
},
{
sort_order: 60,
@@ -144,8 +146,20 @@ const DEFAULT_ENGINEERING_RULES = [
{
sort_order: 210,
chapter: '4.C',
title: 'Tiroirs intérieurs - hauteurs de façades',
details: 'Variantes autorisées: 60, 90, 128, 177, 240 mm.'
title: 'Tiroirs intérieurs - hauteurs de côté (Blum Legrabox)',
details:
'Hauteurs de côté autorisées: 66 (N), 90 (M), 128 (K), 177 (C), 240 (F) mm.\n' +
'Hauteurs de niche SMS correspondantes: 80, 106, 144, 193, 257 mm (à partir de 1/6, 1/6, 2/6, 2/6, 3/6 — 1/6 = 127 mm).'
},
{
sort_order: 215,
chapter: '4.C',
title: 'Tiroirs Legrabox - débit 16 mm et perçages CNC',
details:
'Fond 16 mm: L = NL − 10, l = X − 35, feuillure 38 × 8 mm G+D (X = largeur libre corps).\n' +
'Paroi arrière 16 mm: l = X − 38, h = 39/63/101/148/212 mm selon N/M/K/C/F.\n' +
'Coulisses (Système 32, depuis chant avant): 40 kg → 37, 69, 197, 229, 261, 293, 357 · 70 kg → 37, 69, 261, 293, 357.\n' +
'Façade INSERTA: cheville expansible Ø 10 mm, profondeur > 12 mm.'
},
{
sort_order: 220,
+14 -4
View File
@@ -1,15 +1,25 @@
import pool from '../db/index.js';
const CONFIGURATOR_MATERIAL_FILTER = `
active = 1
AND NOT (
supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele')
OR LOWER(COALESCE(collection, '')) = 'hafele'
OR formex_code LIKE 'HAF-%'
)
`;
/**
* GET /api/finishes
* Returns active Formex materials as finishes for the configurator
* Returns active panel materials as finishes for the configurator
* Falls back to legacy finishes if no Formex materials exist
*/
export async function getFinishes(req, res) {
try {
// Check if we have active Formex materials
// Hardware suppliers can share formex_materials for catalog synchronization,
// but their products must never be offered as panel finishes.
const formexResult = await pool.query(
`SELECT COUNT(*) as count FROM formex_materials WHERE active = 1`
`SELECT COUNT(*) as count FROM formex_materials WHERE ${CONFIGURATOR_MATERIAL_FILTER}`
);
const hasFormexMaterials = (formexResult.rows[0]?.count || 0) > 0;
@@ -32,7 +42,7 @@ export async function getFinishes(req, res) {
dominant_color as hexColor,
'formex' as source
FROM formex_materials
WHERE active = 1
WHERE ${CONFIGURATOR_MATERIAL_FILTER}
ORDER BY collection, name
`);
+125
View File
@@ -0,0 +1,125 @@
import pool, { run } from '../db/index.js';
import { DEFAULT_LAYOUT_PRESETS } from '../db/defaultLayouts.js';
const MAX_NAME_LENGTH = 100;
const PRESET_COLUMNS = 'id, seed_key, name, description, config, sort_order, created_at, updated_at';
let seeded = null;
/**
* Synchronise les dispositions intégrées avec leur définition en code (insertion ou mise à jour).
* Une disposition intégrée supprimée reste masquée (active = 0).
*/
function ensureDefaultLayouts() {
if (!seeded) {
seeded = (async () => {
const { rows } = await pool.query(
'SELECT seed_key, name, description, config, sort_order FROM layout_presets WHERE seed_key IS NOT NULL'
);
const existing = new Map(rows.map((r) => [r.seed_key, r]));
for (const [index, preset] of DEFAULT_LAYOUT_PRESETS.entries()) {
const config = JSON.stringify(preset.config);
const row = existing.get(preset.seedKey);
if (row) {
const unchanged = row.name === preset.name && row.description === preset.description
&& row.config === config && Number(row.sort_order) === index;
if (!unchanged) {
await run(
`UPDATE layout_presets
SET name = $1, description = $2, config = $3, sort_order = $4, updated_at = datetime('now')
WHERE seed_key = $5`,
[preset.name, preset.description, config, index, preset.seedKey]
);
}
continue;
}
await run(
`INSERT INTO layout_presets (seed_key, name, description, config, sort_order)
VALUES ($1, $2, $3, $4, $5)`,
[preset.seedKey, preset.name, preset.description, config, index]
);
}
})().catch((err) => {
seeded = null;
throw err;
});
}
return seeded;
}
function parsePreset(row) {
if (!row) return null;
let config = null;
try {
config = JSON.parse(row.config);
} catch { /* config illisible → null */ }
return { ...row, builtin: row.seed_key != null, config };
}
export async function getLayoutPresets(req, res) {
try {
await ensureDefaultLayouts();
const { rows } = await pool.query(
`SELECT ${PRESET_COLUMNS}
FROM layout_presets
WHERE active = 1
ORDER BY sort_order ASC, id ASC`
);
res.json(rows.map(parsePreset));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function createLayoutPreset(req, res) {
try {
const name = String(req.body?.name || '').trim();
const description = String(req.body?.description || '').trim() || null;
const config = req.body?.config;
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
if (name.length > MAX_NAME_LENGTH) {
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
}
if (!config || typeof config !== 'object' || Array.isArray(config) || !Array.isArray(config.modules) || config.modules.length === 0) {
return res.status(400).json({ error: 'Disposition invalide (au moins une colonne requise).' });
}
await ensureDefaultLayouts();
await run(
`INSERT INTO layout_presets (name, description, config, sort_order)
VALUES ($1, $2, $3, 1000)`,
[name, description, JSON.stringify(config)]
);
const { rows } = await pool.query(
`SELECT ${PRESET_COLUMNS}
FROM layout_presets
ORDER BY id DESC
LIMIT 1`
);
res.status(201).json(parsePreset(rows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function deleteLayoutPreset(req, res) {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'Identifiant invalide.' });
}
const { rows } = await pool.query('SELECT id, seed_key FROM layout_presets WHERE id = $1 LIMIT 1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Disposition introuvable.' });
if (rows[0].seed_key != null) {
await run(`UPDATE layout_presets SET active = 0, updated_at = datetime('now') WHERE id = $1`, [id]);
} else {
await run('DELETE FROM layout_presets WHERE id = $1', [id]);
}
res.status(204).end();
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* GET /api/material-image?url=<encoded absolute image url>
*
* Same-origin proxy for remote material swatch images.
*
* The 3D preview turns swatch images into WebGL textures via a <canvas>.
* Browsers taint a canvas (and refuse `getImageData` / texture upload) when the
* source image comes from another origin without CORS headers. Suppliers such
* as Leuba Hiag serve their swatches from their own domain with no
* `Access-Control-Allow-Origin`, so the textures silently fail and every
* element falls back to its default colour.
*
* By streaming the bytes back through our own origin the canvas stays clean and
* the material textures/colours apply correctly in 3D.
*/
/** Only proxy http(s) images from these hosts (defence against SSRF). */
const ALLOWED_HOST_SUFFIXES = [
'leubahiag.ch',
'formex.ch',
'haefele.ch',
'haefele.com',
'haefele.de',
];
/** Small in-memory cache so repeated texture loads don't refetch remotely. */
const cache = new Map(); // url -> { buffer, contentType, expires }
const MAX_CACHE_ENTRIES = 400;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
function isAllowedHost(hostname) {
const host = hostname.toLowerCase();
return ALLOWED_HOST_SUFFIXES.some(
(suffix) => host === suffix || host.endsWith(`.${suffix}`)
);
}
export async function getMaterialImage(req, res) {
const raw = req.query.url;
if (!raw || typeof raw !== 'string') {
return res.status(400).json({ error: 'Missing url parameter' });
}
let target;
try {
target = new URL(raw);
} catch {
return res.status(400).json({ error: 'Invalid url' });
}
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
return res.status(400).json({ error: 'Unsupported protocol' });
}
if (!isAllowedHost(target.hostname)) {
return res.status(403).json({ error: 'Host not allowed' });
}
const key = target.toString();
const now = Date.now();
const cached = cache.get(key);
if (cached && cached.expires > now) {
res.set('Content-Type', cached.contentType);
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=86400');
return res.send(cached.buffer);
}
try {
const response = await fetch(key, { headers: { Accept: 'image/*' } });
if (!response.ok) {
return res.status(502).json({ error: `Upstream ${response.status}` });
}
const contentType = response.headers.get('content-type') || 'image/jpeg';
if (!contentType.startsWith('image/')) {
return res.status(415).json({ error: 'Not an image' });
}
const buffer = Buffer.from(await response.arrayBuffer());
if (cache.size >= MAX_CACHE_ENTRIES) {
const oldestKey = cache.keys().next().value;
if (oldestKey !== undefined) cache.delete(oldestKey);
}
cache.set(key, { buffer, contentType, expires: now + CACHE_TTL_MS });
res.set('Content-Type', contentType);
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=86400');
return res.send(buffer);
} catch (err) {
return res.status(502).json({ error: err.message });
}
}
+64
View File
@@ -8,6 +8,8 @@ import { generateCncProgram } from '../services/cncGenerator.js';
import { validateConfig } from '../services/configValidation.js';
import { generateHardwareList } from '../services/hardwareListEngine.js';
import { generateMprZipBuffer, generateMprPieceDownload } from '../services/mprGenerator.js';
import { generateCuttingPlan } from '../services/cuttingPlanEngine.js';
import { buildCuttingListXlsx } from '../services/exportExcel.js';
/**
* POST /api/plans/generate
@@ -241,6 +243,68 @@ export async function exportMprPieceRoute(req, res) {
}
}
/**
* POST /api/plans/cutting-plan
* Génère le plan de coupe (calepinage) avec % de chute par panneau et global.
*/
export async function generateCuttingPlanRoute(req, res) {
try {
const { config, modules = [], colors = {}, stockBoard, kerfMm, trimMm, allowRotation } = req.body || {};
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const validation = validateConfig(config);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
const cuttingList = generateCuttingList(config, colors, modules);
const cuttingPlan = generateCuttingPlan(cuttingList, { stockBoard, kerfMm, trimMm, allowRotation });
res.json({ success: true, cuttingPlan, warnings: validation.warnings });
} catch (error) {
console.error('Cutting plan error:', error);
res.status(500).json({ error: error.message || 'Erreur de génération du plan de coupe' });
}
}
/**
* POST /api/plans/export-excel
* Export de la liste de débitage (+ quincaillerie + calepinage) au format Excel (.xlsx).
*/
export async function exportExcelRoute(req, res) {
try {
const { config, modules = [], colors = {} } = req.body || {};
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const validation = validateConfig(config);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
const cuttingList = generateCuttingList(config, colors, modules);
const cuttingPlan = generateCuttingPlan(cuttingList, {});
let hardware = null;
try {
hardware = await generateHardwareList({ config, modules, colors });
} catch (e) {
console.warn('[export-excel] hardware list unavailable:', e.message);
}
const buffer = await buildCuttingListXlsx({ cuttingList, hardware, cuttingPlan });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename="liste-debitage.xlsx"');
res.send(Buffer.from(buffer));
} catch (error) {
console.error('Export Excel error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'export Excel' });
}
}
/**
* POST /api/plans/hardware-list
* Generate bill of materials for hardware/quincaillerie with prices (best-effort).
+111
View File
@@ -0,0 +1,111 @@
import pool, { run } from '../db/index.js';
const MAX_NAME_LENGTH = 100;
function parseDesign(row) {
if (!row) return null;
try {
return { ...row, config: JSON.parse(row.config) };
} catch {
return { ...row, config: null };
}
}
export async function getSavedDesigns(req, res) {
try {
const { rows } = await pool.query(
`SELECT id, name, config, created_at, updated_at
FROM saved_designs
ORDER BY updated_at DESC, id DESC`
);
res.json(rows.map(parseDesign));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function createSavedDesign(req, res) {
try {
const name = String(req.body?.name || '').trim();
const config = req.body?.config;
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
if (name.length > MAX_NAME_LENGTH) {
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return res.status(400).json({ error: 'Configuration invalide.' });
}
await run(
`INSERT INTO saved_designs (name, config)
VALUES ($1, $2)`,
[name, JSON.stringify(config)]
);
const { rows } = await pool.query(
`SELECT id, name, config, created_at, updated_at
FROM saved_designs
ORDER BY id DESC
LIMIT 1`
);
res.status(201).json(parseDesign(rows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function updateSavedDesign(req, res) {
try {
const id = Number(req.params.id);
const name = String(req.body?.name || '').trim();
const config = req.body?.config;
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'Identifiant invalide.' });
}
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
if (name.length > MAX_NAME_LENGTH) {
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return res.status(400).json({ error: 'Configuration invalide.' });
}
const { rows } = await pool.query('SELECT id FROM saved_designs WHERE id = $1 LIMIT 1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Armoire sauvegardée introuvable.' });
await run(
`UPDATE saved_designs
SET name = $1, config = $2, updated_at = datetime('now')
WHERE id = $3`,
[name, JSON.stringify(config), id]
);
const { rows: updatedRows } = await pool.query(
`SELECT id, name, config, created_at, updated_at
FROM saved_designs
WHERE id = $1
LIMIT 1`,
[id]
);
res.json(parseDesign(updatedRows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function deleteSavedDesign(req, res) {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'Identifiant invalide.' });
}
const { rows } = await pool.query('SELECT id FROM saved_designs WHERE id = $1 LIMIT 1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Armoire sauvegardée introuvable.' });
await run('DELETE FROM saved_designs WHERE id = $1', [id]);
res.status(204).end();
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+172
View File
@@ -1,5 +1,6 @@
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
import { syncHaefeleByUrls } from '../services/haefeleCrawler.js';
import { syncLeubaHiag, LEUBA_HIAG_TARGETS } from '../services/leubaHiagCrawler.js';
import { getSchedulerStatus } from '../services/scheduler.js';
import pool, { run } from '../db/index.js';
@@ -206,6 +207,150 @@ export async function getHaefeleMaterials(req, res) {
}
}
/**
* GET /api/sync/leubahiag/targets
* List the curated Leuba Hiag catalog lines available for import.
*/
export function getLeubaHiagTargets(req, res) {
res.json(LEUBA_HIAG_TARGETS.map(({ key, label, tier }) => ({ key, label, tier })));
}
function parseTargetsParam(value) {
if (Array.isArray(value)) return value.filter(Boolean);
if (typeof value === 'string' && value.trim()) {
return value.split(/[\n,;]+/g).map((v) => v.trim()).filter(Boolean);
}
return null;
}
/**
* POST /api/sync/leubahiag
* Trigger a Leuba Hiag curated catalog sync (non-streaming).
* Body: { targets?: string[], activate?: boolean, credentials?: {...} }
*/
export async function triggerLeubaHiagSync(req, res) {
try {
const targets = parseTargetsParam(req.body?.targets);
const activate = req.body?.activate === true || req.body?.activate === '1';
const fetchPrices = req.body?.fetchPrices !== false && req.body?.fetchPrices !== '0';
const credentials = {
username: req.body?.username || req.body?.email,
password: req.body?.password,
};
const result = await syncLeubaHiag({ targets, activate, fetchPrices, credentials }, () => {});
if (!result.success) {
return res.status(500).json({
success: false,
error: result.error || 'Sync Leuba Hiag échouée',
itemsCount: result.itemsCount || 0,
});
}
res.json({
success: true,
message: `Synchronisation Leuba Hiag terminée: ${result.itemsCount} produits`,
itemsCount: result.itemsCount,
createdCount: result.createdCount || 0,
updatedCount: result.updatedCount || 0,
pricedCount: result.pricedCount || 0,
duration: result.duration,
});
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* GET /api/sync/leubahiag/stream
* SSE endpoint for Leuba Hiag sync progress.
* Query: targets=key1,key2 (optional), activate=1 (optional)
*/
export async function streamLeubaHiagSync(req, res) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const emit = (data) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
req.on('close', () => {
// client disconnected
});
const targets = parseTargetsParam(req.query.targets);
const activate = req.query.activate === '1' || req.query.activate === 'true';
const fetchPrices = req.query.prices !== '0' && req.query.prices !== 'false';
try {
await syncLeubaHiag({ targets, activate, fetchPrices }, emit);
} catch (err) {
emit({ type: 'error', error: err.message });
}
res.end();
}
/**
* GET /api/leubahiag/materials
* List Leuba Hiag materials that were imported.
*/
export async function getLeubaHiagMaterials(req, res) {
try {
const { search = '', active, page = '1', limit = '25' } = req.query;
const pageNum = Math.max(1, parseInt(page, 10));
const limitNum = Math.min(200, Math.max(1, parseInt(limit, 10)));
const offset = (pageNum - 1) * limitNum;
const whereClauses = [
`(supplier_id = (SELECT id FROM suppliers WHERE code = 'leubahiag') OR formex_code LIKE 'LH-%')`
];
const params = [];
if (search) {
whereClauses.push('(name LIKE ? OR formex_code LIKE ? OR collection LIKE ?)');
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
if (active === '1' || active === 'true') {
whereClauses.push('active = 1');
} else if (active === '0' || active === 'false') {
whereClauses.push('active = 0');
}
const whereSQL = `WHERE ${whereClauses.join(' AND ')}`;
const countResult = await pool.query(
`SELECT COUNT(*) as total FROM formex_materials ${whereSQL}`,
params
);
const total = countResult.rows[0]?.total || 0;
const { rows } = await pool.query(
`SELECT id, formex_code, name, collection, category, thickness_mm, width_mm, height_mm,
price_chf, price_unit, image_url, formex_url, active, last_synced_at
FROM formex_materials
${whereSQL}
ORDER BY collection, name
LIMIT ? OFFSET ?`,
[...params, limitNum, offset]
);
res.json({
materials: rows,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum)
}
});
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/sync/logs
* Get sync history
@@ -439,6 +584,33 @@ export async function updateFormexMaterial(req, res) {
}
}
/**
* PATCH /api/formex/materials/:id/active
* Toggle only the `active` flag of a material (lossless: never touches other columns).
* Body: { active: boolean }
*/
export async function setFormexMaterialActive(req, res) {
try {
const { id } = req.params;
const active = req.body?.active === true || req.body?.active === 1 || req.body?.active === '1';
const existing = await pool.query('SELECT id FROM formex_materials WHERE id = ?', [id]);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Matériau non trouvé' });
}
await run('UPDATE formex_materials SET active = ? WHERE id = ?', [active ? 1 : 0, id]);
const { rows } = await pool.query(
'SELECT id, formex_code, name, active FROM formex_materials WHERE id = ?',
[id]
);
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* DELETE /api/formex/materials/:id
* Delete a material
@@ -0,0 +1,64 @@
/**
* Restore configurateur.db from the newest non-empty backup.
* Safe to run while the server is up: the guarded saver refuses empty overwrites,
* then restart the server (or let --watch reload) to pick up the restored file.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import initSqlJs from 'sql.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
const backupDir = path.join(dataDir, 'backups');
const SQL = await initSqlJs({
locateFile: (f) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', f)
});
function countMaterials(filePath) {
if (!fs.existsSync(filePath)) return 0;
let db = null;
try {
db = new SQL.Database(fs.readFileSync(filePath));
const r = db.exec('SELECT COUNT(*) FROM formex_materials');
return Number(r[0]?.values?.[0]?.[0] ?? 0);
} catch {
return 0;
} finally {
try { db?.close(); } catch { /* ignore */ }
}
}
const backups = fs.existsSync(backupDir)
? fs.readdirSync(backupDir)
.filter((f) => /^configurateur-.*\.db$/.test(f))
.map((f) => {
const full = path.join(backupDir, f);
return { file: f, full, count: countMaterials(full), mtime: fs.statSync(full).mtimeMs };
})
.filter((b) => b.count > 0)
.sort((a, b) => b.count - a.count || b.mtime - a.mtime)
: [];
if (backups.length === 0) {
console.error('No non-empty backup found under server/data/backups/');
process.exit(1);
}
const best = backups[0];
const currentCount = countMaterials(dbPath);
console.log(`Current DB: ${currentCount} materials`);
console.log(`Restoring from ${best.file} (${best.count} materials)`);
if (fs.existsSync(dbPath) && currentCount > 0) {
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const safety = path.join(backupDir, `configurateur-pre-restore-${stamp}.db`);
fs.copyFileSync(dbPath, safety);
console.log(`Safety copy of current DB → ${safety}`);
}
fs.copyFileSync(best.full, dbPath);
console.log(`Restored. Disk now has ${countMaterials(dbPath)} materials.`);
console.log('Restart the API server so it reloads this file into memory.');
+62
View File
@@ -0,0 +1,62 @@
/**
* Prove the anti-wipe guard: a stale empty in-memory DB must not overwrite
* a richer on-disk materials catalog.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import initSqlJs from 'sql.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dbPath = path.join(__dirname, '..', 'data', 'configurateur.db');
const SQL = await initSqlJs({
locateFile: (f) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', f)
});
function count(file) {
const db = new SQL.Database(fs.readFileSync(file));
const n = Number(db.exec('SELECT COUNT(*) FROM formex_materials')[0].values[0][0]);
db.close();
return n;
}
const before = count(dbPath);
console.log('disk before:', before);
if (before < 1) {
console.error('FAIL: expected materials on disk before guard test');
process.exit(1);
}
// Simulate a stale empty process writing the whole file (old wipe mode).
const empty = new SQL.Database();
empty.exec(`
CREATE TABLE formex_materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
formex_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
collection TEXT NOT NULL
);
`);
// Mimic the NEW guarded save from db/index.js
function guardedSave(database) {
const memCount = Number(database.exec('SELECT COUNT(*) FROM formex_materials')[0]?.values?.[0]?.[0] ?? 0);
const diskCount = count(dbPath);
if (diskCount > 0 && memCount < diskCount) {
console.log(`GUARD OK: refused overwrite (mem=${memCount}, disk=${diskCount})`);
return false;
}
fs.writeFileSync(dbPath, Buffer.from(database.export()));
return true;
}
const wrote = guardedSave(empty);
empty.close();
const after = count(dbPath);
console.log('disk after:', after);
if (wrote || after !== before) {
console.error('FAIL: guard did not protect materials');
process.exit(1);
}
console.log('PASS: scrapes on disk are protected from empty overwrites');
+73 -5
View File
@@ -1,4 +1,11 @@
import { DRAWERS, NICHE } from '../../shared/manufacturing.js';
import {
DRAWERS,
NICHE,
PLINTH,
MAX_PANEL_DIMENSION_MM,
MAX_PANEL_SHORT_SIDE_MM,
validateHorizontalSpans
} from '../../shared/manufacturing.js';
export function validateConfig(config = {}, settings = {}) {
const errors = [];
@@ -12,6 +19,53 @@ export function validateConfig(config = {}, settings = {}) {
const modules = Array.isArray(config.modules) ? config.modules : [];
const allowSliding = String(settings.allow_sliding_doors ?? '0') === '1';
const sanitaryGapMm = Number(settings.sanitary_gap_mm || NICHE.sanitaryGapAround);
const plinthHeightMm = Number(config.plinthHeightMm ?? 100);
const maxPanelLongMm = Number(
settings.panel_max_width_mm ??
settings.max_panel_width_mm ??
settings.max_panel_dimension_mm ??
MAX_PANEL_DIMENSION_MM
);
const maxPanelShortMm = Number(
settings.panel_max_short_side_mm ??
settings.max_panel_short_side_mm ??
MAX_PANEL_SHORT_SIDE_MM
);
if (widthMm > maxPanelLongMm) {
errors.push({
field: 'widthMm',
message: `Largeur maximale ${maxPanelLongMm} mm (capacité CNC).`
});
}
if (depthMm > maxPanelShortMm) {
errors.push({
field: 'depthMm',
message: `Profondeur maximale ${maxPanelShortMm} mm (capacité CNC).`
});
}
if (config.horizontalSpans != null && !Array.isArray(config.horizontalSpans)) {
errors.push({
field: 'horizontalSpans',
message: 'Les traverses horizontales doivent être fournies sous forme de tableau.'
});
} else {
validateHorizontalSpans(config, { maxPanelWidthMm: maxPanelLongMm }).forEach((issue) => {
errors.push({
field: issue.field,
code: issue.code,
message: issue.message
});
});
}
if (!PLINTH.targetHeightsMm.includes(plinthHeightMm)) {
errors.push({
field: 'plinthHeightMm',
message: `Hauteur AXILO 78 invalide. Valeurs admises : ${PLINTH.targetHeightsMm.join(', ')} mm.`
});
}
if (doorType === 'sliding' && !allowSliding) {
errors.push({ field: 'doorType', message: 'Portes coulissantes désactivées.' });
@@ -35,13 +89,12 @@ export function validateConfig(config = {}, settings = {}) {
const drawerDepthStep = Number(settings.drawer_depth_step_mm || DRAWERS.depthStep);
/** Même logique que `useWardrobeConfig` : pas de 50 mm sur la profondeur utile, puis réserve tiroir caché. */
const inferredEffectiveDrawerDepthMm = () => {
const inferredEffectiveDrawerDepthMm = (drawerType = config.hardware?.drawerType || 'visible') => {
const rawGap = depthMm - NICHE.drawerBackGap;
const clamped = Math.max(drawerDepthMin, Math.min(drawerDepthMax, rawGap));
const steppedMm = Math.round(clamped / drawerDepthStep) * drawerDepthStep;
const rawDt = config.hardware?.drawerType || 'visible';
const depthReductionMm =
rawDt === 'hidden' || rawDt === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0;
drawerType === 'hidden' || drawerType === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0;
return Math.max(350, steppedMm - depthReductionMm);
};
@@ -53,13 +106,27 @@ export function validateConfig(config = {}, settings = {}) {
if (m.hasRod && heightMm < 1200) {
errors.push({ field: `modules[${index}]`, message: 'Tringle autorisée à partir de 1200 mm de hauteur armoire.' });
}
if (isDrawerCol) {
const moduleDepth = inferredEffectiveDrawerDepthMm(m.drawerType ?? config.hardware?.drawerType);
if (moduleDepth < 350 || moduleDepth > drawerDepthMax) {
errors.push({
field: `modules[${index}].drawerType`,
message: `Profondeur tiroir utile hors bornes (350-${drawerDepthMax} mm) pour ce block.`
});
}
}
}
/**
* Profondeur utile tiroir — alignée sur useWardrobeConfig (pas de 50 sur prof. caisse puis −40 si caché).
* Ne pas exiger un multiple de 50 sur la valeur finale : ex. 550 − 40 = 510 mm est valide.
*/
const canonicalEffectiveDrawerDepthMm = inferredEffectiveDrawerDepthMm();
const drawerModules = modules.filter((m) => (m.drawers || 0) > 0 || m.type === 'tiroirs');
const canonicalDrawerDepths = drawerModules.map((m) =>
inferredEffectiveDrawerDepthMm(m.drawerType ?? config.hardware?.drawerType)
);
const canonicalEffectiveDrawerDepthMm =
canonicalDrawerDepths[0] ?? inferredEffectiveDrawerDepthMm();
const effectiveDrawerMinMm = 350;
if (
canonicalEffectiveDrawerDepthMm < effectiveDrawerMinMm ||
@@ -75,6 +142,7 @@ export function validateConfig(config = {}, settings = {}) {
if (
Number.isFinite(explicitEff) &&
explicitEff > 0 &&
canonicalDrawerDepths.every((depth) => depth === canonicalEffectiveDrawerDepthMm) &&
Math.abs(explicitEff - canonicalEffectiveDrawerDepthMm) > 2
) {
warnings.push({
File diff suppressed because it is too large Load Diff
+261
View File
@@ -0,0 +1,261 @@
/**
* Cutting Plan Engine (Calepinage / plan de coupe) — APEX
*
* Optimise le placement des pièces de la liste de débitage sur des panneaux de stock
* (par finition + épaisseur) à l'aide d'un algorithme guillotine par étagères
* (First-Fit Decreasing Height). Calcule le pourcentage de chute par panneau et global,
* et produit un SVG de calepinage par panneau pour le fournisseur.
*/
/** Format de panneau de stock par défaut (mm) — mélaminé standard. */
export const DEFAULT_STOCK_BOARD = { widthMm: 2800, heightMm: 2070 };
/** Trait de scie (kerf) entre pièces, mm. */
const DEFAULT_KERF_MM = 3.2;
/** Délignage de rive (trim) sur chaque bord du panneau, mm. */
const DEFAULT_TRIM_MM = 10;
function round(n, d = 3) {
const f = 10 ** d;
return Math.round((Number(n) || 0) * f) / f;
}
/**
* Place une liste de rectangles sur des panneaux successifs (guillotine FFDH).
* @param {Array<{w:number,h:number,label:string,pieceId:string}>} rects
* @param {object} board { usableW, usableH, kerf, trim, boardWidthMm, boardHeightMm }
* @param {boolean} allowRotation
* @returns {Array<object>} boards
*/
function packRectsIntoBoards(rects, board, allowRotation) {
const { usableW, usableH, kerf, trim, boardWidthMm, boardHeightMm } = board;
// Tri par hauteur décroissante (puis largeur) pour FFDH.
const sorted = [...rects].sort((a, b) => (b.h - a.h) || (b.w - a.w));
const boards = [];
function newBoard() {
return { shelves: [], usedY: 0, placements: [] };
}
function tryPlaceOnBoard(b, rect) {
// Orientations candidates.
const orientations = [{ w: rect.w, h: rect.h, rotated: false }];
if (allowRotation && rect.w !== rect.h) {
orientations.push({ w: rect.h, h: rect.w, rotated: true });
}
// 1) Essayer dans une étagère existante.
for (const shelf of b.shelves) {
for (const o of orientations) {
if (o.w <= usableW - shelf.usedX + 1e-6 && o.h <= shelf.height + 1e-6) {
b.placements.push({
xMm: round(trim + shelf.usedX, 1),
yMm: round(trim + shelf.y, 1),
wMm: round(o.w, 1),
hMm: round(o.h, 1),
rotated: o.rotated,
label: rect.label,
pieceId: rect.pieceId
});
shelf.usedX += o.w + kerf;
return true;
}
}
}
// 2) Ouvrir une nouvelle étagère.
for (const o of orientations) {
if (o.h <= usableH - b.usedY + 1e-6 && o.w <= usableW + 1e-6) {
const shelf = { y: b.usedY, height: o.h, usedX: 0 };
b.placements.push({
xMm: round(trim + 0, 1),
yMm: round(trim + shelf.y, 1),
wMm: round(o.w, 1),
hMm: round(o.h, 1),
rotated: o.rotated,
label: rect.label,
pieceId: rect.pieceId
});
shelf.usedX = o.w + kerf;
b.shelves.push(shelf);
b.usedY += o.h + kerf;
return true;
}
}
return false;
}
let current = newBoard();
for (const rect of sorted) {
// Pièce trop grande pour un panneau, même tournée : on la place seule (hors-format signalé).
const fits = rect.w <= usableW + 1e-6 && rect.h <= usableH + 1e-6;
const fitsRotated = allowRotation && rect.h <= usableW + 1e-6 && rect.w <= usableH + 1e-6;
if (!fits && !fitsRotated) {
boards.push(finalizeBoard(current, board));
current = newBoard();
current.placements.push({
xMm: round(trim, 1),
yMm: round(trim, 1),
wMm: round(rect.w, 1),
hMm: round(rect.h, 1),
rotated: false,
label: rect.label,
pieceId: rect.pieceId,
oversize: true
});
boards.push(finalizeBoard(current, board));
current = newBoard();
continue;
}
if (!tryPlaceOnBoard(current, rect)) {
boards.push(finalizeBoard(current, board));
current = newBoard();
tryPlaceOnBoard(current, rect);
}
}
if (current.placements.length) boards.push(finalizeBoard(current, board));
return boards;
function finalizeBoard(b, brd) {
const boardAreaMm2 = brd.boardWidthMm * brd.boardHeightMm;
const usedAreaMm2 = b.placements.reduce((s, p) => s + p.wMm * p.hMm, 0);
const wasteAreaMm2 = Math.max(0, boardAreaMm2 - usedAreaMm2);
return {
boardWidthMm: brd.boardWidthMm,
boardHeightMm: brd.boardHeightMm,
placements: b.placements,
usedAreaM2: round(usedAreaMm2 / 1e6),
boardAreaM2: round(boardAreaMm2 / 1e6),
wasteAreaM2: round(wasteAreaMm2 / 1e6),
wastePercent: round((wasteAreaMm2 / boardAreaMm2) * 100, 1)
};
}
}
/** SVG de calepinage pour un panneau. */
function boardSvg(boardEntry, index) {
const { boardWidthMm, boardHeightMm, placements } = boardEntry;
const pad = 24;
const maxW = 520;
const scale = maxW / boardWidthMm;
const w = boardWidthMm * scale;
const h = boardHeightMm * scale;
const palette = ['#dbeafe', '#fde68a', '#bbf7d0', '#fecaca', '#e9d5ff', '#c7d2fe', '#fed7aa'];
const rects = placements
.map((p, i) => {
const fill = p.oversize ? '#fca5a5' : palette[i % palette.length];
const rx = pad + p.xMm * scale;
const ry = pad + p.yMm * scale;
const rw = Math.max(1, p.wMm * scale);
const rh = Math.max(1, p.hMm * scale);
const label = `${p.label}${p.rotated ? ' ↻' : ''}`;
const fontSize = Math.max(7, Math.min(11, rh / 3, rw / 6));
const text =
rw > 36 && rh > 14
? `<text x="${rx + rw / 2}" y="${ry + rh / 2}" font-size="${fontSize}" text-anchor="middle" dominant-baseline="middle" fill="#1f2937">${escapeXml(label)}</text>`
: '';
return `<rect x="${rx.toFixed(1)}" y="${ry.toFixed(1)}" width="${rw.toFixed(1)}" height="${rh.toFixed(1)}" fill="${fill}" stroke="#374151" stroke-width="0.8"/>${text}`;
})
.join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w + pad * 2} ${h + pad * 2 + 24}" width="${w + pad * 2}" height="${h + pad * 2 + 24}">
<rect x="${pad}" y="${pad}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="#f9fafb" stroke="#111827" stroke-width="1.5"/>
${rects}
<text x="${pad}" y="${h + pad * 2 + 14}" font-size="12" fill="#111827">Panneau ${index + 1} — ${boardWidthMm}×${boardHeightMm} mm — chute ${boardEntry.wastePercent}%</text>
</svg>`;
}
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* Génère le plan de coupe (calepinage) pour toute l'armoire.
* @param {object} cuttingList résultat de generateCuttingList
* @param {object} [options]
* @param {{widthMm:number,heightMm:number}} [options.stockBoard]
* @param {number} [options.kerfMm]
* @param {number} [options.trimMm]
* @param {boolean} [options.allowRotation]
* @returns {object}
*/
export function generateCuttingPlan(cuttingList, options = {}) {
const stock = options.stockBoard || DEFAULT_STOCK_BOARD;
const kerf = Number.isFinite(options.kerfMm) ? options.kerfMm : DEFAULT_KERF_MM;
const trim = Number.isFinite(options.trimMm) ? options.trimMm : DEFAULT_TRIM_MM;
const allowRotation = options.allowRotation !== false;
const usableW = stock.widthMm - 2 * trim;
const usableH = stock.heightMm - 2 * trim;
// Regrouper les pièces par finition + épaisseur (matériaux non mélangeables).
const groupsMap = new Map();
for (const group of (cuttingList?.byFinish || [])) {
for (const p of (group.pieces || [])) {
const wMm = Number(p.rawCutWidthMm || p.widthMm) || 0;
const hMm = Number(p.rawCutHeightMm || p.heightMm) || 0;
const qty = Math.max(1, Math.round(Number(p.quantity) || 1));
if (!(wMm > 0) || !(hMm > 0)) continue;
const key = `${group.finishLabel || 'finition'}__${p.thicknessMm || 0}mm`;
if (!groupsMap.has(key)) {
groupsMap.set(key, {
key,
finishLabel: group.finishLabel || 'Finition',
thicknessMm: Number(p.thicknessMm) || 0,
rects: []
});
}
const bucket = groupsMap.get(key);
for (let i = 0; i < qty; i++) {
bucket.rects.push({ w: wMm, h: hMm, label: p.description || p.elementType || 'pièce', pieceId: p.id });
}
}
}
const allBoards = [];
const groups = [];
for (const bucket of groupsMap.values()) {
const boards = packRectsIntoBoards(bucket.rects, {
usableW, usableH, kerf, trim,
boardWidthMm: stock.widthMm, boardHeightMm: stock.heightMm
}, allowRotation);
const groupBoardAreaM2 = boards.reduce((s, b) => s + b.boardAreaM2, 0);
const groupUsedAreaM2 = boards.reduce((s, b) => s + b.usedAreaM2, 0);
const groupWasteAreaM2 = round(groupBoardAreaM2 - groupUsedAreaM2);
groups.push({
key: bucket.key,
finishLabel: bucket.finishLabel,
thicknessMm: bucket.thicknessMm,
boardCount: boards.length,
usedAreaM2: round(groupUsedAreaM2),
boardAreaM2: round(groupBoardAreaM2),
wasteAreaM2: groupWasteAreaM2,
wastePercent: groupBoardAreaM2 > 0 ? round((groupWasteAreaM2 / groupBoardAreaM2) * 100, 1) : 0
});
boards.forEach((b, i) => {
allBoards.push({
...b,
groupKey: bucket.key,
finishLabel: bucket.finishLabel,
thicknessMm: bucket.thicknessMm,
svg: boardSvg(b, allBoards.length + i)
});
});
}
const totalBoardAreaM2 = round(allBoards.reduce((s, b) => s + b.boardAreaM2, 0));
const totalUsedAreaM2 = round(allBoards.reduce((s, b) => s + b.usedAreaM2, 0));
const totalWasteAreaM2 = round(totalBoardAreaM2 - totalUsedAreaM2);
const totalPlacedPieces = allBoards.reduce((s, b) => s + b.placements.length, 0);
return {
stock: { widthMm: stock.widthMm, heightMm: stock.heightMm, kerfMm: kerf, trimMm: trim, allowRotation },
boards: allBoards,
groups,
totalBoards: allBoards.length,
totalPlacedPieces,
totalUsedAreaM2,
totalBoardAreaM2,
totalWasteAreaM2,
globalWastePercent: totalBoardAreaM2 > 0 ? round((totalWasteAreaM2 / totalBoardAreaM2) * 100, 1) : 0
};
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Export Excel (.xlsx) — APEX
* Génère un classeur OOXML minimal mais valide à partir de la liste de débitage,
* de la quincaillerie et du calepinage, sans dépendance externe (utilise jszip).
*
* Le format .xlsx est une archive ZIP de fichiers XML. On utilise des chaînes
* "inlineStr" pour éviter la table de chaînes partagées.
*/
import JSZip from 'jszip';
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** Convertit un index de colonne 0-based en lettre Excel (0 -> A, 26 -> AA). */
function colLetter(index) {
let n = index;
let s = '';
do {
s = String.fromCharCode(65 + (n % 26)) + s;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return s;
}
function cellXml(ref, value, { header = false } = {}) {
if (value === null || value === undefined || value === '') {
return `<c r="${ref}"${header ? ' s="1"' : ''}/>`;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return `<c r="${ref}"${header ? ' s="1"' : ''}><v>${value}</v></c>`;
}
const styleAttr = header ? ' s="1"' : '';
return `<c r="${ref}"${styleAttr} t="inlineStr"><is><t xml:space="preserve">${escapeXml(value)}</t></is></c>`;
}
/**
* @param {{ name:string, columns:string[], rows:Array<Array<string|number>> }} sheet
*/
function sheetXml(sheet) {
const rowsXml = [];
// Header row
const headerCells = sheet.columns
.map((c, i) => cellXml(`${colLetter(i)}1`, c, { header: true }))
.join('');
rowsXml.push(`<row r="1">${headerCells}</row>`);
// Data rows
sheet.rows.forEach((row, r) => {
const cells = row.map((val, i) => cellXml(`${colLetter(i)}${r + 2}`, val)).join('');
rowsXml.push(`<row r="${r + 2}">${cells}</row>`);
});
const colsCount = sheet.columns.length;
const colsXml = `<cols>${Array.from({ length: colsCount }, (_, i) => `<col min="${i + 1}" max="${i + 1}" width="22" customWidth="1"/>`).join('')}</cols>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
${colsXml}
<sheetData>
${rowsXml.join('\n')}
</sheetData>
</worksheet>`;
}
function contentTypesXml(sheetCount) {
const overrides = Array.from({ length: sheetCount }, (_, i) =>
`<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`
).join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
${overrides}
</Types>`;
}
function workbookXml(sheets) {
const sheetEls = sheets
.map((s, i) => `<sheet name="${escapeXml(s.name).slice(0, 31)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`)
.join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>${sheetEls}</sheets>
</workbook>`;
}
function workbookRelsXml(sheets) {
const rels = sheets
.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`)
.join('');
const stylesRid = sheets.length + 1;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
${rels}
<Relationship Id="rId${stylesRid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`;
}
const ROOT_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`;
const STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFE8EAED"/><bgColor indexed="64"/></patternFill></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/></cellXfs>
</styleSheet>`;
/**
* Construit un buffer .xlsx à partir d'une liste de feuilles.
* @param {Array<{ name:string, columns:string[], rows:Array<Array<string|number>> }>} sheets
* @returns {Promise<Buffer>}
*/
export async function buildXlsxBuffer(sheets) {
const zip = new JSZip();
zip.file('[Content_Types].xml', contentTypesXml(sheets.length));
zip.file('_rels/.rels', ROOT_RELS_XML);
zip.file('xl/workbook.xml', workbookXml(sheets));
zip.file('xl/_rels/workbook.xml.rels', workbookRelsXml(sheets));
zip.file('xl/styles.xml', STYLES_XML);
sheets.forEach((s, i) => {
zip.file(`xl/worksheets/sheet${i + 1}.xml`, sheetXml(s));
});
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
}
/**
* Construit le classeur Excel de la liste de débitage (+ quincaillerie + calepinage).
* @param {object} params
* @param {object} params.cuttingList
* @param {object} [params.hardware] résultat de generateHardwareList
* @param {object} [params.cuttingPlan] résultat de generateCuttingPlan
* @returns {Promise<Buffer>}
*/
export async function buildCuttingListXlsx({ cuttingList, hardware = null, cuttingPlan = null }) {
const sheets = [];
// Feuille 1 — Débitage
const debitColumns = [
'Finition', 'Pièce', 'L finie (mm)', 'H finie (mm)', 'Ép. (mm)', 'Qté',
'L brute (mm)', 'H brute (mm)', 'Surcote (mm)', 'Surface (m²)',
'Chants (côtés)', 'Sens de collage', 'Usinage'
];
const debitRows = [];
for (const group of (cuttingList?.byFinish || [])) {
for (const p of (group.pieces || [])) {
const banding = p.edgeBanding && p.edgeBanding.type !== 'none'
? `${p.edgeBanding.sides} côtés`
: '—';
const sens = p.edgeBanding?.direction?.label || '—';
const usinage = p.machining ? Object.keys(p.machining).join(', ') : '—';
debitRows.push([
group.finishLabel || '—',
p.description || '',
Number(p.widthMm) || 0,
Number(p.heightMm) || 0,
Number(p.thicknessMm) || 0,
Number(p.quantity) || 0,
Number(p.rawCutWidthMm) || 0,
Number(p.rawCutHeightMm) || 0,
Number(p.surcoteMm) || 0,
Number((p.surfaceM2 || 0).toFixed(3)),
banding,
sens,
usinage
]);
}
}
sheets.push({ name: 'Débitage', columns: debitColumns, rows: debitRows });
// Feuille 2 — Quincaillerie
if (hardware && Array.isArray(hardware.items) && hardware.items.length) {
const hwColumns = ['Désignation', 'Référence', 'Qté', 'Prix unitaire (CHF)', 'Total (CHF)'];
const hwRows = hardware.items.map((it) => [
it.label || it.name || '',
it.code || it.reference || '—',
Number(it.quantity) || 0,
it.unitPriceChf != null ? Number(it.unitPriceChf) : '—',
it.lineTotalChf != null ? Number(it.lineTotalChf) : (it.totalChf != null ? Number(it.totalChf) : '—')
]);
sheets.push({ name: 'Quincaillerie', columns: hwColumns, rows: hwRows });
}
// Feuille 3 — Calepinage / plan de coupe
if (cuttingPlan && Array.isArray(cuttingPlan.boards) && cuttingPlan.boards.length) {
const planColumns = [
'Panneau', 'Format (mm)', 'Pièces placées', 'Surface utilisée (m²)',
'Surface panneau (m²)', 'Chute (m²)', 'Chute (%)'
];
const planRows = cuttingPlan.boards.map((b, i) => [
`Panneau ${i + 1}`,
`${b.boardWidthMm} x ${b.boardHeightMm}`,
(b.placements || []).length,
Number((b.usedAreaM2 || 0).toFixed(3)),
Number((b.boardAreaM2 || 0).toFixed(3)),
Number((b.wasteAreaM2 || 0).toFixed(3)),
Number((b.wastePercent || 0).toFixed(1))
]);
// Ligne de synthèse globale
planRows.push([
'TOTAL',
'',
cuttingPlan.totalPlacedPieces ?? '',
Number((cuttingPlan.totalUsedAreaM2 || 0).toFixed(3)),
Number((cuttingPlan.totalBoardAreaM2 || 0).toFixed(3)),
Number((cuttingPlan.totalWasteAreaM2 || 0).toFixed(3)),
Number((cuttingPlan.globalWastePercent || 0).toFixed(1))
]);
sheets.push({ name: 'Calepinage', columns: planColumns, rows: planRows });
}
return buildXlsxBuffer(sheets);
}
+108 -26
View File
@@ -1,6 +1,7 @@
import pool from '../db/index.js';
import { generateCuttingList } from './cuttingEngine.js';
import { PLINTH, CARCASS_FASTENERS } from '../../shared/manufacturing.js';
import { LEGRABOX_BOM_RULES } from '../../shared/blumLegrabox.js';
const HAEFELE_PRODUCT_URLS = {
hinge_blum_clip_top_blumotion_110: 'https://www.haefele.ch/fr/produkt/charni-re-corps-blum-clip-top-blumotion-110-pose-en-applique/34242663/?MasterSKU=P-00898218',
@@ -61,6 +62,24 @@ async function priceById(table, id) {
};
}
/** Système de tiroirs Blum avec specs_json (tip_on, load_kg, …) pour les règles BOM Legrabox. */
async function drawerSystemById(id) {
if (!id) return null;
const row = await fetchOne(
'SELECT id, code, name, price_chf, specs_json FROM blum_hardware WHERE id = ? AND active = 1',
[id]
);
if (!row) return null;
return {
id: row.id,
code: row.code,
name: row.name,
unitPriceChf: toMoney(row.price_chf),
source: 'blum_hardware',
specsJson: row.specs_json || null,
};
}
async function findHaefeleMaterialForModel(model) {
const m = String(model || '').trim();
if (!m) return null;
@@ -142,7 +161,7 @@ async function findHaefeleMaterialByUrl(url) {
};
}
function computeCountsFromCuttingList(cuttingList) {
export function computeCountsFromCuttingList(cuttingList) {
const items = new Map();
const pieces = Array.isArray(cuttingList?.pieces) ? cuttingList.pieces : [];
@@ -256,10 +275,20 @@ function computeCountsFromCuttingList(cuttingList) {
// so we do not count it here to avoid misleading totals.
}
if (p?.elementType === 'tiroirs') {
if (p?.elementType === 'tiroirs' || p?.elementType === 'tiroirFacade') {
drawersCount += qty;
}
// Façades tiroir Legrabox — chevilles expansibles INSERTA (Ø10) selon perçages générés.
if (p?.elementType === 'tiroirFacade' && m.legraboxInserta?.holes?.length && qty > 0) {
addQty(items, 'legrabox_inserta_fixing', {
key: 'legrabox_inserta_fixing',
label: 'Fixation façade Legrabox INSERTA (cheville expansible Ø10)',
quantity: qty * m.legraboxInserta.holes.length,
unit: 'pc',
});
}
// Fixation "dos" (paroi arrière) — 4 côtés (gauche/droite + haut/bas).
// Règle atelier (pour une longueur E = L ou H):
// - par côté: 1 pièce à 5cm de chaque extrémité (=> 2)
@@ -280,6 +309,30 @@ function computeCountsFromCuttingList(cuttingList) {
meta: { model: 'Ixconnect RPC G 13/20' },
});
}
// AXILO 78 : une platine/pied par centre, et un support de plinthe par pied avant.
if (p?.elementType === 'fond' && m.axiloFootPlates?.centers?.length && qty > 0) {
const centers = m.axiloFootPlates.centers;
const frontCount =
Number(m.axiloFootPlates.frontFootCount) ||
centers.filter((center) => center?.row === 'front').length;
addQty(items, 'axilo_78_press_fit_foot', {
key: 'axilo_78_press_fit_foot',
label: `Pied Häfele AXILO 78 à planter — réf. ${PLINTH.mountingPlate.articleNumber}`,
code: PLINTH.mountingPlate.articleNumber,
quantity: qty * centers.length,
unit: 'pc',
meta: { model: PLINTH.mountingPlate.articleNumber }
});
addQty(items, 'axilo_plinth_support', {
key: 'axilo_plinth_support',
label: `Support de plinthe Häfele AXILO — réf. ${PLINTH.plinthSupport.articleNumber}`,
code: PLINTH.plinthSupport.articleNumber,
quantity: qty * frontCount * PLINTH.plinthSupport.quantityPerFrontFoot,
unit: 'pc',
meta: { model: PLINTH.plinthSupport.articleNumber }
});
}
}
return { items: Array.from(items.values()), doorCount, drawersCount };
@@ -289,20 +342,6 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
const cuttingList = generateCuttingList(config, colors, modules);
const { items, doorCount } = computeCountsFromCuttingList(cuttingList);
// Add plinth clips (simple deterministic rule)
const widthMm = Number(config?.widthMm) || 0;
if (widthMm > 0) {
const clips = Math.max(0, Math.ceil((widthMm / 1000) * (Number(PLINTH.clipsPerMeter) || 0)));
if (clips > 0) {
items.push({
key: 'plinth_clips',
label: `Clips plinthe (${PLINTH.model})`,
quantity: clips,
unit: 'pc',
});
}
}
// Selected variants pricing (if configured)
const handleVariantId = config?.hardware?.handleVariantId;
const hingeVariantId = config?.hardware?.hingeVariantId;
@@ -311,7 +350,7 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
const [handleVariant, hingeVariant, drawerSystem] = await Promise.all([
priceById('handle_variants', handleVariantId),
priceById('hinge_variants', hingeVariantId),
priceById('blum_hardware', drawerSystemId),
drawerSystemById(drawerSystemId),
]);
// Add handles as an item when selected
@@ -389,17 +428,60 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
}
// Drawer system: assume priced “per drawer”
if (drawerSystem) {
const totalDrawers = (Array.isArray(config?.modules) ? config.modules : []).reduce((s, m) => s + (Number(m?.drawers) || 0), 0);
if (totalDrawers > 0) {
const configModules = Array.isArray(config?.modules) ? config.modules : [];
const totalDrawers = configModules.reduce((s, m) => s + (Number(m?.drawers) || 0), 0);
if (drawerSystem && totalDrawers > 0) {
items.push({
key: `drawer_system:${drawerSystem.id}`,
label: `Système tiroirs — ${drawerSystem.name}`,
quantity: totalDrawers,
unit: 'pc',
unitPriceChf: drawerSystem.unitPriceChf,
source: drawerSystem.source,
code: drawerSystem.code,
});
}
// Règles automatiques Blum Legrabox (BOM)
if (totalDrawers > 0) {
// 1) Stabilisation de façade — obligatoire si largeur_corps_mm >= 600 (par tiroir concerné).
const wideDrawers = configModules.reduce((s, m) => {
const n = Number(m?.drawers) || 0;
const w = Number(m?.widthMm) || 0;
return s + (n > 0 && w >= LEGRABOX_BOM_RULES.frontStabiliserMinBodyWidthMm ? n : 0);
}, 0);
if (wideDrawers > 0) {
items.push({
key: `drawer_system:${drawerSystem.id}`,
label: `Système tiroirs — ${drawerSystem.name}`,
quantity: totalDrawers,
key: 'legrabox_front_stabiliser',
label: 'Stabilisation de façade Legrabox (largeur corps ≥ 600 mm)',
quantity: wideDrawers,
unit: 'pc',
unitPriceChf: drawerSystem.unitPriceChf,
source: drawerSystem.source,
code: drawerSystem.code,
meta: { model: 'ZST.550BA' },
});
}
// 2) Unité de synchronisation — obligatoire avec la variante Tip-On Blumotion.
const openingVariant = String(config?.hardware?.drawerOpeningVariant || '').toLowerCase();
const specsTipOn = (() => {
try {
const specs = drawerSystem?.specsJson ? JSON.parse(drawerSystem.specsJson) : null;
return Boolean(specs?.tip_on);
} catch {
return false;
}
})();
const isTipOnBlumotion =
openingVariant === LEGRABOX_BOM_RULES.syncUnitVariant ||
openingVariant === 'tip-on blumotion' ||
specsTipOn;
if (isTipOnBlumotion) {
const drawerColumns = configModules.filter((m) => (Number(m?.drawers) || 0) > 0).length;
items.push({
key: 'legrabox_tipon_sync_unit',
label: 'Unité de synchronisation Tip-On Blumotion (set)',
quantity: Math.max(1, drawerColumns),
unit: 'set',
meta: { model: 'T60.000D' },
});
}
}
+22 -29
View File
@@ -10,7 +10,11 @@ import {
buildFinishHabillageHoleSpecsMm,
buildDoorHingeCupHoleSpecsMm,
buildDrawerReinforcementHoleSpecsMm,
buildDrawerRenfortPanelIxconnectSpecsMm
buildDrawerRenfortPanelIxconnectSpecsMm,
buildLegraboxRailHoleSpecsMm,
buildLegraboxFacadeInsertaSpecsMm,
buildAxiloFootHoleSpecsMm,
buildSystem32HoleSpecsMm
} from '../../shared/cncPanelHoles.js';
export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0) {
@@ -25,33 +29,8 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
if (m.system32?.positions?.length) {
const frontX = clampX(37);
const backX = clampX(widthMm - 37);
const d = Number(m.system32.holeDiameter || 5);
const z = Number(m.system32.holeDepth || piece.thicknessMm || 12);
const throughHole = Boolean(m.system32.throughHole);
for (const pos of m.system32.positions) {
const y = clampY(pos);
pushHole({
xMm: frontX,
yMm: y,
diameterMm: d,
depthMm: z,
type: 'system32',
face: 'face',
toolMode: throughHole ? 'through' : 'blind'
});
pushHole({
xMm: backX,
yMm: y,
diameterMm: d,
depthMm: z,
type: 'system32',
face: 'face',
toolMode: throughHole ? 'through' : 'blind'
});
}
for (const spec of buildSystem32HoleSpecsMm(piece, widthMm, heightMm)) {
pushHole(spec);
}
if (['dessus', 'fond'].includes(piece.elementType || '')) {
@@ -60,6 +39,11 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
}
}
// AXILO 78 Press-fit — quatre trous borgnes par platine, exclusivement en sous-face du fond bas.
for (const spec of buildAxiloFootHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' });
}
if (['cotes', 'panneauxInterieurs'].includes(piece.elementType || '')) {
for (const spec of buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
@@ -67,9 +51,18 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
for (const spec of buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
for (const spec of buildLegraboxRailHoleSpecsMm(piece, widthMm, heightMm, partIndex)) {
pushHole({ ...spec, toolMode: 'blind' });
}
}
if (['cotes', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) {
if (piece.elementType === 'tiroirFacade') {
for (const spec of buildLegraboxFacadeInsertaSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' });
}
}
if (['cotes', 'dessus', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) {
for (const spec of buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' });
}
+533
View File
@@ -0,0 +1,533 @@
/**
* Leuba Hiag catalog crawler.
*
* The Leuba Hiag webshop (https://www.leubahiag.ch) is a Laravel/Bagisto shop whose
* product catalog is served by Algolia. The public catalog search page embeds a
* short-lived "secured" Algolia API key (guest scope) inside the page markup. We read
* that key live on every run (it expires), then page through the Algolia index and
* upsert each product into the shared `formex_materials` table under the `leubahiag`
* supplier.
*
* Notes:
* - Algolia exposes the product catalog but NOT prices. Customer-specific prices come
* from the authenticated `/fr/sales/prices-and-availability` endpoint: we log in with
* the account credentials (LEUBAHIAG_EMAIL/PASSWORD) and, after importing each line,
* fetch the per-SKU panel price and store it in price_chf.
* - Imported items are inactive by default so the catalog rows don't flood the
* configurator finish picker. Activate the wanted decors from the Matériaux admin page.
*/
import pool, { run } from '../db/index.js';
const BASE_URL = 'https://www.leubahiag.ch';
const SEARCH_URL = `${BASE_URL}/fr/catalog/search`;
const CRAWL_DELAY = parseInt(process.env.LEUBAHIAG_CRAWL_DELAY_MS || '300', 10);
const ALGOLIA_HITS_PER_PAGE = 1000;
/**
* Curated catalog lines to import (per the supplier's agreed assortment).
* Each target maps to an Algolia query and/or brand facet filter. The `label` is used
* as the material `collection` so the configurator groups items by recognisable line.
*/
export const LEUBA_HIAG_TARGETS = [
{ key: 'pfleiderer-xpsm', label: 'Pfleiderer XPSM (Antifingerprint)', tier: 'Entrée de gamme', query: 'XPSM', brands: ['Pfleiderer'] },
{ key: 'swiss-krono', label: 'Swiss Krono', tier: 'Entrée de gamme', query: '', brands: ['Swiss Krono'] },
{ key: 'unilin-master-oak', label: 'Unilin Master Oak', tier: 'Milieu de gamme', query: 'Master Oak', brands: ['UNILIN'] },
{ key: 'shinnoki', label: 'Shinnoki', tier: 'Milieu de gamme', query: 'Shinnoki', brands: [] },
{ key: 'cleaf', label: 'Cleaf', tier: 'Haut de gamme', query: '', brands: ['CLEAF'] },
{ key: 'valchromat', label: 'Valchromat', tier: 'Haut de gamme', query: '', brands: ['Valchromat'] },
];
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function browserHeaders(extra = {}) {
return {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'fr-CH,fr;q=0.9,en;q=0.5',
...extra,
};
}
function absoluteUrl(url) {
if (!url) return null;
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
}
/** Parse a "CHF 1'234.50" string into a number, or null. */
function parseCHF(text) {
if (typeof text !== 'string') return null;
const clean = text.replace(/CHF/gi, '').replace(/[\u00a0\u202f'’\s]/g, '').replace(',', '.');
const n = parseFloat(clean);
return Number.isFinite(n) ? n : null;
}
/**
* Authenticated Leuba Hiag (Laravel) session. Used to read customer-specific prices
* from the `prices-and-availability` endpoint (axios sends the XSRF-TOKEN cookie back
* as an X-XSRF-TOKEN header — we replicate that here).
*/
class LeubaHiagSession {
constructor() {
this.cookies = new Map();
this.loggedIn = false;
}
captureCookies(response) {
let raw = [];
if (typeof response.headers.getSetCookie === 'function') {
raw = response.headers.getSetCookie();
} else {
const header = response.headers.get('set-cookie');
if (header) raw = header.split(/,\s*(?=[A-Za-z0-9_\-]+=)/);
}
for (const entry of raw) {
const [pair] = entry.split(';');
const eq = pair.indexOf('=');
if (eq > 0) this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}
cookieHeader() {
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
xsrfToken() {
const raw = this.cookies.get('XSRF-TOKEN');
return raw ? decodeURIComponent(raw) : '';
}
async login(emit = () => {}, credentials = {}) {
const email = credentials.username || credentials.email
|| process.env.LEUBAHIAG_EMAIL || process.env.LEUBAHIAG_USER;
const password = credentials.password || process.env.LEUBAHIAG_PASSWORD;
if (!email || !password) {
emit({ type: 'log', message: 'Identifiants Leuba Hiag absents : import sans prix.' });
return false;
}
const loginUrl = `${BASE_URL}/fr/customer/auth/login`;
try {
const getRes = await fetch(loginUrl, { headers: browserHeaders(), redirect: 'follow' });
this.captureCookies(getRes);
const html = await getRes.text();
const token = (html.match(/name="_token"\s+value="([^"]+)"/) || [])[1];
if (!token) {
emit({ type: 'log', message: 'Jeton CSRF Leuba Hiag introuvable : import sans prix.' });
return false;
}
const body = new URLSearchParams();
body.set('_token', token);
body.set('email', email);
body.set('password', password);
// redirect:'manual' so we capture Set-Cookie from the post-login 302 (the
// LoggedIn/session cookies live on the redirect response, not the final page).
const postRes = await fetch(loginUrl, {
method: 'POST',
headers: browserHeaders({
'Content-Type': 'application/x-www-form-urlencoded',
Referer: loginUrl,
Origin: BASE_URL,
Cookie: this.cookieHeader(),
}),
body: body.toString(),
redirect: 'manual',
});
this.captureCookies(postRes);
// Confirm the session and refresh the XSRF-TOKEN from the authenticated catalog page.
const verifyRes = await fetch(`${BASE_URL}/fr/catalog/search`, {
headers: browserHeaders({ Cookie: this.cookieHeader() }),
redirect: 'follow',
});
this.captureCookies(verifyRes);
const verifyHtml = await verifyRes.text();
const isLoggedIn = /\\u0022isLoggedIn\\u0022:true/.test(verifyHtml)
|| this.cookies.get('LoggedIn') === '1';
this.loggedIn = isLoggedIn;
emit({
type: 'log',
message: this.loggedIn
? `Connecté à Leuba Hiag (${email}).`
: 'Connexion Leuba Hiag non confirmée : import sans prix.',
});
return this.loggedIn;
} catch (err) {
emit({ type: 'log', message: `Erreur de connexion Leuba Hiag : ${err.message}` });
return false;
}
}
/** POST {skus} to the prices endpoint; returns the per-sku price map (or {}). */
async fetchPrices(skus) {
if (!this.loggedIn || skus.length === 0) return {};
const res = await fetch(`${BASE_URL}/fr/sales/prices-and-availability`, {
method: 'POST',
headers: browserHeaders({
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': this.xsrfToken(),
Referer: `${BASE_URL}/fr/catalog/search`,
Origin: BASE_URL,
Cookie: this.cookieHeader(),
}),
body: JSON.stringify({ skus }),
});
this.captureCookies(res);
if (!res.ok) {
throw new Error(`prices-and-availability HTTP ${res.status}`);
}
return res.json();
}
}
/**
* Read the live Algolia connection config (appId, index, secured apiKey) from the
* catalog search page. The key is embedded as `\u0022apiKey\u0022:\u0022...\u0022`
* inside the `window.globalSearchConfigBase` JSON blob.
*/
async function fetchAlgoliaConfig(emit = () => {}) {
const res = await fetch(SEARCH_URL, { headers: browserHeaders(), redirect: 'follow' });
if (!res.ok) {
throw new Error(`Impossible de charger la page catalogue Leuba Hiag (HTTP ${res.status}).`);
}
const html = await res.text();
const appId = (html.match(/\\u0022appId\\u0022:\\u0022([A-Za-z0-9]+)\\u0022/) || [])[1]
|| process.env.LEUBAHIAG_ALGOLIA_APP_ID;
const indexName = (html.match(/\\u0022indexName\\u0022:\\u0022([A-Za-z0-9_]+)\\u0022/) || [])[1]
|| process.env.LEUBAHIAG_ALGOLIA_INDEX;
const apiKey = (html.match(/\\u0022apiKey\\u0022:\\u0022([A-Za-z0-9+/=]+)\\u0022/) || [])[1];
if (!appId || !indexName || !apiKey) {
throw new Error('Clé Algolia introuvable sur la page catalogue Leuba Hiag (structure du site modifiée ?).');
}
emit({ type: 'log', message: `Catalogue Algolia détecté: index ${indexName} (app ${appId}).` });
return { appId, indexName, apiKey };
}
/**
* Run one Algolia query page. `facetFilters` is an array of arrays per Algolia syntax.
*/
async function queryAlgolia({ appId, indexName, apiKey }, { query = '', page = 0, hitsPerPage = ALGOLIA_HITS_PER_PAGE, facetFilters = [] } = {}) {
const url = `https://${appId}-dsn.algolia.net/1/indexes/${indexName}/query`;
const params = new URLSearchParams();
params.set('query', query);
params.set('hitsPerPage', String(hitsPerPage));
params.set('page', String(page));
if (facetFilters.length > 0) {
params.set('facetFilters', JSON.stringify(facetFilters));
}
const res = await fetch(url, {
method: 'POST',
headers: {
'X-Algolia-Application-Id': appId,
'X-Algolia-API-Key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ params: params.toString() }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Requête Algolia échouée (HTTP ${res.status})${body ? `: ${body.slice(0, 160)}` : ''}`);
}
return res.json();
}
function firstString(value) {
if (Array.isArray(value)) return value.find((v) => typeof v === 'string') || null;
return typeof value === 'string' ? value : null;
}
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
/**
* Map an Algolia product record to a `formex_materials` row.
* Dimension fields from the index: thickness (mm), length (mm), width (mm).
* @param {string} [collectionOverride] Force the `collection` (curated line label).
*/
function mapHit(hit, collectionOverride) {
const sku = String(hit.sku || hit.objectID || '').trim();
if (!sku) return null;
const categoryPath = Array.isArray(hit.categories_with_path) ? hit.categories_with_path : [];
const category = categoryPath.length > 0
? categoryPath[categoryPath.length - 1]
: (firstString(hit.categories?.level0) || null);
const collection = collectionOverride
|| firstString(hit.brand)
|| firstString(hit.categories?.level0)
|| 'Leuba Hiag';
return {
sku,
materialCode: `LH-${sku}`,
name: hit.name || hit.group_name || sku,
collection,
category,
width_mm: toNumber(hit.width),
height_mm: toNumber(hit.length),
thickness_mm: toNumber(hit.thickness),
image_url: absoluteUrl(hit.image_url),
formex_url: absoluteUrl(hit.url),
price_chf: null,
price_unit: null,
};
}
async function ensureSupplier() {
const existing = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']);
if (existing.rows.length > 0) return existing.rows[0].id;
await run(
'INSERT INTO suppliers (code, name, email, type, active) VALUES (?, ?, ?, ?, ?)',
['leubahiag', 'Leuba Hiag', '[email protected]', 'materials', 1]
);
const created = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']);
return created.rows[0]?.id || null;
}
/**
* Upsert a material. Existing rows keep their `active` flag (so admin activations are
* preserved across re-syncs); new rows are inserted with the provided `active` value.
*/
async function upsertMaterial(material, supplierId, activeForNew) {
const existing = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[material.materialCode]
);
if (existing.rows.length > 0) {
await run(
`UPDATE formex_materials
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
image_url = ?, formex_url = ?,
supplier_id = COALESCE(?, supplier_id), last_synced_at = datetime('now')
WHERE formex_code = ?`,
[
material.name,
material.collection,
material.category,
material.width_mm,
material.height_mm,
material.thickness_mm,
material.image_url,
material.formex_url,
supplierId,
material.materialCode,
]
);
return { id: existing.rows[0].id, created: false };
}
await run(
`INSERT INTO formex_materials
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
image_url, formex_url, price_chf, price_unit, supplier_id, active, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
[
material.materialCode,
material.name,
material.collection,
material.category,
material.width_mm,
material.height_mm,
material.thickness_mm,
material.image_url,
material.formex_url,
material.price_chf ?? null,
material.price_unit ?? null,
supplierId,
activeForNew ? 1 : 0,
]
);
const created = await pool.query('SELECT id FROM formex_materials WHERE formex_code = ?', [material.materialCode]);
return { id: created.rows[0]?.id || null, created: true };
}
/** Update the price of an already-imported material by its SKU. */
async function updateMaterialPrice(sku, priceChf, priceUnit) {
await run(
`UPDATE formex_materials
SET price_chf = ?, price_unit = COALESCE(?, price_unit), last_synced_at = datetime('now')
WHERE formex_code = ?`,
[priceChf, priceUnit, `LH-${sku}`]
);
}
async function logSync(status, itemsCount, error = null) {
await run(
'INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)',
['leubahiag', status, itemsCount, error]
);
}
/**
* Import all products matching one curated target (a single catalog line).
*/
async function importTarget(config, supplierId, target, activate, emit) {
const facetFilters = Array.isArray(target.brands) && target.brands.length > 0
? [target.brands.map((b) => `brand:${b}`)]
: [];
let synced = 0;
let created = 0;
let page = 0;
let nbPages = 1;
let nbHits = 0;
const skus = [];
do {
if (page > 0) await sleep(CRAWL_DELAY);
const data = await queryAlgolia(config, { query: target.query, page, facetFilters });
nbPages = data.nbPages || 1;
nbHits = data.nbHits || 0;
if (page === 0) {
emit({ type: 'log', message: `→ ${target.label} (${target.tier}) : ${nbHits} produit(s).` });
}
const hits = Array.isArray(data.hits) ? data.hits : [];
for (const hit of hits) {
const material = mapHit(hit, target.label);
if (!material) continue;
try {
const result = await upsertMaterial(material, supplierId, activate);
synced++;
if (result.created) created++;
skus.push(material.sku);
} catch (err) {
emit({ type: 'log', message: ` ✗ ${material.materialCode}: ${err.message}` });
}
}
page++;
} while (page < nbPages);
emit({ type: 'log', message: ` ✓ ${target.label} : ${synced} importé(s) (${created} nouveau(x)).` });
return { synced, created, skus };
}
/**
* Fetch and store customer prices for a list of SKUs (in batches).
* Returns the number of items priced.
*/
async function importPrices(session, skus, emit) {
const BATCH = 50;
let priced = 0;
for (let i = 0; i < skus.length; i += BATCH) {
const batch = skus.slice(i, i + BATCH);
try {
const data = await session.fetchPrices(batch);
for (const sku of batch) {
const info = data?.[sku];
if (!info) continue;
const tier = Array.isArray(info.tierprices) ? info.tierprices[0] : null;
const priceChf = parseCHF(tier?.price);
if (priceChf == null) continue;
await updateMaterialPrice(sku, priceChf, 'pièce');
priced++;
}
} catch (err) {
emit({ type: 'log', message: ` ✗ Prix lot ${i / BATCH + 1}: ${err.message}` });
}
emit({ type: 'log', message: ` Prix: ${priced}/${skus.length} récupérés…` });
if (i + BATCH < skus.length) await sleep(CRAWL_DELAY);
}
return priced;
}
/**
* Synchronise the curated Leuba Hiag catalog lines via Algolia.
*
* @param {object} options
* @param {string[]} [options.targets] Subset of target keys to import (default: all).
* @param {boolean} [options.activate] Mark newly imported items active (default false).
* @param {boolean} [options.fetchPrices] Log in and import customer prices (default true).
* @param {object} [options.credentials] { username/email, password } (else env vars).
* @param {function} emit SSE-style event emitter.
*/
export async function syncLeubaHiag(options = {}, emit = () => {}) {
const { targets = null, activate = false, fetchPrices = true, credentials = {} } = options;
const selected = Array.isArray(targets) && targets.length > 0
? LEUBA_HIAG_TARGETS.filter((t) => targets.includes(t.key))
: LEUBA_HIAG_TARGETS;
const start = Date.now();
let synced = 0;
let created = 0;
let priced = 0;
try {
if (selected.length === 0) {
throw new Error('Aucune gamme Leuba Hiag sélectionnée.');
}
const config = await fetchAlgoliaConfig(emit);
const supplierId = await ensureSupplier();
// Log in up-front so prices can be fetched after each line is imported.
const session = new LeubaHiagSession();
if (fetchPrices) {
await session.login(emit, credentials);
}
emit({ type: 'log', message: `${selected.length} gamme(s) à importer.` });
emit({ type: 'progress', current: 0, total: selected.length, collection: 'Leuba Hiag' });
const allSkus = [];
for (let i = 0; i < selected.length; i++) {
const target = selected[i];
emit({ type: 'progress', current: i, total: selected.length, collection: target.label });
try {
const res = await importTarget(config, supplierId, target, activate, emit);
synced += res.synced;
created += res.created;
allSkus.push(...res.skus);
} catch (err) {
emit({ type: 'log', message: ` ✗ ${target.label}: ${err.message}` });
}
if (i < selected.length - 1) await sleep(CRAWL_DELAY);
}
if (session.loggedIn && allSkus.length > 0) {
emit({ type: 'log', message: `Récupération des prix client pour ${allSkus.length} article(s)…` });
priced = await importPrices(session, allSkus, emit);
emit({ type: 'log', message: `✓ ${priced} prix client enregistrés.` });
}
const duration = ((Date.now() - start) / 1000).toFixed(1);
await logSync('success', synced, null);
const result = {
success: true,
itemsCount: synced,
createdCount: created,
updatedCount: synced - created,
pricedCount: priced,
duration,
error: null,
};
emit({ type: 'progress', current: selected.length, total: selected.length, collection: 'Leuba Hiag' });
emit({ type: 'complete', result });
return result;
} catch (err) {
await logSync('error', synced, err.message);
emit({ type: 'error', error: err.message });
return { success: false, itemsCount: synced, error: err.message };
}
}
+345 -58
View File
@@ -11,12 +11,16 @@ import { GROOVE, getBackGroovePlanOrientation, BACK_GROOVE_PLAN_VERTICAL_TYPES }
const MPR_ARTICLE_CODES = Object.freeze({
plintheBas: 'PL_BAS',
plintheGauche: 'PL_G',
plintheDroite: 'PL_D',
bandeauHaut: 'BH',
dessus: 'BO_U',
fond: 'BO_L',
cotes: 'SE',
cotesDoublage: 'CD',
fileur: 'FI',
fileurCotes: 'FI_C',
fileurHaut: 'FI_H',
habillageFinitionGauche: 'FR_L_G',
habillageFinitionDroite: 'FR_L_D',
habillageFinitionDessus: 'FR_H',
@@ -26,7 +30,10 @@ const MPR_ARTICLE_CODES = Object.freeze({
panneauxInterieurs: 'ZA_O',
rayonFixe: 'RF_X',
etageres: 'EB_M',
renfortsTiroir: 'LABO'
renfortsTiroir: 'LABO',
tiroirFond: 'TI_BO',
tiroirDos: 'TI_RW',
tiroirFacade: 'TI_FR'
});
function fmtMm(n) {
@@ -45,6 +52,13 @@ function escQuoted(s) {
return String(s ?? '').replace(/"/g, "'");
}
/** Désignation « type » pour le bloc [001 (sans suffixe côté/index) — ex. SE_L → SE, ZA_O_2 → ZA. */
function baseDesignation(slug) {
const s = String(slug || '').trim();
const cut = s.indexOf('_');
return cut > 0 ? s.slice(0, cut) : s;
}
/** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */
export function flattenPiecesForMprExport(pieces = []) {
const out = [];
@@ -63,6 +77,14 @@ export function flattenPiecesForMprExport(pieces = []) {
}
let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE';
if (piece.horizontalSpanId != null) {
const spanId = String(piece.horizontalSpanId).replace(/[^\w-]+/g, '_');
const role = String(piece.horizontalSpanRole || 'panel').toUpperCase();
baseSlug = `HS_${spanId}_${role}_${baseSlug}`;
if (piece.horizontalSpanRole === 'door' && Number.isFinite(Number(piece.doorIndex))) {
baseSlug += `_${Number(piece.doorIndex) + 1}`;
}
}
for (let qi = 0; qi < qty; qi++) {
let slug = baseSlug;
@@ -80,11 +102,14 @@ export function flattenPiecesForMprExport(pieces = []) {
function mprAnglesForPiece(piece) {
const et = piece.elementType || '';
if (habillageEtPorte(et)) {
const widthMm = toSafe(piece.rawCutWidthMm, piece.widthMm);
const heightMm = toSafe(piece.rawCutHeightMm, piece.heightMm);
const vertical = heightMm > widthMm;
if (vertical) {
return {
DRWIN: '270.0',
beschGedreht: '0',
turnMsg: et === 'portes' ? 'DREHEN X-ACHSE' : 'DREHEN X-ACHSE'
turnMsg: 'DREHEN X-ACHSE'
};
}
if (['dessus', 'fond'].includes(et)) {
@@ -118,7 +143,7 @@ function kantenFlags(piece) {
return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' };
}
function grooveSection(piece, _Lmm, _Bmm) {
function grooveSection(piece, Lmm, Bmm) {
const m = piece?.machining || {};
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
if (!m.groove || !orient) return [];
@@ -127,26 +152,60 @@ function grooveSection(piece, _Lmm, _Bmm) {
const gd = fmtMm(Number(m.groove.depth ?? GROOVE.back.depth));
const abst = fmtMm(Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge));
const vertical = BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType);
const win = vertical ? '180.0' : '270.0';
const axis = Number(abst);
const start = vertical
? [Math.max(0, Lmm - axis), 0]
: [0, Math.max(0, Bmm - axis)];
const end = vertical
? [Math.max(0, Lmm - axis), Bmm]
: [Lmm, Math.max(0, Bmm - axis)];
return [
'<121 \\Block\\',
'MNM="Kantenbearbeitungen von oben"',
'DP="1"',
'KO="04"',
'<139 \\Komponente\\',
'IN="RSO/Makro/RSO_Nut_Flaeche.mpr"',
'MNM="Nut Flaeche rueckseite"',
'XA="L"',
'YA="B"',
'VA="LA L"',
`VA="BR ${gw}"`,
`VA="TI ${gd}"`,
`VA="ABST ${abst}"`,
`VA="WIN ${win}"`,
'VA="ANF 0.0"',
'VA="END 0.0"',
'VA="MAT MAT"',
'<109 \\Nuten\\',
'MNM="Rainure dos"',
`XA="${fmtMm(start[0])}"`,
`YA="${fmtMm(start[1])}"`,
`XE="${fmtMm(end[0])}"`,
`YE="${fmtMm(end[1])}"`,
`NB="${gw}"`,
'RK="NOWRK"',
'EM="MOD2"',
'TV="0"',
'MV="GL"',
`TI="${gd}"`,
'MN="GL"',
'KO="04"'
];
}
function doorHandleGrooveSection(piece, L, B) {
const groove = piece?.machining?.fullHeightHandleGroove;
if (piece?.elementType !== 'portes' || !groove) return [];
const widthMm = Math.max(1, Number(groove.grooveWidthMm) || 8);
const depthMm = Math.max(0.1, Number(groove.grooveDepthMm) || 3);
const offsetMm = Math.max(0, Number(groove.topBottomOffsetMm) || 0);
const hingeSide = piece?.doorPlanHints?.hingeSide === 'right' ? 'right' : 'left';
const xMm = hingeSide === 'right' ? widthMm / 2 : L - widthMm / 2;
const y1Mm = Math.min(B, offsetMm);
const y2Mm = Math.max(y1Mm, B - offsetMm);
return [
'<109 \\Nuten\\',
'MNM="Rainure poignée toute hauteur"',
`XA="${fmtMm(xMm)}"`,
`YA="${fmtMm(y1Mm)}"`,
`XE="${fmtMm(xMm)}"`,
`YE="${fmtMm(y2Mm)}"`,
`NB="${fmtMm(widthMm)}"`,
'RK="NOWRK"',
'EM="MOD2"',
'TV="0"',
'MV="GL"',
`TI="${fmtMm(depthMm)}"`,
'MN="GL"',
'KO="04"'
];
}
@@ -158,15 +217,17 @@ function bohrVertLines(spec, thicknessMm) {
const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end');
const throughHint = Boolean(spec.toolMode === 'through');
const looksThrough = depthRaw >= thicknessMm - 0.5;
const fromUnder = spec.face === 'back';
let bm = 'LS';
let ti;
if (throughHint || looksThrough) {
bm = 'LSL';
bm = fromUnder ? 'LSLU' : 'LSL';
ti = `${fmtMm(thicknessMm)} + 1`;
} else if (edgeHint && depthRaw >= 38) {
bm = 'LSL';
ti = fmtMm(depthRaw);
} else {
bm = fromUnder ? 'LSU' : 'LS';
ti = fmtMm(depthRaw);
}
@@ -192,6 +253,59 @@ function bohrVertLines(spec, thicknessMm) {
];
}
/**
* Un trou de chant → lignes <103 BohrHoriz (perçage horizontal dans un chant).
* `end` désigne le chant d'entrée dans le repère pièce (origine bas-gauche) :
* left → depuis chant gauche (BM=XP), right → chant droit (BM=XM),
* bottom → chant avant Y=0 (BM=YP), top → chant arrière Y=B (BM=YM).
* ZA = position dans l'épaisseur (axe du trou) : `zMm` si fourni, sinon milieu (D/2).
*/
function bohrHorizLines(spec, L, B, D) {
const du = fmtMm(Math.round((Number(spec.diameterMm) || 8) * 10) / 10);
const ti = fmtMm(Number(spec.depthMm) || 25);
const za = fmtMm(Number.isFinite(Number(spec.zMm)) ? Number(spec.zMm) : D / 2);
let bm;
let xa;
let ya;
switch (spec.end) {
case 'right':
bm = 'XM';
xa = L;
ya = spec.yMm;
break;
case 'bottom':
bm = 'YP';
xa = spec.xMm;
ya = 0;
break;
case 'top':
bm = 'YM';
xa = spec.xMm;
ya = B;
break;
case 'left':
default:
bm = 'XP';
xa = 0;
ya = spec.yMm;
break;
}
return [
'<103 \\BohrHoriz\\',
`MNM="Bohrung ${du} mm"`,
`XA="${fmtMm(xa)}"`,
`YA="${fmtMm(ya)}"`,
`ZA="${za}"`,
`DU="${du}"`,
`TI="${ti}"`,
`BM="${bm}"`,
'AN="1"',
'KO="04"'
];
}
/** Supprime les doublons numériques (tolérance 0.02 mm — sans fusionner trous légitimes proches). */
function dedupeHoleSpecs(specs, eps = 0.02) {
const out = [];
@@ -200,14 +314,134 @@ function dedupeHoleSpecs(specs, eps = 0.02) {
(o) =>
Math.abs(o.xMm - s.xMm) < eps &&
Math.abs(o.yMm - s.yMm) < eps &&
Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps
Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps &&
(o.face || 'face') === (s.face || 'face') &&
(o.end || '') === (s.end || '')
);
if (!hit) out.push(s);
}
return out;
}
function buildSingleMprText({
/**
* La fraise à rainurer travaille depuis le dessus de la machine. Pour le
* panneau de dessus, on pose donc la face intérieure caisse vers le haut :
* rainure dos + assemblages caisse dessus, perçages d'habillage dessous.
*/
function orientFaceSpecsForMpr(piece, specs) {
if (piece?.elementType !== 'dessus' || !piece?.machining?.groove) return specs;
return specs.map((spec) => {
if (spec.face === 'face') return { ...spec, face: 'back' };
if (spec.face === 'back') return { ...spec, face: 'face' };
return spec;
});
}
/* ---------------------------------------------------------------------------
* Coupes d'onglet 45° (habillages de finition) :
* - tourillons perpendiculaires à la face d'onglet → <104 BohrUniv (perçage
* spatial : azimut C `CA` + inclinaison agrégat `WI`), sur le système 04.
* (<102 BohrVert exige un plan parallèle au plan supérieur.)
* - trait de scie 45° → <124 Nut_R (« Nuten im A-Winkel » : sciage avec lame
* inclinée WI, macro documenté du format MPR, équivalent Uni_Saeg_...W45
* du postprocesseur DXF HOMAG). Directement sur le plan supérieur (KO 04) :
* pas de plan incliné custom, donc affichage propre et génération NC
* possible avec l'agrégat scie pivotante.
* Géométrie (coordonnées pièce) : la pièce occupe Z=0..D. La scie démarre donc
* explicitement sur la face supérieure ZA=D, puis descend à 45° vers le chant
* extérieur. Les trajectoires sont parcourues de sorte que leur normale gauche
* pointe vers la chute : l'axe de scie est parallèle aux tourillons et le plan
* de lame leur est perpendiculaire. Aucun plan S auxiliaire n'est nécessaire.
* ------------------------------------------------------------------------- */
const MITER_SAW_KERF_MM = 4;
function buildMiterSections({ miterSpecs, L, B, D }) {
const empty = { levelLines: [], opLines: [] };
if (!miterSpecs.length) return empty;
const kerfCenter = MITER_SAW_KERF_MM / 2;
// Trajectoires globales sur KO=04. Pour chaque chant, le côté gauche du
// parcours est l'extérieur de la pièce (la chute).
const SIDES = {
left: {
label: 'gauche',
ca: 0,
entry: (sp) => [D / 2, sp.yMm],
sawPath: [[D - kerfCenter, 0], [D - kerfCenter, B]]
},
right: {
label: 'droite',
ca: 180,
entry: (sp) => [L - D / 2, sp.yMm],
sawPath: [[L - D + kerfCenter, B], [L - D + kerfCenter, 0]]
},
top: {
label: 'haut',
ca: 270,
entry: (sp) => [sp.xMm, B - D / 2],
sawPath: [[0, B - D + kerfCenter], [L, B - D + kerfCenter]]
}
};
const opLines = [];
for (const [side, def] of Object.entries(SIDES)) {
const specs = miterSpecs.filter((sp) => sp.end === side);
if (!specs.length) continue;
/* Tourillons : perçage spatial sur le système 04 (repère pièce). */
for (const sp of specs) {
const [xa, ya] = def.entry(sp);
const du = fmtMm(Number(sp.diameterMm) || 8);
opLines.push(
'<104 \\BohrUniv\\',
`MNM="Tourillon onglet ${def.label} ${du} mm"`,
`XA="${fmtMm(xa)}"`,
`YA="${fmtMm(ya)}"`,
`ZA="${fmtMm(D / 2)}"`,
`CA="${def.ca}"`,
'WI="45"',
// woodWOP compte TI depuis le point XA/YA/ZA le long de l'axe incliné :
// avec l'entrée au centre de la face d'onglet, la profondeur affichée
// vaut le double de la profondeur utile → on émet la moitié.
`TI="${fmtMm((Number(sp.depthMm) || 15) / 2)}"`,
`DU="${du}"`,
'AN="1"',
'F_="STANDARD"',
'KO="04"'
);
}
/* Trait de scie 45° incliné (Nut_R / Nuten im A-Winkel). */
const [start, end] = def.sawPath;
opLines.push(
'<124 \\Nut_R\\',
`MNM="Coupe onglet 45 ${def.label}"`,
`XA="${fmtMm(start[0])}"`,
`YA="${fmtMm(start[1])}"`,
`XE="${fmtMm(end[0])}"`,
`YE="${fmtMm(end[1])}"`,
// KO=04 a son origine en Z=0 sous la pièce ; la face supérieure est Z=D.
`ZA="${fmtMm(D)}"`,
'WI="45"',
`NB="${fmtMm(MITER_SAW_KERF_MM)}"`,
'RK="NOWRK"',
'EM="MOD2"',
'TV="0"',
'MV="GL"',
`TI="${fmtMm(D * Math.SQRT2 + 2)}"`,
'MN="GL"',
'F_="STANDARD"',
'KO="04"'
);
}
return { levelLines: [], opLines };
}
export function buildSingleMprText({
piece,
slug,
qtyIndex,
@@ -226,43 +460,57 @@ function buildSingleMprText({
const posLabel = `${groupIndex}.${seqGlobal}`;
const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex);
const unique = dedupeHoleSpecs(specs);
const unique = orientFaceSpecsForMpr(piece, dedupeHoleSpecs(specs));
/* Bloc Bohrungen */
const holeLines = [];
for (const sp of unique) {
holeLines.push(...bohrVertLines(sp, thicknessMm));
}
const dp = Math.max(1, Math.min(98, holeLines.filter((ln) => ln.startsWith('<102')).length));
// Répartition des perçages par destination d'usinage (repère pièce, origine bas-gauche) :
// - onglet 45° (habillages) → plans inclinés dédiés (buildMiterSections),
// - chant (face='end') → <103 BohrHoriz, regroupés par côté d'entrée,
// - face opposée (face='back') → <102 BohrVert avec cycle WoodWOP « von unten »,
// - grande face (défaut) → <102 BohrVert « von oben ».
const miterSpecs = unique.filter((sp) => sp.type === 'tourillon_miter' && sp.end);
const rest = unique.filter((sp) => !miterSpecs.includes(sp));
const edgeSpecs = rest.filter((sp) => sp.face === 'end');
const backSpecs = rest.filter((sp) => sp.face === 'back');
const topSpecs = rest.filter((sp) => sp.face !== 'end' && sp.face !== 'back');
const matKm = escQuoted(
piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX'
);
// Les opérations restent exprimées dans le repère pièce KO=04. DRWIN tourne
// uniquement la présentation/pose machine (pièces hautes couchées en X).
const miter = buildMiterSections({ miterSpecs, L, B, D });
const bez = escQuoted(baseDesignation(slug));
const hasBack = backSpecs.length > 0;
const lines = [];
lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"');
// Une pièce plus haute que large est couchée à 270° pour correspondre à
// l'orientation machine; les formules DW conservent son repère d'usinage.
lines.push('[001', 'MAT="2"', 'KM="Materialart"', `DRWIN="${angles.DRWIN}"`, 'KM="Drehwinkel 0,90,180,270"');
lines.push(`AUFT="${escQuoted(orderReference)}"`, `KM="AUFTRAG: ${escQuoted(orderReference)}"`);
lines.push(`STKL="${escQuoted(stackListKm)}"`, `KM="STUECKLISTE: ${escQuoted(stackListKm)}"`);
lines.push('AUFT="0"', `KM="AUFTRAG: ${escQuoted(orderReference)}"`);
lines.push('STKL="0"', `KM="STÜCKLISTE: ${escQuoted(stackListKm)}"`);
lines.push(`POS="${escQuoted(posLabel)}"`, `KM="POSITION: ${escQuoted(posLabel)}"`);
lines.push('BEZ="0"', `KM="Bezeichnung: ${escQuoted(slug)}"`);
lines.push(`L="${fmtMm(L)}"`, 'KM="Laenge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"');
lines.push('ZF="2.0"', 'KM="Zugabe Fraesung"', 'ZB="3.0"', 'KM="Zugabe Bohrung"');
lines.push('RITZEN="1.0"', 'KM="Vorritztiefe mm"');
lines.push('BEZ="0"', `KM="Bezeichnung: ${bez}"`);
lines.push(`L="${fmtMm(L)}"`, 'KM="Länge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"');
lines.push('ZF="2.0"', 'KM="Zugabe für DurchgangsFräsung"', 'ZB="3.0"', 'KM="Zugabe für DurchgangsBohrung"');
lines.push('RITZEN="1.0"', 'KM="Vorritztiefe in mm - 0=nicht Vorritzen"');
lines.push(`KAVODI="${kant.kavo}"`, 'KM="Kantendicke vorne"', `KALIDI="${kant.kali}"`, 'KM="Kantendicke links"');
lines.push(`KAHIDI="${kant.kahi}"`, 'KM="Kantendicke hinten"', `KAREDI="${kant.kare}"`, 'KM="Kantendicke rechts"');
lines.push('FORMAT="0"', 'KM="Formatieren 0 oder 1"');
lines.push('ZUGLI="0.0"', 'ZUGRE="0.0"', 'ZUGVO="0.0"', 'ZUGHI="0.0"');
lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"');
lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'bd="IF DW=90 OR DW=270 THEN L ELSE B"');
lines.push(
'offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"',
'offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"',
'offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"',
'offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"'
);
lines.push('ZUGLI="0.0"', 'KM="Zugabe für Gehrung links"');
lines.push('ZUGRE="0.0"', 'KM="Zugabe für Gehrung rechts"');
lines.push('ZUGVO="0.0"', 'KM="Zugabe für Gehrung vorne"');
lines.push('ZUGHI="0.0"', 'KM="Zugabe für Gehrung hinten"');
lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"', 'KM="DISABLED Drehwinkel korrigiert bei Fehleingabe"');
lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'KM="DISABLED Länge in X (gedreht)"');
lines.push('bd="IF DW=90 OR DW=270 THEN L ELSE B"', 'KM="DISABLED Breite in Y (gedreht)"');
lines.push('offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"', 'KM="DISABLED Offset in X (0° oder 180°)"');
lines.push('offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in X (90° oder 270°)"');
lines.push('offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in Y (0° oder 180°)"');
lines.push('offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"', 'KM="DISABLED Offset in Y (90° oder 270°)"');
lines.push(
'[K',
'<00 \\Koordinatensystem\\',
@@ -310,9 +558,12 @@ function buildSingleMprText({
'KI="90"',
'D2="0"',
'MI="0"',
...miter.levelLines,
'<100 \\WerkStck\\',
'LA="IF DW=90 OR DW=270 THEN B ELSE L"',
'BR="IF DW=90 OR DW=270 THEN L ELSE B"',
// WerkStck uses the piece-local dimensions. KO=04 applies DRWIN to the
// stock and operations together; swapping here would rotate the stock twice.
`LA="${fmtMm(L)}"`,
`BR="${fmtMm(B)}"`,
'DI="D"',
'FNX="IF DW=90 OR DW=270 THEN offxB ELSE offxL"',
'FNY="IF DW=90 OR DW=270 THEN offyB ELSE offyL"',
@@ -320,20 +571,25 @@ function buildSingleMprText({
'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"',
'<101 \\Kommentar\\',
`KM="AUFTRAG : ${escQuoted(orderReference)}"`,
`KM="STUECKLISTE : ${escQuoted(stackListKm)}"`,
`KM="STÜCKLISTE : ${escQuoted(stackListKm)}"`,
`KM="POSITION : ${escQuoted(posLabel)}"`,
`KM="Bezeichnung: ${escQuoted(slug)}"`,
'KM=""',
`KM="${escQuoted(angles.turnMsg)}"`,
'KM=""',
'KM="Teileinfo"',
`KM="Groesse: ${fmtMm(L)} x ${fmtMm(B)}"`,
`KM="Staerke: ${fmtMm(D)}"`,
`KM="Größe: ${fmtMm(L)} x ${fmtMm(B)}"`,
`KM="Stärke: ${fmtMm(D)}"`,
`KM=""`,
`KM="Material: ${matKm}"`,
'KM="Belag IN: "',
'KM="Belag AU: "',
'KM=""',
'KM=""'
);
// Les usinages de sous-face utilisent directement BM=LSU/LSLU et ne
// nécessitent donc aucun retournement de la pièce.
lines.push(
'<139 \\Komponente\\',
'MNM="Beschriftung"',
'IN="RSO/Makro/RSO_Beschriftung.mpr"',
@@ -345,7 +601,10 @@ function buildSingleMprText({
'VA="KAREDI KAREDI"',
`VA="GEDREHT ${angles.beschGedreht}"`,
'VA="NCSTOPP 0"',
'KO="04"',
'KO="04"'
);
// Macro de formatage (RSO_Format) — exécutée seulement si FORMAT=1 (??="FORMAT").
lines.push(
'<139 \\Komponente\\',
'MNM="Formatierung"',
'IN="RSO/Makro/RSO_Format.mpr"',
@@ -363,15 +622,41 @@ function buildSingleMprText({
'VA="KAFRMAX 2.0"',
'VA="MAT MAT"',
'??="FORMAT"',
'KO="04"',
'<121 \\Block\\',
'MNM="Bearbeitungen von oben "',
`DP="${dp}"`,
'KO="04"'
);
lines.push(...holeLines);
// Bloc « Bearbeitungen von oben » — perçages verticaux sur la grande face.
if (topSpecs.length) {
lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von oben "', `DP="${topSpecs.length}"`, 'KO="04"');
for (const sp of topSpecs) lines.push(...bohrVertLines(sp, thicknessMm));
}
// Bloc « Bearbeitungen von unten » — BM=LSU/LSLU indique réellement à
// WoodWOP que l'outil entre par la sous-face.
if (hasBack) {
lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von unten "', `DP="${backSpecs.length}"`, 'KO="04"');
for (const sp of backSpecs) {
lines.push(...bohrVertLines(sp, thicknessMm));
}
}
// Blocs de perçage sur chant — un bloc par côté d'entrée.
const edgeBlocks = [
['left', 'Bearbeitungen von links '],
['right', 'Bearbeitungen von rechts '],
['bottom', 'Bearbeitungen von vorne '],
['top', 'Bearbeitungen von hinten ']
];
for (const [end, label] of edgeBlocks) {
const group = edgeSpecs.filter((sp) => (sp.end || 'left') === end);
if (!group.length) continue;
lines.push('<121 \\Block\\', `MNM="${label}"`, `DP="${group.length}"`, 'KO="04"');
for (const sp of group) lines.push(...bohrHorizLines(sp, L, B, D));
}
lines.push(...miter.opLines);
lines.push(...grooveSection(piece, L, B));
lines.push(...doorHandleGrooveSection(piece, L, B));
lines.push('!');
return lines.join('\r\n');
}
@@ -407,10 +692,12 @@ function buildMprFileRecords(entries, mprOptions) {
}
async function packMprRecords(records, zipBaseName) {
// Encodage Windows-1252 (Latin-1) : format attendu par woodWOP pour les
// commentaires KM allemands (ä ö ü ß °), comme dans les fichiers fabricant.
if (records.length === 1) {
const only = records[0];
return {
buffer: Buffer.from(only.content, 'utf8'),
buffer: Buffer.from(only.content, 'latin1'),
fileName: only.fileName,
mimeType: 'application/octet-stream',
fileCount: 1
@@ -419,7 +706,7 @@ async function packMprRecords(records, zipBaseName) {
const zip = new JSZip();
for (const rec of records) {
zip.file(rec.fileName, rec.content);
zip.file(rec.fileName, Buffer.from(rec.content, 'latin1'));
}
const safeBase = safeZipFileName(zipBaseName || 'piece-mpr');
return {
+173 -22
View File
@@ -8,7 +8,10 @@
import {
PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS,
getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE,
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING,
resolveHorizontalSpan, validateHorizontalSpans, getSeparatorVerticalSegments,
hasSideHabillage, hasSidePlinthReturn, getAxiloPlinthReturnLengthMm,
getAxiloPlinthFrontSideDeductionMm
} from '../../shared/manufacturing.js';
// Constants for drawing
@@ -36,6 +39,8 @@ const SHARED_STYLES = `
.renfort { fill: #c9a96e; stroke: #333; stroke-width: 0.5; opacity: 0.7; }
.detail-label { font-family: Arial, sans-serif; font-size: 7px; fill: #666; }
.fh-groove { fill: none; stroke: #0d9488; stroke-width: 1; stroke-dasharray: 3, 2; }
.horizontal-span { fill: none; stroke: #b45309; stroke-width: 2; }
.horizontal-span-label { font-family: Arial, sans-serif; font-size: 8px; font-weight: bold; fill: #92400e; }
`;
// ── Helpers ──────────────────────────────────────────────────────────
@@ -140,6 +145,19 @@ function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const planModules = modules.length > 0 ? modules : (Array.isArray(config.modules) ? config.modules : []);
const spanConfig = { ...config, modules: planModules };
const spanIssues =
Array.isArray(config.horizontalSpans) && config.horizontalSpans.length
? validateHorizontalSpans(spanConfig)
: [];
const invalidSpanIds = new Set(
spanIssues.map((issue) => issue.spanId).filter((id) => id != null).map(String)
);
const resolvedSpans = (Array.isArray(config.horizontalSpans) ? config.horizontalSpans : [])
.filter((span) => span?.id != null && !invalidSpanIds.has(String(span.id)))
.map((span) => resolveHorizontalSpan(span, spanConfig))
.filter(Boolean);
const S = DRAWING_SCALE;
const svgWidth = widthMm * S + MARGIN * 2;
@@ -154,19 +172,49 @@ export function generateAssemblyPlan(config, modules = []) {
const oX = MARGIN;
const oY = MARGIN;
// Plinth
const habLeft = hasSideHabillage(config, 'left');
const habRight = hasSideHabillage(config, 'right');
const returnLeft = hasSidePlinthReturn(config, 'left');
const returnRight = hasSidePlinthReturn(config, 'right');
const habT = PANEL.structure * S;
const isNiche = config?.installationType === 'niche';
const leftDeductMm = isNiche ? 0 : getAxiloPlinthFrontSideDeductionMm(config, 'left');
const rightDeductMm = isNiche ? 0 : getAxiloPlinthFrontSideDeductionMm(config, 'right');
const sideSetbackMm = PLINTH.frontSetbackMm;
// Plinth — shortened where side cladding or a recessed plinth return runs to the floor
const plinthY = oY + bodyHeight * S;
svg += `<rect x="${oX}" y="${plinthY}" width="${widthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
const plinthX = oX + leftDeductMm * S;
const plinthW = (widthMm - leftDeductMm - rightDeductMm) * S;
svg += `<rect x="${plinthX}" y="${plinthY}" width="${plinthW}" height="${plinthHeightMm * S}" class="plinth" />`;
// Body
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${bodyHeight * S}" class="body" />`;
if (habLeft) {
svg += `<rect x="${oX}" y="${oY}" width="${habT}" height="${heightMm * S}" class="body" />`;
} else if (returnLeft) {
svg += `<rect x="${oX + sideSetbackMm * S}" y="${plinthY}" width="${habT}" height="${plinthHeightMm * S}" class="plinth" />`;
}
if (habRight) {
svg += `<rect x="${oX + widthMm * S - habT}" y="${oY}" width="${habT}" height="${heightMm * S}" class="body" />`;
} else if (returnRight) {
svg += `<rect x="${oX + widthMm * S - (sideSetbackMm + PANEL.structure) * S}" y="${plinthY}" width="${habT}" height="${plinthHeightMm * S}" class="plinth" />`;
}
// Module separators
if (modules.length > 0) {
if (planModules.length > 0) {
let currentX = oX;
for (let i = 0; i < modules.length - 1; i++) {
currentX += modules[i].widthMm * S;
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
for (let i = 0; i < planModules.length - 1; i++) {
currentX += planModules[i].widthMm * S;
if (resolvedSpans.length === 0) {
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
} else {
for (const segment of getSeparatorVerticalSegments(spanConfig, i)) {
const y1 = plinthY - segment.topMm * S;
const y2 = plinthY - segment.bottomMm * S;
svg += `<line x1="${currentX}" y1="${y1}" x2="${currentX}" y2="${y2}" class="separator" />`;
}
}
}
}
@@ -182,9 +230,9 @@ export function generateAssemblyPlan(config, modules = []) {
svg += `<rect x="${oX + i * panelWidth * 0.95 + 5}" y="${oY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * S}" class="door" />`;
}
} else {
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
const blockCount = planModules.length > 0 ? planModules.length : (config.blocks?.length || 2);
let currentX = oX;
const items = modules.length > 0 ? modules : (config.blocks || []);
const items = planModules.length > 0 ? planModules : (config.blocks || []);
const hw = config.hardware || {};
const fullHeightHandle = hw.handleType === 'full_height';
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
@@ -193,24 +241,116 @@ export function generateAssemblyPlan(config, modules = []) {
for (let i = 0; i < blockCount; i++) {
const bw = items[i]?.widthMm || (widthMm / blockCount);
const doorWmm = Math.max(100, Math.floor(widthMm / blockCount) - 4 - widthDeductionMm);
const modulePx = bw * S;
const doorWmm = Math.max(
100,
Math.floor(resolvedSpans.length === 0 ? widthMm / blockCount : bw) -
4 -
widthDeductionMm
);
const doorWPx = doorWmm * S;
const doorX = currentX + (modulePx - doorWPx) / 2;
const doorY = oY + gapMm * S;
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${doorHmm * S}" class="door" />`;
const hingeSide = getDoorHingeSide(i, blockCount, hw);
svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
if (fullHeightHandle) {
svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
if (resolvedSpans.length === 0 || planModules.length === 0) {
const doorY = oY + gapMm * S;
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${doorHmm * S}" class="door" />`;
svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
if (fullHeightHandle) {
svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
}
} else {
const module = planModules[i] || {};
let heights = Array.isArray(module.blockHeightsMm)
? module.blockHeightsMm.map(Number).filter((height) => Number.isFinite(height) && height > 0)
: [];
if (!heights.length) {
const stacked = module.stacked === true ||
(module.stacked == null && config.stackEnabled === true);
const bottom = Number(module.stackBottomHeightMm ?? config.stackBottomHeightMm);
heights = stacked && bottom > 0 && bottom < bodyHeight
? [bottom, bodyHeight - bottom]
: [bodyHeight];
}
const blockDoors = Array.isArray(module.blockDoors) ? module.blockDoors : null;
let bottomMm = 0;
heights.forEach((height, blockIndex) => {
const topMm = bottomMm + height;
const covered = resolvedSpans.some((span) =>
span.moduleIndexes.includes(i) &&
bottomMm >= span.bottomMm - 0.5 &&
topMm <= span.topMm + 0.5
);
const hasDoor =
blockDoors && typeof blockDoors[blockIndex] === 'boolean'
? blockDoors[blockIndex]
: true;
if (hasDoor && !covered) {
const segmentDoorHeightMm = Math.max(80, height - 4);
const doorY = plinthY - topMm * S + gapMm * S;
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${segmentDoorHeightMm * S}" class="door" />`;
svg += drawHingeMarkers(
doorX, doorY, doorWmm, segmentDoorHeightMm, S, hingeSide
);
if (fullHeightHandle) {
svg += drawFullHeightHandleGrooveElevation(
doorX, doorY, doorWmm, segmentDoorHeightMm, S, hingeSide
);
}
}
bottomMm = topMm;
});
}
currentX += modulePx;
}
}
for (const span of resolvedSpans) {
const x1 = oX + span.leftMm * S;
const x2 = oX + span.rightMm * S;
const yBottom = plinthY - span.bottomMm * S;
const yTop = plinthY - span.topMm * S;
svg += `<line x1="${x1}" y1="${yBottom}" x2="${x2}" y2="${yBottom}" class="horizontal-span" />`;
svg += `<line x1="${x1}" y1="${yTop}" x2="${x2}" y2="${yTop}" class="horizontal-span" />`;
svg += `<text x="${(x1 + x2) / 2}" y="${(yBottom + yTop) / 2}" class="horizontal-span-label" text-anchor="middle">Portée horizontale — colonnes ${span.moduleIndexes.map((index) => index + 1).join('–')}</text>`;
const doorMode = span.span?.doorMode || 'none';
if (doorType !== 'sliding' && doorMode !== 'none') {
const hw = config.hardware || {};
const fullHeightHandle = hw.handleType === 'full_height';
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
const doorHeightMm = Math.max(120, span.topMm - span.bottomMm - 4);
const doorY = yTop + 2 * S;
const defs = doorMode === 'single'
? [{ leftMm: span.leftMm, widthMm: span.widthMm }]
: [
{
leftMm: span.leftMm,
widthMm: span.widthMm / 2,
hingeSide: 'left'
},
{
leftMm: span.leftMm + span.widthMm / 2,
widthMm: span.widthMm / 2,
hingeSide: 'right'
}
];
defs.forEach((door, doorIndex) => {
const doorWidthMm = Math.max(100, Math.floor(door.widthMm) - 4 - widthDeductionMm);
const doorX = oX + door.leftMm * S + (door.widthMm - doorWidthMm) * S / 2;
const hingeSide = door.hingeSide || getDoorHingeSide(doorIndex, defs.length, hw);
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWidthMm * S}" height="${doorHeightMm * S}" class="door" data-horizontal-span-door="${doorMode}" data-hinge-side="${hingeSide}" />`;
svg += drawHingeMarkers(doorX, doorY, doorWidthMm, doorHeightMm, S, hingeSide);
if (fullHeightHandle) {
svg += drawFullHeightHandleGrooveElevation(
doorX, doorY, doorWidthMm, doorHeightMm, S, hingeSide
);
}
});
}
}
// Reinforcements (Forteresse 16 mm):
// width = armoire width - 2×19 (entre côtés),
// one at top + one at bottom (offset 19 mm),
@@ -257,12 +397,23 @@ export function generateSidePlan(config) {
const oX = MARGIN;
const oY = MARGIN;
// Plinth
const plinthY = oY + bodyHeight * S;
svg += `<rect x="${oX}" y="${plinthY}" width="${depthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
const habLeft = hasSideHabillage(config, 'left');
const returnLeft = hasSidePlinthReturn(config, 'left');
// Body
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${bodyHeight * S}" class="body" />`;
const plinthY = oY + bodyHeight * S;
const plinthSetbackMm = PLINTH.frontSetbackMm;
if (habLeft) {
// Habillage gauche jusqu'au sol : pas de plinthe visible de ce côté.
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${heightMm * S}" class="body" />`;
} else {
if (returnLeft) {
const returnLenMm = getAxiloPlinthReturnLengthMm(depthMm);
svg += `<rect x="${oX + plinthSetbackMm * S}" y="${plinthY}" width="${returnLenMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
} else {
svg += `<rect x="${oX + plinthSetbackMm * S}" y="${plinthY}" width="${PLINTH.panelThicknessMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
}
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${bodyHeight * S}" class="body" />`;
}
// Back panel (8mm) shown as a thin rectangle at the back
const backX = oX + (depthMm - PANEL.back) * S;
+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,
File diff suppressed because it is too large Load Diff