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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-21 19:38:38 +02:00
parent 511f5dac6e
commit 9dd2da3e99
5 changed files with 285 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1060,3 +1060,37 @@ async function cardsNeedingWork(pb: AdminPb): Promise<string[]> {
}
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<CardLineCheck[]> {
const pb = await getAdminPocketBase();
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))];
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,
}));
}