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>
236 lines
8.2 KiB
TypeScript
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)))),
|
|
};
|
|
}
|