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

405 lines
20 KiB
JavaScript

// services/workOrderStore.js
// Production Work Orders in SQL: ZWORK_ORDERS. Generated from a Batch Intimation.
// WO No format: WO-{MM}-{YY}-{NNNN}. 5-step approval workflow.
const sql = require('mssql');
const TABLE = `[dbo].[ZWORK_ORDERS]`;
// Ordered workflow steps (STAGE = number of completed steps)
const STEPS = [
'Prepared By QA',
'Checked By QC',
'Checked By Store In-Charge',
'Checked By (Manager Production)',
'Approved By (Manager QA)',
];
// Optional toggle: skip "Checked By QC" entirely (new work orders start
// already past it; existing ones stuck waiting on it get auto-advanced too
// — see autoAdvanceStuckQC()). Now an admin-editable setting (services/
// appSettingsStore.js) rather than an .env var — read live via SKIP_QC() so
// a change in the Admin panel takes effect without a restart.
const appSettings = require('./appSettingsStore');
const SKIP_QC = () => appSettings.workOrderSkipQc();
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('[WO-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
WO_NO NVARCHAR(30) NOT NULL,
INTIMATION_ID INT,
REFERENCE NVARCHAR(60),
PRODUCT_NAME NVARCHAR(200),
PRODUCT_DESC NVARCHAR(400),
GENERIC_NAME NVARCHAR(200),
PRODUCT_CODE NVARCHAR(60),
BATCH_NUMBER NVARCHAR(60),
BATCH_SIZE NVARCHAR(60),
TOTAL_UNITS NVARCHAR(60),
MFG_DATE NVARCHAR(30),
EXP_DATE NVARCHAR(30),
PACK_SIZE NVARCHAR(60),
TYPE NVARCHAR(60),
MARKET NVARCHAR(120),
RAW_MATERIALS NVARCHAR(MAX),
PACKING_MATERIALS NVARCHAR(MAX),
STAGE INT DEFAULT 1,
STATUS NVARCHAR(20) DEFAULT 'IN_PROGRESS',
WORKFLOW_LOG NVARCHAR(MAX),
REMARKS NVARCHAR(MAX),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_AT DATETIME2,
IS_DELETED BIT DEFAULT 0,
COMPANY NVARCHAR(60)
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[WO-STORE] Table exists — OK'); }
else throw e;
});
// Migration: MFG/EXP dates may be MMM/YYYY — switch DATE columns to text
for (const col of ['MFG_DATE', 'EXP_DATE']) {
await exec(`
IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME='ZWORK_ORDERS' AND COLUMN_NAME='${col}' AND DATA_TYPE='date')
ALTER TABLE ${TABLE} ALTER COLUMN ${col} NVARCHAR(30)
`).catch(e => console.log(`[WO-STORE] ${col} migration:`, e.message));
}
// COMPONENTS_ONLY: true when this WO was saved in the simplified single
// "Components" table mode (no real raw material found via BOM). In that
// case RAW_MATERIALS is a SAVE-TIME-ONLY duplicate of the Components rows
// (for downstream consumers that specifically look at raw materials, e.g.
// Production Order issuance) — an explicit flag, not re-derived from
// array contents, is what tells the UI to keep showing Components mode
// when this record is reopened for editing (otherwise the non-empty
// duplicated RAW_MATERIALS would look like "real" raw material and flip
// the page back to the two-section layout).
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZWORK_ORDERS' AND COLUMN_NAME='COMPONENTS_ONLY') ALTER TABLE ${TABLE} ADD [COMPONENTS_ONLY] BIT DEFAULT 0`).catch(() => {});
// Widen columns that hit "String or binary data would be truncated" in
// practice — the header's "Product Name & Description" is ONE combined
// field (auto-filled from the SAP item's own name, which can easily run
// past 200 chars for pharma-style compound descriptions) mapped straight
// to PRODUCT_NAME; BATCH_SIZE became free text in Components-only mode and
// can hold a multi-line multi-solution summary. SQL Server's truncation
// error doesn't say which column, so widened the realistic candidates
// together rather than chasing them one at a time.
const WIDEN = { PRODUCT_NAME: 500, GENERIC_NAME: 300, REFERENCE: 200, BATCH_SIZE: 200 };
for (const [col, size] of Object.entries(WIDEN)) {
await exec(`
IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME='ZWORK_ORDERS' AND COLUMN_NAME='${col}' AND CHARACTER_MAXIMUM_LENGTH < ${size})
ALTER TABLE ${TABLE} ALTER COLUMN [${col}] NVARCHAR(${size})
`).catch(e => console.log(`[WO-STORE] ${col} widen migration:`, e.message));
}
await autoAdvanceStuckQC();
console.log('[WO-STORE] ✅ Ready');
}
function toTs(iso) { return iso ? iso.replace('T', ' ').replace('Z', '').substring(0, 23) : null; }
function safeJson(v, f) { if (!v) return f; try { return JSON.parse(v); } catch { return f; } }
// While WORK_ORDER_SKIP_QC is on, also un-stick any work order that's
// CURRENTLY sitting at stage 1 waiting for a QC check that will never come
// (a one-time cleanup, not just new orders going forward). Safe to re-run
// on every boot — a WO only ever matches STAGE=1 once, since this always
// advances it to stage 2. If the flag is later turned back off, whatever
// got skipped stays skipped (their history is immutable); new work orders
// simply go back to requiring the QC step normally.
async function autoAdvanceStuckQC() {
if (!SKIP_QC()) return;
const rows = await exec(`SELECT ID, WORKFLOW_LOG FROM ${TABLE} WHERE STAGE = 1 AND STATUS = 'IN_PROGRESS'`);
if (!rows.length) return;
console.log(`[WO-STORE] QC step disabled — auto-advancing ${rows.length} stuck work order(s) past "Checked By QC"…`);
const now = toTs(new Date().toISOString());
for (const r of rows) {
const log = safeJson(r.WORKFLOW_LOG, []);
log.push({ step: STEPS[1], action: 'auto-skipped', by: 'system', byName: 'System (QC step disabled)', at: new Date().toISOString(), remarks: 'Checked By QC temporarily disabled' });
await exec(`UPDATE ${TABLE} SET STAGE = 2, WORKFLOW_LOG = ?, UPDATED_AT = ? WHERE ID = ?`, [JSON.stringify(log), now, r.ID]);
}
console.log('[WO-STORE] ✅ Stuck work orders advanced past QC');
}
function fromRow(row) {
if (!row) return null;
const stage = row.STAGE || 1;
const status = row.STATUS || 'IN_PROGRESS';
return {
id: row.ID,
woNo: row.WO_NO || '',
intimationId: row.INTIMATION_ID || null,
reference: row.REFERENCE || '',
productName: row.PRODUCT_NAME || '',
productDesc: row.PRODUCT_DESC || '',
genericName: row.GENERIC_NAME || '',
productCode: row.PRODUCT_CODE || '',
batchNumber: row.BATCH_NUMBER || '',
batchSize: row.BATCH_SIZE || '',
totalUnits: row.TOTAL_UNITS || '',
mfgDate: row.MFG_DATE || '', // text — may be DD-MMM-YYYY or MMM/YYYY
expDate: row.EXP_DATE || '',
packSize: row.PACK_SIZE || '',
type: row.TYPE || '',
market: row.MARKET || '',
rawMaterials: safeJson(row.RAW_MATERIALS, []),
packingMaterials: safeJson(row.PACKING_MATERIALS, []),
componentsOnly: !!row.COMPONENTS_ONLY,
stage,
status,
steps: STEPS,
currentStep: status === 'APPROVED' ? 'Approved' : status === 'REJECTED' ? 'Rejected' : STEPS[stage] || 'Completed',
workflowLog: safeJson(row.WORKFLOW_LOG, []),
remarks: row.REMARKS || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
isDeleted: !!row.IS_DELETED,
company: row.COMPANY || '',
};
}
async function generateWoNo(company) {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const yy = String(now.getFullYear()).slice(-2);
const prefix = `WO-${mm}-${yy}-`;
const rows = await exec(
`SELECT WO_NO FROM ${TABLE} WHERE WO_NO LIKE ? ${company ? 'AND COMPANY = ?' : ''}`,
company ? [prefix + '%', company] : [prefix + '%']
);
let max = 0;
rows.forEach(r => { const n = parseInt((r.WO_NO || '').slice(prefix.length), 10); if (!isNaN(n) && n > max) max = n; });
return prefix + String(max + 1).padStart(4, '0');
}
async function insertWorkOrder(w) {
const now = toTs(new Date().toISOString());
const woNo = await generateWoNo(w.company);
const log = [{ step: STEPS[0], action: 'prepared', by: w.createdBy, byName: w.createdByName || w.createdBy, at: new Date().toISOString(), remarks: '' }];
let stage = 1;
if (SKIP_QC()) {
log.push({ step: STEPS[1], action: 'auto-skipped', by: 'system', byName: 'System (QC step disabled)', at: new Date().toISOString(), remarks: 'Checked By QC temporarily disabled' });
stage = 2;
}
// INSERT and SELECT SCOPE_IDENTITY() MUST be one batch/one request — as two
// separate exec() calls, the pooled connection can hand the second call a
// DIFFERENT physical connection than the one that just did the INSERT,
// where SCOPE_IDENTITY() correctly returns NULL (it's scoped to the
// session that ran the insert). That silently made insertWorkOrder()
// return null while the row was actually saved fine — the API then sent
// back {success:true, data:null}, so the frontend's `if(_created)` check
// never showed the success screen, and the user (seeing nothing happen)
// kept resubmitting the same form, creating duplicate work orders.
const idRows = await exec(`
INSERT INTO ${TABLE} (
WO_NO, INTIMATION_ID, REFERENCE, PRODUCT_NAME, PRODUCT_DESC, GENERIC_NAME, PRODUCT_CODE,
BATCH_NUMBER, BATCH_SIZE, TOTAL_UNITS, MFG_DATE, EXP_DATE, PACK_SIZE, TYPE, MARKET,
RAW_MATERIALS, PACKING_MATERIALS, COMPONENTS_ONLY, STAGE, STATUS, WORKFLOW_LOG, REMARKS,
CREATED_BY, CREATED_NAME, CREATED_AT, COMPANY
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
woNo, w.intimationId ? parseInt(w.intimationId) : null, w.reference || '',
w.productName || '', w.productDesc || '', w.genericName || '', w.productCode || '',
w.batchNumber || '', w.batchSize || '', w.totalUnits || '',
w.mfgDate || null, w.expDate || null, w.packSize || '', w.type || '', w.market || '',
JSON.stringify(w.rawMaterials || []), JSON.stringify(w.packingMaterials || []), w.componentsOnly ? 1 : 0,
stage, 'IN_PROGRESS', JSON.stringify(log), w.remarks || '',
w.createdBy, w.createdByName || w.createdBy, now, w.company || '',
]);
return findById(idRows[0].ID);
}
// Edit + resubmit a REJECTED work order in one save — restarts the full
// 5-step approval chain from the top (STAGE reset to 0/1/2 exactly like a
// brand-new insertWorkOrder(), including the SKIP_QC auto-skip), since the
// edited content was never seen by any of the original approvers. Keeps the
// rejection's history in WORKFLOW_LOG (appends, doesn't clear) for audit.
async function resubmitAfterReject(id, w, { by, byName }) {
const wo = await findById(id);
if (!wo) throw new Error('Work order not found');
if (wo.status !== 'REJECTED') throw new Error('Work order is not rejected — cannot resubmit');
const now = toTs(new Date().toISOString());
const log = wo.workflowLog || [];
let stage = 1;
log.push({ step: STEPS[0], action: 'resubmitted', by, byName: byName || by, at: new Date().toISOString(), remarks: 'Edited and resubmitted after rejection' });
if (SKIP_QC()) {
log.push({ step: STEPS[1], action: 'auto-skipped', by: 'system', byName: 'System (QC step disabled)', at: new Date().toISOString(), remarks: 'Checked By QC temporarily disabled' });
stage = 2;
}
await exec(`
UPDATE ${TABLE} SET
PRODUCT_NAME=?, PRODUCT_DESC=?, GENERIC_NAME=?, PRODUCT_CODE=?, BATCH_NUMBER=?,
BATCH_SIZE=?, TOTAL_UNITS=?, MFG_DATE=?, EXP_DATE=?, PACK_SIZE=?, TYPE=?, MARKET=?,
REFERENCE=?, RAW_MATERIALS=?, PACKING_MATERIALS=?, COMPONENTS_ONLY=?, REMARKS=?,
STAGE=?, STATUS='IN_PROGRESS', WORKFLOW_LOG=?, UPDATED_AT=?
WHERE ID=? AND (IS_DELETED=0 OR IS_DELETED IS NULL)
`, [
w.productName || '', w.productDesc || '', w.genericName || '', w.productCode || '', w.batchNumber || '',
w.batchSize || '', w.totalUnits || '', w.mfgDate || null, w.expDate || null, w.packSize || '', w.type || '', w.market || '',
w.reference || '', JSON.stringify(w.rawMaterials || []), JSON.stringify(w.packingMaterials || []), w.componentsOnly ? 1 : 0, w.remarks || '',
stage, JSON.stringify(log), now,
parseInt(id),
]);
return findById(id);
}
// Admin-only recall of a FULLY APPROVED Work Order back to QA for editing —
// re-uses the exact same mechanics as a normal rejection (STATUS='REJECTED')
// so it picks up the already-built "Edit & Resubmit" flow for free
// (resubmitAfterReject() restarts the full chain from step 1 once someone
// edits it). No approval step is required for this action — it's an admin
// override for a document that already finished its whole chain, not a
// normal in-flight rejection by whoever holds the current step.
async function sendBackToQaForEdit(id, { by, byName, remarks }) {
const wo = await findById(id);
if (!wo) throw new Error('Work order not found');
if (wo.status !== 'APPROVED') throw new Error('Only a fully approved Work Order can be sent back to QA');
const now = toTs(new Date().toISOString());
const log = wo.workflowLog || [];
log.push({ step: STEPS[STEPS.length - 1], action: 'rejected', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || 'Sent back to QA for edit by admin' });
await exec(`UPDATE ${TABLE} SET STATUS='REJECTED', WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[JSON.stringify(log), now, parseInt(id)]);
return findById(id);
}
// `reference` is a free-text field on the Work Order header (editable in
// Create/Edit — see work-order.html's hf('Reference','reference')), NOT a
// reliable mirror of the Intimation it was generated from: a user can (and
// in practice does) overwrite it with their own note/PO reference after
// generation, so it can legitimately read something like "MIPL-QS-029"
// while intimationId still correctly points at BII-09-26-0070. The ONLY
// authoritative source for "which Intimation was this generated from" is
// the intimationId foreign key — this attaches that Intimation's real
// DOC_NO as `intimationDocNo` so the UI can show it without confusing it
// with the separate, freely-editable `reference` field.
async function attachIntimationDocNos(list) {
const ids = [...new Set(list.map(r => r.intimationId).filter(Boolean))];
if (!ids.length) return list;
const placeholders = ids.map(() => '?').join(',');
const rows = await exec(`SELECT ID, DOC_NO FROM [dbo].[ZBATCH_INTIMATIONS] WHERE ID IN (${placeholders})`, ids);
const byId = {};
rows.forEach(r => { byId[r.ID] = r.DOC_NO || ''; });
list.forEach(r => { r.intimationDocNo = r.intimationId ? (byId[r.intimationId] || '') : ''; });
return list;
}
async function listWorkOrders({ mine, company, status, includeDeleted } = {}) {
const all = await exec(`SELECT * FROM ${TABLE} ORDER BY CREATED_AT DESC`);
const filtered = all.map(fromRow).filter(r => {
if (!includeDeleted && r.isDeleted) return false;
if (status && status !== 'ALL' && r.status !== status.toUpperCase()) return false;
if (mine && r.createdBy !== mine) return false;
if (company && r.company && r.company !== company) return false;
return true;
});
return attachIntimationDocNos(filtered);
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
if (!rows.length) return null;
const [wo] = await attachIntimationDocNos([fromRow(rows[0])]);
return wo;
}
async function updateWorkOrder(id, w) {
const now = toTs(new Date().toISOString());
await exec(`
UPDATE ${TABLE} SET
PRODUCT_NAME=?, PRODUCT_DESC=?, GENERIC_NAME=?, PRODUCT_CODE=?, BATCH_NUMBER=?,
BATCH_SIZE=?, TOTAL_UNITS=?, MFG_DATE=?, EXP_DATE=?, PACK_SIZE=?, TYPE=?, MARKET=?,
REFERENCE=?, RAW_MATERIALS=?, PACKING_MATERIALS=?, COMPONENTS_ONLY=?, REMARKS=?, UPDATED_AT=?
WHERE ID=? AND (IS_DELETED=0 OR IS_DELETED IS NULL)
`, [
w.productName || '', w.productDesc || '', w.genericName || '', w.productCode || '', w.batchNumber || '',
w.batchSize || '', w.totalUnits || '', w.mfgDate || null, w.expDate || null, w.packSize || '', w.type || '', w.market || '',
w.reference || '', JSON.stringify(w.rawMaterials || []), JSON.stringify(w.packingMaterials || []), w.componentsOnly ? 1 : 0, w.remarks || '', now,
parseInt(id),
]);
return findById(id);
}
// Advance or reject the workflow
async function workflowAction(id, { action, by, byName, remarks }) {
const wo = await findById(id);
if (!wo) throw new Error('Work order not found');
if (wo.status !== 'IN_PROGRESS') throw new Error('Work order is already ' + wo.status.toLowerCase());
const log = wo.workflowLog || [];
const pendingStep = STEPS[wo.stage] || 'Approved By (Manager QA)';
const now = toTs(new Date().toISOString());
if (action === 'reject') {
log.push({ step: pendingStep, action: 'rejected', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || '' });
await exec(`UPDATE ${TABLE} SET STATUS='REJECTED', WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[JSON.stringify(log), now, parseInt(id)]);
return findById(id);
}
// approve → complete current pending step
log.push({ step: pendingStep, action: 'approved', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || '' });
const newStage = wo.stage + 1;
const newStatus = newStage >= STEPS.length ? 'APPROVED' : 'IN_PROGRESS';
await exec(`UPDATE ${TABLE} SET STAGE=?, STATUS=?, WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[newStage, newStatus, JSON.stringify(log), now, parseInt(id)]);
return findById(id);
}
async function softDelete(id) {
await exec(`UPDATE ${TABLE} SET IS_DELETED=1, UPDATED_AT=? WHERE ID=?`,
[toTs(new Date().toISOString()), parseInt(id)]);
}
// Merge `patch` into ONE material row (raw or packing) — used for per-row
// Weighing Balance ID / AR No. edits and the Issued/Received/Verified
// one-click stamps (see routes/workOrders.js). Independent of the work
// order's own approval-workflow status/edit-lock — these actions happen
// AFTER the work order is fully approved, gated by their own approval steps
// (work_order:issue/receive/verify), not the 5-step QA chain.
async function patchMaterialRow(id, section, index, patch) {
const wo = await findById(id);
if (!wo) throw new Error('Work order not found');
const arr = (section === 'raw' ? wo.rawMaterials : wo.packingMaterials) || [];
const i = parseInt(index);
if (!(i >= 0) || i >= arr.length) throw new Error('Row not found');
arr[i] = { ...arr[i], ...patch };
if (section === 'raw') wo.rawMaterials = arr; else wo.packingMaterials = arr;
return updateWorkOrder(id, wo);
}
module.exports = {
bootstrap, STEPS, generateWoNo, insertWorkOrder,
listWorkOrders, findById, updateWorkOrder, workflowAction, softDelete, patchMaterialRow,
resubmitAfterReject, sendBackToQaForEdit,
};