toga-ai 1.0.928 → 1.0.929

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,8 +6,8 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-30
10
- owners: ["dfranks", "jcardinal", "kyalamarthi", "ajean", "rgirish"]
9
+ updated: 2026-10-01
10
+ owners: ["dfranks", "jcardinal", "kyalamarthi", "ajean", "rgirish", "bala"]
11
11
  files:
12
12
  - library/app/api/netsuite/rest.php
13
13
  - library/ssl/netsuite_ec_key.pem
@@ -106,6 +106,7 @@ Type codes (`transaction.type`):
106
106
 
107
107
  Status codes (`transaction.status` letter):
108
108
  **Sales Orders:** `A` Pending Approval · `B` Pending Fulfillment · `C` Cancelled · `D` Partially Fulfilled · `E` Pending Billing/Partially Fulfilled · `F` Pending Billing · `G` Billed · `H` Closed. **"Open" sales orders = `B`, `D`, `E`, `F`.** Note `listSalesOrders()` returns **all** statuses for a window (no server-side status filter) — callers must filter.
109
+ **Item Fulfillments (`ItemShip`):** `A` Picked · `B` Packed · `C` Shipped.
109
110
  **Invoices:** `A` Open · `B` Paid In Full. **Credit Memos:** `A` Open · `B` Fully Applied. **Cash Sales:** `A` Unapproved Payment · `C` Deposited. **Cash Refunds:** `Y` (the only status; empty string in SOAP).
110
111
 
111
112
  Header columns present (✓ confirmed in SuiteQL): `id`, `tranid`, `trandate`, `lastmodifieddate`, `type`, `status`, `entity`, `employee`, `leadsource`, `terms`, `memo`, `otherrefnum`, `foreigntotal`/`total` (both return the same value), `foreignamountunpaid` (AR balance — invoices only; NULL for SO/credit memo/cash sale), `duedate`, `shipmethod`, `opportunity`, `shippingaddress` (numeric id → join `transactionShippingAddress`), and custom fields (`CUSTBODY_END_CUSTOMER`, `CUSTBODY_STOCKING_ORDER`, …).
@@ -114,6 +115,25 @@ Header fields NOT in SuiteQL (✗ — require a per-id REST GET): `shippingCost`
114
115
 
115
116
  Tracking numbers in SuiteQL (verified 2026-09-28): `transaction.trackingnumberlist` (on the IF) / `linkedtrackingnumberlist` (on the SO) return **`trackingnumber` record ids**, not the numbers — resolve with `SELECT id, trackingnumber FROM trackingnumber WHERE id IN (...)`. `trackingnumbermap` returned nothing. The REST per-id GET below also has them.
116
117
 
118
+ ### Order → the transactions created FROM it: `nexttransactionlink`
119
+
120
+ **`createdfrom` is a `transactionline` column — SuiteQL rejects it on `transaction`.** There is no
121
+ header-level createdfrom to filter on. To find an order's fulfillments (or any child document), join
122
+ `nexttransactionlink`:
123
+
124
+ ```sql
125
+ SELECT ntl.previousdoc, ntl.nextdoc, t.tranid, t.type, t.status
126
+ FROM nexttransactionlink ntl
127
+ JOIN transaction t ON t.id = ntl.nextdoc
128
+ WHERE ntl.previousdoc IN (<order internal ids>)
129
+ ```
130
+
131
+ Pair it with the `ItemShip` status letters above to see how far each fulfillment got. This is how a
132
+ **NYCHH transfer order** is traced: `Client_Nychh.TransferOrders.c_netsuiteInternalSalesOrderId`
133
+ holds a NetSuite **sales order** internal id (a $0 SO *is* the transfer order — see
134
+ [the outbound push](../../../2.0/apps/worker2/features/netsuite-transferorder-outbound-push.md)), so
135
+ `previousdoc` is that id and the `ItemShip` rows that come back are its fulfillments.
136
+
117
137
  ### `transactionline` table
118
138
 
119
139
  Key columns (confirmed present): `transaction`, `linesequencenumber`, `uniquekey`, `id`, `item`, `itemtype`, `mainline`, `quantity`, `foreignamount`, `rate`, `costestimate`, `class`, `memo`, `quantitybilled`, `quantitycommitted`, `quantitypicked`, `quantityshiprecv`, `createdfrom`, `entity` (line-level customer), `location`, `subsidiary`.
@@ -271,4 +291,7 @@ SalesOrd per-id GET counts by `lastmodifieddate` window: ~16,600 (90d) / ~61,900
271
291
  - `../../worker/features/forecast2-netsuite-reconciliation.md` — the audit/trueup tooling that consumes this API.
272
292
 
273
293
  ## Change history
294
+ - 2026-10-01 — Added `nexttransactionlink` as the order → child-document join (`createdfrom` is a
295
+ `transactionline` column and is rejected on `transaction`), and the `ItemShip` status letters
296
+ (`A` picked / `B` packed / `C` shipped). Found tracing NYCHH transfer orders. (bala)
274
297
  - 2026-09-30 — Gotcha: `inventorybalance.quantityavailable` ignores SO commitments; use `aggregateitemlocation`. (rgirish)
@@ -12,6 +12,7 @@
12
12
  | [Tools — Developers Folder (UUID & Password Generators)](features/developer-tools.md) | Tools "Developers" folder — the Generate UUID and Generate Password tools (reference example of the one-tool-per-`mvc/<route>/get.php` pattern); open for the ge |
13
13
  | [/emails Email Queue Review (Tools → 2.0 Logs_<Client>.Email)](features/email-queue-review.md) | Staff page to browse any 2.0 client's `Logs_<Client>.Email` queue and release/reject `HELD` review-hold emails. |
14
14
  | [/errors Curation Console (Tools → shared Core Logs DB)](features/errors-curation-console.md) | Internal-only 1.0 Tools triage/curation screen (`/errors`) for the 2.0 Issue/Event error pipeline, reading the shared Core Logs DB via `db_toga2logs`; curates, |
15
+ | [Force Logout (tools /developers/force-logout → worker2 sets META_LAST_REFRESH_DATETIME)](features/force-logout.md) | Tools page that signs every 2.0 front-end user out after a big deploy by changing Core parameter `META_LAST_REFRESH_DATETIME` through a worker2 job; open to tri |
15
16
  | [GitHub Audit (monthly code-churn report)](features/github-audit.md) | Internal monthly code-churn report at `/developers/github-audit` (lines added/removed across every `agilantsolutions` repo for one month); open for the two-phas |
16
17
  | [Legacy Email Notifier (tools /email-migration/notify)](features/legacy-email-notifier.md) | SSO-gated admin tool at `/email-migration/notify` that emails employees a digest of mail still hitting their legacy domains (asks senders to switch to `@togatec |
17
18
  | [Tools — Multi-Client SQL (per-client-DB SQL fan-out)](features/multi-client-sql.md) | Tools `/developers/multi-client-sql` — fans one SQL change-set across a live list of 2.0 client/log/archive/module DBs into a copy/download script (never execut |
@@ -0,0 +1,46 @@
1
+ ---
2
+ title: Force Logout (tools /developers/force-logout → worker2 sets META_LAST_REFRESH_DATETIME)
3
+ framework: "1.0"
4
+ repo: tools
5
+ project: Tools
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-01
10
+ owners: [bala]
11
+ files:
12
+ - tools/_/app/developers/forcelogout.php
13
+ - tools/mvc/developers/force-logout/get.php
14
+ - tools/mvc/developers/force-logout/post.php
15
+ - tools/_/app/nav.php
16
+ - worker2/Worker/Platform/ForceLogout.php
17
+ related:
18
+ - ./worker2-job-bridge.md
19
+ - ./developer-tools.md
20
+ - ../../../2.0/apps/toga2-commerce/features/force-logout-on-deployment.md
21
+ - ../../../2.0/apps/toga25-supply/features/force-logout-on-deployment.md
22
+ ---
23
+
24
+ Tools page that signs every 2.0 front-end user out after a big deploy by changing Core parameter `META_LAST_REFRESH_DATETIME` through a worker2 job; open to trigger a force logout, or before touching `Core.Parameters` from code.
25
+
26
+ ## Summary
27
+
28
+ Page `/developers/force-logout` (nav: Developers → Force Logout, persona **Development Team** only). Dev picks a date/time, presses Update. Replaces the old hand-written INSERT/UPDATE on `Core.Parameters` in prod. toga2-commerce and toga25-supply poll that key and log the user out on **any** change; the value itself does not matter (see the two FE docs in `related`).
29
+
30
+ ## How it works
31
+
32
+ 1. `post.php` actions: `load` (current value), `queue-update`, `job-status`.
33
+ 2. `App_Developers_ForceLogout::queueLogoutTimeUpdate()` validates strict `Y-m-d H:i:s` and **refuses a value equal to the stored one** (no change = no logout). Logs via `error_log` with the user uuid (audit).
34
+ 3. Queues `Platform/ForceLogout/SetLogoutTime` with `App_Worker::enqueue` (see [worker2-job-bridge](./worker2-job-bridge.md)).
35
+ 4. Page polls `job-status` every 2s: reads `WorkerJobs.isSuccess/dtStarted/output` scoped to this action only; shows only the first line of a failure, file paths stripped.
36
+ 5. worker2 `_Worker_Platform_ForceLogout::SetLogoutTime(value, requestedByUserUuid = '')` loads `_Model_Core_Parameter` by the hardcoded key, sets `value`, `save()`. Turns the query cache off first, then calls `_Database::transactionCommit(database: _underscore::DB_CORE)` explicitly.
37
+
38
+ ## Gotchas
39
+
40
+ - **⚠ Never take the parameter key from input.** `Core.Parameters` also holds api2 secrets (`API_SECRET_ACCESS_TOKEN` etc). Key is a constant in both tools and worker2.
41
+ - **`createFromFormat` alone accepts `2026-02-31`** (rolls over). Validate with a round trip: `format()` of the parsed date must equal the input.
42
+ - **Works on prod tools only today.** Only `config.production.ini` has both `[worker]` (queue_url/queue_region) and `[database_toga2core]`. alpha has Core but no `[worker]`; no beta tools config has a worker queue.
43
+ - **Deploy worker2 before tools**, or the first job fails with an unknown class.
44
+
45
+ ## Change history
46
+ - 2026-10-01 — Built the page + worker2 action, replacing hand-run SQL on Core.Parameters. TRUE-82414. (bala)
@@ -6,11 +6,12 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-23
10
- owners: [mhammontree]
9
+ updated: 2026-10-01
10
+ owners: [mhammontree, bala]
11
11
  files:
12
12
  - tools/_/app/worker.php
13
13
  related:
14
+ - ./force-logout.md
14
15
  - ../../../clients/compass-usa/features/people-file-import-admin-page.md
15
16
  - ./mvc-data-access-patterns.md
16
17
  - ../../../2.0/apps/worker2/architecture.md
@@ -63,6 +64,16 @@ empty result — because the wrapper format is worker2's, not a contract.
63
64
  `maxExecutionTime`. Never make a page wait for long work — poll, don't block.
64
65
  - **Deploy worker2 first.** A Tools page that calls a new action is broken until the action
65
66
  exists.
67
+ - **Most tools configs cannot enqueue.** Only `config.production.ini` has a `[worker]` section
68
+ (queue_url/queue_region); alpha and beta tools have none.
69
+
70
+ ## Local testing (tools → worker2)
71
+
72
+ 1. Tools picks `config.<ENVIRONMENT>.ini` from the `ENVIRONMENT` env var; `config.dev-local.ini` is git-ignored and needs `[worker]` + the DB sections.
73
+ 2. Tools `composer install` fails on local PHP 8.1 (`symfony/filesystem` needs 8.2). Workaround: a git-ignored `tools/vendor/autoload.php` that requires worker2's vendor autoload.
74
+ 3. The AWS SDK ignores the host in `QueueUrl`. Set `AWS_ENDPOINT_URL_SQS` to a local fake SQS that POSTs the message body to `http://worker2/` (Laragon vhost).
75
+ 4. Dev-bypass login must use a `Client_True` email (e.g. `@togatech.com`, not `@goagilant.com`).
66
76
 
67
77
  ## Change history
78
+ - 2026-10-01 — Added the local tools→worker2 test setup and the prod-only `[worker]` config fact (TRUE-82414). (bala)
68
79
  - 2026-09-23 — Created from TRUE-82250: documented the three `enqueue()` outcomes, the log-line-wrapped `output`, and the CTO decision that tools never speaks SFTP (phpseclib 1.x sha1-only KEX + credential duplication). (mhammontree)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-30
9
+ updated: 2026-10-01
10
10
  owners: ["dfranks", "bala", "jcardinal", "mhammontree", "snaredla", "rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
@@ -106,6 +106,10 @@ The value is `<seconds>-<STATE>`. Three readings, and only the first is a real f
106
106
 
107
107
  - **`1-RUNNING`** (window collapsed ÷3 to 1s) = throwing on **every** run = real freeze. Go read the
108
108
  error (`Logs.Event` by clientId → file:line → `Logs_<Client>.Api` on the transactionId).
109
+ **⚠ Nothing alerts on the way down.** `common_sync_togasupply.php:675-681` shrinks the window
110
+ ÷3 and the paired *"Interval is already at minimum but previous execution still failed to
111
+ finish"* throw is **commented out** (“so the script can continue”), so a phase degrades
112
+ 864000 → … → **1 second** silently and never climbs back. Only the `Parameters` value shows it.
109
113
  - **A large-window `*-RUNNING`** (e.g. `864000-RUNNING`, `288000-RUNNING`) = **usually** healthy
110
114
  mid-run or a deliberate pause/backfill. **⚠ CORRECTED 2026-09-18 — this is NOT sufficient on its
111
115
  own, and the old "NOT stuck, do not fix it" wording caused a real miss.** NYCHH showed
@@ -577,6 +581,23 @@ purchase orders, invoices and item receipts were disabled again on 2026-09-03**
577
581
  is exactly why every NYCHH cursor stops at **2026-09-03**. Before concluding "the sync is broken",
578
582
  check the launcher's toggles at the relevant commit.
579
583
 
584
+ ### ⚠ NYCHH syncs ONCE A DAY — and its live state is in the CLIENT db, not Core
585
+
586
+ Two traps that cost a diagnosis, both NYCHH-specific:
587
+
588
+ - **The `*/5` schedule is not the real cadence.** `cron.worker.sync.json` registers
589
+ `sync_togasupply_hh.php` every 5 minutes like everyone else, but the wrapper calls
590
+ **`App_Framework::cronInitialization(86400)`** (“THIS CRON JOB IS INTENDED TO BE RUN EVERY DAY”),
591
+ so 287 of every 288 invocations exit immediately. Anything NetSuite ships after the morning run
592
+ waits until the next day. Account for that before calling a late import “broken” — and when
593
+ rewinding a cursor, run the wrapper by hand rather than waiting (the 10-day ceiling above means a
594
+ deep rewind would otherwise take weeks).
595
+ - **Read `Client_Nychh.Parameters`, never the `NETSUITE_HH_*` keys in `Core`.** Core still carries
596
+ legacy `NETSUITE_HH_*` rows whose last-sync dates sit in **May 2026**; they are **stale leftovers
597
+ that nothing writes**. The live cursors and execution modes are
598
+ `NETSUITE_LAST_SYNC_CURSOR_*` / `NETSUITE_EXECUTION_MODE_*` in the client DB (see *Data model*).
599
+ Reading the Core copies leads straight to a wrong diagnosis.
600
+
580
601
  ### Replaying one order means rewinding the cursor by hand
581
602
 
582
603
  The cursor is a **seek** position (`<lastmodifieddate>|<internalId>`, see above), so an order whose
@@ -2044,6 +2065,12 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
2044
2065
  [NetSuite Sync Alert Monitor](../../library/features/netsuite-sync-alert-monitor.md).
2045
2066
 
2046
2067
  ## Change history
2068
+ - 2026-10-01 — NYCHH diagnosis traps recorded: the wrapper is gated to **once a day** by
2069
+ `cronInitialization(86400)` despite its `*/5` schedule entry, and the live cursors/modes live in
2070
+ `Client_Nychh.Parameters` — the `NETSUITE_HH_*` keys in `Core` are stale (May 2026) leftovers.
2071
+ Also noted that the “interval already at minimum” throw is **commented out**
2072
+ (`common_sync_togasupply.php:675-681`), so a phase can decay to a 1-second window with no alert.
2073
+ (bala)
2047
2074
  - 2026-09-30 — Closed-order rule v3: drop only when every line is closed and nothing was created from it ([doc](../../library/features/netsuite-closed-order-rule.md)). Sync never auto-creates warehouse Locations (`throwIfUnknownWarehouse`); unit location from the line, not `custitemnumber_location`. Added `process_netsuite_records.php` and the stuck-sync diagnosis path. (rgirish)
2048
2075
  - 2026-09-24 — TO↔PO links: two-direction reconcile (vendor POs no longer upstream; retireStale moves downstream SO links to 326/330); fixed SO downstream line-link deletes on PO re-sync; new `process_netsuite_order.php` + `NETSUITE_SYNC_LOOKUPS_ONLY`. (jcardinal)
2049
2076
  - 2026-09-22 — **The `Logs.Issue` 589 doom-loop's framework root cause is now fully explained and has
@@ -8,6 +8,7 @@ type: feature
8
8
  status: active
9
9
  updated: 2026-10-01
10
10
  owners: [apeterson]
11
+ owners: [apeterson, bala]
11
12
  files:
12
13
  - toga2-commerce/src/hooks/useDeploymentGuard.tsx
13
14
  - toga2-commerce/src/lib/deploymentVersion.ts
@@ -15,6 +16,7 @@ files:
15
16
  - toga2-commerce/src/components/AuthLayout/AuthLayout.tsx
16
17
  - toga2-commerce/src/lib/authStorage.ts
17
18
  related:
19
+ - ../../../../1.0/apps/tools/features/force-logout.md
18
20
  - ./sso-redirect-and-session-gating.md
19
21
  - ./catalog-cache-freshness.md
20
22
  - ../../toga25-supply/features/force-logout-on-deployment.md
@@ -32,6 +34,8 @@ Ported from toga25-supply (`force-logout-on-deployment`). Two commerce-only diff
32
34
 
33
35
  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
36
 
37
+ **Trigger:** change the value with tools `/developers/force-logout` ([force-logout](../../../../1.0/apps/tools/features/force-logout.md)), not hand SQL.
38
+
35
39
  ## How it works
36
40
 
37
41
  1. One query on `META_LAST_REFRESH_DATETIME`, `enabled` only while authenticated, `staleTime: 0`, `refetchOnWindowFocus: true`, `refetchInterval` **5 minutes**.
@@ -76,4 +80,5 @@ To also fire on an in-app navigation: a `useLocation()` effect calling `refetch(
76
80
 
77
81
  ## Change history
78
82
  - 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)
83
+ - 2026-10-01 — Trigger is now the tools Force Logout page (TRUE-82414). (bala)
79
84
  - 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)
@@ -7,12 +7,13 @@ client: shared
7
7
  type: feature
8
8
  status: active
9
9
  updated: 2026-10-01
10
- owners: [apeterson]
10
+ owners: [apeterson, bala]
11
11
  files:
12
12
  - toga25-supply/src/hooks/useDeploymentGuard.tsx
13
13
  - toga25-supply/src/App.tsx
14
14
  related:
15
15
  - ../../toga2-commerce/features/force-logout-on-deployment.md
16
+ - ../../../../1.0/apps/tools/features/force-logout.md
16
17
  ---
17
18
 
18
19
  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`.
@@ -21,6 +22,8 @@ Force-signs-out every user when a deploy bumps a Core parameter, so session + ca
21
22
 
22
23
  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()`.
23
24
 
25
+ **Trigger:** change the value with tools `/developers/force-logout` ([force-logout](../../../../1.0/apps/tools/features/force-logout.md)), not hand SQL.
26
+
24
27
  ## How it works
25
28
 
26
29
  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.
@@ -32,5 +35,6 @@ On large deploys the backend bumps Core parameter `META_LAST_REFRESH_DATETIME`.
32
35
  **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.
33
36
 
34
37
  ## Change history
38
+ - 2026-10-01 — Trigger is now the tools Force Logout page (TRUE-82414). (bala)
35
39
  - 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
40
  - 2026-09-16 — Initial implementation. (apeterson)
@@ -15,7 +15,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
15
15
  - **test** (Test) — 16 doc(s) → [1.0/apps/test/INDEX.md](1.0/apps/test/INDEX.md)
16
16
  - **toga** (TOGa) — 3 doc(s) → [1.0/apps/toga/INDEX.md](1.0/apps/toga/INDEX.md)
17
17
  - **store** (Store) — 3 doc(s) → [1.0/apps/store/INDEX.md](1.0/apps/store/INDEX.md)
18
- - **tools** (Tools) — 23 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
18
+ - **tools** (Tools) — 24 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
19
19
 
20
20
  ## 2.0 framework
21
21
 
@@ -62,7 +62,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
62
62
  - **ManageLife** (`managelife`) → [clients/managelife/INDEX.md](clients/managelife/INDEX.md)
63
63
  - **Northwell Health** (`northwell`) → [clients/northwell/INDEX.md](clients/northwell/INDEX.md)
64
64
  - **New York City Department of Education** (`nycdoe`) → [clients/nycdoe/INDEX.md](clients/nycdoe/INDEX.md)
65
- - **NYC Health & Hospitals** (`nychh`) → [clients/nychh/INDEX.md](clients/nychh/INDEX.md)
65
+ - **NYC Health + Hospitals** (`nychh`) → [clients/nychh/INDEX.md](clients/nychh/INDEX.md)
66
66
  - **Office Depot** (`office-depot`) → [clients/office-depot/INDEX.md](clients/office-depot/INDEX.md)
67
67
  - **PC Matic B2B Client Profile** (`pcmaticb2b`) → [clients/pcmaticb2b/INDEX.md](clients/pcmaticb2b/INDEX.md)
68
68
  - **Prudential Financial** (`prudential`) → [clients/prudential/INDEX.md](clients/prudential/INDEX.md)
@@ -1,4 +1,4 @@
1
- # Client: NYC Health & Hospitals `nychh`
1
+ # Client: NYC Health + Hospitals `nychh`
2
2
 
3
3
  | Doc | Framework | Summary |
4
4
  |-----|-----------|---------|
@@ -10,4 +10,4 @@
10
10
  | [NYCHH transfer-order 2.0 model — inventory quantities + V2 field enablement](features/transfer-order-inventory-quantities.md) | 2.0 | NYCHH 2.0 transfer-order model — two-branch inventory quantities + the model/ACL work that lets a TransferOrder POST; open when NYCHH `_qty*` reads 0 or a Trans |
11
11
  | [NYCHH TransferOrder → NetSuite push (location roles, interceptor row 312, metadata state)](features/transfer-order-netsuite-push.md) | 2.0 | NYCHH is the **first and only** client on the outbound transfer-order push. |
12
12
  | [NYCHH transfer-order notification emails (Submitted / Packed / Shipped)](features/transfer-order-notification-emails.md) | 2.0 | NYC Health + Hospitals (H+H) gets three notification emails for a transfer order raised in **TOGa Supply**: **Submitted**, **Packed** and **Shipped**. |
13
- | [NYC Health & Hospitals](profile.md) | 2.0 | NYC Health & Hospitals (NYCHH) client profile — open for supply-chain / TransferOrder / NetSuite-sync work on prod schema `Client_Nychh`. |
13
+ | [NYC Health + Hospitals](profile.md) | 2.0 | NYC Health & Hospitals (NYCHH) client profile — open for supply-chain / TransferOrder / NetSuite-sync work on prod schema `Client_Nychh`. |
@@ -6,13 +6,14 @@ project: Worker
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-24
9
+ updated: 2026-10-01
10
10
  owners: ["bala", "rgirish", "jcardinal"]
11
11
  files:
12
12
  - worker2/Worker/Client/Nychh/TransferOrderEmails.php
13
13
  - _underscore/Model/Nychh/TransferOrder.php
14
14
  - _underscore/Model/Nychh/ItemFulfillment.php
15
15
  - dbchanges2/Client_Nychh/2026-09-15a - TransferOrderEmails.sql
16
+ - dbchanges2/Client_Nychh/2026-09-30a - TransferOrderPackedAssetTagException.sql
16
17
  - dbchanges2/Client_Nychh/2026-09-15b - TransferOrderEmailGoLiveRecipient.sql
17
18
  - dbchanges2/Core/2026-09-16a - NychhTransferOrderEmailsCron.sql
18
19
  related:
@@ -47,6 +48,8 @@ Three rules carry most of the risk:
47
48
  2. **Only orders raised in TOGa Supply may be announced.** `TransferOrders.createdByUserId` is the
48
49
  discriminator; without it H+H would be mailed about every order the NetSuite sync ever imported.
49
50
  3. **A failed send throws — it is never swallowed**, so nothing is ever marked sent that did not go.
51
+ 4. **Packed (and shipped) are HELD while a line is short of asset tags** — ops is warned instead, and
52
+ the customer email sends itself once the tags land. See below.
50
53
 
51
54
  Recipient is `DevTeam@togatech.com` while testing. `2026-09-15b` swaps it to
52
55
  `EndUserDeviceLifecycle@nychhc.org` — run that file only after sign-off.
@@ -57,6 +60,8 @@ Recipient is `DevTeam@togatech.com` while testing. `2026-09-15b` swaps it to
57
60
  - `SendSubmitted(string $transferOrderUuid)` — queued from the TransferOrder `postPut`.
58
61
  - `SendFulfillment(string $itemFulfillmentUuid)` — queued from the ItemFulfillment hooks.
59
62
  - `SendPending(bool $dryRun = true)` — the `*/5` backstop cron.
63
+ - `resolveStagesStillToAnnounce()` — which stages are still owed (see the late-shipped case).
64
+ - `readLinesMissingAssetTags()` / `sendPackedExceptionEmail()` — the asset-tag hold.
60
65
  - `initialize()` registers the client DB before any query runs.
61
66
  - Builders: `buildItemRowsHtml()`, `buildTrackingSectionHtml()`, `buildAssetTagTableHtml()`,
62
67
  `escapeForEmailHtml()`; budget constant `MAXIMUM_EMAIL_BODY_BYTES`.
@@ -70,6 +75,8 @@ Recipient is `DevTeam@togatech.com` while testing. `2026-09-15b` swaps it to
70
75
  - submitted `0f4a6c21-9d3e-4b87-a15c-6e2b8f70d914`
71
76
  - packed `7b1d9e54-3c62-48af-b0d7-25f4a8c31e6b`
72
77
  - shipped `c58f3a07-6b94-4d21-8e3f-914ad7b06c52`
78
+ - packed **exception** (internal warning, seeded by `2026-09-30a`)
79
+ `9d3b1e6a-47c2-4f85-b019-2ae85c7d4630`
73
80
  - **`nychh_email_templates/*.html`** — ⚠ the HTML source of the three templates. **These files are
74
81
  in no git repo** — they live only in the working folder. The dbchanges2 seed SQL is generated
75
82
  *from* them, so the `EmailTemplates` rows in `2026-09-15a` are the only durable copy.
@@ -114,6 +121,14 @@ they must stay in step: the Submitted guard, the order read, and both `SendPendi
114
121
  2. **Packed / Shipped** — the ItemFulfillment `postPost` / `postPut` queue `SendFulfillment` with
115
122
  the fulfillment uuid. The stage reached is recorded on `c_transferOrderEmailStageSent`, with
116
123
  `c_dtTransferOrderEmailSent` as the timestamp, so a fulfillment cannot re-announce a stage.
124
+ `resolveStagesStillToAnnounce()` decides what is owed: normally the one stage reached, but when a
125
+ fulfillment **arrives already shipped** and packed was never announced it returns
126
+ **`[packed, shipped]`** and both send in that order in one run. Before this the packed stage was
127
+ skipped outright — and **the asset-tag check lives on packed**, so a late shipped fulfillment
128
+ bypassed it completely. Real case: transfer order **291773** got Submitted and Shipped, never
129
+ Packed. The NYCHH NetSuite sync is a **daily** cron, so a fulfillment landing fully shipped is
130
+ normal, not an edge case — see
131
+ [the per-client sync](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
117
132
  3. The **backstop cron** sweeps anything the hooks missed (below).
118
133
  4. All three paths share the same markers, so nothing sends twice.
119
134
  5. The worker renders the stored `EmailTemplates` body, fills `{orderItems}` and
@@ -137,6 +152,42 @@ PUT follows.** Fulfillment **2704** in production: header row at 20:33:09, lines
137
152
  - It also picks up any send that threw (the marker was never written).
138
153
  - Caps at **25 of each kind per run** (`MAXIMUM_EMAILS_PER_RUN`).
139
154
 
155
+ ## ⚠ Missing asset tags HOLD the packed email (and warn ops instead)
156
+
157
+ Added 2026-09-30 (TRUE-82111). `SendFulfillment` runs `readLinesMissingAssetTags()` whenever
158
+ **packed** is one of the stages still owed. A fulfillment line counts as **short** when either:
159
+
160
+ - it has **fewer linked units than its quantity** (`unitsLinked < ItemFulfillmentItems.quantity`), or
161
+ - a linked unit has a **blank `Units.assetTag`**.
162
+
163
+ When any line is short, `SendFulfillment` **returns before writing
164
+ `c_transferOrderEmailStageSent`** — so packed stays pending and the backstop cron re-sends it by
165
+ itself the moment the tags arrive. **The hold covers shipped too**, because the early return happens
166
+ before the stage loop.
167
+
168
+ Instead, an **internal** warning goes out on template `9d3b1e6a-47c2-4f85-b019-2ae85c7d4630`
169
+ (`2026-09-30a`): **to `operations@togatech.com`, CC `HealthcareteamNYCHHC@togatech.com`** —
170
+ **never the client**. It lists one row per short line. Subject carries both numbers so it is
171
+ searchable:
172
+
173
+ ```
174
+ Action needed: asset tags missing on transfer order {transferOrderNumber}, fulfillment {itemFulfillmentNumber}
175
+ ```
176
+
177
+ - **It warns once per fulfillment, not every five minutes.** New custom field
178
+ **`c_dtTransferOrderPackedExceptionSent`** on `ItemFulfillments` (record **28**) is the marker;
179
+ `sendPackedExceptionEmail()` returns early when it is already set, and stamps it only after a
180
+ successful send.
181
+ - **⚠ Warned rows must still be SELECTED by the cron, just sorted last.**
182
+ `sendPendingFulfillmentEmails()` takes `LIMIT 25` per run, and a held fulfillment keeps
183
+ `c_transferOrderEmailStageSent` NULL forever, so 25 stuck rows would sit at the front of every run
184
+ and starve everyone else. The order is now
185
+ `ORDER BY (c_dtTransferOrderPackedExceptionSent IS NOT NULL) ASC, id ASC`. **Do not filter them
186
+ out** — that strands them permanently; they must still be picked up the run after the tags land.
187
+
188
+ See [the NYCHH asset-tag backfill](../../../2.0/apps/worker2/features/nychh-asset-tag-backfill.md)
189
+ for where the tags come from.
190
+
140
191
  ## Schema, registration and deploy order
141
192
 
142
193
  `dbchanges2/Client_Nychh/2026-09-15a - TransferOrderEmails.sql` carries the whole feature:
@@ -145,6 +196,8 @@ PUT follows.** Fulfillment **2704** in production: header row at 20:33:09, lines
145
196
  |---|---|
146
197
  | `c_dtTransferOrderSubmittedEmailSent` | `TransferOrders` (record **312**) |
147
198
  | `c_transferOrderEmailStageSent`, `c_dtTransferOrderEmailSent` | `ItemFulfillments` (record **28**) |
199
+ | `c_dtTransferOrderPackedExceptionSent` (`2026-09-30a`) | `ItemFulfillments` (record **28**) |
200
+ | The packed-exception `EmailTemplates` row + its TO/CC rows (`2026-09-30a`) | `Client_Nychh` |
148
201
  | The three `EmailTemplates` rows + recipient rows | `Client_Nychh` |
149
202
  | `CustomRecordFields` rows + `AclCustomFieldPermissions` grants (Base and API) | `Client_Nychh` |
150
203
  | The record-312 **PUT** interceptor row | `ApiPayloadInterceptors` |
@@ -255,9 +308,22 @@ Cost of the split, stated so nobody is surprised later:
255
308
  `Logs_Nychh.Email` without an inbox — that is how these were verified. See
256
309
  [the email send pipeline](../../../2.0/apps/_underscore/features/email-send-pipeline.md).
257
310
  - **The `nychh_email_templates/*.html` sources are not version-controlled** (see *Key files*).
311
+ - **⚠ An email that leaves the dev team never describes internal process.** The packed-exception
312
+ body first explained NetSuite, TOGa Supply, the sync and the five-minute retry; all of it was
313
+ removed on instruction. An email says **what happened, what to do, and that it is handled** —
314
+ nothing about which system, which cron, or how often we retry. Process detail belongs in code
315
+ comments and the dbchanges2 header. Applies to every template here, not just the exception one.
316
+ - **⚠ The ops / healthcare-team addresses have never actually been exercised.** On dev sandbox the
317
+ debug redirect rewrites every recipient, so the `operations@togatech.com` TO and the
318
+ `HealthcareteamNYCHHC@togatech.com` CC on the exception template are unproven until prod. Check the
319
+ first live exception email lands on both.
258
320
 
259
321
  ## Testing
260
322
 
323
+ **106 tests** as of 2026-09-30. Each fix is proven by **reverting it** and confirming the matching
324
+ tests fail. The five scenarios were then run end to end on **dev sandbox** against fulfillment
325
+ **898** / transfer order **286536**.
326
+
261
327
  A local end-to-end harness (scratchpad, **not committed**) runs the **real** worker class against a
262
328
  throwaway local schema built with `CREATE TABLE LIKE` from `Client_Nychh`, stubbing **only**
263
329
  `_Query` / `_Database` / `_Model_Client_EmailTemplate`, and rendering the **real** template bodies
@@ -266,6 +332,19 @@ loaded out of the dbchanges2 file so an unresolved `{placeholder}` fails a test.
266
332
  unescaped values and both detail-box defects were all found there, not in production.
267
333
 
268
334
  ## Change history
335
+ - 2026-10-01 — **Packed/shipped are now HELD when a fulfillment line is short of asset tags**
336
+ (TRUE-82111). A line is short when it has fewer linked units than its quantity **or** a linked unit
337
+ has a blank `assetTag`; `SendFulfillment` then returns without writing
338
+ `c_transferOrderEmailStageSent`, so the customer email sends itself once the tags land, and an
339
+ internal warning goes to `operations@togatech.com` (CC `HealthcareteamNYCHHC@togatech.com`) once
340
+ per fulfillment via new marker `c_dtTransferOrderPackedExceptionSent` and template
341
+ `9d3b1e6a-…` (`2026-09-30a`). Two fixes came with it: `resolveStagesStillToAnnounce()` now sends
342
+ **packed then shipped in one run** when a fulfillment arrives already shipped (transfer order
343
+ **291773** had Submitted + Shipped and no Packed — which also meant it skipped the asset-tag
344
+ check), and `sendPendingFulfillmentEmails()` sorts warned rows **last**
345
+ (`ORDER BY (c_dtTransferOrderPackedExceptionSent IS NOT NULL) ASC, id ASC`) so 25 held rows cannot
346
+ fill the `LIMIT 25` and starve every other fulfillment. Also recorded the rule that outbound emails
347
+ never describe internal process. (bala)
269
348
  - 2026-09-24 — `TransferOrderEmails` reads PO numbers from upstream bridge 325 (column `purchaseOrderId` dropped). (jcardinal)
270
349
  - 2026-09-23 — Submitted template put on review hold; missing REVIEW row → job failure note. (jcardinal)
271
350
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- title: "NYC Health & Hospitals"
2
+ title: "NYC Health + Hospitals"
3
3
  framework: "2.0"
4
4
  apps:
5
5
  - _underscore
@@ -18,7 +18,7 @@ project: _Underscore
18
18
  client: nychh
19
19
  type: profile
20
20
  status: active
21
- updated: 2026-09-30
21
+ updated: 2026-10-01
22
22
  owners: ["jcardinal", "apeterson", "bala", "akhokhani"]
23
23
  files:
24
24
  - dbchanges2/Client_Nychh/2026-09-02a - TransferOrderNetsuitePushInterceptor.sql
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.928",
3
+ "version": "1.0.929",
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",