MEGAINFRA-WEBAPP/src/lib/agent/format.ts
DEls 39d85fa8a9 fix(ask): supplier balances, payable per supplier per period, PDF fallback
- Ask's supplier loader didn't pass date_received, so supplier_balances
  and the supplier_balance dataset crashed
- supplier_balances takes a period (thisMonth, thisWeek, month1-3) and
  returns what was received from each supplier in it (payable)
- PocketBase's space-separated dates dropped deliveries on the 1st from
  period filters, including the Suppliers page's Payable KPI
- The PDF route falls back to the answer text when a saved report has
  no sections (older format), instead of a 500

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:56:40 +02:00

613 lines
19 KiB
TypeScript

/**
* 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<string, string>,
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<string, string>,
opts: { siteId?: string; limit?: number },
): TopWorkersResult {
const byId = new Map(employees.map((e) => [e.id, e]));
const acc = new Map<string, number>();
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/<id>`.
*/
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 = <T extends { siteId: string }>(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<string, number> };
} = {},
): 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<string, number>();
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,
};
}