toga-ai 1.0.834 → 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.
- package/knowledge/1.0/apps/library/features/netsuite-item-class-sync.md +15 -1
- package/knowledge/1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md +139 -1
- package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +97 -0
- package/knowledge/2.0/apps/_underscore/features/calculated-sql-fields.md +33 -2
- package/knowledge/2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md +46 -2
- package/knowledge/2.0/apps/api2/features/v2-rest-query-contract.md +26 -1
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/elite/INDEX.md +1 -0
- package/knowledge/clients/elite/features/inventory-quantities-drop-ship.md +89 -0
- package/knowledge/clients/elite/features/netsuite-togasupply-sync.md +76 -2
- package/knowledge/clients/elite/features/supply2-tableview-config-drift.md +101 -13
- package/knowledge/clients/elite/profile.md +3 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: Library
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-17
|
|
10
10
|
owners: [rgirish]
|
|
11
11
|
files:
|
|
12
12
|
- library/app/api/toga2.php
|
|
@@ -23,6 +23,7 @@ related:
|
|
|
23
23
|
- ../../../2.0/apps/_underscore/features/acl-permission-chain.md
|
|
24
24
|
- ../../../2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md
|
|
25
25
|
- ../../../clients/elite/features/netsuite-togasupply-sync.md
|
|
26
|
+
- ../../../clients/elite/features/supply2-tableview-config-drift.md
|
|
26
27
|
---
|
|
27
28
|
|
|
28
29
|
## Summary
|
|
@@ -138,7 +139,20 @@ without `AclRecordPermissions` the API role cannot `POST /item-classes` at all.
|
|
|
138
139
|
sync re-walks transactions that reference them. That needs a
|
|
139
140
|
[cursor rewind](../../worker/features/netsuite-togasupply-per-client-sync.md).
|
|
140
141
|
|
|
142
|
+
- **The tree depth is not capped — consumers that walk it with fixed joins can break silently.**
|
|
143
|
+
`Client_Elite` is exactly **5 levels** deep today (4 roots: 1 Lifecycle Services, 4 Technology
|
|
144
|
+
Sales, 22 Advisory and Modernization, 40 True Partner Services; verified 2026-09-15 that every
|
|
145
|
+
5-level chain ends at a true root). Nothing stops NetSuite adding a 6th level. Anything that
|
|
146
|
+
resolves an item to its root class with a fixed number of `LEFT JOIN`s — such as Elite's
|
|
147
|
+
[Inventory service-item filter](../../../clients/elite/features/supply2-tableview-config-drift.md) —
|
|
148
|
+
will then silently mis-classify the deeper items with no error. Re-check depth when the NetSuite
|
|
149
|
+
tree changes.
|
|
150
|
+
|
|
141
151
|
## Change history
|
|
152
|
+
- 2026-09-17 — Added the **unbounded tree depth** caveat: `Client_Elite` is exactly 5 levels with 4
|
|
153
|
+
roots (1 / 4 / 22 / 40) and every chain verified to end at a true root, but nothing caps depth, so
|
|
154
|
+
any consumer walking to the root with a fixed join count breaks silently if NetSuite adds a level.
|
|
155
|
+
No code change. (rgirish)
|
|
142
156
|
- 2026-09-15 — Documented the feature from an audit of the deployed code (library `37b55595`, worker
|
|
143
157
|
`6637ae75`). **Found and fixed a field-name mismatch that made the feature a complete no-op since
|
|
144
158
|
deploy:** the PHP sent `c_netsuiteInternalItemClassId` in 5 places while the live column and
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
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-
|
|
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,8 +6,8 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
10
|
-
owners: ["bala"]
|
|
9
|
+
updated: 2026-09-17
|
|
10
|
+
owners: ["bala", "rgirish"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
13
|
- _underscore/Model/Client/TableView.php
|
|
@@ -15,6 +15,7 @@ files:
|
|
|
15
15
|
related:
|
|
16
16
|
- ../../../clients/compass-usa/features/item-fulfillment-tracking-tableview.md
|
|
17
17
|
- ../../../clients/compass-usa/features/item-catalogs-and-duplicate-items.md
|
|
18
|
+
- ../../../clients/elite/features/supply2-tableview-config-drift.md
|
|
18
19
|
- tableview-field-metadata.md
|
|
19
20
|
---
|
|
20
21
|
|
|
@@ -86,6 +87,34 @@ the existing Compass convention and the stored clauses stay uniform whether they
|
|
|
86
87
|
or five. (The "expression field must not start with `(`" gotcha below is about the *field slot* of a
|
|
87
88
|
condition **after** the wrapper is removed — the two are not in conflict.)
|
|
88
89
|
|
|
90
|
+
### Subqueries ARE possible — use `/**/` in place of every space
|
|
91
|
+
|
|
92
|
+
The parser splits the stored clause on **literal spaces**, so a normal SQL subquery (which needs
|
|
93
|
+
spaces) cannot be stored in `apiWhereClause`. The way around it: write `/**/` instead of every
|
|
94
|
+
space. MySQL treats `/**/` as whitespace, and the parser sees no space at all. That makes an
|
|
95
|
+
arbitrary **correlated subquery** usable as the *field* side of a condition.
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
(SELECT/**/c5.id/**/FROM/**/ItemClasses/**/c1/**/WHERE/**/c1.id=Items.itemClassId):notin:1:22:40
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Confirmed by reading `parseOptionsWhere` and simulating it in PHP (2026-09-17):
|
|
102
|
+
|
|
103
|
+
- Conditions split on `,` **only at matching paren depth** — commas nested deeper (e.g. inside a
|
|
104
|
+
`COALESCE(a,b,c)` within the subquery) are safe.
|
|
105
|
+
- Each condition then splits on `:` into field / operator / value.
|
|
106
|
+
- **The field side passes through to SQL unescaped**, so it can be any expression that has no
|
|
107
|
+
literal space, `:` or `,` at top depth.
|
|
108
|
+
- `notin` splits its value on `:`, so `:notin:1:22:40` becomes the IN-list `(1,22,40)`.
|
|
109
|
+
|
|
110
|
+
**Precedent:** Elite's prod clause on slug `item-fulfillments-for-sales-orders` already uses this
|
|
111
|
+
same `/**/` trick with a `CONCAT((SELECT ...))` subquery, so it is an established pattern, not a
|
|
112
|
+
one-off.
|
|
113
|
+
|
|
114
|
+
**Caveat — leave a comment.** This is powerful but it makes the stored clause hard to read for
|
|
115
|
+
anyone looking straight at `TableViews`. Always add a `--` comment in the migration explaining
|
|
116
|
+
what the subquery does.
|
|
117
|
+
|
|
89
118
|
### Join alias rule (which table-qualified name to use)
|
|
90
119
|
|
|
91
120
|
In `_underscore/Model/Client/TableView.php` (~line 89-96), the **first** occurrence of a joined
|
|
@@ -129,12 +158,27 @@ None — engine behavior. A specific client's view may carry its own `apiWhereCl
|
|
|
129
158
|
starts with a letter (e.g. `IFNULL(...)`), never a bare parenthesized expression.
|
|
130
159
|
- **NULL FKs drop silently.** A plain `field:ne:x` excludes rows where `field IS NULL` too. Wrap
|
|
131
160
|
nullable columns in `IFNULL(col,<sentinel>)` when the intent is "exclude only these values."
|
|
161
|
+
- **A literal space ends the clause element** — that is why a subquery needs `/**/` for every
|
|
162
|
+
space (see above). The same applies to any expression field: no spaces, anywhere.
|
|
163
|
+
- **Filter on ids, not names.** Names in lookup tables are free-text imported from NetSuite and
|
|
164
|
+
can be renamed without the id changing, so a `name LIKE` match is the fragile choice. Proven on
|
|
165
|
+
Elite: filtering root classes by `name LIKE '%Services%'` left 94 items visible instead of the
|
|
166
|
+
correct 82, because "Advisory and Modernization" contains no "Services".
|
|
132
167
|
- **Operator allowlist.** `parseOptionsWhere` whitelists the operators above and rejects unknown
|
|
133
168
|
ones (e.g. `regexp` returns a 500) — see the Compass cost-centers doc. Numeric/regex filtering
|
|
134
169
|
that the grammar can't express must be done client-side or by adding an operator to api2.
|
|
135
170
|
|
|
136
171
|
## Change history
|
|
137
172
|
|
|
173
|
+
- 2026-09-17 — **Recorded that subqueries ARE possible in a stored `apiWhereClause`**: the parser
|
|
174
|
+
splits on literal spaces, so writing `/**/` in place of every space (MySQL reads it as
|
|
175
|
+
whitespace, the parser sees no space) lets an arbitrary correlated subquery act as the field
|
|
176
|
+
side of a condition. Confirmed the parser mechanics by reading `parseOptionsWhere` and
|
|
177
|
+
simulating it in PHP — commas split only at matching paren depth, the field side passes to SQL
|
|
178
|
+
unescaped, and `notin` splits its value on `:` so `:notin:1:22:40` yields the IN-list (1,22,40).
|
|
179
|
+
Noted the existing prod precedent (Elite's `item-fulfillments-for-sales-orders` clause already
|
|
180
|
+
uses `CONCAT((SELECT ...))` this way), and added the "filter on ids, not names" gotcha —
|
|
181
|
+
name-matching left 94 Elite items visible instead of 82. No code change. (rgirish)
|
|
138
182
|
- 2026-09-01 — Recorded that the parser **strips one leading `(` and one trailing `)` from the whole
|
|
139
183
|
clause before splitting**, so the wrapped single-condition form `(Items.catalogId:eq:1)` is safe
|
|
140
184
|
and is the house convention (this was previously only documented for the multi-condition form).
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
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
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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)_ —
|
|
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-
|
|
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
|
|
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`,
|
|
@@ -6,7 +6,7 @@ project: Database Changes
|
|
|
6
6
|
client: elite
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-17
|
|
10
10
|
owners: [tcox, bala, rgirish]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Client_Elite/
|
|
@@ -14,6 +14,7 @@ files:
|
|
|
14
14
|
- dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql
|
|
15
15
|
- dbchanges2/Client_Elite/2026-08-13a - InventoryGroupingsSurfaceOverrides.sql
|
|
16
16
|
- dbchanges2/Client_Elite/2026-09-04b - EliteInventoryHideServiceItems.sql
|
|
17
|
+
- dbchanges2/Client_Elite/2026-09-15a - InventoryHideServiceClassItems.sql
|
|
17
18
|
- dbchanges2/Core/2026-08-07 - ServiceRequests TableView.sql
|
|
18
19
|
- dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql
|
|
19
20
|
- dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql
|
|
@@ -25,6 +26,8 @@ related:
|
|
|
25
26
|
- ../../../2.0/apps/_underscore/features/tracking-number-bridges.md
|
|
26
27
|
- ../../../2.0/apps/_underscore/features/units-for-items-for-purchase-orders.md
|
|
27
28
|
- ../../../2.0/apps/_underscore/features/page-meta-context-field-settings.md
|
|
29
|
+
- ../../../1.0/apps/library/features/netsuite-item-class-sync.md
|
|
30
|
+
- ../../../2.0/apps/_underscore/features/item-classification-itemclasses.md
|
|
28
31
|
---
|
|
29
32
|
|
|
30
33
|
## Summary
|
|
@@ -276,33 +279,106 @@ the live Units view is `inventory_units` (`TableViews` id 21), not
|
|
|
276
279
|
This pairs with the existing team rule that **a committed migration records intent, not deployed
|
|
277
280
|
state** — the frontend repo records the *default*, not this client's state.
|
|
278
281
|
|
|
279
|
-
## Hiding service lines from Inventory — `2026-09-04b`
|
|
282
|
+
## Hiding service lines from Inventory — `2026-09-04b`, replaced by `2026-09-15a`
|
|
280
283
|
|
|
281
|
-
|
|
284
|
+
> **⚠ Current rule (2026-09-15): service items are hidden by their ROOT ItemClass, not by the
|
|
285
|
+
> `SVC-` partNumber prefix.** `2026-09-15a - InventoryHideServiceClassItems.sql` rewrites the
|
|
286
|
+
> `inventory_items` clause. The 2026-09-04b history below is kept because it explains the
|
|
287
|
+
> `IFNULL(...)` NULL-drop rule, which still applies.
|
|
288
|
+
|
|
289
|
+
`2026-09-15a - InventoryHideServiceClassItems.sql` rewrites `TableViews.apiWhereClause` for slug
|
|
290
|
+
`inventory_items` in `Client_Elite`. It walks the `ItemClasses` parent chain and excludes any item
|
|
291
|
+
whose **root** ItemClass id is **1** (Lifecycle Services), **22** (Advisory and Modernization) or
|
|
292
|
+
**40** (True Partner Services), using a correlated subquery written with `/**/` as whitespace —
|
|
293
|
+
see [apiWhereClause row filtering](../../../2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md#subqueries-are-possible--use--in-place-of-every-space)
|
|
294
|
+
for the technique and the parser mechanics.
|
|
295
|
+
|
|
296
|
+
**No `_underscore` deploy is needed.** The whole check lives in the stored clause. An earlier draft
|
|
297
|
+
required a `_isServiceClass` model field on `_Model_Elite_Item`; **that field does not exist and is
|
|
298
|
+
not needed** — do not add one.
|
|
299
|
+
|
|
300
|
+
### Why the class check replaced the `SVC-` prefix
|
|
301
|
+
|
|
302
|
+
The `SVC-` prefix was an unreliable signal. Verified on **prod `Client_Elite`** (2026-09-15):
|
|
303
|
+
|
|
304
|
+
| Measure | Count |
|
|
305
|
+
|---|---|
|
|
306
|
+
| Items total | 122 |
|
|
307
|
+
| Service items by root ItemClass | 40 |
|
|
308
|
+
| Service items by `SVC-` prefix | 27 |
|
|
309
|
+
| Visible after the class filter | 82 |
|
|
310
|
+
|
|
311
|
+
The class check catches **18 services the prefix missed** — Cisco `CON-SNT-*` / `CON-ROB-*`
|
|
312
|
+
contracts, `LIC-ENT-*` licenses, `CarePacks - Fixed`, `PROJECT - CABLING`, `CONFIG/INSTALL` — with
|
|
313
|
+
**zero false hides**.
|
|
314
|
+
|
|
315
|
+
### Match on ids, never on class NAMES
|
|
316
|
+
|
|
317
|
+
Hardcoded ids 1 / 22 / 40 are the stable reference. Names are free-text imported from NetSuite and
|
|
318
|
+
can be renamed without the id changing. Proven wrong in practice here: filtering on
|
|
319
|
+
`name LIKE '%Services%'` returned **94** visible instead of the correct **82**, because "Advisory
|
|
320
|
+
and Modernization" contains no "Services".
|
|
321
|
+
|
|
322
|
+
### `Client_Elite` ItemClasses hierarchy (prod, verified 2026-09-15)
|
|
323
|
+
|
|
324
|
+
The table is **`ItemClasses`**, not `ItemClassifications`. Exactly **4 root rows**
|
|
325
|
+
(`parentItemClassId IS NULL`):
|
|
326
|
+
|
|
327
|
+
| id | Name | Kind |
|
|
328
|
+
|---|---|---|
|
|
329
|
+
| 1 | Lifecycle Services | service |
|
|
330
|
+
| 4 | Technology Sales | product |
|
|
331
|
+
| 22 | Advisory and Modernization | service |
|
|
332
|
+
| 40 | True Partner Services | service |
|
|
333
|
+
|
|
334
|
+
Tree depth is exactly **5 levels**, and every 5-level chain terminates at a true root (a query for
|
|
335
|
+
level-5 classes with a non-null parent returned 0 rows). So the migration's fixed 5-alias
|
|
336
|
+
`LEFT JOIN` walk resolves every item to its true root **today**. See
|
|
337
|
+
[the hierarchy import](../../../1.0/apps/library/features/netsuite-item-class-sync.md).
|
|
338
|
+
|
|
339
|
+
### Unclassified items stay visible — and 5 real services leak
|
|
340
|
+
|
|
341
|
+
**29 Items have `itemClassId` NULL.** The clause is a blocklist, so unclassified items stay
|
|
342
|
+
**visible** — the safer default, since hiding stock people expect to see is worse than showing a
|
|
343
|
+
few extra lines. But 5 of those 29 are real services by name and will newly appear in Inventory:
|
|
344
|
+
|
|
345
|
+
- `SVC-FS-SHIPPING-RECLAIM-LAPTOP`
|
|
346
|
+
- `SVC-FS-SHIPPING-SYSTEM SWAP-LAPTOP`
|
|
347
|
+
- `SVC-FS-DEPLOY-GW`
|
|
348
|
+
- `SVC-FS-SHIPPING-2DAY`
|
|
349
|
+
- `SVC-FS-ELITE-PERIPHERAL`
|
|
350
|
+
|
|
351
|
+
**Open decision left with the developer:** either set an ItemClass on those 5 in NetSuite, or AND
|
|
352
|
+
the old `SVC-` prefix check back into the clause.
|
|
353
|
+
|
|
354
|
+
### The previous rule — `2026-09-04b` (superseded for `inventory_items`)
|
|
355
|
+
|
|
356
|
+
`2026-09-04b - EliteInventoryHideServiceItems.sql` set:
|
|
282
357
|
|
|
283
358
|
| View | Clause |
|
|
284
359
|
|---|---|
|
|
285
|
-
| `inventory_units` | `(IFNULL(Items.assetTypeId,0):ne:2)` |
|
|
286
|
-
| `inventory_items` | `(Items.partNumber:excludes:SVC-,IFNULL(Items.assetTypeId,0):ne:2)` |
|
|
360
|
+
| `inventory_units` | `(IFNULL(Items.assetTypeId,0):ne:2)` — **still in force** |
|
|
361
|
+
| `inventory_items` | `(Items.partNumber:excludes:SVC-,IFNULL(Items.assetTypeId,0):ne:2)` — replaced |
|
|
287
362
|
|
|
288
|
-
`assetTypeId 2` = Elite's `Services` row. Notes
|
|
363
|
+
`assetTypeId 2` = Elite's `Services` row. Notes that still apply to anyone porting this:
|
|
289
364
|
|
|
290
365
|
- **The filter belongs in the database table view, not the frontend** — an explicit scope decision.
|
|
291
366
|
- **`IFNULL(...,0):ne:2` is mandatory, not cosmetic.** A bare `:ne:2` drops every `NULL` row and
|
|
292
|
-
would hide **all unstamped items
|
|
293
|
-
|
|
294
|
-
view. Grammar and the NULL-drop rule:
|
|
367
|
+
would hide **all unstamped items**. The shape is copied from Elite's existing
|
|
368
|
+
`item-fulfillments-for-sales-orders` view. Grammar and the NULL-drop rule:
|
|
295
369
|
[apiWhereClause row filtering](../../../2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md).
|
|
296
|
-
- **The `SVC-` prefix rule is kept on `inventory_items`** because it still catches the three items
|
|
297
|
-
NetSuite mis-types as `InvtPart` (`SVC-TS-ELITE-HDONBOARDING`, `CONFIG/INSTALL`,
|
|
298
|
-
`CONF-RM-INSTALL-SUPP`) — see
|
|
299
|
-
[netsuite-togasupply-sync](./netsuite-togasupply-sync.md).
|
|
300
370
|
- **Both `UPDATE`s are guarded on the current value**, so a re-run is a no-op.
|
|
301
371
|
- `inventory_units` was targeted **because of the `SurfaceOverrides` finding above** — filtering
|
|
302
372
|
view 17 would have had no visible effect for Elite.
|
|
303
373
|
|
|
304
374
|
## Gotchas / known issues
|
|
305
375
|
|
|
376
|
+
- **A 6th NetSuite class level would silently leak services back into Inventory.** The
|
|
377
|
+
`2026-09-15a` clause walks a **fixed 5-alias** chain because Elite's tree is exactly 5 deep
|
|
378
|
+
today — but nothing in the schema caps the depth. If NetSuite adds a level under a service root,
|
|
379
|
+
those items reappear in Inventory with no error. Re-check the depth whenever the classification
|
|
380
|
+
tree changes.
|
|
381
|
+
|
|
306
382
|
- **A `Client_*` DB that predates a platform migration fails silently until a user opens the
|
|
307
383
|
view.** Nothing validates `TableViewJoins.parentRecordFieldId` against live `Core.RecordFields`,
|
|
308
384
|
so a deleted id sits there until it 500s. Worth checking for **any** client onboarded before
|
|
@@ -312,6 +388,18 @@ state** — the frontend repo records the *default*, not this client's state.
|
|
|
312
388
|
[error-reporting-issue-event](../../../2.0/apps/_underscore/features/error-reporting-issue-event.md).
|
|
313
389
|
|
|
314
390
|
## Change history
|
|
391
|
+
- 2026-09-17 — **Replaced the `SVC-` partNumber prefix filter with an ItemClass-hierarchy check**
|
|
392
|
+
on the `inventory_items` view (`2026-09-15a - InventoryHideServiceClassItems.sql`): the clause
|
|
393
|
+
now walks the `ItemClasses` parent chain and excludes items whose ROOT class id is 1, 22 or 40,
|
|
394
|
+
via a `/**/`-whitespace correlated subquery. Prod numbers: 122 items, 40 service by class vs 27
|
|
395
|
+
by prefix — the class check catches 18 the prefix missed (Cisco CON-SNT-*/CON-ROB-*, LIC-ENT-*,
|
|
396
|
+
CarePacks, PROJECT - CABLING, CONFIG/INSTALL) with zero false hides; 82 visible after filtering.
|
|
397
|
+
**No `_underscore` deploy** — an earlier draft's `_isServiceClass` model field does not exist and
|
|
398
|
+
is not needed. Recorded the verified `Client_Elite` hierarchy (4 roots, exactly 5 levels, every
|
|
399
|
+
chain terminating at a true root) and the decision to match on **ids, not names** (a
|
|
400
|
+
`name LIKE '%Services%'` filter left 94 visible, not 82, because "Advisory and Modernization"
|
|
401
|
+
has no "Services" in it). Open item: 29 items have a NULL `itemClassId` and stay visible by
|
|
402
|
+
design, but 5 of them are real services and will newly appear. (rgirish)
|
|
315
403
|
- 2026-09-04 — **Corrected which table view Elite's Inventory page actually reads, then filtered
|
|
316
404
|
it.** `toga25-supply/src` shows the DEFAULT groupings using `inventory_items` +
|
|
317
405
|
`units-for-items-for-purchase-orders`, and `inventory_units` appears nowhere in `src` — reading as
|
|
@@ -16,7 +16,7 @@ project: Worker
|
|
|
16
16
|
client: elite
|
|
17
17
|
type: profile
|
|
18
18
|
status: active
|
|
19
|
-
updated: 2026-09-
|
|
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