Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
685 lines
20 KiB
JavaScript
685 lines
20 KiB
JavaScript
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';
|
|
|
|
/**
|
|
* POST /api/sync/formex
|
|
* Trigger a manual Formex sync (non-streaming)
|
|
*/
|
|
export async function triggerFormexSync(req, res) {
|
|
try {
|
|
// Run sync (this may take a while)
|
|
const result = await sync();
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
message: `Synchronisation terminée: ${result.itemsCount} produits`,
|
|
itemsCount: result.itemsCount,
|
|
duration: result.duration
|
|
});
|
|
} else {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: result.error,
|
|
itemsCount: result.itemsCount
|
|
});
|
|
}
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: err.message
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/sync/formex/stream
|
|
* SSE endpoint for real-time sync progress
|
|
*/
|
|
export async function streamFormexSync(req, res) {
|
|
// Set SSE headers
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
|
res.flushHeaders();
|
|
|
|
// Emitter function that sends SSE events
|
|
const emit = (data) => {
|
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
};
|
|
|
|
// Handle client disconnect
|
|
req.on('close', () => {
|
|
// Client disconnected, cleanup if needed
|
|
});
|
|
|
|
const force = req.query.force === '1' || req.query.force === 'true';
|
|
|
|
try {
|
|
await sync(emit, { force });
|
|
} catch (err) {
|
|
emit({ type: 'error', error: err.message });
|
|
}
|
|
|
|
// Close connection
|
|
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/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
|
|
*/
|
|
export async function getLogs(req, res) {
|
|
try {
|
|
const limit = parseInt(req.query.limit || '20', 10);
|
|
const logs = await getSyncLogs(limit);
|
|
res.json(logs);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/sync/status
|
|
* Get scheduler status
|
|
*/
|
|
export function getStatus(req, res) {
|
|
const status = getSchedulerStatus();
|
|
res.json(status);
|
|
}
|
|
|
|
/**
|
|
* GET /api/formex/materials
|
|
* Get Formex materials with filtering and pagination
|
|
*/
|
|
export async function getFormexMaterials(req, res) {
|
|
try {
|
|
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;
|
|
|
|
let whereClauses = [];
|
|
let params = [];
|
|
|
|
if (search) {
|
|
whereClauses.push('(m.name LIKE ? OR m.formex_code LIKE ?)');
|
|
params.push(`%${search}%`, `%${search}%`);
|
|
}
|
|
|
|
if (collection) {
|
|
whereClauses.push('m.collection = ?');
|
|
params.push(collection);
|
|
}
|
|
|
|
if (category) {
|
|
whereClauses.push('m.category = ?');
|
|
params.push(category);
|
|
}
|
|
|
|
if (active === '1' || active === 'true') {
|
|
whereClauses.push('m.active = 1');
|
|
}
|
|
|
|
if (supplier) {
|
|
whereClauses.push('s.code = ?');
|
|
params.push(supplier);
|
|
}
|
|
|
|
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
|
|
|
const countResult = await pool.query(
|
|
`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;
|
|
|
|
const { rows } = await pool.query(
|
|
`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]
|
|
);
|
|
|
|
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/formex/materials/:id
|
|
* Get a single material by ID with dimensions and variants
|
|
*/
|
|
export async function getFormexMaterial(req, res) {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Get main material
|
|
const { rows } = await pool.query(
|
|
'SELECT * FROM formex_materials WHERE id = ?',
|
|
[id]
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
return res.status(404).json({ error: 'Matériau non trouvé' });
|
|
}
|
|
|
|
const material = rows[0];
|
|
|
|
// Get dimension variants
|
|
const dimensions = await pool.query(
|
|
'SELECT id, width_mm, height_mm, thickness_mm, is_default FROM formex_dimensions WHERE material_id = ? ORDER BY is_default DESC, width_mm',
|
|
[id]
|
|
);
|
|
material.dimensions = dimensions.rows;
|
|
|
|
// Get color/decor variants
|
|
const variants = await pool.query(
|
|
'SELECT id, variant_code, variant_name, variant_url, image_url FROM formex_variants WHERE material_id = ? ORDER BY variant_name',
|
|
[id]
|
|
);
|
|
material.variants = variants.rows;
|
|
|
|
res.json(material);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/formex/materials
|
|
* Create a new material
|
|
*/
|
|
export async function createFormexMaterial(req, res) {
|
|
try {
|
|
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;
|
|
|
|
if (!formex_code || !name || !collection) {
|
|
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
|
|
}
|
|
|
|
const existing = await pool.query(
|
|
'SELECT id FROM formex_materials WHERE formex_code = ?',
|
|
[formex_code]
|
|
);
|
|
if (existing.rows.length > 0) {
|
|
return res.status(409).json({ error: 'Un matériau avec ce code existe déjà' });
|
|
}
|
|
|
|
await run(
|
|
`INSERT INTO formex_materials (formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
|
|
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
|
|
image_url, formex_url, price_chf, price_unit, 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]
|
|
);
|
|
|
|
const { rows } = await pool.query(
|
|
'SELECT * FROM formex_materials WHERE formex_code = ?',
|
|
[formex_code]
|
|
);
|
|
|
|
res.status(201).json(rows[0]);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PUT /api/formex/materials/:id
|
|
* Update a material
|
|
*/
|
|
export async function updateFormexMaterial(req, res) {
|
|
try {
|
|
const { id } = req.params;
|
|
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;
|
|
|
|
const existing = await pool.query(
|
|
'SELECT * FROM formex_materials WHERE id = ?',
|
|
[id]
|
|
);
|
|
if (existing.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Matériau non trouvé' });
|
|
}
|
|
|
|
if (!formex_code || !name || !collection) {
|
|
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
|
|
}
|
|
|
|
if (formex_code !== existing.rows[0].formex_code) {
|
|
const duplicate = await pool.query(
|
|
'SELECT id FROM formex_materials WHERE formex_code = ? AND id != ?',
|
|
[formex_code, id]
|
|
);
|
|
if (duplicate.rows.length > 0) {
|
|
return res.status(409).json({ error: 'Un matériau avec ce code existe déjà' });
|
|
}
|
|
}
|
|
|
|
await run(
|
|
`UPDATE formex_materials
|
|
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,
|
|
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]
|
|
);
|
|
|
|
const { rows } = await pool.query(
|
|
'SELECT * FROM formex_materials WHERE id = ?',
|
|
[id]
|
|
);
|
|
|
|
res.json(rows[0]);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export async function deleteFormexMaterial(req, res) {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Check if material exists
|
|
const existing = await pool.query(
|
|
'SELECT * FROM formex_materials WHERE id = ?',
|
|
[id]
|
|
);
|
|
if (existing.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Matériau non trouvé' });
|
|
}
|
|
|
|
await run('DELETE FROM formex_materials WHERE id = ?', [id]);
|
|
|
|
res.json({ success: true, message: 'Matériau supprimé' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export async function getFormexCollections(req, res) {
|
|
try {
|
|
const { rows } = await pool.query(
|
|
'SELECT DISTINCT collection FROM formex_materials ORDER BY collection'
|
|
);
|
|
res.json(rows.map(r => r.collection));
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/formex/categories
|
|
* Get distinct category names for filter dropdown
|
|
*/
|
|
export async function getFormexCategories(req, res) {
|
|
try {
|
|
const { rows } = await pool.query(
|
|
'SELECT DISTINCT category FROM formex_materials WHERE category IS NOT NULL ORDER BY category'
|
|
);
|
|
res.json(rows.map(r => r.category));
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|