diff --git a/src/lib/server/order-assistant-service.test.ts b/src/lib/server/order-assistant-service.test.ts index c9a1a70..a756db9 100644 --- a/src/lib/server/order-assistant-service.test.ts +++ b/src/lib/server/order-assistant-service.test.ts @@ -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).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; + 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; + 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); diff --git a/src/lib/server/order-assistant-service.ts b/src/lib/server/order-assistant-service.ts index 10c0609..dd127cf 100644 --- a/src/lib/server/order-assistant-service.ts +++ b/src/lib/server/order-assistant-service.ts @@ -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 `id = "${escapeForPbString(id)}"`).join(" || "); +} + async function loadShortlistQuotes( pb: AdminPb, quoteIds: string[], @@ -175,7 +184,7 @@ async function loadShortlistQuotes( const out = new Map(); if (quoteIds.length === 0) return out; const rows = await pb.collection("quotes").getFullList({ - 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(); 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 { - if (aiLines.length === 0) return; +): Promise { + 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 { + 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(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(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)}`); } }