toga-ai 1.0.690 → 1.0.691

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.
@@ -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)_ — 20 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
- - **worker** (Worker) — 30 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
8
+ - **worker** (Worker) — 31 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
11
11
  - **togadesk** (TOGa Desk) — 13 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
@@ -14,6 +14,7 @@
14
14
  | [Compass MR/MA Order Auto-Approval & Status Gate](features/mr-ma-order-approval-and-status.md) | 2.0 | Compass **MR** and **MA** sales orders are system-generated from the MITS / Office Depot EDI pipeline (they do not originate as user-entered SA orders) and must | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
15
15
  | [Compass ODP EDI 850 Line-Item Resolution (VA part number to IN SKU fallback)](features/odp-edi-850-item-resolution.md) | 1.0 | How each **PO1 line** on an inbound Office Depot (ODP) **EDI 850** is resolved to a real `Client_Compass` catalog item before cron `3a_import_office_depot_purch | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, library/app/Edi.php |
16
16
  | [Compass Office Depot EDI 855 Acknowledgement + Over-Quantity PO Guard](features/odp-edi-855-acknowledgement-and-overquantity-guard.md) | 1.0 | How Compass acknowledges Office Depot (ODP) inbound **EDI 850** purchase orders with an **X12 855**, and the **over-quantity guard** that rejects a duplicate PO | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/4_transmit_office_depot_po_acknowledgements.php, library/app/edi.php |
17
+ | [Compass ODP EDI File Retention & Import Retry (cron 3a)](features/odp-edi-file-retention-and-retry.md) | 1.0 | When the Compass Office Depot **850 importer** (cron `3a`, every 5 min) keeps an EDI file in S3 and when it deletes it. | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
17
18
  | [Compass USA — Fulfilled vs Partially Fulfilled (order total kept; per-line rule built then rejected)](features/order-fulfillment-status-per-line.md) | 2.0 | TOGa Supply shows **Fulfilled** on Compass USA orders that are only partly shipped. | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/Canada/SalesOrder.php |
18
19
  | [Compass PEOPLE-File User Lifecycle (duplicate accounts, reactivation grace window, raw-SQL deactivation)](features/people-file-user-lifecycle.md) | 2.0 | The nightly **PEOPLE** file cron (`_Worker_Client_Compass_PeopleFile`, `worker2/Worker/Client/Compass/PeopleFile.php`) owns the whole `Users` row lifecycle for | worker2/Worker/Client/Compass/PeopleFile.php |
19
20
  | [Persona Model & Levy-Sector Gating (worker2 PEOPLE cron)](features/persona-model-and-levy-gating.md) | 2.0 | Compass USA catalogue visibility is driven by **personas** in `Client_Compass`. | worker2/Worker/Client/Compass/PeopleFile.php |
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-25
9
+ updated: 2026-08-28
10
10
  owners: ["jcardinal", "bala"]
11
11
  files:
12
12
  - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
@@ -14,6 +14,7 @@ files:
14
14
  - library/app/edi.php
15
15
  related:
16
16
  - odp-edi-850-item-resolution.md
17
+ - odp-edi-file-retention-and-retry.md
17
18
  - ../workflows/odp-order-pipeline-to-netsuite.md
18
19
  - ../workflows/odp-duplicate-po-line-cleanup.md
19
20
  - mits-po-transmission-to-vendors.md
@@ -139,6 +140,29 @@ Throwing **after** `cronFinished()` is safe: the overlap guard
139
140
  cronFinished's `CronJobExecutions` checkout row, and on CLI an uncaught `App_Exception_Business`
140
141
  just echoes `ERROR [ref]:` and exits.
141
142
 
143
+ ### ⚠ The 855 storm got a second route in — and a second guard (2026-08-28)
144
+
145
+ Deferring the throw was enough only while **every** file was deleted after one pass. Cron 3a now
146
+ **keeps a failed EDI file in S3 and retries it every 5 minutes**
147
+ ([file retention & retry](odp-edi-file-retention-and-retry.md)), so the reject branch is reachable
148
+ again on a later attempt — which would re-send the same reject 855 on every run, the exact storm the
149
+ deferred throw was written to prevent.
150
+
151
+ Two things close it:
152
+
153
+ - **`wasPurchaseOrderRejectionAlreadyAcknowledged(string $purchaseOrderNumber): bool`** — the reject
154
+ 855 is now built and sent **only the first time** the guard trips for a PO. A prior send is
155
+ detected by looking for a `Logs.FileLog` row with `job = 'ODP_EDI'` and
156
+ `fileName = '<purchaseOrderNumber>-ACK-REJECT.x12'`. **The acknowledgement log row is the idempotency
157
+ key** — there is no `dtAcknowledged` to lean on here, because a rejected PO is never persisted.
158
+ - **An over-quantity rejection is deliberately NOT treated as an import failure**, so the file is
159
+ still **deleted** on a reject. The reasoning: a reject 855 has already gone back to Office Depot,
160
+ so the file is finished with and retrying it would achieve nothing. (The
161
+ `SKIP_IF_CONTAINS_PART_NUMBERS` skip is treated the same way — an intentional business skip.)
162
+
163
+ So in practice the reject path still ends in a delete, and the helper is the belt-and-braces guard
164
+ for the case where a **different** PO in the same multi-PO file fails and keeps the file alive.
165
+
142
166
  ## ⚠ Open item — reject codes vs ODP's 855 companion guide
143
167
  The reject codes used (`BAK02 = RD`, `ACK01 = IR`) are the **standards-correct** X12 values.
144
168
  They have **not** yet been confirmed against Office Depot's 855 companion guide. If ODP
@@ -147,6 +171,13 @@ A legacy, abandoned sketch (`compass/edi/3_send_edi_855(reject).php`) used **non
147
171
  codes `BAK01 = A1` / `BAK02 = RE` — that path was **deliberately NOT followed**.
148
172
 
149
173
  ## Change history
174
+ - 2026-08-28 — Cron 3a now retries a failed EDI file instead of deleting it, which reopened the
175
+ **855-storm** risk on the reject branch. Added
176
+ `wasPurchaseOrderRejectionAlreadyAcknowledged()` — keyed on a `Logs.FileLog`
177
+ `<PO>-ACK-REJECT.x12` row, since a rejected PO is never persisted and has no `dtAcknowledged` — so
178
+ a reject 855 is sent only on the first trip. Recorded that an over-quantity rejection (and a
179
+ `SKIP_IF_CONTAINS_PART_NUMBERS` skip) is **deliberately not a failure**, so those files are still
180
+ deleted. Not deployed as of 2026-08-28. (bala)
150
181
  - 2026-08-25 — The over-quantity guard is now fed **resolved** part numbers (it ignores parts it
151
182
  cannot match, so a mistyped ODP part number had been bypassing duplicate protection), the
152
183
  **reject 855 echoes `ediPartNumber`** (what ODP sent) instead of our corrected value, and the
@@ -0,0 +1,137 @@
1
+ ---
2
+ title: Compass ODP EDI File Retention & Import Retry (cron 3a)
3
+ framework: "1.0"
4
+ repo: worker
5
+ project: Worker
6
+ client: compass-usa
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-28
10
+ owners: ["bala"]
11
+ files:
12
+ - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
13
+ related:
14
+ - odp-edi-855-acknowledgement-and-overquantity-guard.md
15
+ - odp-edi-850-item-resolution.md
16
+ - ../workflows/odp-edi-import-recovery.md
17
+ - ../workflows/odp-order-pipeline-to-netsuite.md
18
+ - ../profile.md
19
+ ---
20
+
21
+ ## Summary
22
+ When the Compass Office Depot **850 importer** (cron `3a`, every 5 min) keeps an EDI file in S3 and
23
+ when it deletes it. Until 2026-08-28 the file was deleted **unconditionally** at the end of the 850
24
+ branch, so **any** import failure silently destroyed the order: nothing written, no acknowledgement,
25
+ cron 5 never built a NetSuite SO, and no file left to retry. 3a now deletes the object **only when
26
+ every purchase order in the file imported end to end**; anything less and the file stays in
27
+ `OfficeDepot/` and the next run (5 min later) tries again.
28
+
29
+ > **⚠ Status 2026-08-28 — written in the working tree, NOT committed and NOT deployed.** Production
30
+ > still has the unconditional-delete behavior. Treat the silent-data-loss section below as live
31
+ > production behavior until this ships.
32
+
33
+ ## The failure this was built for (production, 2026-08-27)
34
+
35
+ Office Depot POs `41832546-1214` and `41832551-5910` never reached NetSuite. Between **19:11 and
36
+ 19:16** api2 returned **HTTP 500** (envelope code `EO-1`, "Operation Failed: An internal server
37
+ error occurred") to **every** lookup 3a makes: `GET /items`, `GET /vendor-items`,
38
+ `GET /purchase-orders`. Nothing was written, no 855 was produced, and 3a then deleted the EDI file.
39
+ The orders were only recoverable because the raw x12 was archived in `Logs.FileLog`.
40
+
41
+ This is a **silent data-loss class of bug**, not a one-off: any api2 outage inside the 5-minute
42
+ window destroyed whatever ODP had sent during it.
43
+
44
+ ### ⚠ Where the failure trail actually is (this cost real time)
45
+
46
+ - **NOT in `Logs_Compass.Api`.** The failing api2 calls do **not** appear in the per-client Api log,
47
+ only the surrounding successful `POST /v2/auth/api` and `GET /v2/clients` calls do. Do not conclude
48
+ from an empty `Logs_Compass.Api` that 3a never ran or never called api2.
49
+ - **The reliable trail is the legacy `Logs` schema:** `Logs.Emails` with subject
50
+ `Office Depot PO Import error: <PO>`, plus `Logs.FileLog` with `job = 'ODP_EDI'`.
51
+ - **Diagnostic shape that identifies this exact failure:** a `Logs.FileLog` row for `<PO>.x12` with
52
+ **no** matching `<PO>-ACK.x12` row, **and** no `PurchaseOrders` row in `Client_Compass` for that PO
53
+ number.
54
+ - **The only surviving copy of the EDI is `Logs.FileLog.fileData`.** Recovery is: pull `fileData`,
55
+ write it back byte for byte, re-upload to the **root** of `OfficeDepot/` (never `OUTBOX/` or
56
+ `SENT/`, which 3a skips). Full runbook:
57
+ [Recovering a lost ODP EDI import](../workflows/odp-edi-import-recovery.md).
58
+
59
+ ## How it works
60
+
61
+ ### One flag, one choke point
62
+ Every failure path in 3a already funnelled through **`sendErrorNotification()`**, so that single
63
+ function is the choke point. It now clears the file-scoped flag
64
+ **`$isCurrentEdiFileFullyImported`**, and that flag gates the S3 delete: the object is removed only
65
+ when the flag survives the file, otherwise the PO numbers are pushed onto
66
+ `$retriedPurchaseOrderNumbers` and the file is left in place.
67
+
68
+ **Why it matters:** there are roughly **25 separate `continue` sites** in the 850 branch. Gating on
69
+ the notification function means no error branch can be missed, and a future error path inherits the
70
+ retry for free as long as it reports through `sendErrorNotification()`. The flag is reset to `true`
71
+ at the top of each file's 850 branch, so every file is judged on its own attempt.
72
+
73
+ ### What is deliberately NOT a failure (the file still deletes)
74
+ - **The over-quantity guard rejection** — a reject EDI 855 has already been sent to Office Depot, so
75
+ the file is finished with. See
76
+ [855 Acknowledgement + Over-Quantity Guard](odp-edi-855-acknowledgement-and-overquantity-guard.md).
77
+ - **The `SKIP_IF_CONTAINS_PART_NUMBERS` skip** — an intentional business skip, not an error.
78
+
79
+ ### Supporting guards (retrying breaks things that used to run exactly once)
80
+
81
+ | Helper / constant | Why it exists |
82
+ |---|---|
83
+ | `wasPurchaseOrderRejectionAlreadyAcknowledged()` | A retried file must not re-send the over-quantity **reject 855** on every run. Detects a prior send by looking for a `Logs.FileLog` row named `<PO>-ACK-REJECT.x12`. This is the **855 storm** the original deferred-throw comment warned about, now reachable a second way. |
84
+ | `wasImportErrorAlreadyEmailedRecently()` + `HOURS_BETWEEN_IMPORT_ERROR_EMAILS = 1` | A permanently failing file (e.g. a part missing from the Compass catalog) would email the team every 5 minutes: **288 times a day**. Checks `Logs.Emails` by subject inside the window and stays quiet. |
85
+ | `isEdiFileAlreadyInFileLog()` + `DAYS_TO_SEARCH_FOR_LOGGED_EDI_FILE = 7` | Keeps exactly **one** `Logs.FileLog` row per file no matter how many attempts it takes. Matches on `job`, `fileName`, `fileData` and a bounded `dtStamp`. |
86
+
87
+ The file-log name is the run's imported PO numbers joined by `;` plus `.x12`, so a multi-PO 850
88
+ archives under a semicolon-joined name and searching for a single PO number needs a `LIKE`.
89
+
90
+ ### The new business exception
91
+ **`COMPASS_OFFICE_DEPOT_PO_IMPORT_INCOMPLETE`** (`URGENCY_HIGH`) is thrown from
92
+ `$retriedPurchaseOrderNumbers` **after** `App_Framework::cronFinished()` — the same deferred pattern
93
+ the rejection exception uses, and for the same reason (an inline throw unwinds the loop before the
94
+ per-file bookkeeping).
95
+
96
+ **⚠ Only one throw can win.** It is raised **after** the existing
97
+ `COMPASS_OFFICE_DEPOT_PO_IMPORT_REJECTED_OVER_QUANTITY` throw, deliberately: a rejection is the more
98
+ serious event, so when both happen in one run the rejection is the one that surfaces as a Logs.Issue.
99
+
100
+ ## Why retrying is safe (verified against the code before shipping)
101
+ - The existing **"do we already have this PO"** check (`GET /purchase-orders` by number **and**
102
+ `vendorId` = Office Depot) makes a re-import **idempotent**.
103
+ - It also keeps the **over-quantity guard from firing on a PO a previous attempt already created**,
104
+ because the guard sits *inside* the `totalRecordCount == 0` branch — an already-imported PO never
105
+ reaches it.
106
+ - Sales-order items are matched by **part number** against the existing SO, so a partial import
107
+ backfills rather than duplicates.
108
+
109
+ This is the same idempotency map recorded in the
110
+ [ODP pipeline doc](../workflows/odp-order-pipeline-to-netsuite.md); the retry simply automates the
111
+ re-drop that used to be a manual recovery.
112
+
113
+ ## Gotchas / known issues
114
+ - **⚠ A file that can never import now retries forever.** No cap and no park-after-N behavior was
115
+ added. Unimportable files accumulate in `OfficeDepot/`, lengthening every run against the cron's
116
+ **900-second ceiling** — and that folder is already the subject of the unfixed 138k-object listing
117
+ problem. Flagged to the developer; **not yet decided**.
118
+ - **⚠ Do not deploy with `EMAIL_TO` narrowed.** As observed in the working tree on 2026-08-28,
119
+ `EMAIL_TO` in this cron had been reduced locally to a **single** address instead of the three team
120
+ addresses. That is a local debugging edit, not a change made by this work — restore the full list
121
+ before shipping.
122
+ - Recovery timing: re-importing an old EDI file restarts cron 5's **30-minute** release clock,
123
+ because that clock runs from the Office Depot PO's `dtCreated`. A recovered order waits another
124
+ half hour. See the cron 5 section of the
125
+ [ODP pipeline doc](../workflows/odp-order-pipeline-to-netsuite.md).
126
+
127
+ ## Change history
128
+ - 2026-08-28 — Built: 3a now deletes an EDI file **only when every PO in it imported end to end**,
129
+ gated by `$isCurrentEdiFileFullyImported` cleared inside `sendErrorNotification()` (the one choke
130
+ point all ~25 error paths already used). Over-quantity rejects and `SKIP_IF_CONTAINS_PART_NUMBERS`
131
+ skips still delete. Added `wasPurchaseOrderRejectionAlreadyAcknowledged()` (no repeat reject 855),
132
+ `wasImportErrorAlreadyEmailedRecently()` + `HOURS_BETWEEN_IMPORT_ERROR_EMAILS = 1` (no 288
133
+ emails/day), `isEdiFileAlreadyInFileLog()` + `DAYS_TO_SEARCH_FOR_LOGGED_EDI_FILE = 7` (one file-log
134
+ row per file), and the deferred `COMPASS_OFFICE_DEPOT_PO_IMPORT_INCOMPLETE` exception. Recorded the
135
+ 2026-08-27 api2 500 (`EO-1`) incident that destroyed POs 41832546-1214 / 41832551-5910, and that
136
+ those api2 failures are **absent from `Logs_Compass.Api`** — the trail is `Logs.Emails` +
137
+ `Logs.FileLog`. **Not committed, not deployed.** (bala)
@@ -41,6 +41,7 @@ related:
41
41
  - workflows/odp-duplicate-po-line-cleanup.md
42
42
  - features/odp-edi-850-item-resolution.md
43
43
  - features/odp-edi-855-acknowledgement-and-overquantity-guard.md
44
+ - features/odp-edi-file-retention-and-retry.md
44
45
  - ../../2.0/apps/worker2/features/compass-vip-support-importer.md
45
46
  - ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
46
47
  - ../../2.0/apps/toga2-commerce/features/category-tile-sort-order.md
@@ -113,6 +114,18 @@ separate, related client (see its own profile).
113
114
  [ODP EDI 850 Line-Item Resolution](features/odp-edi-850-item-resolution.md).
114
115
  **Never create an `Items` row so a mangled inbound part number "matches"** — that is what put
115
116
  duplicate junk items in the Agilant catalog (`ODTABKITTINGOPT3` vs `ODPTABKITTINGOPT3`).
117
+ - **⚠ A failed 850 import silently destroys the order (2026-08-28, fix NOT deployed).** Cron 3a
118
+ deleted the EDI file from S3 **unconditionally**, so an api2 outage (HTTP 500 / `EO-1`, 2026-08-27)
119
+ meant ODP POs `41832546-1214` / `41832551-5910` were never imported, never acknowledged, never
120
+ reached NetSuite — and the only surviving copy was `Logs.FileLog`. The fix keeps the file and
121
+ retries it every 5 minutes until every PO in it imports. **These api2 failures do not appear in
122
+ `Logs_Compass.Api`** — use `Logs.Emails` + `Logs.FileLog`:
123
+ [ODP EDI File Retention & Import Retry](features/odp-edi-file-retention-and-retry.md).
124
+ - **Office Depot only 850s back what they buy FROM us (2026-08-28).** A Compass SO can mix a PO ODP
125
+ buys from Toga (returns an 850) with one Toga buys from ODP (never returns one) — both carry
126
+ `vendorId` = Office Depot. Mixed orders can therefore only ever be released to NetSuite by cron 5's
127
+ **30-minute timeout**, so they are consistently half an hour late by design, not by fault. See the
128
+ release gate in [ODP order pipeline to NetSuite](workflows/odp-order-pipeline-to-netsuite.md).
116
129
  - ASN ingestion entry points: cXML to the V2 API (logged in `Logs_Compass.Api`) and
117
130
  `worker/crons/toga2/compass/workflow/3b_import_strategic_systems_advance_shipping_notices.php`.
118
131
 
@@ -6,13 +6,14 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-04
10
- owners: ["dfranks"]
9
+ updated: 2026-08-28
10
+ owners: ["dfranks", "bala"]
11
11
  files:
12
12
  - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
13
13
  - worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php
14
14
  - worker/schedules/cron.worker.sync.json
15
15
  related:
16
+ - clients/compass-usa/features/odp-edi-file-retention-and-retry.md
16
17
  - clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md
17
18
  - clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
18
19
  - ../../../2.0/apps/api2/features/api-payload-interceptors.md
@@ -30,12 +31,43 @@ The recovery works because the raw x12 is archived in `Logs.FileLog` and **3a is
30
31
  re-uploading the file makes it repair the items, the ODP PO, and both bridges. Verified end-to-end
31
32
  in **production** on 2026-08-04 for ODP PO `41608261-1135` / ODP SO `475390815001`.
32
33
 
34
+ The same runbook covers the **total** loss case, which is more common than the partial one: 3a fails
35
+ **before writing anything** (e.g. api2 is down), deletes the file anyway, and the order simply never
36
+ exists — no zero-item order, no ODP PO, nothing to sweep for. Production example 2026-08-27, ODP POs
37
+ `41832546-1214` and `41832551-5910`, when api2 answered **HTTP 500 / `EO-1`** to every lookup between
38
+ 19:11 and 19:16.
39
+
40
+ > **Once the conditional-delete change ships, most of this becomes unnecessary** — 3a keeps a failed
41
+ > file in `OfficeDepot/` and retries it every 5 minutes on its own. See
42
+ > [ODP EDI File Retention & Import Retry](../features/odp-edi-file-retention-and-retry.md). It was
43
+ > **not deployed as of 2026-08-28**, so this runbook is still live, and it stays the recovery path
44
+ > for anything lost before the deploy.
45
+
33
46
  ## When to use this
34
47
 
35
48
  Symptoms: a zero-item ODP SalesOrder (see the orphan sweeps in
36
49
  [order lifecycle & data integrity](order-lifecycle-and-data-integrity.md)), and cron 5 re-attempting
37
50
  the NetSuite SO every 10 minutes because it selects on `c_dtTransmittedToNetsuite IS NULL`.
38
51
 
52
+ ### The diagnostic shape that identifies a destroyed import
53
+
54
+ For the total-loss case there is no order to notice, so match on the **file log** instead:
55
+
56
+ - a `Logs.FileLog` row (`job = 'ODP_EDI'`) for **`<odpPoNumber>.x12`**, **and**
57
+ - **no** matching **`<odpPoNumber>-ACK.x12`** row (nothing was ever acknowledged), **and**
58
+ - **no** `PurchaseOrders` row in `Client_Compass` for that PO number.
59
+
60
+ That combination means the 850 arrived, was archived, and then died — the file is already gone from
61
+ S3 and the file-log copy is the only one left.
62
+
63
+ ### ⚠ `Logs_Compass.Api` is NOT where these failures show up
64
+
65
+ The failing api2 calls 3a makes (`GET /items`, `GET /vendor-items`, `GET /purchase-orders`) were
66
+ **absent** from the per-client `Logs_Compass.Api` log in the 2026-08-27 incident — only the
67
+ surrounding successful `POST /v2/auth/api` and `GET /v2/clients` calls appear. **Do not** read an
68
+ empty `Logs_Compass.Api` as "3a never ran." The reliable trail is **`Logs.Emails`** (subject
69
+ `Office Depot PO Import error: <PO>`) plus **`Logs.FileLog`** (`job = 'ODP_EDI'`).
70
+
39
71
  ## The runbook
40
72
 
41
73
  ### Step 1 — retrieve the archived x12
@@ -55,13 +87,20 @@ you have a corrupted payload — do not upload it.
55
87
 
56
88
  ### Step 2 — know that S3 has nothing left
57
89
 
58
- 3a **deletes the S3 object after processing** (`:769`), so a failed import leaves no file to retry.
59
- Recovery is a **re-upload**, to:
90
+ 3a **deletes the S3 object after processing**, so a failed import leaves no file to retry. (After
91
+ the conditional-delete change ships this is only true of a *successful* import — see the note in the
92
+ Summary.) Recovery is a **re-upload**, to the **root** of the prefix:
60
93
 
61
94
  ```
62
95
  s3://agilant-as2/OfficeDepot/EDI_<odpPoNumber>.txt
63
96
  ```
64
97
 
98
+ **⚠ The root of `OfficeDepot/`, never `OfficeDepot/OUTBOX/` or `OfficeDepot/SENT/`.** 3a skips both
99
+ of those prefixes when processing, so a file dropped there is archived forever and never imported.
100
+
101
+ Write the bytes back **exactly** as stored: pull `fileData`, write it to disk unmodified, and confirm
102
+ the byte count against `LENGTH(fileData)` before uploading.
103
+
65
104
  ### Step 3 — clear the cause BEFORE re-uploading
66
105
 
67
106
  **Confirm no `ApiPayloadInterceptors` row exists for the `sales-order-items` record** (`recordId 15`)
@@ -90,7 +129,11 @@ confirmation the recovery finished.
90
129
  ## Gotchas / known issues
91
130
 
92
131
  - **⚠ Re-uploading before fixing the root cause destroys the file again** (3a deletes on the way
93
- through). Step 3 is not optional.
132
+ through). Step 3 is not optional. This is exactly what the conditional-delete change removes —
133
+ until it deploys, assume one attempt per recovered file.
134
+ - **⚠ A recovered order waits another 30 minutes.** Cron 5's release timer runs from the Office
135
+ Depot PO's `dtCreated`, i.e. **when we imported the 850** — so re-importing restarts it. See the
136
+ release gate in the [ODP pipeline doc](odp-order-pipeline-to-netsuite.md).
94
137
  - **⚠ The archive filename extension differs from the S3 one** — `.x12` in `Logs.FileLog`,
95
138
  `EDI_<po>.txt` in S3. Searching for the wrong one finds nothing.
96
139
  - **Don't search for the ODP SalesOrder by the `BEG` number.** `BEG` is the ODP *PO*; the SO number
@@ -103,6 +146,14 @@ confirmation the recovery finished.
103
146
 
104
147
  ## Change history
105
148
 
149
+ - 2026-08-28 — Added the **total-loss** case (3a fails before writing anything, so there is no
150
+ zero-item order to sweep for) with the 2026-08-27 api2 **500 / `EO-1`** incident that destroyed ODP
151
+ POs `41832546-1214` / `41832551-5910`; the **file-log diagnostic shape** (`<PO>.x12` present, no
152
+ `<PO>-ACK.x12`, no `PurchaseOrders` row); the warning that these api2 failures are **absent from
153
+ `Logs_Compass.Api`** so `Logs.Emails` + `Logs.FileLog` are the real trail; the **re-upload to the
154
+ root of `OfficeDepot/`, never `OUTBOX/` or `SENT/`** rule; and the note that cron 5's 30-minute
155
+ timer restarts on re-import. Flagged that 3a's **conditional delete + auto-retry** (written
156
+ 2026-08-28, not deployed) largely supersedes this runbook once it ships. (bala)
106
157
  - 2026-08-04 — Documented the runbook after recovering a production ODP order whose item write was
107
158
  killed by an api2 fatal: `Logs.FileLog` (`job = 'ODP_EDI'`, `<po>.x12`, bounded by `fileTimestamp`)
108
159
  as the x12 archive, the trailing-newline/byte-count check, the re-upload path to
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-25
9
+ updated: 2026-08-28
10
10
  owners: ["rgirish", "bala", "dfranks", "jcardinal", "mhammontree"]
11
11
  files:
12
12
  - worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php
@@ -19,6 +19,7 @@ files:
19
19
  - library/app/client/compass.php
20
20
  related:
21
21
  - 1.0/apps/worker/features/netsuite-sales-order-sales-rep-sourcing.md
22
+ - clients/compass-usa/features/odp-edi-file-retention-and-retry.md
22
23
  - clients/compass-usa/features/odp-edi-850-item-resolution.md
23
24
  - clients/compass-usa/features/odp-edi-855-acknowledgement-and-overquantity-guard.md
24
25
  - 1.0/apps/worker/workflows/tracing-a-worker-cron-run-in-production.md
@@ -67,8 +68,18 @@ ODP SalesOrder (customerId = 1)
67
68
  3. **`workflow/3a_import_office_depot_purchase_orders.php`** — pulls the ODP **850** from S3 bucket
68
69
  `agilant-as2`, prefix `OfficeDepot/` (excluding `OUTBOX/` and `SENT/`; `:46` S3 read,
69
70
  `:54-58` prefix filters) and creates the downstream **ODP SalesOrder** (customerId=1 = Office
70
- Depot). **Deletes the S3 object after processing** (`:769`) — so an absent S3 object is expected
71
- once ingested, not evidence of a miss, *and* a failed import leaves nothing to retry.
71
+ Depot). **Deletes the S3 object after processing** — so an absent S3 object is expected once
72
+ ingested, not evidence of a miss.
73
+
74
+ > **⚠ Corrected 2026-08-28 — the delete is now CONDITIONAL.** 3a used to delete the object
75
+ > **unconditionally**, so a failed import left nothing to retry and **silently destroyed the
76
+ > order**. It now deletes only when **every PO in the file imported end to end**; otherwise the
77
+ > file stays in `OfficeDepot/` and the next `*/5` run retries it. Which failures count, the
78
+ > reject-855 / error-email / file-log guards that retrying made necessary, and the
79
+ > **retries-forever** limitation:
80
+ > [ODP EDI File Retention & Import Retry](../features/odp-edi-file-retention-and-retry.md).
81
+ > **Not deployed as of 2026-08-28** — production still deletes unconditionally, so treat any
82
+ > pre-deploy incident as a lost file.
72
83
 
73
84
  > **⚠ Correction (2026-08-04): `edi/1_download_edi_s3_create_po_toga.php` is DEAD CODE.** It
74
85
  > appears in **no** schedule file. The scheduled entry *named* "Download EDI From S3 Files &
@@ -148,13 +159,72 @@ Cron 3a resolves each line through that SKU when the `VA` text does not match th
148
159
  **onto the ODP SalesOrder (customerId=1), never the Compass SO (customerId=2)**.
149
160
  **Exclusion filters** (an order legitimately waiting is not a bug):
150
161
  - `CompassSalesOrders.number NOT LIKE 'MA%'` — MA orders are excluded.
151
- - **computer-kit orders** (Bundles 187–196) wait for a **2nd PO** before syncing.
152
- - a **30-minute delay** after the first ODP PO is created.
162
+ - the two-part **release gate** below (this is the "30-minute delay").
163
+ - **computer-kit orders** (Bundles 187–196) wait for a **2nd PO** before syncing. This one is
164
+ **not** in the `WHERE` clause — it is a per-row check run in PHP *after* the query
165
+ (`Bundles.number IN (192, 187, 188, 195, 193, 189, 194, 190, 191, 196)`, then a count of that
166
+ Compass SO's POs), so such an order is selected and then skipped.
153
167
 
154
168
  Because the selection is only `c_dtTransmittedToNetsuite IS NULL` (plus `number NOT LIKE 'MA%'`),
155
169
  a **zero-item ODP SalesOrder sits in this queue retrying every 10 minutes** until its lines are
156
170
  restored — the retry loop is the symptom, not the cause.
157
171
 
172
+ ### How cron 5 decides a Compass order is ready for NetSuite (the release gate)
173
+
174
+ The query groups by **`CompassSalesOrders.id`** and releases the order when **either** condition
175
+ holds (verified against the cron source 2026-08-28, the `WHERE` block around `:90-115`):
176
+
177
+ - **(a) Everything ODP owes us has come back.** The count of Compass POs on that Compass SO with
178
+ `vendorId` = Office Depot that have **nothing chained back to them** is **zero** — the test in the
179
+ SQL is `OfficeDepotSO_PO.id IS NULL`.
180
+ - **(b) OR the 30-minute timeout.** The **earliest** Office Depot PO chained to that Compass SO was
181
+ created at least **30 minutes** ago (`IFNULL(MIN(OfficeDepotPO.dtCreated), NOW()) <=
182
+ DATE_SUB(NOW(), INTERVAL 30 MINUTE)`), in which case it transmits with whatever has come back.
183
+
184
+ **Cron 5 never opens an EDI file and never looks in S3.** It infers that the 850 arrived purely from
185
+ the **link rows cron 3a writes**: Compass PO → `PurchaseOrders_SalesOrders` → Office Depot SO →
186
+ `SalesOrders_PurchaseOrders` → Office Depot PO. If 3a did not write the bridges, cron 5 cannot tell
187
+ the difference between "ODP has not answered yet" and "the import died."
188
+
189
+ **⚠ The 30-minute clock runs from the Office Depot PO's `dtCreated`** — i.e. **the moment we
190
+ imported the 850**, not when the Compass order was raised. So re-importing an old EDI file
191
+ **restarts the timer**, and a recovered order waits another 30 minutes before it can transmit.
192
+
193
+ **⚠ A late 850 produces a SECOND NetSuite sales order.** If a PO's 850 arrives *after* the Compass
194
+ SO has already transmitted, the new ODP SO is picked up on its own and creates a **separate**
195
+ NetSuite SO rather than joining the first.
196
+
197
+ Fields on `Client_Compass.SalesOrders`: `c_dtTransmittedToNetsuite`, `c_netsuiteInternalSalesOrderId`.
198
+
199
+ **⚠ Do not infer the schedule from transmission times.** Transmissions cluster at **:00 and :30**,
200
+ which reads like a 30-minute cron — it is not. The schedule is **`*/10`** (re-verified in
201
+ `worker/schedules/cron.worker.sync.json` on 2026-08-28); the clustering is the 30-minute **release
202
+ gate** above, not the cadence.
203
+
204
+ ### ⚠ Office Depot only sends an 850 back for what they buy FROM us
205
+
206
+ Confirmed by the developer with Office Depot (2026-08-28). A single Compass sales order can carry
207
+ **two different kinds** of Compass purchase order, and **both** have `vendorId` = Office Depot:
208
+
209
+ | Direction | Example | Does an 850 come back? |
210
+ |---|---|---|
211
+ | **ODP buys FROM Toga** (we supply the item) | the laptop | **Yes** — 3a turns it into an ODP SalesOrder + PurchaseOrder |
212
+ | **Toga buys FROM ODP** (they supply the item) | accessories | **No** — nothing ever comes back |
213
+
214
+ Worked example (2026-08-28): Compass SO **`SA136541`** carried `50311948-1` (laptop `C40QYUC`,
215
+ returned as ODP PO `41832546-1214`) **and** `50311949-1` (accessories — HDMI adapter, cable, dock,
216
+ mouse — which never returns).
217
+
218
+ **Consequence:** on any Compass SO that **mixes** the two kinds, release condition **(a) can never be
219
+ satisfied**, because the buy-from-ODP PO permanently has nothing chained to it. Those orders are
220
+ **always** released by the 30-minute timeout instead, so they reach NetSuite **half an hour late,
221
+ every single time**. Nothing is lost — but a "why is this order always 30 minutes behind?" ticket
222
+ is this, not a fault.
223
+
224
+ **Open improvement (not done):** narrow condition (a) to count only the POs Office Depot actually
225
+ buys from us. **The distinguishing field in the data has not been identified yet** — `vendorId` is
226
+ the same for both, so this needs a data investigation before any code change.
227
+
158
228
  ### Cron 5 sales rep — sourced from the end customer, not hardcoded (TRUE-80451)
159
229
 
160
230
  The SO `entity` is the **parent** customer **35581 "ODP Veyer (B2B)"**; the real buying account
@@ -272,6 +342,18 @@ wrong record makes every order look "stuck." Use the **join**, never a number ma
272
342
  transmission feature.)
273
343
 
274
344
  ## Change history
345
+ - 2026-08-28 — **Cron 3a's S3 delete is no longer unconditional** (it destroyed the order on any
346
+ failure; corrected the step-3 note) — details in the new
347
+ [ODP EDI File Retention & Import Retry](../features/odp-edi-file-retention-and-retry.md), which
348
+ also records the 2026-08-27 api2 500 incident and that those failures are **absent from
349
+ `Logs_Compass.Api`**. Documented **cron 5's actual release gate** (`OfficeDepotSO_PO.id IS NULL`
350
+ count = 0 **OR** earliest ODP PO `dtCreated` older than 30 min), that cron 5 reads only the
351
+ bridge rows and never S3, that the 30-min clock starts at **our import** (so a recovery waits
352
+ again), that a late 850 makes a **second** NetSuite SO, and that the :00/:30 clustering is the
353
+ gate and **not** the schedule (still `*/10`). Recorded the ODP business rule that **an 850 only
354
+ comes back for what ODP buys FROM us**, so mixed orders (e.g. `SA136541`) can only ever release on
355
+ the timeout. Corrected the Bundles 187–196 filter as a post-query PHP check, not a `WHERE`
356
+ clause. (bala)
275
357
  - 2026-08-25 — Cron 3a now **resolves every 850 line to a catalog item before it writes anything**
276
358
  (a bad line used to leave a half-built ODP SO); documented the `PO1` two-identifier detail
277
359
  (`elements[7]` = drifting `VA` text, `elements[9]` = ODP's reliable `IN` SKU) behind the new
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.690",
3
+ "version": "1.0.691",
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",