toga-ai 1.0.112 → 1.0.114

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.
@@ -7,5 +7,6 @@
7
7
  | [Creating Worker Actions](features/creating-worker-actions.md) | How to add a new callable Worker action — a PHP class whose `public static` methods are invoked as background jobs (via webhook, cron, or `_Worker::runTask()`). | worker2/Worker/, worker2/Controller/Index.php, _underscore/Worker.php |
8
8
  | [Elite Freshservice Sync (worker2)](features/elite-freshservice-sync.md) | `_Worker_Elite` processes Freshservice webhook events and syncs them into TOGA 2. | worker2/Worker/Elite.php, worker2/Config/dev-kmaramreddy-laptop.ini |
9
9
  | [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/Notification/Email.php, dbchanges2/Core/2026-05-21 - Monitors.sql |
10
- | [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, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
10
+ | [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, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
11
+ | [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, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
11
12
  | [Teams Meeting Transcript Export](features/teams-transcript-export.md) | `_Worker_Team_Transcripts` (action `Team/Transcripts/Export`) polls Microsoft Graph for Teams meeting transcripts produced by a set of organizers, classifies ea | worker2/Worker/Team/Transcripts.php, worker2/Config/production.ini |
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-16
9
+ updated: 2026-06-17
10
10
  owners: ["dfranks"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite.php
@@ -20,8 +20,10 @@ files:
20
20
  - test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md
21
21
  - test/@dave/clickup/backfill_opportunity_numbers.php
22
22
  - test/@dave/clickup/probe_opportunity_fields.php
23
+ - test/@dave/probe_clickup_desc_match.php
23
24
  - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
24
25
  related:
26
+ - ./netsuite-salesorder-open-orders-sync.md
25
27
  - ../architecture.md
26
28
  ---
27
29
 
@@ -88,8 +90,16 @@ second, independently-gated concern in the same handler.
88
90
 
89
91
  1. `findClickupTaskByOpportunityNumber($tranId)` — GET the list filtered by the `Opportunity #`
90
92
  custom field (`include_closed=true&include_archived=true&custom_fields=[{field_id,operator:'=',value}]`).
91
- 2. **Match → `updateTask()`**: PUT name/description, then POST each custom field individually to
92
- `/task/{id}/field/{fieldId}` (ClickUp has no bulk custom-field set on an existing task).
93
+ 2. **Match → `updateTask()`**: **diffs first, writes only what changed** (change-detection backstop,
94
+ since 2026-06-17). PUT name/description **only if** one differs; POST **only** the custom fields
95
+ that differ (each to `/task/{id}/field/{fieldId}` — ClickUp has no bulk custom-field set). When
96
+ nothing differs it writes nothing and returns `task unchanged`. This kills the no-op-write echo: a
97
+ blind rewrite fires a `taskUpdated` webhook on every sync. Comparison is **block-to-block** —
98
+ `buildDescription()` is regenerated from current NS data and compared to the stored block (no field
99
+ extraction needed), and `findClickupTaskByOpportunityNumber()` returns the **full task object** so
100
+ the diff has the existing name/description/custom-field values. Helpers: `clickupFieldMatches()`
101
+ (text = string compare; Presales Lead users field = add-only, matches when already assigned) and
102
+ `normalizeDescription()`.
93
103
  3. **No match → `createTask()`**: POST to list `901111987449`, `custom_item_id` = `1009`.
94
104
 
95
105
  Shared helpers: `buildCustomFields()` (the field array, used by both create and update),
@@ -117,7 +127,10 @@ Shared helpers: `buildCustomFields()` (the field array, used by both create and
117
127
  JS `Date.toString()` style in **America/Los_Angeles**, e.g. `GMT-0800 (PST)`, auto PST/PDT by date);
118
128
  Stage = `entityStatus.refName` (the percent string like "10%", NOT `probability`); Details = `memo`
119
129
  (which carries NetSuite's "Missing Required Details…" fallback verbatim for pre-mandatory records).
120
- The blank separator lines are a single space. Rendered verbatim — no trailing-period normalization.
130
+ The blank separator lines are emitted as a single space, but **ClickUp stores them as truly empty
131
+ lines** (our `\n \n` comes back `\n\n`). Change-detection's `normalizeDescription()` rtrims each line
132
+ so that whitespace delta doesn't read as a change (otherwise every sync would re-write the
133
+ description). Confirmed against task 868jh6x08 / opp 73142 via `test/@dave/probe_clickup_desc_match.php`.
121
134
  - **Custom-field value mapping** (the non-obvious part — these were swapped before 2026-06-16):
122
135
  `Opportunity #` (`a5529cdc-…`) ← `tranId`; `Customer #` (`170dc118-…`) ← `entity->refName`
123
136
  (the customer **name**, deliberately — not the NetSuite customer number). Plus `Sales Rep`,
@@ -226,7 +239,37 @@ None — platform-wide Forecast sync.
226
239
  window to the load→claim gap. `findSendableIds` must list every sendable status
227
240
  (`Created`,`Pending`,`Retry`,`Sending`) — omitting `Created` silently matches nothing (`candidates:0`).
228
241
 
242
+ ## CU→NS direction (future — not yet built): reverse mapping
243
+
244
+ The current sync is **NS→CU only**. When the ClickUp→NetSuite direction is built, it cannot reuse the
245
+ block-to-block compare, because the source of the edit is the ClickUp **composite** but the destination
246
+ is **discrete NetSuite fields**. It must **reverse-map** — extract the real NS values out of the wrapper
247
+ before comparing-to / writing-to NetSuite:
248
+
249
+ - **Title → NS `title`:** the task name is `{opp#} — {customer} — {title}`. Do **not** naively split on
250
+ ` — ` (a title can contain a dash). Strip the **known** `{Opportunity#} — {Customer#} — ` prefix using
251
+ the values already on the `Opportunity #` / `Customer #` custom fields as anchors; the remainder is
252
+ the NS title.
253
+ - **Description → NS `memo`:** take only the lines between the `Details:` marker and the trailing
254
+ `NetSuite Internal ID:` line. Company / Amount / Expected Close / Stage are NS-derived display, not
255
+ ClickUp-authored — parse them out and ignore.
256
+ - **Custom fields** are already discrete — no extraction.
257
+
258
+ Pair this with: a **field-ownership gate** (only write fields ClickUp is authoritative for — Amount/
259
+ Stage/Expected Close are NS-owned, and HubSpot also writes these records, so don't push them back), the
260
+ same **skip-if-unchanged** compare on the extracted values, and **actor-identity suppression** (drop
261
+ `taskUpdated` events authored solely by the ClickUp bot/integration user) so our own NS→CU writes don't
262
+ trigger a CU→NS write. The NS→CU change-detection above is the complementary backstop, not a substitute.
263
+
229
264
  ## Change history
265
+ - 2026-06-17 — ClickUp **change-detection backstop** added to `updateTask()`: diff before write, skip
266
+ no-op syncs (`task unchanged`), PUT name/description only when changed, POST only changed custom
267
+ fields. `findClickupTaskByOpportunityNumber()` now returns the full task object; added
268
+ `clickupFieldMatches()` (add-only users field) + `normalizeDescription()` (per-line rtrim — ClickUp
269
+ trims our single-space separator lines to empty, which previously read as a permanent change).
270
+ Verified opp 73142 (changed in NS → correctly flagged) and same-data (→ `unchanged`). Also recorded
271
+ the future **CU→NS reverse-mapping** design (extract title/memo, field-ownership gate, actor-identity).
272
+ (dfranks)
230
273
  - 2026-06-16 — ClickUp task description switched to a structured block (`buildDescription()` +
231
274
  `formatAmount()`/`formatExpectedClose()`): Company/Opportunity/Amount/Expected Close/Stage/Details/
232
275
  Internal ID. Field sources confirmed via `probe_opportunity_fields.php`; Expected Close rendered
@@ -0,0 +1,120 @@
1
+ ---
2
+ title: NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-17
10
+ owners: ["dfranks"]
11
+ files:
12
+ - worker2/Worker/Netsuite/SalesOrder.php
13
+ - worker2/Worker/Netsuite.php
14
+ - test/@dave/probe_salesorder_rest_shape.php
15
+ - worker/crons/toga2/forecast2/import_open_orders.php
16
+ - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
17
+ related:
18
+ - ./netsuite-opportunity-sync.md
19
+ - ../architecture.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). A NetSuite
25
+ `salesOrder` create/edit/delete arrives at `webhook.togahub.com/netsuite`, the
26
+ `_Worker_NetSuite::Webhook` router dispatches `Netsuite/SalesOrder/{POST,PUT,DELETE}`, and the
27
+ handler syncs the order's currently-**open** line items into `Forecast.OpenOrderItems`. It is the
28
+ real-time equivalent of the batch cron `worker/crons/toga2/forecast2/import_open_orders.php` (whose
29
+ per-record logic lives in `common_import_sales_from_netsuite.php`, OPEN ORDERS section). Mirrors the
30
+ [opportunity sync](./netsuite-opportunity-sync.md) — same shape, different table and gating.
31
+
32
+ `OpenOrderItems` holds the **unbilled remainder** of approved-but-not-fully-billed sales orders;
33
+ realized revenue (invoices/cash sales/credit memos/cash refunds) lives in `Forecast.Sales` and is a
34
+ separate build (see *Related work*).
35
+
36
+ ## Key files / entry points
37
+
38
+ - `Worker/Netsuite/SalesOrder.php` — `_Worker_Netsuite_SalesOrder`. **One class, two directions:**
39
+ the new INBOUND import (`POST`/`PUT`/`DELETE`, REST → Forecast) was *merged into* the pre-existing
40
+ OUTBOUND push (`Create`/`Update`/`Sync`, Toga → NetSuite via SOAP). The action router forces the
41
+ import entry points to live here (`salesOrder` → `_Worker_Netsuite_SalesOrder`), so they coexist.
42
+ - `Worker/Netsuite.php` — the shared router (unchanged; no per-recordType edits needed).
43
+ - NetSuite access is **REST only**, via the `_underscore` client `_Component_Api_Netsuite`
44
+ (`RECORD_SALES_ORDER` + `?expandSubResources=true`) — never the SOAP `NetSuiteService`.
45
+
46
+ ## How it works
47
+
48
+ 1. `POST`/`PUT` → `importOpenOrder($internalId)`; `DELETE` → `removeAll($internalId)`.
49
+ 2. `importOpenOrder` GETs the sales order via REST, then gates on **open status** (`status->refName`
50
+ ∈ `OPEN_STATUSES`: `Pending Fulfillment`, `Partially Fulfilled`, `Pending Billing/Partially
51
+ Fulfilled`, `Pending Billing`). A non-open **or missing** status → `removeAll` (the order carries
52
+ no open rows).
53
+ 3. Per open line: skip lines with no NS item id, item-group items (`netsuiteTransactionType ==
54
+ 'itemGroup'`), and excluded EWR items (`DO_NOT_IMPORT_OPEN_ORDER_ITEM_IDS`). Throw if the NS item
55
+ isn't in `Forecast.Items` (surfaces an item-sync gap rather than dropping revenue).
56
+ 4. Open economics: `qtyOpen = quantity − quantityBilled`; `revenue = qtyOpen × rate`;
57
+ `profit = revenue − qtyOpen × unitCost` where `unitCost = costEstimate / quantity`
58
+ (divide-by-zero-safe). A line with `revenue == 0 && profit == 0` is dropped.
59
+ 5. Lookups → local ids: `Customers`/`Employees` by `netsuiteInternalId` (miss → null);
60
+ `Classifications` create-on-miss, name = last ` : `-segment of `class->refName` (e.g.
61
+ "True Solutions : SaaS Reseller : CSP" → "CSP").
62
+ 6. `syncOpenLines` upserts each open line keyed on `(netsuiteSalesOrderInternalId, lineNumber)`, then
63
+ deletes any row NetSuite no longer returns as open (orphans + now-closed lines). Commits
64
+ `DB_FORECAST` (lazy-transaction discipline).
65
+
66
+ ## Data model
67
+
68
+ `Forecast.OpenOrderItems` — flat, denormalized **leaf** table (no header table, no FK children).
69
+ Unique key `(netsuiteSalesOrderInternalId, lineNumber)`. Columns written: `netsuiteSalesOrderInternalId`,
70
+ `dateOrder` (tranDate +12h), `orderNumber` (tranId), `customerId`, `salesRepEmployeeId`,
71
+ `classificationId`, `itemId` (nullable), `lineNumber`, `revenue`, `profit`. The `accountId` FK column
72
+ exists but is **never populated** (the legacy writer never set it — parity).
73
+
74
+ ## Client variations
75
+
76
+ None — uniform (platform-wide Forecast2 sync).
77
+
78
+ ## Gotchas / known issues
79
+
80
+ - **REST shape ≠ SOAP shape.** The cron reads the SOAP-shim shape; this handler reads the REST record
81
+ (`status->refName`, line `quantityBilled`, `class->refName`, `entity->id`, `salesRep->id`,
82
+ `shippingCost`). Verified against live orders via `test/@dave/probe_salesorder_rest_shape.php`.
83
+ - **Three deliberate departures from the legacy cron (all intentional):**
84
+ 1. **No date-window gate.** The cron flips `isOrderOpen=false` for tranDate outside −365d/+90d
85
+ (`common_import_…:1753`) to bound its windowed scan. Irrelevant to a single-id webhook — dropped.
86
+ (Consequence: a future-dated open SO the cron excludes *would* sync via webhook.)
87
+ 2. **No shipping line.** The cron appends a synthetic `SHIPPING` line for `OpenOrderItems` with a
88
+ **null** item id, which its own item-id guard then drops — so shipping has **never** persisted to
89
+ `OpenOrderItems` (confirmed: 0 line-0 rows in prod). We omit it, preserving that behavior. (NB:
90
+ the cron's *Sales* section is different — it uses real item id **13500**, so shipping *does*
91
+ persist in `Forecast.Sales`.)
92
+ 3. **Zero-open cascade bug fixed.** The cron's `$isOrderOpen` is order-scoped and never reset
93
+ per-line, so the first zero-open line (`common_import_…:1848`) poisons every later line on the
94
+ order — under-reporting open revenue on partially-billed multi-line orders, order-dependent. The
95
+ webhook skips only the zero line and keeps the order's other open lines (correct per-line
96
+ semantic). `trueup_open_orders.php` has been masking this in prod data.
97
+ - **No `initialize()`.** It was removed: it only constructed the SOAP `NetSuiteService` (which *throws*
98
+ without `NS_HOST`/`NS_ENDPOINT` and builds a `SoapClient`), and the framework runs `initialize()`
99
+ before *every* action — keeping it would couple the REST import to SOAP config. The push methods
100
+ (`Create`/`Update`/`Sync`) self-construct `NetSuiteService`, so push is unaffected and the Forecast
101
+ DB is registered globally in `_.php`.
102
+
103
+ ## Related work
104
+
105
+ `Forecast.Sales` (realized revenue) is fed by four other NS record types — `invoice`, `cashSale`,
106
+ `creditMemo`, `cashRefund` — each routing to its own worker (`_Worker_Netsuite_Invoice`, etc.) over a
107
+ planned shared `_Component_Forecast_SaleImport` engine (apply `$factor = −1` for credit memos / cash
108
+ refunds). The low-level Forecast SQL/lookup helpers (`sqlLiteral`, `buildInsert`, `buildAssignments`,
109
+ `lookupId`, `toSqlDate`, `fetchRecord`) are currently duplicated in `Opportunity.php` + `SalesOrder.php`
110
+ and are a candidate to extract into a shared `_Component_Forecast_Db` before the Sales build.
111
+
112
+ ## Change history
113
+
114
+ - 2026-06-17 — Initial open-orders importer merged into `_Worker_Netsuite_SalesOrder` (TRUE-79142):
115
+ REST-only, no date-window gate, no shipping line, cascade bug fixed. (dfranks)
116
+
117
+ ## Related docs
118
+
119
+ - [NetSuite → TOGA Opportunity Sync](./netsuite-opportunity-sync.md) — the sibling pattern this mirrors.
120
+ - [Worker (worker2) Architecture](../architecture.md)
@@ -15,7 +15,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
15
15
  ## 2.0 framework
16
16
 
17
17
  - **_underscore** (_Underscore) _(framework core)_ — 7 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
18
- - **worker2** (Worker) — 7 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
18
+ - **worker2** (Worker) — 8 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
19
19
  - **api2** (API) — 3 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
20
20
  - **dbchanges2** (Database Changes) _(framework core)_ — 1 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
21
21
  - **toga2-supply** (TOGa Supply) — 2 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.112",
3
+ "version": "1.0.114",
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",