feat(orders): fields and settings for the AI order assistant

Task 3 of the AI order assistant plan.

- types: OrderAiQuoteMatch, OrderAiLinePair, OrderItemAiFill,
  OrderAssistantRunSummary; Order.ai_quote_match, OrderItem.ai_fill,
  OrderItem.quote_item
- ai_settings: order_assistant_enabled (off), _interval_minutes (10,
  5-1440), _auto_link (on), _fill_lines (on), _prompt, _last_run_at,
  _last_run_summary; recordOrderAssistantRun()
- migration script + npm run pb:migrate:order-assistant (not run)
- audit_log_utils IGNORED_FIELDS: ai_quote_match, ai_fill

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 12:56:35 +02:00
parent c0a720387a
commit 4dbe28147d
6 changed files with 502 additions and 1 deletions

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: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",
"pb:migrate:giellie-chats": "npx tsx scripts/migrations/2026-09-30-create-giellie-chats.ts",

View file

@ -20,6 +20,7 @@ const IGNORED_FIELDS = {
quote_matching_attempted: 1, delivery_note_matching_attempted: 1,
duplicate_order_matching_attempted: 1, order_number_duplicate_checked: 1,
email_opened: 1, email_opened_at: 1, email_delivered: 1, email_delivered_at: 1, ai_check: 1,
ai_quote_match: 1, ai_fill: 1,
};
// Key fields recorded when a line is added or removed, so the trail can

View file

@ -0,0 +1,140 @@
/**
* One-shot migration: fields for the AI order assistant.
*
* orders.ai_quote_match json (per-order AI verdict)
* order_items.ai_fill json (per-line fill record)
* ai_settings.order_assistant_enabled bool
* ai_settings.order_assistant_interval_minutes number
* ai_settings.order_assistant_auto_link bool
* ai_settings.order_assistant_fill_lines bool
* ai_settings.order_assistant_prompt text (max 50000)
* ai_settings.order_assistant_last_run_at text (max 40)
* ai_settings.order_assistant_last_run_summary json
*
* Run with:
*
* npm run pb:migrate:order-assistant
*
* Idempotent — fields that already exist are skipped. Manual step on the
* live PB (like select values and pb_hooks).
*
* NOTE: PB text fields with `max: 0` cap at 5000 chars, NOT unlimited, so
* the prompt field gets an explicit max of 50000.
*
* The two order-assistant switches default to ON (see the design spec:
* "Auto-link when sure" is on by default), but a fresh PB bool field
* starts every existing row at `false`. So the first run that actually
* adds these fields also seeds the ai_settings row with `true`. A later
* re-run skips the seeding (the fields already exist), which keeps an
* operator's deliberate `false` intact.
*
* Rollback: delete the fields in the PB admin UI.
*/
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 = "[order-assistant]";
type FieldDef = Record<string, unknown> & { name: string };
const text = (name: string, max = 500): FieldDef => ({
name,
type: "text",
required: false,
presentable: false,
max,
});
const json = (name: string, maxSize: number): FieldDef => ({
name,
type: "json",
required: false,
presentable: false,
maxSize,
});
const ORDER_FIELDS: FieldDef[] = [json("ai_quote_match", 65536)];
const ORDER_ITEM_FIELDS: FieldDef[] = [json("ai_fill", 16384)];
const AI_SETTINGS_FIELDS: FieldDef[] = [
{ name: "order_assistant_enabled", type: "bool", required: false, presentable: false },
{ name: "order_assistant_interval_minutes", type: "number", required: false, presentable: false },
{ name: "order_assistant_auto_link", type: "bool", required: false, presentable: false },
{ name: "order_assistant_fill_lines", type: "bool", required: false, presentable: false },
text("order_assistant_prompt", 50000),
text("order_assistant_last_run_at", 40),
json("order_assistant_last_run_summary", 20000),
];
/**
* Add the fields that don't exist yet. Returns true when at least one
* field was actually added — the caller uses that to decide whether it
* is the first run (and may seed the default-on switches).
*/
async function addMissingFields(
pb: PocketBase,
collectionName: string,
wanted: FieldDef[],
): Promise<boolean> {
const collection = await pb.collections.getOne(collectionName);
const existing = new Set(collection.fields.map((f: { name: string }) => f.name));
const missing = wanted.filter((f) => !existing.has(f.name));
for (const f of wanted) {
if (existing.has(f.name)) {
console.log(`${TAG} ${collectionName}.${f.name} already exists — skipping.`);
}
}
if (missing.length === 0) return false;
await pb.collections.update(collection.id, {
fields: [...collection.fields, ...missing],
});
for (const f of missing) {
console.log(`${TAG} ${collectionName}.${f.name} added.`);
}
return true;
}
/**
* Turn the two default-on switches on for the existing ai_settings row.
* Only called on the first run (when the fields were just added).
*/
async function seedDefaultOnSwitches(pb: PocketBase): Promise<void> {
const rows = await pb.collection("ai_settings").getFullList<{ id: string }>();
if (rows.length === 0) {
console.log(`${TAG} no ai_settings row yet — the code defaults cover it.`);
return;
}
await pb.collection("ai_settings").update(rows[0].id, {
order_assistant_auto_link: true,
order_assistant_fill_lines: true,
});
console.log(`${TAG} ai_settings: auto-link + fill-lines defaults turned on.`);
}
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}`);
await addMissingFields(pb, "orders", ORDER_FIELDS);
await addMissingFields(pb, "order_items", ORDER_ITEM_FIELDS);
const settingsAdded = await addMissingFields(pb, "ai_settings", AI_SETTINGS_FIELDS);
if (settingsAdded) await seedDefaultOnSwitches(pb);
console.log(`${TAG} Done.`);
}
main().catch((err) => {
console.error(`${TAG} Migration failed:`, err);
process.exit(1);
});

View file

@ -3,6 +3,7 @@ import {
clearAiSettingsCache,
getAiSettings,
recordCollectionAiRun,
recordOrderAssistantRun,
recordQuoteAssistantRun,
updateAiSettings,
} from "@/lib/server/ai-settings-service";
@ -241,3 +242,139 @@ describe("recordCollectionAiRun", () => {
expect(pb2.update).not.toHaveBeenCalled();
});
});
describe("getAiSettings — order assistant fields", () => {
it("defaults the new fields when the row lacks them", async () => {
buildPb([BASE_ROW]);
const s = await getAiSettings();
expect(s.order_assistant_enabled).toBe(false);
expect(s.order_assistant_interval_minutes).toBe(10);
expect(s.order_assistant_auto_link).toBe(true);
expect(s.order_assistant_fill_lines).toBe(true);
expect(s.order_assistant_prompt).toBe("");
expect(s.order_assistant_last_run_at).toBe("");
expect(s.order_assistant_last_run_summary).toBeNull();
});
it("reads stored values", async () => {
const summary = {
started_at: "a",
finished_at: "b",
orders_seen: 3,
linked: 1,
suggested: 1,
no_match: 1,
lines_filled: 0,
errors: [],
};
buildPb([
{
...BASE_ROW,
order_assistant_enabled: true,
order_assistant_interval_minutes: 15,
order_assistant_auto_link: false,
order_assistant_fill_lines: false,
order_assistant_prompt: "custom",
order_assistant_last_run_at: "2026-10-24T09:00:00.000Z",
order_assistant_last_run_summary: summary,
},
]);
const s = await getAiSettings();
expect(s.order_assistant_enabled).toBe(true);
expect(s.order_assistant_interval_minutes).toBe(15);
expect(s.order_assistant_auto_link).toBe(false);
expect(s.order_assistant_fill_lines).toBe(false);
expect(s.order_assistant_prompt).toBe("custom");
expect(s.order_assistant_last_run_at).toBe("2026-10-24T09:00:00.000Z");
expect(s.order_assistant_last_run_summary).toEqual(summary);
});
});
describe("updateAiSettings — order assistant validation", () => {
it("rejects an interval below 5", async () => {
buildPb([BASE_ROW]);
await expect(
updateAiSettings({ order_assistant_interval_minutes: 4 }),
).rejects.toThrow("order_assistant_interval_minutes must be between 5 and 1440");
});
it("rejects an interval above 1440", async () => {
buildPb([BASE_ROW]);
await expect(
updateAiSettings({ order_assistant_interval_minutes: 1441 }),
).rejects.toThrow("order_assistant_interval_minutes must be between 5 and 1440");
});
it("rounds a valid interval to an integer", async () => {
const pb = buildPb([BASE_ROW]);
await updateAiSettings({ order_assistant_interval_minutes: 7.6 });
expect(pb.update.mock.calls[0][1].order_assistant_interval_minutes).toBe(8);
});
it("round-trips order_assistant_auto_link false", async () => {
const pb = buildPb([BASE_ROW]);
await updateAiSettings({ order_assistant_auto_link: false });
expect(pb.update.mock.calls[0][1].order_assistant_auto_link).toBe(false);
});
it("round-trips order_assistant_fill_lines false", async () => {
const pb = buildPb([BASE_ROW]);
await updateAiSettings({ order_assistant_fill_lines: false });
expect(pb.update.mock.calls[0][1].order_assistant_fill_lines).toBe(false);
});
it("writes the enabled flag", async () => {
const pb = buildPb([BASE_ROW]);
await updateAiSettings({ order_assistant_enabled: true });
expect(pb.update.mock.calls[0][1].order_assistant_enabled).toBe(true);
});
it("caps the prompt at 50 000 characters", async () => {
const pb = buildPb([BASE_ROW]);
await updateAiSettings({ order_assistant_prompt: "x".repeat(60_000) });
const sent = pb.update.mock.calls[0][1].order_assistant_prompt as string;
expect(sent).toHaveLength(50_000);
});
});
describe("recordOrderAssistantRun", () => {
it("writes only the last-run fields and refreshes the cache", async () => {
const pb = buildPb([BASE_ROW]);
await recordOrderAssistantRun({ last_run_at: "2026-10-24T10:00:00.000Z" });
expect(pb.update).toHaveBeenCalledTimes(1);
expect(pb.update).toHaveBeenCalledWith("s1", {
order_assistant_last_run_at: "2026-10-24T10:00:00.000Z",
});
pb.getFullList.mockClear();
const s = await getAiSettings();
expect(s.order_assistant_last_run_at).toBe("2026-10-24T10:00:00.000Z");
expect(pb.getFullList).not.toHaveBeenCalled();
});
it("writes the summary when given", async () => {
const pb = buildPb([BASE_ROW]);
const summary = {
started_at: "a",
finished_at: "b",
orders_seen: 2,
linked: 1,
suggested: 1,
no_match: 0,
lines_filled: 0,
errors: [],
};
await recordOrderAssistantRun({ summary });
expect(pb.update).toHaveBeenCalledWith("s1", {
order_assistant_last_run_summary: summary,
});
});
it("does nothing when there is no settings row or no fields", async () => {
const pb = buildPb([]);
await recordOrderAssistantRun({ last_run_at: "x" });
expect(pb.update).not.toHaveBeenCalled();
const pb2 = buildPb([BASE_ROW]);
await recordOrderAssistantRun({});
expect(pb2.update).not.toHaveBeenCalled();
});
});

View file

@ -30,7 +30,11 @@
* over encryption-at-rest on 2026-08-09. Don't add encryption here
* without re-checking with them.
*/
import type { CollectionAiRunSummary, QuoteAssistantRunSummary } from "@/lib/types";
import type {
CollectionAiRunSummary,
OrderAssistantRunSummary,
QuoteAssistantRunSummary,
} from "@/lib/types";
import { getAdminPocketBase } from "./pocketbase-admin";
export type AiProvider = "anthropic" | "minimax";
@ -74,6 +78,27 @@ export interface AiSettings {
/** ISO timestamp of the last tick; "" = never. */
collection_ai_last_run_at: string;
collection_ai_last_run_summary: CollectionAiRunSummary | null;
/** AI order assistant: background tick on/off (default off). */
order_assistant_enabled: boolean;
/** Minutes between ticks (5–1440, default 10). */
order_assistant_interval_minutes: number;
/**
* May the assistant hard-link an order to a quote on its own?
* Default true — the assistant only links when it is sure (high
* confidence, every real line paired "sure", same company).
*/
order_assistant_auto_link: boolean;
/**
* May the assistant pre-fill order lines from the matched quote?
* Default true. Phase 2 only — the setting exists now so phase 1
* ships the same switch.
*/
order_assistant_fill_lines: boolean;
/** Operator override of the assistant prompt; "" = use the default. */
order_assistant_prompt: string;
/** ISO timestamp of the last tick; "" = never. */
order_assistant_last_run_at: string;
order_assistant_last_run_summary: OrderAssistantRunSummary | null;
updated_at: string;
}
@ -107,6 +132,13 @@ export interface AiSettingsUpdate {
collection_ai_enabled?: boolean;
/** `""` clears the override. Capped at 50 000 chars. */
collection_ai_prompt?: string;
order_assistant_enabled?: boolean;
/** 5–1440; rounded to an integer. */
order_assistant_interval_minutes?: number;
order_assistant_auto_link?: boolean;
order_assistant_fill_lines?: boolean;
/** `""` clears the override. Capped at 50 000 chars. */
order_assistant_prompt?: string;
}
const COLLECTION = "ai_settings";
@ -133,6 +165,13 @@ const DEFAULT_SETTINGS: AiSettings = {
collection_ai_prompt: "",
collection_ai_last_run_at: "",
collection_ai_last_run_summary: null,
order_assistant_enabled: false,
order_assistant_interval_minutes: 10,
order_assistant_auto_link: true,
order_assistant_fill_lines: true,
order_assistant_prompt: "",
order_assistant_last_run_at: "",
order_assistant_last_run_summary: null,
updated_at: "",
};
@ -167,6 +206,13 @@ interface RawSettingsRow {
collection_ai_prompt?: string;
collection_ai_last_run_at?: string;
collection_ai_last_run_summary?: CollectionAiRunSummary | null;
order_assistant_enabled?: boolean;
order_assistant_interval_minutes?: number;
order_assistant_auto_link?: boolean;
order_assistant_fill_lines?: boolean;
order_assistant_prompt?: string;
order_assistant_last_run_at?: string;
order_assistant_last_run_summary?: OrderAssistantRunSummary | null;
updated_at?: string;
}
@ -190,6 +236,13 @@ function normaliseCollectionRunSummary(value: unknown): CollectionAiRunSummary |
return null;
}
function normaliseOrderRunSummary(value: unknown): OrderAssistantRunSummary | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as OrderAssistantRunSummary;
}
return null;
}
function rowToSettings(row: RawSettingsRow): AiSettings {
return {
id: row.id,
@ -220,6 +273,26 @@ function rowToSettings(row: RawSettingsRow): AiSettings {
collection_ai_last_run_summary: normaliseCollectionRunSummary(
row.collection_ai_last_run_summary,
),
// 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",
// i.e. the default, not as an explicit `false`.
order_assistant_enabled: !!row.order_assistant_enabled,
order_assistant_interval_minutes:
typeof row.order_assistant_interval_minutes === "number" &&
row.order_assistant_interval_minutes >= 5 &&
row.order_assistant_interval_minutes <= 1440
? row.order_assistant_interval_minutes
: 10,
order_assistant_auto_link:
row.order_assistant_auto_link === undefined ? true : !!row.order_assistant_auto_link,
order_assistant_fill_lines:
row.order_assistant_fill_lines === undefined ? true : !!row.order_assistant_fill_lines,
order_assistant_prompt: row.order_assistant_prompt ?? "",
order_assistant_last_run_at: row.order_assistant_last_run_at ?? "",
order_assistant_last_run_summary: normaliseOrderRunSummary(
row.order_assistant_last_run_summary,
),
updated_at: row.updated_at ?? "",
};
}
@ -369,6 +442,26 @@ 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.order_assistant_enabled !== undefined) {
dbPatch.order_assistant_enabled = !!patch.order_assistant_enabled;
}
if (patch.order_assistant_interval_minutes !== undefined) {
const n = patch.order_assistant_interval_minutes;
if (typeof n !== "number" || !Number.isFinite(n) || n < 5 || n > 1440) {
throw new Error("order_assistant_interval_minutes must be between 5 and 1440");
}
dbPatch.order_assistant_interval_minutes = Math.round(n);
}
if (patch.order_assistant_auto_link !== undefined) {
dbPatch.order_assistant_auto_link = !!patch.order_assistant_auto_link;
}
if (patch.order_assistant_fill_lines !== undefined) {
dbPatch.order_assistant_fill_lines = !!patch.order_assistant_fill_lines;
}
if (patch.order_assistant_prompt !== undefined) {
// Same defensive cap as system_prompt (PB field max is 50 000).
dbPatch.order_assistant_prompt = patch.order_assistant_prompt.slice(0, 50_000);
}
dbPatch.updated_at = new Date().toISOString();
@ -450,6 +543,32 @@ export async function recordCollectionAiRun(fields: {
cacheSet(rowToSettings(saved));
}
/**
* Persist the outcome of an order-assistant tick. Mirrors
* `recordQuoteAssistantRun`: writes ONLY the last-run fields (never
* `updated_at`), refreshes the cache, no-op without a row or fields.
*/
export async function recordOrderAssistantRun(fields: {
last_run_at?: string;
summary?: OrderAssistantRunSummary;
}): Promise<void> {
const dbPatch: Record<string, unknown> = {};
if (fields.last_run_at !== undefined) {
dbPatch.order_assistant_last_run_at = fields.last_run_at;
}
if (fields.summary !== undefined) {
dbPatch.order_assistant_last_run_summary = fields.summary;
}
if (Object.keys(dbPatch).length === 0) return;
const pb = await getAdminPocketBase();
const rows = await pb.collection(COLLECTION).getFullList({});
if (rows.length === 0) return;
const id = (rows[0] as RawSettingsRow).id;
const saved = (await pb.collection(COLLECTION).update(id, dbPatch)) as RawSettingsRow;
cacheSet(rowToSettings(saved));
}
/**
* Convenience for callers that just want the SDK knobs (no record
* scaffolding). Equivalent to `getAiSettings({ includeSecret: true })`

View file

@ -624,6 +624,87 @@ export interface CollectionAiRunSummary {
errors: string[];
}
/**
* One order-line ↔ quote-line pairing the AI proposed.
*
* Lives inside `OrderAiQuoteMatch.lines` — never on the order line
* itself. `quote_line_id` is null when the AI could not find a quote
* line for this order line (the customer added something new).
*/
export interface OrderAiLinePair {
order_line_id: string;
quote_line_id: string | null;
/** Which quote the paired quote line belongs to. */
quote_id: string | null;
same_item: "sure" | "likely" | "unsure" | null;
reason: string;
}
/**
* What the AI order assistant recorded on an order. Lives in
* `orders.ai_quote_match` (json).
*
* `status` mirrors the outcome:
* - "matched" → the order was hard-linked to `quote_id` by the AI
* - "suggested" → `quote_id` is only a suggestion
* - "no_match" → no plausible quote
* - "error" → the AI call failed; retried after an hour
*/
export interface OrderAiQuoteMatch {
status: "matched" | "suggested" | "no_match" | "error";
quote_id: string | null;
quote_number: string;
confidence: "high" | "medium" | "low" | null;
reason: string;
doubts: string[];
lines: OrderAiLinePair[];
/** True only when the AI itself created the hard link. */
auto_linked: boolean;
/** Fingerprint of the order lines this run looked at. */
lines_hash: string;
model: string;
run_at: string;
error?: string;
}
/**
* What the AI order assistant recorded on an order line. Lives in
* `order_items.ai_fill` (json). Phase 2 fills order lines from the
* matched quote — this is the audit record of that fill.
*/
export interface OrderItemAiFill {
status: "filled" | "skipped" | "error";
source: "quote";
quote_item_id: string;
quote_id: string;
quote_number: string;
cost_price: number;
selling_price: number;
markup: number;
supplier_id: string;
sku: string;
reason: string;
doubts: string[];
model: string;
attempted_at: string;
reviewed_at?: string;
reviewed_by?: string;
accepted_unchanged?: boolean;
supplier_changed?: boolean;
}
/** What one AI order-assistant tick did. */
export interface OrderAssistantRunSummary {
started_at: string;
finished_at: string;
orders_seen: number;
linked: number;
suggested: number;
no_match: number;
lines_filled: number;
errors: string[];
}
export interface QuoteItem extends BaseRecord {
quote: string;
description: string;
@ -930,6 +1011,14 @@ export interface Order extends BaseRecord {
* don't want a schema migration per addition.
*/
quote_match_method: string;
/**
* What the AI order assistant decided about this order — the quote it
* linked (or suggests), why, its doubts, and the line-by-line pairing.
* Free-form JSON written by `src/lib/server/order-assistant-service.ts`.
* `quote_match_method` carries the short version ("ai_auto" /
* "ai_suggest" / "ai_no_match"); this blob carries the story.
*/
ai_quote_match?: OrderAiQuoteMatch | null;
/**
* Snapshot of the AI's analysis of this order, written by the n8n
* agent on creation. Free-form JSON, same shape rules as
@ -1122,6 +1211,20 @@ export interface OrderItem extends BaseRecord {
pricing_source?: string;
matched_price_list_id?: string;
pricing_notes?: string;
/**
* What the AI order assistant wrote onto this line from the matched
* quote (phase 2). JSON blob — see `OrderItemAiFill`. `status:
* "filled"` with no `reviewed_at` is the "pending purple review"
* state. Written only onto lines a person has not touched.
*/
ai_fill?: OrderItemAiFill | null;
/**
* Relation to the `quote_items` row this line came from. Written by
* `pairOrderLines` (description matching) and by the AI order
* assistant (its own pairing). A line that already carries one is
* never re-paired.
*/
quote_item?: string;
/**
* Stock split fields added by the 2026-07-14-create-stock migration.
* When the operator collects a line and the supplier shipped MORE