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:
parent
9ddb811afd
commit
d73fb6f0ab
14 changed files with 376 additions and 17 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue