toga-ai 1.0.835 → 1.0.836

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,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-15
9
+ updated: 2026-09-17
10
10
  owners: ["dfranks", "bala", "jcardinal", "mhammontree", "snaredla", "rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
@@ -24,6 +24,7 @@ files:
24
24
  - dbchanges2/Client/2026-09-02b - NetsuiteSyncCursorRename.sql
25
25
  - test/@srija/Elite Testing/Service Requests/test_sync_togasupply_elite_section.php
26
26
  - test/@srija/Elite Testing/Service Requests/test_diagnose_togasupply_elite.php
27
+ - dbchanges2/_modules/netsuite/2026-09-17a - StaleSalesOrderRetirementAcl.sql
27
28
  related:
28
29
  - ../architecture.md
29
30
  - ../../../2.0/apps/_underscore/features/shipping-carriers-and-accounts.md
@@ -34,6 +35,8 @@ related:
34
35
  - ../../../clients/elite/features/netsuite-togasupply-sync.md
35
36
  - ../../library/features/netsuite-item-class-sync.md
36
37
  - ../../../clients/compass-usa/features/sales-order-line-renumbering.md
38
+ - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
39
+ - ../../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md
37
40
  ---
38
41
 
39
42
  ## Summary
@@ -711,6 +714,123 @@ nested count:
711
714
  line the fulfillment referenced, the match failed, and the section fail-loud-froze. The flat list
712
715
  replaces the truncated nested one before match/reconstruct.
713
716
 
717
+ ### Retiring the stale SalesOrder a reclassified TransferOrder leaves behind (2026-09-17, NOT YET DEPLOYED)
718
+
719
+ **Turning transfer-order detection on for an existing client strands every order it already
720
+ imported as a SalesOrder.** The same NetSuite order now syncs to a `TransferOrder`, but the old
721
+ `SalesOrder` is never cleaned up. Its lines still carry fulfillments, so inventory **double-counts**
722
+ and `Items._qtyOnHand` goes negative. Measured on Elite: **218** SalesOrders duplicate a
723
+ TransferOrder on `c_netsuiteInternalSalesOrderId`, and **217 of 217** matched pairs are identical on
724
+ order number, line count *and* total quantity.
725
+
726
+ `App_Api_Toga2::retireStaleSalesOrderForTransferOrder()` (new, `private static`) is called at the
727
+ **END** of `syncTransferOrderFromNetsuite()` so the TO and its lines exist first. Per SO line:
728
+
729
+ 1. move `ItemFulfillmentItems`: `salesOrderItemId` → `transferOrderItemId`,
730
+ 2. move `PurchaseOrderItems_SalesOrderItems` → `PurchaseOrderItems_TransferOrderItems`,
731
+ 3. `DELETE` the `SalesOrderItem`;
732
+
733
+ then move `SalesOrders_PurchaseOrders` → `PurchaseOrders_TransferOrders` and `DELETE` the
734
+ `SalesOrder`. **Move before delete, always** — the bridge FKs are `RESTRICT`.
735
+
736
+ **The two fulfillment-parent columns are mutually exclusive, which is what makes the move safe.**
737
+ Prod check: 391 rows TO-side, 293 SO-side, **0 with both, 0 with neither**.
738
+
739
+ **It throws — never skips — on:** more than one SalesOrder for the NetSuite id; a TO with no
740
+ lines; an SO line with no matching TO line; a line carrying `InvoiceItems` (**`InvoiceItems` has no
741
+ `transferOrderItemId`**, so they physically cannot be moved); or any bridge whose existence check
742
+ returns no `totalRecordCount` (see the ACL shape below). Consistent with the 2026-08-27 fail-loud
743
+ policy.
744
+
745
+ **Bridge direction is PO-FIRST, confirmed from NYCHH data rather than guessed.** The naming rule is
746
+ `<source>_<created from it>`. `Client_Nychh` populates `PurchaseOrderItems_TransferOrderItems` (98
747
+ rows) and `PurchaseOrders_TransferOrders` (45); the TO-first siblings are **0 in every client**. Same
748
+ rule as the [SO↔PO bridge direction doc](../../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md).
749
+
750
+ **⚠ It needs the three-layer ACL grant first.** Without `AclFieldPermissions` on records 29/325/329
751
+ the bridge existence checks return **200 + `WZ-1` with no `meta.totalRecordCount`** and the method
752
+ throws while blaming record ACL — the exact trap documented on the
753
+ [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md). The
754
+ fleet-wide grant is `dbchanges2/_modules/netsuite/2026-09-17a - StaleSalesOrderRetirementAcl.sql`.
755
+
756
+ **Open, carried forward:**
757
+ - Elite SalesOrder **240734** has 4 `InvoiceItems` and **will throw** on retirement. Deliberate
758
+ fail-loud; it blocks the backfill until resolved by hand.
759
+ - **11 of the 218** stale SalesOrders have no matching TransferOrder (4 are TOGa-created `SA1000xx`
760
+ with no NetSuite id). Manual review.
761
+ - `getTransferOrderItemsByUuid()` (`toga2.php` ~L4555) has **no pagination** — it caps at
762
+ `recordsPerPage` 1000, unlike its paginated sibling `getSalesOrderItemsBySalesOrderUuid()`. It is
763
+ load-bearing for this method's safety. Skipped for now because Elite TOs are 1–5 lines; flagged by
764
+ php-reviewer.
765
+
766
+ ### ⚠ TWO code paths create `PurchaseOrderItems` — only one stamped `fulfillmentType` (fixed 2026-09-17)
767
+
768
+ `fulfillmentType` was stamped by `syncPurchaseOrderFromNetsuite()` (from the order-level
769
+ `isDropShipPurchaseOrder`) but **not** by the **customer-PO block inside
770
+ `syncSalesOrderFromNetsuite()`**, at three POST sites. Those customer POs are built from the NetSuite
771
+ sales order's `otherRefNum` against the synthetic Agilant vendor, so they carry a `customerId` but a
772
+ **NULL `c_netsuiteInternalPurchaseOrderId`** — which is how to spot them. **1,038 of 1,113** NULL
773
+ `fulfillmentType` PO lines came from this path, so **no cursor rewind would ever have fixed them**;
774
+ the rewind only replays the other path.
775
+
776
+ Fix: stamp `fulfillmentType` (inherited from the SO line, exactly as `cost` already is) at all three
777
+ create sites, plus a **backfill PUT on the matched-existing-line branch** so pre-existing rows fill
778
+ in on the next sync. Gated by `isset()` on the payload key so non-opted-in clients send nothing, and
779
+ the backfill uses `property_exists` (not `??`) per this file's established ACL-omission rule — an
780
+ ungranted field is **absent**, not null.
781
+
782
+ **Lesson: before concluding "the backfill is lagging", grep for every writer of the column.** A
783
+ second, un-stamped create path looks identical to a stale cursor from the data side.
784
+
785
+ ### ⚠ A stale child list read at run-start causes FK 1451 on a delete LATER IN THE SAME RUN (fixed 2026-09-17)
786
+
787
+ `NETSUITE_EXECUTION_MODE_ITEM_FULFILLMENTS` decayed `864000 → 1186 → 396`, stuck `RUNNING`, with one
788
+ item (`b96515b3`) failing five consecutive runs ~every 5 minutes. **Not self-correcting.**
789
+
790
+ ```
791
+ EV-11 ... Error #: 1451 Cannot delete or update a parent row: a foreign key constraint fails
792
+ (Client_Elite.ItemFulfillmentItems_TrackingNumbers ...)
793
+ ```
794
+
795
+ **Root cause is ordering inside a single run, not stale data between runs.** The delete reads an
796
+ item's tracking rows from `$itemFulfillment`, fetched at the **top** of the run — but the tracking
797
+ pass **later in the same run** creates new tracking rows against those same items. The run-start
798
+ snapshot misses them, the child delete skips one, and the item delete hits the FK. Prod proof:
799
+ tracking `0ddc9513` created at **03:31:18**, the item delete failed at **03:31:21** — three seconds
800
+ apart.
801
+
802
+ Fix, applied at **both** item-level delete sites: **re-read the item's tracking rows live**
803
+ (`GET /v2/item-fulfillment-item-tracking-numbers` joined on `ItemFulfillmentItems.uuid`) immediately
804
+ before deleting, instead of trusting the run-start snapshot — the same re-fetch pattern this file
805
+ already uses for the single-tracking-number fan-out. A `totalRecordCount` guard was added to both.
806
+
807
+ **Scope was confirmed, not assumed:** every FK-1451 failure that day was on
808
+ `/v2/item-fulfillment-items/` — **zero** on units or tracking rows — so the unit-level lists were
809
+ deliberately left alone.
810
+
811
+ The exception was **not** swallowed. `send()` throws by default; the throw aborted the run and left
812
+ the mode `RUNNING`. That is the designed fail-loud behaviour working correctly.
813
+
814
+ **General rule for this engine: any list used to delete children must be re-read immediately before
815
+ the delete if ANY later pass in the same run can create more of them.** A run-start snapshot is only
816
+ safe for read-only use.
817
+
818
+ ### ⚠ NULL `fulfillmentType` on SO lines can be CORRECT ROUTING, not a sync bug (2026-09-17)
819
+
820
+ "684 `SalesOrderItems` have NULL `fulfillmentType`" looked like a defect and was not. Ruled out in
821
+ order — record these so nobody re-walks them:
822
+
823
+ 1. backfill lag — no,
824
+ 2. the `property_exists` gate — no,
825
+ 3. ACL on the field — grants exist (`recordFieldId` **2594/2595**, roleId 3, `isWritable = 1`),
826
+ 4. a cursor timezone theory — **wrong**; the code correctly uses `America/New_York`.
827
+
828
+ **Actual reason:** those orders are now classified as **Transfer Orders** (`$0` + `holdInvoice`), so
829
+ the sync routes them to `syncTransferOrderFromNetsuite()` and they never reach the sales-order path
830
+ that stamps the field. The rows are stranded leftovers — exactly what the retirement method above
831
+ cleans up. **When a per-line field is NULL on a set of orders, check the routing branch before the
832
+ writer.**
833
+
714
834
  ### Custom field contract (hardcoded in `common_sync_togasupply.php`)
715
835
 
716
836
  The engine requests these `c_` fields by literal name per route — every synced client must have
@@ -1653,6 +1773,24 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
1653
1773
  [NetSuite Sync Alert Monitor](../../library/features/netsuite-sync-alert-monitor.md).
1654
1774
 
1655
1775
  ## Change history
1776
+ - 2026-09-17 — Four importer changes, all found on Elite. (1) New
1777
+ `App_Api_Toga2::retireStaleSalesOrderForTransferOrder()` retires the stale `SalesOrder` left
1778
+ behind when an order is reclassified as a TransferOrder — 218 Elite duplicates were
1779
+ double-counting inventory and driving `_qtyOnHand` negative; it moves fulfillments and PO bridges
1780
+ to the TO side before deleting, and throws on `InvoiceItems` (no `transferOrderItemId` exists).
1781
+ Bridge direction confirmed **PO-first** from NYCHH row counts. **NOT yet deployed.** (2) The
1782
+ customer-PO block inside `syncSalesOrderFromNetsuite()` never stamped `fulfillmentType` at its
1783
+ three POST sites — 1,038 of 1,113 NULL PO lines came from that second writer, so no cursor rewind
1784
+ could fix them; added the stamp plus a backfill PUT on the matched-line branch. (3) ITEM_FULFILLMENTS
1785
+ froze on **FK 1451 / EV-11** because the tracking-row list was read at run start while a later pass
1786
+ in the **same run** created more rows; both item-level delete sites now re-read tracking live before
1787
+ deleting. (4) Recorded that a NULL per-line `fulfillmentType` can be correct **routing** (the order is
1788
+ now a Transfer Order) rather than a sync bug, with the four dead ends already ruled out. Also noted the
1789
+ PO-sync unfreeze needed a three-layer ACL grant — the field layer fails as a silent 200, see the
1790
+ [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md).
1791
+ php-reviewer + silent-failure-hunter clean; one open warning (`getTransferOrderItemsByUuid()` has no
1792
+ pagination). (rgirish)
1793
+
1656
1794
  - 2026-09-15 — Added the **full resync recipe** for a deep cursor rewind (the procedure for
1657
1795
  backfilling a newly-stamped item field): the six `NETSUITE_LAST_SYNC_CURSOR_<SECTION>` keys live in
1658
1796
  the **client** DB, the id half **must be `0`** or the seek skips records at the same datetime, the
@@ -17,6 +17,7 @@ files:
17
17
  - dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql
18
18
  - dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql
19
19
  - dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql
20
+ - dbchanges2/_modules/netsuite/2026-09-17a - StaleSalesOrderRetirementAcl.sql
20
21
  ---
21
22
 
22
23
  ## Summary
@@ -127,6 +128,92 @@ Two distinct failure shapes, and the second is a page-killer:
127
128
  endpoint takes the **entitlement** uuid precisely to avoid requesting the joined
128
129
  `subscription.uuid` and betting the whole entitlements page on one more grant.
129
130
 
131
+ ### ⚠⚠ Record access + ZERO readable fields = HTTP **200**, `WZ-1`, and **NO `meta.totalRecordCount`**
132
+
133
+ The third layer of the chain fails in a shape nothing else does, and it costs hours because
134
+ **nothing lands in the API error log — the call succeeded.**
135
+
136
+ With `AclRecordPermissions` + expression + logic group all correct, but **no `AclFieldPermissions`
137
+ rows at all** for that role × record, a GET returns:
138
+
139
+ - HTTP **200** (not 403, not `EZ-2`),
140
+ - message **`WZ-1: "There are no fields in which you are authorized to read"`**,
141
+ - and **`meta.totalRecordCount` is OMITTED entirely** from the envelope.
142
+
143
+ That last point is the killer. Fail-loud importer code commonly guards on
144
+ `if (!isset($response->meta->totalRecordCount)) { throw ... }` as its "the existence check did not
145
+ run" test — so a pure field-layer gap surfaces as that throw, with a message blaming record ACL,
146
+ while the record ACL is in fact fine.
147
+
148
+ **Distinguish the three failure shapes before touching any grant:**
149
+
150
+ | Shape | Missing layer |
151
+ |---|---|
152
+ | 403 `EZ-1` / `No ACL Logic Groups defined for ACL Record Permissions` | record permission or logic group |
153
+ | 403 `EZ-2` naming specific fields (incl. a `uuid` you never asked for) | *some* field grants exist, the named one does not |
154
+ | **200 + `WZ-1` + no `meta.totalRecordCount`** | **`AclFieldPermissions` is completely empty for that role × record** |
155
+
156
+ **Worked case (Elite, 2026-09-17).** Elite's NetSuite PO sync was dead for days:
157
+ `NETSUITE_EXECUTION_MODE_PURCHASE_ORDERS` sat at `1-RUNNING` with 51 lifetime occurrences of
158
+ `linkPurchaseOrderToTransferOrder: PurchaseOrders_TransferOrders existence check returned no
159
+ totalRecordCount (API role likely lacks ACL read/write on this bridge)`. It was **misdiagnosed
160
+ twice**: the first pass added `AclRecordPermissions` for records 29/325/329 plus `allowDelete` on
161
+ 14/15, verified all five rows present with logic groups — and it still failed, because
162
+ `Client_Elite` had **zero** roleId 3 rows in `AclFieldPermissions` for those bridges.
163
+ `Client_Nychh` had all six, which is exactly why linking worked there.
164
+
165
+ The working set that fixed it (mirrors `Client_Nychh`):
166
+
167
+ | `Core.Records` id | route | granted `RecordFields` (roleId 3) |
168
+ |---|---|---|
169
+ | **325** | `purchase-orders-transfer-orders` | 2210 `uuid`, 2211 `purchaseOrderId`, 2212 `transferOrderId` |
170
+ | **329** | `purchase-order-items-transfer-order-items` | 2226 `uuid`, 2227 `purchaseOrderItemId`, 2228 `transferOrderItemId` |
171
+ | **29** | `item-fulfillment-items` | 366 `uuid`, 367 `itemFulfillmentId`, 368 `salesOrderItemId`, 369 `quantity`, 2151 `transferOrderItemId` |
172
+
173
+ **`id` (2209 / 2225 / 365) is deliberately NOT granted** — NYCHH does not grant it either, and the
174
+ sync joins on `uuid` throughout precisely because the numeric `id` is not ACL-readable. Do not "fix"
175
+ its absence.
176
+
177
+ Result after deploy: `PurchaseOrders_TransferOrders` went **0 → 17** rows and
178
+ `PurchaseOrderItems_TransferOrderItems` **0 → 28** (populated for the first time ever); the PO mode
179
+ recovered to `864000-IDLE` and the cursor moved 2026-02-03 → 2026-03-28.
180
+
181
+ **Rule: when you add a record grant, add the field grants in the same migration.** A record
182
+ permission with no field permissions is not a partial grant — it is a grant that reads as a
183
+ successful, empty, count-less response.
184
+
185
+ ### ⚠ A multi-client ACL migration must SELF-HEAL — every tenant is broken differently
186
+
187
+ Do not write a fleet-wide ACL migration as "insert the rows the reference client has." Surveying
188
+ five `netsuite` clients on 2026-09-17 found **five different broken states** for the same set of
189
+ records:
190
+
191
+ | Tenant | State found |
192
+ |---|---|
193
+ | **NYCHH** | complete and correct — the reference |
194
+ | **Elite** | records 325/329 missing entirely |
195
+ | **GroWrk** | 325/329 missing **and** record 15 has a permission with **zero logic groups** (silently denies) |
196
+ | **Prudential** | 325/329 exist **twice each** (duplicate permission rows), no expressions, no logic groups |
197
+ | **Canon, Quad** | only 14/15, both with `allowDelete = 0` |
198
+
199
+ The six-step shape that survives all of them — every step `NOT EXISTS`-guarded and re-runnable:
200
+
201
+ 1. `UPDATE` `allowDelete = 1` on the records that already have permissions (14/15).
202
+ 2. `INSERT` `AclRecordPermissions` for the missing records (29/325/329).
203
+ 3. `INSERT` `AclRecordExpressions` `'all'` for **all five** records — not just the new three.
204
+ Prudential already had permissions on 325/329 with **no expression**.
205
+ 4. `INSERT` `AclLogicGroups` for **any** permission lacking one — this is what catches GroWrk's
206
+ orphan permission and Prudential's duplicates, which a "new records only" migration walks past.
207
+ 5. `INSERT` `AclLogicGroupExpressions` joining on **`MIN(id)` per record**, not on the expression
208
+ slug. Prudential has **three** `'all'` expressions on record 14; joining by slug alone fans out
209
+ into a cross product.
210
+ 6. `INSERT` `AclFieldPermissions` — the layer the first attempt missed (see the section above).
211
+
212
+ **The `MIN(id)` and orphan-logic-group guards came from real production rows, not from theory.**
213
+ Before writing a fleet migration, survey the tenants and let the worst state design the guards.
214
+ Applies to every module that ships to many clients (this one goes to all **22** clients carrying
215
+ `netsuite` in `dbchanges2/_modules.txt`).
216
+
130
217
  ### ⚠ Resolve `Core.RecordFields` ids by HARDCODED LITERAL in a `Client_*` migration — the subselect cannot run
131
218
 
132
219
  This **overrides** the general "resolve by subselect, never hardcode" rule *for Core lookups from a
@@ -771,6 +858,16 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
771
858
  and every repo is on the **same branch** so the generated model matches the DB.
772
859
 
773
860
  ## Change history
861
+ - 2026-09-17 — Added the **field-layer silent-200** failure shape: record access with **zero**
862
+ `AclFieldPermissions` rows returns HTTP **200** carrying `WZ-1` and **omits**
863
+ `meta.totalRecordCount`, so fail-loud code that guards on that key throws while blaming record
864
+ ACL — and nothing appears in the API error log because the call succeeded. Verified on
865
+ `Client_Elite` (records 29/325/329, roleId 3) after the bug was misdiagnosed twice as a record
866
+ permission gap; recorded the exact `RecordFields` granted (and that `id` is deliberately not).
867
+ Also added the **self-healing fleet migration** rule — a survey of five `netsuite` tenants found
868
+ five different broken states (missing rows, an orphan logic group, duplicate permissions,
869
+ `allowDelete = 0`), so a multi-client ACL migration must guard every step with `NOT EXISTS` and
870
+ join logic-group expressions on `MIN(id)` per record rather than on the expression slug. (rgirish)
774
871
  - 2026-09-17 — Added the **restrictive-expression-is-dead** rule: permissions are collected per role
775
872
  and **any** match authorizes, so a row-restricting expression on one role is cancelled by an `'all'`
776
873
  grant on another role the same user holds. Verified in `Client_Compass`: expression **235** on role
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-14
10
- owners: [snaredla, jcardinal, bala, apeterson]
9
+ updated: 2026-09-17
10
+ owners: [snaredla, jcardinal, bala, apeterson, rgirish]
11
11
  files:
12
12
  - _underscore/Model.php
13
13
  - _underscore/Model/Client/PurchaseOrder.php
@@ -21,6 +21,8 @@ files:
21
21
  - _underscore/Model/Quad/Item.php
22
22
  - _underscore/Model/Quad/VendorItem.php
23
23
  - _underscore/Model/Nychh/Unit.php
24
+ - _underscore/Model/Client/Item.php
25
+ - _underscore/Model/Elite/Item.php
24
26
  - dbchanges2/Client_Nychh/2026-09-10a - UnitsForPoTableViewRebaseAndTransferLocationField.sql
25
27
  - dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql
26
28
  - dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql
@@ -35,6 +37,7 @@ related:
35
37
  - ./sales-order-purchase-order-bridge-direction.md
36
38
  - ../../../../clients/nychh/features/location-shipping-addresses.md
37
39
  - ../../toga25-supply/features/transfer-orders-page.md
40
+ - ../../../clients/elite/features/inventory-quantities-drop-ship.md
38
41
  ---
39
42
 
40
43
  ## Summary
@@ -218,6 +221,26 @@ characters — that is the standard of evidence for touching a field that runs o
218
221
  > even for a client whose subclass overrides it. `_Model_Compass_SalesOrder` has exactly this in its
219
222
  > ApprovalDecision notification query. Late static binding only happens with `static::`.
220
223
 
224
+ ### ⚠ `_Model_Client_Item`'s four composites were `self::` until 2026-09-17 — 25 client models affected
225
+
226
+ A live instance of the `self::` trap above, now fixed, worth knowing because it silently changed
227
+ behaviour for every tenant.
228
+
229
+ `_Model_Client_Item`'s four **composite** calculated fields — `_qtyOnHand`, `_qtyAvailable`,
230
+ `_qtyBackordered`, `_qtyOnOrder` — built their SQL from the leaf `_qty*` methods using **`self::`**.
231
+ So a client subclass that overrode a *leaf* method (e.g. `_qtyReceived`) had the override **silently
232
+ ignored** by the inherited composites; the only way to make it stick was to re-declare all four
233
+ composites verbatim on the child. `_Model_Client_PurchaseOrderItem` already used `static::`.
234
+
235
+ Changed to **`static::`** on 2026-09-17, matching the PurchaseOrderItem model. **This affects all 25
236
+ client `Item` models:** any client already overriding a leaf method now has that override picked up
237
+ by the composites for the first time. **`Nychh` overrides `_qtyReceived` and `_qtyCommitted`, so it
238
+ is the one to watch.**
239
+
240
+ **Lesson for any shared base model:** write composites with `static::` from the start. A `self::`
241
+ composite does not fail — it quietly returns the parent's number, and the child's override looks
242
+ like it "did nothing."
243
+
221
244
  ## Filtering on a calculated field through the V2 API — it works, bare name only
222
245
 
223
246
  A calculated field **can** be used in a V2 `where`, and it is the cheapest way to cut a big list —
@@ -268,6 +291,14 @@ Keep the expression cheap, or expect callers to pay for it on every list.
268
291
  [save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
269
292
 
270
293
  ## Change history
294
+ - 2026-09-17 — Recorded a live instance of the `self::` trap: `_Model_Client_Item`'s four composite
295
+ fields (`_qtyOnHand`, `_qtyAvailable`, `_qtyBackordered`, `_qtyOnOrder`) used **`self::`**, so a
296
+ client subclass overriding a *leaf* `_qty*` method was silently ignored unless it re-declared all
297
+ four composites. Switched to **`static::`**, matching `_Model_Client_PurchaseOrderItem`. This
298
+ changes behaviour for **all 25 client `Item` models** — `Nychh` (overrides `_qtyReceived` and
299
+ `_qtyCommitted`) is the one to watch. Found while adding Elite's drop-ship exclusion; see
300
+ [Elite inventory quantities](../../../clients/elite/features/inventory-quantities-drop-ship.md).
301
+ (rgirish)
271
302
  - 2026-09-14 — Added a **Filtering** section: a calculated field IS usable in a V2 `where` with the
272
303
  bare (non-table-prefixed) name, which contradicts a belief written into several frontend repos.
273
304
  Mechanism and cost figures cross-referenced to the api2 query contract rather than duplicated.
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-14
9
+ updated: 2026-09-17
10
10
  owners: [tcox, bala, apeterson, jcardinal, rgirish]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -469,6 +469,25 @@ ACL gate for calculated fields lives at **:7216** in `getFullModelData()` — wh
469
469
  - **`meta.prohibitedRecordCount`** (set only when `totalRecordCount` is 0) distinguishes
470
470
  "ACL hid every row" from "there genuinely are none."
471
471
 
472
+ ## Bulk DELETE by `where` EXISTS — and you should not use it
473
+
474
+ A `DELETE` with **no uuid** plus a `where` clause is fully implemented: `V2.php:5554` is the branch,
475
+ `:5697` builds a `SELECT id` with ACL applied, `:5740` loops the ids and calls `$model->delete()`.
476
+ It returns `meta.deletedRecordCount`. Verified 2026-09-17.
477
+
478
+ **Three reasons it was rejected for the 1.0 NetSuite importer, and the third is decisive:**
479
+
480
+ 1. **Zero callers anywhere in the codebase.** All 20 deletes in `library/app/api/toga2.php` are by
481
+ uuid. There is no proven path.
482
+ 2. **It is not atomic.** It loops rows, collects per-row errors, and does **no rollback** — a partial
483
+ failure still returns `200`.
484
+ 3. **⚠ Zero matching rows sets `$isPermitted = false` (`V2.php:5732`) and returns an
485
+ UNAUTHORIZED-shaped error.** "Nothing to delete" is indistinguishable from "you may not delete
486
+ this", so any fail-loud caller will misread a clean no-op as an **ACL gap** and go hunting through
487
+ `AclRecordPermissions` for a bug that does not exist.
488
+
489
+ If you need the behaviour, loop uuids client-side. Point 3 is the durable, non-obvious fact.
490
+
472
491
  ## Serialization quirks
473
492
 
474
493
  - **Datetimes on a primary table** serialize as ISO-8601 with a **Central offset** (no
@@ -521,6 +540,12 @@ ACL gate for calculated fields lives at **:7216** in `getFullModelData()` — wh
521
540
  [V2 request logging](request-logging.md).
522
541
 
523
542
  ## Change history
543
+ - 2026-09-17 — Documented that **bulk `DELETE` by `where` is implemented but unused and unsafe to
544
+ adopt**: the branch is real (`V2.php:5554` / `:5697` / `:5740`, returns `meta.deletedRecordCount`)
545
+ but has zero callers, is non-atomic with no rollback, and — the decisive part — **zero matching
546
+ rows sets `$isPermitted = false` (`:5732`) and returns an UNAUTHORIZED-shaped error**, so a clean
547
+ no-op reads as an ACL denial. Recorded after it was considered and rejected for the 1.0 NetSuite
548
+ importer's stale-SalesOrder retirement. No code changed. (rgirish)
524
549
  - 2026-09-14 — **Corrected a wrong team-wide belief: a `where` on a calculated (`FIELD_SQL`) field
525
550
  WORKS, with the BARE field name.** `_qtyAvailable:ge:1` is inlined into the `WHERE` and returns 200
526
551
  with a correct `totalRecordCount`; only the table-prefixed `Table._qtyAvailable` form is routed to a
@@ -19,7 +19,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
19
19
 
20
20
  ## 2.0 framework
21
21
 
22
- - **_underscore** (_Underscore) _(framework core)_ — 87 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
+ - **_underscore** (_Underscore) _(framework core)_ — 88 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
23
23
  - **worker2** (Worker) — 69 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
24
24
  - **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
25
25
  - **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -2,6 +2,7 @@
2
2
 
3
3
  | Doc | Framework | Summary |
4
4
  |-----|-----------|---------|
5
+ | [Elite inventory quantities — drop ship is excluded from On Hand / Available ONLY](features/inventory-quantities-drop-ship.md) | 2.0 | Elite's Inventory page showed **negative Qty On Hand / Qty Available** (worst: `SVC-CI-RETAINER-RS` at **-235**). |
5
6
  | [Elite — NetSuite → TOGa Supply inbound sync (TRUE-80499 onboarding)](features/netsuite-togasupply-sync.md) | 1.0 | Elite is the 18th client on the shared NetSuite → TOGa Supply importer ([engine](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md)). |
6
7
  | [Elite SalesOrder → NetSuite Push (postPost/postPut interceptors → worker2)](features/salesorder-netsuite-push.md) | 2.0 | Elite orders created in Toga are pushed into NetSuite **event-driven**, not on a cron. |
7
8
  | [Elite — Sales Order stage change posts a reply on the TOGa Desk (1.0) ticket](features/salesorder-status-togadesk-reply.md) | 2.0 | When an Elite sales order's **stage** changes, a reply is posted on the originating **TOGa Desk (1.0)** ticket so the requester sees progress where they raised |
@@ -0,0 +1,89 @@
1
+ ---
2
+ title: "Elite inventory quantities — drop ship is excluded from On Hand / Available ONLY"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: elite
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-09-17
10
+ owners: [rgirish]
11
+ files:
12
+ - _underscore/Model/Elite/Item.php
13
+ - _underscore/Model/Client/Item.php
14
+ related:
15
+ - ../profile.md
16
+ - ./netsuite-togasupply-sync.md
17
+ - ../../../2.0/apps/_underscore/features/calculated-sql-fields.md
18
+ - ../../nychh/features/transfer-order-inventory-quantities.md
19
+ - ../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ Elite's Inventory page showed **negative Qty On Hand / Qty Available** (worst:
25
+ `SVC-CI-RETAINER-RS` at **-235**). Cause: **drop-ship lines are fulfilled but never received into
26
+ stock**, so the base `received - fulfilled` arithmetic went negative.
27
+
28
+ The rule Elite runs on: **exclude drop ship from `_qtyOnHand` and `_qtyAvailable` ONLY.** Every
29
+ other quantity counts **all** lines — a drop-ship line really was ordered and really did ship.
30
+
31
+ ## How it works
32
+
33
+ Two `private static` helpers on `_Model_Elite_Item`, used **only** by the two composites:
34
+
35
+ | Helper | Used by |
36
+ |---|---|
37
+ | `qtyFulfilledStockOnly()` | `_qtyOnHand` |
38
+ | `qtyCommittedStockOnly()` | `_qtyAvailable` |
39
+
40
+ `_qtyFulfilled` and `_qtyCommitted` themselves stay **inherited and unfiltered** — the filtering
41
+ lives in the composites, not in the leaf fields, so anything reading the leaf number still sees the
42
+ true shipped/committed total.
43
+
44
+ **Quantities that count ALL lines (drop ship included):** `_qtyOrdered`, `_qtyFulfilled`,
45
+ `_qtyInvoiced`, `_qtyBackordered`, `_qtyOnOrder`.
46
+
47
+ ### Two deliberate decisions — do not "improve" either
48
+
49
+ - **No `GREATEST(..., 0)` floor.** Negatives must stay **visible** so the underlying data problem
50
+ can be traced. Flooring hides a real defect behind a clean-looking zero. This was decided
51
+ explicitly, after one iteration.
52
+ - **A NULL `fulfillmentType` is NOT treated as `STOCK`.** Only a line positively marked stock counts
53
+ toward on-hand. (NULL `fulfillmentType` has its own causes — see the
54
+ [importer doc](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).)
55
+
56
+ ### The base model had to change too (`self::` → `static::`)
57
+
58
+ `_Model_Client_Item`'s four composites (`_qtyOnHand`, `_qtyAvailable`, `_qtyBackordered`,
59
+ `_qtyOnOrder`) called their leaf methods with **`self::`**, which silently ignores a child override.
60
+ They were switched to **`static::`** in the same session. That is a **framework-level** change
61
+ affecting **all 25 client `Item` models** — full detail and the NYCHH watch item on
62
+ [FIELD_SQL calculated fields](../../../2.0/apps/_underscore/features/calculated-sql-fields.md).
63
+
64
+ ## Verification
65
+
66
+ Run against prod data after the change: **0 negatives on all 79 non-service items.** The **7**
67
+ remaining negatives are all `SVC-` service items (received 0, fulfilled > 0), which the developer
68
+ explicitly said to ignore.
69
+
70
+ ## Gotchas / known issues
71
+
72
+ - **The Inventory TableView still filters services by `(Items.partNumber:excludes:SVC-)`**, which
73
+ **misses 18 service items** that sit under service `ItemClasses` trees but carry normal part
74
+ numbers. Undecided and not implemented. Facts established while investigating:
75
+ - **`_isServiceClass` does not exist** anywhere in the code (grepped all of `Model/` and
76
+ `Trait/`) — the migration comment referencing it is **stale**.
77
+ - The table is **`ItemClasses`**, not `ItemClassifications`, and it is a **tree**
78
+ (`parentItemClassId`).
79
+ - Service roots are **Lifecycle Services (1)**, **Advisory and Modernization (22)**, **True
80
+ Partner Services (40)** — but **6 non-service items** sit under the latter two, so matching on
81
+ root alone is too broad.
82
+
83
+ ## Change history
84
+ - 2026-09-17 — Documented Elite's drop-ship exclusion rule: drop ship is removed from `_qtyOnHand`
85
+ and `_qtyAvailable` only, via two `private static` helpers, while every other `_qty*` counts all
86
+ lines; no `GREATEST` floor (negatives must stay visible) and NULL `fulfillmentType` is not treated
87
+ as stock. Verified 0 negatives on all 79 non-service items in prod. Also recorded the paired base
88
+ change from `self::` to `static::` on `_Model_Client_Item`'s composites, and the open
89
+ `partNumber:excludes:SVC-` TableView filter that misses 18 service items. (rgirish)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-15
9
+ updated: 2026-09-17
10
10
  owners: ["snaredla", "jcardinal", "rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/sync_togasupply_elite.php
@@ -22,6 +22,7 @@ files:
22
22
  - api2/Component/Api/V2/V2.php
23
23
  - dbchanges2/Client_Elite/_modules.txt
24
24
  - dbchanges2/Client_Elite/2026-09-04a - EliteItemAssetTypeApiWritePermission.sql
25
+ - dbchanges2/_modules/netsuite/2026-09-17a - StaleSalesOrderRetirementAcl.sql
25
26
  - library/app/api/toga2.php
26
27
  - library/app/api/netsuite/rest.php
27
28
  related:
@@ -36,6 +37,8 @@ related:
36
37
  - ../../growrk/features/transfer-order-flow.md
37
38
  - ../../../2.0/apps/api2/features/nested-relationship-writes.md
38
39
  - ../../../2.0/apps/dbchanges2/workflows/client-schema-drift-audit.md
40
+ - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
41
+ - ./inventory-quantities-drop-ship.md
39
42
  ---
40
43
 
41
44
  ## Summary
@@ -299,12 +302,72 @@ Records **15, 18, 21, 105 are all `aclDatabase = CLIENT`**, so every one of thes
299
302
  logic groups on id 346, from the unguarded `2026-09-11c` migration. Harmless, but see the
300
303
  [item-class doc](../../../1.0/apps/library/features/netsuite-item-class-sync.md) before cleaning.
301
304
 
305
+ ## ⚠ 2026-09-17 incident — PO sync dead for days on a three-layer ACL gap (FIXED + DEPLOYED)
306
+
307
+ **Symptom:** `NETSUITE_EXECUTION_MODE_PURCHASE_ORDERS` stuck at **`1-RUNNING`** (a one-second
308
+ window) with the PO cursor crawling ~5 seconds of NetSuite time per run. **51** lifetime
309
+ occurrences of:
310
+
311
+ ```
312
+ linkPurchaseOrderToTransferOrder: PurchaseOrders_TransferOrders existence check returned no
313
+ totalRecordCount (API role likely lacks ACL read/write on this bridge)
314
+ ```
315
+
316
+ **Misdiagnosed twice before it landed — that is the lesson.** The first pass added
317
+ `AclRecordPermissions` for records 29/325/329 plus `allowDelete` on 14/15, verified all five rows
318
+ present with logic groups, and it **still failed**. The real gap was the **third** layer:
319
+ `Client_Elite` had **zero** roleId 3 rows in `AclFieldPermissions` for those bridges. With record
320
+ access but no readable fields, api2 returns **HTTP 200** carrying
321
+ **`WZ-1: "There are no fields in which you are authorized to read"`** and **omits
322
+ `meta.totalRecordCount`** — which is exactly what the importer throws on. **Nothing appears in the
323
+ API error log, because the call succeeded.** `Client_Nychh` had all six field grants, which is why
324
+ linking worked there.
325
+
326
+ The grants applied (mirroring `Client_Nychh`):
327
+
328
+ | Record | Route | `RecordFields` granted (roleId 3) |
329
+ |---|---|---|
330
+ | 325 | `purchase-orders-transfer-orders` | 2210 `uuid`, 2211 `purchaseOrderId`, 2212 `transferOrderId` |
331
+ | 329 | `purchase-order-items-transfer-order-items` | 2226 `uuid`, 2227 `purchaseOrderItemId`, 2228 `transferOrderItemId` |
332
+ | 29 | `item-fulfillment-items` | 366 `uuid`, 367 `itemFulfillmentId`, 368 `salesOrderItemId`, 369 `quantity`, 2151 `transferOrderItemId` |
333
+
334
+ `id` (2209 / 2225 / 365) is **deliberately not granted** — NYCHH does not grant it either, and the
335
+ sync joins on `uuid` throughout because the numeric id is not ACL-readable.
336
+
337
+ **Verified after deploy:** `PurchaseOrders_TransferOrders` **0 → 17** rows and
338
+ `PurchaseOrderItems_TransferOrderItems` **0 → 28** — populated for the first time ever. PO mode
339
+ recovered to `864000-IDLE`; cursor moved 2026-02-03 → 2026-03-28.
340
+
341
+ **Migration:** `dbchanges2/_modules/netsuite/2026-09-17a - StaleSalesOrderRetirementAcl.sql`. It
342
+ ships to **all 22 clients** carrying `netsuite` in `_modules.txt` and is written to **self-heal**
343
+ (five tenants were each broken differently) — the design rules are on the
344
+ [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md). **Run so far
345
+ on `Client_Elite` only.**
346
+
347
+ ### Elite's stale SalesOrder backfill is queued, not done
348
+
349
+ Enabling transfer-order detection left **218** Elite `SalesOrders` that duplicate a `TransferOrder`
350
+ (**217 of 217** matched pairs identical on order number, line count and total quantity). They still
351
+ carry fulfillments, which is what drove `_qtyOnHand` negative — see
352
+ [inventory quantities](./inventory-quantities-drop-ship.md). The retirement method that cleans them
353
+ up is built but **NOT yet deployed**; mechanics on the
354
+ [engine doc](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
355
+
356
+ Two blockers to clear by hand first:
357
+
358
+ 1. **SalesOrder 240734 has 4 `InvoiceItems` and WILL throw** — `InvoiceItems` has no
359
+ `transferOrderItemId`, so those rows cannot be moved. Deliberate fail-loud; it blocks the
360
+ backfill until resolved.
361
+ 2. **11 of the 218 have no matching TransferOrder** (4 are TOGa-created `SA1000xx` with no NetSuite
362
+ id). Manual review.
363
+
302
364
  ## Gotchas / known issues
303
365
 
304
366
  - **⚠ OPEN, unrelated: `GET /v2/notes/{uuid}/files` returns 405 `EV-7`** ("method not allowed /
305
367
  route does not exist") repeatedly for Elite. Found while diagnosing the 2026-09-08 transfer-order
306
368
  incident; it is **not** part of it. It does **not** abort the sync, and it was still occurring
307
- after the identifier fix. Needs its own ticket.
369
+ after the identifier fix. **Still open and still unticketed — reconfirmed 2026-09-17.** Needs its
370
+ own ticket.
308
371
 
309
372
  - **⚠ Two files named `sync_togasupply_elite.php`, both scheduled.**
310
373
  `worker/crons/toga2/netsuite/sync_togasupply_elite.php` (`*/5`) is **this** supply importer;
@@ -339,6 +402,17 @@ logic groups on id 346, from the unguarded `2026-09-11c` migration. Harmless, bu
339
402
  rewinding `NETSUITE_LAST_SYNC_DATETIME_SALES_ORDERS`.
340
403
 
341
404
  ## Change history
405
+ - 2026-09-17 — **PO sync was dead for days on a three-layer ACL gap — fixed and deployed.**
406
+ `NETSUITE_EXECUTION_MODE_PURCHASE_ORDERS` sat at `1-RUNNING` with 51 occurrences of the
407
+ `linkPurchaseOrderToTransferOrder ... no totalRecordCount` throw. Misdiagnosed twice as a record
408
+ permission gap; the real gap was `AclFieldPermissions` — `Client_Elite` had zero roleId 3 field
409
+ rows for records 29/325/329, and api2 answers that with **200 + `WZ-1` + no
410
+ `meta.totalRecordCount`**, leaving no trace in the API error log. Recorded the exact fields granted
411
+ (and that `id` is deliberately not). Bridges went 0→17 and 0→28 rows; mode back to `864000-IDLE`,
412
+ cursor 2026-02-03 → 2026-03-28. Migration `_modules/netsuite/2026-09-17a` ships to all 22 netsuite
413
+ clients but has been run on `Client_Elite` only. Also recorded the **218 stale SalesOrders**
414
+ backfill as queued-not-done, with SO 240734 (4 `InvoiceItems`) and 11 unmatched orders as manual
415
+ blockers, and reconfirmed the `GET /v2/notes/{uuid}/files` 405 `EV-7` is still open. (rgirish)
342
416
 
343
417
  - 2026-09-15 — **itemClass + line `fulfillmentType` are now on for Elite, and Elite alone.** Recorded
344
418
  the two wrapper constants (`IS_ENABLED_NETSUITE_ITEM_CLASSIFICATIONS`,
@@ -16,7 +16,7 @@ project: Worker
16
16
  client: elite
17
17
  type: profile
18
18
  status: active
19
- updated: 2026-09-08
19
+ updated: 2026-09-17
20
20
  owners: [snaredla, apeterson, tcox, bala, rgirish]
21
21
  files:
22
22
  - worker2/Worker/Elite.php
@@ -25,6 +25,7 @@ files:
25
25
  - library/app/api/servicerequest.php
26
26
  - _underscore/Model/Elite/SalesOrder.php
27
27
  - _underscore/Model/Elite/ServiceRequest.php
28
+ - _underscore/Model/Elite/Item.php
28
29
  - togadesk/desk/includes/classes/class.ticket.php
29
30
  - worker/crons/toga2/netsuite/sync_togasupply_elite.php
30
31
  related:
@@ -36,6 +37,7 @@ related:
36
37
  - features/salesorder-netsuite-push.md
37
38
  - features/togadesk-service-request-intake.md
38
39
  - features/salesorder-status-togadesk-reply.md
40
+ - features/inventory-quantities-drop-ship.md
39
41
  - 2.0/apps/worker2/features/service-request-sales-order-generation.md
40
42
  ---
41
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.835",
3
+ "version": "1.0.836",
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",