411 lines
20 KiB
JavaScript
411 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),
|
|
]);
|
|
// 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,
|
|
};
|