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:
parent
802565d2d8
commit
af4cdc66da
12 changed files with 662 additions and 11 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
67
src/app/api/v1/collection-confirm/move-photo/route.test.ts
Normal file
67
src/app/api/v1/collection-confirm/move-photo/route.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
42
src/app/api/v1/collection-confirm/move-photo/route.ts
Normal file
42
src/app/api/v1/collection-confirm/move-photo/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
16
src/lib/po-reference.ts
Normal 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, "");
|
||||
}
|
||||
|
|
@ -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" }],
|
||||
|
|
|
|||
|
|
@ -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 !== ""))];
|
||||
|
|
|
|||
113
src/lib/server/collection-photo-move-service.test.ts
Normal file
113
src/lib/server/collection-photo-move-service.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
159
src/lib/server/collection-photo-move-service.ts
Normal file
159
src/lib/server/collection-photo-move-service.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue