fix(collection-ai): grey ticks when pairing failed, show line findings on the card

I3: ai_check.pair_failed marks lines whose covering document couldn't be
paired; computeLineTicks returns all grey for them. The card findings box
now also lists each row's ai_check.findings after the document findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 10:12:42 +02:00
parent d1956bc1cf
commit b49911601d
8 changed files with 68 additions and 11 deletions

View file

@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
import { makeAiRow } from "./ai-fixtures";
import { makeAiRow, makeCheck } from "./ai-fixtures";
import { PoCard } from "./po-card";
const props = {
@ -100,6 +100,19 @@ describe("PoCard AI layer", () => {
expect(screen.getByTestId("ai-findings")).toHaveTextContent("Freight not on PO");
});
it("shows a line's own findings (e.g. a pairing failure) in the findings box", () => {
const row = makeAiRow({
ai_check: makeCheck({
doc_line: null,
same_item: null,
pair_failed: true,
findings: ["AI couldn't pair this document: timeout"],
}),
});
render(<PoCard rows={[row]} {...props} />);
expect(screen.getByTestId("ai-findings")).toHaveTextContent("AI couldn't pair this document: timeout");
});
it("Check with AI calls the handler and shows a spinner while pending", async () => {
let resolve!: () => void;
const onCheck = vi.fn(() => new Promise<void>((r) => (resolve = r)));

View file

@ -24,6 +24,28 @@ describe("collection-ai-card", () => {
expect(cardFindings(rows)).toEqual(["a", "b", "c"]);
});
it("adds each row's line findings after the document findings, deduped", () => {
const rows = [
makeAiRow({
id: "1",
ai_doc: doc("r1", ["a"]),
ai_check: makeCheck({ findings: ["Not found on r1.", "a"] }),
}),
makeAiRow({
id: "2",
ai_doc: doc("r1", ["a"]),
ai_check: makeCheck({ findings: ["Not found on r1.", "AI couldn't pair this document: boom"] }),
}),
makeAiRow({ id: "3", ai_doc: doc("r2", ["b"]) }),
];
expect(cardFindings(rows)).toEqual([
"a",
"b",
"Not found on r1.",
"AI couldn't pair this document: boom",
]);
});
it("picks the newest document for the chip", () => {
const rows = [
makeAiRow({ id: "1", ai_doc: doc("r1", [], "2026-10-10") }),

View file

@ -5,20 +5,27 @@ type AiDoc = NonNullable<ConfirmRow["ai_doc"]>;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** Union of document findings across a card's distinct reads, deduped, in order. */
/**
* Union of the document findings across a card's distinct reads, then each
* row's own line findings (`ai_check.findings`), deduped, in order.
*/
export function cardFindings(rows: ConfirmRow[]): string[] {
const seenReads = new Set<string>();
const seen = new Set<string>();
const out: string[] = [];
const add = (finding: string) => {
if (!finding || seen.has(finding)) return;
seen.add(finding);
out.push(finding);
};
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 finding of doc.findings ?? []) {
if (!finding || seen.has(finding)) continue;
seen.add(finding);
out.push(finding);
}
for (const finding of doc.findings ?? []) add(finding);
}
for (const row of rows) {
for (const finding of row.ai_check?.findings ?? []) add(finding);
}
return out;
}

View file

@ -101,6 +101,11 @@ describe("computeLineTicks", () => {
).qty,
).toBe("green");
});
it("pairing failed gives all grey (can't check), not red", () => {
expect(computeLineTicks(row(), check({ doc_line: null, same_item: null, pair_failed: true }))).toEqual({
sku: "grey", qty: "grey", desc: "grey", price: "grey",
});
});
it("a different or unrated pairing is never green, even when values agree", () => {
for (const same_item of ["different", null] as const) {
expect(computeLineTicks(row(), check({ same_item }))).toEqual({

View file

@ -34,6 +34,8 @@ export function effectiveSku(row: Pick<TickRow, "sku" | "line_sku">): string {
export function computeLineTicks(row: TickRow, check: CollectionAiCheck): LineTicks {
const doc = check.doc_line;
// Pairing failed: nothing was compared, so nothing can be judged.
if (check.pair_failed && !doc) return { sku: "grey", qty: "grey", desc: "grey", price: "grey" };
// Only a "sure"/"likely" pairing is a match; anything else doesn't match.
if (!doc || (check.same_item !== "sure" && check.same_item !== "likely")) {
return { sku: "red", qty: "red", desc: "red", price: "red" };

View file

@ -1006,11 +1006,13 @@ describe("runCollectionAiTick", () => {
expect(r.summary?.cards_checked).toBe(2);
expect(r.summary?.errors).toEqual(["PO202610002XX: pairing failed: pairing exploded"]);
expect(aiCheck("order_items", "c1")?.doc_line).not.toBeNull();
// The failure is recorded on the line (all red), not silently dropped.
// The failure is recorded on the line (grey ticks), not silently dropped.
expect(aiCheck("order_items", "c2")).toMatchObject({
doc_line: null,
pair_failed: true,
findings: ["AI couldn't pair this document: pairing exploded"],
});
expect(aiCheck("order_items", "c1")?.pair_failed).toBe(false);
});
it("does not retry a card whose pairing failed on the next tick; a manual check does", async () => {

View file

@ -779,11 +779,14 @@ async function checkCard(
}
}
const docNo = chosen.doc.document_no || "the document";
// No pairing and no successfully paired document covers this line,
// but one failed: the AI couldn't compare it (grey, not red).
const pairFailed = !chosen.docLine && failureFindings.length > 0 && covering.length === 0;
const lineFindings = chosen.docLine
? []
: checkedDocs.length > 0
? [`Not found on ${docNo}.`, ...failureFindings]
: failureFindings;
: pairFailed
? failureFindings
: [`Not found on ${docNo}.`, ...failureFindings];
const check: CollectionAiCheck = {
read_id: chosen.doc.id,
document_no: chosen.doc.document_no,
@ -796,6 +799,7 @@ async function checkCard(
findings: lineFindings,
checked_at: now.toISOString(),
model: chosen.model,
pair_failed: pairFailed,
};
await pb.collection(line.collection).update(line.id, { ai_check: check });
result.lines_checked += 1;

View file

@ -602,6 +602,8 @@ export interface CollectionAiCheck {
findings: string[];
checked_at: string;
model: string;
/** The AI couldn't pair the document(s) covering this line: ticks are grey. */
pair_failed?: boolean;
}
export interface CollectionAiRunSummary {