toga-ai 1.0.375 → 1.0.377

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.
@@ -9,6 +9,7 @@
9
9
  | [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | `App_Api_Toga2` in `library/app/api/toga2.php` orchestrates bidirectional sync between TOGA 2 and TOGaDesk. | library/app/api/toga2.php |
10
10
  | [Branded HTML Email Templates (App_Email_Template)](features/email-templates.md) | `App_Email_Template` (`app/email/template.php`) is the base class for branded HTML emails in the 1.0 (`App_`) framework. | library/app/email/template.php, library/app/email/agilant.php |
11
11
  | [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t | library/app/framework.php, library/app/frameworkindex.php, library/app/mvc.php, library/app/database.php, library/app/model.php, library/app/config.php |
12
+ | [isFulfillable from NetSuite during Item Sync (Phase 1)](features/netsuite-item-isfulfillable-sync.md) | This is the **1.0 (Phase 1)** half of the `isFulfillable` feature: reading the NetSuite `isfulfillable` flag during item sync and stamping it onto the **Agilant | library/app/netsuite.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php |
12
13
  | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem, test/@dave/Junk Drawer/nsq.php |
13
14
  | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php |
14
15
  | [NetSuite Sync Alert Monitor (App_SystemMonitor_NetSuiteIntegration)](features/netsuite-sync-alert-monitor.md) | `App_SystemMonitor_NetSuiteIntegration` (`library/app/systemmonitor/netsuiteintegration.php`, title **"NetSuite Sync Alert"**) is a 1.0 system monitor that watc | library/app/systemmonitor/netsuiteintegration.php, worker/crons/infrastructure/system_monitors.php |
@@ -0,0 +1,102 @@
1
+ ---
2
+ title: isFulfillable from NetSuite during Item Sync (Phase 1)
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-20
10
+ owners: [bala]
11
+ files:
12
+ - library/app/netsuite.php
13
+ - library/app/api/toga2.php
14
+ - worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php
15
+ related:
16
+ - toga2-api-client-and-bridge.md
17
+ - ../../worker/features/netsuite-togasupply-per-client-sync.md
18
+ - ../../../2.0/apps/_underscore/features/fulfillable-item-propagation.md
19
+ ---
20
+
21
+ ## Summary
22
+ This is the **1.0 (Phase 1)** half of the `isFulfillable` feature: reading the NetSuite
23
+ `isfulfillable` flag during item sync and stamping it onto the **Agilant-catalog (source/supplier)
24
+ item** in the 2.0 platform. The client-facing copy is reached separately by the
25
+ [2.0 propagation engine](../../../2.0/apps/_underscore/features/fulfillable-item-propagation.md),
26
+ which walks the supply chain up from the source item. Only the source item is stamped here.
27
+
28
+ ## Key files / entry points
29
+ - `library/app/netsuite.php` — `App_NetSuite::getItemIsFulfillable($itemInternalId): bool|null`.
30
+ A SOAP `ItemSearchBasic` by internal id; returns the boolean (or `null` when unknown). Mirrors
31
+ the existing `getItemIsSerialized` exactly — the only prior item flag the sync read.
32
+ - `library/app/api/toga2.php` — `getCreateItem` (~line 4364). During item create/update the sync
33
+ now reads `$isFulfillableItem = App_NetSuite::getItemIsFulfillable($nsItemInternalId)` and, when
34
+ **non-null**, stamps `$payload['isFulfillable']` on the create/update PUT|POST to api2. The
35
+ NetSuite internal id is resolved from the order line or from the part number
36
+ (`getItemInternalIdFromPartNumber` / `getItemGroupInternalIdFromPartNumber`). Stores
37
+ `c_netsuiteInternalItemId` on the item.
38
+ - `worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php` — one-time backfill (see below).
39
+
40
+ ## How it works
41
+ 1. Item sync resolves the NetSuite internal id for the item (order line, else part-number lookup).
42
+ 2. `getItemIsFulfillable` SOAP-searches the item and returns the flag.
43
+ 3. If non-null, `getCreateItem` adds `isFulfillable` to the item PUT/POST payload to api2. api2's
44
+ Items interceptor then propagates it up-chain (Phase 2).
45
+ 4. Going forward the **daily sync** handles new items automatically; existing items are caught up
46
+ by the one-time backfill.
47
+
48
+ ### One-time backfill (July 5+ orders)
49
+ `backfill_isfulfillable_jul5.php` catches up Agilant items on SalesOrders since **2026-07-05**:
50
+ - It **PUTs each source item to `/items`** via `App_Api_Toga2::send` — the *same* call the sync
51
+ makes — so **api2's interceptor performs the recursion**. It deliberately does **NOT** write the
52
+ DB directly and does **NOT** walk the chain itself. (Design choice: reuse the one propagation
53
+ path so backfill and steady-state behave identically.)
54
+ - Reads the value from NetSuite **by part number**, fulfillable-if-any (`max` across matching
55
+ internal ids).
56
+ - Env-agnostic: `App_Registry::get('config')` with `DB_HOST_PREFERENCE` = `localhost` (local) /
57
+ `writer.client` (prod); `DRY_RUN` flips true→false. Compass credentials are the same source as
58
+ `sync_togasupply_compass_usa.php`.
59
+
60
+ ## Key gotcha — what NetSuite `isfulfillable` actually means
61
+ NetSuite `isfulfillable` (SuiteQL `SELECT isfulfillable FROM item`) is **`T` for InvtPart AND
62
+ NonInvtPart (services)**; it is `F` **only** for Group/bundle headers. It does **NOT** distinguish
63
+ physical goods from services — `itemtype` would, but the sync has never used `itemtype` (only
64
+ `isSerialized`), so this feature intentionally reflects NetSuite's flag **as-is**. **Net effect:
65
+ nearly everything resolves to `fulfillable = 1`.**
66
+
67
+ - **Open product decision (caveat):** because services come back fulfillable, the storefront makes
68
+ everything clickable. Whether that is intended vs. needing `itemtype` to hide services is
69
+ **pending product confirmation**.
70
+
71
+ ## Verification performed
72
+ - **Prod read-only dry-run:** 20 reachable Compass items; chain walk correct
73
+ (Agilant `1989 B4NY9UC#ABA` → Compass `1986 B4NY9UC-2`); ~91% of July-5+ order lines reach a
74
+ Compass item, all landing on the correct Compass copy.
75
+ - **Local live backfill:** 85 Agilant PUTs → interceptor → 20 distinct Compass items set (all `=1`),
76
+ each verified to trace through an Agilant source.
77
+
78
+ ## Gotchas / known issues
79
+ - **Value stamped only on the Agilant source item here** — the client-facing copy is set by the 2.0
80
+ interceptor. If the interceptor rows aren't deployed in the target env, api2 **403s the whole item
81
+ write** on the unknown `isFulfillable` field (see the Phase-2 doc's deploy gotchas).
82
+ - **Backfill fires the api2 path, not direct SQL.** It relies on the Phase-2 interceptor being live
83
+ in the env it targets; running it against an env without the interceptor rows will 403 every PUT.
84
+ - **🔐 Credentials:** the backfill's Compass client/api uuids come from config; the API secret lives
85
+ in `Client_Compass.Apis` (name `Agilant`) — never reproduce the secret value.
86
+
87
+ ## Change history
88
+ - 2026-07-20 — Added `App_NetSuite::getItemIsFulfillable` (SOAP `ItemSearchBasic`, mirrors
89
+ `getItemIsSerialized`) and wired `getCreateItem` to stamp `isFulfillable` on the item PUT/POST to
90
+ api2 when non-null (internal id from order line or part-number lookup; stores
91
+ `c_netsuiteInternalItemId`). Added the one-time `backfill_isfulfillable_jul5.php` cron, which
92
+ re-PUTs Agilant source items so the api2 interceptor does the recursion (no direct DB writes).
93
+ Documented the NetSuite `isfulfillable` semantics gotcha (T for InvtPart + services, F only for
94
+ group headers — does not distinguish service vs physical) and the resulting open product decision.
95
+ (bala)
96
+
97
+ ## Related docs
98
+ - [App_Api_Toga2 — API Client & 1.0↔2.0 Bridge](toga2-api-client-and-bridge.md) — the transport
99
+ `getCreateItem`/the backfill use to reach api2.
100
+ - [NetSuite → TOGa Supply Per-Client Sync](../../worker/features/netsuite-togasupply-per-client-sync.md)
101
+ — the sync engine this item flag rides in.
102
+ - [Phase 2 — isFulfillable propagation up the SO↔PO chain (2.0)](../../../2.0/apps/_underscore/features/fulfillable-item-propagation.md).
@@ -198,6 +198,12 @@ Parameters are stored **per client DB** but accessed **through the TOGa2 API**,
198
198
 
199
199
  ## Change history
200
200
 
201
+ - 2026-07-20 — Fixed a pre-existing **missing `try`-close / brace bug** in
202
+ `common_sync_togasupply.php` (the inventory-adjustments and item-fulfillments blocks lacked a
203
+ `try` close, a parse error that broke the Compass sync run). Same failure class as the 2026-07-09
204
+ tier-wide `rest.php` outage — a dropped token in this shared engine crashes the run before records
205
+ process. (Fixed incidentally while shipping the `isFulfillable` item-sync feature; see the
206
+ [library Phase-1 doc](../../library/features/netsuite-item-isfulfillable-sync.md).) (bala)
201
207
  - 2026-07-10 — Added **Quad + Growrk** to the NetSuite Sync Alert monitor's
202
208
  `CLIENT_CONFIGURATIONS` (previously unmonitored); **AIG deliberately left off** (held
203
209
  account, checkpoints frozen at the 2026-04-28 seed). Recorded a real, ongoing IF/InvAdj
@@ -16,6 +16,7 @@
16
16
  | [Error Reporting — Issue/Event Aggregation (agreed POST-to-receiver design)](features/error-reporting-issue-event.md) | Platform-wide error-reporting infrastructure for TOGA 2.0, built around a two-table **Issue / Event** aggregation model in the shared **Core Logs DB**. | _underscore/Error.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, dbchanges2/Logs/2026-07-06 - Issue and Event tables.sql |
17
17
  | [Record-Changed Event Publishing (_Event::publish to SQS)](features/event-publish-sqs.md) | `_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event pipeline. | _underscore/Event.php |
18
18
  | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
19
+ | [isFulfillable Propagation Up the SO↔PO Chain](features/fulfillable-item-propagation.md) | `Items.isFulfillable` is a boolean that gates whether a storefront line's **Qty Fulfilled** cell is actionable. | _underscore/Model/Client/Item.php, _underscore/Model/Compass/Item.php, dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql |
19
20
  | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
20
21
  | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php |
21
22
  | [_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 |
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: isFulfillable Propagation Up the SO↔PO Chain
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-20
10
+ owners: [bala]
11
+ files:
12
+ - _underscore/Model/Client/Item.php
13
+ - _underscore/Model/Compass/Item.php
14
+ - dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql
15
+ - dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql
16
+ - dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql
17
+ related:
18
+ - recursive-item-fulfillments.md
19
+ - ../../../1.0/apps/library/features/netsuite-item-isfulfillable-sync.md
20
+ - ../../../clients/compass-usa/profile.md
21
+ ---
22
+
23
+ ## Summary
24
+ `Items.isFulfillable` is a boolean that gates whether a storefront line's **Qty Fulfilled**
25
+ cell is actionable. The value is **born on the Agilant-catalog (source/supplier) item** during
26
+ the 1.0 NetSuite item sync (see the [Phase 1 doc](../../../1.0/apps/library/features/netsuite-item-isfulfillable-sync.md)),
27
+ but the toga2-supply storefront shows the **client's own catalog row** — a *different* `Items`
28
+ row linked to the source item only through the multi-tier supply chain. This feature is the
29
+ **2.0 (Phase 2)** half: an api2 interceptor that, whenever an item write carries `isFulfillable`,
30
+ walks the `SalesOrderItem`↔`PurchaseOrderItem` chain **upward** and stamps the same value onto
31
+ every client-facing item it reaches. Value flows source → client copy so the storefront gate is
32
+ correct on the row the customer actually sees.
33
+
34
+ This is a sibling of the [Recursive Item Fulfillments](recursive-item-fulfillments.md) engine —
35
+ same bridge topology, walked for a different payload (a scalar flag rather than fulfillment rows).
36
+
37
+ ## Key files / entry points
38
+ - `_underscore/Model/Client/Item.php`
39
+ - `propagateFulfillableAcrossChain(string $itemUuid)` (~lines 497–565) — the engine.
40
+ - `propagateFulfillableWhenPayloadCarriesTheFlag($api)` — the interceptor body, registered on
41
+ `postPost` and `postPut` for Items. **Gated:** `if (!isset($api->httpPayload->isFulfillable)) return;`
42
+ so an ordinary item edit that doesn't touch the flag never triggers a chain walk.
43
+ - `_underscore/Model/Compass/Item.php` — Compass's Item subclass **must call
44
+ `parent::postPost` / `parent::postPut`** so the base propagation isn't shadowed by the Compass
45
+ price-override override. (Same empty-pass-through pattern the recursive-IF engine relies on.)
46
+
47
+ ## How it works
48
+ 1. **Interceptor gate.** `postPost`/`postPut` call `propagateFulfillableWhenPayloadCarriesTheFlag`,
49
+ which returns immediately unless the payload carried `isFulfillable`. Only the sync (and the
50
+ one-time backfill) send that field, so day-to-day item edits skip the walk entirely.
51
+ 2. **Load the source item** by uuid → `id` + `isFulfillable`. If the value is **NULL**, return
52
+ (nothing authoritative to propagate).
53
+ 3. **Walk the chain UP** with a `WITH RECURSIVE ChainSalesOrderItems` CTE:
54
+ - **Anchor:** `SalesOrderItems WHERE itemId = <sourceItemId>`.
55
+ - **Recursive step:** join `PurchaseOrderItems_SalesOrderItems` (PSO) then
56
+ `SalesOrderItems_PurchaseOrderItems` (SPO) to climb one tier
57
+ (Agilant SOI → PSO → POI → SPO → next SOI). `UNION` (not `UNION ALL`) stops loops.
58
+ - **Result:** `SELECT DISTINCT LinkedSalesOrderItems.itemId WHERE itemId <> sourceItemId` —
59
+ every client-facing item id reachable above the source.
60
+ 4. **Diff-only update:**
61
+ `UPDATE Items SET isFulfillable = <value> WHERE id IN (...) AND (isFulfillable <> <value> OR isFulfillable IS NULL)`.
62
+ Re-runs are no-ops, so repeated syncs / a re-run backfill are safe.
63
+
64
+ ## Chain topology (VERIFIED in prod)
65
+ - **Agilant items sit on the `PSO.salesOrderItemId` side; Compass (client-facing) items sit on
66
+ the `SPO.salesOrderItemId` side.** The value born on the Agilant *source* item therefore flows
67
+ **up** to the Compass item the storefront renders.
68
+ - Same four bridge tables as the recursive-IF engine (`PurchaseOrders_SalesOrders`,
69
+ `SalesOrders_PurchaseOrders`, `PurchaseOrderItems_SalesOrderItems`,
70
+ `SalesOrderItems_PurchaseOrderItems`), so a **broken/missing SOI↔POI bridge row breaks this walk
71
+ too** — the same integrity class as the Compass off-by-one bridge bugs (see
72
+ [MITS PO → SO Item Linking](../../../clients/compass-usa/features/mits-po-to-so-item-linking.md)).
73
+
74
+ ## Data model / schema (deploy)
75
+ Three `dbchanges2` migrations register the field + hooks; **all must be present in the target
76
+ env or the flag write is rejected:**
77
+ 1. **Core RecordField** (`Core/2026-07-17 - Items isFulfillable RecordField.sql`) — recordId **21**
78
+ (Items), field `isFulfillable`, BOOLEAN.
79
+ 2. **Core `ApiPayloadInterceptors`** (`Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql`)
80
+ — rows for recordId 21, **POST/POST** and **POST/PUT**, global (clientId/apiId null).
81
+ 3. **Client column + ACL** (`Client/2026-07-17 - ItemsisFulfillable.sql`) — `Items.isFulfillable`
82
+ column + ACL rows.
83
+
84
+ ## Conflict rule
85
+ Multi-source kits (an item reachable from more than one source) use **LAST-WRITER-WINS**. A
86
+ deterministic `MIN`/`MAX` aggregate was **deliberately deferred** — harmless while values are
87
+ uniformly `1` (see the Phase-1 gotcha: NetSuite flags nearly everything fulfillable).
88
+
89
+ ## Client variations
90
+ - **Compass** (USA/Canada, DB `Client_Compass`) is the built/verified client. Its `_Model_Compass_Item`
91
+ overrides `postPost`/`postPut` for a price override, so it **must** `parent::` up to the base or
92
+ propagation silently never fires.
93
+ - Engine itself is shared `_Model_Client_Item` logic; any client with the schema present gets it.
94
+
95
+ ## Gotchas / known issues
96
+ - **⚠ Interceptors are DB-driven.** The `postPost`/`postPut` PHP does nothing without the
97
+ `ApiPayloadInterceptors` rows in the **target env's Core DB**. Missing rows → the hook never
98
+ fires **and** api2 EV-8's the unknown `isFulfillable` field, **403-ing the whole request** (not
99
+ just dropping the field). This is the same interceptor-row requirement as the recursive-IF engine.
100
+ - **⚠ Cross-cluster Core/client on prod.** On prod, Core and client DBs are on **separate
101
+ clusters**, so the client ACL migration's cross-DB `Core.RecordFields` uuid lookup must be
102
+ **resolved/substituted at deploy** — it cannot join across clusters at runtime.
103
+ - **⚠ Compass subclass shadowing.** If a future client subclass overrides Items `postPost`/`postPut`
104
+ without calling `parent::`, propagation silently stops for that client.
105
+ - **Product decision unresolved (caveat, not a bug).** Because NetSuite's `isfulfillable` is `T`
106
+ for services as well as physical goods (see Phase-1 doc), propagation makes **everything**
107
+ clickable on the storefront. Whether "reflect NetSuite's flag as-is" is the intended behavior —
108
+ vs. using `itemtype` to hide services — is **pending product confirmation**.
109
+
110
+ ## Change history
111
+ - 2026-07-20 — Built the isFulfillable up-chain propagation engine:
112
+ `propagateFulfillableAcrossChain` + the payload-gated `postPost`/`postPut` interceptor on Items
113
+ (recursive SOI↔POI CTE, diff-only `UPDATE`), plus the Compass `parent::` fix so the price-override
114
+ subclass doesn't shadow it. Registered the field + interceptors via three `dbchanges2` migrations
115
+ (Core RecordField 21, Core POST/POST + POST/PUT interceptor rows, Client column + ACL). Verified
116
+ in prod (Agilant `1989 B4NY9UC#ABA` → Compass `1986 B4NY9UC-2`; 20 reachable Compass items) and via
117
+ a local live backfill (85 Agilant PUTs → 20 distinct Compass items set). Last-writer conflict rule
118
+ for multi-source kits (MIN/MAX aggregate deferred). (bala)
119
+
120
+ ## Related docs
121
+ - [Phase 1 — isFulfillable from NetSuite during item sync (1.0)](../../../1.0/apps/library/features/netsuite-item-isfulfillable-sync.md)
122
+ — where the value is sourced + the one-time backfill.
123
+ - [Recursive Item Fulfillments](recursive-item-fulfillments.md) — the sibling engine over the same
124
+ bridge topology.
125
+ - [Compass MITS PO → SO Item Linking](../../../clients/compass-usa/features/mits-po-to-so-item-linking.md)
126
+ — how the SOI↔POI bridges get built (and the integrity bugs that break this walk).
@@ -143,6 +143,9 @@ inheritance. Verified against prod `Client_Compass` chains (≥3 levels deep).
143
143
  - 2026-06-08 — Documented the Recursive Item Fulfillments engine (interceptor-driven upstream fulfillment mirroring, bundle scaling, reconcile loop). (jcardinal)
144
144
 
145
145
  ## Related docs
146
+ - [isFulfillable Propagation Up the SO↔PO Chain](fulfillable-item-propagation.md) — a sibling
147
+ engine that walks the **same** bridge topology to propagate a scalar item flag (rather than
148
+ fulfillment rows) from the source item up to the client-facing copy.
146
149
  - GroWrk transfer order flow plan: `clients/growrk/features/transfer-order-flow.md`.
147
150
  - `_underscore` architecture (interceptors, `internalApiRequest`, `_Model` layer).
148
151
  - `api2` architecture (V2 metadata engine that fires these interceptors; the
@@ -4,6 +4,7 @@
4
4
  |-----|---------|-------|
5
5
  | [TOGa 2.5 Supply — Architecture](architecture.md) | `toga25-supply` ("TOGa 2.5 Supply") is the **React/TypeScript frontend** for the 2.0 Supply application — an iteration and improvement of `toga2-supply`. | toga25-supply/src/main.tsx, toga25-supply/src/App.tsx, toga25-supply/src/routes.tsx, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/fieldsConfig/useClientFields.ts, toga25-supply/src/layout/, toga25-supply/src/pages/, toga25-supply/src/hooks/useTableCellInteractions.ts |
6
6
  | [Action-Button Rule Engine (Flag / Rule grammar)](features/action-button-rule-engine.md) | A declarative, fully config-driven rule engine that resolves the boolean-ish flags (`isEnabled`, `isVisible`, `isComplete`) on SalesOrder action-button options. | toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.ts, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/useApprovalModalViewModel.tsx |
7
+ | [Client API-Fetch Fields (useClientApiFields / apiFields.json)](features/client-api-fetch-fields.md) | The mechanism that resolves a client's **API-fetch projection** — which `fields` / `ojoin` / `join` / `where` to request from the TOGa API for a given page — fr | toga25-supply/src/fieldsConfig/resolveApiConfig.ts, toga25-supply/src/fieldsConfig/useClientApiFields.ts, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx |
7
8
  | [Client-Configurable Fields (useClientFields / fieldsConfig)](features/client-configurable-fields.md) | The mechanism for config that **varies by client** (or client × role) — field overrides, filter buttons, group-by options, column pickers, layout toggles — with | toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/fieldsConfig/useClientFields.ts, toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/, toga25-supply/src/pages/Inventory/viewModel/FIELDS/, toga25-supply/src/pages/Inventory/README.md, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/FIELDS/, toga25-supply/src/layout/VendorItemRecordModalLayout/viewModel/FIELDS/, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/FIELDS/ |
8
9
  | [Column Visibility (URL-driven show/hide columns)](features/column-visibility.md) | A "Columns" header button that opens a modal listing every column from the table meta, lets the user show/hide columns, adjusts the table live, and persists the | toga25-supply/src/components/ColumnVisibilityModal/, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx |
9
10
  | [Meta-Driven Page & Table Setup](features/meta-driven-table-data.md) | A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels, sections, ACL) and *table meta* (the columns/fields + table s | toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts, toga25-supply/src/hooks/useTablePageMeta.ts, toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts, toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts, toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts, toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts |
@@ -0,0 +1,136 @@
1
+ ---
2
+ title: Client API-Fetch Fields (useClientApiFields / apiFields.json)
3
+ framework: "2.0"
4
+ repo: toga25-supply
5
+ project: TOGa 2.5 Supply
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-20
10
+ owners: [apeterson]
11
+ files:
12
+ - toga25-supply/src/fieldsConfig/resolveApiConfig.ts
13
+ - toga25-supply/src/fieldsConfig/useClientApiFields.ts
14
+ - toga25-supply/src/fieldsConfig/index.ts
15
+ - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/FIELDS/apiFields.json
16
+ - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx
17
+ - toga25-supply/src/layout/ItemRecordModalLayout/viewModel/FIELDS/apiFields.json
18
+ - toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx
19
+ related:
20
+ - client-configurable-fields.md
21
+ - surface-frontend.md
22
+ ---
23
+
24
+ ## What it is
25
+
26
+ The mechanism that resolves a client's **API-fetch projection** — which `fields` / `ojoin` /
27
+ `join` / `where` to request from the TOGa API for a given page — from **one colocated
28
+ `apiFields.json` file per page**, keyed by client slug. It is the fetch-projection sibling of
29
+ [client-configurable-fields](client-configurable-fields.md) (`useClientFields`): that hook
30
+ resolves **presentation** config (now largely moving to the DB-backed
31
+ [Surface layer](surface-frontend.md)); this hook resolves **what data to fetch**.
32
+
33
+ The split exists because presentation config migrated to Surface, leaving the API-fetch wiring
34
+ coupled to a now-legacy `orderViewFields`/`*RecordViewFields` blob (formerly the
35
+ `salesOrderDetailsConfig` / `itemRecordDetailsConfig` keys). This mechanism extracts the fetch
36
+ projection into its own scalable per-page file + a shared resolver, keeping the central
37
+ `fieldsConfig/index.ts` registry lean — **each page owns its own `apiFields.json`** rather than
38
+ folding every page's fetch config into `fieldsConfig/index.ts`.
39
+
40
+ ## How it works
41
+
42
+ Two new files in `src/fieldsConfig/`, re-exported from `fieldsConfig/index.ts`:
43
+
44
+ 1. **`apiFields.json` (per page, colocated in the page's `viewModel/FIELDS/`).** A map keyed by
45
+ client slug (uppercase — `DEFAULT`, `COMPASS`, `COMPASSCANADA`, `NYCHH`, …). The `DEFAULT`
46
+ block is the **full base**. Each per-client block is one of two shapes:
47
+ - **Delta** — `{ "extends": "DEFAULT", ...only the differences }`, merged over its parent.
48
+ Use for the common 1–3 field tweak; adding a field to `DEFAULT` then reaches every client.
49
+ - **Full override** — a complete config with **no `extends`**, returned verbatim. Use when a
50
+ client diverges substantially (many fields / different joins / different `where` clauses).
51
+
52
+ 2. **`resolveApiConfig(map, clientSlug)`** — a pure, recursive deep-merge resolver:
53
+ - **Array fields** (`fields`, `ojoin`, `join`) support `add<Field>` / `remove<Field>` sugar
54
+ (`addFields`, `removeFields`, `addOjoin`, …) to tweak the parent's array, **or** you restate
55
+ the array literally to replace it wholesale (a literal wins over add/remove).
56
+ - **Nested objects** merge recursively; **scalars / new keys** replace the parent value.
57
+ - **Array element identity** for dedupe/removal: strings by value, join objects by their
58
+ **first object key** (e.g. `{ "Items": {...} }` → `Items`), else by JSON form. Results are
59
+ deduped.
60
+ - Supports **chained `extends`** (a client can extend another client) with a **cycle guard**
61
+ (a re-visited slug returns its own delta un-merged). An **unknown client falls back to
62
+ `DEFAULT`**; a missing `DEFAULT` yields `{}`.
63
+
64
+ 3. **`useClientApiFields(map)`** — reads the client slug via
65
+ `useHostnameStore.getState().getHostname()` (the **same** slug source as `useClientFields`),
66
+ then `useMemo`s `resolveApiConfig(map, slug)` so consumers get a **stable object reference**
67
+ and the react-query fetch cache key does not churn every render. API-fetch fields are
68
+ **client-only** — there is no role dimension (they describe *what to fetch*, not a per-role
69
+ view).
70
+
71
+ The view model consumes it directly:
72
+
73
+ ```ts
74
+ const salesOrderDetailsConfig = useClientApiFields(apiFields) as Parameters<
75
+ typeof useFetchSalesOrderRecord
76
+ >[1];
77
+ ```
78
+
79
+ ## Hybrid dedup model (decision)
80
+
81
+ The per-client shape is deliberately a **hybrid**: `DEFAULT` base + per-client delta, with a
82
+ full-override escape hatch. Chosen over full-copy-per-client because differences are usually
83
+ 1–3 fields (the delta case, kept DRY so a field added to `DEFAULT` reaches every client) but can
84
+ be 5+ fields plus different joins/where clauses (the full-override case). Full-copy-per-client
85
+ drifts too fast across 6+ clients. The resolver location is likewise a decision: a **generic
86
+ colocated hook** (per-page `apiFields.json` + one shared hook), **not** folding every page's
87
+ fetch config into the central `fieldsConfig/index.ts`.
88
+
89
+ ## Adopting the pattern on a page
90
+
91
+ 1. Add `viewModel/FIELDS/apiFields.json`: a `DEFAULT` full base, plus a block per client that
92
+ diverges (`extends: "DEFAULT"` + `add<Field>`/`remove<Field>` deltas, or a no-`extends` full
93
+ override). A client whose config is byte-identical to `DEFAULT` can be **omitted** — the
94
+ resolver's DEFAULT fallback covers it.
95
+ 2. Wire the view model to `useClientApiFields(apiFields)`, casting to the fetch hook's param type
96
+ (`Parameters<typeof useFetch...>[1]`).
97
+ 3. Delete the now-dead fetch block from the legacy presentation JSON (single source of truth).
98
+
99
+ **Adopted so far:**
100
+ - **SalesOrder record modal** — `SalesOrderRecordModalLayout/viewModel/FIELDS/apiFields.json`
101
+ (DEFAULT full base; COMPASS / COMPASSCANADA as `extends: DEFAULT` + `addFields` deltas, e.g.
102
+ `_totalLease`, `c_erpEntityId`). The view model's `salesOrderDetailsConfig` now comes from
103
+ `useClientApiFields(apiFields)` instead of `tenantFields.salesOrderDetailsConfig`; the dead
104
+ `salesOrderDetailsConfig` block was removed from `FIELDS/DEFAULT/orderViewFields.json`.
105
+ - **Item record modal** — `ItemRecordModalLayout/viewModel/FIELDS/apiFields.json` (DEFAULT full
106
+ base of 31 fields; COMPASS + COMPASSCANADA as `extends: DEFAULT` + a 6-field `addFields` delta:
107
+ `_leasePrice`, `_leasePrice12Months`, 4× `personaItems.*`; **NYCHH omitted** — byte-identical to
108
+ DEFAULT). The view model consumes `useClientApiFields(apiFields)` (dropping its own
109
+ `useClientFields`; view sections still read `useClientFields` in the view components). The dead
110
+ `itemRecordDetailsConfig` block was removed from all four client
111
+ `itemRecordViewFields.json` files, which now hold only presentation keys
112
+ (`header`/`sections`/`editItem`).
113
+
114
+ ## Gotchas
115
+
116
+ - **Field order in a `fields` projection array is irrelevant to the TOGa API fetch.** The API
117
+ returns requested fields regardless of order, and the reshape/view logic reads by key — so
118
+ append-style delta merges (added fields land at the end) are **safe** and do not change
119
+ behavior. This is why `add<Field>` sugar can append without needing positional control.
120
+ - **Omit a client that matches `DEFAULT`.** Don't author a byte-identical client block — the
121
+ resolver falls back to `DEFAULT` for any unknown/absent client, so an identical block is pure
122
+ drift risk (NYCHH on the Item modal is deliberately absent).
123
+ - **Same slug source as presentation.** The slug comes from `useHostnameStore.getHostname()` (the
124
+ same source as `useClientFields`); don't introduce a second slug resolution path.
125
+
126
+ ## Change history
127
+ - 2026-07-20 — Built the client-based API-fetch fields mechanism: `resolveApiConfig` (pure
128
+ recursive deep-merge with `add`/`remove` array sugar, literal-replace, chained `extends` +
129
+ cycle guard, DEFAULT fallback) + `useClientApiFields` (client-only, slug via `useHostnameStore`,
130
+ memoized for stable fetch cache keys), each page owning a colocated `apiFields.json`. Adopted on
131
+ the SalesOrder and Item record modals; removed the dead `salesOrderDetailsConfig` /
132
+ `itemRecordDetailsConfig` fetch blocks from the legacy presentation JSON. Decision: hybrid
133
+ DEFAULT-base + per-client-delta with a full-override escape hatch (over full-copy-per-client).
134
+ Discovered: `fields`-array order is irrelevant to the API fetch. (apeterson)
135
+ </content>
136
+ </invoke>
@@ -22,6 +22,7 @@ related:
22
22
  - ../architecture.md
23
23
  - column-visibility.md
24
24
  - action-button-rule-engine.md
25
+ - client-api-fetch-fields.md
25
26
  ---
26
27
 
27
28
  ## What it is
@@ -31,6 +32,14 @@ filter buttons, group-by options, column pickers, layout toggles — without har
31
32
  the view model or rebuilding per client. All such config resolves through `src/fieldsConfig`
32
33
  and the `useClientFields()` hook.
33
34
 
35
+ > **Scope — presentation, not fetch.** This doc covers **presentation/UI** config. A client's
36
+ > **API-fetch projection** (which `fields`/`ojoin`/`join`/`where` to request) now lives in a
37
+ > separate per-page `apiFields.json` resolved by `useClientApiFields` — see
38
+ > [client-api-fetch-fields](client-api-fetch-fields.md). The old fetch keys
39
+ > (`salesOrderDetailsConfig` in `orderViewFields`, `itemRecordDetailsConfig` in
40
+ > `itemRecordViewFields`) were **extracted out** of these presentation JSON files; those files
41
+ > now hold presentation keys only.
42
+
34
43
  ## How it works
35
44
 
36
45
  1. **Per-client JSON lives next to the consuming page _or layout_**, under `FIELDS/<CLIENT>/`
@@ -159,6 +168,11 @@ modal's chrome from the same per-client JSON, not from the component:
159
168
  `vendorItemRecordViewFields` are record-modal-layout examples living under `src/layout/.../viewModel/FIELDS/`.
160
169
 
161
170
  ## Change history
171
+ - 2026-07-20 — Extracted the **API-fetch projection** out of these presentation JSON blobs into a
172
+ new per-page `apiFields.json` + `useClientApiFields` mechanism (see
173
+ [client-api-fetch-fields](client-api-fetch-fields.md)). The `salesOrderDetailsConfig` block was
174
+ removed from `FIELDS/DEFAULT/orderViewFields.json` and the `itemRecordDetailsConfig` block from
175
+ all four `itemRecordViewFields.json` files; those files now hold presentation keys only. (apeterson)
162
176
  - 2026-07-01 — De-roled `orderViewFields` from client×role to **client-only**: COMPASS/COMPASSCANADA
163
177
  now read a flat client JSON via `mergeOrderView(...)` in `fieldsConfig/index.ts` (COMPASSCANADA's
164
178
  JSON flattened from byte-identical `{ADMIN,MANAGER}`). Fixed a latent bug where COMPASS's
@@ -4,7 +4,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
4
4
 
5
5
  ## 1.0 framework
6
6
 
7
- - **library** (Library) _(framework core)_ — 12 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 13 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 15 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
10
10
  - **togadesk** (TOGa Desk) — 9 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
@@ -17,7 +17,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
17
17
 
18
18
  ## 2.0 framework
19
19
 
20
- - **_underscore** (_Underscore) _(framework core)_ — 34 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
20
+ - **_underscore** (_Underscore) _(framework core)_ — 35 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
21
  - **worker2** (Worker) — 30 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
22
  - **api2** (API) — 12 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
23
23
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -29,7 +29,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
29
29
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
30
30
  - **ai-bdr** (AI-BDR) — 7 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
31
31
  - **toga2-commerce** (TOGa Commerce) — 9 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
32
- - **toga25-supply** (TOGa 2.5 Supply) — 9 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
32
+ - **toga25-supply** (TOGa 2.5 Supply) — 10 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
33
33
  - **toga-blox** (TOGa Blox) — 7 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
34
34
  - **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
35
35
 
@@ -11,6 +11,7 @@ apps:
11
11
  - worker1.5
12
12
  - worker2
13
13
  - dbchanges2
14
+ - library
14
15
  project: _Underscore
15
16
  client: compass-usa
16
17
  type: profile
@@ -88,4 +89,10 @@ separate, related client (see its own profile).
88
89
  order. Contrast Quad, which gets the full picked/packed machine. See
89
90
  [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
90
91
  - Built on the shared 2.0 Recursive Item Fulfillments engine (upstream mirroring).
92
+ - **`Items.isFulfillable` gates the storefront Qty Fulfilled cell (2026-07).** Sourced from
93
+ NetSuite onto the Agilant source item during the 1.0 item sync
94
+ ([Phase 1](../../1.0/apps/library/features/netsuite-item-isfulfillable-sync.md)) and propagated
95
+ up the SO↔PO chain to the client-facing Compass item by an api2 interceptor
96
+ ([Phase 2](../../2.0/apps/_underscore/features/fulfillable-item-propagation.md)). Verified on prod
97
+ Compass chains; a one-time July-5+ backfill re-PUTs source items through the same path.
91
98
  - This profile is a starting point; expand as more Compass-specific behavior is captured.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.375",
3
+ "version": "1.0.377",
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",