// routes/ppc.js // PPC (Production Planning & Control) report — one row per OPEN Sales // Order, pivoted so each distinct Item Code appearing in the filtered // result set becomes its own column showing that order's still-PENDING // (undelivered/uninvoiced — RemainingOpenQuantity) quantity for that item. // Pulled entirely from SAP B1 Service Layer (Orders + Items + // BusinessPartners), per explicit requirement — no direct SQL against SAP's // own database, unlike most other report pages in this app. // NOTE: this deliberately does NOT look at Invoices at all — an invoiced // line has nothing left pending by definition, so it's excluded already by // only ever reading lines with RemainingOpenQuantity > 0. 'use strict'; const express = require('express'); const router = express.Router(); const crypto = require('crypto'); const { verifyToken } = require('../middleware/auth'); const appSettings = require('../services/appSettingsStore'); // Lets an EXTERNAL app (no portal login) call the report routes below with a // dedicated key instead of a JWT — header "X-API-Key: " (or ?apiKey= // query param, for tools that can't set custom headers, e.g. some Power BI/ // Excel connectors). The key is set in Admin → System Settings → "PPC // Report" and is empty by default, meaning external access is OFF until an // admin turns it on. A normal portal Bearer token still works exactly as // before — this only ADDS a second way in, never removes the first. function timingSafeEqual(a, b) { const bufA = Buffer.from(String(a)); const bufB = Buffer.from(String(b)); if (bufA.length !== bufB.length) return false; return crypto.timingSafeEqual(bufA, bufB); } function verifyApiKeyOrToken(req, res, next) { const configured = appSettings.ppcApiKey(); const supplied = req.headers['x-api-key'] || req.query.apiKey; if (configured && supplied && timingSafeEqual(supplied, configured)) { req.user = { username: 'ppc-external-api', role: 'api' }; return next(); } return verifyToken(req, res, next); } let _sapSvc = null; function getSap(){ if(!_sapSvc) _sapSvc = require('../services/sapServiceLayer'); return _sapSvc; } const cq = (req) => req.query?.company || req.body?.company || null; // Warehouse code -> name, cached per company for the life of the server // (this list changes essentially never) — used to label the warehouse-wise // stock breakdown shown on hover over a Current Stock cell. const _warehouseNameCache = {}; async function getWarehouseNames(sap, co) { const key = co || ''; if (_warehouseNameCache[key]) return _warehouseNameCache[key]; try { const r = await sap.sapRequest('GET', 'Warehouses?$select=WarehouseCode,WarehouseName', null, co); const map = {}; (r?.value || []).forEach(w => { map[w.WarehouseCode] = w.WarehouseName || w.WarehouseCode; }); _warehouseNameCache[key] = map; return map; } catch (_e) { return {}; } } // OR-batch a set of values into a Service Layer $filter clause, e.g. // batchFilter('ItemCode', ['A','B']) -> "(ItemCode eq 'A' or ItemCode eq 'B')". // Chunked to keep each request's URL length sane. // SAP Service Layer silently truncates/fails an OR'd $filter batch once it // gets too large (confirmed live: 30-40 DocEntry conditions dropped over a // third of results with no error — 10 was reliable). Kept conservative for // every batched lookup below, not just the one it was caught on. const BATCH_SIZE = 10; function chunk(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; } function orFilter(field, values) { return `(${values.map(v => `${field} eq '${String(v).replace(/'/g, "''")}'`).join(' or ')})`; } // ════════════════════════════════════════════════════════════════ // PPC PENDING ORDER REPORT → GET /api/ppc/invoice-report // (path kept for backward compat with the already-shipped frontend build; // the DATA is now open Sales Orders, not Invoices — see file header.) // Query: company, from (YYYY-MM-DD), to (YYYY-MM-DD), itemGroup (numeric // ItemsGroupCode, optional), salesType ('Trade'|'Institute'|'Institute Ind', optional), // customerType ('DOMESTIC'|'EXPORT', optional) // ════════════════════════════════════════════════════════════════ router.get('/invoice-report', verifyApiKeyOrToken, async (req, res) => { const co = cq(req); const { from, to, itemGroup, salesType, customerType } = req.query; try { const sap = getSap(); // ── 1. Pull matching Sales Orders (paginated) — Date range is OPTIONAL: // given, it narrows by DocDate as before; left blank, every open // order shows regardless of when it was raised. Header // DocumentStatus eq 'bost_Open' is always applied though — a // Closed header can never have a pending line, so this is a pure // efficiency filter (skips years of fully-fulfilled history) // that never drops a genuinely pending line. ──────────────────── // Customer Type is NOT filtered via U_CustomerType here — that field is // unreliably populated (blank on plenty of real orders). Confirmed // convention instead: an order counts as Domestic when U_WEB_SO_NO has a // value, Export when U_WEB_SO_NO_EX does — derived per-order below and // filtered on AFTER fetching (see customerTypeFor()). const filters = [`DocumentStatus eq 'bost_Open'`]; if (from) filters.push(`DocDate ge '${from}'`); if (to) filters.push(`DocDate le '${to}'`); if (salesType) filters.push(`U_SalesType eq '${String(salesType).replace(/'/g, "''")}'`); const filterStr = encodeURIComponent(filters.join(' and ')); const select = encodeURIComponent('DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,U_SalesType,U_CustomerType,U_WEB_SO_NO,U_WEB_SO_NO_EX,DocumentLines'); const orders = []; // NOTE: no $top here on purpose — confirmed live that passing $top made // SAP Service Layer treat it as a TOTAL cap across the whole nextLink // chain (silently stopping once that many results had been returned in // total, each page's own $top counting DOWN — 200,180,160…), not a // per-page size. That silently dropped genuine older still-open orders // once a date range had more than 200 matches. Omitting $top lets SAP // use its own default page size and nextLink keeps going for as long as // results exist. let url = `Orders?$filter=${filterStr}&$select=${select}&$orderby=DocDate desc`; const MAX_PAGES = 500; // safety cap — ~20/page => up to ~10,000 orders per run for (let page = 0; page < MAX_PAGES && url; page++) { const result = await sap.sapRequest('GET', url, null, co); orders.push(...(result?.value || [])); url = result?.['odata.nextLink'] || result?.['@odata.nextLink'] || null; if (!result?.value?.length) break; } if (!orders.length) return res.json({ success: true, data: { itemCodes: [], rows: [] } }); // ── 2. Resolve item groups + current stock for every distinct item // code on these orders (group only needed for the Item Group // filter). Stock is warehouse-specific, not the item's blanket // QuantityOnStock across every warehouse (QA/R&D/Quarantine/etc. // aren't stock actually available to ship) — confirmed live: // Warehouse 1A = "FG DOMESTIC", 1B = "FG EXPORT", the only two // that matter for "what can I ship a customer". Service Layer has // no nested $select on ItemWarehouseInfoCollection (tried, SAP // rejects it), so the full per-warehouse collection comes back and // is filtered down to just those two codes here. ──────────────── const STOCK_WAREHOUSES = { '1A': 'stock1A', '1B': 'stock1B' }; const warehouseNames = await getWarehouseNames(sap, co); const allCodes = [...new Set(orders.flatMap(o => (o.DocumentLines || []).map(l => l.ItemCode).filter(Boolean)))]; let itemMeta = {}; // ItemCode -> { name, group, stock1A, stock1B, byWarehouse } for (const part of chunk(allCodes, BATCH_SIZE)) { try { const r = await sap.sapRequest('GET', `Items?$filter=${encodeURIComponent(orFilter('ItemCode', part))}&$select=ItemCode,ItemName,ItemsGroupCode,ItemWarehouseInfoCollection&$top=${part.length}`, null, co); (r?.value || []).forEach(it => { const meta = { name: it.ItemName || it.ItemCode, group: it.ItemsGroupCode, stock1A: 0, stock1B: 0, byWarehouse: [] }; (it.ItemWarehouseInfoCollection || []).forEach(w => { const key = STOCK_WAREHOUSES[w.WarehouseCode]; const qty = Number(w.InStock) || 0; if (key) meta[key] = qty; // Every warehouse with nonzero stock — full breakdown for the // "warehouse-wise stock" hover tooltip (not just 1A/1B). if (qty) meta.byWarehouse.push({ code: w.WarehouseCode, name: warehouseNames[w.WarehouseCode] || w.WarehouseCode, qty }); }); meta.byWarehouse.sort((a, b) => a.code.localeCompare(b.code)); itemMeta[it.ItemCode] = meta; }); } catch (_e) { /* best-effort — missing metadata just falls back to code-as-name, no group filter match, 0 stock */ } } // Hard ceiling — Admin → System Settings → "PPC Report Item Groups" // (appSettings.ppcItemGroups()). When configured, results can NEVER // include a group outside that list, regardless of what's requested: // no itemGroup picked -> restrict to the WHOLE allowed set; itemGroup // picked but outside it -> empty set (matches nothing), never silently // widened. Mirrors routes/board.js's resolveGroupFilter(). const allowedGroups = appSettings.ppcItemGroups().map(Number).filter(n => !isNaN(n)); let activeGroups = null; // null = no restriction at all if (allowedGroups.length) { activeGroups = new Set(itemGroup != null && itemGroup !== '' ? allowedGroups.filter(g => g === parseInt(itemGroup)) : allowedGroups); } else if (itemGroup != null && itemGroup !== '') { activeGroups = new Set([parseInt(itemGroup)]); } // ── 3. Resolve City for every distinct customer ───────────────────── const cardCodes = [...new Set(orders.map(o => o.CardCode).filter(Boolean))]; let cityByCard = {}; for (const part of chunk(cardCodes, BATCH_SIZE)) { try { const r = await sap.sapRequest('GET', `BusinessPartners?$filter=${encodeURIComponent(orFilter('CardCode', part))}&$select=CardCode,City&$top=${part.length}`, null, co); (r?.value || []).forEach(bp => { cityByCard[bp.CardCode] = bp.City || ''; }); } catch (_e) { /* non-fatal — City just shows blank */ } } // ── 4. Build rows + the set of item-code columns actually used — // ONLY lines that still have something PENDING count: SAP tracks // per-line RemainingOpenQuantity (confirmed live — NOT the // "OpenQuantity" property name one might expect) and LineStatus. // A line that's fully delivered/invoiced has RemainingOpenQuantity // 0 and LineStatus bost_Closed — excluded here so a fully-filled // order contributes nothing (this IS what makes it a "pending" // report instead of a plain order-quantity dump). ────────────── // Confirmed convention: an order counts as Domestic when U_WEB_SO_NO has // a value, Export when U_WEB_SO_NO_EX does — takes priority over the // (unreliable) U_CustomerType UDF, which is only a fallback here. function customerTypeFor(o) { if (o.U_WEB_SO_NO) return 'DOMESTIC'; if (o.U_WEB_SO_NO_EX) return 'EXPORT'; return o.U_CustomerType || ''; } const usedCodes = new Set(); const rows = []; orders.forEach((o) => { const orderCustType = customerTypeFor(o); if (customerType && orderCustType !== customerType) return; // Customer Type filter, applied post-fetch (see above) const qtyByItem = {}; let any = false; (o.DocumentLines || []).forEach(l => { const code = l.ItemCode; if (!code) return; const pending = Number(l.RemainingOpenQuantity) || 0; if (pending <= 0 || l.LineStatus !== 'bost_Open') return; const meta = itemMeta[code]; if (activeGroups && (!meta || !activeGroups.has(meta.group))) return; // outside the selected/allowed Item Group(s) qtyByItem[code] = (qtyByItem[code] || 0) + pending; usedCodes.add(code); any = true; }); if (!any) return; // nothing left pending on this order (or Item Group filter excluded it all) rows.push({ srNo: rows.length + 1, docEntry: o.DocEntry, salesOrderNo: o.DocNum, webSoNo: o.U_WEB_SO_NO || null, webSoNoEx: o.U_WEB_SO_NO_EX || null, cardCode: o.CardCode, cardName: o.CardName, city: cityByCard[o.CardCode] || '', orderType: o.U_SalesType || '', customerType: orderCustType, dispatchDate: o.DocDueDate || '', qtyByItem, }); }); const itemCodes = [...usedCodes].sort().map(code => ({ code, name: (itemMeta[code] && itemMeta[code].name) || code, stock1A: (itemMeta[code] && itemMeta[code].stock1A) || 0, stock1B: (itemMeta[code] && itemMeta[code].stock1B) || 0, byWarehouse: (itemMeta[code] && itemMeta[code].byWarehouse) || [], })); res.json({ success: true, data: { itemCodes, rows } }); } catch (err) { console.error('[PPC] invoice-report failed:', err.message); res.status(400).json({ success: false, message: err.message }); } }); // ════════════════════════════════════════════════════════════════ // ITEM GROUPS FOR THE FILTER → GET /api/ppc/item-groups // Restricted to Admin → System Settings → "PPC Report Item Groups" // (appSettings.ppcItemGroups()) when configured — e.g. just the FG groups // (BB, CAPD, Equipment, …), not every raw-material/packing group SAP has. // Empty setting = unrestricted (every group SAP has). // ════════════════════════════════════════════════════════════════ router.get('/item-groups', verifyApiKeyOrToken, async (req, res) => { const co = req.query.company || null; try { const r = await getSap().sapRequest('GET', 'ItemGroups?$select=Number,GroupName&$orderby=GroupName', null, co); const all = (r?.value || []).map(g => ({ code: g.Number, name: g.GroupName || String(g.Number) })); const allowed = appSettings.ppcItemGroups().map(Number); const restricted = allowed.length ? all.filter(g => allowed.includes(g.code)) : all; res.json({ success: true, data: restricted }); } catch (err) { res.status(400).json({ success: false, message: err.message }); } }); // ── UNRESTRICTED group list — for the admin settings checklist itself (an // admin configuring the restriction must see every group to choose from). router.get('/item-groups/all', verifyToken, async (req, res) => { const co = req.query.company || null; try { const r = await getSap().sapRequest('GET', 'ItemGroups?$select=Number,GroupName&$orderby=GroupName', null, co); res.json({ success: true, data: (r?.value || []).map(g => ({ code: g.Number, name: g.GroupName || String(g.Number) })) }); } catch (err) { res.status(400).json({ success: false, message: err.message }); } }); // ════════════════════════════════════════════════════════════════ // ORDER TYPES FOR THE FILTER → GET /api/ppc/order-types // Admin → System Settings → "PPC Report" → Order Types // (appSettings.ppcOrderTypes()) — SAP's U_SalesType is free text, not a // fixed picklist, so this list is admin-maintained here instead of // hardcoded in ppc-report.html; a new value used in SAP just needs adding // here, no code change. // ════════════════════════════════════════════════════════════════ router.get('/order-types', verifyApiKeyOrToken, async (req, res) => { res.json({ success: true, data: appSettings.ppcOrderTypes() }); }); module.exports = router;