toga-ai 1.0.680 → 1.0.682

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.
@@ -43,9 +43,9 @@
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, dbchanges2/Core/2026-08-12a - NavigationSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-12a - NavigationRoleGrants.sql, toga25-supply/surface-layer-guide.md |
45
45
  | [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 |
46
- | [TableView joins (TableViewJoins → SQL) — aliasing, chained multi-hop joins, ACL](features/tableview-joins.md) | `Client_*.TableViewJoins` rows are what let a table view show a column from a table other than its base record. | _underscore/Model/Client/TableView.php, dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql, dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql |
46
+ | [TableView joins (TableViewJoins → SQL) — aliasing, chained multi-hop joins, ACL](features/tableview-joins.md) | `Client_*.TableViewJoins` rows are what let a table view show a column from a table other than its base record. | _underscore/Model/Client/TableView.php, _underscore/Model.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql, dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql |
47
47
  | [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 |
48
- | [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, dbchanges2/Client_Quad/2026-08-26a - BackfillQuadUnitLevelTrackingSingleTn.sql, dbchanges2/Client_Quad/2026-08-26b - BackfillQuadItemLevelTrackingSingleTn.sql |
48
+ | [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, dbchanges2/Client_Quad/2026-08-26a - BackfillQuadUnitLevelTrackingSingleTn.sql, dbchanges2/Client_Quad/2026-08-26b - BackfillQuadItemLevelTrackingSingleTn.sql, dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql |
49
49
  | [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 |
50
50
  | [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 |
51
51
  | [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 |
@@ -6,18 +6,23 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-28
10
10
  owners: [bala]
11
11
  files:
12
12
  - _underscore/Model/Client/TableView.php
13
+ - _underscore/Model.php
14
+ - api2/Component/Api/V2/V2.php
13
15
  - dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql
14
16
  - dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql
15
17
  - dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql
18
+ - dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql
16
19
  related:
17
20
  - ../../api2/features/tableview-field-metadata.md
18
21
  - ./page-meta-context-field-settings.md
19
22
  - ./tableview-hyperlink-columns.md
20
23
  - ../../api2/features/tableview-apiwhereclause-row-filtering.md
24
+ - ../../api2/features/request-logging.md
25
+ - ../../../../clients/compass-canada/features/item-fulfillment-tableviews.md
21
26
  ---
22
27
 
23
28
  ## Summary
@@ -89,18 +94,67 @@ the all-clients template `dbchanges2/Client/2026-08-11a - ServiceRequestsInvento
89
94
  Only the second hop had to be created. Always check for a usable join first; a duplicate Items join
90
95
  would also have broken the chain above.
91
96
 
92
- ## The joined record needs an AclRecordPermissions row
97
+ ## Two ways `meta()` 500s and they are not the same bug
98
+
99
+ Both look like "the table view is broken for this user", but the trigger, the blast radius and the
100
+ fix are different. Tell them apart before touching config.
101
+
102
+ ### (a) A join pointing at a DELETED Core record/field — fatal for every caller who can see the join
103
+
104
+ The join loop builds a model for **every surviving join, unconditionally**, before a single
105
+ `TableViewFields` row is looked at:
106
+
107
+ ```php
108
+ $joinOnRecordField = new _Model_Core_RecordField($tableViewJoin->joinOnRecordFieldId);
109
+ $joinRecord = new _Model_Core_Record($tableViewJoin->joinRecordId);
110
+ $parentRecordField = new _Model_Core_RecordField($tableViewJoin->parentRecordFieldId);
111
+ ```
112
+
113
+ `_Model::__construct` (`_underscore/Model.php:716`) throws on a primary key that returns nothing:
114
+
115
+ ```
116
+ Error during Model build for primary key id '211' for '_Model_Core_RecordField'.
117
+ Exactly 1 row was expected to be returned but 0 were.
118
+ ```
119
+
120
+ Consequences worth internalising:
121
+
122
+ - **It fires for ANY caller whose roles pass the `AclRecordPermissions` filter on that join** —
123
+ even when **no `TableViewField` references the join at all**. An orphaned dead join is not
124
+ inert.
125
+ - Conversely, a role that *cannot* see the dead join never constructs it, so the same view can 500
126
+ for one user and serve fine for another. That is the usual reason a report reads as
127
+ "intermittent".
128
+ - Nothing validates `joinRecordId` / `parentRecordFieldId` against live `Core` rows, so a client DB
129
+ that predates a platform migration sits broken until someone opens the view. The ten-client audit
130
+ and the per-client fixes are on
131
+ [tracking-number-bridges](./tracking-number-bridges.md).
132
+
133
+ ### (b) A join DROPPED by the ACL filter — harmless until a column hangs off it
93
134
 
94
135
  The joins query **INNER JOINs `AclRecordPermissions` on `joinRecordId`**. With no row for the
95
- caller's role, the join is silently dropped from the result set, and any `TableViewFields` row
96
- pointing at it then throws:
136
+ caller's role, the join is silently dropped from the result set. That on its own is **fine**; it only
137
+ throws when a `TableViewFields` row references the now-missing join:
97
138
 
98
139
  ```
99
140
  There is no AclRecordPermissions record permitting access to TableViewJoins.id = N for this user
100
141
  ```
101
142
 
102
- So a new joined table (here `Manufacturers`, record 20) needs its `AclRecordPermissions` grant in the
103
- same migration.
143
+ So a role gap only 500s when a **visible column** hangs off the missing join — which is why one
144
+ client tolerates ACL gaps on records another client cannot. A new joined table (here
145
+ `Manufacturers`, record 20) needs its `AclRecordPermissions` grant in the same migration.
146
+
147
+ ### The debugging signature: 500 + EMPTY body + NO row in `Logs_<client>.Api`
148
+
149
+ Mode (a) throws *before* the response is assembled, so the caller gets a **500 with an empty
150
+ response body** — no `error` code, no `messages`, nothing to grep — and **no row is written to
151
+ `Logs_<client>.Api`** either, because the V2 engine writes its transaction log near the END of
152
+ `execute()`. **The absence of an Api log row is itself the signature of this class of failure**, not
153
+ evidence the request never arrived. Confirmed again on Compass Canada 2026-08-27; the general rule
154
+ for fatals lives on [request-logging](../../api2/features/request-logging.md).
155
+
156
+ Contrast mode (b), which surfaces a readable message naming a `TableViewJoins.id` — which is exactly
157
+ why it reads like config corruption rather than an ACL gap.
104
158
 
105
159
  ## Context RecordFieldSettings are OPTIONAL for a joined column to render
106
160
 
@@ -153,12 +207,28 @@ Core-level reference ids used (identical across all clients and environments): *
153
207
  not hand-write an alias from memory — check the counter logic before relying on one.
154
208
  - **A missing `AclRecordPermissions` row for the joined record drops the join, not just the column** —
155
209
  and the resulting error names a `TableViewJoins.id`, which is why it reads like config corruption
156
- rather than an ACL gap.
210
+ rather than an ACL gap. It only throws if a column references that join.
211
+ - **A dead Core id on a join is fatal even if nothing uses the join.** Deleting the unused
212
+ `TableViewFields` row does not make the view load; the join row itself has to go.
213
+ - **A 500 with an empty body and no `Logs_<client>.Api` row is the dead-Core-id signature.** Do not
214
+ read the missing log row as "the request never happened".
157
215
  - **An unused join can already exist.** Confirm before inserting; a duplicate join of the same table
158
216
  is what breaks chained joins.
159
217
 
160
218
  ## Change history
161
219
 
220
+ - 2026-08-28 — Separated the **two distinct ways `meta()` 500s**, after Compass Canada's
221
+ `item-fulfillments-for-sales-order(-item)s` views returned 500 while Compass USA's served fine.
222
+ (a) A join pointing at a **deleted `Core.Records` / `Core.RecordFields` row** throws from
223
+ `_Model::__construct` (`Model.php:716`, *"Exactly 1 row was expected to be returned but 0 were"*)
224
+ because the join loop constructs a model for **every** ACL-visible join **before** any
225
+ `TableViewFields` row is considered — so it fires even when nothing references that join, and only
226
+ for roles that can see it. (b) A join **dropped by the ACL filter** is harmless **unless** a
227
+ `TableViewField` references it. Recorded the mode-(a) debugging signature: **500 with an EMPTY
228
+ response body and NO row in `Logs_<client>.Api`**, because the throw unwinds before the V2 engine
229
+ writes its transaction log. Canada's fix:
230
+ [Compass Canada item-fulfillment TableViews](../../../../clients/compass-canada/features/item-fulfillment-tableviews.md).
231
+ (bala)
162
232
  - 2026-08-18 — Documented how `TableViewJoins` compile into SQL after adding a chained
163
233
  **Units → Items → Manufacturers** join for the Part Number / Manufacturer / Item Description columns
164
234
  on `inventory_units` (Elite + NYCHH): `onB` is emitted with the **raw table name** (so a chain only
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-20
9
+ updated: 2026-08-28
10
10
  owners: ["jcardinal", "mhammontree", "dfranks", "apeterson", "bala", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -29,9 +29,11 @@ files:
29
29
  - dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql
30
30
  - dbchanges2/Client_Quad/2026-08-26a - BackfillQuadUnitLevelTrackingSingleTn.sql
31
31
  - dbchanges2/Client_Quad/2026-08-26b - BackfillQuadItemLevelTrackingSingleTn.sql
32
+ - dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql
32
33
  related:
33
34
  - recursive-item-fulfillments.md
34
35
  - ../../api2/features/cxml-shipnotice-gateway.md
36
+ - ../../../../clients/compass-canada/features/item-fulfillment-tableviews.md
35
37
  ---
36
38
 
37
39
  ## Summary
@@ -226,7 +228,7 @@ returnTrackingNumber: {...} }]`, and an IFIU's as `itemFulfillmentItemUnitTracki
226
228
  | Client | 318 (item) | 319 (unit) | Which the views should read |
227
229
  |---|---|---|---|
228
230
  | Compass | 4,312 | 10,144 | 318 |
229
- | CompassCanada | 169 | 138 | 318 |
231
+ | CompassCanada | 169 | 138 | **318** — views rebuilt onto 318 **2026-08-27** |
230
232
  | Elite | **0** | 109 | **319** (fixed 2026-08-17) |
231
233
  | Prudential | **0** | 380 | **319** |
232
234
  | Quad | 0 → +5,129¹ | 633 → +779¹ | **318** — writer + backfill **fixed 2026-08-26** |
@@ -261,7 +263,10 @@ returnTrackingNumber: {...} }]`, and an IFIU's as `itemFulfillmentItemUnitTracki
261
263
  - **The dead-id breakage spans TEN clients, not just Elite (measured 2026-08-17).**
262
264
  `item-fulfillments-for-sales-orders` / `item-fulfillments-for-sales-order-items` still carry
263
265
  dangling `joinRecordId` **41** and dangling `parentRecordFieldId`s **211**, **321** in: **Elite**
264
- (fixed 2026-08-17), **CompassCanada, Prudential, Nychh, Aig, Growrk, Managelife, Rate, SPGlobal**
266
+ (fixed 2026-08-17), **CompassCanada** (the two fulfillment views fixed **2026-08-27**, but its
267
+ `sales-order-shipments` and `units` views still carry the same dead ids — see
268
+ [Compass Canada item-fulfillment TableViews](../../../../clients/compass-canada/features/item-fulfillment-tableviews.md)),
269
+ **Prudential, Nychh, Aig, Growrk, Managelife, Rate, SPGlobal**
265
270
  (which has a fourth dangling id, **1431**) and **True**. **Only Compass and Quad are clean**,
266
271
  because those two were migrated to base record 29. The per-client fix is **not a blind copy** -
267
272
  the re-root is portable but the bridge (318 vs 319) must be chosen from that client's own row
@@ -271,6 +276,13 @@ returnTrackingNumber: {...} }]`, and an IFIU's as `itemFulfillmentItemUnitTracki
271
276
  for record 41.
272
277
 
273
278
  ## Change history
279
+ - 2026-08-28 — **Compass Canada's two item-fulfillment views are off the dead-id list** — rebuilt onto
280
+ base record 29 with tracking through the item-level bridge **318** (Canada measures 169 rows in 318
281
+ vs 138 in 319, so 318 is right on its own numbers, not by copying USA):
282
+ `Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql`, applied to prod and
283
+ verified 200. Canada's `sales-order-shipments` and `units` views still carry dead record 41 /
284
+ fields 211+321. Also recorded that **Compass Canada's `Roles` ids are NOT Compass USA's**, so
285
+ `AclRecordPermissions` rows cannot be copied between the two tenants when porting a view. (bala)
274
286
  - 2026-08-26 — **Quad's empty-Tracking-# live bug RESOLVED** — by populating **318**, not repointing
275
287
  views to 319. Root: Quad's IFs come from NetSuite (one tracking number per IF) and 318/319 are
276
288
  meant to **mirror** the single 317 header number; `App_Api_Toga2::syncItemFulfillmentFromNetsuite`
@@ -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-28
10
10
  owners: ["bala", "tcox"]
11
11
  files:
12
12
  - _underscore/Model/Client/ServiceRequest.php
@@ -14,6 +14,7 @@ files:
14
14
  related:
15
15
  - ./tracking-number-bridges.md
16
16
  - ../../../clients/elite/features/supply2-tableview-config-drift.md
17
+ - ../../../clients/prudential/features/supply-orders-csv-export.md
17
18
  ---
18
19
 
19
20
  ## Summary
@@ -93,6 +94,26 @@ Coverage on `Client_Elite`: **224 of 229** units. The shipped expression selects
93
94
  `IN (<fulfillment chain> UNION <ServiceRequests_Units>)` so the bridge still counts once it is
94
95
  populated - keep the UNION rather than choosing one side.
95
96
 
97
+ ### `ServiceRequests_Units` population is per-client - and it is many-to-many
98
+
99
+ **"0 rows" is an Elite fact, not a schema fact.** On `Client_Prudential` (measured 2026-08-27)
100
+ the same bridge holds **30,268 rows** and is the *only* working route from a unit to its order for
101
+ units that never shipped: `SalesOrderItems_CommittedUnits`, the obvious alternative, has **0 rows**
102
+ there. Count the rows in the client DB before deciding a bridge is dead.
103
+
104
+ Where it is populated, it is genuinely **many-to-many** and **fans rows out**. Generic/template units
105
+ (docks, monitors with no serial number) link to very many ServiceRequests, so
106
+
107
+ ```
108
+ Units -> ServiceRequests_Units -> ServiceRequests -> SalesOrders
109
+ -> SalesOrders_PurchaseOrders -> PurchaseOrders
110
+ ```
111
+
112
+ returns one row **per distinct PurchaseOrder** the unit resolves to - on Prudential, 9,846 distinct
113
+ never-shipped units expand to **21,970 rows**. Any consumer that wants one row per unit must dedupe
114
+ by `Units.uuid`, and must accept that which PurchaseOrder "wins" is **arbitrary**. See
115
+ [Prudential Sales Orders CSV export](../../../clients/prudential/features/supply-orders-csv-export.md).
116
+
96
117
  ## Data model
97
118
  Header-level vs item-level bridges, and the level each transaction is anchored at:
98
119
 
@@ -151,6 +172,12 @@ None — uniform. The linkage structure is framework-level and identical across
151
172
  rather than reading the SQL.
152
173
 
153
174
  ## Change history
175
+ - 2026-08-28 - Corrected the `ServiceRequests_Units` "0 rows" note: that is an **Elite** measurement,
176
+ not a schema fact - `Client_Prudential` has **30,268 rows** and the bridge is the only unit-to-order
177
+ route there for never-shipped units (`SalesOrderItems_CommittedUnits` is the empty one on Prudential,
178
+ 0 rows). Added that the bridge is genuinely **many-to-many and fans rows out** (9,846 units ->
179
+ 21,970 rows on Prudential), so consumers must dedupe by `Units.uuid` and the winning PurchaseOrder
180
+ is arbitrary. (bala)
154
181
  - 2026-08-18 - Added the **third linkage path, `Units.itemId`** - the only *complete* item-to-unit
155
182
  path (Elite: 229/229 units, vs 97/229 through item receipts) - and the all-clients
156
183
  **`inventory_units`** view built on it (base 31, Items as spine via joinRecordId 21 /
@@ -19,7 +19,7 @@
19
19
  | [TOGa IQ Sprint Dashboard API (Record Scripts)](features/sprint-dashboard-api.md) | The internal **TOGa IQ sprint dashboard** is served in production by **six api2 Record Scripts** on `_Model_Team_Sprint` (`_underscore/Model/Team/Sprint.php`), | _underscore/Model/Team/Sprint.php, api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-07-24a - SprintDashboardRecordScripts.sql, dbchanges2/Client_True/2026-07-24b - SprintDashboardScriptAcl.sql |
20
20
  | [Surface action-state via the surface=<slug> request option (M2M-safe)](features/surface-meta-option.md) | An opt-in V2 engine request option, `surface=<slug>`, that attaches per-record UI action state (`isVisible`/`isEnabled`) to a GET response **under `meta.surface | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Surface.php |
21
21
  | [TableView row-filtering via apiWhereClause (options.where grammar, end to end)](features/tableview-apiwhereclause-row-filtering.md) | `TableViews.apiWhereClause` (TEXT, nullable) is the sanctioned, code-free way to restrict or exclude rows from a 2.0 table view. | api2/Component/Api/V2/V2.php, _underscore/Model/Client/TableView.php, toga2-supply/src/api/toga.ts |
22
- | [TableView field/column metadata (TableViewFields, hidden projected columns)](features/tableview-field-metadata.md) | The columns of a 2.0 table view are defined by DB metadata, not code. | _underscore/Model/Client/TableView.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-07-20 - ItemsUuidForPurchaseOrderItemsTableView.sql, dbchanges2/Core/2026-08-17a - ItemFulfillmentQuantityFieldTypeNumber.sql, dbchanges2/Client/2026-08-17a - ItemFulfillmentColumnsCopyable.sql, dbchanges2/Client_Quad/2026-08-18c - SalesOrderListingSortByDateOrderDesc.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql |
22
+ | [TableView field/column metadata (TableViewFields, hidden projected columns)](features/tableview-field-metadata.md) | The columns of a 2.0 table view are defined by DB metadata, not code. | _underscore/Model/Client/TableView.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-07-20 - ItemsUuidForPurchaseOrderItemsTableView.sql, dbchanges2/Core/2026-08-17a - ItemFulfillmentQuantityFieldTypeNumber.sql, dbchanges2/Client/2026-08-17a - ItemFulfillmentColumnsCopyable.sql, dbchanges2/Client_Quad/2026-08-18c - SalesOrderListingSortByDateOrderDesc.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql |
23
23
  | [Tickets API (/v2/tickets)](features/tickets-api.md) | The generic ticket endpoint of the 2.0 REST API. | Component/Api/V2/V2.php |
24
24
  | [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, api2/Component/Api/V2/Response/Oauth/Oauth.php, api2/Controller/Index.php, toga2-supply/src/globalTypes.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/api/toga.ts, _underscore/Model/Client/TrackingNumber.php |
25
25
  | [V2 request deadlock-retry — route-scoped in-process replay](features/v2-deadlock-retry.md) | api2's front controller can **detect a MySQL deadlock (1213) / lock-wait timeout (1205) and replay the whole request in-process**, so a transient lock collision | api2/Controller/Index.php, _underscore/Database.php, _underscore/Query.php |
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-13
9
+ updated: 2026-08-28
10
10
  owners: ["mhammontree", "dfranks", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -117,6 +117,13 @@ Practical consequences:
117
117
  [Compass Order Lifecycle & Data-Integrity Invariants](../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md#detecting-empty-shell-orders-left-by-a-fatal-mid-sequence).
118
118
  - **Orphan sweeps only find PARTIAL casualties.** An attempt that rolled back completely leaves no
119
119
  row in any table and, with no API log row, no trail at all.
120
+ - **It applies to plain GETs too, and the response body comes back EMPTY.** Re-confirmed 2026-08-27:
121
+ a `GET /v2/table-views/meta` that throws inside `_Model_Client_TableView::meta()` (a
122
+ `TableViewJoins` row pointing at a deleted `Core` record) returns **500 with no body at all** — no
123
+ `error` code, no `messages` — and `Logs_CompassCanada.Api` holds no row for it. **An empty 500
124
+ body plus a missing Api log row is a usable fingerprint** for "a fatal was thrown before the
125
+ response was assembled", and is the fastest way to rule out a handled rejection. See
126
+ [tableview-joins](../../_underscore/features/tableview-joins.md).
120
127
 
121
128
  ## ⚠ apiproxy's 5xx retry distorts BOTH logs — always read a 500 through `transactionId`
122
129
 
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-08-28
10
10
  owners: [apeterson, bala]
11
11
  files:
12
12
  - _underscore/Model/Client/TableView.php
@@ -18,6 +18,7 @@ files:
18
18
  - dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql
19
19
  - dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql
20
20
  - dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql
21
+ - dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql
21
22
  related:
22
23
  - tableview-apiwhereclause-row-filtering.md
23
24
  - ../../_underscore/features/page-meta-context-field-settings.md
@@ -144,6 +145,29 @@ Notes that matter when writing it:
144
145
  [tableview-joins](../../_underscore/features/tableview-joins.md). If the script uses `@` session
145
146
  variables, all statements must run in **one connection**.
146
147
 
148
+ > **⚠ `DESCRIBE` / `information_schema` will tell you `index` is unconstrained — it is lying.**
149
+ > A **composite** unique key is only recorded against its **FIRST** column, so
150
+ > `information_schema.COLUMNS` reports `COLUMN_KEY = ''` for `index` (and for `slug`) and the column
151
+ > reads as free. The constraint is real, and you find out mid-script with
152
+ > `Duplicate entry '13-2' for key 'tableviewfields.tableViewId_index'`. Read
153
+ > `information_schema.STATISTICS` (or `SHOW INDEX FROM TableViewFields`) instead — that is the only
154
+ > place a composite key's second column appears.
155
+
156
+ ### Rebuilding a whole view — order of operations
157
+
158
+ Deleting and re-creating a view's columns (not just reordering them) has two FKs to respect and one
159
+ lookup rule:
160
+
161
+ 1. `TableViews.sortPrimaryTableViewFieldId` is an FK onto `TableViewFields.id` — `SET NULL` first.
162
+ 2. `TableViewFields.tableViewJoinId` is an FK onto `TableViewJoins.id` — detach the columns
163
+ (`SET tableViewJoinId = NULL`) or delete them before deleting any join.
164
+ 3. **Resolve `TableViews` by `slug`, never by `id`.** `TableViews` / `TableViewJoins` /
165
+ `TableViewFields` ids differ per client database. Two clients sharing a view id is a coincidence
166
+ and says nothing about the joins and fields underneath it — Compass USA and Compass Canada both
167
+ number these views 12 and 13, with completely different children.
168
+
169
+ Worked example: [Compass Canada item-fulfillment TableViews](../../../../clients/compass-canada/features/item-fulfillment-tableviews.md).
170
+
147
171
  ## Hidden (projected-but-not-rendered) columns
148
172
 
149
173
  A `TableViewFields` row with `isVisible=0` is still selected into the row's data projection but
@@ -225,6 +249,14 @@ SalesOrderListingSortByDateOrderDesc.sql`).
225
249
  *and* that query — and a grep for the property alone under-reports what a missing column breaks.
226
250
 
227
251
  ## Change history
252
+ - 2026-08-28 — Recorded that **`DESCRIBE` / `information_schema.COLUMNS` hides a composite unique
253
+ key's second column** (`COLUMN_KEY = ''` for `TableViewFields.index`), so `index` looks free right
254
+ up until `Duplicate entry '13-2' for key 'tableviewfields.tableViewId_index'` — read
255
+ `information_schema.STATISTICS` / `SHOW INDEX` instead. Added the **full-view-rebuild order of
256
+ operations** (`sortPrimaryTableViewFieldId` FK `SET NULL`, then detach `tableViewJoinId`, then
257
+ delete joins) and the rule to **resolve `TableViews` by `slug`, never by `id`** (ids differ per
258
+ client DB; Compass USA and Compass Canada both use 12/13 with different children). Found while
259
+ rebuilding Compass Canada's two item-fulfillment views. (bala)
228
260
  - 2026-08-27 - Added **`isGroupable`** as a fourth per-view presentation flag on `TableViewFields`,
229
261
  with the warning that it is declared in `_underscore` (`9f296a71`, 2026-08-26 — model property
230
262
  **and** `TableView::meta()`'s hand-written SELECT) while **no `dbchanges2` migration adds it on any
@@ -171,6 +171,22 @@ phases leaves `@parentId` `NULL` and the child inserts either fail on the FK or
171
171
  in the file header, and make the unguarded parent insert the one phase you skip on a re-run (set
172
172
  `@parentId` to the existing row by hand instead).
173
173
 
174
+ ## Dry-running a destructive migration before it goes near an environment
175
+
176
+ A rebuild file (DELETE + re-INSERT, FK detaches, index renumbers) cannot be proved safe by reading
177
+ it. Two cheap local checks catch almost everything, and neither needs a sandbox:
178
+
179
+ 1. **Replay it against a local copy of the schema wrapped in `START TRANSACTION; … ROLLBACK;`.**
180
+ Every FK violation, unique-key collision and typo surfaces; nothing persists, so you can iterate
181
+ in seconds.
182
+ 2. **Prove re-runnability by concatenating the file to itself** and running *both copies inside one
183
+ transaction*. If the second pass errors — or changes anything the first pass did not — the file is
184
+ not idempotent, and the executor will eventually run it twice.
185
+
186
+ Both are local-only evidence: pair them with the
187
+ [local-vs-prod config parity check](../workflows/local-vs-prod-mysql-config-parity.md) before
188
+ concluding a migration is production-safe.
189
+
174
190
  ## Gotchas
175
191
 
176
192
  - **A "re-run safe" file is usually only re-run safe *per phase*.** If Phase 1 creates the parent row
@@ -185,6 +201,10 @@ in the file header, and make the unguarded parent insert the one phase you skip
185
201
  [dbchanges2 architecture](../architecture.md).
186
202
 
187
203
  ## Change history
204
+ - 2026-08-28 — Added the **local dry-run pattern for destructive/rebuild migrations**: replay the
205
+ file against a local copy inside `START TRANSACTION; … ROLLBACK;`, then prove idempotence by
206
+ concatenating the file to itself and running both copies in one transaction. Used while writing
207
+ `Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql`. (bala)
188
208
  - 2026-08-28 — **Corrected the error-1093 claim.** The doc previously said a self-referencing
189
209
  `INSERT … NOT EXISTS` guard trips 1093 and must be wrapped in a derived table. 1093 is an
190
210
  **UPDATE/DELETE** restriction only; tested on MySQL 8.0.30, both the `NOT IN` and `NOT EXISTS`
@@ -16,3 +16,4 @@
16
16
  | [Talos Integration (AppLayout host + adapter wiring)](features/talos-integration.md) | toga25-supply is the first host of the shared blox [Talos assistant](../../toga-blox/features/talos-assistant.md). | toga25-supply/src/layout/AppLayout/AppLayout.tsx, toga25-supply/src/components/Header/Header.tsx, toga25-supply/src/components/Header/Header.module.css, toga25-supply/src/index.css, toga25-supply/src/assets/talos-owl.png |
17
17
  | [AWS Amplify Multi-Environment Deployment](workflows/amplify-deployment.md) | How `toga25-supply` deploys to **all** of its environments on AWS Amplify from a **single shared `amplify.yml`**. | toga25-supply/amplify.yml, toga25-supply/src/api/api.ts, toga25-supply/src/hooks/useAuthenticationFlow.ts, toga25-supply/vite.config.ts, toga25-supply/package.json |
18
18
  | [Cypress Testing Harness (component + e2e)](workflows/cypress-testing.md) | The Cypress test harness for the `toga25-supply` frontend, bootstrapped from scratch (`cypress` was already a dependency but there was no config, no `cypress/` | toga25-supply/cypress.config.ts, toga25-supply/cypress/tsconfig.json, toga25-supply/cypress/support/component.tsx, toga25-supply/cypress/support/component-index.html, toga25-supply/cypress/support/e2e.ts, toga25-supply/cypress/support/commands.ts, toga25-supply/cypress/support/fixtures.ts, toga25-supply/cypress/support/mocks/useApprovalModalViewModel.ts, toga25-supply/cypress/component/SalesOrderApprovalModalsLayout.cy.tsx, toga25-supply/cypress/component/RecordApprovalModalLayout.cy.tsx, toga25-supply/cypress/component/EnterPoNumberModal.cy.tsx, toga25-supply/cypress/e2e/salesOrderApproval.cy.ts |
19
+ | [Porting a page (or query) from toga2-supply to toga25-supply](workflows/porting-a-page-from-toga2-supply.md) | `toga25-supply` re-implements pages that already exist in `toga2-supply`. | toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/PRUDENTIAL/exportApiFields.json, toga25-supply/src/pages/SalesOrders/api/prudentialExportApi.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/components/ui/Tables/hooks/useExportableData.tsx |
@@ -0,0 +1,78 @@
1
+ ---
2
+ title: Porting a page (or query) from toga2-supply to toga25-supply
3
+ framework: "2.0"
4
+ repo: toga25-supply
5
+ project: TOGa 2.5 Supply
6
+ client: shared
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-08-28
10
+ owners: ["bala"]
11
+ files:
12
+ - toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/PRUDENTIAL/exportApiFields.json
13
+ - toga25-supply/src/pages/SalesOrders/api/prudentialExportApi.ts
14
+ - toga2-supply/src/pages/Orders/api/OrdersApi.ts
15
+ - toga2-supply/src/components/ui/Tables/hooks/useExportableData.tsx
16
+ related:
17
+ - ../features/client-api-fetch-fields.md
18
+ - ../architecture.md
19
+ - ../../../clients/prudential/features/supply-orders-csv-export.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ `toga25-supply` re-implements pages that already exist in `toga2-supply`. A port is not a copy:
25
+ the two repos structure API queries differently, and the upstream file **keeps moving** after the
26
+ port is taken. This is the procedure that catches both — written after the Prudential Sales Orders
27
+ export shipped with two upstream fixes silently missing.
28
+
29
+ ## The structural difference (why it is not a copy/paste)
30
+
31
+ - **`toga2-supply`** inlines the query options as **JS object literals inside the API function**
32
+ (e.g. `getPrudentialOrders` in `src/pages/Orders/api/OrdersApi.ts`).
33
+ - **`toga25-supply`** externalises query projections into **JSON sidecars** under
34
+ `src/pages/<Page>/viewModel/FIELDS/<CLIENT_SLUG>/*.json` (per-client folder) or
35
+ `viewModel/FIELDS/apiFields.json` (the slug-keyed `DEFAULT` + delta map described in
36
+ [Client API-Fetch Fields](../features/client-api-fetch-fields.md)).
37
+
38
+ So porting a query means **editing JSON**, and every key used by the sidecar — `fields`, `join`,
39
+ `ojoin`, `where`, `recordsPerPage` — must also be **mirrored into the TS query type**, or it is
40
+ silently dropped on the way to `apiGet` and the request goes out without it.
41
+
42
+ ## Steps
43
+
44
+ 1. **Find the upstream source.** Locate the `toga2-supply` function/hook the 2.5 code came from
45
+ (the ported files should say so in a header comment — add one if they don't).
46
+ 2. **`git log --oneline -- <upstream file>` before you trust the port.** This is the step that
47
+ matters. A port is a snapshot; upstream keeps getting fixed. On the Prudential export this
48
+ surfaced two later commits the port never had (`bfaae3a9c` "Fix Prudential contact mapping",
49
+ `d4c0a612f` "Link units via ServiceRequests_Units and dedupe by uuid"). Read every commit dated
50
+ after the port and decide, per commit, whether it applies.
51
+ 3. **Move the query into a JSON sidecar** and mirror its keys into the TS query type.
52
+ 4. **Diff the projection mechanically, not by eye.** Brace-match the object literals out of the
53
+ `toga2-supply` `.ts`, `eval` them in node, normalise each join entry to a
54
+ `"Table ON left=right"` string, and set-diff against the 2.5 JSON. That proves
55
+ `fields` / `join` / `ojoin` / `where` / `order` match exactly. The same set-diff over the output
56
+ column headers proves the produced file is identical (22 columns, for the Prudential export).
57
+ 5. **Decide the deliberate divergences explicitly** and record them in the feature doc. 2.5 is
58
+ allowed to be better than 2.0 — e.g. the export's `formatContactName()` emits an empty cell where
59
+ 2.0 emits the literal `"undefined undefined"` — but a divergence nobody wrote down reads as a
60
+ port bug six months later.
61
+ 6. **Re-check the join semantics against the client's data**, don't inherit them. An upstream join
62
+ can be correct for one client's data and empty for another's (`SalesOrderItems_CommittedUnits`
63
+ has 0 rows in `Client_Prudential`). Count rows in the client DB before shipping.
64
+
65
+ ## Gotchas
66
+
67
+ - **A port with no provenance comment cannot be audited.** If the 2.5 file doesn't name its
68
+ upstream source path, step 2 is impossible and the next fix upstream will be missed again.
69
+ - **`git log` on the *file*, not the repo.** The upstream fixes here were small commits buried in
70
+ unrelated history; only the path-scoped log surfaced them.
71
+ - **Mirroring a key into the JSON is not enough** — an unmirrored key on the TS type is dropped
72
+ silently, with no type error and no runtime error. The request just goes out under-specified.
73
+
74
+ ## Change history
75
+ - 2026-08-28 — Created after the Prudential Sales Orders export shipped as a stale port of
76
+ `toga2-supply`, missing two later upstream fixes. Records the JSON-sidecar vs. inline-literal
77
+ structural difference, the `git log -- <upstream file>` check, and the eval/normalise/set-diff
78
+ verification technique. (bala)
@@ -21,7 +21,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
21
21
  - **_underscore** (_Underscore) _(framework core)_ — 70 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 58 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
- - **dbchanges2** (Database Changes) _(framework core)_ — 13 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
+ - **dbchanges2** (Database Changes) _(framework core)_ — 14 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
25
  - **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
26
26
  - **saml** (SAML SSO Gateway) — 5 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
27
27
  - **toga2-view** (TOGa View Frontend) — 12 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
@@ -30,7 +30,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
30
30
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
31
31
  - **ai-bdr** (AI-BDR) — 13 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
32
32
  - **toga2-commerce** (TOGa Commerce) — 20 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
33
- - **toga25-supply** (TOGa 2.5 Supply) — 14 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
33
+ - **toga25-supply** (TOGa 2.5 Supply) — 16 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
34
34
  - **toga-blox** (TOGa Blox) — 14 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
35
35
  - **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
36
36
 
@@ -5,6 +5,7 @@
5
5
  | [French (fr-CA) Item Feature Translations (Compass Canada)](features/french-item-feature-translations.md) | 2.0 | Renders item **feature** text on the Compass Canada French storefront — feature names, feature-group headers (e.g. | _underscore/Model/Compass/Canada/Feature.php, _underscore/Model/Compass/Canada/ItemCategoryFeatureGroup.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, worker2/Worker/Etilize/ItemTranslations.php, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql, dbchanges2/Client_CompassCanada/2026-07-13 - FeatureAttributeCustomFields.sql, dbchanges2/Client_CompassCanada/2026-07-13 - DedupeItemFeaturesAndGroups.sql, dbchanges2/Client_CompassCanada/2026-07-13 - SeedFrenchFeatureTranslations.sql |
6
6
  | [French (fr-CA) Order Email Localization (Compass Canada)](features/french-order-email-localization.md) | 2.0 | Compass Canada order emails (order requested, manager-approval request, approved/rejected, in-transit, delivered, reminders) are sent in **each recipient's own | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/ApprovalDecision.php, _underscore/Component/Api/Togaiq/Togaiq.php, _underscore/String.php, library/app/client/compasscanada.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/compass_email_reminders.php, worker/crons/toga2/compasscanada/compass_cancel_pending_approval_orders.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php, worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_email_previews_prod.php |
7
7
  | [Grand & Toy ASN Import (Compass Canada)](features/grand-and-toy-asn-import.md) | 2.0 | Imports Grand & Toy (G&T) Advance Shipping Notices for Compass Canada. | api2/Component/Api/Cxml/Cxml.php, worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php, worker/crons/toga2/compasscanada/workflow/import_grand_and_toy_asn_from_file.php, worker/crons/toga2/compasscanada/workflow_beta/import_grand_and_toy_asn_from_file.php, worker/schedules/cron.worker.sync.json, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Model/Compass/Canada/AdvanceShippingNotice.php, dbchanges2/Client_CompassCanada/ |
8
+ | [Compass Canada — Item-Fulfillment TableViews (dead-Core-id 500, rebuilt onto the USA shape)](features/item-fulfillment-tableviews.md) | 2.0 | `Client_CompassCanada`'s two item-fulfillment table views — **`item-fulfillments-for-sales-orders`** (`TableViews.id 12`) and **`item-fulfillments-for-sales-ord | dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql, dbchanges2/Client_CompassCanada/2026-08-26 - EnableTableViewFieldFilters.sql, _underscore/Model/Client/TableView.php |
8
9
  | [Compass Canada — Fulfilled vs Partially Fulfilled (per-line, ASN bridge only)](features/order-fulfillment-status-per-line.md) | 2.0 | TOGa Supply showed **Fulfilled** on Compass Canada orders that were only partly shipped. | _underscore/Model/Compass/Canada/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php |
9
10
  | [Compass Canada](profile.md) | 2.0 | Compass Canada is the Canadian arm of the Compass account — a separate TOGA tenant, related to but distinct from Compass USA. | |
10
11
  | [Grand & Toy ASN Backfill (cXML replay + CSV-to-JSON)](workflows/grand-and-toy-asn-backfill.md) | 2.0 | How to recover Compass Canada shipments whose ASN never landed — used on 2026-08-20 to backfill the 67-day Grand & Toy outage (**518 shipments**: 171 via cXML r | worker/crons/toga2/compasscanada/workflow_beta/import_grand_and_toy_asn_from_file.php, api2/Component/Api/Cxml/Cxml.php, _underscore/Model/Compass/AdvanceShippingNotice.php |
@@ -0,0 +1,134 @@
1
+ ---
2
+ title: "Compass Canada — Item-Fulfillment TableViews (dead-Core-id 500, rebuilt onto the USA shape)"
3
+ framework: "2.0"
4
+ repo: dbchanges2
5
+ project: Database Changes
6
+ client: compass-canada
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-28
10
+ owners: [bala]
11
+ files:
12
+ - dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql
13
+ - dbchanges2/Client_CompassCanada/2026-08-26 - EnableTableViewFieldFilters.sql
14
+ - _underscore/Model/Client/TableView.php
15
+ related:
16
+ - ../../compass-usa/features/item-fulfillment-tracking-tableview.md
17
+ - ../../elite/features/supply2-tableview-config-drift.md
18
+ - ../../../2.0/apps/_underscore/features/tableview-joins.md
19
+ - ../../../2.0/apps/_underscore/features/tracking-number-bridges.md
20
+ - ../../../2.0/apps/api2/features/tableview-field-metadata.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ `Client_CompassCanada`'s two item-fulfillment table views —
26
+ **`item-fulfillments-for-sales-orders`** (`TableViews.id 12`) and
27
+ **`item-fulfillments-for-sales-order-items`** (`TableViews.id 13`) — returned **HTTP 500 on
28
+ `/v2/table-views/meta`** while the identically-named Compass USA views served fine. Canada was one of
29
+ the ten clients that **never got the fulfillment-model rework**: its `TableViewJoins` still pointed at
30
+ `Core.Records.id 41` and `Core.RecordFields.id 211` / `321`, all deleted when the platform moved
31
+ tracking onto the bridge records. Rebuilt onto the Compass USA shape on **2026-08-27**
32
+ (`dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql`), applied to
33
+ production by the developer, **verified 200 via Postman 2026-08-28**.
34
+
35
+ This closes Canada's entry on the ten-client dead-id list in
36
+ [tracking-number-bridges](../../../2.0/apps/_underscore/features/tracking-number-bridges.md).
37
+ **Two Canada views are still broken** — see [Remaining work](#remaining-work).
38
+
39
+ ## How it works (the rebuilt definitions)
40
+
41
+ Both views now match Compass USA structurally:
42
+
43
+ - **Base record moves from `Units` (31) to `ItemFulfillmentItems` (29)** — the re-root that makes a
44
+ fulfilled line appear even when it has no serialized unit rows.
45
+ - **Tracking resolves through the item-level bridge:** `ItemFulfillmentItems_TrackingNumbers`
46
+ (record **318**) then `TrackingNumbers` (62) then `ShippingCarriers` (9). 318 is the correct bridge
47
+ for Canada on its own measured row counts (**169** rows in 318 vs **138** in 319) — not because USA
48
+ uses it. Never inherit the bridge choice; count both.
49
+ - **Serial # / Asset Tag resolve through** `ItemFulfillmentItemUnits` (30) then `Units` (31), OUTER so
50
+ a non-serialized line is not dropped.
51
+ - **`quantity-fulfilled` was missing from view 13** and was added.
52
+ - Column order was aligned to the USA views.
53
+
54
+ ### Canada-only presentation is deliberately preserved
55
+
56
+ The rebuild is a structural port, **not** a wholesale copy of USA's rows. Three Canada-only things
57
+ survive the rebuild and must survive any future re-port:
58
+
59
+ - `Items._imageUrl` thumbnail on the **part-number** column.
60
+ - `ShippingCarriers.urlLogo` on the **shipping-carrier** column.
61
+ - The `isSortable` / `isCopyable` flags set by
62
+ `dbchanges2/Client_CompassCanada/2026-08-26 - EnableTableViewFieldFilters.sql`.
63
+
64
+ ## Why it 500'd (and why USA did not)
65
+
66
+ `_Model_Client_TableView::meta()` constructs `_Model_Core_Record` / `_Model_Core_RecordField` for
67
+ **every join the caller's roles can see**, before any column is considered. A join pointing at a
68
+ deleted Core row makes that constructor throw
69
+ *"Exactly 1 row was expected to be returned but 0 were"* (`_underscore/Model.php:716`), which unwinds
70
+ past the response assembly — so api2 returns a **500 with an EMPTY body** and writes **nothing** to
71
+ `Logs_CompassCanada.Api`. The mechanism, and the second (ACL-driven) failure mode that looks similar
72
+ but is not, are documented on
73
+ [tableview-joins](../../../2.0/apps/_underscore/features/tableview-joins.md).
74
+
75
+ Compass USA was migrated onto record 29 long ago, so it carries no dead join at all.
76
+
77
+ ## ⚠ Canada's role ids are NOT Compass USA's — never copy ACL rows between them
78
+
79
+ This is the trap that makes "just copy the USA `AclRecordPermissions` rows" wrong:
80
+
81
+ | `Roles.id` | Compass Canada | Compass USA |
82
+ |---|---|---|
83
+ | 1 | Base | Base |
84
+ | 7 | **Contractor** | **Compass Base** |
85
+ | 8 | **Compass Base** | **Manager** |
86
+ | 9 | Manager | — |
87
+
88
+ USA grants the fulfillment records to roles **3 + 7** (Compass Base). Canada grants most of them to
89
+ role **1** (Base) only. In practice nearly every logged-in Canada user carries role 1, so the rebuilt
90
+ views work — but the **~10 users who hold Compass Base (Canada role 8) without Base still 500**.
91
+ Whether to grant Canada role 8 the same record permissions USA gives role 7 is an **open decision**,
92
+ not a mechanical port.
93
+
94
+ ## Writing this migration — what cost real time
95
+
96
+ Reusable beyond this client; the general index/FK notes live on
97
+ [tableview-field-metadata](../../../2.0/apps/api2/features/tableview-field-metadata.md).
98
+
99
+ - **Resolve `TableViews` by `slug`, never by `id`.** `TableViews` / `TableViewJoins` /
100
+ `TableViewFields` ids differ per client database. Canada's views happen to be 12 and 13 like USA's,
101
+ which is a coincidence — the joins and fields underneath them are different ids entirely.
102
+ - **Detach before delete.** `TableViewFields.tableViewJoinId` is a real FK onto `TableViewJoins.id`,
103
+ so columns must be `SET tableViewJoinId = NULL` before their joins can be deleted; and
104
+ `TableViews.sortPrimaryTableViewFieldId` is an FK onto `TableViewFields.id`.
105
+ - **Sequence the reorder so no two rows ever share an `index` mid-script** — `TableViewFields` has
106
+ `UNIQUE (tableViewId, index)` and `UNIQUE (tableViewId, slug)`. Getting this wrong produces
107
+ `Duplicate entry '13-2' for key 'tableviewfields.tableViewId_index'`.
108
+ - **`Core.Records` / `Core.RecordFields` ids are identical between prod and dev-sandbox** — verified
109
+ 2026-08-27 for the whole fulfillment set (29, 30, 31, 62, 9, 318) — so one file serves both. They
110
+ must be **hardcoded**: `Core` and `Client_*` are separate clusters in production, so a join to
111
+ resolve them is unrunnable there.
112
+
113
+ ## Remaining work
114
+
115
+ Not fixed; the developer has been told.
116
+
117
+ - **`sales-order-shipments` (`TableViews.id 6`)** and **`units` (`TableViews.id 11`)** in
118
+ `Client_CompassCanada` still carry the same dead references (join to Core record 41,
119
+ `parentRecordFieldId` 211 / 321), so both still 500.
120
+ - Canada's `units` view is **intentionally a different design from USA's**, so it needs its own
121
+ decision rather than a straight USA copy.
122
+ - The Canada role-8 (Compass Base) permission question above is unresolved.
123
+
124
+ ## Change history
125
+ - 2026-08-28 — Rebuilt both Canada item-fulfillment views onto the Compass USA shape
126
+ (`2026-08-27a - ItemFulfillmentTableViewsRebuild.sql`): base record Units 31 to
127
+ ItemFulfillmentItems 29, tracking via the item-level bridge 318 to TrackingNumbers 62 to
128
+ ShippingCarriers 9, serial/asset tag via ItemFulfillmentItemUnits 30 to Units 31, added the missing
129
+ `quantity-fulfilled` column to view 13, column order aligned to USA, Canada-only `_imageUrl` /
130
+ `urlLogo` / sortable-copyable flags preserved. Fixes the `table-views/meta` 500 caused by dead
131
+ `Core.Records` 41 and `Core.RecordFields` 211 / 321. Applied to prod, verified 200 via Postman.
132
+ Also recorded that **Canada's role ids differ from USA's** (7 = Contractor here, Compass Base
133
+ there), so ACL rows cannot be copied between the two tenants, and that `sales-order-shipments` (6)
134
+ and `units` (11) remain broken. (bala)
@@ -16,7 +16,7 @@ project: _Underscore
16
16
  client: compass-canada
17
17
  type: profile
18
18
  status: active
19
- updated: 2026-08-27
19
+ updated: 2026-08-28
20
20
  owners: [jcardinal, bala, tcox, apeterson]
21
21
  files: []
22
22
  related:
@@ -30,6 +30,7 @@ related:
30
30
  - ../compass-usa/features/isfulfillable-data-quality-and-type-rule.md
31
31
  - features/french-order-email-localization.md
32
32
  - features/order-fulfillment-status-per-line.md
33
+ - features/item-fulfillment-tableviews.md
33
34
  - ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
34
35
  - ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
35
36
  - ../../2.0/apps/_underscore/features/surface-resolver.md
@@ -166,4 +167,18 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
166
167
  - **Item titles in customer emails** come from the `ItemTranslations` sidecar (prod: 220 fr-CA rows,
167
168
  100% coverage of every item ever ordered). Resolve the English/translated fallback **in PHP**, never
168
169
  as a SQL `COALESCE` — the two columns' collations differ and differ *per client DB*.
170
+ - **Canada's TOGa Supply fulfillment table views were never migrated with the platform — two are
171
+ now fixed, two are still broken.** `item-fulfillments-for-sales-orders` (12) and
172
+ `item-fulfillments-for-sales-order-items` (13) 500'd on `/v2/table-views/meta` because their
173
+ `TableViewJoins` still referenced deleted `Core.Records` **41** and `Core.RecordFields` **211** /
174
+ **321**; rebuilt onto the Compass USA shape (base record 29, tracking via bridge 318) by
175
+ `dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql`, prod-applied
176
+ and verified. **`sales-order-shipments` (6) and `units` (11) still carry the same dead ids and
177
+ still 500**, and Canada's `units` view is intentionally a different design from USA's. See
178
+ [Item-Fulfillment TableViews](features/item-fulfillment-tableviews.md).
179
+ - **⚠ Canada's `Roles` ids are NOT Compass USA's — never copy ACL rows between the two tenants.**
180
+ Canada: 1 Base, 7 **Contractor**, 8 **Compass Base**, 9 Manager. USA: 1 Base, 7 **Compass Base**,
181
+ 8 Manager. USA grants the fulfillment records to roles 3 + 7; Canada grants most of them to role 1
182
+ only. Nearly every logged-in Canada user carries role 1, so this is usually invisible — but ~10
183
+ users hold Compass Base without Base and still hit the gap.
169
184
  - Related: [Compass USA](../compass-usa/profile.md).
@@ -8,6 +8,7 @@
8
8
  | [Prudential: OneUptime order-pipeline backlog monitors (Monitor/Prudential/*)](features/oneuptime-order-pipeline-monitors.md) | 2.0 | Three worker2 OneUptime push monitors (TRUE-80587) that watch the Prudential/Dell order pipeline for **stalls**, which were previously invisible — a stuck pipel | worker2/Worker/Monitor/Prudential.php, worker2/Config/production.ini, dbchanges2/Core/2026-08-07a - Prudential AIG ServiceNow OneUptime monitor crons.sql, dbchanges2/Client_Prudential/2026-04-08 - Insert Customers.sql |
9
9
  | [Prudential: Service Request Regional Address Validation](features/service-request-address-validation.md) | 2.0 | The `prePost` interceptor on `_Model_Prudential_ServiceRequest` validates `deliverToAddress` fields differently depending on which Prudential regional customer | _underscore/Model/Prudential/ServiceRequest.php, _underscore/Test/Prudential/ServiceRequestTest.php |
10
10
  | [Prudential: Service Request rejection alert email](features/service-request-rejection-alert-email.md) | 2.0 | When a Prudential ServiceNow→TOGa service-request submission (`POST /v2/service-requests`) is **rejected by validation**, TOGa now sends a real-time internal al | worker2/Worker/Client/Prudential/reports/ReqRejectionEmail.php, _underscore/Model/Prudential/ServiceRequest.php |
11
+ | [Prudential: Sales Orders CSV export (TOGa 2.5 Supply)](features/supply-orders-csv-export.md) | 2.0 | The **Export** button on the TOGa 2.5 Supply *Sales Orders* page is **Prudential-only**: it downloads a 22-column CSV (`prudential-orders-<YYYY-MM-DD>.csv`) cov | toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/PRUDENTIAL/exportApiFields.json, toga25-supply/src/pages/SalesOrders/api/prudentialExportApi.ts, toga25-supply/src/pages/SalesOrders/hooks/usePrudentialOrdersExport.ts, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx |
11
12
  | [Prudential Order Shipped Email — transmit_ordershipped_updates_prudential.php](features/transmit-ordershipped-email.md) | 1.0 | Cron script that transmits "Order Shipped" updates to ServiceNow (RITM) and sends a shipped notification email to the end user. | worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php, worker/crons/toga2/prudential/transmit_closecomplete_updates_prudential.php, worker/crons/toga2/prudential/transmit_rejected_cancelled_updates_prudential.php, worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php, worker/crons/toga2/prudential_beta/generate_sales_and_purchase_orders_from_service_requests.php, worker/crons/notifications/reports/prudential_exception_report.php |
12
13
  | [Prudential Financial](profile.md) | 2.0 | Prudential is a TOGA client whose device-fulfillment flow is driven by **Dell** via the Dell API (`Client_Prudential.Apis.id = 2`). | |
13
14
  | [Prudential: Dell ASN failed POST backfill replay](workflows/dell-asn-backfill-replay.md) | 2.0 | When Dell ASN POSTs fail in bulk (e.g. | |
@@ -0,0 +1,147 @@
1
+ ---
2
+ title: "Prudential: Sales Orders CSV export (TOGa 2.5 Supply)"
3
+ framework: "2.0"
4
+ repo: toga25-supply
5
+ project: TOGa 2.5 Supply
6
+ client: prudential
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-28
10
+ owners: ["bala"]
11
+ files:
12
+ - toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/PRUDENTIAL/exportApiFields.json
13
+ - toga25-supply/src/pages/SalesOrders/api/prudentialExportApi.ts
14
+ - toga25-supply/src/pages/SalesOrders/hooks/usePrudentialOrdersExport.ts
15
+ - toga25-supply/src/pages/SalesOrders/SalesOrders.tsx
16
+ related:
17
+ - ../profile.md
18
+ - ../../../2.0/apps/_underscore/features/units-for-items-for-purchase-orders.md
19
+ - ../../../2.0/apps/toga25-supply/workflows/porting-a-page-from-toga2-supply.md
20
+ - ../../../2.0/apps/toga25-supply/features/client-api-fetch-fields.md
21
+ - ../../../1.0/apps/worker/features/send-shipments-for-the-day.md
22
+ ---
23
+
24
+ ## Summary
25
+
26
+ The **Export** button on the TOGa 2.5 Supply *Sales Orders* page is **Prudential-only**: it
27
+ downloads a 22-column CSV (`prudential-orders-<YYYY-MM-DD>.csv`) covering every Prudential unit —
28
+ both the units shipped on a Dell ASN and the units that have **never** shipped. It is a port of
29
+ `toga2-supply`'s `getPrudentialOrders` + `useExportableData`, and the column set and file name are
30
+ deliberately identical so Prudential's downstream consumers get the same file from either app.
31
+
32
+ This doc records the **two queries** it runs, the two join mistakes that made large parts of the
33
+ CSV blank, and the fan-out/dedupe rule that comes with the fix.
34
+
35
+ ## Key files
36
+
37
+ | File | Role |
38
+ |---|---|
39
+ | `viewModel/FIELDS/PRUDENTIAL/exportApiFields.json` | The two query projections (`fields` / `join` / `ojoin` / `where` / `recordsPerPage`) |
40
+ | `api/prudentialExportApi.ts` | Paginates each query, reshapes orphan units, dedupes, merges |
41
+ | `hooks/usePrudentialOrdersExport.ts` | Formats the 22 columns, CSV-escapes, triggers the download |
42
+ | `SalesOrders.tsx` | Renders the button (unmodified by this work) |
43
+
44
+ ## How it works
45
+
46
+ **Two queries, because one cannot see the other's rows.**
47
+
48
+ 1. **`advanceShippingNotices`** (`/advance-shipping-notices`) — rooted at ASNs; every unit that
49
+ has shipped, with tracking number + status and `dateShipped`.
50
+ 2. **`orphanUnits`** (`/units`, `where AdvanceShippingNoticeItemUnits.id = null`) — units that
51
+ were never shipped on an ASN. These are **structurally absent** from an ASN-rooted result (no
52
+ `AdvanceShippingNoticeItemUnits` row to join through), not merely null, which is why a second
53
+ query is required rather than an outer join on the first.
54
+
55
+ Each query is paged at `recordsPerPage: 1000` by `fetchAllPages()`, which loops
56
+ `while (allRows.length < totalRecordCount)` and takes the first array value out of the response
57
+ envelope rather than hardcoding the route key.
58
+
59
+ Orphan-unit rows are **reshaped** before merging: `/units` returns the unit's own fields at the
60
+ row top level (`name`, `serialNumber`) while `/advance-shipping-notices` nests them under `Units`,
61
+ so the reshape re-nests them and stubs `dateShipped` / `TrackingNumbers` to null — the formatter
62
+ then sees one uniform row shape.
63
+
64
+ The merged rows are formatted into 22 columns and written as UTF-8 CSV **with a BOM** (so Excel
65
+ does not mangle it); cells containing `,` `"` or a newline are quoted with doubled quotes.
66
+
67
+ ## Data model (join chains)
68
+
69
+ **Shipped units** — the same chain the 1.0 cron
70
+ [`send_shipments_for_the_day.php`](../../../1.0/apps/worker/features/send-shipments-for-the-day.md)
71
+ walks:
72
+
73
+ ```
74
+ AdvanceShippingNotices
75
+ -> AdvanceShippingNoticeItems (advanceShippingNoticeId)
76
+ -> PurchaseOrders (AdvanceShippingNotices.purchaseOrderId)
77
+ -> SalesOrders_PurchaseOrders -> SalesOrders
78
+ -> ServiceRequests (SalesOrders.serviceRequestId) -> ServiceRequestTypes
79
+ -> AdvanceShippingNoticeItemUnits -> ..._TrackingNumbers -> TrackingNumbers
80
+ -> Units -> Items
81
+ -> Contacts (SalesOrders.contactId)
82
+ -> Addresses (SalesOrders.shipToAddressId) -> States
83
+ ```
84
+
85
+ **Never-shipped units** — rooted at `Units`, INNER joins out to the order:
86
+
87
+ ```
88
+ Units
89
+ -> ServiceRequests_Units (unitId) [INNER]
90
+ -> ServiceRequests [INNER]
91
+ -> SalesOrders (SalesOrders.serviceRequestId = ServiceRequests.id) [INNER]
92
+ -> SalesOrders_PurchaseOrders -> PurchaseOrders [INNER]
93
+ ... plus OUTER joins to Items / Contacts / Addresses / States / PurchaseOrderStages
94
+ ```
95
+
96
+ INNER is deliberate: a unit with no resolvable ServiceRequest chain has no order data at all and
97
+ does not belong in a **purchase-order-centric** export.
98
+
99
+ ## Decisions
100
+
101
+ - **Contacts join `SalesOrders.contactId`, never `Units.contactId`.** `Units.contactId` stays NULL
102
+ until Dell assigns the asset. Measured on prod `Client_Prudential` (2026-08-27, read-only):
103
+ **12,327 of 29,685** ASN unit rows (~42%) have `Units.contactId` NULL, while
104
+ `SalesOrders.contactId` is NULL on **zero** rows.
105
+ - **Order data for never-shipped units resolves via `ServiceRequests_Units`, not
106
+ `SalesOrderItems_CommittedUnits`.** That table has **0 rows** in `Client_Prudential`, so the old
107
+ path blanked PO number, SO number, REQ, RITM and contact on every never-shipped row.
108
+ `ServiceRequests_Units` has 30,268 rows; of 39,121 never-shipped units, **9,846** resolve to a
109
+ full PO chain through it.
110
+ - **Contact name renders blank, not `"undefined undefined"`.** `formatContactName()` filters falsy
111
+ parts before joining. This is a **deliberate divergence** from `toga2-supply`, which interpolates
112
+ `${firstName} ${lastName}` and emits the literal text `undefined undefined` into the CSV when a
113
+ sales order has no contact.
114
+ - **Ship-to address comes from the SalesOrder, not the ASN.** Dell drops `line2` (apartment/floor)
115
+ from the `shipToAddress` it posts back on the ASN; the SalesOrder address keeps it as originally
116
+ captured. Revert to `AdvanceShippingNotices.shipToAddressId` only once Dell sends `line2`.
117
+
118
+ ## Gotchas
119
+
120
+ - **`ServiceRequests_Units` is many-to-many and fans units out.** Generic/template units (docks,
121
+ monitors with no serial number) link to very many ServiceRequests, so the join produces one row
122
+ per distinct PurchaseOrder the unit resolves to — **9,846 distinct units become 21,970 rows** in
123
+ prod. `dedupeUnitsByUuid()` collapses them; which PurchaseOrder "wins" is **arbitrary** (first row
124
+ returned). This is why `uuid` had to be added to the `orphanUnits` field list.
125
+ - **Dedupe must run AFTER the pagination loop, never inside it.** `fetchAllPages()` terminates on
126
+ `while (allRows.length < totalRecordCount)`. Removing rows mid-loop means the accumulated count
127
+ never reaches `totalRecordCount` — the pager spins forever / over-fetches.
128
+ - **The button is gated on `clientSlug === "PRUDENTIAL"`** (from `useClientFields()`).
129
+ `SalesOrders.tsx` calls `usePrudentialOrdersExport()` **unconditionally for every client**, but
130
+ the hook has no `useEffect`/`useQuery` — the only fetch lives inside the `exportToCsv` callback —
131
+ so non-Prudential tenants fire **zero** requests. Keep it that way: adding a query hook here would
132
+ make every tenant pay for a Prudential-only export.
133
+ - **Blast radius is a closed chain:** `exportApiFields.json` → `prudentialExportApi.ts` →
134
+ `usePrudentialOrdersExport.ts` → `SalesOrders.tsx`. No other client and no other page is affected.
135
+ - **This export was a stale port.** The 2.5 version was taken from a `toga2-supply` revision that
136
+ predated both fixes above (upstream `bfaae3a9c`, `d4c0a612f`). See
137
+ [porting a page from toga2-supply](../../../2.0/apps/toga25-supply/workflows/porting-a-page-from-toga2-supply.md)
138
+ before porting anything else.
139
+
140
+ ## Change history
141
+ - 2026-08-28 — Fixed two blank-column bugs carried in from a stale port: Contacts now join
142
+ `SalesOrders.contactId` (was `Units.contactId`, NULL on ~42% of ASN unit rows), and never-shipped
143
+ units resolve order data through `ServiceRequests_Units → ServiceRequests → SalesOrders →
144
+ SalesOrders_PurchaseOrders → PurchaseOrders` (was `SalesOrderItems_CommittedUnits`, 0 rows in
145
+ `Client_Prudential`). Added `dedupeUnitsByUuid()` for the many-to-many fan-out (9,846 units →
146
+ 21,970 rows) and `formatContactName()` so a contact-less order exports an empty cell instead of
147
+ `"undefined undefined"`. (bala)
@@ -10,11 +10,12 @@ apps:
10
10
  - worker2
11
11
  - library
12
12
  - toga2-commerce
13
+ - toga25-supply
13
14
  project: _Underscore
14
15
  client: prudential
15
16
  type: profile
16
17
  status: active
17
- updated: 2026-08-17
18
+ updated: 2026-08-28
18
19
  owners: ["jcardinal", "rgirish", "bala", "mhammontree"]
19
20
  files: []
20
21
  related:
@@ -25,6 +26,7 @@ related:
25
26
  - features/dell-lch-iop-transmissions.md
26
27
  - features/device-information-import-and-contact-linking.md
27
28
  - workflows/http2-alb-workaround.md
29
+ - features/supply-orders-csv-export.md
28
30
  ---
29
31
 
30
32
  ## Summary
@@ -96,3 +98,6 @@ order-status transmissions.
96
98
  - features/transmit-ordershipped-email.md
97
99
  - features/device-information-import-and-contact-linking.md — device-sync cron, unit→contact
98
100
  linking, the "null null" contact-export incident + dedup/backfill remediation.
101
+ - features/supply-orders-csv-export.md — the Prudential-only Sales Orders CSV export in TOGa 2.5
102
+ Supply (`toga25-supply`): its two queries, why contacts join `SalesOrders.contactId` (not
103
+ `Units.contactId`, NULL until Dell assigns the asset), and the `ServiceRequests_Units` fan-out.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.680",
3
+ "version": "1.0.682",
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",