toga-ai 1.0.697 → 1.0.699

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.
@@ -6,7 +6,7 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-19
9
+ updated: 2026-08-29
10
10
  owners: ["dfranks", "jcardinal", "kyalamarthi"]
11
11
  files:
12
12
  - library/app/api/netsuite/rest.php
@@ -16,6 +16,7 @@ related:
16
16
  - netsuite-suiteql-rest-shim.md
17
17
  - ../architecture.md
18
18
  - ../../worker/features/forecast2-netsuite-reconciliation.md
19
+ - ../../../clients/nychh/features/netsuite-inventory-adjustment-fulfillment-link.md
19
20
  ---
20
21
 
21
22
  ## Summary
@@ -219,6 +220,22 @@ Other JE line facts (same probes):
219
220
  > NetSuite custom field supplying those dimensions is created and populated (no `salesrep` column, no
220
221
  > `item`). Re-evaluate only once that custom field exists — don't re-probe this from scratch.
221
222
 
223
+ ### Item Fulfillment (`ItemShip`) lines — ~3 rows per fulfilled item, and `mainline` is NOT the filter
224
+
225
+ For an Item Fulfillment (`transaction.type = 'ItemShip'`), `transactionline` returns **~3 rows per
226
+ fulfilled item**: the order-linked line, a positive "wash" line, and an `isinventoryaffecting = 'T'`
227
+ line. **Only the order-linked line maps to a source-order line.** Keep exactly that one by requiring
228
+ a `previoustransactionlinelink` of type `SalesOrd`/`TrnfrOrd` (via an `EXISTS` correlated subquery),
229
+ and derive the source `orderLine` from that link's `previousline` (a correlated subquery — see the
230
+ `previousTransactionLineLink` section below).
231
+
232
+ - **⚠ `mainline = 'F'` is the WRONG filter for an ItemShip line.** NetSuite sometimes marks the
233
+ first real fulfilled line `mainline = 'T'`, so a `mainline='F'` predicate **silently drops it**.
234
+ Filter on the `SalesOrd`/`TrnfrOrd` line-link instead, not on `mainline`. (Same "extra posting
235
+ lines per item" shape as the credit-memo/cash-refund COGS lines in
236
+ `netsuite-suiteql-rest-shim.md`.) Surfaced building
237
+ [NYCHH inventory-adjustment → item-fulfillment linking](../../../clients/nychh/features/netsuite-inventory-adjustment-fulfillment-link.md).
238
+
222
239
  ## `previousTransactionLineLink` table
223
240
 
224
241
  The bridge between a child line (e.g. invoice) and its originating parent line (e.g. SO).
@@ -322,6 +339,39 @@ Used for fields SuiteQL doesn't expose on `transaction`: `shippingCost`, `taxTot
322
339
  `linesequencenumber` can diverge from REST `line` for SOAP-era records (confirmed empirically),
323
340
  so keying on `linesequencenumber` risks duplicate Sales rows that can't be matched or deleted.
324
341
  **`listSales()` keeps the per-id GET for this reason — do not eliminate it.**
342
+ - **⚠ REST record SUBLIST endpoints reject `limit`/`offset` (HTTP 400).**
343
+ `GET /record/v1/{type}/{id}/{sublist}` (e.g. `.../itemFulfillment/{id}/item?limit=1000&offset=N`)
344
+ returns **400 `INVALID_PARAMETER`** — *"Use one of: fields, expand, expandRecords,
345
+ expandSubResources, simpleEnumFormat, ids"*. `limit`/`offset` are valid only on **collection**
346
+ endpoints, not on a record's sublist. A shim that paginated a sublist this way silently got
347
+ **0 lines** and imported the record **header-only** with no error. Fetch line data via **SuiteQL
348
+ `transactionline`** instead (the pattern every other shim in `rest.php` already uses). Surfaced
349
+ when `fetchItemFulfillmentById` returned zero lines — see
350
+ [NYCHH inventory-adjustment → item-fulfillment linking](../../../clients/nychh/features/netsuite-inventory-adjustment-fulfillment-link.md).
351
+
352
+ ### `fetch*ById()` single-record helpers — the REST replacements for the SOAP `App_NetSuite::get*`
353
+
354
+ The togasupply importer fetches individual records by internal id through a family of `fetch*ById()`
355
+ helpers on `App_Api_Netsuite_Rest`, each assembling a SOAP-shaped shim from a SuiteQL header/lines
356
+ query plus (where needed) a REST record GET. As of **2026-08-29 the togasupply sync path uses these
357
+ exclusively — zero `App_NetSuite::` SOAP network calls remain** in `common_sync_togasupply.php` or the
358
+ `*FromNetsuite()` methods:
359
+
360
+ - **`fetchItemReceiptById`** — header + lines + serials (serials via SuiteQL
361
+ `inventoryassignment ⋈ inventorynumber`, grouped per product). Replaces SOAP
362
+ `getItemReceipt`/`getInventoryNumber*`/`getItemIsSerialized`.
363
+ - **`fetchPurchaseOrderById`** / **`fetchSalesOrderByIdFull`** — replace SOAP `getPurchaseOrder` /
364
+ `getSalesOrder`.
365
+ - **`fetchItemFlagsByIds`** — batch item serialized/fulfillable flags; replaces
366
+ `getItemSerializedAndFulfillable`.
367
+ - **`fetchInventoryAdjustmentById`** — replaces the `diagnose_hh` script's SOAP `getObject`.
368
+ - **`fetchInvoiceFile`** — SuiteQL `file`-table lookup + REST `GET /record/v1/file/{id}` content,
369
+ `base64_decode` → raw bytes. Replaces SOAP `getInvoiceFile`.
370
+
371
+ `listCustomers()` also gained a **`companyName`** field on its shim (the `netsuite_customers.php` cron
372
+ moved from `listAllCustomers` → `listCustomers`). REST-shim *shape* traps that bite these helpers (an
373
+ absent nested list crashes 1.0; `scriptId` case-sensitivity on `customFieldList`) are in
374
+ [`netsuite-suiteql-rest-shim.md`](netsuite-suiteql-rest-shim.md).
325
375
 
326
376
  ## `list*()` helpers — behavior & cost
327
377
 
@@ -392,6 +442,24 @@ numbers. Budget hours for multi-year runs and launch them under `nohup`/`tmux`.
392
442
 
393
443
  ## Change history
394
444
 
445
+ - 2026-08-29 — Documented the **`fetch*ById()` single-record helper family** (`fetchItemReceiptById`,
446
+ `fetchPurchaseOrderById`, `fetchSalesOrderByIdFull`, `fetchItemFlagsByIds`,
447
+ `fetchInventoryAdjustmentById`, `fetchInvoiceFile`) that replaced the SOAP `App_NetSuite::get*` calls,
448
+ and the `listCustomers` `companyName` addition — the togasupply sync path now makes **zero
449
+ `App_NetSuite::` SOAP network calls**. Added while root-causing the 5 stuck togasupply integrations as
450
+ REST-shim shape mismatches (see the
451
+ [per-client-sync doc](../../worker/features/netsuite-togasupply-per-client-sync.md)). (jcardinal)
452
+ - 2026-08-29 — Recorded two NetSuite REST/SuiteQL facts from the NYCHH inventory-adjustment →
453
+ item-fulfillment **REST migration**: **REST record sublist endpoints
454
+ (`/record/v1/{type}/{id}/{sublist}`) reject `limit`/`offset` with a 400 `INVALID_PARAMETER`** (a
455
+ paginated sublist fetch silently returns 0 lines → header-only import; use SuiteQL
456
+ `transactionline`), and an **Item Fulfillment (`ItemShip`) `transactionline` returns ~3 rows per
457
+ fulfilled item** (order-linked + positive "wash" + `isinventoryaffecting='T'`) — keep only the line
458
+ with a `previoustransactionlinelink` of type `SalesOrd`/`TrnfrOrd` and derive `orderLine` from its
459
+ `previousline`; **`mainline='F'` is the wrong filter** because NetSuite sometimes marks the first
460
+ real line `mainline='T'`. See
461
+ [NYCHH inventory-adjustment → item-fulfillment linking](../../../clients/nychh/features/netsuite-inventory-adjustment-fulfillment-link.md).
462
+ (jcardinal)
395
463
  - 2026-08-19 — Added two SuiteQL mechanics that fail **silently**, both found porting the customer
396
464
  account-type sync (TRUE-80206): **column aliases come back lower-cased** (`AS accountTypeLabel` →
397
465
  `accounttypelabel`, so a camelCase read is `NULL` with no error — it made a backfill report success while
@@ -6,7 +6,7 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-27
9
+ updated: 2026-08-29
10
10
  owners: [dfranks, jcardinal, kyalamarthi]
11
11
  files:
12
12
  - library/app/api/netsuite/rest.php
@@ -112,6 +112,29 @@ Forecast2 tables already store.
112
112
  TransferOrders). A custom field absent from the record reads as null downstream. See
113
113
  [NYCHH NetSuite → TransferOrders import](../../../clients/nychh/features/netsuite-transfer-order-import.md).
114
114
 
115
+ - **⚠ `scriptId` in a `customFieldList` def is CASE-SENSITIVE on lookup — a case skew silently
116
+ returns the default (2026-08-29).** `custbody_stocking_order` was **tagged** in the shim with the
117
+ uppercase scriptId `CUSTBODY_STOCKING_ORDER` but **read** by the importer as lowercase
118
+ `custbody_stocking_order`, so the lookup never matched and always fell back to the default (unchecked)
119
+ — which mis-routed **every** NYCHH order to a Transfer Order. Fixed by lowercasing the scriptId in
120
+ **both** `buildSalesOrderShims()` and `fetchSalesOrderById()` defs; `END_CUSTOMER` is intentionally
121
+ left uppercase because it is resolved by internalId `3149`, not by scriptId. Nothing errors on a case
122
+ skew — the field just reads as its default — so verify the exact scriptId string on both the write and
123
+ the read side. See [NYCHH TransferOrders import](../../../clients/nychh/features/netsuite-transfer-order-import.md).
124
+
125
+ - **A shim must produce the SOAP object's SHAPE, not just its scalar fields — an absent nested list
126
+ crashes 1.0 (2026-08-29).** `fetchCustomerById()`'s `addressbookList` was where the AIG lookup phase
127
+ crashed: a REST customer with no/odd address made the shim omit the nested `addressbook` structure the
128
+ old SOAP object always carried, and the downstream unguarded read raised a warning → `App_Error`
129
+ `exit()` (see the `getCustomFieldValue` warning-safety gotcha below — same class). It now builds
130
+ `addressbookList` via a `shimAddress()` helper that normalizes `country` to the `_unitedStates` enum
131
+ and returns an **empty `addressbook: []`** when there is no address, so the shape always matches;
132
+ two helpers were extracted for reuse — `countryToSoapEnum()` (country string → SOAP enum) and
133
+ `refString()`. This is the concrete "REST shim returns null/absent where SOAP had a value" failure
134
+ the [per-client-sync doc](../../worker/features/netsuite-togasupply-per-client-sync.md) diagnoses via
135
+ `Logs.Issue`/`Logs.Event`. **When adding or editing a shim, reproduce the full SOAP shape (including
136
+ empty nested lists), not just the fields you happen to need.**
137
+
115
138
  ## Transaction status sourcing (1.0) — and the 2026.2 REST `status.id` change
116
139
 
117
140
  1.0 reaches NetSuite over **two transports, and neither reads status off a REST record GET
@@ -192,6 +215,17 @@ None — uniform across clients (NetSuite is a single shared account).
192
215
 
193
216
  ## Change history
194
217
 
218
+ - 2026-08-29 — Recorded two shim-shape facts from root-causing the 5 stuck togasupply integrations
219
+ (which were **shim-shape mismatches, not dead SOAP** — see the
220
+ [per-client-sync doc](../../worker/features/netsuite-togasupply-per-client-sync.md)): a
221
+ `customFieldList` **`scriptId` is case-sensitive on lookup** — `CUSTBODY_STOCKING_ORDER` (write) vs
222
+ `custbody_stocking_order` (read) silently returned the default and mis-routed every NYCHH order to a
223
+ Transfer Order (fixed by lowercasing in both `buildSalesOrderShims`/`fetchSalesOrderById`;
224
+ `END_CUSTOMER` stays uppercase, resolved by internalId 3149); and a shim must reproduce the SOAP
225
+ object's **full shape** — `fetchCustomerById()`'s `addressbookList` omitting the nested `addressbook`
226
+ crashed the AIG lookup phase (unguarded null read → warning → `exit()`), now built via `shimAddress()`
227
+ (country → `_unitedStates` enum, empty → `addressbook: []`) with extracted `countryToSoapEnum()` /
228
+ `refString()` helpers. (jcardinal)
195
229
  - 2026-08-27 — **Traced the missing `tl.location` SELECT to its cause — a merge, not an omission — and
196
230
  recorded what restoring it re-animates** (forecast2 open-orders, TRUE-79162). TRUE-79078 (31fadd77)
197
231
  originally added `tl.location` + `BUILTIN.DF(tl.location)`; merge c3caa11a (2026-06-24) resolved in
@@ -6,7 +6,7 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-17
9
+ updated: 2026-08-29
10
10
  owners: [jcardinal, mhammontree, bala]
11
11
  files:
12
12
  - library/app/api/toga2.php
@@ -396,6 +396,15 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
396
396
 
397
397
  ## Change history
398
398
 
399
+ - 2026-08-29 — The `*FromNetsuite()` importer methods in this class were **completed onto REST** — the
400
+ item-receipt and inventory-adjustment paths dropped their last `App_NetSuite::` SOAP calls in favor of
401
+ `App_Api_Netsuite_Rest::fetch*ById()`, so the togasupply importer now makes **zero SOAP network
402
+ calls**. Also **corrects the 2026-08-27 entry below**: the private `isStockingOrder()` custom-field
403
+ read had a `scriptId` case-mismatch (`CUSTBODY_STOCKING_ORDER` vs `custbody_stocking_order`) that
404
+ silently defaulted and mis-routed every NYCHH order — now fixed. Method-level detail and the full
405
+ per-client fix set live on
406
+ [NetSuite → TOGa Supply per-client sync](../../worker/features/netsuite-togasupply-per-client-sync.md)
407
+ (2026-08-29). (jcardinal)
399
408
  - 2026-08-27 — `send()`'s throw sites were **reverted to plain `throw new Exception(...)`** (both
400
409
  sites) as part of removing the sync engine's swallow-and-skip data-loss bug — the importer must
401
410
  propagate a failed write, not catch it (see
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-28
9
+ updated: 2026-08-29
10
10
  owners: ["dfranks", "bala", "jcardinal", "mhammontree", "snaredla"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
@@ -520,8 +520,117 @@ Parameters are stored **per client DB** but accessed **through the TOGa2 API**,
520
520
  item-fulfillment lookup set through for the on-demand fulfillment import (lookups are passed
521
521
  function-to-function, like the other sync functions).
522
522
 
523
+ - **⚠ Inventory-adjustment sync migrated SOAP → REST + now fails LOUD (2026-08-29).** The SOAP path
524
+ flagged just above (`App_NetSuite::getInventoryNumber*`, ~2 round-trips per serial) has been
525
+ **migrated to REST/SuiteQL** via `App_Api_Netsuite_Rest`, and the **on-demand fulfillment /
526
+ sales-order import** it triggers was converted to REST too — closing the ~1h-per-large-adjustment
527
+ cost that was the previously-planned next step. Also, the remaining **error-log-and-swallow
528
+ `try/catch` blocks in the NetSuite→DB sync path were removed** so a data-corruption failure surfaces
529
+ loudly (StarTech/Sentry catches left alone) — the same loud-failure policy as the 2026-08-27
530
+ per-order `try/catch` removal. Full detail (the REST-shim fixes it required, and the bridges →
531
+ direct-FK-column change) on
532
+ [NYCHH inventory-adjustment → item-fulfillment linking](../../../clients/nychh/features/netsuite-inventory-adjustment-fulfillment-link.md).
533
+
534
+ - **⚠ 2026-08-29: the 5 stuck togasupply integrations were REST-shim SHAPE mismatches, NOT dead
535
+ SOAP — and how to tell them apart.** Five clients had a section frozen at `RUNNING` and the first
536
+ hypothesis was "SOAP is down." **SOAP was up.** The real cause was introduced by the earlier
537
+ SOAP→REST list-method conversion: a REST shim returns `null`/absent where the old SOAP object
538
+ carried a value, and in 1.0 **any unguarded read of that null raises a PHP warning that
539
+ `App_Error` escalates to `exit()`** (see the 1.0 backend-php standard *Any PHP warning terminates
540
+ the request*, and the [rest-shim warning-safety gotcha](../../library/features/netsuite-suiteql-rest-shim.md)).
541
+ The `exit()` mid-section leaves that record-type's `NETSUITE_EXECUTION_MODE_*` stuck at `RUNNING`
542
+ with the watermark frozen — i.e. **a shim-shape mismatch presents identically to a poison order.**
543
+ So a stuck section is never "SOAP is dead"; it is one specific record hitting one specific
544
+ unguarded shim field.
545
+ **Diagnosis playbook (durable):** query prod **`Logs.Issue` + `Logs.Event`** (the
546
+ `App_Error_Capture` tables, schema `Logs`, via the toga-db MCP) for the client's `clientId` — each
547
+ stuck section maps to an exact crash **file:line** in `common_sync_togasupply.php` or `toga2.php`.
548
+ The `RUNNING` mode + frozen watermark **is** the stuck signal (see the window/watermark gotchas
549
+ above); `Logs.Issue`/`Event` turns it into a line number, far faster than reading the cron blind.
550
+ **Per-client fixes shipped (all in `library`, `php -l` clean, and fail-loud — a genuinely
551
+ unresolvable case now throws rather than silently continuing):**
552
+ - **Item receipts — Canon + Endeavor Health** (`syncItemReceiptFromNetsuite`): migrated off SOAP to
553
+ new REST `fetchItemReceiptById` (header + lines + serials via SuiteQL
554
+ `inventoryassignment ⋈ inventorynumber`, serials grouped per product) + `fetchPurchaseOrderById`
555
+ + `fetchItemFlagsByIds`; removed all `App_NetSuite::` SOAP calls (`getItemReceipt` /
556
+ `getPurchaseOrder` / `getItemIsSerialized` / `getInventoryNumber*`). Guards added;
557
+ **`RtnAuth` (return-authorization) item receipts are intentionally NOT imported** (explicit
558
+ documented skip), and an **unknown `createdFrom` type throws**.
559
+ - **Purchase orders** (`syncPurchaseOrderFromNetsuite`): a missing PO entity now **throws** (was an
560
+ unguarded crash); `getSalesOrder` → REST `fetchSalesOrderByIdFull`; a `2400` ACL-strip that used
561
+ to skip now **throws**. Threaded a new `array &$lookupWarehouseByNetsuiteLocationInternalId` param
562
+ from **both** callers — which also fixed a **null `arg13` `TypeError`** into
563
+ `syncSalesOrderFromNetsuite`.
564
+ - **Transfer orders — GroWrk `SALES_ORDERS`** (`syncTransferOrderFromNetsuite`): fixed an
565
+ all-catalog lookup that read `->uuid` off the array instead of `[0]`; an **unmapped NetSuite TO
566
+ status now throws** (was default-to-pending + log).
567
+ - **Item fulfillments — Elite (also Quad / S&P Global / Prudential-EDI)**
568
+ (`syncItemFulfillmentFromNetsuite`): the "Undefined index" on service/shipping lines with an
569
+ **empty `orderLine`** (`$lookupNsSalesOrderItemsByOrderLine['']`) is now guarded so it falls
570
+ through to the existing reconstruct path.
571
+ - **NYCHH stocking-order routing** — the `isStockingOrder` scriptId **case-mismatch** (full detail
572
+ in the [rest-shim doc](../../library/features/netsuite-suiteql-rest-shim.md)): the custom field was
573
+ tagged `CUSTBODY_STOCKING_ORDER` but looked up lowercase → always returned the default →
574
+ **every NYCHH order was mis-routed to a Transfer Order.** Fixed by lowercasing the scriptId in
575
+ both shim defs.
576
+ - **AIG lookup-phase crash** — `fetchCustomerById`'s `addressbookList` is now built via `shimAddress`
577
+ (country normalized to the `_unitedStates` enum, empty → `addressbook: []`); extracted
578
+ `countryToSoapEnum()` + `refString()` helpers, plus a belt-and-suspenders guard in
579
+ `common_sync_togasupply.php`.
580
+
581
+ - **SOAP→REST is now COMPLETE for the togasupply sync path (2026-08-29).** With the item-receipt and
582
+ (2026-08-29) inventory-adjustment migrations plus the new `fetch*ById` methods, **zero
583
+ `App_NetSuite::` (SOAP) network calls remain** anywhere in the `common_sync_togasupply.php` engine
584
+ or the `*FromNetsuite()` methods it calls — so the whole tier-wide-freeze class above is now driven
585
+ by REST-shim shape, not SOAP availability. New REST completeness pieces built in `library`:
586
+ `fetchInvoiceFile` (SuiteQL `file` lookup + REST `GET /record/v1/file/{id}` content, `base64_decode`
587
+ → raw bytes; replaces `getInvoiceFile`), `getItemSerializedAndFulfillable` → `fetchItemFlagsByIds`,
588
+ the `diagnose_hh` script's `getObject` → `fetchInventoryAdjustmentById`, and
589
+ `worker/crons/sync/netsuite/netsuite_customers.php` `listAllCustomers` → `listCustomers` (with
590
+ `companyName` added to the `listCustomers` shim).
591
+
592
+ - **⚠ SALES_ORDERS line renumber is now a validate-before-mutate TWO-PHASE UPDATE (2026-08-29) —
593
+ supersedes the delete-recreate + pinned-skip approach.** `syncSalesOrderFromNetsuite`'s renumber no
594
+ longer DELETEs and re-creates lines (nor keeps un-deletable/invoiced rows at their old number — see
595
+ the 2026-08-17 pinned-skip gotcha above, now historical). It **parks** every drifting line at a
596
+ temporary `lineNumber` (base = `max(current, target lineNumbers) + headroom`, headroom const
597
+ **1000**), then **finalizes** each to its target — so `UNIQUE(salesOrderId, lineNumber)` is never
598
+ transiently violated and pinned FK-RESTRICT children (invoiced/fulfilled lines) renumber accurately
599
+ via `UPDATE` instead of being stranded at a stale number. It **throws BEFORE any PUT** if a kept or
600
+ undeletable row occupies a needed target, so a failure never leaves the order in a partial state.
601
+ Note `SalesOrderItems.lineNumber` is a **`smallint`**, so the parked base must stay within smallint
602
+ range for the client's line counts (headroom 1000 is safe at observed volumes).
603
+
523
604
  ## Change history
524
605
 
606
+ - 2026-08-29 — **Root-caused the 5 stuck togasupply integrations as REST-shim SHAPE mismatches, not
607
+ dead SOAP, and fixed them.** SOAP was up; the earlier SOAP→REST conversion left shims returning
608
+ null/absent where SOAP had values, and a 1.0 unguarded read of that null → PHP warning →
609
+ `App_Error` `exit()` → the section frozen at `RUNNING`/watermark stuck (presents identically to a
610
+ poison order). Recorded the durable diagnosis playbook (map each stuck `clientId`/section to a crash
611
+ file:line via prod `Logs.Issue`/`Logs.Event`, the `App_Error_Capture` tables). Shipped per-client
612
+ `library` fixes, all fail-loud: item-receipts SOAP→REST (`fetchItemReceiptById` +
613
+ `fetchPurchaseOrderById` + `fetchItemFlagsByIds`, RtnAuth skip, unknown `createdFrom` throws — Canon
614
+ + Endeavor Health); PO missing-entity throw + `arg13` warehouse-lookup TypeError fix +
615
+ `fetchSalesOrderByIdFull` + 2400-ACL throw; TransferOrder all-catalog `[0]`/`->uuid` fix + unmapped
616
+ status throw (GroWrk); IF empty-`orderLine` "Undefined index" guard (Elite/Quad/S&P/Prudential-EDI);
617
+ the `isStockingOrder` scriptId case-mismatch that mis-routed **every** NYCHH order to a Transfer
618
+ Order; and the AIG `fetchCustomerById` address-shim crash. Also **completed SOAP→REST for the whole
619
+ togasupply path** (zero `App_NetSuite::` network calls remain; new `fetchInvoiceFile` /
620
+ `fetchItemFlagsByIds` / `fetchInventoryAdjustmentById`, `listAllCustomers`→`listCustomers`+companyName),
621
+ and replaced the SALES_ORDERS renumber with a **validate-before-mutate two-phase UPDATE** (park at
622
+ temp lineNumbers, then finalize; throws before any PUT) superseding the delete-recreate + pinned-skip
623
+ approach. (jcardinal)
624
+ - 2026-08-29 — The inventory-adjustment sync (and the on-demand fulfillment / sales-order import it
625
+ triggers) in `syncInventoryAdjustmentFromNetsuite` was **migrated SOAP → REST/SuiteQL**, closing the
626
+ SOAP round-trip cost flagged 2026-08-28; the remaining error-log-and-swallow `try/catch` in the
627
+ NetSuite→DB sync path were removed for loud failure (StarTech/Sentry catches untouched). The
628
+ migration required two `App_Api_Netsuite_Rest` shim fixes (a record-sublist `limit`/`offset` 400 and
629
+ the ItemShip 3-rows-per-item line filter) and moved the NYCHH adjustment↔fulfillment link off bridge
630
+ tables onto direct FK columns — see
631
+ [NYCHH inventory-adjustment → item-fulfillment linking](../../../clients/nychh/features/netsuite-inventory-adjustment-fulfillment-link.md).
632
+ (jcardinal)
633
+
525
634
  - 2026-08-28 — Recorded two shared-engine inventory-adjustment fixes in
526
635
  `syncInventoryAdjustmentFromNetsuite` (prune child `InventoryAdjustmentItemUnits` before the item —
527
636
  FK RESTRICT/1451/EV-11; index the item lookup **one-level** `[$partNumber]` to match what the cron
@@ -5,13 +5,14 @@ project: Library
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-21
8
+ updated: 2026-08-29
9
9
  owners: [jcardinal, rgirish, mhammontree, ajean]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/library/architecture.md
13
13
  - ../apps/library/features/email-queue-attachments.md
14
14
  - ../apps/library/features/error-capture-1-0.md
15
+ - ../apps/worker/features/netsuite-togasupply-per-client-sync.md
15
16
  - ../../2.0/standards/backend-php.md
16
17
  ---
17
18
 
@@ -507,9 +508,12 @@ Additional verified detail:
507
508
  - **Diagnostic fingerprint:** an occurrence count that is an exact multiple of the job's
508
509
  scheduled frequency (e.g. 20/hour for `*/3 * * * *`) means **one** poisoned item retried,
509
510
  not many distinct failures.
510
- - **In job code, prevention beats containment.** Guard optional data with `isset()` before
511
- reading it, and consume/ack the item in a `finally` (or under a `catch (Throwable)`) so a
512
- genuine exception cannot strand it. You cannot wrap your way out of a notice.
511
+ - **In job code, prevention beats containment for a warning.** Guard optional data with
512
+ `isset()` before reading it — you cannot wrap your way out of a notice. This is about the
513
+ uncatchable-warning mechanics above; it is **not** license to ack-and-continue past a genuine
514
+ (catchable) exception in a reconciliation loop. See *Fail-loud is the default for
515
+ import/sync/cron code* below before reaching for a `finally`/`catch (Throwable)` ack — that
516
+ pattern caused a real data-loss incident in this exact engine.
513
517
 
514
518
  > **Existing guidance under review.** An earlier version of this section recommended
515
519
  > wrapping fragile I/O in `try { ... } catch (\ErrorException $e)` relying on the GLOBAL
@@ -521,6 +525,59 @@ Additional verified detail:
521
525
  > (~L112-123), `clients/prudential/profile.md` (~L75). Prefer the local-handler pattern
522
526
  > from the canonical section above for anything new.
523
527
 
528
+ ### Fail-loud is the default for import/sync/cron code — never catch-and-continue or log-and-skip a record
529
+
530
+ **This is the standing policy for reconciliation jobs; it supersedes the "consume/ack in a
531
+ `finally`" framing in the section above wherever the two would conflict.** For import/sync/cron
532
+ code that reconciles data from an external system into ours — the NetSuite → TOGa Supply sync
533
+ (`worker/crons/toga2/netsuite/`) is the canonical example — the rule is: **if anything prevents
534
+ the job from producing accurate data, let it throw.** The exception must propagate and abort the
535
+ run. Do not:
536
+
537
+ - Wrap a per-record dispatch in `try { … } catch (Throwable) { error_log(...) }` and move on to
538
+ the next record.
539
+ - Wrap a section/job in `try { … } finally { <advance the checkpoint> }` so the checkpoint still
540
+ moves even when the try body threw.
541
+ - Downgrade a hard failure to a log line and keep going "because most records succeeded."
542
+
543
+ **Why.** These jobs track progress with an execution-mode flag plus a watermark/cursor (e.g.
544
+ `NETSUITE_EXECUTION_MODE_*` / `NETSUITE_LAST_SYNC_DATETIME_*`). A thrown, unhandled exception
545
+ leaves that state at **RUNNING** with the watermark **not advanced** past the failure — visible
546
+ proof the run failed, the record still owes a retry, and a developer needs to look. Catching the
547
+ exception and continuing does the opposite: it drops the record, silently advances the checkpoint
548
+ past it (the loss becomes **permanent**, not retried), and can even reset the mode back to
549
+ **IDLE**, telling the next run "everything's fine" when it isn't. **Data accuracy is prioritized
550
+ over availability here — even for a failure that is not auto-retriable** (an ACL/permission
551
+ error, a malformed upstream record, a constraint violation). A run stuck at RUNNING that needs a
552
+ developer to reconcile it is the correct, intended outcome; a clean-looking IDLE run that quietly
553
+ dropped data is not.
554
+
555
+ **The one legitimate exception is a true message/inbox queue** — a loop reading items one at a
556
+ time from something like an IMAP inbox or an SQS queue, where an item that is never acknowledged
557
+ blocks every item behind it forever. There, and only there, consuming/acking the poison item so
558
+ the queue can move past it is defensible — and even then, **prefer surfacing** the failure
559
+ (dead-letter it, alert on it, record it against the item) over silently discarding it. A
560
+ reconciliation cron with its own per-client cursor is **not** this case: nothing blocks behind a
561
+ RUNNING watermark except that one client's own next run, which is exactly what the window
562
+ grow/shrink behavior is designed around.
563
+
564
+ **Real incident.** On 2026-08-20 a change wrapped every per-order dispatch across all six
565
+ NetSuite → TOGa Supply sections in `catch (Throwable) { error_log }` and wrapped each section in
566
+ `try { … } finally { finishModeIteration }`. It looked like an improvement — "don't let one bad
567
+ order jam the sync" — but it silently dropped any record whose write failed, advanced the
568
+ watermark past it, and reset the mode to IDLE, so a failed run looked identical to a successful
569
+ one. This was found and reverted 2026-08-27 (all per-order catches and section-level
570
+ `try/finally` removed — a failure now aborts the section with the mode left RUNNING), and a
571
+ related error-log-and-swallow path in the same engine's inventory-adjustment section was removed
572
+ again 2026-08-29 during its SOAP→REST migration. Full detail: [NetSuite → TOGa Supply Per-Client
573
+ Sync](../apps/worker/features/netsuite-togasupply-per-client-sync.md).
574
+
575
+ This does not relax the "any PHP warning terminates the request" mechanics above, or the two
576
+ sanctioned local opt-outs for a **notice/warning** you must genuinely contain (a monitoring ping,
577
+ a defensively-guarded optional read). Those exist to survive an *uncatchable warning* without
578
+ crashing something that must not crash. This rule is about a **catchable exception** in a
579
+ reconciliation loop, where surviving it quietly is exactly the wrong behavior.
580
+
524
581
  ### Test mode silently disables queued email — non-prod cannot validate a queue bug
525
582
 
526
583
  `App_Email::setQueueForSending()` (`library/app/email.php` ~L161) is gated on
@@ -5,12 +5,13 @@ project: Library
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-06
8
+ updated: 2026-08-29
9
9
  owners: [jcardinal, ajean]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/library/architecture.md
13
13
  - ../apps/worker/architecture.md
14
+ - backend-php.md
14
15
  ---
15
16
 
16
17
  # Framework 1.0 (App_) Rules
@@ -150,3 +151,12 @@ public function processOrder(): void {
150
151
  $this->response->success($result);
151
152
  }
152
153
  ```
154
+
155
+ **This pattern is scoped to a single request/response unit of work — it does not extend to
156
+ import/sync/cron code.** Above, catching stops the *whole* action and returns one error response;
157
+ nothing after the catch runs "successfully" on partial data. A batch/reconciliation job (a cron
158
+ looping over many external records, advancing a cursor/watermark) is a different shape: catching
159
+ per-record there and continuing to the next one is exactly the anti-pattern that caused a
160
+ data-loss incident in the NetSuite → TOGa Supply sync. For that class of code, see
161
+ `1.0/standards/backend-php.md` § *Fail-loud is the default for import/sync/cron code* — the job
162
+ must throw and abort, not catch-and-continue.
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-28
10
- owners: ["mhammontree", "dfranks", "bala"]
9
+ updated: 2026-08-29
10
+ owners: ["mhammontree", "dfranks", "bala", "jcardinal"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - api2/Controller/Index.php
@@ -175,6 +175,14 @@ There is no usable index on those columns. Do not start from a payload search.
175
175
 
176
176
  The working pattern, in order:
177
177
 
178
+ > **`route` values are stored WITH the `/v2` prefix** (e.g. `/v2/inventory-adjustments/{uuid}`,
179
+ > `/v2/item-fulfillment-items`), so any narrowing predicate must filter `route LIKE '/v2/...'` — a
180
+ > bare `/inventory-adjustments` matches **nothing**. `route` also carries `requestPayload` +
181
+ > `responsePayload` + `responseCode` per row, so once narrowed the log gives the full request/response
182
+ > for a call **without NetSuite access** — it was the decisive tool for confirming a V2 PUT persisted
183
+ > a field its response echo omitted (see the [V2 query contract](v2-rest-query-contract.md) PUT-echo
184
+ > gotcha).
185
+
178
186
  1. **Narrow with indexed / non-text columns only** — a `dtStamp` range plus `route`,
179
187
  `method`, `responseCode`, `hostname`. A
180
188
  `GROUP BY hostname, route, method, responseCode` over one day is cheap and is the fastest way
@@ -217,6 +225,12 @@ re-send.
217
225
 
218
226
  ## Change history
219
227
 
228
+ - 2026-08-29 — Noted that `Logs.Api.route` values are stored **with the `/v2` prefix** (e.g.
229
+ `/v2/inventory-adjustments/{uuid}`), so narrowing queries must filter `route LIKE '/v2/...'` — a
230
+ bare `/inventory-adjustments` predicate matches nothing. Reinforced that a narrowed row's
231
+ `requestPayload`/`responsePayload` is the decisive, NetSuite-access-free tool for diagnosing V2
232
+ write behavior — here, confirming a V2 PUT persisted a field its response echo omitted (NYCHH
233
+ inventory-adjustment → item-fulfillment linking). (jcardinal)
220
234
  - 2026-08-13 — **Important limit on the "an HTTP 400 leaves a full log row" rule** (TRUE-80412):
221
235
  `execute()` writes the transaction log near its **end** (~L2225-2271), *after* route processing
222
236
  (~L1742-2043), so **a throw from a PRE interceptor produces no `Logs.Api` row at all** — the
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-28
10
- owners: [tcox, bala, apeterson]
9
+ updated: 2026-08-29
10
+ owners: [tcox, bala, apeterson, jcardinal]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  related:
@@ -108,6 +108,19 @@ calculated field is null on a nested object but correct when that object is fetc
108
108
  Definition of the fields themselves:
109
109
  [FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
110
110
 
111
+ ### ⚠ A non-null FK object at the MAX requested `depth` is OMITTED from the response
112
+
113
+ A GET serialized to `depth: N` renders FK objects down to level N, but a **non-null FK object that
114
+ sits at level N+1** — one hop *past* the max — is **dropped from the payload with no error**. It is
115
+ easy to miss because a **null** sibling FK still renders as `null`, so the omission reads like "that
116
+ FK is null" rather than "that FK was not serialized." Observed on `GET /item-fulfillment-items` at
117
+ `depth: 2`: the grandchild `item` (level 3 — `itemFulfillmentItem → salesOrderItem → item`) was
118
+ absent, so a product-match keyed on `salesOrderItem.item.uuid` came back empty and no link was
119
+ written. **Fix: request `depth: N+1`, or name the nested path explicitly in `fields`** (e.g.
120
+ `salesOrderItem.item.uuid` / `transferOrderItem.item.uuid`). Rule of thumb: the deepest FK you
121
+ actually read must sit at least one level *inside* `depth`, not on the boundary. Surfaced building
122
+ [NYCHH inventory-adjustment → item-fulfillment linking](../../../../clients/nychh/features/netsuite-inventory-adjustment-fulfillment-link.md).
123
+
111
124
  ### What nests by default is `InherentRecordChildren` — everything else needs the nested ROUTE
112
125
 
113
126
  A record's one-to-many children arrive inside the record response **only** if `Core.
@@ -271,8 +284,24 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
271
284
  - **`gte`/`lte` silently are not operators** — use `ge`/`le`.
272
285
  - **Do not trust a 200 with missing fields.** Without an explicit `fields=` list, ACL removal is
273
286
  invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
287
+ - **⚠ A PUT response ECHO can omit a field that DID persist — verify against the DB, not the echo.**
288
+ A `PUT /v2/inventory-adjustments/{uuid}` with `{"itemFulfillment":{"uuid":…}}` returned **200 with
289
+ an empty `messages[]`** and an echo that did **not** contain `itemFulfillment`, yet the FK column
290
+ **persisted correctly**. An absent field in the write-response echo is **not** proof the write
291
+ failed — confirm the field against the record's DB row (or the `Logs_<Client>.Api` `responsePayload`),
292
+ never from the write's own echoed body. See
293
+ [V2 request logging](request-logging.md).
274
294
 
275
295
  ## Change history
296
+ - 2026-08-29 — Recorded two response-shape facts from the NYCHH inventory-adjustment REST work: **a
297
+ non-null FK object one hop past the max `depth` is silently omitted** (a null sibling FK still
298
+ renders, so it reads as "null" not "missing") — `GET /item-fulfillment-items` at `depth:2` dropped
299
+ the level-3 `salesOrderItem.item`; fix with `depth+1` or an explicit nested `fields` path — and a
300
+ **PUT response echo can omit a field that persisted** (a `PUT /v2/inventory-adjustments/{uuid}`
301
+ returned 200 / empty-`messages` without echoing `itemFulfillment` though the FK saved), so verify a
302
+ write against the DB / `Logs_<Client>.Api`, not the echo. See
303
+ [NYCHH inventory-adjustment → item-fulfillment linking](../../../../clients/nychh/features/netsuite-inventory-adjustment-fulfillment-link.md).
304
+ (jcardinal)
276
305
  - 2026-08-28 — Documented two response-shape facts hit while building the Transfer Orders modal.
277
306
  **`calcDepth` defaults to 1**, so a `FIELD_SQL` calculated field on a record one FK hop out returns
278
307
  `null` with **no** message (Location's `_address*` fields needed `calcDepth: 2`) — raise it one per
@@ -5,13 +5,15 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-25
8
+ updated: 2026-08-29
9
9
  owners: [jcardinal, mhammontree, dfranks, bala]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/_underscore/architecture.md
13
13
  - ../apps/api2/architecture.md
14
14
  - ../apps/worker2/architecture.md
15
+ - framework-rules.md
16
+ - ../../1.0/standards/backend-php.md
15
17
  ---
16
18
 
17
19
  ## General Principals
@@ -677,6 +679,14 @@ $c = 'Hello ' . $world;
677
679
  ### PHP Error Handling
678
680
 
679
681
  * Always use exceptions to handle unexpected conditions.
682
+ * **Import/sync/cron code that reconciles external data must fail loud — never catch-and-continue
683
+ or log-and-skip a record.** A mid-loop failure must throw and abort the job, leaving its
684
+ checkpoint/watermark unmoved for retry/investigation, rather than being caught, logged, and
685
+ stepped over. See `2.0/standards/framework-rules.md` § *The above is for a per-entity job — a
686
+ reconciliation/import worker that loops over many records is different, and must fail loud* and
687
+ `1.0/standards/backend-php.md` § *Fail-loud is the default for import/sync/cron code* (the
688
+ policy this corrects, after a per-order catch-and-continue change caused a data-loss incident in
689
+ the NetSuite → TOGa Supply sync).
680
690
 
681
691
  ### Choosing an exception class is a ROUTING decision, not style
682
692
 
@@ -946,6 +956,10 @@ See: 2.0/apps/worker2/features/cross-account-aws-access.md
946
956
 
947
957
  ## Change history
948
958
 
959
+ - 2026-08-29 — Added a fail-loud cross-reference under **PHP Error Handling**: import/sync/cron
960
+ code that reconciles external data must never catch-and-continue or log-and-skip a record;
961
+ points to the full policy and the NetSuite → TOGa Supply sync data-loss incident recorded in
962
+ `framework-rules.md` and `1.0/standards/backend-php.md`. (jcardinal)
949
963
  - 2026-08-25 — Clarified the exception-routing rule: `_Exception_Validation` is a valid 400 throw
950
964
  site from a **scripted-API method** too, not only a pre/post interceptor or model op. Verified at
951
965
  the outer `execute()`/`api()` catch in `api2/Controller/Index.php` (~L317) where
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-28
8
+ updated: 2026-08-29
9
9
  owners: [jcardinal, ajean, apeterson]
10
10
  files: []
11
11
  related:
@@ -14,6 +14,8 @@ related:
14
14
  - ../apps/api2/architecture.md
15
15
  - ../apps/dbchanges2/architecture.md
16
16
  - ../apps/dbchanges2/features/surface-layer-schema.md
17
+ - backend-php.md
18
+ - ../../1.0/standards/backend-php.md
17
19
  ---
18
20
 
19
21
  # Framework 2.0 (_underscore) Rules
@@ -298,6 +300,23 @@ returns nothing (or returns only when it succeeds) leaves a blind spot in the au
298
300
  only for genuinely retriable conditions, where the queue's retry is the intended behaviour; a
299
301
  handled non-retriable failure should return a result describing what happened rather than throwing.
300
302
 
303
+ ### The above is for a per-entity job — a reconciliation/import worker that loops over many records is different, and must fail loud
304
+
305
+ `ProcessOrder(int $orderId)` above is scoped to **one** record: the job either succeeds or fails
306
+ as a whole, and "log and return a failure result" for a non-retriable error means *this job*
307
+ (this one order) is done — no other record is silently dropped alongside it. Do not generalize
308
+ that shape to a worker action that **internally loops over many records to reconcile external
309
+ data** (a NetSuite import, a bulk sync, anything advancing its own cursor/watermark across a
310
+ batch). For that shape, catching a per-record failure inside the loop and moving on to the next
311
+ record is the same anti-pattern documented in `1.0/standards/backend-php.md` § *Fail-loud is the
312
+ default for import/sync/cron code* — it silently drops the record and can leave the job's own
313
+ progress-tracking (checkpoint/cursor) looking healthy when it isn't. A batch/reconciliation worker
314
+ must let a mid-loop failure **throw and abort the job**, leaving its checkpoint unmoved so the
315
+ queue's retry (or a developer) picks up where it actually left off — never catch-per-record and
316
+ continue. Data accuracy outranks job "completion," even when the failure is not retriable (e.g. an
317
+ ACL error on one record) — a developer reconciling a stalled job is the intended outcome, not a
318
+ job that reports success having quietly skipped records.
319
+
301
320
  ## `c_`-prefixed custom columns are framework-dynamic
302
321
 
303
322
  Columns prefixed `c_` are **custom/dynamic** columns that the `_underscore` model layer handles
@@ -317,6 +336,14 @@ and not how the convention works. Use a non-`c_` column when you *do* want the f
317
336
  ACL-governed field treatment.
318
337
 
319
338
  ## Change history
339
+ - 2026-08-29 — Added a distinction under **Error handling in workers**: the documented
340
+ `ProcessOrder`-style per-entity pattern (catch non-retriable, log, return a failure result) does
341
+ **not** extend to a worker action that loops over many records to reconcile external data (a
342
+ NetSuite import, a bulk sync). That shape must fail loud — a mid-loop failure throws and aborts
343
+ the job so its checkpoint/cursor stays unmoved — matching the fail-loud policy corrected in
344
+ `1.0/standards/backend-php.md` after a per-order catch-and-continue change caused a silent
345
+ data-loss incident in the NetSuite → TOGa Supply sync (found 2026-08-20, reverted 2026-08-27,
346
+ final swallow removed 2026-08-29). (jcardinal)
320
347
  - 2026-08-28 — Extended § Schema migrations with the production-state rules. Recorded that cluster
321
348
  isolation is **physical** (verified: the prod Core reader exposes only Core/Forecast/
322
349
  Forecast_Archive/Team, the client reader only `Client_*`), so a cross-database reference cannot
@@ -46,9 +46,11 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
46
46
  ## Clients
47
47
 
48
48
  - **AIG (Staples Protection Plan)** (`aig`) → [clients/aig/INDEX.md](clients/aig/INDEX.md)
49
+ - **Canon** (`canon`) → [clients/canon/INDEX.md](clients/canon/INDEX.md)
49
50
  - **Compass Canada** (`compass-canada`) → [clients/compass-canada/INDEX.md](clients/compass-canada/INDEX.md)
50
51
  - **Compass USA** (`compass-usa`) → [clients/compass-usa/INDEX.md](clients/compass-usa/INDEX.md)
51
52
  - **Elite** (`elite`) → [clients/elite/INDEX.md](clients/elite/INDEX.md)
53
+ - **Endeavor Health** (`endeavor-health`) → [clients/endeavor-health/INDEX.md](clients/endeavor-health/INDEX.md)
52
54
  - **Fordham University** (`fordham`) → [clients/fordham/INDEX.md](clients/fordham/INDEX.md)
53
55
  - **GroWrk** (`growrk`) → [clients/growrk/INDEX.md](clients/growrk/INDEX.md)
54
56
  - **Northwell Health** (`northwell`) → [clients/northwell/INDEX.md](clients/northwell/INDEX.md)
@@ -60,6 +62,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
60
62
  - **Quad Graphics** (`quad`) → [clients/quad/INDEX.md](clients/quad/INDEX.md)
61
63
  - **Rate** (`rate`) → [clients/rate/INDEX.md](clients/rate/INDEX.md)
62
64
  - **Richmond University Medical Center** (`rumcsi`) → [clients/rumcsi/INDEX.md](clients/rumcsi/INDEX.md)
65
+ - **S&P Global** (`spglobal`) → [clients/spglobal/INDEX.md](clients/spglobal/INDEX.md)
63
66
  - **Staples** (`staples`) → [clients/staples/INDEX.md](clients/staples/INDEX.md)
64
67
  - **Tow Foundation** (`tow-foundation`) → [clients/tow-foundation/INDEX.md](clients/tow-foundation/INDEX.md)
65
68
  - **TOGA Technology** (`true`) → [clients/true/INDEX.md](clients/true/INDEX.md)
@@ -6,13 +6,14 @@ apps:
6
6
  - api2
7
7
  - dbchanges2
8
8
  - library
9
+ - worker
9
10
  - worker2
10
11
  project: API
11
12
  client: aig
12
13
  type: profile
13
14
  status: active
14
- updated: 2026-08-17
15
- owners: ["mhammontree"]
15
+ updated: 2026-08-29
16
+ owners: ["mhammontree", "jcardinal"]
16
17
  files: []
17
18
  related:
18
19
  - clients/aig/features/entitlement-intake.md
@@ -0,0 +1,5 @@
1
+ # Client: Canon `canon`
2
+
3
+ | Doc | Framework | Summary | Files |
4
+ |-----|-----------|---------|-------|
5
+ | [Canon](profile.md) | 2.0 | Canon is a **TOGa-Supply NetSuite integration client** on the shared 1.0 NetSuite → TOGa Supply per-client sync (`worker` + `library`). | |
@@ -0,0 +1,34 @@
1
+ ---
2
+ title: "Canon"
3
+ framework: "2.0"
4
+ apps:
5
+ - library
6
+ - worker
7
+ project: Worker
8
+ client: canon
9
+ type: profile
10
+ status: active
11
+ updated: 2026-08-29
12
+ owners: ["jcardinal"]
13
+ files: []
14
+ related:
15
+ - ../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
16
+ ---
17
+
18
+ ## Summary
19
+
20
+ Canon is a **TOGa-Supply NetSuite integration client** on the shared 1.0 NetSuite → TOGa Supply
21
+ per-client sync (`worker` + `library`). Its thin wrapper
22
+ `worker/crons/toga2/netsuite/sync_togasupply_canon.php` is the **canonical example** referenced by
23
+ the shared-engine doc — copy it when onboarding a new client. NetSuite transactions (sales orders,
24
+ POs, invoices, item receipts, item fulfillments, inventory adjustments) sync into Canon's 2.0
25
+ `Client_Canon` tenant through the TOGa2 API.
26
+
27
+ This profile was seeded during the 2026-08-29 root-cause work that fixed Canon's stuck
28
+ `ITEM_RECEIPTS` section — the item-receipt path was migrated SOAP → REST (`fetchItemReceiptById`);
29
+ full mechanics and the diagnosis playbook live in
30
+ [NetSuite → TOGa Supply per-client sync](../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
31
+
32
+ ## Integration touchpoints
33
+
34
+ - **`worker` / `library` (1.0)** — the shared togasupply sync engine + `App_Api_Toga2` bridge.
@@ -0,0 +1,5 @@
1
+ # Client: Endeavor Health `endeavor-health`
2
+
3
+ | Doc | Framework | Summary | Files |
4
+ |-----|-----------|---------|-------|
5
+ | [Endeavor Health](profile.md) | 2.0 | Endeavor Health is a **TOGa-Supply NetSuite integration client** on the shared 1.0 NetSuite → TOGa Supply per-client sync (`worker` + `library`). | |
@@ -0,0 +1,32 @@
1
+ ---
2
+ title: "Endeavor Health"
3
+ framework: "2.0"
4
+ apps:
5
+ - library
6
+ - worker
7
+ project: Worker
8
+ client: endeavor-health
9
+ type: profile
10
+ status: active
11
+ updated: 2026-08-29
12
+ owners: ["jcardinal"]
13
+ files: []
14
+ related:
15
+ - ../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
16
+ ---
17
+
18
+ ## Summary
19
+
20
+ Endeavor Health is a **TOGa-Supply NetSuite integration client** on the shared 1.0 NetSuite → TOGa
21
+ Supply per-client sync (`worker` + `library`). NetSuite transactions sync into Endeavor's 2.0
22
+ `Client_<Id>` tenant through the TOGa2 API via a thin
23
+ `worker/crons/toga2/netsuite/sync_togasupply_*.php` wrapper.
24
+
25
+ This profile was seeded during the 2026-08-29 root-cause work that fixed Endeavor's stuck
26
+ `ITEM_RECEIPTS` section — the item-receipt path was migrated SOAP → REST (`fetchItemReceiptById`);
27
+ full mechanics and the diagnosis playbook live in
28
+ [NetSuite → TOGa Supply per-client sync](../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
29
+
30
+ ## Integration touchpoints
31
+
32
+ - **`worker` / `library` (1.0)** — the shared togasupply sync engine + `App_Api_Toga2` bridge.
@@ -2,7 +2,7 @@
2
2
 
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
- | [NYCHH inventory-adjustment → item-fulfillment linking (custbody_stock_adjustment bridges)](features/netsuite-inventory-adjustment-fulfillment-link.md) | 1.0 | For NYCHH stock adjustments, the NetSuite **inventory adjustment** carries a link to the **Item Fulfillment** it corrects, on the multi-select body custom field | library/app/api/toga2.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_hh.php, worker/crons/toga2/netsuite/diagnose_hh_import_inventory_adjustment.php, _underscore/Model/Client/ItemFulfillments/InventoryAdjustment.php, _underscore/Model/Client/ItemFulfillmentItems/InventoryAdjustmentItem.php, dbchanges2/Core/2026-08-27b - ItemFulfillmentsInventoryAdjustmentsBridge.sql, dbchanges2/Core/2026-08-28b - ItemFulfillmentItemsInventoryAdjustmentItemsBridge.sql, dbchanges2/Client/2026-08-27a - ItemFulfillmentsInventoryAdjustmentsBridgeTable.sql, dbchanges2/Client/2026-08-28c - ItemFulfillmentItemsInventoryAdjustmentItemsBridgeTable.sql |
5
+ | [NYCHH inventory-adjustment → item-fulfillment linking (direct FK columns, REST)](features/netsuite-inventory-adjustment-fulfillment-link.md) | 1.0 | For NYCHH stock adjustments, the NetSuite **inventory adjustment** carries a link to the **Item Fulfillment** it corrects, on the multi-select body custom field | library/app/api/toga2.php, library/app/api/netsuite/rest.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_hh.php, worker/crons/toga2/netsuite/diagnose_hh_import_inventory_adjustment.php |
6
6
  | [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, worker/crons/toga2/netsuite/diagnose_hh_stuck_transfer_order.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, dbchanges2/Client/2026-08-28a - TransferOrderItemsTimestampColumns.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestampColumnsReposition.sql |
7
7
  | [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 |
8
8
  | [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, _underscore/Model/Client/TransferOrderItem.php, dbchanges2/Client_Nychh/2026-08-27a - TransferOrderCustomFieldWritePermission.sql, dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql |
@@ -1,29 +1,28 @@
1
1
  ---
2
- title: "NYCHH inventory-adjustment → item-fulfillment linking (custbody_stock_adjustment bridges)"
2
+ title: "NYCHH inventory-adjustment → item-fulfillment linking (direct FK columns, REST)"
3
3
  framework: "1.0"
4
4
  repo: library
5
5
  project: Library
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-28
9
+ updated: 2026-08-29
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - library/app/api/toga2.php
13
+ - library/app/api/netsuite/rest.php
13
14
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
14
15
  - worker/crons/toga2/netsuite/sync_togasupply_hh.php
15
16
  - worker/crons/toga2/netsuite/diagnose_hh_import_inventory_adjustment.php
16
- - _underscore/Model/Client/ItemFulfillments/InventoryAdjustment.php
17
- - _underscore/Model/Client/ItemFulfillmentItems/InventoryAdjustmentItem.php
18
- - dbchanges2/Core/2026-08-27b - ItemFulfillmentsInventoryAdjustmentsBridge.sql
19
- - dbchanges2/Core/2026-08-28b - ItemFulfillmentItemsInventoryAdjustmentItemsBridge.sql
20
- - dbchanges2/Client/2026-08-27a - ItemFulfillmentsInventoryAdjustmentsBridgeTable.sql
21
- - dbchanges2/Client/2026-08-28c - ItemFulfillmentItemsInventoryAdjustmentItemsBridgeTable.sql
22
17
  related:
23
18
  - ./netsuite-transfer-order-import.md
24
19
  - ./transfer-order-inventory-quantities.md
25
20
  - ../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
26
21
  - ../../../1.0/apps/library/features/toga2-api-client-and-bridge.md
22
+ - ../../../1.0/apps/library/features/netsuite-suiteql-api-reference.md
23
+ - ../../../1.0/apps/library/features/netsuite-suiteql-rest-shim.md
24
+ - ../../../2.0/apps/api2/features/v2-rest-query-contract.md
25
+ - ../../../2.0/apps/api2/features/request-logging.md
27
26
  - ../../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md
28
27
  - ../profile.md
29
28
  ---
@@ -32,86 +31,134 @@ related:
32
31
 
33
32
  For NYCHH stock adjustments, the NetSuite **inventory adjustment** carries a link to the **Item
34
33
  Fulfillment** it corrects, on the multi-select body custom field **`custbody_stock_adjustment`**
35
- (internalId **9735**). This feature imports that link into TOGa Supply as two new bridge tables
36
- (header + line), so H&H gets **adjustment → item fulfillment → sales order → purchase order**
37
- traceability for their PO-specific fulfillment. Per the **2026-08-27 TOGa Supply meeting**; the
38
- NetSuite custom field was implemented **<1 year ago**, so older adjustments carry **no** link.
39
-
40
- Like [NYCHH transfer-order import](./netsuite-transfer-order-import.md), the **tables and models are
41
- all-client infrastructure** (`dbchanges2/Client/` + shared `_Model_Client_*` bridge models) but the
42
- **attach is gated NYCHH-only** by `IS_ENABLED_TRANSFER_ORDER_STOCKING_FLAG_ROUTING`.
43
-
44
- ## The two bridges (both mirror the transfer-order bridge pattern)
45
-
46
- | Level | Bridge table | `Core.Records` id | `RecordFields` | Model |
47
- |---|---|---|---|---|
48
- | Header | `ItemFulfillments_InventoryAdjustments` | 353 | 2515–2518 | `_Model_Client_ItemFulfillments_InventoryAdjustment` |
49
- | Item | `ItemFulfillmentItems_InventoryAdjustmentItems` | 354 | 2519–2522 | `_Model_Client_ItemFulfillmentItems_InventoryAdjustmentItem` |
50
-
51
- Each shipped as: an all-client `Client/` bridge table, Core registration (`Records` + `RecordFields`),
52
- a Super-User ACL grant, and a per-client ACL **copied from the parent record**. The parent record
53
- uuids are already `isIdentifier = 1`, so — unlike the transfer-order bridges — **no `EV-12`
54
- uuid-identifier fix was needed** (contrast the 312/313 anomaly on
55
- [the ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md)).
56
-
57
- > **Bridge FK writes via a nested `{uuid}` need only record-level ACL, not field-level.** Verified
58
- > against the existing transfer-order bridges — the nested-write ACL check is at the record level for a
59
- > bridge link, so no `AclCustomFieldPermissions` grant is required on the FK columns.
34
+ (internalId **9735**). This feature imports that link into TOGa Supply so H&H gets **adjustment →
35
+ item fulfillment → sales order → purchase order** traceability for their PO-specific fulfillment.
36
+ Per the **2026-08-27 TOGa Supply meeting**; the NetSuite custom field was implemented **<1 year
37
+ ago**, so older adjustments carry **no** link.
38
+
39
+ **Current design (2026-08-29): direct FK columns, over REST.** The link is now stored as **two
40
+ direct FK columns** — `InventoryAdjustments.itemFulfillmentId` and
41
+ `InventoryAdjustmentItems.itemFulfillmentItemId` — **replacing** the two bridge tables the
42
+ prior-session build used (`ItemFulfillments_InventoryAdjustments` /
43
+ `ItemFulfillmentItems_InventoryAdjustmentItems`). And the whole inventory-adjustment sync — plus the
44
+ on-demand fulfillment/sales-order import it triggers — was **migrated from SOAP to REST** for speed
45
+ (the SOAP path was ~2 NetSuite round-trips per serial, ~1h for a large serialized adjustment).
46
+
47
+ Like [NYCHH transfer-order import](./netsuite-transfer-order-import.md), the FK columns and models
48
+ are **all-client infrastructure** but the **attach is gated NYCHH-only** by
49
+ `IS_ENABLED_TRANSFER_ORDER_STOCKING_FLAG_ROUTING`.
50
+
51
+ ## The two FK links (replaced the header/line bridge tables)
52
+
53
+ | Level | FK column | `Core.Records` | `Core.RecordFields` |
54
+ |---|---|---|---|
55
+ | Header | `InventoryAdjustments.itemFulfillmentId` | 154 (InventoryAdjustments) | reuses 2517 |
56
+ | Item | `InventoryAdjustmentItems.itemFulfillmentItemId` | 155 (InventoryAdjustmentItems) | reuses 2521 |
57
+
58
+ The FK columns **reuse the `RecordFields` already registered/granted from the earlier bridge work**
59
+ (2517 on record 154, 2521 on record 155), so this change needed **no new Core registration or ACL
60
+ grant** — the bridge tables (records 353/354) and their per-client copies are superseded. Writing a
61
+ link is now a plain PUT of the FK uuid onto the parent record, not a bridge-row insert.
60
62
 
61
63
  ## Attach code — `App_Api_Toga2::attachInventoryAdjustmentToItemFulfillment`
62
64
 
63
65
  Lives in `library/app/api/toga2.php`, called from `syncInventoryAdjustmentFromNetsuite` **after the
64
- header upsert**, gated on `IS_ENABLED_TRANSFER_ORDER_STOCKING_FLAG_ROUTING`, and wrapped in
65
- `try/catch` — it is **best-effort and must never freeze the section** (a failed link is logged, the
66
- adjustment import still completes). It:
66
+ header upsert**, gated on `IS_ENABLED_TRANSFER_ORDER_STOCKING_FLAG_ROUTING`. It:
67
67
 
68
68
  1. reads `custbody_stock_adjustment` off the adjustment (the related Item Fulfillment),
69
69
  2. resolves that fulfillment by `ItemFulfillments.c_netsuiteInternalItemFulfillmentId`,
70
- 3. idempotently creates the **header** link row, then
71
- 4. links **line items** by matching `itemId` — every same-product adjustment-item ↔ fulfillment-item
72
- pair is linked; the bridge's `UNIQUE(fk, fk)` dedupes re-runs.
73
-
74
- ## On-demand dependency import (fulfillment not yet in TOGA)
75
-
76
- When the linked fulfillment has **not** been imported into TOGA yet, the attach no longer skips and
77
- waits for the fulfillment backfill — it **imports the fulfillment on demand** via
78
- `syncItemFulfillmentFromNetsuite` (which itself cascades to the originating sales order when missing),
79
- then **re-resolves and links**.
80
-
81
- This required threading the **full item-fulfillment lookup set** — customer, country, state, location,
82
- manufacturer, item, all-catalog-items, vendor-item, shipping-carrier, shipping-method, warehouse —
83
- through `syncInventoryAdjustmentFromNetsuite` (signature **expanded 5 → 13 args**) and into
84
- `attachInventoryAdjustmentToItemFulfillment`, plus updating the `common_sync_togasupply.php`
85
- `INVENTORY_ADJUSTMENTS` call to pass them (the same set already passed to the item-fulfillment
86
- section). **Design rule (developer instruction): all lookups are passed function-to-function**, like
87
- the other sync functions — not rebuilt or fetched inside the callee.
88
-
89
- ## Two fixes in `syncInventoryAdjustmentFromNetsuite` (shared engine, surfaced by the harness)
90
-
91
- Both are in the shared library method, so they affect **any** client running inventory adjustments —
92
- see also the [per-client sync](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md):
70
+ 3. writes the **header** FK (`InventoryAdjustments.itemFulfillmentId`), then
71
+ 4. writes the **line** FK (`InventoryAdjustmentItems.itemFulfillmentItemId`) for each
72
+ same-product adjustment-item ↔ fulfillment-item pair, matched by product `item` uuid.
73
+
74
+ ### ⚠ Reading the product uuid needs `depth=3` (the max-depth omission trap)
75
+
76
+ Step 4's product match reads `salesOrderItem->item->uuid` from `GET /item-fulfillment-items`. The
77
+ V2 read engine **omits a non-null FK object that sits at the max requested `depth`** — and a null
78
+ sibling FK still renders, so the omission is easy to miss. At `depth=2` the grandchild `item`
79
+ (level 3: `itemFulfillmentItem → salesOrderItem → item`) was dropped → the product-match map was
80
+ empty → **no `itemFulfillmentItemId` was written**. Fix: request **`depth=3`** *plus* explicit
81
+ nested fields `salesOrderItem.item.uuid` / `transferOrderItem.item.uuid`. Framework detail:
82
+ [V2 REST query contract — max-depth FK omission](../../../2.0/apps/api2/features/v2-rest-query-contract.md).
83
+
84
+ ### ⚠ The FK write's PUT echo may not reflect what persisted
85
+
86
+ A `PUT /v2/inventory-adjustments/{uuid}` with `{"itemFulfillment":{"uuid":…}}` returned **200,
87
+ empty `messages[]`, and an echo that did NOT contain `itemFulfillment`** — yet the FK column
88
+ **persisted correctly**. Do not read an empty echo as a failed write; verify against the DB or the
89
+ `Logs_<Client>.Api` `responsePayload`. This was diagnosed entirely from `Logs_Nychh.Api` (routes
90
+ stored with the `/v2` prefix — filter `route LIKE '/v2/...'`), with **no NetSuite access needed**.
91
+ See [V2 request logging](../../../2.0/apps/api2/features/request-logging.md).
92
+
93
+ ### Loud failure (best-effort try/catch removed)
94
+
95
+ The attach and the surrounding NetSuite→DB sync path **no longer swallow errors**: the
96
+ error-log-and-swallow `try/catch` blocks were removed so a data-corruption failure surfaces loudly
97
+ (StarTech/Sentry catches left alone). This is the same loud-failure policy as the 2026-08-27 removal
98
+ of the per-order `try/catch` skips across all six sync sections — see
99
+ [NetSuite → TOGa Supply per-client sync](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
100
+ **Do not "soften" this back into a swallow** — a bridge/link miss must be visible, not silently
101
+ dropped.
102
+
103
+ ## On-demand dependency import (fulfillment not yet in TOGA) — now REST
104
+
105
+ When the linked fulfillment has **not** been imported into TOGA yet, the attach **imports the
106
+ fulfillment on demand** via `syncItemFulfillmentFromNetsuite` (which itself cascades to the
107
+ originating sales order when missing), then **re-resolves and links**. This on-demand import was
108
+ **converted from SOAP to REST** this session.
109
+
110
+ Threading the on-demand import required the **full item-fulfillment lookup set** — customer,
111
+ country, state, location, manufacturer, item, all-catalog-items, vendor-item, shipping-carrier,
112
+ shipping-method, warehouse — to be passed through `syncInventoryAdjustmentFromNetsuite` (signature
113
+ **expanded 5 → 13 args**) and into `attachInventoryAdjustmentToItemFulfillment`, plus updating the
114
+ `common_sync_togasupply.php` `INVENTORY_ADJUSTMENTS` call to pass them. **Design rule (developer
115
+ instruction): all lookups are passed function-to-function**, like the other sync functions — not
116
+ rebuilt or fetched inside the callee.
117
+
118
+ ## The REST-shim fixes this migration required (`library/app/api/netsuite/rest.php`)
119
+
120
+ Both are framework-level NetSuite facts — full detail on
121
+ [NetSuite SuiteQL/REST API Reference](../../../1.0/apps/library/features/netsuite-suiteql-api-reference.md):
122
+
123
+ - **Item-fulfillment REST shim returned zero line items.** `fetchItemFulfillmentById` fetched
124
+ fulfillment lines from the REST **record sublist** `GET /record/v1/itemFulfillment/{id}/item?limit=1000&offset=N`.
125
+ **REST record sublist endpoints reject `limit`/`offset`** (HTTP 400 `INVALID_PARAMETER`), so the
126
+ shim silently got 0 lines and every fulfillment imported **header-only** — starving all downstream
127
+ ItemFulfillmentItems and the line-level link. **Fix:** fetch lines via SuiteQL `transactionline`
128
+ (the pattern every other shim in the file already uses).
129
+ - **Fulfillment SuiteQL returned ~3 rows per fulfilled item.** An Item Fulfillment (`ItemShip`) has
130
+ three `transactionline` rows per fulfilled item: the order-linked line, a positive "wash" line, and
131
+ an `isinventoryaffecting='T'` line — only the order-linked line maps to a source-order line.
132
+ **Fix:** keep only lines with a `previoustransactionlinelink` of type `SalesOrd`/`TrnfrOrd` (via
133
+ `EXISTS`), and derive `orderLine` from that link's `previousline` (correlated subquery).
134
+ **`mainline='F'` is the WRONG filter** — NetSuite sometimes marks the first real fulfilled line
135
+ `mainline='T'`.
136
+
137
+ ## Two shared-engine fixes in `syncInventoryAdjustmentFromNetsuite` (surfaced here, affect any client)
138
+
139
+ Both are in the shared library method — see also the
140
+ [per-client sync](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md):
93
141
 
94
142
  - **(a) Prune-order FK violation.** The stale-item prune deleted an `InventoryAdjustmentItem` while
95
143
  its child `InventoryAdjustmentItemUnits` still referenced it (FK **RESTRICT**, no cascade) → MySQL
96
144
  **1451** → `EV-11`. **Fix:** delete the child units (`/inventory-adjustment-item-units/{uuid}`)
97
- **before** the item. (Same "delete bridge/child rows first" rule as the tracking-number bridges.)
98
- - **(b) Item-lookup indexing mismatch → duplicate items every run.** The item lookup was consumed as a
99
- **two-level** `[$uuidManufacturer][$partNumber]`, but the cron passes it **one-level** `[$partNumber]`
100
- (populated in `common_sync_togasupply.php` ~L313). The two-level
101
- `$lookupItemByClientUuidAndManufacturerUuidAndPartNumberUpper` is declared but **never populated
102
- (dead)**. The mismatch meant existing items were never found, so items were **re-created every run**
103
- (duplicates / `EV-10`). **Fix:** index one-level `[$partNumber]`.
145
+ **before** the item. (Same "delete child rows first" rule as the tracking-number bridges.)
146
+ - **(b) Item-lookup indexing mismatch → duplicate items every run.** The item lookup was consumed
147
+ **two-level** `[$uuidManufacturer][$partNumber]`, but the cron passes it **one-level**
148
+ `[$partNumber]` (populated in `common_sync_togasupply.php` ~L313); the two-level lookup is declared
149
+ but **never populated (dead)**. Existing items were never found, so items were **re-created every
150
+ run** (duplicates / `EV-10`). **Fix:** index one-level `[$partNumber]`.
104
151
 
105
152
  ## Diagnostic harness — `diagnose_hh_import_inventory_adjustment.php`
106
153
 
107
154
  A **manual operator tool** in `worker/crons/toga2/netsuite/` (NOT in `cron.worker.sync.json`; does
108
155
  **not** call `cronInitialization`, so no process lock / no cursor writes; installs a strict
109
156
  `set_error_handler` that rethrows warnings as `ErrorException` for exact crash lines). It imports
110
- **one** inventory adjustment (default NS internal id **6516616**) by calling the real
111
- `syncInventoryAdjustmentFromNetsuite`, seeds the warehouse/item/manufacturer lookups (the item loop
112
- **filters** items to known warehouses and **creates** items on a lookup-miss, so empty lookups drop or
113
- duplicate items), and has a **read-only** diagnostic tail that dumps the NS item list +
114
- `custbody_stock_adjustment` + whether the referenced fulfillment already exists in TOGA.
157
+ **one** inventory adjustment by calling the real `syncInventoryAdjustmentFromNetsuite`, seeds the
158
+ warehouse/item/manufacturer lookups (the item loop **filters** items to known warehouses and
159
+ **creates** items on a lookup-miss, so empty lookups drop or duplicate items), and has a
160
+ **read-only** diagnostic tail that dumps the NS item list + `custbody_stock_adjustment` + whether the
161
+ referenced fulfillment already exists in TOGA.
115
162
 
116
163
  ```
117
164
  php crons/toga2/netsuite/diagnose_hh_import_inventory_adjustment.php <togaClient> <togaApi> <togaSecret> [adjustmentInternalId]
@@ -120,53 +167,53 @@ php crons/toga2/netsuite/diagnose_hh_import_inventory_adjustment.php <togaClient
120
167
 
121
168
  ## Go-live toggle
122
169
 
123
- `IS_ENABLED_INTEGRATION_INVENTORY_ADJUSTMENTS` is still **`false`** in `sync_togasupply_hh.php` —
124
- enabling it is the go-live switch for this feature on the scheduled cron.
125
-
126
- ## Open / next-session — migrate the inventory-adjustment sync from SOAP to REST
127
-
128
- The inventory-adjustment sync is still **SOAP**: `App_NetSuite::getInventoryNumberFromSerialNumber` +
129
- `getInventoryNumber` per serial (`toga2.php` ~5371/5374), plus `getObject('inventoryAdjustment')`,
130
- `getItemDetails`, `detectItemType`, `getItemIsSerialized`, `getItemIsFulfillable`. For a **serialized**
131
- adjustment this is ~2 NetSuite SOAP round-trips **per serial** → ~1 hour for a large adjustment; the
132
- on-demand fulfillment import inherits the same cost. **Decision:** migrate the whole
133
- inventory-adjustment sync to REST via `App_Api_Netsuite_Rest` (`rest.php` already has
134
- `send`/`authenticate`/`suiteqlListAll`/`getCustomFieldValue`/`fetchItemById`/`listInventoryAdjustments`
135
- (minimal) — the gaps are a full inventory-adjustment fetch with inventory detail/serials, and an
136
- inventory-number/serial batch lookup). **This is the next session's task.**
170
+ `IS_ENABLED_INTEGRATION_INVENTORY_ADJUSTMENTS` in `sync_togasupply_hh.php` is the go-live switch for
171
+ this feature on the scheduled cron.
137
172
 
138
- ## Known state — test adjustment 6516616
173
+ ## Validation (end-to-end, prod)
139
174
 
140
- NYCHH adjustment **6516616** (tranId **2136**) has **4 NON-serialized** items (adjustQtyBy
141
- 1800/1800/700/40) and `custbody_stock_adjustment` → Item Fulfillment **#289938** (NS internal
142
- 6507274). That fulfillment is **not yet imported** in TOGA, so the bridges stay empty until the
143
- on-demand import lands it. Import + item-sync of the adjustment itself works (4
144
- `InventoryAdjustmentItems`).
175
+ - **Non-serialized:** linked adjustment **6994071** → fulfillment **6827251** — header FK + line FK
176
+ both populate.
177
+ - **Serialized:** linked adjustment **6871592** → fulfillment **6868763** (2 serials) — header FK,
178
+ line FK, **and 2 serial units on BOTH** the adjustment item and the fulfillment item all populate
179
+ correctly.
145
180
 
146
181
  ## Gotchas / known issues
147
182
 
148
183
  - **Older adjustments have no link.** `custbody_stock_adjustment` was added in NetSuite <1 year ago;
149
- adjustments predating it will never populate a bridge — not a bug.
150
- - **The attach is best-effort by design.** It is gated NYCHH-only and wrapped in `try/catch`; a link
151
- failure logs and lets the adjustment import complete. Do not "harden" it into a throw — a bridge miss
152
- must not freeze `INVENTORY_ADJUSTMENTS`.
184
+ adjustments predating it will never populate an FK — not a bug.
185
+ - **Reading the product uuid at `depth=2` silently returns nothing** — the level-3 `item` object is
186
+ omitted at max depth. Use `depth=3` + explicit nested `fields` (see above).
187
+ - **A V2 PUT echo that omits `itemFulfillment` does NOT mean the write failed** — verify against the
188
+ DB / `Logs_<Client>.Api`, not the echoed body.
153
189
  - **Empty lookups drop or duplicate items.** The item loop filters to known warehouses and creates
154
190
  items on lookup-miss, so any manual/diagnostic invocation must seed the warehouse/item/manufacturer
155
191
  lookups (the harness does).
156
192
 
157
193
  ## Change history
158
194
 
159
- - 2026-08-28 — Built NYCHH inventory-adjustment → item-fulfillment linking off NetSuite
160
- `custbody_stock_adjustment` (internalId 9735): two all-client bridges
161
- (`ItemFulfillments_InventoryAdjustments` record 353 / `ItemFulfillmentItems_InventoryAdjustmentItems`
162
- record 354, Core-registered + Super-User/per-client ACL), a gated best-effort
163
- `App_Api_Toga2::attachInventoryAdjustmentToItemFulfillment` (resolves the fulfillment by
164
- `c_netsuiteInternalItemFulfillmentId`, idempotent header + itemId-matched line links), and **on-demand
165
- import** of a not-yet-imported fulfillment via `syncItemFulfillmentFromNetsuite` (cascades to the SO)
166
- — which expanded `syncInventoryAdjustmentFromNetsuite` 5→13 args to thread the full IF lookup set.
167
- Fixed two shared-engine bugs: the stale-item prune deleted an item before its child units (FK
168
- RESTRICT → 1451/EV-11 — now deletes units first), and the item lookup was consumed two-level while the
169
- cron populates it one-level `[$partNumber]` (re-created items every run → duplicates/EV-10 — now
170
- one-level). Added the `diagnose_hh_import_inventory_adjustment.php` operator harness. Go-live toggle
171
- `IS_ENABLED_INTEGRATION_INVENTORY_ADJUSTMENTS` still false. **Open (next session):** migrate the
172
- inventory-adjustment sync from SOAP (~2 round-trips/serial, ~1h for a large adjustment) to REST. (jcardinal)
195
+ - 2026-08-29 — **Migrated the feature to direct FK columns over REST, and made it fail loud.**
196
+ Replaced the two bridge tables with direct FK columns
197
+ (`InventoryAdjustments.itemFulfillmentId` reusing `RecordField` 2517/record 154;
198
+ `InventoryAdjustmentItems.itemFulfillmentItemId` reusing 2521/record 155 — no new Core
199
+ registration/ACL). Migrated `syncInventoryAdjustmentFromNetsuite` and its on-demand
200
+ fulfillment/sales-order import **SOAP → REST** (was ~2 round-trips/serial → ~1h for a large
201
+ serialized adjustment), and removed the error-log-and-swallow `try/catch` in the NetSuite→DB sync
202
+ path (StarTech/Sentry catches left alone). Fixed three read-path traps hit along the way: the REST
203
+ **record-sublist `limit`/`offset` 400** (`fetchItemFulfillmentById` returned 0 lines → header-only
204
+ import; now uses SuiteQL `transactionline`), the **ItemShip 3-rows-per-item** filter (keep the
205
+ `SalesOrd`/`TrnfrOrd` line-linked line, `mainline='F'` is wrong), and the **V2 max-depth FK
206
+ omission** (`depth=2` dropped `salesOrderItem.item` → now `depth=3` + explicit nested fields).
207
+ Discovered a **V2 PUT echo can omit a field that persisted** (verified via `Logs_Nychh.Api`, no
208
+ NetSuite access). Validated end-to-end in prod: non-serialized 6994071→6827251 and serialized
209
+ 6871592→6868763 (header FK, line FK, and 2 serial units on both sides). (jcardinal)
210
+ - 2026-08-28 — Built the initial NYCHH inventory-adjustment → item-fulfillment linking off NetSuite
211
+ `custbody_stock_adjustment` (internalId 9735) as **two all-client bridge tables** (header record
212
+ 353 / item record 354, Core-registered + Super-User/per-client ACL) with a gated best-effort
213
+ `App_Api_Toga2::attachInventoryAdjustmentToItemFulfillment` and **on-demand import** of a
214
+ not-yet-imported fulfillment via `syncItemFulfillmentFromNetsuite` (cascades to the SO) — which
215
+ expanded `syncInventoryAdjustmentFromNetsuite` 5→13 args to thread the full IF lookup set. Fixed the
216
+ two shared-engine bugs (prune child units before the item — FK RESTRICT/1451/EV-11; index the item
217
+ lookup one-level `[$partNumber]` — was two-level dead → duplicates/EV-10). Added the
218
+ `diagnose_hh_import_inventory_adjustment.php` operator harness. *(This bridge-table design was
219
+ superseded by the 2026-08-29 direct-FK / REST migration above.)* (jcardinal)
@@ -0,0 +1,5 @@
1
+ # Client: S&P Global `spglobal`
2
+
3
+ | Doc | Framework | Summary | Files |
4
+ |-----|-----------|---------|-------|
5
+ | [S&P Global](profile.md) | 2.0 | S&P Global is a **TOGa-Supply NetSuite integration client** on the shared 1.0 NetSuite → TOGa Supply per-client sync (`worker` + `library`). | |
@@ -0,0 +1,32 @@
1
+ ---
2
+ title: "S&P Global"
3
+ framework: "2.0"
4
+ apps:
5
+ - library
6
+ - worker
7
+ project: Worker
8
+ client: spglobal
9
+ type: profile
10
+ status: active
11
+ updated: 2026-08-29
12
+ owners: ["jcardinal"]
13
+ files: []
14
+ related:
15
+ - ../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
16
+ ---
17
+
18
+ ## Summary
19
+
20
+ S&P Global is a **TOGa-Supply NetSuite integration client** on the shared 1.0 NetSuite → TOGa Supply
21
+ per-client sync (`worker` + `library`). NetSuite transactions sync into its 2.0 `Client_<Id>` tenant
22
+ through the TOGa2 API via a thin `worker/crons/toga2/netsuite/sync_togasupply_*.php` wrapper.
23
+
24
+ This profile was seeded during the 2026-08-29 root-cause work on the stuck togasupply integrations:
25
+ S&P Global was one of the clients exposed to the item-fulfillment "Undefined index" on an empty
26
+ `orderLine` (`syncItemFulfillmentFromNetsuite`), fixed in shared code. Full mechanics and the
27
+ diagnosis playbook live in
28
+ [NetSuite → TOGa Supply per-client sync](../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
29
+
30
+ ## Integration touchpoints
31
+
32
+ - **`worker` / `library` (1.0)** — the shared togasupply sync engine + `App_Api_Toga2` bridge.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.697",
3
+ "version": "1.0.699",
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",