@@ -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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
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Generated
+266
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
+2717
-86
File diff suppressed because it is too large
Load Diff
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
+630
-201
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
|
||||
Brut CNC: ${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm
|
||||
|
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 : <strong>#${orderId}</strong> | ${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
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user