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:
parent
5c46152df7
commit
33aa56cbf8
3 changed files with 355 additions and 0 deletions
52
src/app/api/v1/driver/invoice-match/route.test.ts
Normal file
52
src/app/api/v1/driver/invoice-match/route.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
61
src/app/api/v1/driver/invoice-match/route.ts
Normal file
61
src/app/api/v1/driver/invoice-match/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue