toga-ai 1.0.552 → 1.0.554
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/knowledge/2.0/apps/_underscore/features/error-reporting-issue-event.md +24 -1
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/api-payload-interceptors.md +73 -2
- package/knowledge/2.0/apps/api2/features/request-logging.md +32 -2
- package/knowledge/2.0/apps/api2/features/v2-rest-query-contract.md +59 -1
- package/knowledge/2.0/apps/toga2-commerce/INDEX.md +2 -0
- package/knowledge/2.0/apps/toga2-commerce/features/category-tile-sort-order.md +164 -0
- package/knowledge/2.0/apps/toga2-commerce/features/filter-search-results-page.md +6 -1
- package/knowledge/2.0/apps/toga2-commerce/features/order-submit-sync-sequencing.md +115 -0
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/compass-usa/INDEX.md +1 -0
- package/knowledge/clients/compass-usa/features/sales-order-line-renumbering.md +103 -0
- package/knowledge/clients/compass-usa/profile.md +13 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-11
|
|
10
10
|
owners: ["dfranks", "jcardinal", "mhammontree", "ajean", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Error.php
|
|
@@ -33,6 +33,7 @@ related:
|
|
|
33
33
|
- ../../../1.0/apps/tools/features/errors-curation-console.md
|
|
34
34
|
- ../../../1.0/apps/library/features/error-capture-1-0.md
|
|
35
35
|
- ../../api2/features/v2-api-error-codes.md
|
|
36
|
+
- ../../api2/features/request-logging.md
|
|
36
37
|
- ../../dbchanges2/architecture.md
|
|
37
38
|
- ../../../../1.0/apps/library/features/http-500-error-monitor.md
|
|
38
39
|
---
|
|
@@ -362,6 +363,19 @@ WHERE issueId = <issue.id> AND eventNumber = <n>;
|
|
|
362
363
|
tenant that has gone quiet auto-resolves; check `dtLastOccurred` against the tenant's traffic
|
|
363
364
|
before believing it (see
|
|
364
365
|
[error-issue auto-resolution](../../worker2/features/error-issue-auto-resolution.md)).
|
|
366
|
+
- **Tell an AUTO-close from a real fix by the columns, not the status.** `dtAutoResolved`
|
|
367
|
+
populated **and `isManaged = 0`** means the frequency-decay cron closed it after its quiet
|
|
368
|
+
window — **nobody looked at the code**. On the listing that is visually identical to a defect a
|
|
369
|
+
human fixed, and it will silently reopen on the next occurrence. Before you accept "already
|
|
370
|
+
resolved", confirm a code or config change actually shipped. (This diverted a production
|
|
371
|
+
investigation on 2026-08-11.)
|
|
372
|
+
- **`Event.context` carries the per-occurrence request identity** — `requestMethod`,
|
|
373
|
+
`requestPath`, **`transactionId`**, `referer`, `userAgent`, `instanceId`, the Elastic Beanstalk
|
|
374
|
+
environment and the PHP version. `transactionId` is the field that collapses a burst of
|
|
375
|
+
near-identical occurrences back into the **one** user action that caused them, because apiproxy
|
|
376
|
+
retries a 5xx across regions under a single transaction id — see
|
|
377
|
+
[api2 request logging](../../api2/features/request-logging.md). Read occurrence counts through
|
|
378
|
+
it or you will overstate user impact.
|
|
365
379
|
- The **1.0 alert surface** that fires on these is
|
|
366
380
|
[App_SystemMonitor_500Error](../../../../1.0/apps/library/features/http-500-error-monitor.md) — its
|
|
367
381
|
"Error Type" is a fallback literal with **zero** diagnostic value, which is why this recipe
|
|
@@ -617,6 +631,15 @@ clientUserId). **Neither was built.** As built instead:
|
|
|
617
631
|
|
|
618
632
|
## Change history
|
|
619
633
|
|
|
634
|
+
- 2026-08-11 — Sharpened the `error.id` lookup recipe from a production incident investigation
|
|
635
|
+
(Compass, api2 500s): **`status = 'RESOLVED'` with `dtAutoResolved` set and `isManaged = 0` is a
|
|
636
|
+
cron auto-close after a quiet window, not a fix** — it is indistinguishable from a real fix on
|
|
637
|
+
the listing and reopens on the next occurrence, so verify a code/config change actually shipped
|
|
638
|
+
(this misdirected the investigation once). Also recorded what `Event.context` actually carries
|
|
639
|
+
per occurrence (`requestMethod`, `requestPath`, `transactionId`, `referer`, `userAgent`,
|
|
640
|
+
`instanceId`, EB environment, PHP version) and that **`transactionId` is the field that collapses
|
|
641
|
+
an apiproxy cross-region retry storm back into one user action** — occurrence counts read
|
|
642
|
+
without it overstate impact. No code change. (bala)
|
|
620
643
|
- 2026-08-10 — Added the **`error.id` lookup recipe** (`Logs.Api.responsePayload` → `Logs.Issue`
|
|
621
644
|
by `reference` → `Logs.Event` by `eventNumber` for the tenant), because in production `error.id`
|
|
622
645
|
is the **only** handle an api2 500 gives you — `message`/`trace` are debug-mode-gated so the
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
|
|
6
|
-
| [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Elite/SalesOrder.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql |
|
|
6
|
+
| [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql |
|
|
7
7
|
| [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, _underscore/Model/Core/Record.php, worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
|
|
8
8
|
| [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
|
|
9
9
|
| [ENVIRONMENT (not the EB environment name) decides the _underscore branch and Config file](features/environment-variable-drives-underscore-branch.md) | An api2 Elastic Beanstalk instance decides **which `_underscore` branch it clones** and **which `Config/<env>.ini` it loads** from the EB environment property * | api2/.ebextensions/git.php, api2/.ebextensions/php_include_underscore.config, api2/.ebextensions/git.sandbox-dev.json, api2/Config/beta.ini, api2/Config/sandbox-dev.ini, api2/Component/Api/V2/V2.php |
|
|
@@ -6,13 +6,15 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-11
|
|
10
10
|
owners: ["mhammontree", "dfranks", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
13
|
- api2/Controller/Index.php
|
|
14
14
|
- _underscore/Model/Client/ItemFulfillment.php
|
|
15
15
|
- _underscore/Model/Elite/SalesOrder.php
|
|
16
|
+
- _underscore/Model/Compass/SalesOrder.php
|
|
17
|
+
- _underscore/Model/Compass/SalesOrderItem.php
|
|
16
18
|
- dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
|
|
17
19
|
related:
|
|
18
20
|
- ./record-scripts.md
|
|
@@ -22,6 +24,7 @@ related:
|
|
|
22
24
|
- ../../_underscore/features/config-group-access.md
|
|
23
25
|
- ../../worker2/features/creating-worker-actions.md
|
|
24
26
|
- ../../../clients/elite/features/salesorder-netsuite-push.md
|
|
27
|
+
- ../../../clients/compass-usa/features/sales-order-line-renumbering.md
|
|
25
28
|
---
|
|
26
29
|
|
|
27
30
|
## Summary
|
|
@@ -106,9 +109,56 @@ model does not define takes the endpoint down** with a PHP fatal —
|
|
|
106
109
|
mirror image of the "missing row" failure mode above: a *missing* row is silent, an *extra/wrong*
|
|
107
110
|
row is fatal.
|
|
108
111
|
|
|
112
|
+
The post-processing site reads
|
|
113
|
+
`$clientModelName::{$interceptorEntry['phpMethod']}(api: $api, payload: $outData);` — `phpMethod`
|
|
114
|
+
is a key **derived in PHP at lookup time**, not a column, so nothing between the SQL row and the
|
|
115
|
+
call can reject a name the class does not implement.
|
|
116
|
+
|
|
109
117
|
**Fix direction:** guard all four sites with `method_exists()` and raise a logged `Logs.Issue`
|
|
110
118
|
instead of letting the request fatal.
|
|
111
119
|
|
|
120
|
+
**Measured blast radius (Compass, 2026-08-05 → 2026-08-06).** A single row wiring `POST`/`POST` on
|
|
121
|
+
`sales-order-items` resolved to `_Model_Compass_Usa_SalesOrderItem::postPost()`, which was never
|
|
122
|
+
written. Result: **270 failed Compass order submissions over ~15 hours** (2026-08-05 17:16 →
|
|
123
|
+
2026-08-06 07:58), every one an opaque `EO-1` 500 with no message. This is the cost of the missing
|
|
124
|
+
guard — not a degraded response, a dead write endpoint for one tenant until someone deleted the row.
|
|
125
|
+
|
|
126
|
+
## There is NO `Records` route for `api-payload-interceptors` — the rows are UNAUDITED
|
|
127
|
+
|
|
128
|
+
`ApiPayloadInterceptors` has **no `Core.Records` row**, therefore no `/v2` route, therefore no ACL,
|
|
129
|
+
no `Logs.Api` entry, and **no change history of any kind**. Every add, edit, and delete is direct
|
|
130
|
+
SQL against the client database.
|
|
131
|
+
|
|
132
|
+
Practical consequences when you are investigating:
|
|
133
|
+
|
|
134
|
+
- **You cannot answer "who added this row and when."** The 270-order outage above was resolved by
|
|
135
|
+
deleting a row, and there is no record anywhere of who created it or when — the only evidence is
|
|
136
|
+
the shape of the 500s in `Logs.Api`.
|
|
137
|
+
- **A fix by row-deletion leaves no trace either.** "It stopped happening" is the whole audit trail.
|
|
138
|
+
Record the before/after row set in the ticket or in this KB, because the database will not.
|
|
139
|
+
- **Snapshot the table before you change it** (`SELECT` the client's full row set to the ticket).
|
|
140
|
+
It is a small table and the prod-vs-environment full-table compare is already the standard
|
|
141
|
+
diagnostic — that same dump is the only rollback you have.
|
|
142
|
+
|
|
143
|
+
## `postDelete` can NEVER run — delete-time logic must live in `preDelete`
|
|
144
|
+
|
|
145
|
+
The post-processing interceptor block in `api2/Component/Api/V2/V2.php` (~L5692) executes **only
|
|
146
|
+
when `$outData` is non-null**, and a **successful DELETE always sets `$outData = null`**. So a
|
|
147
|
+
`(recordId, POST, DELETE)` row is dead on arrival: the method is never called, and — exactly like
|
|
148
|
+
the missing-row case — there is no error and no log line to tell you.
|
|
149
|
+
|
|
150
|
+
**Therefore any logic that must react to a deletion runs in `preDelete`, and must exclude the row
|
|
151
|
+
that is about to disappear**, because at `preDelete` time it is still present. The Compass line
|
|
152
|
+
renumberer takes the id explicitly for this reason:
|
|
153
|
+
`renumberLineNumbers($salesOrderId, excludeSalesOrderItemId: $id)`. See
|
|
154
|
+
[Compass sales-order line renumbering](../../../clients/compass-usa/features/sales-order-line-renumbering.md)
|
|
155
|
+
for the worked wiring.
|
|
156
|
+
|
|
157
|
+
Corollary for design: when delete-time work also needs the *parent* to be consistent afterwards,
|
|
158
|
+
prefer hooking the **parent** record's `postPost`/`postPut` — the parent hook fires reliably as the
|
|
159
|
+
request's first-resolved record, whereas the child hook has both the `$outData` constraint above and
|
|
160
|
+
the undefined-method risk.
|
|
161
|
+
|
|
112
162
|
### The client-model resolution that decides which class must define the method
|
|
113
163
|
|
|
114
164
|
At `~L5681-5690` the engine derives the concrete class by `str_replace`-ing `_Model_Client_` →
|
|
@@ -223,7 +273,15 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
223
273
|
- **⚠ An interceptor row for a method that does not exist is a production outage, not a no-op.**
|
|
224
274
|
The dispatch is unguarded at all four call sites — the endpoint fatals with *"Call to undefined
|
|
225
275
|
method"*. If a write endpoint suddenly 500s for one client only, list that client's
|
|
226
|
-
`ApiPayloadInterceptors` rows and confirm every derived method exists.
|
|
276
|
+
`ApiPayloadInterceptors` rows and confirm every derived method exists. Measured cost of one such
|
|
277
|
+
row: **270 failed Compass order submissions in ~15 hours.**
|
|
278
|
+
- **⚠ A `(POST, DELETE)` row is silently dead** — the post block runs only when `$outData` is
|
|
279
|
+
non-null and a successful DELETE nulls it. Use `preDelete` (excluding the row being deleted), or
|
|
280
|
+
hook the parent record instead.
|
|
281
|
+
- **⚠ These rows have no route, no ACL and no audit trail.** `api-payload-interceptors` has no
|
|
282
|
+
`Core.Records` row, so every change is direct SQL and nothing records who made it. Snapshot the
|
|
283
|
+
client's row set into the ticket before and after any change — that dump is your only rollback
|
|
284
|
+
and your only history.
|
|
227
285
|
- **⚠ A migration that enables an interceptor must insert `isActive = 0`.** Activation is a *data*
|
|
228
286
|
change that switches on a *code* path; if the PHP defining the method is not confirmed deployed to
|
|
229
287
|
that environment, the row takes the endpoint down. Insert inactive, verify the deploy, then flip
|
|
@@ -252,6 +310,19 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
252
310
|
|
|
253
311
|
## Change history
|
|
254
312
|
|
|
313
|
+
- 2026-08-11 — Three findings from the post-mortem of the `_Model_Compass_Usa_SalesOrderItem::postPost()`
|
|
314
|
+
outage (investigation only, no code change). (1) **`postDelete` can never fire** — the
|
|
315
|
+
post-processing block at `V2.php` ~L5692 runs only when `$outData` is non-null and a successful
|
|
316
|
+
DELETE always sets it to null, so a `(recordId, POST, DELETE)` row is silently dead; delete-time
|
|
317
|
+
logic must run in `preDelete` and **exclude the row about to be deleted**, or hook the parent
|
|
318
|
+
record, which fires reliably as the request's first-resolved record. (2) **These rows are
|
|
319
|
+
completely unaudited** — `api-payload-interceptors` has no `Core.Records` row, hence no route, no
|
|
320
|
+
ACL, no `Logs.Api` entry and no history; adds and removes leave no evidence, so snapshot the
|
|
321
|
+
client's row set into the ticket before/after. (3) Quantified the unguarded-dispatch failure:
|
|
322
|
+
**270 failed Compass order submissions between 2026-08-05 17:16 and 2026-08-06 07:58**, all opaque
|
|
323
|
+
`EO-1` 500s, ended by deleting one row. Also recorded that the post call site invokes a
|
|
324
|
+
runtime-derived `$interceptorEntry['phpMethod']`, so nothing validates the name before the call.
|
|
325
|
+
(bala)
|
|
255
326
|
- 2026-08-10 — Added **Queueing background work from a post interceptor**, five verified rules from
|
|
256
327
|
the Elite sales-order push: (1) `internalApiRequest` returns records **flat** —
|
|
257
328
|
`$response->salesOrders`, not `$response->data->salesOrders` (the `data` envelope exists only on a
|
|
@@ -6,8 +6,8 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
10
|
-
owners: ["mhammontree", "dfranks"]
|
|
9
|
+
updated: 2026-08-11
|
|
10
|
+
owners: ["mhammontree", "dfranks", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
13
|
- api2/Controller/Index.php
|
|
@@ -85,6 +85,27 @@ Practical consequences:
|
|
|
85
85
|
- **Orphan sweeps only find PARTIAL casualties.** An attempt that rolled back completely leaves no
|
|
86
86
|
row in any table and, with no API log row, no trail at all.
|
|
87
87
|
|
|
88
|
+
## ⚠ apiproxy's 5xx retry distorts BOTH logs — always read a 500 through `transactionId`
|
|
89
|
+
|
|
90
|
+
apiproxy retries a 5xx **across regions**, so **one user action produces up to six near-identical
|
|
91
|
+
500 rows** in core `Logs.Api`: same route, same payload, **one shared `transactionId`**, ~2 s apart,
|
|
92
|
+
alternating `instanceId`s. Two consequences, and both have already caused wrong conclusions:
|
|
93
|
+
|
|
94
|
+
1. **Occurrence counts overstate user impact.** A "burst" is usually one person clicking once.
|
|
95
|
+
Collapse on `transactionId` before you report how many users were affected — the same applies to
|
|
96
|
+
`Logs.Issue.totalOccurrences` and to the 1.0 alert's repeat count
|
|
97
|
+
([HTTP 500 monitor](../../../1.0/apps/library/features/http-500-error-monitor.md)).
|
|
98
|
+
2. **A retry that succeeds hides the failure from the client log.** The failed attempts land only
|
|
99
|
+
in **core `Logs.Api`** (failed writes are core-logged — see above), while the retry that
|
|
100
|
+
returned 200 is written to **`Logs_<Client>.Api`**. So the client log shows a **clean 200 for a
|
|
101
|
+
request that 500'd first**, and a client-log-only audit reads a partially-failing endpoint as
|
|
102
|
+
healthy.
|
|
103
|
+
|
|
104
|
+
**`Logs_<Client>.Api.transactionId` is UNIQUE**, so there is at most **one** client-log row per
|
|
105
|
+
transaction no matter how many times apiproxy retried. That is why the retry attempts cannot appear
|
|
106
|
+
there even in principle — the two logs are not two views of the same rows. Join the two by
|
|
107
|
+
`transactionId` when you need the full attempt sequence for one user action.
|
|
108
|
+
|
|
88
109
|
## Blind spots when auditing a client's API traffic
|
|
89
110
|
|
|
90
111
|
Looking only at `Logs_<Client>.Api` misses four sources:
|
|
@@ -156,6 +177,15 @@ re-send.
|
|
|
156
177
|
|
|
157
178
|
## Change history
|
|
158
179
|
|
|
180
|
+
- 2026-08-11 — Documented how **apiproxy's cross-region 5xx retry distorts both logs**, found while
|
|
181
|
+
reading prod 500s for Compass: one user action writes up to **six** near-identical core `Logs.Api`
|
|
182
|
+
500 rows sharing a single `transactionId` (~2 s apart, alternating `instanceId`), so occurrence
|
|
183
|
+
counts overstate impact; and when a retry **succeeds**, the 200 is written to `Logs_<Client>.Api`
|
|
184
|
+
while the failed attempts exist **only** in core `Logs.Api` — so the client log can show a clean
|
|
185
|
+
200 for a request that 500'd first, and a client-log-only audit reads a partially-failing endpoint
|
|
186
|
+
as healthy. Recorded that **`Logs_<Client>.Api.transactionId` is UNIQUE** (at most one client-log
|
|
187
|
+
row per transaction), which is why the retry attempts cannot appear there, and that
|
|
188
|
+
`transactionId` is the join key for reconstructing one user action. No code change. (bala)
|
|
159
189
|
- 2026-08-10 — Added **Querying prod `Logs.Api` without timing out**: any `LIKE` over the
|
|
160
190
|
`mediumtext` `requestPayload`/`responsePayload` times out the `toga_query` MCP call at 300 s even
|
|
161
191
|
for one day. Narrow first on indexed/non-text columns (`dtStamp` + `route` + `responseCode`, or a
|
|
@@ -47,7 +47,47 @@ controller.
|
|
|
47
47
|
- **Pagination is `page` + `recordsPerPage`. There is no `limit`/`offset`.** Defaults:
|
|
48
48
|
`recordsPerPage` **25**, `depth` **3**. Max `recordsPerPage` **10000** — exceeding it returns
|
|
49
49
|
**`EV-13`** (HTTP 400).
|
|
50
|
-
- **Sort:** `-field` = DESC, bare `field` = ASC.
|
|
50
|
+
- **Sort:** `-field` = DESC, `+field` = ASC, bare `field` = ASC. All three are handled
|
|
51
|
+
(`V2.php` ~L3673–3682). **Prefer bare for ascending:** the query string is parsed *without*
|
|
52
|
+
urldecoding (see Encoding rules), so a client that percent-encodes the `+` as `%2B` sends a
|
|
53
|
+
field literally named `%2Bfield` and the sort is silently dropped. Bare has no such failure mode.
|
|
54
|
+
- **A LIST select is `SELECT SQL_CALC_FOUND_ROWS DISTINCT <table>.<primaryKey>`** (`V2.php:3884`;
|
|
55
|
+
the aggregate-fields path is the one exception). Two consequences: (a) join fan-out collapses to
|
|
56
|
+
one row per record, so **duplicate child rows are invisible in the response** — a duplicate join
|
|
57
|
+
row is not a rendering bug you can see; (b) when the collapsed duplicates carry **different**
|
|
58
|
+
values in the sorted column, `ORDER BY` has more than one candidate value for a single output row
|
|
59
|
+
and that record's **position is non-deterministic**. Sorting on a joined column is only stable if
|
|
60
|
+
the join is genuinely 1:1, or the duplicates agree. Worked example:
|
|
61
|
+
[category tile sort order](../../toga2-commerce/features/category-tile-sort-order.md).
|
|
62
|
+
|
|
63
|
+
### ⚠ `depth` is not free — the default 3 re-reads child rows and races concurrent deletes
|
|
64
|
+
|
|
65
|
+
**`depth` defaults to 3 on every request, writes included.** Serialization (`getFullModelData()` in
|
|
66
|
+
`api2/Component/Api/V2/V2.php`) walks the relationship graph to that depth and **re-loads each child
|
|
67
|
+
by primary key** through `_Model::initialize()` (`_underscore/Model.php` ~L705). `_Model`'s build
|
|
68
|
+
**throws when the row is gone**:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
Error during Model build for primary key id 'NNNNNN' for '_Model_<Client>_SalesOrderItem'.
|
|
72
|
+
Exactly 1 row was expected to be returned but 0 were.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Nothing catches it, so it surfaces as an **opaque `EO-1` HTTP 500** on a request that did nothing
|
|
76
|
+
wrong. (This is a *different* path from the dangling-FK tolerance in
|
|
77
|
+
[cross-client data retrieval](cross-client-data-retrieval.md), which nulls a failed **FK expansion**
|
|
78
|
+
— the child-collection re-load is not covered by it.)
|
|
79
|
+
|
|
80
|
+
**The rule for callers:** if another request may be deleting rows in a collection concurrently, a
|
|
81
|
+
response that walks that collection can 500. Two defences, both cheap:
|
|
82
|
+
|
|
83
|
+
- **Send an explicit `depth` — do not inherit the default.** A write that only needs its own row
|
|
84
|
+
should send `depth: 1`; that alone stops the serializer from touching the child collection.
|
|
85
|
+
- **Serialize the writes.** Do not run a request that deletes children in parallel with one whose
|
|
86
|
+
response walks those children. Worked example (a real production 500 burst):
|
|
87
|
+
[TOGa Commerce order-submit sync sequencing](../../toga2-commerce/features/order-submit-sync-sequencing.md).
|
|
88
|
+
|
|
89
|
+
The fragility itself is **unfixed** — any depth ≥ 2 response is exposed. Lowering depth removes
|
|
90
|
+
*your* exposure, not the defect.
|
|
51
91
|
|
|
52
92
|
### CRITICAL — an unrecognized param silently flips LIST → READ
|
|
53
93
|
|
|
@@ -129,6 +169,24 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
|
|
|
129
169
|
invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
|
|
130
170
|
|
|
131
171
|
## Change history
|
|
172
|
+
- 2026-08-11 — **Corrected the sort contract and documented the `DISTINCT` LIST select.** The old
|
|
173
|
+
"do not send `+`" was wrong as an absolute: `V2.php` ~L3673–3682 explicitly handles `+` as ASC.
|
|
174
|
+
The real caveat is encoding — the query string is parsed without urldecoding, so a
|
|
175
|
+
percent-encoded `+` (`%2B`) becomes part of the field name and the sort is silently dropped;
|
|
176
|
+
bare remains the recommended ascending form. Also recorded that a LIST select is
|
|
177
|
+
`SELECT SQL_CALC_FOUND_ROWS DISTINCT <table>.<primaryKey>` (`V2.php:3884`), which hides duplicate
|
|
178
|
+
join rows entirely **and** makes `ORDER BY` non-deterministic for a record whose collapsed
|
|
179
|
+
duplicates disagree on the sorted column. Found while diagnosing storefront category tile
|
|
180
|
+
ordering (duplicate `AssortmentItems` rows for one item). (bala)
|
|
181
|
+
- 2026-08-11 — Recorded that **`depth` (default 3) makes a response race concurrent deletes**:
|
|
182
|
+
`getFullModelData()` re-loads each child by primary key via `_Model::initialize()`, whose build
|
|
183
|
+
**throws** when the row is gone (*"Exactly 1 row was expected to be returned but 0 were"*),
|
|
184
|
+
uncaught → opaque `EO-1` 500. Confirmed in production from a `PUT` at default depth walking
|
|
185
|
+
`salesOrder → salesOrderItems` while a concurrent `DELETE /v2/sales-order-items/*` burst removed
|
|
186
|
+
those exact rows. Defences for callers: send an explicit `depth: 1` on writes that need only their
|
|
187
|
+
own row, and never run a child-deleting request in parallel with one whose response walks that
|
|
188
|
+
collection. Noted this is a **different** path from the dangling-FK tolerance (which nulls a failed
|
|
189
|
+
FK expansion) and that the underlying fragility is unfixed for any depth ≥ 2. (bala)
|
|
132
190
|
- 2026-08-10 — Recorded that **`fields=` narrows the response but not the SQL SELECT**: `_Model`
|
|
133
191
|
selects every property declared on the class **and its traits**, so a declared-but-missing column
|
|
134
192
|
500s (`EO-1`, MySQL 1054) any request touching that record — including one that named only an
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
| [Cart Bundle Submission & the bundleUuid Identity Contract](features/cart-bundle-submission-and-identity.md) | How cart **bundles** (kits) are turned into `SalesOrderItems` when a cart is submitted or an existing order is edited, and the **identity-field contract** every | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts, src/utils/formatSalesOrderBundlesFromApi.ts, src/stores/useCartStoreZu.ts, src/pages/OrderDetails/helpers/formatSalesOrderDataFromLocalStorage.ts, src/pages/OrderDetails/view/components/OrderItems.tsx |
|
|
7
7
|
| [Cart Notification Emails — duplicate prevention](features/cart-notification-emails.md) | On the cart "Notifications" section a user can add CC email addresses to an order. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/stores/useEmailOptionsStore.ts, src/stores/useCartSalesQuoteZu.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts |
|
|
8
8
|
| [Cart Page — config-driven form architecture (current state + planned refactor)](features/cart-page-config-architecture.md) | The Cart page (`src/pages/Cart/`) is the most config-heavy page in `toga2-commerce`. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/pages/Cart/view/cartForm/CartFormSection.tsx, src/pages/Cart/view/cartForm/CartFormRenderer.tsx, src/pages/Cart/view/EditCart.tsx, src/pages/Cart/view/EditOrder.tsx, src/pages/Cart/viewModel/useEditOrderOrEditCartViewModel.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts, src/hooks/useAssignClientFields.ts |
|
|
9
|
+
| [Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category](features/category-tile-sort-order.md) | **"Move item X to the front of category Y" is a DATA change, not a code change.** The order of item tiles on a storefront category page is driven by exactly one | src/pages/Filter/api/FilterApi.ts, src/pages/Filter/viewModel/useFilterViewModel.ts, api2/Component/Api/V2/V2.php, toga2-supply/src/pages/Items/api/itemsApi.ts |
|
|
9
10
|
| [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Almost no user-facing text, field layout, or page config is hard-coded in `toga2-commerce`. | src/pages/Account/view/MySettingsView.tsx, src/contexts/helpers/getLoginSettings.ts, src/pages/Account/viewModel/useAccountViewModel.ts, src/fieldsConfig/index.ts, src/fieldsConfig/getClientLoginFields.ts, src/fieldsConfig/clientFields/COMPASS.json, src/fieldsConfig/clientFields/COMPASSCANADA.json, src/fieldsConfig/clientFields/QUAD.json, src/pages/Cart/api/CartApi.ts, src/hooks/useAuthenticationFlow.ts, src/contexts/AuthContext.tsx, src/pages/Login/viewModel/useLoginPageViewModel.ts, src/hooks/useAssignClientFields.ts, src/hooks/useDynamicConditionalFieldOptions.ts, src/stores/useFieldsStore.ts, src/components/BaseDetailField/BaseDetailField.tsx, src/components/NavIcons/NavIconItem.tsx, src/components/Submenus/AlertSubmenu.tsx, src/components/Submenus/types.ts, src/pages/Account/AccountPage.tsx, src/pages/Account/view/MyOrdersView.tsx, src/pages/GetSupport/GetSupportPage.tsx, src/pages/GetSupport/viewModel/useGetSupportViewModel.ts, src/queries/queries.ts, src/App.tsx, src/pages/Filter/FilterPage.tsx, src/pages/Filter/viewModel/FIELDS/COMPASS/ENGLISH/USER/FILTERPAGEFIELDS.json |
|
|
10
11
|
| [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | On the toga2-commerce **Cart** page, expedited shipping options (**"2nd Day EOB"** and **"Next Day Air"**) are only offered in the *Shipping Method* dropdown wh | toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/viewModel/FIELDS/shared/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx, toga2-commerce/src/pages/Cart/CartPage.tsx |
|
|
11
12
|
| [Filter / Search-Results Page & the Two Search Entry Points](features/filter-search-results-page.md) | The storefront has **two distinct search entry points that render the same card component through completely different code paths and different FIELDS files**. | src/pages/Filter/FilterPage.tsx, src/pages/Filter/viewModel/useFilterViewModel.ts, src/pages/Filter/viewModel/FIELDS/COMPASS/ENGLISH/USER/FILTERPAGEFIELDS.json, src/pages/Filter/viewModel/FIELDS/COMPASSCANADA/FRENCH/USER/FILTERPAGEFIELDS.json, src/pages/Filter/viewModel/FIELDS/QUAD/ENGLISH/BUYER/FILTERPAGEFIELDS.json, src/components/Header/Header.tsx, src/pages/Home/view/components/BundlesSection.tsx, src/pages/Home/viewModel/FIELDS/COMPASS/ENGLISH/USER/HOMEPAGEFIELDS.json, src/components/Cards/BundleViewCard.tsx, src/utils/renderBadge.tsx, src/hooks/useAssignClientFields.ts |
|
|
12
13
|
| [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | `toga2-commerce` serves multiple clients from one codebase. | src/themeConfig/themes.json, src/themeConfig/ThemeContext.tsx, src/themeConfig/types.ts, src/components/ThemeSwitcher/ThemeSwitcher.tsx, src/components/AuthLayout/AuthLayout.tsx, src/api/axiosInstance.ts, src/contexts/AuthContext.tsx, tailwind.config.js |
|
|
14
|
+
| [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | Submitting an order from the cart fires **two independent sync routines** — one for the sales-order header (`syncSalesOrderData`) and one for the line items (`s | toga2-commerce/src/pages/OrderDetails/hooks/useSubmitOrder.ts, toga2-commerce/src/api/syncSalesOrdersDataFromLocalStorageCartToApi.ts, toga2-commerce/src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts |
|
|
13
15
|
| [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-commerce` (React + Vite, "commerce2-react") builds and deploys on **AWS Amplify**. | toga2-commerce/amplify.yml, toga2-commerce/.gitattributes, toga2-commerce/package.json, toga2-commerce/.github/workflows/sync-stage-environments.yml |
|
|
14
16
|
| [Cart e2e — Cypress conventions & harness (toga2-commerce)](workflows/cypress-testing.md) | The Cypress **e2e** convention set for `toga2-commerce`, and the first **active** e2e coverage for the **Cart** page (`cartV2.cy.ts`, slice 1 — 12 tests, verifi | toga2-commerce/cypress/e2e/cartPage/cartV2.cy.ts, toga2-commerce/cypress/fixtures/cart/fetchSingleUserAdmin.json, toga2-commerce/cypress/fixtures/cart/fetchLocations.json, toga2-commerce/cypress/fixtures/cart/fetchUserShippingMethods.json, toga2-commerce/cypress/support/commands.ts, toga2-commerce/cypress/support/e2e.ts, toga2-commerce/src/pages/Cart/CartPage.tsx, toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx, toga2-commerce/src/pages/Cart/view/cartForm/CartFormSection.tsx, toga2-commerce/src/pages/Cart/view/cartTable/CartContentsTable.tsx, toga2-commerce/src/pages/Cart/view/cartTable/CartTableItem.tsx, toga2-commerce/src/components/Inputs/AdvancedInput.tsx, toga2-commerce/src/components/BaseButton/BaseButton.tsx |
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-commerce
|
|
5
|
+
project: TOGa Commerce
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-11
|
|
10
|
+
owners: [bala]
|
|
11
|
+
files:
|
|
12
|
+
- src/pages/Filter/api/FilterApi.ts
|
|
13
|
+
- src/pages/Filter/viewModel/useFilterViewModel.ts
|
|
14
|
+
- api2/Component/Api/V2/V2.php
|
|
15
|
+
- toga2-supply/src/pages/Items/api/itemsApi.ts
|
|
16
|
+
related:
|
|
17
|
+
- filter-search-results-page.md
|
|
18
|
+
- ../architecture.md
|
|
19
|
+
- ../../api2/features/v2-rest-query-contract.md
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Summary
|
|
23
|
+
|
|
24
|
+
**"Move item X to the front of category Y" is a DATA change, not a code change.** The order of
|
|
25
|
+
item tiles on a storefront category page is driven by exactly one column —
|
|
26
|
+
**`Client_<Client>.AssortmentItems.sortOrder`, ascending** — and nothing else. There is no
|
|
27
|
+
merchandising UI, no per-tenant config key, and no client-side re-sort to override.
|
|
28
|
+
|
|
29
|
+
The trap is that the obvious one-row `UPDATE` **does not work**: in MySQL `ORDER BY … ASC`,
|
|
30
|
+
`NULL` sorts **before** every number, and these columns are normally all `NULL`. Setting only the
|
|
31
|
+
target item to `1` leaves the still-`NULL` siblings ahead of it — the opposite of the request. You
|
|
32
|
+
must give **every active item in that assortment** an explicit `sortOrder`.
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
1. **Category browse** is the `/filter` page with a `category` query param and no `search` text.
|
|
37
|
+
`useFilterViewModel.ts` runs the `["assortmentItems", categoryUuid, currentPage]` query, which
|
|
38
|
+
calls `fetchAssortmentItems()` in `src/pages/Filter/api/FilterApi.ts`.
|
|
39
|
+
2. That request joins `Items → AssortmentItems → Assortments` (plus `Personas_Items → Personas`
|
|
40
|
+
for non-admins), filters to the requested assortment uuid with `Items.isActive = 1` and
|
|
41
|
+
`Items.catalogId = 1`, pages at `recordsPerPage: 12`, and sorts with exactly:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
sort: ["AssortmentItems.sortOrder"]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
3. **api2 treats a bare (unprefixed) sort field as ASC.** (`-` = DESC, `+` = ASC — see
|
|
48
|
+
[v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md).)
|
|
49
|
+
4. **The frontend never re-sorts.** The view model does a straight
|
|
50
|
+
`setSortedItems(assortmentItems.data.items)` — despite the `sortedItems` name, no sorting
|
|
51
|
+
happens there. **The API's row order is the final on-screen order.**
|
|
52
|
+
|
|
53
|
+
So the only lever is the column. Ties (equal or all-`NULL` values) fall back to whatever MySQL
|
|
54
|
+
returns, which is not a documented or stable order.
|
|
55
|
+
|
|
56
|
+
## Making the change (the procedure)
|
|
57
|
+
|
|
58
|
+
Assign an explicit `sortOrder` to **every active item** in the assortment — the two pinned ones,
|
|
59
|
+
then the rest. Pattern (`<assortmentId>` from `Assortments`, item ids from `Items`):
|
|
60
|
+
|
|
61
|
+
```sql
|
|
62
|
+
# 1. pin the intended leaders
|
|
63
|
+
UPDATE AssortmentItems
|
|
64
|
+
SET sortOrder = 1
|
|
65
|
+
WHERE
|
|
66
|
+
assortmentId = <assortmentId> AND
|
|
67
|
+
itemId = <firstItemId>;
|
|
68
|
+
|
|
69
|
+
UPDATE AssortmentItems
|
|
70
|
+
SET sortOrder = 2
|
|
71
|
+
WHERE
|
|
72
|
+
assortmentId = <assortmentId> AND
|
|
73
|
+
itemId = <secondItemId>;
|
|
74
|
+
|
|
75
|
+
# 2. every remaining ACTIVE item must get a value, or a NULL row jumps to the front
|
|
76
|
+
UPDATE AssortmentItems ai
|
|
77
|
+
INNER JOIN Items i ON i.id = ai.itemId
|
|
78
|
+
SET ai.sortOrder = 10
|
|
79
|
+
WHERE
|
|
80
|
+
ai.assortmentId = <assortmentId> AND
|
|
81
|
+
ai.sortOrder IS NULL AND
|
|
82
|
+
i.isActive = 1;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Verify with the read the storefront actually performs — ordered, not just spot-checked:
|
|
86
|
+
|
|
87
|
+
```sql
|
|
88
|
+
SELECT
|
|
89
|
+
ai.id,
|
|
90
|
+
ai.itemId,
|
|
91
|
+
ai.sortOrder,
|
|
92
|
+
i.itemNumber,
|
|
93
|
+
i.isActive
|
|
94
|
+
FROM AssortmentItems ai
|
|
95
|
+
INNER JOIN Items i ON i.id = ai.itemId
|
|
96
|
+
WHERE ai.assortmentId = <assortmentId>
|
|
97
|
+
ORDER BY ai.sortOrder ASC;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Recommended, not required:** park **inactive** rows at a high value (e.g. `100`) as well. They
|
|
101
|
+
are filtered out today by `Items.isActive = 1`, so they cost nothing now — but if someone
|
|
102
|
+
reactivates one later it comes back with `sortOrder = NULL` and silently takes first position.
|
|
103
|
+
|
|
104
|
+
## Gotchas
|
|
105
|
+
|
|
106
|
+
- **`NULL` sorts FIRST in ASC.** The single-row `UPDATE` that "obviously" pins an item produces the
|
|
107
|
+
opposite result while looking correct in isolation. Always set the whole active set.
|
|
108
|
+
- **Duplicate `AssortmentItems` rows exist and make order non-deterministic.** api2's LIST query
|
|
109
|
+
selects `SELECT SQL_CALC_FOUND_ROWS DISTINCT <table>.<primaryKey>` (`V2.php:3884`), so two
|
|
110
|
+
`AssortmentItems` rows for the same `itemId` collapse to **one visible tile** — which is why
|
|
111
|
+
nobody notices the duplicate. But if the duplicates carry **different** `sortOrder` values, the
|
|
112
|
+
`ORDER BY` has two candidate values for one output row and the position is unpredictable. When
|
|
113
|
+
duplicates exist, give them the **same** value or delete the extras. Check first:
|
|
114
|
+
|
|
115
|
+
```sql
|
|
116
|
+
SELECT itemId, COUNT(*) AS rowCount
|
|
117
|
+
FROM AssortmentItems
|
|
118
|
+
WHERE assortmentId = <assortmentId>
|
|
119
|
+
GROUP BY itemId
|
|
120
|
+
HAVING rowCount > 1;
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- **⚠ Editing an item's assortments in TOGa Supply WIPES `sortOrder`.** See Known open items —
|
|
124
|
+
any ordering you set is one supply edit away from being erased. Re-verify order after anyone
|
|
125
|
+
edits that item in supply.
|
|
126
|
+
- **A refresh will NOT show the change.** The storefront persists its whole React Query cache to
|
|
127
|
+
`localStorage["commerce"]` with a 24h `staleTime`, so a warm browser replays the cached
|
|
128
|
+
response and the (correct) data fix looks like it failed — then gets "re-fixed". The reliable
|
|
129
|
+
check is **log out and back in** (logout removes the `commerce` key), or
|
|
130
|
+
`localStorage.removeItem('commerce')`; otherwise it appears within 24h. See
|
|
131
|
+
[architecture](../architecture.md) gotchas.
|
|
132
|
+
- **This is data, so it does not ride a deploy.** No build, no release notes — which also means no
|
|
133
|
+
audit trail. Record the assortment id and the values you set in the ticket.
|
|
134
|
+
|
|
135
|
+
## Known open items
|
|
136
|
+
|
|
137
|
+
- **⚠ TOGa Supply resets `sortOrder` to `NULL` on any item-assortment edit — unfixed, no ticket
|
|
138
|
+
filed yet.** In `toga2-supply/src/pages/Items/api/itemsApi.ts`, `updateItemAssortments()`
|
|
139
|
+
(L645) reads the item's existing rows via `getAssortmentItems()` (L434), which requests only
|
|
140
|
+
`["assortment.name", "assortment.uuid", "uuid"]` — **never `sortOrder`** — then `DELETE`s every
|
|
141
|
+
row and `POST`s new ones whose payload carries only `item.uuid` + `assortment.uuid`. The
|
|
142
|
+
merchandising order is therefore silently destroyed and cannot be restored, because the old
|
|
143
|
+
values were never read. A fix must select `sortOrder`, and either preserve it on the re-POST or
|
|
144
|
+
stop the delete-then-recreate in favor of diffing.
|
|
145
|
+
- **Same function: the re-POSTs are never awaited.** `updateItemAssortments()` builds
|
|
146
|
+
`addPromises` and returns without `await Promise.all(addPromises)` (the `DELETE`s above it *are*
|
|
147
|
+
awaited). Failures surface as unhandled rejections and the caller cannot know the writes landed.
|
|
148
|
+
Fire-and-forget `POST`s racing an awaited `DELETE` burst is also the most likely origin of the
|
|
149
|
+
duplicate rows described in Gotchas. (Related: parallel deletes on a collection can 500 the
|
|
150
|
+
response of a concurrent read — see
|
|
151
|
+
[v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md).)
|
|
152
|
+
- **Compass USA "Docking Station" reorder — SQL delivered, NOT executed.** See
|
|
153
|
+
[Compass USA profile](../../../clients/compass-usa/profile.md).
|
|
154
|
+
|
|
155
|
+
## Change history
|
|
156
|
+
- 2026-08-11 — Initial doc, from a read-only prod investigation of a Compass USA request to move
|
|
157
|
+
one item to the front of a category. Recorded that tile order comes solely from
|
|
158
|
+
`AssortmentItems.sortOrder ASC` (`FilterApi.ts:56`) with no client-side re-sort in
|
|
159
|
+
`useFilterViewModel.ts`; the **`NULL`-sorts-first** trap that makes a single-row `UPDATE`
|
|
160
|
+
backfire (the real assortment had `sortOrder = NULL` on all 16 rows); api2's
|
|
161
|
+
`DISTINCT <primaryKey>` LIST select (`V2.php:3884`) hiding duplicate join rows while making
|
|
162
|
+
`ORDER BY` non-deterministic when duplicates disagree; and that toga2-supply's
|
|
163
|
+
`updateItemAssortments()` wipes `sortOrder` (and never awaits its re-POSTs), making any fix
|
|
164
|
+
non-durable. (bala)
|
|
@@ -7,7 +7,7 @@ client: shared
|
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
9
|
updated: 2026-07-28
|
|
10
|
-
owners: ["tcox"]
|
|
10
|
+
owners: ["tcox", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- src/pages/Filter/FilterPage.tsx
|
|
13
13
|
- src/pages/Filter/viewModel/useFilterViewModel.ts
|
|
@@ -24,6 +24,7 @@ related:
|
|
|
24
24
|
- 2.0/apps/toga2-commerce/architecture.md
|
|
25
25
|
- 2.0/apps/toga2-commerce/features/client-fields.md
|
|
26
26
|
- 2.0/apps/toga2-commerce/features/multi-tenant-theming.md
|
|
27
|
+
- 2.0/apps/toga2-commerce/features/category-tile-sort-order.md
|
|
27
28
|
---
|
|
28
29
|
|
|
29
30
|
## Summary
|
|
@@ -53,6 +54,10 @@ bug (see Change history).
|
|
|
53
54
|
tenant × language × role (see [client-fields](client-fields.md) for the resolver). `FilterPage`
|
|
54
55
|
`renderBundles` maps each result to a `BundleViewCard`.
|
|
55
56
|
|
|
57
|
+
> **Third mode, same page:** with a `category` param and **no** `search` text, `/filter` is the
|
|
58
|
+
> category *browse* page and runs a different query (`fetchAssortmentItems`) whose row order is
|
|
59
|
+
> data-driven — see [category tile sort order](category-tile-sort-order.md).
|
|
60
|
+
|
|
56
61
|
### Home search → in-place filter
|
|
57
62
|
|
|
58
63
|
The Home page's own lower search bar never leaves `/home`. `BundlesSection.tsx` reads
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-commerce
|
|
5
|
+
project: TOGa Commerce
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-11
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- toga2-commerce/src/pages/OrderDetails/hooks/useSubmitOrder.ts
|
|
13
|
+
- toga2-commerce/src/api/syncSalesOrdersDataFromLocalStorageCartToApi.ts
|
|
14
|
+
- toga2-commerce/src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts
|
|
15
|
+
related:
|
|
16
|
+
- ./cart-bundle-submission-and-identity.md
|
|
17
|
+
- ../architecture.md
|
|
18
|
+
- ../../api2/features/v2-rest-query-contract.md
|
|
19
|
+
- ../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Summary
|
|
23
|
+
|
|
24
|
+
Submitting an order from the cart fires **two independent sync routines** — one for the sales-order
|
|
25
|
+
header (`syncSalesOrderData`) and one for the line items (`syncSalesOrderLocalStorage`). Running them
|
|
26
|
+
**concurrently is not safe**: the header sync's responses are serialized by api2 at the **default
|
|
27
|
+
depth 3**, which walks down into `salesOrderItems` and re-reads each line by primary key, while the
|
|
28
|
+
item sync is deleting those exact lines. The loser of that race is an **uncaught api2 500**.
|
|
29
|
+
|
|
30
|
+
**The rule: the item sync must complete before the header sync starts.** Anything on this path that
|
|
31
|
+
does not need child data must also send an explicit `depth: 1`.
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
`useSubmitOrder` drives the submit. The two routines are:
|
|
36
|
+
|
|
37
|
+
| Routine | File | What it does |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `syncSalesOrderData()` | `src/api/syncSalesOrdersDataFromLocalStorageCartToApi.ts` | header-level writes, including `updateSalesOrderEmails()` |
|
|
40
|
+
| `syncSalesOrderLocalStorage()` | `src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts` | reconciles the cart's lines against the order — **issues `DELETE /v2/sales-order-items/{uuid}` for removed lines** |
|
|
41
|
+
|
|
42
|
+
`updateSalesOrderEmails()` runs **first** inside `syncSalesOrderData()`, so it is the earliest
|
|
43
|
+
header request on the wire and the one that collides with the delete burst.
|
|
44
|
+
|
|
45
|
+
## ⚠ The race — concurrent deletes vs. a depth-3 response
|
|
46
|
+
|
|
47
|
+
On `_production`, `useSubmitOrder` ran:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
await Promise.all([syncSalesOrderData(), syncSalesOrderLocalStorage()]);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`updateSalesOrderEmails()` issued `PUT /v2/sales-order-email-addresses/{uuid}` at the **default
|
|
54
|
+
depth 3**. api2 serializes that response by walking
|
|
55
|
+
`salesOrderEmailAddresses → salesOrder → salesOrderItems` and re-loading each child by primary key,
|
|
56
|
+
while `syncSalesOrderLocalStorage()` was concurrently deleting line items. Result:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
Error during Model build for primary key id 'NNNNNN' for '_Model_Compass_Usa_SalesOrderItem'.
|
|
60
|
+
Exactly 1 row was expected to be returned but 0 were.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Uncaught → HTTP 500 → opaque `EO-1` for the user, at the last click of the checkout flow.
|
|
64
|
+
|
|
65
|
+
**Confirmed, not inferred.** The production log timeline shows a `DELETE /v2/sales-order-items/*`
|
|
66
|
+
burst at 14:53:36–38 straddling the failing `PUT` at 14:53:37, and all six `SalesOrderItem` ids
|
|
67
|
+
named in the exception are absent from the database.
|
|
68
|
+
|
|
69
|
+
## The fix (TRUE-80551)
|
|
70
|
+
|
|
71
|
+
Three changes, in `toga2-commerce`:
|
|
72
|
+
|
|
73
|
+
1. **Drop the `PUT` entirely.** `updateSalesOrderEmails()` was writing `existingEmail.emailAddress`
|
|
74
|
+
back over itself — a **no-op self-overwrite**. The request that caused the outage had no effect
|
|
75
|
+
in the first place.
|
|
76
|
+
2. **Add `depth: 1` to the remaining `POST`**, so the response never walks the item collection.
|
|
77
|
+
3. **Serialize the two routines** — `syncSalesOrderLocalStorage()` runs to completion *before*
|
|
78
|
+
`syncSalesOrderData()`.
|
|
79
|
+
|
|
80
|
+
> **STATUS (2026-08-11): merged to `origin/_beta`, NOT in `origin/_production`.** The production
|
|
81
|
+
> defect is still live — `Logs.Issue` reference `3T` (id 72) is **OPEN**, urgency HIGH, 6
|
|
82
|
+
> occurrences, last seen 2026-08-05 15:21. This needs a production deploy; do not treat the
|
|
83
|
+
> incident as closed because the branch is merged.
|
|
84
|
+
|
|
85
|
+
## Gotchas / known issues
|
|
86
|
+
|
|
87
|
+
- **The underlying api2 fragility is NOT fixed.** `getFullModelData()` re-reads children by primary
|
|
88
|
+
key with no tolerance for a row that vanished mid-request, so **any** depth ≥ 2 response that
|
|
89
|
+
walks a child collection can still 500 if something concurrently deletes one of those rows.
|
|
90
|
+
TRUE-80551 removed the request that was hitting it; it did not remove the hazard. See
|
|
91
|
+
[V2 REST query contract → `depth` is not free](../../api2/features/v2-rest-query-contract.md).
|
|
92
|
+
- **`depth` defaults to 3 on every call, including writes.** A write helper that omits `depth` is
|
|
93
|
+
opting into a deep read of the order graph it did not ask for. Send `depth: 1` unless you
|
|
94
|
+
genuinely consume the children.
|
|
95
|
+
- **`Promise.all` over two order-sync routines is the anti-pattern here**, not a performance win.
|
|
96
|
+
The two routines touch the same rows; the parallelism buys milliseconds and costs a 500 at
|
|
97
|
+
checkout.
|
|
98
|
+
- **A self-overwriting write is invisible in review but not on the wire.** The failing `PUT` looked
|
|
99
|
+
like normal maintenance of the email record; it wrote the value it had just read. Check whether a
|
|
100
|
+
sync call actually changes anything before assuming it must stay.
|
|
101
|
+
- **The failure is a plain `EO-1` to the user** — the real exception only exists in `Logs.Issue`.
|
|
102
|
+
Resolve `error.id` (`<Issue.reference>-<Event.eventNumber>`) rather than reading the response.
|
|
103
|
+
|
|
104
|
+
## Change history
|
|
105
|
+
|
|
106
|
+
- 2026-08-11 — Created from the production investigation of the checkout 500s: `useSubmitOrder`'s
|
|
107
|
+
`Promise.all([syncSalesOrderData(), syncSalesOrderLocalStorage()])` raced `updateSalesOrderEmails()`'s
|
|
108
|
+
default-depth-3 `PUT /v2/sales-order-email-addresses/{uuid}` (which walks
|
|
109
|
+
`salesOrder → salesOrderItems`) against a concurrent `DELETE /v2/sales-order-items/{uuid}` burst,
|
|
110
|
+
producing *"Exactly 1 row was expected to be returned but 0 were"* → uncaught → 500. Confirmed by
|
|
111
|
+
the prod log timeline (deletes 14:53:36–38 around the failing PUT at 14:53:37) and by all six
|
|
112
|
+
referenced item ids being absent from the database. Fix on branch TRUE-80551 — drop the PUT (it was
|
|
113
|
+
a no-op self-overwrite), send `depth: 1` on the remaining POST, and serialize the item sync before
|
|
114
|
+
the header sync — **merged to `_beta` only; `Logs.Issue` `3T` is still OPEN in production**.
|
|
115
|
+
Recorded the residual `getFullModelData()` fragility that the fix does not remove. (bala)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
18
18
|
|
|
19
19
|
## 2.0 framework
|
|
20
20
|
|
|
21
|
-
- **_underscore** (_Underscore) _(framework core)_ —
|
|
21
|
+
- **_underscore** (_Underscore) _(framework core)_ — 51 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
22
22
|
- **worker2** (Worker) — 46 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
23
23
|
- **api2** (API) — 22 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
24
24
|
- **dbchanges2** (Database Changes) _(framework core)_ — 8 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
@@ -29,7 +29,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
29
29
|
- **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
|
|
30
30
|
- **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
|
|
31
31
|
- **ai-bdr** (AI-BDR) — 9 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
|
|
32
|
-
- **toga2-commerce** (TOGa Commerce) —
|
|
32
|
+
- **toga2-commerce** (TOGa Commerce) — 13 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
|
|
33
33
|
- **toga25-supply** (TOGa 2.5 Supply) — 11 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
|
|
34
34
|
- **toga-blox** (TOGa Blox) — 8 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
|
|
35
35
|
- **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
| [Compass MITS Sales-Order Transmission — Rejection Alerting (Issue/Event, not email-in-cron)](features/mits-sales-order-transmission-alerting.md) | 2.0 | When MITS **rejects** a Compass sales order transmitted by the 1.0 cron `1_transmit_compass_sales_orders_to_mits.php`, the alert is no longer an email built ins | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker2/Worker/Infrastructure/Errors.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, dbchanges2/Logs/2026-08-04a - MITS rejection business recipients.sql |
|
|
12
12
|
| [Compass MR/MA Order Auto-Approval & Status Gate](features/mr-ma-order-approval-and-status.md) | 2.0 | Compass **MR** and **MA** sales orders are system-generated from the MITS / Office Depot EDI pipeline (they do not originate as user-entered SA orders) and must | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
|
|
13
13
|
| [Persona Model & Levy-Sector Gating (worker2 PEOPLE cron)](features/persona-model-and-levy-gating.md) | 2.0 | Compass USA catalogue visibility is driven by **personas** in `Client_Compass`. | worker2/Worker/Client/Compass/PeopleFile.php |
|
|
14
|
+
| [Compass Sales-Order Line-Number Renumbering (and why it hangs off the SO hooks)](features/sales-order-line-renumbering.md) | 2.0 | Compass sales-order lines must stay numbered **1..N with no gaps** after any add, edit, or delete — downstream MITS/PO linking reads `lineNumber` as an identity | _underscore/Model/Compass/SalesOrderItem.php, _underscore/Model/Compass/SalesOrder.php |
|
|
14
15
|
| [Compass USA](profile.md) | 2.0 | Compass USA is a TOGA client running a multi-tier supply-chain commerce operation. | |
|
|
15
16
|
| [Compass Cross-Kit Bundle Corruption — Detection & Repair](workflows/cross-kit-bundle-corruption.md) | 2.0 | A frontend regression in `toga2-commerce`'s edit-order bundle submission mis-attributed bundle (kit) line items and **fees/warranties** to the **wrong kit**, pe | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts |
|
|
16
17
|
| [Compass Office Depot Duplicate PO-Line Cleanup (dual-catalog SKU)](workflows/odp-duplicate-po-line-cleanup.md) | 1.0 | The NetSuite fulfillment-sales-order importer duplicated Office Depot purchase-order lines on Compass USA because it reconciled the fulfillment SO against the e | library/app/api/toga2.php, dbchanges2/Client_Compass/2026-07-22a - CleanupOfficeDepotDuplicatePurchaseOrderItems.sql |
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Compass Sales-Order Line-Number Renumbering (and why it hangs off the SO hooks)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: compass-usa
|
|
7
|
+
type: client-feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-11
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model/Compass/SalesOrderItem.php
|
|
13
|
+
- _underscore/Model/Compass/SalesOrder.php
|
|
14
|
+
related:
|
|
15
|
+
- ../../../2.0/apps/api2/features/api-payload-interceptors.md
|
|
16
|
+
- ../workflows/order-lifecycle-and-data-integrity.md
|
|
17
|
+
- ./mits-po-to-so-item-linking.md
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Summary
|
|
21
|
+
|
|
22
|
+
Compass sales-order lines must stay numbered **1..N with no gaps** after any add, edit, or delete —
|
|
23
|
+
downstream MITS/PO linking reads `lineNumber` as an identity (see
|
|
24
|
+
[MITS PO → SO item linking](./mits-po-to-so-item-linking.md)), so a hole in the sequence is not
|
|
25
|
+
cosmetic. `renumberLineNumbers()` on `_Model_Compass_SalesOrderItem` does the renumbering; **where it
|
|
26
|
+
is hooked is the whole story**, because the obvious wiring is impossible and the second-most obvious
|
|
27
|
+
wiring caused a 270-order production outage.
|
|
28
|
+
|
|
29
|
+
## How it works
|
|
30
|
+
|
|
31
|
+
`_Model_Compass_SalesOrderItem::renumberLineNumbers($salesOrderId, excludeSalesOrderItemId: …)`
|
|
32
|
+
rewrites the order's lines to a contiguous 1..N sequence. The `excludeSalesOrderItemId` argument
|
|
33
|
+
exists because on the delete path the row **still exists** when the renumberer runs.
|
|
34
|
+
|
|
35
|
+
Live production wiring (`ApiPayloadInterceptors`, Compass):
|
|
36
|
+
|
|
37
|
+
| `Core.Records` | route | phase / method | dispatches to |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| **14** | `sales-orders` | `POST` / `POST` | `_Model_Compass_SalesOrder::postPost()` (~L407) → `renumberLineNumbers()` |
|
|
40
|
+
| **14** | `sales-orders` | `POST` / `PUT` | `_Model_Compass_SalesOrder::postPut()` (~L713) → `renumberLineNumbers()` |
|
|
41
|
+
| **15** | `sales-order-items` | `PRE` / `DELETE` | `_Model_Compass_SalesOrderItem::preDelete()` |
|
|
42
|
+
|
|
43
|
+
Add/edit renumbering rides the **parent** (`sales-orders`) hooks; delete renumbering rides the
|
|
44
|
+
**child**'s `preDelete`.
|
|
45
|
+
|
|
46
|
+
## Why delete-time renumbering CANNOT be a `postDelete`
|
|
47
|
+
|
|
48
|
+
A `postDelete` interceptor can never fire at all. api2's post-processing interceptor block runs
|
|
49
|
+
**only when `$outData` is non-null**, and a successful DELETE always sets `$outData = null` — so a
|
|
50
|
+
`(15, POST, DELETE)` row would be registered, valid-looking, and permanently dead, with no error and
|
|
51
|
+
no log line. This is a framework constraint, not a Compass one; full detail in
|
|
52
|
+
[API payload interceptors](../../../2.0/apps/api2/features/api-payload-interceptors.md).
|
|
53
|
+
|
|
54
|
+
Hence `preDelete`, and hence the `excludeSalesOrderItemId` argument: at `preDelete` time the row is
|
|
55
|
+
still in the table, so the renumberer must be told to skip it or it will number the row that is
|
|
56
|
+
about to vanish.
|
|
57
|
+
|
|
58
|
+
## ⚠ The wiring that caused the outage — do not repeat it
|
|
59
|
+
|
|
60
|
+
An earlier attempt registered **`POST`/`POST` on `sales-order-items` (record 15)**. The engine
|
|
61
|
+
derives the method name by convention (`POST`+`POST` → `postPost`) and resolves the class by
|
|
62
|
+
swapping `_Model_Client_` for the JWT client slug, giving `_Model_Compass_Usa_SalesOrderItem` — a
|
|
63
|
+
class that **never had a `postPost()`**. The dispatch is unguarded, so every matching write hit:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
Call to undefined method _Model_Compass_Usa_SalesOrderItem::postPost()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Impact: 270 failed Compass order submissions, 2026-08-05 17:16 → 2026-08-06 07:58**, each an
|
|
70
|
+
opaque `EO-1` 500 with no message for the user.
|
|
71
|
+
|
|
72
|
+
**Resolution:** the row was deleted and the renumbering relocated to the `sales-orders` hooks, which
|
|
73
|
+
fire reliably as the request's **first-resolved record**. Verified healthy afterwards — **249
|
|
74
|
+
`DELETE /v2/sales-order-items/*` calls in production since 2026-08-06, all HTTP 200.**
|
|
75
|
+
|
|
76
|
+
## Gotchas / known issues
|
|
77
|
+
|
|
78
|
+
- **⚠ Never register an interceptor row before confirming the method exists in the deployed
|
|
79
|
+
`_underscore`.** The dispatch has no `method_exists()` guard; a wrong row is a tenant-wide write
|
|
80
|
+
outage, not a no-op.
|
|
81
|
+
- **`(POST, DELETE)` rows are silently dead.** Delete-time logic belongs in `preDelete`.
|
|
82
|
+
- **`preDelete` must exclude the row being deleted** — it is still present when the hook runs.
|
|
83
|
+
- **Prefer the parent record's hook for order-wide invariants.** `sales-orders` `postPost`/`postPut`
|
|
84
|
+
fires as the request's first-resolved record and covers add/edit in one place; per-item hooks add
|
|
85
|
+
both the `$outData` constraint and the undefined-method risk for no benefit.
|
|
86
|
+
- **The method may be defined once on the shared `_Model_Compass_*` base** — `Usa` and `Canada`
|
|
87
|
+
subclasses inherit it. Do not add per-region copies.
|
|
88
|
+
- **These interceptor rows have no route and no audit trail.** They are direct SQL only; nothing
|
|
89
|
+
records who added or removed one. Snapshot Compass's row set into the ticket before changing it.
|
|
90
|
+
|
|
91
|
+
## Change history
|
|
92
|
+
|
|
93
|
+
- 2026-08-11 — Documented the as-built wiring and the reasoning behind it after a production
|
|
94
|
+
post-mortem (investigation only, no code change): add/edit renumbering hangs off
|
|
95
|
+
`_Model_Compass_SalesOrder::postPost`/`postPut` (record **14** `sales-orders`), delete-time
|
|
96
|
+
renumbering off `_Model_Compass_SalesOrderItem::preDelete` (record **15** `sales-order-items`,
|
|
97
|
+
`PRE`/`DELETE`) with `excludeSalesOrderItemId` because the row still exists at that point.
|
|
98
|
+
Recorded that a **`postDelete` interceptor can never fire** (the post block runs only when
|
|
99
|
+
`$outData` is non-null and a successful DELETE nulls it), and that the earlier `POST`/`POST` row on
|
|
100
|
+
`sales-order-items` resolved to the nonexistent `_Model_Compass_Usa_SalesOrderItem::postPost()` and
|
|
101
|
+
caused **270 failed Compass order submissions between 2026-08-05 17:16 and 2026-08-06 07:58**; the
|
|
102
|
+
row was removed and the logic relocated. Verified healthy: 249 `DELETE /v2/sales-order-items/*` in
|
|
103
|
+
production since 2026-08-06, all HTTP 200. (bala)
|
|
@@ -17,7 +17,7 @@ project: _Underscore
|
|
|
17
17
|
client: compass-usa
|
|
18
18
|
type: profile
|
|
19
19
|
status: active
|
|
20
|
-
updated: 2026-08-
|
|
20
|
+
updated: 2026-08-11
|
|
21
21
|
owners: [jcardinal, bala, tcox, apeterson, dfranks]
|
|
22
22
|
files: []
|
|
23
23
|
related:
|
|
@@ -32,6 +32,7 @@ related:
|
|
|
32
32
|
- workflows/odp-duplicate-po-line-cleanup.md
|
|
33
33
|
- ../../2.0/apps/worker2/features/compass-vip-support-importer.md
|
|
34
34
|
- ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
|
|
35
|
+
- ../../2.0/apps/toga2-commerce/features/category-tile-sort-order.md
|
|
35
36
|
- ../../2.0/apps/toga2-commerce/features/cart-bundle-submission-and-identity.md
|
|
36
37
|
- ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
|
|
37
38
|
---
|
|
@@ -61,6 +62,17 @@ separate, related client (see its own profile).
|
|
|
61
62
|
**Known data issue:** many Compass MacBooks are categorized `APPLE LAPTOP` /
|
|
62
63
|
`MAC & ACCESSORIES`, not `COMPUTERS`, so those kits do **not** qualify for expedited — a
|
|
63
64
|
catalog-data normalization matter, not a code gap.
|
|
65
|
+
- **Category tile order is data, not code** — `Client_Compass.AssortmentItems.sortOrder ASC` is the
|
|
66
|
+
only lever; see
|
|
67
|
+
[Category Tile Order](../../2.0/apps/toga2-commerce/features/category-tile-sort-order.md) for the
|
|
68
|
+
`NULL`-sorts-first trap and the procedure.
|
|
69
|
+
**⚠ Pending prod data change (2026-08-11, delivered but NOT executed):** for
|
|
70
|
+
`Assortments.id = 9` ("Docking Station", all 16 rows had `sortOrder = NULL`), pin
|
|
71
|
+
`itemId 2712` / `9X3V1UT` → `1` and `itemId 2812` / `AW5M5UT-1` → `2`, and give the remaining
|
|
72
|
+
active rows an explicit value. Handed off for a DBA / the TOGa UI to run because session DB
|
|
73
|
+
access was read-only. Also outstanding for that assortment: park the 13 inactive rows at a high
|
|
74
|
+
`sortOrder`, and remove the duplicate `AssortmentItems` row `id 2982` (duplicate of `2981`, same
|
|
75
|
+
`itemId 2712`). Confirm whether this was applied before re-diagnosing tile order for Compass.
|
|
64
76
|
- **Cross-kit bundle corruption (edit-order):** a `toga2-commerce` submit bug attributed kit
|
|
65
77
|
line items and shared fees/warranties to the wrong kit; 55 Compass orders / 298 line items are
|
|
66
78
|
corrupted in `Client_Compass.SalesOrderItems` (Canada and Quad: zero). The code fix is
|
package/package.json
CHANGED