first commit
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s

This commit is contained in:
John
2026-09-23 17:31:02 +05:30
commit 69b4e68baf
51657 changed files with 3864077 additions and 0 deletions
+295
View File
@@ -0,0 +1,295 @@
// 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: <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;