diff --git a/src/app/api/ask/messages/[id]/pdf/route.ts b/src/app/api/ask/messages/[id]/pdf/route.ts index 32d36ec..2982961 100644 --- a/src/app/api/ask/messages/[id]/pdf/route.ts +++ b/src/app/api/ask/messages/[id]/pdf/route.ts @@ -69,7 +69,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const date = new Date().toISOString().slice(0, 10); const preparedFor = `Prepared by Ask for ${actor.name}`; - const reportDoc = row.parsed?.pdf; + // Only a report with sections is built section by section. Reports + // saved in the older `{ title, subtitle, filename }` shape (before + // structured documents), or where the model sent no sections, fall + // back to the answer's own text — keeping the report's title. + const pdfMeta = row.parsed?.pdf; + const reportDoc = + pdfMeta && Array.isArray(pdfMeta.sections) && pdfMeta.sections.length > 0 ? pdfMeta : undefined; let buffer: Buffer; let title: string; @@ -100,7 +106,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: images, }); } else { - title = (thread.title as string | undefined) || "Ask answer"; + title = pdfMeta?.title || (thread.title as string | undefined) || "Ask answer"; + filename = pdfMeta?.filename; const imageIds = new Set(Array.from(row.text.matchAll(IMAGE_TOKEN), (m) => m[1])); const images: ResolvedImage[] = []; diff --git a/src/app/dashboard/financial/suppliers/page.tsx b/src/app/dashboard/financial/suppliers/page.tsx index c0c008f..c61e979 100644 --- a/src/app/dashboard/financial/suppliers/page.tsx +++ b/src/app/dashboard/financial/suppliers/page.tsx @@ -4,6 +4,7 @@ import { Topbar } from "@/components/layout/sidebar"; import { AddSupplierButton } from "@/components/supplier-form-dialog"; import { computeSupplierBalances, + toIsoDate, type SupplierBalanceInput as In, } from "@/lib/metrics/supplier-balance"; import { countsAsReceipt, type DocumentKind } from "@/lib/document-kind"; @@ -83,7 +84,9 @@ export default async function SuppliersPage({ searchParams }: { searchParams: Pr const qty = Number((line as { qty_received?: number }).qty_received ?? 0); const dateReceived = dateByDnId.get(dnId) ?? ""; if (!dateReceived) continue; - deliveryValues.push({ dateReceived, value: round2(qty * rate) }); + // ISO "T" form: PocketBase's space-separated date sorts before the + // period window's "T" and dropped deliveries on the 1st. + deliveryValues.push({ dateReceived: toIsoDate(dateReceived), value: round2(qty * rate) }); } return ( diff --git a/src/lib/agent/format.ts b/src/lib/agent/format.ts index 53997e0..a5e1a6f 100644 --- a/src/lib/agent/format.ts +++ b/src/lib/agent/format.ts @@ -435,12 +435,14 @@ export interface SupplierBalancesResult { scope: string; supplierCount: number; showing: number; - totals: { display: { ordered: string; delivered: string; outstanding: string } }; + /** 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 }; + display: { ordered: string; delivered: string; outstanding: string; receivedInPeriod?: string }; href: string; }[]; } @@ -451,10 +453,23 @@ export interface SupplierBalancesResult { */ export function formatSupplierBalances( rows: SupplierBalanceRow[], - opts: { supplier?: string; limit?: number } = {}, + 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 matched = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows; + 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 @@ -466,14 +481,17 @@ export function formatSupplierBalances( 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: { @@ -481,6 +499,7 @@ export function formatSupplierBalances( ordered: formatMoney(ordered), delivered: formatMoney(delivered), outstanding: formatMoney(outstanding), + ...(received ? { receivedInPeriod: formatMoney(inPeriod) } : {}), }, }, suppliers: matched.slice(0, limit).map((r) => ({ @@ -491,6 +510,7 @@ export function formatSupplierBalances( 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}`, })), diff --git a/src/lib/agent/load.ts b/src/lib/agent/load.ts index 7edb751..6523d31 100644 --- a/src/lib/agent/load.ts +++ b/src/lib/agent/load.ts @@ -557,12 +557,18 @@ export async function loadSupplierBalanceInput() { expand: "supplier", fields: "id,supplier,supplier_name_raw,status,stage,batch", }), - pb.collection("delivery_notes").getFullList({ fields: "id,document_type" }), + // date_received too: computeSupplierBalances needs both maps (the + // Suppliers page's period filter anchors on it). Missing it crashed + // Ask's supplier_balances tool. + pb.collection("delivery_notes").getFullList({ fields: "id,document_type,date_received" }), ]); const kindByDnId = new Map( deliveryNotes.map((d) => [d.id, (d.document_type ?? "") as DocumentKind]), ); - return { suppliers, pos, items, deliveryLines, candidates, kindByDnId }; + const dateByDnId = new Map( + deliveryNotes.map((d) => [d.id, (d.date_received ?? "") as string]), + ); + return { suppliers, pos, items, deliveryLines, candidates, kindByDnId, dateByDnId }; } /** Batch and note stages for the delivery-note inbox board counts. */ diff --git a/src/lib/agent/tools.ts b/src/lib/agent/tools.ts index 901a531..fc5ef2e 100644 --- a/src/lib/agent/tools.ts +++ b/src/lib/agent/tools.ts @@ -69,7 +69,8 @@ import { } from "./format"; import { loadRainOutlook, type LocatableSite } from "@/lib/weather/load"; import { countInboxColumns } from "@/lib/dn-pipeline/board"; -import { computeSupplierBalances } from "@/lib/metrics/supplier-balance"; +import { computeSupplierBalances, receivedInPeriodBySupplier } from "@/lib/metrics/supplier-balance"; +import { isInPeriod, PERIOD_OPTIONS, resolvePeriodWindow, type PeriodFilter } from "@/lib/supplier-period"; import { groupAttendance } from "@/lib/attendance"; import { computeSiteSummaries, toListSitesRow } from "@/lib/reports/site-summary"; import { buildSiteReport } from "@/lib/reports/site-report"; @@ -966,15 +967,31 @@ export const tools: AgentTool[] = [ schema: { supplier: z.string().optional().describe("Part of a supplier name; omit for all suppliers"), limit: z.number().optional().describe("Defaults to 25"), + period: z + .enum(["thisMonth", "thisWeek", "month1", "month2", "month3"]) + .optional() + .describe( + "Only suppliers with goods RECEIVED in this period (by the delivery note's date received), with receivedInPeriod per supplier and in total — what is payable for that period, as on the Suppliers page. thisMonth = the current calendar month; month1/2/3 = one/two/three months back. Use for 'who do I need to pay this month'.", + ), }, handler: async (args) => { const input = await loadSupplierBalanceInput(); - const rows = computeSupplierBalances( - input as unknown as Parameters[0], - ); + const typed = input as unknown as Parameters[0]; + const rows = computeSupplierBalances(typed); + const period = typeof args.period === "string" ? (args.period as PeriodFilter) : undefined; + const window = period ? resolvePeriodWindow(period) : undefined; return formatSupplierBalances(rows, { supplier: args.supplier ? String(args.supplier) : undefined, limit: typeof args.limit === "number" ? args.limit : undefined, + period: + period && window + ? { + label: PERIOD_OPTIONS.find((o) => o.value === period)?.label ?? period, + from: window.fromISO?.slice(0, 10) ?? null, + to: window.toExclusiveISO?.slice(0, 10) ?? null, + received: receivedInPeriodBySupplier(typed, (iso) => isInPeriod(iso, window)), + } + : undefined, }); }, }, diff --git a/src/lib/metrics/supplier-balance.test.ts b/src/lib/metrics/supplier-balance.test.ts index d44ab2d..673cd92 100644 --- a/src/lib/metrics/supplier-balance.test.ts +++ b/src/lib/metrics/supplier-balance.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computeSupplierBalances } from "./supplier-balance"; +import { computeSupplierBalances, receivedInPeriodBySupplier, toIsoDate } from "./supplier-balance"; describe("computeSupplierBalances", () => { it("rolls up each supplier's POs and counts open candidates, most outstanding first", () => { @@ -56,3 +56,36 @@ describe("computeSupplierBalances", () => { expect(rows[0].outstanding).toBe(0); }); }); + +describe("receivedInPeriodBySupplier", () => { + const base = { + pos: [{ id: "po1", supplier_ref: "s1" }, { id: "po2", supplier_ref: "s2" }], + items: [ + { id: "i1", purchase_order: "po1", qty: 10, rate: 100 }, + { id: "i2", purchase_order: "po2", qty: 5, rate: 20 }, + ], + deliveryLines: [ + { purchase_order_item: "i1", qty_received: 3, delivery_note: "dnSep1" }, + { purchase_order_item: "i1", qty_received: 2, delivery_note: "dnAug" }, + { purchase_order_item: "i2", qty_received: 4, delivery_note: "dnSep20" }, + ], + kindByDnId: new Map(), + dateByDnId: new Map([ + ["dnSep1", "2026-09-01 00:00:00.000Z"], + ["dnAug", "2026-08-31 00:00:00.000Z"], + ["dnSep20", "2026-09-20 00:00:00.000Z"], + ]), + }; + const september = (iso: string) => iso >= "2026-09-01T00:00:00.000Z" && iso < "2026-10-01T00:00:00.000Z"; + + it("splits value received in the period by supplier", () => { + const r = receivedInPeriodBySupplier(base, september); + expect(r.get("s1")).toBe(300); + expect(r.get("s2")).toBe(80); + }); + + it("counts a delivery on the 1st (PocketBase's space-separated date)", () => { + expect(receivedInPeriodBySupplier(base, september).get("s1")).toBe(300); + expect(toIsoDate("2026-09-01 00:00:00.000Z")).toBe("2026-09-01T00:00:00.000Z"); + }); +}); diff --git a/src/lib/metrics/supplier-balance.ts b/src/lib/metrics/supplier-balance.ts index 5436f6d..fcc8080 100644 --- a/src/lib/metrics/supplier-balance.ts +++ b/src/lib/metrics/supplier-balance.ts @@ -13,7 +13,7 @@ * date ("we only pay what we received this month"). */ import { computeSupplierLedger } from "@/lib/supplier-ledger"; -import type { DocumentKind } from "@/lib/document-kind"; +import { countsAsReceipt, type DocumentKind } from "@/lib/document-kind"; export interface SupplierBalanceRow { id: string; @@ -113,3 +113,37 @@ export function computeSupplierBalances(input: SupplierBalanceInput): SupplierBa rows.sort((a, b) => b.outstanding - a.outstanding); return rows; } + +/** PocketBase writes "2026-09-01 00:00:00.000Z"; period windows use ISO "T". */ +export function toIsoDate(pbDate: string): string { + return pbDate.replace(" ", "T"); +} + +/** + * Value received per supplier (by supplier id) where the delivery note's + * `date_received` passes `inPeriod` — qty_received × the PO line's rate, + * receipt documents only. The same rule as the Suppliers page's "Payable + * this period" KPI, split by supplier, so Ask can answer "who do I pay + * this month". + */ +export function receivedInPeriodBySupplier( + input: Pick, + inPeriod: (isoDate: string) => boolean, +): Map { + const supplierByPo = new Map(input.pos.map((p) => [String(p.id), p.supplier_ref ?? ""])); + const itemById = new Map(input.items.map((it) => [String(it.id), it])); + const out = new Map(); + for (const line of input.deliveryLines) { + const dnId = String(line.delivery_note ?? ""); + if (!countsAsReceipt(input.kindByDnId.get(dnId) ?? "")) continue; + const date = input.dateByDnId.get(dnId) ?? ""; + if (!date || !inPeriod(toIsoDate(date))) continue; + const item = itemById.get(String(line.purchase_order_item ?? "")); + if (!item) continue; + const supplierId = supplierByPo.get(String(item.purchase_order)) ?? ""; + if (!supplierId) continue; + const value = Math.round(Number(line.qty_received ?? 0) * Number(item.rate ?? 0) * 100) / 100; + out.set(supplierId, (out.get(supplierId) ?? 0) + value); + } + return out; +}