toga-ai 1.0.623 → 1.0.625
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/features/netsuite-rest-client.md +25 -1
- package/knowledge/2.0/apps/_underscore/features/record-change-audit-log.md +29 -1
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/api-payload-interceptors.md +112 -2
- package/knowledge/2.0/apps/api2/features/v2-api-error-codes.md +8 -1
- package/knowledge/2.0/apps/worker2/features/netsuite-salesorder-outbound-push.md +135 -9
- package/knowledge/2.0/apps/worker2/features/service-request-sales-order-generation.md +16 -2
- package/knowledge/2.0/apps/worker2/workflows/running-worker2-locally.md +16 -2
- package/knowledge/2.0/standards/backend-php.md +67 -1
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/compass-canada/profile.md +9 -0
- package/knowledge/clients/compass-usa/INDEX.md +1 -0
- package/knowledge/clients/compass-usa/features/approval-decision-flow.md +65 -13
- package/knowledge/clients/compass-usa/features/people-file-user-lifecycle.md +62 -30
- package/knowledge/clients/compass-usa/features/stranded-approval-reassignment.md +206 -0
- package/knowledge/clients/compass-usa/profile.md +2 -1
- package/knowledge/clients/elite/features/salesorder-netsuite-push.md +107 -5
- package/knowledge/clients/elite/profile.md +8 -1
- package/package.json +1 -1
|
@@ -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-20
|
|
10
10
|
owners: ["dfranks", "jcardinal", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Component/Api/Netsuite/Netsuite.php
|
|
@@ -127,6 +127,23 @@ Updates do **not** need a new helper. A NetSuite record PATCH returns 204 with n
|
|
|
127
127
|
`send()` already throws on non-2xx — so the update path is just `send('PATCH', RECORD.'/'.$id,
|
|
128
128
|
$body)`. Only the create path (which must read the `Location` header) required the new primitive.
|
|
129
129
|
|
|
130
|
+
### Idempotent creates — stamp an `externalId`, look it up with `eid:`
|
|
131
|
+
|
|
132
|
+
`createRecord()` has no idempotency of its own, so a caller that must not create twice should put an
|
|
133
|
+
**`externalId`** on the record it creates. Two things follow, and both are cheap:
|
|
134
|
+
|
|
135
|
+
- **NetSuite itself rejects a duplicate `externalId`** within the account, so it is a backstop even
|
|
136
|
+
when the caller's own pre-check misses.
|
|
137
|
+
- **A record can be read back by external id** with `GET /record/v1/<record>/eid:<externalId>` — no
|
|
138
|
+
SuiteQL involved. That matters because whether SuiteQL can **filter** on the native
|
|
139
|
+
`transaction.externalid` column in account 1095849 is **not yet verified** (see the custom-column
|
|
140
|
+
trap below for why a silent zero-row filter is a real risk here). `eid:` is the fallback.
|
|
141
|
+
|
|
142
|
+
Account 1095849 is **shared by every client**, so an external id must be globally unique and
|
|
143
|
+
prefixed — never a per-client record number. Worked example:
|
|
144
|
+
[Toga → NetSuite sales-order push](../../worker2/features/netsuite-salesorder-outbound-push.md)
|
|
145
|
+
(`toga-so-<salesOrder uuid>`).
|
|
146
|
+
|
|
130
147
|
## SuiteQL string-literal escaping — double the quote, never backslash
|
|
131
148
|
|
|
132
149
|
SuiteQL is **ANSI SQL sent to NetSuite as the JSON `q` string** — it is **not** executed against
|
|
@@ -175,6 +192,13 @@ doc.)
|
|
|
175
192
|
|
|
176
193
|
## Change history
|
|
177
194
|
|
|
195
|
+
- 2026-08-20 — Added **idempotent creates via `externalId`**: `createRecord()` has no idempotency, so
|
|
196
|
+
stamp an `externalId` (NetSuite rejects a duplicate within the account) and read the record back
|
|
197
|
+
with `GET /record/v1/<record>/eid:<externalId>` — the non-SuiteQL fallback, which matters because
|
|
198
|
+
**filtering** on the native `transaction.externalid` column is still unverified in account 1095849.
|
|
199
|
+
Noted that the account is shared by every client, so external ids must be globally unique and
|
|
200
|
+
prefixed. (bala)
|
|
201
|
+
|
|
178
202
|
- 2026-08-10 — **Fixed the `createRecord()` trailing-id regex** (`Netsuite.php` ~L198): re-delimited
|
|
179
203
|
`'#/(\d+)(?:[?#]|$)#'` → `~`, so `preg_match` no longer returns `false` with
|
|
180
204
|
*"Unknown modifier ']'"* and the method can actually return an internalId. Recorded the durable
|
|
@@ -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-20
|
|
10
10
|
owners: [bala]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Client/Logs/Record.php
|
|
@@ -15,6 +15,9 @@ files:
|
|
|
15
15
|
related:
|
|
16
16
|
- ../../api2/features/request-logging.md
|
|
17
17
|
- ../../../../clients/compass-usa/features/approval-decision-flow.md
|
|
18
|
+
- ../../../../clients/compass-usa/features/stranded-approval-reassignment.md
|
|
19
|
+
- ../../api2/features/api-payload-interceptors.md
|
|
20
|
+
- ../../worker2/features/creating-worker-actions.md
|
|
18
21
|
---
|
|
19
22
|
|
|
20
23
|
## Summary
|
|
@@ -90,6 +93,19 @@ single record: the milestone notes are the chapter headings, the field changes a
|
|
|
90
93
|
automatically. Real example:
|
|
91
94
|
[Compass PEOPLE-file user lifecycle](../../../../clients/compass-usa/features/people-file-user-lifecycle.md),
|
|
92
95
|
whose deactivation batch is a raw `UPDATE Users SET isActive = 0 WHERE id IN (...)`.
|
|
96
|
+
- **⚠⚠ `_Model_Client_Logs_Record::addNote()` IS A SILENT NO-OP OUTSIDE api2.** `addNote()` only
|
|
97
|
+
**stages** rows into the class's static `$_notes` array. The **only** thing that ever flushes that
|
|
98
|
+
array is api2's `Component/Api/V2/V2.php`. Nothing in `_underscore` and nothing in `worker2`
|
|
99
|
+
flushes it. So **any worker or cron that calls `addNote()` loses the note entirely** — no error, no
|
|
100
|
+
warning, no row. This is the most dangerous failure mode on this page, because the calling code
|
|
101
|
+
reads as if it audited itself.
|
|
102
|
+
|
|
103
|
+
A worker that needs a note must either drain `_Model_Client_Logs_Record::$_notes` itself and INSERT
|
|
104
|
+
into `Logs_<Tenant>.Record`, or write the `Record` rows directly. Note that
|
|
105
|
+
**`Logs_<Tenant>.Record.clientId` is `NOT NULL`**, and a worker generally does not have an `$api`
|
|
106
|
+
to read the client id from — resolve it from `Core` (`Clients` JOIN `Databases` on `name`). Known
|
|
107
|
+
ids: `Client_Compass` = **2** (`Logs_Compass`), `Client_CompassCanada` = **43**
|
|
108
|
+
(`Logs_CompassCanada`). This applies to **every** client, not just Compass.
|
|
93
109
|
- **⚠ `Logs_*` and `Core` are on different database clusters — you cannot join them in one query.**
|
|
94
110
|
`Record.recordId` → `Core.Records` and `RecordField.recordFieldId` → `Core.RecordFields` are
|
|
95
111
|
logical foreign keys across a cluster boundary. Resolve the `Core` ids in a **separate** query and
|
|
@@ -104,6 +120,14 @@ single record: the milestone notes are the chapter headings, the field changes a
|
|
|
104
120
|
|
|
105
121
|
## Change history
|
|
106
122
|
|
|
123
|
+
- 2026-08-20 — Added the **`addNote()` is a silent no-op outside api2** gotcha: `addNote()` only
|
|
124
|
+
stages into the static `$_notes` array and **only api2's `Component/Api/V2/V2.php` ever flushes
|
|
125
|
+
it**, so every `addNote()` call from `_underscore` or `worker2` loses its note with no error. A
|
|
126
|
+
worker must drain `$_notes` itself or write `Record` rows directly, and must resolve the
|
|
127
|
+
`NOT NULL` `Record.clientId` from `Core` (`Clients` JOIN `Databases` on `name`) because it has no
|
|
128
|
+
`$api` — `Client_Compass` = 2, `Client_CompassCanada` = 43. Found while building the Compass
|
|
129
|
+
[stranded approval reassignment](../../../../clients/compass-usa/features/stranded-approval-reassignment.md)
|
|
130
|
+
engine, but it is framework-wide and client-agnostic. (bala)
|
|
107
131
|
- 2026-08-13 — Documented the `Logs_<Tenant>.Record` / `RecordField` field-change audit trail as its
|
|
108
132
|
own subject: the two row shapes (field-change rows with `RecordField` children vs. milestone rows
|
|
109
133
|
carrying only a `note`), scoping by `Record.recordId` = the **`Core.Records` model id**
|
|
@@ -119,3 +143,7 @@ single record: the milestone notes are the chapter headings, the field changes a
|
|
|
119
143
|
request log, the other half of "what happened to this record".
|
|
120
144
|
- [Compass Approval-Decision Flow](../../../../clients/compass-usa/features/approval-decision-flow.md)
|
|
121
145
|
— the investigation this technique was generalized from.
|
|
146
|
+
- [Stranded Approval Reassignment](../../../../clients/compass-usa/features/stranded-approval-reassignment.md)
|
|
147
|
+
— the worker that hit the `addNote()` no-op and had to write its own `Record` rows.
|
|
148
|
+
- [API Payload Interceptors](../../api2/features/api-payload-interceptors.md) — why api2 is the only
|
|
149
|
+
tier that flushes staged notes.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
|
|
6
|
-
| [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Rate/Entitlement.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql, dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
|
|
6
|
+
| [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Rate/Entitlement.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, _underscore/Model/Compass/ApprovalDecision.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql, dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
|
|
7
7
|
| [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, _underscore/Model/Core/Record.php, worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
|
|
8
8
|
| [cXML ShipNotice Gateway (ASN ingestion, carrier resolution, per-client provisioning)](features/cxml-shipnotice-gateway.md) | `_Component_Api_Cxml` accepts a supplier `ShipNoticeRequest` and translates it into a `POST /v2/advance-shipping-notices` on the V2 JSON engine. | api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php |
|
|
9
9
|
| [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 |
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-20
|
|
10
10
|
owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -14,10 +14,12 @@ files:
|
|
|
14
14
|
- _underscore/Model/Client/ItemFulfillment.php
|
|
15
15
|
- _underscore/Model/Rate/Entitlement.php
|
|
16
16
|
- _underscore/Model/Compass/PurchaseOrder.php
|
|
17
|
+
- _underscore/Model/Client/SalesOrder.php
|
|
17
18
|
- _underscore/Model/Elite/SalesOrder.php
|
|
18
19
|
- _underscore/Model/Elite/ServiceRequest.php
|
|
19
20
|
- _underscore/Model/Compass/SalesOrder.php
|
|
20
21
|
- _underscore/Model/Compass/SalesOrderItem.php
|
|
22
|
+
- _underscore/Model/Compass/ApprovalDecision.php
|
|
21
23
|
- dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
|
|
22
24
|
- dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql
|
|
23
25
|
- dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql
|
|
@@ -32,6 +34,9 @@ related:
|
|
|
32
34
|
- ../../_underscore/features/recursive-item-fulfillments.md
|
|
33
35
|
- ../../_underscore/features/config-group-access.md
|
|
34
36
|
- ../../worker2/features/creating-worker-actions.md
|
|
37
|
+
- ../../../clients/compass-usa/features/stranded-approval-reassignment.md
|
|
38
|
+
- ../../_underscore/features/record-change-audit-log.md
|
|
39
|
+
- ../../_underscore/features/email-template-sending.md
|
|
35
40
|
- ../../../clients/elite/features/salesorder-netsuite-push.md
|
|
36
41
|
- ../../../clients/compass-usa/features/sales-order-line-renumbering.md
|
|
37
42
|
---
|
|
@@ -394,7 +399,10 @@ request.** Never conclude "it works on beta" for an in-request read-after-write.
|
|
|
394
399
|
- Pass **`false`** for `internalApiRequest`'s 5th argument (`$throwExceptionsOnError`). Its throw is
|
|
395
400
|
guarded by `!empty($this->response->messages)` on the **shared** response object, and
|
|
396
401
|
`json_encode([])` is the **truthy** string `"[]"` — so it throws even with **zero real errors**
|
|
397
|
-
once any message (even a warning) is present anywhere in the request.
|
|
402
|
+
once any message (even a warning) is present anywhere in the request. **This is not theoretical:**
|
|
403
|
+
it took Elite's production `POST /v2/sales-orders` down on 2026-08-20, from the *parent* model's
|
|
404
|
+
own `internalApiRequest` (see [the inherited-chain section](#-activating-a-clients-first-interceptor-row-also-switches-on-the-whole-inherited-chain)).
|
|
405
|
+
The throw site is `V2.php:2416`.
|
|
398
406
|
|
|
399
407
|
### Two more failure modes to expect on this path
|
|
400
408
|
|
|
@@ -408,6 +416,83 @@ request.** Never conclude "it works on beta" for an in-request read-after-write.
|
|
|
408
416
|
ambiguous** ("never queued" vs "queued, never consumed"). Both are covered in
|
|
409
417
|
[Creating Worker Actions](../../worker2/features/creating-worker-actions.md).
|
|
410
418
|
|
|
419
|
+
## ⚠ Activating a client's FIRST interceptor row also switches on the whole INHERITED chain
|
|
420
|
+
|
|
421
|
+
A client whose `ApiPayloadInterceptors` set is **empty** has never run `postPost` at all — which
|
|
422
|
+
means it has also never run the **parent** `_Model_Client_<Record>::postPost()` that a client
|
|
423
|
+
override calls via `parent::`. Inserting the first row does not merely enable the code you wrote; it
|
|
424
|
+
enables **every inherited statement above it**, for the first time, in that environment, against that
|
|
425
|
+
client's data.
|
|
426
|
+
|
|
427
|
+
**Worked example — Elite, production, 2026-08-20** (Sentry `API-1Z8`, 12 events, escalating).
|
|
428
|
+
Activating two `sales-orders` rows made `POST /v2/sales-orders` return **`EO-1` 500 with the order
|
|
429
|
+
rolled back**, and the exception message was literally **`[]`**:
|
|
430
|
+
|
|
431
|
+
1. Elite's `postPost` calls `parent::postPost()` first (correctly — approvals must still be created);
|
|
432
|
+
2. the parent, `_underscore/Model/Client/SalesOrder.php:359`, reads `/approval-templates` via
|
|
433
|
+
`internalApiRequest` with **throwing left ON**;
|
|
434
|
+
3. Elite has **zero `ApprovalTemplates`** rows, so that nested GET returns empty and puts a message
|
|
435
|
+
on the **shared** response object;
|
|
436
|
+
4. `V2.php:2416` does `json_encode(array_filter($messages, ...ERROR...))` → the string `"[]"` →
|
|
437
|
+
**truthy** → throw, with no real error in it;
|
|
438
|
+
5. the controller catches it, returns `EO-1`, and **rolls back** the write.
|
|
439
|
+
|
|
440
|
+
**Reading the signature:** an exception whose **message is `[]`** on a write route means *this* — a
|
|
441
|
+
message-array truthiness throw. Nothing is wrong with the payload, and the client's own interceptor
|
|
442
|
+
code may never have executed.
|
|
443
|
+
|
|
444
|
+
**Two fixes, both in shared code:**
|
|
445
|
+
|
|
446
|
+
- pass **`false`** as `internalApiRequest`'s 5th argument in the **parent** (narrow, per-caller); or
|
|
447
|
+
- fix **`V2.php:2416`** to test the **filtered array** rather than `json_encode()` of it — which
|
|
448
|
+
fixes **every** caller on this path. Senior-owned: raise it rather than patching one caller.
|
|
449
|
+
|
|
450
|
+
**Before activating a row for a client that has none:** walk the parent chain for the derived method
|
|
451
|
+
and confirm the data each inherited step depends on actually exists for that tenant (here: at least
|
|
452
|
+
one `ApprovalTemplates` row). This is the same reason activation must be staged `isActive = 0` first
|
|
453
|
+
— see the gotchas.
|
|
454
|
+
|
|
455
|
+
## Calling a real interceptor FROM a worker — the minimal `$api` shim
|
|
456
|
+
|
|
457
|
+
The reverse direction of the section above: not "queue a worker from an interceptor" but **"run the
|
|
458
|
+
production interceptor from a worker"**, so a backfill or repair job reuses the business rules
|
|
459
|
+
instead of reimplementing them. This is the right instinct — a duplicated auto-approve threshold or
|
|
460
|
+
email-routing rule will drift from the real one.
|
|
461
|
+
|
|
462
|
+
**The technique: measure the actual `$api` surface, then fake exactly that.** `$api` looks
|
|
463
|
+
un-fakeable because it is huge, but any single interceptor chain touches very little of it. Trace the
|
|
464
|
+
whole chain reachable from the entry point and you usually find a handful of properties.
|
|
465
|
+
|
|
466
|
+
Worked measurement (2026-08-20, `_Model_Compass_ApprovalDecision::postPut()`): the **entire** `$api`
|
|
467
|
+
surface reachable from that method is **three properties** — `$api->client`, `$api->httpPayload`,
|
|
468
|
+
`$api->recordUuid`. So a worker can build a 3-property shim and call the genuine `prePut`/`postPut`.
|
|
469
|
+
The Compass repair engine does exactly this; see
|
|
470
|
+
[Stranded Approval Reassignment](../../../clients/compass-usa/features/stranded-approval-reassignment.md).
|
|
471
|
+
|
|
472
|
+
Rules for doing it safely:
|
|
473
|
+
|
|
474
|
+
- **Trace the chain, per class — do not generalize the surface from a sibling.**
|
|
475
|
+
`$api->internalApiRequest` and `$api->route` *do* appear in
|
|
476
|
+
`_underscore/Model/Compass/SalesOrder.php`, but only inside
|
|
477
|
+
`_Model_Compass_SalesOrder::postPut()` — a **different class**, not in the `ApprovalDecision`
|
|
478
|
+
chain. Assuming "the Compass models need `internalApiRequest`" would have produced a shim that was
|
|
479
|
+
both wrong and larger than needed. Re-measure for every entry point.
|
|
480
|
+
- **A shim is a hard-fail contract.** If the interceptor later starts reading a fourth property, the
|
|
481
|
+
worker gets a fatal on a `stdClass` with no such property. That is the correct outcome (loud, not
|
|
482
|
+
silent), but it means the shim must be built where a reviewer will see it, not buried.
|
|
483
|
+
- **Emails still go out.** The interceptors send real mail through the real pipeline, so a worker
|
|
484
|
+
that runs them is a sending path — cap it, add a `dryRun`, and commit per unit of work, because a
|
|
485
|
+
sent email cannot be rolled back.
|
|
486
|
+
- **Do not use the interceptor path just to send an email.** For a plain send there is a documented
|
|
487
|
+
non-API entry point: `_Model_Client_EmailTemplate::send(string $clientIdentifier, ...)`, versus the
|
|
488
|
+
`$api`-based `sendEmail()`. See
|
|
489
|
+
[Email Template Sending](../../_underscore/features/email-template-sending.md). Faking `$api` is
|
|
490
|
+
for reusing *business logic*, not for reaching the mailer.
|
|
491
|
+
- **Notes will not be logged.** `_Model_Client_Logs_Record::addNote()` is flushed only by
|
|
492
|
+
`Component/Api/V2/V2.php`, so notes staged by the interceptor you just ran are **dropped** in a
|
|
493
|
+
worker. See
|
|
494
|
+
[Record Change Audit Log](../../_underscore/features/record-change-audit-log.md).
|
|
495
|
+
|
|
411
496
|
## Worked example — the EV-10 that was not a code bug
|
|
412
497
|
|
|
413
498
|
`_Model_Client_ItemFulfillment::prePost()` defaults `itemFulfillmentStageId` to the shipped stage on
|
|
@@ -442,6 +527,11 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
442
527
|
`Core.Records` row, so every change is direct SQL and nothing records who made it. Snapshot the
|
|
443
528
|
client's row set into the ticket before and after any change — that dump is your only rollback
|
|
444
529
|
and your only history.
|
|
530
|
+
- **⚠ The FIRST row for a client activates INHERITED code, not just yours.** An empty
|
|
531
|
+
`ApiPayloadInterceptors` set means the shared `_Model_Client_<Record>::postPost()` has never run
|
|
532
|
+
for that tenant either. Elite's first two prod rows took `POST /v2/sales-orders` down through the
|
|
533
|
+
**parent's** `/approval-templates` read — exception message literally `[]`. Check the parent chain
|
|
534
|
+
and its data prerequisites before flipping a client's first row on.
|
|
445
535
|
- **⚠ A migration that enables an interceptor must insert `isActive = 0`.** Activation is a *data*
|
|
446
536
|
change that switches on a *code* path; if the PHP defining the method is not confirmed deployed to
|
|
447
537
|
that environment, the row takes the endpoint down. Insert inactive, verify the deploy, then flip
|
|
@@ -505,6 +595,26 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
505
595
|
|
|
506
596
|
## Change history
|
|
507
597
|
|
|
598
|
+
- 2026-08-20 (later pass) — Recorded that **activating a client's FIRST interceptor row switches on
|
|
599
|
+
the entire INHERITED chain**, with the Elite production outage as the worked example: two new
|
|
600
|
+
`sales-orders` rows made every `POST /v2/sales-orders` return `EO-1` 500 with the order rolled
|
|
601
|
+
back, because the *parent* `_Model_Client_SalesOrder::postPost()` — running for the first time for
|
|
602
|
+
that tenant — reads `/approval-templates` with `internalApiRequest` throwing **on**, Elite has zero
|
|
603
|
+
`ApprovalTemplates`, and `V2.php:2416`'s `json_encode(array_filter(...))` yields the **truthy**
|
|
604
|
+
string `"[]"`. Documented the diagnostic signature (an exception whose message is literally `[]`)
|
|
605
|
+
and the two shared-code fixes (parent passes `false`, or `V2.php:2416` tests the filtered
|
|
606
|
+
**array** — senior-owned, not changed). (bala)
|
|
607
|
+
- 2026-08-20 — Added **"Calling a real interceptor FROM a worker — the minimal `$api` shim"**: the
|
|
608
|
+
reverse of the queue-from-interceptor path. A worker can reuse production interceptor logic by
|
|
609
|
+
measuring the `$api` surface an interceptor chain actually touches and faking only that — verified
|
|
610
|
+
that the **entire** surface reachable from `_Model_Compass_ApprovalDecision::postPut()` is three
|
|
611
|
+
properties (`client`, `httpPayload`, `recordUuid`). Recorded that the surface must be re-measured
|
|
612
|
+
**per class** (`$api->internalApiRequest` / `$api->route` appear in
|
|
613
|
+
`_Model_Compass_SalesOrder::postPut()`, a different class not in that chain), that the shim is a
|
|
614
|
+
deliberately loud hard-fail contract, that running interceptors really does send email, that
|
|
615
|
+
`_Model_Client_EmailTemplate::send($clientIdentifier, …)` is the right tool when you only want a
|
|
616
|
+
mail, and that staged `addNote()` notes are silently dropped outside api2. (bala)
|
|
617
|
+
|
|
508
618
|
- 2026-08-18 — TRUE-81049: documented that a **POST-processing interceptor cannot persist by
|
|
509
619
|
mutating `$payload`** — `V2.php:5934` passes `$outData`, the outbound response, which the engine
|
|
510
620
|
returns and never saves, so the caller sees the value while the column stays NULL. Added the
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-20
|
|
10
10
|
owners: [mhammontree, tcox, jcardinal, ajean, bala]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -48,6 +48,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
48
48
|
| **EO-1** | Operation failed — identifier "There is no field called 'X' in the '_Model_Client_Y' model" | The DB column **and** `Core.RecordFields` exist, but the **generated model class** `_underscore/Model/Client/<Name>.php` doesn't declare the field. This is the **4th** requirement beyond the 3-file migration — and most often it's a **cross-repo git branch mismatch** (`_underscore` on a branch whose generated model lacks a field the DB/RecordFields already carry) | Declare the field in the generated model class (`public $field = self::FIELD_*`) and put all related repos (`_underscore`, `api2`, `dbchanges2`, `toga2-supply`) on the **same** feature branch — see the ACL doc's writable-field recipe |
|
|
49
49
|
| **EO-1** | Operation failed — **surfaced from a PHP warning/notice, not a real op error** (e.g. "Attempt to read property 'id' on bool", undefined variable) | A latent PHP warning escalates to a 500 because **Sentry's `ErrorHandler` in api2 promotes warnings/notices into thrown exceptions** (see diagnosis note 5). The known instance: in `V2.php::processRoutePairs()` an **unresolved route** leaves the local `$record = false`, and the post-processing payload interceptor layer then dereferenced `$record->id` → warning → 500 | Guard before dereferencing an unresolved record. The fix added a guard clause `if (!$record) return [$rawRequestedRouteName => $outData];` **before** the interceptor/logging layer (so a bad route returns a clean envelope, not a fatal), and initializes `$record = false;` at the top of the `foreach ($lookupByRouteNames ...)` loop so the invalid-HTTP-method / null-`$action` branch can't leave `$record` undefined (an undefined-variable warning would itself escalate to a 500) |
|
|
50
50
|
| **EO-1** | Operation failed — MySQL **1054 "Unknown column 'X' in 'field list'"** | The **exact reverse** of the row above: the generated model (or a **trait** it `use`s) *declares* the field but the client table has **no such column**. `_Model` builds its SELECT from **every declared property**, so this fires on **any** load of that record on **any** route — including when the caller never asked for that field. Usual cause: a client DB whose module migration only **partially** applied | Add the missing column to that client DB — plus its `CustomRecordFields` row and `AclCustomFieldPermissions` grant if it is a `c_` field. Audit the rest of the drift first: [client schema-drift audit](../../dbchanges2/workflows/client-schema-drift-audit.md) |
|
|
51
|
+
| **EO-1** | Operation failed — the exception message is literally **`[]`** (an empty JSON array) | Not an operation error at all, but a **message-array truthiness throw**. `internalApiRequest`'s throw is guarded on `!empty($this->response->messages)`, and `V2.php:2416` builds that as `json_encode(array_filter($messages, ...ERROR...))` — with zero real errors it is the **string `"[]"`, which PHP treats as truthy**. So any message (even a warning) anywhere in the request throws — including a nested GET that legitimately returns nothing. Live case (2026-08-20): Elite's `postPost` → `parent::postPost()` → `/approval-templates` with **no `ApprovalTemplates` rows** → every `POST /v2/sales-orders` 500s and the order **rolls back** | Short term, pass `false` as `internalApiRequest`'s 5th argument (`$throwExceptionsOnError`) in the calling model. Real fix: `V2.php:2416` tests the filtered **array** instead of its JSON string — that fixes every caller. Background: [API payload interceptors](api-payload-interceptors.md) |
|
|
51
52
|
| **EO-2** | *"invalid configuration / A required database could not be reached while initializing the request"* — **almost always a lie** | The `catch (Throwable $dbBootstrapError)` in `api2/Controller/Index.php` (~L364) wraps the **entire request dispatch**, not just the DB bootstrap, so **any** Throwable that escapes query processing is relabelled EO-2. Real causes seen: MySQL 1054 on a missing column, a promoted PHP warning, a model fatal | **Ignore the message text.** Get the real exception from Sentry (`captureException`) or, on newer builds, from `identifiers.message` in the envelope; then resolve `error.id` per diagnosis note 9 |
|
|
52
53
|
| **EZ-1** | Unauthorized record/script dispatch | Missing **`AclRecordScripts`** (scripted APIs) or the **`AclRecordPermissions`** four-table chain (records) for the caller's role | Grant `AclRecordScripts` (scripts) or complete the record-CRUD chain |
|
|
53
54
|
| **EZ-2** | Field-level authorization denied (READ) | The field is registered (`Core.RecordFields` present → no `EV-8`) but the caller's role has no **`AclFieldPermissions`** grant to **read** it. The read-side counterpart of `EV-9` (write). **This applies to the `id` field too:** fetching a record by its numeric `id` (`GET /v2/<record>?fields=id,...`) 403s `EZ-2` if `id` has no read grant — the `id` field is ACL-gated like any other, not implicitly readable | Add the `AclFieldPermissions` row for the role (`isWritable=0` if the field is server-written). For a **custom** `c_` field use `AclCustomFieldPermissions` instead — see the ACL doc's standard-vs-custom table |
|
|
@@ -272,6 +273,12 @@ Full mechanics:
|
|
|
272
273
|
- The response-envelope shape and code families: see [api2 architecture](../architecture.md).
|
|
273
274
|
|
|
274
275
|
## Change history
|
|
276
|
+
- 2026-08-20 — Added a **fourth `EO-1` case: an exception message that is literally `[]`.** It is a
|
|
277
|
+
message-array truthiness throw, not an operation error — `internalApiRequest`'s throw is guarded on
|
|
278
|
+
the **shared** `response->messages`, and `V2.php:2416`'s `json_encode(array_filter(...))` is the
|
|
279
|
+
truthy string `"[]"` when there are **zero** real errors, so a nested GET that legitimately returns
|
|
280
|
+
nothing 500s the whole write and rolls it back (live case: Elite `/approval-templates` with no
|
|
281
|
+
`ApprovalTemplates` rows). (bala)
|
|
275
282
|
- 2026-08-18 - **Corrected the EV-8 remedy.** "Register the field in `Core.RecordFields`" is not the
|
|
276
283
|
only route: a **calculated (`_`-prefixed) field** declared on a shared `_Model_Client_*` model is
|
|
277
284
|
fully exposed by a **client-scoped `CustomRecordFields` row + `AclCustomFieldPermissions` grant,
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-20
|
|
10
10
|
owners: ["bala"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite/SalesOrder.php
|
|
@@ -54,8 +54,10 @@ spreads `parameters` as named arguments):
|
|
|
54
54
|
|
|
55
55
|
Supporting privates: `getClientContext`, `fetchSalesOrder`, `buildNetSuiteOrder`,
|
|
56
56
|
`resolveNetSuiteCustomerId`, `resolveNetSuiteItemId`, `runSuiteQl`, `escapeSuiteQl`,
|
|
57
|
-
`assertOrderMatchesUuid`, `doCreate`, `doUpdate
|
|
58
|
-
`
|
|
57
|
+
`assertOrderMatchesUuid`, `doCreate`, `doUpdate`, `buildExternalId`,
|
|
58
|
+
`findNetSuiteOrderByExternalId`, `writeBackNetSuiteId`. Constants:
|
|
59
|
+
`SUITEQL_ITEM_TYPE__GROUP = 'Group'`, `TOGA_FETCH_DEPTH = 4`,
|
|
60
|
+
`NETSUITE_EXTERNAL_ID_PREFIX = 'toga-so-'`.
|
|
59
61
|
|
|
60
62
|
## How it works
|
|
61
63
|
|
|
@@ -73,9 +75,12 @@ Supporting privates: `getClientContext`, `fetchSalesOrder`, `buildNetSuiteOrder`
|
|
|
73
75
|
quote, `''`, it never backslashes it).
|
|
74
76
|
5. **A missing customer / item / location mapping aborts the whole order** rather than sending a
|
|
75
77
|
partial one. A missing **shipping method** is simply omitted so NetSuite applies its default.
|
|
76
|
-
6. Create → `_Component_Api_Netsuite::createRecord()` (id from the 204/201 `Location` header)
|
|
77
|
-
|
|
78
|
-
`
|
|
78
|
+
6. Create → `_Component_Api_Netsuite::createRecord()` (id from the 204/201 `Location` header),
|
|
79
|
+
with an **`externalId` stamped on the record** so the create is idempotent — `doCreate` now has
|
|
80
|
+
four outcomes (`created` / `adopted` / `recovered` / `dryRun`), see
|
|
81
|
+
[Idempotency](#idempotency--the-netsuite-externalid) below; update → `send('PATCH', …)`.
|
|
82
|
+
The internalId is written back to `SalesOrders.c_netsuiteInternalSalesOrderId` by the shared
|
|
83
|
+
`writeBackNetSuiteId()` (three paths now save it).
|
|
79
84
|
7. `dryRun = true` builds and returns the payload without calling NetSuite.
|
|
80
85
|
|
|
81
86
|
### The job carries the order snapshot — the worker does not re-read it
|
|
@@ -97,6 +102,87 @@ process (on a **reader** connection) cannot see the new row at all. See
|
|
|
97
102
|
can never be atomic with the MySQL commit anyway, and holding a transaction open across a 12s
|
|
98
103
|
external call risks row locks and dropped connections. The push stays **async**.
|
|
99
104
|
|
|
105
|
+
## Idempotency — the NetSuite `externalId`
|
|
106
|
+
|
|
107
|
+
Every order this worker creates is stamped with a NetSuite **`externalId`**, so *"have I already
|
|
108
|
+
sent this order?"* is answerable **from NetSuite** rather than from Toga's own write-back. That
|
|
109
|
+
turns the orphan failure below from a manual-reconciliation job into a self-healing one.
|
|
110
|
+
|
|
111
|
+
**Format: `toga-so-<salesOrder uuid>`** — `NETSUITE_EXTERNAL_ID_PREFIX` + the order uuid, built by
|
|
112
|
+
`buildExternalId()`; 44 chars, well inside NetSuite's 255 limit.
|
|
113
|
+
|
|
114
|
+
- **The uuid, NOT `SalesOrders.number`.** NetSuite account **1095849 is shared by every client**,
|
|
115
|
+
while `number` is only unique within one client's database — Elite's `SA100005` and Compass's
|
|
116
|
+
`SA100005` can both exist. Keying on `number` would eventually make NetSuite refuse a legitimate
|
|
117
|
+
order because a **different client's** order already claimed that external id.
|
|
118
|
+
- **The `toga-so-` prefix** because `externalId` is shared with other integrations in that same
|
|
119
|
+
account; a bare uuid says nothing about where the record came from.
|
|
120
|
+
- **⚠ The format must not change once orders carry it.** Orders already in NetSuite would not be
|
|
121
|
+
found under a new format, so the adopt/recover paths below would silently miss them and create
|
|
122
|
+
duplicates. Treat `buildExternalId()` as a frozen contract.
|
|
123
|
+
|
|
124
|
+
### `doCreate` has four outcomes
|
|
125
|
+
|
|
126
|
+
| Result | When | What it does |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `created` | nothing in NetSuite carries this external id | creates the record with `externalId` stamped on it |
|
|
129
|
+
| `adopted` | `findNetSuiteOrderByExternalId()` found one | **no second create** — writes that id back |
|
|
130
|
+
| `recovered` | `createRecord()` threw but the record exists anyway | claims the id, keeps the original error in a `createError` field |
|
|
131
|
+
| `dryRun` | `$dryRun = true` | reports `externalId` and `alreadyInNetSuite`, creates nothing |
|
|
132
|
+
|
|
133
|
+
If the create genuinely failed **and** nothing exists in NetSuite, the original exception is
|
|
134
|
+
**rethrown untouched** — a real failure must never be swallowed by the recovery path.
|
|
135
|
+
|
|
136
|
+
**Two independent layers, deliberately:**
|
|
137
|
+
|
|
138
|
+
1. the SuiteQL pre-check — `SELECT id FROM transaction WHERE externalid = '<escaped>'`; **more than
|
|
139
|
+
one row throws** rather than guessing which record is the order; and
|
|
140
|
+
2. `externalId` on the record itself, so **NetSuite** refuses the duplicate even if the lookup misses.
|
|
141
|
+
|
|
142
|
+
### Write-back failure no longer asks for manual reconciliation
|
|
143
|
+
|
|
144
|
+
The old error on a failed write-back said *"manual reconciliation required"*. It now names **both**
|
|
145
|
+
the internal id and the external id and says a later run will find the record — because with the
|
|
146
|
+
external id that is now true. The regression test asserting the old wording was updated
|
|
147
|
+
**deliberately**: the contract improved, so the assertion moved with it (not a string-match fix).
|
|
148
|
+
|
|
149
|
+
### Verifying the lookup — you have to fake the orphan first
|
|
150
|
+
|
|
151
|
+
`CreateNetSuite` refuses at the top when `c_netsuiteInternalSalesOrderId` is already set, and that
|
|
152
|
+
guard runs **before the dry-run branch**, so the external-id lookup is unreachable on an
|
|
153
|
+
already-synced order. To exercise it (or the orphan-recovery path) against real data:
|
|
154
|
+
|
|
155
|
+
1. `UPDATE SalesOrders SET c_netsuiteInternalSalesOrderId = NULL` for an order whose NetSuite record
|
|
156
|
+
already carries the external id;
|
|
157
|
+
2. **dry-run** it — `alreadyInNetSuite` returning the id proves the SuiteQL filter works, `null`
|
|
158
|
+
means it does not;
|
|
159
|
+
3. a real (non-dry) run then returns `adopted` and restores the id, so the test cleans up after
|
|
160
|
+
itself.
|
|
161
|
+
|
|
162
|
+
### ⚠ Still unverified: can SuiteQL FILTER on `transaction.externalid` in this account?
|
|
163
|
+
|
|
164
|
+
A `created` result **cannot distinguish** *"the lookup ran and found nothing"* from *"the lookup is
|
|
165
|
+
broken and always finds nothing."* This project has already hit a NetSuite column that could be
|
|
166
|
+
`SELECT`ed but silently returned zero rows when filtered on (see the
|
|
167
|
+
[REST client doc](../../_underscore/features/netsuite-rest-client.md)), so the live check above is
|
|
168
|
+
still owed. **Fallback if the filter does not work:** `GET /record/v1/salesOrder/eid:<externalId>`,
|
|
169
|
+
which does not depend on SuiteQL at all.
|
|
170
|
+
|
|
171
|
+
### Proven against live NetSuite (2026-08-20)
|
|
172
|
+
|
|
173
|
+
Four real orders were created and written back by **direct worker invocation** (not the
|
|
174
|
+
interceptor): NetSuite **7358493, 7359455, 7359555, 7366038**, each taking **~11–13s** — which is
|
|
175
|
+
why this push is asynchronous. The duplicate guard was proven by a real second attempt on an
|
|
176
|
+
already-synced order being refused. All four ran with `hasSnapshot = 0` (direct calls), so they do
|
|
177
|
+
**not** exercise the interceptor-queued path — see the
|
|
178
|
+
[Elite client-feature](../../../clients/elite/features/salesorder-netsuite-push.md).
|
|
179
|
+
|
|
180
|
+
**⚠ These four orders reached PRODUCTION client data.** There is no NetSuite sandbox: every
|
|
181
|
+
environment points at live account 1095849, so the inbound NetSuite → Toga sync imported these
|
|
182
|
+
dev-sandbox test orders into **production** `Client_Elite` as real sales orders. The row-level
|
|
183
|
+
cleanup caveat is in the Elite doc; the general rule is in
|
|
184
|
+
[running worker2 locally](../workflows/running-worker2-locally.md).
|
|
185
|
+
|
|
100
186
|
## Earlier history worth keeping (2026-06-24, moved from the inbound doc)
|
|
101
187
|
|
|
102
188
|
- **REST-only since 2026-06-24** — the SOAP `NetSuiteService` was fully removed from this file. Item
|
|
@@ -115,14 +201,26 @@ process (on a **reader** connection) cannot see the new row at all. See
|
|
|
115
201
|
`CreateNetSuite`/`UpdateNetSuite` as thin wrappers — which also closed a TOCTOU, since the decision
|
|
116
202
|
and the write now use the same fetched record.
|
|
117
203
|
|
|
118
|
-
## Test harness (
|
|
204
|
+
## Test harness (85 tests, all passing)
|
|
119
205
|
|
|
120
206
|
`test/@Bala/tests/netsuite_salesorder_payload_tests.php` loads the **real** worker file with
|
|
121
207
|
stubbed dependencies and drives the private methods by **reflection**, so it tests shipped code
|
|
122
208
|
rather than a copy (same technique as
|
|
123
209
|
[DB-free interceptor unit testing](../../_underscore/features/model-interceptor-unit-testing.md)).
|
|
124
210
|
Coverage: header fields, mapping resolution, SuiteQL quote-doubling, missing-mapping aborts, dry
|
|
125
|
-
run, create, update, the job-carried-snapshot path,
|
|
211
|
+
run, create, update, and the job-carried-snapshot path, plus:
|
|
212
|
+
|
|
213
|
+
- **the uuid-match guard** — mismatch refused, refused on a dry run too, refused when the carried
|
|
214
|
+
order has no uuid, checked on update as well as create, reported **before** the already-synced
|
|
215
|
+
check, and a matching order still accepted;
|
|
216
|
+
- **the external id** — built and stamped on the create payload, adoption skips the create,
|
|
217
|
+
adoption still writes back, recovery after a failed create, a genuine failure still surfaces the
|
|
218
|
+
**real** error and writes nothing, two matching records refuses to guess, the query really does
|
|
219
|
+
filter on `externalid`, dry run reports `externalId` + `alreadyInNetSuite`, and updates never
|
|
220
|
+
touch external ids.
|
|
221
|
+
|
|
222
|
+
Stub additions this needed: `$throwOnCreate` and `$createPayloadsSeen` on the NetSuite stub, and a
|
|
223
|
+
`$nextRows` queue on the `_Query` stub so `getClientContext()` resolves.
|
|
126
224
|
|
|
127
225
|
## Gotchas / known issues
|
|
128
226
|
|
|
@@ -131,7 +229,11 @@ run, create, update, the job-carried-snapshot path, and the uuid-match guard.
|
|
|
131
229
|
`#`-delimiter regex bug did exactly this — see the
|
|
132
230
|
[REST client doc](../../_underscore/features/netsuite-rest-client.md)) leaves a live NetSuite
|
|
133
231
|
order with **nothing in Toga pointing at it**. Two Elite orders were orphaned in NetSuite account
|
|
134
|
-
1095849 this way. **
|
|
232
|
+
1095849 this way. **As of 2026-08-20 this is recoverable:** a re-run finds the record by
|
|
233
|
+
`externalId` and returns `adopted`/`recovered` instead of creating a second one (see
|
|
234
|
+
[Idempotency](#idempotency--the-netsuite-externalid)). That only holds for orders created **with**
|
|
235
|
+
an external id — the two pre-existing orphans carry none, so they still need manual linking, and
|
|
236
|
+
a blind retry on any order created before this change can still duplicate.
|
|
135
237
|
- **This action does not retry itself.** Worker actions have no DLQ and no auto-retry: a throw
|
|
136
238
|
records `isSuccess = 0` and stops. Unlike the 1.0 cron it replaces (which re-selected unsent
|
|
137
239
|
orders every tick and was therefore self-healing), an event-triggered push fires **once**. Pair
|
|
@@ -142,6 +244,13 @@ run, create, update, the job-carried-snapshot path, and the uuid-match guard.
|
|
|
142
244
|
*"Unknown named parameter"* from the dispatcher — which on a stale instance looks like a code bug
|
|
143
245
|
rather than a deploy problem (see the
|
|
144
246
|
[deploy workflow](../../api2/workflows/codepipeline-codeconnections-deploy.md)).
|
|
247
|
+
- **⚠ Commit before any file-wide rewrite — a clean `git status` is evidence about HEAD, not
|
|
248
|
+
evidence your work exists.** The entire `externalId` implementation in this file was **lost once**
|
|
249
|
+
and had to be re-applied: it had only ever existed in the working tree, and a file-wide comment
|
|
250
|
+
rewrite overwrote it. `git status` reported the file **clean** while the code was missing, and
|
|
251
|
+
`grep` found the new constant in **no** branch (`TRUE-80497`, `_beta`, `_sandbox-dev`). What
|
|
252
|
+
surfaced it was the **test suite** — 8 failures accurately describing what the code no longer did.
|
|
253
|
+
Commit before a file-wide edit or a branch change, and never read "clean" as "saved".
|
|
145
254
|
- **The inbound importer in this file is untouched and unrelated.** Do not "tidy" shared-looking
|
|
146
255
|
helpers across the banner; there are none.
|
|
147
256
|
- **⚠ `getClientContext()` is copy-pasted across three workers and has already DRIFTED.** The same
|
|
@@ -156,6 +265,23 @@ run, create, update, the job-carried-snapshot path, and the uuid-match guard.
|
|
|
156
265
|
|
|
157
266
|
## Change history
|
|
158
267
|
|
|
268
|
+
- 2026-08-20 — **Made the create idempotent with a NetSuite `externalId`**
|
|
269
|
+
(`NETSUITE_EXTERNAL_ID_PREFIX = 'toga-so-'`, `buildExternalId()`,
|
|
270
|
+
`findNetSuiteOrderByExternalId()`, and `writeBackNetSuiteId()` extracted because three paths now
|
|
271
|
+
save the id): `doCreate` returns `created` / `adopted` / `recovered` / `dryRun`, a genuine failure
|
|
272
|
+
still rethrows untouched, and the guard is two-layer (SuiteQL pre-check **and** `externalId` on the
|
|
273
|
+
record so NetSuite itself refuses a duplicate). **Decided** the format is
|
|
274
|
+
`toga-so-<salesOrder uuid>` and is frozen — the uuid, not `SalesOrders.number`, because NetSuite
|
|
275
|
+
account 1095849 is shared by every client and `number` is only unique per client DB. Reworded the
|
|
276
|
+
write-back failure away from "manual reconciliation required" (it is now self-healing). Recorded
|
|
277
|
+
that the already-synced guard runs **before** the dry-run branch, so verifying the lookup needs
|
|
278
|
+
`c_netsuiteInternalSalesOrderId` NULLed first; that **whether SuiteQL can filter on
|
|
279
|
+
`transaction.externalid` is still unverified** (fallback `GET /record/v1/salesOrder/eid:<id>`); the
|
|
280
|
+
first live end-to-end proof (7358493 / 7359455 / 7359555 / 7366038, ~11–13s each, direct
|
|
281
|
+
invocation — and they round-tripped into **production** Elite data); the harness at **85** tests;
|
|
282
|
+
and the lesson that a clean `git status` does not mean uncommitted work survived a file-wide
|
|
283
|
+
rewrite. (bala)
|
|
284
|
+
|
|
159
285
|
- 2026-08-12 — Recorded that `getClientContext()` is duplicated across three workers
|
|
160
286
|
(`Netsuite/SalesOrder`, `Sync/ServiceRequest`, `Sync/SalesOrderStatus`) and has **drifted**: only
|
|
161
287
|
the Sync/ServiceRequest copy orders the `Apis` credential lookup (`ORDER BY id ASC`), so the other
|
|
@@ -6,8 +6,8 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
10
|
-
owners: ["snaredla"]
|
|
9
|
+
updated: 2026-08-20
|
|
10
|
+
owners: ["snaredla", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Sync/ServiceRequest.php
|
|
13
13
|
- _underscore/Model/Elite/ServiceRequest.php
|
|
@@ -156,6 +156,15 @@ have it. See the `c_`-column convention in `2.0/standards/framework-rules.md`.
|
|
|
156
156
|
- **The NetSuite actions in this chain are `Netsuite/SalesOrder/CreateNetSuite` and
|
|
157
157
|
`Netsuite/SalesOrder/UpdateNetSuite`.** There is **no** `Netsuite/SalesOrder/Push` — do not write
|
|
158
158
|
that string into a migration, a test or a doc.
|
|
159
|
+
- **⚠ It does not set `locationId` — which blocks the NetSuite push downstream (open, 2026-08-20).**
|
|
160
|
+
Orders this worker creates land with `locationId` **NULL** (prod Elite `SalesOrders` **279** /
|
|
161
|
+
`SA100000`, from `serviceRequestId 3`), and `Netsuite/SalesOrder/CreateNetSuite` then fails loudly:
|
|
162
|
+
*"no NetSuite location mapped on line 1 or on the order — map
|
|
163
|
+
`Locations.c_netsuiteInternalLocationId`"*. **The mapping data is complete** (prod Elite
|
|
164
|
+
`Locations` 1 and 2 → 196, 3 → 5), so this is a missing **field**, not a missing mapping. The fix
|
|
165
|
+
belongs **here, not in the push**: an order should record where it ships from regardless of
|
|
166
|
+
NetSuite, and `buildNetSuiteOrder()` deliberately refuses to guess a warehouse when the data does
|
|
167
|
+
not say. See [the NetSuite push](./netsuite-salesorder-outbound-push.md).
|
|
159
168
|
|
|
160
169
|
## Verifying the whole chain — `test_elite_chain_toga2.php`
|
|
161
170
|
|
|
@@ -188,6 +197,11 @@ Recorded so nobody re-debugs them as bugs. All four were confirmed against Elite
|
|
|
188
197
|
|
|
189
198
|
## Change history
|
|
190
199
|
|
|
200
|
+
- 2026-08-20 — Recorded an **open defect: the worker leaves `locationId` NULL**, so the downstream
|
|
201
|
+
`Netsuite/SalesOrder/CreateNetSuite` push fails loudly on every order it generates (prod Elite
|
|
202
|
+
`SalesOrders` 279 / `SA100000`). The prod Elite `Locations` → NetSuite mappings are complete, so
|
|
203
|
+
this is a missing field rather than missing config, and the push will **not** default it — guessing
|
|
204
|
+
a warehouse is what its fail-loud check exists to prevent. (bala)
|
|
191
205
|
- 2026-08-12 (later pass) — Recorded three deployment/verification facts: the action rename
|
|
192
206
|
`GenerateSalesOrder` → `GenerateSalesOrderFromServiceRequest` is a **two-repo, single-deploy**
|
|
193
207
|
change (the `runTask` string lives in `_underscore/Model/Elite/ServiceRequest.php:44`, and a
|
|
@@ -6,8 +6,8 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
10
|
-
owners: ["kyalamarthi"]
|
|
9
|
+
updated: 2026-08-20
|
|
10
|
+
owners: ["kyalamarthi", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/index.php
|
|
13
13
|
- worker2/composer.json
|
|
@@ -85,6 +85,15 @@ through `_Worker::runTask` locally: the dev config's `[cloud] aws_worker_queue_u
|
|
|
85
85
|
appears not to have been applied to dev-sandbox since roughly February 2026. A dry run there dies
|
|
86
86
|
on the missing column, which reads like a code bug. Use a local fixture instead, and see
|
|
87
87
|
[repairing non-prod drift](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
|
|
88
|
+
- **⚠ There is NO NetSuite sandbox — every environment writes to the LIVE account (1095849).**
|
|
89
|
+
A "beta" or dev-sandbox test that creates a NetSuite record creates it in **production NetSuite**,
|
|
90
|
+
and the inbound NetSuite → Toga sync then imports it straight back into **production Toga** as real
|
|
91
|
+
client data. Four dev-sandbox Elite test orders (NetSuite **7358493 / 7359455 / 7359555 /
|
|
92
|
+
7366038**) arrived in prod `Client_Elite.SalesOrders` **261–266** exactly this way. Before running
|
|
93
|
+
any NetSuite **write** test, decide up front how you will identify and clean up the records in
|
|
94
|
+
**both** systems — a stable `externalId` makes them findable, see the
|
|
95
|
+
[sales-order push](../features/netsuite-salesorder-outbound-push.md) — and never bulk-delete a row
|
|
96
|
+
range afterwards: unrelated records land in the same range (7369129 and 7374431 did).
|
|
88
97
|
- **⚠ SECURITY — live credentials are committed in this repo; they are locations to fix, not
|
|
89
98
|
settings to copy.** Do not paste any of these values anywhere, including a knowledge doc:
|
|
90
99
|
- `worker2/.ebextensions/git.json` carries a **live GitHub personal access token**, used so
|
|
@@ -101,6 +110,11 @@ through `_Worker::runTask` locally: the dev config's `[cloud] aws_worker_queue_u
|
|
|
101
110
|
|
|
102
111
|
## Change history
|
|
103
112
|
|
|
113
|
+
- 2026-08-20 — Recorded that **there is no NetSuite sandbox**: every environment points at live
|
|
114
|
+
account 1095849, so a dev-sandbox/beta write test creates **production** NetSuite records **and**
|
|
115
|
+
the inbound sync round-trips them into **production Toga** client data (four Elite test orders
|
|
116
|
+
landed in prod `Client_Elite.SalesOrders` 261–266). Plan the two-system cleanup before testing a
|
|
117
|
+
NetSuite write, and never bulk-delete the resulting row range. (bala)
|
|
104
118
|
- 2026-08-19 — Created while testing the customer account-type sync (TRUE-80206) with no usable
|
|
105
119
|
shared environment: recorded the five-step local boot (composer install with the `ext-zip` opt-out,
|
|
106
120
|
`set_include_path()` for `_underscore` vs. EB's `php_include_underscore.config`, `ENVIRONMENT` →
|