toga-ai 1.0.923 → 1.0.925

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.
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-29
9
+ updated: 2026-10-01
10
10
  owners: ["bala", "mhammontree", "tcox", "apeterson", "rgirish", "jcardinal"]
11
11
  files:
12
12
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateTransferOrder.ts
@@ -194,9 +194,11 @@ behaviour — it bites any write to a nested child, not one feature. Worked exam
194
194
  - **Not every record is linkable by `uuid`** — read `searchableIdentifierFields` in the EV-12 body; fall back to `id` resolved by a natural key, never hardcoded.
195
195
  - **Declaring a `c_` field on the model does not make it searchable** — the `CustomRecordFields.isIdentifier` flag is the gate; set the flag, not the child policy.
196
196
  - **An identifier that is unique only inside a parent (e.g. `bundles.number` per catalog) links the WRONG record, silently.** The match uses only the identifier fields sent, so `bundle: {number: "001"}` hits another catalog's `001`. Send `uuid` (or add the scoping identifier) resolved in the caller. Seen on Prudential India REQs — see [bundle catalog resolution](../../../clients/prudential/features/service-request-bundle-catalog-resolution.md).
197
+ - **⚠ A nested `unit` with only an `item` (no serial/asset tag) REUSES one existing unit.** `ServiceRequests_Units.unitId` is `MATCH_UPSERT` and `Units.itemId` is an identifier, so `{unit:{item:{uuid}}}` matches the first unit of that item — every SR links the same unit. For serial-less requests attach a **bundle** instead (`ServiceRequests_Bundles.bundleId` is `MATCH`; send `{bundle:{uuid}}`). See [Adyen reclaim flow](../../../clients/adyen/features/service-request-salesorder-netsuite.md).
197
198
  - **Singular-FK back-reference defects:** UPDATE path drops the parent's forward FK (echoes `null`); CREATE path re-parents a value-matched child to the newest writer (stolen child row). One defect, two symptoms.
198
199
 
199
200
  ## Change history
201
+ - 2026-09-30 — Gotcha: item-only nested unit matches an existing unit; use a bundle for serial-less SRs. (rgirish)
200
202
  - 2026-09-29 — Gotcha: a per-parent identifier (`bundles.number`) matches across parents and links the wrong row. (rgirish)
201
203
  - 2026-09-24 — Documented that the engine recurses into a nested inherent child with `POST`
202
204
  hardcoded (`V2.php:5415`), so a child nested in a PUT is duplicated or fails 1062; update
@@ -13,7 +13,7 @@
13
13
  | [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Per-tenant/language/role JSON config that drives almost all commerce UI text, fields, and login fetches (Layer A global + Layer B FIELDS registry); open before |
14
14
  | [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | Expedited shipping options are config-gated to show only when the cart holds a computer kit; open when touching cart shipping-method options or the gate config. |
15
15
  | [Filter / Search-Results Page & the Two Search Entry Points](features/filter-search-results-page.md) | Two search entry points (Home in-place vs Header→/filter) render the same card via different code paths and FIELDS files; open for any search-results or kit-car |
16
- | [Force Logout on Deployment (DeploymentGuard) — commerce](features/force-logout-on-deployment.md) | Signs every user out when a deploy bumps a Core parameter, after showing a notice; open for surprise logouts, a repeating "new version" toaster, or `useDeployme |
16
+ | [Force Logout on Deployment (DeploymentGuard) — commerce](features/force-logout-on-deployment.md) | Signs every user out when a deploy bumps a Core parameter, after showing a notice; open for surprise logouts, "why wasn't I logged out?", a repeating "new versi |
17
17
  | ["Order For" — header pill, delegated token switch, and the cart hand-off](features/header-order-for-dropdown.md) | Header pill that switches the session to a delegated token so a buyer orders **as** someone else; open before touching the pill, `useOrderForZu`, the delegation |
18
18
  | [Inactive-item purchase gating (standalone lines only — kits are exempt by design)](features/inactive-item-purchase-gating.md) | `Items.isActive = 0` blocks standalone purchase but means something different on kit components, so the gate keys on purchase CONTEXT not the flag; open when to |
19
19
  | [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | How the hostname resolves the tenant and fans out to API base URL, theme (CSS variables), and fields; open when adding a tenant or debugging wrong-theme/wrong-A |
@@ -6,13 +6,13 @@ project: TOGa Commerce
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: [apeterson]
11
11
  files:
12
12
  - toga2-commerce/src/hooks/useDeploymentGuard.tsx
13
13
  - toga2-commerce/src/lib/deploymentVersion.ts
14
+ - toga2-commerce/src/lib/queryClient.ts
14
15
  - toga2-commerce/src/components/AuthLayout/AuthLayout.tsx
15
- - toga2-commerce/src/contexts/AuthContext.tsx
16
16
  - toga2-commerce/src/lib/authStorage.ts
17
17
  related:
18
18
  - ./sso-redirect-and-session-gating.md
@@ -20,7 +20,7 @@ related:
20
20
  - ../../toga25-supply/features/force-logout-on-deployment.md
21
21
  ---
22
22
 
23
- Signs every user out when a deploy bumps a Core parameter, after showing a notice; open for surprise logouts, a repeating "new version" toaster, or `useDeploymentGuard`.
23
+ Signs every user out when a deploy bumps a Core parameter, after showing a notice; open for surprise logouts, "why wasn't I logged out?", a repeating "new version" toaster, or `useDeploymentGuard`.
24
24
 
25
25
  ## Summary
26
26
 
@@ -30,17 +30,50 @@ Ported from toga25-supply (`force-logout-on-deployment`). Two commerce-only diff
30
30
  - **Mounted as `<DeploymentGuard />` inside `AuthLayout`**, not in `AuthContext`. `ToasterProvider` sits **below** `AuthProvider` in `App.tsx`, so the context that owns sign-out cannot raise a toaster; the guard component is inside both providers.
31
31
  - **Baseline lives only in `localStorage["deployment-version"]`**, listed in `AUTH_STORAGE_KEYS` so logout clears it. No baseline → the check is skipped, which is what stops a sign-out loop.
32
32
 
33
+ Reached commerce `_production` only in merge `26e2fae7` (2026-09-30, TRUE-81991); supply had it earlier. That gap is why one parameter bump logged supply users out and commerce users not at all.
34
+
33
35
  ## How it works
34
36
 
35
- 1. Query `META_LAST_REFRESH_DATETIME`, `enabled` only while authenticated, `staleTime: 0`, `refetchOnWindowFocus: true`, `refetchInterval` **5 minutes**. The old version refetched only on navigation, so an idle tab never noticed a deploy.
36
- 2. First observation writes the baseline and never signs out.
37
+ 1. One query on `META_LAST_REFRESH_DATETIME`, `enabled` only while authenticated, `staleTime: 0`, `refetchOnWindowFocus: true`, `refetchInterval` **5 minutes**.
38
+ 2. First observation writes the baseline (`action: "establish-baseline"`) and never signs out.
37
39
  3. A later differing value → toaster → 2.5s timer → `logout()` + reload.
38
40
  4. `logout()` clears `AUTH_STORAGE_KEYS`, including the baseline, so the next login just re-baselines.
39
41
 
42
+ ### What actually triggers a refetch (and what does not)
43
+
44
+ Exactly three things, verified 2026-10-01:
45
+
46
+ 1. query mount — a full page load or browser refresh;
47
+ 2. `refetchOnWindowFocus` — see the gotcha below for what "focus" really means;
48
+ 3. the 5-minute `refetchInterval`.
49
+
50
+ **An in-app route change does not refetch.** Neither app invalidates `["deploymentVersion"]` on `location.pathname`, and supply has no broad `invalidateQueries()` anywhere. "It logged me out when I changed pages" is always (2) or (3) landing at that moment.
51
+
52
+ `refetchIntervalInBackground` is unset (default `false`), so the 5-minute poll **pauses while the tab is hidden** and resumes on return. Nobody is signed out in a background tab.
53
+
54
+ **User-visible guarantee:** a signed-in user is signed out within 5 minutes of the bump while their tab is open and on screen; if the tab was hidden, at the moment they come back.
55
+
56
+ ### Query config vs supply (does not change sign-out timing)
57
+
58
+ | | app-wide `refetchOnWindowFocus` | guard query |
59
+ |---|---|---|
60
+ | toga25-supply (`App.tsx`) | default `true` — every on-screen query refetches on focus | inherits |
61
+ | toga2-commerce (`lib/queryClient.ts`) | `false` | opts back in individually |
62
+
63
+ Because the guard opts in, sign-out timing is **identical** in both apps. Deleting commerce's `refetchOnWindowFocus: false` buys no change in logout behavior and costs a burst of requests on every tab switch. Commerce `origin/_production` and `origin/_sandbox-client` are byte-identical for `useDeploymentGuard.tsx`, `App.tsx`, and `queryClient.ts` — sandbox-vs-prod differences were timing, not build.
64
+
40
65
  ## Gotchas
41
66
 
67
+ - **⚠ "Window focus" means tab visibility, not app focus.** React Query v5 (`query-core` 5.101.4) registers **only** `visibilitychange`; the v4 focus/blur listeners are gone. Alt-tabbing to Teams/Outlook and back does **not** refetch — the tab was never hidden. Switching browser tabs, minimize/restore, and lock/wake do. Applies to every `refetchOnWindowFocus` query in every 2.0 React app. See [frontend standard §4](../../../standards/frontend.md).
68
+ - **⚠ The first bump after the guard ships is always a no-op.** `localStorage["deployment-version"]` is written in one place only — the guard's own first observation — and nothing seeds it at login. A browser that has never run the guard has nothing to compare against, so the first fetch just stores the value. By design (it is the no-logout-loop guard), but it means "we bumped the parameter and nobody was logged out" is expected right after a deploy that introduces the guard.
69
+ - **⚠ `key=` on `GET /core-parameters` is silently ignored.** Both apps send `?fields=value&key=META_LAST_REFRESH_DATETIME`. api2 parses only `fields, where, join, ojoin, sort, recordsPerPage, page, distinct, group, client, seekValues, seekUuid, surface`, and `/core-parameters` is plain CRUD (no scripted method on `Model/Core/Parameter.php`). The response is page 1 of **all** Core Parameters and the code reads `coreParameters[0].value`. It works today only because row 0 happens to be the right one — adding or reordering Core Parameters would silently point the guard at another row. Correct form: `where: { and: [{ key: { "=": "META_LAST_REFRESH_DATETIME" } }] }`. Never rely on `key=` as a filter anywhere in 2.0.
42
70
  - **⚠ Do not depend an effect on `addToaster`.** It is rebuilt on every `ToasterProvider` render, and raising the toaster re-renders that provider — the effect re-ran and its cleanup cleared the `setTimeout` it had just set, so the notice showed and the sign-out never fired. Keep unstable callbacks in refs and depend only on the stable value.
43
71
  - **⚠ The "already fired" flag must be module scope, not `useRef`.** The guard is mounted inside `AuthLayout`; a route change remounts it and a per-mount ref resets to `false`, so the notice reappeared on every navigation. Also do not clear the timer on unmount.
44
72
 
73
+ ## Deferred — sign out on route change
74
+
75
+ To also fire on an in-app navigation: a `useLocation()` effect calling `refetch()` on `pathname` change, with `refetch` held in a **ref** (its identity changes every render; depending on it directly loops). Commerce can do this as-is — `<DeploymentGuard />` is inside `AuthLayout`, inside the router. **Supply cannot**: its guard sits in `App.tsx` **above** `<AppRouter />`, so `useLocation()` would throw; the guard must move inside the router first. Not implemented — deferred 2026-10-01.
76
+
45
77
  ## Change history
78
+ - 2026-10-01 — Documented the exact refetch triggers (mount / tab-visibility / 5-min poll; **not** route change), the background-pause behavior, the first-bump no-op, the ignored `key=` param, and the commerce-vs-supply query config difference. Route-change refetch deferred. (apeterson)
46
79
  - 2026-09-25 — Built for commerce: moved out of `AuthContext` into `<DeploymentGuard />` so a toaster can be shown; added 5-min poll + focus refetch; baseline key added to `AUTH_STORAGE_KEYS`. TRUE-81991. (apeterson)
@@ -6,28 +6,31 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-01
10
10
  owners: [apeterson]
11
11
  files:
12
12
  - toga25-supply/src/hooks/useDeploymentGuard.tsx
13
13
  - toga25-supply/src/App.tsx
14
- related: []
14
+ related:
15
+ - ../../toga2-commerce/features/force-logout-on-deployment.md
15
16
  ---
16
17
 
17
18
  Force-signs-out every user when a deploy bumps a Core parameter, so session + caches reset to the new build; open when debugging surprise logouts or `useDeploymentGuard`.
18
19
 
19
20
  ## Summary
20
21
 
21
- On large deploys the backend bumps Core parameter `META_LAST_REFRESH_DATETIME`. The FE watches it and, when it changes mid-session, force-signs-out every logged-in user so session, `localStorage`, and React Query cache (including the persisted `supply-chain-query-cache`) reset cleanly and they pick up the new build + data. Implemented as a headless `<DeploymentGuard />` driven by the `useDeploymentGuard` hook, mounted inside the `AuthProvider` / `ToasterProvider` tree in `App.tsx`.
22
+ On large deploys the backend bumps Core parameter `META_LAST_REFRESH_DATETIME`. The FE watches it and, when it changes mid-session, force-signs-out every logged-in user so session, `localStorage`, and React Query cache (including the persisted `supply-chain-query-cache`) reset cleanly. Implemented as a headless `<DeploymentGuard />` driven by `useDeploymentGuard`, mounted inside the `AuthProvider` / `ToasterProvider` tree in `App.tsx` — **above** `<AppRouter />`, so the hook cannot use `useLocation()`.
22
23
 
23
24
  ## How it works
24
25
 
25
- 1. **Poll the Core parameter.** TanStack `useQuery` reads `META_LAST_REFRESH_DATETIME` via `apiGet("/core-parameters", { fields: ["value"], key: "META_LAST_REFRESH_DATETIME" })`, reading `response.data.coreParameters` (tolerant of the value arriving as an array or a single object). Query: `enabled` only while authenticated, `staleTime: 0`, `refetchOnWindowFocus: true`, `refetchInterval` 5 minutes.
26
- 2. **Establish a baseline.** The value observed right after login is stored in `localStorage` key `deployment-version`. The **first** observation only sets the baseline — never logs out. The mismatch check is skipped when no baseline exists.
27
- 3. **Detect a change.** A later fetch whose value differs from the stored baseline triggers the reset.
28
- 4. **Reset UX.** On mismatch: a **non-dismissing info toast** ("A new version is available — signing you out briefly…"), wait **2.5s** for readability, then call `AuthContext` `logout()`.
29
- 5. **Centralized logout does the wipe.** `logout()` runs `performLogout()` + `clearUser()` + `localStorage.clear()` (wipes **all** `localStorage` — tokens, `zu-user`, `zu-hostname`, the persisted query cache, and the `deployment-version` baseline) + `queryClient.clear()`, then `performLogout()` hard-redirects to `/`.
26
+ 1. **Poll the Core parameter.** TanStack `useQuery` reads `META_LAST_REFRESH_DATETIME` via `apiGet("/core-parameters", { fields: ["value"], key: "META_LAST_REFRESH_DATETIME" })`, reading `response.data.coreParameters` (tolerant of an array or a single object). Query: `enabled` only while authenticated, `staleTime: 0`, `refetchOnWindowFocus: true`, `refetchInterval` 5 minutes.
27
+ 2. **Establish a baseline.** The first observed value is stored in `localStorage["deployment-version"]`; the first observation only sets the baseline and never logs out.
28
+ 3. **Detect a change.** A later fetch whose value differs triggers the reset.
29
+ 4. **Reset UX.** Non-dismissing info toast ("A new version is available — signing you out briefly…"), wait **2.5s**, then `AuthContext` `logout()`.
30
+ 5. **Centralized logout does the wipe.** `logout()` runs `performLogout()` + `clearUser()` + `localStorage.clear()` (tokens, `zu-user`, `zu-hostname`, the persisted query cache, and the `deployment-version` baseline) + `queryClient.clear()`, then hard-redirects to `/`. Clearing the baseline is what prevents a logout loop.
30
31
 
31
- ### Why there is no logout loop
32
+ **When it actually fires, the first-bump no-op, and the ignored `key=` param are identical here** — documented once in [commerce's force-logout doc](../../toga2-commerce/features/force-logout-on-deployment.md) (sections *What actually triggers a refetch* and *Gotchas*). Supply-only difference: `App.tsx` leaves `refetchOnWindowFocus` at React Query's default `true` app-wide, so every on-screen query refetches on tab visibility — not just this one. Sign-out timing is the same in both apps.
32
33
 
33
- `logout()` clears the baseline and reloads, so the next login's first fetch just re-establishes the baseline (the mismatch check is skipped when no baseline exists). Simpler than the sibling `toga2-commerce` implementation (`src/contexts/AuthContext.tsx`), which polls the same Core parameter but had to preserve the value across logout. toga25-supply's centralized `logout()` (`localStorage.clear` + `queryClient.clear` + `performLogout` redirect) made the port cleaner.
34
+ ## Change history
35
+ - 2026-10-01 — Recorded that the guard sits above the router (blocks `useLocation()`); moved the shared trigger/gotcha detail to the commerce doc rather than duplicating it. (apeterson)
36
+ - 2026-09-16 — Initial implementation. (apeterson)
@@ -6,11 +6,12 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["snaredla", "bala"]
9
+ updated: 2026-10-01
10
+ owners: ["snaredla", "bala", "rgirish"]
11
11
  files:
12
12
  - worker2/Worker/Sync/ServiceRequest.php
13
13
  - _underscore/Model/Elite/ServiceRequest.php
14
+ - _underscore/Model/Adyen/ServiceRequest.php
14
15
  - dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql
15
16
  related:
16
17
  - ./netsuite-salesorder-outbound-push.md
@@ -19,6 +20,8 @@ related:
19
20
  - ../../_underscore/features/units-for-items-for-purchase-orders.md
20
21
  - ../../../clients/elite/features/salesorder-netsuite-push.md
21
22
  - ../../../clients/elite/features/togadesk-service-request-intake.md
23
+ - ../../../clients/adyen/features/service-request-salesorder-netsuite.md
24
+ - ../../api2/features/nested-relationship-writes.md
22
25
  ---
23
26
 
24
27
  Worker `_Worker_Sync_ServiceRequest` that turns a Service Request → Sales Order → one PO per vendor for any tenant; open for the SR→SO→PO chain, its interceptor queueing, vendor resolution, or the `c_`-column probe.
@@ -34,7 +37,9 @@ _Worker_Sync_ServiceRequest::GenerateSalesOrderFromServiceRequest(
34
37
  ): string
35
38
  ```
36
39
 
37
- Queued from a client's `service-requests` `postPost` interceptor (Elite is first consumer — see [Elite desk intake](../../../clients/elite/features/togadesk-service-request-intake.md)); invocable directly for testing like any worker action.
40
+ Queued from a client's `service-requests` `postPost` interceptor; invocable directly for testing like any worker action. Consumers: **Elite** ([desk intake](../../../clients/elite/features/togadesk-service-request-intake.md)) and **Adyen** ([reclaim flow](../../../clients/adyen/features/service-request-salesorder-netsuite.md), built 2026-09-30, not deployed).
41
+
42
+ **Per-client config = `SALES_ORDER_DEFAULTS_BY_CLIENT`**, keyed by `Core.Clients.clientIdentifier`: `locationUuid` (required — NetSuite rejects an order with no location) + `salesOrderTypeUuid` (nullable). Elite → location `3ca47b91-…`, type drop-ship `5994b81d-…`; Adyen → location `191c75ff-f02e-4dda-abc6-c8d26c35da62`, no type. **A client missing from the map throws** — onboarding a client to this worker = add a map entry. The uuids are the same in beta and prod.
38
43
 
39
44
  **Proven end to end on dev-sandbox** (`https://api.dev.sandbox.togahub.com/v2`): one service request produced **SA100004** plus purchase order **P1**, both bridge rows linked, and a **rerun returned early** rather than duplicating.
40
45
 
@@ -50,8 +55,8 @@ Steps:
50
55
  1. **Resolve the tenant.** `getClientContextFromCore()` looks up by `Core.Clients.clientIdentifier`, registers the client DB connection, returns Toga API credentials. (Deliberate local copy of the same private in `_Worker_Netsuite_SalesOrder`; lift to a shared parent when a third caller appears.)
51
56
  2. **Guard.** `fetchServiceRequestFromToga2()` joins `SalesOrders` on `serviceRequestId`. If a sales order already exists, **return early** with a summary — makes the action idempotent under SQS redelivery and manual reruns. A request already in the **`Exception`** stage is skipped for the same reason.
52
57
  3. **Reconcile the requester's address** (`reconcileRequesterAddressInToga2`) and **opt the requester into Order Updates** (`optInRequesterToOrderUpdatesInToga2`).
53
- 4. **Build and POST the sales order** (`buildSalesOrderPayloadForToga2`): customer by uuid, `serviceRequestId`, `ticketId`, one `salesOrderItems` line per service-request unit (`lineNumber` assigned by the worker, quantity 1, price 0), and `shipToAddress` **only when the request has a deliverable address**.
54
- 5. **Generate purchase orders** (`tryGeneratePurchaseOrdersInToga2` → `generatePurchaseOrdersInToga2`): re-read created lines with `fetchSalesOrderItemsFromToga2()`, resolve a vendor per item with `fetchVendorItemsFromToga2()`, group lines by vendor, POST **one PO per vendor** with line numbers restarted per order.
58
+ 4. **Build and POST the sales order** (`buildSalesOrderPayloadForToga2`): customer by uuid, `serviceRequestId`, `ticketId`, `locationId` + type from the client's map, `shippingMethodId` = the SR's, else **`ShippingMethods` id 1**, and `shipToAddress` **only when the request has a deliverable address**. Lines come from `fetchServiceRequestLinesFromToga2()`: one line per SR **unit**; if the SR has no units, one line per **bundle item** (`ServiceRequests_Bundles` → `BundleItems` → `Items`). Each line is quantity 1, price 0, `lineNumber` assigned by the worker. No units and no bundle items → throws (no empty order).
59
+ 5. **Generate purchase orders** (`tryGeneratePurchaseOrdersInToga2` → `generatePurchaseOrdersInToga2`): re-read created lines with `fetchSalesOrderItemsFromToga2()`, resolve a vendor per item with `fetchVendorItemsFromToga2()`, group lines by vendor, POST **one PO per vendor** with line numbers restarted per order. Items with no vendor are listed in the job result as `No vendor for: …` — the job does **not** fail (Adyen has no `VendorItems`, so it gets no PO).
55
60
  6. **On a rejected sales-order POST**, `moveServiceRequestToExceptionInToga2()` parks the request in the `Exception` stage so the failure is visible *and* a redelivered job short-circuits on the step-2 stage guard.
56
61
 
57
62
  ### Vendor resolution replaces Prudential's hardcoded vendor
@@ -88,12 +93,14 @@ The three structural checks (string resolves → producer matches → intercepto
88
93
  - **⚠ Before nesting a child collection in a payload, confirm the child is registered in `Core.InherentRecordChildren` under that parent record.** `salesOrderItemsCommittedUnits` was briefly nested under sales-order lines; it is **impossible** — record 152 has **zero** `Core.InherentRecordChildren` parent registrations under record 15, nothing writes that table via the API at all, and its model's `unitId` is a foreign key to `_Model_Client_User`, not to a unit. A nested key the API does not recognize is not obviously rejected; run the `InherentRecordChildren` check rather than assuming.
89
94
  - **`shipToAddress` carries `childPolicy CREATE`** — posting an all-null address shape **creates a blank address row**. `hasDeliveryAddressForServiceRequest()` omits the key entirely instead.
90
95
  - **Line numbers are assigned by the worker and re-checked.** After the POST the worker maps created lines back by `lineNumber`; a line it cannot map means the API renumbered them, and that is **reported** rather than silently dropped from the PO.
91
- - **⚠ Never point this worker at Prudential.** Prudential service requests are **bundle-driven** (`BundleItems`), a shape this worker does not implement. It would produce wrong orders, not no orders.
96
+ - **⚠ Do not add Prudential to the map without a review.** The worker now reads bundle items, but only as plain qty-1 lines — not Prudential's `BundleItems.preferredVendorId` / Dell flow. Prudential is not in `SALES_ORDER_DEFAULTS_BY_CLIENT`, so a run for it throws.
97
+ - **Fallback ship method `ShippingMethods` id 1 is a per-tenant row id**, not a known method. When the SR names none, verify id 1 is the right NetSuite-mapped method for that client (open).
98
+ - **⚠ Inbound NetSuite sync can duplicate an order this worker created (all clients, open).** `library/app/api/toga2.php:1002` matches sales orders only by `c_netsuiteInternalSalesOrderId`. If the inbound sync runs after NetSuite has the order but before the push writes the id back, it imports a second copy.
92
99
  - **Placement:** cross-system sync workers live under `worker2/Worker/Sync/`. This worker does **not** belong in `Worker/Netsuite/` (which keeps the NetSuite push, `Worker/Netsuite/SalesOrder.php`) and not in `Worker/Client/`.
93
100
  - **⚠ Renaming this action is a TWO-REPO, single-deploy change.** The method name here and the `runTask(action: 'Sync/ServiceRequest/GenerateSalesOrderFromServiceRequest', …)` **string** in `_underscore/Model/Elite/ServiceRequest.php:44` must land and deploy **together** — the rename from `GenerateSalesOrder` on 2026-08-12 spans both. Ship either alone and the dispatcher targets a name that no longer exists; because `postPost` wraps `runTask` in try/catch + `error_log`, Sales Order generation then stops **silently**. Same exposure for `Sync/SalesOrderStatus/PostSalesOrderStatusToTogadesk1`. See [creating worker actions](./creating-worker-actions.md).
94
101
  - **⚠ The copied client-context helper has already DRIFTED.** `getClientContextFromCore()` here, the same private in `Worker/Sync/SalesOrderStatus.php`, and `getClientContext()` in `Worker/Netsuite/SalesOrder.php` are three copies — and only **this** one has `ORDER BY id ASC` on the `Apis` credential lookup, so the other two pick a **non-deterministic** API credential row (`Worker/Netsuite/SalesOrder.php` documents the flaw in a comment). Add the `ORDER BY` to all three if you touch any, and prefer extraction.
95
102
  - **The NetSuite actions in this chain are `Netsuite/SalesOrder/CreateNetSuite` and `Netsuite/SalesOrder/UpdateNetSuite`.** There is **no** `Netsuite/SalesOrder/Push` — do not write that string into a migration, a test or a doc.
96
- - **⚠ It does not set `locationId` — which blocks the NetSuite push downstream (open, 2026-08-20).** Orders this worker creates land with `locationId` **NULL** (prod Elite `SalesOrders` **279** / `SA100000`, from `serviceRequestId 3`), and `Netsuite/SalesOrder/CreateNetSuite` then fails loudly: *"no NetSuite location mapped on line 1 or on the order — map `Locations.c_netsuiteInternalLocationId`"*. **The mapping data is complete** (prod Elite `Locations` 1 and 2 → 196, 3 → 5), so this is a missing **field**, not a missing mapping. The fix belongs **here, not in the push**: an order should record where it ships from regardless of NetSuite, and `buildNetSuiteOrder()` deliberately refuses to guess a warehouse when the data does not say. See [the NetSuite push](./netsuite-salesorder-outbound-push.md).
103
+ - **`locationId` was missing until 2026-09-30.** Orders created before then (e.g. prod Elite `SalesOrders` 279 / `SA100000`) have `locationId` NULL and fail `Netsuite/SalesOrder/CreateNetSuite` with *"no NetSuite location mapped…"*. The worker now sets it from the client's map (not deployed yet). Keep the fix here — `buildNetSuiteOrder()` must never guess a warehouse.
97
104
 
98
105
  ### Known limitations — business blockers, not code defects
99
106
 
@@ -104,6 +111,9 @@ Recorded so nobody re-debugs them as bugs. All four confirmed against Elite's da
104
111
  - **Generated POs reach no vendor.** No `Integrations` rows and no `defaultPoSubmissionIntegrationId` configured, so nothing transmits them.
105
112
  - The worker is otherwise complete; these are data/config gaps for the client to close.
106
113
 
114
+ ## Change history
115
+ - 2026-09-30 — Per-client `SALES_ORDER_DEFAULTS_BY_CLIENT` (sets `locationId`, unknown client throws); bundle-item lines when an SR has no units; no-vendor items reported, not failed; Adyen added as second consumer. Not deployed. (rgirish)
116
+
107
117
  ## Related
108
118
 
109
119
  - [Toga → NetSuite Sales-Order Push](./netsuite-salesorder-outbound-push.md)
@@ -112,3 +122,4 @@ Recorded so nobody re-debugs them as bugs. All four confirmed against Elite's da
112
122
  - [Units for items for purchase orders](../../_underscore/features/units-for-items-for-purchase-orders.md)
113
123
  - [Elite Sales-Order NetSuite push](../../../clients/elite/features/salesorder-netsuite-push.md)
114
124
  - [Elite TOGaDesk service-request intake](../../../clients/elite/features/togadesk-service-request-intake.md)
125
+ - [Adyen reclaim SR → SO → NetSuite](../../../clients/adyen/features/service-request-salesorder-netsuite.md)
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-09-16
8
+ updated: 2026-10-01
9
9
  owners: [jcardinal, apeterson]
10
10
  files: []
11
11
  related:
@@ -191,6 +191,15 @@ const queryClient = new QueryClient({
191
191
  });
192
192
  ```
193
193
 
194
+ **MUST: read `refetchOnWindowFocus` as "tab became visible", not "app regained focus".**
195
+ React Query v5 (`query-core` 5.101.4) registers **only** `visibilitychange`; v4's focus/blur
196
+ listeners are gone. So alt-tabbing to another **application** (Teams, Outlook) and back does
197
+ **not** refetch — the browser tab was never hidden. Switching browser **tabs**,
198
+ minimize/restore, and lock/wake do. Likewise `refetchInterval` **pauses while the tab is
199
+ hidden** unless `refetchIntervalInBackground: true` is set. Do not design a freshness or
200
+ session guarantee on the assumption that alt-tab refetches.
201
+ (Case: [commerce force-logout-on-deployment](../apps/toga2-commerce/features/force-logout-on-deployment.md).)
202
+
194
203
  **SHOULD: fetch with React Query, not react-router data APIs.** Both apps **deliberately avoid**
195
204
  router `loader`/`action`/`useLoaderData`; data flows through React Query so caching, invalidation,
196
205
  and optimistic updates live in one system. Do not mix the two paradigms.
@@ -19,7 +19,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
19
19
 
20
20
  ## 2.0 framework
21
21
 
22
- - **_underscore** (_Underscore) _(framework core)_ — 93 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
+ - **_underscore** (_Underscore) _(framework core)_ — 94 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
23
23
  - **worker2** (Worker) — 80 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
24
24
  - **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
25
25
  - **dbchanges2** (Database Changes) _(framework core)_ — 22 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -2,4 +2,5 @@
2
2
 
3
3
  | Doc | Framework | Summary |
4
4
  |-----|-----------|---------|
5
+ | [Adyen — Reclaim Service Request → Sales Order → NetSuite (bundle-driven, no serials)](features/service-request-salesorder-netsuite.md) | 2.0 | Adyen's Reclaim SR → SO → NetSuite chain (same as Elite, but items come from a seeded bundle because Adyen sends no serials); open for the prePost bundle attach |
5
6
  | [Adyen](profile.md) | 2.0 | Adyen live TOGa Supply client (`Client_Adyen`) on the 1.0 NetSuite→TOGa Supply sync, status draft; open for the prod tracking-API test orders + SR↔NetSuite matc |
@@ -0,0 +1,59 @@
1
+ ---
2
+ title: "Adyen — Reclaim Service Request → Sales Order → NetSuite (bundle-driven, no serials)"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: adyen
7
+ type: client-feature
8
+ status: draft
9
+ updated: 2026-10-01
10
+ owners: ["rgirish"]
11
+ files:
12
+ - _underscore/Model/Adyen/ServiceRequest.php
13
+ - _underscore/Model/Adyen/SalesOrder.php
14
+ - worker2/Worker/Sync/ServiceRequest.php
15
+ - dbchanges2/Client_Adyen/2026-09-30a - ServiceRequestSalesOrderSetup.sql
16
+ related:
17
+ - ../../../2.0/apps/worker2/features/service-request-sales-order-generation.md
18
+ - ../../../2.0/apps/worker2/features/netsuite-salesorder-outbound-push.md
19
+ - ../../../2.0/apps/api2/features/api-payload-interceptors.md
20
+ - ../../../2.0/apps/api2/features/nested-relationship-writes.md
21
+ - ../../elite/features/salesorder-netsuite-push.md
22
+ - ../profile.md
23
+ ---
24
+
25
+ Adyen's Reclaim SR → SO → NetSuite chain (same as Elite, but items come from a seeded bundle because Adyen sends no serials); open for the prePost bundle attach, the 2026-09-30a setup migration, deploy order, or duplicate risks.
26
+
27
+ ## Summary
28
+
29
+ Adyen's API (`Apis` id 4) posts Service Requests, all of type **"Reclaim"**, and **cannot send serial numbers**. The chain is the same as Elite's: SR `postPost` → worker2 builds the Sales Order → SO `postPost` → NetSuite push. The only Adyen difference: the SR's items come from a seeded **"Reclaim" bundle**, attached in `prePost`.
30
+
31
+ **Status (2026-10-01): code + migration written, NOT run or deployed anywhere, not tested end to end.**
32
+
33
+ ## How it works
34
+
35
+ 1. `_Model_Adyen_ServiceRequest::prePost` — when type is `Reclaim` and the payload has **no units and no bundles**, adds `serviceRequestBundles: [{bundle:{uuid:'23717330-7672-4590-9e3d-7f4097c18723'}}]` (`RECLAIM_BUNDLE_UUID`).
36
+ 2. `_Model_Adyen_ServiceRequest::postPost` — same as Elite's: queues `Sync/ServiceRequest/GenerateSalesOrderFromServiceRequest`.
37
+ 3. The worker turns the bundle's items into SO lines (qty 1 each) and sets location "Adyen" from its client map — see [SR → SO generation](../../../2.0/apps/worker2/features/service-request-sales-order-generation.md). No PO: Adyen has no `VendorItems` (job reports `No vendor for: …`, does not fail).
38
+ 4. The existing `_Model_Adyen_SalesOrder::postPost` queues `Netsuite/SalesOrder/CreateNetSuite` ([shared push](../../../2.0/apps/worker2/features/netsuite-salesorder-outbound-push.md)).
39
+
40
+ **Why a bundle, not units:** a unit with only an item (no serial) matches an **existing** unit, so every SR would link the same unit — see [nested writes](../../../2.0/apps/api2/features/nested-relationship-writes.md). `ServiceRequests_Bundles.bundleId` is `MATCH` (links only), and Prudential already posts bundles daily with 201.
41
+
42
+ ### Setup migration — `dbchanges2/Client_Adyen/2026-09-30a - ServiceRequestSalesOrderSetup.sql`
43
+ - `ApiPayloadInterceptors`: record 35 (`service-requests`) PRE+POST / POST, scoped to Adyen API; record 14 (`sales-orders`) POST/POST and POST/PUT, `apiId` NULL (the worker writes as a different API identity).
44
+ - Items `SVC-FS-OFFBOARD-ADN` (NS 1579821) + `Misc Laptop` (NS 1545437, SERIALIZED); "Reclaim" bundle + 2 `BundleItems`.
45
+ - Location "Adyen" (NS location 70); ShippingCarrier FedEx + ShippingMethod Ground (NS 318627).
46
+ - `Customers.c_netsuiteInternalCustomerId = 31054`.
47
+
48
+ These values copy Adyen's hand-made NetSuite reclaim orders (e.g. 290879, 288260, 279871): always `SVC-FS-OFFBOARD-ADN` + one device item, location 70, ship method 318627.
49
+
50
+ ## Gotchas
51
+
52
+ - **⚠ Deploy order: worker2 → `_underscore` → SQL.** Running the SQL first registers a PRE/POST interceptor whose `prePost` method does not exist yet, so api2 fails **every** Adyen SR POST.
53
+ - **Before 2026-09-30a, `Client_Adyen` had 0 `ApiPayloadInterceptors` rows** — the Adyen model hooks committed 2026-08-24 never fired. "Nothing happened" on Adyen is a data check first.
54
+ - **Open — Adyen resending the same SR creates a duplicate.** `c_externalReferenceId` is not unique (SR100024 already duplicates SR100021 — see [profile](../profile.md)).
55
+ - **Open — NetSuite PO # (`otherrefnum`) will be the SR number**, not the employee name the hand-made orders carry. The profile's SR↔NetSuite matching reads that field.
56
+ - **Open — shipping method fallback** and the inbound-sync duplicate race are shared worker issues — see [SR → SO generation](../../../2.0/apps/worker2/features/service-request-sales-order-generation.md#gotchas).
57
+
58
+ ## Change history
59
+ - 2026-09-30 — Built: Reclaim bundle attach in `prePost`, Elite-style `postPost`, setup migration 2026-09-30a. Not deployed. (rgirish)
@@ -7,11 +7,12 @@ apps:
7
7
  - _underscore
8
8
  - api2
9
9
  - dbchanges2
10
+ - worker2
10
11
  project: Worker
11
12
  client: adyen
12
13
  type: profile
13
14
  status: draft
14
- updated: 2026-09-30
15
+ updated: 2026-10-01
15
16
  owners: ["rgirish"]
16
17
  files:
17
18
  - worker/crons/toga2/netsuite/sync_togasupply_adyen.php
@@ -22,7 +23,10 @@ files:
22
23
  - dbchanges2/Client_Adyen/2026-09-22a - AdyenApiTestSeed.sql
23
24
  - dbchanges2/Client_Adyen/2026-09-28a - LinkTestOrdersToNetsuite.sql
24
25
  - dbchanges2/Client_Adyen/2026-09-28b - RemoveUnmatchedTestOrders.sql
26
+ - _underscore/Model/Adyen/ServiceRequest.php
27
+ - dbchanges2/Client_Adyen/2026-09-30a - ServiceRequestSalesOrderSetup.sql
25
28
  related:
29
+ - ./features/service-request-salesorder-netsuite.md
26
30
  - ../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
27
31
  - ../../1.0/apps/library/features/netsuite-suiteql-api-reference.md
28
32
  - ../growrk/features/transfer-order-flow.md
@@ -31,7 +35,7 @@ related:
31
35
  - ../../2.0/apps/dbchanges2/workflows/nonprod-metadata-drift-repair.md
32
36
  ---
33
37
 
34
- Adyen live TOGa Supply client (`Client_Adyen`) on the 1.0 NetSuite→TOGa Supply sync, status draft; open for the prod tracking-API test orders + SR↔NetSuite matching, the `isFulfillable` column gap, the beta=client-sandbox env trap, and the ACL findings.
38
+ Adyen live TOGa Supply client (`Client_Adyen`) on the 1.0 NetSuite→TOGa Supply sync, status draft; open for the prod tracking-API test orders + SR↔NetSuite matching, the `isFulfillable` column gap, the beta=client-sandbox env trap, the ACL findings, and the Reclaim SR → SO → NetSuite chain.
35
39
 
36
40
  ## Summary
37
41
 
@@ -42,7 +46,8 @@ Adyen live TOGa Supply client (`Client_Adyen`) on the 1.0 NetSuite→TOGa Supply
42
46
 
43
47
  - **NetSuite → TOGa Supply sync.** Per-client thin wrapper over the shared engine — full mechanics on [per-client sync](../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
44
48
  - **Transfer orders ENABLED** (`IS_ENABLED_INTEGRATION_TRANSFER_ORDERS = true`). Adyen's NetSuite transfer orders arrive **as NetSuite sales orders**, classified by `TRANSFER_ORDER_DETECTION_MODE__ZERO_DOLLAR_HOLD` — `holdInvoice === true` **AND** `total === 0`, same rule as GroWrk (see [GroWrk transfer order flow](../growrk/features/transfer-order-flow.md)). Mode is now declared explicitly in Adyen's launcher; `App_Api_Toga2::isTransferOrder()` throws if it is missing, so **`library` and `worker` must deploy together** for Adyen.
45
- - **IDs:** `Core.Clients` id **48**; NetSuite customer **31054** ("4950 Adyen").
49
+ - **IDs:** `Core.Clients` id **48**; NetSuite customer **31054** ("4950 Adyen"); Adyen API = `Apis` id 4.
50
+ - **Reclaim SR → Sales Order → NetSuite** (built 2026-09-30, not deployed): same chain as Elite, items from a seeded "Reclaim" bundle because Adyen sends no serials — see [Adyen reclaim flow](./features/service-request-salesorder-netsuite.md).
46
51
  - **`Client_Adyen`** is one of the client DBs with surface-layer / ACL drift noted on the [ACL permission chain](../../2.0/apps/_underscore/features/acl-permission-chain.md) and [surface-layer schema](../../2.0/apps/dbchanges2/features/surface-layer-schema.md) docs.
47
52
 
48
53
  ## Gotchas
@@ -123,5 +128,4 @@ The `AclRecordPermissions` prod-vs-dev-sandbox route-name diff from that session
123
128
 
124
129
  - Formal/legal client name and account owner — recorded here simply as **Adyen**.
125
130
  - Which repos beyond the `apps:` list are genuinely in Adyen's scope.
126
- - Whether Adyen has client-specific models/features that deserve their own docs.
127
131
  - Confirm with Adyen/ops: which FedEx package is outbound vs return; whether SR100024 should be closed; who sets up the NetSuite sync (customer id, Locations, cursors).
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala", "snaredla"]
9
+ updated: 2026-10-01
10
+ owners: ["bala", "snaredla", "rgirish"]
11
11
  files:
12
12
  - _underscore/Model/Elite/SalesOrder.php
13
13
  - _underscore/Model/Elite/ServiceRequest.php
@@ -90,6 +90,8 @@ Every environment points at the **same live NetSuite account 1095849** — there
90
90
  **Do not bulk-delete that range.** Two further NetSuite ids in it — **7369129** and **7374431** — were **not** created by this work. Any cleanup must be row by row. The general rule (testing against beta writes to production NetSuite **and** round-trips back into production Toga data) is recorded in [running worker2 locally](../../../2.0/apps/worker2/workflows/running-worker2-locally.md).
91
91
 
92
92
  ### The SR → SO worker does not set `locationId`, which blocks the push (hand-off)
93
+ > **Update 2026-09-30:** the worker now sets `locationId` from `SALES_ORDER_DEFAULTS_BY_CLIENT` (Elite → `3ca47b91-…`, type drop-ship). Written, **not deployed** — see [SR → SO generation](../../../2.0/apps/worker2/features/service-request-sales-order-generation.md).
94
+
93
95
  prod `SalesOrders` **279** (`SA100000`, from `serviceRequestId 3`, 2026-08-13 11:11) was created by `Sync/ServiceRequest/GenerateSalesOrder` with **`locationId` NULL**, and `buildNetSuiteOrder()` then failed loudly: *"no NetSuite location mapped on line 1 or on the order — map `Locations.c_netsuiteInternalLocationId`"*.
94
96
 
95
97
  **The mapping data is fine.** prod Elite `Locations`: id 1 *Elite* (uuid `3ca47b91-6d05-4e28-9f17-c0b82a6e1345`) → **196**, id 2 *Elite Eagan US HoldCo LLC* → **196**, id 3 *New York* → **5**. That location uuid is **identical in prod and dev-sandbox**, so the same payload value works in both.
@@ -116,6 +118,9 @@ The interceptor → `runTask` → SQS → worker2 chain **is** proven at volume
116
118
  - **api2's `error_log()` output is not in CloudWatch.** worker2 ships `/var/log/php-fpm/www-error.log` to the `worker` log group via a postdeploy hook; **api2 has no equivalent hook**, so the interceptor's silent-skip messages must be pulled from the EB request/full logs.
117
119
  - **Orders that arrived from NetSuite must not be pushed back** — the `c_netsuiteInternalSalesOrderId`-on-payload bail is what prevents an echo loop. Keep it in any new hook on this model.
118
120
 
121
+ ## Change history
122
+ - 2026-09-30 — SR → SO worker now sets the order location (fixes the `locationId` hand-off); not deployed. (rgirish)
123
+
119
124
  ## Related
120
125
  - [Toga → NetSuite Sales-Order Push (shared)](../../../2.0/apps/worker2/features/netsuite-salesorder-outbound-push.md)
121
126
  - [SR → SO generation](../../../2.0/apps/worker2/features/service-request-sales-order-generation.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.923",
3
+ "version": "1.0.925",
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",