feat(driver): match an invoice photo to the driver's assigned lines

POST /api/v1/driver/invoice-match returns exact matches (same product,
invoice qty = ordered qty) and lines to review. Read-only for the lines.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-21 20:58:33 +02:00
parent 5c46152df7
commit 33aa56cbf8
3 changed files with 355 additions and 0 deletions

View file

@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const matchInvoiceToDriverLines = vi.fn();
vi.mock("@/lib/server/collection-ai-service", () => ({
matchInvoiceToDriverLines: (...a: unknown[]) => matchInvoiceToDriverLines(...a),
COLLECTION_AI_NOT_SET_UP_ERROR: "not set up",
COLLECTION_AI_DISABLED_ERROR: "AI is disabled in settings",
}));
const getBrowserSessionUser = vi.fn();
vi.mock("@/lib/server/browser-session", () => ({
getBrowserSessionUser: (...a: unknown[]) => getBrowserSessionUser(...a),
}));
import { POST, OPTIONS } from "./route";
const req = (body?: unknown) =>
new NextRequest("http://localhost/api/v1/driver/invoice-match", {
method: "POST",
headers: { authorization: "Bearer t", "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
beforeEach(() => {
vi.clearAllMocks();
getBrowserSessionUser.mockResolvedValue({ id: "d1", user_type: null, name: "Driver" });
});
describe("POST /api/v1/driver/invoice-match", () => {
it("answers the CORS preflight", () => {
expect(OPTIONS().status).toBe(204);
});
it("401s without a session", async () => {
getBrowserSessionUser.mockRejectedValue(Object.assign(new Error("no"), { status: 401 }));
const res = await POST(req({ po_number: "PO1" }));
expect(res.status).toBe(401);
expect(matchInvoiceToDriverLines).not.toHaveBeenCalled();
});
it("400s without po_number", async () => {
expect((await POST(req({}))).status).toBe(400);
});
it("409s when AI is disabled", async () => {
matchInvoiceToDriverLines.mockResolvedValue({ error: "AI is disabled in settings" });
expect((await POST(req({ po_number: "PO1" }))).status).toBe(409);
});
it("matches for the signed-in driver only", async () => {
matchInvoiceToDriverLines.mockResolvedValue({ po_number: "PO1", matched: [], review: [], error: "" });
const res = await POST(req({ po_number: " PO1 " }));
expect(res.status).toBe(200);
expect(matchInvoiceToDriverLines).toHaveBeenCalledWith("PO1", "d1");
});
});

View file

@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import {
matchInvoiceToDriverLines,
COLLECTION_AI_DISABLED_ERROR,
COLLECTION_AI_NOT_SET_UP_ERROR,
} from "@/lib/server/collection-ai-service";
/**
* POST /api/v1/driver/invoice-match
*
* The driver photographed a supplier invoice on a PO. Body:
* `{ po_number: string }`. Reads the photo(s) and compares them with every
* line assigned to THIS driver at the same supplier. Returns `matched` (same
* product, invoice quantity = ordered quantity: safe to mark collected) and
* `review` (probably on the invoice but uncertain or a different quantity).
*
* Read-only for the order/stock lines: the driver app does the collecting
* through its normal write path. Auth: the driver's PocketBase token.
* CORS is open because the PWA is on its own origin and uses a bearer token.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 120;
const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Max-Age": "86400",
};
function json(body: unknown, status = 200) {
return NextResponse.json(body, { status, headers: CORS });
}
export function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS });
}
export async function POST(req: NextRequest) {
try {
const user = await getBrowserSessionUser(req);
const body = (await req.json().catch(() => ({}))) as { po_number?: unknown };
const poNumber = typeof body.po_number === "string" ? body.po_number.trim() : "";
if (!poNumber) return json({ error: "po_number is required" }, 400);
const result = await matchInvoiceToDriverLines(poNumber, user.id);
if (
result.error === COLLECTION_AI_NOT_SET_UP_ERROR ||
result.error === COLLECTION_AI_DISABLED_ERROR
) {
return json({ error: result.error }, 409);
}
return json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Invoice match failed";
const status = (err as Error & { status?: number }).status || 500;
return json({ error: message }, status);
}
}

View file

@ -1094,3 +1094,245 @@ export async function loadCardLineChecks(poNumber: string): Promise<CardLineChec
ai_check: l.ai_check,
}));
}
// ─── Invoice → driver's assigned lines (pre-collection match) ───────
export interface InvoiceMatchLine {
id: string;
kind: "customer" | "stock";
po_number: string;
description: string;
/** Quantity on the invoice (equals the ordered quantity for a match). */
quantity: number;
/** What the row looked like before, so the driver app can undo. */
previous: Record<string, unknown>;
}
export interface InvoiceMatchReview {
id: string;
kind: "customer" | "stock";
po_number: string;
description: string;
reason: string;
}
export interface InvoiceMatchResult {
po_number: string;
files_read: number;
/** Exact matches: same product ("sure") and invoice quantity = ordered quantity. */
matched: InvoiceMatchLine[];
/** Probably on the invoice, but not certain or the quantity differs: the driver decides. */
review: InvoiceMatchReview[];
error: string;
}
const MATCH_MAX_FILES = 6;
/**
* Compare the invoice photo(s) on a PO with every line assigned to THIS
* driver at the same supplier. Read-only for the lines: it only caches the
* photo reads (like the Confirm check) and returns what matched; the driver
* app does the collecting through its normal write path.
*/
export async function matchInvoiceToDriverLines(
poNumber: string,
driverId: string,
opts: { deps?: CollectionAiDeps } = {},
): Promise<InvoiceMatchResult> {
const deps = opts.deps ?? {};
const readDocument = deps.readDocument ?? readDocumentImage;
const pairLines = deps.pairLines ?? pairDocumentLines;
const fetchFile = deps.fetchFile ?? defaultFetchFile;
const rotateImage = deps.rotateImage ?? defaultRotateImage;
const now = (deps.now ?? (() => new Date()))();
const result: InvoiceMatchResult = { po_number: poNumber, files_read: 0, matched: [], review: [], error: "" };
return runWithAuditActor({ name: ACTOR_NAME, source: "agent", ignoreRequestUser: true }, async () => {
const pb = await getAdminPocketBase();
const settings = await getAiSettings();
if (!settings.enabled) {
result.error = COLLECTION_AI_DISABLED_ERROR;
return result;
}
// 1. The PO the photo is on, and the photos on it.
const [custPos, stockPos] = await Promise.all([
pb.collection(CUSTOMER_POS).getFullList<Rec>({ filter: `po_number = "${q(poNumber)}"` }),
pb.collection(STOCK_POS).getFullList<Rec>({ filter: `purchase_number = "${q(poNumber)}"` }),
]);
const cardPos = [...custPos.map((r) => toPoRef("customer", r)), ...stockPos.map((r) => toPoRef("stock", r))];
if (cardPos.length === 0) {
result.error = `PO ${poNumber} not found`;
return result;
}
const stockPurchases = new Map(stockPos.map((r) => [r.id, r]));
const cardLines = await loadLinesForPos(pb, cardPos, false);
const files = filesForLines(cardLines, stockPurchases).filter((f) => IMAGE_EXT.has(ext(f.filename)));
if (files.length === 0) return result;
// 2. Read each photo once (cached by content hash, as in the Confirm check).
const readsByFile = await loadReadsForFiles(pb, files);
const wanted = new Set(cardPos.map((p) => normalizePoReference(p.po_number)));
const docsByHash = new Map<string, CollectionDocRead>();
for (const file of files) {
let read = readsByFile.get(fileKey(file));
if (!read || read.status === "failed" || read.status === "skipped") {
if (result.files_read >= MATCH_MAX_FILES) continue;
let fetched: Awaited<ReturnType<typeof fetchFile>>;
try {
fetched = await fetchFile({ collection: file.collection, id: file.id }, file.filename);
} catch (e) {
if (!result.error) result.error = `${file.filename}: ${errMessage(e)}`;
continue;
}
const hash = createHash("sha256").update(fetched.bytes).digest("hex");
const known = await findReadByHash(pb, hash);
let fields: ReadFields;
if (known) {
const rest: Partial<CollectionDocRead> = { ...known };
delete rest.id;
fields = copyOf(file, rest as ReadFields);
} else {
try {
let r = await readDocument({ base64: fetched.base64, contentType: fetched.contentType });
// Sideways photos: retry turned when it names none of this PO's numbers.
if (r.doc_type !== "other" && !r.po_refs.some((ref) => wanted.has(normalizePoReference(ref)))) {
for (const angle of RETRY_ROTATIONS) {
try {
const turned = await readDocument(await rotateImage(fetched.bytes, angle));
if (turned.po_refs.some((ref) => wanted.has(normalizePoReference(ref)))) {
r = { ...turned, notes: [...turned.notes, `Read with the photo turned ${angle}°.`] };
break;
}
} catch (e) {
if (e instanceof AiDisabledError) throw e;
}
}
}
fields = fromResult(file, hash, r, now);
result.files_read += 1;
} catch (e) {
if (e instanceof AiDisabledError) {
result.error = COLLECTION_AI_DISABLED_ERROR;
return result;
}
if (!result.error) result.error = `read failed for ${file.filename}: ${errMessage(e)}`;
continue;
}
}
try {
const existing = readsByFile.get(fileKey(file));
const saved = existing
? await pb.collection(READS).update<Rec>(existing.id, fields)
: await pb.collection(READS).create<Rec>(fields);
read = toRead(saved);
} catch (e) {
if (!result.error) result.error = `saving the read of ${file.filename} failed: ${errMessage(e)}`;
continue;
}
}
if (read.status !== "read") continue;
if (read.doc_type !== "tax_invoice" && read.doc_type !== "delivery_note") continue;
const key = read.content_hash || read.id;
const prev = docsByHash.get(key);
if (!prev || newer(prev, read)) docsByHash.set(key, read);
}
const docs = [...docsByHash.values()];
if (docs.length === 0) return result;
// 3. Everything assigned to this driver at the same supplier(s).
const suppliers = [...new Set(cardPos.map((p) => p.supplier).filter(Boolean))];
if (suppliers.length === 0) return result;
const orderItems = getOrderItemsCollectionName();
const custRecs: Rec[] = [];
const stockRecs: Rec[] = [];
for (const s of suppliers) {
custRecs.push(
...(await pb.collection(orderItems).getFullList<Rec>({
filter: `drv_id = "${q(driverId)}" && item_status = "assigned" && purchase_order.supplier = "${q(s)}"`,
})),
);
stockRecs.push(
...(await pb.collection(STOCK_LINES).getFullList<Rec>({
filter: `drv_id = "${q(driverId)}" && status = "assigned_to_driver" && purchase.supplier = "${q(s)}"`,
})),
);
}
if (custRecs.length === 0 && stockRecs.length === 0) return result;
const poRows = [
...(await listByField(pb, CUSTOMER_POS, "id", custRecs.map((r) => str(r.purchase_order)))).map((r) => toPoRef("customer", r)),
...(await listByField(pb, STOCK_POS, "id", stockRecs.map((r) => str(r.purchase)))).map((r) => toPoRef("stock", r)),
];
const poById = new Map(poRows.map((p) => [`${p.kind}:${p.id}`, p]));
const candidates: Line[] = [
...custRecs.map((r) => customerLine(r, poById.get(`customer:${str(r.purchase_order)}`), orderItems)),
...stockRecs.map((r) => stockLine(r, poById.get(`stock:${str(r.purchase)}`))),
];
const recById = new Map([...custRecs, ...stockRecs].map((r) => [r.id, r]));
const pairInput: PairPoLine[] = candidates.map((l) => ({
id: l.id,
po_number: l.poNumber,
description: l.description,
collected: false,
order_qty: l.order_qty,
quantity: l.quantity,
qty_collected: l.qty_collected,
sku: l.sku,
po_description: l.po_description,
cost_price: l.cost_price,
status: l.status,
}));
const byId = new Map(candidates.map((l) => [l.id, l]));
// 4. Pair, then decide. A line is matched once, by its first document.
const decided = new Set<string>();
for (const doc of docs) {
let paired: Awaited<ReturnType<typeof pairLines>>;
try {
paired = await pairLines(doc.lines, pairInput);
} catch (e) {
if (e instanceof AiDisabledError) {
result.error = COLLECTION_AI_DISABLED_ERROR;
return result;
}
if (!result.error) result.error = `pairing failed: ${errMessage(e)}`;
continue;
}
const docLineByNo = new Map(doc.lines.map((d) => [d.line_no, d]));
for (const decision of paired.pairs.values()) {
const line = decision.po_line_id ? byId.get(decision.po_line_id) : undefined;
const docLine = docLineByNo.get(decision.doc_line_no);
if (!line || !docLine || decided.has(line.id) || !isRealPair(decision.same_item)) continue;
decided.add(line.id);
const ordered = line.order_qty ? line.order_qty : line.quantity;
const base = { id: line.id, kind: line.kind, po_number: line.poNumber, description: line.description };
if (decision.same_item === "sure" && docLine.quantity != null && docLine.quantity === ordered) {
const rec = recById.get(line.id)!;
result.matched.push({
...base,
quantity: docLine.quantity,
previous:
line.kind === "customer"
? {
item_status: str(rec.item_status),
drv_status: str(rec.drv_status),
drv_collected_at: str(rec.drv_collected_at),
collected_date: str(rec.collected_date),
qty_collected: num(rec.qty_collected),
}
: { status: str(rec.status), quantity_received: num(rec.quantity_received) },
});
} else {
const reason =
docLine.quantity == null
? "Couldn't read the quantity on the invoice."
: docLine.quantity !== ordered
? `Invoice says ${docLine.quantity}, ordered ${ordered}.`
: "Looks like the same item, but not certain.";
result.review.push({ ...base, reason });
}
}
}
return result;
});
}