pelagia-portal/App/tests/unit/reports.test.ts
Hardik 47ac2c7813
All checks were successful
PR checks / checks (pull_request) Successful in 48s
PR checks / integration (pull_request) Successful in 31s
feat(reports): weekly granularity, custom compare, line-item allocation
Picks up the three pieces deferred from the initial reports PR:

#3 Line-item account allocation — allocatePoSpend() splits each PO across the
accounting codes its line items carry (line accountId, falling back to the
PO-level account), proportionally so per-PO rows sum back to totalAmount. The
accounting-code report now attributes multi-account POs correctly. SpendRow
gains poId; poCount is now distinct POs, not row count.

#2 Custom "Add to graph" — tick rows on either index (SelectCheckbox links
write ?sel=id1,id2), then "Compare selected" (?cmp=1) shows a custom comparison
of just those entities. Fully server-rendered + shareable; export honours sel.

#1 Weekly granularity — a third Granularity that focuses one FY month and
buckets spend by week-of-month (W1–W5) from approvedAt, with a Month picker in
the toolbar. Real buckets (not the mockup's synthetic split).

All three are URL-driven like the rest, so no client fetching. Charts/KPIs/
detail trends all branch on the new mode.

Tests: +8 unit cases (allocation proportional/fallback/empty, weekly buckets,
sel parse/toggle, month + granularity parsing); fixture updated for poId/week.
Full unit suite 311 green; tsc clean. Smoke-tested weekly + custom-compare +
exports end-to-end (all 200). Docs + wiki updated to mark them implemented.

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

191 lines
7.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,
weekOfMonth,
buildAccountIndex,
costCentreRows,
costCentreWeekly,
accountNodeSpend,
accountNodeWeekly,
accountLevelRows,
topAccountsForCostCentre,
costCentresForAccount,
childBreakdown,
applyScope,
parseScope,
parseGranularity,
resolveFy,
resolveMonth,
parseSel,
toggleSel,
allocatePoSpend,
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("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
});
});