283 lines
10 KiB
JavaScript
283 lines
10 KiB
JavaScript
/**
|
|
* 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
|
|
};
|
|
});
|
|
}
|