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.
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/compass-usa/INDEX.md +1 -0
- package/knowledge/clients/compass-usa/features/odp-edi-855-acknowledgement-and-overquantity-guard.md +32 -1
- package/knowledge/clients/compass-usa/features/odp-edi-file-retention-and-retry.md +137 -0
- package/knowledge/clients/compass-usa/profile.md +13 -0
- package/knowledge/clients/compass-usa/workflows/odp-edi-import-recovery.md +56 -5
- package/knowledge/clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md +87 -5
- package/package.json +1 -1
package/knowledge/INDEX.md
CHANGED
|
@@ -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) —
|
|
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 |
|
package/knowledge/clients/compass-usa/features/odp-edi-855-acknowledgement-and-overquantity-guard.md
CHANGED
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: compass-usa
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
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-
|
|
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
|
|
59
|
-
|
|
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-
|
|
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**
|
|
71
|
-
|
|
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
|
-
-
|
|
152
|
-
-
|
|
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