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 * Apr–Mar 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 (Apr–Mar): Jan–Mar belong to the prior year. */ export function fyStartYear(d: Date): number { return d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1; } /** "FY 2025–26" 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; // 0–11 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 { 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(); 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; childrenOf: (parentId: string | null) => AccountNode[]; leavesUnder: (id: string) => Set; 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(); 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>(); function leavesUnder(id: string): Set { const cached = leafCache.get(id); if (cached) return cached; const out = new Set(); 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 (Apr–Mar) 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(); 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(); 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 = { 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(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]; }