WFDASH-WEBAPP/src/lib/order-item-qty.ts

64 lines
2.6 KiB
TypeScript
Raw Normal View History

fix(orders): bill the customer quantity, not the supplier order_qty `order_items.order_qty` is the SUPPLIER-facing quantity — what prints on the PO. Five call sites read it as the customer quantity, which is a money bug in both directions: - supplier MOQ (customer 4, we buy 10) → invoiced 10, over-billing 6 - shorted line (customer 100, supplier covers 40) → invoiced 40, under-billing 60 until the operator splits the remainder The invoicing screen never fetched `quantity` at all (zero occurrences in page.tsx), so it could not bill the customer quantity even in principle. Added to the PB field projection, which is load-bearing: drop it and PB returns undefined and every line silently falls back to order_qty. A page-level test now fails if the projection drifts. `maxSurplusFor` measured the surplus as `collected - order_qty`, which on an MOQ line is always 0 — the overflow was mathematically unbookable and vanished from inventory by construction, not by operator error. Four call sites built delivery-note lines by hand; two passed `quantity` and two forgot it, so the same DN printed the customer's 4 via the service layer and the supplier's 10 via the API routes. All four now share `buildDeliveryNoteLine`, so they cannot diverge again. `order_qty` had no doc comment at all next to an 11-line one on `quantity` — that absence is why five subsystems guessed differently. Now documented, with a new `src/lib/order-item-qty.ts` holding `customerQty` / `supplierQty` as the single definition. driver-stops.ts and collection-confirm/po-card.tsx read `order_qty` CORRECTLY (they check what the supplier delivered against what we ordered from them) and carry comments saying so, so the next cleanup doesn't "fix" them into breaking. 23 new tests. Full suite: no regressions — the 15 failures are identical before and after (verified by stashing and re-running). Audit: docs/superpowers/specs/2026-08-30-quantity-handling-audit.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:28:33 +00:00
/**
* The two quantities on an `order_items` row, and which one to use where.
*
* `order_items` carries two numbers that are equal on almost every row and
* mean very different things on the rows where they aren't:
*
* `quantity` — what the CUSTOMER asked for. Drives the invoice, the
* delivery note, and the "ordered" column on the picking
* slip. This is the number the customer agreed to pay for.
*
* `order_qty` — what WE order from the supplier for this line. Drives the
* PO PDF, the PO email and the PO total. The process page
* labels this field "what we tell the supplier".
*
* They diverge in two real situations:
*
* - **Supplier MOQ.** Customer wants 4, the supplier won't sell fewer than
* 10. `quantity = 4`, `order_qty = 10`. We bill 4 and book 6 into stock.
* - **Shorting.** Customer wants 100, supplier A can only supply 40. The
* operator sets `order_qty = 40` and splits the remaining 60 onto a new
* line for supplier B (`splitOrderItemRemainder`).
*
* Getting this wrong is a money bug in both directions: reading `order_qty`
* as the customer quantity over-bills on MOQ lines and under-bills on shorted
* lines. Reading `quantity` as the supplier quantity puts the wrong number on
* a purchase order.
*
* Background and the full list of miscounts this module was written to fix:
* `docs/superpowers/specs/2026-08-30-quantity-handling-audit.md`.
*/
/** The subset of an order item these helpers need. */
export type QtyBearingItem = {
quantity?: number | null;
order_qty?: number | null;
};
function finite(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
/**
* What the customer buys and is billed for.
*
* Falls back to `order_qty` only for legacy rows that never had a
* `quantity` — historically the two were written identically at creation,
* so the fallback is safe and keeps old orders invoicing correctly.
*/
export function customerQty(item: QtyBearingItem): number {
return finite(item.quantity) ?? finite(item.order_qty) ?? 0;
}
/**
* What we order from the supplier — the number that prints on the PO.
*
* A literal `0` is meaningful here and is returned as-is: the operator
* setting 0 means "order nothing from this supplier on this line". Only an
* unset `order_qty` falls back to the customer quantity, which is the
* common case where the operator never edited the supplier-side number.
*/
export function supplierQty(item: QtyBearingItem): number {
return finite(item.order_qty) ?? finite(item.quantity) ?? 0;
}