feat(quote-assistant): data model, settings fields, migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7d5f4de7d8
commit
f0e7848eed
6 changed files with 432 additions and 0 deletions
|
|
@ -20,6 +20,7 @@
|
|||
"pb:migrate:pos-sales-delivery-note": "npx tsx scripts/migrations/2026-10-03-add-pos-sales-delivery-note.ts",
|
||||
"pb:migrate:audit-log": "npx tsx scripts/migrations/2026-09-25-create-audit-log.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:giellie-chats": "npx tsx scripts/migrations/2026-09-30-create-giellie-chats.ts",
|
||||
"pb:migrate:place-overrides": "npx tsx scripts/migrations/2026-09-28-create-place-overrides.ts",
|
||||
"pb:migrate:drop-orders-status": "npx tsx scripts/migrations/2026-06-27-drop-orders-status-and-wipe.ts",
|
||||
|
|
|
|||
82
scripts/migrations/2026-10-16-add-quote-assistant-fields.ts
Normal file
82
scripts/migrations/2026-10-16-add-quote-assistant-fields.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* One-shot migration: fields for the AI quote assistant.
|
||||
*
|
||||
* quote_items.ai_fill json (per-line match/fill record)
|
||||
* ai_settings.quote_assistant_enabled bool
|
||||
* ai_settings.quote_assistant_interval_minutes number
|
||||
* ai_settings.quote_assistant_min_confidence text (max 10)
|
||||
* ai_settings.quote_assistant_prompt text (max 50000)
|
||||
* ai_settings.quote_assistant_last_run_at text (max 40)
|
||||
* ai_settings.quote_assistant_last_run_summary json
|
||||
*
|
||||
* Run with:
|
||||
*
|
||||
* npm run pb:migrate:quote-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.
|
||||
*
|
||||
* 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;
|
||||
|
||||
type FieldDef = Record<string, unknown> & { name: string };
|
||||
|
||||
const QUOTE_ITEM_FIELDS: FieldDef[] = [
|
||||
{ name: "ai_fill", type: "json", required: false, presentable: false, maxSize: 200000 },
|
||||
];
|
||||
|
||||
const AI_SETTINGS_FIELDS: FieldDef[] = [
|
||||
{ name: "quote_assistant_enabled", type: "bool", required: false, presentable: false },
|
||||
{ name: "quote_assistant_interval_minutes", type: "number", required: false, presentable: false },
|
||||
{ name: "quote_assistant_min_confidence", type: "text", required: false, presentable: false, max: 10 },
|
||||
{ name: "quote_assistant_prompt", type: "text", required: false, presentable: false, max: 50000 },
|
||||
{ name: "quote_assistant_last_run_at", type: "text", required: false, presentable: false, max: 40 },
|
||||
{ name: "quote_assistant_last_run_summary", type: "json", required: false, presentable: false, maxSize: 20000 },
|
||||
];
|
||||
|
||||
async function addMissingFields(pb: PocketBase, collectionName: string, wanted: FieldDef[]) {
|
||||
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(`[quote-assistant] ${collectionName}.${f.name} already exists — skipping.`);
|
||||
}
|
||||
}
|
||||
if (missing.length === 0) return;
|
||||
|
||||
await pb.collections.update(collection.id, {
|
||||
fields: [...collection.fields, ...missing],
|
||||
});
|
||||
for (const f of missing) {
|
||||
console.log(`[quote-assistant] ${collectionName}.${f.name} added.`);
|
||||
}
|
||||
}
|
||||
|
||||
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(`[quote-assistant] Authenticated against ${PB_URL}`);
|
||||
|
||||
await addMissingFields(pb, "quote_items", QUOTE_ITEM_FIELDS);
|
||||
await addMissingFields(pb, "ai_settings", AI_SETTINGS_FIELDS);
|
||||
console.log("[quote-assistant] Done.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[quote-assistant] Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
170
src/lib/server/ai-settings-service.test.ts
Normal file
170
src/lib/server/ai-settings-service.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
clearAiSettingsCache,
|
||||
getAiSettings,
|
||||
recordQuoteAssistantRun,
|
||||
updateAiSettings,
|
||||
} from "@/lib/server/ai-settings-service";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||||
getAdminPocketBase: vi.fn(),
|
||||
}));
|
||||
|
||||
function buildPb(rows: Array<Record<string, unknown>>) {
|
||||
const getFullList = vi.fn().mockResolvedValue(rows);
|
||||
const update = vi.fn(async (id: string, patch: Record<string, unknown>) => ({
|
||||
...(rows.find((r) => r.id === id) ?? { id }),
|
||||
...patch,
|
||||
}));
|
||||
const create = vi.fn(async (data: Record<string, unknown>) => ({
|
||||
id: "new",
|
||||
...data,
|
||||
}));
|
||||
const collection = vi.fn(() => ({ getFullList, update, create }));
|
||||
vi.mocked(getAdminPocketBase).mockResolvedValue({ collection } as never);
|
||||
return { getFullList, update, create, collection };
|
||||
}
|
||||
|
||||
const BASE_ROW = {
|
||||
id: "s1",
|
||||
provider: "minimax",
|
||||
api_key: "k",
|
||||
base_url: "https://x",
|
||||
model_id: "m",
|
||||
max_tokens: 4096,
|
||||
enabled: true,
|
||||
system_prompt: "",
|
||||
updated_at: "2026-10-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearAiSettingsCache();
|
||||
});
|
||||
|
||||
describe("getAiSettings — quote assistant fields", () => {
|
||||
it("defaults the new fields when the row lacks them", async () => {
|
||||
buildPb([BASE_ROW]);
|
||||
const s = await getAiSettings();
|
||||
expect(s.quote_assistant_enabled).toBe(false);
|
||||
expect(s.quote_assistant_interval_minutes).toBe(10);
|
||||
expect(s.quote_assistant_min_confidence).toBe("medium");
|
||||
expect(s.quote_assistant_prompt).toBe("");
|
||||
expect(s.quote_assistant_last_run_at).toBe("");
|
||||
expect(s.quote_assistant_last_run_summary).toBeNull();
|
||||
});
|
||||
|
||||
it("reads stored values", async () => {
|
||||
const summary = {
|
||||
started_at: "a",
|
||||
finished_at: "b",
|
||||
quotes_seen: 1,
|
||||
lines_filled: 1,
|
||||
lines_unmatched: 0,
|
||||
lines_skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
buildPb([
|
||||
{
|
||||
...BASE_ROW,
|
||||
quote_assistant_enabled: true,
|
||||
quote_assistant_interval_minutes: 30,
|
||||
quote_assistant_min_confidence: "high",
|
||||
quote_assistant_prompt: "custom",
|
||||
quote_assistant_last_run_at: "2026-10-16T10:00:00.000Z",
|
||||
quote_assistant_last_run_summary: summary,
|
||||
},
|
||||
]);
|
||||
const s = await getAiSettings();
|
||||
expect(s.quote_assistant_enabled).toBe(true);
|
||||
expect(s.quote_assistant_interval_minutes).toBe(30);
|
||||
expect(s.quote_assistant_min_confidence).toBe("high");
|
||||
expect(s.quote_assistant_prompt).toBe("custom");
|
||||
expect(s.quote_assistant_last_run_at).toBe("2026-10-16T10:00:00.000Z");
|
||||
expect(s.quote_assistant_last_run_summary).toEqual(summary);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateAiSettings — quote assistant validation", () => {
|
||||
it("rejects an interval below 5", async () => {
|
||||
buildPb([BASE_ROW]);
|
||||
await expect(
|
||||
updateAiSettings({ quote_assistant_interval_minutes: 4 }),
|
||||
).rejects.toThrow("quote_assistant_interval_minutes must be between 5 and 1440");
|
||||
});
|
||||
|
||||
it("rejects an interval above 1440", async () => {
|
||||
buildPb([BASE_ROW]);
|
||||
await expect(
|
||||
updateAiSettings({ quote_assistant_interval_minutes: 1441 }),
|
||||
).rejects.toThrow("quote_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({ quote_assistant_interval_minutes: 7.6 });
|
||||
expect(pb.update.mock.calls[0][1].quote_assistant_interval_minutes).toBe(8);
|
||||
});
|
||||
|
||||
it("rejects a min confidence other than high/medium", async () => {
|
||||
buildPb([BASE_ROW]);
|
||||
await expect(
|
||||
updateAiSettings({ quote_assistant_min_confidence: "low" as never }),
|
||||
).rejects.toThrow('quote_assistant_min_confidence must be "high" or "medium"');
|
||||
});
|
||||
|
||||
it("caps the prompt at 50 000 characters", async () => {
|
||||
const pb = buildPb([BASE_ROW]);
|
||||
await updateAiSettings({ quote_assistant_prompt: "x".repeat(60_000) });
|
||||
const sent = pb.update.mock.calls[0][1].quote_assistant_prompt as string;
|
||||
expect(sent).toHaveLength(50_000);
|
||||
});
|
||||
|
||||
it("writes the enabled flag", async () => {
|
||||
const pb = buildPb([BASE_ROW]);
|
||||
await updateAiSettings({ quote_assistant_enabled: true });
|
||||
expect(pb.update.mock.calls[0][1].quote_assistant_enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordQuoteAssistantRun", () => {
|
||||
it("writes only the last-run fields and refreshes the cache", async () => {
|
||||
const pb = buildPb([BASE_ROW]);
|
||||
await recordQuoteAssistantRun({ last_run_at: "2026-10-16T10:00:00.000Z" });
|
||||
expect(pb.update).toHaveBeenCalledTimes(1);
|
||||
expect(pb.update).toHaveBeenCalledWith("s1", {
|
||||
quote_assistant_last_run_at: "2026-10-16T10:00:00.000Z",
|
||||
});
|
||||
pb.getFullList.mockClear();
|
||||
const s = await getAiSettings();
|
||||
expect(s.quote_assistant_last_run_at).toBe("2026-10-16T10: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",
|
||||
quotes_seen: 2,
|
||||
lines_filled: 1,
|
||||
lines_unmatched: 1,
|
||||
lines_skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
await recordQuoteAssistantRun({ summary });
|
||||
expect(pb.update).toHaveBeenCalledWith("s1", {
|
||||
quote_assistant_last_run_summary: summary,
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing when there is no settings row or no fields", async () => {
|
||||
const pb = buildPb([]);
|
||||
await recordQuoteAssistantRun({ last_run_at: "x" });
|
||||
expect(pb.update).not.toHaveBeenCalled();
|
||||
const pb2 = buildPb([BASE_ROW]);
|
||||
await recordQuoteAssistantRun({});
|
||||
expect(pb2.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
* over encryption-at-rest on 2026-08-09. Don't add encryption here
|
||||
* without re-checking with them.
|
||||
*/
|
||||
import type { QuoteAssistantRunSummary } from "@/lib/types";
|
||||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
|
||||
export type AiProvider = "anthropic" | "minimax";
|
||||
|
|
@ -55,9 +56,22 @@ export interface AiSettings {
|
|||
* processor reads this via `getAiSettings()` (cached, 60s TTL).
|
||||
*/
|
||||
system_prompt: string;
|
||||
/** AI quote assistant: background tick on/off (default off). */
|
||||
quote_assistant_enabled: boolean;
|
||||
/** Minutes between ticks (5–1440, default 10). */
|
||||
quote_assistant_interval_minutes: number;
|
||||
/** Lowest confidence the assistant may write onto a line. */
|
||||
quote_assistant_min_confidence: QuoteAssistantMinConfidence;
|
||||
/** Operator override of the assistant prompt; "" = use the default. */
|
||||
quote_assistant_prompt: string;
|
||||
/** ISO timestamp of the last tick; "" = never. */
|
||||
quote_assistant_last_run_at: string;
|
||||
quote_assistant_last_run_summary: QuoteAssistantRunSummary | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type QuoteAssistantMinConfidence = "high" | "medium";
|
||||
|
||||
/**
|
||||
* The shape returned to write paths. `api_key` here is OPTIONAL — when
|
||||
* omitted on a PUT the existing key is preserved (the UI sends an empty
|
||||
|
|
@ -77,6 +91,12 @@ export interface AiSettingsUpdate {
|
|||
* (matches the PB field's `options.max`).
|
||||
*/
|
||||
system_prompt?: string;
|
||||
quote_assistant_enabled?: boolean;
|
||||
/** 5–1440; rounded to an integer. */
|
||||
quote_assistant_interval_minutes?: number;
|
||||
quote_assistant_min_confidence?: QuoteAssistantMinConfidence;
|
||||
/** `""` clears the override. Capped at 50 000 chars. */
|
||||
quote_assistant_prompt?: string;
|
||||
}
|
||||
|
||||
const COLLECTION = "ai_settings";
|
||||
|
|
@ -93,6 +113,12 @@ const DEFAULT_SETTINGS: AiSettings = {
|
|||
max_tokens: 4096,
|
||||
enabled: false,
|
||||
system_prompt: "",
|
||||
quote_assistant_enabled: false,
|
||||
quote_assistant_interval_minutes: 10,
|
||||
quote_assistant_min_confidence: "medium",
|
||||
quote_assistant_prompt: "",
|
||||
quote_assistant_last_run_at: "",
|
||||
quote_assistant_last_run_summary: null,
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
|
|
@ -117,6 +143,12 @@ interface RawSettingsRow {
|
|||
max_tokens?: number;
|
||||
enabled?: boolean;
|
||||
system_prompt?: string;
|
||||
quote_assistant_enabled?: boolean;
|
||||
quote_assistant_interval_minutes?: number;
|
||||
quote_assistant_min_confidence?: string;
|
||||
quote_assistant_prompt?: string;
|
||||
quote_assistant_last_run_at?: string;
|
||||
quote_assistant_last_run_summary?: QuoteAssistantRunSummary | null;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
|
|
@ -125,6 +157,14 @@ function normaliseProvider(value: unknown): AiProvider | "" {
|
|||
return "";
|
||||
}
|
||||
|
||||
/** PB json fields come back as `null` / `""` when empty. */
|
||||
function normaliseRunSummary(value: unknown): QuoteAssistantRunSummary | null {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as QuoteAssistantRunSummary;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rowToSettings(row: RawSettingsRow): AiSettings {
|
||||
return {
|
||||
id: row.id,
|
||||
|
|
@ -136,6 +176,19 @@ function rowToSettings(row: RawSettingsRow): AiSettings {
|
|||
max_tokens: typeof row.max_tokens === "number" ? row.max_tokens : 4096,
|
||||
enabled: !!row.enabled,
|
||||
system_prompt: row.system_prompt ?? "",
|
||||
quote_assistant_enabled: !!row.quote_assistant_enabled,
|
||||
quote_assistant_interval_minutes:
|
||||
typeof row.quote_assistant_interval_minutes === "number" &&
|
||||
row.quote_assistant_interval_minutes >= 5
|
||||
? row.quote_assistant_interval_minutes
|
||||
: 10,
|
||||
quote_assistant_min_confidence:
|
||||
row.quote_assistant_min_confidence === "high" ? "high" : "medium",
|
||||
quote_assistant_prompt: row.quote_assistant_prompt ?? "",
|
||||
quote_assistant_last_run_at: row.quote_assistant_last_run_at ?? "",
|
||||
quote_assistant_last_run_summary: normaliseRunSummary(
|
||||
row.quote_assistant_last_run_summary,
|
||||
),
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
}
|
||||
|
|
@ -255,6 +308,29 @@ export async function updateAiSettings(
|
|||
// reject so a paste-from-clipboard typo doesn't 400 the whole form.
|
||||
dbPatch.system_prompt = patch.system_prompt.slice(0, 50_000);
|
||||
}
|
||||
if (patch.quote_assistant_enabled !== undefined) {
|
||||
dbPatch.quote_assistant_enabled = !!patch.quote_assistant_enabled;
|
||||
}
|
||||
if (patch.quote_assistant_interval_minutes !== undefined) {
|
||||
const n = patch.quote_assistant_interval_minutes;
|
||||
if (typeof n !== "number" || !Number.isFinite(n) || n < 5 || n > 1440) {
|
||||
throw new Error("quote_assistant_interval_minutes must be between 5 and 1440");
|
||||
}
|
||||
dbPatch.quote_assistant_interval_minutes = Math.round(n);
|
||||
}
|
||||
if (patch.quote_assistant_min_confidence !== undefined) {
|
||||
if (
|
||||
patch.quote_assistant_min_confidence !== "high" &&
|
||||
patch.quote_assistant_min_confidence !== "medium"
|
||||
) {
|
||||
throw new Error('quote_assistant_min_confidence must be "high" or "medium"');
|
||||
}
|
||||
dbPatch.quote_assistant_min_confidence = patch.quote_assistant_min_confidence;
|
||||
}
|
||||
if (patch.quote_assistant_prompt !== undefined) {
|
||||
// Same defensive cap as system_prompt (PB field max is 50 000).
|
||||
dbPatch.quote_assistant_prompt = patch.quote_assistant_prompt.slice(0, 50_000);
|
||||
}
|
||||
|
||||
dbPatch.updated_at = new Date().toISOString();
|
||||
|
||||
|
|
@ -282,6 +358,34 @@ export async function updateAiSettings(
|
|||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the outcome of a quote-assistant tick. Writes ONLY
|
||||
* `quote_assistant_last_run_at` / `quote_assistant_last_run_summary` —
|
||||
* deliberately not `updated_at`, so a background tick never looks like
|
||||
* an operator settings change. Refreshes the cache. No-op when there is
|
||||
* no settings row yet or nothing to write.
|
||||
*/
|
||||
export async function recordQuoteAssistantRun(fields: {
|
||||
last_run_at?: string;
|
||||
summary?: QuoteAssistantRunSummary;
|
||||
}): Promise<void> {
|
||||
const dbPatch: Record<string, unknown> = {};
|
||||
if (fields.last_run_at !== undefined) {
|
||||
dbPatch.quote_assistant_last_run_at = fields.last_run_at;
|
||||
}
|
||||
if (fields.summary !== undefined) {
|
||||
dbPatch.quote_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 })`
|
||||
|
|
|
|||
19
src/lib/server/quote-assistant-prompt.ts
Normal file
19
src/lib/server/quote-assistant-prompt.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Default system prompt for the AI quote assistant (matches quote lines to
|
||||
* POS stock items and drafts a price).
|
||||
*
|
||||
* The operator can override it at runtime via
|
||||
* `ai_settings.quote_assistant_prompt`; an empty override means "use this".
|
||||
* Keep it plain text (no markdown) — it is sent as the `system:` field
|
||||
* verbatim. The rules assume the `submit_line_matches` tool.
|
||||
*/
|
||||
export const DEFAULT_QUOTE_ASSISTANT_PROMPT = `You match quote lines from customers of a South African industrial supplier to items in our own POS catalogue, so the sales team gets a draft price. For each quote line you get a list of candidate POS items. Return your decision for every line through the submit_line_matches tool.
|
||||
|
||||
Rules:
|
||||
1. Pick a candidate ONLY if it is the same product: same type, size, thread, length, grade/class, material and finish where the line states them. A near-miss is NOT a match.
|
||||
2. If the closest candidate differs in anything the customer specified (e.g. line asks grade 12.9, candidate is grade 8), either return no match, or return it with confidence "low" and explain the difference in doubts.
|
||||
3. Never invent or estimate prices. Prices come only from the chosen POS item.
|
||||
4. Flag unit and pack-size differences in doubts (e.g. "POS item is sold per box of 100; customer asked for 50 each").
|
||||
5. confidence: "high" = clearly the same item; "medium" = very likely the same, minor detail unstated by the customer; "low" = uncertain.
|
||||
6. reason: one or two plain sentences a salesperson understands, saying why it is the same item. doubts: short plain sentences, empty list if none.
|
||||
7. Only use stock_item_id values from that line's own candidate list. Use null for no match.`;
|
||||
|
|
@ -504,6 +504,55 @@ export interface QuoteReply extends BaseRecord {
|
|||
};
|
||||
}
|
||||
|
||||
export type AiFillStatus = "filled" | "no_match" | "no_candidates" | "error";
|
||||
export type AiFillConfidence = "high" | "medium" | "low";
|
||||
|
||||
export interface AiFillCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
cost_price: number;
|
||||
selling_price: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the AI quote assistant recorded on a quote line. Lives in
|
||||
* `quote_items.ai_fill` (json). The POS SKU stays here only — it is
|
||||
* never copied onto the line's own `sku`.
|
||||
*/
|
||||
export interface QuoteItemAiFill {
|
||||
status: AiFillStatus;
|
||||
stock_item_id: string | null;
|
||||
stock_item_name: string;
|
||||
stock_item_sku: string;
|
||||
cost_price: number;
|
||||
selling_price: number;
|
||||
markup: number;
|
||||
supplier_id: string;
|
||||
confidence: AiFillConfidence | null;
|
||||
reason: string;
|
||||
doubts: string[];
|
||||
candidates: AiFillCandidate[];
|
||||
description_snapshot: string;
|
||||
model: string;
|
||||
attempted_at: string;
|
||||
price_from_default_markup?: boolean;
|
||||
reviewed_at?: string;
|
||||
reviewed_by?: string;
|
||||
accepted_unchanged?: boolean;
|
||||
supplier_changed?: boolean;
|
||||
}
|
||||
|
||||
export interface QuoteAssistantRunSummary {
|
||||
started_at: string;
|
||||
finished_at: string;
|
||||
quotes_seen: number;
|
||||
lines_filled: number;
|
||||
lines_unmatched: number;
|
||||
lines_skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface QuoteItem extends BaseRecord {
|
||||
quote: string;
|
||||
description: string;
|
||||
|
|
@ -585,6 +634,13 @@ export interface QuoteItem extends BaseRecord {
|
|||
* `components/ai-insights-renderer.tsx`.
|
||||
*/
|
||||
ai_item_insights?: unknown;
|
||||
/**
|
||||
* AI quote assistant's POS match / fill record for this line. Null or
|
||||
* absent = the assistant has not looked at it. A line is "pending
|
||||
* review" only when `status === "filled" && !reviewed_at &&
|
||||
* pricing_source === "ai_pos"`.
|
||||
*/
|
||||
ai_fill?: QuoteItemAiFill | null;
|
||||
/**
|
||||
* Sprint page category bucket. Relation to the `categories`
|
||||
* collection. NULL → lands in the UNKNOWN tab in the UI.
|
||||
|
|
|
|||
Loading…
Reference in a new issue