toga-ai 1.0.790 → 1.0.792

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,13 +6,14 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-01
9
+ updated: 2026-09-10
10
10
  owners: [snaredla, jcardinal, bala]
11
11
  files:
12
12
  - _underscore/Model.php
13
13
  - _underscore/Model/Client/PurchaseOrder.php
14
14
  - _underscore/Model/Client/SalesOrder.php
15
15
  - _underscore/Model/Client/ServiceRequest.php
16
+ - _underscore/Model/Client/Location.php
16
17
  - _underscore/Model/Elite/SalesOrder.php
17
18
  - _underscore/Model/Compass/SalesOrder.php
18
19
  - _underscore/Model/Compass/Canada/SalesOrder.php
@@ -30,6 +31,8 @@ related:
30
31
  - ./model-magic-field-access.md
31
32
  - ./model-save-parent-cascade-stored-field-deadlock.md
32
33
  - ./sales-order-purchase-order-bridge-direction.md
34
+ - ../../../../clients/nychh/features/location-shipping-addresses.md
35
+ - ../../toga25-supply/features/transfer-orders-page.md
33
36
  ---
34
37
 
35
38
  ## Summary
@@ -95,6 +98,45 @@ Verified 2026-08-26 on `_Model_Quad_Item::_unitPrice`, which returns a `GROUP_CO
95
98
  `'CODE amount'` pairs across a Quad item's currencies (see
96
99
  [Quad multi-currency item pricing](../../../../clients/quad/features/multi-currency-item-pricing.md)).
97
100
 
101
+ ## ⚠ A calculated field must return the HUMAN value, not the FK id (`_addressState`, 2026-09-10)
102
+
103
+ `_Model_Client_Location` exposes the location's address as calculated fields
104
+ (`_addressLine1/_addressLine2/_addressCity/_addressState/_addressZip`). `_addressState` selected
105
+ **`Addresses.stateId`**, so every consumer rendered the internal id: `Bronx, 33 10451-5504` instead
106
+ of `Bronx, NY 10451-5504`. A calculated field is a **display** field — resolve the lookup inside the
107
+ subquery rather than handing the caller an id it cannot translate.
108
+
109
+ The fix joins `States` inside the existing subquery (`Model/Client/Location.php`, ~L66):
110
+
111
+ ```php
112
+ LEFT JOIN States ON States.id = Addresses.stateId
113
+ ... SELECT States.code ...
114
+ ```
115
+
116
+ **`LEFT JOIN`, not `INNER`** — `Addresses.stateId` is nullable, and an inner join would drop the
117
+ whole address line for a state-less address. This matches the existing pattern in
118
+ `_underscore/Model/Rate/SalesOrder.php`.
119
+
120
+ ### The blast-radius checklist for changing a shared `_Model_Client_*` calculated field
121
+
122
+ This is a **shared core model**: one edit ships to every tenant at once, and a calculated field has
123
+ no compile-time consumers, so the only way to know who breaks is to look. What was checked before
124
+ this one-line change, and the shape to reuse:
125
+
126
+ 1. **Does every client schema support the new join?** All **36** `Client_*` prod schemas have both a
127
+ `States` and a `Locations` table, so no tenant errors on the join.
128
+ 2. **Who reads the field, and do they DISPLAY it or DECIDE on it?** Only two frontends request
129
+ `_addressState` and both only print it (`toga25-supply` TransferOrders hooks, and
130
+ `toga2-supply/src/pages/Inventory/api/InventoryApi.tsx` ~L560).
131
+ 3. **Does any metadata treat it as an id?** `Core.RecordFields` id **73** is `_addressState`, with
132
+ **0** rows in `SurfaceElements` and `DefaultRecordFieldSettings` and one ACL read row — nothing
133
+ configures it as an id-based filter, so nothing depended on getting a number.
134
+ 4. **Run the new subquery against production before committing.** The edited expression returned
135
+ `NY` for all 24 NYCHH locations; `php -l` alone proves nothing about a SQL string.
136
+
137
+ > **Status 2026-09-10: written, `php -l` clean, UNCOMMITTED on `_production` and NOT deployed.**
138
+ > Production still renders the state id until it ships.
139
+
98
140
  ## Exposing a calculated field to the API - a client `CustomRecordFields` row is enough
99
141
 
100
142
  **Declaring the field on the model does not make it requestable.** A client whose DB has no
@@ -211,6 +253,15 @@ characters — that is the standard of evidence for touching a field that runs o
211
253
  [save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
212
254
 
213
255
  ## Change history
256
+ - 2026-09-10 — **Fixed `_Model_Client_Location::_addressState()` to return the state CODE, not
257
+ `Addresses.stateId`** (addresses rendered as `Bronx, 33 10451-5504`). Added
258
+ `LEFT JOIN States ON States.id = Addresses.stateId` inside the subquery and selected `States.code`
259
+ — LEFT because `stateId` is nullable, matching `_underscore/Model/Rate/SalesOrder.php`. Recorded
260
+ the **blast-radius checklist** used before touching a shared calculated field: all 36 prod
261
+ `Client_*` schemas have `States` + `Locations`; only two frontends read the field and both only
262
+ display it; `Core.RecordFields` 73 has no `SurfaceElements`/`DefaultRecordFieldSettings` rows, so
263
+ no filter relied on the numeric value; and the new subquery was run against prod (returned `NY`
264
+ for all 24 NYCHH locations). **Uncommitted / not deployed** as of this date. (bala)
214
265
  - 2026-09-01 — Added `_shipTo` to the base `_Model_Client_PurchaseOrder` (`public $_shipTo =
215
266
  self::FIELD_SQL;` + static `_shipTo($table = self::TABLE)` returning a correlated subquery
216
267
  `SELECT Addresses.addressee FROM Addresses WHERE Addresses.id = $table.shipToAddressId`),
@@ -6,14 +6,17 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-23
10
- owners: [apeterson]
9
+ updated: 2026-09-10
10
+ owners: [apeterson, bala]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql
14
+ - dbchanges2/Client_Nychh/2026-09-09d - ApiRoleReadLocationAddress.sql
14
15
  related:
15
16
  - cross-client-data-retrieval.md
16
17
  - tableview-apiwhereclause-row-filtering.md
18
+ - v2-rest-query-contract.md
19
+ - ../../worker2/features/netsuite-transferorder-outbound-push.md
17
20
  ---
18
21
 
19
22
  ## What it is
@@ -59,6 +62,33 @@ a machine/API caller on role 3 sees it fine.
59
62
  ids), `NOT EXISTS`-guarded, wrapping any self-referencing subquery in a derived table to avoid MySQL
60
63
  error 1093. See `dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql`.
61
64
 
65
+ ## ⚠ Deepening a `depth` expansion is an ACL change — check EVERY record it crosses
66
+
67
+ The same silent drop bites when nothing about the ACL changed and only the **read shape** did.
68
+ Raising `depth` pulls in records the old read never touched, and each new record needs its own
69
+ `AclRecordPermissions` row **for the calling role** or that whole branch comes back empty — 200,
70
+ `isSuccess: true`, no message.
71
+
72
+ **Rule: before you raise a depth, list the records the new expansion crosses and confirm an ACL row
73
+ exists for the role the caller actually authenticates as.** Service callers are the usual victims,
74
+ because a developer testing in a browser holds different roles.
75
+
76
+ **Confirmed case (2026-09-09, NYCHH transfer-order → NetSuite push).** Expanding the ship-to address
77
+ from `order → destinationLocation` out to `… → primaryLocationAddress → address → state → country`
78
+ crossed Core records **69 “Location addresses”**, **12 “States”** and **23 “Countries”**. The push
79
+ reads `/transfer-orders` as role **3 “API”** (authenticating with the `Client_Nychh.Apis` row named
80
+ `Agilant`), and `Client_Nychh.AclRecordPermissions` had **no roleId 3 row for any of the three** —
81
+ only roleId **1 “Base”**. **Roles are looked up per `roleId` directly in
82
+ `api2/Component/Api/V2/V2.php`; there is no Base-role inheritance**, so a role-1 grant does nothing
83
+ for a role-3 caller. `Client_Compass` already carried role 3 on all three, which is why the identical
84
+ read shape works there. Fixed with `Client_Nychh/2026-09-09d - ApiRoleReadLocationAddress.sql`
85
+ (read-only role-3 rows for 69, 12, 23, `NOT EXISTS`-guarded).
86
+
87
+ **Why this one was expensive:** without those rows the deeper read returns the same thing as the
88
+ shallow read, so the code fix that motivated the depth change would have shipped as a **silent
89
+ no-op** and looked like a code bug. Mechanics of that push are in
90
+ [transfer-order → NetSuite push](../../worker2/features/netsuite-transferorder-outbound-push.md).
91
+
62
92
  ## Diagnostic tell
63
93
 
64
94
  A top-level GET of the child record **works** but the **nested embed of the same record under a
@@ -67,6 +97,15 @@ record's `AclRecordPermissions` for the caller's role — not the parent's grant
67
97
  permissions.
68
98
 
69
99
  ## Change history
100
+ - 2026-09-09 — Added the **depth-expansion** face of this bug: raising `depth` crosses records the old
101
+ read never touched, and each one needs an `AclRecordPermissions` row **for the calling role** or the
102
+ branch is dropped silently. Confirmed on the NYCHH transfer-order → NetSuite push, where expanding
103
+ the ship-to address to depth 6 crossed Core records **69 Location addresses / 12 States / 23
104
+ Countries** and `Client_Nychh` had **no roleId 3 (“API”) row on any of them** — only roleId 1
105
+ (“Base”), which does not inherit, roles being resolved per `roleId` in `V2.php`. `Client_Compass`
106
+ already had role 3 on all three, which is why the same shape worked there. Fixed by
107
+ `Client_Nychh/2026-09-09d`; the durable rule is to audit ACL on every record a new expansion crosses
108
+ before assuming the deeper read is broken. (bala)
70
109
  - 2026-07-23 — Documented that V2 nested-FK embedding (`getFullModelData`) re-checks the CHILD
71
110
  record's own `AclRecordPermissions` and SILENTLY drops the nested object (200, isSuccess true, no
72
111
  message) when no grant exists — needing only an ACL row, not a logic-group expression. Fixed the
@@ -75,5 +114,3 @@ permissions.
75
114
  embedded fine; mirrored Quad's role-1 grant (`Client_Compass/2026-07-23b`). Confirmed this is the
76
115
  SAME role-3-vs-role-1 mis-seed pattern already seen on CompassCanada (record 275) — not
77
116
  CompassCanada-only. (apeterson)
78
- </content>
79
- </invoke>
@@ -7,6 +7,7 @@
7
7
  | [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features |
8
8
  | [2.0 New-Client Onboarding (manual process)](workflows/client-onboarding.md) | > **A local browser wizard now automates this.** Steps 2–9 below (create DBs, generate Core/API > inserts, append to `Clients_Db.txt`) — plus the dbchanges2 bla |
9
9
  | [Auditing a client DB that drifted from its models (partially applied module migration)](workflows/client-schema-drift-audit.md) | A recurring 2.0 failure mode: **one client's database drifts from what the PHP models declare**, usually because a `_modules/<module>/` migration was applied to |
10
+ | [Deleting Contacts rows from a client database (the FK order, and the self-referencing trap)](workflows/deleting-contacts-from-a-client-database.md) | Clearing test `Contacts` out of a 2.0 client database is **not** a one-line `DELETE`. |
10
11
  | [Framework branch running AHEAD of schema — environment-wide 1054/EO-1 after a deploy](workflows/framework-branch-ahead-of-schema.md) | A **third** kind of 2.0 schema drift, distinct from the two already documented: nobody's database went backwards — **the code went forwards**. |
11
12
  | [Local vs prod MySQL config parity — why “it passed locally” is not evidence](workflows/local-vs-prod-mysql-config-parity.md) | Several migration failures that look like "prod-only bugs" are actually **per-machine MySQL server-configuration differences**. |
12
13
  | [Repairing non-prod metadata drift (works in prod, broken in beta/dev-sandbox)](workflows/nonprod-metadata-drift-repair.md) | Almost all 2.0 platform behavior is **metadata** — `Core.Records`/`RecordFields`, `Core.RecordScripts`, `Core.ApiPayloadInterceptors`, and per-client `Acl*` row |
@@ -0,0 +1,87 @@
1
+ ---
2
+ title: "Deleting Contacts rows from a client database (the FK order, and the self-referencing trap)"
3
+ framework: "2.0"
4
+ repo: dbchanges2
5
+ project: Database Changes
6
+ client: shared
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-09-10
10
+ owners: [bala]
11
+ files:
12
+ - dbchanges2/Client_Nychh/2026-09-09a - DeleteTestContacts.sql
13
+ - dbchanges2/Client_Nychh/2026-09-09b - InsertDeliveryContacts.sql
14
+ related:
15
+ - ../architecture.md
16
+ - ../features/rerunnable-additive-inserts.md
17
+ - ../../../clients/nychh/features/location-shipping-addresses.md
18
+ ---
19
+
20
+ ## Summary
21
+
22
+ Clearing test `Contacts` out of a 2.0 client database is **not** a one-line `DELETE`. Nothing
23
+ cascades, and `Contacts` points **back at its own children**, so the obvious order fails with
24
+ `SQL Error [1451] ... Contacts_ibfk_1`. Verified on `Client_Nychh` 2026-09-09 while replacing 12
25
+ test contacts with 19 real delivery contacts.
26
+
27
+ **The two facts that cost the time:**
28
+
29
+ 1. **No foreign key referencing `Contacts` cascades.** 24 of them are `NO ACTION` and 5 are
30
+ `RESTRICT`, so **every child row must be cleared by hand**.
31
+ 2. **`Contacts` holds FKs pointing DOWN at its own children** — `Contacts_ibfk_1` →
32
+ `ContactEmailAddresses`, `Contacts_ibfk_2` → `ContactAddresses`, `Contacts_ibfk_3` →
33
+ `ContactPhoneNumbers`, through `primaryContactEmailAddressId` / `primaryContactAddressId` /
34
+ `primaryContactPhoneNumberId`. So the parent must **release** its children before they can be
35
+ deleted. Delete the email rows first and you get error 1451 on `Contacts_ibfk_1`.
36
+
37
+ ## Steps
38
+
39
+ 1. **Unlink the business rows that reference the contact.** For transfer orders that is
40
+ `UPDATE TransferOrders SET contactId = NULL WHERE contactId IN (…)`. Find the full set with
41
+ `information_schema.KEY_COLUMN_USAGE` against `REFERENCED_TABLE_NAME = 'Contacts'` — do not
42
+ assume the list.
43
+ 2. **NULL the three `primaryContact*Id` columns on `Contacts` first** (`primaryContactEmailAddressId`,
44
+ `primaryContactAddressId`, `primaryContactPhoneNumberId`). This is the step that is easy to miss.
45
+ 3. **Delete `Contacts_Locations`** for those contacts.
46
+ 4. **Delete `ContactEmailAddresses`, then `ContactPhoneNumbers`, then `ContactAddresses`.**
47
+ 5. **Delete `Contacts_Vendors`** — `Contacts_Vendors_ibfk_2` blocks the delete even when the table
48
+ holds no matching rows for these contacts.
49
+ 6. **Delete `Contacts`.**
50
+ 7. **`COMMIT`.**
51
+ 8. **Only then run `ALTER TABLE Contacts AUTO_INCREMENT = 1`.** `ALTER TABLE` performs an **implicit
52
+ commit**, so putting it inside the transaction ends the transaction early and the rollback safety
53
+ net is gone before the deletes are checked.
54
+
55
+ ## Re-seeding contacts — the shape that works
56
+
57
+ Per person: one `Contacts` row, one `ContactEmailAddresses` row (`name = 'Work'`) set as the
58
+ contact's `primaryContactEmailAddressId`, and one `Contacts_Locations` row with `type = 'SHIPPING'`
59
+ per facility. **One contact can hold several `Contacts_Locations` rows** — the same person covering
60
+ two sites is one contact with two links, not two contacts.
61
+
62
+ - **`uuid` must be a real UUID4 literal in the file**, never MySQL `UUID()` (the standing 2.0 rule).
63
+ - **`contactTypeId` is nullable and usually should stay NULL** — a client's `ContactTypes` table is
64
+ commonly empty (it is on `Client_Nychh`), and inventing a type row to fill the column is worse
65
+ than leaving it null.
66
+
67
+ ## Gotchas
68
+
69
+ - **⚠ `SQL Error [1451] ... Contacts_ibfk_1` means the PARENT still points at the child.** It reads
70
+ like a normal child-blocks-parent error and it is the opposite. NULL the `primaryContact*Id`
71
+ columns, then retry.
72
+ - **⚠ `ALTER TABLE` after `COMMIT`, always.** Same trap as any implicit-commit statement inside a
73
+ migration transaction.
74
+ - **⚠ Do not assume the child list.** 29 constraints reference `Contacts` on a full client schema;
75
+ query `information_schema` for the database you are actually in, because client schemas drift
76
+ (see [client schema drift audit](./client-schema-drift-audit.md)).
77
+
78
+ ## Change history
79
+
80
+ - 2026-09-09 — Wrote up the delete order after hitting `Contacts_ibfk_1` on `Client_Nychh` while
81
+ swapping 12 test contacts for 19 real delivery contacts. Recorded that **no FK on `Contacts`
82
+ cascades** (24 `NO ACTION`, 5 `RESTRICT`), that `Contacts` points **back** at
83
+ `ContactEmailAddresses` / `ContactAddresses` / `ContactPhoneNumbers` through its three
84
+ `primaryContact*Id` columns so those must be NULLed **before** the child rows are deleted, that
85
+ `Contacts_Vendors` blocks the delete regardless of content, and that
86
+ `ALTER TABLE … AUTO_INCREMENT = 1` must sit **after** `COMMIT` because it commits on its own.
87
+ (bala)
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-04
9
+ updated: 2026-09-10
10
10
  owners: [apeterson, bala]
11
11
  files:
12
12
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateTransferOrder.ts
@@ -73,6 +73,7 @@ related:
73
73
  - ../../toga-blox/features/advanced-select.md
74
74
  - ../../toga-blox/features/primary-table-templates.md
75
75
  - ../../../standards/frontend.md
76
+ - ../../../../clients/nychh/features/location-shipping-addresses.md
76
77
  ---
77
78
 
78
79
  ## Summary
@@ -482,8 +483,26 @@ Verified on NYCHH 2026-09-01: exactly **1 of 31** active locations sets `allowRe
482
483
  **the same location** that sets `allowSending = 1` — the source warehouse. Filtering targets on
483
484
  `allowReceiving` therefore offers exactly one option: **the one place you cannot transfer to.**
484
485
 
485
- The dropdown lists **all active locations minus the source**. Treat `allowReceiving` as a column
486
- nobody populates until someone proves otherwise per tenant.
486
+ **The dropdown filters on `Locations.locationTypeId` instead (2026-09-10).**
487
+ `hooks/useTargetLocationOptions.ts` now sends a second condition in the same `where` `and` array:
488
+
489
+ ```ts
490
+ where: { and: [
491
+ { "Locations.isActive": { "=": "1" } },
492
+ { "Locations.locationTypeId": { "=": "1" } },
493
+ ] }
494
+ // serializes to where=(Locations.isActive:eq:1,AND,Locations.locationTypeId:eq:1)
495
+ ```
496
+
497
+ Before this it asked for every active location, so all **31** NYCHH active rows were offered,
498
+ including the **7** internal/warehouse rows (Health & Hospitals, Health & Hospitals Processing, the
499
+ New York warehouse, Bay S-A-01-A, Chicago, New York, New York HQ) — one of which is the **source
500
+ warehouse the transfer ships FROM**. Verified on prod `Client_Nychh`: `locationTypeId` **1 = 24
501
+ shipping locations** (hospitals/clinics), **2 = 7 warehouse/office rows**. So the location TYPE is
502
+ the filter that actually works here, and `allowReceiving` is not. Live in production
503
+ (commit `eb12055`, branch `_production`).
504
+
505
+ Treat `allowReceiving` as a column nobody populates until someone proves otherwise per tenant.
487
506
 
488
507
  Related data-shape facts found the same day:
489
508
 
@@ -611,6 +630,14 @@ Wider platform finding from building them:
611
630
  - **Flat-list picker with debounced search.** The breadcrumb path is assembled from
612
631
  `parentLocation.uuid` **with a cycle guard** — a self- or mutually-parented row would otherwise
613
632
  loop forever.
633
+ - **Only shipping locations are listed** — the `where` carries `Locations.locationTypeId = "1"`
634
+ alongside `isActive`; see [the `allowReceiving` section](#-allowreceiving-is-unmaintained--do-not-filter-the-target-dropdown-on-it).
635
+ - **A location's own address IS the transfer's delivery address.** `TransferOrders` has no ship-to
636
+ column (only `originLocationId` / `destinationLocationId`), unlike `SalesOrders`, which carries
637
+ its own `shipToAddressId` per order. So a wrong `Locations` address is invisible on the sales-order
638
+ screens and shows up **only here** — exactly how NYCHH's 24 shipping locations were found all
639
+ rendering one head-office billing address:
640
+ [NYCHH location shipping addresses](../../../../clients/nychh/features/location-shipping-addresses.md).
614
641
  - **Target Contact options come from `Contacts` through the `Contacts_Locations` junction**, scoped
615
642
  to the selected location.
616
643
  - **`Locations` deliberately does NOT get a contact FK.** The junction is the right place, and it is
@@ -885,6 +912,14 @@ deliberate, separate exception — see [surface-frontend](./surface-frontend.md)
885
912
 
886
913
  ## Gotchas
887
914
 
915
+ - **⚠ Never filter the target-location dropdown on `allowReceiving`, and never leave it
916
+ unfiltered either** — filter on `Locations.locationTypeId = "1"` (shipping). Unfiltered, the list
917
+ offers warehouses including the source of the transfer.
918
+ - **⚠ `_addressState` returned the state's internal ID, not its code**, so a location address
919
+ rendered as `Bronx, 33 10451-5504`. Fixed in `_Model_Client_Location` by joining `States` and
920
+ selecting `States.code`; see
921
+ [FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md). **Written but
922
+ NOT deployed as of 2026-09-10**, so the id still shows in production.
888
923
  - **⚠ An empty dropdown with no request in the network tab is a numeric `where` value or a
889
924
  non-zero `staleTime`** — see the section above. Neither logs anything.
890
925
  - **⚠ An ungranted `uuid` field 403s the WHOLE request** — `uuid` is V2's `IDENTIFIER_FIELD` and is
@@ -958,6 +993,18 @@ deliberate, separate exception — see [surface-frontend](./surface-frontend.md)
958
993
  earlier migration without names, so a name-based lookup on them finds nothing.
959
994
 
960
995
  ## Change history
996
+ - 2026-09-10 — **Target Location dropdown now lists only shipping locations.**
997
+ `useTargetLocationOptions.ts` gained a second `where` condition,
998
+ `{ "Locations.locationTypeId": { "=": "1" } }`, so the serialized query is
999
+ `where=(Locations.isActive:eq:1,AND,Locations.locationTypeId:eq:1)`. It previously fetched every
1000
+ active location, which put all **31** NYCHH rows in the list including the **7**
1001
+ warehouse/office rows — one being the source warehouse the transfer ships from. Prod
1002
+ `Client_Nychh` splits **24 shipping / 7 warehouse**, and `allowReceiving` remains unusable
1003
+ (1 of 31, and it is the source). Committed and live in prod (`eb12055`, `_production`). Also
1004
+ recorded that a transfer's delivery address comes from the LOCATION record (no ship-to column on
1005
+ `TransferOrders`), which is why NYCHH's shared head-office address only broke this screen —
1006
+ see [NYCHH location shipping addresses](../../../../clients/nychh/features/location-shipping-addresses.md).
1007
+ (bala)
961
1008
  - 2026-09-04 — **Virtualized the item picker, made the create POST set an opening stage, and lit up
962
1009
  the status badge.** (1) `TransferItemPickerTable` now renders through `@tanstack/react-virtual`
963
1010
  with the offsets as **spacer `<tr>`s inside the real `<table>`**, so `table-layout: fixed`, the
@@ -6,16 +6,18 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-08
9
+ updated: 2026-09-10
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite/SalesOrder.php
13
13
  - _underscore/Model/Client/TransferOrder.php
14
14
  - dbchanges2/Client_Nychh/2026-09-02a - TransferOrderNetsuitePushInterceptor.sql
15
+ - dbchanges2/Client_Nychh/2026-09-09d - ApiRoleReadLocationAddress.sql
15
16
  - test/@Bala/tests/netsuite_transferorder_payload_tests.php
16
17
  - test/@Bala/tests/transferorder_interceptor_tests.php
17
18
  related:
18
19
  - ./netsuite-salesorder-outbound-push.md
20
+ - ../../api2/features/nested-fk-acl-embedding.md
19
21
  - ./creating-worker-actions.md
20
22
  - ../../api2/features/api-payload-interceptors.md
21
23
  - ../../api2/features/v2-rest-query-contract.md
@@ -45,6 +47,10 @@ re-read this doc if you last saw it on 2026-09-02: **the NetSuite customer is th
45
47
  destination location**, and `description` is now sent as the NetSuite **`memo`**. Committed as
46
48
  worker2 **`cbafd25`** on `_production` and deployed.
47
49
 
50
+ **A third correction is written but NOT yet deployed (2026-09-09): the push was sending no ship-to
51
+ address at all**, so NetSuite used the customer's default site on every order. That fix and the
52
+ depth-6 read it needs are below.
53
+
48
54
  ## Key files / entry points
49
55
 
50
56
  | Action | Method | Purpose |
@@ -89,8 +95,8 @@ additive precedence change safe to reason about instead of hope.
89
95
 
90
96
  ## What is actually sent to NetSuite
91
97
 
92
- Nothing beyond this list. A transfer order has no shipping cost, ship method, terms, subsidiary,
93
- shipping address or status columns, so none are sent.
98
+ Nothing beyond this list. A transfer order has no shipping cost, ship method, terms, subsidiary or
99
+ status columns, so none are sent.
94
100
 
95
101
  | NetSuite field | Source |
96
102
  |---|---|
@@ -98,6 +104,7 @@ shipping address or status columns, so none are sent.
98
104
  | `tranDate` | `dateOrder`, date only; **omitted** when null |
99
105
  | `otherRefNum` | the PO number (the developer's explicit requirement) |
100
106
  | `memo` | `TransferOrders.description` — the NYCHH custom form renders it as **"ORDER DESCRIPTION"** |
107
+ | `shippingAddress` | the **destination** Location's `primaryLocationAddress.address` (added 2026-09-09, not yet deployed). The key is **omitted** when the destination has no address, so NetSuite keeps its own default instead of getting an empty one. |
101
108
  | `externalId` | `toga-to-<transfer order uuid>` |
102
109
  | line `item` / `quantity` | each transfer-order item |
103
110
  | line `rate` | **0** — this is the zero-value part |
@@ -147,6 +154,62 @@ showed the transfer order's `description`.
147
154
  destination whose customer id already equals the client's own (**30205** and **36443**), so the old
148
155
  and new rules produce the same id for them.
149
156
 
157
+
158
+ ### ⚠ The ship-to address was NEVER sent — and it needs a depth-6 read (fixed 2026-09-09, not deployed)
159
+
160
+ `buildSalesOrderShapeFromTransferOrder()` never set a `shipToAddress` key, and the `shippingAddress`
161
+ block in `buildNetSuiteOrder()` (~L922) only runs when that key exists. So **every transfer order
162
+ went to NetSuite with no address**, and NetSuite substituted the **customer's default shipping
163
+ address**. Because the customer is now always the client (previous section), that default is one
164
+ head-office site for **every** destination.
165
+
166
+ Live evidence: NetSuite order **7467799** was for **North Central Bronx** in supply and shipped to
167
+ *“1400 Pelham Parkway South, Bronx”* (**Jacobi**); SO **289187** (Woodhull) shows the same address.
168
+ Both are the default on NetSuite customer **28908**. Grepped the whole file — there was no other
169
+ place sending an address for a transfer order.
170
+
171
+ **Fix:** new private `resolveTransferOrderShipToAddress(object $destinationLocation): ?object` reads
172
+ `$destinationLocation->primaryLocationAddress->address`, and the shape sets `shipToAddress` only when
173
+ it is non-null.
174
+
175
+ > **Durable rule:** a transfer order's NetSuite customer is the client's *whole account*, so the
176
+ > customer default can never be the right ship-to. The address must come from the **destination
177
+ > Location**. This is the same “ships-where ≠ who-is-billed” split as the customer fix, one field later.
178
+
179
+ #### The address sits TWO levels deeper than a sales order's
180
+
181
+ | Order | Path to the country | Depth needed |
182
+ |---|---|---|
183
+ | sales order | `order → shipToAddress → state → country` | **4** (`TOGA_FETCH_DEPTH`, unchanged) |
184
+ | transfer order | `order → destinationLocation → primaryLocationAddress → address → state → country` | **6** |
185
+
186
+ At depth 4 the address comes back **missing with no error at all** — the max-depth omission rule in
187
+ [V2 REST query contract](../../api2/features/v2-rest-query-contract.md). The order is read in **two**
188
+ places, so **both** depths were raised:
189
+
190
+ - `_Model_Client_TransferOrder::NETSUITE_READ_DEPTH` **4 → 6** — the `postPost` read done before
191
+ queueing. The order travels with the job, so this is the read that matters in practice.
192
+ - new `TOGA_FETCH_DEPTH__TRANSFER_ORDER = 6` in the worker, chosen by the existing `$isTransferOrder`
193
+ flag in `fetchSalesOrder()` — the fallback path when no order is carried. **Sales orders still read
194
+ at 4.**
195
+
196
+ Note this contradicts nothing above: `NETSUITE_READ_DEPTH` stays a class constant (not INI) for the
197
+ same reason — it is a fact about the read shape, and the shape just got deeper.
198
+
199
+ #### ⚠ Raising a depth is an ACL change, not just a number
200
+
201
+ The new branch crosses three records the old read never touched — Core records **69 Location
202
+ addresses**, **12 States**, **23 Countries** — and the push reads `/transfer-orders` as role **3
203
+ “API”**. `Client_Nychh` had **no role-3 `AclRecordPermissions` rows for any of the three**, so the
204
+ expanded branch would have come back empty, `resolveTransferOrderShipToAddress()` would have returned
205
+ null, and the code fix would have been a **silent no-op**. Fixed by
206
+ `dbchanges2/Client_Nychh/2026-09-09d - ApiRoleReadLocationAddress.sql`.
207
+
208
+ > **Durable rule: when you deepen a `depth` expansion on any api2 read, check
209
+ > `AclRecordPermissions` for the CALLING role on EVERY record the new expansion crosses.** A missing
210
+ > row drops the branch silently instead of erroring — see
211
+ > [nested FK ACL embedding](../../api2/features/nested-fk-acl-embedding.md).
212
+
150
213
  ### `description` → `memo` cannot leak into a plain sales order
151
214
 
152
215
  `memo` is sent only from the transfer-order shape. There is **no `memo` RecordField and no `memo`
@@ -300,6 +363,12 @@ Stub detail for anyone extending it: `_Model_True` is stubbed with **only** the
300
363
  `postPost` reads **zero lines** and the push aborts — the trigger would then have to move. See
301
364
  [Transfer Orders page](../../toga25-supply/features/transfer-orders-page.md).
302
365
  - **dev-sandbox metadata is incomplete** (record 325 + its RecordFields + both inherent-child rows).
366
+ - **The ship-to fix is written, `php -l` clean, but NOT deployed and NOT committed** (worker2 +
367
+ `_underscore`). The two NYCHH SQL files it depends on **have** been run on prod.
368
+ - **Undecided: should the delivery contact go into the NetSuite `attention` field?** NetSuite ship-to
369
+ addresses carry a person on the first line (order **289193** shows *Frederick Roberts*, the Bellevue
370
+ delivery contact). `TransferOrders.contactId` exists as an FK on `_Model_Client_TransferOrder` but
371
+ **nothing in worker2 or toga2-supply reads it** — nothing is wired, and nothing was decided.
303
372
  - **Undecided: disable the customer name-search fallback for transfer orders?**
304
373
  `resolveNetSuiteCustomerId()` falls back to a SuiteQL search by company **name** when the
305
374
  destination has no customer id, and NYCHH's `Health & Hospitals` vs `NYC Health + Hospitals` are
@@ -328,6 +397,21 @@ Stub detail for anyone extending it: `_Model_True` is stubbed with **only** the
328
397
 
329
398
  ## Change history
330
399
 
400
+ - 2026-09-09 — **The push had never sent a shipping address at all.**
401
+ `buildSalesOrderShapeFromTransferOrder()` set no `shipToAddress` key, so the `shippingAddress` block
402
+ in `buildNetSuiteOrder()` never ran and NetSuite substituted the **customer's default** site on every
403
+ order — NetSuite order **7467799** (North Central Bronx) and SO **289187** (Woodhull) both shipped to
404
+ Jacobi's *1400 Pelham Parkway South*, the default on customer 28908. Added
405
+ `resolveTransferOrderShipToAddress()` reading `destinationLocation->primaryLocationAddress->address`,
406
+ set on the shape only when non-null so a destination with no address still lets NetSuite use its own
407
+ default. Discovered the address sits **two levels deeper than a sales order's** (order → destination
408
+ → primaryLocationAddress → address → state → country = **6**, versus 4), and at depth 4 it returns
409
+ **missing with no error**: raised `_Model_Client_TransferOrder::NETSUITE_READ_DEPTH` 4 → 6 and added
410
+ `TOGA_FETCH_DEPTH__TRANSFER_ORDER = 6` for the fallback fetch, sales orders unchanged at 4. Also
411
+ recorded the durable rule that **deepening a depth is an ACL change** — the new branch crosses Core
412
+ records 69/12/23 and `Client_Nychh` had no role-3 grant on any of them, which would have made the
413
+ whole fix a silent no-op (fixed by `Client_Nychh/2026-09-09d`). Code written and `php -l` clean but
414
+ **not deployed and not committed**. (bala)
331
415
  - 2026-09-08 — **Went live, and fixed the customer.** `buildSalesOrderShapeFromTransferOrder()` was
332
416
  sending the **destination Location** as the NetSuite customer, which put SO **7462031** on
333
417
  sub-customer **33674** (CHS) instead of **28908**; it only ever looked correct because almost every
@@ -21,7 +21,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
21
21
  - **_underscore** (_Underscore) _(framework core)_ — 82 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 68 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
- - **dbchanges2** (Database Changes) _(framework core)_ — 15 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
+ - **dbchanges2** (Database Changes) _(framework core)_ — 17 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
25
  - **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
26
26
  - **saml** (SAML SSO Gateway) — 6 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
27
27
  - **toga2-view** (TOGa View Frontend) — 12 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
@@ -2,6 +2,7 @@
2
2
 
3
3
  | Doc | Framework | Summary |
4
4
  |-----|-----------|---------|
5
+ | [NYCHH transfer-order destinations — location shipping addresses + delivery contacts](features/location-shipping-addresses.md) | 2.0 | Every one of NYCHH's **24 shipping locations** (`Locations.locationTypeId = 1`) resolved to the same address — **50 Water St., 3rd Floor, New York, NY 10004**, |
5
6
  | [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 |
6
7
  | [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_ |
7
8
  | [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 |
@@ -0,0 +1,150 @@
1
+ ---
2
+ title: "NYCHH transfer-order destinations — location shipping addresses + delivery contacts"
3
+ framework: "2.0"
4
+ repo: dbchanges2
5
+ project: Database Changes
6
+ client: nychh
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-09-10
10
+ owners: [bala]
11
+ files:
12
+ - dbchanges2/Client_Nychh/2026-09-09c - UpdateLocationShippingAddresses.sql
13
+ - dbchanges2/Client_Nychh/2026-09-09a - DeleteTestContacts.sql
14
+ - dbchanges2/Client_Nychh/2026-09-09b - InsertDeliveryContacts.sql
15
+ - _underscore/Model/Client/Location.php
16
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTargetLocationOptions.ts
17
+ related:
18
+ - ../profile.md
19
+ - ./netsuite-transfer-order-import.md
20
+ - ./transfer-order-netsuite-push.md
21
+ - ../../../2.0/apps/toga25-supply/features/transfer-orders-page.md
22
+ - ../../../2.0/apps/_underscore/features/calculated-sql-fields.md
23
+ - ../../../2.0/apps/dbchanges2/workflows/deleting-contacts-from-a-client-database.md
24
+ - ../../../2.0/apps/worker2/features/netsuite-transferorder-outbound-push.md
25
+ ---
26
+
27
+ ## Summary
28
+
29
+ Every one of NYCHH's **24 shipping locations** (`Locations.locationTypeId = 1`) resolved to the same
30
+ address — **50 Water St., 3rd Floor, New York, NY 10004**, attention *"Accounts Payable OTPS"*. That
31
+ is the head-office **Accounts Payable BILLING** address, not a delivery address, so the Create
32
+ Transfer Order target-location list showed one identical address 24 times and a transfer had no real
33
+ delivery address. Fixed 2026-09-09 by backfilling each location's own **NetSuite default shipping
34
+ address**.
35
+
36
+ The load-bearing fact behind the whole issue: **for NYCHH a `Locations` address only affects transfer
37
+ orders.** `SalesOrders` carries its own `shipToAddressId` per order and those already held the real
38
+ hospital addresses (462 1st Ave, 451 Clarkson Ave, 100 N Portland Ave, …). `TransferOrders` has **no
39
+ ship-to column at all** — only `originLocationId` / `destinationLocationId` — so a transfer's
40
+ delivery address is read off the location record. That is why a wrong location address was invisible
41
+ everywhere except the transfer screen.
42
+
43
+ ## How it works — where a location's shipping address comes from
44
+
45
+ - Each `Locations` row points at its own `Addresses` row through `Locations_Addresses`. The rows
46
+ **existed** (`Addresses` ids **261–303**); they simply all held the same values, so this was a
47
+ data problem, not a missing-topology problem.
48
+ - The source of truth is the location's **NetSuite customer** record:
49
+ `Locations.c_netsuiteInternalCustomerId` → `customer` → `customerAddressbook` with
50
+ **`defaultshipping = 'T'`**. (On a NYCHH transfer order the destination location is the NetSuite
51
+ *customer* and the origin is the NetSuite *warehouse* — see
52
+ [transfer-order NetSuite push](./transfer-order-netsuite-push.md).)
53
+ - `attention` belongs on a **bill-to**, not a ship-to. The backfill cleared it, because it carried
54
+ *"Accounts Payable"* on rows now used as delivery addresses.
55
+
56
+ ## The migration — `Client_Nychh/2026-09-09c - UpdateLocationShippingAddresses.sql`
57
+
58
+ 1. **UPDATE 23 existing `Addresses` rows in place** with the NetSuite default-shipping values.
59
+ 2. **INSERT one new row** for location **2 "NYC Health + Hospitals"** and repoint
60
+ `Locations_Addresses.id = 24` at it, because that location was **sharing `Addresses.id = 1` with
61
+ location 1 (Lincoln)** — updating the shared row would have silently changed Lincoln too.
62
+ 3. Clear `attention`.
63
+
64
+ **Why overwriting in place was safe:** verified that **no** `SalesOrders`, `PurchaseOrders`,
65
+ `ItemFulfillments`, `AdvanceShippingNotices`, `ServiceRequests` or `Customers` row pointed at any of
66
+ those `Addresses` ids. Do that check before reusing an `Addresses` row anywhere — an `Addresses` row
67
+ is shareable, so an in-place UPDATE is a multi-record edit until proven otherwise.
68
+
69
+ **Applied and verified in production 2026-09-09.** All 24 locations now carry their own street, city
70
+ and zip: **22 distinct addresses across 24 locations**, and both repeats are real per NetSuite
71
+ (Harlem + Renaissance at 506 Lenox Ave; Jacobi + NYC Health + Hospitals at 1400 Pelham Parkway
72
+ South).
73
+
74
+ ## Delivery contacts — 12 test rows replaced with 19 real people (2026-09-09)
75
+
76
+ The same Create Transfer Order screen picks a **Target Contact** from `Contacts` through the
77
+ **`Contacts_Locations`** junction, scoped to the chosen location. `Client_Nychh` held **12 test
78
+ contacts**; they were deleted and **19 real NYCHH delivery contacts** inserted (ids **1–19**, **18**
79
+ location links) by `2026-09-09a - DeleteTestContacts.sql` and `2026-09-09b - InsertDeliveryContacts.sql`.
80
+
81
+ - Shape per person: one `Contacts` row, one `ContactEmailAddresses` row (`name = 'Work'`) set as
82
+ `primaryContactEmailAddressId`, and a `Contacts_Locations` row with `type = 'SHIPPING'`.
83
+ - **Omar Moharem covers two facilities** (Dr. Susan Smith McKinney and Kings County) — **one** contact
84
+ with **two** `Contacts_Locations` rows, not two contacts. That is why 19 contacts produce 18 links
85
+ alongside the two people below who have none.
86
+ - `Client_Nychh.ContactTypes` is **empty**, so `contactTypeId` is left NULL.
87
+ - **Deleting the old rows was the hard part** — nothing cascades and `Contacts` points back at its own
88
+ children. The full order is in
89
+ [deleting Contacts from a client database](../../../2.0/apps/dbchanges2/workflows/deleting-contacts-from-a-client-database.md).
90
+
91
+ ## Open questions / known bad data
92
+
93
+ - **🚩 OPEN — two contacts have no location link.** **Erald Velmishi** (Central Office) and **Harry
94
+ Ho** (Community Care) got no `Contacts_Locations` row because **neither facility exists in
95
+ `Locations`**. They will not appear in the Target Contact dropdown anywhere until the locations are
96
+ created.
97
+ - **🚩 OPEN — nothing reads the chosen contact.** `TransferOrders.contactId` is a real FK on
98
+ `_Model_Client_TransferOrder`, but **no code in worker2 or toga25-supply reads it**. NetSuite
99
+ ship-to addresses do carry a person on the first line (order **289193** shows *Frederick Roberts*,
100
+ the Bellevue delivery contact), so wiring the contact into the NetSuite `attention` field is the
101
+ obvious next step — **undecided, not implemented**. See
102
+ [transfer-order → NetSuite push](../../../2.0/apps/worker2/features/netsuite-transferorder-outbound-push.md).
103
+
104
+ - **🚩 OPEN — is location 2 "NYC Health + Hospitals" a real delivery place?** It is the destination
105
+ on **1,832 of 1,939** NYCHH transfer orders (next highest is Coney Island at 27), so it behaves as
106
+ a **catch-all destination**. But every one of those 1,832 has `createdByUserId` **NULL** — not one
107
+ was created by a person on the Create Transfer Order screen. They come from the automated NetSuite
108
+ import (277 on 2026-08-29, 1,528 on 2026-08-30, then 1–3/day). Its address was set to NetSuite's
109
+ value (**1400 Pelham Parkway South, Bronx NY 10461**) at the developer's direction, but that is
110
+ **Jacobi Medical Center's** address. Still with the project manager: is this a real receiving
111
+ place, or should the import pick the actual hospital instead? **Do not treat its address as
112
+ settled**, and see [the NetSuite transfer-order import](./netsuite-transfer-order-import.md).
113
+ - **⚠ Two locations carry NetSuite customer ids that no longer exist.** **Bellevue Hospital Center →
114
+ 29275** and **Jacobi Medical Center → 31917**; neither id is in the NetSuite `customer` table, so
115
+ their addresses came from other sources rather than a `defaultshipping` row. The ids need
116
+ correcting; until then any NetSuite lookup keyed on them returns nothing.
117
+
118
+ ## Gotchas
119
+
120
+ - **⚠ A location's address surfaces ONLY on transfer orders for this client.** Sales orders carry
121
+ their own `shipToAddressId`, so "the address is fine on the order screens" says nothing about the
122
+ location record.
123
+ - **⚠ An `Addresses` row can be shared by several locations** (ids 1 and 2 shared `Addresses.id = 1`
124
+ here). Always check before an in-place UPDATE; insert + repoint the junction instead.
125
+ - **⚠ `attention = "Accounts Payable OTPS"` is the tell that a row is a BILL-TO** being used as a
126
+ ship-to. If a whole tenant's locations share one address, suspect the head-office AP address.
127
+ - **⚠ The state renders as a number until `_addressState` ships.** The fixed
128
+ `_Model_Client_Location::_addressState()` (join `States`, select `States.code`) is written but
129
+ **not deployed**, so production still shows `Bronx, 33 10451-5504`. See
130
+ [FIELD_SQL calculated fields](../../../2.0/apps/_underscore/features/calculated-sql-fields.md).
131
+
132
+ ## Change history
133
+
134
+ - 2026-09-09 — Replaced the **12 test contacts** with **19 real delivery contacts** (ids 1–19, 18
135
+ `Contacts_Locations` `SHIPPING` links; Omar Moharem covers two facilities on one contact), each with
136
+ a `'Work'` `ContactEmailAddresses` row as `primaryContactEmailAddressId` and `contactTypeId` NULL
137
+ because `ContactTypes` is empty. The delete needed a specific FK order —
138
+ [written up separately](../../../2.0/apps/dbchanges2/workflows/deleting-contacts-from-a-client-database.md).
139
+ Left open: Erald Velmishi and Harry Ho have no location link (their facilities do not exist in
140
+ `Locations`), and `TransferOrders.contactId` is still read by nothing. (bala)
141
+ - 2026-09-10 — Recorded the backfill and the surrounding facts. All **24** `locationTypeId = 1`
142
+ locations shared the head-office AP billing address (50 Water St), so
143
+ `Client_Nychh/2026-09-09c - UpdateLocationShippingAddresses.sql` pulled each location's NetSuite
144
+ `defaultshipping` address, UPDATEd 23 `Addresses` rows in place, INSERTed one for location 2 (it
145
+ was sharing `Addresses.id = 1` with Lincoln) and cleared `attention`. Applied and verified in
146
+ prod: 22 distinct addresses over 24 locations. Also captured that a location address drives
147
+ **transfer orders only** (`TransferOrders` has no ship-to column), the open question about
148
+ catch-all destination location 2 (1,832 of 1,939 transfers, all `createdByUserId` NULL, address
149
+ currently Jacobi's), and the two dead NetSuite customer ids (Bellevue 29275, Jacobi 31917).
150
+ (bala)
@@ -6,7 +6,7 @@ project: Library
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-08
9
+ updated: 2026-09-10
10
10
  owners: [jcardinal, bala, rgirish]
11
11
  files:
12
12
  - library/app/api/toga2.php
@@ -29,6 +29,7 @@ related:
29
29
  - ./transfer-order-inventory-quantities.md
30
30
  - ./transfer-order-netsuite-push.md
31
31
  - ../../../2.0/apps/worker2/features/netsuite-transferorder-outbound-push.md
32
+ - ./location-shipping-addresses.md
32
33
  - ../profile.md
33
34
  ---
34
35
 
@@ -286,6 +287,13 @@ item receipts replays about two years unless it is rewound forward first.
286
287
 
287
288
  ## Gotchas / known issues
288
289
 
290
+ - **🚩 The import sends almost everything to catch-all destination location 2 "NYC Health +
291
+ Hospitals" — 1,832 of 1,939 transfer orders**, next highest Coney Island at 27, and **every one
292
+ has `createdByUserId` NULL** (277 on 2026-08-29, 1,528 on 2026-08-30, then 1–3/day). Whether that
293
+ location is a real receiving place or the import should resolve the actual hospital is **open with
294
+ the PM**; its address is currently Jacobi's. Do not assume the destination is correct:
295
+ [NYCHH location shipping addresses](./location-shipping-addresses.md).
296
+
289
297
  - **Real-TrnfrOrd item receipts are disabled — see the DEFERRED section above.** A receipt whose
290
298
  `createdFrom` is a real Transfer Order is skipped by design; do not read that as the importer being
291
299
  broken.
@@ -307,6 +315,11 @@ item receipts replays about two years unless it is rewound forward first.
307
315
 
308
316
  ## Change history
309
317
 
318
+ - 2026-09-10 - Recorded that the import concentrates **1,832 of 1,939** transfer orders on catch-all
319
+ destination **location 2**, all with `createdByUserId` NULL (so none came from the Create Transfer
320
+ Order screen), and that whether it should resolve the real hospital instead is still open with the
321
+ PM. Cross-linked the location-address backfill. (bala)
322
+
310
323
  - 2026-09-08 - **Closed transfer orders never reached the Closed stage** - the `status === 'Closed'`
311
324
  `continue` in `common_sync_togasupply.php` (~L746) runs before the `isTransferOrder()` branch, so
312
325
  `syncTransferOrderFromNetsuite`'s `case 'Closed'` was dead code and **18 NYCHH transfer orders sat
@@ -6,18 +6,21 @@ project: _Underscore
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-08
9
+ updated: 2026-09-10
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - _underscore/Model/Client/TransferOrder.php
13
13
  - worker2/Worker/Netsuite/SalesOrder.php
14
14
  - dbchanges2/Client_Nychh/2026-09-02a - TransferOrderNetsuitePushInterceptor.sql
15
+ - dbchanges2/Client_Nychh/2026-09-09d - ApiRoleReadLocationAddress.sql
15
16
  - test/@Bala/tests/netsuite_transferorder_payload_tests.php
16
17
  related:
17
18
  - ../../../2.0/apps/worker2/features/netsuite-transferorder-outbound-push.md
18
19
  - ../../../2.0/apps/api2/features/api-payload-interceptors.md
19
20
  - ../../../2.0/apps/dbchanges2/workflows/nonprod-metadata-drift-repair.md
21
+ - ../../../2.0/apps/api2/features/nested-fk-acl-embedding.md
20
22
  - ./netsuite-transfer-order-import.md
23
+ - ./location-shipping-addresses.md
21
24
  - ./po-number-upstream-direction.md
22
25
  - ../profile.md
23
26
  ---
@@ -91,6 +94,42 @@ from `TransferOrders.description` (now sent as NetSuite `memo`, a NYCHH custom-f
91
94
  So the location-role table above describes **which column a location carries**, not what gets sent
92
95
  as `entity`. Destination is still what resolves the *ship-to*; it is no longer the customer.
93
96
 
97
+ ## ⚠ `Client_Nychh` was missing the role-3 ACL rows the ship-to address needs (fixed 2026-09-09)
98
+
99
+ The push now sends a ship-to address taken from the destination Location, which needs a **depth-6**
100
+ read (`order → destinationLocation → primaryLocationAddress → address → state → country`) — mechanics
101
+ in [the shared push doc](../../../2.0/apps/worker2/features/netsuite-transferorder-outbound-push.md).
102
+ **That deeper read was dead on arrival for NYCHH.**
103
+
104
+ The push reads `/transfer-orders` as **roleId 3 “API”**, authenticating with the `Client_Nychh.Apis`
105
+ row named **`Agilant`**. `Client_Nychh.AclRecordPermissions` had **no roleId 3 row** for Core records
106
+ **69 “Location addresses”**, **12 “States”** or **23 “Countries”** — only roleId **1 “Base”**. Roles
107
+ are resolved per `roleId` in `api2/Component/Api/V2/V2.php`; **there is no Base-role inheritance**, so
108
+ role 1 grants nothing to a role-3 caller. `Client_Compass` already carried role 3 on all three, which
109
+ is why the identical read shape works there.
110
+
111
+ **Without those rows the expanded address never comes back, the new helper returns null, and no
112
+ address is sent — exactly the broken behaviour the code fix was meant to end.**
113
+
114
+ Fixed by `dbchanges2/Client_Nychh/2026-09-09d - ApiRoleReadLocationAddress.sql` (read-only roleId 3
115
+ rows for records 69, 12, 23, `NOT EXISTS`-guarded). **Run on prod and verified: role 3 now has
116
+ `allowRead` on records 11, 12, 13, 23, 69.**
117
+
118
+ > Generalised on [nested FK ACL embedding](../../../2.0/apps/api2/features/nested-fk-acl-embedding.md):
119
+ > deepening a `depth` is an ACL change, and the branch is dropped **silently**.
120
+
121
+ ## Where the ship-to address comes from — and the location-2 problem
122
+
123
+ The address is the **destination Location's** `primaryLocationAddress.address`. Until 2026-09-09 all
124
+ **24** NYCHH shipping locations shared the head-office Accounts Payable billing address, so even a
125
+ correct push would have shipped everything to 50 Water Street; that backfill and its open questions
126
+ are in [location shipping addresses + delivery contacts](./location-shipping-addresses.md).
127
+
128
+ **Still open and still wrong for the push:** `Locations.id = 2` “NYC Health + Hospitals” carries
129
+ `Addresses.id 32536` = **1400 Pelham Parkway South**, which is **Jacobi's** address, not a real
130
+ delivery site for that location — and location 2 is the destination on the large majority of NYCHH
131
+ transfer orders. Any transfer order to location 2 will ship to Jacobi.
132
+
94
133
  ## Interceptor registration — record 312, and the deploy order matters
95
134
 
96
135
  `dbchanges2/Client_Nychh/2026-09-02a - TransferOrderNetsuitePushInterceptor.sql` inserts into
@@ -125,6 +164,17 @@ switch that turns the feature on for NYCHH and nobody else.
125
164
 
126
165
  ## Change history
127
166
 
167
+ - 2026-09-09 — **The push had never sent a shipping address**, so NetSuite used customer 28908's
168
+ default site on every order: NetSuite order **7467799** was for North Central Bronx yet shipped to
169
+ Jacobi's *1400 Pelham Parkway South*, and SO **289187** (Woodhull) shows the same. The fix reads the
170
+ destination Location's `primaryLocationAddress.address`, which needs a **depth-6** read — and that
171
+ read was **dead on arrival for NYCHH**: `Client_Nychh.AclRecordPermissions` had **no roleId 3
172
+ (“API”) row** for Core records **69 Location addresses / 12 States / 23 Countries**, only roleId 1
173
+ (“Base”), which does not inherit. `Client_Compass` already had role 3 on all three. Added them with
174
+ `2026-09-09d - ApiRoleReadLocationAddress.sql` — **run on prod, role 3 now reads records 11, 12, 13,
175
+ 23, 69.** The worker2 / `_underscore` code side is written and `php -l` clean but **not deployed and
176
+ not committed**. Also noted that location **2** still holds Jacobi's address, so transfers to the
177
+ catch-all destination will ship to the wrong hospital. (bala)
128
178
  - 2026-09-08 — **First real order pushed (SO 289187 / id 7463537), and the customer was wrong until
129
179
  the same day.** The destination location was being sent as the NetSuite customer, which put SO
130
180
  **7462031** on sub-customer **33674** (CHS) instead of **28908** — `Locations` 1–26 are "Shipping"
@@ -18,7 +18,7 @@ project: _Underscore
18
18
  client: nychh
19
19
  type: profile
20
20
  status: active
21
- updated: 2026-09-09
21
+ updated: 2026-09-10
22
22
  owners: ["jcardinal", "apeterson", "bala", "akhokhani"]
23
23
  files:
24
24
  - dbchanges2/Client_Nychh/2026-09-02a - TransferOrderNetsuitePushInterceptor.sql
@@ -32,7 +32,10 @@ files:
32
32
  - dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql
33
33
  - dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
34
34
  - dbchanges2/Client_Nychh/2026-09-02a - TalosAssistantEnable.sql
35
+ - dbchanges2/Client_Nychh/2026-09-09c - UpdateLocationShippingAddresses.sql
35
36
  related:
37
+ - ./features/location-shipping-addresses.md
38
+ - ../../2.0/apps/dbchanges2/workflows/deleting-contacts-from-a-client-database.md
36
39
  - ../../2.0/apps/_underscore/features/tracking-number-bridges.md
37
40
  - ../../2.0/apps/_underscore/features/tableview-joins.md
38
41
  - ./features/po-number-upstream-direction.md
@@ -44,6 +47,7 @@ related:
44
47
  - ./features/talos-assistant-enablement.md
45
48
  - ../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md
46
49
  - ../../2.0/apps/toga25-supply/features/transfer-orders-page.md
50
+ - ./features/location-shipping-addresses.md
47
51
  ---
48
52
 
49
53
  ## Summary
@@ -58,6 +62,17 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
58
62
  run the NetSuite→TOGa Supply sync and the asset-tag verification/backfill diagnostics.
59
63
 
60
64
  ## Key features (this client)
65
+ - **All 24 shipping locations shared ONE address until 2026-09-09 — the head-office Accounts
66
+ Payable bill-to (50 Water St).** Each location did have its own `Addresses` row (261-303); the
67
+ rows just held identical values, and locations 1 + 2 shared `Addresses.id = 1`.
68
+ `Client_Nychh/2026-09-09c - UpdateLocationShippingAddresses.sql` backfilled each from its NetSuite
69
+ customer's `defaultshipping` address (applied + verified in prod: 22 distinct addresses over 24
70
+ locations). It only ever showed on the **transfer-order** screens, because `TransferOrders` has no
71
+ ship-to column while `SalesOrders` carries its own `shipToAddressId`. 🚩 Still open: catch-all
72
+ destination **location 2 "NYC Health + Hospitals"** (1,832 of 1,939 transfers, all
73
+ `createdByUserId` NULL) currently holds **Jacobi's** address, and Bellevue/Jacobi carry NetSuite
74
+ customer ids (29275 / 31917) that no longer exist. See
75
+ [NYCHH location shipping addresses](./features/location-shipping-addresses.md).
61
76
  - 🚨 **sandbox-client (`Core` + `Client_Nychh`) was reset from PRODUCTION again on 2026-09-04** —
62
77
  the second reset in two days (the first, 2026-09-03, was over the stale `purchase-orders`
63
78
  TableView). Every "verified on <date>" sandbox-client claim in this profile dated **before
@@ -267,9 +282,11 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
267
282
  Location is the one active `allowSending = 1` location, read-only from open; all lines on a
268
283
  transfer must share one PO. `Locations.allowReceiving` is **unmaintained** for this tenant (1 of 31
269
284
  active locations, and it is the source warehouse), so the target dropdown lists all active
270
- locations minus the source. Contacts are sparse (2 of 31) and the populated join is
271
- **`Contacts_Locations`** (record 76), not `Locations_Contacts`. See
272
- [Transfer Orders page](../../2.0/apps/toga25-supply/features/transfer-orders-page.md).
285
+ locations minus the source. The populated contact join is **`Contacts_Locations`** (record 76),
286
+ not `Locations_Contacts`; contacts were sparse until 2026-09-09, when 12 test rows were replaced by
287
+ **19 real delivery contacts** (18 location links). See
288
+ [Transfer Orders page](../../2.0/apps/toga25-supply/features/transfer-orders-page.md) and
289
+ [location shipping addresses + delivery contacts](./features/location-shipping-addresses.md).
273
290
  - **Inventory-adjustment → item-fulfillment linking** — imports NetSuite `custbody_stock_adjustment`
274
291
  (adjustment→fulfillment) into two all-client bridges for adjustment→fulfillment→SO→PO traceability;
275
292
  gated NYCHH-only, best-effort, with on-demand fulfillment import. Go-live toggle
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.790",
3
+ "version": "1.0.792",
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",