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:
parent
0f022d5ffa
commit
39d85fa8a9
7 changed files with 135 additions and 15 deletions
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue