322 lines
16 KiB
JavaScript
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 };
|