toga-ai 1.0.651 → 1.0.653

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.
Files changed (22) hide show
  1. package/knowledge/2.0/apps/_underscore/INDEX.md +5 -5
  2. package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +55 -1
  3. package/knowledge/2.0/apps/_underscore/features/calculated-sql-fields.md +65 -2
  4. package/knowledge/2.0/apps/_underscore/features/netsuite-salesorder-address-phone-sync.md +20 -2
  5. package/knowledge/2.0/apps/_underscore/features/page-meta-context-field-settings.md +49 -1
  6. package/knowledge/2.0/apps/_underscore/features/sales-order-po-number-sourcing.md +71 -8
  7. package/knowledge/2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md +72 -7
  8. package/knowledge/2.0/apps/api2/INDEX.md +1 -1
  9. package/knowledge/2.0/apps/api2/features/nested-relationship-writes.md +25 -1
  10. package/knowledge/2.0/apps/api2/features/tableview-field-metadata.md +47 -1
  11. package/knowledge/2.0/apps/toga2-supply/INDEX.md +3 -1
  12. package/knowledge/2.0/apps/toga2-supply/features/currency-amount-lines-editor.md +83 -0
  13. package/knowledge/2.0/apps/toga2-supply/features/order-detail-field-config-and-customer-name.md +57 -1
  14. package/knowledge/2.0/apps/toga2-supply/features/table-data-additional-filters.md +92 -0
  15. package/knowledge/INDEX.md +2 -2
  16. package/knowledge/clients/nychh/INDEX.md +1 -1
  17. package/knowledge/clients/nychh/features/po-number-upstream-direction.md +37 -10
  18. package/knowledge/clients/nychh/profile.md +4 -1
  19. package/knowledge/clients/quad/INDEX.md +1 -0
  20. package/knowledge/clients/quad/features/multi-currency-item-pricing.md +139 -0
  21. package/knowledge/clients/quad/profile.md +16 -1
  22. package/package.json +1 -1
@@ -4,13 +4,13 @@
4
4
  |-----|---------|-------|
5
5
  | [Proposed — git-sourced base+overlay JSON authoring for the Surface layer](architecture/surface-authoring-proposal.md) | A **proposal / handoff recommendation** (not implemented) that the Surface layer's *authoring* model move off hand-authored SQL against the `SurfaceOverrides` E | _underscore/Model/Core/Surface.php, _underscore/Model/Client/SurfaceOverride.php |
6
6
  | [_underscore Framework Architecture](architecture.md) | `_underscore` is the shared PHP backend framework for **all 2.0 applications**. | _underscore/_underscore.php, _underscore/Loader.php, _underscore/Framework.php, _underscore/Model.php, _underscore/Database.php, _underscore/Query.php, _underscore/Route.php, _underscore/Component.php |
7
- | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql |
7
+ | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql |
8
8
  | [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | When a business rule says *"only one X per physical address"*, comparing address rows field-for-field does **not** work: the same dwelling is spelled many diffe | _underscore/Model/Rate/Entitlement.php |
9
9
  | [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php |
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, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/ServiceRequest.php, _underscore/Model/Elite/SalesOrder.php, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql |
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, _underscore/Model/Quad/Item.php, _underscore/Model/Quad/VendorItem.php, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql, dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.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/Component/Library/Carriers/Usps/Usps.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 |
@@ -32,13 +32,13 @@
32
32
  | [_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 |
33
33
  | [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, _underscore/Trait/Netsuite/SalesOrder.php, worker2/Worker/Netsuite/SalesOrder.php, worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Worker/Netsuite/Opportunity.php |
34
34
  | [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 |
35
- | [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
+ | [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, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx |
36
36
  | [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 |
37
37
  | [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 |
38
38
  | [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 |
39
39
  | [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql, dbchanges2/Client_Compass/2026-08-18 - FixSA135471HeroItemHalfQuantityFulfillment.sql |
40
- | [Sales-order \"PO Number\" sourcing — one surface element, `_purchaseOrders`, tenant SQL underneath](features/sales-order-po-number-sourcing.md) | The sales-order modal's **PO Number** field is **not** tenant-split at the display layer. | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts, dbchanges2/Core/2026-08-25a - PoNumberDetailFieldPurchaseOrdersValueKey.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql, dbchanges2/Client_Quad/2026-08-19a - PoDetailsButtonMostRecentPoSelection.sql |
41
- | [SO↔PO bridge tables are TWO tables in OPPOSITE directions (upstream vs downstream)](features/sales-order-purchase-order-bridge-direction.md) | There are **two** bridge tables linking sales orders and purchase orders, and they mean **opposite things**. | _underscore/Model/Client/PurchaseOrders/SalesOrder.php, _underscore/Model/Client/SalesOrders/PurchaseOrder.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/SalesOrder.php |
40
+ | [Sales-order \"PO Number\" sourcing — one surface element, `_purchaseOrders`, tenant SQL underneath](features/sales-order-po-number-sourcing.md) | The sales-order modal's **PO Number** field is **not** tenant-split at the display layer. | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/Canada/SalesOrder.php, dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts, dbchanges2/Core/2026-08-25a - PoNumberDetailFieldPurchaseOrdersValueKey.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql, dbchanges2/Client_Quad/2026-08-19a - PoDetailsButtonMostRecentPoSelection.sql |
41
+ | [SO↔PO bridge tables are TWO tables in OPPOSITE directions (upstream vs downstream)](features/sales-order-purchase-order-bridge-direction.md) | There are **two** bridge tables linking sales orders and purchase orders, and they mean **opposite things**. | _underscore/Model/Client/PurchaseOrders/SalesOrder.php, _underscore/Model/Client/SalesOrders/PurchaseOrder.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/SalesOrder.php, _underscore/Trait/Netsuite/SalesOrder.php, library/app/api/toga2.php |
42
42
  | [Per-client sales-order status filter (Surface FILTER_SET → table meta `filterOptions`)](features/sales-order-status-filter-surface.md) | The status-filter dropdown on the sales-orders table is **per client**, driven by a Surface `FILTER_SET` rather than by the raw contents of the client's `SalesO | _underscore/Model/Client/TableView.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Prudential/SalesOrder.php, toga-blox/src/components/Table/hooks/useFetchTablePageMeta.ts, toga-blox/src/api/types.ts, dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_CompassCanada/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql |
43
43
  | [_String helpers — ASCII-safe HTML entity encoding (and the parseBetween trap)](features/string-html-entity-helpers.md) | `_String` is the 2.0 framework's static string utility class. | _underscore/String.php |
44
44
  | [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/Client/Language.php, dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.sql, dbchanges2/Client_Compass/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_CompassCanada/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, toga25-supply/src/App.tsx, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/contexts/AuthContext.tsx, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Core/2026-08-21a - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_Compass/2026-08-04a - ApprovalDetailsAssignedManagerPreferredStage.sql, _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 |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-26
10
10
  owners: ["jcardinal", "mhammontree", "tcox", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -16,6 +16,7 @@ files:
16
16
  - dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql
17
17
  - dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql
18
18
  - dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql
19
+ - dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql
19
20
  ---
20
21
 
21
22
  ## Summary
@@ -129,6 +130,16 @@ nothing:
129
130
  | **Standard** (e.g. `serviceAddressId`) | `Core.RecordFields` | **`AclFieldPermissions`** | `Core.RecordFields` id |
130
131
  | **Custom** (`c_` prefix) | `CustomRecordFields` | **`AclCustomFieldPermissions`** | `CustomRecordFields` id |
131
132
 
133
+ **A `CustomRecordFields` row is not always a `c_` field, and a presentation-only override needs NO
134
+ grant.** A client may register a `CustomRecordFields` row that deliberately **reuses an existing
135
+ Core field's name** purely to override that field's `type` for one client (the only client-scoped
136
+ way to change a table-view column's rendering type — see
137
+ [TableView field/column metadata](../../api2/features/tableview-field-metadata.md#per-client-type-override)).
138
+ In that case authorization still resolves through the Core field's `AclFieldPermissions` row for
139
+ that name, so **do not** add an `AclCustomFieldPermissions` row: it is unnecessary, and it would let
140
+ the column keep working after the Core grant is revoked. Verified on Quad's `_unitPrice` / `_unitCost`
141
+ (2026-08-26).
142
+
132
143
  **Promoting a custom field to a standard field moves its grant to the other table.** When
133
144
  `c_serviceAddressId` was promoted to the standard `serviceAddressId` (Rate WH guard, TRUE-79533),
134
145
  its `CustomRecordFields` row was deleted, so the pending `AclCustomFieldPermissions` grant
@@ -204,6 +215,39 @@ then checks the caller's roles against `AclRecordPermissions` (→ `EZ-1` if no
204
215
  readable/writable fields from `AclFieldPermissions`. `_Model_Core_Page::meta()` reads the same
205
216
  tables to return per-page ACL to the frontend.
206
217
 
218
+ ## Navigation / action flags (`Core.AclActions` + `AclActionPermissions`) — a THIRD gate
219
+
220
+ A UI control that is not a record CRUD operation and not a scripted API — a nav item, an "Add" or
221
+ "Edit" button in `toga2-supply` — is gated by an **action flag** that the frontend reads out of the
222
+ page meta as `acl['<page-route>']['<action-slug>']`. Two tables:
223
+
224
+ - **`Core.AclActions`** — the flag itself: `recordId` + `slug`. Example: on record **19**
225
+ (`VendorItems`), id **5** = `navigation-vendor-items` (can the page be reached) and id **7** =
226
+ `navigation-edit-on-vendor-items` (can rows be added/edited).
227
+ - **`Client_*.AclActionPermissions`** — `(aclActionId, roleId)`: the grant.
228
+
229
+ `_Model_Core_Page::meta()` (~L1285–1302) **initialises every action flag on the page to `false`** and
230
+ flips it to `true` only when an `AclActionPermissions` row matches one of the caller's roles. So a
231
+ missing row is not an error anywhere — the button is simply not rendered.
232
+
233
+ **⚠ This gate is INDEPENDENT of the CRUD chain, which is what makes it hard to diagnose.** The page
234
+ meta can report `_CREATE` / `_UPDATE` / `_DELETE` `true` (because `AclRecordPermissions` grants them)
235
+ while the button that would invoke them is hidden, so every permission check you run says
236
+ "permissions are fine" and it reads as a frontend bug.
237
+
238
+ **Recipe when a control is missing for one client:** find the exact flag slug the component reads
239
+ (both the Add and the Edit control commonly read the *same* flag), then diff
240
+ `AclActionPermissions` against a client where the control works, and grant the missing
241
+ `(aclActionId, roleId)` in a `dbchanges2` migration.
242
+
243
+ Worked example (Quad, 2026-08-26): the vendor-items **Add and Edit buttons both** read
244
+ `acl['vendor-items']['navigation-edit-on-vendor-items']` (AclActions id 7). `Client_Quad` had a row
245
+ for id 5 only, so the page was reachable but button-less, even though record 19 already granted role
246
+ 1 — held by every Quad user — full CRUD. Prod comparison: Compass grants 5 → roles 9,11 and 7 → role
247
+ 16; Compass Canada grants 5 and 7 both → role 10. Quad followed Compass Canada and granted id 7 to
248
+ role 7 (Global Admin). Because this is only a UI gate, the fix is purely additive and cannot widen
249
+ data access.
250
+
207
251
  ## Record-script dispatch grant (`AclRecordScripts`) — a separate gate
208
252
 
209
253
  A **scripted API** (Record Script) has its own ACL gate, **distinct from the four-table record-CRUD
@@ -359,6 +403,16 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
359
403
  and every repo is on the **same branch** so the generated model matches the DB.
360
404
 
361
405
  ## Change history
406
+ - 2026-08-26 - Added the **action-flag gate** (`Core.AclActions` + `Client_*.AclActionPermissions`),
407
+ a third gate alongside record CRUD and record scripts: `Page.php` (~L1285-1302) defaults every
408
+ action flag to `false` and only a matching `AclActionPermissions` row flips it true, so a missing
409
+ row silently hides a nav item or an Add/Edit button while the page meta still reports
410
+ `_CREATE/_UPDATE/_DELETE` true. Worked example: Quad's vendor-items Add **and** Edit both read
411
+ `navigation-edit-on-vendor-items` (AclActions id 7 on record 19); Quad had id 5 only, fixed by
412
+ granting id 7 to role 7 following Compass Canada. Also noted that a `CustomRecordFields` row which
413
+ reuses a Core field's name to override its **type** for one client must **not** get an
414
+ `AclCustomFieldPermissions` row - the Core grant governs, and an extra row would survive a revoke.
415
+ (bala)
362
416
  - 2026-08-18 - Caveated "explicitly seeded and identical in every environment": true for
363
417
  established `Core.RecordFields` rows, but the **next free id differs per environment** (a new
364
418
  field took 2483 in prod, 2766 in dev-sandbox), so a newly created row may need a per-environment
@@ -6,20 +6,24 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-26
10
10
  owners: [snaredla, jcardinal, bala]
11
11
  files:
12
12
  - _underscore/Model.php
13
13
  - _underscore/Model/Client/SalesOrder.php
14
14
  - _underscore/Model/Client/ServiceRequest.php
15
15
  - _underscore/Model/Elite/SalesOrder.php
16
+ - _underscore/Model/Quad/Item.php
17
+ - _underscore/Model/Quad/VendorItem.php
16
18
  - dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql
19
+ - dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql
17
20
  related:
18
21
  - ../architecture.md
19
22
  - ./acl-permission-chain.md
20
23
  - ../../api2/features/v2-api-error-codes.md
21
24
  - ./model-magic-field-access.md
22
25
  - ./model-save-parent-cascade-stored-field-deadlock.md
26
+ - ./sales-order-purchase-order-bridge-direction.md
23
27
  ---
24
28
 
25
29
  ## Summary
@@ -56,6 +60,35 @@ fallback — `_serialNumbers` the property requires `_serialNumbers()` the metho
56
60
  Options: `FIELDOPT_SQL_TYPE` (defaults to `FIELD_CHAR`) and `FIELDOPT_SQL_STORED` (default `false`,
57
61
  recalculated per query; `true` persists to a real column on save).
58
62
 
63
+ ### ⚠ `FIELDOPT_SQL_TYPE` casts the returned VALUE — a TEXT-returning field must be plain `FIELD_SQL`
64
+
65
+ `FIELDOPT_SQL_TYPE` is **not** only a hint for ordering/pagination cursors: it rewrites the value
66
+ the API returns. `getSqlFieldValue()` (`Model.php` ~L509–520) reads the option off the field
67
+ definition and pipes the fetched value through `formatValueForField()` (~L728), whose `switch`
68
+ **casts**:
69
+
70
+ | `FIELDOPT_SQL_TYPE` | what happens to the value |
71
+ |---|---|
72
+ | `FIELD_DECIMAL` | `(float) $value` |
73
+ | `FIELD_INTEGER` / `FIELD_FOREIGNKEY` / `FIELD_PRIMARYKEY` | `(int) $value` |
74
+ | `FIELD_BOOLEAN` | `null` or a real bool |
75
+ | **omitted** (defaults to `FIELD_CHAR`) | **nothing — there is no `case self::FIELD_CHAR`, so the value passes through untouched** |
76
+
77
+ So a calculated field that returns text but is declared
78
+ `[self::FIELD_SQL, self::FIELDOPT_SQL_TYPE => self::FIELD_DECIMAL]` is **silently zeroed**:
79
+ `(float) 'PLN 5295.00'` is `0`. The symptom — api2 returning `_unitPrice: 0` and the grid rendering
80
+ an em dash — points nowhere near the declaration, and the SQL is right there returning the correct
81
+ string when run by hand.
82
+
83
+ **Rule: if the expression can return text — a formatted amount, a `GROUP_CONCAT`, a status word —
84
+ declare the field as plain `self::FIELD_SQL` with NO `FIELDOPT_SQL_TYPE`.** `_priceType` is the
85
+ in-tree precedent for a text-returning calculated field. Only add the option when the expression
86
+ really does return a number of that type.
87
+
88
+ Verified 2026-08-26 on `_Model_Quad_Item::_unitPrice`, which returns a `GROUP_CONCAT` of
89
+ `'CODE amount'` pairs across a Quad item's currencies (see
90
+ [Quad multi-currency item pricing](../../../../clients/quad/features/multi-currency-item-pricing.md)).
91
+
59
92
  ## Exposing a calculated field to the API - a client `CustomRecordFields` row is enough
60
93
 
61
94
  **Declaring the field on the model does not make it requestable.** A client whose DB has no
@@ -88,6 +121,21 @@ first pass and nothing on the second; against `Client_Compass` it inserted nothi
88
121
  id 77 and its role 1/3/4 grants untouched. Prudential gains the field across **26,280**
89
122
  `SalesOrders_PurchaseOrders` rows.
90
123
 
124
+ **⚠ Idempotent is not the same as applied — audit every schema afterwards.** A `Client/`-scoped
125
+ migration is run per client DB by hand, so a single missed schema leaves that client with `EV-8`
126
+ forever and nobody notices until a user reports a blank field. Re-auditing `_purchaseOrders` two
127
+ weeks after the 2026-08-11b rollout found it **missing on `Client_Imhouston` (1 of 35) on prod and
128
+ on 7 of 36 client-sandbox schemas** (Aig, Browardsheriff, Canon, GroWrk, Imhouston, Masonite,
129
+ Trividiahelth). The re-runnable follow-up is
130
+ `Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql`. Query
131
+ `information_schema` / `CustomRecordFields` across every schema rather than trusting that the
132
+ original change-set was run everywhere. Watch for near-duplicate schemas while you audit —
133
+ `Client_Trividiahelth` is a **misspelled duplicate** that exists only on client-sandbox.
134
+
135
+ The ACL convention for these rows: grants go to the **`Base` and `API` roles only, resolved BY
136
+ NAME**. Most other `SalesOrders` custom fields carry `Base` alone, so `Base + API` is already the
137
+ generous end; `Developer` and `Public` get no ACL row for any field.
138
+
91
139
  ## Gotchas / known issues
92
140
 
93
141
  - **⚠ Never add a parameter TYPE to an override whose parent declares the parameter untyped - it
@@ -125,7 +173,22 @@ id 77 and its role 1/3/4 grants untouched. Prudential gains the field across **2
125
173
  [save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
126
174
 
127
175
  ## Change history
128
-
176
+ - 2026-08-26 - **`FIELDOPT_SQL_TYPE` casts the returned VALUE, not just sort/pagination cursors.**
177
+ `getSqlFieldValue()` (`Model.php` ~L509-520) hands the fetched value to `formatValueForField()`
178
+ (~L728), which `(float)`s a `FIELD_DECIMAL` field - so a text-returning calculated field declared
179
+ `FIELDOPT_SQL_TYPE => FIELD_DECIMAL` is silently zeroed (`(float) 'PLN 5295.00'` = 0, seen as
180
+ `_unitPrice: 0` + an em dash in the grid). A text-returning field must be plain `self::FIELD_SQL`:
181
+ the option defaults to `FIELD_CHAR`, which has **no case** in the switch, so the value passes
182
+ through. `_priceType` is the precedent. Verified on `_Model_Quad_Item::_unitPrice`. (bala)
183
+
184
+ - 2026-08-26 - Added the **audit-after-rollout** rule: an idempotent `Client/`-scoped migration is
185
+ still run per schema by hand, and re-auditing `_purchaseOrders` found it missing on
186
+ `Client_Imhouston` (1 of 35 prod) and 7 of 36 client-sandbox schemas, driving the re-runnable
187
+ `Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql`. Recorded the `Base` + `API`
188
+ by-name ACL convention for these rows. Cross-linked
189
+ [PO-to-SO bridge tables](./sales-order-purchase-order-bridge-direction.md) — the metadata row makes
190
+ `_purchaseOrders` *requestable*, but the field's SQL was reading the wrong bridge table, so a
191
+ successful rollout still returned blank for most clients. (bala)
129
192
  - 2026-08-18 - Documented **how a calculated field becomes requestable**: a client-scoped
130
193
  `CustomRecordFields` row is sufficient and **no `Core.RecordFields` row is needed** (Compass's
131
194
  `_purchaseOrders` = id 77, with `_totalLease` 81 / `_trackingNumbers` 83, has no Core row and
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-11
10
- owners: ["mhammontree"]
9
+ updated: 2026-08-26
10
+ owners: ["mhammontree", "bala"]
11
11
  files:
12
12
  - _underscore/Trait/Netsuite/SalesOrder.php
13
13
  - _underscore/Model.php
@@ -16,6 +16,7 @@ related:
16
16
  - acl-permission-chain.md
17
17
  - address-uniqueness-normalization.md
18
18
  - carrier-shipping-labels.md
19
+ - sales-order-purchase-order-bridge-direction.md
19
20
  - ../../toga2-supply/features/fulfill-and-ship.md
20
21
  - ../../api2/features/record-scripts.md
21
22
  ---
@@ -40,6 +41,17 @@ changes behaviour for all 22 clients at once**.
40
41
  (and was NULL on the order tested), while the **bridge covers 12,643 of 12,658** orders. Resolve the
41
42
  PO through the bridge, not the column.
42
43
 
44
+ **⚠ …and it is the UPSTREAM bridge, which the shared `SalesOrders._purchaseOrders` field did not
45
+ read.** This trait writes the PO link via the api2 child key **`purchaseOrderSalesOrders`**, landing
46
+ in **`PurchaseOrders_SalesOrders`** (upstream — the *customer's* PO). The base calculated field
47
+ `_Model_Client_SalesOrder::_purchaseOrders()` queried **`SalesOrders_PurchaseOrders`** (downstream),
48
+ a *different* table populated by a *different* pipeline — the 1.0
49
+ `App_Api_Toga2::syncPurchaseOrderFromNetsuite` cron — holding Agilant's **vendor** POs. So every
50
+ client on this trait stored a correct PO the field could not see: **41,381 of 72,134** prod orders
51
+ rendered blank until a downstream-then-upstream `COALESCE` was added. Read
52
+ [SO↔PO bridge direction](./sales-order-purchase-order-bridge-direction.md) **before** touching either
53
+ side of this link — the child key you choose decides which table you write, silently.
54
+
43
55
  ## ⚠ Four traps when writing a field on this shared trait
44
56
 
45
57
  1. **Normalise a phone with `_String::cleanPhoneNumber()` on the way INTO the payload.**
@@ -90,6 +102,12 @@ intended, not a fault.
90
102
 
91
103
  ## Change history
92
104
 
105
+ - 2026-08-26 — Corrected the PO half of the "where the values come from" table: the
106
+ `purchaseOrderSalesOrders` child key this trait posts writes the **upstream** bridge
107
+ (`PurchaseOrders_SalesOrders`, customer PO), while the shared `SalesOrders._purchaseOrders`
108
+ calculated field read the **downstream** bridge (`SalesOrders_PurchaseOrders`, Agilant vendor PO) — so
109
+ all 22 trait clients stored a PO the UI could not display (41,381 of 72,134 prod orders blank).
110
+ Cross-linked [SO↔PO bridge direction](./sales-order-purchase-order-bridge-direction.md). (bala)
93
111
  - 2026-08-11 — TRUE-80824: created. Prefilled TOGa Supply's Reference 1 / Reference 2 / Phone # from
94
112
  NetSuite by sourcing `SalesOrders.number`, the bridge-linked purchase order (`otherRefNum` — **not**
95
113
  `customerPurchaseOrder`, ~59% populated vs the bridge's 12,643/12,658), and the shipping address's
@@ -6,12 +6,13 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-26
10
10
  owners: [tcox, bala]
11
11
  files:
12
12
  - _underscore/Model/Core/Page.php
13
13
  - _underscore/Model/Client/TableView.php
14
14
  - toga2-supply/src/components/ui/Tables/PrimaryTable/PrimaryTable.tsx
15
+ - toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx
15
16
  related:
16
17
  - surface-resolver.md
17
18
  - ../../api2/features/tableview-field-metadata.md
@@ -36,6 +37,43 @@ bug that takes down *unrelated* pages for any client that uses the feature.
36
37
  Example (Elite): row id 1 relabels field **58** to **"Total"** in the context of field **190**
37
38
  (`ServiceRequests.id`) — authored for the `service-requests` view.
38
39
 
40
+ ## Column TYPE and column LABEL come from TWO DIFFERENT endpoints
41
+
42
+ Before chasing a grid column's appearance, know which endpoint owns the thing you want to change —
43
+ they never influence each other:
44
+
45
+ | What you see | Source endpoint | Payload path | Consumer (`toga2-supply`) |
46
+ |---|---|---|---|
47
+ | the column's **type / formatting** (currency icon, number/decimal rendering) | `/table-views/meta` | `table.fields[].type` | `generateDynamicConfig` → `formatTableData.tsx` |
48
+ | the column's **header text** | `/pages/meta` | `settings.fields[<record route>][<field>]['label-singular']` | `PrimaryTable.convertColumnTitles` |
49
+
50
+ So a currency/dollar icon on a column is a **type** problem (fix the field's `type` — Core
51
+ `RecordFields`, or a client-scoped override, see
52
+ [TableView field/column metadata](../../api2/features/tableview-field-metadata.md#per-client-type-override)),
53
+ and a wrong header is a **settings-row** problem. Adding a label row will never change the icon,
54
+ and retyping the field will never change the header.
55
+
56
+ ### ⚠ A CUSTOM record field's label row is UNREACHABLE from the grid header
57
+
58
+ `Page.php` emits `ClientCustomRecordFieldSettings` in its own block (~L900–935) and keys it by a
59
+ record route resolved through `$lookupRecordIdFromCustomRecordFieldId` →
60
+ `$lookupRecordFromRecordId[...]->route`. In practice, for a column pointed at a
61
+ `CustomRecordFields` row, that resolves to an **empty** record-route key — the row lands at
62
+ `settings.fields[""][<field>][…]`, a bucket `convertColumnTitles` never reads. **A
63
+ `ClientCustomRecordFieldSettings` `label-singular` row for a custom column is therefore dead
64
+ metadata**, and no amount of adding/correcting those rows will move the header. (Measured
65
+ 2026-08-26: two hours lost adding label rows that could never be read.)
66
+
67
+ Where the label must come from instead, for such a column:
68
+
69
+ - **`Core.DefaultRecordFieldSettings`** on the underlying **Core** `RecordField`, or
70
+ - **`ClientRecordFieldSettings`** when the column points at a real `RecordField`,
71
+
72
+ in both cases with **`contextRecordFieldId` and `contextCustomRecordFieldId` NULL** — the
73
+ context-free branch at `Page.php` ~L831 is what writes
74
+ `settings.fields[record][field][setting]`, the exact path the header reads. This is the same
75
+ "keep label rows plain" conclusion as the correction below, reached from the other direction.
76
+
39
77
  ## Why this matters: table-view **column headers** for joined columns need context rows
40
78
 
41
79
  This is the mechanism behind "why is my column header a camelCase slug?" — and the answer is
@@ -143,6 +181,16 @@ data change, is what makes it fatal.
143
181
  customization. The guard belongs in the framework.
144
182
 
145
183
  ## Change history
184
+ - 2026-08-26 - Documented that a grid column's **type** and its **header label** come from two
185
+ different endpoints: type/formatting (the currency icon) from `/table-views/meta`
186
+ `table.fields[].type` via `generateDynamicConfig` → `formatTableData`, header text from
187
+ `/pages/meta` `settings.fields[<record route>][<field>]['label-singular']` via
188
+ `PrimaryTable.convertColumnTitles`. Added the trap that a **custom** record field's settings rows
189
+ are emitted in `Page.php`'s separate `ClientCustomRecordFieldSettings` block (~L900-935) under an
190
+ **empty** record-route key, so a `ClientCustomRecordFieldSettings` label row for a custom column is
191
+ never read - the label must come from `Core.DefaultRecordFieldSettings` on the underlying Core
192
+ RecordField (or `ClientRecordFieldSettings` when the column points at a real RecordField), with
193
+ both context columns NULL. (bala)
146
194
  - 2026-08-18 - **Corrected the "joined columns need a context row" guidance.** `PrimaryTable.tsx`
147
195
  takes the context branch whenever `contextField` is set and **never falls back** to the plain
148
196
  lookup, so a context row whose context does not resolve in the meta leaves the header as its raw
@@ -6,11 +6,13 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-25
10
- owners: [apeterson]
9
+ updated: 2026-08-26
10
+ owners: [apeterson, bala]
11
11
  files:
12
12
  - _underscore/Model/Client/SalesOrder.php
13
13
  - _underscore/Model/Compass/SalesOrder.php
14
+ - _underscore/Model/Compass/Canada/SalesOrder.php
15
+ - dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql
14
16
  - toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts
15
17
  - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts
16
18
  - dbchanges2/Core/2026-08-25a - PoNumberDetailFieldPurchaseOrdersValueKey.sql
@@ -50,12 +52,53 @@ deliberately **not** set: the value is a scalar comma-joined string, not a perso
50
52
  via `resolvePathInsensitive(valueKey)`, normalising an empty string to an em-dash.
51
53
 
52
54
  ### The tenant layer (`_purchaseOrders`)
55
+
56
+ **Only TWO classes in the whole tree define this method** — verified with
57
+ `grep -rn "function _purchaseOrders" _underscore/`:
58
+
53
59
  | Tenant | Implementation | Direction read |
54
60
  |---|---|---|
55
- | **Compass USA** | overrides in `_underscore/Model/Compass/SalesOrder.php:238` — a `UNION ALL` of its own vendor POs plus Office Depot's POs traversed SO→PO→SO→PO across the multi-tier chain | downstream **+ upstream** (only tenant reading upstream) |
56
- | **Quad Graphics** | no override → base implementation | downstream only |
57
- | **Compass Canada** | no `Model/Compasscanada/` directory exists at all → base implementation | downstream only |
58
- | base (`_Model_Client_SalesOrder:348`) | the shared calculated field | **downstream only** (`SalesOrders_PurchaseOrders`) |
61
+ | **Compass USA and Compass Canada** | both inherit `_Model_Compass_SalesOrder::_purchaseOrders()` (`_underscore/Model/Compass/SalesOrder.php:238`) — a `UNION ALL` of its own vendor POs plus Office Depot's POs traversed SO→PO→SO→PO across the multi-tier chain | downstream **+ upstream** |
62
+ | **Quad Graphics** | no override → base implementation | downstream, then upstream fallback |
63
+ | base (`_Model_Client_SalesOrder:349`) | the shared calculated field | **downstream, falling back to upstream** (see below) |
64
+
65
+ **⚠ Correction (2026-08-26): Compass Canada is not on the base implementation.** The earlier "no
66
+ `Model/Compasscanada/` directory" reading looked in the wrong place — Compass's tenants nest one
67
+ level deeper, at `_underscore/Model/Compass/Canada/SalesOrder.php`, where
68
+ `_Model_Compass_Canada_SalesOrder extends _Model_Compass_SalesOrder`. Compass USA is
69
+ `Model/Compass/Usa/SalesOrder.php` and does the same. So **any change to the Compass override moves
70
+ both Compass tenants**, and neither is affected by a change to the base. Compass Canada's upstream
71
+ bridge is in fact **empty** (0 rows; 735 of 788 orders resolve downstream) — it is downstream-only,
72
+ not upstream-only.
73
+
74
+ ### The base field now falls back to upstream (`COALESCE`)
75
+
76
+ Because the trait-based NetSuite sync writes **upstream** while the base field read **downstream**,
77
+ 41,381 of 72,134 prod orders across the 19 base-model tenants resolved no PO at all. The base
78
+ `_purchaseOrders()` is now a **`COALESCE` of two scalar subqueries** — downstream first, upstream as
79
+ the fallback — which fixes every upstream tenant at once and needed **no per-tenant override**.
80
+
81
+ Branch state as verified **2026-08-26** (do not assume this is live):
82
+
83
+ | Repo | Change | Where it lives |
84
+ |---|---|---|
85
+ | `_underscore` | the `COALESCE` fallback | commit `4579e0e7` on `TRUE-81284`, merged into **`_beta`**, `_sandbox-client`, `_sandbox-dev`. **Not on `_production`.** |
86
+ | `dbchanges2` | `Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql` (the `CustomRecordFields` row for the schemas the 2026-08-11b rollout missed) | commit `0ca4012` on `TRUE-81284`, pushed. **Not on `_main` or `_beta`.** |
87
+
88
+ Verified while applied: **zero existing values changed for any tenant** — the fallback only fills
89
+ nulls — while coverage went NYCHH 64 → 819 of 819, Elite 57 → 288 of 288, Quad 475 → 1,456 of 1,463.
90
+ The MySQL 8 plan stayed indexed (both subqueries `ref` on `idx_salesOrderId`, then `eq_ref` on the
91
+ `PurchaseOrders` primary key) and `COALESCE` short-circuits, so the upstream subquery only runs when
92
+ the downstream one is null.
93
+
94
+ #### ⚠ Still open — which direction wins when an order has both
95
+
96
+ Downstream-first shows the **vendor** PO, while the sales-orders **list** (whose table-view join
97
+ reads upstream) shows the **customer** PO, so one order can display two different PO numbers in the
98
+ list and in the modal. Orders carrying both are real and not rare: NYCHH 64, Elite 55, Quad 469.
99
+ Downstream-first was chosen, then questioned in favour of treating the list as the source of truth;
100
+ a per-tenant override on `_Model_Nychh_SalesOrder` preferring upstream was offered and **not built**.
101
+ **Settle this before quoting either value as correct.**
59
102
 
60
103
  ### `poSelection` is a different thing
61
104
  The `poSelection` config on the `sales-order-record-actions` surface picks **which** linked PO the
@@ -74,14 +117,34 @@ the two — changing one does not move the other.
74
117
  - **Surface seed `id`s are deterministic; surface `uuid`s are not.** Seeds insert with `UUID()`, so
75
118
  the element uuid differs per environment. A uuid quoted in an older migration will **not** match
76
119
  the uuid in a live payload. Key surface updates on the literal `id` (here, `id = 7`).
77
- - **Downstream-only base implementation is a trap for upstream tenants.** On an upstream-only
78
- tenant `_purchaseOrders` resolves cleanly (no `EV-8`) and returns `null` forever. See
120
+ - **Downstream-only base implementation was a trap for upstream tenants.** On an upstream-only
121
+ tenant `_purchaseOrders` resolved cleanly (no `EV-8`) and returned `null` forever. Closed on
122
+ `_beta` by the `COALESCE` fallback above. See
79
123
  [SO↔PO bridge direction](./sales-order-purchase-order-bridge-direction.md) and
80
124
  [NYCHH PO Number](../../../clients/nychh/features/po-number-upstream-direction.md).
125
+ - **A correct value here still may not reach the older TOGa Supply order-details modal.** That UI is
126
+ config-driven and declares the display row and the API field request **separately**: the
127
+ `DEFAULTFIELDS` and `QUAD` field sets ask for a `_purchaseOrders` row without listing
128
+ `_purchaseOrders` in `apiFields.fetchOrderDetails`, so it renders blank regardless of the SQL. See
129
+ [order-detail field config](../../toga2-supply/features/order-detail-field-config-and-customer-name.md).
130
+ - **Fixing the SQL is not enough on a tenant whose schema lacks the metadata row.** No
131
+ `CustomRecordFields` row (`recordId` 14, field `_purchaseOrders`) means `EV-8` no matter what the
132
+ model returns, and the original per-client rollout **missed schemas** — prod 1 of 35, sandbox 7 of
133
+ 36. See [calculated SQL fields](./calculated-sql-fields.md).
81
134
  - Dropping `isPersonaValue` is intentional; re-adding it will make the FE try to treat the string
82
135
  as a persona array.
83
136
 
84
137
  ## Change history
138
+ - 2026-08-26 — Base `_purchaseOrders()` became a **`COALESCE`** of downstream-then-upstream, closing
139
+ the upstream-tenant gap for all base-model tenants with no per-tenant override (verified: no
140
+ existing value changed; NYCHH 64 → 819/819, Elite 57 → 288/288, Quad 475 → 1,456/1,463; plan stays
141
+ indexed). Recorded its branch state (`_beta`, **not** `_production`) and left the
142
+ **downstream-vs-upstream tie-break open** for orders carrying both (NYCHH 64, Elite 55, Quad 469),
143
+ where the modal and the list can disagree. **Corrected the tenant table**: Compass Canada is not on
144
+ the base implementation — it lives at `Model/Compass/Canada/` and inherits the Compass override, so
145
+ both Compass tenants move together and Compass Canada's upstream bridge is empty. Added the two
146
+ other independent causes of a blank PO (missing `CustomRecordFields` row → `EV-8`; the older supply
147
+ order-details field set never requesting the field). (bala)
85
148
  - 2026-08-25 — Repointed `SurfaceElements` id 7 to `_purchaseOrders` for all clients
86
149
  (`Core/2026-08-25a`, reverting `Core/2026-07-23a`) and made the FE adapter honour the surface
87
150
  `valueKey` instead of a hardcoded path; recorded the corrected per-tenant map, which supersedes
@@ -6,17 +6,20 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-25
10
- owners: [apeterson]
9
+ updated: 2026-08-26
10
+ owners: [apeterson, bala]
11
11
  files:
12
12
  - _underscore/Model/Client/PurchaseOrders/SalesOrder.php
13
13
  - _underscore/Model/Client/SalesOrders/PurchaseOrder.php
14
14
  - _underscore/Model/Client/ItemFulfillment.php
15
15
  - _underscore/Model/Client/SalesOrder.php
16
+ - _underscore/Trait/Netsuite/SalesOrder.php
17
+ - library/app/api/toga2.php
16
18
  related:
17
19
  - ./sales-order-po-number-sourcing.md
18
20
  - ./recursive-item-fulfillments.md
19
21
  - ./fulfillable-item-propagation.md
22
+ - ./netsuite-salesorder-address-phone-sync.md
20
23
  - ../../api2/features/v2-rest-query-contract.md
21
24
  - ../../../clients/nychh/features/po-number-upstream-direction.md
22
25
  ---
@@ -47,10 +50,45 @@ origin; the second is what was generated from it.
47
50
  - A multi-tier tenant (Compass USA) has **both**, chained SO→PO→SO→PO.
48
51
  - The only in-code statement of the distinction today is a docblock at
49
52
  `_underscore/Model/Client/ItemFulfillment.php:136-140`.
50
- - The base `_purchaseOrders` calculated field on `_Model_Client_SalesOrder` (line ~348) reads the
51
- **downstream** table only. See
53
+ - The base `_purchaseOrders` calculated field on `_Model_Client_SalesOrder` (line ~348) read the
54
+ **downstream** table only until 2026-08-25, when a **fallback to upstream** was added. See
52
55
  [sales-order PO Number sourcing](./sales-order-po-number-sourcing.md).
53
56
 
57
+ ### Two writers, one per direction — and the child key is what picks the table
58
+
59
+ The two directions are not two views of one write; they are populated by **two different sync
60
+ pipelines**, in **different frameworks**:
61
+
62
+ | | UPSTREAM (`PurchaseOrders_SalesOrders`) | DOWNSTREAM (`SalesOrders_PurchaseOrders`) |
63
+ |---|---|---|
64
+ | Written by | **2.0** `_Trait_Netsuite_SalesOrder` (`_underscore/Trait/Netsuite/SalesOrder.php` ~:664-680 and ~:720) | **1.0** cron `App_Api_Toga2::syncPurchaseOrderFromNetsuite` (`library/app/api/toga2.php:2028`, posts the child key at :2504) |
65
+ | api2 child key | `purchaseOrderSalesOrders` | `salesOrderPurchaseOrders` |
66
+ | PO number stored | NetSuite `$nsOrder->otherRefNum` — the **customer's** PO (`EIT0011108`, `LOI-Woodhull`, `GAVELL/08252026`, Quad `4992713`) | the **vendor** PO — numeric NetSuite PO numbers (`168984`), Elite's `P100019` |
67
+
68
+ **⚠ The child key is the only thing that decides which table you write, and getting it wrong is
69
+ silent.** api2 treats `salesOrderPurchaseOrders` and `purchaseOrderSalesOrders` as two unrelated
70
+ child collections, so a transposed key writes a perfectly valid row into the *other* direction and
71
+ the reading side simply never sees it. No error, no warning. Read the key aloud, like the routes.
72
+
73
+ Because **22 client `SalesOrder` models compose `_Trait_Netsuite_SalesOrder`**, every NetSuite-synced
74
+ tenant writes **upstream** rows — which is why upstream-only tenants are the norm on that trait
75
+ rather than the exception. (Grep the composition list before assuming a trait change is
76
+ client-scoped; note the same grep also matches the `SalesOrderItem` / `SalesOrderStage` models, so
77
+ count `SalesOrder.php` files only or you will report 66.)
78
+
79
+ ### How much downstream-only cost, measured on prod
80
+
81
+ Because the base field read downstream only while the trait wrote upstream, **41,381 of 72,134**
82
+ sales orders across the 19 base-model tenants returned no PO at all — only 30,753 resolved. Worst
83
+ affected: NYCHH 64/822, Canon 594/6,290, GroWrk 1,226/12,793, S&P Global 971/9,468, Endeavor Health
84
+ 1,919/11,836. **An order can also carry both directions at once** (NYCHH 64, Elite 55, Quad 469),
85
+ which is what makes the tie-break in
86
+ [PO Number sourcing](./sales-order-po-number-sourcing.md) a real decision rather than a formality.
87
+
88
+ All **35** prod client schemas contain all four tables (`SalesOrders`, `PurchaseOrders`, both
89
+ bridges), verified via `information_schema` — so reading either direction is structurally safe
90
+ everywhere. Present is not populated.
91
+
54
92
  ## Gotchas
55
93
 
56
94
  - **An empty array is the symptom.** `200` / `totalRecordCount 0` with **no** `EZ-*` or `EV-*`
@@ -62,11 +100,38 @@ origin; the second is what was generated from it.
62
100
  - **The routes are near-homographs.** `sales-order-purchase-orders` vs
63
101
  `purchase-order-sales-orders`. Read them aloud before wiring a front-end call.
64
102
  - **Downstream-only assumptions are baked into shared code.** Any tenant whose POs are purely
65
- upstream (verified: NYCHH; suspected: Compass Canada, which has no `Model/Compasscanada/`
66
- overrides at all) silently gets `null`/empty from the shared downstream-reading helpers rather
67
- than an error. Upstream-only tenants need an explicit upstream counterpart field.
103
+ upstream (verified: NYCHH) silently gets `null`/empty from the shared downstream-reading helpers
104
+ rather than an error.
105
+ - **⚠ Compass Canada is NOT upstream-only, and it DOES have model overrides** — corrected
106
+ 2026-08-26. The earlier "no `Model/Compasscanada/` directory" reading looked in the wrong place:
107
+ the folder is **`_underscore/Model/Compass/Canada/`**, and
108
+ `_Model_Compass_Canada_SalesOrder extends _Model_Compass_SalesOrder`, so Compass Canada inherits
109
+ **Compass's** `_purchaseOrders` override, not the base one. Its **upstream bridge is completely
110
+ empty (0 rows)** while 735 of 788 orders resolve a PO downstream. When checking whether a tenant
111
+ has overrides, search for the class name (`grep -rn "class _Model_.*_SalesOrder extends"`) rather
112
+ than guessing a directory — Compass's tenants nest one level deeper than everyone else's.
113
+ - **⚠ Do NOT paper over the direction with an `ojoin` on a shared single-record fetch.** It is
114
+ tempting because it works in one place: the TOGa Supply NYCHH orders **list** joins the upstream
115
+ bridge and is correct — but only because NYCHH's upstream bridge happens to be **1:1** (823 rows /
116
+ 823 distinct sales orders, max 1 PO per SO). The same join in the shared
117
+ `fetchOrdersDetails` (`toga2-supply/src/pages/Orders/api/OrdersApi.ts:397`) breaks three tenants:
118
+ **Compass** has up to **6,440** downstream POs on one sales order (row multiplication on a
119
+ single-record fetch), **Compass Canada's upstream bridge is empty** (735 of 788 would drop to
120
+ **zero**), and **Prudential** has only **1,868 of 26,843** orders upstream (92% populated would
121
+ become 7%). A scalar `GROUP_CONCAT` calculated field is the right mechanism precisely because it
122
+ collapses many POs without multiplying rows.
68
123
 
69
124
  ## Change history
125
+ - 2026-08-26 — Added the **write** side: the two directions are populated by two different
126
+ pipelines (2.0 `_Trait_Netsuite_SalesOrder` writes **upstream** customer POs from `otherRefNum`;
127
+ the 1.0 `syncPurchaseOrderFromNetsuite` cron writes **downstream** vendor POs), and the api2 child
128
+ key (`purchaseOrderSalesOrders` vs `salesOrderPurchaseOrders`) is the only thing selecting the
129
+ table — a transposed key writes the wrong direction silently. Quantified the downstream-only cost
130
+ on prod (41,381 of 72,134 orders returned no PO across the 19 base-model tenants) and noted
131
+ tenants carrying both directions. **Corrected the Compass Canada claim**: it has overrides at
132
+ `Model/Compass/Canada/` and inherits the Compass `_purchaseOrders` override, and its upstream
133
+ bridge is empty — it is downstream-only, not upstream-only. Added the "do not fix this with an
134
+ `ojoin`" gotcha with the Compass / Compass Canada / Prudential regressions it would cause. (bala)
70
135
  - 2026-08-25 — Documented the two-direction bridge after an NYCHH "empty array" debug that was
71
136
  purely a wrong-direction query; recorded route/model/record-id mapping and the empty-vs-EZ
72
137
  diagnostic signature (apeterson)