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>
This commit is contained in:
DEls 2026-09-27 10:56:40 +02:00
parent 0f022d5ffa
commit 39d85fa8a9
7 changed files with 135 additions and 15 deletions

View file

@ -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[] = [];

View file

@ -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 (

View file

@ -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<string, number> };
} = {},
): 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}`,
})),

View file

@ -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. */

View file

@ -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<typeof computeSupplierBalances>[0],
);
const typed = input as unknown as Parameters<typeof computeSupplierBalances>[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,
});
},
},

View file

@ -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");
});
});

View file

@ -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<SupplierBalanceInput, "pos" | "items" | "deliveryLines" | "kindByDnId" | "dateByDnId">,
inPeriod: (isoDate: string) => boolean,
): Map<string, number> {
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<string, number>();
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;
}