384 lines
25 KiB
JavaScript
384 lines
25 KiB
JavaScript
'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;
|