Back to Spree

Inventory Operations (Transfers + Purchase Orders + Variant History)

docs/plans/6.0-inventory-operations.md

5.5.028.6 KB
Original Source

Inventory Operations (Transfers + Purchase Orders + Variant History)

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

Summary

Lift inventory operations from "atomic decrement + log" to a first-class workflow on par with Shopify. Three coordinated changes:

  1. 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.
  2. New 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.
  3. Variant + Location stock history. Surfaces the audit feed introduced by 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.

Problem

1. Stock transfers don't model reality

A box moves from warehouse A to warehouse B over multiple days. Today's StockTransfer collapses the entire trip into one transaction:

ruby
source.unstock(variant, qty, self)
destination.restock(variant, qty, self)

This means:

  • A merchant cannot record "5 units left warehouse A today, arriving tomorrow" — the destination's count_on_hand jumps the moment the source decrements, so availability is wrong for the intervening period.
  • There is no partial-receive workflow. If 10 went out and 8 arrived (2 damaged in transit), there's no model for the discrepancy.
  • There is no "draft" state — a transfer can't be planned and edited before it ships.
  • There is no per-line-item record. A transfer of 5 SKUs becomes 5 independent rows whose only link is stock_movements.originator_id = transfer.id.

2. "External receive" is a hack

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.

3. Movement history has no UI surface

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.

Current State

WhatHow it works (5.5)
Spree::StockTransferOne-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 receiveStockTransfer.transfer(nil, destination, variants) — no vendor, no cost.
Movement auditSpree::StockMovement with polymorphic originator. Only StockTransfer, ReturnAuthorization, and nil show up in practice. Shipment fulfillment does not write movements (lives on InventoryUnit instead).
Admin SPAProducts → Transfers (single page) with create-transfer sheet. No history view, no PO concept.

Key Decisions (do not deviate without discussion)

Transfers

  • 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.
  • Two-phase stock movement, mirroring Shopify. Source's 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.
  • First-class line items. New table 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".
  • Partial receive is the default model. The receive screen edits 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.
  • Cancelled is terminal. Cancelling a 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.

Purchase Orders

  • New model 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.
  • Status machine: 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.
  • PO does not affect 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.

Typed Stock Movements wiring

These additions live in 6.0-typed-stock-movements.md; this plan only consumes them.

  • Add 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).
  • Add 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.

Admin SPA UX

  • Top-level Products subnav: Transfers, Purchase Orders. Each is a list page with status filters (Draft / Ready / In transit / Received / All).
  • Transfer detail page has three modes by status:
    • 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.
  • Purchase Order detail mirrors transfer detail. draft → editable line items + costs. ordered → frozen line items, "Receive" enabled. Same partial-receive screen.
  • Variant Inventory tab (on Product detail) gains a History panel. Reads 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").
  • Stock Location detail page (settings) gains an Activity tab. Same data shape, scoped via q[stock_item_stock_location_id_eq]. Useful for warehouse reconciliation.
  • No standalone "Stock Movements" page. Movements are always read in the context of a variant or a location. The legacy admin's firehose list is gone.

Design Details

StockTransfer (updated)

ruby
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

StockTransferItem (new)

ruby
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

PurchaseOrder (new)

ruby
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

PurchaseOrderItem (new)

ruby
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

Vendor (new)

ruby
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

Stock movement extensions

Added to Spree::StockMovement (extends what 6.0-typed-stock-movements.md introduces):

ruby
belongs_to :purchase_order, class_name: 'Spree::PurchaseOrder', optional: true
ruby
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.

Admin API

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.

Serializer shapes

json
// 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"
}

Migration Path

Phase 0 (prerequisite)

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.

Phase 1: Schema

ruby
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

Phase 2: Data migration (rake task spree:migrate_external_receives_to_purchase_orders)

For each existing StockTransfer with source_location_id IS NULL:

  1. Find-or-create a Spree::Vendor named "Migrated receives" for the relevant store.
  2. Create a Spree::PurchaseOrder with status: 'received', received_at: transfer.created_at, currency from store default, ordered_at: nil.
  3. Migrate the transfer's 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.
  4. Create PurchaseOrderItem rows from the underlying movements with unit_cost_cents: 0 (historical cost is unknown).
  5. Mark the original 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.

Phase 3: Model & service code

  • New models: Vendor, PurchaseOrder, PurchaseOrderItem, StockTransferItem.
  • StockTransfer gains state machine + line items + transfer/receive methods removed (the old atomic API).
  • New services under 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::CancelTransfer
    • Parallel Spree::Inventory::SubmitPurchaseOrder / Spree::Inventory::ReceivePurchaseOrder / Spree::Inventory::CancelPurchaseOrder
  • Each service returns a ServiceModule::Result (success/error) — same pattern as existing checkout services.
  • CanCanCan: 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.

Phase 4: Admin API + serializers

  • Register dependencies (admin_vendor_serializer, admin_purchase_order_serializer, admin_purchase_order_item_serializer, admin_stock_transfer_item_serializer).
  • Register API scopes: read_inventory / write_inventory for POs and vendors. Transfers stay under read_stock / write_stock (existing).
  • Controllers under 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.
  • New integration specs in spree/api/spec/integration/spree/api/v3/admin/.

Phase 5: SDK + admin SPA

  • Run typelizer + rswag + admin-client generators.
  • useTransfers, useTransfer, useTransition<Transfer> hooks (consolidate the action endpoints behind a single hook with action: 'mark_ready' | 'receive' | 'cancel').
  • Same shape for purchase orders.
  • useVendors.
  • Admin SPA routes: _authenticated/$storeId/products/transfers.{index,$id}.tsx, _authenticated/$storeId/products/purchase-orders.{index,$id}.tsx, _authenticated/$storeId/settings/vendors.tsx.
  • Variant detail page gets a <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.

Phase 6: Cleanup

  • Remove the StockTransfer.receive / StockTransfer.transfer no-source code path (already deprecated).
  • Drop the unused type STI column on spree_stock_transfers after confirming no plugin set it.

Constraints on Current Work

  • Don't extend the old StockTransfer.transfer API. It is going away. New code should write to StockTransferItem and use the state machine.
  • Don't add receive flows to 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.
  • Don't create a separate "Stock Movements" admin SPA page. Movement history is always inline on variant + stock location.
  • Don't fold cost data onto StockTransfer. Transfers don't have a unit cost — they move existing inventory between owned locations. Cost lives on PurchaseOrderItem.unit_cost.

Open Questions

  1. Historical "external receive" transfers — keep or hard-delete after migration? Soft-keeping preserves audit history at the cost of a confusing dual-source-of-truth (movements visible from both the migrated PO and the legacy transfer). Hard-deleting cleans the model but loses the original T-xxx number for historical reporting. Lean toward soft-delete with a release-note + a rake task to purge after one release.
  2. Vendors shared across stores? Today the plan scopes them per-store. Some merchants run multiple storefronts off one supplier base. Could promote to a Spree::Vendor with a M2M to stores in a follow-up. Defer.
  3. Average / weighted-average cost on Variant? A PO's 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.
  4. Pickup transfers (ship-to-store). 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.

References

  • Current StockTransfer model: spree/core/app/models/spree/stock_transfer.rb
  • Current StockTransfer admin controller: spree/api/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb
  • Cheap-win route promotion: packages/dashboard/src/routes/_authenticated/$storeId/products/transfers.tsx (already top-level after the prep PR)
  • Depends on: docs/plans/6.0-typed-stock-movements.md (movement primitives)
  • Depends on: 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)
  • Related: docs/plans/6.0-stock-reservations.md (reservations are checkout-time holds, orthogonal to operational receives)