toga-ai 1.0.805 → 1.0.807

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,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-10
10
- owners: [snaredla, jcardinal, bala]
9
+ updated: 2026-09-14
10
+ owners: [snaredla, jcardinal, bala, apeterson]
11
11
  files:
12
12
  - _underscore/Model.php
13
13
  - _underscore/Model/Client/PurchaseOrder.php
@@ -218,6 +218,19 @@ characters — that is the standard of evidence for touching a field that runs o
218
218
  > even for a client whose subclass overrides it. `_Model_Compass_SalesOrder` has exactly this in its
219
219
  > ApprovalDecision notification query. Late static binding only happens with `static::`.
220
220
 
221
+ ## Filtering on a calculated field through the V2 API — it works, bare name only
222
+
223
+ A calculated field **can** be used in a V2 `where`, and it is the cheapest way to cut a big list —
224
+ but **only with the BARE name** (`_qtyAvailable`), never table-prefixed (`Table._qtyAvailable`,
225
+ which is routed to a `HAVING` and returns 500 / `EO-1`). The full mechanism, the `ge`-not-`gte`
226
+ and string-value traps, and the measured cost numbers live in
227
+ [V2 REST query contract](../../api2/features/v2-rest-query-contract.md) — do not restate them here.
228
+
229
+ Two consequences for whoever **writes** a calculated field: its SQL expression is inlined into a
230
+ `WHERE`, so it must be a self-contained expression valid outside the `SELECT` list, and it is
231
+ evaluated **per row** — the measured cost is roughly **1 s per named `_qty*` field per 1,238 rows**.
232
+ Keep the expression cheap, or expect callers to pay for it on every list.
233
+
221
234
  ## Gotchas / known issues
222
235
 
223
236
  - **⚠ Never add a parameter TYPE to an override whose parent declares the parameter untyped - it
@@ -255,6 +268,10 @@ characters — that is the standard of evidence for touching a field that runs o
255
268
  [save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
256
269
 
257
270
  ## Change history
271
+ - 2026-09-14 — Added a **Filtering** section: a calculated field IS usable in a V2 `where` with the
272
+ bare (non-table-prefixed) name, which contradicts a belief written into several frontend repos.
273
+ Mechanism and cost figures cross-referenced to the api2 query contract rather than duplicated.
274
+ (apeterson)
258
275
  - 2026-09-10 — Added two NYCHH-only calc fields on `_Model_Nychh_Unit`
259
276
  (`_underscore/Model/Nychh/Unit.php`) for the rebuilt `units-for-items-for-purchase-orders` grid,
260
277
  both plain `FIELD_SQL` (text — no `FIELDOPT_SQL_TYPE`): **(a)** an **override** of
@@ -6,12 +6,13 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-09-11
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - _underscore/Test/bootstrap.php
13
13
  - _underscore/Test/Prudential/ServiceRequestTest.php
14
14
  - test/@Bala/tests/netsuite_salesorder_payload_tests.php
15
+ - _underscore/Test/Compass/
15
16
  related:
16
17
  - ../../../../clients/prudential/features/service-request-address-validation.md
17
18
  - ../../worker2/features/netsuite-salesorder-outbound-push.md
@@ -44,6 +45,7 @@ Prudential `validateCustomer()` guard (see the service-request address-validatio
44
45
  4. Run it with:
45
46
 
46
47
  ```
48
+ # there is no phpunit binary in the repo - see the section below
47
49
  phpunit --bootstrap _underscore/Test/bootstrap.php _underscore/Test
48
50
  ```
49
51
 
@@ -51,6 +53,29 @@ The initial suite covers the `validateCustomer()` guard: absent `customer`, null
51
53
  empty-string `uuid`, whitespace-only `uuid`, error-message content, and a valid `uuid` passing.
52
54
  Verified 6/6 passing against the real code.
53
55
 
56
+ ### There is no PHPUnit - newer suites are standalone runnable scripts
57
+
58
+ `_underscore` still has **no `composer.json` and no `phpunit` binary**, so the `phpunit --bootstrap`
59
+ line above only works if a developer has PHPUnit installed globally. The pattern that actually
60
+ travels is a **plain runnable script**: `php Test/Compass/<file>.php`, **exit 0 on pass**. The
61
+ Compass step-1 approval guard (`_underscore/Test/Compass/`, branch `TRUE-81900`) added three shapes
62
+ worth reusing:
63
+
64
+ 1. **Stubbed** - fakes `_Query` and drives the real guard down every branch. No database.
65
+ 2. **Real-database** - points `_Query` at a **local MySQL copy** so the generated SQL actually
66
+ executes against the real schema. It finds its own fixtures **by query** and reports **SKIP**
67
+ (not FAIL) when a particular copy lacks them, so it never fails for the wrong reason.
68
+ 3. **Replay sweep** - replays real historic approvals and compares **every** verdict against plain
69
+ SQL (6,000 approvals for that guard).
70
+
71
+ The stubbed suite proves the logic, the real-database suite proves the **SQL** is valid against the
72
+ live schema, and the sweep proves the verdict matches production reality. They are complementary -
73
+ picking only one leaves a whole class of bug uncovered.
74
+
75
+ **Prove the suite catches regressions with mutation testing.** There is no coverage tool here, so
76
+ three deliberate breaks were introduced and each had to be caught before the suite was trusted. Do
77
+ this for any hand-rolled suite.
78
+
54
79
  ## Gotchas / known issues
55
80
 
56
81
  - **Reflection on private methods is the pattern here** — the interceptor validators are private,
@@ -69,7 +94,25 @@ Verified 6/6 passing against the real code.
69
94
  `--bootstrap` file to wire up requires. Adding more model tests means extending
70
95
  `Test/bootstrap.php` with the stubs that model needs.
71
96
 
97
+ - **⚠ On a PUT, read request-only intent from `$api->httpPayload`, never from the merged
98
+ record.** The merged record always carries `isApproved` from the **existing DB row**, so a guard
99
+ that read it there treated a plain manager reassignment as a **false approval**. The stubbed suite
100
+ caught this during development - it is the concrete payoff of the stub-the-DB pattern.
101
+ - **A real-database suite must SKIP on a missing fixture, not FAIL.** Local prod copies differ
102
+ between developers; a suite that hard-fails when a specific order is absent gets ignored, and an
103
+ ignored suite protects nothing.
104
+
72
105
  ## Change history
106
+ - 2026-09-11 - **Corrected the run story and added the three-suite pattern.** `_underscore` has no
107
+ PHPUnit binary, so suites are **standalone scripts** run as `php Test/<path>.php` (exit 0 = pass).
108
+ The Compass step-1 approval guard shipped three complementary suites in `_underscore/Test/Compass/`
109
+ (branch `TRUE-81900`): a **stubbed** one that fakes `_Query` and covers every branch, a
110
+ **real-database** one that runs the generated SQL against a local prod copy and reports **SKIP**
111
+ when fixtures are missing, and a **replay sweep** that re-decides 6,000 real approvals and compares
112
+ each verdict against plain SQL. **Mutation testing** (three deliberate breaks, all caught) was used
113
+ to prove the suite detects regressions. Recorded the real bug the stubbed suite found: on a PUT the
114
+ merged record always carries `isApproved` from the DB row, so reading it there instead of
115
+ `$api->httpPayload` turns a manager reassignment into a false approval. (bala)
73
116
  - 2026-08-10 — Noted that the stub + reflect-into-privates pattern also carries to **worker2 actions**
74
117
  driven from the `test` repo (`test/@Bala/tests/netsuite_salesorder_payload_tests.php`, 76 tests,
75
118
  plain PHP, loading the real `Worker/Netsuite/SalesOrder.php`) — a second worked example of testing
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-02
9
+ updated: 2026-09-11
10
10
  owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -623,6 +623,13 @@ and the failing environment**. It is a small table, and the drift is usually exa
623
623
  specifics belong in `_Model_<Slug>_X` overrides + interceptor hooks, **not** in `V2.php`.
624
624
 
625
625
  ## Change history
626
+ - 2026-09-11 - Live instance of the "not registered = dead code" rule: `recordId 179`
627
+ (approval-decisions) carried only PRE/PUT, POST/PUT and POST/POST in **both** Compass tenants, so a
628
+ newly added `_Model_Compass_ApprovalDecision::prePost()` would never have run and would have raised
629
+ **no error**. `dbchanges2/Client_Compass/2026-09-10b - RegisterApprovalDecisionPrePostInterceptor.sql`
630
+ and its `Client_CompassCanada` twin add the PRE/POST row, `NOT EXISTS`-guarded so they are safe to
631
+ re-run. Checking the registration table **before** writing a new hook is the cheap step that avoids
632
+ this. (bala)
626
633
 
627
634
  - 2026-09-02 — Recorded the **deliberate shared-model exception**: a hook may live on
628
635
  `_Model_Client_X` when the behaviour is client-agnostic and the interceptor row is the gate (no row,
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-08
9
+ updated: 2026-09-14
10
10
  owners: [tcox, bala, apeterson, jcardinal, rgirish]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -111,6 +111,29 @@ The same route's **list GET was ~1s**, so this is the **write read-back**, not t
111
111
  `api2`/`_underscore` is an **open follow-up** (needs a `cto` review before touching the shared
112
112
  serializer). Lowering caller depth is a mitigation, not the fix.
113
113
 
114
+ ### Measured cost of a list GET — it is per ROW × per NAMED CALCULATED FIELD × per FK hop
115
+
116
+ One `GET /v2/purchase-order-items` over the **same 1,238 rows** (NYCHH, `sandbox-client`), one curl
117
+ per line, 2026-09-14:
118
+
119
+ | Request | Time |
120
+ |---|---|
121
+ | full field set as the app sent it, `calcDepth: 3` | **13.3 s** (785 KB) |
122
+ | identical but `calcDepth: 1` | 13.3 s (byte-identical response) |
123
+ | minus `_qtyOnHand` / `_qtyReceived` / `_qtyFulfilled` | 10.2 s |
124
+ | minus `_qtyAvailable` as well | 6.1 s |
125
+ | minus the `vendorItem → item` FK chain | 4.0 s |
126
+ | `uuid`/`lineNumber`/`quantity` only — no FK, no calculated | 1.3 s |
127
+ | full field set at `recordsPerPage: 100` | 1.7 s |
128
+ | full field set **+ `where=(_qtyAvailable:ge:1)`** | **0.35 s** |
129
+
130
+ Read: ~**11 ms per row** at the full field set. Three named `_qty*` calculated fields cost **3.1 s**
131
+ across 1,238 rows; the `vendorItem → item` FK expansion another **~2 s**.
132
+
133
+ **Therefore, in order of payoff:** (1) filter server-side so fewer rows are built at all — a
134
+ `where` cut 13.3 s to 0.35 s, ~38×; (2) drop calculated fields nothing renders; (3) drop FK hops
135
+ nothing renders; (4) page. Raising/lowering `calcDepth` changed nothing.
136
+
114
137
  ### ⚠ `calcDepth` defaults to **1** — a calculated field one FK hop out comes back NULL, silently
115
138
 
116
139
  `calcDepth` bounds how deep `FIELD_SQL` **calculated** fields are evaluated, independently of
@@ -127,6 +150,22 @@ calculated field is null on a nested object but correct when that object is fetc
127
150
  Definition of the fields themselves:
128
151
  [FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
129
152
 
153
+ #### ⚠ …but `calcDepth` does NOT gate a calculated field you NAME EXPLICITLY in `fields`
154
+
155
+ The rule above holds for the **no-explicit-`fields`** path only. A calculated field written as a
156
+ **dotted path inside `fields=`** — e.g. `vendorItem.item._thumbnailImageUrl`, two FK hops out — is
157
+ classified as calculated at `V2.php` ~L3338, **before** the `calcDepth` gate is reached, so it
158
+ resolves at the default `calcDepth: 1`.
159
+
160
+ Verified 2026-09-14 on `GET /purchase-order-items` (1,238 NYCHH lines): the response is
161
+ **byte-identical** at `calcDepth: 1` and `calcDepth: 3`, with the same 473 non-null thumbnails. So
162
+ raising `calcDepth` "to cover the deepest hop" for a field you already named is **pure cost with no
163
+ effect** — it does not make the request slower on its own, but it hides the real cost driver (the
164
+ number of calculated fields, below).
165
+
166
+ **Decide which path you are on first:** naming the field ⇒ ignore `calcDepth`; letting
167
+ `getFullModelData()` serialize everything ⇒ the one-per-FK-hop rule applies.
168
+
130
169
  ### ⚠ A non-null FK object at the MAX requested `depth` is OMITTED from the response
131
170
 
132
171
  A GET serialized to `depth: N` renders FK objects down to level N, but a **non-null FK object that
@@ -276,6 +315,55 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
276
315
  - **NULL has no operator.** Use the literal value `null`: `field:eq:null` → `IS NULL`,
277
316
  `field:ne:null` → `IS NOT NULL`.
278
317
 
318
+ ### ✅ You CAN `where` on a calculated (`FIELD_SQL`) field — but ONLY with the BARE name
319
+
320
+ This corrects a widespread belief that a `where` on a calculated field is impossible. It works, and
321
+ it is the single biggest performance lever on a large list (13.3 s → 0.35 s above). The **only**
322
+ thing that has to be right is the shape of the field name:
323
+
324
+ ```
325
+ where=(_qtyAvailable:ge:1) ✅ plain WHERE — HTTP 200, 0.35 s
326
+ where=(PurchaseOrderItems._qtyAvailable:ge:1) ❌ becomes a HAVING — HTTP 500 / EO-1
327
+ ```
328
+
329
+ Mechanism, read from `api2/Component/Api/V2/V2.php`:
330
+
331
+ 1. `buildWhereExpressionsFromOptions` (~L6953) **deliberately does not prepend the default table**
332
+ to a field starting with `_` — its own comment says *"add the default table if not specified and
333
+ is not a calculated field."*
334
+ 2. `buildWhereClauseFromWhereExpressions` (~L7069) sees the leading `_` and **inlines the field's own
335
+ SQL expression** straight into the `WHERE` clause. That is the working path.
336
+ 3. `splitWhereExpressionsIntoWhereHaving` (~L8622) routes to `HAVING` **only when the field contains
337
+ `._`** — i.e. only the table-**prefixed** form. That form targets a joined-table select alias
338
+ (`PurchaseOrderItems__qtyAvailable`, built ~L3984) which **does not exist for the main table**,
339
+ and ~L4116 also appends the `HAVING` to the `$sqlProhibited` COUNT query, which has no such alias
340
+ either. Hence the 500.
341
+
342
+ **The COUNT query is fine on the bare form.** `meta.totalRecordCount` comes back correct (`4`), and a
343
+ filter that matches nothing returns a clean **200 with `totalRecordCount: 0`** — verified, it does
344
+ **not** error on the empty case. So pagination is not broken by this, contrary to what several
345
+ in-repo comments claim.
346
+
347
+ Three things that fail **silently or misleadingly** and are worth locking with a test:
348
+
349
+ - **The name must be bare** — `_qtyAvailable`, never `Table._qtyAvailable`.
350
+ - **The operator is `ge`, not `gte`** — `gte` returns 500 / `EO-1` (same as every other op).
351
+ - **The value must be a STRING** when you go through `@agilant/toga-blox` — `urlEncode` calls
352
+ `.replace()` on it, so a numeric value throws inside `buildUrl` and **no HTTP request is ever
353
+ sent** (see [blox api-client](../../toga-blox/features/api-client.md)).
354
+
355
+ Corroboration that this is long-standing, not new: bare calculated fields in `where` already ship in
356
+ production — `toga2-supply/src/api/toga.ts` L906-919 (`_status` OR group) and L886-893 (NYCHH
357
+ `_total:ne:0`), `toga2-supply/src/pages/Orders/api/OrdersApi.ts:1900` (`_name`),
358
+ `toga2-commerce/src/pages/Cart/api/CartApi.ts` L48/L257/L349 (`_name`),
359
+ `toga25-supply/src/layout/RecordApprovalModal/view/ApprovalFlowDetailInputs.tsx:27` (`_name`).
360
+ **Zero** occurrences of the table-prefixed form exist in any repo — nobody has ever used it
361
+ successfully.
362
+
363
+ > ⚠ **Scope this to `where` only.** It says nothing about `group` or `sort` on a calculated field;
364
+ > `group=_status` is separately known to fail. And `where` still never prunes a nested child array
365
+ > (see below).
366
+
279
367
  ## Encoding rules (the query string is never urldecoded at parse time)
280
368
 
281
369
  - Encode a literal `%` in a LIKE pattern as **`%25`**.
@@ -410,6 +498,11 @@ ACL gate for calculated fields lives at **:7216** in `getFullModelData()` — wh
410
498
  - **A 500 (`EO-1`) on a filter ⇒ suspect a `where` reference to an unjoined table.**
411
499
  - **A 500 (`EO-1`) reading `Invalid operator '' in WHERE conditions` ⇒ the `where` clause was URL-encoded.** The query string is never `urldecode`d — send it raw (see the encoding rules).
412
500
  - **`gte`/`lte` silently are not operators** — use `ge`/`le`.
501
+ - **⚠ A 500 / `EO-1` on a `where` over a calculated field ⇒ you table-PREFIXED the field name.**
502
+ Drop the prefix: `_qtyAvailable`, not `PurchaseOrderItems._qtyAvailable`. The prefixed form is
503
+ routed to a `HAVING` against an alias that does not exist.
504
+ - **⚠ Do not raise `calcDepth` for a calculated field you named in `fields=`** — it is classified
505
+ before the gate and already resolves at the default `1`.
413
506
  - **Do not trust a 200 with missing fields.** Without an explicit `fields=` list, ACL removal is
414
507
  invisible; re-request with `fields=` to force `EZ-2` and see the denied list. **⚠ Plain columns
415
508
  only** — an `_`-prefixed field named in `fields=` is never ACL-checked and returns `0`, not
@@ -428,6 +521,13 @@ ACL gate for calculated fields lives at **:7216** in `getFullModelData()` — wh
428
521
  [V2 request logging](request-logging.md).
429
522
 
430
523
  ## Change history
524
+ - 2026-09-14 — **Corrected a wrong team-wide belief: a `where` on a calculated (`FIELD_SQL`) field
525
+ WORKS, with the BARE field name.** `_qtyAvailable:ge:1` is inlined into the `WHERE` and returns 200
526
+ with a correct `totalRecordCount`; only the table-prefixed `Table._qtyAvailable` form is routed to a
527
+ `HAVING` against a non-existent alias and 500s. Also recorded that **`calcDepth` does not gate a
528
+ calculated field named explicitly in `fields=`** (byte-identical response at `calcDepth` 1 vs 3),
529
+ and added a **measured per-row cost model** for a list GET (13.3 s → 0.35 s by filtering
530
+ server-side). Found while fixing the toga25-supply Create Transfer Order item picker. (apeterson)
431
531
  - 2026-09-04 — Recorded the **URL-encoded `where` trap** hit while debugging a production Rate/AIG
432
532
  incident: `parseOptionsWhere` (`V2.php` ~L325) reads `$_SERVER['QUERY_STRING']` with no
433
533
  `urldecode()`, so an encoded clause (curl `--data-urlencode`, `requests` `params=`,
@@ -6,13 +6,15 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-31
10
- owners: [apeterson]
9
+ updated: 2026-09-11
10
+ owners: [apeterson, bala]
11
11
  files:
12
12
  - toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.ts
13
13
  - toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.test.ts
14
14
  - toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts
15
15
  - toga25-supply/src/layout/RecordApprovalModal/view/ApprovalFlowDetailInputs.tsx
16
+ - toga25-supply/src/layout/RecordApprovalModal/view/ApprovalFlowForm.tsx
17
+ - toga25-supply/src/surface/evaluateSurfaceRule.ts
16
18
  - toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/FIELDS/COMPASS/approvalActionFields.json
17
19
  - toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/FIELDS/COMPASSCANADA/approvalActionFields.json
18
20
  - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx
@@ -151,7 +153,38 @@ Retire the shim once all configs express flags as `Rule`.
151
153
  comparison must stay config-only; adding to `NAMED_RULES` is the rare exception, not the
152
154
  default.
153
155
 
156
+ - **⚠ A named rule must use a TRUTHY check, never `!== null`.** `stepTwoAssigned` tested
157
+ `stepTwo.AssignedTo?.uuid !== null`, which is **true** when the API omits `AssignedTo` altogether
158
+ (`undefined !== null`) - so a missing step-2 manager *enabled* the Approve button and let an admin
159
+ strand the order. Now a truthy check on the uuid. The `poNumberEntered` rule directly below it
160
+ already carried a comment warning about this exact trap. The matching server-side guard is in
161
+ [Compass Approval-Decision Flow](../../../clients/compass-usa/features/approval-decision-flow.md).
162
+ - **⚠ The evaluator is DUPLICATED in the surface layer.** `src/surface/evaluateSurfaceRule.ts`
163
+ holds its own copy of the named rules and had the identical `!== null` bug. A change to a named
164
+ rule must be made in **both** files - fixing one is not a fix.
165
+ - **Resolving a flag is not the same as gating the submit.** `isApproveButtonEnabled` disabled the
166
+ approve controls but **not Save Changes**, and "Approve All Stages" writes the decision straight
167
+ into form state, so a blocked approval still posted. `ApprovalFlowForm.tsx` now gates the Save
168
+ button **and** `handleValidSubmit` on an `isApprovalBlocked` flag, which requires **both** that
169
+ approving is disabled **and** that the approve is *newly* selected - compared against
170
+ `defaultValues`, because a stage approved earlier already defaults to `"1"`. The disabled Save
171
+ reuses the existing tooltip.
172
+ - **`stepTwoAssigned` is configured only for COMPASS and COMPASSCANADA** (`approvalActionFields.json`).
173
+ DEFAULT and QUAD gate Approve on `order.purchaseOrderDetails.uuid` instead, so a change to this
174
+ rule cannot reach them.
175
+
154
176
  ## Change history
177
+ - 2026-09-11 - **Fixed `stepTwoAssigned` reading a missing assignee as assigned.** The rule tested
178
+ `stepTwo.AssignedTo?.uuid !== null`, which is true when the API omits `AssignedTo` entirely, so the
179
+ Approve button was enabled on orders with no step-2 manager and admins stranded four prod orders.
180
+ Changed to a truthy uuid check, **in both** `helpers/evaluateEnableRule.ts` and the duplicate copy
181
+ in `src/surface/evaluateSurfaceRule.ts`. Also gated **Save Changes** (and `handleValidSubmit`) in
182
+ `ApprovalFlowForm.tsx` on a new `isApprovalBlocked` flag - disabling only the approve controls was
183
+ not enough, because "Approve All Stages" writes the decision into form state and the save still
184
+ posted; the flag compares against `defaultValues` so a previously approved stage is not treated as
185
+ a new approval. Affects COMPASS and COMPASSCANADA only; DEFAULT/QUAD gate on
186
+ `purchaseOrderDetails.uuid`. Ticket TRUE-81900. **Uncommitted on `_production` at capture time.**
187
+ (bala)
155
188
  - 2026-08-31 — **Added named rule `stepOneUndecided` and extended `buildPatchedTenantFields` to
156
189
  resolve flags on `approvalWorkflow.stages[].inputs[]`**, so a VIP-pre-approved step-2 manager can
157
190
  be reassigned (Compass USA + Compass Canada). `ApprovalFlowDetailInputs` now treats a resolved
@@ -6,8 +6,8 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-04
10
- owners: [apeterson, tcox, jcardinal]
9
+ updated: 2026-09-11
10
+ owners: [apeterson, tcox, jcardinal, bala]
11
11
  files:
12
12
  - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts
13
13
  - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx
@@ -358,7 +358,37 @@ Scaffold per the `ItemRecordModalLayout` pattern (`src/layout/ItemRecordModalLay
358
358
  [v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md) and
359
359
  [Transfer Orders page](transfer-orders-page.md).
360
360
 
361
+ - **⚠ The approval modal has TWO approve paths, and the payload shape tells them apart.**
362
+ Both are dispatched from `RecordApprovalModal/hooks/useApprovalDecisionsMutation.ts`:
363
+ - `type: "single"` (the green per-stage tick) goes through `makeApprovalDecision` /
364
+ `updateApprovalDecision` in `api/approvalDecisionsApi.ts` and sends `isApproved` as a
365
+ **boolean** plus a `note` field.
366
+ - `type: "workflow"` ("Approve All Stages" / Approval Workflow) goes through
367
+ `helpers/handleFormatApprovalWorkflowPayload.ts` and sends `isApproved` as the **string `"1"`**
368
+ with **no** note.
369
+
370
+ That difference is how you tell from `Logs_<Tenant>.Api` which button a user actually pressed -
371
+ the first step in any approval investigation, and the reason a bug can live in one path only.
372
+ - **⚠ The single-stage approve must NOT send `assignedToUserId`.** `makeApprovalDecision()`
373
+ and `updateApprovalDecision()` hardcoded `assignedToUserId: { uuid: userUuid }`, so every
374
+ green-tick approve re-sent the **logged-in user** as the stage assignee whether or not anyone
375
+ touched the field. An admin approving the manager stage therefore **overwrote the real manager
376
+ with themselves**, and the order timeline then read "Manager Approved" - it looked like the
377
+ manager had approved. Removed from both functions; `decidedByUserId` already records who approved.
378
+ The workflow path was always correct (it sends the assignee only when it actually changed), which
379
+ is exactly why the bug never reproduced through "Approve All Stages". Real orders: SA136901,
380
+ SA136807, SA137080. Client context:
381
+ [Compass Approval-Decision Flow](../../../clients/compass-usa/features/approval-decision-flow.md).
382
+
361
383
  ## Change history
384
+ - 2026-09-11 - ⚠ **Removed the hardcoded `assignedToUserId` from the single-stage approve
385
+ payload** (`approvalDecisionsApi.ts`). Every green-tick approve was re-sending the logged-in user
386
+ as the stage assignee, so an admin approving the manager stage silently replaced the real manager
387
+ and the timeline then read "Manager Approved" (prod: SA136901, SA136807, SA137080). Also recorded
388
+ the **two approve paths and their differing payload shapes** (`single` = boolean `isApproved` +
389
+ `note`; `workflow` = string `"1"`, no note), which is how the two are told apart in
390
+ `Logs_<Tenant>.Api` and why the workflow path never showed the bug. Ticket TRUE-81900.
391
+ **Uncommitted on `_production` at capture time.** (bala)
362
392
  - 2026-09-04 — Added the Pattern 6 gotcha that the **item modal's VIEW is Surface-driven while its
363
393
  EDIT is still JSON**: a field visible only in edit mode means a missing client `SurfaceOverride`,
364
394
  not a data bug (found on Compass's "Restrict to Persona" field). Detail in
@@ -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-10
9
+ updated: 2026-09-14
10
10
  owners: [apeterson, bala]
11
11
  files:
12
12
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateTransferOrder.ts
@@ -30,6 +30,7 @@ files:
30
30
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/CreateTransferOrderModal.tsx
31
31
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/index.ts
32
32
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/usePurchaseOrderItemRows.ts
33
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/usePurchaseOrderItemRows.test.ts
33
34
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTargetLocationOptions.ts
34
35
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTransferSourceLocation.ts
35
36
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useLocationContacts.ts
@@ -457,10 +458,14 @@ New hooks/components: `hooks/usePurchaseOrderItemRows.ts`, `hooks/useTargetLocat
457
458
  No parent FK exists; the design's drill-down was dropped.
458
459
  3. **Commit flips unit dispositions to In Transit** — still open (API vs. worker ownership).
459
460
 
460
- ### 🚨 OPEN BLOCKER — the NYCHH picker has zero selectable rows, and it is not a frontend fix
461
+ ### 🚨 OPEN BLOCKER — NYCHH availability is structurally 0, and it is not a frontend fix
461
462
 
462
- The picker keeps only lines with availability >= 1. For NYCHH there are **none**, in sandbox **and**
463
- production.
463
+ The picker keeps only lines with availability >= 1.
464
+
465
+ > **State as of 2026-09-14 (`sandbox-client`): 4 of 1,238 lines now qualify** — availability equal to
466
+ > the full ordered quantity on each. It is no longer literally zero there, so a tester does get rows.
467
+ > **Production not re-checked.** The blocker itself is unchanged: 1,234 of 1,238 still report 0.
468
+ > Uuids and figures: [NYCHH transfer-order inventory quantities](../../../../clients/nychh/features/transfer-order-inventory-quantities.md).
464
469
 
465
470
  `_Model_Nychh_PurchaseOrderItem` (deployed on `_production` and `_sandbox-client`, absent on
466
471
  `_beta`/`_sandbox-dev`) sources "stock in" from inventory adjustments and `INNER JOIN`s
@@ -547,6 +552,82 @@ push) — see
547
552
  `contact: { uuid }`, `createdByUser: { uuid }`. Each is omitted entirely when absent rather than sent
548
553
  as `null`: a location can have no contact, and an SSO session can lack a user uuid.
549
554
 
555
+ ### The picker fetch — 13.3 s → 0.39 s by filtering SERVER-side (2026-09-14)
556
+
557
+ `hooks/usePurchaseOrderItemRows.ts` used to fetch **every** NYCHH purchase-order line and then drop
558
+ the unavailable ones with a client-side `.filter(r => r.available >= 1)`. That cost **13.3 s** to
559
+ open the modal and built 1,238 rows to keep 4.
560
+
561
+ The availability filter now goes in the request:
562
+
563
+ ```ts
564
+ where: { and: [{ _qtyAvailable: { ">=": "1" } }] }
565
+ // blox serializes this to where=(_qtyAvailable:ge:1)
566
+ ```
567
+
568
+ Measured end to end: **0.39 s**, same 4 rows, every row with its linked item uuid, PO number and
569
+ thumbnail key. Four changes made it, in order of payoff:
570
+
571
+ 1. **The `where` above** — 13.3 s → 0.35 s on the wire. This is the whole win.
572
+ 2. **Dropped `calcDepth: 3`.** It was sent to "reach" `vendorItem.item._thumbnailImageUrl`, two FK
573
+ hops out. `calcDepth` does **not** gate a calculated field you name explicitly in `fields` — the
574
+ response is byte-identical at 1 and 3.
575
+ 3. **Dropped `_qtyOnHand` / `_qtyReceived` / `_qtyFulfilled` from the main fetch** — 3.1 s, and
576
+ nothing renders them; they only fed a diagnostic string (see the probe below).
577
+ 4. **Removed the `onHand` field from `PurchaseOrderItemRow`** and the `noAvailabilityCount`
578
+ diagnostic, both unrendered.
579
+
580
+ Three traps, all of which fail **silently**, so the wire form is now locked by a regression test
581
+ (`hooks/usePurchaseOrderItemRows.test.ts`, 6 tests) and the request options are built by a pure
582
+ exported `buildRowRequestOptions(ignoreAvailability)` so they are testable:
583
+
584
+ - **The field name must be BARE.** `PurchaseOrderItems._qtyAvailable` becomes a `HAVING` and 500s.
585
+ - **The operator is `ge`, not `gte`.**
586
+ - **The value must be the STRING `"1"`.** A number throws inside blox `buildUrl` and no request is
587
+ sent at all.
588
+
589
+ Full mechanism and the measured cost table:
590
+ [V2 REST query contract](../../api2/features/v2-rest-query-contract.md).
591
+
592
+ > 🧹 **Stale comments to correct when you are next in these files.** The opposite belief — *"a WHERE
593
+ > on a calculated field becomes a HAVING, and a HAVING breaks V2's count query and therefore
594
+ > pagination"* — is written into six places and is why nobody tried it. Corrected in
595
+ > `usePurchaseOrderItemRows.ts` this session; **still wrong** in `src/hooks/useEntitySearch.ts:15-19`,
596
+ > `src/layout/ItemRecordModalLayout/viewModel/useItemRecordEditViewModel.tsx:44-45`,
597
+ > `src/layout/VendorItemRecordModalLayout/viewModel/useVendorItemRecordEditViewModel.tsx:69-70`,
598
+ > `src/layout/BundleRecordModalLayout/viewModel/useBundleRecordCreateViewModel.tsx:9-10`,
599
+ > `src/layout/BundleRecordModalLayout/viewModel/useBundleRecordEditViewModel.tsx:10`,
600
+ > `src/layout/BundleItemRecordModalLayout/viewModel/useBundleItemSelectsViewModel.tsx:9`.
601
+
602
+ #### ⚠ Moving a filter server-side DESTROYS your empty-state evidence — pair it with a lazy probe
603
+
604
+ This is the reusable part, not a detail of this screen. Once the filter is on the server, **"no rows"
605
+ means two completely different things at once**: *nothing is in stock*, or *the field never came back
606
+ at all* — an ungranted or misnamed calculated field is dropped from the SELECT silently and reads as
607
+ a real `0`. The old code could tell them apart only because it fetched everything.
608
+
609
+ The pattern adopted here: a **second `useQuery`, `enabled` only when the main query succeeds with
610
+ zero rows**, fetching an **unfiltered sample** — 5 rows, `depth: 1`, the full
611
+ `_qtyReceived`/`_qtyFulfilled`/`_qtyOnHand`/`_qtyAvailable` chain. It costs **0.27 s and only on the
612
+ failure path**; the happy path pays nothing. It restores the `sampleBreakdown` string and the
613
+ "field absent vs. real zero" count, and its `meta.totalRecordCount` gives the tenant's true line
614
+ count (1,238) — which the filtered main request no longer reports.
615
+
616
+ `CreateTransferOrderModal.tsx` gained a `diagnostics.isPending` branch so the empty state reads
617
+ **"Checking purchase-order lines…"** rather than accusing the API of returning 0 lines while the
618
+ probe is still in flight.
619
+
620
+ #### ⚠ A dev flag that overrides a value CLIENT-side must also drop the SERVER-side filter on it
621
+
622
+ `VITE_TRANSFER_IGNORE_AVAILABILITY=true` substitutes the PO line's ordered `quantity` for
623
+ `_qtyAvailable` **client-side**. With the filter moved to the server, the API would return only the
624
+ already-available lines and there would be nothing left to substitute — the escape hatch would have
625
+ died silently. The flag therefore now **also omits the `where` clause entirely**.
626
+
627
+ That is the general rule, and it is why the client-side `available >= 1` filter was **kept**: it is a
628
+ no-op on the normal path, and it is what actually filters under the escape hatch, which sends no
629
+ `where`.
630
+
550
631
  ### The picker is VIRTUALIZED — spacer rows inside a real `<table>` (2026-09-03)
551
632
 
552
633
  `view/TransferItemPickerTable.tsx` renders through **`@tanstack/react-virtual`**. The rendering
@@ -563,6 +644,50 @@ Measured on a 1,232-row list: median keystroke latency **248 ms → 24 ms**, DOM
563
644
  > whatever the user is actually typing in. Replaced with an explicit `ref` + `useEffect` that focuses
564
645
  > **once**, on the row that was just checked.
565
646
 
647
+ ### Skeleton loading in the picker — real rows under the table's own `<colgroup>` (2026-09-14)
648
+
649
+ `view/TransferItemPickerTable.tsx` replaced the single "Loading items…" text cell with **8 placeholder
650
+ rows**. They are real `.row` / `.td` elements rendered **under the table's own `<colgroup>`**, so
651
+ column widths, row height and dividers match the loaded table exactly and **nothing shifts sideways**
652
+ when data arrives — which a single spanning text cell cannot do.
653
+
654
+ - Reuses the module's existing `.skBlock` / `toTransferPulse` treatment (the one the Transfer Order
655
+ **record** modal already uses) rather than introducing a second skeleton style.
656
+ - Rows are `aria-hidden`, with `aria-busy` on the scroll region; the existing
657
+ `prefers-reduced-motion` block now covers them too.
658
+
659
+ Two gotchas:
660
+
661
+ - **`.skRow .skBlock` is `display: block`, so `.selCell`'s `text-align: center` does NOT centre the
662
+ checkbox placeholder** — it needs `margin: 0 auto`. The real control is an inline `<input>`, which
663
+ is why the loaded row looks right and only the skeleton looked wrong. Same reason
664
+ `.alignRight .skBlock` needs `margin-left: auto`.
665
+ - **Keep the skeleton branch separate from the virtualizer branch**, or the two spacer `<tr>`s render
666
+ while loading.
667
+
668
+ > ⚠ **Not in the Claude Design source** — `demo/transfer-modal.jsx` has no loading state. This follows
669
+ > the in-repo record-modal treatment instead, and was flagged to the developer as a deviation.
670
+
671
+ ### Testing the picker hook — mock the blox barrel or the suite dies on `global.css`
672
+
673
+ A test importing a module that imports `apiGet` from the `@agilant/toga-blox` barrel fails with:
674
+
675
+ ```
676
+ TypeError: Unknown file extension ".css" for node_modules/@agilant/toga-blox/dist/global.css
677
+ ```
678
+
679
+ House fix, matching `src/components/TableStateBlock/TableStateBlock.test.tsx`:
680
+
681
+ ```ts
682
+ vi.mock("@agilant/toga-blox", () => ({ apiGet: () => undefined }));
683
+ ```
684
+
685
+ The 6 picker tests were **seen to fail before passing**: reintroducing the table-prefixed field name
686
+ turned 3 red, making the value numeric turned 2 red, green again after restore.
687
+
688
+ > `src/hooks/useTalosSurface.test.ts` fails with the **same** `global.css` error and is
689
+ > **pre-existing** — it fails identically with this work stashed. Do not chase it as a regression.
690
+
566
691
  ### A new transfer POSTs an initial stage — and the stage is sent as `{ id }`, NOT `{ uuid }`
567
692
 
568
693
  `hooks/useCreateTransferOrder.ts` now sends an opening stage. **`TransferOrderStages` has no `uuid`
@@ -922,6 +1047,11 @@ deliberate, separate exception — see [surface-frontend](./surface-frontend.md)
922
1047
  NOT deployed as of 2026-09-10**, so the id still shows in production.
923
1048
  - **⚠ An empty dropdown with no request in the network tab is a numeric `where` value or a
924
1049
  non-zero `staleTime`** — see the section above. Neither logs anything.
1050
+ - **⚠ A `where` on a calculated field works — but BARE only.** `_qtyAvailable:ge:1` is fine;
1051
+ `PurchaseOrderItems._qtyAvailable:ge:1` becomes a `HAVING` and returns 500 / `EO-1`. Several
1052
+ in-repo comments still claim the whole thing is impossible; they are wrong (list above).
1053
+ - **⚠ An empty list is ambiguous once the filter is server-side** — it could be "nothing in stock"
1054
+ or "the field never came back". Run the unfiltered diagnostic probe before blaming either.
925
1055
  - **⚠ An ungranted `uuid` field 403s the WHOLE request** — `uuid` is V2's `IDENTIFIER_FIELD` and is
926
1056
  force-added to every record read, so a record whose role lacks an `AclFieldPermissions` grant on
927
1057
  its `uuid` RecordField returns **403 `EZ-2`** listing `"fields": ["uuid"]`. Ordinary ungranted
@@ -993,6 +1123,17 @@ deliberate, separate exception — see [surface-frontend](./surface-frontend.md)
993
1123
  earlier migration without names, so a name-based lookup on them finds nothing.
994
1124
 
995
1125
  ## Change history
1126
+ - 2026-09-14 — **Create Transfer Order item picker: 13.3 s → 0.39 s.** Moved the availability filter
1127
+ into the request as `where=(_qtyAvailable:ge:1)` (bare calculated-field name — the table-prefixed
1128
+ form 500s), dropped the pointless `calcDepth: 3` and three unrendered `_qty*` fields, and locked the
1129
+ wire form with 6 regression tests. Added a **lazy unfiltered diagnostic probe** that runs only when
1130
+ the main query returns zero rows, because a server-side filter otherwise makes "nothing in stock"
1131
+ and "the field never came back" indistinguishable. `VITE_TRANSFER_IGNORE_AVAILABILITY` now also
1132
+ omits the `where`, or the escape hatch would have silently died. Added **skeleton loading rows** to
1133
+ the picker (real rows under the table's `<colgroup>`, reusing the record modal's `.skBlock`
1134
+ treatment). Also noted that the NYCHH blocker has partially cleared on `sandbox-client` — 4 of
1135
+ 1,238 lines now selectable, blocker still open. Nothing committed; work sits on `TRUE-80852`.
1136
+ (apeterson)
996
1137
  - 2026-09-10 — **Target Location dropdown now lists only shipping locations.**
997
1138
  `useTargetLocationOptions.ts` gained a second `where` condition,
998
1139
  `{ "Locations.locationTypeId": { "=": "1" } }`, so the serialized query is
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-10
9
+ updated: 2026-09-14
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite/SalesOrder.php
@@ -47,9 +47,16 @@ re-read this doc if you last saw it on 2026-09-02: **the NetSuite customer is th
47
47
  destination location**, and `description` is now sent as the NetSuite **`memo`**. Committed as
48
48
  worker2 **`cbafd25`** on `_production` and deployed.
49
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.
50
+ **Deployed 2026-09-14 and confirmed working end to end.** Two more things landed since 2026-09-08
51
+ and are the reason to re-read this doc:
52
+
53
+ 1. **The push was sending no ship-to address at all**, so NetSuite used the customer's default site
54
+ on every order. Fixed by reading the destination Location's address, which needs a depth-6 read.
55
+ 2. **Sending that address then broke every create with an HTTP 400 tax error** from 2026-09-09 to
56
+ 2026-09-14. The cause was **NetSuite customer data, not code** — the customer still carried the
57
+ retired tax group **-8**. Read
58
+ [the tax-item section](#-the-customers-netsuite-tax-item-must-be-avatax-or-every-create-400s)
59
+ before you touch anything tax related here, and before onboarding a new client to this push.
53
60
 
54
61
  ## Key files / entry points
55
62
 
@@ -104,11 +111,11 @@ status columns, so none are sent.
104
111
  | `tranDate` | `dateOrder`, date only; **omitted** when null |
105
112
  | `otherRefNum` | the PO number (the developer's explicit requirement) |
106
113
  | `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. |
114
+ | `shippingAddress` | the **destination** Location's `primaryLocationAddress.address` (added 2026-09-09, live 2026-09-14). The key is **omitted** when the destination has no address — and **omitting it is a bug, not a safe default**: NetSuite then ships to the customer's default site. See below. |
108
115
  | `externalId` | `toga-to-<transfer order uuid>` |
109
116
  | line `item` / `quantity` | each transfer-order item |
110
117
  | line `rate` | **0** — this is the zero-value part |
111
- | line `location` | the **origin** warehouse |
118
+ | line `location` | the **origin** warehouse. **`location` is NOT a ship-to** — NetSuite never uses a line's location for delivery. |
112
119
 
113
120
  ### ⚠ Destination = CUSTOMER, origin = WAREHOUSE (this was built backwards first)
114
121
 
@@ -146,6 +153,12 @@ no NetSuite customer for.
146
153
  > multi-site tenant those are different NetSuite records, and the difference is invisible until
147
154
  > someone picks the non-default site.
148
155
 
156
+ **A consequence worth knowing before you go hunting in supply:** `resolveTransferOrderCustomer()`
157
+ always lands on the **client's** customer, so **changing the destination location cannot change the
158
+ NetSuite customer**. When a problem is customer-level (a tax item, a hold, a wrong subsidiary),
159
+ picking a different destination site is not a workaround — the destination only supplies the
160
+ ship-to address.
161
+
149
162
  **Verified live 2026-09-08 on SO 289187 (id 7463537):** destination Woodhull (customer **31925**),
150
163
  customer on the NetSuite order came out **4490 / NYC Health + Hospitals**, and ORDER DESCRIPTION
151
164
  showed the transfer order's `description`.
@@ -155,7 +168,7 @@ destination whose customer id already equals the client's own (**30205** and **3
155
168
  and new rules produce the same id for them.
156
169
 
157
170
 
158
- ### ⚠ The ship-to address was NEVER sent — and it needs a depth-6 read (fixed 2026-09-09, not deployed)
171
+ ### ⚠ The ship-to address was NEVER sent — and it needs a depth-6 read (fixed 2026-09-09, live 2026-09-14)
159
172
 
160
173
  `buildSalesOrderShapeFromTransferOrder()` never set a `shipToAddress` key, and the `shippingAddress`
161
174
  block in `buildNetSuiteOrder()` (~L922) only runs when that key exists. So **every transfer order
@@ -210,6 +223,35 @@ null, and the code fix would have been a **silent no-op**. Fixed by
210
223
  > row drops the branch silently instead of erroring — see
211
224
  > [nested FK ACL embedding](../../api2/features/nested-fk-acl-embedding.md).
212
225
 
226
+ #### ⚠ Dropping the address is NOT a workaround — NetSuite ships to the customer default, silently
227
+
228
+ When the payload omits the ship-to address the create **succeeds**, so it looks like a fix. It is
229
+ not. NetSuite falls back to whichever entry in the **customer's** address book is flagged
230
+ `defaultshipping = T`, with no warning anywhere in the response. Because the customer is the client's
231
+ whole account (previous section), that is one fixed site for **every** destination — NYCHH customer
232
+ 28908 has **124** saved addresses and the default is *NYCHHC Jacobi Dental* (address id 6568575,
233
+ 1400 Pelham Parkway South, Bronx NY 10461). **5 of the 6** transfer orders that reached NetSuite
234
+ before the fix shipped there regardless of their real destination (orders 7453238, 7462009, 7463537,
235
+ 7467799, 7482373); only 7462031 was right, and only by accident — it predates the switch to the
236
+ parent customer and was built on destination sub-customer 33674, whose own default happens to be the
237
+ destination.
238
+
239
+ > **Durable rule: "drop the address to get past the 400" is a wrong-address bug, not a fix.** A
240
+ > successful create proves nothing about where the goods go. And the line-level `location` does not
241
+ > save you — that is the **source** warehouse (NetSuite internal id 117 for NYCHH) and NetSuite never
242
+ > uses it for delivery.
243
+
244
+ #### Depth 6 is verified, and there is a decoy branch next to it
245
+
246
+ `_Model_Client_TransferOrder::NETSUITE_READ_DEPTH = 6` was checked against the raw
247
+ `Core.WorkerJobs.parameters` blob of a real job, not just reasoned about: at 6 the full
248
+ `destinationLocation.primaryLocationAddress.address.state.country` branch comes through.
249
+
250
+ **The decoy:** the same payload also carries `contact.contactLocations[].location...address`, and
251
+ *that* branch runs out of depth and has **no `state`**. If you are debugging a missing state or
252
+ country, check which branch you are reading before you raise the depth again — the destination
253
+ branch is complete.
254
+
213
255
  ### `description` → `memo` cannot leak into a plain sales order
214
256
 
215
257
  `memo` is sent only from the transfer-order shape. There is **no `memo` RecordField and no `memo`
@@ -229,6 +271,76 @@ bridge first** (that is what the Create Transfer Order modal writes), and falls
229
271
  [SO↔PO bridge direction](../../_underscore/features/sales-order-purchase-order-bridge-direction.md)
230
272
  for why the bridge, not the column, is the real source.
231
273
 
274
+ ## ⚠ The customer's NetSuite tax item must be AVATAX, or every create 400s
275
+
276
+ **Symptom.** Every transfer order create fails with NetSuite HTTP **400**:
277
+
278
+ ```
279
+ Error while accessing a resource. Invalid shippingtaxcode reference key -8 for subsidiary 1.
280
+ ```
281
+
282
+ `Core.WorkerJobs` ids **1534082, 1534805, 1535235, 1536824, 1536920** (all 2026-09-14). It started on
283
+ **2026-09-09**, the day the push began sending a ship-to address, and it blocked **every** transfer.
284
+
285
+ **Cause — customer data, not code.** NetSuite customer **28908** (NYC Health + Hospitals, the
286
+ client-level customer from `Core.Clients.netsuiteCustomerInternalId`) still carried the **retired tax
287
+ group -8** as its `taxitem`. When an order carries an **inline ship-to address**, NetSuite rebuilds
288
+ the address on the order and re-checks the shipping tax code against **subsidiary 1**; `-8` fails that
289
+ check and the whole create is rejected. No address, no re-check — which is exactly why this only
290
+ appeared once the address fix went in.
291
+
292
+ **Fix.** NetSuite ops changed `taxitem` on customer **28908** from `-8` to **5050 (AVATAX)**. No code
293
+ change. Verified working the same day. This is the normal value here: **7151** of subsidiary 1's
294
+ customers are already on 5050, and only **61** are still on -8.
295
+
296
+ > **Durable rule: before a new client's first transfer order, check that its NetSuite customer's
297
+ > `taxitem` is 5050 AVATAX.** This is an onboarding step, not a bug to debug later — the first order
298
+ > for any customer still on -8 will fail with the message above.
299
+
300
+ ### ⚠ Pinning `taxItem` / `shippingTaxCode` in the payload does NOTHING
301
+
302
+ This cost several failed attempts, so it is written down in the code as well (comment in
303
+ `buildNetSuiteOrder()` just below the `shippingAddress` block):
304
+
305
+ - **NetSuite derives both fields from the customer AFTER applying the address and discards whatever
306
+ we send.** Proven twice: jobs **1534805** and **1536824** both sent `-7` and both still failed
307
+ reporting **-8**.
308
+ - **Stripping `state` and `country` from the shipping address does not help either** (job
309
+ **1535235**). *Any* inline address at all triggers the re-check.
310
+
311
+ A `NETSUITE_TAX_CODE__NOT_TAXABLE = '-7'` constant and its pin block were added and then **removed
312
+ again**. A comment now sits in their place warning not to re-add them. **Do not re-add them.**
313
+
314
+ ### ⚠ -8 is NOT a dead id — it lives in `taxgroup`, not `salestaxitem`
315
+
316
+ An earlier code comment claimed -8 "points at nothing". **That was wrong**, and it sent the debug off
317
+ in the wrong direction:
318
+
319
+ | Id | Name | Table |
320
+ |---|---|---|
321
+ | **-8** | `-Not Taxable-` | **`taxgroup`** — exists and is **active** |
322
+ | **-7** | `-Not Taxable-` | **`salestaxitem`** |
323
+
324
+ Two different tables, the same display name. **Checking only `salestaxitem` for a tax code id will
325
+ tell you it does not exist when it does.** Query both before concluding an id is dead — SuiteQL
326
+ details in [NetSuite SuiteQL API reference](../../../../1.0/apps/library/features/netsuite-suiteql-api-reference.md).
327
+
328
+ ### Who else is still on -8 (scanned 2026-09-14, re-check before onboarding)
329
+
330
+ **61** NetSuite customers on subsidiary 1 remain on tax group -8. The ones that touch Toga:
331
+
332
+ | NetSuite customer | Who | Risk today |
333
+ |---|---|---|
334
+ | **34283** Yale New Haven Health | a **client-level** customer in `Core.Clients` | will fail its **first** transfer order; client DB has **0** transfer orders today |
335
+ | **2661** Endeavor Health | a **client-level** customer in `Core.Clients` | same — **0** transfer orders today |
336
+ | **31584** Coney Island | a NYCHH **sub-customer** in `Client_Nychh.Customers` | dormant, **0** sales orders |
337
+ | **1101** Miami-Dade Police Department | used by `Client_Miamidade` | nothing failing today |
338
+ | **32030** Security 101 | used by `Client_Miamidade` | nothing failing today |
339
+
340
+ All **17** other NetSuite customer ids used by `Client_Nychh` are already on **5050 AVATAX**. A scan of
341
+ `Core.WorkerJobs` shows this error has only ever hit the transfer-order action — 5 jobs, all on
342
+ 2026-09-14.
343
+
232
344
  ## The trigger — `postPost` on the SHARED `_Model_Client_TransferOrder`
233
345
 
234
346
  `_Model_Client_TransferOrder::postPost` queues the action. **Not** `_Model_Nychh_TransferOrder` — a
@@ -363,8 +475,11 @@ Stub detail for anyone extending it: `_Model_True` is stubbed with **only** the
363
475
  `postPost` reads **zero lines** and the push aborts — the trigger would then have to move. See
364
476
  [Transfer Orders page](../../toga25-supply/features/transfer-orders-page.md).
365
477
  - **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.
478
+ - ~~**The ship-to fix is written but NOT deployed.**~~ **Closed 2026-09-14** — deployed, and
479
+ confirmed working by the developer after NetSuite customer 28908 moved off tax group -8.
480
+ - **The -8 tax-group list is a snapshot (2026-09-14).** Re-run the check before onboarding any new
481
+ client to this push; **34283 Yale New Haven Health** and **2661 Endeavor Health** are the two that
482
+ will fail on their first order.
368
483
  - **Undecided: should the delivery contact go into the NetSuite `attention` field?** NetSuite ship-to
369
484
  addresses carry a person on the first line (order **289193** shows *Frederick Roberts*, the Bellevue
370
485
  delivery contact). `TransferOrders.contactId` exists as an FK on `_Model_Client_TransferOrder` but
@@ -390,6 +505,9 @@ Stub detail for anyone extending it: `_Model_True` is stubbed with **only** the
390
505
  a lost PO number aborts the push. Do not "optimise" the depth.
391
506
  - **This action does not retry itself**, exactly like the sales-order push: a throw records
392
507
  `isSuccess = 0` and stops. An event-triggered push fires once.
508
+ - **A NetSuite HTTP 400 naming a tax code is a CUSTOMER data problem, not a payload problem.** Check
509
+ the customer's `taxitem` first; do not try to pin the tax fields in the payload (they are ignored)
510
+ and do not drop the address to make the error go away (that silently ships to the wrong site).
393
511
  - **`git diff --stat` after any programmatic full-file write to `_underscore`.** A full-file write
394
512
  with an editor default flipped CRLF → LF on `_underscore/Model/Nychh/TransferOrder.php` — content
395
513
  identical, git reported it modified with no content hunks. Restored with `git checkout --`. The
@@ -397,6 +515,30 @@ Stub detail for anyone extending it: `_Model_True` is stubbed with **only** the
397
515
 
398
516
  ## Change history
399
517
 
518
+ - 2026-09-14 — **Deployed the ship-to fix, then spent the day on the 400 it exposed. Root cause was
519
+ NetSuite customer data.** From 2026-09-09 every transfer order create failed with *"Invalid
520
+ shippingtaxcode reference key -8 for subsidiary 1"* (`Core.WorkerJobs` 1534082, 1534805, 1535235,
521
+ 1536824, 1536920). NetSuite customer **28908** still carried the retired tax group **-8**; sending
522
+ an inline ship-to address makes NetSuite rebuild the address and re-check the shipping tax code
523
+ against subsidiary 1, and -8 fails it. **NetSuite ops moved 28908's `taxitem` from -8 to 5050
524
+ (AVATAX)** — no code change, and the push was confirmed working end to end. Recorded three things
525
+ that cost real time: **pinning `taxItem`/`shippingTaxCode` in the payload is ignored** (NetSuite
526
+ derives both from the customer *after* the address — jobs 1534805 and 1536824 both sent -7 and both
527
+ still failed on -8), so the `NETSUITE_TAX_CODE__NOT_TAXABLE = '-7'` constant and its pin block were
528
+ added and then removed with a warning comment left behind; **stripping `state`/`country` does not
529
+ help either** (job 1535235) because any inline address triggers the re-check; and **-8 is not a dead
530
+ id** — it is active in `taxgroup` as "-Not Taxable-", while `salestaxitem` holds -7 with the same
531
+ name, so an earlier comment claiming it "points at nothing" was wrong. Also documented that
532
+ **omitting the address is a wrong-address bug, not a fix**: NetSuite silently uses the customer's
533
+ `defaultshipping = T` entry (28908 has 124 addresses; the default is Jacobi, id 6568575), which is
534
+ why 5 of the 6 orders that reached NetSuite went to the wrong site. Confirmed the line `location` is
535
+ the source warehouse (117) and is never a ship-to; that `resolveTransferOrderCustomer()` always
536
+ lands on the client's customer, so changing the destination cannot work around a customer-level
537
+ problem; and that `NETSUITE_READ_DEPTH = 6` really does carry
538
+ `destinationLocation.primaryLocationAddress.address.state.country` (verified against a real job's
539
+ `parameters` blob), while the neighbouring `contact.contactLocations[]` branch runs out of depth and
540
+ is a decoy. Added the dated list of the **61** customers still on -8, of which **34283 Yale New Haven
541
+ Health** and **2661 Endeavor Health** are client-level and will fail their first transfer order. (bala)
400
542
  - 2026-09-09 — **The push had never sent a shipping address at all.**
401
543
  `buildSalesOrderShapeFromTransferOrder()` set no `shipToAddress` key, so the `shippingAddress` block
402
544
  in `buildNetSuiteOrder()` never ran and NetSuite substituted the **customer's default** site on every
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-31
9
+ updated: 2026-09-11
10
10
  owners: ["apeterson", "dfranks", "bala"]
11
11
  files:
12
12
  - _underscore/Model/Compass/ApprovalDecision.php
@@ -15,6 +15,7 @@ files:
15
15
  - _underscore/Model/Compass/Canada/ApprovalDecision.php
16
16
  - _underscore/Model/Client/ApprovalTemplateStage.php
17
17
  - _underscore/Model/Quad/SalesOrder.php
18
+ - dbchanges2/Client_Compass/2026-09-10b - RegisterApprovalDecisionPrePostInterceptor.sql
18
19
  related:
19
20
  - mr-ma-order-approval-and-status.md
20
21
  - ../../../2.0/apps/toga25-supply/features/action-button-rule-engine.md
@@ -241,6 +242,38 @@ break the invariant and produce a stage with two competing approvers. Both exist
241
242
  paths already respect this (`_Model_Compass_SalesOrder` compares `assignedToUserId != managerId` and
242
243
  then PUTs).
243
244
 
245
+ ### Step-1 approval is blocked when step 2 has no assignee (guard - BUILT, NOT DEPLOYED)
246
+
247
+ An admin could approve step 1 on an order whose step-2 stage had **no `assignedToUserId`**. The
248
+ order then moved to *Pending Approval* assigned to nobody: no manager approval-request email is
249
+ sent (that email is addressed from the step-2 assignee), nobody can act on it, and **no report
250
+ surfaces it** - the order just goes silent. Real prod population: **SA136795, SA137088, SA137146,
251
+ SA137235**.
252
+
253
+ `_Model_Compass_ApprovalDecision` now carries a `prePost()` plus an extended `prePut()` that reject
254
+ a **step-1 approval** when the step-2 stage has no `assignedToUserId`, throwing
255
+ `_Exception_Validation` (api2 maps it to **HTTP 400**). Both tenants are covered automatically,
256
+ because the subclasses are empty.
257
+
258
+ **Every exemption is decided from the database, never from the request payload:**
259
+
260
+ - **MR/MA orders** - matched on the `SalesOrders.number` prefix; see
261
+ [MR/MA order approval and status](mr-ma-order-approval-and-status.md).
262
+ - **Step-2 decisions** - identified by the stage's step number, not by anything the caller sends.
263
+ - **Templates that define no step 2** - nothing can strand, so nothing is blocked.
264
+
265
+ Verified by replaying **6,000 real approvals** against a local copy of prod: 4,209 normal approvals
266
+ and 1,786 MR/MA auto-approvals allowed, only the genuinely stranded orders blocked. Compass Canada:
267
+ 760 approvals, **none** blocked.
268
+
269
+ **⚠ The guard does nothing until its interceptor row exists.** A `prePost` only runs when a
270
+ matching `ApiPayloadInterceptors` row is registered, and `recordId 179` (approval-decisions) carried
271
+ only PRE/PUT, POST/PUT and POST/POST - a newly added `prePost` is **dead code with no error**.
272
+ `dbchanges2/Client_Compass/2026-09-10b - RegisterApprovalDecisionPrePostInterceptor.sql` (and the
273
+ `Client_CompassCanada` twin) insert the PRE/POST row, guarded with `NOT EXISTS` so they are safe to
274
+ re-run. **Run both, then deploy api2** - otherwise nothing changes. Mechanics:
275
+ [API Payload Interceptors](../../../2.0/apps/api2/features/api-payload-interceptors.md).
276
+
244
277
  ### Manager reassignment and the notification list
245
278
  When a step-2 (Manager) approval decision is **reassigned** to a new manager,
246
279
  `_swapManagerEmailAddress()` updates the order's CC/notification list in `SalesOrderEmailAddresses`:
@@ -436,7 +469,56 @@ requester**. All of these comparisons are case-insensitive (`strcasecmp()`), mat
436
469
  could not open the order from the "Manager Approval Needed" email, so admin Kai Wong had to
437
470
  approve as manager on his behalf. (Fixed 2026-07-23.)
438
471
 
472
+ - **⚠ An exemption from a server-side guard must be decided from the DATABASE, never from a
473
+ client-supplied field.** The step-1 guard above originally skipped itself when the request payload
474
+ carried `approvalDecisionType` / `approvalDecisionTypeId`, treated as a "this is a system
475
+ auto-approval" marker. That value comes from the **request body**, so any caller could set it and
476
+ skip the check entirely. The early return and its `_isSystemAutoApproval` helper were removed in
477
+ PR review. Checked against prod before removing: all **5,028** step-1 auto-approvals in Compass
478
+ history are MR/MA orders (Canada has none), so the DB-backed order-number check covers every real
479
+ case, and VIP auto-approvals land on **step 2** and exit at the step check. Treat this as the
480
+ general rule for any guard added here.
481
+ - **⚠ The green approve tick used to silently replace the step-2 manager with the approver.**
482
+ `makeApprovalDecision()` / `updateApprovalDecision()` in supply hardcoded
483
+ `assignedToUserId: { uuid: userUuid }` into every single-stage approve payload, so an admin
484
+ approving the manager stage **overwrote the real manager with themselves** - and the order
485
+ timeline then read "Manager Approved", making it look like the manager had acted. Confirmed on
486
+ **SA136901** (07:18:35 re-sent an assignee that had not changed; Dawn Anderson had already been
487
+ assigned by Emily at 06:50:06), and it also hit **SA136807** (Angel Almodovar replaced) and
488
+ **SA137080** (delegate manager Daniel Mezzanares replaced). Fixed front-end by removing the field
489
+ - `decidedByUserId` already records who approved. The "Approve All Stages" path was always
490
+ correct, which is why the bug never reproduced there. Detail:
491
+ [Record Modals & Nested Tables](../../../2.0/apps/toga25-supply/features/record-modals-and-nested-tables.md).
492
+ - **⚠ A manager swap sent in the SAME save as an approval is never logged, and the email list
493
+ keeps the old manager.** `_evaluateManagerVip()` returns immediately when the request carries
494
+ `isApproved`, on the assumption that a request is *either* an assignment *or* an approval. When
495
+ both arrive together the `"Manager reassigned"` note is skipped (the feature itself works - it has
496
+ fired **757** times since May) **and** so is the `SalesOrderEmailAddresses` swap, so the outgoing
497
+ manager stays on the notification list. A handler for the combined case was drafted on the
498
+ `_sandbox-client` branch only and is **NOT** on `TRUE-81900` - known gap, not shipped.
499
+ - **The upstream cause of an unassigned step 2 is a user saved with no `supervisorUserId`.** The
500
+ admin add-user screen allows saving a user without one, and the PEOPLE-file import may or may not
501
+ backfill it later. That one data hole feeds both the `!$hasManager` wipe above and the
502
+ never-created step-2 decision at order creation. **Not fixed** - the guard only stops the order
503
+ being *stranded*, it does not give it a manager.
504
+
439
505
  ## Change history
506
+ - 2026-09-11 - **Blocked step-1 approval when step 2 has no assignee** (`prePost()` + extended
507
+ `prePut()` on `_Model_Compass_ApprovalDecision`, `_Exception_Validation` -> HTTP 400), after four
508
+ prod orders (SA136795, SA137088, SA137146, SA137235) sat in *Pending Approval* assigned to nobody
509
+ with no email sent and no report surfacing them. Exemptions are **all DB-derived** - MR/MA by
510
+ `SalesOrders.number` prefix, step-2 decisions by step number, templates with no step 2 - after PR
511
+ review **removed a client-controlled bypass** that skipped the guard whenever the request payload
512
+ carried `approvalDecisionType`/`approvalDecisionTypeId` (prod check: all 5,028 historic step-1
513
+ auto-approvals are MR/MA, Canada none). Verified by replaying 6,000 real approvals from a local
514
+ prod copy (4,209 normal + 1,786 MR/MA allowed; Canada 760, none blocked). Also recorded: the green
515
+ approve tick was **overwriting the step-2 manager with the approver** (SA136901 / SA136807 /
516
+ SA137080) until `assignedToUserId` was removed from the single-stage payload, and that
517
+ `_evaluateManagerVip()` skips both the "Manager reassigned" note and the
518
+ `SalesOrderEmailAddresses` swap when an assignment and an approval arrive in one save (drafted on
519
+ `_sandbox-client` only, **not shipped**). Ticket TRUE-81900. **NOT DEPLOYED** - the `2026-09-10b`
520
+ interceptor SQL must run on `Client_Compass` **and** `Client_CompassCanada`, and api2 must be
521
+ deployed, before the guard does anything. (bala)
440
522
  - 2026-08-31 — **Root-caused the "decided but untyped" step-2 approvals that no admin can recover**
441
523
  (both tenants, via the shared parent). `_Model_Compass_SalesOrder::postPut`'s `!$hasManager` branch
442
524
  nulls the entire step-2 decision when the "Order for" contact has no `supervisorUserId`; a
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-02
9
+ updated: 2026-09-14
10
10
  owners: [jcardinal, apeterson]
11
11
  files:
12
12
  - _underscore/Model/Nychh/Item.php
@@ -279,6 +279,30 @@ is exactly the shape of option **(b)** above. Left **UNRESOLVED and handed to Je
279
279
  same integration/data-owner decision, and the evidence above narrows it to the override's stock-in
280
280
  source rather than anything in the request path.
281
281
 
282
+ #### 2026-09-14 — PARTIALLY cleared on sandbox: 4 lines now report availability (blocker still OPEN)
283
+
284
+ Re-measured on `sandbox-client`: of **1,238** NYCHH purchase-order lines, **4** now report
285
+ `_qtyAvailable >= 1`. The other **1,234 still report 0.**
286
+
287
+ | `PurchaseOrderItems.uuid` | `quantity` | `_qtyAvailable` |
288
+ |---|---|---|
289
+ | `31c5871e-97da-7838-b3aa-ac8b9a21cf01` | 29 | 29 |
290
+ | `38e83217-759d-bf7d-8c27-940501603473` | 7 | 7 |
291
+ | `67039882-8aed-d68d-dcd5-cbf089b4ab6b` | 4 | 4 |
292
+ | `f574526e-3de1-f182-e8ba-3945ecd36765` | 100 | 100 |
293
+
294
+ On all four, availability equals the **full ordered quantity** — i.e. these are newly linked lines
295
+ with nothing dispatched yet, not partial recoveries. **Not verified on production.**
296
+
297
+ What this changes and what it does not:
298
+
299
+ - **Changed:** "the picker shows nothing for NYCHH" is now stale for `sandbox-client` — it shows 4.
300
+ Anyone testing the Create Transfer Order flow there now has real selectable rows.
301
+ - **NOT changed:** the underlying blocker stands. The unbackfilled
302
+ `InventoryAdjustmentItems.itemFulfillmentItemId` link is still NULL on ~all rows, and options
303
+ **(a)** and **(b)** above still need the integration / data-owner decision. Do not close this on
304
+ the strength of 4 rows.
305
+
282
306
  ### The role-3 grant migration is NOT the fix for the empty picker
283
307
 
284
308
  `dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql` grants **role 3 (API)**
@@ -413,6 +437,10 @@ pattern (default a NOT NULL non-writable column in the model, never send it) is
413
437
  session — and a *passing* drift check on the base `Model/Client/` file is what made it feel safe.
414
438
 
415
439
  ## Change history
440
+ - 2026-09-14 — **Blocker partially cleared, not resolved.** On `sandbox-client`, 4 of 1,238 NYCHH
441
+ PO lines now report `_qtyAvailable >= 1` (each equal to the full ordered quantity); 1,234 still
442
+ report 0. Production not re-checked. The `itemFulfillmentItemId` backfill decision is still open —
443
+ the state line changed, the blocker did not. (apeterson)
416
444
  - 2026-09-02 — **Re-hit the `_qty*` = 0 blocker on a second line and narrowed it two steps further.**
417
445
  Reference row `PurchaseOrderItems` uuid `715ef031-003d-6742-871d-0d20de2fa40f` (PO 80622, line 21):
418
446
  the V2 API returns `quantity: 3270` **correctly** while `_qtyReceived`/`_qtyOnHand`/`_qtyAvailable`/
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-10
9
+ updated: 2026-09-14
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - _underscore/Model/Client/TransferOrder.php
@@ -33,7 +33,13 @@ and live in
33
33
  this doc holds the NYCHH-specific parts: the interceptor row, the **location → mapping-column
34
34
  roles** (verified in production), and the data/metadata state the push still needs.
35
35
 
36
- **LIVE since 2026-09-08.** NetSuite SO **289187** (internal id 7463537) was created from this path
36
+ **LIVE since 2026-09-08, fully working since 2026-09-14.** Between 2026-09-09 and 2026-09-14 **every**
37
+ transfer order failed on a NetSuite tax error caused by customer **28908**'s data — see
38
+ [the tax item section](#-customer-28908-was-stuck-on-tax-group--8-and-it-blocked-every-transfer) —
39
+ and before that, every order shipped to the wrong hospital. Both are now fixed and the push was
40
+ confirmed working by the developer.
41
+
42
+ NetSuite SO **289187** (internal id 7463537) was created from this path
37
43
  and is correct. The location-role table below still stands, but **the destination location is NOT
38
44
  the NetSuite customer** — that was corrected the same day; see the next section.
39
45
 
@@ -118,6 +124,34 @@ rows for records 69, 12, 23, `NOT EXISTS`-guarded). **Run on prod and verified:
118
124
  > Generalised on [nested FK ACL embedding](../../../2.0/apps/api2/features/nested-fk-acl-embedding.md):
119
125
  > deepening a `depth` is an ACL change, and the branch is dropped **silently**.
120
126
 
127
+ ## ⚠ Customer 28908 was stuck on tax group -8, and it blocked every transfer
128
+
129
+ From **2026-09-09** (the day the push started sending a ship-to address) to **2026-09-14**, every
130
+ NYCHH transfer order create returned NetSuite HTTP **400**:
131
+
132
+ ```
133
+ Error while accessing a resource. Invalid shippingtaxcode reference key -8 for subsidiary 1.
134
+ ```
135
+
136
+ `Core.WorkerJobs` ids **1534082, 1534805, 1535235, 1536824, 1536920**.
137
+
138
+ **It was NetSuite customer data, not Toga code.** Customer **28908** (NYC Health + Hospitals — the
139
+ client-level customer from `Core.Clients.netsuiteCustomerInternalId`, which every NYCHH transfer order
140
+ uses) still carried the retired tax group **-8** as its `taxitem`. An inline ship-to address makes
141
+ NetSuite rebuild the address on the order and re-check the shipping tax code against subsidiary 1,
142
+ and -8 fails that check.
143
+
144
+ **Fix: NetSuite ops changed 28908's `taxitem` from -8 to 5050 (AVATAX).** Verified working the same
145
+ day. 7151 of subsidiary 1's customers were already on 5050.
146
+
147
+ **Still on -8 inside NYCHH: sub-customer 31584 "Coney Island"** (present in `Client_Nychh.Customers`,
148
+ 0 sales orders today, so dormant). All **17** other NetSuite customer ids used by `Client_Nychh` are
149
+ on 5050. The mechanism, the failed workarounds, and the cross-client -8 list are on
150
+ [the shared push doc](../../../2.0/apps/worker2/features/netsuite-transferorder-outbound-push.md).
151
+
152
+ > **Do not try to fix this from supply.** The customer is always the client (28908), so **changing the
153
+ > destination location cannot change the NetSuite customer** — it only changes the ship-to address.
154
+
121
155
  ## Where the ship-to address comes from — and the location-2 problem
122
156
 
123
157
  The address is the **destination Location's** `primaryLocationAddress.address`. Until 2026-09-09 all
@@ -130,6 +164,21 @@ are in [location shipping addresses + delivery contacts](./location-shipping-add
130
164
  delivery site for that location — and location 2 is the destination on the large majority of NYCHH
131
165
  transfer orders. Any transfer order to location 2 will ship to Jacobi.
132
166
 
167
+ ### What "no address" actually did — 5 of 6 orders went to the wrong hospital
168
+
169
+ Customer **28908** holds **124** saved addresses, and the one flagged `defaultshipping = T` is
170
+ **“NYCHHC Jacobi Dental”** (address id **6568575**, 1400 Pelham Parkway South, Bronx NY 10461). With
171
+ no address in the payload NetSuite used that one **silently** on every order:
172
+
173
+ | NetSuite order | Ship-to |
174
+ |---|---|
175
+ | 7453238, 7462009, 7463537, 7467799, 7482373 | **wrong** — Jacobi Dental, whatever the real destination |
176
+ | 7462031 | correct, **by accident** — it predates the switch to the parent customer and was built on destination sub-customer **33674**, whose own default *is* the destination |
177
+
178
+ The line-level `location` does **not** rescue this: that is the **source** warehouse (NetSuite
179
+ internal id **117**) and NetSuite never uses it for delivery. So dropping the address to get past a
180
+ 400 is a wrong-address bug, not a fix.
181
+
133
182
  ## Interceptor registration — record 312, and the deploy order matters
134
183
 
135
184
  `dbchanges2/Client_Nychh/2026-09-02a - TransferOrderNetsuitePushInterceptor.sql` inserts into
@@ -164,6 +213,19 @@ switch that turns the feature on for NYCHH and nobody else.
164
213
 
165
214
  ## Change history
166
215
 
216
+ - 2026-09-14 — **Push deployed and confirmed working; the blocker was NetSuite customer 28908's tax
217
+ item.** Every transfer order from 2026-09-09 failed with *"Invalid shippingtaxcode reference key -8
218
+ for subsidiary 1"* (`Core.WorkerJobs` 1534082, 1534805, 1535235, 1536824, 1536920) because 28908
219
+ still carried the retired tax group **-8**; sending an inline ship-to address makes NetSuite
220
+ re-check the shipping tax code against subsidiary 1. **NetSuite ops moved 28908 to 5050 (AVATAX)**
221
+ and the push worked with no code change. Confirmed the customer is always 28908, so a destination
222
+ change in supply cannot work around a customer-level problem. Quantified the old no-address
223
+ behaviour: 28908 has **124** addresses and its `defaultshipping` entry is **Jacobi Dental (id
224
+ 6568575)**, which is why **5 of the 6** orders that reached NetSuite (7453238, 7462009, 7463537,
225
+ 7467799, 7482373) shipped to the wrong hospital — only 7462031 was right, and only because it was
226
+ built on sub-customer 33674 before the switch to the parent. Remaining NYCHH exposure: sub-customer
227
+ **31584 Coney Island** is still on -8 but dormant; the other 17 NetSuite customer ids used by
228
+ `Client_Nychh` are all on 5050. (bala)
167
229
  - 2026-09-09 — **The push had never sent a shipping address**, so NetSuite used customer 28908's
168
230
  default site on every order: NetSuite order **7467799** was for North Central Bronx yet shipped to
169
231
  Jacobi's *1400 Pelham Parkway South*, and SO **289187** (Woodhull) shows the same. The fix reads the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.805",
3
+ "version": "1.0.807",
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",