diff --git a/AGENTS.md b/AGENTS.md index 21eaeca..7cc0b45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/collection-ai-checks.md b/docs/collection-ai-checks.md index 76097d4..c7bda38 100644 --- a/docs/collection-ai-checks.md +++ b/docs/collection-ai-checks.md @@ -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 | diff --git a/package.json b/package.json index b27982e..546fb64 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/migrations/2026-10-28-add-collection-auto-push.ts b/scripts/migrations/2026-10-28-add-collection-auto-push.ts new file mode 100644 index 0000000..67acff5 --- /dev/null +++ b/scripts/migrations/2026-10-28-add-collection-auto-push.ts @@ -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 & { 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); +}); diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index a06b888..4426ece 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -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() { + + {rows === null && (
{Array.from({ length: 4 }).map((_, i) => ( diff --git a/src/app/(app)/settings/ai/ai-settings-form.tsx b/src/app/(app)/settings/ai/ai-settings-form.tsx index 67413eb..d44db30 100644 --- a/src/app/(app)/settings/ai/ai-settings-form.tsx +++ b/src/app/(app)/settings/ai/ai-settings-form.tsx @@ -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) { />
+
+
+ +

+ 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." + : ""} +

+
+ +
+
diff --git a/src/app/(app)/settings/ai/page.tsx b/src/app/(app)/settings/ai/page.tsx index d45ccad..0194860 100644 --- a/src/app/(app)/settings/ai/page.tsx +++ b/src/app/(app)/settings/ai/page.tsx @@ -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, diff --git a/src/app/(app)/settings/ai/settings-form-helpers.ts b/src/app/(app)/settings/ai/settings-form-helpers.ts index 17c0b2e..4e1de0a 100644 --- a/src/app/(app)/settings/ai/settings-form-helpers.ts +++ b/src/app/(app)/settings/ai/settings-form-helpers.ts @@ -106,6 +106,7 @@ export function parseSettingsForm(formData: FormData): ParseResult { // ── Collection checks section (same
: 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"); diff --git a/src/app/api/v1/collection-confirm/auto-push-log/route.ts b/src/app/api/v1/collection-confirm/auto-push-log/route.ts new file mode 100644 index 0000000..5e493b5 --- /dev/null +++ b/src/app/api/v1/collection-confirm/auto-push-log/route.ts @@ -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 }); + } +} diff --git a/src/components/collection-confirm/auto-push-log.test.tsx b/src/components/collection-confirm/auto-push-log.test.tsx new file mode 100644 index 0000000..4116750 --- /dev/null +++ b/src/components/collection-confirm/auto-push-log.test.tsx @@ -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"); + }); +}); diff --git a/src/components/collection-confirm/auto-push-log.tsx b/src/components/collection-confirm/auto-push-log.tsx new file mode 100644 index 0000000..f9ec6b2 --- /dev/null +++ b/src/components/collection-confirm/auto-push-log.tsx @@ -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; + /** 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 ( +
+

+ + Pushed on automatically · today and yesterday +

+ {groups.length === 0 ? ( +

Nothing pushed yet.

+ ) : ( + groups.map((g) => ( +
+

{g.label}

+
    + {g.entries.map((e) => ( +
  • + {e.po_number} + {e.supplier || "—"} + + {e.items} item{e.items === 1 ? "" : "s"} + + {rand(e.value)} +
  • + ))} +
+
+ )) + )} +
+ ); +} diff --git a/src/lib/server/ai-settings-service.ts b/src/lib/server/ai-settings-service.ts index 2e5988a..9bb017b 100644 --- a/src/lib/server/ai-settings-service.ts +++ b/src/lib/server/ai-settings-service.ts @@ -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 { + 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 diff --git a/src/lib/server/collection-ai-service.ts b/src/lib/server/collection-ai-service.ts index 96e367a..d3bead0 100644 --- a/src/lib/server/collection-ai-service.ts +++ b/src/lib/server/collection-ai-service.ts @@ -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 }); diff --git a/src/lib/server/collection-auto-push-service.test.ts b/src/lib/server/collection-auto-push-service.test.ts new file mode 100644 index 0000000..2671785 --- /dev/null +++ b/src/lib/server/collection-auto-push-service.test.ts @@ -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 = {}, docOver: Record = {}): 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 { + 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"]); + }); +}); diff --git a/src/lib/server/collection-auto-push-service.ts b/src/lib/server/collection-auto-push-service.ts new file mode 100644 index 0000000..a04ee5b --- /dev/null +++ b/src/lib/server/collection-auto-push-service.ts @@ -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(); + 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)))), + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index d1301fa..87d6be0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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. *