v1.36.0 -- Backorders, Admin Ship, wave-create at scale
Backorders you can sell into and release from anywhere stock lands, an Admin Ship escape hatch, and wave-create that scales past twenty orders.
Upgrade notes
BACKORDER_WAREHOUSE_CODE must be set on a multi-warehouse deployment before POS backorders will work. It names the warehouse a create-without-stock backorder is assigned to, which has to be the one that receives restock, since the auto-fulfill hook matches on warehouse_id. Unset is fine on a single-warehouse deployment: that warehouse is used. Documented in .env.example.
ship.voided/1 now accepts SHIPPED as reverted_to_status. Additive, but a wire contract: a consumer switching exhaustively on that field now has a third case.
Added
Create-without-stock backorder at POS checkout (#450). The register can take payment for an item it has no stock of. backorder=true skips the stock gate entirely, since the lines carry no location: only the SKU has to resolve, and each line is inserted PENDING with quantity_ordered alone, nothing reserved and nothing decremented. The order lands at WAITING_STOCK with backorder_opened_at stamped, shows on the backorder screen, sorts by waiting age, and clears through the receiving auto-fulfill hook when stock arrives. Emits backorder.opened/1. A replacement or exchange backorder keeps its order_type and parent link, and an exchange still auto-creates its RMA.
Admin Ship (#455). An order can end up physically shipped without Sentry recording it, whether an external shipping system stamped the label or a legacy backfill left picked quantity with nothing shipped against it. POST /admin/sales-orders/<id>/admin-ship stamps quantity_shipped from quantity_picked across every line where picked exceeds shipped, writes one fulfillment and one ship.confirmed, and moves the order to SHIPPED. It ships all eligible lines rather than offering per-line selection, so the order gets exactly one fulfillment. Returns are refused, and a line under-picked with no explicit shortfall marker refuses with kind=silent_shortfall naming the blocking SKUs until the operator re-submits with acknowledge_shortfall.
Item name and open-PO status on the backorder queue (#452). Nothing links a backorder to the PO that will satisfy it, so the open-PO answer is derived from open PO lines for the same item in the backorder's warehouse. It names the PO and its expected date rather than showing a bare checkbox, which would hide that ambiguity behind a tick.
Changed
Backorders release on any inventory increase (#451). A backorder released only when a PO receipt landed, because the matcher lived in receiving.py and ran only on POST /receive. Stock reaches a warehouse by several routes, so a backorder could sit in WAITING_STOCK while the item was already on the shelf in a pickable bin in that exact warehouse. The matcher moves into inventory_service.release_satisfiable_backorders() and direct adjustments, cycle-count approvals, the adjustment CSV import, inter-warehouse transfers and the inventory sync all call it, each inside the same transaction as the stock that caused the release. Deliberately not hooked inside add_inventory(): the cycle-count approval path writes stock with raw SQL and would be missed, and a blanket hook would fire on restock-on-revert, where stock is only transiently back on the shelf.
Fixed
Wave-create no longer times out past twenty orders (#454). Validation collapses from a round trip per selected order into one set-based query, and the batch build stops re-querying per order. Two correctness guards ride along, both reachable once the endpoint was fast enough to use on large selections: a double-tapped create could open two batches over the same orders, and the auto-cancel that tidied an empty batch could fire against one a concurrent request had just populated.
Adjustments list no longer 500s on a username-authored row (#453). GET /admin/adjustments/list failed on any warehouse that had ever had a cycle count, including the unfiltered default the page loads, so the page was dead for every user. adjusted_by is VARCHAR and its writers disagree: direct adjustments store a numeric user_id, cycle-count submission stores a username. The join guarded its cast with a regex, but Postgres does not promise to evaluate join conditions left to right, so the planner could run adjusted_by::int against every row first.
Migrations
081 backorder_release_comment: documentation only, no schema change, re-runnable.
Mobile
Pick-scan screen and api client change, so the build moves to version 1.36.0, versionCode 13. The APK is rebuilt at the end of the current migration round.
Verification
Merged-main suites: api 2955 passed / 9 skipped, admin 140 passed, mobile 55 passed.