toga-ai 1.0.726 → 1.0.728
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.
- package/knowledge/2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md +45 -3
- package/knowledge/clients/compass-canada/profile.md +8 -0
- package/knowledge/clients/compass-usa/features/item-catalogs-and-duplicate-items.md +39 -3
- package/knowledge/clients/quad/profile.md +12 -0
- package/knowledge/sessions/2026-09-01-transfer-orders-qty-blocker-apeterson.md +109 -0
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-01
|
|
10
10
|
owners: ["bala"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -14,6 +14,7 @@ files:
|
|
|
14
14
|
- toga2-supply/src/api/toga.ts
|
|
15
15
|
related:
|
|
16
16
|
- ../../../clients/compass-usa/features/item-fulfillment-tracking-tableview.md
|
|
17
|
+
- ../../../clients/compass-usa/features/item-catalogs-and-duplicate-items.md
|
|
17
18
|
- tableview-field-metadata.md
|
|
18
19
|
---
|
|
19
20
|
|
|
@@ -36,8 +37,9 @@ view with a migration and no application code.
|
|
|
36
37
|
- **Consumption** — `toga2-supply/src/api/toga.ts` (~line 943-951): pushes the `apiWhereClause`
|
|
37
38
|
literal string into `options.where.and`, and (~line 631-641) builds `options.join` / `ojoin`
|
|
38
39
|
from the view's `TableViewJoins`.
|
|
39
|
-
- **Parsing** — `api2/Component/Api/V2/V2.php::parseOptionsWhere` (~line
|
|
40
|
-
|
|
40
|
+
- **Parsing** — `api2/Component/Api/V2/V2.php::parseOptionsWhere` (~line 8200-8390, re-located
|
|
41
|
+
2026-09-01; it was ~7574 when this doc was first written, so search for the function name rather
|
|
42
|
+
than trusting the line number): turns the clause string into SQL.
|
|
41
43
|
|
|
42
44
|
## How it works
|
|
43
45
|
|
|
@@ -68,6 +70,22 @@ The field slot accepts a **raw SQL expression**, not just a plain column — e.g
|
|
|
68
70
|
exclusion on the Compass fulfillment view): `(IFNULL(Items.assetTypeId,0):ne:<FEE id>)` — the
|
|
69
71
|
`IFNULL(...,0)` keeps rows with a NULL FK from being silently dropped.
|
|
70
72
|
|
|
73
|
+
### A single-condition clause is wrapped in parens too
|
|
74
|
+
|
|
75
|
+
`parseOptionsWhere` **strips one leading `(` and one trailing `)` from the whole clause before it
|
|
76
|
+
scans for top-depth commas** (V2.php ~line 8222-8229). Those outer parens are the group wrapper,
|
|
77
|
+
not part of the first condition, so the house form for a one-condition filter is:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
(Items.catalogId:eq:1)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The wrapper comes off first, leaving `Items.catalogId:eq:1`, which is then read as a plain
|
|
84
|
+
condition — it is never mistaken for a nested group needing recursion. Write the parens; they match
|
|
85
|
+
the existing Compass convention and the stored clauses stay uniform whether they hold one condition
|
|
86
|
+
or five. (The "expression field must not start with `(`" gotcha below is about the *field slot* of a
|
|
87
|
+
condition **after** the wrapper is removed — the two are not in conflict.)
|
|
88
|
+
|
|
71
89
|
### Join alias rule (which table-qualified name to use)
|
|
72
90
|
|
|
73
91
|
In `_underscore/Model/Client/TableView.php` (~line 89-96), the **first** occurrence of a joined
|
|
@@ -77,6 +95,19 @@ referencing `Items.<field>` only binds correctly when the view joins `Items` **e
|
|
|
77
95
|
Before writing a clause, confirm the target table is joined once, or qualify against the correct
|
|
78
96
|
suffixed alias.
|
|
79
97
|
|
|
98
|
+
**A view's base record is not a join, so it never gets a suffix.** Worked example — the shared
|
|
99
|
+
`items` view (`TableViews.slug = 'items'`, `id 9`, `recordId 21` = `_Model_Client_Item`, route
|
|
100
|
+
`items`; the same shape in `Client_Compass`, `Client_CompassCanada` and `Client_Quad`). Its four
|
|
101
|
+
`TableViewJoins` are `joinRecordId` **20** Manufacturers, **33** ItemTypes, **32** AssetTypes and
|
|
102
|
+
**34** ItemCategories, all INNER. `Items` appears only as the base record and is never re-joined,
|
|
103
|
+
so the bare `Items.` qualifier is correct and there is no `_B`/`_C` to worry about. Check this the
|
|
104
|
+
same way on any view before writing a clause: list the view's `TableViewJoins` and count how many
|
|
105
|
+
resolve to the table you want to filter.
|
|
106
|
+
|
|
107
|
+
Two schema notes for anyone writing the migration: `TableViews` has **no `name` or `tableName`
|
|
108
|
+
column** in these client schemas — identify a view by its `slug` (UNIQUE) plus `recordId`, which is
|
|
109
|
+
what makes `WHERE slug = 'items'` a safe, id-agnostic target across tenants.
|
|
110
|
+
|
|
80
111
|
## Data model
|
|
81
112
|
|
|
82
113
|
- `TableViews.apiWhereClause` (TEXT, nullable) — the view-level row filter.
|
|
@@ -91,6 +122,8 @@ None — engine behavior. A specific client's view may carry its own `apiWhereCl
|
|
|
91
122
|
|
|
92
123
|
- **Comma inside a function is safe.** The parser only splits conditions on top-depth commas, so
|
|
93
124
|
a comma inside `IFNULL(x,0)` or `IN(...)` does not break the clause.
|
|
125
|
+
- **A one-condition clause still needs its outer parens** — they are stripped before parsing, so
|
|
126
|
+
`(Field:eq:value)` and `Field:eq:value` behave identically; use the wrapped form for consistency.
|
|
94
127
|
- **An expression field must NOT start with `(`.** A condition that starts with `(` is treated by
|
|
95
128
|
the parser as a nested group and recursed into — so wrap an expression field in a function that
|
|
96
129
|
starts with a letter (e.g. `IFNULL(...)`), never a bare parenthesized expression.
|
|
@@ -102,6 +135,15 @@ None — engine behavior. A specific client's view may carry its own `apiWhereCl
|
|
|
102
135
|
|
|
103
136
|
## Change history
|
|
104
137
|
|
|
138
|
+
- 2026-09-01 — Recorded that the parser **strips one leading `(` and one trailing `)` from the whole
|
|
139
|
+
clause before splitting**, so the wrapped single-condition form `(Items.catalogId:eq:1)` is safe
|
|
140
|
+
and is the house convention (this was previously only documented for the multi-condition form).
|
|
141
|
+
Added a worked alias-verification example on the shared `items` view (id 9, recordId 21, four
|
|
142
|
+
INNER `TableViewJoins` 20/33/32/34, `Items` only as base record → bare `Items.` qualifier), noted
|
|
143
|
+
that `TableViews` carries no `name`/`tableName` column so `slug` is the id-agnostic target, and
|
|
144
|
+
re-pointed `parseOptionsWhere` at its current location (~8200, was ~7574). No code change —
|
|
145
|
+
verified while writing the catalog-1 filter migration for the `items` view in `Client_Compass`,
|
|
146
|
+
`Client_CompassCanada` and `Client_Quad`. (bala)
|
|
105
147
|
- 2026-07-15 — Documented the end-to-end `apiWhereClause` row-filter mechanism (emission →
|
|
106
148
|
consumption → `parseOptionsWhere` grammar), the operator→SQL table, the raw-expression field
|
|
107
149
|
slot, the leading-paren / top-depth-comma parser behavior, and the `_underscore` join-alias
|
|
@@ -196,4 +196,12 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
|
|
|
196
196
|
numbers — because it does **not** use NetSuite, so it never hit the inventory-adjustment
|
|
197
197
|
dupe-item bug that fed the merge. The catalog-fold damage and reversal were Compass USA only.
|
|
198
198
|
See [Compass USA catalog-fold reversal](../compass-usa/workflows/catalog-fold-reversal.md).
|
|
199
|
+
- **Items listing being filtered to catalog 1 (written 2026-09-01, NOT yet applied).** A migration
|
|
200
|
+
sets `TableViews.apiWhereClause = '(Items.catalogId:eq:1)'` on `Client_CompassCanada`'s shared
|
|
201
|
+
`items` view (`slug = 'items'`, id 9, recordId 21), alongside the same change in `Client_Compass`
|
|
202
|
+
and `Client_Quad`. **Canada is the harmless case today** — Canada has a **single catalog**
|
|
203
|
+
("compass", see the bullet above), so every `Client_CompassCanada.Items` row is already catalog 1 and so the filter changes nothing visible now; it only pins the listing so a
|
|
204
|
+
future non-catalog-1 item (e.g. anything a NetSuite-style sync creates) would silently not appear.
|
|
205
|
+
Mechanism: [TableView apiWhereClause row-filtering](../../2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md);
|
|
206
|
+
catalog background: [Compass item catalogs](../compass-usa/features/item-catalogs-and-duplicate-items.md).
|
|
199
207
|
- Related: [Compass USA](../compass-usa/profile.md).
|
|
@@ -17,6 +17,7 @@ files:
|
|
|
17
17
|
- worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php
|
|
18
18
|
related:
|
|
19
19
|
- odp-edi-850-item-resolution.md
|
|
20
|
+
- ../../../2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md
|
|
20
21
|
- mits-po-to-so-item-linking.md
|
|
21
22
|
- mits-sales-order-transmission-alerting.md
|
|
22
23
|
- isfulfillable-data-quality-and-type-rule.md
|
|
@@ -132,6 +133,32 @@ Plus `-1` / `-2` suffixed copies: items **2431, 2432, 2443, 2653**.
|
|
|
132
133
|
(e.g. the `#B1H` / `-S` variant forms above) or genuinely-shared ones before treating table-wide
|
|
133
134
|
`partNumber` uniqueness as settled.
|
|
134
135
|
|
|
136
|
+
## Filtering by catalog: fine for *display*, never for *resolution*
|
|
137
|
+
|
|
138
|
+
The blanket rule below ("never scope a Compass item lookup to one catalog") is about **resolving a
|
|
139
|
+
part number to a record** — order import, PO→SO linking, vendor-item matching. It does **not** mean
|
|
140
|
+
a catalog filter is banned everywhere. A **listing** is the legitimate exception: showing a user
|
|
141
|
+
only the customer-facing Compass catalog is a product decision, and nothing downstream re-resolves
|
|
142
|
+
off that list.
|
|
143
|
+
|
|
144
|
+
**In flight as of 2026-09-01 (SQL written, NOT yet applied):** the shared TOGa Supply `items` table
|
|
145
|
+
view (`TableViews.slug = 'items'`, `id 9`, `recordId 21`) is being restricted to catalog 1 by
|
|
146
|
+
setting `TableViews.apiWhereClause = '(Items.catalogId:eq:1)'` — one `UPDATE … WHERE slug = 'items'`
|
|
147
|
+
per schema, in `Client_Compass`, `Client_CompassCanada` **and** `Client_Quad` (the same view exists
|
|
148
|
+
with the same id/recordId in all three). No application code changes; the mechanism is
|
|
149
|
+
[TableView row-filtering via apiWhereClause](../../../2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md).
|
|
150
|
+
The migration was written as a loose `items_tableview_catalog_filter_2026-09-01.sql` in the
|
|
151
|
+
workspace root and handed to a developer to run (the DB tooling is read-only) — it still needs to
|
|
152
|
+
land in `dbchanges2/Client_<schema>/` under the mandatory `YYYY-MM-DD<letter> - Name.sql` filename
|
|
153
|
+
before it is a real change-set.
|
|
154
|
+
|
|
155
|
+
**Open decision on the clause, and it is the NULL question again.** `Items.catalogId` is nullable in
|
|
156
|
+
all three schemas, so a bare `:eq:1` silently drops the NULL-catalog items (a real, non-trivial
|
|
157
|
+
population here — see the gotcha below, and it is proportionally much larger in `Client_Quad`). If
|
|
158
|
+
those items must stay visible in the listing, the clause has to be
|
|
159
|
+
`(IFNULL(Items.catalogId,1):eq:1)` instead. Decide before applying; the two forms are not
|
|
160
|
+
interchangeable.
|
|
161
|
+
|
|
135
162
|
## "Who changed this part number?" — the audit trail says nobody
|
|
136
163
|
|
|
137
164
|
The reflex answer to a part number that looks edited is to hunt for the person who edited it. For
|
|
@@ -151,9 +178,10 @@ children" rule:
|
|
|
151
178
|
|
|
152
179
|
## Gotchas
|
|
153
180
|
|
|
154
|
-
- **⚠ Never scope a Compass item lookup to one catalog** unless you have proved the item can only
|
|
155
|
-
come from that source.
|
|
156
|
-
|
|
181
|
+
- **⚠ Never scope a Compass item *lookup* to one catalog** unless you have proved the item can only
|
|
182
|
+
come from that source. (A read-only **listing** filter is the one sanctioned exception — see
|
|
183
|
+
[Filtering by catalog](#filtering-by-catalog-fine-for-display-never-for-resolution) above.) The
|
|
184
|
+
correct default is `Items.partNumber IN (...)` with **no** catalog condition, and then reference the resolved record **by `uuid`**. **The catalog filter never buys
|
|
157
185
|
uniqueness on Compass** — `Items.partNumber` is unique across the whole table (re-verified prod
|
|
158
186
|
2026-09-01) — so every catalog condition is pure downside.
|
|
159
187
|
- **⚠ The 2.0 side hardcodes the OTHER catalog, and it broke MR purchase orders.**
|
|
@@ -193,6 +221,14 @@ children" rule:
|
|
|
193
221
|
**unresolved tension**: that reversal folded/restored 199 catalog-1 items by `UPPER(partNumber)`,
|
|
194
222
|
which implies shared cross-catalog part numbers pre-merge and does not square with the same-day
|
|
195
223
|
"partNumber is unique table-wide" verification — needs a prod re-check. (jcardinal)
|
|
224
|
+
- 2026-09-01 — Split the catalog-filter rule into **display vs. resolution**: the "never scope to one
|
|
225
|
+
catalog" ban covers part-number *resolution*, while a read-only *listing* filter is sanctioned.
|
|
226
|
+
Recorded the in-flight (written, **not yet applied**) change setting
|
|
227
|
+
`TableViews.apiWhereClause = '(Items.catalogId:eq:1)'` on the shared `items` view
|
|
228
|
+
(`slug = 'items'`, id 9, recordId 21) in `Client_Compass`, `Client_CompassCanada` and
|
|
229
|
+
`Client_Quad`, and the open decision it carries: `Items.catalogId` is nullable, so bare `:eq:1`
|
|
230
|
+
hides NULL-catalog items and `IFNULL(Items.catalogId,1):eq:1` is the alternative. Migration still
|
|
231
|
+
needs moving into `dbchanges2/Client_<schema>/`. (bala)
|
|
196
232
|
- 2026-09-01 — Recorded a **second, independent victim of the same catalog-scoping trap, on the 2.0
|
|
197
233
|
side**: `_Model_Compass_PurchaseOrder::handleMrOrder` references MR sales-order items with a
|
|
198
234
|
hardcoded `COMPASS_CATALOG_UUID` (`961dbcbc…` = catalog **1**), which is the mirror image of the
|
|
@@ -142,4 +142,16 @@ Client-specific DB change-sets live in `dbchanges2/Client_Quad/`.
|
|
|
142
142
|
Quad's **storefront only** (not orders/transmit), and Quad was the **only other client besides
|
|
143
143
|
Compass** that needed a reversal. See
|
|
144
144
|
[Quad catalog-fold reversal](./workflows/catalog-fold-reversal.md).
|
|
145
|
+
- **Items listing being filtered to catalog 1 (written 2026-09-01, NOT yet applied) — verify the
|
|
146
|
+
intent for Quad first.** A migration sets `TableViews.apiWhereClause = '(Items.catalogId:eq:1)'`
|
|
147
|
+
on `Client_Quad`'s shared `items` view (`slug = 'items'`, id 9, recordId 21); the same one-line
|
|
148
|
+
change is going to `Client_Compass` and `Client_CompassCanada`. Read it against the bullet above:
|
|
149
|
+
for Quad, catalog 1 is the **storefront (Etilize)** catalog and catalog 2 is the **procurement
|
|
150
|
+
(Agilant/NetSuite)** catalog that Quad's storefront orders actually sit on, so `:eq:1` hides the
|
|
151
|
+
procurement half of `Client_Quad.Items` from the Supply items listing — a much bigger cut than the
|
|
152
|
+
equivalent change on Compass, and a majority of Quad's items. `Items.catalogId` is also nullable
|
|
153
|
+
here and a bare `:eq:1` drops those rows too; `IFNULL(Items.catalogId,1):eq:1` recovers the NULLs
|
|
154
|
+
but not catalog 2. Mechanism:
|
|
155
|
+
[TableView apiWhereClause row-filtering](../../2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md);
|
|
156
|
+
catalog background: [Compass item catalogs](../compass-usa/features/item-catalogs-and-duplicate-items.md).
|
|
145
157
|
- This profile is a starting point; expand as more Quad-specific behavior is captured.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: session
|
|
3
|
+
slug: transfer-orders-qty-blocker
|
|
4
|
+
title: NYCHH Transfer Orders item picker empty — root-caused to the Nychh _qtyReceived override
|
|
5
|
+
author: apeterson
|
|
6
|
+
repos: [toga25-supply, dbchanges2, api2, _underscore]
|
|
7
|
+
framework: "2.0"
|
|
8
|
+
client: nychh
|
|
9
|
+
status: active
|
|
10
|
+
created: 2026-09-01
|
|
11
|
+
updated: 2026-09-01
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Session: transfer-orders-qty-blocker
|
|
15
|
+
**Date:** 2026-09-01
|
|
16
|
+
**Project/Repo:** toga25-supply (2.0), with dbchanges2; api2 + _underscore read-only
|
|
17
|
+
**Task:** Finish the NYCHH Create Transfer Order modal (contact picker, notes, PO select, item picker) and find out why the item picker renders empty even though `/purchase-order-items` returns 823 rows.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## What WORKED
|
|
22
|
+
|
|
23
|
+
- **ROOT CAUSE FOUND.** `_Model_Nychh_PurchaseOrderItem` overrides `_qtyReceived` to source stock-in from **inventory adjustments** instead of item receipts. Verified both formulas against the same row (sandbox `Client_Nychh`, `PurchaseOrderItems.id = 196`, uuid `01110c53-ba73-e60a-d94c-0ccf77327c0e`, PO 80622 line 1):
|
|
24
|
+
- base `_Model_Client_PurchaseOrderItem::_qtyReceived` → **40.00**
|
|
25
|
+
- `_Model_Nychh_PurchaseOrderItem::_qtyReceived` → **0.00**
|
|
26
|
+
- **Why the override yields 0** — its chain does `INNER JOIN InventoryAdjustmentItems ON itemFulfillmentItemId = ItemFulfillmentItems.id`, and that column is NULL on every row:
|
|
27
|
+
- sandbox `Client_Nychh`: 292 `InventoryAdjustmentItems`, **0** with `itemFulfillmentItemId` set
|
|
28
|
+
- production `Client_Nychh`: 317 `InventoryAdjustmentItems`, **0** with `itemFulfillmentItemId` set
|
|
29
|
+
- production PO lines the override reports with qty > 0: **0**
|
|
30
|
+
This is the override file's own documented "old-data caveat" (NetSuite only began linking adjustments to fulfillments within the last year) — currently it is 100% of the data, not a corner case.
|
|
31
|
+
- **Correct way to detect a tenant model override** — `git cat-file -e origin/<branch>:Model/<Slug>/<Model>.php`. Confirmed PRESENT on `origin/_sandbox-client` and `origin/_production`, absent on `origin/_beta` and `origin/_sandbox-dev`.
|
|
32
|
+
- **Documented the full `_qty*` formula chain** from `_underscore/Model/Client/PurchaseOrderItem.php` (lines 41–158): `_qtyReceived` = SUM(ItemReceiptItems.quantity); `_qtyFulfilled` = shipped ItemFulfillmentItems via `PurchaseOrderItems_TransferOrderItems`; `_qtyOnHand` = received − fulfilled; `_qtyCommitted` = SO qtyCommitted − shipped, matched by **vendor item** not PO line; `_qtyAvailable` = onHand − committed.
|
|
33
|
+
- **Fixed: numeric `where` values silently cancel the request.** blox `urlEncode(input: string)` calls `input.replace()`, so a numeric `1` throws inside `buildUrl` → `queryFn`. React Query swallows it and **no HTTP request is sent** — an empty dropdown with nothing in the network tab. Corrected to `{ "Locations.isActive": { "=": "1" } }` in `useTargetLocationOptions.ts` and `useTransferSourceLocation.ts`; the target-location and PO dropdowns then issued requests.
|
|
34
|
+
- **Fixed: non-zero `staleTime` suppresses fetches across sessions.** `App.tsx` uses `PersistQueryClientProvider` + `createSyncStoragePersister` on localStorage key `supply-chain-query-cache`, and the QueryClient default is `staleTime: 0`. Three new hooks had `staleTime: 5*60*1000`, letting a persisted entry from an earlier session count as fresh. All set to 0.
|
|
35
|
+
- **Ran `Core/2026-09-01a - TransferOrderCreatePoColumnAndFilter.sql` successfully on sandbox** — message 288, SurfaceElements 214 (PO column) + 215 (poFilter), absolute `CASE` sortOrder assignment, `isVisible = 0` on 209/211/213. Confirmed by the resulting sortOrder rows.
|
|
36
|
+
- **Wrote and ran `Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql`** on sandbox `Client_Nychh` — 8 role-3 grants landed, verified. (It is NOT the fix; see below.)
|
|
37
|
+
- **Fleet ACL survey (production, 36 client DBs)** — 31 grant all eight `_qty*` to role 1 and none to role 3; 4 (Compass, Imhouston, Mcgrawhill, Tdsynnex) have none at all; only Prudential (roles 1+3) and Northwell (role 3) are correct.
|
|
38
|
+
- **Verified `Core.Records`/`Core.RecordFields` ids are identical between production Core and sandbox Core** for record 18 — this is what makes literal id references safe in `Client/` migrations.
|
|
39
|
+
- **Knowledge captured and pushed** — commit `261d8e7` on `_main`, 9 docs, validate OK (32 repos).
|
|
40
|
+
|
|
41
|
+
## What did NOT work — DO NOT RETRY THESE
|
|
42
|
+
|
|
43
|
+
- **Granting `AclFieldPermissions` to role 3 does NOT fix the empty picker.** The 8 grants landed and the response was byte-for-byte unchanged — still `_qtyAvailable: 0`. Reason: `V2.php:3338` classifies a field as calculated when `$modelFieldConfig[$f]['type'] == _Model::FIELD_SQL`, and that branch runs **before** the ACL branch at `:3353` and checks no permissions. An explicit `fields=_qtyAvailable` therefore bypasses `AclFieldPermissions` entirely. The ACL gate for calc fields is at `V2.php:7216`, which is the *no-explicit-fields* (`getFullModelData`) path only.
|
|
44
|
+
- **Checking `Core.AclFieldPermissions` for record 18 is meaningless.** `Core.Records.id = 18` has `aclDatabase = CLIENT`, so grants are read from `Client_<Tenant>.AclFieldPermissions`. I checked Core, got a confident answer, and it sent the investigation down a wrong path for a long stretch.
|
|
45
|
+
- **Eliminating the tenant model override by listing the local working tree.** `ls Model/Nychh/` showed 21 files and no `PurchaseOrderItem.php` — because the local checkout is on branch `features-talos-transferorders`, which does not carry it. The file **is** on the deployed branches. This single wrong elimination is why the root cause took so long.
|
|
46
|
+
- **Trusting the sibling drift check.** `git diff origin/_sandbox-client -- Model/Client/PurchaseOrderItem.php` was empty and all four branches are byte-identical — a true result that made the override elimination feel safe. The drift was one directory over, in `Model/Nychh/`.
|
|
47
|
+
- **Mirroring a sibling field's roles in an ACL grant migration.** Drafted it (matching house precedent `Client/2026-07-22c`) and rejected it before shipping: Compass grants `quantity` to **role 4 (Public)**, so the mirror would have handed Public read on eight derived inventory quantities, and Imhouston has no `quantity` grant so the mirror would have silently inserted nothing there.
|
|
48
|
+
- **Reading `Core.RecordFields` from a `Client/` migration.** First draft did this (copying `Client/2026-07-22c`); the `dbchanges2-cluster-isolation.js` PreToolUse hook blocked it, correctly. Verified on production that the client cluster (reader **and** writer endpoints) hosts only `Client_*` databases — no `Core`. `2026-07-22c` is a shipped violation, not a pattern to copy.
|
|
49
|
+
- **Inferring a table's column list from a sibling migration's VALUES row.** Produced `Unknown column 'slug' in 'field list'`. `Core.Messages` columns are `(id, uuid, dtCreated, messageKey, name, defaultValue)`.
|
|
50
|
+
- **`ON DUPLICATE KEY UPDATE id = id` on literal-id rows** — self-caught before running; violates the rule that a stolen team-assigned id must fail loudly. Replaced with `NOT EXISTS` guards on the natural key.
|
|
51
|
+
- **Relative `sortOrder + 1` shift guarded by a subquery on the table being updated** — MySQL error 1093 risk. Replaced with an idempotent absolute `CASE` assignment that needs no guard.
|
|
52
|
+
- **Flipping `[debug] show_queries` in `Config/sandbox-client.ini`** — was queued as the next diagnostic and is now unnecessary. There is nothing left to find in the query.
|
|
53
|
+
- **`ERROR 1054 Unknown column 'f.sql'` on `Core.RecordFields`** — the table has no `sql` column; calculated-field SQL lives in the `_underscore` model as `FIELD_SQL` static methods.
|
|
54
|
+
- **`SELECT ... slug FROM Roles`** — the client `Roles` table has no `slug` column (id, name only).
|
|
55
|
+
|
|
56
|
+
## Not tried yet (candidates for next session)
|
|
57
|
+
|
|
58
|
+
- **Decide the stock-in source for NYCHH.** Either (a) get the NetSuite integration to backfill `InventoryAdjustmentItems.itemFulfillmentItemId`, or (b) change `_Model_Nychh_PurchaseOrderItem::_qtyReceived`. This is the actual blocker and is an integration/data-owner call (Jason owns the availability-decrement behavior).
|
|
59
|
+
- **Ask why the NYCHH override exists at all** — if item receipts are the truth for availability, the override may simply be wrong for this use case rather than needing a backfill.
|
|
60
|
+
- **Notes persistence** — `TransferOrders` has no notes/description column. Options: `TransferOrders.description varchar(255)` mirroring `SalesOrders`, or a `TransferOrderNotes` child table mirroring `SalesOrderNotes`.
|
|
61
|
+
- **Build the review step** — currently a placeholder ("Review step — not built yet"); the Create button is disabled.
|
|
62
|
+
- **Strip the diagnostics** — `_qtyReceived`/`_qtyFulfilled` were added to `PO_ITEM_FIELDS` purely to diagnose, and `sampleBreakdown` threads through to the empty-state message. Remove both once the blocker is resolved.
|
|
63
|
+
- **Decide whether `Client/2026-09-01a` should fan out to all tenants** or stay NYCHH-only. It is currently applied to sandbox `Client_Nychh` only.
|
|
64
|
+
- **`allowReceiving` data hygiene** — decide whether the flag should be maintained so the target dropdown can filter on it.
|
|
65
|
+
- **Flag `Client_Compass_20260831_120000`** — a dated backup database on the production client cluster that any `Client/` fan-out would hit.
|
|
66
|
+
- Parked, do not resume unasked: Line Items + Units columns, Target Contact column on the list table, the status 5→3 vocabulary relabel, Target Location path rendering, the location pin icon.
|
|
67
|
+
|
|
68
|
+
## Current file state
|
|
69
|
+
|
|
70
|
+
| File | Status | Notes |
|
|
71
|
+
|------|--------|-------|
|
|
72
|
+
| `toga25-supply/.../CreateTransferOrderModal/CreateTransferOrderModal.tsx` | Modified, uncommitted | Bidirectional PO binding (`lockedPo`/`preLockPoRef`/`wasLockedRef`), search input first then PO select, `FILTER_ROLES` splits controls from columns, empty-state cascade names the real cause |
|
|
73
|
+
| `toga25-supply/.../hooks/usePurchaseOrderItemRows.ts` | Created, uncommitted | Fetches all PO lines, derives PO options; still carries the `_qtyReceived`/`_qtyFulfilled` + `sampleBreakdown` diagnostics |
|
|
74
|
+
| `toga25-supply/.../hooks/useTargetLocationOptions.ts` | Created, uncommitted | All active locations minus source; `where` values are strings; `staleTime: 0` |
|
|
75
|
+
| `toga25-supply/.../hooks/useTransferSourceLocation.ts` | Created, uncommitted | Resolves the single `allowSending` location; exports `formatLocationAddress`; returns `hasAmbiguousSource` |
|
|
76
|
+
| `toga25-supply/.../hooks/useLocationContacts.ts` | Created, uncommitted | Route `/contact-locations` (Record 76); `DEFAULT_CONTACT_TYPE = "SHIPPING"` |
|
|
77
|
+
| `toga25-supply/.../hooks/useCreateLocationContact.ts` | Created, uncommitted | POST `/contacts` then POST `/contact-locations` |
|
|
78
|
+
| `toga25-supply/.../view/AddContactModal.tsx` | Created, uncommitted | Two required names, capture-phase Escape, own scrim |
|
|
79
|
+
| `toga25-supply/.../view/TransferNotesField.tsx` | Created, uncommitted | `useAutoGrow`, `FieldClear`, Escape blurs |
|
|
80
|
+
| `toga25-supply/.../view/TransferItemPickerTable.tsx` | Modified, uncommitted | Type renamed `PurchaseOrderItemRow`; caller-owned `emptyMessage` |
|
|
81
|
+
| `toga25-supply/.../hooks/useTransferableItemRows.ts` | Deleted, uncommitted | Superseded |
|
|
82
|
+
| `toga25-supply/.../hooks/usePurchaseOrderOptions.ts` | Deleted, uncommitted | Superseded — options now derived from item rows |
|
|
83
|
+
| `dbchanges2/Core/2026-09-01a - TransferOrderCreatePoColumnAndFilter.sql` | Created, uncommitted | **RUN on sandbox, succeeded** |
|
|
84
|
+
| `dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql` | Created, uncommitted | **RUN on sandbox `Client_Nychh`, succeeded** — but is NOT the fix; reversible via its 8 uuid literals |
|
|
85
|
+
| `toga-tech/knowledge/**` (9 docs) | Committed + pushed | Commit `261d8e7` on `_main` |
|
|
86
|
+
|
|
87
|
+
`npx tsc --noEmit` from the toga25-supply root: exit 0, clean. Nothing committed or pushed in any project repo.
|
|
88
|
+
|
|
89
|
+
## Decisions made
|
|
90
|
+
|
|
91
|
+
- **Grant role 3 explicitly rather than mirroring a sibling field's roles.** Rationale: mirroring `quantity` would have granted Compass's role 4 (Public) eight derived inventory quantities and would have inserted nothing on Imhouston. Rejected: the house precedent `Client/2026-07-22c`, which mirrors.
|
|
92
|
+
- **Use hardcoded v4 uuid literals, not `UUID()`, in fan-out ACL grant migrations.** Rationale: rollback becomes exact — `DELETE ... WHERE uuid IN (<literals>)` removes only the rows that file created and cannot touch Prudential's/Northwell's pre-existing role-3 grants. A rollback keyed on `(recordFieldId, roleId)` would have deleted theirs. uuid is unique per table per database, so one literal reused across tenants is valid. Rejected: the house habit of `UUID()` "so each client's rows are distinct" — it buys nothing here and costs the clean undo.
|
|
93
|
+
- **Reference `Core.RecordFields` ids as literals in `Client/` migrations.** Rationale: Core is on a separate cluster in production, so a `Core.*` lookup resolves only in non-prod; `Records`/`RecordFields` ids are team-assigned and verified identical across environments. Rejected: the cross-database lookup used by `2026-07-22c`.
|
|
94
|
+
- **Target Location lists all active locations minus the source, NOT filtered on `allowReceiving`.** Rationale: exactly 1 of NYCHH's 31 active locations has `allowReceiving = 1`, and it is the same location that has `allowSending = 1` — filtering would offer only the place you cannot transfer to. The flag is unmaintained for this tenant.
|
|
95
|
+
- **Source Location is resolved by flag (`allowSending = 1`), read-only from modal open** — not derived from row selection and not hardcoded. Rationale: another tenant, or a NYCHH rename, then needs no code change. The old selection-derived source lock was replaced by the PO lock.
|
|
96
|
+
- **The PO lock replaces the location lock** — all lines on one transfer must come from the same purchase order, per the live Claude Design export.
|
|
97
|
+
|
|
98
|
+
## Blockers
|
|
99
|
+
|
|
100
|
+
- **HARD BLOCKER — no NYCHH PO line reports available stock, in sandbox or production.** `_Model_Nychh_PurchaseOrderItem::_qtyReceived` INNER JOINs `InventoryAdjustmentItems.itemFulfillmentItemId`, which is NULL on all 292 sandbox rows and all 317 production rows. Production PO lines with qty > 0: 0. The Create Transfer Order item picker cannot show a selectable line until either the NetSuite integration backfills that FK or the override's stock-in source changes. **This is an integration/data-owner decision, not a frontend fix.**
|
|
101
|
+
- **Demo trap:** the override is absent from `_beta` and `_sandbox-dev`, so those tiers fall back to the base item-receipt formula and **will show a working picker with real quantities**. A beta demo will look healthy while production is structurally empty. Do not read beta as evidence the feature works.
|
|
102
|
+
- Notes field has nowhere to persist (`TransferOrders` has no notes/description column).
|
|
103
|
+
|
|
104
|
+
## Exact next step
|
|
105
|
+
|
|
106
|
+
> Take the blocker to Jason and the NetSuite integration owner with these two numbers: production `Client_Nychh` has 317 `InventoryAdjustmentItems` and **0** with `itemFulfillmentItemId` set, so `_Model_Nychh_PurchaseOrderItem::_qtyReceived` returns 0.00 for every PO line. Ask which of the two fixes they want — backfill `InventoryAdjustmentItems.itemFulfillmentItemId`, or change the override's stock-in source (base `_Model_Client_PurchaseOrderItem::_qtyReceived` uses `ItemReceiptItems` and returns 40.00 for `PurchaseOrderItems.id = 196`). No further frontend work on the item picker until that is answered.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
_Saved by /session-save on 2026-09-01_
|
package/package.json
CHANGED