sale order
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s

This commit is contained in:
John
2026-10-05 18:45:17 +05:30
parent e725a6571b
commit eead8f5ffd
129 changed files with 14252 additions and 452 deletions
+178
View File
@@ -0,0 +1,178 @@
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
// 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 };