# 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`: ```ts const qtyCollected = input.qty_collected !== undefined ? Math.floor(input.qty_collected) : Number(lineTyped.order_qty ?? lineTyped.quantity ?? 0); const patch: Record = { 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`: ```ts 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?