fix(collection-ai): stock rows tick and show their own line SKU

I4: ConfirmRow.line_sku carries stock_purchase_items.sku; the SKU tick,
the AI robot and (for stock rows) the SKU column use line_sku before the
catalogue sku via effectiveSku. The SKU pencil keeps line_sku in step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 10:14:17 +02:00
parent b49911601d
commit 457ad30f12
5 changed files with 64 additions and 6 deletions

View file

@ -267,9 +267,13 @@ export default function CollectionConfirmPage() {
async (id: string, sku: string) => {
const prevRow = rows?.find((r) => r.id === id) ?? null;
const prevSku = prevRow?.sku;
const prevLineSku = prevRow?.line_sku;
const endpoint = prevRow ? endpointFor(prevRow, "sku") : null;
// Stock rows display their own line SKU, so keep it in step.
setRows((prev) =>
prev?.map((r) => (r.id === id ? { ...r, sku } : r)) ?? null,
prev?.map((r) =>
r.id === id ? { ...r, sku, ...(r.source === "stock" ? { line_sku: sku } : {}) } : r,
) ?? null,
);
if (!endpoint) return;
try {
@ -285,7 +289,15 @@ export default function CollectionConfirmPage() {
const message = err instanceof Error ? err.message : String(err);
toast.error(message);
setRows((prev) =>
prev?.map((r) => (r.id === id ? { ...r, sku: prevSku ?? undefined } : r)) ?? null,
prev?.map((r) =>
r.id === id
? {
...r,
sku: prevSku ?? undefined,
...(r.source === "stock" ? { line_sku: prevLineSku } : {}),
}
: r,
) ?? null,
);
}
},

View file

@ -90,6 +90,21 @@ describe("ItemsTable AI layer", () => {
expect(screen.queryByTestId("ai-sku-robot-b")).not.toBeInTheDocument();
});
it("stock rows: shows, ticks and robot-marks the line's own SKU (line_sku)", () => {
const stock = makeAiRow({
id: "s1",
source: "stock",
sku: undefined,
line_sku: "SK-001",
ai_check: makeCheck({ sku_filled_value: "SK-001" }),
});
render(<ItemsTable rows={[stock]} onCostChange={noop} onSkuChange={noop} />);
const rowEl = screen.getByTestId("ai-ticks-s1").closest("tr")!;
expect(within(rowEl).getByText("SK-001")).toBeInTheDocument();
expect(screen.getByTestId("ai-sku-robot-s1")).toBeInTheDocument();
expect(screen.getByLabelText(/^SKU: matches/)).toBeInTheDocument();
});
it("shows In POS with a stock link when in_pos", () => {
const row = makeAiRow({ in_pos: true, pos_stock_item_id: "stock-77" });
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} onAddToPos={noop} />);

View file

@ -14,6 +14,7 @@ import { useState } from "react";
import { Input } from "@/components/ui/input";
import {
computeLineTicks,
effectiveSku,
showsAiFilledSku,
type TickState,
} from "@/lib/collection-ai-ticks";
@ -92,7 +93,7 @@ function AiTicks({ row }: { row: ConfirmRow }) {
<Tick
name="SKU"
state={t.sku}
detail={`Ours: ${row.sku || "none"} / Document: ${doc?.code || "none"}`}
detail={`Ours: ${effectiveSku(row) || "none"} / Document: ${doc?.code || "none"}`}
/>
<Tick
name="Qty"
@ -113,6 +114,11 @@ function AiTicks({ row }: { row: ConfirmRow }) {
);
}
/** Stock rows show their own line SKU when set; everything else as before. */
function shownSku(row: ConfirmRow): string | undefined {
return row.line_sku || row.sku;
}
type EditState = {
id: string;
value: string;
@ -188,7 +194,7 @@ export function ItemsTable({
const startSkuEdit = (row: ConfirmRow) => {
setEdit({
id: row.id,
value: row.sku ?? "",
value: shownSku(row) ?? "",
error: null,
field: "sku",
});
@ -329,8 +335,8 @@ export function ItemsTable({
/>
) : (
<div className="flex items-center gap-1 font-mono text-xs">
<span data-sku-value={row.sku ?? ""} className="min-w-0 flex-1 truncate">
{row.sku ?? "—"}
<span data-sku-value={shownSku(row) ?? ""} className="min-w-0 flex-1 truncate">
{shownSku(row) ?? "—"}
</span>
{showsAiFilledSku(row, row.ai_check) ? (
<span

View file

@ -1384,6 +1384,23 @@ describe("listCollectedItems — AI check passthrough", () => {
expect(byId.get("oi-3")).toMatchObject({ in_pos: false, pos_stock_item_id: null });
});
it("carries a stock row's own SKU as line_sku (and leaves sku as before)", async () => {
const { admin } = makeAiAdmin({
stockItems: [
{ id: "spi-1", description: "x", quantity: 2, quantity_received: 1, status: "collected", stock_item: "st-1", sku: "SM2NUT9B506" },
{ id: "spi-2", description: "y", quantity: 2, quantity_received: 1, status: "collected", stock_item: "st-2", sku: "" },
],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
const byId = new Map(rows.map((r) => [r.id, r]));
expect(byId.get("spi-1")?.line_sku).toBe("SM2NUT9B506");
expect(byId.get("spi-2")?.line_sku).toBe("");
expect(byId.get("spi-1")?.sku).toBeUndefined();
});
it("marks stock rows in_pos with their catalogue item", async () => {
const { admin, posGetFullList } = makeAiAdmin({
stockItems: [

View file

@ -109,6 +109,12 @@ export type ConfirmRow = {
// collection-confirm page's supplier-SKU pencil and written via
// `updateStockPurchaseItem` (patchable whitelist member).
supplier_sku?: string;
/**
* Stock-only: the line's own `stock_purchase_items.sku` (what a rep or
* the AI fill writes). Shown, ticked and robot-marked in place of `sku`
* when present.
*/
line_sku?: string;
supplier?: string;
poNumber?: string;
/** AI check of this line against the driver's invoice photo. */
@ -339,6 +345,7 @@ export async function listCollectedItems(): Promise<ConfirmRow[]> {
stockRows as unknown as Array<{
id: string;
stock_item: string;
sku?: string | null;
description: string;
quantity: number;
quantity_received: number;
@ -369,6 +376,7 @@ export async function listCollectedItems(): Promise<ConfirmRow[]> {
quantity: s.quantity,
item_status: s.status ?? undefined,
sku: s.expand?.stock_item?.sku,
line_sku: typeof s.sku === "string" ? s.sku.trim() : "",
supplier: s.expand?.purchase?.expand?.supplier?.id ?? undefined,
stock_line_status: s.status ?? null,
ai_check: (s as { ai_check?: CollectionAiCheck | null }).ai_check ?? null,