@spree/docs 0.1.223 → 0.1.225
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -17,10 +17,19 @@ erDiagram
|
|
|
17
17
|
StockLevel ||--o{ StockReservation : "held by checkouts"
|
|
18
18
|
Order ||--o{ Fulfillment : "ships as"
|
|
19
19
|
Fulfillment ||--o{ FulfillmentItem : "contains"
|
|
20
|
+
Supplier ||--o{ PurchaseOrder : "supplies"
|
|
21
|
+
PurchaseOrder ||--o{ PurchaseOrderItem : "orders"
|
|
22
|
+
StockTransfer ||--o{ StockTransferItem : "moves"
|
|
23
|
+
PurchaseOrder ||--o{ StockReceipt : "received as"
|
|
24
|
+
StockTransfer ||--o{ StockReceipt : "received as"
|
|
25
|
+
StockReceipt ||--o{ StockReceiptItem : "counts"
|
|
26
|
+
StockReceipt ||--o{ StockMovement : "lands"
|
|
20
27
|
|
|
21
28
|
StockLevel {
|
|
22
29
|
integer count_on_hand
|
|
23
30
|
integer allocated_count
|
|
31
|
+
integer reserved_count
|
|
32
|
+
integer incoming_count
|
|
24
33
|
boolean backorderable
|
|
25
34
|
}
|
|
26
35
|
StockLocation {
|
|
@@ -30,15 +39,21 @@ erDiagram
|
|
|
30
39
|
}
|
|
31
40
|
```
|
|
32
41
|
|
|
33
|
-
## The
|
|
42
|
+
## The five numbers
|
|
34
43
|
|
|
35
|
-
A stock level carries
|
|
44
|
+
A stock level carries four counts, and a fifth is derived from them. Keeping them apart is what stops a warehouse from disagreeing with a website.
|
|
36
45
|
|
|
37
46
|
| | What it means |
|
|
38
47
|
|---|---|
|
|
39
48
|
| `count_on_hand` | What is physically on the shelf, right now |
|
|
40
49
|
| `allocated_count` | How much of that is already spoken for by orders not yet shipped |
|
|
41
|
-
|
|
|
50
|
+
| `reserved_count` | How much is held by checkouts still in progress |
|
|
51
|
+
| `incoming_count` | How much is on its way here — on a purchase order placed with a supplier, or a transfer already in transit |
|
|
52
|
+
| **`available_count`** | `count_on_hand − allocated_count` — what's actually sellable, before checkout holds |
|
|
53
|
+
|
|
54
|
+
Reserved and incoming are kept as counters rather than worked out on every read, because the Inventory page in the dashboard reads them for every row, many times a day, and only a handful of things change them: entering or leaving checkout moves reserved; placing, cancelling, closing or receiving a purchase order or transfer moves incoming. Nothing else writes them. If a figure ever looks wrong, `rake spree:stock:recount_levels` recomputes both from their sources and reports every row it corrected — the same task the 6.0 upgrade runs to fill them in for existing stock.
|
|
55
|
+
|
|
56
|
+
A transfer counts as incoming from the moment it is marked in transit, not when it is drafted: until the van leaves, nothing is moving. Units a delivery rejects at the dock leave incoming too — they are recorded on the receipt, but they are not coming.
|
|
42
57
|
|
|
43
58
|
> **NOTE:** **Selling an item does not reduce `count_on_hand`.** Placing an order raises `allocated_count`; the physical count only drops when the parcel actually ships.
|
|
44
59
|
>
|
|
@@ -53,10 +68,11 @@ A stock location is somewhere stock physically sits: a warehouse, a shop, a thir
|
|
|
53
68
|
| `name` | What staff call it |
|
|
54
69
|
| `active` | Whether it can be used for new orders |
|
|
55
70
|
| `default` | Used first when nothing else decides |
|
|
71
|
+
| `kind` | What sort of place it is — `warehouse`, `store` or `fulfillment_center` |
|
|
56
72
|
| `country_code`, `state_code`, `city`, `postal_code` | Where it is — used for shipping rates and for finding the nearest one |
|
|
57
73
|
| `backorderable_default` | Whether new stock levels here allow backorders |
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
74
|
+
| `pickup_enabled` | Whether customers can collect here |
|
|
75
|
+
| `returns_enabled` | Whether customers can bring returns here |
|
|
60
76
|
|
|
61
77
|
```typescript Admin SDK
|
|
62
78
|
const { data: locations } = await adminClient.stockLocations.list()
|
|
@@ -77,11 +93,11 @@ A stock level is the intersection of a variant and a location. They're created f
|
|
|
77
93
|
```typescript Admin SDK
|
|
78
94
|
// Stock for one location
|
|
79
95
|
const { data: levels } = await adminClient.stockLevels.list({
|
|
80
|
-
|
|
96
|
+
stock_location_id_eq: 'sloc_xxx',
|
|
81
97
|
})
|
|
82
98
|
|
|
83
99
|
// Correct a single count
|
|
84
|
-
await adminClient.stockLevels.update('
|
|
100
|
+
await adminClient.stockLevels.update('sl_xxx', {
|
|
85
101
|
count_on_hand: 150,
|
|
86
102
|
backorderable: true,
|
|
87
103
|
})
|
|
@@ -89,15 +105,15 @@ await adminClient.stockLevels.update('slv_xxx', {
|
|
|
89
105
|
// Set many at once — the right call for a nightly sync
|
|
90
106
|
await adminClient.stockLevels.bulkUpsert({
|
|
91
107
|
stock_levels: [
|
|
92
|
-
{ variant_id: '
|
|
93
|
-
{ variant_id: '
|
|
108
|
+
{ variant_id: 'variant_xxx', stock_location_id: 'sloc_xxx', count_on_hand: 40 },
|
|
109
|
+
{ variant_id: 'variant_yyy', stock_location_id: 'sloc_xxx', count_on_hand: 12 },
|
|
94
110
|
],
|
|
95
111
|
})
|
|
96
112
|
```
|
|
97
113
|
|
|
98
114
|
```bash CLI
|
|
99
|
-
spree api get '/stock_levels?q[stock_location_id_eq]=
|
|
100
|
-
spree api patch /stock_levels/
|
|
115
|
+
spree api get '/stock_levels?q[stock_location_id_eq]=sloc_xxx'
|
|
116
|
+
spree api patch /stock_levels/sl_xxx -d '{"count_on_hand": 150}'
|
|
101
117
|
```
|
|
102
118
|
|
|
103
119
|
|
|
@@ -116,12 +132,15 @@ Every change to stock is recorded as a movement, so "why is this number what it
|
|
|
116
132
|
| `adjusted` | A manual correction, such as after a stock count |
|
|
117
133
|
|
|
118
134
|
```typescript Admin SDK
|
|
135
|
+
// Everything that happened to one SKU, across every location
|
|
119
136
|
const { data: movements } = await adminClient.stockMovements.list({
|
|
120
|
-
|
|
137
|
+
stock_level_variant_id_eq: 'variant_xxx',
|
|
121
138
|
})
|
|
122
139
|
```
|
|
123
140
|
|
|
124
|
-
Each movement points at what caused it — the order, the fulfillment, the transfer — so an audit trail reads as a sequence of business events rather than a list of numbers that changed.
|
|
141
|
+
Each movement points at what caused it — the order, the fulfillment, the transfer, the purchase order, and for stock that arrived, the delivery it came in on — so an audit trail reads as a sequence of business events rather than a list of numbers that changed.
|
|
142
|
+
|
|
143
|
+
A movement that arrived through a purchase order also carries what the units cost, so "what did this stock cost us" is answerable from the ledger rather than reconstructed from the orders that bought it.
|
|
125
144
|
|
|
126
145
|
## What a shopper sees
|
|
127
146
|
|
|
@@ -151,7 +170,7 @@ A reservation is a short, time-limited hold placed when a customer enters checko
|
|
|
151
170
|
| They complete the order | The hold becomes a real allocation |
|
|
152
171
|
| They abandon it | The hold expires and stock returns |
|
|
153
172
|
|
|
154
|
-
Reservations never touch `count_on_hand` — they're a separate layer consulted when availability is read. A backorderable item is skipped entirely, since unlimited supply needs no holding.
|
|
173
|
+
Reservations never touch `count_on_hand` — they're a separate layer consulted when availability is read, and their total per stock level is what `reserved_count` shows. A backorderable item is skipped entirely, since unlimited supply needs no holding.
|
|
155
174
|
|
|
156
175
|
How long the hold lasts is a **store setting**, because it's a decision about the checkout experience rather than a property of a warehouse. Reservations can also be switched off entirely.
|
|
157
176
|
|
|
@@ -165,12 +184,108 @@ When new stock arrives, backorders are filled first, and only the remainder beco
|
|
|
165
184
|
|
|
166
185
|
## Moving stock between locations
|
|
167
186
|
|
|
168
|
-
A stock transfer moves inventory from one location to another, and reflects that this takes time: stock is booked out of the source, is in transit, and is
|
|
187
|
+
A stock transfer moves inventory from one location to another, and reflects that this takes time: stock is booked out of the source, is in transit, and is counted in at the destination — where a partial delivery is a normal outcome, because sometimes not everything arrives at once.
|
|
188
|
+
|
|
189
|
+
That is four steps, not one. A transfer is planned as a draft, packed, sent, and finally counted in:
|
|
190
|
+
|
|
191
|
+
```typescript Admin SDK
|
|
192
|
+
const transfer = await adminClient.stockTransfers.create({
|
|
193
|
+
source_location_id: 'sloc_xxx',
|
|
194
|
+
destination_location_id: 'sloc_yyy',
|
|
195
|
+
items: [{ variant_id: 'variant_xxx', quantity_shipped: 10 }],
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// The units leave the source here — and are on no shelf until they arrive.
|
|
199
|
+
await adminClient.stockTransfers.markInTransit(transfer.id)
|
|
200
|
+
|
|
201
|
+
// Eight arrived intact and two were crushed. See "Counting a delivery in".
|
|
202
|
+
await adminClient.stockTransfers.stockReceipts.create(transfer.id, {
|
|
203
|
+
items: [{ id: 'sti_xxx', quantity_accepted: 8, quantity_rejected: 2, rejection_reason: 'damaged' }],
|
|
204
|
+
})
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
| Status | What it means |
|
|
208
|
+
|---|---|
|
|
209
|
+
| `draft` | A plan. Lines can still change; nothing has moved |
|
|
210
|
+
| `ready_to_ship` | Packed. Lines are frozen, nothing has left yet — `markDraft` unfreezes it |
|
|
211
|
+
| `in_transit` | The units have left the source and are on no shelf |
|
|
212
|
+
| `partially_received` | Some units have been counted in; the rest are still on the road |
|
|
213
|
+
| `received` | Everything that left has arrived — or the balance was closed short |
|
|
214
|
+
| `over_received` | More arrived than was sent |
|
|
215
|
+
| `canceled` | Called off. Once units have left, the merchant says whether they come back or are written off |
|
|
216
|
+
|
|
217
|
+
Two things follow from the middle step. Availability at the destination does not rise until somebody counts the goods in, which is what makes the number trustworthy. And cancelling a transfer whose units have already left is a decision rather than an undo: either they come back to the source, or they are written off as lost.
|
|
218
|
+
|
|
219
|
+
## Buying stock in
|
|
220
|
+
|
|
221
|
+
Stock that comes from a supplier is a purchase order, not a transfer — it has a supplier, a cost per unit and a date it was promised for, none of which apply to moving goods you already own.
|
|
222
|
+
|
|
223
|
+
```typescript Admin SDK
|
|
224
|
+
const order = await adminClient.purchaseOrders.create({
|
|
225
|
+
supplier_id: 'sup_xxx',
|
|
226
|
+
destination_location_id: 'sloc_xxx',
|
|
227
|
+
expected_at: '2026-10-01',
|
|
228
|
+
cancel_by: '2026-10-15',
|
|
229
|
+
items: [{ variant_id: 'variant_xxx', quantity_ordered: 100, unit_cost: '12.50' }],
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
await adminClient.purchaseOrders.markOrdered(order.id)
|
|
233
|
+
|
|
234
|
+
// A delivery arrives: 58 accepted, 2 refused. The order stays open for the rest.
|
|
235
|
+
await adminClient.purchaseOrders.stockReceipts.create(order.id, {
|
|
236
|
+
reference: 'DN-4471',
|
|
237
|
+
items: [{ id: 'poi_xxx', quantity_accepted: 58, quantity_rejected: 2, rejection_reason: 'damaged' }],
|
|
238
|
+
})
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
| Status | What it means |
|
|
242
|
+
|---|---|
|
|
243
|
+
| `draft` | Being put together. Lines and costs can still change |
|
|
244
|
+
| `ordered` | Sent to the supplier. Lines are frozen — `markDraft` reopens it while nothing has arrived |
|
|
245
|
+
| `partially_received` | Some units have been counted in; the supplier still owes the rest |
|
|
246
|
+
| `received` | Every line has what it expected — or the balance was closed short |
|
|
247
|
+
| `over_received` | The supplier sent more than was ordered |
|
|
248
|
+
| `canceled` | Called off. Units already received stay on the shelf |
|
|
249
|
+
|
|
250
|
+
Ordered stock is never available stock. Nothing reaches `count_on_hand` until a delivery is counted in, because a merchant who has ordered goods does not have them — and suppliers under-ship often enough that receiving in parts is the ordinary case.
|
|
251
|
+
|
|
252
|
+
An order carries the day the supplier promised (`expected_at`) and, optionally, the day after which the goods are no longer wanted (`cancel_by`). Both are calendar dates. The dashboard lists filter on *Overdue* and *Past cancel-by*; nothing cancels automatically, because that is a call to confirm with the supplier.
|
|
253
|
+
|
|
254
|
+
Purchase orders export to CSV, one row per line, which is the file a merchant sends a supplier. They import from one too: rows sharing a `reference` become one draft order, naming the supplier, warehouse and SKU by name — a name that does not exist fails the row rather than creating anything.
|
|
255
|
+
|
|
256
|
+
Suppliers themselves are an address book, kept per store:
|
|
257
|
+
|
|
258
|
+
```typescript Admin SDK
|
|
259
|
+
const { data: suppliers } = await adminClient.suppliers.list()
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Counting a delivery in
|
|
263
|
+
|
|
264
|
+
Transfers and purchase orders are received the same way: each delivery is a **stock receipt**, numbered and dated, carrying the packing-slip reference and — per line — what was accepted and what was refused, with a reason.
|
|
169
265
|
|
|
170
266
|
```typescript Admin SDK
|
|
171
|
-
const { data:
|
|
267
|
+
const { data: deliveries } = await adminClient.purchaseOrders.stockReceipts.list(order.id, {
|
|
268
|
+
expand: ['items'],
|
|
269
|
+
})
|
|
172
270
|
```
|
|
173
271
|
|
|
272
|
+
A receipt records *this delivery's* counts, not running totals: a second box adds to the first rather than restating it. Accepted units land on the destination's shelf, each as a `received` movement that names the receipt. Refused units are recorded and never stocked; the reason is one of `damaged`, `wrong_item`, `expired` or `other`. Omit `items` to count in everything still outstanding, intact.
|
|
273
|
+
|
|
274
|
+
> **NOTE:** **Refused units count differently on the two documents.** A supplier's refused units are still owed — the goods go back, and the order stays `partially_received` until they are replaced. A transfer's refused units have *arrived*, in whatever state: a crushed unit is not still in the van, so it counts toward the trip being over.
|
|
275
|
+
|
|
276
|
+
When the balance is not coming — the supplier is out of stock, the missing units fell off the van — the document is **closed short**: it settles in `received` with `closed_short_at` and the reason recorded, and the outstanding count stays on each line as the record of the gap. Nothing moves.
|
|
277
|
+
|
|
278
|
+
```typescript Admin SDK
|
|
279
|
+
await adminClient.purchaseOrders.close(order.id, { reason: 'Supplier out of stock' })
|
|
280
|
+
await adminClient.stockTransfers.close(transfer.id, { reason: 'Two fell off the van' })
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## The Inventory page
|
|
284
|
+
|
|
285
|
+
The dashboard's Inventory section opens on a list with one row per variant per location: on hand, allocated, reserved, available and incoming — the page uses the column names, with the location as a filter and SKU or product name as the search. Every figure on it is a column read off the stock level.
|
|
286
|
+
|
|
287
|
+
The on-hand figure is corrected in place — set it to a count, or adjust it by a delta, and say why — and the change lands in the stock history like any other adjustment. The incoming figure links to the two ways of making it grow: a transfer from another warehouse, or a purchase order from a supplier.
|
|
288
|
+
|
|
174
289
|
## Turning tracking off
|
|
175
290
|
|
|
176
291
|
Some things don't need counting — a service, a made-to-order item, a digital download. Tracking can be switched off, and those variants are always purchasable.
|
|
@@ -71,7 +71,9 @@ await client.customers.addresses.create('cus_xxx', {
|
|
|
71
71
|
|---|---|
|
|
72
72
|
| `client.stockLocations` | CRUD on stock locations. |
|
|
73
73
|
| `client.stockItems` | List, get, update, delete stock items. |
|
|
74
|
-
| `client.stockTransfers` |
|
|
74
|
+
| `client.stockTransfers` | CRUD plus the lifecycle: `markReady`, `markInTransit`, `receive`, `cancel`. |
|
|
75
|
+
| `client.suppliers` | CRUD on the suppliers you buy from. |
|
|
76
|
+
| `client.purchaseOrders` | CRUD plus `markOrdered`, `receive`, `cancel`. |
|
|
75
77
|
|
|
76
78
|
## Sales channels
|
|
77
79
|
|
|
@@ -130,6 +130,14 @@ bundle exec rake spree:backfill_order_markets
|
|
|
130
130
|
|
|
131
131
|
Orders missing a market get the store default — `market` is required on carts and orders in 6.0.
|
|
132
132
|
|
|
133
|
+
### Fill in the stock level counters
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
bundle exec rake spree:stock:recount_levels
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`spree_stock_levels` gains `reserved_count` (units held by checkouts in progress) and `incoming_count` (units on their way on a purchase order placed with a supplier or a transfer in transit). Both are kept by the workflows that change them and start at zero on an existing install; this recomputes them from active reservations and open documents and prints every level it corrected. Until it runs, the dashboard's Inventory page shows zero reserved and incoming for stock that predates the upgrade. Safe to re-run at any time.
|
|
140
|
+
|
|
133
141
|
## The Cart/Order split
|
|
134
142
|
|
|
135
143
|
The single biggest change. What used to be one `Spree::Order` living through checkout and beyond is now two models:
|