toga-ai 1.0.61 → 1.0.63

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.
@@ -4,4 +4,5 @@
4
4
  |-----|---------|-------|
5
5
  | [Library (1.0 Framework) Architecture](architecture.md) | `library` is the shared library repository for **all 1.0 (legacy) applications** — the `App_` framework. | library/_.php, library/app/, library/browser/ |
6
6
  | [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | `App_Api_Toga2` in `library/app/api/toga2.php` orchestrates bidirectional sync between TOGA 2 and TOGaDesk. | library/app/api/toga2.php |
7
+ | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem |
7
8
  | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php |
@@ -0,0 +1,321 @@
1
+ ---
2
+ title: NetSuite SuiteQL/REST API Reference
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-11
10
+ owners: ["dfranks"]
11
+ files:
12
+ - library/app/api/netsuite/rest.php
13
+ - library/ssl/netsuite_ec_key.pem
14
+ related:
15
+ - netsuite-suiteql-rest-shim.md
16
+ - ../architecture.md
17
+ - ../../worker/features/forecast2-netsuite-reconciliation.md
18
+ ---
19
+
20
+ ## Summary
21
+
22
+ General working reference for the Agilant NetSuite integration: how to authenticate, how
23
+ SuiteQL behaves, and the confirmed schema of the tables/columns/codes we query. The client class
24
+ is `App_Api_Netsuite_Rest` (`library/app/api/netsuite/rest.php`), split out of the old
25
+ `netsuite.php` ~2026-05-15. SOAP is deprecated — **production code must use REST/SuiteQL**; SOAP
26
+ is acceptable only for ad-hoc troubleshooting.
27
+
28
+ This doc is the **broad map** (mechanics, schemas, codes, performance). For the deep field
29
+ *semantics* that silently corrupt totals (sign conventions, `tl.id`==line, ShipItem NULL cost,
30
+ `iscogs` COGS lines, `createdFrom` non-determinism), see the companion
31
+ `netsuite-suiteql-rest-shim.md`.
32
+
33
+ ## Authentication
34
+
35
+ - **REST OAuth 2.0 / JWT client-credentials.** The client assertion is signed with an EC private
36
+ key at `library/ssl/netsuite_ec_key.pem`. Token lifetime is **3600s**; the adapter auto-refreshes
37
+ ~60s before expiry (`TOKEN_EXPIRATION_SAFETY_SECONDS`).
38
+ - The `certificateId` (the JWT `kid`) comes from the NetSuite integration record. The **worker**
39
+ config (`worker/config.*.ini` `[netsuite]` section) holds the real value; `worker2/Config/` ships
40
+ a placeholder `CERTIFICATE_ID_HERE` that `initialize()` explicitly rejects.
41
+ - `readConfig()` supports two framework contexts: 1.0 via `App_Registry::get('config')['netsuite']`,
42
+ and 2.0 via `_Config::netsuite()`.
43
+
44
+ ## Bootstrapping the client from a standalone script
45
+
46
+ To use the client outside a normal app entry point (e.g. a `test/@dave` CLI):
47
+
48
+ ```php
49
+ $_SERVER['SCRIPT_FILENAME'] = realpath(__DIR__ . '/../../worker') . DIRECTORY_SEPARATOR . '_approot_anchor.php';
50
+ chdir(__DIR__ . '/../../worker'); // REQUIRED — OAuth cert / certificateId resolves relative to worker/
51
+ require_once('_.php');
52
+ App_Framework_Worker::initialize();
53
+ App_Api_Netsuite_Rest::authenticate(); // acquire OAuth2 token once
54
+ ```
55
+
56
+ - **`chdir` into `worker/` is mandatory** — without it the OAuth `certificateId`/cert path won't
57
+ resolve and `authenticate()` throws.
58
+ - In our usage the client is effectively **read-only**: only `GET` (record fetch) and `POST`
59
+ (SuiteQL) are exercised. `send()` accepts any verb (incl. PATCH/PUT) but we don't write through it.
60
+
61
+ ### The `send()` logging trap (dev laptop)
62
+
63
+ `App_Api_Netsuite_Rest::send()` auto-authenticates but does **not** call `setLogging(false)`. From a
64
+ dev laptop the API-log DB write then fails and the framework error handler reports a **misleading
65
+ "Could not connect to database 'Vision'"** that masks the real result. Workaround: `authenticate()`
66
+ once, then issue your own `App_ApiTransaction` with logging off:
67
+
68
+ ```php
69
+ $url = rtrim(App_Api_Netsuite_Rest::$endpoint, '/') . App_Api_Netsuite_Rest::API_BASE_PATH
70
+ . '/query/v1/suiteql?limit=1000&offset=0';
71
+ $req = new App_ApiTransaction('POST', $url, ['q' => $sql], App_ApiTransaction::ENCODE_JSON);
72
+ $req->setLogging(false);
73
+ $req->addHeader('Authorization', 'Bearer ' . App_Api_Netsuite_Rest::$accessToken);
74
+ $req->addHeader('Accept', 'application/json');
75
+ $req->addHeader('Content-Type', 'application/json');
76
+ $req->addHeader('Prefer', 'transient'); // required for SuiteQL
77
+ $resp = $req->execute(false);
78
+ ```
79
+
80
+ In production (worker/EB) `send()` is fine — the Logs DB is reachable there.
81
+
82
+ ## SuiteQL
83
+
84
+ - **Endpoint:** `POST {base}/query/v1/suiteql?limit=1000&offset=N`, body `{"q": "<sql>"}`.
85
+ - **`Prefer: transient` header is required** (avoids server-side cursor overhead / errors).
86
+ - **Pagination:** max `limit=1000`; the response carries `items[]` and a boolean **`hasMore`**. Loop
87
+ `offset += 1000` until `hasMore` is false. The private `suiteqlListAll()` helper in `rest.php` does
88
+ exactly this.
89
+ - **Session timezone is `America/New_York`** — `CURRENT_DATE`/`CURRENT_TIMESTAMP`, `TO_TIMESTAMP()`,
90
+ and `lastmodifieddate` all resolve in ET (−04:00/−05:00), distinct from the account's "Pacific"
91
+ setting. Confirmed empirically: `SYSDATE` is −07:00 but the date columns are ET. **Build window
92
+ bounds in ET.**
93
+ - **Date vs timestamp filters:** `trandate` is a **DATE** column — use
94
+ `TO_DATE('YYYY-MM-DD', 'YYYY-MM-DD')`. `lastmodifieddate` is a **TIMESTAMP** — use
95
+ `TO_TIMESTAMP('YYYY-MM-DD HH:MM:SS', 'YYYY-MM-DD HH24:MI:SS')`. `TO_DATE` rejects impossible
96
+ calendar dates (e.g. `2026-06-31`) with an opaque HTTP 400 — validate dates before building SQL.
97
+ - **Oracle-flavored functions work:** `TO_DATE`, `TO_TIMESTAMP`, `TO_CHAR(trandate,'YYYY')` /
98
+ `'YYYY-MM'` for bucketing, `NVL(...)`, and `BUILTIN.DF(field)` for a reference field's display
99
+ value (e.g. `BUILTIN.DF(entity)` → customer name, `BUILTIN.DF(status)` → "Pending Fulfillment") —
100
+ a cheap alternative to per-id GETs for display strings.
101
+ - **`ORDER BY` inside a `GROUP BY` query is rejected** with `Invalid or unsupported search`. Sort in
102
+ PHP after fetching all pages.
103
+ - **Correlated subqueries are supported** (used in `listSales()` for `orderLine` — see the
104
+ `previousTransactionLineLink` section).
105
+
106
+ ### Debugging: the framework swallows errors as an HTML page
107
+
108
+ A failing query (or any PHP error) often renders the framework's HTML "Oops" error page with a
109
+ Sentry Error ID instead of a usable message. To see the real cause from a CLI script:
110
+
111
+ - check `$req->responseCode` and print the raw body on a non-2xx, and/or
112
+ - register a shutdown handler: `register_shutdown_function(function () { print_r(error_get_last()); });`
113
+
114
+ `execute()` may return an already-decoded object **or** a string — normalize with
115
+ `json_decode(json_encode($resp), true)` and always check `responseCode` is 2xx first.
116
+
117
+ ## `transaction` table
118
+
119
+ ### Type codes (`transaction.type`)
120
+
121
+ | Code | Meaning |
122
+ |------|---------|
123
+ | `SalesOrd` | Sales Order |
124
+ | `CustInvc` | Invoice |
125
+ | `CashSale` | Cash Sale |
126
+ | `CashRfnd` | Cash Refund |
127
+ | `CustCred` | Credit Memo |
128
+ | `Opprtnty` | Opportunity |
129
+ | `PurchOrd` | Purchase Order |
130
+ | `ItemRcpt` | Item Receipt |
131
+ | `ItemShip` | Item Fulfillment |
132
+ | `InvAdjst` | Inventory Adjustment |
133
+
134
+ ### Status codes (`transaction.status` letter)
135
+
136
+ **Sales Orders:** `A` Pending Approval · `B` Pending Fulfillment · `C` Cancelled ·
137
+ `D` Partially Fulfilled · `E` Pending Billing/Partially Fulfilled · `F` Pending Billing ·
138
+ `G` Billed · `H` Closed. **"Open" sales orders = `B`, `D`, `E`, `F`.** Note `listSalesOrders()`
139
+ returns **all** statuses for a window (no server-side status filter) — callers must filter.
140
+
141
+ **Invoices:** `A` Open · `B` Paid In Full.
142
+ **Credit Memos:** `A` Open · `B` Fully Applied.
143
+ **Cash Sales:** `A` Unapproved Payment · `C` Deposited.
144
+ **Cash Refunds:** `Y` (the only status; empty string in SOAP).
145
+
146
+ ### Header columns present (✓ confirmed in SuiteQL)
147
+
148
+ `id`, `tranid`, `trandate`, `lastmodifieddate`, `type`, `status`, `entity`, `employee`,
149
+ `leadsource`, `terms`, `memo`, `otherrefnum`, `foreigntotal`/`total` (both return the same value),
150
+ `foreignamountunpaid` (AR balance — invoices only; NULL for SO/credit memo/cash sale), `duedate`,
151
+ `shipmethod`, `opportunity`, `shippingaddress` (numeric id → join `transactionShippingAddress`),
152
+ and custom fields (`CUSTBODY_END_CUSTOMER`, `CUSTBODY_STOCKING_ORDER`, …).
153
+
154
+ ### Header fields NOT in SuiteQL (✗ — require a per-id REST GET)
155
+
156
+ `shippingCost`, `taxTotal`, `subTotal`, `createdFrom` (also derivable from
157
+ `previousTransactionLineLink`, but non-deterministically — see shim doc), `trackingNumbers`,
158
+ `linkedTrackingNumbers`, and the **structured** `shippingAddress` object (SuiteQL gives only the id).
159
+
160
+ ## `transactionline` table
161
+
162
+ ### Key columns (confirmed present)
163
+
164
+ `transaction`, `linesequencenumber`, `uniquekey`, `id`, `item`, `itemtype`, `mainline`, `quantity`,
165
+ `foreignamount`, `rate`, `costestimate`, `class`, `memo`, `quantitybilled`, `quantitycommitted`,
166
+ `quantitypicked`, `quantityshiprecv`, `createdfrom`, `entity` (line-level customer), `location`,
167
+ `subsidiary`.
168
+
169
+ - **`mainline = 'F'`** selects detail (non-summary) lines; `mainline = 'T'` is the header row.
170
+ - **`tl.orderline` does NOT exist** — HTTP 400 `Field 'orderline' for record 'transactionLine' was
171
+ not found.` The originating SO line lives in `previousTransactionLineLink`.
172
+ - **Synthetic-line sentinels:** `tl.item` returns **negative ids** for synthetic rows (e.g. `-8` =
173
+ TaxGroup "-Not Taxable-"). Filter `tl.item > 0`, and exclude `itemtype NOT IN ('ShipItem',
174
+ 'TaxItem','TaxGroup','Discount','EndGroup','BeginGroup','Subtotal','Description','Markup',
175
+ 'Payment')`.
176
+ - Sign conventions and the `tl.id`==`line` rule are detailed in `netsuite-suiteql-rest-shim.md`.
177
+
178
+ ### Line filtering for revenue queries
179
+
180
+ - `tl.mainline = 'F'` and `tl.item > 0` plus the itemtype denylist above.
181
+ - For a **product vs shipping** split, also exclude `'ShipItem'`/`'Discount'` from product and sum
182
+ `ShipItem` separately.
183
+ - **Open-order (in-flight) product revenue:** `((-tl.quantity) - NVL(tl.quantitybilled,0)) * tl.rate`;
184
+ shipping term `SUM(-tl.foreignamount)` over `tl.itemtype = 'ShipItem'`.
185
+
186
+ ## `previousTransactionLineLink` table
187
+
188
+ The bridge between a child line (e.g. invoice) and its originating parent line (e.g. SO).
189
+
190
+ | Column | Meaning |
191
+ |--------|---------|
192
+ | `nextdoc` / `nextline` / `nexttype` | Child transaction id / its `linesequencenumber` / type (e.g. `CustInvc`) |
193
+ | `previousdoc` / `previousline` / `previoustype` | Parent id / its `linesequencenumber` (= SOAP `orderLine`) / type (e.g. `SalesOrd`) |
194
+ | `linktype` | e.g. `OrdBill`, `OrdRvCom`, `OppClose` |
195
+
196
+ **SOAP `orderLine` for an invoice line:**
197
+
198
+ ```sql
199
+ SELECT MIN(potl.previousline)
200
+ FROM previoustransactionlinelink potl
201
+ WHERE potl.nextdoc = <invoice_id>
202
+ AND potl.nextline = <linesequencenumber>
203
+ AND potl.previoustype = 'SalesOrd'
204
+ ```
205
+
206
+ Filter `previoustype = 'SalesOrd'` to exclude the `OppClose` → Opportunity link; `MIN()` collapses
207
+ the multiple link types (`OrdBill`, `OrdRvCom`) that reference the same line. For `createdFrom`, the
208
+ REST record GET is authoritative — the mainline link is ambiguous (see shim doc).
209
+
210
+ ## `transactionShippingAddress` table
211
+
212
+ `transaction.shippingaddress` is a numeric id → join here **on `nkey`** (`WHERE id = ...` does NOT
213
+ work; the pk is `nkey`). Columns: `addr1`, `addr2`, `addressee`, `attention`, `city`, `state`
214
+ (two-letter), `country` (two-letter), `zip`, `addrtext` (formatted full-address blob).
215
+
216
+ ## `item` table
217
+
218
+ `itemtype` (+ `subtype` / `isserialitem`) → REST shim `_recordType` / SOAP class:
219
+
220
+ | `itemtype` + qualifier | `_recordType` |
221
+ |------------------------|---------------|
222
+ | `InvtPart` + `isserialitem=T` | `serializedInventoryItem` |
223
+ | `InvtPart` + `isserialitem=F` | `inventoryItem` |
224
+ | `NonInvtPart` + `Sale` / `Resale` / `Purchase` | `nonInventorySaleItem` / `nonInventoryResaleItem` / `nonInventoryPurchaseItem` |
225
+ | `OthCharge` + `Sale` / `Resale` | `otherChargeSaleItem` / `otherChargeResaleItem` |
226
+ | `Service` + `Sale` / `Resale` | `serviceSaleItem` / `serviceResaleItem` |
227
+ | `Discount` / `Kit` / `Group` / `Description` | `discountItem` / `kitItem` / `itemGroup` / `descriptionItem` |
228
+ | `Expense` | **skip** — internal; no SOAP equivalent |
229
+
230
+ `item.description` = SOAP `salesDescription`; `item.purchasedescription` = SOAP
231
+ `purchaseDescription`. There is **no** SuiteQL `salesdescription` column.
232
+
233
+ ## `customer` table
234
+
235
+ - `BUILTIN.DF(custentity10)` = display name of the **Consolidated Customer** custom field (CF id
236
+ 4968, script id `custentity10`); SOAP `customFieldList` CF internalId 4968 maps to this.
237
+ - `entityid` and `companyname` are split in SuiteQL; SOAP concatenated them as
238
+ `"<entityid> <companyname>"`. When they're equal, return the value once (don't duplicate).
239
+
240
+ ## Custom fields
241
+
242
+ | NS internalId | Script id | Description |
243
+ |---------------|-----------|-------------|
244
+ | 3149 | `CUSTBODY_END_CUSTOMER` | End Customer (on transaction) |
245
+ | 7097 | `CUSTBODY_STOCKING_ORDER` | Stocking Order flag |
246
+ | 4968 | `custentity10` | Consolidated Customer (on customer record) |
247
+ | 6840 | `custbody_forecast_category` | Forecast Category (on opportunity) |
248
+ | 6841 | `custbody_sales_stage` | Sales Stage (on opportunity) |
249
+ | 3942 | `custbody_ctc_account` (`CF_ACCOUNT`) | Account |
250
+ | 6579 | `custbody_ctc_business_unit` (`CF_BUSINESS_UNIT`) | Business Unit |
251
+
252
+ ## REST per-id record GET
253
+
254
+ Used for fields SuiteQL doesn't expose on `transaction`: `shippingCost`, `taxTotal`, `subTotal`,
255
+ `amountRemaining` (SuiteQL `foreignamountunpaid` is the equivalent), `trackingNumbers` /
256
+ `linkedTrackingNumbers`, the structured `shippingAddress`, the `createdFrom` reference, and the
257
+ **stable `line` number** on the record sublist (`line` ↔ `lineUniqueKey`).
258
+
259
+ - **Stable line numbers — the critical one.** The REST record's `item.items[*].line` (via
260
+ `lineUniqueKey`) is the **authoritative** source for `Forecast.Sales.lineNumber`. SuiteQL
261
+ `linesequencenumber` can diverge from REST `line` for SOAP-era records (confirmed empirically),
262
+ so keying on `linesequencenumber` risks duplicate Sales rows that can't be matched or deleted.
263
+ **`listSales()` keeps the per-id GET for this reason — do not eliminate it.**
264
+
265
+ ## `list*()` helpers — behavior & cost
266
+
267
+ `listSales()`, `listSalesOrders()`, `listOpportunities()` all:
268
+
269
+ 1. **Filter on `lastmodifieddate`, NOT `trandate`.** A record dated long ago but edited recently IS
270
+ returned; a recently-dated but unmodified record is NOT. (Trips up any local lookup that windows
271
+ on transaction date.)
272
+ 2. Fetch headers via paginated SuiteQL, then — for sales / sales-orders — do a **per-id REST GET per
273
+ header** for the GET-only fields above, then fetch lines in `id` chunks of 500.
274
+
275
+ **Cost implication:** runtime scales with the number of records *modified* in the window (open +
276
+ closed) because of the per-id GET, not with the number you care about — a wide window is expensive.
277
+ `listOpportunities()` has no per-id GET, so it runs in minutes regardless of window.
278
+
279
+ ### Measured volumes / latency (Agilant account, 2024–2026)
280
+
281
+ | Metric | Value |
282
+ |--------|-------|
283
+ | Heaviest month (Aug 2024) | 14,795 transactions / 69,802 item lines |
284
+ | Typical heavy month | ~4,000–6,000 transactions |
285
+ | SuiteQL header page (1000 rows) | ~1.5s |
286
+ | SuiteQL line chunk (500 txns, 1000 rows) | ~1.4s |
287
+ | SuiteQL link chunk (500 txns, 1000 rows) | ~0.5s |
288
+ | Per-id REST GET (one record) | ~100–150ms |
289
+ | `listSales()` — typical month (5k txns) | ~1.5–2h (GET-bound) |
290
+ | `listSales()` — Aug 2024 (14.8k txns) | ~4–6h (GET-bound) |
291
+ | `listOpportunities()` — same window | minutes (no GET) |
292
+
293
+ SalesOrd per-id GET counts by `lastmodifieddate` window: ~16,600 (90d) / ~61,900 (365d) / ~145,700
294
+ (3yr) — vs only ~13,500 currently-open SOs, i.e. a 3-year window does ~10× the work of the target
295
+ set. Fields that *could* move to the header query (eliminating most GETs): `total`,
296
+ `foreignamountunpaid`, `dueDate`, `terms`, `shipMethod`, `opportunity`, `shippingAddress`. Fields
297
+ that **cannot**: `shippingCost`, `taxTotal`, `subTotal`, `trackingNumbers`, and the stable `line`
298
+ numbers. Budget hours for multi-year runs and launch them under `nohup`/`tmux`.
299
+
300
+ ## Gotchas / known issues
301
+
302
+ - **PHP 7.2 prod constraint.** `library`/`worker` run PHP 7.2 (Amazon Linux 1 EB). Banned syntax:
303
+ arrow functions (`fn() =>`), typed properties (`public int $x`), `??=`, `match()`, numeric
304
+ separators (`1_000`). Lint with `C:\xampp7\php\php.exe -l` before deploying — `C:\xampp8\php`
305
+ (PHP 8.0) is for running probes on the laptop only, **not** for compat checking.
306
+ - Field/relationship availability varies by NetSuite account **and** record type — probe the live
307
+ account before assuming a column exists.
308
+
309
+ ## Change history
310
+
311
+ - 2026-06-11 — Initial reference captured from live-probe notes (TRUE-79183): SuiteQL mechanics,
312
+ ET session timezone, transaction/transactionline/previousTransactionLineLink/shippingAddress/item
313
+ schemas, type & status codes, custom-field map, REST GET-only fields, `list*()` cost model and
314
+ measured volumes. (dfranks)
315
+
316
+ ## Related docs
317
+
318
+ - `netsuite-suiteql-rest-shim.md` — deep field semantics (signs, `tl.id`==line, `iscogs`, ShipItem
319
+ cost, `createdFrom`) that sit on top of these mechanics.
320
+ - `../../worker/features/forecast2-netsuite-reconciliation.md` — the audit/trueup tooling that
321
+ consumes this API.
@@ -11,6 +11,7 @@ owners: [dfranks]
11
11
  files:
12
12
  - library/app/api/netsuite/rest.php
13
13
  related:
14
+ - netsuite-suiteql-api-reference.md
14
15
  - ../architecture.md
15
16
  - ../../worker/features/forecast2-netsuite-reconciliation.md
16
17
  ---
@@ -113,5 +114,7 @@ None — uniform across clients (NetSuite is a single shared account).
113
114
 
114
115
  ## Related docs
115
116
 
117
+ - `netsuite-suiteql-api-reference.md` — the broad API reference (auth, SuiteQL mechanics, table
118
+ schemas, type/status codes, custom-field map, performance) these semantics sit on top of.
116
119
  - `../architecture.md` — Library core architecture.
117
120
  - `../../worker/features/forecast2-netsuite-reconciliation.md` — the tooling that applies these.
@@ -3,4 +3,4 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [Worker (1.0 Framework) Architecture](architecture.md) | `worker` is the legacy (**1.0** `App_` framework) **background-job tier**. | worker/index.php, worker/_/app/framework.php, worker/crons/, worker/schedules/, worker/ebs/cron.worker.php, worker/.ebextensions/035_cron.worker.config |
6
- | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/reconcile_netsuite_totals.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/trueup_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
6
+ | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/reconcile_netsuite_totals.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
@@ -13,10 +13,13 @@ files:
13
13
  - test/@dave/analyze_netsuite_forecast_diff.php
14
14
  - test/@dave/trueup_sales.php
15
15
  - test/@dave/trueup_open_orders.php
16
+ - test/@dave/trueup_opportunities.php
17
+ - test/@dave/probe_sales_gap_direct.php
16
18
  - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
17
19
  related:
18
20
  - ../architecture.md
19
21
  - ../../library/features/netsuite-suiteql-rest-shim.md
22
+ - ../../library/features/netsuite-suiteql-api-reference.md
20
23
  ---
21
24
 
22
25
  ## Summary
@@ -38,6 +41,11 @@ by reconciling a chosen tranDate range directly against NetSuite.
38
41
  match NetSuite for a tranDate range (insert/update/delete per line).
39
42
  - `trueup_open_orders.php --from= --to= [--prod] [--dry-run] [--verbose]` — same for
40
43
  `Forecast.OpenOrderItems` (currently-open SOs whose tranDate falls in range).
44
+ - `trueup_opportunities.php --from --to [--prod] [--dry-run]` — same for `Forecast.Opportunities`
45
+ by tranDate range. Pure SuiteQL (no per-id GET), so it's fast: a 2026-YTD prod run reconciled
46
+ ~4,000 opps in ~13s to a $0 delta.
47
+ - `probe_sales_gap_direct.php` / `probe_open_orders_gap.php` — read-only NS-vs-FC2 revenue
48
+ breakdowns by type+month using **direct prod mysqli** (demonstrate the sandbox-dev bypass below).
41
49
 
42
50
  ## How it works
43
51
 
@@ -92,11 +100,36 @@ None — Forecast2 is a single shared dataset.
92
100
  - **Always dry-run prod first** and confirm the change counts are real drift, not artifacts —
93
101
  e.g. 2023-01-03 showed 240 "updates" that were entirely createdFrom backfill + profit float-dust
94
102
  (zero real drift); after the fixes it correctly reports `unchanged=240`.
103
+ - **`App_Database` routes *reads* to sandbox-dev on dev laptops.** On the laptop,
104
+ `App_Database::query('db_forecast2')` resolves (via `config.dev-*.ini`) to the **sandbox-dev**
105
+ database, not prod — so any probe using the framework DB layer shows zero rows for
106
+ `Forecast.Sales` even though prod has data. Read probes must bypass the ini and connect directly
107
+ to the **core2 reader** (`reader1.core.database.togahub.com`, creds in `CLAUDE.md` /
108
+ `worker/config.worker.ini`). `reconcile_netsuite_totals.php` and `probe_sales_gap_direct.php`
109
+ already do this. (This is the read-side mirror of the `--prod` write override above.)
110
+ - **`reconcile`'s Open Orders figure is a live snapshot**, not a tranDate-bounded query: it sums
111
+ **all currently-open SOs** regardless of date. Comparing that against the full `OpenOrderItems`
112
+ table shows a large apparent gap driven by records that will never sync, **not** by drift:
113
+ - **Future-dated SOs** (trandate 2027–2029) — NetSuite data-entry errors (wrong year). The
114
+ backfill window doesn't extend into the future, so they never sync. Flag to whoever owns
115
+ NetSuite data; not a code bug.
116
+ - **Pre-2024 legacy open SOs** — ordered before the sync window.
117
+ When a user asks about a date range (e.g. 2026 YTD), **filter the open-orders comparison to that
118
+ trandate window manually** — within the normal window (2024→present) the delta is rounding-only.
119
+ - **Legacy credit-memo wrong-sign rows.** Some `Forecast.Sales` credit-memo/cash-refund rows were
120
+ stored with **flipped signs** by a prior sync version (positive instead of negative), inflating
121
+ revenue. `trueup_sales.php` repairs them: it compares against NS by trandate, detects the sign
122
+ mismatch, and delete+re-inserts with the correct sign. See the shim/reference docs for why a
123
+ uniform `-foreignamount` is correct and an extra per-type `$factor` double-negates.
95
124
  - These tools live in `test/@dave/` (developer tooling), but `trueup_open_orders` has been run
96
125
  against production. The `Defaults`/checkpoint mechanics of the scheduled sync are separate.
97
126
 
98
127
  ## Change history
99
128
 
129
+ - 2026-06-11 — Documented the `App_Database` sandbox-dev read pitfall (bypass with direct core2
130
+ mysqli), `reconcile`'s open-orders live-snapshot semantics (future-dated + pre-2024 legacy SOs
131
+ explain apparent gaps), the legacy credit-memo wrong-sign repair, and added `trueup_opportunities`
132
+ + the gap-probe tools. (dfranks)
100
133
  - 2026-06-11 — `trueup_sales` rewritten to bulk SuiteQL (`fetchSalesBulk`, ~300× faster);
101
134
  `amountDue` + `createdFrom` dropped; float-dust fixed; `--prod` flag added. `reconcile`/`analyze`
102
135
  profit queries `NVL(costestimate,0)` — established the $663K gap was an audit artifact and the
@@ -4,7 +4,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
4
4
 
5
5
  ## 1.0 framework
6
6
 
7
- - **library** (Library) _(framework core)_ — 3 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 4 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 3 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
 
10
10
  ## 2.0 framework
@@ -71,7 +71,28 @@ USA and Canada share the parent handler unchanged.
71
71
  number onto the line). Example seen on SA132739: UPS `1Z8696XA0193326215` actually shipped
72
72
  `C40QYUC-1` (SO line 4) but was attached to SO line 1 (`1000555`).
73
73
 
74
+ - **Over-fulfillment (fulfilled qty > ordered) is the other visible symptom of the same
75
+ scramble.** When a spuriously-linked PO item ships, its ASN/PO-driven ItemFulfillment walks
76
+ the bad bridge link and creates a **phantom `ItemFulfillmentItem` on the wrong SO line**, so
77
+ that line shows more fulfilled than ordered (the same shipment is also fulfilled correctly on
78
+ its real line). Cleanup per order:
79
+ - **Spurious bridge rows:** delete where the SO item's part ≠ the PO item's part, scoped to
80
+ the one `salesOrderId` (`soi.itemId <> vi.itemId` via `VendorItems`). Idempotent.
81
+ - **Phantom IFI:** the duplicate on the over-fulfilled line is the IFI with **no
82
+ `ItemFulfillmentItemUnits`, no `ItemFulfillmentItems_TrackingNumbers`, and nothing
83
+ mirroring it** (`upstreamItemFulfillmentItemId`). The legitimate line fulfillment is the
84
+ sibling IFI that has a real `Unit` and/or a downstream mirror (often in an auto-numbered
85
+ `F#####` IF rather than the SO-named IF). Delete the phantom guarded by
86
+ `id + salesOrderItemId + itemFulfillmentId + quantity`.
87
+ - These are pre-fix orders only (POs created before the 2026-06-11 guard). Cleanup is
88
+ one-time data, written as a dated `dbchanges2/Client_Compass/` file per order.
89
+ - ⚠ Not every over-fulfilled Compass line is this bug — high multipliers (5×–20×) seen on
90
+ other orders (e.g. SA114091 25→125) do not fit the split-PO 2× pattern and are a separate,
91
+ uninvestigated cause.
92
+
74
93
  ## Change history
94
+ - 2026-06-11 — Documented the over-fulfillment symptom and the phantom-IFI cleanup predicate;
95
+ remediated SA132657 and SA132641 (one dated dbchanges2 file each). (jcardinal)
75
96
  - 2026-06-11 — Guarded `postPost` SO↔PO item linking to MR orders only; SA links come solely
76
97
  from `prePost`'s `createdFromSalesOrderItem` remap. Fixes spurious cross-part links. (jcardinal)
77
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.61",
3
+ "version": "1.0.63",
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",