feat(dn): per-line readiness and price tolerance for inbox 3

Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
DEls 2026-10-26 15:38:51 +02:00
parent c36fc5fb82
commit 2ccb6efadc
2 changed files with 403 additions and 0 deletions

View file

@ -0,0 +1,196 @@
import { describe, expect, it } from "vitest";
import { buildPreview, type PreviewAllocation, type PreviewContext } from "@/lib/dn-pipeline/ai-preview";
import { guardAgrees, lineReady, priceDiffers, readyIndexes, slipRateNet } from "./readiness";
const PIPE_ITEM = "pipeitem1234567";
const PLIER_ITEM = "plieritem1234567";
const ROLL_RULE = { rule_key: "roll-to-metre", version: 1, status: "active", supplier_ids: [], instructions: ["One roll is 30 m"] };
const context = (over: Partial<PreviewContext> = {}): PreviewContext => ({
note: { id: "noteabcdefghijk", batch: "batchabcdefghij", pages: [1], doc_number: "VDN1", items: [] },
pages: [],
supplier: { id: "supplierabcdefg", name: "Vula", reconciliation_rules: { rules: [ROLL_RULE] } },
po: { id: "poabcdefghijklm", purchase_order_number: "PO1", total_value: 1150 },
poItems: [
{ id: PIPE_ITEM, description: "S&S Pipes-1050 X 2.5(100D)", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 },
{ id: PLIER_ITEM, description: "Fencing Pliers", unit: "ea", qty: 10, rate: 250, disc_percent: 0, vat_percent: 15 },
],
lines: [
{ sourceLineId: "pipe", description: "U PIPES-600 X 2.5(100D)", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null },
{ sourceLineId: "plier", description: "PLIER FENCING 300MM", qty: 3, unit: "ea", unit_price: 250, line_amount: 750, product_code: null },
],
priorDeliveries: [],
priorItems: [],
...over,
});
const allocation = (over: Partial<PreviewAllocation> = {}): PreviewAllocation => ({
sourceLineIds: ["pipe"],
purchaseOrderItemId: PIPE_ITEM,
quantityBasis: "direct",
factor: 1,
amountBasis: "po_rate",
sourceAmountBasis: "unknown",
ruleKeys: [],
explanation: "test",
reviewQuestion: null,
poDescription: null,
quantity: null,
unit: null,
amount: null,
amountTaxBasis: "unknown",
netAmount: null,
status: "proposed",
reviewReasons: [],
quantityExcess: null,
...over,
});
describe("priceDiffers", () => {
it("flags a rate that is more than R0.50 or 1% away from the PO rate", () => {
expect(priceDiffers(1124.35, 1128.7)).toBe(true);
expect(priceDiffers(100, 100.4)).toBe(false);
expect(priceDiffers(100, 101.01)).toBe(true);
expect(priceDiffers(100, 99.5)).toBe(false);
expect(priceDiffers(100, 98.9)).toBe(true);
});
it("never flags a slip that printed no rate", () => {
expect(priceDiffers(100, null)).toBe(false);
});
});
describe("slipRateNet", () => {
it("reads a VAT-inclusive slip rate as an ex-VAT rate per PO unit", () => {
expect(slipRateNet({ sourceLineId: "mesh", description: "MESH", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }, 1, 15, "gross")).toBe(1128.7);
});
it("divides by the conversion factor to get a rate per PO unit", () => {
expect(slipRateNet({ sourceLineId: "mesh", description: "MESH", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }, 2, 15, "net")).toBe(649);
});
it("refuses to compare a rate whose tax basis nobody stated", () => {
expect(slipRateNet({ sourceLineId: "mesh", description: "MESH", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }, 1, 15, "unknown")).toBeNull();
});
});
describe("guardAgrees", () => {
it("refuses a line the rule matcher sends to a different PO line", () => {
// 600 pipe against a 1050 line: the guard declines on the dimensions.
expect(guardAgrees(context(), allocation({ purchaseOrderItemId: PIPE_ITEM }))).toBe(false);
});
it("accepts a line the rule matcher simply does not recognise", () => {
// "PLIER FENCING 300MM" against "Fencing Pliers": no dimension on the PO
// line to contradict the slip, so the guard has nothing to say.
expect(guardAgrees(context(), allocation({ sourceLineIds: ["plier"], purchaseOrderItemId: PLIER_ITEM }))).toBe(true);
});
it("accepts a line the rule matcher sends to the same PO line", () => {
const c = context({
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "m", unit_price: 1124.35, line_amount: 2248.7, product_code: null }],
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
});
expect(guardAgrees(c, allocation())).toBe(true);
});
});
describe("lineReady", () => {
it("keeps a line open while it is over the ordered quantity", () => {
const c = context({
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 103, unit: "m", unit_price: 1124.35, line_amount: 115808.05, product_code: null }],
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
});
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] });
expect(preview.allocations[0].quantityExcess).toBe(3);
expect(lineReady(c, preview, 0)).toBe(false);
});
it("accepts a line a person confirmed, even over the ordered quantity", () => {
const c = context({
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 103, unit: "m", unit_price: 1124.35, line_amount: 115808.05, product_code: null }],
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
});
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] });
preview.allocations[0].operatorConfirmation = { confirmed: true, poItemId: PIPE_ITEM, quantity: 103, unit: "m", usePoRate: true, acceptExcess: true, reason: "Site confirmed the over-delivery" };
expect(lineReady(c, preview, 0)).toBe(true);
});
it("keeps an AI line open when its unit differs from the PO line and nothing remembers the conversion", () => {
const c = context({
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "roll", unit_price: 1124.35, line_amount: 2248.7, product_code: null }],
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
});
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30 })] });
expect(preview.allocations[0].status).toBe("proposed");
expect(lineReady(c, preview, 0)).toBe(false);
});
it("accepts an AI line whose conversion a supplier rule remembers and whose price agrees", () => {
const c = context({
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "roll", unit_price: 33730.5, line_amount: 67461, product_code: null }],
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
});
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30, ruleKeys: ["roll-to-metre"] })] });
expect(lineReady(c, preview, 0)).toBe(true);
});
it("keeps an AI line open when its price differs from the PO rate by more than the tolerance", () => {
const c = context({
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }],
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
});
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30, ruleKeys: ["roll-to-metre"] })] });
expect(lineReady(c, preview, 0)).toBe(false);
});
it("keeps a VAT-inclusive slip price honest: R1 298 incl is R4.35 over the PO rate", () => {
const c = context({
lines: [{ sourceLineId: "pipe", description: "MESH DIAMOND ROLL", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }],
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
});
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 1, ruleKeys: ["roll-to-metre"] })] });
// 1298 incl is 1128.70 ex, which is R4.35 (0.39%) over the PO rate.
expect(slipRateNet(c.lines[0], 1, 15, "gross")).toBe(1128.7);
expect(lineReady(c, preview, 0)).toBe(false);
});
it("keeps a line open while the rule guard contradicts the allocation", () => {
const c = context({
lines: [{ sourceLineId: "pipe", description: "U PIPES-600 X 2.5(100D)", qty: 2, unit: "m", unit_price: 1124.35, line_amount: 2248.7, product_code: null }],
});
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] });
expect(preview.allocations[0].status).toBe("proposed");
expect(lineReady(c, preview, 0)).toBe(false);
});
it("never calls a posted line ready", () => {
const c = context({
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "m", unit_price: 1124.35, line_amount: 2248.7, product_code: null }],
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
});
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] });
preview.allocations[0].posted = true;
expect(lineReady(c, preview, 0)).toBe(false);
});
});
describe("readyIndexes", () => {
it("lists only the lines that can be confirmed", () => {
const c = context({
lines: [
{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "m", unit_price: 1124.35, line_amount: 2248.7, product_code: null },
{ sourceLineId: "plier", description: "Fencing Pliers", qty: 3, unit: "ea", unit_price: 250, line_amount: 750, product_code: null },
],
poItems: [
{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 },
{ id: PLIER_ITEM, description: "Fencing Pliers", unit: "ea", qty: 10, rate: 250, disc_percent: 0, vat_percent: 15 },
],
});
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] });
// The plier line was never allocated to anything, so it stays open.
expect(preview.allocations).toHaveLength(2);
expect(readyIndexes(c, preview)).toEqual([0]);
});
});

View file

@ -0,0 +1,207 @@
/**
* Per-line readiness for Inbox 3.
*
* A slip line is READY when a person can confirm it without changing
* anything: the AI allocation and the rule guard agree on the PO line, the
* unit needs no conversion nobody remembers, the price agrees with the PO
* rate, and the line books without a fight.
*
* Pure. No I/O and no posting — a person still presses Confirm. The office
* screen and the existing inbox read the same answer, so a line cannot be
* ready in one and open in the other.
*/
import { allocationNeedsHelp } from "@/lib/dn-pipeline/delivery-ready";
import { prepareLineBooking } from "@/lib/dn-pipeline/line-booking";
import { dims, resolvePoItem, type PoItemRef } from "@/lib/dn-pipeline/match-po-item";
import { sameReceivingUnit } from "@/lib/dn-pipeline/receiving-defaults";
import { finiteNumber, type PreviewAllocation, type PreviewContext, type PreviewLine, type ProcessingPreview } from "@/lib/dn-pipeline/ai-preview";
/** A line is flagged once its price is this far from the PO rate. */
export const PRICE_TOLERANCE = { rand: 0.5, percent: 1 } as const;
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100;
/**
* Does the slip's rate differ from the PO rate?
*
* More than R0.50 per PO unit, or more than 1% of it. A slip that printed
* no rate is not a price problem — there is nothing to compare.
*/
export function priceDiffers(poRateNet: number, slipRateNet: number | null): boolean {
if (slipRateNet === null || !Number.isFinite(slipRateNet)) return false;
if (!Number.isFinite(poRateNet)) return false;
const difference = Math.abs(slipRateNet - poRateNet);
return difference > PRICE_TOLERANCE.rand || difference > (Math.abs(poRateNet) * PRICE_TOLERANCE.percent) / 100;
}
/**
* The slip's own rate, per PO unit, ex VAT.
*
* `factor` is how many PO units one slip unit is (30 for a 30 m roll), so
* the slip's per-unit price has to be divided by it before it can be
* compared with a PO rate. A VAT-inclusive slip price is taken back to ex
* VAT first: comparing R1 298 incl with R1 124.35 ex would show 15% on
* every line instead of the R4.35 that is really there.
*
* An unknown tax basis is null: the two rates cannot be compared honestly,
* and a comparison nobody can trust is worse than none.
*/
export function slipRateNet(
line: PreviewLine,
factor: number,
vatPercent: number,
amountTaxBasis: "net" | "gross" | "unknown",
): number | null {
const printed = finiteNumber(line.unit_price);
if (printed === null || printed < 0) return null;
if (!Number.isFinite(factor) || factor <= 0) return null;
const perPoUnit = printed / factor;
if (amountTaxBasis === "net") return round2(perPoUnit);
if (amountTaxBasis === "gross") {
const vat = Number.isFinite(vatPercent) && vatPercent > 0 ? vatPercent : 0;
return round2(perPoUnit / (1 + vat / 100));
}
return null;
}
/**
* The PO rate the line books at, after the line's own discount.
*/
function poRateNet(context: PreviewContext, a: PreviewAllocation): number | null {
const item = context.poItems.find((i) => i.id === a.purchaseOrderItemId);
if (!item) return null;
const rate = finiteNumber(item.rate);
const discount = finiteNumber(item.disc_percent) ?? 0;
if (rate === null || rate < 0 || discount < 0 || discount > 100) return null;
return rate * (1 - discount / 100);
}
/**
* The rule matcher declined because the PO line carries a different size.
*
* 600 pipe against a 1050 line is a contradiction: the PO line states its
* own dimensions and they are not the slip's. A PO line with no dimensions
* at all ("Fencing Pliers") is not — it simply never states a size.
*/
function dimensionConflict(description: string | null | undefined, chosen: PoItemRef | undefined): boolean {
if (!chosen) return false;
const slipDims = dims(description);
if (slipDims.size === 0) return false;
const chosenDims = dims(chosen.description);
if (chosenDims.size === 0) return false;
return [...slipDims].every((d) => chosenDims.has(d)) === false;
}
/**
* Does the rule guard agree with this allocation?
*
* The guard is a second opinion, not a second decision: a line it sends to
* a different PO line, or refuses because of a size conflict, is shown as
* "check" and never auto-matches. A line it merely does not recognise is
* no objection — the AI may well have seen something the wording lost.
*/
export function guardAgrees(context: PreviewContext, a: PreviewAllocation): boolean {
const poItems = context.poItems as unknown as PoItemRef[];
for (const id of a.sourceLineIds) {
const line = context.lines.find((l) => l.sourceLineId === id);
if (!line) continue;
const match = resolvePoItem(line.description, poItems);
if (match.kind === "matched") {
if (a.purchaseOrderItemId && match.poItem.id !== a.purchaseOrderItemId) return false;
continue;
}
if (match.kind === "none") {
const chosen = poItems.find((p) => p.id === a.purchaseOrderItemId);
if (dimensionConflict(line.description, chosen)) return false;
}
}
return true;
}
/**
* Does the slip's unit already agree with the PO line's?
*/
function unitAgrees(context: PreviewContext, a: PreviewAllocation): boolean {
const item = context.poItems.find((i) => i.id === a.purchaseOrderItemId);
if (!item) return false;
const target = String(item.unit ?? "");
if (!target.trim()) return false;
return a.sourceLineIds.every((id) => {
const line = context.lines.find((l) => l.sourceLineId === id);
return line ? sameReceivingUnit(line.unit, target) : false;
});
}
/**
* Does the slip's printed price differ from the PO rate?
*
* A PO rate is always ex VAT and a slip may print either, so the slip rate
* is read twice — as printed, and with VAT removed — and the line is only
* flagged when the difference survives BOTH. A slip printing exactly the
* PO rate plus VAT is not a price problem, and a slip printing the PO rate
* is not one either.
*/
function priceFlagged(context: PreviewContext, a: PreviewAllocation): boolean {
const rate = poRateNet(context, a);
if (rate === null) return false;
const line = context.lines.find((l) => a.sourceLineIds.includes(l.sourceLineId));
if (!line) return false;
const factor = a.factor ?? 1;
const vat = a.vatPercent ?? 15;
const asPrinted = slipRateNet(line, factor, vat, "net");
const exVat = slipRateNet(line, factor, vat, "gross");
return priceDiffers(rate, asPrinted) && priceDiffers(rate, exVat);
}
/** The source lines this note has already booked. */
function bookedSourceKeys(context: PreviewContext): string[] {
const state = context.note.line_posting as { bookings?: { sourceKeys?: string[] }[] } | undefined;
return (state?.bookings ?? []).flatMap((b) => b.sourceKeys ?? []);
}
/**
* Can this line be confirmed as it stands?
*
* A person who has already confirmed the line owns it: their decision
* stands even where the guard would have objected. An AI line has to earn
* it — agreement from the guard, a unit nobody had to guess, a price
* within tolerance, and a booking that passes the same validation a
* person's edit passes.
*/
export function lineReady(context: PreviewContext, preview: ProcessingPreview, index: number): boolean {
const a = preview.allocations[index];
if (!a || a.posted) return false;
if (allocationNeedsHelp(a, preview)) return false;
if (a.netAmount === null || !Number.isFinite(a.netAmount)) return false;
if (!a.operatorConfirmation) {
if (a.status !== "proposed") return false;
if (!guardAgrees(context, a)) return false;
// A unit that differs from the PO's is only allowed through on a
// conversion a supplier rule remembers, so the factor is on the line.
if (!unitAgrees(context, a) && a.ruleKeys.length === 0) return false;
if (priceFlagged(context, a)) return false;
}
try {
prepareLineBooking({
context,
preview,
index,
posted: bookedSourceKeys(context),
confirmation: a.operatorConfirmation ?? { confirmed: true },
});
} catch {
return false;
}
return true;
}
/** The indexes of the lines that can be confirmed without a change. */
export function readyIndexes(context: PreviewContext, preview: ProcessingPreview): number[] {
const ready: number[] = [];
preview.allocations.forEach((_, index) => {
if (lineReady(context, preview, index)) ready.push(index);
});
return ready;
}