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>
141 lines
4.1 KiB
TypeScript
141 lines
4.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { OrderItemAiFill } from "@/lib/types";
|
|
import {
|
|
orderReviewerFromAuthRecord,
|
|
withOrderAiReview,
|
|
} from "./order-assistant-save";
|
|
|
|
const NOW = new Date("2026-10-24T12:00:00.000Z");
|
|
const REVIEWER = { id: "u1", name: "Sam" };
|
|
|
|
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" };
|
|
const saved = { cost_price: 18.2, selling_price: 16.64, supplier: "sup1" };
|
|
|
|
describe("withOrderAiReview", () => {
|
|
it("keeps ai_quote when the operator saves an AI-filled line unchanged", () => {
|
|
const out = withOrderAiReview(
|
|
pending,
|
|
{ cost_price: 18.2, pricing_source: "ai_quote" },
|
|
saved,
|
|
REVIEWER,
|
|
NOW,
|
|
);
|
|
expect(out.pricing_source).toBe("ai_quote");
|
|
expect(out.ai_fill).toMatchObject({
|
|
reviewed_at: NOW.toISOString(),
|
|
reviewed_by: "Sam",
|
|
accepted_unchanged: true,
|
|
});
|
|
});
|
|
|
|
it("lets the one-click 'use this cost' copy end the purple state", () => {
|
|
// The copy sets pricing_source to "quote" — the operator chose the
|
|
// quote's cost themselves, so the AI's provenance must not survive.
|
|
const out = withOrderAiReview(
|
|
pending,
|
|
{ cost_price: 18.2, pricing_source: "quote" },
|
|
saved,
|
|
REVIEWER,
|
|
NOW,
|
|
);
|
|
expect(out.pricing_source).toBe("quote");
|
|
expect(out.ai_fill).toMatchObject({ reviewed_at: NOW.toISOString() });
|
|
});
|
|
|
|
it("marks a manually priced line as manual", () => {
|
|
const out = withOrderAiReview(
|
|
{ ai_fill: null, pricing_source: "" },
|
|
{ cost_price: 5, pricing_source: "" },
|
|
{ cost_price: 5, selling_price: 9, supplier: null },
|
|
REVIEWER,
|
|
NOW,
|
|
);
|
|
expect(out.pricing_source).toBe("manual");
|
|
expect(out.ai_fill).toBeUndefined();
|
|
});
|
|
|
|
it("keeps catalogue", () => {
|
|
const out = withOrderAiReview(
|
|
{ ai_fill: null, pricing_source: "catalogue" },
|
|
{ cost_price: 5, pricing_source: "catalogue" },
|
|
{ cost_price: 5, selling_price: 9, supplier: null },
|
|
REVIEWER,
|
|
NOW,
|
|
);
|
|
expect(out.pricing_source).toBe("catalogue");
|
|
});
|
|
|
|
it("never touches a patch with no pricing_source", () => {
|
|
const out = withOrderAiReview(
|
|
{ ai_fill: null, pricing_source: "" },
|
|
{ notes: "hello" },
|
|
saved,
|
|
REVIEWER,
|
|
NOW,
|
|
);
|
|
expect(out).toEqual({ notes: "hello" });
|
|
});
|
|
|
|
it("never mutates the patch it was given", () => {
|
|
const patch = { cost_price: 18.2, pricing_source: "ai_quote" };
|
|
withOrderAiReview(pending, patch, saved, REVIEWER, NOW);
|
|
expect(patch).toEqual({ cost_price: 18.2, pricing_source: "ai_quote" });
|
|
});
|
|
|
|
it("records a changed price as not accepted unchanged", () => {
|
|
const out = withOrderAiReview(
|
|
pending,
|
|
{ cost_price: 20, pricing_source: "ai_quote" },
|
|
{ cost_price: 20, selling_price: 16.64, supplier: "sup1" },
|
|
REVIEWER,
|
|
NOW,
|
|
);
|
|
expect((out.ai_fill as OrderItemAiFill).accepted_unchanged).toBe(false);
|
|
});
|
|
|
|
it("survives a missing line", () => {
|
|
const out = withOrderAiReview(
|
|
undefined,
|
|
{ cost_price: 5, pricing_source: "ai_quote" },
|
|
saved,
|
|
REVIEWER,
|
|
NOW,
|
|
);
|
|
expect(out.pricing_source).toBe("ai_quote");
|
|
expect(out.ai_fill).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("orderReviewerFromAuthRecord", () => {
|
|
it("prefers the name, then the email", () => {
|
|
expect(orderReviewerFromAuthRecord({ id: "u1", name: "Sam", email: "s@x.co" })).toEqual({
|
|
id: "u1",
|
|
name: "Sam",
|
|
});
|
|
expect(orderReviewerFromAuthRecord({ id: "u1", email: "s@x.co" })).toEqual({
|
|
id: "u1",
|
|
name: "s@x.co",
|
|
});
|
|
expect(orderReviewerFromAuthRecord(null)).toEqual({ id: undefined, name: "" });
|
|
});
|
|
});
|