import { runWithAuditActor } from "@/lib/server/audit-context"; import { cardAiState } from "@/lib/collection-ai-ticks"; import { clearAiSettingsCache, getAiSettings, recordCollectionAutoPushLog, } from "@/lib/server/ai-settings-service"; import { confirmOrderItemsWithSplits, confirmStockRows, listCollectedItems, markStockOrdersReceived, type ConfirmRow, } from "@/lib/server/collection-confirm-service"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import type { CollectionAutoPushEntry } from "@/lib/types"; /** * Auto-push: a Collection Confirm card the AI has checked and the page shows * purple (`cardAiState` "verified") is confirmed without a person — customer * lines go to ready for invoicing, stock lines to the accountant's stock * booking — and the push is recorded in `collection_auto_push_log`. * * It calls the same functions as the manual Confirm buttons, so notes, the * audit trail and short-collection splits behave identically. A card that is * not clearly clean is left for a person. */ const ACTOR_NAME = "AI collection check"; const ACTOR_ID = "ai-collection-check"; const KEEP_LOG_MS = 3 * 24 * 3600 * 1000; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; export interface AutoPushPlan { poNumber: string; supplier: string; kind: "customer" | "stock"; rowIds: string[]; /** Customer plans only: the invoice every row in the plan was read from. */ invoice: { number: string; date: string } | null; /** Stock plans only: the stock order the rows belong to. */ stockPurchaseId: string | null; invoiceNo: string; items: number; value: number; } function rowPo(row: ConfirmRow): string { return row.poNumber ?? row.expand?.purchase_order?.po_number ?? ""; } function supplierName(row: ConfirmRow): string { return row.expand?.purchase_order?.expand?.supplier?.suppliername ?? row.ai_doc?.supplier_name ?? ""; } function lineValue(row: ConfirmRow): number { const qty = row.qty_collected ?? row.quantity ?? 0; return qty * (row.cost_price ?? 0); } function roundMoney(n: number): number { return Math.round(n * 100) / 100; } /** * Which cards may be pushed, and as what. Pure: no writes. A card is skipped * whole (never half pushed) when any line is flagged, any photo looks like it * belongs to another PO, or a customer line has no usable invoice number/date. */ export function planAutoPush(rows: ConfirmRow[]): AutoPushPlan[] { const byCard = new Map(); for (const row of rows) { const po = rowPo(row); if (!po) continue; byCard.set(po, [...(byCard.get(po) ?? []), row]); } const plans: AutoPushPlan[] = []; for (const [poNumber, cardRows] of byCard) { if (cardAiState(cardRows).state !== "verified") continue; if (cardRows.some((r) => r.problem_flag || r.ai_doc?.other_po)) continue; const customer = cardRows.filter((r) => r.source !== "stock"); const stock = cardRows.filter((r) => r.source === "stock"); const supplier = supplierName(cardRows[0]); // One plan per invoice: a card can be covered by more than one. const byInvoice = new Map(); let usable = true; for (const r of customer) { const no = r.ai_check?.document_no?.trim() ?? ""; const date = r.ai_check?.doc_date?.trim() ?? ""; if (!no || !DATE_RE.test(date)) { usable = false; break; } const key = `${no}\u0000${date}`; byInvoice.set(key, [...(byInvoice.get(key) ?? []), r]); } if (!usable) continue; const cardPlans: AutoPushPlan[] = []; for (const [key, group] of byInvoice) { const [number, date] = key.split("\u0000"); cardPlans.push({ poNumber, supplier, kind: "customer", rowIds: group.map((r) => r.id), invoice: { number, date }, stockPurchaseId: null, invoiceNo: number, items: group.length, value: roundMoney(group.reduce((sum, r) => sum + lineValue(r), 0)), }); } if (stock.length > 0) { cardPlans.push({ poNumber, supplier, kind: "stock", rowIds: stock.map((r) => r.id), invoice: null, stockPurchaseId: stock[0].stock_purchase_id ?? null, invoiceNo: stock[0].ai_check?.document_no?.trim() ?? "", items: stock.length, value: roundMoney(stock.reduce((sum, r) => sum + lineValue(r), 0)), }); } plans.push(...cardPlans); } return plans; } /** Entries from the last three days, newest first (the page shows today and yesterday). */ export function trimLog(log: CollectionAutoPushEntry[], now: Date): CollectionAutoPushEntry[] { const cutoff = now.getTime() - KEEP_LOG_MS; return log .filter((e) => Date.parse(e.at) >= cutoff) .sort((a, b) => b.at.localeCompare(a.at)); } /** A stock order is only pushed whole: any line still outstanding keeps it for a person. */ async function stockOrderComplete(purchaseId: string | null): Promise { if (!purchaseId) return false; const pb = await getAdminPocketBase(); const lines = await pb .collection("stock_purchase_items") .getFullList<{ status?: string }>({ filter: `purchase = "${purchaseId.replace(/"/g, "")}"` }); return lines.length > 0 && lines.every((l) => l.status === "collected" || l.status === "booked"); } export interface AutoPushResult { pushed: number; errors: string[]; } export async function runAutoPush(opts: { now?: () => Date } = {}): Promise { const nowFn = opts.now ?? (() => new Date()); const result: AutoPushResult = { pushed: 0, errors: [] }; clearAiSettingsCache(); const settings = await getAiSettings(); // Without the PB field the push could not be recorded, so it must not happen. if (!settings.enabled || !settings.collection_ai_enabled) return result; if (!settings.collection_auto_push_enabled || !settings.collection_auto_push_available) return result; let log = settings.collection_auto_push_log; const rows = await listCollectedItems(); const actor = { id: ACTOR_ID, name: ACTOR_NAME }; for (const plan of planAutoPush(rows)) { try { await runWithAuditActor({ name: ACTOR_NAME, source: "agent", ignoreRequestUser: true }, async () => { if (plan.kind === "customer") { await confirmOrderItemsWithSplits(plan.rowIds, { invoice: plan.invoice as { number: string; date: string }, costOverrides: {}, actor, }); return; } if (!(await stockOrderComplete(plan.stockPurchaseId))) throw new SkipPlan(); await confirmStockRows(plan.rowIds, { actor }); const sent = await markStockOrdersReceived(plan.rowIds); if (sent.received.length === 0) throw new Error(`${plan.poNumber}: stock order was not sent to booking`); }); } catch (e) { if (!(e instanceof SkipPlan)) { result.errors.push(`${plan.poNumber}: ${e instanceof Error ? e.message : String(e)}`); } continue; } result.pushed += 1; const at = nowFn(); log = trimLog( [ { at: at.toISOString(), po_number: plan.poNumber, supplier: plan.supplier, kind: plan.kind, items: plan.items, value: plan.value, invoice_no: plan.invoiceNo, }, ...log, ], at, ); // Saved after every push so a later failure never loses the record. try { await recordCollectionAutoPushLog(log); } catch (e) { result.errors.push(`log write failed after ${plan.poNumber}: ${e instanceof Error ? e.message : String(e)}`); } } return result; } class SkipPlan extends Error {} /** The log for the page: today and yesterday (South African time), newest first. */ export async function getAutoPushLog(now: Date = new Date()): Promise<{ enabled: boolean; entries: CollectionAutoPushEntry[]; }> { const settings = await getAiSettings(); const day = (d: Date) => d.toLocaleDateString("en-CA", { timeZone: "Africa/Johannesburg" }); const keep = new Set([day(now), day(new Date(now.getTime() - 24 * 3600 * 1000))]); return { enabled: settings.collection_auto_push_enabled && settings.collection_auto_push_available, entries: settings.collection_auto_push_log.filter((e) => keep.has(day(new Date(e.at)))), }; }