A line that sells below the quote's cost produced a negative markup. Every other write to an order line's markup stores Math.max(0, computedMarkup), so the fill now does the same. The margin chip is unaffected — it works from cost and selling, not from this field, so a loss-making line still shows its real margin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
634 lines
20 KiB
TypeScript
634 lines
20 KiB
TypeScript
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, OrderItem, QuoteItem } from "@/lib/types";
|
|
|
|
const NOW = new Date("2026-10-24T12:00:00.000Z");
|
|
|
|
function line(over: Partial<OrderItemLite> = {}): OrderItemLite {
|
|
return {
|
|
id: "ol1",
|
|
description: "M16 bolt 60mm",
|
|
quantity: 10,
|
|
item_status: "pending",
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe("PLACEHOLDER_LINE_PATTERNS", () => {
|
|
it("covers the two known placeholders", () => {
|
|
expect(PLACEHOLDER_LINE_PATTERNS.some((p) => p.test("See attached"))).toBe(true);
|
|
expect(PLACEHOLDER_LINE_PATTERNS.some((p) => p.test("Buyer to add items"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("isRealOrderLine", () => {
|
|
it("is false for an empty description", () => {
|
|
expect(isRealOrderLine({ description: "" })).toBe(false);
|
|
expect(isRealOrderLine({ description: " " })).toBe(false);
|
|
expect(isRealOrderLine({})).toBe(false);
|
|
});
|
|
|
|
it("is false for a placeholder line", () => {
|
|
expect(isRealOrderLine({ description: "See attached" })).toBe(false);
|
|
expect(isRealOrderLine({ description: "buyer to add items" })).toBe(false);
|
|
});
|
|
|
|
it("is false for a cancelled line", () => {
|
|
expect(isRealOrderLine({ description: "M16 bolt", item_status: "cancelled" })).toBe(false);
|
|
});
|
|
|
|
it("is true for a normal pending line", () => {
|
|
expect(isRealOrderLine({ description: "M16 bolt 60mm", item_status: "pending" })).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("orderLinesHash", () => {
|
|
it("is stable over line order", () => {
|
|
const a = orderLinesHash([
|
|
{ id: "ol1", description: "Bolt", quantity: 10 },
|
|
{ id: "ol2", description: "Nut", quantity: 5 },
|
|
]);
|
|
const b = orderLinesHash([
|
|
{ id: "ol2", description: "Nut", quantity: 5 },
|
|
{ id: "ol1", description: "Bolt", quantity: 10 },
|
|
]);
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it("changes when a quantity changes", () => {
|
|
const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]);
|
|
const b = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 11 }]);
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it("changes when a line is added", () => {
|
|
const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]);
|
|
const b = orderLinesHash([
|
|
{ id: "ol1", description: "Bolt", quantity: 10 },
|
|
{ id: "ol2", description: "Nut", quantity: 5 },
|
|
]);
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it("changes when a description changes", () => {
|
|
const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]);
|
|
const b = orderLinesHash([{ id: "ol1", description: "Nut", quantity: 10 }]);
|
|
expect(a).not.toBe(b);
|
|
});
|
|
});
|
|
|
|
describe("isOrderEligible", () => {
|
|
it("is false when the order already has a quote", () => {
|
|
expect(
|
|
isOrderEligible({ quote: "q1" }, [line()], { now: NOW }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("is false when there is no real line", () => {
|
|
expect(
|
|
isOrderEligible({}, [line({ description: "See attached" })], { now: NOW }),
|
|
).toBe(false);
|
|
expect(isOrderEligible({}, [], { now: NOW })).toBe(false);
|
|
});
|
|
|
|
it("is false when no real line is pending", () => {
|
|
expect(
|
|
isOrderEligible({}, [line({ item_status: "po_generated" })], { now: NOW }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("is true when a real line is pending", () => {
|
|
expect(
|
|
isOrderEligible({}, [line({ item_status: "pending" })], { now: NOW }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("ignores non-pending lines when deciding", () => {
|
|
expect(
|
|
isOrderEligible(
|
|
{},
|
|
[line({ id: "ol1", item_status: "po_generated" }), line({ id: "ol2" })],
|
|
{ now: NOW },
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("is false when the same lines were already matched", () => {
|
|
const lines = [line()];
|
|
const hash = orderLinesHash(lines);
|
|
const ai_quote_match: OrderAiQuoteMatch = {
|
|
status: "suggested",
|
|
quote_id: "q1",
|
|
quote_number: "Q-1",
|
|
confidence: "medium",
|
|
reason: "",
|
|
doubts: [],
|
|
lines: [],
|
|
auto_linked: false,
|
|
lines_hash: hash,
|
|
model: "m",
|
|
run_at: NOW.toISOString(),
|
|
};
|
|
expect(isOrderEligible({ ai_quote_match }, lines, { now: NOW })).toBe(false);
|
|
});
|
|
|
|
it("is true for the same lines when forced", () => {
|
|
const lines = [line()];
|
|
const hash = orderLinesHash(lines);
|
|
const ai_quote_match: OrderAiQuoteMatch = {
|
|
status: "suggested",
|
|
quote_id: "q1",
|
|
quote_number: "Q-1",
|
|
confidence: "medium",
|
|
reason: "",
|
|
doubts: [],
|
|
lines: [],
|
|
auto_linked: false,
|
|
lines_hash: hash,
|
|
model: "m",
|
|
run_at: NOW.toISOString(),
|
|
};
|
|
expect(isOrderEligible({ ai_quote_match }, lines, { now: NOW, force: true })).toBe(true);
|
|
});
|
|
|
|
it("is true when the lines changed since the last run", () => {
|
|
const ai_quote_match: OrderAiQuoteMatch = {
|
|
status: "suggested",
|
|
quote_id: "q1",
|
|
quote_number: "Q-1",
|
|
confidence: "medium",
|
|
reason: "",
|
|
doubts: [],
|
|
lines: [],
|
|
auto_linked: false,
|
|
lines_hash: "stale-hash",
|
|
model: "m",
|
|
run_at: NOW.toISOString(),
|
|
};
|
|
expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(true);
|
|
});
|
|
|
|
it("is false within the error cooldown", () => {
|
|
const ai_quote_match: OrderAiQuoteMatch = {
|
|
status: "error",
|
|
quote_id: null,
|
|
quote_number: "",
|
|
confidence: null,
|
|
reason: "",
|
|
doubts: [],
|
|
lines: [],
|
|
auto_linked: false,
|
|
lines_hash: "stale-hash",
|
|
model: "m",
|
|
run_at: new Date(NOW.getTime() - 30 * 60 * 1000).toISOString(),
|
|
error: "boom",
|
|
};
|
|
expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(false);
|
|
});
|
|
|
|
it("is true after the error cooldown", () => {
|
|
const ai_quote_match: OrderAiQuoteMatch = {
|
|
status: "error",
|
|
quote_id: null,
|
|
quote_number: "",
|
|
confidence: null,
|
|
reason: "",
|
|
doubts: [],
|
|
lines: [],
|
|
auto_linked: false,
|
|
lines_hash: "stale-hash",
|
|
model: "m",
|
|
run_at: new Date(NOW.getTime() - 61 * 60 * 1000).toISOString(),
|
|
error: "boom",
|
|
};
|
|
expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("decideOutcome", () => {
|
|
const QUOTE_LINE_TO_QUOTE = new Map([
|
|
["ql1", "q1"],
|
|
["ql2", "q1"],
|
|
]);
|
|
|
|
function decision(over: Partial<AiOrderMatchDecision> = {}): AiOrderMatchDecision {
|
|
return {
|
|
quote_id: "q1",
|
|
confidence: "high",
|
|
reason: "same items",
|
|
doubts: [],
|
|
lines: [
|
|
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "same" },
|
|
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure", reason: "same" },
|
|
],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
const BASE = {
|
|
realLineIds: ["ol1", "ol2"],
|
|
quoteLineToQuote: QUOTE_LINE_TO_QUOTE,
|
|
orderCompany: "c1",
|
|
quoteCompany: "c1",
|
|
autoLinkEnabled: true,
|
|
};
|
|
|
|
it("auto-links when everything is sure on one quote", () => {
|
|
expect(decideOutcome({ decision: decision(), ...BASE })).toBe("auto_link");
|
|
});
|
|
|
|
it("suggests when one line is only likely", () => {
|
|
const d = decision({
|
|
lines: [
|
|
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
|
|
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "likely", reason: "" },
|
|
],
|
|
});
|
|
expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest");
|
|
});
|
|
|
|
it("suggests when one line is unpaired", () => {
|
|
const d = decision({
|
|
lines: [
|
|
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
|
|
{ order_line_id: "ol2", quote_line_id: null, same_item: null, reason: "" },
|
|
],
|
|
});
|
|
expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest");
|
|
});
|
|
|
|
it("suggests when a pair points at another quote's line", () => {
|
|
const d = decision({
|
|
lines: [
|
|
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
|
|
{ order_line_id: "ol2", quote_line_id: "qlOther", same_item: "sure", reason: "" },
|
|
],
|
|
});
|
|
expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest");
|
|
});
|
|
|
|
it("suggests when the company differs", () => {
|
|
expect(
|
|
decideOutcome({ decision: decision(), ...BASE, quoteCompany: "c2" }),
|
|
).toBe("suggest");
|
|
});
|
|
|
|
it("suggests when the quote has no company", () => {
|
|
expect(
|
|
decideOutcome({ decision: decision(), ...BASE, quoteCompany: null }),
|
|
).toBe("suggest");
|
|
});
|
|
|
|
it("suggests when auto-link is switched off", () => {
|
|
expect(
|
|
decideOutcome({ decision: decision(), ...BASE, autoLinkEnabled: false }),
|
|
).toBe("suggest");
|
|
});
|
|
|
|
it("suggests when the confidence is medium", () => {
|
|
expect(
|
|
decideOutcome({ decision: decision({ confidence: "medium" }), ...BASE }),
|
|
).toBe("suggest");
|
|
});
|
|
|
|
it("is no_match when the AI chose no quote", () => {
|
|
expect(
|
|
decideOutcome({ decision: decision({ quote_id: null }), ...BASE }),
|
|
).toBe("no_match");
|
|
});
|
|
|
|
it("is no_match when no pair lands on the chosen quote", () => {
|
|
const d = decision({
|
|
lines: [
|
|
{ order_line_id: "ol1", quote_line_id: "qlOther", same_item: "sure", reason: "" },
|
|
{ order_line_id: "ol2", quote_line_id: "qlOther", same_item: "sure", reason: "" },
|
|
],
|
|
});
|
|
expect(decideOutcome({ decision: d, ...BASE })).toBe("no_match");
|
|
});
|
|
|
|
it("ignores pairs for lines that are not real order lines", () => {
|
|
const d = decision({
|
|
lines: [
|
|
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
|
|
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure", reason: "" },
|
|
{ order_line_id: "olPlaceholder", quote_line_id: "ql1", same_item: "unsure", reason: "" },
|
|
],
|
|
});
|
|
expect(decideOutcome({ decision: d, ...BASE })).toBe("auto_link");
|
|
});
|
|
});
|
|
|
|
describe("confidenceToScore", () => {
|
|
it("maps the three levels", () => {
|
|
expect(confidenceToScore("high")).toBe(0.9);
|
|
expect(confidenceToScore("medium")).toBe(0.7);
|
|
expect(confidenceToScore("low")).toBe(0.5);
|
|
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("clamps a loss-making markup at 0, like every other order-line write", () => {
|
|
// The customer's price is below the quote's cost. The margin chip
|
|
// shows the real margin from cost + selling; the stored markup
|
|
// follows the app's convention of never going negative.
|
|
const patch = buildOrderLineFillPatch(
|
|
fillLine({ selling_price: 8 }),
|
|
fillQuoteLine({ cost_price: 10, selling_price: 12 }),
|
|
FILL_CTX,
|
|
);
|
|
expect(patch?.markup).toBe(0);
|
|
expect(patch?.markup_percent).toBe(0);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|