// routes/pwoSourceAudit.js // "PWO Source Report" — for each Production Order, shows whether each of its // four lifecycle actions (Create / Release / Issue / Receipt) was done // through this portal or directly in SAP B1. Pulled entirely from SAP B1 // Service Layer + this portal's own local tracking table. // // How each stage is told apart: // - CREATE: SAP UDF U_ERP_SO_NO on the Production Order itself — stamped // with this portal's own local record ID whenever it creates // one (see routes/productionOrders.js). Blank = created // directly in SAP. // - RELEASE: no UDF exists for this — instead, compares SAP's live // ProductionOrderStatus against this portal's own local // tracking record's workflow log. A "Release" / "completed" // entry only ever gets written by the portal's own Release // route, so its presence (or absence, with SAP nonetheless // showing Released) is what's checked here. // - ISSUE / RECEIPT: no UDF exists on InventoryGenExits/InventoryGenEntries // either, and DI API (needed to add one) isn't available on // this SAP install — so the portal instead tags the Comments // field of every Issue/Receipt document it posts with // "[Portal PWO#]" (see routes/sap.js). A document without // that tag was posted directly in SAP. // // NOTE — this only works going forward from when each tagging mechanism was // added: historical orders/documents posted before that have no tag either // way and will show as "Direct (SAP)" even if they went through the portal. 'use strict'; const express = require('express'); const router = express.Router(); const { verifyToken } = require('../middleware/auth'); const poStore = () => require('../services/productionOrderStore'); let _sapSvc = null; function getSap(){ if(!_sapSvc) _sapSvc = require('../services/sapServiceLayer'); return _sapSvc; } const cq = (req) => req.query?.company || req.body?.company || null; // Follows SAP's nextLink until exhausted or MAX_PAGES hit — no $top on the // initial request (confirmed elsewhere in this app: SAP treats an explicit // $top as a TOTAL cap across the whole nextLink chain here, not a per-page // size, silently truncating a large result set otherwise). async function fetchAllPaged(sap, co, entity, filter, select, orderByField = 'DocDate') { const out = []; let url = `${entity}?$filter=${encodeURIComponent(filter)}&$select=${select}&$orderby=${orderByField} desc`; const MAX_PAGES = 300; for (let p = 0; p < MAX_PAGES && url; p++) { const r = await sap.sapRequest('GET', url, null, co); out.push(...(r?.value || [])); url = r?.['odata.nextLink'] || r?.['@odata.nextLink'] || null; if (!r?.value?.length) break; } return out; } router.get('/report', verifyToken, async (req, res) => { const co = cq(req); const { from, to } = req.query; if (!from || !to) return res.status(400).json({ success: false, message: 'from and to dates are required' }); try { const sap = getSap(); // ProductionOrders has no "DocDate" field (confirmed live — filtering on // it 502s instead of a clean error) — PostingDate is its equivalent // "when created" field. const orders = await fetchAllPaged( sap, co, 'ProductionOrders', `PostingDate ge '${from}' and PostingDate le '${to}'`, 'AbsoluteEntry,DocumentNumber,ItemNo,ProductDescription,ProductionOrderStatus,PostingDate,U_ERP_SO_NO', 'PostingDate' ); if (!orders.length) return res.json({ success: true, data: [] }); // Issue/Receipt can, in principle, happen any time after an order is // created — but scanning with no upper bound turned out to be far too // heavy in practice (2000+ Goods Issue documents for a single month on // this install, ~100+ SAP pages, occasionally 502-ing SAP's own gateway // under the load). Scoped to the SAME [from,to] window as the orders // themselves instead — the known trade-off: an order created near the // END of the selected range whose Issue/Receipt happens AFTER `to` will // show that stage as "not reached yet" here even though it has been. // Widen `to` (or re-run with a later range) to see those. const genFilter = `DocDate ge '${from}' and DocDate le '${to}'`; const [genExits, genEntries] = await Promise.all([ fetchAllPaged(sap, co, 'InventoryGenExits', genFilter, 'DocEntry,DocNum,DocumentLines'), fetchAllPaged(sap, co, 'InventoryGenEntries', genFilter, 'DocEntry,DocNum,DocumentLines'), ]); const localRecs = await poStore().listProductionOrders({ company: co }); const localByEntry = {}; localRecs.forEach(p => { if (p.sapAbsEntry != null) localByEntry[p.sapAbsEntry] = p; }); // Portal-origin marker is now a genuine per-LINE UDF (IGE1/IGN1's own // U_ERP_SO_NO — set directly in routes/sap.js's creation payload, holding // the same local Production Order tracking ID as OWOR.U_ERP_SO_NO). // Checked per matching LINE, not per whole document, since one document // could in principle carry lines for more than one Production Order. function groupByBaseEntry(docs) { const map = {}; docs.forEach(d => { (d.DocumentLines || []).forEach(l => { if (l.BaseType === 202 && l.BaseEntry != null) (map[l.BaseEntry] = map[l.BaseEntry] || []).push(l); }); }); return map; } const issueByEntry = groupByBaseEntry(genExits); const receiptByEntry = groupByBaseEntry(genEntries); function sourceLabel(lines) { if (!lines || !lines.length) return null; // stage not reached yet const tagged = lines.filter(l => !!l.U_ERP_SO_NO).length; if (tagged === lines.length) return 'Portal'; if (tagged === 0) return 'Direct (SAP)'; return 'Mixed'; } const rows = orders.map(o => { const local = localByEntry[o.AbsoluteEntry]; const released = o.ProductionOrderStatus === 'boposReleased' || o.ProductionOrderStatus === 'boposClosed'; let releaseSource = null; if (released) { const hasPortalRelease = !!(local && Array.isArray(local.workflowLog) && local.workflowLog.some(l => l.step === 'Release' && l.action === 'completed')); releaseSource = hasPortalRelease ? 'Portal' : 'Direct (SAP)'; } return { absoluteEntry: o.AbsoluteEntry, docNum: o.DocumentNumber, itemCode: o.ItemNo, itemName: o.ProductDescription, status: (o.ProductionOrderStatus || '').replace('bopos', ''), creationSource: o.U_ERP_SO_NO ? 'Portal' : 'Direct (SAP)', releaseSource, issueSource: sourceLabel(issueByEntry[o.AbsoluteEntry]), receiptSource: sourceLabel(receiptByEntry[o.AbsoluteEntry]), }; }); res.json({ success: true, data: rows }); } catch (err) { res.status(400).json({ success: false, message: err.message }); } }); module.exports = router;