WFDASHDRIVER-APP/lib/invoiceCheck.ts

96 lines
3.5 KiB
TypeScript
Raw Normal View History

// Beta helper: photograph the invoice and let WFDASH match it against
// everything assigned to this driver at that supplier. Exact matches (same
// product, invoice quantity = ordered quantity) are marked collected through
// the same writes the bulk-collect button uses, and can be undone.
// On by default; a driver can switch it off in Settings → Beta.
import { pb, getSettings, updateStockCollectionStatus } from './pocketbase';
/** Used when neither the phone's setting nor the build sets an address. */
export const DEFAULT_WFDASH_URL = 'https://wfdash.apphub.co.za';
export interface MatchedLine {
id: string;
kind: 'customer' | 'stock';
po_number: string;
description: string;
quantity: number;
previous: Record<string, unknown>;
}
export interface ReviewLine {
id: string;
kind: 'customer' | 'stock';
po_number: string;
description: string;
reason: string;
}
export interface InvoiceMatchResult {
po_number: string;
matched: MatchedLine[];
review: ReviewLine[];
error: string;
}
/** Fired by the PO buttons; InvoiceMatchHost (once per page) does the work. */
export const RUN_EVENT = 'invoice-match:run';
/** The WFDASH address to call, or '' when the driver switched the helper off. */
export function invoiceCheckBase(): string {
const s = getSettings();
if (!s.aiCheckEnabled) return '';
const url = (s.aiCheckUrl || process.env.NEXT_PUBLIC_WFDASH_URL || DEFAULT_WFDASH_URL).trim();
return url.replace(/\/+$/, '');
}
export async function matchInvoice(poNumber: string): Promise<InvoiceMatchResult> {
const base = invoiceCheckBase();
if (!base) throw new Error('Invoice check is not set up (Settings → Beta).');
const res = await fetch(`${base}/api/v1/driver/invoice-match`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pb.authStore.token}` },
body: JSON.stringify({ po_number: poNumber }),
signal: AbortSignal.timeout(120_000),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `Check failed (${res.status})`);
return body as InvoiceMatchResult;
}
/** Mark one matched line collected, exactly as bulkCollect / the stock modal do. */
export async function collectMatched(line: MatchedLine): Promise<void> {
if (line.kind === 'stock') {
await updateStockCollectionStatus(line.id, { qty_collected: line.quantity, expectedQty: line.quantity });
return;
}
const now = new Date().toISOString();
await pb.collection('order_items').update(line.id, {
item_status: 'collected',
drv_status: 'collected',
drv_collected_at: now,
qty_collected: line.quantity,
collected_date: now,
});
}
/** Put a matched line back to how it was before the auto-collect. */
export async function undoMatched(line: MatchedLine): Promise<void> {
await pb
.collection(line.kind === 'stock' ? 'stock_purchase_items' : 'order_items')
.update(line.id, line.previous);
}
/**
* Record why a line differs from the invoice. Only the note is written: the
* item is not collected and nothing else about it changes. Any earlier note
* on the line is kept.
*/
export async function saveReviewNote(line: ReviewLine, reason: string): Promise<void> {
const col = line.kind === 'stock' ? 'stock_purchase_items' : 'order_items';
const rec = await pb.collection(col).getOne(line.id);
const before = String(rec.notes ?? '').trim();
const entry = `[Invoice differs: ${line.reason}] ${reason.trim()}`;
await pb.collection(col).update(line.id, { notes: before ? `${before}\n${entry}` : entry });
}