sale order
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s

This commit is contained in:
John
2026-10-05 18:45:17 +05:30
parent e725a6571b
commit eead8f5ffd
129 changed files with 14252 additions and 452 deletions
+109 -14
View File
@@ -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