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.
- package/knowledge/1.0/apps/worker/features/staples-cxml-order-import.md +45 -67
- package/knowledge/2.0/apps/_underscore/features/calculated-column-filter-options.md +44 -12
- package/knowledge/2.0/apps/api2/features/environment-variable-drives-underscore-branch.md +7 -1
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -0
- package/knowledge/2.0/apps/worker2/features/sandbox-dev-worker-environments.md +101 -0
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/nychh/features/transfer-order-notification-emails.md +116 -81
- package/knowledge/clients/staples/profile.md +5 -5
- package/package.json +1 -1
|
@@ -6,8 +6,8 @@ project: Worker
|
|
|
6
6
|
client: staples
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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**
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
**
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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-
|
|
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)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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)_ —
|
|
23
|
-
- **worker2** (Worker) —
|
|
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:
|
|
5
|
-
project:
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
6
|
client: nychh
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-18
|
|
10
10
|
owners: ["bala"]
|
|
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
|
-
- 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
|
-
|
|
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
|
-
- **`
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
- `
|
|
53
|
-
- `
|
|
54
|
-
- `
|
|
55
|
-
`
|
|
56
|
-
- **`_underscore/Model/Nychh/TransferOrder.php`** —
|
|
57
|
-
`c_dtTransferOrderSubmittedEmailSent
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
`Client/Nychh/TransferOrderEmails/
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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`
|
|
102
|
-
|
|
103
|
-
stamps the marker.
|
|
104
|
-
2. **Packed / Shipped** — `
|
|
105
|
-
stage reached is recorded on `c_transferOrderEmailStageSent`, with
|
|
106
|
-
as the timestamp, so a fulfillment cannot re-announce
|
|
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.
|
|
109
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
152
|
-
|
|
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
|
|
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
|
|
167
|
-
- **Tracking is
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
row from about 340 bytes to about 140.
|
|
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
|
-
- **⚠
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 **
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
-
|
|
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)
|
|
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,
|
|
234
|
-
|
|
235
|
-
`
|
|
236
|
-
|
|
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-
|
|
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`** —
|
|
30
|
-
2. **`staples_asn_netsuite.php`** —
|
|
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**
|
|
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