WFDASH-WEBAPP/src/lib/server/collection-auto-push-service.ts
Dave 3eea87ec03 feat(collection-confirm): auto-push purple cards on to invoicing, with a 2-day log
After each AI tick, every Collection Confirm card the page shows purple
(cardAiState verified) is confirmed without a person. Customer lines go
to ready for invoicing with the invoice number and date the AI read; stock
orders go to stock booking once every line is collected. Cards with a
problem flag, a photo for another PO, or no readable invoice number/date
stay for review.

Each push is recorded in ai_settings.collection_auto_push_log and shown at
the top of the page for today and yesterday (PO, supplier, items, value).
Off by default behind a Settings > AI switch; needs the manual live step
npm run pb:migrate:collection-auto-push before it will run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 11:36:23 +02:00

236 lines
8.2 KiB
TypeScript

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<string, ConfirmRow[]>();
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<string, ConfirmRow[]>();
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<boolean> {
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<AutoPushResult> {
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)))),
};
}