first commit
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s

This commit is contained in:
John
2026-09-23 17:31:02 +05:30
commit 69b4e68baf
51657 changed files with 3864077 additions and 0 deletions
+373
View File
@@ -0,0 +1,373 @@
// services/rejectionRegisterStore.js
// In-process rejection tracking for a manufacturing stage (e.g. EBB, Sheet
// Welding, Moulding) — a line/shift user counts rejections against a set of
// admin-configured causes for the batch they're inspecting, records what got
// reworked back to good, and QA can edit after submission. Two tables:
// ZREJECTION_CAUSES (admin-maintained, per-stage, each with a root cause
// roll-up) and ZREJECTION_ENTRIES (one row per batch/stage counted).
const sql = require('mssql');
const CAUSES_TABLE = `[dbo].[ZREJECTION_CAUSES]`;
const ENTRIES_TABLE = `[dbo].[ZREJECTION_ENTRIES]`;
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('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
function nowTs() { return new Date().toISOString().replace('T', ' ').substring(0, 23); }
function safeJson(v, f) { if (!v) return f; try { return JSON.parse(v); } catch { return f; } }
async function bootstrap() {
console.log('[REJECTION-REGISTER] Checking tables...');
await exec(`
CREATE TABLE ${CAUSES_TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
STAGE NVARCHAR(50) NOT NULL,
NAME NVARCHAR(150) NOT NULL,
ROOT_CAUSE NVARCHAR(150),
ACTIVE BIT DEFAULT 1,
COMPANY NVARCHAR(60),
CREATED_BY NVARCHAR(50),
CREATED_AT DATETIME2
)
`).catch(e => { if (!isAlreadyExists(e)) throw e; });
await exec(`
CREATE TABLE ${ENTRIES_TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
BATCH_NO NVARCHAR(60) NOT NULL,
PRODUCT_CODE NVARCHAR(60),
PRODUCT_NAME NVARCHAR(200),
STAGE NVARCHAR(50) NOT NULL,
SHIFT NVARCHAR(50),
PROD_DATE NVARCHAR(20),
BATCH_SIZE FLOAT,
CAUSES NVARCHAR(MAX),
REMARKS NVARCHAR(400),
NET_QTY FLOAT DEFAULT 0,
GROSS_QTY FLOAT DEFAULT 0,
NET_PCT FLOAT DEFAULT 0,
STATUS NVARCHAR(20) DEFAULT 'SUBMITTED',
COMPANY NVARCHAR(60),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_BY NVARCHAR(50),
UPDATED_NAME NVARCHAR(100),
UPDATED_AT DATETIME2,
IS_DELETED BIT DEFAULT 0
)
`).catch(e => { if (!isAlreadyExists(e)) throw e; });
// Set at creation when the entry's Stage had ZERO active causes at that
// moment (the Entry wizard's free-text fallback screen) — flags it for
// QA follow-up: add causes for that stage, then go back and code this
// entry properly. See createEntry()/pendingCauseSetupSummary() below.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZREJECTION_ENTRIES' AND COLUMN_NAME='NEEDS_CAUSE_SETUP') ALTER TABLE ${ENTRIES_TABLE} ADD [NEEDS_CAUSE_SETUP] BIT DEFAULT 0`).catch(() => {});
console.log('[REJECTION-REGISTER] ✅ Ready');
}
// ── Causes (admin/QA-maintained, per stage) ────────────────────────────
function causeFromRow(row) {
return {
id: row.ID,
stage: row.STAGE || '',
name: row.NAME || '',
rootCause: row.ROOT_CAUSE || '',
active: !!row.ACTIVE,
company: row.COMPANY || '',
createdBy: row.CREATED_BY || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
};
}
async function listCauses({ stage, company, includeInactive } = {}) {
const rows = await exec(`SELECT * FROM ${CAUSES_TABLE} ORDER BY STAGE, NAME`);
return rows.map(causeFromRow).filter(c => {
if (!includeInactive && !c.active) return false;
if (stage && c.stage !== stage) return false;
if (company && c.company && c.company !== company) return false;
return true;
});
}
async function createCause({ stage, name, rootCause, company, createdBy }) {
if (!stage || !String(stage).trim()) throw new Error('Stage is required');
if (!name || !String(name).trim()) throw new Error('Cause name is required');
const idRows = await exec(`
INSERT INTO ${CAUSES_TABLE} (STAGE, NAME, ROOT_CAUSE, ACTIVE, COMPANY, CREATED_BY, CREATED_AT)
VALUES (?,?,?,1,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [String(stage).trim(), String(name).trim(), rootCause || '', company || '', createdBy || '', nowTs()]);
const rows = await exec(`SELECT * FROM ${CAUSES_TABLE} WHERE ID=?`, [idRows[0].ID]);
return causeFromRow(rows[0]);
}
async function updateCause(id, { name, rootCause, active }) {
const sets = [], vals = [];
if (name !== undefined) { sets.push('NAME=?'); vals.push(String(name).trim()); }
if (rootCause !== undefined) { sets.push('ROOT_CAUSE=?'); vals.push(rootCause || ''); }
if (active !== undefined) { sets.push('ACTIVE=?'); vals.push(active ? 1 : 0); }
if (!sets.length) return findCauseById(id);
vals.push(parseInt(id));
await exec(`UPDATE ${CAUSES_TABLE} SET ${sets.join(', ')} WHERE ID=?`, vals);
return findCauseById(id);
}
async function findCauseById(id) {
const rows = await exec(`SELECT * FROM ${CAUSES_TABLE} WHERE ID=?`, [parseInt(id)]);
return rows.length ? causeFromRow(rows[0]) : null;
}
async function deleteCause(id) {
await exec(`DELETE FROM ${CAUSES_TABLE} WHERE ID=?`, [parseInt(id)]);
}
// ── Entries (one per batch/stage counted) ──────────────────────────────
function entryFromRow(row) {
const causes = safeJson(row.CAUSES, []);
return {
id: row.ID,
batchNo: row.BATCH_NO || '',
productCode: row.PRODUCT_CODE || '',
productName: row.PRODUCT_NAME || '',
stage: row.STAGE || '',
shift: row.SHIFT || '',
prodDate: row.PROD_DATE || '',
batchSize: row.BATCH_SIZE || 0,
causes,
remarks: row.REMARKS || '',
netQty: row.NET_QTY || 0,
grossQty: row.GROSS_QTY || 0,
netPct: row.NET_PCT || 0,
status: row.STATUS || 'SUBMITTED',
company: row.COMPANY || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedBy: row.UPDATED_BY || '',
updatedByName: row.UPDATED_NAME || '',
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
isDeleted: !!row.IS_DELETED,
needsCauseSetup: !!row.NEEDS_CAUSE_SETUP,
};
}
// Derives gross/net qty + net% from a causes array — kept server-side so a
// client can never fake the totals it submits.
// "Okay after rework" is the OUTCOME of the pieces that were "Sent for
// rework" — a SUBSET of it, not a separate bucket alongside it. The real
// constraint is Okay after rework ≤ Sent for rework ≤ Rejected (checking
// "Rework + OK ≤ Rejected" instead would double-count and wrongly reject
// the legitimate all-recovered case: Rejected=10, Rework=10, OK=10 is
// perfectly valid — Net comes out to exactly 0, everything accounted for).
// The client already clamps this in the UI, but never trust that alone; a
// hand-crafted request could still send a bad value.
// `batchSize` is optional — pass it whenever it's known (createEntry/
// updateEntry both do) to also enforce the total-rejected-vs-batch-size
// ceiling; the client already clamps this in the UI, but never trust that
// alone.
// Rejected and Sent for rework are now TWO INDEPENDENT piles pulled from
// the same batch (both entered at Count/Step 2, not one nested inside the
// other) — so there's no Rework ≤ Rejected rule anymore; instead the two
// piles TOGETHER can never exceed the Batch Size. Okay after rework IS a
// subset of Sent for rework specifically (Okay after Rework ≤ Sent for
// Rework).
function validateCauses(causes, batchSize) {
let totalPulled = 0;
(causes || []).forEach(c => {
const rejected = parseFloat(c.rejected) || 0;
const rework = parseFloat(c.rework) || 0;
const ok = parseFloat(c.ok) || 0;
if (ok > rework) throw new Error(`"${c.name}": Okay after rework (${ok}) can't exceed Sent for rework (${rework})`);
totalPulled += rejected + rework;
});
const size = parseFloat(batchSize) || 0;
if (size > 0 && totalPulled > size) throw new Error(`Rejected + Sent for rework across all causes (${totalPulled}) can't exceed Batch Size (${size})`);
}
// Reject after rework = Sent for rework − Okay after rework (whatever
// didn't recover). Net = Rejected + Reject after rework — the
// outright-rejected pile PLUS whatever the (separate) rework pile still
// lost, added together since they're independent piles pulled from the
// same batch. E.g. Rejected=10, Rework=8, OK=8 → Net=10 (all 8 reworked
// pieces recovered, but the original 10 rejects still stand); Rejected=10,
// Rework=8, OK=5 → Net=13 (10 rejected + 3 that failed rework).
function computeTotals(causes, batchSize) {
const gross = (causes || []).reduce((s, c) => s + (parseFloat(c.rejected) || 0), 0);
const rework = (causes || []).reduce((s, c) => s + (parseFloat(c.rework) || 0), 0);
const ok = (causes || []).reduce((s, c) => s + (parseFloat(c.ok) || 0), 0);
const net = gross + (rework - ok);
const size = parseFloat(batchSize) || 0;
const pct = size ? Math.round((net / size) * 1000) / 10 : 0;
return { gross, net, pct };
}
async function listEntries({ company, stage, from, to, includeDeleted } = {}) {
const rows = await exec(`SELECT * FROM ${ENTRIES_TABLE} ORDER BY CREATED_AT DESC`);
return rows.map(entryFromRow).filter(e => {
if (!includeDeleted && e.isDeleted) return false;
if (company && e.company && e.company !== company) return false;
if (stage && e.stage !== stage) return false;
if (from && e.prodDate && e.prodDate < from) return false;
if (to && e.prodDate && e.prodDate > to) return false;
return true;
});
}
async function findEntryById(id) {
const rows = await exec(`SELECT * FROM ${ENTRIES_TABLE} WHERE ID=?`, [parseInt(id)]);
return rows.length ? entryFromRow(rows[0]) : null;
}
async function createEntry(p) {
if (!p.batchNo || !String(p.batchNo).trim()) throw new Error('Batch number is required');
if (!p.stage) throw new Error('Stage is required');
validateCauses(p.causes, p.batchSize);
const { gross, net, pct } = computeTotals(p.causes, p.batchSize);
// Flags this entry as needing QA follow-up in either of two cases —
// computed here, never trusted from the client, so it can't be spoofed
// either way:
// 1. The Stage had zero ACTIVE causes set up at submission (Entry
// wizard's free-text fallback screen), OR
// 2. Real causes DID exist, but the operator used the fixed "Other /
// Unknown" sentinel bucket for some/all of the qty because they
// didn't know (or it wasn't) one of the listed ones — see
// public/rejection-register.html's stepCount().
const activeCauses = await listCauses({ stage: p.stage, company: p.company });
const usedOtherBucket = (p.causes || []).some(c => c.name === 'Other / Unknown' && (parseFloat(c.rejected) || 0) > 0);
const needsCauseSetup = activeCauses.length === 0 || usedOtherBucket;
const now = nowTs();
const idRows = await exec(`
INSERT INTO ${ENTRIES_TABLE} (
BATCH_NO, PRODUCT_CODE, PRODUCT_NAME, STAGE, SHIFT, PROD_DATE, BATCH_SIZE,
CAUSES, REMARKS, NET_QTY, GROSS_QTY, NET_PCT, STATUS, COMPANY,
CREATED_BY, CREATED_NAME, CREATED_AT, NEEDS_CAUSE_SETUP
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
String(p.batchNo).trim(), p.productCode || '', p.productName || '', p.stage, p.shift || '', p.prodDate || '', parseFloat(p.batchSize) || 0,
JSON.stringify(p.causes || []), p.remarks || '', net, gross, pct, 'SUBMITTED', p.company || '',
p.createdBy || '', p.createdByName || p.createdBy || '', now, needsCauseSetup ? 1 : 0,
]);
return findEntryById(idRows[0].ID);
}
// QA-only edit after submission — recomputes totals from whatever causes
// array is passed (never trusts a client-sent total).
async function updateEntry(id, p, { by, byName } = {}) {
const existing = await findEntryById(id);
if (!existing) throw new Error('Entry not found');
const causes = p.causes !== undefined ? p.causes : existing.causes;
const stage = p.stage !== undefined ? p.stage : existing.stage;
const batchSize = p.batchSize !== undefined ? p.batchSize : existing.batchSize;
validateCauses(causes, batchSize);
const { gross, net, pct } = computeTotals(causes, batchSize);
// Re-coding the causes only clears the flag if it's ACTUALLY resolved now
// (same check as createEntry() — no leftover "Other / Unknown" qty, and
// the stage has real active causes) — saving the modal without actually
// moving the Other/Unknown qty into a real cause must NOT silently mark
// this as handled. An edit that doesn't touch causes at all (Shift/Date/
// Remarks only) leaves the flag exactly as it was.
let needsCauseSetup = existing.needsCauseSetup;
if (p.causes !== undefined) {
const activeCauses = await listCauses({ stage, company: existing.company });
const usedOtherBucket = (causes || []).some(c => c.name === 'Other / Unknown' && (parseFloat(c.rejected) || 0) > 0);
needsCauseSetup = activeCauses.length === 0 || usedOtherBucket;
}
await exec(`
UPDATE ${ENTRIES_TABLE} SET
PRODUCT_CODE=?, PRODUCT_NAME=?, SHIFT=?, PROD_DATE=?, BATCH_SIZE=?,
CAUSES=?, REMARKS=?, NET_QTY=?, GROSS_QTY=?, NET_PCT=?, STATUS='QA_EDITED',
NEEDS_CAUSE_SETUP=?, UPDATED_BY=?, UPDATED_NAME=?, UPDATED_AT=?
WHERE ID=?
`, [
p.productCode !== undefined ? p.productCode : existing.productCode,
p.productName !== undefined ? p.productName : existing.productName,
p.shift !== undefined ? p.shift : existing.shift,
p.prodDate !== undefined ? p.prodDate : existing.prodDate,
parseFloat(batchSize) || 0,
JSON.stringify(causes || []), p.remarks !== undefined ? p.remarks : existing.remarks,
net, gross, pct,
needsCauseSetup ? 1 : 0, by || '', byName || by || '', nowTs(),
parseInt(id),
]);
return findEntryById(id);
}
async function softDeleteEntry(id) {
await exec(`UPDATE ${ENTRIES_TABLE} SET IS_DELETED=1 WHERE ID=?`, [parseInt(id)]);
}
// ── Analytics — aggregated over a filtered entry set ───────────────────
// Everything here is derived straight from listEntries()'s own filtering
// (company/stage/date range), so the caller decides scope; this just does
// the rollup math once instead of repeating it per screen.
function analyticsFor(entries) {
const totalGross = entries.reduce((s, e) => s + (e.grossQty || 0), 0);
const totalNet = entries.reduce((s, e) => s + (e.netQty || 0), 0);
const totalSize = entries.reduce((s, e) => s + (e.batchSize || 0), 0);
const avgPct = totalSize ? Math.round((totalNet / totalSize) * 1000) / 10 : 0;
const causeAgg = {};
entries.forEach(e => (e.causes || []).forEach(c => {
// Same per-cause formula as computeTotals()'s batch-level Net: rejected
// + (rework − ok), the two independent piles added together.
const net = (parseFloat(c.rejected) || 0) + ((parseFloat(c.rework) || 0) - (parseFloat(c.ok) || 0));
if (!c.name) return;
causeAgg[c.name] = (causeAgg[c.name] || 0) + net;
}));
const topCauses = Object.entries(causeAgg).map(([name, qty]) => ({ name, qty })).sort((a, b) => b.qty - a.qty);
// Daily trend — date -> {gross,net,size}
const byDate = {};
entries.forEach(e => {
const d = e.prodDate || (e.createdAt || '').slice(0, 10);
if (!byDate[d]) byDate[d] = { date: d, gross: 0, net: 0, size: 0 };
byDate[d].gross += e.grossQty || 0;
byDate[d].net += e.netQty || 0;
byDate[d].size += e.batchSize || 0;
});
const trend = Object.values(byDate).sort((a, b) => (a.date > b.date ? 1 : -1)).map(d => ({
date: d.date, grossPct: d.size ? Math.round((d.gross / d.size) * 1000) / 10 : 0, netPct: d.size ? Math.round((d.net / d.size) * 1000) / 10 : 0,
}));
return { totalGross, totalNet, totalSize, avgPct, savedByRework: totalGross - totalNet, topCauses, trend, batchCount: entries.length };
}
// Every entry flagged NEEDS_CAUSE_SETUP at creation (free-text fallback),
// grouped by Stage — what Rejection Analytics → Root Cause Setup's "Needs
// Causes" card shows QA. Not auto-cleared once causes are added for that
// stage (see routes/rejectionRegister.js's PUT /entries/:id — recoding the
// entry itself clears its own flag; adding a cause for the stage doesn't
// retroactively touch older already-submitted entries).
// Grouped by Stage, each with the actual pending entries (not just a count)
// — who recorded each one (createdByName) and when, so QA knows both WHO to
// follow up with and can jump straight to fixing it (the client pairs this
// with PUT /entries/:id to actually re-code the causes).
async function pendingCauseSetupSummary({ company } = {}) {
const entries = await listEntries({ company });
const byStage = {};
entries.filter(e => e.needsCauseSetup).forEach(e => {
if (!byStage[e.stage]) byStage[e.stage] = { stage: e.stage, count: 0, latestAt: e.createdAt, entries: [] };
byStage[e.stage].count++;
byStage[e.stage].entries.push({ id: e.id, batchNo: e.batchNo, createdBy: e.createdBy, createdByName: e.createdByName, createdAt: e.createdAt });
if (e.createdAt && (!byStage[e.stage].latestAt || e.createdAt > byStage[e.stage].latestAt)) byStage[e.stage].latestAt = e.createdAt;
});
Object.values(byStage).forEach(s => s.entries.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)));
return Object.values(byStage).sort((a, b) => b.count - a.count);
}
module.exports = {
bootstrap,
listCauses, createCause, updateCause, findCauseById, deleteCause,
listEntries, findEntryById, createEntry, updateEntry, softDeleteEntry,
analyticsFor, pendingCauseSetupSummary,
};