toga-ai 1.0.671 → 1.0.673

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.
@@ -92,6 +92,20 @@ Forecast2 tables already store.
92
92
  - **TO_DATE rejects impossible dates** (e.g. `2026-06-31`) with an opaque HTTP 400 — validate
93
93
  calendar dates before building SuiteQL.
94
94
 
95
+ - **The line SuiteQL must SELECT the line location or it is always NULL — the shim reads it but
96
+ didn't select it.** `listSalesOrders()`'s line query now selects `tl.location` and
97
+ `BUILTIN.DF(tl.location) AS location_name`. The line-shim assembly code already *read* those two
98
+ fields, but the SELECT never included them, so every line's location came back null — which broke
99
+ NYCHH transfer-order origin resolution (`TransferOrders.originLocationId` is NOT NULL → V2 `EV-10`).
100
+ Same class of bug as ShipItem cost above: a field the shim consumes must actually be in the SELECT.
101
+ (Added 2026-08-27.)
102
+
103
+ - **Custom body fields ride in `customFieldList` on the per-id REST GET.** `fetchSalesOrderById()`
104
+ now carries `custbody_stocking_order` (NetSuite internal id 7097) via `customFieldList` so the
105
+ importer can route NYCHH orders (checked = stocking/in → SalesOrders, unchecked = transfer/out →
106
+ TransferOrders). A custom field absent from the record reads as null downstream. See
107
+ [NYCHH NetSuite → TransferOrders import](../../../clients/nychh/features/netsuite-transfer-order-import.md).
108
+
95
109
  ## Transaction status sourcing (1.0) — and the 2026.2 REST `status.id` change
96
110
 
97
111
  1.0 reaches NetSuite over **two transports, and neither reads status off a REST record GET
@@ -136,9 +150,21 @@ None — uniform across clients (NetSuite is a single shared account).
136
150
  `??=`, or `match`. Lint with `C:\xampp7\php\php.exe -l` before deploying.
137
151
  - Field availability varies by NetSuite account **and** record type — probe the live account
138
152
  before assuming a column/relationship exists.
153
+ - **`App_NetSuite::getCustomFieldValue()` (`library/app/netsuite.php`) must be warning-safe.** 1.0
154
+ runs on PHP 7.2 in an unguarded path, so any PHP warning → `exit()` (kills the cron). It was
155
+ hardened 2026-08-27 with `?? []` / `?? null` guards on the custom-field-list traversal. Keep that
156
+ discipline when touching it.
139
157
 
140
158
  ## Change history
141
159
 
160
+ - 2026-08-27 — Recorded three field-semantics facts from NYCHH transfer-order support:
161
+ `listSalesOrders()` line SuiteQL now **selects `tl.location` + `BUILTIN.DF(tl.location)`** (the line
162
+ shim read them but never selected them → always null, breaking TO origin resolution / V2 `EV-10`);
163
+ `fetchSalesOrderById()` now carries **`custbody_stocking_order` (id 7097)** via `customFieldList` for
164
+ order routing; and `App_NetSuite::getCustomFieldValue()` was hardened with `?? []`/`?? null` for
165
+ PHP 7.2 warning-safety. See
166
+ [NYCHH NetSuite → TransferOrders import](../../../clients/nychh/features/netsuite-transfer-order-import.md).
167
+ (jcardinal)
142
168
  - 2026-08-25 — Documented **1.0 transaction-status sourcing across both transports** and the
143
169
  conclusion that the **NetSuite 2026.2 REST `status.id` text→letter standardization does not
144
170
  affect 1.0** (SuiteAnswers 89313). `App_Api_Netsuite_Rest` derives status from the SuiteQL
@@ -396,6 +396,17 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
396
396
 
397
397
  ## Change history
398
398
 
399
+ - 2026-08-27 — `send()`'s throw sites were **reverted to plain `throw new Exception(...)`** (both
400
+ sites) as part of removing the sync engine's swallow-and-skip data-loss bug — the importer must
401
+ propagate a failed write, not catch it (see
402
+ [NetSuite → TOGa Supply per-client sync](../../worker/features/netsuite-togasupply-per-client-sync.md),
403
+ 2026-08-27). New transfer-order importer methods landed in this class for NYCHH:
404
+ `isTransferOrder()` rewritten + private `isStockingOrder()` (route by NetSuite
405
+ `custbody_stocking_order`), private `linkPurchaseOrderToTransferOrder()`, TO origin resolution in
406
+ `syncSalesOrderFromNetsuite`, `transferOrderItem.uuid` linking in `syncItemFulfillmentFromNetsuite`,
407
+ and an expanded `syncTransferOrderFromNetsuite` status map — all documented on
408
+ [NYCHH NetSuite → TransferOrders import](../../../clients/nychh/features/netsuite-transfer-order-import.md).
409
+ (jcardinal)
399
410
  - 2026-08-17 — Recorded that **depth-4 `GET /sales-orders` omits the `itemFulfillmentItems` and
400
411
  `invoiceItems` reverse-hasMany collections** (it does return `salesOrderItems` and the PO-link
401
412
  bridges), verified against prod `Logs_Compass.Api` — so code guarding on those collections from
@@ -120,6 +120,39 @@ client's `Parameters` table back before the lost window. Re-import is **idempote
120
120
  on `c_netsuiteInternal*Id` and `SalesOrders.c_netsuiteInternalSalesOrderId` carries a unique index —
121
121
  so a rewind re-PUTs what is already there and POSTs only what is missing.
122
122
 
123
+ ### ⚠ 2026-08-27: all per-order `try/catch` REMOVED — every section now fails LOUD (was a data-loss bug)
124
+
125
+ The "section-specific isolation" described just above was a **transient, harmful state** and no
126
+ longer exists. On 2026-08-20 a prior dev (Rohan) had wrapped **each order dispatch** in all six
127
+ sections in `try { … } catch (Throwable) { error_log }`, and each section in `try { … } finally {
128
+ finishModeIteration }`. The effect was a **silent data-loss bug**: any non-2XX write was swallowed,
129
+ the record was dropped, **the watermark/checkpoint still advanced past it** (permanent loss), and the
130
+ execution mode was reset to **IDLE** on failure — so the next run had no idea the prior run had
131
+ failed.
132
+
133
+ **Decision + fix (this session), the standing behavior now:**
134
+
135
+ - **All 6 per-order `catch (Throwable)` skips removed** (SALES_ORDERS, PURCHASE_ORDERS, INVOICES,
136
+ ITEM_RECEIPTS, ITEM_FULFILLMENTS, INVENTORY_ADJUSTMENTS). A failed dispatch now **propagates**.
137
+ - **All 6 section-level `try/finally` removed.** `finishModeIteration()` now runs on the **success
138
+ path only**. On failure the cron **aborts**, the watermark PUT is **skipped**, and the execution
139
+ mode is **left as RUNNING** so the next run knows the prior run failed (and the ÷3 window shrink
140
+ fires, as intended).
141
+ - `startModeIteration`'s internal try/catch is **kept** (it re-throws, does not skip).
142
+ - The `send()` throw sites in `library/app/api/toga2.php` were reverted to plain
143
+ `throw new Exception(...)`.
144
+
145
+ **REJECTED alternative (do not re-attempt):** converting the throws into a custom
146
+ `App_Exception_ApiResponse` and catching *that*. The real fix is to **stop catching `Throwable`**, not
147
+ to introduce a catchable wrapper — the `app/exception/apiresponse.php` class created for that approach
148
+ was deleted.
149
+
150
+ **Net:** a failed import halts loudly and preserves `RUNNING` state instead of advancing past dropped
151
+ records. The isolation discussion above now applies uniformly — **no** section steps over a bad
152
+ record; every section behaves the way SALES_ORDERS always did (throw → freeze at RUNNING → same
153
+ poison record re-tried each run → go read the error). This is an application of the universal
154
+ coding-style rule *never catch-and-silently-ignore*, not a new engine-specific policy.
155
+
123
156
  ### Sections are NOT order-dependent — one cursor can be rewound alone
124
157
 
125
158
  Each section's `startModeIteration()` reads only **its own** `Parameters` key, and the PO section
@@ -273,6 +306,14 @@ Parameters are stored **per client DB** but accessed **through the TOGa2 API**,
273
306
 
274
307
  ## Client variations
275
308
 
309
+ - **Transfer-order routing is opt-in per wrapper (default OFF).** A client whose NetSuite
310
+ *transfer orders* arrive as NetSuite *sales orders* can have the importer split them into
311
+ `TransferOrders` vs `SalesOrders` by setting
312
+ `const IS_ENABLED_TRANSFER_ORDER_STOCKING_FLAG_ROUTING = true;` before the `require_once`
313
+ (NYCHH's `sync_togasupply_hh.php` is the only one that does). The signal is the NetSuite custom
314
+ checkbox `custbody_stocking_order`, read by `App_Api_Toga2::isTransferOrder()`/`isStockingOrder()`
315
+ — **not** a dollar/hold heuristic. Full mechanics, PO bridges, and origin-location resolution:
316
+ [NYCHH NetSuite → TransferOrders import](../../../clients/nychh/features/netsuite-transfer-order-import.md).
276
317
  - `isParentCustomer` — `true` walks NetSuite child customers (`listChildCustomers`); `false`
277
318
  syncs the single customer. Prudential-EDI and the live Quad wrapper use `false`.
278
319
  - The original combined-array entries used `App_Api_Toga2::CLIENT_UUID_*` consts for Quad and
@@ -465,6 +506,18 @@ Parameters are stored **per client DB** but accessed **through the TOGa2 API**,
465
506
 
466
507
  ## Change history
467
508
 
509
+ - 2026-08-27 — **Removed a silent data-loss bug: all per-order `catch (Throwable)` skips and all
510
+ section-level `try/finally` were deleted, so every section now fails LOUD.** A 2026-08-20 change had
511
+ wrapped each order dispatch in `catch (Throwable) { error_log }` and each section in
512
+ `try/finally { finishModeIteration }` — swallowing any non-2XX write, dropping the record, advancing
513
+ the watermark past it, and resetting mode to IDLE. Now dispatch propagates, `finishModeIteration()`
514
+ runs on the success path only, and a failure aborts with the watermark skipped and mode left
515
+ RUNNING; `send()`'s throw sites in `library/app/api/toga2.php` reverted to plain `throw`. The
516
+ rejected `App_Exception_ApiResponse` wrapper was deleted. The "isolation is section-specific" gotcha
517
+ now applies uniformly — no section steps over a bad record. Also recorded the new opt-in
518
+ transfer-order routing switch (`IS_ENABLED_TRANSFER_ORDER_STOCKING_FLAG_ROUTING`, default off,
519
+ NYCHH only) — see [NYCHH NetSuite → TransferOrders import](../../../clients/nychh/features/netsuite-transfer-order-import.md).
520
+ (jcardinal)
468
521
  - 2026-08-26 — Corrected the IF full-reconcile tracking claim and **fixed two bugs** in
469
522
  `syncItemFulfillmentFromNetsuite`'s single-tracking-number branch: **318 (item) was never written**
470
523
  (`Client_Quad` had 0 rows → blank Tracking # column) and **319 (unit) received only the last
@@ -222,6 +222,32 @@ then checks the caller's roles against `AclRecordPermissions` (→ `EZ-1` if no
222
222
  readable/writable fields from `AclFieldPermissions`. `_Model_Core_Page::meta()` reads the same
223
223
  tables to return per-page ACL to the frontend.
224
224
 
225
+ > **⚠ There is NO ACL/metadata caching layer — the load is per-request and there is nothing to bust.**
226
+ > `buildLookups()` re-reads the ACL/metadata tables on **every** request, so an inserted/updated
227
+ > `AclCustomFieldPermissions` / `CustomRecordFields` / `AclFieldPermissions` row takes effect on the
228
+ > **very next request**. There is no cache-invalidation step, and looking for one is a dead end
229
+ > (verified 2026-08-27 wiring NYCHH transfer-order custom fields — a whole debugging pass was wasted
230
+ > on the assumption that a cache had to be cleared). If a freshly-applied grant/metadata row still
231
+ > doesn't take, suspect the row itself (wrong DB/role/id, or a broken cross-schema subselect that
232
+ > inserted zero rows), not staleness.
233
+
234
+ ### ⚠ A nested FK resolved BY a custom field needs `CustomRecordFields.isIdentifier = 1`
235
+
236
+ When the API resolves a nested foreign-key reference **through a custom (`c_`) field** — i.e. the
237
+ custom field is the identifier the child is matched on — that field's `CustomRecordFields.isIdentifier`
238
+ must be `1`, or the write fails **`EV-12`** with `searchableIdentifierFields:[id]` (the resolver falls
239
+ back to `id` because no custom field is marked searchable). This is a **fourth** thing to check beyond
240
+ column + `CustomRecordFields` row + `AclCustomFieldPermissions` grant + model declaration. Verified
241
+ 2026-08-27 on NYCHH `c_netsuiteInternalTransferOrderStatus` (recordId 351): the field existed and was
242
+ granted, but the nested transfer-order-stage lookup 400'd until `isIdentifier` was set to 1.
243
+
244
+ **The `c_` declaration on a client-override model comes from its NetSuite trait.** The pattern for a
245
+ NetSuite-synced record is `_Model_<Client>_X extends _Model_Client_X { use _Trait_Netsuite_X; }` — the
246
+ trait is what declares the `c_` fields (so a client with only `extends`, no `use`, is missing them and
247
+ its POST 500s `EO-1`). NYCHH's `_Model_Nychh_TransferOrder` / `_Model_Nychh_TransferOrderStage` had to
248
+ be **created** for this reason before their transfer orders could carry NetSuite ids — see
249
+ [NYCHH transfer-order inventory & quantities](../../../clients/nychh/features/transfer-order-inventory-quantities.md).
250
+
225
251
  ## Navigation / action flags (`Core.AclActions` + `AclActionPermissions`) — a THIRD gate
226
252
 
227
253
  A UI control that is not a record CRUD operation and not a scripted API — a nav item, an "Add" or
@@ -410,6 +436,15 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
410
436
  and every repo is on the **same branch** so the generated model matches the DB.
411
437
 
412
438
  ## Change history
439
+ - 2026-08-27 — Recorded two facts from wiring NYCHH transfer-order custom fields: **there is NO
440
+ ACL/metadata cache** (`buildLookups()` re-reads per request, so a new grant/metadata row applies on
441
+ the next request — no cache-bust exists; a whole debugging pass was wasted assuming one did), and a
442
+ **nested FK resolved by a custom field needs `CustomRecordFields.isIdentifier = 1`** or the write
443
+ 400s `EV-12 searchableIdentifierFields:[id]` (NYCHH `c_netsuiteInternalTransferOrderStatus`, recordId
444
+ 351). Also noted that a client-override model's `c_` declarations come from its `_Trait_Netsuite_X`
445
+ (create `_Model_<Client>_X { use _Trait_Netsuite_X; }` or the POST 500s `EO-1`). See
446
+ [NYCHH transfer-order inventory & quantities](../../../clients/nychh/features/transfer-order-inventory-quantities.md).
447
+ (jcardinal)
413
448
  - 2026-08-26 - Added the **verify-per-sibling-field, verify-as-the-least-privileged-role** rule:
414
449
  columns of the same table often carry different role sets, so adding one more field to a `fields=`
415
450
  list is a permission change. `Client_Quad.Currencies` grants `code` (1424) and `symbol` (1425) to
@@ -55,6 +55,8 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
55
55
  | **EV-5** | Duplicate `transactionId` | The globally-unique `transactionId` was reused | Send a fresh unique `transactionId` per request |
56
56
  | **EV-13** | Validation — page size too large | `recordsPerPage` exceeds the **10000** max | Lower `recordsPerPage` and paginate with `page` |
57
57
  | **EV-12** | Record's parent not synced | (Fulfill & Ship) POST `/item-fulfillments` when the Sales Order isn't synced into Toga yet | `GET /sales-orders/syncNetsuiteSalesOrder?netsuiteInternalSalesOrderId=<id>` first |
58
+ | **EV-12** | `invalidSearchableField` / `searchableIdentifierFields:[id]` on a nested write | A nested FK is being resolved **through a custom (`c_`) field**, but that field is not marked searchable, so the resolver has only `id` | Set **`CustomRecordFields.isIdentifier = 1`** on that `c_` field (NYCHH `c_netsuiteInternalTransferOrderStatus`, recordId 351). Also confirm the field is declared on the client model via its `_Trait_Netsuite_*` — see the [ACL chain](../../_underscore/features/acl-permission-chain.md) |
59
+ | **EV-10** | NOT NULL column received NULL on insert (e.g. `originLocationId cannot be null`) | Either the payload never resolved a value for a NOT NULL column, **or** a NOT NULL column that is **not a writable V2 field** was expected to arrive in the payload | Resolve the value upstream (NYCHH `TransferOrders.originLocationId`: select `tl.location` in the SuiteQL + fall back to the first line's location), **or** default the column in the ORM if it is server-owned — see EV-8 below and diagnosis note 15 |
58
60
 
59
61
  ## How to diagnose
60
62
 
@@ -197,6 +199,15 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
197
199
  not on matching the response text — see
198
200
  [V2 deadlock-retry](./v2-deadlock-retry.md).
199
201
 
202
+ 15. **A NOT NULL column that is NOT a writable V2 field must be DEFAULTED in the ORM — never sent in
203
+ the payload.** Adding the key to the POST body throws **`EV-8`** ("field does not exist") because
204
+ the column has no writable-field registration; but leaving it out throws the DB NOT NULL error on
205
+ insert. The fix is neither — declare a model default so the ORM writes it without a payload key,
206
+ e.g. `public $isActive = [self::FIELD_BOOLEAN, self::FIELDOPT_DEFAULT => true];` on
207
+ `_Model_Client_TransferOrder` (2026-08-27, NYCHH transfer-order POST). Contrast with a NOT NULL
208
+ column that *should* come from the caller/upstream (that is `EV-10` — resolve the value, don't
209
+ default it).
210
+
200
211
  ## BREAKING (2026-07-30): `error` is now an object, not a bare string
201
212
 
202
213
  The V2 envelope's `error` changed from a bare string (`"EO-1"`) to an **object**:
@@ -281,6 +292,12 @@ Full mechanics:
281
292
  - The response-envelope shape and code families: see [api2 architecture](../architecture.md).
282
293
 
283
294
  ## Change history
295
+ - 2026-08-27 — Added three codes/notes from the NYCHH transfer-order POST work: **EV-10** (a NOT NULL
296
+ column received NULL — `originLocationId cannot be null`; resolve upstream or default in the ORM), a
297
+ second **EV-12** flavor (`searchableIdentifierFields:[id]` — a nested FK resolved *through* a `c_`
298
+ field needs `CustomRecordFields.isIdentifier = 1`), and **diagnosis note 15**: a NOT NULL column
299
+ that is **not a writable V2 field** must be defaulted in the ORM (`FIELDOPT_DEFAULT`), never sent in
300
+ the payload — sending it is `EV-8`, omitting it is a DB NOT NULL error. (jcardinal)
284
301
  - 2026-08-25 — Added the diagnostic that a **malformed bearer token returns EO-1 500 instead of
285
302
  EN-3 401**: the header/payload `json_decode` yields `null` and `property_exists()` throws a
286
303
  `TypeError` (`V2.php` ~1840); the sibling check at ~826 already guarded with `is_object()`.
@@ -6,7 +6,7 @@ project: SAML SSO Gateway
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-06-11
9
+ updated: 2026-08-27
10
10
  owners: ["rgirish", "jcardinal"]
11
11
  files:
12
12
  - saml/index.php
@@ -71,6 +71,14 @@ To onboard a new SSO client, add the class in `_underscore` — not in this repo
71
71
  - **`_underscore` pulled at deploy time** via `.ebextensions/git.php` + prebuild hook — clones branch `_production`. Deploy `saml` after `_underscore` merges to pick up framework changes.
72
72
  - **CodePipeline:** Source (GitHub `_production`) → ManualApproval → Deploy (EB).
73
73
  - **Composer deps** installed at postdeploy via `015_install_composer.sh`.
74
+ - **Non-prod shared-ALB self-registration (2026-08-27).** Postdeploy hook
75
+ `060_register_instance_to_shared_application_load_balancer.sh` + `ebs/register_instance_to_shared_application_load_balancer.php`
76
+ register a non-production `saml` instance into the target group named exactly its EB env name;
77
+ **skips `production`** (so `saml-production` is unaffected) and always `exit 0`. Requires
78
+ `aws/aws-sdk-php` (now in `composer.json`, lock/vendor to be regenerated) and two IAM actions on
79
+ the instance profile (`elasticloadbalancing:DescribeTargetGroups` region-scoped +
80
+ `RegisterTargets` scoped to the non-prod target-group ARN). See
81
+ [ALB target-group auto-registration](../worker2/features/alb-target-group-auto-registration.md).
74
82
 
75
83
  ## Dependencies
76
84
 
@@ -88,3 +96,4 @@ To onboard a new SSO client, add the class in `_underscore` — not in this repo
88
96
  ## Change history
89
97
  - 2026-06-11 — Added `transactionCommit()` before redirect; wrapped `getAuthenticatedSsoUser()` in try/catch with Sentry; echo+exit on auth failure (rgirish)
90
98
  - 2026-06-25 — Sharpened signature-verification gotcha with explicit threat model and flagged it as the repo's top security priority; documented plaintext secrets in `production.ini` + hardcoded Sentry DSN in `Controller/Index.php` with SSM Parameter Store recommendation (jcardinal)
99
+ - 2026-08-27 — Documented non-prod shared-ALB self-registration postdeploy hook (`060_...sh` + `ebs/register_instance_to_shared_application_load_balancer.php`); skips `production`, requires `aws/aws-sdk-php` + two IAM actions; cross-linked worker2 ALB auto-registration feature (jcardinal)
@@ -3,7 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [Worker (worker2) Architecture](architecture.md) | Worker (repo `worker2`) is an AWS Elastic Beanstalk **Worker Tier** application that processes background jobs. | worker2/Controller/Index.php, worker2/Worker/, worker2/LambdaFunctions/, _underscore/Worker.php, worker2/composer.json |
6
- | [Deploy-Time Auto-Registration to the Shared ALB Target Group (non-production)](features/alb-target-group-auto-registration.md) | TOGA does **not** pay for EB-managed load-balancer registration, so an EB instance is normally **not** added to its environment's ALB target group — a fresh or | worker2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, worker2/ebs/register_instance_to_shared_application_load_balancer.php, worker2/.platform/hooks/prebuild/_shared/040-write-instance-id.sh, worker2/.platform/hooks/prebuild/_shared/041-write-region.sh, worker2/.platform/hooks/prebuild/_shared/042-write-eb-environment.sh, api2/.platform/hooks/prebuild/_shared/042-write-eb-environment.sh, worker2/.platform/hooks/postdeploy/015_install_composer.sh, api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
6
+ | [Deploy-Time Auto-Registration to the Shared ALB Target Group (non-production)](features/alb-target-group-auto-registration.md) | TOGA does **not** pay for EB-managed load-balancer registration, so an EB instance is normally **not** added to its environment's ALB target group — a fresh or | worker2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, worker2/ebs/register_instance_to_shared_application_load_balancer.php, worker2/.platform/hooks/_shared/040-write-instance-id.sh, worker2/.platform/hooks/_shared/041-write-region.sh, worker2/.platform/hooks/_shared/042-write-eb-environment.sh, worker2/.platform/hooks/postdeploy/015_install_composer.sh, saml/.platform/hooks/_shared/040-write-instance-id.sh, saml/.platform/hooks/_shared/041-write-region.sh, saml/.platform/hooks/_shared/042-write-eb-environment.sh, saml/.platform/hooks/prestart/040-write-instance-id.sh, saml/.platform/hooks/prestart/041-write-region.sh, saml/.platform/hooks/postdeploy/040-write-instance-id.sh, saml/.platform/hooks/postdeploy/041-write-region.sh, saml/.platform/hooks/postdeploy/042-write-eb-environment.sh, saml/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, saml/ebs/register_instance_to_shared_application_load_balancer.php, saml/composer.json, api2/.platform/hooks/prebuild/_shared/042-write-eb-environment.sh, api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
7
7
  | [All-Client Email Queue Monitor (Monitor/Operations/EmailQueue)](features/all-client-email-queue-monitor.md) | `_Worker_Monitor_Operations::EmailQueue()` is the cross-client health check for the 2.0 outbound email queue (`Logs_<Client>.Email` — see [2.0 Email Send Pipeli | worker2/Worker/Monitor/Operations.php, _underscore/Database.php, _underscore/Query.php, dbchanges2/Logs_Client/2026-05-21 - Email.sql |
8
8
  | [Automated PR Merger — Concurrent Force-Push Clobber Race](features/automated-pr-merger-force-push-race.md) | The automated PR merger `_Worker_Team_GitHub::Merge` (`worker2` `Worker/Team/Github.php`) merges approved PRs to `_production` by **force-pushing from a clone t | Worker/Team/Github.php |
9
9
  | [Callback Scheduling ("call me back") — worker2 AI-BDR](features/callback-scheduling.md) | The **AI-BDR callback path**: what happens between a prospect saying *"call me back later"* on a Vapi call and the dialer actually placing that second call. | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php, worker2/Controller/Index.php |
@@ -6,20 +6,32 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
9
+ updated: 2026-08-27
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - worker2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
13
13
  - worker2/ebs/register_instance_to_shared_application_load_balancer.php
14
- - worker2/.platform/hooks/prebuild/_shared/040-write-instance-id.sh
15
- - worker2/.platform/hooks/prebuild/_shared/041-write-region.sh
16
- - worker2/.platform/hooks/prebuild/_shared/042-write-eb-environment.sh
17
- - api2/.platform/hooks/prebuild/_shared/042-write-eb-environment.sh
14
+ - worker2/.platform/hooks/_shared/040-write-instance-id.sh
15
+ - worker2/.platform/hooks/_shared/041-write-region.sh
16
+ - worker2/.platform/hooks/_shared/042-write-eb-environment.sh
18
17
  - worker2/.platform/hooks/postdeploy/015_install_composer.sh
18
+ - saml/.platform/hooks/_shared/040-write-instance-id.sh
19
+ - saml/.platform/hooks/_shared/041-write-region.sh
20
+ - saml/.platform/hooks/_shared/042-write-eb-environment.sh
21
+ - saml/.platform/hooks/prestart/040-write-instance-id.sh
22
+ - saml/.platform/hooks/prestart/041-write-region.sh
23
+ - saml/.platform/hooks/postdeploy/040-write-instance-id.sh
24
+ - saml/.platform/hooks/postdeploy/041-write-region.sh
25
+ - saml/.platform/hooks/postdeploy/042-write-eb-environment.sh
26
+ - saml/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
27
+ - saml/ebs/register_instance_to_shared_application_load_balancer.php
28
+ - saml/composer.json
29
+ - api2/.platform/hooks/prebuild/_shared/042-write-eb-environment.sh
19
30
  - api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
20
31
  - api2/ebs/register_instance_to_shared_application_load_balancer.php
21
32
  related:
22
33
  - ../architecture.md
34
+ - ../../saml/architecture.md
23
35
  - ../../api2/workflows/codepipeline-codeconnections-deploy.md
24
36
  - ../../api2/architecture.md
25
37
  ---
@@ -34,8 +46,10 @@ pair automates that step for **non-production** environments: on every deploy th
34
46
  registers **itself** into the target group whose **name exactly equals the EB environment name**.
35
47
 
36
48
  Originally built in `api2`; ported to `worker2` on 2026-07-28 with three security hardenings
37
- (instance-profile credentials, native-PHP IMDSv2, fail-closed token handling). **`worker2` is now
38
- the reference implementation — `api2`'s copy has known defects, see below.**
49
+ (instance-profile credentials, native-PHP IMDSv2, fail-closed token handling), and to `saml`
50
+ (SAML SSO Gateway) on 2026-08-27 as a byte-faithful copy of the hardened `worker2` variant.
51
+ **`worker2` is now the reference implementation — port from it, not from `api2`, whose copy has
52
+ known defects (see below).**
39
53
 
40
54
  Non-prod `worker2` environments need this because they are reached **over HTTP for manual job
41
55
  invocation**, not through SQS.
@@ -50,8 +64,8 @@ invocation**, not through SQS.
50
64
  2. **`ebs/register_instance_to_shared_application_load_balancer.php`**
51
65
  - Reads the instance id and region from
52
66
  `/var/app/current/storage/instance-id.txt` and `storage/region.txt` (written earlier by the
53
- `_shared/040-write-instance-id.sh` / `041-write-region.sh` prebuild hooks), falling back to a
54
- direct **IMDSv2** lookup.
67
+ `_shared/040-write-instance-id.sh` / `041-write-region.sh` hooks — see the layout note
68
+ below), falling back to a direct **IMDSv2** lookup.
55
69
  - Reads the EB environment name via `get-config container -k environment_name`.
56
70
  - Uses the AWS SDK (`aws/aws-sdk-php`) ELBv2 client to `DescribeTargetGroups` and selects the
57
71
  target group whose **`TargetGroupName` is an exact string match** for the environment name —
@@ -64,13 +78,23 @@ invocation**, not through SQS.
64
78
 
65
79
  | Hook | Provides |
66
80
  |---|---|
67
- | `prebuild/_shared/040-write-instance-id.sh` | `storage/instance-id.txt` |
68
- | `prebuild/_shared/041-write-region.sh` | `storage/region.txt` |
69
- | `prebuild/_shared/042-write-eb-environment.sh` | `storage/eb-environment.txt` |
81
+ | `_shared/040-write-instance-id.sh` (run via a phase wrapper) | `storage/instance-id.txt` |
82
+ | `_shared/041-write-region.sh` (run via a phase wrapper) | `storage/region.txt` |
83
+ | `_shared/042-write-eb-environment.sh` (run via a phase wrapper) | `storage/eb-environment.txt` |
70
84
  | `postdeploy/015_install_composer.sh` | `vendor/` (the AWS SDK) |
71
85
 
72
86
  Renumber it below `015` and the SDK autoloader does not exist yet.
73
87
 
88
+ ### Real on-disk hook layout — `_shared` + thin phase wrappers (corrected 2026-08-27)
89
+
90
+ The canonical `040`/`041`/`042` scripts live at **`.platform/hooks/_shared/0xx.sh`** — **not**
91
+ under `prebuild/`, as earlier revisions of this doc stated. Each deploy phase that needs one
92
+ carries a **thin wrapper** in its own hook dir (`.platform/hooks/prestart/0xx.sh`,
93
+ `.platform/hooks/postdeploy/0xx.sh`) that just runs `bash ../_shared/0xx.sh`. In the `worker2`
94
+ and `saml` trees, `040`/`041` have both a `prestart` and a `postdeploy` wrapper, while `042` has
95
+ a `postdeploy` wrapper only (no `prestart` copy). When porting, copy the canonical `_shared`
96
+ script **and** the phase wrappers the tier needs — the wrappers are what actually execute.
97
+
74
98
  ### `042-write-eb-environment.sh` — the EB environment name, cached to disk (2026-08-04)
75
99
 
76
100
  Added to the same `_shared` hook family (plus a postdeploy hook) in **both `api2` and `worker2`**,
@@ -95,6 +119,21 @@ an identical `015_install_composer.sh`; and the same `get-config environment -k
95
119
  convention already used by `.ebextensions/git.php`. Check these four before porting the pair to
96
120
  any other 2.0 tier.
97
121
 
122
+ **`saml` port (2026-08-27) — the two prerequisites a bare tier is missing.** `saml` had *none*
123
+ of the hook family (prebuild was `git.sh` only, postdeploy was `015_install_composer.sh` only,
124
+ no `ebs/` dir), so the port copied the canonical `_shared/040/041/042` scripts, their phase
125
+ wrappers, `060_…​.sh`, and the hardened `ebs/register_instance_to_shared_application_load_balancer.php`.
126
+ The two prerequisites it did **not** already meet:
127
+
128
+ - **`aws/aws-sdk-php` was absent** from `composer.json` — added `aws/aws-sdk-php: ^3.337`
129
+ (the SDK's ELBv2 client is what the script uses). **`composer.lock` + `vendor/` must be
130
+ regenerated** with `composer require aws/aws-sdk-php:^3.337` before deploy (a developer step;
131
+ not done in the porting session).
132
+ - **Stale `config.platform.php` pin.** `saml` pinned `7.2.33`, but the EB box runs PHP 8.5 (AL2023
133
+ / platform 4.13.1) and `aws-sdk-php ^3.337` needs PHP ≥ 8.1, so the stale pin would block
134
+ dependency resolution. Corrected to `8.1.0`. **Check the platform pin on any tier before
135
+ porting** — a low pin silently blocks the SDK install.
136
+
98
137
  ## Credentials — instance profile only (decision, 2026-07-28)
99
138
 
100
139
  **Deploy-time AWS credentials come from the EC2 instance profile. Never from source, never from
@@ -141,6 +180,12 @@ implementation instead:
141
180
  group name first when a non-prod env is unreachable after a deploy.
142
181
  - **Exit 0 hides failures.** Registration problems will not show in deploy status — read
143
182
  `/var/log/eb-hooks.log` on the instance.
183
+ - **`saml` does nothing until it has a non-prod env + matching target group.** The team KB lists
184
+ `saml`'s only environment as `saml-production`, which this hook **self-skips**. The `saml` port
185
+ registers a target only on a **non-prod** `saml` env, and only if (a) that env's EC2 instance
186
+ profile carries the two IAM actions below (Describe scoped by `aws:RequestedRegion`, Register
187
+ scoped to the **non-prod** target-group ARN — never `targetgroup/*/*`), and (b) a target group
188
+ exists named **exactly** the non-prod env name. Naming equality is the whole contract.
144
189
  - **The hook turns on reachability; it does not secure it.** The target group and listener already
145
190
  exist by convention, but because this hook is what actually puts non-prod instances behind the
146
191
  shared ALB, the **listener rules and security groups must restrict non-prod to internal/VPN
@@ -172,6 +217,13 @@ which lists the other hooks but not `060` — pending an elevated-doc update.
172
217
 
173
218
  ## Change history
174
219
 
220
+ - 2026-08-27 — Ported the hardened `worker2` hook pair to **`saml`** (SAML SSO Gateway): copied
221
+ `_shared/040/041/042`, their prestart/postdeploy wrappers, `060_…​.sh`, and the instance-profile
222
+ `ebs/register_instance_to_shared_application_load_balancer.php`. Added `aws/aws-sdk-php: ^3.337`
223
+ to `saml/composer.json` and corrected its stale `config.platform.php` pin `7.2.33 → 8.1.0`
224
+ (lock/vendor still to be regenerated by a developer). Also **corrected this doc's file paths**:
225
+ the canonical `040/041/042` scripts live at `.platform/hooks/_shared/`, not `prebuild/_shared/`,
226
+ and run via thin per-phase wrappers. (jcardinal)
175
227
  - 2026-08-04 — Added `_shared/042-write-eb-environment.sh` (+ a postdeploy hook) to **api2 and
176
228
  worker2**, caching the EB environment name to disk from `get-config` → `$EB_ENVIRONMENT_NAME` →
177
229
  the IMDS tag. Read from a file rather than shelled out to at use time because its first consumer
@@ -4,7 +4,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
4
4
 
5
5
  ## 1.0 framework
6
6
 
7
- - **library** (Library) _(framework core)_ — 19 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 20 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 28 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 69 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 70 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 57 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 13 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -2,5 +2,7 @@
2
2
 
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
+ | [NYCHH NetSuite → TransferOrders import (stocking-flag routing, PO bridges, origin location)](features/netsuite-transfer-order-import.md) | 1.0 | NYCHH's NetSuite **transfer orders are represented as NetSuite sales orders** (same as the GroWrk pattern), so the shared NetSuite → TOGa Supply importer (`App_ | library/app/api/toga2.php, library/app/api/netsuite/rest.php, library/app/netsuite.php, worker/crons/toga2/netsuite/sync_togasupply_hh.php, worker/crons/toga2/netsuite/common_sync_togasupply.php |
5
6
  | [NYCHH PO links are UPSTREAM — the downstream SO→PO route returns empty](features/po-number-upstream-direction.md) | 2.0 | NYCHH's sales orders are created **from the customer's purchase order**, so their SO↔PO links live in the **upstream** table `PurchaseOrders_SalesOrders` (route | _underscore/Model/Client/SalesOrder.php, _underscore/Trait/Netsuite/SalesOrder.php, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql, dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql |
6
- | [NYC Health & Hospitals](profile.md) | 2.0 | NYC Health & Hospitals (NYCHH) is a TOGA 2.0 client on the `_underscore` platform, prod schema `Client_Nychh`. | dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql |
7
+ | [NYCHH transfer-order 2.0 model — inventory quantities + V2 field enablement](features/transfer-order-inventory-quantities.md) | 2.0 | The 2.0 (`_underscore`) side of NYCHH transfer-order support: a **two-branch inventory quantity model** on the NYCHH `Item` override, plus the **client-override | _underscore/Model/Nychh/Item.php, _underscore/Model/Nychh/TransferOrder.php, _underscore/Model/Nychh/TransferOrderStage.php, _underscore/Model/Client/TransferOrder.php, dbchanges2/Client_Nychh/2026-08-27a - TransferOrderCustomFieldWritePermission.sql, dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql |
8
+ | [NYC Health & Hospitals](profile.md) | 2.0 | NYC Health & Hospitals (NYCHH) is a TOGA 2.0 client on the `_underscore` platform, prod schema `Client_Nychh`. | dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client_Nychh/2026-08-27a - TransferOrderCustomFieldWritePermission.sql, dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql |
@@ -0,0 +1,143 @@
1
+ ---
2
+ title: "NYCHH NetSuite → TransferOrders import (stocking-flag routing, PO bridges, origin location)"
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: nychh
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-27
10
+ owners: [jcardinal]
11
+ files:
12
+ - library/app/api/toga2.php
13
+ - library/app/api/netsuite/rest.php
14
+ - library/app/netsuite.php
15
+ - worker/crons/toga2/netsuite/sync_togasupply_hh.php
16
+ - worker/crons/toga2/netsuite/common_sync_togasupply.php
17
+ related:
18
+ - ../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
19
+ - ../../../1.0/apps/library/features/toga2-api-client-and-bridge.md
20
+ - ../../../1.0/apps/library/features/netsuite-suiteql-rest-shim.md
21
+ - ../../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md
22
+ - ../../growrk/features/transfer-order-flow.md
23
+ - ./transfer-order-inventory-quantities.md
24
+ - ../profile.md
25
+ ---
26
+
27
+ ## Summary
28
+
29
+ NYCHH's NetSuite **transfer orders are represented as NetSuite sales orders** (same as the GroWrk
30
+ pattern), so the shared NetSuite → TOGa Supply importer (`App_Api_Toga2::sync*FromNetsuite`) must
31
+ decide, per order, whether to write it to `Client_Nychh.SalesOrders` or `Client_Nychh.TransferOrders`.
32
+ This doc covers the **1.0-side ingestion**: the routing signal, the PO↔order bridge links, transfer-order
33
+ origin/destination location resolution, the item-fulfillment transfer-order line link, and the
34
+ transfer-order status map. The **2.0-side** (quantity model + the model/ACL work that lets the
35
+ TransferOrder POST succeed) is in
36
+ [NYCHH transfer-order inventory & quantities](./transfer-order-inventory-quantities.md).
37
+
38
+ **End-to-end verified this session:** a transfer order imported into `Client_Nychh.TransferOrders`
39
+ after deploy. One open edge case remains (order 128829, below).
40
+
41
+ ## Routing signal — the NetSuite stocking flag, NOT the dollar amount
42
+
43
+ Route by the custom NetSuite checkbox **`custbody_stocking_order`** (internal id **7097**), not by
44
+ total/hold:
45
+
46
+ - **CHECKED** = a *stocking order* (how stock comes **IN**) → `Client_Nychh.SalesOrders`.
47
+ - **UNCHECKED** = a *transfer order* (how stock goes **OUT**) → `Client_Nychh.TransferOrders`.
48
+
49
+ `App_Api_Toga2::isTransferOrder()` was rewritten to read this flag through a new private
50
+ `isStockingOrder()` that normalizes NetSuite's `'T'`/`'F'` / bool / int representations. A new
51
+ private `linkPurchaseOrderToTransferOrder()` mirrors the existing sales-order PO linker (below).
52
+
53
+ > **⚠ The old heuristic — `total === 0` + `custbody_asi_hold` / `holdInvoice` — was WRONG for NYCHH
54
+ > and is gone.** NYCHH does **not** set the hold flag, so under the old rule every order matched as
55
+ > a sales order and `TransferOrders` stayed empty. The dollar amount is **ignored entirely** now.
56
+ > (GroWrk's own `holdInvoice + total===0` signal — see
57
+ > [GroWrk transfer order flow](../../growrk/features/transfer-order-flow.md) — is client-specific and
58
+ > not universal; the TO-detection signal must be read per client.)
59
+
60
+ **Routing is opt-in per client, default OFF.** The shared engine only routes to TransferOrders when
61
+ the wrapper enables it. NYCHH's wrapper sets
62
+ `const IS_ENABLED_TRANSFER_ORDER_STOCKING_FLAG_ROUTING = true;` in
63
+ `worker/crons/toga2/netsuite/sync_togasupply_hh.php`; every other client inherits the default (off)
64
+ and behaves exactly as before.
65
+
66
+ ## PO ↔ order bridge links (header + line)
67
+
68
+ A single NetSuite PO is bridged to whichever order table the order landed in, at both header and
69
+ line level; links are idempotent (line matching is by shared item `uuid`):
70
+
71
+ | Order routed to | Header bridge | Line bridge |
72
+ |---|---|---|
73
+ | stocking → SalesOrders | `PurchaseOrders_SalesOrders` | `PurchaseOrderItems_SalesOrderItems` |
74
+ | transfer → TransferOrders | `PurchaseOrders_TransferOrders` | `PurchaseOrderItems_TransferOrderItems` |
75
+
76
+ `linkPurchaseOrderToTransferOrder()` is the new transfer-order counterpart to the existing
77
+ sales-order linker. See the shared
78
+ [SO↔PO bridge direction](../../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md)
79
+ doc for the bridge naming convention.
80
+
81
+ ## Transfer-order origin/destination location resolution
82
+
83
+ `TransferOrders.originLocationId` is **NOT NULL**, so a missing origin fails the V2 insert with
84
+ **EV-10** ("originLocationId cannot be null"). Two fixes were needed:
85
+
86
+ 1. **The line SuiteQL never selected the line location.** `App_Api_Netsuite_Rest::listSalesOrders()`
87
+ line query now selects `tl.location` and `BUILTIN.DF(tl.location) AS location_name`. The line
88
+ shim already *read* these fields, but they were never *selected*, so they were always null — see
89
+ the [SuiteQL/REST shim](../../../1.0/apps/library/features/netsuite-suiteql-rest-shim.md).
90
+ 2. **Header-location fallback.** In `toga2.php`, origin resolution falls back to the **first
91
+ line-item location** when the NetSuite header location is empty.
92
+
93
+ > **OPEN edge case (pending developer decision):** NetSuite sales order **128829** has **no
94
+ > location** on the header *or* on any SuiteQL line, so neither rule resolves an origin. A
95
+ > default-origin rule is proposed — candidate: NYCHH warehouse **Location 75** /
96
+ > `c_netsuiteInternalLocationId` **117** — but not yet applied. Until decided, 128829 cannot import.
97
+
98
+ ## Item-fulfillment transfer-order line link
99
+
100
+ `ItemFulfillmentItems.transferOrderItemId` must populate so the 2.0 stock-out quantity calc can tie
101
+ a fulfillment back to its transfer-order line. In `syncItemFulfillmentFromNetsuite`:
102
+
103
+ - added `itemFulfillmentItems.transferOrderItem.uuid` to the GET `fields` allowlist,
104
+ - null-guarded the dedup lookup (`?? null`),
105
+ - the header IF POST sets `transferOrder` when applicable.
106
+
107
+ ## Transfer-order status map
108
+
109
+ `syncTransferOrderFromNetsuite`'s status switch was expanded to cover the real NetSuite statuses:
110
+ **Billed**, **Pending Billing**, **Pending Billing-Partially Fulfilled** → `received` / `inTransit`.
111
+ The **default** now **logs and defaults to `'pending'`** instead of throwing. An unmapped "Billed"
112
+ status was previously throwing (and being swallowed), which is why order 128829 never imported.
113
+
114
+ `App_NetSuite::getCustomFieldValue()` (`library/app/netsuite.php`) was hardened with `?? []` / `?? null`
115
+ guards: 1.0 targets PHP 7.2 and any PHP warning on that path terminates the cron.
116
+
117
+ ## Gotchas / known issues
118
+
119
+ - **Order 128829 (no location anywhere) is unresolved** — see the origin-resolution section. Do not
120
+ assume the importer is broken if this single order is stuck; every located order imports.
121
+ - **Routing is gated by the wrapper constant.** If a future client should route transfer orders,
122
+ add `const IS_ENABLED_TRANSFER_ORDER_STOCKING_FLAG_ROUTING = true;` before its
123
+ `require_once 'common_sync_togasupply.php';` — and confirm that client actually sets
124
+ `custbody_stocking_order` in NetSuite. Absent the flag, everything lands in SalesOrders.
125
+ - **`custbody_stocking_order` must reach the shim.** `fetchSalesOrderById()` now carries the field
126
+ via `customFieldList`; a missing custom field read returns null → the order routes as a transfer
127
+ order (unchecked = transfer). Verify the field is populated in NetSuite before trusting the split.
128
+
129
+ ## Change history
130
+
131
+ - 2026-08-27 — Built NYCHH transfer-order ingestion end-to-end. **Routing by `custbody_stocking_order`
132
+ (id 7097), not dollar amount** (`isTransferOrder()` rewritten + new `isStockingOrder()` normalizer);
133
+ the old `total===0 + hold-flag` heuristic was wrong for NYCHH (no hold flag set → everything landed
134
+ in SalesOrders) and is removed. Routing is opt-in per client via
135
+ `IS_ENABLED_TRANSFER_ORDER_STOCKING_FLAG_ROUTING` (default off; NYCHH true). Added the
136
+ `PurchaseOrders_TransferOrders` / `PurchaseOrderItems_TransferOrderItems` bridge linker; resolved TO
137
+ origin (line SuiteQL now selects `tl.location`, header-empty falls back to first line location);
138
+ linked item fulfillments to their transfer-order line (`transferOrderItem.uuid`); expanded the TO
139
+ status map (Billed / Pending Billing → received/inTransit, default `pending` instead of throwing);
140
+ hardened `getCustomFieldValue()` for PHP 7.2. A transfer order imported into
141
+ `Client_Nychh.TransferOrders` after deploy. **Open:** order 128829 has no location on header or any
142
+ line — default-origin rule (candidate Location 75 / `c_netsuiteInternalLocationId` 117) pending the
143
+ developer's decision. (jcardinal)
@@ -0,0 +1,116 @@
1
+ ---
2
+ title: "NYCHH transfer-order 2.0 model — inventory quantities + V2 field enablement"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: nychh
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-27
10
+ owners: [jcardinal]
11
+ files:
12
+ - _underscore/Model/Nychh/Item.php
13
+ - _underscore/Model/Nychh/TransferOrder.php
14
+ - _underscore/Model/Nychh/TransferOrderStage.php
15
+ - _underscore/Model/Client/TransferOrder.php
16
+ - dbchanges2/Client_Nychh/2026-08-27a - TransferOrderCustomFieldWritePermission.sql
17
+ - dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql
18
+ related:
19
+ - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
20
+ - ../../../2.0/apps/api2/features/v2-api-error-codes.md
21
+ - ../../growrk/features/transfer-order-flow.md
22
+ - ./netsuite-transfer-order-import.md
23
+ - ../profile.md
24
+ ---
25
+
26
+ ## Summary
27
+
28
+ The 2.0 (`_underscore`) side of NYCHH transfer-order support: a **two-branch inventory quantity
29
+ model** on the NYCHH `Item` override, plus the **client-override model declarations and ACL rows**
30
+ that let a NetSuite-sourced TransferOrder actually POST through the V2 API. The 1.0 ingestion that
31
+ feeds these POSTs is in
32
+ [NYCHH NetSuite → TransferOrders import](./netsuite-transfer-order-import.md).
33
+
34
+ The general, framework-level lessons from the field-exposure work here are recorded on the shared
35
+ [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md) and
36
+ [V2 API error codes](../../../2.0/apps/api2/features/v2-api-error-codes.md) docs; this doc keeps the
37
+ NYCHH-specific composition.
38
+
39
+ ## NYCHH-only quantity calculations (two-branch inventory model)
40
+
41
+ Per the 2026-08-17 "TOGa Supply" meeting. **NYCHH ONLY** — the overrides live on the client model
42
+ `_Model_Nychh_Item` (`_underscore/Model/Nychh/Item.php`), not the base. Stocking orders bring stock
43
+ **in**, transfer orders send it **out**, so the quantities reflect that direction:
44
+
45
+ | Quantity | Definition |
46
+ |---|---|
47
+ | **on hand** | `received` (PO item receipts) − `fulfilled` (transfer-order item fulfillments) |
48
+ | **available** | `on hand` − `committed` |
49
+ | **fulfilled** | Σ shipped transfer-order fulfillments |
50
+ | **backordered** | `ordered` − `received` |
51
+
52
+ `_qtyOnOrder` was added alongside these. The stock-in side reads PO receipts; the stock-out side
53
+ reads transfer orders (which is why the 1.0 importer must populate
54
+ `ItemFulfillmentItems.transferOrderItemId` — see the import doc).
55
+
56
+ ## V2 field enablement — how the TransferOrder POST was made to work
57
+
58
+ Posting a NetSuite-sourced transfer order that carries NetSuite custom fields required **every** part
59
+ of the custom-field recipe on the NYCHH client DB. Each missing part surfaced as a distinct V2 error,
60
+ peeled in order:
61
+
62
+ 1. **Client-override model must DECLARE the custom field.** The framework resolves
63
+ `_Model_Nychh_X extends _Model_Client_X`, and the NetSuite `c_` declarations come from
64
+ `_Trait_Netsuite_X`. Created:
65
+ - `_underscore/Model/Nychh/TransferOrder.php` —
66
+ `class _Model_Nychh_TransferOrder extends _Model_Client_TransferOrder { use _Trait_Netsuite_TransferOrder; }`
67
+ (declares `c_netsuiteInternalSalesOrderId`) → fixes the **EO-1** 500.
68
+ - `_underscore/Model/Nychh/TransferOrderStage.php` —
69
+ `class _Model_Nychh_TransferOrderStage extends _Model_Client_TransferOrderStage { use _Trait_Netsuite_TransferOrderStage; }`
70
+ (declares `c_netsuiteInternalTransferOrderStatus`) → fixes **EV-12** `invalidSearchableField`.
71
+ 2. **ACL write grant** — an `AclCustomFieldPermissions` row (roleId 1, `isWritable 1`) for
72
+ transfer-orders `c_netsuiteInternalSalesOrderId` (record 312) → fixes **EV-9**.
73
+ `dbchanges2/Client_Nychh/2026-08-27a - TransferOrderCustomFieldWritePermission.sql`.
74
+ 3. **Searchable identifier** — for a nested FK resolved *by a custom field*, that field's
75
+ `CustomRecordFields.isIdentifier` must be `1`. Set `isIdentifier=1` on
76
+ `c_netsuiteInternalTransferOrderStatus` (recordId 351) → fixes **EV-12**
77
+ `searchableIdentifierFields:[id]`.
78
+ `dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql`.
79
+
80
+ > **There is NO ACL/metadata caching layer in the 2.0 V2 API.** Applying these ACL/metadata rows
81
+ > takes effect on the **very next request** — there is no cache to invalidate and no cache-bust step.
82
+ > (This corrects an earlier assumption that a cache had to be busted, which sent debugging down a
83
+ > dead end.) Recorded framework-wide on the
84
+ > [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md).
85
+
86
+ ## `isActive` is defaulted in the ORM, not sent in the payload (shared model)
87
+
88
+ `TransferOrders.isActive` is **NOT NULL**, but `isActive` is **not a registered/writable V2 field** —
89
+ adding `'isActive' => 1` to the POST payload throws **EV-8** ("field does not exist"). The correct
90
+ fix is to default it in the ORM: `_underscore/Model/Client/TransferOrder.php` now declares
91
+ `public $isActive = [self::FIELD_BOOLEAN, self::FIELDOPT_DEFAULT => true];`, so the ORM writes `1` on
92
+ insert without a payload key. This is a **shared** (`_Model_Client_TransferOrder`) change — the
93
+ pattern (default a NOT NULL non-writable column in the model, never send it) is on the
94
+ [V2 API error codes](../../../2.0/apps/api2/features/v2-api-error-codes.md) map under EV-8.
95
+
96
+ ## Gotchas / known issues
97
+
98
+ - **A custom field is writable/searchable ONLY when BOTH the model declares it (via the NetSuite
99
+ trait on the client-override model) AND the ACL/metadata rows exist.** Declaring without the ACL
100
+ row, or granting the ACL without the declaration, each fails with its own error — see the peeled
101
+ sequence above.
102
+ - **These quantity overrides are NYCHH-only.** They live on `_Model_Nychh_Item`; do not lift them to
103
+ the base `_Model_Client_Item` — other clients do not have the stocking/transfer two-branch model.
104
+
105
+ ## Change history
106
+
107
+ - 2026-08-27 — Built the NYCHH two-branch inventory quantity model on `_Model_Nychh_Item` (on hand =
108
+ received − fulfilled; available = on hand − committed; fulfilled = Σ shipped TO fulfillments;
109
+ backordered = ordered − received; added `_qtyOnOrder`), per the 2026-08-17 TOGa Supply meeting.
110
+ Enabled the NetSuite-sourced TransferOrder POST by creating the NYCHH client-override models
111
+ `_Model_Nychh_TransferOrder` / `_Model_Nychh_TransferOrderStage` (each `use`s its
112
+ `_Trait_Netsuite_*` to declare the `c_` field), adding the `AclCustomFieldPermissions` write grant
113
+ (2026-08-27a) and setting `CustomRecordFields.isIdentifier=1` on
114
+ `c_netsuiteInternalTransferOrderStatus` (2026-08-27b). Defaulted the shared
115
+ `_Model_Client_TransferOrder::$isActive` in the ORM (NOT NULL, non-writable → EV-8 if sent).
116
+ Confirmed there is **no ACL/metadata cache** — rows apply on the next request. (jcardinal)
@@ -15,15 +15,19 @@ project: _Underscore
15
15
  client: nychh
16
16
  type: profile
17
17
  status: active
18
- updated: 2026-08-26
18
+ updated: 2026-08-27
19
19
  owners: ["jcardinal", "apeterson", "bala"]
20
20
  files:
21
21
  - dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql
22
22
  - dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql
23
+ - dbchanges2/Client_Nychh/2026-08-27a - TransferOrderCustomFieldWritePermission.sql
24
+ - dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql
23
25
  related:
24
26
  - ../../2.0/apps/_underscore/features/tracking-number-bridges.md
25
27
  - ../../2.0/apps/_underscore/features/tableview-joins.md
26
28
  - ./features/po-number-upstream-direction.md
29
+ - ./features/netsuite-transfer-order-import.md
30
+ - ./features/transfer-order-inventory-quantities.md
27
31
  - ../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md
28
32
  ---
29
33
 
@@ -53,6 +57,14 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
53
57
  `macAddress` synced from the NetSuite serial (inventory-number) record, closing the gap where a
54
58
  tag added after the ItemShip never bumps the fulfillment sync. See
55
59
  [NYCHH Asset-Tag Backfill](../../2.0/apps/worker2/features/nychh-asset-tag-backfill.md).
60
+ - **Transfer orders (NetSuite sales orders → `TransferOrders`)** — NYCHH's NetSuite transfer orders
61
+ arrive as sales orders; the 1.0 importer routes them by the `custbody_stocking_order` flag (not the
62
+ dollar amount), bridges the PO, and resolves origin/destination locations. See
63
+ [NYCHH NetSuite → TransferOrders import](./features/netsuite-transfer-order-import.md).
64
+ - **Two-branch inventory quantities + TransferOrder V2 enablement** — NYCHH-only on-hand/available/
65
+ fulfilled/backordered model on `_Model_Nychh_Item`, plus the client-override models + ACL rows that
66
+ let a NetSuite TransferOrder POST succeed. See
67
+ [NYCHH transfer-order inventory & quantities](./features/transfer-order-inventory-quantities.md).
56
68
 
57
69
  - **PO links are UPSTREAM (`PurchaseOrders_SalesOrders`), not downstream.** The downstream
58
70
  `sales-order-purchase-orders` route returns an empty array for NYCHH by design, and the shared
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.671",
3
+ "version": "1.0.673",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",