toga-ai 1.0.931 → 1.0.933

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.
@@ -21,6 +21,7 @@
21
21
  | [NetSuite File Cabinet Content Retrieval via RESTlet (fetchInvoiceFile)](features/netsuite-filecabinet-restlet.md) | How 1.0 pulls NetSuite File Cabinet binary content (invoice PDFs) via a SuiteScript RESTlet (`fetchInvoiceFile`); open when fetching NetSuite file bytes over RE |
22
22
  | [assetType from NetSuite itemtype during Item Sync (opt-in per client)](features/netsuite-item-assettype-sync.md) | Stamp `Items.assetTypeId` from NetSuite `item.itemtype` during item sync (opt-in per client, fill-NULL-only); open before enabling asset-type mapping for a clie |
23
23
  | [NetSuite classification → ItemClasses sync (opt-in per client)](features/netsuite-item-class-sync.md) | `App_Api_Toga2::getCreateItemClass()` mirrors NetSuite's **classification tree** into the client's `ItemClasses` table and stamps `Items.itemClassId`. |
24
+ | [Items.description from the NetSuite item record (Sales Description), refreshed every sync](features/netsuite-item-description-sync.md) | How the NetSuite sync sets and refreshes `Items.description` (all NetSuite clients); open when a toga item description is blank, stale, or differs from NetSuite |
24
25
  | [isFulfillable from NetSuite during Item Sync (Phase 1)](features/netsuite-item-isfulfillable-sync.md) | 1.0 Phase-1 stamping of the NetSuite `isfulfillable` flag onto the Agilant source item during sync; open before changing fulfillability sync or a client overrid |
25
26
  | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | Working reference for the Agilant NetSuite REST/SuiteQL integration (`App_Api_Netsuite_Rest`): auth, SuiteQL mechanics, table schemas, type/status codes, perfor |
26
27
  | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | Non-obvious NetSuite SuiteQL/REST field semantics behind `App_Api_Netsuite_Rest` shims (signs, `tl.id`==line, `iscogs`, ShipItem cost, `createdFrom`, shim-shape |
@@ -0,0 +1,47 @@
1
+ ---
2
+ title: Items.description from the NetSuite item record (Sales Description), refreshed every sync
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-01
10
+ owners: ["rgirish"]
11
+ files:
12
+ - library/app/api/toga2.php
13
+ - library/app/api/netsuite/rest.php
14
+ - worker/crons/toga2/netsuite/common_sync_togasupply.php
15
+ related:
16
+ - netsuite-item-class-sync.md
17
+ - netsuite-item-assettype-sync.md
18
+ - netsuite-item-isfulfillable-sync.md
19
+ - toga2-api-client-and-bridge.md
20
+ - ../../worker/features/netsuite-togasupply-per-client-sync.md
21
+ ---
22
+
23
+ How the NetSuite sync sets and refreshes `Items.description` (all NetSuite clients); open when a toga item description is blank, stale, or differs from NetSuite.
24
+
25
+ ## Summary
26
+ - Source = NetSuite **item record** Sales Description: SuiteQL `item.description`, fallback `item.purchasedescription`. Not the transaction line memo.
27
+ - Refreshed every time the sync touches an item or unit, for **all** NetSuite clients (no opt-in flag).
28
+ - Before 2026-10-01: set once at item create from the line memo, never updated; blank when the item was first seen on an item fulfillment or a memo-less line.
29
+
30
+ ## How it works
31
+ - `App_Api_Netsuite_Rest::fetchItemFlagsByIds()` selects `description, purchasedescription` and sets `$shim->description`.
32
+ - `App_Api_Toga2::getNetsuiteItemDescription()` — item-record text, else line text, cut to `MAX_LENGTH_ITEM_DESCRIPTION` (255, the column size).
33
+ - `App_Api_Toga2::refreshItemDescriptionFromNetsuite()` — PUT `/items/{uuid}` `description` only when NetSuite's value is non-empty and different; updates the caller's cached item so repeat lines do not PUT again.
34
+ - Call sites in `toga2.php`: `getCreateItem` (existing + create), `syncItemFulfillmentFromNetsuite` (line not on the SO), `syncInventoryAdjustmentFromNetsuite`, `syncItemReceiptFromNetsuite` (serialized + non-serialized), `syncTransferOrderFromNetsuite` (one batched flags lookup).
35
+ - `common_sync_togasupply.php` shared `GET /items` lookup requests `description`.
36
+
37
+ ### Rules
38
+ - Never blanks a stored description.
39
+ - Throws if the caller's item GET did not request `description` (fail loud, same pattern as itemClass) — otherwise every item would be re-written every run.
40
+ - Needs the API-role write grant on `Items.description` (RecordFields 109, roleId 3). Checked present in Elite, Adyen, Prudential, Growrk, Nychh.
41
+
42
+ ## Gotchas
43
+ - **⚠ `library` and `worker` MUST deploy together.** Library alone (2026-10-01 prod) made every client's SO sync throw `getCreateItem: the caller's item GET must request 'description'`. Tell-tale: trace `common_sync_togasupply.php:825` = old worker, `:827` = new — PHP reports the LAST argument line of a multi-line call.
44
+ - Items whose only NetSuite records will never resync keep the old text. Fix by hand (done for Elite items 228, 80, 89, 90). Elite item 125 is blank in NetSuite too — nothing to copy.
45
+
46
+ ## Change history
47
+ - 2026-10-01 — Built: description from NetSuite item record, refreshed on every touch, all clients; deploy-together incident; Elite one-time backfill. (rgirish)
@@ -34,6 +34,7 @@ related:
34
34
  - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
35
35
  - ../../../clients/elite/features/netsuite-togasupply-sync.md
36
36
  - ../../library/features/netsuite-item-class-sync.md
37
+ - ../../library/features/netsuite-item-description-sync.md
37
38
  - ../../../clients/compass-usa/features/sales-order-line-renumbering.md
38
39
  - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
39
40
  - ../../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md
@@ -1973,6 +1974,18 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
1973
1974
  invoice against a duplicated SO it will false-throw the same way, and **their duplicates have a DIFFERENT
1974
1975
  cause than Compass's Office-Depot dual-source.** Also a data-quality question: why do six non-Compass
1975
1976
  clients have duplicate SalesOrders rows at all.
1977
+ - **⚠ "refusing to import invoice … billed sales order not imported" can mean the SO is OLDER than the
1978
+ SO cursor (Adyen, 2026-10-01, issue 910).** Invoices from 2026-07-02 on billed 10 SOs last-modified
1979
+ 2025-11-04 → 2026-06-15, but `NETSUITE_LAST_SYNC_CURSOR_SALES_ORDERS` was 2026-07-31, so the SO sync
1980
+ never pulls them. Fix: wait for SO mode `IDLE`, then set the SO cursor (Eastern) before the oldest
1981
+ billed SO's lastmod (here `2025-11-01 00:00:00|0`, ~117 SOs re-pull). To see which invoice/SO it fails
1982
+ on now, read `Logs_<Tenant>.Api` `queryString` — `Logs.Issue` keeps the first message only.
1983
+ - **⚠ The receipt sync never rebuilds lines on an EXISTING receipt** — `itemReceiptItems` is sent on POST
1984
+ only. A receipt whose PO lines were deleted and re-created keeps `ItemReceiptItems.purchaseOrderItemId
1985
+ NULL` (and can gain duplicate unlinked rows), which skews On Hand. Detect:
1986
+ `ItemReceiptItems WHERE purchaseOrderItemId IS NULL AND transferOrderItemId IS NULL`. Fix: delete
1987
+ those receipts and rewind the IR cursor so they re-import (Elite 2026-10-01,
1988
+ `Client_Elite/2026-10-01b`).
1976
1989
  - **⚠ ROOT CAUSE of the duplicate `Items`: the inventory-adjustment item-create OMITTED the catalog
1977
1990
  (fixed 2026-08-31).** `getCreateItem` (`toga2.php:6233`) and the fulfillment create (~4951) stamp
1978
1991
  `'catalog' => ['name' => 'Agilant']`; `syncInventoryAdjustmentFromNetsuite`'s item-create did **not** →
@@ -2065,6 +2078,7 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
2065
2078
  [NetSuite Sync Alert Monitor](../../library/features/netsuite-sync-alert-monitor.md).
2066
2079
 
2067
2080
  ## Change history
2081
+ - 2026-10-01 — `Items.description` now from the NetSuite item record, refreshed every touch ([doc](../../library/features/netsuite-item-description-sync.md); library + worker deploy together). Gotchas: invoice billing an SO older than the SO cursor (Adyen); receipt lines never rebuilt on an existing receipt (Elite). (rgirish)
2068
2082
  - 2026-10-01 — NYCHH diagnosis traps recorded: the wrapper is gated to **once a day** by
2069
2083
  `cronInitialization(86400)` despite its `*/5` schedule entry, and the live cursors/modes live in
2070
2084
  `Client_Nychh.Parameters` — the `NETSUITE_HH_*` keys in `Core` are stale (May 2026) leftovers.
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-23
10
- owners: [jcardinal, mhammontree]
9
+ updated: 2026-10-01
10
+ owners: [jcardinal, mhammontree, rgirish]
11
11
  files:
12
12
  - _underscore/Model/Client/User.php
13
13
  - worker2/Worker/Sync/ActiveDirectory.php
@@ -64,8 +64,10 @@ Northwell ServiceNow users arrive via api2 eBonding (`_Model_Northwell_Ticket` i
64
64
 
65
65
  - **⚠ A reconstructed `new _Model_Client_User($id)` does NOT carry `contactId`.** In `ActiveDirectory.php` the AD sync reads the existing `contactId` from the **bulk SELECT** and only creates a contact when it is truly missing. A naive `empty($user->contactId)` guard against a freshly reconstructed model reads null even when the row has a contact — and would **mint a duplicate contact every sync**. Trust the SELECT'd value, not the rehydrated model property.
66
66
  - **⚠ Set `primaryContactEmailAddressId` with a second server-side load+save, never a nested payload.** The nested singular-FK write re-parents (CREATE) or echoes null (UPDATE).
67
+ - **Adding a user by hand (dbchanges2 SQL): look up `ContactEmailAddresses` for an existing Contact first.** Sync-created Contacts often exist already (Elite: Contacts 29, 886, 1044 from the Elite sync, `Client_Elite/2026-10-01a`); link to them instead of minting a duplicate.
67
68
  - **Interceptor no-ops on a user with no email** — a no-email straggler gets no Contact from this path (the backfill migrations handle those name-only, per client). It is why the fan-out `NOT NULL` skips a client that still has a no-email residual (see [rerunnable-additive-inserts](../../dbchanges2/features/rerunnable-additive-inserts.md)).
68
69
 
69
70
  ## Change history
71
+ - 2026-10-01 — Hand-added users: link an existing Contact found via `ContactEmailAddresses` (Elite). (rgirish)
70
72
  - 2026-09-23 — Corrected the origin map: the Compass PEOPLE importer did **not** safely set `contactId` — it inserted `Users` first and linked afterwards, so the NOT NULL rollout broke it in prod (TRUE-82230). Added the write-order and `LAST_INSERT_ID()` gotchas for every raw-SQL creation path. (mhammontree)
71
73
  - 2026-09-21 — Initial. Decided `Client_*.Users.contactId` goes NOT NULL platform-wide (CTO Option B); built the shared `_Model_Client_User::postPost` interceptor (Core row: recordId 2, clientId+apiId NULL) to create+link a Contact for api2-created users; fixed the worker2 AD-sync and `cacheClickupUser` True paths; recorded the origin map and the reconstructed-model `contactId` gotcha. (jcardinal)
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: [apeterson, ajean]
9
+ updated: 2026-10-01
10
+ owners: [apeterson, ajean, rgirish]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/String.php
@@ -26,16 +26,17 @@ How `/v2/auth/login` swaps to a cross-client (home) user's home client mid-reque
26
26
 
27
27
  ## How it works
28
28
 
29
- `_Component_Api_V2::execute()` (~L701-744):
30
- 1. The user is loaded **by email** from the current client's DB (~L705) — the client is resolved from `ClientEmailDomains`, **not** from any Bearer token.
29
+ `_Component_Api_V2::execute()` (~L762-812, re-checked 2026-10-01 — grep `homeClientUserId`, line numbers drift):
30
+ 1. The user is loaded **by email** from the current client's DB (~L762) — the client is resolved from `ClientEmailDomains`, **not** from any Bearer token.
31
31
  2. The submitted password is checked with `_String::passwordVerify()` → `password_verify()` (bcrypt) against the local user row (`_underscore/String.php` L563-576).
32
- 3. **If the password does NOT verify AND the loaded user row has a non-null `clientId` that differs from the current client**, the engine treats it as a **cross-client user**: it calls `registerClientDatabases($user->clientId)` and re-loads the *home* user via `homeClientUserId` (~L716-731). Authentication then completes against the home client.
32
+ 3. **If the password does NOT verify AND the loaded user row has a non-null `clientId` that differs from the current client**, the engine treats it as a **cross-client user**: it calls `registerClientDatabases($user->clientId)` and re-loads the *home* user via `homeClientUserId` (~L787-812). Authentication then completes against the home client.
33
33
 
34
34
  This is the same `homeClientUserId` bridge that [cross-client data retrieval](./cross-client-data-retrieval.md) uses to fan a home user across tenant DBs — here it governs which client a login actually authenticates against.
35
35
 
36
36
  ## Gotchas
37
37
 
38
38
  - **A login `EO-1 "Unknown database 'client_<x>'"` is the cross-client branch failing to open the home client's DB.** Observed: logging into **Elite** locally with `devteam@togatech.com` throws `EO-1 Unknown database 'client_true'`. That account is an internal TOGA (client `true` / `Client_True`) user shared into Elite via `homeClientUserId`; because its Elite-local password does not verify, the cross-client path tries to open `Client_True`, which does not exist in local dev. It works on the dev sandbox only because `Client_True` exists there.
39
+ - `devteam@togatech.com` in `Client_Elite`: `clientId` 1 (True), `homeClientUserId` 2 = `Client_True` devteam@goagilant.com (re-checked 2026-10-01).
39
40
  - **Local-dev workaround (never commit):** set the **local** `Client_<Client>` user's own bcrypt password hash to a known value so login succeeds on the **direct** path and never enters the cross-client branch. Generate with `_String::passwordHash()` / `password_hash($pw, PASSWORD_BCRYPT)`. A per-machine local fixup, not a code or data change to commit.
40
41
  - **The api2 dev server talks to `localhost` as `root` — sandbox DB edits are invisible to it.** Local stack traces show `mysqli_connect(["localhost","root",...])`. Any ACL/grant/password change must be applied to the **localhost** client DB, not a sandbox connection in a DB GUI, or the local API will never see it. (In DbGate: "Run" autocommits; a query-tab "unsaved" label refers to the unsaved `.sql` *script file*, not pending uncommitted data.) This exact confusion — a grant present in sandbox while the API read localhost — cost real debugging time.
41
42
  - **⚠ The domain lookup is `Core.ClientEmailDomains` at `V2.php:611-621`, and it only runs when the request host does not identify the client.** Confirmed 2026-09-04. Two follow-ons: an **editable `Users.email` is therefore a tenant-routing control**, and the session bind at `V2.php:762-765` attaches the session to whichever row carries the address (that path **does** filter `isActive = true`). Both matter to anything that writes an email — see [Users.email is a login identity](../../_underscore/features/user-email-as-identity-and-password-reset.md).
@@ -46,3 +47,6 @@ This is the same `homeClientUserId` bridge that [cross-client data retrieval](./
46
47
  - [Surface meta option](./surface-meta-option.md)
47
48
  - [Per-client database connections](../../_underscore/features/per-client-database-connections.md)
48
49
  - [Users.email as login identity & password reset](../../_underscore/features/user-email-as-identity-and-password-reset.md)
50
+
51
+ ## Change history
52
+ - 2026-10-01 — Re-checked V2.php line refs (~L762-812); worked example: devteam@togatech.com in Elite = clientId 1 (True), homeClientUserId 2. (rgirish)
@@ -32,7 +32,7 @@
32
32
  | [Etilize Item Translation Import](features/etilize-item-translation-import.md) | Imports non-English item + feature text from Etilize into the client's `ItemTranslations` (and feature sidecar tables), writing directly to `DB_CLIENT`; open wh |
33
33
  | [Logs retention cleanup worker (Infrastructure/Logs/Cleanup — Logs_<Tenant>.Api pruning)](features/logs-retention-cleanup.md) | Daily batched pruning of `Logs_<Tenant>.Api` (GET 30d, other methods 90d); open when changing log retention, adding a table to prune, or when an old Api log row |
34
34
  | [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | DB-driven monitoring framework: one orchestrator applies anti-flap + notification rules to any registered "is X healthy?" child check; open when adding a monito |
35
- | [Bidirectional NetSuite ↔ Forecast ↔ ClickUp opportunity stage sync — BUILT (stage map, echo guard, claim split)](features/netsuite-clickup-opportunity-bidirectional-design.md) | Two-way NetSuite↔ClickUp opportunity STAGE sync (shared `_Component_Opportunity_StageMap`, actor echo guard, claim split, stale-claim takeover); open before tou |
35
+ | [Bidirectional NetSuite ↔ Forecast ↔ ClickUp opportunity stage + title sync — BUILT (stage map, echo guard, claim split, NS-owned title)](features/netsuite-clickup-opportunity-bidirectional-design.md) | Two-way NetSuite↔ClickUp opportunity STAGE sync + one-way NS→CU TITLE sync (shared `_Component_Opportunity_StageMap`, actor echo guard, claim split, stale-claim |
36
36
  | [NetSuite Integrations Monitor (Monitor/Operations/NetsuiteIntegrations)](features/netsuite-integrations-monitor.md) | Cross-client health check that flags stuck NetSuite↔2.0 integrations; open when a NetSuite integration hangs or when building an all-client OneUptime sweep. |
37
37
  | [NetSuite item → client routing (there is NO client on the NetSuite item record)](features/netsuite-item-client-routing.md) | Evidence that no NetSuite field can route an item to a client; open before designing NetSuite item webhook phase 2 (TRUE-80553) so the "route by a NetSuite fiel |
38
38
  | [ClickUp Opportunity Client vs End-Customer Labels (Stakeholders / End Customer)](features/netsuite-opportunity-client-labels.md) | The NetSuite→ClickUp opportunity task's two customer labels (Stakeholders=client, End Customer=`custbody_end_customer`), their backfills/digest and the fuzzy-ma |
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-25
9
+ updated: 2026-10-01
10
10
  owners: [jcardinal, ajean]
11
11
  files:
12
12
  - worker2/Worker/Clickup/Project.php
@@ -49,6 +49,8 @@ This single fact determines the shape of the whole NetSuite↔ClickUp opportunit
49
49
  | Closed Won | `901111987478` |
50
50
  | Closed Lost | `901111987480` |
51
51
 
52
+ **Won leaves the folder; Lost stays.** Setting Opportunity Stage = Opportunity Won makes a ClickUp native Automation move the task out of the Opportunities folder into Project Hub (space `90113939341`) → list "New Project" (`901112061579`), folder "hidden" (`90117098908`), status "new project", task type Epic. The API cannot undo it (home list). Closed Lost tasks stay in the folder (list `901111987480`). So code scoped to folder `90117060687` (e.g. the NS→CU title sync) does not see Won tasks.
53
+
52
54
  All 8 share **one status group** (`proj_90113928591`), so `opportunity won` and `closed lost` are valid statuses on **every** list — a status is not what distinguishes a stage.
53
55
 
54
56
  **ClickUp v2 cannot move a task's home list**, and it fails in two different ways — one silent. Recorded empirically at `worker2/Worker/Clickup/Project.md:14-26`:
@@ -143,4 +145,5 @@ Read-only / setup actions, invoked via `curl -X POST https://worker.togahub.com/
143
145
  - [Outbound third-party API rate budget](../../_underscore/features/outbound-api-rate-budget.md)
144
146
 
145
147
  ## Change history
148
+ - 2026-10-01 — Won tasks are moved to Project Hub "New Project" by Automation (ajean)
146
149
  - 2026-09-25 — Noted folder-gated opportunity dispatch, no early return (jcardinal)
@@ -1,12 +1,12 @@
1
1
  ---
2
- title: Bidirectional NetSuite ↔ Forecast ↔ ClickUp opportunity stage sync — BUILT (stage map, echo guard, claim split)
2
+ title: Bidirectional NetSuite ↔ Forecast ↔ ClickUp opportunity stage + title sync — BUILT (stage map, echo guard, claim split, NS-owned title)
3
3
  framework: "2.0"
4
4
  repo: worker2
5
5
  project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-25
9
+ updated: 2026-10-01
10
10
  owners: ["ajean", "jcardinal"]
11
11
  files:
12
12
  - worker2/Component/Opportunity/StageMap/StageMap.php
@@ -25,16 +25,18 @@ related:
25
25
  - ../../../../1.0/apps/worker/features/forecast2-netsuite-reconciliation.md
26
26
  ---
27
27
 
28
- Two-way NetSuite↔ClickUp opportunity STAGE sync (shared `_Component_Opportunity_StageMap`, actor echo guard, claim split, stale-claim takeover); open before touching any opportunity stage/close/reopen logic or the stage map rules.
28
+ Two-way NetSuite↔ClickUp opportunity STAGE sync + one-way NS→CU TITLE sync (shared `_Component_Opportunity_StageMap`, actor echo guard, claim split, stale-claim takeover); open before touching any opportunity stage/close/reopen/title logic or the stage map rules.
29
29
 
30
30
  ## Summary
31
31
 
32
- Built 2026-09-24 (`jcardinal`, design by `ajean`). Code on worker2 `_production`; flags `SYNC_STAGE_TO_CLICKUP` (NS→CU) and `SYNC_CLOSE_TO_NETSUITE` (CU→NS close/reopen) now `true`. `UPDATE_CLICKUP` (full-task NS→CU rewrite) stays `false`. Live verification still pending — see Handoff. Notes/files are a separate job: [opportunity documents sync](./netsuite-opportunity-documents-sync.md).
32
+ Built 2026-09-24 (`jcardinal`, design by `ajean`). Code on worker2 `_production`; flags `SYNC_STAGE_TO_CLICKUP` (NS→CU), `SYNC_TITLE_TO_CLICKUP` (NS→CU title, PR #183, 2026-10-01) and `SYNC_CLOSE_TO_NETSUITE` (CU→NS close/reopen) all `true`. `UPDATE_CLICKUP` (full-task NS→CU rewrite) stays `false`. Live verification still pending — see Handoff. Notes/files are a separate job: [opportunity documents sync](./netsuite-opportunity-documents-sync.md).
33
33
 
34
34
  ## How it works
35
35
 
36
36
  ### Per-field ownership
37
- NetSuite owns: Opportunity Stage dropdown, close state. ClickUp owns: description (Fluffer output — never diffed from NS), assignees, presales fields. NS→CU writes ONLY the Opportunity Stage dropdown (`d8fd5819-d8f7-494b-8671-aff7fbdeb985`); the ClickUp Automation moves the task list from it ([routing](./clickup-project-routing.md)).
37
+ NetSuite owns: Opportunity Stage dropdown, close state, **opportunity title** (decided Aaron Jean 2026-10-01). ClickUp owns: description (Fluffer output — never diffed from NS), assignees, presales fields. NS→CU writes ONLY the Opportunity Stage dropdown (`d8fd5819-d8f7-494b-8671-aff7fbdeb985`) and the task name; the ClickUp Automation moves the task list from the dropdown ([routing](./clickup-project-routing.md)).
38
+
39
+ **Title rule:** CU→NS never writes title (not NetSuite, not `Forecast.title`). A human rename in ClickUp is reverted by the next NS event — accepted on purpose, so NetSuite stays the one record.
38
40
 
39
41
  ### Stage map — `_Component_Opportunity_StageMap` (PHP, one file, both directions)
40
42
  Replaced `SALES_STAGE_MAP` / `STAGE_OPTION_ORDERINDEX` constants (deleted). Rules from Aaron Jean + Eric, 2026-09-24:
@@ -61,18 +63,26 @@ Option orderindex is resolved LIVE from the field's `type_config.options` (fixes
61
63
 
62
64
  ### CU→NS (`Worker/Clickup/Opportunity.php`)
63
65
  - Routed from `Worker/Clickup.php` by Opportunities **folder** `90117060687` (all 8 stage lists), no early return — hub routing handlers still run.
66
+ - Writes stage/close only — never title (PR #183 removed the title PATCH, the `Forecast.title` write, `stripTaskNamePrefix()`, `CLICK_UP_CUSTOM_FIELD_ID__CUSTOMER_NUMBER`, `FORECAST_TITLE_MAX_LENGTH`). `clickupPostComment()` is public as an internal helper — not a queue action.
64
67
  - **Actor echo guard:** drop an event when every history item's actor is our config-token or component-token user (resolved via `GET /user`). Never drop actor `-1` (ClickUp Automation — a human list-move arrives as `taskUpdated` on the dropdown with actor `-1`). Value diff kept as backstop.
65
68
  - **Link check (security):** before any PATCH, require `Forecast.Opportunities.clickupTaskId === taskId`. `Opportunity #` is free text; anyone could point a task at another opp.
66
69
  - `logged()` commits `DB_LOGS`.
67
70
 
68
71
  ### NS→CU (`Worker/Netsuite/Opportunity.php`) — claim split
69
- - The `clickupTaskId` claim now guards **creation only**. Linked opportunity → `syncStageToClickup()`: fresh task GET, write dropdown only if the map says so; ClickUp 404 = skip.
72
+ - The `clickupTaskId` claim now guards **creation only**. Linked opportunity → `syncLinkedTaskToClickup()` (was `syncStageToClickup()`): one task GET, then title sync and stage sync run independently — both are tried, one combined exception if either fails. ClickUp 404 = `sync skipped (task X not found in ClickUp)`.
73
+ - **Title sync — `syncTitleToClickup()`** (kill switch `SYNC_TITLE_TO_CLICKUP`):
74
+ - Only tasks in Opportunities folder `90117060687`; Won tasks moved to Project Hub are left alone.
75
+ - Match = normalized name equals the NS title, or ends with ` — <title>` / ` - <title>`. Empty NS title → skip.
76
+ - Mismatch → PUT name from pure `buildTaskName()` (same function task creation uses, so names are byte-identical), then comment: "The opportunity title was changed in NetSuite, so the automation has updated it in ClickUp. Previous name: …".
77
+ - Post-PUT check: if ClickUp stored a different name → no comment, Sentry message, result `title rename did not stick`. Comment failure is non-fatal and never auto-retried.
78
+ - `setAutoRetry` on the name PUT and the stage POST. Result string ends `; title updated|unchanged|skipped (...)`.
70
79
  - **Stale PENDING takeover:** `PENDING` older than 30 min (or NULL `dtClickupClaimed`) can be re-claimed. `dtClickupClaimed` is raw-SQL only — deliberately NOT on either ORM model (1.0 or 2.0); code checks `SHOW COLUMNS` first. `endClaim()` / `loadClaimTimestamp()` are fenced on the claim timestamp so a slow job can't overwrite a newer owner.
71
80
 
72
81
  ### Reconciler
73
82
  The 1.0 `periodic_forecast_discrepancy_fix_opportunities.php` still INSERTs (Aaron's 2026-09-09 session kept and completed it) — the old "reconciler becomes a reporter" plan is dropped.
74
83
 
75
84
  ## Gotchas
85
+ - **NS→CU stage write echoes back as a human event.** The stage write makes the ClickUp Automation move the list; that event has actor `-1`, so the echo guard never drops it and CU→NS `Process` runs. Before PR #183 this PATCHed NetSuite title from the stale ClickUp name (old ` - ` format; `UPDATE_CLICKUP=false` meant NS title edits never reached ClickUp). 15 NetSuite titles were corrupted 2026-10-01 (e.g. "Pen test" → "68272 - 4502 Societe Generale Financial Corp"); each PATCH also re-fired the NS webhook (3 PUTs per opp). Restore list is held by Aaron, restore deferred. Lesson: anything CU→NS writes must be safe to run after every NS→CU write.
76
86
  - `taskMoved` is not subscribed for space `90113928591`; stage detection relies on the dropdown `taskUpdated`.
77
87
  - `Opportunities` FK children (`OpportunityDocuments`) use ON DELETE CASCADE because the 1.0 reconciler/fixer delete `Opportunities` rows.
78
88
  - worker2 has no `?controller=` URL routing — run the map test / probes via CLI runner.
@@ -90,5 +100,6 @@ The 1.0 `periodic_forecast_discrepancy_fix_opportunities.php` still INSERTs (Aar
90
100
  - [ClickUp routing](./clickup-project-routing.md) — stage-as-list Automation.
91
101
 
92
102
  ## Change history
103
+ - 2026-10-01 — NetSuite owns title: CU→NS title write removed, NS→CU title sync added (PR #183) (ajean)
93
104
  - 2026-09-25 — Built: StageMap component, actor guard, link check, claim split, stale takeover; flags on. Map stays PHP; reconciler keeps INSERT (jcardinal)
94
105
  - 2026-09-16 — Agreed design recorded, not built (ajean)
@@ -86,7 +86,7 @@ Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer
86
86
 
87
87
  Shared helpers: `buildCustomFields()` (the field array, used by both create and update), `resolvePresalesUserId()` (cached `Client_True.Users.c_clickupUserIdentifier` lookup → else a `GET /team` roster scan that back-fills the cache via `cacheClickupUser()`).
88
88
 
89
- - **Task name** = `{opportunityNumber} — {customer} — {title}`, e.g. `60533 — 560 Endeavor Health — Data Center Facility Consolidation Services` (empty parts dropped).
89
+ - **Task name** = `{opportunityNumber} — {customer} — {title}` from pure `buildTaskName()` (also used by the NS→CU title sync), e.g. `60533 — 560 Endeavor Health — Data Center Facility Consolidation Services` (empty parts dropped).
90
90
  - **Task description** = a fixed structured block built by `buildDescription()`:
91
91
  ```
92
92
  Company: 560 Endeavor Health
@@ -123,7 +123,7 @@ Required a new `dbchanges2/Forecast/` ALTER adding the `clickupTaskId` column, a
123
123
 
124
124
  #### Claim guards creation only (since 2026-09-24)
125
125
 
126
- The claim used to double as "do anything at all", making the integration create-only. Now split: a linked opportunity goes to `syncStageToClickup()` (stage dropdown only, behind `SYNC_STAGE_TO_CLICKUP = true`). `updateTask()` is still dead behind `UPDATE_CLICKUP = false` — leave it. A stale `PENDING` (>30 min via `dtClickupClaimed`, or NULL timestamp) is now taken over automatically. Details: [stage sync](./netsuite-clickup-opportunity-bidirectional-design.md).
126
+ The claim used to double as "do anything at all", making the integration create-only. Now split: a linked opportunity goes to `syncLinkedTaskToClickup()` (stage dropdown + task name only, behind `SYNC_STAGE_TO_CLICKUP` / `SYNC_TITLE_TO_CLICKUP`). `updateTask()` is still dead behind `UPDATE_CLICKUP = false` — leave it. A stale `PENDING` (>30 min via `dtClickupClaimed`, or NULL timestamp) is now taken over automatically. Details: [stage sync](./netsuite-clickup-opportunity-bidirectional-design.md).
127
127
 
128
128
  `clickupTaskId` semantics: `NULL` = unclaimed; `PENDING` = claim in flight; else the real task id.
129
129
 
@@ -173,6 +173,13 @@ curl -X POST https://webhook.togahub.com/netsuite -H 'Content-Type: application/
173
173
  - An opportunity with no presales lead returns `skipped (no presales lead)` and stays queued.
174
174
  - `importOpportunity()` is self-healing, so the replay also repairs the Forecast row.
175
175
 
176
+ **Bulk replay rules (from the 2026-10-01 Won/Lost catch-up — 287 tasks, 149 → Closed Lost, 138 → Opportunity Won):**
177
+ - **Retroactive data fixes use the live-sync logic (webhook replay), never hand-set values in ClickUp** (decided Aaron Jean 2026-10-01).
178
+ - **Never bulk-replay without PR #183.** Before it, each replay's stage write echoed back through CU→NS and overwrote NetSuite titles (see [stage sync](./netsuite-clickup-opportunity-bidirectional-design.md) gotchas).
179
+ - **Space replays 3–5 s apart.** ClickUp returned HTTP 429 at 0.5 s (31 failures) and still 8 failures at 1 s with retries. A failed replay is safe to re-send.
180
+ - Candidate list: ClickUp folder `90117060687` tasks, match by opportunity number to `Forecast.Opportunities` + `PercentToCloseStatuses.netsuiteInternalId` (`124`/`130` = Won, `14` = Lost). `130` Alternate Quote goes to Won per StageMap even when NS status reads Closed - Lost.
181
+ - The ClickUp MCP filter rejects two filters on the same custom field ("use a single RANGE") — run `!= Won` and `= Lost` as separate queries.
182
+
176
183
  **A `404` on replay is the correct signal for a NetSuite-deleted opportunity, not a defect.** The single failure in that run (NS internalId **7442501**) was NetSuite REST HTTP 404 *"The opportunity does not exist"* — the record had been deleted in NetSuite, which is also why it was the one row missing from Forecast.
177
184
 
178
185
  #### Open item — item-sync watchdog timeouts (NOT investigated)
@@ -245,7 +252,7 @@ Three facts worth not rediscovering:
245
252
  1. Resolve NS opp from `Opportunity #` (`a5529cdc-8dbb-4165-8bc5-9f5cc691796a`, = `tranId`) via one-row SuiteQL.
246
253
  2. **Require `Forecast.Opportunities.clickupTaskId === taskId`** — else skip (free-text field; security).
247
254
  3. Drop echo events by actor (our token users; never actor `-1`); GET the opp with `expandSubResources=true`.
248
- 4. PATCH only differing NS-owned fields (`title`, stage/`entityStatus` per `_Component_Opportunity_StageMap`).
255
+ 4. PATCH only differing stage/`entityStatus` per `_Component_Opportunity_StageMap`. **Never title** — NetSuite owns it (PR #183, 2026-10-01).
249
256
 
250
257
  Stage/close/reopen rules: [stage sync](./netsuite-clickup-opportunity-bidirectional-design.md). Notes/files: [documents sync](./netsuite-opportunity-documents-sync.md).
251
258
 
@@ -327,5 +334,6 @@ Stage/close/reopen rules: [stage sync](./netsuite-clickup-opportunity-bidirectio
327
334
  - [MCP Tool Usage](../../../../standalone/apps/claude/workflows/mcp-tool-usage.md) — the `toga-db` query traps hit while running the verdict census.
328
335
 
329
336
  ## Change history
337
+ - 2026-10-01 — CU→NS no longer writes title; bulk replay rules from the Won/Lost catch-up (ajean)
330
338
  - 2026-10-01 — Flagged `folder_ids[]` vs documented `project_ids[]` filter (unverified) (ajean)
331
339
  - 2026-09-25 — Claim guards creation only; CU→NS routed by folder, link check, actor guard; orderindex-0 bug fixed (jcardinal)
@@ -4,7 +4,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
4
4
 
5
5
  ## 1.0 framework
6
6
 
7
- - **library** (Library) _(framework core)_ — 30 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 31 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 41 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
@@ -54,6 +54,9 @@ Adyen live TOGa Supply client (`Client_Adyen`) on the 1.0 NetSuite→TOGa Supply
54
54
 
55
55
  - **⚠ Transfer-order mode not independently verified against Adyen's NetSuite account.** The mode preserves the behavior Adyen had under the old implicit default; nobody has confirmed with the client that $0 + hold is the right signal *for Adyen*. Check before trusting the split.
56
56
 
57
+ - **⚠ Invoice sync stuck 2026-10-01 (issue 910, "billed sales order not imported")** — the billed SOs are older than the SO cursor. Fix handed to developer: SO cursor → `2025-11-01 00:00:00|0` once SO mode is `IDLE`. Details on the [engine doc](../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
58
+ - **⚠ OPEN: `ShippingCarriers` has only FedEx + UPS.** Item-fulfillment tracking POST fails `EV-12` (issue 925) — likely a USPS/DHL carrier. Not fixed. See [shipping carriers](../../2.0/apps/_underscore/features/shipping-carriers-and-accounts.md).
59
+
57
60
  ### Environments — where `Client_Adyen` lives, and which one "beta" means
58
61
 
59
62
  - `toga_find_schema` returns the tenant DB in exactly **three** environments: **prod, dev-sandbox, client-sandbox**. There is **no `Client_Adyen` in `client-beta`**.
@@ -6,12 +6,13 @@ project: _Underscore
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-30
9
+ updated: 2026-10-01
10
10
  owners: [rgirish]
11
11
  files:
12
12
  - _underscore/Model/Elite/Item.php
13
13
  - _underscore/Model/Client/Item.php
14
14
  - worker/crons/toga2/netsuite/sync_togasupply_elite.php
15
+ - dbchanges2/Client_Elite/2026-10-01b - EliteReimportReceiptsWithUnlinkedLines.sql
15
16
  related:
16
17
  - ../profile.md
17
18
  - ./netsuite-togasupply-sync.md
@@ -43,7 +44,13 @@ How Elite's `_qtyOnHand` / `_qtyAvailable` are computed (`_Model_Elite_Item`) so
43
44
  - **Inventory page filter** is data, in `Client_Elite/2026-09-28b`: hides `SVC-`/`SVT-`/`LIC-`/`CON-` parts, AppleCare, service item classes and drop-ship-only items; shows only rows with nonzero on hand or available.
44
45
  - **Known gap: Docking 1 is below NetSuite** — RMA return receipts (created from a Return Authorization) are not imported by the sync, so returned stock is missing. Open.
45
46
  - Owned-warehouse scope is Elite-only today; other clients have fake "bay" warehouses (see [Elite sync](./netsuite-togasupply-sync.md)).
47
+ - **⚠ OPEN (developer to decide): Qty On Order is ALWAYS 0.** `Elite/Item.php` overrides `_qtyOrdered = _qtyReceived`, and `Client/Item.php` `_qtyOnOrder = Ordered − Received`. So "Qty Ordered" on the Inventory page means units received on POs. NetSuite has real on-order qty.
48
+ - **On Hand off by 1 = receipt lines with NULL `purchaseOrderItemId`** (PO lines deleted + re-created; sync never rebuilds lines on an existing receipt). Fix = delete the receipts + rewind the IR cursor — `Client_Elite/2026-10-01b` (receipts 6947587, 6957582, 7043074, 7068990; written, not yet run). Detection on the [engine doc](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
49
+ - **Part numbers match case-insensitively** — NS items 1505560 "iPhone 14" and 1505562 "IPhone 14" are ONE toga item. Sum both when comparing to NetSuite.
50
+ - **Compare to NetSuite** with SuiteQL `aggregateItemLocation` (location 196) for qty, `inventoryNumberLocation` for serials.
51
+ - **Client-facing guide** (Ordered, On Hand, Available, Fulfilled, Invoiced, On Order, worked example Docking Station 67 received + 100 adjusted − 15 SO − 69 TO = 83): https://claude.ai/artifact/2wrVUcTBesKEq2b2CsySKw. Artifact pages print badly — make the PDF with headless Chrome `--print-to-pdf` and `display:block` in `@media print`.
46
52
 
47
53
  ## Change history
54
+ - 2026-10-01 — Qty On Order always 0 (open); receipt-line On Hand fix `2026-10-01b`; case-insensitive part merge; NS compare queries; client guide link. (rgirish)
48
55
  - 2026-09-30 — New formula: owned-warehouse (`allowReceiving = 1`) STOCK SO lines + Transfer Order shipped/committed (`qtyCommittedTransferOrders`); verified vs NetSuite `aggregateitemlocation`. Inventory filter 2026-09-28b; RMA receipt gap recorded. (rgirish)
49
56
  - 2026-09-17 — Drop ship excluded from `_qtyOnHand` / `_qtyAvailable` only; no floor; NULL `fulfillmentType` not stock; base composites `self::` → `static::`. (rgirish)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-30
9
+ updated: 2026-10-01
10
10
  owners: ["snaredla", "jcardinal", "rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/sync_togasupply_elite.php
@@ -420,6 +420,14 @@ Set in `sync_togasupply_elite.php`; they feed the [inventory formula](./inventor
420
420
 
421
421
  ## Gotchas / known issues
422
422
 
423
+ - **Full NS↔toga recheck 2026-10-01: SO/IF/PO/Invoice/IA reconcile.** Expected differences (by design, not bugs):
424
+ - SOs closed in NS (all item lines closed, only Ship/Tax open, no related records) are deleted by the [closed-order rule](../../../1.0/apps/library/features/netsuite-closed-order-rule.md) (10 today).
425
+ - An invoice billed against a $0 hold SO (stored as a TransferOrder) is skipped — e.g. 6215693.
426
+ - Credit memos (`CustCred`) are stored in `Invoices`.
427
+ - IA 6326239 is a company-wide count at NS locations 4/5/6.
428
+ - Receipts at NS location 5 (New York) are excluded — `2026-09-28a` removed that Location (8 today).
429
+ - RMA return receipts are out of scope.
430
+ - **Users added by hand 2026-10-01** (`Client_Elite/2026-10-01a`): Base role, linked to the EXISTING sync-created Contacts (carry `c_eliteUserId`) — look up `ContactEmailAddresses` before creating a Contact. `devteam@togatech.com` here is a cross-client user (clientId 1 True, `homeClientUserId` 2) — see [cross-client login](../../../2.0/apps/api2/features/login-cross-client-user-resolution.md).
423
431
  - **Data fixes 2026-09-28/29 (`dbchanges2/Client_Elite/`):** `2026-09-28b` inventory filter, `28d`, `28e` (ACL + `allowReceiving = 1` on NS 196), `2026-09-29a - EliteReceiptLinkAndMissingSalesOrder.sql` (relinks receipt 58882 line to PO line 1765; inserts missing SO 289992). 7 closed-order shipments backfilled with `process_netsuite_records.php` after the [closed-order rule](../../../1.0/apps/library/features/netsuite-closed-order-rule.md) change.
424
432
  - **Open 2026-09-30:** Elite sync cron re-enabled locally in `worker/schedules/cron.worker.sync.json` — pending deploy. RMA return receipts are not imported (Docking 1 below NetSuite).
425
433
 
@@ -462,6 +470,7 @@ Set in `sync_togasupply_elite.php`; they feed the [inventory formula](./inventor
462
470
  rewinding `NETSUITE_LAST_SYNC_DATETIME_SALES_ORDERS`.
463
471
 
464
472
  ## Change history
473
+ - 2026-10-01 — Full NS↔toga recheck: list of by-design differences; hand-added users linked to existing Contacts; item descriptions now from NS item record ([doc](../../../1.0/apps/library/features/netsuite-item-description-sync.md)), items 228/80/89/90 backfilled by hand. (rgirish)
465
474
  - 2026-09-30 — Elite-only scope flags (owned-warehouse receipts, NS line location, skip TOs from unknown warehouse); NS 196 marked owned; receipt/SO data fixes; 7 closed-order shipments backfilled. (rgirish)
466
475
  - 2026-09-24 — TO↔PO links now via the two-direction reconcile; some upstream rows were vendor POs; SO downstream line links were being deleted on every PO re-sync (55 of 97 SO-linked vendor POs had none) — fixed. (jcardinal)
467
476
  - 2026-09-18 — **Stale-SalesOrder backfill is live: 218 → 187.** The retirement method needed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.931",
3
+ "version": "1.0.933",
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",