Files
sap-erp/services/rejectionRegisterStore.js
T
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

374 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};