diff --git a/AGENTS.md b/AGENTS.md index 7cc0b45..0f2b307 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/collection-ai-checks.md b/docs/collection-ai-checks.md index c7bda38..518eab2 100644 --- a/docs/collection-ai-checks.md +++ b/docs/collection-ai-checks.md @@ -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…: 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. diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index 4426ece..22d0bd4 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -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 }); diff --git a/src/lib/server/collection-ai-service.test.ts b/src/lib/server/collection-ai-service.test.ts index e983663..6b5e7a8 100644 --- a/src/lib/server/collection-ai-service.test.ts +++ b/src/lib/server/collection-ai-service.test.ts @@ -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(); }); }); diff --git a/src/lib/server/collection-ai-service.ts b/src/lib/server/collection-ai-service.ts index d3bead0..2c27ada 100644 --- a/src/lib/server/collection-ai-service.ts +++ b/src/lib/server/collection-ai-service.ts @@ -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;