toga-ai 1.0.880 → 1.0.882

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,9 +6,10 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala", "mhammontree", "tcox", "apeterson", "rgirish"]
9
+ updated: 2026-09-23
10
+ owners: ["bala", "mhammontree", "tcox", "apeterson", "rgirish", "jcardinal"]
11
11
  files:
12
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateTransferOrder.ts
12
13
  - api2/Component/Api/V2/V2.php
13
14
  - _underscore/Model/Client/ContactEmailAddress.php
14
15
  - _underscore/Model/Client/TransferOrderStage.php
@@ -156,6 +157,13 @@ WHERE recordId = 351 AND field = 'c_netsuiteInternalTransferOrderStatus';
156
157
 
157
158
  `recordId 351` is `transfer-order-stages`; the equivalent row exists per tenant with a **different `id`** but often the **same `uuid`** (both Elite and NYCHH carry `b273f4d1-dac0-4093-ad1d-7c3a526ab894`), the tell that the row came from one shared seed and only one tenant's copy has the wrong flag.
158
159
 
160
+ ### Nested inherent-child create — parent FK is auto-set, any depth
161
+
162
+ - V2 auto-sets the child's FK to the parent: first field whose `FIELDOPT_FOREIGNKEY_MODEL` equals the parent class (exact normalized `_Model_Client_` string), then break (`V2.php` ~5211-5221). Payload supplies the OTHER FK only (e.g. bridge row `{ purchaseOrder: { uuid } }` under a transfer order).
163
+ - `processRoutePairs` recurses with `parentModel` (~5284-5300); `lookupInherentChildByFieldName` is rebuilt per record (~3407), so grandchildren work (`transferOrderItems[].purchaseOrderItemsTransferOrderItems[]`).
164
+ - Each level's key must be an `InherentRecordChildren` child of THAT level's record, else it lands in `unknownFields` (~5047).
165
+ - Adding an inherent-child row also inlines that child in the parent's default GET for every client — check deep readers (1.0 NetSuite sync reads depth 5) first.
166
+
159
167
  ## Gotchas
160
168
 
161
169
  - **Nested write with only a non-identifier field silently creates duplicates.** A duplicate-generating footgun: each write inserts a new empty child and the parent links to a fresh shell (or is left with a NULL FK). It compounds on any cron/integration that writes the same entity repeatedly. Root-caused in production on the Prudential device-sync cron, which sent `contact: {c_employeeXid}` and generated ~893k empty Contact rows (~5,600/day). See [Prudential device import + contact linking](../../../clients/prudential/features/device-information-import-and-contact-linking.md).
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-23
10
10
  owners: [apeterson, bala]
11
11
  files:
12
12
  - _underscore/Model/Client/TableView.php
@@ -18,13 +18,15 @@ files:
18
18
  - dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql
19
19
  - dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql
20
20
  - dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql
21
- - dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql
21
+ - dbchanges2/Core/2026-09-23a - BundleRecordFieldTypes.sql
22
+ - dbchanges2/Client_Compass/2026-09-23a - BundlesTableViewFieldFlags.sql
22
23
  related:
23
24
  - tableview-apiwhereclause-row-filtering.md
24
25
  - ../../_underscore/features/page-meta-context-field-settings.md
25
26
  - ../../_underscore/features/acl-permission-chain.md
26
27
  - ../../toga25-supply/features/meta-driven-table-data.md
27
28
  - ../../_underscore/features/tableview-joins.md
29
+ - v2-rest-query-contract.md
28
30
  ---
29
31
 
30
32
  How a 2.0 table view's columns/order/sort are defined by DB metadata (`TableViews`/`TableViewFields`/`TableViewJoins`), the per-client type override, hidden projected columns, and the safe reorder/rebuild patterns; open before writing any TableView migration.
@@ -45,6 +47,12 @@ The columns of a 2.0 table view are defined by DB metadata, not code. Per-client
45
47
 
46
48
  **A column's `type` / `precision` are NOT per-view — they come from `Core.RecordFields`.** A field's rendered **type and precision are canonical**, read from the shared `Core.RecordFields.type` / `Core.RecordFields.precision` — there is **no per-view type/precision override** on `TableViewFields`. Changing `Core.RecordFields.type`/`precision` therefore changes the column's type/format **everywhere that field appears, for every client**. Intended canonical behavior — if you only want the change in one view, this mechanism cannot do it. (Reference: `Core.RecordFields` id 369 = `ItemFulfillmentItems.quantity` set to `type=NUMBER`, `precision=0`, which normalizes that column across all fulfillment views/clients.)
47
49
 
50
+ **`Core.RecordFields.type` controls DISPLAY and FILTER only — it does NOT change sorting.** `_Model_Client_TableView::meta()` emits `'type' => $recordField->type` straight off `Core.RecordFields` (`_underscore/Model/Client/TableView.php` ~L355). The type decides which **cell component** renders and which **filter widget** appears — nothing more. Sorting is done by the database on the real column, so a **text** column typed `NUMBER` still sorts alphabetically (`10` before `9`). Verified across sales-orders, items, vendor-items and bundles (2026-09-23). To get numeric ordering you must change the column or the `ORDER BY`, not the metadata — see [V2 REST query contract](v2-rest-query-contract.md).
51
+
52
+ **🚨 Typing a text column `NUMBER` BLANKS every non-numeric row.** `NumberCell` runs `Number(value)`; `Number("kit 207")` is `NaN` and renders empty. `Client_Compass` has 3 bundles whose `number` is literally `kit 205` / `kit 206` / `kit 207`, so setting `Bundles.number` to `NUMBER` emptied those cells. **Only type a column `NUMBER` after confirming every tenant's values actually convert** (`WHERE col REGEXP '^[0-9]+$' = 0`). `BOOLEAN` has the opposite story and is usually right: a JS boolean renders as nothing in a plain text cell, so a genuinely blank flag column (`Bundles.isActive`) is fixed by typing it `BOOLEAN`. Reference: `dbchanges2/Core/2026-09-23a - BundleRecordFieldTypes.sql`.
53
+
54
+ **Flag columns are NULLable — a migration guarded with `isFilterable = 0` updates NOTHING.** `isSortable`/`isFilterable`/`isCopyable`/`isGroupable` are left **NULL** by a seed that names only `uuid`/`tableViewId`/`recordFieldId`/`isVisible`/`index`/`slug` (which is what the 2026-04-07 bundles view did), and `NULL = 0` is unknown in SQL. Guard with `COALESCE(col, 0) = 0` — full write-up in [re-runnable additive migrations](../../dbchanges2/features/rerunnable-additive-inserts.md).
55
+
48
56
  **Per-client type override — point the column at a same-named `CustomRecordFields` row.** "No per-view override" is about `TableViewFields` having no `type` column of its own. It does **not** mean a client is stuck with the Core type: a column does not have to point at a Core `RecordField` at all. `TableViewFields.customRecordFieldId` is the alternative to `recordFieldId`, so **one client can retype a column without touching Core**:
49
57
  1. Insert a `Client_<X>.CustomRecordFields` row with the **same `field` name** as the Core `RecordField` (e.g. `_unitPrice`) and the wanted `type` (e.g. `STRING`).
50
58
  2. Repoint that client's `TableViewFields` row at `customRecordFieldId` instead of `recordFieldId`.
@@ -120,6 +128,9 @@ So the visible failure is a broken SQL query while the actual defect is one miss
120
128
  - **Don't assume `Core.RecordFields`/`Core.Records` ids are stable across environments** — look them up by `field` name + record.
121
129
  - **Make column migrations idempotent** (`NOT EXISTS` guard) — they run per client DB.
122
130
  - **`isVisible=0` still costs a fetch** — the field is selected into the projection; only the render is suppressed.
131
+ - **`type` drives rendering + filtering, NOT sort order** — a text column typed `NUMBER` still sorts alphabetically.
132
+ - **`NUMBER` on a column with non-numeric values renders those cells BLANK** (`Number("kit 207")` → NaN). Check every tenant's data first.
133
+ - **Flag columns are NULLable** — guard flag migrations with `COALESCE(col, 0) = 0`, never `col = 0`, or the UPDATE matches 0 rows and looks like caching.
123
134
  - **`type`/`precision` are canonical (Core-wide), not per-view.** Editing `Core.RecordFields` changes every view/client that shows the field; there is no per-view type override. `isCopyable`, `isSortable`, `isFilterable`, `isGroupable`, `isVisible` are the per-view knobs (on `TableViewFields`).
124
135
  - **`TableView::meta()` contains a hand-written `SELECT` that names `TableViewFields` columns explicitly.** So a new column on that table is required by **two** mechanisms — the model property *and* that query — and a grep for the property alone under-reports what a missing column breaks.
125
136
  - **`isGroupable` has no migration** — omit it from new TableView migrations (fails on prod/beta) until the fan-out migration lands. Composite unique keys read as unconstrained in `information_schema.COLUMNS`; use `information_schema.STATISTICS` / `SHOW INDEX`.
@@ -131,3 +142,6 @@ So the visible failure is a broken SQL query while the actual defect is one miss
131
142
  - [ACL permission chain](../../_underscore/features/acl-permission-chain.md)
132
143
  - [Meta-driven table data](../../toga25-supply/features/meta-driven-table-data.md)
133
144
  - [TableView joins](../../_underscore/features/tableview-joins.md)
145
+
146
+ ## Change history
147
+ - 2026-09-23 — Recorded that `Core.RecordFields.type` drives display/filter only and never sorting; that `NUMBER` on a text column blanks non-numeric rows (Compass `kit 205-207` bundles) while `BOOLEAN` fixes genuinely blank flag columns; and that the flag columns are NULLable so a `= 0` migration guard matches nothing. (apeterson)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-18
9
+ updated: 2026-09-23
10
10
  owners: [tcox, bala, apeterson, jcardinal, rgirish]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -18,6 +18,7 @@ related:
18
18
  - nested-fk-acl-embedding.md
19
19
  - ../../_underscore/features/calculated-sql-fields.md
20
20
  - ../../toga25-supply/features/transfer-orders-page.md
21
+ - tableview-field-metadata.md
21
22
  ---
22
23
 
23
24
  ## Summary
@@ -63,6 +64,33 @@ controller.
63
64
  the join is genuinely 1:1, or the duplicates agree. Worked example:
64
65
  [category tile sort order](../../toga2-commerce/features/category-tile-sort-order.md).
65
66
 
67
+ ### How `sort` becomes `ORDER BY` — and why "descending sort is broken" is usually just NULLs
68
+
69
+ `ORDER BY` is a **plain string concatenation** of the resolved column and `ASC`/`DESC` — no cast, no
70
+ `NULLS FIRST/LAST` handling, no per-field hook (`V2.php` plain-column branch ~L3782-3784,
71
+ calculated-field branch ~L3771-3773).
72
+
73
+ Two things follow.
74
+
75
+ **1. Empty/NULL columns fill page 1 of an ascending sort.** MySQL puts NULLs **first** on `ASC` and
76
+ **last** on `DESC`. A column with many empties — e.g. the Compass sales-orders MITS PO number (slug
77
+ `purchaseOrderNumber`, backed by the calculated `_purchaseOrders`) — therefore shows a page of
78
+ dashes when sorted ascending. That is correct SQL, **not a bug**; triage it as data coverage. A
79
+ "blanks always last" behaviour would be `ORDER BY (col IS NULL), col`, and should be a **per-field
80
+ opt-in**, never blanket.
81
+
82
+ **2. The same expression is the keyset (seek) pagination cursor.** `$seekSortColumn` is reused in the
83
+ seek `WHERE` (~L4042) and the uuid tiebreaker (~L4123). So **any change to the `ORDER BY` expression
84
+ must be mirrored in the seek `WHERE` and in `getSeekFieldType()`** — otherwise page 1 looks right and
85
+ page 2 onward silently returns the wrong rows, with no error.
86
+
87
+ **Text columns sort as text — that is the column, not the engine.** Every genuinely numeric column
88
+ sorts correctly (`_qtyItems`, `_totalPurchase`, `_unitPrice`, `_unitCost`, `sortOrder`). Metadata
89
+ cannot fix it: `Core.RecordFields.type` affects rendering/filtering only
90
+ ([TableView field metadata](tableview-field-metadata.md)). **Decision (2026-09-23, TRUE-82137): do
91
+ not add a shared-engine cast** for a one-column problem — it risks the keyset pagination contract
92
+ above and loses the index. Fix the column type in the schema, or cast per-field, deliberately.
93
+
66
94
  ### ⚠ `depth` is not free — the default 3 re-reads child rows and races concurrent deletes
67
95
 
68
96
  **`depth` defaults to 3 on every request, writes included.** Serialization (`getFullModelData()` in
@@ -557,6 +585,11 @@ If you need the behaviour, loop uuids client-side. Point 3 is the durable, non-o
557
585
  [V2 request logging](request-logging.md).
558
586
 
559
587
  ## Change history
588
+ - 2026-09-23 — Documented how `sort` becomes `ORDER BY` (plain concatenation, no cast or NULL
589
+ handling), that the same expression doubles as the keyset-pagination cursor so any change must be
590
+ mirrored in the seek `WHERE`/`getSeekFieldType()`, and that "descending sort is broken" reports are
591
+ usually normal MySQL NULL ordering. Decided against an engine-level numeric cast for Bundles
592
+ (TRUE-82137). (apeterson)
560
593
  - 2026-09-18 — Recorded that swapping an OR-chain of `contains` for a single `in` on a
561
594
  calculated field only shortens the SQL text and does not speed it up: the Compass approvals list
562
595
  (`_status:in:...`) still takes 14.66 s with the `in` clause live (TRUE-81379). (apeterson)
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-22
9
+ updated: 2026-09-23
10
10
  owners: [bala, kyalamarthi, apeterson, ajean, jcardinal, rgirish]
11
11
  files:
12
12
  - dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
@@ -16,6 +16,7 @@ files:
16
16
  - dbchanges2/_modules/netsuite/2026-09-11c - ItemClassRecordPermission.sql
17
17
  - dbchanges2/Client_Compass/2026-09-18a - BackfillUpstreamItemFulfillmentTracking.sql
18
18
  - dbchanges2/Client_Compass/2026-09-21a - BackfillUpstreamItemFulfillmentUnits.sql
19
+ - dbchanges2/Client_Compass/2026-09-23a - BundlesTableViewFieldFlags.sql
19
20
  related:
20
21
  - ../architecture.md
21
22
  - ../../worker2/features/netsuite-salesorder-open-orders-sync.md
@@ -413,8 +414,35 @@ Both are local-only evidence: pair them with the
413
414
  [local-vs-prod config parity check](../workflows/local-vs-prod-mysql-config-parity.md) before
414
415
  concluding a migration is production-safe.
415
416
 
417
+ ### ⚠ A `col = 0` guard on a NULLable flag matches NOTHING — use `COALESCE(col, 0) = 0`
418
+
419
+ The house pattern for a "turn these flags on" migration guards the UPDATE so a re-run is a no-op:
420
+
421
+ ```sql
422
+ -- WRONG — silently updates 0 rows when the flags are NULL
423
+ UPDATE TableViewFields SET isFilterable = 1, isCopyable = 1
424
+ WHERE tableViewId = @tv AND (isFilterable = 0 OR isCopyable = 0);
425
+
426
+ -- CORRECT
427
+ WHERE tableViewId = @tv AND (COALESCE(isFilterable, 0) = 0 OR COALESCE(isCopyable, 0) = 0);
428
+ ```
429
+
430
+ In SQL `NULL = 0` is **unknown**, not true, so the guard excludes exactly the rows that most need
431
+ the update. MySQL reports success with `0 rows affected` and the UI is unchanged — which reads as a
432
+ caching problem, not a migration bug. Hit live on 2026-09-23 on the Compass bundles TableView, whose
433
+ flags were left NULL by the original seed.
434
+
435
+ **Applies to every NULLable flag column**, and the flags on `TableViewFields`
436
+ (`isSortable`/`isFilterable`/`isCopyable`/`isGroupable`) are NULLable and are routinely left NULL by
437
+ an INSERT that names only `uuid`/`tableViewId`/`recordFieldId`/`isVisible`/`index`/`slug`.
438
+
439
+ > **Known outstanding hole:** the precedent files `2026-08-26 - EnableTableViewFieldFilters.sql`
440
+ > (NYCHH, Quad, Compass USA, Compass Canada) all use the bare `= 0` guard, so any row that was NULL
441
+ > rather than 0 was never updated by them. Not yet audited.
442
+
416
443
  ## Gotchas
417
444
 
445
+ - **⚠ `col = 0` never matches a NULL** — guard flag-enabling UPDATEs with `COALESCE(col, 0) = 0`, or the migration reports success and changes nothing.
418
446
  - **⚠ An unguarded ACL grant migration duplicates silently — `AclRecordPermissions` has no unique key
419
447
  that stops it (found in prod 2026-09-15).** `_modules/netsuite/2026-09-11c - ItemClassRecordPermission.sql`
420
448
  is a plain `INSERT … VALUES` for `(recordId 105, roleId 3)` with **no `NOT EXISTS` guard**. Every
@@ -477,6 +505,7 @@ concluding a migration is production-safe.
477
505
  [FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
478
506
 
479
507
  ## Change history
508
+ - 2026-09-23 — Added the NULL-unsafe flag-guard trap (`col = 0` matches no NULL row; use `COALESCE(col, 0) = 0`), found when a bundles TableViewFields migration updated 0 rows; flagged the 2026-08-26 `EnableTableViewFieldFilters` files as carrying the same hole. (apeterson)
480
509
  - 2026-09-22 — Added multi-tier chain backfill rules from the Compass tracking/serial backfills: re-run the same file until the verify count is 0 (each pass moves data up one tier; both files needed 3 passes); `GROUP BY` the target key pair instead of `SELECT DISTINCT` when many downstream rows roll up to one upstream row (bundles tripped a bridge UNIQUE 6-to-1); measure the safe/ambiguous/no-match split before writing bulk DML and skip the ambiguous rows (4,900 broken links = 1,982 safe + 0 ambiguous + 2,918 no-match). Sharpened the unique-key rule with measured composite keys across the four item-fulfillment tables — `ItemFulfillmentItemUnits` has none and already carries 254 duplicates. (rgirish)
481
510
  - 2026-09-21 — Added three lessons from the platform-wide `Users.contactId` NOT NULL backfill: a guarded
482
511
  `NOT NULL` ALTER (`sql_mode ''` silently coerces leftover NULLs to `0`, so gate on a residual count —
@@ -8,6 +8,7 @@
8
8
  | [API client (axios wrapper, auth, table-data fetchers)](features/api-client.md) | blox axios/React-Query client — envelope, auth (Bearer + refresh), option serialization, CRUD, table-data fetchers; open for init, query shapes, and the many si |
9
9
  | [BaseInput (react-hook-form field factory)](features/base-input.md) | react-hook-form field factory rendering an input per `inputType`; open for its config surfaces, the input-type switch, validation rules, and the `<FormProvider> |
10
10
  | [blox authoring defect backlog (fix IN blox, not around it)](features/blox-authoring-defects.md) | Backlog of defects to fix INSIDE `@agilant/toga-blox` (the counterpart to frontend §22's app-side workarounds); open when editing blox itself or deciding whethe |
11
+ | [EnvironmentBadge & endpoint classifier](features/environment-badge.md) | The on-screen badge that names which API a build is talking to, and the URL classifier behind it; open before changing its hide rule, adding an environment, or |
11
12
  | [Primary Table templates (server/client, sizing, virtualization)](features/primary-table-templates.md) | The production wired-up table (server/client templates, measure-and-freeze sizing, virtualization, sticky columns); open for props, column-sizing/sticky mechani |
12
13
  | [TableRecordModal (record-detail modal shell)](features/table-record-modal.md) | Generic presentational record-detail modal shell (position/animation/lifecycle only); open for its props, the `data-blox-modal-open` root marker, and the no-por |
13
14
  | [Table component (cells, action cells, filters & sorts, hooks, theming)](features/table.md) | TanStack-v8 Table building block (column building, cell types, filters/sorts, data hooks, theming); open for cell resolution, the filter components, hooks, and |
@@ -0,0 +1,70 @@
1
+ ---
2
+ title: EnvironmentBadge & endpoint classifier
3
+ framework: "2.0"
4
+ repo: toga-blox
5
+ project: TOGa Blox
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-23
10
+ owners: [apeterson]
11
+ files:
12
+ - toga-blox/src/components/EnvironmentBadge/EnvironmentBadge.tsx
13
+ - toga-blox/src/components/EnvironmentBadge/EnvironmentBadge.css
14
+ - toga-blox/src/utils/getEndpointFromEnvironment.ts
15
+ related:
16
+ - ../architecture.md
17
+ - ../../../standards/frontend-deploy.md
18
+ - ../../../standards/frontend.md
19
+ ---
20
+
21
+ The on-screen badge that names which API a build is talking to, and the URL classifier behind it; open before changing its hide rule, adding an environment, or debugging a build that shows no badge.
22
+
23
+ ## Summary
24
+
25
+ `EnvironmentBadge` is a dev/QA warning indicator shipped by blox and mounted by the apps (see `toga2-view` `App.tsx`, commerce `AuthLayout.tsx`). It labels the resolved API endpoint so nobody mistakes a non-production site for production — or worse, a non-production site that is wired to the **production** API.
26
+
27
+ It is the client-side detector for a broken deploy rule: `frontend-deploy.md` says never build with `npm run build` (no `--mode`), because Vite then defaults to mode `production` and loads `.env.production`. When that happens the site points at the production API. The badge exists to make that visible.
28
+
29
+ **Hiding is this component's worst failure mode.** Treat "show a wrong-ish label" as always better than "show nothing".
30
+
31
+ ## How it works
32
+
33
+ ### Hide rule (three conditions, all required)
34
+
35
+ The badge hides only when **all** of:
36
+
37
+ 1. build mode is `production` (`import.meta.env.MODE`),
38
+ 2. the classified endpoint is `production`, and
39
+ 3. `isNonProductionHost(window.location.hostname)` is false.
40
+
41
+ The hostname overrides the build. Before 2026-09-23 only conditions 1 and 2 existed, so the exact incident the badge is for — a sandbox site built without `--mode`, defaulting to mode `production` and `.env.production` — matched both and rendered nothing. Now a sandbox URL talking to the prod API shows a red `Endpoint: Production`.
42
+
43
+ ### `isNonProductionHost(hostname)` (exported)
44
+
45
+ Returns true when the hostname contains any marker in `NON_PRODUCTION_HOST_MARKERS`, case-insensitive: `.client.sandbox`, `.dev.sandbox`, `.beta`, `.gamma`.
46
+
47
+ ### `getEndPointFromEnvironment()` — explicit match, never a fallback
48
+
49
+ Classifies `VITE_API` into `production` | `local` | `unknown` (plus the existing named environments):
50
+
51
+ - `production` — only an explicit match on `/^(https?:\/\/)?api\d?\.togahub\.com(:\d+)?(\/|$)/i` (api, api1, api2, api3).
52
+ - `local` — a **dotless** host (the XAMPP vhost `http://api/v2`); a dotless host can never be a public production host.
53
+ - `unknown` — any other fully-qualified host.
54
+
55
+ **The general rule this encodes: never let the "hide the warning" value be the fallback.** Match it explicitly and label the leftovers. Previously `production` was returned for any unrecognised base URL, so an unrecognised `VITE_API` went completely unlabelled.
56
+
57
+ ### Styling
58
+
59
+ `EnvironmentBadge.css` classes are `env-<endpoint>`. `.env-unknown` is styled **red like production**, so an unclassified backend reads as a warning, not a normal environment.
60
+
61
+ ## Gotchas
62
+
63
+ - **The marker list is exhaustive, not a heuristic.** A differently shaped non-prod host (e.g. `staging.togasupply.com`) still hides. Adding an environment means adding its marker to `NON_PRODUCTION_HOST_MARKERS`.
64
+ - **A CSS class typo silently unstyles the badge.** `.env-localost` (missing `h`) shipped for a long time; the localhost badge rendered as plain text and nobody noticed. Class names must match the endpoint strings the classifier returns — add both together.
65
+ - **Testing `import.meta.env`:** use `vi.stubEnv("MODE", …)` / `vi.stubEnv("VITE_API", …)`. To set the hostname without navigating jsdom, redefine `window.location` with `Object.defineProperty`.
66
+ - **Consumers import it in ways that matter.** A barrel `import { EnvironmentBadge } from "@agilant/toga-blox"` drags in unrelated peers at build time; older published versions exported it as **default only**. See [toga2-commerce Amplify build & deploy](../../toga2-commerce/workflows/amplify-build-and-deploy.md).
67
+ - Do not remove the badge from an app to "clean up" — it is a deliberate QA tool.
68
+
69
+ ## Change history
70
+ - 2026-09-23 — Hostname now overrides the hide rule (`isNonProductionHost`); endpoint classifier matches `production` explicitly and adds `local`/`unknown`; fixed `.env-localost` typo, added `.env-local`/`.env-unknown`; 24 tests added (apeterson)
@@ -6,13 +6,14 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-23
10
10
  owners: [apeterson]
11
11
  files:
12
12
  - toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx
13
13
  - toga25-supply/src/pages/TransferOrders/viewModel/useTransferOrdersPageViewModel.tsx
14
14
  - toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts
15
15
  - toga25-supply/src/hooks/useTablePageMeta.ts
16
+ - toga25-supply/src/pages/Bundles/viewModel/useBundlesPageViewModel.tsx
16
17
  - toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts
17
18
  - toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts
18
19
  - toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts
@@ -90,6 +91,23 @@ The view model then returns `salesOrdersTableMeta` (after column-visibility adju
90
91
  1. a legacy `<slug>-listing` **page-meta record** (matches every existing page), or
91
92
  2. extending the **list surface** with per-column `FIELD` elements carrying a `labelMessageId` — which additionally makes headers translatable through `MessageTranslations`, something the page-meta route cannot do. See [surface-frontend](./surface-frontend.md).
92
93
 
94
+ ### 🚨 Never hardcode a table meta in a view model — it fakes the flags too
95
+
96
+ The Bundles page (fixed 2026-09-23, TRUE-82137) carried a **hardcoded `meta` object** as its table
97
+ meta, called `useFetchPageMeta` with the **wrong page slug** (`"inventory"`, copied from the
98
+ Inventory page), and called `useTablePageMeta` but **threw the fetched result away**, keeping only
99
+ `isLoadingFilterOptions`. Two failures, and the second is the dangerous one:
100
+
101
+ 1. No labels reached the table, so headers rendered raw camelCase slugs (`number`, `isActive`, …) —
102
+ the visible "lowercase headers" bug.
103
+ 2. The fake meta declared `isSortable`/`isFilterable`/`isCopyable` **`true` on every column**, which
104
+ **masked** the fact that the real served meta had them all off (the flags were NULL in
105
+ `TableViewFields`). The page looked configured while the database said otherwise.
106
+
107
+ Use the fetched server meta plus a `humanizeSlug` label fallback — the pattern in
108
+ `useTransferOrdersPageViewModel`. A hardcoded meta is never a workaround for missing data; it hides
109
+ the data problem.
110
+
93
111
  ### What's in the table meta (the columns)
94
112
 
95
113
  `useFetchTablePageMeta` returns `{ table, nestedTable }`. The `table` object is the `TableViewMeta` (alias of `DataTableMetaProp`) and carries:
@@ -137,5 +155,9 @@ App-side glue (NOT in toga-blox):
137
155
  - **Don't shrink the fetch meta for column hiding.** `getDataTableData` builds its API field list from `meta.fields`; adjust `isVisible` only on the *display* meta (see column-visibility).
138
156
  - **Infinite scroll needs a height-constrained scroll container** (`max-height` + `overflow-y: auto`); inline-expanded nested tables where the page scrolls won't load more. Pagination is unaffected.
139
157
  - **`additionalData` must be in the query key** (it is, inside `useTableData`) — required for WHERE-filtered modal tables or React Query serves a stale unfiltered first render.
158
+ - **Never hardcode a table meta object in a view model.** It silently fakes `isSortable`/`isFilterable`/`isCopyable` and hides a real `TableViewFields` gap; always render the fetched meta.
140
159
  - **toga-blox edits need a rebuild** — the app consumes `dist/`; editing only `src` is a silent no-op.
141
160
  - **A new page with no page-meta record has no column labels at all** — the table meta carries no `label` and the merge hook no-ops on a null `pageMeta`, so headers *and* the Columns modal render raw camelCase slugs. Plan the label source before building the screen (see above).
161
+
162
+ ## Change history
163
+ - 2026-09-23 — Added the hardcoded-table-meta anti-pattern found on the Bundles page: it produced raw slug headers and, worse, faked `isSortable`/`isFilterable`/`isCopyable` as true, masking NULL flags in `TableViewFields` (TRUE-82137). (apeterson)
@@ -6,8 +6,8 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: [apeterson, bala]
9
+ updated: 2026-09-23
10
+ owners: [apeterson, bala, "jcardinal"]
11
11
  files:
12
12
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateTransferOrder.ts
13
13
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/PanelSelect.tsx
@@ -199,7 +199,7 @@ One `SurfaceOverrides` row, `IS_VISIBLE = '1'`, **client-wide** (`personaId`/`ro
199
199
 
200
200
  **Two record requests, not one.** The modal reads the lines from the nested route `GET /v2/transfer-orders/{uuid}/transfer-order-items`, with the child route read from the items surface's `config.childRoute` so the FE never hardcodes it.
201
201
 
202
- > ⚠ **The reason for the second request is GONE — the read path was not revisited.** This build declined to add the `InherentRecordChildren` row (it changes the default response shape for *every* existing `/transfer-orders` caller, including the 1.0 NetSuite importer). **That decision was reversed later:** `Core.InherentRecordChildren` row **152** now declares `transfer-orders` (312) → `transfer-order-items` (313), so `/transfer-orders` **does** inline `transferOrderItems` today. The modal still makes two calls; collapsing it to one is available but unclaimed work. See [v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md).
202
+ > ⚠ **The reason for the second request is GONE — the read path was not revisited.** This build declined to add the `InherentRecordChildren` row (it changes the default response shape for *every* existing `/transfer-orders` caller, including the 1.0 NetSuite importer). **That decision was reversed later:** `Core.InherentRecordChildren` row **150** now declares `transfer-orders` (312) → `transfer-order-items` (313), so `/transfer-orders` **does** inline `transferOrderItems` today. The modal still makes two calls; collapsing it to one is available but unclaimed work. See [v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md).
203
203
 
204
204
  **The record fetch raises `calcDepth` to 2.** `_Model_Client_Location` exposes `_addressLine1/_addressLine2/_addressCity/_addressState/_addressZip` as `FIELD_SQL` calculated fields. One FK hop out from the order they need `calcDepth 2`; at the default of 1 they come back **NULL silently** rather than erroring.
205
205
 
@@ -321,7 +321,7 @@ Related data-shape facts found the same day:
321
321
 
322
322
  #### The whole transfer is ONE POST — lines nested under `transferOrderItems`
323
323
 
324
- `hooks/useCreateTransferOrder.ts`. Header **and** every line go out in a **single `POST /transfer-orders`**, with the lines nested under the key **`transferOrderItems`**. This is possible because `Core.InherentRecordChildren` row **152** now makes `transfer-order-items` (313) an inherent child of `transfer-orders` (312).
324
+ `hooks/useCreateTransferOrder.ts`. Header **and** every line go out in a **single `POST /transfer-orders`**, with the lines nested under the key **`transferOrderItems`**. This is possible because `Core.InherentRecordChildren` row **150** now makes `transfer-order-items` (313) an inherent child of `transfer-orders` (312).
325
325
 
326
326
  **Why one request, not three.** An earlier draft posted the header and then each line separately and could leave a **half-written transfer** behind on a mid-sequence failure. One API request is one API transaction — the front controller commits the umbrella connection on `isSuccess` and rolls the data back on failure — so the nested POST is all-or-nothing **without** a scripted endpoint.
327
327
 
@@ -331,6 +331,13 @@ Related data-shape facts found the same day:
331
331
 
332
332
  This closes both **required POST changes** recorded on 2026-09-02: `number` is no longer sent (so `FIELD_AUTOINCREMENT` mints the `TA100000` sequence) and the lines ride nested in the same request (so `_Model_Client_TransferOrder::postPost` sees them when it reads the order back for the NetSuite push) — see [Toga TransferOrder → NetSuite Push](../../worker2/features/netsuite-transferorder-outbound-push.md).
333
333
 
334
+ **PO links ride the same POST (2026-09-23).** `CreateTransferOrderModal/hooks/useCreateTransferOrder.ts`:
335
+ - header: `purchaseOrdersTransferOrders: [{ purchaseOrder: { uuid } }]` → bridge `PurchaseOrders_TransferOrders` (record **325**; inherent child of 312 via row 151).
336
+ - each `transferOrderItems[]` line: `purchaseOrderItemsTransferOrderItems: [{ purchaseOrderItem: { uuid } }]` → bridge `PurchaseOrderItems_TransferOrderItems` (record **329**; inherent child of 313 via `dbchanges2/Core/2026-09-23d`). Source: `TransferOrderDraftLine.purchaseOrderItemUuid` ← `PurchaseOrderItemRow.purchaseOrderItemUuid`.
337
+ - V2 auto-sets each bridge's parent-side FK; payload supplies only the other side. Engine: [nested-relationship-writes](../../api2/features/nested-relationship-writes.md).
338
+ - Direct `purchaseOrder: { uuid }` (`TransferOrders.purchaseOrderId`) is **kept** — the record modal's PO deep link reads `transferOrder.purchaseOrder.uuid`.
339
+ - Use 325/329, never the reverse-named twins 326 `TransferOrders_PurchaseOrders` / 330 `TransferOrderItems_PurchaseOrderItems` (0 rows in prod; dead).
340
+
334
341
  **FKs go out as nested `{ uuid }` objects under the field name WITHOUT the `Id` suffix** — `contact: { uuid }`, `createdByUser: { uuid }`. Each is omitted entirely when absent rather than sent as `null`: a location can have no contact, and an SSO session can lack a user uuid.
335
342
 
336
343
  #### The picker fetch — 13.3 s → 0.39 s by filtering SERVER-side (2026-09-14)
@@ -516,7 +523,7 @@ Money (Unit Value / Total / Total Value) and Target Contact are **dropped for v1
516
523
 
517
524
  ~~⚠ `TransferOrders` carries **no contact FK at all** today, so the Target Contact element's `valueKey` is a **placeholder**~~ — **closed 2026-09-01.** `TransferOrders.contactId` (FK `Contacts`) exists and RecordField **2561** is granted to roles 1 and 3, so the element has a real binding and enabling it is now genuinely a one-row client override.
518
525
 
519
- The items table's **PO column is order-level**: `TransferOrderItems` has no line-level PO, so the column binds the parent order's `purchaseOrder.number` and repeats on every row. For NYCHH it resolves NULL because their PO links are **upstream** — see [NYCHH PO direction](../../../../clients/nychh/features/po-number-upstream-direction.md). It is left visible in Core (neutral default; other tenants populate `TransferOrders.purchaseOrderId` normally). **The header's PO value is now a link into Inventory** (above); for NYCHH it is the same null, so nothing renders there for this tenant yet.
526
+ The items table's **PO column is order-level**: `TransferOrderItems` has no line-level PO column (line PO link lives in bridge 329 since 2026-09-23, not bound to the column), so the column binds the parent order's `purchaseOrder.number` and repeats on every row. For NYCHH it resolves NULL because their PO links are **upstream** — see [NYCHH PO direction](../../../../clients/nychh/features/po-number-upstream-direction.md). It is left visible in Core (neutral default; other tenants populate `TransferOrders.purchaseOrderId` normally). **The header's PO value is now a link into Inventory** (above); for NYCHH it is the same null, so nothing renders there for this tenant yet.
520
527
 
521
528
  ### Entry point #2 — the Inventory page's "Create Transfer Order" button (2026-09-03)
522
529
 
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala"]
9
+ updated: 2026-09-23
10
+ owners: ["bala", "jcardinal"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite/SalesOrder.php
13
13
  - _underscore/Model/Client/TransferOrder.php
@@ -84,7 +84,7 @@ Nothing beyond this list. A transfer order has no shipping cost, ship method, te
84
84
  | line `rate` | **0** — the zero-value part |
85
85
  | line `location` | the **origin** warehouse. **`location` is NOT a ship-to** — NetSuite never uses a line's location for delivery. |
86
86
 
87
- **Missing data ABORTS the order — never sends a partial one.** Each throws with a named message: **no origin location, no destination location, no lines, no PO number**. Mirrors the sales-order push's missing-mapping rule. **PO # is mandatory on the NetSuite order form**, which is why a missing PO number is fatal rather than omitted. `resolvePurchaseOrderNumber()` reads the PO number from the **`purchaseOrdersTransferOrders` header bridge first** (what the Create Transfer Order modal writes), falling back to the direct `purchaseOrderId` column. See [SO↔PO bridge direction](../../_underscore/features/sales-order-purchase-order-bridge-direction.md).
87
+ **Missing data ABORTS the order — never sends a partial one.** Each throws with a named message: **no origin location, no destination location, no lines, no PO number**. Mirrors the sales-order push's missing-mapping rule. **PO # is mandatory on the NetSuite order form**, which is why a missing PO number is fatal rather than omitted. `resolvePurchaseOrderNumber()` reads the PO number from the **`purchaseOrdersTransferOrders` header bridge first** (written by the Create Transfer Order modal since 2026-09-23), falling back to the direct `purchaseOrderId` column. See [SO↔PO bridge direction](../../_underscore/features/sales-order-purchase-order-bridge-direction.md).
88
88
 
89
89
  **`description` → `memo` cannot leak into a plain sales order.** `memo` is sent only from the transfer-order shape. There is **no `memo` RecordField and no `memo` column anywhere** in the sales-order path.
90
90
 
@@ -34,7 +34,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
34
34
  - **ai-bdr** (AI-BDR) — 13 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
35
35
  - **toga2-commerce** (TOGa Commerce) — 26 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
36
36
  - **toga25-supply** (TOGa 2.5 Supply) — 21 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
37
- - **toga-blox** (TOGa Blox) — 14 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
37
+ - **toga-blox** (TOGa Blox) — 15 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
38
38
  - **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
39
39
 
40
40
  ## standalone framework
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-17
10
- owners: ["bala"]
9
+ updated: 2026-09-23
10
+ owners: ["bala", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Model/Client/TransferOrder.php
13
13
  - worker2/Worker/Netsuite/SalesOrder.php
@@ -203,11 +203,18 @@ email, which must fire *after* this push writes `number` back. The `POST` row ab
203
203
 
204
204
  ## Data + metadata state (2026-09-02)
205
205
 
206
- - **`Client_Nychh.PurchaseOrders_TransferOrders` has 0 rows in production**, and
207
- `TransferOrders.purchaseOrderId` is set on **0** of the 1,930 rows — **no transfer order has a PO
208
- link yet.** New orders from the Create Transfer Order modal are what will populate the bridge.
209
- Until then `resolvePurchaseOrderNumber()` returns null and **the push aborts by design** (PO # is
210
- mandatory on the NetSuite order form).
206
+ - **Prod 2026-09-23:** `PurchaseOrders_TransferOrders` (325) = **1,592 rows**; `TransferOrders.purchaseOrderId`
207
+ set on 10 rows. The Create Transfer Order modal now writes 325 (header) + 329 (lines) in its single POST
208
+ ([transfer-orders-page](../../../2.0/apps/toga25-supply/features/transfer-orders-page.md)). An order
209
+ with no PO link still makes `resolvePurchaseOrderNumber()` return null → push aborts by design.
210
+ - Reverse-named twins 326 / 330 have 0 rows — dead; never read or write them.
211
+ - Prod `InherentRecordChildren` ids: 150 = 312→313, 151 = 312→325, 152 = 312→357; added 2026-09-23
212
+ (`Core/2026-09-23d`): 17→325, 18→329, 313→329.
213
+ - RecordFields: 325 = 2209 id / 2210 uuid / 2211 purchaseOrderId / 2212 transferOrderId; 329 = 2225 / 2226 /
214
+ 2227 purchaseOrderItemId / 2228 transferOrderItemId.
215
+ - Base (role 1) + API (role 3) ACL for 325/329 already complete (record CRUD, expressions 302/303, field
216
+ write on uuid + both FKs). Duplicate Base `AclRecordPermissions` (325/331, 328/334) — harmless.
217
+ - `TransferOrderStages` prod: 1 Pending, 2 In Transit, 3 Received, 4 Canceled, 5 Closed.
211
218
  - **`Core.InherentRecordChildren` for record 312** — the depth-4 read needs both the lines and the
212
219
  header PO bridge:
213
220
  - **prod** — was 312→313 and 312→**329** (the *item-level* bridge); repointed by the developer, now
@@ -18,7 +18,7 @@ project: _Underscore
18
18
  client: nychh
19
19
  type: profile
20
20
  status: active
21
- updated: 2026-09-17
21
+ updated: 2026-09-23
22
22
  owners: ["jcardinal", "apeterson", "bala", "akhokhani"]
23
23
  files:
24
24
  - dbchanges2/Client_Nychh/2026-09-02a - TransferOrderNetsuitePushInterceptor.sql
@@ -102,6 +102,7 @@ TOGA 2.0 client on `_underscore`, prod schema `Client_Nychh`. Supply-chain / ful
102
102
  - 🚨 **`Client_Nychh.TransferOrders` HAS production data — 1,927 rows (verified 2026-08-31 on `reader1.client.database.togahub.com`).** The transfer-order *tables and data* have been live in prod for a while (the 1.0 NetSuite importer writes them); only the **2026-08-28 UI vertical** (TableView, Core surfaces, nav override) is new. An earlier version said transfer orders were "local only … not yet run on sandbox-client or production", and that stale line produced a wrong recommendation — that a prod-to-local restore would *wipe* the transfer-orders feature. **Never infer prod state from this repo or from a migration's run status; read prod.** Restoring or overwriting `Client_Nychh` destroys real transfer-order rows.
103
103
  - **The Transfer Orders SCREEN is NYCHH-only and new (2026-08-28).** NYCHH is the first — and today the only — tenant with a `transfer-orders` TableView (`Client_Nychh/2026-08-28a`, which also seeds the ACL chain for record 351 that the Status column silently depends on) and the one `SurfaceOverrides` row that reveals the nav item (`2026-08-28b`, client-wide, matching how this tenant grants Sales Orders and Inventory — NYCHH does not scope navigation by role). The Core surfaces and stage colour tokens are **shared/neutral**, hidden for every other client until they opt in. The seven migration files were **committed 2026-08-31** (`dbchanges2` commit `1c3fe2e`, branch `TRUE-80852`, pushed); run order is **`Core/` → `Client/` fan-out → `Client_Nychh/`**, because the client files reference Core record ids and the NYCHH TableView/ACL need the columns the fan-out files add. Run state per environment must be **verified against that environment**, never assumed from the repo. **beta is blocked** because it lacks the `TransferOrderStages` table. The **Create Transfer Order** modal was reworked 2026-09-01 and its data layer is wired, but 🚨 **the item picker shows zero selectable lines for NYCHH** — see the availability blocker below. See [Transfer Orders page](../../2.0/apps/toga25-supply/features/transfer-orders-page.md).
104
104
  - 🚨 **The transfer-order ACL + schema on prod/sandbox are UNTRACKED (2026-08-28).** Record and field ACL for records 312/313, their logic-group/expression chain, and the `TransferOrderItems.dtCreated`/`dtUpdated` columns were all hand-applied when the NetSuite TransferOrder POST was enabled — **no `dbchanges2` file creates any of it**, so a fresh environment can reproduce neither the UI nor the importer. Reconstructed in `Client_Nychh/2026-08-28c` (ACL, faithful — role 2 deliberately excluded) and `Client/2026-08-28b` (columns; root cause is the blank client template, which affects **every** tenant). See [transfer-order inventory & quantities](./features/transfer-order-inventory-quantities.md).
105
+ - **Create Transfer Order links POs (2026-09-23)** — one POST writes header bridge 325 + line bridge 329 (Core `2026-09-23d` inherent children). Prod 325 has 1,592 rows; 326/330 twins are dead. See [transfer-order push](./features/transfer-order-netsuite-push.md).
105
106
  - **Two-branch inventory quantities + TransferOrder V2 enablement** — NYCHH-only on-hand/available/fulfilled/backordered model on `_Model_Nychh_Item`, plus the client-override models + ACL rows that let a NetSuite TransferOrder POST succeed. See [transfer-order inventory & quantities](./features/transfer-order-inventory-quantities.md).
106
107
  - 🚨 **OPEN BLOCKER — every NYCHH PO line reports availability 0, in sandbox AND production (verified 2026-09-01).** The deployed `_Model_Nychh_PurchaseOrderItem` sources stock-in from inventory adjustments and `INNER JOIN`s `InventoryAdjustmentItems.itemFulfillmentItemId`, which is **NULL on 100% of rows** (292/292 sandbox, 317/317 prod). So `_qtyReceived` / `_qtyOnHand` / `_qtyAvailable` are all `0.00` for every purchase-order line, and the **Create Transfer Order item picker has nothing to show**. Needs an **integration / data-owner decision** — backfill the FK, or change the override's stock-in source (base PO item receipts return real numbers today). Not a frontend or ACL defect, and **not** fixed by `Client/2026-09-01a`. See [transfer-order inventory & quantities](./features/transfer-order-inventory-quantities.md).
107
108
  - 🚨 **`_Model_Nychh_PurchaseOrderItem` is NOT on every `_underscore` branch (verified 2026-09-01).** `Model/Nychh/PurchaseOrderItem.php` exists on **`_production`** and **`_sandbox-client`** but is **absent from `_beta`** and **`_sandbox-dev`**. Because `_underscore` is pulled at deploy by branch, NYCHH PO-line `_qty*` values are **different numbers per tier**: prod/sandbox-client run the inventory-adjustment formula (0.00 until the NetSuite link is backfilled), beta/sandbox-dev fall back to the base item-receipt formula and report real quantities. **Never eliminate a tenant override by `ls`-ing your working tree** — use `git ls-tree -r --name-only origin/<branch>`. That mistake cost a full session chasing a non-existent "API returns 0" bug. See [transfer-order inventory & quantities](./features/transfer-order-inventory-quantities.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.880",
3
+ "version": "1.0.882",
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",