From d73fb6f0ab62ff321b24f6db42aa5a612acbe159 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 19 Oct 2026 13:18:21 +0200 Subject: [PATCH] feat(collection-ai): read invoice discounts and set the discounted cost 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 --- AGENTS.md | 2 +- docs/collection-ai-checks.md | 20 ++- src/app/(app)/collection-confirm/page.tsx | 5 +- .../collection-confirm/ai-findings.tsx | 7 +- .../items-table-ai.test.tsx | 34 ++++++ .../collection-confirm/items-table.tsx | 12 +- src/lib/collection-ai-ticks.test.ts | 38 ++++++ src/lib/collection-ai-ticks.ts | 34 +++++- src/lib/server/collection-ai-prompt.ts | 1 + src/lib/server/collection-ai-read.test.ts | 46 +++++++ src/lib/server/collection-ai-read.ts | 26 ++++ src/lib/server/collection-ai-service.test.ts | 115 +++++++++++++++++- src/lib/server/collection-ai-service.ts | 45 ++++++- src/lib/types.ts | 8 ++ 14 files changed, 376 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5fe9484..9146f3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,6 +195,6 @@ 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, and an **empty** SKU when it is sure. It never overwrites a person's SKU, confirms a collection, or changes status. +- 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 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 104518f..6feeca8 100644 --- a/docs/collection-ai-checks.md +++ b/docs/collection-ai-checks.md @@ -24,8 +24,22 @@ 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, and an **empty SKU**, and only when it is sure. It never overwrites -a SKU a person set, never confirms a collection, and never changes status. +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, +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. 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. @@ -85,7 +99,7 @@ Collection checks, and stored in | Colour | Meaning | |---|---| -| Green | The document agrees with our line: our SKU equals the document code after normalising (uppercase, letters and digits only; this is also how a SKU the AI filled goes green), quantity equals what was collected and what was ordered, the AI is sure it is the same item, unit price within 1 cent | +| Green | The document agrees with our line: our SKU equals the document code after normalising (uppercase, letters and digits only; this is also how a SKU the AI filled goes green), quantity equals what was collected and what was ordered, the AI is sure it is the same item, unit price after any discount within 1 cent | | Amber | Close but worth a look: our SKU differs from the document code; collected quantity matches the document but the ordered quantity differs; the AI thinks it is the same item but is only "likely" sure | | Red | Mismatch or missing: no SKU on our line, quantity differs from the document, price differs or we have no cost, or the line is not on the document or the AI says it is a different product (all four red) | | Grey | Can't check, per tick: **SKU** when the document line shows no code; **Qty** when it shows no quantity; **Price** on a delivery note or when the document line shows no price; **all four** when the AI couldn't pair the document (see the AI findings box for the reason; **Check with AI** retries) | diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index bb9371b..9812af1 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -526,6 +526,7 @@ export default function CollectionConfirmPage() { files_read?: number; lines_checked?: number; skus_filled?: number; + costs_changed?: number; error?: string | null; }; if (!response.ok) { @@ -536,8 +537,10 @@ 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"}`, + `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` : ""), ); } await load({ silent: true }); diff --git a/src/components/collection-confirm/ai-findings.tsx b/src/components/collection-confirm/ai-findings.tsx index aed0112..f5211b6 100644 --- a/src/components/collection-confirm/ai-findings.tsx +++ b/src/components/collection-confirm/ai-findings.tsx @@ -3,6 +3,7 @@ import { Bot } from "lucide-react"; import type { CollectionDocLine } from "@/lib/types"; +import { docPriceLabel, docUnitCost, roundCents } from "@/lib/collection-ai-ticks"; const zar = new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }); @@ -67,9 +68,11 @@ export function DocumentLinesPanel({ {line.description} {line.quantity ?? "—"} - {line.unit_price == null ? "—" : zar.format(line.unit_price)} + {docUnitCost(line) == null ? "—" : zar.format(roundCents(docUnitCost(line)!))} + + + {[line.net_unit_price != null ? docPriceLabel(line) : "", line.price_note].filter(Boolean).join(" · ")} - {line.price_note} {matchedLineNos?.has(line.line_no) ? ( matched diff --git a/src/components/collection-confirm/items-table-ai.test.tsx b/src/components/collection-confirm/items-table-ai.test.tsx index b63456e..3d6e9a7 100644 --- a/src/components/collection-confirm/items-table-ai.test.tsx +++ b/src/components/collection-confirm/items-table-ai.test.tsx @@ -178,4 +178,38 @@ describe("ItemsTable AI layer", () => { 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( + , + ); + 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( + , + ); + expect(screen.getByTestId("ai-cost-changed-row-c")).toHaveTextContent(note); + }); }); diff --git a/src/components/collection-confirm/items-table.tsx b/src/components/collection-confirm/items-table.tsx index a99085f..2876b19 100644 --- a/src/components/collection-confirm/items-table.tsx +++ b/src/components/collection-confirm/items-table.tsx @@ -14,6 +14,7 @@ import { useState } from "react"; import { Input } from "@/components/ui/input"; import { computeLineTicks, + docPriceLabel, effectiveSku, showsAiFilledSku, type TickState, @@ -90,7 +91,7 @@ function AiTicks({ row }: { row: ConfirmRow }) { const t = computeLineTicks(row, check); const ordered = row.order_qty ? row.order_qty : (row.quantity ?? 0); const priceOurs = row.cost_price == null ? "none" : `R${row.cost_price.toFixed(2)}`; - const priceDoc = doc?.unit_price == null ? "none" : `R${doc.unit_price.toFixed(2)}`; + const priceDoc = doc ? docPriceLabel(doc) : "none"; const note = doc?.price_note ? ` (${doc.price_note})` : ""; return (
@@ -424,6 +425,15 @@ export function ItemsTable({ {posColumn ? : null}
) : null} + {aiColumn && row.ai_check?.cost_changed ? ( +

+ + {row.ai_check.cost_changed.note} +

+ ) : null} {ordered} diff --git a/src/lib/collection-ai-ticks.test.ts b/src/lib/collection-ai-ticks.test.ts index 11cb5b1..f5a80a7 100644 --- a/src/lib/collection-ai-ticks.test.ts +++ b/src/lib/collection-ai-ticks.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from "vitest"; import { computeLineTicks, + docUnitCost, + docPriceLabel, + roundCents, allGreen, showsAiFilledSku, normaliseCode, @@ -130,6 +133,20 @@ describe("computeLineTicks", () => { expect(computeLineTicks(row(), check({ same_item: "different" })).desc).toBe("red"); expect(computeLineTicks(row(), check({ same_item: null })).desc).toBe("red"); }); + it("price tick compares the cost with the price after discount", () => { + // HYCO I009753: R15.10 less 25% on the invoice totals = R11.325; cost R11.33. + const hyco = line({ unit_price: 15.1, doc_discount_percent: 25, net_unit_price: 11.325 }); + expect(computeLineTicks(row({ cost_price: 11.33 }), check({ doc_line: hyco })).price).toBe("green"); + // Paying the full printed price when there was a discount is wrong. + expect(computeLineTicks(row({ cost_price: 15.1 }), check({ doc_line: hyco })).price).toBe("red"); + // Swagelok 622831: R558.62 less 5% on the line = R530.689; cost R530.69. + const swage = line({ unit_price: 558.62, discount_percent: 5, net_unit_price: 530.689 }); + expect(computeLineTicks(row({ cost_price: 530.69 }), check({ doc_line: swage })).price).toBe("green"); + expect(docUnitCost(swage)).toBe(530.689); + expect(docUnitCost(line({ unit_price: 7.6 }))).toBe(7.6); + expect(docUnitCost(line({ unit_price: null }))).toBeNull(); + }); + it("price ticks", () => { expect(computeLineTicks(row(), check()).price).toBe("green"); expect( @@ -164,3 +181,24 @@ describe("showsAiFilledSku", () => { expect(showsAiFilledSku({ sku: "OUR-1" }, null)).toBe(false); }); }); + +describe("docPriceLabel", () => { + it("spells out the discount so the operator can follow the sum", () => { + expect(docPriceLabel(line({ unit_price: 15.1, doc_discount_percent: 25, net_unit_price: 11.325 }))).toBe( + "R15.10 less 25% invoice discount = R11.33", + ); + expect(docPriceLabel(line({ unit_price: 558.62, discount_percent: 5, net_unit_price: 530.689 }))).toBe( + "R558.62 less 5% = R530.69", + ); + expect(docPriceLabel(line({ unit_price: 7.6 }))).toBe("R7.60"); + expect(docPriceLabel(line({ unit_price: null }))).toBe("none"); + }); +}); + +describe("roundCents", () => { + it("rounds half-cents up despite float error", () => { + expect(roundCents(11.325)).toBe(11.33); + expect(roundCents(530.689)).toBe(530.69); + expect(roundCents(5.7)).toBe(5.7); + }); +}); diff --git a/src/lib/collection-ai-ticks.ts b/src/lib/collection-ai-ticks.ts index 4b1064d..360cdc1 100644 --- a/src/lib/collection-ai-ticks.ts +++ b/src/lib/collection-ai-ticks.ts @@ -1,4 +1,4 @@ -import type { CollectionAiCheck } from "@/lib/types"; +import type { CollectionAiCheck, CollectionDocLine } from "@/lib/types"; export type TickState = "green" | "amber" | "red" | "grey"; @@ -32,6 +32,33 @@ export function effectiveSku(row: Pick): string { return row.line_sku || row.sku || ""; } +/** + * What one unit actually cost on the document: the price after any line + * or whole-document discount (`net_unit_price`), else the printed price. + */ +export function docUnitCost(doc: Pick): number | null { + return doc.net_unit_price ?? doc.unit_price ?? null; +} + +/** Round to cents the way a person would: 11.325 → 11.33 (plain toFixed gives 11.32). */ +export function roundCents(n: number): number { + return Math.round(n * 100 + 1e-7) / 100; +} + +/** "R15.10 less 25% invoice discount = R11.33", or just "R7.60" without a discount. */ +export function docPriceLabel( + doc: Pick, +): string { + if (doc.unit_price == null) return "none"; + const printed = `R${doc.unit_price.toFixed(2)}`; + if (doc.net_unit_price == null) return printed; + const less = [ + doc.discount_percent ? `${doc.discount_percent}%` : "", + doc.doc_discount_percent ? `${doc.doc_discount_percent}% invoice discount` : "", + ].filter(Boolean); + return `${printed} less ${less.join(" and ")} = R${roundCents(doc.net_unit_price).toFixed(2)}`; +} + export function computeLineTicks(row: TickRow, check: CollectionAiCheck): LineTicks { const doc = check.doc_line; // Pairing failed: nothing was compared, so nothing can be judged. @@ -64,11 +91,12 @@ export function computeLineTicks(row: TickRow, check: CollectionAiCheck): LineTi // Price let price: TickState; - if (check.doc_type === "delivery_note" || doc.unit_price == null) price = "grey"; + const unitCost = docUnitCost(doc); + if (check.doc_type === "delivery_note" || unitCost == null) price = "grey"; else if (row.cost_price == null) price = "red"; else price = - Math.abs(doc.unit_price - row.cost_price) <= PRICE_TOLERANCE + FLOAT_EPSILON + Math.abs(unitCost - row.cost_price) <= PRICE_TOLERANCE + FLOAT_EPSILON ? "green" : "red"; diff --git a/src/lib/server/collection-ai-prompt.ts b/src/lib/server/collection-ai-prompt.ts index f280b7a..f1baad4 100644 --- a/src/lib/server/collection-ai-prompt.ts +++ b/src/lib/server/collection-ai-prompt.ts @@ -10,5 +10,6 @@ Rules: 3. po_refs: every customer order / PO number printed (often under "Customer PO", "Customer Order No" or "Your Ref"). Split values like "PO202610003CEM/PO202610009CAS" into separate entries. 4. lines: one entry per item line. code = the supplier's stock code; description as printed; quantity = quantity supplied; unit as printed. code_certain = true only if you can read every character of the code clearly; if any digit or letter is a guess, set false. 5. unit_price must be the price for ONE unit. If the document prices per 100 (or per 1000, per box of N), divide accordingly and say so in price_note (e.g. "printed 33.00 per 100"). +5b. Discounts: unit_price is the price as printed BEFORE any discount. If the line has its own discount (a "Disc %" column), put that number in discount_percent (5.00 → 5). If the discount is printed only in the totals for the whole document (e.g. "Discount 25.00%" above "Amount excl tax"), put it in doc_discount_percent. A 0.00% or blank discount is null. Never apply the discount to unit_price yourself. 6. to_follow: the "to follow" / back-order quantity if printed, else null. 7. Never guess a number you cannot read clearly: use null. The photo may be rotated or partly covered.`; diff --git a/src/lib/server/collection-ai-read.test.ts b/src/lib/server/collection-ai-read.test.ts index 42f1029..92ab878 100644 --- a/src/lib/server/collection-ai-read.test.ts +++ b/src/lib/server/collection-ai-read.test.ts @@ -116,6 +116,52 @@ describe("parseDocumentRead", () => { expect(r.lines.map((l) => l.code_certain)).toEqual([true, false, false]); }); + describe("discounts", () => { + it("takes a line's Disc % off its unit price (Swagelok 622831)", () => { + const r = parseDocumentRead({ + lines: [{ code: "SS-4-HLN-3.00", description: "nipple", quantity: 30, unit: "EA", unit_price: 558.62, discount_percent: 5, line_total: 15920.67 }], + }); + expect(r.lines[0]).toMatchObject({ unit_price: 558.62, discount_percent: 5, doc_discount_percent: null }); + expect(r.lines[0].net_unit_price).toBeCloseTo(530.689, 3); + }); + + it("applies a discount printed only in the invoice totals to every line (HYCO I009753)", () => { + const r = parseDocumentRead({ + doc_discount_percent: 25, + lines: [ + { code: "H1", description: "str", quantity: 500, unit: "EA", unit_price: 7.6 }, + { code: "H2", description: "45", quantity: 500, unit: "EA", unit_price: 15.1 }, + ], + }); + expect(r.lines.map((l) => l.doc_discount_percent)).toEqual([25, 25]); + expect(r.lines[0].net_unit_price).toBeCloseTo(5.7, 4); + expect(r.lines[1].net_unit_price).toBeCloseTo(11.325, 4); + }); + + it("stacks a line discount and an invoice discount", () => { + const r = parseDocumentRead({ + doc_discount_percent: 10, + lines: [{ code: "A", description: "a", quantity: 1, unit: "", unit_price: 100, discount_percent: 10 }], + }); + expect(r.lines[0].net_unit_price).toBeCloseTo(81, 4); + }); + + it("has no net price without a discount, and ignores impossible percentages", () => { + const r = parseDocumentRead({ + doc_discount_percent: 0, + lines: [ + { code: "A", description: "a", quantity: 1, unit: "", unit_price: 10 }, + { code: "B", description: "b", quantity: 1, unit: "", unit_price: 10, discount_percent: 100 }, + { code: "C", description: "c", quantity: 1, unit: "", unit_price: 10, discount_percent: -5 }, + { code: "D", description: "d", quantity: 1, unit: "", unit_price: null, discount_percent: 5 }, + ], + }); + expect(r.lines.map((l) => l.net_unit_price)).toEqual([null, null, null, null]); + expect(r.lines.map((l) => l.discount_percent)).toEqual([null, null, null, 5]); + expect(r.lines[0].doc_discount_percent).toBeNull(); + }); + }); + it("is defensive about bad input", () => { const r = parseDocumentRead({ doc_type: "receipt", diff --git a/src/lib/server/collection-ai-read.ts b/src/lib/server/collection-ai-read.ts index b7c76f0..2912509 100644 --- a/src/lib/server/collection-ai-read.ts +++ b/src/lib/server/collection-ai-read.ts @@ -55,10 +55,19 @@ export const SUBMIT_DOCUMENT_READ_TOOL = { line_total: { type: ["number", "null"] }, to_follow: { type: ["number", "null"], description: "Back-order quantity, else null." }, price_note: { type: "string", description: "e.g. \"printed 33.00 per 100\"." }, + discount_percent: { + type: ["number", "null"], + description: "This line's discount % (a \"Disc %\" column), e.g. 5 for 5.00. null when none.", + }, }, required: ["code", "description", "quantity", "unit", "unit_price"], }, }, + doc_discount_percent: { + type: ["number", "null"], + description: + "A discount % printed in the document totals that applies to the whole document (e.g. \"Discount 25.00%\"), else null. 0 or a blank discount is null.", + }, notes: { type: "array", items: { type: "string" }, @@ -93,6 +102,19 @@ function strList(v: unknown): string[] { return v.map(str).filter((s) => s !== ""); } +/** A usable discount: strictly between 0 and 100 percent. */ +function pctOrNull(v: unknown): number | null { + const n = numOrNull(v); + return n !== null && n > 0 && n < 100 ? n : null; +} + +/** Unit price after a line discount and a whole-document discount, or null when neither applies. */ +function netUnitPrice(unit: number | null, linePct: number | null, docPct: number | null): number | null { + if (unit === null || (linePct === null && docPct === null)) return null; + const net = unit * (1 - (linePct ?? 0) / 100) * (1 - (docPct ?? 0) / 100); + return Math.round(net * 10000) / 10000; +} + const DAY_MS = 86_400_000; function isoIfValid(y: number, m: number, d: number): string { @@ -134,6 +156,7 @@ export function parseDocumentRead(raw: unknown, now?: Date): Omit { const l = (item && typeof item === "object" ? item : {}) as Record; // Always 1..n in document order: the model's own line_no can repeat, @@ -149,6 +172,9 @@ export function parseDocumentRead(raw: unknown, now?: Date): Omit ({ })); const skuCalls: { id: string; sku: string; actor: { id: string; name: string }; audit: Actor | null }[] = []; +const costCalls: { id: string; cost_price: number; actor: { id: string; name: string }; audit: Actor | null }[] = []; vi.mock("@/lib/server/collection-confirm-service", () => ({ + updateOrderItemCost: async (id: string, args: { cost_price: number; actor: { id: string; name: string } }) => { + costCalls.push({ id, cost_price: args.cost_price, actor: args.actor, audit: currentActor }); + const r = table("order_items").get(id)!; + r.cost_price = args.cost_price; + writes.push({ op: "update", coll: "order_items", id, data: { cost_price: args.cost_price }, actor: currentActor }); + return structuredClone(r); + }, updateOrderItemSku: async (id: string, args: { sku: string; actor: { id: string; name: string } }) => { if (failSkuFill) throw new Error("sku boom"); skuCalls.push({ id, sku: args.sku, actor: args.actor, audit: currentActor }); @@ -231,10 +239,15 @@ vi.mock("@/lib/server/collection-confirm-service", () => ({ return structuredClone(r); }, })); -const stockSkuCalls: { id: string; patch: Record; audit: Actor | null }[] = []; +const stockSkuCalls: { id: string; patch: Record; force?: boolean; audit: Actor | null }[] = []; vi.mock("@/lib/server/stock-purchase-items-service", () => ({ - updateStockPurchaseItem: async (input: { id: string; patch: Record }) => { - stockSkuCalls.push({ id: input.id, patch: input.patch, audit: currentActor }); + updateStockPurchaseItem: async (input: { id: string; patch: Record; force_overwrite?: boolean }) => { + stockSkuCalls.push({ + id: input.id, + patch: input.patch, + ...(input.force_overwrite ? { force: true } : {}), + audit: currentActor, + }); const r = table("stock_purchase_items").get(input.id)!; Object.assign(r, input.patch); writes.push({ op: "update", coll: "stock_purchase_items", id: input.id, data: input.patch, actor: currentActor }); @@ -468,6 +481,7 @@ beforeEach(() => { writes.length = 0; events.length = 0; skuCalls.length = 0; + costCalls.length = 0; stockSkuCalls.length = 0; currentActor = null; orderItemFields = ["description", "ai_check"]; @@ -998,7 +1012,7 @@ describe("checkCollectionCard", () => { expect(aiCheck("order_items", "l1")).toBeNull(); }); - it("never writes cost, quantity or status", async () => { + it("never writes cost, quantity or status when the document has no discount", async () => { seedNss(); const { deps } = makeDeps(); await checkCollectionCard(PO_CEM, { deps }); @@ -1009,6 +1023,99 @@ describe("checkCollectionCard", () => { }); }); +describe("checkCollectionCard discounts", () => { + // HYCO I009753: lines printed at full price, "Discount 25.00%" in the totals. + const hycoRead = (over: Partial = {}) => + nssRead({ + lines: [ + docLine({ + line_no: 1, + code: "H2 1/8BSP", + description: "1/8BSP 45 GREASE NIPPLE", + quantity: 4, + unit_price: 15.1, + doc_discount_percent: 25, + net_unit_price: 11.325, + ...over, + }), + ], + }); + + it("changes a full-price cost to the discounted price and says why", 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 }); + + 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); + }); + + it("leaves a cost that already matches the discounted price alone", async () => { + seedNss(); + table("order_items").get("l1")!.cost_price = 11.33; + const { deps } = makeDeps({ readDocument: vi.fn(async () => hycoRead()) }); + + const r = 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 () => { + seedNss(); + table("order_items").get("l1")!.cost_price = 99; + await checkCollectionCard(PO_CEM, { deps: makeDeps().deps }); + expect(costCalls).toEqual([]); + + 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 })), + }); + await checkCollectionCard(PO_CEM, { deps }); + expect(costCalls).toEqual([]); + }); + + it("stock lines: overwrites the cost through updateStockPurchaseItem", 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" } }; + const { deps } = makeDeps({ + readDocument: vi.fn(async () => ({ ...hycoRead(), po_refs: ["PO26101501STK"] })), + }); + + 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); + }); +}); + // ─── runCollectionAiTick ──────────────────────────────────────────── /** One card per PO with one collected line carrying one photo. */ diff --git a/src/lib/server/collection-ai-service.ts b/src/lib/server/collection-ai-service.ts index bb73eca..8e0d0ff 100644 --- a/src/lib/server/collection-ai-service.ts +++ b/src/lib/server/collection-ai-service.ts @@ -23,7 +23,8 @@ import { recordCollectionAiRun, } from "@/lib/server/ai-settings-service"; import { getOrderItemsCollectionName } from "@/lib/server/orders-service"; -import { updateOrderItemSku } from "@/lib/server/collection-confirm-service"; +import { 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 { readDocumentImage, type DocumentReadResult } from "@/lib/server/collection-ai-read"; @@ -66,11 +67,15 @@ export interface CardCheckResult { files_read: number; 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"; @@ -526,7 +531,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, error: "" }; + const result: CardCheckResult = { po_number: poNumber, files_read: 0, lines_checked: 0, skus_filled: 0, costs_changed: 0, error: "" }; const fail = (msg: string) => { if (!result.error) result.error = msg; }; @@ -846,6 +851,41 @@ 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. + const costNow = typeof fresh.cost_price === "number" ? fresh.cost_price : null; + let 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; @@ -867,6 +907,7 @@ async function checkCard( checked_at: now.toISOString(), model: chosen.model, pair_failed: pairFailed, + ...(costChanged ? { cost_changed: costChanged } : {}), }; await pb.collection(line.collection).update(line.id, { ai_check: check }); result.lines_checked += 1; diff --git a/src/lib/types.ts b/src/lib/types.ts index afce876..d3defee 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -568,6 +568,12 @@ export interface CollectionDocLine { price_note: string; /** The AI read every character of `code` clearly. Only then may it fill a SKU. */ code_certain?: boolean; + /** This line's own discount ("Disc %" column), 0 < % < 100, else null. */ + discount_percent?: number | null; + /** Discount printed only in the document totals, applied to every line. */ + doc_discount_percent?: number | null; + /** `unit_price` after both discounts; null when no discount applies. */ + net_unit_price?: number | null; } export interface CollectionDocRead { @@ -606,6 +612,8 @@ export interface CollectionAiCheck { model: string; /** The AI couldn't pair the document(s) covering this line: ticks are grey. */ pair_failed?: boolean; + /** The AI set the cost to the document's price after discount. */ + cost_changed?: { from: number | null; to: number; note: string }; } export interface CollectionAiRunSummary {