feat(dn): inbox 3 line rows with unit factor, price and left on PO

Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
DEls 2026-10-26 19:03:06 +02:00
parent 4107bbec9a
commit e65bfcdd72
3 changed files with 334 additions and 1 deletions

View file

@ -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> = {}): 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");
});
});

View file

@ -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<string, number>,
): 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,
};
}

View file

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