toga-ai 1.0.206 → 1.0.208
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knowledge/1.0/apps/library/INDEX.md +1 -1
- package/knowledge/1.0/apps/library/architecture.md +10 -0
- package/knowledge/1.0/apps/library/features/netsuite-suiteql-api-reference.md +35 -1
- package/knowledge/1.0/apps/tools/INDEX.md +8 -0
- package/knowledge/1.0/apps/tools/architecture.md +81 -0
- package/knowledge/1.0/apps/tools/features/developer-tools.md +37 -0
- package/knowledge/1.0/apps/tools/features/persona-gated-navigation.md +44 -0
- package/knowledge/1.0/apps/tools/features/saml-sso-auth.md +76 -0
- package/knowledge/1.0/apps/worker/INDEX.md +1 -1
- package/knowledge/1.0/apps/worker/features/forecast2-netsuite-reconciliation.md +26 -0
- package/knowledge/2.0/apps/_underscore/features/netsuite-rest-client.md +21 -3
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -1
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +34 -1
- package/knowledge/INDEX.md +2 -1
- package/knowledge/clients/true/profile.md +4 -0
- package/knowledge/registry.json +9 -0
- package/package.json +1 -1
|
@@ -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 |
|
|
@@ -103,3 +103,13 @@ packages: `phpoffice/phpspreadsheet ^1.6`, `smalot/pdfparser ^0.14.0`.
|
|
|
103
103
|
- **Timezone** is always `America/Chicago` (set in `_.php`).
|
|
104
104
|
- **`__APPROOT__`** is resolved by locating an ancestor folder named `_`; each app must
|
|
105
105
|
have that `_` folder at its root.
|
|
106
|
+
|
|
107
|
+
## App_String cross-framework crypto (`encryptWithKey` / `decryptWithKey`)
|
|
108
|
+
|
|
109
|
+
`App_String::encryptWithKey($data, $key)` and `decryptWithKey($data, $key)` (public static) do
|
|
110
|
+
**AES-256-CBC**: random IV via `random_bytes`, returning `base64(iv . ciphertext)` with openssl
|
|
111
|
+
flag `0`. They are **deliberately byte-for-byte interoperable** with the 2.0
|
|
112
|
+
`_String::encryptWithKey` / `decryptWithKey`, so a SAML handoff token encrypted by the 2.0
|
|
113
|
+
gateway decrypts in any 1.0 app (round-trip verified). Net-new methods — no existing method
|
|
114
|
+
changed, no behavior change for existing apps. First consumer: the Tools app's `App_Auth`
|
|
115
|
+
(`1.0/apps/tools/features/saml-sso-auth.md`).
|
|
@@ -6,11 +6,12 @@ project: Library
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-06-
|
|
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
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# tools (Tools) — 1.0 knowledge
|
|
2
|
+
|
|
3
|
+
| Doc | Summary | Files |
|
|
4
|
+
|-----|---------|-------|
|
|
5
|
+
| [Tools (1.0 Internal-Tools App) Architecture](architecture.md) | **Tools** is a standalone 1.0 (`App_`) application that houses many small internal tools behind simple interfaces, gated by Client_True staff persona. | tools/index.php, tools/_/app/framework.php, tools/_/app/frameworkindex.php, tools/_/app/auth.php, tools/_/app/nav.php, tools/common/header.php, tools/common/footer.php, tools/mvc/get.php, tools/mvc/_TEMPLATE/get.php, tools/docs/ADDING_A_TOOL.md |
|
|
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
|
+
| [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
|
+
| [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 |
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Tools (1.0 Internal-Tools App) Architecture
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: tools
|
|
5
|
+
project: Tools
|
|
6
|
+
client: shared
|
|
7
|
+
type: architecture
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-25
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- tools/index.php
|
|
13
|
+
- tools/_/app/framework.php
|
|
14
|
+
- tools/_/app/frameworkindex.php
|
|
15
|
+
- tools/_/app/auth.php
|
|
16
|
+
- tools/_/app/nav.php
|
|
17
|
+
- tools/common/header.php
|
|
18
|
+
- tools/common/footer.php
|
|
19
|
+
- tools/mvc/get.php
|
|
20
|
+
- tools/mvc/_TEMPLATE/get.php
|
|
21
|
+
- tools/docs/ADDING_A_TOOL.md
|
|
22
|
+
related:
|
|
23
|
+
- ./features/saml-sso-auth.md
|
|
24
|
+
- ./features/persona-gated-navigation.md
|
|
25
|
+
- ./features/developer-tools.md
|
|
26
|
+
- ../library/architecture.md
|
|
27
|
+
- ../library/features/mvc-page-pattern-and-app-skeleton.md
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Summary
|
|
31
|
+
|
|
32
|
+
**Tools** is a standalone 1.0 (`App_`) application that houses many small internal tools behind
|
|
33
|
+
simple interfaces, gated by Client_True staff persona. It is modeled on `togaview` and depends on
|
|
34
|
+
the `library` core. Auth comes via the SAML gateway `?saml=` handoff; the app owns its own
|
|
35
|
+
session and reads `Client_True` **read-only**. Adding a tool = create `mvc/<route>/get.php`
|
|
36
|
+
(copy `mvc/_TEMPLATE`) + add one `App_Nav::definition()` entry + `App_Auth::requireAuth([...])`.
|
|
37
|
+
|
|
38
|
+
**Critical rules:** Do NOT use bare `<header>` elements in this app — they inherit the legacy
|
|
39
|
+
dashboard stylesheet's `header{position:fixed;width:100vw}` rule and cause horizontal scroll
|
|
40
|
+
(the top header bar was removed for exactly this reason). A nav action's `route` maps **1:1** to
|
|
41
|
+
`mvc/<route>/get.php`. Auth fails **closed** and never auto-creates users; the dev bypass is
|
|
42
|
+
double-gated (`[internal] dev_mode` AND `App_Registry::inDevMode()`). `config.*.ini`
|
|
43
|
+
(incl. `config.prod.ini`) is committed with plaintext production secrets — a known, team-accepted
|
|
44
|
+
risk (see Known issues); never add more secrets and treat these as compromised if leaked.
|
|
45
|
+
|
|
46
|
+
## Boot & structure
|
|
47
|
+
|
|
48
|
+
Standard 3-line `index.php`: `require '_.php'; App_Framework_Tools::initialize();
|
|
49
|
+
App_Framework_Tools::renderIndex();`. App-specific framework subclasses
|
|
50
|
+
`App_Framework_Tools` / `App_FrameworkIndex_Tools` live in `_/app/`. The 1.0 folder-based MVC
|
|
51
|
+
page pattern is unchanged from the library skeleton (see the library feature doc).
|
|
52
|
+
|
|
53
|
+
`App_Framework_Tools::initialize()` sets the session cookie HttpOnly + SameSite=Lax before
|
|
54
|
+
calling the parent initialize.
|
|
55
|
+
|
|
56
|
+
## Layout (single-style app)
|
|
57
|
+
|
|
58
|
+
No per-host `stylePath` subfolder — one `assets/css/style.css`. Uses TOGA Technology logos in
|
|
59
|
+
`assets/img` (`toga-brandmark-white.png` in the dark sidebar; `toga-horizontal-colored.png` on
|
|
60
|
+
login/welcome). A fixed left two-column shell: dark left sidebar (`.app-nav`) with brand at top,
|
|
61
|
+
persona-filtered nav in the middle, pinned footer with the user's name + a ghost "Log out"
|
|
62
|
+
button; content area on the right. **No top header bar** (see Critical rules).
|
|
63
|
+
|
|
64
|
+
## Home route
|
|
65
|
+
|
|
66
|
+
`/` (`mvc/get.php`) renders a persona-filtered dashboard of available tools (tiles grouped by
|
|
67
|
+
folder) when logged in, via `App_Nav::visibleGroups()`, or a welcome/sign-in card when not.
|
|
68
|
+
|
|
69
|
+
## Adding a tool
|
|
70
|
+
|
|
71
|
+
Create `mvc/<folder>/<tool>/get.php` (copy `mvc/_TEMPLATE/get.php`), add one entry to
|
|
72
|
+
`App_Nav::definition()`, and put `App_Auth::requireAuth([...personas])` at the top. Documented in
|
|
73
|
+
`docs/ADDING_A_TOOL.md`. See the App_Nav and App_Auth feature docs.
|
|
74
|
+
|
|
75
|
+
## Known issues / security
|
|
76
|
+
|
|
77
|
+
- **Plaintext secrets committed to git.** `config.*.ini` (incl. `config.prod.ini`) contains
|
|
78
|
+
plaintext production secrets (RDS master password, SMTP, Payeezy, NetSuite). The team has
|
|
79
|
+
**accepted this for now** (developer decision, 2026-06-25). Future remediation: git-ignore the
|
|
80
|
+
config files and rotate all exposed credentials. (Location only — no values recorded here.)
|
|
81
|
+
- **SSO initiation + replay defense are open items** — see `features/saml-sso-auth.md`.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Tools — Developers Folder (UUID & Password Generators)
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: tools
|
|
5
|
+
project: Tools
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-25
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- tools/mvc/developers/uuid/get.php
|
|
13
|
+
- tools/mvc/developers/password/get.php
|
|
14
|
+
related:
|
|
15
|
+
- ../architecture.md
|
|
16
|
+
- ./persona-gated-navigation.md
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Summary
|
|
20
|
+
|
|
21
|
+
The first two tools shipped in the Tools app, both under the **Developers** folder and gated to
|
|
22
|
+
personas **Development Team** / **TOGa Technology**. They are the reference example of the
|
|
23
|
+
"one small tool = one `mvc/<route>/get.php`" pattern.
|
|
24
|
+
|
|
25
|
+
## How it works
|
|
26
|
+
|
|
27
|
+
### Generate UUID (`mvc/developers/uuid/get.php`)
|
|
28
|
+
- Server generates an RFC 4122 **v4** UUID via `random_bytes(16)` with version/variant bits set.
|
|
29
|
+
- UI is click-to-copy, then instant **client-side** regenerate using `crypto.getRandomValues`.
|
|
30
|
+
|
|
31
|
+
### Generate Password (`mvc/developers/password/get.php`)
|
|
32
|
+
- Reproduces the standalone `test/team/generate_password.php` script.
|
|
33
|
+
- `App_String::generateSupportCode(8)` produces the password; `App_String::passwordHash()`
|
|
34
|
+
produces the bcrypt hash. The page shows **both** the password and the hash, each copyable.
|
|
35
|
+
|
|
36
|
+
## Change history
|
|
37
|
+
- 2026-06-25 — Built the first two Developers tools: RFC 4122 v4 UUID generator and a password+bcrypt-hash generator (ported from test/team/generate_password.php), both gated to Development Team / TOGa Technology personas (jcardinal)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Tools Persona-Gated Navigation (App_Nav)
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: tools
|
|
5
|
+
project: Tools
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-25
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- tools/_/app/nav.php
|
|
13
|
+
related:
|
|
14
|
+
- ../architecture.md
|
|
15
|
+
- ./saml-sso-auth.md
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Summary
|
|
19
|
+
|
|
20
|
+
`App_Nav` is the Tools app's two-level, **persona-gated** navigation. The structure is a single
|
|
21
|
+
**hard-coded `definition()` array** (NOT in the DB), so developers/Claude edit it directly. It
|
|
22
|
+
drives both the left-rail nav and the home dashboard, and is the single source of truth for which
|
|
23
|
+
personas may reach a given route.
|
|
24
|
+
|
|
25
|
+
## How it works
|
|
26
|
+
|
|
27
|
+
### Structure
|
|
28
|
+
- Two levels: **folders** (grouping) → **actions** (each loads a page).
|
|
29
|
+
- Each folder and each action carries a `personas` list. An item is visible iff
|
|
30
|
+
`App_Auth::hasAnyPersona()` matches — folder gate plus an optional per-action gate.
|
|
31
|
+
|
|
32
|
+
### Render surfaces
|
|
33
|
+
- `render()` — emits the filtered left-rail nav with active-route highlighting.
|
|
34
|
+
- `visibleGroups()` — returns the filtered structure as data for the home dashboard tiles.
|
|
35
|
+
- `personasForRoute()` — returns the union of folder+action personas so a page can self-guard
|
|
36
|
+
with `App_Auth::requireAuth(...)` using the same data the nav used.
|
|
37
|
+
|
|
38
|
+
### Route convention
|
|
39
|
+
An action's `route` (e.g. `/developers/uuid`) maps **1:1** to `mvc/<route>/get.php`. Adding a
|
|
40
|
+
tool means creating that file and adding one entry to `definition()` — see
|
|
41
|
+
`docs/ADDING_A_TOOL.md` and the architecture doc.
|
|
42
|
+
|
|
43
|
+
## Change history
|
|
44
|
+
- 2026-06-25 — Built App_Nav: hard-coded two-level persona-gated navigation; render() for the left rail, visibleGroups() for the dashboard, personasForRoute() for page self-guarding; route maps 1:1 to mvc/<route>/get.php (jcardinal)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Tools SAML SSO Consumer & Persona-Gated Auth (App_Auth)
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: tools
|
|
5
|
+
project: Tools
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-25
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- tools/_/app/auth.php
|
|
13
|
+
- tools/mvc/sso/get.php
|
|
14
|
+
- tools/mvc/login/get.php
|
|
15
|
+
- tools/mvc/login/post.php
|
|
16
|
+
- tools/mvc/logout/get.php
|
|
17
|
+
related:
|
|
18
|
+
- ../architecture.md
|
|
19
|
+
- ../../../2.0/apps/saml/features/downstream-integration-contract.md
|
|
20
|
+
- ../../../clients/true/features/users-personas-data-model.md
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Summary
|
|
24
|
+
|
|
25
|
+
`App_Auth` is the Tools app's authentication layer: it consumes the SAML gateway `?saml=`
|
|
26
|
+
handoff (see the 2.0 SAML downstream integration contract), establishes its own session, and
|
|
27
|
+
gates every page by **Client_True persona**. The gateway holds no session — session ownership
|
|
28
|
+
is entirely this app's. Auth fails **closed** to a self-contained 401 page; users are **never**
|
|
29
|
+
auto-created.
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
### Consuming the `?saml=` handoff (`mvc/sso/get.php` → `App_Auth`)
|
|
34
|
+
1. Length-guard `$_GET['saml']`, then base64-decode → `json_decode` → read `payload.client`
|
|
35
|
+
and `payload.user`.
|
|
36
|
+
2. Decrypt each with `App_String::decryptWithKey()` using config `[saml] api_secret_access_token`,
|
|
37
|
+
with a **dual-key retry** against `api_secret_access_token_previous` (supports gateway key
|
|
38
|
+
rotation). This interoperates byte-for-byte with the 2.0 `_String::encryptWithKey()` that
|
|
39
|
+
produced the token (see the library `App_String` crypto methods).
|
|
40
|
+
3. Validate the decrypted **client uuid == configured `true_client_uuid`** via `hash_equals`,
|
|
41
|
+
and that both decrypted values match a UUID regex.
|
|
42
|
+
4. Load the active Client_True user: `WHERE uuid = ? AND isActive = 1`. No match → fail closed.
|
|
43
|
+
|
|
44
|
+
### Session establishment (`establishSession()`)
|
|
45
|
+
- Calls `session_regenerate_id(true)`, then caches the user and **persona names** in `$_SESSION`.
|
|
46
|
+
- Persona lookup (read-only `db_true`):
|
|
47
|
+
`SELECT p.name FROM Users u JOIN Users_Personas up ON up.userId=u.id JOIN Personas p ON p.id=up.personaId WHERE u.uuid = <escaped>`.
|
|
48
|
+
- The session cookie is set **HttpOnly + SameSite=Lax** in `App_Framework_Tools::initialize()`
|
|
49
|
+
before the parent initialize runs.
|
|
50
|
+
|
|
51
|
+
### Page guarding
|
|
52
|
+
- `requireAuth([personas])` at the top of each page; `hasAnyPersona()` is `array_intersect`
|
|
53
|
+
against the cached session personas. App_Nav uses the same cached data to filter the nav.
|
|
54
|
+
|
|
55
|
+
### Dev bypass (double-gated, fails closed in prod)
|
|
56
|
+
`mvc/login/post.php` permits **email-only** login **only** when config `[internal] dev_mode`
|
|
57
|
+
is truthy **and** `App_Registry::inDevMode()` — both must hold. When used it writes a `SECURITY`
|
|
58
|
+
line to `error_log`.
|
|
59
|
+
|
|
60
|
+
## Gotchas / known issues
|
|
61
|
+
|
|
62
|
+
- **SSO initiation is not implemented in this 1.0 app.** Building the signed AuthnRequest lives
|
|
63
|
+
in 2.0 `_underscore` (`singleSignOnServiceUrl()`); login here points at a configurable
|
|
64
|
+
`[saml] initiation_url` **placeholder**. Open item: needs a gateway initiation entry point or
|
|
65
|
+
a ported builder.
|
|
66
|
+
- **No app-side replay defense.** The handoff token carries no nonce/timestamp the app verifies.
|
|
67
|
+
Recommend the gateway embed `iat` + `jti`.
|
|
68
|
+
|
|
69
|
+
## Config keys
|
|
70
|
+
|
|
71
|
+
`[database_true]` (read-only Client_True); `[saml]` `api_secret_access_token` /
|
|
72
|
+
`api_secret_access_token_previous`, `initiation_url`, `client_authentication_uuid`,
|
|
73
|
+
`domain_uuid`, `true_client_uuid`; `[internal]` `dev_mode`.
|
|
74
|
+
|
|
75
|
+
## Change history
|
|
76
|
+
- 2026-06-25 — Built App_Auth: SAML `?saml=` handoff consumer with dual-key decrypt, hash_equals client-uuid check, fail-closed 401, persona-cached session (HttpOnly+SameSite=Lax), and a double-gated dev bypass. Initiation + replay defense left as open items (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-
|
|
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
|
|
52
|
-
|
|
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
|
|
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`)** —
|
package/knowledge/INDEX.md
CHANGED
|
@@ -12,6 +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
16
|
|
|
16
17
|
## 2.0 framework
|
|
17
18
|
|
|
@@ -27,7 +28,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
27
28
|
- **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
|
|
28
29
|
- **ai-bdr** (AI-BDR) — 4 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
|
|
29
30
|
- **toga2-commerce** (TOGa Commerce) — 6 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
|
|
30
|
-
- **toga25-supply** (TOGa 2.5 Supply) —
|
|
31
|
+
- **toga25-supply** (TOGa 2.5 Supply) — 7 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
|
|
31
32
|
- **toga-blox** (TOGa Blox) — 7 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
|
|
32
33
|
|
|
33
34
|
## standalone framework
|
|
@@ -3,6 +3,7 @@ title: "TOGA Technology"
|
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
apps:
|
|
5
5
|
- _underscore
|
|
6
|
+
- tools
|
|
6
7
|
project: _Underscore
|
|
7
8
|
client: true
|
|
8
9
|
type: profile
|
|
@@ -24,3 +25,6 @@ by staff role (e.g. the planned Toolbox app) reads its `Users` / `Personas` mode
|
|
|
24
25
|
- **Client DB:** `Client_True`
|
|
25
26
|
- **Client identifier:** `True`
|
|
26
27
|
- **SSO mapper:** `_Model_True_ClientAuthentication` (base; matches by email from NameID)
|
|
28
|
+
|
|
29
|
+
The new **Tools** app (1.0; repo `tools`) reads the `Client_True` DB **read-only** to gate its
|
|
30
|
+
internal tooling by staff persona (see `1.0/apps/tools/`).
|
package/knowledge/registry.json
CHANGED
package/package.json
CHANGED