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:
parent
d1956bc1cf
commit
b49911601d
8 changed files with 68 additions and 11 deletions
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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") }),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue