213 lines
12 KiB
JavaScript
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;
|