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:
parent
dade4592be
commit
802565d2d8
10 changed files with 91 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"]) }),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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[]) : [],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue