toga-ai 1.0.269 → 1.0.271

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.
@@ -11,5 +11,5 @@
11
11
  | [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t | library/app/framework.php, library/app/frameworkindex.php, library/app/mvc.php, library/app/database.php, library/app/model.php, library/app/config.php |
12
12
  | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem, test/@dave/Junk Drawer/nsq.php |
13
13
  | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php |
14
- | [Startech PC Matic B2B Sync (library)](features/startech-pcmaticb2b-sync.md) | `library/app/api/toga2.php` handles bidirectional ticket sync for PC Matic B2B between TOGaDesk 1.0 and TOGA 2.0. | library/app/api/toga2.php |
14
+ | [Startech PC Matic B2B Sync (library)](features/startech-pcmaticb2b-sync.md) | `library/app/api/toga2.php` handles bidirectional ticket sync for PC Matic B2B between TOGaDesk 1.0 and TOGA 2.0. | library/app/api/toga2.php, library/app/api/startechticket.php, worker/crons/toga2/startech/common_import_supporting_records.php |
15
15
  | [App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge](features/toga2-api-client-and-bridge.md) | `App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross | library/app/api/toga2.php, worker/crons/toga2/aig/sync_togasupply_aig.php, worker/crons/toga2/wje/sync_togasupply_wje.php |
@@ -6,10 +6,12 @@ project: Library
6
6
  client: pcmaticb2b
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-06-23
9
+ updated: 2026-07-06
10
10
  owners: [snaredla]
11
11
  files:
12
12
  - library/app/api/toga2.php
13
+ - library/app/api/startechticket.php
14
+ - worker/crons/toga2/startech/common_import_supporting_records.php
13
15
  related:
14
16
  - clients/pcmaticb2b/features/startech-ticket-sync.md
15
17
  - clients/pcmaticb2b/profile.md
@@ -63,6 +65,21 @@ Both `PCS Ticket` and `TOGaDesk-PCS` map to `PCS` in TOGA 2.0.
63
65
  - **`SUPPORT REQUEST` with a space** — `TOGA2_TICKET_TYPE_CODE__SUPPORT = 'SUPPORT REQUEST'`. Using `'SUPPORT'` silently breaks type matching.
64
66
  - **`$isPcmaticb2bUuid` flag** is used for PC Matic B2B-specific branching throughout `syncToga2TicketIntoTogadesk1Ticket()`.
65
67
 
68
+ ## syncWithToga → TOGA_PCMaticB2B (V1) — the third leg
69
+
70
+ `sync_togadesk_startech_pcmaticb2b.php` runs `syncWithToga(21, …, true, false)` **before** `syncWithTogadesk`. It pushes 2.0 → V1: Contacts→`Customers` (writes `c_togaCustomerId` back to the 2.0 contact), Entitlements→`ServiceRequests` (writes `c_togaServiceRequestId` back). Only records where the cross-ref **IS NULL** are processed. It uses `throwExceptionOnApiError=true` and runs first, so **any throw here aborts the whole cron — blocking `syncWithTogadesk` too.**
71
+
72
+ Ruled out while debugging "nothing syncs to V1" (0/77 contacts + 0/37 entitlements had their cross-ref set): client-21→`TOGA_PCMaticB2B` mapping (present in `TOGA.Clients`), the `Customers` INSERT columns, and `changeDbLinkToClientDatabase` (`App_Query::execute` re-selects the DB per query from the config `dbname`). Remaining suspects: the PUT-back to 2.0 (throws on non-200) or the cron not executing. The exact throw is only visible in Sentry / `error_log`.
73
+
74
+ ## Supporting-records import (worker/crons/toga2/startech/common_import_supporting_records.php)
75
+
76
+ Imports Startech (Easeedesk V3) config into TOGA 2.0 — users, groups, devices, categories, ticket types.
77
+
78
+ - **Groups** must come from `GET /groups_dispatching` (`data.dispatch[]`, keys `group_id`/`group_name`). The company-scoped `dynamic_field_options->data->groups` returns empty here — groups are provider-level, not per-company.
79
+ - **Categories** — `App_Api_StarTechTicket::getCategories($companyId, $ticketTypeIds)` forwards a `ticket_type` filter alongside `company`. ⚠ The param name `ticket_type` is **unverified** against the Easeedesk API (not documented for `/tickets/categories`) — confirm in Postman before relying on type filtering; if it's ignored, categories come back unfiltered.
80
+ - Any single upsert failing aborts the run (all `send()` calls throw on API error); the block order is users → groups → devices → categories → ticket types.
81
+
66
82
  ## Change history
67
83
 
68
84
  - 2026-06-23: Initial doc — ticket type maps (both directions), NULL sync behavior, TOGADESK_PCS type added
85
+ - 2026-07-06: Added syncWithToga V1 leg (cross-ref gating, cron-abort ordering, debugging findings) and supporting-records import notes (groups via groups_dispatching, getCategories ticket-type filter)
@@ -5,8 +5,8 @@ project: Library
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-06-08
9
- owners: [jcardinal]
8
+ updated: 2026-07-06
9
+ owners: [jcardinal, rgirish]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/library/architecture.md
@@ -395,6 +395,34 @@ class Browser_Datagrid_Accounts extends Browser_Datagrid {
395
395
  * The `@` error-suppression operator is acceptable only for defensive file I/O (e.g. reading an optional cache file); do not use it to hide real errors.
396
396
  * Use structured logging to capture application behavior and errors.
397
397
 
398
+ ### PHP warnings throw — guard fragile I/O, do not rely on `!== false`
399
+
400
+ The bootstrap sets `error_reporting(E_ALL)` and `App_Error::handleError` (`library/app/error.php`)
401
+ converts **every** PHP warning into an `ErrorException`. Consequences you must design around:
402
+
403
+ - Warning-emitting calls **throw at the call site** rather than returning their documented
404
+ failure value. `file_get_contents()` on an unreachable/403/timed-out URL raises an
405
+ `ErrorException` **inside** the call — so a following `if ($x !== false)` guard never runs.
406
+ - In a loop (e.g. a cron), one such throw aborts the **entire** remaining batch, not just the
407
+ current item. This is especially dangerous in two-phase "stamp then act" crons where earlier
408
+ phases have already committed state.
409
+
410
+ Wrap fragile I/O and return the failure value yourself:
411
+
412
+ ```php
413
+ function safeFetchPdf(string $url) {
414
+ try {
415
+ return file_get_contents($url);
416
+ } catch (\ErrorException $e) {
417
+ error_log('PDF fetch failed: ' . $e->getMessage());
418
+ return false;
419
+ }
420
+ }
421
+ ```
422
+
423
+ Then guard each dependent step **independently** (not a joint `&&`) so one failure degrades
424
+ gracefully instead of dropping unrelated work or crashing the loop.
425
+
398
426
  ## Security Best Practices
399
427
 
400
428
  ### Input Validation and Sanitization
@@ -5,7 +5,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
5
5
  ## 1.0 framework
6
6
 
7
7
  - **library** (Library) _(framework core)_ — 11 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
- - **worker** (Worker) — 12 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
8
+ - **worker** (Worker) — 13 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **togadesk** (TOGa Desk) — 8 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
10
10
  - **togaview** (TOGa View) — 6 doc(s) → [1.0/apps/togaview/INDEX.md](1.0/apps/togaview/INDEX.md)
11
11
  - **webhook** (Webhook) — 1 doc(s) → [1.0/apps/webhook/INDEX.md](1.0/apps/webhook/INDEX.md)
@@ -7,4 +7,5 @@
7
7
  | [Compass MITS PO → SO Item Linking](features/mits-po-to-so-item-linking.md) | 2.0 | MITS sends Compass inbound Purchase Orders (`POST /v2/purchase-orders`) against a Sales Order (`mitsSalesOrder`). | _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
8
8
  | [Compass MITS PO Transmission to Vendors](features/mits-po-transmission-to-vendors.md) | 2.0 | The 1.0 worker cron `2_transmit_mits_purchase_orders_to_vendors.php` transmits Compass PurchaseOrders to their vendors (Office Depot, Strategic Systems, Compass | worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, library/app/client/compass.php |
9
9
  | [Compass USA](profile.md) | 2.0 | Compass USA is a TOGA client running a multi-tier supply-chain commerce operation. | |
10
+ | [Compass ODP Order Pipeline to NetSuite (numbered worker crons)](workflows/odp-order-pipeline-to-netsuite.md) | 1.0 | The end-to-end **Compass Office Depot (ODP) order → NetSuite** pipeline as it actually runs through the 1.0 `worker` crons under `worker/crons/toga2/compass/`, | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, worker/crons/toga2/compass/edi/1_download_edi_s3_create_po_toga.php, worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, library/app/client/compass.php |
10
11
  | [Compass Order Lifecycle & Data-Integrity Invariants](workflows/order-lifecycle-and-data-integrity.md) | 2.0 | End-to-end map of how a Compass order flows through the `Client_Compass` (2.0) database and the **expected raw-data shape** at each link/ASN/IF level. | |
@@ -0,0 +1,113 @@
1
+ ---
2
+ title: Compass ODP Order Pipeline to NetSuite (numbered worker crons)
3
+ framework: "1.0"
4
+ repo: worker
5
+ project: Worker
6
+ client: compass-usa
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-07-01
10
+ owners: ["rgirish"]
11
+ files:
12
+ - worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php
13
+ - worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php
14
+ - worker/crons/toga2/compass/edi/1_download_edi_s3_create_po_toga.php
15
+ - worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php
16
+ - library/app/client/compass.php
17
+ related:
18
+ - clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
19
+ - clients/compass-usa/features/mits-po-transmission-to-vendors.md
20
+ - clients/compass-usa/features/mits-po-to-so-item-linking.md
21
+ - clients/compass-usa/profile.md
22
+ ---
23
+
24
+ ## Summary
25
+ The end-to-end **Compass Office Depot (ODP) order → NetSuite** pipeline as it actually runs
26
+ through the 1.0 `worker` crons under `worker/crons/toga2/compass/`, and the **diagnostic
27
+ method** for "this Compass order never reached NetSuite" support tickets. This is the *cron
28
+ mechanics* companion to the 2.0 [Compass Order Lifecycle & Data-Integrity
29
+ Invariants](order-lifecycle-and-data-integrity.md) (which covers the raw-data shape / repair
30
+ side of the same chain). All facts verified against the live `prod` / `Client_Compass` schema
31
+ and the cron source. The crons are `App_` (1.0) framework code; the data lives in the 2.0
32
+ `Client_Compass` / `Logs_Compass` schema.
33
+
34
+ ## The pipeline (customerId / vendorId, in run order)
35
+
36
+ ```
37
+ Commerce → Compass SalesOrder (SalesOrders.customerId = 2)
38
+ [cron 1_transmit_compass_sales_orders_to_mits] → MITS (API)
39
+ MITS → PurchaseOrder (PurchaseOrders.vendorId = 1 = OFFICE DEPOT; POST /v2/purchase-orders; PO number e.g. 50305906-1)
40
+ [cron 2_transmit_mits_purchase_orders_to_vendors] → ODP (cXML; email fallback per integration) [sets PurchaseOrders.dtSubmitted]
41
+ ODP → 850 EDI dropped to S3 (AS2) (bucket agilant-as2, prefix OfficeDepot/)
42
+ [cron edi/1_download_edi_s3_create_po_toga] → creates ODP SalesOrder (customerId = 1); deletes the S3 file after processing
43
+ ODP SalesOrder (customerId = 1)
44
+ [cron 5_create_netsuite_sales_orders_from_office_depot_purchase_orders, hourly] → NetSuite SO
45
+ writes back c_dtTransmittedToNetsuite + c_netsuiteInternalSalesOrderId ONTO the ODP SO (customerId=1)
46
+ ```
47
+
48
+ ## The crons
49
+
50
+ 1. **`workflow/1_transmit_compass_sales_orders_to_mits.php`** — transmits the Compass
51
+ `SalesOrder` (customerId=2) to MITS via API.
52
+ 2. **`workflow/2_transmit_mits_purchase_orders_to_vendors.php`** — transmits the MITS-created
53
+ PO to ODP. ODP integration is **cXML** (auth audience `"Office Depot (Cxml)"`); the script
54
+ also supports an **email fallback** path per the vendor's integration type. Sets
55
+ `PurchaseOrders.dtSubmitted` when transmitted (~2 min after PO creation). (See the dedicated
56
+ [MITS PO Transmission to Vendors](../features/mits-po-transmission-to-vendors.md) feature for
57
+ the item-less-PO gotcha and the CXML-vs-EMAIL routing detail.)
58
+ 3. **`edi/1_download_edi_s3_create_po_toga.php`** — pulls the ODP **850** from S3 bucket
59
+ `agilant-as2`, prefix `OfficeDepot/` (delivered via AS2), and creates a downstream **ODP
60
+ SalesOrder** (customerId=1 = Office Depot). **Deletes the file from S3 after processing** —
61
+ so an absent S3 object is expected once ingested, not evidence of a miss.
62
+ 4. **`workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php`** — runs
63
+ **hourly**; picks up ODP SalesOrders
64
+ (`OfficeDepotSalesOrders.customerId = 1 AND c_dtTransmittedToNetsuite IS NULL`), creates the
65
+ SO in NetSuite, and writes back `c_dtTransmittedToNetsuite` + `c_netsuiteInternalSalesOrderId`
66
+ **onto the ODP SalesOrder (customerId=1), never the Compass SO (customerId=2)**.
67
+ **Exclusion filters** (an order legitimately waiting is not a bug):
68
+ - `CompassSalesOrders.number NOT LIKE 'MA%'` — MA orders are excluded.
69
+ - **computer-kit orders** (Bundles 187–196) wait for a **2nd PO** before syncing.
70
+ - a **30-minute delay** after the first ODP PO is created.
71
+
72
+ ## Constants & identities (`library/app/client/compass.php`)
73
+ - `App_Client_Compass::VENDOR_ID__OFFICE_DEPOT = 1` — `Vendors.id = 1` = "OFFICE DEPOT".
74
+ - `App_Client_Compass::CUSTOMER_ID__OFFICE_DEPOT = 1` — `Customers.id = 1` = "Office Depot".
75
+ - Compass customer SOs are `customerId = 2`. (Agilant/NetSuite SOs are `customerId = 3`; see the
76
+ lifecycle doc.)
77
+
78
+ ## Diagnosing "this Compass order never reached NetSuite" (support-ticket method)
79
+
80
+ The NetSuite writeback lands on the **downstream ODP SalesOrder (customerId=1)**, so checking the
81
+ wrong record makes every order look "stuck." Use the **join**, never a number match:
82
+
83
+ - **Do NOT judge the Compass SO (customerId=2) by its NetSuite fields.**
84
+ `c_dtTransmittedToNetsuite` / `c_netsuiteInternalSalesOrderId` are **ALWAYS NULL on
85
+ customerId=2 SOs by design** — only the downstream ODP SO (customerId=1) carries them.
86
+ (Verified: of 1,827 customerId=2 SOs since 2026-06-01, **zero** have NetSuite fields set.)
87
+ - **Do NOT search for the downstream ODP SalesOrder by the MITS PO number**
88
+ (e.g. `number LIKE '%50305906%'`). ODP SalesOrders are numbered with **ODP's own** order
89
+ numbers (e.g. `471134097001`), not the MITS PO number. Traverse the bridge instead.
90
+ - **Link tables are directional and distinct:**
91
+ - `SalesOrders_PurchaseOrders` = **SO→PO** (a SalesOrder to the PO it generated).
92
+ - `PurchaseOrders_SalesOrders` = **PO→SO** (a PurchaseOrder to the downstream ODP SalesOrder
93
+ created from ODP's 850).
94
+ - To find the ODP SO for a Compass PO, **traverse `PurchaseOrders_SalesOrders`.**
95
+ - **To confirm a PO actually reached ODP:** check `PurchaseOrders.dtSubmitted` is set **AND**
96
+ that downstream ODP SO(s) exist via the `PurchaseOrders_SalesOrders` join — their existence
97
+ proves ODP received the PO and 850'd back.
98
+ - **`PurchaseOrderNotes` is NOT the record of truth.** That table is **empty across ALL POs** —
99
+ do not read its emptiness as evidence a transmission script never ran. (Transmission evidence
100
+ is `dtSubmitted` + the `Logs_Compass.Record` "transmitted to Vendor" note — see the MITS PO
101
+ transmission feature.)
102
+
103
+ ## Change history
104
+ - 2026-07-01 — Documented the numbered ODP→NetSuite worker-cron pipeline (crons 1/2/edi-1/5),
105
+ the AS2 850 S3 hand-off (`agilant-as2` / `OfficeDepot/`), the customerId=1-vs-2 NetSuite
106
+ writeback rule + cron-5 exclusion filters, and the "order not in NetSuite" diagnostic method
107
+ (join not number-match; empty `PurchaseOrderNotes` is not evidence). Production-support
108
+ investigation; no code change. (rgirish)
109
+
110
+ ## Related docs
111
+ - [Compass Order Lifecycle & Data-Integrity Invariants](order-lifecycle-and-data-integrity.md)
112
+ - [Compass MITS PO Transmission to Vendors](../features/mits-po-transmission-to-vendors.md)
113
+ - [Compass MITS PO → SO Item Linking](../features/mits-po-to-so-item-linking.md)
@@ -15,6 +15,7 @@ related:
15
15
  - clients/compass-usa/features/item-fulfillment-tracking-tableview.md
16
16
  - clients/compass-usa/features/mits-po-transmission-to-vendors.md
17
17
  - 2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
18
+ - clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md
18
19
  ---
19
20
 
20
21
  ## Summary
@@ -2,5 +2,5 @@
2
2
 
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
- | [PC Matic B2B — Startech Ticket Sync](features/startech-ticket-sync.md) | 1.0 | Bidirectional ticket sync between TOGaDesk 1.0 (client 177), TOGA 2.0 (client 21), and Startech (Easeedesk). | worker/crons/toga2/startech/sync_togadesk_startech_pcmaticb2b.php, library/app/api/toga2.php, togadesk/desk/includes/controllers/quickactions.php, togadesk/desk/template/pages/tickets/manage.php, togadesk/desk/includes/functions.php, worker2/Worker/Startech.php, _underscore/Trait/Startech/Ticket.php, dbchanges2/Client_Pcmaticb2b/2026-06-22-pcmaticb2b-enhancements.sql |
5
+ | [PC Matic B2B — Startech Ticket Sync](features/startech-ticket-sync.md) | 1.0 | Bidirectional ticket sync between TOGaDesk 1.0 (client 177), TOGA 2.0 (client 21), and Startech (Easeedesk). | worker/crons/toga2/startech/sync_togadesk_startech_pcmaticb2b.php, worker/crons/toga2/startech/common_import_supporting_records.php, library/app/api/toga2.php, library/app/api/startechticket.php, togadesk/desk/includes/controllers/quickactions.php, togadesk/desk/template/pages/tickets/manage.php, togadesk/desk/includes/functions.php, worker2/Worker/Startech.php, _underscore/Trait/Startech/Ticket.php, dbchanges2/Client_Pcmaticb2b/2026-06-22-pcmaticb2b-enhancements.sql |
6
6
  | [PC Matic B2B Client Profile](profile.md) | 1.0 | PC Matic B2B is a client using TOGaDesk 1.0 for ticket management, with TOGA 2.0 as the data layer and Startech (Easeedesk) as an external ticketing system for | worker/crons/toga2/startech/sync_togadesk_startech_pcmaticb2b.php, togadesk/desk/includes/functions.php |
@@ -5,11 +5,13 @@ project: TOGa
5
5
  client: pcmaticb2b
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-06-23
8
+ updated: 2026-07-06
9
9
  owners: [snaredla]
10
10
  files:
11
11
  - worker/crons/toga2/startech/sync_togadesk_startech_pcmaticb2b.php
12
+ - worker/crons/toga2/startech/common_import_supporting_records.php
12
13
  - library/app/api/toga2.php
14
+ - library/app/api/startechticket.php
13
15
  - togadesk/desk/includes/controllers/quickactions.php
14
16
  - togadesk/desk/template/pages/tickets/manage.php
15
17
  - togadesk/desk/includes/functions.php
@@ -154,6 +156,13 @@ const TOGADESK_TICKET_TYPE__TOGADESK_PCS = 'TOGaDesk-PCS';
154
156
  - **Webhook must set c_escalateToStartech=0** — without this, the `postPost` gate evaluates `!null = true` and would skip Startech for webhook-originated tickets on any subsequent PUT.
155
157
  - **ticketType uses c_startechTicketTypeId dynamically** — stored on `TicketTypes`, not hardcoded in the worker.
156
158
 
159
+ ## Sync Prerequisites & Gotchas (2026-07-06)
160
+
161
+ - **The entitlement gate blocks the 2.0 create.** A TOGaDesk ticket only syncs up to TOGA 2.0 (and can then reach Startech) if its email maps to an existing 2.0 contact **with an active entitlement**. `_Model_Pcmaticb2b_Ticket::prePost` rejects the create otherwise, so the desk ticket never gets a `referenceId` — and the escalate handler shows "Ticket is not linked to TOGa 2.0".
162
+ - **Staff-email collision.** `App_Api_Toga2::syncToga2ContactIntoTogadesk1People` matches `people` by email OR referenceId and **updates in place**. If the email already exists as a staff/admin account (clientid ≠ 177), it links `referenceId` onto that staff row and never creates a client-177 `type='user'` customer. Use a fresh, non-staff email for customer test tickets.
163
+ - **The escalate button applies desk changes unconditionally.** The handler sets dept→337, `escalateToStartech=1`, Ticket Type→"Phone Call Support", and adds history **regardless of 2.0 linkage**. The `c_escalateToStartech=1` PUT (which triggers Startech) fires **only when `referenceId` is already set**; unlinked tickets are left for the sync cron to link — the button does not create the 2.0 ticket on-demand.
164
+
157
165
  ## Change history
158
166
 
159
167
  - 2026-06-23: Initial doc — full bidirectional sync, c_escalateToStartech semantics, ticket type maps, escalate button, DB migration, constants, gotchas
168
+ - 2026-07-06: Added sync prerequisites/gotchas (entitlement gate, staff-email collision) and clarified the escalate button's actual behavior (desk changes always; 2.0 PUT only when linked)
@@ -5,6 +5,6 @@
5
5
  | [Prudential: Dell ASN units PRE/POST interceptor (legacy key + flat tracking)](features/dell-asn-units-interceptor.md) | 2.0 | After the tracking-number bridge migration, the ASN unit route was renamed (`advance-shipping-notice-units` → `advance-shipping-notice-item-units`), so the inhe | _underscore/Model/Prudential/AdvanceShippingNotice.php, dbchanges2/Client_Prudential/2026-06-10 - AsnUnitsInterceptor.sql |
6
6
  | [Prudential: Dell LCH IOP transmissions (LCHRequestV2)](features/dell-lch-iop-transmissions.md) | 1.0 | Outbound order transmissions from the 1.0 worker tier to Dell's Lifecycle Hub (LCH) ITSM integration. | library/app/api/delllch.php, worker/crons/toga2/prudential/transmissions_to_dell.php, worker/crons/toga2/prudential_beta/transmissions_to_dell_india.php, worker/crons/toga2/prudential_beta/transmissions_to_dell_ireland.php, worker/crons/toga2/prudential_beta/transmissions_to_dell_usa.php |
7
7
  | [Prudential: Service Request Regional Address Validation](features/service-request-address-validation.md) | 2.0 | The `prePost` interceptor on `_Model_Prudential_ServiceRequest` validates `deliverToAddress` fields differently depending on which Prudential regional customer | _underscore/Model/Prudential/ServiceRequest.php |
8
- | [Prudential Order Shipped Email — transmit_ordershipped_updates_prudential.php](features/transmit-ordershipped-email.md) | 1.0 | Cron script that transmits "Order Shipped" updates to ServiceNow (RITM) and sends a shipped notification email to the end user. | worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php |
8
+ | [Prudential Order Shipped Email — transmit_ordershipped_updates_prudential.php](features/transmit-ordershipped-email.md) | 1.0 | Cron script that transmits "Order Shipped" updates to ServiceNow (RITM) and sends a shipped notification email to the end user. | worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php, worker/crons/toga2/prudential/transmit_closecomplete_updates_prudential.php, worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php, worker/crons/toga2/prudential_beta/generate_sales_and_purchase_orders_from_service_requests.php |
9
9
  | [Prudential Financial](profile.md) | 2.0 | Prudential is a TOGA client whose device-fulfillment flow is driven by **Dell** via the Dell API (`Client_Prudential.Apis.id = 2`). | |
10
10
  | [Prudential: Dell ASN failed POST backfill replay](workflows/dell-asn-backfill-replay.md) | 2.0 | When Dell ASN POSTs fail in bulk (e.g. | |
@@ -6,10 +6,13 @@ project: Worker
6
6
  client: prudential
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-06-18
9
+ updated: 2026-07-06
10
10
  owners: ["rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php
13
+ - worker/crons/toga2/prudential/transmit_closecomplete_updates_prudential.php
14
+ - worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php
15
+ - worker/crons/toga2/prudential_beta/generate_sales_and_purchase_orders_from_service_requests.php
13
16
  related:
14
17
  - ../profile.md
15
18
  ---
@@ -49,22 +52,68 @@ Iterates the same POs collected in Phase 1. For each:
49
52
  `c_dtTransmittedOrderShippedUpdateToPrudential IS NOT NULL` AND `c_dtEmailSentOrderShipped IS NULL`
50
53
  as "Order Shipped Email Not Sent". This appears daily in the Prudential REQ Processing Report email.
51
54
 
55
+ ## Sibling scripts sharing this pattern
56
+ The same two-phase (transmit-then-email) shape and the same PDF-fetch fragility exist in three
57
+ sibling crons, all fixed the same way this session (2026-07-06):
58
+ - `transmit_closecomplete_updates_prudential.php` — the "Order Closed/Complete" analogue. Phase 1
59
+ stamps `c_dtTransmittedOrderClosedUpdateToPrudential`; Phase 2 stamps `c_dtEmailSentOrderClosed`.
60
+ Same stranding flaw (Phase-1 SELECT filters only on the transmitted column).
61
+ - `generate_sales_and_purchase_orders_from_service_requests.php` (prod and `prudential_beta/`) —
62
+ got the PDF crash-proofing only. Their re-processing is gated by SalesOrder/PurchaseOrder
63
+ **existence**, not a timestamp, so no self-heal was added there (it would risk duplicate SO/PO
64
+ or duplicate emails).
65
+
52
66
  ## Gotchas / known issues
53
67
 
54
- **PDF fetch crash kills entire batch (fixed 2026-06-18):**
55
- The New-type branch fetches 3 PDFs from S3 via `file_get_contents()`. If any S3 URL returns
56
- a non-200 (e.g. 403 Forbidden), PHP emits a warning which `App_Error::handleError` converts
57
- to an `ErrorException` — crashing the script mid-loop. Phase 1 has already stamped
58
- `c_dtTransmittedOrderShippedUpdateToPrudential` for all POs in the batch, but Phase 2 dies
59
- before writing `c_dtEmailSentOrderShipped` for any remaining POs.
68
+ **ROOT CAUSE — `file_get_contents` in a 1.0 cron THROWS, it does not return false (discovered 2026-07-06):**
69
+ The 1.0 framework bootstrap sets `error_reporting(E_ALL)` and `App_Error::handleError`
70
+ (`library/app/error.php` ~line 348) converts **every** PHP warning into an `ErrorException`.
71
+ So `file_get_contents()` on an unreachable / 403 / timed-out S3 URL **throws at the call site**,
72
+ before any `if ($pdf !== false)` guard can ever run. This is why the pre-existing `!== false`
73
+ guards (and the 2026-06-18 guard) never worked: the exception is raised **inside**
74
+ `file_get_contents`, not returned by it. Failures are often transient (the same URL returns 200
75
+ on a later check), which is why the "Order Shipped email not sent" issue kept recurring after
76
+ multiple prior "fixes" that only swapped individual PDF filenames.
77
+
78
+ **Fix — `safeFetchPdf()` + independent per-PDF guards (2026-07-06):**
79
+ Added a file-scope helper `function safeFetchPdf(string $url)` that wraps `file_get_contents`
80
+ in `try/catch (\ErrorException)` and returns `false` on failure — so a failed fetch now really
81
+ does return false. Every PDF fetch was converted to `safeFetchPdf()`, and each
82
+ `file_put_contents` + `$mail->addAttachment` is guarded by its **own independent**
83
+ `if ($pdfGuideX !== false)` — NOT a joint `&&`, so one failed PDF drops only that attachment and
84
+ the other attachments and the email itself still go out. **The email must always send; a
85
+ missing/failed PDF must never crash the loop or block the send.** Applied across all four files.
86
+
87
+ **Two-phase stranding is permanent — recovery MUST be date-bounded (discovered 2026-07-06):**
88
+ Phase 1 stamps the transmitted column and the Phase-1 SELECT filters **only** on that column
89
+ being NULL. So any Phase-2 crash strands that PO's email **forever** (transmitted flag already
90
+ set → never re-selected). Prod inspection found ~8,070 ordershipped + ~11,343 closecomplete POs
91
+ historically stranded (transmitted set, email NULL) — but **most are OLD rows never
92
+ back-populated and are NOT owed an email**; only ~21 shipped / ~60 close were genuinely recent
93
+ (last 7 days). **Any stranding-recovery query MUST be date-bounded**, or you will mass-email
94
+ customers about months-old orders. `prudential_exception_report.php` already flags these
95
+ (transmitted set + email NULL). This session deliberately did NOT auto-recover stranded POs —
96
+ see Scope decision below.
97
+
98
+ **Scope decision — fix-forward only, no automated self-heal (decided 2026-07-06):**
99
+ An earlier iteration built a bounded 7-day self-heal SELECT; the developer decided to keep the
100
+ fix to **forward crash-proofing only** ("just make sure we send emails; if the PDF fails, send
101
+ without attachment"). Already-stranded emails are NOT auto-recovered. The generate scripts got
102
+ crash-proofing only for the existence-gated reason above. Do not "helpfully" re-add an unbounded
103
+ self-heal.
60
104
 
61
- Fix applied: added `!== false` guard around all three `file_get_contents` calls in the
62
- New branch (matching the guard the non-New branch already had). If any PDF fetch fails,
63
- the email sends without attachments rather than crashing.
105
+ **Wrong Mac PDF URL (partially fixed 2026-06-18, fully fixed 2026-07-06):**
106
+ `PrudentialMacSetupGuide_15.6_v4.pdf` (403 since ~May 5 2026) should be
107
+ `PrudentialMacSetupGuide.pdf`. The 2026-06-18 fix corrected it only in `ordershipped`; the stale
108
+ URL was still live in 3 of the sibling files and was corrected this session.
64
109
 
65
- **Wrong Mac PDF URL (fixed 2026-06-18):**
66
- The New branch was pointing to `PrudentialMacSetupGuide_15.6_v4.pdf` (403 since ~May 5 2026)
67
- instead of `PrudentialMacSetupGuide.pdf` (the working URL used by the non-New branch).
110
+ **Pre-existing issues surfaced this session (NOT fixed — awareness only):**
111
+ - SQL-injection risk: unescaped `$reqNumber` concatenated into a `LIKE '%...%'` query in the
112
+ Phase-2 body of both `transmit_ordershipped` and `transmit_closecomplete`.
113
+ - `transmit_closecomplete` calls `App_Database::escapeString()` (~line 158) which does not exist
114
+ on `App_Database` (only `sqlEscape` does) — latent error in the `createAssetAndRetryPut` path.
115
+ - Separate inbound issue: Dell SR re-POST hitting `ServiceRequests.number` UNIQUE constraint →
116
+ API 400 "Duplicate entry SR129013" (REQ4444748). Not part of the email fix.
68
117
 
69
118
  **Re-queueing stuck POs:**
70
119
  If `c_dtEmailSentOrderShipped` is NULL but `c_dtTransmittedOrderShippedUpdateToPrudential`
@@ -83,4 +132,5 @@ stamp (crash hits the next iteration). With multiple New-type POs, a crash on PO
83
132
  all subsequent POs in the same run.
84
133
 
85
134
  ## Change history
86
- - 2026-06-18 — Fixed 403 crash: corrected Mac PDF URL (`_15.6_v4` → `PrudentialMacSetupGuide.pdf`) and added `false` guard on all S3 fetches in New branch (rgirish)
135
+ - 2026-07-06 — Found the real root cause: `file_get_contents` throws (E_ALL→ErrorException) so the `!== false` guards (incl. the 2026-06-18 one) were never effective. Added `safeFetchPdf()` (try/catch ErrorException → false) + independent per-PDF guards; email now always sends even if a PDF fails. Extended the same fix to `transmit_closecomplete` and both `generate_sales_and_purchase_orders_from_service_requests` scripts, and finished the stale Mac PDF URL fix in the 3 files the 2026-06-18 change missed. Documented the permanent two-phase stranding flaw and the date-bounded-recovery requirement; scope kept to fix-forward (no auto self-heal) by developer decision (rgirish)
136
+ - 2026-06-18 — Fixed 403 crash: corrected Mac PDF URL (`_15.6_v4` → `PrudentialMacSetupGuide.pdf`) and added a `!== false` guard on all S3 fetches in New branch. NOTE (2026-07-06): this guard was ineffective — the exception is thrown inside `file_get_contents`, not returned (rgirish)
@@ -12,7 +12,7 @@ project: _Underscore
12
12
  client: prudential
13
13
  type: profile
14
14
  status: active
15
- updated: 2026-06-30
15
+ updated: 2026-07-06
16
16
  owners: ["jcardinal", "rgirish"]
17
17
  files: []
18
18
  related:
@@ -45,10 +45,15 @@ order-status transmissions.
45
45
  PRE/POST translation that keeps their feed working after the tracking-number bridge migration.
46
46
  - Service request region is resolved from `Customers.name` string (`'USA'`/`'India'`/`'Ireland'`).
47
47
  Renaming those customer records in the DB would silently break regional validation routing.
48
- - `transmit_ordershipped_updates_prudential.php` runs in two phases. Phase 1 stamps
49
- `c_dtTransmittedOrderShippedUpdateToPrudential` immediately; Phase 2 sends the email and stamps
50
- `c_dtEmailSentOrderShipped`. Any exception in Phase 2 leaves `c_dtEmailSentOrderShipped` NULL
51
- and the PO is never re-processed automatically. See the exception report and the transmit-ordershipped-email feature doc.
48
+ - `transmit_ordershipped_updates_prudential.php` **and** `transmit_closecomplete_updates_prudential.php`
49
+ run in two phases. Phase 1 stamps the transmitted column immediately
50
+ (`c_dtTransmittedOrderShipped…` / `c_dtTransmittedOrderClosed…`); Phase 2 sends the email and
51
+ stamps a separate column (`c_dtEmailSentOrderShipped` / `c_dtEmailSentOrderClosed`). The Phase-1
52
+ SELECT filters only on the transmitted column, so any Phase-2 crash strands that PO's email
53
+ **forever** — and any recovery query MUST be date-bounded (most historically-stranded rows are
54
+ old and NOT owed an email). Note: PDF fetches via `file_get_contents` in these 1.0 crons **throw**
55
+ (E_ALL→ErrorException), so `!== false` guards do not work — use the `safeFetchPdf()` pattern. See
56
+ the exception report and the transmit-ordershipped-email feature doc.
52
57
 
53
58
  ## Related docs
54
59
  - Dell ASN units interceptor.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.269",
3
+ "version": "1.0.271",
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",