toga-ai 1.0.600 → 1.0.601

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.
@@ -10,7 +10,7 @@
10
10
  | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
11
11
  | [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
12
12
  | [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
13
- | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent | _underscore/Model.php |
13
+ | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent | _underscore/Model.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/ServiceRequest.php, _underscore/Model/Elite/SalesOrder.php, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql |
14
14
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | test/@Mark/true-80824-fedex-inflate-test.php, dbchanges2/Client_Growrk/2026-08-10e - GrowrkUpsServiceMethodCodes.sql, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/ShipmentRequest/ShipmentRequest.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
15
15
  | [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the | _underscore/Database.php, _underscore/Environment.php, api2/Initialize.php |
16
16
  | [_Cloud S3 helpers (copy / get / delete / list)](features/cloud-s3-helpers.md) | `_Cloud` centralizes AWS SDK S3 usage for the 2.0 stack so the `S3Client` never leaks into workers or app code. | _underscore/Cloud.php |
@@ -24,14 +24,14 @@
24
24
  | [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 |
25
25
  | [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 |
26
26
  | [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, toga2-supply/src/pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx, dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql |
27
- | [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 |
27
+ | [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/Elite/SalesOrder.php, _underscore/Model/Elite/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
28
  | [DB-free unit testing for _underscore model interceptors](features/model-interceptor-unit-testing.md) | `_underscore` shipped with **no** PHPUnit setup (no `composer.json`/`phpunit`; only vendored PhpOffice tests existed). | _underscore/Test/bootstrap.php, _underscore/Test/Prudential/ServiceRequestTest.php, test/@Bala/tests/netsuite_salesorder_payload_tests.php |
29
29
  | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php, _underscore/Model.php, _underscore/Model/Rate/Subscription.php |
30
30
  | [_Model::save() parent FK cascade — stored-SQL-field recompute deadlocks](features/model-save-parent-cascade-stored-field-deadlock.md) | `_Model::save()` runs a **generic parent foreign-key cascade**: inserting (or saving) a child row that carries an FK to a parent causes `_Model` to **re-load an | _underscore/Model.php, _underscore/Model/Client/PurchaseOrder.php, _underscore/Model/Client/AdvanceShippingNotice.php |
31
31
  | [_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 |
32
32
  | [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 |
33
33
  | [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 |
34
- | [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 |
34
+ | [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, toga2-supply/src/components/ui/Tables/PrimaryTable/PrimaryTable.tsx |
35
35
  | [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/Model.php, _underscore/Query.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
36
36
  | [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 |
37
37
  | [Record Change Audit Log (Logs_<Client>.Record / RecordField) — reading a field's history](features/record-change-audit-log.md) | Every 2.0 client schema has a sibling **logs** schema `Logs_<Tenant>` (e.g. | _underscore/Model/Client/Logs/Record.php, _underscore/Model/Client/Logs/RecordField.php, _underscore/Model/Client/Logs/CustomRecordField.php |
@@ -40,8 +40,8 @@
40
40
  | [Surface Resolver (_Model_Core_Surface::resolve — replaces Page::meta)](features/surface-resolver.md) | The runtime for the platform-wide **Surface** UI presentation layer: 9 `_underscore` models plus a cached resolver, `_Model_Core_Surface::resolve(&$api, string | _underscore/Model/Core/Surface.php, _underscore/Model/Client/AclRecordScript.php, _underscore/Model/Core/RecordScript.php, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Quad/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Client_CompassCanada/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Client_Compass/2026-07-15f - SalesOrderRecordActionsRemoveDeadConfigRuleOverrides.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, _underscore/Model/Core/SurfaceElement.php, _underscore/Model/Core/Action.php, _underscore/Model/Core/Vocabulary.php, _underscore/Model/Core/VocabularyTerm.php, _underscore/Model/Core/Message.php, _underscore/Model/Client/SurfaceOverride.php, _underscore/Model/Client/MessageTranslation.php, _underscore/Model/Client/ThemeToken.php, _underscore/Model/Core/Page.php, api2/Component/Api/V2/V2.php |
41
41
  | [Table-View Hyperlink Columns (meta → ACL → computed URL → render)](features/tableview-hyperlink-columns.md) | Any 2.0 table-view column can render its value as a clickable link instead of plain text. | _underscore/Model/Client/TableView.php, _underscore/Model/Client/TrackingNumber.php, api2/Component/Api/V2/V2.php, toga2-supply/src/api/toga.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/convertData.tsx, toga2-supply/src/components/ui/Tables/hooks/useDataTableState.tsx, dbchanges2/Client/2026-07-20 - TrackingNumberHyperlinkAndFieldPermission.sql |
42
42
  | [TogaIQ Gateway Client (_Component_Api_Togaiq) — AI generate/translate from 2.0](features/togaiq-gateway-client.md) | `_Component_Api_Togaiq` is the 2.0 framework's client for the **TogaIQ** (Talos) AI gateway. | _underscore/Component/Api/Togaiq/Togaiq.php, _underscore/ApiRequest.php |
43
- | [Tracking-Number Bridge Migration (ASN / Item Fulfillment / Item Receipt)](features/tracking-number-bridges.md) | Shipment tracking numbers used to live as **scalar FK columns** (`trackingNumberId`, `returnTrackingNumberId`) directly on the lowest-level "unit"/"item" tables | api2/Component/Api/V2/V2.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnit.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillmentItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Prudential/AdvanceShippingNotice.php, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Trait/Netsuite/ItemFulfillment.php, api2/Component/Api/Cxml/Cxml.php, dbchanges2/Client/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Core/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Client_Prudential/2026-06-15 - ItemFulfillmentTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Quad/2026-06-18a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19b - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Growrk/2026-07-13a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql, dbchanges2/Client_Quad/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql |
44
- | [Units for Items for Purchase Orders — Data Structure](features/units-for-items-for-purchase-orders.md) | Describes how unit (serialized inventory) data is linked to sales-order and purchase-order line items behind the `units-for-items-for-purchase-orders` TableView | |
43
+ | [Tracking-Number Bridge Migration (ASN / Item Fulfillment / Item Receipt)](features/tracking-number-bridges.md) | Shipment tracking numbers used to live as **scalar FK columns** (`trackingNumberId`, `returnTrackingNumberId`) directly on the lowest-level "unit"/"item" tables | api2/Component/Api/V2/V2.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnit.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillmentItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Prudential/AdvanceShippingNotice.php, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Trait/Netsuite/ItemFulfillment.php, api2/Component/Api/Cxml/Cxml.php, dbchanges2/Client/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Core/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Client_Prudential/2026-06-15 - ItemFulfillmentTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Quad/2026-06-18a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19b - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Growrk/2026-07-13a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql, dbchanges2/Client_Quad/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql, dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql |
44
+ | [Units for Items for Purchase Orders — Data Structure](features/units-for-items-for-purchase-orders.md) | Describes how unit (serialized inventory) data is linked to sales-order and purchase-order line items behind the `units-for-items-for-purchase-orders` TableView | _underscore/Model/Client/ServiceRequest.php, dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql |
45
45
  | [USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)](features/usps-dpv-deliverability.md) | **USPS returning HTTP 200 with a populated address is NOT evidence that the address is deliverable.** The authoritative signal is USPS's **DPV (Delivery Point V | _underscore/Component/Library/Carriers/Usps/Usps.php, _underscore/Model/Client/Address.php, _underscore/Model/Rate/Entitlement.php |
46
46
  | [Refreshing a Local Dev Database from Beta (dev-sandbox)](workflows/local-db-refresh-from-beta.md) | How to reset a local 2.0 dev database from the **beta / dev-sandbox** environment: dump each schema (`Core`, `Client_<Id>`, `Logs_<Id>`, …) from the beta host, | api2/Config/, _underscore/Loader.php, _underscore/Model/Client/BundleTranslation.php, api2/Component/Api/V2/V2.php, toga25-supply/sync_compasscanada_schema.sql |
47
47
  | [Running a 2.0 App Locally (browser, end-to-end via api2)](workflows/running-a-2.0-app-locally.md) | The full dependency chain required to run a 2.0 client app **through the browser**, end-to-end, against a **local `api2`** (e.g. | _underscore/Environment.php, _underscore/Config.php, _underscore/Database.php, _underscore/Route.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, api2/index.php, api2/.htaccess |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-18
10
10
  owners: ["jcardinal", "mhammontree", "tcox", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -111,7 +111,9 @@ inserted zero rows**.
111
111
 
112
112
  Use **hardcoded `Core.RecordFields` id literals** instead: those ids are explicitly seeded and
113
113
  identical in every environment (the platform's only sanctioned hardcoded ids — see
114
- `2.0/apps/dbchanges2/architecture.md`).
114
+ `2.0/apps/dbchanges2/architecture.md`). **Caveat: that holds for *established* rows only - the
115
+ next free id differs per environment (a new field took 2483 in production and 2766 in
116
+ dev-sandbox), so a row you are creating right now may need a per-environment literal.**
115
117
 
116
118
  **Known-broken existing files (same root cause):** `Client_Rate/2026-07-23a` and
117
119
  `Client/2026-07-16c`. A clean local run is never proof either worked.
@@ -357,6 +359,10 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
357
359
  and every repo is on the **same branch** so the generated model matches the DB.
358
360
 
359
361
  ## Change history
362
+ - 2026-08-18 - Caveated "explicitly seeded and identical in every environment": true for
363
+ established `Core.RecordFields` rows, but the **next free id differs per environment** (a new
364
+ field took 2483 in prod, 2766 in dev-sandbox), so a newly created row may need a per-environment
365
+ literal. Keeps this doc consistent with `2.0/apps/dbchanges2/architecture.md`. (bala)
360
366
 
361
367
  - **2026-08-10** — Added the **custom (`c_`) field THREE-part anatomy** as the counterpart of the
362
368
  standard-field recipe — physical column (missing → MySQL 1054 → 500/`EO-1` on every load),
@@ -6,12 +6,18 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-13
10
- owners: [snaredla, jcardinal]
9
+ updated: 2026-08-18
10
+ owners: [snaredla, jcardinal, bala]
11
11
  files:
12
12
  - _underscore/Model.php
13
+ - _underscore/Model/Client/SalesOrder.php
14
+ - _underscore/Model/Client/ServiceRequest.php
15
+ - _underscore/Model/Elite/SalesOrder.php
16
+ - dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql
13
17
  related:
14
18
  - ../architecture.md
19
+ - ./acl-permission-chain.md
20
+ - ../../api2/features/v2-api-error-codes.md
15
21
  - ./model-magic-field-access.md
16
22
  - ./model-save-parent-cascade-stored-field-deadlock.md
17
23
  ---
@@ -50,8 +56,56 @@ fallback — `_serialNumbers` the property requires `_serialNumbers()` the metho
50
56
  Options: `FIELDOPT_SQL_TYPE` (defaults to `FIELD_CHAR`) and `FIELDOPT_SQL_STORED` (default `false`,
51
57
  recalculated per query; `true` persists to a real column on save).
52
58
 
59
+ ## Exposing a calculated field to the API - a client `CustomRecordFields` row is enough
60
+
61
+ **Declaring the field on the model does not make it requestable.** A client whose DB has no
62
+ metadata row for it gets api2 **`EV-8`** - *"A field specified in your request does not exist"* -
63
+ even though the model inherits the field and the SQL is right there.
64
+
65
+ **The mechanism, and it is counter-intuitive: a calculated field can be exposed purely through a
66
+ client-scoped `CustomRecordFields` row. No `Core.RecordFields` row is required.** Compass proves
67
+ it - `_purchaseOrders` is `CustomRecordFields` id **77** in `Client_Compass`, alongside
68
+ `_totalLease` (81) and `_trackingNumbers` (83), with no Core registration - and the field returns
69
+ fine. The natural assumption ("no Core `RecordFields` row, therefore impossible for any client") is
70
+ **wrong**; it was reached and then disproved in this session, so do not re-derive it.
71
+
72
+ That is what makes rolling a calculated field out to every client cheap: the SQL already lives on
73
+ the shared `_Model_Client_*` model, so each client needs only the metadata row plus its ACL grant.
74
+ `_purchaseOrders` on `SalesOrders` shipped this way in
75
+ `dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql`.
76
+
77
+ **Writing that migration idempotently:**
78
+
79
+ - `INSERT ... SELECT ... WHERE NOT EXISTS`. `CustomRecordFields` is UNIQUE on
80
+ **(`recordId`, `field`)** and `AclCustomFieldPermissions` on
81
+ **(`customRecordFieldId`, `roleId`)**, so the guards line up with the unique keys.
82
+ - **Resolve roles BY NAME** (`'Base'`, `'API'`) - role ids differ per client DB.
83
+ - The grant table is `AclCustomFieldPermissions`, **not** `AclFieldPermissions` - see the
84
+ standard-vs-custom table in [acl-permission-chain](./acl-permission-chain.md).
85
+
86
+ **Verified by running it twice:** against `Client_True` it created 1 field + 2 ACL rows on the
87
+ first pass and nothing on the second; against `Client_Compass` it inserted nothing at all, leaving
88
+ id 77 and its role 1/3/4 grants untouched. Prudential gains the field across **26,280**
89
+ `SalesOrders_PurchaseOrders` rows.
90
+
53
91
  ## Gotchas / known issues
54
92
 
93
+ - **⚠ Never add a parameter TYPE to an override whose parent declares the parameter untyped - it
94
+ is a FATAL, and `php -l` will not catch it.** Overriding a calculated field's static method on a
95
+ `_Model_<Client>_*` subclass is the common case. Because PHP parameter types are
96
+ **contravariant**, adding a type where the parent has none *narrows* the signature and PHP
97
+ refuses:
98
+
99
+ ```
100
+ Declaration of _Model_Elite_SalesOrder::_status(string $alias)
101
+ must be compatible with _Model_Client_SalesOrder::_status($alias)
102
+ ```
103
+
104
+ Adding a **return** type is fine (return types are covariant). `php -l` only checks syntax, so
105
+ the file lints clean and dies at runtime on the first model construction. Verified empirically
106
+ 2026-08-17. This is the one place the team-wide "type-hint every parameter" standard does not
107
+ apply: **match the parent signature exactly.**
108
+
55
109
  - **The leading underscore is REQUIRED — it is not a camelCase-standard violation.** The TOGA
56
110
  naming standard says database/class fields are camelCase, and a reviewer (human or agent) will
57
111
  reflexively flag `_serialNumbers` and "fix" it to `serialNumbers`. Doing so **throws**:
@@ -72,6 +126,17 @@ recalculated per query; `true` persists to a real column on save).
72
126
 
73
127
  ## Change history
74
128
 
129
+ - 2026-08-18 - Documented **how a calculated field becomes requestable**: a client-scoped
130
+ `CustomRecordFields` row is sufficient and **no `Core.RecordFields` row is needed** (Compass's
131
+ `_purchaseOrders` = id 77, with `_totalLease` 81 / `_trackingNumbers` 83, has no Core row and
132
+ works); without the row api2 returns **`EV-8`**. The opposite conclusion was reached and disproved
133
+ this session. Added the idempotent-migration shape (`INSERT ... SELECT ... WHERE NOT EXISTS`
134
+ against the UNIQUE keys `(recordId, field)` and `(customRecordFieldId, roleId)`, roles resolved
135
+ **by name** because role ids differ per client) as shipped in
136
+ `dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql`, verified by double-running
137
+ against `Client_True` and `Client_Compass`. Also added the **override-signature fatal**: adding a
138
+ parameter type to an override whose parent leaves it untyped is a fatal (parameter types are
139
+ contravariant) that `php -l` does not catch - adding a return type is fine. (bala)
75
140
  - 2026-08-13 — Added a gotcha: a `FIELDOPT_SQL_STORED` field that aggregates the model's own
76
141
  children deadlocks under the `_Model::save()` parent cascade (correlated-subquery recompute
77
142
  X-locks the parent + S-locks its children on every child insert). Cross-linked the new
@@ -6,13 +6,15 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-30
9
+ updated: 2026-08-18
10
10
  owners: [bala]
11
11
  files:
12
12
  - _underscore/Model/Client/SalesOrder.php
13
13
  - _underscore/Model/Quad/SalesOrder.php
14
14
  - _underscore/Model/Compass/SalesOrder.php
15
15
  - _underscore/Model/Compass/SalesOrderStatus.php
16
+ - _underscore/Model/Elite/SalesOrder.php
17
+ - _underscore/Model/Elite/SalesOrderStatus.php
16
18
  - _underscore/Model/Client/SalesOrderItem.php
17
19
  - _underscore/Model/Client/Item.php
18
20
  - _underscore/Model/Client/PurchaseOrderItem.php
@@ -97,10 +99,23 @@ fulfillment statuses — filter widened to `['_picked','_packed','_shipped']`:
97
99
  |---|---|---|
98
100
  | Base, Quad (non-SA) | Pending → Picked → Packed → Partially Fulfilled → Fulfilled | all IF stages |
99
101
  | Compass USA + Canada | Pending → Partially Fulfilled → Fulfilled | **shipped IF stage only** |
102
+ | Elite | pendingFulfillment → partiallyFulfilled → fulfilled → shipped → delivered (+ canceled) | fulfillment + shipment state; **no approval stage** |
100
103
 
101
104
  Quad `SA%` orders delegate to the base machine; an explicit `salesOrderStageId` overrides the
102
105
  calculated status everywhere.
103
106
 
107
+ **Elite is a third policy, not a variant of Compass.** `_Model_Elite_SalesOrder::_status`
108
+ (`FIELD_SQL` override, with four private helper builders, plus `_Model_Elite_SalesOrderStatus`) walks
109
+ a **six-status ladder**: `pendingFulfillment`, `partiallyFulfilled`, `fulfilled`, `shipped`,
110
+ `delivered`, `canceled`. The two new statuses (`shipped`, `delivered`) were seeded into
111
+ `Client_Elite.SalesOrderStatuses`. **Elite has no approval model at all**, so the Compass status
112
+ logic — which is written around the MR/MA approval flow — does not apply and must not be copied
113
+ across. The override also fixed a **`billed` → `pendingFulfillment` mislabel affecting 13 orders**.
114
+
115
+ > When overriding `_status` on a client model, **match the parent's parameter signature exactly** —
116
+ > adding a parameter type the parent leaves untyped is a fatal that `php -l` does not catch. See
117
+ > [calculated SQL fields](./calculated-sql-fields.md).
118
+
104
119
  ### Inventory movement (all clients)
105
120
 
106
121
  `_qtyFulfilled` and `_qtyCommitted` sum only IF items whose stage is **shipped** — picked/packed
@@ -153,6 +168,12 @@ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is
153
168
  picked/packed constant would let the literal be removed.
154
169
 
155
170
  ## Change history
171
+ - 2026-08-18 - Added **Elite** as a third order-status policy: `_Model_Elite_SalesOrder::_status`
172
+ walks a six-status ladder (pendingFulfillment / partiallyFulfilled / fulfilled / shipped /
173
+ delivered / canceled) with four private helper builders and two new `SalesOrderStatuses` rows
174
+ (shipped, delivered). **Elite has no approval model**, so the Compass logic does not transfer.
175
+ The override also corrected a `billed` -> `pendingFulfillment` mislabel on 13 orders. Noted the
176
+ override-signature fatal to watch for when writing a client `_status`. (bala)
156
177
  - 2026-06-30 — Built the IF stage lifecycle end-to-end: NetSuite sync imports all
157
178
  picked/packed/shipped statuses and advances stages on re-sync; base + Quad order `_status`
158
179
  picked/packed/partial/fulfilled machine; Compass USA + Canada order status shipped-only;
@@ -6,11 +6,12 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-11
10
- owners: [tcox]
9
+ updated: 2026-08-18
10
+ owners: [tcox, bala]
11
11
  files:
12
12
  - _underscore/Model/Core/Page.php
13
13
  - _underscore/Model/Client/TableView.php
14
+ - toga2-supply/src/components/ui/Tables/PrimaryTable/PrimaryTable.tsx
14
15
  related:
15
16
  - surface-resolver.md
16
17
  - ../../api2/features/tableview-field-metadata.md
@@ -57,6 +58,29 @@ nothing. The row must carry **`contextRecordFieldId` = the table-view join's
57
58
  **When nothing resolves, the header falls back to the raw `TableViewFields.slug`** — which is why
58
59
  unlabeled joined columns render as camelCase identifiers rather than as blanks.
59
60
 
61
+ > **⚠ CORRECTION (2026-08-17) — do not add a context row unless the context genuinely resolves.**
62
+ > The table above describes where the frontend *looks*; it does **not** mean "add a context row and
63
+ > the header appears." `PrimaryTable.tsx` takes the **context branch whenever `contextField` is
64
+ > set and never falls back** to the plain lookup, so a `ClientRecordFieldSettings` row carrying a
65
+ > `contextRecordFieldId` that does not resolve in the meta leaves the header rendering as its raw
66
+ > slug — the same symptom you were trying to fix, now with a row that can also 500 other pages
67
+ > (see the bug below). **Elite's `service-requests` headers were fixed with
68
+ > `contextRecordFieldId = NULL`**, not with context rows. Keep label rows plain unless the context
69
+ > really exists; treat context rows as the exception.
70
+
71
+ ### Check which page slug the frontend ACTUALLY requests
72
+
73
+ Because every label and ACL lookup is filtered on the page's `Core.PageRecords`, the records must
74
+ be attached to **the page the frontend asks for** — which is not always the page you built. Elite's
75
+ `service-requests` view is requested as **`meta?page=sales-orders-listing`** (Elite reuses that
76
+ page), so records 99 (Tickets) and 36 (ServiceRequestTypes) had to be attached to
77
+ `sales-orders-listing`, **not** only to the new `service-requests-listing` page. Attaching them to
78
+ the new page alone changed nothing.
79
+
80
+ **Read the Network tab first.** This was mis-diagnosed twice before the actual request URL settled
81
+ it. A missing header is a *page-attachment* question, and only the real request identifies the
82
+ page.
83
+
60
84
  ### The `Core.PageRecords` gate
61
85
 
62
86
  `Page.php` assembles `settings.fields` from an **INNER JOIN** on `Core.RecordFields` (~L652) that is
@@ -89,6 +113,19 @@ because the service-requests row exists.
89
113
  **Fix:** an `isset()` guard around `Page.php` ~L822–829 — skip any context setting whose context
90
114
  field is not present on the requested page.
91
115
 
116
+ **Only the client path is unguarded.** `Page.php:824` reads
117
+ `$lookupRecordFieldSlugFromRecordFieldId[$row->contextRecordFieldId]` with no guard, while the
118
+ **Core Default path at `Page.php:696` self-heals** by inserting the missing `PageRecords` row. So
119
+ the same shape of data is harmless on the Core side and fatal on the client side — which is why
120
+ this reads as a client-data problem when it is really a missing guard.
121
+
122
+ **Concrete blast radius (Elite, 2026-08-17):** the offending row was `recordFieldId` **58**
123
+ (`SalesOrders._total`) with `contextRecordFieldId` **190**. Record field 58 lives on `SalesOrders`,
124
+ which appears on **six** supply pages, so one label override intended for `service-requests` 500'd
125
+ the **Inventory** page. Applied fix: `contextRecordFieldId = NULL`. **Before adding any context row,
126
+ check how many pages carry the labelled field's record** — a widely-shared field makes the row a
127
+ platform-wide hazard rather than a local tweak.
128
+
92
129
  **Blast radius:** any client that uses context-scoped field settings can hit this on **every page
93
130
  that shares the base field**. The failure looks unrelated to the page you are debugging, and it can
94
131
  appear long after the settings row was authored — a redeploy that tightens error handling, not a
@@ -106,6 +143,16 @@ data change, is what makes it fatal.
106
143
  customization. The guard belongs in the framework.
107
144
 
108
145
  ## Change history
146
+ - 2026-08-18 - **Corrected the "joined columns need a context row" guidance.** `PrimaryTable.tsx`
147
+ takes the context branch whenever `contextField` is set and **never falls back** to the plain
148
+ lookup, so a context row whose context does not resolve in the meta leaves the header as its raw
149
+ slug anyway — Elite's `service-requests` headers were fixed with `contextRecordFieldId = NULL`,
150
+ not with context rows. Added the diagnostic that the records must be attached to **the page the
151
+ frontend actually requests** (`meta?page=sales-orders-listing` for Elite's service-requests view,
152
+ not the new `service-requests-listing`) — read the Network tab first. Recorded that only the
153
+ **client** path is unguarded (`Page.php:824`) while the **Core Default path at `Page.php:696`
154
+ self-heals** by inserting the missing `PageRecords` row, and the concrete blast radius: field 58
155
+ (`SalesOrders._total`) sits on six supply pages, so one context row 500'd Inventory. (bala)
109
156
  - 2026-08-11 — Documented what context-scoped settings are actually **for**: table-view **column
110
157
  header labels**. A base-record column resolves as `settings.fields[record][field][label-singular]`
111
158
  but a **joined** column resolves the context-nested
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-06
9
+ updated: 2026-08-18
10
10
  owners: ["jcardinal", "mhammontree", "dfranks", "apeterson", "bala", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -26,6 +26,7 @@ files:
26
26
  - dbchanges2/Client_Growrk/2026-07-13a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql
27
27
  - dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql
28
28
  - dbchanges2/Client_Quad/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql
29
+ - dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql
29
30
  related:
30
31
  - recursive-item-fulfillments.md
31
32
  ---
@@ -191,9 +192,63 @@ returnTrackingNumber: {...} }]`, and an IFIU's as `itemFulfillmentItemUnitTracki
191
192
  audit found `sales-order-shipments` (join 10) referencing dead RecordField **211 in
192
193
  `Client_Nychh` too** — that view is broken platform-wide and needs a backend decision, not a
193
194
  copy.
194
- - **Porting the item-fulfillment TableView fix to another client is two independent decisions, not a copy-paste.** The **re-root** (Units 31 → ItemFulfillmentItems 29 + OUTER unit chain) is universally portable and fixes the "0 records" bug everywhere. The **tracking source is client-specific**: Compass writes one tracking number per item fulfillment at the **item level (318)**, so its views read 318. Other clients populate different bridge levels — verify with `SELECT COUNT(*)` per bridge before choosing. As of 2026-06-19, **record 318 is empty in `Client_Nychh` and `Client_Quad`**; their tracking lives at the **IF/shipment level (317)** (covers IFIs: NYCHH 7955/10049 ≈ 79%, Quad 4878/4967 ≈ 98%) and the **unit level (319)** (≈ 7–9% — serialized only). Copying Compass's 318 join into a client that doesn't write 318 yields a structurally-correct view with permanently blank tracking until 318 is backfilled. (NYCHH's pre-fix view pointed at the now-deleted record 41 = old IF-level/package bridge, i.e. it originally intended IF-level 317.)
195
+ - **Porting the item-fulfillment TableView fix to another client is two independent decisions, not a copy-paste.** The **re-root** (Units 31 -> ItemFulfillmentItems 29 + OUTER unit chain) is universally portable and fixes the "0 records" bug everywhere. The **tracking source is client-specific** and must be *measured*, never inherited from Compass. (NYCHH's pre-fix view pointed at the now-deleted record 41 = old IF-level/package bridge, i.e. it originally intended IF-level 317.)
196
+
197
+ - **The bridge a client actually populates - measured 2026-08-17.** This is the single most
198
+ reusable fact in this doc: **two sibling bridges exist and they are not interchangeable.**
199
+
200
+ | Bridge | Record | Grain | Key fields |
201
+ |---|---|---|---|
202
+ | `ItemFulfillmentItems_TrackingNumbers` | **318** | per fulfillment **item** | 2178 `itemFulfillmentItemId` / 2179 `trackingNumberId` |
203
+ | `ItemFulfillmentItemUnits_TrackingNumbers` | **319** | per **unit** | 2183 `itemFulfillmentItemUnitId` / 2184 `trackingNumberId` |
204
+
205
+ Row counts per client DB:
206
+
207
+ | Client | 318 (item) | 319 (unit) | Which the views should read |
208
+ |---|---|---|---|
209
+ | Compass | 4,312 | 10,144 | 318 |
210
+ | CompassCanada | 169 | 138 | 318 |
211
+ | Elite | **0** | 109 | **319** (fixed 2026-08-17) |
212
+ | Prudential | **0** | 380 | **319** |
213
+ | Quad | **0** | 633 | **319** - but its views read 318 (**live bug**, see below) |
214
+ | NYCHH | **0** | unit-level | **319** |
215
+
216
+ Worked example: copying Compass's **318** join into Elite yields 402 rows with **zero** tracking
217
+ numbers; **319** yields 500 rows with 109 populated. `SELECT COUNT(*)` both bridges in the target
218
+ client DB **before** choosing - the wrong choice produces a structurally-correct view with a
219
+ permanently blank Tracking # / Carrier column, which reads as "the data is missing" rather than
220
+ "the join is wrong."
221
+
222
+ - **Live bug: Quad's Tracking # column is empty today.** Quad's two fulfillment views are a direct
223
+ copy of Compass and read **318**, but Quad has **0** rows in 318 and **633** in 319. Same visible
224
+ symptom as the Elite bug, different cause (Elite's was dead ids; Quad's is the wrong bridge
225
+ level). **Not fixed** - out of scope when found 2026-08-17. This supersedes the earlier note that
226
+ Quad's tracking "lives at 317 (~98%)"; the unit-level bridge is where it is now.
227
+
228
+ - **The dead-id breakage spans TEN clients, not just Elite (measured 2026-08-17).**
229
+ `item-fulfillments-for-sales-orders` / `item-fulfillments-for-sales-order-items` still carry
230
+ dangling `joinRecordId` **41** and dangling `parentRecordFieldId`s **211**, **321** in: **Elite**
231
+ (fixed 2026-08-17), **CompassCanada, Prudential, Nychh, Aig, Growrk, Managelife, Rate, SPGlobal**
232
+ (which has a fourth dangling id, **1431**) and **True**. **Only Compass and Quad are clean**,
233
+ because those two were migrated to base record 29. The per-client fix is **not a blind copy** -
234
+ the re-root is portable but the bridge (318 vs 319) must be chosen from that client's own row
235
+ counts above. A dead record is a **fatal**, not a skipped join: `TableView::meta` line 87 does
236
+ `($joinRecord->model)::TABLE`, and the dead record is not filtered out first because meta's join
237
+ query gates on `AclRecordPermissions.recordId` - and these clients still hold a permission row
238
+ for record 41.
195
239
 
196
240
  ## Change history
241
+ - 2026-08-18 - **Measured which bridge each client actually populates, and it is not what "copy
242
+ Compass" assumes.** Added the 318-vs-319 row-count table (Compass 4,312/10,144; CompassCanada
243
+ 169/138; Elite 0/109; Prudential 0/380; Quad 0/633; NYCHH 0/unit-level) and the rule to
244
+ `COUNT(*)` both bridges before porting a fulfillment view - the wrong level yields a
245
+ structurally-correct view with a permanently blank Tracking # column. Fixed **Elite** onto 319
246
+ (`Client_Elite/2026-08-17 - FulfillmentTableViews.sql`). Recorded a **live unfixed bug in Quad**
247
+ (views read 318, data is in 319, so its Tracking # column is empty today), superseding the older
248
+ "Quad's tracking lives at 317" note. Also measured the dangling `joinRecordId` 41 /
249
+ `parentRecordFieldId` 211+321 breakage across **ten** clients - only Compass and Quad are clean -
250
+ and recorded why a dead record is fatal rather than skipped (`TableView::meta` L87
251
+ `($joinRecord->model)::TABLE`, not filtered because meta gates on `AclRecordPermissions`). (bala)
197
252
  - 2026-08-06 — Recorded that the per-client TableView repointing left **unmigrated clients broken**:
198
253
  `Client_Elite` still has 11 references to deleted RecordFields (211/321/932/358/1431) across 9
199
254
  views in **prod and sandbox**, so its views 500 on `table-views/meta` and then on the data query
@@ -6,9 +6,11 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-06
9
+ updated: 2026-08-18
10
10
  owners: ["bala", "tcox"]
11
- files: []
11
+ files:
12
+ - _underscore/Model/Client/ServiceRequest.php
13
+ - dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql
12
14
  related:
13
15
  - ./tracking-number-bridges.md
14
16
  - ../../../clients/elite/features/supply2-tableview-config-drift.md
@@ -46,6 +48,51 @@ The receipt path requires both the item-level bridge **and** an item receipt aga
46
48
  bridged purchase-order line. The fulfillment path reaches `SalesOrderItems` directly by
47
49
  `salesOrderItemId` with no purchase order or bridge involved.
48
50
 
51
+ **3. Direct item path (`Units.itemId`):**
52
+ ```
53
+ Items
54
+ -> Units (Units.itemId = Items.id)
55
+ ```
56
+
57
+ `Units.itemId` is the **only complete** item-to-unit path. Measured on `Client_Elite`
58
+ (2026-08-11): **229 of 229** units resolve through `Units.itemId`, versus **97 of 229** through
59
+ item receipts. Confirmed with the team before building on it. If a view needs "every unit for this
60
+ item", walk `Units.itemId` - not a receipt or fulfillment chain.
61
+
62
+ ## The `inventory_units` view (2026-08-11, all clients)
63
+
64
+ The all-clients `inventory_units` table view is built on this third path: base record **31**
65
+ (Units) with **Items as the spine** - `joinRecordId` **21**, `joinOnRecordFieldId` **105**,
66
+ `parentRecordFieldId` **238** - then 15 OUTER joins and 13 columns
67
+ (`dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql`). This is the
68
+ "resolver/view change that also walks past the receipt path" that the gotcha below asks for.
69
+
70
+ **Per-client custom columns cannot live in that all-clients file.** A `customRecordFieldId` names
71
+ a *different field per client*, so client custom columns (and their
72
+ `ClientCustomRecordFieldSettings` labels) go in the client's own `Client_<Name>/` file - e.g.
73
+ Elite's four columns at index 14-17 (`grade`, `chargingBrick`, `chargingCable`, `wipeStatus` via
74
+ `c_deviceStatus`).
75
+
76
+ ## Serial numbers for a Service Request - the bridge table is empty, use the fulfillment chain
77
+
78
+ `_Model_Client_ServiceRequest::_serialNumbers` (a `FIELD_SQL` field, so every client inherits it)
79
+ `GROUP_CONCAT`s `Units.serialNumber`. The obvious source - the **`ServiceRequests_Units` bridge -
80
+ has 0 rows**, so a field built on it alone returns nothing. The path that actually carries the
81
+ data is the fulfillment chain off the service request's sales orders:
82
+
83
+ ```
84
+ ServiceRequests
85
+ -> SalesOrders (SalesOrders.serviceRequestId = ServiceRequests.id)
86
+ -> SalesOrderItems
87
+ -> ItemFulfillmentItems (.salesOrderItemId = SalesOrderItems.id)
88
+ -> ItemFulfillmentItemUnits
89
+ -> Units
90
+ ```
91
+
92
+ Coverage on `Client_Elite`: **224 of 229** units. The shipped expression selects
93
+ `IN (<fulfillment chain> UNION <ServiceRequests_Units>)` so the bridge still counts once it is
94
+ populated - keep the UNION rather than choosing one side.
95
+
49
96
  ## Data model
50
97
  Header-level vs item-level bridges, and the level each transaction is anchored at:
51
98
 
@@ -93,7 +140,28 @@ None — uniform. The linkage structure is framework-level and identical across
93
140
  [Elite TableView config drift](../../../clients/elite/features/supply2-tableview-config-drift.md).
94
141
  Check the view config before investigating the linkage paths above.
95
142
 
143
+ - **A join can also be broken by a `parentRecordFieldId` that belongs to a record the view never
144
+ joins.** Distinct from the dead-id case above: the id **exists**, it is just on the wrong record.
145
+ Found 2026-08-17 - a join in this view carried `parentRecordFieldId` **92**, which belongs to
146
+ record **289** (`SalesOrderItemPurchaseOrderItems`), a record the view does not join anywhere, and
147
+ it **500'd** the view. **Proven by bisection:** all 23 other joins returned 200; adding this one
148
+ returned 500. No fields or joins depended on it, so the fix was a targeted `DELETE`. The same row
149
+ is present in **Elite, Compass and Prudential**. When a view 500s and the ids all resolve, check
150
+ that every `parentRecordFieldId` sits on a record the view actually joins - and bisect the joins
151
+ rather than reading the SQL.
152
+
96
153
  ## Change history
154
+ - 2026-08-18 - Added the **third linkage path, `Units.itemId`** - the only *complete* item-to-unit
155
+ path (Elite: 229/229 units, vs 97/229 through item receipts) - and the all-clients
156
+ **`inventory_units`** view built on it (base 31, Items as spine via joinRecordId 21 /
157
+ joinOnRecordFieldId 105 / parentRecordFieldId 238, 15 OUTER joins, 13 columns), plus the rule that
158
+ per-client custom columns must stay in `Client_<Name>/` because a `customRecordFieldId` means a
159
+ different field per client. Documented **`ServiceRequest::_serialNumbers`**: the
160
+ `ServiceRequests_Units` bridge has **0 rows**, so serials come from the SalesOrders ->
161
+ SalesOrderItems -> ItemFulfillmentItems -> ItemFulfillmentItemUnits chain (224/229 on Elite),
162
+ UNIONed with the bridge so it still counts later. Added a second 500 cause - a
163
+ `parentRecordFieldId` (92) that **exists but belongs to a record the view never joins** (289),
164
+ found by bisecting joins; present in Elite, Compass and Prudential. (bala)
97
165
  - 2026-08-06 — Added the gotcha that a 500 on this view is usually **stale per-client
98
166
  `TableViewJoins`** left behind by the bridge migrations (records 319 / 289), not a data-linkage
99
167
  problem; confirmed on `Client_Elite` view 17. (tcox)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-11
9
+ updated: 2026-08-18
10
10
  owners: [mhammontree, tcox, jcardinal, ajean, bala]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -43,7 +43,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
43
43
  | Code | Meaning | Root cause | Fix |
44
44
  |------|---------|------------|-----|
45
45
  | **EV-6** | Resource / route not found | A **scripted-API** route segment is being treated as a record uuid because no Record Script matches — i.e. the `Core.RecordScripts` route row is missing | Register the `Core.RecordScripts` row (`recordId`, `method`, `route`, `phpMethod`) |
46
- | **EV-8** | Request field does not exist | A field sent in the payload isn't registered as a **`Core.RecordFields`** row for that record | Register the field in `Core.RecordFields` (see the "add a field to a V2 record" recipe in the ACL doc) |
46
+ | **EV-8** | Request field does not exist | The field named in the request has **no registration row for that record in this client's DB** - usually a missing `Core.RecordFields` row, but for a **calculated (`_`-prefixed) field a client-scoped `CustomRecordFields` row alone is enough** and no Core row is needed | Register the field. Standard field -> `Core.RecordFields` (see the "add a field to a V2 record" recipe in the ACL doc). **Calculated field already declared on a shared `_Model_Client_*` model -> just add the client `CustomRecordFields` row + `AclCustomFieldPermissions` grant**; see [calculated SQL fields](../../_underscore/features/calculated-sql-fields.md) |
47
47
  | **EV-9** | No permission to write field | The field exists but the caller's role has no **`AclFieldPermissions`** grant (`isWritable=1`) in the CLIENT DB | Add the `AclFieldPermissions` row for the role (clone a writable sibling field's grant) |
48
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 |
49
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) |
@@ -60,8 +60,11 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
60
60
  1. **Resolve the role.** Field/script ACLs are keyed by `roleId` **in the CLIENT DB**, and role
61
61
  ids differ per client — resolve by subselect (`SELECT id FROM Roles WHERE name='Base'`), never
62
62
  hardcode. To find the right grant shape, inspect a **working sibling** field/script's rows.
63
- 2. **EV-8 vs EV-9 vs EZ-2** — three states of one field. **EV-8** = the Core `RecordFields`
64
- registration is missing (field unknown). Once registered, a **write** with no grant is **EV-9**
63
+ 2. **EV-8 vs EV-9 vs EZ-2** — three states of one field. **EV-8** = the field's registration is
64
+ missing for that record (field unknown) — normally the Core `RecordFields` row, but note that a
65
+ **calculated `_`-prefixed field can be registered by a client `CustomRecordFields` row alone**,
66
+ with no Core row at all (Compass's `_purchaseOrders`, `_totalLease`, `_trackingNumbers` are all
67
+ like this), so "no Core row" does not mean "impossible to expose". Once registered, a **write** with no grant is **EV-9**
65
68
  and a **read** with no grant is **EZ-2** — both mean the `AclFieldPermissions` row is missing,
66
69
  differing only by direction. Registering a field and granting access to it are separate steps:
67
70
  a field can pass EV-8 yet still EZ-2/EV-9. (For custom `c_` fields the grant table is
@@ -269,6 +272,13 @@ Full mechanics:
269
272
  - The response-envelope shape and code families: see [api2 architecture](../architecture.md).
270
273
 
271
274
  ## Change history
275
+ - 2026-08-18 - **Corrected the EV-8 remedy.** "Register the field in `Core.RecordFields`" is not the
276
+ only route: a **calculated (`_`-prefixed) field** declared on a shared `_Model_Client_*` model is
277
+ fully exposed by a **client-scoped `CustomRecordFields` row + `AclCustomFieldPermissions` grant,
278
+ with no `Core.RecordFields` row at all** (Compass's `_purchaseOrders` id 77, `_totalLease` 81,
279
+ `_trackingNumbers` 83). Clients missing only that row return EV-8 for a field whose SQL they
280
+ already inherit. Cross-linked
281
+ [calculated SQL fields](../../_underscore/features/calculated-sql-fields.md). (bala)
272
282
 
273
283
  - 2026-08-13 — Added diagnosis note 14: a **validation (`EV-10`) response still leaks the raw failing
274
284
  SQL statement + DB name** to the external caller (seen in Office Depot's 400 body on the ASN
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-08-03
9
+ updated: 2026-08-18
10
10
  owners: [jcardinal, mhammontree, bala, ajean]
11
11
  files:
12
12
  - Core/
@@ -224,6 +224,23 @@ Because the `id`s must be reserved by a human, **this cannot be guessed or deriv
224
224
  mandatory, not a courtesy. Record the assigned values in a comment at the top of the file so the
225
225
  next reader knows they were allocated, not invented.
226
226
 
227
+ #### The next free `id` is NOT the same in every environment
228
+
229
+ The rule above keeps *existing* ids aligned, but each environment's `Core` has drifted
230
+ independently, so **the next free value differs**. Verified 2026-08-17: the same new
231
+ `ServiceRequests._serialNumbers` recordField took id **2483 in production** and **2766 in
232
+ dev-sandbox**. Consequences:
233
+
234
+ - **Never subselect for a Core id from a `Client_*` file** - `Core` and `Client_*` are on separate
235
+ clusters, so it cannot resolve there; write the literal.
236
+ - When a change inserts a **new** `Core.RecordFields` row *and* client-side rows that reference it,
237
+ expect the reference to need a **per-environment value**, and say so in a header comment. A single
238
+ file that "works everywhere" is only safe when every id it touches is long-standing Core data (as
239
+ in `Client_Elite/2026-08-17 - FulfillmentTableViews.sql`, which needed no split).
240
+ - This qualifies the claim in `2.0/apps/_underscore/features/acl-permission-chain.md` that these ids
241
+ are "identical in every environment": true for established rows, **not** for a row you are
242
+ creating now.
243
+
227
244
  ### Referencing them — hardcode the `id`, including foreign keys
228
245
 
229
246
  Anywhere a query references one of these rows — **and anywhere a foreign key points at them**
@@ -360,6 +377,17 @@ its own header.)
360
377
  referenced, including in foreign keys (`recordId`, `recordFieldId`) from other databases. See
361
378
  *`Core.Records` / `Core.RecordFields`* above.
362
379
 
380
+ 10. **A fan-out `Client/` file must never reference a `customRecordFieldId` or a client-specific
381
+ label.** Unlike `Core.RecordFields` ids, `CustomRecordFields` ids are **per-client
382
+ `AUTO_INCREMENT`** - the same numeric id names a **different field in every client database**.
383
+ A `Client/` file that hardcodes one silently wires the wrong field for 29 of 30 tenants, and
384
+ there is no error. The same applies to `ClientCustomRecordFieldSettings` label rows. Move
385
+ anything touching a custom field into the client's own `Client_<Name>/` file, even when it is
386
+ "the same change" for several clients. Worked example: the all-clients `inventory_units` table
387
+ view shipped in `Client/2026-08-11a`, but Elite's four custom columns (`grade`, `chargingBrick`,
388
+ `chargingCable`, `wipeStatus` via `c_deviceStatus`) and their labels shipped separately in
389
+ `Client_Elite/`.
390
+
363
391
  ## Bulk data loads — batch, and stage large sets in a temp table
364
392
 
365
393
  When a change inserts many rows (reference-data / code-table loads), write it as **batched
@@ -547,6 +575,15 @@ defined in `2.0/apps/_underscore/architecture.md`, and its change files create/a
547
575
  tables that `_Model_*` classes map to.
548
576
 
549
577
  ## Change history
578
+
579
+ - 2026-08-18 - Added **rule 10**: a fan-out `Client/` file must never reference a
580
+ `customRecordFieldId` or a client-specific label, because `CustomRecordFields` ids are per-client
581
+ `AUTO_INCREMENT` - the same numeric id names a different field in every tenant and mis-wires
582
+ silently (worked example: the all-clients `inventory_units` view in `Client/2026-08-11a` vs Elite's
583
+ four custom columns in `Client_Elite/`). Also recorded that **the next free `Core.RecordFields` id
584
+ differs per environment** (`_serialNumbers` = 2483 in production, 2766 in dev-sandbox), so a change
585
+ that creates a new Core row and references it from client rows may need a per-environment literal -
586
+ qualifying the "identical in every environment" claim for newly created rows only. (bala)
550
587
  - 2026-08-03 — **Added *Staging tables MUST pin the collation of the column they join against*.**
551
588
  A `CREATE TEMPORARY TABLE` without an explicit `COLLATE` inherits the connection default
552
589
  (commonly `utf8mb4_general_ci`) and dies with **error 1267 illegal mix of collations** when
@@ -4,6 +4,7 @@
4
4
  |-----|---------|-------|
5
5
  | [TOGa Supply (toga2-supply) Architecture](architecture.md) | `toga2-supply` is the **React + Vite frontend** for TOGa Supply — warehouse fulfillment tooling (shipment selection, fulfill & ship against carrier APIs, NetSui | toga2-supply/src/api/toga.ts, toga2-supply/src/pages/ShipmentItems/view/ShipmentItemsPage.tsx, toga2-supply/src/pages/EditShipment/view/EditShipmentPage.tsx, toga2-supply/src/pages/EditShipment/api/UpdateShipmentApi.ts, toga2-supply/src/pages/Shipments/view/components/ShipmentsCardTableForm/ShipmentsCardTableForm.tsx |
6
6
  | [Fulfill & Ship](features/fulfill-and-ship.md) | Fulfill & Ship lets a warehouse user select sales-order line items, enter serials, pick a carrier/method, and in one action: create the Item Fulfillment records | _underscore/Trait/Netsuite/SalesOrder.php, dbchanges2/Client_Growrk/2026-08-10e - GrowrkUpsServiceMethodCodes.sql, toga2-supply/src/pages/EditShipment/view/EditShipmentPage.tsx, _underscore/Model/Client/Measure.php, _underscore/Model/Client/TrackingNumber.php, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/getEditShipmentFormOptions.ts, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/validateFormOnSubmit.ts, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/checkDimensions.ts, toga2-supply/src/components/ui/Tables/BasicTable/BasicTable.tsx, toga2-supply/src/components/ui/Tables/types.ts, toga2-supply/src/components/ui/GoogleMapsLink.tsx, toga2-supply/src/pages/ShipmentItems/view/forms/ShipmentItemsTable.tsx, toga2-supply/src/pages/ShipmentItems/api/ShipmentItemsApi.ts, toga2-supply/src/pages/ShipmentItems/types.ts, toga2-supply/src/pages/EditShipment/viewModel/FIELDS/RETURNLABELFIELDS.json, toga2-supply/src/pages/EditShipment/view/components/forms/EditShipmentForm.tsx, toga2-supply/src/components/ui/BaseInput/UnitSelect.tsx, toga2-supply/src/pages/EditShipment/view/modals/SerialNumbersModal.tsx, toga2-supply/src/pages/ShipmentItems/view/ShipmentItemsPage.tsx, toga2-supply/src/pages/EditShipment/view/EditShipmentPage.tsx, toga2-supply/src/pages/EditShipment/view/components/forms/EditShipmentForm.tsx, toga2-supply/src/pages/EditShipment/view/components/SelectedShipmentItemsTable.tsx, toga2-supply/src/pages/EditShipment/view/helpers/ShipmentDetailsForm/renderEditShipmentFormInput.tsx, toga2-supply/src/pages/EditShipment/viewModel/FIELDS/DUMMYUPDATESHIPMENTFIELDS.json, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/renderEditShipmentFormInput.tsx, toga2-supply/tailwind.config.cjs, toga2-supply/src/pages/EditShipment/view/modals/ReturnShippingModal.tsx, toga2-supply/src/styles/index.scss, toga2-supply/src/components/ui/BaseInput/BaseInput.tsx, toga2-supply/src/pages/EditShipment/api/UpdateShipmentApi.ts, toga2-supply/src/pages/EditShipment/viewModel/signatureTypes.ts, toga2-supply/src/pages/EditShipment/view/modals/SelectReturnAddressModal.tsx, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/formatShipmentData.ts, toga2-supply/src/pages/EditShipment/types.ts, toga2-supply/src/pages/Shipments/view/ShipmentsPage.tsx, toga2-supply/src/pages/Shipments/view/components/ShipmentsCardTableForm/ShipmentsCardTableForm.tsx, toga2-supply/src/pages/Shipments/api/ShipmentsApi.ts, toga2-supply/src/pages/Shipments/types.ts, toga2-supply/src/pages/FulfilledShipments/view/FulfilledShipmentsPage.tsx, toga2-supply/src/components/ui/CardTable/CardTable.tsx, toga2-supply/src/components/ui/CardTable/types.ts, toga2-supply/src/assets/pen-line.svg, _underscore/Model/Client/ItemFulfillment.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Component/Library/Carriers/Ups/Ups.php |
7
+ | [Order-details field config (valueKey resolution) & the hardcoded Customer name](features/order-detail-field-config-and-customer-name.md) | The supply order-details modal is **config-driven**: each host has a JSON field list under `src/pages/Orders/view/OrderView/viewModel/FIELDS/<HOST>/`, and every | toga2-supply/src/utils/formatCustomerName.tsx, toga2-supply/src/pages/Orders/view/OrderView/components/invoices/OrderContentSection.tsx, toga2-supply/src/pages/Orders/view/OrderView/viewModel/FIELDS/, toga2-supply/src/api/toga.ts |
7
8
  | [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-supply` (React + Vite) builds and deploys on **AWS Amplify**. | toga2-supply/amplify.yml, toga2-supply/.gitattributes, toga2-supply/.github/workflows/sync-stage-environments.yml, toga2-supply/.env.qc-security |
8
9
  | [Onboarding a client to the supply2 frontend (host-scoping)](workflows/client-host-scoping.md) | How a new tenant becomes a "client" in the `toga2-supply` frontend. | toga2-supply/src/stores/useHostNameStore.ts, toga2-supply/src/utils/resolveClientHostName.ts, toga2-supply/src/hooks/useHostName.tsx, toga2-supply/src/hooks/usePageDetails.tsx, toga2-supply/src/hooks/usePageListDetails.tsx, toga2-supply/src/components/layout/SlideMenu/SlideMenu.tsx, toga2-supply/src/utils/handleClientAuthentication.ts, toga2-supply/src/App.tsx, toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts, toga2-supply/src/pages/Inventory/viewModel/FIELDS/INVENTORYPAGEFIELDS.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/pages/Orders/OrdersPage.tsx, 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/utils/convertConstructorColumnTitles.ts, toga2-supply/src/api/toga.ts, toga2-supply/package.json |
9
10
  | [Onboarding a client to TOGa Supply Fulfill & Ship](workflows/onboarding-a-client-to-fulfill-and-ship.md) | **Onboarding a client to Fulfill & Ship is CONFIGURATION, not development** — unless the client wants tracking pushed into their own system. | _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Trait/Netsuite/ItemFulfillment.php, dbchanges2/_modules/netsuite/ |
@@ -0,0 +1,122 @@
1
+ ---
2
+ title: Order-details field config (valueKey resolution) & the hardcoded Customer name
3
+ framework: "2.0"
4
+ repo: toga2-supply
5
+ project: TOGa Supply
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-18
10
+ owners: [bala]
11
+ files:
12
+ - toga2-supply/src/utils/formatCustomerName.tsx
13
+ - toga2-supply/src/pages/Orders/view/OrderView/components/invoices/OrderContentSection.tsx
14
+ - toga2-supply/src/pages/Orders/view/OrderView/viewModel/FIELDS/
15
+ - toga2-supply/src/api/toga.ts
16
+ related:
17
+ - ../../../../clients/elite/features/supply2-scope.md
18
+ - ../workflows/client-host-scoping.md
19
+ - ../architecture.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ The supply order-details modal is **config-driven**: each host has a JSON field list under
25
+ `src/pages/Orders/view/OrderView/viewModel/FIELDS/<HOST>/`, and every row names a
26
+ **`valueKey`** that `OrderContentSection.tsx` walks segment-by-segment over the API response.
27
+
28
+ **The Customer row is the exception, and it looks data-driven when it is not.** Every client's
29
+ config points at a `valueKey` that **resolves to nothing for every client**, and the displayed name
30
+ comes from a **hardcoded map in the frontend** keyed on the subdomain. Anyone who sets out to "fix"
31
+ a blank or wrong Customer column will otherwise spend the session chasing the API.
32
+
33
+ ## How it works
34
+
35
+ 1. A field row in `FIELDS/<HOST>/*.json` carries a `valueKey` such as `"salesOrderType.name"`.
36
+ 2. `OrderContentSection.tsx` splits that string on `.` and walks the segments over the fetched
37
+ order response. A segment that does not exist yields nothing, and the row renders a dash.
38
+ 3. If the row also carries **`isCustomerName: true`**, the resolved value is passed through
39
+ `formatCustomerName(name, hostName)`.
40
+ 4. `formatCustomerName` returns the real `name` **only when it is non-empty and not `"—"` / `"N/A"`**.
41
+ Otherwise it substitutes `CUSTOMER_MAP[hostName]`.
42
+
43
+ ## The trap: `orderBoxDetails` does not exist
44
+
45
+ Every client's order-details config uses:
46
+
47
+ ```json
48
+ "valueKey": "orderBoxDetails.customer.name"
49
+ ```
50
+
51
+ **`orderBoxDetails` exists nowhere** — not in the codebase, not in the API response. It appears
52
+ only as a literal inside those JSON config files (11 occurrences across the COMPASS, COMPASSCANADA,
53
+ QUAD, ELITE and DEFAULT field sets). So the walk in step 2 resolves to nothing, the value becomes a
54
+ dash, and step 4 always falls through to the hardcoded map:
55
+
56
+ ```ts
57
+ const CUSTOMER_MAP: Record<string, string> = {
58
+ COMPASS: "Compass Group",
59
+ ELITE: "Elite",
60
+ MIAMIDADE: "Miami-Dade County",
61
+ NYCHH: "NYC Health + Hospitals",
62
+ BANKOFAMERICA: "Bank of America",
63
+ PRUDENTIAL: "Prudential",
64
+ QUAD: "N/A",
65
+ };
66
+ ```
67
+
68
+ Consequences worth internalising:
69
+
70
+ - **Compass's "Compass Group" never came from `Customers.name`.** It is the map entry.
71
+ - **No client requests `customer.name`** in `apiFields.fetchOrderDetails`, so the data is not even
72
+ fetched.
73
+ - A host missing from the map shows a **dash**, which is the only visible symptom.
74
+
75
+ ### The host key
76
+
77
+ `CUSTOMER_MAP` is keyed on `localStorage.hostName`, set in `src/api/toga.ts:36` from
78
+ `window.location.hostname.toUpperCase().split(".")[0]`. So the key is the uppercased subdomain —
79
+ `elite.togasupply` → `ELITE`. Ten other places in the repo already compare against the literal
80
+ `"ELITE"`, which is what makes a new map entry safe to add.
81
+
82
+ ## Making it genuinely data-driven (deliberately NOT done)
83
+
84
+ The database value is available. On Elite, `Customers.name` is a single row
85
+ (`6312 Elite Eagan US HoldCo LLC`) and all 264 Elite sales orders carry a `customerId`. Reading it
86
+ requires **both** changes, not either one:
87
+
88
+ 1. add `"customer.name"` to `apiFields.fetchOrderDetails` for that host, **and**
89
+ 2. change the `valueKey` to `"customer.name"` in that host's `FIELDS/<HOST>/BASEFIELDS.json`.
90
+
91
+ **Left as an open option, not applied.** Two reasons to be careful:
92
+
93
+ - The real name is often not the display name a client wants. Elite's DB value is the legal entity
94
+ string, not "Elite".
95
+ - **Do not do this to Compass.** Its `Customers` row (id 2) is named `Compass`, so switching would
96
+ change the header from **"Compass Group"** to **"Compass"** — a visible regression for the
97
+ platform's largest tenant. Leave the Compass field file alone.
98
+
99
+ ## Gotchas / known issues
100
+
101
+ - **A dash in this column is a frontend-config symptom, not an API failure.** Check
102
+ `CUSTOMER_MAP` and the `valueKey` before opening the Network tab.
103
+ - **`formatCustomerName` only substitutes for empty/`—`/`N/A` values.** If a `valueKey` ever starts
104
+ resolving, the map silently stops applying for that host — so fixing the `valueKey` is a
105
+ behaviour change for the *label*, not just the plumbing.
106
+ - **A broken `valueKey` fails silently.** `OrderContentSection.tsx` walks segments with no
107
+ validation, so a typo or a stale key is indistinguishable from missing data. There is no build- or
108
+ run-time check that a `valueKey` corresponds to anything the API returns.
109
+ - `QUAD: "N/A"` is intentional — Quad's Customer row is meant to read as not-applicable.
110
+
111
+ ## Change history
112
+ - 2026-08-18 — First capture. Documented that the order-details Customer name is **hardcoded in the
113
+ frontend**, not read from the database: every host's config uses
114
+ `valueKey: "orderBoxDetails.customer.name"`, `orderBoxDetails` exists nowhere in the codebase or
115
+ API response, so `OrderContentSection.tsx`'s segment walk resolves to nothing and
116
+ `isCustomerName: true` routes the row through `formatCustomerName`'s subdomain-keyed
117
+ `CUSTOMER_MAP` (Compass's "Compass Group" is the map, not `Customers.name`). Added
118
+ **`ELITE: "Elite"`** so Elite shows a name instead of a dash (host key comes from
119
+ `api/toga.ts:36`, `hostname.toUpperCase().split(".")[0]`). Recorded the two changes needed to make
120
+ it data-driven (`customer.name` in `apiFields.fetchOrderDetails` **and** in the host's
121
+ `BASEFIELDS.json`), why it was left undone, and that doing it to Compass would regress its header
122
+ to "Compass". (bala)
@@ -5,8 +5,8 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-17
9
- owners: [jcardinal, mhammontree, dfranks]
8
+ updated: 2026-08-18
9
+ owners: [jcardinal, mhammontree, dfranks, bala]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/_underscore/architecture.md
@@ -529,6 +529,37 @@ $list = implode(separator: ",\n", array: $fields);
529
529
  $uuid = substr($value, 0, 8);
530
530
  ```
531
531
 
532
+ ### Overrides: match the parent signature exactly - never add a parameter type the parent lacks
533
+
534
+ The "type-hint every parameter" rule applies when you **author** a method. It does **not** apply
535
+ when you **override** one. PHP parameter types are **contravariant**: adding a type where the parent
536
+ declares the parameter untyped *narrows* the signature, and PHP refuses to load the class.
537
+
538
+ ```php
539
+ // Parent - _underscore/Model/Client/SalesOrder.php
540
+ public static function _status($alias) { ... }
541
+
542
+ // WRONG - fatal at class load
543
+ class _Model_Elite_SalesOrder extends _Model_Client_SalesOrder {
544
+ public static function _status(string $alias): string { ... }
545
+ }
546
+ // Declaration of _Model_Elite_SalesOrder::_status(string $alias)
547
+ // must be compatible with _Model_Client_SalesOrder::_status($alias)
548
+
549
+ // CORRECT - parameters match the parent; a RETURN type may be added (return types are covariant)
550
+ class _Model_Elite_SalesOrder extends _Model_Client_SalesOrder {
551
+ public static function _status($alias): string { ... }
552
+ }
553
+ ```
554
+
555
+ **`php -l` does NOT catch this.** It only checks syntax, so the file lints clean and dies at
556
+ runtime on the first model construction. Verified empirically 2026-08-17 on
557
+ `_Model_Elite_SalesOrder::_status`.
558
+
559
+ This bites hardest on `_Model_<Client>_*` overrides of `FIELD_SQL` methods, which is the most common
560
+ override in the codebase. **If you want the parent typed, type the parent** - then every subclass
561
+ can follow. Do not type the child alone.
562
+
532
563
  ### Documentation
533
564
 
534
565
  #### **Comments**
@@ -848,6 +879,12 @@ See: 2.0/apps/worker2/features/cross-account-aws-access.md
848
879
 
849
880
  ## Change history
850
881
 
882
+ - 2026-08-18 - Added "Overrides: match the parent signature exactly": adding a parameter type to an
883
+ override whose parent leaves it untyped is a **fatal at class load** (parameter types are
884
+ contravariant), and `php -l` does not catch it - the class dies at runtime on first model
885
+ construction. Adding a return type is fine. This is the one carve-out from the team-wide
886
+ "type-hint every parameter" rule; if you want the parent typed, type the parent. Verified on
887
+ `_Model_Elite_SalesOrder::_status`. (bala)
851
888
  - 2026-08-17 — SQL Comments: banned prose preambles on migration files (one short description per
852
889
  statement; runbooks go on the ticket, patterns in the knowledge base), while explicitly protecting
853
890
  PHP DocBlocks that record *why* a query is shaped a certain way. Also scoped the "no
@@ -22,7 +22,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
22
22
  - **worker2** (Worker) — 51 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 24 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)
25
- - **toga2-supply** (TOGa Supply) — 6 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
25
+ - **toga2-supply** (TOGa Supply) — 7 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
26
26
  - **saml** (SAML SSO Gateway) — 3 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
27
27
  - **toga2-view** (TOGa View Frontend) — 7 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
28
28
  - **toga2-hub** (TOGa Hub) — 2 doc(s) → [2.0/apps/toga2-hub/INDEX.md](2.0/apps/toga2-hub/INDEX.md)
@@ -5,7 +5,7 @@
5
5
  | [Elite — NetSuite → TOGa Supply inbound sync (TRUE-80499 onboarding)](features/netsuite-togasupply-sync.md) | 1.0 | Elite is the 18th client on the shared NetSuite → TOGa Supply importer ([engine](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md)). | worker/crons/toga2/netsuite/sync_togasupply_elite.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/schedules/cron.worker.sync.json, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/PurchaseOrder.php, _underscore/Model/Elite/ItemReceipt.php, dbchanges2/Client_Elite/_modules.txt, test/@srija/Elite Testing/Service Requests/test_sync_togasupply_elite_section.php, test/@srija/Elite Testing/Service Requests/test_diagnose_togasupply_elite.php |
6
6
  | [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, _underscore/Model/Elite/ServiceRequest.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql |
7
7
  | [Elite — Sales Order stage change posts a reply on the TOGa Desk (1.0) ticket](features/salesorder-status-togadesk-reply.md) | 2.0 | When an Elite sales order's **stage** changes, a reply is posted on the originating **TOGa Desk (1.0)** ticket so the requester sees progress where they raised | worker2/Worker/Sync/SalesOrderStatus.php, _underscore/Model/Elite/SalesOrderStatus.php, _underscore/Model/Elite/SalesOrder.php |
8
- | [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 |
9
- | [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
+ | [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/formatCustomerName.tsx, 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 |
9
+ | [Elite — stale TableView config (11 dead Core.RecordFields across 9 views; 2 now fixed)](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_Elite/2026-08-07 - ServiceRequests TableView.sql, dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql, dbchanges2/Core/2026-08-07 - ServiceRequests TableView.sql, dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql |
10
10
  | [Elite — raising a Service Request from a TOGa Desk ticket (App_Api_ServiceRequest)](features/togadesk-service-request-intake.md) | 1.0 | An Elite agent raises a **Service Request** from a TOGa Desk (1.0) ticket via a modal. | library/app/api/servicerequest.php, library/app/model/togadesk/ticket.php, togadesk/desk/includes/classes/class.ticket.php, togadesk/desk/template/modals/tickets/serviceRequest.php, togadesk/desk/includes/controllers/modals/tickets/serviceRequest.php, togadesk/desk/includes/controllers/actions/tickets/serviceRequest.php, dbchanges2/Client_Elite/2026-08-11a - EliteServiceRequestTicketUnique.sql, test/@srija/Elite Testing/Service Requests/test_elite_desk_service_request.php |
11
11
  | [Elite](profile.md) | 2.0 | Elite is a managed-services client that uses **Freshservice** as their helpdesk platform. | worker2/Worker/Elite.php, worker2/Worker/Sync/ServiceRequest.php, library/app/api/toga2.php, library/app/api/servicerequest.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, togadesk/desk/includes/classes/class.ticket.php, worker/crons/toga2/netsuite/sync_togasupply_elite.php |
@@ -6,8 +6,8 @@ project: TOGa Supply
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-11
10
- owners: [apeterson, tcox]
9
+ updated: 2026-08-18
10
+ owners: [apeterson, tcox, bala]
11
11
  files:
12
12
  - toga2-supply/ELITE-CLIENT-TASK-NOTES.md
13
13
  - toga2-supply/src/pages/Orders/view/OrderView/viewModel/FIELDS/ELITE/BASEFIELDS.json
@@ -22,6 +22,7 @@ files:
22
22
  - toga2-supply/src/pages/Inventory/listing/InventoryRouter.tsx
23
23
  - toga2-supply/src/pages/Inventory/listing/InventorySubTablePage.tsx
24
24
  - toga2-supply/src/utils/resolveClientHostName.ts
25
+ - toga2-supply/src/utils/formatCustomerName.tsx
25
26
  - toga2-supply/src/utils/convertConstructorColumnTitles.ts
26
27
  - toga2-supply/src/pages/Orders/OrdersPage.tsx
27
28
  - toga2-supply/src/pages/Orders/viewModel/FIELDS/ELITE/BASE.json
@@ -33,6 +34,7 @@ files:
33
34
  - toga2-supply/package.json
34
35
  related:
35
36
  - ../../../2.0/apps/toga2-supply/workflows/client-host-scoping.md
37
+ - ../../../2.0/apps/toga2-supply/features/order-detail-field-config-and-customer-name.md
36
38
  - ./supply2-tableview-config-drift.md
37
39
  - ../profile.md
38
40
  ---
@@ -157,6 +159,16 @@ this doc previously carried). `useOrderDetailsViewModel.ts` reads the resolver's
157
159
  *"No order details are available for this service request."* Most Elite service requests on
158
160
  dev-sandbox have no linked sales order, so this is the common path — do not "fix" the blank modal.
159
161
 
162
+ **The Customer row is a hardcoded frontend map, not a database read.** Added
163
+ `ELITE: "Elite"` to `src/utils/formatCustomerName.tsx`'s `CUSTOMER_MAP` so Elite's Customer row
164
+ shows a name instead of a dash. The key is the uppercased subdomain (`api/toga.ts:36`). This is not
165
+ an Elite quirk - **every** host's config points at `valueKey`
166
+ `"orderBoxDetails.customer.name"`, and `orderBoxDetails` does not exist anywhere, so the value never
167
+ resolves for anyone and the map is what renders. Making it read `Customers.name` needs two changes
168
+ and was deliberately **not** done (Elite's DB value is `6312 Elite Eagan US HoldCo LLC`). Full
169
+ mechanism:
170
+ [order-details field config & Customer name](../../../2.0/apps/toga2-supply/features/order-detail-field-config-and-customer-name.md).
171
+
160
172
  **Elite skips the ASN tracking query.** `OrdersApi.ts` short-circuits the
161
173
  `AdvanceShippingNoticeUnits` lookup for `ELITE` — **that table does not exist in `Client_Elite`**,
162
174
  so issuing it 500s the details fetch.
@@ -188,6 +200,13 @@ byte-identical in behavior** — that is the bar for editing shared UI here (see
188
200
  2026-08-07 prod→beta refresh that wiped the hand-built `service-requests` view.
189
201
 
190
202
  ## Change history
203
+ - 2026-08-18 - Added `ELITE: "Elite"` to `formatCustomerName.tsx`'s `CUSTOMER_MAP` so the
204
+ order-details **Customer** row shows a name instead of a dash. Recorded that this row is
205
+ **hardcoded in the frontend for every host** - the shared `valueKey`
206
+ `"orderBoxDetails.customer.name"` resolves to nothing because `orderBoxDetails` exists nowhere -
207
+ and that reading the real `Customers.name` (Elite: `6312 Elite Eagan US HoldCo LLC`) needs both an
208
+ `apiFields.fetchOrderDetails` addition and a `valueKey` change, left as an open option. Cross-linked
209
+ the new shared toga2-supply doc. (bala)
191
210
  - 2026-08-11 — Order-details modal + service-requests polish: per-host
192
211
  `FIELDS/ELITE/BASEFIELDS.json` (Type row from `salesOrderType.name`, `$` currency character,
193
212
  Elite-worded no-details copy, section flags), export button disabled in `FIELDS/ELITE/BASE.json`,
@@ -1,15 +1,19 @@
1
1
  ---
2
- title: Elite — stale TableView config (11 dead Core.RecordFields across 9 views)
2
+ title: Elite — stale TableView config (11 dead Core.RecordFields across 9 views; 2 now fixed)
3
3
  framework: "2.0"
4
4
  repo: dbchanges2
5
5
  project: Database Changes
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-11
10
- owners: [tcox]
9
+ updated: 2026-08-18
10
+ owners: [tcox, bala]
11
11
  files:
12
12
  - dbchanges2/Client_Elite/
13
+ - dbchanges2/Client_Elite/2026-08-07 - ServiceRequests TableView.sql
14
+ - dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql
15
+ - dbchanges2/Core/2026-08-07 - ServiceRequests TableView.sql
16
+ - dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql
13
17
  - dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql
14
18
  related:
15
19
  - ./supply2-scope.md
@@ -29,9 +33,11 @@ the same stale config**, so it is not a sandbox-only problem.
29
33
  Found while building the [Elite Inventory frontend](./supply2-scope.md): expanding the nested
30
34
  inventory table is the first place a user hits it.
31
35
 
32
- > **⚠ Current state: BROKEN on dev-sandbox.** The hand-fixes below were **applied and then
33
- > reverted** at the developer's request (out of scope for the FE ticket). Only the seeded
34
- > `devteam@togatech.com` user was kept. The fix still has to be shipped as a `dbchanges2` migration.
36
+ > **⚠ Current state (2026-08-17): PARTIALLY FIXED, now as migrations.** The two
37
+ > item-fulfillment views are repaired and the two new views (`service-requests`,
38
+ > `inventory_units`) are built — all shipped as `dbchanges2` files, not hand-inserts. The
39
+ > remaining views in the table below are still stale. See
40
+ > [What shipped 2026-08-07 → 08-17](#what-shipped-2026-08-07--08-17-as-migrations).
35
41
 
36
42
  ## Root pattern
37
43
 
@@ -112,14 +118,115 @@ note, script, or diff that identifies a join by **row id** is worthless after a
112
118
  by **(`tableViewId`, `joinRecordId`, `joinOnRecordFieldId`)** instead — the id columns in the table
113
119
  above are a snapshot for orientation, not a key.
114
120
 
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
121
+ ## What shipped 2026-08-07 -> 08-17 (as migrations)
122
+
123
+ Four table views were built or repaired and shipped as `dbchanges2` files. Nothing here is a
124
+ hand-insert, so the next tenant refresh keeps it.
125
+
126
+ | View | Record | File | What |
127
+ |---|---|---|---|
128
+ | `service-requests` | 35 ServiceRequests | `Core/2026-08-07 - ServiceRequests TableView.sql` + `Client_Elite/2026-08-07 - ...` | new; 5 OUTER joins (Tickets 99, Customers 113, ServiceRequestTypes 36, SalesOrders 14, Locations 11), 9 columns |
129
+ | `inventory_units` | 31 Units | `Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql` | new, **all clients**; Items as the spine, 15 OUTER joins, 13 columns |
130
+ | `item-fulfillments-for-sales-orders` (12) | re-rooted to 29 | `Client_Elite/2026-08-17 - FulfillmentTableViews.sql` | rebuilt |
131
+ | `item-fulfillments-for-sales-order-items` (13) | re-rooted to 29 | same file | rebuilt |
132
+
133
+ **Core side of the `service-requests` view:** recordField `_serialNumbers` (**id 2483 in prod,
134
+ 2766 in dev-sandbox** - the next free Core id differs per environment), `AclActions` id 6 slug
135
+ `navigation-service-requests`, a `Pages` row `service-requests-listing`, 10 `PageRecords` rows, 5
136
+ default labels. **Elite side:** 2 `SalesOrderStatuses` (shipped, delivered), 2
137
+ `AclFieldPermissions`, 1 `AclActionPermissions`, 6 label overrides.
138
+
139
+ **Elite's 4 custom `inventory_units` columns** (indexes 14-17: `grade`, `chargingBrick`,
140
+ `chargingCable`, `wipeStatus` via `c_deviceStatus`) plus their
141
+ `ClientCustomRecordFieldSettings` labels live in the **Elite** file, not the all-clients one -
142
+ a `customRecordFieldId` names a *different field per client*, so it can never go in `Client/`.
143
+
144
+ ### The three faults in the Elite fulfillment views (all verified against local data)
145
+
146
+ Worth reading before porting the same fix to another client - only one of the three is the
147
+ well-known dead-id problem.
148
+
149
+ 1. **Dead Core ids.** The tracking-bridge join used `joinRecordId` **41**, which no longer exists
150
+ in `Core.Records` (ids jump 40 -> 43; 41 and 42 are both gone), plus `parentRecordFieldId`s
151
+ **211** and **321**, neither of which exists in `Core.RecordFields`.
152
+ `TableView::meta` line 87 does `($joinRecord->model)::TABLE`, so a dead record is a **fatal** -
153
+ and it is **not filtered out first**, because meta's join query gates on
154
+ `AclRecordPermissions.recordId` and Elite still holds a permission row for record 41. That is
155
+ why Tracking # and Carrier never rendered.
156
+ 2. **Wrong base record.** Base was Units (31) with an **INNER** join to `ItemFulfillmentItemUnits`,
157
+ so any fulfilled line without unit rows vanished. Elite has **402** `ItemFulfillmentItems` and
158
+ only **126** have units. Both views moved to base record **29** (`ItemFulfillmentItems`) with
159
+ OUTER unit and tracking legs, matching Compass.
160
+ 3. **A table joined to itself on the wrong column.** View 13's first join was
161
+ `ItemFulfillmentItemUnits.id = ItemFulfillmentItemUnits.unitId`. It was also missing the
162
+ Quantity Fulfilled column Compass has.
163
+
164
+ **Proof (order 169441):** before, 7 rows covering lines 1, 2, 3, 6 with no tracking; after, **16
165
+ rows covering all 13 lines** (1-11, 18, 19) with tracking `735453090110` / FedEx. Elite-wide
166
+ visible fulfilled lines went from **126 of 402 to all 402**.
167
+
168
+ **Which tracking bridge Elite uses is a measured decision, not a copy of Compass.** Elite has
169
+ **0** rows in `ItemFulfillmentItems_TrackingNumbers` (318) and **109** in
170
+ `ItemFulfillmentItemUnits_TrackingNumbers` (319), so these views read **319**. Copying Compass's
171
+ 318 join would have produced a structurally-correct view with a permanently empty Tracking #
172
+ column. The per-client counts are in
173
+ [tracking-number-bridges](../../../2.0/apps/_underscore/features/tracking-number-bridges.md).
174
+
175
+ ### Writing the rebuild file - delete order and portability
176
+
177
+ - **Keyed entirely by slug and uuid**, 33 statements, replayed inside a rolled-back transaction
178
+ before shipping.
179
+ - **Delete order matters.** `TableViews.sortPrimaryTableViewFieldId` is a FK to `TableViewFields`,
180
+ so it must be `SET NULL` **before** the fields are deleted; and fields must be deleted **before**
181
+ joins (`TableViewFields.tableViewJoinId` -> `TableViewJoins.id`).
182
+ - **One file serves prod and dev-sandbox with no id split** - every record/field id it uses is
183
+ long-standing Core data. Contrast the `service-requests` Core file, which needed the explicit
184
+ per-environment `_serialNumbers` id.
185
+
186
+ ### The dead-id breakage is systemic - Elite was not special
187
+
188
+ Measuring every client schema found `item-fulfillments-for-sales-orders` /
189
+ `item-fulfillments-for-sales-order-items` still carrying dangling `joinRecordId` **41** and
190
+ dangling `parentRecordFieldId`s **211**, **321** in **ten** clients: Elite (now fixed),
191
+ CompassCanada, Prudential, Nychh, Aig, Growrk, Managelife, Rate, SPGlobal (which has a fourth
192
+ dangling id, **1431**) and True. **Only Compass and Quad are clean**, because those two were
193
+ migrated to base record 29. Details and the per-client fix caveat live in
194
+ [tracking-number-bridges](../../../2.0/apps/_underscore/features/tracking-number-bridges.md).
195
+
196
+ ## Service-requests column headers - SHIPPED, and the context-row plan was WRONG
197
+
198
+ The header fix landed, but **not** the way this doc previously planned. The context-row mapping
199
+ below was authored and then abandoned; **the rows that shipped carry
200
+ `contextRecordFieldId = NULL`.** Three findings replaced it.
201
+
202
+ **1. The page the frontend actually asks for is the one that must carry the records.**
203
+ `Page::meta` builds its field list from `Core.PageRecords` and then filters **every** label and
204
+ ACL lookup on those records, so a record not attached to the page loses its header and falls back
205
+ to the raw slug. `toga2-supply` still requests **`page=sales-orders-listing`** for this view (Elite
206
+ reuses it), so records **99** (Tickets) and **36** (ServiceRequestTypes) had to be attached to
207
+ **that** page, not only to the new `service-requests-listing` page. This was diagnosed the wrong
208
+ way twice; the signal that settled it was the **Network tab showing
209
+ `meta?page=sales-orders-listing`**. Check the request the frontend actually issues before
210
+ reasoning about which page owns a header.
211
+
212
+ **2. A context-scoped label row made things worse, not better.** `PrimaryTable.tsx` takes the
213
+ context branch whenever `contextField` is set and **never falls back** to the plain lookup, so a
214
+ `ClientRecordFieldSettings` row carrying a `contextRecordFieldId` renders the header as its slug
215
+ unless the context genuinely resolves in the meta. **Keep the label rows plain unless the context
216
+ really exists.** This corrects the theory in
120
217
  [legacy page meta](../../../2.0/apps/_underscore/features/page-meta-context-field-settings.md).
121
218
 
122
- Mapping derived for the view (target `Core.RecordFields` id → **context** `Core.RecordFields` id):
219
+ **3. It also 500'd an unrelated page.** A `ClientRecordFieldSettings` row for `recordFieldId`
220
+ **58** (`SalesOrders._total`) with `contextRecordFieldId` **190** broke the **Inventory** page with
221
+ *"Undefined array key 190"* (HTTP 500). `Page.php:824` reads
222
+ `$lookupRecordFieldSlugFromRecordFieldId[$row->contextRecordFieldId]` **unguarded on the client
223
+ path**, while the Core Default path at `Page.php:696` **self-heals** by inserting the missing
224
+ `PageRecords` row. Record field 58 lives on `SalesOrders`, which appears on **six** supply pages -
225
+ so any label override on a widely-shared field can take down a page it was never meant to touch.
226
+ **Fix applied: `contextRecordFieldId = NULL`.**
227
+
228
+ <details>
229
+ <summary>Superseded - the context-row mapping authored 2026-08-11 (kept for history)</summary>
123
230
 
124
231
  | Target field id(s) | Context field id | Context field |
125
232
  |---|---|---|
@@ -128,16 +235,11 @@ Mapping derived for the view (target `Core.RecordFields` id → **context** `Cor
128
235
  | 203 | 192 | `serviceRequestTypeId` |
129
236
  | 48, 175, 58 | 190 | `ServiceRequests.id` |
130
237
  | 28 | 43 | `SalesOrders.locationId` |
131
- | 2766 | — | flat row (base record — **no** context) |
238
+ | 2766 | - | flat row (base record - **no** context) |
132
239
 
133
- **Blocked on the backend, in this order — do not skip ahead:**
240
+ Do not revive this without first re-testing finding 2 above.
134
241
 
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).
242
+ </details>
141
243
 
142
244
  ## Gotchas / known issues
143
245
 
@@ -150,6 +252,21 @@ Mapping derived for the view (target `Core.RecordFields` id → **context** `Cor
150
252
  [error-reporting-issue-event](../../../2.0/apps/_underscore/features/error-reporting-issue-event.md).
151
253
 
152
254
  ## Change history
255
+ - 2026-08-18 - **The Elite table views shipped as migrations, and two of the planned approaches
256
+ turned out to be wrong.** Built the `service-requests` view (record 35, 5 OUTER joins, 9 columns)
257
+ and the all-clients `inventory_units` view (record 31, Items as spine, 15 joins, 13 columns);
258
+ rebuilt Elite's two item-fulfillment views (`Client_Elite/2026-08-17 - FulfillmentTableViews.sql`)
259
+ after finding **three** faults - dead `joinRecordId` 41 / `parentRecordFieldId` 211+321 that
260
+ `TableView::meta` L87 fatals on (not filtered, because meta gates on `AclRecordPermissions` and
261
+ Elite still holds a perm row for 41), a **wrong base record** (Units 31 + INNER unit join hid 276
262
+ of 402 fulfilled lines), and a **self-join on the wrong column** in view 13. Elite reads tracking
263
+ bridge **319**, not Compass's 318 (Elite: 0 rows in 318, 109 in 319). **Corrected the header plan:**
264
+ the shipped `ClientRecordFieldSettings` rows carry `contextRecordFieldId = NULL`, because
265
+ `PrimaryTable.tsx` takes the context branch whenever `contextField` is set and never falls back,
266
+ and because a context row on the widely-shared field 58 500'd the unrelated Inventory page
267
+ (`Page.php:824` unguarded on the client path; `Page.php:696` self-heals on the Core path). The
268
+ records had to be attached to **`sales-orders-listing`** - the page the frontend actually requests.
269
+ Also measured the dead-id breakage across **ten** clients (only Compass and Quad are clean). (bala)
153
270
  - 2026-08-11 — Added the authored **context-row mapping for the `service-requests` column headers**
154
271
  (target → context `Core.RecordFields` ids; 2766 is flat/base-record) and the mandatory backend
155
272
  ordering it is blocked behind: `Page.php` `isset()` guard → `Core.PageRecords` additions on
@@ -15,7 +15,7 @@ project: Worker
15
15
  client: elite
16
16
  type: profile
17
17
  status: active
18
- updated: 2026-08-13
18
+ updated: 2026-08-18
19
19
  owners: [snaredla, apeterson, tcox, bala]
20
20
  files:
21
21
  - worker2/Worker/Elite.php
@@ -46,8 +46,10 @@ keeps Freshservice and TOGA 2 in sync via webhook-driven worker jobs.
46
46
 
47
47
  **Elite is also being onboarded to TOGa Supply (2026-08).** Both frontend pages — **Inventory** and
48
48
  **Service Requests** (the Orders page host-retitled, not a new route) — are built and live-verified
49
- on beta as of 2026-08-10; see [supply2-scope](features/supply2-scope.md). The backend blocker (stale
50
- `Client_Elite` TableView config, a production go-live risk) is in
49
+ on beta as of 2026-08-10; see [supply2-scope](features/supply2-scope.md). The backend table views
50
+ are now **shipped as `dbchanges2` migrations** (2026-08-07 -> 08-17): the new `service-requests` and
51
+ all-clients `inventory_units` views, and a rebuild of Elite's two item-fulfillment views. **The
52
+ remaining stale views are still an open production go-live risk** - see
51
53
  [supply2-tableview-config-drift](features/supply2-tableview-config-drift.md). On the backend,
52
54
  dbchanges2 **PR #454** (TRUE-80499, snaredla24, 2026-08-05) added the **netsuite** module to
53
55
  `Client_Elite/_modules.txt` — the supply-chain schema layer is arriving.
@@ -58,6 +60,11 @@ orders, 201 item fulfillments, 153 items, 229 units) — this is what adds **`wo
58
60
  Elite's app scope. Elite-specific config, the still-owed `Countries` cleanup, and the two active
59
61
  `Apis` rows hazard are in [netsuite-togasupply-sync](features/netsuite-togasupply-sync.md).
60
62
 
63
+ **Elite order status is its own six-status ladder.** `_Model_Elite_SalesOrder::_status` emits
64
+ pendingFulfillment / partiallyFulfilled / fulfilled / shipped / delivered / canceled. **Elite has no
65
+ approval model**, so the Compass status logic does not transfer - see
66
+ [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
67
+
61
68
  ## Shipping rules (confirmed with Elite by Bala, 2026-08-12)
62
69
 
63
70
  Two client rules that constrain every order-creation surface (the desk Service Request modal, the
@@ -14,7 +14,7 @@ project: _Underscore
14
14
  client: quad
15
15
  type: profile
16
16
  status: active
17
- updated: 2026-08-06
17
+ updated: 2026-08-18
18
18
  owners: ["jcardinal", "bala", "apeterson", "ajean"]
19
19
  files: []
20
20
  related:
@@ -70,8 +70,16 @@ Client-specific DB change-sets live in `dbchanges2/Client_Quad/`.
70
70
  client role 1, id-agnostic + NOT-EXISTS-guarded, beta only). Quad exposes surfaces to role 1 only,
71
71
  vs Compass's 1/3/4 — revisit if broader roles are needed. See
72
72
  [Surface Resolver](../../2.0/apps/_underscore/features/surface-resolver.md).
73
- - Tracking data: record 318 (item-level) is currently empty for this client; their tracking
74
- populates the IF/shipment level (record 317, ~98% coverage) and unit level (record 319). The
75
- rebuilt views use 318 (per the Compass pattern) and will show tracking once item-level
76
- tracking is backfilled.
73
+ - **LIVE BUG (found 2026-08-17, not fixed): Quad's Tracking # column is empty today.** The two
74
+ fulfillment views are a direct copy of Compass and read the **item-level bridge 318**, but a
75
+ fresh count shows `Client_Quad` has **0** rows in 318 and **633** in the **unit-level bridge
76
+ 319**. The views are structurally correct and will never show a tracking number until either they
77
+ are repointed to 319 or 318 is backfilled. This supersedes the earlier note that Quad's tracking
78
+ "lives at 317 (~98% coverage)". Repointing is the same shape as the Elite fix
79
+ (`dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql`) - **but choose the bridge from
80
+ Quad's own row counts, not from Compass**. See
81
+ [Tracking-Number Bridge Migration](../../2.0/apps/_underscore/features/tracking-number-bridges.md).
82
+ - Quad is one of only **two** clients (with Compass) whose fulfillment views are free of the
83
+ dangling `joinRecordId` 41 / `parentRecordFieldId` 211+321 config, because both were migrated to
84
+ base record 29. Ten other clients still carry it.
77
85
  - This profile is a starting point; expand as more Quad-specific behavior is captured.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.600",
3
+ "version": "1.0.601",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",