diff --git a/src/lib/inbox-three/line-rows.test.ts b/src/lib/inbox-three/line-rows.test.ts new file mode 100644 index 0000000..5591c23 --- /dev/null +++ b/src/lib/inbox-three/line-rows.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { buildPreview, type PreviewContext } from "@/lib/dn-pipeline/ai-preview"; +import { buildLineRows, editToConfirmation, UNIT_SUGGESTIONS, type LineEdit } from "./line-rows"; + +/** + * The fencing slip 0007075669 against MEGCPO9448. + * + * The slip prints two rolls of mesh at R1 298 (VAT inclusive). The order + * carries the same goods as 2 "m" at R1 124.35 — the PO's unit label is + * wrong, which is exactly the question the office has to answer. + */ +const MESH_ITEM = "meshitem1234567"; +const KERB_ITEM = "kerbitem12345678"; + +const context = (): PreviewContext => ({ + note: { id: "noteabcdefghijk", batch: "batchabcdefghij", pages: [1], doc_number: "0007075669", items: [] }, + pages: [], + supplier: { id: "supplierabcdefg", name: "Vula" }, + po: { id: "poabcdefghijklm", purchase_order_number: "MEGCPO9448", total_value: 2248.7 }, + poItems: [ + { id: MESH_ITEM, description: "MESH DIAMOND 600 x 2.5 ROLL", unit: "m", qty: 2, rate: 1124.35, disc_percent: 0, vat_percent: 15 }, + { id: KERB_ITEM, description: "KERB 150", unit: "ea", qty: 10, rate: 95, disc_percent: 0, vat_percent: 15 }, + ], + lines: [ + { sourceLineId: "mesh", description: "MESH DIAMOND … ROLL", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }, + { sourceLineId: "kerb-a", description: "KERB 150", qty: 4, unit: "ea", unit_price: 95, line_amount: 380, product_code: null }, + { sourceLineId: "kerb-b", description: "KERB 150", qty: 4, unit: "ea", unit_price: 95, line_amount: 380, product_code: null }, + { sourceLineId: "kerb-c", description: "KERB 150", qty: 4, unit: "ea", unit_price: 95, line_amount: 380, product_code: null }, + ], + priorDeliveries: [], + priorItems: [], +}); + +const preview = (c: PreviewContext) => buildPreview(c, { allocations: [ + { sourceLineIds: ["mesh"], purchaseOrderItemId: MESH_ITEM, quantityBasis: "direct", factor: 1, amountBasis: "po_rate", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Mesh diamond roll", reviewQuestion: null }, + { sourceLineIds: ["kerb-a"], purchaseOrderItemId: KERB_ITEM, quantityBasis: "direct", factor: 1, amountBasis: "po_rate", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Kerb", reviewQuestion: null }, + { sourceLineIds: ["kerb-b", "kerb-c"], purchaseOrderItemId: KERB_ITEM, quantityBasis: "kit", factor: 1, amountBasis: "po_rate", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Kerb pair", reviewQuestion: null }, +] }); + +const edit = (over: Partial = {}): LineEdit => ({ + lines: [1], + poItemId: MESH_ITEM, + slipQty: 2, + slipUnit: "roll", + factor: 1, + price: null, + usePoRate: true, + reason: "", + ...over, +}); + +describe("buildLineRows", () => { + it("shows the slip's roll against the order's metre", () => { + const c = context(); + const row = buildLineRows(c, preview(c))[0]; + expect(row.numbers).toEqual([1]); + expect(row.slipText).toBe("MESH DIAMOND … ROLL"); + expect(row.slipQty).toBe(2); + expect(row.slipUnit).toBe("roll"); + expect(row.poItemId).toBe(MESH_ITEM); + expect(row.poUnit).toBe("m"); + expect(row.poRate).toBe(1124.35); + expect(row.ordered).toBe(2); + expect(row.flags).toContain("unit_differs"); + }); + + it("values the line at the order rate and says what is left on the order", () => { + const c = context(); + const row = buildLineRows(c, preview(c))[0]; + expect(row.factor).toBe(1); + expect(row.poQty).toBe(2); + expect(row.value).toBe(2248.7); + expect(row.leftOnPo).toBe(0); + }); + + it("asks how many order units one roll is when nobody said", () => { + const c = context(); + const p = preview(c); + p.allocations[0].factor = null; + const row = buildLineRows(c, p)[0]; + expect(row.flags).toContain("factor_missing"); + expect(row.problem).toBe("Enter how many m one roll is"); + expect(row.value).toBeNull(); + }); + + it.each([0, -1, 20000])("refuses a factor of %s", (factor) => { + const c = context(); + const p = preview(c); + p.allocations[0].factor = factor; + const row = buildLineRows(c, p)[0]; + expect(row.flags).toContain("factor_missing"); + expect(row.value).toBeNull(); + }); + + it("numbers a combined row by the slip's own line numbers", () => { + const c = context(); + const rows = buildLineRows(c, preview(c)); + const combined = rows.find((row) => row.numbers.length > 1); + expect(combined?.numbers).toEqual([3, 4]); + }); + + it("offers the unit suggestions the clerk can pick", () => { + expect(UNIT_SUGGESTIONS).toContain("roll"); + expect(UNIT_SUGGESTIONS).toContain("m"); + }); +}); + +describe("editToConfirmation", () => { + it("turns two rolls into sixty metres with the factor on the line", () => { + const c = context(); + const row = buildLineRows(c, preview(c))[0]; + const confirmation = editToConfirmation(row, edit({ factor: 30 }), "m"); + expect(confirmation).toMatchObject({ quantity: 60, unit: "m", usePoRate: true }); + expect(confirmation.reason).toBe("2 roll × 30 = 60 m"); + }); + + it("uses the edited price instead of the order rate", () => { + const c = context(); + const row = buildLineRows(c, preview(c))[0]; + const confirmation = editToConfirmation(row, edit({ price: 1298, usePoRate: false }), "m"); + expect(confirmation).toMatchObject({ netAmount: 2596 }); + expect(confirmation.usePoRate).toBeUndefined(); + expect(confirmation.reason).toContain("Price R1298"); + }); + + it("overrides a wrong order unit label when the factor is one", () => { + const c = context(); + const row = buildLineRows(c, preview(c))[0]; + const confirmation = editToConfirmation(row, edit({ factor: 1 }), "m"); + expect(confirmation).toMatchObject({ quantity: 2, unit: "roll", unitOverride: true }); + expect(confirmation.reason?.trim()).not.toBe(""); + }); + + it.each([null, 0, -1, 20000])("refuses to confirm with a factor of %s", (factor) => { + const c = context(); + const row = buildLineRows(c, preview(c))[0]; + expect(() => editToConfirmation(row, edit({ factor }), "m")).toThrow(/Enter how many m one roll is/); + }); + + it("always gives a reason", () => { + const c = context(); + const row = buildLineRows(c, preview(c))[0]; + expect(editToConfirmation(row, edit({ factor: 1 }), "m").reason?.trim()).not.toBe(""); + expect(editToConfirmation(row, edit({ factor: 1, reason: "the order unit is wrong" }), "m").reason).toBe("the order unit is wrong"); + }); +}); diff --git a/src/lib/inbox-three/line-rows.ts b/src/lib/inbox-three/line-rows.ts new file mode 100644 index 0000000..14fa922 --- /dev/null +++ b/src/lib/inbox-three/line-rows.ts @@ -0,0 +1,187 @@ +/** + * The Inbox 3 line rows. + * + * One row per allocation, in the order the slip printed its lines: what the + * slip says, what the order says, what the row books, and what is still in + * the way. The office reads this instead of a matched-to-against table, + * because the question on a slip is rarely "which line" — it is "how many + * of the order's unit is one of mine". + * + * Pure. No I/O and no posting: a row says what is true, and a person + * confirms. + */ +import { finiteNumber, type PreviewAllocation, type PreviewContext, type PreviewLine, type ProcessingPreview } from "@/lib/dn-pipeline/ai-preview"; +import type { LineConfirmation } from "@/lib/dn-pipeline/line-booking"; +import { sameReceivingUnit } from "@/lib/dn-pipeline/receiving-defaults"; +import { guardAgrees, lineReady, priceFlagged } from "@/lib/inbox-three/readiness"; + +export interface LineEdit { + /** 0-based indexes into the note's saved lines. */ + lines: number[]; + poItemId: string | null; + slipQty: number; + slipUnit: string; + /** How many order units one slip unit is. */ + factor: number | null; + /** The edited unit price, or null to keep the order rate. */ + price: number | null; + usePoRate: boolean; + reason: string; +} + +export interface LineRow { + /** 1-based slip line numbers this row covers; a combined row reads "3+4". */ + numbers: number[]; + allocationIndex: number; + slipText: string; slipQty: number | null; slipUnit: string | null; slipPrice: number | null; slipAmount: number | null; + poItemId: string | null; poUnit: string | null; poRate: number | null; ordered: number | null; priorReceived: number; + qty: number | null; unit: string | null; factor: number | null; poQty: number | null; + price: number | null; value: number | null; leftOnPo: number | null; + flags: Array<"unit_differs" | "factor_missing" | "price_differs" | "over_po" | "guard_conflict" | "not_on_po">; + ready: boolean; posted: boolean; problem: string | null; +} + +export const UNIT_SUGGESTIONS = ["each", "roll", "m", "m²", "m³", "kg", "5kg bag", "ton", "length", "box", "pack"] as const; + +const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100; +/** A factor has to be a real conversion: above zero and no bigger than a roll of 10000. */ +const usableFactor = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 10000; + +/** What this note has already booked against an order line. */ +function bookedForItem(context: PreviewContext, poItemId: string | null): number { + if (!poItemId) return 0; + const state = context.note.line_posting as { bookings?: { poItemId?: string; qty?: number }[] } | undefined; + return (state?.bookings ?? []).filter((booking) => booking.poItemId === poItemId).reduce((sum, booking) => sum + (finiteNumber(booking.qty) ?? 0), 0); +} + +/** What other notes have already delivered against an order line. */ +function priorReceivedForItem(context: PreviewContext, poItemId: string | null): number { + if (!poItemId) return 0; + return context.priorItems.filter((row) => row.purchase_order_item === poItemId).reduce((sum, row) => sum + (finiteNumber(row.qty_received) ?? 0), 0); +} + +function buildRow( + context: PreviewContext, + preview: ProcessingPreview, + allocation: PreviewAllocation, + allocationIndex: number, + lineNumbers: Map, +): LineRow { + const source = allocation.sourceLineIds + .map((id) => context.lines.find((line) => line.sourceLineId === id)) + .filter((line): line is PreviewLine => Boolean(line)); + const first = source[0]; + const item = context.poItems.find((candidate) => candidate.id === allocation.purchaseOrderItemId); + const poItemId = allocation.purchaseOrderItemId ?? null; + const poUnit = item && typeof item.unit === "string" ? item.unit : null; + const poRate = item ? (finiteNumber(item.rate) ?? null) : null; + const ordered = item ? (finiteNumber(item.qty) ?? null) : null; + const priorReceived = priorReceivedForItem(context, poItemId); + + const slipQty = finiteNumber(first?.qty) ?? null; + const slipUnit = first?.unit ?? null; + const slipPrice = finiteNumber(first?.unit_price) ?? null; + const slipAmount = finiteNumber(first?.line_amount) ?? null; + + // The slip's unit against the order's unit: this is the question a roll + // of mesh against an order in metres asks. + const unitDiffers = Boolean(slipUnit && poUnit && !sameReceivingUnit(slipUnit, poUnit)); + const confirmedQty = finiteNumber(allocation.operatorConfirmation?.quantity); + const factor = unitDiffers + ? (usableFactor(allocation.factor) ? allocation.factor : null) + : (usableFactor(allocation.factor) ? allocation.factor : 1); + const qty = slipQty; + const unit = slipUnit; + const poQty = confirmedQty ?? (factor === null || qty === null ? null : round2(qty * factor)); + + const leftOnPo = ordered === null || poQty === null + ? null + : round2(ordered - priorReceived - bookedForItem(context, poItemId) - poQty); + + const confirmedNet = finiteNumber(allocation.operatorConfirmation?.netAmount); + const value = confirmedNet ?? (poQty === null || poRate === null ? null : round2(poQty * poRate)); + + const flags: LineRow["flags"] = []; + if (unitDiffers) flags.push("unit_differs"); + if (unitDiffers && factor === null && confirmedQty === null) flags.push("factor_missing"); + // A price comparison needs the factor: the slip's rate is per slip unit + // and the order's is per order unit. + if (factor !== null && priceFlagged(context, allocation)) flags.push("price_differs"); + if (leftOnPo !== null && leftOnPo < 0) flags.push("over_po"); + if (!guardAgrees(context, allocation)) flags.push("guard_conflict"); + // A NIL line and a free accessory legitimately have no order line. + if (!poItemId && !["not_delivered", "included"].includes(allocation.amountBasis)) flags.push("not_on_po"); + + const problem = flags.includes("factor_missing") + ? `Enter how many ${poUnit ?? "order unit"} one ${slipUnit ?? "unit"} is` + : flags.includes("not_on_po") + ? "This line is not on the purchase order. Choose the order line it belongs to." + : flags.includes("guard_conflict") + ? "The purchase order line and this slip line do not agree. Check which order line this is." + : flags.includes("price_differs") + ? `The slip price R${slipPrice ?? "?"} differs from the order rate R${poRate ?? "?"}` + : flags.includes("over_po") + ? "This is more than the purchase order has left." + : null; + + return { + numbers: allocation.sourceLineIds.map((id) => lineNumbers.get(id)).filter((value): value is number => typeof value === "number").sort((a, b) => a - b), + allocationIndex, + slipText: source.map((line) => line.description ?? "Unreadable line").join(" + "), + slipQty, slipUnit, slipPrice, slipAmount, + poItemId, poUnit, poRate, ordered, priorReceived, + qty, unit, factor, poQty, + price: slipPrice, value, leftOnPo, + flags, + ready: lineReady(context, preview, allocationIndex) && !flags.includes("factor_missing"), + posted: Boolean(allocation.posted), + problem, + }; +} + +/** One row per allocation, in the order the slip printed its lines. */ +export function buildLineRows(context: PreviewContext, preview: ProcessingPreview): LineRow[] { + const lineNumbers = new Map(context.lines.map((line, index) => [line.sourceLineId, index + 1])); + return preview.allocations.map((allocation, index) => buildRow(context, preview, allocation, index, lineNumbers)); +} + +/** + * Turn the clerk's edit into the confirmation that books the line. + * + * The quantity booked is the slip's quantity times the factor, in the + * order's unit. A factor of one with a different unit means the order's + * unit label is wrong, and the slip's own unit is booked instead. The + * reason is never empty: the arithmetic is written down so the next person + * can see how the number was reached. + */ +export function editToConfirmation(row: LineRow, edit: LineEdit, poUnit: string | null): LineConfirmation { + const targetUnit = poUnit ?? row.poUnit ?? null; + const slipUnit = edit.slipUnit; + const factor = edit.factor; + if (factor === null || factor === undefined || !usableFactor(factor)) + throw Error(`Enter how many ${targetUnit ?? "order unit"} one ${slipUnit} is`); + + const poQty = round2(edit.slipQty * factor); + const differs = Boolean(targetUnit && !sameReceivingUnit(slipUnit, targetUnit)); + // A factor of one with a different unit means the order's unit label is + // wrong, so the slip's own unit is what was received. + const unitOverride = differs && factor === 1; + const unit = differs && !unitOverride ? targetUnit! : slipUnit; + const priceEdited = edit.price !== null && edit.price !== undefined && edit.usePoRate !== true; + + const reason = edit.reason?.trim() || [ + `${edit.slipQty} ${slipUnit} × ${factor} = ${poQty} ${unit}`, + ...(priceEdited ? [`Price R${edit.price} instead of PO R${row.poRate ?? "?"}`] : []), + ].join("; "); + + return { + confirmed: true, + poItemId: edit.poItemId, + quantity: poQty, + unit, + ...(unitOverride ? { unitOverride: true } : {}), + ...(priceEdited ? { netAmount: round2(poQty * edit.price!) } : { usePoRate: true }), + reason, + }; +} diff --git a/src/lib/inbox-three/readiness.ts b/src/lib/inbox-three/readiness.ts index f5403bc..999bf5b 100644 --- a/src/lib/inbox-three/readiness.ts +++ b/src/lib/inbox-three/readiness.ts @@ -143,7 +143,7 @@ function unitAgrees(context: PreviewContext, a: PreviewAllocation): boolean { * 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 { +export 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));