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

Commit 1f75a01

Browse files
fix(dashboard): the point of sale searches every product and customer
The point of sale read the first 500 products, categories, manufacturers, customers and tax rates by name or id with every load. A product past the 500th could not be found or rung up; a product whose category or manufacturer was past the 500th failed the whole screen; the checkout could not be given a later customer; and the summary counted those 500. GET /api/dashboard/commerce/pos/products is the catalogue now, searched and paged by the database: q (name, description, category, manufacturer, allergens), category, availability (sellable or unsellable), page and per_page, by name ignoring case. Its categories and manufacturers are read by exactly the ids the page references. With `ids=1,2,3` it answers exactly those products, which is how the cart brings its lines up to date after a reload or a sale: before, a line was checked against the 500 products the screen held, and a cart line outside a page would now have been dropped. GET /api/dashboard/commerce/pos keeps the settings: the categories some product is in (read whole), the tax rate (the default active rate, else the first active one, chosen in SQL), the currency, the payment methods, and the summary counted over every product. The checkout searches Active customers by name, email or phone through GET /api/dashboard/commerce/customer-choices?active=1, the shared customer search (commerce-choices.ts) with a new active-only option. The POS customer normalizer and its Active check are gone with the list they served. The catalogue reuses the Products list's search, so its availability filter gains `sellable` and `unsellable`, and productRecordsFor is exported and can skip the variant, unit and review counts the point of sale does not show. The page pages the catalogue with useServerList and UI/Pagination. Tested on SQLite, PostgreSQL and MySQL with 520 products, categories, manufacturers and customers, BOOLEAN or TINYINT(1) availability and enum statuses. Three controls went red on all three: reading the first 500 categories ("Product 511 references missing Category 515"), ignoring the active-only filter, and ignoring the default tax rate. Live: GET /commerce/pos answers 10 products, 10 sellable, 583 units, a 59% tax rate and five categories, as SQL counts them, and the active customer search finds the 11 Active customers. The catalogue itself answers 503 on this dev database, whose products table has no slug column, which every product read (Products, POS before this change) already fails on, so the checkout was not exercised live. Refs #3218
1 parent abec83f commit 1f75a01

15 files changed

Lines changed: 549 additions & 152 deletions

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ export const actions = {
300300
'Actions/Dashboard/Commerce/CommercePaymentsExportAction': '../defaults/app/Actions/Dashboard/Commerce/CommercePaymentsExportAction.ts',
301301
'Actions/Dashboard/Commerce/CommercePosAction': '../defaults/app/Actions/Dashboard/Commerce/CommercePosAction.ts',
302302
'Actions/Dashboard/Commerce/CommercePosCheckoutAction': '../defaults/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts',
303+
'Actions/Dashboard/Commerce/CommercePosProductsAction': '../defaults/app/Actions/Dashboard/Commerce/CommercePosProductsAction.ts',
303304
'Actions/Dashboard/Commerce/CommercePrintDevicesAction': '../defaults/app/Actions/Dashboard/Commerce/CommercePrintDevicesAction.ts',
304305
'Actions/Dashboard/Commerce/CommercePrintLogsAction': '../defaults/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts',
305306
'Actions/Dashboard/Commerce/CommerceProductChoicesAction': '../defaults/app/Actions/Dashboard/Commerce/CommerceProductChoicesAction.ts',
@@ -338,6 +339,7 @@ export const actions = {
338339
'Actions/Dashboard/Commerce/commerce-order-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-order-list.ts',
339340
'Actions/Dashboard/Commerce/commerce-order-records': '../defaults/app/Actions/Dashboard/Commerce/commerce-order-records.ts',
340341
'Actions/Dashboard/Commerce/commerce-payment-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-payment-list.ts',
342+
'Actions/Dashboard/Commerce/commerce-pos-catalog': '../defaults/app/Actions/Dashboard/Commerce/commerce-pos-catalog.ts',
341343
'Actions/Dashboard/Commerce/commerce-pos': '../defaults/app/Actions/Dashboard/Commerce/commerce-pos.ts',
342344
'Actions/Dashboard/Commerce/commerce-product-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-product-list.ts',
343345
'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": "3369da254f6f8132820989bb681ba828a81fff16f8ee7e1ab66e697182ffbfe6"
2+
"fingerprint": "35affd5a7d4523633fb5959e5bac5010f999e2e083e3ed5e3cf88e3496b0a509"
33
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ export default new Action({
2222
return response.json({ message: query.error }, 422)
2323

2424
try {
25-
const { choices, total } = await searchCustomerChoices(query)
25+
// `active=1` keeps to Active customers, as the point of sale offers them.
26+
const activeOnly = ['1', 'true'].includes(String(request.get<unknown>('active', '') ?? '').trim())
27+
const { choices, total } = await searchCustomerChoices(query, undefined, undefined, { activeOnly })
2628
return { choices, total, limit: query.limit }
2729
}
2830
catch (error) {

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

Lines changed: 18 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,35 @@
11
import { Action } from '@stacksjs/actions/runtime'
22
import { config } from '@stacksjs/config'
3-
import { Category, Customer, Manufacturer, Product, TaxRate } from '@stacksjs/orm'
43
import { dashboardOperationalError } from '../dashboard-response'
5-
import {
6-
isCommercePosCustomerActive,
7-
normalizeCommercePosCustomer,
8-
normalizeCommercePosProduct,
9-
selectCommercePosTaxRate,
10-
} from './commerce-pos'
11-
import {
12-
normalizeCommerceProductRecord,
13-
normalizeCommerceCurrency,
14-
normalizeManufacturerOption,
15-
normalizeProductOption,
16-
} from './commerce-product-records'
4+
import { readPosSettings } from './commerce-pos-catalog'
5+
import { normalizeCommerceCurrency } from './commerce-product-records'
176

7+
/**
8+
* `GET /api/dashboard/commerce/pos` - the point of sale's settings: the
9+
* categories in use, the tax rate, the currency, the payment methods and the
10+
* catalogue summary, counted over every product (commerce-pos-catalog.ts,
11+
* stacksjs/stacks#3218).
12+
*
13+
* The catalogue itself is GET /commerce/pos/products, searched and paged, and
14+
* the checkout searches customers through GET /commerce/customer-choices.
15+
* This used to send the first 500 products, categories, manufacturers,
16+
* customers and tax rates.
17+
*/
1818
export default new Action({
1919
name: 'CommercePosAction',
20-
description: 'Returns persisted products, customers, and tax configuration for the native point of sale.',
20+
description: 'Returns the native point of sale settings and catalogue summary.',
2121
method: 'GET',
2222
apiResponse: true,
2323

2424
async handle() {
2525
try {
26-
const [products, categories, manufacturers, customers, taxRates] = await Promise.all([
27-
Product.orderBy('name', 'asc').limit(500).get(),
28-
Category.orderBy('name', 'asc').limit(500).get(),
29-
Manufacturer.orderBy('manufacturer', 'asc').limit(500).get(),
30-
Customer.orderBy('name', 'asc').limit(500).get(),
31-
TaxRate.orderBy('id', 'asc').limit(500).get(),
32-
])
33-
const categoryOptions = categories.map(normalizeProductOption)
34-
const manufacturerOptions = manufacturers.map(normalizeManufacturerOption)
35-
const categoryMap = new Map(categoryOptions.map(option => [option.id, option.label]))
36-
const manufacturerMap = new Map(manufacturerOptions.map(option => [option.id, option.label]))
37-
const emptyCounts = new Map<string, number>()
38-
const records = products.map(product => normalizeCommercePosProduct(normalizeCommerceProductRecord(
39-
product,
40-
categoryMap,
41-
manufacturerMap,
42-
emptyCounts,
43-
emptyCounts,
44-
emptyCounts,
45-
)))
46-
const sellable = records.filter(record => record.isAvailable && record.inventoryCount > 0)
47-
const usedCategoryIds = new Set(records.map(record => record.categoryId).filter(Boolean))
48-
26+
const settings = await readPosSettings()
4927
return {
50-
products: records,
51-
categories: categoryOptions.filter(category => usedCategoryIds.has(category.id)),
52-
customers: customers
53-
.filter(isCommercePosCustomerActive)
54-
.map(normalizeCommercePosCustomer),
55-
taxRate: selectCommercePosTaxRate(taxRates),
28+
categories: settings.categories,
29+
taxRate: settings.taxRate,
5630
defaultCurrency: normalizeCommerceCurrency(config.commerce?.currency),
5731
paymentMethods: [{ id: 'cash', label: 'Cash' }],
58-
summary: {
59-
products: records.length,
60-
sellable: sellable.length,
61-
inventory: records.reduce((sum, record) => sum + record.inventoryCount, 0),
62-
},
32+
summary: settings.summary,
6333
}
6434
}
6535
catch (error) {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { RequestInstance } from '@stacksjs/types'
2+
import { Action } from '@stacksjs/actions/runtime'
3+
import { response } from '@stacksjs/router'
4+
import { dashboardOperationalError } from '../dashboard-response'
5+
import { parsePosCatalogQuery, parsePosIds, readPosCatalogPage, readPosProducts } from './commerce-pos-catalog'
6+
7+
/**
8+
* `GET /api/dashboard/commerce/pos/products` - the point of sale catalogue,
9+
* searched and paged by the database (commerce-pos-catalog.ts,
10+
* stacksjs/stacks#3218): `q`, `category`, `availability` (sellable or
11+
* unsellable), `page` and `per_page`. With `ids=1,2,3` it answers exactly
12+
* those products instead, which is how the cart checks its lines.
13+
*/
14+
export default new Action({
15+
name: 'CommercePosProductsAction',
16+
description: 'Searches and pages the point of sale catalogue, or reads the products a cart holds.',
17+
method: 'GET',
18+
apiResponse: true,
19+
20+
async handle(request: RequestInstance) {
21+
const read = (key: string) => request.get<unknown>(key, '')
22+
const ids = parsePosIds(read)
23+
if ('error' in ids)
24+
return response.json({ message: ids.error }, 422)
25+
const query = parsePosCatalogQuery(read)
26+
if ('error' in query)
27+
return response.json({ message: query.error }, 422)
28+
29+
try {
30+
if (ids.length > 0)
31+
return { records: await readPosProducts(ids) }
32+
const catalog = await readPosCatalogPage(query)
33+
return { records: catalog.products, total: catalog.total, page: catalog.page, perPage: query.perPage }
34+
}
35+
catch (error) {
36+
return dashboardOperationalError(error, 'Point of sale products could not be read.', 'CommercePosProductsAction')
37+
}
38+
},
39+
})

‎storage/framework/defaults/app/Actions/Dashboard/Commerce/commerce-choices.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@ async function search<T>(
2828
normalize: (row: Record<string, unknown>) => T,
2929
run: CommerceSqlRunner,
3030
dialect: QueryBuilderDialect,
31+
only: string[] = [],
3132
): Promise<{ choices: T[], total: number }> {
3233
const { bind, values } = binder(dialect)
33-
let where = ''
34+
const clauses = [...only]
3435
if (query.search) {
3536
const pattern = containsPattern(query.search)
36-
where = ` WHERE ${searched.map(column => `LOWER(${column}) LIKE ${bind(pattern)} ESCAPE '!'`).join(' OR ')}`
37+
clauses.push(`(${searched.map(column => `LOWER(${column}) LIKE ${bind(pattern)} ESCAPE '!'`).join(' OR ')})`)
3738
}
39+
const where = clauses.length > 0 ? ` WHERE ${clauses.join(' AND ')}` : ''
3840
const [counted, found] = await Promise.all([
3941
run.unsafe(`SELECT COUNT(*) AS total_count FROM ${table}${where}`, values).execute(),
4042
// LOWER, so the order is the same on every dialect: each collates case its own way.
@@ -52,11 +54,17 @@ export function searchProductChoices(
5254
return search('products', 'id, name, inventory_count', [`COALESCE(name, '')`, asText('id', dialect)], query, normalizeProductChoice, run, dialect)
5355
}
5456

55-
/** Customers by name, email or phone. */
57+
/**
58+
* Customers by name, email or phone. `activeOnly` keeps to customers whose
59+
* status is Active, as the point of sale offers them.
60+
*/
5661
export function searchCustomerChoices(
5762
query: NamedSearchQuery,
5863
run: CommerceSqlRunner = db,
5964
dialect: QueryBuilderDialect = getDatabaseDialect(),
65+
options: { activeOnly?: boolean } = {},
6066
): Promise<{ choices: CustomerChoice[], total: number }> {
61-
return search('customers', 'id, name, email, phone', [`COALESCE(name, '')`, `COALESCE(email, '')`, `COALESCE(phone, '')`], query, normalizeCustomerChoice, run, dialect)
67+
// A text comparison: PostgreSQL keeps the status as an enum.
68+
const only = options.activeOnly ? [`${asText('status', dialect)} = 'Active'`] : []
69+
return search('customers', 'id, name, email, phone', [`COALESCE(name, '')`, `COALESCE(email, '')`, `COALESCE(phone, '')`], query, normalizeCustomerChoice, run, dialect, only)
6270
}

0 commit comments

Comments
 (0)