// 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 = `
| Item Code | ${esc(itemCode)} |
| Description | ${esc(itemName)} |
| Company | ${esc(company)} |
| In Stock | 0 |
Automated alert from the SAP ERP Portal — Short in Stock watch-list (Admin → System Settings).