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.
- package/knowledge/2.0/apps/_underscore/INDEX.md +6 -6
- package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +8 -2
- package/knowledge/2.0/apps/_underscore/features/calculated-sql-fields.md +67 -2
- package/knowledge/2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md +22 -1
- package/knowledge/2.0/apps/_underscore/features/page-meta-context-field-settings.md +49 -2
- package/knowledge/2.0/apps/_underscore/features/recursive-item-fulfillments.md +49 -6
- package/knowledge/2.0/apps/_underscore/features/tracking-number-bridges.md +57 -2
- package/knowledge/2.0/apps/_underscore/features/units-for-items-for-purchase-orders.md +70 -2
- package/knowledge/2.0/apps/api2/features/v2-api-error-codes.md +14 -4
- package/knowledge/2.0/apps/dbchanges2/architecture.md +38 -1
- package/knowledge/2.0/apps/toga2-supply/INDEX.md +1 -0
- package/knowledge/2.0/apps/toga2-supply/features/order-detail-field-config-and-customer-name.md +122 -0
- package/knowledge/2.0/standards/backend-php.md +39 -2
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/elite/INDEX.md +2 -2
- package/knowledge/clients/elite/features/supply2-scope.md +21 -2
- package/knowledge/clients/elite/features/supply2-tableview-config-drift.md +137 -20
- package/knowledge/clients/elite/profile.md +10 -3
- package/knowledge/clients/quad/profile.md +13 -5
- package/package.json +1 -1
|
@@ -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-
|
|
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-
|
|
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
|
package/knowledge/2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
CHANGED
|
@@ -6,13 +6,15 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
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-
|
|
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-
|
|
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**
|
|
59
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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-
|
|
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
|
|
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-
|
|
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)
|