374 lines
18 KiB
JavaScript
374 lines
18 KiB
JavaScript
// 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,
|
||
};
|