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:
parent
8eb64f2b2d
commit
a04d58b781
2 changed files with 1146 additions and 0 deletions
496
src/lib/server/order-assistant-service.test.ts
Normal file
496
src/lib/server/order-assistant-service.test.ts
Normal 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) }),
|
||||
});
|
||||
});
|
||||
});
|
||||
650
src/lib/server/order-assistant-service.ts
Normal file
650
src/lib/server/order-assistant-service.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue