feat(collection-confirm): spot a photo on the wrong PO and move it

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 <PO>" in the header; when open, "Invoice <no>
  is for <PO>, 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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-19 14:08:47 +02:00
parent 802565d2d8
commit af4cdc66da
12 changed files with 662 additions and 11 deletions

View file

@ -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 <PO>" and, when open, "Invoice <no> is for
<PO>, not this PO" with a **Move photo to <PO>** 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 |

View file

@ -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,

View file

@ -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" });
});
});

View file

@ -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 });
}
}

View file

@ -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(<PoCard rows={[makeAiRow({ ai_doc: wrongDoc })]} {...props} onMovePhoto={onMovePhoto} />);
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(<PoCard rows={[makeAiRow({ ai_doc: wrongDoc })]} {...props} />);
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(<PoCard rows={[makeAiRow({ ai_doc: { ...wrongDoc, other_po: null } })]} {...props} onMovePhoto={vi.fn()} />);
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({

View file

@ -141,6 +141,8 @@ type Props = {
onCheckWithAi?: () => Promise<void>;
/** 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<void>;
};
/**
@ -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<string>();
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<string | null>(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({
</span>
</>
) : null}
{wrongPo.length > 0 ? (
<>
<span className="px-1 text-border">·</span>
<span
className="font-medium text-amber-700 dark:text-amber-400"
data-testid={`po-card-wrong-po-hint-${poNumber}`}
>
Photo may be for {wrongPo[0].po.po_number}
</span>
</>
) : null}
{aiCheckedCount > 0 ? (
<>
<span className="px-1 text-border">·</span>
@ -892,6 +922,41 @@ export function PoCard({
</div>
)}
{wrongPo.map((w) => (
<div
key={`${w.source.id}/${w.source.filename}`}
className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs dark:border-amber-800 dark:bg-amber-950/30"
data-testid={`po-card-wrong-po-${poNumber}`}
>
<p>
<span className="font-medium">
Invoice {w.document_no || "on this photo"} is for {w.po.po_number}
{w.po.kind === "stock" ? " (stock order)" : ""}, not this PO.
</span>{" "}
The driver probably attached the photo to the wrong order.
</p>
{onMovePhoto ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={movingPhoto !== null}
onClick={async () => {
setMovingPhoto(w.source.filename);
try {
await onMovePhoto(w.source, w.po.po_number);
} finally {
setMovingPhoto(null);
}
}}
>
{movingPhoto === w.source.filename ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Move photo to {w.po.po_number}
</Button>
) : null}
</div>
))}
<AiFindings findings={aiFindings} notes={aiNotes} />
<footer className="flex items-center justify-between gap-2">

16
src/lib/po-reference.ts Normal file
View file

@ -0,0 +1,16 @@
/**
* Normalise a PO number as printed by a supplier ("PO 202610009 CAS",
* "PO202610009CAS/COLLECT") to the bare uppercase form we store. Pure and
* client-safe; the server's invoice matching and Collection Confirm both
* use it.
*/
export function normalizePoReference(raw: string): string {
let s = String(raw ?? "").toUpperCase();
// Strip common non-PO suffixes first (with or without leading
// separator). Whitelist-based so we never strip digits.
s = s.replace(
/\s*[\/\-]?\s*(MAIL|EMAIL|FAX|COURIER|COLLECT|HAND|DRIVER|COPY|POSTAL|URGENT|PICKUP|FAXED)\b/g,
"",
);
return s.replace(/[^A-Z0-9]/g, "");
}

View file

@ -1245,6 +1245,8 @@ describe("listCollectedItems — AI check passthrough", () => {
stockItems?: Record<string, unknown>[];
docReads?: Record<string, unknown>[];
posItems?: Record<string, unknown>[];
customerPos?: Record<string, unknown>[];
stockPos?: Record<string, unknown>[];
}) => {
const docReadsGetFullList = vi.fn().mockResolvedValue(opts.docReads ?? []);
const posGetFullList = vi.fn().mockResolvedValue(opts.posItems ?? []);
@ -1258,6 +1260,8 @@ describe("listCollectedItems — AI check passthrough", () => {
}
if (name === "collection_doc_reads") return { getFullList: docReadsGetFullList };
if (name === "stock_items") return { getFullList: posGetFullList };
if (name === "purchase_orders") return { getFullList: vi.fn().mockResolvedValue(opts.customerPos ?? []) };
if (name === "stock_purchases") return { getFullList: vi.fn().mockResolvedValue(opts.stockPos ?? []) };
throw new Error(`unexpected collection ${name}`);
}),
};
@ -1313,11 +1317,70 @@ describe("listCollectedItems — AI check passthrough", () => {
findings: ["Document 5028256 covers 2 POs: A, B."],
notes: ["Two top lines highlighted in pink."],
lines: [DOC_LINE],
po_refs: [],
source: null,
other_po: null,
});
expect(byId.get("oi-2")?.ai_doc?.read_id).toBe("r1");
expect(byId.get("oi-3")?.ai_doc).toBeNull();
});
describe("photo on the wrong PO", () => {
const read = (po_refs: string[]) => ({
id: "r1",
doc_type: "tax_invoice",
document_no: "00282979",
doc_date: "2026-10-19",
supplier_name: "B & R Products",
findings: [],
notes: [],
lines: [],
po_refs,
source_collection: "order_items",
source_id: "oi-1",
filename: "inv_abc.jpg",
});
const line = {
id: "oi-1",
item_status: "collected",
sku: "",
ai_check: check("r1"),
expand: { purchase_order: { id: "po-bly", po_number: "PO202610017BLY" } },
};
it("names the other PO when the invoice prints a different PO that exists", async () => {
const { admin } = makeAiAdmin({
orderItems: [line],
docReads: [read(["PO26100702STK"])],
stockPos: [{ id: "sp-1", purchase_number: "PO26100702STK" }],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const [row] = await listCollectedItems();
expect(row?.ai_doc?.other_po).toEqual({ kind: "stock", po_number: "PO26100702STK" });
expect(row?.ai_doc?.source).toEqual({ collection: "order_items", id: "oi-1", filename: "inv_abc.jpg" });
});
it("says nothing when the invoice names this card's PO (even among others)", async () => {
const { admin } = makeAiAdmin({
orderItems: [line],
docReads: [read(["PO 202610017 BLY", "PO26100702STK"])],
stockPos: [{ id: "sp-1", purchase_number: "PO26100702STK" }],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const [row] = await listCollectedItems();
expect(row?.ai_doc?.other_po).toBeNull();
});
it("says nothing when the other PO number isn't in the system", async () => {
const { admin } = makeAiAdmin({ orderItems: [line], docReads: [read(["PO99999999XXX"])] });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const [row] = await listCollectedItems();
expect(row?.ai_doc?.other_po).toBeNull();
});
});
it("gives rows without ai_check ai_check: null and ai_doc: null", async () => {
const { admin, docReadsGetFullList } = makeAiAdmin({
orderItems: [{ id: "oi-1", item_status: "collected" }],

View file

@ -3,6 +3,7 @@ import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import { compressFile } from "@/lib/server/attachment-compressor";
import type { CollectionAiCheck, CollectionDocLine, CollectionDocType } from "@/lib/types";
import { normaliseCode } from "@/lib/collection-ai-ticks";
import { normalizePoReference } from "@/lib/po-reference";
import {
appendCostNote,
@ -129,6 +130,15 @@ export type ConfirmRow = {
findings: string[];
/** What the AI noticed while reading the photo (rotated, year missing, …). */
notes?: string[];
/** PO numbers printed on the document, as read. */
po_refs?: string[];
/** The photo this read came from (what "Move photo" moves). */
source?: { collection: string; id: string; filename: string } | null;
/**
* Set when the document names another PO that exists, and not this
* row's PO: the driver probably attached the photo to the wrong order.
*/
other_po?: { kind: "customer" | "stock"; po_number: string } | null;
/** Every line the AI read from the document, paired or not. */
lines: CollectionDocLine[];
} | null;
@ -531,6 +541,43 @@ function pbQuote(value: string): string {
* POS lookup (`in_pos`). Every lookup is fail-soft — the page must work
* exactly as before when the AI data is missing or PB hiccups.
*/
function rowPoNumber(row: ConfirmRow): string {
return row.poNumber ?? (row.expand as { purchase_order?: { po_number?: string } } | undefined)?.purchase_order?.po_number ?? "";
}
/** Sets `ai_doc.other_po` on rows whose document names a different, existing PO. */
async function markOtherPos(pb: AdminPb, rows: ConfirmRow[]): Promise<void> {
const wanted = new Map<ConfirmRow, string[]>();
for (const row of rows) {
const doc = row.ai_doc;
const refs = [...new Set((doc?.po_refs ?? []).map(normalizePoReference).filter(Boolean))];
const own = normalizePoReference(rowPoNumber(row));
if (refs.length === 0 || !own || refs.includes(own)) continue;
wanted.set(row, refs);
}
const all = [...new Set([...wanted.values()].flat())];
if (all.length === 0) return;
const found = new Map<string, { kind: "customer" | "stock"; po_number: string }>();
for (const ids of chunked(all)) {
const [cust, stock] = await Promise.all([
pb.collection("purchase_orders").getFullList<{ po_number: string }>({
filter: ids.map((n) => `po_number = "${pbQuote(n)}"`).join(" || "),
fields: "id,po_number",
}),
pb.collection("stock_purchases").getFullList<{ purchase_number: string }>({
filter: ids.map((n) => `purchase_number = "${pbQuote(n)}"`).join(" || "),
fields: "id,purchase_number",
}),
]);
for (const c of cust) found.set(normalizePoReference(c.po_number), { kind: "customer", po_number: c.po_number });
for (const s of stock) found.set(normalizePoReference(s.purchase_number), { kind: "stock", po_number: s.purchase_number });
}
for (const [row, refs] of wanted) {
const hits = refs.flatMap((r) => (found.has(r) ? [found.get(r)!] : []));
if (hits.length === 1 && row.ai_doc) row.ai_doc = { ...row.ai_doc, other_po: hits[0] };
}
}
async function attachAiFields(pb: AdminPb, rows: ConfirmRow[]): Promise<void> {
for (const row of rows) {
row.ai_check = row.ai_check ?? null;
@ -552,9 +599,14 @@ async function attachAiFields(pb: AdminPb, rows: ConfirmRow[]): Promise<void> {
findings?: unknown;
notes?: unknown;
lines?: unknown;
po_refs?: unknown;
source_collection?: string;
source_id?: string;
filename?: string;
}>({
filter: ids.map((id) => `id = "${pbQuote(id)}"`).join(" || "),
fields: "id,doc_type,document_no,doc_date,supplier_name,findings,notes,lines",
fields:
"id,doc_type,document_no,doc_date,supplier_name,findings,notes,lines,po_refs,source_collection,source_id,filename",
});
for (const r of found) {
readsById.set(r.id, {
@ -566,6 +618,12 @@ async function attachAiFields(pb: AdminPb, rows: ConfirmRow[]): Promise<void> {
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[]) : [],
po_refs: Array.isArray(r.po_refs) ? (r.po_refs as string[]) : [],
source:
r.source_collection && r.source_id && r.filename
? { collection: r.source_collection, id: r.source_id, filename: r.filename }
: null,
other_po: null,
});
}
}
@ -587,6 +645,14 @@ async function attachAiFields(pb: AdminPb, rows: ConfirmRow[]): Promise<void> {
};
}
// 1b. Photo on the wrong PO: the document prints PO numbers, none is
// this row's, and exactly one of them is a PO we have.
try {
await markOtherPos(pb, rows);
} catch {
// Only feeds a hint on the card; never fail the list over it.
}
// 2. In POS: a stock_items row whose normalised SKU equals the line's.
const customer = rows.filter((r) => r.source !== "stock");
const skus = [...new Set(customer.map((r) => (r.sku ?? "").trim()).filter((s) => s !== ""))];

View file

@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// ─── Tiny in-memory PB: getOne / getFullList (a = "b" joined by && / ||) / update / delete
type Rec = Record<string, unknown> & { id: string };
let db: Record<string, Map<string, Rec>> = {};
const writes: { coll: string; id: string; data: unknown }[] = [];
const deletes: { coll: string; id: string }[] = [];
const table = (n: string) => (db[n] ??= new Map());
function matches(r: Rec, filter: string): boolean {
return filter.split("||").some((alt) =>
alt.split("&&").every((cond) => {
const m = cond.trim().replace(/^\(|\)$/g, "").match(/^(\w+)\s*=\s*"(.*)"$/);
if (!m) throw new Error(`bad filter ${cond}`);
return String(r[m[1]] ?? "") === m[2];
}),
);
}
const pb = {
collection: (name: string) => ({
getOne: async (id: string) => {
const r = table(name).get(id);
if (!r) throw Object.assign(new Error("not found"), { status: 404 });
return structuredClone(r);
},
getFullList: async (opts: { filter?: string } = {}) =>
[...table(name).values()].filter((r) => !opts.filter || matches(r, opts.filter)).map((r) => structuredClone(r)),
update: async (id: string, data: unknown) => {
writes.push({ coll: name, id, data });
const r = table(name).get(id)!;
if (data instanceof FormData) {
for (const [k, v] of data.entries()) {
const name2 = (v as File).name;
if (k.endsWith("+")) r[k.slice(0, -1)] = [...((r[k.slice(0, -1)] as string[]) ?? []), name2];
else r[k] = name2;
}
} else {
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
if (k.endsWith("-")) r[k.slice(0, -1)] = ((r[k.slice(0, -1)] as string[]) ?? []).filter((f) => !(v as string[]).includes(f));
else r[k] = v;
}
}
return structuredClone(r);
},
delete: async (id: string) => {
deletes.push({ coll: name, id });
table(name).delete(id);
return true;
},
}),
};
vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: async () => pb }));
import { movePhotoToPo, MovePhotoError } from "./collection-photo-move-service";
const fetchFile = vi.fn(async () => ({ bytes: Buffer.from("jpeg-bytes"), contentType: "image/jpeg" }));
const SOURCE = { collection: "order_items", id: "oi-thin", filename: "inv_abc.jpg" };
beforeEach(() => {
db = {};
writes.length = 0;
deletes.length = 0;
fetchFile.mockClear();
table("purchase_orders").set("po-bly", { id: "po-bly", po_number: "PO202610017BLY" });
table("purchase_orders").set("po-cas", { id: "po-cas", po_number: "PO202610009CAS" });
table("order_items").set("oi-thin", {
id: "oi-thin",
purchase_order: "po-bly",
item_status: "collected",
order_item_attachements: ["inv_abc.jpg", "other.jpg"],
ai_check: { read_id: "r1" },
});
table("order_items").set("oi-cas1", { id: "oi-cas1", purchase_order: "po-cas", item_status: "assigned", order_item_attachements: [] });
table("order_items").set("oi-cas2", { id: "oi-cas2", purchase_order: "po-cas", item_status: "collected", order_item_attachements: [] });
table("stock_purchases").set("sp-1", { id: "sp-1", purchase_number: "PO26100702STK", additional_attachments: "" });
table("stock_purchase_items").set("spi-1", { id: "spi-1", purchase: "sp-1", ai_check: null });
table("collection_doc_reads").set("r1", { id: "r1", source_collection: "order_items", source_id: "oi-thin", filename: "inv_abc.jpg" });
table("collection_doc_reads").set("r2", { id: "r2", source_collection: "order_items", source_id: "oi-thin", filename: "other.jpg" });
});
describe("movePhotoToPo", () => {
it("moves the photo onto the stock PO, off the wrong line, and clears the stale AI read", async () => {
const r = await movePhotoToPo({ source: SOURCE, to_po_number: "PO26100702STK" }, { fetchFile });
expect(r).toEqual({ kind: "stock", po_number: "PO26100702STK", target_id: "sp-1" });
expect(table("stock_purchases").get("sp-1")?.additional_attachments).toBe("inv_abc.jpg");
expect(table("order_items").get("oi-thin")?.order_item_attachements).toEqual(["other.jpg"]);
expect(deletes).toEqual([{ coll: "collection_doc_reads", id: "r1" }]);
expect(table("order_items").get("oi-thin")?.ai_check).toBeNull();
});
it("puts it on a collected line of a customer PO (normalising the printed number)", async () => {
const r = await movePhotoToPo({ source: SOURCE, to_po_number: "PO 202610009 CAS" }, { fetchFile });
expect(r).toEqual({ kind: "customer", po_number: "PO202610009CAS", target_id: "oi-cas2" });
expect(table("order_items").get("oi-cas2")?.order_item_attachements).toEqual(["inv_abc.jpg"]);
});
it("refuses a stock PO that already has its one photo", async () => {
table("stock_purchases").get("sp-1")!.additional_attachments = "existing.jpg";
await expect(movePhotoToPo({ source: SOURCE, to_po_number: "PO26100702STK" }, { fetchFile })).rejects.toMatchObject({
status: 409,
});
expect(table("order_items").get("oi-thin")?.order_item_attachements).toEqual(["inv_abc.jpg", "other.jpg"]);
});
it("refuses an unknown PO, the same PO, or a photo that isn't on the source", async () => {
await expect(movePhotoToPo({ source: SOURCE, to_po_number: "PO000" }, { fetchFile })).rejects.toBeInstanceOf(MovePhotoError);
await expect(movePhotoToPo({ source: SOURCE, to_po_number: "PO202610017BLY" }, { fetchFile })).rejects.toMatchObject({ status: 400 });
await expect(
movePhotoToPo({ source: { ...SOURCE, filename: "nope.jpg" }, to_po_number: "PO26100702STK" }, { fetchFile }),
).rejects.toMatchObject({ status: 404 });
expect(writes).toEqual([]);
});
});

View file

@ -0,0 +1,159 @@
/**
* Move a driver photo to the PO it belongs to.
*
* Drivers photograph invoices per item and can attach one to the wrong
* order (2026-10-19: B&R invoice 00282979 for stock order PO26100702STK
* landed on the thinners line of PO202610017BLY). The AI reads the PO
* number off the invoice and Collection Confirm offers "Move photo"; this
* does the move:
*
* 1. resolve the target PO (customer `purchase_orders` or stock
* `stock_purchases`) from the printed number;
* 2. copy the file onto it — a collected line's `order_item_attachements`
* for a customer PO, the single `additional_attachments` slot for a
* stock PO (refused when that slot is taken, so nothing is replaced);
* 3. take it off the source record;
* 4. delete the AI's read of that file and clear the checks that pointed
* at it, so the tick (or "Check with AI") reads it again on its new PO.
*
* The copy happens before the removal: a failure part-way leaves the photo
* on both records, never on neither.
*/
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import { normalizePoReference } from "@/lib/po-reference";
const READS = "collection_doc_reads";
const CUSTOMER_POS = "purchase_orders";
const STOCK_POS = "stock_purchases";
const STOCK_LINES = "stock_purchase_items";
export class MovePhotoError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
}
}
export interface PhotoSource {
collection: string;
id: string;
filename: string;
}
export interface MovePhotoResult {
kind: "customer" | "stock";
po_number: string;
/** The record the photo now sits on (an order line or a stock purchase). */
target_id: string;
}
type Rec = Record<string, unknown> & { id: string };
type FetchFile = (src: PhotoSource) => Promise<{ bytes: Buffer; contentType: string }>;
const q = (s: string) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const fileList = (v: unknown): string[] =>
Array.isArray(v) ? (v as string[]) : typeof v === "string" && v ? [v] : [];
async function defaultFetchFile(src: PhotoSource): Promise<{ bytes: Buffer; contentType: string }> {
const pb = await getAdminPocketBase();
const token = await pb.files.getToken();
const url = pb.files.getURL({ id: src.id, collectionName: src.collection }, src.filename, { token });
const res = await fetch(url);
if (!res.ok) throw new MovePhotoError(`Couldn't fetch the photo (${res.status})`, 502);
return {
bytes: Buffer.from(await res.arrayBuffer()),
contentType: res.headers.get("content-type") || "application/octet-stream",
};
}
export async function movePhotoToPo(
args: { source: PhotoSource; to_po_number: string },
deps: { fetchFile?: FetchFile } = {},
): Promise<MovePhotoResult> {
const orderItems = getOrderItemsCollectionName();
const { source } = args;
if (source.collection !== orderItems && source.collection !== STOCK_POS) {
throw new MovePhotoError(`Can't move photos from ${source.collection}`, 400);
}
const target = normalizePoReference(args.to_po_number);
if (!target) throw new MovePhotoError("to_po_number is required", 400);
const pb = await getAdminPocketBase();
// Source record and the photo on it.
const src = (await pb.collection(source.collection).getOne(source.id).catch(() => null)) as Rec | null;
const field = source.collection === STOCK_POS ? "additional_attachments" : "order_item_attachements";
if (!src || !fileList(src[field]).includes(source.filename)) {
throw new MovePhotoError("That photo isn't on the record any more", 404);
}
const srcPoId = source.collection === STOCK_POS ? src.id : String(src.purchase_order ?? "");
// Target PO (stored numbers are already in the bare form).
const [cust, stock] = await Promise.all([
pb.collection(CUSTOMER_POS).getFullList<Rec>({ filter: `po_number = "${q(target)}"` }),
pb.collection(STOCK_POS).getFullList<Rec>({ filter: `purchase_number = "${q(target)}"` }),
]);
const hits = [
...cust.map((r) => ({ kind: "customer" as const, rec: r, po_number: String(r.po_number) })),
...stock.map((r) => ({ kind: "stock" as const, rec: r, po_number: String(r.purchase_number) })),
];
if (hits.length === 0) throw new MovePhotoError(`No PO ${args.to_po_number} in the system`, 404);
if (hits.length > 1) throw new MovePhotoError(`More than one PO matches ${args.to_po_number}`, 409);
const hit = hits[0];
if (hit.rec.id === srcPoId) throw new MovePhotoError("The photo is already on that PO", 400);
// Where the photo goes.
let targetCollection: string;
let targetId: string;
let targetField: string;
if (hit.kind === "stock") {
if (fileList(hit.rec.additional_attachments).length > 0) {
throw new MovePhotoError(`${hit.po_number} already has a photo; add this one there by hand`, 409);
}
targetCollection = STOCK_POS;
targetId = hit.rec.id;
targetField = "additional_attachments";
} else {
const lines = await pb.collection(orderItems).getFullList<Rec>({ filter: `purchase_order = "${q(hit.rec.id)}"` });
const line = lines.find((l) => l.item_status === "collected") ?? lines[0];
if (!line) throw new MovePhotoError(`${hit.po_number} has no lines to attach the photo to`, 409);
targetCollection = orderItems;
targetId = line.id;
targetField = "order_item_attachements+";
}
// 1. Copy onto the target.
const { bytes, contentType } = await (deps.fetchFile ?? defaultFetchFile)(source);
const form = new FormData();
form.append(targetField, new File([new Uint8Array(bytes)], source.filename, { type: contentType }));
await pb.collection(targetCollection).update(targetId, form);
// 2. Off the source.
if (source.collection === STOCK_POS) {
await pb.collection(STOCK_POS).update(source.id, { additional_attachments: null });
} else {
await pb.collection(orderItems).update(source.id, { "order_item_attachements-": [source.filename] });
}
// 3. Forget the AI's read of it where it was, and the checks built on it.
const reads = await pb.collection(READS).getFullList<Rec>({
filter: `source_collection = "${q(source.collection)}" && source_id = "${q(source.id)}" && filename = "${q(source.filename)}"`,
});
const readIds = new Set(reads.map((r) => r.id));
for (const id of readIds) await pb.collection(READS).delete(id);
if (readIds.size > 0) {
const srcLines =
source.collection === STOCK_POS
? { coll: STOCK_LINES, items: await pb.collection(STOCK_LINES).getFullList<Rec>({ filter: `purchase = "${q(source.id)}"` }) }
: { coll: orderItems, items: await pb.collection(orderItems).getFullList<Rec>({ filter: `purchase_order = "${q(srcPoId)}"` }) };
for (const l of srcLines.items) {
const readId = (l.ai_check as { read_id?: string } | null)?.read_id;
if (readId && readIds.has(readId)) await pb.collection(srcLines.coll).update(l.id, { ai_check: null });
}
}
return { kind: hit.kind, po_number: hit.po_number, target_id: targetId };
}

View file

@ -335,16 +335,8 @@ export type MatchAndReceiveResult =
* supplier almost certainly meant the same PO, just formatted
* differently on the invoice.
*/
export function normalizePoReference(raw: string): string {
let s = String(raw ?? "").toUpperCase();
// Strip common non-PO suffixes first (with or without leading
// separator). Whitelist-based so we never strip digits.
s = s.replace(
/\s*[\/\-]?\s*(MAIL|EMAIL|FAX|COURIER|COLLECT|HAND|DRIVER|COPY|POSTAL|URGENT|PICKUP|FAXED)\b/g,
"",
);
return s.replace(/[^A-Z0-9]/g, "");
}
export { normalizePoReference } from "@/lib/po-reference";
import { normalizePoReference } from "@/lib/po-reference";
/** Slugify a supplier name: lowercase, strip PTY/LTD/CC/INC/LLC/CO, collapse. */
export function slugifySupplierName(name: string): string {