// services/productionOrderStore.js // Local tracking for SAP B1 Production Orders (OWOR) generated FROM a Work // Order. SAP itself only knows Planned/Released/Closed — this table adds the // finer 5-step approval chain (Release → Issuance → Receipt → Transfer to // Finished Goods → Close) and remembers which Work Order originated it. // Actual SAP transactions (release/issue/receipt/close/transfer) are still // performed via routes/sap.js's existing OWOR-calling endpoints; this store // is the local record of WHO did WHICH step and WHEN, plus stage sequencing. const sql = require('mssql'); const notify = () => require('./notifyStore'); const TABLE = `[dbo].[ZPRODUCTION_ORDERS]`; // Ordered workflow steps AFTER creation (STAGE = number of completed steps). // Index-aligned with STEP_KEYS and services/approvalStepsStore.js's // workflow:'production_order' keys (minus 'create', which is a separate gate). const STEPS = [ 'Release', 'Issuance', 'Receipt from Production', 'Transfer to Finished Goods', 'Close', ]; const STEP_KEYS = ['release', 'issuance', 'receipt', 'transfer_fg', 'close']; 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('[PO-STORE] Checking table', TABLE, '...'); await exec(` CREATE TABLE ${TABLE} ( ID INT IDENTITY(1,1) PRIMARY KEY, WORK_ORDER_ID INT NULL, -- NULL = manually-created PO (no source Work Order) — see insertProductionOrder() SAP_ABS_ENTRY INT, SAP_DOC_NUM NVARCHAR(30), ITEM_CODE NVARCHAR(60), ITEM_NAME NVARCHAR(200), PLANNED_QTY NVARCHAR(60), BATCH_NUMBER NVARCHAR(60), WAREHOUSE NVARCHAR(20), FG_WAREHOUSE NVARCHAR(20), STAGE INT DEFAULT 0, 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('[PO-STORE] Table exists — OK'); } else throw e; }); // Migration: MFG/EXP Date, carried from the source Work Order's Batch // Issuance Intimation data (same free-text format — DD-MMM-YYYY or // MMM/YYYY — as ZWORK_ORDERS.MFG_DATE/EXP_DATE) so Receipt from // Production can default to them instead of asking the user to retype. for (const col of ['MFG_DATE', 'EXP_DATE']) { await exec(` IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='${col}') ALTER TABLE ${TABLE} ADD [${col}] NVARCHAR(30) `).catch(e => console.log(`[PO-STORE] ${col} migration:`, e.message)); } // Portal-only post-Issuance verification sign-off (not a SAP transaction). for (const [col, type] of [['VERIFIED_BY', 'NVARCHAR(50)'], ['VERIFIED_BY_NAME', 'NVARCHAR(100)'], ['VERIFIED_AT', 'DATETIME2'], ['VERIFIED_REMARKS', 'NVARCHAR(400)']]) { await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='${col}') ALTER TABLE ${TABLE} ADD [${col}] ${type}`).catch(() => {}); } // Portal-only "Received By" manual sign-off — independent of whoever // performed the actual SAP "Receipt from Production" step; when set, this // always overrides that step's signer on the printed Work Order. for (const [col, type] of [['RECEIVED_MANUAL_BY', 'NVARCHAR(50)'], ['RECEIVED_MANUAL_BY_NAME', 'NVARCHAR(100)'], ['RECEIVED_MANUAL_AT', 'DATETIME2'], ['RECEIVED_MANUAL_REMARKS', 'NVARCHAR(400)']]) { await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='${col}') ALTER TABLE ${TABLE} ADD [${col}] ${type}`).catch(() => {}); } // Migration: an existing table from before "manually-created" (no source // Work Order) Production Orders were supported still has the original // WORK_ORDER_ID NOT NULL constraint — insertProductionOrder() has allowed // passing null here for a while now (see its own comment), so every such // insert fails until this is relaxed. Safe/non-destructive: no data is // touched, only the column's nullability. await exec(`ALTER TABLE ${TABLE} ALTER COLUMN [WORK_ORDER_ID] INT NULL`).catch(e => console.log('[PO-STORE] WORK_ORDER_ID nullable migration:', e.message)); // "+ PWO" shortcut (a component of ANOTHER Production Order that's itself // an SFG, raised as its own standalone order): purely informational // reference back to that other order's WO No. / Batch No., for traceability // only — NOT a real workOrderId link (this order wasn't generated from that // Work Order, so it must never participate in the one-PWO-per-WO gate). for (const col of ['REF_WO_NO', 'REF_BATCH_NUMBER']) { await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='${col}') ALTER TABLE ${TABLE} ADD [${col}] NVARCHAR(60)`).catch(() => {}); } // The parent order's own SAP AbsoluteEntry — the UNAMBIGUOUS link (WO // No./Batch No. above can both be blank) used to detect "a PWO for this // exact SFG component, off THIS exact parent order, already exists" and // block/hide raising a duplicate one for the same requirement. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='REF_PARENT_ENTRY') ALTER TABLE ${TABLE} ADD [REF_PARENT_ENTRY] INT NULL`).catch(() => {}); // Consumable Order ("+ Consumable Order" — Manual Entry restricted to // Service items, Item Group 132): whoever CREATED one may also Release/ // Issue/Receipt THAT SAME order even without the general approval-step // permissions for those stages — but only for orders flagged here, never // for a normal PWO (see routes/sap.js's isOwnConsumableOrder()). await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='IS_CONSUMABLE') ALTER TABLE ${TABLE} ADD [IS_CONSUMABLE] BIT DEFAULT 0`).catch(() => {}); // Consumable Orders are raised by many departments — the creator picks // theirs (from the same OUDP/"PR Departments" list + per-user restriction // already used for Purchase Requisition) at creation. Purely an // organizational tag for VIEW scoping (see routes/sap.js's // consumableDeptFilterFor()) — Release/Issue/Close are deliberately // department-independent, so this column plays no role in those gates. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='DEPARTMENT') ALTER TABLE ${TABLE} ADD [DEPARTMENT] NVARCHAR(100)`).catch(() => {}); console.log('[PO-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; } } function fromRow(row) { if (!row) return null; const stage = row.STAGE || 0; const status = row.STATUS || 'IN_PROGRESS'; return { id: row.ID, workOrderId: row.WORK_ORDER_ID, sapAbsEntry: row.SAP_ABS_ENTRY || null, sapDocNum: row.SAP_DOC_NUM || '', itemCode: row.ITEM_CODE || '', itemName: row.ITEM_NAME || '', plannedQty: row.PLANNED_QTY || '', batchNumber: row.BATCH_NUMBER || '', refWoNo: row.REF_WO_NO || '', refBatchNumber:row.REF_BATCH_NUMBER || '', refParentEntry:row.REF_PARENT_ENTRY || null, isConsumable: !!row.IS_CONSUMABLE, department: row.DEPARTMENT || '', mfgDate: row.MFG_DATE || '', expDate: row.EXP_DATE || '', warehouse: row.WAREHOUSE || '', fgWarehouse: row.FG_WAREHOUSE || '', stage, status, steps: STEPS, currentStep: status === 'CLOSED' ? 'Closed' : status === 'CANCELLED' ? 'Cancelled' : status === 'REJECTED' ? 'Rejected' : (STEPS[stage] || 'Completed'), workflowLog: safeJson(row.WORKFLOW_LOG, []), verifiedBy: row.VERIFIED_BY || '', verifiedByName: row.VERIFIED_BY_NAME || '', verifiedAt: row.VERIFIED_AT ? new Date(row.VERIFIED_AT).toISOString() : null, verifiedRemarks:row.VERIFIED_REMARKS || '', receivedManualBy: row.RECEIVED_MANUAL_BY || '', receivedManualByName: row.RECEIVED_MANUAL_BY_NAME || '', receivedManualAt: row.RECEIVED_MANUAL_AT ? new Date(row.RECEIVED_MANUAL_AT).toISOString() : null, receivedManualRemarks: row.RECEIVED_MANUAL_REMARKS || '', 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 insertProductionOrder(p) { const now = toTs(new Date().toISOString()); const log = [{ step: 'Created', action: 'created', by: p.createdBy, byName: p.createdByName || p.createdBy, at: new Date().toISOString(), remarks: '' }]; // INSERT and SELECT SCOPE_IDENTITY() must be one batch — as two separate // exec() calls, a pooled connection can route the second one to a // DIFFERENT physical connection than the one that just inserted, where // SCOPE_IDENTITY() correctly returns NULL (see the identical fix + full // explanation in services/workOrderStore.js's insertWorkOrder()). const idRows = await exec(` INSERT INTO ${TABLE} ( WORK_ORDER_ID, SAP_ABS_ENTRY, SAP_DOC_NUM, ITEM_CODE, ITEM_NAME, PLANNED_QTY, BATCH_NUMBER, REF_WO_NO, REF_BATCH_NUMBER, REF_PARENT_ENTRY, IS_CONSUMABLE, DEPARTMENT, MFG_DATE, EXP_DATE, WAREHOUSE, FG_WAREHOUSE, STAGE, STATUS, WORKFLOW_LOG, REMARKS, CREATED_BY, CREATED_NAME, CREATED_AT, COMPANY ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); SELECT SCOPE_IDENTITY() AS ID; `, [ p.workOrderId != null ? parseInt(p.workOrderId) : null, // null = manually-created PO (no source Work Order) — still stage-tracked p.sapAbsEntry != null ? parseInt(p.sapAbsEntry) : null, p.sapDocNum || '', p.itemCode || '', p.itemName || '', p.plannedQty || '', p.batchNumber || '', p.refWoNo || '', p.refBatchNumber || '', p.refParentEntry != null ? parseInt(p.refParentEntry) : null, p.isConsumable ? 1 : 0, p.department || '', p.mfgDate || '', p.expDate || '', p.warehouse || '', p.fgWarehouse || '', 0, 'IN_PROGRESS', JSON.stringify(log), p.remarks || '', p.createdBy, p.createdByName || p.createdBy, now, p.company || '', ]); const created = await findById(idRows[0].ID); notify().notify({ stepFullKey: notifyStepFor(created, STEP_KEYS[0]), title: `Production Order ${created.sapDocNum || '#' + created.id} — awaiting ${STEPS[0]}`, lines: [['Production Order', created.sapDocNum || '#' + created.id], ['Item', created.itemName || created.itemCode], ['Created By', created.createdByName], ['Pending Step', STEPS[0]]], url: `${process.env.APP_BASE_URL || ''}/production-order?id=${created.id}`, excludeUsernames: [p.createdBy], }); return created; } // Consumable Order ("+ Consumable Order") stages are each gated by their OWN // dedicated step — not the general per-stage steps, and not one shared // step either: Release/Issue/Close each have their own independent // approval step, so a user can hold any subset of them. There is no // consumable Receipt/Transfer-to-FG step at all (that workflow never // reaches those stages — confirmed Release → Issue → Close only), so // notifyStepFor() returns null for those and no "awaiting Receipt" email // (which nobody could act on anyway) is ever sent for a Consumable Order. const CONSUMABLE_STEP_FOR = { release: 'production_order:consumable_release', issuance: 'production_order:consumable_issue', close: 'production_order:consumable_close', }; const CONSUMABLE_STEPS = ['production_order:consumable_create', 'production_order:consumable_release', 'production_order:consumable_issue', 'production_order:consumable_close']; // Which approval step should be emailed for this order's next pending stage? // Normal orders keep emailing whoever holds the general step for that stage. function notifyStepFor(po, generalStepKey) { if (po.isConsumable) return CONSUMABLE_STEP_FOR[generalStepKey] || null; return generalStepKey ? `production_order:${generalStepKey}` : null; } // For a terminal/informational event (Closed, Rejected, Cancelled, caught // up) a Consumable Order's "concerned users" = its creator PLUS anyone // holding ANY permission on ANY of the four Consumable Order steps — not // just whoever happened to create this one order, and not gated to one // specific action (this is informational, not an action gate). Collected as // a single deduplicated username list (not one notify() call per step) so a // user holding more than one of the four steps gets exactly one email, not // several duplicates. Normal orders keep the existing creator-only // notifyUsernames() behavior unchanged. async function notifyForOrder(po, opts) { await notify().notifyUsernames([po.createdBy], opts); if (po.isConsumable) { const lists = await Promise.all(CONSUMABLE_STEPS.map(s => notify().usersForStep(s))); const usernames = [...new Set(lists.flat().map(u => u.username))].filter(u => u !== po.createdBy); if (usernames.length) await notify().notifyUsernames(usernames, opts); } } async function listProductionOrders({ mine, company, workOrderId, status, includeDeleted } = {}) { const all = await exec(`SELECT * FROM ${TABLE} ORDER BY CREATED_AT DESC`); return 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; if (workOrderId && String(r.workOrderId) !== String(workOrderId)) 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 findBySapAbsEntry(absEntry) { const rows = await exec(`SELECT * FROM ${TABLE} WHERE SAP_ABS_ENTRY = ? AND (IS_DELETED=0 OR IS_DELETED IS NULL)`, [parseInt(absEntry)]); return rows.length ? fromRow(rows[0]) : null; } // Advance (complete) or reject the CURRENT pending step. `stepKey` must match // STEP_KEYS[stage] — the caller (routes) is responsible for checking the // actual SAP transaction succeeded BEFORE calling this, so the local stage // never claims a step happened when the real SAP call failed. async function advanceStage(id, { action, stepKey, by, byName, remarks }) { const po = await findById(id); if (!po) throw new Error('Production order not found'); // A REJECTED order (its receipt was posted with rejection lines) is an // OUTCOME, not a dead end — it must still be closeable in SAP and locally. if (po.status === 'REJECTED' && stepKey === 'close' && action !== 'reject') { const rlog = po.workflowLog || []; rlog.push({ step: 'Close', action: 'completed', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || 'Closed after rejected receipt' }); await exec(`UPDATE ${TABLE} SET STAGE=?, STATUS='CLOSED', WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`, [STEPS.length, JSON.stringify(rlog), toTs(new Date().toISOString()), parseInt(id)]); const closedAfterReject = await findById(id); await notifyForOrder(closedAfterReject, { title: `Production Order ${closedAfterReject.sapDocNum || '#' + closedAfterReject.id} — Closed (after rejected receipt)`, lines: [['Production Order', closedAfterReject.sapDocNum || '#' + closedAfterReject.id], ['Item', closedAfterReject.itemName || closedAfterReject.itemCode], ['Closed By', byName || by]], url: `${process.env.APP_BASE_URL || ''}/production-order?id=${closedAfterReject.id}`, }); return closedAfterReject; } if (po.status !== 'IN_PROGRESS') throw new Error('Production order is already ' + po.status.toLowerCase()); const expectedKey = STEP_KEYS[po.stage]; if (stepKey !== expectedKey) throw new Error(`Out of sequence: next required step is "${STEPS[po.stage]}" (${expectedKey}), not "${stepKey}"`); const log = po.workflowLog || []; const now = toTs(new Date().toISOString()); if (action === 'reject') { log.push({ step: STEPS[po.stage], 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)]); const rejected = await findById(id); await notifyForOrder(rejected, { title: `Production Order ${rejected.sapDocNum || '#' + rejected.id} — Rejected at ${STEPS[po.stage]}`, lines: [['Production Order', rejected.sapDocNum || '#' + rejected.id], ['Item', rejected.itemName || rejected.itemCode], ['Rejected By', byName || by], ['Remarks', remarks || '']], url: `${process.env.APP_BASE_URL || ''}/production-order?id=${rejected.id}`, }); return rejected; } log.push({ step: STEPS[po.stage], action: 'completed', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || '' }); const newStage = po.stage + 1; const newStatus = newStage >= STEPS.length ? 'CLOSED' : 'IN_PROGRESS'; await exec(`UPDATE ${TABLE} SET STAGE=?, STATUS=?, WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`, [newStage, newStatus, JSON.stringify(log), now, parseInt(id)]); const advanced = await findById(id); const url = `${process.env.APP_BASE_URL || ''}/production-order?id=${advanced.id}`; if (newStatus === 'CLOSED') { await notifyForOrder(advanced, { title: `Production Order ${advanced.sapDocNum || '#' + advanced.id} — Closed`, lines: [['Production Order', advanced.sapDocNum || '#' + advanced.id], ['Item', advanced.itemName || advanced.itemCode], ['Closed By', byName || by]], url, }); } else { const nextStepKey = STEP_KEYS[newStage]; notify().notify({ stepFullKey: notifyStepFor(advanced, nextStepKey), title: `Production Order ${advanced.sapDocNum || '#' + advanced.id} — awaiting ${STEPS[newStage]}`, lines: [['Production Order', advanced.sapDocNum || '#' + advanced.id], ['Item', advanced.itemName || advanced.itemCode], ['Completed By', byName || by], ['Pending Step', STEPS[newStage]]], url, excludeUsernames: [by], }); } return advanced; } // Admin/System Admin override — force this local record to CLOSED at // whatever stage it's currently sitting at, bypassing the normal sequential // advanceStage() rules entirely (it demands the exact next step in order). // The caller (routes/sap.js) is responsible for confirming the real SAP // transaction succeeded first — this only updates the local tracking state // to match. async function adminForceClose(id, { by, byName, remarks }) { const po = await findById(id); if (!po) throw new Error('Production order not found'); const log = po.workflowLog || []; log.push({ step: 'Close', action: 'admin_force_closed', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || '' }); await exec(`UPDATE ${TABLE} SET STAGE=?, STATUS='CLOSED', WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`, [STEPS.length, JSON.stringify(log), toTs(new Date().toISOString()), parseInt(id)]); const closed = await findById(id); await notifyForOrder(closed, { title: `Production Order ${closed.sapDocNum || '#' + closed.id} — Closed (Override)`, lines: [['Production Order', closed.sapDocNum || '#' + closed.id], ['Item', closed.itemName || closed.itemCode], ['Closed By', byName || by], ['Remarks', remarks || '']], url: `${process.env.APP_BASE_URL || ''}/production-order?id=${closed.id}`, }); return closed; } // Admin/System Admin override — mark this local record CANCELLED (a new // terminal state, distinct from CLOSED/REJECTED) at whatever stage it's // currently sitting at. Same non-sequential bypass as adminForceClose above. async function adminForceCancel(id, { by, byName, remarks }) { const po = await findById(id); if (!po) throw new Error('Production order not found'); const log = po.workflowLog || []; log.push({ step: 'Cancel', action: 'admin_cancelled', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || '' }); await exec(`UPDATE ${TABLE} SET STATUS='CANCELLED', WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`, [JSON.stringify(log), toTs(new Date().toISOString()), parseInt(id)]); const cancelled = await findById(id); await notifyForOrder(cancelled, { title: `Production Order ${cancelled.sapDocNum || '#' + cancelled.id} — Cancelled (Admin override)`, lines: [['Production Order', cancelled.sapDocNum || '#' + cancelled.id], ['Item', cancelled.itemName || cancelled.itemCode], ['Cancelled By', byName || by], ['Remarks', remarks || '']], url: `${process.env.APP_BASE_URL || ''}/production-order?id=${cancelled.id}`, }); return cancelled; } // Catches the local stage tracker up to `targetStage` directly, skipping the // normal sequential advanceStage() rules — used when SAP itself already // shows a later state (e.g. fully Released AND Issued) because someone // performed those actions directly in SAP B1 instead of through the portal. // Without this, such an order is stuck below its true SAP state forever: the // portal's own Issue for Production button only shows while SAP is NOT yet // fully issued (see production.html's `fullyIssued` gate), so there is no // sequential path to "catch up" — Verify Work Order, Receipt from // Production and Close all then stay permanently out of reach even though // SAP is ready. The caller (routes/sap.js) is responsible for confirming // SAP's real state matches BEFORE calling this. async function catchUpStage(id, targetStage, { by, byName, remarks }) { const po = await findById(id); if (!po) throw new Error('Production order not found'); if (po.status !== 'IN_PROGRESS') throw new Error('Production order is already ' + po.status.toLowerCase()); if (targetStage <= po.stage) return po; const log = po.workflowLog || []; for (let s = po.stage; s < targetStage; s++) { log.push({ step: STEPS[s], action: 'caught_up', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || 'Already done directly in SAP — caught up in the portal' }); } const now = toTs(new Date().toISOString()); const newStatus = targetStage >= STEPS.length ? 'CLOSED' : 'IN_PROGRESS'; await exec(`UPDATE ${TABLE} SET STAGE=?, STATUS=?, WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`, [targetStage, newStatus, JSON.stringify(log), now, parseInt(id)]); const caughtUp = await findById(id); const url = `${process.env.APP_BASE_URL || ''}/production-order?id=${caughtUp.id}`; if (newStatus === 'CLOSED') { await notifyForOrder(caughtUp, { title: `Production Order ${caughtUp.sapDocNum || '#' + caughtUp.id} — Closed`, lines: [['Production Order', caughtUp.sapDocNum || '#' + caughtUp.id], ['Item', caughtUp.itemName || caughtUp.itemCode], ['Closed By', byName || by]], url, }); } else { const nextStepKey = STEP_KEYS[targetStage]; notify().notify({ stepFullKey: notifyStepFor(caughtUp, nextStepKey), title: `Production Order ${caughtUp.sapDocNum || '#' + caughtUp.id} — awaiting ${STEPS[targetStage]}`, lines: [['Production Order', caughtUp.sapDocNum || '#' + caughtUp.id], ['Item', caughtUp.itemName || caughtUp.itemCode], ['Pending Step', STEPS[targetStage]]], url, excludeUsernames: [by], }); } return caughtUp; } async function softDelete(id) { await exec(`UPDATE ${TABLE} SET IS_DELETED=1, UPDATED_AT=? WHERE ID=?`, [toTs(new Date().toISOString()), parseInt(id)]); } // Portal-only verification sign-off performed AFTER Issuance. Does NOT touch // SAP or the stage chain — records who verified + when (+ a workflow-log entry // with step 'Verified' so it flows to the Work Order PDF's "Verified By"). // The verifier's stored signature image is drawn on the printed Work Order. const ISSUANCE_STAGE = STEP_KEYS.indexOf('issuance') + 1; // stage once Issuance is done async function verify(id, { by, byName, remarks }) { const po = await findById(id); if (!po || po.isDeleted) throw new Error('Production order not found'); if (po.verifiedAt) throw new Error('This production order has already been verified by ' + (po.verifiedByName || po.verifiedBy)); if ((po.stage || 0) < ISSUANCE_STAGE) throw new Error('Production order must be Issued before it can be verified'); const atIso = new Date().toISOString(); const log = po.workflowLog || []; log.push({ step: 'Verified', action: 'verified', by, byName: byName || by, at: atIso, remarks: remarks || '' }); await exec( `UPDATE ${TABLE} SET VERIFIED_BY=?, VERIFIED_BY_NAME=?, VERIFIED_AT=?, VERIFIED_REMARKS=?, WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`, [by, byName || by, toTs(atIso), remarks || '', JSON.stringify(log), toTs(atIso), parseInt(id)] ); return findById(id); } // Portal-only "Received By" manual sign-off — same shape/rules as verify() // above, but independent of it: records who received + when (+ a workflow-log // entry with step 'Received By (Manual)'). This ALWAYS takes priority over the // SAP "Receipt from Production" step's signer wherever "Received By" is shown // (see routes/workOrders.js computeIssuance()). async function receiveManual(id, { by, byName, remarks }) { const po = await findById(id); if (!po || po.isDeleted) throw new Error('Production order not found'); if (po.receivedManualAt) throw new Error('Received By has already been saved by ' + (po.receivedManualByName || po.receivedManualBy)); if ((po.stage || 0) < ISSUANCE_STAGE) throw new Error('Production order must be Issued before Received By can be saved'); const atIso = new Date().toISOString(); const log = po.workflowLog || []; log.push({ step: 'Received By (Manual)', action: 'received', by, byName: byName || by, at: atIso, remarks: remarks || '' }); await exec( `UPDATE ${TABLE} SET RECEIVED_MANUAL_BY=?, RECEIVED_MANUAL_BY_NAME=?, RECEIVED_MANUAL_AT=?, RECEIVED_MANUAL_REMARKS=?, WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`, [by, byName || by, toTs(atIso), remarks || '', JSON.stringify(log), toTs(atIso), parseInt(id)] ); return findById(id); } module.exports = { bootstrap, STEPS, STEP_KEYS, ISSUANCE_STAGE, insertProductionOrder, listProductionOrders, findById, findBySapAbsEntry, advanceStage, verify, receiveManual, softDelete, adminForceClose, adminForceCancel, catchUpStage, };