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

322 lines
16 KiB
JavaScript

// 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]`;
const TYPES = ['SHORTAGE', 'SUBSTITUTION', 'DAMAGE'];
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 };