Files
sap-erp/routes/productionOrders.js
T
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

213 lines
12 KiB
JavaScript

'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;