toga-ai 1.0.600 → 1.0.602

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
11
11
  | [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
12
12
  | [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
13
- | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent | _underscore/Model.php |
13
+ | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent | _underscore/Model.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/ServiceRequest.php, _underscore/Model/Elite/SalesOrder.php, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql |
14
14
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | test/@Mark/true-80824-fedex-inflate-test.php, dbchanges2/Client_Growrk/2026-08-10e - GrowrkUpsServiceMethodCodes.sql, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/ShipmentRequest/ShipmentRequest.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
15
15
  | [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the | _underscore/Database.php, _underscore/Environment.php, api2/Initialize.php |
16
16
  | [_Cloud S3 helpers (copy / get / delete / list)](features/cloud-s3-helpers.md) | `_Cloud` centralizes AWS SDK S3 usage for the 2.0 stack so the `S3Client` never leaks into workers or app code. | _underscore/Cloud.php |
@@ -24,24 +24,24 @@
24
24
  | [FIELD_STORAGE fields — per-row lazy hydration and the platform-wide missing-column 500](features/field-storage-row-hydration.md) | `FIELD_STORAGE` is the 2.0 field type for blob-backed columns (S3 or local folder). | _underscore/Model.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/Invoice.php, dbchanges2/Core/HISTORIC/2024/2024-11b - item-fulfillments.sql |
25
25
  | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
26
26
  | [isFulfillable Propagation Up the SO↔PO Chain](features/fulfillable-item-propagation.md) | `Items.isFulfillable` is a boolean that gates whether a storefront line's **Qty Fulfilled** cell is actionable. | _underscore/Model/Client/Item.php, _underscore/Model/Compass/Item.php, toga2-supply/src/pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx, dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql |
27
- | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
27
+ | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
28
28
  | [DB-free unit testing for _underscore model interceptors](features/model-interceptor-unit-testing.md) | `_underscore` shipped with **no** PHPUnit setup (no `composer.json`/`phpunit`; only vendored PhpOffice tests existed). | _underscore/Test/bootstrap.php, _underscore/Test/Prudential/ServiceRequestTest.php, test/@Bala/tests/netsuite_salesorder_payload_tests.php |
29
29
  | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php, _underscore/Model.php, _underscore/Model/Rate/Subscription.php |
30
30
  | [_Model::save() parent FK cascade — stored-SQL-field recompute deadlocks](features/model-save-parent-cascade-stored-field-deadlock.md) | `_Model::save()` runs a **generic parent foreign-key cascade**: inserting (or saving) a child row that carries an FK to a parent causes `_Model` to **re-load an | _underscore/Model.php, _underscore/Model/Client/PurchaseOrder.php, _underscore/Model/Client/AdvanceShippingNotice.php |
31
31
  | [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php, _underscore/Model/Rate/Subscription.php |
32
32
  | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
33
33
  | [NetSuite Sales Order sync — ship-to address, phone, and PO reference sourcing](features/netsuite-salesorder-address-phone-sync.md) | `_Trait_Netsuite_SalesOrder` is the **shared** sales-order importer composed into **22 client models** (every client on the dbchanges2 `netsuite` module). | _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Model.php, dbchanges2/_modules/netsuite/2026-08-10a - AddressPhoneNumberApiRoleAcl.sql |
34
- | [Legacy page meta (Page::meta) & context-scoped ClientRecordFieldSettings](features/page-meta-context-field-settings.md) | `_Model_Core_Page::meta()` is the **legacy** page-meta resolver behind `GET /pages/meta?slug=<slug>` — still the live path for `toga2-supply` and other pre-Surf | _underscore/Model/Core/Page.php, _underscore/Model/Client/TableView.php |
34
+ | [Legacy page meta (Page::meta) & context-scoped ClientRecordFieldSettings](features/page-meta-context-field-settings.md) | `_Model_Core_Page::meta()` is the **legacy** page-meta resolver behind `GET /pages/meta?slug=<slug>` — still the live path for `toga2-supply` and other pre-Surf | _underscore/Model/Core/Page.php, _underscore/Model/Client/TableView.php, toga2-supply/src/components/ui/Tables/PrimaryTable/PrimaryTable.tsx |
35
35
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/Model.php, _underscore/Query.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
36
36
  | [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
37
37
  | [Record Change Audit Log (Logs_<Client>.Record / RecordField) — reading a field's history](features/record-change-audit-log.md) | Every 2.0 client schema has a sibling **logs** schema `Logs_<Tenant>` (e.g. | _underscore/Model/Client/Logs/Record.php, _underscore/Model/Client/Logs/RecordField.php, _underscore/Model/Client/Logs/CustomRecordField.php |
38
- | [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql |
38
+ | [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql, dbchanges2/Client_Compass/2026-08-18 - FixSA135471HeroItemHalfQuantityFulfillment.sql |
39
39
  | [_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 |
40
40
  | [Surface Resolver (_Model_Core_Surface::resolve — replaces Page::meta)](features/surface-resolver.md) | The runtime for the platform-wide **Surface** UI presentation layer: 9 `_underscore` models plus a cached resolver, `_Model_Core_Surface::resolve(&$api, string | _underscore/Model/Core/Surface.php, _underscore/Model/Client/AclRecordScript.php, _underscore/Model/Core/RecordScript.php, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Quad/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Client_CompassCanada/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Client_Compass/2026-07-15f - SalesOrderRecordActionsRemoveDeadConfigRuleOverrides.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, _underscore/Model/Core/SurfaceElement.php, _underscore/Model/Core/Action.php, _underscore/Model/Core/Vocabulary.php, _underscore/Model/Core/VocabularyTerm.php, _underscore/Model/Core/Message.php, _underscore/Model/Client/SurfaceOverride.php, _underscore/Model/Client/MessageTranslation.php, _underscore/Model/Client/ThemeToken.php, _underscore/Model/Core/Page.php, api2/Component/Api/V2/V2.php |
41
41
  | [Table-View Hyperlink Columns (meta → ACL → computed URL → render)](features/tableview-hyperlink-columns.md) | Any 2.0 table-view column can render its value as a clickable link instead of plain text. | _underscore/Model/Client/TableView.php, _underscore/Model/Client/TrackingNumber.php, api2/Component/Api/V2/V2.php, toga2-supply/src/api/toga.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/convertData.tsx, toga2-supply/src/components/ui/Tables/hooks/useDataTableState.tsx, dbchanges2/Client/2026-07-20 - TrackingNumberHyperlinkAndFieldPermission.sql |
42
42
  | [TogaIQ Gateway Client (_Component_Api_Togaiq) — AI generate/translate from 2.0](features/togaiq-gateway-client.md) | `_Component_Api_Togaiq` is the 2.0 framework's client for the **TogaIQ** (Talos) AI gateway. | _underscore/Component/Api/Togaiq/Togaiq.php, _underscore/ApiRequest.php |
43
- | [Tracking-Number Bridge Migration (ASN / Item Fulfillment / Item Receipt)](features/tracking-number-bridges.md) | Shipment tracking numbers used to live as **scalar FK columns** (`trackingNumberId`, `returnTrackingNumberId`) directly on the lowest-level "unit"/"item" tables | api2/Component/Api/V2/V2.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnit.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillmentItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Prudential/AdvanceShippingNotice.php, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Trait/Netsuite/ItemFulfillment.php, api2/Component/Api/Cxml/Cxml.php, dbchanges2/Client/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Core/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Client_Prudential/2026-06-15 - ItemFulfillmentTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Quad/2026-06-18a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19b - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Growrk/2026-07-13a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql, dbchanges2/Client_Quad/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql |
44
- | [Units for Items for Purchase Orders — Data Structure](features/units-for-items-for-purchase-orders.md) | Describes how unit (serialized inventory) data is linked to sales-order and purchase-order line items behind the `units-for-items-for-purchase-orders` TableView | |
43
+ | [Tracking-Number Bridge Migration (ASN / Item Fulfillment / Item Receipt)](features/tracking-number-bridges.md) | Shipment tracking numbers used to live as **scalar FK columns** (`trackingNumberId`, `returnTrackingNumberId`) directly on the lowest-level "unit"/"item" tables | api2/Component/Api/V2/V2.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnit.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillmentItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Prudential/AdvanceShippingNotice.php, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Trait/Netsuite/ItemFulfillment.php, api2/Component/Api/Cxml/Cxml.php, dbchanges2/Client/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Core/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Client_Prudential/2026-06-15 - ItemFulfillmentTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Quad/2026-06-18a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19b - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Growrk/2026-07-13a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql, dbchanges2/Client_Quad/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql, dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql |
44
+ | [Units for Items for Purchase Orders — Data Structure](features/units-for-items-for-purchase-orders.md) | Describes how unit (serialized inventory) data is linked to sales-order and purchase-order line items behind the `units-for-items-for-purchase-orders` TableView | _underscore/Model/Client/ServiceRequest.php, dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql |
45
45
  | [USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)](features/usps-dpv-deliverability.md) | **USPS returning HTTP 200 with a populated address is NOT evidence that the address is deliverable.** The authoritative signal is USPS's **DPV (Delivery Point V | _underscore/Component/Library/Carriers/Usps/Usps.php, _underscore/Model/Client/Address.php, _underscore/Model/Rate/Entitlement.php |
46
46
  | [Refreshing a Local Dev Database from Beta (dev-sandbox)](workflows/local-db-refresh-from-beta.md) | How to reset a local 2.0 dev database from the **beta / dev-sandbox** environment: dump each schema (`Core`, `Client_<Id>`, `Logs_<Id>`, …) from the beta host, | api2/Config/, _underscore/Loader.php, _underscore/Model/Client/BundleTranslation.php, api2/Component/Api/V2/V2.php, toga25-supply/sync_compasscanada_schema.sql |
47
47
  | [Running a 2.0 App Locally (browser, end-to-end via api2)](workflows/running-a-2.0-app-locally.md) | The full dependency chain required to run a 2.0 client app **through the browser**, end-to-end, against a **local `api2`** (e.g. | _underscore/Environment.php, _underscore/Config.php, _underscore/Database.php, _underscore/Route.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, api2/index.php, api2/.htaccess |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-18
10
10
  owners: ["jcardinal", "mhammontree", "tcox", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -111,7 +111,9 @@ inserted zero rows**.
111
111
 
112
112
  Use **hardcoded `Core.RecordFields` id literals** instead: those ids are explicitly seeded and
113
113
  identical in every environment (the platform's only sanctioned hardcoded ids — see
114
- `2.0/apps/dbchanges2/architecture.md`).
114
+ `2.0/apps/dbchanges2/architecture.md`). **Caveat: that holds for *established* rows only - the
115
+ next free id differs per environment (a new field took 2483 in production and 2766 in
116
+ dev-sandbox), so a row you are creating right now may need a per-environment literal.**
115
117
 
116
118
  **Known-broken existing files (same root cause):** `Client_Rate/2026-07-23a` and
117
119
  `Client/2026-07-16c`. A clean local run is never proof either worked.
@@ -357,6 +359,10 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
357
359
  and every repo is on the **same branch** so the generated model matches the DB.
358
360
 
359
361
  ## Change history
362
+ - 2026-08-18 - Caveated "explicitly seeded and identical in every environment": true for
363
+ established `Core.RecordFields` rows, but the **next free id differs per environment** (a new
364
+ field took 2483 in prod, 2766 in dev-sandbox), so a newly created row may need a per-environment
365
+ literal. Keeps this doc consistent with `2.0/apps/dbchanges2/architecture.md`. (bala)
360
366
 
361
367
  - **2026-08-10** — Added the **custom (`c_`) field THREE-part anatomy** as the counterpart of the
362
368
  standard-field recipe — physical column (missing → MySQL 1054 → 500/`EO-1` on every load),
@@ -6,12 +6,18 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-13
10
- owners: [snaredla, jcardinal]
9
+ updated: 2026-08-18
10
+ owners: [snaredla, jcardinal, bala]
11
11
  files:
12
12
  - _underscore/Model.php
13
+ - _underscore/Model/Client/SalesOrder.php
14
+ - _underscore/Model/Client/ServiceRequest.php
15
+ - _underscore/Model/Elite/SalesOrder.php
16
+ - dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql
13
17
  related:
14
18
  - ../architecture.md
19
+ - ./acl-permission-chain.md
20
+ - ../../api2/features/v2-api-error-codes.md
15
21
  - ./model-magic-field-access.md
16
22
  - ./model-save-parent-cascade-stored-field-deadlock.md
17
23
  ---
@@ -50,8 +56,56 @@ fallback — `_serialNumbers` the property requires `_serialNumbers()` the metho
50
56
  Options: `FIELDOPT_SQL_TYPE` (defaults to `FIELD_CHAR`) and `FIELDOPT_SQL_STORED` (default `false`,
51
57
  recalculated per query; `true` persists to a real column on save).
52
58
 
59
+ ## Exposing a calculated field to the API - a client `CustomRecordFields` row is enough
60
+
61
+ **Declaring the field on the model does not make it requestable.** A client whose DB has no
62
+ metadata row for it gets api2 **`EV-8`** - *"A field specified in your request does not exist"* -
63
+ even though the model inherits the field and the SQL is right there.
64
+
65
+ **The mechanism, and it is counter-intuitive: a calculated field can be exposed purely through a
66
+ client-scoped `CustomRecordFields` row. No `Core.RecordFields` row is required.** Compass proves
67
+ it - `_purchaseOrders` is `CustomRecordFields` id **77** in `Client_Compass`, alongside
68
+ `_totalLease` (81) and `_trackingNumbers` (83), with no Core registration - and the field returns
69
+ fine. The natural assumption ("no Core `RecordFields` row, therefore impossible for any client") is
70
+ **wrong**; it was reached and then disproved in this session, so do not re-derive it.
71
+
72
+ That is what makes rolling a calculated field out to every client cheap: the SQL already lives on
73
+ the shared `_Model_Client_*` model, so each client needs only the metadata row plus its ACL grant.
74
+ `_purchaseOrders` on `SalesOrders` shipped this way in
75
+ `dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql`.
76
+
77
+ **Writing that migration idempotently:**
78
+
79
+ - `INSERT ... SELECT ... WHERE NOT EXISTS`. `CustomRecordFields` is UNIQUE on
80
+ **(`recordId`, `field`)** and `AclCustomFieldPermissions` on
81
+ **(`customRecordFieldId`, `roleId`)**, so the guards line up with the unique keys.
82
+ - **Resolve roles BY NAME** (`'Base'`, `'API'`) - role ids differ per client DB.
83
+ - The grant table is `AclCustomFieldPermissions`, **not** `AclFieldPermissions` - see the
84
+ standard-vs-custom table in [acl-permission-chain](./acl-permission-chain.md).
85
+
86
+ **Verified by running it twice:** against `Client_True` it created 1 field + 2 ACL rows on the
87
+ first pass and nothing on the second; against `Client_Compass` it inserted nothing at all, leaving
88
+ id 77 and its role 1/3/4 grants untouched. Prudential gains the field across **26,280**
89
+ `SalesOrders_PurchaseOrders` rows.
90
+
53
91
  ## Gotchas / known issues
54
92
 
93
+ - **⚠ Never add a parameter TYPE to an override whose parent declares the parameter untyped - it
94
+ is a FATAL, and `php -l` will not catch it.** Overriding a calculated field's static method on a
95
+ `_Model_<Client>_*` subclass is the common case. Because PHP parameter types are
96
+ **contravariant**, adding a type where the parent has none *narrows* the signature and PHP
97
+ refuses:
98
+
99
+ ```
100
+ Declaration of _Model_Elite_SalesOrder::_status(string $alias)
101
+ must be compatible with _Model_Client_SalesOrder::_status($alias)
102
+ ```
103
+
104
+ Adding a **return** type is fine (return types are covariant). `php -l` only checks syntax, so
105
+ the file lints clean and dies at runtime on the first model construction. Verified empirically
106
+ 2026-08-17. This is the one place the team-wide "type-hint every parameter" standard does not
107
+ apply: **match the parent signature exactly.**
108
+
55
109
  - **The leading underscore is REQUIRED — it is not a camelCase-standard violation.** The TOGA
56
110
  naming standard says database/class fields are camelCase, and a reviewer (human or agent) will
57
111
  reflexively flag `_serialNumbers` and "fix" it to `serialNumbers`. Doing so **throws**:
@@ -72,6 +126,17 @@ recalculated per query; `true` persists to a real column on save).
72
126
 
73
127
  ## Change history
74
128
 
129
+ - 2026-08-18 - Documented **how a calculated field becomes requestable**: a client-scoped
130
+ `CustomRecordFields` row is sufficient and **no `Core.RecordFields` row is needed** (Compass's
131
+ `_purchaseOrders` = id 77, with `_totalLease` 81 / `_trackingNumbers` 83, has no Core row and
132
+ works); without the row api2 returns **`EV-8`**. The opposite conclusion was reached and disproved
133
+ this session. Added the idempotent-migration shape (`INSERT ... SELECT ... WHERE NOT EXISTS`
134
+ against the UNIQUE keys `(recordId, field)` and `(customRecordFieldId, roleId)`, roles resolved
135
+ **by name** because role ids differ per client) as shipped in
136
+ `dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql`, verified by double-running
137
+ against `Client_True` and `Client_Compass`. Also added the **override-signature fatal**: adding a
138
+ parameter type to an override whose parent leaves it untyped is a fatal (parameter types are
139
+ contravariant) that `php -l` does not catch - adding a return type is fine. (bala)
75
140
  - 2026-08-13 — Added a gotcha: a `FIELDOPT_SQL_STORED` field that aggregates the model's own
76
141
  children deadlocks under the `_Model::save()` parent cascade (correlated-subquery recompute
77
142
  X-locks the parent + S-locks its children on every child insert). Cross-linked the new
@@ -6,13 +6,15 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-30
9
+ updated: 2026-08-18
10
10
  owners: [bala]
11
11
  files:
12
12
  - _underscore/Model/Client/SalesOrder.php
13
13
  - _underscore/Model/Quad/SalesOrder.php
14
14
  - _underscore/Model/Compass/SalesOrder.php
15
15
  - _underscore/Model/Compass/SalesOrderStatus.php
16
+ - _underscore/Model/Elite/SalesOrder.php
17
+ - _underscore/Model/Elite/SalesOrderStatus.php
16
18
  - _underscore/Model/Client/SalesOrderItem.php
17
19
  - _underscore/Model/Client/Item.php
18
20
  - _underscore/Model/Client/PurchaseOrderItem.php
@@ -97,10 +99,23 @@ fulfillment statuses — filter widened to `['_picked','_packed','_shipped']`:
97
99
  |---|---|---|
98
100
  | Base, Quad (non-SA) | Pending → Picked → Packed → Partially Fulfilled → Fulfilled | all IF stages |
99
101
  | Compass USA + Canada | Pending → Partially Fulfilled → Fulfilled | **shipped IF stage only** |
102
+ | Elite | pendingFulfillment → partiallyFulfilled → fulfilled → shipped → delivered (+ canceled) | fulfillment + shipment state; **no approval stage** |
100
103
 
101
104
  Quad `SA%` orders delegate to the base machine; an explicit `salesOrderStageId` overrides the
102
105
  calculated status everywhere.
103
106
 
107
+ **Elite is a third policy, not a variant of Compass.** `_Model_Elite_SalesOrder::_status`
108
+ (`FIELD_SQL` override, with four private helper builders, plus `_Model_Elite_SalesOrderStatus`) walks
109
+ a **six-status ladder**: `pendingFulfillment`, `partiallyFulfilled`, `fulfilled`, `shipped`,
110
+ `delivered`, `canceled`. The two new statuses (`shipped`, `delivered`) were seeded into
111
+ `Client_Elite.SalesOrderStatuses`. **Elite has no approval model at all**, so the Compass status
112
+ logic — which is written around the MR/MA approval flow — does not apply and must not be copied
113
+ across. The override also fixed a **`billed` → `pendingFulfillment` mislabel affecting 13 orders**.
114
+
115
+ > When overriding `_status` on a client model, **match the parent's parameter signature exactly** —
116
+ > adding a parameter type the parent leaves untyped is a fatal that `php -l` does not catch. See
117
+ > [calculated SQL fields](./calculated-sql-fields.md).
118
+
104
119
  ### Inventory movement (all clients)
105
120
 
106
121
  `_qtyFulfilled` and `_qtyCommitted` sum only IF items whose stage is **shipped** — picked/packed
@@ -153,6 +168,12 @@ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is
153
168
  picked/packed constant would let the literal be removed.
154
169
 
155
170
  ## Change history
171
+ - 2026-08-18 - Added **Elite** as a third order-status policy: `_Model_Elite_SalesOrder::_status`
172
+ walks a six-status ladder (pendingFulfillment / partiallyFulfilled / fulfilled / shipped /
173
+ delivered / canceled) with four private helper builders and two new `SalesOrderStatuses` rows
174
+ (shipped, delivered). **Elite has no approval model**, so the Compass logic does not transfer.
175
+ The override also corrected a `billed` -> `pendingFulfillment` mislabel on 13 orders. Noted the
176
+ override-signature fatal to watch for when writing a client `_status`. (bala)
156
177
  - 2026-06-30 — Built the IF stage lifecycle end-to-end: NetSuite sync imports all
157
178
  picked/packed/shipped statuses and advances stages on re-sync; base + Quad order `_status`
158
179
  picked/packed/partial/fulfilled machine; Compass USA + Canada order status shipped-only;
@@ -6,11 +6,12 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-11
10
- owners: [tcox]
9
+ updated: 2026-08-18
10
+ owners: [tcox, bala]
11
11
  files:
12
12
  - _underscore/Model/Core/Page.php
13
13
  - _underscore/Model/Client/TableView.php
14
+ - toga2-supply/src/components/ui/Tables/PrimaryTable/PrimaryTable.tsx
14
15
  related:
15
16
  - surface-resolver.md
16
17
  - ../../api2/features/tableview-field-metadata.md
@@ -57,6 +58,29 @@ nothing. The row must carry **`contextRecordFieldId` = the table-view join's
57
58
  **When nothing resolves, the header falls back to the raw `TableViewFields.slug`** — which is why
58
59
  unlabeled joined columns render as camelCase identifiers rather than as blanks.
59
60
 
61
+ > **⚠ CORRECTION (2026-08-17) — do not add a context row unless the context genuinely resolves.**
62
+ > The table above describes where the frontend *looks*; it does **not** mean "add a context row and
63
+ > the header appears." `PrimaryTable.tsx` takes the **context branch whenever `contextField` is
64
+ > set and never falls back** to the plain lookup, so a `ClientRecordFieldSettings` row carrying a
65
+ > `contextRecordFieldId` that does not resolve in the meta leaves the header rendering as its raw
66
+ > slug — the same symptom you were trying to fix, now with a row that can also 500 other pages
67
+ > (see the bug below). **Elite's `service-requests` headers were fixed with
68
+ > `contextRecordFieldId = NULL`**, not with context rows. Keep label rows plain unless the context
69
+ > really exists; treat context rows as the exception.
70
+
71
+ ### Check which page slug the frontend ACTUALLY requests
72
+
73
+ Because every label and ACL lookup is filtered on the page's `Core.PageRecords`, the records must
74
+ be attached to **the page the frontend asks for** — which is not always the page you built. Elite's
75
+ `service-requests` view is requested as **`meta?page=sales-orders-listing`** (Elite reuses that
76
+ page), so records 99 (Tickets) and 36 (ServiceRequestTypes) had to be attached to
77
+ `sales-orders-listing`, **not** only to the new `service-requests-listing` page. Attaching them to
78
+ the new page alone changed nothing.
79
+
80
+ **Read the Network tab first.** This was mis-diagnosed twice before the actual request URL settled
81
+ it. A missing header is a *page-attachment* question, and only the real request identifies the
82
+ page.
83
+
60
84
  ### The `Core.PageRecords` gate
61
85
 
62
86
  `Page.php` assembles `settings.fields` from an **INNER JOIN** on `Core.RecordFields` (~L652) that is
@@ -89,6 +113,19 @@ because the service-requests row exists.
89
113
  **Fix:** an `isset()` guard around `Page.php` ~L822–829 — skip any context setting whose context
90
114
  field is not present on the requested page.
91
115
 
116
+ **Only the client path is unguarded.** `Page.php:824` reads
117
+ `$lookupRecordFieldSlugFromRecordFieldId[$row->contextRecordFieldId]` with no guard, while the
118
+ **Core Default path at `Page.php:696` self-heals** by inserting the missing `PageRecords` row. So
119
+ the same shape of data is harmless on the Core side and fatal on the client side — which is why
120
+ this reads as a client-data problem when it is really a missing guard.
121
+
122
+ **Concrete blast radius (Elite, 2026-08-17):** the offending row was `recordFieldId` **58**
123
+ (`SalesOrders._total`) with `contextRecordFieldId` **190**. Record field 58 lives on `SalesOrders`,
124
+ which appears on **six** supply pages, so one label override intended for `service-requests` 500'd
125
+ the **Inventory** page. Applied fix: `contextRecordFieldId = NULL`. **Before adding any context row,
126
+ check how many pages carry the labelled field's record** — a widely-shared field makes the row a
127
+ platform-wide hazard rather than a local tweak.
128
+
92
129
  **Blast radius:** any client that uses context-scoped field settings can hit this on **every page
93
130
  that shares the base field**. The failure looks unrelated to the page you are debugging, and it can
94
131
  appear long after the settings row was authored — a redeploy that tightens error handling, not a
@@ -106,6 +143,16 @@ data change, is what makes it fatal.
106
143
  customization. The guard belongs in the framework.
107
144
 
108
145
  ## Change history
146
+ - 2026-08-18 - **Corrected the "joined columns need a context row" guidance.** `PrimaryTable.tsx`
147
+ takes the context branch whenever `contextField` is set and **never falls back** to the plain
148
+ lookup, so a context row whose context does not resolve in the meta leaves the header as its raw
149
+ slug anyway — Elite's `service-requests` headers were fixed with `contextRecordFieldId = NULL`,
150
+ not with context rows. Added the diagnostic that the records must be attached to **the page the
151
+ frontend actually requests** (`meta?page=sales-orders-listing` for Elite's service-requests view,
152
+ not the new `service-requests-listing`) — read the Network tab first. Recorded that only the
153
+ **client** path is unguarded (`Page.php:824`) while the **Core Default path at `Page.php:696`
154
+ self-heals** by inserting the missing `PageRecords` row, and the concrete blast radius: field 58
155
+ (`SalesOrders._total`) sits on six supply pages, so one context row 500'd Inventory. (bala)
109
156
  - 2026-08-11 — Documented what context-scoped settings are actually **for**: table-view **column
110
157
  header labels**. A base-record column resolves as `settings.fields[record][field][label-singular]`
111
158
  but a **joined** column resolves the context-nested
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-02
9
+ updated: 2026-08-18
10
10
  owners: [jcardinal, rgirish]
11
11
  files:
12
12
  - _underscore/Model/Client/ItemFulfillment.php
@@ -17,6 +17,7 @@ files:
17
17
  - dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql
18
18
  - dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql
19
19
  - dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql
20
+ - dbchanges2/Client_Compass/2026-08-18 - FixSA135471HeroItemHalfQuantityFulfillment.sql
20
21
  related:
21
22
  - ../architecture.md
22
23
  - ../../api2/architecture.md
@@ -55,8 +56,10 @@ fire those records' interceptors) no-op. A `while` loop walks UP one level at a
55
56
  1. Load downstream IF; no `salesOrderId` (transfer order) → return null (out of scope).
56
57
  2. Resolve upstream SO via the **header walk** (bridge tables); none → null (top of chain).
57
58
  3. Build desired upstream items: map each downstream IFI's SOI up via the **item walk**,
58
- GROUP BY upstream SOI, sum fulfilled qty, then **scale into upstream order units** for
59
- bundle decomposition (see Gotchas).
59
+ GROUP BY upstream SOI, sum fulfilled qty, then **scale into upstream order units** — but
60
+ only for genuine multi-component bundles, keyed on **distinct downstream items** (see
61
+ Gotchas → Bundle scaling). A single distinct downstream item (incl. accepted over-ship)
62
+ mirrors unscaled.
60
63
  4. Resolve-or-create the upstream IF (**eager** — header-only POST still builds the chain):
61
64
  load existing via `upstreamItemFulfillmentId` and PUT changed header fields, or POST a
62
65
  new IF with mirrored header + upstream SO then PUT the downstream IF's link.
@@ -95,9 +98,36 @@ inheritance. Verified against prod `Client_Compass` chains (≥3 levels deep).
95
98
  `…Put.sql` for POST/PUT) against that env's Core DB — the PHP won't fire on PUT otherwise.
96
99
  - **`depth: -1` always** on orchestration calls. `depth => 0` means *unlimited* FK
97
100
  traversal → OOM. Never use 0.
98
- - **Bundle scaling:** `upstreamQty = totalDownstreamFulfilled × upstreamOrderedQty /
99
- totalDownstreamOrderedQty`, **capped at the raw sum** so the factor can only reduce
100
- (roll-up), never inflate. A qty-1 bundle of 6 fully-shipped components → upstream qty 1.
101
+ - **Bundle scaling is gated on DISTINCT DOWNSTREAM ITEMS, not downstream SOI lines
102
+ (corrected 2026-08-18).** A "Recipe" query, per upstream SOI, takes **one ordered-qty slot
103
+ per distinct downstream `itemId`** (MAX ordered qty across duplicate same-item sourcing
104
+ lines) and counts the downstream lines. Scaling
105
+ (`upstreamQty = totalDownstreamFulfilled × upstreamOrderedQty / Σ distinctDownstreamOrderedQty`,
106
+ **capped at the raw sum** so it can only reduce, never inflate) applies **only when
107
+ `distinctDownstreamItemCount > 1`** — a genuine multi-component bundle. A qty-1 bundle of 6
108
+ fully-shipped distinct components still → upstream qty 1 (6×1/6); 3×2/6=1 preserved. When a
109
+ single distinct downstream item is sourced (incl. an accepted over-shipment split across
110
+ several downstream SOs/SOIs), the raw fulfilled qty mirrors up **unscaled** (round() applied
111
+ symmetrically on this branch too).
112
+ - **Same-item over-shipment used to mis-scale into fractions (root cause, fixed 2026-08-18).**
113
+ Pre-fix the denominator summed ordered qty over *distinct downstream SOI lines*. A vendor
114
+ (e.g. Office Depot / ODP) can create **multiple downstream SalesOrders for the SAME item off
115
+ ONE upstream PO line** (accepted over-ship / re-send); each duplicate is another distinct
116
+ downstream SOI, inflating the denominator and shrinking the factor. Compass SA135471's hero
117
+ SOI (item 2682, qty 1) mapped to two ODP SOIs (same item, each qty 1) via one POI, so factor
118
+ = 1/2 and each of two customer IFs showed **qty 0.50** ("Quantity Fulfilled" 0.5 + 0.5). Two
119
+ physically distinct serials had shipped — a real over-ship, not a phantom duplicate. Fix:
120
+ distinct-item denominator (above) mirrors the raw qty (→ 2). Data repaired by
121
+ `dbchanges2/Client_Compass/2026-08-18 - FixSA135471HeroItemHalfQuantityFulfillment.sql`.
122
+ - **Mixed topology (a real bundle whose component is ALSO sourced across multiple downstream
123
+ lines) is logged, not silently averaged.** When the Recipe's `lineCount > distinctItemCount`,
124
+ the engine `error_log()`s a diagnostic instead of averaging — same log-don't-swallow rule as
125
+ the broken-bridge guard.
126
+ - **Diagnosing over-ship vs. phantom duplicate:** traverse the bridge — upstream SOI →
127
+ `SalesOrderItems_PurchaseOrderItems` → POI → `PurchaseOrders_SalesOrders` → downstream ODP
128
+ SOs (`customerId=1`); compare downstream vs. upstream `itemId` (**same itemId ⇒ over-ship /
129
+ duplicate sourcing, NOT a bundle**); then check `ItemFulfillmentItemUnits` serials — distinct
130
+ serials ⇒ genuine over-shipment, identical ⇒ phantom duplicate.
101
131
  - **DELETE propagation is NOT implemented.** The engine sets `$outData = null` on DELETE so
102
132
  post-delete hooks never fire; a standalone downstream DELETE doesn't immediately remove its
103
133
  upstream mirror. A later reconciling PUT cleans up stale records via set comparison. Full
@@ -131,6 +161,19 @@ inheritance. Verified against prod `Client_Compass` chains (≥3 levels deep).
131
161
  upstream record is written at most once — idempotent). Fine for normal fulfillment sizes.
132
162
 
133
163
  ## Change history
164
+ - 2026-08-18 — **Bundle scaling now discriminates on distinct downstream items, not SOI lines
165
+ (CTO-approved "Option B").** `reconcileUpstreamLevel`'s Recipe query takes one ordered-qty
166
+ slot per distinct downstream `itemId` and scales only when `distinctDownstreamItemCount > 1`;
167
+ a single distinct item (incl. accepted over-ship split across multiple downstream SOs)
168
+ mirrors the raw fulfilled qty unscaled. Mixed topology (recipe lineCount > distinctItemCount)
169
+ is `error_log`'d, not averaged. Added `(int)` casts on the method's trusted-PK SQL
170
+ interpolations and round() symmetry on the unscaled branch. Root-caused on Compass prod
171
+ SA135471: the hero item (2682, qty 1) sourced via two ODP SalesOrders off one PO line showed
172
+ "Quantity Fulfilled" 0.5 + 0.5 across two customer IFs (old denominator summed distinct SOI
173
+ lines → factor 1/2); two distinct serials confirmed a genuine over-ship. Data repaired by the
174
+ idempotent retro migration `dbchanges2/Client_Compass/2026-08-18 - FixSA135471HeroItemHalfQuantityFulfillment.sql`
175
+ (IFI 302121 / 302126 quantity 0.50 → 1.00). Deploy order: `_underscore` code first, then the
176
+ SQL against the Compass DB. Neither deployed at capture time. (jcardinal)
134
177
  - 2026-07-02 — Added a **broken-bridge guard** to `reconcileUpstreamLevel`: when `$desiredItems`
135
178
  is empty but the downstream IF has items (broken SOI↔POI bridge), it logs and returns null
136
179
  instead of creating an empty header-only duplicate upstream IF. Root-caused on Compass SA133377
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-06
9
+ updated: 2026-08-18
10
10
  owners: ["jcardinal", "mhammontree", "dfranks", "apeterson", "bala", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -26,6 +26,7 @@ files:
26
26
  - dbchanges2/Client_Growrk/2026-07-13a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql
27
27
  - dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql
28
28
  - dbchanges2/Client_Quad/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql
29
+ - dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql
29
30
  related:
30
31
  - recursive-item-fulfillments.md
31
32
  ---
@@ -191,9 +192,63 @@ returnTrackingNumber: {...} }]`, and an IFIU's as `itemFulfillmentItemUnitTracki
191
192
  audit found `sales-order-shipments` (join 10) referencing dead RecordField **211 in
192
193
  `Client_Nychh` too** — that view is broken platform-wide and needs a backend decision, not a
193
194
  copy.
194
- - **Porting the item-fulfillment TableView fix to another client is two independent decisions, not a copy-paste.** The **re-root** (Units 31 → ItemFulfillmentItems 29 + OUTER unit chain) is universally portable and fixes the "0 records" bug everywhere. The **tracking source is client-specific**: Compass writes one tracking number per item fulfillment at the **item level (318)**, so its views read 318. Other clients populate different bridge levels — verify with `SELECT COUNT(*)` per bridge before choosing. As of 2026-06-19, **record 318 is empty in `Client_Nychh` and `Client_Quad`**; their tracking lives at the **IF/shipment level (317)** (covers IFIs: NYCHH 7955/10049 ≈ 79%, Quad 4878/4967 ≈ 98%) and the **unit level (319)** (≈ 7–9% — serialized only). Copying Compass's 318 join into a client that doesn't write 318 yields a structurally-correct view with permanently blank tracking until 318 is backfilled. (NYCHH's pre-fix view pointed at the now-deleted record 41 = old IF-level/package bridge, i.e. it originally intended IF-level 317.)
195
+ - **Porting the item-fulfillment TableView fix to another client is two independent decisions, not a copy-paste.** The **re-root** (Units 31 -> ItemFulfillmentItems 29 + OUTER unit chain) is universally portable and fixes the "0 records" bug everywhere. The **tracking source is client-specific** and must be *measured*, never inherited from Compass. (NYCHH's pre-fix view pointed at the now-deleted record 41 = old IF-level/package bridge, i.e. it originally intended IF-level 317.)
196
+
197
+ - **The bridge a client actually populates - measured 2026-08-17.** This is the single most
198
+ reusable fact in this doc: **two sibling bridges exist and they are not interchangeable.**
199
+
200
+ | Bridge | Record | Grain | Key fields |
201
+ |---|---|---|---|
202
+ | `ItemFulfillmentItems_TrackingNumbers` | **318** | per fulfillment **item** | 2178 `itemFulfillmentItemId` / 2179 `trackingNumberId` |
203
+ | `ItemFulfillmentItemUnits_TrackingNumbers` | **319** | per **unit** | 2183 `itemFulfillmentItemUnitId` / 2184 `trackingNumberId` |
204
+
205
+ Row counts per client DB:
206
+
207
+ | Client | 318 (item) | 319 (unit) | Which the views should read |
208
+ |---|---|---|---|
209
+ | Compass | 4,312 | 10,144 | 318 |
210
+ | CompassCanada | 169 | 138 | 318 |
211
+ | Elite | **0** | 109 | **319** (fixed 2026-08-17) |
212
+ | Prudential | **0** | 380 | **319** |
213
+ | Quad | **0** | 633 | **319** - but its views read 318 (**live bug**, see below) |
214
+ | NYCHH | **0** | unit-level | **319** |
215
+
216
+ Worked example: copying Compass's **318** join into Elite yields 402 rows with **zero** tracking
217
+ numbers; **319** yields 500 rows with 109 populated. `SELECT COUNT(*)` both bridges in the target
218
+ client DB **before** choosing - the wrong choice produces a structurally-correct view with a
219
+ permanently blank Tracking # / Carrier column, which reads as "the data is missing" rather than
220
+ "the join is wrong."
221
+
222
+ - **Live bug: Quad's Tracking # column is empty today.** Quad's two fulfillment views are a direct
223
+ copy of Compass and read **318**, but Quad has **0** rows in 318 and **633** in 319. Same visible
224
+ symptom as the Elite bug, different cause (Elite's was dead ids; Quad's is the wrong bridge
225
+ level). **Not fixed** - out of scope when found 2026-08-17. This supersedes the earlier note that
226
+ Quad's tracking "lives at 317 (~98%)"; the unit-level bridge is where it is now.
227
+
228
+ - **The dead-id breakage spans TEN clients, not just Elite (measured 2026-08-17).**
229
+ `item-fulfillments-for-sales-orders` / `item-fulfillments-for-sales-order-items` still carry
230
+ dangling `joinRecordId` **41** and dangling `parentRecordFieldId`s **211**, **321** in: **Elite**
231
+ (fixed 2026-08-17), **CompassCanada, Prudential, Nychh, Aig, Growrk, Managelife, Rate, SPGlobal**
232
+ (which has a fourth dangling id, **1431**) and **True**. **Only Compass and Quad are clean**,
233
+ because those two were migrated to base record 29. The per-client fix is **not a blind copy** -
234
+ the re-root is portable but the bridge (318 vs 319) must be chosen from that client's own row
235
+ counts above. A dead record is a **fatal**, not a skipped join: `TableView::meta` line 87 does
236
+ `($joinRecord->model)::TABLE`, and the dead record is not filtered out first because meta's join
237
+ query gates on `AclRecordPermissions.recordId` - and these clients still hold a permission row
238
+ for record 41.
195
239
 
196
240
  ## Change history
241
+ - 2026-08-18 - **Measured which bridge each client actually populates, and it is not what "copy
242
+ Compass" assumes.** Added the 318-vs-319 row-count table (Compass 4,312/10,144; CompassCanada
243
+ 169/138; Elite 0/109; Prudential 0/380; Quad 0/633; NYCHH 0/unit-level) and the rule to
244
+ `COUNT(*)` both bridges before porting a fulfillment view - the wrong level yields a
245
+ structurally-correct view with a permanently blank Tracking # column. Fixed **Elite** onto 319
246
+ (`Client_Elite/2026-08-17 - FulfillmentTableViews.sql`). Recorded a **live unfixed bug in Quad**
247
+ (views read 318, data is in 319, so its Tracking # column is empty today), superseding the older
248
+ "Quad's tracking lives at 317" note. Also measured the dangling `joinRecordId` 41 /
249
+ `parentRecordFieldId` 211+321 breakage across **ten** clients - only Compass and Quad are clean -
250
+ and recorded why a dead record is fatal rather than skipped (`TableView::meta` L87
251
+ `($joinRecord->model)::TABLE`, not filtered because meta gates on `AclRecordPermissions`). (bala)
197
252
  - 2026-08-06 — Recorded that the per-client TableView repointing left **unmigrated clients broken**:
198
253
  `Client_Elite` still has 11 references to deleted RecordFields (211/321/932/358/1431) across 9
199
254
  views in **prod and sandbox**, so its views 500 on `table-views/meta` and then on the data query
@@ -6,9 +6,11 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-06
9
+ updated: 2026-08-18
10
10
  owners: ["bala", "tcox"]
11
- files: []
11
+ files:
12
+ - _underscore/Model/Client/ServiceRequest.php
13
+ - dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql
12
14
  related:
13
15
  - ./tracking-number-bridges.md
14
16
  - ../../../clients/elite/features/supply2-tableview-config-drift.md
@@ -46,6 +48,51 @@ The receipt path requires both the item-level bridge **and** an item receipt aga
46
48
  bridged purchase-order line. The fulfillment path reaches `SalesOrderItems` directly by
47
49
  `salesOrderItemId` with no purchase order or bridge involved.
48
50
 
51
+ **3. Direct item path (`Units.itemId`):**
52
+ ```
53
+ Items
54
+ -> Units (Units.itemId = Items.id)
55
+ ```
56
+
57
+ `Units.itemId` is the **only complete** item-to-unit path. Measured on `Client_Elite`
58
+ (2026-08-11): **229 of 229** units resolve through `Units.itemId`, versus **97 of 229** through
59
+ item receipts. Confirmed with the team before building on it. If a view needs "every unit for this
60
+ item", walk `Units.itemId` - not a receipt or fulfillment chain.
61
+
62
+ ## The `inventory_units` view (2026-08-11, all clients)
63
+
64
+ The all-clients `inventory_units` table view is built on this third path: base record **31**
65
+ (Units) with **Items as the spine** - `joinRecordId` **21**, `joinOnRecordFieldId` **105**,
66
+ `parentRecordFieldId` **238** - then 15 OUTER joins and 13 columns
67
+ (`dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql`). This is the
68
+ "resolver/view change that also walks past the receipt path" that the gotcha below asks for.
69
+
70
+ **Per-client custom columns cannot live in that all-clients file.** A `customRecordFieldId` names
71
+ a *different field per client*, so client custom columns (and their
72
+ `ClientCustomRecordFieldSettings` labels) go in the client's own `Client_<Name>/` file - e.g.
73
+ Elite's four columns at index 14-17 (`grade`, `chargingBrick`, `chargingCable`, `wipeStatus` via
74
+ `c_deviceStatus`).
75
+
76
+ ## Serial numbers for a Service Request - the bridge table is empty, use the fulfillment chain
77
+
78
+ `_Model_Client_ServiceRequest::_serialNumbers` (a `FIELD_SQL` field, so every client inherits it)
79
+ `GROUP_CONCAT`s `Units.serialNumber`. The obvious source - the **`ServiceRequests_Units` bridge -
80
+ has 0 rows**, so a field built on it alone returns nothing. The path that actually carries the
81
+ data is the fulfillment chain off the service request's sales orders:
82
+
83
+ ```
84
+ ServiceRequests
85
+ -> SalesOrders (SalesOrders.serviceRequestId = ServiceRequests.id)
86
+ -> SalesOrderItems
87
+ -> ItemFulfillmentItems (.salesOrderItemId = SalesOrderItems.id)
88
+ -> ItemFulfillmentItemUnits
89
+ -> Units
90
+ ```
91
+
92
+ Coverage on `Client_Elite`: **224 of 229** units. The shipped expression selects
93
+ `IN (<fulfillment chain> UNION <ServiceRequests_Units>)` so the bridge still counts once it is
94
+ populated - keep the UNION rather than choosing one side.
95
+
49
96
  ## Data model
50
97
  Header-level vs item-level bridges, and the level each transaction is anchored at:
51
98
 
@@ -93,7 +140,28 @@ None — uniform. The linkage structure is framework-level and identical across
93
140
  [Elite TableView config drift](../../../clients/elite/features/supply2-tableview-config-drift.md).
94
141
  Check the view config before investigating the linkage paths above.
95
142
 
143
+ - **A join can also be broken by a `parentRecordFieldId` that belongs to a record the view never
144
+ joins.** Distinct from the dead-id case above: the id **exists**, it is just on the wrong record.
145
+ Found 2026-08-17 - a join in this view carried `parentRecordFieldId` **92**, which belongs to
146
+ record **289** (`SalesOrderItemPurchaseOrderItems`), a record the view does not join anywhere, and
147
+ it **500'd** the view. **Proven by bisection:** all 23 other joins returned 200; adding this one
148
+ returned 500. No fields or joins depended on it, so the fix was a targeted `DELETE`. The same row
149
+ is present in **Elite, Compass and Prudential**. When a view 500s and the ids all resolve, check
150
+ that every `parentRecordFieldId` sits on a record the view actually joins - and bisect the joins
151
+ rather than reading the SQL.
152
+
96
153
  ## Change history
154
+ - 2026-08-18 - Added the **third linkage path, `Units.itemId`** - the only *complete* item-to-unit
155
+ path (Elite: 229/229 units, vs 97/229 through item receipts) - and the all-clients
156
+ **`inventory_units`** view built on it (base 31, Items as spine via joinRecordId 21 /
157
+ joinOnRecordFieldId 105 / parentRecordFieldId 238, 15 OUTER joins, 13 columns), plus the rule that
158
+ per-client custom columns must stay in `Client_<Name>/` because a `customRecordFieldId` means a
159
+ different field per client. Documented **`ServiceRequest::_serialNumbers`**: the
160
+ `ServiceRequests_Units` bridge has **0 rows**, so serials come from the SalesOrders ->
161
+ SalesOrderItems -> ItemFulfillmentItems -> ItemFulfillmentItemUnits chain (224/229 on Elite),
162
+ UNIONed with the bridge so it still counts later. Added a second 500 cause - a
163
+ `parentRecordFieldId` (92) that **exists but belongs to a record the view never joins** (289),
164
+ found by bisecting joins; present in Elite, Compass and Prudential. (bala)
97
165
  - 2026-08-06 — Added the gotcha that a 500 on this view is usually **stale per-client
98
166
  `TableViewJoins`** left behind by the bridge migrations (records 319 / 289), not a data-linkage
99
167
  problem; confirmed on `Client_Elite` view 17. (tcox)