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:
parent
c0a720387a
commit
4dbe28147d
6 changed files with 502 additions and 1 deletions
|
|
@ -22,6 +22,7 @@
|
||||||
"pb:migrate:rep-trip-legs": "npx tsx scripts/migrations/2026-09-28-create-rep-trip-legs.ts",
|
"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: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-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: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: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",
|
"pb:migrate:giellie-chats": "npx tsx scripts/migrations/2026-09-30-create-giellie-chats.ts",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const IGNORED_FIELDS = {
|
||||||
quote_matching_attempted: 1, delivery_note_matching_attempted: 1,
|
quote_matching_attempted: 1, delivery_note_matching_attempted: 1,
|
||||||
duplicate_order_matching_attempted: 1, order_number_duplicate_checked: 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,
|
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
|
// Key fields recorded when a line is added or removed, so the trail can
|
||||||
|
|
|
||||||
140
scripts/migrations/2026-10-24-add-order-assistant-fields.ts
Normal file
140
scripts/migrations/2026-10-24-add-order-assistant-fields.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
clearAiSettingsCache,
|
clearAiSettingsCache,
|
||||||
getAiSettings,
|
getAiSettings,
|
||||||
recordCollectionAiRun,
|
recordCollectionAiRun,
|
||||||
|
recordOrderAssistantRun,
|
||||||
recordQuoteAssistantRun,
|
recordQuoteAssistantRun,
|
||||||
updateAiSettings,
|
updateAiSettings,
|
||||||
} from "@/lib/server/ai-settings-service";
|
} from "@/lib/server/ai-settings-service";
|
||||||
|
|
@ -241,3 +242,139 @@ describe("recordCollectionAiRun", () => {
|
||||||
expect(pb2.update).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,11 @@
|
||||||
* over encryption-at-rest on 2026-08-09. Don't add encryption here
|
* over encryption-at-rest on 2026-08-09. Don't add encryption here
|
||||||
* without re-checking with them.
|
* 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";
|
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||||
|
|
||||||
export type AiProvider = "anthropic" | "minimax";
|
export type AiProvider = "anthropic" | "minimax";
|
||||||
|
|
@ -74,6 +78,27 @@ export interface AiSettings {
|
||||||
/** ISO timestamp of the last tick; "" = never. */
|
/** ISO timestamp of the last tick; "" = never. */
|
||||||
collection_ai_last_run_at: string;
|
collection_ai_last_run_at: string;
|
||||||
collection_ai_last_run_summary: CollectionAiRunSummary | null;
|
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;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,6 +132,13 @@ export interface AiSettingsUpdate {
|
||||||
collection_ai_enabled?: boolean;
|
collection_ai_enabled?: boolean;
|
||||||
/** `""` clears the override. Capped at 50 000 chars. */
|
/** `""` clears the override. Capped at 50 000 chars. */
|
||||||
collection_ai_prompt?: string;
|
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";
|
const COLLECTION = "ai_settings";
|
||||||
|
|
@ -133,6 +165,13 @@ const DEFAULT_SETTINGS: AiSettings = {
|
||||||
collection_ai_prompt: "",
|
collection_ai_prompt: "",
|
||||||
collection_ai_last_run_at: "",
|
collection_ai_last_run_at: "",
|
||||||
collection_ai_last_run_summary: null,
|
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: "",
|
updated_at: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -167,6 +206,13 @@ interface RawSettingsRow {
|
||||||
collection_ai_prompt?: string;
|
collection_ai_prompt?: string;
|
||||||
collection_ai_last_run_at?: string;
|
collection_ai_last_run_at?: string;
|
||||||
collection_ai_last_run_summary?: CollectionAiRunSummary | null;
|
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;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,6 +236,13 @@ function normaliseCollectionRunSummary(value: unknown): CollectionAiRunSummary |
|
||||||
return null;
|
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 {
|
function rowToSettings(row: RawSettingsRow): AiSettings {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|
@ -220,6 +273,26 @@ function rowToSettings(row: RawSettingsRow): AiSettings {
|
||||||
collection_ai_last_run_summary: normaliseCollectionRunSummary(
|
collection_ai_last_run_summary: normaliseCollectionRunSummary(
|
||||||
row.collection_ai_last_run_summary,
|
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 ?? "",
|
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).
|
// Same defensive cap as system_prompt (PB field max is 50 000).
|
||||||
dbPatch.collection_ai_prompt = patch.collection_ai_prompt.slice(0, 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();
|
dbPatch.updated_at = new Date().toISOString();
|
||||||
|
|
||||||
|
|
@ -450,6 +543,32 @@ export async function recordCollectionAiRun(fields: {
|
||||||
cacheSet(rowToSettings(saved));
|
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
|
* Convenience for callers that just want the SDK knobs (no record
|
||||||
* scaffolding). Equivalent to `getAiSettings({ includeSecret: true })`
|
* scaffolding). Equivalent to `getAiSettings({ includeSecret: true })`
|
||||||
|
|
|
||||||
103
src/lib/types.ts
103
src/lib/types.ts
|
|
@ -624,6 +624,87 @@ export interface CollectionAiRunSummary {
|
||||||
errors: string[];
|
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 {
|
export interface QuoteItem extends BaseRecord {
|
||||||
quote: string;
|
quote: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -930,6 +1011,14 @@ export interface Order extends BaseRecord {
|
||||||
* don't want a schema migration per addition.
|
* don't want a schema migration per addition.
|
||||||
*/
|
*/
|
||||||
quote_match_method: string;
|
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
|
* Snapshot of the AI's analysis of this order, written by the n8n
|
||||||
* agent on creation. Free-form JSON, same shape rules as
|
* agent on creation. Free-form JSON, same shape rules as
|
||||||
|
|
@ -1122,6 +1211,20 @@ export interface OrderItem extends BaseRecord {
|
||||||
pricing_source?: string;
|
pricing_source?: string;
|
||||||
matched_price_list_id?: string;
|
matched_price_list_id?: string;
|
||||||
pricing_notes?: 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.
|
* Stock split fields added by the 2026-07-14-create-stock migration.
|
||||||
* When the operator collects a line and the supplier shipped MORE
|
* When the operator collects a line and the supplier shipped MORE
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue