fix(collection-ai): the AI never changes a price

On PO202610005TEL the AI read a 15% discount that was not on the invoice
and cut a line's cost from R146.47 to R124.50. It no longer writes a cost
to order or stock lines. A cost that differs from the invoice's discounted
price shows as a red Price tick, which also keeps the card out of
auto-push, so a person settles it. Earlier cost changes stay visible on
the line. Tests now assert no cost is ever written; runbook and AGENTS.md
updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-30 13:56:16 +02:00
parent 18cdc2976e
commit 25466d4e64
5 changed files with 35 additions and 93 deletions

View file

@ -224,7 +224,7 @@ Runbook: `docs/collection-ai-checks.md`. Files: `src/lib/server/collection-ai-{r
`.../ai/check` and `.../add-to-pos` (browser session), MCP `collection_confirm.ai_check`. Settings live in Settings → AI → Collection checks.
- **Manual first:** the Add to POS button and the Check with AI button are manual; the tick only reads and checks, nothing else.
- The AI writes **only** `ai_check`, `collection_doc_reads` rows, an **empty** SKU when it is sure, and — only when a "sure" tax-invoice line shows a discount — the cost set to the price after discount (via `updateOrderItemCost`, with a note). It never overwrites a person's SKU, confirms a collection, or changes status.
- The AI writes **only** `ai_check`, `collection_doc_reads` rows and an **empty** SKU when it is sure. It **never writes a price** (owner's rule, 2026-10-30: it misread a discount that wasn't on the invoice) — a differing cost is a red Price tick for a person. It never overwrites a person's SKU, confirms a collection, or changes status.
- **Auto-push** (`collection-auto-push-service.ts`, runbook section "Auto-push"): a purple card is confirmed by the tick itself and logged to `ai_settings.collection_auto_push_log`; off by default, needs `npm run pb:migrate:collection-auto-push`. Change what counts as pushable in `planAutoPush`, never by calling confirm elsewhere.
- The service refuses to run until `ai_check` exists (PB drops unknown fields silently); `ai_check` is in `IGNORED_FIELDS` of the audit hook.
- Manual live steps (like select values and pb_hooks): run `npm run pb:migrate:collection-ai` against the live PB, redeploy `pocketbase/pb_hooks/audit_log_utils.js`, deploy, activate the n8n workflow "WFDASH Collection check tick" (every 5 min), then switch Collection checks on in Settings → AI. See the runbook.

View file

@ -24,22 +24,16 @@ per line (SKU, quantity, description, price), plus a POS column and an
lines added, failed and still needing a SKU.
What the AI writes: only `ai_check` on the order/stock line, the document
reads, an **empty SKU**, and a **discounted cost** — each only when it is
sure. It never overwrites a SKU a person set, never confirms a collection,
reads and an **empty SKU** when it is sure. It never writes a price. It never overwrites a SKU a person set, never confirms a collection,
and never changes status.
Discounts (added 2026-10-19): the AI reads a line's own "Disc %" column
(e.g. Swagelok) and a discount printed only in the invoice totals
(e.g. HYCO "Discount 25.00%"), and the Price tick compares our cost with
the price **after** discount. When a tax-invoice line it is "sure" about
has a discount and our cost isn't the discounted price (to the cent), the
AI sets the cost to it — through the same path as the cost pencil, so a
`cost` note is added and the audit log records it — and shows "Cost
changed from R… to R…: <invoice> shows R… less …%" in purple under the
line. Without a discount, from a delivery note, or on a "likely" match it
never touches the cost. Reads saved before this change have no discount
fields: delete that document's `collection_doc_reads` row and run
**Check with AI** to re-read it.
Discounts: the AI reads a line's own "Disc %" column and a discount printed
only in the invoice totals, and the Price tick compares our cost with the
price **after** discount. It **never changes a cost** (changed 2026-10-30):
it once read a 15% discount that wasn't on the invoice (PO202610005TEL) and
cut a cost. A cost that differs from the discounted price is a red Price
tick, which also keeps the card out of auto-push, so a person settles it.
Cost changes the AI made before then still show in purple under the line.
On order lines the SKU fill is audited as an ordinary `sku` change; stock
purchase lines (`stock_purchase_items`) are not covered by the audit hook,
so a SKU fill there is not in the audit log.

View file

@ -495,7 +495,6 @@ export default function CollectionConfirmPage() {
files_read?: number;
lines_checked?: number;
skus_filled?: number;
costs_changed?: number;
error?: string | null;
};
if (!response.ok) {
@ -506,10 +505,8 @@ export default function CollectionConfirmPage() {
} else {
const files = data.files_read ?? 0;
const lines = data.lines_checked ?? 0;
const costs = data.costs_changed ?? 0;
toast.success(
`AI read ${files} file${files === 1 ? "" : "s"} and checked ${lines} line${lines === 1 ? "" : "s"}` +
(costs > 0 ? `; set ${costs} cost${costs === 1 ? "" : "s"} to the discounted price` : ""),
`AI read ${files} file${files === 1 ? "" : "s"} and checked ${lines} line${lines === 1 ? "" : "s"}`,
);
}
await load({ silent: true });

View file

@ -1102,29 +1102,22 @@ describe("checkCollectionCard discounts", () => {
],
});
it("changes a full-price cost to the discounted price and says why", async () => {
// Owner's call (2026-10-30): the AI misread a 15% discount that wasn't on the
// invoice and cut a cost. It must never write a price, whatever the document says.
it("never changes a cost, even when the document shows a discount", async () => {
seedNss();
table("order_items").get("l1")!.cost_price = 15.1;
table("order_items").get("l1")!.sku = "H2 1/8BSP";
const { deps } = makeDeps({ readDocument: vi.fn(async () => hycoRead()) });
const r = await checkCollectionCard(PO_CEM, { deps });
await checkCollectionCard(PO_CEM, { deps });
expect(costCalls).toEqual([
{
id: "l1",
cost_price: 11.33,
actor: { id: "ai-collection-check", name: "AI collection check" },
audit: expect.objectContaining({ name: "AI collection check" }),
},
]);
expect(table("order_items").get("l1")?.cost_price).toBe(11.33);
expect(aiCheck("order_items", "l1")?.cost_changed).toEqual({
from: 15.1,
to: 11.33,
note: "Cost changed from R15.10 to R11.33: 5028256 shows R15.10 less 25% invoice discount = R11.33.",
});
expect(r.costs_changed).toBe(1);
expect(costCalls).toEqual([]);
expect(table("order_items").get("l1")?.cost_price).toBe(15.1);
expect(aiCheck("order_items", "l1")?.cost_changed).toBeUndefined();
// The disagreement shows as a red Price tick for a person to settle.
const c = aiCheck("order_items", "l1")!;
expect(computeLineTicks({ sku: "H2 1/8BSP", qty_collected: 4, cost_price: 15.1, order_qty: 4 }, c).price).toBe("red");
});
it("leaves a cost that already matches the discounted price alone", async () => {
@ -1132,35 +1125,26 @@ describe("checkCollectionCard discounts", () => {
table("order_items").get("l1")!.cost_price = 11.33;
const { deps } = makeDeps({ readDocument: vi.fn(async () => hycoRead()) });
const r = await checkCollectionCard(PO_CEM, { deps });
await checkCollectionCard(PO_CEM, { deps });
expect(costCalls).toEqual([]);
expect(aiCheck("order_items", "l1")?.cost_changed).toBeUndefined();
expect(r.costs_changed).toBe(0);
});
it("doesn't touch the cost without a discount, on a 'likely' match, or on a delivery note", async () => {
it("doesn't touch the cost on a 'likely' match or from a delivery note", async () => {
seedNss();
table("order_items").get("l1")!.cost_price = 99;
await checkCollectionCard(PO_CEM, { deps: makeDeps().deps });
expect(costCalls).toEqual([]);
table("order_items").get("l1")!.cost_price = 15.1;
pairPlan[1] = { id: "l1", same: "likely" };
await checkCollectionCard(PO_CEM, { deps: makeDeps({ readDocument: vi.fn(async () => hycoRead()) }).deps });
expect(costCalls).toEqual([]);
});
it("doesn't change the cost from a delivery note", async () => {
seedNss();
table("order_items").get("l1")!.cost_price = 15.1;
const { deps } = makeDeps({
readDocument: vi.fn(async () => ({ ...hycoRead(), doc_type: "delivery_note" as const })),
pairPlan[1] = { id: "l1", same: "sure" };
await checkCollectionCard(PO_CEM, {
deps: makeDeps({ readDocument: vi.fn(async () => ({ ...hycoRead(), doc_type: "delivery_note" as const })) }).deps,
});
await checkCollectionCard(PO_CEM, { deps });
expect(costCalls).toEqual([]);
});
it("stock lines: overwrites the cost through updateStockPurchaseItem", async () => {
it("stock lines: never changes the cost either", async () => {
addStockPurchase("sp1", "PO26101501STK", ["stock_inv.jpg"]);
addStockLine("s1", "sp1", { description: "grease nipple", cost_price: 15.1, sku: "H2 1/8BSP" });
pairPlan = { 1: { id: "s1", same: "sure" } };
@ -1170,10 +1154,8 @@ describe("checkCollectionCard discounts", () => {
await checkCollectionCard("PO26101501STK", { deps });
expect(stockSkuCalls).toEqual([
{ id: "s1", patch: { cost_price: 11.33 }, force: true, audit: expect.objectContaining({ name: "AI collection check" }) },
]);
expect(aiCheck("stock_purchase_items", "s1")?.cost_changed?.to).toBe(11.33);
expect(stockSkuCalls.filter((c) => "cost_price" in c.patch)).toEqual([]);
expect(aiCheck("stock_purchase_items", "s1")?.cost_changed).toBeUndefined();
});
});

View file

@ -26,10 +26,8 @@ import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import {
STOCK_ON_PAGE_FILTER,
stockLineOnPage,
updateOrderItemCost,
updateOrderItemSku,
} from "@/lib/server/collection-confirm-service";
import { docPriceLabel, roundCents } from "@/lib/collection-ai-ticks";
import { updateStockPurchaseItem } from "@/lib/server/stock-purchase-items-service";
import { normalizePoReference } from "@/lib/server/stock-purchase-invoice-matching-service";
import { heicToJpeg, isHeic } from "@/lib/server/heic-to-jpeg";
@ -75,14 +73,12 @@ export interface CardCheckResult {
lines_checked: number;
skus_filled: number;
/** Lines whose cost the AI set to the document's price after discount. */
costs_changed: number;
error: string;
}
const ACTOR_NAME = "AI collection check";
const SKU_ACTOR_ID = "ai-collection-check";
/** A cost within a cent of the document's discounted price is left alone. */
const COST_TOLERANCE = 0.01 + 1e-9;
const READS = "collection_doc_reads";
const STOCK_LINES = "stock_purchase_items";
const STOCK_POS = "stock_purchases";
@ -547,7 +543,7 @@ async function checkCard(
const nowFn = deps.now ?? (() => new Date());
const maxFiles = opts.maxFiles ?? TICK_MAX_FILES;
const actorName = opts.actorName ? `${ACTOR_NAME} (run by ${opts.actorName})` : ACTOR_NAME;
const result: CardCheckResult = { po_number: poNumber, files_read: 0, lines_checked: 0, skus_filled: 0, costs_changed: 0, error: "" };
const result: CardCheckResult = { po_number: poNumber, files_read: 0, lines_checked: 0, skus_filled: 0, error: "" };
const fail = (msg: string) => {
if (!result.error) result.error = msg;
};
@ -871,40 +867,13 @@ async function checkCard(
}
const docNo = chosen.doc.document_no || "the document";
// Discount on the invoice (owner's call, 2026-10-19): when a "sure"
// tax-invoice line has a discount and our cost isn't the price after
// discount, set the cost to it and say why. Without a discount the
// AI still never touches the cost.
// The AI never writes a price (owner's call, 2026-10-30: it misread a 15%
// discount that wasn't on the invoice and cut a cost). A cost that
// differs from the invoice shows as a red Price tick for a person to fix.
// A cost change the AI made before then stays visible while the cost is still its value.
const costNow = typeof fresh.cost_price === "number" ? fresh.cost_price : null;
let costChanged =
const costChanged =
previous?.cost_changed && costNow === previous.cost_changed.to ? previous.cost_changed : undefined;
const docLine = chosen.docLine;
if (
docLine?.net_unit_price != null &&
docLine.net_unit_price > 0 &&
chosen.decision?.same_item === "sure" &&
chosen.doc.doc_type === "tax_invoice"
) {
const to = roundCents(docLine.net_unit_price);
if (costNow === null || Math.abs(costNow - to) > COST_TOLERANCE) {
try {
if (line.kind === "customer") {
await updateOrderItemCost(line.id, { cost_price: to, actor: { id: SKU_ACTOR_ID, name: actorName } });
} else {
await updateStockPurchaseItem({ id: line.id, patch: { cost_price: to }, force_overwrite: true });
}
const from = costNow === null ? "none" : `R${costNow.toFixed(2)}`;
costChanged = {
from: costNow,
to,
note: `Cost changed from ${from} to R${to.toFixed(2)}: ${docNo} shows ${docPriceLabel(docLine)}.`,
};
result.costs_changed += 1;
} catch (e) {
fail(`cost update failed for line ${line.id}: ${errMessage(e)}`);
}
}
}
// No pairing and no successfully paired document covers this line,
// but one failed: the AI couldn't compare it (grey, not red).
const pairFailed = !chosen.docLine && failureFindings.length > 0 && covering.length === 0;