From 9dd2da3e99b9f1e0baf159f272ee27acb45c5a20 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 21 Oct 2026 19:38:38 +0200 Subject: [PATCH] feat(driver): read-only invoice check endpoint for the driver app POST /api/v1/driver/collection-check runs the collection AI check for a PO and returns a per-line verdict (right item, right quantity) for drivers. Co-Authored-By: Claude Sonnet 5.5 --- .../v1/driver/collection-check/route.test.ts | 60 +++++++++++++++ .../api/v1/driver/collection-check/route.ts | 75 +++++++++++++++++++ src/lib/collection-driver-verdict.test.ts | 47 ++++++++++++ src/lib/collection-driver-verdict.ts | 69 +++++++++++++++++ src/lib/server/collection-ai-service.ts | 34 +++++++++ 5 files changed, 285 insertions(+) create mode 100644 src/app/api/v1/driver/collection-check/route.test.ts create mode 100644 src/app/api/v1/driver/collection-check/route.ts create mode 100644 src/lib/collection-driver-verdict.test.ts create mode 100644 src/lib/collection-driver-verdict.ts diff --git a/src/app/api/v1/driver/collection-check/route.test.ts b/src/app/api/v1/driver/collection-check/route.test.ts new file mode 100644 index 0000000..26430cb --- /dev/null +++ b/src/app/api/v1/driver/collection-check/route.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const checkCollectionCard = vi.fn(); +const loadCardLineChecks = vi.fn(); +vi.mock("@/lib/server/collection-ai-service", () => ({ + checkCollectionCard: (...a: unknown[]) => checkCollectionCard(...a), + loadCardLineChecks: (...a: unknown[]) => loadCardLineChecks(...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/collection-check", { + 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" }); + checkCollectionCard.mockResolvedValue({ po_number: "PO1", error: "" }); +}); + +describe("POST /api/v1/driver/collection-check", () => { + it("answers the CORS preflight", () => { + const res = OPTIONS(); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-headers")).toContain("Authorization"); + }); + it("401s without a session and sends CORS headers", async () => { + getBrowserSessionUser.mockRejectedValue(Object.assign(new Error("no"), { status: 401 })); + const res = await POST(req({ po_number: "PO1" })); + expect(res.status).toBe(401); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + }); + it("400s without po_number", async () => { + expect((await POST(req({}))).status).toBe(400); + }); + it("409s when AI is disabled", async () => { + checkCollectionCard.mockResolvedValue({ po_number: "PO1", error: "AI is disabled in settings" }); + expect((await POST(req({ po_number: "PO1" }))).status).toBe(409); + }); + it("returns per-line verdicts and all_green", async () => { + loadCardLineChecks.mockResolvedValue([ + { kind: "customer", id: "a", description: "x", order_qty: 1, qty_collected: 1, cost_price: null, sku: "", ai_check: null }, + ]); + const res = await POST(req({ po_number: "PO1" })); + const body = await res.json(); + expect(body.lines).toEqual([{ id: "a", kind: "customer", description: "x", qty_collected: 1, verdict: "unchecked", reason: "" }]); + expect(body.all_green).toBe(false); + }); +}); diff --git a/src/app/api/v1/driver/collection-check/route.ts b/src/app/api/v1/driver/collection-check/route.ts new file mode 100644 index 0000000..05ee2da --- /dev/null +++ b/src/app/api/v1/driver/collection-check/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { + checkCollectionCard, + loadCardLineChecks, + COLLECTION_AI_DISABLED_ERROR, + COLLECTION_AI_NOT_SET_UP_ERROR, +} from "@/lib/server/collection-ai-service"; +import { driverVerdict } from "@/lib/collection-driver-verdict"; + +/** + * POST /api/v1/driver/collection-check + * + * The driver app asks: does my invoice photo match what I collected on this + * PO? Body: `{ po_number: string }`. Runs the same AI check as Collection + * Confirm (so the office sees the identical result) and returns one verdict + * per collected line: + * green — same item, quantity matches + * amber — probably right, but quantity/wording differs from the order + * red — not on the invoice, or a different quantity + * unchecked — nothing to compare yet (no readable invoice photo) + * Verdicts are advisory: the driver app lets the driver continue regardless. + * + * Auth: the driver's PocketBase token in `Authorization` (drivers are PB + * users). CORS is open because the driver PWA is served from its own origin + * and authenticates with a bearer token, never cookies. + */ +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 checkCollectionCard(poNumber, { actorName: user.name || "a driver" }); + if ( + result.error === COLLECTION_AI_NOT_SET_UP_ERROR || + result.error === COLLECTION_AI_DISABLED_ERROR + ) { + return json({ error: result.error }, 409); + } + + const lines = (await loadCardLineChecks(poNumber)).map(driverVerdict); + return json({ + po_number: poNumber, + lines, + all_green: lines.length > 0 && lines.every((l) => l.verdict === "green"), + // A check problem never blocks the driver; they just see "couldn't check". + error: result.error, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Collection check failed"; + const status = (err as Error & { status?: number }).status || 500; + return json({ error: message }, status); + } +} diff --git a/src/lib/collection-driver-verdict.test.ts b/src/lib/collection-driver-verdict.test.ts new file mode 100644 index 0000000..1530b38 --- /dev/null +++ b/src/lib/collection-driver-verdict.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { driverVerdict } from "./collection-driver-verdict"; +import type { CollectionAiCheck, CollectionDocLine } from "@/lib/types"; + +const doc = (o: Partial = {}): CollectionDocLine => ({ + line_no: 1, code: "A1", description: "Cement 50kg", quantity: 10, unit: "ea", + unit_price: 100, line_total: 1000, to_follow: null, price_note: "", code_certain: true, + discount_percent: null, doc_discount_percent: null, net_unit_price: null, ...o, +}); +const check = (o: Partial = {}): CollectionAiCheck => ({ + read_id: "r", document_no: "1", doc_date: "", doc_type: "tax_invoice", doc_line: doc(), + same_item: "sure", reason: "", sku_filled_value: "", findings: [], checked_at: "", model: "m", ...o, +}); +const row = (ai: CollectionAiCheck | null, o = {}) => ({ + id: "l1", kind: "customer" as const, sku: "WRONG", qty_collected: 10, order_qty: 10, cost_price: 5, ai_check: ai, ...o, +}); + +describe("driverVerdict", () => { + it("is unchecked without an ai_check", () => { + expect(driverVerdict(row(null)).verdict).toBe("unchecked"); + }); + it("is unchecked when pairing failed", () => { + expect(driverVerdict(row(check({ pair_failed: true, doc_line: null }))).verdict).toBe("unchecked"); + }); + it("is green when item and quantity match, ignoring SKU and price", () => { + expect(driverVerdict(row(check())).verdict).toBe("green"); + }); + it("is red when the item is not on the invoice", () => { + const v = driverVerdict(row(check({ doc_line: null, same_item: null }))); + expect(v.verdict).toBe("red"); + expect(v.reason).not.toBe(""); + }); + it("is red for a different item", () => { + expect(driverVerdict(row(check({ same_item: "different" }))).verdict).toBe("red"); + }); + it("is red when quantity differs from what was collected", () => { + const v = driverVerdict(row(check({ doc_line: doc({ quantity: 8 }) }))); + expect(v.verdict).toBe("red"); + expect(v.reason).toContain("8"); + }); + it("is amber when invoice qty differs from the ordered qty", () => { + expect(driverVerdict(row(check(), { order_qty: 12 })).verdict).toBe("amber"); + }); + it("is amber for a likely (not sure) match", () => { + expect(driverVerdict(row(check({ same_item: "likely" }))).verdict).toBe("amber"); + }); +}); diff --git a/src/lib/collection-driver-verdict.ts b/src/lib/collection-driver-verdict.ts new file mode 100644 index 0000000..168ac00 --- /dev/null +++ b/src/lib/collection-driver-verdict.ts @@ -0,0 +1,69 @@ +import type { CollectionAiCheck } from "@/lib/types"; +import { computeLineTicks } from "@/lib/collection-ai-ticks"; + +export type DriverVerdict = "green" | "amber" | "red" | "unchecked"; + +export interface DriverLineVerdict { + id: string; + kind: "customer" | "stock"; + description: string; + qty_collected: number; + verdict: DriverVerdict; + /** One short plain sentence for the driver; empty when green. */ + reason: string; +} + +type Row = { + id: string; + kind: "customer" | "stock"; + description?: string; + sku: string; + qty_collected: number; + order_qty: number; + cost_price: number | null; + ai_check: CollectionAiCheck | null; +}; + +/** + * What the driver needs to know at the supplier counter: is it the right + * product, in the right quantity? Price and SKU are the office's concern + * and never turn a driver's line amber or red. + */ +export function driverVerdict(row: Row): DriverLineVerdict { + const base = { + id: row.id, + kind: row.kind, + description: row.description ?? "", + qty_collected: row.qty_collected, + }; + const check = row.ai_check; + if (!check || (check.pair_failed && !check.doc_line)) { + return { ...base, verdict: "unchecked", reason: "" }; + } + const doc = check.doc_line; + if (!doc || (check.same_item !== "sure" && check.same_item !== "likely")) { + return { + ...base, + verdict: "red", + reason: check.reason || "Not found on the invoice photo.", + }; + } + const t = computeLineTicks( + { sku: row.sku, qty_collected: row.qty_collected, order_qty: row.order_qty, cost_price: row.cost_price }, + check, + ); + if (t.qty === "red") { + return { + ...base, + verdict: "red", + reason: `Invoice says ${doc.quantity}, you collected ${row.qty_collected}.`, + }; + } + if (t.qty === "amber") { + return { ...base, verdict: "amber", reason: `Invoice says ${doc.quantity}, ordered ${row.order_qty}.` }; + } + if (t.desc === "amber") { + return { ...base, verdict: "amber", reason: check.reason || "Looks like the same item, but not certain." }; + } + return { ...base, verdict: "green", reason: "" }; +} diff --git a/src/lib/server/collection-ai-service.ts b/src/lib/server/collection-ai-service.ts index 3556213..e8fa5c1 100644 --- a/src/lib/server/collection-ai-service.ts +++ b/src/lib/server/collection-ai-service.ts @@ -1060,3 +1060,37 @@ async function cardsNeedingWork(pb: AdminPb): Promise { } return out; } + +// ─── Read-back for the driver app ─────────────────────────────────── + +export interface CardLineCheck { + kind: "customer" | "stock"; + id: string; + description: string; + order_qty: number; + qty_collected: number; + cost_price: number | null; + sku: string; + ai_check: CollectionAiCheck | null; +} + +/** The card's collected lines with their current `ai_check`. Writes nothing. */ +export async function loadCardLineChecks(poNumber: string): Promise { + const pb = await getAdminPocketBase(); + 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))]; + const lines = await loadLinesForPos(pb, cardPos, true); + return lines.map((l) => ({ + kind: l.kind, + id: l.id, + description: l.description, + order_qty: l.order_qty, + qty_collected: l.qty_collected, + cost_price: l.cost_price, + sku: l.sku, + ai_check: l.ai_check, + })); +}