diff --git a/src/app/api/v1/driver/invoice-match/route.test.ts b/src/app/api/v1/driver/invoice-match/route.test.ts new file mode 100644 index 0000000..2c21041 --- /dev/null +++ b/src/app/api/v1/driver/invoice-match/route.test.ts @@ -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"); + }); +}); diff --git a/src/app/api/v1/driver/invoice-match/route.ts b/src/app/api/v1/driver/invoice-match/route.ts new file mode 100644 index 0000000..11ce878 --- /dev/null +++ b/src/app/api/v1/driver/invoice-match/route.ts @@ -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); + } +} diff --git a/src/lib/server/collection-ai-service.ts b/src/lib/server/collection-ai-service.ts index e8fa5c1..96e367a 100644 --- a/src/lib/server/collection-ai-service.ts +++ b/src/lib/server/collection-ai-service.ts @@ -1094,3 +1094,245 @@ export async function loadCardLineChecks(poNumber: string): Promise; +} + +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 { + 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({ filter: `po_number = "${q(poNumber)}"` }), + pb.collection(STOCK_POS).getFullList({ 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(); + 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>; + 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 = { ...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(existing.id, fields) + : await pb.collection(READS).create(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({ + filter: `drv_id = "${q(driverId)}" && item_status = "assigned" && purchase_order.supplier = "${q(s)}"`, + })), + ); + stockRecs.push( + ...(await pb.collection(STOCK_LINES).getFullList({ + 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(); + for (const doc of docs) { + let paired: Awaited>; + 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; + }); +}