HYCO (I009753) prints full prices on the lines and "Discount 25.00%" in
the totals; Swagelok (622831) has a "Disc %" column on each line. The AI
compared our cost (already the discounted price) with the printed price,
so the Price tick went red on both.
- The read now captures a line's discount_percent and a whole-document
doc_discount_percent (prompt rule 5b) and stores net_unit_price on each
line (both discounts stacked).
- The Price tick compares the cost with the price after discount; the
tooltip and the document-lines panel spell it out
("R15.10 less 25% invoice discount = R11.33").
- Owner's call: when a "sure" tax-invoice line has a discount and our
cost isn't the discounted price, the AI sets the cost to it (customer
lines through updateOrderItemCost, which adds a cost note; stock lines
through updateStockPurchaseItem) and records why in ai_check.cost_changed,
shown in purple under the line. No discount, a delivery note or a
"likely" match: the cost is never touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
9.3 KiB
TypeScript
215 lines
9.3 KiB
TypeScript
import { render, screen, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
|
|
import { makeAiRow, makeCheck } from "./ai-fixtures";
|
|
import { ItemsTable } from "./items-table";
|
|
|
|
const plain: ConfirmRow = {
|
|
id: "row-p",
|
|
sku: "SK-9",
|
|
description: "Plain",
|
|
order_qty: 1,
|
|
qty_collected: 1,
|
|
cost_price: 1,
|
|
surplus_qty: 0,
|
|
};
|
|
|
|
const noop = vi.fn();
|
|
|
|
describe("ItemsTable AI layer", () => {
|
|
it("renders the original six headers and no AI/tick/robot UI without AI data", () => {
|
|
render(<ItemsTable rows={[plain]} onCostChange={noop} onSkuChange={noop} />);
|
|
const headers = screen.getAllByRole("columnheader").map((h) => h.textContent);
|
|
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus"]);
|
|
expect(screen.queryByLabelText(/^SKU: /)).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId("ai-sku-robot-row-p")).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/add to pos/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps the original six columns and shows POS status under the description when no row has ai_check", () => {
|
|
const { container } = render(
|
|
<ItemsTable rows={[{ ...plain, in_pos: false }]} onCostChange={noop} onSkuChange={noop} onAddToPos={noop} />,
|
|
);
|
|
const headers = screen.getAllByRole("columnheader").map((h) => h.textContent);
|
|
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus"]);
|
|
expect(container.querySelector("table")?.className).toContain("min-w-[640px]");
|
|
expect(container.querySelectorAll("col")).toHaveLength(6);
|
|
expect(screen.getByText(/tap the pencil/i).closest("td")).toHaveAttribute("colspan", "6");
|
|
const extra = screen.getByTestId(`ai-pos-row-${plain.id}`);
|
|
expect(extra.closest("td")?.textContent).toContain(plain.description ?? "");
|
|
expect(extra.textContent).toContain("Add to POS");
|
|
expect(container.querySelector('[data-testid^="ai-ticks-"]')).toBeNull();
|
|
});
|
|
|
|
it("shows the ticks under the description (no extra columns) when a row has ai_check", () => {
|
|
const row = makeAiRow();
|
|
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
|
|
const headers = screen.getAllByRole("columnheader").map((h) => h.textContent);
|
|
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus"]);
|
|
const ticks = screen.getByTestId(`ai-ticks-${row.id}`);
|
|
expect(ticks.closest("td")?.textContent).toContain(row.description ?? "");
|
|
expect(ticks.textContent).toContain("SKU");
|
|
expect(ticks.textContent).toContain("Price");
|
|
});
|
|
|
|
it("renders four ticks with aria-labels from computeLineTicks and ours-vs-document titles", () => {
|
|
const row = makeAiRow({
|
|
sku: "SK-001",
|
|
qty_collected: 9,
|
|
cost_price: 45,
|
|
ai_check: makeCheck({
|
|
same_item: "likely",
|
|
doc_line: {
|
|
line_no: 1,
|
|
code: "SK-001",
|
|
description: "Totally different thing",
|
|
quantity: 10,
|
|
unit: "ea",
|
|
unit_price: null,
|
|
line_total: null,
|
|
to_follow: null,
|
|
price_note: "",
|
|
},
|
|
}),
|
|
});
|
|
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
|
|
expect(screen.getByLabelText(/^SKU: matches/)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/^Qty: doesn't match/)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/^Desc: have a look/)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/^Price: can't check/)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/^Qty: doesn't match/)).toHaveAttribute(
|
|
"title",
|
|
expect.stringContaining("9"),
|
|
);
|
|
expect(screen.getByLabelText(/^Price: can't check — Ours: R45.00/)).toBeInTheDocument();
|
|
const desc = screen.getByLabelText(/^Desc: have a look/).getAttribute("title") ?? "";
|
|
expect(desc).toContain("Item 1");
|
|
expect(desc).toContain("Totally different thing");
|
|
});
|
|
|
|
it("a 'different' verdict is all red even when the code matches", () => {
|
|
const row = makeAiRow({ ai_check: makeCheck({ same_item: "different" }) });
|
|
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
|
|
expect(screen.getByLabelText(/^SKU: doesn't match/)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/^Desc: doesn't match/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the amber label", () => {
|
|
const row = makeAiRow({ ai_check: makeCheck({ same_item: "likely" }) });
|
|
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
|
|
expect(screen.getByLabelText(/^Desc: have a look/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the robot next to the SKU only while the SKU equals sku_filled_value", () => {
|
|
const filled = makeAiRow({ id: "a", sku: "SK-001", ai_check: makeCheck({ sku_filled_value: "SK-001" }) });
|
|
const human = makeAiRow({ id: "b", sku: "HUMAN", ai_check: makeCheck({ sku_filled_value: "SK-001" }) });
|
|
render(<ItemsTable rows={[filled, human]} onCostChange={noop} onSkuChange={noop} />);
|
|
expect(screen.getByTestId("ai-sku-robot-a")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("ai-sku-robot-b")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("stock rows: shows, ticks and robot-marks the line's own SKU (line_sku)", () => {
|
|
const stock = makeAiRow({
|
|
id: "s1",
|
|
source: "stock",
|
|
sku: undefined,
|
|
line_sku: "SK-001",
|
|
ai_check: makeCheck({ sku_filled_value: "SK-001" }),
|
|
});
|
|
render(<ItemsTable rows={[stock]} onCostChange={noop} onSkuChange={noop} />);
|
|
const rowEl = screen.getByTestId("ai-ticks-s1").closest("tr")!;
|
|
expect(within(rowEl).getByText("SK-001")).toBeInTheDocument();
|
|
expect(screen.getByTestId("ai-sku-robot-s1")).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/^SKU: matches/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows In POS with a stock link when in_pos", () => {
|
|
const row = makeAiRow({ in_pos: true, pos_stock_item_id: "stock-77" });
|
|
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} onAddToPos={noop} />);
|
|
const link = screen.getByRole("link", { name: /in pos/i });
|
|
expect(link).toHaveAttribute("href", "/stock/stock-77");
|
|
expect(screen.queryByRole("button", { name: /add to pos/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onAddToPos with the row id when not in POS", async () => {
|
|
const onAdd = vi.fn();
|
|
render(<ItemsTable rows={[makeAiRow()]} onCostChange={noop} onSkuChange={noop} onAddToPos={onAdd} />);
|
|
await userEvent.click(screen.getByRole("button", { name: /add to pos/i }));
|
|
expect(onAdd).toHaveBeenCalledWith("row-1");
|
|
});
|
|
|
|
it("disables Add to POS on a row without a SKU (Set a SKU first)", async () => {
|
|
const onAdd = vi.fn();
|
|
render(
|
|
<ItemsTable rows={[makeAiRow({ sku: "" })]} onCostChange={noop} onSkuChange={noop} onAddToPos={onAdd} />,
|
|
);
|
|
const button = screen.getByRole("button", { name: /add to pos/i });
|
|
expect(button).toBeDisabled();
|
|
expect(button).toHaveAttribute("title", "Set a SKU first");
|
|
await userEvent.click(button);
|
|
expect(onAdd).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders no tick icons for a row without ai_check inside an AI table", () => {
|
|
render(
|
|
<ItemsTable
|
|
rows={[makeAiRow(), { ...plain, in_pos: false }]}
|
|
onCostChange={noop}
|
|
onSkuChange={noop}
|
|
/>,
|
|
);
|
|
const rowEl = screen.getByText("Plain").closest("tr")!;
|
|
expect(within(rowEl).queryByLabelText(/^SKU: /)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("tints the lines the AI checked purple and leaves the others plain", () => {
|
|
render(
|
|
<ItemsTable
|
|
rows={[makeAiRow({ id: "row-a" }), makeAiRow({ id: "row-f", ai_check: makeCheck({ pair_failed: true }) }), plain]}
|
|
onCostChange={noop}
|
|
onSkuChange={noop}
|
|
/>,
|
|
);
|
|
const checked = screen.getByTestId("items-row-row-a");
|
|
expect(checked).toHaveAttribute("data-ai-checked", "true");
|
|
expect(checked.className).toMatch(/bg-ai\//);
|
|
expect(screen.getByTestId("items-row-row-f")).toHaveAttribute("data-ai-checked", "false");
|
|
expect(screen.getByTestId("items-row-row-p").className).not.toMatch(/bg-ai\//);
|
|
});
|
|
|
|
it("shows the discounted document price in the Price tick", () => {
|
|
render(
|
|
<ItemsTable
|
|
rows={[
|
|
makeAiRow({
|
|
id: "row-d",
|
|
cost_price: 11.33,
|
|
ai_check: makeCheck({
|
|
doc_line: { ...makeCheck().doc_line!, unit_price: 15.1, doc_discount_percent: 25, net_unit_price: 11.325 },
|
|
}),
|
|
}),
|
|
]}
|
|
onCostChange={noop}
|
|
onSkuChange={noop}
|
|
/>,
|
|
);
|
|
expect(screen.getByLabelText(/^Price: /)).toHaveAccessibleName(
|
|
expect.stringContaining("Document: R15.10 less 25% invoice discount = R11.33"),
|
|
);
|
|
expect(screen.getByLabelText(/^Price: /)).toHaveAttribute("data-state", "green");
|
|
});
|
|
|
|
it("shows the AI's cost change note under the line", () => {
|
|
const note = "Cost changed from R15.10 to R11.33: I009753 shows R15.10 less 25% invoice discount = R11.33.";
|
|
render(
|
|
<ItemsTable
|
|
rows={[makeAiRow({ id: "row-c", cost_price: 11.33, ai_check: makeCheck({ cost_changed: { from: 15.1, to: 11.33, note } }) })]}
|
|
onCostChange={noop}
|
|
onSkuChange={noop}
|
|
/>,
|
|
);
|
|
expect(screen.getByTestId("ai-cost-changed-row-c")).toHaveTextContent(note);
|
|
});
|
|
});
|