toga-ai 1.0.430 → 1.0.432
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 +1 -0
- package/knowledge/2.0/apps/_underscore/features/model-interceptor-unit-testing.md +67 -0
- package/knowledge/2.0/apps/api2/INDEX.md +1 -0
- package/knowledge/2.0/apps/api2/features/nested-fk-acl-embedding.md +79 -0
- package/knowledge/2.0/apps/dbchanges2/INDEX.md +1 -1
- package/knowledge/2.0/apps/dbchanges2/features/surface-layer-schema.md +10 -1
- package/knowledge/2.0/apps/toga25-supply/features/surface-frontend.md +56 -17
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/prudential/INDEX.md +2 -2
- package/knowledge/clients/prudential/features/dell-lch-iop-transmissions.md +91 -6
- package/knowledge/clients/prudential/features/service-request-address-validation.md +26 -7
- package/package.json +1 -1
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
| [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 |
|
|
19
19
|
| [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, dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql |
|
|
20
20
|
| [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 |
|
|
21
|
+
| [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 |
|
|
21
22
|
| [_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 |
|
|
22
23
|
| [_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 |
|
|
23
24
|
| [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 |
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "DB-free unit testing for _underscore model interceptors"
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-24
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Test/bootstrap.php
|
|
13
|
+
- _underscore/Test/Prudential/ServiceRequestTest.php
|
|
14
|
+
related:
|
|
15
|
+
- ../../../../clients/prudential/features/service-request-address-validation.md
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Summary
|
|
19
|
+
|
|
20
|
+
`_underscore` shipped with **no** PHPUnit setup (no `composer.json`/`phpunit`; only vendored
|
|
21
|
+
PhpOffice tests existed). This is the first test scaffold in the repo and establishes a runnable,
|
|
22
|
+
**DB-free** pattern for exercising the pure-validation logic inside a model's `prePost` /
|
|
23
|
+
interceptor methods without a database or the full framework bootstrap. It was introduced with the
|
|
24
|
+
Prudential `validateCustomer()` guard (see the service-request address-validation client-feature).
|
|
25
|
+
|
|
26
|
+
## Key files / entry points
|
|
27
|
+
|
|
28
|
+
- `_underscore/Test/bootstrap.php` — the test bootstrap. It **stubs `_Model_Client_ServiceRequest`**
|
|
29
|
+
(the DB-backed base class) and then `require`s the real `Exception/Validation.php` and the real
|
|
30
|
+
model under test, so the guard code runs unchanged with **no DB connection**.
|
|
31
|
+
- `_underscore/Test/Prudential/ServiceRequestTest.php` — 6 regression tests for the private
|
|
32
|
+
`validateCustomer()` guard.
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
1. The bootstrap defines a minimal stub for the model's DB-backed parent
|
|
37
|
+
(`_Model_Client_ServiceRequest`) so the class under test can be loaded without the ORM/DB layer.
|
|
38
|
+
2. It `require`s the **real** `_Exception_Validation` and the **real** model file, so the actual
|
|
39
|
+
production validation code executes — only its persistence base is stubbed.
|
|
40
|
+
3. Private validators (e.g. `validateCustomer()`) are invoked through **PHP reflection**, letting a
|
|
41
|
+
test assert on a single private method's behaviour directly instead of driving a full POST.
|
|
42
|
+
4. Run it with:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
phpunit --bootstrap _underscore/Test/bootstrap.php _underscore/Test
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The initial suite covers the `validateCustomer()` guard: absent `customer`, null `uuid`,
|
|
49
|
+
empty-string `uuid`, whitespace-only `uuid`, error-message content, and a valid `uuid` passing.
|
|
50
|
+
Verified 6/6 passing against the real code.
|
|
51
|
+
|
|
52
|
+
## Gotchas / known issues
|
|
53
|
+
|
|
54
|
+
- **Reflection on private methods is the pattern here** — the interceptor validators are private,
|
|
55
|
+
so tests use `ReflectionMethod::setAccessible(true)`. Keep testing them directly rather than
|
|
56
|
+
making them public just to test them.
|
|
57
|
+
- **Stub only the persistence base, load the real logic.** The value of the pattern is that the
|
|
58
|
+
production validator runs unchanged; do not reimplement the validator in the stub.
|
|
59
|
+
- There is still no `composer.json`/autoloader for the repo — tests rely on the explicit
|
|
60
|
+
`--bootstrap` file to wire up requires. Adding more model tests means extending
|
|
61
|
+
`Test/bootstrap.php` with the stubs that model needs.
|
|
62
|
+
|
|
63
|
+
## Change history
|
|
64
|
+
- 2026-07-24 — Created: first PHPUnit scaffold in `_underscore` (`Test/bootstrap.php` +
|
|
65
|
+
`Test/Prudential/ServiceRequestTest.php`), a DB-free pattern that stubs the model's persistence
|
|
66
|
+
base and reflects into private interceptor validators; seeded with 6 regression tests for the
|
|
67
|
+
Prudential `validateCustomer()` guard. (bala)
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
| [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
|
|
8
8
|
| [Health-check endpoint (/health liveness short-circuit)](features/health-check-endpoint.md) | `_Controller_Index::api()` short-circuits **liveness/health-probe** requests to an HTTP 200 **before** any routing, DB bootstrap, or V2 engine work runs. | api2/Controller/Index.php |
|
|
9
9
|
| [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, plus item **feature** text — `Features.name`, `ItemCategoryFeatureGroups.name`, `ItemFeatures | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql |
|
|
10
|
+
| [Nested FK object embedding is gated by the CHILD record's own ACL](features/nested-fk-acl-embedding.md) | When the V2 JSON engine serializes a foreign-key field into a **nested object** (in `getFullModelData()`, ~V2.php L6016-6060), it re-checks the **child** record | api2/Component/Api/V2/V2.php, dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql |
|
|
10
11
|
| [Nested-relationship writes & child matching (link vs. create)](features/nested-relationship-writes.md) | When a 2.0 API write payload (`POST`/`PUT`) contains a **nested related object** (e.g. | api2/Component/Api/V2/V2.php |
|
|
11
12
|
| [Record Scripts (computed/aggregate /v2 endpoints — the authoring contract)](features/record-scripts.md) | In api2 you almost never write a controller. | api2/Component/Api/V2/V2.php, _underscore/Model/Team/Sprint.php |
|
|
12
13
|
| [POST + JSON-body args for scripted APIs](features/scripted-api-post-body-args.md) | The V2 engine can run a Record Script (scripted API) for a **POST** request, and a scripted API can receive its arguments from the **JSON request body** instead | api2/Component/Api/V2/V2.php |
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Nested FK object embedding is gated by the CHILD record's own ACL
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: api2
|
|
5
|
+
project: API
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-23
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- api2/Component/Api/V2/V2.php
|
|
13
|
+
- dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql
|
|
14
|
+
related:
|
|
15
|
+
- cross-client-data-retrieval.md
|
|
16
|
+
- tableview-apiwhereclause-row-filtering.md
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## What it is
|
|
20
|
+
|
|
21
|
+
When the V2 JSON engine serializes a foreign-key field into a **nested object** (in
|
|
22
|
+
`getFullModelData()`, ~V2.php L6016-6060), it re-checks the **child** record's own
|
|
23
|
+
`AclRecordPermissions` against the caller's roles before embedding it. If no grant exists, the
|
|
24
|
+
engine records a debug `"No ACL Record Permissions records exist"` and **silently drops the nested
|
|
25
|
+
object** — still HTTP 200, `isSuccess: true`, no error or warning `message`. This is why a linked
|
|
26
|
+
object can silently disappear from an API payload for one client but embed fine for another.
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
- The gate needs only an `AclRecordPermissions` **row** for the child record on one of the caller's
|
|
31
|
+
roles. Unlike a direct top-level GET of that record, the embedding path does **not** call
|
|
32
|
+
`buildSqlExpression` — an ACL row alone (no logic-group expression) is sufficient to embed.
|
|
33
|
+
- Which roles are checked depends on the child `Records.aclDatabase`. A **CLIENT**-acl record checks
|
|
34
|
+
only the caller's **client** roles; a CORE-acl record checks CORE roles (mirrors the dispatch/record
|
|
35
|
+
rules in [surface-meta-option](surface-meta-option.md)).
|
|
36
|
+
- The drop is **silent by design**: absence of a grant is treated as "not authorized to see this
|
|
37
|
+
linked object", collapsed to omission rather than an error. There is no `messages[]` entry, so the
|
|
38
|
+
symptom presents purely as a missing key in `data`.
|
|
39
|
+
|
|
40
|
+
## The role-3-vs-role-1 mis-seed pattern (the durable gotcha)
|
|
41
|
+
|
|
42
|
+
The most common cause of a silently-missing nested object is an ACL **mis-seed on the wrong role**:
|
|
43
|
+
a child record granted to a service role no human carries (e.g. role **3 "API"**) instead of the
|
|
44
|
+
base role every user carries (role **1 "Base"**). Because a CLIENT-acl record checks only the
|
|
45
|
+
caller's client roles, a human user authenticated with role 1 gets no grant → the child is dropped;
|
|
46
|
+
a machine/API caller on role 3 sees it fine.
|
|
47
|
+
|
|
48
|
+
- **Confirmed instances:** the sales-order↔purchase-order join. On **Compass** the `purchase-orders`
|
|
49
|
+
record (Core Record 17, `_Model_Client_PurchaseOrder`, `aclDatabase=CLIENT`) was granted in
|
|
50
|
+
`Client_Compass` only to role 3, so the nested `purchaseOrder` dropped from the
|
|
51
|
+
sales-order-purchase-orders join response; **Quad** had it on role 1 (perm + logic group → `all`
|
|
52
|
+
expression) and embedded fine. The **same** mis-seed was previously found on **CompassCanada**
|
|
53
|
+
(record 275). It is **not** a single-client quirk — audit every client when a nested object is
|
|
54
|
+
reported missing for some tenants only.
|
|
55
|
+
- **The fix mirrors the working tenant:** add a role-1 `AclRecordPermissions` (full CRUD,
|
|
56
|
+
app-agnostic) + `AclLogicGroups` (AND) + `AclLogicGroupExpressions` reusing the existing `all`
|
|
57
|
+
record expression on the child record. Field-read perms on role 1 usually already exist (no
|
|
58
|
+
`AclFieldPermissions` insert needed). Author it **id-agnostically** (natural keys, not hardcoded
|
|
59
|
+
ids), `NOT EXISTS`-guarded, wrapping any self-referencing subquery in a derived table to avoid MySQL
|
|
60
|
+
error 1093. See `dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql`.
|
|
61
|
+
|
|
62
|
+
## Diagnostic tell
|
|
63
|
+
|
|
64
|
+
A top-level GET of the child record **works** but the **nested embed of the same record under a
|
|
65
|
+
parent is missing**, with a 200/`isSuccess:true` response and no `messages[]`. Chase the child
|
|
66
|
+
record's `AclRecordPermissions` for the caller's role — not the parent's grant, and not field
|
|
67
|
+
permissions.
|
|
68
|
+
|
|
69
|
+
## Change history
|
|
70
|
+
- 2026-07-23 — Documented that V2 nested-FK embedding (`getFullModelData`) re-checks the CHILD
|
|
71
|
+
record's own `AclRecordPermissions` and SILENTLY drops the nested object (200, isSuccess true, no
|
|
72
|
+
message) when no grant exists — needing only an ACL row, not a logic-group expression. Fixed the
|
|
73
|
+
Compass sales-order↔PO join: `purchase-orders` (record 17, CLIENT-acl) was granted only to role 3
|
|
74
|
+
("API") not role 1 ("Base"), so humans lost the nested `purchaseOrder` while Quad (role-1 grant)
|
|
75
|
+
embedded fine; mirrored Quad's role-1 grant (`Client_Compass/2026-07-23b`). Confirmed this is the
|
|
76
|
+
SAME role-3-vs-role-1 mis-seed pattern already seen on CompassCanada (record 275) — not
|
|
77
|
+
CompassCanada-only. (apeterson)
|
|
78
|
+
</content>
|
|
79
|
+
</invoke>
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [Database Changes (dbchanges2) Repository Architecture](architecture.md) | `dbchanges2` is the **schema-migration / SQL change-set repository** for the entire 2.0 platform. | Core/, Client/, Client_<Tenant>/, Logs/, Logs_Client/, _modules/ |
|
|
6
|
-
| [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.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, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql |
|
|
6
|
+
| [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.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, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql |
|
|
7
7
|
| [2.0 New-Client Onboarding (manual process)](workflows/client-onboarding.md) | > **A local browser wizard now automates this.** Steps 2–9 below (create DBs, generate Core/API > inserts, append to `Clients_Db.txt`) — plus the dbchanges2 bla | Client/, Client_<Tenant>/, Core/, Logs_Client/ |
|
|
@@ -6,7 +6,7 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-23
|
|
10
10
|
owners: [jcardinal, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql
|
|
@@ -65,6 +65,7 @@ files:
|
|
|
65
65
|
- dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql
|
|
66
66
|
- dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql
|
|
67
67
|
- dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql
|
|
68
|
+
- dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql
|
|
68
69
|
related:
|
|
69
70
|
- ../../_underscore/features/surface-resolver.md
|
|
70
71
|
---
|
|
@@ -499,6 +500,14 @@ rule resumes.
|
|
|
499
500
|
override is added). **Open follow-up.**
|
|
500
501
|
|
|
501
502
|
## Change history
|
|
503
|
+
- 2026-07-23 — Repointed the Order Details **"PO Number"** detail field (all clients, Core seed).
|
|
504
|
+
`Core/2026-07-23a - PoNumberDetailFieldValueKey.sql` updates the `SurfaceElements` row (uuid
|
|
505
|
+
`d45c51d8-8211-11f1-bfa7-a30f63c3a801`, label `salesOrder.field.poNumber`) `config` from
|
|
506
|
+
`{"valueKey":"_purchaseOrders","isPersonaValue":true}` to
|
|
507
|
+
`{"valueKey":"purchaseOrderDetails.purchaseOrder.number"}`. The FE DetailSection resolves a field
|
|
508
|
+
value by plain dot-path against the order object, and the real PO number lives at
|
|
509
|
+
`purchaseOrderDetails.purchaseOrder.number`; `isPersonaValue` was dropped (it applies only to
|
|
510
|
+
persona arrays, not scalars). Keyed by stable uuid. (apeterson)
|
|
502
511
|
- 2026-07-21 — Seeded the **per-client decision-summary overrides** off the shared
|
|
503
512
|
`sales-order-decision-summary` surface (all `2026-07-21c`, client-level — `roleId`/`personaId`/
|
|
504
513
|
`languageId` NULL): **Quad** hides three summary rows (`assignedTo._name`, `c_erpEntityId`,
|
|
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-23
|
|
10
10
|
owners: [jcardinal, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/surface/useFetchSurfaceMeta.ts
|
|
@@ -165,11 +165,37 @@ modal copy, visibility/enable) to Surface; **leave data-fetch wiring (fetch slug
|
|
|
165
165
|
nested-table topology) in JSON/code.** Not every screen reduces to a generic `SurfaceSection` swap —
|
|
166
166
|
see SalesOrders below.
|
|
167
167
|
|
|
168
|
-
##
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
## Surface on/off is 100% BACKEND-driven — no FE roster (2026-07-23)
|
|
169
|
+
|
|
170
|
+
**A client turns Surface on purely by backend state — never a hardcoded FE allowlist.** The
|
|
171
|
+
SalesOrders record modal previously gated surface resolution on an in-code roster
|
|
172
|
+
(`SALES_ORDER_SURFACE_MIGRATED_CLIENTS = ["COMPASS","COMPASSCANADA","QUAD"]`): it fetched the surface
|
|
173
|
+
group only for rostered clients, and everyone else fell back to JSON. That roster was the **last FE
|
|
174
|
+
gate** blocking BE-driven resolution — and it blocked NYCHH from ever receiving a corrected surface
|
|
175
|
+
field even though its API returned the data. **It has been removed entirely.**
|
|
176
|
+
|
|
177
|
+
- The SO modal now **ALWAYS** fetches the surface group and spreads
|
|
178
|
+
`surfaceBundleToTenantFields(surfaces)` **unconditionally** (view-model
|
|
179
|
+
`useSalesOrderRecordModalLayoutModel.tsx`; roster constant deleted from
|
|
180
|
+
`surfaceBundleToTenantFields.ts` and `helpers/index.ts`).
|
|
181
|
+
- Fetch is **failure-isolated** (`retry:false`, errors → `isError`, `surfaces` defaults to `{}`), and
|
|
182
|
+
`surfaceBundleToTenantFields` returns `{}` for an empty/unauthorized bundle — so a client the backend
|
|
183
|
+
does **not** serve stays JSON-driven with no roster. Merge remains **JSON-base + Surface-wins**.
|
|
184
|
+
- A client now "turns on" Surface by two backend facts only: (1) being granted the `surfaces`/`meta-group`
|
|
185
|
+
script ACL (`AclRecordScripts` — see [surface-meta-option](../../api2/features/surface-meta-option.md)),
|
|
186
|
+
**and** (2) having its surfaces seeded/overridden server-side. No FE change is ever needed to onboard a
|
|
187
|
+
client onto Surface.
|
|
188
|
+
- **Audit outcome:** every other `useFetchSurfaceMeta`/`useFetchSurfaceMetaGroup` call site (item-record
|
|
189
|
+
modal, approval-decision modal, Items/VendorItems/Inventory/Login) was already BE-driven — the SO modal
|
|
190
|
+
roster was the ONLY offending gate. The `clientSlug` conditionals in `src/fieldsConfig/` (`resolveRole`,
|
|
191
|
+
`FIELDS[clientSlug]`) are the **legacy JSON fallback layer, NOT surface gating**, and are correctly left
|
|
192
|
+
in place (they serve clients the backend doesn't yet drive via Surface).
|
|
193
|
+
|
|
194
|
+
## SalesOrders record-modal sections — migrated via the registered-renderer seam
|
|
195
|
+
|
|
196
|
+
The SalesOrders detail sections are migrated onto Surface via the registered-renderer approach (not a
|
|
197
|
+
generic `SurfaceSection` swap — `SalesOrderSummaryGrid`'s three bespoke renderers
|
|
198
|
+
`detailSection`/`locationCard`/`totalsCard` are untouched).
|
|
173
199
|
|
|
174
200
|
- **`surfaceBundleToTenantFields()`** (`SalesOrders/helpers/`) adapts the grouped Surface bundle into
|
|
175
201
|
the existing **`TenantFields`** shape, so the existing `SalesOrderSummaryGrid` renderers and
|
|
@@ -178,8 +204,9 @@ three bespoke renderers `detailSection`/`locationCard`/`totalsCard` are untouche
|
|
|
178
204
|
gating are **not** moved. (Registered-renderer seam: *config describes, code decides.*)
|
|
179
205
|
- **Full adapter-seam data path (verified this session, no direct `<SurfaceSection>`):**
|
|
180
206
|
`useFetchSurfaceMetaGroup(SALES_ORDER_SURFACE_SLUGS)` → `surfaceBundleToTenantFields(surfaces)` →
|
|
181
|
-
merged into `tenantFields` (
|
|
182
|
-
|
|
207
|
+
merged into `tenantFields` (**unconditionally, for every client**; an empty/unauthorized bundle
|
|
208
|
+
adapts to `{}` so unseeded clients stay JSON-driven) → **`buildPatchedTenantFields`** (which only
|
|
209
|
+
overrides
|
|
183
210
|
`recordActionFields`, so it **preserves all surface section keys**) → `SalesOrderView` →
|
|
184
211
|
**`SalesOrderSummaryGrid`** (renders by `section.type` / `cardType`:
|
|
185
212
|
`detailSection`/`locationCard`/`totalsCard`, where `cardType` comes from `surface.config.cardType`)
|
|
@@ -212,15 +239,14 @@ from "≥1 field visible", and bind an array-derived value via element `config`
|
|
|
212
239
|
resolved on the FE. This is the presentation counterpart of the schema's field-driven pattern and the
|
|
213
240
|
third section pattern alongside card sections (`cardType` → grid renderer) and marker toggles.
|
|
214
241
|
|
|
215
|
-
- **
|
|
216
|
-
in-code
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
own seeds are verified.
|
|
242
|
+
- **No roster (as of 2026-07-23).** The former
|
|
243
|
+
`SALES_ORDER_SURFACE_MIGRATED_CLIENTS = [COMPASS, COMPASSCANADA, QUAD]` in-code gate was **removed**;
|
|
244
|
+
the view-model now resolves sections from Surface for every client, and an empty/unauthorized bundle
|
|
245
|
+
adapts to `{}` so unseeded clients stay JSON-driven. Merge is still **JSON-base + Surface-wins**. The
|
|
246
|
+
guardrail that used to justify the roster (shared Core SECTION surfaces seeded from Compass's shape)
|
|
247
|
+
is now enforced backend-side by the **Core-neutral-default → per-client opt-in** seeding rule (see
|
|
248
|
+
[surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md)) — Core no longer ships
|
|
249
|
+
Compass's shape as a live default, so removing the roster does not regress unseeded clients.
|
|
224
250
|
- **JSON cleanup:** the migrated section blocks (orderDetails/shipTo/billTo/orderSummary/
|
|
225
251
|
orderRecurring + the 5 display sections) were **deleted** from the COMPASS / COMPASSCANADA / QUAD
|
|
226
252
|
`orderViewFields.json` files. Data-wiring (`salesOrderDetailsConfig`/`itemFulfillmentClickRule`/
|
|
@@ -471,6 +497,19 @@ now carry it (2026-07-21):
|
|
|
471
497
|
treat type-checking as pending. Runtime `GET /v2/surfaces/{slug}/meta` also not yet exercised.
|
|
472
498
|
|
|
473
499
|
## Change history
|
|
500
|
+
- 2026-07-23 — **DECISION + FIX: surface on/off per client is now 100% backend-driven — removed the FE
|
|
501
|
+
roster entirely.** Deleted `SALES_ORDER_SURFACE_MIGRATED_CLIENTS = [COMPASS,COMPASSCANADA,QUAD]` from
|
|
502
|
+
`surfaceBundleToTenantFields.ts` + `helpers/index.ts`; `useSalesOrderRecordModalLayoutModel.tsx` now
|
|
503
|
+
ALWAYS fetches the surface group and spreads `surfaceBundleToTenantFields(surfaces)` unconditionally.
|
|
504
|
+
The fetch is failure-isolated (`retry:false` → `surfaces` defaults to `{}`) and the adapter returns
|
|
505
|
+
`{}` for an empty/unauthorized bundle, so a client the BE doesn't serve stays JSON-driven with no
|
|
506
|
+
roster. A client "turns on" Surface purely via backend state: the `surfaces`/`meta-group`
|
|
507
|
+
`AclRecordScripts` grant + seeded/overridden surfaces. This unblocked NYCHH (was blocked by the roster
|
|
508
|
+
from receiving the corrected PO-number field). Swept every other `useFetchSurfaceMeta(Group)` call
|
|
509
|
+
site (item-record modal, approval-decision modal, Items/VendorItems/Inventory/Login) — all already
|
|
510
|
+
BE-driven; the SO modal roster was the only FE gate. Confirmed the `src/fieldsConfig/` `clientSlug`
|
|
511
|
+
conditionals are the legacy JSON fallback layer (not surface gating) and left them in place.
|
|
512
|
+
(apeterson)
|
|
474
513
|
- 2026-07-21 — Extended `surfaceBundlesToDecisionFields.ts` to emit **`isConcatenated`** on
|
|
475
514
|
decision-summary rows: new exported `DecisionSummaryConcat` type (string shorthand | descriptor
|
|
476
515
|
`{valueKey, prefix?, suffix?, valueType?, layout?, label?}`, mirroring blox `BaseDetailField`); optional
|
package/knowledge/INDEX.md
CHANGED
|
@@ -17,7 +17,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
17
17
|
|
|
18
18
|
## 2.0 framework
|
|
19
19
|
|
|
20
|
-
- **_underscore** (_Underscore) _(framework core)_ —
|
|
20
|
+
- **_underscore** (_Underscore) _(framework core)_ — 38 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
21
21
|
- **worker2** (Worker) — 31 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
22
22
|
- **api2** (API) — 14 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
23
23
|
- **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
| Doc | Framework | Summary | Files |
|
|
4
4
|
|-----|-----------|---------|-------|
|
|
5
5
|
| [Prudential: Dell ASN units PRE/POST interceptor (legacy key + flat tracking)](features/dell-asn-units-interceptor.md) | 2.0 | After the tracking-number bridge migration, the ASN unit route was renamed (`advance-shipping-notice-units` → `advance-shipping-notice-item-units`), so the inhe | _underscore/Model/Prudential/AdvanceShippingNotice.php, dbchanges2/Client_Prudential/2026-06-10 - AsnUnitsInterceptor.sql |
|
|
6
|
-
| [Prudential: Dell LCH IOP transmissions (LCHRequestV2)](features/dell-lch-iop-transmissions.md) | 1.0 | Outbound order transmissions from the 1.0 worker tier to Dell's Lifecycle Hub (LCH) ITSM integration. | library/app/api/delllch.php,
|
|
6
|
+
| [Prudential: Dell LCH IOP transmissions (LCHRequestV2)](features/dell-lch-iop-transmissions.md) | 1.0 | Outbound order transmissions from the 1.0 worker tier to Dell's Lifecycle Hub (LCH) ITSM integration. | library/app/api/delllch.php, library/app/apitransaction.php, worker/crons/toga2/prudential/transmissions_to_dell_usa.php, worker/crons/toga2/prudential/transmissions_to_dell_ireland.php, worker/crons/toga2/prudential/transmissions_to_dell_india.php |
|
|
7
7
|
| [Prudential: Device information import + unit→contact linking (import_device_information.php)](features/device-information-import-and-contact-linking.md) | 1.0 | Prudential's **device-sync** cron (`worker/crons/toga2/prudential/import_device_information.php`) pulls device/asset records (from ServiceNow / the Dell CMDB fe | worker/crons/toga2/prudential/import_device_information.php, worker/crons/toga2/prudential/backfill_unit_contacts.php, dbchanges2/Client_Prudential/2026-07-07 - Contact Dedup Merge.sql |
|
|
8
|
-
| [Prudential: Service Request Regional Address Validation](features/service-request-address-validation.md) | 2.0 | The `prePost` interceptor on `_Model_Prudential_ServiceRequest` validates `deliverToAddress` fields differently depending on which Prudential regional customer | _underscore/Model/Prudential/ServiceRequest.php |
|
|
8
|
+
| [Prudential: Service Request Regional Address Validation](features/service-request-address-validation.md) | 2.0 | The `prePost` interceptor on `_Model_Prudential_ServiceRequest` validates `deliverToAddress` fields differently depending on which Prudential regional customer | _underscore/Model/Prudential/ServiceRequest.php, _underscore/Test/Prudential/ServiceRequestTest.php |
|
|
9
9
|
| [Prudential Order Shipped Email — transmit_ordershipped_updates_prudential.php](features/transmit-ordershipped-email.md) | 1.0 | Cron script that transmits "Order Shipped" updates to ServiceNow (RITM) and sends a shipped notification email to the end user. | worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php, worker/crons/toga2/prudential/transmit_closecomplete_updates_prudential.php, worker/crons/toga2/prudential/transmit_rejected_cancelled_updates_prudential.php, worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php, worker/crons/toga2/prudential_beta/generate_sales_and_purchase_orders_from_service_requests.php, worker/crons/notifications/reports/prudential_exception_report.php |
|
|
10
10
|
| [Prudential Financial](profile.md) | 2.0 | Prudential is a TOGA client whose device-fulfillment flow is driven by **Dell** via the Dell API (`Client_Prudential.Apis.id = 2`). | |
|
|
11
11
|
| [Prudential: Dell ASN failed POST backfill replay](workflows/dell-asn-backfill-replay.md) | 2.0 | When Dell ASN POSTs fail in bulk (e.g. | |
|
|
@@ -6,14 +6,14 @@ project: Worker
|
|
|
6
6
|
client: prudential
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["rgirish"]
|
|
9
|
+
updated: 2026-07-24
|
|
10
|
+
owners: ["rgirish", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- library/app/api/delllch.php
|
|
13
|
-
-
|
|
14
|
-
- worker/crons/toga2/
|
|
15
|
-
- worker/crons/toga2/
|
|
16
|
-
- worker/crons/toga2/
|
|
13
|
+
- library/app/apitransaction.php
|
|
14
|
+
- worker/crons/toga2/prudential/transmissions_to_dell_usa.php
|
|
15
|
+
- worker/crons/toga2/prudential/transmissions_to_dell_ireland.php
|
|
16
|
+
- worker/crons/toga2/prudential/transmissions_to_dell_india.php
|
|
17
17
|
related:
|
|
18
18
|
- ../profile.md
|
|
19
19
|
- dell-asn-units-interceptor.md
|
|
@@ -31,6 +31,68 @@ only the request route and payload schema changed.
|
|
|
31
31
|
This is the **outbound** counterpart to the 2.0 inbound Dell ASN flow (see
|
|
32
32
|
`dell-asn-units-interceptor.md`).
|
|
33
33
|
|
|
34
|
+
## Which cron is live (READ THIS FIRST)
|
|
35
|
+
|
|
36
|
+
The current **production** Dell transmitters are the three regional split files in
|
|
37
|
+
`worker/crons/toga2/prudential/`: `transmissions_to_dell_usa.php`,
|
|
38
|
+
`transmissions_to_dell_ireland.php`, `transmissions_to_dell_india.php`. They are active in
|
|
39
|
+
`worker/schedules/cron.worker.sync.json`, run every 5 minutes, and are each scoped to one
|
|
40
|
+
region by `Customers.uuid`. Only the split files have the pre-send `validateLCHRequestV2`
|
|
41
|
+
step and Apple-device handling.
|
|
42
|
+
|
|
43
|
+
- The 119 KB monolith `transmissions_to_dell.php` is **DISABLED** — do not debug or edit it.
|
|
44
|
+
- `transmissions_to_dell_v1.php` is a **dead backup** — ignore it.
|
|
45
|
+
- The regional files were formerly under `crons/toga2/prudential_beta/`; they have since been
|
|
46
|
+
promoted to `crons/toga2/prudential/` and are the live production crons.
|
|
47
|
+
|
|
48
|
+
## Selection query (how a REQ becomes a Dell send)
|
|
49
|
+
|
|
50
|
+
Each regional cron selects Dell POs that have not yet been transmitted:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
INNER JOIN Customers ON Customers.id = ServiceRequests.customerId
|
|
54
|
+
WHERE PurchaseOrders.vendorId = 1 # Dell = Vendors.id 1
|
|
55
|
+
AND PurchaseOrders.dtSubmitted IS NULL
|
|
56
|
+
AND Customers.uuid = '<region-uuid>'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- **Region lives on `ServiceRequests.customerId`**, resolved through the join to
|
|
60
|
+
`Client_Prudential.Customers`. Region rows: **USA = id 3, uuid
|
|
61
|
+
`b3f7a2c1-4e89-4d6a-9c3b-8f1e5d2a7b04`**; India and Ireland are separate `Customers` rows /
|
|
62
|
+
uuids. This is NOT the SalesOrder customer — the SO customer is always
|
|
63
|
+
**"Agilant - Tech Hub" (id 1, uuid `36da53f8-38d2-404f-becf-f58f33c215d3`)**.
|
|
64
|
+
- A REQ is only ever transmitted, validated, or marked once it is selected here. Unit/bundle/SO/PO
|
|
65
|
+
creation happens elsewhere and does **not** send anything to Dell.
|
|
66
|
+
|
|
67
|
+
## Send mechanism
|
|
68
|
+
|
|
69
|
+
- `App_Api_Delllch::send` (`library/app/api/delllch.php`) POSTs to route
|
|
70
|
+
`api/v2/request/LCHRequestV2`. Prod host `https://cms-iop.us.dell.com/iopv2/`; non-prod
|
|
71
|
+
`.../iopv2np/`.
|
|
72
|
+
- Auth: OAuth2 client-credentials Bearer token obtained via `auth/token` (credentials stored in
|
|
73
|
+
config, not in this doc).
|
|
74
|
+
- The split files first call `validateLCHRequestV2` (route `api/v2/validate/LCHRequestV2`) before
|
|
75
|
+
the real submit.
|
|
76
|
+
|
|
77
|
+
## Transaction log (where to pull any Dell send for an audit/RCA)
|
|
78
|
+
|
|
79
|
+
Dell's requested transaction record (date/time, payload, response code, response body) lives in
|
|
80
|
+
`Logs_Prudential.Api`:
|
|
81
|
+
|
|
82
|
+
| Column | Meaning |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `dtStamp` | timestamp (indexed) |
|
|
85
|
+
| `direction` | `'OUT'` for our sends to Dell |
|
|
86
|
+
| `method` / `hostname` / `route` | HTTP verb, `https://cms-iop.us.dell.com`, e.g. `.../request/LCHRequestV2` |
|
|
87
|
+
| `requestPayload` / `responseCode` / `responsePayload` | outbound body, HTTP status, Dell response body |
|
|
88
|
+
|
|
89
|
+
Reading it:
|
|
90
|
+
- A row with a `requestPayload` but **NULL `responseCode`** = curl never got an answer.
|
|
91
|
+
- A `/validate/` row with **no** matching `/request/` row = validated but never submitted.
|
|
92
|
+
- **Do not `LIKE`-scan on `requestPayload`** — `Logs_Prudential.Api` is millions of rows and the
|
|
93
|
+
payload is not indexed, so a `requestPayload LIKE '%REQ%'` query times out. Filter by `dtStamp`
|
|
94
|
+
(indexed) + `direction` + exact `route` instead.
|
|
95
|
+
|
|
34
96
|
## How it works
|
|
35
97
|
|
|
36
98
|
- **Route:** `api/v2/request/LCHRequest` → **`api/v2/request/LCHRequestV2`** in all four
|
|
@@ -101,6 +163,23 @@ This is the established pattern for all Prudential script changes.
|
|
|
101
163
|
3. After beta passes a full order cycle (New → shipped), confirm the production script is live.
|
|
102
164
|
|
|
103
165
|
## Gotchas
|
|
166
|
+
- **NULL `ServiceRequests.customerId` = order silently never reaches Dell.** The selection
|
|
167
|
+
query's `INNER JOIN Customers ON Customers.id = ServiceRequests.customerId` drops any REQ whose
|
|
168
|
+
`customerId` is NULL, so it is never selected, never validated, never POSTed — and the Dell PO's
|
|
169
|
+
`dtSubmitted` stays NULL forever (not sent, not marked, invisible). This is the sole
|
|
170
|
+
differentiator behind "Dell has no trace of the REQ though Talos says we sent it": SO/PO/unit
|
|
171
|
+
creation all succeeded, only transmission was skipped. At one investigation 79 Dell POs were
|
|
172
|
+
stuck this way, all with `customerId` NULL. The 2.0-side guard added in
|
|
173
|
+
`service-request-address-validation.md` stops NEW customer-less REQs; already-stuck ones need a
|
|
174
|
+
`customerId` backfill from ship-to country.
|
|
175
|
+
- **`dtSubmitted` is stamped without inspecting the response (latent bug, not yet fixed).** Every
|
|
176
|
+
regional cron sets `PurchaseOrders.dtSubmitted = NOW()` immediately after the send without
|
|
177
|
+
checking Dell's HTTP status, and `App_ApiTransaction::execute()` (`library/app/apitransaction.php`)
|
|
178
|
+
only throws on curl transport failure / empty / non-JSON body — **not** on HTTP >= 400. So a Dell
|
|
179
|
+
4xx/5xx rejection is recorded as "sent", the PO drops out of the `dtSubmitted IS NULL` queue, and
|
|
180
|
+
is never retried — it looks sent internally while Dell never accepted it. `getAccessToken()` also
|
|
181
|
+
does not check the token-call result (silent 401 path). This is a second way an order can silently
|
|
182
|
+
fail to reach Dell; fix later by inspecting the response before stamping.
|
|
104
183
|
- `requestType` is the SR type only (Refresh/Breakfix/Reclaim/Bulk) — do **not** put the event
|
|
105
184
|
there. Event signalling moved to the separate required `requestEvent` field
|
|
106
185
|
(Create/Update/Cancel). Conflating them is the easiest way to reproduce the old overloaded
|
|
@@ -111,6 +190,12 @@ This is the established pattern for all Prudential script changes.
|
|
|
111
190
|
do not reintroduce them.
|
|
112
191
|
|
|
113
192
|
## Change history
|
|
193
|
+
- 2026-07-24 — Documented the live-cron reality (production is the three regional split files under
|
|
194
|
+
`crons/toga2/prudential/`; monolith `transmissions_to_dell.php` disabled, `_v1` dead), the
|
|
195
|
+
selection query, region model on `ServiceRequests.customerId` (USA id 3), the `App_Api_Delllch`
|
|
196
|
+
send route/host/auth, and the `Logs_Prudential.Api` transaction-log location + safe-query rule.
|
|
197
|
+
Recorded two silent-failure gotchas: NULL `customerId` excluded by the inner join (root cause of
|
|
198
|
+
79 stuck Dell POs) and `dtSubmitted` stamped without response inspection (latent bug). (bala)
|
|
114
199
|
- 2026-06-30 — Migrated all Prudential Dell transmissions (3 beta regional + production) and
|
|
115
200
|
`App_Api_Delllch` from deprecated `LCHRequest` to `LCHRequestV2` per Dell's May 2025 IOP
|
|
116
201
|
spec: re-shaped payload (employee/ship/return objects, renamed return-item keys), new
|
|
@@ -6,13 +6,16 @@ project: _Underscore
|
|
|
6
6
|
client: prudential
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["rgirish"]
|
|
9
|
+
updated: 2026-07-24
|
|
10
|
+
owners: ["rgirish", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Prudential/ServiceRequest.php
|
|
13
|
+
- _underscore/Test/Prudential/ServiceRequestTest.php
|
|
13
14
|
related:
|
|
14
15
|
- clients/prudential/profile.md
|
|
15
16
|
- clients/prudential/features/dell-asn-units-interceptor.md
|
|
17
|
+
- clients/prudential/features/dell-lch-iop-transmissions.md
|
|
18
|
+
- ../../../2.0/apps/_underscore/features/model-interceptor-unit-testing.md
|
|
16
19
|
---
|
|
17
20
|
|
|
18
21
|
## Summary
|
|
@@ -27,12 +30,20 @@ back to USA rules.
|
|
|
27
30
|
## Key files / entry points
|
|
28
31
|
|
|
29
32
|
- `_underscore/Model/Prudential/ServiceRequest.php` — `prePost` interceptor; private methods
|
|
30
|
-
`
|
|
33
|
+
`validateCustomer()`, `resolveRegion()`, `validateUsaAddress()`, `validateIndiaAddress()`,
|
|
34
|
+
`validateIrelandAddress()`
|
|
31
35
|
|
|
32
36
|
## How it works
|
|
33
37
|
|
|
34
38
|
1. `prePost` fires before each POST to `/v2/service-requests` for the Prudential client.
|
|
35
|
-
2.
|
|
39
|
+
2. **`validateCustomer()` is the first check** (before `deliverToAddress` / region resolution). It
|
|
40
|
+
rejects the create when `customer.uuid` is missing/null/blank — the test is
|
|
41
|
+
`trim($payload->customer->uuid ?? '') === ''` — by throwing `_Exception_Validation`, which
|
|
42
|
+
`Controller/Index.php` maps to **HTTP 400**. This stops customer-less / unroutable REQs at the
|
|
43
|
+
API boundary. A customer-less REQ that slips through is exactly what strands an order: with a
|
|
44
|
+
NULL `ServiceRequests.customerId` it is silently excluded from every regional Dell
|
|
45
|
+
transmission cron (see `dell-lch-iop-transmissions.md`).
|
|
46
|
+
3. `resolveRegion()` reads `payload->customer->uuid`, escapes it, and queries
|
|
36
47
|
`Client_Prudential.Customers` for the matching `name`. Returns `'India'`, `'Ireland'`, or
|
|
37
48
|
`'USA'` (default for anything unrecognised or when uuid is null).
|
|
38
49
|
3. A `match` expression dispatches to the correct validator.
|
|
@@ -82,9 +93,12 @@ with a space (e.g. `F92 FP83`). No numeric restriction.
|
|
|
82
93
|
|
|
83
94
|
## Gotchas / known issues
|
|
84
95
|
|
|
85
|
-
- **Null UUID
|
|
86
|
-
|
|
87
|
-
|
|
96
|
+
- **Null/blank UUID is now REJECTED (changed 2026-07-24).** Previously a missing `customer.uuid`
|
|
97
|
+
silently fell through `resolveRegion()` to the USA default — which is how customer-less,
|
|
98
|
+
unroutable REQs (NULL `ServiceRequests.customerId`) got created and then silently dropped by the
|
|
99
|
+
Dell crons. The new `validateCustomer()` guard rejects a blank uuid with a 400, so region
|
|
100
|
+
fallback to USA now only ever applies to a uuid that is *present but unrecognised*. Already-stuck
|
|
101
|
+
REQs created before this guard still need a `customerId` backfill from ship-to country.
|
|
88
102
|
- **Region check is by `Customers.name` string, not by id.** If the customer name is ever
|
|
89
103
|
changed in the DB (e.g. `'USA'` → `'United States'`), the validator will silently fall
|
|
90
104
|
back to USA rules for all regions. The match arms are `'India'` and `'Ireland'` only;
|
|
@@ -97,5 +111,10 @@ with a space (e.g. `F92 FP83`). No numeric restriction.
|
|
|
97
111
|
their Dell spec). If Dell starts sending it for USA/Ireland it is silently ignored.
|
|
98
112
|
|
|
99
113
|
## Change history
|
|
114
|
+
- 2026-07-24 — Added `validateCustomer()` as the first check in `prePost`: rejects a create when
|
|
115
|
+
`customer.uuid` is missing/null/blank (`trim(... ?? '') === ''`) via `_Exception_Validation`
|
|
116
|
+
(→ HTTP 400), stopping customer-less/unroutable REQs at the boundary. Previously a blank uuid
|
|
117
|
+
silently defaulted to USA, producing NULL-`customerId` REQs that the Dell transmission crons
|
|
118
|
+
drop. Backed by 6 regression tests in `_underscore/Test/Prudential/ServiceRequestTest.php`. (bala)
|
|
100
119
|
- 2026-06-16 — Created: regional address validation (USA/India/Ireland) replacing the single
|
|
101
120
|
USA-only `validateDeliverToAddress` method; region resolved from `customer.uuid` lookup (rgirish)
|
package/package.json
CHANGED