WFDASH-WEBAPP/src/lib/order-assistant-review.test.ts
Dave d3a52d97f2 feat(orders): purple review for AI-filled order lines
A line the AI filled now shows up purple until a person checks it, so
nothing the assistant priced is ever taken on trust.

- order-assistant-review.ts: the four client helpers — a line is
  pending only while it is filled, unreviewed and still priced from
  the quote; a human save keeps "ai_quote" / "quote" / "catalogue" and
  turns anything else into "manual"; the review patch records who
  checked the line and whether the prices were accepted unchanged.
- order-assistant-save.ts: the save-time merge, the order-side twin of
  quote-assistant-save.ts. The value the operator committed wins over
  the one the page was loaded with, so the one-click "use this cost"
  copy really does end the AI's provenance.
- The Process page applies the purple row class, shows the bot marker
  with the quote it was filled from, the reason and the doubts, and
  routes every line save through the merge.
- The Orders board shows an "AI x/y" badge: how many lines the AI
  filled, and how many still need a review. One query per page, and
  never fatal — the board has to render whether or not the assistant's
  columns exist yet.
- AiFillMarker grows an "order" variant instead of assuming a POS item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:25:11 +02:00

157 lines
4.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { OrderItemAiFill } from "@/lib/types";
import {
buildOrderAiReviewPatch,
isOrderAiFillPending,
nextOrderPricingSourceOnSave,
orderAiRowClass,
} from "./order-assistant-review";
const NOW = new Date("2026-10-24T12:00:00.000Z");
function fill(over: Partial<OrderItemAiFill> = {}): OrderItemAiFill {
return {
status: "filled",
source: "quote",
quote_item_id: "ql1",
quote_id: "q1",
quote_number: "Q-2026-10-014",
cost_price: 18.2,
selling_price: 16.64,
markup: -8.57,
supplier_id: "sup1",
sku: "56020510",
reason: "same item",
doubts: [],
model: "m",
attempted_at: NOW.toISOString(),
...over,
};
}
const pending = { ai_fill: fill(), pricing_source: "ai_quote" };
describe("isOrderAiFillPending", () => {
it("is true only for an unreviewed filled ai_quote line", () => {
expect(isOrderAiFillPending(pending)).toBe(true);
});
it("is false once reviewed", () => {
expect(
isOrderAiFillPending({
ai_fill: fill({ reviewed_at: NOW.toISOString() }),
pricing_source: "ai_quote",
}),
).toBe(false);
});
it("is false when a rep's save flipped pricing_source to manual", () => {
expect(isOrderAiFillPending({ ai_fill: fill(), pricing_source: "manual" })).toBe(false);
});
it("is false when the 'use this' copy set pricing_source to quote", () => {
expect(isOrderAiFillPending({ ai_fill: fill(), pricing_source: "quote" })).toBe(false);
});
it("is false for non-filled statuses and missing ai_fill", () => {
expect(
isOrderAiFillPending({ ai_fill: fill({ status: "skipped" }), pricing_source: "ai_quote" }),
).toBe(false);
expect(isOrderAiFillPending({ ai_fill: null, pricing_source: "ai_quote" })).toBe(false);
expect(isOrderAiFillPending({ pricing_source: "ai_quote" })).toBe(false);
});
});
describe("nextOrderPricingSourceOnSave", () => {
it("keeps ai_quote, quote and catalogue, otherwise manual", () => {
expect(nextOrderPricingSourceOnSave("ai_quote")).toBe("ai_quote");
expect(nextOrderPricingSourceOnSave("quote")).toBe("quote");
expect(nextOrderPricingSourceOnSave("catalogue")).toBe("catalogue");
expect(nextOrderPricingSourceOnSave("manual")).toBe("manual");
expect(nextOrderPricingSourceOnSave("ai_pos")).toBe("manual");
expect(nextOrderPricingSourceOnSave("")).toBe("manual");
expect(nextOrderPricingSourceOnSave(undefined)).toBe("manual");
});
it("keeps ai_quote on a stale save, so the line stays in review", () => {
// A rep saving from a stale page must not lose the AI's provenance.
expect(nextOrderPricingSourceOnSave("ai_quote")).toBe("ai_quote");
});
});
describe("buildOrderAiReviewPatch", () => {
const reviewer = { id: "u1", name: "Sam" };
it("records an unchanged acceptance", () => {
const patch = buildOrderAiReviewPatch(
pending,
{ cost_price: 18.2, selling_price: 16.64, supplier: "sup1" },
reviewer,
NOW,
);
expect(patch.ai_fill).toMatchObject({
status: "filled",
source: "quote",
quote_number: "Q-2026-10-014",
accepted_unchanged: true,
supplier_changed: false,
reviewed_at: NOW.toISOString(),
reviewed_by: "Sam",
});
});
it("flags a changed selling price", () => {
const patch = buildOrderAiReviewPatch(
pending,
{ cost_price: 18.2, selling_price: 20, supplier: "sup1" },
reviewer,
NOW,
);
expect(patch.ai_fill?.accepted_unchanged).toBe(false);
});
it("flags a changed cost price", () => {
const patch = buildOrderAiReviewPatch(
pending,
{ cost_price: 19, selling_price: 16.64, supplier: "sup1" },
reviewer,
NOW,
);
expect(patch.ai_fill?.accepted_unchanged).toBe(false);
});
it("flags a different or cleared supplier", () => {
const changed = buildOrderAiReviewPatch(
pending,
{ cost_price: 18.2, selling_price: 16.64, supplier: "sup2" },
reviewer,
NOW,
);
expect(changed.ai_fill?.supplier_changed).toBe(true);
const cleared = buildOrderAiReviewPatch(
pending,
{ cost_price: 18.2, selling_price: 16.64, supplier: null },
reviewer,
NOW,
);
expect(cleared.ai_fill?.supplier_changed).toBe(true);
});
it("returns an empty patch for a non-pending line", () => {
const saved = { cost_price: 1, selling_price: 2, supplier: null };
expect(
buildOrderAiReviewPatch({ ai_fill: fill(), pricing_source: "manual" }, saved, reviewer, NOW),
).toEqual({});
expect(
buildOrderAiReviewPatch({ ai_fill: null, pricing_source: "ai_quote" }, saved, reviewer, NOW),
).toEqual({});
});
});
describe("orderAiRowClass", () => {
it("highlights pending lines only", () => {
expect(orderAiRowClass(pending)).toBe("bg-ai/10");
expect(orderAiRowClass({ ai_fill: fill(), pricing_source: "manual" })).toBe("");
expect(orderAiRowClass({ ai_fill: null, pricing_source: "" })).toBe("");
});
});