Files
sap-erp/routes/board.js
T
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

384 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// routes/board.js — Board Member analytics: company-growth summary numbers
// straight from the SAP company DB (net sales = AR Invoices − AR Credit
// Memos; purchases = AP Invoices). Read-only, one endpoint, gated to the
// board_member role (and admin).
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const appSettings = require('../services/appSettingsStore');
async function hana(sqlText) {
const pool = await getPool();
return (await pool.request().query(sqlText)).recordset || [];
}
// FG EQUIPMENT (item group 103) holds both blood-bag and CAPD machines. These
// two item codes are the CAPD ones; everything else in 103 is BB. Used to
// split "FG EQUIPMENT" into "FG EQUIPMENT BB" / "FG EQUIPMENT CAPD" in the
// Sales-by-Product-Group card and its multi-select filter.
const EQUIP_GRP = 103;
const CAPD_EQUIP = "'MEAPD20','m.CYCLER'";
// Resolves the effective Item Group filter for a request: the caller's own
// ?groups= selection, narrowed by (or defaulted to) the admin-configured
// restriction (System Settings → Company Growth Dashboard), which is always
// a HARD CEILING — applied even when the caller picked no filter of their
// own. Shared by every endpoint that needs to respect it (breakdown, monthly
// trend, …) so the restriction can never be bypassed by hitting a route that
// forgot to check it. `t1Alias`/`t2Alias` let callers match whatever line/
// OITM table aliases their own query already uses (T1/T2 in /breakdown, but
// /monthly's UNION query needs its own since it has 3 separate branches).
function resolveGroupFilter(req, t1Alias = 'T1', t2Alias = 'T2') {
let rawGroups = (req.query.groups || '').split(',').map(s => s.trim()).filter(Boolean);
const allowedGroups = appSettings.boardProductGroups();
if (allowedGroups.length) {
rawGroups = rawGroups.length ? rawGroups.filter(g => allowedGroups.includes(g)) : allowedGroups.slice();
}
const plainIds = rawGroups.filter(g => /^\d+$/.test(g)).map(Number).filter(n => n !== EQUIP_GRP);
const gClauses = [];
if (plainIds.length) gClauses.push(`${t2Alias}.ItmsGrpCod IN (${plainIds.join(',')})`);
if (rawGroups.includes(String(EQUIP_GRP))) gClauses.push(`${t2Alias}.ItmsGrpCod=${EQUIP_GRP}`);
if (rawGroups.includes('103bb')) gClauses.push(`(${t2Alias}.ItmsGrpCod=${EQUIP_GRP} AND ${t1Alias}.ItemCode NOT IN (${CAPD_EQUIP}))`);
if (rawGroups.includes('103capd')) gClauses.push(`(${t2Alias}.ItmsGrpCod=${EQUIP_GRP} AND ${t1Alias}.ItemCode IN (${CAPD_EQUIP}))`);
const hasGrpFilter = gClauses.length > 0;
return { rawGroups, hasGrpFilter, grpCond: hasGrpFilter ? ` AND (${gClauses.join(' OR ')})` : '' };
}
function requireBoard(req, res, next) {
const u = req.user || {};
if (['board_member', 'admin'].includes(u.role)) return next();
// Any role explicitly granted the Board Dashboard MODULE may view it too —
// the module checkbox is how admins hand out this dashboard.
if (Array.isArray(u.modules) && u.modules.includes('board-dashboard')) return next();
return res.status(403).json({ success: false, message: 'Board Dashboard access requires the Board Member role or the "Board Dashboard" module' });
}
router.get('/summary', verifyToken, requireBoard, async (req, res) => {
try {
// ── Monthly net sales & purchases, last 24 calendar months ───────────
const monthly = await hana(`
SELECT y, m, SUM(sales) AS sales, SUM(purch) AS purch FROM (
SELECT YEAR(DocDate) y, MONTH(DocDate) m, SUM(DocTotal-VatSum) sales, 0 purch
FROM OINV WHERE CANCELED='N' AND DocDate>=DATEADD(month,-24,GETDATE()) GROUP BY YEAR(DocDate),MONTH(DocDate)
UNION ALL
SELECT YEAR(DocDate), MONTH(DocDate), -SUM(DocTotal-VatSum), 0
FROM ORIN WHERE CANCELED='N' AND DocDate>=DATEADD(month,-24,GETDATE()) GROUP BY YEAR(DocDate),MONTH(DocDate)
UNION ALL
SELECT YEAR(DocDate), MONTH(DocDate), 0, SUM(DocTotal-VatSum)
FROM OPCH WHERE CANCELED='N' AND DocDate>=DATEADD(month,-24,GETDATE()) GROUP BY YEAR(DocDate),MONTH(DocDate)
) x GROUP BY y, m ORDER BY y, m`);
// ── Top customers & items, trailing 12 months ────────────────────────
const topCustomers = await hana(`
SELECT TOP 6 CardName AS name, SUM(DocTotal-VatSum) AS total
FROM OINV WHERE CANCELED='N' AND DocDate>=DATEADD(month,-12,GETDATE())
GROUP BY CardName ORDER BY total DESC`);
const topItems = await hana(`
SELECT TOP 6 T1.ItemCode AS code, MAX(T1.Dscription) AS name, SUM(T1.LineTotal) AS total
FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry
WHERE T0.CANCELED='N' AND T0.DocDate>=DATEADD(month,-12,GETDATE())
GROUP BY T1.ItemCode ORDER BY total DESC`);
// ── Headline KPIs ────────────────────────────────────────────────────
const kpi = (await hana(`
SELECT
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE()))
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE())) AS ytdSales,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE())-1 AND DocDate<=DATEADD(year,-1,GETDATE()))
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE())-1 AND DocDate<=DATEADD(year,-1,GETDATE())) AS lastYtdSales,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE()) AND MONTH(DocDate)=MONTH(GETDATE())) AS monthSales,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OPCH WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE())) AS ytdPurchases,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OINV WHERE DocStatus='O' AND CANCELED='N') AS openAR,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OPCH WHERE DocStatus='O' AND CANCELED='N') AS openAP,
(SELECT COUNT(*) FROM OWOR WHERE Status='R') AS openProdOrders,
(SELECT COUNT(DISTINCT CardCode) FROM OINV WHERE CANCELED='N' AND DocDate>=DATEADD(month,-12,GETDATE())) AS activeCustomers`))[0] || {};
res.json({ success: true, data: {
monthly: monthly.map(r => ({ y: r.y, m: r.m, sales: Number(r.sales) || 0, purchases: Number(r.purch) || 0 })),
topCustomers: topCustomers.map(r => ({ name: r.name, total: Number(r.total) || 0 })),
topItems: topItems.map(r => ({ code: r.code, name: r.name, total: Number(r.total) || 0 })),
kpi: {
ytdSales: Number(kpi.ytdSales) || 0,
lastYtdSales: Number(kpi.lastYtdSales) || 0,
monthSales: Number(kpi.monthSales) || 0,
ytdPurchases: Number(kpi.ytdPurchases) || 0,
openAR: Number(kpi.openAR) || 0,
openAP: Number(kpi.openAP) || 0,
openProdOrders: Number(kpi.openProdOrders) || 0,
activeCustomers:Number(kpi.activeCustomers) || 0,
},
}});
} catch (err) {
console.error('[BOARD] summary failed:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
// ── Monthly trend for a caller-chosen date range (defaults 24 months) ─────
// Respects the Product Groups filter (own ?groups= selection, narrowed by
// the admin-configured restriction) the same way /breakdown's KPI cards do —
// otherwise the trend chart would keep showing whole-company figures while
// every other card on the page is filtered, which is exactly the mismatch
// that was reported.
router.get('/monthly', verifyToken, requireBoard, async (req, res) => {
try {
const DS = /^\d{4}-\d{2}-\d{2}$/;
const from = DS.test(req.query.from || '') ? req.query.from : null;
const to = DS.test(req.query.to || '') ? req.query.to : null;
const { hasGrpFilter, grpCond } = resolveGroupFilter(req);
const monthly = hasGrpFilter ? await hana(`
SELECT y, m, SUM(sales) AS sales, SUM(purch) AS purch FROM (
SELECT YEAR(T0.DocDate) y, MONTH(T0.DocDate) m, SUM(T1.LineTotal) sales, 0 purch
FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
WHERE T0.CANCELED='N' AND ${(from && to) ? `T0.DocDate>='${from}' AND T0.DocDate<='${to}'` : `T0.DocDate>=DATEADD(month,-24,GETDATE())`}${grpCond}
GROUP BY YEAR(T0.DocDate),MONTH(T0.DocDate)
UNION ALL
SELECT YEAR(T0.DocDate), MONTH(T0.DocDate), -SUM(T1.LineTotal), 0
FROM RIN1 T1 JOIN ORIN T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
WHERE T0.CANCELED='N' AND ${(from && to) ? `T0.DocDate>='${from}' AND T0.DocDate<='${to}'` : `T0.DocDate>=DATEADD(month,-24,GETDATE())`}${grpCond}
GROUP BY YEAR(T0.DocDate),MONTH(T0.DocDate)
UNION ALL
SELECT YEAR(T0.DocDate), MONTH(T0.DocDate), 0, SUM(T1.LineTotal)
FROM PCH1 T1 JOIN OPCH T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
WHERE T0.CANCELED='N' AND ${(from && to) ? `T0.DocDate>='${from}' AND T0.DocDate<='${to}'` : `T0.DocDate>=DATEADD(month,-24,GETDATE())`}${grpCond}
GROUP BY YEAR(T0.DocDate),MONTH(T0.DocDate)
) x GROUP BY y, m ORDER BY y, m`)
: await hana(`
SELECT y, m, SUM(sales) AS sales, SUM(purch) AS purch FROM (
SELECT YEAR(DocDate) y, MONTH(DocDate) m, SUM(DocTotal-VatSum) sales, 0 purch
FROM OINV WHERE CANCELED='N' AND ${(from && to) ? `DocDate>='${from}' AND DocDate<='${to}'` : `DocDate>=DATEADD(month,-24,GETDATE())`} GROUP BY YEAR(DocDate),MONTH(DocDate)
UNION ALL
SELECT YEAR(DocDate), MONTH(DocDate), -SUM(DocTotal-VatSum), 0
FROM ORIN WHERE CANCELED='N' AND ${(from && to) ? `DocDate>='${from}' AND DocDate<='${to}'` : `DocDate>=DATEADD(month,-24,GETDATE())`} GROUP BY YEAR(DocDate),MONTH(DocDate)
UNION ALL
SELECT YEAR(DocDate), MONTH(DocDate), 0, SUM(DocTotal-VatSum)
FROM OPCH WHERE CANCELED='N' AND ${(from && to) ? `DocDate>='${from}' AND DocDate<='${to}'` : `DocDate>=DATEADD(month,-24,GETDATE())`} GROUP BY YEAR(DocDate),MONTH(DocDate)
) x GROUP BY y, m ORDER BY y, m`);
res.json({ success: true, data: monthly.map(r => ({ y: r.y, m: r.m, sales: Number(r.sales) || 0, purchases: Number(r.purch) || 0 })) });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Every Item Group SAP actually has items in, with the FG EQUIPMENT (103)
// split applied — the raw, UNRESTRICTED list. Shared by both routes below.
async function allGroups() {
const rows = await hana(`
SELECT T3.ItmsGrpCod AS code, T3.ItmsGrpNam AS name
FROM OITB T3
WHERE EXISTS (SELECT 1 FROM OITM T2 WHERE T2.ItmsGrpCod=T3.ItmsGrpCod)
ORDER BY T3.ItmsGrpNam`);
// Codes are strings; FG EQUIPMENT (103) becomes two virtual selectable
// entries so BB vs CAPD equipment can be filtered separately.
const data = [];
rows.forEach(r => {
if (Number(r.code) === EQUIP_GRP) {
data.push({ code: '103bb', name: 'FG EQUIPMENT BB' });
data.push({ code: '103capd', name: 'FG EQUIPMENT CAPD' });
} else {
data.push({ code: String(r.code), name: r.name });
}
});
return data;
}
// ── Item Group list for the multi-select filter — ADMIN-RESTRICTED ────────
// Admin → System Settings → "Company Growth Dashboard — Product Groups" can
// limit this to a chosen subset; empty configuration = no restriction (every
// group SAP has shows, as before).
router.get('/groups', verifyToken, requireBoard, async (req, res) => {
try {
const data = await allGroups();
const allowed = appSettings.boardProductGroups();
const restricted = allowed.length ? data.filter(g => allowed.includes(g.code)) : data;
// `total` = how many groups the company actually has, so the frontend can
// tell "All (5 of 32 groups)" apart from a genuinely unrestricted "All" —
// without exposing the unrestricted group NAMES themselves to a
// restricted viewer, just the count.
res.json({ success: true, data: restricted, total: data.length });
} catch (err) { res.status(500).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,
// not the already-restricted set — same gate as the dashboard, admin-only in
// practice since only admins reach the System Settings page).
router.get('/groups/all', verifyToken, requireBoard, async (req, res) => {
try { res.json({ success: true, data: await allGroups() }); }
catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── Filterable breakdown: top customers / products / product groups over a
// caller-chosen date range (defaults to trailing 12 months) ─────────────
router.get('/breakdown', verifyToken, requireBoard, async (req, res) => {
try {
const DS = /^\d{4}-\d{2}-\d{2}$/;
const iso = d => d.toISOString().slice(0, 10);
// Resolve to EXPLICIT dates (default trailing 12 months) so the previous
// equal-length comparison period can be computed for the growth badge.
let from = DS.test(req.query.from || '') ? req.query.from : null;
let to = DS.test(req.query.to || '') ? req.query.to : null;
if (!from || !to) {
const now = new Date(); to = iso(now);
const f = new Date(now); f.setMonth(f.getMonth() - 12); from = iso(f);
}
const fromD = new Date(from + 'T00:00:00Z'), toD = new Date(to + 'T00:00:00Z');
const lenDays = Math.max(1, Math.round((toD - fromD) / 86400000) + 1);
const prevTo = iso(new Date(fromD.getTime() - 86400000));
const prevFrom = iso(new Date(fromD.getTime() - lenDays * 86400000));
const cond = `T0.DocDate>='${from}' AND T0.DocDate<='${to}'`;
const condP = `DocDate>='${from}' AND DocDate<='${to}'`;
const condPrev = `DocDate>='${prevFrom}' AND DocDate<='${prevTo}'`;
// Optional multi-select Item Group filter (?groups=101,102,103bb,103capd),
// narrowed by (or defaulted to) the admin-configured restriction — see
// resolveGroupFilter(). Computed BEFORE the KPI query below so the KPI
// cards respect it too (not just the Top Customers/Items/Groups cards).
const { hasGrpFilter, grpCond } = resolveGroupFilter(req);
// Period KPIs — net sales/purchases/customers for the range + previous
// period. Two shapes: company-wide (header-level, cheap) when no group
// filter is active — same as before — or LINE-LEVEL (joined to the
// item's group, only lines in the selected groups count) when a filter
// is active, so the KPI cards agree with the Top Customers/Items/Groups
// cards instead of always showing the whole company regardless of the
// Product Groups filter.
// NOTE: Open AR / Open AP (outstanding, unpaid amount) are intentionally
// NEVER group-filtered — SAP tracks PaidToDate at the DOCUMENT level, not
// per line, so there's no sound way to allocate "how much of this
// invoice's unpaid balance belongs to which item group" without making
// up an allocation rule. They always show the whole company's
// outstanding balance for docs dated in the period.
const k = hasGrpFilter ? (await hana(`SELECT
(SELECT ISNULL(SUM(T1.LineTotal),0) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND ${cond}${grpCond})
- (SELECT ISNULL(SUM(T1.LineTotal),0) FROM RIN1 T1 JOIN ORIN T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND ${cond}${grpCond}) AS netSales,
(SELECT ISNULL(SUM(T1.LineTotal),0) FROM PCH1 T1 JOIN OPCH T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND ${cond}${grpCond}) AS purchases,
(SELECT COUNT(DISTINCT T0.CardCode) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND ${cond}${grpCond}) AS activeCustomers,
(SELECT COUNT(DISTINCT T0.DocEntry) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND ${cond}${grpCond}) AS invoices,
(SELECT ISNULL(SUM(T1.LineTotal),0) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.DocDate>='${prevFrom}' AND T0.DocDate<='${prevTo}'${grpCond})
- (SELECT ISNULL(SUM(T1.LineTotal),0) FROM RIN1 T1 JOIN ORIN T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.DocDate>='${prevFrom}' AND T0.DocDate<='${prevTo}'${grpCond}) AS prevNetSales,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OINV WHERE DocStatus='O' AND CANCELED='N' AND ${condP}) AS openAR,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OPCH WHERE DocStatus='O' AND CANCELED='N' AND ${condP}) AS openAP,
(SELECT ISNULL(SUM(T1.LineTotal),0) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.U_CustomerCategory='Direct Export' AND ${cond}${grpCond})
- (SELECT ISNULL(SUM(T1.LineTotal),0) FROM RIN1 T1 JOIN ORIN T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.U_CustomerCategory='Direct Export' AND ${cond}${grpCond}) AS exportDirect,
(SELECT ISNULL(SUM(T1.LineTotal),0) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.U_CustomerCategory='Indirect Export' AND ${cond}${grpCond})
- (SELECT ISNULL(SUM(T1.LineTotal),0) FROM RIN1 T1 JOIN ORIN T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.U_CustomerCategory='Indirect Export' AND ${cond}${grpCond}) AS exportIndirect`))[0] || {}
: (await hana(`SELECT
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND ${condP})
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND ${condP}) AS netSales,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OPCH WHERE CANCELED='N' AND ${condP}) AS purchases,
(SELECT COUNT(DISTINCT CardCode) FROM OINV WHERE CANCELED='N' AND ${condP}) AS activeCustomers,
(SELECT COUNT(*) FROM OINV WHERE CANCELED='N' AND ${condP}) AS invoices,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND ${condPrev})
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND ${condPrev}) AS prevNetSales,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OINV WHERE DocStatus='O' AND CANCELED='N' AND ${condP}) AS openAR,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OPCH WHERE DocStatus='O' AND CANCELED='N' AND ${condP}) AS openAP,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND U_CustomerCategory='Direct Export' AND ${condP})
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND U_CustomerCategory='Direct Export' AND ${condP}) AS exportDirect,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND U_CustomerCategory='Indirect Export' AND ${condP})
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND U_CustomerCategory='Indirect Export' AND ${condP}) AS exportIndirect`))[0] || {};
// Division Summary — EXACT same segment logic as the Sales Report
// (routes/reports.js buildRevSubq: G/L-account + item-subtype based,
// incl. excluded docs). Matches the management figures to the paisa.
// Now ALSO respects the Product Groups filter: buildRevSubq() is shared
// with routes/reports.js's Sales Report, so it's never touched directly —
// instead the group condition is appended into the `dateWhere` string
// buildRevSubq already splices verbatim into its own WHERE clause, using
// ITS aliases (t1=OITM, t2=OITB, lowercase — NOT the T1/T2 used by the
// rest of this route) so it resolves against columns actually in scope.
let divisions = null;
try {
const { buildRevSubq } = require('./reports');
const divGrp = resolveGroupFilter(req, 't1', 't2');
const dw = `i.DocDate BETWEEN '${from}' AND '${to}'${divGrp.grpCond}`;
const div = (await hana(`
SELECT ROUND(SUM(TM),2) TM, ROUND(SUM(PD),2) PD, ROUND(SUM(ED),2) ED, ROUND(SUM(EI),2) EI,
ROUND(SUM(TM)+SUM(PD)+SUM(ED)+SUM(EI),2) Total
FROM ( ${buildRevSubq(dw, false)} UNION ALL ${buildRevSubq(dw, true)} ) CK`))[0];
if (div) divisions = {
tm: Number(div.TM) || 0, pd: Number(div.PD) || 0,
ed: Number(div.ED) || 0, ei: Number(div.EI) || 0,
total: Number(div.Total) || 0,
};
} catch (e) { console.warn('[BOARD] division summary failed (non-fatal):', e.message); }
// Optional per-card search text (?custQ= / ?itemQ= / ?grpQ=) — searched
// rows are still ranked by sales; quote-escaped and length-capped.
const like = s => String(s || '').replace(/'/g, "''").slice(0, 60).trim();
const custQ = like(req.query.custQ), itemQ = like(req.query.itemQ), grpQ = like(req.query.grpQ);
const custCond = custQ ? ` AND T0.CardName LIKE '%${custQ}%'` : '';
const itemCond = itemQ ? ` AND (T1.ItemCode LIKE '%${itemQ}%' OR T1.Dscription LIKE '%${itemQ}%')` : '';
const grpNameCond = grpQ ? ` AND T3.ItmsGrpNam LIKE '%${grpQ}%'` : '';
// Top customers: header totals normally; when a group filter is active the
// sum has to be line-level (only lines of the selected groups count).
const topCustomers = hasGrpFilter
? await hana(`
SELECT TOP ${custQ?10:6} T0.CardName AS name, SUM(T1.LineTotal) AS total
FROM INV1 T1
JOIN OINV T0 ON T0.DocEntry=T1.DocEntry
JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
WHERE T0.CANCELED='N' AND ${cond}${grpCond}${custCond}
GROUP BY T0.CardName ORDER BY total DESC`)
: await hana(`
SELECT TOP ${custQ?10:6} T0.CardName AS name, SUM(T0.DocTotal-T0.VatSum) AS total
FROM OINV T0 WHERE T0.CANCELED='N' AND ${cond}${custCond}
GROUP BY T0.CardName ORDER BY total DESC`);
const topItems = await hana(`
SELECT TOP ${itemQ?10:6} T1.ItemCode AS code, MAX(T1.Dscription) AS name, SUM(T1.LineTotal) AS total
FROM INV1 T1
JOIN OINV T0 ON T0.DocEntry=T1.DocEntry
JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
WHERE T0.CANCELED='N' AND ${cond}${grpCond}${itemCond}
GROUP BY T1.ItemCode ORDER BY total DESC`);
// Product Groups — same hygiene as the Sales Report (net of credit notes,
// excluded docs, status condition) and SPLIT Domestic vs Export per group
// (export = the report's export revenue accounts), so the domestic part of
// FG BLOOD BAG reconciles with the Division Summary's Blood Bag (TM).
const { REV_CONST } = require('./reports');
const EXP_ACCTS = REV_CONST.EXCL_ED + ',' + REV_CONST.EXCL_EI;
const invExtras = ` AND T0.DocNum NOT IN (${REV_CONST.EXCL_DOCS})
AND (T3.ItmsGrpNam IN ('FG CAPD','FG CAPD Accessories') OR (T0.DocStatus<>'C' OR T0.InvntSttus='O'))`;
const grpLeg = (tbl, hdr, sign, extras) => `
SELECT CASE WHEN T2.ItmsGrpCod=${EQUIP_GRP} AND T1.ItemCode IN (${CAPD_EQUIP}) THEN 'FG EQUIPMENT CAPD'
WHEN T2.ItmsGrpCod=${EQUIP_GRP} THEN 'FG EQUIPMENT BB'
ELSE T3.ItmsGrpNam END AS name,
CASE WHEN T1.AcctCode IN (${EXP_ACCTS}) THEN 0 ELSE ${sign}T1.LineTotal END AS domestic,
CASE WHEN T1.AcctCode IN (${EXP_ACCTS}) THEN ${sign}T1.LineTotal ELSE 0 END AS export
FROM ${tbl} T1
JOIN ${hdr} T0 ON T0.DocEntry=T1.DocEntry
JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
JOIN OITB T3 ON T3.ItmsGrpCod=T2.ItmsGrpCod
WHERE T0.CANCELED='N' AND ${cond}${grpCond}${grpNameCond}${extras}`;
const topGroups = await hana(`
SELECT TOP ${grpQ?12:8} name, SUM(domestic) AS domestic, SUM(export) AS export, SUM(domestic)+SUM(export) AS total
FROM ( ${grpLeg('INV1','OINV','',invExtras)} UNION ALL ${grpLeg('RIN1','ORIN','-','')} ) x
GROUP BY name ORDER BY total DESC`);
res.json({ success: true, data: {
from, to, prevFrom, prevTo,
divisions,
kpi: {
netSales: Number(k.netSales) || 0,
purchases: Number(k.purchases) || 0,
activeCustomers: Number(k.activeCustomers) || 0,
invoices: Number(k.invoices) || 0,
prevNetSales: Number(k.prevNetSales) || 0,
openAR: Number(k.openAR) || 0,
openAP: Number(k.openAP) || 0,
exportDirect: Number(k.exportDirect) || 0,
exportIndirect: Number(k.exportIndirect) || 0,
},
topCustomers: topCustomers.map(r => ({ name: r.name, total: Number(r.total) || 0 })),
topItems: topItems.map(r => ({ code: r.code, name: r.name, total: Number(r.total) || 0 })),
topGroups: topGroups.map(r => ({ name: r.name, domestic: Number(r.domestic) || 0, export: Number(r.export) || 0, total: Number(r.total) || 0 })),
}});
} catch (err) {
console.error('[BOARD] breakdown failed:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;