toga-ai 1.0.650 → 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.
- package/knowledge/2.0/apps/_underscore/INDEX.md +3 -3
- package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +55 -1
- package/knowledge/2.0/apps/_underscore/features/calculated-sql-fields.md +65 -2
- package/knowledge/2.0/apps/_underscore/features/netsuite-salesorder-address-phone-sync.md +19 -2
- package/knowledge/2.0/apps/_underscore/features/page-meta-context-field-settings.md +49 -1
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/nested-relationship-writes.md +25 -1
- package/knowledge/2.0/apps/api2/features/tableview-field-metadata.md +47 -1
- package/knowledge/2.0/apps/toga2-supply/INDEX.md +3 -1
- package/knowledge/2.0/apps/toga2-supply/features/currency-amount-lines-editor.md +83 -0
- package/knowledge/2.0/apps/toga2-supply/features/order-detail-field-config-and-customer-name.md +57 -1
- package/knowledge/2.0/apps/toga2-supply/features/table-data-additional-filters.md +92 -0
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -1
- package/knowledge/2.0/apps/worker2/features/elastic-beanstalk-health-monitor.md +198 -154
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/nychh/profile.md +4 -1
- package/knowledge/clients/quad/INDEX.md +1 -0
- package/knowledge/clients/quad/features/multi-currency-item-pricing.md +139 -0
- package/knowledge/clients/quad/profile.md +16 -1
- package/package.json +1 -1
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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)
|