toga-ai 1.0.280 → 1.0.282
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/library/features/netsuite-suiteql-api-reference.md +30 -0
- package/knowledge/1.0/apps/togadesk/features/field-service-dispatch.md +28 -0
- package/knowledge/1.0/apps/worker/features/forecast2-netsuite-reconciliation.md +31 -0
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -1
- package/knowledge/2.0/apps/_underscore/features/forecast-sale-import.md +47 -2
- package/knowledge/clients/nycdoe/INDEX.md +1 -1
- package/knowledge/clients/nycdoe/features/hold-status-sync.md +74 -9
- package/knowledge/clients/nycdoe/profile.md +2 -1
- package/package.json +1 -1
|
@@ -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
|
|
@@ -57,6 +57,28 @@ the originating page. Status changes are audited through `RepairHistory` (`repai
|
|
|
57
57
|
onsite/install started → review → closed; depot repairs add parts/tracking steps. Bulk actions
|
|
58
58
|
exist for closing installs and editing onsite tickets.
|
|
59
59
|
|
|
60
|
+
### Status is a denormalized cache (`repair_orders.status`)
|
|
61
|
+
This is **shared, client-agnostic** behavior — every repair-order client is subject to it.
|
|
62
|
+
- Status transitions are **recorded on `repair_order_notes.newstatus`**, not written to
|
|
63
|
+
`repair_orders.status` directly. `Repair::addNotes`, `Repair::changeStatus`, and
|
|
64
|
+
`Repair::reworkRepair` (`class.repair.php`) each only create a note — none writes the
|
|
65
|
+
status column itself.
|
|
66
|
+
- **`repair_orders.status` is a denormalized CACHE**, reconciled from the latest note **only
|
|
67
|
+
when** `App_Model_TogaDesk_RepairOrder::updateStatus()` runs (fired by the central actions
|
|
68
|
+
`scheduleDispatch`, `technicianOnsite`, `technicianEnRoute`, `awaitingDispatch`,
|
|
69
|
+
`reworkAwaitingScheduling`, and the part flows). `updateStatus()` writes via a raw `UPDATE`
|
|
70
|
+
with no history row (see gotchas).
|
|
71
|
+
- **The LIVE status the UI shows is DERIVED, not the cache:** the grid/detail SELECTs use
|
|
72
|
+
`qqStatus() AS status` (`repairorder.php` ~L356), recomputing status from the latest note on
|
|
73
|
+
read. So the UI can show a status the cache column does not currently hold.
|
|
74
|
+
- **Consequence:** any consumer that reads the cached `repair_orders.status` (e.g. the worker
|
|
75
|
+
sync crons) diverges from the derived UI status whenever a note changed status but no
|
|
76
|
+
`updateStatus()` ran afterward — the consumer then acts on a **stale** status. This is
|
|
77
|
+
exactly how a DOE hold set via a comment failed to sync (see
|
|
78
|
+
[NYCDOE Hold-Status Sync](../../../../clients/nycdoe/features/hold-status-sync.md)).
|
|
79
|
+
- `updateStatus()`'s notification block is **enqueue-only** — it inserts dedup'd
|
|
80
|
+
`repair_order_notifications` rows; it sends no inline email.
|
|
81
|
+
|
|
60
82
|
## UPS shipment / carrier-account billing (Prepare Shipment flow)
|
|
61
83
|
The "PREPARE SHIPMENT" button opens modal `desk/template/modals/central/addTracking.php`,
|
|
62
84
|
whose carrier/account dropdown is built from the constant
|
|
@@ -126,6 +148,12 @@ always confirm the client arm before changing shared central logic.
|
|
|
126
148
|
account-location map noted under *UPS shipment / carrier-account billing*.
|
|
127
149
|
|
|
128
150
|
## Change history
|
|
151
|
+
- 2026-07-07 — TRUE-80060 (togadesk portion): documented the durable, client-agnostic
|
|
152
|
+
status-cache model — status transitions land on `repair_order_notes.newstatus`;
|
|
153
|
+
`repair_orders.status` is a denormalized cache reconciled only by `updateStatus()`; the UI
|
|
154
|
+
shows the derived `qqStatus() AS status`, so cache-reading consumers (e.g. worker crons) can
|
|
155
|
+
act on a stale status when a note changed status without a following `updateStatus()`. Noted
|
|
156
|
+
`updateStatus()`'s notification block is enqueue-only. (mhammontree)
|
|
129
157
|
- 2026-07-07 — TRUE-80060: documented the shared `qqStatus()`/`updateStatus()` status-recompute
|
|
130
158
|
hold gates (in `library/app/model/togadesk/repairorder.php`) — silent raw-`UPDATE` overwrite,
|
|
131
159
|
hold states must be gated or dropped, the six-hold-variant collapse to base `STATUS_HOLD`, and
|
|
@@ -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
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
| Doc | Framework | Summary | Files |
|
|
4
4
|
|-----|-----------|---------|-------|
|
|
5
|
-
| [NYCDOE Ticket Hold-Status Sync (ServiceNow ⇄ TOGaDesk)](features/hold-status-sync.md) | 1.0 | DOE ticket **hold** status must round-trip between ServiceNow (SNOW) and TOGaDesk and **stay held** — holds are SLA-bearing in both systems. | worker/crons/sync/nycdoe/send_ticket_updates.php, worker/crons/sync/nycdoe/process_tickets.php, worker/crons/sync/nycdoe/send_request_item_updates.php, library/app/model/togadesk/repairorder.php, library/app/api/nycdoev2.php |
|
|
5
|
+
| [NYCDOE Ticket Hold-Status Sync (ServiceNow ⇄ TOGaDesk)](features/hold-status-sync.md) | 1.0 | DOE ticket **hold** status must round-trip between ServiceNow (SNOW) and TOGaDesk and **stay held** — holds are SLA-bearing in both systems. | worker/crons/sync/nycdoe/send_ticket_updates.php, worker/crons/sync/nycdoe/process_tickets.php, worker/crons/sync/nycdoe/send_request_item_updates.php, library/app/model/togadesk/repairorder.php, library/app/api/nycdoev2.php, togadesk/desk/includes/classes/class.repair.php |
|
|
6
6
|
| [NYCDOE ServiceNow / ASN Integration](features/servicenow-integration.md) | 1.0 | The NYCDOE/ServiceNow integration mirrors DOE's ServiceNow tickets (Incidents + RITMs) into local tables, turns vendor shipment notices into NetSuite Sales Orde | worker/crons/sync/nycdoe/import_asn.php, worker/crons/sync/nycdoe/import_inc.php, worker/crons/sync/nycdoe/legacy_import_asn.php, worker/crons/sync/nycdoe/legacy_process_asn_queue.php, worker/crons/sync/nycdoe/process_tickets.php, worker/crons/sync/nycdoe/1_send_asn_to_netsuite.php, worker/crons/sync/nycdoe/2_send_serials_to_netsuite.php, worker/crons/sync/nycdoe/3_create_installation_ticket.php, worker/crons/sync/nycdoe/send_ticket_updates.php, worker/crons/sync/nycdoe/send_request_item_updates.php, worker/crons/sync/nycdoe/send_nycdoe_proof_of_delivery.php, worker/crons/sync/nycdoe/sync_nycdoe_locations.php, worker/crons/sync/nycdoe/receive_edi_purchase_orders.php, worker/crons/sync/nycdoe/send_edi_open_invoices.php, worker/crons/notifications/nycdoe/, worker/schedules/cron.worker.sync.json, worker/schedules/cron.worker.notification.json, library/app/api/nycdoe.php, library/app/api/nycdoev2.php, library/app/asnprocessor/manufacturer.php, library/app/asnprocessor/apple.php, library/app/asnprocessor/lenovo.php, library/app/asnprocessor/lexmark.php, library/app/asnprocessor/acer.php, library/app/edi.php |
|
|
7
7
|
| [New York City Department of Education](profile.md) | 1.0 | NYC DOE (New York City Department of Education) is a TOGA client whose entire integration runs in the **1.0 worker tier** (~30 cron scripts under `worker/crons/ | |
|
|
@@ -14,6 +14,7 @@ files:
|
|
|
14
14
|
- worker/crons/sync/nycdoe/send_request_item_updates.php
|
|
15
15
|
- library/app/model/togadesk/repairorder.php
|
|
16
16
|
- library/app/api/nycdoev2.php
|
|
17
|
+
- togadesk/desk/includes/classes/class.repair.php
|
|
17
18
|
related:
|
|
18
19
|
- servicenow-integration.md
|
|
19
20
|
- ../profile.md
|
|
@@ -25,14 +26,14 @@ related:
|
|
|
25
26
|
DOE ticket **hold** status must round-trip between ServiceNow (SNOW) and TOGaDesk and
|
|
26
27
|
**stay held** — holds are SLA-bearing in both systems. This doc covers the bidirectional
|
|
27
28
|
hold-status sync for DOE Incidents (INC) and Request Items (RITM), the authoritative
|
|
28
|
-
business rule that governs it, and the
|
|
29
|
-
revert. It is the status-sync companion to the broader
|
|
29
|
+
business rule that governs it, and the three independent defects that let holds silently
|
|
30
|
+
revert or never reach ServiceNow. It is the status-sync companion to the broader
|
|
30
31
|
[ServiceNow / ASN integration](servicenow-integration.md) doc; the underlying crons and
|
|
31
32
|
`App_Api_NYCDOEV2` plumbing are documented there.
|
|
32
33
|
|
|
33
|
-
> **
|
|
34
|
-
> A held DOE order could
|
|
35
|
-
>
|
|
34
|
+
> **Three distinct hold-failure paths — different repos, mechanisms, and symptoms.**
|
|
35
|
+
> A held DOE order could fail to persist/propagate via *any* of three unrelated code paths;
|
|
36
|
+
> all three had to be closed (see *Coordinated three-repo fix & deploy ordering* below):
|
|
36
37
|
> - **TRUE-79922 (worker crons):** the SNOW-side `state` 3⇄2 oscillation — an outbound
|
|
37
38
|
> self-clobber pushing `state:2` over a held `state:3`, plus an inbound wrong-field read
|
|
38
39
|
> — reverted the order to **"In Progress"**. Fixed; see *Change history* + the sections
|
|
@@ -43,6 +44,14 @@ revert. It is the status-sync companion to the broader
|
|
|
43
44
|
> *Local status recompute* below. This defect is in a **shared, client-agnostic** model
|
|
44
45
|
> method — surfaced by DOE but affecting every TogaDesk repair-order client; the durable
|
|
45
46
|
> mechanism lives in [Field-Service Dispatch](../../../1.0/apps/togadesk/features/field-service-dispatch.md).
|
|
47
|
+
> - **TRUE-80060 (`togadesk` desk app):** a hold set **via a note/comment**
|
|
48
|
+
> (`Repair::addNotes`) never reached ServiceNow at all. `addNotes` recorded the new status
|
|
49
|
+
> only on `repair_order_notes.newstatus` — never writing the `repair_orders.status` cache
|
|
50
|
+
> nor calling `updateStatus()` — and the TRUE-75199 real-time-sync optimization suppressed
|
|
51
|
+
> `repair_orders.dtUpdated`. The worker reads the **cached** `repair_orders.status` and
|
|
52
|
+
> selects orders by `dtUpdated >= dtSynced`, so an on-hold-with-comment order left a stale
|
|
53
|
+
> non-hold cache **and** an un-bumped `dtUpdated` — the worker never selected it and never
|
|
54
|
+
> pushed `state:3`. Fixed; see *Hold set via a note/comment* below.
|
|
46
55
|
|
|
47
56
|
> DOE "tickets" are **`repair_orders`** rows (`App_Model_TogaDesk_RepairOrder`,
|
|
48
57
|
> `db_togadesk` / legacy `TOGaDeskSupport`, **clientid = 16**) — NOT the generic `tickets`
|
|
@@ -117,6 +126,41 @@ revert. It is the status-sync companion to the broader
|
|
|
117
126
|
- The revert is **silent** — `updateStatus()` writes via a raw `UPDATE` with **no**
|
|
118
127
|
`repair_order_history` / `repair_order_notes` entry.
|
|
119
128
|
|
|
129
|
+
### Hold set via a note/comment (`addNotes`) — TRUE-80060 (togadesk)
|
|
130
|
+
- In TOGaDesk a status change carried on a comment goes through `Repair::addNotes`
|
|
131
|
+
(`togadesk/desk/includes/classes/class.repair.php`), which records the new status on
|
|
132
|
+
`repair_order_notes.newstatus`. Because `repair_orders.status` is a denormalized cache
|
|
133
|
+
reconciled only by `updateStatus()` (see
|
|
134
|
+
[Field-Service Dispatch](../../../1.0/apps/togadesk/features/field-service-dispatch.md)),
|
|
135
|
+
a hold set this way did **not** update the cache the worker reads.
|
|
136
|
+
- Compounding it, the **TRUE-75199** real-time comment-sync optimization suppressed
|
|
137
|
+
`repair_orders.dtUpdated`, so the order also failed the worker's `dtUpdated >= dtSynced`
|
|
138
|
+
selection — the hold never even entered the outbound cron.
|
|
139
|
+
- **Fix (scoped to `clientid == 16`):** (a) suppress `dtUpdated` only for a **pure comment**
|
|
140
|
+
(`newstatus == oldStatus`); keep it bumped when the status changed, so the worker selects
|
|
141
|
+
the order — the worker's work-note query filters `referenceId IS NULL`, so a
|
|
142
|
+
real-time-reconciled comment is not re-sent; (b) after a **status-changing** note, call
|
|
143
|
+
`App_Model_TogaDesk_RepairOrder::updateStatus()` to reconcile `repair_orders.status` from
|
|
144
|
+
the latest note. This reconcile depends on the library TRUE-80060 `qqStatus()` fix — see
|
|
145
|
+
deploy ordering below.
|
|
146
|
+
|
|
147
|
+
## Coordinated three-repo fix & deploy ordering (TRUE-79922 / TRUE-80060)
|
|
148
|
+
Making DOE holds persist took a change in **three repos**, with a **deploy-order dependency**:
|
|
149
|
+
- **worker — TRUE-79922:** stop the SNOW `state` 3⇄2 oscillation (outbound self-clobber +
|
|
150
|
+
inbound wrong-field read). *(failure path #1 above)*
|
|
151
|
+
- **library — TRUE-80060:** `qqStatus()` recognizes all six `HOLD_*` on recompute so
|
|
152
|
+
`updateStatus()` can't demote a hold. *(failure path #2 above)*
|
|
153
|
+
- **togadesk — TRUE-80060:** `addNotes` reconciles the cache + keeps `dtUpdated` so a
|
|
154
|
+
note-set hold reaches the worker. *(failure path #3 above)*
|
|
155
|
+
|
|
156
|
+
> **DEPLOY ORDER: `library` must ship WITH or BEFORE `togadesk`.** The togadesk `addNotes`
|
|
157
|
+
> change calls `updateStatus()`, which relies on the library `qqStatus()` fix. Ship togadesk
|
|
158
|
+
> first and `updateStatus()` recomputes a base hold **out of** hold — reintroducing failure
|
|
159
|
+
> path #2 on every note-set hold.
|
|
160
|
+
|
|
161
|
+
The SNOW round-trip note reconciliation (delete-and-reinsert in `process_tickets`) is
|
|
162
|
+
**intentionally kept** as a failover for SNOW API unreliability — not removed by this work.
|
|
163
|
+
|
|
120
164
|
## Reference facts (DOE sync debugging)
|
|
121
165
|
|
|
122
166
|
- **SNOW request/response logs:** logged to the legacy `Logs` schema, **`API` table**
|
|
@@ -146,21 +190,42 @@ revert. It is the status-sync companion to the broader
|
|
|
146
190
|
- **Diagnosing which writer dropped a hold:** `repair_order_history`
|
|
147
191
|
(`repairid, userid, dtStamp, note`) records *user-driven* status changes. A hold reverting
|
|
148
192
|
with **no** history row between the hold and the revert = a raw-`UPDATE` writer
|
|
149
|
-
(`updateStatus`/`qqStatus`), **not** the
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
193
|
+
(`updateStatus`/`qqStatus`), **not** the worker inbound cron (`process_tickets.php` contains
|
|
194
|
+
no reference to `ORDER_ASSIGNED_AWAITING_SCHEDULING`). Note that **since TRUE-80060** a
|
|
195
|
+
*status-changing* `class.repair.php::addNotes` now **calls** `updateStatus()` (so it too can
|
|
196
|
+
move the cache via that raw `UPDATE`); a **pure comment** still writes nothing to
|
|
197
|
+
`repair_orders.status`. This history-gap technique is how TRUE-80060 was pinned to
|
|
198
|
+
`qqStatus()` rather than the SNOW round-trip.
|
|
153
199
|
- **KNOWN REMAINING GAP (needs a follow-up ticket):** both `qqStatus()` hold gates live inside
|
|
154
200
|
the `type IN (ONSITE_REPAIR, ONSITE_SERVICE)` branch. The `DEPOT_REPAIR` branch
|
|
155
201
|
(`repairorder.php` from ~L799) has **no hold gate at all**, so a `DEPOT_REPAIR` order set to
|
|
156
202
|
any hold status is still silently recomputed out of hold by `updateStatus()` — the same
|
|
157
203
|
class of bug, untouched by TRUE-80060.
|
|
204
|
+
- **KNOWN REMAINING GAP (needs a follow-up ticket) — inbound note reconciliation is paged too
|
|
205
|
+
small.** `process_tickets.php`'s inbound comment reconciliation fetches only `page_size=50,
|
|
206
|
+
page_number=1` of the SNOW journal, then **deletes any local note not in that set** (the
|
|
207
|
+
remainder-delete ~L1326–1330). An order with **>50** comments/work_notes can have local
|
|
208
|
+
notes silently deleted. It is also inconsistent with `addNotes`, which fetches
|
|
209
|
+
`page_size=100`.
|
|
210
|
+
- **KNOWN REMAINING GAP — `buildDoubleFieldArray` collapses unsynced notes.** In
|
|
211
|
+
`process_tickets`, `buildDoubleFieldArray` keys on `IFNULL(referenceId,0)`, so **all**
|
|
212
|
+
NULL-`referenceId` notes collapse to key `'0'`, making the remainder-delete handle multiple
|
|
213
|
+
unsynced notes inconsistently.
|
|
158
214
|
- `qqStatus()` collapses all recognized hold sub-statuses to base `HOLD` in
|
|
159
215
|
`repair_orders.status`; the specific hold **sub-reason persists only in
|
|
160
216
|
`repair_order_notes.newstatus`**. Any status badge / report / filter reading
|
|
161
217
|
`repair_orders.status` directly cannot distinguish the six hold variants after a recompute.
|
|
162
218
|
|
|
163
219
|
## Change history
|
|
220
|
+
- 2026-07-07 — TRUE-80060 (togadesk portion): closed the **third** DOE hold-failure path — a
|
|
221
|
+
hold set via a note/comment (`Repair::addNotes`) never reaching SNOW. `addNotes` recorded
|
|
222
|
+
status only on `repair_order_notes.newstatus` (never the `repair_orders.status` cache the
|
|
223
|
+
worker reads), and the TRUE-75199 optimization suppressed `dtUpdated`, so the worker's
|
|
224
|
+
`dtUpdated >= dtSynced` selection skipped it. Fix (clientid 16): suppress `dtUpdated` only
|
|
225
|
+
for a pure comment; call `updateStatus()` after a status-changing note. Documented the
|
|
226
|
+
coordinated three-repo fix + `library`-before-`togadesk` deploy order, and two new
|
|
227
|
+
`process_tickets` reconciliation gaps (page_size=50 remainder-delete; `IFNULL(referenceId,0)`
|
|
228
|
+
collapse). php -l clean; php-reviewer 0 blocking. (mhammontree)
|
|
164
229
|
- 2026-07-07 — TRUE-80060: closed the **second** DOE hold-revert path — the local
|
|
165
230
|
`qqStatus()` recompute (in the shared model `library/app/model/togadesk/repairorder.php`)
|
|
166
231
|
silently demoting held orders to `ORDER_ASSIGNED_AWAITING_SCHEDULING` whenever
|
package/package.json
CHANGED