@spree/docs 0.1.225 → 0.1.227

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.
@@ -51,7 +51,7 @@ A stock level carries four counts, and a fifth is derived from them. Keeping the
51
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
52
  | **`available_count`** | `count_on_hand − allocated_count` — what's actually sellable, before checkout holds |
53
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.
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; marking a purchase order ordered or a transfer in transit adds to incoming, and receiving, closing, cancelling or reopening the document takes the remainder back off. 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
55
 
56
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.
57
57
 
@@ -0,0 +1,277 @@
1
+ ---
2
+ title: Reporting
3
+ description: The semantic layer behind the dashboard home screen, the Reports page and saved reports — a registry of metrics and dimensions compiled into one query contract.
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ Spree answers analytics questions through a **semantic layer**: a registry of
9
+ *metrics* (numbers) and *dimensions* (ways to group and filter them), and one
10
+ query contract that compiles a combination of the two into SQL.
11
+
12
+ Developers extend the vocabulary. Merchants, the dashboard, saved reports and
13
+ future agent tools all compose sentences from it. Nobody writes a report class
14
+ per question.
15
+
16
+ ```mermaid
17
+ flowchart LR
18
+ R["Spree.reporting<br/>registry"] --> Q["Spree::Reporting::Query<br/>validates + normalizes"]
19
+ Q --> A["Adapter<br/>(Live: SQL over orders + line items)"]
20
+ A --> Res["Result<br/>rows + totals"]
21
+ Res --> API["POST /reporting/query"]
22
+ R --> S["GET /reporting/schema"]
23
+ API --> D["Dashboard home,<br/>Reports page,<br/>saved reports, CSV export"]
24
+ S --> D
25
+ ```
26
+
27
+ ## The query contract
28
+
29
+ A query names what to measure and how to slice it. This is the whole surface:
30
+
31
+ ```json
32
+ {
33
+ "metrics": ["total_sales", "orders"],
34
+ "dimensions": [{ "name": "completed_at", "grain": "week" }],
35
+ "filters": [{ "dimension": "channel", "op": "eq", "value": "ch_86Rf07xd4z" }],
36
+ "time_range": { "preset": "last_4_weeks" },
37
+ "compare": "previous_period",
38
+ "sort": "-total_sales",
39
+ "limit": 50
40
+ }
41
+ ```
42
+
43
+ | Field | Meaning |
44
+ |-------|---------|
45
+ | `metrics` | One or more registered metric names. Required. |
46
+ | `dimensions` | At most two. A `time` dimension also takes a `grain` (`day`, `week`, `month`). |
47
+ | `filters` | `eq` or `in` against a dimension. Values are prefixed IDs where the dimension identifies a record. |
48
+ | `time_range` | A named preset, or `since`/`until` as ISO 8601. A bare date covers the store's whole day. |
49
+ | `compare` | `previous_period` adds `previous` and `growth` to every metric. |
50
+ | `sort` / `limit` | Ranking controls; ignored for time series, which are always chronological and complete. |
51
+
52
+ Unknown members are rejected, never dropped. A malformed query answers 422,
53
+ not a partial result computed from the half the server understood.
54
+
55
+ > **NOTE:** `GET /api/v3/admin/reporting/schema` returns the vocabulary the **calling
56
+ > credential** may use, with labels, per-dimension compatible metrics, filter
57
+ > operators, enumerated values and time presets. Build pickers from it rather
58
+ > than hardcoding member names — that is how a new dimension reaches the UI
59
+ > without a dashboard change.
60
+
61
+ ## Metrics
62
+
63
+ A metric is an aggregate over one **base** relation. Core registers four, in
64
+ three families:
65
+
66
+ | Family | Bases | Anchored on |
67
+ |--------|-------|-------------|
68
+ | `sales` | `:orders`, `:line_items` | when the order completed |
69
+ | `payments` | `:payments` | when the payment was taken |
70
+ | `inventory` | `:stock_movements` | when the stock moved |
71
+
72
+ A query draws from **one family only**. The three answer different questions on
73
+ different clocks, so a payment total beside a units-received count is two
74
+ reports wearing one table — the query refuses the mix rather than inventing a
75
+ join between grains that have no honest relationship.
76
+
77
+ ```ruby
78
+ Spree.reporting.metric :total_sales,
79
+ sql: 'SUM(%{orders}.total)', base: :orders, format: :money
80
+ ```
81
+
82
+ - `sql` is a portable aggregate fragment. `%{orders}`, `%{line_items}`,
83
+ `%{variants}`, `%{products}`, `%{addresses}`, `%{product_categories}`,
84
+ `%{refunds}`, `%{fees}` and `%{commission_lines}` interpolate to real table
85
+ names.
86
+ - `format` is `:money`, `:integer`, `:decimal` or `:percent`. A `:money` metric
87
+ forces a single-currency scope and arrives with a formatted `display` string;
88
+ a `:percent` metric arrives as the number a merchant reads (`42.5`), not the
89
+ fraction.
90
+
91
+ Amounts are **never converted**. A query answers in one currency — the one
92
+ it names, or the store's default — and every base a money metric reads must
93
+ filter by it, or two currencies end up added together under one symbol. A
94
+ store selling in several therefore has as many answers as it has
95
+ currencies, which is why the home screen offers a currency alongside its
96
+ channel and date range.
97
+ - `ratio: [:numerator, :denominator]` defines a **derived** metric, computed
98
+ after aggregation so it is correct for both rows and totals. Average order
99
+ value is `ratio: %i[total_sales orders]`, never `AVG()`.
100
+
101
+ Money that belongs to an order but lives on another table — refunds,
102
+ commissions — is summed through a **correlated subquery** rather than a join.
103
+ An order with three refunds must stay one row; joining would multiply the base
104
+ row and inflate every other metric in the same query.
105
+
106
+ ### The sales chain
107
+
108
+ Core's sales metrics follow the sequence merchants and accountants already use,
109
+ so a Spree figure means what the same word means everywhere else:
110
+
111
+ ```
112
+ gross_sales − discounts − returns = net_sales
113
+ net_sales + shipping + duties + fees + taxes = total_sales
114
+ ```
115
+
116
+ `returns` counts money refunded **in the period the refund was issued**, not
117
+ the period of the original order. A report over a past range can therefore
118
+ change after the fact, which is the conventional treatment and the only one an
119
+ open accounting period can produce.
120
+
121
+ `cost_of_goods`, `gross_profit` and `gross_margin` read the line item's
122
+ `cost_price`. That column is nullable, so a variant with no cost contributes
123
+ zero and margin over an incompletely-costed catalogue reads high.
124
+
125
+ ## Dimensions
126
+
127
+ A dimension groups and filters. Its definition owns every behaviour keyed off
128
+ it, which is what lets an extension-registered dimension work end to end:
129
+
130
+ ```ruby
131
+ Spree.reporting.dimension :channel, base: :orders, column: :channel_id,
132
+ lookup: :channel,
133
+ subject: -> { Spree::Channel }, key_scope: 'read_channels',
134
+ resolve: ->(store, value) { store.channels.find_by_prefix_id!(value).id },
135
+ hydrate: lambda { |store, ids, _params|
136
+ store.channels.where(id: ids).to_h { |c| [c.id, { id: c.prefixed_id, label: c.name, meta: {} }] }
137
+ }
138
+ ```
139
+
140
+ | Option | Purpose |
141
+ |--------|---------|
142
+ | `base` | Which relation this axis belongs to. A base declares which other bases it reaches: `:line_items` reaches `:orders` through the order join, not the reverse. |
143
+ | `column` | A column on the base table, or a `%{table}.column` string. Must resolve to a plain identifier. |
144
+ | `expression` | SQL computing the key instead of reading a column — how `customer_type` decides first-time from returning. Use it only where a column genuinely cannot answer; it carries the same trust as a metric's `sql`. |
145
+ | `joins` | Association joins the grouping needs, applied only to the grouped query. |
146
+ | `type` | `:value` (default) or `:time`. Time dimensions take `grains` and zero-fill buckets. |
147
+ | `lookup` | Tags what the keys identify (`:product`, `:customer`), so clients know display payloads are coming. |
148
+ | `resolve` | Maps a filter's prefixed ID to the raw key, through store-scoped collections. |
149
+ | `hydrate` | Turns raw keys into `{ id:, label:, meta: }` for display. |
150
+ | `subject` / `key_scope` | Authorization — see below. |
151
+ | `values` | Enumerated raw values for status-like dimensions, published as the filter list. |
152
+
153
+ ## Authorization
154
+
155
+ Reporting never widens what a caller can see. Two axes are enforced together:
156
+
157
+ - **Staff (JWT)** need `read_reports` plus `:read` on each referenced
158
+ dimension's `subject`. Order data is always required.
159
+ - **API keys** need `read_reports` plus each dimension's `key_scope`.
160
+
161
+ Declaring `subject` without `key_scope` raises at registration, so a member
162
+ cannot ship with one axis unguarded. The schema endpoint filters by the same
163
+ rule, so a picker never offers a dimension whose query would be refused.
164
+
165
+ ## Counters
166
+
167
+ Not everything on the home screen is a report over a period. "Orders to
168
+ fulfill", "Open returns" and "Low stock" are the state of the store right now,
169
+ with no time range and no currency. The registry holds these as **counters**
170
+ beside metrics and dimensions, and `GET /dashboard/counters` evaluates them:
171
+
172
+ ```json
173
+ {
174
+ "channel_id": null,
175
+ "counters": [
176
+ {
177
+ "key": "orders_to_fulfill",
178
+ "value": 12,
179
+ "nav": "orders",
180
+ "link": {
181
+ "resource": "orders",
182
+ "filters": [{ "field": "fulfillment_status", "operator": "eq", "value": "unfulfilled" }]
183
+ }
184
+ }
185
+ ]
186
+ }
187
+ ```
188
+
189
+ One request serves both surfaces that show these numbers: the home screen's
190
+ card and the sidebar's badges. A parent nav entry sums its own counter and its
191
+ children's, because the sidebar only shows the children of the section you are
192
+ in — a count nobody sees until they have already navigated there tells them
193
+ nothing. Orders carries the total of what is waiting to ship plus the open
194
+ returns, exchanges and claims; expanding the section breaks it down.
195
+
196
+ Three things follow from counters living in the registry:
197
+
198
+ - **No copy crosses the wire.** A counter is a key and a number; the client
199
+ translates the key in its own language. An interface in one language can
200
+ never end up labelling a number in another.
201
+ - **Each counter carries its own `subject` and `key_scope`**, filtered by the
202
+ same rule as the rest of the vocabulary. A role without stock access gets
203
+ a shorter list, not a refused card.
204
+ - **The link is declared beside the count.** The list a number opens shows
205
+ exactly the rows that were counted, because the filter and the query are
206
+ registered together — the counter and the list share one scope rather than
207
+ each expressing the question their own way. A counter with no list that can
208
+ honestly show what it counted carries no link.
209
+
210
+ Core ships seven: orders to fulfill, payments to collect, open returns, open
211
+ exchanges, open claims, low stock and out of stock. Orders to fulfill and the
212
+ three post-sale counters badge the sidebar; payments to collect deliberately
213
+ does not, since a badge that is lit on almost every store stops carrying
214
+ information.
215
+
216
+ The two stock counters read the shelves exactly as the Inventory page does —
217
+ what a customer could still buy, counted per location — so the number and the
218
+ filtered list it opens always agree. The low stock one reads the store's
219
+ `low_stock_threshold` preference (default five units; zero turns it off),
220
+ which merchants set under store settings → Inventory.
221
+
222
+ ## What a report counts
223
+
224
+ Two rules decide what the numbers mean:
225
+
226
+ **Canceled orders are excluded.** A canceled order keeps its `completed_at`,
227
+ so both bases filter it out. Sales figures count what stayed sold. Refunds are
228
+ unaffected — they already net out of `payment_total`.
229
+
230
+ **The Total row is the dimensionless figure.** Grouping joins apply only to
231
+ the grouped query; totals run on the base rows with the same filters. A
232
+ product in three categories is counted once in the footer, and an order
233
+ without a shipping address still counts in "Sales by country". A consequence
234
+ worth stating to merchants: **rows of a fan-out breakdown need not sum to the
235
+ total**, and that is correct.
236
+
237
+ Time buckets resolve in the store's timezone, and a comparison period is the
238
+ range shifted back by its own length, paired bucket by bucket from the start.
239
+
240
+ ## Saved reports
241
+
242
+ A saved report (`Spree::SavedReport`, prefixed ID `sq_…`) is a stored query
243
+ plus a name, owned by the store and visible to every staff member who may read
244
+ reports. Its visualization is inferred from the query's shape rather than
245
+ configured: a time dimension charts, any other dimension ranks, no dimension
246
+ shows totals.
247
+
248
+ Nine built-in reports are seeded per store. They are read-only on the model,
249
+ not merely in the UI — copy one to change it.
250
+
251
+ CSV export rides the existing [export pipeline](imports-exports.md)
252
+ as `Spree::Exports::Report`, so it inherits the background job, attachment and
253
+ email. The export re-authorizes its members against the requesting user, which
254
+ is why it requires a user and API keys cannot queue one.
255
+
256
+ ## Current limits
257
+
258
+ Worth knowing before you design an extension:
259
+
260
+ - **A new base is a registration, but a new table is not.** `registry.base`
261
+ adds a relation to query; the `%{table}` interpolation map is still fixed, so
262
+ a base over your own table needs an entry added to the adapter.
263
+ - **Columns are identifiers, not expressions.** A dimension's `column` must
264
+ resolve to `table.column`; computed groupings such as `SUBSTR(...)` are
265
+ refused.
266
+ - **The dashboard maps `lookup` to a picker from a fixed list.** A new lookup
267
+ value still filters correctly, but through a plain ID input.
268
+
269
+ So a new axis or number over data core already knows is a registration; a base
270
+ over a table core has never seen still needs one line in the adapter's table
271
+ map.
272
+
273
+ ## Related
274
+
275
+ - [Extend reporting](../how-to/extend-reporting.md) — a worked example
276
+ - [Imports and exports](imports-exports.md) — the CSV pipeline
277
+ - [Permissions](../dashboard/customization/permissions.md) — `read_reports` and member scopes
@@ -0,0 +1,215 @@
1
+ ---
2
+ title: Extend Reporting
3
+ description: Register your own metrics and dimensions so merchants can compose them in the Reports builder, saved reports and CSV exports.
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ Reporting is extended by adding *vocabulary*, not by writing report classes.
9
+ Register a metric or a dimension in an initializer and it becomes available
10
+ everywhere at once: the Reports builder, saved reports, CSV export and the
11
+ schema endpoint.
12
+
13
+ Read [how reporting works](../core-concepts/reporting.md) first — this
14
+ guide assumes the query contract and the metric/dimension split.
15
+
16
+ ## Add a metric
17
+
18
+ A metric is an aggregate over one registered base — `:orders`, `:line_items`,
19
+ `:payments` or `:stock_movements`:
20
+
21
+ ```ruby config/initializers/spree.rb
22
+ Rails.application.config.after_initialize do
23
+ Spree.reporting.metric :items_per_order,
24
+ sql: 'SUM(%{line_items}.quantity)',
25
+ base: :line_items,
26
+ format: :integer
27
+ end
28
+ ```
29
+
30
+ That is the whole change. The metric appears in `GET /reporting/schema`, in
31
+ the builder's metric list, and can be saved into a report.
32
+
33
+ Give it a label so merchants do not read the raw name:
34
+
35
+ ```yaml config/locales/en.yml
36
+ en:
37
+ spree:
38
+ reporting:
39
+ metrics:
40
+ items_per_order:
41
+ label: Items per order
42
+ description: Units sold divided across orders in the period.
43
+ ```
44
+
45
+ ### Derived metrics
46
+
47
+ A ratio is computed after aggregation, so it stays correct for both rows and
48
+ totals — which a plain `AVG()` would not:
49
+
50
+ ```ruby
51
+ Spree.reporting.metric :average_units_per_order,
52
+ ratio: %i[units_sold orders], format: :decimal
53
+ ```
54
+
55
+ ## Add a dimension
56
+
57
+ A dimension groups and filters. The simplest kind is a column on the base
58
+ table:
59
+
60
+ ```ruby
61
+ Spree.reporting.dimension :order_locale, base: :orders, column: :locale
62
+ ```
63
+
64
+ ### A dimension that identifies records
65
+
66
+ When the keys are IDs rather than plain values, declare four things together:
67
+ how to reach the table, how to resolve a filter value, how to render the key,
68
+ and who may see it.
69
+
70
+ ```ruby
71
+ Spree.reporting.dimension :seller, base: :line_items,
72
+ column: '%{variants}.seller_id',
73
+ joins: [:variant],
74
+ lookup: :seller,
75
+ subject: -> { Spree::Seller },
76
+ key_scope: 'read_sellers',
77
+ resolve: ->(store, value) { store.sellers.find_by_prefix_id!(value).id },
78
+ hydrate: lambda { |store, ids, _params|
79
+ store.sellers.where(id: ids).to_h do |seller|
80
+ [seller.id, { id: seller.prefixed_id, label: seller.name, meta: {} }]
81
+ end
82
+ }
83
+ ```
84
+
85
+ What each part buys you:
86
+
87
+ - `joins` is applied only to the grouped query, so the Total row stays the
88
+ store's real figure even when the join fans out.
89
+ - `resolve` lets a filter accept the prefixed IDs clients already hold.
90
+ Scope it through the store, so an ID from another store is a 404.
91
+ - `hydrate` is batched once per dimension per response, not per row.
92
+ - `subject` and `key_scope` are mandatory together. Staff need `:read` on the
93
+ subject; API keys need the scope. Omitting `key_scope` raises at
94
+ registration rather than shipping a member that only guards one axis.
95
+
96
+ ### Status-like dimensions
97
+
98
+ Publish the values so the builder can offer checkboxes instead of a text box:
99
+
100
+ ```ruby
101
+ Spree.reporting.dimension :subscription_state, base: :orders,
102
+ column: :subscription_state,
103
+ values: -> { MyExtension::SUBSCRIPTION_STATES }
104
+ ```
105
+
106
+ Use a lambda so model constants load lazily. Label the values under
107
+ `spree.reporting.values.<dimension>.<value>`; they are translated server-side,
108
+ so a plugin needs no dashboard locale edit.
109
+
110
+ ## Verify it
111
+
112
+ Query the contract directly:
113
+
114
+ ```ruby
115
+ Spree::Reporting::Query.new(
116
+ store: Spree::Store.default,
117
+ params: { metrics: %w[items_per_order], dimensions: %w[order_locale] }
118
+ ).execute
119
+ ```
120
+
121
+ Or over HTTP:
122
+
123
+ ```bash
124
+ curl -X POST https://example.com/api/v3/admin/reporting/query \
125
+ -H "X-Spree-API-Key: $SECRET_KEY" -H 'Content-Type: application/json' \
126
+ -d '{"metrics":["items_per_order"],"dimensions":["order_locale"]}'
127
+ ```
128
+
129
+ A spec is worth writing for the parts that are easy to get wrong — the
130
+ authorization declaration and the hydration payload:
131
+
132
+ ```ruby spec/lib/my_extension/reporting_spec.rb
133
+ RSpec.describe 'seller reporting vocabulary' do
134
+ let(:store) { @default_store }
135
+
136
+ it 'requires the seller subject and scope' do
137
+ query = Spree::Reporting::Query.new(
138
+ store: store, params: { metrics: %w[units_sold], dimensions: %w[seller] }
139
+ )
140
+
141
+ expect(query.required_subjects).to include(Spree::Seller)
142
+ expect(query.required_key_scopes).to include('read_sellers')
143
+ end
144
+ end
145
+ ```
146
+
147
+ ## Add a counter
148
+
149
+ A counter is a point-in-time number for the home screen's Operations card:
150
+ no time range, no currency, no base. It takes the store and the channel the
151
+ merchant is looking at and returns an integer.
152
+
153
+ ```ruby
154
+ Spree.reporting.counter :orders_on_hold,
155
+ subject: -> { Spree::Order }, key_scope: 'read_orders',
156
+ count: ->(store, channel:) { store.orders.complete.for_channel(channel).where(on_hold: true).count },
157
+ link: { resource: 'orders', filters: [{ field: 'on_hold', operator: 'eq', value: 'true' }] }
158
+ ```
159
+
160
+ The endpoint sends the key and the number, never any text — the dashboard
161
+ owns every string it shows. Add
162
+ `admin.pages.home.operations.counters.orders_on_hold.label`, and optionally
163
+ `.description`, to your dashboard locale files; a counter with no entry falls
164
+ back to its humanized key, so it reads sensibly before you translate it.
165
+
166
+ Pass `nav:` with a sidebar entry's key to badge that entry with this count.
167
+ Declare `link` only when the list filter shows exactly the rows counted — the
168
+ dashboard resolves `resource` to one of its lists (`orders`, `returns`,
169
+ `exchanges`, `claims`, `products`, `inventory`) and appends the channel filter
170
+ itself for order lists.
171
+
172
+ ## Overriding a built-in member
173
+
174
+ Registration refuses a duplicate name unless you say so explicitly:
175
+
176
+ ```ruby
177
+ Spree.reporting.metric :total_sales, replace: true,
178
+ sql: 'SUM(%{orders}.total - %{orders}.delivery_total)',
179
+ base: :orders, format: :money
180
+ ```
181
+
182
+ Redefining what a built-in number means changes every saved report using it,
183
+ including the seeded ones. Prefer a new name unless you intend exactly that.
184
+
185
+ ## What you cannot do yet
186
+
187
+ These are limits of the compiler, not of registration:
188
+
189
+ - **The `%{table}` map is fixed.** Registering a base over your own table
190
+ needs an entry added to the adapter's interpolation map — the one place a
191
+ new table still has to be named in core.
192
+ - **A dimension's column must be a plain identifier.** Computed groupings
193
+ such as `SUBSTR(email, ...)` are refused; add a real column, or a database
194
+ view, if you need one.
195
+ - **Custom `lookup` values fall back to an ID input** in the builder, since
196
+ the dashboard maps known lookups to pickers.
197
+
198
+ Registering a base is otherwise ordinary: declare its family, its table, a
199
+ store-scoped relation, its time column and which bases its dimensions reach.
200
+
201
+ ```ruby
202
+ Spree.reporting.base :subscriptions, family: :subscriptions,
203
+ table: '%{subscriptions}',
204
+ time_column: '%{subscriptions}.created_at',
205
+ relation: ->(store, range, _currency) { store.subscriptions.where(created_at: range) }
206
+ ```
207
+
208
+ Give it its own family unless its rows genuinely answer the same question, on
209
+ the same clock, as an existing one.
210
+
211
+ ## Related
212
+
213
+ - [Reporting](../core-concepts/reporting.md) — the concepts
214
+ - [Permissions](../dashboard/customization/permissions.md) — scopes and subjects
215
+ - [Imports and exports](../core-concepts/imports-exports.md) — row-level CSV
@@ -572,6 +572,18 @@ to `Spree::Base`.
572
572
 
573
573
  The `spree_state_changes` and `spree_log_entries` tables are **not dropped until 6.1**, so your existing rows survive the upgrade. If you want the history long-term, export it before upgrading to 6.1.
574
574
 
575
+ ### `Spree::Report` is gone
576
+
577
+ `Spree::Report`, its `Reports::SalesTotal` and `Reports::ProductsPerformance` subclasses, `Spree::ReportLineItem` and its subclasses, `Spree::ReportMailer`, `Spree::ReportSubscriber`, `Spree::Reports::GenerateJob`, the `Spree.reports` registry and the `reports` queue entry are all removed, along with the `reports` associations on `Spree::Store` and the admin user.
578
+
579
+ Nothing in 5.6 could reach it: there was no API endpoint and no admin screen, so a report could only be created from Ruby.
580
+
581
+ **Where each question goes now.** Aggregates — revenue by channel, product performance, anything you would have written a report class for — are [reporting](../core-concepts/reporting.md) queries, composed from registered metrics and dimensions rather than a class per question. Row-level CSV of records is a `Spree::Export` subclass, which is what [imports and exports](../core-concepts/imports-exports.md) covers, and unlike `Spree::Report` it has both an Admin API and a dashboard page.
582
+
583
+ **Your rows are safe.** The `spree_reports` and `spree_report_line_items` tables are left exactly as they are — nothing reads them, and no migration drops them. A store installing 6.0 fresh never creates them. If you want the old report history, export it whenever suits you; there is no deadline.
584
+
585
+ If you subclassed `Spree::Report` in your own application, that class no longer has a superclass and will raise on boot. Move the question to one of the two replacements above.
586
+
575
587
  ## Deprecated in 6.0, removed in 6.1
576
588
 
577
589
  Every rename keeps the legacy name working for one release with a deprecation warning. The notable ones:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spree/docs",
3
- "version": "0.1.225",
3
+ "version": "0.1.227",
4
4
  "description": "Spree Commerce developer documentation for AI agents and local reference",
5
5
  "type": "module",
6
6
  "license": "CC-BY-4.0",
@@ -1,387 +0,0 @@
1
- ---
2
- title: Build a Custom Report
3
- description: Step-by-step guide to creating custom reports with data queries, line item formatters, and CSV export.
4
- ---
5
-
6
- ## Overview
7
-
8
- Spree's reporting system is designed for extension. Each report is a pair of classes — a **Report** that defines the data query and a **ReportLineItem** that formats each row — registered in `Spree.reports` so it appears in the admin UI.
9
-
10
- This guide walks you through building a custom report from scratch, including advanced patterns for SQL aggregations and multi-vendor support.
11
-
12
- Before starting, make sure you understand [how the reporting system works](/v5/developer/core-concepts/reports).
13
-
14
- ## Creating a Custom Report
15
-
16
- ### Step 1: Create the Report Class
17
-
18
- Create a new report class inheriting from `Spree::Report`. The key method to implement is `line_items_scope`, which returns an `ActiveRecord::Relation` defining the records in your report:
19
-
20
- ```ruby app/models/spree/reports/customer_orders.rb
21
- module Spree
22
- module Reports
23
- class CustomerOrders < Spree::Report
24
- # Define the scope of records to include in the report
25
- def line_items_scope
26
- store.orders.complete.where(
27
- completed_at: date_from..date_to,
28
- currency: currency
29
- ).includes(:user, :bill_address)
30
- end
31
- end
32
- end
33
- end
34
- ```
35
-
36
- The `line_items_scope` method has access to:
37
-
38
- | Helper | Description |
39
- |--------|-------------|
40
- | `store` | The current store |
41
- | `date_from` | Report start date |
42
- | `date_to` | Report end date |
43
- | `currency` | Report currency |
44
- | `vendor` | Vendor (if multi-vendor is enabled) |
45
-
46
- ### Step 2: Create the Line Item Class
47
-
48
- Create a corresponding line item class that transforms each record into report columns. The class name must match the report class name (e.g., `Reports::CustomerOrders` → `ReportLineItems::CustomerOrders`):
49
-
50
- ```ruby app/models/spree/report_line_items/customer_orders.rb
51
- module Spree
52
- module ReportLineItems
53
- class CustomerOrders < Spree::ReportLineItem
54
- # Define attributes that will become columns
55
- attribute :order_number, :string
56
- attribute :completed_at, :string
57
- attribute :customer_email, :string
58
- attribute :customer_name, :string
59
- attribute :item_count, :integer
60
- attribute :total, :string
61
-
62
- # Map record fields to report columns
63
- def order_number
64
- record.number
65
- end
66
-
67
- def completed_at
68
- record.completed_at.strftime('%Y-%m-%d %H:%M')
69
- end
70
-
71
- def customer_email
72
- record.email
73
- end
74
-
75
- def customer_name
76
- record.bill_address&.full_name
77
- end
78
-
79
- def item_count
80
- record.line_items.sum(:quantity)
81
- end
82
-
83
- def total
84
- Spree::Money.new(record.total, currency: currency)
85
- end
86
- end
87
- end
88
- end
89
- ```
90
-
91
- **Important notes:**
92
- - Use `attribute` to define columns with their types — these become CSV headers
93
- - Each attribute needs a corresponding method that extracts/formats data from `record`
94
- - `record` is a single item from `line_items_scope`
95
- - Use `Spree::Money` for currency formatting
96
- - `currency` and `store` are delegated from the report
97
-
98
- #### Available Base Class Methods
99
-
100
- `Spree::ReportLineItem` provides:
101
-
102
- ```ruby
103
- # Returns column headers for display
104
- self.headers
105
- # => [{ name: :order_number, label: "Order Number" }, ...]
106
-
107
- # Returns column names for CSV header row
108
- self.csv_headers
109
- # => ["order_number", "completed_at", "customer_email", ...]
110
-
111
- # Converts line item to CSV row array
112
- to_csv
113
- # => ["R123456", "2025-01-15 14:30", "john@example.com", ...]
114
- ```
115
-
116
- ### Step 3: Register the Report
117
-
118
- Add your report to the registry in `config/initializers/spree.rb`:
119
-
120
- ```ruby config/initializers/spree.rb
121
- Rails.application.config.after_initialize do
122
- Spree.reports << Spree::Reports::CustomerOrders
123
- end
124
- ```
125
-
126
- ### Step 4: Add Translations
127
-
128
- Add the report name and column header translations:
129
-
130
- ```yaml config/locales/en.yml
131
- en:
132
- spree:
133
- report_names:
134
- customer_orders: Customer Orders
135
- order_number: Order Number
136
- completed_at: Completed At
137
- customer_email: Customer Email
138
- customer_name: Customer Name
139
- item_count: Item Count
140
- total: Total
141
- ```
142
-
143
- After restarting your application, the new report will be available in **Admin > Reports**.
144
-
145
- ## Advanced Patterns
146
-
147
- ### Complex Queries with Aggregations
148
-
149
- For reports that aggregate data across records, use SQL directly in `line_items_scope`:
150
-
151
- ```ruby app/models/spree/reports/revenue_by_category.rb
152
- module Spree
153
- module Reports
154
- class RevenueByCategory < Spree::Report
155
- def line_items_scope
156
- line_items_sql = Spree::LineItem
157
- .joins(:order)
158
- .where(
159
- spree_orders: {
160
- completed_at: date_from..date_to,
161
- currency: currency
162
- }
163
- )
164
- .select(
165
- "spree_line_items.variant_id",
166
- "SUM(spree_line_items.quantity) as quantity",
167
- "SUM(spree_line_items.pre_tax_amount) as revenue"
168
- )
169
- .group(:variant_id)
170
- .to_sql
171
-
172
- store.taxons
173
- .where(depth: 1) # Top-level categories
174
- .joins("LEFT JOIN spree_products_taxons ON spree_products_taxons.taxon_id = spree_taxons.id")
175
- .joins("LEFT JOIN spree_variants ON spree_variants.product_id = spree_products_taxons.product_id")
176
- .joins("LEFT JOIN (#{line_items_sql}) AS line_items ON line_items.variant_id = spree_variants.id")
177
- .select(
178
- "spree_taxons.*",
179
- "COALESCE(SUM(line_items.quantity), 0) AS total_quantity",
180
- "COALESCE(SUM(line_items.revenue), 0.0) AS total_revenue"
181
- )
182
- .group("spree_taxons.id")
183
- end
184
- end
185
- end
186
- end
187
- ```
188
-
189
- When using aggregated queries, the `record` in your line item class will have virtual attributes (like `total_quantity`, `total_revenue`) available as methods.
190
-
191
- ### Custom Summary Section
192
-
193
- Override `summary` to provide aggregate metrics alongside the line items:
194
-
195
- ```ruby app/models/spree/reports/customer_orders.rb
196
- module Spree
197
- module Reports
198
- class CustomerOrders < Spree::Report
199
- def summary
200
- {
201
- total_orders: line_items_scope.count,
202
- total_revenue: line_items_scope.sum(:total),
203
- average_order_value: line_items_scope.average(:total)
204
- }
205
- end
206
- end
207
- end
208
- end
209
- ```
210
-
211
- ### Multi-Vendor Support
212
-
213
- If you're using Spree Multi-Vendor, filter by vendor when one is selected:
214
-
215
- ```ruby app/models/spree/reports/vendor_sales.rb
216
- module Spree
217
- module Reports
218
- class VendorSales < Spree::Report
219
- def line_items_scope
220
- scope = store.line_items.where(
221
- order: Spree::Order.complete.where(
222
- completed_at: date_from..date_to,
223
- currency: currency
224
- )
225
- )
226
-
227
- # Filter by vendor if one is selected
228
- scope = scope.where(vendor_id: vendor.id) if defined?(vendor) && vendor.present?
229
-
230
- scope
231
- end
232
- end
233
- end
234
- end
235
- ```
236
-
237
- ## Testing Custom Reports
238
-
239
- ### Testing the Report Class
240
-
241
- Test that your report's `line_items_scope` returns the correct records:
242
-
243
- ```ruby spec/models/spree/reports/customer_orders_spec.rb
244
- require 'rails_helper'
245
-
246
- RSpec.describe Spree::Reports::CustomerOrders, type: :model do
247
- let(:store) { create(:store) }
248
- let(:user) { create(:admin_user) }
249
-
250
- subject(:report) do
251
- described_class.new(
252
- store: store,
253
- user: user,
254
- currency: 'USD',
255
- date_from: 1.month.ago,
256
- date_to: Time.current
257
- )
258
- end
259
-
260
- describe '#line_items_scope' do
261
- let!(:completed_order) do
262
- create(:completed_order_with_totals, store: store, currency: 'USD', completed_at: 1.week.ago)
263
- end
264
-
265
- let!(:incomplete_order) do
266
- create(:order, store: store, currency: 'USD', state: 'cart')
267
- end
268
-
269
- let!(:other_currency_order) do
270
- create(:completed_order_with_totals, store: store, currency: 'EUR', completed_at: 1.week.ago)
271
- end
272
-
273
- let!(:old_order) do
274
- create(:completed_order_with_totals, store: store, currency: 'USD', completed_at: 2.months.ago)
275
- end
276
-
277
- it 'returns only completed orders within the date range and currency' do
278
- scope = report.line_items_scope
279
-
280
- expect(scope).to include(completed_order)
281
- expect(scope).not_to include(incomplete_order)
282
- expect(scope).not_to include(other_currency_order)
283
- expect(scope).not_to include(old_order)
284
- end
285
- end
286
-
287
- describe '#line_items' do
288
- let!(:order) do
289
- create(:completed_order_with_totals, store: store, currency: 'USD', completed_at: 1.week.ago)
290
- end
291
-
292
- it 'returns ReportLineItem objects' do
293
- items = report.line_items
294
- expect(items).to all(be_a(Spree::ReportLineItems::CustomerOrders))
295
- end
296
-
297
- it 'respects the limit option' do
298
- create(:completed_order_with_totals, store: store, currency: 'USD', completed_at: 2.days.ago)
299
- items = report.line_items(limit: 1)
300
- expect(items.length).to eq(1)
301
- end
302
- end
303
- end
304
- ```
305
-
306
- ### Testing the ReportLineItem Class
307
-
308
- Test that your line item correctly formats each record:
309
-
310
- ```ruby spec/models/spree/report_line_items/customer_orders_spec.rb
311
- require 'rails_helper'
312
-
313
- RSpec.describe Spree::ReportLineItems::CustomerOrders, type: :model do
314
- let(:store) { create(:store) }
315
- let(:user) { create(:admin_user) }
316
- let(:bill_address) { create(:address) }
317
- let(:order) do
318
- create(:completed_order_with_totals,
319
- store: store,
320
- currency: 'USD',
321
- completed_at: 1.week.ago,
322
- bill_address: bill_address)
323
- end
324
-
325
- let(:report) do
326
- Spree::Reports::CustomerOrders.new(
327
- store: store,
328
- user: user,
329
- currency: 'USD',
330
- date_from: 1.month.ago,
331
- date_to: Time.current
332
- )
333
- end
334
-
335
- subject(:line_item) { described_class.new(record: order, report: report) }
336
-
337
- describe '#order_number' do
338
- it 'returns the order number' do
339
- expect(line_item.order_number).to eq(order.number)
340
- end
341
- end
342
-
343
- describe '#customer_name' do
344
- it 'returns the billing address full name' do
345
- expect(line_item.customer_name).to eq(bill_address.full_name)
346
- end
347
-
348
- context 'when no billing address' do
349
- let(:bill_address) { nil }
350
-
351
- it 'returns nil' do
352
- order.update_column(:bill_address_id, nil)
353
- order.reload
354
- expect(line_item.customer_name).to be_nil
355
- end
356
- end
357
- end
358
-
359
- describe '#total' do
360
- it 'returns a Spree::Money formatted total' do
361
- result = line_item.total
362
- expect(result).to be_a(Spree::Money)
363
- expect(result.money.to_f).to eq(order.total.to_f)
364
- end
365
- end
366
-
367
- describe '#to_csv' do
368
- it 'returns an array of values for CSV row' do
369
- csv_row = line_item.to_csv
370
- expect(csv_row.length).to eq(6)
371
- expect(csv_row[0]).to eq(order.number)
372
- end
373
- end
374
- end
375
- ```
376
-
377
- ### Key Testing Patterns
378
-
379
- 1. **Test scope filtering** — verify `line_items_scope` returns only records matching date range, currency, and store
380
- 2. **Test attribute formatting** — verify each attribute method returns correctly formatted data
381
- 3. **Test CSV output** — check `headers`, `csv_headers`, and `to_csv` return expected values
382
- 4. **Test edge cases** — handle nil values gracefully (e.g., missing addresses)
383
-
384
- ## Related Documentation
385
-
386
- - [Reports](/v5/developer/core-concepts/reports) - Report architecture and built-in reports
387
- - [Events](../core-concepts/events.md) - How report generation uses the events system