639 lines
37 KiB
JavaScript
639 lines
37 KiB
JavaScript
'use strict';
|
||
// routes/workOrders.js — Production Work Orders (generated from Batch Intimation)
|
||
const express = require('express');
|
||
const router = express.Router();
|
||
const { verifyToken, requireApprovalStep, requireWorkflowPerm, hasStepPerm } = require('../middleware/auth');
|
||
|
||
const store = () => require('../services/workOrderStore');
|
||
const notify = () => require('../services/notifyStore');
|
||
|
||
// MFG/EXP accept any of 6 formats (empty allowed) — same set as the
|
||
// picker-only date fields in public/work-order.html, public/batch-issuance.html
|
||
// and public/receipt-production.html: DD-MMM-YYYY, DD-MM-YYYY, MMM-YYYY,
|
||
// MM-YYYY, YYYY-MMM, YYYY-MM. Kept in sync with those — this backend check
|
||
// must never fall behind the frontend's accepted formats again.
|
||
const DATE_RES = [
|
||
/^(\d{1,2})[-/]([A-Za-z]{3})[-/](\d{4})$/, // DD-MMM-YYYY
|
||
/^(\d{1,2})-(\d{1,2})-(\d{4})$/, // DD-MM-YYYY
|
||
/^([A-Za-z]{3})[-/](\d{4})$/, // MMM-YYYY
|
||
/^(\d{1,2})-(\d{4})$/, // MM-YYYY
|
||
/^(\d{4})-([A-Za-z]{3})$/, // YYYY-MMM
|
||
/^(\d{4})-(\d{1,2})$/, // YYYY-MM
|
||
];
|
||
function isValidMEDate(v) { const t = String(v || '').trim(); return !t || DATE_RES.some(re => re.test(t)); }
|
||
function badDate(v) { return v && !isValidMEDate(v); }
|
||
|
||
// This Work Order's main product's SAP Item Group (ItmsGrpCod) — resolved
|
||
// fresh per notify() call (rare enough that caching isn't worth it) so
|
||
// notifyStore's Item-Group email routing (Admin → System Settings → "Notify
|
||
// by Item Group") can reach the right inbox. Never throws — a lookup failure
|
||
// just means no group-routed recipients get added, the normal step/module
|
||
// recipients still fire regardless.
|
||
async function itemGroupOf(itemCode, company) {
|
||
if (!itemCode) return null;
|
||
try {
|
||
const { getPool } = require('../services/sqlPool');
|
||
const pool = await getPool(company || null);
|
||
const r = await pool.request().query(`SELECT "ItmsGrpCod" FROM [dbo].[OITM] WHERE "ItemCode" = '${String(itemCode).replace(/'/g, "''")}'`);
|
||
return r.recordset && r.recordset[0] ? String(r.recordset[0].ItmsGrpCod) : null;
|
||
} catch (e) { console.warn('[WO] itemGroupOf lookup failed:', e.message); return null; }
|
||
}
|
||
|
||
// Batched version — one round trip for a whole list's worth of distinct
|
||
// product codes, instead of one query per row. Returns {itemCode: groupCode}.
|
||
async function itemGroupsFor(itemCodes, company) {
|
||
const codes = [...new Set((itemCodes || []).filter(Boolean))];
|
||
if (!codes.length) return {};
|
||
try {
|
||
const { getPool } = require('../services/sqlPool');
|
||
const pool = await getPool(company || null);
|
||
const list = codes.map(c => `'${String(c).replace(/'/g, "''")}'`).join(',');
|
||
const r = await pool.request().query(`SELECT "ItemCode","ItmsGrpCod" FROM [dbo].[OITM] WHERE "ItemCode" IN (${list})`);
|
||
const map = {};
|
||
(r.recordset || []).forEach(row => { map[row.ItemCode] = String(row.ItmsGrpCod); });
|
||
return map;
|
||
} catch (e) { console.warn('[WO] itemGroupsFor lookup failed:', e.message); return {}; }
|
||
}
|
||
|
||
function normRaw(rows) {
|
||
return (rows || [])
|
||
.filter(r => (r.itemCode || '').trim() || (r.rawMaterial || '').trim())
|
||
.map(r => ({
|
||
itemCode: (r.itemCode || '').trim(),
|
||
rawMaterial: (r.rawMaterial || '').trim(),
|
||
spec: (r.spec || '').trim(),
|
||
stdQty: (r.stdQty || '').toString().trim(), // Qty Req. (std/per unit)
|
||
uom: (r.uom || '').trim(),
|
||
ovg: (r.ovg || '').toString().trim(),
|
||
qtyReq: (r.qtyReq || '').toString().trim(), // Qty Req. (total)
|
||
weighingBalanceId: (r.weighingBalanceId || '').trim(),
|
||
arNo: (r.arNo || '').trim(),
|
||
// Multi-solution support: which Solution this raw material belongs to,
|
||
// and that solution's own Batch Size (Ltr) — different solutions in the
|
||
// same product can have different batch sizes.
|
||
solCode: (r.solCode || '').trim(),
|
||
solName: (r.solName || '').trim(),
|
||
solBatchSize: (r.solBatchSize || '').toString().trim(),
|
||
solPerUnitLitres: r.solPerUnitLitres != null && r.solPerUnitLitres !== '' ? Number(r.solPerUnitLitres) : '',
|
||
solBSManual: !!r.solBSManual,
|
||
// Item Group Rules "RAW" override: shown as itself (not exploded from a
|
||
// Solution), qty calc = Std Qty/Unit × Total Units × (1+Ovg%) — see
|
||
// recalcMaterials() in work-order.html.
|
||
directRaw: !!r.directRaw,
|
||
}));
|
||
}
|
||
function normPack(rows) {
|
||
return (rows || [])
|
||
.filter(r => (r.itemCode || '').trim() || (r.packingMaterial || '').trim())
|
||
.map(r => ({
|
||
itemCode: (r.itemCode || '').trim(),
|
||
packingMaterial: (r.packingMaterial || '').trim(),
|
||
artworkNo: (r.artworkNo || '').trim(),
|
||
stdQtyPerUnit: (r.stdQtyPerUnit || '').toString().trim(),
|
||
// Stock UOM from SAP (display-only column) — was missing from this
|
||
// whitelist entirely, so it silently vanished on every save even
|
||
// though it displayed correctly right after loading the BOM.
|
||
uom: (r.uom || '').trim(),
|
||
// Std. Qty/Unit's own chosen display unit (mg/gm/Kg/ml/etc, or blank
|
||
// for a plain count) and the resulting Qty Req.(Units) unit label —
|
||
// also missing before, so picking a real unit never survived a save.
|
||
stdQtyUnit: (r.stdQtyUnit || '').trim(),
|
||
qtyUnitLabel: (r.qtyUnitLabel || '').trim(),
|
||
ovgPercent: (r.ovgPercent || '').toString().trim(),
|
||
qtyReqUnits: (r.qtyReqUnits || '').toString().trim(),
|
||
// AR No. is normally set via the separate per-row PATCH on Verify Work
|
||
// Order, but was missing here too — meaning a later edit+save of the
|
||
// WHOLE Work Order (e.g. a QA correction) would silently erase every
|
||
// AR No. already recorded, since this whitelist is what's actually
|
||
// persisted, not just what the client happens to send.
|
||
arNo: (r.arNo || '').trim(),
|
||
// Per-row Round Up/Exact override (work-order.html's round-pill on a
|
||
// "no unit picked" row) — same class of bug as the ones above: missing
|
||
// from this whitelist, so any edit+save of the Work Order silently
|
||
// reset every row back to the global default, even though the pill
|
||
// itself displayed the override correctly right up until that save.
|
||
roundUp: r.roundUp != null ? !!r.roundUp : null,
|
||
}));
|
||
}
|
||
function pickHeader(b) {
|
||
return {
|
||
reference: b.reference || '',
|
||
productName: b.productName || '',
|
||
productDesc: b.productDesc || '',
|
||
genericName: b.genericName || '',
|
||
productCode: b.productCode || '',
|
||
batchNumber: b.batchNumber || '',
|
||
batchSize: b.batchSize || '',
|
||
totalUnits: b.totalUnits || '',
|
||
mfgDate: b.mfgDate || null,
|
||
expDate: b.expDate || null,
|
||
packSize: b.packSize || '',
|
||
type: b.type || '',
|
||
market: b.market || '',
|
||
remarks: b.remarks || '',
|
||
rawMaterials: normRaw(b.rawMaterials),
|
||
packingMaterials: normPack(b.packingMaterials),
|
||
componentsOnly: !!b.componentsOnly,
|
||
};
|
||
}
|
||
|
||
router.get('/', verifyToken, requireWorkflowPerm('work_order', 'view'), async (req, res) => {
|
||
try {
|
||
const { mine, company, status } = req.query;
|
||
let data = await store().listWorkOrders({
|
||
mine: mine === '1' ? req.user.username : undefined, company, status,
|
||
});
|
||
// Item Group visibility restriction (Admin → user → Issue Items allowed
|
||
// groups) — same 'issueItemGroups' list already enforced at actual
|
||
// issuance time, reused here purely for list visibility: a user
|
||
// restricted to specific Item Groups shouldn't see OTHER groups' Work
|
||
// Orders in the list at all. Empty list (default) = unrestricted.
|
||
try {
|
||
const acting = await require('../services/hanaUsers').findById(req.user.id);
|
||
const allowedGroups = Array.isArray(acting && acting.issueItemGroups) ? acting.issueItemGroups.map(String) : [];
|
||
if (allowedGroups.length && data.length) {
|
||
const byCompany = {};
|
||
data.forEach(w => { (byCompany[w.company || ''] = byCompany[w.company || ''] || []).push(w.productCode); });
|
||
const groupMaps = {};
|
||
await Promise.all(Object.keys(byCompany).map(async co => {
|
||
groupMaps[co] = await itemGroupsFor(byCompany[co], co || null);
|
||
}));
|
||
const allowSet = new Set(allowedGroups);
|
||
data = data.filter(w => allowSet.has((groupMaps[w.company || ''] || {})[w.productCode]));
|
||
}
|
||
} catch (e) { console.warn('[WO] item-group visibility filter failed (non-fatal):', e.message); }
|
||
res.json({ success: true, data });
|
||
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
router.get('/:id', verifyToken, requireWorkflowPerm('work_order', 'view'), async (req, res) => {
|
||
try {
|
||
const r = await store().findById(req.params.id);
|
||
if (!r || r.isDeleted) return res.status(404).json({ success: false, message: 'Not found' });
|
||
res.json({ success: true, data: r });
|
||
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
// Create (generate) a work order — this IS the "Prepared By QA" step, so only
|
||
// users assigned that approval step (or admin) may create one. Having the
|
||
// production-work-order MODULE just lets a user open/view the page; it does
|
||
// not by itself grant the right to originate a new work order.
|
||
router.post('/', verifyToken, requireApprovalStep('work_order:prepared_qa', 'add'), async (req, res) => {
|
||
try {
|
||
const b = req.body || {};
|
||
if (!(b.productName || b.productCode))
|
||
return res.status(400).json({ success: false, message: 'Product Name / Code is required' });
|
||
if (badDate(b.mfgDate) || badDate(b.expDate))
|
||
return res.status(400).json({ success: false, message: 'Invalid MFG/EXP date — use DD-MMM-YYYY or MMM/YYYY' });
|
||
// A given (Intimation, Product, Batch No.) combination may only ever
|
||
// produce ONE Work Order — its quantity is fully captured the first time.
|
||
// Client-side the picker hides/disables already-used batches, but this is
|
||
// the enforcing check (the client guard alone can be bypassed).
|
||
if (b.intimationId) {
|
||
const existing = await store().listWorkOrders({});
|
||
const dup = existing.find(w => !w.isDeleted
|
||
&& String(w.intimationId) === String(b.intimationId)
|
||
&& (w.productCode || '') === (b.productCode || '')
|
||
&& (w.batchNumber || '') === (b.batchNumber || ''));
|
||
if (dup)
|
||
return res.status(409).json({ success: false, message: `A Work Order (${dup.woNo}) has already been generated for this Intimation's batch "${b.batchNumber || b.productCode}" — its full quantity is already captured.` });
|
||
}
|
||
const saved = await store().insertWorkOrder({
|
||
...pickHeader(b),
|
||
intimationId: b.intimationId || null,
|
||
company: b.company || '',
|
||
createdBy: req.user.username, createdByName: req.user.name || req.user.username,
|
||
});
|
||
res.json({ success: true, data: saved });
|
||
if (saved.status === 'IN_PROGRESS') {
|
||
const nextKey = WORK_ORDER_STEP_KEYS[saved.stage];
|
||
notify().notify({
|
||
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
|
||
itemGroupCode: await itemGroupOf(saved.productCode, saved.company),
|
||
title: `Work Order ${saved.woNo} — awaiting ${saved.currentStep}`,
|
||
lines: [['Work Order', saved.woNo], ['Product', saved.productName || ''], ['Created By', saved.createdByName], ['Pending Step', saved.currentStep]],
|
||
url: `${process.env.APP_BASE_URL || ''}/work-order?id=${saved.id}`,
|
||
excludeUsernames: [req.user.username],
|
||
});
|
||
}
|
||
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
// Edit header/materials — normally only while In Progress (requires 'edit'
|
||
// perm on whichever step currently owns the record, same step that would
|
||
// act next). A REJECTED work order is ALSO editable, but as a combined
|
||
// edit+resubmit: the save both applies the changes AND restarts the full
|
||
// approval chain from step 1 (see resubmitAfterReject) — since none of the
|
||
// original approvers ever saw the edited content. Editing a rejected order
|
||
// is gated on the FIRST step's 'edit' permission (the same step it restarts
|
||
// at), not the step that happened to reject it.
|
||
router.put('/:id', verifyToken, async (req, res) => {
|
||
try {
|
||
const existing = await store().findById(req.params.id);
|
||
if (!existing || existing.isDeleted) return res.status(404).json({ success: false, message: 'Not found' });
|
||
if (badDate(req.body?.mfgDate) || badDate(req.body?.expDate))
|
||
return res.status(400).json({ success: false, message: 'Invalid MFG/EXP date — use DD-MMM-YYYY or MMM/YYYY' });
|
||
|
||
if (existing.status === 'REJECTED') {
|
||
const firstStepKey = WORK_ORDER_STEP_KEYS[0];
|
||
if (!hasStepPerm(req.user, `work_order:${firstStepKey}`, 'edit'))
|
||
return res.status(403).json({ success: false, message: `You are not assigned "edit" on approval step: work_order:${firstStepKey}` });
|
||
const updated = await store().resubmitAfterReject(req.params.id, pickHeader(req.body || {}), {
|
||
by: req.user.username, byName: req.user.name || req.user.username,
|
||
});
|
||
res.json({ success: true, data: updated });
|
||
const nextKey = WORK_ORDER_STEP_KEYS[updated.stage];
|
||
notify().notify({
|
||
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
|
||
itemGroupCode: await itemGroupOf(updated.productCode, updated.company),
|
||
title: `Work Order ${updated.woNo} — Resubmitted, awaiting ${updated.currentStep}`,
|
||
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Resubmitted By', req.user.name || req.user.username], ['Pending Step', updated.currentStep]],
|
||
url: `${process.env.APP_BASE_URL || ''}/work-order?id=${updated.id}`,
|
||
excludeUsernames: [req.user.username],
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (existing.status !== 'IN_PROGRESS')
|
||
return res.status(409).json({ success: false, message: 'Work order is ' + existing.status.toLowerCase() + ' and cannot be edited' });
|
||
const curStepKey = WORK_ORDER_STEP_KEYS[existing.stage];
|
||
if (!curStepKey || !hasStepPerm(req.user, `work_order:${curStepKey}`, 'edit'))
|
||
return res.status(403).json({ success: false, message: `You are not assigned "edit" on approval step: work_order:${curStepKey || '?'}` });
|
||
const updated = await store().updateWorkOrder(req.params.id, pickHeader(req.body || {}));
|
||
res.json({ success: true, data: updated });
|
||
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
// Workflow: approve (advance) or reject
|
||
// Index-aligned with services/workOrderStore.js STEPS (both are the 5-step
|
||
// QA/Production sign-off chain) and services/approvalStepsStore.js's
|
||
// workflow:'work_order' step keys.
|
||
const WORK_ORDER_STEP_KEYS = ['prepared_qa', 'checked_qc', 'checked_production', 'checked_mgr_production', 'approved_mgr_qa'];
|
||
|
||
router.patch('/:id/action', verifyToken, async (req, res) => {
|
||
try {
|
||
const action = (req.body.action || '').toLowerCase();
|
||
if (!['approve', 'reject'].includes(action))
|
||
return res.status(400).json({ success: false, message: 'action must be approve or reject' });
|
||
if (action === 'reject' && !(req.body.remarks || '').trim())
|
||
return res.status(400).json({ success: false, message: 'A reason is required to reject a Work Order' });
|
||
{
|
||
const wo = await store().findById(req.params.id);
|
||
if (!wo) return res.status(404).json({ success: false, message: 'Work order not found' });
|
||
const stepKey = WORK_ORDER_STEP_KEYS[wo.stage];
|
||
if (!stepKey || !hasStepPerm(req.user, `work_order:${stepKey}`, 'approve'))
|
||
return res.status(403).json({ success: false, message: `You are not assigned "approve" on approval step: work_order:${stepKey || '?'}` });
|
||
}
|
||
const updated = await store().workflowAction(req.params.id, {
|
||
action, by: req.user.username, byName: req.user.name || req.user.username,
|
||
remarks: req.body.remarks || '',
|
||
});
|
||
res.json({ success: true, data: updated });
|
||
const woUrl = `${process.env.APP_BASE_URL || ''}/work-order?id=${updated.id}`;
|
||
if (action === 'reject') {
|
||
notify().notify({
|
||
stepFullKey: 'work_order:prepared_qa',
|
||
itemGroupCode: await itemGroupOf(updated.productCode, updated.company),
|
||
title: `Work Order ${updated.woNo} — Rejected`,
|
||
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Rejected By', req.user.name || req.user.username], ['Remarks', req.body.remarks || '']],
|
||
url: woUrl,
|
||
excludeUsernames: [req.user.username],
|
||
});
|
||
} else if (updated.status === 'IN_PROGRESS') {
|
||
const nextKey = WORK_ORDER_STEP_KEYS[updated.stage];
|
||
notify().notify({
|
||
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
|
||
itemGroupCode: await itemGroupOf(updated.productCode, updated.company),
|
||
title: `Work Order ${updated.woNo} — awaiting ${updated.currentStep}`,
|
||
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Approved By', req.user.name || req.user.username], ['Pending Step', updated.currentStep]],
|
||
url: woUrl,
|
||
excludeUsernames: [req.user.username],
|
||
});
|
||
} else if (updated.status === 'APPROVED') {
|
||
notify().notify({
|
||
stepFullKey: 'work_order:prepared_qa',
|
||
itemGroupCode: await itemGroupOf(updated.productCode, updated.company),
|
||
title: `Work Order ${updated.woNo} — Fully Approved`,
|
||
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Final Approval By', req.user.name || req.user.username]],
|
||
url: woUrl,
|
||
excludeUsernames: [req.user.username],
|
||
});
|
||
}
|
||
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
// Recall of a FULLY APPROVED Work Order back to QA for editing — not a
|
||
// normal in-flight rejection (no per-stage approval step is checked), just
|
||
// an override for a document that already finished its whole chain.
|
||
// Admin/system_admin always bypass; a regular user may also be granted this
|
||
// specifically via 'approve' on the dedicated work_order:send_to_qa step
|
||
// (Admin → Edit User → Approval Steps) — previously this action had NO
|
||
// assignable step at all. Re-uses the exact same status ('REJECTED') the
|
||
// normal reject action sets, so the existing "Edit & Resubmit" flow
|
||
// (PUT /:id above) picks it up for free — once edited, it restarts the
|
||
// full 5-step chain from Prepared By QA.
|
||
router.post('/:id/send-to-qa', verifyToken, async (req, res) => {
|
||
try {
|
||
if (req.user.role !== 'admin' && req.user.role !== 'system_admin' && !hasStepPerm(req.user, 'work_order:send_to_qa', 'approve'))
|
||
return res.status(403).json({ success: false, message: 'You are not assigned to approval step: work_order:send_to_qa' });
|
||
const updated = await store().sendBackToQaForEdit(req.params.id, {
|
||
by: req.user.username, byName: req.user.name || req.user.username,
|
||
remarks: req.body?.remarks || '',
|
||
});
|
||
res.json({ success: true, data: updated });
|
||
notify().notify({
|
||
stepFullKey: 'work_order:prepared_qa',
|
||
itemGroupCode: await itemGroupOf(updated.productCode, updated.company),
|
||
title: `Work Order ${updated.woNo} — Sent back to QA for edit`,
|
||
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Sent Back By', req.user.name || req.user.username], ['Remarks', req.body?.remarks || '']],
|
||
url: `${process.env.APP_BASE_URL || ''}/work-order?id=${updated.id}`,
|
||
excludeUsernames: [req.user.username],
|
||
});
|
||
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
router.delete('/:id', verifyToken, async (req, res) => {
|
||
try {
|
||
const existing = await store().findById(req.params.id);
|
||
if (!existing) return res.status(404).json({ success: false, message: 'Not found' });
|
||
const curStepKey = WORK_ORDER_STEP_KEYS[existing.stage];
|
||
if (!hasStepPerm(req.user, `work_order:${curStepKey || 'prepared_qa'}`, 'delete'))
|
||
return res.status(403).json({ success: false, message: `You are not assigned "delete" on approval step: work_order:${curStepKey || 'prepared_qa'}` });
|
||
await store().softDelete(req.params.id);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
// ── Per-row material fields (Weighing Balance ID / AR No.) ─────────────────
|
||
// Editable by whoever holds 'edit' on work_order:issue — independent of the
|
||
// work order's own approval status/edit-lock (this happens AFTER approval,
|
||
// at the moment materials are physically issued).
|
||
router.patch('/:id/row/:section/:index', verifyToken, async (req, res) => {
|
||
try {
|
||
const section = req.params.section;
|
||
if (!['raw', 'pack'].includes(section)) return res.status(400).json({ success: false, message: 'section must be "raw" or "pack"' });
|
||
if (!hasStepPerm(req.user, 'work_order:issue', 'edit'))
|
||
return res.status(403).json({ success: false, message: 'You are not assigned "edit" on approval step: work_order:issue' });
|
||
const patch = {};
|
||
if (req.body.weighingBalanceId !== undefined && section === 'raw') patch.weighingBalanceId = String(req.body.weighingBalanceId || '').trim();
|
||
if (req.body.arNo !== undefined) patch.arNo = String(req.body.arNo || '').trim();
|
||
if (!Object.keys(patch).length) return res.status(400).json({ success: false, message: 'Nothing to update' });
|
||
const updated = await store().patchMaterialRow(req.params.id, section, req.params.index, patch);
|
||
res.json({ success: true, data: updated });
|
||
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
// ── Per-row Issued/Received/Verified one-click stamps ───────────────────────
|
||
// Each is a SEPARATE portal-only sign-off on that ONE material row — not the
|
||
// whole work order — because different items can be issued/received/verified
|
||
// on different days by different people. Each is gated by 'approve' on its
|
||
// own approval step, and can only be stamped once per row.
|
||
const ROW_STAMP_STEPS = { issued: 'work_order:issue', received: 'work_order:receive', verified: 'work_order:verify' };
|
||
// Enforced order per row: Issued → Received → Verified. Each key names the
|
||
// PRECEDING stamp that must already exist before this one can be set.
|
||
const ROW_STAMP_PREREQ = { received: 'issued', verified: 'received' };
|
||
router.post('/:id/row/:section/:index/mark', verifyToken, async (req, res) => {
|
||
try {
|
||
const section = req.params.section;
|
||
if (!['raw', 'pack'].includes(section)) return res.status(400).json({ success: false, message: 'section must be "raw" or "pack"' });
|
||
const which = (req.body.which || '').toLowerCase();
|
||
const stepKey = ROW_STAMP_STEPS[which];
|
||
if (!stepKey) return res.status(400).json({ success: false, message: 'which must be issued, received, or verified' });
|
||
// Production Order Issuance (production_order:issuance) and Work Order
|
||
// row-stamping are different screens/responsibilities — no longer
|
||
// coupled. Marking a row here always requires its own explicit
|
||
// approval-step grant.
|
||
const allowed = hasStepPerm(req.user, stepKey, 'approve');
|
||
if (!allowed)
|
||
return res.status(403).json({ success: false, message: `You are not assigned "approve" on approval step: ${stepKey}` });
|
||
const wo = await store().findById(req.params.id);
|
||
if (!wo) return res.status(404).json({ success: false, message: 'Work order not found' });
|
||
const arr = (section === 'raw' ? wo.rawMaterials : wo.packingMaterials) || [];
|
||
const i = parseInt(req.params.index);
|
||
if (!(i >= 0) || i >= arr.length) return res.status(404).json({ success: false, message: 'Row not found' });
|
||
const row = arr[i];
|
||
const atField = `${which}At`;
|
||
// A stamp can only be set once — EXCEPT for a woVerifyOverride user (or
|
||
// admin), who may re-stamp ANY of the three (issued/received/verified)
|
||
// even after it's already signed, to correct who it's recorded as
|
||
// signed by and/or its date. That's the actual point of the override:
|
||
// catching up/correcting paperwork on any step, not just Verified, and
|
||
// not just rows nobody has touched yet.
|
||
const canOverrideStamp = req.user.role === 'admin' || req.user.woVerifyOverride;
|
||
if (row[atField] && !canOverrideStamp) return res.status(400).json({ success: false, message: `This row's "${which}" was already stamped by ${row[which + 'ByName'] || row[which + 'By']}` });
|
||
// Per-user item-group restriction (Admin → user → Issue Items) — same rule
|
||
// enforced on the real SAP issuance (routes/sap.js). Marking a row Issued
|
||
// must be blocked for item groups this user isn't allowed to issue, even
|
||
// if they hold Issue for Production generally.
|
||
if (which === 'issued') {
|
||
try {
|
||
const acting = await require('../services/hanaUsers').findById(req.user.id);
|
||
const allowedGroups = Array.isArray(acting && acting.issueItemGroups) ? acting.issueItemGroups.map(String) : [];
|
||
if (allowedGroups.length && row.itemCode) {
|
||
const { getPool } = require('../services/sqlPool');
|
||
// Must query the WO's OWN company database, not whatever the shared
|
||
// pool happens to default to (services/sqlPool.js's getPool() is
|
||
// per-database now — see [[displayed-sap-company-restriction]]) —
|
||
// this route gets no `company` param from the frontend at all, so
|
||
// the Work Order record's own stored company is the source of truth.
|
||
const pool = await getPool(wo.company || null);
|
||
const r = await pool.request().query(`SELECT "ItmsGrpCod" FROM [dbo].[OITM] WHERE "ItemCode" = '${String(row.itemCode).replace(/'/g, "''")}'`);
|
||
const grp = r.recordset && r.recordset[0] ? String(r.recordset[0].ItmsGrpCod) : null;
|
||
if (!grp || !allowedGroups.includes(grp))
|
||
return res.status(403).json({ success: false, message: `You are not permitted to issue this item (${row.itemCode}) — its item group is not in your allowed list.` });
|
||
}
|
||
} catch (e) { console.warn('[WO-ROW-MARK] item-group restriction check failed:', e.message); }
|
||
// Mirrors the client's own gate (public/verify-work-order.html's
|
||
// issCells()) — the 'mark_issued' bypass ONLY applies to Raw Material:
|
||
// under that mode THIS stamp is what writes Qty Issued for a raw row
|
||
// in the first place (checking it first would be circular). Packing
|
||
// Material's Qty Issued always comes from the live SAP posting
|
||
// regardless of this setting (never written by this stamp), so it
|
||
// must always be checked for real — otherwise a Work Order with no
|
||
// Production Order/issuance posted yet would let Packing/Components
|
||
// rows be marked Issued with nothing actually issued. An override
|
||
// user may still catch up paperwork regardless.
|
||
const rawRowBypass = section === 'raw' && require('../services/appSettingsStore').woRawQtyIssuedSource() === 'mark_issued';
|
||
if (!canOverrideStamp) {
|
||
// Even under the raw-material bypass, nothing is issuable before a
|
||
// Production Order actually exists for this Work Order — there's no
|
||
// production event yet for the stamp to be recording. This is the
|
||
// actual fix for a fresh WO with no PO yet still allowing every
|
||
// raw-material row to be marked Issued.
|
||
let iss = null;
|
||
try { iss = await computeIssuance(wo.id, wo.company); } catch (e) { console.warn('[WO-ROW-MARK] issuance lookup failed:', e.message); }
|
||
if (!iss || !iss.hasPO)
|
||
return res.status(400).json({ success: false, message: 'Cannot mark "Issued" — no Production Order has been created for this Work Order yet.' });
|
||
if (!rawRowBypass) {
|
||
const reqQty = parseFloat(section === 'raw' ? row.qtyReq : row.qtyReqUnits) || 0;
|
||
if (reqQty > 0) {
|
||
const issuedQty = section === 'raw'
|
||
? (parseFloat(row.qtyIssued) || 0)
|
||
: ((iss.qtyByItem && iss.qtyByItem[String(row.itemCode || '').trim()]) || 0);
|
||
if (issuedQty + 0.001 < reqQty)
|
||
return res.status(400).json({ success: false, message: `Cannot mark "Issued" — only ${issuedQty} of ${reqQty} required has actually been issued for this item.` });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Issued→Received→Verified order is enforced for EVERYONE, including a
|
||
// woVerifyOverride/admin user — no one may sign a stage before the prior
|
||
// one has genuinely happened. That override only ever applies to
|
||
// RE-SIGNING an already-completed stamp (see the canOverrideStamp check
|
||
// above — correcting who it's recorded as signed by and/or its date),
|
||
// never to skipping straight past a stage that hasn't happened yet.
|
||
const prereq = ROW_STAMP_PREREQ[which];
|
||
if (prereq && !row[`${prereq}At`])
|
||
return res.status(400).json({ success: false, message: `Mark "${prereq}" on this row before "${which}".` });
|
||
// Normal case: the stamp always records the ACTUAL logged-in user, right
|
||
// now — no client input is trusted for who/when. The one narrow
|
||
// exception: a user explicitly granted woVerifyOverride (Admin → Edit
|
||
// User), or anyone with the admin role (same bypass every approval-step
|
||
// check gives admin elsewhere — see hasStepPerm), can, on ANY of the
|
||
// three stamps (issued/received/verified), sign as a different user
|
||
// and/or backdate the sign date — for catching up paperwork signed on
|
||
// paper on an earlier date by someone else. Every other user is
|
||
// unaffected regardless of which stamp.
|
||
let signerUsername = req.user.username;
|
||
let signerName = req.user.name || req.user.username;
|
||
let atIso = new Date().toISOString();
|
||
if (canOverrideStamp) {
|
||
const actAs = String(req.body.actAsUsername || '').trim();
|
||
if (actAs) {
|
||
const actingUser = await require('../services/hanaUsers').findByUsername(actAs);
|
||
if (!actingUser) return res.status(400).json({ success: false, message: `User "${actAs}" not found` });
|
||
signerUsername = actingUser.username;
|
||
signerName = actingUser.fullName || actingUser.username;
|
||
}
|
||
if (req.body.signedAt) {
|
||
const d = new Date(req.body.signedAt);
|
||
if (isNaN(d.getTime())) return res.status(400).json({ success: false, message: 'Invalid sign date' });
|
||
if (d.getTime() > Date.now()) return res.status(400).json({ success: false, message: 'Sign date cannot be in the future' });
|
||
atIso = d.toISOString();
|
||
}
|
||
}
|
||
const patch = {
|
||
[`${which}By`]: signerUsername,
|
||
[`${which}ByName`]: signerName,
|
||
[atField]: atIso,
|
||
};
|
||
// Admin → System Settings → "Raw Material Qty Issued Source" picks which
|
||
// of two decoupled events is authoritative for EVERY material table's
|
||
// Qty Issued (Raw Material, Packing Material, Components alike):
|
||
// 'issue_for_production' (default) calculates it from the real SAP
|
||
// posting instead (see routes/sap.js's /issue-production, and the live
|
||
// qtyByItem lookup for Packing/Components); only under 'mark_issued'
|
||
// does THIS one-click paperwork stamp (the same action that sets Issued
|
||
// By/Date, above) set Qty Issued = the row's full Qty Req.
|
||
if (which === 'issued' && (section === 'raw' || section === 'pack') && require('../services/appSettingsStore').woRawQtyIssuedSource() === 'mark_issued') {
|
||
patch.qtyIssued = parseFloat(section === 'raw' ? row.qtyReq : row.qtyReqUnits) || 0;
|
||
}
|
||
const updated = await store().patchMaterialRow(req.params.id, section, i, patch);
|
||
res.json({ success: true, data: updated });
|
||
// Notify whoever holds the NEXT stamp in the Issued→Received→Verified
|
||
// chain for this row (no next step after Verified — nothing to notify).
|
||
const NEXT_STAMP_STEP = { issued: 'work_order:receive', received: 'work_order:verify' };
|
||
const nextStep = NEXT_STAMP_STEP[which];
|
||
if (nextStep) {
|
||
notify().notify({
|
||
stepFullKey: nextStep,
|
||
title: `Work Order ${wo.woNo} — item ${which}, awaiting ${which === 'issued' ? 'Received By' : 'Verified By'}`,
|
||
lines: [['Work Order', wo.woNo], ['Item', row.itemCode || row.rawMaterial || ''], [which === 'issued' ? 'Issued By' : 'Received By', req.user.name || req.user.username]],
|
||
url: `${process.env.APP_BASE_URL || ''}/verify-work-order`,
|
||
excludeUsernames: [req.user.username],
|
||
});
|
||
}
|
||
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
// ── Issuance info for the printable Work Order ─────────────────────────────
|
||
// Pulls the linked Production Order(s): per-component issued quantity (live
|
||
// from SAP, summed by item code) and the Issuance / Receipt / Close signers
|
||
// (name + username + date, from each PO's workflow log). If no PO exists yet,
|
||
// or SAP is unreachable, the corresponding pieces come back empty so the print
|
||
// view simply leaves those cells blank.
|
||
async function computeIssuance(woId, co) {
|
||
const poStore = require('../services/productionOrderStore');
|
||
const sapSL = require('../services/sapServiceLayer');
|
||
const pos = (await poStore.listProductionOrders({ workOrderId: woId, company: co })) || [];
|
||
const active = pos.filter(p => !p.isDeleted);
|
||
const qtyByItem = {};
|
||
let issued = null, received = null, receivedManual = null, verified = null;
|
||
const pickLatest = (cur, e) => (!cur || new Date(e.at) >= new Date(cur.at)) ? e : cur;
|
||
for (const po of active) {
|
||
for (const e of (Array.isArray(po.workflowLog) ? po.workflowLog : [])) {
|
||
if (e.action === 'rejected') continue;
|
||
if (e.step === 'Issuance') issued = pickLatest(issued, e);
|
||
else if (e.step === 'Receipt from Production') received = pickLatest(received, e);
|
||
else if (e.step === 'Verified') verified = pickLatest(verified, e); // portal-only post-Issuance sign-off
|
||
}
|
||
// Portal-only "Received By" sign-off — ALWAYS overrides the SAP receipt
|
||
// step's signer above, wherever it exists (see productionOrderStore.receiveManual()).
|
||
if (po.receivedManualAt) {
|
||
receivedManual = pickLatest(receivedManual, { by: po.receivedManualBy, byName: po.receivedManualByName, at: po.receivedManualAt });
|
||
}
|
||
if (po.sapAbsEntry) {
|
||
try {
|
||
const so = await sapSL.sapRequest('GET', `ProductionOrders(${parseInt(po.sapAbsEntry)})`, null, co);
|
||
const lines = (so.ProductionOrderLines || []).filter(l => l.ItemType !== 'pit_Resource');
|
||
for (const l of lines) {
|
||
const code = (l.ItemNo || l.ItemCode || '').toString().trim();
|
||
if (!code) continue;
|
||
qtyByItem[code] = (qtyByItem[code] || 0) + (Number(l.IssuedQuantity) || 0);
|
||
}
|
||
} catch (_e) { /* SAP unreachable → leave issued qty blank */ }
|
||
}
|
||
}
|
||
const sig = (e) => e ? { name: e.byName || e.by || '', user: e.by || '', at: e.at || '' } : null;
|
||
return { hasPO: active.length > 0, qtyByItem, issuedBy: sig(issued), receivedBy: sig(receivedManual || received), verifiedBy: sig(verified) };
|
||
}
|
||
|
||
router.get('/:id/issuance-info', verifyToken, async (req, res) => {
|
||
try {
|
||
const data = await computeIssuance(parseInt(req.params.id), req.query.company || null);
|
||
res.json({ success: true, data });
|
||
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
// ── Server-generated PDF of the Production Work Order (pdfmake) ─────────────
|
||
router.get('/:id/pdf', verifyToken, requireWorkflowPerm('work_order', 'view'), async (req, res) => {
|
||
const co = req.query.company || null;
|
||
try {
|
||
const wo = await store().findById(req.params.id);
|
||
if (!wo) return res.status(404).json({ success: false, message: 'Not found' });
|
||
const settings = require('../services/appSettingsStore').getAll();
|
||
const iss = await computeIssuance(parseInt(req.params.id), co);
|
||
// Gather signature images: the 5 approval signers + every material row's
|
||
// own Issued/Received/Verified signer (per-row sign-offs — see
|
||
// public/verify-work-order.html; each row is stamped independently).
|
||
const users = new Set();
|
||
(Array.isArray(wo.workflowLog) ? wo.workflowLog : []).forEach(l => { if (l.by) users.add(l.by); });
|
||
[...(wo.rawMaterials || []), ...(wo.packingMaterials || [])].forEach(r => {
|
||
['issuedBy', 'receivedBy', 'verifiedBy'].forEach(k => { if (r[k]) users.add(r[k]); });
|
||
});
|
||
const hu = require('../services/hanaUsers');
|
||
const sigs = {};
|
||
for (const u of users) { try { const s = await hu.getSignatureByUsername(u); if (s) sigs[u] = s; } catch (_e) {} }
|
||
// U_NewItemCode (OITM) for the header Product Code only — printed as
|
||
// "(code)" right after it, when SAP has a value (see public/work-order-print.html).
|
||
let newItemCode = '';
|
||
try {
|
||
const { getPool } = require('../services/sqlPool');
|
||
const pool = await getPool(co);
|
||
const code = String(wo.productCode || '').replace(/'/g, "''");
|
||
const r = await pool.request().query(`SELECT "U_NewItemCode" FROM [dbo]."OITM" WHERE "ItemCode"='${code}'`);
|
||
newItemCode = r.recordset?.[0]?.U_NewItemCode || '';
|
||
} catch (_e) {}
|
||
const qtyIssuedSource = require('../services/appSettingsStore').woRawQtyIssuedSource();
|
||
const doc = require('../services/workOrderPdf').generate({ wo, settings, iss, sigs, newItemCode, qtyIssuedSource });
|
||
res.setHeader('Content-Type', 'application/pdf');
|
||
res.setHeader('Content-Disposition', `inline; filename="WO-${(wo.woNo || wo.id)}.pdf"`);
|
||
doc.pipe(res);
|
||
doc.end();
|
||
} catch (err) {
|
||
if (!res.headersSent) res.status(500).json({ success: false, message: err.message });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|