/** * Supplier voucher service. * After an order is placed, splits order items by supplier and sends each * supplier an HTML email voucher listing the items they need to provide. */ import pool from '../db/index.js'; import { sendEmail } from './mailer.js'; import { getHingeCountForDoor } from '../../shared/manufacturing.js'; /** * Resolve which supplier (if any) provides the finish/panel material. */ async function resolveFinishSupplier(finishId) { if (!finishId) return null; // Try formex_materials first (linked via finishes → formex_materials) const { rows } = await pool.query( `SELECT fm.id, fm.formex_code AS code, fm.name, fm.price_chf, s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code FROM formex_materials fm JOIN suppliers s ON s.id = fm.supplier_id WHERE fm.id = $1 AND fm.active = 1 AND s.active = 1`, [finishId] ); if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'panel' }; return null; } /** * Resolve drawer system supplier. */ async function resolveBlumHardware(hardwareId, type) { if (!hardwareId) return null; const { rows } = await pool.query( `SELECT bh.id, bh.code, bh.name, bh.price_chf, bh.type, s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code FROM blum_hardware bh JOIN suppliers s ON s.id = bh.supplier_id WHERE bh.id = $1 AND bh.active = 1 AND s.active = 1`, [hardwareId] ); if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: type }; return null; } /** * Resolve handle supplier. */ async function resolveHandle(handleId) { if (!handleId) return null; const { rows } = await pool.query( `SELECT hv.id, hv.code, hv.name, hv.price_chf, s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code FROM handle_variants hv JOIN suppliers s ON s.id = hv.supplier_id WHERE hv.id = $1 AND hv.active = 1 AND s.active = 1`, [handleId] ); if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'handle' }; return null; } /** * Resolve hinge supplier. */ async function resolveHinge(hingeId) { if (!hingeId) return null; const { rows } = await pool.query( `SELECT hv.id, hv.code, hv.name, hv.price_chf, s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code FROM hinge_variants hv JOIN suppliers s ON s.id = hv.supplier_id WHERE hv.id = $1 AND hv.active = 1 AND s.active = 1`, [hingeId] ); if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'hinge' }; return null; } /** * Estimate quantity for each item based on the order config. */ function estimateQuantity(category, config) { const { modules = [], blocks = [], doorType, slidingPanelsCount } = config; const itemCount = modules.length || blocks.length || 1; switch (category) { case 'panel': return 1; // quantity expressed in panels by the price engine; we report 1 "lot" case 'drawer_system': return modules.reduce((sum, m) => sum + (m.drawers || 0), 0) || 1; case 'hinge': if (doorType === 'sliding') return 0; return itemCount * getHingeCountForDoor({ heightMm: (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4 }); case 'handle': return doorType === 'sliding' ? (slidingPanelsCount || 2) : itemCount; case 'runner': case 'lift_system': return 1; default: return 1; } } /** * Build an HTML voucher email body for a single supplier. */ function buildVoucherHtml({ orderId, orderDate, customerName, supplierName, items }) { const formattedDate = new Date(orderDate).toLocaleDateString('fr-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }); const subtotal = items.reduce((sum, it) => sum + (it.unitPrice * it.qty), 0); const rows = items.map(it => ` ${it.code || '—'} ${it.name} ${it.qty} ${it.unitPrice != null ? it.unitPrice.toFixed(2) + ' CHF' : '—'} ${it.unitPrice != null ? (it.unitPrice * it.qty).toFixed(2) + ' CHF' : '—'} `).join(''); return ` Bon de commande #${orderId}

Bon de commande — APEX Configurateur

Référence commande : #${orderId}  |  ${formattedDate}

Fournisseur ${supplierName}
Client ${customerName || '—'}

Articles à fournir

${rows}
Code Désignation Qté PU (CHF) Total
Sous-total ${subtotal.toFixed(2)} CHF
Ce document est généré automatiquement par le configurateur APEX. Pour toute question : configurateur@apex.ch
`; } /** * Main entry point — called from orders route after insert. * Splits the order by supplier and sends each supplier their voucher. * * @param {number} orderId * @param {Object} config - the order config JSON (finishId, hardware, modules, blocks, doorType, …) * @param {string} customerName * @param {string} orderDate - ISO date string (defaults to now) */ export async function sendSupplierVouchers(orderId, config, customerName, orderDate = new Date().toISOString()) { const suppliers = await buildSupplierVoucherPayload(config); if (suppliers.length === 0) { console.log(`[supplier-voucher] Commande #${orderId} : aucun fournisseur résolu, aucun email envoyé.`); return; } // Send one email per supplier const sends = []; for (const supplier of suppliers) { const html = buildVoucherHtml({ orderId, orderDate, customerName, supplierName: supplier.supplierName, items: supplier.items }); sends.push( sendEmail({ to: supplier.supplierEmail, subject: `Bon de commande #${orderId} — APEX Configurateur`, html }).then(() => { console.log(`[supplier-voucher] Email envoyé à ${supplier.supplierName} <${supplier.supplierEmail}> pour commande #${orderId}`); }) ); } await Promise.all(sends); } /** * Build supplier payload from order config. * Used by email sender and by admin API preview. * * @param {Object} config * @returns {Promise>} */ export async function buildSupplierVoucherPayload(config) { const { hardware = {} } = config; const { handleVariantId, hingeVariantId, drawerSystemId } = hardware; // Resolve all items in parallel const [finishResult, drawerResult, handleResult, hingeResult] = await Promise.all([ resolveFinishSupplier(config.finishId), resolveBlumHardware(drawerSystemId, 'drawer_system'), resolveHandle(handleVariantId), resolveHinge(hingeVariantId) ]); // Group items by supplier_id const supplierMap = new Map(); // supplier_id → { supplier info, items[] } for (const result of [finishResult, drawerResult, handleResult, hingeResult]) { if (!result) continue; const { supplier, item, category } = result; const sid = supplier.supplier_id; if (!supplierMap.has(sid)) { supplierMap.set(sid, { code: supplier.supplier_code || null, name: supplier.supplier_name, email: supplier.supplier_email, items: [] }); } const qty = estimateQuantity(category, config); if (qty > 0) { supplierMap.get(sid).items.push({ code: item.code, name: item.name, unitPrice: item.price_chf, qty, category }); } } return Array.from(supplierMap, ([supplierId, supplierData]) => { const subtotal = supplierData.items.reduce((sum, item) => sum + ((item.unitPrice || 0) * item.qty), 0); return { supplierId, supplierCode: supplierData.code || null, supplierName: supplierData.name, supplierEmail: supplierData.email, items: supplierData.items, subtotal }; }); }