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

486 lines
28 KiB
JavaScript

// 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,
};