+109
-14
@@ -23,6 +23,38 @@ const DATE_RES = [
|
||||
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())
|
||||
@@ -108,9 +140,28 @@ function pickHeader(b) {
|
||||
router.get('/', verifyToken, requireWorkflowPerm('work_order', 'view'), async (req, res) => {
|
||||
try {
|
||||
const { mine, company, status } = req.query;
|
||||
const data = await store().listWorkOrders({
|
||||
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 }); }
|
||||
});
|
||||
@@ -158,6 +209,7 @@ router.post('/', verifyToken, requireApprovalStep('work_order:prepared_qa', 'add
|
||||
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}`,
|
||||
@@ -193,6 +245,7 @@ router.put('/:id', verifyToken, async (req, res) => {
|
||||
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}`,
|
||||
@@ -240,6 +293,7 @@ router.patch('/:id/action', verifyToken, async (req, res) => {
|
||||
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,
|
||||
@@ -249,6 +303,7 @@ router.patch('/:id/action', verifyToken, async (req, res) => {
|
||||
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,
|
||||
@@ -257,6 +312,7 @@ router.patch('/:id/action', verifyToken, async (req, res) => {
|
||||
} 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,
|
||||
@@ -266,16 +322,20 @@ router.patch('/:id/action', verifyToken, async (req, res) => {
|
||||
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
|
||||
});
|
||||
|
||||
// Admin-only recall of a FULLY APPROVED Work Order back to QA for editing —
|
||||
// not a normal in-flight rejection (no approval step is checked), just an
|
||||
// override for a document that already finished its whole chain. 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.
|
||||
// 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')
|
||||
return res.status(403).json({ success: false, message: 'Only admin/system admin can send a fully approved Work Order back to QA' });
|
||||
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 || '',
|
||||
@@ -283,6 +343,7 @@ router.post('/:id/send-to-qa', verifyToken, async (req, res) => {
|
||||
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}`,
|
||||
@@ -382,13 +443,47 @@ router.post('/:id/row/:section/:index/mark', verifyToken, async (req, res) => {
|
||||
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.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A woVerifyOverride/admin user may sign a stage out of the normal
|
||||
// Issued→Received→Verified order too (e.g. catching up Verified before
|
||||
// Received ever gets marked in the portal) — everyone else must still
|
||||
// follow it.
|
||||
// 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`] && !canOverrideStamp)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user