toga-ai 1.0.243 → 1.0.244

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.
@@ -35,6 +35,7 @@ related:
35
35
  - ../architecture.md
36
36
  - ../../worker2/architecture.md
37
37
  - ../../worker2/features/netsuite-salesorder-open-orders-sync.md
38
+ - ../../worker2/features/netsuite-supporting-record-webhook-importer.md
38
39
  - ./netsuite-rest-client.md
39
40
  ---
40
41
 
@@ -15,6 +15,7 @@
15
15
  | [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 |
16
16
  | [NetSuite → 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/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, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
17
17
  | [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 |
18
+ | [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 |
18
19
  | [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 |
19
20
  | [Startech Webhook Handler (worker2)](features/startech-webhook-handler.md) | Receives inbound webhook events from Startech (Easeedesk) and creates or updates the corresponding ticket in TOGA 2.0. | worker2/Worker/Startech.php |
20
21
  | [Talos (TOGa IQ) Meeting-Notes Integration & Token Auto-Refresh (consumer)](features/talos-meeting-notes-integration.md) | How a **dev tool / agent consumes Talos (TOGa IQ)** to query the team meeting-notes corpus programmatically. | .claude/skills/plan-ticket/scripts/talos.js |
@@ -0,0 +1,135 @@
1
+ ---
2
+ title: NetSuite Supporting-Record Webhook Importer (the reusable recipe)
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-30
10
+ owners: ["dfranks"]
11
+ files:
12
+ - worker2/Worker/Netsuite/Employee.php
13
+ - worker2/Worker/Netsuite/Account.php
14
+ - worker2/Worker/Netsuite/Classification.php
15
+ - worker2/Worker/Netsuite/Customer.php
16
+ - worker2/Worker/Netsuite/Item.php
17
+ - worker2/Worker/Netsuite.php
18
+ - _underscore/Model/Forecast/Employee.php
19
+ - _underscore/Model/Forecast/Account.php
20
+ - _underscore/Model/Forecast/Classification.php
21
+ - _underscore/Component/Forecast/Db/Db.php
22
+ - test/@dave/test_employee_lifecycle.php
23
+ - test/@dave/test_account_lifecycle.php
24
+ - test/@dave/test_classification_lifecycle.php
25
+ - test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js
26
+ - worker/crons/toga2/forecast2/import_supporting_records.php
27
+ related:
28
+ - ./netsuite-opportunity-sync.md
29
+ - ./netsuite-salesorder-open-orders-sync.md
30
+ - ../../_underscore/features/forecast-sale-import.md
31
+ - ../../_underscore/features/netsuite-rest-client.md
32
+ - ../architecture.md
33
+ ---
34
+
35
+ ## Summary
36
+
37
+ A single **repeatable recipe** for porting a legacy daily-pull NetSuite *supporting-record*
38
+ importer (the lookup/dimension tables behind Forecast2 — Employees, Accounts, Classifications,
39
+ Customers, Items) into a **real-time, webhook-driven, single-record upsert**. A NetSuite
40
+ create/edit/delete arrives at `webhook.togahub.com/netsuite`, the `_Worker_NetSuite::Webhook`
41
+ router dispatches `Netsuite/<Record>/<verb>`, and a thin per-record handler upserts (or
42
+ guarded-deletes) one row in the `Forecast` schema.
43
+
44
+ This is the supporting-record sibling of the transaction-family importers — the
45
+ [Opportunity sync](./netsuite-opportunity-sync.md) (header+children), the
46
+ [Open-Orders sync](./netsuite-salesorder-open-orders-sync.md), and the
47
+ [Forecast.Sales import engine](../../_underscore/features/forecast-sale-import.md). After
48
+ Items + Customers established it and Employees/Accounts/Classifications repeated it verbatim, the
49
+ **recipe is the durable knowledge**: the remaining siblings are mechanical. This doc captures the
50
+ recipe and its two recurring variants so a new one is a fill-in-the-blanks job, not a fresh design.
51
+
52
+ ## The recipe (per supporting record)
53
+
54
+ 1. **Handler** `worker2/Worker/Netsuite/<Record>.php`:
55
+ `abstract class _Worker_Netsuite_<Record> implements _Interface_Static_Webhook_Netsuite`.
56
+ The shared `_Worker_NetSuite::Webhook` router PascalCases the inbound `recordType` to find it —
57
+ **no router edit is needed.** Verbs:
58
+ - `post` / `put` → `syncRecord()` then `_Database::transactionCommit(self::DB_FORECAST)`
59
+ (the lazy-transaction discipline — `syncRecord` itself never commits; the caller owns the txn).
60
+ - `delete` → run the **referenced-guard** first, and commit **only if it actually deletes**.
61
+ 2. **`syncRecord()`** (no commit): fetch the record via SuiteQL single-id form —
62
+ `_Component_Api_Netsuite::send('POST', QUERY_SUITEQL.'?limit=1', ['q'=>$sql], ['Prefer'=>'transient'])`
63
+ — then **upsert keyed on `netsuiteInternalId`**: `load()` the existing row, then **change-detect
64
+ with string-cast compares** (`_Number::nullableIntChanged` for nullable ints) before `save()`.
65
+ The guard is mandatory because **`save()` does NOT diff** — without the change-detect it would
66
+ re-write (and could re-touch) every field on every webhook.
67
+ 3. **Model** `_underscore/Model/Forecast/<Record>.php extends _Model_True`, declaring **ONLY the
68
+ NetSuite-sourced columns.** Deliberately **omit externally-managed columns** so a webhook upsert's
69
+ `save()` can never null them — e.g. Employees' `teamId` / `departmentId` / `role` / `urlHeadshot`
70
+ are Power-BI/HR-managed and are left off the model.
71
+ 4. **Delete guard** = a `_Component_Forecast_Db::<record>IsReferenced(int): bool` helper using
72
+ `SELECT EXISTS( … UNION ALL … )` across **every inbound RESTRICT FK**. Referenced → return a
73
+ *retained-message string* (do **NOT** throw, do **NOT** blast dependent rows); unreferenced →
74
+ hard-delete. All these helpers are added to the **same** `_underscore/Component/Forecast/Db/Db.php`
75
+ right after `lookupId()` (so independent sibling-ticket PRs branched from `_production` conflict
76
+ *trivially* on merge — keep every helper method).
77
+ 5. **Enqueuer:** add `'<record>':'<record>'` to `RECORD_TYPE_MAP` in
78
+ `test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js` (account/customer/employee/
79
+ classification entries already exist).
80
+ 6. **e2e harness** `test/@dave/test_<record>_lifecycle.php`: clone an existing one. It drives NetSuite
81
+ REST (check/create/get/update/delete) + the in-process worker verbs (post/put/verb-delete) + a local
82
+ dump of the resulting `Forecast` rows. Boots via chdir `worker2` + `require index.php`.
83
+ 7. **Cron:** leave the legacy `SHOULD_SYNC_<X>` daily pull **ON** as a backstop (and, for self-ref
84
+ records, the parent/supervisor backfill). It shares `import_supporting_records.php` with the other
85
+ sections, so **never disable the wrapper.**
86
+
87
+ ## Two recurring variants
88
+
89
+ - **Self-referential parent/supervisor FK** (`Employees.supervisorEmployeeId`,
90
+ `Classifications.parentClassificationId`) = **RESOLVE-OR-NULL, never recursive upsert.** Resolve the
91
+ parent's local id by its NetSuite internalId via `lookupId`; if absent store **NULL** (the daily cron
92
+ backfills the link later). Inserting a non-existent local parent id would violate or dangle the FK.
93
+ The delete guard for a self-ref record must **also** check for child rows
94
+ (`WHERE parent…Id = id`) to avoid orphaning the hierarchy.
95
+ - **Type/enum mapping** (Accounts): replicate the authoritative shim's `accttype`→canonical map
96
+ (`App_Api_Netsuite_Rest::listAccounts`, `library/app/api/netsuite/rest.php:218-237`) as a handler
97
+ const `ACCT_TYPE_MAP` so stored values stay format-consistent (`_income`, `_accountsReceivable`, …);
98
+ an unmapped value passes through raw. **Keep the const in sync with the shim.**
99
+ - **Name from a hierarchical NetSuite `name`/`fullname`** = the **leaf segment**
100
+ (`explode(' : ')` + `array_pop`), matching the SOAP/cron/Item resolver.
101
+
102
+ ## Gotchas / known issues
103
+
104
+ - **SuiteQL over the REST client has NO server-side placeholder binding.** `_Component_Api_Netsuite::send`
105
+ passes the query string as-is, so the **`(int)` cast on an already-int id is the injection control** —
106
+ the same codebase-wide idiom used by `lookupId()` and the Item/Opportunity/Employee handlers. A
107
+ php-reviewer flags it **CRITICAL** on a fresh read; it is **acceptable here** because the id is a
108
+ local PK from `load()`, never user input. Document the cast-only mitigation with a one-line comment.
109
+ - **SuiteQL returns parent/supervisor reference ids as numeric STRINGS**, so the private resolve-helper
110
+ parameter must stay **untyped** (not `int|null`) or it `TypeError`s.
111
+ - **The supporting-record tables already carry a UNIQUE index on `netsuiteInternalId`** (Employees,
112
+ Accounts, Classifications, Customers, Items), so the upsert's `load()` resolves exactly one row — no
113
+ duplicate-amplification (unlike the [opportunity `load()` footgun](./netsuite-opportunity-sync.md)),
114
+ no migration needed.
115
+ - **`save()` does not diff** — see recipe step 2: the change-detect guard before `save()` is what makes
116
+ a no-change webhook a true no-op.
117
+
118
+ ## Change history
119
+
120
+ - 2026-06-30 — Captured the supporting-record webhook-importer recipe and its two variants
121
+ (self-ref RESOLVE-OR-NULL parent FK + child-orphan delete-guard; Accounts `accttype` enum map; leaf
122
+ segment naming) after the third+ application (TRUE-79870 Employees, TRUE-79871 Accounts, TRUE-79872
123
+ Classifications) repeated the Items/Customers pattern verbatim. Recorded the model-omits-externally-
124
+ managed-columns rule, the `_Component_Forecast_Db::<record>IsReferenced` delete-guard contract
125
+ (retained-message string, never throw/blast), the trusted-int SuiteQL idiom (no REST placeholder
126
+ binding; cast-only mitigation), the SuiteQL-returns-string reference-id `TypeError` trap, and the
127
+ leave-the-legacy-cron-on-as-backstop boundary. (dfranks)
128
+
129
+ ## Related docs
130
+
131
+ - [NetSuite → TOGA Opportunity Sync](./netsuite-opportunity-sync.md) — header+children transaction sibling.
132
+ - [NetSuite → Forecast Open-Orders Sync](./netsuite-salesorder-open-orders-sync.md).
133
+ - [Forecast.Sales NetSuite import engine](../../_underscore/features/forecast-sale-import.md) — the
134
+ revenue-transaction family + the shared `_Component_Forecast_Db` low-level helpers.
135
+ - [Worker (worker2) Architecture](../architecture.md).
@@ -17,7 +17,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
17
17
  ## 2.0 framework
18
18
 
19
19
  - **_underscore** (_Underscore) _(framework core)_ — 19 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
20
- - **worker2** (Worker) — 22 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
20
+ - **worker2** (Worker) — 23 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
21
21
  - **api2** (API) — 7 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
22
22
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
23
23
  - **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.243",
3
+ "version": "1.0.244",
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",