// 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, '>'); } // 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 = `

⚠ Out of Stock — ${esc(itemCode)}

Item Code${esc(itemCode)}
Description${esc(itemName)}
Company${esc(company)}
In Stock0

Automated alert from the SAP ERP Portal — Short in Stock watch-list (Admin → System Settings).

`; 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 };