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:
parent
6c83282888
commit
3eea87ec03
16 changed files with 665 additions and 0 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
46
scripts/migrations/2026-10-28-add-collection-auto-push.ts
Normal file
46
scripts/migrations/2026-10-28-add-collection-auto-push.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
23
src/app/api/v1/collection-confirm/auto-push-log/route.ts
Normal file
23
src/app/api/v1/collection-confirm/auto-push-log/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
24
src/components/collection-confirm/auto-push-log.test.tsx
Normal file
24
src/components/collection-confirm/auto-push-log.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
98
src/components/collection-confirm/auto-push-log.tsx
Normal file
98
src/components/collection-confirm/auto-push-log.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
109
src/lib/server/collection-auto-push-service.test.ts
Normal file
109
src/lib/server/collection-auto-push-service.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
236
src/lib/server/collection-auto-push-service.ts
Normal file
236
src/lib/server/collection-auto-push-service.ts
Normal 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)))),
|
||||
};
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue