From af4cdc66da347aa98534af8c5e03c7b8c1b94f98 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 19 Oct 2026 14:08:47 +0200 Subject: [PATCH] feat(collection-confirm): spot a photo on the wrong PO and move it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drivers photograph invoices per item and can't see which order a photo lands on. B&R invoice 00282979 (stock order PO26100702STK) ended up on the thinners line of PO202610017BLY; the AI read "PO26100702STK" off it but the card only said "not found". - The card list now carries each read's po_refs and source file, and sets ai_doc.other_po when the document names a PO that exists and isn't the row's PO (none named, or this card's PO among them: no hint). - PO card: "Photo may be for " in the header; when open, "Invoice is for , not this PO" with a Move photo button. Nothing moves on its own. - POST /api/v1/collection-confirm/move-photo (browser session) → movePhotoToPo: copies the photo to the target (a collected line of a customer PO, or the stock purchase's single photo slot — refused when taken), removes it from the source, deletes the AI's read of it and the checks built on it so it is re-read where it now belongs. - normalizePoReference moves to the client-safe src/lib/po-reference.ts (re-exported from the matching service). Co-Authored-By: Claude Opus 5.5 --- docs/collection-ai-checks.md | 11 ++ src/app/(app)/collection-confirm/page.tsx | 21 +++ .../move-photo/route.test.ts | 67 ++++++++ .../v1/collection-confirm/move-photo/route.ts | 42 +++++ .../collection-confirm/po-card-ai.test.tsx | 36 ++++ src/components/collection-confirm/po-card.tsx | 65 +++++++ src/lib/po-reference.ts | 16 ++ .../server/collection-confirm-service.test.ts | 63 +++++++ src/lib/server/collection-confirm-service.ts | 68 +++++++- .../collection-photo-move-service.test.ts | 113 +++++++++++++ .../server/collection-photo-move-service.ts | 159 ++++++++++++++++++ ...stock-purchase-invoice-matching-service.ts | 12 +- 12 files changed, 662 insertions(+), 11 deletions(-) create mode 100644 src/app/api/v1/collection-confirm/move-photo/route.test.ts create mode 100644 src/app/api/v1/collection-confirm/move-photo/route.ts create mode 100644 src/lib/po-reference.ts create mode 100644 src/lib/server/collection-photo-move-service.test.ts create mode 100644 src/lib/server/collection-photo-move-service.ts diff --git a/docs/collection-ai-checks.md b/docs/collection-ai-checks.md index c5ce304..1153468 100644 --- a/docs/collection-ai-checks.md +++ b/docs/collection-ai-checks.md @@ -95,6 +95,17 @@ The last run (files read, cards checked) is shown in Settings, AI, Collection checks, and stored in `ai_settings.collection_ai_last_run_summary`. +Photo on the wrong PO (added 2026-10-19): when the AI reads a PO number +on a photo that isn't the card's PO, and that number is a PO we have, the +card says "Photo may be for " and, when open, "Invoice is for +, not this PO" with a **Move photo to ** button. The move is a +person's call — nothing moves on its own. It copies the photo onto the +other PO (a collected line of a customer PO, or the stock purchase's one +photo slot — refused if that slot is taken), removes it from this line, +and deletes the AI's read of it so it is read again where it now belongs +(`POST /api/v1/collection-confirm/move-photo`, +`src/lib/server/collection-photo-move-service.ts`). + ## Reading the ticks | Colour | Meaning | diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index 9812af1..95bcea3 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -514,6 +514,26 @@ export default function CollectionConfirmPage() { [addRowsToPos, load], ); + /** Move a driver photo onto the PO its invoice names, then reload. */ + const handleMovePhoto = useCallback( + async (source: { collection: string; id: string; filename: string }, toPoNumber: string) => { + try { + const response = await fetch("/api/v1/collection-confirm/move-photo", { + method: "POST", + headers: authedJsonHeaders(), + body: JSON.stringify({ source, to_po_number: toPoNumber }), + }); + const data = (await response.json().catch(() => ({}))) as { po_number?: string; error?: string }; + if (!response.ok) throw new Error(data.error || `Moving the photo failed (HTTP ${response.status})`); + toast.success(`Photo moved to ${data.po_number ?? toPoNumber}. The AI reads it there on its next run.`); + await load({ silent: true }); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, + [authedJsonHeaders, load], + ); + const handleCheckWithAi = useCallback( async (poNumber: string) => { try { @@ -988,6 +1008,7 @@ export default function CollectionConfirmPage() { onClearRows={(payload) => handleClearRows(group, payload)} onAddToPos={(rowId) => void handleAddToPos([rowId])} onCheckWithAi={() => handleCheckWithAi(group.poNumber)} + onMovePhoto={handleMovePhoto} onConfirmAndAddToPos={() => setConfirmState({ poNumber: group.poNumber, diff --git a/src/app/api/v1/collection-confirm/move-photo/route.test.ts b/src/app/api/v1/collection-confirm/move-photo/route.test.ts new file mode 100644 index 0000000..ccd3839 --- /dev/null +++ b/src/app/api/v1/collection-confirm/move-photo/route.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const movePhotoToPo = vi.fn(); +vi.mock("@/lib/server/collection-photo-move-service", async () => { + class MovePhotoError extends Error { + constructor(message: string, public status: number) { + super(message); + } + } + return { movePhotoToPo: (...a: unknown[]) => movePhotoToPo(...a), MovePhotoError }; +}); +const getBrowserSessionUser = vi.fn(); +vi.mock("@/lib/server/browser-session", () => ({ + getBrowserSessionUser: (...a: unknown[]) => getBrowserSessionUser(...a), +})); + +import { POST } from "./route"; +import { MovePhotoError } from "@/lib/server/collection-photo-move-service"; + +const body = { + source: { collection: "order_items", id: "oi-1", filename: "inv.jpg" }, + to_po_number: "PO26100702STK", +}; +const req = (b: unknown) => + new NextRequest("http://localhost/api/v1/collection-confirm/move-photo", { + method: "POST", + headers: { authorization: "Bearer tok", "content-type": "application/json" }, + body: JSON.stringify(b), + }); + +beforeEach(() => { + vi.clearAllMocks(); + getBrowserSessionUser.mockResolvedValue({ id: "u1", name: "Sam" }); +}); + +describe("POST /api/v1/collection-confirm/move-photo", () => { + it("401s without a session", async () => { + getBrowserSessionUser.mockRejectedValue(Object.assign(new Error("Not authenticated."), { status: 401 })); + expect((await POST(req(body))).status).toBe(401); + expect(movePhotoToPo).not.toHaveBeenCalled(); + }); + + it.each([ + [{ ...body, to_po_number: "" }], + [{ ...body, source: { collection: "order_items", id: "oi-1" } }], + [{}], + ])("400s for a bad body %#", async (b) => { + expect((await POST(req(b))).status).toBe(400); + expect(movePhotoToPo).not.toHaveBeenCalled(); + }); + + it("moves the photo and returns where it went", async () => { + movePhotoToPo.mockResolvedValue({ kind: "stock", po_number: "PO26100702STK", target_id: "sp-1" }); + const res = await POST(req(body)); + expect(movePhotoToPo).toHaveBeenCalledWith(body); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ kind: "stock", po_number: "PO26100702STK", target_id: "sp-1" }); + }); + + it("passes the service's status through", async () => { + movePhotoToPo.mockRejectedValue(new MovePhotoError("already has a photo", 409)); + const res = await POST(req(body)); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "already has a photo" }); + }); +}); diff --git a/src/app/api/v1/collection-confirm/move-photo/route.ts b/src/app/api/v1/collection-confirm/move-photo/route.ts new file mode 100644 index 0000000..6c04315 --- /dev/null +++ b/src/app/api/v1/collection-confirm/move-photo/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { movePhotoToPo, MovePhotoError } from "@/lib/server/collection-photo-move-service"; + +/** + * POST /api/v1/collection-confirm/move-photo + * + * "Move photo" on a Collection Confirm card: the AI read a PO number on + * the driver's photo that isn't this card's PO. Body: + * `{ source: { collection, id, filename }, to_po_number }`. + * + * Auth: browser session (PB token forwarded in `Authorization`). + * Response: `{ kind, po_number, target_id }`; 400/404/409 with `{ error }`. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const str = (v: unknown) => (typeof v === "string" ? v.trim() : ""); + +export async function POST(req: NextRequest) { + try { + await getBrowserSessionUser(req); + const body = (await req.json().catch(() => ({}))) as { + source?: { collection?: unknown; id?: unknown; filename?: unknown }; + to_po_number?: unknown; + }; + const source = { + collection: str(body.source?.collection), + id: str(body.source?.id), + filename: str(body.source?.filename), + }; + const to = str(body.to_po_number); + if (!source.collection || !source.id || !source.filename || !to) { + return NextResponse.json({ error: "source {collection, id, filename} and to_po_number are required" }, { status: 400 }); + } + return NextResponse.json(await movePhotoToPo({ source, to_po_number: to })); + } catch (err) { + const message = err instanceof Error ? err.message : "Moving the photo failed"; + const status = err instanceof MovePhotoError ? err.status : (err as { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/components/collection-confirm/po-card-ai.test.tsx b/src/components/collection-confirm/po-card-ai.test.tsx index fbf6375..b0f176a 100644 --- a/src/components/collection-confirm/po-card-ai.test.tsx +++ b/src/components/collection-confirm/po-card-ai.test.tsx @@ -119,6 +119,42 @@ describe("PoCard AI layer", () => { expect(box).toHaveTextContent("Year not printed on the document date"); }); + describe("photo on the wrong PO", () => { + const wrongDoc = { + read_id: "r", + doc_type: "tax_invoice" as const, + document_no: "00282979", + doc_date: "2026-10-19", + supplier_name: "B & R", + findings: [], + lines: [], + po_refs: ["PO26100702STK"], + source: { collection: "order_items", id: "oi-thin", filename: "inv.jpg" }, + other_po: { kind: "stock" as const, po_number: "PO26100702STK" }, + }; + + it("says which PO the invoice names and moves the photo there on request", async () => { + const onMovePhoto = vi.fn(async () => {}); + render(); + const notice = screen.getByTestId("po-card-wrong-po-PO-1"); + expect(notice).toHaveTextContent("Invoice 00282979 is for PO26100702STK (stock order), not this PO."); + expect(screen.getByTestId("po-card-wrong-po-hint-PO-1")).toHaveTextContent("Photo may be for PO26100702STK"); + await userEvent.click(screen.getByRole("button", { name: "Move photo to PO26100702STK" })); + expect(onMovePhoto).toHaveBeenCalledWith(wrongDoc.source, "PO26100702STK"); + }); + + it("shows the notice without a button when moving isn't wired up", () => { + render(); + expect(screen.getByTestId("po-card-wrong-po-PO-1")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /move photo/i })).not.toBeInTheDocument(); + }); + + it("shows nothing when the invoice is for this PO", () => { + render(); + expect(screen.queryByTestId("po-card-wrong-po-PO-1")).not.toBeInTheDocument(); + }); + }); + it("shows a line's own findings (e.g. a pairing failure) in the findings box", () => { const row = makeAiRow({ ai_check: makeCheck({ diff --git a/src/components/collection-confirm/po-card.tsx b/src/components/collection-confirm/po-card.tsx index fe143cd..5e8fa4e 100644 --- a/src/components/collection-confirm/po-card.tsx +++ b/src/components/collection-confirm/po-card.tsx @@ -141,6 +141,8 @@ type Props = { onCheckWithAi?: () => Promise; /** Confirm for invoicing, then add every not-in-POS customer line to the POS. */ onConfirmAndAddToPos?: () => void; + /** Move a driver photo whose invoice names another PO onto that PO. */ + onMovePhoto?: (source: { collection: string; id: string; filename: string }, toPoNumber: string) => Promise; }; /** @@ -199,6 +201,7 @@ export function PoCard({ onAddToPos, onCheckWithAi, onConfirmAndAddToPos, + onMovePhoto, }: Props) { const itemCount = rows.length; const [checkingAi, setCheckingAi] = useState(false); @@ -206,6 +209,22 @@ export function PoCard({ const aiDoc = useMemo(() => cardDocument(rows), [rows]); const aiFindings = useMemo(() => cardFindings(rows), [rows]); const aiNotes = useMemo(() => cardNotes(rows), [rows]); + // A photo whose invoice names another PO (the driver attached it to + // the wrong order). One notice per photo. + const wrongPo = useMemo(() => { + const seen = new Set(); + const out: Array<{ document_no: string; po: { kind: "customer" | "stock"; po_number: string }; source: { collection: string; id: string; filename: string } }> = []; + for (const r of rows) { + const d = r.ai_doc; + if (!d?.other_po || !d.source) continue; + const key = `${d.source.collection}/${d.source.id}/${d.source.filename}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ document_no: d.document_no, po: d.other_po, source: d.source }); + } + return out; + }, [rows]); + const [movingPhoto, setMovingPhoto] = useState(null); // 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. @@ -556,6 +575,17 @@ export function PoCard({ ) : null} + {wrongPo.length > 0 ? ( + <> + · + + Photo may be for {wrongPo[0].po.po_number} + + + ) : null} {aiCheckedCount > 0 ? ( <> · @@ -892,6 +922,41 @@ export function PoCard({ )} + {wrongPo.map((w) => ( +
+

+ + Invoice {w.document_no || "on this photo"} is for {w.po.po_number} + {w.po.kind === "stock" ? " (stock order)" : ""}, not this PO. + {" "} + The driver probably attached the photo to the wrong order. +

+ {onMovePhoto ? ( + + ) : null} +
+ ))} +