187 lines
14 KiB
JavaScript
187 lines
14 KiB
JavaScript
// services/sales/notify.js
|
|
// Email routing for the Sales Order module. Mirrors msale's recipients
|
|
// (BUH for the division, Logistics, Accounts, the customer, mapped sales
|
|
// persons as CC) but resolves "who is BUH/Logistics/Accounts" from the ERP's
|
|
// Approval Steps + each user's Sales divisions instead of hardcoded user_type.
|
|
// Never throws — a mail problem must not break an order action.
|
|
'use strict';
|
|
const { sendMail } = require('../mailer');
|
|
const { usersForStep } = require('../notifyStore');
|
|
const masters = require('./masters');
|
|
const { query } = require('./db');
|
|
const appSettingsStore = require('../appSettingsStore');
|
|
|
|
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
const base = () => (process.env.APP_BASE_URL || '').replace(/\/$/, '');
|
|
|
|
// Active users explicitly assigned `stepKey` whose sales divisions include
|
|
// `divisionId` (users with no division restriction configured are skipped
|
|
// unless they're admin, so a BUH of TM isn't mailed about PD orders).
|
|
async function emailsForStep(stepKey, divisionId) {
|
|
try {
|
|
// Admin → System Settings → per-step email opt-out (same list the
|
|
// Production module honours) — a listed step mails nobody.
|
|
if (!appSettingsStore.isNotifyStepEnabled(stepKey)) return [];
|
|
const users = await usersForStep(stepKey);
|
|
if (!users.length) return [];
|
|
const profs = await query(`SELECT USER_ID, DIVISIONS FROM dbo.ZSO_USER_PROFILES`);
|
|
const divMap = new Map(profs.map(p => [p.USER_ID, String(p.DIVISIONS || '').split(',').map(Number).filter(Boolean)]));
|
|
return users.filter(u => u.email && (u.role === 'admin' || !divisionId || (divMap.get(u.id) || []).includes(Number(divisionId)))).map(u => u.email);
|
|
} catch (e) { console.warn('[SALES-NOTIFY] recipient lookup failed:', e.message); return []; }
|
|
}
|
|
|
|
function html({ title, lines, link }) {
|
|
return `<div style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#1f2937">
|
|
<h3 style="margin:0 0 10px;color:#0f3d91">${esc(title)}</h3>
|
|
${lines.map(l => `<p style="margin:4px 0">${l}</p>`).join('')}
|
|
${link ? `<p style="margin:14px 0 0"><a href="${esc(link)}" style="background:#1a6fff;color:#fff;padding:8px 14px;border-radius:6px;text-decoration:none">Open</a></p>` : ''}
|
|
<p style="margin-top:18px;font-size:12px;color:#6b7280">Mitra Industries Pvt. Ltd. — Sales Order Portal</p></div>`;
|
|
}
|
|
|
|
async function send(to, cc, subject, body) {
|
|
// Two switches, both must be on: the ERP-wide email switch (Admin →
|
|
// System Settings → Stage-Change Email Notifications) and the Sales-only
|
|
// switch (Sales Admin → Settings → "Send Sales email notifications").
|
|
if (!appSettingsStore.notifyEmailsEnabled()) return;
|
|
const s = await masters.getSettings();
|
|
if (s.emailNotifications === false) return;
|
|
const list = [...new Set([...(to || []), ...(cc || []), ...String(s.orderEmailCc || '').split(',')].map(x => String(x || '').trim()).filter(Boolean))];
|
|
// Test mode never falls through to real recipients: if it's on, the mail
|
|
// goes to the test address only (even when nobody real would have got it,
|
|
// so routing gaps are visible too) — or nowhere if no test address is set.
|
|
if (s.emailTestMode !== false) {
|
|
const testTo = String(s.emailTestRecipient || '').split(',').map(x => x.trim()).filter(Boolean);
|
|
if (!testTo.length) { console.log(`[SALES-NOTIFY] test mode, no test recipient — skipped "${subject}"`); return; }
|
|
const note = `<div style="background:#fff7e6;border:1px solid #f5a623;padding:8px 10px;margin-bottom:12px;font-size:12px">
|
|
<b>TEST MODE</b> — this email was NOT sent to its real recipients.<br>Would have gone to: <b>${list.length ? list.map(esc).join(', ') : '(nobody — no recipient found)'}</b></div>`;
|
|
try { await sendMail({ to: testTo, subject: `[TEST] ${subject}`, html: note + html(body) }); } catch (e) { console.warn('[SALES-NOTIFY] send failed:', e.message); }
|
|
return;
|
|
}
|
|
if (!list.length) return;
|
|
try { await sendMail({ to: list, subject, html: html(body) }); } catch (e) { console.warn('[SALES-NOTIFY] send failed:', e.message); }
|
|
}
|
|
|
|
// Order status-change notifications. `order` is the full order object.
|
|
async function orderEvent(event, order, { customerEmail = '', remarks = '' } = {}) {
|
|
try {
|
|
const div = order.divisionId;
|
|
const spCc = await masters.salesPersonEmailsFor(order.cardCode, div);
|
|
const who = `<b>${esc(order.cardName)}</b> (${esc(order.cardCode)})`;
|
|
const ref = `<b>${esc(order.refNo)}</b>`;
|
|
const empLink = `${base()}/sales-orders?id=${order.id}`;
|
|
const custLink = `${base()}/customer-portal#order/${order.id}`;
|
|
const rem = remarks ? [`Remarks: ${esc(remarks)}`] : [];
|
|
switch (event) {
|
|
case 'created': {
|
|
if (order.orderType === 'DIRECT') {
|
|
return send(await emailsForStep('sales_order:logistics_confirm', div), [], `Direct Sales Order ${order.refNo} generated`,
|
|
{ title: 'Direct sales order generated', lines: [`${esc(order.createdByName)} generated direct order ${ref} for ${who}.`, 'Please confirm quantity & dispatch time.'], link: empLink });
|
|
}
|
|
const to = order.status === 2 ? await emailsForStep('sales_order:logistics_confirm', div) : await emailsForStep('sales_order:review', div);
|
|
return send(to, spCc, `Sales Order ${order.refNo} generated`,
|
|
{ title: 'New sales order', lines: [`${who} placed sales order ${ref}.`, order.status === 2 ? 'Prices are standard — sent straight to Logistics for confirmation.' : 'Special prices were requested — please review.'], link: empLink });
|
|
}
|
|
case 'to_logistics':
|
|
return send(await emailsForStep('sales_order:logistics_confirm', div), spCc, `Confirm quantity & dispatch date — ${order.refNo}`,
|
|
{ title: 'Confirmation required', lines: [`Please confirm quantity & dispatch date for order ${ref} of ${who}.`, ...rem], link: empLink });
|
|
case 'qty_confirmed':
|
|
return send([...await emailsForStep('sales_order:final_approve', div), ...await emailsForStep('sales_order:review', div)], [], `Logistics confirmed ${order.refNo}`,
|
|
{ title: 'Quantity & dispatch time confirmed', lines: [`Logistics confirmed order ${ref} of ${who}.`, ...rem], link: empLink });
|
|
case 'approved':
|
|
return send([customerEmail], [...spCc, ...await emailsForStep('sales_order:logistics_confirm', div)], `Sales Order ${order.refNo} approved — please make payment`,
|
|
{ title: 'Order approved', lines: [`Your sales order ${ref} is approved.`, `Amount payable: <b>₹ ${Number(order.grandTotal).toLocaleString('en-IN', { minimumFractionDigits: 2 })}</b>.`, 'Please log in and submit the payment details.'], link: custLink });
|
|
case 'modify':
|
|
return send(order.orderType === 'DIRECT' ? await emailsForStep('sales_order:create_direct', div) : [customerEmail],
|
|
[...spCc, ...await emailsForStep('sales_order:review', div)], `Modification required — ${order.refNo}`,
|
|
{ title: 'Order sent back for modification', lines: [`Order ${ref} of ${who} needs modification.`, ...rem], link: order.orderType === 'DIRECT' ? empLink : custLink });
|
|
case 'payment_submitted':
|
|
return send(await emailsForStep('sales_order:payment_verify', div), [...spCc, ...await emailsForStep('sales_order:review', div)],
|
|
`Payment submitted for ${order.refNo} — verification required`,
|
|
{ title: 'Payment submitted', lines: [`${who} submitted payment for order ${ref}.`, `Mode: ${esc(order.paymentMode)} · Ref: ${esc(order.paymentRef)} · Amount: ₹ ${esc(order.paidAmount)}`], link: `${base()}/sales-orders?tab=payments` });
|
|
case 'payment_received':
|
|
return send([customerEmail], [...spCc, ...await emailsForStep('sales_order:sap_post', div)], `Payment received for ${order.refNo}`,
|
|
{ title: 'Payment received', lines: [`Payment for order ${ref} is confirmed. The order is now being processed for invoicing.`], link: custLink });
|
|
case 'payment_rejected':
|
|
return send([customerEmail], spCc, `Payment not verified for ${order.refNo}`,
|
|
{ title: 'Payment could not be verified', lines: [`The payment submitted for order ${ref} could not be verified. Please check and submit again.`, ...rem], link: custLink });
|
|
case 'ready_for_sap':
|
|
return send(await emailsForStep('sales_order:sap_post', div), [], `Order ${order.refNo} ready to upload in SAP`,
|
|
{ title: 'Ready for SAP', lines: [`Order ${ref} of ${who} is ready to be posted to SAP.`], link: empLink });
|
|
case 'in_sap':
|
|
return send([customerEmail], spCc, `Order ${order.refNo} placed in SAP`,
|
|
{ title: 'Order placed', lines: [`Your order ${ref} has been placed (SAP SO ${esc(order.sapDocNum || '')}).`], link: custLink });
|
|
case 'cancelled':
|
|
return send([customerEmail], [...spCc, ...await emailsForStep('sales_order:review', div)], `Sales Order ${order.refNo} cancelled`,
|
|
{ title: 'Order cancelled', lines: [`Order ${ref} of ${who} has been cancelled.`, ...rem], link: custLink });
|
|
default: return undefined;
|
|
}
|
|
} catch (e) { console.warn('[SALES-NOTIFY] orderEvent failed:', e.message); }
|
|
}
|
|
|
|
async function sampleEvent(kind, title, lines, stepKey, divisionId, extraTo = []) {
|
|
try {
|
|
const to = [...(stepKey ? await emailsForStep(stepKey, divisionId) : []), ...extraTo];
|
|
await send(to, [], title, { title, lines, link: `${base()}/sales-samples${kind === 'export' ? '?tab=export' : ''}` });
|
|
} catch (e) { console.warn('[SALES-NOTIFY] sampleEvent failed:', e.message); }
|
|
}
|
|
|
|
// "Invoice raised" — to the customer and everyone concerned with the order:
|
|
// mapped sales persons, BUH of the category, Logistics, Accounts and (Direct
|
|
// orders) the employee who raised it.
|
|
async function invoiceEvent(order, inv, lines, { customerEmail = '' } = {}) {
|
|
try {
|
|
const div = order.divisionId;
|
|
const staff = [
|
|
...await masters.salesPersonEmailsFor(order.cardCode, div),
|
|
...await emailsForStep('sales_order:review', div),
|
|
...await emailsForStep('sales_order:logistics_confirm', div),
|
|
...await emailsForStep('sales_order:payment_verify', div),
|
|
];
|
|
if (order.createdByType === 'user' && order.createdBy) {
|
|
const u = await query(`SELECT EMAIL FROM dbo.ZCUST_USERS WHERE ID=?`, [parseInt(order.createdBy) || 0]).catch(() => []);
|
|
if (u[0] && u[0].EMAIL) staff.push(u[0].EMAIL);
|
|
}
|
|
const d = inv.dispatch || {};
|
|
const fmt = v => (v ? new Date(v).toLocaleDateString('en-GB').replace(/\//g, '-') : '');
|
|
const money = v => '₹ ' + Number(v || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
const table = `<table style="border-collapse:collapse;font-size:13px;margin:6px 0" cellpadding="5">
|
|
<tr style="background:#eef3ff"><th align="left">Item</th><th align="left">Description</th><th align="right">Qty</th></tr>
|
|
${lines.map(l => `<tr><td style="border-top:1px solid #e5e7eb">${esc(l.itemCode)}</td><td style="border-top:1px solid #e5e7eb">${esc(l.description)}</td><td align="right" style="border-top:1px solid #e5e7eb">${esc(l.qty)}</td></tr>`).join('')}</table>`;
|
|
await send([customerEmail], staff, `Invoice ${inv.invoiceNo} raised for your order ${order.refNo}`, {
|
|
title: 'Invoice raised',
|
|
lines: [
|
|
`An invoice has been raised against sales order <b>${esc(order.refNo)}</b> of <b>${esc(order.cardName)}</b> (${esc(order.cardCode)})${order.custOrderNo ? ` — your ref. <b>${esc(order.custOrderNo)}</b>` : ''}.`,
|
|
`Invoice no.: <b>${esc(inv.invoiceNo)}</b> Date: <b>${esc(fmt(inv.invoiceDate))}</b> Amount: <b>${money(inv.total)}</b>`,
|
|
table,
|
|
d.lrNo || d.transporter || d.vehicle ? `Dispatch: LR/GR/AWB <b>${esc(d.lrNo || '—')}</b>${d.lrDate ? ' dated ' + esc(fmt(d.lrDate)) : ''}${d.transporter ? ' · ' + esc(d.transporter) : ''}${d.vehicle ? ' · vehicle ' + esc(d.vehicle) : ''}${d.ewayBill ? ' · e-Way bill ' + esc(d.ewayBill) : ''}` : '',
|
|
'The invoice copy and COA certificates can be downloaded from the order in the Customer Portal.',
|
|
].filter(Boolean),
|
|
link: `${base()}/customer-portal#order/${order.id}`,
|
|
});
|
|
} catch (e) { console.warn('[SALES-NOTIFY] invoiceEvent failed:', e.message); }
|
|
}
|
|
|
|
// Customer portal login enabled / password reset by MIPL. The password is a
|
|
// one-time password — the customer is forced to change it at first login.
|
|
async function customerLoginEvent(kind, { cardCode, cardName, email, password }) {
|
|
try {
|
|
const link = `${base()}/customer-portal`;
|
|
const created = kind === 'created';
|
|
await send([email], [], created ? 'Your MIPL Customer Portal login' : 'Your MIPL Customer Portal password was reset', {
|
|
title: created ? 'Welcome to the MIPL Customer Portal' : 'Password reset',
|
|
lines: [
|
|
`Dear ${esc(cardName || cardCode)},`,
|
|
created ? 'A login has been created for you on the Mitra Industries Customer Portal, where you can place and track sales orders, make payments, view your ledger and download invoices & COA certificates.'
|
|
: 'Your Customer Portal password has been reset by MIPL.',
|
|
`Portal: <a href="${esc(link)}">${esc(link)}</a>`,
|
|
`Customer code: <b>${esc(cardCode)}</b>`,
|
|
password ? `Temporary password: <b>${esc(password)}</b> — you will be asked to set your own password when you log in.` : '',
|
|
'If you did not expect this email, please contact your MIPL sales representative.',
|
|
].filter(Boolean),
|
|
link,
|
|
});
|
|
} catch (e) { console.warn('[SALES-NOTIFY] customerLoginEvent failed:', e.message); }
|
|
}
|
|
|
|
module.exports = { orderEvent, sampleEvent, emailsForStep, customerLoginEvent, invoiceEvent };
|