179 lines
7.9 KiB
JavaScript
179 lines
7.9 KiB
JavaScript
// services/shortStockAlertStore.js
|
|
// Independent, fully automated feature (NOT tied to the Inventory Status
|
|
// Report screen at all): admin defines a watch-list of item codes/item
|
|
// groups in System Settings, each with its own concerned-user recipients.
|
|
// A background job (started from server.js, see checkShortStock() below)
|
|
// periodically sums each watched item's on-hand quantity (OITW.OnHand,
|
|
// across every warehouse) and emails the concerned users the FIRST time it
|
|
// hits zero — not on every check while it stays at zero, and not gated by
|
|
// the stage-change notification system (services/notifyStore.js) at all,
|
|
// since this isn't a workflow-stage event.
|
|
'use strict';
|
|
const sql = require('mssql');
|
|
const appSettings = require('./appSettingsStore');
|
|
const hanaUsers = require('./hanaUsers');
|
|
const mailer = require('./mailer');
|
|
const { getPool: getSapPool } = require('./sqlPool');
|
|
|
|
const TABLE = `[dbo].[ZSHORT_STOCK_STATE]`;
|
|
|
|
let _conn = null;
|
|
async function getConn() {
|
|
if (_conn) return _conn;
|
|
_conn = await sql.connect({
|
|
server: process.env.APP_SQL_HOST,
|
|
port: parseInt(process.env.APP_SQL_PORT),
|
|
user: process.env.APP_SQL_USER,
|
|
password: process.env.APP_SQL_PASSWORD,
|
|
database: process.env.APP_SQL_DATABASE,
|
|
options: { encrypt: true, trustServerCertificate: true },
|
|
});
|
|
return _conn;
|
|
}
|
|
async function exec(sqlQuery, params = []) {
|
|
const conn = await getConn();
|
|
const request = conn.request();
|
|
params.forEach((param, index) => { request.input(`param${index}`, param); });
|
|
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
|
|
const i = (string.slice(0, offset).match(/\?/g) || []).length;
|
|
return `@param${i}`;
|
|
});
|
|
const result = await request.query(replacedSql);
|
|
return result.recordset || [];
|
|
}
|
|
function isAlreadyExists(e) {
|
|
const m = (e.message || '').toLowerCase();
|
|
return m.includes('already exists') || m.includes('there is already an object');
|
|
}
|
|
|
|
async function bootstrap() {
|
|
console.log('[SHORT-STOCK-ALERT] Checking table', TABLE, '...');
|
|
await exec(`
|
|
CREATE TABLE ${TABLE} (
|
|
ITEM_CODE NVARCHAR(60) NOT NULL,
|
|
COMPANY NVARCHAR(60) NOT NULL,
|
|
WAS_ZERO BIT DEFAULT 0,
|
|
LAST_ALERT_AT DATETIME2,
|
|
CONSTRAINT PK_SHORT_STOCK_STATE PRIMARY KEY (ITEM_CODE, COMPANY)
|
|
)
|
|
`).catch(e => { if (!isAlreadyExists(e)) throw e; });
|
|
console.log('[SHORT-STOCK-ALERT] ✅ Ready');
|
|
}
|
|
|
|
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
|
|
// Resolves every configured rule into a flat map of itemCode → Set of
|
|
// recipient usernames (a item matched by more than one rule — e.g. both a
|
|
// direct ITEM rule and a GROUP rule it happens to belong to — gets every
|
|
// rule's recipients merged, mailed once).
|
|
async function resolveWatchedItems(company) {
|
|
const rules = appSettings.shortStockAlertRules();
|
|
const map = new Map(); // itemCode -> Set(usernames)
|
|
if (!rules.length) return map;
|
|
|
|
const itemRules = rules.filter(r => r.type === 'ITEM' && r.value);
|
|
const groupRules = rules.filter(r => r.type === 'GROUP' && r.value);
|
|
|
|
itemRules.forEach(r => {
|
|
const set = map.get(r.value) || new Set();
|
|
(r.recipients || []).forEach(u => set.add(u));
|
|
map.set(r.value, set);
|
|
});
|
|
|
|
if (groupRules.length) {
|
|
const pool = await getSapPool(company);
|
|
for (const r of groupRules) {
|
|
const grp = parseInt(r.value);
|
|
if (isNaN(grp)) continue;
|
|
try {
|
|
const rows = await pool.request().query(`SELECT "ItemCode" FROM [dbo].[OITM] WHERE "ItmsGrpCod"=${grp}`);
|
|
rows.recordset.forEach(row => {
|
|
const set = map.get(row.ItemCode) || new Set();
|
|
(r.recipients || []).forEach(u => set.add(u));
|
|
map.set(row.ItemCode, set);
|
|
});
|
|
} catch (e) { console.warn('[SHORT-STOCK-ALERT] group resolve failed for', r.value, e.message); }
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
async function getState(itemCode, company) {
|
|
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ITEM_CODE=? AND COMPANY=?`, [itemCode, company]);
|
|
return rows[0] || null;
|
|
}
|
|
async function upsertState(itemCode, company, wasZero, alertedNow) {
|
|
const existing = await getState(itemCode, company);
|
|
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23);
|
|
if (existing) {
|
|
await exec(
|
|
`UPDATE ${TABLE} SET WAS_ZERO=?${alertedNow ? ', LAST_ALERT_AT=?' : ''} WHERE ITEM_CODE=? AND COMPANY=?`,
|
|
alertedNow ? [wasZero ? 1 : 0, now, itemCode, company] : [wasZero ? 1 : 0, itemCode, company]
|
|
);
|
|
} else {
|
|
await exec(
|
|
`INSERT INTO ${TABLE} (ITEM_CODE, COMPANY, WAS_ZERO, LAST_ALERT_AT) VALUES (?,?,?,?)`,
|
|
[itemCode, company, wasZero ? 1 : 0, alertedNow ? now : null]
|
|
);
|
|
}
|
|
}
|
|
|
|
async function sendShortStockMail(itemCode, itemName, recipients, company) {
|
|
const all = await hanaUsers.listUsers();
|
|
const want = new Set([...recipients].map(u => u.toLowerCase()));
|
|
const users = all.filter(u => u.active && u.emailNotify !== false && want.has((u.username || '').toLowerCase()) && u.email);
|
|
if (!users.length) return false;
|
|
const html = `<div style="font-family:Segoe UI,Arial,sans-serif;max-width:520px">
|
|
<h2 style="margin:0 0 12px;font-size:16px;color:#1a2b4a">⚠ Out of Stock — ${esc(itemCode)}</h2>
|
|
<table>
|
|
<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">Item Code</td><td style="padding:3px 0;font-size:13px;font-weight:600">${esc(itemCode)}</td></tr>
|
|
<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">Description</td><td style="padding:3px 0;font-size:13px;font-weight:600">${esc(itemName)}</td></tr>
|
|
<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">Company</td><td style="padding:3px 0;font-size:13px;font-weight:600">${esc(company)}</td></tr>
|
|
<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">In Stock</td><td style="padding:3px 0;font-size:13px;font-weight:600;color:#e53e3e">0</td></tr>
|
|
</table>
|
|
<p style="margin-top:20px;color:#98a;font-size:11px">Automated alert from the SAP ERP Portal — Short in Stock watch-list (Admin → System Settings).</p>
|
|
</div>`;
|
|
return mailer.sendMail({ to: users.map(u => u.email), subject: `Out of Stock — ${itemCode}${itemName ? ' (' + itemName + ')' : ''}`, html });
|
|
}
|
|
|
|
// Main job — safe to call on a timer; never throws (a failure here must
|
|
// never crash the app), logs and returns instead.
|
|
async function checkShortStock() {
|
|
try {
|
|
if (!appSettings.shortStockAlertEnabled()) return;
|
|
const company = process.env.SAP_B1_COMPANY;
|
|
if (!company) return;
|
|
const watched = await resolveWatchedItems(company);
|
|
if (!watched.size) return;
|
|
|
|
const itemCodes = [...watched.keys()];
|
|
const pool = await getSapPool(company);
|
|
const list = itemCodes.map(c => `'${String(c).replace(/'/g, "''")}'`).join(',');
|
|
const rows = await pool.request().query(`
|
|
SELECT T0."ItemCode", T0."ItemName", SUM(ISNULL(T1."OnHand",0)) AS "InStock"
|
|
FROM [dbo].[OITM] T0
|
|
LEFT JOIN [dbo].[OITW] T1 ON T1."ItemCode"=T0."ItemCode"
|
|
WHERE T0."ItemCode" IN (${list})
|
|
GROUP BY T0."ItemCode", T0."ItemName"`);
|
|
|
|
for (const row of rows.recordset) {
|
|
const itemCode = row.ItemCode;
|
|
const inStock = Number(row.InStock) || 0;
|
|
const state = await getState(itemCode, company);
|
|
const wasZero = !!(state && state.WAS_ZERO);
|
|
const isZero = inStock <= 0;
|
|
if (isZero && !wasZero) {
|
|
const sent = await sendShortStockMail(itemCode, row.ItemName, watched.get(itemCode), company);
|
|
console.log(`[SHORT-STOCK-ALERT] ${itemCode} hit zero — mail ${sent ? 'sent' : 'skipped (SMTP not configured / no eligible recipients)'}`);
|
|
await upsertState(itemCode, company, true, true);
|
|
} else if (!isZero && wasZero) {
|
|
await upsertState(itemCode, company, false, false);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[SHORT-STOCK-ALERT] check failed:', e.message);
|
|
}
|
|
}
|
|
|
|
module.exports = { bootstrap, checkShortStock, resolveWatchedItems };
|