@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.
- package/dist/developer/core-concepts/inventory.md +1 -1
- package/dist/developer/core-concepts/reporting.md +277 -0
- package/dist/developer/how-to/extend-reporting.md +215 -0
- package/dist/developer/upgrades/5.6-to-6.0.md +12 -0
- package/package.json +1 -1
- package/dist/developer/how-to/custom-report.md +0 -387
|
@@ -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;
|
|
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,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
|