pelagia-portal/App/tests/unit/reports.test.ts
Hardik 8c6bbd8304
All checks were successful
PR checks / checks (pull_request) Successful in 46s
PR checks / integration (pull_request) Successful in 31s
feat(reports): Purchasing spend analytics (Cost Centres + Accounting Codes)
Implements the wiki "Reports Mockup" as a Reports → Purchasing sidebar section,
wired to real approved-PO spend.

Two report families, each index → drill/detail:
- Cost Centres (/reports/cost-centres) — spend compared across vessels; row
  opens a cost-centre report with a Top-accounting-codes breakdown re-pivotable
  by tier (Heading/Sub/Leaf) + Top-N.
- Accounting Codes (/reports/accounting-codes) — drills the Account tree
  (headings → sub → leaves) via ?parent=; a leaf opens its report broken down by
  cost centre (or, for a non-leaf, by sub-account).

Shared: a pinned filter toolbar (Granularity Monthly/Yearly, Financial Year,
Show Top5/Top10/Bottom5/All) whose values live in the URL query so the server
component re-renders — no client fetching. KPI tiles, recharts comparison/trend/
breakdown charts, per-row trend sparklines, and CSV export (/api/reports/spend).

- lib/reports.ts: the pure, unit-tested aggregation core. Spend = a PO once it
  reaches POST_APPROVAL_STATUSES, dated by approvedAt, valued at totalAmount
  (the dashboard's basis); Indian Apr–Mar FY; each PO's leaf accountId rolled up
  to parents. One query in getReportDataset(), everything else pure.
- Sidebar: new collapsible "Reports" section with a "Purchasing" subheading
  (subgroup support added to the Section model). Gated by view_analytics
  (Manager/SuperUser/Auditor/Admin); export by the same.

Deferred (documented): synthetic Weekly granularity, the "Add to graph" custom
multi-select, and line-item-level account allocation (v1 uses the PO-level
account). Sites are not cost centres — only vessels.

Tests: 11 unit cases for the aggregation core + 3 sidebar cases for the Reports
section. Full unit suite 303 green; tsc clean. Smoke-tested all routes end to
end against seed data (index/drill/detail/export 200; non-analytics role 307/403).

Wiki: "Reports Mockup" marked implemented; "Pages and Navigation" lists the new
routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:52:23 +05:30

129 lines
4.6 KiB
TypeScript
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.

import { describe, it, expect } from "vitest";
import {
fyStartYear,
fyLabel,
fyMonthIndex,
buildAccountIndex,
costCentreRows,
accountNodeSpend,
accountLevelRows,
topAccountsForCostCentre,
costCentresForAccount,
childBreakdown,
applyScope,
parseScope,
resolveFy,
type ReportDataset,
type AccountNode,
} from "@/lib/reports";
const ACCOUNTS: AccountNode[] = [
{ id: "H", code: "5000", name: "Operating", parentId: null, tier: "Heading" },
{ id: "S", code: "5100", name: "Vessel Running", parentId: "H", tier: "Sub-heading" },
{ id: "L1", code: "5110", name: "Fuel", parentId: "S", tier: "Leaf" },
{ id: "L2", code: "5120", name: "Spares", parentId: "S", tier: "Leaf" },
];
// fys ascending: [2024, 2025]
const DS: ReportDataset = {
vessels: [
{ id: "v1", code: "V1", name: "MV One" },
{ id: "v2", code: "V2", name: "MV Two" },
],
accounts: ACCOUNTS,
fys: [2024, 2025],
rows: [
{ vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0 },
{ vesselId: "v1", accountId: "L1", amount: 50, fy: 2025, month: 1 },
{ vesselId: "v1", accountId: "L2", amount: 30, fy: 2025, month: 0 },
{ vesselId: "v2", accountId: "L1", amount: 200, fy: 2024, month: 5 },
{ vesselId: "v2", accountId: "L2", amount: 70, fy: 2025, month: 11 },
],
};
describe("financial-year helpers", () => {
it("maps AprMar to the Indian FY start year", () => {
expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr
expect(fyStartYear(new Date(2025, 0, 15))).toBe(2024); // Jan → prior FY
expect(fyStartYear(new Date(2025, 2, 31))).toBe(2024); // Mar → prior FY
});
it("labels and indexes months within the FY", () => {
expect(fyLabel(2025)).toBe("FY 202526");
expect(fyMonthIndex(new Date(2025, 3, 1))).toBe(0); // Apr
expect(fyMonthIndex(new Date(2026, 2, 1))).toBe(11); // Mar
});
});
describe("costCentreRows", () => {
it("totals the selected FY by vessel with a 12-month series and PO count", () => {
const rows = costCentreRows(DS, 2025);
const v1 = rows.find((r) => r.id === "v1")!;
expect(v1.total).toBe(180);
expect(v1.months[0]).toBe(130); // 100 + 30
expect(v1.months[1]).toBe(50);
expect(v1.poCount).toBe(3);
expect(v1.fyTotals).toEqual([0, 180]); // [2024, 2025]
const v2 = rows.find((r) => r.id === "v2")!;
expect(v2.total).toBe(70);
expect(v2.fyTotals).toEqual([200, 70]);
});
});
describe("accounting-code rollup", () => {
const idx = buildAccountIndex(ACCOUNTS);
it("rolls leaf spend up to the heading", () => {
expect(accountNodeSpend(DS, idx, "H", 2025).total).toBe(250); // 100+50+30+70
expect(accountNodeSpend(DS, idx, "L1", 2025).total).toBe(150);
});
it("lists the children to compare at a drill level", () => {
const top = accountLevelRows(DS, idx, null, 2025); // headings
expect(top.map((r) => r.node.id)).toEqual(["H"]);
const subs = accountLevelRows(DS, idx, "H", 2025);
expect(subs.map((r) => r.node.id)).toEqual(["S"]);
});
it("leaf detection and leaf set", () => {
expect(idx.isLeaf("L1")).toBe(true);
expect(idx.isLeaf("H")).toBe(false);
expect([...idx.leavesUnder("H")].sort()).toEqual(["L1", "L2"]);
});
});
describe("breakdowns", () => {
const idx = buildAccountIndex(ACCOUNTS);
it("top accounting codes for a cost centre (by tier)", () => {
const bd = topAccountsForCostCentre(DS, idx, "v1", 2025, "Leaf");
expect(bd.map((b) => [b.id, b.value])).toEqual([
["L1", 150],
["L2", 30],
]);
});
it("cost centres for an account node", () => {
const bd = costCentresForAccount(DS, idx, "H", 2025);
expect(bd.map((b) => [b.id, b.value])).toEqual([
["v1", 180],
["v2", 70],
]);
});
it("child breakdown of a non-leaf node", () => {
const bd = childBreakdown(DS, idx, "H", 2025);
expect(bd).toEqual([{ id: "S", label: "5100 · Vessel Running", value: 250 }]);
});
});
describe("scope + param parsing", () => {
it("applies Top/Bottom-N to a descending list", () => {
const sorted = [5, 4, 3, 2, 1];
expect(applyScope(sorted, "top5")).toEqual([5, 4, 3, 2, 1]);
expect(applyScope([9, 8, 7, 6, 5, 4, 3, 2, 1, 0, -1], "top10")).toHaveLength(10);
expect(applyScope(sorted, "bottom5")).toEqual([1, 2, 3, 4, 5]);
expect(applyScope(sorted, "all")).toEqual(sorted);
});
it("parses scope + resolves FY with sensible defaults", () => {
expect(parseScope("top10")).toBe("top10");
expect(parseScope("garbage")).toBe("top5");
expect(resolveFy(DS, "2024")).toBe(2024);
expect(resolveFy(DS, undefined)).toBe(2025); // latest
expect(resolveFy(DS, "1999")).toBe(2025); // out of range → latest
});
});