toga-ai 1.0.432 → 1.0.433

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.
@@ -19,7 +19,7 @@
19
19
  | [Etilize Item Translation Import](features/etilize-item-translation-import.md) | The abstract worker class `_Worker_Etilize_ItemTranslations` imports **non-English** item text from Etilize into the client's `ItemTranslations` table. | worker2/Worker/Etilize/ItemTranslations.php |
20
20
  | [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Monitors/RateEntitlement.php, worker2/Worker/Notification/Email.php, worker2/Worker/Rate.php, dbchanges2/Core/2026-05-21 - Monitors.sql, dbchanges2/Core/2026-06-29a - Rate Entitlement Contract Monitor.sql |
21
21
  | [NetSuite ↔ ClickUp / TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer pulls (opportunities first; sales/items/etc. | worker2/Worker/Netsuite.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Clickup.php, worker2/Worker/Clickup/Opportunity.php, worker2/Controller/Index.php, _underscore/Worker.php, test/@dave/NetSuite/api-message-queue/lib_amq_queue.js, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/ue_amq_drain.js, test/@dave/NetSuite/api-message-queue/ss_amq_drain.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, test/@dave/clickup/backfill_opportunity_numbers.php, test/@dave/clickup/probe_opportunity_fields.php, test/@dave/probe_clickup_desc_match.php, test/@dave/test_model_load_behavior.php, dbchanges2/Forecast/2026-06-25a - Add unique index on Opportunities netsuiteOpportunityInternalId.sql, _underscore/Model/Forecast/Opportunity.php, test/@dave/approach/TRUE-80044.md, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
22
- | [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). | worker2/Worker/Netsuite/SalesOrder.php, worker2/Worker/Netsuite.php, test/@dave/probe_salesorder_rest_shape.php, test/@dave/probe_open_order_lines.php, test/@dave/check_so_status.php, test/@dave/check_so_history.php, test/@dave/probe_so_rest_lines.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_open_order_gating.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
22
+ | [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). | worker2/Worker/Netsuite/SalesOrder.php, worker2/Worker/Netsuite.php, worker2/Component/Forecast/Db/Db.php, worker2/Worker/Netsuite/Location.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_amq_invoice_resync_salesorder.js, test/@dave/probe_salesorder_rest_shape.php, test/@dave/probe_open_order_lines.php, test/@dave/check_so_status.php, test/@dave/check_so_history.php, test/@dave/probe_so_rest_lines.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_open_order_gating.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
23
23
  | [NetSuite Supporting-Record Webhook Importer (the reusable recipe)](features/netsuite-supporting-record-webhook-importer.md) | A single **repeatable recipe** for porting a legacy daily-pull NetSuite *supporting-record* importer (the lookup/dimension tables behind Forecast2 — Employees, | worker2/Worker/Netsuite/Employee.php, worker2/Worker/Netsuite/Account.php, worker2/Worker/Netsuite/Classification.php, worker2/Worker/Netsuite/Customer.php, worker2/Worker/Netsuite/Item.php, worker2/Worker/Netsuite.php, _underscore/Model/Forecast/Employee.php, _underscore/Model/Forecast/Account.php, _underscore/Model/Forecast/Classification.php, _underscore/Component/Forecast/Db/Db.php, test/@dave/test_employee_lifecycle.php, test/@dave/test_account_lifecycle.php, test/@dave/test_classification_lifecycle.php, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, worker/crons/toga2/forecast2/import_supporting_records.php |
24
24
  | [Background Email-Template Worker (_Worker_Notification_EmailTemplate)](features/notification-email-template.md) | `_Worker_Notification_EmailTemplate::Send(...)` dispatches a **stored, client-defined `EmailTemplates` row off-thread** as a background WorkerJob. | worker2/Worker/Notification/EmailTemplate.php, worker2/Worker/Client/True.php, _underscore/Model/Client/EmailTemplate.php |
25
25
  | [DB-Driven Notification (Internal) Email](features/notification-email.md) | Internal/notification emails (merge-conflict alerts, ops notices — anything system-generated, not client-facing transactional mail) are sent through one worker | worker2/Worker/Notification/Email.php, _underscore/Model/Client/EmailTemplate.php, dbchanges2/Client/2026-06-23a - EmailTemplateWrapper.sql, dbchanges2/Client_True/2026-06-23a - EmailTemplateWrapper.sql |
@@ -6,11 +6,15 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-29
9
+ updated: 2026-07-24
10
10
  owners: ["dfranks"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite/SalesOrder.php
13
13
  - worker2/Worker/Netsuite.php
14
+ - worker2/Component/Forecast/Db/Db.php
15
+ - worker2/Worker/Netsuite/Location.php
16
+ - test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js
17
+ - test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_amq_invoice_resync_salesorder.js
14
18
  - test/@dave/probe_salesorder_rest_shape.php
15
19
  - test/@dave/probe_open_order_lines.php
16
20
  - test/@dave/check_so_status.php
@@ -127,32 +131,105 @@ The table lives in the **`Forecast` schema on the core2 cluster** (reader
127
131
  sales-order FK column is **`netsuiteSalesOrderInternalId`** (`int unsigned`) — note this is *not* the
128
132
  same column name used by `Forecast.Sales` (see the column-name gotcha below).
129
133
 
130
- ### Model/DB drift to know before backfilling location / backorder / amountDue (TRUE-79162)
134
+ ### Backfilling location / backorder / amountDue onto open orders (TRUE-79162 / TRUE-80262)
131
135
 
132
- A planned backfill of **location, backorder, and amount-due** data onto open orders runs into three
133
- schema/model facts (all verified against the prod core2 reader; nothing built yet):
136
+ A backfill of **location, backorder, and amount-due** data onto open orders runs into model/schema
137
+ facts plus NetSuite field realities (all verified against the prod core2 reader + live NetSuite;
138
+ planning/investigation — see change history for what is built vs. planned):
134
139
 
135
140
  - **`_Model_Forecast_OpenOrderItem` (`_underscore/Model/Forecast/OpenOrderItem.php`) does NOT declare
136
141
  `locationId` or `quantityBackordered` — even though both columns already exist in the prod
137
142
  `Forecast.OpenOrderItems` table** (`locationId` `int unsigned NULL`, `quantityBackordered`
138
- `decimal(15,4) NULL`). The model declares only `id, netsuiteSalesOrderInternalId, dateOrder,
139
- orderNumber, customerId, salesRepEmployeeId, classificationId, accountId, itemId, lineNumber,
140
- revenue, profit`. **Writing those two fields through the 2.0 importer requires adding the
141
- properties to the model first** — the column existing in the DB is not sufficient.
142
- - **`Forecast.Locations` is EMPTY in prod (0 rows).** Schema: `id, name, netsuiteInternalLocationId`
143
- (UNIQUE, nullable), `typeId` — **no parent/hierarchy column.** Any open-order `locationId`
144
- resolution returns NULL until this table is populated from NetSuite, so **populating `Locations` is
145
- a hard prerequisite** for the location backfill. NetSuite returns a **leaf sub-location** but the
146
- warehouse dashboard wants the **top-level location**, so a leaf→root rollup is needed (and there is
147
- no parent column on `Locations` today to express it).
148
- - **`Forecast.OpenOrderItems` has NO `amountDue` column** (neither does `Forecast.Sales` in prod —
149
- see the [Sales import doc](../../_underscore/features/forecast-sale-import.md)), and `amountDue`
150
- appears in no worker2 Netsuite handler or `_Model_Forecast_*`. Importing open-order amountDue
151
- needs a **new column + model field**. The amountDue source semantics from the prior Sales work
152
- (TRUE-78923) — REST `amountRemaining` / SuiteQL `foreignamountunpaid`, **anchor-line only, store
153
- RAW POSITIVE** — carry over, **but `amountRemaining` is an AR/invoice field**, so for an *unbilled*
154
- sales order it may return null/0. **Live-probe an open SO before finalizing** the open-order
155
- amountDue source.
143
+ `decimal(15,4) NULL`). **Writing those two fields through the 2.0 importer requires adding the
144
+ properties to the model first** — the column existing in the DB is not sufficient. (And per the
145
+ change-detection gotcha above, wire any new column into **both** `buildOpenLineRows()` and the
146
+ `syncOpenLines()` diff.)
147
+ - **`Forecast.OpenOrderItems` has NO `amountDue` column** — importing open-order amountDue needs a
148
+ **new column + model field**.
149
+
150
+ #### Why `locationId` is NULL — a missing dimension sync, not an importer bug
151
+
152
+ `Forecast.Locations` is empty/underpopulated in prod, and **`resolveLocationId`
153
+ (`worker2/Component/Forecast/Db/Db.php`) returns NULL on a miss with NO self-heal** — unlike the item
154
+ path, which calls `_Worker_Netsuite_Item::syncItem` on a miss. `resolveLocationId` walks the leaf
155
+ location → root via `netsuiteParentLocationId` up to the top-level warehouse (NetSuite hands back a
156
+ **leaf sub-location** but the dashboard wants the **top-level** one).
157
+
158
+ `Forecast.Locations` is populated **only** by the action `Netsuite/Location/SyncAll`
159
+ (`_Worker_Netsuite_Location::SyncAll`, `worker2/Worker/Netsuite/Location.php`) — a full SuiteQL
160
+ re-pull of `id/name/parent`, **upsert-only, never deletes**. There is **no per-record location
161
+ webhook** (`location` is absent from the AMQ enqueuer `RECORD_TYPE_MAP`) and **no cron**. So if
162
+ `SyncAll` never runs, `Locations` stays empty and **every `locationId` resolves NULL** — the
163
+ "warehouse data missing" class of bug is a missing dimension sync, not the SO importer.
164
+
165
+ **Recommended durable design:** on a `resolveLocationId` miss, call `SyncAll` **once per process**
166
+ (the whole small dimension re-pulls in ~0.9s; a surgical single-row fetch is *worse* because you'd
167
+ have to walk/fetch the entire parent chain anyway). Optionally add per-record location webhook
168
+ methods `post()`/`put()` → `SyncAll`, and `delete()` → **no-op** (deleting the row would orphan
169
+ `OpenOrderItems.locationId`). To populate prod now, enqueue `Netsuite/Location/SyncAll` manually (see
170
+ the [architecture doc](../architecture.md) WorkerJobs pending path: INSERT a `jobType='ACTION'` row
171
+ with `dtQueued` NULL and the JobScheduler Lambda queues it within a minute).
172
+
173
+ #### amountDue on an open SO — the AR field lives on the invoice, not the sales order
174
+
175
+ The amountDue semantics from the prior Sales work (TRUE-78923) do **not** transfer directly: **a
176
+ NetSuite Sales Order REST record has NO amount-remaining / AR field.** A GET of
177
+ `record/v1/salesOrder` returns only `subtotal`/`total`/`totalCostEstimate`; `$salesOrder->amountRemaining`
178
+ **does not exist** and yields NULL — this is the root cause of the empty amountDue column. AR lives
179
+ **only on the invoice**: SuiteQL `transaction.foreignamountunpaid` (equivalently REST invoice
180
+ `amountRemaining` / `amountRemainingTotalBox`). `foreignamountunpaid` is already
181
+ invoice-amount-less-payments (it nets applied payments **and** applied credit memos) and is NULL/0 on
182
+ Paid-In-Full and on cash sales/refunds → wrap `NVL(...,0)`.
183
+
184
+ **Compute an open SO's outstanding AR by aggregating its linked invoices (reusable SuiteQL pattern):**
185
+
186
+ 1. SO→invoice linkage is `previoustransactionlinelink`: `previousdoc=<soId>`, `previoustype='SalesOrd'`,
187
+ `nexttype='CustInvc'` → collect **DISTINCT** `nextdoc` = invoice ids. There are **multiple link
188
+ rows per (SO,invoice)** (`linktype` ShipRcpt/OrdBill/OrdRvCom) — you **MUST dedup on `nextdoc`** or
189
+ the AR triples.
190
+ 2. `SUM(NVL(transaction.foreignamountunpaid,0))` over those invoice ids.
191
+ 3. **Run PER-SO with small `IN`-lists** — the full-book JOIN+GROUP BY over all SOs returns a NetSuite
192
+ **400** (the framework renders an HTML error page).
193
+ 4. **TOGA SuiteQL convention: NO table aliases** — fully-qualify (`previoustransactionlinelink.nextdoc`,
194
+ `transaction.foreignamountunpaid`). Verified live penny-exact (26 invoices → openAr $79,448.78).
195
+ 5. **Gotcha:** NetSuite **lowercases the SuiteQL output alias** (request `openAr` → response key
196
+ `openar`) — read the result key case-insensitively.
197
+
198
+ **Customer Deposit unapplied balance has NO direct field either — derive it.** `CustDep` records
199
+ expose no remaining/unapplied scalar (`foreignamountunpaid` is NULL on deposits; REST
200
+ `customerDeposit` has no remaining field) — only `foreigntotal` and `status` ("Deposited" = has
201
+ remaining vs "Fully Applied"). Derive: `unapplied = customerDeposit.foreigntotal − SUM(the deposit's
202
+ DepAppl application amounts)`, where the applications are `previoustransactionlinelink`
203
+ `previousdoc=<depId>`, `previoustype='CustDep'`, `nexttype='DepAppl'` → each `DepAppl.foreigntotal`;
204
+ the deposit reaches its SO via the `OrdDep` link (SalesOrd→CustDep). Verified: 79176 − 26392 = 52784.
205
+
206
+ #### Freshness — balance events don't reach the SO's webhook; bridge via an invoice UE
207
+
208
+ A 180-day audit of `Core.WorkerJobs` (`action LIKE 'Netsuite/%'`) shows which record types the AMQ
209
+ enqueuer actually POSTs to `webhook.togahub.com/netsuite`: **SalesOrder, Invoice, Opportunity,
210
+ JournalEntry, CashSale, CreditMemo** (+ item/customer/employee dims). It does **NOT** send
211
+ `customerPayment`, `customerDeposit`, `customerRefund`, or `cashRefund` (all 0 in 180d) — and
212
+ `location` is not in the map. Compounding it: **billing an SO fires no SalesOrder UE** (the saved
213
+ record is the Invoice; submit events don't chain), and **applying a customer payment submits neither
214
+ the invoice nor the SO** — so no balance event reaches the SO's enqueuer. Any SO-derived AR value
215
+ therefore goes stale on billing/payment.
216
+
217
+ **Bridge pattern** (`test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_amq_invoice_resync_salesorder.js`):
218
+ a UE **on the Invoice** resolves the invoice's `createdfrom` (its parent SO — confirmed via SuiteQL
219
+ `type='SalesOrd'`) and enqueues a `salesOrder` **'edit'** webhook → worker2 router →
220
+ `_Worker_Netsuite_SalesOrder::importOpenOrder` re-reads and re-syncs. **To keep any SO-derived value
221
+ fresh across balance events, route those events to a `salesOrder` edit** rather than adding new
222
+ per-type handlers. (This is also the concrete form of the "fix direction" in the billing-not-removed
223
+ gotcha below.)
224
+
225
+ #### Backfilling recomputed columns — delete-and-reconcile beats a backfill script
226
+
227
+ The Forecast reconciler's fingerprint detects record-set drift (n / idsum). **Deleting a whole SO's
228
+ `OpenOrderItems` rows IS that drift** → the SO classifies `nsOnly` → the fixer re-fetches the SO and
229
+ `importOpenOrder` recreates the rows **with the recomputed columns**. So no presence-check or backfill
230
+ script is needed — delete in small batches (a brief gap until recreated). This depends on the recompute
231
+ code being deployed and the reconciler's OpenOrders category being live; the fallback is a **per-SO
232
+ `salesOrder`-edit webhook** (proven to recreate rows *and* resolve `locationId`).
156
233
 
157
234
  ## Client variations
158
235
 
@@ -180,7 +257,9 @@ None — uniform (platform-wide Forecast2 sync).
180
257
  silent **false-negative "order missing"** reading. Verify the schema before concluding an order is
181
258
  absent from Forecast.
182
259
  - **A successful `WorkerJobs` row does NOT prove rows were written — two silent zero-write paths.** An
183
- `isSuccess=1` / `failureReason = NULL` job is **not** evidence that any `OpenOrderItems` rows persisted.
260
+ `isSuccess=1` job is **not** evidence that any `OpenOrderItems` rows persisted. (NB: prod
261
+ `Core.WorkerJobs` has **no `failureReason` column** — it was renamed to `output`, which holds both
262
+ failure text and success result; see the [architecture doc](../architecture.md).)
184
263
  `importOpenOrder` has two success-with-zero-write paths:
185
264
  - **(a) STATUS GATE** — if NetSuite `status->refName` is not in `OPEN_STATUSES` (`Pending Fulfillment`,
186
265
  `Partially Fulfilled`, `Pending Billing/Partially Fulfilled`, `Pending Billing`) or is missing, it
@@ -336,6 +415,24 @@ test fixture (it surfaced the stale SO 7181316 above).
336
415
 
337
416
  ## Change history
338
417
 
418
+ - 2026-07-24 — **Resolved the open questions on the OpenOrderItems location / amountDue Power-BI
419
+ backfill (investigation + planning).** `locationId` is NULL because `Forecast.Locations` is
420
+ unpopulated and `resolveLocationId` (`worker2/Component/Forecast/Db/Db.php`) has no self-heal on a
421
+ miss; the dimension is filled **only** by `Netsuite/Location/SyncAll`, which has no location webhook
422
+ (absent from the AMQ `RECORD_TYPE_MAP`) and no cron — recommended a per-process `SyncAll` self-heal
423
+ (optionally a location webhook → SyncAll; delete → no-op). Root-caused the empty amountDue: **a
424
+ NetSuite SO REST record has no amount-remaining/AR field** (only the invoice does —
425
+ `transaction.foreignamountunpaid`); documented the reusable **per-SO SuiteQL AR rollup** over linked
426
+ invoices (`previoustransactionlinelink` `SalesOrd→CustInvc`, DISTINCT `nextdoc` or AR triples;
427
+ per-SO IN-lists — full-book JOIN 400s; no table aliases; NetSuite lowercases the output alias) and
428
+ the **Customer Deposit unapplied** derivation (`foreigntotal − Σ DepAppl`). Audited the AMQ
429
+ enqueuer's actual record types (180d WorkerJobs: SalesOrder/Invoice/Opportunity/JournalEntry/
430
+ CashSale/CreditMemo; NOT customerPayment/Deposit/Refund/cashRefund; not location) and recorded the
431
+ **invoice-UE → `createdfrom` → salesOrder-edit bridge** (`ue_amq_invoice_resync_salesorder.js`) as
432
+ the freshness fix for balance events that never reach the SO's webhook. Added the
433
+ **delete-and-reconcile backfill technique** (deleting an SO's OOI rows is reconciler drift → fixer
434
+ recreates them with the recomputed columns; no backfill script). Corrected the stale
435
+ `failureReason` reference (column renamed to `output`). (dfranks)
339
436
  - 2026-06-29 — **Recorded the model/DB drift + prerequisites for the open-order location/backorder/
340
437
  amountDue backfill (TRUE-79162, planning only — no code written).** `_Model_Forecast_OpenOrderItem`
341
438
  declares neither `locationId` nor `quantityBackordered` though both columns already exist in prod
@@ -19,7 +19,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
19
19
 
20
20
  - **_underscore** (_Underscore) _(framework core)_ — 38 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
21
  - **worker2** (Worker) — 31 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
- - **api2** (API) — 14 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
22
+ - **api2** (API) — 16 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
23
23
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
24
  - **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
25
25
  - **saml** (SAML SSO Gateway) — 3 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.432",
3
+ "version": "1.0.433",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",