+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
|
||||
|
||||
Reference in New Issue
Block a user