toga-ai 1.0.197 → 1.0.199
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/worker/INDEX.md +1 -1
- package/knowledge/1.0/apps/worker/features/forecast2-netsuite-reconciliation.md +56 -3
- package/knowledge/2.0/apps/_underscore/features/forecast-sale-import.md +8 -1
- package/knowledge/2.0/apps/ai-bdr/features/call-orchestration.md +18 -2
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -0
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +29 -1
- package/knowledge/2.0/apps/worker2/features/netsuite-salesorder-open-orders-sync.md +12 -1
- package/knowledge/2.0/apps/worker2/features/vapi-webhook-handler.md +156 -0
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
| [Worker (1.0 Framework) Architecture](architecture.md) | `worker` is the legacy (**1.0** `App_` framework) **background-job tier**. | worker/index.php, worker/_/app/framework.php, worker/crons/, worker/schedules/, worker/ebs/cron.worker.php, worker/.ebextensions/035_cron.worker.config |
|
|
6
6
|
| [Compass MA Sales Order Exception Report](features/compass-ma-sales-order-exception-report.md) | A worker cron that emails operations the "Compass Refresh Exception Report" — Compass `MA%` sales orders whose corresponding Office Depot (ODP) sales order has | worker/crons/toga2/compass/workflow/7_generate_ma_sales_order_exception_report.php |
|
|
7
7
|
| [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php |
|
|
8
|
-
| [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/reconcile_netsuite_totals.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.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_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
|
|
8
|
+
| [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/reconcile_netsuite_totals.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.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_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
|
|
9
9
|
| [NetSuite → TOGa Supply Per-Client Sync (thin wrappers)](features/netsuite-togasupply-per-client-sync.md) | Syncs NetSuite transactions (sales orders, purchase orders, invoices, item receipts, item fulfillments, inventory adjustments) into each TOGa Supply (2.0) clien | worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_canon.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql |
|
|
10
10
|
| [Prudential: Send Shipments for the Day report (daily cron)](features/send-shipments-for-the-day.md) | Daily cron (9:00 PM) that emails Prudential and Dell stakeholders an Excel report of all devices shipped that day, including tracking number, serial number, emp | worker/crons/notifications/reports/send_shipments_for_the_day.php |
|
|
11
11
|
| [Onboarding a Client to the NetSuite TOGa Supply Sync](workflows/onboarding-client-to-netsuite-togasupply-sync.md) | How to add a new TOGa 2 client to the per-client NetSuite → TOGa Supply importer (`worker/crons/toga2/netsuite/`). | worker/crons/toga2/netsuite/sync_togasupply.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql |
|
|
@@ -6,13 +6,14 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-06-
|
|
9
|
+
updated: 2026-06-25
|
|
10
10
|
owners: [dfranks]
|
|
11
11
|
files:
|
|
12
12
|
- test/@dave/reconcile_netsuite_totals.php
|
|
13
13
|
- test/@dave/analyze_netsuite_forecast_diff.php
|
|
14
14
|
- test/@dave/trueup_sales.php
|
|
15
15
|
- test/@dave/trueup_open_orders.php
|
|
16
|
+
- test/@dave/loop_trueup_open_orders.php
|
|
16
17
|
- test/@dave/trueup_opportunities.php
|
|
17
18
|
- test/@dave/probe_sales_gap_direct.php
|
|
18
19
|
- test/@dave/probe_missing_oo_timing.php
|
|
@@ -21,6 +22,9 @@ files:
|
|
|
21
22
|
- test/@dave/probe_profit_invoices.php
|
|
22
23
|
- test/@dave/probe_profit_gap.php
|
|
23
24
|
- worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
|
|
25
|
+
- worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php
|
|
26
|
+
- worker/crons/toga2/forecast2/import_open_orders.php
|
|
27
|
+
- worker/schedules/cron.worker.infrastructure.json
|
|
24
28
|
related:
|
|
25
29
|
- ../architecture.md
|
|
26
30
|
- ../../library/features/netsuite-suiteql-rest-shim.md
|
|
@@ -49,8 +53,29 @@ by reconciling a chosen tranDate range directly against NetSuite.
|
|
|
49
53
|
NS_ONLY (missing from FC), FC_ONLY (stale/extra), DRIFT (value differs). Read-only.
|
|
50
54
|
- `trueup_sales.php --from --to [--chunk-days N] [--prod] [--dry-run]` — makes `Forecast.Sales`
|
|
51
55
|
match NetSuite for a tranDate range (insert/update/delete per line).
|
|
52
|
-
- `trueup_open_orders.php --from= --to= [--prod] [--dry-run] [--verbose]
|
|
53
|
-
`Forecast.OpenOrderItems` (currently-open SOs whose tranDate falls in range).
|
|
56
|
+
- `trueup_open_orders.php --from= --to= [--prod] [--dry-run] [--verbose] [--quiet] [--by-lastmodified]`
|
|
57
|
+
— same for `Forecast.OpenOrderItems` (currently-open SOs whose tranDate falls in range).
|
|
58
|
+
- **`--by-lastmodified`** windows **both** passes on `lastmodifieddate` instead of `tranDate`:
|
|
59
|
+
Step 1 (open-SO reconcile) and Step 3b (stale cleanup). `lastmodifieddate` is a TIMESTAMP →
|
|
60
|
+
bounded `TO_TIMESTAMP(from 00:00:00)..(to 23:59:59)` in the NetSuite **ET** session tz. Under this
|
|
61
|
+
mode Step 3b **inverts the lookup**: it asks NS for SOs **modified in the window** that are no longer
|
|
62
|
+
open (`status NOT IN B/D/E/F`) via id-only paged SuiteQL (**no per-id GET, no per-row request** —
|
|
63
|
+
cost scales with SOs modified, not with local row count), then deletes the local rows for any still
|
|
64
|
+
held. This catches an order **billed in the window whose `tranDate` predates it** — exactly the
|
|
65
|
+
invoice-transform billing case a `tranDate` window misses (see the open-orders sync doc).
|
|
66
|
+
- **`--quiet`** suppresses all progress (`out()` no-op) and prints the Step 4 results block **only**
|
|
67
|
+
when `insert+update+delete > 0` (a no-change pass prints nothing); transient NS failures + fatals
|
|
68
|
+
still go to stderr.
|
|
69
|
+
- **`--verbose`** was trimmed — removed per-line MATCH/EXCLUDE/NOOP and the always-on per-SO summary;
|
|
70
|
+
the per-SO header now prints lazily, once, only when the order has an INSERT/UPDATE/DELETE.
|
|
71
|
+
- `loop_trueup_open_orders.php [--prod] [--dry-run] [--verbose] [--quiet] [--sleep=N] [--days-back=N]`
|
|
72
|
+
— continuous runner that recomputes a rolling `[today−N .. today]` window each pass and re-invokes
|
|
73
|
+
`trueup_open_orders.php --by-lastmodified` until Ctrl-C. Uses `PHP_BINARY` + `passthru` with
|
|
74
|
+
`escapeshellarg` over a **fixed flag allowlist** (`--prod`/`--dry-run`/`--verbose`/`--quiet`); survives
|
|
75
|
+
a failed pass. With `--quiet` it also suppresses its own per-pass banner + the sleeping line, so the
|
|
76
|
+
only output is a trueup Step 4 block when rows actually change. **This is the stopgap for the real-time
|
|
77
|
+
open-order removal gap** (an SO billed via invoice-transform fires no webhook → `removeAll` never runs —
|
|
78
|
+
see the open-orders sync doc).
|
|
54
79
|
- `trueup_opportunities.php --from --to [--prod] [--dry-run]` — same for `Forecast.Opportunities`
|
|
55
80
|
by tranDate range. Pure SuiteQL (no per-id GET), so it's fast: a 2026-YTD prod run reconciled
|
|
56
81
|
~4,000 opps in ~13s to a $0 delta.
|
|
@@ -206,11 +231,39 @@ None — Forecast2 is a single shared dataset.
|
|
|
206
231
|
revenue. `trueup_sales.php` repairs them: it compares against NS by trandate, detects the sign
|
|
207
232
|
mismatch, and delete+re-inserts with the correct sign. See the shim/reference docs for why a
|
|
208
233
|
uniform `-foreignamount` is correct and an extra per-type `$factor` double-negates.
|
|
234
|
+
- **`OpenOrderItems.dtPendingBilling` does not exist in prod and NO prod code writes it.** A `SHOW
|
|
235
|
+
COLUMNS` on the core2 reader confirms the column is **present locally, absent in prod** — the
|
|
236
|
+
TRUE-79081 migration was never deployed. `periodic_forecast_discrepancy_fix_open_orders.php` **intentionally
|
|
237
|
+
does not write it** (explicit guard comments; writing it would raise MySQL 1054) and worker2's
|
|
238
|
+
`_Worker_Netsuite_SalesOrder` never references it. So **nothing currently sets
|
|
239
|
+
`OpenOrderItems.dtPendingBilling` in prod.** (This **corrects** the earlier note that it was "fixed in
|
|
240
|
+
both writers" on 2026-06-15 — the writers were since changed to **skip** it pending the prod migration.)
|
|
241
|
+
- **Of the legacy open-orders crons, only the discrepancy-fix is active — and it CLEANS billed orders,
|
|
242
|
+
it does not resurrect them.** `import_open_orders.php` is **`"active": 0` (DISABLED)** in
|
|
243
|
+
`worker/schedules/cron.worker.infrastructure.json`, but its flag `SHOULD_SYNC_OPEN_ORDERS = true` is
|
|
244
|
+
still set — a **latent re-enable footgun** (flipping `active` back on would resume the cron with its
|
|
245
|
+
known cascade/date-window quirks). The **only active legacy writer** of `OpenOrderItems` is
|
|
246
|
+
`periodic_forecast_discrepancy_fix_open_orders.php` (`active:1`, daily 3:00 AM): it INSERTs only for
|
|
247
|
+
currently-**open** SOs (status B/D/E/F) and its **Step-3b stale-cleanup DELETEs** rows whose SO is no
|
|
248
|
+
longer open — so it **removes** billed/closed orders rather than resurrecting them. It is therefore
|
|
249
|
+
**not** a resurrection risk against the webhook `removeAll` path; it is in fact the current backstop
|
|
250
|
+
deleter for orders the webhook never sees billed (the invoice-transform gap).
|
|
209
251
|
- These tools live in `test/@dave/` (developer tooling), but `trueup_open_orders` has been run
|
|
210
252
|
against production. The `Defaults`/checkpoint mechanics of the scheduled sync are separate.
|
|
211
253
|
|
|
212
254
|
## Change history
|
|
213
255
|
|
|
256
|
+
- 2026-06-25 — **`trueup_open_orders` gained `--by-lastmodified` / `--quiet` / leaner `--verbose`; added
|
|
257
|
+
`loop_trueup_open_orders.php` as the stopgap for the real-time billed-order removal gap.**
|
|
258
|
+
`--by-lastmodified` windows both Step 1 and Step 3b on `lastmodifieddate` (ET TIMESTAMP bounds) and
|
|
259
|
+
inverts Step 3b to an id-only paged SuiteQL "modified-and-no-longer-open" lookup (no per-id GET), so it
|
|
260
|
+
catches an order billed in-window whose `tranDate` predates it. `--quiet` prints only a non-zero Step 4
|
|
261
|
+
block. `loop_trueup_open_orders.php` is a continuous rolling-window runner (PHP_BINARY + passthru over a
|
|
262
|
+
fixed flag allowlist). Also recorded that **`OpenOrderItems.dtPendingBilling` is local-schema-only and no
|
|
263
|
+
prod code writes it** (corrects the earlier "fixed in both writers" note — both writers now skip it), and
|
|
264
|
+
that of the legacy open-orders crons **only `periodic_forecast_discrepancy_fix_open_orders.php` is active**
|
|
265
|
+
and it cleans up billed/closed orders rather than resurrecting them (while `import_open_orders.php` is
|
|
266
|
+
`active:0` but still flags `SHOULD_SYNC_OPEN_ORDERS=true` — a latent re-enable footgun). (dfranks)
|
|
214
267
|
- 2026-06-24 — **Sales audit correctness pass.** Fixed a phantom Sales delta: `reconcile`'s NS Sales
|
|
215
268
|
SUM/diff now applies the sync's status exclusions (Unapproved Payment / Voided / Rejected) so it can't
|
|
216
269
|
count rows the sync never stores (closed revenue delta to $0). Added a Sales per-transaction presence
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-06-
|
|
9
|
+
updated: 2026-06-25
|
|
10
10
|
owners: [dfranks]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Component/Forecast/SaleImport/SaleImport.php
|
|
@@ -154,4 +154,11 @@ would be needed only for a future real-time item webhook, **not** for this Sales
|
|
|
154
154
|
- The cron's sign handling is not portable here — see Sign convention.
|
|
155
155
|
|
|
156
156
|
## Change history
|
|
157
|
+
- 2026-06-25 — **Verified `sync()` end-to-end across all four sale types** (prod NS reads, local Forecast
|
|
158
|
+
writes; 40+ records spanning invoice/cashSale/creditMemo/cashRefund): revenue reconciled **to the penny**
|
|
159
|
+
against an independent SuiteQL `SUM(-foreignamount)` oracle on every record; the uniform sign factor held
|
|
160
|
+
(invoice/cashSale positive, creditMemo/cashRefund negative); kit handling correct (itemGroup line and the
|
|
161
|
+
no-item-id summary line dropped, the real component line kept); tax-reversal credit memos correctly net to
|
|
162
|
+
$0 line revenue; the guarded `INSERT … WHERE NOT EXISTS` proved idempotent (re-sync → 0 duplicates); and
|
|
163
|
+
item self-heal worked for old records. No code change — confirms the existing behavior. (dfranks)
|
|
157
164
|
- 2026-06-24 — Built the Forecast.Sales real-time webhook importer (shared `_Component_Forecast_SaleImport` engine + four thin worker2 handlers), replacing the legacy cron SALES section; decided the uniform-factor sign convention against the raw REST record; deduped `fetchRecord`/sublist pagination onto `_Component_Forecast_Db`. (dfranks)
|
|
@@ -17,6 +17,7 @@ files:
|
|
|
17
17
|
related:
|
|
18
18
|
- ../architecture.md
|
|
19
19
|
- vapi-integration.md
|
|
20
|
+
- ../../worker2/features/vapi-webhook-handler.md
|
|
20
21
|
---
|
|
21
22
|
|
|
22
23
|
## Summary
|
|
@@ -204,8 +205,23 @@ gracefully handles `"Information not available"`.
|
|
|
204
205
|
|
|
205
206
|
## Where the PHP code lives
|
|
206
207
|
|
|
207
|
-
**
|
|
208
|
-
|
|
208
|
+
**Now committed in `worker2`** as `_Worker_Vapi` (`worker2/Worker/Vapi.php`,
|
|
209
|
+
the webhook) alongside the outbound dialer `_Worker_Ai_Bdr_Vapi`
|
|
210
|
+
(`worker2/Worker/Ai/Bdr/Vapi.php`). Full detail:
|
|
211
|
+
**`2.0/apps/worker2/features/vapi-webhook-handler.md`**.
|
|
212
|
+
|
|
213
|
+
> **Corrections confirmed against the live `_Worker_Vapi` code (2026-06-24):**
|
|
214
|
+
> 1. **Structured output is in `artifact.structuredOutputs`** (keyed by a random
|
|
215
|
+
> UUID; first entry's `result`), **not** `analysis.structuredData` —
|
|
216
|
+
> `analysis{}` is always empty in practice.
|
|
217
|
+
> 2. **The webhook join key is `assistantOverrides.metadata.contactAttemptUuid`**
|
|
218
|
+
> (the dialer pre-creates the `ContactAttempt` and passes its UUID). `call.id`
|
|
219
|
+
> is stored only as `c_vapiCallIdentifier`, not used as the join key.
|
|
220
|
+
> 3. **Filter on `message.type`** — only `end-of-call-report` is processed;
|
|
221
|
+
> `status-update`/`hang`/etc. are acknowledged and skipped. Inbound and test
|
|
222
|
+
> calls carry no `contactAttemptUuid` and must be skipped gracefully.
|
|
223
|
+
|
|
224
|
+
Historical (pre-commit) shape guess:
|
|
209
225
|
|
|
210
226
|
- 1.0: an `App_Controller_*` for the webhook (under the framework's HTTP
|
|
211
227
|
surface), with an `App_Worker_*` or scheduled cron for cadence.
|
|
@@ -15,4 +15,5 @@
|
|
|
15
15
|
| [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 |
|
|
16
16
|
| [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 |
|
|
17
17
|
| [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 |
|
|
18
|
+
| [VAPI Webhook Handler (worker2 — AI-BDR end-of-call processing)](features/vapi-webhook-handler.md) | `_Worker_Vapi` ([worker2/Worker/Vapi.php](worker2/Worker/Vapi.php)) is the **PHP side of the AI-BDR call loop** — the webhook that receives VAPI's end-of-call r | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php |
|
|
18
19
|
| [WJE Freshservice Sync (worker2)](features/wje-freshservice-sync.md) | WJE ("WJE IT", helpdesk `wje.freshservice.com`) is a **Freshservice**-based help-desk client whose tickets, contacts, assets, groups, categories, and canned res | worker2/Worker/Wje.php, _underscore/Component/Api/Wje/Wje.php, _underscore/Model/Wje/Ticket.php, _underscore/Model/Wje/TicketNote.php, _underscore/Model/Wje/Contact.php, _underscore/Model/Wje/Unit.php, _underscore/Model/Wje/TicketTeam.php, _underscore/Model/Wje/TicketCategory.php, _underscore/Model/Wje/AssetType.php, _underscore/Model/Wje/PredefinedReply.php, library/app/api/wje.php, worker/crons/toga2/wje/import_supporting_records.php, worker/crons/toga2/wje/sync_togasupply_wje.php, worker/crons/notifications/reports/wje/wje_common.php, library/app/systemmonitor/wje.php, dbchanges2/Client_Wje/2024-10-04 - WjeOnboarding.sql |
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-06-
|
|
9
|
+
updated: 2026-06-25
|
|
10
10
|
owners: ["dfranks"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite.php
|
|
@@ -321,6 +321,25 @@ None — platform-wide Forecast sync.
|
|
|
321
321
|
HTTP call so a concurrent drainer pass skips it (`Sending` ∉ sendable), narrowing the double-send
|
|
322
322
|
window to the load→claim gap. `findSendableIds` must list every sendable status
|
|
323
323
|
(`Created`,`Pending`,`Retry`,`Sending`) — omitting `Created` silently matches nothing (`candidates:0`).
|
|
324
|
+
- **The enqueuer is generic over all record types but RELEASED only on Opportunity + Sales Order.**
|
|
325
|
+
`ue_api_msg_queue_enqueue` (`customscript_ue_amq_enqueue`) is generic via `RECORD_TYPE_MAP`, fires
|
|
326
|
+
`afterSubmit` for create/edit/xedit/delete, has **no execution-context filter**, and runs with
|
|
327
|
+
`DEV_OVERRIDE.enabled=false` in prod (posts to `webhook.togahub.com/netsuite`). Probe-confirmed it is
|
|
328
|
+
**`Released` on only two record types**: Opportunity (`customdeploy1`) and Sales Order
|
|
329
|
+
(`customdeploy2`). So Invoice/CashSale/CreditMemo/CashRefund (the Forecast.Sales types) are **not yet
|
|
330
|
+
enqueued via webhook** — which is why a billing invoice fires no webhook (see the open-orders doc's
|
|
331
|
+
invoice-transform gap). Adding a real-time sale-import webhook requires releasing this same enqueuer on
|
|
332
|
+
those four types.
|
|
333
|
+
- **`lib_amq_queue.js` is a LIBRARY module (no `@NScriptType`) → it has NO deployment of its own, and its
|
|
334
|
+
`N/log` output surfaces in the CALLING script's execution log.** Its `transmit()` wraps `https.request`
|
|
335
|
+
in a try, but the surrounding `record.load`/claim and `markFailure`'s `submitFields` are **outside** that
|
|
336
|
+
try — so a throw there escapes to `ss_amq_drain`'s per-row catch. A live drainer error
|
|
337
|
+
`{"queueId":40529,"error":"UNEXPECTED_ERROR: An unexpected SuiteScript error has occurred"}` (a TypeError,
|
|
338
|
+
observed 2026-06-25 1:10am) originates in this transmit path and shows up under **`ss_amq_drain`'s**
|
|
339
|
+
execution log (not a library log of its own). **Durable error detail is also persisted on the API Message
|
|
340
|
+
Queue custom record** fields `custrecordcustrecord_amq_errormsg` / `_resp_status` / `_resp_body`
|
|
341
|
+
(doubled-prefix ids — see the doubled-id gotcha), which are **SuiteQL-queryable** for after-the-fact
|
|
342
|
+
diagnosis even though the calling-script log rolls off.
|
|
324
343
|
|
|
325
344
|
## CU→NS direction (future — not yet built): reverse mapping
|
|
326
345
|
|
|
@@ -345,6 +364,15 @@ same **skip-if-unchanged** compare on the extracted values, and **actor-identity
|
|
|
345
364
|
trigger a CU→NS write. The NS→CU change-detection above is the complementary backstop, not a substitute.
|
|
346
365
|
|
|
347
366
|
## Change history
|
|
367
|
+
- 2026-06-25 — **Characterized the AMQ enqueuer scope + a live drainer TypeError.** The enqueuer is
|
|
368
|
+
generic over all record types via `RECORD_TYPE_MAP` (no context filter, prod `DEV_OVERRIDE.enabled=false`)
|
|
369
|
+
but probe-confirmed `Released` on **only Opportunity (`customdeploy1`) + Sales Order (`customdeploy2`)** —
|
|
370
|
+
so the four Forecast.Sales record types are not yet webhook-enqueued. Recorded that `lib_amq_queue.js` is a
|
|
371
|
+
**library module** (no `@NScriptType`/own deployment) whose `N/log` surfaces in the **calling** script's
|
|
372
|
+
(`ss_amq_drain`) log, and that an `UNEXPECTED_ERROR` (TypeError, queueId 40529, 2026-06-25 1:10am)
|
|
373
|
+
originates in its `transmit()` path where `record.load`/claim + `markFailure.submitFields` sit **outside**
|
|
374
|
+
the `https.request` try. Durable error detail also persists on the queue record's
|
|
375
|
+
`custrecordcustrecord_amq_errormsg`/`_resp_status`/`_resp_body` fields (SuiteQL-queryable). (dfranks)
|
|
348
376
|
- 2026-06-24 — **Default presales-lead assignee fallback.** `buildCustomFields()` now wires the
|
|
349
377
|
previously-unused `DEFAULT_PRESALES_LEAD_USER_ID = '87374309'` (Cory Martin, comartin@togatech.com —
|
|
350
378
|
verified active via ClickUp `GET /team`) into the else-branch when `resolvePresalesUserId()` returns
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-06-
|
|
9
|
+
updated: 2026-06-25
|
|
10
10
|
owners: ["dfranks"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite/SalesOrder.php
|
|
@@ -133,6 +133,7 @@ None — uniform (platform-wide Forecast2 sync).
|
|
|
133
133
|
|
|
134
134
|
## Gotchas / known issues
|
|
135
135
|
|
|
136
|
+
- **Billing an SO via invoice-transform flips it to "Billed" WITHOUT firing the SalesOrder afterSubmit UE → no edit/PUT webhook → `removeAll()` never runs → the OpenOrderItems rows linger (this is the real cause of the "billed order not removed" problem — NOT a removeAll-didn't-persist bug).** When a NetSuite Sales Order is billed by **creating an Invoice from it**, the SO's `status` transitions to `Billed` as a **side-effect of the invoice transform** — this does **not** trigger the SalesOrder `afterSubmit` User Event, so the AMQ enqueuer (`ue_api_msg_queue_enqueue`, deployed per-record-type) **never emits an edit/PUT webhook for the SO**. Consequently `_Worker_Netsuite_SalesOrder` is never invoked for the billing change, `importOpenOrder`'s status gate (which *would* call `removeAll` on a non-open status) never runs, and the order's `OpenOrderItems` rows survive until the nightly discrepancy-fix cron deletes them. **Confirmed live 2026-06-24/25 on SO 7190415** (tranId 280387): only a `create` webhook delivery exists in `Logs.Webhook` (2026-06-24 23:48, 16 rows inserted) — **NO** edit/delete delivery at billing time (~09:41 UTC 2026-06-25); `Core.WorkerJobs` shows only `Netsuite/Webhook` + `Netsuite/SalesOrder/post` (no put/delete); `Logs.Api` has only the original NS GET + a 200 `OPEN_ORDER_IMPORT` breadcrumb — **no 204 removeAll breadcrumb**. The invoice (7190621) that billed it **also produced no webhook** — its 16 `Forecast.Sales` rows came from the **legacy 5-min pull cron**, not the webhook. **This corrects the earlier suspicion that "removeAll fired but DB didn't persist"** — both `removeAll` paths *do* commit `DB_FORECAST`; `removeAll` was simply **never called**. **Diagnostic ladder for a lingering billed order:** `Logs.Webhook` (was an edit/delete even delivered? — here, no) → `Core.WorkerJobs` (was a put/delete processed? — no) → `Logs.Api` `source='OPEN_ORDER_IMPORT'` (any 204 removeAll breadcrumb? — no). **Fix direction (not yet implemented):** the SalesOrder-side enqueuer must also fire on the invoice-transform status change — either deploy the AMQ enqueuer on **Invoice** so the bill event drives an SO re-sync, or have the SalesOrder handler re-evaluate the SO when its child invoice arrives; until then the **stopgap is the continuous `loop_trueup_open_orders.php --by-lastmodified` runner** (see the reconciliation doc), which windows on `lastmodifieddate` so it catches an order billed today whose `tranDate` predates the window. The `removeAll` path in `SalesOrder.php` was re-confirmed correct in this session.
|
|
136
137
|
- **Wrong FK column name → MySQL 1054 that masquerades as "order missing".** `OpenOrderItems`'s
|
|
137
138
|
sales-order key is **`netsuiteSalesOrderInternalId`**. Do **NOT** query it with
|
|
138
139
|
`netsuiteTransactionInternalId` — that is the **`Forecast.Sales`** column. On `OpenOrderItems` it
|
|
@@ -296,6 +297,16 @@ test fixture (it surfaced the stale SO 7181316 above).
|
|
|
296
297
|
|
|
297
298
|
## Change history
|
|
298
299
|
|
|
300
|
+
- 2026-06-25 — **Root-caused "billed SO not removed from OpenOrderItems in real time" to a missing
|
|
301
|
+
webhook, NOT a removeAll-didn't-persist bug.** Billing an SO via invoice-transform flips it to
|
|
302
|
+
`Billed` as a side-effect that does **not** fire the SalesOrder `afterSubmit` UE → the AMQ enqueuer
|
|
303
|
+
emits no edit/PUT webhook → `_Worker_Netsuite_SalesOrder` never runs → `removeAll()` is never called
|
|
304
|
+
(both removeAll paths confirmed to commit `DB_FORECAST` correctly — the call simply never happens).
|
|
305
|
+
Confirmed live on SO 7190415: only a `create` delivery in `Logs.Webhook`, no put/delete in
|
|
306
|
+
`Core.WorkerJobs`, no 204 removeAll breadcrumb in `Logs.Api`; the billing invoice produced no webhook
|
|
307
|
+
either (its Sales rows came from the legacy pull cron). Recorded the `Logs.Webhook`→`WorkerJobs`→
|
|
308
|
+
`OPEN_ORDER_IMPORT`-breadcrumb diagnostic ladder and the stopgap (`loop_trueup_open_orders.php
|
|
309
|
+
--by-lastmodified`). (dfranks)
|
|
299
310
|
- 2026-06-24 — **Outbound push (`CreateNetSuite`/`UpdateNetSuite`/`Sync`) converted SOAP →
|
|
300
311
|
REST-only** via `_Component_Api_Netsuite`: item + customer lookups now SuiteQL
|
|
301
312
|
(`resolveNetSuiteCustomerId()` throws on 0/>1 name matches), `buildNetSuiteOrder()` emits a REST
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: VAPI Webhook Handler (worker2 — AI-BDR end-of-call processing)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-24
|
|
10
|
+
owners: [snaredla]
|
|
11
|
+
files:
|
|
12
|
+
- worker2/Worker/Vapi.php
|
|
13
|
+
- worker2/Worker/Ai/Bdr/Vapi.php
|
|
14
|
+
related:
|
|
15
|
+
- ../../ai-bdr/features/call-orchestration.md
|
|
16
|
+
- ../../ai-bdr/features/vapi-integration.md
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Summary
|
|
20
|
+
|
|
21
|
+
`_Worker_Vapi` ([worker2/Worker/Vapi.php](worker2/Worker/Vapi.php)) is the **PHP side of the
|
|
22
|
+
AI-BDR call loop** — the webhook that receives VAPI's end-of-call report, writes the call
|
|
23
|
+
results back onto the originating `ContactAttempt`, and dispatches the post-call action
|
|
24
|
+
(book meeting / schedule callback / nurture / DNC). This is the concrete handler that the
|
|
25
|
+
`ai-bdr` repo's `call-orchestration.md` referred to as "lives in worker/worker2 — add a
|
|
26
|
+
doc when committed."
|
|
27
|
+
|
|
28
|
+
It is the **counterpart** to the outbound dialer `_Worker_Ai_Bdr_Vapi`
|
|
29
|
+
([worker2/Worker/Ai/Bdr/Vapi.php](worker2/Worker/Ai/Bdr/Vapi.php)), which places the calls.
|
|
30
|
+
|
|
31
|
+
## Key files / entry points
|
|
32
|
+
|
|
33
|
+
| File | Role |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `worker2/Worker/Vapi.php` (`_Worker_Vapi`) | Inbound **webhook** — processes `end-of-call-report`, writes back, dispatches actions |
|
|
36
|
+
| `worker2/Worker/Ai/Bdr/Vapi.php` (`_Worker_Ai_Bdr_Vapi`) | Outbound **dialer** — creates the `ContactAttempt`, places the VAPI call |
|
|
37
|
+
|
|
38
|
+
- Action route: `Vapi/Webhook` → `webhook.togahub.com/vapi` (VAPI server messages POST here).
|
|
39
|
+
- `initialize()` registers the `Client_True` DB connection (`_underscore::DB_CLIENT`).
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
1. **`Webhook($payload, $headers)`** — JSON-decodes the body, takes `message`, hands off to
|
|
44
|
+
`processVapiEndOfCallPayload()`.
|
|
45
|
+
2. **`processVapiEndOfCallPayload()`**:
|
|
46
|
+
- Reads `call.assistantOverrides.metadata.contactAttemptUuid` (falls back to
|
|
47
|
+
`call.metadata`).
|
|
48
|
+
- Loads `_Model_True_ContactAttempt` by that UUID. This is the **join key** back to our DB.
|
|
49
|
+
- Extracts structured output via `extractVapiStructuredOutput()`.
|
|
50
|
+
- `updateContactAttempt()` writes the results, then `executePostCallActions()` dispatches.
|
|
51
|
+
3. **`extractVapiStructuredOutput($artifact)`** — returns the first
|
|
52
|
+
`artifact.structuredOutputs[*].result` array. **`analysis{}` is always empty — the data
|
|
53
|
+
lives in `artifact.structuredOutputs`, keyed by a random UUID.**
|
|
54
|
+
4. **`updateContactAttempt()`** — maps `structured.phpWorkerAction` → `c_callOutcome` /
|
|
55
|
+
`c_actionToTake` via `$actionMap`; builds a payload of only non-empty `c_*` fields (so
|
|
56
|
+
blanks never overwrite existing data); converts VAPI ISO timestamps to **Central Time**;
|
|
57
|
+
persists via `_Component_Api_Toga::send('PUT', '/contact-attempts/{uuid}', …)` (Toga 2.0
|
|
58
|
+
REST API, **not** a direct DB write); also clears the contact's `dtNextContactRequested`.
|
|
59
|
+
5. **`executePostCallActions()`** — `switch ($contactAttempt->c_actionToTake)`:
|
|
60
|
+
- `BOOK_CALCOM_MEETING` → `actionCalComBookMeeting` (Cal.com booking + queue
|
|
61
|
+
`Ai/Bdr/Netsuite/createLeadFromBookedMeeting` + mark COMPLETED)
|
|
62
|
+
- `SCHEDULE_CALLBACK` → `actionScheduleCallback` (sets `dtNextContactRequested` +
|
|
63
|
+
`contactCallTypeId=CALLBACK`)
|
|
64
|
+
- `SEND_NURTURE_EMAIL` → `actionSendNurtureEmail` (queue
|
|
65
|
+
`Ai/Bdr/Netsuite/createLeadFromNurtureEmail` + mark COMPLETED)
|
|
66
|
+
- `DO_NOT_CALL` → `actionFlagDnc` (`isOkayToCall=0` + EXCLUDE from PENDING/ACTIVE campaigns)
|
|
67
|
+
- `NO_ACTION` / default → reset the campaign-contact to PENDING
|
|
68
|
+
|
|
69
|
+
### phpWorkerAction → outcome/action map
|
|
70
|
+
|
|
71
|
+
| `phpWorkerAction` | `c_callOutcome` | `c_actionToTake` |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `BOOK_CALCOM_MEETING` | MEETING_BOOKED | BOOK_CALCOM_MEETING |
|
|
74
|
+
| `SCHEDULE_CALLBACK` | CALLBACK_SCHEDULED | SCHEDULE_CALLBACK |
|
|
75
|
+
| `SEND_NURTURE_EMAIL` | NURTURE_REQUESTED | SEND_NURTURE_EMAIL |
|
|
76
|
+
| `FLAG_DNC_REMOVE_FROM_CAMPAIGNS` | DNC_REQUESTED | DO_NOT_CALL |
|
|
77
|
+
| `MARK_NOT_INTERESTED` | NOT_INTERESTED | DO_NOT_CALL |
|
|
78
|
+
| `MARK_WRONG_PERSON` | WRONG_PERSON | DO_NOT_CALL |
|
|
79
|
+
| `MARK_VOICEMAIL_RETRY` | VOICEMAIL | NO_ACTION |
|
|
80
|
+
| `MARK_NO_ANSWER_RETRY` | NO_ANSWER | NO_ACTION |
|
|
81
|
+
| `MARK_GATEKEEPER_BLOCKED_RETRY` | GATEKEEPER_BLOCKED | NO_ACTION |
|
|
82
|
+
|
|
83
|
+
## The contactAttemptUuid write-back mechanism
|
|
84
|
+
|
|
85
|
+
`contactAttemptUuid` is the **sole linkage** between a VAPI call and our DB:
|
|
86
|
+
|
|
87
|
+
1. The dialer (`_Worker_Ai_Bdr_Vapi`) creates the `ContactAttempt` row **before** placing the
|
|
88
|
+
call ([Ai/Bdr/Vapi.php:438](worker2/Worker/Ai/Bdr/Vapi.php#L438),
|
|
89
|
+
[:694](worker2/Worker/Ai/Bdr/Vapi.php#L694)) and passes its UUID in
|
|
90
|
+
`assistantOverrides.metadata.contactAttemptUuid`
|
|
91
|
+
([Ai/Bdr/Vapi.php:628](worker2/Worker/Ai/Bdr/Vapi.php#L628)).
|
|
92
|
+
2. VAPI echoes that same metadata onto **every** server event for the call.
|
|
93
|
+
3. The webhook loads the row by that UUID and writes results back to it.
|
|
94
|
+
|
|
95
|
+
`contactId` and `campaignId` are read from the **loaded `ContactAttempt` row**, never from the
|
|
96
|
+
webhook payload. `call.id` is stored only as `c_vapiCallIdentifier`.
|
|
97
|
+
|
|
98
|
+
## Message-type filtering (the bug this doc was born from)
|
|
99
|
+
|
|
100
|
+
VAPI sends the webhook **many** server-message types: `status-update`, `hang`,
|
|
101
|
+
`speech-update`, `end-of-call-report`, etc. **Only `end-of-call-report` carries the call data
|
|
102
|
+
(`artifact` / structured output) and should be processed.** The handler must guard on
|
|
103
|
+
`message.type === 'end-of-call-report'` in `Webhook()` and acknowledge (`return 'ok'`) all
|
|
104
|
+
other types before they reach `processVapiEndOfCallPayload()`.
|
|
105
|
+
|
|
106
|
+
- **Regression origin:** commit `583f6be` "Added remaining webhooks" (2026-05-14) created this
|
|
107
|
+
file and broadened the VAPI subscription to all server-message types. Without a type guard,
|
|
108
|
+
every event was processed as an end-of-call report. First Sentry errors appeared ~late May.
|
|
109
|
+
- **Symptom:** `Warning: Undefined property: stdClass::$contactAttemptUuid` (promoted to an
|
|
110
|
+
`ErrorException` by the framework error handler, captured by Sentry).
|
|
111
|
+
|
|
112
|
+
## Inbound / test calls vs worker-placed outbound calls
|
|
113
|
+
|
|
114
|
+
The metadata shape depends on **who placed the call**, not the event type:
|
|
115
|
+
|
|
116
|
+
- **Outbound (our dialer)** → metadata carries `contactAttemptUuid` (every event type,
|
|
117
|
+
including status-updates).
|
|
118
|
+
- **Inbound calls and dashboard test calls** → **no `contactAttemptUuid`** (they never went
|
|
119
|
+
through our dialer, so no `ContactAttempt` was pre-created). Test calls may instead carry
|
|
120
|
+
hand-typed metadata like `{contactId, campaignId, attemptNumber}`.
|
|
121
|
+
|
|
122
|
+
Therefore an `end-of-call-report` can legitimately arrive **without** a `contactAttemptUuid`
|
|
123
|
+
(inbound/test). Those have no attempt to write back to and should be **skipped gracefully**
|
|
124
|
+
(`return 'ok'`), not thrown — otherwise they raise `Contact Attempt not found` and fail the
|
|
125
|
+
job. `Contact Attempt not found` should be reserved for a report that *has* a
|
|
126
|
+
`contactAttemptUuid` but no matching row (a real data-integrity anomaly).
|
|
127
|
+
|
|
128
|
+
## Data model
|
|
129
|
+
|
|
130
|
+
Writes to `ContactAttempts` (via Toga 2.0 API) — `dtStarted`, `dtEnded`, `transcript`,
|
|
131
|
+
`contactCallTypeId`, and many `c_*` fields (`c_callOutcome`, `c_actionToTake`,
|
|
132
|
+
`c_prospectSentiment`, `c_qualificationScore`, `c_callSummary`, `c_meetingTime`,
|
|
133
|
+
`c_meetingType`, `c_contactEmail`, `c_callbackTime`, `c_recordingUrl`, `c_callCost`,
|
|
134
|
+
`c_vapiCallIdentifier`, `c_dncRequested`, etc.). Also updates `Contacts`
|
|
135
|
+
(`dtNextContactRequested`, `contactCallTypeId`, `isOkayToCall`) and `Campaigns_Contacts`
|
|
136
|
+
status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field list.
|
|
137
|
+
|
|
138
|
+
## Gotchas / known issues
|
|
139
|
+
|
|
140
|
+
- **Structured data is in `artifact.structuredOutputs`, not `analysis.structuredData`.**
|
|
141
|
+
`analysis{}` is always empty. (Corrects the older `ai-bdr/call-orchestration.md`.)
|
|
142
|
+
- **Join key is `contactAttemptUuid`, not `call.id`.** `call.id` is only stored as
|
|
143
|
+
`c_vapiCallIdentifier`.
|
|
144
|
+
- **No message-type guard = errors.** Always filter to `end-of-call-report` first.
|
|
145
|
+
- **Persistence is via the Toga REST API**, not a direct model `save()` — failures are
|
|
146
|
+
caught and logged, not re-thrown.
|
|
147
|
+
- **Timestamps are stored in Central Time**; Cal.com booking converts `c_meetingTime` back to
|
|
148
|
+
UTC.
|
|
149
|
+
|
|
150
|
+
## Related docs
|
|
151
|
+
- [AI-BDR Call Orchestration (the PHP↔VAPI contract)](../../ai-bdr/features/call-orchestration.md)
|
|
152
|
+
- [VAPI Integration — assistants, tools, structured output](../../ai-bdr/features/vapi-integration.md)
|
|
153
|
+
|
|
154
|
+
## Change history
|
|
155
|
+
- 2026-06-24 — Initial doc: webhook handler, contactAttemptUuid write-back, message-type
|
|
156
|
+
filtering bug, inbound/test vs outbound metadata. (snaredla)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -16,7 +16,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
16
16
|
## 2.0 framework
|
|
17
17
|
|
|
18
18
|
- **_underscore** (_Underscore) _(framework core)_ — 14 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
19
|
-
- **worker2** (Worker) —
|
|
19
|
+
- **worker2** (Worker) — 15 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)
|
|
22
22
|
- **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