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; } /** Week-of-month bucket: 0–4 (W1–W5) from the day of the month. */ export function weekOfMonth(d: Date): number { return Math.min(4, Math.floor((d.getDate() - 1) / 7)); } export const WEEK_LABELS = ["W1", "W2", "W3", "W4", "W5"] as const; 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 (PO, accounting code). Multi-account POs yield several rows. */ export interface SpendRow { poId: string; vesselId: string; accountId: string; amount: number; fy: number; month: number; // 0–11 within the FY (Apr=0) week: number; // 0–4 within the calendar month } /** * Split a PO's spend across the accounting codes its line items carry, so the * accounting-code report attributes multi-account POs correctly. The PO's * `totalAmount` is allocated **proportionally** to each line's account share * (line `accountId`, falling back to the PO-level account), so the per-PO rows * always sum back to `totalAmount` exactly. With no line items (or zero line * value) the whole amount lands on the PO-level account. */ export function allocatePoSpend( po: { id: string; vesselId: string; accountId: string; amount: number; fy: number; month: number; week: number }, lines: { accountId: string | null; amount: number }[] ): SpendRow[] { const base = { poId: po.id, vesselId: po.vesselId, fy: po.fy, month: po.month, week: po.week }; const byAccount = new Map(); let lineTotal = 0; for (const l of lines) { const key = l.accountId ?? po.accountId; byAccount.set(key, (byAccount.get(key) ?? 0) + l.amount); lineTotal += l.amount; } if (byAccount.size === 0 || lineTotal <= 0) { return [{ ...base, accountId: po.accountId, amount: po.amount }]; } return [...byAccount.entries()].map(([accountId, share]) => ({ ...base, accountId, amount: po.amount * (share / lineTotal) })); } 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: { id: true, vesselId: true, accountId: true, totalAmount: true, approvedAt: true, lineItems: { select: { accountId: true, totalPrice: 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; const meta = { id: po.id, vesselId: po.vesselId, accountId: po.accountId, amount: Number(po.totalAmount), fy: fyStartYear(po.approvedAt), month: fyMonthIndex(po.approvedAt), week: weekOfMonth(po.approvedAt), }; rows.push(...allocatePoSpend(meta, po.lineItems.map((l) => ({ accountId: l.accountId, amount: Number(l.totalPrice) })))); } 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(); const poSets = new Map>(); // distinct POs per vessel in the selected FY 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) }); poSets.set(v.id, new Set()); } 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; poSets.get(r.vesselId)!.add(r.poId); } } for (const [id, set] of poSets) idx.get(id)!.poCount = set.size; return [...idx.values()]; } /** Weekly buckets (W1–W5) of one FY month for a cost centre. */ export function costCentreWeekly(ds: ReportDataset, vesselId: string, fy: number, month: number): number[] { const weeks = Array(5).fill(0); for (const r of ds.rows) if (r.vesselId === vesselId && r.fy === fy && r.month === month) weeks[r.week] += r.amount; return weeks; } /** 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); const poSet = new Set(); let total = 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; poSet.add(r.poId); } } return { total, months, fyTotals, poCount: poSet.size }; } /** Weekly buckets (W1–W5) of one FY month for an account node (rolls leaves up). */ export function accountNodeWeekly(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number, month: number): number[] { const leaves = idx.leavesUnder(nodeId); const weeks = Array(5).fill(0); for (const r of ds.rows) if (leaves.has(r.accountId) && r.fy === fy && r.month === month) weeks[r.week] += r.amount; return weeks; } 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" | "weekly"; export function parseGranularity(v: string | undefined): Granularity { return v === "yearly" || v === "weekly" ? v : "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]; } /** Resolve the FY-month index (0–11) for weekly mode (default: latest month with spend, else 0). */ export function resolveMonth(ds: ReportDataset, fy: number, v: string | undefined): number { const n = v ? Number(v) : NaN; if (Number.isFinite(n) && n >= 0 && n <= 11) return n; let last = 0; for (const r of ds.rows) if (r.fy === fy && r.month > last) last = r.month; return last; } /** Parse the `?sel=id1,id2` custom-comparison selection into an ordered, de-duped id list. */ export function parseSel(v: string | undefined): string[] { if (!v) return []; const seen = new Set(); for (const id of v.split(",")) if (id.trim()) seen.add(id.trim()); return [...seen]; } /** Toggle an id within a selection list (for the checkbox links). */ export function toggleSel(sel: string[], id: string): string[] { return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]; }