fix(orders): order assistant works against real PocketBase

- id lists are OR-chains: PB filters have no IN operator, so the
  shortlist and supplier lookups would 400 on every real run
- AI line pairs are written before the auto-link (and undone if it is
  refused), so setOrderQuote's word pairing can't replace them
- the tick looks past orders it skips; otherwise the same oldest
  already-handled orders filled every run and newer ones were never reached

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 13:29:24 +02:00
parent 6fe09f7752
commit 379c978cc5
2 changed files with 140 additions and 18 deletions

View file

@ -231,6 +231,66 @@ describe("matchOrder", () => {
expect(itemUpdate).toHaveBeenCalledWith("ol2", { quote_item: "ql2" });
});
it("never uses an IN filter (PocketBase has no IN operator)", async () => {
const pb = makePb();
mockAdminPb(pb);
await matchOrder("ord1", {
deps: {
link: vi.fn().mockResolvedValue({ order: {}, previous: { quote: "" } }),
match: vi.fn().mockResolvedValue(aiDecision()),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
const filters = [QUOTES, SUPPLIERS, ORDER_ITEMS].flatMap((name) =>
(pb.collection(name).getFullList as ReturnType<typeof vi.fn>).mock.calls.map(
(c) => (c[0] as { filter?: string })?.filter ?? "",
),
);
expect(filters.length).toBeGreaterThan(0);
for (const f of filters) expect(f).not.toMatch(/\bIN\s*\(/);
expect(filters).toContain('id = "q1"');
});
it("writes the AI pairs before linking, so word pairing can't replace them", async () => {
const pb = makePb();
mockAdminPb(pb);
const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
let pairsAtLinkTime = 0;
const link = vi.fn().mockImplementation(async () => {
pairsAtLinkTime = itemUpdate.mock.calls.length;
return { order: {}, previous: { quote: "" } };
});
await matchOrder("ord1", {
deps: {
link,
match: vi.fn().mockResolvedValue(aiDecision()),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
expect(pairsAtLinkTime).toBe(2);
});
it("undoes the AI pairs when the link is refused", async () => {
const pb = makePb();
mockAdminPb(pb);
const err = new Error("locked");
(err as Error & { status?: number }).status = 409;
await matchOrder("ord1", {
deps: {
link: vi.fn().mockRejectedValue(err),
match: vi.fn().mockResolvedValue(aiDecision()),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
expect(itemUpdate).toHaveBeenCalledWith("ol1", { quote_item: "" });
expect(itemUpdate).toHaveBeenCalledWith("ol2", { quote_item: "" });
});
it("never overwrites a quote_item a person set", async () => {
const pb = makePb({
[ORDER_ITEMS]: {
@ -495,6 +555,33 @@ describe("runOrderAssistantTick", () => {
expect(res.summary?.orders_seen).toBe(10);
});
it("looks past orders it skips, so already-handled orders don't block newer ones", async () => {
// 15 oldest orders are already linked (skipped); the next 3 are new.
const rows = Array.from({ length: 18 }, (_, i) => ({ id: `ord${i}` }));
const pb = makePb({
[ORDERS]: {
getOne: vi.fn().mockImplementation((id: string) =>
Promise.resolve({ ...ORDER, id, quote: Number(id.slice(3)) < 15 ? "qX" : "" }),
),
getList: vi.fn().mockResolvedValue({ items: rows }),
getFullList: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const match = vi.fn().mockResolvedValue(aiDecision({ confidence: "medium" }));
const res = await runOrderAssistantTick({
suggest: vi.fn().mockResolvedValue(SUGGESTION),
match,
link: vi.fn(),
});
expect(res.summary?.orders_seen).toBe(3);
expect(res.summary?.suggested).toBe(3);
expect(match).toHaveBeenCalledTimes(3);
});
it("writes a summary with the run stamp", async () => {
const pb = makePb();
mockAdminPb(pb);

View file

@ -54,6 +54,8 @@ import type {
/** Orders the AI may look at per tick. */
export const TICK_MAX_ORDERS = 10;
/** How many unlinked orders one run looks through to find TICK_MAX_ORDERS to work on. */
export const TICK_CANDIDATE_CAP = 200;
/** 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. */
@ -168,6 +170,13 @@ async function loadOrderLines(pb: AdminPb, orderId: string): Promise<OrderItem[]
});
}
/** PocketBase filters have no `IN` operator: an id list is an OR-chain,
* chunked so it stays under PB's filter-length limit. */
const ID_CHUNK = 50;
function idFilter(ids: string[]): string {
return ids.map((id) => `id = "${escapeForPbString(id)}"`).join(" || ");
}
async function loadShortlistQuotes(
pb: AdminPb,
quoteIds: string[],
@ -175,7 +184,7 @@ async function loadShortlistQuotes(
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(",")})`,
filter: idFilter(quoteIds),
fields: "id,quote_number,company,created",
batch: 200,
});
@ -190,12 +199,16 @@ async function loadSupplierNames(
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 ?? "");
for (let i = 0; i < ids.length; i += ID_CHUNK) {
const rows = await pb
.collection("suppliers")
.getFullList<{ id: string; suppliername: string }>({
filter: idFilter(ids.slice(i, i + ID_CHUNK)),
fields: "id,suppliername",
batch: 200,
});
for (const row of rows) out.set(row.id, row.suppliername ?? "");
}
return out;
}
@ -458,11 +471,15 @@ export async function matchOrder(
};
if (outcome === "auto_link" && chosenQuoteId) {
const actor = { name: ACTOR_NAME, source: "agent" as const, ignoreRequestUser: true };
// The AI's pairs go on BEFORE the link: `setOrderQuote` then runs the
// word-based pairing, which never overwrites a set `quote_item`, so the
// AI's pairing wins on every line it paired. Undone if the link fails.
const written = await runWithAuditActor(actor, () => writeAiPairs(pb, orderId, aiLines));
try {
await runWithAuditActor({ name: ACTOR_NAME, source: "agent", ignoreRequestUser: true }, () =>
linkFn({ orderId, quoteId: chosenQuoteId }),
);
await runWithAuditActor(actor, () => linkFn({ orderId, quoteId: chosenQuoteId }));
} catch (e) {
await runWithAuditActor(actor, () => clearAiPairs(pb, written));
const status = (e as Error & { status?: number }).status;
if (status === 409) {
// The link was refused (lines moved on, quote taken). That is
@ -477,10 +494,6 @@ export async function matchOrder(
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_matching_attempted: true,
quote_match_method: "ai_auto",
@ -540,8 +553,9 @@ async function writeAiPairs(
pb: AdminPb,
orderId: string,
aiLines: OrderAiLinePair[],
): Promise<void> {
if (aiLines.length === 0) return;
): Promise<string[]> {
const written: string[] = [];
if (aiLines.length === 0) return written;
const current = await pb.collection("order_items").getFullList<{
id: string;
quote_item: string;
@ -559,6 +573,7 @@ async function writeAiPairs(
quote_item: pair.quote_line_id,
});
byId.set(pair.order_line_id, pair.quote_line_id);
written.push(pair.order_line_id);
} catch (e) {
console.error(
`[order-assistant] could not write the AI pairing onto line ${pair.order_line_id}:`,
@ -566,6 +581,18 @@ async function writeAiPairs(
);
}
}
return written;
}
/** Undo `writeAiPairs` when the link it prepared for is refused. */
async function clearAiPairs(pb: AdminPb, lineIds: string[]): Promise<void> {
for (const id of lineIds) {
try {
await pb.collection("order_items").update(id, { quote_item: "" });
} catch (e) {
console.error(`[order-assistant] could not undo the AI pairing on line ${id}:`, errMessage(e));
}
}
}
/**
@ -626,23 +653,31 @@ export async function runOrderAssistantTick(
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, {
// Scan past orders `matchOrder` skips (already matched with the same
// lines, nothing pending): only orders it actually works on count
// towards TICK_MAX_ORDERS, or the same oldest orders would fill every
// run and newer orders would never be reached.
const page = await pb.collection("orders").getList<OrderLite>(1, TICK_CANDIDATE_CAP, {
filter: `quote = "" && created >= "${cutoff.toISOString()}"`,
sort: "created",
// `matchOrder` re-reads what it needs; orders also carry email_body.
fields: "id",
});
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 === "skipped") continue;
summary.orders_seen += 1;
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.orders_seen += 1;
summary.errors.push(`${row.id}: ${errMessage(e)}`);
}
}