feat(orders): AI order assistant matches orders to quotes

Task 6 of the AI order assistant plan.

- matchOrder(orderId): load order + lines, isOrderEligible, shortlist
  from computeQuoteSuggestion (best + alternatives, first 5), empty
  shortlist writes ai_no_match with no AI call, otherwise ask the AI in
  batches of 30 lines against the same shortlist
- auto_link only through setOrderQuote inside
  runWithAuditActor({ name: "AI order assistant" }); a 409 falls back to
  a suggestion, never an error
- suggest writes potential_quote + potential_quote_confidence
  (0.9 / 0.7 / 0.5) + quote_match_method "ai_suggest"
- always writes orders.ai_quote_match (lines_hash, model, run_at); an
  AI failure is recorded with status "error" and never thrown out
- runOrderAssistantTick: same due/lease/stamp logic as the quote
  assistant, oldest eligible order first, max 10 per tick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 13:03:45 +02:00
parent 8eb64f2b2d
commit a04d58b781
2 changed files with 1146 additions and 0 deletions

View file

@ -0,0 +1,496 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
vi.mock("@/lib/server/ai-settings-service", () => ({
getActiveAiConfig: vi.fn(),
getAiSettings: vi.fn(),
recordOrderAssistantRun: vi.fn(),
clearAiSettingsCache: vi.fn(),
AiDisabledError: class AiDisabledError extends Error {
readonly code = "ai_disabled";
constructor(message: string) {
super(message);
this.name = "AiDisabledError";
}
},
}));
vi.mock("@/lib/server/quotes-service", () => ({
fetchQuoteItemsForQuotesAdmin: vi.fn(),
}));
import {
matchOrder,
runOrderAssistantTick,
resetAiQuoteMatchFieldCheck,
} from "@/lib/server/order-assistant-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getAiSettings, recordOrderAssistantRun } from "@/lib/server/ai-settings-service";
import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service";
const ORDERS = "orders";
const ORDER_ITEMS = "order_items";
const QUOTES = "quotes";
const SUPPLIERS = "suppliers";
interface CollectionMock {
getOne?: ReturnType<typeof vi.fn>;
getList?: ReturnType<typeof vi.fn>;
getFullList?: ReturnType<typeof vi.fn>;
update?: ReturnType<typeof vi.fn>;
}
const ORDER = {
id: "ord1",
order_number: "ORD-2026-10-001",
created: "2026-10-20T10:00:00.000Z",
company: "co1",
customer: "cust1",
quote: "",
ai_quote_match: null,
};
const ORDER_LINES = [
{
id: "ol1",
order: "ord1",
description: "M16 bolt 60mm",
po_description: "BOLT M16X60",
quantity: 100,
item_status: "pending",
selling_price: 350,
sku: "",
quote_item: "",
},
{
id: "ol2",
order: "ord1",
description: "M16 nut",
po_description: "",
quantity: 100,
item_status: "pending",
selling_price: 50,
sku: "",
quote_item: "",
},
];
const QUOTE_ITEMS = [
{
id: "ql1",
quote: "q1",
description: "M16 x 60 bolt",
quantity: 200,
selling_price: 340,
sku: "56020510",
supplier: "sup1",
},
{
id: "ql2",
quote: "q1",
description: "M16 nut",
quantity: 200,
selling_price: 45,
sku: "",
supplier: "sup1",
},
];
/**
* The makePb pattern from `order-quote-link-service.test.ts`: sensible
* defaults for every collection, merged per METHOD with the per-test
* overrides so a test only has to declare what it cares about.
*/
function makePb(overrides: Record<string, CollectionMock> = {}) {
const defaults: Record<string, CollectionMock> = {
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER }),
getList: vi.fn().mockResolvedValue({ items: [] }),
getFullList: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue(ORDER_LINES.map((l) => ({ ...l }))),
update: vi.fn().mockResolvedValue({}),
},
[QUOTES]: {
getFullList: vi.fn().mockResolvedValue([
{ id: "q1", quote_number: "Q-2026-10-014", company: "co1", created: "2026-10-01T00:00:00.000Z" },
]),
},
[SUPPLIERS]: {
getFullList: vi.fn().mockResolvedValue([{ id: "sup1", suppliername: "Afrisam" }]),
},
};
const merged: Record<string, CollectionMock> = {};
for (const name of new Set([...Object.keys(defaults), ...Object.keys(overrides)])) {
merged[name] = { ...(defaults[name] ?? {}), ...(overrides[name] ?? {}) };
}
return {
autoCancellation: vi.fn(),
collections: {
getOne: vi.fn().mockResolvedValue({
fields: [{ name: "ai_quote_match" }, { name: "quote" }],
}),
},
collection: (name: string) => {
const c = merged[name];
if (!c) throw new Error(`unexpected collection: ${name}`);
return c;
},
};
}
function mockAdminPb(pb: ReturnType<typeof makePb>) {
vi.mocked(getAdminPocketBase).mockResolvedValue(
pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>,
);
}
const SUGGESTION = {
orderId: "ord1",
best: {
quoteId: "q1",
quoteNumber: "Q-2026-10-014",
quoteCreated: "2026-10-01T00:00:00.000Z",
confidence: 0.8,
method: "company_customer_desc_price" as const,
},
alternatives: [],
method: "company_customer_desc_price" as const,
window: "initial" as const,
ageGapDays: 3,
};
function aiDecision(over: Record<string, unknown> = {}) {
return {
decision: {
quote_id: "q1",
confidence: "high" as const,
reason: "same items",
doubts: [],
lines: [
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure" as const, reason: "same" },
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure" as const, reason: "same" },
],
...over,
},
model: "test-model",
};
}
function baseSettings(over: Record<string, unknown> = {}) {
return {
enabled: true,
order_assistant_enabled: true,
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,
...over,
};
}
beforeEach(() => {
vi.clearAllMocks();
resetAiQuoteMatchFieldCheck();
vi.mocked(getAiSettings).mockResolvedValue(baseSettings() as never);
vi.mocked(recordOrderAssistantRun).mockResolvedValue(undefined);
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue(QUOTE_ITEMS as never);
});
describe("matchOrder", () => {
it("auto-links when the AI is sure and writes the pairing onto empty lines", async () => {
const pb = makePb();
mockAdminPb(pb);
const link = vi.fn().mockResolvedValue({ order: {}, previous: { quote: "" } });
const match = vi.fn().mockResolvedValue(aiDecision());
const suggest = vi.fn().mockResolvedValue(SUGGESTION);
const res = await matchOrder("ord1", { deps: { link, match, suggest } });
expect(res.outcome).toBe("linked");
expect(res.quoteId).toBe("q1");
expect(link).toHaveBeenCalledTimes(1);
expect(link.mock.calls[0][0]).toMatchObject({ orderId: "ord1", quoteId: "q1" });
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
expect(orderUpdate).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({ quote_match_method: "ai_auto" }),
);
// The AI pairing is written onto the lines.
const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
expect(itemUpdate).toHaveBeenCalledWith("ol1", { quote_item: "ql1" });
expect(itemUpdate).toHaveBeenCalledWith("ol2", { quote_item: "ql2" });
});
it("never overwrites a quote_item a person set", async () => {
const pb = makePb({
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([
{ ...ORDER_LINES[0], quote_item: "ql-person" },
{ ...ORDER_LINES[1] },
]),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const link = vi.fn().mockResolvedValue({ order: {}, previous: { quote: "" } });
await matchOrder("ord1", {
deps: {
link,
match: vi.fn().mockResolvedValue(aiDecision()),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
expect(itemUpdate).not.toHaveBeenCalledWith("ol1", expect.anything());
expect(itemUpdate).toHaveBeenCalledWith("ol2", { quote_item: "ql2" });
});
it("falls back to a suggestion when the link is refused with 409", async () => {
const pb = makePb();
mockAdminPb(pb);
const err = new Error("locked");
(err as Error & { status?: number }).status = 409;
const link = vi.fn().mockRejectedValue(err);
const res = await matchOrder("ord1", {
deps: {
link,
match: vi.fn().mockResolvedValue(aiDecision()),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
expect(res.outcome).toBe("suggested");
expect(res.quoteId).toBe("q1");
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
expect(orderUpdate).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({
potential_quote: "q1",
potential_quote_confidence: 0.9,
quote_match_method: "ai_suggest",
}),
);
});
it("suggests (and does not link) on medium confidence", async () => {
const pb = makePb();
mockAdminPb(pb);
const link = vi.fn();
const res = await matchOrder("ord1", {
deps: {
link,
match: vi.fn().mockResolvedValue(aiDecision({ confidence: "medium" })),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
expect(res.outcome).toBe("suggested");
expect(link).not.toHaveBeenCalled();
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
expect(orderUpdate).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({ potential_quote_confidence: 0.7, quote_match_method: "ai_suggest" }),
);
});
it("suggests (and does not link) when auto-link is switched off", async () => {
vi.mocked(getAiSettings).mockResolvedValue(
baseSettings({ order_assistant_auto_link: false }) as never,
);
const pb = makePb();
mockAdminPb(pb);
const link = vi.fn();
const res = await matchOrder("ord1", {
deps: {
link,
match: vi.fn().mockResolvedValue(aiDecision()),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
expect(res.outcome).toBe("suggested");
expect(link).not.toHaveBeenCalled();
});
it("does not call the AI when the shortlist is empty", async () => {
const pb = makePb();
mockAdminPb(pb);
const match = vi.fn();
const res = await matchOrder("ord1", {
deps: {
link: vi.fn(),
match,
suggest: vi.fn().mockResolvedValue({
...SUGGESTION,
best: null,
alternatives: [],
}),
},
});
expect(res.outcome).toBe("no_match");
expect(match).not.toHaveBeenCalled();
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
expect(orderUpdate).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({ quote_match_method: "ai_no_match" }),
);
});
it("records an AI failure on the order and does not throw", async () => {
const pb = makePb();
mockAdminPb(pb);
const res = await matchOrder("ord1", {
deps: {
link: vi.fn(),
match: vi.fn().mockRejectedValue(new Error("AI exploded")),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
expect(res.outcome).toBe("error");
expect(res.error).toBe("AI exploded");
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
const patch = orderUpdate.mock.calls[0][1] as { ai_quote_match: { status: string; error: string } };
expect(patch.ai_quote_match.status).toBe("error");
expect(patch.ai_quote_match.error).toBe("AI exploded");
});
it("skips a placeholder-only order and writes nothing", async () => {
const pb = makePb({
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([
{ ...ORDER_LINES[0], description: "See attached" },
]),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const match = vi.fn();
const res = await matchOrder("ord1", {
deps: { link: vi.fn(), match, suggest: vi.fn().mockResolvedValue(SUGGESTION) },
});
expect(res.outcome).toBe("skipped");
expect(match).not.toHaveBeenCalled();
expect(pb.collection(ORDERS).update).not.toHaveBeenCalled();
expect(pb.collection(ORDER_ITEMS).update).not.toHaveBeenCalled();
});
it("skips an order that already has a quote", async () => {
const pb = makePb({
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER, quote: "q-other" }),
getList: vi.fn().mockResolvedValue({ items: [] }),
getFullList: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const res = await matchOrder("ord1", {
deps: {
link: vi.fn(),
match: vi.fn(),
suggest: vi.fn(),
},
});
expect(res.outcome).toBe("skipped");
expect(pb.collection(ORDERS).update).not.toHaveBeenCalled();
});
it("reports that the migration has not run yet", async () => {
const pb = makePb();
pb.collections.getOne = vi.fn().mockResolvedValue({ fields: [{ name: "quote" }] });
mockAdminPb(pb);
const res = await matchOrder("ord1", { deps: {} });
expect(res.outcome).toBe("error");
expect(res.error).toContain("pb:migrate:order-assistant");
});
});
describe("runOrderAssistantTick", () => {
it("returns disabled when the order assistant is off", async () => {
vi.mocked(getAiSettings).mockResolvedValue(
baseSettings({ order_assistant_enabled: false }) as never,
);
const res = await runOrderAssistantTick();
expect(res).toEqual({ ran: false, reason: "disabled" });
});
it("returns disabled when the global AI switch is off", async () => {
vi.mocked(getAiSettings).mockResolvedValue(baseSettings({ enabled: false }) as never);
const res = await runOrderAssistantTick();
expect(res).toEqual({ ran: false, reason: "disabled" });
});
it("returns not_due when the interval has not elapsed", async () => {
vi.mocked(getAiSettings).mockResolvedValue(
baseSettings({ order_assistant_last_run_at: new Date().toISOString() }) as never,
);
const res = await runOrderAssistantTick();
expect(res).toEqual({ ran: false, reason: "not_due" });
});
it("stops after 10 orders", async () => {
const pb = makePb({
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER }),
// Deliberately ignores perPage — the tick's own cap must stop it.
getList: vi.fn().mockResolvedValue({
items: Array.from({ length: 25 }, (_, i) => ({ ...ORDER, id: `ord${i}` })),
}),
getFullList: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const res = await runOrderAssistantTick({
suggest: vi.fn().mockResolvedValue(SUGGESTION),
match: vi.fn().mockResolvedValue(aiDecision()),
link: vi.fn(),
});
expect(res.ran).toBe(true);
expect(res.summary?.orders_seen).toBe(10);
});
it("writes a summary with the run stamp", async () => {
const pb = makePb();
mockAdminPb(pb);
const res = await runOrderAssistantTick({
suggest: vi.fn().mockResolvedValue(SUGGESTION),
match: vi.fn().mockResolvedValue(aiDecision()),
link: vi.fn(),
});
expect(res.ran).toBe(true);
expect(recordOrderAssistantRun).toHaveBeenCalledWith({ last_run_at: expect.any(String) });
expect(recordOrderAssistantRun).toHaveBeenCalledWith({
summary: expect.objectContaining({ orders_seen: expect.any(Number) }),
});
});
});

View file

@ -0,0 +1,650 @@
/**
* order-assistant-service — matches an order to the quote it came from.
*
* The AI decides; this module decides what to DO with the decision.
*
* Rules (from the design spec):
* - `orders.quote` is only ever written through `setOrderQuote`, inside
* `runWithAuditActor({ name: "AI order assistant" }, …)`, so the audit
* trail shows the AI did it.
* - The AI may hard-link an order only when it is sure: high
* confidence, every real order line paired "sure", every pair on a
* line of the one chosen quote, and the quote belonging to the
* order's company. Anything else is a soft suggestion for a human.
* - `setOrderQuote` refusing (409) is NOT an error — it falls back to
* a suggestion.
* - The AI never changes a line's status, never creates a purchase
* order and never sends anything.
* - An AI failure is recorded on `orders.ai_quote_match` with status
* "error" and is never thrown out of `matchOrder`.
*/
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service";
import { setOrderQuote } from "@/lib/server/orders-service";
import {
computeQuoteSuggestion,
type QuoteMatchCandidate,
type SuggestQuoteResult,
} from "@/lib/server/order-quote-link-service";
import { matchOrderWithAi, type MatchInput } from "@/lib/server/order-assistant-ai";
import { runWithAuditActor } from "@/lib/server/audit-context";
import {
AiDisabledError,
clearAiSettingsCache,
getAiSettings,
recordOrderAssistantRun,
} from "@/lib/server/ai-settings-service";
import {
confidenceToScore,
decideOutcome,
isOrderEligible,
isRealOrderLine,
orderLinesHash,
type AiOrderMatchDecision,
} from "@/lib/server/order-assistant-core";
import { escapeForPbString } from "@/lib/server/price-list-match";
import type {
Order,
OrderAiLinePair,
OrderAiQuoteMatch,
OrderAssistantRunSummary,
OrderItem,
} from "@/lib/types";
/** Orders the AI may look at per tick. */
export const TICK_MAX_ORDERS = 10;
/** Stop starting new orders after this long; leftovers go to the next run. */
export const TICK_TIME_BUDGET_MS = 90_000;
/** A started run with no newer summary is treated as in progress this long. */
export const TICK_LEASE_MS = 10 * 60_000;
/** Candidate quotes handed to the AI per order. */
const SHORTLIST_MAX = 5;
/** Order lines per AI call — keeps one call well inside the model's limit. */
const AI_BATCH_LINES = 30;
/** Only orders created this recently are picked up by the tick. */
const TICK_ORDER_AGE_DAYS = 30;
const ACTOR_NAME = "AI order assistant";
const MAX_SUMMARY_ERRORS = 20;
const MAX_SUMMARY_ERROR_CHARS = 300;
/** `MatchOrderResult.error` when the global AI switch is off (409). */
export const AI_DISABLED_ERROR = "AI is disabled in settings";
/** `MatchOrderResult.error` when the live PB has no `orders.ai_quote_match` yet (409). */
export const NOT_SET_UP_ERROR =
"Order assistant is not set up yet: run `npm run pb:migrate:order-assistant` against PocketBase first";
export interface MatchOrderResult {
orderId: string;
outcome: "linked" | "suggested" | "no_match" | "skipped" | "error";
quoteId: string | null;
error: string;
}
export interface OrderAssistantDeps {
match?: typeof matchOrderWithAi;
suggest?: typeof computeQuoteSuggestion;
link?: typeof setOrderQuote;
now?: () => Date;
}
type AdminPb = Awaited<ReturnType<typeof getAdminPocketBase>>;
let aiQuoteMatchFieldConfirmed = false;
/**
* PocketBase silently drops unknown fields on update. Without the
* `orders.ai_quote_match` field (added by the migration) the AI's
* verdict would land nowhere, so refuse to write until it exists.
* A positive answer is cached for the life of the process.
*/
async function hasAiQuoteMatchField(pb: AdminPb): Promise<boolean> {
if (aiQuoteMatchFieldConfirmed) return true;
const collection = (await pb.collections.getOne("orders")) as {
fields?: Array<{ name: string }>;
schema?: Array<{ name: string }>;
};
const fields = collection.fields ?? collection.schema ?? [];
aiQuoteMatchFieldConfirmed = fields.some((f) => f.name === "ai_quote_match");
return aiQuoteMatchFieldConfirmed;
}
/** Test hook: forget the cached schema check. */
export function resetAiQuoteMatchFieldCheck(): void {
aiQuoteMatchFieldConfirmed = false;
}
function errMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function boundRunErrors(errors: string[]): string[] {
return errors
.slice(0, MAX_SUMMARY_ERRORS)
.map((e) => e.slice(0, MAX_SUMMARY_ERROR_CHARS));
}
interface OrderLite {
id: string;
order_number: string;
created: string;
company: string;
customer: string;
customer_name: string;
quote: string;
ai_quote_match?: OrderAiQuoteMatch | null;
}
interface QuoteLite {
id: string;
quote_number: string;
company: string;
created: string;
}
async function loadOrder(pb: AdminPb, orderId: string): Promise<OrderLite> {
const order = await pb.collection("orders").getOne<OrderLite>(orderId, {
expand: "customer",
});
return {
id: order.id,
order_number: order.order_number ?? "",
created: order.created ?? "",
company: order.company ?? "",
customer: order.customer ?? "",
customer_name: (order as unknown as { expand?: { customer?: { customer?: string } } }).expand
?.customer?.customer ?? "",
quote: order.quote ?? "",
ai_quote_match: order.ai_quote_match ?? null,
};
}
async function loadOrderLines(pb: AdminPb, orderId: string): Promise<OrderItem[]> {
return pb.collection("order_items").getFullList<OrderItem>({
filter: `order = "${escapeForPbString(orderId)}"`,
sort: "created",
batch: 500,
});
}
async function loadShortlistQuotes(
pb: AdminPb,
quoteIds: string[],
): Promise<Map<string, QuoteLite>> {
const out = new Map<string, QuoteLite>();
if (quoteIds.length === 0) return out;
const rows = await pb.collection("quotes").getFullList<QuoteLite>({
filter: `id IN (${quoteIds.map((id) => `"${escapeForPbString(id)}"`).join(",")})`,
fields: "id,quote_number,company,created",
batch: 200,
});
for (const row of rows) out.set(row.id, row);
return out;
}
async function loadSupplierNames(
pb: AdminPb,
supplierIds: string[],
): Promise<Map<string, string>> {
const out = new Map<string, string>();
const ids = [...new Set(supplierIds.filter(Boolean))];
if (ids.length === 0) return out;
const rows = await pb.collection("suppliers").getFullList<{ id: string; suppliername: string }>({
filter: `id IN (${ids.map((id) => `"${escapeForPbString(id)}"`).join(",")})`,
fields: "id,suppliername",
batch: 200,
});
for (const row of rows) out.set(row.id, row.suppliername ?? "");
return out;
}
/** The deterministic shortlist: the best candidate plus its alternatives. */
function buildShortlist(suggestion: SuggestQuoteResult): QuoteMatchCandidate[] {
const out: QuoteMatchCandidate[] = [];
if (suggestion.best) out.push(suggestion.best);
for (const alt of suggestion.alternatives) out.push(alt);
return out.slice(0, SHORTLIST_MAX);
}
/** The lowest confidence across the batches wins. */
function lowestConfidence(a: "high" | "medium" | "low", b: "high" | "medium" | "low") {
const rank: Record<string, number> = { high: 3, medium: 2, low: 1 };
return rank[a] <= rank[b] ? a : b;
}
function batch<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
/**
* Match one order to a quote.
*
* Returns what happened; never throws for an AI failure — that is
* recorded on the order instead.
*/
export async function matchOrder(
orderId: string,
opts: { force?: boolean; deps?: OrderAssistantDeps } = {},
): Promise<MatchOrderResult> {
const deps = opts.deps ?? {};
const nowFn = deps.now ?? (() => new Date());
const matchFn = deps.match ?? matchOrderWithAi;
const suggestFn = deps.suggest ?? computeQuoteSuggestion;
const linkFn = deps.link ?? setOrderQuote;
const result: MatchOrderResult = { orderId, outcome: "skipped", quoteId: null, error: "" };
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
if (!(await hasAiQuoteMatchField(pb))) {
result.outcome = "error";
result.error = NOT_SET_UP_ERROR;
return result;
}
const order = await loadOrder(pb, orderId);
const lines = await loadOrderLines(pb, orderId);
const now = nowFn();
const settings = await getAiSettings();
// Nothing to decide — a linked order, an order with no real lines, an
// order already looked at on these same lines, or one in an AI-error
// cooldown. Write nothing at all.
if (!isOrderEligible(order, lines, { now, force: opts.force })) {
return result;
}
const realLines = lines.filter((l) => isRealOrderLine(l));
const realLineIds = realLines.map((l) => l.id);
const linesHash = orderLinesHash(realLines);
// 1. The deterministic shortlist decides WHICH quotes the AI may choose
// from. No shortlist, no AI call — the order simply has no candidate.
let suggestion: SuggestQuoteResult;
try {
suggestion = await suggestFn(orderId);
} catch (e) {
result.outcome = "error";
result.error = `quote shortlist failed: ${errMessage(e)}`;
return result;
}
const shortlist = buildShortlist(suggestion);
if (shortlist.length === 0) {
await pb.collection("orders").update(orderId, {
quote_match_method: "ai_no_match",
potential_quote: "",
potential_quote_confidence: 0,
ai_quote_match: {
status: "no_match",
quote_id: null,
quote_number: "",
confidence: null,
reason: "No candidate quote shares a line with this order.",
doubts: [],
lines: [],
auto_linked: false,
lines_hash: linesHash,
model: "",
run_at: now.toISOString(),
} satisfies OrderAiQuoteMatch,
});
result.outcome = "no_match";
return result;
}
// 2. Load the candidate quotes and their lines for the AI.
const quoteIds = shortlist.map((c) => c.quoteId);
const quoteRows = await loadShortlistQuotes(pb, quoteIds);
const quoteItems = await fetchQuoteItemsForQuotesAdmin(
pb,
quoteIds,
"id,quote,description,sku,quantity,selling_price,supplier",
);
const supplierNames = await loadSupplierNames(
pb,
quoteItems.map((q) => q.supplier ?? ""),
);
const quotesForAi: MatchInput["quotes"] = quoteIds
.map((id) => {
const row = quoteRows.get(id);
return {
id,
quote_number: row?.quote_number ?? "",
created: row?.created ?? "",
lines: quoteItems
.filter((q) => q.quote === id)
.map((q) => ({
id: q.id,
description: q.description ?? "",
quantity: Number(q.quantity) || 0,
selling_price: Number(q.selling_price) || 0,
sku: q.sku ?? "",
supplier_name:
supplierNames.get(q.supplier ?? "") ?? "",
})),
};
})
.filter((q) => q.lines.length > 0);
// 3. Ask the AI. One call per 30 order lines; the batches share the
// same shortlist and their answers are merged.
let model = "";
let decision: AiOrderMatchDecision;
try {
const prompt = (settings.order_assistant_prompt || "").trim() || undefined;
const mergedLines: AiOrderMatchDecision["lines"] = [];
const chosenQuotes = new Set<string>();
let overallConfidence: "high" | "medium" | "low" = "high";
let reason = "";
const doubts: string[] = [];
for (const chunk of batch(realLines, AI_BATCH_LINES)) {
const input: MatchInput = {
order: {
id: order.id,
order_number: order.order_number,
customer_name: order.customer_name,
lines: chunk.map((l) => ({
id: l.id,
description: l.description ?? "",
po_description: l.po_description ?? "",
quantity: Number(l.quantity) || 0,
sku: l.sku ?? "",
selling_price: Number(l.selling_price) || 0,
})),
},
quotes: quotesForAi,
};
const out = await matchFn(input, prompt ? { prompt } : {});
model = out.model || model;
mergedLines.push(...out.decision.lines);
overallConfidence = lowestConfidence(overallConfidence, out.decision.confidence);
if (out.decision.quote_id) chosenQuotes.add(out.decision.quote_id);
if (!reason && out.decision.reason) reason = out.decision.reason;
for (const d of out.decision.doubts) {
if (!doubts.includes(d)) doubts.push(d);
}
}
if (mergedLines.length === 0) {
result.outcome = "error";
result.error = "no order lines to match";
return result;
}
decision = {
// The batches must agree on the quote. If they don't, we cannot
// claim to know which quote the order came from.
quote_id: chosenQuotes.size === 1 ? [...chosenQuotes][0] : null,
confidence: overallConfidence,
reason,
doubts,
lines: mergedLines,
};
} catch (e) {
if (e instanceof AiDisabledError) {
// AI switched off: write nothing so the order isn't poisoned.
result.outcome = "error";
result.error = AI_DISABLED_ERROR;
return result;
}
const message = errMessage(e);
await pb.collection("orders").update(orderId, {
ai_quote_match: {
status: "error",
quote_id: null,
quote_number: "",
confidence: null,
reason: "",
doubts: [],
lines: [],
auto_linked: false,
lines_hash: linesHash,
model,
run_at: now.toISOString(),
error: message,
} satisfies OrderAiQuoteMatch,
});
result.outcome = "error";
result.error = message;
return result;
}
// 4. Decide what to do with the answer.
const quoteLineToQuote = new Map<string, string>();
for (const item of quoteItems) quoteLineToQuote.set(item.id, item.quote);
const chosenQuoteId = decision.quote_id;
const chosenQuote = chosenQuoteId ? quoteRows.get(chosenQuoteId) : undefined;
const outcome = decideOutcome({
decision,
realLineIds,
quoteLineToQuote,
orderCompany: order.company,
quoteCompany: chosenQuote?.company ?? null,
autoLinkEnabled: settings.order_assistant_auto_link,
});
const aiLines: OrderAiLinePair[] = decision.lines
.filter((l) => realLineIds.includes(l.order_line_id))
.map((l) => ({
order_line_id: l.order_line_id,
quote_line_id: l.quote_line_id,
quote_id: l.quote_line_id ? quoteLineToQuote.get(l.quote_line_id) ?? null : null,
same_item: l.same_item,
reason: l.reason,
}));
const baseMatch: OrderAiQuoteMatch = {
status: outcome === "auto_link" ? "matched" : outcome === "suggest" ? "suggested" : "no_match",
quote_id: chosenQuoteId,
quote_number: chosenQuote?.quote_number ?? "",
confidence: decision.confidence,
reason: decision.reason,
doubts: decision.doubts,
lines: aiLines,
auto_linked: false,
lines_hash: linesHash,
model,
run_at: now.toISOString(),
};
if (outcome === "auto_link" && chosenQuoteId) {
try {
await runWithAuditActor({ name: ACTOR_NAME, source: "agent", ignoreRequestUser: true }, () =>
linkFn({ orderId, quoteId: chosenQuoteId }),
);
} catch (e) {
const status = (e as Error & { status?: number }).status;
if (status === 409) {
// The link was refused (lines moved on, quote taken). That is
// NOT an error — fall back to a suggestion.
await writeSuggestion(pb, orderId, decision, baseMatch, chosenQuoteId);
result.outcome = "suggested";
result.quoteId = chosenQuoteId;
return result;
}
result.outcome = "error";
result.error = `link failed: ${errMessage(e)}`;
return result;
}
// The link exists. Now write the AI's own pairing onto the lines
// that have no pairing yet — a person's pairing always wins.
await writeAiPairs(pb, orderId, aiLines);
await pb.collection("orders").update(orderId, {
quote_match_method: "ai_auto",
potential_quote: "",
potential_quote_confidence: 0,
ai_quote_match: { ...baseMatch, auto_linked: true } satisfies OrderAiQuoteMatch,
});
result.outcome = "linked";
result.quoteId = chosenQuoteId;
return result;
}
if (outcome === "suggest" && chosenQuoteId) {
await writeSuggestion(pb, orderId, decision, baseMatch, chosenQuoteId);
result.outcome = "suggested";
result.quoteId = chosenQuoteId;
return result;
}
// No plausible quote.
await pb.collection("orders").update(orderId, {
quote_match_method: "ai_no_match",
potential_quote: "",
potential_quote_confidence: 0,
ai_quote_match: baseMatch,
});
result.outcome = "no_match";
return result;
}
/** The auto-link switch — read once per run. */
async function writeSuggestion(
pb: AdminPb,
orderId: string,
decision: AiOrderMatchDecision,
baseMatch: OrderAiQuoteMatch,
quoteId: string,
): Promise<void> {
await pb.collection("orders").update(orderId, {
potential_quote: quoteId,
potential_quote_confidence: confidenceToScore(decision.confidence),
quote_match_method: "ai_suggest",
ai_quote_match: { ...baseMatch, status: "suggested" } satisfies OrderAiQuoteMatch,
});
}
/**
* Write the AI's pairing onto the order lines that have no pairing yet.
* A `quote_item` a person set is never overwritten.
*/
async function writeAiPairs(
pb: AdminPb,
orderId: string,
aiLines: OrderAiLinePair[],
): Promise<void> {
if (aiLines.length === 0) return;
const current = await pb.collection("order_items").getFullList<{
id: string;
quote_item: string;
}>({
filter: `order = "${escapeForPbString(orderId)}"`,
fields: "id,quote_item",
batch: 500,
});
const byId = new Map(current.map((c) => [c.id, c.quote_item ?? ""]));
for (const pair of aiLines) {
if (!pair.quote_line_id) continue;
if (byId.get(pair.order_line_id)) continue;
try {
await pb.collection("order_items").update(pair.order_line_id, {
quote_item: pair.quote_line_id,
});
byId.set(pair.order_line_id, pair.quote_line_id);
} catch (e) {
console.error(
`[order-assistant] could not write the AI pairing onto line ${pair.order_line_id}:`,
errMessage(e),
);
}
}
}
/**
* Scheduled entry point. Does nothing unless the AI and the order
* assistant are both on and the configured interval has elapsed. Picks
* the oldest eligible orders (no quote yet, created in the last 30
* days) and matches them; one order failing never stops the others.
*/
export async function runOrderAssistantTick(
deps: OrderAssistantDeps = {},
): Promise<{
ran: boolean;
reason?: "disabled" | "not_due" | "running";
summary?: OrderAssistantRunSummary;
}> {
const nowFn = deps.now ?? (() => new Date());
clearAiSettingsCache();
const settings = await getAiSettings();
if (!settings.enabled || !settings.order_assistant_enabled) {
return { ran: false, reason: "disabled" };
}
const now = nowFn();
const last = Date.parse(settings.order_assistant_last_run_at || "");
const intervalMs = settings.order_assistant_interval_minutes * 60_000;
if (!Number.isNaN(last) && now.getTime() - last < intervalMs) {
return { ran: false, reason: "not_due" };
}
// In-progress lease: a run stamps last_run_at when it starts and
// writes its summary when it ends. A start newer than the last
// summary means a run is still going; don't overlap it.
const lastFinished = Date.parse(
settings.order_assistant_last_run_summary?.finished_at || "",
);
const unfinished = Number.isNaN(lastFinished) || lastFinished < last;
if (!Number.isNaN(last) && unfinished && now.getTime() - last < TICK_LEASE_MS) {
return { ran: false, reason: "running" };
}
// Overlap guard: stamp the run start before doing any work.
const startedAt = now.toISOString();
await recordOrderAssistantRun({ last_run_at: startedAt });
const summary: OrderAssistantRunSummary = {
started_at: startedAt,
finished_at: startedAt,
orders_seen: 0,
linked: 0,
suggested: 0,
no_match: 0,
lines_filled: 0,
errors: [],
};
try {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const cutoff = new Date(now.getTime() - TICK_ORDER_AGE_DAYS * 24 * 60 * 60 * 1000);
const page = await pb.collection("orders").getList<OrderLite>(1, TICK_MAX_ORDERS, {
filter: `quote = "" && created >= "${cutoff.toISOString()}"`,
sort: "created",
});
const startMs = nowFn().getTime();
for (const row of page.items) {
if (summary.orders_seen >= TICK_MAX_ORDERS) break;
if (nowFn().getTime() - startMs >= TICK_TIME_BUDGET_MS) break;
summary.orders_seen += 1;
try {
const r = await matchOrder(row.id, { deps });
if (r.outcome === "linked") summary.linked += 1;
else if (r.outcome === "suggested") summary.suggested += 1;
else if (r.outcome === "no_match") summary.no_match += 1;
else if (r.outcome === "error") summary.errors.push(`${row.id}: ${r.error}`);
} catch (e) {
summary.errors.push(`${row.id}: ${errMessage(e)}`);
}
}
} catch (e) {
summary.errors.push(`tick failed: ${errMessage(e)}`);
}
summary.errors = boundRunErrors(summary.errors);
summary.finished_at = nowFn().toISOString();
await recordOrderAssistantRun({ summary });
return { ran: true, summary };
}