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:
parent
511f5dac6e
commit
9dd2da3e99
5 changed files with 285 additions and 0 deletions
60
src/app/api/v1/driver/collection-check/route.test.ts
Normal file
60
src/app/api/v1/driver/collection-check/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
75
src/app/api/v1/driver/collection-check/route.ts
Normal file
75
src/app/api/v1/driver/collection-check/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
src/lib/collection-driver-verdict.test.ts
Normal file
47
src/lib/collection-driver-verdict.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
69
src/lib/collection-driver-verdict.ts
Normal file
69
src/lib/collection-driver-verdict.ts
Normal 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: "" };
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue