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:
parent
6fe09f7752
commit
379c978cc5
2 changed files with 140 additions and 18 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue