toga-ai 1.0.256 → 1.0.258
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knowledge/1.0/apps/library/features/netsuite-suiteql-api-reference.md +15 -1
- package/knowledge/1.0/apps/tools/features/saml-sso-auth.md +44 -13
- package/knowledge/1.0/apps/worker/features/forecast2-netsuite-reconciliation.md +37 -0
- package/knowledge/2.0/apps/_underscore/features/forecast-sale-import.md +28 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: Library
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-06-
|
|
9
|
+
updated: 2026-06-30
|
|
10
10
|
owners: ["dfranks"]
|
|
11
11
|
files:
|
|
12
12
|
- library/app/api/netsuite/rest.php
|
|
@@ -330,9 +330,23 @@ numbers. Budget hours for multi-year runs and launch them under `nohup`/`tmux`.
|
|
|
330
330
|
(PHP 8.0) is for running probes on the laptop only, **not** for compat checking.
|
|
331
331
|
- Field/relationship availability varies by NetSuite account **and** record type — probe the live
|
|
332
332
|
account before assuming a column exists.
|
|
333
|
+
- **`IS NOT NULL` on a custom `transactionline` column inside an aggregate query is a ~15× planner
|
|
334
|
+
trap — prefer `> 0` (or `NVL(col,0) <> 0`).** Filtering a custom employee-ref column
|
|
335
|
+
(`custcol_sales_rep_line`) with `AND tl.custcol_sales_rep_line IS NOT NULL` inside a
|
|
336
|
+
`GROUP BY` / power-sum aggregate (`MOD(SUM(MOD(id*id,P)),P)`) made the planner scan
|
|
337
|
+
pathologically: **~122s** vs **~8s** for the semantically identical `AND tl.custcol_sales_rep_line
|
|
338
|
+
> 0` (employee internalIds are always positive) — a ~15× regression purely from the predicate form.
|
|
339
|
+
Measured against the Forecast2 reconciliation checker (TRUE-79968). Use `> 0` / `NVL(col,0) <> 0`
|
|
340
|
+
for a custom ref column you only need to test for presence in an aggregate; reserve `IS NOT NULL`
|
|
341
|
+
for non-aggregate row filters.
|
|
333
342
|
|
|
334
343
|
## Change history
|
|
335
344
|
|
|
345
|
+
- 2026-06-30 — **Recorded the `IS NOT NULL` vs `> 0` planner trap for custom `transactionline`
|
|
346
|
+
columns in aggregate queries** (TRUE-79968): a custom employee-ref column filtered with
|
|
347
|
+
`IS NOT NULL` inside a power-sum `GROUP BY` aggregate scanned ~15× slower (~122s) than the
|
|
348
|
+
identical `> 0` predicate (~8s). Prefer `> 0` / `NVL(col,0) <> 0` for presence tests in
|
|
349
|
+
aggregates. (dfranks)
|
|
336
350
|
- 2026-06-25 — **Documented Journal Entry line structure + the JE→Forecast.Sales deferral.** JEs
|
|
337
351
|
invert the `mainline` rule — **all** JE `transactionline` rows are `mainline='T'` (no `'F'` detail
|
|
338
352
|
split), so a `mainline='F'` revenue query over a JE returns empty. Also: `tl.salesrep` does not
|
|
@@ -36,10 +36,13 @@ auto-created.
|
|
|
36
36
|
## How it works
|
|
37
37
|
|
|
38
38
|
### Wiring overview
|
|
39
|
-
- **`/sso/initiate`** (`mvc/sso/initiate/get.php`) — guards `isLoggedIn`, reads `config[saml]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
- **`/sso/initiate`** (`mvc/sso/initiate/get.php`) — guards `isLoggedIn`, reads `config[saml]`
|
|
40
|
+
for the IdP/ACS urls + `domain_uuid`, sources the **rotating shared secret from `Core.Parameters`
|
|
41
|
+
via `App_Auth::currentApiSecret()`** (NOT from config — see below), calls `App_Sso::initiate([...])`
|
|
42
|
+
passing `'apiSecretAccessToken' => App_Auth::currentApiSecret()`, then `App_MVC::routeTo($url); exit;`.
|
|
43
|
+
Initiation uses the reusable 1.0 [`App_Sso`](../../library/features/app-sso-initiation.md) library
|
|
44
|
+
class (no longer a placeholder). The secret encrypts the RelayState — see the non-empty-token
|
|
45
|
+
gotcha below.
|
|
43
46
|
- **`/sso`** (`mvc/sso/get.php`) — the handoff consumer (below).
|
|
44
47
|
- **Core.Domains** — a fixed-uuid row registers this app's return domain so the gateway resolves
|
|
45
48
|
client/environment/return URL: `uuid 2927bc15-e347-4358-a430-fb28f9446d27`, `clientId 1` (True),
|
|
@@ -59,18 +62,29 @@ auto-created.
|
|
|
59
62
|
and that both decrypted values match a UUID regex.
|
|
60
63
|
4. Load the active Client_True user: `WHERE uuid = ? AND isActive = 1`. No match → fail closed.
|
|
61
64
|
|
|
62
|
-
### Handoff key sourcing (`handoffKeys()` → `Core.Parameters`)
|
|
63
|
-
The shared secret is **rotated regularly**, so
|
|
65
|
+
### Handoff key sourcing (`handoffKeys()` → `Core.Parameters`) — used by BOTH SSO legs
|
|
66
|
+
The shared secret is **rotated regularly**, so any static `config.*.ini [saml]` copy goes stale —
|
|
64
67
|
that staleness was exactly why `decryptHandoffValue()` returned `false` and production login failed
|
|
65
68
|
(the payload decoded to the correct iv+base64 shape; it was a **key** mismatch, not a format bug).
|
|
66
|
-
|
|
69
|
+
**Both SSO legs must source the secret from `Core.Parameters` at runtime** — the initiate leg
|
|
70
|
+
encrypts the RelayState with it, the consumer leg decrypts the handoff with it. A config-only copy
|
|
71
|
+
silently breaks SSO on the next rotation.
|
|
72
|
+
- `App_Auth::handoffKeys()` (private helper) `SELECT \`key\`,\`value\` FROM Parameters WHERE
|
|
67
73
|
\`key\` IN ('API_SECRET_ACCESS_TOKEN','API_SECRET_ACCESS_TOKEN_PREVIOUS')` over the **`db_toga2core`**
|
|
68
74
|
connection (2.0 PROD Core DB; `const CORE_DB = 'db_toga2core'`). Result is **cached per request**.
|
|
69
75
|
`\`key\`` and `\`value\`` are SQL reserved words → **must be backticked**.
|
|
70
|
-
- `decryptHandoffValue()`
|
|
71
|
-
Its **public signature is unchanged
|
|
72
|
-
-
|
|
73
|
-
|
|
76
|
+
- **Consumer leg** (`mvc/sso/get.php`): `decryptHandoffValue()` tries the current key, falling back
|
|
77
|
+
to previous (rotation window). Its **public signature is unchanged**.
|
|
78
|
+
- **Initiate leg** (`mvc/sso/initiate/get.php`): calls **`App_Auth::currentApiSecret()`** (public;
|
|
79
|
+
reuses the cached `handoffKeys()` lookup, returns the current `API_SECRET_ACCESS_TOKEN`) and passes
|
|
80
|
+
it to `App_Sso::initiate(['apiSecretAccessToken' => …])` to encrypt the RelayState.
|
|
81
|
+
- **Do NOT remove `[saml] api_secret_access_token` / `_previous` from config.** ⚠ An earlier capture
|
|
82
|
+
this session wrongly called these "dead/removable" — that guidance **broke SSO**: the initiate leg
|
|
83
|
+
still read the config token, so removing it made `App_Sso::initiate()` throw on an empty token
|
|
84
|
+
(`/login?error=1`). The fix moved the initiate leg to `Core.Parameters` too; the config copies are
|
|
85
|
+
now genuinely unused, but the durable rule is that the **only source of truth is `Core.Parameters`**.
|
|
86
|
+
- **`[saml] true_client_uuid` is still read from config** (`sso/get.php:80-84`) and **must NOT be
|
|
87
|
+
removed**. The IdP/ACS urls and `domain_uuid` also stay in config.
|
|
74
88
|
- See [Core.Parameters key/value store](#coreparameters-key-value-store) for the table shape and
|
|
75
89
|
which DB cluster it lives on.
|
|
76
90
|
|
|
@@ -115,6 +129,19 @@ removed.
|
|
|
115
129
|
preloader and runs `App_Page::flushCapture()` (`ob_end_flush + flush`, `library/app/page.php:213`)
|
|
116
130
|
**before** `body()/App_MVC::loadFile()` runs the mvc page. Fixed with the same
|
|
117
131
|
`if (!headers_sent()) { http_response_code(401); }` guard (matches `App_MVC::routeTo`'s own check).
|
|
132
|
+
- **`App_Sso::initiate()` hard-requires a non-empty `apiSecretAccessToken`** (`library/app/sso.php:64-65`,
|
|
133
|
+
`empty()` guard → `InvalidArgumentException`; the token encrypts the RelayState). An empty/absent token
|
|
134
|
+
surfaces as a redirect to **`/login?error=1` ("Sign in failed. Please try again.")** — the initiate leg
|
|
135
|
+
catches the exception (`sso/initiate/get.php:30-34`) and routes to `/login?error=1`. This fails
|
|
136
|
+
**before** the IdP round-trip, so it never reaches the consumer's **"Authentication failed"**
|
|
137
|
+
(`tools_ssoFail`) page. **The two error UIs tell you which leg failed:** `/login?error=1` = initiate
|
|
138
|
+
(RelayState encryption / token), "Authentication failed" = consumer (decrypt). This was the
|
|
139
|
+
2026-06-30 regression — removing the config token per the (wrong) earlier note made initiate throw.
|
|
140
|
+
- **Both legs depend on `db_toga2core` + a populated `Core.Parameters` token.** If `[database_toga2core]`
|
|
141
|
+
is not configured, `currentApiSecret()`/`handoffKeys()` runs `App_Database::query` against an
|
|
142
|
+
unregistered alias and **throws**. Because initiate only catches `InvalidArgumentException`/`RuntimeException`,
|
|
143
|
+
that surfaces as a **500** — not the login banner. **Diagnostic:** a 500 at initiate = missing
|
|
144
|
+
`db_toga2core`; `/login?error=1` = empty/absent `API_SECRET_ACCESS_TOKEN`.
|
|
118
145
|
- **No app-side replay defense.** The handoff token carries no nonce/timestamp the app verifies.
|
|
119
146
|
Recommend the gateway embed `iat` + `jti`.
|
|
120
147
|
|
|
@@ -140,8 +167,11 @@ from the 1.0 prod-cluster and from the 2.0 client cluster that backs `db_true`/`
|
|
|
140
167
|
`db_toga2core`, dbname `Core`, the prod core cluster host (`production-core-cluster…us-west-2…`),
|
|
141
168
|
needed in **each** `config.*.ini` so the handoff keys can be read at runtime (developer is adding
|
|
142
169
|
creds). `[saml]`: `true_client_uuid` (still used), `client_authentication_uuid`, `domain_uuid`,
|
|
143
|
-
plus the IdP/ACS urls used by initiation; `[internal]` `dev_mode`.
|
|
144
|
-
|
|
170
|
+
plus the IdP/ACS urls used by initiation; `[internal]` `dev_mode`. `[saml] api_secret_access_token` /
|
|
171
|
+
`_previous` are **superseded by `Core.Parameters`** (the only source of truth for BOTH legs) — but do
|
|
172
|
+
**not** treat them as freely removable: removing the config token while the initiate leg still read it
|
|
173
|
+
is what caused the 2026-06-30 `/login?error=1` regression. `[saml] true_client_uuid` is **still read
|
|
174
|
+
from config** and must stay.
|
|
145
175
|
|
|
146
176
|
**Secret location.** Rotating secrets are now sourced from `Core.Parameters` **at runtime** rather
|
|
147
177
|
than static config — an improvement over the old committed `config.production.ini [saml]` tokens.
|
|
@@ -150,6 +180,7 @@ remains (already noted in tools knowledge) — **flag for rotation.** Document *
|
|
|
150
180
|
never the values.
|
|
151
181
|
|
|
152
182
|
## Change history
|
|
183
|
+
- 2026-06-30 — Fixed SSO "Sign in failed" (`/login?error=1`) regression and **corrected the earlier wrong "config tokens are removable" note**. The prior fix moved only the consumer/decrypt leg to `Core.Parameters` and advised the `[saml] api_secret_access_token` was removable — but the **initiate** leg (`mvc/sso/initiate/get.php`) still read it from config, so removing it made `App_Sso::initiate()` throw on an empty token (`library/app/sso.php:64-65`) → `/login?error=1`. Fix: **both** legs now source the rotating secret from `Core.Parameters` — added public `App_Auth::currentApiSecret()` (reuses cached `handoffKeys()`), and `sso/initiate/get.php` passes it instead of the config value. Documented the non-empty-token requirement, the `/login?error=1` (initiate) vs "Authentication failed" (consumer) leg distinction, the `true_client_uuid`-stays-in-config rule, and the `db_toga2core`-missing→500 diagnostic. `php -l` clean on both files. (jcardinal)
|
|
153
184
|
- 2026-06-30 — Fixed prod SSO login failure: handoff decrypt keys now sourced from `Core.Parameters` (rotated `API_SECRET_ACCESS_TOKEN`/`_PREVIOUS`) via new `App_Auth::handoffKeys()` over `db_toga2core` (per-request cached, current→previous fallback), instead of the stale committed `config [saml]` tokens — the stale key was the root cause. Added `[database_toga2core]` connection requirement to every config and documented the AES-256-CBC crypto format. Fixed `tools_ssoFail()` 500→intended 401: `http_response_code(401)` fataled on headers-already-sent (preloader flushes before mvc page runs); guarded with `!headers_sent()`. (jcardinal)
|
|
154
185
|
- 2026-06-26 — Wired up real SSO initiation via the new 1.0 `App_Sso` library class (`/sso/initiate`), replacing the `initiation_url` placeholder; registered the fixed-uuid Core.Domains return row (dbchanges2 `2026-06-26a`); fixed the `session_regenerate_id` headers-already-sent fatal (guarded with `!headers_sent()`) and documented the mid-render failure-path limitation; collapsed home → `/login` to one-step sign-in; noted prod secrets live in `config.production.ini [saml]` (jcardinal)
|
|
155
186
|
- 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)
|
|
@@ -233,6 +233,22 @@ by reconciling a chosen tranDate range directly against NetSuite.
|
|
|
233
233
|
are correctly excluded **by design** (e.g. JE 7213407 = a Bank↔Equity reclass, 0 allowlist lines → never
|
|
234
234
|
imported — not a miss). An accrual JE and its NetSuite auto-reversal are **separate transactions**, each
|
|
235
235
|
reconciled independently by its own `internalId`.
|
|
236
|
+
- **The rep-only-NS-side / unfiltered-FC-side asymmetry is DELIBERATE and load-bearing — do NOT add
|
|
237
|
+
a rep filter to any Forecast-side query (TRUE-79968, dfranks 2026-06-30).** The importer now keeps
|
|
238
|
+
**only JE lines that carry a raw `custcol_sales_rep_line`** (see the sale-import doc). To make the
|
|
239
|
+
fixer be the **cleanup mechanism** for any pre-existing no-rep JE rows (there is no separate delete
|
|
240
|
+
script), the **NS-side** JE SuiteQL in both `checker.php` (`journalEntryNsTotals` arm of the sales
|
|
241
|
+
`nsSub`) and `fixer.php` (`journalEntryNsTotals` FIND + `fixJournalEntries` FIX) is scoped to
|
|
242
|
+
**rep-bearing lines** (mirroring the importer), while the **Forecast-side** reads in both tools stay
|
|
243
|
+
**UNFILTERED** (still SUM all JE rows, incl. any `salesRepEmployeeId IS NULL`). That asymmetry is
|
|
244
|
+
what surfaces the stale rows: NS (rep-only) **<** FC (still holds no-rep rows) → FIND flags the JE →
|
|
245
|
+
the existing per-`'journalEntry'` orphan-delete in `fixJournalEntries` removes them. **If the FC side
|
|
246
|
+
were ALSO rep-filtered, both sides would match and the stale rows would be invisible / never purged.**
|
|
247
|
+
A blanket `DELETE WHERE salesRepEmployeeId IS NULL` is the wrong fix — locally-null also means
|
|
248
|
+
"rep present in NetSuite but unmapped to `Forecast.Employees`," which must survive; the fixer sweep
|
|
249
|
+
correctly re-derives "no raw rep" from NetSuite. One-time cleanup =
|
|
250
|
+
`fixer.php --commit --prod --category sales` over the JE window, then `checker.php` to confirm
|
|
251
|
+
(in current prod this was a no-op — 0 no-rep JE rows — so the importer filter is preventative).
|
|
236
252
|
- **JE item dimension is NULL today; here is how to enable it later.** `itemId` is null on JE Sales rows
|
|
237
253
|
because the JE import path reads item **only** via the configurable `JE_LINE_ITEM_FIELD` (currently null)
|
|
238
254
|
— it does **not** read native `transactionline.item`, so even a populated native `tl.item` would NOT flow
|
|
@@ -468,11 +484,32 @@ None — Forecast2 is a single shared dataset.
|
|
|
468
484
|
invariant, any field added to the open-order **importer** (TRUE-79162) must also be added to this
|
|
469
485
|
OOI path (and ideally behind the same `forecastColumnExists` guard pattern) or `fixer.php`/`looper`
|
|
470
486
|
will overwrite the importer's value to NULL on its next run.
|
|
487
|
+
- **`IS NOT NULL` on a CUSTOM `transactionline` column inside a SuiteQL aggregate is a ~15× planner
|
|
488
|
+
trap — use `> 0` instead.** Filtering `custcol_sales_rep_line` (an employee-ref custom column) with
|
|
489
|
+
`AND tl.custcol_sales_rep_line IS NOT NULL` inside checker's UNION'd moment-fingerprint (the
|
|
490
|
+
`MOD(SUM(MOD(id*id,P)),P)` power-sum aggregates) made the planner scan pathologically: a full-window
|
|
491
|
+
checker run measured **~122s** with `IS NOT NULL` vs **~8s** with the semantically identical
|
|
492
|
+
`AND tl.custcol_sales_rep_line > 0` (employee internalIds are always positive). Applied to
|
|
493
|
+
`checker.php` (JE arm) and `fixer.php` (both JE queries) with inline comments warning against
|
|
494
|
+
reverting. See the SuiteQL API reference for the general rule. (Use `> 0`, or `NVL(col,0) <> 0`,
|
|
495
|
+
never `IS NOT NULL`, for a custom employee-ref `transactionline` column in a SuiteQL aggregate.)
|
|
471
496
|
- These tools live in `test/@dave/` (developer tooling), but `trueup_open_orders` has been run
|
|
472
497
|
against production. The `Defaults`/checkpoint mechanics of the scheduled sync are separate.
|
|
473
498
|
|
|
474
499
|
## Change history
|
|
475
500
|
|
|
501
|
+
- 2026-06-30 — **Made the fixer the cleanup mechanism for no-rep JE rows + killed a ~15× SuiteQL
|
|
502
|
+
planner trap (TRUE-79968, dfranks).** The importer now keeps only rep-bearing JE lines (see the
|
|
503
|
+
sale-import doc); to let `fixer.php` purge any pre-existing no-rep `Forecast.Sales` JE rows with no
|
|
504
|
+
separate delete script, the **NS-side** JE SuiteQL in both `checker.php` and `fixer.php` was scoped
|
|
505
|
+
to rep-bearing lines while the **FC-side reads stay UNFILTERED** — a deliberate, load-bearing
|
|
506
|
+
asymmetry (NS rep-only < FC still-has-no-rep → FIND flags → existing per-`'journalEntry'` orphan
|
|
507
|
+
delete removes them; filtering the FC side too would hide them forever). A blanket
|
|
508
|
+
`DELETE WHERE salesRepEmployeeId IS NULL` is wrong (locally-null also = NS-present-but-unmapped rep,
|
|
509
|
+
which must survive). **Perf:** replaced `AND tl.custcol_sales_rep_line IS NOT NULL` with
|
|
510
|
+
`AND ... > 0` in checker (JE arm) + fixer (both JE queries) — a clean full-window checker run dropped
|
|
511
|
+
from **~122s to ~8s** purely from the predicate form, with inline anti-revert comments. Current prod
|
|
512
|
+
had 0 no-rep JE rows, so the cleanup sweep was a no-op (filter is preventative). (dfranks)
|
|
476
513
|
- 2026-06-30 — **Folded JOURNAL ENTRIES into the SALES reconciliation in `fixer.php` + `checker.php`
|
|
477
514
|
(TRUE-79862).** Architecture decision (dfranks): a JE is **not its own category** — JE rows live in the
|
|
478
515
|
same `Forecast.Sales` table (`netsuiteTransactionType='journalEntry'`) as the four sale types, so the
|
|
@@ -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-30
|
|
10
10
|
owners: [dfranks]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Component/Forecast/SaleImport/SaleImport.php
|
|
@@ -163,6 +163,19 @@ location, memo). Handler `_Worker_Netsuite_JournalEntry`: `post`/`put` → `sync
|
|
|
163
163
|
item-line upsert/reconcile machinery (`syncLines`/`guardedInsert`/`deleteRows`).
|
|
164
164
|
|
|
165
165
|
**Mapping model (durable design):**
|
|
166
|
+
- **Only JE lines that carry a sales rep are imported (TRUE-79968, dfranks 2026-06-30).**
|
|
167
|
+
`buildJournalEntryRows` applies a **PER-LINE** filter **before** the (salesRep,item)
|
|
168
|
+
bucketing: a GL line whose **raw** `custcol_sales_rep_line` is empty is **skipped**
|
|
169
|
+
(`continue`). Consequences — a JE with no rep on any line yields **zero** `Forecast.Sales`
|
|
170
|
+
rows; a mixed JE keeps only its rep-bearing groups; offset/balancing lines (which omit the
|
|
171
|
+
custom column) drop out naturally. **The test is on the RAW NetSuite line field, NOT the
|
|
172
|
+
mapped local `salesRepEmployeeId`** — so a rep present in NetSuite but **not yet mapped** to
|
|
173
|
+
a local `Forecast.Employees` row **still qualifies** (its group keeps a null
|
|
174
|
+
`salesRepEmployeeId`). This is deliberate: it keeps no-rep JE noise out of Forecast revenue
|
|
175
|
+
while not silently dropping a real-but-unmapped rep's revenue. (A blanket
|
|
176
|
+
`DELETE WHERE salesRepEmployeeId IS NULL` would be **wrong** for the same reason — locally
|
|
177
|
+
null also means "rep present in NS but unmapped"; any cleanup must re-derive "no raw rep"
|
|
178
|
+
from NetSuite. See the reconciliation doc for how the fixer sweep does exactly that.)
|
|
166
179
|
- **Sales rep is now LIVE on JE lines** as the custom column **`custcol_sales_rep_line`**
|
|
167
180
|
(`JE_LINE_SALESREP_FIELD = 'custcol_sales_rep_line'`, wired 2026-06-29 after live Apr–May
|
|
168
181
|
2026 probing). The value is a NetSuite **employee REFERENCE object** `{links, id, refName}`;
|
|
@@ -429,6 +442,20 @@ record is deleted in NetSuite.)
|
|
|
429
442
|
- The cron's sign handling is not portable here — see Sign convention.
|
|
430
443
|
|
|
431
444
|
## Change history
|
|
445
|
+
- 2026-06-30 — **JE import now keeps ONLY rep-bearing lines** (TRUE-79968, dfranks).
|
|
446
|
+
`buildJournalEntryRows` skips any GL line whose **raw** `custcol_sales_rep_line` is empty,
|
|
447
|
+
applied **per-line before** the (salesRep,item) bucketing: a no-rep JE → 0 Sales rows, a
|
|
448
|
+
mixed JE keeps only rep-bearing groups. The filter tests the **RAW NS field**, not the
|
|
449
|
+
mapped local `salesRepEmployeeId`, so a rep present in NetSuite but unmapped to
|
|
450
|
+
`Forecast.Employees` still qualifies (group stays null `salesRepEmployeeId`) — which is also
|
|
451
|
+
why a blanket `DELETE WHERE salesRepEmployeeId IS NULL` is wrong; cleanup must re-derive
|
|
452
|
+
"no raw rep" from NetSuite. No-rep cleanup is handled by the existing `fixer.php` orphan-
|
|
453
|
+
delete sweep (see the reconciliation doc), not a separate delete script — in current prod
|
|
454
|
+
there were 0 no-rep JE rows so the filter is preventative going forward.
|
|
455
|
+
**Confirmed the JE webhook handler `_Worker_Netsuite_JournalEntry`
|
|
456
|
+
(worker2/Worker/Netsuite/JournalEntry.php) holds no business logic** — post/put delegate to
|
|
457
|
+
`_Component_Forecast_SaleImport::syncJournalEntry`, delete to `removeAllJournalEntry`; editing
|
|
458
|
+
the importer in `_underscore` is the single correct entry point for JE import changes. (dfranks)
|
|
432
459
|
- 2026-06-29 — **Wired JE sales-rep ingestion live** (TRUE-79862): set
|
|
433
460
|
`JE_LINE_SALESREP_FIELD = 'custcol_sales_rep_line'` after live Apr–May 2026 probing
|
|
434
461
|
confirmed the rep is now present on JE lines as that custom column — a NetSuite employee
|
package/package.json
CHANGED