toga-ai 1.0.813 → 1.0.815
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/2.0/apps/_underscore/INDEX.md +1 -0
- package/knowledge/2.0/apps/_underscore/features/calculated-column-filter-options.md +128 -0
- package/knowledge/2.0/apps/_underscore/features/sales-order-status-filter-surface.md +7 -1
- package/knowledge/2.0/apps/toga-blox/features/table.md +21 -0
- package/knowledge/2.0/apps/worker2/architecture.md +11 -1
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-client-labels.md +27 -1
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +142 -20
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
| [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's |
|
|
12
12
|
| [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has |
|
|
13
13
|
| [FIELD_AUTOINCREMENT record numbering (SA / TA prefixes, NULL-only trigger, no padding)](features/autoincrement-record-numbering.md) | A human-facing record number (`SA100001`, `TA100000`) is generated by the ORM, not by MySQL. |
|
|
14
|
+
| [Calculated-column filter options (`<field>FilterOptions` model static → table meta)](features/calculated-column-filter-options.md) | A seam that lets a **calculated (`FIELD_SQL`) table column supply its own filter options**, so the front end renders a multi-select instead of a free-text searc |
|
|
14
15
|
| [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent |
|
|
15
16
|
| [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki |
|
|
16
17
|
| [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the |
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Calculated-column filter options (`<field>FilterOptions` model static → table meta)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-15
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model/Client/TableView.php
|
|
13
|
+
- _underscore/Model/Compass/SalesOrder.php
|
|
14
|
+
- _underscore/Model.php
|
|
15
|
+
related:
|
|
16
|
+
- sales-order-status-filter-surface.md
|
|
17
|
+
- tableview-joins.md
|
|
18
|
+
- ../../toga-blox/features/table.md
|
|
19
|
+
- ../../api2/features/tableview-field-metadata.md
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## What it is
|
|
23
|
+
|
|
24
|
+
A seam that lets a **calculated (`FIELD_SQL`) table column supply its own filter options**, so the
|
|
25
|
+
front end renders a multi-select instead of a free-text search.
|
|
26
|
+
|
|
27
|
+
Before this, `filterOptions` in table meta was filled in **only** for `TYPE__STATUS` columns, via
|
|
28
|
+
`getStatusFilterOptions()` (a Surface `FILTER_SET` bound to a status vocabulary — see
|
|
29
|
+
[sales-order-status-filter-surface](sales-order-status-filter-surface.md)). That path cannot serve a
|
|
30
|
+
column whose options are **live data** (e.g. the list of active Admin users): seeding a surface
|
|
31
|
+
element per user would go stale constantly.
|
|
32
|
+
|
|
33
|
+
First and only consumer today: the Compass **Assigned Admin** column on the sales-orders table.
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
`_Model_Client_TableView::meta()`, in the per-field loop right after
|
|
38
|
+
`$modelFieldConfig = (new $modelName)->getFieldConfig();`, calls
|
|
39
|
+
`$modelName::{$field}FilterOptions()` when **all three** hold:
|
|
40
|
+
|
|
41
|
+
1. the column has no `filterOptions` yet,
|
|
42
|
+
2. `isFilterable` is set,
|
|
43
|
+
3. the model field config type is `_Model::FIELD_SQL`,
|
|
44
|
+
|
|
45
|
+
and the static exists (`method_exists` guard). Suffix constant:
|
|
46
|
+
`_Model_Client_TableView::FILTER_OPTIONS_METHOD_SUFFIX = 'FilterOptions'`.
|
|
47
|
+
|
|
48
|
+
Returning **null** means "no opinion" — the column keeps its free-text search rather than rendering
|
|
49
|
+
an empty dropdown. Same contract as `getStatusFilterOptions()`.
|
|
50
|
+
|
|
51
|
+
**This is not a new convention.** `_underscore/Model.php` (the `FIELD_SQL` case, ~line 135) already
|
|
52
|
+
throws when a `FIELD_SQL` field has no static of the same name — that is why `_assignedAdmin()`
|
|
53
|
+
exists. The seam just adds a `FilterOptions` suffix to the same name-derived rule.
|
|
54
|
+
|
|
55
|
+
It is a **no-op for every existing column**: nothing else declares a `<field>FilterOptions` static.
|
|
56
|
+
|
|
57
|
+
### The Compass example
|
|
58
|
+
|
|
59
|
+
`_Model_Compass_SalesOrder::_assignedAdminFilterOptions(): ?array` runs a raw `_Query` on
|
|
60
|
+
`_underscore::DB_CLIENT` joining `Users` / `Users_Roles` / `Roles`, filtered
|
|
61
|
+
`Users.isActive = 1 AND Roles.name = ROLE_NAME__ADMIN` (`'Admin'`), and returns
|
|
62
|
+
`[{slug, name}]` where **both** are `CONCAT(firstName, ' ', lastName)`.
|
|
63
|
+
|
|
64
|
+
- **The value must be the NAME, not a uuid.** `_assignedAdmin()` is a correlated subquery that
|
|
65
|
+
resolves to the assignee's `CONCAT(firstName, ' ', lastName)`, so the filter compares against that
|
|
66
|
+
string; a uuid option would match zero rows.
|
|
67
|
+
- **Both Compass tenants are covered by inheritance** — `_Model_Compass_Usa_SalesOrder` and
|
|
68
|
+
`_Model_Compass_Canada_SalesOrder` both extend `_Model_Compass_SalesOrder`. No per-client copy.
|
|
69
|
+
- Verified locally: 13 options for `Client_Compass`, 5 for `Client_CompassCanada`. Compass Canada
|
|
70
|
+
also has a role `Agilant - Administrators`, correctly excluded by the exact `name = 'Admin'` match.
|
|
71
|
+
|
|
72
|
+
## Why a model static and not a config column
|
|
73
|
+
|
|
74
|
+
A CTO review compared this against adding a `TableViewFields` column holding a query descriptor
|
|
75
|
+
(route + where + label field) and **agreed** on the model static: the descriptor could not express
|
|
76
|
+
the required two-table `Users_Roles` / `Roles` join without growing into a query language inside a
|
|
77
|
+
config column, and it would spend a multi-tenant schema change to serve one column. Revisit the
|
|
78
|
+
generic version at roughly **4–5** such columns, driven by real examples.
|
|
79
|
+
|
|
80
|
+
## Gotchas
|
|
81
|
+
|
|
82
|
+
- **`filterOptions` alone is the front-end gate.** `PrimaryTableHeaderCell` renders
|
|
83
|
+
`HeaderFilterMultiselect` whenever `isFilterable && filterOptions`, and the free-text
|
|
84
|
+
`HeaderFilterSearch` only when `isFilterable && !filterOptions`. There is **no type gate** on the
|
|
85
|
+
multi-select branch — a `STRING` column with options gets a multi-select. `recordRoute` and
|
|
86
|
+
`surfaceSlug` appear on status columns only because a status column's options come from a
|
|
87
|
+
`FILTER_SET` surface; they are **not** required. `recordRoute` is only a *fallback* fetch
|
|
88
|
+
(`needsFetchedOptions = !!field.recordRoute && !field.filterOptions`), so
|
|
89
|
+
`filterOptions` with `recordRoute: null` is fine. Verified against
|
|
90
|
+
`@agilant/toga-blox 1.1.2-production.144` (the build toga25-supply pins), in `node_modules/dist`.
|
|
91
|
+
- **The `$recordField` may be a `_Model_Client_CustomRecordField`, not a `_Model_Core_RecordField`.**
|
|
92
|
+
The Compass Assigned Admin/Assigned Manager columns are backed by `CustomRecordFields` in the
|
|
93
|
+
**Client** DB: `Client_Compass.TableViewFields` id 192 slug `assigned-admin` →
|
|
94
|
+
`customRecordFieldId` 96 (`recordFieldId` NULL); `Client_CompassCanada.TableViewFields` id 211 →
|
|
95
|
+
`customRecordFieldId` 57. Both custom fields are `field = '_assignedAdmin'`, `type = 'STRING'`,
|
|
96
|
+
`recordId = 14`. Both classes expose `->field` and `->type` identically, so field-name-keyed logic
|
|
97
|
+
works either way — just do not assume a Core RecordField.
|
|
98
|
+
- **Key on the FIELD name, not the column slug.** The slug is `assigned-admin` (hyphen); the model
|
|
99
|
+
field is `_assignedAdmin`. The seam uses the field name.
|
|
100
|
+
- **Type `STRING` (not STATUS/SELECT) is what lets the branch fire** — the existing `switch` in
|
|
101
|
+
`meta()` leaves `filterOptions` null for it.
|
|
102
|
+
- **⚠ Name-as-identifier limit.** The option value is a display name, not a user id. Two admins with
|
|
103
|
+
the same full name collapse into one option, and picking it matches both of their orders.
|
|
104
|
+
`SELECT DISTINCT` dedupes strings, not people. Forced by `_assignedAdmin()` resolving to a name —
|
|
105
|
+
the same pattern `_assignedManager()` already uses.
|
|
106
|
+
- **Matching a role by its human-readable `name` is fragile** (flagged by `sql-reviewer`): a client
|
|
107
|
+
renaming the `Admin` role silently empties the list. A stable role id would be sturdier.
|
|
108
|
+
- **⚠ Filtering a calculated column lands in HAVING, not WHERE.**
|
|
109
|
+
`api2/Component/Api/V2/V2.php` `splitWhereExpressionsIntoWhereHaving` routes a where on a
|
|
110
|
+
calculated field into `HAVING`, so the `_assignedAdmin` correlated subquery is evaluated for every
|
|
111
|
+
row surviving the WHERE, **before LIMIT** — and again for the pagination count query. On a large
|
|
112
|
+
Compass `SalesOrders` table this can be slow, and it will not fail loudly (api2's Apache gateway
|
|
113
|
+
timeout is 1800s). This is pre-existing behaviour of filtering **any** calculated column, not new
|
|
114
|
+
here, and it was **not** measured with `EXPLAIN` at production size. The cheaper target would be
|
|
115
|
+
the indexed `ApprovalDecisions.assignedToUserId` with uuid option slugs — but the current
|
|
116
|
+
`filterOptions` contract has no way to say "filter on a different column than the one displayed",
|
|
117
|
+
so that stays a fallback design.
|
|
118
|
+
- The multi-select's comma-joined `contains` encoding over-includes and breaks on commas — see
|
|
119
|
+
[toga-blox table](../../toga-blox/features/table.md). It affects this column and Order Status alike.
|
|
120
|
+
|
|
121
|
+
## Change history
|
|
122
|
+
- 2026-09-15 — Built the seam (`FILTER_OPTIONS_METHOD_SUFFIX` + the guarded branch in
|
|
123
|
+
`_Model_Client_TableView::meta()`) and its first consumer,
|
|
124
|
+
`_Model_Compass_SalesOrder::_assignedAdminFilterOptions()` (active Admin users, covering both
|
|
125
|
+
Compass tenants by inheritance). TRUE-81388, `_underscore` commit `7d92b0df`. Recorded that
|
|
126
|
+
`filterOptions` alone gates the front-end multi-select, that these columns are `CustomRecordFields`
|
|
127
|
+
rather than Core `RecordFields`, the name-as-identifier limit, and the HAVING performance risk.
|
|
128
|
+
(apeterson)
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-15
|
|
10
10
|
owners: [apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Client/TableView.php
|
|
@@ -22,6 +22,7 @@ files:
|
|
|
22
22
|
- dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql
|
|
23
23
|
- dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql
|
|
24
24
|
related:
|
|
25
|
+
- calculated-column-filter-options.md
|
|
25
26
|
- surface-resolver.md
|
|
26
27
|
- item-fulfillment-stage-lifecycle-and-order-status.md
|
|
27
28
|
- ../../dbchanges2/features/surface-layer-schema.md
|
|
@@ -137,6 +138,11 @@ states from the NetSuite billing sync.
|
|
|
137
138
|
metadata DB change (see [toga-blox table](../../toga-blox/features/table.md)).
|
|
138
139
|
|
|
139
140
|
## Change history
|
|
141
|
+
- 2026-09-15 — Cross-linked the new calculated-column seam: a `FIELD_SQL` column can now emit its own
|
|
142
|
+
`filterOptions` from a `<field>FilterOptions` model static, so `getStatusFilterOptions()` is no
|
|
143
|
+
longer the only producer. `recordRoute`/`surfaceSlug` are status-specific, not requirements for a
|
|
144
|
+
multi-select. See
|
|
145
|
+
[calculated-column-filter-options](calculated-column-filter-options.md). (apeterson)
|
|
140
146
|
- 2026-08-25 — Built the feature end to end: Core `FILTER_SET` surface 44 + BADGE elements 147–160
|
|
141
147
|
(labels/colours from the `sales-order-status` vocabulary, per-client hiding via `SurfaceOverrides`
|
|
142
148
|
`IS_VISIBLE=0`); `_Model_Client_TableView` emits `surfaceSlug` + `filterOptions` on `TYPE__STATUS`
|
|
@@ -163,8 +163,25 @@ layer and emits it — see
|
|
|
163
163
|
[sales-order-status-filter-surface](../../_underscore/features/sales-order-status-filter-surface.md).
|
|
164
164
|
**The blox side never derives a surface slug by naming convention; it renders what meta gives it.**
|
|
165
165
|
|
|
166
|
+
**`filterOptions` alone is the gate.** `PrimaryTableHeaderCell` renders `HeaderFilterMultiselect`
|
|
167
|
+
when `isFilterable && filterOptions`, and `HeaderFilterSearch` only when
|
|
168
|
+
`isFilterable && !filterOptions` (and the type is not CURRENCY/NUMBER/DATETIME/DATE). There is **no
|
|
169
|
+
type gate** on the multi-select branch — a `STRING` column with options gets a multi-select, and
|
|
170
|
+
`recordRoute` / `surfaceSlug` are **not** required (`recordRoute` is only the fallback fetch:
|
|
171
|
+
`needsFetchedOptions = !!field.recordRoute && !field.filterOptions`). The backend can therefore feed
|
|
172
|
+
a calculated column's options from a model static — see
|
|
173
|
+
[calculated-column-filter-options](../../_underscore/features/calculated-column-filter-options.md).
|
|
174
|
+
|
|
166
175
|
## Gotchas
|
|
167
176
|
|
|
177
|
+
- **⚠ OPEN — the multi-select filter matches by SUBSTRING, not exactly.** `HeaderFilterMultiselect`
|
|
178
|
+
comma-joins the picked slugs and `src/api/tableData/getDataTableData.ts` turns that into
|
|
179
|
+
`where: { or: [ { field: { contains: value } }, … ] }`. Picking "Jon Smith" also matches
|
|
180
|
+
"Jon Smithson" — it over-includes rows.
|
|
181
|
+
- **⚠ OPEN — an option value containing a comma silently returns zero rows.** It splits into two
|
|
182
|
+
bogus filter terms, with no error. Both bugs are pre-existing and affect **every** multi-select
|
|
183
|
+
column including Order Status; both fix in one place — send a list / exact-equals operator instead
|
|
184
|
+
of a comma-joined `contains`. Not fixed as of 2026-09-15.
|
|
168
185
|
- **⚠ `useTableData` APPENDS `.uuid` to `additionalData.slug` — unless the slug is on a hardcoded
|
|
169
186
|
allow-list.** The list is exactly `catalogId`, `customerId`, `Items.catalogId`,
|
|
170
187
|
`SalesOrderItems.uuid`, `SalesOrders.uuid` (verified in `@agilant/toga-blox`
|
|
@@ -238,6 +255,10 @@ layer and emits it — see
|
|
|
238
255
|
requires `npm run build` before it takes effect at runtime.
|
|
239
256
|
|
|
240
257
|
## Change history
|
|
258
|
+
- 2026-09-15 — Recorded that `filterOptions` alone gates the multi-select (no type gate;
|
|
259
|
+
`recordRoute`/`surfaceSlug` not required) and logged two pre-existing multi-select filter bugs:
|
|
260
|
+
substring `contains` matching over-includes rows, and a comma inside an option value returns zero
|
|
261
|
+
rows. Found while adding the Compass Assigned Admin filter (TRUE-81388); neither fixed. (apeterson)
|
|
241
262
|
- 2026-09-04 — Recorded the `useTableData` **`.uuid` append + hardcoded allow-list** contract
|
|
242
263
|
(`catalogId`, `customerId`, `Items.catalogId`, `SalesOrderItems.uuid`, `SalesOrders.uuid`,
|
|
243
264
|
verified in `1.1.2-production.144`). A dotted slug that is not on the list becomes
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-15
|
|
10
10
|
owners: [jcardinal, ajean]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Controller/Index.php
|
|
@@ -213,6 +213,16 @@ file `Worker/Team/Github.php`.
|
|
|
213
213
|
`_Worker_Infrastructure_Worker_Cleanup`: `WorkerJobs()` deletes `isSuccess=1` rows >90 days
|
|
214
214
|
(≤1000/run); `WebhookLogs()` deletes `Logs.Webhook` >90 days. Two daily CronJobs at 2 AM.
|
|
215
215
|
|
|
216
|
+
**⚠ The OBSERVED `Core.WorkerJobs` retention is ~30 days, not 90 — unexplained.** Measured
|
|
217
|
+
2026-09-15 in prod: `MIN(dtCreated)` across the **whole** table is **2026-08-16 02:00:46**
|
|
218
|
+
(790,597 rows). Either the cron does not behave as described above, or something else prunes the
|
|
219
|
+
table. **Not investigated** — no cause is asserted here.
|
|
220
|
+
|
|
221
|
+
**Triage consequence: a missing `WorkerJobs` row is NOT proof the job never ran.** Any "there is no
|
|
222
|
+
job for this record, so the webhook never fired" conclusion is invalid past the ~30-day floor. That
|
|
223
|
+
exact wrong call was made and retracted on 2026-09-15. Same failure family as *an empty log table is
|
|
224
|
+
not proof a code path never ran*.
|
|
225
|
+
|
|
216
226
|
## Cron actions must return a result on BOTH paths
|
|
217
227
|
|
|
218
228
|
**A new cron action is expected to return a result for a success as well as a failure.** Both
|
|
@@ -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-15
|
|
10
10
|
owners: ["ajean"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite/Opportunity.php
|
|
@@ -143,6 +143,25 @@ legitimately be running before the column lands. An **ungated write does not deg
|
|
|
143
143
|
breaks the ENTIRE opportunity sync**, because `importOpportunity()`'s save names a column that does
|
|
144
144
|
not exist and *every* NetSuite webhook fails. A label enhancement must not be able to do that.
|
|
145
145
|
|
|
146
|
+
**⚠ CORRECTED 2026-09-15 — this gate did not prevent that outcome; it happened anyway, for 9 days.**
|
|
147
|
+
The predicted failure ("it breaks the ENTIRE opportunity sync") occurred 2026-09-01 → 2026-09-10
|
|
148
|
+
with the gate in place, because the gate checks the **DB column** and the failing write went through
|
|
149
|
+
the **ORM**: the column existed, but `_underscore`'s `_Model_Forecast_Opportunity` had not yet
|
|
150
|
+
declared the `endCustomerName` property, so `_Model` rejected the field. **The deploy has two
|
|
151
|
+
independent halves and they ship from different repos** — the dbchanges2 migration *and* the
|
|
152
|
+
`_underscore` model declaration. General rule: **gate on the layer the write actually goes through,
|
|
153
|
+
not the layer that is easiest to query.**
|
|
154
|
+
|
|
155
|
+
So there are now **two** gates (worker2 PR #168, open against `_production` as of 2026-09-15):
|
|
156
|
+
|
|
157
|
+
| Gate | Checks | Use for |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `hasEndCustomerColumn()` (unchanged) | `information_schema` column | **raw `_Query` SQL only** — the digest + both backfills |
|
|
160
|
+
| `canWriteEndCustomerNameToModel()` (new) | the above **and** `property_exists(_Model_Forecast_Opportunity::class,'endCustomerName')` | **`importOpportunity()`'s ORM write only** |
|
|
161
|
+
|
|
162
|
+
Each half `error_log`s on its own, naming the repo that is behind. Full incident detail:
|
|
163
|
+
[the outage gotcha on the sync doc](./netsuite-opportunity-sync.md#gotchas--known-issues).
|
|
164
|
+
|
|
146
165
|
- `information_schema.COLUMNS` check, **memoised per process** in a `static` (the answer cannot
|
|
147
166
|
change mid-job, and the import runs per webhook) — `static`, not a constant, so a long-lived
|
|
148
167
|
worker picks the column up on its next job.
|
|
@@ -348,6 +367,13 @@ A one-shot rem+add corrector was scoped and deliberately **not** built.
|
|
|
348
367
|
injection before they go into the digest.
|
|
349
368
|
|
|
350
369
|
## Change history
|
|
370
|
+
- 2026-09-15 — **Corrected the deploy gate: `hasEndCustomerColumn()` did NOT protect the ORM write,
|
|
371
|
+
and the sync was down 9 days (2026-09-01 → 2026-09-10).** The column existed; `_underscore`'s
|
|
372
|
+
`_Model_Forecast_Opportunity` had not declared the `endCustomerName` property, so `_Model`
|
|
373
|
+
rejected the field on every `Netsuite/Opportunity/post|put` (611 failed jobs / 310 opportunities).
|
|
374
|
+
Added `canWriteEndCustomerNameToModel()` for the ORM write and scoped `hasEndCustomerColumn()` to
|
|
375
|
+
raw `_Query` callers (worker2 PR #168, open). Lesson: gate on the layer the write goes through —
|
|
376
|
+
the model declaration is a second deploy half and it lives in a different repo. (ajean)
|
|
351
377
|
- 2026-09-02 — **End Customer now comes from NetSuite `custbody_end_customer`, else mirrors the
|
|
352
378
|
client (worker2 PR #158 + dbchanges2 PR #477; re-import PR #159 — all merged).** Root cause: End
|
|
353
379
|
Customer was derived from the opportunity `entity`, which **is the partner** on a partner-routed
|
|
@@ -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-15
|
|
10
10
|
owners: ["dfranks", "kyalamarthi", "ajean", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite.php
|
|
@@ -144,6 +144,12 @@ claim on a never-claimed opportunity, so it does **not** contradict the rule tha
|
|
|
144
144
|
*Consequence: `updateTask()` is unreachable* below). So when triaging a backlog of
|
|
145
145
|
`skipped (no presales lead)`, read it as **"queued, awaiting a lead"**, not "lost".
|
|
146
146
|
|
|
147
|
+
**Confirmed end-to-end a second time, 2026-09-15.** Opportunity **75504** (NS internalId 7377655,
|
|
148
|
+
`Forecast.Opportunities.id` 94076) had no task; replaying its webhook returned
|
|
149
|
+
`{"db":"updated opportunity 7377655 (id=94076, items=1)","clickup":"skipped (no presales lead)"}`.
|
|
150
|
+
Assigning the Presales Lead in NetSuite produced the task. **This gate is the single most common
|
|
151
|
+
reason a specific opportunity has no ClickUp task** — check it before suspecting the sync.
|
|
152
|
+
|
|
147
153
|
Shared helpers: `buildCustomFields()` (the field array, used by both create and update),
|
|
148
154
|
`resolvePresalesUserId()` (cached `Client_True.Users.c_clickupUserIdentifier` lookup → else a
|
|
149
155
|
`GET /team` roster scan that back-fills the cache via `cacheClickupUser()`).
|
|
@@ -306,6 +312,57 @@ Every `Netsuite/Opportunity/post|put` job writes a JSON result into `Core.Worker
|
|
|
306
312
|
string literal or a column alias**, so match the `skipped update (update disabled)` verdict with
|
|
307
313
|
`LIKE '%disabled%'` and alias the column `skippedDisabled`, never `updateDisabled`. See
|
|
308
314
|
[MCP Tool Usage](../../../../standalone/apps/claude/workflows/mcp-tool-usage.md).
|
|
315
|
+
- **⚠ `Core.WorkerJobs` holds only ~30 days — a missing job row is NOT proof the webhook never
|
|
316
|
+
fired.** Measured 2026-09-15: `MIN(dtCreated)` across the **whole** table is **2026-08-16
|
|
317
|
+
02:00:46** (790,597 rows). So any "this opportunity has no job, therefore NetSuite never sent it"
|
|
318
|
+
conclusion is invalid past that horizon — a wrong call made and retracted mid-session on
|
|
319
|
+
2026-09-15. Same failure family as *an empty log table is not proof a code path never ran*. Note
|
|
320
|
+
the **discrepancy**: `worker2/architecture.md` describes the cleanup cron as deleting `isSuccess=1`
|
|
321
|
+
rows older than **90 days**, but the observed floor is ~30 — either the cron's behaviour or that
|
|
322
|
+
description is wrong. Trust the observed value and check before relying on either.
|
|
323
|
+
- **`skipped (no presales lead)` has TWO causes that look identical in the DB.** The gate tests the
|
|
324
|
+
**resolved email**, not the employee reference — so it fires both when NetSuite's
|
|
325
|
+
`custbody_ctc_solution_architect` is **unset** *and* when it is set to an employee whose record has
|
|
326
|
+
**no email**. Do not read the verdict as "the field is empty in NetSuite."
|
|
327
|
+
- **Scale reference for triage** — prod `Forecast.Opportunities` census, 2026-09-15. Most
|
|
328
|
+
opportunities have no ClickUp task, and that is normal:
|
|
329
|
+
|
|
330
|
+
| `netsuiteStatus` | Total | No task | With task |
|
|
331
|
+
|---|---|---|---|
|
|
332
|
+
| In Progress | 2,506 | 2,025 | 481 (~19%) |
|
|
333
|
+
| Closed - Won | 52,931 | 52,808 | 123 |
|
|
334
|
+
| Closed - Lost | 19,235 | 19,042 | 193 |
|
|
335
|
+
|
|
336
|
+
### Backfill = replay the webhook, and bulk replay is safe
|
|
337
|
+
|
|
338
|
+
Re-firing the ingestion endpoint is the repair mechanism for any opportunity the sync missed:
|
|
339
|
+
|
|
340
|
+
```
|
|
341
|
+
curl -X POST https://webhook.togahub.com/netsuite -H 'Content-Type: application/json' \
|
|
342
|
+
-d '{"recordType":"opportunity","eventType":"edit","internalId":<id>}'
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**Why bulk replay is safe** (exercised at scale 2026-09-15 — 264 replays at 0.5 s spacing, all 264
|
|
346
|
+
accepted by the ingestion Lambda; 265 jobs succeeded, 1 failed, 14 ClickUp tasks created, 1
|
|
347
|
+
`already claimed`, 250 `no presales lead`):
|
|
348
|
+
|
|
349
|
+
- The atomic `clickupTaskId` claim makes an already-claimed opportunity return
|
|
350
|
+
`skipped (already claimed)` and write **nothing** to ClickUp — **no duplicate tasks**.
|
|
351
|
+
- An opportunity with no presales lead returns `skipped (no presales lead)` and stays queued.
|
|
352
|
+
- `importOpportunity()` is self-healing, so the replay also repairs the Forecast row.
|
|
353
|
+
|
|
354
|
+
**A `404` on replay is the correct signal for a NetSuite-deleted opportunity, not a defect.** The
|
|
355
|
+
single failure in that run (NS internalId **7442501**) was NetSuite REST HTTP 404
|
|
356
|
+
*"The opportunity does not exist"* — the record had been deleted in NetSuite, which is also why it
|
|
357
|
+
was the one row missing from Forecast.
|
|
358
|
+
|
|
359
|
+
### Open item — item-sync watchdog timeouts (NOT investigated)
|
|
360
|
+
|
|
361
|
+
`Netsuite/InventoryItem/put|post` and `Netsuite/NonInventoryItem/*` logged **427 failures** over
|
|
362
|
+
2026-09-01 → 2026-09-10, all with output `watchdog: exceeded maxExecutionTime without completing`.
|
|
363
|
+
**Different root cause from the `endCustomerName` outage** (execution time, not a model error) and
|
|
364
|
+
deliberately out of scope on 2026-09-15. Recorded as a known open item; needs its own ticket. No
|
|
365
|
+
cause is asserted here.
|
|
309
366
|
|
|
310
367
|
### ⚠ A healthy sync reads as dead: new tasks do NOT stay in "Presales Qualification"
|
|
311
368
|
|
|
@@ -341,6 +398,17 @@ Full list ids and the API constraints are in
|
|
|
341
398
|
re-derive them here. **Practical rule is unchanged:** verify by task id or across the whole
|
|
342
399
|
Opportunities folder, never by one list.
|
|
343
400
|
|
|
401
|
+
**More evidence, 2026-09-15 — and a counter-example that pins down *when* the Automation moves a
|
|
402
|
+
task.** After the outage fix, new tasks again landed spread across folder `90117060687`
|
|
403
|
+
("Opportunities"), space `90113928591`, **none** in Presales Qualification (`901111987449`):
|
|
404
|
+
`868m40zf5` (opp 76045 Adyen, 9/10) → Pending Client Response · `868m484vn` (opp 76021 Seminole
|
|
405
|
+
Gaming, 9/11) → BU Discovery (`901112518588`) · `868m5e958` (opp 75740, 9/15) and `868m5fmc3`
|
|
406
|
+
(opp 76115, 9/15) → Contract Negotiations (`901111987463`). **But the 14 tasks created by the
|
|
407
|
+
backfill replay the same day DID stay in Presales Qualification** (e.g. `868m5gexk` opp 72119,
|
|
408
|
+
`868m5ghgt` opp 76051). So the Automation moves a task **only when the stage dropdown changes**, not
|
|
409
|
+
on creation — a task sitting in Presales Qualification means its stage has not moved since birth,
|
|
410
|
+
not that the mover is broken.
|
|
411
|
+
|
|
344
412
|
**The Automation is load-bearing.** NetSuite writes the dropdown and ClickUp→NetSuite reads the
|
|
345
413
|
human's list move; both directions run through it. Treat any change to that Automation as a
|
|
346
414
|
**breaking change** to this integration.
|
|
@@ -383,26 +451,25 @@ the legacy cron's OPPORTUNITIES section:
|
|
|
383
451
|
letter, so the handler runs a one-row SuiteQL query for it.
|
|
384
452
|
- **`endCustomerName`** (added 2026-09-01, `dbchanges2/Forecast/2026-09-01a …`) holds NetSuite
|
|
385
453
|
`custbody_end_customer`'s `refName` — a **name, not a FK**, because nothing upserts an
|
|
386
|
-
end-customer `Customers` row.
|
|
387
|
-
(`hasEndCustomerColumn()`),
|
|
388
|
-
the
|
|
454
|
+
end-customer `Customers` row. **CORRECTED 2026-09-15 — this bullet used to say the write and both
|
|
455
|
+
reads are "gated on the column existing (`hasEndCustomerColumn()`)", which reads as "safe". It was
|
|
456
|
+
not: that gate checks the DB column and the model write took the sync down for 9 days anyway.**
|
|
457
|
+
There are now **two** gates, and they are not interchangeable:
|
|
458
|
+
- `hasEndCustomerColumn()` — `information_schema` column check. Correct **only** for raw `_Query`
|
|
459
|
+
SQL (the digest and the two backfills).
|
|
460
|
+
- `canWriteEndCustomerNameToModel()` — `hasEndCustomerColumn() && property_exists(
|
|
461
|
+
_Model_Forecast_Opportunity::class, 'endCustomerName')`. The **only** gate valid for
|
|
462
|
+
`importOpportunity()`'s ORM write.
|
|
463
|
+
|
|
464
|
+
See the *`endCustomerName` 9-day outage* gotcha below and
|
|
389
465
|
[the labels doc](./netsuite-opportunity-client-labels.md).
|
|
390
|
-
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
every opportunity import dead. `Core.WorkerJobs` census: Sep 1 = 115 ok / 60 fail (last good day),
|
|
398
|
-
Sep 2 → Sep 10 = 0 ok / 100% fail (61 fails on Sep 10 alone). This was a **half-deploy**: the
|
|
399
|
-
migration + the handler write shipped, the model field declaration did not.
|
|
400
|
-
**Durable rule: a new column that the handler writes must ship THREE things together — the
|
|
401
|
-
`dbchanges2` migration, the handler write, AND the `_Model` field declaration.** A column-existence
|
|
402
|
-
guard (`SHOW COLUMNS`) proves nothing about the ORM; both ORMs enumerate *declared* properties, so an
|
|
403
|
-
undeclared field is a hard `__set` throw, not a silent skip. Fixed 2026-09-10 by declaring
|
|
404
|
-
`public $endCustomerName = self::FIELD_CHAR;` (matching sibling char fields clickupTaskId/title/memo
|
|
405
|
-
over the varchar column); deployed to worker2 `_production`, sync recovered.
|
|
466
|
+
- **Durable rule from the outage: a new column the handler writes must ship THREE things together —
|
|
467
|
+
the `dbchanges2` migration, the handler write, AND the `_Model` field declaration.** A
|
|
468
|
+
column-existence guard proves nothing about the ORM; both ORMs enumerate *declared* properties, so
|
|
469
|
+
an undeclared field is a hard `__set` throw, not a silent skip. Fixed 2026-09-10 by declaring
|
|
470
|
+
`public $endCustomerName = self::FIELD_CHAR;` (matching sibling char fields
|
|
471
|
+
`clickupTaskId`/`title`/`memo` over the varchar column). Full incident detail in the
|
|
472
|
+
*`endCustomerName` 9-day outage* gotcha below — do not restate it here.
|
|
406
473
|
- Writes use raw `_Query` against `_underscore::DB_FORECAST` and **must**
|
|
407
474
|
`_Database::transactionCommit(DB_FORECAST)` (lazy-transaction gotcha).
|
|
408
475
|
|
|
@@ -439,6 +506,40 @@ None — platform-wide Forecast sync.
|
|
|
439
506
|
|
|
440
507
|
## Gotchas / known issues
|
|
441
508
|
|
|
509
|
+
- **⚠ The `endCustomerName` 9-day total outage — a column-existence gate does NOT protect an ORM
|
|
510
|
+
write (2026-09-01 → 2026-09-10).** Every `Netsuite/Opportunity/post|put` job threw
|
|
511
|
+
`There is no field called 'endCustomerName' in the '_Model_Forecast_Opportunity' model.`
|
|
512
|
+
(`_underscore/Model.php:498`) from 2026-09-01 12:01 to 2026-09-10 15:48 CDT. **The whole
|
|
513
|
+
NetSuite→Forecast→ClickUp opportunity sync was dead for 9 days and every job was red.**
|
|
514
|
+
- **Cause — a deploy split across TWO repos.** worker2 shipped the `$values['endCustomerName']`
|
|
515
|
+
write (PR #158) while `_underscore/Model/Forecast/Opportunity.php` had not yet declared the
|
|
516
|
+
property. `_Model` builds its field map from **declared properties** in the constructor, so
|
|
517
|
+
`__set` rejected the unknown field. The DB column existed (the migration had been applied) — the
|
|
518
|
+
missing half was the model declaration, in a different repo.
|
|
519
|
+
- **Blast radius:** the throw lands in `importOpportunity()` **before** the Forecast save and
|
|
520
|
+
**before** `maybeCreateClickupTask()`, so affected opportunities got **neither** a Forecast row
|
|
521
|
+
**nor** a ClickUp task. Measured in prod `Core.WorkerJobs`: **611 failed jobs, 310 distinct
|
|
522
|
+
opportunities, 264 still with no ClickUp task**, 1 (NS internalId 7442501) absent from Forecast.
|
|
523
|
+
- **Ended by `_underscore` commit `34f76ee5` "Add end customer field to Opportunity"** (Jeff
|
|
524
|
+
Cardinal, 2026-09-10 15:51:31 CDT — `public $endCustomerName = self::FIELD_CHAR;`) — three
|
|
525
|
+
minutes after the last failure. Per-day `WorkerJobs` census: Sep 1 = 115 ok / 60 fail (the
|
|
526
|
+
failures start midday, which is why this is written up as both "8 days" and "9 days"),
|
|
527
|
+
Sep 2 → Sep 10 = **0 ok / 100% fail**.
|
|
528
|
+
- **The lesson, and it generalizes: gate on the layer the write actually goes through, not the
|
|
529
|
+
layer that is easiest to query.** `hasEndCustomerColumn()`'s own docblock predicted this exact
|
|
530
|
+
failure ("it breaks the ENTIRE opportunity sync") and still did not stop it, because it ran
|
|
531
|
+
`SHOW COLUMNS` against `Forecast.Opportunities` while the write went through the ORM. A column
|
|
532
|
+
guard protects raw SQL only. **A model-declaration gap is a second, independent half of the
|
|
533
|
+
deploy, and it ships from a different repo.**
|
|
534
|
+
- **Fix (worker2 PR #168, open against `_production` as of 2026-09-15, not yet merged).**
|
|
535
|
+
`hasEndCustomerColumn()` is byte-identical and now documented as being for **raw `_Query`
|
|
536
|
+
reads/writes only** — its three callers (`OpportunityStakeholderDigest.php:180`,
|
|
537
|
+
`OpportunityStakeholderBackfill.php:579`, `OpportunityEndCustomerBackfill.php:91`) all build
|
|
538
|
+
plain SQL. New `canWriteEndCustomerNameToModel()` = `hasEndCustomerColumn() && property_exists(
|
|
539
|
+
_Model_Forecast_Opportunity::class, 'endCustomerName')` is used **only** by
|
|
540
|
+
`importOpportunity()`. Each half `error_log`s separately, naming the repo that is behind. No
|
|
541
|
+
migration. **No regression test** — worker2 has no test coverage under `Worker/` to hang one off
|
|
542
|
+
(only `Controller/CompassPeopleTest.php` and `Controller/ClickupDesignTest.php` exist).
|
|
442
543
|
- **`_Model::load()` returns TRUE only on an EXACTLY-ONE match — a duplicate-row self-amplifier in
|
|
443
544
|
any load-then-upsert handler.** `_Model::load()` (`_underscore/Model.php:788–808`) returns FALSE
|
|
444
545
|
for **both** 0 rows **and** 2+ rows — it succeeds only when the lookup matches exactly one row.
|
|
@@ -775,6 +876,27 @@ deprecated** for production opportunity code.
|
|
|
775
876
|
blocked on the Aaron stakeholder decision noted above.
|
|
776
877
|
|
|
777
878
|
## Change history
|
|
879
|
+
- 2026-09-15 — **Root-caused a 9-day TOTAL outage of the opportunity sync and split the deploy gate
|
|
880
|
+
(worker2 PR #168, open).** Same incident jcardinal fixed on the `_underscore` side below; this
|
|
881
|
+
entry adds the worker2-side gate and the measured blast radius. From 2026-09-01 12:01 to
|
|
882
|
+
2026-09-10 15:48 CDT every `Netsuite/Opportunity/post|put` threw `There is no field called
|
|
883
|
+
'endCustomerName' in the '_Model_Forecast_Opportunity' model` — worker2 shipped the write
|
|
884
|
+
(PR #158) before `_underscore` declared the property, and `_Model` builds its field map from
|
|
885
|
+
declared properties. **611 failed jobs / 310 opportunities / 264 left with no ClickUp task** (1,
|
|
886
|
+
NS internalId 7442501, absent from Forecast); ended by `_underscore` `34f76ee5`. The existing
|
|
887
|
+
`hasEndCustomerColumn()` gate did not help because it checks the **DB column** while the write
|
|
888
|
+
goes through the **ORM** — corrected that claim in *Data model* and added
|
|
889
|
+
`canWriteEndCustomerNameToModel()` (`hasEndCustomerColumn() && property_exists(...)`) used only by
|
|
890
|
+
`importOpportunity()`; `hasEndCustomerColumn()` is now scoped to raw `_Query` callers.
|
|
891
|
+
**Also recorded:** `Core.WorkerJobs` retains only **~30 days** (MIN `dtCreated` 2026-08-16), so a
|
|
892
|
+
missing job row proves nothing — and that contradicts architecture.md's 90-day cleanup
|
|
893
|
+
description; **bulk webhook replay is safe** (264 replays, 0 duplicate tasks — the claim, the
|
|
894
|
+
presales gate and self-healing import make it so) and a NetSuite **404 on replay = the record was
|
|
895
|
+
deleted**; `skipped (no presales lead)` has **two** causes (field unset, or the employee has no
|
|
896
|
+
email); a fresh `Forecast.Opportunities` census; more evidence for the list-visibility trap plus
|
|
897
|
+
the counter-example that the Automation moves a task **only on a stage-dropdown change**, not on
|
|
898
|
+
creation. Flagged an unrelated open item: **427** `Netsuite/(Non)InventoryItem/*` watchdog
|
|
899
|
+
execution-time failures in the same window, not investigated. (ajean)
|
|
778
900
|
- 2026-09-10 — **Fixed an 8-day total opportunity-sync outage caused by a half-deploy: a missing ORM
|
|
779
901
|
model field.** `Forecast.Opportunities.endCustomerName` (column added ~Sep 1) and the worker2 handler
|
|
780
902
|
write shipped, but `_Model_Forecast_Opportunity` (`_underscore/Model/Forecast/Opportunity.php`) never
|
package/knowledge/INDEX.md
CHANGED
|
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
18
18
|
|
|
19
19
|
## 2.0 framework
|
|
20
20
|
|
|
21
|
-
- **_underscore** (_Underscore) _(framework core)_ —
|
|
21
|
+
- **_underscore** (_Underscore) _(framework core)_ — 85 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
22
22
|
- **worker2** (Worker) — 69 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
23
23
|
- **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
24
24
|
- **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
package/package.json
CHANGED