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: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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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,
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 })`
|
||||
|
|
|
|||
103
src/lib/types.ts
103
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue