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.
@@ -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-24
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]` — same for
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-24
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
- **Not in this repo.** The handler lives in the TOGA worker repo (`worker`
208
- for 1.0 or `worker2` for 2.0). Likely class shape:
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-18
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-23
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)
@@ -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) — 14 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.197",
3
+ "version": "1.0.199",
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",