// services/deviationStore.js // Production Deviations: after Issue for Production, a component may run // short (Shortage — top up qty of the same item), need swapping for a // different item entirely (Substitution), or something already in-process // gets damaged (Damage/Loss — optionally written off to a scrap warehouse). // The SAP posting (if any) happens immediately when raised, so production is // never blocked; this table is the documented "why" — reason, who raised it, // and (when Admin → System Settings → "Deviation — Require QA Approval" is // on) a post-hoc QA sign-off that closes the paper trail without ever // gating the actual goods movement. 'use strict'; const sql = require('mssql'); const TABLE = `[dbo].[ZPRODUCTION_DEVIATIONS]`; // ADDITION: a genuinely new item/component that was never on the Production // Order at all — distinct from Substitution (which replaces an EXISTING // component) and Shortage (which tops up an EXISTING component's qty). // Reuses ITEM_CODE/ITEM_NAME for the new item (no "old" item involved, so no // OLD_LINE_NUM either), and shares Substitution's own "add a real component // line" SAP posting/apply/reversal logic in routes/sap.js — just always with // oldLineNum=null, since applySubstitutionLine() already handles that case. const TYPES = ['SHORTAGE', 'SUBSTITUTION', 'DAMAGE', 'ADDITION']; 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'); } async function bootstrap() { console.log('[DEVIATION-STORE] Checking table', TABLE, '...'); await exec(` CREATE TABLE ${TABLE} ( ID INT IDENTITY(1,1) PRIMARY KEY, PRODUCTION_ORDER_ID INT, SAP_ABS_ENTRY INT, SAP_DOC_NUM NVARCHAR(30), TYPE NVARCHAR(20) NOT NULL, ITEM_CODE NVARCHAR(60), ITEM_NAME NVARCHAR(200), NEW_ITEM_CODE NVARCHAR(60), NEW_ITEM_NAME NVARCHAR(200), QUANTITY DECIMAL(19,6), WAREHOUSE NVARCHAR(20), DEST_WAREHOUSE NVARCHAR(20), BATCH_NUMBER NVARCHAR(MAX), LINE_NUM INT, POST_INTENDED BIT DEFAULT 0, POSTED_TO_SAP BIT DEFAULT 0, SAP_DOC_ENTRY INT, SAP_DOC_NUM_POSTED NVARCHAR(30), SAP_DOC_OBJECT NVARCHAR(60), REVERSED BIT DEFAULT 0, REVERSAL_DOC_ENTRY INT, REVERSAL_DOC_NUM NVARCHAR(30), REVERSAL_DOC_OBJECT NVARCHAR(60), REVERSAL_AT DATETIME2, REVERSAL_ERROR NVARCHAR(MAX), REASON NVARCHAR(MAX), RAISED_BY NVARCHAR(50), RAISED_NAME NVARCHAR(100), RAISED_AT DATETIME2, QA_REQUIRED BIT DEFAULT 0, QA_STATUS NVARCHAR(20) DEFAULT 'N_A', QA_BY NVARCHAR(50), QA_NAME NVARCHAR(100), QA_AT DATETIME2, QA_REMARKS NVARCHAR(MAX), COMPANY NVARCHAR(60), IS_DELETED BIT DEFAULT 0 ) `).catch(e => { if (isAlreadyExists(e)) { console.log('[DEVIATION-STORE] Table exists — OK'); } else throw e; }); // Migration: BATCH_NUMBER added after the table already existed in some // environments — holds a JSON array of {BatchNumber,Quantity} (a // Shortage/Substitution issue or a Damage/Loss transfer can draw from // MORE THAN ONE batch, same as Issue for Production, when a single batch // doesn't have enough quantity in stock). await exec(` IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_DEVIATIONS' AND COLUMN_NAME='BATCH_NUMBER') ALTER TABLE ${TABLE} ADD BATCH_NUMBER NVARCHAR(MAX) `).catch(e => console.warn('[DEVIATION-STORE] BATCH_NUMBER migration failed (non-fatal):', e.message)); // Widen it in case it was created earlier as the old NVARCHAR(60) — safe, // non-destructive (widening never loses data). await exec(`ALTER TABLE ${TABLE} ALTER COLUMN BATCH_NUMBER NVARCHAR(MAX)`) .catch(e => console.warn('[DEVIATION-STORE] BATCH_NUMBER widen failed (non-fatal):', e.message)); // Migration: LINE_NUM + REVERSAL_* columns added after the table already // existed in some environments — LINE_NUM records the affected component's // own BaseLine (Shortage only, needed to reverse via the same "Return // Components" shape); REVERSAL_* records what happened when QA rejects an // already-posted deviation (the goods movement is reversed automatically — // see routes/sap.js PATCH /deviations/:id/approve). const addCol = (col, ddl) => exec(` IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_DEVIATIONS' AND COLUMN_NAME='${col}') ALTER TABLE ${TABLE} ADD ${ddl} `).catch(e => console.warn(`[DEVIATION-STORE] ${col} migration failed (non-fatal):`, e.message)); await addCol('LINE_NUM', 'LINE_NUM INT'); await addCol('REVERSED', 'REVERSED BIT DEFAULT 0'); await addCol('REVERSAL_DOC_ENTRY', 'REVERSAL_DOC_ENTRY INT'); await addCol('REVERSAL_DOC_NUM', 'REVERSAL_DOC_NUM NVARCHAR(30)'); await addCol('REVERSAL_DOC_OBJECT', 'REVERSAL_DOC_OBJECT NVARCHAR(60)'); await addCol('REVERSAL_AT', 'REVERSAL_AT DATETIME2'); await addCol('REVERSAL_ERROR', 'REVERSAL_ERROR NVARCHAR(MAX)'); // POST_INTENDED — only meaningful for a Damage/Loss deviation raised while // "Defer Issue Until Approval" is on: remembers that the user DID check // "post an actual SAP write-off" at raise time, even though (in deferred // mode) nothing was posted yet — the deviation's own POSTED_TO_SAP stays 0 // until the concerned user manually posts it once QA approves. Without // this the intent would be lost the moment the immediate posting is // skipped, and there'd be no way to tell "informational only" apart from // "queued to post" once it's sitting there un-posted. await addCol('POST_INTENDED', 'POST_INTENDED BIT DEFAULT 0'); // LINE_APPLIED — for Shortage/Substitution: whether the Production Order // line change (bump PlannedQuantity / add the component) has actually been // made yet. Immediate (non-deferred) postings apply it right away; deferred // ones do NOT touch the Production Order at raise time at all — only once // QA approves (see POST /deviations vs PATCH /deviations/:id/approve in // routes/sap.js). A still-PENDING deviation must never show up on the // Production Order, full stop. await addCol('LINE_APPLIED', 'LINE_APPLIED BIT DEFAULT 0'); await addCol('LINE_APPLY_ERROR', 'LINE_APPLY_ERROR NVARCHAR(MAX)'); // One-time backfill for rows created under the OLD deferred design (which // DID patch the Production Order at raise time, just skipped the goods // issue) — those already touched SAP even though this new column defaults // to 0. Without this, rejecting one of those (if still PENDING) would // wrongly skip reverting a change that genuinely happened. Scoped to a // fixed cutoff (this feature's ship date) so it never misclassifies a // brand-new deferred Shortage deviation raised under the CORRECT new // behavior (which also has LINE_NUM set immediately but LINE_APPLIED // correctly still 0 until approval). // NOTE: ALTER TABLE ... ADD col DEFAULT 0 only applies that default to NEW // inserts — SQL Server leaves already-existing rows NULL (not 0), unless // the column is also NOT NULL. Must match NULL here too, or this silently // backfills nothing. await exec(` UPDATE ${TABLE} SET LINE_APPLIED = 1 WHERE (LINE_APPLIED = 0 OR LINE_APPLIED IS NULL) AND ISNULL(POSTED_TO_SAP,0) = 0 AND LINE_NUM IS NOT NULL AND TYPE IN ('SHORTAGE','SUBSTITUTION') AND RAISED_AT < '2026-08-06T08:30:00' `).catch(e => console.warn('[DEVIATION-STORE] LINE_APPLIED backfill failed (non-fatal):', e.message)); // Any remaining NULLs (rows outside the backfill's scope — e.g. already // POSTED_TO_SAP, or DAMAGE) just need to become a real 0, not stay NULL. await exec(`UPDATE ${TABLE} SET LINE_APPLIED = 0 WHERE LINE_APPLIED IS NULL`) .catch(e => console.warn('[DEVIATION-STORE] LINE_APPLIED NULL cleanup failed (non-fatal):', e.message)); // OLD_LINE_NUM — Substitution only: the REPLACED item's own component // LineNumber (the "Affected Component" the user picked). Substituting must // shrink that item's remaining (unissued) quantity by the deviation's own // qty at the same time the new item is added/bumped — otherwise the store // could still issue the item that's actually unavailable. Recorded so // rejection can add that amount back. await addCol('OLD_LINE_NUM', 'OLD_LINE_NUM INT'); // OLD_LINE_REMOVED — Substitution only: whether the replaced item's own // (now-superseded) line has been physically DELETED from the Production // Order in SAP via DI API (Service Layer can't do this at all — verified // live). The app's own audit trail (this deviation record) is shown // regardless of this flag; it only reflects whether SAP itself still has // the zeroed-out line sitting there or not. await addCol('OLD_LINE_REMOVED', 'OLD_LINE_REMOVED BIT DEFAULT 0'); console.log('[DEVIATION-STORE] ✅ Ready'); } function toTs(d) { return d.toISOString().replace('T', ' ').replace('Z', '').substring(0, 23); } function safeJson(val, fallback) { if (!val) return fallback; try { return JSON.parse(val); } catch { return fallback; } } function fromRow(row) { if (!row) return null; return { id: row.ID, productionOrderId: row.PRODUCTION_ORDER_ID, sapAbsEntry: row.SAP_ABS_ENTRY, sapDocNum: row.SAP_DOC_NUM, type: row.TYPE, itemCode: row.ITEM_CODE || '', itemName: row.ITEM_NAME || '', newItemCode: row.NEW_ITEM_CODE || '', newItemName: row.NEW_ITEM_NAME || '', quantity: row.QUANTITY != null ? Number(row.QUANTITY) : 0, warehouse: row.WAREHOUSE || '', destWarehouse: row.DEST_WAREHOUSE || '', batchNumbers: safeJson(row.BATCH_NUMBER, []), // [{BatchNumber,Quantity}] — can be more than one, same as Issue for Production lineNum: row.LINE_NUM, oldLineNum: row.OLD_LINE_NUM, oldLineRemoved: !!row.OLD_LINE_REMOVED, lineApplied: !!row.LINE_APPLIED, lineApplyError: row.LINE_APPLY_ERROR || '', postIntended: !!row.POST_INTENDED, postedToSap: !!row.POSTED_TO_SAP, sapDocEntry: row.SAP_DOC_ENTRY, sapDocNumPosted: row.SAP_DOC_NUM_POSTED, sapDocObject: row.SAP_DOC_OBJECT || '', reversed: !!row.REVERSED, reversalDocEntry: row.REVERSAL_DOC_ENTRY, reversalDocNum: row.REVERSAL_DOC_NUM || '', reversalDocObject: row.REVERSAL_DOC_OBJECT || '', reversalAt: row.REVERSAL_AT ? new Date(row.REVERSAL_AT).toISOString() : null, reversalError: row.REVERSAL_ERROR || '', reason: row.REASON || '', raisedBy: row.RAISED_BY || '', raisedName: row.RAISED_NAME || '', raisedAt: row.RAISED_AT ? new Date(row.RAISED_AT).toISOString() : null, qaRequired: !!row.QA_REQUIRED, qaStatus: row.QA_STATUS || 'N_A', qaBy: row.QA_BY || '', qaName: row.QA_NAME || '', qaAt: row.QA_AT ? new Date(row.QA_AT).toISOString() : null, qaRemarks: row.QA_REMARKS || '', company: row.COMPANY || '', isDeleted: !!row.IS_DELETED, }; } async function createDeviation(d) { const now = toTs(new Date()); const qaRequired = !!d.qaRequired; const rows = await exec(` INSERT INTO ${TABLE} ( PRODUCTION_ORDER_ID, SAP_ABS_ENTRY, SAP_DOC_NUM, TYPE, ITEM_CODE, ITEM_NAME, NEW_ITEM_CODE, NEW_ITEM_NAME, QUANTITY, WAREHOUSE, DEST_WAREHOUSE, BATCH_NUMBER, LINE_NUM, OLD_LINE_NUM, LINE_APPLIED, POST_INTENDED, POSTED_TO_SAP, SAP_DOC_ENTRY, SAP_DOC_NUM_POSTED, SAP_DOC_OBJECT, REASON, RAISED_BY, RAISED_NAME, RAISED_AT, QA_REQUIRED, QA_STATUS, COMPANY ) OUTPUT INSERTED.* VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `, [ d.productionOrderId || null, d.sapAbsEntry || null, d.sapDocNum || '', d.type, d.itemCode || '', d.itemName || '', d.newItemCode || '', d.newItemName || '', d.quantity || 0, d.warehouse || '', d.destWarehouse || '', JSON.stringify(d.batchNumbers || []), d.lineNum != null && d.lineNum !== '' ? parseInt(d.lineNum) : null, d.oldLineNum != null && d.oldLineNum !== '' ? parseInt(d.oldLineNum) : null, d.lineApplied ? 1 : 0, d.postIntended ? 1 : 0, d.postedToSap ? 1 : 0, d.sapDocEntry || null, d.sapDocNumPosted || '', d.sapDocObject || '', d.reason || '', d.raisedBy || '', d.raisedName || '', now, qaRequired ? 1 : 0, qaRequired ? 'PENDING' : 'N_A', d.company || '', ]); return fromRow(rows[0]); } async function listDeviations({ productionOrderId, sapAbsEntry, company, includeDeleted } = {}) { const rows = await exec(`SELECT * FROM ${TABLE} ORDER BY RAISED_AT DESC`); return rows.map(fromRow).filter(r => { if (!includeDeleted && r.isDeleted) return false; if (productionOrderId != null && String(r.productionOrderId) !== String(productionOrderId)) return false; if (sapAbsEntry != null && String(r.sapAbsEntry) !== String(sapAbsEntry)) return false; if (company && r.company && r.company !== company) return false; return true; }); } async function findById(id) { const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]); return rows.length ? fromRow(rows[0]) : null; } async function approveDeviation(id, { by, byName, remarks, approve }) { const now = toTs(new Date()); await exec(` UPDATE ${TABLE} SET QA_STATUS = ?, QA_BY = ?, QA_NAME = ?, QA_AT = ?, QA_REMARKS = ? WHERE ID = ? `, [approve ? 'APPROVED' : 'REJECTED', by || '', byName || by || '', now, remarks || '', parseInt(id)]); return findById(id); } async function recordReversal(id, { reversed, docEntry, docNum, docObject, error }) { const now = toTs(new Date()); await exec(` UPDATE ${TABLE} SET REVERSED = ?, REVERSAL_DOC_ENTRY = ?, REVERSAL_DOC_NUM = ?, REVERSAL_DOC_OBJECT = ?, REVERSAL_AT = ?, REVERSAL_ERROR = ? WHERE ID = ? `, [reversed ? 1 : 0, docEntry || null, docNum || '', docObject || '', now, error || '', parseInt(id)]); return findById(id); } // Marks a previously-deferred (not posted at raise time) deviation as now // posted — used by the manual "Post to SAP" action (currently Damage/Loss // only; Shortage/Substitution are picked up by Issue for Production instead // and never call this). async function markPosted(id, { docEntry, docNum, docObject }) { await exec(` UPDATE ${TABLE} SET POSTED_TO_SAP = 1, SAP_DOC_ENTRY = ?, SAP_DOC_NUM_POSTED = ?, SAP_DOC_OBJECT = ? WHERE ID = ? `, [docEntry || null, docNum || '', docObject || '', parseInt(id)]); return findById(id); } // Records that the Shortage/Substitution component change has now actually // been applied to the Production Order (called once, right after QA // approves — see PATCH /deviations/:id/approve). `lineNum` is the FINAL // LineNumber it landed on (Substitution may reuse an already-existing line // for the same item rather than always creating a new one). async function markLineApplied(id, { lineNum }) { await exec(`UPDATE ${TABLE} SET LINE_APPLIED = 1, LINE_APPLY_ERROR = '', LINE_NUM = ? WHERE ID = ?`, [lineNum != null ? parseInt(lineNum) : null, parseInt(id)]); return findById(id); } async function markLineApplyError(id, error) { await exec(`UPDATE ${TABLE} SET LINE_APPLY_ERROR = ? WHERE ID = ?`, [error || '', parseInt(id)]); return findById(id); } // Records that the replaced item's own (now-superseded) line has been // physically deleted from the Production Order in SAP via DI API. async function markOldLineRemoved(id) { await exec(`UPDATE ${TABLE} SET OLD_LINE_REMOVED = 1 WHERE ID = ?`, [parseInt(id)]); return findById(id); } module.exports = { bootstrap, TYPES, createDeviation, listDeviations, findById, approveDeviation, recordReversal, markPosted, markLineApplied, markLineApplyError, markOldLineRemoved };