toga-ai 1.0.944 → 1.0.946

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-05
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - library/app/email.php
@@ -88,7 +88,8 @@ Call `App_Framework_Worker::initialize()` (or the app's equivalent) for config/r
88
88
  2. Read your sample data with a **single SELECT** — no models that `save()`, no api2 calls.
89
89
  3. Build the mail exactly as the real code does (same template class, same subject, same S3 fetches) so you test the real body, not a paraphrase.
90
90
  4. Set the three switches above.
91
- 5. Print, before sending, which attachments resolved and their byte sizes — a missing PDF is otherwise invisible (see `email-queue-attachments.md`: PHPMailer 5 sends anyway).
91
+ 5. To read **prod** data from a local run: call `App_Registry::setOkToPerformOnReadCluster(true)` and force the prod DB links by name (e.g. `db_prod_<client>`, `db_prod_logs_<client>`), because client crons pick `db_dev_*` links under `inDevMode`. Stop before any `UPDATE` the real cron does after sending.
92
+ 6. Print, before sending, which attachments resolved and their byte sizes — a missing PDF is otherwise invisible (see `email-queue-attachments.md`: PHPMailer 5 sends anyway).
92
93
 
93
94
  Run it with the framework repos on the include path:
94
95
  ```
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-05
10
10
  owners: ["bala", "tcox", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Model/Client/ServiceRequest.php
@@ -21,6 +21,7 @@ related:
21
21
  - ./tracking-number-bridges.md
22
22
  - ../../../clients/elite/features/supply2-tableview-config-drift.md
23
23
  - ../../../clients/prudential/features/supply-orders-csv-export.md
24
+ - ../../../clients/nychh/features/transfer-order-notification-emails.md
24
25
  ---
25
26
 
26
27
  How unit (serialized inventory) data links to sales-order/purchase-order line items behind the `units-for-items-for-purchase-orders` view; open when units don't show, a unit-to-order path is needed, or a view here 500s.
@@ -131,7 +132,7 @@ Header-level vs item-level bridges, and the level each transaction is anchored a
131
132
  - **The view only walks the receipt path.** A line whose units exist solely via the fulfillment path (shipped from stock, never received against the bridged PO line) shows **0 units** even though units physically exist. No data backfill can surface these — only a resolver/view change that also walks the fulfillment path will.
132
133
  - **Missing item bridge → no units**, even when units were received. Without a `SalesOrderItems_PurchaseOrderItems` row, the receipt path has no starting link.
133
134
  - **A present bridge is not sufficient.** If the bridged PO line has no `ItemReceiptItemUnits`, the receipt path still resolves to nothing. The **fulfillment-chain** analog (NYCHH rebuild): a PO line with a bridge **and** a fulfillment but **zero `ItemFulfillmentItemUnits`** (no serialized units captured) correctly shows an **empty grid** — that is not a bug, just a line with no serials recorded.
134
- - **`inventoryType` determines unit existence**: `SERIALIZED` and `HYBRID` items can produce unit records; `NON_SERIALIZED` (bulk/consumable) never do. `HYBRID` items fulfilled as a plain quantity (no serial capture) also produce no units — legitimately blank.
135
+ - **`inventoryType` determines unit existence**: `SERIALIZED` and `HYBRID` items can produce unit records; `NON_SERIALIZED` (bulk/consumable) never do. `HYBRID` items fulfilled as a plain quantity (no serial capture) also produce no units — legitimately blank. **Only `SERIALIZED` is reliable enough to *expect* units from.** `Items.inventoryType` is `enum('SERIALIZED','NON_SERIALIZED','HYBRID')` and the constants live on `_Model_Client_Item` (`INVENTORY_TYPE__SERIALIZED`) — compare against those, never a raw string. Measured on prod `Client_Nychh` fulfillment lines: NON_SERIALIZED **0 of 2,372** ever have units, HYBRID **510 of 5,743**, SERIALIZED **1,242 of 1,389**. So any "this line is missing its units/asset tags" alert must be gated on SERIALIZED or it fires on every accessory — see [NYCHH transfer-order emails](../../../clients/nychh/features/transfer-order-notification-emails.md).
135
136
  - **Receipt-path counts are PO-line-wide.** Units surfaced reflect everything received against the bridged PO line, which may exceed or differ from what was actually fulfilled to a given sales order (over/under-attribution), because attribution is by PO line, not by the fulfillment that shipped the unit.
136
137
  - **A 500 here is usually stale per-client view config, not missing data.** This view's joins were repointed onto the bridge records (319 tracking, 289 `SalesOrderItems_PurchaseOrderItems`) per client. A client that never got that migration 500s on `table-views/meta` (*"Model build for primary key id '1431' for `_Model_Core_RecordField` … 0 rows"*) and then on the data query (*"Unknown column `SalesOrderItems_PurchaseOrderItems.salesOrderItemId` in 'on clause'"*). Confirmed on `Client_Elite` (view 17) — see [Elite TableView config drift](../../../clients/elite/features/supply2-tableview-config-drift.md). Check the view config before investigating the linkage paths above.
137
138
  - **A join can also be broken by a `parentRecordFieldId` that belongs to a record the view never joins.** Distinct from the dead-id case above: the id **exists**, it is just on the wrong record. Found 2026-08-17 - a join in this view carried `parentRecordFieldId` **92**, which belongs to record **289** (`SalesOrderItemPurchaseOrderItems`), a record the view does not join anywhere, and it **500'd** the view. **Proven by bisection:** all 23 other joins returned 200; adding this one returned 500. No fields or joins depended on it, so the fix was a targeted `DELETE`. The same row is present in **Elite, Compass and Prudential**. When a view 500s and the ids all resolve, check that every `parentRecordFieldId` sits on a record the view actually joins - and bisect the joins rather than reading the SQL.
@@ -144,3 +145,6 @@ Header-level vs item-level bridges, and the level each transaction is anchored a
144
145
  - [tableview-joins](./tableview-joins.md)
145
146
  - [Elite supply2 TableView config drift](../../../clients/elite/features/supply2-tableview-config-drift.md)
146
147
  - [Prudential Sales Orders CSV export](../../../clients/prudential/features/supply-orders-csv-export.md)
148
+
149
+ ## Change history
150
+ - 2026-10-05 — Tightened the `inventoryType` gotcha: named the `_Model_Client_Item::INVENTORY_TYPE__SERIALIZED` constant and added the prod NYCHH counts (NON_SERIALIZED 0 of 2,372 lines ever carry units, HYBRID 510 of 5,743, SERIALIZED 1,242 of 1,389), so a "missing units" alert is gated on SERIALIZED. (bala)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-05
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker2/Worker/Client/Nychh.php
@@ -58,6 +58,31 @@ Two 1.0 `worker` scripts (run via PuTTY) back this up:
58
58
  - **`worker/crons/toga2/netsuite/verify_fulfillment_asset_tag_sync_nychh.php`** — read-only, compares NetSuite vs TOGa per fulfillment to confirm the sync grabbed tags correctly.
59
59
  - **`worker/crons/toga2/netsuite/backfill_all_asset_tags_from_netsuite_nychh.php`** — full-history SOAP per-serial backfill; `DRY_RUN` default, never-blank, resumable via a ledger file.
60
60
 
61
+ ## ⚠ Open defects — the cron succeeds and fills 0 units
62
+
63
+ Cron **`Core.CronJobs` id 44**, `0 7,16 * * *`, `{"dryRun": false}`. It **reports success** every
64
+ run: 60,565 of 61,174 serials found, 24,978 carrying a NetSuite tag — and writes **0 units**.
65
+ Three faults, all **open as of 2026-10-05** (found, not fixed):
66
+
67
+ 1. **`fetchTagsForSerials()` never paginates.** It sends 500 serials per chunk with `?limit=1000`
68
+ and reads `$page->items` **once**. Serials are **not unique** in NetSuite — 1,151,658
69
+ `inventorynumber` rows against 1,126,401 distinct values, and junk serials make it worse
70
+ (`'NA4'` alone returns 9 rows) — so a chunk can overflow one page and the tail is dropped
71
+ silently. That matches the 609 serials the run cannot find (inferred from the counts, not
72
+ reproduced).
73
+ 2. **`loadUnitsBySerial()` keys units by serial** (`$unitsBySerial[$serial] = $row`). Prod has
74
+ **61,643 units with a serial but only 61,166 distinct**, so **477 units are overwritten** in the
75
+ map and can never be filled, whatever NetSuite holds.
76
+ 3. **`fetchTagsForSerials()` keys the NetSuite side by serial too** — last row wins, so a row
77
+ with a NULL tag can **wipe a good value** read moments earlier.
78
+
79
+ Live case that exposed it: units **57144** and **59631** (serials `62C26G4`, `B2C26G4`) are blank
80
+ in `Client_Nychh.Units` since 12 Sep while NetSuite holds `ITD0081907` / `ITD0081919`.
81
+
82
+ **Do not over-correct — most blank tags are legitimately blank.** A sample of 25 blank-tag
83
+ serials had a NULL asset tag in NetSuite as well. Fixing the three faults recovers hundreds of
84
+ units, not tens of thousands.
85
+
61
86
  ## Gotchas
62
87
 
63
88
  - **Do not filter on the custom columns.** SuiteQL will `SELECT` `custitemnumber_asset_tag` / `custitemnumber_mac_address` but a `WHERE custitemnumber_asset_tag IS NOT NULL` returns **0 rows with no error** — see the SuiteQL custom-field gotcha in [netsuite-rest-client](../../_underscore/features/netsuite-rest-client.md). Filter on the native `inventorynumber` column, select the custom columns, filter blanks in PHP.
@@ -70,3 +95,6 @@ Two 1.0 `worker` scripts (run via PuTTY) back this up:
70
95
  - [NetSuite ↔ TOGa Supply per-client sync](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md) — the fulfillment sync this backstops.
71
96
  - [creating-worker-actions](creating-worker-actions.md) — dispatch + `DB_CLIENT` auto-registration.
72
97
  - [NYCHH profile](../../../clients/nychh/profile.md).
98
+
99
+ ## Change history
100
+ - 2026-10-05 — Recorded three open faults behind the "succeeds but fills 0 units" runs: no pagination in `fetchTagsForSerials()` (serials are not unique in NetSuite), and serial-keyed maps on both the TOGa side (477 of 61,643 units lost) and the NetSuite side (a NULL-tag row overwrites a good one). Also recorded the counter-evidence that most blank tags are genuinely blank in NetSuite. Not fixed. (bala)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-10-01
9
+ updated: 2026-10-05
10
10
  owners: ["bala", "rgirish", "jcardinal"]
11
11
  files:
12
12
  - worker2/Worker/Client/Nychh/TransferOrderEmails.php
@@ -18,6 +18,7 @@ files:
18
18
  - dbchanges2/Core/2026-09-16a - NychhTransferOrderEmailsCron.sql
19
19
  related:
20
20
  - ./transfer-order-netsuite-push.md
21
+ - ../../../2.0/apps/_underscore/features/units-for-items-for-purchase-orders.md
21
22
  - ./netsuite-transfer-order-import.md
22
23
  - ../../../2.0/apps/_underscore/features/email-template-sending.md
23
24
  - ../../../2.0/apps/_underscore/features/email-send-pipeline.md
@@ -157,8 +158,21 @@ PUT follows.** Fulfillment **2704** in production: header row at 20:33:09, lines
157
158
  Added 2026-09-30 (TRUE-82111). `SendFulfillment` runs `readLinesMissingAssetTags()` whenever
158
159
  **packed** is one of the stages still owed. A fulfillment line counts as **short** when either:
159
160
 
160
- - it has **fewer linked units than its quantity** (`unitsLinked < ItemFulfillmentItems.quantity`), or
161
- - a linked unit has a **blank `Units.assetTag`**.
161
+ - the item is **`SERIALIZED`** and it has **fewer linked units than its quantity**
162
+ (`unitsLinked < ItemFulfillmentItems.quantity`), or
163
+ - a linked unit has a **blank `Units.assetTag`** — that half holds whatever the item type is.
164
+
165
+ **⚠ The unit-count half is SERIALIZED-only.** `Client_Nychh.Items.inventoryType` is
166
+ `enum('SERIALIZED','NON_SERIALIZED','HYBRID')`; only SERIALIZED items are expected to carry units,
167
+ so the query compares against `_Model_Client_Item::INVENTORY_TYPE__SERIALIZED` — never a raw
168
+ string. Type-blind, the rule reported every accessory as missing tags: the first live exception
169
+ (fulfillment **330197** on transfer order **291771**, 2026-10-05 09:42) fired on `AC785544`, a
170
+ wired desktop set. Measured on prod NYCHH fulfillment lines, NON_SERIALIZED lines have **never**
171
+ had units (0 of 2,372), HYBRID only 510 of 5,743, SERIALIZED 1,242 of 1,389 — the type-blind rule
172
+ would have warned on roughly **7,600 normal lines**. After the fix shipped the same fulfillment
173
+ passed at 10:10 and Order Packed went out with the accessory line correctly ignored. The
174
+ underlying rule lives in
175
+ [inventoryType determines unit existence](../../../2.0/apps/_underscore/features/units-for-items-for-purchase-orders.md).
162
176
 
163
177
  When any line is short, `SendFulfillment` **returns before writing
164
178
  `c_transferOrderEmailStageSent`** — so packed stays pending and the backstop cron re-sends it by
@@ -320,8 +334,10 @@ Cost of the split, stated so nobody is surprised later:
320
334
 
321
335
  ## Testing
322
336
 
323
- **106 tests** as of 2026-09-30. Each fix is proven by **reverting it** and confirming the matching
324
- tests fail. The five scenarios were then run end to end on **dev sandbox** against fulfillment
337
+ **111 tests** as of 2026-10-05 — the last five cover the SERIALIZED-only unit-count rule, including
338
+ a mixed fulfillment whose exception email names only the serialized line. Each fix is proven by
339
+ **reverting it** and confirming the matching tests fail (reverting the inventory-type rule fails
340
+ three). The five scenarios were then run end to end on **dev sandbox** against fulfillment
325
341
  **898** / transfer order **286536**.
326
342
 
327
343
  A local end-to-end harness (scratchpad, **not committed**) runs the **real** worker class against a
@@ -332,6 +348,14 @@ loaded out of the dbchanges2 file so an unresolved `{placeholder}` fails a test.
332
348
  unescaped values and both detail-box defects were all found there, not in production.
333
349
 
334
350
  ## Change history
351
+ - 2026-10-05 — **The asset-tag hold now only counts missing units on `SERIALIZED` items**
352
+ (TRUE-82111). `readLinesMissingAssetTags()` treated "fewer linked units than quantity" as missing
353
+ tags for every item, so accessories were reported short: first prod exception was `AC785544`, a
354
+ wired desktop set, on fulfillment 330197 / transfer order 291771. Now compared against
355
+ `_Model_Client_Item::INVENTORY_TYPE__SERIALIZED`; the blank-`assetTag` half is unchanged. Prod
356
+ shows NON_SERIALIZED lines never carry units (0 of 2,372), so the old rule would have warned on
357
+ ~7,600 normal lines. Shipped and verified live: same fulfillment passed at 10:10 and Order Packed
358
+ sent. Harness now 111 tests. (bala)
335
359
  - 2026-10-01 — **Packed/shipped are now HELD when a fulfillment line is short of asset tags**
336
360
  (TRUE-82111). A line is short when it has fewer linked units than its quantity **or** a linked unit
337
361
  has a blank `assetTag`; `SendFulfillment` then returns without writing
@@ -7,7 +7,7 @@
7
7
  | [Prudential: Device information import + unit→contact linking (import_device_information.php)](features/device-information-import-and-contact-linking.md) | 1.0 | How the device-sync cron links a Unit to its employee Contact, the "null null" duplicate-contact incident, and the dedup+backfill remediation; open before touch |
8
8
  | [Prudential: ItemClasses device-tier data is dead (and how that was proved)](features/itemclasses-device-tier-data.md) | 2.0 | Prudential `ItemClasses` device-tier data is dead/partly-wrong and was cleared 2026-09-11; also the reusable 4-source method for proving any field is dead. |
9
9
  | [Prudential: OneUptime order-pipeline backlog monitors (Monitor/Prudential/*)](features/oneuptime-order-pipeline-monitors.md) | 2.0 | Three worker2 OneUptime push monitors watching the Prudential/Dell order pipeline for stalls; open before touching Prudential monitors, their thresholds, or the |
10
- | [Prudential: order email PDF attachments (which guide goes on which email)](features/order-email-pdf-attachments.md) | 1.0 | Which S3 PDF guide attaches to which Prudential order email by service-request type/stage, and how to change an attachment safely; open before editing any Prude |
10
+ | [Prudential: order email PDF attachments (which guide goes on which email)](features/order-email-pdf-attachments.md) | 1.0 | Which S3 PDF guide attaches to which Prudential order email by service-request type/stage, every hardcoded spot to change when a guide is swapped, and how to te |
11
11
  | [Prudential: Service Request Regional Address Validation](features/service-request-address-validation.md) | 2.0 | The prePost interceptor that validates a Prudential service request's deliverToAddress per region (USA/India/Ireland) and rejects a blank customer uuid; open fo |
12
12
  | [Prudential: refused REQ alert (401/403 on POST /v2/service-requests)](features/service-request-auth-rejection-alert.md) | 1.0 | The 15-min cron that alerts on refused (401/403) inbound Prudential REQs, where each code is logged, and how to tell "we refused it" from "they never sent it"; |
13
13
  | [Prudential: Service Request bundle lookup scoped to the customer's catalog (wrong Dell India provision profile)](features/service-request-bundle-catalog-resolution.md) | 2.0 | Why India REQs link to USA bundles (bundle numbers repeat across catalogs) and the prePost fix that resolves the bundle uuid inside the customer's catalog; open |
@@ -48,6 +48,8 @@ WHERE PurchaseOrders.vendorId = 1 # Dell = Vendors.id 1
48
48
  This is NOT the SalesOrder customer — the SO customer is always **"Agilant - Tech Hub" (id 1, uuid `36da53f8-38d2-404f-becf-f58f33c215d3`)**.
49
49
  - A REQ is only ever transmitted, validated, or marked once selected here. Unit/bundle/SO/PO creation happens elsewhere and does **not** send anything to Dell.
50
50
 
51
+ **⚠ The ship-to address Dell receives comes from `ServiceRequests.deliverToAddressId`, NOT the PO's `shipToAddressId`.** The cron joins `LEFT OUTER JOIN Addresses ON Addresses.id = ServiceRequests.deliverToAddressId` and sends `Addresses.line1` as `shipAddress.address1`. A separate `Addresses` row *is* created for the PurchaseOrder, and it is **not** the one transmitted. Correcting a bad Dell address means editing the **SR's deliverTo row**; fixing only the PO's address changes nothing and the same rejection repeats.
52
+
51
53
  ### Send mechanism
52
54
  - `App_Api_Delllch::send` (`library/app/api/delllch.php`) POSTs to route `api/v2/request/LCHRequestV2`. Prod host `https://cms-iop.us.dell.com/iopv2/`; non-prod `.../iopv2np/`.
53
55
  - Auth: OAuth2 client-credentials Bearer token obtained via `auth/token` (credentials in config, not in this doc).
@@ -56,6 +58,14 @@ WHERE PurchaseOrders.vendorId = 1 # Dell = Vendors.id 1
56
58
  - **Event model (the key change):** the new schema adds a required **`requestEvent`** field — `'Create'` for new orders, `'Update'` for updates, `'Cancel'` for cancellations. Replaces the old overloaded `svcOrderType` that previously signalled both the SR type AND the event (`'CANCEL'`/`'UPDATE'`). In V2, `requestType` carries the SR type only (Refresh / Breakfix / Reclaim / Bulk).
57
59
  - **Optional pre-submission validation:** `App_Api_Delllch::validateLCHRequestV2(array $payload, bool $isProduction = true): mixed` calls `api/v2/validate/LCHRequestV2` — returns 200 on a valid payload, `422` with a `problems[]` / `issues[]` array on schema violations.
58
60
 
61
+ ### What a pre-send 422 does (and does not) do
62
+ Order of operations in each regional cron: **validate → send → stamp**.
63
+ 1. `POST api/v2/validate/LCHRequestV2`. A rejection is `422` with e.g. `{"msg":{"problems":1,"issues":["Unsupported [shipAddress.address1] is a PO Box"]}}` — **Dell refuses PO Box ship-to addresses.**
64
+ 2. On failure the cron emails `devteam@togatech.com` + `vburks@togatech.com`, throttled to **once an hour per PO** via `PurchaseOrders.c_dtLastValidationAlert`, then `continue`s. `dtSubmitted` is left NULL "so it retries once the data is corrected".
65
+ 3. On success it POSTs `api/v2/request/LCHRequestV2` (Dell answers **202**) and only then runs `UPDATE PurchaseOrders SET dtSubmitted = NOW()` — which is what drops the PO out of the queue.
66
+
67
+ **Consequence: fixing the data is enough. No manual push, no re-queue** — the next 5-minute run picks the PO up and stamps it.
68
+
59
69
  ### Payload schema migration (old → new)
60
70
  | Old (`LCHRequest`) | New (`LCHRequestV2`) |
61
71
  | --- | --- |
@@ -108,11 +118,13 @@ Verification plan:
108
118
  - **The country-split cutover (~2026-07-17) is when this started biting.** Before the split, `transmissions_to_dell_v1.php` had **no customer gate**, so every REQ transmitted regardless. The regional split files added `INNER JOIN Customers` + `WHERE Customers.uuid = '<region>'`, which drops NULL-`customerId` REQs. ~38 REQs stranded this way since the cutover.
109
119
  - **Remediation:** backfill `ServiceRequests.customerId = 3` (USA) for the stuck US-ship-to REQs; **durable fix** = have the generation cron derive country/`customerId` from `deliverToAddress` so `customerId` is never NULL.
110
120
  - **`dtSubmitted` is stamped without inspecting the response (latent bug, not yet fixed).** Every regional cron sets `PurchaseOrders.dtSubmitted = NOW()` immediately after the send without checking Dell's HTTP status, and `App_ApiTransaction::execute()` (`library/app/apitransaction.php`) only throws on curl transport failure / empty / non-JSON body — **not** on HTTP >= 400. So a Dell 4xx/5xx rejection is recorded as "sent", the PO drops out of the `dtSubmitted IS NULL` queue, and is never retried. `getAccessToken()` also does not check the token-call result (silent 401 path). Second way an order can silently fail to reach Dell; fix later by inspecting the response before stamping.
121
+ - **⚠ A 422 retries forever — a permanent data rejection is treated as transient.** Because validation failure `continue`s without stamping `dtSubmitted`, the same PO is re-selected **every 5 minutes, indefinitely**. One PO Box address cost **748 Dell asset lookups between 2026-09-18 and 2026-09-22** (~280 calls/day), each ending in the same 422. A 422 is a schema/data rejection that will never pass on retry: it should **park** the request (stamp a failure state) rather than re-queue. Until that is built, the hourly alert email is the only stop signal — and it stops nothing.
111
122
  - `requestType` is the SR type only (Refresh/Breakfix/Reclaim/Bulk) — do **not** put the event there. Event signalling moved to the separate required `requestEvent` field (Create/Update/Cancel). Conflating them reproduces the old overloaded `svcOrderType` bug under the new schema.
112
123
  - Dell deprecated `LCHRequest`; any script still POSTing to the old route will fail. All four transmission scripts must point at `LCHRequestV2`.
113
124
  - The removed fields (e.g. `deviceType`, `lob`, `imageType`) will be rejected by V2 validation — do not reintroduce them.
114
125
 
115
126
  ## Change history
127
+ - 2026-10-05 — Recorded that the Dell ship-to comes from `ServiceRequests.deliverToAddressId` (not the PO address), Dell's PO Box 422, and the forever-retry loop a 422 creates (748 lookups for one bad address). Investigation only, no code change. (bala)
116
128
  - 2026-09-29 — Noted the provision profile is read from the linked bundle; wrong bundle link sent India REQs the USA profile. (rgirish)
117
129
 
118
130
  ## Related
@@ -6,19 +6,21 @@ project: Worker
6
6
  client: prudential
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-05
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php
13
13
  - worker/crons/toga2/prudential/transmissions_to_netsuite.php
14
14
  - worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php
15
15
  - worker/crons/toga2/prudential/transmit_closecomplete_updates_prudential.php
16
+ - worker/crons/toga2/prudential_beta/generate_sales_and_purchase_orders_from_service_requests.php
17
+ - store/common/prudential/home.php
16
18
  related:
17
19
  - transmit-ordershipped-email.md
18
20
  - ../profile.md
19
21
  - ../../../1.0/apps/library/features/email-test-mode-and-write-free-sends.md
20
22
  ---
21
- Which S3 PDF guide attaches to which Prudential order email by service-request type/stage, and how to change an attachment safely; open before editing any Prudential order-email attachment.
23
+ Which S3 PDF guide attaches to which Prudential order email by service-request type/stage, every hardcoded spot to change when a guide is swapped, and how to test against prod safely; open before editing any Prudential order-email attachment or guide link.
22
24
 
23
25
  ## Summary
24
26
  Every customer-facing Prudential order email carries S3-hosted PDF guides, and **which** guide is attached is decided by the service request type. The rule was inconsistent between stages (same request type got different documents at order-created vs shipped vs delivered), and a new hire was being sent the *return* instructions instead of a setup guide. As of 2026-08-27 one rule applies at **every** stage:
@@ -42,7 +44,7 @@ Reported by the client (Gary Yerman) against the new-hire order-created email; t
42
44
  Load-bearing part is the **Return** column: no non-New type may go out without the return guide, and New must never carry it.
43
45
 
44
46
  ### Which cron sends which email
45
- All four are `active: 1` in `worker/schedules/cron.worker.sync.json`:
47
+ Schedules in `worker/schedules/cron.worker.sync.json`. All `active: 1` except `transmissions_to_netsuite.php` (`active: 0` since 2026-07-17, see `transmit-ordershipped-email.md`):
46
48
  | Stage | Cron | Branches on |
47
49
  |---|---|---|
48
50
  | Order created | `generate_sales_and_purchase_orders_from_service_requests.php` | `serviceRequestTypeId` (`1`, `5`, `3`, `else`) |
@@ -53,14 +55,29 @@ All four are `active: 1` in `worker/schedules/cron.worker.sync.json`:
53
55
  **Two different crons send the shipped email** (`transmissions_to_netsuite.php` and `transmit_ordershipped_updates_prudential.php` — both also stamp `c_dtTransmittedOrderShippedUpdateToPrudential`). Any attachment change to the shipped email must be made in **both**, or they drift apart. The order-created cron switches on the numeric `serviceRequestTypeId`, while shipped and delivered crons switch on the type **name string** read from `serviceRequest.serviceRequestType.name` in the TOGa API response. Names match the enum exactly (`New`, `Reclaim`), so the string comparisons are safe — but the two styles mean a newly added request type lands in the `else` branch everywhere and is never matched by name.
54
56
 
55
57
  ### The PDF assets
56
- All three live under `https://external-client-files.s3.us-west-2.amazonaws.com/prudential/` and all returned HTTP 200 (verified 2026-08-27). Two are **renamed on attach**:
58
+ All three live under `https://external-client-files.s3.us-west-2.amazonaws.com/prudential/`. Two are **renamed on attach**:
57
59
  | S3 object | Attached to the email as | Size |
58
60
  |---|---|---|
59
- | `EquipMe-Win11-v1.pdf` | `Getting_Started_Guide_Windows11.pdf` | 773,591 B |
61
+ | `EquipMe-Win11-Sep_2026.pdf` (since 2026-10-05; replaced `EquipMe-Win11-v1.pdf`) | `Getting_Started_Guide_Windows11.pdf` | 2,735,276 B |
60
62
  | `2026_Aug_PrudentialMacSetupGuide_ZCC.pdf` | `PrudentialMacSetupGuide.pdf` | 7,543,154 B |
61
63
  | `Prudential_Instruction-2Page-V1.pdf` | (unchanged) | 100,224 B |
62
64
 
63
- The Mac guide dominates: a new-hire email is roughly **11 MB** once base64-encoded. Grepping for the attached-as filename will not find the S3 object — search the S3 basename.
65
+ A new-hire email (Win11 + Mac) is roughly **14 MB** once base64-encoded. Grepping for the attached-as filename will not find the S3 object, search the S3 basename.
66
+
67
+ **The attached name never changes, so it does not tell you the version.** The email shows `Getting_Started_Guide_Windows11.pdf` and the cron caches to `$cacheFolder/Getting_Started_Guide_Windows11.pdf` whatever URL was fetched. Check the byte size instead (Sep 2026 = 2,735,276; old v1 = 773,591). The old `EquipMe-Win11-v1.pdf` and the S3 object `Getting_Started_Guide_Windows11.pdf` are byte-identical (both the old guide).
68
+
69
+ ### Swapping the Windows 11 guide: every hardcoded spot
70
+ There is **no setting or table** for the guide URL. It is a literal in each file below, and all must change together:
71
+ | File | Where |
72
+ |---|---|
73
+ | `worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php` | `$pdfUrl2` in the order-created branches New (1), Refresh (3), and `else` (Breakfix/Swap/Label). Reclaim (5) attaches only `Prudential_Instruction-2Page-V1.pdf`. |
74
+ | `worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php` | Shipped (Dell, vendorId 1): new-hire block, plus the shared block inside `if ($reqType != 'Reclaim')`. |
75
+ | `worker/crons/toga2/prudential/transmit_closecomplete_updates_prudential.php` | Delivered: same two places, same Reclaim guard. |
76
+ | `worker/crons/toga2/prudential/transmissions_to_netsuite.php` | Shipped for Agilant-shipped POs (vendorId 2). Cron is `active: 0`, and prod has 0 vendor-2 POs with `c_dtEmailSentOrderShipped`, so it never fires today. Change it anyway. |
77
+ | `worker/crons/toga2/prudential_beta/generate_sales_and_purchase_orders_from_service_requests.php` | Beta copy of order-created. |
78
+ | `store/common/prudential/home.php` (~L244-245) | Equip Me home page "Document" announcement: `$link` ("Click here") and `$win10Link` ("click here") both point at the guide. The Mac link (`$macLink`) is separate. |
79
+
80
+ Last swap 2026-10-05 (to the Sep 2026 build guide): New, Refresh, Breakfix/Swap/Label emails and the Equip Me page; Reclaim stays excluded.
64
81
 
65
82
  ### The verbiage has to match what actually attached
66
83
  Every attach is guarded by `if ($pdfGuideX !== false)`, but until 2026-08-31 the `$attachmentText` paragraph that *describes* the attachments was unconditional. A failed S3 fetch produced an email whose copy pointed the recipient at guides not on the message — and it always said "guides" plural even when a single guide attached. The rule now, in every branch touched: **derive the sentence from the guards, not from the intent.**
@@ -94,23 +111,18 @@ Only the new-hire and Breakfix/Swap/Label order-created templates carry the (com
94
111
  2. Each PDF gets its **own** `if ($pdfX !== false)` guard around the `file_put_contents` + `addAttachment` pair — never a joint `&&`. One failed PDF must drop only itself; the email always sends.
95
112
  3. **Put the return-guide fetch *before* the `if ($reqType != 'Reclaim')` guard** in the shipped and delivered crons. Everything inside that guard is skipped for Reclaim — and Reclaim is precisely the type that most needs the return instructions. That guard is how Reclaim ended up receiving zero attachments.
96
113
 
97
- ### Testing these emails without writing anything
98
- **There is no committed test harness — do not go looking for one.** Three throwaway scripts (`test_new_hire_email.php`, `test_prudential_order_emails.php`, `test_prudential_email_templates.php`) were written under `crons/toga2/prudential/` on 2026-08-31 to mail every stage/type combination with the real templates and real S3 PDFs. They were **never committed and are no longer in the working tree**. What is durable is the technique.
99
-
100
- A harness that mails a real, byte-identical copy while writing **nothing** to the database needs all of this:
101
- - `setQueueForSending(false)`, `sendEmailEvenIfOutboundEmailIsDisabled = true`, and `setIsLogEnabled(false)` on the `App_Email` object. All three — the first two keep it out of `Common.EmailsQueued`, the third keeps it out of `Logs.Email`. Full detail in [App_Email Side Effects & Test Mode](../../../1.0/apps/library/features/email-test-mode-and-write-free-sends.md).
102
- - `App_Framework_Worker::initialize()` **only**. Do **not** call `App_Framework::cronInitialization()` — it INSERTs a `CronJobExecutions` row. Set `set_time_limit()` and `date_default_timezone_set()` yourself instead.
103
- - One read-only SELECT for the sample data (below), rather than the cron's api2 fetches.
104
- - Print each PDF's resolved/missing state and byte size **before** sending; PHPMailer 5 will send the message anyway if an attachment is unreadable.
105
-
106
- **Run it on the worker server if anyone other than you needs to receive it.** Local and beta configs have `internal.test_mode = 1`, which discards the recipient list and sends only to `email.dev_email_to`. `config.worker.ini` on the worker server has `test_mode = 0`.
107
-
108
- Read-only query for a real sample request: one row per request type is enough. Join `ServiceRequests` to `Addresses` on `deliverToAddressId`, `States` on `Addresses.stateId`, `Contacts` on `requestedForContactId`, and **LEFT JOIN** `SalesOrders` on `SalesOrders.serviceRequestId` (left, because an unprocessed request has no SO yet). Select `ServiceRequests.c_reqNumber AS reqNumber`, `Contacts.firstName`, the address parts, `States.code`, and `SalesOrders.number` — the exact set the shipped/delivered templates interpolate. All six request types have live rows in prod `Client_Prudential` (verified 2026-08-31); **Swap has only 2 rows**, the rest have thousands, so filter narrowly for Swap or you will miss it.
114
+ ### Testing these emails against prod data without writing anything
115
+ No committed harness. Build a throwaway `test_<x>_emails.php` next to the cron, use it, delete it. The generic recipe (the three `App_Email` switches, skip `cronInitialization()`, reading prod from a local run) is in [App_Email Side Effects & Test Mode](../../../1.0/apps/library/features/email-test-mode-and-write-free-sends.md). Prudential specifics:
116
+ - Copy the cron's email block **verbatim** (template, subject, S3 fetches) and force `$emailAddresses = [$testEmailRecipient]`.
117
+ - Force links `db_prod_prudential` / `db_prod_logs_prudential` (the crons pick `db_dev_*` under `inDevMode`).
118
+ - Sample = the latest prod record per `serviceRequestTypeId` that was **already emailed** at that stage: `SalesOrders.c_dtEmailSent` (created), `PurchaseOrders.c_dtEmailSentOrderShipped` (shipped), `c_dtEmailSentOrderClosed` (delivered) `IS NOT NULL`. Swap has very few rows in prod, so filter narrowly.
119
+ - Stop before the cron's `UPDATE` of those date columns.
120
+ - Print each PDF's byte size before sending (that is how you tell guide versions apart, see above).
109
121
 
110
122
  ## Gotchas
111
123
  - **`transmit_closecomplete_updates_prudential.php` still over-promises (~line 447).** Its delivered email hardcodes "Guides for Windows 11 and MacBook devices are provided" with no reference to whether either PDF actually attached — the exact bug fixed everywhere else on 2026-08-31. Left alone deliberately to keep PR #1707's diff to files already being changed. **Worth a follow-up ticket.**
112
124
  - **`transmissions_to_netsuite.php` also sends *delivered* emails — with no attachments at all.** It carries its own "New hire equipment has been delivered" / "Your equipment has been delivered" blocks (~L1765 and ~L2149) that make **zero** `addAttachment()` calls, while `transmit_closecomplete_updates_prudential.php` sends the **same two subjects** with the guides attached. **Whether both fire for the same order was not established** — if they do, the recipient gets a duplicate delivered email, one attachment-less. Check this before changing either.
113
- - **`prudential_beta/` does not match prod.** `crons/toga2/prudential_beta/generate_sales_and_purchase_orders_from_service_requests.php` is `active: 1` on the same schedule but points at the beta DB and still references the retired `Prudential_Return_Instructions.pdf` (and the old `PrudentialMacSetupGuide.pdf`). **A beta test will not reproduce prod attachment behavior.** Left as-is deliberately; the developer is aware.
125
+ - **`prudential_beta/` does not match prod.** `crons/toga2/prudential_beta/generate_sales_and_purchase_orders_from_service_requests.php` is `active: 1` on the same schedule but points at the beta DB and still references the retired `Prudential_Return_Instructions.pdf` (and the old `PrudentialMacSetupGuide.pdf`). **A beta test will not reproduce prod attachment behavior.** Only the Win11 guide URL is kept in sync with prod (2026-10-05); the rest is left as-is on purpose.
114
126
  - **Breakfix / Swap / Label get no Mac guide on the order-created email** (they do get it on shipped and delivered). Known, and left alone on purpose — not a bug to "fix" unasked.
115
127
  - The delivered cron (`transmit_closecomplete_updates_prudential.php`) already followed the rule and was deliberately **not** touched.
116
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.944",
3
+ "version": "1.0.946",
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",