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>
236 lines
9 KiB
TypeScript
236 lines
9 KiB
TypeScript
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 (Apr–Mar)", () => {
|
||
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 Jan–Mar 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 Apr–Mar 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 2025–26");
|
||
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
|
||
});
|
||
});
|