// 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, };