diff --git a/package.json b/package.json index 50dde34..b27982e 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: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", diff --git a/pocketbase/pb_hooks/audit_log_utils.js b/pocketbase/pb_hooks/audit_log_utils.js index 26e1a50..899e33d 100644 --- a/pocketbase/pb_hooks/audit_log_utils.js +++ b/pocketbase/pb_hooks/audit_log_utils.js @@ -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 diff --git a/scripts/migrations/2026-10-24-add-order-assistant-fields.ts b/scripts/migrations/2026-10-24-add-order-assistant-fields.ts new file mode 100644 index 0000000..2ae46f8 --- /dev/null +++ b/scripts/migrations/2026-10-24-add-order-assistant-fields.ts @@ -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 & { 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 { + 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 { + 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); +}); diff --git a/src/lib/server/ai-settings-service.test.ts b/src/lib/server/ai-settings-service.test.ts index 7ed109f..2191f1a 100644 --- a/src/lib/server/ai-settings-service.test.ts +++ b/src/lib/server/ai-settings-service.test.ts @@ -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(); + }); +}); diff --git a/src/lib/server/ai-settings-service.ts b/src/lib/server/ai-settings-service.ts index 45316cb..2e5988a 100644 --- a/src/lib/server/ai-settings-service.ts +++ b/src/lib/server/ai-settings-service.ts @@ -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 { + const dbPatch: Record = {}; + 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 })` diff --git a/src/lib/types.ts b/src/lib/types.ts index d3defee..d299c14 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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