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>
This commit is contained in:
Dave 2026-10-28 11:36:23 +02:00
parent 6c83282888
commit 3eea87ec03
16 changed files with 665 additions and 0 deletions

View file

@ -225,6 +225,7 @@ Runbook: `docs/collection-ai-checks.md`. Files: `src/lib/server/collection-ai-{r
- **Manual first:** the Add to POS button and the Check with AI button are manual; the tick only reads and checks, nothing else.
- The AI writes **only** `ai_check`, `collection_doc_reads` rows, an **empty** SKU when it is sure, and — only when a "sure" tax-invoice line shows a discount — the cost set to the price after discount (via `updateOrderItemCost`, with a note). It never overwrites a person's SKU, confirms a collection, or changes status.
- **Auto-push** (`collection-auto-push-service.ts`, runbook section "Auto-push"): a purple card is confirmed by the tick itself and logged to `ai_settings.collection_auto_push_log`; off by default, needs `npm run pb:migrate:collection-auto-push`. Change what counts as pushable in `planAutoPush`, never by calling confirm elsewhere.
- The service refuses to run until `ai_check` exists (PB drops unknown fields silently); `ai_check` is in `IGNORED_FIELDS` of the audit hook.
- Manual live steps (like select values and pb_hooks): run `npm run pb:migrate:collection-ai` against the live PB, redeploy `pocketbase/pb_hooks/audit_log_utils.js`, deploy, activate the n8n workflow "WFDASH Collection check tick" (every 5 min), then switch Collection checks on in Settings → AI. See the runbook.

View file

@ -125,6 +125,33 @@ Cancelled stock orders never show. Driver photos for stock pickups are
kept on the stock purchase (`additional_attachments`, multi-file after
`npm run pb:migrate:stock-photos`).
## Auto-push (added 2026-10-28)
With **Auto-push verified cards** on (Settings, AI, Collection checks, off by
default), the tick confirms a card on its own when the page shows it purple
(`cardAiState` "verified": every line checked, no red or amber tick). It runs
at the end of every tick over every card on the page, so a card a person
checked by hand is covered too. It calls the same functions as the manual
buttons (`confirmOrderItemsWithSplits`, `confirmStockRows` +
`markStockOrdersReceived`), as actor "AI collection check".
- Customer lines go to ready for invoicing with the invoice number and date
the AI read. A card covered by two invoices is pushed one invoice at a time.
- Stock lines go to the accountant's stock booking, but only when every line
of the stock order is already collected (no follow-on order is created by
the AI).
- A card is left for a person when: any line has a problem flag, a photo
looks like it belongs to another PO, or a customer line has no invoice
number or no `YYYY-MM-DD` date. Grey ticks do not block it (same rule as
the purple card).
- Every push is appended to `ai_settings.collection_auto_push_log` (kept
three days). The page shows today and yesterday at the top: PO, supplier,
items, value. Endpoint: `GET /api/v1/collection-confirm/auto-push-log`.
- Without the PB fields it never pushes (it could not record the push).
Going live: `npm run pb:migrate:collection-auto-push`, deploy, then switch it on
in Settings, AI and Save.
## Reading the ticks
| Colour | Meaning |

View file

@ -22,6 +22,7 @@
"pb:migrate:rep-trip-legs": "npx tsx scripts/migrations/2026-09-28-create-rep-trip-legs.ts",
"pb:migrate:quote-assistant": "npx tsx scripts/migrations/2026-10-16-add-quote-assistant-fields.ts",
"pb:migrate:collection-ai": "npx tsx scripts/migrations/2026-10-17-add-collection-ai.ts",
"pb:migrate:collection-auto-push": "npx tsx scripts/migrations/2026-10-28-add-collection-auto-push.ts",
"pb:migrate:order-assistant": "npx tsx scripts/migrations/2026-10-24-add-order-assistant-fields.ts",
"pb:migrate:po-number-unique": "npx tsx scripts/migrations/2026-10-19-po-number-unique-index.ts",
"pb:migrate:stock-photos": "npx tsx scripts/migrations/2026-10-19-stock-photos-multi.ts",

View file

@ -0,0 +1,46 @@
/**
* One-shot migration: Collection Confirm auto-push.
*
* ai_settings.collection_auto_push_enabled bool (off by default)
* ai_settings.collection_auto_push_log json (what was pushed, last 2 days)
*
* Run with: npm run pb:migrate:collection-auto-push
*
* Idempotent. Manual step on the live PB, before deploying. Until it has
* run the app never auto-pushes (it needs the log to record what it did).
*/
import "./_env-loader";
import PocketBase from "pocketbase";
const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090";
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
const TAG = "[collection-auto-push]";
const FIELDS: Array<Record<string, unknown> & { name: string }> = [
{ name: "collection_auto_push_enabled", type: "bool", required: false, presentable: false },
{ name: "collection_auto_push_log", type: "json", required: false, presentable: false, maxSize: 200000 },
];
async function main() {
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD (e.g. from .env.local).");
}
const pb = new PocketBase(PB_URL);
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
console.log(`${TAG} Authenticated against ${PB_URL}`);
const collection = await pb.collections.getOne("ai_settings");
const existing = new Set(collection.fields.map((f: { name: string }) => f.name));
const missing = FIELDS.filter((f) => !existing.has(f.name));
for (const f of FIELDS) if (existing.has(f.name)) console.log(`${TAG} ai_settings.${f.name} already exists — skipping.`);
if (missing.length > 0) {
await pb.collections.update(collection.id, { fields: [...collection.fields, ...missing] });
for (const f of missing) console.log(`${TAG} ai_settings.${f.name} added.`);
}
console.log(`${TAG} Done.`);
}
main().catch((err) => {
console.error(`${TAG} Migration failed:`, err);
process.exit(1);
});

View file

@ -7,6 +7,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { AiChecksToggle } from "@/components/collection-confirm/ai-checks-toggle";
import { ConfirmInvoiceDialog } from "@/components/collection-confirm/confirm-invoice-dialog";
import { AutoPushLog } from "@/components/collection-confirm/auto-push-log";
import { ConfirmStockDialog } from "@/components/collection-confirm/confirm-stock-dialog";
import { InvoiceViewer } from "@/components/collection-confirm/invoice-viewer";
import { PoCard } from "@/components/collection-confirm/po-card";
@ -817,6 +818,8 @@ export default function CollectionConfirmPage() {
</div>
</header>
<AutoPushLog getHeaders={authedJsonHeaders} refreshKey={rows} />
{rows === null && (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (

View file

@ -63,6 +63,9 @@ export interface AiSettingsFormInitial {
/** Empty string = use the built-in default prompt. */
collection_ai_prompt: string;
collection_ai_last_run_summary: CollectionAiRunSummary | null;
collection_auto_push_enabled?: boolean;
/** False until the PB fields exist (npm run pb:migrate:collection-auto-push). */
collection_auto_push_available?: boolean;
/** AI order assistant (matches orders to the quote they came from). */
order_assistant_enabled: boolean;
order_assistant_interval_minutes: number;
@ -119,6 +122,7 @@ export function AiSettingsForm({ initial }: Props) {
const [qaPrompt, setQaPrompt] = useState(initial.quote_assistant_prompt);
const [showQaDefault, setShowQaDefault] = useState(false);
const [caEnabled, setCaEnabled] = useState(initial.collection_ai_enabled);
const [caPushEnabled, setCaPushEnabled] = useState(!!initial.collection_auto_push_enabled);
const [caPrompt, setCaPrompt] = useState(initial.collection_ai_prompt);
const [showCaDefault, setShowCaDefault] = useState(false);
const [oaEnabled, setOaEnabled] = useState(initial.order_assistant_enabled);
@ -515,6 +519,30 @@ export function AiSettingsForm({ initial }: Props) {
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border border-border p-3">
<div>
<Label htmlFor="ca_auto_push" className="text-base">
Auto-push verified cards
</Label>
<p className="text-xs text-muted-foreground">
When every line on a card checks out (the purple cards), confirm it
without a person: customer lines go to ready for invoicing, stock
orders to stock booking. Cards with anything amber or red stay for
review. Needs both switches above.
{initial.collection_auto_push_available === false
? " Not available yet: run npm run pb:migrate:collection-auto-push against PocketBase."
: ""}
</p>
</div>
<Switch
id="ca_auto_push"
name="ca_auto_push"
checked={caPushEnabled}
disabled={initial.collection_auto_push_available === false}
onCheckedChange={setCaPushEnabled}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="ca_prompt">Collection checks prompt</Label>

View file

@ -50,6 +50,8 @@ export default async function AiSettingsPage() {
collection_ai_enabled: settings.collection_ai_enabled,
collection_ai_prompt: settings.collection_ai_prompt,
collection_ai_last_run_summary: settings.collection_ai_last_run_summary,
collection_auto_push_enabled: settings.collection_auto_push_enabled,
collection_auto_push_available: settings.collection_auto_push_available,
order_assistant_enabled: settings.order_assistant_enabled,
order_assistant_interval_minutes: settings.order_assistant_interval_minutes,
order_assistant_auto_link: settings.order_assistant_auto_link,

View file

@ -106,6 +106,7 @@ export function parseSettingsForm(formData: FormData): ParseResult {
// ── Collection checks section (same <form>: missing checkbox = off).
patch.collection_ai_enabled = formData.get("ca_enabled") === "on";
patch.collection_auto_push_enabled = formData.get("ca_auto_push") === "on";
// Plain text, untrimmed. Empty means "use the built-in default".
const caPromptRaw = formData.get("ca_prompt");

View file

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { getAutoPushLog } from "@/lib/server/collection-auto-push-service";
/**
* GET /api/v1/collection-confirm/auto-push-log
*
* What Collection Confirm pushed on by itself today and yesterday (South
* African time), newest first: `{ enabled, entries }`. Browser session auth.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const user = await getBrowserSessionUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
return NextResponse.json(await getAutoPushLog());
} catch (error) {
console.error("[GET /api/v1/collection-confirm/auto-push-log]", error);
return NextResponse.json({ error: "internal error" }, { status: 500 });
}
}

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { groupByDay } from "./auto-push-log";
const e = (at: string, po: string) => ({ at, po_number: po, supplier: "S", kind: "customer" as const, items: 1, value: 1, invoice_no: "" });
describe("groupByDay", () => {
it("groups today and yesterday in South African time and drops older days", () => {
const now = new Date("2026-10-28T10:00:00Z");
const groups = groupByDay(
[e("2026-10-28T09:00:00Z", "A"), e("2026-10-27T20:30:00Z", "B"), e("2026-10-26T09:00:00Z", "C")],
now,
);
expect(groups.map((g) => [g.label, g.entries.map((x) => x.po_number)])).toEqual([
["Today", ["A"]],
["Yesterday", ["B"]],
]);
});
it("puts 23:30 UTC on the next South African day", () => {
const now = new Date("2026-10-28T10:00:00Z");
const groups = groupByDay([e("2026-10-27T23:30:00Z", "N")], now);
expect(groups[0].label).toBe("Today");
});
});

View file

@ -0,0 +1,98 @@
"use client";
import { useEffect, useState } from "react";
import { Bot } from "lucide-react";
import type { CollectionAutoPushEntry } from "@/lib/types";
interface AutoPushLogProps {
/** Same auth headers the page uses for its other /api/v1 calls. */
getHeaders: () => Record<string, string>;
/** Changes when the page reloads, so the log refreshes with it. */
refreshKey?: unknown;
}
const URL = "/api/v1/collection-confirm/auto-push-log";
const TZ = "Africa/Johannesburg";
const rand = (n: number) =>
n.toLocaleString("en-ZA", { style: "currency", currency: "ZAR", maximumFractionDigits: 2 });
const dayOf = (iso: string) => new Date(iso).toLocaleDateString("en-CA", { timeZone: TZ });
/** Groups entries (already newest first) under Today / Yesterday. */
export function groupByDay(
entries: CollectionAutoPushEntry[],
now: Date = new Date(),
): Array<{ label: "Today" | "Yesterday"; entries: CollectionAutoPushEntry[] }> {
const today = dayOf(now.toISOString());
const yesterday = dayOf(new Date(now.getTime() - 24 * 3600 * 1000).toISOString());
return [
{ label: "Today" as const, entries: entries.filter((e) => dayOf(e.at) === today) },
{ label: "Yesterday" as const, entries: entries.filter((e) => dayOf(e.at) === yesterday) },
].filter((g) => g.entries.length > 0);
}
/**
* What Collection Confirm pushed on to invoicing by itself over the last two
* days: PO, supplier, number of items and value. Nothing else — the details
* are on the order. Hidden until auto-push is on or has something to show.
*/
export function AutoPushLog({ getHeaders, refreshKey }: AutoPushLogProps) {
const [data, setData] = useState<{ enabled: boolean; entries: CollectionAutoPushEntry[] } | null>(null);
useEffect(() => {
let cancelled = false;
fetch(URL, { headers: getHeaders() })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((d) => {
if (!cancelled && d && Array.isArray(d.entries)) setData({ enabled: !!d.enabled, entries: d.entries });
})
.catch(() => {
// Fail-soft: the log is a convenience, never a reason to break the page.
});
return () => {
cancelled = true;
};
}, [getHeaders, refreshKey]);
if (!data || (!data.enabled && data.entries.length === 0)) return null;
const groups = groupByDay(data.entries);
return (
<section
className="rounded-md border bg-muted/20 px-3 py-2 text-sm"
data-testid="auto-push-log"
aria-label="Pushed automatically"
>
<h2 className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
Pushed on automatically · today and yesterday
</h2>
{groups.length === 0 ? (
<p className="mt-1 text-xs text-muted-foreground">Nothing pushed yet.</p>
) : (
groups.map((g) => (
<div key={g.label} className="mt-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{g.label}</p>
<ul className="divide-y">
{g.entries.map((e) => (
<li
key={`${e.at}-${e.po_number}-${e.kind}`}
className="flex flex-wrap items-baseline justify-between gap-x-4 py-1"
>
<span className="font-mono text-xs">{e.po_number}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{e.supplier || "—"}</span>
<span className="tabular-nums text-xs text-muted-foreground">
{e.items} item{e.items === 1 ? "" : "s"}
</span>
<span className="w-28 text-right tabular-nums">{rand(e.value)}</span>
</li>
))}
</ul>
</div>
))
)}
</section>
);
}

View file

@ -32,6 +32,7 @@
*/
import type {
CollectionAiRunSummary,
CollectionAutoPushEntry,
OrderAssistantRunSummary,
QuoteAssistantRunSummary,
} from "@/lib/types";
@ -78,6 +79,12 @@ export interface AiSettings {
/** ISO timestamp of the last tick; "" = never. */
collection_ai_last_run_at: string;
collection_ai_last_run_summary: CollectionAiRunSummary | null;
/** Push purple (verified) Collection Confirm cards on to invoicing by itself (default off). */
collection_auto_push_enabled: boolean;
/** What was pushed, newest first. Trimmed to the last two days on write. */
collection_auto_push_log: CollectionAutoPushEntry[];
/** False until the PB fields exist (PB silently drops writes to unknown fields). */
collection_auto_push_available: boolean;
/** AI order assistant: background tick on/off (default off). */
order_assistant_enabled: boolean;
/** Minutes between ticks (5–1440, default 10). */
@ -132,6 +139,7 @@ export interface AiSettingsUpdate {
collection_ai_enabled?: boolean;
/** `""` clears the override. Capped at 50 000 chars. */
collection_ai_prompt?: string;
collection_auto_push_enabled?: boolean;
order_assistant_enabled?: boolean;
/** 5–1440; rounded to an integer. */
order_assistant_interval_minutes?: number;
@ -165,6 +173,9 @@ const DEFAULT_SETTINGS: AiSettings = {
collection_ai_prompt: "",
collection_ai_last_run_at: "",
collection_ai_last_run_summary: null,
collection_auto_push_enabled: false,
collection_auto_push_log: [],
collection_auto_push_available: false,
order_assistant_enabled: false,
order_assistant_interval_minutes: 10,
order_assistant_auto_link: true,
@ -206,6 +217,8 @@ interface RawSettingsRow {
collection_ai_prompt?: string;
collection_ai_last_run_at?: string;
collection_ai_last_run_summary?: CollectionAiRunSummary | null;
collection_auto_push_enabled?: boolean;
collection_auto_push_log?: CollectionAutoPushEntry[] | null;
order_assistant_enabled?: boolean;
order_assistant_interval_minutes?: number;
order_assistant_auto_link?: boolean;
@ -273,6 +286,13 @@ function rowToSettings(row: RawSettingsRow): AiSettings {
collection_ai_last_run_summary: normaliseCollectionRunSummary(
row.collection_ai_last_run_summary,
),
collection_auto_push_enabled: !!row.collection_auto_push_enabled,
collection_auto_push_log: Array.isArray(row.collection_auto_push_log)
? row.collection_auto_push_log
: [],
// PB returns every field the collection has, so a missing key means the
// migration hasn't run.
collection_auto_push_available: "collection_auto_push_log" in row,
// The two order-assistant switches default to ON (see the spec:
// "auto-link when sure" is on by default). A row that predates the
// migration has no such field at all — treat that as "not set yet",
@ -442,6 +462,9 @@ export async function updateAiSettings(
// Same defensive cap as system_prompt (PB field max is 50 000).
dbPatch.collection_ai_prompt = patch.collection_ai_prompt.slice(0, 50_000);
}
if (patch.collection_auto_push_enabled !== undefined) {
dbPatch.collection_auto_push_enabled = !!patch.collection_auto_push_enabled;
}
if (patch.order_assistant_enabled !== undefined) {
dbPatch.order_assistant_enabled = !!patch.order_assistant_enabled;
}
@ -543,6 +566,22 @@ export async function recordCollectionAiRun(fields: {
cacheSet(rowToSettings(saved));
}
/**
* Replace the auto-push log (the caller appends and trims). Writes ONLY the
* log field. Resolves false when the live PB has no such field yet.
*/
export async function recordCollectionAutoPushLog(log: CollectionAutoPushEntry[]): Promise<boolean> {
const pb = await getAdminPocketBase();
const rows = await pb.collection(COLLECTION).getFullList({});
if (rows.length === 0) return false;
const id = (rows[0] as RawSettingsRow).id;
const saved = (await pb
.collection(COLLECTION)
.update(id, { collection_auto_push_log: log })) as RawSettingsRow;
cacheSet(rowToSettings(saved));
return "collection_auto_push_log" in saved;
}
/**
* Persist the outcome of an order-assistant tick. Mirrors
* `recordQuoteAssistantRun`: writes ONLY the last-run fields (never

View file

@ -41,6 +41,7 @@ import {
type PairPoLine,
} from "@/lib/server/collection-ai-pair";
import { buildDocFindings } from "@/lib/server/collection-ai-findings";
import { runAutoPush } from "@/lib/server/collection-auto-push-service";
import type {
CollectionAiCheck,
CollectionAiRunSummary,
@ -1003,6 +1004,16 @@ export async function runCollectionAiTick(
summary.errors.push(`tick failed: ${errMessage(e)}`);
}
// After the checks, so a card that just came out purple goes on in the same run.
// Looks at every card on the page, so one a person checked by hand is covered too.
try {
const pushed = await runAutoPush({ now: nowFn });
summary.auto_pushed = pushed.pushed;
summary.errors.push(...pushed.errors.map((m) => `auto-push ${m}`));
} catch (e) {
summary.errors.push(`auto-push failed: ${errMessage(e)}`);
}
summary.errors = boundErrors(summary.errors);
summary.finished_at = nowFn().toISOString();
await recordCollectionAiRun({ summary });

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import type { CollectionAiCheck } from "@/lib/types";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
import { planAutoPush, trimLog } from "./collection-auto-push-service";
function check(over: Partial<CollectionAiCheck> = {}, docOver: Record<string, unknown> = {}): CollectionAiCheck {
return {
read_id: "r1",
document_no: "INV-1",
doc_date: "2026-10-27",
doc_type: "tax_invoice",
doc_line: {
line_no: 1,
code: "AB1",
description: "Bolt",
quantity: 10,
unit_price: 5,
net_unit_price: null,
...docOver,
} as CollectionAiCheck["doc_line"],
same_item: "sure",
reason: "",
sku_filled_value: "",
findings: [],
checked_at: "2026-10-28T08:00:00.000Z",
model: "m",
...over,
};
}
function row(id: string, over: Partial<ConfirmRow> = {}): ConfirmRow {
return {
id,
source: "customer",
sku: "AB1",
order_qty: 10,
quantity: 10,
qty_collected: 10,
cost_price: 5,
poNumber: "PO1",
expand: { purchase_order: { id: "p", po_number: "PO1", expand: { supplier: { id: "s", suppliername: "Acme" } } } },
ai_check: check(),
...over,
};
}
describe("planAutoPush", () => {
it("plans a verified customer card as one push per invoice with count and value", () => {
const plans = planAutoPush([row("a"), row("b", { qty_collected: 4, order_qty: 4, quantity: 4, ai_check: check({}, { quantity: 4 }) })]);
expect(plans).toHaveLength(1);
expect(plans[0]).toMatchObject({
poNumber: "PO1",
supplier: "Acme",
kind: "customer",
rowIds: ["a", "b"],
invoice: { number: "INV-1", date: "2026-10-27" },
items: 2,
value: 70,
});
});
it("leaves a card alone when any line is not clean", () => {
const bad = row("b", { ai_check: check({}, { quantity: 9 }) });
expect(planAutoPush([row("a"), bad])).toEqual([]);
});
it("leaves a card with an unchecked line alone", () => {
expect(planAutoPush([row("a"), row("b", { ai_check: null })])).toEqual([]);
});
it("skips a card with a flagged line or a photo for another PO", () => {
expect(planAutoPush([row("a", { problem_flag: true })])).toEqual([]);
const other = row("a");
other.ai_doc = {
read_id: "r1", doc_type: "tax_invoice", document_no: "INV-1", doc_date: "2026-10-27",
supplier_name: "Acme", findings: [], lines: [], other_po: { kind: "customer", po_number: "PO2" },
};
expect(planAutoPush([other])).toEqual([]);
});
it("skips a customer card without a readable invoice number or date", () => {
expect(planAutoPush([row("a", { ai_check: check({ document_no: "" }) })])).toEqual([]);
expect(planAutoPush([row("a", { ai_check: check({ doc_date: "27/10/2026" }) })])).toEqual([]);
});
it("splits a card covered by two invoices into one push each", () => {
const plans = planAutoPush([row("a"), row("b", { ai_check: check({ document_no: "INV-2" }) })]);
expect(plans.map((p) => [p.invoice?.number, p.rowIds])).toEqual([
["INV-1", ["a"]],
["INV-2", ["b"]],
]);
});
it("plans stock lines separately, without needing an invoice date", () => {
const stock = row("s1", { source: "stock", stock_purchase_id: "sp1", ai_check: check({ doc_date: "" }) });
const plans = planAutoPush([stock]);
expect(plans).toHaveLength(1);
expect(plans[0]).toMatchObject({ kind: "stock", rowIds: ["s1"], stockPurchaseId: "sp1", invoice: null });
});
});
describe("trimLog", () => {
it("keeps three days, newest first", () => {
const now = new Date("2026-10-28T10:00:00Z");
const e = (at: string) => ({ at, po_number: "P", supplier: "", kind: "customer" as const, items: 1, value: 1, invoice_no: "" });
const out = trimLog([e("2026-10-24T10:00:00Z"), e("2026-10-26T10:00:00Z"), e("2026-10-28T09:00:00Z")], now);
expect(out.map((x) => x.at)).toEqual(["2026-10-28T09:00:00Z", "2026-10-26T10:00:00Z"]);
});
});

View file

@ -0,0 +1,236 @@
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)))),
};
}

View file

@ -639,9 +639,25 @@ export interface CollectionAiRunSummary {
finished_at: string;
files_read: number;
cards_checked: number;
/** Cards pushed on to invoicing by auto-push in this run. */
auto_pushed?: number;
errors: string[];
}
/** One card the AI pushed on from Collection Confirm without a person. */
export interface CollectionAutoPushEntry {
/** ISO time of the push. */
at: string;
po_number: string;
supplier: string;
/** "customer": lines went to ready for invoicing. "stock": sent to stock booking. */
kind: "customer" | "stock";
items: number;
/** Collected quantity × cost, in rand. */
value: number;
invoice_no: string;
}
/**
* One order-line ↔ quote-line pairing the AI proposed.
*