feat(orders): AI fills order lines from the matched quote

The assistant now prices the order's lines from the quote they are
paired with, as purple drafts a person then reviews.

- order-assistant-core.ts: isOrderLineWritable + buildOrderLineFillPatch.
  Only a pending line with no cost of its own, no person-set pricing
  source and no reviewed fill may be written. Supplier / SKU /
  matched_price_list_id only when the line has none; selling_price only
  when the line has none; markup + markup_percent written together.
  pricing_source becomes "ai_quote". A customer price that differs
  from the quoted price by more than 5% becomes a doubt.
- order-assistant-lines.ts (new): the shared, cycle-free helpers —
  idFilter, aiQuotePairs, writeAiPairs, clearAiPairs and
  fillOrderFromQuote. It lives in its own module so orders-service.ts
  can use it without an import cycle, since order-assistant-service.ts
  already imports setOrderQuote.
- The fill re-reads every line right before writing it, so a cost a
  person typed in the meantime wins. It stays quiet until the migration
  has added order_items.ai_fill — PB rejects a projection that names a
  column the collection does not have.
- setOrderQuote writes the AI's line pairs before pairOrderLines runs,
  so the AI's pairing wins over word matching, then fills the lines.
  A failure there is logged and never fails the link.
- The tick gains a second pass that fills the lines of orders already
  linked to a quote. Only orders the fill actually wrote to count
  towards the budget, so a skipped order cannot block the run.
- The run route fills after the match and reports the counts.

PocketBase filters have no IN operator, so every id list is an
OR-chain. fetchQuoteItemsForQuotesAdmin no longer tries an IN filter
first — that was a guaranteed 400 on every call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 14:10:40 +02:00
parent 379c978cc5
commit 89e52bbd9d
12 changed files with 1459 additions and 91 deletions

View file

@ -2,12 +2,19 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const matchOrder = vi.fn();
const fillOrderFromQuote = vi.fn();
vi.mock("@/lib/server/order-assistant-service", () => ({
matchOrder: (...a: unknown[]) => matchOrder(...a),
fillOrderFromQuote: (...a: unknown[]) => fillOrderFromQuote(...a),
AI_DISABLED_ERROR: "AI is disabled in settings",
NOT_SET_UP_ERROR: "Order assistant is not set up yet",
}));
const getAiSettings = vi.fn();
vi.mock("@/lib/server/ai-settings-service", () => ({
getAiSettings: (...a: unknown[]) => getAiSettings(...a),
}));
const getBrowserSessionUser = vi.fn();
vi.mock("@/lib/server/browser-session", () => ({
getBrowserSessionUser: (...a: unknown[]) => getBrowserSessionUser(...a),
@ -26,6 +33,11 @@ const ctx = { params: Promise.resolve({ id: "o1" }) };
beforeEach(() => {
vi.clearAllMocks();
getBrowserSessionUser.mockResolvedValue({ id: "u1", user_type: null, name: "Sam" });
getAiSettings.mockResolvedValue({
order_assistant_enabled: true,
order_assistant_fill_lines: true,
});
fillOrderFromQuote.mockResolvedValue({ filled: 2, skipped: 1 });
});
describe("POST /api/v1/orders/[id]/assistant/run", () => {
@ -44,7 +56,7 @@ describe("POST /api/v1/orders/[id]/assistant/run", () => {
const res = await POST(makeReq(), ctx);
expect(matchOrder).toHaveBeenCalledWith("o1", { force: true });
expect(res.status).toBe(200);
expect(await res.json()).toEqual(result);
expect(await res.json()).toEqual({ ...result, filled: 2, skipped: 1 });
});
it("409s when the AI is disabled", async () => {
@ -76,7 +88,50 @@ describe("POST /api/v1/orders/[id]/assistant/run", () => {
matchOrder.mockResolvedValue(result);
const res = await POST(makeReq(), ctx);
expect(res.status).toBe(200);
expect(await res.json()).toEqual(result);
expect(await res.json()).toEqual({ ...result, filled: 2, skipped: 1 });
});
it("does not fill when the fill switch is off", async () => {
getAiSettings.mockResolvedValue({
order_assistant_enabled: true,
order_assistant_fill_lines: false,
});
matchOrder.mockResolvedValue({
orderId: "o1",
outcome: "linked",
quoteId: "q1",
error: "",
});
const res = await POST(makeReq(), ctx);
expect(fillOrderFromQuote).not.toHaveBeenCalled();
expect(await res.json()).toEqual({
orderId: "o1",
outcome: "linked",
quoteId: "q1",
error: "",
filled: 0,
skipped: 0,
});
});
it("reports the match even when the fill fails", async () => {
fillOrderFromQuote.mockRejectedValue(new Error("pb down"));
matchOrder.mockResolvedValue({
orderId: "o1",
outcome: "linked",
quoteId: "q1",
error: "",
});
const res = await POST(makeReq(), ctx);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
orderId: "o1",
outcome: "linked",
quoteId: "q1",
error: "",
filled: 0,
skipped: 0,
});
});
it("500s when matchOrder throws unexpectedly", async () => {

View file

@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { getAiSettings } from "@/lib/server/ai-settings-service";
import {
AI_DISABLED_ERROR,
fillOrderFromQuote,
matchOrder,
NOT_SET_UP_ERROR,
} from "@/lib/server/order-assistant-service";
@ -35,7 +37,27 @@ export async function POST(
if (result.error === AI_DISABLED_ERROR || result.error === NOT_SET_UP_ERROR) {
return NextResponse.json({ error: result.error }, { status: 409 });
}
return NextResponse.json(result);
// Fill the order's lines from the quote they are paired with. A
// failure here is logged and reported in the counts — it never
// undoes the match.
let filled = 0;
let skipped = 0;
try {
const settings = await getAiSettings();
if (settings.order_assistant_enabled && settings.order_assistant_fill_lines) {
const fill = await fillOrderFromQuote(id);
filled = fill.filled;
skipped = fill.skipped;
}
} catch (err) {
console.error(
`[orders/${id}/assistant/run] fill failed:`,
err instanceof Error ? err.message : err,
);
}
return NextResponse.json({ ...result, filled, skipped });
} catch (err) {
const message = err instanceof Error ? err.message : "Assistant run failed";
const status = (err as Error & { status?: number }).status || 500;

View file

@ -1,15 +1,17 @@
import { describe, expect, it } from "vitest";
import {
buildOrderLineFillPatch,
confidenceToScore,
decideOutcome,
isOrderEligible,
isOrderLineWritable,
isRealOrderLine,
orderLinesHash,
PLACEHOLDER_LINE_PATTERNS,
type AiOrderMatchDecision,
type OrderItemLite,
} from "@/lib/server/order-assistant-core";
import type { OrderAiQuoteMatch } from "@/lib/types";
import type { OrderAiQuoteMatch, OrderItem, QuoteItem } from "@/lib/types";
const NOW = new Date("2026-10-24T12:00:00.000Z");
@ -336,3 +338,284 @@ describe("confidenceToScore", () => {
expect(confidenceToScore(null)).toBe(0);
});
});
// ──────────────────────────────────────────────────────────────────────
// Fill patch — Task 10
// ──────────────────────────────────────────────────────────────────────
function fillLine(over: Partial<OrderItem> = {}): OrderItem {
return {
id: "ol1",
order: "o1",
description: "M16 bolt 60mm",
po_description: "",
quantity: 100,
item_status: "pending",
cost_price: 0,
selling_price: 0,
markup: 0,
supplier: "",
sku: "",
matched_price_list_id: "",
pricing_source: "",
ai_fill: null,
...over,
} as OrderItem;
}
function fillQuoteLine(over: Partial<QuoteItem> = {}): QuoteItem {
return {
id: "ql1",
quote: "q1",
description: "M16 x 60 bolt",
quantity: 200,
cost_price: 18.2,
selling_price: 18.2,
markup: 0,
supplier: "sup1",
sku: "56020510",
matched_price_list_id: "pl1",
...over,
} as QuoteItem;
}
const FILL_CTX = {
quoteNumber: "Q-2026-10-014",
reason: "same item",
model: "test-model",
now: new Date("2026-10-24T12:00:00.000Z"),
};
describe("isOrderLineWritable", () => {
it("is true for a plain pending line with no cost", () => {
expect(isOrderLineWritable(fillLine())).toBe(true);
});
it("is false when the line is not pending", () => {
expect(isOrderLineWritable(fillLine({ item_status: "po_generated" }))).toBe(false);
expect(isOrderLineWritable(fillLine({ item_status: "processed" }))).toBe(false);
});
it("is false when the line already has a cost", () => {
expect(isOrderLineWritable(fillLine({ cost_price: 12.8 }))).toBe(false);
expect(isOrderLineWritable(fillLine({ cost_price: 0.01 }))).toBe(false);
});
it("is false when the pricing source is manual or quote", () => {
expect(isOrderLineWritable(fillLine({ pricing_source: "manual" }))).toBe(false);
expect(isOrderLineWritable(fillLine({ pricing_source: "quote" }))).toBe(false);
});
it("is false when the AI fill was already reviewed", () => {
expect(
isOrderLineWritable(
fillLine({
ai_fill: {
status: "filled",
source: "quote",
quote_item_id: "ql1",
quote_id: "q1",
quote_number: "Q-1",
cost_price: 1,
selling_price: 2,
markup: 100,
supplier_id: "",
sku: "",
reason: "",
doubts: [],
model: "",
attempted_at: "",
reviewed_at: "2026-10-24T10:00:00.000Z",
},
}),
),
).toBe(false);
});
it("is true for an unreviewed AI fill with no cost", () => {
expect(
isOrderLineWritable(
fillLine({
pricing_source: "ai_quote",
ai_fill: {
status: "filled",
source: "quote",
quote_item_id: "ql1",
quote_id: "q1",
quote_number: "Q-1",
cost_price: 0,
selling_price: 0,
markup: 0,
supplier_id: "",
sku: "",
reason: "",
doubts: [],
model: "",
attempted_at: "",
},
}),
),
).toBe(true);
});
});
describe("buildOrderLineFillPatch", () => {
it("returns null when the line is not writable", () => {
expect(
buildOrderLineFillPatch(fillLine({ item_status: "po_generated" }), fillQuoteLine(), FILL_CTX),
).toBeNull();
expect(
buildOrderLineFillPatch(fillLine({ cost_price: 12.8 }), fillQuoteLine(), FILL_CTX),
).toBeNull();
expect(
buildOrderLineFillPatch(fillLine({ pricing_source: "manual" }), fillQuoteLine(), FILL_CTX),
).toBeNull();
});
it("returns null when the quote line has no cost", () => {
expect(
buildOrderLineFillPatch(fillLine(), fillQuoteLine({ cost_price: 0 }), FILL_CTX),
).toBeNull();
});
it("fills the cost, supplier, sku and price-list id from the quote line", () => {
const patch = buildOrderLineFillPatch(fillLine(), fillQuoteLine(), FILL_CTX);
expect(patch).not.toBeNull();
expect(patch?.cost_price).toBe(18.2);
expect(patch?.supplier).toBe("sup1");
expect(patch?.sku).toBe("56020510");
expect(patch?.matched_price_list_id).toBe("pl1");
expect(patch?.pricing_source).toBe("ai_quote");
});
it("never overwrites a supplier the line already has", () => {
const patch = buildOrderLineFillPatch(
fillLine({ supplier: "sup-other" }),
fillQuoteLine(),
FILL_CTX,
);
expect(patch?.supplier).toBeUndefined();
});
it("never overwrites a SKU the line already has", () => {
const patch = buildOrderLineFillPatch(fillLine({ sku: "MINE" }), fillQuoteLine(), FILL_CTX);
expect(patch?.sku).toBeUndefined();
});
it("never overwrites a matched_price_list_id the line already has", () => {
const patch = buildOrderLineFillPatch(
fillLine({ matched_price_list_id: "pl-other" }),
fillQuoteLine(),
FILL_CTX,
);
expect(patch?.matched_price_list_id).toBeUndefined();
});
it("never writes a selling price when the line has one", () => {
const patch = buildOrderLineFillPatch(
fillLine({ selling_price: 25 }),
fillQuoteLine(),
FILL_CTX,
);
expect(patch?.selling_price).toBeUndefined();
});
it("writes the selling price when the line has none", () => {
const patch = buildOrderLineFillPatch(fillLine(), fillQuoteLine(), FILL_CTX);
expect(patch?.selling_price).toBe(18.2);
});
it("writes markup and markup_percent together, from the resulting pair", () => {
const patch = buildOrderLineFillPatch(fillLine(), fillQuoteLine(), FILL_CTX);
expect(patch?.markup).toBe(0);
expect(patch?.markup_percent).toBe(0);
const withMarkup = buildOrderLineFillPatch(
fillLine(),
fillQuoteLine({ cost_price: 10, selling_price: 25 }),
FILL_CTX,
);
expect(withMarkup?.markup).toBe(150);
expect(withMarkup?.markup_percent).toBe(150);
});
it("computes the markup from the customer's own selling price when it keeps it", () => {
const patch = buildOrderLineFillPatch(
fillLine({ selling_price: 30 }),
fillQuoteLine({ cost_price: 20, selling_price: 25 }),
FILL_CTX,
);
expect(patch?.selling_price).toBeUndefined();
expect(patch?.markup).toBe(50);
expect(patch?.markup_percent).toBe(50);
});
it("adds the price doubt when the customer price differs by more than 5%", () => {
const patch = buildOrderLineFillPatch(
fillLine({ selling_price: 16.64 }),
fillQuoteLine({ cost_price: 10, selling_price: 18.2 }),
FILL_CTX,
);
const fill = patch?.ai_fill as { doubts: string[] };
expect(fill.doubts).toEqual(["customer price R16.64, quoted R18.20"]);
});
it("does not add the price doubt within 5%", () => {
const patch = buildOrderLineFillPatch(
fillLine({ selling_price: 16.64 }),
fillQuoteLine({ cost_price: 10, selling_price: 17.0 }),
FILL_CTX,
);
const fill = patch?.ai_fill as { doubts: string[] };
expect(fill.doubts).toEqual([]);
});
it("adds no doubt when the line has no price of its own", () => {
const patch = buildOrderLineFillPatch(fillLine(), fillQuoteLine(), FILL_CTX);
const fill = patch?.ai_fill as { doubts: string[] };
expect(fill.doubts).toEqual([]);
});
it("writes the ai_fill audit blob", () => {
const patch = buildOrderLineFillPatch(fillLine(), fillQuoteLine(), FILL_CTX);
const fill = patch?.ai_fill as {
status: string;
source: string;
quote_item_id: string;
quote_id: string;
quote_number: string;
cost_price: number;
selling_price: number;
markup: number;
supplier_id: string;
sku: string;
reason: string;
doubts: string[];
model: string;
attempted_at: string;
};
expect(fill.status).toBe("filled");
expect(fill.source).toBe("quote");
expect(fill.quote_item_id).toBe("ql1");
expect(fill.quote_id).toBe("q1");
expect(fill.quote_number).toBe("Q-2026-10-014");
expect(fill.cost_price).toBe(18.2);
expect(fill.selling_price).toBe(18.2);
expect(fill.supplier_id).toBe("sup1");
expect(fill.sku).toBe("56020510");
expect(fill.reason).toBe("same item");
expect(fill.model).toBe("test-model");
expect(fill.attempted_at).toBe("2026-10-24T12:00:00.000Z");
});
it("records the resulting supplier and sku in the audit blob", () => {
const patch = buildOrderLineFillPatch(
fillLine({ supplier: "sup-other", sku: "MINE" }),
fillQuoteLine(),
FILL_CTX,
);
const fill = patch?.ai_fill as { supplier_id: string; sku: string };
expect(fill.supplier_id).toBe("sup-other");
expect(fill.sku).toBe("MINE");
});
});

View file

@ -4,7 +4,7 @@
* function of its arguments, so it is cheap to test exhaustively.
*/
import { createHash } from "node:crypto";
import type { OrderAiQuoteMatch, OrderItem } from "@/lib/types";
import type { OrderAiQuoteMatch, OrderItem, OrderItemAiFill, QuoteItem } from "@/lib/types";
import { ERROR_RETRY_MS } from "@/lib/server/quote-assistant-core";
/**
@ -191,3 +191,117 @@ export function confidenceToScore(c: "high" | "medium" | "low" | null): number {
return 0;
}
}
// ──────────────────────────────────────────────────────────────────────
// Filling order lines from the matched quote (Task 10)
// ──────────────────────────────────────────────────────────────────────
/** Pricing sources that mean "a person decided this" — never overwritten. */
const PERSON_PRICING_SOURCES: readonly string[] = ["manual", "quote"];
/**
* May the assistant write a fill onto this line?
*
* Only a line that is still `pending`, carries no cost of its own, was
* not priced by a person, and whose AI fill nobody has reviewed yet.
* The customer's own selling price is left alone — the assistant only
* fills a price where there is none.
*/
export function isOrderLineWritable(line: OrderItem): boolean {
if (line.item_status !== "pending") return false;
if (Number(line.cost_price) > 0) return false;
if (PERSON_PRICING_SOURCES.includes(line.pricing_source ?? "")) return false;
if (line.ai_fill?.reviewed_at) return false;
return true;
}
const round2 = (n: number) => Math.round(n * 100) / 100;
const money = (n: number) => `R${n.toFixed(2)}`;
/**
* The patch that fills one order line from its quote line.
*
* `null` when the line may not be written, or when the quote line has
* no cost to fill from. Otherwise:
* - `cost_price` always comes from the quote line,
* - `supplier` / `sku` / `matched_price_list_id` only when the line
* has none of its own (a person's choice always wins),
* - `selling_price` only when the line has none,
* - `markup` + `markup_percent` are written together from the
* resulting cost/selling pair,
* - `pricing_source` becomes "ai_quote" so the line shows up in the
* purple "waiting for review" state,
* - `ai_fill` records what the assistant did.
*
* A customer price that differs from the quoted price by more than 5%
* becomes a doubt — the operator has to look at it.
*/
export function buildOrderLineFillPatch(
line: OrderItem,
quoteLine: QuoteItem,
ctx: { quoteNumber: string; reason: string; model: string; now: Date },
): (Record<string, unknown> & { ai_fill: OrderItemAiFill }) | null {
if (!isOrderLineWritable(line)) return null;
const cost = Number(quoteLine.cost_price) || 0;
if (cost <= 0) return null;
const customerPrice = Number(line.selling_price) || 0;
const quotedPrice = Number(quoteLine.selling_price) || 0;
const doubts: string[] = [];
if (customerPrice > 0 && quotedPrice > 0) {
if (Math.abs(customerPrice - quotedPrice) / customerPrice > 0.05) {
doubts.push(`customer price ${money(customerPrice)}, quoted ${money(quotedPrice)}`);
}
}
// The resulting selling price: the customer's own price when there is
// one, otherwise the quoted price.
const selling = customerPrice > 0 ? customerPrice : quotedPrice;
const markup = cost > 0 && selling > 0 ? round2(((selling - cost) / cost) * 100) : 0;
const supplier = (line.supplier ?? "").trim() || (quoteLine.supplier ?? "");
const sku = (line.sku ?? "").trim() || (quoteLine.sku ?? "");
const priceListId = (line.matched_price_list_id ?? "").trim() || (quoteLine.matched_price_list_id ?? "");
const patch: Record<string, unknown> = {
cost_price: cost,
markup,
markup_percent: markup,
pricing_source: "ai_quote",
};
if (!(line.supplier ?? "").trim() && (quoteLine.supplier ?? "")) {
patch.supplier = quoteLine.supplier;
}
if (!(line.sku ?? "").trim() && (quoteLine.sku ?? "")) {
patch.sku = quoteLine.sku;
}
if (!(line.matched_price_list_id ?? "").trim() && (quoteLine.matched_price_list_id ?? "")) {
patch.matched_price_list_id = quoteLine.matched_price_list_id;
}
if (customerPrice <= 0 && quotedPrice > 0) {
patch.selling_price = quotedPrice;
}
const aiFill: OrderItemAiFill = {
status: "filled",
source: "quote",
quote_item_id: quoteLine.id,
quote_id: quoteLine.quote ?? "",
quote_number: ctx.quoteNumber,
cost_price: cost,
selling_price: selling,
markup,
supplier_id: supplier,
sku,
reason: ctx.reason,
doubts,
model: ctx.model,
attempted_at: ctx.now.toISOString(),
};
patch.ai_fill = aiFill;
return patch as Record<string, unknown> & { ai_fill: OrderItemAiFill };
}

View file

@ -0,0 +1,289 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
vi.mock("@/lib/server/quotes-service", () => ({
fetchQuoteItemsForQuotesAdmin: vi.fn(),
}));
import {
fillOrderFromQuote,
idFilter,
resetOrderFillFieldCheck,
writeAiPairs,
} from "@/lib/server/order-assistant-lines";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service";
const ORDERS = "orders";
const ORDER_ITEMS = "order_items";
const QUOTES = "quotes";
const ORDER = {
id: "ord1",
quote: "q1",
ai_quote_match: {
quote_number: "Q-2026-10-014",
model: "test-model",
lines: [{ order_line_id: "ol1", quote_line_id: "ql1", reason: "the AI said so" }],
},
};
const ORDER_LINE = {
id: "ol1",
order: "ord1",
quote_item: "ql1",
item_status: "pending",
cost_price: 0,
selling_price: 0,
markup: 0,
supplier: "",
sku: "",
matched_price_list_id: "",
pricing_source: "",
ai_fill: null,
};
const QUOTE_LINE = {
id: "ql1",
quote: "q1",
description: "M16 x 60 bolt",
cost_price: 18.2,
selling_price: 18.2,
supplier: "sup1",
sku: "56020510",
matched_price_list_id: "pl1",
};
function makePb(overrides: Record<string, Record<string, unknown>> = {}) {
const base: Record<string, Record<string, unknown>> = {
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER }),
getList: vi.fn().mockResolvedValue({ items: [] }),
getFullList: vi.fn().mockResolvedValue([
{ id: "q1", quote_number: "Q-2026-10-014" },
]),
update: vi.fn().mockResolvedValue({}),
},
[ORDER_ITEMS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER_LINE }),
getList: vi.fn().mockResolvedValue({ items: [] }),
getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE }]),
update: vi.fn().mockResolvedValue({}),
},
[QUOTES]: {
getFullList: vi.fn().mockResolvedValue([
{ id: "q1", quote_number: "Q-2026-10-014" },
]),
},
};
const merged: Record<string, Record<string, unknown>> = {};
for (const name of new Set([...Object.keys(base), ...Object.keys(overrides)])) {
merged[name] = { ...(base[name] ?? {}), ...(overrides[name] ?? {}) };
}
return {
autoCancellation: vi.fn(),
// The fill checks that the live PB has the two columns it reads and
// writes before it projects them.
collections: {
getOne: vi.fn().mockImplementation(async (name: string) => ({
fields:
name === "order_items"
? [{ name: "ai_fill" }, { name: "quote_item" }]
: [{ 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>>,
);
}
beforeEach(() => {
vi.clearAllMocks();
resetOrderFillFieldCheck();
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([
QUOTE_LINE,
] as never);
});
describe("idFilter", () => {
it("builds an OR-chain — PocketBase has no IN operator", () => {
expect(idFilter(["a", "b"])).toBe('id = "a" || id = "b"');
expect(idFilter([])).toBe("");
});
it("escapes quotes in the id", () => {
expect(idFilter(['a"b'])).toBe('id = "a\\"b"');
});
});
describe("writeAiPairs", () => {
it("writes the pairing onto a line with no quote_item and returns the ids", async () => {
const pb = makePb({
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE, quote_item: "" }]),
update: vi.fn().mockResolvedValue({}),
},
});
const update = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
const written = await writeAiPairs(pb as never, "ord1", [
{ order_line_id: "ol1", quote_line_id: "ql1", quote_id: "q1", same_item: "sure", reason: "" },
]);
expect(update).toHaveBeenCalledWith("ol1", { quote_item: "ql1" });
expect(written).toEqual(["ol1"]);
});
it("leaves a line a person already paired alone", async () => {
const pb = makePb({
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE, quote_item: "ql-other" }]),
update: vi.fn().mockResolvedValue({}),
},
});
const update = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
const written = await writeAiPairs(pb as never, "ord1", [
{ order_line_id: "ol1", quote_line_id: "ql1", quote_id: "q1", same_item: "sure", reason: "" },
]);
expect(update).not.toHaveBeenCalled();
expect(written).toEqual([]);
});
});
describe("fillOrderFromQuote", () => {
it("fills the line from its quote line", async () => {
const pb = makePb();
mockAdminPb(pb);
const res = await fillOrderFromQuote("ord1");
expect(res).toEqual({ filled: 1, skipped: 0 });
const update = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
expect(update).toHaveBeenCalledTimes(1);
const patch = update.mock.calls[0][1] as {
cost_price: number;
supplier: string;
sku: string;
pricing_source: string;
ai_fill: {
status: string;
quote_item_id: string;
quote_id: string;
quote_number: string;
reason: string;
model: string;
};
};
expect(patch.cost_price).toBe(18.2);
expect(patch.supplier).toBe("sup1");
expect(patch.sku).toBe("56020510");
expect(patch.pricing_source).toBe("ai_quote");
expect(patch.ai_fill.status).toBe("filled");
expect(patch.ai_fill.quote_item_id).toBe("ql1");
expect(patch.ai_fill.quote_id).toBe("q1");
expect(patch.ai_fill.quote_number).toBe("Q-2026-10-014");
expect(patch.ai_fill.reason).toBe("the AI said so");
expect(patch.ai_fill.model).toBe("test-model");
});
it("skips a line whose re-read shows a cost the person typed", async () => {
const pb = makePb({
[ORDER_ITEMS]: {
// The candidate list still says "no cost"…
getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE }]),
// …but the re-read right before the write shows a cost.
getOne: vi.fn().mockResolvedValue({ ...ORDER_LINE, cost_price: 12.8 }),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const res = await fillOrderFromQuote("ord1");
expect(res).toEqual({ filled: 0, skipped: 1 });
expect(pb.collection(ORDER_ITEMS).update).not.toHaveBeenCalled();
});
it("skips a line that is no longer pending", async () => {
const pb = makePb({
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE }]),
getOne: vi.fn().mockResolvedValue({ ...ORDER_LINE, item_status: "po_generated" }),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const res = await fillOrderFromQuote("ord1");
expect(res).toEqual({ filled: 0, skipped: 1 });
});
it("skips a line whose quote line has no cost", async () => {
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([
{ ...QUOTE_LINE, cost_price: 0 },
] as never);
const pb = makePb();
mockAdminPb(pb);
const res = await fillOrderFromQuote("ord1");
expect(res).toEqual({ filled: 0, skipped: 1 });
});
it("does nothing when the order has no quote", async () => {
const pb = makePb({
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ id: "ord1", quote: "" }),
getList: vi.fn().mockResolvedValue({ items: [] }),
getFullList: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const res = await fillOrderFromQuote("ord1");
expect(res).toEqual({ filled: 0, skipped: 0 });
expect(pb.collection(ORDER_ITEMS).getFullList).not.toHaveBeenCalled();
});
it("does nothing when the live PB has no ai_fill column yet", async () => {
// The migration has not run: PB would reject a projection that
// names a column the collection does not have, so the fill has to
// stay quiet instead of throwing on every linked order.
const pb = makePb();
pb.collections.getOne = vi.fn().mockResolvedValue({
fields: [{ name: "quote" }],
}) as never;
mockAdminPb(pb);
const res = await fillOrderFromQuote("ord1");
expect(res).toEqual({ filled: 0, skipped: 0 });
expect(pb.collection(ORDER_ITEMS).getFullList).not.toHaveBeenCalled();
expect(pb.collection(ORDER_ITEMS).update).not.toHaveBeenCalled();
});
it("never uses an IN filter (PocketBase has no IN operator)", async () => {
const pb = makePb();
mockAdminPb(pb);
await fillOrderFromQuote("ord1");
const filters = [ORDERS, ORDER_ITEMS, QUOTES].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*\(/);
});
});

View file

@ -0,0 +1,301 @@
/**
* Shared, cycle-free helpers for the AI order assistant.
*
* These live in their own module because `orders-service.ts` needs two
* of them (write the AI's line pairs, run the fill after a person links
* a quote) while `order-assistant-service.ts` already imports
* `setOrderQuote` from `orders-service.ts`. A static import back would
* be a cycle, so both sides import from here instead.
*
* Nothing in this module imports `orders-service.ts`.
*/
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service";
import { escapeForPbString } from "@/lib/server/price-list-match";
import { buildOrderLineFillPatch, isOrderLineWritable } from "@/lib/server/order-assistant-core";
import type { OrderAiLinePair, OrderItem, QuoteItem } from "@/lib/types";
export type AdminPb = Awaited<ReturnType<typeof getAdminPocketBase>>;
/** How many ids go into one OR-chain before the filter gets too long. */
export const ID_CHUNK = 50;
/**
* PocketBase filters have NO `IN` operator — `id IN ("a","b")` is a
* 400. An id list is an OR-chain instead:
*
* id = "a" || id = "b"
*
* Chunked by the caller when the list is long enough to push the filter
* past PB's URL length cap.
*/
export function idFilter(ids: string[]): string {
return ids.map((id) => `id = "${escapeForPbString(id)}"`).join(" || ");
}
function errMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/**
* The AI's line pairs for an order, as `writeAiPairs` wants them.
* Pairs without a quote line are dropped — there is nothing to write.
*/
export function aiQuotePairs(
match: { lines?: OrderAiLinePair[] } | null | undefined,
): OrderAiLinePair[] {
return (match?.lines ?? []).filter((l) => !!l?.quote_line_id);
}
/**
* Write the AI's pairing onto the order lines that have no pairing yet.
* A `quote_item` a person set is never overwritten.
*
* Returns the ids of the lines it wrote, so the caller can undo them
* when the link they were preparing for is refused.
*/
export async function writeAiPairs(
pb: AdminPb,
orderId: string,
aiLines: OrderAiLinePair[],
): Promise<string[]> {
const written: string[] = [];
if (aiLines.length === 0) return written;
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);
written.push(pair.order_line_id);
} catch (e) {
console.error(
`[order-assistant] could not write the AI pairing onto line ${pair.order_line_id}:`,
errMessage(e),
);
}
}
return written;
}
/** Undo `writeAiPairs` when the link it prepared for is refused. */
export 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));
}
}
}
/** The fields the fill needs off an order line. */
const ORDER_ITEM_FILL_FIELDS = [
"id",
"order",
"quote_item",
"item_status",
"cost_price",
"selling_price",
"markup",
"supplier",
"sku",
"matched_price_list_id",
"pricing_source",
"ai_fill",
].join(",");
/** The fields the fill needs off a quote line. */
const QUOTE_ITEM_FILL_FIELDS = [
"id",
"quote",
"description",
"cost_price",
"selling_price",
"supplier",
"sku",
"matched_price_list_id",
].join(",");
export interface OrderAssistantFillDeps {
now?: () => Date;
}
let fillFieldsConfirmed = false;
/** Test hook: forget the cached schema check. */
export function resetOrderFillFieldCheck(): void {
fillFieldsConfirmed = false;
}
/**
* Does the live PB have the two columns the fill reads and writes?
*
* The fill projects `orders.ai_quote_match` and `order_items.ai_fill`.
* Both are added by the order-assistant migration, and PocketBase
* rejects a projection that names a column the collection does not
* have — so before the migration runs, the fill has to stay quiet
* rather than throw on every linked order.
*
* The answer is cached for the life of the process, like the quote
* assistant's own field check.
*/
async function hasOrderFillFields(pb: AdminPb): Promise<boolean> {
if (fillFieldsConfirmed) return true;
const wanted: Array<[string, string]> = [
["orders", "ai_quote_match"],
["order_items", "ai_fill"],
];
const collections = await Promise.all(
wanted.map(([name]) =>
pb.collections.getOne(name) as Promise<{
fields?: Array<{ name: string }>;
schema?: Array<{ name: string }>;
}>,
),
);
const missing = wanted.some(([, field], i) => {
const col = collections[i];
const present = col?.fields ?? col?.schema ?? [];
return !present.some((f) => f.name === field);
});
if (missing) return false;
fillFieldsConfirmed = true;
return true;
}
/**
* Fill the order's lines from the quote they are linked to.
*
* No AI call: the pairing is already on the lines (`quote_item`), this
* only copies the quote's cost / supplier / SKU onto the lines that may
* still be written. A line is re-read immediately before its write, so
* a person who typed a cost in the meantime wins.
*
* Returns how many lines were filled and how many were skipped.
*/
export async function fillOrderFromQuote(
orderId: string,
deps: OrderAssistantFillDeps = {},
): Promise<{ filled: number; skipped: number }> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const now = (deps.now ?? (() => new Date()))();
let filled = 0;
let skipped = 0;
// The columns this reads and writes only exist after the migration.
if (!(await hasOrderFillFields(pb))) {
return { filled, skipped };
}
const order = await pb.collection("orders").getOne<{
id: string;
quote: string;
ai_quote_match?: {
quote_number?: string;
model?: string;
lines?: Array<{ order_line_id: string; reason: string }>;
} | null;
}>(orderId, { fields: "id,quote,ai_quote_match" });
const quoteId = order.quote || "";
if (!quoteId) return { filled, skipped };
const candidates = await pb.collection("order_items").getFullList<OrderItem>({
filter: `order = "${escapeForPbString(orderId)}" && quote_item != ""`,
fields: ORDER_ITEM_FILL_FIELDS,
sort: "created",
batch: 500,
});
if (candidates.length === 0) return { filled, skipped };
const quoteRows = await pb.collection("quotes").getFullList<{ id: string; quote_number: string }>({
filter: idFilter([quoteId]),
fields: "id,quote_number",
batch: 10,
});
const quoteNumber =
quoteRows[0]?.quote_number || order.ai_quote_match?.quote_number || "";
const quoteItems = await fetchQuoteItemsForQuotesAdmin(
pb,
[quoteId],
QUOTE_ITEM_FILL_FIELDS,
);
const quoteLines = new Map<string, QuoteItem>();
for (const q of quoteItems) quoteLines.set(q.id, q);
const aiReasons = new Map<string, string>();
for (const l of order.ai_quote_match?.lines ?? []) {
if (l?.order_line_id) aiReasons.set(l.order_line_id, l.reason || "");
}
for (const candidate of candidates) {
// Re-read the line right before writing it. A person may have typed
// a cost since we listed the candidates — their value wins.
let fresh: OrderItem;
try {
fresh = await pb.collection("order_items").getOne<OrderItem>(candidate.id, {
fields: ORDER_ITEM_FILL_FIELDS,
});
} catch (e) {
console.error(
`[order-assistant] could not re-read line ${candidate.id} before filling:`,
errMessage(e),
);
skipped += 1;
continue;
}
if (!fresh.quote_item) {
skipped += 1;
continue;
}
if (!isOrderLineWritable(fresh)) {
skipped += 1;
continue;
}
const quoteLine = quoteLines.get(fresh.quote_item);
if (!quoteLine) {
skipped += 1;
continue;
}
const reason =
aiReasons.get(fresh.id) || "Paired with quote line by description";
const patch = buildOrderLineFillPatch(fresh, quoteLine, {
quoteNumber,
reason,
model: order.ai_quote_match?.model ?? "",
now,
});
if (!patch) {
skipped += 1;
continue;
}
try {
await pb.collection("order_items").update(fresh.id, patch);
filled += 1;
} catch (e) {
console.error(
`[order-assistant] could not fill line ${fresh.id} from quote ${quoteId}:`,
errMessage(e),
);
skipped += 1;
}
}
return { filled, skipped };
}

View file

@ -26,6 +26,7 @@ import {
matchOrder,
runOrderAssistantTick,
resetAiQuoteMatchFieldCheck,
resetOrderFillFieldCheck,
} from "@/lib/server/order-assistant-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getAiSettings, recordOrderAssistantRun } from "@/lib/server/ai-settings-service";
@ -84,6 +85,7 @@ const QUOTE_ITEMS = [
quote: "q1",
description: "M16 x 60 bolt",
quantity: 200,
cost_price: 30,
selling_price: 340,
sku: "56020510",
supplier: "sup1",
@ -93,6 +95,7 @@ const QUOTE_ITEMS = [
quote: "q1",
description: "M16 nut",
quantity: 200,
cost_price: 10,
selling_price: 45,
sku: "",
supplier: "sup1",
@ -113,10 +116,13 @@ function makePb(overrides: Record<string, CollectionMock> = {}) {
update: vi.fn().mockResolvedValue({}),
},
[ORDER_ITEMS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER_LINES[0] }),
getFullList: vi.fn().mockResolvedValue(ORDER_LINES.map((l) => ({ ...l }))),
update: vi.fn().mockResolvedValue({}),
},
[QUOTES]: {
getOne: vi.fn().mockResolvedValue({ id: "q1", quote_number: "Q-2026-10-014" }),
getList: vi.fn().mockResolvedValue({ items: [] }),
getFullList: vi.fn().mockResolvedValue([
{ id: "q1", quote_number: "Q-2026-10-014", company: "co1", created: "2026-10-01T00:00:00.000Z" },
]),
@ -132,9 +138,14 @@ function makePb(overrides: Record<string, CollectionMock> = {}) {
return {
autoCancellation: vi.fn(),
collections: {
getOne: vi.fn().mockResolvedValue({
fields: [{ name: "ai_quote_match" }, { name: "quote" }],
}),
// Per-collection field lists, so a service that checks for a
// column on a collection it did not get a mock for is caught.
getOne: vi.fn().mockImplementation(async (name: string) => ({
fields:
name === "order_items"
? [{ name: "ai_fill" }, { name: "quote_item" }]
: [{ name: "ai_quote_match" }, { name: "quote" }],
})),
},
collection: (name: string) => {
const c = merged[name];
@ -199,6 +210,7 @@ function baseSettings(over: Record<string, unknown> = {}) {
beforeEach(() => {
vi.clearAllMocks();
resetAiQuoteMatchFieldCheck();
resetOrderFillFieldCheck();
vi.mocked(getAiSettings).mockResolvedValue(baseSettings() as never);
vi.mocked(recordOrderAssistantRun).mockResolvedValue(undefined);
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue(QUOTE_ITEMS as never);
@ -599,3 +611,175 @@ describe("runOrderAssistantTick", () => {
});
});
});
describe("matchOrder — filling the lines", () => {
it("fills the lines from the quote after a successful auto-link", async () => {
// The order starts unlinked. `link` links it, exactly as
// `setOrderQuote` does on real PB — the fill re-reads the order
// to learn which quote it is linked to, so the mock has to be
// stateful rather than frozen at `quote: ""`.
const order = { ...ORDER, quote: "" };
const pb = makePb({
[ORDERS]: {
getOne: vi.fn().mockImplementation(async () => ({ ...order })),
},
});
mockAdminPb(pb);
const link = vi.fn().mockImplementation(async () => {
order.quote = "q1";
return { order: {}, previous: { quote: "" } };
});
await matchOrder("ord1", {
deps: {
link,
match: vi.fn().mockResolvedValue(aiDecision()),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
// The fill looks for lines that are paired to a quote line.
const itemFilters = (
pb.collection(ORDER_ITEMS).getFullList as ReturnType<typeof vi.fn>
).mock.calls.map((c) => (c[0] as { filter?: string })?.filter ?? "");
expect(itemFilters.some((f) => f.includes('quote_item != ""'))).toBe(true);
});
it("does not fill when the fill switch is off", async () => {
vi.mocked(getAiSettings).mockResolvedValue(
baseSettings({ order_assistant_fill_lines: false }) as never,
);
const pb = makePb();
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 itemFilters = (
pb.collection(ORDER_ITEMS).getFullList as ReturnType<typeof vi.fn>
).mock.calls.map((c) => (c[0] as { filter?: string })?.filter ?? "");
expect(itemFilters.some((f) => f.includes('quote_item != ""'))).toBe(false);
});
});
describe("runOrderAssistantTick — the fill pass", () => {
function fillPassPb() {
return makePb({
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER, quote: "q1" }),
getList: vi.fn().mockResolvedValue({ items: [] }),
getFullList: vi.fn().mockResolvedValue([
{ id: "q1", quote_number: "Q-2026-10-014" },
]),
update: vi.fn().mockResolvedValue({}),
},
[ORDER_ITEMS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER_LINES[0], quote_item: "ql1" }),
getList: vi.fn().mockResolvedValue({
items: [
{
id: "ol1",
order: "ord1",
quote_item: "ql1",
item_status: "pending",
ai_fill: null,
},
],
}),
getFullList: vi.fn().mockResolvedValue([
{ ...ORDER_LINES[0], quote_item: "ql1" },
]),
update: vi.fn().mockResolvedValue({}),
},
});
}
it("fills the lines of a linked order in a second pass", async () => {
const pb = fillPassPb();
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?.lines_filled).toBe(1);
const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
const patch = itemUpdate.mock.calls[0][1] as {
cost_price: number;
pricing_source: string;
ai_fill: { status: string };
};
expect(patch.cost_price).toBe(30);
expect(patch.pricing_source).toBe("ai_quote");
expect(patch.ai_fill.status).toBe("filled");
});
it("does not fill when the fill switch is off", async () => {
vi.mocked(getAiSettings).mockResolvedValue(
baseSettings({ order_assistant_fill_lines: false }) as never,
);
const pb = fillPassPb();
mockAdminPb(pb);
const res = await runOrderAssistantTick({
suggest: vi.fn().mockResolvedValue(SUGGESTION),
match: vi.fn().mockResolvedValue(aiDecision()),
link: vi.fn(),
});
expect(res.summary?.lines_filled).toBe(0);
const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
expect(itemUpdate).not.toHaveBeenCalled();
});
it("never uses an IN filter in the fill pass (PocketBase has no IN operator)", async () => {
const pb = fillPassPb();
mockAdminPb(pb);
await runOrderAssistantTick({
suggest: vi.fn().mockResolvedValue(SUGGESTION),
match: vi.fn().mockResolvedValue(aiDecision()),
link: vi.fn(),
});
const filters = [ORDERS, ORDER_ITEMS, QUOTES].flatMap((name) => [
...(pb.collection(name).getFullList as ReturnType<typeof vi.fn>).mock.calls.map(
(c) => (c[0] as { filter?: string })?.filter ?? "",
),
...(pb.collection(name).getList as ReturnType<typeof vi.fn>).mock.calls.map(
(c) => (c[2] as { filter?: string })?.filter ?? "",
),
]);
expect(filters.length).toBeGreaterThan(0);
for (const f of filters) expect(f).not.toMatch(/\bIN\s*\(/);
});
it("only looks at lines that are paired, still pending and carry no cost", async () => {
// A line that already carries a cost (a filled line) must not be
// re-read on every tick — it would crowd out the lines that still
// need filling.
const pb = fillPassPb();
mockAdminPb(pb);
await runOrderAssistantTick({
suggest: vi.fn().mockResolvedValue(SUGGESTION),
match: vi.fn().mockResolvedValue(aiDecision()),
link: vi.fn(),
});
const itemList = pb.collection(ORDER_ITEMS).getList as ReturnType<typeof vi.fn>;
const filter = (itemList.mock.calls[0][2] as { filter?: string })?.filter ?? "";
expect(filter).toContain('quote_item != ""');
expect(filter).toContain('item_status = "pending"');
expect(filter).toContain("cost_price = 0");
});
});

View file

@ -27,6 +27,13 @@ import {
type SuggestQuoteResult,
} from "@/lib/server/order-quote-link-service";
import { matchOrderWithAi, type MatchInput } from "@/lib/server/order-assistant-ai";
import {
clearAiPairs,
fillOrderFromQuote,
idFilter,
ID_CHUNK,
writeAiPairs,
} from "@/lib/server/order-assistant-lines";
import { runWithAuditActor } from "@/lib/server/audit-context";
import {
AiDisabledError,
@ -53,9 +60,10 @@ import type {
} from "@/lib/types";
/** 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_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;
/** How many linked orders one run fills the lines of. */
export const TICK_MAX_FILL_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. */
@ -72,6 +80,13 @@ const MAX_SUMMARY_ERRORS = 20;
const MAX_SUMMARY_ERROR_CHARS = 300;
/** `MatchOrderResult.error` when the global AI switch is off (409). */
// Re-exported so callers have one import site for the assistant:
// `fillOrderFromQuote` lives in `order-assistant-lines.ts` so that
// `orders-service.ts` can use it without an import cycle.
export { fillOrderFromQuote };
export type { OrderAssistantFillDeps } from "@/lib/server/order-assistant-lines";
export { resetOrderFillFieldCheck } from "@/lib/server/order-assistant-lines";
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 =
@ -171,11 +186,9 @@ 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(" || ");
}
* chunked so it stays under PB's filter-length limit. `idFilter` and
* `ID_CHUNK` live in `order-assistant-lines.ts` so `orders-service.ts`
* can share them without an import cycle. */
async function loadShortlistQuotes(
pb: AdminPb,
@ -501,6 +514,18 @@ export async function matchOrder(
potential_quote_confidence: 0,
ai_quote_match: { ...baseMatch, auto_linked: true } satisfies OrderAiQuoteMatch,
});
// The order is linked — fill its lines from the quote. The lines are
// already paired, so this is a price copy, not an AI call.
if (settings.order_assistant_fill_lines) {
try {
await runWithAuditActor(actor, () => fillOrderFromQuote(orderId, { now: nowFn }));
} catch (e) {
// A failed fill never undoes the link.
console.error(`[order-assistant] fill after auto-link failed for ${orderId}:`, errMessage(e));
}
}
result.outcome = "linked";
result.quoteId = chosenQuoteId;
return result;
@ -546,51 +571,73 @@ async function writeSuggestion(
}
/**
* Write the AI's pairing onto the order lines that have no pairing yet.
* A `quote_item` a person set is never overwritten.
* Fill the lines of orders that are already linked to a quote but whose
* lines have no AI fill yet.
*
* The candidate lines are found with one query on `order_items` (a
* paired line that is still pending and carries no cost of its own),
* and the orders behind them are filled one at a time. Only orders the
* fill actually wrote to count towards `TICK_MAX_FILL_ORDERS` — an
* order with nothing to fill is skipped and does not eat the budget,
* so the same old orders cannot block the run.
*
* `cost_price = 0` in the filter is what keeps already-filled lines out
* of the candidate set: a filled line carries the quote's cost, so it
* would otherwise be re-read on every tick and crowd out the lines
* that still need filling.
*/
async function writeAiPairs(
async function fillLinkedOrders(
pb: AdminPb,
orderId: string,
aiLines: OrderAiLinePair[],
): Promise<string[]> {
const written: string[] = [];
if (aiLines.length === 0) return written;
const current = await pb.collection("order_items").getFullList<{
summary: OrderAssistantRunSummary,
nowFn: () => Date,
startMs: number,
): Promise<void> {
const candidates = await pb.collection("order_items").getList<{
id: string;
order: string;
quote_item: string;
}>({
filter: `order = "${escapeForPbString(orderId)}"`,
fields: "id,quote_item",
batch: 500,
item_status: string;
}>(1, TICK_CANDIDATE_CAP, {
// A paired line that is still pending and carries no cost yet.
// `cost_price = 0` mirrors the quote assistant's open-line filter —
// an optional number field reads as 0 until something is written.
filter: `quote_item != "" && item_status = "pending" && cost_price = 0`,
// Newest first: a buyer who added lines to a linked order should
// see them filled promptly, and stale lines that can never be
// filled drift to the back instead of blocking the run.
sort: "-created",
fields: "id,order,quote_item,item_status",
});
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);
written.push(pair.order_line_id);
} catch (e) {
console.error(
`[order-assistant] could not write the AI pairing onto line ${pair.order_line_id}:`,
errMessage(e),
);
}
}
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) {
const seen = new Set<string>();
let ordersFilled = 0;
for (const row of candidates.items) {
if (ordersFilled >= TICK_MAX_FILL_ORDERS) break;
if (nowFn().getTime() - startMs >= TICK_TIME_BUDGET_MS) break;
const orderId = row.order || "";
if (!orderId || seen.has(orderId)) continue;
seen.add(orderId);
// Only orders that are actually linked to a quote can be filled.
let linked = false;
try {
await pb.collection("order_items").update(id, { quote_item: "" });
const order = await pb.collection("orders").getOne<{ quote?: string }>(orderId, {
fields: "id,quote",
});
linked = !!order.quote;
} catch {
linked = false;
}
if (!linked) continue;
try {
const r = await fillOrderFromQuote(orderId, { now: nowFn });
if (r.filled > 0) {
ordersFilled += 1;
summary.lines_filled += r.filled;
}
} catch (e) {
console.error(`[order-assistant] could not undo the AI pairing on line ${id}:`, errMessage(e));
summary.errors.push(`${orderId}: fill failed: ${errMessage(e)}`);
}
}
}
@ -681,6 +728,19 @@ export async function runOrderAssistantTick(
summary.errors.push(`${row.id}: ${errMessage(e)}`);
}
}
// Second pass: fill the lines of orders that are already linked to a
// quote but whose lines have no AI fill yet — an order the buyer
// added lines to after the match, or a link a person made by hand.
// Like the match pass, orders the fill does not touch do not use up
// the budget.
if (settings.order_assistant_fill_lines) {
try {
await fillLinkedOrders(pb, summary, nowFn, startMs);
} catch (e) {
summary.errors.push(`fill pass failed: ${errMessage(e)}`);
}
}
} catch (e) {
summary.errors.push(`tick failed: ${errMessage(e)}`);
}

View file

@ -226,14 +226,17 @@ describe("suggestQuoteForOrder", () => {
}));
const quotesGetList = vi.fn().mockResolvedValue({ items: quoteRows });
// Simulate PB rejecting the first attempt (the `quote IN (...)`
// syntax) the way a too-long filter expression would 400 in real
// PB, then succeeding on every chunked retry. The un-fixed code
// issued a single unchunked OR-chain call with no fallback, so
// this same mock makes suggestQuoteForOrder reject outright
// against that code — it only resolves once the fetch is chunked.
const quoteItemsGetFullList = vi.fn().mockImplementation(() => {
if (quoteItemsGetFullList.mock.calls.length === 1) {
// PB rejects a filter that embeds too many ids, the way a real
// oversized filter expression 400s. Reject on the SHAPE of the
// filter (one chunk's worth of ids or fewer), not on a call index:
// the fetch must go straight to chunked OR-chains, so the very
// first call already has to be a chunk. The un-fixed code issued a
// single unchunked OR-chain with no fallback, so this same mock
// makes suggestQuoteForOrder reject outright against that code.
const CHUNK = 80;
const quoteItemsGetFullList = vi.fn().mockImplementation((opts: { filter: string }) => {
const idsInFilter = (opts.filter.match(/quote = "/g) || []).length;
if (idsInFilter > CHUNK) {
return Promise.reject(new Error("PB 400: filter expression too long"));
}
return Promise.resolve([]);
@ -251,27 +254,33 @@ describe("suggestQuoteForOrder", () => {
}),
);
// Must resolve, not reject — the un-fixed code has no fallback and
// propagates the simulated PB rejection straight out of
// suggestQuoteForOrder.
// Must resolve, not reject — an unchunked fetch propagates the
// simulated PB rejection straight out of suggestQuoteForOrder.
await expect(suggestQuoteForOrder("ord1")).resolves.toBeDefined();
// First call is the failed IN(...) attempt; every call after it is
// a chunked OR-chain retry, so there must be more than one.
// Every call is a chunked OR-chain fetch, and there must be more
// than one (100 ids cannot fit in one 80-id chunk).
expect(quoteItemsGetFullList.mock.calls.length).toBeGreaterThan(1);
const chunkCalls = quoteItemsGetFullList.mock.calls.slice(1);
let idsAcrossChunks = 0;
for (const [opts] of chunkCalls) {
// The matcher may widen its window and run a second pass, so the
// same ids can be fetched twice. What must hold: no single call
// embeds more than a chunk's worth of ids, no chunk repeats an id,
// and every candidate id is covered.
const seenIds = new Set<string>();
for (const [opts] of quoteItemsGetFullList.mock.calls) {
const filter = (opts as { filter: string }).filter;
const idsInThisChunk = (filter.match(/quote = "/g) || []).length;
// No single retry re-embeds every candidate id in one filter —
const ids = [...filter.matchAll(/quote = "([^"]+)"/g)].map((m) => m[1]);
// No single call re-embeds every candidate id in one filter —
// that's exactly the oversized-filter bug this test pins.
expect(idsInThisChunk).toBeLessThanOrEqual(80);
idsAcrossChunks += idsInThisChunk;
expect(ids.length).toBeLessThanOrEqual(CHUNK);
// No id duplicated inside a chunk.
expect(new Set(ids).size).toBe(ids.length);
// PocketBase has no IN operator, so an id list is an OR-chain.
expect(filter).not.toMatch(/\bIN\s*\(/);
for (const id of ids) seenIds.add(id);
}
// No ids lost or duplicated across chunks.
expect(idsAcrossChunks).toBe(CANDIDATE_COUNT);
// No ids lost.
expect(seenIds.size).toBe(CANDIDATE_COUNT);
});
it("records no_match and still stamps quote_matching_attempted", async () => {

View file

@ -221,11 +221,10 @@ async function fuzzyPass(
// A naive `quote = "x" || quote = "y" || ...` OR-chain over
// FUZZY_CANDIDATE_CAP (200) ids blows PB's filter length limit
// (HTTP 400) well before hitting the cap — `fetchQuoteItemsForQuotesAdmin`
// tries PB's `IN (...)` syntax first and falls back to chunked
// OR-chains, so a company with a large quote history doesn't break
// the matcher outright. Reused (not reimplemented) from
// `quotes-service.ts`, which already has this exact chunking logic
// for its duplicate-quote scan.
// chunks the ids into OR-chains of its own, so a company with a large
// quote history doesn't break the matcher outright. Reused (not
// reimplemented) from `quotes-service.ts`, which already has this
// exact chunking logic for its duplicate-quote scan.
const quoteIds = candidates.items.map((q) => q.id);
const quoteItems = await fetchQuoteItemsForQuotesAdmin(pb, quoteIds, MATCHER_QUOTE_ITEM_FIELDS);
@ -504,9 +503,9 @@ export async function pairOrderLines(
batch: 1000,
}),
// Reuses the same helper the fuzzy pass uses for quote_items — it
// already handles PB's IN(...) filter and the chunked-OR-chain
// fallback, so there is one code path in this file that knows how
// to fetch quote_items rather than two.
// already chunks the ids into OR-chains so PB's filter-length
// limit is never hit, so there is one code path in this file
// that knows how to fetch quote_items rather than two.
fetchQuoteItemsForQuotesAdmin(pb, [quoteId], PAIR_QUOTE_ITEM_FIELDS),
]);

View file

@ -1,6 +1,13 @@
import { after } from "next/server";
import { getAdminPocketBase } from "./pocketbase-admin";
import { pairOrderLines, suggestQuoteForOrder } from "./order-quote-link-service";
import {
aiQuotePairs,
fillOrderFromQuote,
writeAiPairs,
} from "./order-assistant-lines";
import { getAiSettings } from "./ai-settings-service";
import { runWithAuditActor } from "./audit-context";
import { suggestDeliveryNoteForOrder } from "./order-delivery-note-link-service";
import { suggestOrderNumberDuplicate } from "./order-number-duplicate-link-service";
import { suggestOrderDuplicate } from "./order-order-duplicate-link-service";
@ -4296,6 +4303,26 @@ export async function setOrderQuote(
// Capture the previous quote value for the response.
const previous: { quote: string } = { quote: existing.quote || "" };
// ── AI order assistant: a person accepted the AI's suggestion ──
//
// When the order carries an AI verdict for THIS quote, the AI already
// paired the order lines to the quote lines. Those pairs go on BEFORE
// `pairOrderLines` runs, because the word-based pairing never
// overwrites a `quote_item` that is already set — so the AI's pairing
// wins on every line it paired, and the description matcher only
// fills the lines the AI left alone. A `quote_item` a person set is
// never overwritten.
if (existing.ai_quote_match?.quote_id === quoteId) {
try {
await writeAiPairs(pb, orderId, aiQuotePairs(existing.ai_quote_match));
} catch (err) {
console.error(
`[setOrderQuote] could not write the AI line pairs for order ${orderId}:`,
err instanceof Error ? err.message : err,
);
}
}
// Idempotent no-op when the order already points at this quote.
// Don't write `orders.quote`, don't stamp — just return the order
// as-is. Pairing still runs on this path: it's safe to repeat
@ -4313,6 +4340,7 @@ export async function setOrderQuote(
if (previous.quote === quoteId) {
const fresh = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId);
await pairOrderLinesSafely(orderId, quoteId);
await fillOrderLinesFromQuoteSafely(orderId);
return { order: fresh, previous };
}
@ -4335,9 +4363,37 @@ ${stamp}` : stamp;
// doubtful ones are surfaced in the UI for confirmation instead.
await pairOrderLinesSafely(orderId, quoteId);
// The AI order assistant fills the order's lines from the quote they
// are now paired with. Only when the operator left both switches on.
await fillOrderLinesFromQuoteSafely(orderId);
return { order: updated, previous };
}
/**
* The AI order assistant's fill hook: copy the quote's cost / supplier /
* SKU onto the order lines that may still be written.
*
* Runs only when the order assistant and the fill switch are both on.
* A failure is logged and swallowed — the link the operator just made
* must never fail because the fill could not be written.
*/
async function fillOrderLinesFromQuoteSafely(orderId: string): Promise<void> {
try {
const settings = await getAiSettings();
if (!settings.order_assistant_enabled || !settings.order_assistant_fill_lines) return;
await runWithAuditActor(
{ name: "AI order assistant", source: "agent", ignoreRequestUser: true },
() => fillOrderFromQuote(orderId),
);
} catch (err) {
console.error(
`[setOrderQuote] AI fill failed for order ${orderId}:`,
err instanceof Error ? err.message : err,
);
}
}
/**
* Run `pairOrderLines`, never letting it fail the caller.
*

View file

@ -3533,15 +3533,11 @@ export async function fetchQuoteItemsForQuotesAdmin(
if (quoteIds.length === 0) return [];
const escape = (id: string) => id.replace(/"/g, '\\"');
const safeIds = quoteIds.map(escape);
try {
return await pb.collection(QUOTE_ITEMS_COLLECTION).getFullList<QuoteItem>({
filter: `quote IN (${safeIds.map((id) => `"${id}"`).join(",")})`,
fields,
batch: 5000,
});
} catch {
// PB rejected the IN filter — fall back to chunked OR-chains.
}
// PocketBase filters have NO `IN` operator — `quote IN ("a","b")` is a
// 400. An id list is an OR-chain, chunked so the filter stays under
// PB's URL length cap. (The old code tried `IN` first and fell back
// to exactly this on the resulting 400, so every call paid for a
// guaranteed failing round-trip.)
const CHUNK = 80;
const chunks: string[][] = [];
for (let i = 0; i < safeIds.length; i += CHUNK) {