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-
|
|
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
|
-
###
|
|
134
|
+
### Backfilling location / backorder / amountDue onto open orders (TRUE-79162 / TRUE-80262)
|
|
131
135
|
|
|
132
|
-
A
|
|
133
|
-
|
|
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`).
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
- **`Forecast.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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`
|
|
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
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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) —
|
|
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