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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-19 13:18:21 +02:00
parent 9ddb811afd
commit d73fb6f0ab
14 changed files with 376 additions and 17 deletions

View file

@ -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.

View file

@ -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…: <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.
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) |

View file

@ -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 });

View file

@ -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({
<td className="pr-2">{line.description}</td>
<td className="pr-2 text-right tabular-nums">{line.quantity ?? "—"}</td>
<td className="pr-2 text-right tabular-nums">
{line.unit_price == null ? "—" : zar.format(line.unit_price)}
{docUnitCost(line) == null ? "—" : zar.format(roundCents(docUnitCost(line)!))}
</td>
<td className="pr-2 text-muted-foreground">
{[line.net_unit_price != null ? docPriceLabel(line) : "", line.price_note].filter(Boolean).join(" · ")}
</td>
<td className="pr-2 text-muted-foreground">{line.price_note}</td>
<td>
{matchedLineNos?.has(line.line_no) ? (
<span className="font-medium text-success">matched</span>

View file

@ -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(
<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);
});
});

View file

@ -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 (
<div className="flex items-center gap-2" data-testid={`ai-ticks-${row.id}`}>
@ -424,6 +425,15 @@ export function ItemsTable({
{posColumn ? <PosStatus row={row} onAddToPos={onAddToPos} /> : null}
</div>
) : null}
{aiColumn && row.ai_check?.cost_changed ? (
<p
className="mt-1 flex items-start gap-1 text-xs text-ai"
data-testid={`ai-cost-changed-${row.id}`}
>
<Bot className="mt-0.5 size-3 shrink-0" aria-hidden />
<span>{row.ai_check.cost_changed.note}</span>
</p>
) : null}
</td>
<td className="px-3 py-2 text-right align-top tabular-nums">{ordered}</td>
<td className="border-l border-border/60 px-3 py-2 text-right align-top tabular-nums">

View file

@ -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);
});
});

View file

@ -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<TickRow, "sku" | "line_sku">): 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<CollectionDocLine, "unit_price" | "net_unit_price">): 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<CollectionDocLine, "unit_price" | "net_unit_price" | "discount_percent" | "doc_discount_percent">,
): 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";

View file

@ -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.`;

View file

@ -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",

View file

@ -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<DocumentReadRe
if (!date && /^\d{4}-\d{2}-\d{2}$/.test(isoDate)) date = isoIfValid(+isoDate.slice(0, 4), +isoDate.slice(5, 7), +isoDate.slice(8, 10));
if (now) date = plausibleDate(date, now);
const rawLines = Array.isArray(r.lines) ? r.lines : [];
const docPct = pctOrNull(r.doc_discount_percent);
const lines: CollectionDocLine[] = rawLines.map((item, i) => {
const l = (item && typeof item === "object" ? item : {}) as Record<string, unknown>;
// 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<DocumentReadRe
to_follow: numOrNull(l.to_follow),
price_note: str(l.price_note),
code_certain: l.code_certain === true,
discount_percent: pctOrNull(l.discount_percent),
doc_discount_percent: docPct,
net_unit_price: netUnitPrice(numOrNull(l.unit_price), pctOrNull(l.discount_percent), docPct),
};
});
return {

View file

@ -221,7 +221,15 @@ vi.mock("@/lib/server/audit-context", () => ({
}));
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<string, unknown>; audit: Actor | null }[] = [];
const stockSkuCalls: { id: string; patch: Record<string, unknown>; force?: boolean; audit: Actor | null }[] = [];
vi.mock("@/lib/server/stock-purchase-items-service", () => ({
updateStockPurchaseItem: async (input: { id: string; patch: Record<string, unknown> }) => {
stockSkuCalls.push({ id: input.id, patch: input.patch, audit: currentActor });
updateStockPurchaseItem: async (input: { id: string; patch: Record<string, unknown>; 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<CollectionDocLine> = {}) =>
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. */

View file

@ -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;

View file

@ -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 {