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:
parent
379c978cc5
commit
89e52bbd9d
12 changed files with 1459 additions and 91 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
289
src/lib/server/order-assistant-lines.test.ts
Normal file
289
src/lib/server/order-assistant-lines.test.ts
Normal 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*\(/);
|
||||
});
|
||||
});
|
||||
301
src/lib/server/order-assistant-lines.ts
Normal file
301
src/lib/server/order-assistant-lines.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue