WFDASH-WEBAPP/docs/superpowers/specs/2026-08-30-quantity-handling-audit.md
Dave 4d95b7af8c docs: non-invoiceable extras design + plan, quantity-handling audit
Three documents behind the two features on this branch:

- specs/2026-08-30-non-invoiceable-order-extras-design.md — the design
  the implementation followed, including why the flag must be named for
  the exception (a positively-named field defaults to false and would
  mark every existing row non-billable the moment it is created).
- plans/2026-08-30-non-invoiceable-order-extras.md — the task-by-task
  plan, now fully implemented.
- specs/2026-08-30-quantity-handling-audit.md — the order_qty /
  quantity audit that produced the preceding fix, plus the partial
  collection findings that are still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:28:44 +02:00

8.6 KiB

Quantity Handling Audit — Surplus, Shortfall and Partial Collection

Date: 2026-08-30 Status: Findings only. No design decisions taken yet.

Answers two operator questions:

  1. We order 2 because of a supplier MOQ but the client only wanted 1. Does the extra get booked into stock, and does the picking slip show the client only the quantity that must actually go out?
  2. The driver collects 2 of 5. What happens to the other 3? Does it go back to the driver as outstanding, or is it lost?

Question 1: MOQ surplus — mostly already works

The good news: the picking slip already does exactly what you described.

computePickingSlipLine (src/app/(app)/invoicing/picking-slip.tsx:250) computes five columns per line:

Column Formula Meaning
ordered order_qty what the client asked for
collected qty_collected what actually came in from the supplier
pickQty min(collected, ordered) what goes out to the client
outstanding max(ordered - pickQty, 0) still owed to the client
toStock max(collected - pickQty, 0) the MOQ surplus

pickQty is capped at the client's quantity, so the picker is never told to send out the surplus. Ordered 1, received 2 → pick 1, to stock 1. Exactly the behaviour you asked for, already shipped.

The invoice agrees. resolveInvoiceLineDefaults (src/app/(app)/invoicing/view-model.ts:362) bills order_qty and explicitly refuses to bill qty_collected, with a comment naming the MOQ case as the reason.

Surplus booking exists too: src/lib/server/stock-surplus.ts and POST /api/v1/stock/surplus-book, invoked from /collection-confirm. It increments stock_items.current_qty, writes a purchase_surplus stock movement, and stamps order_items.surplus_qty. The booking is capped at qty_collected - customerQty so it can't over-book.

Gap 1.1 — surplus booking is manual and unprompted

Nothing makes the operator book the surplus. The picking slip prints toStock: 1, and if the operator never clicks the button on /collection-confirm, that unit is physically in the building and invisible to inventory forever. There is no "unbooked surplus" indicator, no count, no queue.

Severity: medium. Silent inventory loss, proportional to how often operators forget.

Gap 1.2 — no check that the full quantity actually arrived

You said "if the full quantity came in you must check that." There is no such check. Nothing compares qty_collected against order_qty at any gate. See Question 2 — this is the same root cause.


Question 2: Partial collection — the missing units disappear

Direct answer: the 3 outstanding units do not go back to the driver. They are not tracked anywhere. The line is marked fully collected and moves on.

Both paths that mark a line collected set item_status = "collected" unconditionally, without comparing quantities:

Driver app — src/lib/server/driver-stops.ts:620:

const qtyCollected =
  input.qty_collected !== undefined
    ? Math.floor(input.qty_collected)
    : Number(lineTyped.order_qty ?? lineTyped.quantity ?? 0);

const patch: Record<string, unknown> = { qty_collected: qtyCollected };
if (input.action === "collected") {
  patch.item_status = "collected";   // ← no quantity comparison

Driver reports 2 of 5 → qty_collected: 2, item_status: "collected". The stop leaves their list. Nothing re-queues it.

Agent / MCP path — confirmOrderItemsCollected (src/lib/server/order-items-service.ts:224) is more careful, and then throws the care away. It computes remaining, validates the partial doesn't overrun, and writes a genuinely useful audit note — "2 units added to qty_collected (now 2/5)" — before patching item_status: "collected" at line 318 regardless. remaining is used only for validation and note text. It never holds the line open.

What happens next

The line is collected, so it flows to ready_invoice and lands on the invoicing screen. There:

  • The picking slip is honest — it shows outstanding: 3, because computePickingSlipLine does the subtraction. Good.
  • The invoice is not — resolveInvoiceLineDefaults bills order_qty, which is 5. You invoice the client for 5 units when 2 arrived.

The operator can correct this: the invoicing modal supports per-line qty overrides (InvoiceLineOverride). But the default is wrong and nothing flags the discrepancy, so it depends entirely on someone noticing.

Gap 2.1 — splitOrderItemRemainder is locked out exactly when needed

There is already a purpose-built tool for a shorted line: splitOrderItemRemainder (src/lib/server/order-items-service.ts:376). It creates a remainder line for the shortfall, supplier-less so it sorts to the top of the process page as "needs supplier", and re-enters the normal PO flow.

It refuses unless the line is pending or processed:

if (!["pending", "processed"].includes(status)) {
  throw new OrderItemSplitError(
    `Cannot split an item in status "${status}" — only pending/processed lines are still editable.`, 409);
}

A short-collected line is assigned or collection_pool, so the split is refused. The mechanism that would solve this problem is unavailable in the only situation where the problem occurs. It was built for a different case: the operator knowing up-front that supplier A can only supply 40 of 100.

Gap 2.2 — outstanding_since is about age, not quantity

driver-stops.ts:95 exposes outstanding_since, which looks like it tracks this. It doesn't — it's the parent order's created date, used for "outstanding N days" reporting. There is no quantity-outstanding concept anywhere in the model.


Cross-cutting: order_qty means two different things

This is the root cause underneath both questions, and the most dangerous thing in this audit.

order_items.order_qty has no doc comment (src/lib/types.ts:807), while the neighbouring quantity has eleven lines of it. Two subsystems consequently disagree about what it means.

Reading A — order_qty is what the CUSTOMER asked for:

  • stock-surplus.ts:66 — "order_qty is the canonical 'what the customer asked for' field"
  • view-model.ts:362 — invoice quantity
  • driver-stops.ts:313 — quantity_ordered: order_qty ?? quantity
  • picking-slip.tsx — the ordered column

Reading B — order_qty is what we're buying from THIS SUPPLIER:

  • splitOrderItemRemainder doc, order-items-service.ts: "the process page lets the operator enter a supplier-facing order_qty that is LOWER than the customer-facing quantity" — customer 100, order_qty 40.

The two readings converge only after a completed split, because the split reduces quantity down to match order_qty. In the window between the operator typing order_qty = 40 and clicking Split, every consumer in Reading A believes the customer ordered 40.

Failure scenario: customer orders 100. Operator sets order_qty = 40 for supplier A, intending to split the other 60 to supplier B. They get interrupted and never click Split. The invoice bills 40. The picking slip says ordered 40, outstanding 0. The other 60 units are gone with no trace and no warning.

There is also a smaller inconsistency: confirmOrderItemsCollected computes remaining from row.quantity, while driver-stops.ts defaults from row.order_qty. Two different denominators for the same concept.


Summary

# Finding Severity
X-1 order_qty means "customer qty" in four places and "supplier qty" in one, undocumented. Un-split lines silently under-bill. High
2.1 Partial collection marks the line fully collected; the shortfall is untracked and the invoice defaults to the full quantity. High
2.2 splitOrderItemRemainder is blocked by status exactly when a short collection needs it. Medium
1.1 Surplus booking is manual with no prompt or unbooked-surplus queue. Medium
1.2 No gate anywhere compares qty_collected to order_qty. Medium

The picking slip is the one component that already gets all of this right. The arithmetic you asked for exists — it just isn't enforced anywhere upstream of the print, and the invoice ignores it.

Not yet decided

No design decisions have been taken. The open questions are:

  • Should a short collection hold the line open (new status / partial state), or auto-split the remainder into a new line, or just warn loudly at invoicing?
  • Should order_qty be documented as customer-qty and the split flow moved to a separate supplier_qty field, or the reverse?
  • Should surplus booking be forced before a line can be invoiced?