Files
apex/server/services/supplierVoucher.js
T
lejayandCursor 6c0d7e47b8 v2.0
Co-authored-by: Cursor <[email protected]>
2026-05-28 07:20:38 +02:00

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&nbsp;: <strong>#${orderId}</strong> &nbsp;|&nbsp; ${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
};
});
}