镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Commit 648e2bb

Browse files
fix(dashboard): Orders is searched, filtered, sorted, paged and totalled by the database
The Orders page read the newest 500 orders and did everything else in the browser: an order past the 500th could not be found by any search, and the summary cards and currency totals described those 500, not the store. The export was built from the same 500, whatever the filters matched. GET /api/dashboard/commerce/orders now takes q (order id, customer name and email, status, type, address; case ignored, wildcards literal), status, type, sort (created, id, customer, total, type, status), direction, page and per_page, and answers one page with the total that matched. The summary and currency totals come from aggregates over every order. The id breaks every tie, so pages never repeat or skip an order, and customers sort the same on every dialect. A page past the end moves back to the last one. GET /api/dashboard/commerce/orders/export returns every match as the same CSV the page used to build, read 500 at a time. The page asks the server on every change, debounces the search, keeps only the newest answer, and sends column header sorts to the server. containsPattern moves to the shared content-list helpers, used by both the Kanban picker and Orders. Checked on SQLite, PostgreSQL and MySQL with real engines. Controls: a summary over 10 rows, and a sort without the id tie-break, both fail on all three. Live against the dev database with every feature on: the summary (20; 1, 8, 4, 7) and the five currency totals match SQL; "deliver" matches 12 and the export holds those 12; deliver plus Canceled is order 10, $4.76, as in SQL; paging, page size and the Total header all go to the server. The order dialog's customer choices are still the first 500 by name; the next slice makes them a search. Refs #3218
1 parent 60f6040 commit 648e2bb

12 files changed

Lines changed: 720 additions & 132 deletions

File tree

‎storage/framework/auto-imports/actions.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ export const actions = {
292292
'Actions/Dashboard/Commerce/CommerceDeliveryAction': '../defaults/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts',
293293
'Actions/Dashboard/Commerce/CommerceGiftCardsAction': '../defaults/app/Actions/Dashboard/Commerce/CommerceGiftCardsAction.ts',
294294
'Actions/Dashboard/Commerce/CommerceOrdersAction': '../defaults/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts',
295+
'Actions/Dashboard/Commerce/CommerceOrdersExportAction': '../defaults/app/Actions/Dashboard/Commerce/CommerceOrdersExportAction.ts',
295296
'Actions/Dashboard/Commerce/CommercePaymentsAction': '../defaults/app/Actions/Dashboard/Commerce/CommercePaymentsAction.ts',
296297
'Actions/Dashboard/Commerce/CommercePosAction': '../defaults/app/Actions/Dashboard/Commerce/CommercePosAction.ts',
297298
'Actions/Dashboard/Commerce/CommercePosCheckoutAction': '../defaults/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts',
@@ -322,6 +323,7 @@ export const actions = {
322323
'Actions/Dashboard/Commerce/commerce-customer-records': '../defaults/app/Actions/Dashboard/Commerce/commerce-customer-records.ts',
323324
'Actions/Dashboard/Commerce/commerce-dashboard': '../defaults/app/Actions/Dashboard/Commerce/commerce-dashboard.ts',
324325
'Actions/Dashboard/Commerce/commerce-delivery': '../defaults/app/Actions/Dashboard/Commerce/commerce-delivery.ts',
326+
'Actions/Dashboard/Commerce/commerce-order-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-order-list.ts',
325327
'Actions/Dashboard/Commerce/commerce-order-records': '../defaults/app/Actions/Dashboard/Commerce/commerce-order-records.ts',
326328
'Actions/Dashboard/Commerce/commerce-pos': '../defaults/app/Actions/Dashboard/Commerce/commerce-pos.ts',
327329
'Actions/Dashboard/Commerce/commerce-product-records': '../defaults/app/Actions/Dashboard/Commerce/commerce-product-records.ts',
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"fingerprint": "01b7abb5c608230b1acd4781d917ed9df03eea757cc42fad03ff53660f8e88a1"
2+
"fingerprint": "135074fb361f5cb13ce437175ddfaf545f4076c3f25c3f27fa6fdcf55dda9aaa"
33
}

‎storage/framework/defaults/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts‎

Lines changed: 27 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,47 @@
1+
import type { RequestInstance } from '@stacksjs/types'
12
import { Action } from '@stacksjs/actions/runtime'
23
import { config } from '@stacksjs/config'
3-
import { Coupon, Customer, Order, OrderItem } from '@stacksjs/orm'
4+
import { Customer } from '@stacksjs/orm'
5+
import { response } from '@stacksjs/router'
46
import { dashboardOperationalError } from '../dashboard-response'
5-
import {
6-
addOrderItemQuantity,
7-
normalizeCommerceOrderRecord,
8-
normalizeOrderCouponId,
9-
normalizeOrderCustomerContext,
10-
normalizeOrderCustomerOption,
11-
referencedIds,
12-
summarizeCommerceOrders,
13-
} from './commerce-order-records'
14-
import { commerceCurrency, commerceIdentifier, commerceValue } from './commerce-record'
7+
import { parseOrderListQuery, readOrderPage } from './commerce-order-list'
8+
import { normalizeOrderCustomerOption } from './commerce-order-records'
9+
import { commerceCurrency } from './commerce-record'
1510

11+
/**
12+
* `GET /api/dashboard/commerce/orders` - one page of orders, searched, filtered,
13+
* sorted, counted and summarised by the database (commerce-order-list.ts,
14+
* stacksjs/stacks#3218). It used to return the newest 500 for the browser to
15+
* do all of that with.
16+
*/
1617
export default new Action({
1718
name: 'CommerceOrdersAction',
18-
description: 'Returns persisted Order records with customer context and item counts for dashboard management.',
19+
description: 'Returns one page of Order records with customer context, item counts and store-wide totals.',
1920
method: 'GET',
2021
apiResponse: true,
2122

22-
async handle() {
23+
async handle(request: RequestInstance) {
24+
const query = parseOrderListQuery(key => request.get<unknown>(key, ''))
25+
if ('error' in query)
26+
return response.json({ message: query.error }, 422)
27+
2328
try {
24-
const orders = await Order.orderBy('created_at', 'desc').limit(500).get()
25-
const orderIds = new Set(orders.map(order =>
26-
commerceIdentifier(commerceValue(order, 'id', 'uuid'), 'Order'),
27-
))
28-
const numericOrderIds = [...orderIds].map(Number).filter(Number.isSafeInteger)
29-
// Exactly the customers and coupons these orders reference, so no order
30-
// points at one that was not read (commerce-order-records.ts).
31-
const customerIds = referencedIds(orders, 'customer_id', 'customerId')
32-
const couponReferences = referencedIds(orders, 'coupon_id', 'couponId')
33-
const [referencedCustomers, coupons, items, customers] = await Promise.all([
34-
customerIds.length > 0 ? Customer.whereIn('id', customerIds).get() : [],
35-
couponReferences.length > 0 ? Coupon.whereIn('id', couponReferences).get() : [],
36-
numericOrderIds.length > 0 ? OrderItem.whereIn('order_id', numericOrderIds).get() : [],
29+
const [orders, customers] = await Promise.all([
30+
readOrderPage(query),
3731
// The order dialog's customer choices.
3832
Customer.orderBy('name', 'asc').limit(500).get(),
3933
])
40-
const customerContexts = referencedCustomers.map(normalizeOrderCustomerContext)
41-
const customerMap = new Map(customerContexts.map(customer => [customer.id, customer.context]))
42-
const couponIds = new Set(coupons.map(normalizeOrderCouponId))
43-
const itemCounts = new Map<string, number>()
44-
for (const item of items)
45-
addOrderItemQuantity(item, orderIds, itemCounts)
46-
47-
const records = orders.map(order =>
48-
normalizeCommerceOrderRecord(order, customerMap, itemCounts, couponIds),
49-
)
50-
const defaultStatuses = ['PENDING', 'PREPARING', 'READY', 'DELIVERED', 'CANCELED']
51-
const defaultTypes = ['DINE_IN', 'TAKEOUT', 'DELIVERY']
5234
return {
53-
records,
54-
summary: summarizeCommerceOrders(records),
35+
records: orders.records,
36+
total: orders.total,
37+
page: orders.page,
38+
perPage: query.perPage,
39+
summary: orders.summary,
5540
customers: customers
5641
.map(normalizeOrderCustomerOption)
5742
.sort((left, right) => left.label.localeCompare(right.label)),
58-
statuses: [...new Set([...defaultStatuses, ...records.map(record => record.status)])],
59-
orderTypes: [...new Set([...defaultTypes, ...records.map(record => record.orderType)])],
43+
statuses: orders.statuses,
44+
orderTypes: orders.orderTypes,
6045
defaultCurrency: commerceCurrency(config.commerce?.currency, 'Commerce configuration'),
6146
}
6247
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { RequestInstance } from '@stacksjs/types'
2+
import { Action } from '@stacksjs/actions/runtime'
3+
import { minorToInput } from '@stacksjs/commerce/money'
4+
import { response } from '@stacksjs/router'
5+
import { dashboardOperationalError } from '../dashboard-response'
6+
import { ordersCsv, parseOrderListQuery, readOrdersForExport } from './commerce-order-list'
7+
8+
/**
9+
* `GET /api/dashboard/commerce/orders/export` - every order matching the Orders
10+
* page's search, filters and sort, as CSV (stacksjs/stacks#3218).
11+
*
12+
* The page used to build the file from the orders it had loaded, so an export
13+
* held at most the newest 500 orders, whatever the filters matched.
14+
*/
15+
export default new Action({
16+
name: 'CommerceOrdersExportAction',
17+
description: 'Exports every order matching the Orders page filters as CSV.',
18+
method: 'GET',
19+
apiResponse: false,
20+
21+
async handle(request: RequestInstance) {
22+
const query = parseOrderListQuery(key => request.get<unknown>(key, ''))
23+
if ('error' in query)
24+
return response.json({ message: query.error }, 422)
25+
26+
try {
27+
const csv = ordersCsv(await readOrdersForExport(query), minorToInput)
28+
const day = new Date().toISOString().slice(0, 10)
29+
return new Response(csv, {
30+
status: 200,
31+
headers: {
32+
'Cache-Control': 'private, no-store',
33+
'Content-Disposition': `attachment; filename="orders-${day}.csv"`,
34+
'Content-Type': 'text/csv; charset=utf-8',
35+
'X-Content-Type-Options': 'nosniff',
36+
},
37+
})
38+
}
39+
catch (error) {
40+
return dashboardOperationalError(error, 'Orders could not be exported.', 'CommerceOrdersExportAction')
41+
}
42+
},
43+
})
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { Database } from 'bun:sqlite'
2+
import { SQL } from 'bun'
3+
import { describe, expect, test } from 'bun:test'
4+
import type { OrderListQuery, OrderListRunner } from './commerce-order-list'
5+
import { ordersCsv, parseOrderListQuery, readOrderPage, readOrdersForExport } from './commerce-order-list'
6+
7+
/**
8+
* The Orders list against each dialect's real engine (stacksjs/stacks#3218):
9+
* search, filters, sorts, paging, store-wide summary and the full export.
10+
*/
11+
type Dialect = 'sqlite' | 'postgres' | 'mysql'
12+
13+
const CUSTOMERS: Array<[number, string, string]> = [
14+
[1, 'Ava Stone', 'ava@example.com'],
15+
[2, 'ben carter', 'BEN@example.com'],
16+
[3, 'Cara Lim', 'cara@example.com'],
17+
]
18+
const STATUSES = ['PENDING', 'preparing', 'DELIVERED', 'CANCELED', 'READY']
19+
const TYPES = ['DELIVERY', 'TAKEOUT', 'DINE_IN']
20+
21+
/** 30 orders: customers 1-3 and unassigned in turn, five statuses, three types. */
22+
const ORDERS = Array.from({ length: 30 }, (_, index) => {
23+
const id = index + 1
24+
return {
25+
id,
26+
customer_id: id % 4 === 0 ? null : (id % 3) + 1,
27+
status: STATUSES[id % STATUSES.length]!,
28+
total_amount: (id * 37) % 101 * 100,
29+
// Two spellings of one currency must be one total.
30+
currency: id % 7 === 0 ? 'eur' : (id % 2 === 0 ? 'EUR' : 'USD'),
31+
order_type: TYPES[id % TYPES.length]!,
32+
delivery_address: id === 13 ? '100% Sure Street' : `${id} Main Street`,
33+
coupon_id: id % 10 === 0 ? 2 : null,
34+
created_at: `2026-11-${String(1 + (id % 9)).padStart(2, '0')} 10:00:00`,
35+
}
36+
})
37+
38+
function query(overrides: Partial<OrderListQuery> = {}): OrderListQuery {
39+
return { search: '', status: '', type: '', sort: 'created', direction: 'desc', page: 1, perPage: 10, ...overrides }
40+
}
41+
42+
async function setUp(run: (sql: string, values?: unknown[]) => Promise<unknown>, dialect: Dialect) {
43+
const pk = dialect === 'postgres' ? 'BIGINT PRIMARY KEY' : 'INTEGER PRIMARY KEY'
44+
const time = dialect === 'sqlite' ? 'TEXT' : dialect === 'mysql' ? 'DATETIME' : 'TIMESTAMP'
45+
const text = dialect === 'mysql' ? 'VARCHAR(255)' : 'TEXT'
46+
await run(`CREATE TABLE customers (id ${pk}, name ${text}, email ${text})`)
47+
await run(`CREATE TABLE coupons (id ${pk})`)
48+
await run(`CREATE TABLE order_items (id ${pk}, order_id INTEGER, quantity INTEGER)`)
49+
await run(`CREATE TABLE orders (id ${pk}, uuid ${text}, customer_id INTEGER NULL, coupon_id INTEGER NULL, status ${text}, total_amount INTEGER, currency ${text}, tax_amount INTEGER, discount_amount INTEGER, delivery_fee INTEGER, tip_amount INTEGER, order_type ${text}, delivery_address ${text} NULL, special_instructions ${text} NULL, estimated_delivery_time ${text} NULL, applied_coupon_id ${text} NULL, created_at ${time})`)
50+
const p = (n: number) => dialect === 'postgres' ? `$${n}` : '?'
51+
const ps = (count: number) => Array.from({ length: count }, (_, i) => p(i + 1)).join(', ')
52+
for (const customer of CUSTOMERS)
53+
await run(`INSERT INTO customers (id, name, email) VALUES (${ps(3)})`, customer)
54+
await run(`INSERT INTO coupons (id) VALUES (${ps(1)})`, [2])
55+
for (const order of ORDERS) {
56+
await run(
57+
`INSERT INTO orders (id, customer_id, coupon_id, status, total_amount, currency, tax_amount, discount_amount, delivery_fee, tip_amount, order_type, delivery_address, created_at) VALUES (${ps(13)})`,
58+
[order.id, order.customer_id, order.coupon_id, order.status, order.total_amount, order.currency, 0, 0, 0, 0, order.order_type, order.delivery_address, order.created_at],
59+
)
60+
}
61+
// Order 5 has two lines, 3 + 2 items.
62+
await run(`INSERT INTO order_items (id, order_id, quantity) VALUES (${ps(3)})`, [1, 5, 3])
63+
await run(`INSERT INTO order_items (id, order_id, quantity) VALUES (${ps(3)})`, [2, 5, 2])
64+
}
65+
66+
async function exercise(runner: OrderListRunner, dialect: Dialect) {
67+
const read = (overrides: Partial<OrderListQuery> = {}) => readOrderPage(query(overrides), runner, dialect)
68+
69+
// The summary covers every order, not the page of 10.
70+
const first = await read()
71+
expect(first.total).toBe(30)
72+
expect(first.records).toHaveLength(10)
73+
expect(first.summary.total).toBe(30)
74+
const statusCount = (names: string[]) => ORDERS.filter(order => names.includes(order.status.toUpperCase())).length
75+
expect(first.summary.pending).toBe(statusCount(['PENDING', 'NEW']))
76+
expect(first.summary.inProgress).toBe(statusCount(['PREPARING', 'PROCESSING', 'READY', 'SHIPPED']))
77+
expect(first.summary.completed).toBe(statusCount(['DELIVERED', 'COMPLETED', 'PAID']))
78+
expect(first.summary.cancelled).toBe(statusCount(['CANCELED', 'CANCELLED', 'REFUNDED']))
79+
const sum = (currency: string) => ORDERS.filter(order => order.currency.toUpperCase() === currency).reduce((total, order) => total + order.total_amount, 0)
80+
expect(first.summary.currencyTotals).toEqual([{ currency: 'EUR', amount: sum('EUR') }, { currency: 'USD', amount: sum('USD') }])
81+
expect(first.statuses).toContain('preparing')
82+
83+
// Newest first, ties broken by id descending: stable across page boundaries.
84+
const expected = [...ORDERS].sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id - a.id).map(order => String(order.id))
85+
const pages = [await read({ page: 1 }), await read({ page: 2 }), await read({ page: 3 })]
86+
expect(pages.flatMap(result => result.records.map(record => record.id))).toEqual(expected)
87+
88+
// Past the end moves back to the last page.
89+
expect((await read({ page: 9 })).page).toBe(3)
90+
91+
// Search: customer name in any case, address, literal %, id.
92+
const ben = await read({ search: 'BEN', perPage: 100 })
93+
expect(ben.total).toBe(ORDERS.filter(order => order.customer_id === 2).length)
94+
expect(ben.records.every(record => record.customerName === 'ben carter')).toBeTrue()
95+
expect((await read({ search: '100%' })).records.map(record => record.id)).toEqual(['13'])
96+
expect((await read({ search: '27', perPage: 100 })).records.map(record => record.id)).toContain('27')
97+
98+
// Filters are exact values.
99+
expect((await read({ status: 'preparing', perPage: 100 })).total).toBe(ORDERS.filter(order => order.status === 'preparing').length)
100+
expect((await read({ type: 'TAKEOUT', perPage: 100 })).total).toBe(ORDERS.filter(order => order.order_type === 'TAKEOUT').length)
101+
102+
// Totals sort, high first.
103+
const byTotal = await read({ sort: 'total', direction: 'desc', perPage: 100 })
104+
const totals = byTotal.records.map(record => record.totalAmount)
105+
expect(totals).toEqual([...totals].sort((a, b) => b - a))
106+
107+
// Customer sort: unassigned first ascending, then by name ignoring case.
108+
const byCustomer = (await read({ sort: 'customer', direction: 'asc', perPage: 100 })).records.map(record => record.customerName)
109+
const unassigned = ORDERS.filter(order => order.customer_id === null).length
110+
expect(byCustomer.slice(0, unassigned).every(name => name === '')).toBeTrue()
111+
expect(byCustomer.slice(unassigned)).toEqual([...byCustomer.slice(unassigned)].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())))
112+
113+
// Item counts and coupon references come through.
114+
const five = (await read({ search: '5 Main', perPage: 100 })).records.find(record => record.id === '5')
115+
expect(five?.itemCount).toBe(5)
116+
expect((await read({ search: '10 Main' })).records[0]?.couponId).toBe('2')
117+
118+
// The export reads every match, in order, across batches.
119+
const exported = await readOrdersForExport({ search: '', status: '', type: '', sort: 'created', direction: 'desc' }, runner, dialect, 4)
120+
expect(exported.map(record => record.id)).toEqual(expected)
121+
const filtered = await readOrdersForExport({ search: 'ben', status: '', type: '', sort: 'created', direction: 'desc' }, runner, dialect, 4)
122+
expect(filtered).toHaveLength(ben.total)
123+
}
124+
125+
describe('readOrderPage on SQLite', () => {
126+
test('searches, filters, sorts, pages, summarises and exports', async () => {
127+
const database = new Database(':memory:')
128+
await setUp(async (sql, values = []) => database.query(sql).run(...(values as never[])), 'sqlite')
129+
await exercise({ unsafe: (sql, values = []) => ({ execute: async () => database.query(sql).all(...(values as never[])) }) }, 'sqlite')
130+
})
131+
})
132+
133+
for (const dialect of ['postgres', 'mysql'] as const) {
134+
const connection = dialect === 'postgres' ? process.env.STACKS_TEST_POSTGRES_URL : process.env.STACKS_TEST_MYSQL_URL
135+
136+
test.skipIf(!connection)(`readOrderPage on ${dialect} searches, filters, sorts, pages, summarises and exports`, async () => {
137+
const url = new URL(connection!)
138+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))
139+
throw new Error('Order list tests require a local disposable database server')
140+
141+
const name = `stacks_order_list_${crypto.randomUUID().replaceAll('-', '')}`
142+
const quoted = dialect === 'mysql' ? `\`${name}\`` : `"${name}"`
143+
const admin = new SQL(url.href)
144+
await admin.unsafe(`CREATE DATABASE ${quoted}`)
145+
const scoped = new URL(url.href)
146+
scoped.pathname = `/${name}`
147+
const database = new SQL(scoped.href)
148+
149+
try {
150+
await setUp(async (sql, values = []) => await database.unsafe(sql, values), dialect)
151+
await exercise({ unsafe: (sql, values = []) => ({ execute: async () => await database.unsafe(sql, values) }) }, dialect)
152+
}
153+
finally {
154+
await database.close()
155+
try { await admin.unsafe(`DROP DATABASE ${quoted}${dialect === 'postgres' ? ' WITH (FORCE)' : ''}`) }
156+
finally { await admin.close() }
157+
}
158+
}, 60_000)
159+
}
160+
161+
describe('parseOrderListQuery', () => {
162+
const reader = (values: Record<string, unknown>) => (key: string) => values[key] ?? ''
163+
164+
test('defaults to the newest 25', () => {
165+
expect(parseOrderListQuery(reader({}))).toEqual({ search: '', status: '', type: '', sort: 'created', direction: 'desc', page: 1, perPage: 25 })
166+
})
167+
168+
test('rejects what it cannot honour', () => {
169+
expect(parseOrderListQuery(reader({ sort: 'secret_column' }))).toHaveProperty('error')
170+
expect(parseOrderListQuery(reader({ direction: 'sideways' }))).toHaveProperty('error')
171+
expect(parseOrderListQuery(reader({ per_page: '7' }))).toHaveProperty('error')
172+
expect(parseOrderListQuery(reader({ page: '0' }))).toHaveProperty('error')
173+
})
174+
})
175+
176+
test('the CSV keeps the columns the page exported, with every row', () => {
177+
const record = { id: '7', customerId: '', customerName: 'Ava "A" Stone', customerEmail: 'ava@example.com', status: 'READY', totalAmount: 1250, currency: 'USD', taxAmount: 0, discountAmount: 0, deliveryFee: 0, tipAmount: 0, orderType: 'DELIVERY', deliveryAddress: '1 Main', specialInstructions: '', estimatedDeliveryTime: '', appliedCouponId: '', couponId: '', itemCount: 2, createdAt: '2026-11-01T10:00:00.000Z' }
178+
const csv = ordersCsv([record], (amount) => (amount / 100).toFixed(2))
179+
const [header, row] = csv.replace('', '').split('\n')
180+
expect(header).toBe('"Order ID","Customer","Customer email","Status","Order type","Items","Total","Currency","Tax","Discount","Delivery fee","Tip","Delivery address","Created at"')
181+
expect(row).toBe('"7","Ava ""A"" Stone","ava@example.com","READY","DELIVERY","2","12.50","USD","0.00","0.00","0.00","0.00","1 Main","2026-11-01T10:00:00.000Z"')
182+
})

0 commit comments

Comments
 (0)