docs/plans/6.0-inventory-operations.md
Status: Draft Target: Spree 6.0 Depends on: Typed Stock Movements (6.0-typed-stock-movements.md), Fulfillment & Delivery (6.0-fulfillment-and-delivery.md), Stock Reservations (6.0-stock-reservations.md) Author: Damian + Claude Last updated: 2026-05-16
Lift inventory operations from "atomic decrement + log" to a first-class workflow on par with Shopify. Three coordinated changes:
Spree::StockTransfer gains a lifecycle. Today a transfer is one-shot — source decrements and destination increments in the same transaction. After this plan: draft → ready_to_ship → in_transit → received (with cancelled as a terminal escape hatch), plus line items, partial receive support, and a discrepancy column. Source units leave on mark_in_transit, destination units land on receive — not at the same instant.Spree::PurchaseOrder model for vendor receives. Today's "external receive" hack (a StockTransfer with source_location_id: nil) is replaced by a proper PurchaseOrder with vendor, expected_at, line_items[] with unit_cost, and the same receive flow as transfers.6.0-typed-stock-movements.md as inline timelines in the admin SPA — answering "why is on-hand 47?" in the place users always ask: on a SKU or a warehouse.This plan is the UX surface for inventory ops. The data primitives (typed StockMovement, kind, concrete FKs to Order/Fulfillment/StockTransfer/ReturnAuthorization/PurchaseOrder) come from 6.0-typed-stock-movements.md and the Fulfillment rename. Those two plans must land first.
A box moves from warehouse A to warehouse B over multiple days. Today's StockTransfer collapses the entire trip into one transaction:
source.unstock(variant, qty, self)
destination.restock(variant, qty, self)
This means:
count_on_hand jumps the moment the source decrements, so availability is wrong for the intervening period.stock_movements.originator_id = transfer.id.Today, receiving stock from a vendor is implemented as StockTransfer.transfer(nil, destination, variants) — passing a nil source. There is no vendor identity, no PO number, no unit cost, no expected-arrival date, no audit of who placed the order. The legacy admin's "Receive" screen is a StockTransfer form with the source dropdown hidden.
Vendors and merchant warehouses are different things. A PO has unit cost (drives COGS / margin reporting), an expected date (drives replenishment scheduling), and a vendor (drives accounts payable). A transfer has none of those — it's internal logistics. Folding them together costs us margin reporting and replenishment UX forever.
Once 6.0-typed-stock-movements.md lands, every change to count_on_hand carries a typed reason (received | allocated | shipped | released | adjusted) and a concrete FK to its cause. That data is invisible without screens. The legacy admin had a "Stock Movements" page that listed +5 from T-123, but it was an undifferentiated firehose across all SKUs and all locations — the question merchants actually ask is per-SKU ("why is this on-hand 47?") or per-location ("what changed in the Brooklyn warehouse this week?"). The destination is the question, not the dataset.
| What | How it works (5.5) |
|---|---|
Spree::StockTransfer | One-shot atomic transfer. Columns: source_location_id (nullable for receives), destination_location_id, number, reference, type (STI column, unused), created_at. No status, no expected/received-at, no line items. |
| External receive | StockTransfer.transfer(nil, destination, variants) — no vendor, no cost. |
| Movement audit | Spree::StockMovement with polymorphic originator. Only StockTransfer, ReturnAuthorization, and nil show up in practice. Shipment fulfillment does not write movements (lives on InventoryUnit instead). |
| Admin SPA | Products → Transfers (single page) with create-transfer sheet. No history view, no PO concept. |
Spree::StockTransfer gets a status column with values draft | ready_to_ship | in_transit | partially_received | received | cancelled. State machine via state_machines-activerecord, column name status per 6.0-normalize-state-to-status.md.count_on_hand decrements on mark_in_transit. Destination's count_on_hand increments on receive (potentially partial). Between those two events, the units are in flight — physically gone from the source, not yet at the destination.spree_stock_transfer_items with (stock_transfer_id, variant_id, quantity_shipped, quantity_received, condition). Replaces today's "implicit line item via stock_movement.stock_item.variant".quantity_received per item. If SUM(quantity_received) < SUM(quantity_shipped), status becomes partially_received; when equal, received. A discrepancy_reason column on the line item captures "damaged in transit / lost / undercount" for the gap.draft is a no-op for stock. Cancelling an in_transit transfer requires the merchant to choose: write the source back to its count_on_hand (treat as if it never shipped), or write off the units (shrinkage; adjusted movement with reason). No silent reversal.receive writes typed received stock movements with stock_transfer_id. mark_in_transit writes typed shipped movements with stock_transfer_id. Two-way reconciliation through the kind + stock_transfer_id columns of 6.0-typed-stock-movements.md.Spree::PurchaseOrder, not an STI subclass of StockTransfer. The two share a UX rhythm (line items → receive flow) but their domains are distinct (vendor vs warehouse, unit cost vs no cost, AP vs internal logistics). STI would force one table to carry both vocabularies. Better to have parallel models with a shared Receivable concern that owns the line-items + receive-flow + status machine.Spree::Vendor is the supplier identity. Minimal: name, contact_name, email, phone, address, notes, metadata. Stores manage their own vendor list — vendors are not shared across stores in 6.0.draft | ordered | partially_received | received | cancelled. No in_transit — a PO doesn't track in-flight stock from the vendor; the merchant only knows about it when units land. (Future enhancement: optional shipped state if the vendor provides tracking, but not in 6.0.)PurchaseOrderItem has unit_cost (Money) and quantity_ordered / quantity_received. Currency comes from the PO's currency column (defaults to the store's). Receiving units writes typed received stock movements with purchase_order_id.count_on_hand until receive. Ordered units are visible to the merchant as "on order" (computed: SUM(quantity_ordered - quantity_received) per variant), but never count toward availability.These additions live in 6.0-typed-stock-movements.md; this plan only consumes them.
purchase_order_id FK to Spree::StockMovement (typed-movements plan currently lists shipment_id/fulfillment_id, order_id, return_authorization_id, stock_transfer_id — extend with purchase_order_id).Spree::StockMovement::KINDS += ['purchase_order_received'] — actually no, reuse received with purchase_order_id set. Same shape as RMA receive. The kind is what happened to stock; the FK is why.Transfers, Purchase Orders. Each is a list page with status filters (Draft / Ready / In transit / Received / All).draft → editable line items, "Mark as ready to ship" / "Cancel" actions.ready_to_ship → frozen line items, "Mark in transit" (writes source shipped movements) / "Back to draft" / "Cancel".in_transit / partially_received → receive screen: per-item quantity_received input with running totals, optional discrepancy_reason per item, "Receive" submit (writes destination received movements for the deltas).received / cancelled → read-only summary + activity log.draft → editable line items + costs. ordered → frozen line items, "Receive" enabled. Same partial-receive screen.GET /api/v3/admin/stock_movements?q[stock_item_variant_id_eq]=… (the typed-movements admin endpoint), renders date · location · qty before → after · kind · cause with the cause linking to the underlying entity (Transfer T-…, PO PO-…, RMA RA-…, Order #…, "Manual adjustment").q[stock_item_stock_location_id_eq]. Useful for warehouse reconciliation.class Spree::StockTransfer < Spree.base_class
has_prefix_id :st
include Spree::Core::NumberGenerator.new(prefix: 'T')
include Spree::NumberIdentifier
include Spree::Metafields
include Spree::Metadata
publishes_lifecycle_events
belongs_to :source_location, class_name: 'Spree::StockLocation'
belongs_to :destination_location, class_name: 'Spree::StockLocation'
has_many :items, class_name: 'Spree::StockTransferItem',
inverse_of: :stock_transfer, dependent: :destroy
has_many :stock_movements, class_name: 'Spree::StockMovement',
inverse_of: :stock_transfer
accepts_nested_attributes_for :items, allow_destroy: true
validates :source_location, :destination_location, presence: true
validate :source_differs_from_destination
validate :has_items, on: :submit
state_machine :status, initial: :draft do
event :mark_ready do
transition from: :draft, to: :ready_to_ship
end
event :mark_in_transit do
transition from: %i[draft ready_to_ship], to: :in_transit
end
event :receive_partial do
transition from: :in_transit, to: :partially_received,
if: ->(t) { t.items.any?(&:short?) }
end
event :receive do
transition from: %i[in_transit partially_received], to: :received,
unless: ->(t) { t.items.any?(&:short?) }
end
event :cancel do
transition from: %i[draft ready_to_ship in_transit], to: :cancelled
end
after_transition to: :in_transit, do: :write_shipped_movements
after_transition to: :partially_received, do: :write_received_deltas
after_transition to: :received, do: :write_received_deltas
end
scope :open, -> { where(status: %w[draft ready_to_ship in_transit partially_received]) }
scope :closed, -> { where(status: %w[received cancelled]) }
end
class Spree::StockTransferItem < Spree.base_class
has_prefix_id :sti
belongs_to :stock_transfer, class_name: 'Spree::StockTransfer', inverse_of: :items
belongs_to :variant, -> { with_deleted }, class_name: 'Spree::Variant'
validates :quantity_shipped, numericality: { greater_than: 0, only_integer: true }
validates :quantity_received, numericality: { greater_than_or_equal_to: 0, only_integer: true }
validate :received_does_not_exceed_shipped
attribute :discrepancy_reason, :string
def short?
quantity_received < quantity_shipped
end
def outstanding
quantity_shipped - quantity_received
end
end
class Spree::PurchaseOrder < Spree.base_class
has_prefix_id :po
include Spree::Core::NumberGenerator.new(prefix: 'PO')
include Spree::NumberIdentifier
include Spree::SingleStoreResource
include Spree::Metafields
include Spree::Metadata
publishes_lifecycle_events
belongs_to :vendor, class_name: 'Spree::Vendor'
belongs_to :destination_location, class_name: 'Spree::StockLocation'
belongs_to :store, class_name: 'Spree::Store'
has_many :items, class_name: 'Spree::PurchaseOrderItem',
inverse_of: :purchase_order, dependent: :destroy
has_many :stock_movements, class_name: 'Spree::StockMovement',
inverse_of: :purchase_order
accepts_nested_attributes_for :items, allow_destroy: true
attribute :currency, :string
attribute :expected_at, :date
attribute :notes, :text
validates :vendor, :destination_location, :currency, presence: true
validate :has_items, on: :submit
state_machine :status, initial: :draft do
event :mark_ordered do
transition from: :draft, to: :ordered
end
event :receive_partial do
transition from: :ordered, to: :partially_received,
if: ->(po) { po.items.any?(&:short?) }
end
event :receive do
transition from: %i[ordered partially_received], to: :received,
unless: ->(po) { po.items.any?(&:short?) }
end
event :cancel do
transition from: %i[draft ordered partially_received], to: :cancelled
end
after_transition to: :partially_received, do: :write_received_deltas
after_transition to: :received, do: :write_received_deltas
end
end
class Spree::PurchaseOrderItem < Spree.base_class
has_prefix_id :poi
belongs_to :purchase_order, class_name: 'Spree::PurchaseOrder', inverse_of: :items
belongs_to :variant, -> { with_deleted }, class_name: 'Spree::Variant'
attribute :quantity_ordered, :integer
attribute :quantity_received, :integer, default: 0
attribute :unit_cost_cents, :integer
attribute :unit_cost_currency, :string
monetize :unit_cost_cents
validates :quantity_ordered, numericality: { greater_than: 0, only_integer: true }
validates :quantity_received, numericality: { greater_than_or_equal_to: 0, only_integer: true }
validate :received_does_not_exceed_ordered
validate :unit_cost_present_when_required
def short?
quantity_received < quantity_ordered
end
def outstanding
quantity_ordered - quantity_received
end
def total_cost
unit_cost * quantity_ordered
end
end
class Spree::Vendor < Spree.base_class
has_prefix_id :vnd
include Spree::SingleStoreResource
include Spree::Metafields
include Spree::Metadata
acts_as_paranoid
belongs_to :store, class_name: 'Spree::Store'
belongs_to :address, class_name: 'Spree::Address', optional: true
has_many :purchase_orders, class_name: 'Spree::PurchaseOrder'
validates :name, presence: true,
uniqueness: { scope: spree_base_uniqueness_scope }
end
Added to Spree::StockMovement (extends what 6.0-typed-stock-movements.md introduces):
belongs_to :purchase_order, class_name: 'Spree::PurchaseOrder', optional: true
class AddPurchaseOrderToStockMovements < ActiveRecord::Migration[7.2]
def change
add_column :spree_stock_movements, :purchase_order_id, :bigint
add_index :spree_stock_movements, :purchase_order_id
end
end
The kind stays received (already defined). PO is just a new cause flavor — the purchase_order_id FK is set, others are null.
New endpoints; all CRUD-shaped, all under /api/v3/admin/:
GET /stock_transfers
POST /stock_transfers
GET /stock_transfers/:id
PATCH /stock_transfers/:id
DELETE /stock_transfers/:id # only when status == draft
POST /stock_transfers/:id/mark_ready
POST /stock_transfers/:id/mark_in_transit
POST /stock_transfers/:id/receive # body: { items: [{ id, quantity_received, discrepancy_reason }] }
POST /stock_transfers/:id/cancel # body: { on_in_transit: 'restock' | 'write_off' } when needed
GET /vendors / POST / PATCH / DELETE (standard CRUD)
GET /purchase_orders
POST /purchase_orders
GET /purchase_orders/:id
PATCH /purchase_orders/:id
DELETE /purchase_orders/:id # only when status == draft
POST /purchase_orders/:id/mark_ordered
POST /purchase_orders/:id/receive # body: { items: [{ id, quantity_received }] }
POST /purchase_orders/:id/cancel
State-transition endpoints are explicit POSTs (not PATCHes that mutate status). This matches the existing pattern for orders/:id/cancel etc.
GET /stock_movements (the audit feed introduced by 6.0-typed-stock-movements.md) gains support for q[purchase_order_id_eq] automatically once the FK is in the ransackable list.
// StockTransfer (admin)
{
"id": "st_xxx",
"number": "T-12345",
"status": "in_transit",
"reference": "PO from supplier A",
"source_location_id": "sl_aaa",
"destination_location_id": "sl_bbb",
"shipped_at": "2026-05-10T12:00:00Z", // set by mark_in_transit
"received_at": null, // set by terminal `received`
"items_count": 3,
"quantity_shipped_total": 17,
"quantity_received_total": 12,
"created_at": "...",
"updated_at": "..."
}
// StockTransferItem (admin)
{
"id": "sti_xxx",
"stock_transfer_id": "st_xxx",
"variant_id": "variant_xxx",
"quantity_shipped": 10,
"quantity_received": 7,
"outstanding": 3,
"discrepancy_reason": "damaged_in_transit"
}
// PurchaseOrder (admin)
{
"id": "po_xxx",
"number": "PO-00042",
"status": "partially_received",
"vendor_id": "vnd_xxx",
"destination_location_id": "sl_bbb",
"currency": "USD",
"expected_at": "2026-06-01",
"ordered_at": "2026-05-15T09:00:00Z",
"items_count": 5,
"quantity_ordered_total": 100,
"quantity_received_total": 60,
"subtotal": "1250.00",
"display_subtotal": "$1,250.00",
"created_at": "...",
"updated_at": "..."
}
// PurchaseOrderItem (admin) — includes unit_cost
{
"id": "poi_xxx",
"purchase_order_id": "po_xxx",
"variant_id": "variant_xxx",
"quantity_ordered": 20,
"quantity_received": 12,
"outstanding": 8,
"unit_cost": "12.50",
"display_unit_cost": "$12.50",
"currency": "USD"
}
6.0-typed-stock-movements.md lands first — the kind column, concrete FKs, allocated_count column on StockItem, and StockMovement model rewrite. Without this, transfers and POs can't write typed audit rows.
class AddInventoryOperations < ActiveRecord::Migration[7.2]
def change
# StockTransfer gains status + timestamps
add_column :spree_stock_transfers, :status, :string, null: false, default: 'draft'
add_column :spree_stock_transfers, :shipped_at, :datetime
add_column :spree_stock_transfers, :received_at, :datetime
add_index :spree_stock_transfers, :status
# source_location_id becomes required — receives move to PurchaseOrder.
change_column_null :spree_stock_transfers, :source_location_id, false
create_table :spree_stock_transfer_items do |t|
t.references :stock_transfer, null: false
t.references :variant, null: false
t.integer :quantity_shipped, null: false
t.integer :quantity_received, null: false, default: 0
t.string :discrepancy_reason
t.timestamps
end
add_index :spree_stock_transfer_items,
[:stock_transfer_id, :variant_id],
unique: true,
name: 'idx_stock_transfer_items_unique'
create_table :spree_vendors do |t|
t.string :name, null: false
t.string :contact_name
t.string :email
t.string :phone
t.text :notes
t.references :store, null: false
t.references :address
if t.respond_to?(:jsonb)
t.jsonb :private_metadata
t.jsonb :public_metadata
else
t.json :private_metadata
t.json :public_metadata
end
t.datetime :deleted_at
t.timestamps
end
add_index :spree_vendors, [:store_id, :name], unique: true
create_table :spree_purchase_orders do |t|
t.string :number, null: false
t.string :status, null: false, default: 'draft'
t.references :vendor, null: false
t.references :store, null: false
t.references :destination_location, null: false
t.string :currency, null: false
t.date :expected_at
t.datetime :ordered_at
t.datetime :received_at
t.text :notes
if t.respond_to?(:jsonb)
t.jsonb :private_metadata
t.jsonb :public_metadata
else
t.json :private_metadata
t.json :public_metadata
end
t.timestamps
end
add_index :spree_purchase_orders, :number, unique: true
add_index :spree_purchase_orders, :status
create_table :spree_purchase_order_items do |t|
t.references :purchase_order, null: false
t.references :variant, null: false
t.integer :quantity_ordered, null: false
t.integer :quantity_received, null: false, default: 0
t.integer :unit_cost_cents, null: false, default: 0
t.string :unit_cost_currency, null: false
t.timestamps
end
add_index :spree_purchase_order_items,
[:purchase_order_id, :variant_id],
unique: true,
name: 'idx_purchase_order_items_unique'
# Typed-movements extension
add_column :spree_stock_movements, :purchase_order_id, :bigint
add_index :spree_stock_movements, :purchase_order_id
end
end
spree:migrate_external_receives_to_purchase_orders)For each existing StockTransfer with source_location_id IS NULL:
Spree::Vendor named "Migrated receives" for the relevant store.Spree::PurchaseOrder with status: 'received', received_at: transfer.created_at, currency from store default, ordered_at: nil.stock_movements into the PO via purchase_order_id (set, leave stock_transfer_id for historical lineage if desired, or null it). The migration leaves count_on_hand untouched — the movements already affected stock at the time.PurchaseOrderItem rows from the underlying movements with unit_cost_cents: 0 (historical cost is unknown).StockTransfer as deleted_at-stamped + status: 'received' for audit, or hard-delete it after a release of soft-deprecation. Open question — see below.All other existing transfers (with a real source location) become status: 'received' with received_at = created_at. They retroactively look like instantaneous transfers, which matches today's reality.
Vendor, PurchaseOrder, PurchaseOrderItem, StockTransferItem.StockTransfer gains state machine + line items + transfer/receive methods removed (the old atomic API).Spree::Inventory:::
Spree::Inventory::SubmitTransfer (draft → ready_to_ship → in_transit, writes shipped movements)Spree::Inventory::ReceiveTransfer (params: { items: [{ id, quantity_received, discrepancy_reason }] }, writes received movements for deltas)Spree::Inventory::CancelTransferSpree::Inventory::SubmitPurchaseOrder / Spree::Inventory::ReceivePurchaseOrder / Spree::Inventory::CancelPurchaseOrderServiceModule::Result (success/error) — same pattern as existing checkout services.Vendor and PurchaseOrder map to a new permission set Spree::PermissionSets::PurchaseOrderManagement and Spree::PermissionSets::PurchaseOrderDisplay. Stock transfers stay under the existing StockManagement / StockDisplay sets.admin_vendor_serializer, admin_purchase_order_serializer, admin_purchase_order_item_serializer, admin_stock_transfer_item_serializer).read_inventory / write_inventory for POs and vendors. Transfers stay under read_stock / write_stock (existing).Spree::Api::V3::Admin:::
VendorsController (standard CRUD)PurchaseOrdersController (standard CRUD + transition actions)StockTransfersController rewritten — no more create doing source/dest decrement; create persists a draft. Transitions are explicit actions.spree/api/spec/integration/spree/api/v3/admin/.useTransfers, useTransfer, useTransition<Transfer> hooks (consolidate the action endpoints behind a single hook with action: 'mark_ready' | 'receive' | 'cancel').useVendors._authenticated/$storeId/products/transfers.{index,$id}.tsx, _authenticated/$storeId/products/purchase-orders.{index,$id}.tsx, _authenticated/$storeId/settings/vendors.tsx.<StockMovementsHistory variantId> panel; stock-location detail page gets a <StockMovementsHistory stockLocationId> panel. Both read /admin/stock_movements with the relevant q[...] filter and render the timeline with RelativeTime.StockTransfer.receive / StockTransfer.transfer no-source code path (already deprecated).type STI column on spree_stock_transfers after confirming no plugin set it.StockTransfer.transfer API. It is going away. New code should write to StockTransferItem and use the state machine.StockTransfer without source_location_id. Vendor receives move to PurchaseOrder. If a 5.5 feature needs to record a vendor receive, scope it minimally and flag it for migration.StockTransfer. Transfers don't have a unit cost — they move existing inventory between owned locations. Cost lives on PurchaseOrderItem.unit_cost.T-xxx number for historical reporting. Lean toward soft-delete with a release-note + a rake task to purge after one release.Spree::Vendor with a M2M to stores in a follow-up. Defer.unit_cost enables margin reporting. The natural extension is Variant.average_cost (rolling avg of received unit costs) for COGS. Likely worth a separate plan — call it out, don't scope it here.6.0-fulfillment-and-delivery.md mentions pickup_stock_policy: 'any' as enabling ship-to-store. The natural implementation is auto-creating a StockTransfer when a pickup order's items aren't local at the chosen pickup location. Mechanism is straightforward; UX (does the customer see "ready in 3 days" instead of "ready in 2 hours"?) needs a separate decision. Defer.spree/core/app/models/spree/stock_transfer.rbspree/api/app/controllers/spree/api/v3/admin/stock_transfers_controller.rbpackages/dashboard/src/routes/_authenticated/$storeId/products/transfers.tsx (already top-level after the prep PR)docs/plans/6.0-typed-stock-movements.md (movement primitives)docs/plans/6.0-fulfillment-and-delivery.md (Fulfillment writes shipped/allocated/released movements that show up alongside transfer/PO movements in the variant history)docs/plans/6.0-stock-reservations.md (reservations are checkout-time holds, orthogonal to operational receives)