// 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), ]); // Keep an already-generated Production Order's batch snapshot in sync — // see syncBatchFromWorkOrder()'s comment for why this can still be needed // even after a Production Order exists. await require('./productionOrderStore').syncBatchFromWorkOrder(id, { batchNumber: w.batchNumber || '', mfgDate: w.mfgDate || '', expDate: w.expDate || '', }).catch(e => console.error('[WO-STORE] syncBatchFromWorkOrder failed:', e.message)); 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, };