toga-ai 1.0.810 → 1.0.812
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/INDEX.md +1 -0
- package/knowledge/1.0/apps/library/features/netsuite-item-class-sync.md +150 -0
- package/knowledge/1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md +46 -8
- package/knowledge/2.0/apps/_underscore/features/item-classification-itemclasses.md +22 -5
- package/knowledge/2.0/apps/api2/features/nested-relationship-writes.md +16 -1
- package/knowledge/2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md +23 -2
- package/knowledge/2.0/apps/dbchanges2/features/surface-layer-schema.md +98 -2
- package/knowledge/2.0/apps/toga25-supply/features/surface-frontend.md +72 -2
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/compass-canada/profile.md +8 -1
- package/knowledge/clients/compass-usa/features/item-catalogs-and-duplicate-items.md +15 -1
- package/knowledge/clients/elite/features/netsuite-togasupply-sync.md +49 -5
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
| [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t |
|
|
18
18
|
| [NetSuite File Cabinet Content Retrieval via RESTlet (fetchInvoiceFile)](features/netsuite-filecabinet-restlet.md) | How 1.0 pulls **File Cabinet binary content** (invoice PDFs) out of NetSuite over REST. |
|
|
19
19
|
| [assetType from NetSuite itemtype during Item Sync (opt-in per client)](features/netsuite-item-assettype-sync.md) | `getCreateItem()` never sent `assetType`, so **every item the NetSuite importer created had `Items.assetTypeId = NULL`** — for every client, since the importer |
|
|
20
|
+
| [NetSuite classification → ItemClasses sync (opt-in per client)](features/netsuite-item-class-sync.md) | `App_Api_Toga2::getCreateItemClass()` mirrors NetSuite's **classification tree** into the client's `ItemClasses` table and stamps `Items.itemClassId`. |
|
|
20
21
|
| [isFulfillable from NetSuite during Item Sync (Phase 1)](features/netsuite-item-isfulfillable-sync.md) | This is the **1.0 (Phase 1)** half of the `isFulfillable` feature: reading the NetSuite `isfulfillable` flag during item sync and stamping it onto the **Agilant |
|
|
21
22
|
| [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w |
|
|
22
23
|
| [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. |
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: NetSuite classification → ItemClasses sync (opt-in per client)
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: library
|
|
5
|
+
project: Library
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-15
|
|
10
|
+
owners: [rgirish]
|
|
11
|
+
files:
|
|
12
|
+
- library/app/api/toga2.php
|
|
13
|
+
- worker/crons/toga2/netsuite/sync_togasupply_elite.php
|
|
14
|
+
- dbchanges2/_modules/netsuite/2026-09-11a - ItemClassesNetsuiteHierarchy.sql
|
|
15
|
+
- dbchanges2/_modules/netsuite/2026-09-11b - ItemClassApiWritePermission.sql
|
|
16
|
+
- dbchanges2/_modules/netsuite/2026-09-11c - ItemClassRecordPermission.sql
|
|
17
|
+
related:
|
|
18
|
+
- netsuite-item-assettype-sync.md
|
|
19
|
+
- netsuite-item-isfulfillable-sync.md
|
|
20
|
+
- toga2-api-client-and-bridge.md
|
|
21
|
+
- ../../worker/features/netsuite-togasupply-per-client-sync.md
|
|
22
|
+
- ../../../2.0/apps/_underscore/features/item-classification-itemclasses.md
|
|
23
|
+
- ../../../2.0/apps/_underscore/features/acl-permission-chain.md
|
|
24
|
+
- ../../../2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md
|
|
25
|
+
- ../../../clients/elite/features/netsuite-togasupply-sync.md
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Summary
|
|
29
|
+
|
|
30
|
+
`App_Api_Toga2::getCreateItemClass()` mirrors NetSuite's **classification tree** into the client's
|
|
31
|
+
`ItemClasses` table and stamps `Items.itemClassId`. It is **opt-in per client** via
|
|
32
|
+
`IS_ENABLED_NETSUITE_ITEM_CLASSIFICATIONS`, and today **only Elite** has it on.
|
|
33
|
+
|
|
34
|
+
It is the third flag stamped by the item importer, alongside
|
|
35
|
+
[assetType](netsuite-item-assettype-sync.md) and [isFulfillable](netsuite-item-isfulfillable-sync.md).
|
|
36
|
+
Unlike those two it **creates rows** rather than linking to a hand-curated lookup, so it needs a
|
|
37
|
+
schema migration and a full ACL chain before it can run.
|
|
38
|
+
|
|
39
|
+
**Do not repurpose `ItemClasses` without reading
|
|
40
|
+
[who already uses it](../../../2.0/apps/_underscore/features/item-classification-itemclasses.md)** —
|
|
41
|
+
Northwell (27 rows, ServiceNow categories) and Prudential (7 rows, device tiers) already hold
|
|
42
|
+
unrelated data in that table.
|
|
43
|
+
|
|
44
|
+
## Key files / entry points
|
|
45
|
+
|
|
46
|
+
- **`library/app/api/toga2.php` → `getCreateItemClass()`** — the whole feature. Returns an
|
|
47
|
+
`ItemClasses.uuid` or `null`.
|
|
48
|
+
- **`App_Api_Netsuite_Rest::listClassifications()`** — pulls the full NetSuite tree (311 rows as of
|
|
49
|
+
2026-09-11).
|
|
50
|
+
- **`worker/crons/toga2/netsuite/sync_togasupply_elite.php:65`** — the only launcher that turns it on.
|
|
51
|
+
|
|
52
|
+
## How it works
|
|
53
|
+
|
|
54
|
+
1. **Gated first.** If `IS_ENABLED_NETSUITE_ITEM_CLASSIFICATIONS` is undefined or false it returns
|
|
55
|
+
`null` immediately, so the `itemClass` key never reaches the item payload.
|
|
56
|
+
2. **Caches the NetSuite tree once per run** into a `static` map keyed by `internalId`.
|
|
57
|
+
3. **Caches the client's existing rows once per run** via paged `GET /item-classes`, keyed by
|
|
58
|
+
`c_netsuiteInternalClassId`.
|
|
59
|
+
4. **Climbs to the root** collecting the ancestor chain, then **creates from the root down** so each
|
|
60
|
+
parent exists before its child's `parentItemClass` references it.
|
|
61
|
+
5. Each `POST /item-classes` sends `name` + `c_netsuiteInternalClassId` (+ `parentItemClass.uuid`).
|
|
62
|
+
|
|
63
|
+
**Matching is on the NetSuite internal id, never on name.** NetSuite classification names repeat
|
|
64
|
+
across branches ("Shipping" appears 3x), which is why
|
|
65
|
+
`2026-09-11a - ItemClassesNetsuiteHierarchy.sql` **drops the `name` UNIQUE index** and adds a UNIQUE
|
|
66
|
+
on `c_netsuiteInternalClassId` instead.
|
|
67
|
+
|
|
68
|
+
### Two cache guards worth keeping
|
|
69
|
+
|
|
70
|
+
- **A loaded-flag, not `empty()`.** `ItemClasses` starts empty on every client, so an `empty()` check
|
|
71
|
+
would re-fetch the whole list on every item until the first class exists.
|
|
72
|
+
- **`recordsPerPage => 1000` is explicit on purpose.** An unpaginated V2 list GET **silently
|
|
73
|
+
truncates at 25 rows** — with the default the lookup would miss existing classes and try to
|
|
74
|
+
re-create them.
|
|
75
|
+
|
|
76
|
+
## ⚠ The field name is `c_netsuiteInternalClassId` — no `Item` in the middle
|
|
77
|
+
|
|
78
|
+
The single trap in this feature, and it broke the whole thing on first deploy.
|
|
79
|
+
|
|
80
|
+
The migration and the live DB column are **`c_netsuiteInternalClassId`**. The code shipped in
|
|
81
|
+
`37b55595` used **`c_netsuiteInternalItemClassId`** in all 5 places. Symptoms:
|
|
82
|
+
|
|
83
|
+
- Every `POST /item-classes` failed with **`EV-9`** — api2 rejects the *entire* record when one field
|
|
84
|
+
is not writable, and an unregistered field is not writable.
|
|
85
|
+
- The lookup `GET` asked for a field that does not exist, so `$lookupItemClassUuid` never populated.
|
|
86
|
+
- Net effect: `Client_Elite.ItemClasses` held **0 rows** — the feature never worked at all since
|
|
87
|
+
deploy, with no error surfaced to the cron.
|
|
88
|
+
|
|
89
|
+
**The migration file is the authority on a custom-field name; code follows the DB.** The column is
|
|
90
|
+
already live in ~22 client databases, so renaming the column to match the code was never an option.
|
|
91
|
+
Fixed in `70f1129f` (merged PR #883, in `_production`).
|
|
92
|
+
|
|
93
|
+
**Generalise this:** before shipping any `c_` field, grep the `dbchanges2` migration for the exact
|
|
94
|
+
string and diff it against the PHP. A mismatch here is invisible — no 500, no log line, just an empty
|
|
95
|
+
table.
|
|
96
|
+
|
|
97
|
+
## Required schema + ACL (all three layers)
|
|
98
|
+
|
|
99
|
+
`ItemClasses` is `Core.Records` id **105**, `aclDatabase = CLIENT` — so **every grant lives in the
|
|
100
|
+
client database, not Core**. Records 15, 18, 21 and 105 are all CLIENT.
|
|
101
|
+
|
|
102
|
+
| Layer | Table | What it grants | Migration |
|
|
103
|
+
|---|---|---|---|
|
|
104
|
+
| Record | `AclRecordPermissions` + paired `AclLogicGroups` | roleId 3 create/read/update on record 105 | `2026-09-11c` |
|
|
105
|
+
| Base field | `AclFieldPermissions` | roleId 3 writable on RecordFields 601-605, 2596 | `2026-09-11b` |
|
|
106
|
+
| Custom field | `AclCustomFieldPermissions` | roleId 3 writable on `c_netsuiteInternalClassId` | `2026-09-11a` |
|
|
107
|
+
|
|
108
|
+
RecordFields worth knowing: **605** = `Items.itemClassId`, **2596** =
|
|
109
|
+
`ItemClasses.parentItemClassId`, **2594/2595** = `SalesOrderItems.fulfillmentType` /
|
|
110
|
+
`PurchaseOrderItems.fulfillmentType`.
|
|
111
|
+
|
|
112
|
+
**The record-level layer is the one that gets missed** — field grants alone are not enough, and
|
|
113
|
+
without `AclRecordPermissions` the API role cannot `POST /item-classes` at all. Full mechanics:
|
|
114
|
+
[ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md).
|
|
115
|
+
|
|
116
|
+
## Gotchas
|
|
117
|
+
|
|
118
|
+
- **⚠ `2026-09-11c` has no duplicate guard.** It is a plain `INSERT` into `AclRecordPermissions`, so
|
|
119
|
+
every re-run inserts another row. Production shows **three** rows for recordId 105 / roleId 3 on
|
|
120
|
+
every client checked (Elite: ids 346, 347, 348), and only the first (346) has a paired
|
|
121
|
+
`AclLogicGroups` — Elite has 3 logic groups hanging off it instead of 1. Harmless today (all rows
|
|
122
|
+
grant the same access) but it is noise, and the file should get a `NOT EXISTS` guard on
|
|
123
|
+
`(recordId, roleId)` per
|
|
124
|
+
[re-runnable additive migrations](../../../2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md).
|
|
125
|
+
Those migrations also use `UUID()`, which the team standard forbids.
|
|
126
|
+
- **The opt-in constants are the safety net for other clients.** `getCreateItemClass()` and the
|
|
127
|
+
`fulfillmentType` path both return early without their flag, so the keys are omitted entirely and
|
|
128
|
+
no un-provisioned client can hit `EV-9`. Confirmed by grep across all 19
|
|
129
|
+
`sync_togasupply_*.php` launchers — only Elite defines either constant.
|
|
130
|
+
- **Before another client opts in it needs three things**, not just the shared module: (a) the
|
|
131
|
+
`_modules/netsuite` 2026-09-11 grants — already applied broadly (Canon, Quad, Nychh, Adyen,
|
|
132
|
+
Prudential all have record 105 + fields 601-605, 2596); (b) an Elite-`2026-09-11a`-equivalent
|
|
133
|
+
setting `allowUpdate = 1` on records **21** (Items) and **18** (Purchase order items) — Canon,
|
|
134
|
+
Nychh and Adyen are currently `0` there; (c) the Elite-`2026-09-11b`-equivalent grants on fields
|
|
135
|
+
**2594/2595**.
|
|
136
|
+
- **Existing items are not backfilled.** Like assetType stamping, `getCreateItem()` only runs when a
|
|
137
|
+
transaction line references the item, so already-imported items keep `itemClassId = NULL` until the
|
|
138
|
+
sync re-walks transactions that reference them. That needs a
|
|
139
|
+
[cursor rewind](../../worker/features/netsuite-togasupply-per-client-sync.md).
|
|
140
|
+
|
|
141
|
+
## Change history
|
|
142
|
+
- 2026-09-15 — Documented the feature from an audit of the deployed code (library `37b55595`, worker
|
|
143
|
+
`6637ae75`). **Found and fixed a field-name mismatch that made the feature a complete no-op since
|
|
144
|
+
deploy:** the PHP sent `c_netsuiteInternalItemClassId` in 5 places while the live column and
|
|
145
|
+
`CustomRecordFields` row (recordId 105) are `c_netsuiteInternalClassId`, so every `POST
|
|
146
|
+
/item-classes` failed `EV-9` and `Client_Elite.ItemClasses` had 0 rows. Fixed in `70f1129f`.
|
|
147
|
+
Verified all three ACL layers are correctly granted for Elite in production, and recorded that the
|
|
148
|
+
two opt-in constants are Elite-only so no other client is exposed. Also recorded that
|
|
149
|
+
`2026-09-11c` is unguarded and has produced 3 duplicate `AclRecordPermissions` rows per client.
|
|
150
|
+
(rgirish)
|
|
@@ -32,6 +32,7 @@ related:
|
|
|
32
32
|
- ../../library/features/toga2-api-client-and-bridge.md
|
|
33
33
|
- ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
|
|
34
34
|
- ../../../clients/elite/features/netsuite-togasupply-sync.md
|
|
35
|
+
- ../../library/features/netsuite-item-class-sync.md
|
|
35
36
|
- ../../../clients/compass-usa/features/sales-order-line-renumbering.md
|
|
36
37
|
---
|
|
37
38
|
|
|
@@ -68,7 +69,7 @@ Each section (sales orders, POs, invoices, item receipts, item fulfillments, inv
|
|
|
68
69
|
adjustments) uses **adaptive time-windowing** via `startModeIteration()`/`finishModeIteration()`:
|
|
69
70
|
a window stored as `<seconds>-<STATE>` in the client's `Parameters` table shrinks ÷3 when the
|
|
70
71
|
prior run was left `RUNNING` (didn't finish) and grows ×3 (capped at
|
|
71
|
-
`MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS`, default
|
|
72
|
+
`MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS`, default **864000s = 10 days**) when it
|
|
72
73
|
finished `IDLE`. The window state is written `RUNNING` at the **start** of each section, so a
|
|
73
74
|
crash leaves it visibly RUNNING and the next run backs off. `LAST_SYNC_DATETIME_*` advances to
|
|
74
75
|
the window's upper bound after each successful section.
|
|
@@ -76,12 +77,15 @@ the window's upper bound after each successful section.
|
|
|
76
77
|
### The window cap is per-client overridable (2026-08-12)
|
|
77
78
|
|
|
78
79
|
`common_sync_togasupply.php:3` is
|
|
79
|
-
`defined('MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS') || define(…,
|
|
80
|
+
`defined('MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS') || define(…, 864000)` — **not** a
|
|
80
81
|
`const` — so a wrapper may `const MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS = …;`
|
|
81
|
-
*before* its `require_once` to widen its own catch-up window.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
*before* its `require_once` to widen its own catch-up window.
|
|
83
|
+
|
|
84
|
+
**⚠ Re-verified 2026-09-15: the shared default is now `864000` (10 days), and NO wrapper overrides
|
|
85
|
+
it — including Elite.** Elite's 30-day (2592000) backfill override was removed after its 2024
|
|
86
|
+
backfill finished, exactly as the note here said it should be. So today every client runs the same
|
|
87
|
+
10-day cap, and a deep rewind advances at most 10 days per run (see the resync recipe below). Use an
|
|
88
|
+
override only for a deep backfill, and put the reason in a comment next to it.
|
|
85
89
|
|
|
86
90
|
### ⚠ A shrinking window is a BUG SIGNAL, not a load signal
|
|
87
91
|
|
|
@@ -125,8 +129,9 @@ mis-set during the 2026-09-08 Elite incident:
|
|
|
125
129
|
- **The cap is `864000` (10 days), not `86400` (1 day).** One zero short is a silent 10× smaller
|
|
126
130
|
window, not an error.
|
|
127
131
|
|
|
128
|
-
(`MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS` is per-client overridable
|
|
129
|
-
|
|
132
|
+
(`MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS` is per-client overridable, but as of 2026-09-15
|
|
133
|
+
**no wrapper overrides it** — Elite's old 2592000 is gone. So `864000` is the right value for every
|
|
134
|
+
client today; re-check the wrapper before assuming it, not the other way round.)
|
|
130
135
|
|
|
131
136
|
### Per-record resume cursor — checkpoint after EACH record, not window-end (LIVE 2026-09-02)
|
|
132
137
|
|
|
@@ -514,6 +519,30 @@ stage 5: 289135 was closed at **10:50** and the cursor had already passed **15:2
|
|
|
514
519
|
WARNING: **`NETSUITE_LAST_SYNC_CURSOR_ITEM_RECEIPTS` on NYCHH is stale at 2024-08-06**, so re-enabling
|
|
515
520
|
that feed replays roughly **two years** of receipts. Rewind it forward *before* flipping the toggle.
|
|
516
521
|
|
|
522
|
+
#### Full resync recipe — and the 10-day ceiling that makes it a loop (2026-09-15)
|
|
523
|
+
|
|
524
|
+
Rewinding to backfill a newly-stamped field (assetType, itemClass, a `c_` field) is the same
|
|
525
|
+
mechanic, but at depth three details decide whether it works. The six keys, all in the **client**
|
|
526
|
+
database's `Parameters` table:
|
|
527
|
+
|
|
528
|
+
`NETSUITE_LAST_SYNC_CURSOR_` + `SALES_ORDERS` · `PURCHASE_ORDERS` · `INVOICES` · `ITEM_RECEIPTS` ·
|
|
529
|
+
`ITEM_FULFILLMENTS` · `INVENTORY_ADJUSTMENTS`.
|
|
530
|
+
|
|
531
|
+
1. **The id half must be `0` on a rewind.** The value is `<datetime>|<netsuiteInternalId>` and it is a
|
|
532
|
+
*seek* key — a non-zero id makes the run skip every record at that same datetime whose internal id
|
|
533
|
+
sorts below it. Always write `'2026-01-01 00:00:00|0'`, never carry the old id back.
|
|
534
|
+
2. **The datetime is on the Eastern clock** (`America/New_York`), not UTC and not the server's. See
|
|
535
|
+
the wrong-clock bugs above — this is the same clock they settled on.
|
|
536
|
+
3. **Reset the paired execution-mode key at the same time.** `NETSUITE_EXECUTION_MODE_<SECTION>`
|
|
537
|
+
holds `<seconds>-<STATE>`; a prior crash leaves it `RUNNING`, and the next run then **shrinks**
|
|
538
|
+
the window ÷3 instead of using the full cap. Observed live 2026-09-15: Elite's
|
|
539
|
+
`NETSUITE_EXECUTION_MODE_SALES_ORDERS` was stuck at `864000-RUNNING`. Set it to
|
|
540
|
+
`<the client's own cap>-IDLE` — see the manual-reset section above for both halves.
|
|
541
|
+
4. **One run only advances `MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS`** — 864000s (10 days) by
|
|
542
|
+
default, and **Elite does not override this one**. So a rewind to 1 Jan 2026 needs **~26 runs** to
|
|
543
|
+
catch up. On the 5-minute cron that is fine; on a daily cron it would take ~26 days. **Run the
|
|
544
|
+
wrapper by hand in a loop** rather than waiting for the schedule.
|
|
545
|
+
|
|
517
546
|
### Sections are NOT order-dependent — one cursor can be rewound alone
|
|
518
547
|
|
|
519
548
|
Each section's `startModeIteration()` reads only **its own** `Parameters` key, and the PO section
|
|
@@ -1624,6 +1653,15 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
|
|
|
1624
1653
|
[NetSuite Sync Alert Monitor](../../library/features/netsuite-sync-alert-monitor.md).
|
|
1625
1654
|
|
|
1626
1655
|
## Change history
|
|
1656
|
+
- 2026-09-15 — Added the **full resync recipe** for a deep cursor rewind (the procedure for
|
|
1657
|
+
backfilling a newly-stamped item field): the six `NETSUITE_LAST_SYNC_CURSOR_<SECTION>` keys live in
|
|
1658
|
+
the **client** DB, the id half **must be `0`** or the seek skips records at the same datetime, the
|
|
1659
|
+
datetime is on the **Eastern** clock, and the paired `NETSUITE_EXECUTION_MODE_<SECTION>` must be
|
|
1660
|
+
reset to `<cap>-IDLE` in the same pass — Elite's `..._SALES_ORDERS` was found stuck at
|
|
1661
|
+
`864000-RUNNING`, which shrinks the next window ÷3. Recorded the **10-day ceiling per run** (Elite
|
|
1662
|
+
does *not* override this one), so a rewind to Jan 2026 needs ~26 runs and should be looped by hand
|
|
1663
|
+
rather than left to the schedule. Found auditing the itemClass/fulfillmentType deploy — see
|
|
1664
|
+
[NetSuite item-class sync](../../library/features/netsuite-item-class-sync.md). (rgirish)
|
|
1627
1665
|
|
|
1628
1666
|
- 2026-09-15 — **Compass SALES_ORDERS unfrozen: a Compass-gated prune runs BEFORE the renumber.**
|
|
1629
1667
|
`syncSalesOrderFromNetsuite` was throwing *"unresolvable NetSuite<->DB lineNumber conflict"*
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-15
|
|
10
10
|
owners: ["rgirish"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Client/ItemClass.php
|
|
@@ -18,6 +18,7 @@ related:
|
|
|
18
18
|
- ./record-change-audit-log.md
|
|
19
19
|
- ../../../../clients/prudential/features/itemclasses-device-tier-data.md
|
|
20
20
|
- ../../dbchanges2/workflows/client-schema-drift-audit.md
|
|
21
|
+
- ../../../../1.0/apps/library/features/netsuite-item-class-sync.md
|
|
21
22
|
---
|
|
22
23
|
|
|
23
24
|
## Summary
|
|
@@ -78,7 +79,13 @@ LIMIT 1
|
|
|
78
79
|
That cron is **disabled** — see the
|
|
79
80
|
[Prudential ItemClasses data doc](../../../../clients/prudential/features/itemclasses-device-tier-data.md).
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
**~~Nothing writes `itemClassId`.~~ There is now ONE writer (2026-09-15).** The audit below was
|
|
83
|
+
correct on 2026-09-11; four days later the 1.0 NetSuite importer became the first writer —
|
|
84
|
+
`App_Api_Toga2::getCreateItemClass()` creates `ItemClasses` rows from the NetSuite classification
|
|
85
|
+
tree and stamps `Items.itemClassId`. It is **opt-in per client and only Elite has it on**, so the
|
|
86
|
+
"0 rows for 34 clients" picture below still holds everywhere else. See
|
|
87
|
+
[NetSuite item-class sync](../../../../1.0/apps/library/features/netsuite-item-class-sync.md).
|
|
88
|
+
No writer exists in `api2`, `worker2`, or `_underscore`.
|
|
82
89
|
|
|
83
90
|
### Frontend: one real reference, and a grep trap
|
|
84
91
|
|
|
@@ -101,9 +108,12 @@ Queried live via SuiteQL (2026-09-11): **311 classifications**, columns
|
|
|
101
108
|
|
|
102
109
|
## Gotchas
|
|
103
110
|
|
|
104
|
-
-
|
|
105
|
-
classification
|
|
106
|
-
|
|
111
|
+
- **~~⚠ `ItemClasses.name` has a UNIQUE index~~ — DROPPED 2026-09-11.** It was the first blocker on
|
|
112
|
+
NetSuite-classification work exactly as predicted here; `_modules/netsuite/2026-09-11a -
|
|
113
|
+
ItemClassesNetsuiteHierarchy.sql` drops it and adds a UNIQUE on the new
|
|
114
|
+
`c_netsuiteInternalClassId` instead, so matching is by NetSuite internal id and repeated leaf
|
|
115
|
+
names ("Shipping" x3) are fine. That migration also adds the self-referencing
|
|
116
|
+
`parentItemClassId` FK for the tree.
|
|
107
117
|
- **A hierarchy path column must be `TEXT`, not `varchar(512)`.** NetSuite `fullname` values are
|
|
108
118
|
long, and the 2.0 standard caps varchar at 255.
|
|
109
119
|
- **The table is already `utf8mb4_0900_ai_ci`**, so new columns need no explicit `COLLATE`.
|
|
@@ -117,6 +127,13 @@ Queried live via SuiteQL (2026-09-11): **311 classifications**, columns
|
|
|
117
127
|
would break Northwell's 245 tagged items, which hold ServiceNow service categories.
|
|
118
128
|
|
|
119
129
|
## Change history
|
|
130
|
+
- 2026-09-15 — **Two facts from this audit are now superseded by the shipped NetSuite item-class
|
|
131
|
+
sync.** (1) "Nothing writes `itemClassId`" — `App_Api_Toga2::getCreateItemClass()` is now the
|
|
132
|
+
first writer, opt-in per client, Elite only. (2) The `ItemClasses.name` UNIQUE index — called out
|
|
133
|
+
here as the first blocker — was **dropped** by `_modules/netsuite/2026-09-11a`, which also added
|
|
134
|
+
`parentItemClassId` (self-FK) and a UNIQUE `c_netsuiteInternalClassId`. The Northwell (27 rows)
|
|
135
|
+
and Prudential (7 rows) warning is unaffected and still governs any reuse of this table.
|
|
136
|
+
(rgirish)
|
|
120
137
|
- 2026-09-11 — Documented from a full usage audit of `ItemClasses` across every TOGA codebase and
|
|
121
138
|
all 36 prod client schemas: the table ships blank to all clients but **only Northwell (27 rows,
|
|
122
139
|
ServiceNow service categories) and Prudential (7 rows, Dell device tiers) have data**, for two
|
|
@@ -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-15
|
|
10
10
|
owners: ["bala", "mhammontree", "tcox", "apeterson", "rgirish"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -234,6 +234,16 @@ production `Core.RecordFields` for `Items`:
|
|
|
234
234
|
|---|---|---|---|
|
|
235
235
|
| **107** | `Items.manufacturerId` | `MATCH_UPSERT` | **creates** the manufacturer — get-or-create by name works |
|
|
236
236
|
| **269** | `Items.assetTypeId` | `MATCH` | **fails the entire item write** |
|
|
237
|
+
| **914** | `Items.catalogId` | `MATCH` | **matches** the existing catalog — and can never create one |
|
|
238
|
+
|
|
239
|
+
Row 914 is the benign shape of `MATCH`, and it is worth knowing why it is benign: the match set is
|
|
240
|
+
built from **`isIdentifier`**, and the `catalogs` record carries `isIdentifier = 1` on **`id`,
|
|
241
|
+
`uuid` AND `name`** (verified on production `Core.RecordFields`, 2026-09-15). So
|
|
242
|
+
`catalog: {name: 'Compass'}` resolves to the existing `Catalogs` row by name and **cannot mint a
|
|
243
|
+
new catalog** — which is what makes a name-keyed catalog safe to seed as a per-tenant default
|
|
244
|
+
(see [surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md)). The identifier set
|
|
245
|
+
is per-record data, not a code constant: check `isIdentifier` before assuming a business key is
|
|
246
|
+
matchable.
|
|
237
247
|
|
|
238
248
|
The 1.0 NetSuite importer's `getCreateItem()` contains **both** calls. `getCreateManufacturer()`
|
|
239
249
|
sends `manufacturer: {name}` and has always worked; copying that shape for `assetType: {name}`
|
|
@@ -442,6 +452,11 @@ only one tenant's copy has the wrong flag.
|
|
|
442
452
|
|
|
443
453
|
## Change history
|
|
444
454
|
|
|
455
|
+
- 2026-09-15 — Added **`Items.catalogId` (recordFieldId 914, `MATCH`)** to the childPolicy table as
|
|
456
|
+
the *benign* `MATCH` case, and recorded why: the `catalogs` record has `isIdentifier = 1` on
|
|
457
|
+
**`id`, `uuid` and `name`** (prod `Core.RecordFields`), so `catalog: {name: '<catalog>'}` always
|
|
458
|
+
links the existing row and can never create a catalog. That is what lets a tenant ship a
|
|
459
|
+
name-keyed catalog as an item-create default. (bala)
|
|
445
460
|
- 2026-09-08 — ⚠ Recorded the **third EV-12 flavor: the field is declared in PHP but
|
|
446
461
|
`isIdentifier = 0` in the tenant's `CustomRecordFields`.** `V2.php` (~L7471-7530) builds
|
|
447
462
|
`$recordIdentifiers` purely from DB metadata (`Core.RecordFields` + `Client_<X>.CustomRecordFields`
|
|
@@ -6,13 +6,14 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
10
|
-
owners: [bala, kyalamarthi, apeterson, ajean, jcardinal]
|
|
9
|
+
updated: 2026-09-15
|
|
10
|
+
owners: [bala, kyalamarthi, apeterson, ajean, jcardinal, rgirish]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
|
|
13
13
|
- dbchanges2/Core/2026-08-27a - Insert - Netsuite Location SyncAll CronJob.sql
|
|
14
14
|
- dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
|
|
15
15
|
- dbchanges2/Core/2026-09-08e - Insert QcBatch CronJob.sql
|
|
16
|
+
- dbchanges2/_modules/netsuite/2026-09-11c - ItemClassRecordPermission.sql
|
|
16
17
|
related:
|
|
17
18
|
- ../architecture.md
|
|
18
19
|
- ../../worker2/features/netsuite-salesorder-open-orders-sync.md
|
|
@@ -284,6 +285,20 @@ concluding a migration is production-safe.
|
|
|
284
285
|
|
|
285
286
|
## Gotchas
|
|
286
287
|
|
|
288
|
+
- **⚠ An unguarded ACL grant migration duplicates silently — `AclRecordPermissions` has no unique key
|
|
289
|
+
that stops it (found in prod 2026-09-15).** `_modules/netsuite/2026-09-11c - ItemClassRecordPermission.sql`
|
|
290
|
+
is a plain `INSERT … VALUES` for `(recordId 105, roleId 3)` with **no `NOT EXISTS` guard**. Every
|
|
291
|
+
client checked in production carries **three** identical rows (Elite: ids 346, 347, 348), and its
|
|
292
|
+
paired `AclLogicGroups` insert uses `LIMIT 1` with no guard either — so Elite ended up with **3
|
|
293
|
+
logic groups on permission 346** and **zero** on 347/348. Nothing errored, and access is still
|
|
294
|
+
correct (every row grants the same thing), which is precisely why it went unnoticed across a whole
|
|
295
|
+
module rollout. Two rules this file breaks and the next one should not:
|
|
296
|
+
- Guard on the real identity, `NOT EXISTS (… WHERE recordId = 105 AND roleId = 3)` — the
|
|
297
|
+
declared unique key is `(appId, recordId, roleId, indirectRecordId)`, and `appId`/
|
|
298
|
+
`indirectRecordId` being NULL means MySQL does **not** enforce it.
|
|
299
|
+
- It uses `UUID()`, which the team standard forbids everywhere (see the UUID4 section above).
|
|
300
|
+
Cleanup is safe but must keep **one** permission row *and* its logic group together — delete the
|
|
301
|
+
childless duplicates (347, 348), not the one that owns the group.
|
|
287
302
|
- **A "re-run safe" file is usually only re-run safe *per phase*.** If Phase 1 creates the parent row
|
|
288
303
|
with no `NOT EXISTS` guard, re-running the *whole file* creates a **second parent** and then hangs
|
|
289
304
|
a full set of children off it. Re-run safety of the child phases does not make the file idempotent.
|
|
@@ -318,6 +333,12 @@ concluding a migration is production-safe.
|
|
|
318
333
|
[FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
|
|
319
334
|
|
|
320
335
|
## Change history
|
|
336
|
+
- 2026-09-15 — Added a worked production example of the unguarded-insert trap on an **ACL grant**
|
|
337
|
+
migration: `_modules/netsuite/2026-09-11c` inserts `AclRecordPermissions (105, 3)` with no
|
|
338
|
+
`NOT EXISTS` guard, so every client now has **three** duplicate rows (Elite 346/347/348) and a
|
|
339
|
+
lopsided `AclLogicGroups` fan-out (3 groups on 346, none on 347/348). The declared unique key
|
|
340
|
+
`(appId, recordId, roleId, indirectRecordId)` does **not** protect it because `appId` and
|
|
341
|
+
`indirectRecordId` are NULL. Also flagged the file's use of `UUID()`. (rgirish)
|
|
321
342
|
- 2026-09-09 — Added the rule that **every hardcoded uuid literal must be generated independently at
|
|
322
343
|
random** — never bump a digit / copy-and-tweak / pattern them (guessable, defeats uniqueness); the
|
|
323
344
|
fan-out same-literal reuse is the one intended exception. Restated **never `UUID()`, single-row or
|
|
@@ -6,9 +6,12 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
10
|
-
owners: [jcardinal, apeterson, tcox]
|
|
9
|
+
updated: 2026-09-15
|
|
10
|
+
owners: [jcardinal, apeterson, tcox, bala]
|
|
11
11
|
files:
|
|
12
|
+
- dbchanges2/Core/2026-09-15a - ItemCreateDefaultsSurfaceSeed.sql
|
|
13
|
+
- dbchanges2/Client_Compass/2026-09-15a - ItemCreateCatalogDefault.sql
|
|
14
|
+
- dbchanges2/Client_CompassCanada/2026-09-15a - ItemCreateCatalogDefault.sql
|
|
12
15
|
- dbchanges2/Client_Compass/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql
|
|
13
16
|
- dbchanges2/Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql
|
|
14
17
|
- dbchanges2/Core/2026-09-02a - TalosAssistantSurfaceSeed.sql
|
|
@@ -1372,7 +1375,100 @@ environment and take the **highest** `MAX(id)+1`, never the one in front of you,
|
|
|
1372
1375
|
`Core.SurfaceElements.id` = **233**, next `Core.Messages.id` = **299**. Both consuming files were
|
|
1373
1376
|
still unrun at the time, so this is a reservation, not a deployed state.
|
|
1374
1377
|
|
|
1378
|
+
## A marker element can carry per-tenant WRITE defaults, not just visibility (`item-record-create-defaults`, Core 61, 2026-09-15)
|
|
1379
|
+
|
|
1380
|
+
The marker-element pattern (a hidden element whose `config` is the payload) had so far only carried
|
|
1381
|
+
**visibility / mode** flags. `Core/2026-09-15a - ItemCreateDefaultsSurfaceSeed.sql` uses it to carry
|
|
1382
|
+
**default field VALUES for a create form**: surface **`item-record-create-defaults`** (type
|
|
1383
|
+
`SECTION`, `config.cardType = 'createDefaults'`) with one element, `isVisible = 0`, whose config is
|
|
1384
|
+
|
|
1385
|
+
```json
|
|
1386
|
+
{"role":"createDefaults","values":{}}
|
|
1387
|
+
```
|
|
1388
|
+
|
|
1389
|
+
Core seeds `values` **empty** — the true-neutral default — and each tenant opts in with a single
|
|
1390
|
+
`SurfaceOverrides` **`CONFIG`** row. The consuming app spreads `values` into its `POST` body before
|
|
1391
|
+
the form fields, so a default never beats real user input. FE side:
|
|
1392
|
+
[surface-frontend](../../toga25-supply/features/surface-frontend.md). This shipped to fix items
|
|
1393
|
+
created in toga25-supply being written with `Items.catalogId = NULL` (28 rows in prod
|
|
1394
|
+
`Client_Compass`, 6 in `Client_CompassCanada`).
|
|
1395
|
+
|
|
1396
|
+
**Why `name` and not `uuid` in the seeded value.** `Items.catalogId` is `Core.RecordFields` **914**
|
|
1397
|
+
with `childPolicy = MATCH`, and the `catalogs` record has `isIdentifier = 1` on `id`, `uuid` **and**
|
|
1398
|
+
`name` — so a name matches the existing row and **can never create a catalog**
|
|
1399
|
+
([nested-relationship writes](../../api2/features/nested-relationship-writes.md)). A name also keeps
|
|
1400
|
+
the seed readable and tenant-portable, where a uuid would not be.
|
|
1401
|
+
|
|
1402
|
+
**The two tenants spell the catalog differently, and the seeds use the STORED spelling:**
|
|
1403
|
+
|
|
1404
|
+
| Tenant | Seeded value | `Catalogs` reality |
|
|
1405
|
+
|---|---|---|
|
|
1406
|
+
| `Client_Compass` | `{"catalog":{"name":"Compass"}}` | id 1 **`Compass`** (also id 2 `Agilant`, id 3 `Office Depot`) |
|
|
1407
|
+
| `Client_CompassCanada` | `{"catalog":{"name":"compass"}}` | id 1 **`compass`** — the only row |
|
|
1408
|
+
|
|
1409
|
+
Both `name` columns are case-insensitive (`utf8mb4_unicode_ci` on Compass,
|
|
1410
|
+
`utf8mb4_0900_ai_ci` on Canada), so either spelling would in fact match; matching the stored
|
|
1411
|
+
spelling is a readability rule, not a correctness one. Catalog background:
|
|
1412
|
+
[Compass item catalogs](../../../clients/compass-usa/features/item-catalogs-and-duplicate-items.md).
|
|
1413
|
+
|
|
1414
|
+
### Both client files restate `role` — the CONFIG-replaces-wholesale rule, hit again
|
|
1415
|
+
|
|
1416
|
+
A `CONFIG` override **replaces the element's config object entirely** (`Surface.php:839`, see
|
|
1417
|
+
[the load-bearing rule](#-a-config-override-replaces-config-wholesale--this-is-the-load-bearing-rule-of-the-whole-design)),
|
|
1418
|
+
so each client row must ship **`role` AND `values`**:
|
|
1419
|
+
|
|
1420
|
+
```json
|
|
1421
|
+
{"role":"createDefaults","values":{"catalog":{"name":"Compass"}}}
|
|
1422
|
+
```
|
|
1423
|
+
|
|
1424
|
+
Send only `values` and the element loses its `role`, the adapter stops finding the marker, and the
|
|
1425
|
+
default silently disappears with no error anywhere. This is now the **second** feature to hit it
|
|
1426
|
+
(after the Talos suggestion chips) — treat "restate every key the FE matches on" as the default
|
|
1427
|
+
authoring step for any CONFIG override, not a special case.
|
|
1428
|
+
|
|
1429
|
+
### The client files hardcode the Core ids — and the 2026-07-20d precedent is NOT copyable
|
|
1430
|
+
|
|
1431
|
+
`Client_Compass/2026-09-15a` and `Client_CompassCanada/2026-09-15a` inline the Core `surfaceId` /
|
|
1432
|
+
`surfaceElementId` read from Core **after** the Core file was run, and guard re-runs with
|
|
1433
|
+
`SELECT … FROM DUAL WHERE NOT EXISTS (…)` against the **client table only** — no `Core.*` read at
|
|
1434
|
+
all.
|
|
1435
|
+
|
|
1436
|
+
**Confirmed again by a real failure this session:** querying `Client_Compass` with a join to `Core`
|
|
1437
|
+
returns `OperationalError (1049, "Unknown database 'Core'")` on the `prod-client` cluster. The
|
|
1438
|
+
nearest existing example, `Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql`, resolves
|
|
1439
|
+
its element id with `FROM Core.SurfaceElements JOIN Core.Surfaces` — **do not copy it.** It is one of
|
|
1440
|
+
the ~51 stale cross-cluster files described above; it landed in prod by hand transposition, not by
|
|
1441
|
+
running as written.
|
|
1442
|
+
|
|
1443
|
+
### Ids consumed — and beta must be RE-READ, not reused
|
|
1444
|
+
|
|
1445
|
+
`Surfaces`/`SurfaceElements` ids differ sharply per environment: before this work prod Core was at
|
|
1446
|
+
`Surfaces` **60** / `SurfaceElements` **233**, while dev-sandbox (beta) was at **23** / **65**. The
|
|
1447
|
+
developer ran the Core file **on PROD**, taking **surface 61** and **element 234**, and the two
|
|
1448
|
+
client files hardcode those prod ids.
|
|
1449
|
+
|
|
1450
|
+
> ⚠ **Beta has not had the Core file run.** Running the client files on beta as written would point
|
|
1451
|
+
> the overrides at ids that mean something else (or nothing) there. Re-read the beta ids after
|
|
1452
|
+
> running the Core file and swap them in before running the client files.
|
|
1453
|
+
|
|
1454
|
+
**Handoff published (verify before your own run):** next prod `Core.Surfaces.id` = **62**, next prod
|
|
1455
|
+
`Core.SurfaceElements.id` = **235**.
|
|
1456
|
+
|
|
1375
1457
|
## Change history
|
|
1458
|
+
- 2026-09-15 — Added the **`item-record-create-defaults`** seed trio (`Core/2026-09-15a` +
|
|
1459
|
+
`Client_Compass` / `Client_CompassCanada` `2026-09-15a - ItemCreateCatalogDefault.sql`), which
|
|
1460
|
+
extends the marker-element pattern from visibility flags to **per-tenant create-form default
|
|
1461
|
+
VALUES** (`config {"role":"createDefaults","values":{}}`, Core seeds `values` empty, each tenant
|
|
1462
|
+
overrides). Shipped to stop toga25-supply writing `Items.catalogId = NULL`. Recorded: the value is
|
|
1463
|
+
seeded by catalog **name** because `RecordFields` 914 is `MATCH` over a record with
|
|
1464
|
+
`isIdentifier` on `name` (so it can never mint a catalog); the two tenants store the name with
|
|
1465
|
+
different case (`Compass` vs `compass`, both CI collations); both client files must restate
|
|
1466
|
+
**`role`** because a CONFIG override replaces the config wholesale (second feature to hit that);
|
|
1467
|
+
and the client files hardcode Core ids with a client-only `NOT EXISTS` guard — re-confirmed by a
|
|
1468
|
+
live `1049 Unknown database 'Core'` on `prod-client`, which also makes the older
|
|
1469
|
+
`2026-07-20d - ItemRecordVendorItemsEnable.sql` a NON-copyable precedent. Prod consumed
|
|
1470
|
+
**Surface 61 / SurfaceElement 234** (beta is at 23 / 65 and has NOT run the Core file — re-read
|
|
1471
|
+
its ids first); next prod ids = **62** / **235**. (bala)
|
|
1376
1472
|
- 2026-09-04 — Added the **restrict-to-persona opt-in pair** (`Client_Compass/2026-09-04a` +
|
|
1377
1473
|
`Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql`): client-wide
|
|
1378
1474
|
`IS_VISIBLE`+`IS_ENABLED` overrides on `surfaceId` **19** / `surfaceElementId` **57**, literal Core
|
|
@@ -6,8 +6,8 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
10
|
-
owners: [jcardinal, apeterson, tcox, rgirish]
|
|
9
|
+
updated: 2026-09-15
|
|
10
|
+
owners: [jcardinal, apeterson, tcox, rgirish, bala]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.ts
|
|
13
13
|
- toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.test.ts
|
|
@@ -71,6 +71,7 @@ files:
|
|
|
71
71
|
- toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts
|
|
72
72
|
- toga25-supply/src/fieldsConfig/index.ts
|
|
73
73
|
- toga25-supply/src/layout/ItemRecordModalLayout/helpers/surfaceBundleToItemFields.ts
|
|
74
|
+
- toga25-supply/src/layout/ItemRecordModalLayout/hooks/useItemCreateForm.tsx
|
|
74
75
|
- toga25-supply/src/layout/ItemRecordModalLayout/helpers/index.ts
|
|
75
76
|
- toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx
|
|
76
77
|
- toga25-supply/src/layout/ItemRecordModalLayout/ItemRecordModalLayout.tsx
|
|
@@ -402,6 +403,64 @@ a `computeValueKey`, grep the consuming view's registry for that literal string
|
|
|
402
403
|
> makes the field visible and therefore **crashes the modal**; the FE alone changes nothing visible.
|
|
403
404
|
> Same shape as the approvals-gate ship-together dependency.
|
|
404
405
|
|
|
406
|
+
## Item CREATE defaults also come from Surface — and a missing default wrote NULL `catalogId` (2026-09-15)
|
|
407
|
+
|
|
408
|
+
The item modal's **create** payload now takes its per-tenant defaults from the Surface layer instead
|
|
409
|
+
of a client branch in code. This started as a data bug: `useItemCreateForm` built the `POST /items`
|
|
410
|
+
body with **no catalog at all**, so every item created in this app was saved with
|
|
411
|
+
`Items.catalogId = NULL`. The old `toga2-supply` `CreateItemModal.tsx` had always sent
|
|
412
|
+
`catalog: { name: "Compass" }` — which is why only the 2.5 app produced NULL rows, and why nobody
|
|
413
|
+
saw it in the older app. **Measured on prod 2026-09-15:** `Client_Compass.Items` held **28** rows
|
|
414
|
+
with a NULL `catalogId` (newest 2026-09-14) against 1,509 on catalog 1, and
|
|
415
|
+
`Client_CompassCanada.Items` **6** NULL against 221. A NULL-catalog item **never reaches the
|
|
416
|
+
storefront** — see
|
|
417
|
+
[Compass item catalogs](../../../clients/compass-usa/features/item-catalogs-and-duplicate-items.md).
|
|
418
|
+
|
|
419
|
+
**How it works now** — the same marker-element shape as `item-record-vendor-items`, read by a new
|
|
420
|
+
adapter:
|
|
421
|
+
|
|
422
|
+
- New Core surface **`item-record-create-defaults`** (type `SECTION`, `surface.config.cardType =
|
|
423
|
+
'createDefaults'`) carrying **one non-rendered marker element** with
|
|
424
|
+
`config = {"role":"createDefaults","values":{}}` and `isVisible = 0`.
|
|
425
|
+
- The slug was added to **`ITEM_RECORD_SURFACE_SLUGS`**, so it rides the group fetch the record
|
|
426
|
+
modal already makes — no new request.
|
|
427
|
+
- **`surfaceBundleToItemCreateDefaults()`** (`helpers/surfaceBundleToItemFields.ts`, exported via
|
|
428
|
+
`helpers/index.ts`) finds the marker by `config.role === 'createDefaults'` and returns its
|
|
429
|
+
`config.values` object.
|
|
430
|
+
- `useItemCreateForm` **spreads the defaults FIRST** into the `POST /items` body, so a real form
|
|
431
|
+
field always wins over a default. Order is the whole contract here.
|
|
432
|
+
- A tenant opts in with one `SurfaceOverrides` CONFIG row. Compass seeds
|
|
433
|
+
`{"catalog":{"name":"Compass"}}`; Compass Canada seeds `{"catalog":{"name":"compass"}}` — the
|
|
434
|
+
spelling differs per tenant, see
|
|
435
|
+
[surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md).
|
|
436
|
+
|
|
437
|
+
**Why this is the right seam:** a tenant default is presentation/tenant config, so it belongs in the
|
|
438
|
+
surface layer, not in a hostname or client-slug switch in the create form. Adding the next default
|
|
439
|
+
(vendor, inventory type, anything) is now a `values` key plus a seed row — zero FE change.
|
|
440
|
+
|
|
441
|
+
### Adding a slug to a group fetch is SAFE on unseeded clients and environments
|
|
442
|
+
|
|
443
|
+
`_buildBundle` returns `{ surface: null, elements: [] }` when `$surface->load()` fails, so a slug
|
|
444
|
+
that has not been seeded in a tenant (or a whole environment where the Core file has not run) simply
|
|
445
|
+
resolves to an empty bundle. The adapter then returns `{}` and the create payload is byte-identical
|
|
446
|
+
to before. Verified against `Model/Core/Surface.php` — see
|
|
447
|
+
[surface-resolver](../../_underscore/features/surface-resolver.md). **So the FE can ship before the
|
|
448
|
+
SQL runs anywhere** — the reverse of the restrict-to-persona deploy-order rule above, because this
|
|
449
|
+
element renders nothing.
|
|
450
|
+
|
|
451
|
+
### Sending `catalog` by NAME does not trip the Compass POST interceptor
|
|
452
|
+
|
|
453
|
+
`_Model_Compass_Item::postPost` throws *"Could not load catalog uuid"* when it receives a catalog
|
|
454
|
+
with no uuid, which looks like a reason to send a uuid instead of a name. It is not. api2 invokes the
|
|
455
|
+
POST payload interceptor with **`$outData` — the created record, not the submitted body**
|
|
456
|
+
(`V2.php:6019`), and the created record always carries the resolved catalog uuid. Compass has an
|
|
457
|
+
active POST/POST interceptor on `recordId 21` (Items); Compass Canada has one too, but there is no
|
|
458
|
+
`_Model_CompassCanada_Item`, so it falls back to `_Model_Client_Item::postPost`, which returns early
|
|
459
|
+
unless `isFulfillable` was sent. Background:
|
|
460
|
+
[API payload interceptors](../../api2/features/api-payload-interceptors.md). The name→row match
|
|
461
|
+
itself is safe because `Items.catalogId` is a `MATCH` field over a record whose `name` is an
|
|
462
|
+
identifier — [nested-relationship writes](../../api2/features/nested-relationship-writes.md).
|
|
463
|
+
|
|
405
464
|
## SalesOrders approval-decision modal (approve + deny) — migrated via an OVERLAY adapter seam
|
|
406
465
|
|
|
407
466
|
The SalesOrder approve/deny decision modal's config now sources its display fields from Surface,
|
|
@@ -1100,6 +1159,17 @@ claim is about the `navigation-*` **flags** being inert — still true — not a
|
|
|
1100
1159
|
unused).
|
|
1101
1160
|
|
|
1102
1161
|
## Change history
|
|
1162
|
+
- 2026-09-15 — **Item create defaults moved to the Surface layer, fixing NULL `catalogId` on every
|
|
1163
|
+
item created in this app.** `useItemCreateForm` sent no catalog at all (prod: 28 NULL-catalog rows
|
|
1164
|
+
in `Client_Compass`, 6 in `Client_CompassCanada`; the older `toga2-supply` modal had always sent
|
|
1165
|
+
`catalog: {name:"Compass"}`), and a NULL-catalog item never reaches the storefront. New Core
|
|
1166
|
+
surface `item-record-create-defaults` carries a hidden `config.role='createDefaults'` marker whose
|
|
1167
|
+
`config.values` each tenant overrides; the slug rides the existing `ITEM_RECORD_SURFACE_SLUGS`
|
|
1168
|
+
group fetch, `surfaceBundleToItemCreateDefaults()` adapts it, and the create form spreads it
|
|
1169
|
+
**first** so form fields still win. Recorded two rules that make this cheap: an unseeded slug
|
|
1170
|
+
resolves to an empty bundle (so FE can ship before the SQL, unlike the restrict-to-persona case),
|
|
1171
|
+
and sending `catalog` by **name** cannot trip `_Model_Compass_Item::postPost` because api2 hands a
|
|
1172
|
+
POST interceptor the created record, not the request body. (bala)
|
|
1103
1173
|
- 2026-09-04 — Fixed the item modal's **More Info → "Restrict to Persona"** field missing in VIEW for
|
|
1104
1174
|
Compass USA + Canada. Two causes, both now rules here: (1) the item modal's VIEW is Surface-driven
|
|
1105
1175
|
while EDIT is still JSON, so a Core-default-OFF element with no client `SurfaceOverride` vanishes
|
package/knowledge/INDEX.md
CHANGED
|
@@ -4,7 +4,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
4
4
|
|
|
5
5
|
## 1.0 framework
|
|
6
6
|
|
|
7
|
-
- **library** (Library) _(framework core)_ —
|
|
7
|
+
- **library** (Library) _(framework core)_ — 26 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
|
|
8
8
|
- **worker** (Worker) — 35 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
|
|
9
9
|
- **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
|
|
10
10
|
- **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
|
|
@@ -17,7 +17,7 @@ project: _Underscore
|
|
|
17
17
|
client: compass-canada
|
|
18
18
|
type: profile
|
|
19
19
|
status: active
|
|
20
|
-
updated: 2026-09-
|
|
20
|
+
updated: 2026-09-15
|
|
21
21
|
owners: [jcardinal, bala, tcox, apeterson, ajean]
|
|
22
22
|
files: []
|
|
23
23
|
related:
|
|
@@ -61,6 +61,13 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
|
|
|
61
61
|
resolution + client constants that all 8 Canada crons depend on. `library` is in `apps` for that
|
|
62
62
|
reason.
|
|
63
63
|
|
|
64
|
+
- **Catalogs: one row, lowercase.** `Client_CompassCanada.Catalogs` holds a **single** row, id 1
|
|
65
|
+
name **`compass`** (lowercase) — not the `Compass` / `Agilant` / `Office Depot` trio Compass USA
|
|
66
|
+
has ([item catalogs](../compass-usa/features/item-catalogs-and-duplicate-items.md)). The column
|
|
67
|
+
collation is case-insensitive, but write the stored spelling when seeding a value that references
|
|
68
|
+
it by name. Items created in `toga25-supply` were saved with a NULL `catalogId` until 2026-09-15
|
|
69
|
+
(6 rows here); the fix seeds the catalog as a Surface create-default per tenant.
|
|
70
|
+
|
|
64
71
|
## Vendors & integrations
|
|
65
72
|
- **Grand & Toy (G&T)** — primary hardware vendor. SOs flow toga → MITS → PO to G&T; G&T sends
|
|
66
73
|
back ASNs. ASN ingestion (email CSV + the auto-created ItemFulfillment chain + bilingual
|
|
@@ -6,7 +6,7 @@ project: Library
|
|
|
6
6
|
client: compass-usa
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-15
|
|
10
10
|
owners: ["bala", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- library/app/api/toga2.php
|
|
@@ -214,6 +214,15 @@ children" rule:
|
|
|
214
214
|
- **⚠ A NULL `catalogId` is a real state** (27 items in prod, 22 ODP `VendorItems` point at such
|
|
215
215
|
items). Anything joining through `Catalogs` **drops those rows silently** — an inner join to
|
|
216
216
|
`Catalogs` is itself a filter.
|
|
217
|
+
**Where the newest NULL rows came from is now known, and it is fixed (2026-09-15):** the
|
|
218
|
+
`toga25-supply` item **create** form posted no catalog at all, so every item created in the 2.5 app
|
|
219
|
+
was born with `catalogId = NULL` — **28** such rows in `Client_Compass` (newest 2026-09-14) and
|
|
220
|
+
**6** in `Client_CompassCanada`, against 1,509 / 221 on catalog 1. A NULL-catalog item never
|
|
221
|
+
reaches the storefront. The create form now takes `catalog: {name: 'Compass'}` from a seeded
|
|
222
|
+
Surface default instead of hardcoding it (the older `toga2-supply` modal always sent it, which is
|
|
223
|
+
why the NULL rows are all from the new app) — see
|
|
224
|
+
[surface-frontend](../../../2.0/apps/toga25-supply/features/surface-frontend.md). The existing NULL
|
|
225
|
+
rows still need a data repair; the leak is closed, the backlog is not.
|
|
217
226
|
- **⚠ `BundleItems.itemId` is a silent catalog-resolution consumer — the storefront never
|
|
218
227
|
re-catalogs it.** The `toga2-commerce` storefront relays whatever item `GET /v2/bundles/{uuid}`
|
|
219
228
|
returns; its `catalogId = 1` filter guards standalone item search only, never bundles. So a kit
|
|
@@ -241,6 +250,11 @@ children" rule:
|
|
|
241
250
|
[catalog-fold reversal](../workflows/catalog-fold-reversal.md).
|
|
242
251
|
|
|
243
252
|
## Change history
|
|
253
|
+
- 2026-09-15 — Identified the **source of the newest NULL-`catalogId` items**: the `toga25-supply`
|
|
254
|
+
create form sent no catalog, so every item created in the 2.5 app was born catalog-less (**28** rows
|
|
255
|
+
in `Client_Compass`, newest 2026-09-14; **6** in `Client_CompassCanada`), and such an item never
|
|
256
|
+
reaches the storefront. The leak is closed — the create payload now takes its catalog from a seeded
|
|
257
|
+
Surface default — but the existing NULL rows are still unrepaired. (bala)
|
|
244
258
|
- 2026-09-02 — **Narrowed the same-day uniqueness resolution below.** `Client_Compass.Items` holds
|
|
245
259
|
`MD7F4LL/A-S` **twice** — id 2760 (`title` set, `isActive = 0`, the row all referencing bundles
|
|
246
260
|
use) and id 2784 (`title` NULL, `isActive = 1`, unused) — a pair from outside the fold population, so
|
|
@@ -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-15
|
|
10
10
|
owners: ["snaredla", "jcardinal", "rgirish"]
|
|
11
11
|
files:
|
|
12
12
|
- worker/crons/toga2/netsuite/sync_togasupply_elite.php
|
|
@@ -27,6 +27,7 @@ files:
|
|
|
27
27
|
related:
|
|
28
28
|
- ../profile.md
|
|
29
29
|
- ../../../1.0/apps/library/features/netsuite-item-assettype-sync.md
|
|
30
|
+
- ../../../1.0/apps/library/features/netsuite-item-class-sync.md
|
|
30
31
|
- ../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
|
|
31
32
|
- ../../../1.0/apps/worker/workflows/onboarding-client-to-netsuite-togasupply-sync.md
|
|
32
33
|
- ./salesorder-netsuite-push.md
|
|
@@ -119,10 +120,11 @@ migration file.
|
|
|
119
120
|
|
|
120
121
|
### Elite-only wrapper deviations
|
|
121
122
|
|
|
122
|
-
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
- **~~`MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS = 2592000` (30 days)~~ — REMOVED, and the
|
|
124
|
+
backfill it existed for is done (verified 2026-09-15).** Elite's wrapper no longer overrides the
|
|
125
|
+
cap, so it now inherits the shared **864000 (10 days)**. Keep this in mind before planning a
|
|
126
|
+
rewind: one Elite run advances 10 days, not 30. (The engine still `define()`s rather than
|
|
127
|
+
`const`s the cap, so an override remains possible if another deep backfill is ever needed.)
|
|
126
128
|
- **⚠ EVERY integration flag is now `true` (2026-09-04) — including invoices, and that carries a
|
|
127
129
|
live risk.** `_SALES_ORDERS`, `_INVOICES`, `_ITEM_RECEIPTS`, `_ITEM_FULFILLMENTS`,
|
|
128
130
|
`_INVENTORY_ADJUSTMENTS` and `_TRANSFER_ORDERS` were all flipped `false → true`
|
|
@@ -266,6 +268,37 @@ failure/in-progress flag (you want `IDLE`), and the cap is **864000** (10 days),
|
|
|
266
268
|
takes many runs. **Only reset after the underlying error is fixed**, or it just fails and shrinks
|
|
267
269
|
again.
|
|
268
270
|
|
|
271
|
+
### Elite is also the ONLY client opted in to itemClass + line fulfillmentType (2026-09-15)
|
|
272
|
+
|
|
273
|
+
Same shape as assetType above: two constants in Elite's wrapper, and nothing else turns them on.
|
|
274
|
+
|
|
275
|
+
```php
|
|
276
|
+
const IS_ENABLED_NETSUITE_LINE_FULFILLMENT_TYPE = true; // :60
|
|
277
|
+
const IS_ENABLED_NETSUITE_ITEM_CLASSIFICATIONS = true; // :65
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Without them `toga2.php` omits the `fulfillmentType` and `itemClass` keys entirely, so **no other
|
|
281
|
+
client can hit `EV-9` from this work.** Confirmed by grep across all 19 `sync_togasupply_*.php`
|
|
282
|
+
launchers. Mechanics and the field-name trap live on the
|
|
283
|
+
[NetSuite item-class sync doc](../../../1.0/apps/library/features/netsuite-item-class-sync.md).
|
|
284
|
+
|
|
285
|
+
**Elite's ACL is fully provisioned — verified in production 2026-09-15.** All three layers are
|
|
286
|
+
granted, so no further ACL work is owed for Elite:
|
|
287
|
+
|
|
288
|
+
| Layer | Verified |
|
|
289
|
+
|---|---|
|
|
290
|
+
| `AclFieldPermissions` roleId 3 writable | RecordFields **601-605** + **2596** (605 = `Items.itemClassId`, 2596 = `ItemClasses.parentItemClassId`) and **2594/2595** (`SalesOrderItems`/`PurchaseOrderItems.fulfillmentType`) |
|
|
291
|
+
| `AclRecordPermissions` roleId 3 | record **105** (Item classes) create/read/update = 1; `allowUpdate = 1` on records **21** (Items) and **18** (Purchase order items) |
|
|
292
|
+
| `AclCustomFieldPermissions` | `c_netsuiteInternalClassId` (`CustomRecordFields` id 79, recordId 105) |
|
|
293
|
+
| paired `AclLogicGroups` | present for the record-105 permission |
|
|
294
|
+
|
|
295
|
+
Records **15, 18, 21, 105 are all `aclDatabase = CLIENT`**, so every one of these grants lives in
|
|
296
|
+
`Client_Elite`, not `Core` — the usual place this goes wrong.
|
|
297
|
+
|
|
298
|
+
**Elite has 3 duplicate `AclRecordPermissions` rows** for 105/roleId 3 (ids 346, 347, 348) and 3
|
|
299
|
+
logic groups on id 346, from the unguarded `2026-09-11c` migration. Harmless, but see the
|
|
300
|
+
[item-class doc](../../../1.0/apps/library/features/netsuite-item-class-sync.md) before cleaning.
|
|
301
|
+
|
|
269
302
|
## Gotchas / known issues
|
|
270
303
|
|
|
271
304
|
- **⚠ OPEN, unrelated: `GET /v2/notes/{uuid}/files` returns 405 `EV-7`** ("method not allowed /
|
|
@@ -307,6 +340,17 @@ again.
|
|
|
307
340
|
|
|
308
341
|
## Change history
|
|
309
342
|
|
|
343
|
+
- 2026-09-15 — **itemClass + line `fulfillmentType` are now on for Elite, and Elite alone.** Recorded
|
|
344
|
+
the two wrapper constants (`IS_ENABLED_NETSUITE_ITEM_CLASSIFICATIONS`,
|
|
345
|
+
`IS_ENABLED_NETSUITE_LINE_FULFILLMENT_TYPE`) that gate the whole feature — no other launcher
|
|
346
|
+
defines either, so no other client is exposed. **Verified all three ACL layers in production**
|
|
347
|
+
(fields 601-605/2596 + 2594/2595, record 105 create/read/update plus `allowUpdate` on records 21
|
|
348
|
+
and 18, and the `c_netsuiteInternalClassId` custom-field grant), all in `Client_Elite` because
|
|
349
|
+
records 15/18/21/105 are `aclDatabase = CLIENT`. Also noted Elite's 3 duplicate
|
|
350
|
+
`AclRecordPermissions` rows from the unguarded `2026-09-11c` migration. The feature itself was a
|
|
351
|
+
no-op until a field-name fix landed the same day — see
|
|
352
|
+
[NetSuite item-class sync](../../../1.0/apps/library/features/netsuite-item-class-sync.md).
|
|
353
|
+
(rgirish)
|
|
310
354
|
- 2026-09-08 — **Elite sales-order sync was fully blocked for 4 days by a transfer-order 400 EV-12**,
|
|
311
355
|
and the reported symptom ("transfer orders missing") hid the real blast radius: nothing after
|
|
312
356
|
`2026-09-04 14:02:52` synced. Root cause was **metadata, not code** —
|
package/package.json
CHANGED