toga-ai 1.0.722 → 1.0.724

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.
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-31
9
+ updated: 2026-09-01
10
10
  owners: ["dfranks", "bala", "jcardinal", "mhammontree", "snaredla"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
@@ -22,6 +22,7 @@ files:
22
22
  - test/@srija/Elite Testing/Service Requests/test_diagnose_togasupply_elite.php
23
23
  related:
24
24
  - ../architecture.md
25
+ - ../../../2.0/apps/_underscore/features/shipping-carriers-and-accounts.md
25
26
  - forecast2-netsuite-reconciliation.md
26
27
  - ../workflows/onboarding-client-to-netsuite-togasupply-sync.md
27
28
  - ../../library/features/toga2-api-client-and-bridge.md
@@ -336,6 +337,27 @@ Parameters are stored **per client DB** but accessed **through the TOGa2 API**,
336
337
 
337
338
  ## Gotchas / known issues
338
339
 
340
+ - **⚠ A lookup seeded as a BY-PRODUCT of another list will miss records — this created runaway
341
+ duplicate `ShippingCarriers` rows (fixed 2026-09-01, TRUE-81576).**
342
+ `$lookupShippingCarrierByClientUuidAndShippingCarrierNameUpper` was built only inside the
343
+ `/shipping-methods` loop, harvesting each method's parent carrier, so **a carrier with ZERO
344
+ shipping methods never entered the lookup**. The invoice sync's `!isset($lookup[$name])` check in
345
+ `library/app/api/toga2.php` (~L3194, fed by `App_Misc::getCarrierFromTrackingNumber`) then created
346
+ it **again every run** — and the new row also had no methods, so it stayed invisible. Unbounded
347
+ growth, one row per 5-minute sync. **The tell:** only **USPS and DHL** duplicated (17x / 6x on
348
+ GroWrk) — the two carriers with zero methods; FedEx (7 methods) and UPS (4) had exactly one row
349
+ each. Now seeded directly from a paged `GET /shipping-carriers`
350
+ (`MAX_RECORDS_PER_PAGE_TO_FETCH_FROM_TOGA_API` = 1000), blank names skipped. Full write-up:
351
+ [Shipping Carriers & Carrier Account Numbers](../../../2.0/apps/_underscore/features/shipping-carriers-and-accounts.md).
352
+ Same lesson as the manufacturer/catalog bug below: **initialise and seed a lookup from its own
353
+ source, not from a neighbouring loop.**
354
+ - **⚠ A blank-named carrier matched EVERYTHING** — the matcher in `library/app/api/toga2.php` uses
355
+ `stripos($name, $carrierName) === 0`, and under PHP 8 an **empty needle returns `int(0)`**. The
356
+ placeholder became a catch-all and, having no account numbers, was invisible in the UI. Blank
357
+ names are now skipped **and** the creation branch reuses the existing blank placeholder instead of
358
+ POSTing a new `name => ''` carrier each run. **Those two changes must ship together** — skipping
359
+ blank names alone would make the placeholder permanently unmatchable and increase creation.
360
+
339
361
  - **A client with NEITHER a manufacturer NOR a catalog on its items used to crash the whole run
340
362
  (fixed 2026-08-12).** `$lookupItemByClientUuidAndPartNumberUpper[$uuidClient]` and
341
363
  `$lookupAllCatalogItemsByClientUuidAndPartNumberUpper[$uuidClient]` were only keyed **inside** the
@@ -821,6 +843,15 @@ Parameters are stored **per client DB** but accessed **through the TOGa2 API**,
821
843
 
822
844
  ## Change history
823
845
 
846
+ - 2026-09-01 — **TRUE-81576: stopped runaway duplicate `ShippingCarriers` creation (worker + library).**
847
+ `common_sync_togasupply.php` now seeds the carrier lookup from its own paged `GET /shipping-carriers`
848
+ loop instead of harvesting carriers out of the `/shipping-methods` loop — a carrier with zero
849
+ shipping methods was absent from the lookup, so the invoice sync recreated it every run (GroWrk had
850
+ 17 USPS + 6 DHL duplicates; NYCHH 100 carriers, Compass 98). In `library/app/api/toga2.php` the
851
+ carrier matcher now **skips blank names** (an empty `stripos` needle returns `int(0)` and matched
852
+ every ship method) and the creation branch **reuses the existing blank placeholder** rather than
853
+ POSTing another empty-named carrier. Committed on branch `TRUE-81576`, PRs open. (mhammontree)
854
+
824
855
  - 2026-08-31 — **Item-receipt partial-receipt NULL-quantity fix (shared, `library`).**
825
856
  `syncItemReceiptFromNetsuite` now **skips line items whose `quantity` is null** (unreceived PO lines on a
826
857
  partial receipt) in both passes over `$nsItemReceipt->itemList->item` (payload-build ~L3376 + unit/serial
@@ -10,7 +10,7 @@
10
10
  | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
11
11
  | [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
12
12
  | [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
13
- | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent | _underscore/Model.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/ServiceRequest.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/Canada/SalesOrder.php, api2/Component/Api/V2/V2.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 |
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/PurchaseOrder.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/ServiceRequest.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/Canada/SalesOrder.php, api2/Component/Api/V2/V2.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 |
@@ -41,11 +41,12 @@
41
41
  | [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 |
42
42
  | [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 |
43
43
  | [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 |
44
+ | [Shipping Carriers & Carrier Account Numbers (records, matching, duplication, deletion)](features/shipping-carriers-and-accounts.md) | `ShippingCarriers` is a **per-client** table (one row per carrier, per client DB) that the NetSuite syncs create on demand and that Fulfill & Ship reads to buil | _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ShippingMethod.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, toga2-supply/src/pages/EditShipment/api/UpdateShipmentApi.ts, api2/Component/Api/V2/V2.php |
44
45
  | [_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 |
45
46
  | [Surface FIELD ↔ RecordField binding (maxLength / validationType / isWritable fold-in)](features/surface-field-recordfield-binding.md) | A resolver capability that makes a surface `FIELD` element carry its **input contract** — max length, validator name, and whether the caller may write it — inst | _underscore/Model/Core/Surface.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/AclFieldPermission.php, _underscore/Model/Client/RecordFieldSetting.php, _underscore/Model/Client/ClientRecordFieldSetting.php, dbchanges2/Core/2026-08-14c - RecordFieldsValidationMetadata.sql, dbchanges2/Client/2026-08-14a - RecordFieldSettingsClientTable.sql, api2/Component/Api/V2/V2.php |
46
47
  | [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 |
47
48
  | [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 |
48
- | [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, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql |
49
+ | [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, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql, dbchanges2/Client_Nychh/2026-08-31a - PurchaseOrdersTableViewRebase.sql |
49
50
  | [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 |
50
51
  | [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 |
51
52
  | [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 |
@@ -6,10 +6,11 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-09-01
10
10
  owners: [snaredla, jcardinal, bala]
11
11
  files:
12
12
  - _underscore/Model.php
13
+ - _underscore/Model/Client/PurchaseOrder.php
13
14
  - _underscore/Model/Client/SalesOrder.php
14
15
  - _underscore/Model/Client/ServiceRequest.php
15
16
  - _underscore/Model/Elite/SalesOrder.php
@@ -210,6 +211,13 @@ characters — that is the standard of evidence for touching a field that runs o
210
211
  [save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
211
212
 
212
213
  ## Change history
214
+ - 2026-09-01 — Added `_shipTo` to the base `_Model_Client_PurchaseOrder` (`public $_shipTo =
215
+ self::FIELD_SQL;` + static `_shipTo($table = self::TABLE)` returning a correlated subquery
216
+ `SELECT Addresses.addressee FROM Addresses WHERE Addresses.id = $table.shipToAddressId`),
217
+ paralleling `SalesOrders._shipTo`. Registered here via a **`Core.RecordFields`** row (id 2555,
218
+ recordId 17 `purchase-orders`, type STRING, mirroring `SalesOrders._shipTo` id 626) —
219
+ i.e. the Core-registration path, not the `CustomRecordFields`-only path this doc shows is also
220
+ sufficient. Additive and shared: it renders only where a view references it. (jcardinal)
213
221
  - 2026-08-26 - Documented **how to override a calculated field for one client / sub-client**: api2
214
222
  resolves the model class by `str_replace('_Model_Client_', '_Model_' . $clientIdentifier . '_', ...)`
215
223
  (`Component/Api/V2/V2.php`), and because the expression is invoked as
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-24
9
+ updated: 2026-09-01
10
10
  owners: [mhammontree, tcox, bala]
11
11
  files:
12
12
  - test/@Mark/true-80824-fedex-inflate-test.php
@@ -28,6 +28,8 @@ files:
28
28
  - _underscore/Cloud.php
29
29
  related:
30
30
  - ../architecture.md
31
+ - shipping-carriers-and-accounts.md
32
+ - address-validation.md
31
33
  - field-storage-row-hydration.md
32
34
  - recursive-item-fulfillments.md
33
35
  - ../../toga2-supply/features/fulfill-and-ship.md
@@ -336,6 +338,54 @@ framework-level note is pending on the `_underscore` architecture doc.
336
338
  **unconditionally**, so a *dirty read* (reading a storage field after assigning but before saving)
337
339
  still discards the unsaved in-memory value.
338
340
 
341
+ ## Account association: why a valid credential still fails (resolved 2026-09-01, TRUE-81576)
342
+
343
+ **Carrier API credentials are GLOBAL; account numbers are PER-CLIENT.** `_Config::fedex(...)` and
344
+ `_Config::ups(...)` read one platform-wide config group (`[fedex]` / `[ups]` in
345
+ `api2/Config/production.ini`) and **neither carries an account number** — that comes only from the
346
+ client's `ShippingCarrierAccountNumbers` row. So **a client's own carrier account works only if the
347
+ carrier has authorised TOGA's API credentials to bill it.** See
348
+ [Shipping Carriers & Carrier Account Numbers](shipping-carriers-and-accounts.md).
349
+
350
+ **The long-running FedEx `SHIPMENT.ACCOUNTNUMBER.UNAUTHORIZED` failure (`ship/v1/shipments`) was an
351
+ account-association problem, not a code bug.** The FedEx API developer account is
352
+ **`jcardinal@goagilant.com`**, whose credentials live in **1Password** — that is where Fulfill &
353
+ Ship's authorised account ID is configured. Using that account ID **succeeded in production**.
354
+ (Location only — no credential values or account numbers belong in this doc.)
355
+
356
+ ### ⚠ Address-validation success is NOT evidence the Ship API works
357
+
358
+ The `/addresses/validate` USPS→FedEx→UPS cascade can succeed while `ship/v1/shipments` rejects the
359
+ account: **different carrier API surfaces, separate entitlements.** This is exactly what made a ship
360
+ path that had **never worked in production** look like a regression, and it cost significant
361
+ investigation time. Supporting detail: the config carries **separate
362
+ `tracking_client_id` / `tracking_client_secret`** from the ship credentials, implying tracking and
363
+ shipping are provisioned as **different FedEx projects** — an account attached to the tracking
364
+ project but not the ship project produces exactly this error.
365
+
366
+ ### Two diagnostic shortcuts worth remembering
367
+
368
+ - **Reaching a FedEx BUSINESS-level error proves the OAuth token was issued.**
369
+ `Fedex::getAccessToken()` throws `'Invalid FedEx API Response'` on **any non-200** from
370
+ `/oauth/token`, so a business error means the **credentials are valid** and only the account link
371
+ is missing.
372
+ - **UPS's equivalent is `"Missing or invalid shipper number"`.** That string exists **nowhere in our
373
+ codebase** (verified by grep), so it is UPS's own message and proves the account **was
374
+ transmitted**. Our own guard reads *"A carrier account number is required to generate a shipping
375
+ label."* (`Model/Client/ItemFulfillment.php` ~L794.)
376
+
377
+ ### Where carrier error history actually lives
378
+
379
+ - **FedEx has NO outbound log row** — the ship call runs `$api->setLogging(false)`
380
+ (`Fedex.php` ~L280), deliberate because a successful ship response carries the full base64 label
381
+ (megabytes).
382
+ - The **inbound** `/v2/item-fulfillments/fedexShipmentApi` call **is** recorded in `Logs.Api`
383
+ (`responseCode` 200, with the carrier error inside `responsePayload`) — **that is the only complete
384
+ history of label attempts.**
385
+ - **UPS outbound IS logged** (`direction = OUT`, `hostname LIKE '%ups.com%'`).
386
+ - A carrier failure returns **HTTP 200 with an `error` key in `data`**, so it will **not** show up in
387
+ a 5xx search.
388
+
339
389
  ## Gotchas / known issues
340
390
 
341
391
  - **⚠ `[ups] label_format` must be set in EVERY environment's api2 config.** Without it **production
@@ -444,6 +494,16 @@ still discards the unsaved in-memory value.
444
494
  bill) — expect a hard error rather than a silently-wrong bill in those cases.
445
495
 
446
496
  ## Change history
497
+ - 2026-09-01 — TRUE-81576: **resolved the long-standing FedEx `SHIPMENT.ACCOUNTNUMBER.UNAUTHORIZED`** —
498
+ it was an **account-association** problem, not code. The authorised account ID belongs to the FedEx
499
+ developer account `jcardinal@goagilant.com` (credentials in **1Password**); using it succeeded in
500
+ production. Recorded the architecture behind it (carrier **credentials are global**, **account
501
+ numbers per-client** in `ShippingCarrierAccountNumbers`), the trap that **address-validation success
502
+ proves nothing about the Ship API** (separate entitlements; separate `tracking_client_*` creds imply
503
+ separate FedEx projects), the two diagnostic shortcuts (a FedEx *business* error proves the OAuth
504
+ token issued; UPS's "Missing or invalid shipper number" is UPS's own string, not ours), and where
505
+ carrier error history actually lives (FedEx: inbound `Logs.Api` only; UPS: outbound logged; failures
506
+ return HTTP 200). TRUE-80824's gzip-decode fix is what made this error readable at all. (mhammontree)
447
507
  - 2026-08-24 — Added **batch tracking-status polling** to the carrier libs for the platform-wide
448
508
  [tracking-status refresh](../../worker2/features/tracking-status-refresh.md):
449
509
  `Fedex::getStatuses()` (~30/call) and `Usps::getStatuses()` (~10/call), reusing the single-shipment
@@ -0,0 +1,169 @@
1
+ ---
2
+ title: Shipping Carriers & Carrier Account Numbers (records, matching, duplication, deletion)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-01
10
+ owners: [mhammontree]
11
+ files:
12
+ - _underscore/Trait/Netsuite/SalesOrder.php
13
+ - _underscore/Component/Library/Carriers/Fedex/Fedex.php
14
+ - _underscore/Component/Library/Carriers/Ups/Ups.php
15
+ - _underscore/Model/Client/ItemFulfillment.php
16
+ - _underscore/Model/Client/ShippingMethod.php
17
+ - library/app/api/toga2.php
18
+ - worker/crons/toga2/netsuite/common_sync_togasupply.php
19
+ - toga2-supply/src/pages/EditShipment/api/UpdateShipmentApi.ts
20
+ - api2/Component/Api/V2/V2.php
21
+ related:
22
+ - carrier-shipping-labels.md
23
+ - address-validation.md
24
+ - ../../toga2-supply/features/fulfill-and-ship.md
25
+ - ../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
26
+ - ../../../clients/growrk/features/shipping-carrier-cleanup.md
27
+ ---
28
+
29
+ ## Summary
30
+
31
+ `ShippingCarriers` is a **per-client** table (one row per carrier, per client DB) that the
32
+ NetSuite syncs create on demand and that Fulfill & Ship reads to build its Carrier dropdown.
33
+ Two independent code paths create carrier rows — the 1.0 worker sync
34
+ (`library/app/api/toga2.php`) and the 2.0 NetSuite sales-order trait
35
+ (`_underscore/Trait/Netsuite/SalesOrder.php`) — and both got the "does it already exist?"
36
+ question wrong in different ways, which produced **runaway duplicate rows** and a
37
+ **blank-named catch-all carrier** in production. TRUE-81576 fixed both.
38
+
39
+ ## Data model
40
+
41
+ - **`ShippingCarriers`** — `id`, `uuid`, `name`. Rows are auto-created by the syncs.
42
+ - **`ShippingMethods.shippingCarrierId`** — the carrier's ship methods (`code` = the carrier
43
+ service code, e.g. UPS `03` Ground / `01` Next Day Air).
44
+ - **`ShippingCarrierAccountNumbers`** — `id`, `uuid`, `shippingCarrierId` (FK), `name`,
45
+ `accountNumber`. **This is the only place a per-client carrier account lives.**
46
+ - `ShippingCarriers.defaultShippingCarrierAccountNumberId` — the client's default account.
47
+
48
+ ### Credentials are GLOBAL; account numbers are PER-CLIENT
49
+
50
+ Both carrier clients read **one platform-wide config group** — `_Config::fedex(...)` /
51
+ `_Config::ups(...)`, defined as `[fedex]` and `[ups]` in `api2/Config/production.ini` — and
52
+ **neither group contains an account number**. The shipper/account number comes **only** from
53
+ `ShippingCarrierAccountNumbers`. Consequence: **a client's own carrier account only works if the
54
+ carrier has authorised TOGA's API credentials to bill it.** A perfectly valid account number that
55
+ is not linked to our API app fails at ship time. This is the architectural reason behind both the
56
+ FedEx and UPS account failures documented in [carrier shipping labels](carrier-shipping-labels.md).
57
+
58
+ ### No account number means invisible in the UI
59
+
60
+ The Fulfill & Ship **Carrier dropdown only lists carriers with at least one
61
+ `ShippingCarrierAccountNumbers` row**. A carrier with no account row is unselectable no matter how
62
+ it is configured. (GroWrk has FedEx and UPS accounts and no USPS account, so USPS cannot be
63
+ picked there.)
64
+
65
+ ## Runaway duplicate carriers — root cause (fixed TRUE-81576)
66
+
67
+ `worker/crons/toga2/netsuite/common_sync_togasupply.php` built
68
+ `$lookupShippingCarrierByClientUuidAndShippingCarrierNameUpper` **only as a by-product of its
69
+ `/shipping-methods` loop**, harvesting each method's parent carrier. **A carrier with ZERO
70
+ shipping methods therefore never entered the lookup.** The invoice sync's
71
+ `!isset($lookup[$name])` check in `library/app/api/toga2.php` (~L3194, fed by
72
+ `App_Misc::getCarrierFromTrackingNumber`) then created the carrier **again** — and the new row
73
+ also had no methods, so it stayed invisible to the next run. **One extra row per sync, forever.**
74
+
75
+ **The diagnostic that cracked it:** the duplicates were **only USPS and DHL** (17x and 6x on
76
+ GroWrk) — exactly the two carriers with **zero** shipping methods. FedEx (7 methods) and UPS
77
+ (4 methods) had **exactly one row each** and were never duplicated. That asymmetry disproved an
78
+ earlier pagination theory and pointed straight at the lookup's source. Scale found in the sweep:
79
+ GroWrk 28 carriers, NYC Health & Hospitals 100, Compass 98, Quad 11, AIG/Rate/Elite 4.
80
+
81
+ **Fix:** a paged `GET /shipping-carriers` loop that **seeds the lookup directly**, using the same
82
+ `for ($page = 1, $nextPage = 0; ...)` idiom and `MAX_RECORDS_PER_PAGE_TO_FETCH_FROM_TOGA_API`
83
+ (1000) as the neighbouring lookups. Blank names are skipped — they are unusable as keys.
84
+
85
+ ## A blank-named carrier is a WILDCARD (PHP stripos with an empty needle)
86
+
87
+ The carrier matcher keys on `stripos($nsShippingMethodName, $carrierName) === 0`. Under PHP 8 an
88
+ **empty needle returns `int(0)`** — verified directly:
89
+
90
+ ```
91
+ php -r 'var_dump(stripos("UPS Ground", ""));' // int(0)
92
+ ```
93
+
94
+ So a carrier row with a blank `name` **matched every ship-method name** and became a catch-all.
95
+ Compounding it: a blank-named carrier has no account numbers, and the UI filters the dropdown to
96
+ carriers with at least one account — so every method swallowed by the placeholder became
97
+ **unreachable in the UI**. Blank names are now **skipped in both matchers**
98
+ (`Trait/Netsuite/SalesOrder.php` and `library/app/api/toga2.php`). The added `(string)` cast also
99
+ removes a PHP 8.1 null-to-non-nullable deprecation on a null `->name`.
100
+
101
+ ### The skip and the placeholder-reuse MUST ship together
102
+
103
+ The creation branch used to `POST` a **new** carrier with `name => ''` every time nothing matched,
104
+ instead of reusing the placeholder it created on the previous run. It now reuses the existing
105
+ blank placeholder. **Skipping blank names WITHOUT the reuse would leave the placeholder
106
+ permanently unmatchable and INCREASE row creation.**
107
+
108
+ ### Resolve the placeholder BEFORE the shipping-method lookup
109
+
110
+ (Found in review by php-reviewer.) *Where* the blank-placeholder fallback is resolved changes
111
+ matching semantics. Resolving it **after** the shipping-method lookup leaves `$shippingCarrier`
112
+ null through that lookup, which (a) drops the `ShippingCarriers` join added by
113
+ `if ($shippingCarrier)` and (b) short-circuits the uuid comparison via
114
+ `is_null($shippingCarrier) || ...`. The lookup then degrades into a **carrier-blind global name
115
+ match** that can bind this NetSuite ship-method id onto a *real* carrier's identically-named
116
+ method (an unmapped "Ground" landing on **UPS Ground**). Resolve the placeholder **before** the
117
+ method lookup so the query stays scoped exactly as before. The `$nsShippingMethodName`
118
+ carrier-prefix strip is unaffected either way (`strlen('')` = 0 was already a no-op).
119
+
120
+ ## An unpaginated V2 list GET SILENTLY TRUNCATES
121
+
122
+ `api2/Component/Api/V2/V2.php`: `DEFAULT_RECORDS_PER_PAGE = 25`, `MAX_RECORDS_PER_PAGE = 10000`,
123
+ and results come back **ordered by uuid**. There is **no error and no warning** — records simply
124
+ are not in the response.
125
+
126
+ **Concrete impact:** GroWrk had 30 carriers; **UPS** (uuid `db26a116...`) sorted *after* the
127
+ page-1 cutoff (`d513f1ff...`) and was dropped. UPS was therefore unselectable in the Fulfill &
128
+ Ship carrier dropdown **and** invisible to the 2.0 sync's carrier matcher — which also meant the
129
+ UPS service codes populated under TRUE-80824 (`03` Ground / `01` Next Day Air) were never
130
+ reachable. Compass (98) and NYCHH (100) are 4x over the default and were affected the same way.
131
+
132
+ **Fix:** `recordsPerPage => 1000` on the `_underscore` carrier lookup; `recordsPerPage: 1000` plus
133
+ `sort: ["name"]` on toga2-supply's `getShippingCarriers()`.
134
+
135
+ **Convention note (toga2-supply):** the `sort` option is a **flat array of field-name strings**,
136
+ with a `-` prefix for descending (e.g. `sort: ["-dateOrder"]`) — **not** objects like
137
+ `[{ name: "ASC" }]`. `ExtendedOptionsType` is `any`, so **tsc will not catch a wrong shape.**
138
+
139
+ ## Delete-safety — every column that references a carrier
140
+
141
+ Before deleting a `ShippingCarriers` row in a client DB, check **all** of:
142
+
143
+ - `TrackingNumbers.shippingCarrierId` (**direct**)
144
+ - `TrackingNumbers.shippingCarrierAccountNumberId`
145
+ - `InvoiceTrackingNumbers.shippingCarrierId`
146
+ - `ShippingMethods.shippingCarrierId`
147
+ - `ShippingCarriers.defaultShippingCarrierAccountNumberId`
148
+
149
+ **`TrackingNumbers` reaches a carrier BOTH directly and via `shippingMethodId`, so a single-path
150
+ check is not sufficient.** Rows with any reference must be repointed to the canonical carrier
151
+ first. See the [GroWrk carrier cleanup](../../../clients/growrk/features/shipping-carrier-cleanup.md).
152
+
153
+ ## Gotchas / known issues
154
+
155
+ - **Cleaning duplicates before the fix is deployed is wasted work** — the syncs refill the rows on
156
+ the next run (every 5 minutes). Deploy first, clean second.
157
+ - Compass (98 carriers) and NYC Health & Hospitals (100) are **not** cleaned yet — separate tasks.
158
+ - The `[fedex]` config group contains a key literally named **`_`** with a 32-character value.
159
+ Purpose unknown; no reader has been traced.
160
+
161
+ ## Change history
162
+ - 2026-09-01 — TRUE-81576: root-caused and fixed **runaway duplicate `ShippingCarriers`** (the 1.0
163
+ sync lookup was seeded only from `/shipping-methods`, so a method-less carrier was recreated
164
+ every run) by seeding from a paged `GET /shipping-carriers`; **skipped blank carrier names in
165
+ both matchers** (an empty `stripos` needle matches everything) and made the creation branch
166
+ **reuse** the blank placeholder instead of POSTing a new one; resolved the placeholder **before**
167
+ the shipping-method lookup to keep that query carrier-scoped; and set `recordsPerPage` on both
168
+ carrier list GETs after an unpaginated 25-row page silently dropped GroWrk's UPS row.
169
+ (mhammontree)
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-28
10
- owners: [bala, apeterson]
9
+ updated: 2026-09-01
10
+ owners: [bala, apeterson, jcardinal]
11
11
  files:
12
12
  - _underscore/Model/Client/TableView.php
13
13
  - _underscore/Model.php
@@ -17,6 +17,7 @@ files:
17
17
  - dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql
18
18
  - dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql
19
19
  - dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql
20
+ - dbchanges2/Client_Nychh/2026-08-31a - PurchaseOrdersTableViewRebase.sql
20
21
  related:
21
22
  - ../../api2/features/tableview-field-metadata.md
22
23
  - ./page-meta-context-field-settings.md
@@ -193,6 +194,28 @@ grants of any kind — `Records` **351** (`transfer-order-stages`) had none befo
193
194
  `Client_Nychh/2026-08-28a`, so the Status column of a brand-new view would have vanished silently.
194
195
  Check the joined record's grants before assuming an existing record is grant-covered.
195
196
 
197
+ ## A third mode: an INNER join to an EMPTY FK returns an EMPTY GRID — not an error
198
+
199
+ The two `meta()` failures above both throw. There is a third, quieter mode that is **not an
200
+ error at all**: a view loads, the header/meta looks right, and the data grid shows **zero rows**.
201
+
202
+ Cause: an **INNER** `TableViewJoins` on a foreign-key column that is **unpopulated for that
203
+ tenant** drops every row, because an inner join with no matching parent row returns nothing.
204
+ Confirmed 2026-09-01 on the NYCHH `purchase-orders` view: `Client_Nychh.PurchaseOrders` had **0 of
205
+ 175 rows** with a populated `locationId`, so an INNER `Locations` join
206
+ (`Locations.id = PurchaseOrders.locationId`) emptied the whole grid. The meta API looked correct;
207
+ the data API just returned 0 results.
208
+
209
+ - **The blast radius is per tenant.** The same view can serve fine for a client whose FK is
210
+ populated and go empty for one whose FK is not — so it reads as "the grid is broken for this
211
+ client only", exactly like an ACL gap, but the join type is the cause.
212
+ - **Fix / avoid:** for an **optional / possibly-empty** FK, use **LEFT OUTER** (rows survive with
213
+ nulls in the joined columns) or omit the column entirely. Only use INNER when the FK is
214
+ guaranteed populated for the tenant.
215
+ - **Check before you add:** verify FK population in the target tenant
216
+ (`SELECT COUNT(*) ... WHERE <fk> IS NOT NULL`) before choosing INNER. NYCHH's `purchase-orders`
217
+ ultimately **dropped** the Location (and Ship To) column rather than switch to OUTER.
218
+
196
219
  ## Context RecordFieldSettings are OPTIONAL for a joined column to render
197
220
 
198
221
  `meta()` looks up `contextRecordFieldId`-scoped settings (`DefaultRecordFieldSettings` plus the
@@ -251,9 +274,19 @@ Core-level reference ids used (identical across all clients and environments): *
251
274
  read the missing log row as "the request never happened".
252
275
  - **An unused join can already exist.** Confirm before inserting; a duplicate join of the same table
253
276
  is what breaks chained joins.
277
+ - **An INNER join to an EMPTY FK empties the whole grid with NO error.** Third failure mode: an
278
+ inner join on an FK that is unpopulated for the tenant drops every row (meta looks fine, data
279
+ returns 0). Use LEFT OUTER for an optional FK, or verify FK population before choosing INNER.
254
280
 
255
281
  ## Change history
256
282
 
283
+ - 2026-09-01 — Added a **third `meta()` failure mode that is not an error**: an **INNER** join to a
284
+ foreign key that is **unpopulated for the tenant** returns an **empty grid** (meta correct, data
285
+ 0 rows), because an inner join with no matching parent row drops everything. Found on NYCHH's
286
+ `purchase-orders` view — `Client_Nychh.PurchaseOrders` had 0 of 175 rows with a populated
287
+ `locationId`, so an INNER `Locations` join emptied the grid. Rule: use LEFT OUTER (or omit the
288
+ column) for an optional/possibly-empty FK, and verify FK population in the target tenant before
289
+ using INNER. (jcardinal)
257
290
  - 2026-08-28 — Separated the **two distinct ways `meta()` 500s**, after Compass Canada's
258
291
  `item-fulfillments-for-sales-order(-item)s` views returned 500 while Compass USA's served fine.
259
292
  (a) A join pointing at a **deleted `Core.Records` / `Core.RecordFields` row** throws from
@@ -6,7 +6,7 @@ project: TOGa Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-11
9
+ updated: 2026-09-01
10
10
  owners: [mhammontree]
11
11
  files:
12
12
  - _underscore/Trait/Netsuite/SalesOrder.php
@@ -56,6 +56,7 @@ files:
56
56
  - _underscore/Component/Library/Carriers/Ups/Ups.php
57
57
  related:
58
58
  - ../architecture.md
59
+ - ../../_underscore/features/shipping-carriers-and-accounts.md
59
60
  - ../../_underscore/features/carrier-shipping-labels.md
60
61
  - ../../_underscore/features/recursive-item-fulfillments.md
61
62
  - ../../_underscore/features/netsuite-salesorder-address-phone-sync.md
@@ -402,6 +403,24 @@ the call 403s and returns no label.
402
403
 
403
404
  ## Gotchas / known issues
404
405
 
406
+ - **⚠ The Carrier dropdown silently lost UPS because the list GET was UNPAGINATED (fixed
407
+ 2026-09-01, TRUE-81576).** api2's `DEFAULT_RECORDS_PER_PAGE` is **25** and list results come back
408
+ **ordered by uuid** — no error, no warning, records just are not there. GroWrk had 30 carriers and
409
+ **UPS** (uuid `db26a116...`) sorted after the page-1 cutoff (`d513f1ff...`), so UPS was unselectable
410
+ in the dropdown **and** invisible to the 2.0 sync's carrier matcher, which also made the TRUE-80824
411
+ UPS service codes (`03` Ground / `01` Next Day Air) unreachable. `getShippingCarriers()` now sends
412
+ **`recordsPerPage: 1000`** and **`sort: ["name"]`**. Compass (98) and NYCHH (100) were affected too.
413
+ - **⚠ The `sort` option is a FLAT ARRAY OF FIELD-NAME STRINGS**, `-` prefix for descending
414
+ (`sort: ["-dateOrder"]`) — **not** objects like `[{ name: "ASC" }]`. `ExtendedOptionsType` is `any`,
415
+ so **tsc will not catch a wrong shape**; it just silently does nothing.
416
+ - **⚠ The Carrier dropdown only lists carriers that have at least one
417
+ `ShippingCarrierAccountNumbers` row.** A carrier with no account number is invisible regardless of
418
+ configuration — GroWrk has FedEx and UPS accounts but no USPS account, so USPS cannot be selected.
419
+ - **⚠ Address validation succeeding tells you NOTHING about the Ship API.** `/addresses/validate`
420
+ (USPS→FedEx→UPS) and `ship/v1/shipments` are different carrier surfaces with separate entitlements;
421
+ a green address check next to a failing label buy is normal, not a contradiction. See
422
+ [carrier shipping labels](../../_underscore/features/carrier-shipping-labels.md).
423
+
405
424
  - **⚠⚠ A config-driven validator MUST skip DISPLAY-ONLY fields — this caused a TOTAL production
406
425
  submit outage (2026-07-29 → 2026-07-31, all clients).** `DUMMYUPDATESHIPMENTFIELDS.json` contains a
407
426
  **display-only section header** (id 31, "Address") with **`valueKey: ""` and `isRequired: true`**.
@@ -820,6 +839,13 @@ not the base `_Model_Client_ItemFulfillment`. Tested with GroWrk; UPS support wa
820
839
  for Compass and is not yet in prod.
821
840
 
822
841
  ## Change history
842
+ - 2026-09-01 — TRUE-81576: **UPS reappeared in the Carrier dropdown.** `getShippingCarriers()` was an
843
+ unpaginated list GET, so api2's 25-row uuid-ordered default page dropped GroWrk's UPS carrier
844
+ entirely; now sends `recordsPerPage: 1000` and `sort: ["name"]`. Also documented that the dropdown
845
+ only shows carriers with a `ShippingCarrierAccountNumbers` row, that the `sort` option is a flat
846
+ array of field-name strings (typed `any`, so tsc will not catch a wrong shape), and that address
847
+ validation succeeding is not evidence the Ship API works. Verified in production alongside the
848
+ GroWrk carrier cleanup. (mhammontree)
823
849
  - 2026-08-11 — TRUE-80824: **Reference 1 / Reference 2 / Phone # are now PREFILLED from NetSuite**
824
850
  (still user-overridable, still not persisted) — **correcting the earlier SME note that they are
825
851
  never prefilled**. Confirmed sources against production: Ref1 = `SalesOrders.number`; Ref2 =
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-29
8
+ updated: 2026-09-01
9
9
  owners: [jcardinal, mhammontree, dfranks, bala]
10
10
  files: []
11
11
  related:
@@ -547,6 +547,11 @@ clients/compass-usa/features/stranded-approval-reassignment.md
547
547
  * A whole-file ending flip turns a one-line change into a full-file rewrite in `git diff`, burying the real change, breaking `git blame`, and inviting merge conflicts.
548
548
  * **Check before committing:** if `git diff --stat` shows a tiny edit as a near-total rewrite, the endings flipped. Restore them — e.g. for an LF file: `tr -d '\r' < file > file.tmp && mv file.tmp file` — then re-stage.
549
549
 
550
+ #### These repos use CRLF line endings — normalise on programmatic writes
551
+
552
+ A scripted or tool-driven edit that writes LF into a CRLF file produces **mixed line endings** and a
553
+ diff that appears to touch the whole file. Any script that rewrites a source file must preserve CRLF.
554
+
550
555
  #### **Braces**
551
556
 
552
557
  * Opening braces for classes, methods and control structures (`if`, `else`, `for`, `while`, etc.) should go on the same line.
@@ -752,6 +757,22 @@ re-verify against `Controller/Index.php` before relying on it.
752
757
 
753
758
  * Always use HTTPS to encrypt data in transit.
754
759
 
760
+ ### NEVER ship a hardcoded-credential fallback — fail loudly instead
761
+
762
+ `_underscore/Component/Library/Carriers/Fedex/Fedex.php` `initialize()` carries a "production
763
+ fallback" branch with a **literal `client_id` and `client_secret`**. The sandbox branch above it is
764
+ dead code (`elseif (false && _Environment::isDebugMode())`). The hardcoded `client_id` was verified
765
+ programmatically to be **byte-identical** to the one in `api2/Config/production.ini` — i.e. a **live
766
+ production credential committed to git**.
767
+
768
+ Per the team security rule, a committed secret is compromised. Required actions:
769
+ 1. Remove the fallback branch; throw when the `[fedex]` config group is unset.
770
+ 2. Delete the dead `false && ...` sandbox branch.
771
+ 3. Rotate the FedEx API key with FedEx.
772
+
773
+ Rule: **config absence is an error, not a reason to fall back to a baked-in value.** A missing
774
+ config group must throw at startup. Never commit a credential value, in any branch, dead or live.
775
+
755
776
  ## Performance Optimization
756
777
 
757
778
  ### Caching
@@ -870,6 +891,26 @@ client sending a shallow depth.
870
891
  (TRUE-79533) — both read the same starved node. Relevant code: `api2/Component/Api/V2/V2.php`,
871
892
  `api2/Controller/Index.php`.
872
893
 
894
+ ### An unpaginated V2 list GET SILENTLY TRUNCATES — always send `recordsPerPage`
895
+
896
+ `api2/Component/Api/V2/V2.php`: `DEFAULT_RECORDS_PER_PAGE = 25`, `MAX_RECORDS_PER_PAGE = 10000`.
897
+ Omitting `recordsPerPage` returns only the first 25 rows with **no error and no warning** — the rest
898
+ simply are not there, and the caller cannot tell. **No meaningful ordering is guaranteed**: a
899
+ production response was observed coming back in uuid order, so which rows survive truncation is
900
+ effectively arbitrary and can change as data is added.
901
+
902
+ - Any list GET used to **build a lookup or populate a dropdown** must set `recordsPerPage`
903
+ explicitly (1000 is the established value; `MAX_RECORDS_PER_PAGE_TO_FETCH_FROM_TOGA_API` in the
904
+ 1.0 sync) **or** loop pages with the `for ($page = 1, $nextPage = 0; ...)` idiom.
905
+ - Always sort by a **meaningful** field rather than relying on the default.
906
+ - Real cost: GroWrk's UPS carrier sorted past the page-1 cutoff and vanished from the Fulfill & Ship
907
+ carrier dropdown and from the NetSuite carrier matcher, which also made the UPS service codes
908
+ added under TRUE-80824 unreachable. Compass (98 carriers) and NYCHH (100) are 4× over the default.
909
+
910
+ **Frontend note (toga2-supply):** the `sort` option is a flat array of field-name strings with a `-`
911
+ prefix for descending (`sort: ["-dateOrder"]`), **not** objects. `ExtendedOptionsType` is `any`, so
912
+ tsc will not catch a wrong shape — it fails silently.
913
+
873
914
  ### API Versioning
874
915
 
875
916
  * Implement a new version (ie: `/v1`, `/v2`, `/v3`) when a major API framework is introduced to manage the new API framework without breaking existing clients.
@@ -956,6 +997,16 @@ See: 2.0/apps/worker2/features/cross-account-aws-access.md
956
997
 
957
998
  ## Change history
958
999
 
1000
+ - 2026-09-01 — TRUE-81576. Three additions: (1) Security — never ship a hardcoded-credential
1001
+ fallback; the FedEx `initialize()` "production fallback" holds a live `client_id`/`client_secret`
1002
+ byte-identical to `api2/Config/production.ini`, so it must throw on missing `[fedex]` config, the
1003
+ dead `false && ...` sandbox branch must go, and the key must be rotated. (2) API Design — an
1004
+ unpaginated V2 list GET silently truncates at `DEFAULT_RECORDS_PER_PAGE = 25` with no error and no
1005
+ guaranteed ordering; always send `recordsPerPage` (or page the loop) for lookups/dropdowns. Cost:
1006
+ GroWrk's UPS carrier fell off page 1 and disappeared from the Fulfill & Ship dropdown and the
1007
+ NetSuite matcher, stranding the TRUE-80824 service codes. Includes the toga2-supply `sort`
1008
+ flat-string-array note (`ExtendedOptionsType` is `any`, so tsc catches nothing). (3) Code
1009
+ Formatting — programmatic writes must preserve CRLF. (mhammontree)
959
1010
  - 2026-08-29 — Added a fail-loud cross-reference under **PHP Error Handling**: import/sync/cron
960
1011
  code that reconciles external data must never catch-and-continue or log-and-skip a record;
961
1012
  points to the full policy and the NetSuite → TOGa Supply sync data-loss incident recorded in
@@ -2,6 +2,7 @@
2
2
 
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
+ | [GroWrk — shipping-carrier cleanup, UPS visibility, and the NetSuite Fulfill-and-Ship punch-out URL](features/shipping-carrier-cleanup.md) | 2.0 | GroWrk (`Core.Clients` id **33**, DB `Client_Growrk`) is where the runaway `ShippingCarriers` duplication was found and fixed (TRUE-81576). | _underscore/Trait/Netsuite/SalesOrder.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, toga2-supply/src/pages/EditShipment/api/UpdateShipmentApi.ts |
5
6
  | [Transfer Order Flow (NetSuite SalesOrder workaround → dedicated TransferOrders)](features/transfer-order-flow.md) | 2.0 | GroWrk moves inventory between their own locations (warehouses, distribution points). | _underscore/Model/Client/TransferOrder.php, _underscore/Model/Client/TransferOrderItem.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemReceipt.php, _underscore/Model/Client/ItemReceiptItem.php, _underscore/Trait/Netsuite/TransferOrder.php, dbchanges2/Client_Growrk/, dbchanges2/Client/ |
6
7
  | [GroWrk NetSuite-module custom fields — the never-executed 2026-05-28 commit (Units + ServiceRequests 500s)](features/units-netsuite-custom-fields.md) | 2.0 | GroWrk is on the dbchanges2 **`netsuite`** module (`dbchanges2/Client_Growrk/_modules.txt`), so its Units carry the module's seven `c_` custom fields. | _underscore/Trait/Netsuite/Unit.php, _underscore/Model/Growrk/Unit.php, _underscore/Model/Growrk/ItemFulfillment.php, dbchanges2/Client_Growrk/2026-05-28.sql, dbchanges2/_modules/netsuite/2026-07-10a - UnitInventoryFields.sql, dbchanges2/Client_Growrk/2026-08-10 - GrowrkUnitInventoryFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10b - GrowrkUnitItemDescriptionAcl.sql, dbchanges2/Client_Growrk/2026-08-10c - GrowrkServiceRequestCustomFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10d - GrowrkServiceRequestTypeAndDispositionSeeds.sql, dbchanges2/Client_Growrk/_modules.txt |
7
8
  | [GroWrk](profile.md) | 2.0 | GroWrk is a 2.0 client using TOGA for inventory and order management. | |
@@ -0,0 +1,81 @@
1
+ ---
2
+ title: "GroWrk — shipping-carrier cleanup, UPS visibility, and the NetSuite Fulfill-and-Ship punch-out URL"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: growrk
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-09-01
10
+ owners: ["mhammontree"]
11
+ files:
12
+ - _underscore/Trait/Netsuite/SalesOrder.php
13
+ - library/app/api/toga2.php
14
+ - worker/crons/toga2/netsuite/common_sync_togasupply.php
15
+ - toga2-supply/src/pages/EditShipment/api/UpdateShipmentApi.ts
16
+ related:
17
+ - ../profile.md
18
+ - ../../../2.0/apps/_underscore/features/shipping-carriers-and-accounts.md
19
+ - ../../../2.0/apps/_underscore/features/carrier-shipping-labels.md
20
+ - ../../../2.0/apps/toga2-supply/features/fulfill-and-ship.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ GroWrk (`Core.Clients` id **33**, DB `Client_Growrk`) is where the runaway
26
+ `ShippingCarriers` duplication was found and fixed (TRUE-81576). GroWrk carried **28** carrier
27
+ rows — 17 duplicate USPS and 6 duplicate DHL, all with zero shipping methods. The mechanics are
28
+ shared and live in
29
+ [Shipping Carriers & Carrier Account Numbers](../../../2.0/apps/_underscore/features/shipping-carriers-and-accounts.md);
30
+ this doc records what was done to GroWrk's **data** and what is still parked.
31
+
32
+ ## What was done (2026-09-01, production, by hand)
33
+
34
+ **20 fully-orphaned carrier rows** (zero shipping methods, zero account numbers, zero tracking
35
+ references) were **deleted manually in production**, taking GroWrk from **28 carriers to 8**.
36
+ Immediately after, **UPS became visible and selectable** in the Fulfill & Ship carrier dropdown,
37
+ and the NetSuite phone prefill from TRUE-80824 was confirmed working in the same test.
38
+
39
+ **Decision (Mark): treated as a one-off — no `dbchanges2` migration was written.** The code fixes
40
+ prevent recurrence, so a migration would only re-run a cleanup that cannot happen again. **Note
41
+ this leaves production ahead of `dbchanges2` for that table.**
42
+
43
+ ## Parked follow-ups (not done in this ticket)
44
+
45
+ - **Carrier ids 15, 17 and 20 could NOT be deleted.** Each still has **one direct
46
+ `TrackingNumbers.shippingCarrierId` reference**. Those rows must be repointed to the canonical
47
+ **USPS (id 3)** first. (Remember `TrackingNumbers` reaches a carrier both directly and through
48
+ `shippingMethodId` — check both paths.)
49
+ - **GroWrk UPS shipping methods ids 11 and 12** ("Overnight Standard" / "Overnight Priority")
50
+ still have **NULL `code`**, and both are **FedEx product names sitting on UPS rows**.
51
+ - **Compass (98 carriers) and NYC Health & Hospitals (100) are not cleaned** — separate tasks, and
52
+ the cleanup must happen **after** this deploy or the rows refill.
53
+
54
+ ## Known issue — the NetSuite "Fulfill and Ship" button opens spglobal.togasupply.com
55
+
56
+ **Not caused by anything in our repos.** Ruled out during this session:
57
+
58
+ - **No Fulfill & Ship SuiteScript exists anywhere in the workspace.** The only NetSuite user
59
+ scripts present are `NetSuite_Prudential_Bridge.js`, two forecast exports, and test-folder junk.
60
+ - **No PHP builds a `/shipment-items` or `/edit-shipment` URL.**
61
+ - **`Core.Domains` is correct for GroWrk** — id **400**, `clientId` 33, `appId` 1,
62
+ `environmentId` 1 → `https://growrk.togasupply.com`.
63
+
64
+ The URL is therefore built **inside NetSuite's File Cabinet**. Leading hypothesis: **S&P Global**
65
+ is `Core.Clients` id **4** with `Domains` ids 7-9 and was one of the earliest TOGa Supply tenants,
66
+ so the punch-out was likely built for them first and the target is **hardcoded or read from a
67
+ script parameter that is unset for the GroWrk subsidiary**. Where to look: the script on the Sales
68
+ Order / Item Fulfillment form, its script parameters, and any subsidiary-to-URL custom record.
69
+
70
+ ## Data drift
71
+
72
+ `Core.Domains` id **403** (`growrk.alpha.togasupply.com`) has **NULL `clientId` AND NULL `appId`**,
73
+ unlike every other GroWrk row. Harmless in production; **it will misbehave on alpha.**
74
+
75
+ ## Change history
76
+ - 2026-09-01 — TRUE-81576: deleted **20 orphaned `ShippingCarriers` rows** in production by hand
77
+ (28 → 8); UPS became selectable in Fulfill & Ship and the TRUE-80824 phone prefill was confirmed.
78
+ Recorded the parked items (carrier ids 15/17/20 blocked by direct `TrackingNumbers` references;
79
+ UPS methods 11/12 with NULL codes and FedEx names), the NetSuite punch-out URL pointing at
80
+ `spglobal.togasupply.com` (built in NetSuite, not our code), and the NULL `clientId`/`appId` drift
81
+ on `Core.Domains` id 403. Deliberately **no dbchanges2 migration** — one-off cleanup. (mhammontree)
@@ -13,12 +13,13 @@ project: _Underscore
13
13
  client: growrk
14
14
  type: profile
15
15
  status: active
16
- updated: 2026-08-28
16
+ updated: 2026-09-01
17
17
  owners: ["rgirish", "mhammontree", "bala", "jcardinal"]
18
18
  files: []
19
19
  related:
20
20
  - clients/growrk/features/transfer-order-flow.md
21
21
  - clients/growrk/features/units-netsuite-custom-fields.md
22
+ - clients/growrk/features/shipping-carrier-cleanup.md
22
23
  ---
23
24
 
24
25
  ## Summary
@@ -38,6 +39,14 @@ module (`dbchanges2/Client_Growrk/_modules.txt`).
38
39
 
39
40
  ## Gotchas
40
41
 
42
+ - **Carrier rows were duplicating every sync and UPS was invisible in Fulfill & Ship** (fixed
43
+ TRUE-81576, 2026-09-01). 20 orphaned `ShippingCarriers` rows were deleted **by hand in production**
44
+ (28 → 8) with **no dbchanges2 migration**, so production is ahead of `dbchanges2` for that table.
45
+ Carrier ids **15/17/20** are still blocked by direct `TrackingNumbers` references. Details:
46
+ [shipping-carrier cleanup](features/shipping-carrier-cleanup.md).
47
+ - **`Core.Domains` id 403 (`growrk.alpha.togasupply.com`) has NULL `clientId` AND NULL `appId`** —
48
+ real data drift; harmless in production, will misbehave on alpha.
49
+
41
50
  - **`_Model_Growrk_Unit`'s `c_` columns must physically exist on `Client_Growrk.Units` — in EVERY
42
51
  environment, production included.** The model body is empty: it is only
43
52
  `extends _Model_Client_Unit` + `use _Trait_Netsuite_Unit`, and **the trait** declares all seven
@@ -6,4 +6,4 @@
6
6
  | [NYCHH NetSuite → TransferOrders import (stocking-flag routing, PO bridges, origin location)](features/netsuite-transfer-order-import.md) | 1.0 | NYCHH's NetSuite **transfer orders are represented as NetSuite sales orders** (same as the GroWrk pattern), so the shared NetSuite → TOGa Supply importer (`App_ | library/app/api/toga2.php, library/app/api/netsuite/rest.php, library/app/netsuite.php, worker/crons/toga2/netsuite/sync_togasupply_hh.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/diagnose_hh_stuck_transfer_order.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, dbchanges2/Client/2026-08-28a - TransferOrderItemsTimestampColumns.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestampColumnsReposition.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql |
7
7
  | [NYCHH PO links are UPSTREAM — the downstream SO→PO route returns empty](features/po-number-upstream-direction.md) | 2.0 | NYCHH's sales orders are created **from the customer's purchase order**, so their SO↔PO links live in the **upstream** table `PurchaseOrders_SalesOrders` (route | _underscore/Model/Client/SalesOrder.php, _underscore/Trait/Netsuite/SalesOrder.php, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql, dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql |
8
8
  | [NYCHH transfer-order 2.0 model — inventory quantities + V2 field enablement](features/transfer-order-inventory-quantities.md) | 2.0 | The 2.0 (`_underscore`) side of NYCHH transfer-order support: a **two-branch inventory quantity model** on the NYCHH `Item` override, plus the **client-override | _underscore/Model/Nychh/Item.php, _underscore/Model/Nychh/PurchaseOrderItem.php, _underscore/Model/Nychh/TransferOrder.php, _underscore/Model/Nychh/TransferOrderStage.php, _underscore/Model/Client/TransferOrder.php, _underscore/Model/Client/TransferOrderItem.php, dbchanges2/Client_Nychh/2026-08-27a - TransferOrderCustomFieldWritePermission.sql, dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql |
9
- | [NYC Health & Hospitals](profile.md) | 2.0 | NYC Health & Hospitals (NYCHH) is a TOGA 2.0 client on the `_underscore` platform, prod schema `Client_Nychh`. | dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql |
9
+ | [NYC Health & Hospitals](profile.md) | 2.0 | NYC Health & Hospitals (NYCHH) is a TOGA 2.0 client on the `_underscore` platform, prod schema `Client_Nychh`. | dbchanges2/Client_Nychh/2026-08-31a - PurchaseOrdersTableViewRebase.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql |
@@ -16,9 +16,10 @@ project: _Underscore
16
16
  client: nychh
17
17
  type: profile
18
18
  status: active
19
- updated: 2026-08-31
19
+ updated: 2026-09-01
20
20
  owners: ["jcardinal", "apeterson", "bala", "akhokhani"]
21
21
  files:
22
+ - dbchanges2/Client_Nychh/2026-08-31a - PurchaseOrdersTableViewRebase.sql
22
23
  - dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql
23
24
  - dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql
24
25
  - dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql
@@ -48,6 +49,19 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
48
49
  run the NetSuite→TOGa Supply sync and the asset-tag verification/backfill diagnostics.
49
50
 
50
51
  ## Key features (this client)
52
+ - **`purchase-orders` table view was rebased off the wrong record (2026-09-01).** The view was
53
+ based on **SalesOrders (Core record 14)** but had to be **PurchaseOrders (record 17)** — it was
54
+ mislabeled. `Client_Nychh/2026-08-31a - PurchaseOrdersTableViewRebase.sql` flips
55
+ `TableViews.recordId` 14→17, drops the SalesOrders→bridge→PurchaseOrders join chain, removes the
56
+ "Sales Order number" column (`SalesOrders.number`, RecordFields 49), and repoints the rest to PO
57
+ fields — final columns **number (base field 87), dateOrder (86), total (`_total` 1429)**. Location
58
+ and Ship To were intentionally **omitted**: PO `locationId` is empty for this tenant, so an INNER
59
+ Locations join would empty the grid (see the third failure mode in
60
+ [tableview-joins](../../2.0/apps/_underscore/features/tableview-joins.md)). The migration also
61
+ seeds a **Developer-role (role 2)** record ACL grant chain (`AclRecordPermissions` →
62
+ `AclLogicGroups` → `AclLogicGroupExpressions`, reusing record 17's `all` expression) and reads
63
+ `AclFieldPermissions` for the displayed fields, resolving `roleId` by role **name**. Written
64
+ idempotent / uuid-keyed; applied to **prod and client-sandbox**.
51
65
  - **`inventory_units` table view carries item detail as of 2026-08-18** — Part Number, Manufacturer
52
66
  and Item Description sit directly after Asset Tag, reached through a **chained** Units → Items →
53
67
  Manufacturers join (`dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql`, identical
@@ -112,6 +126,8 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
112
126
  [NYCHH PO Number direction](./features/po-number-upstream-direction.md).
113
127
 
114
128
  ## Notes
129
+ - **Role ids in `Client_Nychh` (verified 2026-09-01):** `1 = Base`, `2 = Developer`, `3 = API`.
130
+ Role ids differ per client DB — resolve by **name** in migrations, never hard-code these.
115
131
  - **PO Number is upstream-sourced** (`PurchaseOrders_SalesOrders`), which is why the downstream
116
132
  SO→PO route and the shared `_purchaseOrders` field came back empty. Details, and the
117
133
  now-closed-on-`_beta` fix: [NYCHH PO Number](./features/po-number-upstream-direction.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.722",
3
+ "version": "1.0.724",
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",