toga-ai 1.0.773 → 1.0.775
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/webhook/INDEX.md +1 -1
- package/knowledge/1.0/apps/webhook/features/netsuite-clickup-opportunity-sync.md +50 -15
- package/knowledge/1.0/apps/worker/INDEX.md +2 -2
- package/knowledge/1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md +156 -11
- package/knowledge/1.0/apps/worker/features/forecast2-netsuite-reconciliation.md +62 -2
- package/knowledge/1.0/apps/worker/workflows/tracing-a-worker-cron-run-in-production.md +22 -1
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -1
- package/knowledge/2.0/apps/_underscore/architecture.md +18 -3
- package/knowledge/2.0/apps/_underscore/features/apirequest-json-content-type.md +39 -1
- package/knowledge/2.0/apps/_underscore/features/forecast-sale-import.md +23 -2
- package/knowledge/2.0/apps/_underscore/features/netsuite-rest-client.md +28 -2
- package/knowledge/2.0/apps/api2/features/v2-deadlock-retry.md +35 -2
- package/knowledge/2.0/apps/dbchanges2/architecture.md +3 -2
- package/knowledge/2.0/apps/worker2/INDEX.md +4 -2
- package/knowledge/2.0/apps/worker2/features/all-client-email-queue-monitor.md +15 -2
- package/knowledge/2.0/apps/worker2/features/clickup-project-routing.md +85 -3
- package/knowledge/2.0/apps/worker2/features/netsuite-clickup-opportunity-bidirectional-design.md +155 -0
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +147 -25
- package/knowledge/2.0/apps/worker2/features/oneuptime-worker2-monitoring.md +68 -6
- package/knowledge/2.0/apps/worker2/features/tracking-status-refresh.md +45 -1
- package/knowledge/2.0/apps/worker2/workflows/clickup-credential-rotation.md +142 -0
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/compass-canada/INDEX.md +2 -2
- package/knowledge/clients/compass-canada/features/french-order-email-localization.md +2 -2
- package/knowledge/clients/compass-canada/features/grand-and-toy-asn-import.md +2 -2
- package/knowledge/clients/compass-canada/features/order-fulfillment-status-per-line.md +1 -1
- package/knowledge/clients/compass-usa/INDEX.md +1 -0
- package/knowledge/clients/compass-usa/features/contact-email-resolution.md +149 -0
- package/knowledge/clients/compass-usa/features/order-fulfillment-status-per-line.md +1 -1
- package/package.json +1 -1
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
|
-
| [NetSuite → ClickUp Opportunity Sync](features/netsuite-clickup-opportunity-sync.md) |
|
|
5
|
+
| [NetSuite → ClickUp Opportunity Sync](features/netsuite-clickup-opportunity-sync.md) | > **⚠ CORRECTED 2026-09-04 — parts of this doc were factually wrong and actively misleading.** > Two claims below have been retracted in place: that > `worker2/ | webhook/_/webhook/netsuite.php, webhook/api.php, api2/Webhook/Netsuite.php, worker2/Worker/Netsuite.php, api2/Controller/Index.php |
|
|
@@ -6,17 +6,33 @@ project: Webhook
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["dfranks"]
|
|
9
|
+
updated: 2026-09-04
|
|
10
|
+
owners: ["dfranks", "ajean"]
|
|
11
11
|
files:
|
|
12
12
|
- webhook/_/webhook/netsuite.php
|
|
13
13
|
- webhook/api.php
|
|
14
14
|
- api2/Webhook/Netsuite.php
|
|
15
15
|
- worker2/Worker/Netsuite.php
|
|
16
16
|
- api2/Controller/Index.php
|
|
17
|
-
related:
|
|
17
|
+
related:
|
|
18
|
+
- ../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md
|
|
18
19
|
---
|
|
19
20
|
|
|
21
|
+
> **⚠ CORRECTED 2026-09-04 — parts of this doc were factually wrong and actively misleading.**
|
|
22
|
+
> Two claims below have been retracted in place: that
|
|
23
|
+
> `worker2/Worker/Netsuite/Opportunity.php` and `maybeCreateClickupTask()` **do not exist**, and that
|
|
24
|
+
> the integration has **"no dedup of any kind."** Both files exist and are git-active, and the 2.0
|
|
25
|
+
> path carries an **atomic DB claim** on `Forecast.Opportunities.clickupTaskId` plus folder-wide
|
|
26
|
+
> dedup. Those two claims are why a later session opened with a contradiction about which app owns
|
|
27
|
+
> the receiver. Canonical detail:
|
|
28
|
+
> [NetSuite ↔ ClickUp Opportunity Sync (worker2)](../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md).
|
|
29
|
+
>
|
|
30
|
+
> **Scope caveat on everything else here:** the 1.0 `webhook` repo is **not checked out** on the
|
|
31
|
+
> machine used for the 2026-09-04 review and could not be found anywhere on disk, so the claims in
|
|
32
|
+
> this doc about the *live 1.0 receiver* are **unverified as of that date**. The NetSuite-side
|
|
33
|
+
> content (scheduled script **2412**, UserEvent **2493**, the `OPP <id>` stub root cause and its fix)
|
|
34
|
+
> was **not** in question and stands.
|
|
35
|
+
|
|
20
36
|
## Summary
|
|
21
37
|
NetSuite Opportunities are pushed into the ClickUp **Opportunities** list (list id `901111987449`,
|
|
22
38
|
in the Opportunity/RFP Hub space `90113928591`) as tasks named `<oppNumber> — <customerNumber>
|
|
@@ -74,23 +90,32 @@ the assignment fires 2493 in `edit` (full-record) context → proper name (see G
|
|
|
74
90
|
None — single internal Agilant/True integration.
|
|
75
91
|
|
|
76
92
|
## Gotchas / known issues
|
|
77
|
-
- **Create-only
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
- **Create-only with no dedup *in this 1.0 handler* — but the claim "NO dedup of any kind" was
|
|
94
|
+
WRONG and is retracted (2026-09-04).** As originally written (2026-07-17) this bullet asserted the
|
|
95
|
+
integration as a whole had "no idempotency check whatsoever." That is false at the platform level:
|
|
96
|
+
the **2.0** path (`worker2/Worker/Netsuite/Opportunity.php`) carries a **race-safe atomic claim** —
|
|
97
|
+
a guarded `UPDATE Opportunities SET clickupTaskId=… WHERE … AND clickupTaskId IS NULL` — plus
|
|
98
|
+
folder-wide dedup by `Opportunity #`. What remains true, scoped correctly: **`Webhook_NetSuite::post()`
|
|
99
|
+
in the 1.0 `webhook` repo** json-decodes the payload and calls `createTask()`, which POSTs a new
|
|
100
|
+
task **unconditionally on every delivery** (writing link `db_client`), so *that* handler
|
|
101
|
+
double-creates on any repeat or concurrent delivery. Unverified since 2026-09-04 (repo not checked
|
|
102
|
+
out).
|
|
82
103
|
- **Live payload carries no immutable opportunity identifier (confirmed 2026-07-17).** The
|
|
83
104
|
inbound body has **only**: `name`, `description`, `presalesLead`, `salesRepText`, `token`.
|
|
84
105
|
It has **no NetSuite opportunity internal id and no opportunity/customer number**, so there
|
|
85
106
|
is no stable key available in this handler to dedup on today. Any dedup fix must first get
|
|
86
107
|
NetSuite (script 2493) to include an immutable id in the payload, or derive one another way.
|
|
87
|
-
- **Where the swapped Opp#/Cust# mapping
|
|
88
|
-
`OPPORTUNITY_NUMBER ← customerNumber` /
|
|
89
|
-
**dormant 2.0 port**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
**no** `maybeCreateClickupTask()` method anywhere
|
|
93
|
-
|
|
108
|
+
- **Where the swapped Opp#/Cust# mapping lives — with the "these files don't exist" claim
|
|
109
|
+
RETRACTED (2026-09-04).** The `OPPORTUNITY_NUMBER ← customerNumber` /
|
|
110
|
+
`CUSTOMER_NUMBER ← opportunityNumber` swap is in the **dormant 2.0 port**
|
|
111
|
+
`worker2/Worker/Netsuite.php` (class `_Worker_NetSuite`, ~lines 116–121). That part stands.
|
|
112
|
+
**What was wrong:** this bullet claimed there is "**no** `worker2/Worker/Netsuite/Opportunity.php`
|
|
113
|
+
and **no** `maybeCreateClickupTask()` method anywhere," and that plans citing them were citing
|
|
114
|
+
fictional files. **Both exist and are git-active.** `_Worker_Netsuite_Opportunity` is the current
|
|
115
|
+
2.0 handler — it does the Forecast upsert, holds the atomic `clickupTaskId` claim, and
|
|
116
|
+
`maybeCreateClickupTask()` is its ClickUp entry point. It also **fixed** the swap (2026-06-16).
|
|
117
|
+
Do not use this bullet to conclude the 2.0 path is vapour; read
|
|
118
|
+
[the worker2 doc](../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md).
|
|
94
119
|
- **`OPP <internalId>` degenerate tasks (root-caused & confirmed 2026-06-15).** A task named just
|
|
95
120
|
`OPP 7147699` with no description/fields. The name is built by NetSuite and relayed verbatim;
|
|
96
121
|
the opps are **fully populated** in NetSuite (so it's not missing data, not a TOGA bug). Cause:
|
|
@@ -148,6 +173,16 @@ Done 2026-06-15 for opps 7147699/7147698/7147472/6382879 → 4 proper tasks crea
|
|
|
148
173
|
(task ids `868k0tr41`, `868k0tr16`, `868k0rwtp`, `868k0h44m`) were archived in ClickUp afterward.
|
|
149
174
|
|
|
150
175
|
## Change history
|
|
176
|
+
- 2026-09-04 — **Corrected two factually wrong claims (retracted in place; no code changed).** This
|
|
177
|
+
doc asserted that `worker2/Worker/Netsuite/Opportunity.php` and `maybeCreateClickupTask()` "do not
|
|
178
|
+
exist" and that the integration has "no dedup of any kind." Both files exist and are git-active,
|
|
179
|
+
and the 2.0 path carries an **atomic DB claim** on `Forecast.Opportunities.clickupTaskId` plus
|
|
180
|
+
folder-wide dedup; the create-only/no-dedup statement is true only of the **1.0 `webhook` handler**
|
|
181
|
+
and has been rescoped to it. These two claims were the reason a later session opened with a
|
|
182
|
+
contradiction about which app owns the receiver. Added a scope caveat: the 1.0 `webhook` repo is
|
|
183
|
+
**not checked out** on the reviewing machine and could not be found on disk, so this doc's claims
|
|
184
|
+
about the live 1.0 receiver are **unverified as of 2026-09-04**; the NetSuite-side script 2412/2493
|
|
185
|
+
content was never in question and stands. (ajean)
|
|
151
186
|
- 2026-07-17 — Confirmed against live code: `createTask()` POSTs to list `901111987449`
|
|
152
187
|
**unconditionally with no dedup** (double-creates on any repeat/concurrent delivery); the
|
|
153
188
|
live payload carries only `name`/`description`/`presalesLead`/`salesRepText`/`token` — **no
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
| [Canon received-PO → NetSuite sales order sync](features/canon-received-po-netsuite-order-sync.md) | A 1.0 `worker` cron that reads Canon customer purchase orders out of the **Vision** databases and creates the matching **sales order in NetSuite**. | worker/crons/sync/sync_received_po.php, library/netsuitetoolkit/NSPHPClient.php |
|
|
7
7
|
| [Compass MA Sales Order Exception Report](features/compass-ma-sales-order-exception-report.md) | A worker cron that emails operations the "Compass Refresh Exception Report" — Compass `MA%` sales orders whose corresponding Office Depot (ODP) sales order has | worker/crons/toga2/compass/workflow/7_generate_ma_sales_order_exception_report.php, worker/schedules/cron.worker.sync.json |
|
|
8
8
|
| [Compass Manager Approval Reminder Emails (1.0 worker crons)](features/compass-manager-approval-reminder-emails.md) | Two 1.0 worker crons nag approvers about sales orders still waiting on a decision — one per Compass tenant. | worker/crons/toga2/compass/compass_email_reminders.php, worker/crons/toga2/compasscanada/compass_email_reminders.php, worker/schedules/cron.worker.sync.json, worker1.5/crons/toga2/compass/compass_email_reminders.php, worker1.5/schedules/cron.worker.json |
|
|
9
|
-
| [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compass/send_delivered_email.php, worker/crons/toga2/compasscanada/
|
|
9
|
+
| [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compass/send_delivered_email.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, library/app/client/compasscanada.php, worker/schedules/cron.worker.sync.json, worker2/Worker/Monitor/Compass.php, dbchanges2/Client_Compass/2026-09-03 - compass_intransit_email_backlog_backfill.sql |
|
|
10
10
|
| [Elite TOGA 2.0 → TOGaDeskSupport Standalone Attachment Sync](features/elite-togadesk-attachment-sync.md) | `sync_togadesk_elite_attachments.php` is a standalone cron (every 5 minutes) that syncs file attachments from TOGA 2.0 into TOGaDeskSupport for Elite. | worker/crons/toga2/elite/sync_togadesk_elite_attachments.php, worker/crons/toga2/elite/test_sync_togadesk_elite_attachments.php |
|
|
11
|
-
| [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/checker.php, worker2/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, tools/bin/forecast/fixer.php, tools/bin/forecast/checker.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/reconcile_drift_2023plus.php, test/@dave/probe_invoice_gap_2026.php, test/@dave/probe_creditmemo_gap_detail.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
|
|
11
|
+
| [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/checker.php, worker2/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, tools/bin/forecast/fixer.php, tools/bin/forecast/checker.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/reconcile_drift_2023plus.php, test/@dave/probe_invoice_gap_2026.php, test/@dave/probe_creditmemo_gap_detail.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_opportunities.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
|
|
12
12
|
| [Forecast2 Supporting-Records Nightly Import (1.0 cron) — Customers & ConsolidatedCustomers](features/forecast2-supporting-records-import.md) | The nightly **1.0** pull that keeps the Forecast2 lookup/dimension tables (`Forecast.Accounts`, `Classifications`, `Customers`, `ConsolidatedCustomers`, `Employ | worker/crons/toga2/forecast2/import_supporting_records.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/schedules/cron.worker.infrastructure.json, worker/.ebextensions/009_setup_phpini.config, worker/crons/sync/netsuite/netsuite_customers.php, library/app/api/netsuite/rest.php, library/app/model/forecast2/customer.php, library/app/model/forecast2/consolidatedcustomer.php |
|
|
13
13
|
| [NetSuite Sales Order Sales Rep Sourcing (Staples & ODP EDI orders)](features/netsuite-sales-order-sales-rep-sourcing.md) | How the **sales rep** on a NetSuite Sales Order is determined for the two 1.0 `worker` EDI order-creation integrations (Staples cXML and Compass/ODP EDI). | worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, worker/crons/sync/staples/sync_staples_cxml.php, test/@Mark/NetSuite/TRUE_80451_customer_salesrep_diag.php |
|
|
14
14
|
| [NetSuite → TOGa Supply Per-Client Sync (thin wrappers)](features/netsuite-togasupply-per-client-sync.md) | Syncs NetSuite transactions (sales orders, purchase orders, invoices, item receipts, item fulfillments, inventory adjustments) into each TOGa Supply (2.0) clien | worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_canon.php, worker/crons/toga2/netsuite/sync_togasupply_elite.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql, library/app/api/toga2.php, library/app/api/netsuite/rest.php, library/app/query.php, library/app/database.php, library/app/framework.php, library/app/systemmonitor/netsuiteintegration.php, dbchanges2/Client/2026-09-02b - NetsuiteSyncCursorRename.sql, test/@srija/Elite Testing/Service Requests/test_sync_togasupply_elite_section.php, test/@srija/Elite Testing/Service Requests/test_diagnose_togasupply_elite.php |
|
|
@@ -6,18 +6,23 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-04
|
|
10
10
|
owners: ["bala"]
|
|
11
11
|
files:
|
|
12
12
|
- worker/crons/toga2/compass/update_salesorder_status_from_odp.php
|
|
13
13
|
- worker/crons/toga2/compass/send_delivered_email.php
|
|
14
|
-
- worker/crons/toga2/compasscanada/
|
|
15
|
-
- worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php
|
|
14
|
+
- worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php
|
|
16
15
|
- worker/crons/toga2/compasscanada/send_delivered_email.php
|
|
17
16
|
- worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php
|
|
18
17
|
- worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php
|
|
19
18
|
- library/app/client/compasscanada.php
|
|
19
|
+
- worker/schedules/cron.worker.sync.json
|
|
20
|
+
- worker2/Worker/Monitor/Compass.php
|
|
21
|
+
- dbchanges2/Client_Compass/2026-09-03 - compass_intransit_email_backlog_backfill.sql
|
|
20
22
|
related:
|
|
23
|
+
- ../../../clients/compass-usa/features/contact-email-resolution.md
|
|
24
|
+
- ../../../2.0/apps/worker2/features/tracking-status-refresh.md
|
|
25
|
+
- ../../../2.0/apps/worker2/features/oneuptime-worker2-monitoring.md
|
|
21
26
|
- ../../../clients/compass-canada/features/french-order-email-localization.md
|
|
22
27
|
- ../../../clients/compass-usa/features/order-fulfillment-status-per-line.md
|
|
23
28
|
- ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
|
|
@@ -34,18 +39,28 @@ and earlier packages). The dynamic HTML is injected into a stored `EmailTemplate
|
|
|
34
39
|
|
|
35
40
|
## Key files / entry points
|
|
36
41
|
|
|
37
|
-
- `worker/crons/toga2/compass/update_salesorder_status_from_odp.php` — Compass USA prod cron
|
|
38
|
-
|
|
39
|
-
—
|
|
42
|
+
- `worker/crons/toga2/compass/update_salesorder_status_from_odp.php` — Compass USA prod cron,
|
|
43
|
+
registered in `worker/schedules/cron.worker.sync.json` as **"Update Compass SalesOrder Status
|
|
44
|
+
From ODP CXML"**, schedule **`0 */4 * * *`** — it fires at 00/04/08/12/16/20 **US/Central**
|
|
45
|
+
(the prod DB timezone). Every threshold below (batch cap, staleness, monitor alarms) is
|
|
46
|
+
calibrated to that 4-hour cadence.
|
|
47
|
+
- `worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php` — Compass Canada
|
|
48
|
+
prod cron, registered as "Update CompassCanada SalesOrder Status From G&T CXML".
|
|
49
|
+
**⚠ This filename contains `&` and therefore cannot run — see the gotcha below.** It was
|
|
50
|
+
renamed from `update_salesorder_status_from_odp.php` on 2026-09-03; the duplicate
|
|
51
|
+
`workflow/3_update_salesorder_status_from_grand_and_toy.php` was **deleted** in the same pass and
|
|
52
|
+
its stale `schedules/cron.worker.sync.json` entry removed.
|
|
40
53
|
- Shared helper set in each cron: `getOrderFulfillmentData`, `buildItemRowsHtml`,
|
|
41
54
|
`buildSectionHeaderHtml`, `buildPackageBlockHtml`, `buildTrackingUrl`, `buildOrderItemsHtml`.
|
|
42
55
|
|
|
43
56
|
## How it works
|
|
44
57
|
|
|
45
58
|
1. **Driving query** finds shipped sales orders and their tracking numbers (`computedStatusSlug
|
|
46
|
-
= 'shipped'`, `c_dtInTransitEmailSent IS NULL`, date
|
|
47
|
-
(`AdvanceShippingNoticeItemUnits_TrackingNumbers`), one row per `TrackingNumbers.number
|
|
48
|
-
|
|
59
|
+
= 'shipped'`, `c_dtInTransitEmailSent IS NULL`, rolling date window) via the ASN chain
|
|
60
|
+
(`AdvanceShippingNoticeItemUnits_TrackingNumbers`), one row per `TrackingNumbers.number`,
|
|
61
|
+
**newest first and capped** — see *Draining the candidate set* below.
|
|
62
|
+
2. Per tracking number: skip if the shipment is already finished — checked first against the
|
|
63
|
+
stored `TrackingNumbers.status` (free), and only then with a live carrier call.
|
|
49
64
|
3. **`getOrderFulfillmentData`** returns the order's packages (grouped by tracking number,
|
|
50
65
|
ordered by `dtCreated`) and ordered items, sourced from the **`ItemFulfillmentItems`** line
|
|
51
66
|
bridge (`ItemFulfillmentItems` → `ItemFulfillments` (scoped to the buyer SO) →
|
|
@@ -58,6 +73,73 @@ and earlier packages). The dynamic HTML is injected into a stored `EmailTemplate
|
|
|
58
73
|
`App_Api_Toga2::send(..., 'POST', '/email-templates/sendEmail', $payload, [], true)`.
|
|
59
74
|
6. On a successful (non-throwing) send, set `c_dtInTransitEmailSent = NOW()`.
|
|
60
75
|
|
|
76
|
+
### Draining the candidate set: two-pass, newest-first, bounded
|
|
77
|
+
|
|
78
|
+
**This is the load-bearing part of the cron. Getting it wrong took Compass USA in-transit emails to
|
|
79
|
+
near zero for a week** (2026-08-27 → 2026-09-03: 130–190 a day before, then 7 / 1 / 6 / 0 / 1).
|
|
80
|
+
Customers were not told their order had shipped.
|
|
81
|
+
|
|
82
|
+
The original shape was: select **every** unstamped `TrackingNumbers` row since a hardcoded
|
|
83
|
+
`'2026-06-15'`, then call UPS/FedEx **live, one row at a time**. When a shipment turned out to be
|
|
84
|
+
already delivered the loop `continue`d **without stamping `c_dtInTransitEmailSent`** — so the same
|
|
85
|
+
row came back on the next run, forever. The candidate set grew to **2,379** and the run hit its
|
|
86
|
+
`App_Framework::cronInitialization(3600)` one-hour limit while still inside the June rows. It never
|
|
87
|
+
reached the day's new ASNs. Nothing errored; the cron just ran out of time on every tick.
|
|
88
|
+
|
|
89
|
+
Four changes fix it, and they are the pattern to copy for any *"poll an external API per row, then
|
|
90
|
+
mark the row done"* cron:
|
|
91
|
+
|
|
92
|
+
1. **Two passes.** Pass 1 walks the candidates and **stamps** every already-finished row so it
|
|
93
|
+
retires from the candidate set permanently. Pass 2 sends. A row can never again be examined
|
|
94
|
+
forever without being retired.
|
|
95
|
+
2. **Newest first, hard cap.** `ORDER BY MAX(TrackingNumbers.dtCreated) DESC` +
|
|
96
|
+
`LIMIT IN_TRANSIT_EMAIL_BATCH_SIZE` (**400**). Today's shipments are served before any backlog,
|
|
97
|
+
so a backlog can delay old mail but can never starve new mail.
|
|
98
|
+
3. **Rolling window, not a fixed date.** `IN_TRANSIT_EMAIL_LOOKBACK_DAYS = 14` →
|
|
99
|
+
`dtCreated >= DATE_SUB(NOW(), INTERVAL 14 DAY)`. A hardcoded cutoff date guarantees an
|
|
100
|
+
ever-growing scan; a rolling window is self-limiting.
|
|
101
|
+
4. **Trust the stored status before paying for a carrier call.** `isTrackingStatusFinal(?string)`
|
|
102
|
+
short-circuits when `TrackingNumbers.status` is already `DELIVERED` or `RETURNED_TO_SENDER`
|
|
103
|
+
(`TRACKING_FINAL_STATUSES`). worker2's
|
|
104
|
+
[centralized tracking-status refresh](../../../2.0/apps/worker2/features/tracking-status-refresh.md)
|
|
105
|
+
keeps that column current for every client, so this 1.0 cron should not re-ask the carrier about
|
|
106
|
+
a finished shipment.
|
|
107
|
+
|
|
108
|
+
### Resolving the recipient: a `Users` row is NOT required (USA)
|
|
109
|
+
|
|
110
|
+
The To address originally came only from `Users.email`, via `INNER JOIN Users ON Users.contactId =
|
|
111
|
+
…`. **EDI-created MR orders never get a `Users` row**, so those orders were silently absent from
|
|
112
|
+
the driving query — 12 MR orders with shipped parcels were never emailed and never even showed up
|
|
113
|
+
as a backlog.
|
|
114
|
+
|
|
115
|
+
The USA cron now resolves the address in this precedence:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
LEFT OUTER JOIN Users
|
|
119
|
+
ON Users.contactId = ShippedSalesOrders.contactId
|
|
120
|
+
AND Users.email IS NOT NULL AND Users.email <> ''
|
|
121
|
+
INNER JOIN Contacts
|
|
122
|
+
ON Contacts.id = ShippedSalesOrders.contactId
|
|
123
|
+
LEFT OUTER JOIN ContactEmailAddresses
|
|
124
|
+
ON ContactEmailAddresses.id = Contacts.primaryContactEmailAddressId
|
|
125
|
+
AND ContactEmailAddresses.contactId = Contacts.id # ownership guard
|
|
126
|
+
…
|
|
127
|
+
IFNULL(Users.email, ContactEmailAddresses.emailAddress) AS contactEmail
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The same `IFNULL` resolves the display name, and the `WHERE` requires the resolved address to be
|
|
131
|
+
non-empty.
|
|
132
|
+
|
|
133
|
+
**The `ContactEmailAddresses.contactId = Contacts.id` predicate is not optional.** About 14% of
|
|
134
|
+
Compass contacts point at an email row owned by a *different* contact, so without it the cron mails
|
|
135
|
+
the wrong person — see
|
|
136
|
+
[Compass contact email resolution](../../../clients/compass-usa/features/contact-email-resolution.md).
|
|
137
|
+
|
|
138
|
+
**Regression-checked, not assumed.** Over 60 days and **4,419 SA tracking rows** the old and new
|
|
139
|
+
queries both select **4,416**: 0 newly included, 0 lost, resolved address and name identical on all
|
|
140
|
+
4,416. The `Users` address always wins because it is first in the `IFNULL`, so the change is
|
|
141
|
+
strictly additive — it only reaches orders the old query could not see at all.
|
|
142
|
+
|
|
61
143
|
### Tracking-URL building (`buildTrackingUrl`)
|
|
62
144
|
|
|
63
145
|
The "Track Shipment" button and each per-package link resolve in this precedence:
|
|
@@ -100,7 +182,22 @@ the tracking number as emailed (it retries next run).
|
|
|
100
182
|
|
|
101
183
|
## Client variations
|
|
102
184
|
|
|
103
|
-
- **Compass USA** — English only; `update_salesorder_status_from_odp.php`.
|
|
185
|
+
- **Compass USA** — English only; `update_salesorder_status_from_odp.php`. Has the full
|
|
186
|
+
2026-09-03 hardening: two-pass drain, 400-row cap, 14-day window, `isTrackingStatusFinal()`,
|
|
187
|
+
**and** the `Users` → `Contacts`/`ContactEmailAddresses` recipient fallback with the ownership
|
|
188
|
+
guard.
|
|
189
|
+
- **⚠ Canada is only PARTLY at parity (verified 2026-09-04).**
|
|
190
|
+
`compasscanada/update_salesorder_status_from_g&t.php` has the batching changes
|
|
191
|
+
(`IN_TRANSIT_EMAIL_BATCH_SIZE = 400`, `IN_TRANSIT_EMAIL_LOOKBACK_DAYS = 14`,
|
|
192
|
+
`ORDER BY MAX(TrackingNumbers.dtCreated) DESC`, `isTrackingStatusFinal()`) but **still resolves
|
|
193
|
+
the recipient with `INNER JOIN Users … Users.email AS contactEmail`** — no `Contacts` /
|
|
194
|
+
`ContactEmailAddresses` fallback and no ownership guard. Canada therefore still cannot email a
|
|
195
|
+
contact that has no `Users` row. Port the recipient block if Canada ever gets EDI-created MR
|
|
196
|
+
orders.
|
|
197
|
+
- **Canada keeps its own retry behaviour on a carrier throw.** It leaves the row **unstamped** when
|
|
198
|
+
the carrier lookup throws, so the next run retries it; only rows confirmed *final* are stamped.
|
|
199
|
+
That is deliberate and must survive any future parity pass — stamping on a throw would silently
|
|
200
|
+
drop the email.
|
|
104
201
|
- **Compass Canada** — EN/FR by `UserGlobalSettings.settingId = 2` (FR when the value starts
|
|
105
202
|
with `fr`); French section labels and a French (HTML-entity) status message; uses
|
|
106
203
|
`htmlentities` in the section header builder and a 7-param French-aware `buildPackageBlockHtml`.
|
|
@@ -113,6 +210,33 @@ the tracking number as emailed (it retries next run).
|
|
|
113
210
|
|
|
114
211
|
## Gotchas / known issues
|
|
115
212
|
|
|
213
|
+
- **⚠ A cron filename containing `&` cannot run at all.** `worker/ebs/cron.worker.php:41` builds
|
|
214
|
+
the crontab command by plain unquoted concatenation
|
|
215
|
+
(`'php /var/www/html/crons/' . $cron->cron`), so cron reads `&` as *"background this and start a
|
|
216
|
+
new command"*: `update_salesorder_status_from_g&t.php` executes as
|
|
217
|
+
`…/update_salesorder_status_from_g` plus a stray `t.php`, and the real script never runs —
|
|
218
|
+
silently, on every tick. **The Canada cron is in this state as of 2026-09-04 and must be renamed
|
|
219
|
+
(e.g. `update_salesorder_status_from_gt.php`) with `schedules/cron.worker.sync.json` repointed.**
|
|
220
|
+
Never put `&` (or any shell metacharacter) in a cron filename. See
|
|
221
|
+
[tracing a 1.0 worker cron run](../workflows/tracing-a-worker-cron-run-in-production.md).
|
|
222
|
+
- **⚠ Invoice tracking numbers are NOT in scope and must never be counted as in-transit
|
|
223
|
+
backlog.** `POST /v2/invoice-tracking-numbers` creates `TrackingNumbers` rows bridged through the
|
|
224
|
+
**`InvoiceTrackingNumbers`** table — never the ASN chain. On 2026-09-03 alone, 98 were created
|
|
225
|
+
(11:15–11:40) and **zero** of them touch `AdvanceShippingNoticeItemUnits_TrackingNumbers`.
|
|
226
|
+
Anything that walks the ASN unit bridge (this cron, and the OneUptime queue monitor) correctly
|
|
227
|
+
ignores them. Their shape is distinctive: **`shippingCarrierId` set, `shippingMethodId` NULL**.
|
|
228
|
+
A naive `TrackingNumbers WHERE c_dtInTransitEmailSent IS NULL` count will read these as a backlog
|
|
229
|
+
that never drains.
|
|
230
|
+
- **Clear an in-transit backlog by STAMPING, not by emailing — and refresh statuses first.**
|
|
231
|
+
Decided 2026-09-03 while draining the outage backlog
|
|
232
|
+
(`dbchanges2/Client_Compass/2026-09-03 - compass_intransit_email_backlog_backfill.sql`):
|
|
233
|
+
`c_dtInTransitEmailSent` was stamped on **64,132** rows older than the 14-day window and **421**
|
|
234
|
+
in-window rows already on a final status, leaving **68** genuinely still moving. Sending an
|
|
235
|
+
"in transit" email for a June order that arrived two months ago confuses the customer, so old
|
|
236
|
+
rows retire silently. **Ordering matters:** let worker2 `Sync/TrackingNumbers` refresh any
|
|
237
|
+
recently-unparked rows **before** running the backfill, otherwise a row with a stale status gets
|
|
238
|
+
a wrong in-transit email. (A parked row holds a stale status indefinitely — see
|
|
239
|
+
[tracking-status refresh](../../../2.0/apps/worker2/features/tracking-status-refresh.md).)
|
|
116
240
|
- **Canada now uses the `ItemFulfillmentItems` line bridge, not the ASN chain**, for
|
|
117
241
|
`getOrderFulfillmentData` — same query as Compass USA. Canada was originally on the ASN chain
|
|
118
242
|
because it had **zero `ItemFulfillmentItems`**; if that line-bridge data is not populated for a
|
|
@@ -138,7 +262,7 @@ the tracking number as emailed (it retries next run).
|
|
|
138
262
|
language even though the mail goes to the **manager**. Fixed 2026-08-03.
|
|
139
263
|
- **⚠ Each cron keeps its OWN duplicated copy of the 2.0 `_status` CASE — a standing divergence
|
|
140
264
|
risk.** `compass/update_salesorder_status_from_odp.php`,
|
|
141
|
-
`compasscanada/
|
|
265
|
+
`compasscanada/update_salesorder_status_from_g&t.php` and
|
|
142
266
|
`compass/workflow/test_partial_in_transit_email.php` each re-implement the model's status SQL as
|
|
143
267
|
`computedStatusSlug`. Today that is safe by luck of scope: the copies only ever produce canceled /
|
|
144
268
|
pendingApprovalUnknown / pendingFulfillment / shipped and they filter on
|
|
@@ -161,6 +285,27 @@ the tracking number as emailed (it retries next run).
|
|
|
161
285
|
|
|
162
286
|
## Change history
|
|
163
287
|
|
|
288
|
+
- 2026-09-04 — **Fixed the Compass USA in-transit email outage (2026-08-27 → 2026-09-03, 130–190
|
|
289
|
+
emails/day → near zero).** Root cause: already-delivered rows were `continue`d without stamping
|
|
290
|
+
`c_dtInTransitEmailSent`, so the candidate set grew to 2,379 and every run burned its
|
|
291
|
+
`cronInitialization(3600)` hour inside rows dating back to a hardcoded `'2026-06-15'`, never
|
|
292
|
+
reaching new ASNs. Fix: two-pass drain (stamp finished rows, then send), newest-first
|
|
293
|
+
`ORDER BY MAX(dtCreated) DESC` with `IN_TRANSIT_EMAIL_BATCH_SIZE = 400`, rolling
|
|
294
|
+
`IN_TRANSIT_EMAIL_LOOKBACK_DAYS = 14` replacing the fixed date, and `isTrackingStatusFinal()`
|
|
295
|
+
short-circuiting the carrier call on `DELIVERED`/`RETURNED_TO_SENDER` using the status worker2
|
|
296
|
+
`Sync/TrackingNumbers` already maintains. Also **widened the recipient**: `LEFT OUTER JOIN Users`
|
|
297
|
+
+ `Contacts`/`ContactEmailAddresses` with an ownership guard and
|
|
298
|
+
`IFNULL(Users.email, ContactEmailAddresses.emailAddress)`, which recovered 12 EDI-created MR
|
|
299
|
+
orders that have no `Users` row (regression-checked: 4,416 of 4,419 SA rows selected by both old
|
|
300
|
+
and new queries, 0 gained, 0 lost, identical resolved address/name). Backlog cleared by
|
|
301
|
+
**stamping** 64,132 out-of-window + 421 final-status rows rather than emailing them. Recorded
|
|
302
|
+
that **invoice tracking numbers are not in scope** (bridged via `InvoiceTrackingNumbers`, never
|
|
303
|
+
the ASN chain) and that a **`&` in a cron filename stops the cron dead**. Canada got the batching
|
|
304
|
+
changes only (renamed to `update_salesorder_status_from_g&t.php`; duplicate
|
|
305
|
+
`workflow/3_update_salesorder_status_from_grand_and_toy.php` deleted); its recipient block is
|
|
306
|
+
**still `INNER JOIN Users`**. New OneUptime monitor `Monitor/Compass/InTransitEmailQueue` now
|
|
307
|
+
watches this queue — see
|
|
308
|
+
[OneUptime push-metric monitors](../../../2.0/apps/worker2/features/oneuptime-worker2-monitoring.md). (bala)
|
|
164
309
|
- 2026-08-26 — Flagged the **duplicated `_status` CASE** in the three crons as a standing divergence
|
|
165
310
|
risk after the 2.0 Compass fulfillment rule was rewritten. Verified the crons are unaffected: their
|
|
166
311
|
copies only emit canceled / pendingApprovalUnknown / pendingFulfillment / shipped and filter on
|
|
@@ -6,8 +6,8 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: [dfranks, jcardinal, kyalamarthi]
|
|
9
|
+
updated: 2026-09-04
|
|
10
|
+
owners: [dfranks, jcardinal, kyalamarthi, ajean]
|
|
11
11
|
files:
|
|
12
12
|
- test/@dave/checker.php
|
|
13
13
|
- worker2/Component/Forecast/SaleImport/SaleImport.php
|
|
@@ -32,6 +32,7 @@ files:
|
|
|
32
32
|
- test/@dave/probe_profit_gap.php
|
|
33
33
|
- worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
|
|
34
34
|
- worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php
|
|
35
|
+
- worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_opportunities.php
|
|
35
36
|
- worker/crons/toga2/forecast2/import_open_orders.php
|
|
36
37
|
- worker/schedules/cron.worker.infrastructure.json
|
|
37
38
|
related:
|
|
@@ -373,6 +374,50 @@ have broken **every insert the cron makes**. It was caught only by rendering the
|
|
|
373
374
|
stub harness before any run. **`php -l` cannot catch malformed SQL string assembly**; when you edit a
|
|
374
375
|
query *builder*, print the built query and read it.
|
|
375
376
|
|
|
377
|
+
### ⚠ The opportunities discrepancy-fix cron DELETES rows that carry a ClickUp link (2026-09-04)
|
|
378
|
+
|
|
379
|
+
**This is a live data-loss path — treat it as the highest-priority item in this doc.**
|
|
380
|
+
`worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_opportunities.php` (209 lines,
|
|
381
|
+
`0 4 * * *`, **active**) and the same code path in `tools/bin/forecast/fixer.php`
|
|
382
|
+
(`fixOpportunities()`) both build their delete set **without ever selecting or checking
|
|
383
|
+
`clickupTaskId`**.
|
|
384
|
+
|
|
385
|
+
**Blast radius, measured 2026-09-04: 746 of 26,766 in-window rows carried a ClickUp task id**, and
|
|
386
|
+
one row was already stranded at the `PENDING` sentinel. Deleting such a row **orphans the ClickUp
|
|
387
|
+
task** and, because the 2.0 webhook's atomic claim keys on `Forecast.Opportunities.clickupTaskId`,
|
|
388
|
+
the next NetSuite event for that opportunity finds no claim and **creates a duplicate task**. The
|
|
389
|
+
trigger does not require anything exotic: **a single truncated `listOpportunities()` page makes every
|
|
390
|
+
unreturned opportunity look stale**, so an API hiccup is enough to mass-delete live rows.
|
|
391
|
+
Claim semantics and the duplicate-creation consequence:
|
|
392
|
+
[NetSuite ↔ ClickUp Opportunity Sync](../../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md).
|
|
393
|
+
|
|
394
|
+
Three further defects found in the same file:
|
|
395
|
+
|
|
396
|
+
1. **⚠ `MAINTENANCE_MODE = true` gates LOGGING ONLY — it does NOT stop writes.** Its sibling
|
|
397
|
+
`periodic_forecast_discrepancy_fix_open_orders.php` uses the same-named constant as a genuine
|
|
398
|
+
**dry run**. So the reasonable assumption — "same constant, same meaning, safe to leave on while I
|
|
399
|
+
watch it" — is **wrong here, and it deletes**. This is a genuine trap, not a style inconsistency:
|
|
400
|
+
verify per file before relying on it.
|
|
401
|
+
2. **The INSERT omits seven columns:** `forecastCategoryId`, `salesStageId`,
|
|
402
|
+
`percentToCloseStatusId`, `projectedProfit`, `leadSource`, `clickupTaskId`, and now
|
|
403
|
+
`endCustomerName`. Rows the cron recreates therefore come back stripped of all seven — the same
|
|
404
|
+
delete-and-reinsert failure shape as the `locationId` case above.
|
|
405
|
+
3. **Empty `sql_mode` in prod turns omission #2 into silent corruption.** `projectedProfit` is
|
|
406
|
+
NOT NULL with **no default**, so instead of rejecting the INSERT, MySQL silently stores `0.00`.
|
|
407
|
+
**This is the root cause of the perpetual checker↔fixer churn** this doc has tracked for months:
|
|
408
|
+
the checker flags a profit delta, the fixer "repairs" the row, the cron zeroes it again the next
|
|
409
|
+
night.
|
|
410
|
+
|
|
411
|
+
**And the reconciler cannot compute `projectedProfit` at all.** It fetches **no line items** — zero
|
|
412
|
+
references to `itemList` or `costEstimate` anywhere in the file — while opportunity profit is
|
|
413
|
+
Σ(amount − costEstimate) over qualifying lines (the formula recorded above). So it cannot fix the
|
|
414
|
+
column it breaks, and there is no version of "just add `projectedProfit` to the INSERT" that works
|
|
415
|
+
without adding line-item fetching first.
|
|
416
|
+
|
|
417
|
+
**Agreed direction (2026-09-04): the reconciler should STOP INSERTing and report instead.** A
|
|
418
|
+
component that cannot compute a NOT-NULL column should not be authoring rows. Detail in
|
|
419
|
+
[the bidirectional opportunity-sync design](../../../../2.0/apps/worker2/features/netsuite-clickup-opportunity-bidirectional-design.md).
|
|
420
|
+
|
|
376
421
|
## Data model
|
|
377
422
|
|
|
378
423
|
`Forecast.Sales`, `Forecast.OpenOrderItems` on the **core2** cluster
|
|
@@ -654,6 +699,21 @@ None — Forecast2 is a single shared dataset.
|
|
|
654
699
|
sequence the rotation against whatever else reads that replica.
|
|
655
700
|
|
|
656
701
|
## Change history
|
|
702
|
+
- 2026-09-04 — **Investigation only; no code changed (a prototype fix was written and deliberately
|
|
703
|
+
reverted).** Recorded a **live data-loss path**: the nightly
|
|
704
|
+
`periodic_forecast_discrepancy_fix_opportunities.php` (`0 4 * * *`, active) and
|
|
705
|
+
`tools/bin/forecast/fixer.php::fixOpportunities()` build their delete set **without checking
|
|
706
|
+
`clickupTaskId`** — **746 of 26,766 in-window rows carried a task id** (one already stranded at
|
|
707
|
+
`PENDING`), so a delete **orphans the ClickUp task and causes duplicate task creation**, and a
|
|
708
|
+
single truncated `listOpportunities()` page is enough to make every unreturned opportunity look
|
|
709
|
+
stale. Three more defects in the same file: **`MAINTENANCE_MODE = true` gates logging only, NOT
|
|
710
|
+
writes** (unlike its `..._open_orders.php` sibling, where it *is* a dry run — a genuine trap); the
|
|
711
|
+
INSERT **omits** `forecastCategoryId`, `salesStageId`, `percentToCloseStatusId`, `projectedProfit`,
|
|
712
|
+
`leadSource`, `clickupTaskId` and `endCustomerName`; and because prod `sql_mode` is **empty**,
|
|
713
|
+
`projectedProfit` (NOT NULL, no default) silently lands as **`0.00`** — the **root cause of the
|
|
714
|
+
long-standing checker↔fixer churn**. Also established that the reconciler **cannot compute
|
|
715
|
+
`projectedProfit` at all** (it fetches no line items — zero `itemList`/`costEstimate` references),
|
|
716
|
+
so the agreed direction is that it **stops INSERTing and reports** instead. (ajean)
|
|
657
717
|
|
|
658
718
|
- 2026-08-28 — **⚠ Security finding (not fixed):** the `tools` repo copies of these scripts —
|
|
659
719
|
`tools/bin/forecast/fixer.php:1424` and `tools/bin/forecast/checker.php:678` — hardcode **live
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-04
|
|
10
10
|
owners: ["bala", "kyalamarthi"]
|
|
11
11
|
files:
|
|
12
12
|
- worker/ebs/cron.worker.php
|
|
@@ -170,7 +170,28 @@ to **2.0 only**. The 1.0 standard wins in `library/`, `worker/` and every other
|
|
|
170
170
|
`library/app/systemmonitor/500error.php` uses `str_contains()`; that file is not a version
|
|
171
171
|
signal, it is a latent bug.
|
|
172
172
|
|
|
173
|
+
## Deploy-time trap: a `&` (or any shell metacharacter) in the cron FILENAME silently kills it
|
|
174
|
+
`ebs/cron.worker.php:41` builds each crontab line by plain string concatenation
|
|
175
|
+
(`'php /var/www/html/crons/' . $cron->cron`) with **no quoting and no escaping**. Cron then parses
|
|
176
|
+
that line as a shell command, so a `&` in the filename is read as *"run everything before this in
|
|
177
|
+
the background and start a new command."*
|
|
178
|
+
|
|
179
|
+
Worked example (found 2026-09-03): a developer renamed the Compass Canada status cron to
|
|
180
|
+
`crons/toga2/compasscanada/update_salesorder_status_from_g&t.php` and repointed
|
|
181
|
+
`schedules/cron.worker.sync.json` at it. The crontab line becomes two commands —
|
|
182
|
+
`php .../update_salesorder_status_from_g` (a file that does not exist) and a stray `t.php` — so the
|
|
183
|
+
real script **never executes**, on every tick, with no error anywhere. It looks exactly like
|
|
184
|
+
"the cron never fired".
|
|
185
|
+
|
|
186
|
+
**Rule: a cron filename must be plain `[a-z0-9_]` plus `.php`.** No `&`, no spaces, no `;`, `|`,
|
|
187
|
+
`$`, `(`, `)`, `*`, `?`, or quotes. The *display name* in `schedules/*.json` is free text and may
|
|
188
|
+
contain anything; only the `cron` path is shell-parsed.
|
|
189
|
+
|
|
173
190
|
## Change history
|
|
191
|
+
- 2026-09-04 — Added the **`&`-in-a-cron-filename trap**: `ebs/cron.worker.php:41` concatenates the
|
|
192
|
+
path into the crontab line unquoted, so a shell metacharacter splits the command and the script
|
|
193
|
+
never runs while leaving no trace. Found via the renamed Compass Canada
|
|
194
|
+
`update_salesorder_status_from_g&t.php`. (bala)
|
|
174
195
|
- 2026-09-01 — Added **Step 6: check the email log, not the inbox** — a 1.0 cron's sends land in
|
|
175
196
|
the **legacy** env's `Logs.Emails` (schema `Logs`, table `Emails`), never prod `Logs` or
|
|
176
197
|
`Logs_<Client>`; gave the verification query, and noted that a missing row usually means the job
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
| [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, _underscore/Model/Core/Page.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
|
|
8
8
|
| [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | When a business rule says *"only one X per physical address"*, comparing address rows field-for-field does **not** work: the same dwelling is spelled many diffe | _underscore/Model/Rate/Entitlement.php |
|
|
9
9
|
| [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php |
|
|
10
|
-
| [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
|
|
10
|
+
| [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, _underscore/Component/Api/Clickup/Clickup.php |
|
|
11
11
|
| [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
|
|
12
12
|
| [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
|
|
13
13
|
| [FIELD_AUTOINCREMENT record numbering (SA / TA prefixes, NULL-only trigger, no padding)](features/autoincrement-record-numbering.md) | A human-facing record number (`SA100001`, `TA100000`) is generated by the ORM, not by MySQL. | _underscore/Model.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/TransferOrder.php |
|
|
@@ -6,8 +6,8 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["jcardinal", "rgirish", "mhammontree"]
|
|
9
|
+
updated: 2026-09-04
|
|
10
|
+
owners: ["jcardinal", "rgirish", "mhammontree", "ajean"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/_underscore.php
|
|
13
13
|
- _underscore/Loader.php
|
|
@@ -31,7 +31,10 @@ Minimum PHP 8.1 (enforced in `_underscore.php`). The current architecture is
|
|
|
31
31
|
**Critical rules:** framework classes use a leading-underscore prefix (`_Model_*`, `_Controller_*`,
|
|
32
32
|
`_Worker_*`). Always use parameterized queries / the `_Db` layer — never interpolate input into SQL.
|
|
33
33
|
Beware the **lazy-transaction gotcha**: writes issued outside an explicitly committed transaction
|
|
34
|
-
can be silently dropped — confirm commit semantics before relying on a write.
|
|
34
|
+
can be silently dropped — confirm commit semantics before relying on a write. Two corollaries:
|
|
35
|
+
**never hold a DB transaction open across an external HTTP call**, and **an empty log table is not
|
|
36
|
+
proof a code path never ran** — uncommitted rows vanish, so absence of log rows means "no committed
|
|
37
|
+
write", not "no execution".
|
|
35
38
|
The Surface presentation layer replaces `Page::meta()` but `meta()` must NOT be deleted until
|
|
36
39
|
every page is cut over (per-surface, with a parity diff); config describes, PHP decides —
|
|
37
40
|
business logic never moves into Surface config.
|
|
@@ -369,6 +372,18 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
|
|
|
369
372
|
pin is `Addresses.isValidated` and `Entitlements.serviceAddressId` both-or-neither. Relevant code:
|
|
370
373
|
`_underscore/Model/Rate/Entitlement.php`, `api2/Component/Api/V2/V2.php::getFullModelData`.)
|
|
371
374
|
- **`_Database::register()` auto-starts a lazy transaction (since Apr 2 2026, commit `fa7835ed`).** Any code that calls `register()` and then writes to that DB must call `_Database::transactionCommit()` before the request ends — otherwise MySQL silently rolls back all writes when the connection closes. Lazy transactions only materialise on the first write, so read-only callers are unaffected. See `_underscore/Database.php:48`. First discovered when Rate SAML user provisioning silently discarded all new user INSERTs (Jun 2026).
|
|
375
|
+
- **Never hold a DB transaction open across an external HTTP round-trip.** Beyond pinning a
|
|
376
|
+
connection for the duration of a third-party call, it means the log/telemetry rows you are
|
|
377
|
+
writing *about* that call are still uncommitted while it runs. Live instance found 2026-09-04:
|
|
378
|
+
`_Worker_Clickup_Opportunity::logged()` opens `DB_LOGS`, never commits, and holds the
|
|
379
|
+
transaction across every ClickUp/NetSuite call.
|
|
380
|
+
- **An empty log table is NOT proof that a code path never ran.** This is the diagnostic trap the
|
|
381
|
+
gotcha creates, and it has already produced one wrong conclusion in this knowledge base: a
|
|
382
|
+
zero-row `Logs.Api` census was read as "this integration has been dead since it shipped," when
|
|
383
|
+
the writes were simply never committed. Before concluding a path is dead from absent rows,
|
|
384
|
+
confirm the writer commits. Correct pattern:
|
|
385
|
+
`worker2/Worker/Netsuite/SalesOrder.php:441`. Case detail:
|
|
386
|
+
[netsuite-opportunity-sync](../worker2/features/netsuite-opportunity-sync.md).
|
|
372
387
|
- **`_Model::load()` with a collapsed WHERE clause silently full-table-scans → OOM (guarded 2026-08-26, `Model.php`).** `_Model::search()` builds `SELECT <pk> FROM <table> [WHERE …] ORDER BY <pk>` and emits the WHERE **only if at least one search term survives**. `buildSqlFieldValue()` **drops** a term (returns false) when a set field's value is `null` and its type is a plain `FIELD_CHAR` — i.e. not one of the special-cased types (`AUTOINCREMENT`, `DATETIME_CREATED`, `DATETIME_UPDATED`, `SQL`, `STORAGE`, `CHAR_UUID`). If the only set search field collapses this way, the WHERE vanishes and `search()` selects the **whole table**, constructing one model per row → memory exhaustion on a large table. Because `load()` calls `search()` first and only then checks `count() == 1`, it **OOMs building the result array before it ever counts** — the empty-WHERE case never reaches the "not exactly one" branch. **`load()` means "find exactly one by criteria"; an empty WHERE is always a bug, never a full-table scan.** **Fix (commit `0b944a51`, `_production`):** `load()` now guards at the top — if no set search field yields a WHERE term (via `buildSqlFieldValue`), it `error_log`s and returns `false` (or throws when `throwExceptionIfNotFound`) instead of scanning. `search()` is unchanged, so intentional "list all rows" callers of `search()` are unaffected. **Triage:** a PHP OOM stack trace of `Model.php search() → __construct` that is *shallow* (a single search→construct, not deeply repeated) is a runaway result set from a collapsed WHERE, **not** recursion — confirm by checking whether the single set search field is a `FIELD_CHAR` whose value is null. (Root cause of a recurring prod OOM: `Logs.Issue` ref `2L` (#38), first seen 2026-08-03, 136 occurrences, trace `Model.php search()→__construct`.)
|
|
373
388
|
- **PHP "Unclosed '{'" parse errors report a MISLEADING line number.** When a `.php` file loaded by the autoloader (`Loader.php`) has a dropped/unbalanced brace, PHP reports `Unclosed '{' on line N` where N is the **outermost `class X {` line** and fails at EOF — NOT at the true location of the missing `}`. Worse, because the file loads lazily via the SPL autoloader, the runtime trace points at the **caller** that triggered the autoload (e.g. a `new _Email()` call site), not the broken file. **Triage rule:** for an "Unclosed '{'" error, the real culprit is a missing `}` somewhere between the reported line and EOF of the file that failed to load — run `php -l <file>` (it reports the EOF line) and scan the whole file. **Merge-conflict resolutions are a common source of a single dropped brace** — review the entire merge, not just the one file the error appears to name. (First hit: production 500 EO-1, Jul 2026 — a `}` dropped from `_Email::send()` during merge `685e4a14` surfaced as a trace pointing at the `new _Email()` caller.)
|
|
374
389
|
|