toga-ai 1.0.218 → 1.0.220

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.
@@ -7,7 +7,7 @@
7
7
  | [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
8
8
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
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
- | [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, 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/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
10
+ | [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/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
11
11
  | [_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 |
12
12
  | [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 |
13
13
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
@@ -11,6 +11,7 @@ owners: [dfranks]
11
11
  files:
12
12
  - _underscore/Component/Forecast/SaleImport/SaleImport.php
13
13
  - _underscore/Component/Forecast/Db/Db.php
14
+ - _underscore/Component/Api/Netsuite/Netsuite.php
14
15
  - worker2/Worker/Netsuite/Invoice.php
15
16
  - worker2/Worker/Netsuite/CashSale.php
16
17
  - worker2/Worker/Netsuite/CreditMemo.php
@@ -24,6 +25,9 @@ files:
24
25
  - test/@dave/test_creditmemo_lifecycle.php
25
26
  - test/@dave/test_cashsale_lifecycle.php
26
27
  - test/@dave/test_cashrefund_lifecycle.php
28
+ - test/@dave/test_fetchrecord_routes.php
29
+ - test/@dave/verify_je_classification.php
30
+ - test/@dave/probe_je_accounts.php
27
31
  - test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js
28
32
  - test/@dave/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js
29
33
  related:
@@ -71,7 +75,7 @@ Engine public surface: `sync(int $internalId, string $type)` and
71
75
 
72
76
  ## How it works
73
77
  Per-record flow in `sync`:
74
- 1. REST GET the record (`expandSubResources=true`) via `_Component_Forecast_Db::fetchRecord`.
78
+ 1. REST GET the record (`expandSubResources=true`) via `_Component_Api_Netsuite::fetchRecord`.
75
79
  2. **Per-type status gate** → if excluded, call `removeAll` and stop. Exclusions: invoice
76
80
  `Voided`/`Rejected`; cashSale `Unapproved Payment`; creditMemo `Voided`; cashRefund has
77
81
  no gate. **A cashSale created via REST lands in status `Deposited`** (NOT the excluded
@@ -88,14 +92,32 @@ Per-record flow in `sync`:
88
92
  `removeAll(internalId)` deletes all rows for that transaction (used by delete events and by
89
93
  the status gate).
90
94
 
91
- ### Shared helpers on `_Component_Forecast_Db` (dedup)
92
- - `fetchRecord(route, label)` — NS REST GET + decode.
93
- - `collectItemLines(record)` + `nextPageRoute(links)` — `rel=next` sublist pagination.
95
+ ### Shared NS helpers — `fetchRecord` lives on `_Component_Api_Netsuite`, the rest on `_Component_Forecast_Db`
96
+ - **`_Component_Api_Netsuite::fetchRecord(route, label)`** — NS REST GET + decode. **Moved
97
+ here** (beside `createRecord`) from `_Component_Forecast_Db` — it is a generic REST read,
98
+ not Forecast-specific, so it belongs with the other REST primitives. **Verify the class
99
+ before relying on it** (it was relocated this session). The **7 callers** were all
100
+ reprefixed to `_Component_Api_Netsuite::fetchRecord`: `SaleImport.php` ×2, `Opportunity.php`
101
+ ×3, `SalesOrder.php` ×2 (static git-grep: zero stale `_Component_Forecast_Db::fetchRecord`
102
+ refs, all 7 resolve, lint clean; runtime-verified across all 5 SaleImport types +
103
+ opportunity/employee/salesOrder routes via `test_fetchrecord_routes.php`).
104
+ - **Still on `_Component_Forecast_Db`** (Forecast-specific): `collectItemLines(record)` +
105
+ `nextPageRoute(links)` (`rel=next` sublist pagination), `lookupId`, `toSqlDate`.
94
106
 
95
107
  `Opportunity.php` and `SalesOrder.php` were refactored onto these shared versions; their
96
108
  private `fetchRecord`/`collectAllItemLines`/`nextPageRoute` copies (and SalesOrder's
97
109
  orphaned `MAX_SUBLIST_PAGES` const) were deleted.
98
110
 
111
+ > **Process gotcha — enumerate callers of these shared NS helpers with `git grep`, not the
112
+ > fuzzy/agent search.** Any move/rename of `_Component_Api_Netsuite::createRecord` /
113
+ > `fetchRecord` (or the other shared NS helpers) must build its caller inventory with
114
+ > `git grep -nF` **per repo** — the fuzzy search tool silently **missed**
115
+ > `worker2/Worker/Netsuite/SalesOrder.php:925` (`createRecord`, the SalesOrder SOAP→REST
116
+ > outbound push — a real **production** caller) on a first pass, producing a wrong "zero
117
+ > callers / test-only" conclusion and a broken refactor that had to be reverted. Search
118
+ > case-insensitively for `::method`, `function method`, and string/callable forms; verify
119
+ > **zero stale refs** after editing.
120
+
99
121
  ## Sign convention (the load-bearing decision)
100
122
  The **raw REST record** returns each line `amount`/`costEstimate` **POSITIVE for all four
101
123
  types** (verified by live probe 2026-06-24 against NS account 1095849: creditMemo lines
@@ -147,11 +169,25 @@ item-line upsert/reconcile machinery (`syncLines`/`guardedInsert`/`deleteRows`).
147
169
  - Lines hitting a **revenue** or **cost** GL account are grouped by **(salesRep, item)**:
148
170
  `revenue = Σ(credit − debit)` over revenue lines, `cost = Σ(debit − credit)` over cost
149
171
  lines, `profit = revenue − cost`.
150
- - **Account classification is done on the fly by NetSuite `accttype`** — SuiteQL
151
- `SELECT id, accttype FROM account WHERE id IN (...)`, cached per run, bucketed via a tunable
152
- `JE_ACCTTYPE_BUCKETS` map. It is **NOT** a hardcoded account-id list. PROVISIONAL default:
153
- `Income → revenue`, `COGS → cost` (pending sales-team sign-off; open questions: whether
154
- `OthIncome`/`Expense` count, and the Income-typed but "Cost"-named accounts 430/433).
172
+ - **Account classification is an EXACT account-NUMBER allowlist** (sales-team-defined; this
173
+ *replaced* the old provisional `accttype`-bucket rule). Constants in
174
+ `_Component_Forecast_SaleImport`: `JE_REVENUE_ACCOUNT_NUMBERS = ['41100','41300','41500']`,
175
+ `JE_COST_ACCOUNT_NUMBERS = ['51100','51200']`. Any line whose account number is in neither
176
+ list is **ignored**. The helper `accountNumbers(accountIds)` (which **replaced**
177
+ `accountTypes()`) resolves each line account's `acctnumber` via SuiteQL
178
+ `SELECT id, acctnumber FROM account WHERE id IN (...)`, cached per run; matching is
179
+ **string membership of `acctnumber`**, not `accttype`.
180
+ - **Sub-accounts are EXCLUDED by design — exact match, never prefix.** This chart of accounts
181
+ has real traps that both a `accttype` rule AND a prefix match would mis-bucket: under **41300**
182
+ "Service Revenue" sit `41300.01` (Service Revenue-Agent, `accttype` Income), `41300.02`/`.03`
183
+ (Service Cost-Agent, `accttype` **Bank**), and `41300.04` (Service **Costs**-Agent, `accttype`
184
+ **Income** despite the cost name); **51200** has `51200.01` (COGS Service-Agent). Exact-number
185
+ matching sidesteps all of them.
186
+ - **Resolved account map** (number → internalId / name): `41100→245` Product Revenue,
187
+ `41300→339` Service Revenue, `41500→355` Pre-Sales Consulting; `51100→248` COGS Product,
188
+ `51200→249` COGS Service. Read-only resolver tool: `test/@dave/probe_je_accounts.php`.
189
+ - **Likely future one-line tweak:** if the Agent sub-accounts should count, add `41300.01` to
190
+ the revenue list and `51200.01` to the cost list.
155
191
  - Group key uses `json_encode([salesRep, item])` (not a string-join) to stay collision-safe
156
192
  once the fields go live — an empty-string separator would let `"5|" + null` collide.
157
193
 
@@ -218,28 +254,116 @@ events as specific **subtypes** (`inventoryItem`, `nonInventoryResaleItem`, `kit
218
254
  would be needed only for a future real-time item webhook, **not** for this Sales importer
219
255
  (which keeps items fresh via the hourly item pull cron plus the inline self-heal).
220
256
 
221
- ## Local testing harness (`test/@dave/test_invoice_lifecycle.php`)
222
- A CLI that boots worker2/_underscore (chdir to `worker2/` then `require index.php`) and drives a
223
- **full NetSuite invoice lifecycle** through the live `_underscore` REST client
224
- (`_Component_Api_Netsuite`) to exercise this importer end-to-end against a local Forecast mirror.
225
- Actions: `check | create | get | update | delete | sync | recent | amq | deployments | locations`.
226
-
227
- - **Fixture:** customer **58** ("8 Test Company") + item **103741** ("Test Other Charge for Sale",
228
- maps to local `Forecast.Items` id 25), **rate 0** → a **$0 invoice that still yields one Sales
229
- row** (the engine does **not** skip a $0 line — revenue `0.00` is written; same as the cron's
230
- Sales section). Confirms create→insert, edit→update (tracked-column change-detection), and
231
- delete→`removeAll` all land in local `Forecast.Sales`.
232
- - It carries its **own** `createInvoiceRecord()` that POSTs via `_ApiRequest` and parses the
233
- `Location` header with a **correct** regex (delimiter `~`) — a deliberate workaround for the live
234
- `_Component_Api_Netsuite::createRecord()` `#`-delimiter bug (see the
235
- [REST client doc](./netsuite-rest-client.md)); the framework was **not** edited this session.
236
- - `amq` action SuiteQLs the AMQ custom record; `deployments` SuiteQLs `scriptdeployment`+`script`
237
- to show which enqueuer fires per record type (used to find the dual-deployment trap below).
238
- - **Per-type siblings** `test_creditmemo_lifecycle.php`, `test_cashsale_lifecycle.php`,
239
- `test_cashrefund_lifecycle.php` (and `test_je_lifecycle.php`) mirror this harness, each with its
240
- own `create()` POSTing via `_ApiRequest` with the `~`-delimited Location regex workaround.
241
- **cashRefund and creditMemo require a header-level location** on create. The create webhook can
242
- lag **~20s** before the row appears (read-after-write/processing) — wait before asserting.
257
+ ## E2E testing playbook + harness inventory (run this cold)
258
+ This section is the reusable procedure for verifying the NetSuite → `Forecast.Sales` path
259
+ end-to-end against a **local** Forecast mirror. It is written to be runnable in a future
260
+ session with no prior context.
261
+
262
+ ### Harness inventory (`test/@dave/`, all PHP)
263
+ All harnesses boot worker2/_underscore by `chdir('C:/Users/dfranks/www/worker2'); require
264
+ 'index.php';` and drive the live `_underscore` REST client (`_Component_Api_Netsuite`).
265
+ **Run them with xampp8 PHP 8.0:** `C:\xampp8\php\php.exe test/@dave/<harness>.php <action> …`
266
+ (local `_underscore` floor is PHP 8.0.0).
267
+
268
+ | Harness | Type | Actions |
269
+ |---|---|---|
270
+ | `test_invoice_lifecycle.php` | invoice | `check`, `create`, `get <id>`, `sync <id>`, `update <id> <date>`, `delete <id>`, `recent` (plus `amq`/`deployments`/`locations` SuiteQL probes) |
271
+ | `test_creditmemo_lifecycle.php` | creditMemo | same per-type action set |
272
+ | `test_cashsale_lifecycle.php` | cashSale | same per-type action set |
273
+ | `test_cashrefund_lifecycle.php` | cashRefund | same per-type action set |
274
+ | `test_je_lifecycle.php` | journalEntry | per-type set **plus** `sync` (`syncJournalEntry`) and `remove` (`removeAllJournalEntry`, direct); `create` makes a **balanced reversing JE** |
275
+ | `test_fetchrecord_routes.php` | **read-only probe** | calls `_Component_Api_Netsuite::fetchRecord` against a real opportunity/employee/salesOrder id — proves that method per route, **no side effects** |
276
+
277
+ **Per-type actions explained:**
278
+ - `check` — boot + OAuth only, **no writes** (smoke test connectivity/creds).
279
+ - `create` — create a real record in NetSuite (tiny `$0`/`$0.01`).
280
+ - `get <id>` — REST GET the record.
281
+ - `sync <id>` — **DIRECT** `_Component_Forecast_SaleImport::sync()` (or `syncJournalEntry()`)
282
+ against NetSuite reads, **local, no webhook**. This is the local-only execution path.
283
+ - `update <id> <date>` — PATCH `tranDate` (a tracked field) to exercise edit→update.
284
+ - `delete <id>` — delete the record in NetSuite.
285
+ - `recent` — list recent records of that type.
286
+
287
+ Each harness carries its **own** local create helper that POSTs via `_ApiRequest` and parses
288
+ the `Location` header with a **correct `~`-delimited regex** — a deliberate workaround for the
289
+ live `_Component_Api_Netsuite::createRecord()` `#`-delimiter bug (still unfixed; see the
290
+ [REST client doc](./netsuite-rest-client.md)). The framework is **not** edited to test.
291
+
292
+ ### The 6-step lifecycle pattern (every type)
293
+ 1. **create** → a real NetSuite record.
294
+ 2. **verify** the local `forecast.Sales` row(s) appeared.
295
+ 3. **update** the `tranDate`.
296
+ 4. **verify** the update landed (tracked-column change-detection).
297
+ 5. **delete** the record.
298
+ 6. **verify** the local row is gone (`removeAll`).
299
+
300
+ > **Scope of the update step (what it does and does NOT cover):** the update step changes
301
+ > **only `tranDate`** — a tracked column, so it forces a real change-detection UPDATE while
302
+ > the amount stays unchanged. It does **NOT** exercise updates to revenue / customer /
303
+ > salesRep / item, nor line add/remove. A future tester verifying those must extend the
304
+ > harness; the current update-step assertion only proves the tracked-column UPDATE path.
305
+
306
+ Poll the **local** Forecast DB between steps:
307
+ `C:\xampp8\mysql\bin\mysql.exe -u root` → db **`forecast`**, table **`Sales`** (filter on the
308
+ test internalId, or customerId `2` / itemId `25`).
309
+
310
+ ### Two execution modes
311
+ - **(a) WEBHOOK path** — NS create → AMQ enqueuer → ngrok → local worker2 → engine. Requires:
312
+ the **dev** AMQ enqueuer **deployed on each record type under test** with its **AUDIENCE
313
+ including the REST/M2M integration user** (Execute-As-Role does **not** control triggering —
314
+ AUDIENCE does); the **prod** enqueuer left **UNDEPLOYED** for isolation; and **ngrok up** at
315
+ the enqueuer's `DEV_OVERRIDE` endpoint. Exercises the real inbound path.
316
+ - **(b) LOCAL-ONLY path** (use when deployments are off / you can't touch NetSuite scripts) —
317
+ skip the webhook entirely: call the harness **`sync`** action, which runs
318
+ `_Component_Forecast_SaleImport::sync()` / `syncJournalEntry()` **directly** against NetSuite
319
+ reads. Exercises the same import + `fetchRecord` with **no enqueuer/ngrok dependency**.
320
+
321
+ ### Fixtures
322
+ - Customer **58** ("8 Test Company") → local `forecast.Customers` id **2**.
323
+ - Item **103741** ("Test Other Charge for Sale") → local `forecast.Items` id **25**.
324
+ - Location **5** ("Main") — required as a **header-level** field on creditMemo/cashSale/cashRefund
325
+ and **line-level** on invoice.
326
+ - **JE fixture:** subsidiary **1**; accounts **245** (Product Revenue, acctnumber **41100**),
327
+ **248** (COGS Product, **51100**), **215** (Clearing - CSS — ignored, neither revenue nor
328
+ cost); set a `reversalDate` so NetSuite auto-creates the reversal. Classification is now by
329
+ **account number** (allowlist), so any clearing/balance line lands outside both lists and is
330
+ ignored automatically.
331
+ - **Discriminating classification test — `test/@dave/verify_je_classification.php`:** posts one
332
+ balanced JE `+10/+20/+30` to `41100/41300/41500`, `−5/−7` to `51100/51200`, **plus `+100` to
333
+ the excluded sub-account `41300.01`**, plus a clearing balance line → local `Forecast.Sales`
334
+ aggregates to **revenue=60.00, profit=48.00** (NOT 160 — proving `41300.01` is excluded and the
335
+ exact-number rule is in force).
336
+
337
+ ### Expected local revenue signs / row outcomes
338
+ - **invoice** → `+` (a **$0** invoice still writes **one** row, revenue `0.00` — the engine does
339
+ not skip a $0 line).
340
+ - **cashSale** → `+`; status must be **`Deposited`** (a REST-created cash sale lands Deposited,
341
+ **not** the excluded `Unapproved Payment`).
342
+ - **creditMemo** → `−`.
343
+ - **cashRefund** → `−`.
344
+ - **JE** → `revenue = Σ(credit − debit)` over revenue-acct lines, `cost = Σ(debit − credit)`
345
+ over cost-acct lines, `profit = revenue − cost`. The **reversal** lands as the negation keyed
346
+ by its **own** internalId with `createdFromNetsuiteTransactionInternalId = the original`.
347
+
348
+ ### Timing / verification notes
349
+ - The create webhook can lag **~20s** before the row appears (read-after-write / processing) —
350
+ wait before asserting.
351
+ - On the debug webhook path there are **no** local app logs — confirm inbound arrival at the
352
+ ngrok inspector `http://127.0.0.1:4040/api/requests/http` (body is **base64**; decode before
353
+ grepping; buffer is ephemeral). See Gotchas.
354
+
355
+ ### Prod hygiene (critical — tests write to the single prod NetSuite account)
356
+ All harnesses create **real** tiny (`$0`/`$0.01`) records in the **single prod NetSuite
357
+ account (1095849)**. The legacy 5-min pull cron
358
+ (`worker/crons/toga2/forecast2/import_sales.php`) can pull a briefly-alive test record into
359
+ **prod** `Forecast.Sales` **independent of the webhook code**. After each test batch:
360
+ 1. Query the **prod reader** (`reader1.core.database.togahub.com`, `Forecast.Sales`) for the
361
+ test internalIds.
362
+ 2. **Guarded-DELETE exact matches** (customerId `2` / itemId `25` / revenue `±0.01`) via the
363
+ **prod writer**.
364
+ Keep **create→delete windows short** so most records slip between cron runs; or pause
365
+ `import_sales` during a batch. (The daily discrepancy-fix also cleans stragglers since the
366
+ record is deleted in NetSuite.)
243
367
 
244
368
  ## Gotchas / known issues
245
369
  - **Testing locally pollutes PROD unless you UNDEPLOY the prod AMQ enqueuer first.** Each sale
@@ -287,6 +411,29 @@ Actions: `check | create | get | update | delete | sync | recent | amq | deploym
287
411
  - The cron's sign handling is not portable here — see Sign convention.
288
412
 
289
413
  ## Change history
414
+ - 2026-06-26 — **Changed JE revenue/cost classification from `accttype` buckets to an EXACT
415
+ account-NUMBER allowlist** (sales-team-defined): `JE_REVENUE_ACCOUNT_NUMBERS =
416
+ {41100,41300,41500}`, `JE_COST_ACCOUNT_NUMBERS = {51100,51200}`; everything else ignored.
417
+ `accountNumbers()` (replaced `accountTypes()`) resolves each account's `acctnumber` via
418
+ SuiteQL, cached per run; match is exact-string, **never prefix** — sub-accounts are excluded
419
+ by design to dodge real chart-of-accounts traps (`41300.04` "Service Costs-Agent" is
420
+ `accttype` Income; `41300.02/.03` are `accttype` Bank). Resolved map: 41100→245, 41300→339,
421
+ 41500→355, 51100→248, 51200→249. Verified with a discriminating balanced-JE test
422
+ (`test/@dave/verify_je_classification.php` → revenue=60.00/profit=48.00, proving `41300.01`
423
+ excluded); read-only resolver `test/@dave/probe_je_accounts.php`. Future one-liner: add
424
+ `41300.01`/`51200.01` if Agent sub-accounts should count. Also clarified the e2e harness
425
+ update step exercises **only `tranDate`** (a tracked column) — not revenue/customer/salesRep/
426
+ item or line add/remove. (dfranks)
427
+ - 2026-06-26 — **Documented the full e2e testing playbook + harness inventory to run cold**
428
+ (6-step create→verify→update→verify→delete→verify lifecycle; per-type harness action sets;
429
+ the two execution modes — WEBHOOK vs LOCAL-ONLY `sync` — fixtures, expected signs, prod
430
+ hygiene). Added the read-only `test/@dave/test_fetchrecord_routes.php` probe. Recorded that
431
+ **`fetchRecord` moved from `_Component_Forecast_Db` to `_Component_Api_Netsuite`** (beside
432
+ `createRecord`; 7 callers reprefixed — SaleImport ×2, Opportunity ×3, SalesOrder ×2;
433
+ verify-the-class-before-relying). Added the **`git grep` caller-enumeration gotcha**: build
434
+ caller inventories for the shared NS helpers with `git grep -nF` per repo, not the fuzzy
435
+ search, which silently missed the production `SalesOrder.php` `createRecord` caller and caused
436
+ a reverted refactor. (dfranks)
290
437
  - 2026-06-26 — **Hardened + verified the full webhook path for all four sale types end-to-end**
291
438
  (create→insert, edit→update, delete→removeAll) with sign conventions reconfirmed (invoice/cashSale
292
439
  +, creditMemo/cashRefund −). Recorded durable gotchas: a UE enqueuer fires for a REST/M2M user only
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-25
9
+ updated: 2026-06-26
10
10
  owners: ["dfranks"]
11
11
  files:
12
12
  - _underscore/Component/Api/Netsuite/Netsuite.php
@@ -64,6 +64,24 @@ through `_ApiRequest` directly (mirroring `send()`'s auth/endpoint/header setup)
64
64
  > value carried in the thrown exception message (the lifecycle harness ships its own correct
65
65
  > `~`-delimited parse to sidestep this without editing the framework). Confirmed live 2026-06-26.
66
66
 
67
+ ### Read — `fetchRecord(string $route, string $label): array`
68
+
69
+ A generic NS REST **GET + decode** primitive. **Moved here from `_Component_Forecast_Db`**
70
+ (2026-06-26) so it sits beside `createRecord` with the other REST primitives — it is not
71
+ Forecast-specific. Its **7 callers** were reprefixed to `_Component_Api_Netsuite::fetchRecord`:
72
+ `SaleImport.php` ×2, `Opportunity.php` ×3, `SalesOrder.php` ×2 (static git-grep: zero stale
73
+ `_Component_Forecast_Db::fetchRecord` refs; runtime-verified across all 5 SaleImport types +
74
+ opportunity/employee/salesOrder routes via `test/@dave/test_fetchrecord_routes.php`).
75
+ **Verify the class before relying on it** — it was just relocated.
76
+
77
+ > **Enumerate callers of this class's shared helpers with `git grep`, not the fuzzy/agent
78
+ > search.** When moving/renaming `createRecord` / `fetchRecord`, build the caller inventory with
79
+ > `git grep -nF` **per repo** — the fuzzy search silently **missed**
80
+ > `worker2/Worker/Netsuite/SalesOrder.php:925` (`createRecord`, the SalesOrder SOAP→REST
81
+ > outbound push — a real **production** caller), producing a wrong "zero callers / test-only"
82
+ > conclusion and a broken refactor that had to be reverted. Search case-insensitively for
83
+ > `::method`, `function method`, and string/callable forms; confirm zero stale refs afterward.
84
+
67
85
  ### Update — reuse `send('PATCH', $route, $body)`
68
86
 
69
87
  Updates do **not** need a new helper. A NetSuite record PATCH returns 204 with no body, and
@@ -103,6 +121,12 @@ doc.)
103
121
 
104
122
  ## Change history
105
123
 
124
+ - 2026-06-26 — **`fetchRecord` (generic NS REST GET + decode) moved onto this class** from
125
+ `_Component_Forecast_Db`, beside `createRecord`; 7 callers reprefixed (SaleImport ×2,
126
+ Opportunity ×3, SalesOrder ×2), git-grep clean + runtime-verified. Recorded the
127
+ caller-enumeration lesson: use `git grep -nF` per repo (not the fuzzy search, which missed the
128
+ production `SalesOrder.php` `createRecord` caller and caused a reverted refactor) when
129
+ moving/renaming these shared NS helpers. (dfranks)
106
130
  - 2026-06-25 — **Recorded a live bug in `createRecord()`'s trailing-id regex.** The pattern
107
131
  `'#/(\d+)(?:[?#]|$)#'` uses `#` as both the PCRE delimiter and a class member, so it always throws
108
132
  `Unknown modifier ']'` — *after* the record is created, breaking the outbound create/push path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.218",
3
+ "version": "1.0.220",
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",