toga-ai 1.0.190 → 1.0.192
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/1.0/apps/togadesk/INDEX.md +1 -0
- package/knowledge/1.0/apps/togadesk/architecture.md +2 -0
- package/knowledge/1.0/apps/togadesk/features/notifications.md +79 -0
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -0
- package/knowledge/2.0/apps/_underscore/features/netsuite-rest-client.md +101 -0
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -1
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +14 -1
- package/knowledge/2.0/apps/worker2/features/netsuite-salesorder-open-orders-sync.md +77 -2
- package/knowledge/INDEX.md +2 -2
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
| [TOGa Desk Architecture](architecture.md) | TOGa Desk is the staff-facing support desk **and field-service platform** (analysts work at `/desk/`). | desk/index.php, desk/includes/loader.php, desk/config.php, desk/includes/controllers/actions.php, desk/includes/controllers/data.php, desk/includes/controllers/modals.php, desk/includes/classes/class.app.php, desk/includes/classes/class.ticket.php, desk/api/index.php, desk/api/resources/tickets.php, crons/tickets.php |
|
|
6
6
|
| [Email-to-Ticket Intake (crons/tickets.php)](features/email-to-ticket-intake.md) | TOGa Desk ingests support email into tickets through a cron-driven IMAP poller (`crons/tickets.php`) plus a postfix pipe variant (`crons/pipe.php`). | crons/tickets.php, crons/tickets_prod.php, crons/pipe.php, desk/includes/classes/class.ticket.php |
|
|
7
7
|
| [Field-Service Dispatch (central / repair orders)](features/field-service-dispatch.md) | The **central** subsystem is TOGa Desk's field-service dispatch domain: repair-order lifecycle, technician scheduling, onsite vs depot service, parts, and shipm | desk/includes/controllers/actions/central/, desk/includes/classes/class.repair.php, desk/includes/classes/class.repairhistory.php, desk/_/browser/datatable/central.php, desk/template/pages/central.php, desk/template/pages/central/view.php, desk/template/modals/central/addTracking.php, library/app/model/togadesk/repairordertracking.php, library/app/api/carrier/ups.php |
|
|
8
|
+
| [Ticket Email Notifications (notifications table)](features/notifications.md) | Which TOGa Desk emails fire for a given client is driven **entirely by data**, not code: the `TOGaDeskSupport.notifications` table holds one row per `(clientid, | desk/includes/classes/class.notification.php, crons/tickets.php, crons/tickets_prod.php |
|
|
8
9
|
| [REST API (RPC-over-POST) & API-Key Auth](features/rest-api.md) | TOGa Desk exposes a programmatic API at `desk/api/`. | desk/api/index.php, desk/api/resources/tickets.php, desk/api/resources/assets.php, desk/api/resources/authenticate.php, desk/includes/classes/class.apikey.php, desk/includes/functions.php |
|
|
9
10
|
| [SMB Contract Editing & the clientMspId Corruption Trap](features/smb-contract-editing.md) | The SMB contracts page (`/desk/?route=toga/smbcontracts&togaClientId=<id>`) edits `TOGA_*.SMBContracts` rows via a modal. | desk/template/modals/toga/smbcontracts/smbContract.php, desk/includes/controllers/modals/toga/smbcontracts/smbContract.php, desk/includes/controllers/actions/toga/smbcontracts/smbContract.php, desk/includes/controllers/actions/toga/smbcontracts/edit.php |
|
|
10
11
|
| [Ticket Lifecycle (class.ticket.php)](features/ticket-lifecycle.md) | All TOGa Desk ticket creation and reply handling funnels through `Ticket` in `desk/includes/classes/class.ticket.php`. | desk/includes/classes/class.ticket.php, desk/includes/controllers/actions.php, desk/api/resources/tickets.php, crons/tickets.php, desk/includes/controllers/actions/tickets/merge.php |
|
|
@@ -27,6 +27,7 @@ related:
|
|
|
27
27
|
- 1.0/apps/togadesk/features/rest-api.md
|
|
28
28
|
- 1.0/apps/togadesk/features/field-service-dispatch.md
|
|
29
29
|
- 1.0/apps/togadesk/features/email-to-ticket-intake.md
|
|
30
|
+
- 1.0/apps/togadesk/features/notifications.md
|
|
30
31
|
- 1.0/apps/togadesk/workflows/standalone-test-scripts.md
|
|
31
32
|
---
|
|
32
33
|
|
|
@@ -203,4 +204,5 @@ Invariants:
|
|
|
203
204
|
- [REST API](features/rest-api.md)
|
|
204
205
|
- [Field-service dispatch (central)](features/field-service-dispatch.md)
|
|
205
206
|
- [Email-to-ticket intake](features/email-to-ticket-intake.md)
|
|
207
|
+
- [Ticket email notifications](features/notifications.md)
|
|
206
208
|
- [SMB contract editing](features/smb-contract-editing.md)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Ticket Email Notifications (notifications table)
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: togadesk
|
|
5
|
+
project: TOGa Desk
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-24
|
|
10
|
+
owners: ["ajean"]
|
|
11
|
+
files:
|
|
12
|
+
- desk/includes/classes/class.notification.php
|
|
13
|
+
- crons/tickets.php
|
|
14
|
+
- crons/tickets_prod.php
|
|
15
|
+
related:
|
|
16
|
+
- 1.0/apps/togadesk/features/ticket-lifecycle.md
|
|
17
|
+
- 1.0/apps/togadesk/features/email-to-ticket-intake.md
|
|
18
|
+
- 1.0/apps/togadesk/architecture.md
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Summary
|
|
22
|
+
Which TOGa Desk emails fire for a given client is driven **entirely by data**, not code:
|
|
23
|
+
the `TOGaDeskSupport.notifications` table holds one row per `(clientid, type)`. If a client
|
|
24
|
+
has no row for a given `type`, **that notification silently does not send** — there is no
|
|
25
|
+
fallback to a global/default template. So a "staff never got notified" report is almost
|
|
26
|
+
always a **missing notification row for that client**, not a recipient-config or code bug.
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
Each ticket event (new ticket, reply, assignment, escalation, auto-close, new account) looks
|
|
30
|
+
up a `notifications` row keyed by the ticket's `clientid` and a fixed `type` string. A match
|
|
31
|
+
renders and sends; **no match = no email, no error, no log**. Notifications split into two
|
|
32
|
+
audiences:
|
|
33
|
+
|
|
34
|
+
- **Submitter-facing (`USER_*` / `PEOPLE_*`):** `USER_NEW_TICKET` (creation acknowledgment),
|
|
35
|
+
`USER_NEW_TICKET_REPLY`, `USER_AUTO_CLOSE`, `PEOPLE_USER_NEW_ACCOUNT`.
|
|
36
|
+
- **Staff-facing (`STAFF_*`):** `STAFF_NEW_TICKET`, `STAFF_NEW_TICKET_REPLY`,
|
|
37
|
+
`STAFF_TICKET_ASSIGNED_TO_DEPARTMENT`, `STAFF_TICKET_ASSIGNED_TO_PERSON`,
|
|
38
|
+
`STAFF_TICKET_ESCALATION`.
|
|
39
|
+
|
|
40
|
+
Most clients have the **full** set of both audiences (e.g. clientids 2, 3, 19, 20, 138). A
|
|
41
|
+
client configured with only `USER_*`/`PEOPLE_*` rows will acknowledge submitters but **never
|
|
42
|
+
notify staff**.
|
|
43
|
+
|
|
44
|
+
### Staff recipient resolution (separate from the `type` lookup)
|
|
45
|
+
Once a `STAFF_*` notification fires, recipients for the ticket's department are resolved via
|
|
46
|
+
`TOGaDeskSupport.people_departments` (peopleid ↔ departmentid) joined to
|
|
47
|
+
`TOGaDeskSupport.people`, filtered to `isActive = 1` **and** `ticketsnotification = 1`.
|
|
48
|
+
Recipient config and the `(clientid, type)` row are independent: correct recipients still get
|
|
49
|
+
nothing if the `STAFF_*` row is absent.
|
|
50
|
+
|
|
51
|
+
## Data model
|
|
52
|
+
- `TOGaDeskSupport.notifications` — keyed `(clientid, type)`; one row per enabled notification
|
|
53
|
+
per client. Absence of a row = that notification disabled for that client.
|
|
54
|
+
- `TOGaDeskSupport.people` — `isActive`, `ticketsnotification` (1 = opted into ticket email).
|
|
55
|
+
- `TOGaDeskSupport.people_departments` — peopleid ↔ departmentid mapping.
|
|
56
|
+
|
|
57
|
+
## Gotchas / known issues
|
|
58
|
+
- **Silent disable by omission.** A missing `(clientid, type)` row produces no email and no log
|
|
59
|
+
line — diagnose "missing notification" reports by querying `notifications` for that client's
|
|
60
|
+
rows first, before suspecting recipient flags or code.
|
|
61
|
+
- **TOGA Technology Helpdesk (clientid 178) had only the `USER_*`/`PEOPLE_*` rows and was
|
|
62
|
+
missing every `STAFF_*` row** (notably `STAFF_NEW_TICKET`). Submitters got their
|
|
63
|
+
`USER_NEW_TICKET` acknowledgment, but no staff were notified of new tickets even though
|
|
64
|
+
department-9 staff had `ticketsnotification = 1`. Root cause was purely the missing rows;
|
|
65
|
+
fixed by adding the `STAFF_*` rows via the TOGa Desk UI (no code change). Reproduced with
|
|
66
|
+
internal ticket #178000688 (internal id 263292, department 9, origin EMAIL).
|
|
67
|
+
- Tickets with **origin EMAIL** are created by the email-to-ticket cron
|
|
68
|
+
(`crons/tickets_prod.php`, run every ~30 min from the worker's `cron.worker.togadesk.json`
|
|
69
|
+
out of the sibling `../ontrack` checkout) — see the email-to-ticket intake doc. The
|
|
70
|
+
notification still depends on a `STAFF_NEW_TICKET` row existing for the ticket's client.
|
|
71
|
+
|
|
72
|
+
## Change history
|
|
73
|
+
- 2026-06-24 — documented the `notifications` `(clientid, type)` data model and staff-recipient
|
|
74
|
+
resolution; recorded the clientid-178 missing-`STAFF_*`-rows incident (ajean)
|
|
75
|
+
|
|
76
|
+
## Related docs
|
|
77
|
+
- [Ticket lifecycle](ticket-lifecycle.md)
|
|
78
|
+
- [Email-to-ticket intake](email-to-ticket-intake.md)
|
|
79
|
+
- [TOGa Desk architecture](../architecture.md)
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
| [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql |
|
|
7
7
|
| [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 | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
|
|
8
8
|
| [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
|
|
9
|
+
| [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
|
|
9
10
|
| [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
|
|
10
11
|
| [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql |
|
|
11
12
|
| [Tracking-Number Bridge Migration (ASN / Item Fulfillment / Item Receipt)](features/tracking-number-bridges.md) | Shipment tracking numbers used to live as **scalar FK columns** (`trackingNumberId`, `returnTrackingNumberId`) directly on the lowest-level "unit"/"item" tables | api2/Component/Api/V2/V2.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnit.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillmentItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Prudential/AdvanceShippingNotice.php, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Trait/Netsuite/ItemFulfillment.php, api2/Component/Api/Cxml/Cxml.php, dbchanges2/Client/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Core/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Client_Prudential/2026-06-15 - ItemFulfillmentTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Quad/2026-06-18a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19b - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql, dbchanges2/Client_Quad/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql |
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-24
|
|
10
|
+
owners: ["dfranks"]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Component/Api/Netsuite/Netsuite.php
|
|
13
|
+
related:
|
|
14
|
+
- ../../worker2/features/netsuite-salesorder-open-orders-sync.md
|
|
15
|
+
- ../../worker2/features/netsuite-opportunity-sync.md
|
|
16
|
+
- ../architecture.md
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Summary
|
|
20
|
+
|
|
21
|
+
`_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive
|
|
22
|
+
every worker2/api2 NetSuite caller uses for record GETs, SuiteQL, and (now) record **writes**. It
|
|
23
|
+
is the REST replacement for the SOAP `NetSuiteService` (`library/netsuitetoolkit`), per the
|
|
24
|
+
team standard that production NetSuite code must use REST/SuiteQL (SOAP is OK only for ad-hoc
|
|
25
|
+
troubleshooting). This doc covers the framework-level client mechanics — write methods and the
|
|
26
|
+
SuiteQL escaping rule — that callers in other repos depend on.
|
|
27
|
+
|
|
28
|
+
> **Two `_Component_Api_Netsuite` classes exist — edit the right one.** worker2 (and the webhook
|
|
29
|
+
> integrations) call the **global** `_Component_Api_Netsuite` autoloaded from
|
|
30
|
+
> `_underscore/Component/Api/Netsuite/Netsuite.php`. `api2` ships a *separate* `namespace api` copy
|
|
31
|
+
> (`\api\_Component_Api_Netsuite`) for its own use. Editing the api2 copy has **no effect** on the
|
|
32
|
+
> worker2/webhook path — change the `_underscore` one.
|
|
33
|
+
|
|
34
|
+
## Record writes — REST has no SOAP `baseRef->internalId`
|
|
35
|
+
|
|
36
|
+
### Create — `createRecord(string $route, array $payload): string`
|
|
37
|
+
|
|
38
|
+
The **first REST write method in the framework** (added 2026-06-24). It is the reusable primitive
|
|
39
|
+
for migrating all NetSuite writes off SOAP — there is no REST equivalent of SOAP's
|
|
40
|
+
`writeResponse->baseRef->internalId`. NetSuite returns a record POST as **HTTP 204 with the new
|
|
41
|
+
internalId in the `Location` response header** (no body), so the id cannot be read from the
|
|
42
|
+
response payload.
|
|
43
|
+
|
|
44
|
+
`send()` deliberately surfaces only the body, not headers, so `createRecord()` issues the POST
|
|
45
|
+
through `_ApiRequest` directly (mirroring `send()`'s auth/endpoint/header setup), then parses the
|
|
46
|
+
`Location` header off the raw header string:
|
|
47
|
+
|
|
48
|
+
- `_ApiRequest` exposes **`responseCode` + `responseHeaders`** (a raw header string — there is **no
|
|
49
|
+
`getHeader()`** accessor). `_Component_Api_Netsuite::send()` does **not** surface either.
|
|
50
|
+
- Robust parse: `preg_match_all('/^Location:\s*(\S+)/im', $responseHeaders, ...)` and take the
|
|
51
|
+
**LAST** match (skips any 100-Continue / redirect block), then `#/(\d+)(?:[?#]|$)#` to pull the
|
|
52
|
+
trailing numeric id tolerating a query string or fragment.
|
|
53
|
+
- Throws on non-2xx, on no `Location` header, and on no id parsed.
|
|
54
|
+
|
|
55
|
+
### Update — reuse `send('PATCH', $route, $body)`
|
|
56
|
+
|
|
57
|
+
Updates do **not** need a new helper. A NetSuite record PATCH returns 204 with no body, and
|
|
58
|
+
`send()` already throws on non-2xx — so the update path is just `send('PATCH', RECORD.'/'.$id,
|
|
59
|
+
$body)`. Only the create path (which must read the `Location` header) required the new primitive.
|
|
60
|
+
|
|
61
|
+
## SuiteQL string-literal escaping — double the quote, never backslash
|
|
62
|
+
|
|
63
|
+
SuiteQL is **ANSI SQL sent to NetSuite as the JSON `q` string** — it is **not** executed against
|
|
64
|
+
MySQL. It escapes a single quote by **doubling it** (`''`), not with a backslash. Therefore
|
|
65
|
+
`_Database::escape()` (mysqli `real_escape_string`, backslash-style) is the **wrong tool** and
|
|
66
|
+
would corrupt values like `O'Brien`. Use `str_replace("'", "''", $value)` to escape a SuiteQL
|
|
67
|
+
string literal. (worker2's `SalesOrder.php` carries this as a private `escapeSuiteQl()` helper;
|
|
68
|
+
any new SuiteQL caller must do the same.)
|
|
69
|
+
|
|
70
|
+
## Logging
|
|
71
|
+
|
|
72
|
+
`send()` calls `setLogging(false)`, so **NetSuite REST request/response bodies are NOT written to
|
|
73
|
+
`Logs.Api`** — unlike platform/ClickUp/Freshservice/Sentry traffic. You cannot pull a historical
|
|
74
|
+
NetSuite response for debugging. (Removing the default-off is under review; see the opportunity-sync
|
|
75
|
+
doc.)
|
|
76
|
+
|
|
77
|
+
## Gotchas / known issues
|
|
78
|
+
|
|
79
|
+
- **No `Location` header / wrong header accessor.** `_ApiRequest` has `responseHeaders` (raw
|
|
80
|
+
string) but no `getHeader()` — parse the string. `send()` exposes neither `responseCode` nor
|
|
81
|
+
`responseHeaders`, which is why `createRecord()` goes through `_ApiRequest` directly rather than
|
|
82
|
+
layering on `send()`.
|
|
83
|
+
- **Don't escape SuiteQL with `_Database::escape()`** — backslash escaping corrupts `O'Brien`-style
|
|
84
|
+
values. SuiteQL doubles the quote (`''`).
|
|
85
|
+
- **204-with-empty-body is success, not failure.** Both create (204 + `Location`) and update (204,
|
|
86
|
+
no body) return no payload; treat a 2xx with empty body as success and key off the status, not the
|
|
87
|
+
body.
|
|
88
|
+
|
|
89
|
+
## Change history
|
|
90
|
+
|
|
91
|
+
- 2026-06-24 — **Added `createRecord()` — the first REST write method in the framework.** Posts
|
|
92
|
+
through `_ApiRequest` directly (since `send()` hides headers) and parses the new internalId from
|
|
93
|
+
the 204 `Location` header (last match, trailing-id regex). Update path documented as reuse of
|
|
94
|
+
`send('PATCH', …)`. Also recorded the SuiteQL `''`-escaping rule (not `_Database::escape()`) as a
|
|
95
|
+
framework-level caller requirement. (dfranks)
|
|
96
|
+
|
|
97
|
+
## Related docs
|
|
98
|
+
|
|
99
|
+
- [NetSuite → Forecast Open-Orders Sync](../../worker2/features/netsuite-salesorder-open-orders-sync.md) — first caller of `createRecord()` (the SalesOrder outbound push).
|
|
100
|
+
- [NetSuite → TOGA Opportunity Sync](../../worker2/features/netsuite-opportunity-sync.md) — the two-copies gotcha and the `Logs.Api` logging note.
|
|
101
|
+
- [_underscore Framework Architecture](../architecture.md)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
| [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 |
|
|
11
11
|
| [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 |
|
|
12
12
|
| [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 |
|
|
13
|
-
| [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, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
|
|
13
|
+
| [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 |
|
|
14
14
|
| [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 |
|
|
15
15
|
| [Team Sprint Management & Reporting](features/team-sprint-management.md) | `_Worker_Team_Sprint` (file `Worker/Team/Sprint.php`) is the engine behind TOGA's internal **development-sprint process and reporting**. | worker2/Worker/Team/Sprint.php |
|
|
16
16
|
| [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, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Core/2026-06-18a - Teams Transcript Export schedule.sql |
|
|
@@ -287,7 +287,15 @@ None — platform-wide Forecast sync.
|
|
|
287
287
|
/ Peter Violini — **zero** `asisystem.com` members in the 72-person workspace), the user field is always
|
|
288
288
|
blank. **It does not self-heal:** `UPDATE_CLICKUP = false` means a later NetSuite edit never back-fills the
|
|
289
289
|
field, and the users field is add-only on update anyway — so onboarding the person into ClickUp later
|
|
290
|
-
requires a one-time backfill of the existing tasks.
|
|
290
|
+
requires a one-time backfill of the existing tasks.
|
|
291
|
+
**Mitigation (2026-06-24): a default assignee fallback.** `buildCustomFields()`'s else-branch (when
|
|
292
|
+
`resolvePresalesUserId()` returns `null`) now wires in the previously-unused constant
|
|
293
|
+
`DEFAULT_PRESALES_LEAD_USER_ID = '87374309'` (verified via ClickUp `GET /team` = Cory Martin,
|
|
294
|
+
comartin@togatech.com, an active member) so the Presales Lead **users** field is set to that fallback
|
|
295
|
+
user instead of being omitted — the task no longer lands with an empty users field. The ClickUp
|
|
296
|
+
people-field value shape is `['add' => [$userId], 'rem' => []]` (delta op: `add` attaches, `rem`
|
|
297
|
+
removes; `rem` only matters on the update path; a `null` in the `add` array is still a 400, which is why
|
|
298
|
+
the fallback is wired rather than allowing null). Diagnostic signature when triaging a "no presales lead"
|
|
291
299
|
report: NS **has** the lead set, the ClickUp task's **Presales Email is set** but **Presales Lead user is
|
|
292
300
|
empty**, and `creator` = the worker2 `[clickup] token` owner (`jcardinal@togatech.com`) — which also rules
|
|
293
301
|
out the NetSuite-side `OPP <id>` stub path (those are unnamed, empty-field tasks) and the legacy `webhook/`
|
|
@@ -337,6 +345,11 @@ same **skip-if-unchanged** compare on the extracted values, and **actor-identity
|
|
|
337
345
|
trigger a CU→NS write. The NS→CU change-detection above is the complementary backstop, not a substitute.
|
|
338
346
|
|
|
339
347
|
## Change history
|
|
348
|
+
- 2026-06-24 — **Default presales-lead assignee fallback.** `buildCustomFields()` now wires the
|
|
349
|
+
previously-unused `DEFAULT_PRESALES_LEAD_USER_ID = '87374309'` (Cory Martin, comartin@togatech.com —
|
|
350
|
+
verified active via ClickUp `GET /team`) into the else-branch when `resolvePresalesUserId()` returns
|
|
351
|
+
null, so external/non-roster presales leads no longer create a task with an **empty** Presales Lead
|
|
352
|
+
users field. Recorded the people-field delta shape `['add' => [userId], 'rem' => []]`. (dfranks)
|
|
340
353
|
- 2026-06-23 — **Root-caused "ClickUp opportunity tasks with no Presales Lead."** NetSuite HAD the lead set
|
|
341
354
|
on all four reported opps; the ClickUp Presales Lead *user* field was blank because the leads are external
|
|
342
355
|
(`@asisystem.com`) employees with no ClickUp account, so `resolvePresalesUserId()` returns null and
|
|
@@ -19,10 +19,12 @@ files:
|
|
|
19
19
|
- test/@dave/probe_missing_oo_timing.php
|
|
20
20
|
- test/@dave/probe_missing_oo_createdby.php
|
|
21
21
|
- test/@dave/probe_drift_so_dates.php
|
|
22
|
+
- test/@dave/probe_open_order_gating.php
|
|
22
23
|
- worker/crons/toga2/forecast2/import_open_orders.php
|
|
23
24
|
- worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
|
|
24
25
|
related:
|
|
25
26
|
- ./netsuite-opportunity-sync.md
|
|
27
|
+
- ../../_underscore/features/netsuite-rest-client.md
|
|
26
28
|
- ../architecture.md
|
|
27
29
|
---
|
|
28
30
|
|
|
@@ -44,8 +46,10 @@ separate build (see *Related work*).
|
|
|
44
46
|
|
|
45
47
|
- `Worker/Netsuite/SalesOrder.php` — `_Worker_Netsuite_SalesOrder`. **One class, two directions:**
|
|
46
48
|
the new INBOUND import (`POST`/`PUT`/`DELETE`, REST → Forecast) was *merged into* the pre-existing
|
|
47
|
-
OUTBOUND push (`
|
|
48
|
-
import entry points to live here (`salesOrder` → `_Worker_Netsuite_SalesOrder`), so they coexist.
|
|
49
|
+
OUTBOUND push (`CreateNetSuite`/`UpdateNetSuite`/`Sync`, Toga → NetSuite). The action router forces
|
|
50
|
+
the import entry points to live here (`salesOrder` → `_Worker_Netsuite_SalesOrder`), so they coexist.
|
|
51
|
+
**The OUTBOUND push is now REST-only** (2026-06-24) — see *Outbound push (Toga → NetSuite)* below;
|
|
52
|
+
zero SOAP references remain in this file.
|
|
49
53
|
- `Worker/Netsuite.php` — the shared router (unchanged; no per-recordType edits needed).
|
|
50
54
|
- NetSuite access is **REST only**, via the `_underscore` client `_Component_Api_Netsuite`
|
|
51
55
|
(`RECORD_SALES_ORDER` + `?expandSubResources=true`) — never the SOAP `NetSuiteService`.
|
|
@@ -70,6 +74,46 @@ separate build (see *Related work*).
|
|
|
70
74
|
deletes any row NetSuite no longer returns as open (orphans + now-closed lines). Commits
|
|
71
75
|
`DB_FORECAST` (lazy-transaction discipline).
|
|
72
76
|
|
|
77
|
+
## Outbound push (Toga → NetSuite) — REST-only
|
|
78
|
+
|
|
79
|
+
The reverse direction (`CreateNetSuite` / `UpdateNetSuite` / `Sync`) pushes a Toga sales order
|
|
80
|
+
*into* NetSuite. As of 2026-06-24 it is **REST-only** via the `_underscore`
|
|
81
|
+
[`_Component_Api_Netsuite`](../../_underscore/features/netsuite-rest-client.md) client — the SOAP
|
|
82
|
+
`NetSuiteService` was fully removed from this file.
|
|
83
|
+
|
|
84
|
+
- **Item lookup** — SOAP `ItemSearchBasic/search()` → SuiteQL `SELECT id FROM item WHERE itemid =
|
|
85
|
+
'<part>'`.
|
|
86
|
+
- **Customer resolution** — new `resolveNetSuiteCustomerId()`: prefers mapped
|
|
87
|
+
`c_netsuiteInternalCustomerId`, else SuiteQL `SELECT id FROM customer WHERE companyname =
|
|
88
|
+
'<name>'`; **throws on 0 or >1 matches** (never attach the wrong customer — REST has no SOAP
|
|
89
|
+
RecordRef-by-name resolution).
|
|
90
|
+
- **`buildNetSuiteOrder()`** returns a REST JSON body: `entity:{id}`,
|
|
91
|
+
`item:{items:[{item:{id},quantity,rate}]}`, **date-only** `tranDate` (`YYYY-MM-DD`; SOAP needed
|
|
92
|
+
full ISO 8601), `shippingAddress` via `array_filter` dropping null/`''`. `amount` is **omitted**
|
|
93
|
+
(NetSuite computes it).
|
|
94
|
+
- **Create** → `createRecord()` (reads the new internalId from the 204 `Location` header);
|
|
95
|
+
**Update** → `send('PATCH', RECORD_SALES_ORDER.'/'.$id, $body)` (204, no body, throws on non-2xx).
|
|
96
|
+
The SOAP `getNetSuiteErrorDetail()` was removed — REST errors surface as exceptions from
|
|
97
|
+
`send()`/`createRecord()`.
|
|
98
|
+
- **SuiteQL string escaping** uses a private `escapeSuiteQl()` (`str_replace("'", "''", $value)`),
|
|
99
|
+
**not** `_Database::escape()` — SuiteQL doubles the quote, so backslash escaping would corrupt
|
|
100
|
+
`O'Brien`-style names. (See the [REST client doc](../../_underscore/features/netsuite-rest-client.md).)
|
|
101
|
+
- **Blast radius isolated.** Two other worker2 files still use SOAP and each construct their own
|
|
102
|
+
`NetSuiteService` (`Worker/Ai/Bdr/Netsuite.php`, `Worker/Client/Aig/ClosedClaims.php`) — untouched;
|
|
103
|
+
`library/netsuitetoolkit` stays loaded for worker2.
|
|
104
|
+
|
|
105
|
+
### Refactor (2026-06-24)
|
|
106
|
+
|
|
107
|
+
- `fetchSalesOrder()` 4 positional params → `(array $clientContext, int $salesOrderId)` per the
|
|
108
|
+
TOGA 4-param rule (4+ positional must become an assoc-array/named-params signature; PHP named
|
|
109
|
+
*arguments* at the call site do **not** satisfy the rule — it targets the **signature**).
|
|
110
|
+
- **`Sync()` double-fetch fixed.** `Sync` previously re-ran `getClientContext()` + `fetchSalesOrder()`
|
|
111
|
+
and then called Create/Update, which fetched **again** (2× Core DB + 2× Toga API per Sync). It now
|
|
112
|
+
fetches **once** and delegates to private `doCreate(array,object,int)` / `doUpdate(object,int)`;
|
|
113
|
+
the public `CreateNetSuite`/`UpdateNetSuite` are thin fetch-then-delegate wrappers (public
|
|
114
|
+
action-router contract unchanged). Closes a small **TOCTOU** — the open/close decision and the
|
|
115
|
+
write now use the same fetched record.
|
|
116
|
+
|
|
73
117
|
## Data model
|
|
74
118
|
|
|
75
119
|
`Forecast.OpenOrderItems` — flat, denormalized **leaf** table (no header table, no FK children).
|
|
@@ -229,8 +273,39 @@ refunds). The low-level Forecast SQL/lookup helpers (`sqlLiteral`, `buildInsert`
|
|
|
229
273
|
`lookupId`, `toSqlDate`, `fetchRecord`) are currently duplicated in `Opportunity.php` + `SalesOrder.php`
|
|
230
274
|
and are a candidate to extract into a shared `_Component_Forecast_Db` before the Sales build.
|
|
231
275
|
|
|
276
|
+
## Retiring the legacy import crons — what the webhook does NOT yet cover
|
|
277
|
+
|
|
278
|
+
The NS inbound webhook currently covers **only** `salesOrder` (→ `OpenOrderItems`) and `opportunity`
|
|
279
|
+
(→ `Opportunities`). `invoice` / `cashSale` / `creditMemo` / `cashRefund` (→ `Forecast.Sales`) and
|
|
280
|
+
the supporting-records sync are **not yet on the webhook**. So disabling `import_sales.php` /
|
|
281
|
+
`import_supporting_records.php` / the Sales discrepancy-fix would **stop those syncs**.
|
|
282
|
+
|
|
283
|
+
**Durable retirement rule:** a legacy import cron may only be safely retired once its NS-side AMQ
|
|
284
|
+
enqueuer is **RELEASED in prod** (not `Testing` — `Testing` fires only for the deploying user) with
|
|
285
|
+
`DEV_OVERRIDE.enabled=false`. Note also that `periodic_forecast_discrepancy_fix_open_orders.php` is
|
|
286
|
+
currently the **only path that deletes stale `OpenOrderItems` rows** (a webhook edit only deletes
|
|
287
|
+
rows for orders it re-reads) — confirmed live: SO 7181316 went `Billed` (0 open) in NS but kept a
|
|
288
|
+
stale local `OpenOrderItems` row that only the discrepancy-fix cron would remove. Don't retire that
|
|
289
|
+
cron until the webhook path owns stale-row cleanup.
|
|
290
|
+
|
|
291
|
+
**Pre-fixture gating check.** `test/@dave/probe_open_order_gating.php` is a read-only probe that
|
|
292
|
+
replicates the inbound import gate against NS (REST GET `salesOrder`; `status ∈ OPEN_STATUSES`;
|
|
293
|
+
per-line `qtyOpen = quantity − quantityBilled`; `revenue = qtyOpen × rate`; keep unless
|
|
294
|
+
`revenue == 0 && profit == 0`) — use it to verify an order still passes gating before using it as a
|
|
295
|
+
test fixture (it surfaced the stale SO 7181316 above).
|
|
296
|
+
|
|
232
297
|
## Change history
|
|
233
298
|
|
|
299
|
+
- 2026-06-24 — **Outbound push (`CreateNetSuite`/`UpdateNetSuite`/`Sync`) converted SOAP →
|
|
300
|
+
REST-only** via `_Component_Api_Netsuite`: item + customer lookups now SuiteQL
|
|
301
|
+
(`resolveNetSuiteCustomerId()` throws on 0/>1 name matches), `buildNetSuiteOrder()` emits a REST
|
|
302
|
+
JSON body (date-only `tranDate`, `amount` omitted), create → `createRecord()` (204 `Location`
|
|
303
|
+
header), update → `send('PATCH', …)`; SuiteQL escaping via `escapeSuiteQl()` (`''`, not
|
|
304
|
+
`_Database::escape()`). Refactored `fetchSalesOrder()` to `(array,int)` (4-param rule) and fixed
|
|
305
|
+
`Sync()` double-fetch (now fetch-once + private `doCreate`/`doUpdate`, closing a TOCTOU). Recorded
|
|
306
|
+
the legacy-cron retirement boundary (webhook covers only salesOrder + opportunity; Sales/supporting
|
|
307
|
+
not yet; the open-orders discrepancy-fix is the only stale-row deleter) and added
|
|
308
|
+
`probe_open_order_gating.php`. (dfranks)
|
|
234
309
|
- 2026-06-23 — **Documented the OpenOrderItems schema/FK column, two silent zero-write paths, and the
|
|
235
310
|
full 3-table diagnostic ladder.** Recorded that the table lives in `Forecast` on core2 (~4,347 rows)
|
|
236
311
|
with FK `netsuiteSalesOrderInternalId`, and the **1054 false-negative** trap from querying it with the
|
package/knowledge/INDEX.md
CHANGED
|
@@ -6,7 +6,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
6
6
|
|
|
7
7
|
- **library** (Library) _(framework core)_ — 8 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
|
|
8
8
|
- **worker** (Worker) — 10 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
|
|
9
|
-
- **togadesk** (TOGa Desk) —
|
|
9
|
+
- **togadesk** (TOGa Desk) — 8 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
|
|
10
10
|
- **togaview** (TOGa View) — 6 doc(s) → [1.0/apps/togaview/INDEX.md](1.0/apps/togaview/INDEX.md)
|
|
11
11
|
- **webhook** (Webhook) — 1 doc(s) → [1.0/apps/webhook/INDEX.md](1.0/apps/webhook/INDEX.md)
|
|
12
12
|
- **walmarttechservices** (Walmart Tech Services) — 1 doc(s) → [1.0/apps/walmarttechservices/INDEX.md](1.0/apps/walmarttechservices/INDEX.md)
|
|
@@ -15,7 +15,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
15
15
|
|
|
16
16
|
## 2.0 framework
|
|
17
17
|
|
|
18
|
-
- **_underscore** (_Underscore) _(framework core)_ —
|
|
18
|
+
- **_underscore** (_Underscore) _(framework core)_ — 12 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
19
19
|
- **worker2** (Worker) — 13 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
20
20
|
- **api2** (API) — 6 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
21
21
|
- **dbchanges2** (Database Changes) _(framework core)_ — 2 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
package/package.json
CHANGED