@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 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 => `
|
||||
<tr>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${it.code || '—'}</td>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${it.name}</td>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:center;">${it.qty}</td>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${it.unitPrice != null ? it.unitPrice.toFixed(2) + ' CHF' : '—'}</td>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${it.unitPrice != null ? (it.unitPrice * it.qty).toFixed(2) + ' CHF' : '—'}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="utf-8"><title>Bon de commande #${orderId}</title></head>
|
||||
<body style="margin:0;padding:0;background:#f9fafb;font-family:Arial,sans-serif;color:#111827;">
|
||||
<div style="max-width:680px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.1);">
|
||||
|
||||
<div style="background:#1e293b;color:#fff;padding:28px 32px;">
|
||||
<h1 style="margin:0;font-size:20px;font-weight:700;">Bon de commande — APEX Configurateur</h1>
|
||||
<p style="margin:6px 0 0;opacity:.75;font-size:14px;">Référence commande : <strong>#${orderId}</strong> | ${formattedDate}</p>
|
||||
</div>
|
||||
|
||||
<div style="padding:24px 32px;">
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#6b7280;font-size:13px;width:140px;">Fournisseur</td>
|
||||
<td style="padding:4px 0;font-weight:600;">${supplierName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#6b7280;font-size:13px;">Client</td>
|
||||
<td style="padding:4px 0;">${customerName || '—'}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 style="font-size:15px;font-weight:600;margin:0 0 12px;color:#1e293b;">Articles à fournir</h2>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px;">
|
||||
<thead>
|
||||
<tr style="background:#f1f5f9;">
|
||||
<th style="padding:8px 12px;text-align:left;font-weight:600;color:#374151;">Code</th>
|
||||
<th style="padding:8px 12px;text-align:left;font-weight:600;color:#374151;">Désignation</th>
|
||||
<th style="padding:8px 12px;text-align:center;font-weight:600;color:#374151;">Qté</th>
|
||||
<th style="padding:8px 12px;text-align:right;font-weight:600;color:#374151;">PU (CHF)</th>
|
||||
<th style="padding:8px 12px;text-align:right;font-weight:600;color:#374151;">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" style="padding:10px 12px;text-align:right;font-weight:700;border-top:2px solid #e5e7eb;">Sous-total</td>
|
||||
<td style="padding:10px 12px;text-align:right;font-weight:700;border-top:2px solid #e5e7eb;">${subtotal.toFixed(2)} CHF</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="padding:16px 32px 24px;font-size:12px;color:#9ca3af;border-top:1px solid #e5e7eb;">
|
||||
Ce document est généré automatiquement par le configurateur APEX. Pour toute question : [email protected]
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Array<{supplierId:number, supplierCode:string|null, supplierName:string, supplierEmail:string, items:Array, subtotal:number}>>}
|
||||
*/
|
||||
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
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user