toga-ai 1.0.805 → 1.0.806

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,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,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: _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`/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.805",
3
+ "version": "1.0.806",
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",