toga-ai 1.0.279 → 1.0.281

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.
@@ -262,6 +262,21 @@ work; the pk is `nkey`). Columns: `addr1`, `addr2`, `addressee`, `attention`, `c
262
262
  - `entityid` and `companyname` are split in SuiteQL; SOAP concatenated them as
263
263
  `"<entityid> <companyname>"`. When they're equal, return the value once (don't duplicate).
264
264
 
265
+ ## `systemnote` table — field-change forensics
266
+
267
+ The audit trail of individual field changes — use it to see exactly **who / what / when / how** a
268
+ field changed, **including sublist LINE edits that fire no User Event and therefore no webhook**
269
+ (see the sale-import doc's AMQ line-field blind spot).
270
+
271
+ - **Performance: filter by `field` + `"date"`, NEVER by `recordid` alone.** A `recordid`-only
272
+ filter is a pathological full scan (**~279s**); adding a `field` predicate + a `"date"` range
273
+ drops it to **~1s**.
274
+ - Columns: `context` (`UIF` = user interface; also `CSV`, `WS`, …), `name` (the acting user),
275
+ `role`, `recordid`, `field`, `linetransactionid`, `lineid`, `oldvalue`, `newvalue`, and `"date"`
276
+ (a reserved word — quote it). A sublist line-field inline edit (e.g. `MCOSTESTIMATE`) shows up
277
+ here as a `context='UIF'` row with the acting `name` even though it bumped **no**
278
+ `lastmodifieddate` and fired no webhook.
279
+
265
280
  ## Custom fields
266
281
 
267
282
  | NS internalId | Script id | Description |
@@ -339,9 +354,24 @@ numbers. Budget hours for multi-year runs and launch them under `nohup`/`tmux`.
339
354
  Measured against the Forecast2 reconciliation checker (TRUE-79968). Use `> 0` / `NVL(col,0) <> 0`
340
355
  for a custom ref column you only need to test for presence in an aggregate; reserve `IS NOT NULL`
341
356
  for non-aggregate row filters.
357
+ - **⚠ `> 0` is NOT a universal fix — it is column-dependent.** A predicate on a *different*
358
+ custom column, `tl.custcol_item_sku > 0`, **never returned even as a STANDALONE aggregate**
359
+ (not just inside the UNION+MOD fingerprint), while `tl.custcol_sales_rep_line > 0` in the same
360
+ query stayed fast (~8s). Merely **SELECTing** the custom column is cheap either way. **Durable
361
+ rule when a custom-column predicate hangs:** keep the fast predicates in SQL, **SELECT** the
362
+ problem column, and apply that gate in **PHP** — do not assume `> 0` will rescue every custom
363
+ column. (TRUE-79973, `test/@dave/checker.php` / `fixer.php`.)
342
364
 
343
365
  ## Change history
344
366
 
367
+ - 2026-07-07 — **Added the `systemnote` field-change forensics section and qualified the custom-column
368
+ perf rule** (Forecast2 SALES profit-drift forensics). `systemnote` filtered by `field`+`"date"` is
369
+ ~1s vs ~279s by `recordid` alone; it carries `context` (`UIF`), `name` (acting user), `role`,
370
+ `linetransactionid`, `lineid`, `oldvalue`/`newvalue` — and is the only NetSuite-side record of a
371
+ sublist line-field inline edit (which bumps no `lastmodifieddate` and fires no webhook). Also
372
+ qualified the `IS NOT NULL`→`> 0` note: `> 0` is **column-dependent, not universal** —
373
+ `custcol_item_sku > 0` hung even standalone while `custcol_sales_rep_line > 0` stayed fast; when a
374
+ custom-column predicate hangs, SELECT the column and gate in PHP (TRUE-79973). (dfranks)
345
375
  - 2026-06-30 — **Recorded the `IS NOT NULL` vs `> 0` planner trap for custom `transactionline`
346
376
  columns in aggregate queries** (TRUE-79968): a custom employee-ref column filtered with
347
377
  `IS NOT NULL` inside a power-sum `GROUP BY` aggregate scanned ~15× slower (~122s) than the
@@ -4,7 +4,7 @@
4
4
  |-----|---------|-------|
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
- | [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 |
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/repairorder.php, library/app/model/togadesk/repairordertracking.php, library/app/api/carrier/ups.php |
8
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 |
9
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 |
10
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 |
@@ -6,7 +6,7 @@ project: TOGa Desk
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-15
9
+ updated: 2026-07-07
10
10
  owners: ["jcardinal", "mhammontree"]
11
11
  files:
12
12
  - desk/includes/controllers/actions/central/
@@ -16,6 +16,7 @@ files:
16
16
  - desk/template/pages/central.php
17
17
  - desk/template/pages/central/view.php
18
18
  - desk/template/modals/central/addTracking.php
19
+ - library/app/model/togadesk/repairorder.php
19
20
  - library/app/model/togadesk/repairordertracking.php
20
21
  - library/app/api/carrier/ups.php
21
22
  related:
@@ -90,6 +91,26 @@ always confirm the client arm before changing shared central logic.
90
91
  `controllers/actions/central/*.php` (the default fallback). Check both when tracing an action.
91
92
  - Status recomputation is centralized in `updateStatus()` — write paths that bypass it can leave
92
93
  an order in an inconsistent state.
94
+ - **`updateStatus()` / `qqStatus()` silently overwrite status via a raw `UPDATE`** (no
95
+ `repair_order_history` / `repair_order_notes` row). `qqStatus()`
96
+ (`library/app/model/togadesk/repairorder.php` ~L356) is a computed SQL `CASE` that
97
+ recomputes `repair_orders.status` from technician / dispatch / ETA / parts data on every
98
+ `updateStatus()` call (fired by `scheduleDispatch`, `technicianOnsite`, `technicianEnRoute`,
99
+ `awaitingDispatch`, `reworkAwaitingScheduling`, and part flows). If a status is not
100
+ explicitly gated inside `qqStatus()`, it will be recomputed away with no audit trail — this
101
+ is **shared, client-agnostic** logic (no tenant conditional), so a change hits every
102
+ repair-order client.
103
+ - **Hold states must be gated in `qqStatus()` or they are dropped.** There are six `HOLD_*`
104
+ constants; `qqStatus()` has hold gates that collapse recognized holds to base `STATUS_HOLD`.
105
+ TRUE-80060 fixed both `ONSITE_REPAIR`/`ONSITE_SERVICE`-branch gates (~L389, ~L429) recognizing
106
+ only 2 of the 6. **KNOWN REMAINING GAP:** the `DEPOT_REPAIR` branch (~L799) has **no hold gate
107
+ at all**, so a depot order set to any hold is still silently recomputed out of hold — a
108
+ follow-up ticket is needed. (DOE surfacing + full detail:
109
+ [NYCDOE Hold-Status Sync](../../../../clients/nycdoe/features/hold-status-sync.md).)
110
+ - **The hold sub-reason is lost from `repair_orders.status` after a recompute.** `qqStatus()`
111
+ collapses all six hold variants to base `HOLD`; the specific sub-reason persists only in
112
+ `repair_order_notes.newstatus`. Badges/reports/filters reading `repair_orders.status`
113
+ directly cannot distinguish the variants.
93
114
  - Cross-database context: some central data controllers switch the DB link to a client database
94
115
  (`App_Model_Client::changeDbLinkToClientDatabase()`).
95
116
  - **UPS carrier-account dropdown is NOT honored by the shipment API call.** In
@@ -105,6 +126,11 @@ always confirm the client arm before changing shared central logic.
105
126
  account-location map noted under *UPS shipment / carrier-account billing*.
106
127
 
107
128
  ## Change history
129
+ - 2026-07-07 — TRUE-80060: documented the shared `qqStatus()`/`updateStatus()` status-recompute
130
+ hold gates (in `library/app/model/togadesk/repairorder.php`) — silent raw-`UPDATE` overwrite,
131
+ hold states must be gated or dropped, the six-hold-variant collapse to base `STATUS_HOLD`, and
132
+ the untouched `DEPOT_REPAIR`-branch gap (follow-up needed). Added `repairorder.php` to files.
133
+ (mhammontree)
108
134
  - 2026-06-15 — documented UPS Prepare Shipment flow: CARRIER_ACCOUNTS dropdown vs hardcoded
109
135
  SHIPPER_NUMBER, and the gotcha that the dropdown selection is never forwarded to the UPS API
110
136
  (always bills Agilant). Agilant account migrated 135FV5 → 8696XA. (mhammontree)
@@ -493,11 +493,42 @@ None — Forecast2 is a single shared dataset.
493
493
  `checker.php` (JE arm) and `fixer.php` (both JE queries) with inline comments warning against
494
494
  reverting. See the SuiteQL API reference for the general rule. (Use `> 0`, or `NVL(col,0) <> 0`,
495
495
  never `IS NOT NULL`, for a custom employee-ref `transactionline` column in a SuiteQL aggregate.)
496
+ **⚠ But `> 0` is column-dependent, not a universal fix:** `tl.custcol_item_sku > 0` hung even as a
497
+ standalone aggregate while `tl.custcol_sales_rep_line > 0` stayed fast (~8s) — when a custom-column
498
+ predicate hangs, SELECT the column and gate in PHP instead. (TRUE-79973; see the SuiteQL API ref.)
499
+ - **These reconcilers are the ONLY backstop for NetSuite sublist line-field inline edits — the
500
+ real-time webhook path is structurally blind to them.** Editing a line-level field inline in the
501
+ NetSuite UI (e.g. estimated cost `TRANLINE.MCOSTESTIMATE`) fires **no** User Event `afterSubmit`
502
+ (not create/edit/`xedit`) and **does not bump `lastmodifieddate`** — it writes only a `systemnote`
503
+ (context `UIF`). So the AMQ enqueuer never fires (no webhook) **and** any `lastmodifieddate`-windowed
504
+ `list*()`/cron sync misses it too — `Forecast.Sales` **cost/profit drifts while revenue reconciles**
505
+ (the $0-revenue cost-line case). This is the concrete mechanism behind the Summary's "older rows
506
+ whose NetSuite values change (e.g. cost re-valuation) are never re-pulled." `checker`/`fixer` catch
507
+ it because they reconcile a chosen `tranDate` range directly against NetSuite by internal id, not by
508
+ `lastmodifieddate`. Real case: invoice 6715127 (2026-02-20) drifted $3,392.70; recurring/multi-user
509
+ (~12 line-cost edits/day). See the sale-import doc's AMQ blind-spot section. To pinpoint *who/what/
510
+ when* changed a line, poll SuiteQL `systemnote` filtered by `field`+`"date"` (see the SuiteQL API ref).
511
+ - **Inbound webhook forensics — `db_logs` `Logs.Webhook`.** Every inbound webhook TOGA receives is
512
+ logged here (columns: `dtStamp`, `sourceIp`, `method`, `route`, `requestHeaders`, `requestPayload`);
513
+ NetSuite events are `route='/netsuite'` with payload `{recordType,internalId,eventType,action,timestamp}`.
514
+ Logging began **~2026-06-18**, so its absence before then is not evidence a webhook didn't fire. Use it
515
+ to confirm whether a given transaction's edit actually produced an inbound POST (paired with the AMQ
516
+ enqueuer execution log on the NetSuite side).
496
517
  - These tools live in `test/@dave/` (developer tooling), but `trueup_open_orders` has been run
497
518
  against production. The `Defaults`/checkpoint mechanics of the scheduled sync are separate.
498
519
 
499
520
  ## Change history
500
521
 
522
+ - 2026-07-07 — **Recorded that these reconcilers are the only backstop for NetSuite sublist
523
+ line-field inline edits + added `Logs.Webhook` forensics** (SALES profit-drift forensics; invoice
524
+ 6715127, 2026-02-20, $3,392.70). A line-level inline edit (e.g. `TRANLINE.MCOSTESTIMATE`) fires no
525
+ User Event `afterSubmit` (not create/edit/`xedit`) and does **not** bump `lastmodifieddate` — only
526
+ a `systemnote` (context `UIF`) — so both the AMQ webhook path and any `lastmodifieddate`-windowed
527
+ sync miss it; profit drifts while revenue reconciles. `checker`/`fixer` catch it (they reconcile by
528
+ `tranDate` range + internal id, not `lastmodifieddate`). Documented `db_logs.Logs.Webhook` for
529
+ inbound-webhook forensics (route `/netsuite`, payload shape; logging began ~2026-06-18) and qualified
530
+ the custom-column perf rule (`> 0` is column-dependent — `custcol_item_sku > 0` hung even standalone;
531
+ SELECT + gate in PHP). (dfranks)
501
532
  - 2026-06-30 — **Made the fixer the cleanup mechanism for no-rep JE rows + killed a ~15× SuiteQL
502
533
  planner trap (TRUE-79968, dfranks).** The importer now keeps only rep-bearing JE lines (see the
503
534
  sale-import doc); to let `fixer.php` purge any pre-existing no-rep `Forecast.Sales` JE rows with no
@@ -9,7 +9,7 @@
9
9
  | [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 |
10
10
  | [Error Reporting — Issue/Event Aggregation (exceptionHandler)](features/error-reporting-issue-event.md) | `_underscore`'s global exception handler persists every uncaught exception into a two-table **Issue / Event** model in the **shared Core Logs DB** (`_underscore | _underscore/Error.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, dbchanges2/Logs/2026-07-06 - Issue and Event tables.sql |
11
11
  | [Record-Changed Event Publishing (_Event::publish to SQS)](features/event-publish-sqs.md) | `_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event pipeline. | _underscore/Event.php |
12
- | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | _underscore/Component/Forecast/SaleImport/SaleImport.php, _underscore/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
12
+ | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | _underscore/Component/Forecast/SaleImport/SaleImport.php, _underscore/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
13
13
  | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
14
14
  | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php |
15
15
  | [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 |
@@ -30,8 +30,8 @@ files:
30
30
  - test/@dave/probe_je_accounts.php
31
31
  - test/@dave/probe_je_shape.php
32
32
  - test/@dave/fixer.php
33
- - test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js
34
- - test/@dave/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js
33
+ - test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js
34
+ - test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js
35
35
  related:
36
36
  - ../architecture.md
37
37
  - ../../worker2/architecture.md
@@ -295,6 +295,39 @@ events as specific **subtypes** (`inventoryItem`, `nonInventoryResaleItem`, `kit
295
295
  would be needed only for a future real-time item webhook, **not** for this Sales importer
296
296
  (which keeps items fresh via the hourly item pull cron plus the inline self-heal).
297
297
 
298
+ ### AMQ enqueuer trigger behavior + the line-field inline-edit blind spot (durable — read for any real-time NS→TOGA sync debugging)
299
+ The enqueuer is `customscript_ue_amq_enqueue` (**script id 2618**), a UserEvent `afterSubmit`
300
+ deployed **one-per-record-type** (invoice/cashSale/creditMemo/cashRefund/journalEntry/salesOrder/
301
+ opportunity/items/…). The **invoice** deployment (primarykey **16019**) is **RELEASED**,
302
+ `allroles=T`, `allemployees=T`, with **every execution context enabled** — so it is NOT
303
+ restricted by user/role/context.
304
+
305
+ **What DOES fire it:** record `create` / `edit` / `delete`, **and `xedit`** — a BODY-field inline
306
+ edit (this is the source of the `eventType:"xedit"` webhooks, built by the script's own
307
+ `buildPayload`). On fire it logs `AMQ enquerer triggered by "<context.type>" event` then
308
+ `AMQ enqueued {recordType,internalId,action}`, enqueues to custom record
309
+ `customrecordcustomrecord_api_msg_queue` (**internal id 1185**), and kicks the drainer
310
+ `customscript_ss_amq_drain`.
311
+
312
+ **What does NOT fire it — the structural blind spot:** editing a **sublist LINE field**
313
+ (e.g. estimated cost `TRANLINE.MCOSTESTIMATE`) inline via the NetSuite UI raises **NO** record
314
+ User Event `afterSubmit` — **not `create`, not `edit`, not `xedit`**. It also does **NOT** bump
315
+ the transaction's `lastmodifieddate`; it writes only a **`systemnote`** (context `UIF`). So the
316
+ enqueuer never fires → no AMQ row → no `webhook.togahub.com/netsuite` POST → `Forecast.Sales`
317
+ cost/profit **silently drifts**. When the edited line is $0-revenue (a fee/cost line), **revenue
318
+ reconciles while profit drifts** — the classic symptom. Proven with live enqueuer execution-log
319
+ evidence: two invoices whose ONLY change that day was a cost line-edit had **zero** "triggered by"
320
+ entries, while 8 other cost-edited invoices that also had normal create/edit activity **did** fire
321
+ and synced. This is recurring/multi-user (~12/day; 35 `MCOSTESTIMATE` edits in 3 days by 9 users).
322
+
323
+ **Corollary gotcha: `lastmodifieddate` is NOT a reliable change signal for line-cost edits** — a
324
+ line-field inline edit leaves it untouched, so **both** the webhook pipeline **and** any
325
+ `lastmodifieddate`-windowed `list*()`/cron sync miss it entirely. **Only reconciliation**
326
+ (`checker`/`fixer` — see the reconciliation doc) **or a `systemnote` poll** catches this drift.
327
+ Forensic tool for who/what/when/how a line changed: SuiteQL `systemnote` filtered by `field` +
328
+ `"date"` (see the SuiteQL API-reference `systemnote` section — filtering by `recordid` alone is a
329
+ ~279s full scan; by `field`+`"date"` it's ~1s).
330
+
298
331
  ## E2E testing playbook + harness inventory (run this cold)
299
332
  This section is the reusable procedure for verifying the NetSuite → `Forecast.Sales` path
300
333
  end-to-end against a **local** Forecast mirror. It is written to be runnable in a future
@@ -452,6 +485,18 @@ record is deleted in NetSuite.)
452
485
  - The cron's sign handling is not portable here — see Sign convention.
453
486
 
454
487
  ## Change history
488
+ - 2026-07-07 — **Documented the AMQ enqueuer's line-field inline-edit blind spot** (Forecast2 SALES
489
+ profit-drift forensics; invoice 6715127 / 2026-02-20 / $3,392.70). Editing a **sublist LINE
490
+ field** (e.g. cost `TRANLINE.MCOSTESTIMATE`) inline in the NetSuite UI raises **no** record UE
491
+ `afterSubmit` (not create/edit/**xedit**), does **not** bump `lastmodifieddate`, and writes only a
492
+ `systemnote` (context `UIF`) — so `customscript_ue_amq_enqueue` never fires and `Forecast.Sales`
493
+ cost/profit silently drifts (revenue reconciles when the line is $0-revenue). Proven via live
494
+ enqueuer execution logs (2 cost-line-only-edited invoices had zero "triggered by" entries vs 8
495
+ that had other activity and synced); recurring/multi-user (~12/day). Corollary:
496
+ `lastmodifieddate` is not a reliable change signal for line-cost edits — only reconciliation or a
497
+ `systemnote` poll catches them. Also recorded the enqueuer's exact trigger surface (script id
498
+ 2618; invoice deployment pk 16019 RELEASED/allroles/allemployees; fires create/edit/delete +
499
+ `xedit` BODY edits; enqueues to custom record 1185; kicks `customscript_ss_amq_drain`). (dfranks)
455
500
  - 2026-07-07 — **CORRECTION: JE lines DO carry an item — in the custom column
456
501
  `custcol_item_sku`, not the native item field** (TRUE-79973, dfranks). Live probe
457
502
  2026-07-07 confirmed native `transactionline.item` / REST `line.item` is empty on **all
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: nycdoe
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-06-29
9
+ updated: 2026-07-07
10
10
  owners: [mhammontree]
11
11
  files:
12
12
  - worker/crons/sync/nycdoe/send_ticket_updates.php
@@ -25,9 +25,24 @@ related:
25
25
  DOE ticket **hold** status must round-trip between ServiceNow (SNOW) and TOGaDesk and
26
26
  **stay held** — holds are SLA-bearing in both systems. This doc covers the bidirectional
27
27
  hold-status sync for DOE Incidents (INC) and Request Items (RITM), the authoritative
28
- business rule that governs it, and the TRUE-79922 fix that made hold persist. It is the
29
- status-sync companion to the broader [ServiceNow / ASN integration](servicenow-integration.md)
30
- doc; the underlying crons and `App_Api_NYCDOEV2` plumbing are documented there.
28
+ business rule that governs it, and the two independent defects that let holds silently
29
+ revert. It is the status-sync companion to the broader
30
+ [ServiceNow / ASN integration](servicenow-integration.md) doc; the underlying crons and
31
+ `App_Api_NYCDOEV2` plumbing are documented there.
32
+
33
+ > **Two distinct hold-revert paths — different repos, mechanisms, and revert targets.**
34
+ > A held DOE order could be knocked out of hold by *either* of two unrelated code paths;
35
+ > both had to be closed:
36
+ > - **TRUE-79922 (worker crons):** the SNOW-side `state` 3⇄2 oscillation — an outbound
37
+ > self-clobber pushing `state:2` over a held `state:3`, plus an inbound wrong-field read
38
+ > — reverted the order to **"In Progress"**. Fixed; see *Change history* + the sections
39
+ > below. (Still pending deploy at time of writing.)
40
+ > - **TRUE-80060 (`library` shared model):** the local `qqStatus()` recompute silently
41
+ > demoting the order to a scheduling status (**"Orders Assigned / Awaiting Scheduling"**,
42
+ > `ORDER_ASSIGNED_AWAITING_SCHEDULING`) whenever `updateStatus()` ran. Fixed; see
43
+ > *Local status recompute* below. This defect is in a **shared, client-agnostic** model
44
+ > method — surfaced by DOE but affecting every TogaDesk repair-order client; the durable
45
+ > mechanism lives in [Field-Service Dispatch](../../../1.0/apps/togadesk/features/field-service-dispatch.md).
31
46
 
32
47
  > DOE "tickets" are **`repair_orders`** rows (`App_Model_TogaDesk_RepairOrder`,
33
48
  > `db_togadesk` / legacy `TOGaDeskSupport`, **clientid = 16**) — NOT the generic `tickets`
@@ -82,6 +97,26 @@ doc; the underlying crons and `App_Api_NYCDOEV2` plumbing are documented there.
82
97
  technician. This mirrors the INC cron, which already sends `u_eta` while holding — keeping
83
98
  scheduling metadata flowing while the hold STATE stays asserted.
84
99
 
100
+ ### Local status recompute (`qqStatus` / `updateStatus`) — TRUE-80060
101
+ - `App_Model_TogaDesk_RepairOrder::qqStatus()` is a computed SQL `CASE` expression that
102
+ `App_Model_TogaDesk_RepairOrder::updateStatus()` writes to `repair_orders.status`. It
103
+ recomputes status from technician / dispatch / ETA / parts data. Both live in the shared
104
+ model `library/app/model/togadesk/repairorder.php` (`qqStatus` ~L356, `updateStatus` ~L181)
105
+ — **no tenant conditional**, so this path is not DOE-specific.
106
+ - `updateStatus()` is called from the togadesk central actions `scheduleDispatch`,
107
+ `technicianOnsite`, `technicianEnRoute`, `awaitingDispatch`, `reworkAwaitingScheduling`
108
+ (and the part flows). Any of these recomputes `repair_orders.status` via `qqStatus()`.
109
+ - `qqStatus()` has two "hold gates" that short-circuit the recompute when an order is held.
110
+ Before TRUE-80060 they recognized only **2 of the 6** `HOLD_*` constants
111
+ (`HOLD_AWAITING_APPROVAL`, `HOLD_OTHER`); the base `STATUS_HOLD` and the other three
112
+ (`HOLD_AWAITING_CUSTOMER_APPROVAL`, `HOLD_ADDITIONAL_INFO_NEEDED`,
113
+ `HOLD_AWAITING_CUSTOMER_CONTACT`) fell through and were silently recomputed into a
114
+ scheduling / WIP status. The fix (`repairorder.php` ~L389 and ~L429) expanded **both**
115
+ gates to all six `HOLD_*` constants, still collapsing to base `STATUS_HOLD` (matching the
116
+ pre-existing behavior of the two that already worked).
117
+ - The revert is **silent** — `updateStatus()` writes via a raw `UPDATE` with **no**
118
+ `repair_order_history` / `repair_order_notes` entry.
119
+
85
120
  ## Reference facts (DOE sync debugging)
86
121
 
87
122
  - **SNOW request/response logs:** logged to the legacy `Logs` schema, **`API` table**
@@ -108,8 +143,33 @@ doc; the underlying crons and `App_Api_NYCDOEV2` plumbing are documented there.
108
143
  on native `state` only.
109
144
  - Holds are **never auto-released** — if a SNOW user manually changes status, the outbound
110
145
  cron correctly re-asserts On Hold; that is intended, not a bug. Clear the hold in TOGaDesk.
146
+ - **Diagnosing which writer dropped a hold:** `repair_order_history`
147
+ (`repairid, userid, dtStamp, note`) records *user-driven* status changes. A hold reverting
148
+ with **no** history row between the hold and the revert = a raw-`UPDATE` writer
149
+ (`updateStatus`/`qqStatus`), **not** the note/reply path (`class.repair.php::addNotes` syncs
150
+ replies to SNOW but never writes `repair_orders.status`) and **not** the worker inbound cron
151
+ (`process_tickets.php` contains no reference to `ORDER_ASSIGNED_AWAITING_SCHEDULING`). This
152
+ is how TRUE-80060 was pinned to `qqStatus()` rather than the SNOW round-trip.
153
+ - **KNOWN REMAINING GAP (needs a follow-up ticket):** both `qqStatus()` hold gates live inside
154
+ the `type IN (ONSITE_REPAIR, ONSITE_SERVICE)` branch. The `DEPOT_REPAIR` branch
155
+ (`repairorder.php` from ~L799) has **no hold gate at all**, so a `DEPOT_REPAIR` order set to
156
+ any hold status is still silently recomputed out of hold by `updateStatus()` — the same
157
+ class of bug, untouched by TRUE-80060.
158
+ - `qqStatus()` collapses all recognized hold sub-statuses to base `HOLD` in
159
+ `repair_orders.status`; the specific hold **sub-reason persists only in
160
+ `repair_order_notes.newstatus`**. Any status badge / report / filter reading
161
+ `repair_orders.status` directly cannot distinguish the six hold variants after a recompute.
111
162
 
112
163
  ## Change history
164
+ - 2026-07-07 — TRUE-80060: closed the **second** DOE hold-revert path — the local
165
+ `qqStatus()` recompute (in the shared model `library/app/model/togadesk/repairorder.php`)
166
+ silently demoting held orders to `ORDER_ASSIGNED_AWAITING_SCHEDULING` whenever
167
+ `updateStatus()` ran. Root cause: both `qqStatus()` hold gates recognized only 2 of the 6
168
+ `HOLD_*` constants; expanded both (~L389, ~L429) to all six, collapsing to base
169
+ `STATUS_HOLD`. Separate from TRUE-79922 (different repo/mechanism/revert target). Documented
170
+ the raw-`UPDATE` silent-revert, the `repair_order_history`-gap debugging technique, and the
171
+ untouched `DEPOT_REPAIR`-branch gap (follow-up ticket needed). php -l clean; php-reviewer 0
172
+ critical / 0 blocking. (mhammontree)
113
173
  - 2026-06-29 — TRUE-79922: fixed DOE holds never persisting (reverted to In Progress, broke
114
174
  SLA reporting both sides). Two coupled defects: outbound self-clobber (`send_ticket_updates.php`
115
175
  unguarded Assigned→In-Progress pushing `state:2` over a held `state:3`) and inbound
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.279",
3
+ "version": "1.0.281",
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",