2026-10-21 18:58:33 +00:00
|
|
|
// 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.
|
2026-10-26 08:46:27 +00:00
|
|
|
// On by default; a driver can switch it off in Settings → Beta.
|
2026-10-21 17:38:38 +00:00
|
|
|
|
2026-10-21 18:58:33 +00:00
|
|
|
import { pb, getSettings, updateStockCollectionStatus } from './pocketbase';
|
2026-10-21 17:38:38 +00:00
|
|
|
|
2026-10-26 08:46:27 +00:00
|
|
|
/** Used when neither the phone's setting nor the build sets an address. */
|
|
|
|
|
export const DEFAULT_WFDASH_URL = 'https://wfdash.apphub.co.za';
|
|
|
|
|
|
2026-10-21 18:58:33 +00:00
|
|
|
export interface MatchedLine {
|
|
|
|
|
id: string;
|
|
|
|
|
kind: 'customer' | 'stock';
|
|
|
|
|
po_number: string;
|
|
|
|
|
description: string;
|
|
|
|
|
quantity: number;
|
|
|
|
|
previous: Record<string, unknown>;
|
|
|
|
|
}
|
2026-10-21 17:38:38 +00:00
|
|
|
|
2026-10-21 18:58:33 +00:00
|
|
|
export interface ReviewLine {
|
2026-10-21 17:38:38 +00:00
|
|
|
id: string;
|
|
|
|
|
kind: 'customer' | 'stock';
|
2026-10-21 18:58:33 +00:00
|
|
|
po_number: string;
|
2026-10-21 17:38:38 +00:00
|
|
|
description: string;
|
|
|
|
|
reason: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 18:58:33 +00:00
|
|
|
export interface InvoiceMatchResult {
|
2026-10-21 17:38:38 +00:00
|
|
|
po_number: string;
|
2026-10-21 18:58:33 +00:00
|
|
|
matched: MatchedLine[];
|
|
|
|
|
review: ReviewLine[];
|
2026-10-21 17:38:38 +00:00
|
|
|
error: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 18:58:33 +00:00
|
|
|
/** Fired by the PO buttons; InvoiceMatchHost (once per page) does the work. */
|
|
|
|
|
export const RUN_EVENT = 'invoice-match:run';
|
|
|
|
|
|
2026-10-26 08:46:27 +00:00
|
|
|
/** The WFDASH address to call, or '' when the driver switched the helper off. */
|
2026-10-21 17:38:38 +00:00
|
|
|
export function invoiceCheckBase(): string {
|
|
|
|
|
const s = getSettings();
|
|
|
|
|
if (!s.aiCheckEnabled) return '';
|
2026-10-26 08:46:27 +00:00
|
|
|
const url = (s.aiCheckUrl || process.env.NEXT_PUBLIC_WFDASH_URL || DEFAULT_WFDASH_URL).trim();
|
2026-10-21 17:38:38 +00:00
|
|
|
return url.replace(/\/+$/, '');
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 18:58:33 +00:00
|
|
|
export async function matchInvoice(poNumber: string): Promise<InvoiceMatchResult> {
|
2026-10-21 17:38:38 +00:00
|
|
|
const base = invoiceCheckBase();
|
|
|
|
|
if (!base) throw new Error('Invoice check is not set up (Settings → Beta).');
|
2026-10-21 18:58:33 +00:00
|
|
|
const res = await fetch(`${base}/api/v1/driver/invoice-match`, {
|
2026-10-21 17:38:38 +00:00
|
|
|
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})`);
|
2026-10-21 18:58:33 +00:00
|
|
|
return body as InvoiceMatchResult;
|
2026-10-21 17:38:38 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-21 18:58:33 +00:00
|
|
|
/** 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;
|
2026-10-21 17:38:38 +00:00
|
|
|
}
|
2026-10-21 18:58:33 +00:00
|
|
|
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,
|
|
|
|
|
});
|
2026-10-21 17:38:38 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-21 18:58:33 +00:00
|
|
|
/** 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);
|
2026-10-21 17:38:38 +00:00
|
|
|
}
|
2026-10-22 02:14:47 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 });
|
|
|
|
|
}
|