/** * Shape loaded records + metrics output into tool results. Pure. * * Every numeric field here comes from @/lib/metrics. Tool results also * carry a `display` block of preformatted strings so the model never has * to format or round money itself. */ import { computeSiteSpend, computeSiteProjection, resolveSiteStartDate, parseDurationMonths, computeFuelLogStatuses, computeFuelCost, resolveFuelPrice, type EmployeeRateMap, type FuelPriceLike, type SiteStatus, type VehicleAttention, } from "@/lib/metrics"; import type { PnGItemLike } from "@/lib/partition-fixed-costs"; import { describeLimitMismatch } from "@/lib/fuel-variance"; import type { AttendanceGroups } from "@/lib/attendance"; import type { SupplierBalanceRow } from "@/lib/metrics/supplier-balance"; import type { InboxColumnCount } from "@/lib/dn-pipeline/board"; import type { RainOutlookData } from "@/lib/weather/load"; import { formatNumber } from "@/lib/utils"; import { RAIN_STOP_MM, RAIN_WATCH_MM, RAIN_WATCH_PROBABILITY_PCT } from "@/lib/weather/outlook"; /** ZAR, no decimals, thin-space thousands. */ export function formatMoney(n: number): string { const neg = n < 0; const abs = Math.abs(Math.round(n)); const grouped = abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); return `${neg ? "-" : ""}R ${grouped}`; } export interface SiteFinancialsInput { site: { id: string; name?: string; project_code?: string; duration?: string; contract_value?: number; job_start_date?: string; }; fixedCosts: PnGItemLike[]; timesheets: { employee: string; hours_worked?: number; date_logged?: string }[]; employeeRates: EmployeeRateMap; purchaseOrders: { total_value?: number; created?: string }[]; fuelLogs: { litres?: number; date_logged?: string }[]; income: { gross_amount?: number; paid_on?: string; period_end?: string }[]; /** Recorded diesel price changes. Omit to cost every litre at RATE_PER_LITRE. */ fuelPrices?: FuelPriceLike[]; now?: Date; } export interface SiteFinancialsResult { site: string; projectCode: string; contractValue: number; totalSpent: number; breakdown: { overheadsMonthly: number; overheadsOneOff: number; payroll: number; materials: number; fuel: number; }; fuelLitres: number; startDate: string; startDateSource: string; projection: { durationMonths: number; elapsedMonths: number; percentElapsed: number; percentBurnt: number; remainingBudget: number; projectedTotal: number; projectedProfit: number; status: SiteStatus; }; display: { contractValue: string; totalSpent: string; remainingBudget: string; projectedTotal: string; projectedProfit: string; }; } export function formatSiteFinancials( input: SiteFinancialsInput, ): SiteFinancialsResult { const spend = computeSiteSpend({ siteId: input.site.id, fixedCosts: input.fixedCosts, timesheets: input.timesheets, employeeRates: input.employeeRates, purchaseOrders: input.purchaseOrders, fuelLogs: input.fuelLogs, fuelPrices: input.fuelPrices, }); const siteTimesheets = input.timesheets.filter( (t) => input.employeeRates.get(t.employee)?.site === input.site.id, ); const start = resolveSiteStartDate({ jobStartDate: input.site.job_start_date, fixedCosts: input.fixedCosts, timesheets: siteTimesheets, fuelLogs: input.fuelLogs, income: input.income, now: input.now, }); const projection = computeSiteProjection({ contractValue: input.site.contract_value ?? 0, totalSpent: spend.totalSpent, durationMonths: parseDurationMonths(input.site.duration), startDate: start.startDate, hasExplicitStart: start.source === "job_start_date", now: input.now, }); return { site: input.site.name ?? "", projectCode: input.site.project_code ?? "", contractValue: input.site.contract_value ?? 0, totalSpent: spend.totalSpent, breakdown: { overheadsMonthly: spend.fixedSum, overheadsOneOff: spend.oneOffSum, payroll: spend.payrollBurn, materials: spend.materialSum, fuel: spend.fuelBurn, }, fuelLitres: spend.fuelLitres, startDate: start.startDate, startDateSource: start.source, projection: { durationMonths: projection.months, elapsedMonths: projection.elapsedMonths, percentElapsed: projection.elapsedPct, percentBurnt: projection.burnPct, remainingBudget: projection.remaining, projectedTotal: projection.projectedTotal, projectedProfit: projection.projectedProfit, status: projection.status, }, display: { contractValue: formatMoney(input.site.contract_value ?? 0), totalSpent: formatMoney(spend.totalSpent), remainingBudget: formatMoney(projection.remaining), projectedTotal: formatMoney(projection.projectedTotal), projectedProfit: formatMoney(projection.projectedProfit), }, }; } export interface AttentionResult { /** Vehicles with real fuel alerts or warnings. */ totalFlagged: number; /** Vehicles whose fuel limit is in the wrong unit (config to fix). */ limitProblems: number; vehicles: { vehicle: string; projectCode: string; qrCodeId: string; site: string; alerts: number; warnings: number; lastFlagged: string | null; dismissed: boolean; /** Plain-language wrong-unit explanation, or null. */ limitProblem: string | null; }[]; } export function formatAttentionList( items: VehicleAttention[], siteNames: Map, opts: { includeDismissed?: boolean }, ): AttentionResult { const visible = opts.includeDismissed ? items : items.filter((i) => !i.acknowledged); const vehicles = visible .slice() .sort((a, b) => { if (b.alert !== a.alert) return b.alert - a.alert; return b.warn - a.warn; }) .map((i) => ({ vehicle: i.description, projectCode: i.projectCode, qrCodeId: i.qrCodeId, site: siteNames.get(i.siteId) ?? i.siteId, alerts: i.alert, warnings: i.warn, lastFlagged: i.maxFlaggedDate, dismissed: i.acknowledged, limitProblem: i.limitMismatch ? describeLimitMismatch(i.limitMismatch) : null, })); return { totalFlagged: vehicles.filter((v) => v.alerts + v.warnings > 0).length, limitProblems: vehicles.filter((v) => v.limitProblem).length, vehicles, }; } export interface TopWorkersResult { scope: string; workers: { worker: string; position: string; site: string; hours: number; cost: number; ratePerHour: number; display: { cost: string }; }[]; } /** * Roll up hours and cost per worker. The data already exists — this is * `computeSiteSpend(...).payrollByEmployee` joined to an employee * record — but no tool exposed it before. */ export function formatTopWorkers( employees: { id: string; name?: string; rate_per_hour?: number; site?: string; position?: string; }[], timesheets: { employee: string; hours_worked?: number }[], siteNames: Map, opts: { siteId?: string; limit?: number }, ): TopWorkersResult { const byId = new Map(employees.map((e) => [e.id, e])); const acc = new Map(); for (const t of timesheets) { const emp = byId.get(t.employee); if (!emp) continue; if (opts.siteId && emp.site !== opts.siteId) continue; acc.set(t.employee, (acc.get(t.employee) ?? 0) + (t.hours_worked ?? 0)); } const workers = Array.from(acc.entries()) .filter(([, hours]) => hours > 0) .map(([id, hours]) => { const e = byId.get(id)!; const rate = e.rate_per_hour ?? 0; const cost = hours * rate; return { worker: e.name ?? "(unnamed)", position: e.position ?? "", site: siteNames.get(e.site ?? "") ?? e.site ?? "", hours, cost, ratePerHour: rate, display: { cost: formatMoney(cost) }, }; }) .sort((a, b) => b.hours - a.hours) .slice(0, opts.limit ?? 10); return { scope: opts.siteId ? siteNames.get(opts.siteId) ?? opts.siteId : "all sites", workers, }; } export interface FuelLogResult { vehicle: string; qrCodeId: string; projectCode: string; fuelLimit: string; fillUpCount: number; totalLitres: number; totalSpend: number; flagged: number; fillUps: { date: string; litres: number; odoReading: number; variancePct: number | null; status: string; cost: number; display: { cost: string }; }[]; display: { totalSpend: string }; } /** * Per-fill variance for one vehicle, reusing the same pass the dashboard * uses so per-fill status matches `/dashboard/fuel/`. */ export function formatFuelLog( vehicle: { id: string; description?: string; qr_code_id?: string; project_code?: string; fuel_limits?: string; }, logs: { id: string; vehicle: string; date_logged?: string; odo_reading?: number; litres?: number; }[], opts: { limit?: number; fuelPrices?: FuelPriceLike[] }, ): FuelLogResult { const fuelPrices = opts.fuelPrices ?? []; // computeFuelLogStatuses takes a vehicles array; pass one-element. const statuses = new Map( computeFuelLogStatuses({ vehicles: [vehicle], fuelLogs: logs }).map( (s) => [s.logId, s], ), ); const totalLitres = logs.reduce((s, l) => s + (l.litres ?? 0), 0); const totalSpend = computeFuelCost(logs, fuelPrices); const flagged = Array.from(statuses.values()).filter( (s) => s.status === "warn" || s.status === "alert", ).length; const fillUps = logs .slice() .sort((a, b) => (a.date_logged ?? "") < (b.date_logged ?? "") ? 1 : -1) .slice(0, opts.limit ?? 20) .map((l) => { const st = statuses.get(l.id); const cost = (l.litres ?? 0) * resolveFuelPrice(l.date_logged, fuelPrices); return { date: l.date_logged ?? "", litres: l.litres ?? 0, odoReading: l.odo_reading ?? 0, variancePct: st?.variance ?? null, status: st?.status ?? "no-limit", cost, display: { cost: formatMoney(cost) }, }; }); return { vehicle: vehicle.description ?? "", qrCodeId: vehicle.qr_code_id ?? "", projectCode: vehicle.project_code ?? "", fuelLimit: vehicle.fuel_limits ?? "", fillUpCount: logs.length, totalLitres, totalSpend, flagged, fillUps, display: { totalSpend: formatMoney(totalSpend) }, }; } /** "07:42" in Johannesburg. */ function saTime(iso: string): string { return new Intl.DateTimeFormat("en-ZA", { timeZone: "Africa/Johannesburg", hour: "2-digit", minute: "2-digit", hour12: false, }).format(new Date(iso)); } /** "2026-09-23" in Johannesburg. */ function saDate(iso: string): string { return new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Johannesburg" }).format(new Date(iso)); } export interface AttendanceResult { scope: string; counts: { onSiteNow: number; clockedOutToday: number; notArrivedToday: number; needsAttention: number }; onSiteNow: { worker: string; site: string; since: string }[]; clockedOutToday: { worker: string; site: string; at: string }[]; notArrivedToday: { worker: string; site: string; lastSeen: string }[]; needsAttention: { worker: string; site: string; clockedInOn: string; at: string }[]; /** True when a list was cut to `limit`; counts are always complete. */ truncated: boolean; href: string; } /** * Today's attendance from groupAttendance (the On-site page's grouping), * optionally narrowed to one site. Counts are the full list lengths; * each list is capped so a big roster does not flood the answer. */ export function formatAttendance( g: AttendanceGroups, opts: { siteId?: string; scope?: string; limit?: number } = {}, ): AttendanceResult { const limit = opts.limit ?? 30; const keep = (rows: T[]) => opts.siteId ? rows.filter((r) => r.siteId === opts.siteId) : rows; const onSite = keep(g.onSite); const out = keep(g.clockedOutToday); const notArrived = keep(g.notArrivedToday); const attention = keep(g.needsAttention); return { scope: opts.scope ?? "all sites", counts: { onSiteNow: onSite.length, clockedOutToday: out.length, notArrivedToday: notArrived.length, needsAttention: attention.length, }, onSiteNow: onSite.slice(0, limit).map((r) => ({ worker: r.employeeName, site: r.siteName, since: saTime(r.clockedInAt) })), clockedOutToday: out.slice(0, limit).map((r) => ({ worker: r.employeeName, site: r.siteName, at: saTime(r.clockedOutAt) })), notArrivedToday: notArrived.slice(0, limit).map((r) => ({ worker: r.employeeName, site: r.siteName, lastSeen: saDate(r.lastSeenAt) })), needsAttention: attention.slice(0, limit).map((r) => ({ worker: r.employeeName, site: r.siteName, clockedInOn: saDate(r.clockedInAt), at: saTime(r.clockedInAt), })), truncated: [onSite, out, notArrived, attention].some((l) => l.length > limit), href: opts.siteId ? `/dashboard/workforce/on-site?site=${opts.siteId}` : "/dashboard/workforce/on-site", }; } export interface SupplierBalancesResult { scope: string; supplierCount: number; showing: number; /** Set when a period was asked for: the window and what was received in it. */ period?: { label: string; from: string | null; to: string | null }; totals: { display: { ordered: string; delivered: string; outstanding: string; receivedInPeriod?: string } }; suppliers: { supplier: string; purchaseOrders: number; unresolvedDeliveryNotes: number; display: { ordered: string; delivered: string; outstanding: string; receivedInPeriod?: string }; href: string; }[]; } /** * Supplier balances (computeSupplierBalances), optionally narrowed by a * name fragment. Totals are summed here so the model never adds rows. */ export function formatSupplierBalances( rows: SupplierBalanceRow[], opts: { supplier?: string; limit?: number; /** Value received per supplier id in the period; rows with none are dropped. */ period?: { label: string; from: string | null; to: string | null; received: Map }; } = {}, ): SupplierBalancesResult | { error: string } { const q = (opts.supplier ?? "").trim().toLowerCase(); const byName = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows; const received = opts.period?.received; // With a period, "who do I pay" = suppliers with something received in // it, largest first. const matched = received ? byName .filter((r) => (received.get(r.id) ?? 0) > 0) .sort((a, b) => (received.get(b.id) ?? 0) - (received.get(a.id) ?? 0)) : byName; if (q && matched.length === 0) { return { error: `No supplier matches "${opts.supplier}". Suppliers with the most outstanding: ${rows .slice(0, 8) .map((r) => r.name) .join(", ")}`, }; } let ordered = 0; let delivered = 0; let outstanding = 0; let inPeriod = 0; for (const r of matched) { ordered += r.ordered; delivered += r.delivered; outstanding += r.outstanding; inPeriod += received?.get(r.id) ?? 0; } const limit = opts.limit ?? 25; return { scope: q ? `suppliers matching "${opts.supplier}"` : "all suppliers", ...(opts.period ? { period: { label: opts.period.label, from: opts.period.from, to: opts.period.to } } : {}), supplierCount: matched.length, showing: Math.min(limit, matched.length), totals: { display: { ordered: formatMoney(ordered), delivered: formatMoney(delivered), outstanding: formatMoney(outstanding), ...(received ? { receivedInPeriod: formatMoney(inPeriod) } : {}), }, }, suppliers: matched.slice(0, limit).map((r) => ({ supplier: r.name, purchaseOrders: r.poCount, unresolvedDeliveryNotes: r.unresolvedCandidates, display: { ordered: formatMoney(r.ordered), delivered: formatMoney(r.delivered), outstanding: formatMoney(r.outstanding), ...(received ? { receivedInPeriod: formatMoney(received.get(r.id) ?? 0) } : {}), }, href: `/dashboard/financial/suppliers/${r.id}`, })), }; } export interface DnQueueCandidateLike { siteName: string; supplierId: string; supplierName: string; purchaseOrderId: string; purchaseOrderNumber: string; documentNumber: string; reason: string; unresolvedLineItems: number; } export interface DnQueueResult { inbox: { columns: InboxColumnCount[]; failedBatches: number; href: string }; workQueue: { open: number; withoutSupplier: number; withoutPurchaseOrder: number; withUnmatchedLines: number; byReason: { reason: string; count: number }[]; examples: { document: string; site: string; supplier: string; purchaseOrder: string; reason: string }[]; href: string; }; } /** Inbox board counts plus the open work-queue exceptions, display-ready. */ export function formatDnQueue( board: { columns: InboxColumnCount[]; failedBatches: number }, candidates: DnQueueCandidateLike[], opts: { examples?: number } = {}, ): DnQueueResult { const byReason = new Map(); for (const c of candidates) byReason.set(c.reason, (byReason.get(c.reason) ?? 0) + 1); return { inbox: { ...board, href: "/dashboard/financial/delivery-notes/inbox" }, workQueue: { open: candidates.length, withoutSupplier: candidates.filter((c) => !c.supplierId).length, withoutPurchaseOrder: candidates.filter((c) => !c.purchaseOrderId).length, withUnmatchedLines: candidates.filter((c) => c.unresolvedLineItems > 0).length, byReason: [...byReason.entries()] .map(([reason, count]) => ({ reason, count })) .sort((a, b) => b.count - a.count), examples: candidates.slice(0, opts.examples ?? 10).map((c) => ({ document: c.documentNumber || "(no number yet)", site: c.siteName, supplier: c.supplierName || "(no supplier)", purchaseOrder: c.purchaseOrderNumber || "(no PO)", reason: c.reason, })), href: "/dashboard/financial/delivery-notes/work-queue", }, }; } const SEVERITY_TEXT = { dry: "dry", watch: "watch", "likely-stop": "likely stop" } as const; export interface RainOutlookResult { available: boolean; rule: string; sites: { site: string; projectCode: string; likelyStopDays: number; watchDays: number; display: { totalRain: string }; days: { date: string; rain: string; chance: string; outlook: string }[]; href: string; }[]; sitesWithoutLocation: string[]; } /** The Overview rain card's data (loadRainOutlook), display-ready. */ export function formatRainOutlook(data: RainOutlookData): RainOutlookResult { const mm = (n: number) => `${formatNumber(n, { maximumFractionDigits: 1 })} mm`; return { available: data.available, rule: `A day with ${RAIN_STOP_MM} mm or more is a likely stop for earthworks; ${RAIN_WATCH_MM} mm or more, or a ${RAIN_WATCH_PROBABILITY_PCT}% chance, is one to watch.`, sites: data.outlooks.map((o) => ({ site: o.siteName, projectCode: o.projectCode, likelyStopDays: o.likelyStopDays, watchDays: o.watchDays, display: { totalRain: mm(o.totalMm) }, days: o.days.map((d) => ({ date: d.date, rain: mm(d.precipitationMm), chance: `${formatNumber(d.probabilityPct, { maximumFractionDigits: 0 })}%`, outlook: SEVERITY_TEXT[d.severity], })), href: `/dashboard/sites/${o.siteId}`, })), sitesWithoutLocation: data.sitesWithoutLocation, }; }