'use strict'; // routes/productionOrders.js — local tracking for SAP B1 Production Orders // generated FROM a Work Order (see services/productionOrderStore.js for why // this table exists — SAP alone doesn't know about the extra Transfer-to- // Finished-Goods step or which Work Order originated an order). // The actual SAP transactions (create/release/issue/receipt/transfer/close) // live in routes/sap.js — this file is just the local link record's CRUD. const express = require('express'); const router = express.Router(); const { verifyToken, requireApprovalStep, requireWorkflowPerm, hasStepPerm } = require('../middleware/auth'); const store = () => require('../services/productionOrderStore'); const woStore = () => require('../services/workOrderStore'); let _sapSvc = null; function getSap(){ if(!_sapSvc) _sapSvc = require('../services/sapServiceLayer'); return _sapSvc; } // Passes if the user holds `perm` on ANY of the given approval steps. Used for // the verify/receive endpoints below: 'work_order:verify'/'work_order:receive' // are the steps actually wired to the "Verify Work Order" screen; // 'production_order:verify' is kept (unused today) for possible future use — // holding any of them grants the same access, so re-enabling the old screen // later, or adding more sign-off types, needs no further backend change. function requireAnyStep(fullKeys, perm) { return (req, res, next) => { if (fullKeys.some(k => hasStepPerm(req.user, k, perm))) return next(); res.status(403).json({ success: false, message: `You are not assigned "${perm}" on approval step: ${fullKeys.join(' or ')}` }); }; } router.get('/', verifyToken, requireWorkflowPerm('production_order', 'view'), async (req, res) => { try { const { mine, company, workOrderId, status } = req.query; const data = await store().listProductionOrders({ mine: mine === '1' ? req.user.username : undefined, company, workOrderId, status, }); res.json({ success: true, data }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // "+ PWO" shortcut support: which components of a given parent order already // have their own sub-PWO raised against them (REF_PARENT_ENTRY), so the // button can be disabled/labelled instead of letting someone raise a // duplicate for the same requirement. No workflow 'view' gate (unlike the // general list below) — anyone who can open a Production Order's detail // view needs this, not just users with production_order 'view'. router.get('/by-ref-parent/:entry', verifyToken, async (req, res) => { try { const entry = parseInt(req.params.entry); const all = await store().listProductionOrders({ company: req.query.company }); const data = all.filter(p => !p.isDeleted && p.refParentEntry === entry) .map(p => ({ itemCode: p.itemCode, sapAbsEntry: p.sapAbsEntry, sapDocNum: p.sapDocNum, status: p.status })); res.json({ success: true, data }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Every fully APPROVED Work Order — verification is a Work-Order-level // sign-off, independent of whatever its linked Production Order's own // Issue/Receipt/Close stage happens to be (a WO can be, and stay, APPROVED // long before/after any of that). The linked Production Order (if one // exists yet) is joined in ONLY for display context (stage/doc no.), never // to filter the list. Gated by the dedicated 'verify' step (a verifier need // not hold the general production_order 'view' permission). router.get('/for-verification', verifyToken, requireAnyStep(['work_order:verify', 'work_order:receive', 'work_order:issue', 'production_order:verify'], 'view'), async (req, res) => { try { const company = req.query.company; const wos = await woStore().listWorkOrders({ company, status: 'APPROVED' }); const pos = await store().listProductionOrders({ company }); const poByWoId = {}; pos.forEach(p => { if (!p.isDeleted && p.workOrderId != null) poByWoId[p.workOrderId] = p; }); // Every applicable row (raw+pack, or just pack under componentsOnly) // carries its own Issued/Received/Verified stamp — a WO counts as // "complete" for a given stamp only once none of its rows are still // pending that one. Computed here (not on the client) since the // lightweight list payload below doesn't otherwise carry rawMaterials/ // packingMaterials — used to drive the card grid's "Issued By"/"Received // By"/"Verified By" status filters (each one shows WOs still pending // that specific sign-off). function stampComplete(w, which) { const rows = w.componentsOnly ? (w.packingMaterials || []) : [...(w.rawMaterials || []), ...(w.packingMaterials || [])]; return rows.length > 0 && rows.every(r => !!r[which + 'At']); } const data = wos.filter(w => !w.isDeleted).map(w => { const po = poByWoId[w.id] || null; return { id: w.id, woNo: w.woNo, workOrderId: w.id, itemCode: w.productCode, itemName: w.productName, plannedQty: w.totalUnits, batchNumber: w.batchNumber, intimationDocNo: w.intimationDocNo || '', createdBy: w.createdBy, createdByName: w.createdByName, createdAt: w.createdAt, status: w.status, issuedComplete: stampComplete(w, 'issued'), receivedComplete: stampComplete(w, 'received'), verified: stampComplete(w, 'verified'), po: po ? { id: po.id, sapDocNum: po.sapDocNum, sapAbsEntry: po.sapAbsEntry, stage: po.stage, currentStep: po.currentStep, status: po.status } : null, }; }); res.json({ success: true, data }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Perform the verification sign-off (portal-only — no SAP call). router.post('/:id/verify', verifyToken, requireAnyStep(['work_order:verify', 'production_order:verify'], 'approve'), async (req, res) => { try { const updated = await store().verify(req.params.id, { by: req.user.username, byName: req.user.name || req.user.username, remarks: req.body.remarks || '', }); res.json({ success: true, data: updated }); } catch (err) { res.status(400).json({ success: false, message: err.message }); } }); // Perform the "Received By" manual sign-off (portal-only — no SAP call). // Independent of Verify; always overrides the SAP receipt step's signer on // the printed Work Order (see routes/workOrders.js computeIssuance()). router.post('/:id/receive', verifyToken, requireAnyStep(['work_order:receive'], 'approve'), async (req, res) => { try { const updated = await store().receiveManual(req.params.id, { by: req.user.username, byName: req.user.name || req.user.username, remarks: req.body.remarks || '', }); res.json({ success: true, data: updated }); } catch (err) { res.status(400).json({ success: false, message: err.message }); } }); router.get('/:id', verifyToken, requireWorkflowPerm('production_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 }); } }); // Register the local tracking row AFTER the frontend has already created the // SAP Production Order (POST /api/sap/production-order). Works for BOTH // origins: linked to a Work Order (workOrderId set), or a manual/standalone // order (workOrderId null) — manual orders MUST be tracked too, otherwise // their later stages (Issue → Receipt → Close) never appear in the pending- // actions bell. Permission mirrors the SAP create route: which create step // applies depends on whether a Work Order is the source. router.post('/', verifyToken, (req, res, next) => { const perm = req.body?.workOrderId ? 'production_order:create' : req.body?.fromComponent ? 'production_order:create_from_component' : req.body?.fromConsumable ? 'production_order:consumable_create' : 'production_order:manual_create'; if (hasStepPerm(req.user, perm, 'add')) return next(); res.status(403).json({ success: false, message: `You are not assigned "add" on approval step: ${perm}` }); }, async (req, res) => { try { const b = req.body || {}; let wo = null; if (b.workOrderId) { wo = await woStore().findById(b.workOrderId); if (!wo || wo.isDeleted) return res.status(404).json({ success: false, message: 'Work order not found' }); } wo = wo || {}; // manual order — no WO fallbacks below if (!b.sapAbsEntry) return res.status(400).json({ success: false, message: 'sapAbsEntry (from the SAP creation response) is required' }); // Consumable Orders are raised by many departments — Department is // required (an organizational tag only, see [[consumable-order-department]]) // and, when this creator's own department list is restricted (Admin → // User → "PR Departments"), must be one of theirs — never trust the // client's own dropdown filtering for this. if (b.fromConsumable) { if (!String(b.department || '').trim()) return res.status(400).json({ success: false, message: 'Department is required for a Consumable Order.' }); try { const acting = await require('../services/hanaUsers').findById(req.user.id); const allowed = Array.isArray(acting?.allowedDepartments) ? acting.allowedDepartments.map(String) : []; if (allowed.length && !allowed.includes(String(b.department))) return res.status(403).json({ success: false, message: `You are not permitted to raise a Consumable Order for department "${b.department}".` }); } catch (_e) { /* non-fatal — proceeds unrestricted if the lookup itself fails */ } } const saved = await store().insertProductionOrder({ workOrderId: b.workOrderId || null, sapAbsEntry: b.sapAbsEntry, sapDocNum: b.sapDocNum || '', itemCode: b.itemCode || wo.productCode, itemName: b.itemName || wo.productName, plannedQty: b.plannedQty || wo.totalUnits, batchNumber: b.batchNumber || wo.batchNumber, refWoNo: b.refWoNo || '', refBatchNumber: b.refBatchNumber || '', refParentEntry: b.refParentEntry || null, isConsumable: !!b.fromConsumable, department: b.fromConsumable ? (b.department || '') : '', mfgDate: b.mfgDate || wo.mfgDate, expDate: b.expDate || wo.expDate, warehouse: b.warehouse || '', fgWarehouse: b.fgWarehouse || '', company: b.company || '', createdBy: req.user.username, createdByName: req.user.name || req.user.username, }); res.json({ success: true, data: saved }); // Stamp this local tracking record's own ID onto the just-created SAP // Production Order (UDF U_ERP_SO_NO) — best-effort, after the response // is already sent, so a failure here never blocks order creation. Lets // anyone in the SAP B1 client see which PWOs were created through the // portal (vs typed directly into SAP) by checking that field. try { await getSap().sapRequest('PATCH', `ProductionOrders(${parseInt(b.sapAbsEntry)})`, { U_ERP_SO_NO: String(saved.id) }, b.company || ''); } catch (e) { console.warn('[PROD-ORDER] U_ERP_SO_NO stamp failed (non-fatal):', e.message); } } catch (err) { res.status(500).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' }); // Deleting the local link record is tied to 'create'-level authority // (same as who's allowed to originate one), not whichever SAP stage the // order happens to be sitting at. if (!hasStepPerm(req.user, 'production_order:create', 'delete')) return res.status(403).json({ success: false, message: 'You are not assigned "delete" on approval step: production_order:create' }); await store().softDelete(req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); module.exports = router;