v2.1
Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user