fix(core-flows): split reservations across stock locations + setReservationAllocations hook - #16246
Conversation
…tReservationAllocations hook When completing a cart, inventory is confirmed against the aggregate availability of the sales channel's stock locations, but the reservation was always created against the first location only. When no single location could cover a line item's quantity, cart completion failed with "Not enough stock available" even though the aggregate sufficed (medusajs#14987). - prepareConfirmInventoryInput now carries each item's per-location availability (already computed there) into the reserve step, so no extra inventory query is needed. - reserveInventoryStep splits an item's reservation across its candidate locations (greedily, in whole multiples of required_quantity) when no single location covers the full quantity, and accepts optional custom allocations. - The new reserveInventoryWorkflow wraps the step and exposes a setReservationAllocations hook so applications can decide where quantities are reserved (e.g. in-store pickup). completeCartWorkflow now reserves through this workflow. The exported pure computeReservationAllocations util lets hook handlers start from the default plan. - createOrderFulfillmentWorkflow consumes reservations at the fulfillment's location first and only up to the fulfilled quantity, so split reservations are consumed by their respective per-location fulfillments. Reservations at other locations are still consumed as a fallback, preserving the existing fulfil-from-another-location behavior; no new errors are introduced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EGisZW2K4M3h6XX7ybXtqj
🦋 Changeset detectedLatest commit: 7ae6ccf The changes in this PR will be included in the next version bump. This PR includes changesets to release 79 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Thanks for the contribution! Initial automated review looks good. Fixes the multi-location reservation split bug: when no single location covers a cart item's full quantity, the reservation is now split greedily across candidate locations. Adds a new reserveInventoryWorkflow with a setReservationAllocations hook for operator customization, and updates createOrderFulfillmentWorkflow to consume same-location reservations first. Template complete, changeset present (patch), unit tests added for all new utilities and the step. No security, performance, or correctness issues found. Heads up: PR #15327 also references issue #14987 and was opened earlier — if it is merged first, this PR may need to be closed as a duplicate. Triggered by: manual workflow dispatch |
|
I believe the failed pipeline is not caused by the changes in this PR... |
Summary
What — What changes are introduced in this PR?
Closes #14987.
When a cart needs more stock than any single location holds, but the sales channel has enough across several locations combined, completion previously failed in
reserveInventoryStepwithNot enough stock available for item ... at location ...— inventory confirmation checks the aggregate, but the reservation was always created againstlocation_ids[0].This PR fixes the mismatch and, following the discussion in #14987, adds a hook so applications can customize where quantities are reserved:
required_quantityper location.setReservationAllocationshook. A newreserveInventoryWorkflowwrapsreserveInventoryStepand exposes the hook.completeCartWorkflownow reserves through this workflow, passing the cart and created order as hook context. Handlers return allocations per(line_item_id, inventory_item_id); unreturned items keep the default behavior. The pure, exportedcomputeReservationAllocationsutility computes the default plan so handlers can start from it and adjust.createOrderFulfillmentWorkflownow consumes a line item's reservations same-location-first and only up to the fulfilled quantity, per inventory item. Reservations at other locations are still consumed as a fallback with the previous adjustment semantics, so the existing "fulfill from a different location than the reservation" behavior keeps working and no new errors are introduced.Why — Why are these changes relevant or necessary?
For a valid multi-warehouse setup — variant with 1 unit at location A and 1 unit at B, cart quantity 2 — confirmation passes (1+1 = 2) but order placement then fails. Beyond the bug, allocation policy is a business decision (in-store pickup, preferred warehouse, shipping-cost minimization); the core shouldn't hardcode it, and today it's buried too deep in the workflow to work around. The hook keeps the core logic minimal (simple greedy default) while making the policy replaceable.
How — How have these changes been implemented?
packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.tsThe prepared items now include
location_availability(per-location available quantity). This data was already computed in this function and previously discarded — so the reserve step needs no additional inventory query (the inventory module still validates availability atomically when the reservation is created, so stale availability degrades to today's insufficient-inventory error, never a wrong reservation).packages/core/core-flows/src/cart/utils/compute-reservation-allocations.ts(new)Pure function computing the default allocation plan: full quantity at the first location when it suffices (or for backorder / single-location / no-availability-data items), greedy split in whole
required_quantitymultiples otherwise, and a fallback to the first location when even the aggregate can't cover the item — preserving the canonicalINSUFFICIENT_INVENTORYerror contract. Exported for reuse by hook handlers.packages/core/core-flows/src/cart/steps/reserve-inventory.tsAccepts optional
allocationsoverrides (validated: positive quantities adding up torequired_quantity * quantity) and otherwise computes the default plan per item. Locking keys are unchanged.packages/core/core-flows/src/cart/workflows/reserve-inventory.ts(new)reserveInventoryWorkflowwith thesetReservationAllocationshook (zod-validated result,getResult()wired into the step, same pattern assetPricingContext). Used bycompleteCartWorkflowviarunAsStepinside the existingparallelize. The other callers ofreserveInventoryStep(draft order conversion, claims, exchanges, order edits) automatically get the default split fix through the step; migrating them to the workflow (and thus the hook) can follow in a separate PR.packages/core/core-flows/src/order/workflows/create-fulfillment.tsprepareFulfillmentDatacreates one fulfillment item per inventory item instead of one per reservation (identical output for non-split reservations; avoids duplicated fulfillment items for split ones).prepareInventoryUpdategroups reservations per inventory item and plans consumption via the newplanReservationConsumptionutil: same-location reservations first, then others, capped at the fulfilled quantity. Inventory adjustments and reservation updates keep the previousinput.location_id ?? reservation.location_idsemantics. The pre-existing "Quantity to fulfill exceeds the reserved quantity" error is thrown in exactly the situations it was before.packages/core/core-flows/src/order/utils/plan-reservation-consumption.ts(new)Pure consumption planner, exported and unit-tested.
Testing — How have these changes been tested?
compute-reservation-allocations.spec.ts(9 tests),plan-reservation-consumption.spec.ts(6 tests),reserve-inventory.spec.ts(5 tests incl. an end-to-end run ofreserveInventoryWorkflowwith a registeredsetReservationAllocationshandler).prepare-confirm-inventory-input.spec.tsextended for the newlocation_availabilityoutput.@medusajs/core-flowsunit suite passes (49 tests).turbo run buildfor@medusajs/medusa,@medusajs/test-utilsand all their dependencies passes (71/71 tasks).Examples
Checklist
Additional Context
Relation to earlier work: #11538 fixed the "some single location has full stock" case in
prepareConfirmInventoryInput; this PR handles the remaining aggregate-only case. PR #15327 attempted the same fix but embedded the allocation policy in the step (no hook), re-queried inventory levels inside the reserve step, and madecreateOrderFulfillmentWorkflowthrowINVALID_DATAwhenever a line item had no reservation at the fulfillment location — breaking the existing (and common) flow of fulfilling from a different location than the one reserved at. This PR keeps that flow working: same-location reservations are simply consumed first, and cross-location consumption remains a fallback with unchanged semantics.A design note for reviewers — why a new
reserveInventoryWorkflowinstead of a hook directly oncompleteCartWorkflow: the reservation happens inside thewhen("create-order")block, and the hook's input (items keyed by order line-item IDs) only exists there. Hooks created insidewhen()blocks can run, but can't be included in theWorkflowResponsehooks: [...]array (the hook variable is scoped to the closure), so they can't be typed or picked up by the docs generator — which is whyorderCreatedandbeforePaymentAuthorizationare@ignored today. Wrapping the step in a small workflow gives the hook the standard top-level, typed, documented shape (same pattern assetPricingContext), and lets the otherreserveInventoryStepcallers (draft order conversion, claims, exchanges, order edits) adopt the same hook later by switching to the workflow. Happy to restructure if you'd prefer a different shape.🤖 Generated with Claude Code
https://claude.ai/code/session_01EGisZW2K4M3h6XX7ybXtqj