feat(dn): inbox 3 line rows with unit factor, price and left on PO
Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
parent
4107bbec9a
commit
e65bfcdd72
3 changed files with 334 additions and 1 deletions
146
src/lib/inbox-three/line-rows.test.ts
Normal file
146
src/lib/inbox-three/line-rows.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
187
src/lib/inbox-three/line-rows.ts
Normal file
187
src/lib/inbox-three/line-rows.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue