diff --git a/src/lib/inbox-three/readiness.test.ts b/src/lib/inbox-three/readiness.test.ts new file mode 100644 index 0000000..b880e06 --- /dev/null +++ b/src/lib/inbox-three/readiness.test.ts @@ -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 => ({ + 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 => ({ + 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]); + }); +}); diff --git a/src/lib/inbox-three/readiness.ts b/src/lib/inbox-three/readiness.ts new file mode 100644 index 0000000..d66b1a3 --- /dev/null +++ b/src/lib/inbox-three/readiness.ts @@ -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; +}