toga-ai 1.0.556 → 1.0.557
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/INDEX.md +2 -1
- package/knowledge/2.0/apps/_underscore/features/carrier-shipping-labels.md +13 -1
- package/knowledge/2.0/apps/_underscore/features/field-storage-row-hydration.md +105 -0
- package/knowledge/2.0/apps/_underscore/features/page-meta-context-field-settings.md +50 -1
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/v2-api-error-codes.md +29 -1
- package/knowledge/2.0/apps/toga2-supply/workflows/client-host-scoping.md +27 -3
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/elite/INDEX.md +1 -1
- package/knowledge/clients/elite/features/supply2-scope.md +64 -11
- package/knowledge/clients/elite/features/supply2-tableview-config-drift.md +34 -1
- package/knowledge/clients/elite/profile.md +6 -4
- package/package.json +1 -1
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
| [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
|
|
21
21
|
| [Error Reporting — Issue/Event Capture, Fingerprinting & Aggregation](features/error-reporting-issue-event.md) | Platform-wide error reporting for TOGA 2.0, built on an **Issue / Event** aggregation model in the **shared Core Logs DB**. | _underscore/Error.php, _underscore/Database.php, _underscore/Exception/Business.php, api2/Controller/Index.php, worker2/Controller/Index.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, _underscore/Model/Core/Logs/IssueFingerprint.php, _underscore/Model/Core/Logs/IssueClickupTask.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, _underscore/Model/Core/Logs/IssueAreaOwner.php, dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql, dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql, dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql, dbchanges2/Core/2026-07-30a - Error escalation cron job.sql |
|
|
22
22
|
| [Record-Changed Event Publishing (_Event::publish to SQS)](features/event-publish-sqs.md) | `_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event pipeline. | _underscore/Event.php |
|
|
23
|
+
| [FIELD_STORAGE fields — per-row lazy hydration and the platform-wide missing-column 500](features/field-storage-row-hydration.md) | `FIELD_STORAGE` is the 2.0 field type for blob-backed columns (S3 or local folder). | _underscore/Model.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/Invoice.php, dbchanges2/Core/HISTORIC/2024/2024-11b - item-fulfillments.sql |
|
|
23
24
|
| [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
|
|
24
25
|
| [isFulfillable Propagation Up the SO↔PO Chain](features/fulfillable-item-propagation.md) | `Items.isFulfillable` is a boolean that gates whether a storefront line's **Qty Fulfilled** cell is actionable. | _underscore/Model/Client/Item.php, _underscore/Model/Compass/Item.php, dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql |
|
|
25
26
|
| [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
| [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php, _underscore/Model/Rate/Subscription.php |
|
|
29
30
|
| [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
|
|
30
31
|
| [NetSuite Sales Order sync — ship-to address, phone, and PO reference sourcing](features/netsuite-salesorder-address-phone-sync.md) | `_Trait_Netsuite_SalesOrder` is the **shared** sales-order importer composed into **22 client models** (every client on the dbchanges2 `netsuite` module). | _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Model.php, dbchanges2/_modules/netsuite/2026-08-10a - AddressPhoneNumberApiRoleAcl.sql |
|
|
31
|
-
| [Legacy page meta (Page::meta) & context-scoped ClientRecordFieldSettings](features/page-meta-context-field-settings.md) | `_Model_Core_Page::meta()` is the **legacy** page-meta resolver behind `GET /pages/meta?slug=<slug>` — still the live path for `toga2-supply` and other pre-Surf | _underscore/Model/Core/Page.php |
|
|
32
|
+
| [Legacy page meta (Page::meta) & context-scoped ClientRecordFieldSettings](features/page-meta-context-field-settings.md) | `_Model_Core_Page::meta()` is the **legacy** page-meta resolver behind `GET /pages/meta?slug=<slug>` — still the live path for `toga2-supply` and other pre-Surf | _underscore/Model/Core/Page.php, _underscore/Model/Client/TableView.php |
|
|
32
33
|
| [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/Query.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
|
|
33
34
|
| [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
|
|
34
35
|
| [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql |
|
|
@@ -7,7 +7,7 @@ client: shared
|
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
9
|
updated: 2026-08-11
|
|
10
|
-
owners: [mhammontree]
|
|
10
|
+
owners: [mhammontree, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- test/@Mark/true-80824-fedex-inflate-test.php
|
|
13
13
|
- dbchanges2/Client_Growrk/2026-08-10e - GrowrkUpsServiceMethodCodes.sql
|
|
@@ -27,6 +27,7 @@ files:
|
|
|
27
27
|
- _underscore/Cloud.php
|
|
28
28
|
related:
|
|
29
29
|
- ../architecture.md
|
|
30
|
+
- field-storage-row-hydration.md
|
|
30
31
|
- recursive-item-fulfillments.md
|
|
31
32
|
- ../../toga2-supply/features/fulfill-and-ship.md
|
|
32
33
|
- ../../api2/features/record-scripts.md
|
|
@@ -276,6 +277,12 @@ review. **The UPS client has the same latent gap.**
|
|
|
276
277
|
|
|
277
278
|
## `FIELD_STORAGE` mechanics (reference)
|
|
278
279
|
|
|
280
|
+
> **⚠ `TrackingNumbers.labelPdfFile` has NO physical column in any `Client_*` schema in any
|
|
281
|
+
> environment** (verified 2026-08-11) — the 2024 change registered the Core `RecordField` but never
|
|
282
|
+
> wrote an `ALTER TABLE`. Because storage fields hydrate **lazily per returned row**, `GET
|
|
283
|
+
> /tracking-numbers` 500s (`EO-1` / MySQL 1054) the moment a query matches rows. Full write-up +
|
|
284
|
+
> fix: [FIELD_STORAGE row hydration](field-storage-row-hydration.md).
|
|
285
|
+
|
|
279
286
|
`Model.php` handles storage fields separately from regular columns (write loop ~line
|
|
280
287
|
393; read ~line 730). Config keys in `[_underscore]`: `model_storage_folder` → local
|
|
281
288
|
folder mode (if set, S3 is never used); `model_storage_s3_bucket` +
|
|
@@ -416,6 +423,11 @@ still discards the unsaved in-memory value.
|
|
|
416
423
|
bill) — expect a hard error rather than a silently-wrong bill in those cases.
|
|
417
424
|
|
|
418
425
|
## Change history
|
|
426
|
+
- 2026-08-11 — Flagged that `TrackingNumbers.labelPdfFile` has **no physical column in any
|
|
427
|
+
`Client_*` schema in any environment** (only the Core `RecordFields` metadata was ever inserted),
|
|
428
|
+
so `GET /tracking-numbers` 500s once a query returns rows — storage fields hydrate lazily per row,
|
|
429
|
+
which is why it stayed dormant. Details in the new
|
|
430
|
+
[FIELD_STORAGE row hydration](field-storage-row-hydration.md) doc. (tcox)
|
|
419
431
|
- 2026-08-11 — TRUE-80824: **made FedEx carrier rejection reasons readable.** FedEx returned a
|
|
420
432
|
**gzip-encoded HTTP 400** and `_ApiRequest` sets no `CURLOPT_ENCODING`, so `json_decode` saw
|
|
421
433
|
compressed bytes and `describeShipmentError()` printed `\x1f\x8b…`; with `setLogging(false)` on the
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "FIELD_STORAGE fields — per-row lazy hydration and the platform-wide missing-column 500"
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-11
|
|
10
|
+
owners: [tcox]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model.php
|
|
13
|
+
- _underscore/Model/Client/TrackingNumber.php
|
|
14
|
+
- _underscore/Model/Client/Invoice.php
|
|
15
|
+
- dbchanges2/Core/HISTORIC/2024/2024-11b - item-fulfillments.sql
|
|
16
|
+
related:
|
|
17
|
+
- carrier-shipping-labels.md
|
|
18
|
+
- ../../api2/features/v2-api-error-codes.md
|
|
19
|
+
- ../../dbchanges2/workflows/client-schema-drift-audit.md
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Summary
|
|
23
|
+
|
|
24
|
+
`FIELD_STORAGE` is the 2.0 field type for blob-backed columns (S3 or local folder). Unlike every
|
|
25
|
+
other declared field it is **excluded from the model's main SELECT** (`Model.php` ~L664) and
|
|
26
|
+
**hydrated lazily, per returned row**, when the V2 engine walks the result set. That single
|
|
27
|
+
difference changes the failure mode of schema drift completely:
|
|
28
|
+
|
|
29
|
+
> **A `FIELD_STORAGE` field whose column does not exist cannot fail on an empty result set.**
|
|
30
|
+
> The request only 500s once a query actually **returns rows** — so the defect sits dormant for
|
|
31
|
+
> months and then appears the day a client's data starts matching.
|
|
32
|
+
|
|
33
|
+
**Live platform defect (found 2026-08-11, unfixed):** `TrackingNumbers.labelPdfFile` and
|
|
34
|
+
`Invoices.invoicePdfFile` are declared `FIELD_STORAGE` on the models but the columns exist in
|
|
35
|
+
**no `Client_*` schema in any environment** — verified against `information_schema` on both
|
|
36
|
+
dev-sandbox and production. `GET /tracking-numbers` and `GET /invoices` 500 for **every client**
|
|
37
|
+
as soon as a row matches.
|
|
38
|
+
|
|
39
|
+
## How storage fields differ from normal fields
|
|
40
|
+
|
|
41
|
+
| | Normal / `c_` field | `FIELD_STORAGE` field |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| In the model's SELECT | **Yes** — `_Model` builds the SELECT from every declared property, ignoring the caller's `fields=` | **No** — skipped at `Model.php` ~L664 |
|
|
44
|
+
| When the value is read | With the row | **Lazily, per row**, at depth 1 of hydration (`Model.php` ~L730) |
|
|
45
|
+
| Missing column, 0 rows returned | **500** (`EO-1` / MySQL 1054) — the SELECT itself is invalid | **200** — nothing is hydrated, nothing fails |
|
|
46
|
+
| Missing column, ≥1 row returned | 500 | **500** (`EO-1` / MySQL 1054 "Unknown column") |
|
|
47
|
+
|
|
48
|
+
The consequence for diagnosis: the usual
|
|
49
|
+
[client schema-drift audit](../../dbchanges2/workflows/client-schema-drift-audit.md) reasoning
|
|
50
|
+
("a missing column fails **any** load of the record, on **any** route") is **not** true for storage
|
|
51
|
+
fields. An endpoint that returns 200 on an empty/filtered query is **not** evidence its schema is
|
|
52
|
+
sound. Re-drive the request with a filter you know matches rows before clearing it.
|
|
53
|
+
|
|
54
|
+
Storage path, config keys and the two 2026-07 `Model.php` write/read bugs are documented in
|
|
55
|
+
[carrier shipping labels → `FIELD_STORAGE` mechanics](carrier-shipping-labels.md).
|
|
56
|
+
|
|
57
|
+
## The current defect — `labelPdfFile` / `invoicePdfFile` were never migrated
|
|
58
|
+
|
|
59
|
+
- `_Model_Client_TrackingNumber::$labelPdfFile = self::FIELD_STORAGE` was added on **2024-10-16**
|
|
60
|
+
(`ffe72b61`, "Adding the fedex shipment api call").
|
|
61
|
+
- `_Model_Client_Invoice::$invoicePdfFile = self::FIELD_STORAGE` is declared the same way.
|
|
62
|
+
- `dbchanges2/Core/HISTORIC/2024/2024-11b - item-fulfillments.sql` inserted only the **Core
|
|
63
|
+
`RecordFields` metadata** for them. **No `ALTER TABLE` was ever written**, so the physical
|
|
64
|
+
columns were never created in any tenant schema.
|
|
65
|
+
- Confirmed absent from **every** `Client_*` schema on **both** dev-sandbox and production.
|
|
66
|
+
|
|
67
|
+
Dormant for ~10 months because the frontend fulfillment → tracking path was broken in other ways
|
|
68
|
+
and never returned tracking rows. **Elite was the first client to return rows and hit it.**
|
|
69
|
+
|
|
70
|
+
**Backend fix (correct):** a `dbchanges2` migration adding both columns to all `Client_*` schemas —
|
|
71
|
+
or, if the feature is dead, remove the properties from the models. Do not "fix" it per client; the
|
|
72
|
+
declaration is in the shared base model, so every tenant is affected identically.
|
|
73
|
+
|
|
74
|
+
**Sandbox demo unblock only** (hand-applied, not a substitute for the migration):
|
|
75
|
+
|
|
76
|
+
```sql
|
|
77
|
+
ALTER TABLE TrackingNumbers ADD COLUMN labelPdfFile LONGBLOB NULL;
|
|
78
|
+
ALTER TABLE Invoices ADD COLUMN invoicePdfFile LONGBLOB NULL;
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Gotchas / known issues
|
|
82
|
+
|
|
83
|
+
- **"It returns 200" proves nothing about a storage field.** Empty result sets never hydrate.
|
|
84
|
+
Reproduce with rows.
|
|
85
|
+
- **The 500 is per-row, so it looks data-dependent.** The same route works for one filter and
|
|
86
|
+
fails for another — which reads like a bad row rather than a missing column.
|
|
87
|
+
- **Metadata without DDL is a real migration state.** A `Core.RecordFields` row can exist for a
|
|
88
|
+
column that was never created; the field registration is what makes the API *offer* it, not what
|
|
89
|
+
makes it *exist*. When auditing drift, check `information_schema` for storage fields too — the
|
|
90
|
+
model-vs-schema diff described in the drift audit will otherwise not flag them as urgent because
|
|
91
|
+
nothing is failing yet.
|
|
92
|
+
- **Blast radius is the platform, not a tenant.** Unlike the usual one-client drift, a field
|
|
93
|
+
declared on a shared `_Model_Client_*` class with no migration anywhere means **all** clients are
|
|
94
|
+
broken at once.
|
|
95
|
+
|
|
96
|
+
## Change history
|
|
97
|
+
- 2026-08-11 — First capture: documented that `FIELD_STORAGE` fields are excluded from the model's
|
|
98
|
+
main SELECT and hydrated **lazily per returned row**, so a missing storage column 500s only when
|
|
99
|
+
a query actually returns rows (empty sets stay 200). Recorded the live platform defect —
|
|
100
|
+
`TrackingNumbers.labelPdfFile` (declared 2024-10-16, `ffe72b61`) and `Invoices.invoicePdfFile`
|
|
101
|
+
exist in **no `Client_*` schema in any environment**, because the 2024-11b item-fulfillments
|
|
102
|
+
change inserted only the Core `RecordFields` metadata and no `ALTER TABLE`; `GET /tracking-numbers`
|
|
103
|
+
and `GET /invoices` 500 for every client once rows match. Found via Elite, the first client to
|
|
104
|
+
return tracking rows. Fix = a dbchanges2 migration across all `Client_*` schemas (or drop the
|
|
105
|
+
model fields). (tcox)
|
|
@@ -6,12 +6,14 @@ 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: [tcox]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Core/Page.php
|
|
13
|
+
- _underscore/Model/Client/TableView.php
|
|
13
14
|
related:
|
|
14
15
|
- surface-resolver.md
|
|
16
|
+
- ../../api2/features/tableview-field-metadata.md
|
|
15
17
|
- error-reporting-issue-event.md
|
|
16
18
|
- ../../../clients/elite/features/supply2-tableview-config-drift.md
|
|
17
19
|
---
|
|
@@ -33,6 +35,42 @@ bug that takes down *unrelated* pages for any client that uses the feature.
|
|
|
33
35
|
Example (Elite): row id 1 relabels field **58** to **"Total"** in the context of field **190**
|
|
34
36
|
(`ServiceRequests.id`) — authored for the `service-requests` view.
|
|
35
37
|
|
|
38
|
+
## Why this matters: table-view **column headers** for joined columns need context rows
|
|
39
|
+
|
|
40
|
+
This is the mechanism behind "why is my column header a camelCase slug?" — and the answer is
|
|
41
|
+
counter-intuitive enough that it has cost more than one session.
|
|
42
|
+
|
|
43
|
+
A table view's column header is resolved by the frontend (`PrimaryTable` in `toga2-supply`) out of
|
|
44
|
+
the **page meta**, by two *different* paths depending on where the column's field comes from:
|
|
45
|
+
|
|
46
|
+
| Column's field | Lookup path in `pageMeta.settings.fields` |
|
|
47
|
+
|---|---|
|
|
48
|
+
| on the view's **base record** | `[record][field][label-singular]` — a flat, context-free row |
|
|
49
|
+
| reached through a **join** | `[record][field][contextRecord][contextField][label-singular]` |
|
|
50
|
+
|
|
51
|
+
So for a **joined** column, a `ClientRecordFieldSettings` row with a null `contextRecordFieldId`
|
|
52
|
+
lands in the flat path the frontend never reads for that column, and the label silently does
|
|
53
|
+
nothing. The row must carry **`contextRecordFieldId` = the table-view join's
|
|
54
|
+
`parentRecordFieldId`** (`Model/Client/TableView.php` sets `settings.contextTable` /
|
|
55
|
+
`settings.contextField` for every joined column from exactly that value).
|
|
56
|
+
|
|
57
|
+
**When nothing resolves, the header falls back to the raw `TableViewFields.slug`** — which is why
|
|
58
|
+
unlabeled joined columns render as camelCase identifiers rather than as blanks.
|
|
59
|
+
|
|
60
|
+
### The `Core.PageRecords` gate
|
|
61
|
+
|
|
62
|
+
`Page.php` assembles `settings.fields` from an **INNER JOIN** on `Core.RecordFields` (~L652) that is
|
|
63
|
+
gated by **`Core.PageRecords`**: only fields of records *attached to the page* are emitted at all.
|
|
64
|
+
A perfectly correct settings row therefore does nothing until **both** the labelled field's record
|
|
65
|
+
**and** the context record are attached to the page. Sequencing for any such change:
|
|
66
|
+
|
|
67
|
+
1. **The `isset()` guard in `Page.php` (below) goes first.** Until it ships, adding context rows is
|
|
68
|
+
actively dangerous — a context row fatals **every** page that contains the labelled field's
|
|
69
|
+
record but not the context record.
|
|
70
|
+
2. Then the `Core.PageRecords` attachments.
|
|
71
|
+
3. Then the `ClientRecordFieldSettings` rows, as a `dbchanges2` migration (hand-inserts on beta do
|
|
72
|
+
not survive a tenant refresh).
|
|
73
|
+
|
|
36
74
|
## The bug — "Undefined array key \<contextRecordFieldId\>" 500
|
|
37
75
|
|
|
38
76
|
> **Status: reported to the backend, unfixed as of 2026-08-10.**
|
|
@@ -68,6 +106,17 @@ data change, is what makes it fatal.
|
|
|
68
106
|
customization. The guard belongs in the framework.
|
|
69
107
|
|
|
70
108
|
## Change history
|
|
109
|
+
- 2026-08-11 — Documented what context-scoped settings are actually **for**: table-view **column
|
|
110
|
+
header labels**. A base-record column resolves as `settings.fields[record][field][label-singular]`
|
|
111
|
+
but a **joined** column resolves the context-nested
|
|
112
|
+
`settings.fields[record][field][contextRecord][contextField][label-singular]`, so a label row for a
|
|
113
|
+
joined column must carry `contextRecordFieldId` = the join's `parentRecordFieldId`
|
|
114
|
+
(`Model/Client/TableView.php` emits `settings.contextTable`/`contextField` from it) — a
|
|
115
|
+
context-free row lands in a path the frontend never reads. Unresolved headers fall back to the raw
|
|
116
|
+
`TableViewFields.slug` (the camelCase headers). Also recorded the **`Core.PageRecords` gate**
|
|
117
|
+
(`Page.php` ~L652 INNER JOINs `Core.RecordFields` only for records attached to the page) and the
|
|
118
|
+
mandatory ordering: `isset()` guard → PageRecords attachments → settings rows as a migration.
|
|
119
|
+
(tcox)
|
|
71
120
|
- 2026-08-10 — First capture: documented context-scoped `ClientRecordFieldSettings` and the
|
|
72
121
|
`Page.php:824` *"Undefined array key \<contextRecordFieldId\>"* fatal (context field not on the
|
|
73
122
|
requested page), which the 2026-08-07 strict Sentry handler turned from a warning into a 500 on
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
| [TableView row-filtering via apiWhereClause (options.where grammar, end to end)](features/tableview-apiwhereclause-row-filtering.md) | `TableViews.apiWhereClause` (TEXT, nullable) is the sanctioned, code-free way to restrict or exclude rows from a 2.0 table view. | api2/Component/Api/V2/V2.php, _underscore/Model/Client/TableView.php, toga2-supply/src/api/toga.ts |
|
|
20
20
|
| [TableView field/column metadata (TableViewFields, hidden projected columns)](features/tableview-field-metadata.md) | The columns of a 2.0 table view are defined by DB metadata, not code. | _underscore/Model/Client/TableView.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-07-20 - ItemsUuidForPurchaseOrderItemsTableView.sql |
|
|
21
21
|
| [Tickets API (/v2/tickets)](features/tickets-api.md) | The generic ticket endpoint of the 2.0 REST API. | Component/Api/V2/V2.php |
|
|
22
|
-
| [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, api2/Component/Api/V2/Response/Oauth/Oauth.php, api2/Controller/Index.php, toga2-supply/src/globalTypes.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, _underscore/Model/Client/TrackingNumber.php |
|
|
22
|
+
| [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, api2/Component/Api/V2/Response/Oauth/Oauth.php, api2/Controller/Index.php, toga2-supply/src/globalTypes.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/api/toga.ts, _underscore/Model/Client/TrackingNumber.php |
|
|
23
23
|
| [V2 REST query contract (params, where grammar, encoding, ACL behavior)](features/v2-rest-query-contract.md) | What an **HTTP client** has to get right to query the Toga v2 REST API: which query params are recognized, the exact `where` grammar, how the query string is (n | api2/Component/Api/V2/V2.php |
|
|
24
24
|
| [V2 reverse hasMany collections must be named in the fetch fields whitelist](features/v2-reverse-hasmany-fields-whitelist.md) | In the V2 JSON engine, a **reverse hasMany** relationship — the collection of child records that foreign-key back to a parent (e.g. | api2/Component/Api/V2/V2.php |
|
|
25
25
|
| [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php, api2/.platform/hooks/prebuild/git.sh, api2/.ebextensions/git.php, api2/.ebextensions/git.sandbox-dev.json |
|
|
@@ -6,7 +6,7 @@ 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, tcox, jcardinal, ajean, bala]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -15,8 +15,10 @@ files:
|
|
|
15
15
|
- api2/Controller/Index.php
|
|
16
16
|
- toga2-supply/src/globalTypes.ts
|
|
17
17
|
- toga2-supply/src/pages/Orders/api/OrdersApi.ts
|
|
18
|
+
- toga2-supply/src/api/toga.ts
|
|
18
19
|
- _underscore/Model/Client/TrackingNumber.php
|
|
19
20
|
related:
|
|
21
|
+
- ../../_underscore/features/field-storage-row-hydration.md
|
|
20
22
|
- ../../_underscore/features/error-reporting-issue-event.md
|
|
21
23
|
- ../../_underscore/features/acl-permission-chain.md
|
|
22
24
|
- ../../dbchanges2/workflows/client-schema-drift-audit.md
|
|
@@ -46,6 +48,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
46
48
|
| **EO-1** | Operation failed — identifier "There is no field called 'X' in the '_Model_Client_Y' model" | The DB column **and** `Core.RecordFields` exist, but the **generated model class** `_underscore/Model/Client/<Name>.php` doesn't declare the field. This is the **4th** requirement beyond the 3-file migration — and most often it's a **cross-repo git branch mismatch** (`_underscore` on a branch whose generated model lacks a field the DB/RecordFields already carry) | Declare the field in the generated model class (`public $field = self::FIELD_*`) and put all related repos (`_underscore`, `api2`, `dbchanges2`, `toga2-supply`) on the **same** feature branch — see the ACL doc's writable-field recipe |
|
|
47
49
|
| **EO-1** | Operation failed — **surfaced from a PHP warning/notice, not a real op error** (e.g. "Attempt to read property 'id' on bool", undefined variable) | A latent PHP warning escalates to a 500 because **Sentry's `ErrorHandler` in api2 promotes warnings/notices into thrown exceptions** (see diagnosis note 5). The known instance: in `V2.php::processRoutePairs()` an **unresolved route** leaves the local `$record = false`, and the post-processing payload interceptor layer then dereferenced `$record->id` → warning → 500 | Guard before dereferencing an unresolved record. The fix added a guard clause `if (!$record) return [$rawRequestedRouteName => $outData];` **before** the interceptor/logging layer (so a bad route returns a clean envelope, not a fatal), and initializes `$record = false;` at the top of the `foreach ($lookupByRouteNames ...)` loop so the invalid-HTTP-method / null-`$action` branch can't leave `$record` undefined (an undefined-variable warning would itself escalate to a 500) |
|
|
48
50
|
| **EO-1** | Operation failed — MySQL **1054 "Unknown column 'X' in 'field list'"** | The **exact reverse** of the row above: the generated model (or a **trait** it `use`s) *declares* the field but the client table has **no such column**. `_Model` builds its SELECT from **every declared property**, so this fires on **any** load of that record on **any** route — including when the caller never asked for that field. Usual cause: a client DB whose module migration only **partially** applied | Add the missing column to that client DB — plus its `CustomRecordFields` row and `AclCustomFieldPermissions` grant if it is a `c_` field. Audit the rest of the drift first: [client schema-drift audit](../../dbchanges2/workflows/client-schema-drift-audit.md) |
|
|
51
|
+
| **EO-2** | *"invalid configuration / A required database could not be reached while initializing the request"* — **almost always a lie** | The `catch (Throwable $dbBootstrapError)` in `api2/Controller/Index.php` (~L364) wraps the **entire request dispatch**, not just the DB bootstrap, so **any** Throwable that escapes query processing is relabelled EO-2. Real causes seen: MySQL 1054 on a missing column, a promoted PHP warning, a model fatal | **Ignore the message text.** Get the real exception from Sentry (`captureException`) or, on newer builds, from `identifiers.message` in the envelope; then resolve `error.id` per diagnosis note 9 |
|
|
49
52
|
| **EZ-1** | Unauthorized record/script dispatch | Missing **`AclRecordScripts`** (scripted APIs) or the **`AclRecordPermissions`** four-table chain (records) for the caller's role | Grant `AclRecordScripts` (scripts) or complete the record-CRUD chain |
|
|
50
53
|
| **EZ-2** | Field-level authorization denied (READ) | The field is registered (`Core.RecordFields` present → no `EV-8`) but the caller's role has no **`AclFieldPermissions`** grant to **read** it. The read-side counterpart of `EV-9` (write). **This applies to the `id` field too:** fetching a record by its numeric `id` (`GET /v2/<record>?fields=id,...`) 403s `EZ-2` if `id` has no read grant — the `id` field is ACL-gated like any other, not implicitly readable | Add the `AclFieldPermissions` row for the role (`isWritable=0` if the field is server-written). For a **custom** `c_` field use `AclCustomFieldPermissions` instead — see the ACL doc's standard-vs-custom table |
|
|
51
54
|
| **EV-5** | Duplicate `transactionId` | The globally-unique `transactionId` was reused | Send a fresh unique `transactionId` per request |
|
|
@@ -158,6 +161,21 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
158
161
|
**missing DB column**. And several near-identical 500s sharing **one `transactionId`** are
|
|
159
162
|
apiproxy's cross-region retry, not repeated user impact.
|
|
160
163
|
|
|
164
|
+
13. **`EO-2` is a catch-all mislabel — and an ABSENT log row is itself a signal.** Three facts that
|
|
165
|
+
together cost a full session of misdirected DB-connectivity debugging:
|
|
166
|
+
- The controller's bootstrap `catch (Throwable …)` spans the whole dispatch, so a query-time
|
|
167
|
+
fatal is reported as *"A required database could not be reached."* **The database was fine.**
|
|
168
|
+
Treat EO-2 as "an unclassified Throwable escaped", nothing more.
|
|
169
|
+
- **5xx responses are NOT written to `Logs_<Client>.Api`** — response logging is skipped on a
|
|
170
|
+
fatal. So a request you watched 500 in DevTools with **no matching `Logs_<Client>.Api` row**
|
|
171
|
+
is *confirmation* of a server-side fatal, not a sign you queried the wrong client/timestamp.
|
|
172
|
+
(Pair with note 12: `Logs.Api.transactionId` stores a timestamp, so you cannot look the
|
|
173
|
+
request up by the envelope's `transactionId` either.)
|
|
174
|
+
- **The frontend swallows it.** `togaApiRequest` (`toga2-supply/src/api/toga.ts`) returns
|
|
175
|
+
**`null`** for any response `>= 500` (axios `validateStatus` under 500 throws), so callers see
|
|
176
|
+
a null payload, not an error — every consumer must null-guard, and "the page rendered empty"
|
|
177
|
+
is a plausible symptom of a 500 you never saw.
|
|
178
|
+
|
|
161
179
|
## BREAKING (2026-07-30): `error` is now an object, not a bare string
|
|
162
180
|
|
|
163
181
|
The V2 envelope's `error` changed from a bare string (`"EO-1"`) to an **object**:
|
|
@@ -243,6 +261,16 @@ Full mechanics:
|
|
|
243
261
|
|
|
244
262
|
## Change history
|
|
245
263
|
|
|
264
|
+
- 2026-08-11 — Added **`EO-2`** to the map as a **catch-all mislabel**: `Controller/Index.php`'s
|
|
265
|
+
`catch (Throwable $dbBootstrapError)` (~L364) wraps the entire dispatch, so any escaped Throwable
|
|
266
|
+
is reported as *"A required database could not be reached while initializing the request"* — the
|
|
267
|
+
text is usually wrong (the observed case was MySQL 1054 on a `FIELD_STORAGE` column that exists in
|
|
268
|
+
no client schema). Added diagnosis note 13: the real exception is in Sentry / `identifiers.message`;
|
|
269
|
+
**5xx responses are never written to `Logs_<Client>.Api`**, so a missing log row for a request that
|
|
270
|
+
visibly 500'd confirms a server fatal; and `togaApiRequest` in toga2-supply returns **`null`** on
|
|
271
|
+
any `>= 500`, so the frontend shows an empty page rather than an error. Linked the new
|
|
272
|
+
[FIELD_STORAGE row hydration](../../_underscore/features/field-storage-row-hydration.md) doc, whose
|
|
273
|
+
lazy per-row hydration is why that 1054 only fires when a query returns rows. (tcox)
|
|
246
274
|
- 2026-08-10 — Added a **third `EO-1` case — MySQL 1054 "Unknown column"**: the reverse of the
|
|
247
275
|
known model-declaration gap. When the model (or a **trait** it `use`s) declares a field the client
|
|
248
276
|
table lacks, `_Model` still SELECTs it — it builds its SELECT from **every declared property**,
|
|
@@ -6,7 +6,7 @@ project: TOGa Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-11
|
|
10
10
|
owners: [apeterson, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- toga2-supply/src/stores/useHostNameStore.ts
|
|
@@ -132,7 +132,14 @@ before wiring the modal. The fix pattern:
|
|
|
132
132
|
the host**, so other hosts never issue the extra request;
|
|
133
133
|
- expect **columns the other record does not have** — see the `isQuote` gotcha below;
|
|
134
134
|
- expect the join to be **optional**: an unmatched row resolves to `null` and the modal opens empty.
|
|
135
|
-
Decide the empty-state treatment with product; do not silently ship a blank modal.
|
|
135
|
+
Decide the empty-state treatment with product; do not silently ship a blank modal. Elite settled
|
|
136
|
+
on an explicit empty state driven off the resolver query's `isFetched`/`isError` → `null`
|
|
137
|
+
details ("No order details are available for this service request") — copy that shape rather than
|
|
138
|
+
leaving a blank panel.
|
|
139
|
+
- **expect the join to be 1:N, and sort.** Elite's ServiceRequests → SalesOrders was assumed 1:1 and
|
|
140
|
+
is not (one SR had two orders), so the resolver returned an arbitrary row and the modal showed a
|
|
141
|
+
different order run to run. **Fetch an ordering key alongside the uuid** (Elite: `number`) and pick
|
|
142
|
+
deterministically. Verify the cardinality in the data — do not take it from the view definition.
|
|
136
143
|
|
|
137
144
|
## Getting a login for a new host (auth + test user)
|
|
138
145
|
|
|
@@ -197,7 +204,10 @@ actually talks to.
|
|
|
197
204
|
- **Selection is uuid-driven and non-null-asserted.** Selection resolves from the URL param
|
|
198
205
|
`<tableSlug>:group=<uuid>` with fallback `groupOptions.find(o=>o.isDefault) || groupOptions[0]`;
|
|
199
206
|
`InventoryPage.tsx` non-null-asserts the uuid find and **throws** if the uuid is missing for a
|
|
200
|
-
host. Every host must have a valid default. **
|
|
207
|
+
host. Every host must have a valid default. (**Fixed 2026-08-11 in `InventoryPage.tsx`**: a URL
|
|
208
|
+
uuid absent from the resolved host's options now falls back to `defaultOption` instead of
|
|
209
|
+
crashing — a shared link from another host no longer white-screens the page.) **There is a third
|
|
210
|
+
`find(...)!` consumer**:
|
|
201
211
|
`InventorySubTablePage.tsx` (~L139) does the same non-null assert, so a host whose array is
|
|
202
212
|
missing the selected uuid crashes on the **nested** table too, not just the primary one.
|
|
203
213
|
- **Shared localStorage is order-dependent and persists across host switches** in one browser
|
|
@@ -221,12 +231,26 @@ actually talks to.
|
|
|
221
231
|
(callers already fall back to the field name). Reusing another page's `listingSlug` is therefore
|
|
222
232
|
survivable, but remember it only affects **ACL and search labels** — column headers come from the
|
|
223
233
|
**table-view** meta, not the page meta.
|
|
234
|
+
- **Fixing a SHARED component for one host: make the condition provably a no-op for the others.**
|
|
235
|
+
The bar is *byte-identical rendering* for every host that does not meet the new condition, and you
|
|
236
|
+
should be able to state why. Worked examples (2026-08-11): `Toaster.tsx` centers a **message-only**
|
|
237
|
+
toaster (`flex-1` + `text-center`) — every other call site passes an action, so the branch is never
|
|
238
|
+
taken; `OrdersPage.tsx` re-syncs `pageTitle`/`tableDisplayData` **after the host store hydrates**,
|
|
239
|
+
which only changes first-render behavior where `fields` was `undefined`. A host conditional is the
|
|
240
|
+
escape hatch; a *condition on the data the component already has* is better and safer.
|
|
224
241
|
- **A record swap costs you columns.** Elite's `service-requests` view is built on ServiceRequests,
|
|
225
242
|
which has **no `isQuote` column**, so Orders' unconditional `isQuote: false` filter 500'd with
|
|
226
243
|
`1054 Unknown column`. Filters that assume the SalesOrders shape must be made conditional on a
|
|
227
244
|
FIELDS flag (`hasQuoteFilter`), not assumed. Backend confirmed this is an FE-side fix.
|
|
228
245
|
|
|
229
246
|
## Change history
|
|
247
|
+
- 2026-08-11 — Added the rule for **fixing shared components on one host's behalf** (the condition
|
|
248
|
+
must be a provable no-op for every other host — Toaster message-only centering, OrdersPage state
|
|
249
|
+
re-sync after host-store hydration), recorded that a cross-record resolver join can be **1:N** and
|
|
250
|
+
must sort deterministically on a fetched key (Elite's ServiceRequests → SalesOrders was assumed
|
|
251
|
+
1:1 and is not), noted the empty-state shape for an unmatched resolver row, and that
|
|
252
|
+
`InventoryPage.tsx` now falls back to `defaultOption` for a URL group uuid the host does not have
|
|
253
|
+
instead of crashing on `find(...)!`. (tcox)
|
|
230
254
|
- 2026-08-10 — Added **reference implementation 2 — retitling a shared page** (per-host
|
|
231
255
|
`FIELDS/<HOST>/BASE.json` supplying `pageTitle`/`tableSlug`/`listingSlug`, explicit host entry in
|
|
232
256
|
the view model, slug literals threaded through `fields?.` with literal fallbacks, feature removal
|
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)_ — 53 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)
|
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
| Doc | Framework | Summary | Files |
|
|
4
4
|
|-----|-----------|---------|-------|
|
|
5
5
|
| [Elite SalesOrder → NetSuite Push (postPost/postPut interceptors → worker2)](features/salesorder-netsuite-push.md) | 2.0 | Elite orders created in Toga are pushed into NetSuite **event-driven**, not on a cron. | _underscore/Model/Elite/SalesOrder.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Client_Elite/2026-08-10 - SalesOrderNetsuiteInterceptors.sql |
|
|
6
|
-
| [Elite — supply2 frontend scope (Inventory + Service Requests, both built)](features/supply2-scope.md) | 2.0 | Scope for onboarding Elite to the `toga2-supply` frontend (host `ELITE`). | toga2-supply/ELITE-CLIENT-TASK-NOTES.md, toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts, toga2-supply/src/pages/Inventory/viewModel/FIELDS/INVENTORYPAGEFIELDS.ts, toga2-supply/src/pages/Inventory/viewModel/index.ts, toga2-supply/src/pages/Inventory/listing/InventoryPage.tsx, toga2-supply/src/pages/Inventory/listing/InventoryRouter.tsx, toga2-supply/src/pages/Inventory/listing/InventorySubTablePage.tsx, toga2-supply/src/utils/resolveClientHostName.ts, toga2-supply/src/utils/convertConstructorColumnTitles.ts, toga2-supply/src/pages/Orders/OrdersPage.tsx, toga2-supply/src/pages/Orders/viewModel/FIELDS/ELITE/BASE.json, toga2-supply/src/pages/Orders/viewModel/useOrdersPageViewModel.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/pages/Orders/view/OrderView/viewModel/useOrderDetailsViewModel.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/renderModalContent.tsx, toga2-supply/src/components/layout/SlideMenu/SlideMenu.tsx, toga2-supply/package.json |
|
|
6
|
+
| [Elite — supply2 frontend scope (Inventory + Service Requests, both built)](features/supply2-scope.md) | 2.0 | Scope for onboarding Elite to the `toga2-supply` frontend (host `ELITE`). | toga2-supply/ELITE-CLIENT-TASK-NOTES.md, toga2-supply/src/pages/Orders/view/OrderView/viewModel/FIELDS/ELITE/BASEFIELDS.json, toga2-supply/src/pages/Orders/viewModel/FIELDS/ELITE/BASE.json, toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts, toga2-supply/src/hooks/useFetchData.tsx, toga2-supply/src/components/ui/Toaster.tsx, toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts, toga2-supply/src/pages/Inventory/viewModel/FIELDS/INVENTORYPAGEFIELDS.ts, toga2-supply/src/pages/Inventory/viewModel/index.ts, toga2-supply/src/pages/Inventory/listing/InventoryPage.tsx, toga2-supply/src/pages/Inventory/listing/InventoryRouter.tsx, toga2-supply/src/pages/Inventory/listing/InventorySubTablePage.tsx, toga2-supply/src/utils/resolveClientHostName.ts, toga2-supply/src/utils/convertConstructorColumnTitles.ts, toga2-supply/src/pages/Orders/OrdersPage.tsx, toga2-supply/src/pages/Orders/viewModel/FIELDS/ELITE/BASE.json, toga2-supply/src/pages/Orders/viewModel/useOrdersPageViewModel.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/pages/Orders/view/OrderView/viewModel/useOrderDetailsViewModel.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/renderModalContent.tsx, toga2-supply/src/components/layout/SlideMenu/SlideMenu.tsx, toga2-supply/package.json |
|
|
7
7
|
| [Elite — stale TableView config (11 dead Core.RecordFields across 9 views)](features/supply2-tableview-config-drift.md) | 2.0 | `Client_Elite`'s `TableViewJoins` predate **two** platform bridge-table migrations and still reference **11 deleted `Core.RecordFields` ids (211, 321, 932, 358, | dbchanges2/Client_Elite/, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql |
|
|
8
8
|
| [Elite](profile.md) | 2.0 | Elite is a managed-services client that uses **Freshservice** as their helpdesk platform. | worker2/Worker/Elite.php, library/app/api/toga2.php, _underscore/Model/Elite/SalesOrder.php |
|
|
@@ -6,10 +6,15 @@ project: TOGa Supply
|
|
|
6
6
|
client: elite
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-11
|
|
10
10
|
owners: [apeterson, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- toga2-supply/ELITE-CLIENT-TASK-NOTES.md
|
|
13
|
+
- toga2-supply/src/pages/Orders/view/OrderView/viewModel/FIELDS/ELITE/BASEFIELDS.json
|
|
14
|
+
- toga2-supply/src/pages/Orders/viewModel/FIELDS/ELITE/BASE.json
|
|
15
|
+
- toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts
|
|
16
|
+
- toga2-supply/src/hooks/useFetchData.tsx
|
|
17
|
+
- toga2-supply/src/components/ui/Toaster.tsx
|
|
13
18
|
- toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts
|
|
14
19
|
- toga2-supply/src/pages/Inventory/viewModel/FIELDS/INVENTORYPAGEFIELDS.ts
|
|
15
20
|
- toga2-supply/src/pages/Inventory/viewModel/index.ts
|
|
@@ -108,14 +113,22 @@ is literal-identical to before.
|
|
|
108
113
|
|
|
109
114
|
The backend table view (slug supplied by the backend dev) is built on the **ServiceRequests RECORD**
|
|
110
115
|
— joining Tickets / Customers / ServiceRequestTypes / SalesOrders, where
|
|
111
|
-
`SalesOrders.serviceRequestId = ServiceRequests.id
|
|
116
|
+
`SalesOrders.serviceRequestId = ServiceRequests.id`. It is **not** a renamed SalesOrders view.
|
|
112
117
|
Three consequences drove the rest of the work:
|
|
113
118
|
|
|
119
|
+
> **⚠ CORRECTION (2026-08-11): the ServiceRequests ↔ SalesOrders relationship is 1:N, not 1:1.**
|
|
120
|
+
> This doc previously said 1:1; that is wrong. **SR100001 links to two sales orders (SA100000 and
|
|
121
|
+
> SA100003).** Any resolver that assumes a single match will pick an arbitrary row and the modal
|
|
122
|
+
> will show a *different* order run to run.
|
|
123
|
+
|
|
114
124
|
1. **Row uuids are ServiceRequests uuids, but every detail-modal route is a `/sales-orders` record
|
|
115
125
|
route.** Fixed with `getSalesOrderUuidForServiceRequest()` in `src/pages/Orders/api/OrdersApi.ts`
|
|
116
126
|
(`GET /sales-orders` + a ServiceRequests join, `where ServiceRequests.uuid = …`), whose resolved
|
|
117
127
|
`detailsUuid` is threaded through `useOrderDetailsViewModel.ts` via react-query. The query is
|
|
118
128
|
**enabled only for `hostName === "ELITE"`**, so every other host bypasses it entirely.
|
|
129
|
+
Because the join is **1:N**, the resolver fetches `uuid` **and** `number` and **sorts
|
|
130
|
+
client-side, taking the lowest number** — a deterministic pick, so the modal opens the same order
|
|
131
|
+
every time.
|
|
119
132
|
2. **ServiceRequests has no `isQuote` column.** `OrdersPage`'s unconditional `isQuote: false` filter
|
|
120
133
|
500'd with `1054 unknown column`; the backend confirmed the fix belongs on the FE, hence
|
|
121
134
|
`hasQuoteFilter`.
|
|
@@ -126,18 +139,45 @@ Three consequences drove the rest of the work:
|
|
|
126
139
|
Verified working end-to-end on the one Elite service request that has a linked sales order
|
|
127
140
|
(ticket INC-13982).
|
|
128
141
|
|
|
129
|
-
##
|
|
142
|
+
## Part 3 — order-details modal & polish (2026-08-11)
|
|
143
|
+
|
|
144
|
+
The details modal is now per-host configured through
|
|
145
|
+
`src/pages/Orders/view/OrderView/viewModel/FIELDS/ELITE/BASEFIELDS.json`:
|
|
146
|
+
|
|
147
|
+
- a **Type** row sourced from `salesOrderType.name`;
|
|
148
|
+
- `settings.currencyCharacter` `"$"`;
|
|
149
|
+
- Elite-worded copy for the no-details state;
|
|
150
|
+
- section visibility flags for the sections Elite does not show.
|
|
151
|
+
|
|
152
|
+
`FIELDS/ELITE/BASE.json` also **disables the export button** for Elite.
|
|
153
|
+
|
|
154
|
+
**Orphan service requests are a DESIGNED EMPTY STATE, not a bug** (this closes the open question
|
|
155
|
+
this doc previously carried). `useOrderDetailsViewModel.ts` reads the resolver's `isFetched` /
|
|
156
|
+
`isError` and yields **`null` `orderDetails`**, which renders
|
|
157
|
+
*"No order details are available for this service request."* Most Elite service requests on
|
|
158
|
+
dev-sandbox have no linked sales order, so this is the common path — do not "fix" the blank modal.
|
|
159
|
+
|
|
160
|
+
**Elite skips the ASN tracking query.** `OrdersApi.ts` short-circuits the
|
|
161
|
+
`AdvanceShippingNoticeUnits` lookup for `ELITE` — **that table does not exist in `Client_Elite`**,
|
|
162
|
+
so issuing it 500s the details fetch.
|
|
163
|
+
|
|
164
|
+
**Inventory nested view renamed to the backend's slug.** `DUMMYGROUPOPTIONS.ts` now uses
|
|
165
|
+
`inventory_units` (the backend's slug) with `nestedPrimaryRecordSlug` `Items`, and
|
|
166
|
+
`useFetchData.tsx`'s primary-fetch filter guard was extended to cover `"Items"`.
|
|
130
167
|
|
|
131
|
-
|
|
132
|
-
opens empty — the resolver correctly yields `null` and there is nothing to render. Undecided:
|
|
168
|
+
### Shared-component fixes made along the way (guardrail-safe)
|
|
133
169
|
|
|
134
|
-
|
|
135
|
-
-
|
|
136
|
-
-
|
|
170
|
+
Each of these touches a component every host renders, so each was written so that **other hosts are
|
|
171
|
+
byte-identical in behavior** — that is the bar for editing shared UI here (see the
|
|
172
|
+
[host-scoping playbook](../../../2.0/apps/toga2-supply/workflows/client-host-scoping.md)):
|
|
137
173
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
174
|
+
- `components/ui/Toaster.tsx` — a **message-only** toaster now centers (conditional `flex-1` +
|
|
175
|
+
`text-center`); the previous `justify-between` stranded the text at the right edge. The condition
|
|
176
|
+
provably no-ops every other call site (they all pass an action).
|
|
177
|
+
- `pages/Orders/OrdersPage.tsx` — `pageTitle` / `tableDisplayData` state is **re-synced after the
|
|
178
|
+
host store hydrates**; on a first visit those rendered with `fields` still `undefined`.
|
|
179
|
+
- `pages/Inventory/listing/InventoryPage.tsx` — a URL group uuid that is not in the resolved host's
|
|
180
|
+
options now falls back to `defaultOption` instead of crashing on the non-null-asserted `find(...)!`.
|
|
141
181
|
|
|
142
182
|
## Known limitations / notes
|
|
143
183
|
|
|
@@ -148,6 +188,19 @@ that is answered.
|
|
|
148
188
|
2026-08-07 prod→beta refresh that wiped the hand-built `service-requests` view.
|
|
149
189
|
|
|
150
190
|
## Change history
|
|
191
|
+
- 2026-08-11 — Order-details modal + service-requests polish: per-host
|
|
192
|
+
`FIELDS/ELITE/BASEFIELDS.json` (Type row from `salesOrderType.name`, `$` currency character,
|
|
193
|
+
Elite-worded no-details copy, section flags), export button disabled in `FIELDS/ELITE/BASE.json`,
|
|
194
|
+
inventory nested view renamed to the backend's `inventory_units` slug
|
|
195
|
+
(`nestedPrimaryRecordSlug: Items`) with `useFetchData.tsx`'s filter guard extended to `"Items"`,
|
|
196
|
+
and the ELITE skip of the ASN tracking query (`AdvanceShippingNoticeUnits` does not exist in
|
|
197
|
+
`Client_Elite`). **CORRECTED: ServiceRequests ↔ SalesOrders is 1:N, not 1:1** (SR100001 →
|
|
198
|
+
SA100000 + SA100003), so the resolver now fetches `uuid`+`number` and picks the lowest number
|
|
199
|
+
deterministically. **Closed the orphan-SR open question** — an order-less SR is a *designed empty
|
|
200
|
+
state* ("No order details are available for this service request") driven by the resolver's
|
|
201
|
+
`isFetched`/`isError` yielding null `orderDetails`. Also landed three guardrail-safe shared-component
|
|
202
|
+
fixes (Toaster message-only centering, OrdersPage state re-sync after host-store hydration,
|
|
203
|
+
InventoryPage URL-uuid fallback to `defaultOption`). (tcox)
|
|
151
204
|
- 2026-08-10 — **Service Requests shipped and verified** (INC-13982): implemented as the Orders page
|
|
152
205
|
host-configured, not a new route — new `FIELDS/ELITE/BASE.json` (pageTitle/tableSlug/listingSlug/
|
|
153
206
|
`hasQuoteFilter`/no `approvalActionOptions`), explicit `ELITE` entry in `useOrdersPageViewModel`,
|
|
@@ -6,7 +6,7 @@ project: Database Changes
|
|
|
6
6
|
client: elite
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-11
|
|
10
10
|
owners: [tcox]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Client_Elite/
|
|
@@ -112,6 +112,33 @@ note, script, or diff that identifies a join by **row id** is worthless after a
|
|
|
112
112
|
by **(`tableViewId`, `joinRecordId`, `joinOnRecordFieldId`)** instead — the id columns in the table
|
|
113
113
|
above are a snapshot for orientation, not a key.
|
|
114
114
|
|
|
115
|
+
## Service-requests column headers — the context-row mapping (authored, BLOCKED)
|
|
116
|
+
|
|
117
|
+
Elite's `service-requests` view renders several headers as raw camelCase slugs because its columns
|
|
118
|
+
are reached through joins and Elite has no context-scoped label rows for them. The mechanism (why a
|
|
119
|
+
joined column needs `contextRecordFieldId`, and the `Core.PageRecords` gate) is in
|
|
120
|
+
[legacy page meta](../../../2.0/apps/_underscore/features/page-meta-context-field-settings.md).
|
|
121
|
+
|
|
122
|
+
Mapping derived for the view (target `Core.RecordFields` id → **context** `Core.RecordFields` id):
|
|
123
|
+
|
|
124
|
+
| Target field id(s) | Context field id | Context field |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| 566, 574 | 1595 | `ServiceRequests.ticketId` |
|
|
127
|
+
| 679 | 2087 | `customerId` |
|
|
128
|
+
| 203 | 192 | `serviceRequestTypeId` |
|
|
129
|
+
| 48, 175, 58 | 190 | `ServiceRequests.id` |
|
|
130
|
+
| 28 | 43 | `SalesOrders.locationId` |
|
|
131
|
+
| 2766 | — | flat row (base record — **no** context) |
|
|
132
|
+
|
|
133
|
+
**Blocked on the backend, in this order — do not skip ahead:**
|
|
134
|
+
|
|
135
|
+
1. The `Page.php` `isset()` guard (~L822–829). Until it ships, inserting these rows fatals every
|
|
136
|
+
page that carries the labelled field's record but not the context record.
|
|
137
|
+
2. `Core.PageRecords` additions on **`sales-orders-listing`** (Elite reuses it): **Tickets** and
|
|
138
|
+
**Service request types**. Without them the fields are not emitted into the meta at all.
|
|
139
|
+
3. The `ClientRecordFieldSettings` rows themselves, shipped as a `dbchanges2/Client_Elite/`
|
|
140
|
+
migration — a hand-insert on beta will not survive the next tenant refresh (see above).
|
|
141
|
+
|
|
115
142
|
## Gotchas / known issues
|
|
116
143
|
|
|
117
144
|
- **A `Client_*` DB that predates a platform migration fails silently until a user opens the
|
|
@@ -123,6 +150,12 @@ above are a snapshot for orientation, not a key.
|
|
|
123
150
|
[error-reporting-issue-event](../../../2.0/apps/_underscore/features/error-reporting-issue-event.md).
|
|
124
151
|
|
|
125
152
|
## Change history
|
|
153
|
+
- 2026-08-11 — Added the authored **context-row mapping for the `service-requests` column headers**
|
|
154
|
+
(target → context `Core.RecordFields` ids; 2766 is flat/base-record) and the mandatory backend
|
|
155
|
+
ordering it is blocked behind: `Page.php` `isset()` guard → `Core.PageRecords` additions on
|
|
156
|
+
`sales-orders-listing` (Tickets + Service request types) → the settings rows as a
|
|
157
|
+
`dbchanges2/Client_Elite/` migration. Unlabelled joined columns currently fall back to the raw
|
|
158
|
+
`TableViewFields.slug`, which is the camelCase headers on that view. (tcox)
|
|
126
159
|
- 2026-08-10 — Recorded the **2026-08-07 prod→beta rebuild of `Client_Elite`** and its four
|
|
127
160
|
breakages (wiped hand-built `service-requests` view, empty-password `devteam` user, the `isQuote`
|
|
128
161
|
`1054`, and the `/pages/meta` 500), plus the rule that **`TableViewJoins` row ids shift on
|
|
@@ -12,7 +12,7 @@ project: Worker
|
|
|
12
12
|
client: elite
|
|
13
13
|
type: profile
|
|
14
14
|
status: active
|
|
15
|
-
updated: 2026-08-
|
|
15
|
+
updated: 2026-08-11
|
|
16
16
|
owners: [snaredla, apeterson, tcox, bala]
|
|
17
17
|
files:
|
|
18
18
|
- worker2/Worker/Elite.php
|
|
@@ -42,9 +42,11 @@ dbchanges2 **PR #454** (TRUE-80499, snaredla24, 2026-08-05) added the **netsuite
|
|
|
42
42
|
|
|
43
43
|
Elite's service-request data model: `ServiceRequests` is the record the `service-requests` table
|
|
44
44
|
view is built on (joining Tickets / Customers / ServiceRequestTypes / SalesOrders, with
|
|
45
|
-
`SalesOrders.serviceRequestId = ServiceRequests.id
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
`SalesOrders.serviceRequestId = ServiceRequests.id`). **That relationship is 1:N, not 1:1**
|
|
46
|
+
(corrected 2026-08-11 — SR100001 links to both SA100000 and SA100003), so anything resolving an SR
|
|
47
|
+
to "its" sales order must pick deterministically. **Most Elite service requests on dev-sandbox have
|
|
48
|
+
no linked sales order at all**, and that is now treated as a legitimate state: the details modal
|
|
49
|
+
renders a designed "no order details" empty state (see supply2-scope).
|
|
48
50
|
|
|
49
51
|
**Elite orders are pushed to NetSuite event-driven (2026-08).** `_Model_Elite_SalesOrder`'s
|
|
50
52
|
`postPost`/`postPut` interceptors queue the shared worker2 push. Elite's
|
package/package.json
CHANGED