Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-10-07 17:13:03 +02:00
co-authored by Cursor
parent 6c0d7e47b8
commit 30921aade6
75 changed files with 15618 additions and 1300 deletions
+17 -3
View File
@@ -41,7 +41,9 @@ const DEFAULT_ENGINEERING_RULES = [
details:
"Recul face plinthe >= 50 mm par rapport à la façade.\n" +
'Calcul de référence: porte 19 + jeu 3 + retrait base 28 = 50 mm.\n' +
"Avec plinthe 19 mm: centre pied Axilo à 69 mm des bords avant/arrière."
'AXILO 78 Press-fit réf. 637.76.710: centre avant à 59 mm du chant avant (plinthe finie 19 + 40), ' +
'centre arrière à 50 mm du chant arrière; 4 perçages borgnes Ø10 × 12 mm par platine, entraxe 64 mm.\n' +
'Répartition: 4 pieds aux extrémités de l\'armoire + 2 pieds (avant/arrière) tous les 600 mm.'
},
{
sort_order: 60,
@@ -144,8 +146,20 @@ const DEFAULT_ENGINEERING_RULES = [
{
sort_order: 210,
chapter: '4.C',
title: 'Tiroirs intérieurs - hauteurs de façades',
details: 'Variantes autorisées: 60, 90, 128, 177, 240 mm.'
title: 'Tiroirs intérieurs - hauteurs de côté (Blum Legrabox)',
details:
'Hauteurs de côté autorisées: 66 (N), 90 (M), 128 (K), 177 (C), 240 (F) mm.\n' +
'Hauteurs de niche SMS correspondantes: 80, 106, 144, 193, 257 mm (à partir de 1/6, 1/6, 2/6, 2/6, 3/6 — 1/6 = 127 mm).'
},
{
sort_order: 215,
chapter: '4.C',
title: 'Tiroirs Legrabox - débit 16 mm et perçages CNC',
details:
'Fond 16 mm: L = NL − 10, l = X − 35, feuillure 38 × 8 mm G+D (X = largeur libre corps).\n' +
'Paroi arrière 16 mm: l = X − 38, h = 39/63/101/148/212 mm selon N/M/K/C/F.\n' +
'Coulisses (Système 32, depuis chant avant): 40 kg → 37, 69, 197, 229, 261, 293, 357 · 70 kg → 37, 69, 261, 293, 357.\n' +
'Façade INSERTA: cheville expansible Ø 10 mm, profondeur > 12 mm.'
},
{
sort_order: 220,
+14 -4
View File
@@ -1,15 +1,25 @@
import pool from '../db/index.js';
const CONFIGURATOR_MATERIAL_FILTER = `
active = 1
AND NOT (
supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele')
OR LOWER(COALESCE(collection, '')) = 'hafele'
OR formex_code LIKE 'HAF-%'
)
`;
/**
* GET /api/finishes
* Returns active Formex materials as finishes for the configurator
* Returns active panel materials as finishes for the configurator
* Falls back to legacy finishes if no Formex materials exist
*/
export async function getFinishes(req, res) {
try {
// Check if we have active Formex materials
// Hardware suppliers can share formex_materials for catalog synchronization,
// but their products must never be offered as panel finishes.
const formexResult = await pool.query(
`SELECT COUNT(*) as count FROM formex_materials WHERE active = 1`
`SELECT COUNT(*) as count FROM formex_materials WHERE ${CONFIGURATOR_MATERIAL_FILTER}`
);
const hasFormexMaterials = (formexResult.rows[0]?.count || 0) > 0;
@@ -32,7 +42,7 @@ export async function getFinishes(req, res) {
dominant_color as hexColor,
'formex' as source
FROM formex_materials
WHERE active = 1
WHERE ${CONFIGURATOR_MATERIAL_FILTER}
ORDER BY collection, name
`);
+125
View File
@@ -0,0 +1,125 @@
import pool, { run } from '../db/index.js';
import { DEFAULT_LAYOUT_PRESETS } from '../db/defaultLayouts.js';
const MAX_NAME_LENGTH = 100;
const PRESET_COLUMNS = 'id, seed_key, name, description, config, sort_order, created_at, updated_at';
let seeded = null;
/**
* Synchronise les dispositions intégrées avec leur définition en code (insertion ou mise à jour).
* Une disposition intégrée supprimée reste masquée (active = 0).
*/
function ensureDefaultLayouts() {
if (!seeded) {
seeded = (async () => {
const { rows } = await pool.query(
'SELECT seed_key, name, description, config, sort_order FROM layout_presets WHERE seed_key IS NOT NULL'
);
const existing = new Map(rows.map((r) => [r.seed_key, r]));
for (const [index, preset] of DEFAULT_LAYOUT_PRESETS.entries()) {
const config = JSON.stringify(preset.config);
const row = existing.get(preset.seedKey);
if (row) {
const unchanged = row.name === preset.name && row.description === preset.description
&& row.config === config && Number(row.sort_order) === index;
if (!unchanged) {
await run(
`UPDATE layout_presets
SET name = $1, description = $2, config = $3, sort_order = $4, updated_at = datetime('now')
WHERE seed_key = $5`,
[preset.name, preset.description, config, index, preset.seedKey]
);
}
continue;
}
await run(
`INSERT INTO layout_presets (seed_key, name, description, config, sort_order)
VALUES ($1, $2, $3, $4, $5)`,
[preset.seedKey, preset.name, preset.description, config, index]
);
}
})().catch((err) => {
seeded = null;
throw err;
});
}
return seeded;
}
function parsePreset(row) {
if (!row) return null;
let config = null;
try {
config = JSON.parse(row.config);
} catch { /* config illisible → null */ }
return { ...row, builtin: row.seed_key != null, config };
}
export async function getLayoutPresets(req, res) {
try {
await ensureDefaultLayouts();
const { rows } = await pool.query(
`SELECT ${PRESET_COLUMNS}
FROM layout_presets
WHERE active = 1
ORDER BY sort_order ASC, id ASC`
);
res.json(rows.map(parsePreset));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function createLayoutPreset(req, res) {
try {
const name = String(req.body?.name || '').trim();
const description = String(req.body?.description || '').trim() || null;
const config = req.body?.config;
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
if (name.length > MAX_NAME_LENGTH) {
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
}
if (!config || typeof config !== 'object' || Array.isArray(config) || !Array.isArray(config.modules) || config.modules.length === 0) {
return res.status(400).json({ error: 'Disposition invalide (au moins une colonne requise).' });
}
await ensureDefaultLayouts();
await run(
`INSERT INTO layout_presets (name, description, config, sort_order)
VALUES ($1, $2, $3, 1000)`,
[name, description, JSON.stringify(config)]
);
const { rows } = await pool.query(
`SELECT ${PRESET_COLUMNS}
FROM layout_presets
ORDER BY id DESC
LIMIT 1`
);
res.status(201).json(parsePreset(rows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function deleteLayoutPreset(req, res) {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'Identifiant invalide.' });
}
const { rows } = await pool.query('SELECT id, seed_key FROM layout_presets WHERE id = $1 LIMIT 1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Disposition introuvable.' });
if (rows[0].seed_key != null) {
await run(`UPDATE layout_presets SET active = 0, updated_at = datetime('now') WHERE id = $1`, [id]);
} else {
await run('DELETE FROM layout_presets WHERE id = $1', [id]);
}
res.status(204).end();
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* GET /api/material-image?url=<encoded absolute image url>
*
* Same-origin proxy for remote material swatch images.
*
* The 3D preview turns swatch images into WebGL textures via a <canvas>.
* Browsers taint a canvas (and refuse `getImageData` / texture upload) when the
* source image comes from another origin without CORS headers. Suppliers such
* as Leuba Hiag serve their swatches from their own domain with no
* `Access-Control-Allow-Origin`, so the textures silently fail and every
* element falls back to its default colour.
*
* By streaming the bytes back through our own origin the canvas stays clean and
* the material textures/colours apply correctly in 3D.
*/
/** Only proxy http(s) images from these hosts (defence against SSRF). */
const ALLOWED_HOST_SUFFIXES = [
'leubahiag.ch',
'formex.ch',
'haefele.ch',
'haefele.com',
'haefele.de',
];
/** Small in-memory cache so repeated texture loads don't refetch remotely. */
const cache = new Map(); // url -> { buffer, contentType, expires }
const MAX_CACHE_ENTRIES = 400;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
function isAllowedHost(hostname) {
const host = hostname.toLowerCase();
return ALLOWED_HOST_SUFFIXES.some(
(suffix) => host === suffix || host.endsWith(`.${suffix}`)
);
}
export async function getMaterialImage(req, res) {
const raw = req.query.url;
if (!raw || typeof raw !== 'string') {
return res.status(400).json({ error: 'Missing url parameter' });
}
let target;
try {
target = new URL(raw);
} catch {
return res.status(400).json({ error: 'Invalid url' });
}
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
return res.status(400).json({ error: 'Unsupported protocol' });
}
if (!isAllowedHost(target.hostname)) {
return res.status(403).json({ error: 'Host not allowed' });
}
const key = target.toString();
const now = Date.now();
const cached = cache.get(key);
if (cached && cached.expires > now) {
res.set('Content-Type', cached.contentType);
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=86400');
return res.send(cached.buffer);
}
try {
const response = await fetch(key, { headers: { Accept: 'image/*' } });
if (!response.ok) {
return res.status(502).json({ error: `Upstream ${response.status}` });
}
const contentType = response.headers.get('content-type') || 'image/jpeg';
if (!contentType.startsWith('image/')) {
return res.status(415).json({ error: 'Not an image' });
}
const buffer = Buffer.from(await response.arrayBuffer());
if (cache.size >= MAX_CACHE_ENTRIES) {
const oldestKey = cache.keys().next().value;
if (oldestKey !== undefined) cache.delete(oldestKey);
}
cache.set(key, { buffer, contentType, expires: now + CACHE_TTL_MS });
res.set('Content-Type', contentType);
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=86400');
return res.send(buffer);
} catch (err) {
return res.status(502).json({ error: err.message });
}
}
+64
View File
@@ -8,6 +8,8 @@ import { generateCncProgram } from '../services/cncGenerator.js';
import { validateConfig } from '../services/configValidation.js';
import { generateHardwareList } from '../services/hardwareListEngine.js';
import { generateMprZipBuffer, generateMprPieceDownload } from '../services/mprGenerator.js';
import { generateCuttingPlan } from '../services/cuttingPlanEngine.js';
import { buildCuttingListXlsx } from '../services/exportExcel.js';
/**
* POST /api/plans/generate
@@ -241,6 +243,68 @@ export async function exportMprPieceRoute(req, res) {
}
}
/**
* POST /api/plans/cutting-plan
* Génère le plan de coupe (calepinage) avec % de chute par panneau et global.
*/
export async function generateCuttingPlanRoute(req, res) {
try {
const { config, modules = [], colors = {}, stockBoard, kerfMm, trimMm, allowRotation } = req.body || {};
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const validation = validateConfig(config);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
const cuttingList = generateCuttingList(config, colors, modules);
const cuttingPlan = generateCuttingPlan(cuttingList, { stockBoard, kerfMm, trimMm, allowRotation });
res.json({ success: true, cuttingPlan, warnings: validation.warnings });
} catch (error) {
console.error('Cutting plan error:', error);
res.status(500).json({ error: error.message || 'Erreur de génération du plan de coupe' });
}
}
/**
* POST /api/plans/export-excel
* Export de la liste de débitage (+ quincaillerie + calepinage) au format Excel (.xlsx).
*/
export async function exportExcelRoute(req, res) {
try {
const { config, modules = [], colors = {} } = req.body || {};
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const validation = validateConfig(config);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
const cuttingList = generateCuttingList(config, colors, modules);
const cuttingPlan = generateCuttingPlan(cuttingList, {});
let hardware = null;
try {
hardware = await generateHardwareList({ config, modules, colors });
} catch (e) {
console.warn('[export-excel] hardware list unavailable:', e.message);
}
const buffer = await buildCuttingListXlsx({ cuttingList, hardware, cuttingPlan });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename="liste-debitage.xlsx"');
res.send(Buffer.from(buffer));
} catch (error) {
console.error('Export Excel error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'export Excel' });
}
}
/**
* POST /api/plans/hardware-list
* Generate bill of materials for hardware/quincaillerie with prices (best-effort).
+111
View File
@@ -0,0 +1,111 @@
import pool, { run } from '../db/index.js';
const MAX_NAME_LENGTH = 100;
function parseDesign(row) {
if (!row) return null;
try {
return { ...row, config: JSON.parse(row.config) };
} catch {
return { ...row, config: null };
}
}
export async function getSavedDesigns(req, res) {
try {
const { rows } = await pool.query(
`SELECT id, name, config, created_at, updated_at
FROM saved_designs
ORDER BY updated_at DESC, id DESC`
);
res.json(rows.map(parseDesign));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function createSavedDesign(req, res) {
try {
const name = String(req.body?.name || '').trim();
const config = req.body?.config;
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
if (name.length > MAX_NAME_LENGTH) {
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return res.status(400).json({ error: 'Configuration invalide.' });
}
await run(
`INSERT INTO saved_designs (name, config)
VALUES ($1, $2)`,
[name, JSON.stringify(config)]
);
const { rows } = await pool.query(
`SELECT id, name, config, created_at, updated_at
FROM saved_designs
ORDER BY id DESC
LIMIT 1`
);
res.status(201).json(parseDesign(rows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function updateSavedDesign(req, res) {
try {
const id = Number(req.params.id);
const name = String(req.body?.name || '').trim();
const config = req.body?.config;
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'Identifiant invalide.' });
}
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
if (name.length > MAX_NAME_LENGTH) {
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return res.status(400).json({ error: 'Configuration invalide.' });
}
const { rows } = await pool.query('SELECT id FROM saved_designs WHERE id = $1 LIMIT 1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Armoire sauvegardée introuvable.' });
await run(
`UPDATE saved_designs
SET name = $1, config = $2, updated_at = datetime('now')
WHERE id = $3`,
[name, JSON.stringify(config), id]
);
const { rows: updatedRows } = await pool.query(
`SELECT id, name, config, created_at, updated_at
FROM saved_designs
WHERE id = $1
LIMIT 1`,
[id]
);
res.json(parseDesign(updatedRows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function deleteSavedDesign(req, res) {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'Identifiant invalide.' });
}
const { rows } = await pool.query('SELECT id FROM saved_designs WHERE id = $1 LIMIT 1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Armoire sauvegardée introuvable.' });
await run('DELETE FROM saved_designs WHERE id = $1', [id]);
res.status(204).end();
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+172
View File
@@ -1,5 +1,6 @@
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
import { syncHaefeleByUrls } from '../services/haefeleCrawler.js';
import { syncLeubaHiag, LEUBA_HIAG_TARGETS } from '../services/leubaHiagCrawler.js';
import { getSchedulerStatus } from '../services/scheduler.js';
import pool, { run } from '../db/index.js';
@@ -206,6 +207,150 @@ export async function getHaefeleMaterials(req, res) {
}
}
/**
* GET /api/sync/leubahiag/targets
* List the curated Leuba Hiag catalog lines available for import.
*/
export function getLeubaHiagTargets(req, res) {
res.json(LEUBA_HIAG_TARGETS.map(({ key, label, tier }) => ({ key, label, tier })));
}
function parseTargetsParam(value) {
if (Array.isArray(value)) return value.filter(Boolean);
if (typeof value === 'string' && value.trim()) {
return value.split(/[\n,;]+/g).map((v) => v.trim()).filter(Boolean);
}
return null;
}
/**
* POST /api/sync/leubahiag
* Trigger a Leuba Hiag curated catalog sync (non-streaming).
* Body: { targets?: string[], activate?: boolean, credentials?: {...} }
*/
export async function triggerLeubaHiagSync(req, res) {
try {
const targets = parseTargetsParam(req.body?.targets);
const activate = req.body?.activate === true || req.body?.activate === '1';
const fetchPrices = req.body?.fetchPrices !== false && req.body?.fetchPrices !== '0';
const credentials = {
username: req.body?.username || req.body?.email,
password: req.body?.password,
};
const result = await syncLeubaHiag({ targets, activate, fetchPrices, credentials }, () => {});
if (!result.success) {
return res.status(500).json({
success: false,
error: result.error || 'Sync Leuba Hiag échouée',
itemsCount: result.itemsCount || 0,
});
}
res.json({
success: true,
message: `Synchronisation Leuba Hiag terminée: ${result.itemsCount} produits`,
itemsCount: result.itemsCount,
createdCount: result.createdCount || 0,
updatedCount: result.updatedCount || 0,
pricedCount: result.pricedCount || 0,
duration: result.duration,
});
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* GET /api/sync/leubahiag/stream
* SSE endpoint for Leuba Hiag sync progress.
* Query: targets=key1,key2 (optional), activate=1 (optional)
*/
export async function streamLeubaHiagSync(req, res) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const emit = (data) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
req.on('close', () => {
// client disconnected
});
const targets = parseTargetsParam(req.query.targets);
const activate = req.query.activate === '1' || req.query.activate === 'true';
const fetchPrices = req.query.prices !== '0' && req.query.prices !== 'false';
try {
await syncLeubaHiag({ targets, activate, fetchPrices }, emit);
} catch (err) {
emit({ type: 'error', error: err.message });
}
res.end();
}
/**
* GET /api/leubahiag/materials
* List Leuba Hiag materials that were imported.
*/
export async function getLeubaHiagMaterials(req, res) {
try {
const { search = '', active, page = '1', limit = '25' } = req.query;
const pageNum = Math.max(1, parseInt(page, 10));
const limitNum = Math.min(200, Math.max(1, parseInt(limit, 10)));
const offset = (pageNum - 1) * limitNum;
const whereClauses = [
`(supplier_id = (SELECT id FROM suppliers WHERE code = 'leubahiag') OR formex_code LIKE 'LH-%')`
];
const params = [];
if (search) {
whereClauses.push('(name LIKE ? OR formex_code LIKE ? OR collection LIKE ?)');
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
if (active === '1' || active === 'true') {
whereClauses.push('active = 1');
} else if (active === '0' || active === 'false') {
whereClauses.push('active = 0');
}
const whereSQL = `WHERE ${whereClauses.join(' AND ')}`;
const countResult = await pool.query(
`SELECT COUNT(*) as total FROM formex_materials ${whereSQL}`,
params
);
const total = countResult.rows[0]?.total || 0;
const { rows } = await pool.query(
`SELECT id, formex_code, name, collection, category, thickness_mm, width_mm, height_mm,
price_chf, price_unit, image_url, formex_url, active, last_synced_at
FROM formex_materials
${whereSQL}
ORDER BY collection, name
LIMIT ? OFFSET ?`,
[...params, limitNum, offset]
);
res.json({
materials: rows,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum)
}
});
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/sync/logs
* Get sync history
@@ -439,6 +584,33 @@ export async function updateFormexMaterial(req, res) {
}
}
/**
* PATCH /api/formex/materials/:id/active
* Toggle only the `active` flag of a material (lossless: never touches other columns).
* Body: { active: boolean }
*/
export async function setFormexMaterialActive(req, res) {
try {
const { id } = req.params;
const active = req.body?.active === true || req.body?.active === 1 || req.body?.active === '1';
const existing = await pool.query('SELECT id FROM formex_materials WHERE id = ?', [id]);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Matériau non trouvé' });
}
await run('UPDATE formex_materials SET active = ? WHERE id = ?', [active ? 1 : 0, id]);
const { rows } = await pool.query(
'SELECT id, formex_code, name, active FROM formex_materials WHERE id = ?',
[id]
);
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* DELETE /api/formex/materials/:id
* Delete a material