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:
parent
18cdc2976e
commit
25466d4e64
5 changed files with 35 additions and 93 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue