toga-ai 1.0.842 → 1.0.844

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: Worker
6
6
  client: staples
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: [jcardinal, mhammontree]
9
+ updated: 2026-09-18
10
+ owners: [jcardinal, mhammontree, bala]
11
11
  files:
12
12
  - worker/crons/sync/staples/sync_staples_cxml.php
13
13
  related:
@@ -15,99 +15,77 @@ related:
15
15
  - 1.0/apps/worker/features/netsuite-sales-order-sales-rep-sourcing.md
16
16
  ---
17
17
 
18
- Hourly cron importing Staples cXML POs from SFTP into NetSuite Sales Orders (writes 855 ack back); open for the per-file flow, saved-search-8412 Location resolution, new-account onboarding, and the silent add-failure trap.
18
+ Hourly cron importing Staples cXML POs from SFTP into NetSuite Sales Orders (writes 855 ack back); open for the per-file flow, saved-search-8412 Location resolution, new-account onboarding, how to diagnose an order that never appeared, and the silent add-failure trap.
19
19
 
20
20
  ## Summary
21
21
 
22
- `sync_staples_cxml.php` is an **hourly** cron (runs at **:45**) that imports Staples cXML
23
- purchase orders from SFTP into NetSuite as Sales Orders, then writes an 855 acknowledgement
24
- back. Staples is NetSuite parent entity/customer **internalId 30057**.
22
+ `sync_staples_cxml.php` is an hourly cron (`45 * * * *`, `schedules/cron.worker.sync.json`) that imports Staples cXML purchase orders from SFTP into NetSuite as Sales Orders, then writes an 855 acknowledgement back. Staples is NetSuite parent entity/customer **internalId 30057**.
25
23
 
26
24
  ## How it works
27
25
 
28
- Flow per run: log into SFTP `sftp.goagilant.com` as `staplessftp`, list `/in`, and for each
29
- cXML order file:
30
-
31
- 1. Replace a known part-number escaping edge case in the raw file.
32
- 2. Parse via `App_Xml`.
33
- 3. Log the raw file to **FileLog** (schema `Logs`, table `FileLog`, job
34
- `STAPLES_IMPORT_ORDER`). `App_Model_Logs_FileLog` sets
35
- `_databaseNameOverride = 'Logs'`, so this writes to the legacy/V1 prod schema `Logs`.
36
- The full inbound cXML is stored in `FileLog.fileData`.
37
- 4. Dedupe against existing NetSuite sales orders by customer PO
38
- (`App_NetSuite::getSalesOrdersByCustomerPo`).
39
- 5. Build and add a NetSuite `SalesOrder` under parent customer internalId 30057.
40
- 6. On successful add: write an **855 acknowledgement** cXML to SFTP `/out`, archive the
41
- source file to `/archive/archive_in`, and delete it from `/in`.
26
+ Per run: log into SFTP `sftp.goagilant.com` as `staplessftp`, list `/in`, and for each cXML order file:
27
+
28
+ 1. Replace a known part-number escaping edge case in the raw file; parse via `App_Xml`.
29
+ 2. Log the raw file to **FileLog** (schema `Logs`, job `STAPLES_IMPORT_ORDER`) — written **before** any NetSuite call. `App_Model_Logs_FileLog` sets `_databaseNameOverride = 'Logs'`, so this writes to the legacy/V1 prod schema `Logs`. The full inbound cXML is stored in `FileLog.fileData`.
30
+ 3. Dedupe against existing NetSuite sales orders by customer PO (`App_NetSuite::getSalesOrdersByCustomerPo`).
31
+ 4. Build and add a NetSuite `SalesOrder` under parent customer 30057.
32
+ 5. **On success only:** write the 855 acknowledgement cXML to `/out` as `855_<orderId>_<ts>`, archive the source file to `/archive/archive_in`, and delete it from `/in`.
33
+
34
+ Step 5 running only on success is the key diagnostic lever: no 855 and no archived PO means the sales order was never created.
42
35
 
43
36
  ### Line-item Location resolution
44
37
 
45
- Each line-item's **Location** is resolved by matching the order's Staples `CustomerID`
46
- (from `<Extrinsic name="CustomerID">`) against NetSuite **saved search 8412**:
38
+ Each line-item's **Location** comes from the order's `<Extrinsic name="CustomerID">` account code matched against NetSuite **saved search 8412** (`ns_runSavedSearch`, recordType `customer`):
47
39
 
48
40
  - match key: `custentity_external_customer_id`
49
41
  - location value: `custentity_customer_location`
50
42
 
51
- If no match is found, `$internalClientLocationId` stays **NULL**, the line-item Location is
52
- empty, and **NetSuite rejects the whole add** with `USER_ERROR` "Please enter value(s) for:
53
- Location".
43
+ No match — **or a match whose Location is blank** — leaves `$internalClientLocationId` **NULL**, the line-item Location empty, and **NetSuite rejects the whole add** with `USER_ERROR` "Please enter value(s) for: Location". This is the most common cause of a Staples PO that never reaches NetSuite, and it produces no log line (see gotchas).
54
44
 
55
45
  ### Onboarding rule — new Staples buying account
56
46
 
57
- A Staples buying account is a **NetSuite child customer** of parent 30057. Before that
58
- account's first order arrives it must exist in NetSuite with:
47
+ A Staples buying account is a NetSuite **child customer of 30057**. Set it up *before* its first order arrives, or every order for that account jams on SFTP `/in`, retrying hourly forever with no error surfaced:
48
+
49
+ - **Parent** = 30057. A customer not under 30057 does not appear in saved search 8412.
50
+ - **`custentity_external_customer_id`** = the account code from the cXML `CustomerID`.
51
+ - **`custentity_customer_location`** = one Location. The field is a **multi-select** and must hold **exactly one** entry.
52
+ - Staples Locations are children of Location **36** "New York : New York Warehouse : Staples". Each end customer normally gets its own child Location there; some reuse 36 directly.
53
+
54
+ Worked example (2026-09-18): account code `0ZZZMICR`, customer **54606** "Staples, Inc. : Ingram Micro" (entity id 4679:7752) existed with the account code but a **blank** Location, so its orders failed every run. Creating Location **209** "Ingram Micro" (parent 36) and setting it on 54606 made the next run create SO **290453** for PO 955KVL, write `855_955KVL_1789744201` to `/out`, and archive the PO. Known-good reference row: customer 38626 Advance Auto, location 79.
55
+
56
+ ## Diagnosing an order that never appeared
59
57
 
60
- - **External Customer ID** = the account's Staples `CustomerID`, and
61
- - a valid **Customer Location**
58
+ The account code lives only in the **file body**, never in the filename, so start from FileLog.
62
59
 
63
- so it appears in saved search 8412. If it is missing (or has no Customer Location), every
64
- order for that account fails the NetSuite add and the file sits on SFTP `/in` retrying
65
- hourly forever — **with no error surfaced** (see gotchas). Coordinate with the NetSuite
66
- business admins to create the customer before go-live for any new account.
60
+ 1. **Count imports** — legacy `Logs`.`FileLog`, job `STAPLES_IMPORT_ORDER`, filtered `fileData LIKE '%<accountCode>%'`. Repeated hourly rows of the **same `fileName`** = the file is stuck in `/in` and the NetSuite add is failing silently.
61
+ 2. **Check SFTP** — an `855_<orderId>_*` in `/out` or `/archive`, and the PO in `/archive/archive_in`, are written only on success. Absent = never created.
62
+ 3. **Check NetSuite by SuiteQL** — `SELECT ... FROM transaction WHERE otherrefnum = '<orderId>'`. The Staples orderID lands in `otherRefNum`, which **Global Search does not index**, so a UI search proves nothing.
63
+ 4. **Read the mapping** — run saved search 8412 and read that account code's row's **Location Internal ID**. Blank Location = confirmed root cause. No row at all = customer missing, or not a child of 30057.
64
+
65
+ This also separates the two failure modes: FileLog is written before the NetSuite call, and a PHP warning **hard-exits** a 1.0 cron (`App_Error` → `exit`), so **few** rows for a run means it died early, while **one row per file** means it processed every file and the adds failed. Because the full cXML lives in `FileLog.fileData`, ship-to identity and `CustomerID` can be recovered from the legacy `Logs` DB without touching SFTP.
67
66
 
68
67
  ## Gotchas / known issues
69
68
 
70
- **Silent-swallow of failed NetSuite adds (durable warning).** The
71
- `if ($response->writeResponse->status->isSuccess)` block (~line 888) has **no `else`**. A
72
- failed add logs nothing, emails no one, and leaves the file on `/in`. A file only leaves
73
- `/in` on (a) add success — ack + archive + delete — or (b) the "already processed" dedupe
74
- branch — delete. A failed add never removes it, so a pure data gap (e.g. an un-set-up
75
- customer) is invisible and retries indefinitely. This is why a real backlog went unnoticed
76
- for ~6 days.
77
-
78
- **`getSavedSearch(8412)` is called once per order inside the file loop** — expensive
79
- (~20s/file). A future refactor should fetch/cache it once per run.
80
-
81
- **The `salesRep` assignment on this cron is dead code.** `$salesOrder->salesRep = 'Aaron Myall';`
82
- (~line 138) assigns a plain **string** to a field NetSuite types as a **RecordRef**; NetSuite
83
- silently discards it. Proven empirically: SO **286680** came out with **Paulina Szeliga** — the
84
- parent customer 30057's rep — not Aaron Myall. Because `entity` is always the parent, NetSuite
85
- sources the parent's rep and the custom `custbody_end_customer` never drives it. Separate-ticket
86
- candidate; left in place as of 2026-08-21. Also: the comment `//sales rep` above the
87
- `custbody_ctc_inside_sales_transaction` block is **wrong** — that block sets **Inside Sales
88
- Support** (hardcoded Nicholas Vance, internalId 497). Full mechanics in
89
- [NetSuite Sales Order Sales Rep Sourcing](netsuite-sales-order-sales-rep-sourcing.md).
69
+ **Silent-swallow of failed NetSuite adds (durable warning).** The `if ($response->writeResponse->status->isSuccess)` block (~line 888) has **no `else`**. A failed add logs nothing, emails no one, and leaves the file on `/in`. A file leaves `/in` only on add success (ack + archive + delete) or on the "already processed" dedupe branch. Real cost: order **435KNN** (Ingram, May 2026) retried **674 times** between 13 May and 10 Jun, was never created, and the file was eventually deleted by hand — that order is lost. **955KVL** retried 28 times over 17–18 Sep. A temporary diagnostic logging the NetSuite rejection under FileLog job `STAPLES_ADD_FAIL_DIAG` (run 13 Aug 2026) captured the exact "Please enter value(s) for: Location" error, but that code is **not** in the committed cron.
90
70
 
91
- **Recommended hardening (NOT yet implemented):**
92
- - Add the missing `else` to email + log add failures (mirror the existing "missing items"
93
- email) and leave the file for retry.
94
- - Pre-guard: if `$internalClientLocationId` is NULL, skip the doomed add and alert
95
- "Staples customer `<CustomerID>` not set up in NetSuite", naming the exact id.
71
+ **Dedupe does hold under resends.** Staples resent 955KVL twice while it was failing, leaving three copies in `/in`; when it finally succeeded only **one** sales order was created.
96
72
 
97
- ### Diagnostic technique — early PHP-warning exit vs. silent add-failure
73
+ **Duplicate `custentity_external_customer_id` across customers.** The cron keys its lookup array by the external customer id, so if two customers share a code the **last row returned by the saved search wins** — silently pointing orders at the wrong Location. Seen: `0ZZZMICR` on both the old standalone INGRAM MICRO (21608 — not a child of 30057, so not in search 8412, and therefore harmless) and the new sub-client 54606; `AIM71009` on two customers. Worth cleaning up.
98
74
 
99
- In Worker 1.0 a PHP warning/notice **hard-exits** the cron (`App_Error` → `exit`), so
100
- nothing after the failure point runs. This cron writes its **FileLog** row (job
101
- `STAPLES_IMPORT_ORDER`) **before** any NetSuite call. So counting fresh FileLog rows for a
102
- run distinguishes the two failure modes:
75
+ **`getSavedSearch(8412)` is called once per order inside the file loop** — expensive (~20s/file). A future refactor should fetch/cache it once per run.
103
76
 
104
- - **few** rows → it died early (a PHP warning aborted the run partway), vs.
105
- - **one row per file** → it processed every file but the NetSuite adds failed.
77
+ **The `salesRep` assignment on this cron is dead code.** `$salesOrder->salesRep = 'Aaron Myall';` (~line 138) assigns a plain **string** to a field NetSuite types as a **RecordRef**; NetSuite silently discards it. SO **286680** came out with **Paulina Szeliga**, parent 30057's rep, because `entity` is always the parent. Also, the `//sales rep` comment above the `custbody_ctc_inside_sales_transaction` block is **wrong** — that block sets **Inside Sales Support** (hardcoded Nicholas Vance, internalId 497). Full mechanics in [NetSuite Sales Order Sales Rep Sourcing](netsuite-sales-order-sales-rep-sourcing.md).
106
78
 
107
- Because the full inbound cXML lives in `FileLog.fileData`, ship-to identity and `CustomerID`
108
- can be recovered directly from the legacy `Logs` DB without touching SFTP.
79
+ **Recommended hardening (NOT yet implemented):**
80
+ - Add the missing `else` to log the NetSuite `statusDetail` and email add failures (mirror the existing "missing items" email), leaving the file for retry.
81
+ - Pre-guard: if `$internalClientLocationId` is NULL, skip the doomed add and alert "Staples customer `<CustomerID>` not set up in NetSuite", naming the exact id.
109
82
 
110
83
  ## Related
111
84
 
112
85
  - [Staples profile](../../../clients/staples/profile.md)
113
86
  - [NetSuite Sales Order Sales Rep Sourcing](netsuite-sales-order-sales-rep-sourcing.md)
87
+
88
+ ## Change history
89
+ - 2026-09-18 — Production debug of account `0ZZZMICR` (Ingram Micro) confirmed the blank `custentity_customer_location` root cause end to end. Added the diagnostic steps (FileLog `fileData LIKE`, success-only 855/archive, SuiteQL on `otherrefnum`, saved search 8412), the multi-select single-entry + parent-Location-36 setup rule, the duplicate external-customer-id failure mode, and real retry counts (435KNN x674, order lost; 955KVL x28, dedupe held). No code changed. (bala)
90
+ - 2026-08-21 — Recorded that `$salesOrder->salesRep = 'Aaron Myall'` is dead code and that the adjacent `//sales rep` comment actually describes Inside Sales Support (Nicholas Vance, 497). Removed a departing employee from the sibling cron's recipient list. No behavior change. (mhammontree)
91
+ - 2026-08-13 — Documented feature (first KB entry). Diagnosed a ~6-day, ~30-order backlog stuck on `/in`: new child customer INTEGRA PARTNERS INC. (CustomerID 03716185) was not set up, so no saved-search-8412 match → NULL Location. 28 of 30 files were that account; 2 were CustomerID 05611619 (existed, missing Customer Location). Business admins created/fixed the customers; re-running drained the backlog. (jcardinal)
@@ -16,6 +16,8 @@ related:
16
16
  - sales-order-status-filter-surface.md
17
17
  - tableview-joins.md
18
18
  - ../../toga-blox/features/table.md
19
+ - ../../api2/features/environment-variable-drives-underscore-branch.md
20
+ - ../../toga25-supply/features/persisted-query-cache.md
19
21
  - ../../api2/features/tableview-field-metadata.md
20
22
  ---
21
23
 
@@ -72,23 +74,44 @@ It is a **no-op for every existing column**: nothing else declares a `<field>Fil
72
74
  - Verified locally: 13 options for `Client_Compass`, 5 for `Client_CompassCanada`. Compass Canada
73
75
  also has a role `Agilant - Administrators`, correctly excluded by the exact `name = 'Admin'` match.
74
76
 
75
- **Internal TOGA staff are excluded (2026-09-18).** Admin-role holders on `@togatech.com`
76
- (Dev Team, internal employees) were showing up as assignable to Compass users. Added
77
- `const INTERNAL_EMAIL_DOMAIN = 'togatech.com'` plus a WHERE condition. The condition is
78
- **NULL-safe on purpose**:
77
+ **Two exclusion rules, both applied (2026-09-18).** Admin-role holders were showing up as
78
+ assignable to Compass users when they should not be:
79
+
80
+ 1. **Internal TOGA staff, by domain** — `const INTERNAL_EMAIL_DOMAIN = 'togatech.com'`.
81
+ 2. **Named individuals on a CLIENT domain** — `const EXCLUDED_ADMIN_EMAILS = ['vanessa.burks@compass-usa.com'];`
82
+ built into a `NOT IN (...)`. The domain rule alone could never catch her — her email is a
83
+ `@compass-usa.com` address. Assume any hide list will eventually need both.
84
+
85
+ Both conditions are **NULL-safe on purpose**:
79
86
 
80
87
  ```sql
81
88
  (Users.email IS NULL OR Users.email NOT LIKE '%@togatech.com')
89
+ (Users.email IS NULL OR Users.email NOT IN ('...'))
82
90
  ```
83
91
 
84
- A bare `NOT LIKE` yields NULL for a NULL email and would silently drop those admins.
85
-
86
- Two alternatives were considered and rejected:
87
- 1. Removing the `Admin` role from those users in `Users_Roles` — the team will not strip admin
88
- roles from real accounts.
89
- 2. A `c_isHiddenFromAdminFilter` custom column on `Client_Compass.Users` (the `c_isVip`
90
- precedent), making a hide a plain UPDATE with no deploy. Rejected for the simpler hardcoded
91
- domain rule — but this is the natural next step if per-user control is ever needed.
92
+ A bare `NOT LIKE` / `NOT IN` yields NULL for a NULL email and would silently drop those admins.
93
+ Each list value is escaped with `_Database::escape()`, and the `NOT IN` is **skipped entirely when
94
+ the array is empty** — an empty list builds `NOT IN ()`, a MySQL syntax error.
95
+
96
+ **Why a constant and not a `c_isHiddenFromAdminFilter` column on `Client_Compass.Users`** (proposed
97
+ twice, rejected twice — do not re-propose): these same people **must still appear in other admin
98
+ lists** in the product. A `c_` flag reads as a property of the person ("hidden from admin lists"),
99
+ so the next developer reuses it for a different list and hides them there too. A constant inside
100
+ `_assignedAdminFilterOptions()` cannot leak — the exclusion stays scoped to this one dropdown.
101
+ Accepted trade: adding a name needs a deploy. Rationale is repeated in the method docblock.
102
+ The other rejected option: stripping the `Admin` role in `Users_Roles` — the team will not strip
103
+ admin roles from real accounts.
104
+
105
+ **ACL cannot do this**, for two independent reasons. Row-level ACL does exist in 2.0
106
+ (`Core.AclRecordExpressions.sqlExpression`, wired to a role via `AclLogicGroups` /
107
+ `AclLogicGroupExpressions`), so the instinct is reasonable, but:
108
+ 1. `_assignedAdminFilterOptions()` runs a raw `new _Query(...)` straight against `DB_CLIENT` and
109
+ never passes through the `_Model` / V2 ACL layer, so no ACL row changes its result;
110
+ 2. even if it did, an `AclRecordExpression` filters **every** read of that record for that role —
111
+ hiding the person from user lists and lookups everywhere, far wider than one dropdown.
112
+
113
+ **General rule: a filter-options static that builds its own `_Query` is outside ACL by
114
+ construction.** Whatever it must exclude, it excludes in its own SQL.
92
115
 
93
116
  ### Why a model static and not a config column
94
117
 
@@ -119,6 +142,15 @@ generic version at roughly **4–5** such columns, driven by real examples.
119
142
  - **The dropdown is backend-driven — do not look in toga25-supply.** The front end only renders
120
143
  what `_assignedAdminFilterOptions()` returns. Any change to which admins appear goes in
121
144
  `_underscore/Model/Compass/SalesOrder.php`.
145
+ - **⚠ "My change had no effect" is usually stale cache or an undeployed branch, not this code.**
146
+ Work the ladder cheapest-first: clear the `supply-chain-query-cache` localStorage key (stale table
147
+ meta is served with **nothing in the network tab**), then read the meta request host in DevTools to
148
+ learn which api2 tier answered, then check branch/deploy state — see
149
+ [ENVIRONMENT drives the _underscore branch](../../api2/features/environment-variable-drives-underscore-branch.md).
150
+ The client-model class chain is **not** a likely cause: `_Model_Client_TableView::meta()` swaps
151
+ `_Model_Client_` → `_Model_<clientIdentifier>_` (`TableView.php` ~L266), and
152
+ `_Model_Compass_Usa_SalesOrder` is an empty subclass of `_Model_Compass_SalesOrder`, so the
153
+ override is inherited. Rule it out in one minute.
122
154
  - **Key on the FIELD name, not the column slug.** The slug is `assigned-admin` (hyphen); the model
123
155
  field is `_assignedAdmin`. The seam uses the field name.
124
156
  - **Type `STRING` (not STATUS/SELECT) is what lets the branch fire** — the existing `switch` in
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-18
10
10
  owners: ["bala", "mhammontree", "apeterson", "jcardinal", "tcox"]
11
11
  files:
12
12
  - api2/.ebextensions/git.php
@@ -90,6 +90,12 @@ So on `api.beta.togahub.com` and `api.dev.sandbox.togahub.com` the code fataled
90
90
 
91
91
  ## Gotchas
92
92
 
93
+ - **⚠ Merged locally ≠ live. A `_underscore` change reaches a tier only when the branch is PUSHED *and* api2 has been REDEPLOYED since.** The clone happens in the api2 prebuild hook, so an unpushed (or un-redeployed) branch leaves the tier running the old framework while your local checkout and `git log` both look correct. Seen 2026-09-18: a model change was committed and merged locally but never pushed; `_sandbox-client` served the old code.
94
+ - Diagnosis ladder, cheapest first, when "my `_underscore` change did nothing":
95
+ 1. Delete the `supply-chain-query-cache` localStorage key. toga25-supply persists the React Query cache, so stale table meta is served with **nothing in the network tab** — which reads as a backend fault. See [persisted query cache](../../toga25-supply/features/persisted-query-cache.md).
96
+ 2. Read the meta request **host** in DevTools to learn which tier actually answered (`api.client.sandbox.togahub.com` = the `_sandbox-client` tier).
97
+ 3. Only then suspect branch/deploy state — `git ls-remote origin <branch>` (is it pushed?) then the pipeline source action.
98
+
93
99
  - **⚠ Cross-repo change ordering: land `_underscore` BEFORE deploying api2.** Because api2's EB deploy **clones `_underscore` (branch `_<ENVIRONMENT>`, e.g. `_production`) at BUILD time** (`.platform/hooks/prebuild/git.sh`), a change spanning both repos must have its `_underscore` side merged to `_<ENVIRONMENT>` **first** — otherwise the freshly-built api2 calls a framework method/class not on the box and fatals. Landing `_underscore` first is **safe on its own**: the old api2 controller doesn't use the new framework code yet. Order: merge `_underscore` → (verify it's on the branch) → deploy api2.
94
100
  - **🚨 Worked instance — `client-sandbox` was 100% DOWN on this exact violation (2026-08-31, since fixed).** Every request returned 500 `Call to undefined method _Model_Core_AclFieldPermission::resolveFieldPermissions()`. `api2@_sandbox-client` (`Component/Api/V2/V2.php:6198`, commit `0cc37fe` "Delegate ACL field perms to shared resolver", jcardinal 2026-08-14) delegates to a resolver whose `_underscore` half (`a5e79448` "Surface field binding and ACL field resolver", same author/date) exists **only on `_qa-alpha` and `toga25-desk`** — never merged to **`_underscore@_sandbox-client`**. Resolved the same day (branches matched + redeployed). **Why a half-shipped ACL refactor is a TOTAL outage:** field-ACL resolution runs on essentially every authenticated GET, so there is no degraded mode — the whole tier 500s and every unrelated feature under test looks broken. When a whole sandbox goes dark, check the two-repo pairing before debugging your own feature.
95
101
  - **⚠ A "Could not find required file for `_Model_...`" fatal on prod can be a STALE-BUILD artifact, not a missing file.** `Logs.Issue #476` ("Could not find required file for '_Model_Client_ItemFulfillments_InventoryAdjustment'") fired because that model file **was** committed to `_production` but `api-production-1` was running an **older build**; a **redeploy** (re-pulls `_production`) resolved it. Before assuming a model file is absent, check the **running build's `_underscore` against `_production`** (file presence on the box; `git merge-base --is-ancestor`).
@@ -47,6 +47,7 @@
47
47
  | [QA/QC Branch Automation (auto-mirror + rebuild-from-ledger revert)](features/qa-qc-branch-automation.md) | worker2-centralized GitHub branch automation (mirror, ledger-revert, the single guarded door to `_production`); open when touching QA/QC branch merges or revert |
48
48
  | [QA/QC Review Pipeline (ClickUp review chain, QC batch + defect/client-review)](features/qa-qc-review-pipeline.md) | ClickUp-driven QA/QC review + promotion chain (Dev→QA→QC→Client Review→Stage→Prod) for worker2; open when touching a review gate, the QC/Stage crons, or the Fea |
49
49
  | [S3 folder retention cleanup worker action (CleanS3Folder)](features/s3-folder-retention-cleanup.md) | Cron-parameterized worker action that deletes S3 objects under one folder older than a retention window; open when adding/tuning an S3 retention sweep or its de |
50
+ | [The two sandbox-dev worker2 environments (and how `_underscore` reaches a worker box)](features/sandbox-dev-worker-environments.md) | Two Elastic Beanstalk environments both call themselves "sandbox-dev" worker, they run **different `ENVIRONMENT` values**, they clone **different `_underscore` |
50
51
  | [Service Request → Sales Order → Purchase Order generation (Sync/ServiceRequest)](features/service-request-sales-order-generation.md) | 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 |
51
52
  | [SSO Stability Monitor (Monitor/Operations/SsoStability)](features/sso-stability-monitor.md) | Reporter cron that GETs the saml `/healthcheck` and pushes an SSO heartbeat to a OneUptime monitor; open when working on the SSO uptime probe, its tokens, or it |
52
53
  | [Startech Webhook Handler (worker2)](features/startech-webhook-handler.md) | Inbound Startech (Easeedesk) webhook handler that creates/updates the matching TOGA 2.0 ticket; open for the webhook flow, its payload, or ticket-type/stage res |
@@ -0,0 +1,101 @@
1
+ ---
2
+ title: "The two sandbox-dev worker2 environments (and how `_underscore` reaches a worker box)"
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-18
10
+ owners: ["bala"]
11
+ files:
12
+ - worker2/.ebextensions/git.php
13
+ - worker2/.ebextensions/git.json
14
+ - worker2/.ebextensions/php_include_underscore.config
15
+ - worker2/Config/beta.ini
16
+ - worker2/Config/sandbox-dev.ini
17
+ - _underscore/Worker.php
18
+ related:
19
+ - ../architecture.md
20
+ - ./creating-worker-actions.md
21
+ - ../../api2/features/environment-variable-drives-underscore-branch.md
22
+ - ../../api2/workflows/codepipeline-codeconnections-deploy.md
23
+ ---
24
+
25
+ ## Summary
26
+
27
+ Two Elastic Beanstalk environments both call themselves "sandbox-dev" worker, they run **different
28
+ `ENVIRONMENT` values**, they clone **different `_underscore` branches**, and **only one of them is
29
+ deployed by CodePipeline** — yet both write the same database. Testing on one and assuming the other
30
+ behaves the same wastes a deploy cycle and hides stale code.
31
+
32
+ | EB environment | Tier | `ENVIRONMENT` | `_underscore` branch | Deployed by |
33
+ |---|---|---|---|---|
34
+ | `worker-invoke-sandbox-dev` | **Worker** (SQS) | `sandbox-dev` | `_sandbox-dev` | **by hand only** |
35
+ | `worker-sandbox-dev` | **WebServer** | `beta` | `_beta` | CodePipeline |
36
+
37
+ - `worker.beta.togahub.com` resolves to the same IPs as
38
+ `worker-sandbox-dev.us-east-1.elasticbeanstalk.com`, so an HTTP invoke tests the **WebServer** box.
39
+ - **The cron runs on `worker-invoke-sandbox-dev`.** CodePipeline never touches it, so it keeps
40
+ running old code until someone deploys it manually.
41
+ - `worker2/Config/beta.ini` and `Config/sandbox-dev.ini` both point at
42
+ `sandbox-dev.cluster-cb860gg2qw2n.us-east-1.rds.amazonaws.com`, so **finding your row in the
43
+ database proves nothing about which box wrote it.**
44
+
45
+ ## `_underscore` is NOT in the deployment zip — it is cloned at deploy time
46
+
47
+ `worker2/.ebextensions/git.php` clones `_underscore` on the instance from branch
48
+ **`'_' . strtolower(ENVIRONMENT)`**, into the path named in `.ebextensions/git.json`, and
49
+ `php_include_underscore.config` puts that path on `include_path`. This is the same mechanism api2
50
+ uses — see
51
+ [ENVIRONMENT drives the `_underscore` branch](../../api2/features/environment-variable-drives-underscore-branch.md)
52
+ for the full rules, the `branch`-pin trap and the committed-PAT warning. Do not restate them here.
53
+
54
+ **The practical consequence for worker2:**
55
+
56
+ - An **`_underscore`-only** change needs only a **redeploy of the same worker2 application
57
+ version** — the clone re-runs and picks up the branch tip.
58
+ - A **worker2** change needs a **fresh pipeline build**; redeploying the existing version ships the
59
+ old PHP.
60
+
61
+ Getting that backwards costs a full deploy cycle for a change that was never going to appear.
62
+
63
+ ## `_Worker::runTask` does not always use SQS — check `debug_mode` on the PRODUCER
64
+
65
+ `_underscore/Worker.php` branches on `_Environment::isDebugMode()`: true → a synchronous POST to
66
+ `_Config::api('worker')`; false → an SQS send using `_Config::cloud('aws_worker_queue_url')`. The
67
+ general rule and its read-after-write hazard live in
68
+ [creating worker actions](./creating-worker-actions.md).
69
+
70
+ The concrete config that surprises people:
71
+
72
+ - **`api2/Config/sandbox-dev.ini` sets `debug_mode = 1` and defines no `aws_worker_queue_url` at
73
+ all.** So a job queued from api2 on that environment travels over **HTTP to
74
+ `worker.beta.togahub.com`** — the WebServer box — and never enters SQS.
75
+ - **`api2/Config/production.ini` sets `debug_mode = 0`** and does define the queue keys, so
76
+ production really is SQS.
77
+
78
+ Before you go looking for a missing SQS message on a sandbox, confirm the producer's `debug_mode`.
79
+
80
+ ## Gotchas / known issues
81
+
82
+ - **Staleness is per-instance.** Take the failing job's `Core.WorkerJobs.instanceId` and verify
83
+ *that* box received the deploy. "I deployed beta" is not a verification — see the
84
+ [CodePipeline deploy workflow](../../api2/workflows/codepipeline-codeconnections-deploy.md).
85
+ - **Never infer the environment from the EB environment name.** `worker-sandbox-dev` is
86
+ `ENVIRONMENT=beta`; the name and the value disagree on purpose-built tiers more often than not.
87
+ - **The clone deletes `.git`**, so the instance carries no commit id. Verify by file presence.
88
+
89
+ ## Change history
90
+
91
+ - 2026-09-18 — Initial capture, from the NYCHH transfer-order-email work. Recorded that
92
+ `worker-invoke-sandbox-dev` (Worker tier, `ENVIRONMENT=sandbox-dev`, branch `_sandbox-dev`) runs
93
+ the crons and is **deployed by hand only**, while `worker-sandbox-dev` (WebServer tier,
94
+ `ENVIRONMENT=beta`, branch `_beta`) is the CodePipeline target behind
95
+ `worker.beta.togahub.com` — and that both `Config/beta.ini` and `Config/sandbox-dev.ini` point at
96
+ the same `sandbox-dev` cluster, so the data gives no clue which box ran. Recorded that
97
+ `_underscore` is cloned by `.ebextensions/git.php` at deploy time rather than zipped, so a
98
+ framework-only change needs a redeploy of the same version while a worker2 change needs a fresh
99
+ build. Recorded that `api2/Config/sandbox-dev.ini` sets `debug_mode = 1` with **no**
100
+ `aws_worker_queue_url`, so `_Worker::runTask` there POSTs synchronously to
101
+ `worker.beta.togahub.com` instead of using SQS. (bala)
@@ -19,8 +19,8 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
19
19
 
20
20
  ## 2.0 framework
21
21
 
22
- - **_underscore** (_Underscore) _(framework core)_ — 88 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
23
- - **worker2** (Worker) — 69 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
+ - **_underscore** (_Underscore) _(framework core)_ — 87 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
23
+ - **worker2** (Worker) — 71 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)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
26
26
  - **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
@@ -1,18 +1,17 @@
1
1
  ---
2
2
  title: "NYCHH transfer-order notification emails (Submitted / Packed / Shipped)"
3
3
  framework: "2.0"
4
- repo: _underscore
5
- project: _Underscore
4
+ repo: worker2
5
+ project: Worker
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-17
9
+ updated: 2026-09-18
10
10
  owners: ["bala"]
11
11
  files:
12
- - _underscore/Trait/Nychh/TransferOrderEmail.php
12
+ - worker2/Worker/Client/Nychh/TransferOrderEmails.php
13
13
  - _underscore/Model/Nychh/TransferOrder.php
14
14
  - _underscore/Model/Nychh/ItemFulfillment.php
15
- - worker2/Worker/Client/Nychh/TransferOrderEmails.php
16
15
  - dbchanges2/Client_Nychh/2026-09-15a - TransferOrderEmails.sql
17
16
  - dbchanges2/Client_Nychh/2026-09-15b - TransferOrderEmailGoLiveRecipient.sql
18
17
  - dbchanges2/Core/2026-09-16a - NychhTransferOrderEmailsCron.sql
@@ -22,8 +21,10 @@ related:
22
21
  - ../../../2.0/apps/_underscore/features/email-template-sending.md
23
22
  - ../../../2.0/apps/_underscore/features/email-send-pipeline.md
24
23
  - ../../../2.0/apps/_underscore/features/autoincrement-record-numbering.md
24
+ - ../../../2.0/apps/_underscore/features/database-alias-repointing.md
25
25
  - ../../../2.0/apps/api2/features/api-payload-interceptors.md
26
26
  - ../../../2.0/apps/worker2/features/creating-worker-actions.md
27
+ - ../../../2.0/apps/worker2/features/sandbox-dev-worker-environments.md
27
28
  - ../profile.md
28
29
  ---
29
30
 
@@ -34,35 +35,44 @@ NYC Health + Hospitals (H+H) gets three notification emails for a transfer order
34
35
  All three send from `HealthcareteamNYCHHC@togatech.com` through the normal
35
36
  [client email-template](../../../2.0/apps/_underscore/features/email-template-sending.md) path.
36
37
 
37
- Two things carry most of the risk and are the reason this doc exists:
38
+ **The whole feature is ONE worker2 file.** `worker2/Worker/Client/Nychh/TransferOrderEmails.php`
39
+ holds the two queued entry points, the backstop cron, every query and the HTML builder. The
40
+ `_underscore` per-client models only declare the `c_` marker fields and queue the job — they build
41
+ nothing. That is deliberate: app-level over core, and a slow send must not sit inside an api2 save.
42
+
43
+ Three rules carry most of the risk:
38
44
 
39
45
  1. **Submitted fires on `postPut`, not `postPost`** — on the POST the order still holds the
40
46
  `TA…` autoincrement placeholder, not the NetSuite number.
41
47
  2. **Only orders raised in TOGa Supply may be announced.** `TransferOrders.createdByUserId` is the
42
48
  discriminator; without it H+H would be mailed about every order the NetSuite sync ever imported.
49
+ 3. **A failed send throws — it is never swallowed**, so nothing is ever marked sent that did not go.
43
50
 
44
51
  Recipient is `DevTeam@togatech.com` while testing. `2026-09-15b` swaps it to
45
52
  `EndUserDeviceLifecycle@nychhc.org` — run that file only after sign-off.
46
53
 
47
54
  ## Key files / entry points
48
55
 
49
- - **`_underscore/Trait/Nychh/TransferOrderEmail.php`** — all the logic: guards, the SQL reads, the
50
- HTML builders, the escaping, the size budget, and the send. Both models and the worker2 cron call
51
- into it, so there is exactly one implementation.
52
- - `isTransferOrderReadyToAnnounce()` / `readTransferOrderForEmail()` — eligibility + data read.
53
- - `sendTransferOrderEmail(): bool` — sends and **returns whether it really sent**.
54
- - `buildTrackingSectionHtml()`, `buildAssetTagTableHtml()`, `escapeForEmailHtml()`,
55
- `maximumEmailBodyBytes()`.
56
- - **`_underscore/Model/Nychh/TransferOrder.php`** — `postPut` hook → Submitted; declares
57
- `c_dtTransferOrderSubmittedEmailSent`.
58
- - **`_underscore/Model/Nychh/ItemFulfillment.php`** — `postPost` / `postPut` → Packed / Shipped;
59
- declares `c_transferOrderEmailStageSent` + `c_dtTransferOrderEmailSent`.
60
- - **`worker2/Worker/Client/Nychh/TransferOrderEmails.php`** — action
61
- `Client/Nychh/TransferOrderEmails/SendPending`, the backstop cron (`*/5`).
56
+ - **`worker2/Worker/Client/Nychh/TransferOrderEmails.php`** — the entire feature.
57
+ - `SendSubmitted(string $transferOrderUuid)` — queued from the TransferOrder `postPut`.
58
+ - `SendFulfillment(string $itemFulfillmentUuid)` — queued from the ItemFulfillment hooks.
59
+ - `SendPending(bool $dryRun = true)` — the `*/5` backstop cron.
60
+ - `initialize()` registers the client DB before any query runs.
61
+ - Builders: `buildItemRowsHtml()`, `buildTrackingSectionHtml()`, `buildAssetTagTableHtml()`,
62
+ `escapeForEmailHtml()`; budget constant `MAXIMUM_EMAIL_BODY_BYTES`.
63
+ - **`_underscore/Model/Nychh/TransferOrder.php`** — declares
64
+ `c_dtTransferOrderSubmittedEmailSent`; `postPut` calls `_Worker::runTask` with action
65
+ `Client/Nychh/TransferOrderEmails/SendSubmitted` and `{"transferOrderUuid": "…"}`.
66
+ - **`_underscore/Model/Nychh/ItemFulfillment.php`** — declares `c_transferOrderEmailStageSent` +
67
+ `c_dtTransferOrderEmailSent`; `postPost` / `postPut` call action
68
+ `Client/Nychh/TransferOrderEmails/SendFulfillment` with `{"itemFulfillmentUuid": "…"}`.
69
+ - **Template uuids** (rows in `Client_Nychh.EmailTemplates`, seeded by `2026-09-15a`):
70
+ - submitted `0f4a6c21-9d3e-4b87-a15c-6e2b8f70d914`
71
+ - packed `7b1d9e54-3c62-48af-b0d7-25f4a8c31e6b`
72
+ - shipped `c58f3a07-6b94-4d21-8e3f-914ad7b06c52`
62
73
  - **`nychh_email_templates/*.html`** — ⚠ the HTML source of the three templates. **These files are
63
74
  in no git repo** — they live only in the working folder. The dbchanges2 seed SQL is generated
64
- *from* them, so right now the `EmailTemplates` rows in `2026-09-15a` are the only durable copy.
65
- Get them into a repo before anyone else has to edit a template.
75
+ *from* them, so the `EmailTemplates` rows in `2026-09-15a` are the only durable copy.
66
76
 
67
77
  ## ⚠ Submitted must fire on `postPut` — the POST number is a placeholder
68
78
 
@@ -93,20 +103,25 @@ Measured in production: **all 1,936 sync orders have `createdByUserId` NULL and
93
103
  have it set.** That test is the only thing standing between H+H and a mailshot covering every
94
104
  historic order the importer has pulled in.
95
105
 
96
- The test appears in **four** places and they must stay in step: the trait's
97
- `isTransferOrderReadyToAnnounce()` and `readTransferOrderForEmail()`, and both worker2 cron queries.
106
+ `TransferOrders.createdByUserId IS NOT NULL` appears in **four** queries in the worker file and
107
+ they must stay in step: the Submitted guard, the order read, and both `SendPending` sweeps.
98
108
 
99
109
  ## How it works
100
110
 
101
- 1. **Submitted** — `_Model_Nychh_TransferOrder::postPut` calls the trait. If the order is
102
- supply-raised and `c_dtTransferOrderSubmittedEmailSent` is empty, it builds the body, sends, then
103
- stamps the marker.
104
- 2. **Packed / Shipped** — `_Model_Nychh_ItemFulfillment::postPost` / `postPut` call the trait. The
105
- stage reached is recorded on `c_transferOrderEmailStageSent`, with `c_dtTransferOrderEmailSent`
106
- as the timestamp, so a fulfillment cannot re-announce the same stage.
111
+ 1. **Submitted** — `_Model_Nychh_TransferOrder::postPut` queues `SendSubmitted` with the order
112
+ uuid. The worker re-checks the supply-order test and the empty
113
+ `c_dtTransferOrderSubmittedEmailSent` marker, sends, then stamps the marker.
114
+ 2. **Packed / Shipped** — the ItemFulfillment `postPost` / `postPut` queue `SendFulfillment` with
115
+ the fulfillment uuid. The stage reached is recorded on `c_transferOrderEmailStageSent`, with
116
+ `c_dtTransferOrderEmailSent` as the timestamp, so a fulfillment cannot re-announce a stage.
107
117
  3. The **backstop cron** sweeps anything the hooks missed (below).
108
- 4. The trait renders the stored `EmailTemplates` body, fills `{orderItems}` and `{trackingSection}`
109
- itself, and sends through `_Model_Client_EmailTemplate`.
118
+ 4. All three paths share the same markers, so nothing sends twice.
119
+ 5. The worker renders the stored `EmailTemplates` body, fills `{orderItems}` and
120
+ `{trackingSection}` itself, and sends through `_Model_Client_EmailTemplate`.
121
+
122
+ Queuing rather than sending inline also means the api2 save returns immediately. See
123
+ [creating worker actions](../../../2.0/apps/worker2/features/creating-worker-actions.md) for how the
124
+ string-keyed parameters arrive as PHP named arguments and why `initialize()` runs first.
110
125
 
111
126
  ### The backstop cron — and why it is not optional
112
127
 
@@ -119,8 +134,8 @@ PUT follows.** Fulfillment **2704** in production: header row at 20:33:09, lines
119
134
 
120
135
  - Its fulfillment query requires **`EXISTS` on `ItemFulfillmentItems`**, so a header row written
121
136
  seconds ago with no lines yet is left for the next run instead of being mailed empty.
122
- - It also picks up any send that failed.
123
- - Caps at **25 of each kind per run**.
137
+ - It also picks up any send that threw (the marker was never written).
138
+ - Caps at **25 of each kind per run** (`MAXIMUM_EMAILS_PER_RUN`).
124
139
 
125
140
  ## Schema, registration and deploy order
126
141
 
@@ -138,22 +153,26 @@ A custom field needs **all three** pieces, matching how `Model/Client/TrackingNu
138
153
  the column, the `CustomRecordFields` row, the `AclCustomFieldPermissions` grant for **Base and
139
154
  API** — plus a public property on the per-client model (`FIELD_DATETIME` / `FIELD_CHAR`).
140
155
 
141
- - **⚠ Run `2026-09-15a` BEFORE the `_underscore` deploy.** The models now declare the three
156
+ - **⚠ Run `2026-09-15a` BEFORE the `_underscore` deploy.** The models declare the three
142
157
  properties, so if the columns are missing every NYCHH transfer-order read breaks.
158
+ - **⚠ The two repos reach a box by different routes.** An `_underscore`-only change needs only a
159
+ **redeploy of the same worker2 application version**; a change to `TransferOrderEmails.php` needs
160
+ a **fresh pipeline build**. See
161
+ [worker2 sandbox-dev environments](../../../2.0/apps/worker2/features/sandbox-dev-worker-environments.md).
143
162
  - **⚠ Cloning prod `Client_Nychh` into a sandbox WIPES this feature.** Prod does not have any of it
144
163
  yet, so a clone removes the three marker columns, the three `EmailTemplates` rows, the recipient
145
164
  rows, the custom-field rows and the PUT interceptor. **Re-run `2026-09-15a` after any clone.**
146
165
 
147
- ## The item block lives in the trait, not in the `EmailTemplates` row
166
+ ## The item block lives in the worker, not in the `EmailTemplates` row
148
167
 
149
168
  `_Model_Client_EmailTemplate::replaceTemplateVariables()` is **one `str_replace` per key, with no
150
169
  loop, and it skips non-scalar values outright**. A list whose length varies per order therefore
151
- cannot live in the stored template. `{orderItems}` is a placeholder the trait fills with built HTML
152
- — the same split Compass already uses for its order items.
170
+ cannot live in the stored template. `{orderItems}` is a placeholder the worker fills with built
171
+ HTML.
153
172
 
154
173
  Cost of the split, stated so nobody is surprised later:
155
174
 
156
- - a change to the **row markup** needs an `_underscore` deploy;
175
+ - a change to the **row markup** needs a worker2 deploy;
157
176
  - a change to the **chrome** only needs the SQL re-run;
158
177
  - the SQL is **generated from the `nychh_email_templates/*.html` sources**, so it must be
159
178
  regenerated after every template edit.
@@ -163,25 +182,34 @@ Cost of the split, stated so nobody is surprised later:
163
182
  - **Escape every scalar.** `replaceTemplateVariables()` does no escaping and the Send worker forces
164
183
  HTML mode, so a site name, contact name, address or PO number containing `<` landed as live
165
184
  markup. All **seven** scalar values go through `escapeForEmailHtml()`. `orderItems` and
166
- `trackingSection` stay raw — they are HTML the trait built and already escaped.
167
- - **Tracking is built per order, not templated.** In production **1,484 of 2,479** shipped
168
- transfer-order fulfillments carry **no tracking number at all**, and **322** of the rest have no
169
- carrier on the number. As three flat placeholders those cases rendered `Tracking:` beside an empty
170
- `img src` and a *Track Shipment* button pointing nowhere. `buildTrackingSectionHtml()` now drops
171
- the whole block when there is no number, and drops the button when there is no URL. Placeholders
172
- changed from `{trackingNumber}` `{trackingUrl}` `{trackingCarrierLogo}` to
185
+ `trackingSection` stay raw — they are HTML the worker built and already escaped.
186
+ - **Tracking is per PACKAGE, not per fulfillment.** `readTrackingForEmail()` returns **every** row;
187
+ the section prints one *Tracking* line per package and the *Track Shipment* button uses the first
188
+ link. It had `LIMIT 1` and returned a single row, so a multi-package shipment showed the customer
189
+ only one number. Measured 2026-09-18: of **1,495** shipped fulfillments carrying tracking,
190
+ **1,203** have one number and **292** have two or more, **up to 25**.
191
+ - **Many shipped fulfillments have no tracking at all** — 1,484 of 2,479 measured 2026-09-17, and
192
+ 322 of the rest have no carrier on the number. `buildTrackingSectionHtml()` drops the whole block
193
+ when there is no number and drops the button when there is no URL. Placeholders are
173
194
  **`{trackingIntroSuffix}`** and **`{trackingSection}`**.
174
- - **Gmail clips a message past about 102 KB**, and one fulfillment line can carry hundreds of units
175
- (fulfillment **2287** in prod has 300). `maximumEmailBodyBytes()` sets a **92,000-byte** budget,
176
- measured against the items HTML as it is built; the remainder is summarised as *"and N more"*
177
- rather than listed. Declaring the type once on the serial table and letting cells inherit cut a
178
- row from about 340 bytes to about 140. Measured worst case: **94,864 bytes with 502 units
179
- listed**, under the 102,400 limit.
195
+ - **Gmail clips a message past about 102,400 bytes.** `MAXIMUM_EMAIL_BODY_BYTES = 80000` budgets the
196
+ **item block only**; the remainder is summarised as *"and N more"*. 80,000 (not the earlier
197
+ 92,000) because the stored shipped template is **11,695 bytes** on its own and a 25-package
198
+ tracking block adds about **9,500** more. Declaring the type once on the serial table and letting
199
+ cells inherit cut a row from about 340 bytes to about 140.
180
200
  - **Item titles need a fallback chain and a trim.** NYCHH `Items` rows often have an empty `title`,
181
201
  so the read uses
182
202
  `COALESCE(NULLIF(Items.title,''), NULLIF(Items.description,''), Items.partNumber)`. Titles and
183
203
  part numbers also carry trailing whitespace (item `210-BPPS` is stored with three trailing tabs),
184
204
  so both are `trim()`ed before rendering.
205
+ - **Item image fallback is the NYC H+H mark** (`FALLBACK_ITEM_IMAGE_URL`, the H+H logo SVG on
206
+ `toga-public`), not the Compass no-product-image placeholder it was inheriting.
207
+ - **The image cell is `width="50"`, matching the 50px image.** It was `width="70"` with a 20px right
208
+ padding, so the gap rendered roughly double the design — the padding is meant to be the whole gap.
209
+ - **Fonts: `'Plus Jakarta Sans'` everywhere EXCEPT the item block.** Only the item title, P/N, Qty
210
+ and the asset-tag table use **Inter** (`ITEM_FONT_STACK`). The message paragraphs in all three
211
+ templates and the tracking line are Plus Jakarta Sans. Outlook gets Arial inside an
212
+ `<!--[if mso]-->` block because it cannot load web fonts.
185
213
  - **Detail box:** *Site Contact* falls back to the person who raised the order — 3 of the 6 supply
186
214
  orders in prod have no contact, and that is the same fallback the greeting already used. And
187
215
  `CONCAT_WS` skips NULL but **not** an empty string, so a blank address line 2 produced
@@ -189,17 +217,20 @@ Cost of the split, stated so nobody is surprised later:
189
217
 
190
218
  ## Gotchas / known issues
191
219
 
192
- - **⚠ `_Database::register()` takes the REAL database name first and the ALIAS last.** The cron
193
- originally passed `_underscore::DB_CLIENT` (`'Client'`) as `$database` and `'Client_Nychh'` as
194
- `$alias`, which registers a database literally called **`Client`** and re-points the
195
- `Client_Nychh` alias at it — breaking the parent registration too. Correct form:
196
- `database: self::DB_CLIENT_NYCHH, alias: _underscore::DB_CLIENT`. The trait reads through
220
+ - **⚠ There is NO `try`/`catch` anywhere in this feature, by design.**
221
+ `sendTransferOrderEmail()` returns `void` and **throws `_Exception`** when
222
+ `_Model_Client_EmailTemplate::send()` returns `false` (which happens only when the template row is
223
+ inactive). Both callers write the sent marker on the line *after* the send, so a throw means
224
+ nothing is marked sent and the cron retries. Do not "harden" this with a catch: a swallowed
225
+ failure loses the email permanently **and** hides the row from the backstop cron. (This reverses
226
+ the earlier bool-returning design — the throw is now the contract.)
227
+ - **⚠ `_Database::register()` takes the REAL database name first and the ALIAS last.**
228
+ `initialize()` originally passed `_underscore::DB_CLIENT` (`'Client'`) as `$database` and
229
+ `'Client_Nychh'` as `$alias`, which registers a database literally called **`Client`** and
230
+ re-points the `Client_Nychh` alias at it. Correct form:
231
+ `database: self::DB_CLIENT_NYCHH, alias: _underscore::DB_CLIENT` — the queries read through
197
232
  `_underscore::DB_CLIENT` because that is the alias every `_Model_Client_*` uses. See
198
233
  [DB alias re-pointing](../../../2.0/apps/_underscore/features/database-alias-repointing.md).
199
- - **A failed send must not be marked as sent.** `sendTransferOrderEmail()` returned `void` and the
200
- caller wrote the marker unconditionally, so a single mail failure lost that email permanently
201
- **and** hid the row from the backstop cron. It returns `bool` now, and the marker only lands on a
202
- real send.
203
234
  - **`_Email::send()` only queues.** The finished email HTML can be read straight out of
204
235
  `Logs_Nychh.Email` without an inbox — that is how these were verified. See
205
236
  [the email send pipeline](../../../2.0/apps/_underscore/features/email-send-pipeline.md).
@@ -207,32 +238,36 @@ Cost of the split, stated so nobody is surprised later:
207
238
 
208
239
  ## Testing
209
240
 
210
- A local end-to-end harness (scratchpad, **not committed**) runs **82 scenarios, all passing**. The
211
- approach is worth reusing for any email feature:
212
-
213
- - It loads the **real** trait and the **real** worker2 cron class, not copies.
214
- - It runs against a throwaway local schema built with `CREATE TABLE LIKE` from `Client_Nychh`.
215
- - It stubs **only** `_Query` / `_Database` / `_Model_Client_EmailTemplate`.
216
- - It renders the **real** template bodies, loaded out of the dbchanges2 file, so an unresolved
217
- `{placeholder}` fails a test.
218
- - Coverage: the send guards, the detail box, the item and serial tables, the tracking block, mail
219
- failure, and the cron.
220
-
221
- The reversed `_Database::register()` arguments, the lost-on-failure marker, the tracking block, the
222
- unescaped values and both detail-box defects were all found here, not in production.
241
+ A local end-to-end harness (scratchpad, **not committed**) runs the **real** worker class against a
242
+ throwaway local schema built with `CREATE TABLE LIKE` from `Client_Nychh`, stubbing **only**
243
+ `_Query` / `_Database` / `_Model_Client_EmailTemplate`, and rendering the **real** template bodies
244
+ loaded out of the dbchanges2 file so an unresolved `{placeholder}` fails a test. The reversed
245
+ `_Database::register()` arguments, the marker-written-on-failure bug, the tracking block, the
246
+ unescaped values and both detail-box defects were all found there, not in production.
223
247
 
224
248
  ## Change history
225
249
 
250
+ - 2026-09-18 — **Moved the whole feature out of `_underscore` into worker2.** Deleted
251
+ `_Trait_Nychh_TransferOrderEmail`; `worker2/Worker/Client/Nychh/TransferOrderEmails.php` now holds
252
+ the queued `SendSubmitted` / `SendFulfillment` entry points, the `SendPending` cron, every query
253
+ and the HTML builder, and the two per-client models only declare their `c_` fields and call
254
+ `_Worker::runTask` with the record uuid. Rationale: app-level over core, and a slow send must not
255
+ run inside an api2 save. Fixed the shipped email listing only the **first** tracking number
256
+ (`readTrackingForEmail` had `LIMIT 1`; 292 of 1,495 shipped fulfillments with tracking carry two or
257
+ more, up to 25). Lowered `MAXIMUM_EMAIL_BODY_BYTES` 92,000 → **80,000** so the 11,695-byte shipped
258
+ template and a ~9,500-byte 25-package tracking block still fit under Gmail's ~102,400 clip.
259
+ Replaced the inherited Compass no-product-image placeholder with the NYC H+H mark, and narrowed the
260
+ image cell `width="70"` → `width="50"`. Recorded the typeface rule (Inter for the item block only,
261
+ Plus Jakarta Sans everywhere else, Arial for Outlook) and the three `EmailTemplates` uuids.
262
+ **Reversed the earlier bool-return decision:** `sendTransferOrderEmail()` returns `void` and
263
+ **throws**, and there is deliberately no `try`/`catch` in the feature. (bala)
226
264
  - 2026-09-17 — Initial capture. Built the three H+H transfer-order emails (Submitted / Packed /
227
- Shipped) as `_Trait_Nychh_TransferOrderEmail` plus model hooks, the `*/5` backstop cron
228
- `Client/Nychh/TransferOrderEmails/SendPending`, and the `2026-09-15a` / `2026-09-15b` /
265
+ Shipped) plus model hooks, the `*/5` backstop cron, and the `2026-09-15a` / `2026-09-15b` /
229
266
  `2026-09-16a` migrations. Recorded the two load-bearing rules — **Submitted fires on `postPut`**
230
267
  because the POST still carries the `TA` placeholder (transfer order 1948: POST 10:05:34
231
268
  `TA100000`, PUT 10:05:46 `290007`), and **`createdByUserId` separates supply orders from sync
232
269
  orders** (1,936 sync rows NULL vs 6 supply rows set). Fixed a reversed `_Database::register()`
233
- database/alias pair, a failed send still being marked sent, an unescaped HTML body, a broken
234
- tracking block (1,484 of 2,479 fulfillments have no tracking number), a blank Site Contact and a
235
- `CONCAT_WS` empty-string address gap. Added a 92,000-byte body budget for Gmail's ~102 KB clip
236
- (worst case measured 94,864 bytes / 502 units). Flagged that a `Client_Nychh` clone wipes the
237
- feature and that `nychh_email_templates/*.html` lives in no git repo. Verified by an 82-scenario
238
- local harness running the real trait and cron. (bala)
270
+ database/alias pair, an unescaped HTML body, a broken tracking block (1,484 of 2,479 fulfillments
271
+ have no tracking number), a blank Site Contact and a `CONCAT_WS` empty-string address gap. Flagged
272
+ that a `Client_Nychh` clone wipes the feature and that `nychh_email_templates/*.html` lives in no
273
+ git repo. (bala)
@@ -9,7 +9,7 @@ project: Worker
9
9
  client: staples
10
10
  type: profile
11
11
  status: active
12
- updated: 2026-09-16
12
+ updated: 2026-09-18
13
13
  owners: [jcardinal, bala, mhammontree]
14
14
  files:
15
15
  - worker/crons/sync/staples/sync_staples_cxml.php
@@ -24,13 +24,13 @@ Staples — headless Worker-1.0 integration client (two SFTP crons); open for it
24
24
 
25
25
  ## Summary
26
26
 
27
- Staples is a **headless** integration client — no UI, Worker 1.0 only, depending on the `library` (1.0 `App_`) core. Its entire footprint is two crons under `worker/crons/sync/staples/`:
27
+ Staples is a **headless** integration client — no UI, Worker 1.0 only, depending on the `library` (1.0 `App_`) core. Its entire footprint is two crons under `worker/crons/sync/staples/`, both scheduled in `schedules/cron.worker.sync.json`:
28
28
 
29
- 1. **`sync_staples_cxml.php`** — hourly inbound: pulls cXML purchase orders off SFTP, creates NetSuite Sales Orders, and writes 855 acknowledgements back. The documented feature — see **`1.0/apps/worker/features/staples-cxml-order-import.md`**.
30
- 2. **`staples_asn_netsuite.php`** — outbound ASN (Advance Ship Notice) from NetSuite back to Staples. (Not yet documented in detail.)
29
+ 1. **`sync_staples_cxml.php`** — `45 * * * *`, "Create Sales Orders in Netsuite From Staples". Inbound: pulls cXML purchase orders off SFTP, creates NetSuite Sales Orders, and writes 855 acknowledgements back. The documented feature — see **`1.0/apps/worker/features/staples-cxml-order-import.md`**.
30
+ 2. **`staples_asn_netsuite.php`** — `50 * * * *`, "Create ASN from net suite to staples". Outbound ASN (Advance Ship Notice) from NetSuite back to Staples. (Not yet documented in detail.)
31
31
 
32
32
  **NetSuite identity:** Staples is the NetSuite parent entity/customer **internalId 30057**. Individual Staples buying accounts are NetSuite **child customers** matched by their Staples `CustomerID` against **saved search 8412** (match key `custentity_external_customer_id`; location from `custentity_customer_location`).
33
33
 
34
- **Onboarding rule (load-bearing):** any new Staples buying account must be set up in NetSuite — as a child customer of 30057, with its **External Customer ID** = the Staples `CustomerID` **and** a valid **Customer Location** — *before* its first order arrives. Miss either and inbound orders for that account silently jam on SFTP with no error (see the feature doc's gotchas).
34
+ **Onboarding rule (load-bearing):** any new Staples buying account must be set up in NetSuite — as a child customer of 30057, with its **External Customer ID** = the Staples `CustomerID` **and** exactly one **Customer Location** (a child of Location 36 "New York : New York Warehouse : Staples") — *before* its first order arrives. Miss either and inbound orders for that account silently jam on SFTP `/in`, retrying hourly with nothing logged. Full setup rule and the diagnostic steps are in the [cXML import feature doc](../../1.0/apps/worker/features/staples-cxml-order-import.md).
35
35
 
36
36
  **Sales rep on Staples sales orders:** the SO `entity` is always parent **30057**, so NetSuite sources **30057's** rep (currently Paulina Szeliga, 50695) regardless of which child account bought — see [NetSuite Sales Order Sales Rep Sourcing](../../1.0/apps/worker/features/netsuite-sales-order-sales-rep-sourcing.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.842",
3
+ "version": "1.0.844",
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",