pelagia-portal/App/lib/reports.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

271 lines
10 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 { db } from "@/lib/db";
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
/**
* Spend reporting (Reports → Purchasing). Aggregates approved purchase-order
* spend across two dimensions:
* • Cost centres — the PO's vessel (`PurchaseOrder.vesselId`).
* • Accounting codes — the self-referential `Account` tree (Heading →
* Sub-heading → Leaf); each PO's `accountId` is a leaf, rolled up to parents.
*
* "Spend" = a PO that has reached manager approval (`POST_APPROVAL_STATUSES`),
* dated by `approvedAt` and valued at the full `totalAmount` — the same
* definition the dashboard's spend tiles use. Financial year is the Indian
* AprMar year. The heavy lifting is a single query in `getReportDataset()`;
* everything below is pure functions over that dataset so they're unit-testable.
*/
export const FY_MONTHS = ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"] as const;
/** Indian FY start year for a date (AprMar): JanMar belong to the prior year. */
export function fyStartYear(d: Date): number {
return d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
}
/** "FY 202526" for start year 2025. */
export function fyLabel(start: number): string {
return `FY ${start}${String((start + 1) % 100).padStart(2, "0")}`;
}
/** Month index within the FY: Apr=0 … Mar=11. */
export function fyMonthIndex(d: Date): number {
return (d.getMonth() - 3 + 12) % 12;
}
export type Tier = "Heading" | "Sub-heading" | "Leaf";
export interface CostCentre {
id: string;
code: string;
name: string;
}
export interface AccountNode {
id: string;
code: string;
name: string;
parentId: string | null;
tier: Tier;
}
/** One row per spend PO. */
export interface SpendRow {
vesselId: string;
accountId: string;
amount: number;
fy: number;
month: number; // 011 within the FY
}
export interface ReportDataset {
rows: SpendRow[];
vessels: CostCentre[];
accounts: AccountNode[];
fys: number[]; // ascending FYs that have spend (falls back to the current FY)
}
/** Pull every approved PO and the cost-centre / accounting-code reference data. */
export async function getReportDataset(): Promise<ReportDataset> {
const [pos, vessels, accounts] = await Promise.all([
db.purchaseOrder.findMany({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null } },
select: { vesselId: true, accountId: true, totalAmount: true, approvedAt: true },
}),
db.vessel.findMany({ select: { id: true, code: true, name: true }, orderBy: { name: "asc" } }),
db.account.findMany({ select: { id: true, code: true, name: true, parentId: true } }),
]);
const childCount = new Map<string, number>();
for (const a of accounts) if (a.parentId) childCount.set(a.parentId, (childCount.get(a.parentId) ?? 0) + 1);
const accountNodes: AccountNode[] = accounts.map((a) => ({
id: a.id,
code: a.code,
name: a.name,
parentId: a.parentId,
tier: a.parentId === null ? "Heading" : (childCount.get(a.id) ?? 0) > 0 ? "Sub-heading" : "Leaf",
}));
const rows: SpendRow[] = [];
for (const po of pos) {
if (!po.approvedAt) continue;
rows.push({
vesselId: po.vesselId,
accountId: po.accountId,
amount: Number(po.totalAmount),
fy: fyStartYear(po.approvedAt),
month: fyMonthIndex(po.approvedAt),
});
}
const fySet = new Set(rows.map((r) => r.fy));
const fys = fySet.size ? [...fySet].sort((a, b) => a - b) : [fyStartYear(new Date())];
return { rows, vessels, accounts: accountNodes, fys };
}
// ── Account tree helpers ───────────────────────────────────────────────────
export interface AccountIndex {
byId: Map<string, AccountNode>;
childrenOf: (parentId: string | null) => AccountNode[];
leavesUnder: (id: string) => Set<string>;
isLeaf: (id: string) => boolean;
pathTo: (id: string) => AccountNode[];
}
export function buildAccountIndex(accounts: AccountNode[]): AccountIndex {
const byId = new Map(accounts.map((a) => [a.id, a]));
const kids = new Map<string | null, AccountNode[]>();
for (const a of accounts) {
const k = a.parentId;
if (!kids.has(k)) kids.set(k, []);
kids.get(k)!.push(a);
}
const childrenOf = (parentId: string | null) => kids.get(parentId) ?? [];
const isLeaf = (id: string) => childrenOf(id).length === 0;
const leafCache = new Map<string, Set<string>>();
function leavesUnder(id: string): Set<string> {
const cached = leafCache.get(id);
if (cached) return cached;
const out = new Set<string>();
const children = childrenOf(id);
if (children.length === 0) out.add(id);
else for (const c of children) for (const lf of leavesUnder(c.id)) out.add(lf);
leafCache.set(id, out);
return out;
}
function pathTo(id: string): AccountNode[] {
const node = byId.get(id);
if (!node) return [];
return node.parentId ? [...pathTo(node.parentId), node] : [node];
}
return { byId, childrenOf, leavesUnder, isLeaf, pathTo };
}
// ── Aggregations ───────────────────────────────────────────────────────────
export interface CostCentreSpend {
id: string;
code: string;
name: string;
total: number; // selected FY
months: number[]; // 12 (AprMar) of the selected FY
poCount: number; // selected FY
fyTotals: number[]; // aligned to ds.fys
}
export function costCentreRows(ds: ReportDataset, fy: number): CostCentreSpend[] {
const idx = new Map<string, CostCentreSpend>();
for (const v of ds.vessels) {
idx.set(v.id, { id: v.id, code: v.code, name: v.name, total: 0, months: Array(12).fill(0), poCount: 0, fyTotals: Array(ds.fys.length).fill(0) });
}
for (const r of ds.rows) {
const row = idx.get(r.vesselId);
if (!row) continue;
const fi = ds.fys.indexOf(r.fy);
if (fi >= 0) row.fyTotals[fi] += r.amount;
if (r.fy === fy) {
row.months[r.month] += r.amount;
row.total += r.amount;
row.poCount += 1;
}
}
return [...idx.values()];
}
/** Spend for an account node (rolls leaf descendants up) in a FY: total + 12 months + per-FY totals. */
export function accountNodeSpend(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number) {
const leaves = idx.leavesUnder(nodeId);
const months = Array(12).fill(0);
const fyTotals = Array(ds.fys.length).fill(0);
let total = 0;
let poCount = 0;
for (const r of ds.rows) {
if (!leaves.has(r.accountId)) continue;
const fi = ds.fys.indexOf(r.fy);
if (fi >= 0) fyTotals[fi] += r.amount;
if (r.fy === fy) {
months[r.month] += r.amount;
total += r.amount;
poCount += 1;
}
}
return { total, months, fyTotals, poCount };
}
export interface NodeSpend {
node: AccountNode;
total: number;
months: number[];
fyTotals: number[];
poCount: number;
}
/** The accounting-code nodes to compare at a drill level (children of `parentId`; null = top headings). */
export function accountLevelRows(ds: ReportDataset, idx: AccountIndex, parentId: string | null, fy: number): NodeSpend[] {
return idx.childrenOf(parentId).map((node) => ({ node, ...accountNodeSpend(ds, idx, node.id, fy) }));
}
export interface Breakdown {
id: string;
label: string;
value: number;
}
/** For a cost centre detail: spend on each accounting code of `tier`, this FY. */
export function topAccountsForCostCentre(ds: ReportDataset, idx: AccountIndex, vesselId: string, fy: number, tier: Tier): Breakdown[] {
return ds.accounts
.filter((a) => a.tier === tier)
.map((a) => {
const leaves = idx.leavesUnder(a.id);
let value = 0;
for (const r of ds.rows) if (r.fy === fy && r.vesselId === vesselId && leaves.has(r.accountId)) value += r.amount;
return { id: a.id, label: `${a.code} · ${a.name}`, value };
})
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
}
/** For an account-node detail: which cost centres drive its spend, this FY. */
export function costCentresForAccount(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
const leaves = idx.leavesUnder(nodeId);
const byVessel = new Map<string, number>();
for (const r of ds.rows) if (r.fy === fy && leaves.has(r.accountId)) byVessel.set(r.vesselId, (byVessel.get(r.vesselId) ?? 0) + r.amount);
return ds.vessels
.map((v) => ({ id: v.id, label: v.name, value: byVessel.get(v.id) ?? 0 }))
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
}
/** For a non-leaf account-node detail: spend split across its direct children, this FY. */
export function childBreakdown(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
return idx
.childrenOf(nodeId)
.map((c) => ({ id: c.id, label: `${c.code} · ${c.name}`, value: accountNodeSpend(ds, idx, c.id, fy).total }))
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
}
// ── Scope (Top N / Bottom N) ───────────────────────────────────────────────
export type ScopeMode = "top5" | "top10" | "bottom5" | "all";
export const SCOPE_LABELS: Record<ScopeMode, string> = { top5: "Top 5", top10: "Top 10", bottom5: "Bottom 5", all: "All" };
/** Apply a Top/Bottom-N scope to rows already sorted by spend descending. */
export function applyScope<T>(sortedDesc: T[], scope: ScopeMode): T[] {
if (scope === "top5") return sortedDesc.slice(0, 5);
if (scope === "top10") return sortedDesc.slice(0, 10);
if (scope === "bottom5") return sortedDesc.slice(-5).reverse();
return sortedDesc;
}
export function parseScope(v: string | undefined): ScopeMode {
return v === "top10" || v === "bottom5" || v === "all" ? v : "top5";
}
export type Granularity = "yearly" | "monthly";
export function parseGranularity(v: string | undefined): Granularity {
return v === "yearly" ? "yearly" : "monthly";
}
/** Resolve the selected FY from a query param against the available FYs (default: latest). */
export function resolveFy(ds: ReportDataset, v: string | undefined): number {
const n = v ? Number(v) : NaN;
if (Number.isFinite(n) && ds.fys.includes(n)) return n;
return ds.fys[ds.fys.length - 1];
}