toga-ai 1.0.207 → 1.0.209

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
  | [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 |
8
8
  | [Branded HTML Email Templates (App_Email_Template)](features/email-templates.md) | `App_Email_Template` (`app/email/template.php`) is the base class for branded HTML emails in the 1.0 (`App_`) framework. | library/app/email/template.php, library/app/email/agilant.php |
9
9
  | [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t | library/app/framework.php, library/app/frameworkindex.php, library/app/mvc.php, library/app/database.php, library/app/model.php, library/app/config.php |
10
- | [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 |
10
+ | [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, test/@dave/Junk Drawer/nsq.php |
11
11
  | [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 |
12
12
  | [Startech PC Matic B2B Sync (library)](features/startech-pcmaticb2b-sync.md) | `library/app/api/toga2.php` handles bidirectional ticket sync for PC Matic B2B between TOGaDesk 1.0 and TOGA 2.0. | library/app/api/toga2.php |
13
13
  | [App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge](features/toga2-api-client-and-bridge.md) | `App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross | library/app/api/toga2.php, worker/crons/toga2/aig/sync_togasupply_aig.php, worker/crons/toga2/wje/sync_togasupply_wje.php |
@@ -6,11 +6,12 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-11
9
+ updated: 2026-06-25
10
10
  owners: ["dfranks"]
11
11
  files:
12
12
  - library/app/api/netsuite/rest.php
13
13
  - library/ssl/netsuite_ec_key.pem
14
+ - test/@dave/Junk Drawer/nsq.php
14
15
  related:
15
16
  - netsuite-suiteql-rest-shim.md
16
17
  - ../architecture.md
@@ -183,6 +184,30 @@ and custom fields (`CUSTBODY_END_CUSTOMER`, `CUSTBODY_STOCKING_ORDER`, …).
183
184
  - **Open-order (in-flight) product revenue:** `((-tl.quantity) - NVL(tl.quantitybilled,0)) * tl.rate`;
184
185
  shipping term `SUM(-tl.foreignamount)` over `tl.itemtype = 'ShipItem'`.
185
186
 
187
+ ### Journal Entry lines — the `mainline` rule INVERTS
188
+
189
+ Journal Entries break the `mainline='F' = detail / mainline='T' = header` rule above — **all JE
190
+ `transactionline` rows have `mainline='T'`** (there is no header/`mainline='F'` split). A revenue
191
+ query that filters `mainline='F'` over a JE returns **empty**. When querying JE lines, do **not**
192
+ filter `mainline='F'`. Confirmed by probing accrual JE #6962 (internalId 7128398), reclass JE #6967
193
+ (7128548), and 15 recent revenue JEs.
194
+
195
+ Other JE line facts (same probes):
196
+ - **`tl.salesrep` does NOT exist** on `transactionline` — selecting it is an HTTP 500. (There is no
197
+ line-level sales-rep dimension on a JE.)
198
+ - **`tl.item` is NEVER populated on JE revenue/cost lines** (`COUNT(item) = 0` across every sampled
199
+ JE) — JEs post to accounts, not items.
200
+ - The only line-level dimensions present on a JE are **`class`** and **`entity`** (customer), and
201
+ only when the preparer fills them in.
202
+ - Revenue line = `accttype IN ('Income','OthIncome')`; cost line = `accttype = 'COGS'`.
203
+ - An auto-created **reversing JE** (`isreversal='T'`) fires **NO webhook** — if a sync consumes JEs it
204
+ must synthesize the reversal locally.
205
+
206
+ > **Deferral decision (2026-06-25):** adding Journal Entries as a 5th `Forecast.Sales` transaction
207
+ > type is **DEFERRED**. The intended `(salesRep, item)` grouping is **impossible** on JE lines until a
208
+ > NetSuite custom field supplying those dimensions is created and populated (no `salesrep` column, no
209
+ > `item`). Re-evaluate only once that custom field exists — don't re-probe this from scratch.
210
+
186
211
  ## `previousTransactionLineLink` table
187
212
 
188
213
  The bridge between a child line (e.g. invoice) and its originating parent line (e.g. SO).
@@ -308,6 +333,15 @@ numbers. Budget hours for multi-year runs and launch them under `nohup`/`tmux`.
308
333
 
309
334
  ## Change history
310
335
 
336
+ - 2026-06-25 — **Documented Journal Entry line structure + the JE→Forecast.Sales deferral.** JEs
337
+ invert the `mainline` rule — **all** JE `transactionline` rows are `mainline='T'` (no `'F'` detail
338
+ split), so a `mainline='F'` revenue query over a JE returns empty. Also: `tl.salesrep` does not
339
+ exist (HTTP 500), `tl.item` is never populated on JE lines (post to accounts, not items), the only
340
+ line dims are `class`/`entity` (when filled), revenue = `accttype IN ('Income','OthIncome')` / cost
341
+ = `'COGS'`, and an auto-reversing JE (`isreversal='T'`) fires no webhook. Recorded the decision to
342
+ **defer** adding JEs as a 5th `Forecast.Sales` type — the intended `(salesRep, item)` grouping is
343
+ impossible until a NetSuite custom field supplies those dims. Probed via `test/@dave/Junk
344
+ Drawer/nsq.php` against accrual JE #6962, reclass #6967, and 15 recent revenue JEs. (dfranks)
311
345
  - 2026-06-11 — Initial reference captured from live-probe notes (TRUE-79183): SuiteQL mechanics,
312
346
  ET session timezone, transaction/transactionline/previousTransactionLineLink/shippingAddress/item
313
347
  schemas, type & status codes, custom-field map, REST GET-only fields, `list*()` cost model and
@@ -6,3 +6,4 @@
6
6
  | [Tools — Developers Folder (UUID & Password Generators)](features/developer-tools.md) | The first two tools shipped in the Tools app, both under the **Developers** folder and gated to personas **Development Team** / **TOGa Technology**. | tools/mvc/developers/uuid/get.php, tools/mvc/developers/password/get.php |
7
7
  | [Tools Persona-Gated Navigation (App_Nav)](features/persona-gated-navigation.md) | `App_Nav` is the Tools app's two-level, **persona-gated** navigation. | tools/_/app/nav.php |
8
8
  | [Tools SAML SSO Consumer & Persona-Gated Auth (App_Auth)](features/saml-sso-auth.md) | `App_Auth` is the Tools app's authentication layer: it consumes the SAML gateway `?saml=` handoff (see the 2.0 SAML downstream integration contract), establishe | tools/_/app/auth.php, tools/mvc/sso/get.php, tools/mvc/login/get.php, tools/mvc/login/post.php, tools/mvc/logout/get.php |
9
+ | [Deploying Tools to Elastic Beanstalk (PHP 8.5 / Amazon Linux 2023)](workflows/deploy-to-elastic-beanstalk-al2023.md) | How the **Tools** 1.0 app boots on Elastic Beanstalk running `PHP 8.5 on 64bit Amazon Linux 2023/4.13.1 (aarch64)`. | tools/.ebextensions/004_http_to_https.config, tools/.ebextensions/006_mount-s3fs.config, tools/.ebextensions/007_setup_export_cache_folders.config, tools/.ebextensions/008_setup_ldap.config, tools/.ebextensions/009_setup_phpini.config, tools/.ebextensions/020_setup_git_libraries.config, tools/.ebextensions/050_register_instance_to_shared_application_load_balancer.config, tools/ebs/git.json |
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: Deploying Tools to Elastic Beanstalk (PHP 8.5 / Amazon Linux 2023)
3
+ framework: "1.0"
4
+ repo: tools
5
+ project: Tools
6
+ client: shared
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-06-26
10
+ owners: [jcardinal]
11
+ files:
12
+ - tools/.ebextensions/004_http_to_https.config
13
+ - tools/.ebextensions/006_mount-s3fs.config
14
+ - tools/.ebextensions/007_setup_export_cache_folders.config
15
+ - tools/.ebextensions/008_setup_ldap.config
16
+ - tools/.ebextensions/009_setup_phpini.config
17
+ - tools/.ebextensions/020_setup_git_libraries.config
18
+ - tools/.ebextensions/050_register_instance_to_shared_application_load_balancer.config
19
+ - tools/ebs/git.json
20
+ related:
21
+ - ../architecture.md
22
+ ---
23
+
24
+ ## Summary
25
+
26
+ How the **Tools** 1.0 app boots on Elastic Beanstalk running
27
+ `PHP 8.5 on 64bit Amazon Linux 2023/4.13.1 (aarch64)`. The EB platform was upgraded from
28
+ Amazon Linux 2 (AL2) to Amazon Linux 2023 (AL2023), which changes package names, the proxy
29
+ (nginx instead of Apache), the PHP ini scan directory, and how credentials reach the
30
+ instance. The `.ebextensions/*.config` files run alphabetically and are merged by EB; getting
31
+ them wrong leaves the app booting without `/var/www/library` and serving 500s on every
32
+ request. This doc is the migration checklist and the durable AL2023 gotchas learned doing it.
33
+
34
+ ## AL2023 package migration checklist
35
+
36
+ When migrating any 1.0 app's `.ebextensions` from AL2 to AL2023, rename/remove these yum
37
+ packages (AL2023 uses different names for the same libraries):
38
+
39
+ - `libstdc++48` → `libstdc++`
40
+ - `php73-ldap` → `php-ldap` (in `008_setup_ldap.config`)
41
+ - **Remove `libcurl: []` entirely** — AL2023 ships `libcurl-minimal` pre-installed, and adding
42
+ `libcurl` to the yum packages list causes a package-conflict error that fails the deploy.
43
+
44
+ ## How it works
45
+
46
+ ### PHP ini — write the file directly, do not run a script
47
+
48
+ `009_setup_phpini.config` writes `/etc/php.d/application.ini` with a cfn-init **`files:`**
49
+ block, **not** a container_command that runs a PHP script. On AL2023, `/etc/php.d/` is the
50
+ PHP ini scan directory, and cfn-init `files:` runs before container_commands and before
51
+ PHP-FPM starts, so the file is guaranteed present at startup with no PHP execution needed.
52
+
53
+ The old approach ran `ebs/setup_phpini.php` (now superseded) which called
54
+ `php_ini_scanned_files()`, parsed it to find the scan dir, and wrote there. On AL2023 that
55
+ failed silently (it was wrapped in `ignoreErrors: true`), so `include_path` never picked up
56
+ `/var/www/library`, producing fatal `require_once('_.php')` errors at runtime.
57
+
58
+ The `application.ini` sets `include_path = ".:/var/www/library"`, upload/post limits (16M),
59
+ `memory_limit = 2G`, `display_errors`, the `redis.so` extension, and the rediscluster session
60
+ handler (`session.save_path` points at the ElastiCache cluster cfg endpoint). Session GC
61
+ maxlifetime is 4 days.
62
+
63
+ ### Cloning the libraries — bash, not the CodePipeline PHP script
64
+
65
+ `020_setup_git_libraries.config` clones the framework libraries onto the instance. The old
66
+ `setup_git_libraries.php` (downloaded from S3) requires a DB connection to the `Logs` database
67
+ and a matching row in the `CodePipelineEnvironments` table; for a new app with **no**
68
+ CodePipeline it exits silently without cloning anything, so the app boots without
69
+ `/var/www/library` and 500s on every request.
70
+
71
+ The replacement is pure bash via a cfn-init `files:` block that writes
72
+ `/tmp/clone_git_repos.sh` (mode `000755`) plus a `container_commands` entry that runs
73
+ `bash /tmp/clone_git_repos.sh`. It clones:
74
+
75
+ - `agilantsolutions/library` branch `_production` → `/var/www/library`
76
+ - `agilantsolutions/resources` branch `_production` → `/var/app/ondeck/resources`
77
+
78
+ `ebs/git.json` was corrected to match: branch `_production` for both repos and the resources
79
+ path set to `/var/app/ondeck/resources`.
80
+
81
+ ### Duplicate config neutralization
82
+
83
+ EB merges every `.config` alphabetically; two configs with **identical** `container_commands`
84
+ keys silently overwrite each other, causing unpredictable failures (the redis build ran twice;
85
+ an http→https collision obscured failure diagnosis). Two duplicate pairs were neutralized
86
+ (replaced with a comment), keeping the canonical one of each:
87
+
88
+ - `004_enforce_http_to_https.config` (dup of `004_http_to_https.config`) — neutralized
89
+ - `010_setup_redis_sessions.config` (dup of `010_setup_redis.config`) — neutralized
90
+
91
+ ### ignoreErrors on non-fatal infrastructure commands
92
+
93
+ Several `container_commands` write to system paths that do not exist on AL2023 (e.g.
94
+ `/etc/httpd/conf.d/` — AL2023 EB uses **nginx**, not Apache, as the reverse proxy). These were
95
+ marked `ignoreErrors: true` in `004`, `006`, `007`, `009`, and `050` so a non-fatal failure
96
+ does not block the deploy. This also covers `006`'s `01_credentials` step: modern EB uses IAM
97
+ roles, so `AWS_SECRET_KEY` / `AWS_ACCESS_KEY_ID` are not injected into `$_SERVER` and the
98
+ s3fs credential write fails cleanly. Use `ignoreErrors` only on genuinely non-fatal infra
99
+ setup — the git clone (`020`) deliberately does **not** use it (see gotchas).
100
+
101
+ ## Gotchas (durable AL2023 / EB rules)
102
+
103
+ - **Write new-deploy files to `/var/app/ondeck/<path>`, never `/var/www/html/<path>` during
104
+ `container_commands`.** While container_commands run, `/var/www/html` still symlinks to the
105
+ **old** app. After the deploy swap, `/var/app/ondeck` becomes `/var/app/current`
106
+ (= `/var/www/html`). Writing to `/var/www/html` during container_commands writes into the
107
+ currently-running old app.
108
+ - **Only `/var/log/eb-activity.log` (and `cfn-init.log`) are captured in EB bundle snapshots.**
109
+ Custom log files under `/var/log/` are invisible unless you SSH the instance. Deploy scripts
110
+ should log to `/var/log/eb-activity.log`.
111
+ - **CodePipeline-dependent scripts are no-ops for apps without a pipeline.**
112
+ `setup_git_libraries.php` silently does nothing when the DB has no `CodePipelineEnvironments`
113
+ row — fatal for a brand-new app, since nothing gets cloned and the failure is silent.
114
+ - **Make clone failures block the deploy.** The bash clone script does `exit 1` on clone
115
+ failure so the deploy fails visibly, rather than succeeding and serving 500s.
116
+ - **PATH is unreliable in the cfn-init execution environment** — resolve binaries defensively,
117
+ e.g. `GIT=$(which git || echo /usr/bin/git)`.
118
+ - **cfn-init `files:` runs before `container_commands`** — the correct pattern for writing a
119
+ script and then executing it in the same config.
120
+
121
+ ## Change history
122
+ - 2026-06-26 — Documented the AL2 → AL2023 (PHP 8.5) EB migration for Tools: package renames
123
+ (`libstdc++48`→`libstdc++`, `php73-ldap`→`php-ldap`, drop `libcurl`), php ini via cfn-init
124
+ `files:` writing `/etc/php.d/application.ini`, bash-based library clone replacing the
125
+ CodePipeline PHP script, duplicate-config neutralization, `ignoreErrors` on non-fatal infra
126
+ commands, and the ondeck-path / eb-activity.log / pipeline-noop gotchas (jcardinal)
@@ -5,7 +5,7 @@
5
5
  | [Worker (1.0 Framework) Architecture](architecture.md) | `worker` is the legacy (**1.0** `App_` framework) **background-job tier**. | worker/index.php, worker/_/app/framework.php, worker/crons/, worker/schedules/, worker/ebs/cron.worker.php, worker/.ebextensions/035_cron.worker.config |
6
6
  | [Compass MA Sales Order Exception Report](features/compass-ma-sales-order-exception-report.md) | A worker cron that emails operations the "Compass Refresh Exception Report" — Compass `MA%` sales orders whose corresponding Office Depot (ODP) sales order has | worker/crons/toga2/compass/workflow/7_generate_ma_sales_order_exception_report.php |
7
7
  | [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php |
8
- | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/reconcile_netsuite_totals.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
8
+ | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
9
9
  | [NetSuite → TOGa Supply Per-Client Sync (thin wrappers)](features/netsuite-togasupply-per-client-sync.md) | Syncs NetSuite transactions (sales orders, purchase orders, invoices, item receipts, item fulfillments, inventory adjustments) into each TOGa Supply (2.0) clien | worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_canon.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql |
10
10
  | [Prudential: Send Shipments for the Day report (daily cron)](features/send-shipments-for-the-day.md) | Daily cron (9:00 PM) that emails Prudential and Dell stakeholders an Excel report of all devices shipped that day, including tracking number, serial number, emp | worker/crons/notifications/reports/send_shipments_for_the_day.php |
11
11
  | [Onboarding a Client to the NetSuite TOGa Supply Sync](workflows/onboarding-client-to-netsuite-togasupply-sync.md) | How to add a new TOGa 2 client to the per-client NetSuite → TOGa Supply importer (`worker/crons/toga2/netsuite/`). | worker/crons/toga2/netsuite/sync_togasupply.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql |
@@ -10,6 +10,7 @@ updated: 2026-06-25
10
10
  owners: [dfranks]
11
11
  files:
12
12
  - test/@dave/reconcile_netsuite_totals.php
13
+ - test/@dave/fixer.php
13
14
  - test/@dave/analyze_netsuite_forecast_diff.php
14
15
  - test/@dave/trueup_sales.php
15
16
  - test/@dave/trueup_open_orders.php
@@ -49,6 +50,15 @@ by reconciling a chosen tranDate range directly against NetSuite.
49
50
  bucket. The diffs are **gated** because they materialize every transaction/order in the window
50
51
  into PHP arrays and OOM on multi-year windows — default is summary-only; positional `[from] [to]`
51
52
  work alongside the flag.
53
+ - `fixer.php [--commit --prod]` — **self-contained** all-in-one delta finder + corrector across
54
+ **Sales + OpenOrderItems + Opportunities** in one tool (bootstraps the 1.0 framework; no shelling
55
+ out to the per-table trueups). **FIND** phase aggregates per-id diffs of all three categories,
56
+ NetSuite (SuiteQL) vs the prod `Forecast` reader, over a **fixed window** (current date back
57
+ through 2025-01-01); paginated `GROUP BY` made stable with `ORDER BY t.id`. **FIX** phase
58
+ (`fixSales`/`fixOpenOrders`/`fixOpportunities`) emulates the existing trueup logic **scoped to
59
+ only the discrepant ids**. **Default dry-run**; `--commit` requires `--prod` (both must be passed
60
+ to write). See the two baked-in gotchas (lazy self-reconnecting `fcReader()`; `buildDoubleFieldArray`
61
+ by-ref) and the **NS-deleted-invoice stale Sales row** failure mode below.
52
62
  - `analyze_netsuite_forecast_diff.php [from] [to]` — decomposes the delta **per transaction** into
53
63
  NS_ONLY (missing from FC), FC_ONLY (stale/extra), DRIFT (value differs). Read-only.
54
64
  - `trueup_sales.php --from --to [--chunk-days N] [--prod] [--dry-run]` — makes `Forecast.Sales`
@@ -143,6 +153,22 @@ None — Forecast2 is a single shared dataset.
143
153
 
144
154
  ## Gotchas / known issues
145
155
 
156
+ - **An NS-DELETED invoice leaves STALE cost-only `Forecast.Sales` rows the add/update-only import can
157
+ never remove.** The 5-min `import_sales.php` cron is **add/update-only — it has no delete path** (only
158
+ the nightly discrepancy-fix deletes). So when an invoice is **deleted in NetSuite**, its already-imported
159
+ `Forecast.Sales` lines survive as orphans — and they survive in a telltale shape: **`revenue = 0`,
160
+ `profit = -cost`** (the revenue side is gone but the cost line lingers), inflating a **profit-only**
161
+ delta with no matching revenue delta. `fixer.php` detects and **deletes** them. Real example: closed a
162
+ **$12,838.84 profit delta** on 2026-06-18 — 5 NS-deleted invoices, 33 stale lines. When a profit-only
163
+ gap localizes to invoices that **no longer exist in NetSuite**, this is the cause; deletion (not trueup
164
+ re-insert) is the fix.
165
+ - **`fixer.php` baked-in gotchas (apply to any long NS-bulk + Forecast-read tool):**
166
+ - **The Forecast reader connection idles out mid-run (Aurora idle-drop) during long NetSuite bulk
167
+ fetches.** Don't hold one long-lived handle across the NS calls — use a **lazy self-reconnecting
168
+ `fcReader()`** (ping-or-reconnect) called **immediately before each FC query**, not once up front.
169
+ - **`App_Database::buildDoubleFieldArray(App_Database::query(...))` throws "Only variables should be
170
+ passed by reference."** `buildDoubleFieldArray` takes its argument by reference, so a function-call
171
+ result can't be passed inline — **assign `query(...)` to a temp variable first**, then pass the temp.
146
172
  - **An audit must apply the sync's own inclusion rules or it manufactures phantom deltas.**
147
173
  `reconcile`'s NetSuite Sales SUM/diff originally had **no transaction-status filter**, but the
148
174
  sync (`trueup_sales` / `common_import_sales_from_netsuite.php`) deliberately **excludes** Invoice
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-24
9
+ updated: 2026-06-25
10
10
  owners: ["dfranks"]
11
11
  files:
12
12
  - _underscore/Component/Api/Netsuite/Netsuite.php
@@ -48,10 +48,20 @@ through `_ApiRequest` directly (mirroring `send()`'s auth/endpoint/header setup)
48
48
  - `_ApiRequest` exposes **`responseCode` + `responseHeaders`** (a raw header string — there is **no
49
49
  `getHeader()`** accessor). `_Component_Api_Netsuite::send()` does **not** surface either.
50
50
  - Robust parse: `preg_match_all('/^Location:\s*(\S+)/im', $responseHeaders, ...)` and take the
51
- **LAST** match (skips any 100-Continue / redirect block), then `#/(\d+)(?:[?#]|$)#` to pull the
52
- trailing numeric id tolerating a query string or fragment.
51
+ **LAST** match (skips any 100-Continue / redirect block), then a trailing-id regex to pull the
52
+ numeric id tolerating a query string or fragment.
53
53
  - Throws on non-2xx, on no `Location` header, and on no id parsed.
54
54
 
55
+ > **⚠ BUG (live, not yet fixed):** the trailing-id regex was written as `'#/(\d+)(?:[?#]|$)#'`,
56
+ > which uses `#` as the PCRE **delimiter** *and* puts `#` inside the `[?#]` character class — so PHP
57
+ > reads the class-`#` as a premature closing delimiter and the whole pattern **always throws
58
+ > `preg_match(): Unknown modifier ']'`**. This fires *after* the record is already created, so the
59
+ > create succeeds in NetSuite but `createRecord()` raises and the caller never gets the new id —
60
+ > it breaks the entire outbound create/push path. **Fix direction:** change the delimiter so `#`
61
+ > isn't both delimiter and class member, e.g. `~/(\d+)(?:[?#]|$)~`. Interim workaround used in
62
+ > probes: recover the new id by regex-parsing the thrown exception message (the `Location` value
63
+ > is in it). Confirmed live 2026-06-25.
64
+
55
65
  ### Update — reuse `send('PATCH', $route, $body)`
56
66
 
57
67
  Updates do **not** need a new helper. A NetSuite record PATCH returns 204 with no body, and
@@ -85,9 +95,17 @@ doc.)
85
95
  - **204-with-empty-body is success, not failure.** Both create (204 + `Location`) and update (204,
86
96
  no body) return no payload; treat a 2xx with empty body as success and key off the status, not the
87
97
  body.
98
+ - **`createRecord()`'s trailing-id regex is a live `#`-delimiter bug** (`'#/(\d+)(?:[?#]|$)#'` →
99
+ always `Unknown modifier ']'`), thrown *after* the record is created — see the ⚠ note under
100
+ *Create* for the cause and the `~…~`-delimiter fix.
88
101
 
89
102
  ## Change history
90
103
 
104
+ - 2026-06-25 — **Recorded a live bug in `createRecord()`'s trailing-id regex.** The pattern
105
+ `'#/(\d+)(?:[?#]|$)#'` uses `#` as both the PCRE delimiter and a class member, so it always throws
106
+ `Unknown modifier ']'` — *after* the record is created, breaking the outbound create/push path.
107
+ Fix direction: re-delimit (e.g. `~/(\d+)(?:[?#]|$)~`); interim workaround is to parse the new id
108
+ out of the thrown exception message. (dfranks)
91
109
  - 2026-06-24 — **Added `createRecord()` — the first REST write method in the framework.** Posts
92
110
  through `_ApiRequest` directly (since `send()` hides headers) and parses the new internalId from
93
111
  the 204 `Location` header (last match, trailing-id regex). Update path documented as reuse of
@@ -9,7 +9,7 @@
9
9
  | [Creating Worker Actions](features/creating-worker-actions.md) | How to add a new callable Worker action — a PHP class whose `public static` methods are invoked as background jobs (via webhook, cron, or `_Worker::runTask()`). | worker2/Worker/, worker2/Controller/Index.php, _underscore/Worker.php |
10
10
  | [Elite Freshservice Sync (worker2)](features/elite-freshservice-sync.md) | `_Worker_Elite` processes Freshservice webhook events and syncs them into TOGA 2. | worker2/Worker/Elite.php, worker2/Config/dev-kmaramreddy-laptop.ini |
11
11
  | [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Notification/Email.php, dbchanges2/Core/2026-05-21 - Monitors.sql |
12
- | [NetSuite → TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer pulls (opportunities first; sales/items/etc. | worker2/Worker/Netsuite.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Controller/Index.php, _underscore/Worker.php, test/@dave/NetSuite/api-message-queue/lib_amq_queue.js, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/ue_amq_drain.js, test/@dave/NetSuite/api-message-queue/ss_amq_drain.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, test/@dave/clickup/backfill_opportunity_numbers.php, test/@dave/clickup/probe_opportunity_fields.php, test/@dave/probe_clickup_desc_match.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
12
+ | [NetSuite → TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer pulls (opportunities first; sales/items/etc. | worker2/Worker/Netsuite.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Controller/Index.php, _underscore/Worker.php, test/@dave/NetSuite/api-message-queue/lib_amq_queue.js, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/ue_amq_drain.js, test/@dave/NetSuite/api-message-queue/ss_amq_drain.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, test/@dave/clickup/backfill_opportunity_numbers.php, test/@dave/clickup/probe_opportunity_fields.php, test/@dave/probe_clickup_desc_match.php, test/@dave/test_model_load_behavior.php, dbchanges2/Forecast/2026-06-25a - Add unique index on Opportunities netsuiteOpportunityInternalId.sql, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
13
13
  | [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). | worker2/Worker/Netsuite/SalesOrder.php, worker2/Worker/Netsuite.php, test/@dave/probe_salesorder_rest_shape.php, test/@dave/probe_open_order_lines.php, test/@dave/check_so_status.php, test/@dave/check_so_history.php, test/@dave/probe_so_rest_lines.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_open_order_gating.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
14
14
  | [DB-Driven Notification (Internal) Email](features/notification-email.md) | Internal/notification emails (merge-conflict alerts, ops notices — anything system-generated, not client-facing transactional mail) are sent through one worker | worker2/Worker/Notification/Email.php, _underscore/Model/Client/EmailTemplate.php, dbchanges2/Client/2026-06-23a - EmailTemplateWrapper.sql, dbchanges2/Client_True/2026-06-23a - EmailTemplateWrapper.sql |
15
15
  | [Startech Webhook Handler (worker2)](features/startech-webhook-handler.md) | Receives inbound webhook events from Startech (Easeedesk) and creates or updates the corresponding ticket in TOGA 2.0. | worker2/Worker/Startech.php |
@@ -21,6 +21,8 @@ files:
21
21
  - test/@dave/clickup/backfill_opportunity_numbers.php
22
22
  - test/@dave/clickup/probe_opportunity_fields.php
23
23
  - test/@dave/probe_clickup_desc_match.php
24
+ - test/@dave/test_model_load_behavior.php
25
+ - "dbchanges2/Forecast/2026-06-25a - Add unique index on Opportunities netsuiteOpportunityInternalId.sql"
24
26
  - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
25
27
  related:
26
28
  - ./netsuite-salesorder-open-orders-sync.md
@@ -163,7 +165,15 @@ Shared helpers: `buildCustomFields()` (the field array, used by both create and
163
165
 
164
166
  Writes `Forecast.Opportunities` (header) + `Forecast.OpportunityItems` (children), faithful to
165
167
  the legacy cron's OPPORTUNITIES section:
166
- - Upsert header by `netsuiteOpportunityInternalId`; items insert/update/delete by `line`; no
168
+ - Upsert header by `netsuiteOpportunityInternalId` via a **business-key resolve**, NOT
169
+ `_Model::load()` — `SELECT id FROM Opportunities WHERE netsuiteOpportunityInternalId=? ORDER BY
170
+ id`, take the first row (or new), save, then **delete any extra duplicate rows** (and their
171
+ `OpportunityItems` children) inline so the handler is **self-healing** against pre-existing
172
+ duplicates (see the `load()` gotcha below for why `load()` cannot be used here).
173
+ - A **UNIQUE index on `Opportunities.netsuiteOpportunityInternalId`** (migration
174
+ `dbchanges2/Forecast/2026-06-25a …`) is the only race-proof guard against concurrent/redelivered
175
+ webhooks creating a duplicate. The table was confirmed duplicate-free before the index was added.
176
+ - items insert/update/delete by `line`; no
167
177
  header delete; children deleted before the header on DELETE.
168
178
  - Lookups: `customerId`/`salesRepEmployeeId` → null on miss; `forecastCategoryId`/`salesStageId`/
169
179
  `percentToCloseStatusId` → **create-on-miss** (+name update); unknown line item → **throws**
@@ -182,6 +192,19 @@ None — platform-wide Forecast sync.
182
192
 
183
193
  ## Gotchas / known issues
184
194
 
195
+ - **`_Model::load()` returns TRUE only on an EXACTLY-ONE match — a duplicate-row self-amplifier in
196
+ any load-then-upsert handler.** `_Model::load()` (`_underscore/Model.php:788–808`) returns FALSE
197
+ for **both** 0 rows **and** 2+ rows — it succeeds only when the lookup matches exactly one row.
198
+ The old inbound opportunity handler used `load()` to find the existing header before its upsert, so
199
+ the instant an `netsuiteOpportunityInternalId` had **two** rows (from any earlier race/redelivery),
200
+ `load()` could never re-find it and **every subsequent webhook INSERTed yet another row** — a
201
+ self-amplifying duplicate (opp 7192265 reached **4 rows**, a $1,275 reconcile delta). This is a
202
+ **platform-wide footgun**: any `load()`-by-business-key-then-upsert pattern silently flips to
203
+ insert-only once a duplicate exists. **Fix:** resolve the id with a plain
204
+ `SELECT id … ORDER BY id` (take the first) instead of `load()`, and have the handler delete the
205
+ extra rows so it self-heals; back it with a UNIQUE index so duplicates can't form. The exactly-1
206
+ semantics is proven empirically by `test/@dave/test_model_load_behavior.php` (seeds 2 sentinel
207
+ rows, asserts `load()` → FALSE; self-cleaning local fixture).
185
208
  - **A User Event can't trigger another User Event → the immediate trigger lives in the enqueuer.**
186
209
  The enqueuer (a UE) creating the queue row does NOT fire any UE on the queue record (only the
187
210
  *scheduled* drainer's `submitFields` does). So a drain-UE on the queue record never runs for
@@ -364,6 +387,16 @@ same **skip-if-unchanged** compare on the extracted values, and **actor-identity
364
387
  trigger a CU→NS write. The NS→CU change-detection above is the complementary backstop, not a substitute.
365
388
 
366
389
  ## Change history
390
+ - 2026-06-25 — **Fixed the Opportunity duplicate-row amplification + added a unique-index guard.**
391
+ Root cause: `_Model::load()` returns TRUE only on an **exactly-one** match (FALSE for 0 *and* 2+),
392
+ so once an nsId had 2 rows the `load()`-then-upsert handler could never re-find it and every
393
+ webhook INSERTed another row (opp 7192265 hit 4 rows / $1,275 delta). Replaced `load()` with a
394
+ business-key `SELECT id … ORDER BY id` resolve + inline delete of extra duplicate rows and their
395
+ `OpportunityItems` children (self-healing), and added a **UNIQUE index on
396
+ `Opportunities.netsuiteOpportunityInternalId`** (`dbchanges2/Forecast/2026-06-25a …`; table
397
+ confirmed duplicate-free first) as the race-proof guard. Recorded the `load()` exactly-1 semantics
398
+ as a platform-wide footgun for any load-then-upsert handler; proven by
399
+ `test/@dave/test_model_load_behavior.php`. (dfranks)
367
400
  - 2026-06-25 — **Characterized the AMQ enqueuer scope + a live drainer TypeError.** The enqueuer is
368
401
  generic over all record types via `RECORD_TYPE_MAP` (no context filter, prod `DEV_OVERRIDE.enabled=false`)
369
402
  but probe-confirmed `Released` on **only Opportunity (`customdeploy1`) + Sales Order (`customdeploy2`)** —
@@ -12,7 +12,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
12
12
  - **walmarttechservices** (Walmart Tech Services) — 1 doc(s) → [1.0/apps/walmarttechservices/INDEX.md](1.0/apps/walmarttechservices/INDEX.md)
13
13
  - **test** (Test) — 11 doc(s) → [1.0/apps/test/INDEX.md](1.0/apps/test/INDEX.md)
14
14
  - **toga** (TOGa) — 2 doc(s) → [1.0/apps/toga/INDEX.md](1.0/apps/toga/INDEX.md)
15
- - **tools** (Tools) — 4 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
15
+ - **tools** (Tools) — 5 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
16
16
 
17
17
  ## 2.0 framework
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.207",
3
+ "version": "1.0.209",
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",