Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-05-28 07:20:38 +02:00
co-authored by Cursor
parent 6923c319a1
commit 6c0d7e47b8
81 changed files with 21780 additions and 2986 deletions
+17
View File
@@ -2,10 +2,27 @@
# DATABASE_PATH=./data/configurateur.db
PORT=3001
# Formex account credentials (required for price scraping)
FORMEX_EMAIL=[email protected]
FORMEX_PASSWORD=[email protected]
# Formex sync schedule (cron expression, default: 3:00 AM daily)
#FORMEX_SYNC_CRON=0 3 * * *
# Delay between requests in ms (to avoid rate limiting)
#FORMEX_CRAWL_DELAY_MS=500
# Häfele account credentials (optional; needed if product price requires authentication)
#HAFELE_USERNAME=CHxxxxxxx
#HAFELE_PASSWORD=your-password
# Optional explicit customer number (defaults to HAFELE_USERNAME)
#HAFELE_CUSTOMER_NO=CHxxxxxxx
# Delay between Häfele requests in ms
#HAFELE_CRAWL_DELAY_MS=500
# Browser fallback for Cloudflare-protected pages (Playwright)
# Set to 0 to run visible browser, 1 (default) for headless
#HAFELE_BROWSER_HEADLESS=1
# Browser timeout in ms
#HAFELE_BROWSER_TIMEOUT_MS=90000
# OpenAI API Key for room photo analysis (GPT-4 Vision)
OPENAI_API_KEY=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
+50
View File
@@ -7,9 +7,47 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
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 schemaPath = path.join(__dirname, 'schema.sql');
let db = null;
/** Création idempotente — bases anciennes sans ces tables (avant migration 014). */
const HARDWARE_CATALOG_DDL = `
CREATE TABLE IF NOT EXISTS blum_hardware (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
variant TEXT,
price_chf REAL NOT NULL,
specs_json TEXT,
image_url TEXT,
active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS handle_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
style TEXT,
length_mm INTEGER,
price_chf REAL NOT NULL,
image_url TEXT,
active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS hinge_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
brand TEXT DEFAULT 'Blum',
price_chf REAL NOT NULL,
active INTEGER DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_blum_hardware_type ON blum_hardware(type);
CREATE INDEX IF NOT EXISTS idx_handle_variants_style ON handle_variants(style);
CREATE INDEX IF NOT EXISTS idx_hinge_variants_type ON hinge_variants(type);
`;
function toSqlitePlaceholders(sql) {
return sql.replace(/\$(\d+)/g, '?');
}
@@ -31,6 +69,18 @@ async function getDb() {
buffer = fs.readFileSync(dbPath);
}
db = new SQL.Database(buffer || null);
try {
// Ensure full schema exists for legacy/partial DB files.
if (fs.existsSync(schemaPath)) {
const schemaSql = fs.readFileSync(schemaPath, 'utf8');
db.exec(schemaSql);
}
db.exec(HARDWARE_CATALOG_DDL);
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
} catch (e) {
console.error('[db] ensureHardwareCatalogTables:', e.message);
}
return db;
}
@@ -0,0 +1,2 @@
-- Add price_chf to formex_dimensions so each size variant can have its own price
ALTER TABLE formex_dimensions ADD COLUMN price_chf REAL;
+16
View File
@@ -0,0 +1,16 @@
-- Migration 007: Suppliers table for order voucher emails
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
type TEXT NOT NULL, -- 'panels', 'hardware', 'handles', 'other'
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
-- Seed default suppliers
INSERT OR IGNORE INTO suppliers (code, name, email, type) VALUES
('formex', 'Formex', '[email protected]', 'panels'),
('blum', 'Blum Switzerland', '[email protected]', 'hardware');
+14
View File
@@ -91,6 +91,7 @@ CREATE TABLE IF NOT EXISTS formex_dimensions (
width_mm INTEGER NOT NULL,
height_mm INTEGER NOT NULL,
thickness_mm INTEGER NOT NULL,
price_chf REAL,
is_default INTEGER DEFAULT 0
);
@@ -200,3 +201,16 @@ CREATE INDEX IF NOT EXISTS idx_blum_hardware_type ON blum_hardware(type);
CREATE INDEX IF NOT EXISTS idx_handle_variants_style ON handle_variants(style);
CREATE INDEX IF NOT EXISTS idx_hinge_variants_type ON hinge_variants(type);
CREATE INDEX IF NOT EXISTS idx_element_colors_order ON element_colors(order_id);
-- Exports de plans/debitage enregistrés par commande
CREATE TABLE IF NOT EXISTS plan_exports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
export_type TEXT NOT NULL,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
mime_type TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_plan_exports_order ON plan_exports(order_id);
+233 -5
View File
@@ -4,7 +4,7 @@ import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import cors from 'cors';
import { dbReady } from './db/index.js';
import { dbReady, run, query } from './db/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.join(__dirname, '.env') });
@@ -12,12 +12,32 @@ import { getProducts } from './routes/products.js';
import { getBlockTypes } from './routes/blockTypes.js';
import { getFinishes } from './routes/finishes.js';
import { getServiceOptions } from './routes/serviceOptions.js';
import { getSettings } from './routes/settings.js';
import { getSettings, updateSettings } from './routes/settings.js';
import {
ensureEngineeringRulesSeed,
getEngineeringRules,
createEngineeringRule,
updateEngineeringRule,
deleteEngineeringRule
} from './routes/engineeringRules.js';
import { postPrice } from './routes/price.js';
import { getOrders, postOrder } from './routes/orders.js';
import {
getOrders,
postOrder,
getOrderSupplierVouchers,
listOrderPlanExports,
createOrderPlanExport,
downloadOrderPlanExport,
generateOrderPlanExports,
getOrderProductionDetails,
deleteOrderPlanExport
} from './routes/orders.js';
import { getSuppliers, createSupplier, updateSupplier, deleteSupplier } from './routes/suppliers.js';
import {
triggerFormexSync,
streamFormexSync,
triggerHaefeleSync,
streamHaefeleSync,
getLogs,
getStatus,
getFormexMaterials,
@@ -26,7 +46,9 @@ import {
updateFormexMaterial,
deleteFormexMaterial,
getFormexCollections,
getFormexCategories
getFormexCategories,
getFormexSuppliers,
getHaefeleMaterials
} from './routes/sync.js';
import {
uploadMiddleware,
@@ -53,7 +75,11 @@ import {
generatePlansRoute,
generateCuttingListRoute,
exportCuttingListText,
exportPDFHtml
exportPDFHtml,
exportCncRoute,
exportMprRoute,
exportMprPieceRoute,
generateHardwareListRoute
} from './routes/plans.js';
import {
designTipsRoute,
@@ -76,13 +102,33 @@ app.get('/api/block-types', getBlockTypes);
app.get('/api/finishes', getFinishes);
app.get('/api/service-options', getServiceOptions);
app.get('/api/settings', getSettings);
app.put('/api/settings', updateSettings);
app.get('/api/engineering-rules', getEngineeringRules);
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/orders', getOrders);
app.post('/api/orders', postOrder);
app.get('/api/orders/:id/supplier-vouchers', getOrderSupplierVouchers);
app.get('/api/orders/:id/plan-exports', listOrderPlanExports);
app.post('/api/orders/:id/plan-exports', createOrderPlanExport);
app.post('/api/orders/:id/plan-exports/generate', generateOrderPlanExports);
app.get('/api/orders/:id/plan-exports/:exportId/download', downloadOrderPlanExport);
app.delete('/api/orders/:id/plan-exports/:exportId', deleteOrderPlanExport);
app.get('/api/orders/:id/production-details', getOrderProductionDetails);
// Supplier routes
app.get('/api/suppliers', getSuppliers);
app.post('/api/suppliers', createSupplier);
app.put('/api/suppliers/:id', updateSupplier);
app.delete('/api/suppliers/:id', deleteSupplier);
// Sync routes (Formex crawler)
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/logs', getLogs);
app.get('/api/sync/status', getStatus);
@@ -94,6 +140,8 @@ app.put('/api/formex/materials/:id', updateFormexMaterial);
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);
// Room analysis routes (AI-powered)
app.post('/api/room/analyze', uploadMiddleware, analyzeRoom);
@@ -125,8 +173,12 @@ 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/hardware-list', generateHardwareListRoute);
app.post('/api/plans/export-text', exportCuttingListText);
app.post('/api/plans/export-pdf', exportPDFHtml);
app.post('/api/plans/export-cnc', exportCncRoute);
app.post('/api/plans/export-mpr', exportMprRoute);
app.post('/api/plans/export-mpr-piece', exportMprPieceRoute);
if (hasClientBuild) {
app.use(express.static(clientDist));
@@ -152,6 +204,182 @@ if (hasClientBuild) {
await dbReady;
// Apply migration 007: suppliers table + supplier_id columns
async function applyMigration007() {
try {
await run(`CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
type TEXT NOT NULL,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
)`);
// Add supplier_id to formex_materials if missing
const fmCols = (await query("PRAGMA table_info(formex_materials)")).rows;
if (!fmCols.find(c => c.name === 'supplier_id')) {
await run('ALTER TABLE formex_materials ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
}
// Add supplier_id to blum_hardware if missing
const bhCols = (await query("PRAGMA table_info(blum_hardware)")).rows;
if (!bhCols.find(c => c.name === 'supplier_id')) {
await run('ALTER TABLE blum_hardware ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
}
// Add supplier_id to handle_variants if missing
const hvCols = (await query("PRAGMA table_info(handle_variants)")).rows;
if (!hvCols.find(c => c.name === 'supplier_id')) {
await run('ALTER TABLE handle_variants ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
}
// Add supplier_id to hinge_variants if missing
const hingeCols = (await query("PRAGMA table_info(hinge_variants)")).rows;
if (!hingeCols.find(c => c.name === 'supplier_id')) {
await run('ALTER TABLE hinge_variants ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
}
// Seed default suppliers
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')`);
// 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`);
await run(`UPDATE blum_hardware SET supplier_id = (SELECT id FROM suppliers WHERE code = 'blum') WHERE supplier_id IS NULL`);
await run(`UPDATE handle_variants SET supplier_id = (SELECT id FROM suppliers WHERE code = 'blum') WHERE supplier_id IS NULL`);
await run(`UPDATE hinge_variants SET supplier_id = (SELECT id FROM suppliers WHERE code = 'blum') WHERE supplier_id IS NULL`);
console.log('[migration-007] Suppliers table ready.');
} catch (err) {
console.error('[migration-007] Error:', err.message);
}
}
await applyMigration007();
// Apply migration 008: price_unit column on formex_materials
async function applyMigration008() {
try {
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
if (!cols.find(c => c.name === 'price_unit')) {
await run('ALTER TABLE formex_materials ADD COLUMN price_unit TEXT');
console.log('[migration-008] Added price_unit column to formex_materials.');
}
} catch (err) {
console.error('[migration-008] Error:', err.message);
}
}
await applyMigration008();
// Apply migration 009: persist generated plan exports
async function applyMigration009() {
try {
await run(`CREATE TABLE IF NOT EXISTS plan_exports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
export_type TEXT NOT NULL,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
mime_type TEXT,
created_at TEXT DEFAULT (datetime('now'))
)`);
await run('CREATE INDEX IF NOT EXISTS idx_plan_exports_order ON plan_exports(order_id)');
console.log('[migration-009] Plan exports table ready.');
} catch (err) {
console.error('[migration-009] Error:', err.message);
}
}
await applyMigration009();
async function applyMigration010() {
try {
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
if (!cols.find(c => c.name === 'bore_diameter_mm')) {
await run('ALTER TABLE formex_materials ADD COLUMN bore_diameter_mm REAL');
console.log('[migration-010] Added bore_diameter_mm to formex_materials.');
}
} catch (err) {
console.error('[migration-010] Error:', err.message);
}
}
await applyMigration010();
async function applyMigration011() {
try {
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
const colNames = cols.map(c => c.name);
if (!colNames.includes('bore_depth_base_mm')) {
await run('ALTER TABLE formex_materials ADD COLUMN bore_depth_base_mm REAL');
console.log('[migration-011] Added bore_depth_base_mm to formex_materials.');
}
if (!colNames.includes('bore_depth_piece_mm')) {
await run('ALTER TABLE formex_materials ADD COLUMN bore_depth_piece_mm REAL');
console.log('[migration-011] Added bore_depth_piece_mm to formex_materials.');
}
} catch (err) {
console.error('[migration-011] Error:', err.message);
}
}
await applyMigration011();
async function applyMigration012() {
try {
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
const colNames = cols.map(c => c.name);
if (!colNames.includes('diameter_mm')) {
await run('ALTER TABLE formex_materials ADD COLUMN diameter_mm REAL');
console.log('[migration-012] Added diameter_mm to formex_materials.');
}
if (!colNames.includes('length_mm')) {
await run('ALTER TABLE formex_materials ADD COLUMN length_mm REAL');
console.log('[migration-012] Added length_mm to formex_materials.');
}
} catch (err) {
console.error('[migration-012] Error:', err.message);
}
}
await applyMigration012();
async function applyMigration013() {
try {
await run(`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)`);
const defaults = [
['allow_sliding_doors', '0'],
['sanitary_gap_mm', '30'],
['drawer_depth_min_mm', '400'],
['drawer_depth_max_mm', '650'],
['drawer_depth_step_mm', '50'],
['drawer_back_gap_mm', '10'],
['drawer_hidden_handle_reduction_mm', '40'],
['standard_handle_axis_mm', '1000'],
['standard_handle_tolerance_mm', '200'],
['groove_width_mm', '9'],
['groove_depth_mm', '10.5'],
['groove_edge_offset_mm', '16']
];
for (const [key, value] of defaults) {
await run('INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2)', [key, value]);
}
console.log('[migration-013] Rule settings defaults ready.');
} catch (err) {
console.error('[migration-013] Error:', err.message);
}
}
await applyMigration013();
await ensureEngineeringRulesSeed();
// Initialize the scheduler for automated sync
initScheduler();
+266
View File
@@ -12,12 +12,25 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jszip": "^3.10.1",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"nodemailer": "^8.0.1",
"openai": "^4.28.0",
"pdfkit": "^0.17.2",
"playwright": "^1.58.2",
"sql.js": "^1.11.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
@@ -92,6 +105,26 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -122,6 +155,15 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -219,6 +261,15 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -305,6 +356,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -370,6 +427,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -618,6 +681,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -636,6 +705,23 @@
"node": ">= 0.8"
}
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -689,6 +775,20 @@
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -858,6 +958,12 @@
"node": ">=0.10.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -879,6 +985,59 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jpeg-exif": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1055,6 +1214,15 @@
}
}
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -1130,6 +1298,12 @@
}
}
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -1194,6 +1368,54 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pdfkit": {
"version": "0.17.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
"license": "MIT",
"dependencies": {
"crypto-js": "^4.2.0",
"fontkit": "^2.0.4",
"jpeg-exif": "^1.1.4",
"linebreak": "^1.1.0",
"png-js": "^1.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/png-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -1273,6 +1495,12 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1344,6 +1572,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1460,6 +1694,12 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1475,6 +1715,12 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1509,6 +1755,26 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "configurateur-armoires-server",
"version": "1.0.0",
"version": "2.0.0",
"description": "API Node/Express pour le configurateur d'armoires sur mesure",
"type": "module",
"main": "index.js",
@@ -16,9 +16,13 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jszip": "^3.10.1",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"nodemailer": "^8.0.1",
"openai": "^4.28.0",
"pdfkit": "^0.17.2",
"playwright": "^1.58.2",
"sql.js": "^1.11.0"
}
}
+4 -3
View File
@@ -29,15 +29,16 @@ export async function designTipsRoute(req, res) {
/**
* POST /api/ai/recommend-materials
* Body: { config, finishes: [{ id, name, collection }], roomAnalysis? }
* Body: { config, finishes: [{ id, name, collection, price_chf? }], roomAnalysis?, priorities? }
* priorities: { price: 1-5, quality: 1-5, durability: 1-5, aesthetics: 1-5 }
*/
export async function recommendMaterialsRoute(req, res) {
try {
const { config, finishes, roomAnalysis } = req.body;
const { config, finishes, roomAnalysis, priorities } = req.body;
if (!config || !finishes) {
return res.status(400).json({ error: 'Missing config or finishes in body' });
}
const result = await recommendMaterials(config, finishes, roomAnalysis);
const result = await recommendMaterials(config, finishes, roomAnalysis, priorities);
res.json(result);
} catch (err) {
console.error('[AI recommend-materials]', err.message);
+384
View File
@@ -0,0 +1,384 @@
import pool, { run } from '../db/index.js';
const DEFAULT_ENGINEERING_RULES = [
{
sort_order: 10,
chapter: '1.A',
title: "Hiérarchie d'assemblage (Côtés filants vs chapeau)",
details:
"Si hauteur >= 1200 mm: montage en Côtés filants (montants verticaux enferment fond et dessus).\n" +
"Si hauteur < 1200 mm: montage en Chapeau (panneau dessus recouvre les montants)."
},
{
sort_order: 20,
chapter: '1.B',
title: 'Sens du fil du bois (variable sens_du_fil)',
details:
"Le matériau doit porter une variable booléenne sens_du_fil.\n" +
'Forcé vertical: portes, montants, joues.\n' +
'Forcé horizontal: tiroirs, tablettes/rayons, fonds/dessus.'
},
{
sort_order: 30,
chapter: '2.A',
title: 'Portée étagères anti-flexion',
details:
"Rayon libre mélaminé 19 mm <= 1200 mm.\n" +
"Si > 1200 mm: forcer une séparation verticale OU imposer épaisseur 25 mm."
},
{
sort_order: 40,
chapter: '2.A',
title: 'Portes et gauchissement',
details:
'Porte monobloc <= 2500 mm (hauteur) et <= 1000 mm (largeur).\n' +
'Au-delà: imposer automatiquement des tendeurs de redressement encastrés.'
},
{
sort_order: 50,
chapter: '2.A',
title: 'Socle et passage de pieds',
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."
},
{
sort_order: 60,
chapter: '2.B',
title: "Tolérance jeu périphérique des façades",
details: 'Façade (porte/tiroir) plus petite de 1.5 mm à 2 mm sur chaque bord.'
},
{
sort_order: 70,
chapter: '2.B',
title: "Tolérance jeu entre façades (fuge)",
details: 'Jeu entre deux portes/tiroirs: 2 mm à 3 mm.'
},
{
sort_order: 80,
chapter: '2.B',
title: 'Jeu au sol porte descendante',
details: 'Sans socle visible: déduire 10 mm à 15 mm en bas de la hauteur de porte.'
},
{
sort_order: 90,
chapter: '2.C',
title: 'Surcote de tablettage (doublage)',
details:
'Prévoir du gras pour ajustement mur.\n' +
'Profondeur = ép. porte 19 + jeu butée 3 + profondeur caisse + surcote tablettage 20.'
},
{
sort_order: 100,
chapter: '3',
title: 'Placage chants - déduction CNC',
details: 'La machine doit soustraire 2 mm par chant appliqué sur la coupe brute.'
},
{
sort_order: 110,
chapter: '3',
title: 'Placage chants - côté caisse / porte / face tiroir / plinthe',
details: '4 chants. Ordre plaqueuse: 2 largeurs puis 2 longueurs.'
},
{
sort_order: 120,
chapter: '3',
title: 'Placage chants - fonds et dessus structure',
details: '2 chants: longueurs avant et arrière.'
},
{
sort_order: 130,
chapter: '3',
title: 'Placage chants - rayons et séparations',
details: '1 chant: bord avant.'
},
{
sort_order: 140,
chapter: '3',
title: 'Placage chants - joues de doublage',
details: '3 chants: 2 largeurs puis chant avant sur toute la hauteur.'
},
{
sort_order: 150,
chapter: '3',
title: "Placage chants - dessus d'habillage (chapeau)",
details:
'3 chants: 2 largeurs puis chant avant sur la longueur.\n' +
"Coupe à 45° avec petit plat 2x2 mm sur l'angle extérieur."
},
{
sort_order: 160,
chapter: '3',
title: 'Pièces sans chants',
details: 'Dos 8 mm et fonds de tiroirs: 0 chant.'
},
{
sort_order: 170,
chapter: '4.A',
title: 'Mode accessibilité SIA 500',
details:
'Option UI "Conception SIA 500".\n' +
"Si activée: verrouiller la hauteur des éléments interactifs entre 800 mm et 1100 mm du sol."
},
{
sort_order: 180,
chapter: '4.B',
title: 'Penderie - profondeur cintres',
details:
'Profondeur intérieure utile >= 600 mm (meuble total env. 647 mm).\n' +
'Si profondeur < 400 mm: désactiver le bouton "Ajouter penderie".'
},
{
sort_order: 190,
chapter: '4.B',
title: 'Penderie - hauteurs libres',
details: 'Vestes courtes >= 1100 mm. Manteaux longs >= 1600 mm.'
},
{
sort_order: 200,
chapter: '4.C',
title: 'Tiroirs intérieurs - hauteur maximale',
details: "Interdire un module tiroir au-delà de 1400 mm du sol (ligne d'épaule)."
},
{
sort_order: 210,
chapter: '4.C',
title: 'Tiroirs intérieurs - hauteurs de façades',
details: 'Variantes autorisées: 60, 90, 128, 177, 240 mm.'
},
{
sort_order: 220,
chapter: '4.C',
title: 'Calepinage tiroirs',
details: 'H façade = [H ouverture - (nb jeux * dimension jeu)] / nb tiroirs.'
},
{
sort_order: 230,
chapter: '4.C',
title: "Tiroirs à l'anglaise (cales de compensation)",
details:
"Si tiroir derrière porte à charnières: générer une cale (ép. 25 mm x prof. int., hauteur = hauteur bloc tiroirs).\n" +
"La cale doit être dans la même couleur que l'intérieur."
},
{
sort_order: 240,
chapter: '5.A',
title: 'Mode cuisine - standards de largeur',
details:
'Snapping des largeurs sur multiples de 60 cm (EN) ou 55 cm (SMS).\n' +
'Meubles bas: caisse 720/780 mm, plan fini 860-920 mm.\n' +
'Meubles hauts: profondeur 350 mm, crédence 500-600 mm (650 mm gaz), hauteurs 360/480/600/720/900 mm.\n' +
'Colonnes alignées aux meubles hauts: 2070/2150/2330 mm.'
},
{
sort_order: 250,
chapter: '5.B',
title: 'Meubles hauts suspendus - quincaillerie et usinages',
details:
'Ferrure droite 290.54.900 et gauche 290.54.901.\n' +
'Entaille haute: 120x12 mm, prof. 25 mm, à 50 mm depuis le haut (bord arrière).\n' +
'Entaille basse: 120x12 mm, prof. 68 mm, à 50 mm du bord arrière.\n' +
'Perçages réglage face intérieure: axe 110 mm; +22 mm et -10 mm; profondeur 10 mm; à 60 mm du bord arrière.'
},
{
sort_order: 260,
chapter: '5.C',
title: 'Gestion thermique fours/frigos - règle absolue du dos',
details:
'Ne jamais générer de dos 8 mm ni rainure derrière four/réfrigérateur.\n' +
"Prévoir un vide arrière d'au moins 50 mm."
},
{
sort_order: 270,
chapter: '5.C',
title: 'Aération réfrigérateurs',
details:
'Plinthe >= 100 mm pour la grille.\n' +
'Générer 1 grille basse (plinthe) + 1 grille haute (bandeau), découpe 457x92 mm.\n' +
'Références: Inox 959.10.000, Blanc 959.10.001, Pin 959.10.002, Acajou 959.10.003, Noyer 959.10.004.'
},
{
sort_order: 280,
chapter: '5.C',
title: 'Porte frigo - règle logistique',
details:
"Ne pas générer ni facturer les accessoires de liaison porte armoire/porte frigo (fournis avec l'électroménager)."
},
{
sort_order: 290,
chapter: '5.D',
title: 'Dimensions de niches appareils',
details:
'Fours EN 600: standard 590/560/550, compact 450/560/550.\n' +
'Fours SMS 550: standard 762/512/550, compact 381 ou 508 / 512 / 550.\n' +
'Doubles fours: séparation fixe 19 mm obligatoire.\n' +
'Plaque cuisson: protéger le tiroir inférieur avec panneau horizontal + vide ventilé 20 mm.\n' +
"Sous-évier: dos partiel (traverses 150 mm haut/bas) + option fond de protection aluminium."
},
{
sort_order: 300,
chapter: '6.A',
title: 'Système 32 - axe charnière à 103 mm',
details:
"Pour réversibilité: centre embase à 105 mm, déduction jeu façade 2 mm => axe perçage porte à 103 mm du haut/bas.\n" +
'Répartition intermédiaire: arrondir au multiple de 32 mm.'
},
{
sort_order: 310,
chapter: '6.B',
title: 'Matrice quantité charnières (épaisseur 19 mm)',
details:
'<=900 mm ou <=6 kg: 2 charnières.\n' +
'901-1600 mm ou <=17 kg: 3.\n' +
'1601-2000 mm ou <=20 kg: 4.\n' +
'2001-2400 mm ou <=22 kg: 5.\n' +
'2401-2600 mm: 6.'
},
{
sort_order: 320,
chapter: '7.A',
title: 'Sécurité LRFP - risque de basculement',
details:
"Si hauteur > 3 x profondeur: générer la ferrure murale obligatoire (Réf 988.98.010) et lever une alerte UI."
},
{
sort_order: 330,
chapter: '7.A',
title: 'Éclairage LED (NIN)',
details:
'Le transformateur ne doit pas être enfermé derrière le dos.\n' +
'Usinage profilés LED: rainure 14x9 mm + perçage traversant Ø8 mm en extrémité.'
},
{
sort_order: 340,
chapter: '7.A',
title: 'Accès technique et pièces humides',
details:
'Option "caisson amovible": découpe fenêtre dans le dos 8 mm selon cotes.\n' +
'Pièces humides: imposer colle PU étanche pour chants ABS et électricité IP44.'
},
{
sort_order: 350,
chapter: '7.B',
title: 'E-commerce Suisse - conformité légale',
details:
'OChim: matériaux codés E1/CARB2 + badge "Swiss Quality / Faible émission E1".\n' +
'ODoB: stocker Nom commercial, Essence (nom latin), Pays, Label et afficher sur facture.\n' +
'OIP: affichage prix TTC 8.1% en direct.\n' +
'TAR: si électronique incluse, afficher "TVA et TAR incluses".\n' +
'LCD: afficher Impressum complet en pied de page.\n' +
'PCI-DSS: passerelle certifiée (Datatrans/Twint/Stripe), aucune donnée bancaire hébergée par APEX.'
}
];
async function ensureEngineeringRulesTable() {
await run(`CREATE TABLE IF NOT EXISTS engineering_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sort_order INTEGER NOT NULL,
chapter TEXT NOT NULL,
title TEXT NOT NULL,
details TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`);
}
export async function ensureEngineeringRulesSeed() {
await ensureEngineeringRulesTable();
const { rows } = await pool.query('SELECT COUNT(*) AS total FROM engineering_rules');
const total = Number(rows?.[0]?.total || 0);
if (total > 0) return;
for (const rule of DEFAULT_ENGINEERING_RULES) {
await run(
'INSERT INTO engineering_rules (sort_order, chapter, title, details) VALUES (?, ?, ?, ?)',
[rule.sort_order, rule.chapter, rule.title, rule.details]
);
}
}
export async function getEngineeringRules(req, res) {
try {
await ensureEngineeringRulesSeed();
const { rows } = await pool.query(
'SELECT id, sort_order, chapter, title, details, created_at, updated_at FROM engineering_rules ORDER BY sort_order ASC, id ASC'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function createEngineeringRule(req, res) {
try {
await ensureEngineeringRulesTable();
const { sort_order, chapter, title, details } = req.body || {};
if (!chapter || !title || !details) {
return res.status(400).json({ error: 'chapter, title et details sont requis' });
}
const normalizedSort = Number.isFinite(Number(sort_order)) ? Number(sort_order) : 9999;
await run(
'INSERT INTO engineering_rules (sort_order, chapter, title, details) VALUES (?, ?, ?, ?)',
[normalizedSort, String(chapter).trim(), String(title).trim(), String(details).trim()]
);
const { rows } = await pool.query(
'SELECT id, sort_order, chapter, title, details, created_at, updated_at FROM engineering_rules ORDER BY id DESC LIMIT 1'
);
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function updateEngineeringRule(req, res) {
try {
await ensureEngineeringRulesTable();
const { id } = req.params;
const { sort_order, chapter, title, details } = req.body || {};
const existing = await pool.query('SELECT id FROM engineering_rules WHERE id = ?', [id]);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Règle non trouvée' });
}
if (!chapter || !title || !details) {
return res.status(400).json({ error: 'chapter, title et details sont requis' });
}
const normalizedSort = Number.isFinite(Number(sort_order)) ? Number(sort_order) : 9999;
await run(
`UPDATE engineering_rules
SET sort_order = ?, chapter = ?, title = ?, details = ?, updated_at = datetime('now')
WHERE id = ?`,
[normalizedSort, String(chapter).trim(), String(title).trim(), String(details).trim(), id]
);
const { rows } = await pool.query(
'SELECT id, sort_order, chapter, title, details, created_at, updated_at FROM engineering_rules WHERE id = ?',
[id]
);
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function deleteEngineeringRule(req, res) {
try {
await ensureEngineeringRulesTable();
const { id } = req.params;
const existing = await pool.query('SELECT id FROM engineering_rules WHERE id = ?', [id]);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Règle non trouvée' });
}
await run('DELETE FROM engineering_rules WHERE id = ?', [id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+322 -1
View File
@@ -1,4 +1,59 @@
import pool, { run } from '../db/index.js';
import { sendSupplierVouchers, buildSupplierVoucherPayload } from '../services/supplierVoucher.js';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { generateCuttingList, formatCuttingListForExport } from '../services/cuttingEngine.js';
import { generatePackingList } from '../services/packingEngine.js';
import { generateAllPlans } from '../services/planGenerator.js';
import { generateOrderDossierPdf } from '../services/exportPdf.js';
import { validateConfig } from '../services/configValidation.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const planExportsRoot = path.join(__dirname, '..', 'data', 'plan-exports');
function ensurePlanExportDir(orderId) {
const dir = path.join(planExportsRoot, `order-${orderId}`);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
async function saveOrderPlanExportRecord(orderId, exportType, content, mimeType = 'text/plain', fileExtension = 'txt') {
const safeExt = (fileExtension || 'txt').toLowerCase().replace(/[^a-z0-9]/g, '') || 'txt';
const safeType = String(exportType || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
if (!safeType) throw new Error('exportType invalide.');
if (!(typeof content === 'string' || Buffer.isBuffer(content))) throw new Error('content requis.');
if ((typeof content === 'string' && content.length === 0) || (Buffer.isBuffer(content) && content.length === 0)) {
throw new Error('content requis.');
}
const exportDir = ensurePlanExportDir(orderId);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `${stamp}-${safeType}.${safeExt}`;
const absolutePath = path.join(exportDir, fileName);
const relativePath = path.join('plan-exports', `order-${orderId}`, fileName).replace(/\\/g, '/');
if (Buffer.isBuffer(content)) {
fs.writeFileSync(absolutePath, content);
} else {
fs.writeFileSync(absolutePath, content, 'utf8');
}
await run(
`INSERT INTO plan_exports (order_id, export_type, file_name, file_path, mime_type)
VALUES ($1, $2, $3, $4, $5)`,
[orderId, safeType, fileName, relativePath, mimeType || 'text/plain']
);
const { rows } = await pool.query(
`SELECT id, order_id, export_type, file_name, mime_type, created_at
FROM plan_exports
WHERE order_id = $1
ORDER BY id DESC
LIMIT 1`,
[orderId]
);
return rows[0];
}
export async function getOrders(req, res) {
try {
@@ -14,14 +69,280 @@ export async function getOrders(req, res) {
export async function postOrder(req, res) {
try {
const { config, totalChf, email, name, phone, notes } = req.body || {};
const settingsRows = await pool.query('SELECT key, value FROM settings');
const settings = Object.fromEntries((settingsRows.rows || []).map((r) => [r.key, r.value]));
const validation = validateConfig(config || {}, settings);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
await run(
`INSERT INTO orders (config, total_chf, email, name, phone, notes)
VALUES ($1, $2, $3, $4, $5, $6)`,
[JSON.stringify(config || {}), totalChf ?? null, email ?? null, name ?? null, phone ?? null, notes ?? null]
);
const { rows } = await pool.query('SELECT id, created_at FROM orders ORDER BY id DESC LIMIT 1');
res.status(201).json(rows[0]);
const order = rows[0];
// Fire-and-forget: send supplier vouchers without blocking the HTTP response
sendSupplierVouchers(order.id, config || {}, name ?? '', order.created_at).catch(err => {
console.error('[supplier-voucher] Email error:', err.message);
});
res.status(201).json(order);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function getOrderSupplierVouchers(req, res) {
try {
const { id } = req.params;
const { rows } = await pool.query(
'SELECT id, config, name, created_at FROM orders WHERE id = $1 LIMIT 1',
[id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Commande introuvable.' });
}
const order = rows[0];
let config = {};
try {
config = typeof order.config === 'string' ? JSON.parse(order.config) : (order.config || {});
} catch {
config = {};
}
const suppliers = await buildSupplierVoucherPayload(config);
return res.json({
orderId: order.id,
customerName: order.name || null,
createdAt: order.created_at,
suppliers
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function listOrderPlanExports(req, res) {
try {
const { id } = req.params;
const { rows: orderRows } = await pool.query('SELECT id FROM orders WHERE id = $1 LIMIT 1', [id]);
if (orderRows.length === 0) {
return res.status(404).json({ error: 'Commande introuvable.' });
}
const { rows } = await pool.query(
`SELECT id, order_id, export_type, file_name, mime_type, created_at
FROM plan_exports
WHERE order_id = $1
ORDER BY created_at DESC, id DESC`,
[id]
);
return res.json(rows);
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function createOrderPlanExport(req, res) {
try {
const { id } = req.params;
const { exportType, content, mimeType, fileExtension } = req.body || {};
if (!exportType || typeof exportType !== 'string') {
return res.status(400).json({ error: 'exportType requis.' });
}
if (typeof content !== 'string' || content.length === 0) {
return res.status(400).json({ error: 'content requis.' });
}
const { rows: orderRows } = await pool.query('SELECT id FROM orders WHERE id = $1 LIMIT 1', [id]);
if (orderRows.length === 0) {
return res.status(404).json({ error: 'Commande introuvable.' });
}
const created = await saveOrderPlanExportRecord(id, exportType, content, mimeType, fileExtension);
return res.status(201).json(created);
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function generateOrderPlanExports(req, res) {
try {
const { id } = req.params;
const { rows } = await pool.query(
'SELECT id, config FROM orders WHERE id = $1 LIMIT 1',
[id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Commande introuvable.' });
}
let config = {};
try {
config = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : (rows[0].config || {});
} catch {
config = {};
}
if (!config || typeof config !== 'object' || !config.widthMm || !config.heightMm || !config.depthMm) {
return res.status(400).json({ error: 'Configuration commande invalide pour générer les plans.' });
}
{
const settingsRows = await pool.query('SELECT key, value FROM settings');
const settings = Object.fromEntries((settingsRows.rows || []).map((r) => [r.key, r.value]));
const validation = validateConfig(config, settings);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
}
const modules = Array.isArray(config.modules) ? config.modules : [];
const colors = (config.colors && typeof config.colors === 'object') ? config.colors : {};
const cuttingList = generateCuttingList(config, colors, modules);
const packing = generatePackingList(cuttingList);
const textContent = formatCuttingListForExport(cuttingList);
const pdfBuffer = await generateOrderDossierPdf({
orderId: id,
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
installationType: config.installationType,
doorType: config.doorType,
moduleCount: modules.length
},
cuttingList,
packing
});
const [textExport, pdfExport] = await Promise.all([
saveOrderPlanExportRecord(id, 'cutting_list_text', textContent, 'text/plain; charset=utf-8', 'txt'),
saveOrderPlanExportRecord(id, 'production_dossier_pdf', pdfBuffer, 'application/pdf', 'pdf')
]);
return res.status(201).json({ created: [textExport, pdfExport] });
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function getOrderProductionDetails(req, res) {
try {
const { id } = req.params;
const { rows } = await pool.query(
'SELECT id, config, total_chf, name, created_at FROM orders WHERE id = $1 LIMIT 1',
[id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Commande introuvable.' });
}
let config = {};
try {
config = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : (rows[0].config || {});
} catch {
config = {};
}
if (!config || typeof config !== 'object' || !config.widthMm || !config.heightMm || !config.depthMm) {
return res.status(400).json({ error: 'Configuration commande invalide pour générer le dossier.' });
}
{
const settingsRows = await pool.query('SELECT key, value FROM settings');
const settings = Object.fromEntries((settingsRows.rows || []).map((r) => [r.key, r.value]));
const validation = validateConfig(config, settings);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
}
const modules = Array.isArray(config.modules) ? config.modules : [];
const colors = (config.colors && typeof config.colors === 'object') ? config.colors : {};
const plans = generateAllPlans(config, modules, colors);
const cuttingList = generateCuttingList(config, colors, modules);
const packing = generatePackingList(cuttingList);
return res.json({
orderId: rows[0].id,
customerName: rows[0].name || null,
totalChf: rows[0].total_chf,
createdAt: rows[0].created_at,
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
plinthHeightMm: config.plinthHeightMm,
installationType: config.installationType,
doorType: config.doorType,
slidingPanelsCount: config.slidingPanelsCount,
moduleCount: modules.length
},
plans,
cuttingList,
packing
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function downloadOrderPlanExport(req, res) {
try {
const { id, exportId } = req.params;
const disposition = String(req.query.disposition || 'attachment').toLowerCase();
const headerDisposition = disposition === 'inline' ? 'inline' : 'attachment';
const { rows } = await pool.query(
`SELECT id, order_id, file_name, file_path, mime_type
FROM plan_exports
WHERE id = $1 AND order_id = $2
LIMIT 1`,
[exportId, id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Export introuvable.' });
}
const record = rows[0];
const absolutePath = path.join(__dirname, '..', 'data', record.file_path);
if (!fs.existsSync(absolutePath)) {
return res.status(404).json({ error: 'Fichier export introuvable.' });
}
res.setHeader('Content-Type', record.mime_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `${headerDisposition}; filename="${record.file_name}"`);
return res.sendFile(absolutePath);
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function deleteOrderPlanExport(req, res) {
try {
const { id, exportId } = req.params;
const { rows } = await pool.query(
`SELECT id, order_id, file_path
FROM plan_exports
WHERE id = $1 AND order_id = $2
LIMIT 1`,
[exportId, id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Export introuvable.' });
}
const record = rows[0];
const absolutePath = path.join(__dirname, '..', 'data', record.file_path);
if (fs.existsSync(absolutePath)) {
fs.unlinkSync(absolutePath);
}
await run('DELETE FROM plan_exports WHERE id = $1 AND order_id = $2', [exportId, id]);
return res.status(204).end();
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
+176 -6
View File
@@ -1,8 +1,13 @@
/**
* Plans and cutting list routes
* Plans, cutting list, and packing routes
*/
import { generateAllPlans, generatePDFHtml } from '../services/planGenerator.js';
import { generateCuttingList, formatCuttingListForExport } from '../services/cuttingEngine.js';
import { generatePackingList } from '../services/packingEngine.js';
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';
/**
* POST /api/plans/generate
@@ -15,9 +20,13 @@ export async function generatePlansRoute(req, res) {
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 plans = generateAllPlans(config, modules, colors);
res.json({ success: true, plans });
res.json({ success: true, plans, warnings: validation.warnings });
} catch (error) {
console.error('Plan generation error:', error);
@@ -27,7 +36,7 @@ export async function generatePlansRoute(req, res) {
/**
* POST /api/plans/cutting-list
* Generate cutting list for a configuration
* Generate cutting list + packing list for a configuration
*/
export async function generateCuttingListRoute(req, res) {
try {
@@ -36,9 +45,15 @@ export async function generateCuttingListRoute(req, res) {
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);
res.json({ success: true, cuttingList });
const packing = generatePackingList(cuttingList);
res.json({ success: true, cuttingList, packing, warnings: validation.warnings });
} catch (error) {
console.error('Cutting list error:', error);
@@ -57,6 +72,10 @@ export async function exportCuttingListText(req, res) {
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 textContent = formatCuttingListForExport(cuttingList);
@@ -73,7 +92,7 @@ export async function exportCuttingListText(req, res) {
/**
* POST /api/plans/export-pdf
* Export plans and cutting list as HTML (for PDF conversion)
* Export plans, cutting list, and packing as HTML (for PDF conversion)
*/
export async function exportPDFHtml(req, res) {
try {
@@ -82,9 +101,14 @@ export async function exportPDFHtml(req, res) {
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 html = generatePDFHtml(config, modules, cuttingList);
const packing = generatePackingList(cuttingList);
const html = generatePDFHtml(config, modules, cuttingList, packing);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
@@ -94,3 +118,149 @@ export async function exportPDFHtml(req, res) {
res.status(500).json({ error: error.message || 'Erreur d\'export PDF' });
}
}
/**
* POST /api/plans/export-cnc
* Export CNC program as standard ISO G-code (.nc)
*/
export async function exportCncRoute(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 cnc = generateCncProgram(config, modules, colors);
res.setHeader('Content-Type', cnc.mimeType);
res.setHeader('Content-Disposition', `attachment; filename="${cnc.fileName}"`);
res.send(cnc.content);
} catch (error) {
console.error('Export CNC error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'export CNC' });
}
}
/**
* POST /api/plans/export-mpr
* Archive ZIP avec un fichier .mpr Homag/Woodwop par panneau.
*/
export async function exportMprRoute(req, res) {
try {
const { config, modules = [], colors = {}, orderReference = '', stackListKm = '', mprMaterialArticle = '', mprGroupIndex = 1 } = 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 gi = Number(mprGroupIndex);
const groupIndex = Number.isFinite(gi) && gi >= 1 ? Math.floor(gi) : 1;
const { buffer, fileName, mimeType } = await generateMprZipBuffer({
config,
modules,
colors,
orderReference,
stackListKm: typeof stackListKm === 'string' ? stackListKm : String(stackListKm || '01'),
defaultMaterialArticle: typeof mprMaterialArticle === 'string' ? mprMaterialArticle : '',
groupIndex
});
const safeName =
/^[\w\-_.]+$/.test(String(fileName)) ? fileName : 'apex-mpr.zip';
res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
res.send(Buffer.from(buffer));
} catch (error) {
console.error('Export MPR error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'export MPR' });
}
}
/**
* POST /api/plans/export-mpr-piece
* Export MPR pour une ligne de la liste de débitage (fichier unique ou mini-ZIP si plusieurs exemplaires).
*/
export async function exportMprPieceRoute(req, res) {
try {
const {
config,
modules = [],
colors = {},
pieceId,
instanceIndex,
orderReference = '',
stackListKm = '',
mprMaterialArticle = '',
mprGroupIndex = 1
} = req.body || {};
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
if (!pieceId || typeof pieceId !== 'string') {
return res.status(400).json({ error: 'pieceId requis' });
}
const validation = validateConfig(config);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
const gi = Number(mprGroupIndex);
const groupIndex = Number.isFinite(gi) && gi >= 1 ? Math.floor(gi) : 1;
const { buffer, fileName, mimeType } = await generateMprPieceDownload({
config,
modules,
colors,
pieceId,
instanceIndex,
orderReference,
stackListKm: typeof stackListKm === 'string' ? stackListKm : String(stackListKm || '01'),
defaultMaterialArticle: typeof mprMaterialArticle === 'string' ? mprMaterialArticle : '',
groupIndex
});
const safeName = /^[\w\-_.]+$/.test(String(fileName)) ? fileName : 'piece.mpr';
res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
res.send(Buffer.from(buffer));
} catch (error) {
console.error('Export MPR piece error:', error);
const status = /introuvable|requis|invalide/i.test(error.message || '') ? 400 : 500;
res.status(status).json({ error: error.message || 'Erreur d\'export MPR pièce' });
}
}
/**
* POST /api/plans/hardware-list
* Generate bill of materials for hardware/quincaillerie with prices (best-effort).
*/
export async function generateHardwareListRoute(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 result = await generateHardwareList({ config, modules, colors });
res.json({ success: true, ...result, warnings: validation.warnings });
} catch (error) {
console.error('Hardware list error:', error);
res.status(500).json({ error: error.message || 'Erreur de génération de la liste de quincaillerie' });
}
}
+31 -3
View File
@@ -1,13 +1,41 @@
import pool from '../db/index.js';
import { run } from '../db/index.js';
function parseSettingValue(raw) {
if (typeof raw !== 'string') return raw;
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
if (raw === 'true') return true;
if (raw === 'false') return false;
return raw;
}
export async function getSettings(req, res) {
try {
const { rows } = await pool.query('SELECT key, value FROM settings');
const obj = {};
for (const r of rows) {
let v = r.value;
if (typeof v === 'string' && /^\d+$/.test(v)) v = Number(v);
obj[r.key] = v;
obj[r.key] = parseSettingValue(r.value);
}
res.json(obj);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function updateSettings(req, res) {
try {
const updates = req.body || {};
for (const [key, value] of Object.entries(updates)) {
await run(
'INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
[key, String(value)]
);
}
// Return updated settings
const { rows } = await pool.query('SELECT key, value FROM settings');
const obj = {};
for (const r of rows) {
obj[r.key] = parseSettingValue(r.value);
}
res.json(obj);
} catch (err) {
+55
View File
@@ -0,0 +1,55 @@
import pool, { run } from '../db/index.js';
export async function getSuppliers(req, res) {
try {
const { rows } = await pool.query(
'SELECT id, code, name, email, type, active, created_at FROM suppliers ORDER BY name'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function createSupplier(req, res) {
try {
const { code, name, email, type, active = 1 } = req.body || {};
if (!code || !name || !email || !type) {
return res.status(400).json({ error: 'code, name, email et type sont requis.' });
}
await run(
'INSERT INTO suppliers (code, name, email, type, active) VALUES ($1, $2, $3, $4, $5)',
[code, name, email, type, active]
);
const { rows } = await pool.query('SELECT * FROM suppliers ORDER BY id DESC LIMIT 1');
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function updateSupplier(req, res) {
try {
const { id } = req.params;
const { name, email, type, active } = req.body || {};
await run(
'UPDATE suppliers SET name = $1, email = $2, type = $3, active = $4 WHERE id = $5',
[name, email, type, active, id]
);
const { rows } = await pool.query('SELECT * FROM suppliers WHERE id = $1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Fournisseur introuvable.' });
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function deleteSupplier(req, res) {
try {
const { id } = req.params;
await run('DELETE FROM suppliers WHERE id = $1', [id]);
res.status(204).end();
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+197 -33
View File
@@ -1,7 +1,5 @@
/**
* Sync routes for Formex crawler
*/
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
import { syncHaefeleByUrls } from '../services/haefeleCrawler.js';
import { getSchedulerStatus } from '../services/scheduler.js';
import pool, { run } from '../db/index.js';
@@ -58,9 +56,10 @@ export async function streamFormexSync(req, res) {
// Client disconnected, cleanup if needed
});
const force = req.query.force === '1' || req.query.force === 'true';
try {
// Run sync with emitter
await sync(emit);
await sync(emit, { force });
} catch (err) {
emit({ type: 'error', error: err.message });
}
@@ -69,6 +68,144 @@ export async function streamFormexSync(req, res) {
res.end();
}
/**
* POST /api/sync/haefele
* Trigger Häfele sync for a provided list of product URLs
* Body: { urls: string[] }
*/
export async function triggerHaefeleSync(req, res) {
try {
const urls = Array.isArray(req.body?.urls) ? req.body.urls : [];
const credentials = {
username: req.body?.username,
password: req.body?.password,
customerNo: req.body?.customerNo
};
if (urls.length === 0) {
return res.status(400).json({ error: 'Le champ urls (tableau) est requis.' });
}
const result = await syncHaefeleByUrls(urls, () => {}, { credentials });
if (!result.success) {
return res.status(500).json({
success: false,
error: result.error || 'Sync Häfele échouée',
itemsCount: result.itemsCount || 0
});
}
res.json({
success: true,
message: `Synchronisation Häfele terminée: ${result.itemsCount} produits`,
itemsCount: result.itemsCount,
failedCount: result.failedCount || 0,
duration: result.duration
});
} catch (err) {
res.status(500).json({
success: false,
error: err.message
});
}
}
/**
* GET /api/sync/haefele/stream
* SSE endpoint for Häfele sync progress (URL list from query params)
* Query:
* - url=... (repeatable)
* - urls=... (newline/comma/semicolon separated fallback)
*/
export async function streamHaefeleSync(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 repeatable = req.query.url;
let urls = [];
if (Array.isArray(repeatable)) {
urls = repeatable;
} else if (typeof repeatable === 'string' && repeatable.trim()) {
urls = [repeatable];
} else if (typeof req.query.urls === 'string') {
urls = req.query.urls.split(/[\n,;]+/g);
}
try {
await syncHaefeleByUrls(urls, emit);
} catch (err) {
emit({ type: 'error', error: err.message });
}
res.end();
}
/**
* GET /api/haefele/materials
* List Häfele materials that were fetched/synced.
*/
export async function getHaefeleMaterials(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 = 'haefele') OR collection = 'Hafele' OR formex_code LIKE 'HAF-%')`
];
const params = [];
if (search) {
whereClauses.push('(name LIKE ? OR formex_code LIKE ? OR formex_url 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, category, thickness_mm, price_chf, price_unit, image_url, formex_url, active, last_synced_at
FROM formex_materials
${whereSQL}
ORDER BY last_synced_at DESC, id DESC
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
@@ -98,47 +235,50 @@ export function getStatus(req, res) {
*/
export async function getFormexMaterials(req, res) {
try {
const { search, collection, category, active, page = '1', limit = '25' } = req.query;
const { search, collection, category, active, supplier, page = '1', limit = '25' } = req.query;
const pageNum = Math.max(1, parseInt(page, 10));
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10)));
const offset = (pageNum - 1) * limitNum;
// Build query with optional filters
let whereClauses = [];
let params = [];
if (search) {
whereClauses.push('(name LIKE ? OR formex_code LIKE ?)');
whereClauses.push('(m.name LIKE ? OR m.formex_code LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (collection) {
whereClauses.push('collection = ?');
whereClauses.push('m.collection = ?');
params.push(collection);
}
if (category) {
whereClauses.push('category = ?');
whereClauses.push('m.category = ?');
params.push(category);
}
// For configurator: only active materials
if (active === '1' || active === 'true') {
whereClauses.push('active = 1');
whereClauses.push('m.active = 1');
}
if (supplier) {
whereClauses.push('s.code = ?');
params.push(supplier);
}
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
// Get total count
const countResult = await pool.query(
`SELECT COUNT(*) as total FROM formex_materials ${whereSQL}`,
`SELECT COUNT(*) as total FROM formex_materials m LEFT JOIN suppliers s ON m.supplier_id = s.id ${whereSQL}`,
params
);
const total = countResult.rows[0]?.total || 0;
// Get paginated results
const { rows } = await pool.query(
`SELECT * FROM formex_materials ${whereSQL} ORDER BY collection, name LIMIT ? OFFSET ?`,
`SELECT m.*, s.code AS supplier_code, s.name AS supplier_name
FROM formex_materials m LEFT JOIN suppliers s ON m.supplier_id = s.id
${whereSQL} ORDER BY m.collection, m.name LIMIT ? OFFSET ?`,
[...params, limitNum, offset]
);
@@ -202,14 +342,14 @@ export async function getFormexMaterial(req, res) {
*/
export async function createFormexMaterial(req, res) {
try {
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
image_url, formex_url, price_chf, price_unit, active } = req.body;
// Validation
if (!formex_code || !name || !collection) {
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
}
// Check for duplicate code
const existing = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[formex_code]
@@ -219,13 +359,17 @@ export async function createFormexMaterial(req, res) {
}
await run(
`INSERT INTO formex_materials (formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1]
`INSERT INTO formex_materials (formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
image_url, formex_url, price_chf, price_unit, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
diameter_mm ?? null, length_mm ?? null,
bore_diameter_mm ?? null, bore_depth_base_mm ?? null, bore_depth_piece_mm ?? null,
image_url || null, formex_url || null, price_chf ?? null, price_unit || null,
active !== undefined ? (active ? 1 : 0) : 1]
);
// Get the created material
const { rows } = await pool.query(
'SELECT * FROM formex_materials WHERE formex_code = ?',
[formex_code]
@@ -244,9 +388,10 @@ export async function createFormexMaterial(req, res) {
export async function updateFormexMaterial(req, res) {
try {
const { id } = req.params;
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
image_url, formex_url, price_chf, price_unit, active } = req.body;
// Check if material exists
const existing = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
@@ -255,12 +400,10 @@ export async function updateFormexMaterial(req, res) {
return res.status(404).json({ error: 'Matériau non trouvé' });
}
// Validation
if (!formex_code || !name || !collection) {
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
}
// Check for duplicate code (if changed)
if (formex_code !== existing.rows[0].formex_code) {
const duplicate = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ? AND id != ?',
@@ -273,14 +416,18 @@ export async function updateFormexMaterial(req, res) {
await run(
`UPDATE formex_materials
SET formex_code = ?, name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
image_url = ?, formex_url = ?, price_chf = ?, active = ?
SET formex_code = ?, name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
diameter_mm = ?, length_mm = ?,
bore_diameter_mm = ?, bore_depth_base_mm = ?, bore_depth_piece_mm = ?,
image_url = ?, formex_url = ?, price_chf = ?, price_unit = ?, active = ?
WHERE id = ?`,
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1, id]
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
diameter_mm ?? null, length_mm ?? null,
bore_diameter_mm ?? null, bore_depth_base_mm ?? null, bore_depth_piece_mm ?? null,
image_url || null, formex_url || null, price_chf ?? null, price_unit || null,
active !== undefined ? (active ? 1 : 0) : 1, id]
);
// Get the updated material
const { rows } = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
@@ -317,6 +464,23 @@ export async function deleteFormexMaterial(req, res) {
}
}
/**
* GET /api/formex/suppliers
* Get suppliers that have at least one material linked
*/
export async function getFormexSuppliers(req, res) {
try {
const { rows } = await pool.query(
`SELECT DISTINCT s.code, s.name FROM suppliers s
INNER JOIN formex_materials m ON m.supplier_id = s.id
ORDER BY s.name`
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/formex/collections
* Get distinct collection names for filter dropdown
+24
View File
@@ -0,0 +1,24 @@
import { query } from '../db/index.js';
async function main() {
const tables = (await query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")).rows.map(
(r) => r.name
);
console.log('tables', tables);
const counts = ['formex_materials', 'formex_variants', 'formex_dimensions', 'finishes', 'products', 'suppliers'];
for (const t of counts) {
try {
const r = await query(`SELECT COUNT(*) as c FROM ${t}`);
console.log(t, r.rows[0]?.c);
} catch {
console.log(t, '(missing)');
}
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+38
View File
@@ -0,0 +1,38 @@
import { query } from '../db/index.js';
async function main() {
const countSql = `
SELECT COUNT(*) as c
FROM formex_materials
WHERE (
supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele')
OR collection = 'Hafele'
OR formex_code LIKE 'HAF-%'
)
`;
const count = await query(countSql);
console.log('haefele_count', count.rows[0]?.c ?? 0);
const sampleSql = `
SELECT id, formex_code, name, collection, supplier_id, last_synced_at
FROM formex_materials
WHERE (
supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele')
OR collection = 'Hafele'
OR formex_code LIKE 'HAF-%'
)
ORDER BY last_synced_at DESC, id DESC
LIMIT 10
`;
const sample = await query(sampleSql);
console.log('haefele_sample', sample.rows);
const syncCols = await query("PRAGMA table_info(sync_logs)");
console.log('sync_logs_cols', syncCols.rows.map((c) => c.name));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+110
View File
@@ -0,0 +1,110 @@
import { generateCuttingList } from './cuttingEngine.js';
import { collectHoleSpecsForPiece } from './holeSpecifications.js';
import { GROOVE, getBackGroovePlanOrientation } from '../../shared/manufacturing.js';
function toSafeNumber(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function toLabel(value) {
return String(value || '')
.replace(/[^a-zA-Z0-9_\-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.toUpperCase();
}
function emitPanelOutline(lines, widthMm, heightMm, safeZ, cutZ, feed) {
lines.push(`G0 Z${safeZ.toFixed(3)}`);
lines.push('G0 X0.000 Y0.000');
lines.push(`G1 Z${cutZ.toFixed(3)} F${Math.round(feed)}`);
lines.push(`G1 X${widthMm.toFixed(3)} Y0.000`);
lines.push(`G1 X${widthMm.toFixed(3)} Y${heightMm.toFixed(3)}`);
lines.push(`G1 X0.000 Y${heightMm.toFixed(3)}`);
lines.push('G1 X0.000 Y0.000');
lines.push(`G0 Z${safeZ.toFixed(3)}`);
}
function emitHole(lines, { xMm, yMm, diameterMm = 5, depthMm = 10, type = 'generic', face = 'face', end = null }) {
let tail = `TYPE=${String(type)} FACE=${face}`;
if (end) tail += ` END=${end}`;
lines.push(
`; HOLE X=${Number(xMm).toFixed(3)} Y=${Number(yMm).toFixed(3)} D=${Number(diameterMm).toFixed(3)} Z=${Number(depthMm).toFixed(3)} ${tail}`
);
}
function emitPieceHoleComments(lines, piece, widthMm, heightMm, partIndex = 0) {
const { specs, thicknessMm: _tm } = collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex);
for (const spec of specs) {
emitHole(lines, spec);
}
const m = piece?.machining || {};
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
if (m.groove && orient) {
const gw = Number(m.groove.width ?? GROOVE.back.width);
const gd = Number(m.groove.depth ?? GROOVE.back.depth);
const ax = Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge);
lines.push(`; GROOVE BACK W=${gw.toFixed(3)} D=${gd.toFixed(3)} AXIS=${ax.toFixed(3)} ORIENT=${orient}`);
}
}
export function generateCncProgram(config, modules = [], colors = {}) {
const cuttingList = generateCuttingList(config, colors, modules);
const safeZ = 10;
const cutDepth = 3;
const feedCut = 1800;
const lines = [
'%',
'; APEX CNC ISO (G-code) - v1',
`; Generated: ${new Date().toISOString()}`,
`; Config: ${config.widthMm}x${config.heightMm}x${config.depthMm} mm`,
'G21 ; metric units',
'G90 ; absolute coordinates',
'G17 ; XY plane',
`G0 Z${safeZ.toFixed(3)}`
];
let programPartCount = 0;
for (const piece of cuttingList.pieces || []) {
const widthMm = toSafeNumber(piece.rawCutWidthMm, piece.widthMm);
const heightMm = toSafeNumber(piece.rawCutHeightMm, piece.heightMm);
const qty = Math.max(1, Math.round(toSafeNumber(piece.quantity, 1)));
const thickness = toSafeNumber(piece.thicknessMm, 19);
const pieceName = toLabel(piece.description || piece.elementType || 'PIECE');
const targetDepth = -Math.min(cutDepth, Math.max(1, thickness * 0.25));
for (let i = 0; i < qty; i++) {
programPartCount += 1;
lines.push('');
lines.push(`; --- PART ${programPartCount}: ${pieceName} #${i + 1} ---`);
lines.push(`; SIZE_RAW: X=${widthMm.toFixed(3)} Y=${heightMm.toFixed(3)} T=${thickness.toFixed(3)}`);
emitPieceHoleComments(lines, piece, widthMm, heightMm, i);
emitPanelOutline(lines, widthMm, heightMm, safeZ, targetDepth, feedCut);
}
}
lines.push('');
lines.push(`G0 Z${safeZ.toFixed(3)}`);
lines.push('G0 X0.000 Y0.000');
lines.push('M30');
lines.push('%');
return {
fileName: 'apex-program.nc',
mimeType: 'text/plain; charset=utf-8',
content: lines.join('\n'),
metadata: {
generatedAt: new Date().toISOString(),
parts: programPartCount
}
};
}
export default {
generateCncProgram
};
+97
View File
@@ -0,0 +1,97 @@
import { DRAWERS, NICHE } from '../../shared/manufacturing.js';
export function validateConfig(config = {}, settings = {}) {
const errors = [];
const warnings = [];
const installationType = config.installationType || 'free';
const doorType = config.doorType || 'hinged';
const widthMm = Number(config.widthMm || 0);
const heightMm = Number(config.heightMm || 0);
const depthMm = Number(config.depthMm || 0);
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);
if (doorType === 'sliding' && !allowSliding) {
errors.push({ field: 'doorType', message: 'Portes coulissantes désactivées.' });
}
if (installationType === 'niche') {
const nm = config.nicheMeasurements || {};
const minW = Math.min(nm.widthTopMm || widthMm, nm.widthMidMm || widthMm, nm.widthBottomMm || widthMm);
const minD = Math.min(nm.depthTopMm || depthMm, nm.depthMidMm || depthMm, nm.depthBottomMm || depthMm);
const minH = Math.min(nm.heightLeftMm || heightMm, nm.heightRightMm || heightMm);
const expectedW = Math.max(400, minW - sanitaryGapMm * 2);
const expectedD = Math.max(400, minD - sanitaryGapMm * 2);
const expectedH = Math.max(1800, minH - sanitaryGapMm * 2);
if (Math.abs(expectedW - widthMm) > 2 || Math.abs(expectedD - depthMm) > 2 || Math.abs(expectedH - heightMm) > 2) {
errors.push({ field: 'nicheMeasurements', message: 'Dimensions niche incohérentes avec les mesures mini et vide sanitaire.' });
}
}
const drawerDepthMin = Number(settings.drawer_depth_min_mm || DRAWERS.minDepth);
const drawerDepthMax = Number(settings.drawer_depth_max_mm || DRAWERS.maxDepth);
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 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;
return Math.max(350, steppedMm - depthReductionMm);
};
for (const [index, m] of modules.entries()) {
const isDrawerCol = (m.drawers || 0) > 0 || m.type === 'tiroirs';
if (isDrawerCol && Number(m.widthMm || 0) > DRAWERS.maxColumnWidth) {
errors.push({ field: `modules[${index}]`, message: `Colonne tiroirs > ${DRAWERS.maxColumnWidth} mm.` });
}
if (m.hasRod && heightMm < 1200) {
errors.push({ field: `modules[${index}]`, message: 'Tringle autorisée à partir de 1200 mm de hauteur armoire.' });
}
}
/**
* 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 effectiveDrawerMinMm = 350;
if (
canonicalEffectiveDrawerDepthMm < effectiveDrawerMinMm ||
canonicalEffectiveDrawerDepthMm > drawerDepthMax
) {
errors.push({
field: 'effectiveDrawerDepthMm',
message: `Profondeur tiroir utile hors bornes (${effectiveDrawerMinMm}-${drawerDepthMax} mm) pour cette profondeur caisse.`
});
}
const explicitEff = Number(config.effectiveDrawerDepthMm);
if (
Number.isFinite(explicitEff) &&
explicitEff > 0 &&
Math.abs(explicitEff - canonicalEffectiveDrawerDepthMm) > 2
) {
warnings.push({
field: 'effectiveDrawerDepthMm',
message:
`Valeur stockée (${explicitEff} mm) différente du calcul attendu (${canonicalEffectiveDrawerDepthMm} mm) — le serveur utilise le calcul canonique.`
});
}
const handleAxis = Number(config.hardware?.handleBoreHeightMm || 1000);
const stdAxis = Number(settings.standard_handle_axis_mm || 1000);
const tolerance = Number(settings.standard_handle_tolerance_mm || 200);
if (handleAxis < stdAxis - tolerance || handleAxis > stdAxis + tolerance) {
warnings.push({ field: 'handleBoreHeightMm', message: 'Perçage poignée hors plage standard.' });
}
return { errors, warnings };
}
export default { validateConfig };
File diff suppressed because it is too large Load Diff
+89 -18
View File
@@ -64,40 +64,107 @@ export async function getDesignTip(config) {
/* -------------------------------------------------------------------------- */
const MATERIAL_REC_PROMPT = `Tu es un conseiller en matériaux et couleurs pour armoires sur mesure suisses haut de gamme.
À partir de la configuration actuelle et de la liste de matériaux disponibles, recommande 3 à 5 matériaux qui iraient parfaitement ensemble.
À partir de la configuration actuelle, des PRIORITÉS UTILISATEUR (échelle 1–5) et de la liste de matériaux disponibles, recommande 3 à 5 finitions.
Critères de recommandation :
- Harmonie des couleurs avec les matériaux déjà sélectionnés
- Cohérence avec le style d'armoire (dimensions, configuration)
- Tendances actuelles en design d'intérieur suisse
- Si un contexte de pièce est fourni, s'accorder avec le style de la pièce
Règles :
- Les priorités indiquent ce qui compte le PLUS pour le client (5 = très important, 1 = peu important). Tu dois PONDÉRER tes choix en conséquence (ex. : priorité « prix » élevée → privilégier des décors à bon rapport qualité-prix et l'expliquer ; priorité « qualité » élevée → insister sur la noblesse de la finition, la précision du relief, etc.).
- Si un prix indicatif [CHF] est fourni pour un matériau, utilise-le honnêtement pour le critère prix.
- Pour CHAQUE recommandation, explique CLAIREMENT POURQUOI tu proposes CE matériau : lien explicite avec les priorités du client (pas seulement « joli »).
- Remplis les champs criteria avec une phrase courte CHACUN (en français) : ce que ce décor apporte sur ce critère, au regard des priorités.
Réponds UNIQUEMENT en JSON :
Réponds UNIQUEMENT en JSON valide :
{
"recommendations": [
{ "finishId": <number>, "reason": "…raison courte en français…" }
{
"finishId": <number>,
"summary": "2 phrases max : pourquoi ce choix au regard des priorités",
"criteria": {
"price": "phrase sur le rapport prix / gamme / accessibilité",
"quality": "phrase sur la qualité de finition, rendu, sensation",
"durability": "phrase sur tenue dans le temps, entretien, résistance",
"aesthetics": "phrase sur l'harmonie couleur/style avec le projet"
},
"reason": "une ligne très courte pour infobulle (optionnel, peut résumer summary)"
}
]
}`;
function clampPriority(n, fallback = 3) {
const x = Number(n);
if (!Number.isFinite(x)) return fallback;
return Math.min(5, Math.max(1, Math.round(x)));
}
/**
* @param {object} [p] - { price, quality, durability, aesthetics } each 1–5
*/
export function normalizeRecommendationPriorities(p) {
const d = { price: 3, quality: 3, durability: 3, aesthetics: 3 };
if (!p || typeof p !== 'object') return { ...d };
return {
price: clampPriority(p.price, d.price),
quality: clampPriority(p.quality, d.quality),
durability: clampPriority(p.durability, d.durability),
aesthetics: clampPriority(p.aesthetics, d.aesthetics)
};
}
function normalizeMaterialRecommendation(rec) {
if (!rec || rec.finishId == null) return null;
const criteria = rec.criteria && typeof rec.criteria === 'object' ? rec.criteria : {};
const summary = (rec.summary || rec.reason || '').trim();
const reason = (rec.reason || summary).trim();
return {
finishId: Number(rec.finishId),
summary,
reason,
criteria: {
price: String(criteria.price || '').trim(),
quality: String(criteria.quality || '').trim(),
durability: String(criteria.durability || '').trim(),
aesthetics: String(criteria.aesthetics || '').trim()
}
};
}
function buildPrioritiesBlock(priorities) {
const p = normalizeRecommendationPriorities(priorities);
return `PRIORITÉS UTILISATEUR (1 = peu important, 5 = très important — ajuste tes recommandations et tes explications en conséquence) :
- Prix / rapport qualité-prix : ${p.price}
- Qualité perçue & finition : ${p.quality}
- Durabilité & entretien : ${p.durability}
- Esthétique & harmonie avec le projet : ${p.aesthetics}`;
}
/**
* Recommend materials that complement the current configuration
* @param {object} config - Current wardrobe configuration
* @param {Array} availableFinishes - [{ id, name, collection, code }]
* @param {Array} availableFinishes - [{ id, name, collection, code, price_chf? }]
* @param {object} [roomAnalysis] - Optional room photo analysis
* @returns {{ recommendations: Array<{ finishId: number, reason: string }> }}
* @param {object} [priorities] - { price, quality, durability, aesthetics } each 1–5
* @returns {{ recommendations: Array<{ finishId, summary, reason, criteria }> }}
*/
export async function recommendMaterials(config, availableFinishes, roomAnalysis) {
export async function recommendMaterials(config, availableFinishes, roomAnalysis, priorities) {
const client = getOpenAIClient();
const configSummary = buildConfigSummary(config);
const priBlock = buildPrioritiesBlock(priorities);
// Compact finish list (only names + ids to save tokens)
// Compact finish list (names + ids + optional price for fair "prix" explanations)
const finishList = availableFinishes
.slice(0, 80) // limit to avoid token overflow
.map(f => `${f.id}: ${f.name || f.label} (${f.collection || '?'})`)
.map((f) => {
const name = f.name || f.label || '?';
const col = f.collection || '?';
const price =
f.price_chf != null && Number.isFinite(Number(f.price_chf))
? ` — indicatif ${Number(f.price_chf).toFixed(2)} CHF`
: '';
return `${f.id}: ${name} (${col})${price}`;
})
.join('\n');
let userMessage = `CONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
let userMessage = `${priBlock}\n\nCONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
if (roomAnalysis) {
userMessage += `\n\nCONTEXTE PIÈCE:\n${JSON.stringify(roomAnalysis, null, 1)}`;
@@ -105,8 +172,8 @@ export async function recommendMaterials(config, availableFinishes, roomAnalysis
const response = await client.chat.completions.create({
model: 'gpt-4o',
temperature: 0.7,
max_tokens: 400,
temperature: 0.65,
max_tokens: 1400,
messages: [
{ role: 'system', content: MATERIAL_REC_PROMPT },
{ role: 'user', content: userMessage }
@@ -117,9 +184,13 @@ export async function recommendMaterials(config, availableFinishes, roomAnalysis
const raw = response.choices[0]?.message?.content;
try {
const parsed = JSON.parse(raw);
return { recommendations: (parsed.recommendations || []).slice(0, 5) };
const rawList = (parsed.recommendations || []).slice(0, 5);
const recommendations = rawList
.map(normalizeMaterialRecommendation)
.filter(Boolean);
return { recommendations, priorities: normalizeRecommendationPriorities(priorities) };
} catch {
return { recommendations: [] };
return { recommendations: [], priorities: normalizeRecommendationPriorities(priorities) };
}
}
+101
View File
@@ -0,0 +1,101 @@
import PDFDocument from 'pdfkit';
function addSectionTitle(doc, title) {
doc.moveDown(0.5);
doc.fontSize(14).font('Helvetica-Bold').text(title);
doc.moveDown(0.3);
doc.fontSize(10).font('Helvetica');
}
function ensurePageSpace(doc, neededHeight = 80) {
if (doc.y + neededHeight > doc.page.height - doc.page.margins.bottom) {
doc.addPage();
}
}
function keyValue(doc, key, value) {
doc.font('Helvetica-Bold').text(`${key}: `, { continued: true });
doc.font('Helvetica').text(String(value ?? '-'));
}
function tableRow(doc, cols, widths, opts = {}) {
const startX = doc.x;
const y = doc.y;
const lineHeight = opts.lineHeight || 14;
let x = startX;
for (let i = 0; i < cols.length; i++) {
const text = cols[i] == null ? '-' : String(cols[i]);
doc.text(text, x, y, {
width: widths[i],
lineBreak: false
});
x += widths[i];
}
doc.y = y + lineHeight;
}
export function generateOrderDossierPdf({ orderId, createdAt, customerName, config, cuttingList, packing }) {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 40 });
const chunks = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc.fontSize(18).font('Helvetica-Bold').text(`Dossier technique commande #${orderId}`);
doc.fontSize(10).font('Helvetica').text(`Généré le ${new Date().toLocaleString('fr-CH')}`);
if (createdAt) doc.text(`Commande du ${new Date(createdAt).toLocaleString('fr-CH')}`);
if (customerName) doc.text(`Client: ${customerName}`);
addSectionTitle(doc, 'Configuration');
keyValue(doc, 'Dimensions', `${config.widthMm} x ${config.heightMm} x ${config.depthMm} mm`);
keyValue(doc, 'Installation', config.installationType === 'niche' ? 'Encastree' : 'Libre');
keyValue(doc, 'Portes', config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes');
keyValue(doc, 'Modules', config.moduleCount || 0);
addSectionTitle(doc, 'Liste de decoupage');
const cutWidths = [170, 130, 40, 130];
tableRow(doc, ['Piece', 'L x H x Ep.', 'Qte', 'Finition'], cutWidths, { lineHeight: 16 });
doc.moveTo(doc.x, doc.y).lineTo(doc.page.width - doc.page.margins.right, doc.y).stroke();
doc.moveDown(0.2);
for (const piece of (cuttingList?.pieces || [])) {
ensurePageSpace(doc, 18);
tableRow(
doc,
[
piece.description,
`${piece.widthMm}x${piece.heightMm}x${piece.thicknessMm}`,
piece.quantity,
piece.finishLabel || '-'
],
cutWidths
);
}
addSectionTitle(doc, 'Colisage');
const packWidths = [50, 170, 70, 70];
tableRow(doc, ['Colis', 'Niveau', 'Poids', 'Pieces'], packWidths, { lineHeight: 16 });
doc.moveTo(doc.x, doc.y).lineTo(doc.page.width - doc.page.margins.right, doc.y).stroke();
doc.moveDown(0.2);
for (const box of (packing?.boxes || [])) {
ensurePageSpace(doc, 18);
tableRow(
doc,
[
box.id,
box.level || '-',
`${Number(box.totalWeightKg || 0).toFixed(1)} kg`,
box.pieces?.length || 0
],
packWidths
);
}
doc.end();
});
}
File diff suppressed because it is too large Load Diff
+851
View File
@@ -0,0 +1,851 @@
import * as cheerio from 'cheerio';
import pool, { run } from '../db/index.js';
const BASE_URL = 'https://www.haefele.ch';
const CRAWL_DELAY = parseInt(process.env.HAFELE_CRAWL_DELAY_MS || '500', 10);
const BROWSER_TIMEOUT_MS = parseInt(process.env.HAFELE_BROWSER_TIMEOUT_MS || '90000', 10);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeSpaces(value) {
return (value || '').replace(/\s+/g, ' ').trim();
}
function absoluteUrl(url) {
if (!url) return null;
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
}
function parsePriceCHF(text) {
const clean = normalizeSpaces(text)
.replace(/[\u00a0\u202f]/g, ' ')
.replace(/[’']/g, '');
const match = clean.match(/([\d]+(?:[.,][\d]+)?)\s*CHF|CHF\s*([\d]+(?:[.,][\d]+)?)/i);
if (!match) return null;
return parseFloat((match[1] || match[2]).replace(',', '.'));
}
function parseFirstMm(text) {
const match = normalizeSpaces(text).match(/(\d+(?:[.,]\d+)?)\s*mm/i);
if (!match) return null;
return parseFloat(match[1].replace(',', '.'));
}
function parseBoreDiameter(text) {
const clean = normalizeSpaces(text);
const patterns = [
/[Øø∅]\s*(\d+(?:[.,]\d+)?)\s*mm/i,
/(?:trou|per[çc]age|diam[eè]tre|boring|bohrung)\s*[:\s]*[Øø∅]?\s*(\d+(?:[.,]\d+)?)\s*mm/i,
/(\d+(?:[.,]\d+)?)\s*mm\s*(?:trou|per[çc]age|boring|bohrung)/i,
];
for (const pat of patterns) {
const m = clean.match(pat);
if (m) {
const val = parseFloat((m[1] || m[2]).replace(',', '.'));
if (val > 0 && val <= 50) return val;
}
}
return null;
}
function parseBoreDepths(specs) {
const baseKeys = [
'profondeur de perçage dans le fond',
'profondeur de perçage fond',
'profondeur perçage fond',
'profondeur percage fond',
'bohrtiefe in boden',
'bohrtiefe boden',
'drilling depth base',
'profondeur de perçage dans la partie inférieure',
];
const pieceKeys = [
'profondeur de perçage dans la pièce',
'profondeur de perçage pièce',
'profondeur perçage pièce',
'profondeur percage piece',
'profondeur de perçage dans le panneau latéral',
'profondeur de perçage dans la pièce fixée',
'bohrtiefe in seitenteil',
'bohrtiefe seitenteil',
'drilling depth piece',
'profondeur de perçage dans la partie latérale',
];
let depthBase = null;
let depthPiece = null;
for (const [key, value] of Object.entries(specs)) {
const k = key.toLowerCase();
if (!depthBase) {
for (const bk of baseKeys) {
if (k.includes(bk)) { depthBase = parseFirstMm(value); break; }
}
}
if (!depthPiece) {
for (const pk of pieceKeys) {
if (k.includes(pk)) { depthPiece = parseFirstMm(value); break; }
}
}
if (depthBase && depthPiece) break;
}
// Fallback: look for a generic "profondeur de perçage" with two values
if (!depthBase && !depthPiece) {
for (const [key, value] of Object.entries(specs)) {
if (key.toLowerCase().includes('profondeur') && key.toLowerCase().includes('per')) {
const allMm = [...value.matchAll(/(\d+(?:[.,]\d+)?)\s*mm/gi)].map(m => parseFloat(m[1].replace(',', '.')));
if (allMm.length >= 2) {
depthBase = allMm[0];
depthPiece = allMm[1];
} else if (allMm.length === 1 && !depthBase) {
depthBase = allMm[0];
}
break;
}
}
}
return { depthBase, depthPiece };
}
function isCloudflareChallenge(html = '') {
const lower = normalizeSpaces(html).toLowerCase();
return lower.includes('just a moment') || lower.includes('cloudflare') || lower.includes('cf-challenge');
}
class HaefeleSession {
constructor() {
this.cookies = new Map();
this.loggedIn = false;
}
captureCookies(response) {
let rawCookies = [];
if (typeof response.headers.getSetCookie === 'function') {
rawCookies = response.headers.getSetCookie();
} else {
const raw = response.headers.get('set-cookie');
if (raw) {
rawCookies = raw.split(/,\s*(?=[A-Za-z0-9_\-]+=)/);
}
}
for (const header of rawCookies) {
const [pair] = header.split(';');
const eqIdx = pair.indexOf('=');
if (eqIdx > 0) {
this.cookies.set(pair.slice(0, eqIdx).trim(), pair.slice(eqIdx + 1).trim());
}
}
}
cookieHeader() {
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
headers(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,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'fr-CH,fr;q=0.9,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'sec-ch-ua': '"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
...(this.cookies.size > 0 ? { Cookie: this.cookieHeader() } : {}),
...extra,
};
}
async login(emit = () => {}, credentials = {}) {
const username = credentials.username || process.env.HAFELE_USERNAME || process.env.HAFELE_USER;
const password = credentials.password || process.env.HAFELE_PASSWORD;
const customerNo = credentials.customerNo || process.env.HAFELE_CUSTOMER_NO || username;
if (!username || !password) {
emit({ type: 'log', message: 'Identifiants Häfele absents (sync publique uniquement).' });
return false;
}
const homeUrl = `${BASE_URL}/fr/`;
const accountStartUrl = `${BASE_URL}/fr/account/start/`;
try {
// 1) Load FR home page (contains header login form + synchronizer token)
const getRes = await fetch(homeUrl, {
headers: this.headers(),
redirect: 'follow',
});
this.captureCookies(getRes);
const html = await getRes.text();
const $ = cheerio.load(html);
// Prefer the known header login form from the provided markup.
let form = $('#loginWithExistingAccountForm').first();
if (!form.length) {
form = $('form').filter((_, el) => {
const id = ($(el).attr('id') || '').toLowerCase();
const action = ($(el).attr('action') || '').toLowerCase();
return id.includes('login') || action.includes('processlogin') || action.includes('/account/login');
}).first();
}
if (!form.length) {
emit({ type: 'log', message: 'Formulaire de login Häfele introuvable, continuation sans session.' });
return false;
}
const action = form.attr('action') || '/fr/account/login/';
const loginAction = absoluteUrl(action);
const payload = new URLSearchParams();
form.find('input').each((_, input) => {
const name = ($(input).attr('name') || '').trim();
const type = (($(input).attr('type') || 'text')).toLowerCase();
const value = ($(input).attr('value') || '').trim();
if (!name) return;
if (type === 'hidden') {
payload.set(name, value);
return;
}
const lowered = name.toLowerCase();
if (lowered.includes('pass')) {
payload.set(name, password);
} else if (lowered.includes('kunden') || lowered.includes('customer')) {
payload.set(name, customerNo);
} else if (lowered.includes('user') || lowered.includes('login') || lowered.includes('mitbenutzer')) {
payload.set(name, username);
}
});
// Explicit Häfele CH header login fields from current markup.
if (!payload.has('ShopLoginForm_Login_headerItemLogin')) {
payload.set('ShopLoginForm_Login_headerItemLogin', customerNo || username);
}
if (!payload.has('ShopLoginForm_Password_headerItemLogin')) {
payload.set('ShopLoginForm_Password_headerItemLogin', password);
}
if (!payload.has('ShopLoginForm_RememberLogin_headerItemLogin')) {
payload.set('ShopLoginForm_RememberLogin_headerItemLogin', 'on');
}
// Optional co-user login flow.
if (username && customerNo && username !== customerNo) {
payload.set('ShopLoginForm_LoginAsJointUser_headerItemLogin', 'on');
if (!payload.has('ShopLoginForm_UserName_headerItemLogin')) {
payload.set('ShopLoginForm_UserName_headerItemLogin', username);
}
}
const postRes = await fetch(loginAction, {
method: 'POST',
headers: this.headers({
'Content-Type': 'application/x-www-form-urlencoded',
Referer: homeUrl,
Origin: BASE_URL,
}),
body: payload.toString(),
redirect: 'follow',
});
this.captureCookies(postRes);
// 2) Verify with account start page (authenticated area)
const checkRes = await fetch(accountStartUrl, {
headers: this.headers(),
redirect: 'follow',
});
this.captureCookies(checkRes);
const finalUrl = checkRes.url || '';
const finalHtml = await checkRes.text();
const pageText = normalizeSpaces(finalHtml).toLowerCase();
const stillLogin =
finalUrl.includes('/account/login') ||
pageText.includes('mot de passe') ||
pageText.includes('passwort') ||
pageText.includes('connexion') && pageText.includes('numéro de client');
this.loggedIn = !stillLogin;
emit({
type: 'log',
message: this.loggedIn
? `Connecté à Häfele (${this.cookies.size} cookies).`
: 'Login Häfele non confirmé, continuation de la sync.',
});
return this.loggedIn;
} catch (err) {
emit({ type: 'log', message: `Erreur login Häfele: ${err.message}` });
return false;
}
}
}
let session = new HaefeleSession();
let currentCredentials = {};
let currentEmit = () => {};
async function waitForCloudflare(page, maxSeconds = 30) {
for (let elapsed = 0; elapsed < maxSeconds; elapsed += 3) {
const title = await page.title().catch(() => '');
const html = await page.content().catch(() => '');
if (!isCloudflareChallenge(html) && !title.toLowerCase().includes('just a moment')) {
return true;
}
currentEmit({ type: 'log', message: ` Cloudflare en cours… (${elapsed}s)` });
await page.waitForTimeout(3000);
}
return false;
}
let browserInstance = null;
let browserContext = null;
async function getBrowserPage() {
if (browserInstance && browserContext) {
return browserContext.newPage();
}
let chromium;
try {
({ chromium } = await import('playwright'));
} catch {
throw new Error('Cloudflare challenge détecté et Playwright indisponible.');
}
const headless = process.env.HAFELE_BROWSER_HEADLESS !== '0';
browserInstance = await chromium.launch({
headless,
args: [
'--disable-blink-features=AutomationControlled',
'--no-sandbox'
]
});
browserContext = await browserInstance.newContext({
locale: 'fr-CH',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
viewport: { width: 1920, height: 1080 }
});
return browserContext.newPage();
}
async function closeBrowser() {
if (browserInstance) {
await browserInstance.close().catch(() => {});
browserInstance = null;
browserContext = null;
}
}
let browserLoggedIn = false;
async function ensureBrowserLogin() {
if (browserLoggedIn) return;
const username = currentCredentials.username || process.env.HAFELE_USERNAME || process.env.HAFELE_USER;
const password = currentCredentials.password || process.env.HAFELE_PASSWORD;
const customerNo = currentCredentials.customerNo || process.env.HAFELE_CUSTOMER_NO || username;
if (!username || !password) {
currentEmit({ type: 'log', message: 'Pas d\'identifiants Häfele, navigation sans login.' });
return;
}
const page = await getBrowserPage();
try {
currentEmit({ type: 'log', message: 'Ouverture de la page de login Häfele…' });
await page.goto(`${BASE_URL}/fr/account/login/`, { waitUntil: 'load', timeout: BROWSER_TIMEOUT_MS });
const cfCleared = await waitForCloudflare(page, 45);
if (!cfCleared) {
currentEmit({ type: 'log', message: 'Cloudflare non résolu sur la page login.' });
const title = await page.title().catch(() => '?');
currentEmit({ type: 'log', message: ` Page titre: "${title}", URL: ${page.url()}` });
return;
}
const currentUrl = page.url();
const title = await page.title().catch(() => '');
currentEmit({ type: 'log', message: `Page login chargée: "${title}" (${currentUrl})` });
// Use exact ID selectors — the form has #ShopLoginForm_Login (text input)
// and #ShopLoginForm_LoginAsJointUser (hidden checkbox). Substring selectors
// like [name*=] would match the checkbox first and break .first().
const customerField = page.locator('#ShopLoginForm_Login');
const pwField = page.locator('#ShopLoginForm_Password');
try {
await customerField.waitFor({ state: 'visible', timeout: 15000 });
} catch {
currentEmit({ type: 'log', message: 'Champ #ShopLoginForm_Login non visible après attente.' });
const bodySnippet = normalizeSpaces(await page.content().catch(() => '')).slice(0, 500);
currentEmit({ type: 'log', message: ` Début HTML: ${bodySnippet}` });
return;
}
currentEmit({ type: 'log', message: 'Formulaire de login trouvé, saisie des identifiants…' });
await customerField.fill(customerNo || username);
await pwField.fill(password);
// Handle co-user mode if customer number differs from username.
if (username && customerNo && username !== customerNo) {
const coUserCheck = page.locator('#ShopLoginForm_LoginAsJointUser');
const coUserCheckDiv = page.locator('#divShopLoginForm_LoginAsJointUser');
// The real checkbox is hidden; the visible <div> acts as the toggle.
if (await coUserCheckDiv.count()) {
await coUserCheckDiv.click().catch(() => {});
await page.waitForTimeout(800);
} else if (await coUserCheck.count()) {
await page.evaluate(() => {
document.querySelector('#ShopLoginForm_LoginAsJointUser').checked = true;
document.querySelector('#ShopLoginForm_LoginAsJointUser').dispatchEvent(new Event('change', { bubbles: true }));
});
await page.waitForTimeout(800);
}
const coUserName = page.locator('#ShopLoginForm_UserName');
if (await coUserName.isVisible({ timeout: 2000 }).catch(() => false)) {
await coUserName.fill(username);
}
}
const submitBtn = page.locator('button[data-testid="accountLoginFormBtn"]');
await Promise.all([
page.waitForNavigation({ waitUntil: 'load', timeout: 30000 }).catch(() => {}),
submitBtn.click()
]);
await page.waitForTimeout(3000);
const afterUrl = page.url();
browserLoggedIn = !afterUrl.includes('/account/login');
currentEmit({
type: 'log',
message: browserLoggedIn
? `Connecté à Häfele via navigateur (${afterUrl}).`
: `Login navigateur non confirmé (${afterUrl}), continuation sans prix.`
});
} finally {
await page.close();
}
}
async function fetchPageWithBrowser(url) {
await ensureBrowserLogin();
const page = await getBrowserPage();
try {
currentEmit({ type: 'log', message: `Navigation vers ${url}…` });
await page.goto(url, { waitUntil: 'load', timeout: BROWSER_TIMEOUT_MS });
const cfCleared = await waitForCloudflare(page, 45);
if (!cfCleared) {
throw new Error('Cloudflare challenge non résolu automatiquement dans le navigateur.');
}
// Wait for product content to appear.
try {
await page.locator('.productDetailPage, .productHeadline, h1').first()
.waitFor({ state: 'visible', timeout: 15000 });
} catch {
// Page loaded but no product selector found — continue anyway.
}
// Price is loaded via AJAX after page load — wait for it.
try {
await page.locator('#productPriceInformation span.price').first()
.waitFor({ state: 'visible', timeout: 15000 });
currentEmit({ type: 'log', message: 'Prix détecté dans la page.' });
} catch {
currentEmit({ type: 'log', message: 'Prix non chargé après 15s, récupération du contenu actuel.' });
}
const html = await page.content();
currentEmit({ type: 'log', message: 'Contenu récupéré via navigateur (Playwright).' });
return html;
} finally {
await page.close();
}
}
async function fetchPage(url) {
const referer = `${BASE_URL}/fr/`;
let response = await fetch(url, {
headers: session.headers({
Referer: referer,
Origin: BASE_URL,
'Sec-Fetch-Site': 'same-origin'
}),
redirect: 'follow',
});
session.captureCookies(response);
let firstBody = '';
if (response.status === 403) {
firstBody = await response.text().catch(() => '');
if (isCloudflareChallenge(firstBody)) {
currentEmit({ type: 'log', message: 'Cloudflare détecté, tentative de fallback navigateur…' });
return fetchPageWithBrowser(url);
}
}
// Retry once after refreshing home page cookies when anti-bot blocks first hit.
if (response.status === 403) {
const warmup = await fetch(referer, {
headers: session.headers({
Referer: referer,
Origin: BASE_URL,
'Sec-Fetch-Site': 'same-origin'
}),
redirect: 'follow',
});
session.captureCookies(warmup);
response = await fetch(url, {
headers: session.headers({
Referer: referer,
Origin: BASE_URL,
'Sec-Fetch-Site': 'same-origin'
}),
redirect: 'follow',
});
session.captureCookies(response);
if (response.status === 403) {
const secondBody = await response.text().catch(() => '');
if (isCloudflareChallenge(secondBody)) {
currentEmit({ type: 'log', message: 'Challenge persistant, fallback navigateur…' });
return fetchPageWithBrowser(url);
}
throw new Error(`Failed to fetch ${url}: 403`);
}
}
if (!response.ok) {
const body = await response.text().catch(() => '');
const sample = normalizeSpaces(body).slice(0, 180);
throw new Error(`Failed to fetch ${url}: ${response.status}${sample ? ` (${sample})` : ''}`);
}
return response.text();
}
function extractKeyValueSpecs($) {
const specs = {};
$('.productPropertiesSection.details-product-body').each((_, el) => {
const key = normalizeSpaces($(el).find('.productPropertiesSectionHeader').first().text()).toLowerCase();
const value = normalizeSpaces($(el).find('.productPropertiesSectionBody').first().text());
if (key && value) specs[key] = value;
});
return specs;
}
function extractArticleNumber($) {
const text = normalizeSpaces($('.product-sku-title').first().text() || $.text());
const match = text.match(/N°\s*d['’]art\.\s*([\d.]+)/i);
return match ? match[1] : null;
}
function buildMaterialCode(url, articleNumber) {
if (articleNumber) {
return `HAF-${articleNumber.replace(/[^\dA-Za-z]/g, '')}`;
}
const skuMatch = url.match(/\/(P-\d+)\//i);
if (skuMatch) return `HAF-${skuMatch[1].toUpperCase()}`;
const slug = (url.split('/').filter(Boolean).pop() || 'unknown').replace(/[^\dA-Za-z]/g, '').slice(0, 30);
return `HAF-${slug.toUpperCase()}`;
}
function parseProductPage(html, url) {
const $ = cheerio.load(html);
const name = normalizeSpaces($('h1.productHeadline[itemprop="name"]').first().text() || $('h1.productHeadline').first().text());
const description = normalizeSpaces($('h2.productSubline[itemprop="description"]').first().text() || $('h2.productSubline').first().text());
const articleNumber = extractArticleNumber($);
const priceText =
normalizeSpaces($('#productPriceInformation .CUSTOMER_PRICE_Display .price span.price').first().text()) ||
normalizeSpaces($('#productPriceInformation .pricedisplay:not(.hidden) span.price').first().text()) ||
normalizeSpaces($('#productPriceInformation span.price').first().text()) ||
normalizeSpaces($('[id^="selectedArticlePriceInfo"] span.price').first().text()) ||
normalizeSpaces($('.priceContainer .price').first().text());
const priceCHF = parsePriceCHF(priceText);
if (priceText) {
currentEmit({ type: 'log', message: ` Prix trouvé: "${priceText}" → ${priceCHF} CHF` });
} else {
currentEmit({ type: 'log', message: ' Aucun prix trouvé dans le HTML.' });
}
const priceUnit = normalizeSpaces($('#productPriceInformation span.perUnit').first().text()) ||
normalizeSpaces($('[id^="selectedArticlePriceInfo"] span.perUnit').first().text()) || null;
const availabilityText = normalizeSpaces($('#productAvailabilityInformation .availability-flag').first().text());
const inStock = availabilityText
? (availabilityText.toLowerCase().includes('disponible') || availabilityText.toLowerCase().includes('stock')) &&
!availabilityText.toLowerCase().includes('indisponible')
: null;
// Deterministic image pick: prefer "primary" image, not the last diapo slide.
const pickImg = (sel) => {
const el = $(sel).first();
if (!el?.length) return null;
const src = el.attr('data-fullsrc') || el.attr('data-src') || el.attr('src') || '';
return absoluteUrl(String(src).trim());
};
// 1) Category/tile view markup (your snippet) — prefer when present.
const tileImg = pickImg('div.product_picture a.shopItemImage img, a.shopItemImage img');
// 2) OpenGraph is often the canonical hero image.
const ogImg = (
$('meta[property="og:image"]').attr('content') ||
$('meta[name="og:image"]').attr('content') ||
''
).trim();
const ogFull = absoluteUrl(ogImg);
// 3) Product detail hero image (if present).
const heroImg =
pickImg('.productImagePanelInner img[itemprop="image"]') ||
pickImg('.productImagePanelInner img');
// 4) First thumbnail (diapo) image.
const thumbImg = pickImg('.thumbsList img');
const imageUrl = tileImg || ogFull || heroImg || thumbImg || null;
const specs = extractKeyValueSpecs($);
if (Object.keys(specs).length > 0) {
const specsSummary = Object.entries(specs).map(([k, v]) => `${k}: ${v}`).join(' | ');
currentEmit({ type: 'log', message: ` Specs: ${specsSummary}` });
}
const category = normalizeSpaces($('.breadcrumb li:last-child').text()) || 'Hafele';
const thickness = parseFirstMm(specs['pour épaisseur du bois'] || specs['epaisseur'] || specs['épaisseur'] || '');
const allText = `${description} ${Object.values(specs).join(' ')}`;
const boreDiameter = parseBoreDiameter(allText);
const { depthBase, depthPiece } = parseBoreDepths(specs);
// Parse physical dimensions: diameter + length for cylindrical parts
let diameter = null;
let length = null;
const dimKeys = Object.keys(specs).map(k => k.toLowerCase());
for (const [key, value] of Object.entries(specs)) {
const k = key.toLowerCase();
if (!diameter && (k.includes('diamètre') || k.includes('diametre') || k.includes('durchmesser') || k.includes('diameter'))) {
diameter = parseFirstMm(value);
}
if (!length && (k.includes('longueur') || k.includes('länge') || k.includes('length') || k.includes('lang'))) {
length = parseFirstMm(value);
}
}
// Fallback: parse "Ø 8/60" or "8/60" pattern from the product name (e.g. SC 8/60 = Ø8mm, 60mm long)
if (!diameter) {
const nameMatch = (name || '').match(/(\d+(?:[.,]\d+)?)\s*\/\s*(\d+(?:[.,]\d+)?)/);
if (nameMatch) {
const d = parseFloat(nameMatch[1].replace(',', '.'));
const l = parseFloat(nameMatch[2].replace(',', '.'));
if (d > 0 && d <= 30) diameter = d;
if (l > 0 && l <= 200 && !length) length = l;
}
}
return {
materialCode: buildMaterialCode(url, articleNumber),
articleNumber,
name: name || articleNumber || url,
description,
collection: 'Hafele',
category,
width_mm: null,
height_mm: null,
thickness_mm: thickness,
diameter_mm: diameter,
length_mm: length,
bore_diameter_mm: boreDiameter,
bore_depth_base_mm: depthBase,
bore_depth_piece_mm: depthPiece,
price_chf: priceCHF,
price_unit: priceUnit,
image_url: imageUrl,
formex_url: url,
in_stock: inStock,
};
}
async function ensureSupplier() {
const existing = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['haefele']);
if (existing.rows.length > 0) return existing.rows[0].id;
await run(
'INSERT INTO suppliers (code, name, email, type, active) VALUES (?, ?, ?, ?, ?)',
['haefele', 'Hafele', '[email protected]', 'materials', 1]
);
const created = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['haefele']);
return created.rows[0]?.id || null;
}
async function upsertMaterial(material, supplierId) {
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 = ?,
diameter_mm = COALESCE(?, diameter_mm), length_mm = COALESCE(?, length_mm),
bore_diameter_mm = COALESCE(?, bore_diameter_mm),
bore_depth_base_mm = COALESCE(?, bore_depth_base_mm),
bore_depth_piece_mm = COALESCE(?, bore_depth_piece_mm),
image_url = ?, formex_url = ?, price_chf = COALESCE(?, price_chf), price_unit = COALESCE(?, price_unit),
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.diameter_mm ?? null,
material.length_mm ?? null,
material.bore_diameter_mm ?? null,
material.bore_depth_base_mm ?? null,
material.bore_depth_piece_mm ?? null,
material.image_url,
material.formex_url,
material.price_chf ?? null,
material.price_unit ?? null,
supplierId,
material.materialCode,
]
);
return existing.rows[0].id;
}
await run(
`INSERT INTO formex_materials
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
image_url, formex_url, price_chf, price_unit, supplier_id, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
[
material.materialCode,
material.name,
material.collection,
material.category,
material.width_mm,
material.height_mm,
material.thickness_mm,
material.diameter_mm ?? null,
material.length_mm ?? null,
material.bore_diameter_mm ?? null,
material.bore_depth_base_mm ?? null,
material.bore_depth_piece_mm ?? null,
material.image_url,
material.formex_url,
material.price_chf ?? null,
material.price_unit ?? null,
supplierId,
]
);
const created = await pool.query('SELECT id FROM formex_materials WHERE formex_code = ?', [material.materialCode]);
return created.rows[0]?.id || null;
}
async function logSync(status, itemsCount, error = null) {
await run(
'INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)',
['haefele', status, itemsCount, error]
);
}
export async function syncHaefeleByUrls(urls = [], emit = () => {}, options = {}) {
const credentials = options.credentials || {};
currentCredentials = credentials;
currentEmit = emit;
const validUrls = [...new Set((urls || [])
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean)
.filter((v) => v.includes('haefele.ch')))];
if (validUrls.length === 0) {
throw new Error('Aucune URL Häfele valide fournie.');
}
session = new HaefeleSession();
browserLoggedIn = false;
const supplierId = await ensureSupplier();
await session.login(emit, credentials);
let synced = 0;
let failed = 0;
const start = Date.now();
try {
emit({ type: 'log', message: `${validUrls.length} URL(s) Häfele à traiter.` });
emit({ type: 'progress', current: 0, total: validUrls.length, collection: 'Hafele' });
for (let i = 0; i < validUrls.length; i++) {
const url = validUrls[i];
emit({ type: 'log', message: `Traitement [${i + 1}/${validUrls.length}] ${url}` });
emit({ type: 'progress', current: i, total: validUrls.length, collection: 'Hafele' });
if (i > 0) await sleep(CRAWL_DELAY);
try {
const html = await fetchPage(url);
const parsed = parseProductPage(html, url);
await upsertMaterial(parsed, supplierId);
synced++;
const pricePart = parsed.price_chf != null ? ` - ${parsed.price_chf} CHF` : '';
const boreParts = [
parsed.bore_diameter_mm != null ? `Ø${parsed.bore_diameter_mm}` : null,
parsed.bore_depth_base_mm != null ? `fond:${parsed.bore_depth_base_mm}` : null,
parsed.bore_depth_piece_mm != null ? `pièce:${parsed.bore_depth_piece_mm}` : null,
].filter(Boolean);
const boreStr = boreParts.length ? ` [${boreParts.join(', ')}mm]` : '';
emit({ type: 'log', message: ` ✓ ${parsed.name}${pricePart}${boreStr}` });
} catch (err) {
failed++;
emit({ type: 'log', message: ` ✗ Erreur: ${err.message}` });
}
}
const duration = ((Date.now() - start) / 1000).toFixed(1);
const status = failed > 0 && synced === 0 ? 'error' : 'success';
await logSync(status, synced, failed > 0 ? `${failed} url(s) failed` : null);
const result = {
success: status === 'success',
itemsCount: synced,
failedCount: failed,
duration,
error: status === 'error' ? 'Aucune URL Häfele n\'a pu être synchronisée.' : null
};
emit({ type: 'progress', current: validUrls.length, total: validUrls.length, collection: 'Hafele' });
if (result.success) emit({ type: 'complete', result });
else emit({ type: 'error', error: result.error });
return result;
} catch (err) {
await logSync('error', synced, err.message);
emit({ type: 'error', error: err.message });
return { success: false, itemsCount: synced, error: err.message };
} finally {
await closeBrowser();
}
}
+478
View File
@@ -0,0 +1,478 @@
import pool from '../db/index.js';
import { generateCuttingList } from './cuttingEngine.js';
import { PLINTH, CARCASS_FASTENERS } from '../../shared/manufacturing.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',
back_panel_fastener_ixconnect_rpc_g_13_20:
'https://www.haefele.ch/fr/produkt/ferrure-d-assemblage-pour-paroi-arri-re-haefele-ixconnect-rpc-g-13-20-enficher-dans-la-rainure/P-00861578/',
};
function toMoney(n) {
const x = Number(n);
if (!Number.isFinite(x)) return 0;
return Math.round(x * 100) / 100;
}
function addQty(map, key, row) {
const prev = map.get(key);
if (!prev) {
map.set(key, { ...row, quantity: Math.max(0, Number(row.quantity) || 0) });
return;
}
map.set(key, {
...prev,
quantity: (Number(prev.quantity) || 0) + (Number(row.quantity) || 0),
});
}
function pricePackSizeFromUnitLabel(unitLabel, unit) {
const u = String(unit || '').trim().toLowerCase();
if (u !== 'pc') return null;
const s = String(unitLabel || '').trim();
if (!s) return null;
// Examples we see in Häfele sync:
// - "par 100 Pièce (PC)"
// - "par 10 pièce"
// - "Par 25 PC"
// - "par 100 Pieces"
const m = s.match(/\bpar\s+(\d+)\s*(pi[eè]ce?s?|pc)\b/i);
const n = m ? Number(m[1]) : NaN;
if (!Number.isFinite(n) || n <= 1) return null;
return n;
}
async function fetchOne(sql, params) {
const r = await pool.query(sql, params);
return r?.rows?.[0] || null;
}
async function priceById(table, id) {
if (!id) return null;
const row = await fetchOne(`SELECT id, code, name, price_chf FROM ${table} 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: table,
};
}
async function findHaefeleMaterialForModel(model) {
const m = String(model || '').trim();
if (!m) return null;
// Prefer strict matches (code contains model OR name contains model)
// so we link to the exact Häfele product page when synced.
const row = await fetchOne(
`SELECT id, formex_code as code, name, price_chf, price_unit, image_url, formex_url
FROM formex_materials
WHERE active = 1
AND (supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele') OR collection = 'Hafele' OR formex_code LIKE 'HAF-%')
AND (formex_code LIKE ? OR name LIKE ?)
ORDER BY last_synced_at DESC, id DESC
LIMIT 1`,
[`%${m}%`, `%${m}%`]
);
if (!row) return null;
return {
id: row.id,
code: row.code,
name: row.name,
unitPriceChf: Number.isFinite(Number(row.price_chf)) ? toMoney(row.price_chf) : null,
unitLabel: row.price_unit || null,
source: 'haefele_materials',
url: row.formex_url || null,
imageUrl: row.image_url || null,
};
}
async function findHaefeleMaterialByUrl(url) {
const raw = String(url || '').trim();
if (!raw) return null;
// Normalize: strip query/hash (synced DB may store canonical URL).
const uNoHash = raw.split('#')[0];
const uBase = uNoHash.split('?')[0];
const u = uNoHash;
// Extract a stable numeric product id if present (e.g. ".../34242663/").
const m = uBase.match(/\/(\d{6,})\/?$/);
const productId = m?.[1] || null;
const candidates = [
u,
uBase,
].filter((v, idx, arr) => v && arr.indexOf(v) === idx);
const row = await fetchOne(
`SELECT id, formex_code as code, name, price_chf, price_unit, image_url, formex_url
FROM formex_materials
WHERE active = 1
AND (supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele') OR collection = 'Hafele' OR formex_code LIKE 'HAF-%')
AND (
formex_url = ?
OR formex_url = ?
OR formex_url LIKE ?
OR (? IS NOT NULL AND formex_url LIKE ?)
)
ORDER BY last_synced_at DESC, id DESC
LIMIT 1`,
[
candidates[0] || raw,
candidates[1] || raw,
`${uBase}%`,
productId,
productId ? `%/${productId}/%` : null,
]
);
if (!row) return null;
return {
id: row.id,
code: row.code,
name: row.name,
unitPriceChf: Number.isFinite(Number(row.price_chf)) ? toMoney(row.price_chf) : null,
unitLabel: row.price_unit || null,
source: 'haefele_materials',
url: row.formex_url || null,
imageUrl: row.image_url || null,
};
}
function computeCountsFromCuttingList(cuttingList) {
const items = new Map();
const pieces = Array.isArray(cuttingList?.pieces) ? cuttingList.pieces : [];
let doorCount = 0;
let drawersCount = 0;
for (const p of pieces) {
const qty = Math.max(0, Number(p?.quantity) || 0);
const m = p?.machining || {};
const wMm = Number(p?.widthMm) || 0;
if (p?.elementType === 'portes') {
doorCount += qty;
if (m.hingeCups?.count) {
addQty(items, 'hinge', {
key: 'hinge',
label: 'Charnière',
quantity: qty * (Number(m.hingeCups.count) || 0),
unit: 'pc',
});
}
// Assumption: 2 finish connectors per door (front/back).
if (m.finishConnectors?.model) {
addQty(items, `finish_connector:${m.finishConnectors.model}`, {
key: `finish_connector:${m.finishConnectors.model}`,
label: `Connecteur finition ${m.finishConnectors.model}`,
quantity: qty * 2,
unit: 'pc',
meta: { model: m.finishConnectors.model },
});
}
}
// Étagères amovibles — ferrures SP 15/8/5 (système ressort)
// User spec: 4 pieces per shelf/tablette.
if (p?.elementType === 'etageres' && m.mobileShelfSystem?.model === 'sp_15_8_5' && qty > 0) {
addQty(items, 'shelf_ferrure_sp_15_8_5', {
key: 'shelf_ferrure_sp_15_8_5',
label: 'Ferrure d’assemblage de tablettes SP 15/8/5 (ressort)',
quantity: qty * 4,
unit: 'pc',
meta: { model: 'SP 15/8/5' },
});
}
if (m.connectorHoles?.model && ['dessus', 'fond'].includes(p?.elementType)) {
const joints = Number(m.connectorHoles.jointsPerPiece) || (Array.isArray(m.connectorHoles.ixconnectJointsAlongDepth) ? m.connectorHoles.ixconnectJointsAlongDepth.length : 0);
const perJoint = Number(m.connectorHoles.connectorsPerJoint) || 1;
const tPer = Number(m.connectorHoles.tourillonsPerJoint) || 0;
addQty(items, `ixconnect:${m.connectorHoles.model}`, {
key: `ixconnect:${m.connectorHoles.model}`,
label: `Connecteur structure ${m.connectorHoles.model}`,
quantity: qty * joints * perJoint,
unit: 'pc',
meta: { model: m.connectorHoles.model },
});
if (tPer > 0) {
addQty(items, `tourillon:${m.connectorHoles.tourillonDiameter || 8}x30`, {
key: `tourillon:${m.connectorHoles.tourillonDiameter || 8}x30`,
label: `Tourillon Ø${m.connectorHoles.tourillonDiameter || 8}×30`,
quantity: qty * joints * tPer,
unit: 'pc',
});
}
}
if (m.carcassFasteners?.positionsAlongJointMm?.length && ['dessus', 'fond'].includes(p?.elementType)) {
const ferruresPerJoint = m.carcassFasteners.positionsAlongJointMm.length;
const jointCount = Number(m.carcassFasteners.jointCount) || (Array.isArray(m.carcassFasteners.junctionAxesAcrossWidthMm) ? m.carcassFasteners.junctionAxesAcrossWidthMm.length : 0);
const ferrures = qty * ferruresPerJoint * jointCount;
addQty(items, 'ferrure_carcasse', {
key: 'ferrure_carcasse',
label: `Ferrure corps (jonction) — ${CARCASS_FASTENERS.model}`,
quantity: ferrures,
unit: 'pc',
productUrl: CARCASS_FASTENERS.productUrl,
meta: { model: CARCASS_FASTENERS.model },
});
// 2 dowels per ferrure (per spec text)
addQty(items, `tourillon:${m.carcassFasteners.dowelDiameter || 8}x40`, {
key: `tourillon:${m.carcassFasteners.dowelDiameter || 8}x40`,
label: `Tourillon Ø${m.carcassFasteners.dowelDiameter || 8}×${m.carcassFasteners.dowelDepth || 40}`,
quantity: ferrures * 2,
unit: 'pc',
});
}
if (p?.elementType === 'renforts' && m.reinforcementFixing?.connectorModel) {
const sideCount = Number(m.reinforcementFixing.sideCount) || 2;
const ixPerSide = Number(m.reinforcementFixing.ixconnectPerSide) || 1;
const tPerSide = Number(m.reinforcementFixing.tourillonsPerSide) || 0;
addQty(items, `reinforcement_ix:${m.reinforcementFixing.connectorModel}`, {
key: `reinforcement_ix:${m.reinforcementFixing.connectorModel}`,
label: `Connecteur renfort ${m.reinforcementFixing.connectorModel}`,
quantity: qty * sideCount * ixPerSide,
unit: 'pc',
meta: { model: m.reinforcementFixing.connectorModel },
});
if (tPerSide > 0) {
addQty(items, `tourillon:${m.reinforcementFixing.tourillonDiameter || 8}x30`, {
key: `tourillon:${m.reinforcementFixing.tourillonDiameter || 8}x30`,
label: `Tourillon Ø${m.reinforcementFixing.tourillonDiameter || 8}×30`,
quantity: qty * sideCount * tPerSide,
unit: 'pc',
});
}
}
if (m.blockAssembly?.middleCount && ['panneauxInterieurs', 'cotes'].includes(p?.elementType)) {
// Note: block assembly “vis de jonction” doesn't provide exact count per piece,
// so we do not count it here to avoid misleading totals.
}
if (p?.elementType === 'tiroirs') {
drawersCount += qty;
}
// 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)
// - + 1 pièce tous les 30cm disponibles entre les 2 extrémités
// Donc: n(E) = 2 + floor(max(0, E-100) / 300) (E en mm)
if (p?.elementType === 'dos' && wMm > 0 && qty > 0) {
const hMm = Number(p?.heightMm) || 0;
const perEdgeW = 2 + Math.floor(Math.max(0, wMm - 100) / 300);
const perEdgeH = hMm > 0 ? 2 + Math.floor(Math.max(0, hMm - 100) / 300) : 0;
const totalPerPanel = perEdgeW * 2 + perEdgeH * 2;
const total = qty * totalPerPanel;
addQty(items, 'back_panel_fastener_ixconnect_rpc_g_13_20', {
key: 'back_panel_fastener_ixconnect_rpc_g_13_20',
label: 'Ferrure dos — Häfele Ixconnect RPC G 13/20 (rainure)',
quantity: total,
unit: 'pc',
productUrl: HAEFELE_PRODUCT_URLS.back_panel_fastener_ixconnect_rpc_g_13_20,
meta: { model: 'Ixconnect RPC G 13/20' },
});
}
}
return { items: Array.from(items.values()), doorCount, drawersCount };
}
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;
const drawerSystemId = config?.hardware?.drawerSystemId;
const [handleVariant, hingeVariant, drawerSystem] = await Promise.all([
priceById('handle_variants', handleVariantId),
priceById('hinge_variants', hingeVariantId),
priceById('blum_hardware', drawerSystemId),
]);
// Add handles as an item when selected
if (handleVariant && doorCount > 0) {
items.push({
key: `handle_variant:${handleVariant.id}`,
label: `Poignée — ${handleVariant.name}`,
quantity: doorCount,
unit: 'pc',
unitPriceChf: handleVariant.unitPriceChf,
source: handleVariant.source,
code: handleVariant.code,
});
}
// Attach hinge price to computed hinges (prefer synced Häfele, even without selected variant)
const hingeUrl = HAEFELE_PRODUCT_URLS.hinge_blum_clip_top_blumotion_110;
const haefeleHinge = await findHaefeleMaterialByUrl(hingeUrl);
for (const it of items) {
if (it.key !== 'hinge') continue;
if (hingeVariant) {
it.label = `Charnière — ${hingeVariant.name}`;
it.source = it.source ?? hingeVariant.source;
it.code = it.code ?? hingeVariant.code;
it.unitPriceChf = it.unitPriceChf ?? hingeVariant.unitPriceChf;
}
// Always attach product URL for the hinge line (useful for admins to sync/verify).
it.productUrl = it.productUrl ?? hingeUrl;
// Prefer synced Häfele data when available.
if (haefeleHinge) {
it.unitPriceChf = haefeleHinge.unitPriceChf ?? it.unitPriceChf;
it.productUrl = haefeleHinge.url || it.productUrl;
it.imageUrl = it.imageUrl ?? haefeleHinge.imageUrl;
it.unitLabel = it.unitLabel ?? haefeleHinge.unitLabel;
it.code = haefeleHinge.code || it.code;
it.source = it.source ?? haefeleHinge.source;
}
}
// Attach carcass fastener (Ferrure corps / jonction) reference + price
const carcassUrl = CARCASS_FASTENERS.productUrl;
const haefeleCarcass = await findHaefeleMaterialByUrl(carcassUrl);
for (const it of items) {
if (it.key !== 'ferrure_carcasse') continue;
it.productUrl = it.productUrl ?? carcassUrl;
if (haefeleCarcass) {
it.unitPriceChf = haefeleCarcass.unitPriceChf ?? it.unitPriceChf;
it.productUrl = haefeleCarcass.url || it.productUrl;
it.imageUrl = it.imageUrl ?? haefeleCarcass.imageUrl;
it.unitLabel = it.unitLabel ?? haefeleCarcass.unitLabel;
it.code = haefeleCarcass.code || it.code;
it.source = it.source ?? haefeleCarcass.source;
// Keep user-facing label stable but include model when present.
it.label = it.label || `Ferrure corps (jonction) — ${CARCASS_FASTENERS.model}`;
}
}
// Attach back panel fastener (dos) reference + price
const backFastenerUrl = HAEFELE_PRODUCT_URLS.back_panel_fastener_ixconnect_rpc_g_13_20;
const haefeleBackFastener = await findHaefeleMaterialByUrl(backFastenerUrl);
for (const it of items) {
if (it.key !== 'back_panel_fastener_ixconnect_rpc_g_13_20') continue;
it.productUrl = it.productUrl ?? backFastenerUrl;
if (haefeleBackFastener) {
it.unitPriceChf = haefeleBackFastener.unitPriceChf ?? it.unitPriceChf;
it.productUrl = haefeleBackFastener.url || it.productUrl;
it.imageUrl = it.imageUrl ?? haefeleBackFastener.imageUrl;
it.unitLabel = it.unitLabel ?? haefeleBackFastener.unitLabel;
it.code = haefeleBackFastener.code || it.code;
it.source = it.source ?? haefeleBackFastener.source;
}
}
// 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) {
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,
});
}
}
// Price Häfele items (best-effort by name match)
const haefeleLookups = new Map();
for (const it of items) {
const model = it?.meta?.model;
if (!model) continue;
if (!haefeleLookups.has(model)) {
haefeleLookups.set(model, findHaefeleMaterialForModel(model));
}
}
const haefeleResolved = new Map();
await Promise.all(
Array.from(haefeleLookups.entries()).map(async ([model, prom]) => {
const r = await prom;
haefeleResolved.set(model, r);
})
);
for (const it of items) {
const model = it?.meta?.model;
if (!model) continue;
const mat = haefeleResolved.get(model);
if (!mat) continue;
// For Häfele-connectors (SC 8/60, SC 8/25, SC 8/65...), prefer synced Häfele price when available.
it.unitPriceChf = mat.unitPriceChf ?? it.unitPriceChf;
it.source = it.source ?? mat.source;
it.code = it.code ?? mat.code;
it.productUrl = it.productUrl ?? mat.url;
it.imageUrl = it.imageUrl ?? mat.imageUrl;
it.unitLabel = it.unitLabel ?? mat.unitLabel;
}
// Compute totals
const normalized = items
.filter((it) => (Number(it.quantity) || 0) > 0)
.map((it) => {
let unitPriceChf = it.unitPriceChf == null ? null : toMoney(it.unitPriceChf);
const quantity = Math.max(0, Number(it.quantity) || 0);
let unitLabel = it.unitLabel || null;
// Häfele sometimes provides prices as "par 100 pièces" etc.
// Our UI + totals expect unitPriceChf to be per displayed `unit` (usually per pc).
if (unitPriceChf != null) {
const packSize = pricePackSizeFromUnitLabel(it.unitLabel, it.unit || 'pc');
if (packSize) {
unitPriceChf = toMoney(unitPriceChf / packSize);
unitLabel = 'par pièce (PC)';
}
}
const lineTotalChf = unitPriceChf == null ? null : toMoney(unitPriceChf * quantity);
return {
key: String(it.key),
label: String(it.label),
code: it.code || null,
quantity,
unit: it.unit || 'pc',
unitPriceChf,
lineTotalChf,
source: it.source || null,
productUrl: it.productUrl || null,
imageUrl: it.imageUrl || null,
unitLabel,
};
})
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
const totalChf = toMoney(
normalized.reduce((s, it) => s + (Number(it.lineTotalChf) || 0), 0)
);
return { items: normalized, totalChf };
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Perçages par pièce pour exports CNC (.nc) et MPR (HOMAC/Woodwop XML-like).
* Même jeu de coordonnées que la vue liste / G-code commentaires : origine bas-gauche, X/Y en mm.
*/
import {
buildTopBottomPlanHoleSpecsMm,
buildVerticalPanelIxconnectFaceHoleSpecsMm,
buildShelfMobileHoleSpecsMm,
buildFinishHabillageHoleSpecsMm,
buildDoorHingeCupHoleSpecsMm,
buildDrawerReinforcementHoleSpecsMm,
buildDrawerRenfortPanelIxconnectSpecsMm
} from '../../shared/cncPanelHoles.js';
export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0) {
const specs = [];
function pushHole(params) {
specs.push(params);
}
const m = piece?.machining || {};
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
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'
});
}
}
if (['dessus', 'fond'].includes(piece.elementType || '')) {
for (const spec of buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
}
if (['cotes', 'panneauxInterieurs'].includes(piece.elementType || '')) {
for (const spec of buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
for (const spec of buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
}
if (['cotes', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) {
for (const spec of buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' });
}
}
if (piece.elementType === 'etageres' || piece.elementType === 'rayonFixe') {
for (const spec of buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
}
if (piece.elementType === 'renfortsTiroir') {
for (const spec of buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' });
}
}
if (piece.elementType === 'portes') {
for (const spec of buildDoorHingeCupHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind', face: 'face' });
}
}
if (m.reinforcementFixing) {
const rf = m.reinforcementFixing;
const dIx = Number(rf.edgeHole?.diameter || 8);
const dT = Number(rf.tourillonDiameter || 8);
const zIx = Number(rf.edgeHole?.depth || 45);
const zT = Number(piece.thicknessMm || 16);
const yFromTop = (mm) => clampY(heightMm - Number(mm));
for (const end of ['left', 'right']) {
const xEnd = end === 'left' ? 0 : widthMm;
if (Number.isFinite(Number(rf.ixconnectAxisFromTopMm))) {
pushHole({
xMm: clampX(xEnd),
yMm: yFromTop(rf.ixconnectAxisFromTopMm),
diameterMm: dIx,
depthMm: zIx,
type: 'ixconnect',
face: 'end',
end,
toolMode: 'edge'
});
}
for (const a of rf.tourillonAxesFromTopMm || []) {
pushHole({
xMm: clampX(xEnd),
yMm: yFromTop(a),
diameterMm: dT,
depthMm: zT,
type: 'tourillon',
face: 'end',
end,
toolMode: 'edge'
});
}
}
}
return { specs, thicknessMm };
}
+35
View File
@@ -0,0 +1,35 @@
import nodemailer from 'nodemailer';
let transporter = null;
function getTransporter() {
if (transporter) return transporter;
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT) || 587;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) {
throw new Error('SMTP configuration manquante (SMTP_HOST, SMTP_USER, SMTP_PASS)');
}
transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass }
});
return transporter;
}
/**
* Send an email.
* @param {{ to: string, subject: string, html: string }} options
*/
export async function sendEmail({ to, subject, html }) {
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const t = getTransporter();
await t.sendMail({ from, to, subject, html });
}
+522
View File
@@ -0,0 +1,522 @@
/**
* Export MPR (WOODWOP / Homag-family .mpr) pour le débit APEX — un fichier par panneau, ZIP.
* Coordonnées XY = même repère que le G-code (.nc commentaires): origine bas-gauche, mm.
* Macros RSO/* : chemins exemple — à présence confirmée sur le poste/cellule.
*/
import JSZip from 'jszip';
import { generateCuttingList } from './cuttingEngine.js';
import { collectHoleSpecsForPiece } from './holeSpecifications.js';
import { GROOVE, getBackGroovePlanOrientation, BACK_GROOVE_PLAN_VERTICAL_TYPES } from '../../shared/manufacturing.js';
const MPR_ARTICLE_CODES = Object.freeze({
plintheBas: 'PL_BAS',
bandeauHaut: 'BH',
dessus: 'BO_U',
fond: 'BO_L',
cotes: 'SE',
cotesDoublage: 'CD',
fileur: 'FI',
habillageFinitionGauche: 'FR_L_G',
habillageFinitionDroite: 'FR_L_D',
habillageFinitionDessus: 'FR_H',
dos: 'DO',
renforts: 'RE',
portes: 'TU',
panneauxInterieurs: 'ZA_O',
rayonFixe: 'RF_X',
etageres: 'EB_M',
renfortsTiroir: 'LABO'
});
function fmtMm(n) {
const v = Number(n);
if (!Number.isFinite(v)) return '0.0';
const rounded = Math.round(v * 10) / 10;
return Number.isInteger(rounded) ? `${rounded}.0` : `${rounded}`;
}
function toSafe(value, fb) {
const n = Number(value);
return Number.isFinite(n) ? n : fb;
}
function escQuoted(s) {
return String(s ?? '').replace(/"/g, "'");
}
/** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */
export function flattenPiecesForMprExport(pieces = []) {
const out = [];
for (const piece of pieces) {
const qty = Math.max(1, Math.round(toSafe(piece.quantity, 1)));
const splitSides =
piece.elementType === 'cotes' &&
qty === 2 &&
piece.id === 'piece-cotes' &&
String(piece.description || '').includes('gauche et droit');
if (splitSides) {
out.push({ piece: { ...piece, quantity: 1 }, slug: 'SE_L', qtyIndex: 0 });
out.push({ piece: { ...piece, quantity: 1 }, slug: 'SE_R', qtyIndex: 1 });
continue;
}
let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE';
for (let qi = 0; qi < qty; qi++) {
let slug = baseSlug;
if (
qty > 1 &&
['panneauxInterieurs', 'renforts', 'etageres', 'rayonFixe', 'bandeauHaut'].includes(piece.elementType || '')
) {
slug = `${baseSlug}_${qi + 1}`;
}
out.push({ piece, slug, qtyIndex: qi });
}
}
return out;
}
function mprAnglesForPiece(piece) {
const et = piece.elementType || '';
if (habillageEtPorte(et)) {
return {
DRWIN: '270.0',
beschGedreht: '0',
turnMsg: et === 'portes' ? 'DREHEN X-ACHSE' : 'DREHEN X-ACHSE'
};
}
if (['dessus', 'fond'].includes(et)) {
return {
DRWIN: '0.0',
beschGedreht: '1',
turnMsg: 'DREHEN Y-ACHSE'
};
}
return {
DRWIN: '0.0',
beschGedreht: '0',
turnMsg: 'DREHEN Y-ACHSE'
};
}
function habillageEtPorte(et) {
return (
['habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus', 'portes'].includes(
et
)
);
}
/** Kantendicken : habillages façon exemple FR_* (tous faces 1 mm). */
function kantenFlags(piece) {
const fin = habillageEtPorte(piece.elementType || '');
if (fin) {
return { kavo: '1.0', kali: '1.0', kahi: '1.0', kare: '1.0' };
}
return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' };
}
function grooveSection(piece, _Lmm, _Bmm) {
const m = piece?.machining || {};
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
if (!m.groove || !orient) return [];
const gw = fmtMm(Number(m.groove.width ?? GROOVE.back.width));
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';
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"',
'KO="04"'
];
}
/** Un trou → lignes BohrVert (projection face Z+ ; perçages chant restent approximation LS/LSL). */
function bohrVertLines(spec, thicknessMm) {
const dRaw = Number(spec.diameterMm) || 5;
const depthRaw = Number(spec.depthMm) || 12;
const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end');
const throughHint = Boolean(spec.toolMode === 'through');
const looksThrough = depthRaw >= thicknessMm - 0.5;
let bm = 'LS';
let ti;
if (throughHint || looksThrough) {
bm = 'LSL';
ti = `${fmtMm(thicknessMm)} + 1`;
} else if (edgeHint && depthRaw >= 38) {
bm = 'LSL';
ti = fmtMm(depthRaw);
} else {
ti = fmtMm(depthRaw);
}
const name = fmtMm(Math.round(dRaw * 10) / 10);
const du = fmtMm(Math.round(dRaw * 10) / 10);
const xa = fmtMm(spec.xMm);
const ya = fmtMm(spec.yMm);
return [
'<102 \\BohrVert\\',
`MNM="Bohrung ${name} mm"`,
`XA="${xa}"`,
`YA="${ya}"`,
`BM="${bm}"`,
`TI="${ti}"`,
`DU="${du}"`,
'AN="1"',
'MI="0"',
'S_="2"',
'ASG="2"',
'MLM="0"',
'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 = [];
for (const s of specs) {
const hit = out.find(
(o) =>
Math.abs(o.xMm - s.xMm) < eps &&
Math.abs(o.yMm - s.yMm) < eps &&
Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps
);
if (!hit) out.push(s);
}
return out;
}
function buildSingleMprText({
piece,
slug,
qtyIndex,
seqGlobal,
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
}) {
const angles = mprAnglesForPiece(piece);
const kant = kantenFlags(piece);
const L = toSafe(piece.rawCutWidthMm, piece.widthMm);
const B = toSafe(piece.rawCutHeightMm, piece.heightMm);
const D = toSafe(piece.thicknessMm, 19);
const posLabel = `${groupIndex}.${seqGlobal}`;
const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex);
const unique = 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));
const matKm = escQuoted(
piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX'
);
const lines = [];
lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"');
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(`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(`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(
'[K',
'<00 \\Koordinatensystem\\',
'NR="04"',
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=0 OR DW=90 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW"',
'KI="0"',
'D2="0"',
'MI="0"',
'<00 \\Koordinatensystem\\',
'NR="F1"',
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=0 OR DW=90 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW"',
'KI="90"',
'D2="0"',
'MI="0"',
'<00 \\Koordinatensystem\\',
'NR="F2"',
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=90 OR DW=0 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW+270"',
'KI="90"',
'D2="0"',
'MI="1"',
'<00 \\Koordinatensystem\\',
'NR="F3"',
'XP="IF DW=0 OR DW=90 THEN 0 ELSE ld"',
'YP="IF DW=180 OR DW=90 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW+180"',
'KI="90"',
'D2="0"',
'MI="1"',
'<00 \\Koordinatensystem\\',
'NR="F4"',
'XP="IF DW=180 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=0 OR DW=270 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW+90"',
'KI="90"',
'D2="0"',
'MI="0"',
'<100 \\WerkStck\\',
'LA="IF DW=90 OR DW=270 THEN B ELSE L"',
'BR="IF DW=90 OR DW=270 THEN L ELSE 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"',
'AX="IF DW=90 OR DW=270 THEN ZUGVO+ZUGHI ELSE ZUGLI+ZUGRE"',
'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"',
'<101 \\Kommentar\\',
`KM="AUFTRAG : ${escQuoted(orderReference)}"`,
`KM="STUECKLISTE : ${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=""`,
`KM="Material: ${matKm}"`,
'KM="Belag IN: "',
'KM="Belag AU: "',
'KM=""',
'<139 \\Komponente\\',
'MNM="Beschriftung"',
'IN="RSO/Makro/RSO_Beschriftung.mpr"',
'VA="L L"',
'VA="B B"',
'VA="KAVODI KAVODI"',
'VA="KALIDI KALIDI"',
'VA="KAHIDI KAHIDI"',
'VA="KAREDI KAREDI"',
`VA="GEDREHT ${angles.beschGedreht}"`,
'VA="NCSTOPP 0"',
'KO="04"',
'<139 \\Komponente\\',
'MNM="Formatierung"',
'IN="RSO/Makro/RSO_Format.mpr"',
'VA="L L"',
'VA="B B"',
'VA="D D"',
'VA="WKZ 316"',
'VA="KAVODI KAVODI"',
'VA="KALIDI KALIDI"',
'VA="KAHIDI KAHIDI"',
'VA="KAREDI KAREDI"',
'VA="DIST 0.0"',
'VA="ECKRUND 0.1"',
'VA="KAFR 0"',
'VA="KAFRMAX 2.0"',
'VA="MAT MAT"',
'??="FORMAT"',
'KO="04"',
'<121 \\Block\\',
'MNM="Bearbeitungen von oben "',
`DP="${dp}"`,
'KO="04"'
);
lines.push(...holeLines);
lines.push(...grooveSection(piece, L, B));
lines.push('!');
return lines.join('\r\n');
}
function attachGlobalSeq(pieceFlat, allFlat) {
return pieceFlat.map((entry) => {
const globalIdx = allFlat.findIndex(
(f) =>
f.piece.id === entry.piece.id &&
f.qtyIndex === entry.qtyIndex &&
f.slug === entry.slug
);
return { ...entry, seqGlobal: globalIdx >= 0 ? globalIdx + 1 : 1 };
});
}
function buildMprFileRecords(entries, mprOptions) {
const { groupIndex = 1, orderReference = '', stackListKm = '01', defaultMaterialArticle = '' } = mprOptions;
return entries.map((entry) => {
const fileName = `${groupIndex}_${entry.seqGlobal}_${safeZipFileName(entry.slug)}.mpr`;
const content = buildSingleMprText({
piece: entry.piece,
slug: entry.slug,
qtyIndex: entry.qtyIndex,
seqGlobal: entry.seqGlobal,
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
});
return { fileName, content };
});
}
async function packMprRecords(records, zipBaseName) {
if (records.length === 1) {
const only = records[0];
return {
buffer: Buffer.from(only.content, 'utf8'),
fileName: only.fileName,
mimeType: 'application/octet-stream',
fileCount: 1
};
}
const zip = new JSZip();
for (const rec of records) {
zip.file(rec.fileName, rec.content);
}
const safeBase = safeZipFileName(zipBaseName || 'piece-mpr');
return {
buffer: await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }),
fileName: `${safeBase}.zip`,
mimeType: 'application/zip',
fileCount: records.length
};
}
/** Nombre de fichiers MPR générés pour une ligne de débitage (ex. côtés L+R → 2). */
export function countMprInstancesForPiece(piece) {
return flattenPiecesForMprExport([piece]).length;
}
export async function generateMprZipBuffer({
config,
modules = [],
colors = {},
orderReference = '',
stackListKm = '01',
defaultMaterialArticle = '',
/** Index groupe commande dans le nom fichier (premier nombre). */
groupIndex = 1
}) {
const cuttingList = generateCuttingList(config, colors, modules);
const flat = flattenPiecesForMprExport(cuttingList.pieces);
const withSeq = attachGlobalSeq(flat, flat);
const records = buildMprFileRecords(withSeq, {
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
});
return {
...(await packMprRecords(records, `apex-mpr-g${groupIndex}`)),
fileCount: records.length
};
}
/**
* Export MPR pour une seule ligne de la liste de débitage (identifiée par pieceId).
* @param {number|null|undefined} instanceIndex — exemplaire 0-based ; omis = tous les exemplaires de la ligne.
*/
export async function generateMprPieceDownload({
config,
modules = [],
colors = {},
pieceId,
instanceIndex,
orderReference = '',
stackListKm = '01',
defaultMaterialArticle = '',
groupIndex = 1
}) {
if (!pieceId || typeof pieceId !== 'string') {
throw new Error('pieceId requis.');
}
const cuttingList = generateCuttingList(config, colors, modules);
const piece = (cuttingList.pieces || []).find((p) => p.id === pieceId);
if (!piece) {
throw new Error('Pièce introuvable dans la liste de débitage.');
}
const allFlat = flattenPiecesForMprExport(cuttingList.pieces);
let pieceFlat = attachGlobalSeq(flattenPiecesForMprExport([piece]), allFlat);
if (instanceIndex != null && instanceIndex !== '') {
const idx = Math.floor(Number(instanceIndex));
if (!Number.isFinite(idx) || idx < 0) {
throw new Error('instanceIndex invalide.');
}
pieceFlat = pieceFlat.filter((entry) => entry.qtyIndex === idx);
if (pieceFlat.length === 0) {
throw new Error('Exemplaire introuvable pour cette pièce.');
}
}
const records = buildMprFileRecords(pieceFlat, {
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
});
return packMprRecords(records, pieceId);
}
function safeZipFileName(seg) {
return String(seg).replace(/[^\w\-]+/g, '_').replace(/_+/g, '_');
}
export default {
generateMprZipBuffer,
generateMprPieceDownload,
countMprInstancesForPiece,
flattenPiecesForMprExport
};
+219
View File
@@ -0,0 +1,219 @@
/**
* Packing Engine — APEX 4.0
* Layer-based bin packing algorithm (per Dossier Technique Maître §3.1).
*
* Strategy:
* Level 1 — Côtés empilés (side panels stacked flat)
* Level 2 — Dessus + Fond côte à côte (top & bottom side by side)
* Level 3 — Étagères + Renforts groupés par épaisseur (shelves & reinforcements)
*
* Within each level, pieces are packed using First-Fit-Decreasing by weight,
* respecting a 28 kg max per box.
*/
import { PACKING, PANEL } from '../../shared/manufacturing.js';
/**
* Estimate the weight of a single piece in kg.
* Uses approximate melamine panel density by thickness.
* @param {number} widthMm
* @param {number} heightMm
* @param {number} thicknessMm
* @returns {number} weight in kg for ONE piece
*/
function estimatePieceWeight(widthMm, heightMm, thicknessMm) {
const volumeM3 = (widthMm / 1000) * (heightMm / 1000) * (thicknessMm / 1000);
let density;
if (thicknessMm >= PANEL.structure) {
density = PACKING.densityKgM3.melamine19;
} else if (thicknessMm >= PANEL.reinforcement) {
density = PACKING.densityKgM3.melamine16;
} else {
density = PACKING.densityKgM3.melamine8;
}
return volumeM3 * density;
}
/**
* Expand cutting list pieces into individual packable items.
* Each piece with quantity > 1 becomes multiple single items.
* @param {Array} pieces - pieces from cutting list
* @returns {Array} flat array of single items with weight
*/
function expandPieces(pieces) {
const items = [];
for (const piece of pieces) {
// Skip non-physical items (edge banding, etc.)
if (piece.unit === 'ml' || piece.widthMm === 0 || piece.heightMm === 0) continue;
const weightPerPiece = estimatePieceWeight(piece.widthMm, piece.heightMm, piece.thicknessMm);
for (let i = 0; i < piece.quantity; i++) {
items.push({
pieceId: piece.id,
elementType: piece.elementType,
description: piece.description,
widthMm: piece.widthMm,
heightMm: piece.heightMm,
thicknessMm: piece.thicknessMm,
quantity: 1,
weightKg: Math.round(weightPerPiece * 100) / 100,
index: i,
});
}
}
return items;
}
/**
* Classify expanded items into packing layers per APEX 4.0 §3.1.
*
* Level 1: cotes (side panels) — stacked
* Level 2: dessus + fond (top & bottom) — side by side
* Level 3: everything else grouped by thickness (etageres, renforts, panneauxInterieurs, dos, etc.)
*
* @param {Array} items - expanded single-piece items
* @returns {Object} { level1, level2, level3 } arrays
*/
function classifyByLayer(items) {
const level1 = []; // Côtés
const level2 = []; // Dessus + Fond
const level3 = []; // Étagères, Renforts, Séparateurs, Dos, Portes, etc.
for (const item of items) {
switch (item.elementType) {
case 'cotes':
case 'cotesDoublage':
level1.push(item);
break;
case 'dessus':
case 'fond':
level2.push(item);
break;
default:
level3.push(item);
break;
}
}
// Sort level 3 by thickness (group same thickness together) then by weight desc
level3.sort((a, b) => {
if (a.thicknessMm !== b.thicknessMm) return b.thicknessMm - a.thicknessMm;
return b.weightKg - a.weightKg;
});
return { level1, level2, level3 };
}
/**
* First-Fit-Decreasing bin packing for a set of items.
* Sort items by weight (heaviest first), then assign each to the
* first box that still has capacity.
*
* @param {Array} items - items to pack
* @param {number} startBoxId - starting box ID
* @param {string} levelLabel - label for these boxes (e.g. "Niveau 1")
* @returns {{ boxes: Array, nextBoxId: number }}
*/
function firstFitDecreasing(items, startBoxId = 1, levelLabel = '') {
if (items.length === 0) return { boxes: [], nextBoxId: startBoxId };
const sorted = [...items].sort((a, b) => b.weightKg - a.weightKg);
const maxWeight = PACKING.maxWeightKg;
const boxes = [];
for (const item of sorted) {
// If the single item exceeds max weight, it gets its own box
if (item.weightKg > maxWeight) {
boxes.push({
id: startBoxId + boxes.length,
level: levelLabel,
pieces: [item],
totalWeightKg: item.weightKg,
});
continue;
}
// Try to fit in an existing box
let placed = false;
for (const box of boxes) {
if (box.totalWeightKg + item.weightKg <= maxWeight) {
box.pieces.push(item);
box.totalWeightKg = Math.round((box.totalWeightKg + item.weightKg) * 100) / 100;
placed = true;
break;
}
}
// Open a new box
if (!placed) {
boxes.push({
id: startBoxId + boxes.length,
level: levelLabel,
pieces: [item],
totalWeightKg: item.weightKg,
});
}
}
return { boxes, nextBoxId: startBoxId + boxes.length };
}
/**
* Generate packing list from a cutting list result.
* Uses the APEX 4.0 layer-based strategy:
* Level 1 → sides stacked
* Level 2 → top + bottom side by side
* Level 3 → shelves + reinforcements grouped by thickness
*
* Each level is independently bin-packed with FFD, then all boxes
* are merged into a single sequential list.
*
* @param {Object} cuttingList - output of generateCuttingList()
* @returns {Object} packing summary
*/
export function generatePackingList(cuttingList) {
if (!cuttingList || !cuttingList.pieces || cuttingList.pieces.length === 0) {
return {
boxes: [],
totalBoxes: 0,
maxWeightKg: PACKING.maxWeightKg,
heaviestBoxKg: 0,
totalWeightKg: 0,
strategy: 'layer-based (APEX 4.0)',
};
}
const items = expandPieces(cuttingList.pieces);
const { level1, level2, level3 } = classifyByLayer(items);
// Pack each level independently
const pack1 = firstFitDecreasing(level1, 1, 'Niveau 1 — Côtés');
const pack2 = firstFitDecreasing(level2, pack1.nextBoxId, 'Niveau 2 — Dessus/Fond');
const pack3 = firstFitDecreasing(level3, pack2.nextBoxId, 'Niveau 3 — Étagères/Renforts');
const allBoxes = [...pack1.boxes, ...pack2.boxes, ...pack3.boxes];
const heaviestBoxKg = allBoxes.reduce((max, b) => Math.max(max, b.totalWeightKg), 0);
const totalWeightKg = allBoxes.reduce((sum, b) => sum + b.totalWeightKg, 0);
return {
boxes: allBoxes,
totalBoxes: allBoxes.length,
maxWeightKg: PACKING.maxWeightKg,
heaviestBoxKg: Math.round(heaviestBoxKg * 10) / 10,
totalWeightKg: Math.round(totalWeightKg * 10) / 10,
strategy: 'layer-based (APEX 4.0)',
layerSummary: {
level1: { label: 'Côtés empilés', boxes: pack1.boxes.length, items: level1.length },
level2: { label: 'Dessus + Fond', boxes: pack2.boxes.length, items: level2.length },
level3: { label: 'Étagères + Renforts', boxes: pack3.boxes.length, items: level3.length },
},
};
}
export default { generatePackingList };
+583 -183
View File
@@ -1,170 +1,113 @@
/**
* Plan Generator - Generate 2D assembly and parts plans in SVG format
* Also handles PDF export
* Includes APEX 4.0 manufacturing details: System 32 drilling, hinge
* positions, Ixconnect connectors, back panel groove, and reinforcements.
* Also handles PDF export.
*/
import {
PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS,
getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE,
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING
} from '../../shared/manufacturing.js';
// Constants for drawing
const DRAWING_SCALE = 0.1; // 1mm = 0.1px
const MARGIN = 50;
const LABEL_OFFSET = 15;
const ARROW_SIZE = 8;
// ── Shared SVG styles ────────────────────────────────────────────────
const SHARED_STYLES = `
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.inner { fill: none; stroke: #666; stroke-width: 0.5; }
.dimension { fill: none; stroke: #0066cc; stroke-width: 0.5; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
.label { font-family: Arial, sans-serif; font-size: 9px; fill: #333; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.door { fill: #d4c4a4; stroke: #333; stroke-width: 1; opacity: 0.8; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
.s32-hole { fill: none; stroke: #e63946; stroke-width: 0.4; }
.hinge-marker { fill: #ff6b35; stroke: #333; stroke-width: 0.3; }
.groove { fill: none; stroke: #2a9d8f; stroke-width: 1; stroke-dasharray: 2,2; }
.connector { fill: #6c5ce7; stroke: #333; stroke-width: 0.3; }
.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; }
`;
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Generate SVG for assembly view (front elevation)
* Draw System 32 hole column (vertical series of holes)
*/
export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType, installationType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.inner { fill: none; stroke: #666; stroke-width: 0.5; }
.dimension { fill: none; stroke: #0066cc; stroke-width: 0.5; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
.label { font-family: Arial, sans-serif; font-size: 9px; fill: #333; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.door { fill: #d4c4a4; stroke: #333; stroke-width: 1; opacity: 0.8; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
</style>`;
// Title
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${widthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Module separators
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length - 1; i++) {
currentX += modules[i].widthMm * DRAWING_SCALE;
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
}
function drawSystem32Holes(x, topY, panelHeightMm, scale) {
let svg = '';
const usable = panelHeightMm - SYSTEM32.firstHoleOffset;
const count = Math.max(0, Math.floor(usable / SYSTEM32.pitch) + 1);
const r = (SYSTEM32.holeDiameter / 2) * scale;
for (let i = 0; i < count; i++) {
const yOffset = (SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch) * scale;
svg += `<circle cx="${x}" cy="${topY + yOffset}" r="${r}" class="s32-hole" />`;
}
// Doors
if (doorType === 'sliding') {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = (widthMm / panelCount) * DRAWING_SCALE;
for (let i = 0; i < panelCount; i++) {
svg += `<rect x="${offsetX + i * panelWidth * 0.95 + 5}" y="${offsetY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * DRAWING_SCALE}" class="door" />`;
}
return svg;
}
/**
* Draw hinge position markers on a door plan (côté charnières)
*/
function drawHingeMarkers(doorX, doorY, doorWidthMm, doorHeightMm, scale, hingeSide = 'left') {
let svg = '';
const markerH = 6;
const markerW = 3;
const hingeCount = getHingeCountForDoor({ heightMm: doorHeightMm });
const positions = getHingeCupPositions(doorHeightMm, hingeCount);
const doorW = doorWidthMm * scale;
const cupInset = Math.min(22 * scale, doorW * 0.13);
const hingeLineX = hingeSide === 'right' ? doorX + doorW - cupInset : doorX + cupInset;
for (const posMm of positions) {
const y = doorY + posMm * scale;
svg += `<rect x="${hingeLineX - markerW / 2}" y="${y - markerH / 2}" width="${markerW}" height="${markerH}" class="hinge-marker" />`;
}
return svg;
}
/**
* Rainure poignée toute hauteur — côté opposé aux charnières, retrait H/B (HANDLE.fullHeightTopBottomOffset)
*/
function drawFullHeightHandleGrooveElevation(doorX, doorY, doorWidthMm, doorHeightMm, scale, hingeSide) {
const off = HANDLE.fullHeightTopBottomOffset;
const gw = HANDLE.fullHeightGroove.width;
const doorW = doorWidthMm * scale;
const axisX = hingeSide === 'right'
? doorX + (gw / 2) * scale
: doorX + doorW - (gw / 2) * scale;
const y1 = doorY + off * scale;
const y2 = doorY + doorHeightMm * scale - off * scale;
let svg = `<line x1="${axisX}" y1="${y1}" x2="${axisX}" y2="${y2}" class="fh-groove" />`;
const tx = axisX + (hingeSide === 'right' ? 5 : -5);
const anchor = hingeSide === 'right' ? 'start' : 'end';
svg += `<text x="${tx}" y="${doorY + doorHeightMm * scale * 0.45}" class="detail-label" text-anchor="${anchor}" fill="#0d9488">Rainure poignée TH (${gw} mm) · ${off} mm H/B</text>`;
return svg;
}
/**
* Draw back panel groove indicator (dashed line near panel edge).
* Per updated spec: groove at 16mm from panel edge.
*/
function drawGrooveIndicator(x, y, length, direction, scale) {
let svg = '';
const grooveOffset = GROOVE.back.axisFromPanelEdge * scale;
if (direction === 'vertical') {
svg += `<line x1="${x + grooveOffset}" y1="${y}" x2="${x + grooveOffset}" y2="${y + length}" class="groove" />`;
} else {
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
let currentX = offsetX;
const items = modules.length > 0 ? modules : (config.blocks || []);
for (let i = 0; i < blockCount; i++) {
const blockWidth = items[i]?.widthMm || (widthMm / blockCount);
svg += `<rect x="${currentX + 3}" y="${offsetY + 3}" width="${blockWidth * DRAWING_SCALE - 6}" height="${(bodyHeight - 6) * DRAWING_SCALE}" class="door" />`;
currentX += blockWidth * DRAWING_SCALE;
}
svg += `<line x1="${x}" y1="${y + grooveOffset}" x2="${x + length}" y2="${y + grooveOffset}" class="groove" />`;
}
// Dimension lines
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, widthMm * DRAWING_SCALE, `${widthMm} mm`, 'horizontal');
svg += generateDimensionLine(offsetX - 20, offsetY, bodyHeight * DRAWING_SCALE, `${bodyHeight} mm`, 'vertical');
svg += generateDimensionLine(offsetX - 40, offsetY, heightMm * DRAWING_SCALE, `${heightMm} mm (total)`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for side view
*/
export function generateSidePlan(config) {
const { heightMm, depthMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = depthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${depthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${depthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Dimension
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, depthMm * DRAWING_SCALE, `${depthMm} mm`, 'horizontal');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for top view
*/
export function generateTopPlan(config, modules = []) {
const { widthMm, depthMm } = config;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = depthMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.label { font-family: Arial, sans-serif; font-size: 8px; fill: #666; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Body outline
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${depthMm * DRAWING_SCALE}" class="body" />`;
// Module separators and labels
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length; i++) {
const module = modules[i];
const moduleWidth = module.widthMm * DRAWING_SCALE;
// Label
svg += `<text x="${currentX + moduleWidth / 2}" y="${offsetY + depthMm * DRAWING_SCALE / 2}" class="label" text-anchor="middle">${module.moduleCode || `M${i + 1}`}</text>`;
// Separator
if (i < modules.length - 1) {
currentX += moduleWidth;
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${offsetY + depthMm * DRAWING_SCALE}" class="separator" />`;
}
}
}
svg += '</svg>';
return svg;
}
@@ -173,25 +116,366 @@ export function generateTopPlan(config, modules = []) {
*/
function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
let svg = '';
if (direction === 'horizontal') {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x + length}" y2="${y}" class="dimension" />`;
// Arrows
svg += `<polygon points="${x},${y} ${x + ARROW_SIZE},${y - 3} ${x + ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
svg += `<polygon points="${x + length},${y} ${x + length - ARROW_SIZE},${y - 3} ${x + length - ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
// Label
svg += `<text x="${x + length / 2}" y="${y + LABEL_OFFSET}" class="dim-text" text-anchor="middle">${label}</text>`;
} else {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x}" y2="${y + length}" class="dimension" />`;
// Arrows
svg += `<polygon points="${x},${y} ${x - 3},${y + ARROW_SIZE} ${x + 3},${y + ARROW_SIZE}" fill="#0066cc" />`;
svg += `<polygon points="${x},${y + length} ${x - 3},${y + length - ARROW_SIZE} ${x + 3},${y + length - ARROW_SIZE}" fill="#0066cc" />`;
// Label (rotated)
svg += `<text x="${x - LABEL_OFFSET}" y="${y + length / 2}" class="dim-text" text-anchor="middle" transform="rotate(-90 ${x - LABEL_OFFSET} ${y + length / 2})">${label}</text>`;
}
return svg;
}
// ── Plan generators ──────────────────────────────────────────────────
/**
* Generate SVG for assembly view (front elevation)
*/
export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const S = DRAWING_SCALE;
const svgWidth = widthMm * S + MARGIN * 2;
const svgHeight = heightMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
// Title
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
const oX = MARGIN;
const oY = MARGIN;
// Plinth
const plinthY = oY + bodyHeight * S;
svg += `<rect x="${oX}" y="${plinthY}" width="${widthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
// Body
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${bodyHeight * S}" class="body" />`;
// Module separators
if (modules.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" />`;
}
}
// System 32 holes on left and right side panels
svg += drawSystem32Holes(oX + PANEL.structure * S / 2, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
svg += drawSystem32Holes(oX + widthMm * S - PANEL.structure * S / 2, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
// Doors
if (doorType === 'sliding') {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = (widthMm / panelCount) * S;
for (let i = 0; i < panelCount; i++) {
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);
let currentX = oX;
const items = modules.length > 0 ? modules : (config.blocks || []);
const hw = config.hardware || {};
const fullHeightHandle = hw.handleType === 'full_height';
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
const gapMm = 2;
const doorHmm = bodyHeight - 4;
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 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);
}
currentX += modulePx;
}
}
// Reinforcements (Forteresse 16 mm):
// width = armoire width - 2×19 (entre côtés),
// one at top + one at bottom (offset 19 mm),
// third one centered if wardrobe height > 1500 mm.
const renfortX = oX + PANEL.structure * S;
const renfortW = Math.max(0, (widthMm - PANEL.structure * 2) * S);
const renfortH = PANEL.reinforcement * S;
const topRenfortY = oY + REINFORCEMENT.topBottomOffsetMm * S;
const bottomRenfortY = plinthY - REINFORCEMENT.topBottomOffsetMm * S - renfortH;
const reinforcementYs = [topRenfortY, bottomRenfortY];
if (heightMm > REINFORCEMENT.thirdRequiredAboveHeightMm) {
reinforcementYs.push(oY + (bodyHeight * S) / 2 - renfortH / 2);
}
for (const y of reinforcementYs) {
svg += `<rect x="${renfortX}" y="${y}" width="${renfortW}" height="${renfortH}" class="renfort" />`;
}
svg += `<text x="${oX + (widthMm * S) / 2}" y="${svgHeight - 10}" class="detail-label" text-anchor="middle" fill="#6b5344" font-size="7.5">Renforts Forteresse (16 mm) : fixation par montant — ${REINFORCEMENT_MACHINING.connectorModel} au centre + 2× tourillons Ø${REINFORCEMENT_MACHINING.tourillonDiameter} à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords · axes sur plan pièce « Renforts »</text>`;
// Dimension lines
svg += generateDimensionLine(oX, plinthY + plinthHeightMm * S + 20, widthMm * S, `${widthMm} mm`, 'horizontal');
svg += generateDimensionLine(oX - 20, oY, bodyHeight * S, `${bodyHeight} mm`, 'vertical');
svg += generateDimensionLine(oX - 40, oY, heightMm * S, `${heightMm} mm (total)`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for side view — includes groove indicator and System 32 holes
*/
export function generateSidePlan(config) {
const { heightMm, depthMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const S = DRAWING_SCALE;
const svgWidth = depthMm * S + MARGIN * 2;
const svgHeight = heightMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
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" />`;
// Body
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;
svg += `<rect x="${backX}" y="${oY + PANEL.structure * S}" width="${PANEL.back * S}" height="${(bodyHeight - PANEL.structure * 2) * S}" fill="#d4c4a4" stroke="#666" stroke-width="0.5" />`;
svg += `<text x="${backX - 2}" y="${oY + bodyHeight * S / 2}" class="detail-label" text-anchor="end">Dos ${PANEL.back}mm</text>`;
// Groove indicator (dashed green line at 16mm from panel edge)
const grooveAxisX = oX + (depthMm - GROOVE.back.axisFromPanelEdge) * S;
svg += `<line x1="${grooveAxisX}" y1="${oY + PANEL.structure * S}" x2="${grooveAxisX}" y2="${plinthY - PANEL.structure * S}" class="groove" />`;
svg += `<text x="${grooveAxisX - 3}" y="${oY + bodyHeight * S * 0.3}" class="detail-label" text-anchor="end" fill="#2a9d8f">Rainure ${GROOVE.back.width}×${GROOVE.back.depth} (16mm du bord)</text>`;
// System 32 holes along the side panel
svg += drawSystem32Holes(oX + 37 * S, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
svg += `<text x="${oX + 37 * S}" y="${oY - 5}" class="detail-label" text-anchor="middle" fill="#e63946">System 32</text>`;
// Dimension lines
svg += generateDimensionLine(oX, plinthY + plinthHeightMm * S + 20, depthMm * S, `${depthMm} mm`, 'horizontal');
svg += generateDimensionLine(oX - 20, oY, bodyHeight * S, `${bodyHeight} mm`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for top view — includes groove line for back panel
*/
export function generateTopPlan(config, modules = []) {
const { widthMm, depthMm } = config;
const S = DRAWING_SCALE;
const svgWidth = widthMm * S + MARGIN * 2;
const svgHeight = depthMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
const oX = MARGIN;
const oY = MARGIN;
// Body outline
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${depthMm * S}" class="body" />`;
// Back panel groove line (horizontal dashed line near panel edge)
const grooveY = oY + (depthMm - PANEL.structure) * S;
svg += `<line x1="${oX + PANEL.structure * S}" y1="${grooveY}" x2="${oX + (widthMm - PANEL.structure) * S}" y2="${grooveY}" class="groove" />`;
svg += `<text x="${oX + widthMm * S + 5}" y="${grooveY + 3}" class="detail-label" fill="#2a9d8f">Rainure dos</text>`;
// Module separators and labels
if (modules.length > 0) {
let currentX = oX;
for (let i = 0; i < modules.length; i++) {
const module = modules[i];
const moduleWidth = module.widthMm * S;
// Label
svg += `<text x="${currentX + moduleWidth / 2}" y="${oY + depthMm * S / 2}" class="label" text-anchor="middle">${module.moduleCode || `M${i + 1}`}</text>`;
// Separator
if (i < modules.length - 1) {
currentX += moduleWidth;
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${oY + depthMm * S}" class="separator" />`;
}
}
}
svg += '</svg>';
return svg;
}
/**
* Generate SVG detail plan — zoomed view of a side panel showing
* System 32 drilling, connector holes, groove, and edge banding
*/
export function generateDetailPlan(config) {
const { depthMm, heightMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const innerWidth = config.widthMm - (PANEL.structure * 2);
const innerDepth = depthMm - PANEL.structure;
// Zoomed scale for detail view (3× normal)
const DS = 0.3;
// Show top 600mm of a side panel (enough for connectors, first S32 holes, groove)
const detailHeightMm = Math.min(600, bodyHeight);
const detailWidthMm = depthMm;
// Extra space on the right for shelf drilling inset
const svgWidth = detailWidthMm * DS + MARGIN * 2 + 260;
const svgHeight = detailHeightMm * DS + MARGIN * 2 + 40;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}
.edge-band { fill: #ff9f1c; stroke: none; opacity: 0.6; }
.callout { font-family: Arial, sans-serif; font-size: 8px; fill: #333; }
.callout-line { stroke: #999; stroke-width: 0.3; fill: none; }
.shelf-outline { fill: #f9fafb; stroke: #111827; stroke-width: 0.8; }
.shelf-face-hole { fill: none; stroke: #ea580c; stroke-width: 0.55; }
.shelf-edge-hole { fill: none; stroke: #7c3aed; stroke-width: 0.55; stroke-dasharray: 1.2 1.2; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="20" class="title" text-anchor="middle">Détail — Côté (vue intérieure)</text>`;
const oX = MARGIN + 40;
const oY = MARGIN + 20;
// Panel outline
svg += `<rect x="${oX}" y="${oY}" width="${detailWidthMm * DS}" height="${detailHeightMm * DS}" class="body" stroke-width="2" />`;
// Edge banding (2mm ABS on all 4 edges)
const eb = EDGE_BANDING.abs * DS;
svg += `<rect x="${oX}" y="${oY}" width="${detailWidthMm * DS}" height="${eb}" class="edge-band" />`; // top
svg += `<rect x="${oX}" y="${oY + detailHeightMm * DS - eb}" width="${detailWidthMm * DS}" height="${eb}" class="edge-band" />`; // bottom
svg += `<rect x="${oX}" y="${oY}" width="${eb}" height="${detailHeightMm * DS}" class="edge-band" />`; // left
svg += `<rect x="${oX + detailWidthMm * DS - eb}" y="${oY}" width="${eb}" height="${detailHeightMm * DS}" class="edge-band" />`; // right
// Back panel groove — 16mm from panel edge
const grooveAxisOffset = (detailWidthMm - GROOVE.back.axisFromPanelEdge) * DS;
const grooveX = oX + grooveAxisOffset - (GROOVE.back.width / 2) * DS;
const grooveW = GROOVE.back.width * DS;
svg += `<rect x="${grooveX}" y="${oY + 5}" width="${grooveW}" height="${detailHeightMm * DS - 10}" fill="#2a9d8f" opacity="0.3" stroke="#2a9d8f" stroke-width="0.5" />`;
// Callout
svg += `<line x1="${grooveX + grooveW}" y1="${oY + 30}" x2="${grooveX + grooveW + 25}" y2="${oY + 20}" class="callout-line" />`;
svg += `<text x="${grooveX + grooveW + 27}" y="${oY + 22}" class="callout">Rainure ${GROOVE.back.width}×${GROOVE.back.depth} mm</text>`;
svg += `<text x="${grooveX + grooveW + 27}" y="${oY + 31}" class="callout">Position à ${GROOVE.back.axisFromPanelEdge} mm du bord</text>`;
// System 32 holes (front row) — blind holes, depth 12mm
const s32X = oX + 37 * DS; // 37mm from front edge
const usable = detailHeightMm - SYSTEM32.firstHoleOffset * 2; // first AND last at 51mm
const holeCount = Math.max(0, Math.floor(usable / SYSTEM32.pitch) + 1);
const r = (SYSTEM32.holeDiameter / 2) * DS;
for (let i = 0; i < Math.min(holeCount, 15); i++) {
const holeY = oY + (SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch) * DS;
svg += `<circle cx="${s32X}" cy="${holeY}" r="${r}" class="s32-hole" />`;
}
// Callout for System 32
svg += `<line x1="${s32X}" y1="${oY + SYSTEM32.firstHoleOffset * DS}" x2="${oX - 15}" y2="${oY + SYSTEM32.firstHoleOffset * DS - 10}" class="callout-line" />`;
svg += `<text x="${oX - 17}" y="${oY + SYSTEM32.firstHoleOffset * DS - 12}" class="callout" text-anchor="end">System 32 (borgne)</text>`;
svg += `<text x="${oX - 17}" y="${oY + SYSTEM32.firstHoleOffset * DS - 3}" class="callout" text-anchor="end">Ø${SYSTEM32.holeDiameter} / prof. ${SYSTEM32.holeDepth} / pas ${SYSTEM32.pitch}</text>`;
// First hole offset dimension
svg += generateDimensionLine(oX + detailWidthMm * DS + 10, oY, SYSTEM32.firstHoleOffset * DS, `${SYSTEM32.firstHoleOffset}`, 'vertical');
// Ixconnect connector holes (top edge) — extrémité d’arête: T@20 → IX@50 → T@80 (vue détail côté)
const conn = CONNECTORS.ixconnect;
const pattern = CONNECTORS.jointPattern;
// Show 3 holes: tourillon@20, ixconnect@50, tourillon@80 from left edge (arête extrémité)
const jT1X = oX + pattern.tourillon1Axis * DS;
const jIXX = oX + pattern.ixconnectAxis * DS;
const jT2X = oX + pattern.tourillon2Axis * DS;
const tourR = (CONNECTORS.tourillonDiameter / 2) * DS;
const ixEdgeR = (conn.edgeHole.diameter / 2) * DS;
// Edge holes on top edge
svg += `<circle cx="${jT1X}" cy="${oY}" r="${tourR}" class="connector" opacity="0.5" />`; // tourillon 1
svg += `<circle cx="${jIXX}" cy="${oY}" r="${ixEdgeR}" class="connector" />`; // Ixconnect
svg += `<circle cx="${jT2X}" cy="${oY}" r="${tourR}" class="connector" opacity="0.5" />`; // tourillon 2
// Face holes (Ixconnect only — Ø8×12mm)
svg += `<circle cx="${jIXX}" cy="${oY + conn.faceHole.depth * DS}" r="${(conn.faceHole.diameter / 2) * DS}" class="connector" />`;
// Callout
svg += `<line x1="${jT2X + 5}" y1="${oY}" x2="${jT2X + 30}" y2="${oY - 15}" class="callout-line" />`;
svg += `<text x="${jT2X + 32}" y="${oY - 22}" class="callout">Ixconnect ${conn.model}</text>`;
svg += `<text x="${jT2X + 32}" y="${oY - 13}" class="callout">About: Ø${conn.edgeHole.diameter}×${conn.edgeHole.depth} / Face: Ø${conn.faceHole.diameter}×${conn.faceHole.depth}</text>`;
svg += `<text x="${jT2X + 32}" y="${oY - 4}" class="callout">Pattern: T@${pattern.tourillon1Axis} → IX@${pattern.ixconnectAxis} → T@${pattern.tourillon2Axis}</text>`;
// Panel thickness label
svg += `<text x="${oX + detailWidthMm * DS / 2}" y="${oY + detailHeightMm * DS + 15}" class="callout" text-anchor="middle">Épaisseur: ${PANEL.structure} mm — Chant ABS ${EDGE_BANDING.abs} mm</text>`;
// --- Inset: Rayons mobiles (Étagères amovibles) — SP 15/8/5 ---
// Dimensions de découpe : L.int - 2 mm / P.int - 5 mm
const shelfWmm = Math.max(80, innerWidth - 2);
const shelfDmm = Math.max(80, innerDepth - 5);
const insetMaxWpx = 170;
const insetMaxHpx = 190;
const insetScale = Math.min(insetMaxWpx / shelfWmm, insetMaxHpx / shelfDmm);
const insetX = oX + detailWidthMm * DS + 60;
const insetY = oY + 40;
const insetW = shelfWmm * insetScale;
const insetH = shelfDmm * insetScale;
svg += `<text x="${insetX + insetW / 2}" y="${insetY - 16}" class="callout" text-anchor="middle">Rayons mobiles — SP 15/8/5 (dessous)</text>`;
svg += `<rect x="${insetX}" y="${insetY}" width="${insetW}" height="${insetH}" class="shelf-outline" />`;
// Hole positions: from front 32mm; from back 37mm
const yFrontMm = 32;
const yBackMm = shelfDmm - 37;
const yPosMm = [yFrontMm, yBackMm].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
// Face holes: Ø20 axis 9.5mm from left/right
const xSideMm = 9.5;
const xPosMm = [xSideMm, shelfWmm - xSideMm].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
const rFace = (20 / 2) * insetScale;
const rEdge = (8 / 2) * insetScale;
for (const yMm of yPosMm) {
const cy = insetY + yMm * insetScale;
// Face holes (underside) — left/right
for (const xMm of xPosMm) {
const cx = insetX + xMm * insetScale;
svg += `<circle cx="${cx}" cy="${cy}" r="${rFace}" class="shelf-face-hole" />`;
}
// Edge holes (against side panels) — left/right edge
svg += `<circle cx="${insetX}" cy="${cy}" r="${rEdge}" class="shelf-edge-hole" />`;
svg += `<circle cx="${insetX + insetW}" cy="${cy}" r="${rEdge}" class="shelf-edge-hole" />`;
}
// Callouts
svg += `<line x1="${insetX + insetW}" y1="${insetY + 10}" x2="${insetX + insetW + 22}" y2="${insetY}" class="callout-line" />`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 2}" class="callout">Face: Ø20×15 — axe 9.5 mm</text>`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 12}" class="callout">Chant: Ø8×37 — à 6 mm du dessous</text>`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 22}" class="callout">Avant: 32 mm · Arrière: 37 mm</text>`;
svg += '</svg>';
return svg;
}
@@ -203,8 +487,10 @@ export function generateAllPlans(config, modules = [], colors = {}) {
assembly: generateAssemblyPlan(config, modules),
side: generateSidePlan(config),
top: generateTopPlan(config, modules),
detail: generateDetailPlan(config),
metadata: {
generatedAt: new Date().toISOString(),
specs: 'APEX 4.0',
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
@@ -220,14 +506,14 @@ export function generateAllPlans(config, modules = [], colors = {}) {
/**
* Generate HTML page with all plans for PDF export
*/
export function generatePDFHtml(config, modules = [], cuttingList = null) {
export function generatePDFHtml(config, modules = [], cuttingList = null, packingList = null) {
const plans = generateAllPlans(config, modules);
let html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Plans Armoire</title>
<title>Plans Armoire — APEX 4.0</title>
<style>
@page { size: A4; margin: 15mm; }
body { font-family: Arial, sans-serif; font-size: 11px; }
@@ -244,15 +530,19 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
.summary h3 { margin: 0 0 10px; }
.specs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.spec-item { display: flex; justify-content: space-between; }
.mfg-specs { background: #fff8e7; padding: 12px; border-radius: 6px; margin: 15px 0; border-left: 4px solid #c9a96e; }
.mfg-specs h4 { margin: 0 0 8px; color: #8b7355; }
.mfg-specs ul { margin: 0; padding-left: 18px; }
.mfg-specs li { margin: 2px 0; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<h1 class="title">Plans de fabrication - Armoire sur mesure</h1>
<p class="subtitle">Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}</p>
<p class="subtitle">APEX 4.0 — Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}</p>
</div>
<div class="summary">
<h3>Spécifications</h3>
<div class="specs">
@@ -264,25 +554,53 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<div class="spec-item"><span>Portes:</span><strong>${config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</strong></div>
</div>
</div>
<div class="mfg-specs">
<h4>Normes de fabrication APEX 4.0</h4>
<ul>
<li>Structure ${PANEL.structure} mm / Renforts ${PANEL.reinforcement} mm / Dos ${PANEL.back} mm</li>
<li>Chants ABS ${EDGE_BANDING.abs} mm visible — Dos: ABS sur ${EDGE_BANDING.backPanelSides} chants</li>
<li>Rainure dos ${GROOVE.back.width}×${GROOVE.back.depth} mm — position à ${GROOVE.back.axisFromPanelEdge} mm du bord</li>
<li>System 32 côtés : Ø${SYSTEM32.holeDiameter} mm borgne, prof. ${SYSTEM32.holeDepth} mm, pas ${SYSTEM32.pitch} mm, offset ${SYSTEM32.firstHoleOffset} mm</li>
<li>System 32 séparateurs : Ø${SYSTEM32_SEPARATORS.holeDiameter} mm traversant, offset ${SYSTEM32_SEPARATORS.firstHoleOffset} mm</li>
<li>Connecteurs ${CONNECTORS.ixconnect.model} (structure) : about Ø${CONNECTORS.ixconnect.edgeHole.diameter}×${CONNECTORS.ixconnect.edgeHole.depth} / face Ø${CONNECTORS.ixconnect.faceHole.diameter}×${CONNECTORS.ixconnect.faceHole.depth}</li>
<li>Connecteurs ${CONNECTORS.ixconnectFinish.model} (finitions) : face Ø${CONNECTORS.ixconnectFinish.faceHole.diameter}×${CONNECTORS.ixconnectFinish.faceHole.depth}, axes ${CONNECTORS.ixconnectFinish.axes.back}/${CONNECTORS.ixconnectFinish.axes.front} mm</li>
<li>Ixconnect: extrémités d’arête T@${CONNECTORS.jointPattern.tourillon1Axis} → IX@${CONNECTORS.jointPattern.ixconnectAxis} → T@${CONNECTORS.jointPattern.tourillon2Axis} mm (depuis bord avant) · jonctions internes dessus/fond: IX au milieu profondeur + T ±${CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm} mm — faces séparateurs verticaux sans Ixconnect</li>
<li>Renforts : 1× ${REINFORCEMENT_MACHINING.connectorModel} au centre + 2× tourillons Ø${REINFORCEMENT_MACHINING.tourillonDiameter} à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords (axes selon largeur renfort)</li>
<li>Charnières ${HINGE.model} : axe à ${HINGE.axisOffset} mm du haut et du bas (réversible)</li>
<li>Matrice charnières: ≤900mm=2, 901-1600=3, 1601-2000=4, 2001-2400=5, 2401-2600=6</li>
<li>Étagères : ${SHELF_MACHINING.tourillonCount}× tourillons Ø${SHELF_MACHINING.tourillonDiameter} — ${SHELF_MACHINING.shelvesPerStratum} rayons/strate · Rayons mobiles SP 15/8/5 : face Ø20×15 (axe 9.5) + chant Ø8×37 (à 6 du dessous) · avant 32 / arrière 37</li>
<li>Plinthe : ${PLINTH.model} (pieds ${PLINTH.footHeight} mm + clips)</li>
<li>Dessus/Fond : ABS ${EDGE_BANDING.abs} mm avant ET arrière</li>
${config.hardware?.handleType === 'full_height' ? `<li>Poignée toute hauteur : déduction ${HANDLE.fullHeightWidthDeductionMm} mm en largeur porte ; rainure ${HANDLE.fullHeightGroove.width}×${HANDLE.fullHeightGroove.depth} mm côté opposé aux charnières ; retrait mécanique ${HANDLE.fullHeightTopBottomOffset} mm haut et bas</li>` : ''}
</ul>
</div>
<div class="plan-container">
<div class="plan-title">Vue de face (Élévation)</div>
${plans.assembly}
</div>
</div>
<div class="page">
<div class="plan-container">
<div class="plan-title">Vue de côté</div>
${plans.side}
</div>
<div class="plan-container">
<div class="plan-title">Vue de dessus</div>
${plans.top}
</div>
</div>
<div class="page">
<div class="plan-container">
<div class="plan-title">Détail constructif — Côté</div>
${plans.detail}
</div>
</div>`;
// Cutting list page
if (cuttingList) {
html += `
@@ -293,11 +611,11 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<div class="specs">
<div class="spec-item"><span>Nombre de pièces:</span><strong>${cuttingList.summary.totalPieces}</strong></div>
<div class="spec-item"><span>Surface totale:</span><strong>${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²</strong></div>
<div class="spec-item"><span>Chant plaqué:</span><strong>${cuttingList.summary.edgeBandingMl} ml</strong></div>
<div class="spec-item"><span>Chant ABS 2mm:</span><strong>${cuttingList.summary.edgeBandingMl} ml</strong></div>
<div class="spec-item"><span>Finitions:</span><strong>${cuttingList.summary.finishCount}</strong></div>
</div>
</div>`;
for (const group of cuttingList.byFinish) {
html += `
<h3>Finition: ${group.finishLabel}</h3>
@@ -310,11 +628,15 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<th>Ép.</th>
<th>Qté</th>
<th>Surface</th>
<th>Usinage</th>
</tr>
</thead>
<tbody>`;
for (const piece of group.pieces) {
const machStr = piece.machining
? Object.keys(piece.machining).join(', ')
: '-';
html += `
<tr>
<td>${piece.description}</td>
@@ -323,36 +645,114 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<td>${piece.thicknessMm} mm</td>
<td>${piece.quantity}</td>
<td>${piece.surfaceM2.toFixed(3)} m²</td>
<td>${machStr}</td>
</tr>`;
}
html += `
</tbody>
<tfoot>
<tr>
<th colspan="4">Sous-total</th>
<th>${group.pieceCount}</th>
<th colspan="5">Sous-total</th>
<th>${group.pieceCount} pcs</th>
<th>${group.totalSurfaceM2.toFixed(2)} m²</th>
</tr>
</tfoot>
</table>`;
}
html += `
</div>`;
// Piece-by-piece machining plan pages
for (const piece of (cuttingList.pieces || [])) {
if (!piece.piecePlanSvg) continue;
html += `
<div class="page">
<h2>Plan pièce - ${piece.description}</h2>
<p style="margin:0 0 8px;color:#555;">
Fini: ${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm
&nbsp;|&nbsp;
Brut CNC: ${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm
&nbsp;|&nbsp;
Qté: ${piece.quantity}
</p>
<div class="plan-container">
${piece.piecePlanSvg}
</div>
</div>`;
}
}
// Packing list page (layer-based per APEX 4.0)
if (packingList && packingList.boxes && packingList.boxes.length > 0) {
html += `
<div class="page">
<h2>Liste de colisage — Stratégie par niveaux (APEX 4.0)</h2>
<div class="summary">
<h3>Résumé</h3>
<div class="specs">
<div class="spec-item"><span>Nombre de colis:</span><strong>${packingList.totalBoxes}</strong></div>
<div class="spec-item"><span>Poids max/colis:</span><strong>${packingList.maxWeightKg} kg</strong></div>
<div class="spec-item"><span>Colis le plus lourd:</span><strong>${packingList.heaviestBoxKg.toFixed(1)} kg</strong></div>
<div class="spec-item"><span>Poids total:</span><strong>${packingList.totalWeightKg.toFixed(1)} kg</strong></div>
</div>
</div>`;
// Layer summary table
if (packingList.layerSummary) {
const ls = packingList.layerSummary;
html += `
<table>
<thead><tr><th>Niveau</th><th>Contenu</th><th>Colis</th><th>Pièces</th></tr></thead>
<tbody>
<tr><td>1</td><td>${ls.level1.label}</td><td>${ls.level1.boxes}</td><td>${ls.level1.items}</td></tr>
<tr><td>2</td><td>${ls.level2.label}</td><td>${ls.level2.boxes}</td><td>${ls.level2.items}</td></tr>
<tr><td>3</td><td>${ls.level3.label}</td><td>${ls.level3.boxes}</td><td>${ls.level3.items}</td></tr>
</tbody>
</table>`;
}
for (const box of packingList.boxes) {
html += `
<h3>Colis ${box.id}${box.level ? ` — ${box.level}` : ''} — ${box.totalWeightKg.toFixed(1)} kg</h3>
<table>
<thead>
<tr><th>Pièce</th><th>Dim. (L×H×Ép.)</th><th>Qté</th><th>Poids</th></tr>
</thead>
<tbody>`;
for (const item of box.pieces) {
html += `
<tr>
<td>${item.description}</td>
<td>${item.widthMm}×${item.heightMm}×${item.thicknessMm} mm</td>
<td>${item.quantity}</td>
<td>${item.weightKg.toFixed(1)} kg</td>
</tr>`;
}
html += `
</tbody>
</table>`;
}
html += `
</div>`;
}
html += `
</body>
</html>`;
return html;
}
export default {
generateAssemblyPlan,
generateSidePlan,
generateTopPlan,
export default {
generateAssemblyPlan,
generateSidePlan,
generateTopPlan,
generateDetailPlan,
generateAllPlans,
generatePDFHtml
generatePDFHtml
};
+89 -10
View File
@@ -1,15 +1,23 @@
/**
* Moteur de calcul de prix — configurateur armoires sur mesure.
* Règles : panneaux entiers (chute intégrée), blocs, kit, options.
* Specs APEX 4.0 importées de shared/manufacturing.js
*/
import { PANEL, DRAWERS, NICHE, getHingeCountForDoor } from '../../shared/manufacturing.js';
function numberOr(value, fallback) {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
}
/**
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
* Épaisseur panneaux 19 mm (référence).
* Épaisseur panneaux 19 mm (référence APEX 4.0).
* @param {Object} config - configuration (installation, width, height, depth, plinthHeightMm, blocks, doorType, slidingPanels)
* @param {number} panelThicknessMm - épaisseur panneaux (19)
*/
function calculateVisibleSurface(config, panelThicknessMm = 19) {
function calculateVisibleSurface(config, panelThicknessMm = PANEL.structure) {
const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], doorType, slidingPanelsCount } = config;
const isNiche = installationType === 'niche';
let surface = 0;
@@ -204,12 +212,13 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
}
}
// Hinge costs - typically 3 per hinged door
// Hinge costs - dynamic quantity from APEX 4.0 matrix
if (hardware.hingeVariantId && config.doorType !== 'sliding') {
const hinge = hinges.find(h => h.id === hardware.hingeVariantId);
if (hinge) {
const doorCount = config.modules?.length || config.blocks?.length || 2;
const hingesPerDoor = 3;
const doorHeightMm = (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4;
const hingesPerDoor = getHingeCountForDoor({ heightMm: doorHeightMm });
const quantity = doorCount * hingesPerDoor;
const cost = hinge.price_chf * quantity;
totalChf += cost;
@@ -263,16 +272,22 @@ export function calculatePrice(config, data) {
if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)) v = Number(v);
settings[r.key] = v;
}
const kitFeeChf = Number(settings.kit_preparation_fee_chf) ?? 350;
const plinthMin = Number(settings.plinth_height_min_mm) ?? 60;
const plinthMax = Number(settings.plinth_height_max_mm) ?? 160;
const slidingMinWidth = Number(settings.sliding_doors_min_width_mm) ?? 1200;
const kitFeeChf = numberOr(settings.kit_preparation_fee_chf, 350);
const plinthMin = numberOr(settings.plinth_height_min_mm, 60);
const plinthMax = numberOr(settings.plinth_height_max_mm, 160);
const slidingMinWidth = numberOr(settings.sliding_doors_min_width_mm, 1200);
const allowSlidingDoors = String(settings.allow_sliding_doors ?? '0') === '1';
const standardHandleAxisMm = numberOr(settings.standard_handle_axis_mm, 1000);
const standardHandleToleranceMm = numberOr(settings.standard_handle_tolerance_mm, 200);
const sanitaryGapMm = numberOr(settings.sanitary_gap_mm, NICHE.sanitaryGapAround);
const {
installationType, widthMm, heightMm, depthMm, plinthHeightMm,
blocks = [], modules = [], hardware = {},
doorType, slidingPanelsCount, finishId, serviceOptionIds = []
doorType, slidingPanelsCount, finishId, serviceOptionIds = [],
columnMode: columnModeRaw
} = config;
const columnMode = columnModeRaw === 'block' ? 'block' : 'separations';
// Determine if using modules (V2) or blocks (legacy)
const useModules = modules && modules.length > 0;
@@ -286,11 +301,32 @@ export function calculatePrice(config, data) {
if (doorType === 'sliding' && widthMm < slidingMinWidth) {
errors.push({ field: 'doorType', message: `Les portes coulissantes ne sont possibles qu'à partir de ${slidingMinWidth} mm de largeur.` });
}
if (doorType === 'sliding' && !allowSlidingDoors) {
errors.push({ field: 'doorType', message: 'Les portes coulissantes sont désactivées pour le moment.' });
}
if (doorType === 'sliding' && (slidingPanelsCount < 2 || slidingPanelsCount > 3)) {
errors.push({ field: 'slidingPanelsCount', message: 'Choisissez 2 ou 3 vantaux coulissants.' });
}
if (installationType === 'niche') {
const nm = config.nicheMeasurements || {};
const minW = Math.min(nm.widthTopMm || widthMm, nm.widthMidMm || widthMm, nm.widthBottomMm || widthMm);
const minD = Math.min(nm.depthTopMm || depthMm, nm.depthMidMm || depthMm, nm.depthBottomMm || depthMm);
const minH = Math.min(nm.heightLeftMm || heightMm, nm.heightRightMm || heightMm);
const expectedW = Math.max(400, minW - sanitaryGapMm * 2);
const expectedD = Math.max(400, minD - sanitaryGapMm * 2);
const expectedH = Math.max(1800, minH - sanitaryGapMm * 2);
if (Math.abs(widthMm - expectedW) > 2 || Math.abs(depthMm - expectedD) > 2 || Math.abs(heightMm - expectedH) > 2) {
errors.push({ field: 'nicheMeasurements', message: 'Les dimensions retenues ne correspondent pas aux plus petites mesures niche avec vide sanitaire de 3 cm.' });
}
}
const handleAxis = Number(config.hardware?.handleBoreHeightMm || standardHandleAxisMm);
const minStandard = standardHandleAxisMm - standardHandleToleranceMm;
const maxStandard = standardHandleAxisMm + standardHandleToleranceMm;
const isHandleAxisNonStandard = handleAxis < minStandard || handleAxis > maxStandard;
// Calculate block/module costs
let blocksChf = 0;
let modulesBreakdown = null;
@@ -307,6 +343,13 @@ export function calculatePrice(config, data) {
if (Math.abs(totalModuleWidth - widthMm) > 10) {
errors.push({ field: 'modules', message: `La somme des largeurs des modules (${totalModuleWidth}mm) doit correspondre à la largeur totale (${widthMm}mm).` });
}
const hasDrawerColumnTooWide = modules.some((m) => ((m.drawers || 0) > 0 || m.type === 'tiroirs') && (m.widthMm || 0) > DRAWERS.maxColumnWidth);
if (hasDrawerColumnTooWide) {
errors.push({ field: 'modules', message: `Une colonne tiroirs dépasse ${DRAWERS.maxColumnWidth} mm (non autorisé).` });
}
if (modules.some((m) => m.hasRod) && heightMm < 1200) {
errors.push({ field: 'modules', message: 'Rayon avec tringle: hauteur minimale 1200 mm.' });
}
} else {
// Legacy: Use block-based pricing
const totalWidth = blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0);
@@ -386,6 +429,34 @@ export function calculatePrice(config, data) {
}
}
// Renforts Forteresse — one pair (haut+bas) per panneau vertical de structure
// Séparations: 2 côtés + (n−1) séparateurs = n+1 panneaux verticaux
// Block (x caisses): chaque caisse a 2 montants → 2n panneaux verticaux (plus de bois qu’en séparations)
const columnCount = useModules
? Math.max(1, modules.length)
: Math.max(1, blocks.length || 1);
const verticalStructurePanelCount = columnMode === 'block'
? 2 * columnCount
: 2 + Math.max(0, columnCount - 1);
const reinforcementCount = verticalStructurePanelCount * 2;
// Surcoût panneaux 19 mm (structure): en mode block, (n−1) montants de plus qu’en séparations
const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100);
const extraBlockVerticalPanels = columnMode === 'block' && columnCount > 1 ? columnCount - 1 : 0;
const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0
? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000
: 0;
const structureUnitPriceM2 = finish
? (Number(finish.price_chf) || 0) / ((finish.width_mm || 2800) * (finish.height_mm || 2070) / 1_000_000)
: 0;
const blockExtraStructureChf = Math.round(extraBlockStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100;
// Each reinforcement is 100mm × innerDepth × 16mm — price as fraction of a panel
const reinforcementSurfaceM2 = (PANEL.reinforcementWidth * (depthMm - PANEL.structure - 20)) / 1_000_000 * reinforcementCount;
// Use 50% of panel price per m² for 16mm material (thinner, cheaper)
const reinforcementUnitPriceM2 = finish ? (Number(finish.price_chf) || 0) * 0.5 / ((finish.width_mm || 2800) * (finish.height_mm || 2070) / 1_000_000) : 0;
const reinforcementChf = Math.round(reinforcementSurfaceM2 * reinforcementUnitPriceM2 * 100) / 100;
let optionsChf = 0;
const services = data.serviceOptions || [];
for (const optId of serviceOptionIds) {
@@ -393,7 +464,7 @@ export function calculatePrice(config, data) {
if (opt) optionsChf += Number(opt.price_chf) || 0;
}
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf;
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf;
return {
totalChf: Math.round(totalChf * 100) / 100,
@@ -403,11 +474,19 @@ export function calculatePrice(config, data) {
modulesBreakdown,
kitChf: kitFeeChf,
panelsChf,
reinforcementChf,
reinforcementCount,
columnMode,
verticalStructurePanelCount,
blockExtraStructureChf,
optionsChf,
hardwareChf,
hardwareBreakdown,
panelOptimization
},
warnings: isHandleAxisNonStandard
? [{ field: 'handleBoreHeightMm', message: `Hauteur perçage poignée hors plage standard (${minStandard}-${maxStandard} mm).` }]
: undefined,
errors: errors.length ? errors : undefined
};
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Supplier voucher service.
* After an order is placed, splits order items by supplier and sends each
* supplier an HTML email voucher listing the items they need to provide.
*/
import pool from '../db/index.js';
import { sendEmail } from './mailer.js';
import { getHingeCountForDoor } from '../../shared/manufacturing.js';
/**
* Resolve which supplier (if any) provides the finish/panel material.
*/
async function resolveFinishSupplier(finishId) {
if (!finishId) return null;
// Try formex_materials first (linked via finishes → formex_materials)
const { rows } = await pool.query(
`SELECT fm.id, fm.formex_code AS code, fm.name, fm.price_chf,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM formex_materials fm
JOIN suppliers s ON s.id = fm.supplier_id
WHERE fm.id = $1 AND fm.active = 1 AND s.active = 1`,
[finishId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'panel' };
return null;
}
/**
* Resolve drawer system supplier.
*/
async function resolveBlumHardware(hardwareId, type) {
if (!hardwareId) return null;
const { rows } = await pool.query(
`SELECT bh.id, bh.code, bh.name, bh.price_chf, bh.type,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM blum_hardware bh
JOIN suppliers s ON s.id = bh.supplier_id
WHERE bh.id = $1 AND bh.active = 1 AND s.active = 1`,
[hardwareId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: type };
return null;
}
/**
* Resolve handle supplier.
*/
async function resolveHandle(handleId) {
if (!handleId) return null;
const { rows } = await pool.query(
`SELECT hv.id, hv.code, hv.name, hv.price_chf,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM handle_variants hv
JOIN suppliers s ON s.id = hv.supplier_id
WHERE hv.id = $1 AND hv.active = 1 AND s.active = 1`,
[handleId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'handle' };
return null;
}
/**
* Resolve hinge supplier.
*/
async function resolveHinge(hingeId) {
if (!hingeId) return null;
const { rows } = await pool.query(
`SELECT hv.id, hv.code, hv.name, hv.price_chf,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM hinge_variants hv
JOIN suppliers s ON s.id = hv.supplier_id
WHERE hv.id = $1 AND hv.active = 1 AND s.active = 1`,
[hingeId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'hinge' };
return null;
}
/**
* Estimate quantity for each item based on the order config.
*/
function estimateQuantity(category, config) {
const { modules = [], blocks = [], doorType, slidingPanelsCount } = config;
const itemCount = modules.length || blocks.length || 1;
switch (category) {
case 'panel':
return 1; // quantity expressed in panels by the price engine; we report 1 "lot"
case 'drawer_system':
return modules.reduce((sum, m) => sum + (m.drawers || 0), 0) || 1;
case 'hinge':
if (doorType === 'sliding') return 0;
return itemCount * getHingeCountForDoor({
heightMm: (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4
});
case 'handle':
return doorType === 'sliding'
? (slidingPanelsCount || 2)
: itemCount;
case 'runner':
case 'lift_system':
return 1;
default:
return 1;
}
}
/**
* Build an HTML voucher email body for a single supplier.
*/
function buildVoucherHtml({ orderId, orderDate, customerName, supplierName, items }) {
const formattedDate = new Date(orderDate).toLocaleDateString('fr-CH', {
day: '2-digit', month: '2-digit', year: 'numeric'
});
const subtotal = items.reduce((sum, it) => sum + (it.unitPrice * it.qty), 0);
const rows = items.map(it => `
<tr>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${it.code || '—'}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${it.name}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:center;">${it.qty}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${it.unitPrice != null ? it.unitPrice.toFixed(2) + ' CHF' : '—'}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${it.unitPrice != null ? (it.unitPrice * it.qty).toFixed(2) + ' CHF' : '—'}</td>
</tr>`).join('');
return `<!DOCTYPE html>
<html lang="fr">
<head><meta charset="utf-8"><title>Bon de commande #${orderId}</title></head>
<body style="margin:0;padding:0;background:#f9fafb;font-family:Arial,sans-serif;color:#111827;">
<div style="max-width:680px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.1);">
<div style="background:#1e293b;color:#fff;padding:28px 32px;">
<h1 style="margin:0;font-size:20px;font-weight:700;">Bon de commande — APEX Configurateur</h1>
<p style="margin:6px 0 0;opacity:.75;font-size:14px;">Référence commande&nbsp;: <strong>#${orderId}</strong> &nbsp;|&nbsp; ${formattedDate}</p>
</div>
<div style="padding:24px 32px;">
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<tr>
<td style="padding:4px 0;color:#6b7280;font-size:13px;width:140px;">Fournisseur</td>
<td style="padding:4px 0;font-weight:600;">${supplierName}</td>
</tr>
<tr>
<td style="padding:4px 0;color:#6b7280;font-size:13px;">Client</td>
<td style="padding:4px 0;">${customerName || '—'}</td>
</tr>
</table>
<h2 style="font-size:15px;font-weight:600;margin:0 0 12px;color:#1e293b;">Articles à fournir</h2>
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<thead>
<tr style="background:#f1f5f9;">
<th style="padding:8px 12px;text-align:left;font-weight:600;color:#374151;">Code</th>
<th style="padding:8px 12px;text-align:left;font-weight:600;color:#374151;">Désignation</th>
<th style="padding:8px 12px;text-align:center;font-weight:600;color:#374151;">Qté</th>
<th style="padding:8px 12px;text-align:right;font-weight:600;color:#374151;">PU (CHF)</th>
<th style="padding:8px 12px;text-align:right;font-weight:600;color:#374151;">Total</th>
</tr>
</thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td colspan="4" style="padding:10px 12px;text-align:right;font-weight:700;border-top:2px solid #e5e7eb;">Sous-total</td>
<td style="padding:10px 12px;text-align:right;font-weight:700;border-top:2px solid #e5e7eb;">${subtotal.toFixed(2)} CHF</td>
</tr>
</tfoot>
</table>
</div>
<div style="padding:16px 32px 24px;font-size:12px;color:#9ca3af;border-top:1px solid #e5e7eb;">
Ce document est généré automatiquement par le configurateur APEX. Pour toute question : [email protected]
</div>
</div>
</body>
</html>`;
}
/**
* Main entry point — called from orders route after insert.
* Splits the order by supplier and sends each supplier their voucher.
*
* @param {number} orderId
* @param {Object} config - the order config JSON (finishId, hardware, modules, blocks, doorType, …)
* @param {string} customerName
* @param {string} orderDate - ISO date string (defaults to now)
*/
export async function sendSupplierVouchers(orderId, config, customerName, orderDate = new Date().toISOString()) {
const suppliers = await buildSupplierVoucherPayload(config);
if (suppliers.length === 0) {
console.log(`[supplier-voucher] Commande #${orderId} : aucun fournisseur résolu, aucun email envoyé.`);
return;
}
// Send one email per supplier
const sends = [];
for (const supplier of suppliers) {
const html = buildVoucherHtml({
orderId,
orderDate,
customerName,
supplierName: supplier.supplierName,
items: supplier.items
});
sends.push(
sendEmail({
to: supplier.supplierEmail,
subject: `Bon de commande #${orderId} — APEX Configurateur`,
html
}).then(() => {
console.log(`[supplier-voucher] Email envoyé à ${supplier.supplierName} <${supplier.supplierEmail}> pour commande #${orderId}`);
})
);
}
await Promise.all(sends);
}
/**
* Build supplier payload from order config.
* Used by email sender and by admin API preview.
*
* @param {Object} config
* @returns {Promise<Array<{supplierId:number, supplierCode:string|null, supplierName:string, supplierEmail:string, items:Array, subtotal:number}>>}
*/
export async function buildSupplierVoucherPayload(config) {
const { hardware = {} } = config;
const { handleVariantId, hingeVariantId, drawerSystemId } = hardware;
// Resolve all items in parallel
const [finishResult, drawerResult, handleResult, hingeResult] = await Promise.all([
resolveFinishSupplier(config.finishId),
resolveBlumHardware(drawerSystemId, 'drawer_system'),
resolveHandle(handleVariantId),
resolveHinge(hingeVariantId)
]);
// Group items by supplier_id
const supplierMap = new Map(); // supplier_id → { supplier info, items[] }
for (const result of [finishResult, drawerResult, handleResult, hingeResult]) {
if (!result) continue;
const { supplier, item, category } = result;
const sid = supplier.supplier_id;
if (!supplierMap.has(sid)) {
supplierMap.set(sid, {
code: supplier.supplier_code || null,
name: supplier.supplier_name,
email: supplier.supplier_email,
items: []
});
}
const qty = estimateQuantity(category, config);
if (qty > 0) {
supplierMap.get(sid).items.push({
code: item.code,
name: item.name,
unitPrice: item.price_chf,
qty,
category
});
}
}
return Array.from(supplierMap, ([supplierId, supplierData]) => {
const subtotal = supplierData.items.reduce((sum, item) => sum + ((item.unitPrice || 0) * item.qty), 0);
return {
supplierId,
supplierCode: supplierData.code || null,
supplierName: supplierData.name,
supplierEmail: supplierData.email,
items: supplierData.items,
subtotal
};
});
}
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { validateConfig } from '../services/configValidation.js';
import { generateCuttingList } from '../services/cuttingEngine.js';
function run() {
const baseConfig = {
installationType: 'free',
widthMm: 2400,
heightMm: 2400,
depthMm: 610,
plinthHeightMm: 100,
modules: [{ type: 'tiroirs', widthMm: 900, drawers: 3, hasRod: false }],
doorType: 'hinged',
hardware: {
handleBoreHeightMm: 1000,
drawerType: 'visible'
}
};
const okValidation = validateConfig(baseConfig, { allow_sliding_doors: '0' });
assert.equal(okValidation.errors.length, 0, 'Base config should be valid');
const invalidDrawerConfig = {
...baseConfig,
modules: [{ type: 'tiroirs', widthMm: 1201, drawers: 2, hasRod: false }]
};
const invalidDrawerValidation = validateConfig(invalidDrawerConfig, {});
assert.ok(
invalidDrawerValidation.errors.some((e) => String(e.message).includes('1000')),
'Drawer width limit should trigger validation error'
);
const nonStandardHandle = {
...baseConfig,
hardware: { ...baseConfig.hardware, handleBoreHeightMm: 1450 }
};
const nonStandardHandleValidation = validateConfig(nonStandardHandle, {});
assert.ok(nonStandardHandleValidation.warnings.length > 0, 'Handle warning should be emitted');
const cuttingList = generateCuttingList(baseConfig, {}, baseConfig.modules);
const sidePiece = cuttingList.pieces.find((p) => p.elementType === 'cotes');
assert.ok(sidePiece?.machining?.groove, 'Side piece should include groove machining');
assert.equal(sidePiece.machining.groove.width, 9, 'Groove width should be 9mm');
assert.equal(sidePiece.machining.groove.depth, 10.5, 'Groove depth should be 10.5mm');
assert.equal(sidePiece.machining.groove.axisFromPanelEdge, 16, 'Groove position should be 16mm from edge');
}
run();
console.log('rules.spec.mjs: all assertions passed');