pelagia-portal/App/tests/unit/reports.test.ts
Claude (review-bot) fd7b31e64d
All checks were successful
PR checks / checks (pull_request) Successful in 49s
PR checks / integration (pull_request) Successful in 32s
feat(reports): drill from cost centre / accounting code into PO History (#126)
Report detail pages now link to the underlying POs, addressing the PR #126
review comment: drilling into a cost centre or accounting code opens PO
History pre-filtered to that dimension and the period in view.

- Cost Centre / Accounting Code detail pages gain a "View POs" link.
- periodRange() maps the on-screen period onto History's approved-date
  window (weekly→month, monthly→FY, yearly→full span); spend is dated by
  approvedAt.
- PO History gains an accountId filter (any tree node, expanded to leaves
  via accountLeafIds()) matching PO-level OR line-item accounts — the same
  basis the reports use.
- History page + CSV/PDF export share one buildPoHistoryWhere() builder so
  they never diverge.
- Tests: unit (periodRange, accountLeafIds) + integration (History account
  filter across PO-level/line-item, with the approved window).

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

236 lines
9 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,
weekOfMonth,
buildAccountIndex,
costCentreRows,
costCentreWeekly,
accountNodeSpend,
accountNodeWeekly,
accountLevelRows,
topAccountsForCostCentre,
costCentresForAccount,
childBreakdown,
applyScope,
parseScope,
parseGranularity,
resolveFy,
resolveMonth,
parseSel,
toggleSel,
allocatePoSpend,
accountLeafIds,
periodRange,
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]. PO p1 is multi-account (rows on L1 + L2).
const DS: ReportDataset = {
vessels: [
{ id: "v1", code: "V1", name: "MV One" },
{ id: "v2", code: "V2", name: "MV Two" },
],
accounts: ACCOUNTS,
fys: [2024, 2025],
rows: [
{ poId: "p1", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 },
{ poId: "p2", vesselId: "v1", accountId: "L1", amount: 50, fy: 2025, month: 1, week: 1 },
{ poId: "p1", vesselId: "v1", accountId: "L2", amount: 30, fy: 2025, month: 0, week: 0 },
{ poId: "p3", vesselId: "v2", accountId: "L1", amount: 200, fy: 2024, month: 5, week: 0 },
{ poId: "p4", vesselId: "v2", accountId: "L2", amount: 70, fy: 2025, month: 11, week: 2 },
],
};
describe("accountLeafIds (report → PO drill-down)", () => {
const RAW = ACCOUNTS.map((a) => ({ id: a.id, parentId: a.parentId }));
it("expands a heading to every leaf underneath it", () => {
expect(accountLeafIds(RAW, "H").sort()).toEqual(["L1", "L2"]);
expect(accountLeafIds(RAW, "S").sort()).toEqual(["L1", "L2"]);
});
it("returns a leaf node as itself", () => {
expect(accountLeafIds(RAW, "L1")).toEqual(["L1"]);
});
it("returns [] for an unknown id", () => {
expect(accountLeafIds(RAW, "nope")).toEqual([]);
});
});
describe("periodRange (report → PO History approved window)", () => {
it("monthly → the whole selected FY (AprMar)", () => {
expect(periodRange("monthly", 2025, 0, [2024, 2025])).toEqual({
from: "2025-04-01",
to: "2026-03-31",
});
});
it("yearly → the full span of FYs in the dataset", () => {
expect(periodRange("yearly", 2025, 0, [2024, 2025])).toEqual({
from: "2024-04-01",
to: "2026-03-31",
});
});
it("weekly → the focused FY month (Apr=0)", () => {
expect(periodRange("weekly", 2025, 0, [2025])).toEqual({
from: "2025-04-01",
to: "2025-04-30",
});
});
it("weekly → a JanMar month rolls into the next calendar year", () => {
// FY-month index 9 = Jan, which belongs to calendar year fy+1.
expect(periodRange("weekly", 2025, 9, [2025])).toEqual({
from: "2026-01-01",
to: "2026-01-31",
});
});
});
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(2); // distinct POs (p1 is multi-account, + p2) — not row count
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("line-item account allocation (#3)", () => {
const po = { id: "po9", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 };
it("splits a PO proportionally across its line-item accounts, summing back to the PO total", () => {
const out = allocatePoSpend(po, [
{ accountId: "L1", amount: 30 },
{ accountId: "L2", amount: 90 },
]);
const byAcc = Object.fromEntries(out.map((r) => [r.accountId, r.amount]));
expect(byAcc["L1"]).toBeCloseTo(25); // 100 * 30/120
expect(byAcc["L2"]).toBeCloseTo(75); // 100 * 90/120
expect(out.reduce((s, r) => s + r.amount, 0)).toBeCloseTo(100);
expect(out.every((r) => r.poId === "po9" && r.vesselId === "v1")).toBe(true);
});
it("falls a line with no account back to the PO-level account", () => {
const out = allocatePoSpend(po, [{ accountId: null, amount: 10 }, { accountId: "L2", amount: 10 }]);
expect(Object.fromEntries(out.map((r) => [r.accountId, r.amount]))).toEqual({ L1: 50, L2: 50 });
});
it("puts the whole amount on the PO account when there are no line items", () => {
expect(allocatePoSpend(po, [])).toEqual([{ poId: "po9", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 }]);
});
});
describe("weekly buckets (#1)", () => {
const idx = buildAccountIndex(ACCOUNTS);
it("computes week-of-month from the day", () => {
expect(weekOfMonth(new Date(2025, 3, 1))).toBe(0);
expect(weekOfMonth(new Date(2025, 3, 8))).toBe(1);
expect(weekOfMonth(new Date(2025, 3, 29))).toBe(4);
});
it("buckets a month's spend into weeks for a cost centre and an account node", () => {
expect(costCentreWeekly(DS, "v1", 2025, 0)).toEqual([130, 0, 0, 0, 0]); // both month-0 rows in W1
expect(accountNodeWeekly(DS, idx, "H", 2025, 0)).toEqual([130, 0, 0, 0, 0]);
});
});
describe("custom selection (#2)", () => {
it("parses and de-dupes ?sel=", () => {
expect(parseSel("a,b,a, ,c")).toEqual(["a", "b", "c"]);
expect(parseSel(undefined)).toEqual([]);
});
it("toggles ids in and out", () => {
expect(toggleSel(["a", "b"], "a")).toEqual(["b"]);
expect(toggleSel(["a"], "b")).toEqual(["a", "b"]);
});
});
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
});
it("parses granularity (incl. weekly) and resolves the weekly month", () => {
expect(parseGranularity("weekly")).toBe("weekly");
expect(parseGranularity("yearly")).toBe("yearly");
expect(parseGranularity(undefined)).toBe("monthly");
expect(resolveMonth(DS, 2025, undefined)).toBe(11); // latest month with spend in FY2025
expect(resolveMonth(DS, 2025, "3")).toBe(3);
expect(resolveMonth(DS, 2025, "99")).toBe(11); // out of range → latest
});
});