296 lines
17 KiB
JavaScript
296 lines
17 KiB
JavaScript
// 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;
|