toga-ai 1.0.651 → 1.0.652

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.
@@ -4,13 +4,13 @@
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, _underscore/Model/Core/Page.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 |
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, _underscore/Model/Core/Page.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 |
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 |
11
11
  | [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
12
12
  | [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
13
- | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent | _underscore/Model.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/ServiceRequest.php, _underscore/Model/Elite/SalesOrder.php, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql |
13
+ | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent | _underscore/Model.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/ServiceRequest.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Quad/Item.php, _underscore/Model/Quad/VendorItem.php, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql, dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql |
14
14
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | test/@Mark/true-80824-fedex-inflate-test.php, dbchanges2/Client_Growrk/2026-08-10e - GrowrkUpsServiceMethodCodes.sql, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/ShipmentRequest/ShipmentRequest.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Component/Library/Carriers/Usps/Usps.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
15
15
  | [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the | _underscore/Database.php, _underscore/Environment.php, api2/Initialize.php |
16
16
  | [_Cloud S3 helpers (copy / get / delete / list)](features/cloud-s3-helpers.md) | `_Cloud` centralizes AWS SDK S3 usage for the 2.0 stack so the `S3Client` never leaks into workers or app code. | _underscore/Cloud.php |
@@ -32,7 +32,7 @@
32
32
  | [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php, _underscore/Model/Rate/Subscription.php |
33
33
  | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php, _underscore/Trait/Netsuite/SalesOrder.php, worker2/Worker/Netsuite/SalesOrder.php, worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Worker/Netsuite/Opportunity.php |
34
34
  | [NetSuite Sales Order sync — ship-to address, phone, and PO reference sourcing](features/netsuite-salesorder-address-phone-sync.md) | `_Trait_Netsuite_SalesOrder` is the **shared** sales-order importer composed into **22 client models** (every client on the dbchanges2 `netsuite` module). | _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Model.php, dbchanges2/_modules/netsuite/2026-08-10a - AddressPhoneNumberApiRoleAcl.sql |
35
- | [Legacy page meta (Page::meta) & context-scoped ClientRecordFieldSettings](features/page-meta-context-field-settings.md) | `_Model_Core_Page::meta()` is the **legacy** page-meta resolver behind `GET /pages/meta?slug=<slug>` — still the live path for `toga2-supply` and other pre-Surf | _underscore/Model/Core/Page.php, _underscore/Model/Client/TableView.php, toga2-supply/src/components/ui/Tables/PrimaryTable/PrimaryTable.tsx |
35
+ | [Legacy page meta (Page::meta) & context-scoped ClientRecordFieldSettings](features/page-meta-context-field-settings.md) | `_Model_Core_Page::meta()` is the **legacy** page-meta resolver behind `GET /pages/meta?slug=<slug>` — still the live path for `toga2-supply` and other pre-Surf | _underscore/Model/Core/Page.php, _underscore/Model/Client/TableView.php, toga2-supply/src/components/ui/Tables/PrimaryTable/PrimaryTable.tsx, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx |
36
36
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/Model.php, _underscore/Query.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
37
37
  | [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
38
38
  | [Record Change Audit Log (Logs_<Client>.Record / RecordField) — reading a field's history](features/record-change-audit-log.md) | Every 2.0 client schema has a sibling **logs** schema `Logs_<Tenant>` (e.g. | _underscore/Model/Client/Logs/Record.php, _underscore/Model/Client/Logs/RecordField.php, _underscore/Model/Client/Logs/CustomRecordField.php |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-26
10
10
  owners: ["jcardinal", "mhammontree", "tcox", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -16,6 +16,7 @@ files:
16
16
  - dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql
17
17
  - dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql
18
18
  - dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql
19
+ - dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql
19
20
  ---
20
21
 
21
22
  ## Summary
@@ -129,6 +130,16 @@ nothing:
129
130
  | **Standard** (e.g. `serviceAddressId`) | `Core.RecordFields` | **`AclFieldPermissions`** | `Core.RecordFields` id |
130
131
  | **Custom** (`c_` prefix) | `CustomRecordFields` | **`AclCustomFieldPermissions`** | `CustomRecordFields` id |
131
132
 
133
+ **A `CustomRecordFields` row is not always a `c_` field, and a presentation-only override needs NO
134
+ grant.** A client may register a `CustomRecordFields` row that deliberately **reuses an existing
135
+ Core field's name** purely to override that field's `type` for one client (the only client-scoped
136
+ way to change a table-view column's rendering type — see
137
+ [TableView field/column metadata](../../api2/features/tableview-field-metadata.md#per-client-type-override)).
138
+ In that case authorization still resolves through the Core field's `AclFieldPermissions` row for
139
+ that name, so **do not** add an `AclCustomFieldPermissions` row: it is unnecessary, and it would let
140
+ the column keep working after the Core grant is revoked. Verified on Quad's `_unitPrice` / `_unitCost`
141
+ (2026-08-26).
142
+
132
143
  **Promoting a custom field to a standard field moves its grant to the other table.** When
133
144
  `c_serviceAddressId` was promoted to the standard `serviceAddressId` (Rate WH guard, TRUE-79533),
134
145
  its `CustomRecordFields` row was deleted, so the pending `AclCustomFieldPermissions` grant
@@ -204,6 +215,39 @@ then checks the caller's roles against `AclRecordPermissions` (→ `EZ-1` if no
204
215
  readable/writable fields from `AclFieldPermissions`. `_Model_Core_Page::meta()` reads the same
205
216
  tables to return per-page ACL to the frontend.
206
217
 
218
+ ## Navigation / action flags (`Core.AclActions` + `AclActionPermissions`) — a THIRD gate
219
+
220
+ A UI control that is not a record CRUD operation and not a scripted API — a nav item, an "Add" or
221
+ "Edit" button in `toga2-supply` — is gated by an **action flag** that the frontend reads out of the
222
+ page meta as `acl['<page-route>']['<action-slug>']`. Two tables:
223
+
224
+ - **`Core.AclActions`** — the flag itself: `recordId` + `slug`. Example: on record **19**
225
+ (`VendorItems`), id **5** = `navigation-vendor-items` (can the page be reached) and id **7** =
226
+ `navigation-edit-on-vendor-items` (can rows be added/edited).
227
+ - **`Client_*.AclActionPermissions`** — `(aclActionId, roleId)`: the grant.
228
+
229
+ `_Model_Core_Page::meta()` (~L1285–1302) **initialises every action flag on the page to `false`** and
230
+ flips it to `true` only when an `AclActionPermissions` row matches one of the caller's roles. So a
231
+ missing row is not an error anywhere — the button is simply not rendered.
232
+
233
+ **⚠ This gate is INDEPENDENT of the CRUD chain, which is what makes it hard to diagnose.** The page
234
+ meta can report `_CREATE` / `_UPDATE` / `_DELETE` `true` (because `AclRecordPermissions` grants them)
235
+ while the button that would invoke them is hidden, so every permission check you run says
236
+ "permissions are fine" and it reads as a frontend bug.
237
+
238
+ **Recipe when a control is missing for one client:** find the exact flag slug the component reads
239
+ (both the Add and the Edit control commonly read the *same* flag), then diff
240
+ `AclActionPermissions` against a client where the control works, and grant the missing
241
+ `(aclActionId, roleId)` in a `dbchanges2` migration.
242
+
243
+ Worked example (Quad, 2026-08-26): the vendor-items **Add and Edit buttons both** read
244
+ `acl['vendor-items']['navigation-edit-on-vendor-items']` (AclActions id 7). `Client_Quad` had a row
245
+ for id 5 only, so the page was reachable but button-less, even though record 19 already granted role
246
+ 1 — held by every Quad user — full CRUD. Prod comparison: Compass grants 5 → roles 9,11 and 7 → role
247
+ 16; Compass Canada grants 5 and 7 both → role 10. Quad followed Compass Canada and granted id 7 to
248
+ role 7 (Global Admin). Because this is only a UI gate, the fix is purely additive and cannot widen
249
+ data access.
250
+
207
251
  ## Record-script dispatch grant (`AclRecordScripts`) — a separate gate
208
252
 
209
253
  A **scripted API** (Record Script) has its own ACL gate, **distinct from the four-table record-CRUD
@@ -359,6 +403,16 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
359
403
  and every repo is on the **same branch** so the generated model matches the DB.
360
404
 
361
405
  ## Change history
406
+ - 2026-08-26 - Added the **action-flag gate** (`Core.AclActions` + `Client_*.AclActionPermissions`),
407
+ a third gate alongside record CRUD and record scripts: `Page.php` (~L1285-1302) defaults every
408
+ action flag to `false` and only a matching `AclActionPermissions` row flips it true, so a missing
409
+ row silently hides a nav item or an Add/Edit button while the page meta still reports
410
+ `_CREATE/_UPDATE/_DELETE` true. Worked example: Quad's vendor-items Add **and** Edit both read
411
+ `navigation-edit-on-vendor-items` (AclActions id 7 on record 19); Quad had id 5 only, fixed by
412
+ granting id 7 to role 7 following Compass Canada. Also noted that a `CustomRecordFields` row which
413
+ reuses a Core field's name to override its **type** for one client must **not** get an
414
+ `AclCustomFieldPermissions` row - the Core grant governs, and an extra row would survive a revoke.
415
+ (bala)
362
416
  - 2026-08-18 - Caveated "explicitly seeded and identical in every environment": true for
363
417
  established `Core.RecordFields` rows, but the **next free id differs per environment** (a new
364
418
  field took 2483 in prod, 2766 in dev-sandbox), so a newly created row may need a per-environment
@@ -6,20 +6,24 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-26
10
10
  owners: [snaredla, jcardinal, bala]
11
11
  files:
12
12
  - _underscore/Model.php
13
13
  - _underscore/Model/Client/SalesOrder.php
14
14
  - _underscore/Model/Client/ServiceRequest.php
15
15
  - _underscore/Model/Elite/SalesOrder.php
16
+ - _underscore/Model/Quad/Item.php
17
+ - _underscore/Model/Quad/VendorItem.php
16
18
  - dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql
19
+ - dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql
17
20
  related:
18
21
  - ../architecture.md
19
22
  - ./acl-permission-chain.md
20
23
  - ../../api2/features/v2-api-error-codes.md
21
24
  - ./model-magic-field-access.md
22
25
  - ./model-save-parent-cascade-stored-field-deadlock.md
26
+ - ./purchase-order-sales-order-bridges.md
23
27
  ---
24
28
 
25
29
  ## Summary
@@ -56,6 +60,35 @@ fallback — `_serialNumbers` the property requires `_serialNumbers()` the metho
56
60
  Options: `FIELDOPT_SQL_TYPE` (defaults to `FIELD_CHAR`) and `FIELDOPT_SQL_STORED` (default `false`,
57
61
  recalculated per query; `true` persists to a real column on save).
58
62
 
63
+ ### ⚠ `FIELDOPT_SQL_TYPE` casts the returned VALUE — a TEXT-returning field must be plain `FIELD_SQL`
64
+
65
+ `FIELDOPT_SQL_TYPE` is **not** only a hint for ordering/pagination cursors: it rewrites the value
66
+ the API returns. `getSqlFieldValue()` (`Model.php` ~L509–520) reads the option off the field
67
+ definition and pipes the fetched value through `formatValueForField()` (~L728), whose `switch`
68
+ **casts**:
69
+
70
+ | `FIELDOPT_SQL_TYPE` | what happens to the value |
71
+ |---|---|
72
+ | `FIELD_DECIMAL` | `(float) $value` |
73
+ | `FIELD_INTEGER` / `FIELD_FOREIGNKEY` / `FIELD_PRIMARYKEY` | `(int) $value` |
74
+ | `FIELD_BOOLEAN` | `null` or a real bool |
75
+ | **omitted** (defaults to `FIELD_CHAR`) | **nothing — there is no `case self::FIELD_CHAR`, so the value passes through untouched** |
76
+
77
+ So a calculated field that returns text but is declared
78
+ `[self::FIELD_SQL, self::FIELDOPT_SQL_TYPE => self::FIELD_DECIMAL]` is **silently zeroed**:
79
+ `(float) 'PLN 5295.00'` is `0`. The symptom — api2 returning `_unitPrice: 0` and the grid rendering
80
+ an em dash — points nowhere near the declaration, and the SQL is right there returning the correct
81
+ string when run by hand.
82
+
83
+ **Rule: if the expression can return text — a formatted amount, a `GROUP_CONCAT`, a status word —
84
+ declare the field as plain `self::FIELD_SQL` with NO `FIELDOPT_SQL_TYPE`.** `_priceType` is the
85
+ in-tree precedent for a text-returning calculated field. Only add the option when the expression
86
+ really does return a number of that type.
87
+
88
+ Verified 2026-08-26 on `_Model_Quad_Item::_unitPrice`, which returns a `GROUP_CONCAT` of
89
+ `'CODE amount'` pairs across a Quad item's currencies (see
90
+ [Quad multi-currency item pricing](../../../../clients/quad/features/multi-currency-item-pricing.md)).
91
+
59
92
  ## Exposing a calculated field to the API - a client `CustomRecordFields` row is enough
60
93
 
61
94
  **Declaring the field on the model does not make it requestable.** A client whose DB has no
@@ -88,6 +121,21 @@ first pass and nothing on the second; against `Client_Compass` it inserted nothi
88
121
  id 77 and its role 1/3/4 grants untouched. Prudential gains the field across **26,280**
89
122
  `SalesOrders_PurchaseOrders` rows.
90
123
 
124
+ **⚠ Idempotent is not the same as applied — audit every schema afterwards.** A `Client/`-scoped
125
+ migration is run per client DB by hand, so a single missed schema leaves that client with `EV-8`
126
+ forever and nobody notices until a user reports a blank field. Re-auditing `_purchaseOrders` two
127
+ weeks after the 2026-08-11b rollout found it **missing on `Client_Imhouston` (1 of 35) on prod and
128
+ on 7 of 36 client-sandbox schemas** (Aig, Browardsheriff, Canon, GroWrk, Imhouston, Masonite,
129
+ Trividiahelth). The re-runnable follow-up is
130
+ `Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql`. Query
131
+ `information_schema` / `CustomRecordFields` across every schema rather than trusting that the
132
+ original change-set was run everywhere. Watch for near-duplicate schemas while you audit —
133
+ `Client_Trividiahelth` is a **misspelled duplicate** that exists only on client-sandbox.
134
+
135
+ The ACL convention for these rows: grants go to the **`Base` and `API` roles only, resolved BY
136
+ NAME**. Most other `SalesOrders` custom fields carry `Base` alone, so `Base + API` is already the
137
+ generous end; `Developer` and `Public` get no ACL row for any field.
138
+
91
139
  ## Gotchas / known issues
92
140
 
93
141
  - **⚠ Never add a parameter TYPE to an override whose parent declares the parameter untyped - it
@@ -125,7 +173,22 @@ id 77 and its role 1/3/4 grants untouched. Prudential gains the field across **2
125
173
  [save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
126
174
 
127
175
  ## Change history
128
-
176
+ - 2026-08-26 - **`FIELDOPT_SQL_TYPE` casts the returned VALUE, not just sort/pagination cursors.**
177
+ `getSqlFieldValue()` (`Model.php` ~L509-520) hands the fetched value to `formatValueForField()`
178
+ (~L728), which `(float)`s a `FIELD_DECIMAL` field - so a text-returning calculated field declared
179
+ `FIELDOPT_SQL_TYPE => FIELD_DECIMAL` is silently zeroed (`(float) 'PLN 5295.00'` = 0, seen as
180
+ `_unitPrice: 0` + an em dash in the grid). A text-returning field must be plain `self::FIELD_SQL`:
181
+ the option defaults to `FIELD_CHAR`, which has **no case** in the switch, so the value passes
182
+ through. `_priceType` is the precedent. Verified on `_Model_Quad_Item::_unitPrice`. (bala)
183
+
184
+ - 2026-08-26 - Added the **audit-after-rollout** rule: an idempotent `Client/`-scoped migration is
185
+ still run per schema by hand, and re-auditing `_purchaseOrders` found it missing on
186
+ `Client_Imhouston` (1 of 35 prod) and 7 of 36 client-sandbox schemas, driving the re-runnable
187
+ `Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql`. Recorded the `Base` + `API`
188
+ by-name ACL convention for these rows. Cross-linked
189
+ [PO-to-SO bridge tables](./purchase-order-sales-order-bridges.md) — the metadata row makes
190
+ `_purchaseOrders` *requestable*, but the field's SQL was reading the wrong bridge table, so a
191
+ successful rollout still returned blank for most clients. (bala)
129
192
  - 2026-08-18 - Documented **how a calculated field becomes requestable**: a client-scoped
130
193
  `CustomRecordFields` row is sufficient and **no `Core.RecordFields` row is needed** (Compass's
131
194
  `_purchaseOrders` = id 77, with `_totalLease` 81 / `_trackingNumbers` 83, has no Core row and
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-11
10
- owners: ["mhammontree"]
9
+ updated: 2026-08-26
10
+ owners: ["mhammontree", "bala"]
11
11
  files:
12
12
  - _underscore/Trait/Netsuite/SalesOrder.php
13
13
  - _underscore/Model.php
@@ -16,6 +16,7 @@ related:
16
16
  - acl-permission-chain.md
17
17
  - address-uniqueness-normalization.md
18
18
  - carrier-shipping-labels.md
19
+ - purchase-order-sales-order-bridges.md
19
20
  - ../../toga2-supply/features/fulfill-and-ship.md
20
21
  - ../../api2/features/record-scripts.md
21
22
  ---
@@ -40,6 +41,16 @@ changes behaviour for all 22 clients at once**.
40
41
  (and was NULL on the order tested), while the **bridge covers 12,643 of 12,658** orders. Resolve the
41
42
  PO through the bridge, not the column.
42
43
 
44
+ **⚠ …and it is the REVERSE bridge, which the shared `SalesOrders._purchaseOrders` field did not
45
+ read.** This trait writes the PO link via the api2 child key **`purchaseOrderSalesOrders`**, which
46
+ lands in **`PurchaseOrders_SalesOrders`** — while the base calculated field
47
+ `_Model_Client_SalesOrder::_purchaseOrders()` queried **`SalesOrders_PurchaseOrders`**, a *different*
48
+ table populated by a *different* (1.0 cron) pipeline holding Agilant's **vendor** POs rather than the
49
+ customer's. So every client on this trait stored a correct PO that the field could not see: 41,381 of
50
+ 72,134 prod orders rendered blank. Read
51
+ [PO-to-SO bridge tables](./purchase-order-sales-order-bridges.md) **before** touching either side of
52
+ this link — the child key you choose decides which table you write, silently.
53
+
43
54
  ## ⚠ Four traps when writing a field on this shared trait
44
55
 
45
56
  1. **Normalise a phone with `_String::cleanPhoneNumber()` on the way INTO the payload.**
@@ -90,6 +101,12 @@ intended, not a fault.
90
101
 
91
102
  ## Change history
92
103
 
104
+ - 2026-08-26 — Corrected the PO half of the "where the values come from" table: the
105
+ `purchaseOrderSalesOrders` child key this trait posts writes the **reverse** bridge
106
+ (`PurchaseOrders_SalesOrders`, customer PO), while the shared `SalesOrders._purchaseOrders`
107
+ calculated field read the **forward** bridge (`SalesOrders_PurchaseOrders`, Agilant vendor PO) — so
108
+ all 22 trait clients stored a PO the UI could not display (41,381 of 72,134 prod orders blank).
109
+ Cross-linked the new [PO-to-SO bridge tables](./purchase-order-sales-order-bridges.md) doc. (bala)
93
110
  - 2026-08-11 — TRUE-80824: created. Prefilled TOGa Supply's Reference 1 / Reference 2 / Phone # from
94
111
  NetSuite by sourcing `SalesOrders.number`, the bridge-linked purchase order (`otherRefNum` — **not**
95
112
  `customerPurchaseOrder`, ~59% populated vs the bridge's 12,643/12,658), and the shipping address's
@@ -6,12 +6,13 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-26
10
10
  owners: [tcox, bala]
11
11
  files:
12
12
  - _underscore/Model/Core/Page.php
13
13
  - _underscore/Model/Client/TableView.php
14
14
  - toga2-supply/src/components/ui/Tables/PrimaryTable/PrimaryTable.tsx
15
+ - toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx
15
16
  related:
16
17
  - surface-resolver.md
17
18
  - ../../api2/features/tableview-field-metadata.md
@@ -36,6 +37,43 @@ bug that takes down *unrelated* pages for any client that uses the feature.
36
37
  Example (Elite): row id 1 relabels field **58** to **"Total"** in the context of field **190**
37
38
  (`ServiceRequests.id`) — authored for the `service-requests` view.
38
39
 
40
+ ## Column TYPE and column LABEL come from TWO DIFFERENT endpoints
41
+
42
+ Before chasing a grid column's appearance, know which endpoint owns the thing you want to change —
43
+ they never influence each other:
44
+
45
+ | What you see | Source endpoint | Payload path | Consumer (`toga2-supply`) |
46
+ |---|---|---|---|
47
+ | the column's **type / formatting** (currency icon, number/decimal rendering) | `/table-views/meta` | `table.fields[].type` | `generateDynamicConfig` → `formatTableData.tsx` |
48
+ | the column's **header text** | `/pages/meta` | `settings.fields[<record route>][<field>]['label-singular']` | `PrimaryTable.convertColumnTitles` |
49
+
50
+ So a currency/dollar icon on a column is a **type** problem (fix the field's `type` — Core
51
+ `RecordFields`, or a client-scoped override, see
52
+ [TableView field/column metadata](../../api2/features/tableview-field-metadata.md#per-client-type-override)),
53
+ and a wrong header is a **settings-row** problem. Adding a label row will never change the icon,
54
+ and retyping the field will never change the header.
55
+
56
+ ### ⚠ A CUSTOM record field's label row is UNREACHABLE from the grid header
57
+
58
+ `Page.php` emits `ClientCustomRecordFieldSettings` in its own block (~L900–935) and keys it by a
59
+ record route resolved through `$lookupRecordIdFromCustomRecordFieldId` →
60
+ `$lookupRecordFromRecordId[...]->route`. In practice, for a column pointed at a
61
+ `CustomRecordFields` row, that resolves to an **empty** record-route key — the row lands at
62
+ `settings.fields[""][<field>][…]`, a bucket `convertColumnTitles` never reads. **A
63
+ `ClientCustomRecordFieldSettings` `label-singular` row for a custom column is therefore dead
64
+ metadata**, and no amount of adding/correcting those rows will move the header. (Measured
65
+ 2026-08-26: two hours lost adding label rows that could never be read.)
66
+
67
+ Where the label must come from instead, for such a column:
68
+
69
+ - **`Core.DefaultRecordFieldSettings`** on the underlying **Core** `RecordField`, or
70
+ - **`ClientRecordFieldSettings`** when the column points at a real `RecordField`,
71
+
72
+ in both cases with **`contextRecordFieldId` and `contextCustomRecordFieldId` NULL** — the
73
+ context-free branch at `Page.php` ~L831 is what writes
74
+ `settings.fields[record][field][setting]`, the exact path the header reads. This is the same
75
+ "keep label rows plain" conclusion as the correction below, reached from the other direction.
76
+
39
77
  ## Why this matters: table-view **column headers** for joined columns need context rows
40
78
 
41
79
  This is the mechanism behind "why is my column header a camelCase slug?" — and the answer is
@@ -143,6 +181,16 @@ data change, is what makes it fatal.
143
181
  customization. The guard belongs in the framework.
144
182
 
145
183
  ## Change history
184
+ - 2026-08-26 - Documented that a grid column's **type** and its **header label** come from two
185
+ different endpoints: type/formatting (the currency icon) from `/table-views/meta`
186
+ `table.fields[].type` via `generateDynamicConfig` → `formatTableData`, header text from
187
+ `/pages/meta` `settings.fields[<record route>][<field>]['label-singular']` via
188
+ `PrimaryTable.convertColumnTitles`. Added the trap that a **custom** record field's settings rows
189
+ are emitted in `Page.php`'s separate `ClientCustomRecordFieldSettings` block (~L900-935) under an
190
+ **empty** record-route key, so a `ClientCustomRecordFieldSettings` label row for a custom column is
191
+ never read - the label must come from `Core.DefaultRecordFieldSettings` on the underlying Core
192
+ RecordField (or `ClientRecordFieldSettings` when the column points at a real RecordField), with
193
+ both context columns NULL. (bala)
146
194
  - 2026-08-18 - **Corrected the "joined columns need a context row" guidance.** `PrimaryTable.tsx`
147
195
  takes the context branch whenever `contextField` is set and **never falls back** to the plain
148
196
  lookup, so a context row whose context does not resolve in the meta leaves the header as its raw
@@ -19,7 +19,7 @@
19
19
  | [TOGa IQ Sprint Dashboard API (Record Scripts)](features/sprint-dashboard-api.md) | The internal **TOGa IQ sprint dashboard** is served in production by **six api2 Record Scripts** on `_Model_Team_Sprint` (`_underscore/Model/Team/Sprint.php`), | _underscore/Model/Team/Sprint.php, api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-07-24a - SprintDashboardRecordScripts.sql, dbchanges2/Client_True/2026-07-24b - SprintDashboardScriptAcl.sql |
20
20
  | [Surface action-state via the surface=<slug> request option (M2M-safe)](features/surface-meta-option.md) | An opt-in V2 engine request option, `surface=<slug>`, that attaches per-record UI action state (`isVisible`/`isEnabled`) to a GET response **under `meta.surface | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Surface.php |
21
21
  | [TableView row-filtering via apiWhereClause (options.where grammar, end to end)](features/tableview-apiwhereclause-row-filtering.md) | `TableViews.apiWhereClause` (TEXT, nullable) is the sanctioned, code-free way to restrict or exclude rows from a 2.0 table view. | api2/Component/Api/V2/V2.php, _underscore/Model/Client/TableView.php, toga2-supply/src/api/toga.ts |
22
- | [TableView field/column metadata (TableViewFields, hidden projected columns)](features/tableview-field-metadata.md) | The columns of a 2.0 table view are defined by DB metadata, not code. | _underscore/Model/Client/TableView.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-07-20 - ItemsUuidForPurchaseOrderItemsTableView.sql, dbchanges2/Core/2026-08-17a - ItemFulfillmentQuantityFieldTypeNumber.sql, dbchanges2/Client/2026-08-17a - ItemFulfillmentColumnsCopyable.sql, dbchanges2/Client_Quad/2026-08-18c - SalesOrderListingSortByDateOrderDesc.sql, dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql |
22
+ | [TableView field/column metadata (TableViewFields, hidden projected columns)](features/tableview-field-metadata.md) | The columns of a 2.0 table view are defined by DB metadata, not code. | _underscore/Model/Client/TableView.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-07-20 - ItemsUuidForPurchaseOrderItemsTableView.sql, dbchanges2/Core/2026-08-17a - ItemFulfillmentQuantityFieldTypeNumber.sql, dbchanges2/Client/2026-08-17a - ItemFulfillmentColumnsCopyable.sql, dbchanges2/Client_Quad/2026-08-18c - SalesOrderListingSortByDateOrderDesc.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql |
23
23
  | [Tickets API (/v2/tickets)](features/tickets-api.md) | The generic ticket endpoint of the 2.0 REST API. | Component/Api/V2/V2.php |
24
24
  | [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, api2/Component/Api/V2/Response/Oauth/Oauth.php, api2/Controller/Index.php, toga2-supply/src/globalTypes.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/api/toga.ts, _underscore/Model/Client/TrackingNumber.php |
25
25
  | [V2 request deadlock-retry — route-scoped in-process replay](features/v2-deadlock-retry.md) | api2's front controller can **detect a MySQL deadlock (1213) / lock-wait timeout (1205) and replay the whole request in-process**, so a transient lock collision | api2/Controller/Index.php, _underscore/Database.php, _underscore/Query.php |
@@ -6,13 +6,14 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-20
9
+ updated: 2026-08-26
10
10
  owners: ["bala", "mhammontree", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Client/ContactEmailAddress.php
14
14
  related:
15
15
  - ../architecture.md
16
+ - ../../toga2-supply/features/currency-amount-lines-editor.md
16
17
  - cxml-shipnotice-gateway.md
17
18
  - ../../../clients/aig/features/entitlement-intake.md
18
19
  - ../../ai-bdr/features/web-funnel-app.md
@@ -293,6 +294,23 @@ anywhere — no `messages[]`, nothing in the client `Logs` or the core/writer `L
293
294
  **double-wrapped** (`entitlementEntitlementCoverageTypes: [{entitlementCoverageType: {…}}]`).
294
295
  Both shapes routinely appear in the *same* payload, so the sibling collections actively mislead.
295
296
 
297
+ ## ⚠ The UPDATE path reports 200 even when a CHILD write FAILED
298
+
299
+ The V2 **update** branch sets the response status to **200 unconditionally after its children
300
+ loop**, so a nested `PUT` on a parent comes back `isSuccess: true` even when a child insert/update
301
+ was rejected (a unique-key violation, an ACL denial). The parent-level status is not a verdict on
302
+ the children.
303
+
304
+ **Consequence for any parent-with-children editor: do not save child rows as a nested `PUT` on the
305
+ parent if you need to know whether they landed.** Issue the child writes **directly against the
306
+ child route** and check each response individually. Found 2026-08-26 while building the
307
+ per-currency price/cost line editors for Quad, where a unique-key rejection on one line was
308
+ completely invisible to the UI — see
309
+ [per-currency amount lines](../../toga2-supply/features/currency-amount-lines-editor.md).
310
+
311
+ Same family as the 201-with-dropped-data case above: **a success status from V2 proves only that the
312
+ parent was written.** Assert child row counts, or write children explicitly.
313
+
296
314
  ## Gotcha
297
315
 
298
316
  - **Nested write with only a non-identifier field silently creates duplicates.** This is a
@@ -304,6 +322,12 @@ anywhere — no `messages[]`, nothing in the client `Logs` or the core/writer `L
304
322
 
305
323
  ## Change history
306
324
 
325
+ - 2026-08-26 — Added: **the UPDATE path sets status 200 unconditionally after its children loop**, so
326
+ a nested `PUT` reports success even when a child write failed (unique-key rejection, ACL denial).
327
+ Any parent-with-children editor that needs to know whether the children landed must write them
328
+ directly against the child route and check each response. Found while building Quad's per-currency
329
+ price/cost line editors. (bala)
330
+
307
331
  - 2026-08-20 — Documented that **`overrideChildPolicy` (not just `overrideIsIdentifier`) is per-API
308
332
  provisioning**, and that a *missing* `Apis_RecordFields` row is a silent 100%-rejection outage:
309
333
  Compass Canada's cXML API had zero rows and the Core defaults for the ASN **item**/**header**
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-26
10
10
  owners: [apeterson, bala]
11
11
  files:
12
12
  - _underscore/Model/Client/TableView.php
@@ -15,10 +15,13 @@ files:
15
15
  - dbchanges2/Core/2026-08-17a - ItemFulfillmentQuantityFieldTypeNumber.sql
16
16
  - dbchanges2/Client/2026-08-17a - ItemFulfillmentColumnsCopyable.sql
17
17
  - dbchanges2/Client_Quad/2026-08-18c - SalesOrderListingSortByDateOrderDesc.sql
18
+ - dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql
18
19
  - dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql
19
20
  - dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql
20
21
  related:
21
22
  - tableview-apiwhereclause-row-filtering.md
23
+ - ../../_underscore/features/page-meta-context-field-settings.md
24
+ - ../../_underscore/features/acl-permission-chain.md
22
25
  - ../../toga25-supply/features/meta-driven-table-data.md
23
26
  - ../../_underscore/features/tableview-joins.md
24
27
  ---
@@ -53,6 +56,40 @@ the column's type/format **everywhere that field appears, for every client**. Th
53
56
  intended canonical behavior — if you only want the change in one view, this mechanism cannot do
54
57
  it. (Reference: `Core.RecordFields` id 369 = `ItemFulfillmentItems.quantity` set to
55
58
  `type=NUMBER`, `precision=0`, which normalizes that column across all fulfillment views/clients.)
59
+
60
+ #### Per-client type override — point the column at a same-named `CustomRecordFields` row {#per-client-type-override}
61
+
62
+ "No per-view override" is about `TableViewFields` having no `type` column of its own. It does
63
+ **not** mean a client is stuck with the Core type: a column does not have to point at a Core
64
+ `RecordField` at all. `TableViewFields.customRecordFieldId` is the alternative to `recordFieldId`,
65
+ so **one client can retype a column without touching Core**:
66
+
67
+ 1. Insert a `Client_<X>.CustomRecordFields` row with the **same `field` name** as the Core
68
+ `RecordField` (e.g. `_unitPrice`) and the wanted `type` (e.g. `STRING`).
69
+ 2. Repoint that client's `TableViewFields` row at `customRecordFieldId` instead of
70
+ `recordFieldId`.
71
+
72
+ Why this is safe rather than a hack:
73
+
74
+ - **Reads** — for a **calculated** (`FIELD_SQL`) field the API resolves the expression off the
75
+ model by NAME, so it does not care which metadata table the column came from.
76
+ - **Writes** — the V2 write path accepts the payload key from **either** lookup:
77
+ `api2/Component/Api/V2/V2.php` ~L4801 ORs `array_key_exists($key, $lookupRecordFieldByField)`
78
+ with `$lookupCustomRecordFieldByField` (and again for the `<key>Id` suffix form).
79
+ - **ACL** — **do not add an `AclCustomFieldPermissions` row** when the field name is already
80
+ granted through the Core `RecordField`'s `AclFieldPermissions` row. It is unnecessary, and it is
81
+ actively wrong: it would keep the column readable after the Core grant is revoked. See
82
+ [ACL permission chain](../../_underscore/features/acl-permission-chain.md).
83
+ - **Header labels do NOT follow.** A custom-record-field column's label rows land in a page-meta
84
+ bucket the grid never reads, so the label must still come from the Core field — see
85
+ [legacy page meta](../../_underscore/features/page-meta-context-field-settings.md).
86
+
87
+ Worked example (Quad, 2026-08-26): `_unitPrice` (Core `RecordFields` **909**, `CURRENCY`) and
88
+ `_unitCost` (Core **906**, `CURRENCY`) were retyped to `STRING` for `Client_Quad` only, so those
89
+ columns render multi-currency text (`PLN 5295.00, USD 1310.00`) instead of a dollar-formatted
90
+ number — Core is shared with every other client and was left untouched.
91
+ `dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql`. This is currently the
92
+ **only** client-scoped way to change a column's rendering type.
56
93
  - **`Client_*.TableViewJoins`** — the view's joins; also drive `options.join`/`ojoin` on the data
57
94
  request (see [tableview-apiwhereclause-row-filtering](tableview-apiwhereclause-row-filtering.md)).
58
95
  How a join row becomes SQL — aliasing, chained multi-hop joins, and the ACL row the joined record
@@ -173,6 +210,15 @@ SalesOrderListingSortByDateOrderDesc.sql`).
173
210
  `isSortable`, `isFilterable`, `isVisible` are the per-view knobs (on `TableViewFields`).
174
211
 
175
212
  ## Change history
213
+ - 2026-08-26 - Added the **per-client type override**: a column can point at
214
+ `TableViewFields.customRecordFieldId` instead of `recordFieldId`, so registering a
215
+ `Client_<X>.CustomRecordFields` row with the **same field name** as the Core RecordField and the
216
+ wanted `type` retypes the column for that client only. Safe because a calculated field resolves off
217
+ the model by name and the V2 write path ORs both field lookups (`V2.php` ~L4801); **no
218
+ `AclCustomFieldPermissions` row should be added** when the name is already granted through the Core
219
+ field (it would survive a revoke of the Core grant), and header labels still have to come from the
220
+ Core field. Shipped for Quad's `_unitPrice` (Core 909) / `_unitCost` (Core 906), CURRENCY → STRING,
221
+ in `dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql`. (bala)
176
222
 
177
223
  - 2026-08-18 — Documented DB-driven **default sort**: `TableViews.sortPrimaryTableViewFieldId`
178
224
  (+ `sortPrimaryDirection`, and the secondary pair) FK into `TableViewFields`, emitted as
@@ -3,8 +3,10 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [TOGa Supply (toga2-supply) Architecture](architecture.md) | `toga2-supply` is the **React + Vite frontend** for TOGa Supply — warehouse fulfillment tooling (shipment selection, fulfill & ship against carrier APIs, NetSui | toga2-supply/src/api/toga.ts, toga2-supply/src/pages/ShipmentItems/view/ShipmentItemsPage.tsx, toga2-supply/src/pages/EditShipment/view/EditShipmentPage.tsx, toga2-supply/src/pages/EditShipment/api/UpdateShipmentApi.ts, toga2-supply/src/pages/Shipments/view/components/ShipmentsCardTableForm/ShipmentsCardTableForm.tsx |
6
+ | [Per-currency amount lines (CurrencyAmountLines / CurrencyAmountList) and child-row diffing](features/currency-amount-lines-editor.md) | The shared UI for "one amount per currency" on a parent record — an item's prices, a vendor item's costs. | toga2-supply/src/components/ui/CurrencyAmountLines.tsx, toga2-supply/src/pages/Items/api/itemsApi.ts, toga2-supply/src/pages/VendorItems/api/vendorItemsApi.ts |
6
7
  | [Fulfill & Ship](features/fulfill-and-ship.md) | Fulfill & Ship lets a warehouse user select sales-order line items, enter serials, pick a carrier/method, and in one action: create the Item Fulfillment records | _underscore/Trait/Netsuite/SalesOrder.php, dbchanges2/Client_Growrk/2026-08-10e - GrowrkUpsServiceMethodCodes.sql, toga2-supply/src/pages/EditShipment/view/EditShipmentPage.tsx, _underscore/Model/Client/Measure.php, _underscore/Model/Client/TrackingNumber.php, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/getEditShipmentFormOptions.ts, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/validateFormOnSubmit.ts, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/checkDimensions.ts, toga2-supply/src/components/ui/Tables/BasicTable/BasicTable.tsx, toga2-supply/src/components/ui/Tables/types.ts, toga2-supply/src/components/ui/GoogleMapsLink.tsx, toga2-supply/src/pages/ShipmentItems/view/forms/ShipmentItemsTable.tsx, toga2-supply/src/pages/ShipmentItems/api/ShipmentItemsApi.ts, toga2-supply/src/pages/ShipmentItems/types.ts, toga2-supply/src/pages/EditShipment/viewModel/FIELDS/RETURNLABELFIELDS.json, toga2-supply/src/pages/EditShipment/view/components/forms/EditShipmentForm.tsx, toga2-supply/src/components/ui/BaseInput/UnitSelect.tsx, toga2-supply/src/pages/EditShipment/view/modals/SerialNumbersModal.tsx, toga2-supply/src/pages/ShipmentItems/view/ShipmentItemsPage.tsx, toga2-supply/src/pages/EditShipment/view/EditShipmentPage.tsx, toga2-supply/src/pages/EditShipment/view/components/forms/EditShipmentForm.tsx, toga2-supply/src/pages/EditShipment/view/components/SelectedShipmentItemsTable.tsx, toga2-supply/src/pages/EditShipment/view/helpers/ShipmentDetailsForm/renderEditShipmentFormInput.tsx, toga2-supply/src/pages/EditShipment/viewModel/FIELDS/DUMMYUPDATESHIPMENTFIELDS.json, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/renderEditShipmentFormInput.tsx, toga2-supply/tailwind.config.cjs, toga2-supply/src/pages/EditShipment/view/modals/ReturnShippingModal.tsx, toga2-supply/src/styles/index.scss, toga2-supply/src/components/ui/BaseInput/BaseInput.tsx, toga2-supply/src/pages/EditShipment/api/UpdateShipmentApi.ts, toga2-supply/src/pages/EditShipment/viewModel/signatureTypes.ts, toga2-supply/src/pages/EditShipment/view/modals/SelectReturnAddressModal.tsx, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/formatShipmentData.ts, toga2-supply/src/pages/EditShipment/types.ts, toga2-supply/src/pages/Shipments/view/ShipmentsPage.tsx, toga2-supply/src/pages/Shipments/view/components/ShipmentsCardTableForm/ShipmentsCardTableForm.tsx, toga2-supply/src/pages/Shipments/api/ShipmentsApi.ts, toga2-supply/src/pages/Shipments/types.ts, toga2-supply/src/pages/FulfilledShipments/view/FulfilledShipmentsPage.tsx, toga2-supply/src/components/ui/CardTable/CardTable.tsx, toga2-supply/src/components/ui/CardTable/types.ts, toga2-supply/src/assets/pen-line.svg, _underscore/Model/Client/ItemFulfillment.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Component/Library/Carriers/Ups/Ups.php |
7
- | [Order-details field config (valueKey resolution) & the hardcoded Customer name](features/order-detail-field-config-and-customer-name.md) | The supply order-details modal is **config-driven**: each host has a JSON field list under `src/pages/Orders/view/OrderView/viewModel/FIELDS/<HOST>/`, and every | toga2-supply/src/utils/formatCustomerName.tsx, toga2-supply/src/pages/Orders/view/OrderView/components/invoices/OrderContentSection.tsx, toga2-supply/src/pages/Orders/view/OrderView/viewModel/FIELDS/, toga2-supply/src/api/toga.ts |
8
+ | [Order-details field config (valueKey resolution) & the hardcoded Customer name](features/order-detail-field-config-and-customer-name.md) | The supply order-details modal is **config-driven**: each host has a JSON field list under `src/pages/Orders/view/OrderView/viewModel/FIELDS/<HOST>/`, and every | toga2-supply/src/utils/formatCustomerName.tsx, toga2-supply/src/pages/Orders/view/OrderView/components/invoices/OrderContentSection.tsx, toga2-supply/src/pages/Orders/view/OrderView/viewModel/FIELDS/, toga2-supply/src/pages/Orders/view/OrderView/viewModel/useOrderDetailsViewModel.ts, toga2-supply/src/api/toga.ts |
9
+ | [Table-data request filters (`additionalData` → `getDataTableData`)](features/table-data-additional-filters.md) | A listing page adds its own row filters to a table-view data request by passing an **`additionalData`** prop down to the shared `useFetchData` hook, which forwa | toga2-supply/src/components/ui/Tables/hooks/useFetchData.tsx, toga2-supply/src/api/toga.ts, toga2-supply/src/pages/VendorItems/VendorItemsPage.tsx |
8
10
  | [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-supply` (React + Vite) builds and deploys on **AWS Amplify**. | toga2-supply/amplify.yml, toga2-supply/.gitattributes, toga2-supply/.github/workflows/sync-stage-environments.yml, toga2-supply/.env.qc-security |
9
11
  | [Onboarding a client to the supply2 frontend (host-scoping)](workflows/client-host-scoping.md) | How a new tenant becomes a "client" in the `toga2-supply` frontend. | toga2-supply/src/stores/useHostNameStore.ts, toga2-supply/src/utils/resolveClientHostName.ts, toga2-supply/src/hooks/useHostName.tsx, toga2-supply/src/hooks/usePageDetails.tsx, toga2-supply/src/hooks/usePageListDetails.tsx, toga2-supply/src/components/layout/SlideMenu/SlideMenu.tsx, toga2-supply/src/utils/handleClientAuthentication.ts, toga2-supply/src/App.tsx, toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts, toga2-supply/src/pages/Inventory/viewModel/FIELDS/INVENTORYPAGEFIELDS.ts, toga2-supply/src/pages/Inventory/listing/InventoryPage.tsx, toga2-supply/src/pages/Inventory/listing/InventoryRouter.tsx, toga2-supply/src/pages/Inventory/listing/InventorySubTablePage.tsx, toga2-supply/src/pages/Orders/OrdersPage.tsx, toga2-supply/src/pages/Orders/viewModel/useOrdersPageViewModel.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/pages/Orders/view/OrderView/viewModel/useOrderDetailsViewModel.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/renderModalContent.tsx, toga2-supply/src/utils/convertConstructorColumnTitles.ts, toga2-supply/src/api/toga.ts, toga2-supply/package.json |
10
12
  | [Onboarding a client to TOGa Supply Fulfill & Ship](workflows/onboarding-a-client-to-fulfill-and-ship.md) | **Onboarding a client to Fulfill & Ship is CONFIGURATION, not development** — unless the client wants tracking pushed into their own system. | _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Trait/Netsuite/ItemFulfillment.php, dbchanges2/_modules/netsuite/ |
@@ -0,0 +1,83 @@
1
+ ---
2
+ title: Per-currency amount lines (CurrencyAmountLines / CurrencyAmountList) and child-row diffing
3
+ framework: "2.0"
4
+ repo: toga2-supply
5
+ project: TOGa Supply
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-26
10
+ owners: [bala]
11
+ files:
12
+ - toga2-supply/src/components/ui/CurrencyAmountLines.tsx
13
+ - toga2-supply/src/pages/Items/api/itemsApi.ts
14
+ - toga2-supply/src/pages/VendorItems/api/vendorItemsApi.ts
15
+ related:
16
+ - ../architecture.md
17
+ - ../workflows/client-host-scoping.md
18
+ - ../../api2/features/nested-relationship-writes.md
19
+ - ../../../../clients/quad/features/multi-currency-item-pricing.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ The shared UI for "one amount per currency" on a parent record — an item's prices, a vendor item's
25
+ costs. Two components: **`CurrencyAmountLines`** (editable, `useFieldArray`) for the create/edit
26
+ modals, and **`CurrencyAmountList`** (read-only) for the detail views. First consumer is Quad's
27
+ multi-currency item pricing, gated by host (`localStorage` `hostName === 'QUAD'`), but the
28
+ components and — more importantly — the **save/diff rules below are generic**: they apply to any
29
+ parent whose children are unique per `(parent, type, currency)`.
30
+
31
+ ## How it works
32
+
33
+ - **Editable lines** — `CurrencyAmountLines` drives a `useFieldArray` list of `{currency, amount}`
34
+ rows inside the parent's form: add a line, pick a currency, type an amount, remove a line.
35
+ - **Read-only lines** — `CurrencyAmountList` deliberately **does not use form context.** The detail
36
+ modals render their view mode **outside** the `FormProvider`, so a read-only component that calls
37
+ `useFormContext()` throws there. Keep the read-only variant context-free; do not "simplify" the
38
+ two components into one that always reads the form.
39
+ - **Host gating** — the lines are wired into the item and vendor-item create/edit modals behind the
40
+ host check, per the escape-hatch rule in
41
+ [client host-scoping](../workflows/client-host-scoping.md).
42
+ - **Saving** — the parent save diffs the on-screen lines against the rows that were loaded, then
43
+ issues explicit child writes (see the rules below). Existing rows carry their `uuid`; new lines
44
+ do not.
45
+
46
+ ## ⚠ Diffing rules — a currency change is a DELETE + CREATE, never an UPDATE
47
+
48
+ The child tables are UNIQUE on `(parentId, priceTypeId, currencyId)`, and that unique key is what
49
+ makes the naive diff fail:
50
+
51
+ 1. **A line whose CURRENCY moved cannot be issued as an UPDATE.** The currency it moves onto may
52
+ still be held by another line, so the update is rejected by the unique key. A straight two-line
53
+ currency swap fails **both** ways and writes nothing at all.
54
+ 2. **Deleting first is not sufficient on its own.** It only helps when the colliding row is itself
55
+ being deleted, which is exactly what a swap is not.
56
+ 3. **Correct shape:** reclassify a currency-moved line as **a delete of the old row plus a create
57
+ of the new one**, then **run all deletes before all creates**. With that ordering an N-way
58
+ currency rotation resolves in a single pass. Only an amount-only change stays an UPDATE.
59
+ 4. **Do not send the children as a nested `PUT` on the parent.** The V2 update branch sets status
60
+ **200 unconditionally after its children loop**, so a failed child reports success — see
61
+ [nested-relationship writes](../../api2/features/nested-relationship-writes.md). Write each child
62
+ against its own route and check each response.
63
+ 5. **Refresh the diff baseline after ANY save attempt, not only a fully successful one.** Otherwise
64
+ a retry re-sends writes that already landed (duplicate creates, deletes of rows that are gone).
65
+
66
+ ## Gotchas
67
+
68
+ - A read-only currency list that reaches for form context works in the edit modal and throws in the
69
+ view modal — the two render on opposite sides of the `FormProvider`.
70
+ - A calculated field that returns the concatenated per-currency text must be declared as plain
71
+ `FIELD_SQL` on the model, or the value is cast to `0` before it ever reaches the grid — see
72
+ [FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
73
+
74
+ ## Change history
75
+
76
+ - 2026-08-26 — First capture. Added the shared `CurrencyAmountLines` (editable, `useFieldArray`) and
77
+ `CurrencyAmountList` (read-only, intentionally context-free because view mode renders outside the
78
+ `FormProvider`) components, wired into the item / vendor-item create and edit modals behind
79
+ `hostName === 'QUAD'`. Recorded the generic diff rules for per-currency children: a currency change
80
+ must become delete + create (the `(parent, type, currency)` unique key rejects the update, and a
81
+ two-line swap otherwise writes nothing), all deletes run before all creates so an N-way rotation
82
+ resolves in one pass, children are written on their own routes rather than as a nested PUT, and the
83
+ diff baseline is refreshed after any save attempt. (bala)
@@ -6,17 +6,19 @@ project: TOGa Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-26
10
10
  owners: [bala]
11
11
  files:
12
12
  - toga2-supply/src/utils/formatCustomerName.tsx
13
13
  - toga2-supply/src/pages/Orders/view/OrderView/components/invoices/OrderContentSection.tsx
14
14
  - toga2-supply/src/pages/Orders/view/OrderView/viewModel/FIELDS/
15
+ - toga2-supply/src/pages/Orders/view/OrderView/viewModel/useOrderDetailsViewModel.ts
15
16
  - toga2-supply/src/api/toga.ts
16
17
  related:
17
18
  - ../../../../clients/elite/features/supply2-scope.md
18
19
  - ../workflows/client-host-scoping.md
19
20
  - ../architecture.md
21
+ - ../../_underscore/features/purchase-order-sales-order-bridges.md
20
22
  ---
21
23
 
22
24
  ## Summary
@@ -79,6 +81,44 @@ Consequences worth internalising:
79
81
  `elite.togasupply` → `ELITE`. Ten other places in the repo already compare against the literal
80
82
  `"ELITE"`, which is what makes a new map entry safe to add.
81
83
 
84
+ ## The second trap: a row renders even when the field is never requested
85
+
86
+ **A field set declares the row and the fetch separately, and nothing checks that they agree.** Each
87
+ `FIELDS/**/*.json` carries both:
88
+
89
+ - a **detail row** with a `valueKey`, which decides what is *displayed*, and
90
+ - **`apiFields.fetchOrderDetails`**, which decides what is *asked for*.
91
+
92
+ api2 returns only the fields you request, so a row whose `valueKey` is missing from `apiFields`
93
+ renders **permanently blank** no matter how correct the backend data is. There is no build-time or
94
+ run-time warning — it is indistinguishable from an API bug or missing data.
95
+
96
+ **The worked example: the purchase-order row.** `DEFAULTFIELDS.json` and all three `QUAD/*.json`
97
+ field sets declare a row with `valueKey: "_purchaseOrders"` but omit `_purchaseOrders` from
98
+ `apiFields.fetchOrderDetails`. `COMPASS`, `COMPASSCANADA` and `ELITE` list it in both places — which
99
+ is the *only* reason the PO shows for those three hosts and nowhere else. Quick audit:
100
+
101
+ ```bash
102
+ # 2 = declared AND requested (works). 1 = declared but never requested (blank row).
103
+ for f in $(find src/pages/Orders/view/OrderView/viewModel/FIELDS -name '*.json'); do
104
+ echo "$(grep -c '"_purchaseOrders"' "$f") $f"
105
+ done
106
+ ```
107
+
108
+ As of 2026-08-26 that still reports `1` for `DEFAULTFIELDS.json` and the three `QUAD` sets — **the
109
+ fix was drafted this session but is not present on any branch or stash.** The backend half (the
110
+ `_purchaseOrders` calculated field reading the wrong bridge table) is a separate, independent cause
111
+ of the same blank row; see
112
+ [PO-to-SO bridge tables](../../_underscore/features/purchase-order-sales-order-bridges.md).
113
+
114
+ ### Which field set a host gets
115
+
116
+ `useOrderDetailsViewModel.ts` (~L120-134) holds a `fieldMapping` keyed on `hostName` with entries
117
+ for **`COMPASS`, `COMPASSCANADA`, `QUAD`** only, plus an early `if (hostName === "ELITE")` return.
118
+ **Every other host falls through to `DEFAULTFIELDS`** — so NYCHH, Prudential, Canon, GroWrk and the
119
+ rest all share one field set, and a gap in `DEFAULTFIELDS` is a gap for most of the platform. As
120
+ with `CUSTOMER_MAP`, `hostName` is the uppercased subdomain from `src/api/toga.ts:36`.
121
+
82
122
  ## Making it genuinely data-driven (deliberately NOT done)
83
123
 
84
124
  The database value is available. On Elite, `Customers.name` is a single row
@@ -107,8 +147,24 @@ requires **both** changes, not either one:
107
147
  validation, so a typo or a stale key is indistinguishable from missing data. There is no build- or
108
148
  run-time check that a `valueKey` corresponds to anything the API returns.
109
149
  - `QUAD: "N/A"` is intentional — Quad's Customer row is meant to read as not-applicable.
150
+ - **Check `apiFields` BEFORE the Network tab, and check both halves of the field set.** A blank
151
+ detail row is far more often a field set that never requested the field than a backend fault. Any
152
+ new row needs **two** edits in the same JSON: the row's `valueKey` *and* the `apiFields`
153
+ entry — and it needs them in **every** field set that should show it (11 of them today).
154
+ - **A `DEFAULTFIELDS` gap hits most clients, not the edge cases.** Only four hosts have their own
155
+ field set; everyone else uses the default.
110
156
 
111
157
  ## Change history
158
+ - 2026-08-26 — Added the **render-vs-request** trap: a field set declares the display row
159
+ (`valueKey`) and the fetch (`apiFields.fetchOrderDetails`) separately with nothing checking they
160
+ agree, so a declared-but-unrequested row renders permanently blank and looks like an API fault.
161
+ Worked example: `DEFAULTFIELDS.json` and the three `QUAD` sets declare
162
+ `valueKey: "_purchaseOrders"` without requesting it, which is why the PO shows only for COMPASS /
163
+ COMPASSCANADA / ELITE. Also recorded that `useOrderDetailsViewModel.ts`'s `fieldMapping` only
164
+ covers COMPASS / COMPASSCANADA / QUAD (+ an ELITE early return), so **every other host falls
165
+ through to `DEFAULTFIELDS`** — a default-set gap is a platform-wide gap. The four-file fix was
166
+ drafted but is **not on any branch**; the backend half is a separate cause, see
167
+ [PO-to-SO bridge tables](../../_underscore/features/purchase-order-sales-order-bridges.md). (bala)
112
168
  - 2026-08-18 — First capture. Documented that the order-details Customer name is **hardcoded in the
113
169
  frontend**, not read from the database: every host's config uses
114
170
  `valueKey: "orderBoxDetails.customer.name"`, `orderBoxDetails` exists nowhere in the codebase or
@@ -0,0 +1,92 @@
1
+ ---
2
+ title: Table-data request filters (`additionalData` → `getDataTableData`)
3
+ framework: "2.0"
4
+ repo: toga2-supply
5
+ project: TOGa Supply
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-26
10
+ owners: [bala]
11
+ files:
12
+ - toga2-supply/src/components/ui/Tables/hooks/useFetchData.tsx
13
+ - toga2-supply/src/api/toga.ts
14
+ - toga2-supply/src/pages/VendorItems/VendorItemsPage.tsx
15
+ related:
16
+ - ../architecture.md
17
+ - ../../api2/features/tableview-apiwhereclause-row-filtering.md
18
+ - ../../api2/features/tableview-field-metadata.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ A listing page adds its own row filters to a table-view data request by passing an
24
+ **`additionalData`** prop down to the shared `useFetchData` hook, which forwards it to
25
+ `getDataTableData` in `src/api/toga.ts`. `getDataTableData` supports **two** shapes — a single
26
+ filter object and an array of filter objects — but **only the single-object shape actually reaches
27
+ the API**. The array shape has never worked, and fixing it globally changes what several tenants
28
+ see, so the array form must be avoided rather than repaired in place.
29
+
30
+ ## How it works
31
+
32
+ 1. A page renders the table with `additionalData={…}`.
33
+ 2. `useFetchData.tsx` (~L145) calls:
34
+
35
+ ```ts
36
+ getDataTableData(tableMeta, nestedPrimaryRecordSlug, nestedTableUuid,
37
+ { ...(additionalData ?? {}), page: pageToFetch }, …)
38
+ ```
39
+
40
+ 3. `getDataTableData` (`src/api/toga.ts` ~L833–900) inspects that argument: an `Array.isArray`
41
+ branch handles a list of filters, and an object branch handles a single filter (keyed by
42
+ `.slug`, plus special-cased literal keys such as `'Items.catalogId'`), turning it into the
43
+ `apiWhereClause` / query params on the table-view data call.
44
+
45
+ ## ⚠ The ARRAY form of `additionalData` is silently dropped
46
+
47
+ `useFetchData` **object-spreads** `additionalData`. Spreading an array produces
48
+ `{0: {...}, 1: {...}}` — a plain object with numeric keys. Downstream:
49
+
50
+ - `Array.isArray(...)` is now **false**, so the array branch never runs;
51
+ - the object branch finds no `.slug` on the outer object, so it contributes nothing.
52
+
53
+ The filter is dropped with **no error and no warning** — the grid renders unfiltered and looks
54
+ correct. Any page that declares its filters as an array has therefore never been filtered.
55
+
56
+ ## Do NOT "just fix the spread" — it changes what other tenants see
57
+
58
+ `VendorItemsPage` is the **only** array-form caller in the repo, and its `Items.catalogId = 1`
59
+ filter has consequently never applied for **any** tenant. Repairing the spread in the shared hook
60
+ would start applying it everywhere at once. Measured in prod, that would hide:
61
+
62
+ | Tenant | Vendor items that would disappear |
63
+ |---|---|
64
+ | Compass | 305 |
65
+ | Prudential | 36 |
66
+ | Elite | 1 |
67
+
68
+ **Decision (2026-08-26):** leave `useFetchData` untouched and have the page pass the **single-object**
69
+ form, which the existing object branch already handles (including the literal `'Items.catalogId'`
70
+ key), scoped to the client that wants the filter — Quad. A latent bug in a shared hook is not a free
71
+ fix when the "correct" behavior is a data-visibility change for tenants nobody asked about.
72
+
73
+ ## Rules
74
+
75
+ - **Pass `additionalData` as a single OBJECT.** Treat the array branch in `getDataTableData` as dead
76
+ code; do not add new array-form callers.
77
+ - **Prove a filter is applied before trusting it** — compare the row count with and without it, or
78
+ read the outgoing request in the Network tab. Silence is not evidence.
79
+ - **Before repairing any shared-hook filter bug, count the rows it would start hiding, per tenant.**
80
+ Turning a dormant filter on is a behavior change for every client that shares the page.
81
+ - Row filtering that should apply for *everyone* belongs in the backend
82
+ (`TableViews.apiWhereClause`), not in a page-level filter prop — see
83
+ [tableview apiWhereClause row filtering](../../api2/features/tableview-apiwhereclause-row-filtering.md).
84
+
85
+ ## Change history
86
+
87
+ - 2026-08-26 — First capture. Documented that `useFetchData` object-spreads `additionalData`, so the
88
+ **array** form collapses to `{0:…,1:…}` and is silently dropped by `getDataTableData`
89
+ (`Array.isArray` false, no `.slug` on the outer object). `VendorItemsPage` was the only array-form
90
+ caller, so its `Items.catalogId = 1` filter had never applied for any tenant; fixing the spread
91
+ globally would hide 305 vendor items from Compass, 36 from Prudential and 1 from Elite, so the
92
+ hook was left alone and the page switched to the single-object form for Quad only. (bala)
@@ -18,11 +18,11 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 66 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 67 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 56 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 9 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
- - **toga2-supply** (TOGa Supply) — 7 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
25
+ - **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
26
26
  - **saml** (SAML SSO Gateway) — 4 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
27
27
  - **toga2-view** (TOGa View Frontend) — 12 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
28
28
  - **toga2-hub** (TOGa Hub) — 2 doc(s) → [2.0/apps/toga2-hub/INDEX.md](2.0/apps/toga2-hub/INDEX.md)
@@ -15,7 +15,7 @@ project: _Underscore
15
15
  client: nychh
16
16
  type: profile
17
17
  status: active
18
- updated: 2026-08-24
18
+ updated: 2026-08-26
19
19
  owners: ["jcardinal", "apeterson", "bala"]
20
20
  files:
21
21
  - dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql
@@ -60,6 +60,9 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
60
60
  [NYCHH PO Number direction](./features/po-number-upstream-direction.md).
61
61
 
62
62
  ## Notes
63
+ - **PO Number is upstream-sourced** (`PurchaseOrders_SalesOrders`), which is why the downstream
64
+ SO→PO route and the shared `_purchaseOrders` field came back empty. Details, and the
65
+ now-closed-on-`_beta` fix: [NYCHH PO Number](./features/po-number-upstream-direction.md).
63
66
  - Tracking data: record 318 (item-level) is currently empty for this client; their tracking
64
67
  populates the IF/shipment level (record 317). The rebuilt views use 318 (per the Compass
65
68
  pattern) and will show tracking once item-level tracking is backfilled.
@@ -3,5 +3,6 @@
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
5
  | [Quad Enter PO Details — single-vendor-by-country selection and the EV-12 `vendorItem` failure](features/enter-po-details-vendor-selection-ev12.md) | 2.0 | Adding a PO number for Quad through **Enter PO Details** can `400` with **`EV-12` "unable to find a unique match"** on field **`vendorItem`**. | _underscore/Model/Quad/PurchaseOrder.php |
6
+ | [Quad multi-currency item prices & vendor item costs](features/multi-currency-item-pricing.md) | 2.0 | Quad prices the **same item in several currencies** and costs the **same vendor item in the vendor's own currency**, so both listings must show a *set* of amoun | _underscore/Model/Quad/Item.php, _underscore/Model/Quad/VendorItem.php, toga2-supply/src/components/ui/CurrencyAmountLines.tsx, toga2-supply/src/pages/Items/api/itemsApi.ts, toga2-supply/src/pages/VendorItems/api/vendorItemsApi.ts, toga2-supply/src/pages/VendorItems/VendorItemsPage.tsx, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, worker/crons/toga2/quad/Imports/import_items.php |
6
7
  | [Quad: PO + ASN email importer (import_po_and_asn.php)](features/po-asn-email-import.md) | 1.0 | Quad Graphics' supplier emails Purchase Order and Advance Shipping Notice CSVs into a monitored Microsoft 365 mailbox. | worker/crons/toga2/quad/import_po_and_asn.php |
7
8
  | [Quad Graphics](profile.md) | 2.0 | Quad Graphics is a TOGA 2.0 client on the `_underscore` platform, prod schema `Client_Quad`. | |
@@ -0,0 +1,139 @@
1
+ ---
2
+ title: Quad multi-currency item prices & vendor item costs
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: quad
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-26
10
+ owners: [bala]
11
+ files:
12
+ - _underscore/Model/Quad/Item.php
13
+ - _underscore/Model/Quad/VendorItem.php
14
+ - toga2-supply/src/components/ui/CurrencyAmountLines.tsx
15
+ - toga2-supply/src/pages/Items/api/itemsApi.ts
16
+ - toga2-supply/src/pages/VendorItems/api/vendorItemsApi.ts
17
+ - toga2-supply/src/pages/VendorItems/VendorItemsPage.tsx
18
+ - dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql
19
+ - worker/crons/toga2/quad/Imports/import_items.php
20
+ related:
21
+ - ../profile.md
22
+ - ../../../2.0/apps/_underscore/features/calculated-sql-fields.md
23
+ - ../../../2.0/apps/api2/features/tableview-field-metadata.md
24
+ - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
25
+ - ../../../2.0/apps/toga2-supply/features/currency-amount-lines-editor.md
26
+ - ../../../2.0/apps/toga2-supply/features/table-data-additional-filters.md
27
+ ---
28
+
29
+ ## Summary
30
+
31
+ Quad prices the **same item in several currencies** and costs the **same vendor item in the
32
+ vendor's own currency**, so both listings must show a *set* of amounts rather than one number. Built
33
+ 2026-08-26: per-currency rows in `Client_Quad.ItemPrices` / `VendorItemCosts`, two calculated model
34
+ fields that render them as text, a client-only column retype so the grid stops formatting them as
35
+ dollars, and shared per-currency line editors in the item / vendor-item modals.
36
+
37
+ ## Data model
38
+
39
+ - **`Client_Quad.ItemPrices`** — UNIQUE on `(itemId, priceTypeId, currencyId)`: one row per
40
+ currency an item is priced in.
41
+ - **`Client_Quad.VendorItemCosts`** — UNIQUE on `(vendorItemId, priceTypeId, currencyId)`: one row
42
+ per currency a vendor item is costed in.
43
+
44
+ ### Quad's vendors are CURRENCY-LOCKED, one per country
45
+
46
+ An item priced in N currencies has **exactly N vendor items** — one per regional vendor, each
47
+ holding the cost in that vendor's own currency. Measured 2026-08-26: **76** items at 1 currency /
48
+ 1 vendor item, **37** at 2/2, **7** at 3/3.
49
+
50
+ This is why the vendor-items grid **looks** broken and is not:
51
+
52
+ - **Two `Vendors` rows share the name `TECNOLOGIA INFORMATICA TECINF SAS`** but are distinct
53
+ companies — number **COL-1** (Colombia, COP, 45 vendor items) and **PER-1** (Peru, PEN, 22). A
54
+ third row with no number and no vendor items is an unused stub.
55
+ - Duplicate-looking rows are therefore **correct**. The instinct to merge them is wrong; the grid
56
+ needed a **Vendor #** column so the rows can be told apart.
57
+ - **`catalogId = 1` maps exactly onto "has a cost"** for Quad: 179 vendor items in catalog 1 are
58
+ costed; the 402 in catalog 2 and 11 with `NULL` have none. That is what the vendor-items page
59
+ filters on (single-object filter form — see
60
+ [table-data request filters](../../../2.0/apps/toga2-supply/features/table-data-additional-filters.md)).
61
+
62
+ ## Backend — `_unitPrice` and `_unitCost`
63
+
64
+ - **`_Model_Quad_Item::_unitPrice`** returns the persona-scoped **single amount** when a
65
+ `personaId` argument is supplied, and a **`GROUP_CONCAT` of `'CODE amount'` pairs across every
66
+ currency** when it is not. Previously it returned literal SQL `NULL` with no persona, which is
67
+ why the listing column was always empty.
68
+ - **`_Model_Quad_VendorItem::_unitCost`** (new) does the same for costs.
69
+ - **Both must be declared as plain `self::FIELD_SQL`.** With
70
+ `FIELDOPT_SQL_TYPE => FIELD_DECIMAL` the framework casts the value with `(float)`, so
71
+ `'PLN 5295.00'` becomes `0` and the API returns `_unitPrice: 0` — see
72
+ [FIELD_SQL calculated fields](../../../2.0/apps/_underscore/features/calculated-sql-fields.md).
73
+
74
+ ## Grid presentation — retyped for Quad only
75
+
76
+ The Core fields are `CURRENCY`, which renders a dollar icon and number formatting that is wrong for
77
+ multi-currency text. Quad registers `CustomRecordFields` rows **with the same field names** and
78
+ `type = STRING`, and points its `TableViewFields` rows at `customRecordFieldId`:
79
+
80
+ - `_unitPrice` — Core `RecordFields` **909** (`CURRENCY`) → Quad `STRING`
81
+ - `_unitCost` — Core **906** (`CURRENCY`) → Quad `STRING`
82
+
83
+ Core is shared with every other client and was left untouched. **No `AclCustomFieldPermissions` row
84
+ was added** — the names are already granted through the Core fields, and an extra row would keep the
85
+ columns readable after a revoke. Recipe and rationale:
86
+ [TableView field/column metadata](../../../2.0/apps/api2/features/tableview-field-metadata.md#per-client-type-override).
87
+
88
+ **Header labels do not come from the custom rows.** A `ClientCustomRecordFieldSettings` label row
89
+ for a custom column is never read by the grid; labels must come from
90
+ `Core.DefaultRecordFieldSettings` on the Core field (or `ClientRecordFieldSettings` on a real
91
+ `RecordField`), with both context columns `NULL`.
92
+
93
+ ## Front end
94
+
95
+ One shared editable component (`CurrencyAmountLines`, `useFieldArray`) plus a read-only
96
+ `CurrencyAmountList`, wired into the item and vendor-item **create and edit** modals behind
97
+ `localStorage` `hostName === 'QUAD'`. The save/diff rules (a currency change is a delete + create;
98
+ all deletes before all creates; never a nested `PUT`) are generic and live in
99
+ [per-currency amount lines](../../../2.0/apps/toga2-supply/features/currency-amount-lines-editor.md).
100
+
101
+ ## Vendor-items Add / Edit buttons were hidden (fixed)
102
+
103
+ Both controls read `acl['vendor-items']['navigation-edit-on-vendor-items']` — `Core.AclActions`
104
+ id **7** on record **19**. `Client_Quad` had an `AclActionPermissions` row for id **5**
105
+ (`navigation-vendor-items`) only, so the page was reachable with no buttons. This was a **UI gate
106
+ only**: record 19 already granted role 1 (held by every Quad user) full CRUD and the live page meta
107
+ already reported `_CREATE`/`_UPDATE`/`_DELETE` true. Granted id 7 to Quad **role 7 (Global Admin)**,
108
+ following Compass Canada (which grants 5 and 7 both to role 10; Compass grants 5 → roles 9,11 and
109
+ 7 → role 16). In prod all seven Quad Global Admins also hold role 6, so they already see the page.
110
+ Mechanism: [ACL permission chain — action flags](../../../2.0/apps/_underscore/features/acl-permission-chain.md).
111
+
112
+ ## ⚠ Open blocker — Items cannot be created from the UI (as of 2026-08-26)
113
+
114
+ In **Quad prod**, `Core` record **21** (`Items`) has **`allowCreate = 0` for every Supply role**:
115
+ role 1 (Base, appId 1) and roles 7/8/9 (appId 5) are all 0, and only role **3** (API) may create.
116
+ So `POST /items` **403s for a UI user** and the New Item form cannot work until someone grants it.
117
+
118
+ Not affected: records **19** (vendor items), **117** (item prices) and **118** (vendor item costs)
119
+ all grant role 1 full CRUD, so vendor-item creates and every price/cost edit are permissioned
120
+ correctly.
121
+
122
+ **Do not re-debug this as a code bug, and do not build a write-translation hook.** The Quad item
123
+ import cron (`worker/crons/toga2/quad/Imports/import_items.php`) already writes one `/item-prices`
124
+ and one `/vendor-item-costs` row **per currency**, so no `_unitPrice` write hook is needed — the
125
+ import was never broken, only the UI was.
126
+
127
+ ## Change history
128
+
129
+ - 2026-08-26 — First capture. Built Quad per-currency item prices and vendor item costs end to end:
130
+ `_Model_Quad_Item::_unitPrice` now returns a persona-scoped amount with a `personaId` and a
131
+ `GROUP_CONCAT` of `'CODE amount'` pairs without one (it previously returned SQL `NULL`, so the
132
+ column was always empty), new `_Model_Quad_VendorItem::_unitCost` does the same for costs, both
133
+ declared plain `FIELD_SQL` so the value is not `(float)`-zeroed. Retyped `_unitPrice` (Core 909)
134
+ and `_unitCost` (Core 906) from `CURRENCY` to `STRING` for `Client_Quad` only via same-named
135
+ `CustomRecordFields` rows + `TableViewFields.customRecordFieldId`. Granted `AclActions` id 7 to role
136
+ 7 so the vendor-items Add/Edit buttons appear. Recorded the currency-locked vendor model (76 items
137
+ 1/1, 37 2/2, 7 3/3; TECINF COL-1 vs PER-1 are different companies; `catalogId = 1` ≡ has a cost) and
138
+ the open blocker that record 21 `allowCreate = 0` for all Supply roles makes UI item creation 403.
139
+ (bala)
@@ -15,13 +15,14 @@ project: _Underscore
15
15
  client: quad
16
16
  type: profile
17
17
  status: active
18
- updated: 2026-08-25
18
+ updated: 2026-08-26
19
19
  owners: ["jcardinal", "bala", "apeterson", "ajean"]
20
20
  files: []
21
21
  related:
22
22
  - ./features/po-asn-email-import.md
23
23
  - ./features/enter-po-details-vendor-selection-ev12.md
24
24
  - ../../2.0/apps/_underscore/features/sales-order-po-number-sourcing.md
25
+ - ./features/multi-currency-item-pricing.md
25
26
  - ../../2.0/apps/_underscore/features/tracking-number-bridges.md
26
27
  - ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
27
28
  - ../../2.0/apps/_underscore/features/surface-resolver.md
@@ -103,4 +104,18 @@ Client-specific DB change-sets live in `dbchanges2/Client_Quad/`.
103
104
  `EmailTemplates` rows containing a hardcoded `togasupply` host, so unlike Compass there is no
104
105
  template-body sweep to do. **The new route is unverified** — see the gotcha in
105
106
  [Compass approval-decision flow](../compass-usa/features/approval-decision-flow.md).
107
+ - **Multi-currency item pricing & vendor costs (2026-08-26).** Quad prices an item once per
108
+ currency and costs each vendor item in that vendor's own currency; vendors are **currency-locked,
109
+ one per country**, so an item priced in N currencies has exactly N vendor items. The listings show
110
+ concatenated `'CODE amount'` text from `_unitPrice` / `_unitCost`, with those columns retyped
111
+ `CURRENCY → STRING` for `Client_Quad` only. See
112
+ [Quad multi-currency item pricing](./features/multi-currency-item-pricing.md).
113
+ - **BLOCKER (2026-08-26): UI item creation 403s in prod.** `Core` record 21 (`Items`) has
114
+ `allowCreate = 0` for every Supply role (role 1 appId 1, roles 7/8/9 appId 5); only role 3 (API)
115
+ may create, so `POST /items` fails for a UI user. Vendor items (19), item prices (117) and vendor
116
+ item costs (118) are permissioned correctly. Not a code bug — needs an ACL grant.
117
+ - **Vendor-items Add/Edit buttons needed an action-flag grant (2026-08-26).** Both controls read
118
+ `acl['vendor-items']['navigation-edit-on-vendor-items']` (`Core.AclActions` id 7 on record 19);
119
+ Quad had id 5 only. Granted id 7 to role 7 (Global Admin). UI gate only — record 19 already
120
+ granted role 1 full CRUD.
106
121
  - This profile is a starting point; expand as more Quad-specific behavior is captured.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.651",
3
+ "version": "1.0.652",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",