toga-ai 1.0.725 → 1.0.726
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/_underscore/INDEX.md +1 -1
- package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +130 -4
- package/knowledge/2.0/apps/api2/features/v2-rest-query-contract.md +59 -2
- package/knowledge/2.0/apps/dbchanges2/INDEX.md +1 -1
- package/knowledge/2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md +54 -1
- package/knowledge/2.0/apps/dbchanges2/workflows/targeting-the-right-database.md +14 -1
- package/knowledge/2.0/apps/toga-blox/features/api-client.md +26 -1
- package/knowledge/2.0/apps/toga25-supply/INDEX.md +1 -1
- package/knowledge/2.0/apps/toga25-supply/architecture.md +8 -1
- package/knowledge/2.0/apps/toga25-supply/features/transfer-orders-page.md +143 -24
- package/knowledge/clients/nychh/INDEX.md +2 -2
- package/knowledge/clients/nychh/features/transfer-order-inventory-quantities.md +182 -1
- package/knowledge/clients/nychh/profile.md +30 -2
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [Proposed — git-sourced base+overlay JSON authoring for the Surface layer](architecture/surface-authoring-proposal.md) | A **proposal / handoff recommendation** (not implemented) that the Surface layer's *authoring* model move off hand-authored SQL against the `SurfaceOverrides` E | _underscore/Model/Core/Surface.php, _underscore/Model/Client/SurfaceOverride.php |
|
|
6
6
|
| [_underscore Framework Architecture](architecture.md) | `_underscore` is the shared PHP backend framework for **all 2.0 applications**. | _underscore/_underscore.php, _underscore/Loader.php, _underscore/Framework.php, _underscore/Model.php, _underscore/Database.php, _underscore/Query.php, _underscore/Route.php, _underscore/Component.php |
|
|
7
|
-
| [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, _underscore/Model/Core/Page.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql |
|
|
7
|
+
| [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, _underscore/Model/Core/Page.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
|
|
8
8
|
| [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | When a business rule says *"only one X per physical address"*, comparing address rows field-for-field does **not** work: the same dwelling is spelled many diffe | _underscore/Model/Rate/Entitlement.php |
|
|
9
9
|
| [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php |
|
|
10
10
|
| [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
|
|
@@ -6,8 +6,8 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["jcardinal", "mhammontree", "tcox", "bala"]
|
|
9
|
+
updated: 2026-09-01
|
|
10
|
+
owners: ["jcardinal", "mhammontree", "tcox", "bala", "apeterson"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
13
|
- dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql
|
|
@@ -21,6 +21,7 @@ files:
|
|
|
21
21
|
- dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql
|
|
22
22
|
- dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql
|
|
23
23
|
- dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql
|
|
24
|
+
- dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
|
|
24
25
|
---
|
|
25
26
|
|
|
26
27
|
## Summary
|
|
@@ -127,8 +128,46 @@ identical in every environment (the platform's only sanctioned hardcoded ids —
|
|
|
127
128
|
next free id differs per environment (a new field took 2483 in production and 2766 in
|
|
128
129
|
dev-sandbox), so a row you are creating right now may need a per-environment literal.**
|
|
129
130
|
|
|
130
|
-
**Known-broken existing files (same root cause)
|
|
131
|
-
`Client
|
|
131
|
+
**Known-broken existing files (same root cause).** A full sweep of `dbchanges2` on **2026-09-01**
|
|
132
|
+
found **12** `Client/` files doing a real cross-cluster read (`FROM`/`JOIN Core.*`). All of them are
|
|
133
|
+
**ACL or surface-grant** migrations, and all fall in a **2026-06-25 → 2026-07-23** window —
|
|
134
|
+
`Core/` is clean, so the leak is one-directional:
|
|
135
|
+
|
|
136
|
+
| File (`dbchanges2/Client/`) |
|
|
137
|
+
|---|
|
|
138
|
+
| `2026-07-22c - TrackingNumberMeasureIdsFieldPermission` (10 `Core.` refs) |
|
|
139
|
+
| `2026-07-16b - TrackingNumberSignatureTypeFieldPermission` |
|
|
140
|
+
| `2026-07-16c - AddressValidateScriptAcl` |
|
|
141
|
+
| `2026-07-23a - EntitlementServiceAddressIdFieldPermission` |
|
|
142
|
+
| `2026-07-17 - ItemsisFulfillable` |
|
|
143
|
+
| `2026-07-10b - ItemFulfillmentReturnAddressIdFieldPermission` |
|
|
144
|
+
| `2026-07-10c - GenerateReturnLabelScriptAcl` |
|
|
145
|
+
| `2026-07-02a - TrackingNumberReprintScriptAcl` |
|
|
146
|
+
| `2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission` |
|
|
147
|
+
| `2026-06-30a - SurfaceMetaGroupAcl` |
|
|
148
|
+
| `2026-06-29c - SurfaceRecordScriptAcl` |
|
|
149
|
+
| `2026-06-25c - SurfaceClientAcl` |
|
|
150
|
+
|
|
151
|
+
(plus `Client_Rate/2026-07-23a`, previously known.) **A clean local run is never proof any of them
|
|
152
|
+
worked** — locally the schema literally named `Core` is the **1.0** database, so the subselect
|
|
153
|
+
resolves against the wrong schema instead of failing.
|
|
154
|
+
|
|
155
|
+
**Their failure mode is silent, and it is worth stating precisely** because it is why nobody caught
|
|
156
|
+
this for two months: `SET @x = (SELECT … FROM Core.X)` yields **NULL**, the `IS NOT NULL` guard then
|
|
157
|
+
suppresses the `INSERT`, and **the file reports success having granted nothing**. No error, no
|
|
158
|
+
warning, zero rows.
|
|
159
|
+
|
|
160
|
+
**Confirmed live instance:** `Client_Adyen` has `2026-07-22c`'s mirror sibling grant but **none of
|
|
161
|
+
that file's three measure-id fields** — exactly the shape this bug produces.
|
|
162
|
+
|
|
163
|
+
Verified on production 2026-09-01: both `reader1.client.database.togahub.com` and
|
|
164
|
+
`writer.client.database.togahub.com` host **only `Client_*` databases — there is no `Core` there
|
|
165
|
+
at all**, so a `Core.X` reference from a `Client/` file cannot resolve under any circumstances.
|
|
166
|
+
|
|
167
|
+
**New occurrences are now blocked** by the `dbchanges2-cluster-isolation` PreToolUse hook
|
|
168
|
+
(`.claude/hooks/toga/dbchanges2-cluster-isolation.js`), which inspects every `Write`/`Edit`.
|
|
169
|
+
The 12 above pre-date it and are a **remediation backlog**, not a closed issue — each needs its
|
|
170
|
+
`Core` subselect swapped for a hardcoded `Core.RecordFields` id literal and then re-running.
|
|
132
171
|
|
|
133
172
|
### Standard field vs. custom (`c_`) field — TWO different permission tables
|
|
134
173
|
|
|
@@ -191,6 +230,33 @@ first (see
|
|
|
191
230
|
granting access therefore goes in `dbchanges2/Client/` (or a specific `Client_<Name>/`) so it
|
|
192
231
|
runs against every client DB.
|
|
193
232
|
|
|
233
|
+
### ⚠ Read `aclDatabase` FIRST — querying the wrong database gives a confident, meaningless answer
|
|
234
|
+
|
|
235
|
+
This is the step that is easiest to skip and the most expensive to skip, because **both databases
|
|
236
|
+
have an `AclFieldPermissions` table**. Query the wrong one and you get rows back, draw a conclusion,
|
|
237
|
+
and never learn it was about a different record. Verified the hard way 2026-09-01: `Core` was
|
|
238
|
+
checked for `purchase-order-items` (`Records.id = 18`), which is **`aclDatabase = 'CLIENT'`** — so
|
|
239
|
+
Core's rows are **never consulted for that record** and the whole investigation went down a wrong
|
|
240
|
+
path on the strength of them.
|
|
241
|
+
|
|
242
|
+
**Recipe — "a field returns 0/null but the data is there":**
|
|
243
|
+
|
|
244
|
+
1. **Establish the ACL database before anything else:**
|
|
245
|
+
```sql
|
|
246
|
+
SELECT id, route, aclDatabase FROM Core.Records WHERE route = '<route>';
|
|
247
|
+
```
|
|
248
|
+
2. **Query `AclFieldPermissions` in THAT database** — `Core` if `CORE`, `Client_<Tenant>` if
|
|
249
|
+
`CLIENT`. Rows in the other one are noise.
|
|
250
|
+
3. **Compare a field that WORKS against the broken one, side by side.** A role row present on one
|
|
251
|
+
and absent on the other is conclusive; a raw list of grants for the broken field alone is not,
|
|
252
|
+
because you have no baseline for what "granted" looks like on that record.
|
|
253
|
+
|
|
254
|
+
> **⚠ Before spending any time on steps 1–3, check whether ACL can even be the cause.** If the
|
|
255
|
+
> field is **`_`-prefixed and requested through an explicit `fields=` list**, `AclFieldPermissions`
|
|
256
|
+
> is **bypassed entirely** and no grant you add or remove will change the result — see
|
|
257
|
+
> [V2 REST query contract → ACL behavior](../../api2/features/v2-rest-query-contract.md). Confirm
|
|
258
|
+
> the request shape first; it eliminates the entire ACL chain in one step.
|
|
259
|
+
|
|
194
260
|
**Role ids differ per client DB**, so never hardcode a `roleId` in a `CLIENT` ACL migration —
|
|
195
261
|
resolve it with a subselect, e.g. the Base role:
|
|
196
262
|
|
|
@@ -423,6 +489,46 @@ and **succeeds with 0 inserted and no error**. Always confirm the sibling has ro
|
|
|
423
489
|
`shippingMethodId` (338), `measures.slug` (723), `containsBattery` (962), `shipToAddressId` (991).
|
|
424
490
|
**Do NOT mirror `needsReturnLabel`** — its grants are absent in some clients.
|
|
425
491
|
|
|
492
|
+
### ⚠ But when you know the TARGET role, grant it EXPLICITLY — mirroring is the riskier option
|
|
493
|
+
|
|
494
|
+
The mirror practice above answers *"which roles read this kind of field?"* when you genuinely do not
|
|
495
|
+
know. **When you already know the role you need, name it — do not mirror.** Mirroring looks tidier
|
|
496
|
+
and reads as the house style (`Client/2026-07-22c` is the precedent), but it copies whatever the
|
|
497
|
+
sibling happens to have in each tenant, and that set is **not uniform**. Two live failures from one
|
|
498
|
+
2026-09-01 survey of the `PurchaseOrderItems._qty*` fields:
|
|
499
|
+
|
|
500
|
+
- **Mirroring over-grants.** `Client_Compass` grants `quantity` to **role 4 (Public)**. A migration
|
|
501
|
+
mirroring `quantity` would have handed the **Public** role read access to eight derived inventory
|
|
502
|
+
quantities across that tenant — a real widening, applied silently, in a file whose stated purpose
|
|
503
|
+
was to grant the API role.
|
|
504
|
+
- **Mirroring under-grants.** `Client_Imhouston` has **no `quantity` grant at all**, so the same
|
|
505
|
+
mirror inserts **zero rows** there and the field stays broken — the silent-no-op failure already
|
|
506
|
+
described above.
|
|
507
|
+
|
|
508
|
+
**Rule: resolve the role BY NAME (`(SELECT id FROM Roles WHERE name = 'API')`) and grant it
|
|
509
|
+
explicitly.** You keep the per-tenant role-id independence that mirroring was bought for, without
|
|
510
|
+
inheriting a per-tenant role *set* you never inspected. Reserve mirroring for the case where the
|
|
511
|
+
reader role is genuinely unknown — and when you do mirror, dry-run it per tenant and read the role
|
|
512
|
+
ids it would actually insert.
|
|
513
|
+
|
|
514
|
+
### Fleet reality check: a template mis-seed means "most tenants" is not "correct"
|
|
515
|
+
|
|
516
|
+
The same survey (36 production client databases, 2026-09-01) on the eight
|
|
517
|
+
`PurchaseOrderItems._qty*` fields:
|
|
518
|
+
|
|
519
|
+
| Grant state | Tenants |
|
|
520
|
+
|---|---|
|
|
521
|
+
| all eight granted to **role 1 (Base)**, **none to role 3 (API)** | **31** |
|
|
522
|
+
| **no `_qty*` grants at all** | 4 — Compass, Imhouston, Mcgrawhill, Tdsynnex |
|
|
523
|
+
| roles 1 **and** 3 (correct) | 1 — Prudential |
|
|
524
|
+
| role 3 (correct for API use) | 1 — Northwell |
|
|
525
|
+
|
|
526
|
+
**A 31-of-36 majority was the WRONG state**, not the baseline — this is a template-level mis-seed
|
|
527
|
+
propagated by the blank-client DDL, so every tenant provisioned from it inherits the same gap.
|
|
528
|
+
Practical consequence: **never infer the intended grant from what most tenants have.** Derive it
|
|
529
|
+
from which role actually reads the field, then check whether the majority agrees; when it does not,
|
|
530
|
+
suspect the template rather than the two outliers.
|
|
531
|
+
|
|
426
532
|
Reconfirmed 2026-08-03 (TRUE-80494): `measures.measureType` had **zero** grant rows in dev-sandbox
|
|
427
533
|
`Client_Growrk`, so it was omitted from `GET /v2/measures`; the frontend's strict
|
|
428
534
|
`m.measureType === "LENGTH"` split then produced empty arrays and **both unit dropdowns rendered
|
|
@@ -492,6 +598,26 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
|
|
|
492
598
|
and every repo is on the **same branch** so the generated model matches the DB.
|
|
493
599
|
|
|
494
600
|
## Change history
|
|
601
|
+
- **2026-09-01** — Four additions from a `PurchaseOrderItems._qty*` grant investigation.
|
|
602
|
+
(1) **Read `Core.Records.aclDatabase` FIRST** — both databases carry an `AclFieldPermissions`
|
|
603
|
+
table, so checking Core for a `CLIENT`-acl record (here `purchase-order-items`, record 18) returns
|
|
604
|
+
rows and a confident wrong answer; added the compare-a-working-field diagnostic recipe, gated on
|
|
605
|
+
first confirming ACL can even apply (an `_`-prefixed field in an explicit `fields=` list **bypasses
|
|
606
|
+
`AclFieldPermissions` entirely** — new section in
|
|
607
|
+
[v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md)).
|
|
608
|
+
(2) **Grant a KNOWN target role explicitly rather than mirroring a sibling** — mirroring `quantity`
|
|
609
|
+
would have granted **role 4 (Public)** eight derived inventory quantities on `Client_Compass` and
|
|
610
|
+
inserted **nothing** on `Client_Imhouston` (no `quantity` grant there); resolve by role *name*
|
|
611
|
+
instead. This qualifies, and partly reverses, the house mirror precedent `Client/2026-07-22c`.
|
|
612
|
+
(3) **A 31-of-36 majority can be the wrong state** — 31 prod tenants grant the `_qty*` fields to
|
|
613
|
+
role 1 and none to role 3; only Prudential and Northwell are correct. Template-level mis-seed, so
|
|
614
|
+
never infer the intended grant from the majority.
|
|
615
|
+
(4) **Expanded the known-broken cross-cluster file list from 2 to 12** (full `dbchanges2` sweep;
|
|
616
|
+
all `Client/` ACL/surface grants in a 2026-06-25 → 2026-07-23 window, `Core/` clean), stated the
|
|
617
|
+
silent failure mechanism (`SET @x = (SELECT … FROM Core.X)` → NULL → guard suppresses the INSERT →
|
|
618
|
+
reports success), confirmed `Client_Adyen` as a live instance, verified on prod that the client
|
|
619
|
+
cluster hosts no `Core` at all, and noted the new `dbchanges2-cluster-isolation` PreToolUse hook
|
|
620
|
+
blocks new ones while these 12 remain a remediation backlog. (apeterson)
|
|
495
621
|
- **2026-08-29** — Two more worked examples of the **standard-field write grant via hardcoded
|
|
496
622
|
`Core.RecordFields` id literal** (the cross-cluster caveat — a `Client_*`→`Core` JOIN is refused on the
|
|
497
623
|
prod client cluster), each cloning a working sibling: `ItemReceipts.transferOrderId` (Core RF **2153**,
|
|
@@ -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: [tcox, bala, apeterson, jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -259,6 +259,46 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
|
|
|
259
259
|
list, **and the permitted data still populated**.
|
|
260
260
|
- **Recommendation: always send an explicit `fields=` list.** Silent omission is how a
|
|
261
261
|
consumer ends up "working" against data it never actually received.
|
|
262
|
+
- **⚠ That recommendation holds for PLAIN fields only — an `_`-prefixed field named in
|
|
263
|
+
`fields=` is never ACL-checked at all.** See the next section.
|
|
264
|
+
|
|
265
|
+
### ⚠ An explicitly-requested `_`-prefixed field BYPASSES `AclFieldPermissions` entirely
|
|
266
|
+
|
|
267
|
+
This is the exception to everything above, and it inverts the usual debugging instinct: for an
|
|
268
|
+
underscore-prefixed field, **adding an `AclFieldPermissions` grant changes nothing** about an
|
|
269
|
+
explicit `fields=` request. Verified 2026-09-01 on `purchase-order-items._qty*`.
|
|
270
|
+
|
|
271
|
+
`processRoutePairs()` classifies each entry of an explicit `fields=` list in a fixed order, and the
|
|
272
|
+
ACL branch is **not first**:
|
|
273
|
+
|
|
274
|
+
| `V2.php` | branch | ACL checked? |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
| **:3338** | `$modelFieldConfig[$f]['type'] == _Model::FIELD_SQL` → **calculated field** | **no** |
|
|
277
|
+
| **:3353** | field starts with `_` but is not `FIELD_SQL` → reclassified as an **aggregate function** (`'function' => substr($field, 1)`) | **no** |
|
|
278
|
+
| **:3375–3381** | plain field with no grant → *"not permitted access, unauthorized"* → **`EZ-2`** | yes |
|
|
279
|
+
|
|
280
|
+
So `fields=_qtyAvailable` resolves at **:3338** and the request never reaches the ACL branch. The
|
|
281
|
+
ACL gate for calculated fields lives at **:7216** in `getFullModelData()` — which is the
|
|
282
|
+
**no-explicit-`fields=`** path only.
|
|
283
|
+
|
|
284
|
+
**The two practical consequences:**
|
|
285
|
+
|
|
286
|
+
1. **Granting `AclFieldPermissions` does not change the result of an explicit `fields=` request for
|
|
287
|
+
a calculated field.** If a `_qty*` field returns `0` with the grant added and with it removed,
|
|
288
|
+
the ACL was never the variable — stop testing grants and go look at the SQL the field emits
|
|
289
|
+
(and at *which model class* is emitting it — see the NYCHH case linked below).
|
|
290
|
+
The grant is still worth having: it is load-bearing on the `getFullModelData()` path.
|
|
291
|
+
2. **Plain fields fail LOUDLY, calculated fields fail SILENTLY.** An ungranted plain field is a
|
|
292
|
+
`EZ-2` 403 you cannot miss. An ungranted `_`-prefixed field is quietly turned into an aggregate
|
|
293
|
+
function, and — because these fields typically carry
|
|
294
|
+
`FIELDOPT_SQL_TYPE => FIELD_DECIMAL` — the serializer still emits the key and it renders as
|
|
295
|
+
**`0`**. It reads as a broken calculation, not as a permission problem, which is exactly the
|
|
296
|
+
wrong place to start looking. (The `FIELD_DECIMAL` cast is documented on
|
|
297
|
+
[FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).)
|
|
298
|
+
|
|
299
|
+
**Corollary for the `fields=`-forces-`EZ-2` diagnostic above:** re-requesting with an explicit
|
|
300
|
+
`fields=` list to smoke out a denied field **only works on plain columns**. It will never produce an
|
|
301
|
+
`EZ-2` for a `_`-prefixed field, so a silent `0` is not evidence that the grant exists.
|
|
262
302
|
- **`EZ-1` vs `EZ-2` for a consumer:** `EZ-1` = record-level block (`data.<route>` is `null`) —
|
|
263
303
|
hard blocked, nothing usable. `EZ-2` = field-level — on a **LIST** it is
|
|
264
304
|
*degraded-but-usable*; on a **by-uuid READ** it is **fatal**.
|
|
@@ -296,7 +336,14 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
|
|
|
296
336
|
- **A 500 (`EO-1`) on a filter ⇒ suspect a `where` reference to an unjoined table.**
|
|
297
337
|
- **`gte`/`lte` silently are not operators** — use `ge`/`le`.
|
|
298
338
|
- **Do not trust a 200 with missing fields.** Without an explicit `fields=` list, ACL removal is
|
|
299
|
-
invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
|
|
339
|
+
invisible; re-request with `fields=` to force `EZ-2` and see the denied list. **⚠ Plain columns
|
|
340
|
+
only** — an `_`-prefixed field named in `fields=` is never ACL-checked and returns `0`, not
|
|
341
|
+
`EZ-2` (see the ACL section).
|
|
342
|
+
- **⚠ A calculated field returning `0` is almost never an ACL problem.** Eliminate ACL in one
|
|
343
|
+
step (it is bypassed on the explicit-`fields=` path), then check **which model class** supplies
|
|
344
|
+
the expression — a `_Model_<Client>_X` override on the *deployed* `_underscore` branch can
|
|
345
|
+
replace the base SQL with something that legitimately evaluates to `0`. Worked example:
|
|
346
|
+
[NYCHH transfer-order inventory quantities](../../../clients/nychh/features/transfer-order-inventory-quantities.md).
|
|
300
347
|
- **⚠ A PUT response ECHO can omit a field that DID persist — verify against the DB, not the echo.**
|
|
301
348
|
A `PUT /v2/inventory-adjustments/{uuid}` with `{"itemFulfillment":{"uuid":…}}` returned **200 with
|
|
302
349
|
an empty `messages[]`** and an echo that did **not** contain `itemFulfillment`, yet the FK column
|
|
@@ -306,6 +353,16 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
|
|
|
306
353
|
[V2 request logging](request-logging.md).
|
|
307
354
|
|
|
308
355
|
## Change history
|
|
356
|
+
- 2026-09-01 — **Recorded the biggest exception to this doc's own "always send an explicit `fields=`
|
|
357
|
+
list" advice: an `_`-prefixed field named in `fields=` is NEVER ACL-checked.** `V2.php:3338`
|
|
358
|
+
classifies a `FIELD_SQL` field as calculated *before* the ACL branch at `:3353`, and `:3353` then
|
|
359
|
+
reclassifies any other `_`-prefixed field as an **aggregate function** rather than denying it —
|
|
360
|
+
only plain fields reach the `EZ-2` branch at `:3375-3381`. The ACL gate for calculated fields is
|
|
361
|
+
at `:7216`, on the **no-explicit-`fields=`** path. Two consequences: adding an
|
|
362
|
+
`AclFieldPermissions` grant cannot change the result of an explicit `fields=_qtyX` request, and
|
|
363
|
+
ungranted calculated fields fail **silently as `0`** (the `FIELDOPT_SQL_TYPE => FIELD_DECIMAL`
|
|
364
|
+
cast still emits the key) while plain fields fail loudly. Found while a `_qty*` field returned 0
|
|
365
|
+
and a grant migration was written against the wrong theory. (apeterson)
|
|
309
366
|
- 2026-08-29 — Recorded two response-shape facts from the NYCHH inventory-adjustment REST work: **a
|
|
310
367
|
non-null FK object one hop past the max `depth` is silently omitted** (a null sibling FK still
|
|
311
368
|
renders, so it reads as "null" not "missing") — `GET /item-fulfillment-items` at `depth:2` dropped
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [Database Changes (dbchanges2) Repository Architecture](architecture.md) | `dbchanges2` is the **schema-migration / SQL change-set repository** for the entire 2.0 platform. | Core/, Client/, Client_<Tenant>/, Logs/, Logs_Client/, _modules/ |
|
|
6
|
-
| [Re-runnable additive migrations (uuid4 in SQL, INSERT guards, the DISTINCT trap, conditional ALTERs)](features/rerunnable-additive-inserts.md) | Most `dbchanges2` files are **additive data grants** run by hand against production, often more than once (once per environment, or twice because someone was no | dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql, dbchanges2/Core/2026-08-27a - Insert - Netsuite Location SyncAll CronJob.sql |
|
|
6
|
+
| [Re-runnable additive migrations (uuid4 in SQL, INSERT guards, the DISTINCT trap, conditional ALTERs)](features/rerunnable-additive-inserts.md) | Most `dbchanges2` files are **additive data grants** run by hand against production, often more than once (once per environment, or twice because someone was no | dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql, dbchanges2/Core/2026-08-27a - Insert - Netsuite Location SyncAll CronJob.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
|
|
7
7
|
| [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Core/2026-08-31a - SalesOrderDenialDetailsSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-31a - SalesOrderDenialDetailsReadOnlyRules.sql, toga25-supply/db-migrations/PLAYBOOK.md, toga25-supply/db-migrations/SURFACE-FEATURE-RUNBOOK.md, dbchanges2/Client_Compass/, dbchanges2/Client_CompassCanada/, dbchanges2/Client_Quad/, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_CompassCanada/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.sql, dbchanges2/Client_Quad/2026-07-21b - ApproveDisabledTooltipPoNumber.sql, dbchanges2/Client_Quad/2026-08-21 - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_CompassCanada/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql, dbchanges2/Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-05b - ApprovalDetailsShippingMethodConcatCharge.sql, dbchanges2/Core/2026-08-14a - NoteBadgesVocabularySurface.sql, dbchanges2/Client/2026-08-14a - NoteBadgeThemeTokens.sql, dbchanges2/Client/2026-08-14b - NoteBadgeUserColorFix.sql, dbchanges2/Client_Compass/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Client_CompassCanada/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Core/2026-08-26a - ServiceRequestRecordHeaderSurfaceSeed.sql, dbchanges2/Core/2026-08-26b - ServiceRequestDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql, dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql |
|
|
8
8
|
| [2.0 New-Client Onboarding (manual process)](workflows/client-onboarding.md) | > **A local browser wizard now automates this.** Steps 2–9 below (create DBs, generate Core/API > inserts, append to `Clients_Db.txt`) — plus the dbchanges2 bla | Client/, Client_<Tenant>/, Core/, Logs_Client/ |
|
|
9
9
|
| [Auditing a client DB that drifted from its models (partially applied module migration)](workflows/client-schema-drift-audit.md) | A recurring 2.0 failure mode: **one client's database drifts from what the PHP models declare**, usually because a `_modules/<module>/` migration was applied to | dbchanges2/Client_Growrk/2026-05-28.sql, dbchanges2/Client_Growrk/2026-08-10c - GrowrkServiceRequestCustomFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10d - GrowrkServiceRequestTypeAndDispositionSeeds.sql, dbchanges2/_modules/netsuite/2026-07-10a - UnitInventoryFields.sql, dbchanges2/Client_Growrk/2026-08-10 - GrowrkUnitInventoryFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10b - GrowrkUnitItemDescriptionAcl.sql, dbchanges2/Client_Growrk/_modules.txt |
|
|
@@ -6,15 +6,17 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-01
|
|
10
10
|
owners: [bala, kyalamarthi, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
|
|
13
13
|
- dbchanges2/Core/2026-08-27a - Insert - Netsuite Location SyncAll CronJob.sql
|
|
14
|
+
- dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
|
|
14
15
|
related:
|
|
15
16
|
- ../architecture.md
|
|
16
17
|
- ../../worker2/features/netsuite-salesorder-open-orders-sync.md
|
|
17
18
|
- ../../_underscore/features/acl-permission-chain.md
|
|
19
|
+
- ../../_underscore/features/calculated-sql-fields.md
|
|
18
20
|
- ../../_underscore/features/tracking-number-bridges.md
|
|
19
21
|
- ../../../../clients/compass-usa/workflows/granting-persona-bundle-access.md
|
|
20
22
|
- ../../../../clients/compass-usa/workflows/granting-navigation-access.md
|
|
@@ -49,6 +51,36 @@ and `SUBSTRING('89ab', …)` picks the variant nibble. Verified working on prod
|
|
|
49
51
|
that is *not* v4-shaped (older migrations concatenated `RANDOM_BYTES` without the version/variant
|
|
50
52
|
nibbles). They are fine and must not be "fixed" — but do not use them as the template for new SQL.
|
|
51
53
|
|
|
54
|
+
### ⚠ In a FAN-OUT grant migration, use HARDCODED uuid literals — generated uuids make rollback unsafe
|
|
55
|
+
|
|
56
|
+
The generator above is right for a normal additive insert. It is **wrong for a `Client/` fan-out
|
|
57
|
+
that grants a permission across every tenant**, because you cannot write a safe rollback for rows
|
|
58
|
+
whose uuids you do not know.
|
|
59
|
+
|
|
60
|
+
The tempting rollback is a natural key:
|
|
61
|
+
|
|
62
|
+
```sql
|
|
63
|
+
-- DANGEROUS in a fan-out: deletes grants this file never created
|
|
64
|
+
DELETE FROM AclFieldPermissions WHERE recordFieldId IN (…) AND roleId = (SELECT id FROM Roles WHERE name = 'API');
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
That also removes **pre-existing** grants held by the tenants that were already correct. Concrete
|
|
68
|
+
case (2026-09-01, the `PurchaseOrderItems._qty*` role-3 grant): Prudential and Northwell already had
|
|
69
|
+
these rows, so a key-based rollback would have **revoked working production permissions** on two
|
|
70
|
+
tenants as collateral for undoing 34 others.
|
|
71
|
+
|
|
72
|
+
**Instead, hardcode one v4 uuid literal per row in the file.** The rollback is then exact:
|
|
73
|
+
|
|
74
|
+
```sql
|
|
75
|
+
DELETE FROM AclFieldPermissions WHERE uuid IN ('<literal-1>', …, '<literal-8>');
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
It can only ever match rows this file inserted. **Reusing the same literal across tenants is valid**
|
|
79
|
+
— `uuid` is unique *per table per database*, and each tenant is a separate database, so one literal
|
|
80
|
+
per (row × file) is enough for the whole fan-out. Keep the `NOT EXISTS` guard on the real unique key
|
|
81
|
+
(here `(recordFieldId, roleId)`) so re-running is still a no-op on tenants that already have the
|
|
82
|
+
grant; the literal governs only deletion.
|
|
83
|
+
|
|
52
84
|
### ⚠ `DISTINCT` cannot dedupe a row that carries a random uuid
|
|
53
85
|
|
|
54
86
|
This is the silent one. In an `INSERT … SELECT`, putting the uuid expression inside a
|
|
@@ -235,8 +267,29 @@ concluding a migration is production-safe.
|
|
|
235
267
|
file: separate production clusters mean a `Core.` reference is unrunnable in prod even though it
|
|
236
268
|
works locally. Hardcode `Core.Records` / `Core.RecordFields` ids as literals — see
|
|
237
269
|
[dbchanges2 architecture](../architecture.md).
|
|
270
|
+
- **⚠ Read the sibling migration's `INSERT` HEADER — never infer the column list from its `VALUES`
|
|
271
|
+
row.** Copying a neighbouring file is the normal way to start a migration, and matching the
|
|
272
|
+
*shape* of its values tuple (right arity, plausible-looking types) produces a file that looks
|
|
273
|
+
correct and fails on execution. Verified 2026-09-01: `Core.Messages` is
|
|
274
|
+
**`(id, uuid, dtCreated, messageKey, name, defaultValue)`** — a guess of `slug` / `message`
|
|
275
|
+
(inferred from a sibling's values row) died on `Unknown column 'slug'`. When in doubt,
|
|
276
|
+
`SHOW COLUMNS FROM <Table>` costs one round-trip.
|
|
277
|
+
- **`Core.RecordFields` has NO `sql` column.** A calculated field's SQL is **not** stored in
|
|
278
|
+
metadata — it lives in the `_underscore` model as a `FIELD_SQL` static method. Do not go looking
|
|
279
|
+
for a migration to change a calculated field's expression; there is none. See
|
|
280
|
+
[FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
|
|
238
281
|
|
|
239
282
|
## Change history
|
|
283
|
+
- 2026-09-01 — Added the **fan-out rollback rule**: a `Client/` grant migration that fans out across
|
|
284
|
+
every tenant must use **hardcoded v4 uuid literals**, not the generator, so its rollback
|
|
285
|
+
(`DELETE … WHERE uuid IN (…)`) can only touch rows that file created. A natural-key rollback on
|
|
286
|
+
`(recordFieldId, roleId)` would have revoked **pre-existing production grants** on Prudential and
|
|
287
|
+
Northwell while undoing the `PurchaseOrderItems._qty*` role-3 fan-out. Reusing one literal per row
|
|
288
|
+
across tenants is valid (uuid is unique per table per database). Also recorded two schema gotchas:
|
|
289
|
+
**read a sibling's `INSERT` header, never infer columns from its `VALUES` row** (`Core.Messages` is
|
|
290
|
+
`(id, uuid, dtCreated, messageKey, name, defaultValue)` — a guessed `slug` failed with *Unknown
|
|
291
|
+
column*), and **`Core.RecordFields` has no `sql` column** — calculated-field SQL lives in the
|
|
292
|
+
model, not in metadata. (apeterson)
|
|
240
293
|
- 2026-08-28 — Added **conditional (re-runnable) ALTERs**: MySQL 8 has no
|
|
241
294
|
`ADD COLUMN IF NOT EXISTS`, so a `Client/` all-tenant file must gate each `ALTER` on
|
|
242
295
|
`information_schema` (with `TABLE_SCHEMA = DATABASE()` for cluster safety) and run it through
|
|
@@ -6,7 +6,7 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-01
|
|
10
10
|
owners: [apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Config/production.ini
|
|
@@ -85,11 +85,24 @@ Delete the file when you are done.
|
|
|
85
85
|
`Table 'Client_Nychh.Messages' doesn't exist`, which names a missing table rather than the real
|
|
86
86
|
mistake. If you see "table doesn't exist" for a table you know is in Core, check which schema you
|
|
87
87
|
piped the file into before you touch the SQL.
|
|
88
|
+
- 🚨 **A `Client/` fan-out hits EVERY `Client_*` schema on the cluster — including dated BACKUP
|
|
89
|
+
databases.** The production client cluster carries copies that are not tenants. Found
|
|
90
|
+
2026-09-01: **`Client_Compass_20260831_120000`**, a dated backup of `Client_Compass`, sitting
|
|
91
|
+
alongside the live schemas. Any "run this against every `Client_*` database" loop picks it up,
|
|
92
|
+
which at best wastes a run and at worst mutates a snapshot someone is holding for a restore.
|
|
93
|
+
**Before a fan-out, list the target schemas and eyeball them** —
|
|
94
|
+
`SHOW DATABASES LIKE 'Client\_%'` — and exclude anything matching a `_YYYYMMDD_HHMMSS` suffix or
|
|
95
|
+
otherwise absent from `Core.Clients`. Do not assume the tenant list and the schema list agree.
|
|
88
96
|
- **The repo is evidence of intent, never of what an environment contains.** Confirming a file ran
|
|
89
97
|
is its own procedure — see
|
|
90
98
|
[Verifying whether a migration actually ran](./verifying-a-migration-ran.md).
|
|
91
99
|
|
|
92
100
|
## Change history
|
|
101
|
+
- 2026-09-01 — Added the **backup-database hazard for `Client/` fan-outs**: the production client
|
|
102
|
+
cluster hosts dated snapshots alongside live tenants (`Client_Compass_20260831_120000`, found
|
|
103
|
+
during a 36-database ACL survey), and a naive "every `Client_*` schema" loop will run against
|
|
104
|
+
them. List and filter the target schemas (`SHOW DATABASES LIKE 'Client\_%'`, drop
|
|
105
|
+
`_YYYYMMDD_HHMMSS` suffixes / anything not in `Core.Clients`) before fanning out. (apeterson)
|
|
93
106
|
- 2026-08-31 — Initial. Captured after a dump taken from `127.0.0.1` was restored onto sandbox-client
|
|
94
107
|
`Client_Nychh` believing it was production (the tell: sandbox matched local exactly). Records the
|
|
95
108
|
verify-by-differing-row-count check, the `--single-transaction` snapshot-drift caveat, the
|
|
@@ -6,7 +6,7 @@ project: TOGa Blox
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-01
|
|
10
10
|
owners: [apeterson, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- toga-blox/src/reactQuery/queryHelpers.ts
|
|
@@ -118,6 +118,25 @@ Filterable`, `hyperlinkField`, `imageUrlField`, `sticky`, `precision`, …), `Da
|
|
|
118
118
|
paren pair so the group re-wrap doesn't double-wrap. Any raw pre-formatted where clause was
|
|
119
119
|
100% broken before this. If you add a new raw-string clause path, route it through this string
|
|
120
120
|
branch — do **not** feed a raw clause into an object-expecting serializer.
|
|
121
|
+
- **⚠ Every `where` VALUE must be a STRING — a number throws inside `buildUrl` and NO request is
|
|
122
|
+
ever sent.** `urlEncode(input: string)` calls `input.replace()`, so a numeric value raises
|
|
123
|
+
`input.replace is not a function` **synchronously** inside `assembleOptions` → `buildUrl` →
|
|
124
|
+
`queryFn`. React Query reports it as a query error, and because the throw happens *before* the
|
|
125
|
+
fetch, **DevTools shows no network activity at all** — the symptom is an empty list/dropdown with
|
|
126
|
+
a completely clean network tab, which reads as a backend or ACL problem and is neither.
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// WRONG — throws in buildUrl, no HTTP request
|
|
130
|
+
where: { "Locations.isActive": { "=": 1 } }
|
|
131
|
+
|
|
132
|
+
// CORRECT
|
|
133
|
+
where: { "Locations.isActive": { "=": "1" } }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
TypeScript does not catch it today. This is the **second** shape-assumption failure in
|
|
137
|
+
`reactQuery/queryHelpers.ts` (the raw-string `apiWhereClause` bug above is the first): the
|
|
138
|
+
serializer trusts its input's type and fails loudly only at the very end of the chain. Verified
|
|
139
|
+
2026-09-01 in `toga25-supply` (`useTargetLocationOptions.ts`, `useTransferSourceLocation.ts`).
|
|
121
140
|
- **⚠ AUTHORING DEFECT — `handleClientAuthentication(uuid)` collides by name with app-local SSO
|
|
122
141
|
helpers and is not interchangeable with them.** blox's version `POST`s
|
|
123
142
|
`/auth/encrypted-user-uuid` with an **empty payload** (`{client:"",user:""}`), writes
|
|
@@ -152,6 +171,12 @@ Filterable`, `hyperlinkField`, `imageUrlField`, `sticky`, `precision`, …), `Da
|
|
|
152
171
|
thread, not a settled finding.
|
|
153
172
|
|
|
154
173
|
## Change history
|
|
174
|
+
- 2026-09-01 — Recorded that **`where` values must be STRINGS**: `urlEncode` calls
|
|
175
|
+
`input.replace()`, so a numeric value throws `input.replace is not a function` synchronously in
|
|
176
|
+
`buildUrl`/`queryFn` and **no HTTP request is made at all** — an empty dropdown with a clean
|
|
177
|
+
network tab, easily misread as a backend/ACL fault. Second shape-assumption failure in
|
|
178
|
+
`reactQuery/queryHelpers.ts` after the 2026-08-17 raw-string clause bug; TypeScript does not catch
|
|
179
|
+
it. Hit in `toga25-supply`'s Create Transfer Order modal. (apeterson)
|
|
155
180
|
- 2026-08-27 — Recorded three blox **authoring defects** surfaced while debugging toga25-supply SSO:
|
|
156
181
|
(1) `handleClientAuthentication` collides by name with app-local SSO helpers and returns a
|
|
157
182
|
`/users` payload with no `singleSignOnServiceUrl`; (2) `fetchPublicToken` persists the public
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
| [SSO redirect & public-vs-user session gating (useAuthenticationFlow)](features/sso-redirect-and-session-gating.md) | How 2.5 Supply decides, on every navigation, whether an anonymous visitor should be bounced to their client's SSO IdP instead of the local `/login` form. | toga25-supply/src/hooks/useAuthenticationFlow.ts, toga25-supply/src/routes.tsx, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/api/api.ts |
|
|
15
15
|
| [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J | toga25-supply/src/App.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/DenialBanner.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/SalesOrderRecordModal.module.css, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/SalesOrderApprovalModalsLayout.tsx, toga25-supply/src/layout/RecordApprovalModal/RecordApprovalModalLayout.tsx, toga25-supply/src/layout/RecordApprovalModal/view/ApprovalTimelineView.tsx, toga25-supply/src/layout/RecordApprovalModal/ApprovalModal.module.css, toga25-supply/src/utils/formatDateTime.ts, toga25-supply/src/utils/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/layout/RecordApprovalModal/, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/surface/applyColSpan.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/sectionRenderers.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getVisibleSections.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderApprovalSummaryGrid.tsx, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/ServiceRequestRecordModalLayout.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/view/ServiceRequestsView.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/useServiceRequestRecordModalLayoutModel.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderSummaryGrid.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getDetailSections.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderNotesSection.tsx, toga25-supply/src/pages/SalesOrders/helpers/cleanOrder.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/AdminNotesSection.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getAdminNotes.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/surface/resolveElementState.ts, toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.ts, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderRowRecordState.ts, toga25-supply/src/surface/actionRegistry.ts, toga25-supply/src/surface/componentRegistry.tsx, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/surface/SurfaceSection.tsx, toga25-supply/src/surface/resolve.ts, toga25-supply/src/surface/types.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/Login/LoginPage.tsx, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/view/SurfaceRowActions.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderVip.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/Items/ItemsPage.tsx, toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx, toga25-supply/src/pages/VendorItems/VendorItemsPage.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/surfaceBundleToItemFields.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx, toga25-supply/src/layout/ItemRecordModalLayout/ItemRecordModalLayout.tsx, toga25-supply/src/layout/ItemRecordModalLayout/components/ItemRecordView.tsx, toga25-supply/src/surface/useStatusColors.ts, toga25-supply/src/surface/SurfaceHeader.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/helpers/surfaceBundlesToDecisionFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/useApprovalModalViewModel.tsx |
|
|
16
16
|
| [Talos Integration (AppLayout host + adapter wiring)](features/talos-integration.md) | toga25-supply is the first host of the shared blox [Talos assistant](../../toga-blox/features/talos-assistant.md). | toga25-supply/src/layout/AppLayout/AppLayout.tsx, toga25-supply/src/components/Header/Header.tsx, toga25-supply/src/components/Header/Header.module.css, toga25-supply/src/index.css, toga25-supply/src/assets/talos-owl.png |
|
|
17
|
-
| [Transfer Orders page (TableView → Core surfaces → React page + record modal)](features/transfer-orders-page.md) | The Transfer Orders screen — list + read-only record modal — built end to end on 2026-08-28 from a Claude Design prototype. | toga25-supply/src/pages/TransferOrders/TransferOrders.tsx, toga25-supply/src/pages/TransferOrders/hooks/useTransferOrdersTableState.ts, toga25-supply/src/pages/TransferOrders/viewModel/useTransferOrdersPageViewModel.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrdersTableLayout/TransferOrdersTableLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/TransferOrderRecordModalLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/hooks/useTransferOrderRecord.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/viewModel/useTransferOrderRecordModalLayoutModel.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/view/TransferOrderRecordView.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/CreateTransferOrderModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/index.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/
|
|
17
|
+
| [Transfer Orders page (TableView → Core surfaces → React page + record modal)](features/transfer-orders-page.md) | The Transfer Orders screen — list + read-only record modal — built end to end on 2026-08-28 from a Claude Design prototype. | toga25-supply/src/pages/TransferOrders/TransferOrders.tsx, toga25-supply/src/pages/TransferOrders/hooks/useTransferOrdersTableState.ts, toga25-supply/src/pages/TransferOrders/viewModel/useTransferOrdersPageViewModel.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrdersTableLayout/TransferOrdersTableLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/TransferOrderRecordModalLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/hooks/useTransferOrderRecord.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/viewModel/useTransferOrderRecordModalLayoutModel.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/view/TransferOrderRecordView.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/CreateTransferOrderModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/index.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/usePurchaseOrderItemRows.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTargetLocationOptions.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTransferSourceLocation.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useLocationContacts.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateLocationContact.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/AddContactModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/TransferNotesField.tsx, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql, toga25-supply/src/surface/useColumnVisibilityModalConfig.ts, toga25-supply/src/routes.tsx, dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql |
|
|
18
18
|
| [AWS Amplify Multi-Environment Deployment](workflows/amplify-deployment.md) | How `toga25-supply` deploys to **all** of its environments on AWS Amplify from a **single shared `amplify.yml`**. | toga25-supply/amplify.yml, toga25-supply/src/api/api.ts, toga25-supply/src/hooks/useAuthenticationFlow.ts, toga25-supply/vite.config.ts, toga25-supply/package.json, toga25-supply/.env.development |
|
|
19
19
|
| [Cypress Testing Harness (component + e2e)](workflows/cypress-testing.md) | The Cypress test harness for the `toga25-supply` frontend, bootstrapped from scratch (`cypress` was already a dependency but there was no config, no `cypress/` | toga25-supply/cypress.config.ts, toga25-supply/cypress/tsconfig.json, toga25-supply/cypress/support/component.tsx, toga25-supply/cypress/support/component-index.html, toga25-supply/cypress/support/e2e.ts, toga25-supply/cypress/support/commands.ts, toga25-supply/cypress/support/fixtures.ts, toga25-supply/cypress/support/mocks/useApprovalModalViewModel.ts, toga25-supply/cypress/component/SalesOrderApprovalModalsLayout.cy.tsx, toga25-supply/cypress/component/RecordApprovalModalLayout.cy.tsx, toga25-supply/cypress/component/EnterPoNumberModal.cy.tsx, toga25-supply/cypress/e2e/salesOrderApproval.cy.ts |
|
|
20
20
|
| [Porting a page (or query) from toga2-supply to toga25-supply](workflows/porting-a-page-from-toga2-supply.md) | `toga25-supply` re-implements pages that already exist in `toga2-supply`. | toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/PRUDENTIAL/exportApiFields.json, toga25-supply/src/pages/SalesOrders/api/prudentialExportApi.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/components/ui/Tables/hooks/useExportableData.tsx |
|
|
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-01
|
|
10
10
|
owners: [apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/main.tsx
|
|
@@ -111,5 +111,12 @@ its primary consumer and resolves it via a symlinked `node_modules/@agilant/toga
|
|
|
111
111
|
each other's cached data (e.g. record modal vs. approval modal — `scope="record-modal"` vs
|
|
112
112
|
`scope="approval-modal"`). Including `additionalData` in the query key is mandatory for
|
|
113
113
|
WHERE-filtered modal tables, or React Query serves a stale unfiltered first render.
|
|
114
|
+
- **⚠ Data hooks must use `staleTime: 0` — the query cache is PERSISTED.** `App.tsx` wraps the app
|
|
115
|
+
in `PersistQueryClientProvider` with a `createSyncStoragePersister` on the `localStorage` key
|
|
116
|
+
`supply-chain-query-cache`, so cache entries survive reloads *and* sessions. The `QueryClient`
|
|
117
|
+
default is `staleTime: 0` for exactly this reason. A hook that sets its own non-zero `staleTime`
|
|
118
|
+
can serve a persisted entry from a **previous session** and never fetch — the symptom is an empty
|
|
119
|
+
list/dropdown with **nothing in the network tab**, which reads as a backend fault and is not one.
|
|
120
|
+
(`staleTime: Infinity` on the **surface-meta** hooks is a deliberate, separate exception.)
|
|
114
121
|
- **Typecheck with `npx tsc --noEmit` / `npx tsc -b`** after changes; adding a required key to
|
|
115
122
|
`ClientFields` surfaces every bundle missing it.
|
|
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-01
|
|
10
10
|
owners: [apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/pages/TransferOrders/TransferOrders.tsx
|
|
@@ -19,7 +19,14 @@ files:
|
|
|
19
19
|
- toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/view/TransferOrderRecordView.tsx
|
|
20
20
|
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/CreateTransferOrderModal.tsx
|
|
21
21
|
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/index.ts
|
|
22
|
-
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/
|
|
22
|
+
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/usePurchaseOrderItemRows.ts
|
|
23
|
+
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTargetLocationOptions.ts
|
|
24
|
+
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTransferSourceLocation.ts
|
|
25
|
+
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useLocationContacts.ts
|
|
26
|
+
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateLocationContact.ts
|
|
27
|
+
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/AddContactModal.tsx
|
|
28
|
+
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/TransferNotesField.tsx
|
|
29
|
+
- dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
|
|
23
30
|
- toga25-supply/src/surface/useColumnVisibilityModalConfig.ts
|
|
24
31
|
- toga25-supply/src/routes.tsx
|
|
25
32
|
- dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql
|
|
@@ -39,6 +46,7 @@ related:
|
|
|
39
46
|
- ../../_underscore/features/tableview-joins.md
|
|
40
47
|
- ../../api2/features/v2-rest-query-contract.md
|
|
41
48
|
- ../../api2/features/tableview-field-metadata.md
|
|
49
|
+
- ../../toga-blox/features/api-client.md
|
|
42
50
|
- ../../../../clients/nychh/profile.md
|
|
43
51
|
- ../../../../clients/nychh/features/transfer-order-inventory-quantities.md
|
|
44
52
|
---
|
|
@@ -53,9 +61,12 @@ reserved-id seed → per-tenant TableView → per-tenant nav opt-in → surface-
|
|
|
53
61
|
**It was fully greenfield.** An audit of production, beta, sandbox-client **and all 36 client
|
|
54
62
|
databases** on 2026-08-28 found **no transfer-order TableView anywhere**. Nothing was extended.
|
|
55
63
|
|
|
56
|
-
A **Create Transfer Order** modal was started the same day
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
A **Create Transfer Order** modal was started the same day and **reworked on 2026-09-01** against
|
|
65
|
+
the live Claude Design export. Its data layer is wired (two of the three blocking data-model
|
|
66
|
+
*questions* dissolved — availability comes from per-PO-line `_qty*`, and Locations are flat) and the
|
|
67
|
+
order is scoped by a **purchase-order lock**, not by source location. 🚨 **But the picker still
|
|
68
|
+
shows nothing for NYCHH**: every PO line reports availability 0 because of an upstream data gap,
|
|
69
|
+
which is an open integration decision — see the blocker below.
|
|
59
70
|
|
|
60
71
|
The vertical is **platform-shared with NYCHH as the first (and today only) tenant**: the Core
|
|
61
72
|
surfaces and the stage theme tokens are neutral/shared, and only the TableView and the navigation
|
|
@@ -268,34 +279,85 @@ This closes the "no BUTTON_BAR surface" gap the first build left open.
|
|
|
268
279
|
through the new `useColumnVisibilityModalConfig` hook — see
|
|
269
280
|
[column-visibility](./column-visibility.md).
|
|
270
281
|
|
|
271
|
-
## Create Transfer Order modal —
|
|
282
|
+
## Create Transfer Order modal — the lock is on the PURCHASE ORDER, not the location
|
|
272
283
|
|
|
273
|
-
`view/CreateTransferOrderModal
|
|
274
|
-
|
|
284
|
+
`view/CreateTransferOrderModal/`, opened from the header button seeded above. Rebuilt **2026-09-01**
|
|
285
|
+
against the live Claude Design export; the three data-layer blockers below are resolved and the
|
|
286
|
+
picker is live.
|
|
275
287
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
- **
|
|
288
|
+
> **⚠ Supersedes the earlier "single-source lock" rule.** Source Location is **no longer derived
|
|
289
|
+
> from row selection**. Read the two rules below as a replacement, not an addition.
|
|
290
|
+
|
|
291
|
+
- **Source Location is fixed from the moment the modal opens** — it is the single active location
|
|
292
|
+
with **`allowSending = 1`**, resolved by its own hook, and read-only. Nothing the user checks
|
|
293
|
+
changes it.
|
|
294
|
+
- **The lock moved to the PURCHASE ORDER: every line on one transfer must come from the same PO.**
|
|
295
|
+
The binding is **bidirectional** — picking a PO filters the item list to that PO's lines, and
|
|
296
|
+
checking an item **locks the PO select** to that item's PO. Unlocking **restores the pre-lock
|
|
297
|
+
value**, the same restore behaviour the old location lock had.
|
|
298
|
+
- **The picker columns dropped Location, Unit Value and Total** — Location is now redundant (there
|
|
299
|
+
is only one source) and the money columns are out of v1 scope.
|
|
280
300
|
- **Dirty means "different from the state the form OPENED IN"**, not "different from empty" — so a
|
|
281
301
|
pre-scoped open is clean, which is what makes the discard guard trustworthy.
|
|
282
302
|
- **Qty error state machine:** touched on first keystroke; the error surfaces on **blur** only if
|
|
283
303
|
touched; then it live-revalidates.
|
|
284
304
|
- **The Review gate names the FIRST unmet condition** rather than a generic "incomplete".
|
|
285
305
|
|
|
286
|
-
|
|
287
|
-
|
|
306
|
+
New hooks/components: `hooks/usePurchaseOrderItemRows.ts`, `hooks/useTargetLocationOptions.ts`,
|
|
307
|
+
`hooks/useTransferSourceLocation.ts`, `hooks/useLocationContacts.ts`,
|
|
308
|
+
`hooks/useCreateLocationContact.ts`, `view/AddContactModal.tsx`, `view/TransferNotesField.tsx`.
|
|
309
|
+
**Deleted:** `hooks/useTransferableItemRows.ts`, `hooks/usePurchaseOrderOptions.ts`.
|
|
310
|
+
|
|
311
|
+
### The three original blockers
|
|
312
|
+
|
|
313
|
+
1. **~~Availability per (item, location) has no API~~ — the QUESTION dissolved, but the DATA does
|
|
314
|
+
not exist.** `PurchaseOrderItems._qty*` are per-PO-line `FIELD_SQL` fields and base availability
|
|
315
|
+
bottoms out at **item receipts**, not unit dispositions — so the belief that this needed a
|
|
316
|
+
`UnitDispositions` slug (and that NYCHH's all-NULL `unitDispositionId` blocked it) was **wrong**,
|
|
317
|
+
and no new API was needed. **However NYCHH's deployed tenant override returns 0 for every line**
|
|
318
|
+
— see the blocker below.
|
|
319
|
+
2. **~~The target-location picker is a TREE~~ — resolved: Locations are flat, so it is a select.**
|
|
320
|
+
No parent FK exists; the design's drill-down was dropped.
|
|
321
|
+
3. **Commit flips unit dispositions to In Transit** — still open (API vs. worker ownership).
|
|
322
|
+
|
|
323
|
+
### 🚨 OPEN BLOCKER — the NYCHH picker has zero selectable rows, and it is not a frontend fix
|
|
324
|
+
|
|
325
|
+
The picker keeps only lines with availability >= 1. For NYCHH there are **none**, in sandbox **and**
|
|
326
|
+
production.
|
|
327
|
+
|
|
328
|
+
`_Model_Nychh_PurchaseOrderItem` (deployed on `_production` and `_sandbox-client`, absent on
|
|
329
|
+
`_beta`/`_sandbox-dev`) sources "stock in" from inventory adjustments and `INNER JOIN`s
|
|
330
|
+
`InventoryAdjustmentItems.itemFulfillmentItemId`. **That column is NULL on 100% of rows** — 292 of
|
|
331
|
+
292 in sandbox, 317 of 317 in production — so `_qtyReceived`, `_qtyOnHand` and `_qtyAvailable` are
|
|
332
|
+
all `0.00` for every NYCHH purchase-order line. **0 production PO lines report qty > 0.**
|
|
333
|
+
|
|
334
|
+
Resolution is an **integration / data-owner decision**, not UI work: either the NetSuite integration
|
|
335
|
+
backfills `InventoryAdjustmentItems.itemFulfillmentItemId`, or the override's stock-in source
|
|
336
|
+
changes (base PO item receipts return real numbers today). Until then, do **not** treat an empty
|
|
337
|
+
picker here as a frontend or ACL defect. Full evidence and both options:
|
|
338
|
+
[NYCHH transfer-order inventory quantities](../../../../clients/nychh/features/transfer-order-inventory-quantities.md).
|
|
339
|
+
|
|
340
|
+
> The `Client/2026-09-01a` role-3 `_qty*` grant is **not** the fix for this and was verified not to
|
|
341
|
+
> change the response — an explicit `fields=` request bypasses `AclFieldPermissions` entirely.
|
|
342
|
+
|
|
343
|
+
### ⚠ `allowReceiving` is unmaintained — do NOT filter the target dropdown on it
|
|
344
|
+
|
|
345
|
+
Verified on NYCHH 2026-09-01: exactly **1 of 31** active locations sets `allowReceiving`, and it is
|
|
346
|
+
**the same location** that sets `allowSending = 1` — the source warehouse. Filtering targets on
|
|
347
|
+
`allowReceiving` therefore offers exactly one option: **the one place you cannot transfer to.**
|
|
288
348
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
349
|
+
The dropdown lists **all active locations minus the source**. Treat `allowReceiving` as a column
|
|
350
|
+
nobody populates until someone proves otherwise per tenant.
|
|
351
|
+
|
|
352
|
+
Related data-shape facts found the same day:
|
|
353
|
+
|
|
354
|
+
- **Contacts are sparse** — only **2 of 31** NYCHH locations have any, which is why the modal ships
|
|
355
|
+
an inline **Add Contact** path (`AddContactModal.tsx` + `useCreateLocationContact`).
|
|
356
|
+
- **The populated join table is `Contacts_Locations`** (route `/contact-locations`, **Record 76**) —
|
|
357
|
+
**not** `Locations_Contacts`. Binding the reversed name finds nothing.
|
|
358
|
+
- **`TransferOrders` has NO notes/description column.** The notes field's persistence is an **open
|
|
359
|
+
decision**: either `TransferOrders.description varchar(255)` (mirroring `SalesOrders`) or a
|
|
360
|
+
`TransferOrderNotes` child table (mirroring `SalesOrderNotes`). Nothing is written yet.
|
|
299
361
|
|
|
300
362
|
### Line-item fetch is off behind a FLAG, not commented out
|
|
301
363
|
|
|
@@ -357,8 +419,42 @@ visible in Core (neutral default; other tenants populate `TransferOrders.purchas
|
|
|
357
419
|
must not be committed there. Origin/destination were picked by co-prime strides over ranked active
|
|
358
420
|
`Locations` so they never collide, and the stage resolved via a ranked CTE rather than hardcoded ids.
|
|
359
421
|
|
|
422
|
+
## ⚠ Two ways a hook renders an EMPTY dropdown with NOTHING in the network tab
|
|
423
|
+
|
|
424
|
+
Both were hit building this modal, both look identical in the browser, and neither logs anything.
|
|
425
|
+
When a select is empty **and DevTools shows no request at all**, it is one of these two — not a
|
|
426
|
+
backend problem. Check them before touching the API.
|
|
427
|
+
|
|
428
|
+
**1. A numeric value in `where` throws inside `buildUrl`, so React Query never fires.**
|
|
429
|
+
blox's `urlEncode(input: string)` calls `input.replace()`, so a number throws
|
|
430
|
+
`input.replace is not a function` **synchronously inside `queryFn`**. React Query swallows it as a
|
|
431
|
+
query error — no request is ever sent. **Every `where` value must be a STRING:**
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
// WRONG — throws in buildUrl, no HTTP request, silent empty dropdown
|
|
435
|
+
where: { "Locations.isActive": { "=": 1 } }
|
|
436
|
+
|
|
437
|
+
// CORRECT
|
|
438
|
+
where: { "Locations.isActive": { "=": "1" } }
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Library-side detail: [blox API client](../../toga-blox/features/api-client.md).
|
|
442
|
+
|
|
443
|
+
**2. A non-zero `staleTime` lets a cache entry from a PREVIOUS SESSION count as fresh.**
|
|
444
|
+
`App.tsx` wraps the app in `PersistQueryClientProvider` with a `createSyncStoragePersister` on the
|
|
445
|
+
`localStorage` key **`supply-chain-query-cache`**, so the query cache **survives reloads and
|
|
446
|
+
sessions**. The `QueryClient` default is `staleTime: 0` for exactly this reason. A hook that sets
|
|
447
|
+
its own `staleTime: 5 * 60 * 1000` will happily serve a **stale persisted entry from yesterday** and
|
|
448
|
+
never fetch.
|
|
449
|
+
|
|
450
|
+
**Rule: data hooks in this app use `staleTime: 0`.** Three of this modal's new hooks shipped with
|
|
451
|
+
`5 * 60 * 1000` and had to be corrected. (`staleTime: Infinity` on the **surface-meta** hooks is a
|
|
452
|
+
deliberate, separate exception — see [surface-frontend](./surface-frontend.md).)
|
|
453
|
+
|
|
360
454
|
## Gotchas
|
|
361
455
|
|
|
456
|
+
- **⚠ An empty dropdown with no request in the network tab is a numeric `where` value or a
|
|
457
|
+
non-zero `staleTime`** — see the section above. Neither logs anything.
|
|
362
458
|
- **A missing `AclRecordPermissions` grant on a joined record deletes a column, not a request.** The
|
|
363
459
|
Status column would have silently vanished; record 351 had no grants at all.
|
|
364
460
|
- **A missing `AclFieldPermissions` grant on the DEFAULT-SORT column is a 500, not a 403.** The
|
|
@@ -383,6 +479,29 @@ visible in Core (neutral default; other tenants populate `TransferOrders.purchas
|
|
|
383
479
|
earlier migration without names, so a name-based lookup on them finds nothing.
|
|
384
480
|
|
|
385
481
|
## Change history
|
|
482
|
+
- 2026-09-01 — **Rebuilt the Create Transfer Order modal against the live Claude Design export and
|
|
483
|
+
closed two of its three data-layer blockers.** The **single-source lock is superseded**: Source
|
|
484
|
+
Location is now the one active `allowSending = 1` location, read-only from open and never derived
|
|
485
|
+
from row selection; the lock moved to the **PURCHASE ORDER** (all lines must share one PO,
|
|
486
|
+
bidirectional — picking a PO filters items, checking an item locks the PO select, unlock restores
|
|
487
|
+
the pre-lock value). Picker dropped the Location / Unit Value / Total columns. Blocker 1's
|
|
488
|
+
*question* dissolved — per-PO-line availability comes from `PurchaseOrderItems._qty*`, which bottom
|
|
489
|
+
out at **item receipts, not unit dispositions**, so NYCHH's all-NULL `unitDispositionId` was never
|
|
490
|
+
relevant; blocker 2 dissolved — Locations are flat, so the tree picker became a select.
|
|
491
|
+
🚨 **But recorded a NEW open blocker: the NYCHH picker has zero selectable rows** — the deployed
|
|
492
|
+
`_Model_Nychh_PurchaseOrderItem` INNER JOINs `InventoryAdjustmentItems.itemFulfillmentItemId`,
|
|
493
|
+
which is NULL on 100% of rows (292/292 sandbox, 317/317 prod), so every PO line reports
|
|
494
|
+
availability 0. That needs an integration/data-owner decision, not UI work, and the
|
|
495
|
+
`Client/2026-09-01a` role-3 grant is **not** its fix. Recorded that
|
|
496
|
+
**`allowReceiving` is unmaintained** (1 of 31 NYCHH locations, and it is the source warehouse — so
|
|
497
|
+
filtering targets on it offers only the one place you cannot ship to), that contacts are sparse
|
|
498
|
+
(2 of 31, hence the inline Add Contact path), that the populated join is **`Contacts_Locations`**
|
|
499
|
+
(record 76), and that **`TransferOrders` has no notes column** — persistence still an open choice
|
|
500
|
+
between `description varchar(255)` and a `TransferOrderNotes` child table. Also added the
|
|
501
|
+
**two silent empty-dropdown causes**: a numeric `where` value throws inside blox's
|
|
502
|
+
`urlEncode`/`buildUrl` so no request is sent, and a non-zero `staleTime` serves a previous
|
|
503
|
+
session's entry out of the persisted `supply-chain-query-cache` — data hooks here must use
|
|
504
|
+
`staleTime: 0`. (apeterson)
|
|
386
505
|
- 2026-08-31 — Recorded the **run state after sandbox-client was reset from production**: the three
|
|
387
506
|
`Client_Nychh` layers and the `TransferOrderItems` timestamp columns are already present there (so
|
|
388
507
|
those files insert 0 rows), the two **Core** seeds have not run, `Client/2026-08-28a` has run on
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
| [NYCHH inventory-adjustment → item-fulfillment linking (direct FK columns, REST)](features/netsuite-inventory-adjustment-fulfillment-link.md) | 1.0 | For NYCHH stock adjustments, the NetSuite **inventory adjustment** carries a link to the **Item Fulfillment** it corrects, on the multi-select body custom field | library/app/api/toga2.php, library/app/api/netsuite/rest.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_hh.php, worker/crons/toga2/netsuite/diagnose_hh_import_inventory_adjustment.php |
|
|
6
6
|
| [NYCHH NetSuite → TransferOrders import (stocking-flag routing, PO bridges, origin location)](features/netsuite-transfer-order-import.md) | 1.0 | NYCHH's NetSuite **transfer orders are represented as NetSuite sales orders** (same as the GroWrk pattern), so the shared NetSuite → TOGa Supply importer (`App_ | library/app/api/toga2.php, library/app/api/netsuite/rest.php, library/app/netsuite.php, worker/crons/toga2/netsuite/sync_togasupply_hh.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/diagnose_hh_stuck_transfer_order.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, dbchanges2/Client/2026-08-28a - TransferOrderItemsTimestampColumns.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestampColumnsReposition.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql |
|
|
7
7
|
| [NYCHH PO links are UPSTREAM — the downstream SO→PO route returns empty](features/po-number-upstream-direction.md) | 2.0 | NYCHH's sales orders are created **from the customer's purchase order**, so their SO↔PO links live in the **upstream** table `PurchaseOrders_SalesOrders` (route | _underscore/Model/Client/SalesOrder.php, _underscore/Trait/Netsuite/SalesOrder.php, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql, dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql |
|
|
8
|
-
| [NYCHH transfer-order 2.0 model — inventory quantities + V2 field enablement](features/transfer-order-inventory-quantities.md) | 2.0 | The 2.0 (`_underscore`) side of NYCHH transfer-order support: a **two-branch inventory quantity model** on the NYCHH `Item` override, plus the **client-override | _underscore/Model/Nychh/Item.php, _underscore/Model/Nychh/PurchaseOrderItem.php, _underscore/Model/Nychh/TransferOrder.php, _underscore/Model/Nychh/TransferOrderStage.php, _underscore/Model/Client/TransferOrder.php, _underscore/Model/Client/TransferOrderItem.php, dbchanges2/Client_Nychh/2026-08-27a - TransferOrderCustomFieldWritePermission.sql, dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql |
|
|
9
|
-
| [NYC Health & Hospitals](profile.md) | 2.0 | NYC Health & Hospitals (NYCHH) is a TOGA 2.0 client on the `_underscore` platform, prod schema `Client_Nychh`. | dbchanges2/Client_Nychh/2026-08-31a - PurchaseOrdersTableViewRebase.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql |
|
|
8
|
+
| [NYCHH transfer-order 2.0 model — inventory quantities + V2 field enablement](features/transfer-order-inventory-quantities.md) | 2.0 | The 2.0 (`_underscore`) side of NYCHH transfer-order support: a **two-branch inventory quantity model** on the NYCHH `Item` override, plus the **client-override | _underscore/Model/Nychh/Item.php, _underscore/Model/Nychh/PurchaseOrderItem.php, _underscore/Model/Nychh/TransferOrder.php, _underscore/Model/Nychh/TransferOrderStage.php, _underscore/Model/Client/TransferOrder.php, _underscore/Model/Client/TransferOrderItem.php, dbchanges2/Client_Nychh/2026-08-27a - TransferOrderCustomFieldWritePermission.sql, dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql, _underscore/Model/Client/PurchaseOrderItem.php |
|
|
9
|
+
| [NYC Health & Hospitals](profile.md) | 2.0 | NYC Health & Hospitals (NYCHH) is a TOGA 2.0 client on the `_underscore` platform, prod schema `Client_Nychh`. | dbchanges2/Client_Nychh/2026-08-31a - PurchaseOrdersTableViewRebase.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: nychh
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-01
|
|
10
10
|
owners: [jcardinal, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Nychh/Item.php
|
|
@@ -19,6 +19,8 @@ files:
|
|
|
19
19
|
- dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql
|
|
20
20
|
- dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql
|
|
21
21
|
- dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql
|
|
22
|
+
- dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
|
|
23
|
+
- _underscore/Model/Client/PurchaseOrderItem.php
|
|
22
24
|
related:
|
|
23
25
|
- ../../../2.0/apps/_underscore/features/acl-permission-chain.md
|
|
24
26
|
- ../../../2.0/apps/api2/features/v2-api-error-codes.md
|
|
@@ -123,6 +125,151 @@ hardening (not done): a `UNIQUE(purchaseOrderItemId, salesOrderItemId)` on the b
|
|
|
123
125
|
`PurchaseOrderItems_TransferOrderItems`, which is currently **empty (0 rows)** in prod — so PO-level
|
|
124
126
|
outbound reads 0 until the transfer-order import populates that bridge. Separate import gap, not this change.
|
|
125
127
|
|
|
128
|
+
### 🚨 The override is NOT on every branch — NYCHH PO-line quantities differ per environment
|
|
129
|
+
|
|
130
|
+
Verified 2026-09-01 with `git ls-tree` against each `origin/` branch of `_underscore`:
|
|
131
|
+
|
|
132
|
+
| Branch | `Model/Nychh/PurchaseOrderItem.php` | Effective `_qtyReceived` for NYCHH |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| `_production` | **present** | inventory adjustments → **0.00 today** |
|
|
135
|
+
| `_sandbox-client` | **present** | inventory adjustments → **0.00 today** |
|
|
136
|
+
| `_beta` | **absent** | falls back to base → **item receipts** |
|
|
137
|
+
| `_sandbox-dev` | **absent** | falls back to base → **item receipts** |
|
|
138
|
+
|
|
139
|
+
Because `_underscore` is **pulled at deploy time** by branch (`.platform/hooks/prebuild/git.sh`),
|
|
140
|
+
the same API request returns a **different number** depending on which tier answers it. On beta and
|
|
141
|
+
sandbox-dev the base `_Model_Client_PurchaseOrderItem` runs and reports real received quantities;
|
|
142
|
+
on production and sandbox-client the NYCHH override runs and reports 0.00 by design.
|
|
143
|
+
|
|
144
|
+
**This is the single most misleading thing about debugging these fields.** Checking your local
|
|
145
|
+
working tree tells you nothing — see the worked case below.
|
|
146
|
+
|
|
147
|
+
### The BASE `_Model_Client_PurchaseOrderItem` chain (what NYCHH falls back to)
|
|
148
|
+
|
|
149
|
+
Worth having written down, because it is the formula that runs wherever the override is absent, and
|
|
150
|
+
because it is what a hand-run SQL check reproduces unless you deliberately pick the override. All
|
|
151
|
+
are `FIELD_SQL` correlated subqueries — **none is a stored column**
|
|
152
|
+
(`_underscore/Model/Client/PurchaseOrderItem.php`, ~L41–158):
|
|
153
|
+
|
|
154
|
+
| Field | Base definition |
|
|
155
|
+
|---|---|
|
|
156
|
+
| `_qtyReceived` | `SUM(ItemReceiptItems.quantity)` where `purchaseOrderItemId = PurchaseOrderItems.id` |
|
|
157
|
+
| `_qtyFulfilled` | `SUM(ItemFulfillmentItems.quantity)` through `PurchaseOrderItems_TransferOrderItems → ItemFulfillments → ItemFulfillmentStages → ItemFulfillmentStatuses`, restricted to status slug **`SHIPPED`** (i.e. shipped on a linked **transfer** order) |
|
|
158
|
+
| `_qtyOnHand` | `_qtyReceived − _qtyFulfilled` |
|
|
159
|
+
| `_qtyCommitted` | `SUM(SalesOrderItems.qtyCommitted) − SUM(shipped ItemFulfillmentItems.quantity)`, both reached via `PurchaseOrders_SalesOrders` and matched on `VendorItems.id = PurchaseOrderItems.vendorItemId` — **matched by VENDOR ITEM, not by PO line** |
|
|
160
|
+
| `_qtyAvailable` | `_qtyOnHand − _qtyCommitted` |
|
|
161
|
+
|
|
162
|
+
**Two things follow that were previously believed otherwise:**
|
|
163
|
+
|
|
164
|
+
- **Base availability bottoms out at ITEM RECEIPTS, not unit dispositions.** This dissolves the
|
|
165
|
+
earlier blocker that "availability needs `Units` + a `UnitDispositions` slug" — NYCHH units all
|
|
166
|
+
carry `NULL unitDispositionId`, which turned out to be irrelevant to these fields. It is what
|
|
167
|
+
unblocked the Create Transfer Order item picker (see
|
|
168
|
+
[Transfer Orders page](../../../2.0/apps/toga25-supply/features/transfer-orders-page.md)).
|
|
169
|
+
- **`_qtyCommitted` matches by vendor item, not by PO line**, so at base it can attribute
|
|
170
|
+
commitment from a *different* PO line carrying the same `vendorItemId`. The NYCHH override
|
|
171
|
+
replaces it with a PO-line-scoped Transfer-Order commitment precisely to avoid this.
|
|
172
|
+
|
|
173
|
+
### ⚠ Worked case — "the API returns 0 but the SQL returns 40" (diagnosed 2026-09-01)
|
|
174
|
+
|
|
175
|
+
`GET /v2/purchase-order-items` returned `_qtyReceived/_qtyOnHand/_qtyFulfilled/_qtyAvailable: 0`
|
|
176
|
+
for every line on sandbox-client, while the *same*-looking subquery run by hand on the *same* host
|
|
177
|
+
returned **40.00**.
|
|
178
|
+
|
|
179
|
+
**Cause: the two were never the same SQL.** Both formulas run against the reference row
|
|
180
|
+
(sandbox `Client_Nychh`, `PurchaseOrderItems.id = 196`, uuid
|
|
181
|
+
`01110c53-ba73-e60a-d94c-0ccf77327c0e`, PO 80622 line 1):
|
|
182
|
+
|
|
183
|
+
| Expression | `_qtyReceived` |
|
|
184
|
+
|---|---|
|
|
185
|
+
| base `_Model_Client_PurchaseOrderItem::_qtyReceived` (item receipts) | **40.00** |
|
|
186
|
+
| `_Model_Nychh_PurchaseOrderItem::_qtyReceived` (inventory adjustments) | **0.00** |
|
|
187
|
+
|
|
188
|
+
The hand-run check used the base formula; the API ran the NYCHH override. **The API was reporting
|
|
189
|
+
correctly** — but see the blocker below, because "correct" here means "0 for every line".
|
|
190
|
+
|
|
191
|
+
**Why the override was wrongly eliminated:** `_underscore/Model/Nychh/` was listed in the **local
|
|
192
|
+
working tree**, which sits on branch `features-talos-transferorders` and does not carry the file.
|
|
193
|
+
Branch drift *was* also checked — but only on `Model/Client/PurchaseOrderItem.php`, which is
|
|
194
|
+
byte-identical across all four branches. **That passing sibling check is what made the wrong
|
|
195
|
+
conclusion feel safe.** The drift was one directory over.
|
|
196
|
+
|
|
197
|
+
**The generalisable rules:**
|
|
198
|
+
|
|
199
|
+
1. **Check a tenant model override on the DEPLOYED branch, never on the local working tree.**
|
|
200
|
+
```
|
|
201
|
+
git cat-file -e origin/<branch>:Model/<Slug>/<Model>.php && echo present || echo absent
|
|
202
|
+
```
|
|
203
|
+
A feature branch is not evidence about production.
|
|
204
|
+
2. **A base file being byte-identical across branches rules out drift in THAT FILE ONLY.** It says
|
|
205
|
+
nothing about `Model/<Tenant>/`. Re-run the check there before writing the elimination down —
|
|
206
|
+
a clean sibling result is the trap, not the reassurance.
|
|
207
|
+
3. **A calculated field disagreeing with hand-run SQL means two different expressions are running.**
|
|
208
|
+
Identify the emitting class first; ACL, `calcDepth`, wrong DB and deploy lag are all downstream
|
|
209
|
+
of that and were each eliminated here at real cost.
|
|
210
|
+
|
|
211
|
+
Also eliminated and worth not repeating: ACL (bypassed entirely on the explicit-`fields=` path —
|
|
212
|
+
see [v2-rest-query-contract](../../../2.0/apps/api2/features/v2-rest-query-contract.md)),
|
|
213
|
+
`calcDepth` (echoed 3; the gate at `V2.php:7231` is `depth <= calcDepth`), wrong database
|
|
214
|
+
(`Core.DatabaseHosts` maps "Sandbox Client" for `Client_Nychh` to
|
|
215
|
+
`client.sandbox.database.togahub.com`, matching the API the frontend targets), deploy lag (the base
|
|
216
|
+
`_qtyReceived` formula is unchanged across the 2026-08-19 rewrite), wrong table
|
|
217
|
+
(`_Model_Client_ItemReceiptItem::TABLE = 'ItemReceiptItems'`), and missing data (sandbox has 823 PO
|
|
218
|
+
lines, 207 `ItemReceiptItems`, 122 lines with available >= 1).
|
|
219
|
+
|
|
220
|
+
### 🚨 OPEN BLOCKER — the override reads 0 for 100% of NYCHH PO lines, in sandbox AND production
|
|
221
|
+
|
|
222
|
+
The override's chain `INNER JOIN`s
|
|
223
|
+
`InventoryAdjustmentItems ON itemFulfillmentItemId = ItemFulfillmentItems.id`. **That column is
|
|
224
|
+
NULL on every row in both environments** (verified 2026-09-01):
|
|
225
|
+
|
|
226
|
+
| Environment | `InventoryAdjustmentItems` rows | rows with `itemFulfillmentItemId` set |
|
|
227
|
+
|---|---|---|
|
|
228
|
+
| sandbox `Client_Nychh` | 292 | **0** |
|
|
229
|
+
| production `Client_Nychh` | 317 | **0** |
|
|
230
|
+
|
|
231
|
+
Production PO lines the override would report with qty > 0: **0, out of all lines.**
|
|
232
|
+
|
|
233
|
+
An INNER JOIN makes an unlinked adjustment contribute nothing, so `_qtyReceived` — and therefore
|
|
234
|
+
`_qtyOnHand` and `_qtyAvailable` — is **0.00 for every NYCHH purchase-order line today.**
|
|
235
|
+
|
|
236
|
+
This is precisely the "Old-data caveat" the override file states in its own header comment (NetSuite
|
|
237
|
+
began linking inventory adjustments to fulfillments less than a year ago; unlinked adjustments carry
|
|
238
|
+
a NULL `itemFulfillmentItemId` and are excluded until the integration backfills them). **It is not a
|
|
239
|
+
corner case — it is currently 100% of the data.**
|
|
240
|
+
|
|
241
|
+
**Consequence: the Create Transfer Order item picker cannot show a single selectable line for
|
|
242
|
+
NYCHH.** The picker keeps only rows with availability >= 1, and there are none. This is
|
|
243
|
+
**unresolved and needs a decision from the integration / data owner** — it is *not* a frontend fix.
|
|
244
|
+
Two options:
|
|
245
|
+
|
|
246
|
+
- **(a)** the NetSuite integration backfills `InventoryAdjustmentItems.itemFulfillmentItemId`, or
|
|
247
|
+
- **(b)** the override's definition of "stock in" is changed (e.g. back to PO item receipts, which
|
|
248
|
+
return real numbers today — 40.00 on the reference row).
|
|
249
|
+
|
|
250
|
+
Until one of those lands, treat any NYCHH transfer-order availability figure as **structurally 0**,
|
|
251
|
+
not as a bug to debug. Tracking the adjustment↔fulfillment link:
|
|
252
|
+
[NYCHH inventory-adjustment → item-fulfillment linking](./netsuite-inventory-adjustment-fulfillment-link.md).
|
|
253
|
+
|
|
254
|
+
### The role-3 grant migration is NOT the fix for the empty picker
|
|
255
|
+
|
|
256
|
+
`dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql` grants **role 3 (API)**
|
|
257
|
+
READ (`isWritable = 0`) on all eight `_qty*` `RecordFields` (**1500–1504, 1524–1526**), guarded by
|
|
258
|
+
`NOT EXISTS` on the `(recordFieldId, roleId)` unique key.
|
|
259
|
+
|
|
260
|
+
**It was written against a wrong theory and was verified NOT to change the response** — an explicit
|
|
261
|
+
`fields=_qtyX` request never consults `AclFieldPermissions` at all. Nothing about the empty picker
|
|
262
|
+
is a permission problem; the cause is the data gap in the section above.
|
|
263
|
+
|
|
264
|
+
**Keep it anyway.** It closes a real and *separate* gap on the `getFullModelData()` (no-explicit-
|
|
265
|
+
`fields=`) path, where these fields genuinely are ACL-gated, and 31 of 36 prod tenants have the same
|
|
266
|
+
hole. It was **applied on sandbox `Client_Nychh`** and is **cleanly reversible** via its eight
|
|
267
|
+
hardcoded uuid literals (`DELETE … WHERE uuid IN (…)`) — which is why it uses literals rather than
|
|
268
|
+
generated uuids; see
|
|
269
|
+
[re-runnable additive migrations](../../../2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md).
|
|
270
|
+
Fleet survey and the "grant explicitly, don't mirror a sibling" rule it produced:
|
|
271
|
+
[ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md).
|
|
272
|
+
|
|
126
273
|
### Index coverage / schema
|
|
127
274
|
|
|
128
275
|
No `dbchanges2` file — pure calculated `FIELD_SQL` in PHP. All correlation columns
|
|
@@ -226,8 +373,42 @@ pattern (default a NOT NULL non-writable column in the model, never send it) is
|
|
|
226
373
|
- **A 0.00 quantity for NYCHH is expected today, not a bug.** Stock-in reads inventory-adjustment links
|
|
227
374
|
that are not backfilled yet, and PO-level outbound reads a still-empty
|
|
228
375
|
`PurchaseOrderItems_TransferOrderItems` bridge (see above).
|
|
376
|
+
- 🚨 **Every NYCHH PO line reads `_qtyReceived`/`_qtyOnHand`/`_qtyAvailable` = 0.00 right now**, in
|
|
377
|
+
sandbox **and** production, because `InventoryAdjustmentItems.itemFulfillmentItemId` is NULL on
|
|
378
|
+
100% of rows and the override INNER JOINs on it. Blocks the Create Transfer Order picker; needs an
|
|
379
|
+
integration/data decision, not code. See the OPEN BLOCKER section.
|
|
380
|
+
- **⚠ `_Model_Nychh_PurchaseOrderItem` is on `_production`/`_sandbox-client` but NOT on
|
|
381
|
+
`_beta`/`_sandbox-dev`** — so NYCHH PO-line quantities are genuinely different numbers per tier.
|
|
382
|
+
Establish the deployed `_underscore` branch before comparing environments or trusting a repro.
|
|
383
|
+
- **Never eliminate a tenant model override by `ls`-ing your working tree.** Use
|
|
384
|
+
`git cat-file -e origin/<deployed-branch>:Model/<Slug>/<Model>.php`. This exact mistake cost a
|
|
385
|
+
session — and a *passing* drift check on the base `Model/Client/` file is what made it feel safe.
|
|
229
386
|
|
|
230
387
|
## Change history
|
|
388
|
+
- 2026-09-01 — **Diagnosed the "API returns 0, hand-run SQL returns 40" report, and it turned into a
|
|
389
|
+
data blocker rather than a bug.** The API ran `_Model_Nychh_PurchaseOrderItem`'s
|
|
390
|
+
inventory-adjustment `_qtyReceived` while the hand-run check used the **base** item-receipt
|
|
391
|
+
formula — on the reference row (`PurchaseOrderItems.id 196`, PO 80622 line 1) the base returns
|
|
392
|
+
**40.00** and the override **0.00**. 🚨 **The override returns 0 for 100% of NYCHH PO lines in
|
|
393
|
+
both sandbox and production**: it INNER JOINs
|
|
394
|
+
`InventoryAdjustmentItems.itemFulfillmentItemId`, which is NULL on **all** rows (292 sandbox /
|
|
395
|
+
317 prod, 0 linked; 0 prod PO lines report qty > 0). The file's own "old-data caveat" is
|
|
396
|
+
currently the entire dataset, so the **Create Transfer Order item picker cannot show any
|
|
397
|
+
selectable NYCHH line** — recorded as an **open blocker needing an integration/data-owner
|
|
398
|
+
decision** (backfill the FK, or change the override's stock-in source), explicitly not a frontend
|
|
399
|
+
fix. Also found `Model/Nychh/PurchaseOrderItem.php` present on **`_production` + `_sandbox-client`**
|
|
400
|
+
and **absent from `_beta` + `_sandbox-dev`** (verified with `git cat-file -e`), so PO-line
|
|
401
|
+
quantities differ per tier; the override had been wrongly eliminated by listing the **local**
|
|
402
|
+
working tree on a feature branch that lacks it, while the drift check covered only
|
|
403
|
+
`Model/Client/PurchaseOrderItem.php` — the passing sibling check is what made the wrong conclusion
|
|
404
|
+
feel safe. Documented the **base `_qty*` chain** (received = `ItemReceiptItems`; fulfilled =
|
|
405
|
+
SHIPPED transfer-order fulfillments; committed matched **by vendor item, not PO line**), which
|
|
406
|
+
establishes that base availability bottoms out at **item receipts, not unit dispositions** —
|
|
407
|
+
dissolving the belief that NULL `unitDispositionId` was the blocker. Corrected the framing of
|
|
408
|
+
`Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql`: it is **not** the fix for the
|
|
409
|
+
empty picker and was verified not to change the response (explicit `fields=` bypasses ACL); it
|
|
410
|
+
remains a real separate gap on the `getFullModelData()` path, was applied on sandbox
|
|
411
|
+
`Client_Nychh`, and is cleanly reversible via its eight hardcoded uuid literals. (apeterson)
|
|
231
412
|
|
|
232
413
|
- 2026-08-31 — **Stock-in flipped from PO item receipts → inventory adjustments** (per the 2026-08-27
|
|
233
414
|
TOGa Supply meeting: NetSuite has no stocking concept, stock is added only by an inventory adjustment
|
|
@@ -26,6 +26,7 @@ files:
|
|
|
26
26
|
- dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql
|
|
27
27
|
- dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql
|
|
28
28
|
- dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql
|
|
29
|
+
- dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
|
|
29
30
|
related:
|
|
30
31
|
- ../../2.0/apps/_underscore/features/tracking-number-bridges.md
|
|
31
32
|
- ../../2.0/apps/_underscore/features/tableview-joins.md
|
|
@@ -99,8 +100,9 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
|
|
|
99
100
|
`Client_Nychh/`**, because the client files reference Core record ids and the NYCHH TableView/ACL
|
|
100
101
|
need the columns the fan-out files add. Run state per environment must be **verified against that
|
|
101
102
|
environment**, never assumed from the repo. **beta is blocked** because it lacks the
|
|
102
|
-
`TransferOrderStages` table.
|
|
103
|
-
layer
|
|
103
|
+
`TransferOrderStages` table. The **Create Transfer Order** modal was reworked 2026-09-01 and its
|
|
104
|
+
data layer is wired, but 🚨 **the item picker shows zero selectable lines for NYCHH** — see the
|
|
105
|
+
availability blocker below. See
|
|
104
106
|
[Transfer Orders page](../../2.0/apps/toga25-supply/features/transfer-orders-page.md).
|
|
105
107
|
- 🚨 **The transfer-order ACL + schema on prod/sandbox are UNTRACKED (2026-08-28).** Record and field
|
|
106
108
|
ACL for records 312/313, their logic-group/expression chain, and the
|
|
@@ -114,6 +116,32 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
|
|
|
114
116
|
fulfilled/backordered model on `_Model_Nychh_Item`, plus the client-override models + ACL rows that
|
|
115
117
|
let a NetSuite TransferOrder POST succeed. See
|
|
116
118
|
[NYCHH transfer-order inventory & quantities](./features/transfer-order-inventory-quantities.md).
|
|
119
|
+
- 🚨 **OPEN BLOCKER — every NYCHH PO line reports availability 0, in sandbox AND production
|
|
120
|
+
(verified 2026-09-01).** The deployed `_Model_Nychh_PurchaseOrderItem` sources stock-in from
|
|
121
|
+
inventory adjustments and `INNER JOIN`s `InventoryAdjustmentItems.itemFulfillmentItemId`, which is
|
|
122
|
+
**NULL on 100% of rows** (292/292 sandbox, 317/317 prod). So `_qtyReceived` / `_qtyOnHand` /
|
|
123
|
+
`_qtyAvailable` are all `0.00` for every purchase-order line, and the **Create Transfer Order item
|
|
124
|
+
picker has nothing to show**. Needs an **integration / data-owner decision** — backfill the FK, or
|
|
125
|
+
change the override's stock-in source (base PO item receipts return real numbers today). Not a
|
|
126
|
+
frontend or ACL defect, and **not** fixed by `Client/2026-09-01a`. See
|
|
127
|
+
[NYCHH transfer-order inventory & quantities](./features/transfer-order-inventory-quantities.md).
|
|
128
|
+
- 🚨 **`_Model_Nychh_PurchaseOrderItem` is NOT on every `_underscore` branch (verified 2026-09-01).**
|
|
129
|
+
`Model/Nychh/PurchaseOrderItem.php` exists on **`_production`** and **`_sandbox-client`** but is
|
|
130
|
+
**absent from `_beta`** and **`_sandbox-dev`**. Because `_underscore` is pulled at deploy by
|
|
131
|
+
branch, NYCHH PO-line `_qty*` values are **different numbers per tier**: prod/sandbox-client run
|
|
132
|
+
the inventory-adjustment formula (0.00 until the NetSuite link is backfilled), beta/sandbox-dev
|
|
133
|
+
fall back to the base item-receipt formula and report real quantities. **Never eliminate a tenant
|
|
134
|
+
override by `ls`-ing your working tree** — use
|
|
135
|
+
`git ls-tree -r --name-only origin/<branch>`. That mistake cost a full session chasing a
|
|
136
|
+
non-existent "API returns 0" bug. See
|
|
137
|
+
[NYCHH transfer-order inventory & quantities](./features/transfer-order-inventory-quantities.md).
|
|
138
|
+
- **Create Transfer Order modal is scoped by PURCHASE ORDER, not by location (2026-09-01).** Source
|
|
139
|
+
Location is the one active `allowSending = 1` location, read-only from open; all lines on a
|
|
140
|
+
transfer must share one PO. `Locations.allowReceiving` is **unmaintained** for this tenant (1 of 31
|
|
141
|
+
active locations, and it is the source warehouse), so the target dropdown lists all active
|
|
142
|
+
locations minus the source. Contacts are sparse (2 of 31) and the populated join is
|
|
143
|
+
**`Contacts_Locations`** (record 76), not `Locations_Contacts`. See
|
|
144
|
+
[Transfer Orders page](../../2.0/apps/toga25-supply/features/transfer-orders-page.md).
|
|
117
145
|
- **Inventory-adjustment → item-fulfillment linking** — imports NetSuite `custbody_stock_adjustment`
|
|
118
146
|
(adjustment→fulfillment) into two all-client bridges for adjustment→fulfillment→SO→PO traceability;
|
|
119
147
|
gated NYCHH-only, best-effort, with on-demand fulfillment import. Go-live toggle
|
package/package.json
CHANGED