feat(collection-confirm): check printed prices on delivery notes; show AI reading notes

Plumblink hands out delivery notes with prices on them. The Price tick
was always grey on a delivery note, so a card could go purple without
its prices ever being checked, and the AI's reading notes ("delivery
note, not a tax invoice", "year not printed") were never shown.

- Price tick: compare whenever the document line has a price, invoice
  or delivery note; grey only when no price is printed. The AI still
  only corrects a cost from a tax invoice.
- ai_doc now carries the read's notes; cardNotes() unions them per card
  and the AI findings box lists them under "Reading notes" (the box now
  shows when there are notes but no findings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-19 13:50:27 +02:00
parent dade4592be
commit 802565d2d8
10 changed files with 91 additions and 16 deletions

View file

@ -102,11 +102,13 @@ Collection checks, and stored in
| Green | The document agrees with our line: our SKU equals the document code after normalising (uppercase, letters and digits only; this is also how a SKU the AI filled goes green), quantity equals what was collected and what was ordered, the AI is sure it is the same item, unit price after any discount within 1 cent |
| Amber | Close but worth a look: our SKU differs from the document code; collected quantity matches the document but the ordered quantity differs; the AI thinks it is the same item but is only "likely" sure |
| Red | Mismatch or missing: no SKU on our line, quantity differs from the document, price differs or we have no cost, or the line is not on the document or the AI says it is a different product (all four red) |
| Grey | Can't check, per tick: **SKU** when the document line shows no code; **Qty** when it shows no quantity; **Price** on a delivery note or when the document line shows no price; **all four** when the AI couldn't pair the document (see the AI findings box for the reason; **Check with AI** retries) |
| Grey | Can't check, per tick: **SKU** when the document line shows no code; **Qty** when it shows no quantity; **Price** when the document line shows no price (a delivery note that prints prices, like Plumblink's, is compared like an invoice — but the AI only ever corrects a cost from a tax invoice); **all four** when the AI couldn't pair the document (see the AI findings box for the reason; **Check with AI** retries) |
For stock purchase lines the SKU tick and robot use the line's own SKU
(the one a rep or the AI set on the purchase line), not the catalogue SKU.
The AI findings box also lists the AI's **reading notes** — what it noticed on the photo (a delivery note rather than an invoice, the year missing from the date, a rotated or partly covered page).
Hover a tick for the values compared (ours vs the document). A robot icon
marks a SKU the AI filled. Click the document chip on a card to see the
extracted lines next to the photo.

View file

@ -7,9 +7,12 @@ import { docPriceLabel, docUnitCost, roundCents } from "@/lib/collection-ai-tick
const zar = new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" });
/** Box of document-level observations from the AI. Renders nothing when empty. */
export function AiFindings({ findings }: { findings: string[] }) {
if (findings.length === 0) return null;
/**
* Box of document-level observations from the AI, plus its reading notes
* (what it noticed on the photo). Renders nothing when both are empty.
*/
export function AiFindings({ findings, notes = [] }: { findings: string[]; notes?: string[] }) {
if (findings.length === 0 && notes.length === 0) return null;
return (
<div
className="mb-3 rounded-lg border border-ai/30 bg-ai/10 px-3 py-2"
@ -19,11 +22,23 @@ export function AiFindings({ findings }: { findings: string[] }) {
<Bot className="size-3.5" aria-hidden />
AI findings
</p>
<ul className="list-disc pl-5 text-xs">
{findings.map((finding, i) => (
<li key={`${i}-${finding}`}>{finding}</li>
))}
</ul>
{findings.length > 0 ? (
<ul className="list-disc pl-5 text-xs">
{findings.map((finding, i) => (
<li key={`${i}-${finding}`}>{finding}</li>
))}
</ul>
) : null}
{notes.length > 0 ? (
<div className="mt-1.5" data-testid="ai-reading-notes">
<p className="text-[11px] font-medium text-muted-foreground">Reading notes</p>
<ul className="list-disc pl-5 text-xs text-muted-foreground">
{notes.map((note, i) => (
<li key={`${i}-${note}`}>{note}</li>
))}
</ul>
</div>
) : null}
</div>
);
}

View file

@ -100,6 +100,25 @@ describe("PoCard AI layer", () => {
expect(screen.getByTestId("ai-findings")).toHaveTextContent("Freight not on PO");
});
it("shows the AI's reading notes in the findings box even when there are no findings", () => {
const row = makeAiRow({
ai_doc: {
read_id: "r",
doc_type: "delivery_note",
document_no: "2010/02893666",
doc_date: "2026-10-09",
supplier_name: "Plumblink",
findings: [],
notes: ["Year not printed on the document date"],
lines: [],
},
});
render(<PoCard rows={[row]} {...props} />);
const box = screen.getByTestId("ai-findings");
expect(box).toHaveTextContent("Reading notes");
expect(box).toHaveTextContent("Year not printed on the document date");
});
it("shows a line's own findings (e.g. a pairing failure) in the findings box", () => {
const row = makeAiRow({
ai_check: makeCheck({

View file

@ -16,6 +16,7 @@ import {
import {
cardDocument,
cardFindings,
cardNotes,
documentChipLabel,
} from "@/lib/collection-ai-card";
import type { CollectionDocLine } from "@/lib/types";
@ -204,6 +205,7 @@ export function PoCard({
const [docPanelOpen, setDocPanelOpen] = useState(false);
const aiDoc = useMemo(() => cardDocument(rows), [rows]);
const aiFindings = useMemo(() => cardFindings(rows), [rows]);
const aiNotes = useMemo(() => cardNotes(rows), [rows]);
// Every line the AI read from the chip's document; lines paired with a row
// on this card are marked "matched". Falls back to the paired lines alone
// when the document's full line list is unavailable.
@ -890,7 +892,7 @@ export function PoCard({
</div>
)}
<AiFindings findings={aiFindings} />
<AiFindings findings={aiFindings} notes={aiNotes} />
<footer className="flex items-center justify-between gap-2">
<p className="hidden text-[11px] text-muted-foreground lg:block">

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { makeAiRow, makeCheck } from "@/components/collection-confirm/ai-fixtures";
import { aiInvoiceDefaults, cardDocument, cardFindings, posResultToast } from "./collection-ai-card";
import { aiInvoiceDefaults, cardDocument, cardFindings, cardNotes, posResultToast } from "./collection-ai-card";
const doc = (read_id: string, findings: string[], doc_date = "2026-10-16") => ({
read_id,
@ -14,6 +14,16 @@ const doc = (read_id: string, findings: string[], doc_date = "2026-10-16") => ({
});
describe("collection-ai-card", () => {
it("unions the AI's reading notes across distinct reads, deduped in order", () => {
const rows = [
makeAiRow({ id: "1", ai_doc: { ...doc("r1", []), notes: ["Delivery note, not a tax invoice", "Rotated"] } }),
makeAiRow({ id: "2", ai_doc: { ...doc("r1", []), notes: ["Delivery note, not a tax invoice", "Rotated"] } }),
makeAiRow({ id: "3", ai_doc: { ...doc("r2", []), notes: ["Rotated", "Year not printed"] } }),
makeAiRow({ id: "4", ai_doc: doc("r3", []) }),
];
expect(cardNotes(rows)).toEqual(["Delivery note, not a tax invoice", "Rotated", "Year not printed"]);
});
it("unions findings across distinct reads, deduped in order", () => {
const rows = [
makeAiRow({ id: "1", ai_doc: doc("r1", ["a", "b"]) }),

View file

@ -30,6 +30,19 @@ export function cardFindings(rows: ConfirmRow[]): string[] {
return out;
}
/** The AI's reading notes across a card's distinct reads, deduped, in order. */
export function cardNotes(rows: ConfirmRow[]): string[] {
const seenReads = new Set<string>();
const out: string[] = [];
for (const row of rows) {
const doc = row.ai_doc;
if (!doc || seenReads.has(doc.read_id)) continue;
seenReads.add(doc.read_id);
for (const note of doc.notes ?? []) if (note && !out.includes(note)) out.push(note);
}
return out;
}
/** The card's newest document (first non-null wins on equal dates). */
export function cardDocument(rows: ConfirmRow[]): AiDoc | null {
let best: AiDoc | null = null;

View file

@ -159,7 +159,14 @@ describe("computeLineTicks", () => {
expect(
computeLineTicks(row({ cost_price: 14.51 }), check({ doc_line: line({ unit_price: 14.52 }) })).price,
).toBe("green");
expect(computeLineTicks(row(), check({ doc_type: "delivery_note" })).price).toBe("grey");
// A delivery note that prints prices (Plumblink) is compared like an invoice.
expect(computeLineTicks(row(), check({ doc_type: "delivery_note" })).price).toBe("green");
expect(
computeLineTicks(row({ cost_price: 0.5 }), check({ doc_type: "delivery_note" })).price,
).toBe("red");
expect(
computeLineTicks(row(), check({ doc_type: "delivery_note", doc_line: line({ unit_price: null }) })).price,
).toBe("grey");
expect(
computeLineTicks(row(), check({ doc_line: line({ unit_price: null }) })).price,
).toBe("grey");
@ -208,8 +215,8 @@ describe("cardAiState", () => {
const good = { ...row(), ai_check: check() };
it("is verified only when every line was checked and no tick is red or amber", () => {
expect(cardAiState([good, good])).toEqual({ state: "verified", checked: 2, needLook: 0 });
// A grey price (delivery note) is fine: nothing the AI could check is off.
expect(cardAiState([{ ...good, ai_check: check({ doc_type: "delivery_note" }) }]).state).toBe("verified");
// A grey price (no price printed) is fine: nothing the AI could check is off.
expect(cardAiState([{ ...good, ai_check: check({ doc_line: line({ unit_price: null }) }) }]).state).toBe("verified");
});
it("needs a look when a line is off or wasn't checked", () => {
expect(cardAiState([good, { ...row({ cost_price: 99 }), ai_check: check() }])).toEqual({

View file

@ -92,7 +92,8 @@ export function computeLineTicks(row: TickRow, check: CollectionAiCheck): LineTi
// Price
let price: TickState;
const unitCost = docUnitCost(doc);
if (check.doc_type === "delivery_note" || unitCost == null) price = "grey";
// Delivery notes are compared too when they print a price (Plumblink does).
if (unitCost == null) price = "grey";
else if (row.cost_price == null) price = "red";
else
price =

View file

@ -1291,6 +1291,7 @@ describe("listCollectedItems — AI check passthrough", () => {
doc_date: "2026-10-16",
supplier_name: "NSS Fasteners",
findings: ["Document 5028256 covers 2 POs: A, B."],
notes: ["Two top lines highlighted in pink."],
lines: [DOC_LINE],
},
],
@ -1310,6 +1311,7 @@ describe("listCollectedItems — AI check passthrough", () => {
doc_date: "2026-10-16",
supplier_name: "NSS Fasteners",
findings: ["Document 5028256 covers 2 POs: A, B."],
notes: ["Two top lines highlighted in pink."],
lines: [DOC_LINE],
});
expect(byId.get("oi-2")?.ai_doc?.read_id).toBe("r1");

View file

@ -127,6 +127,8 @@ export type ConfirmRow = {
doc_date: string;
supplier_name: string;
findings: string[];
/** What the AI noticed while reading the photo (rotated, year missing, …). */
notes?: string[];
/** Every line the AI read from the document, paired or not. */
lines: CollectionDocLine[];
} | null;
@ -548,10 +550,11 @@ async function attachAiFields(pb: AdminPb, rows: ConfirmRow[]): Promise<void> {
doc_date?: string;
supplier_name?: string;
findings?: unknown;
notes?: unknown;
lines?: unknown;
}>({
filter: ids.map((id) => `id = "${pbQuote(id)}"`).join(" || "),
fields: "id,doc_type,document_no,doc_date,supplier_name,findings,lines",
fields: "id,doc_type,document_no,doc_date,supplier_name,findings,notes,lines",
});
for (const r of found) {
readsById.set(r.id, {
@ -561,6 +564,7 @@ async function attachAiFields(pb: AdminPb, rows: ConfirmRow[]): Promise<void> {
doc_date: r.doc_date ?? "",
supplier_name: r.supplier_name ?? "",
findings: Array.isArray(r.findings) ? (r.findings as string[]) : [],
notes: Array.isArray(r.notes) ? (r.notes as string[]) : [],
lines: Array.isArray(r.lines) ? (r.lines as CollectionDocLine[]) : [],
});
}