toga-ai 1.0.1028 → 1.0.1029

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.
Files changed (20) hide show
  1. package/knowledge/1.0/apps/test/INDEX.md +2 -1
  2. package/knowledge/1.0/apps/test/features/compass-fix-item-fulfillment-chain.md +42 -0
  3. package/knowledge/1.0/apps/test/features/compass-retrofix2-sql-generator.md +39 -45
  4. package/knowledge/2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md +29 -51
  5. package/knowledge/2.0/apps/_underscore/features/recursive-item-fulfillments.md +6 -4
  6. package/knowledge/INDEX.md +1 -1
  7. package/knowledge/clients/compass-canada/INDEX.md +1 -1
  8. package/knowledge/clients/compass-canada/features/order-fulfillment-status-per-line.md +20 -80
  9. package/knowledge/clients/compass-canada/profile.md +5 -19
  10. package/knowledge/clients/compass-usa/INDEX.md +1 -1
  11. package/knowledge/clients/compass-usa/features/asn-to-item-fulfillment.md +4 -3
  12. package/knowledge/clients/compass-usa/features/isfulfillable-data-quality-and-type-rule.md +14 -4
  13. package/knowledge/clients/compass-usa/features/mr-ma-order-approval-and-status.md +6 -3
  14. package/knowledge/clients/compass-usa/features/multi-tier-tracking-and-serial-mirroring.md +5 -8
  15. package/knowledge/clients/compass-usa/features/order-fulfillment-status-per-line.md +48 -85
  16. package/knowledge/clients/compass-usa/profile.md +7 -17
  17. package/knowledge/clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md +4 -2
  18. package/knowledge/clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md +5 -2
  19. package/knowledge/clients/quad/profile.md +1 -1
  20. package/package.json +1 -1
@@ -5,7 +5,8 @@
5
5
  | [Test (test) Architecture](architecture.md) | Repo of ad-hoc developer scripts (not a deployed app); `team/` is the shared, documented surface — open to find/run a one-off script or add one. |
6
6
  | [2.0 Deployment — Per-Client SQL Generator (superseded)](features/2-0-deployment-client-sql.md) | SUPERSEDED by the Tools Multi-Client SQL tool — old offline script that fans one SQL change-set across a static list of 2.0 client DBs; open only when working o |
7
7
  | [Active Directory Authentication Test](features/active-directory-auth-test.md) | Interactive CLI REPL to test Active Directory authentication via `App_ActiveDirectory::isAuthenticated()`; open to verify AD login from a terminal. |
8
- | [Compass retrofix2 — Retroactive Data-Fix SQL Generator](features/compass-retrofix2-sql-generator.md) | Standalone 1.0 script emitting review-first SQL to retroactively repair historical Compass USA (`Client_Compass`, 2.0) order data broken by 50+ past bugs; open |
8
+ | [Compass fix_item_fulfillment_chain — per-order fulfillment-chain repair tool](features/compass-fix-item-fulfillment-chain.md) | Operator script that repairs one Compass SA order's links and tracking, then lets the real recursive IF engine rebuild the Compass/ODP fulfillments; open when a |
9
+ | [Compass retrofix2 — Retroactive Data-Fix SQL Generator](features/compass-retrofix2-sql-generator.md) | Standalone 1.0 script emitting review-first SQL to retroactively repair historical Compass USA (`Client_Compass`, 2.0) order links and fulfillment chains; open |
9
10
  | [Create Elastic Beanstalk Environment (script)](features/create-elastic-beanstalk.md) | Standalone constants-driven generator that PRINTS a ready-to-paste AWS CloudShell `create-environment` command for a new EB env in an existing app (never calls |
10
11
  | [Developer Generators (password, UUID)](features/dev-generators.md) | Two tiny 1.0 App_ scripts — `generate_password.php` (random password + hash) and `uuid.php` (platform-style UUID); open when seeding a user password/hash or a f |
11
12
  | [Forecast vs NetSuite Discrepancy Analysis](features/forecast-netsuite-discrepancy-analysis.md) | 1.0 App_ script reconciling our Forecast DB against NetSuite (source of truth) from the "Data Dump for Mark H" CSV (searchid 7276); open to detect revenue/profi |
@@ -0,0 +1,42 @@
1
+ ---
2
+ title: Compass fix_item_fulfillment_chain — per-order fulfillment-chain repair tool
3
+ framework: "1.0"
4
+ repo: test
5
+ project: Test
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-15
10
+ owners: [jcardinal]
11
+ files:
12
+ - test/@jeff/compass/fix_item_fulfillment_chain.php
13
+ related:
14
+ - compass-retrofix2-sql-generator.md
15
+ - ../../../../2.0/apps/_underscore/features/recursive-item-fulfillments.md
16
+ - ../../../../clients/compass-usa/features/multi-tier-tracking-and-serial-mirroring.md
17
+ - ../../../../clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md
18
+ ---
19
+
20
+ Operator script that repairs one Compass SA order's links and tracking, then lets the real recursive IF engine rebuild the Compass/ODP fulfillments; open when a Compass order shipped but shows no fulfillment or tracking.
21
+
22
+ ## Summary
23
+ `@jeff/compass/fix_item_fulfillment_chain.php --number=SA…[,SA…] [--execute]` — **dry run unless `--execute`**. Unlike [retrofix2](compass-retrofix2-sql-generator.md) (bulk, emits SQL), this writes to prod for a few orders and uses api2 so the shared engine does the mirroring. Used in prod on SA130861, SA130762, SA130463 (verified).
24
+
25
+ ## How it works
26
+ Per order:
27
+ 1. **Agilant links** — add missing ODP PO line → Agilant SO line links. Add-only, and only onto **real** ODP PO lines (lines linked to an ODP SO line). Part + qty match first, then one free real line takes leftovers as a kit.
28
+ 2. **Compass PO → ODP SO links** — repair by `itemId`; repeated items pair one-to-one by qty. (Undoes cron 3a's last-line bug.)
29
+ 3. **Bottom-tier tracking** — on Agilant IFs with exactly **one** header tracking number, fill missing line (318) / unit (319) tracking. Same rule as the NetSuite IF sync.
30
+ 4. **PUT the Agilant IF through api2** — the [recursive IF engine](../../../../2.0/apps/_underscore/features/recursive-item-fulfillments.md) rebuilds the ODP and Compass IFs. No hand-built upstream IFs.
31
+
32
+ ## Gotchas
33
+ - **Refuses** when the DB alias is not the prod writer host.
34
+ - **Refuses** when the Compass SO already has a non-chain IF (e.g. ASN-made) — the engine does not adopt an existing IF, so a PUT would create a duplicate.
35
+ - Always dry-run first and read the plan.
36
+
37
+ ## Change history
38
+ - 2026-10-15 — Built for TRUE-79669; used on SA130861, SA130762, SA130463. (jcardinal)
39
+
40
+ ## Related
41
+ - [retrofix2 — bulk SQL generator](compass-retrofix2-sql-generator.md)
42
+ - [Compass multi-tier tracking](../../../../clients/compass-usa/features/multi-tier-tracking-and-serial-mirroring.md)
@@ -6,73 +6,67 @@ project: Test
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-15
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - test/@jeff/compass/retrofix2.php
13
13
  related:
14
14
  - ../architecture.md
15
- - ../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
16
- - ../../../clients/compass-usa/features/mits-po-to-so-item-linking.md
15
+ - compass-fix-item-fulfillment-chain.md
16
+ - ../../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
17
+ - ../../../../clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md
18
+ - ../../../../clients/compass-usa/features/mits-po-to-so-item-linking.md
19
+ - ../../../../2.0/apps/_underscore/features/recursive-item-fulfillments.md
17
20
  ---
18
21
 
19
- Standalone 1.0 script emitting review-first SQL to retroactively repair historical Compass USA (`Client_Compass`, 2.0) order data broken by 50+ past bugs; open for its 3-phase structure, level isolation, and write guards.
22
+ Standalone 1.0 script emitting review-first SQL to retroactively repair historical Compass USA (`Client_Compass`, 2.0) order links and fulfillment chains; open for its phases, matching rules, write guards, and how to run it.
20
23
 
21
24
  ## Summary
22
25
 
23
- `@jeff/compass/retrofix2.php` is a standalone **1.0 `App_`** script that retroactively repairs historical Compass USA (`Client_Compass`, 2.0) order data left in the wrong shape by 50+ past bugs. It **emits review-first `.sql`** — never writes the DB directly. Supersedes the older sibling `retrofix.php` in the same folder (superseded/kept only for reference). The domain facts it repairs against (the supply chain, item-link bridges, both fulfillment flows, over-fulfill cap) live in the Compass [order-lifecycle & data-integrity workflow](../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md); this doc covers the **script's structure**.
26
+ `@jeff/compass/retrofix2.php` emits **review-first `.sql`** — never writes the DB. It repairs Compass SA orders broken by past bugs (incl. cron 3a's last-ODP-line link bug, see [ODP pipeline](../../../../clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md)). Supersedes `retrofix.php`. Domain facts: [order-lifecycle & data-integrity](../../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md). For one order in prod, use [fix_item_fulfillment_chain](compass-fix-item-fulfillment-chain.md) instead.
24
27
 
25
- ## How it works
26
-
27
- `processOrder()` runs three phases per Compass SO, newest-first. **Phase ordering is load-bearing: Phase 0 must run before Phase 1/2** because units are **shared up the chain** (the same `Units.id` at every level), so unit corrections must land before Phase 2 mirrors fulfillments upward.
28
-
29
- ### Phase 0 — CG-unit asset-tag merge (new)
28
+ **Worklist = SA orders only** (developer decision; MR/MA excluded on purpose).
30
29
 
31
- Reproduces retroactively what the forward fix does at `library/app/api/toga2.php` (~lines 3688–3965): NetSuite delivers asset tags on **separate service-item lines** whose part number contains `ASSETTAG` (e.g. `ODP-COMPASS-ASSETTAG1`); older Compass imports landed each asset tag as its **own `Units` row** whose `serialNumber` holds the CG value (e.g. `CG2000027730`), attached via `ItemFulfillmentItemUnits` alongside the real serialized unit on the same fulfillment. Phase 0 folds each duplicate CG unit into its real device.
32
-
33
- Per fulfillment, order the IFIU-attached units by `ItemFulfillmentItemUnits.id` and split into CG (serial starts `CG`) vs real (non-CG serialized), then:
34
- - **1:1** → merge (≈17,500 fulfillments).
35
- - **equal N:N** → order-pair and emit a `REVIEW` comment (≈7).
36
- - **unequal, or zero real units** → logged and skipped (≈184).
30
+ ## How it works
37
31
 
38
- Each merge emits, in order:
39
- 1. `UPDATE Units SET assetTag = <CG serial>` on the real unit — **only if empty; never clobbers a different existing tag**.
40
- 2. Repoint `InventoryAdjustmentItemUnits.unitId` CG→real (decided: **repoint, not delete**, to preserve stock history — 14,249 CG units have inventory-adjustment rows).
41
- 3. Delete the CG unit's `ItemFulfillmentItemUnits` rows (and their `..._TrackingNumbers` bridges).
42
- 4. `DELETE` the CG unit.
32
+ ### Running it
33
+ 1. Generate **one phase at a time against the target DB**, apply it, then generate the next. Each phase reads the previous phase's applied result — generating all phases up front gives wrong SQL.
34
+ 2. Order is load-bearing: Phase 0 → 1 → 2 (units are shared up the chain by `Units.id`).
43
35
 
44
- Scope: **IFIU-attached CG units only** (per developer). DB facts at time of build: 22,288 total CG-serial units, none with `assetTag` set; 19,214 attached to an IFIU.
36
+ Local run 2026-04-01..2026-10-15 SA orders: Phase 0 **800** CG merges; Phase 1 **4,404** bad links deleted, **5,606 + 2,321 + 50** added (all verified by part number); Phase 2 **2,024** new upstream IFs, **0** over-fulfillment. Not yet applied to prod.
45
37
 
46
- ### Phase 1 — header/link/bridge repairs (levels L1–L4)
38
+ ### Phase 0 — CG-unit asset-tag merge
39
+ Folds the duplicate CG asset-tag `Units` row (serial `CG…`, from NetSuite `ASSETTAG` service lines) into the real device on the same IF — retro version of the forward fix in `library/app/api/toga2.php`. Per IF: 1:1 → merge; equal N:N → pair + `REVIEW`; unequal / no real unit → skip. Merge = set `assetTag` (only if empty) → repoint `InventoryAdjustmentItemUnits` → delete CG IFIU rows (+ tracking bridges) → delete CG unit.
40
+ - **Reach limit:** only CG units under Compass **SA** order chains. **19,214** CG units remain IFIU-attached locally (mostly MR/MA, pre-April, or no chain). Newest CG unit 2026-05-19 — the NetSuite sync stopped creating them.
47
41
 
48
- Repairs the supply-chain header chain and the item-link bridges. Levels:
49
- - **L1** `repairOdpPoToAgilantSoLinks` — **always calls NetSuite** (`buildNetsuiteGroupMap`), even when the L1 links already exist.
50
- - **L2/L3/L4** — NetSuite-free bridge repairs. **L3** repairs the `PurchaseOrderItems_SalesOrderItems` rung (MITS PO item → ODP SO item) that the Flow-B recursive IF mirror depends on.
42
+ ### Phase 1 — link repairs (L1–L4)
43
+ - **L1** (ODP PO → Agilant SO links, calls NetSuite): **add-only**, and only onto **real** ODP PO lines (item/part present on the ODP SO). Match part + qty, then part-number fallback, then kit fallback ("one free real line takes leftovers"). **Never deletes** — the old L1 deleted good links because Agilant items are catalog-2 twins.
44
+ - **L2 / L3 / L4** match by **part number (`lineItemKey`), not `itemId`**: cron 3a resolves 850 lines through ODP `VendorItems`, so the ODP SO line is often the catalog-2 twin (SA139173: item 2387 vs 2394). Repeated parts pair one-to-one by equal qty; ambiguous items keep existing links (`protectAmbiguousLinks`); an unmatched line's links are never deleted.
45
+ - **L2 never creates ODP SO items.** It was about to add 2,367 junk lines from NetSuite-appended kit parts.
46
+ - `runLevel()` isolates each level: an exception (e.g. L1 NetSuite SSL/SOAP) is logged as a skip, the other levels still emit.
51
47
 
52
48
  ### Phase 2 — fulfillment mirror climb
49
+ Walks each source IF up Agilant → ODP → Compass, emitting the mirror IFs.
50
+ - Qty scaling uses the engine's **distinct-item** rule ([recursive IF](../../../../2.0/apps/_underscore/features/recursive-item-fulfillments.md)).
51
+ - `capToOpenQuantity` never over-fulfills: skips lines already filled by an ASN-made IF (emits a `REVIEW` comment).
52
+ - `fillSourceLineTracking` fills 318/319 on source IFs with exactly one header tracking number.
53
+ - An IF already reached in this run is **not climbed again** (old behavior emitted 390 duplicate units + 124 duplicate line-tracking rows → unique-key failure). Duplicate IFIU rows on reconciled lines are deleted.
53
54
 
54
- Walks each source ItemFulfillment up the chain (Agilant → ODP → Compass), creating the mirror IFs.
55
-
56
- ### Level isolation — `runLevel()`
57
-
58
- Originally Phase 1 ran L1→L4 with **no isolation**. Because L1 unconditionally calls NetSuite, a NetSuite SSL/SOAP failure there threw, `main()`'s catch marked the **whole order FAILED**, and the independent NetSuite-free L2/L3/L4 repairs (including the L3 rung Phase 2 needs) were skipped — even though L1 usually had nothing to insert (the affected orders' Agilant SOIs were already fully linked, so the NetSuite call was pure overhead/risk).
59
-
60
- `runLevel()` wraps each Phase-1 level (and each Phase-2 source-IF climb) so **a level's exception is logged as a skip and swallowed**, letting the remaining independent levels still emit. This is the core reliability fix: independent repairs no longer share a failure fate with L1's external call.
61
-
62
- ### Write guards — `emit()` vs `emitUnit()`
63
-
64
- - `emit()` — the default SQL sink; **hard-blocks any write to the `Units` and `TrackingNumbers` tables**.
65
- - `emitUnit()` — a narrow helper for the Phase-0 units track that **permits `Units` UPDATE/DELETE but still blocks `TrackingNumbers`**. A deliberate, scoped exemption to the never-write-`Units` guard, used only by Phase 0.
55
+ ### Write guards
56
+ - `emit()` — blocks any write to `Units` and `TrackingNumbers`.
57
+ - `emitUnit()` — Phase 0 only: allows `Units` UPDATE/DELETE, still blocks `TrackingNumbers`.
66
58
 
67
59
  ## Gotchas
60
+ - **Never emit writes to `TrackingNumbers`** — existing rows are only linked.
61
+ - **Link repair is add-only by default.** Deleting a link because `itemId` differs is wrong on Compass: catalog-2 twins and kits make legit links join different items. Match on part number.
62
+ - **Phases depend on applied results** — never batch-generate phases 1 and 2 before phase 1 is applied.
63
+ - Phase-0 asset-tag `UPDATE` only fills an empty tag.
68
64
 
69
- - **Phase ordering is load-bearing** — Phase 0 before Phase 1/2 (units shared up the chain via the same `Units.id`).
70
- - **Never emit writes to `TrackingNumbers`.** Existing tracking rows are only ever linked, never created (consistent with the Compass workflow invariants). `emit()` and `emitUnit()` both block `TrackingNumbers`.
71
- - Phase-0 asset-tag `UPDATE` writes **only if empty** — never clobbers a different existing tag.
72
- - L1 always calls NetSuite even when links exist — `runLevel()` isolation exists so an L1 SSL/SOAP failure no longer fails the whole order.
65
+ ## Change history
66
+ - 2026-10-15 — L1 add-only on real ODP lines with part/kit fallbacks; L2 stops creating ODP SO items; L2–L4 match by part number; Phase 2 distinct-item scaling, no over-fulfill, single-tracking fill, no re-climb; SA-only worklist; phase-at-a-time run rule; Phase 0 reach limit recorded. (jcardinal)
67
+ - 2026-09-16 — Phase 0 CG merge and `runLevel()` isolation. (jcardinal)
73
68
 
74
69
  ## Related
75
-
70
+ - [fix_item_fulfillment_chain — per-order tool](compass-fix-item-fulfillment-chain.md)
76
71
  - [Test architecture](../architecture.md)
77
- - [Compass order-lifecycle & data-integrity workflow](../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md)
78
- - [MITS PO-to-SO item linking](../../../clients/compass-usa/features/mits-po-to-so-item-linking.md)
72
+ - [Compass order-lifecycle & data-integrity](../../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md)
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-14
10
- owners: [bala, rgirish]
9
+ updated: 2026-10-15
10
+ owners: [bala, rgirish, jcardinal]
11
11
  files:
12
12
  - _underscore/Model/Client/SalesOrder.php
13
13
  - _underscore/Model/Quad/SalesOrder.php
@@ -40,18 +40,12 @@ This makes the *picked/packed* portion of the fulfillment lifecycle visible (pre
40
40
  *shipped* IFs were imported, so picked/packed were invisible) and drives a calculated order
41
41
  `_status` state machine. Two deliberately different status policies exist:
42
42
 
43
- - **Base + Quad:** order status walks the full machine — Pending → Picked → Packed →
44
- Partially Fulfilled → Fulfilled — so non-Compass clients see the picked/packed lifecycle.
45
- - **Compass USA + Compass Canada:** all IF stages are *imported*, but order status counts
46
- **shipped only** — picked/packed never advance a Compass order's status. Compass walks
47
- Pending → Partially Fulfilled → Fulfilled by shipped quantity alone. **The shipped-only *stage*
48
- policy is shared, but the fulfilled-vs-partiallyFulfilled *rule* is now per region.** **Only Canada
49
- actually changed:** the fulfillment branch lives in `_fulfillmentStatus()`, Canada overrides it with
50
- a pure per-line rule, and **Compass USA still runs the original order-total logic on purpose**
51
- (its ASN feed is not trustworthy at line level). The seam was reverted in git on 2026-08-26 and
52
- rebuilt uncommitted. See
53
- [Compass USA](../../../clients/compass-usa/features/order-fulfillment-status-per-line.md) and
54
- [Compass Canada](../../../clients/compass-canada/features/order-fulfillment-status-per-line.md).
43
+ - **Base + Quad + Compass USA/Canada:** order status walks the full machine — Pending → Picked →
44
+ Packed → Partially Fulfilled → Fulfilled — via the base `_fulfillmentStatus()` (per line, skips
45
+ `Items.isFulfillable = 0`). **Compass joined the base rule in TRUE-79669 (2026-10-15, not yet
46
+ deployed)**; before that Compass counted shipped only, USA by order totals and Canada by an
47
+ ASN-bridge override. Compass keeps only its approval gates. See
48
+ [Compass order fulfillment status](../../../clients/compass-usa/features/order-fulfillment-status-per-line.md).
55
49
 
56
50
  Independently of order status, **inventory movement (`qtyFulfilled` / `qtyCommitted`) is
57
51
  shipped-only for ALL clients** — picked/packed quantities never count as inventory movement.
@@ -59,21 +53,16 @@ shipped-only for ALL clients** — picked/packed quantities never count as inven
59
53
  ## How it works
60
54
  ### Key files / entry points
61
55
 
62
- - **`Model/Client/SalesOrder.php`** — base `_status` calculated field. With `salesOrderStageId`
63
- NULL and a non-SA order: 0 fulfillments → Pending Fulfillment; nothing shipped → Picked (if
64
- any line picked) else Packed; something shipped → Partially Fulfilled (shipped qty < ordered)
65
- else Fulfilled. An explicit `salesOrderStageId` overrides everything.
56
+ - **`Model/Client/SalesOrder.php`** — base `_status` calculated field (approval gates, then
57
+ `static::_fulfillmentStatus()`). `_fulfillmentStatus()` (`protected static`): 0 fulfillments →
58
+ Pending Fulfillment; nothing shipped → Picked (if any line picked) else Packed; any line with
59
+ `COALESCE(isFulfillable,1) = 1` and shipped qty < ordered → Partially Fulfilled; else Fulfilled.
60
+ An explicit `salesOrderStageId` overrides everything.
66
61
  - **`Model/Quad/SalesOrder.php`** — Quad `_status`. `SA%` orders delegate to the parent; non-SA
67
62
  orders use the inline picked/packed/partial/fulfilled machine.
68
- - **`Model/Compass/SalesOrder.php`** — Compass `_status`, shipped-only: both the existence
69
- `COUNT` and the shipped-qty `SUM` subqueries `INNER JOIN ItemFulfillmentStages →
70
- ItemFulfillmentStatuses` filtered to the shipped status slug. Shared by Compass USA + Canada.
71
- Its **fulfillment half is a separate `protected static _fulfillmentStatus()`** called via
72
- `static::` — that is the per-region seam (see the Compass docs above), and its **body is the original
73
- order-total logic that USA still runs**. The approval gate stays in
74
- `_status()` and is region-independent.
75
- - **`Model/Compass/Canada/SalesOrder.php`** — Canada's `_fulfillmentStatus()` override (per-line,
76
- ASN-bridge only).
63
+ - **`Model/Compass/SalesOrder.php`** — Compass `_status`: its own stage / denial / pending-approval
64
+ gates, then the inherited base `static::_fulfillmentStatus()`. Shared by Compass USA + Canada;
65
+ Canada has no override.
77
66
  - **`Model/Compass/SalesOrderStatus.php`** — adds `const SLUG__PICKED = 'picked'` and
78
67
  `SLUG__PACKED = 'packed'`.
79
68
  - **`Model/Client/{SalesOrderItem,Item,PurchaseOrderItem}.php`** — the `_qtyFulfilled` /
@@ -105,15 +94,13 @@ fulfillment statuses — filter widened to `['_picked','_packed','_shipped']`:
105
94
  (`c_netsuiteInternalItemFulfillmentStatus`) instead of hardcoding shipped.
106
95
  - **On re-sync** it compares the current stage's `c_netsuiteInternalItemFulfillmentStatus` against
107
96
  the NetSuite status and PUTs a new stage, so a fulfillment advances picked → packed → shipped.
108
- - Applies to **all** clients including Compass / Compass Canada (which import all stages but still
109
- count shipped-only for order status — see below).
97
+ - Applies to **all** clients including Compass / Compass Canada.
110
98
 
111
99
  #### Order-status machines
112
100
 
113
101
  | Client | Statuses walked | Driven by |
114
102
  |---|---|---|
115
- | Base, Quad (non-SA) | Pending → Picked → Packed → Partially Fulfilled → Fulfilled | all IF stages |
116
- | Compass USA + Canada | Pending → Partially Fulfilled → Fulfilled | **shipped IF stage only**; fulfilled-vs-partial rule differs per region (`_fulfillmentStatus()`) |
103
+ | Base, Quad (non-SA), Compass USA + Canada | Pending → Picked → Packed → Partially Fulfilled → Fulfilled | all IF stages; per-line shipped qty, `isFulfillable = 0` lines skipped (Compass after its approval gates) |
117
104
  | Elite | pendingFulfillment → picked → packed → partiallyFulfilled → shipped / fulfilled → delivered (+ canceled) | all IF stages + shipment state; **no approval stage** |
118
105
 
119
106
  Quad `SA%` orders delegate to the base machine; an explicit `salesOrderStageId` overrides the
@@ -188,25 +175,15 @@ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is
188
175
  matched (empty `SUM` = NULL), and `NULL < orderedQty` is not true, so empty orders read
189
176
  **Fulfilled**. Wrap the shipped-qty subquery with `IFNULL(…, 0)` before comparing to the ordered
190
177
  quantity.
191
- - **Compass imports all stages but counts shipped-only.** Do not assume importing picked/packed
192
- IFs changes a Compass order's status — by design it does not (the Compass supply UI must show
193
- shipped-only progression). This supersedes the old "stages 1/2 are unused in prod" assumption in
194
- the Compass order-lifecycle workflow.
178
+ - **The base per-line rule needs `isFulfillable = 0` on every non-shipping item** (fees, services,
179
+ warranties). It has no price / PO-link / parent-line filter, so a NULL or 1 flag on a fee line
180
+ holds the order at Partially Fulfilled forever. Compass data fixes: see the
181
+ [Compass status doc](../../../clients/compass-usa/features/order-fulfillment-status-per-line.md).
195
182
  - **⚠ An order-level "ordered total vs shipped total" comparison is not a fulfillment test.** A
196
- grand total lets one line's surplus cancel another line's gap, so an order with one line short and
197
- another over-shipped reads **Fulfilled**. This is what the Compass `_status` does for **USA
198
- today** — and its two sides do not even count the same lines (the ordered side filters
199
- `parentSalesOrderItemId IS NULL`, the shipped side does not). Measured on prod: **90** Canada orders
200
- were mislabeled and have been corrected by a per-line rule, and **5,223** USA orders would move —
201
- but the USA change was **deliberately not made**, because duplicate Office Depot ASNs make its
202
- per-line shipped quantity unreliable. Any client `_status` that judges completeness must compare
203
- **per line**, and must not do so on a feed that double-credits lines. **⚠ Earlier figures of 5,234
204
- USA / 115 Canada are void** (the first attempt was reverted in git). See the two Compass docs
205
- linked above.
206
- - **A `self::`-qualified call to a calculated field inside the same model always uses the parent's
207
- logic.** `_Model_Compass_SalesOrder` calls `self::_status()` in its ApprovalDecision notification
208
- query, so that one call site never sees a region subclass override. Use `static::` unless you
209
- specifically want the parent.
183
+ grand total lets one line's surplus cancel another line's gap. Compass USA ran exactly this until
184
+ TRUE-79669. Judge completeness **per line**.
185
+ - **A `self::`-qualified call to a calculated field always uses that class's own logic**, never a
186
+ subclass override. Use `static::` to reach overrides (`_status` → `static::_fulfillmentStatus()`).
210
187
  - The base/Compass picked check is a string literal `'picked'` (no `SLUG__PICKED` on
211
188
  `_Model_Client_ItemFulfillmentStatus`); only the shipped check uses a constant. Adding a base IF
212
189
  picked/packed constant would let the literal be removed.
@@ -218,6 +195,7 @@ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is
218
195
  added (2026-10-08). No frontend code change is needed for a new slug.
219
196
 
220
197
  ## Change history
198
+ - 2026-10-15 — TRUE-79669: Compass USA + Canada moved to the base `_fulfillmentStatus()` (per line, skips `isFulfillable = 0`, shows Picked/Packed); base rule extracted into `protected static _fulfillmentStatus()`. Not deployed. (jcardinal)
221
199
  - 2026-10-14 — Elite `_status` now shows Picked / Packed from NetSuite IF stages; Core vocabulary
222
200
  terms added for picked/packed/shipped/delivered; billing statuses kept out by decision (rgirish)
223
201
  - 2026-09-16 — Compass per-region `_fulfillmentStatus()` seam; Elite six-status ladder documented (bala)
@@ -234,6 +212,6 @@ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is
234
212
  - [Compass ASN → ItemFulfillment Auto-Creation](../../../clients/compass-usa/features/asn-to-item-fulfillment.md)
235
213
  — ASN-created IFs now resolve the shipped stage.
236
214
  - [Compass USA — Fulfilled vs Partially Fulfilled](../../../clients/compass-usa/features/order-fulfillment-status-per-line.md)
237
- — the per-region fulfillment rule (order aggregate AND per-line).
215
+ — Compass status on the base rule; Compass data fixes.
238
216
  - [Compass Canada — Fulfilled vs Partially Fulfilled](../../../clients/compass-canada/features/order-fulfillment-status-per-line.md)
239
- — Canada's per-line, ASN-bridge-only override.
217
+ — Canada-specific notes.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-22
9
+ updated: 2026-10-15
10
10
  owners: [jcardinal, rgirish]
11
11
  files:
12
12
  - _underscore/Model/Client/ItemFulfillment.php
@@ -41,7 +41,7 @@ The whole engine is in `_Model_Client_ItemFulfillment`; the three child models a
41
41
 
42
42
  | File | Hooks | Role |
43
43
  |---|---|---|
44
- | `Model/Client/ItemFulfillment.php` | `postPost`, `postPut` | engine: `ensureUpstreamItemFulfillment`, `reconcileUpstreamLevel`, `reconcileUpstreamUnits`, `reconcileUpstreamPackages`, `buildUpstreamHeaderPayload`, `buildUpstreamHeaderUpdatePayload` |
44
+ | `Model/Client/ItemFulfillment.php` | `postPost`, `postPut` | engine: `ensureUpstreamItemFulfillment`, `reconcileUpstreamLevel`, `reconcileUpstreamItemTracking`, `reconcileUpstreamUnits`, `reconcileUpstreamPackages`, `buildUpstreamHeaderPayload`, `buildUpstreamHeaderUpdatePayload` |
45
45
  | `Model/Client/ItemFulfillmentItem.php` | `postPost`, `postPut` | resolve owning IF uuid → driver |
46
46
  | `Model/Client/ItemFulfillmentItemUnit.php` | `postPost`, `postPut` | resolve owning IF uuid → driver |
47
47
  | `Model/Client/ItemFulfillmentPackage.php` | `postPost`, `postPut` | resolve owning IF uuid → driver |
@@ -61,7 +61,7 @@ Triggers live on every child (not just the IF) so the sync is robust to all path
61
61
  2. Resolve upstream SO via the **header walk** (bridge tables); none → null (top of chain).
62
62
  3. Build desired upstream items: map each downstream IFI's SOI up via the **item walk**, GROUP BY upstream SOI, sum fulfilled qty, then **scale into upstream order units** — but only for genuine multi-component bundles, keyed on **distinct downstream items** (see Gotchas → Bundle scaling). A single distinct downstream item (incl. accepted over-ship) mirrors unscaled.
63
63
  4. Resolve-or-create the upstream IF (**eager** — header-only POST still builds the chain): load existing via `upstreamItemFulfillmentId` and PUT changed header fields, or POST a new IF with mirrored header + upstream SO then PUT the downstream IF's link.
64
- 5. Reconcile upstream IFIs keyed by `salesOrderItemId`: update qty, create missing, link `upstreamItemFulfillmentItem`, delete stale (units first, then item).
64
+ 5. Reconcile upstream IFIs keyed by `salesOrderItemId`: update qty, create missing, link `upstreamItemFulfillmentItem`, delete stale (units first, then item). Then `reconcileUpstreamItemTracking()` copies line-level tracking (**318**) from the grouped downstream IFIs onto the upstream IFI — **add-only, never deletes**.
65
65
  6. Reconcile units per IFI matched on `unitId`; reconcile packages on the IF matched on `trackingNumberId`: add missing, update changed tracking, delete leftovers.
66
66
  7. Return upstream IF uuid → loop continues upward.
67
67
 
@@ -86,9 +86,10 @@ Engine is shared `_Model_Client_*` logic. Compass uses **empty pass-through subc
86
86
  - **Diagnosing over-ship vs. phantom duplicate:** traverse the bridge — upstream SOI → `SalesOrderItems_PurchaseOrderItems` → POI → `PurchaseOrders_SalesOrders` → downstream ODP SOs (`customerId=1`); compare downstream vs. upstream `itemId` (**same itemId ⇒ over-ship / duplicate sourcing, NOT a bundle**); then check `ItemFulfillmentItemUnits` serials — distinct serials ⇒ genuine over-shipment, identical ⇒ phantom duplicate.
87
87
  - **DELETE propagation is NOT implemented.** The engine sets `$outData = null` on DELETE so post-delete hooks never fire; a standalone downstream DELETE doesn't immediately remove its upstream mirror. A later reconciling PUT cleans up stale records via set comparison. Full delete handling would need a `preDelete` mechanism + `PRE/DELETE` interceptor rows.
88
88
  - **Transfer-order fulfillments are explicitly excluded from the upstream walk.** `reconcileUpstreamLevel()` returns `null` immediately when the downstream IF has `transferOrderId` set (no `salesOrderId`). Intentional for standalone TOs (GroWrk v1). When SO→TO→SO chain scenarios are needed, Phase 5 of the GroWrk transfer order plan extends this method to walk `TransferOrders_SalesOrders` bridge tables — see `clients/growrk/features/transfer-order-flow.md`.
89
- - **Broken SOI↔POI bridge → empty header-only duplicate IFs (root cause, guard added 2026-07-02).** The item walk maps a downstream IFI up via `IFI.salesOrderItemId → PurchaseOrderItems_SalesOrderItems → SalesOrderItems_PurchaseOrderItems`. When that upstream `SalesOrderItems_PurchaseOrderItems` bridge is **missing/broken** (the same integrity class as the ~116 off-by-one Compass orders — see the Compass `asn-to-item-fulfillment` doc), `$desiredItems` resolves to **zero** even though the downstream IF has items. Pre-guard, step 4's eager resolve-or-create then POSTed an **empty header-only upstream IF** (e.g. Compass SA133377 got duplicate empty customer-facing IFs 71724/71725 alongside the legit direct-ASN IF 70635). **Guard:** `reconcileUpstreamLevel` now, after building `$desiredItems`, if it is empty **AND** the downstream IF has >0 ItemFulfillmentItems, `error_log()`s a diagnostic and returns `null` (skips the upstream mirror) — distinguished from the legitimate header-first POST case (downstream IF genuinely has 0 items), which is unchanged.
89
+ - **Broken SOI↔POI bridge → empty header-only duplicate IFs (root cause, guard added 2026-07-02).** The item walk maps a downstream IFI up via `IFI.salesOrderItemId → PurchaseOrderItems_SalesOrderItems → SalesOrderItems_PurchaseOrderItems`. When that upstream `SalesOrderItems_PurchaseOrderItems` bridge is **missing/broken** (the same integrity class as the ~116 off-by-one Compass orders — see the Compass `asn-to-item-fulfillment` doc), `$desiredItems` resolves to **zero** even though the downstream IF has items. Pre-guard, step 4's eager resolve-or-create then POSTed an **empty header-only upstream IF** (e.g. Compass SA133377 got duplicate empty customer-facing IFs 71724/71725 alongside the legit direct-ASN IF 70635). **Biggest Compass source (fixed 2026-10-06, `worker` `02cc2473`):** cron 3a linked every Compass PO line to the **last** Office Depot SO line in `PurchaseOrderItems_SalesOrderItems` (since 2026-04-28), so the ODP→Compass tier resolved to nothing and this guard skipped — the Compass order got no IF or tracking. See [ODP order pipeline](../../../../clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md). **Guard:** `reconcileUpstreamLevel` now, after building `$desiredItems`, if it is empty **AND** the downstream IF has >0 ItemFulfillmentItems, `error_log()`s a diagnostic and returns `null` (skips the upstream mirror) — distinguished from the legitimate header-first POST case (downstream IF genuinely has 0 items), which is unchanged.
90
90
  - **Duplicate customer-facing IF: the engine only adopts an upstream IF it created itself, never a pre-existing one (known gap, adoption DEFERRED).** `reconcileUpstreamLevel` finds an existing upstream IF only via `upstreamItemFulfillmentId`; it does **not** adopt a pre-existing IF that already sits on the upstream SO from another flow (e.g. a direct-ASN-created IF). So a broken bridge could yield two customer-facing IFs on the same SO (SA133377: direct-ASN IF 70635 + a mirror-created duplicate). **Naive adoption is dangerous and was intentionally not implemented:** step 5's delete-stale step deletes upstream IFIs with no matching downstream group, so adopting an IF while the item walk is empty/broken would **DELETE the adopted IF's legitimate line items**. Requires a CTO design review before implementing adoption semantics. The 2026-07-02 broken-bridge guard prevents the duplicate cascade for this failure mode without needing adoption.
91
91
  - **⚠ NARROWED 2026-09-18 — the 317/318/319 interceptors ARE now required, for a different job than the one rejected in August.** The August decision below still stands for **populating** item/unit tracking from a header number; that work belongs in the NetSuite source writer. But the bridges also need an interceptor to **trigger the upstream walk** when tracking is attached after the chain already exists, and that one was missing entirely. Records **317/318/319 had ZERO `ApiPayloadInterceptors` rows** while their parents 28/29/30 had them: header tracking used to live in `ItemFulfillmentPackages` (record 41) with its own interceptor, and when that table was consolidated into the plain bridge `ItemFulfillments_TrackingNumbers`, **the registration did not follow the data**. So `reconcileUpstreamPackages()` — correct code — never fired on a tracking POST. Cost for Compass: **1,819 stranded orders since 2026-08-01, 95% of them tracking added after the chain was built.** Fix = `postPost`/`postPut` + `triggerUpstreamSync()` on the three bridge models **plus** `dbchanges2/Core/2026-09-18a - ItemFulfillmentTrackingNumberInterceptors.sql`. The two jobs do not fight: the writer decides *what* tracking a row carries, the interceptor only re-runs the mirror. See [Compass multi-tier tracking & serial mirroring](../../../clients/compass-usa/features/multi-tier-tracking-and-serial-mirroring.md). **Generalize the lesson: when a table is consolidated or replaced, audit `ApiPayloadInterceptors` for the old record id — a lost registration is silent, never an error.**
92
+ - **Line-level tracking (318) was never mirrored until 2026-10-06 (`_underscore` `46877314`, `_production`).** The engine copied header (317) and unit (319) tracking up but not 318: **645 of 645** chain-created upstream lines since 2026-09-25 had none. `reconcileUpstreamItemTracking()` now copies 318 **add-only** — it must never delete, because the Compass ASN flow also attaches tracking directly to those upstream lines. Do not "symmetrize" it with the delete-stale logic used for units/packages.
92
93
  - **Units mirror on `unitId` ONLY — never on part number.** A unit is one physical device, so the shared `unitId` is the only safe key. **32,585** `upstreamItemFulfillmentItemId` links legitimately join **different** part numbers (kit/bundle roll-ups, vendor suffixes like `C40QYUC#ABA` → `C40QYUC`, service lines). Those are correct data; a "repair" that aligns part numbers across tiers corrupts them.
93
94
  - **Source-side IF tracking population belongs in the NetSuite sync writer, not a 317 interceptor (design decision, CTO-reviewed 2026-08-26; narrowed 2026-09-18 — see above).** For NetSuite-integrated clients the fix that populates item/unit tracking (318/319) from a fulfillment's single header tracking number lives in the **source writer** `App_Api_Toga2::syncItemFulfillmentFromNetsuite` (see the [per-client sync doc](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md)) — **not** an `_underscore` payload interceptor on `ItemFulfillments_TrackingNumbers` (317). An interceptor fires **per row** as tracking is attached, so it cannot know at insertion time whether the shipment is single- or multi-package, and it would **fight this engine**: `reconcileUpstreamUnits`/`reconcileUpstreamPackages` own upstream 319 with delete-stale logic. The source writer knows NetSuite's one tracking number and the full item/unit set up front, and this recursive engine then mirrors it upstream. A 317-interceptor approach was prototyped and **abandoned**.
94
95
  - **Out of scope:** NetSuite sync of upstream IFs; transfer-order fulfillments (`transferOrderId`) are skipped.
@@ -101,6 +102,7 @@ Engine is shared `_Model_Client_*` logic. Compass uses **empty pass-through subc
101
102
  - `api2` architecture (V2 metadata engine that fires these interceptors; the commit-logs / rollback-data-on-failure transaction invariant the throws rely on).
102
103
 
103
104
  ## Change history
105
+ - 2026-10-06 — Added `reconcileUpstreamItemTracking()`: line-level tracking (318) now mirrors up, add-only (645/645 chain lines lacked it). Recorded cron 3a's last-line link bug as the main broken-bridge source on Compass. (jcardinal)
104
106
  - 2026-09-22 — Narrowed the "no 317 interceptor" decision: the three tracking bridges (317/318/319) had **zero** `ApiPayloadInterceptors` rows — lost when `ItemFulfillmentPackages`/record 41 was consolidated — so the upstream walk never ran when tracking was attached after the chain existed (1,819 stranded Compass orders). Added `postPost`/`postPut` to the three bridge models + a Core registration migration. Recorded that units mirror on `unitId` only (32,585 legitimate cross-part links) and that the bridge models live in plural-named subfolders. (rgirish)
105
107
  - 2026-08-18 — Bundle scaling re-gated on distinct downstream **items** rather than downstream SOI lines, fixing same-item over-shipments mirroring as fractional quantities (Compass SA135471). (jcardinal)
106
108
  - 2026-07-02 — Added the broken-bridge guard: an empty `$desiredItems` on a downstream IF that has items now logs and skips instead of POSTing an empty header-only upstream IF (Compass SA133377 duplicates). (jcardinal)
@@ -12,7 +12,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
12
12
  - **togaview** (TOGa View) — 8 doc(s) → [1.0/apps/togaview/INDEX.md](1.0/apps/togaview/INDEX.md)
13
13
  - **webhook** (Webhook) — 1 doc(s) → [1.0/apps/webhook/INDEX.md](1.0/apps/webhook/INDEX.md)
14
14
  - **walmarttechservices** (Walmart Tech Services) — 1 doc(s) → [1.0/apps/walmarttechservices/INDEX.md](1.0/apps/walmarttechservices/INDEX.md)
15
- - **test** (Test) — 16 doc(s) → [1.0/apps/test/INDEX.md](1.0/apps/test/INDEX.md)
15
+ - **test** (Test) — 17 doc(s) → [1.0/apps/test/INDEX.md](1.0/apps/test/INDEX.md)
16
16
  - **toga** (TOGa) — 4 doc(s) → [1.0/apps/toga/INDEX.md](1.0/apps/toga/INDEX.md)
17
17
  - **store** (Store) — 3 doc(s) → [1.0/apps/store/INDEX.md](1.0/apps/store/INDEX.md)
18
18
  - **tools** (Tools) — 25 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
@@ -7,7 +7,7 @@
7
7
  | [Grand & Toy ASN Import (Compass Canada)](features/grand-and-toy-asn-import.md) | 2.0 | Imports Grand & Toy ASNs for Compass Canada (CSV cron + cXML gateway) and auto-builds the ItemFulfillment chain + bilingual in-transit email; open for G&T ASN f |
8
8
  | [Compass Canada — Item-Fulfillment TableViews (dead-Core-id 500, rebuilt onto the USA shape)](features/item-fulfillment-tableviews.md) | 2.0 | Why Compass Canada's item-fulfillment TableViews 500'd on dead Core ids and how they were rebuilt onto the USA shape; open when a Canada TableView 500s on /meta |
9
9
  | [Compass Canada: PO-to-vendor transmission monitor (Monitor/Compass/CompassCanadaPurchaseOrderTransmissionsToVendors)](features/oneuptime-po-transmissions-to-vendors-monitor.md) | 2.0 | worker2 OneUptime push monitor for Compass Canada POs the 1.0 cron has not yet sent to the vendor (Grand & Toy), plus how the Canada PO transmit cron differs fr |
10
- | [Compass Canada — Fulfilled vs Partially Fulfilled (per-line, ASN bridge only)](features/order-fulfillment-status-per-line.md) | 2.0 | Compass Canada's pure per-line fulfilled/partiallyFulfilled rule (ASN bridge only) and why it diverges from USA; open before touching Canada `_fulfillmentStatus |
10
+ | [Compass Canada — order fulfillment status (standard base rule, no Canada override)](features/order-fulfillment-status-per-line.md) | 2.0 | Canada-specific facts for the shared Compass order fulfillment status (standard base rule since TRUE-79669); open before touching Canada order status or Canada |
11
11
  | [Compass Canada](profile.md) | 2.0 | Compass Canada is the Canadian arm of the Compass account — a separate TOGA tenant, related to but distinct from Compass USA. |
12
12
  | [Copying a Compass USA user into the Canada tenant (cross-tenant hand-add)](workflows/copying-a-us-user-into-the-canada-tenant.md) | 2.0 | Giving a Compass **USA** person a login on the Canada TechHub portal: the guarded insert chain, the uuid role/persona mapping, and the worker2 guard that stops |
13
13
  | [Grand & Toy ASN Backfill (cXML replay + CSV-to-JSON)](workflows/grand-and-toy-asn-backfill.md) | 2.0 | How to recover Compass Canada shipments whose ASN never landed (cXML replay, CSV-to-JSON, or SQL line backfill); open when replaying a G&T outage or repairing a |
@@ -1,102 +1,42 @@
1
1
  ---
2
- title: Compass Canada — Fulfilled vs Partially Fulfilled (per-line, ASN bridge only)
2
+ title: Compass Canada — order fulfillment status (standard base rule, no Canada override)
3
3
  framework: "2.0"
4
4
  repo: _underscore
5
5
  project: _Underscore
6
6
  client: compass-canada
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala"]
9
+ updated: 2026-10-15
10
+ owners: ["bala", "jcardinal"]
11
11
  files:
12
+ - _underscore/Model/Client/SalesOrder.php
12
13
  - _underscore/Model/Compass/Canada/SalesOrder.php
13
- - _underscore/Model/Compass/SalesOrder.php
14
+ - dbchanges2/Client_CompassCanada/2026-10-15a - NonShippableItemTypesNotFulfillable.sql
14
15
  related:
15
16
  - ../../compass-usa/features/order-fulfillment-status-per-line.md
17
+ - ../../compass-usa/features/isfulfillable-data-quality-and-type-rule.md
16
18
  - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
17
- - ../../../2.0/apps/_underscore/features/calculated-sql-fields.md
18
19
  - grand-and-toy-asn-import.md
19
- - ../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md
20
20
  ---
21
- Compass Canada's pure per-line fulfilled/partiallyFulfilled rule (ASN bridge only) and why it diverges from USA; open before touching Canada `_fulfillmentStatus()` or comparing the two regions.
21
+ Canada-specific facts for the shared Compass order fulfillment status (standard base rule since TRUE-79669); open before touching Canada order status or Canada `isFulfillable`.
22
22
 
23
23
  ## Summary
24
- TOGa Supply showed **Fulfilled** on Compass Canada orders that were only partly shipped. Reported on `SAC100664` (`SalesOrders.id 665`): line 1 the ZBOOK `C40QYUC#ABA` ordered 1 / shipped 0, line 2 the mouse `9VA80AA#ABA` ordered 1 / shipped 1, and the badge read **Fulfilled**.
25
-
26
- Root cause is in the shared parent `_Model_Compass_SalesOrder`, which decided fulfilled vs partiallyFulfilled by comparing two **order-level grand totals** whose two sides did not count the same lines: the ordered side filtered `SalesOrderItems.parentSalesOrderItemId IS NULL` (dropping bundle child lines) while the shipped side summed **every** ASN quantity on the order with no such filter. On `SAC100664` the mouse is a child of the ZBOOK (`parentSalesOrderItemId = 1131`), so ordered = 1 and shipped = 1 and it read fulfilled. Confirmed on prod: orderedSide `1.00`, shippedSide `1`.
27
-
28
- Canada's fix is a **pure per-line rule** in its own subclass, with **no order-total check at all**. Measured on prod after the rule: **489 fulfilled / 184 pendingFulfillment / 109 partiallyFulfilled** — **90 orders corrected** from Fulfilled to Partially Fulfilled, plus 3 corrected from partiallyFulfilled to pendingFulfillment.
29
-
30
- **Canada's rule is NOT the USA rule, and the two must not be merged.** Compass **USA is deliberately unchanged** and still runs the order-total logic, because the Office Depot ASN feed is not trustworthy at line level. See [the USA doc](../../compass-usa/features/order-fulfillment-status-per-line.md).
24
+ Since TRUE-79669 (2026-10-15, not yet deployed) Compass Canada has **no** `_fulfillmentStatus()` override. It runs the standard base per-line rule, same as Compass USA. The rule, gates and blast radius live in **[Compass USA + Canada — order fulfillment status](../../compass-usa/features/order-fulfillment-status-per-line.md)**. This page holds only what differs for Canada.
31
25
 
32
26
  ## How it works
33
-
34
- ### Key files / entry points
35
- - **`_underscore/Model/Compass/Canada/SalesOrder.php`** — `_fulfillmentStatus()`, a `protected static` override. **Self-contained:** builds its own expected-line set and its own per-line shipped quantity and borrows nothing conditional from the parent. This is the whole Canada rule.
36
- - **`_underscore/Model/Compass/SalesOrder.php`** — the parent. The fulfillment branch of `_status()` was extracted into `protected static _fulfillmentStatus()` and is called via `static::`, which makes the Canada override reachable. **The parent body is the ORIGINAL order-total logic, moved verbatim** — the seam exists only so Canada can override. Proved behaviour-neutral by diffing the emitted `_status` SQL: whitespace-identical, **7,548 normalized characters** both ways.
37
- - `const ITEM_TYPES__NOT_SHIPPABLE` (Canada) — `SERVICE`, `SERVICES`, `CONSULTING`, `WARRANTY`.
38
-
39
- ### The rule
40
- ```
41
- pendingFulfillment WHEN every expected-to-ship line has received ZERO quantity
42
- partiallyFulfilled WHEN any expected-to-ship line has received LESS than its ordered quantity
43
- fulfilled otherwise
44
- ```
45
- **Both branches read the same expected-line set and the same per-line quantity**, so the gate and the shortfall test can never disagree. Keep that symmetry if you edit the method.
46
-
47
- ### "Expected to ship" — all four conditions
48
- A `SalesOrderItems` row counts only when:
49
- 1. `price > 0`;
50
- 2. it has a `SalesOrderItems_PurchaseOrderItems` link (it was actually purchased);
51
- 3. its `ItemTypes.name` is **not** in `ITEM_TYPES__NOT_SHIPPABLE`; and
52
- 4. `COALESCE(Items.isFulfillable, 1) = 1`.
53
-
54
- No parent/child line filter, so bundle children are judged on their own merits — that is the original fix.
55
-
56
- ### The per-line shipped source (ASN bridge, and only the ASN bridge)
57
- ```
58
- AdvanceShippingNoticeItems.purchaseOrderItemId
59
- -> SalesOrderItems_PurchaseOrderItems.purchaseOrderItemId
60
- -> SalesOrderItems_PurchaseOrderItems.salesOrderItemId
61
- ```
62
- This mapping is **100% complete in `Client_CompassCanada`**: all **947** ASN items have a `purchaseOrderItemId` and every one resolves to a sales-order line. Both join columns are indexed. Canada ships through Grand & Toy ASNs only (see [G&T ASN Import](grand-and-toy-asn-import.md)), so this single source is sufficient — Canada needs none of USA's TOGa Tech / Office Depot fulfillment chain.
63
-
64
- ### Why each condition is there (measured, prod)
65
- - **Condition 3 (item type) is what corrected the over-correction.** Canada has SERVICE and CONSULTING lines that **do carry PO links but never ship**: `COMPASS-IPAD-ABM` (Apple Business Manager enrolment — 53 lines, only 9 ever on an ASN), `SDU42Z/A` and `SDRU2Z/A` (AppleCare for Enterprise), `CN-COMP-DED-IN` / `CN-CONE-SHR-WS1` (Profile SKUs), and `COMPASS-MACBOOK-ABM-ACE`. Requiring them to ship held **25 fully-delivered orders** at partiallyFulfilled — e.g. `SAC100041`, `SAC100088`, `SAC100523`. Excluding the item types fixed all 25. **This is the 115 to 90 correction.**
66
- - **Condition 2 (purchased line) is still required.** Canada has **100 priced lines with no PO link across 48 orders**; without the link test those lines can never ship and would strand their orders.
67
- - **Condition 4 (`isFulfillable`) is a no-op in Canada today.** `Items.isFulfillable` is **NULL on every Canada item**, so `COALESCE(...,1)` always passes. It is in the expression so the rule starts honouring the flag automatically once Canada is stamped — the **item-type list does all the real work right now**. See [isFulfillable — Data Quality & the Type-Derived Rule](../../compass-usa/features/isfulfillable-data-quality-and-type-rule.md).
68
- - **`ItemTypes` is the only usable column in Canada.** Canada's `AssetTypes` are only `Equipment` / `Hardware` / `Tablets` — **there is no `FEE` asset type in Canada at all**, so `AssetTypes` gives zero signal here. USA is the opposite case; see the USA doc.
69
- - **Keep the `price > 0` filter.** Only **9** of Canada's **69** zero-price lines ever receive an ASN, so requiring them to ship would strand orders in partiallyFulfilled forever.
70
-
71
- ### Why per-line QUANTITY is the right unit (the counter-examples)
72
- - **Order totals fail.** `SAC100664`: the mouse's shipment paid for the ZBOOK's quantity.
73
- - **Line counts fail.** `SAC100108`: both expected lines had an ASN, so a line-count check matched — but only 1 of 2 iPads and 1 of 2 accessories actually shipped. Also **96 Canada lines carry 2 to 3 ASN lines each**, so an ASN-line count can exceed the order-line count while the order is still short.
74
- - **There is no line-coverage gap to catch.** Across **601** Canada orders past approval with shipments, **zero** read fulfilled while an expected line had no ASN line — a line with no ASN has shipped 0 and is always caught as short. A line-count check would add nothing and only weaken the rule.
75
- - **Going per line also neutralises duplicate ASNs for the verdict.** A surplus stays on its own line and can no longer cover another line's gap, so no clamping or de-duplication is needed.
76
-
77
- ### The pendingFulfillment gate reads quantity, not "an ASN exists"
78
- The gate is **not** `COUNT(ASNs on the order) = 0`. Three orders (`SAC100131`, `SAC100509`, `SAC100522`) had an ASN raised only against an AppleCare / ABM line while **zero physical goods had shipped**, and therefore read partiallyFulfilled. Reading the expected-line quantity instead makes them correctly read **pendingFulfillment**.
79
-
80
- ### Design decisions worth keeping
81
- - **⚠ Do NOT switch Canada to the local `ItemFulfillments` / `ItemFulfillmentItems` tables**, even though the line-level `_qtyFulfilled` UI column does read them. They are **incomplete** in Canada: **47** orders have ASN shipments with no local `ItemFulfillment` at all and **52** more disagree on quantity, so moving this rule onto the base `_Model_Client_SalesOrder` logic would regress roughly **99** orders.
82
- - **Canada deliberately has NO order-total condition.** USA keeps one; Canada does not need it, because its ASN bridge is complete and its expected-line set is never empty in the way USA's is.
83
- - **Canada deliberately omits any Compass-vendor exclusion.** `VENDOR_ID__COMPASS = 26` on the parent is the **US** Compass vendor id. In `Client_CompassCanada` all **901** POs are GRAND & TOY (`vendorId 1`), and "COMPASS CANADA" is `vendorId 4` with **zero** POs — the inherited constant is a latent wrong-id no-op. Do not "restore" it here.
84
-
85
- ### History: the first attempt was reverted
86
- - The original fix, commit **`90c964f1`** "Updating the status logic as per the client", was **reverted by commit `43049d1d`** ("Revert …", kmaramreddy08, 2026-08-26 16:19). The revert is on **`#sprint85`, `TRUE-81284`, `_beta`, `_production` and `_sandbox-client`**; only **`_sandbox-dev`** still carries the original fix.
87
- - Every refinement made after `90c964f1` was **never committed** and is gone from git. The rule on this page was **rebuilt from scratch** on `_production`.
88
- - The ticket branch for this work is **`TRUE-81284`**, not `TRUE-80431`.
89
- - **Any statement that this shipped is wrong**, including the previously published "115 Canada orders corrected" figure.
27
+ - Shipped quantity comes from **ItemFulfillmentItems** (created from Grand & Toy ASNs — [G&T ASN Import](grand-and-toy-asn-import.md)), not from the ASN bridge the deleted override read.
28
+ - Non-shipping lines are excluded by `Items.isFulfillable = 0`, set by `dbchanges2/Client_CompassCanada/2026-10-15a - NonShippableItemTypesNotFulfillable.sql` for item types `CONSULTING`, `SERVICE`, `SERVICES`, `WARRANTY` (PO-linked AppleCare / ABM SKUs such as `COMPASS-IPAD-ABM`, `SDU42Z/A` never ship).
29
+ - `ItemTypes` is the only usable type column in Canada (no `FEE` asset type).
90
30
 
91
31
  ## Gotchas
92
- - **⚠ Uncommitted.** Both touched files pass `php -l`, nothing was committed or pushed, and the repo is sitting on **`_production`** — a branch is required before committing. Canada has **not** been exercised through the Supply UI yet.
93
- - **The badge is display-only and never stored.** The status is computed live, so there is no backfill and reverting the code fully reverts the behaviour. **No cron, email or report reads the fulfilled vs partiallyFulfilled distinction.** Canada's in-transit email cron (`worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php`) keeps its **own duplicated copy** of the `_status` CASE, but it only ever produces canceled / pendingApprovalUnknown / pendingFulfillment / shipped and filters on `computedStatusSlug = 'shipped'`. The Canada pendingFulfillment gate is **no longer byte-identical** to that copy (it now reads quantity, not ASN existence). The cron's copy is unchanged and still only acts on `'shipped'`, so behaviour is unaffected, but the two are now a genuine divergence.
94
- - **⚠ Canada's order status is no longer identical to Compass USA.** Older notes (and the profile) said "order status is shipped-only, same as Compass USA". The *stage* policy is still shared (shipped-only), but the **fulfilled vs partiallyFulfilled rule now diverges by region** — and it diverges because **only Canada changed**.
95
- - **The ApprovalDecision notification query on the parent calls `self::_status()`**, not `static::`, so it always evaluates the **parent** fulfillment rule even for Canada. Because Canada's pendingFulfillment gate now differs from the parent's, that query sees the **parent's** gate for Canada orders. Harmless today (it only branches on pendingApproval / canceled / pendingFulfillment and acts on approvals) but it is now a real divergence, not theoretical.
96
- - **Canada's approval gate is healthy**, unlike USA's: 733 approved, 30 denied, only **19** stuck at a pending-approval status. Canada also has all ten `SalesOrderStages` rows, where USA has only ids 5 to 9.
32
+ - **⚠ Follow-up open: new Canada service-type items start with `isFulfillable` NULL**, which the base rule treats as fulfillable. A new SERVICE / CONSULTING / WARRANTY item will hold its orders at Partially Fulfilled until it is set to 0. The data fix only covers items that exist today.
33
+ - **46 open Canada orders have an ASN but no IF** (measured locally 2026-10-15) — they now read **Pending Fulfillment**. Fix by creating the missing IF, not by changing the rule.
34
+ - Superseded: the 2026-09-16 Canada ASN-bridge per-line rule (`ITEM_TYPES__NOT_SHIPPABLE`, "do NOT switch to local IF tables") — deleted in TRUE-79669.
35
+
36
+ ## Change history
37
+ - 2026-10-15 — TRUE-79669: Canada override and `ITEM_TYPES__NOT_SHIPPABLE` deleted; Canada runs the standard base rule; service item types set to `isFulfillable = 0`. Not deployed. (jcardinal)
38
+ - 2026-09-16 — Canada per-line ASN-bridge rule (90 orders corrected), now superseded. (bala)
97
39
 
98
40
  ## Related
99
- - [Compass USA — order aggregate, deliberately unchanged](../../compass-usa/features/order-fulfillment-status-per-line.md) — the parent rule, and why USA was not touched.
100
- - [Grand & Toy ASN Import](grand-and-toy-asn-import.md) — where Canada's ASN rows come from.
101
- - [IF Stage Lifecycle & Order Status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md) — the shipped-only stage policy shared by both Compass regions.
102
- - [FIELD_SQL calculated fields](../../../2.0/apps/_underscore/features/calculated-sql-fields.md) — why a region-subclass override of a calculated field is picked up at all.
41
+ - [Compass USA + Canada — order fulfillment status](../../compass-usa/features/order-fulfillment-status-per-line.md)
42
+ - [isFulfillable — Data Quality & the Type-Derived Rule](../../compass-usa/features/isfulfillable-data-quality-and-type-rule.md)
@@ -19,7 +19,7 @@ project: _Underscore
19
19
  client: compass-canada
20
20
  type: profile
21
21
  status: active
22
- updated: 2026-10-13
22
+ updated: 2026-10-15
23
23
  owners: [jcardinal, bala, tcox, apeterson, ajean, mhammontree, rgirish]
24
24
  files: []
25
25
  related:
@@ -237,25 +237,11 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
237
237
  207 → 1. See
238
238
  [isFulfillable — Data Quality & the Type-Derived Rule](../compass-usa/features/isfulfillable-data-quality-and-type-rule.md).
239
239
  - The 2026-06-08 ASN → ItemFulfillment work was for **Compass USA**, not Compass Canada.
240
- - **Order status is shipped-only** (shared `_Model_Compass_SalesOrder`). Compass Canada's own IF
241
- lifecycle stages (picked/packed/shipped) + shipped backfill are seeded by
240
+ - **Order status = the standard base per-line rule (TRUE-79669, 2026-10-15, not yet deployed).**
241
+ Canada's `_fulfillmentStatus()` override and `ITEM_TYPES__NOT_SHIPPABLE` were deleted; service
242
+ item types are set to `isFulfillable = 0` instead. IF stages (picked/packed/shipped) are seeded by
242
243
  `dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql`.
243
- See [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
244
- - **⚠ Order status is no longer identical to Compass USA — Canada is the only region whose
245
- fulfilled-vs-partiallyFulfilled rule changed (2026-08-27).** Canada overrides
246
- `_fulfillmentStatus()` in `_Model_Compass_Canada_SalesOrder` with a **pure per-line** rule and **no
247
- order-total condition**, sourced from the **ASN bridge only**
248
- (`AdvanceShippingNoticeItems.purchaseOrderItemId` -> `SalesOrderItems_PurchaseOrderItems`, 100%
249
- complete here). A line counts as expected-to-ship only when `price > 0`, it has a PO-item link, its
250
- `ItemTypes.name` is not in `ITEM_TYPES__NOT_SHIPPABLE` (SERVICE/SERVICES/CONSULTING/WARRANTY) and
251
- `COALESCE(Items.isFulfillable,1) = 1` — the item-type list matters because Canada has PO-linked
252
- AppleCare/ABM SKUs that never ship, and `isFulfillable` is NULL on every Canada item. Do not read
253
- Canada's local `ItemFulfillments` tables for this (incomplete: ~99 orders would regress), and do
254
- not port USA's rule or its `VENDOR_ID__COMPASS = 26` (a US vendor id; all 901 Canada POs are Grand
255
- & Toy `vendorId 1`). **Corrects 90 prod orders, not the 115 previously published** — the first
256
- attempt was reverted in git and over-corrected by 25 orders. **Uncommitted** (repo on
257
- `_production`). See
258
- [Fulfilled vs Partially Fulfilled](features/order-fulfillment-status-per-line.md).
244
+ [Canada order fulfillment status](features/order-fulfillment-status-per-line.md).
259
245
  - **Item titles in customer emails** come from the `ItemTranslations` sidecar (prod: 220 fr-CA rows,
260
246
  100% coverage of every item ever ordered). Resolve the English/translated fallback **in PHP**, never
261
247
  as a SQL `COALESCE` — the two columns' collations differ and differ *per client DB*.
@@ -24,7 +24,7 @@
24
24
  | [Compass: MA refresh-order monitor (Monitor/Compass/MaRefreshOrderExceptions)](features/oneuptime-ma-refresh-order-monitor.md) | 2.0 | worker2 OneUptime push monitor counting Compass MA employee-refresh orders whose ODP SO has no NetSuite order yet; open when working on Compass MA monitoring or |
25
25
  | [Compass: NetSuite duplicate sales-order monitor (Monitor/Compass/NetsuiteDuplicateSalesOrders)](features/oneuptime-netsuite-duplicate-sales-orders-monitor.md) | 2.0 | worker2 OneUptime push monitor that queries NetSuite (SuiteQL) for Office Depot POs with more than one open NetSuite sales order; open when working on Compass d |
26
26
  | [Compass: Office Depot inbound AS2 files monitor (Monitor/Compass/OfficeDepotInboundFiles)](features/oneuptime-office-depot-inbound-files-monitor.md) | 2.0 | worker2 OneUptime push monitor that checks Office Depot 850/997 files are arriving over AS2 and are moved out of `agilant-as2/processed/` to the import folder; |
27
- | [Compass USA — Fulfilled vs Partially Fulfilled (order total kept; per-line rule built then rejected)](features/order-fulfillment-status-per-line.md) | 2.0 | Why Compass USA shows Fulfilled on partly-shipped orders and why the per-line fix was built, measured, then reverted; open before touching Compass USA `_status` |
27
+ | [Compass USA + Canada — order fulfillment status (standard base per-line rule)](features/order-fulfillment-status-per-line.md) | 2.0 | How Compass USA and Canada compute Pending / Picked / Packed / Partially Fulfilled / Fulfilled (the standard base per-line rule since TRUE-79669) and why the ol |
28
28
  | [People File Import admin page (tools /compass/people-file-import → worker2)](features/people-file-import-admin-page.md) | 1.0 | Browser page that lists the Compass PEOPLE files on SFTP and runs the importer on demand; open before changing the page or the importer's listing/guard code. |
29
29
  | [Compass PEOPLE-File User Lifecycle (duplicate accounts, reactivation grace window, raw-SQL deactivation)](features/people-file-user-lifecycle.md) | 2.0 | The nightly Compass PEOPLE-file cron's Users-row lifecycle (insert/reactivate/deactivate), why it creates duplicate accounts, and its raw-SQL/index traps; open |
30
30
  | [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`. |
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: compass-usa
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-10-13
8
+ updated: 2026-10-15
9
9
  owners: [jcardinal, bala]
10
10
  files:
11
11
  - _underscore/Model/Compass/AdvanceShippingNotice.php
@@ -125,12 +125,13 @@ Something was fixed around May/June 2026. Not fully fixed.
125
125
  - Separate latent bug in the 1.0 worker: the Strategic Systems cron's no-serials branch builds `$itemLevelTrackingNumbers` but never attaches it to the ASN payload.
126
126
  - **MySQL 1213 deadlocks on this path are a framework cascade, not ASN code (diagnosed 2026-08-13).** Office Depot re-transmits the same ASN for days, so one PO receives many concurrent same-PO ASN inserts. Each `AdvanceShippingNotices` insert (FK `purchaseOrderId`) trips the generic `_Model::save()` parent cascade, which re-saves the PurchaseOrder to refresh its **stored** `_total` correlated-subquery field — X-locking the PO row and S-locking all its `PurchaseOrderItems` on a write that changes neither. Concurrent same-PO ASNs then deadlock. 100% of observed 1213s were this one `_total` UPDATE. Two mitigations (working tree, pending validation): the base client ASN model opts out of the parent cascade (`$_model_skipParentCascadeOnSave`), and api2 retries the ASN route on a detected deadlock. Full mechanic: [save-cascade stored-field deadlock](../../../2.0/apps/_underscore/features/model-save-parent-cascade-stored-field-deadlock.md).
127
127
  - **Concurrent same-SO-number ASNs lost the ASN + its tracking to a unique-key race (fixed 2026-08-28, working tree).** `postPost` get-or-creates the SO-numbered IF (GET by number, else POST). `ItemFulfillments.number` is UNIQUE and vendors re-transmit the same file, so two concurrent same-number ASNs race: the loser's IF INSERT hits the unique key (MySQL **1062**), `internalApiRequest` threw (default `throwExceptionsOnError=true`), the 500 propagated out of `postPost`, and the api2 controller rolled back the whole request — losing the ASN row AND its tracking numbers (`Logs.Issue #239`, EV-10). Fix: the IF-create call now passes `throwExceptionsOnError:false`; on an empty response with `lastErrorNumber===1062` the model sets `_Database::$retriableUniqueRace = true` and returns null, `postPost` aborts, and api2's Prong A replays the whole request on a fresh REPEATABLE-READ snapshot so the retry's GET reuses the winner's committed IF. Distinct from the 1213 deadlock (a different concurrency failure on the same re-transmission traffic). Full mechanic + why in-place recovery is impossible (V2 response poisoning): [V2 request retry — Prong A](../../../2.0/apps/api2/features/v2-deadlock-retry.md).
128
- - **Cross-line tracking contamination from bad SOI↔POI bridge rows (root cause, fixed forward 2026-06-11):** `postPost` resolves which IFI(s) a tracking number attaches to by joining `AdvanceShippingNoticeItems → SalesOrderItems_PurchaseOrderItems → SalesOrderItems`. It trusts that bridge completely, so a wrong bridge row silently sends one ASN's tracking onto an unrelated part's IFI. Two distinct pre-fix bugs produced bad bridge rows on Compass SA orders: (1) `_Model_Compass_PurchaseOrder` linked SO↔PO items by matching `SalesOrderItems.lineNumber = PurchaseOrderItems.lineNumber` — but a PO item's lineNumber is PO-local (each PO starts at 1), so when one SO is split across multiple POs the PO line numbers collide with unrelated SO lines (e.g. SA132743: PO `50305071-1` line 1 = 910-006272 wrongly linked to SO line 1 = C40QYUC). Fixed in `_underscore` commit `ec935478` (2026-06-11, "Only link SO/PO items for MR orders") — the lineNumber join is now MR-only; SA orders link via `createdFromSalesOrderItem` (item identity). (2) The 1.0 worker cron `3a_import_office_depot_purchase_orders.php` paired API-returned PO items to SOIs by array index, producing off-by-one neighbor links (fixed forward to pair by lineNumber; see SA132763 cleanup file header). **Diagnosing:** a bridge row is provably wrong when its `SalesOrderItems.itemId <> VendorItems.itemId` (the linked SOI's product differs from the PO item's actual product). The `2026-06-16*` cleanup migrations delete exactly those rows plus the tracking they spilled, scoped to one order.
129
- - **Retroactive cleanup MUST be paired with a backfill — a cleanup-only repair leaves the line blank.** Removing the *wrong* tracking from a contaminated IFI does not restore the *correct* one: the correct number often never reached the IF side (the SO was fulfilled out-of-band before the 2026-06-15 reconcile fix, and the cleanup adds nothing). The original `2026-06-16 - CleanupSA132763CrossLineTracking.sql` was cleanup-only and left C40QYUC on SA132763 with no tracking at all; `2026-06-16c` backfilled it. When repairing an order, also backfill each affected IFI's correct tracking (the tn carried by an ASN item whose `VendorItems.itemId` matches the IFI's SOI item) at both item and package level, guarded by `NOT EXISTS`. ~116 PO items across other Compass orders share the off-by-one fingerprint and are not yet repaired — a broader reviewed backfill is still outstanding.
128
+ - **Cross-line tracking contamination from bad SOI↔POI bridge rows (root cause, fixed forward 2026-06-11):** `postPost` resolves which IFI(s) a tracking number attaches to by joining `AdvanceShippingNoticeItems → SalesOrderItems_PurchaseOrderItems → SalesOrderItems`. It trusts that bridge completely, so a wrong bridge row silently sends one ASN's tracking onto an unrelated part's IFI. Two distinct pre-fix bugs produced bad bridge rows on Compass SA orders: (1) `_Model_Compass_PurchaseOrder` linked SO↔PO items by matching `SalesOrderItems.lineNumber = PurchaseOrderItems.lineNumber` — but a PO item's lineNumber is PO-local (each PO starts at 1), so when one SO is split across multiple POs the PO line numbers collide with unrelated SO lines (e.g. SA132743: PO `50305071-1` line 1 = 910-006272 wrongly linked to SO line 1 = C40QYUC). Fixed in `_underscore` commit `ec935478` (2026-06-11, "Only link SO/PO items for MR orders") — the lineNumber join is now MR-only; SA orders link via `createdFromSalesOrderItem` (item identity). (2) The 1.0 worker cron `3a_import_office_depot_purchase_orders.php` paired API-returned PO items to SOIs by array index, producing off-by-one neighbor links (fixed forward to pair by lineNumber; see SA132763 cleanup file header). (3) Cron 3a then linked every Compass PO line to the **last** ODP SO line, 2026-04-28 → 2026-10-06 — see [ODP order pipeline](../workflows/odp-order-pipeline-to-netsuite.md) gotchas. **Diagnosing:** a bridge row is provably wrong when its `SalesOrderItems.itemId <> VendorItems.itemId` (the linked SOI's product differs from the PO item's actual product). The `2026-06-16*` cleanup migrations delete exactly those rows plus the tracking they spilled, scoped to one order.
129
+ - **Retroactive cleanup MUST be paired with a backfill — a cleanup-only repair leaves the line blank.** Removing the *wrong* tracking from a contaminated IFI does not restore the *correct* one: the correct number often never reached the IF side (the SO was fulfilled out-of-band before the 2026-06-15 reconcile fix, and the cleanup adds nothing). The original `2026-06-16 - CleanupSA132763CrossLineTracking.sql` was cleanup-only and left C40QYUC on SA132763 with no tracking at all; `2026-06-16c` backfilled it. When repairing an order, also backfill each affected IFI's correct tracking (the tn carried by an ASN item whose `VendorItems.itemId` matches the IFI's SOI item) at both item and package level, guarded by `NOT EXISTS`. The bulk link repair is now [retrofix2](../../../1.0/apps/test/features/compass-retrofix2-sql-generator.md) Phase 1 (generated and verified locally 2026-10-15, not yet applied to prod).
130
130
  - **Duplicate tracking from vendor ASN re-transmission (root cause, fixed forward 2026-06-18) — DISTINCT from cross-line contamination above.** Compass vendors (Office Depot, Strategic Systems) re-send the same shipment ASN file for several days; one PO can receive many ASNs (e.g. SA132898 / PO `50305272-1` got 6). The first ASN takes the create path and attaches its tracking; a later ASN re-listing the same, now fully-fulfilled line takes the reconcile path and — pre-fix — attached its *different* tracking number onto the existing IFI. On a qty-1 line this yields two item-level tracking rows (plus a propagated package row). Unlike the cross-line bug, both tracking numbers here map cleanly to the correct line via their own valid ASN items, so the `NOT EXISTS (ASN item with matching VendorItems.itemId)` cleanup guard does not catch it — diagnose instead by an IFI carrying more item-level tracking numbers than its fulfilled quantity. Fix: the reconcile path now skips any IFI that already has item-level tracking (`itemFulfillmentItemHasTracking()`), so only untracked out-of-band IFIs receive ASN tracking. The legitimate out-of-band case (IFI has no tracking, one ASN arrives) is preserved. Forward-only; SA132898 repaired by `2026-06-18a` (delete the later tracking + orphaned package row, keep the original; no backfill needed since the correct number is already present).
131
131
  - **⚠ Contract weakness to record — ASN identifies the PO item positionally.** The ASN payload identifies the PO item **positionally**, by `purchaseOrderItem.lineNumber`, NOT by uuid. So any line-numbering drift — on either side — silently credits the wrong item, with no error. Same positional-identity class as the PO-local `lineNumber` collision fixed in 2026-06, and it is why [Compass USA's per-line fulfillment rule was rejected](order-fulfillment-status-per-line.md): the feed cannot currently support a line-level completeness test.
132
132
 
133
133
  ## Change history
134
+ - 2026-10-15 — Linked cron 3a's last-line link bug as a third bad-bridge source; bulk repair now via retrofix2. (jcardinal)
134
135
  - 2026-10-13 — Recorded that supply's tracking column reads only ASN unit tracking (or the Agilant IF), the 1062 on a repeated tracking number in one payload, and that Presidio sends no ASNs. (bala)
135
136
 
136
137
  ## Related
@@ -6,14 +6,16 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala"]
9
+ updated: 2026-10-15
10
+ owners: ["bala", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Model/Client/Item.php
13
13
  - library/app/api/toga2.php
14
14
  - worker/crons/toga2/netsuite/backfill_isfulfillable_all_clients.php
15
15
  - dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql
16
16
  - toga2-supply/src/pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx
17
+ - dbchanges2/Client_Compass/2026-10-15a - FeeItemsNotFulfillable.sql
18
+ - dbchanges2/Client_CompassCanada/2026-10-15a - NonShippableItemTypesNotFulfillable.sql
17
19
  related:
18
20
  - ../profile.md
19
21
  - ../../compass-canada/profile.md
@@ -27,7 +29,7 @@ Why Compass storefront Qty-Fulfilled cells stay dead — `Items.isFulfillable` N
27
29
  ## Summary
28
30
  A prod-data investigation (2026-08-12, read-only) into why so many Compass storefront lines still have a dead **Qty Fulfilled** cell. Conclusion: the remaining `Items.isFulfillable` NULLs are **not a backfill backlog — they are structurally unreachable**, and the values that *are* set are partly wrong. Records the measured state of `Client_Compass` / `Client_CompassCanada`, the evidence for/against deriving the flag from item type, the **decision** to override NetSuite with a type-derived rule for Compass, and the **write-time guard** that decision requires to survive the daily NetSuite sync.
29
31
 
30
- No source files changed and **no migration written yet**. Figures dated 2026-08-12 were read from production that date; later sections (retraction, column choice, data-fix status) were measured 2026-08-27 against prod and client-sandbox as marked.
32
+ **⚠ Since TRUE-79669 (2026-10-15) the flag also drives Compass order status** — the base `_fulfillmentStatus()` skips `isFulfillable = 0` lines, NULL counts as fulfillable ([order fulfillment status](order-fulfillment-status-per-line.md)). A wrong 1/NULL on a non-shipping item now holds orders at Partially Fulfilled. Figures dated 2026-08-12 were read from production that date; later sections (retraction, column choice, data-fix status) were measured 2026-08-27 against prod and client-sandbox as marked.
31
33
 
32
34
  ## How it works
33
35
 
@@ -117,7 +119,11 @@ The two columns disagree, and the gap matters:
117
119
 
118
120
  **Decision (developer's call): code reads `ItemTypes`, and lets `Items.isFulfillable` carry the assetType meaning**, because the data fix below folds both columns into the flag. So the flag is the single source of truth for consumers and `ItemTypes` is only a secondary, code-side signal.
119
121
 
120
- **Compass Canada has no such choice.** Canada's `AssetTypes` are only `Equipment` / `Hardware` / `Tablets` — no `FEE` asset type at all — so `ItemTypes` is the only usable column there, and Canada's fulfillment rule uses an item-type exclusion list (`SERVICE`, `SERVICES`, `CONSULTING`, `WARRANTY`) rather than the flag. See [Compass Canada — Fulfilled vs Partially Fulfilled](../../compass-canada/features/order-fulfillment-status-per-line.md).
122
+ **Compass Canada has no such choice.** Canada's `AssetTypes` are only `Equipment` / `Hardware` / `Tablets` — no `FEE` asset type at all — so `ItemTypes` is the only usable column there, so Canada's non-shipping types (`SERVICE`, `SERVICES`, `CONSULTING`, `WARRANTY`) are set to `isFulfillable = 0` by `dbchanges2/Client_CompassCanada/2026-10-15a`. See [Compass Canada — order fulfillment status](../../compass-canada/features/order-fulfillment-status-per-line.md).
123
+
124
+ ### Data fixes written for TRUE-79669 (2026-10-15, `dbchanges2` branch `TRUE-79669`, not run in prod)
125
+ - `Client_Compass/2026-10-15a - FeeItemsNotFulfillable.sql` — every `ItemTypes.name = 'FEE'` item → 0 (13 of 16 already 0; `LT-FREIGHT`, `LT-PROPERTY TAX`, `TabAdminSupp` were 1). ItemTypes only — the wider assetType/catalog rule below is still pending.
126
+ - `Client_CompassCanada/2026-10-15a - NonShippableItemTypesNotFulfillable.sql` — CONSULTING / SERVICE / SERVICES / WARRANTY → 0.
121
127
 
122
128
  ### Status of the type-derived data fix (verified 2026-08-27)
123
129
  The developer's two-statement `UPDATE`, scoped to `catalogId = 1`, sets `isFulfillable = 0` where `ItemTypes.name` **or** `AssetTypes.name` `IN ('FEE','SERVICEFEES')` and `1` otherwise. **Pending Alex's sign-off before prod.** Verified environment state:
@@ -129,6 +135,7 @@ The developer's two-statement `UPDATE`, scoped to `catalogId = 1`, sets `isFulfi
129
135
  | prod | not applied |
130
136
 
131
137
  ## Gotchas
138
+ - **⚠ New Compass Canada service-type items start with `isFulfillable` NULL** (= fulfillable to the status rule). The 2026-10-15 fix covers existing items only — follow-up needed (write-time guard or type rule).
132
139
  - **⚠ CRITICAL — a backfill alone will be undone by the daily NetSuite sync.** Phase 1's `getCreateItem` **refreshes `isFulfillable` on EXISTING items** whenever NetSuite's value differs from the stored value. NetSuite returns **`T` for service (`NonInvtPart`) items**, so any type-derived **0** is flipped back to 1 on the next sync run. Proven from the audit log: Compass items **2382 / 2384 / 2385** (`US-IOS-SSC-SHR`, `US-IOS-VENUENEXT`, `US-IOS-VIRTUALMGR` — itemType `SERVICES`, assetType `CONSULTING`) were stamped NULL → 1 on 2026-08-04 13:56:00-01 by `apiId 1`, `userId NULL`, inside a 1–2 items/second cron sweep that began 13:45 and set every item to 1. **Therefore the rule MUST be enforced at write time** — a `prePut`/`prePost` guard on Items in `_Model_Client_Item` — in addition to the backfill migration. A migration on its own silently reverts within a day.
133
140
  - **⚠ Recorded caveat — the SERVICES/CONSULTING half of the rule is NOT supported by the evidence.** 413 `SERVICES` fulfillment rows carry their own tracking, and the rule flips **131 currently-clickable items off**. That half needs product sign-off. `SOFTWARE` (100% ride-along) is the *stronger* candidate on the evidence but was deliberately left out of scope.
134
141
  - **⚠ RETRACTED (2026-08-27) — "it appears on an ASN, so it must be fulfillable."** An in-session proposal to run `UPDATE Items SET isFulfillable = 1` for 7 Compass items — **691, 237, 661, 2004, 2005, 703, 657** — because they appeared to ship was **wrong and withdrawn.** All 7 have `AssetTypes.name = 'FEE'` and their descriptions confirm it: *"5-Year Comprehensive Coverage"*, *"Printer Warranty 3 Year Plan"*, *"Staging and Kitting Charge"*, *"Tablet Lab fee"*, *"Config fee for Samsung Configurations"*. **`isFulfillable = 0` is CORRECT for all of them.** They "ship" only because the vendor lists the warranty / staging charge as a line on the shipping notice next to the hardware (ride-along, seen from the other direction). **Appearing on an ASN is NOT the same as being a physical good.** Never promote an item to `isFulfillable = 1` on shipment evidence alone.
@@ -138,6 +145,9 @@ The developer's two-statement `UPDATE`, scoped to `catalogId = 1`, sets `isFulfi
138
145
  - **⚠ The NetSuite sync rewrites some type-derived 0s back.** On client-sandbox **6 fee items are back at `isFulfillable = 1` despite matching the WHERE** — `US-AND-CROTHALL-PO`, `LVY_MobileOps`, `US-TASKS-TECHS-IN`, `US-IOS-ESFM-NUVOLO`, `HDL-TASKUP-IN`, `Unidine-Knox` — with `dtUpdated` 18–25 Aug, i.e. after the fix ran. Their `itemType` is `SOFTWARE` / `SERVICES` / `HARDWARE` while only their `assetType` says FEE, so **an itemType-based code test will not catch these if the flag drifts.** The concrete failure mode of the column decision, and it re-proves the write-time-guard requirement.
139
146
  - **⚠ The `catalogId = 1` scope misses 49 items carrying 52,367 priced order lines** — `PC-KIT-OPT2` (33,259 lines), `CG-MEDKITTING` (6,317), `CG-MEDKITTING-NWN-APPLE`, `ASI-WAREHAND`, `ODP-COMPASS_KIOSKBUILD` — all on catalog 2 or NULL, all flagged `isFulfillable = 1`, and **none has ever shipped**. They cause no problem *today* only because none has a PO link, so a purchased-line test excludes them anyway. Widen the scope or accept the flag is wrong on the largest-volume non-shipping SKU in the catalog.
140
147
 
148
+ ## Change history
149
+ - 2026-10-15 — The flag now drives Compass order status (TRUE-79669); FEE (USA) and service-type (Canada) data fixes written; new Canada service items still start NULL (follow-up). (jcardinal)
150
+
141
151
  ## Related
142
152
  - [Phase 1 — isFulfillable from NetSuite during item sync (1.0)](../../../1.0/apps/library/features/netsuite-item-isfulfillable-sync.md) — where the value is born and the refresh that reverts overrides.
143
153
  - [Phase 2 — isFulfillable propagation up the SO↔PO chain (2.0)](../../../2.0/apps/_underscore/features/fulfillable-item-propagation.md) — the chain walk whose reach is the structural limit here.
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["rgirish", "bala", "apeterson"]
9
+ updated: 2026-10-15
10
+ owners: ["rgirish", "bala", "apeterson", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Model/Compass/SalesOrder.php
13
13
  - _underscore/Model/Compass/PurchaseOrder.php
@@ -110,10 +110,13 @@ Safe scoping baked in — **decisions only, never Approvals**:
110
110
  - **⚠ OPEN BUG — the MR auto-approval failure path cannot report its own error.** `_underscore/Model/Compass/SalesOrder.php` (~line 647), inside the `if ($isMrOrder)` loop, calls `throw new _Exception('MR auto-approval failed: ApprovalTemplateStage step … not found …')` with **one** argument, but `_Exception::__construct($errorNumber, $errorString, $errorFile, $errorLine, $trace)` requires **five**. So when `$approvalTemplateStage->load()` fails, PHP raises an `ArgumentCountError` instead of the intended message — the diagnostic is lost, surfaces as a generic fatal. Found 2026-08-05; **not fixed**, worth its own ticket. Same class of defect (wrong call signature never exercised) in `_String::parseBetween()` — see [_String helpers](../../../2.0/apps/_underscore/features/string-html-entity-helpers.md).
111
111
  - **⚠ NEW LIVE SIGNAL — Agilant stuck orders spiked.** Ran at ~**5 a month**, then **268 appeared on 2026-08-11**, rate **17–51 a day since 2026-08-24**. Every one is **`salesOrderTypeId 2` (Drop Ship)** with **`createdByUserId NULL`** — system-created orders on a path with no auto-approval, the same class of defect as the original MR problem but a different order type. Live and unresolved.
112
112
 
113
+ ## Change history
114
+ - 2026-10-15 — Fulfillment half of Compass `_status` is now the base per-line rule (TRUE-79669). (jcardinal)
115
+
113
116
  ## Related
114
117
  - [Compass MITS PO → SO Item Linking](mits-po-to-so-item-linking.md) — the 3a cron item-population fix that stops NEW empty MA orders.
115
118
  - [Compass ODP Order Pipeline to NetSuite](../workflows/odp-order-pipeline-to-netsuite.md)
116
119
  - [Fulfilled vs Partially Fulfilled](order-fulfillment-status-per-line.md)
117
- - [IF Stage Lifecycle & Order Status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md) — the shipped-only fulfillment half of Compass `_status`.
120
+ - [IF Stage Lifecycle & Order Status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md) — the fulfillment half of Compass `_status` (base per-line rule since TRUE-79669).
118
121
  - [sales-order-denial-reason](../../../2.0/apps/_underscore/features/sales-order-denial-reason.md)
119
122
  - [Order lifecycle & data integrity](../workflows/order-lifecycle-and-data-integrity.md)
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-22
10
- owners: ["rgirish"]
9
+ updated: 2026-10-15
10
+ owners: ["rgirish", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Model/Compass/SalesOrder.php
13
13
  - _underscore/Model/Client/ItemFulfillments/TrackingNumber.php
@@ -118,12 +118,8 @@ the new query finds tracking on **5,264** orders the old one missed; 1,046 diffe
118
118
  - **Asset Tag is a capture gap, not a sync gap.** `Units.assetTag` is NULL on **every** tier
119
119
  including the origin. There is nothing to mirror — chasing it through the chain is wasted work.
120
120
  Needs its own ticket at the capture end.
121
- - **Open bug — broken PO↔SO item bridge (needs its own ticket).** **2,918** `ItemFulfillmentItems`
122
- have no matching upstream part and cannot be linked, because their
123
- `PurchaseOrderItems_SalesOrderItems` rows are **missing entirely**. Example: Office Depot SO lines
124
- 619922 / 619923 / 619924 have **0** bridge rows, while line 619925 wrongly maps to all 4 Compass
125
- lines. Same integrity class as the off-by-one bridge breakage in
126
- [asn-to-item-fulfillment](asn-to-item-fulfillment.md).
121
+ - **Broken PO↔SO item bridge — root-caused and fixed 2026-10-06 (`worker` `02cc2473`).** Cron 3a linked **every** Compass PO line to the **last** Office Depot SO line in `PurchaseOrderItems_SalesOrderItems` (leftover scalar reused in the link loop since `afb520c1`, 2026-04-28). Fingerprint: ODP SO lines with 0 bridge rows while the last line maps to all Compass lines (e.g. 619922–619924 vs 619925). The ODP→Compass tier then resolved to nothing, the broken-bridge guard skipped, and the Compass order got no IF or tracking. Zero new bad links since 2026-10-07. History: [retrofix2](../../../1.0/apps/test/features/compass-retrofix2-sql-generator.md); one order: [fix_item_fulfillment_chain](../../../1.0/apps/test/features/compass-fix-item-fulfillment-chain.md).
122
+ - **Line-level tracking (318) did not mirror up until 2026-10-06** (`_underscore` `46877314`) — header and unit tracking did. Now copied add-only; see [recursive IF engine](../../../2.0/apps/_underscore/features/recursive-item-fulfillments.md).
127
123
  - **Latent — `triggerUpstreamSync()` skips silently.** It returns quietly when no parent fulfillment
128
124
  is found (`if ($itemFulfillmentUuid)`). This matches the existing sibling's behaviour but violates
129
125
  the team's throw-don't-skip rule. Harden all four files together, not one at a time.
@@ -140,6 +136,7 @@ across unrelated orders. The multi-pass technique these backfills needed is writ
140
136
  [re-runnable additive migrations](../../../2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md).
141
137
 
142
138
  ## Change history
139
+ - 2026-10-15 — Broken PO↔SO bridge root-caused to cron 3a's last-line link bug (fixed `02cc2473`); line-level 318 tracking now mirrors up (`46877314`); linked the repair tools. (jcardinal)
143
140
  - 2026-09-22 — First write-up. Root-caused blank tracking/serials on Compass customer orders to
144
141
  `Core.ApiPayloadInterceptors` having **zero** rows for bridge records 317/318/319 (lost when
145
142
  `ItemFulfillmentPackages`/record 41 was consolidated into `ItemFulfillments_TrackingNumbers`), so
@@ -1,129 +1,92 @@
1
1
  ---
2
- title: Compass USA — Fulfilled vs Partially Fulfilled (order total kept; per-line rule built then rejected)
2
+ title: Compass USA + Canada — order fulfillment status (standard base per-line rule)
3
3
  framework: "2.0"
4
4
  repo: _underscore
5
5
  project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-10-13
10
- owners: ["bala"]
9
+ updated: 2026-10-15
10
+ owners: ["bala", "jcardinal"]
11
11
  files:
12
+ - _underscore/Model/Client/SalesOrder.php
12
13
  - _underscore/Model/Compass/SalesOrder.php
13
14
  - _underscore/Model/Compass/Canada/SalesOrder.php
15
+ - dbchanges2/Client_Compass/2026-10-15a - FeeItemsNotFulfillable.sql
16
+ - dbchanges2/Client_CompassCanada/2026-10-15a - NonShippableItemTypesNotFulfillable.sql
14
17
  related:
15
18
  - ../../compass-canada/features/order-fulfillment-status-per-line.md
16
19
  - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
20
+ - ../../../2.0/apps/_underscore/features/recursive-item-fulfillments.md
17
21
  - ../../../2.0/apps/_underscore/features/calculated-sql-fields.md
18
22
  - ../../../2.0/apps/_underscore/features/sales-order-status-filter-surface.md
19
23
  - mr-ma-order-approval-and-status.md
20
24
  - asn-to-item-fulfillment.md
21
25
  - isfulfillable-data-quality-and-type-rule.md
26
+ - ../../../1.0/apps/test/features/compass-retrofix2-sql-generator.md
22
27
  - ../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md
23
28
  ---
24
- Why Compass USA shows Fulfilled on partly-shipped orders and why the per-line fix was built, measured, then reverted; open before touching Compass USA `_status` fulfillment logic.
29
+ How Compass USA and Canada compute Pending / Picked / Packed / Partially Fulfilled / Fulfilled (the standard base per-line rule since TRUE-79669) and why the old order-total and Canada ASN rules were deleted; open before touching Compass `_status`.
25
30
 
26
31
  ## Summary
27
- TOGa Supply shows **Fulfilled** on Compass USA orders that are only partly shipped. Cause is structural: the fulfillment half of `_Model_Compass_SalesOrder` decides fulfilled vs partiallyFulfilled by comparing **two order-level grand totals** — an "ordered" sum against a "shipped" sum. A grand total lets one line's surplus cancel another line's gap, so an order with line 1 short and line 2 over-shipped reads as complete.
32
+ **Decision (2026-10-15, TRUE-79669, jcardinal):** Compass USA and Compass Canada use the **standard base fulfillment rule** — `_Model_Client_SalesOrder::_fulfillmentStatus()`. Compass keeps only its own **approval gates** in `_status`. Deleted: USA's order-total rule (incl. Strategic Systems ASN counting) and Canada's per-line ASN-bridge override.
28
33
 
29
- **As of 2026-08-27 Compass USA is deliberately NOT fixed.** A per-line rule was written, measured against prod, then **reverted on purpose**. The parent method now holds the **original order-total logic moved verbatim**; the only change to USA's code path is a refactor seam so Compass **Canada** can override the rule.
34
+ Why now: the old reason to keep USA on order totals (ODP duplicate ASNs, broken chain) is gone once the fulfillment chain is repaired — the base rule reads **ItemFulfillmentItems**, not ASN quantity, and [retrofix2](../../../1.0/apps/test/features/compass-retrofix2-sql-generator.md) plus the cron 3a and 318-tracking fixes rebuilt those IFs.
30
35
 
31
- **The reason USA was left alone is the Office Depot ASN feed, not the SQL.** ODP sends **duplicate ASN messages** that credit the same PO line twice: of ODP PO lines with more than one shipping notice, **10,012 exceed the PO line quantity and only 8 are legitimate split shipments**. A per-line rule fed by that data would move ~5,223 orders to Partially Fulfilled, many of which were probably delivered. Evidence: [ASN → ItemFulfillment](asn-to-item-fulfillment.md#office-depot-sends-duplicate-asn-messages-measured-2026-08-27). **Revisit this page only after the ODP feed is fixed.**
32
-
33
- Compass Canada has the same false-Fulfilled bug and **has** been changed to a pure per-line rule — see the [Canada doc](../../compass-canada/features/order-fulfillment-status-per-line.md). USA cannot adopt it as-is (see Gotchas).
36
+ **State:** `_underscore` branch `TRUE-79669` (not yet merged), data SQL committed on `dbchanges2` `TRUE-79669`. **Not deployed** — non-prod test planned first.
34
37
 
35
38
  ## How it works
36
39
 
37
- ### Status of the code (read this first)
38
- - **USA behaviour is unchanged.** The extraction was proved behaviour-neutral by simulating the PHP concatenation and diffing the emitted `_status` SQL: **whitespace-identical, 7,548 normalized characters both ways**.
39
- - An earlier real USA change shipped as commit **`90c964f1`** and was **reverted by `43049d1d`** (2026-08-26 16:19) on `#sprint85`, `TRUE-81284`, `_beta`, `_production` and `_sandbox-client`. Only **`_sandbox-dev`** still carries it. So **any claim that a USA per-line rule shipped — including the previously published "Fulfilled fell by exactly 5,234" — is wrong.**
40
- - The current working tree is **uncommitted**, on branch `_production`, both touched files pass `php -l`. Ticket branch: **`TRUE-81284`**.
41
-
42
40
  ### Key files / entry points
43
- - **`_underscore/Model/Compass/SalesOrder.php`**
44
- - `_status()` — four gates, in order (below). Its fulfillment branch now calls **`static::_fulfillmentStatus($alias_SalesOrders)`**.
45
- - `_fulfillmentStatus()` (`protected static`) — **the original order-total logic, verbatim.** What Compass USA runs. Canada overrides it.
46
- - `const VENDOR_ID__COMPASS = 26` — the **US** Compass vendor id, excluded from "purchased" lines.
47
- - **`_underscore/Model/Compass/Canada/SalesOrder.php`** — the only region that actually overrides the rule.
48
- - Front end (labels only): toga2-supply `FILTERFIELDS.ts` (user-selectable status filter) and `renderBadge.tsx` (badge text).
49
-
50
- ### The `_status` gate order (unchanged, and why fulfillment often never runs)
51
- `_status` evaluates four gates in this order:
52
- 1. an explicit `salesOrderStageId` (and not 2) wins outright;
53
- 2. then any **denied** `ApprovalDecision` returns `canceled`;
54
- 3. then any active-template stage with **no decision** returns that stage's slug and **never looks at shipping**;
55
- 4. only then fulfillment.
41
+ - `_underscore/Model/Client/SalesOrder.php` — `_status()` calls `static::_fulfillmentStatus()` (new `protected static`). The one rule for both Compass regions.
42
+ - `_underscore/Model/Compass/SalesOrder.php` — `_status()`: stage / denial / pending-approval gates, then `static::_fulfillmentStatus()` (inherited base). No `_fulfillmentStatus` of its own. Removed consts `VENDOR_ID__COMPASS`, `CATALOG_ID__COMPASS`.
43
+ - `_underscore/Model/Compass/Canada/SalesOrder.php` — no status override anymore (removed `ITEM_TYPES__NOT_SHIPPABLE` + helpers).
56
44
 
57
- **Gate 3 is the bigger live problem on USA.** Of USA orders on the computed path, **37,173** are fully approved and reach fulfillment while **11,341 are stuck at a pending-approval status** — roughly a quarter of computed-path orders never reach any fulfillment rule. That population, its per-customer breakdown, and the live Agilant spike are in [MR/MA Order Approval & Status Gate](mr-ma-order-approval-and-status.md). Fixing the fulfillment rule would not touch one of them.
45
+ ### Gate order (Compass `_status`)
46
+ 1. `salesOrderStageId` set (and not 2) → that stage wins.
47
+ 2. Any **denied** ApprovalDecision → `canceled`.
48
+ 3. Any active-template stage with no decision → that stage's slug (never reaches fulfillment). See [MR/MA approval gate](mr-ma-order-approval-and-status.md).
49
+ 4. Else base `_fulfillmentStatus()`.
58
50
 
59
- ### What USA runs today
51
+ ### The base rule (`_fulfillmentStatus`)
60
52
  ```
61
- pendingFulfillment WHEN COUNT(shipped TOGa Tech ItemFulfillments) + COUNT(ASNs on the order) = 0
62
- partiallyFulfilled WHEN NOT ( orderedSum <= shippedSum ) # order-level grand totals
63
- fulfilled otherwise
64
- ```
65
- Both sides are the legacy expressions, with all known defects intact:
66
- - the **ordered side filters `parentSalesOrderItemId IS NULL`** while the shipped side does not, so the two sides never count the same lines. USA has **62,865** child lines, so this is widespread. Canada's `SAC100664` is the minimal reproduction.
67
- - the **ordered side explicitly INCLUDES lines with no PO link**, i.e. it expects tax, freight and service lines to ship — making the comparison unreachable for many orders.
68
- - the **order-level TOGa Tech shipped sum is inflated by bridge fan-out**, because it correlates nothing at item level: on `SA136116` the order-level chain returns **48** where a per-item chain returns **32**.
69
-
70
- Recorded as known-wrong, not fixed. They are the case *for* a per-line rule; the ODP feed is the case against it.
71
-
72
- ### The per-line rule that was built and rejected
73
- Shape tested (kept the order total as an AND, so the change could only ever downgrade):
53
+ pendingFulfillment no ItemFulfillments on the order (any stage)
54
+ picked / packed IFs exist, nothing shipped: picked if any picked IF line, else packed
55
+ partiallyFulfilled any line with isFulfillable != 0 has shipped IFI qty < ordered qty
56
+ fulfilled otherwise
74
57
  ```
75
- partiallyFulfilled WHEN NOT ( legacyOrderTotalCondition ) OR ( any expected-to-ship line is short )
76
- ```
77
- Measured on prod:
78
- - **5,223 orders** would move fulfilled → partiallyFulfilled.
79
- - **3,728 orders** were correctly held back **by keeping the order-total check as an AND** — without it they would have been *promoted* to Fulfilled.
80
- - **1,550 USA orders have ZERO expected-to-ship lines** (priced hardware with no PO-item link at all). A pure per-line rule passes **vacuously** on those and returns fulfilled on nothing. Worked example: `SA127967` has five priced hardware lines (`AW5M5UT-EW`, `S24D402GAN`, `HDMM6---ODP`, `9VA80AA`, `9SR37UT`), none PO-linked.
81
-
82
- **Decision (2026-08-27): reverted, not shipped.** The 5,223 cannot be trusted as corrections while ODP duplicate ASNs make a line's shipped quantity unreliable.
83
-
84
- ### Telling a shippable line from a service line (USA column choice)
85
- **Neither `price > 0` nor `Items.inventoryType` separates shippable from non-shippable** — HYBRID dominates both groups. Verified on prod: `PC-KIT-OPT2`, `Tech Support`, `LEASE REFRESH`, `LT-FREIGHT`, `LT-PROPERTY TAX`, `LTX-SALES TAX`, `LT-HPWARRANTY`, `TabAdminSupp`, `CG-MEDKITTING-NWN-APPLE` and `ODP-COMPASS_MERAKI` have **zero** PO links, while real hardware does (`C40QYUC`: 1,208 of 1,362 lines; `MD4A4LL/A-S`: 130 of 137).
86
-
87
- USA fees are identifiable through **either** type column, and the two do not agree:
88
- | Signal | Priced order lines | Lines ever on an ASN |
89
- |---|---|---|
90
- | `AssetTypes.name = 'FEE'` | 90,857 | 732 |
91
- | `ItemTypes.name = 'FEE'` | 89,921 | 70 |
92
-
93
- **936 lines are assetType FEE but not itemType FEE.** The developer's decision: **code reads `ItemTypes` and lets `Items.isFulfillable` carry the assetType meaning**, because the planned data fix folds both columns into the flag — see [isFulfillable — Data Quality & the Type-Derived Rule](isfulfillable-data-quality-and-type-rule.md). Risk that leaves: if the flag drifts back to 1, an itemType-only code test will not catch an assetType-only FEE item.
58
+ - Per **line**, shipped stage only (`ItemFulfillmentStatuses.slug = shipped`), `IFNULL(...,0)`.
59
+ - Skips lines whose `Items.isFulfillable = 0`. **NULL counts as fulfillable.**
60
+ - No `price > 0`, PO-link or parent-line filter. So every non-shipping line (fee, service, never-purchased) must have `isFulfillable = 0`, or the order stays Partially Fulfilled.
61
+ - Compass now shows **Picked / Packed** too (was shipped-only).
94
62
 
95
- ### Verified clean (negatives, audited both Compass clients 2026-08-27)
96
- - every `salesOrderStageId` in use resolves to a real `SalesOrderStages` row — **no orphans**;
97
- - the approval half is structurally sound too (one active template, no duplicate `Approval` rows or decisions), so **`_status` cannot return NULL in either client today** — details in [MR/MA Order Approval & Status Gate](mr-ma-order-approval-and-status.md).
63
+ ### Data the rule needs
64
+ - `dbchanges2/Client_Compass/2026-10-15a - FeeItemsNotFulfillable.sql` — every `ItemTypes.name = 'FEE'` item → `isFulfillable = 0` (`LT-FREIGHT`, `LT-PROPERTY TAX`, `TabAdminSupp` were wrongly 1). Re-runnable.
65
+ - `dbchanges2/Client_CompassCanada/2026-10-15a - NonShippableItemTypesNotFulfillable.sql` — `CONSULTING` / `SERVICE` / `SERVICES` / `WARRANTY` item types → 0.
98
66
 
99
- Post-fulfillment tail (USA, explicit stage): **473** Pending Billing, **39,869** Billed, **1,592** Canceled, **809** Closed — the lifecycle continues Fulfilled → Pending Billing → Billed via the stored stage, not the computed status.
67
+ ### Measured locally (post-retrofix, 2026-10-15)
68
+ - USA shipped SA orders: **5,809 of 7,887 Fulfilled, 2,078 Partially Fulfilled.** The partials are real hardware lines with no shipment on the IF side: 1,834 ODP lines with no ASN, 667 with an ASN but no IF, 646 never purchased.
69
+ - Canada: **46** open orders have an ASN but no IF → now **Pending Fulfillment** (the old ASN rule counted them).
100
70
 
101
- ### Blast radius — fulfilled vs partiallyFulfilled is display-only (re-confirmed 2026-08-27)
102
- **No cron, email or report reads the distinction.**
103
- - **The status is computed live and never stored.** No backfill; reverting the code fully reverts the behaviour.
104
- - **In-transit / delivered email crons are unaffected.** `worker/crons/toga2/compass/update_salesorder_status_from_odp.php`, `worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php` and `compass/workflow/test_partial_in_transit_email.php` each keep their **own duplicated copy** of the `_status` CASE, but those copies only ever produce canceled / pendingApprovalUnknown / pendingFulfillment / shipped and filter on `computedStatusSlug = 'shipped'`. **That duplication is itself a divergence risk** — see the [in-transit email doc](../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md).
105
- - **The MA sales-order exception report** (`compass/workflow/7_generate_ma_sales_order_exception_report.php`) filters the **stored** `salesOrderStageId` column, not the computed status.
106
- - **toga2-supply** sends only the approvals filter values (`pendingInitialApproval` / `pendingApproval`) to the server; `fulfilled` and `partiallyFulfilled` appear only in `FILTERFIELDS.ts` and `renderBadge.tsx`.
71
+ ### Blast radius — display only
72
+ - Status is computed live, never stored. Reverting the code reverts the behavior.
73
+ - In-transit / delivered crons (`worker/crons/toga2/compass/update_salesorder_status_from_odp.php`, `compasscanada/update_salesorder_status_from_g&t.php`) keep their **own copy** of the `_status` CASE and act only on `shipped`. Unaffected, but now diverge from the model.
74
+ - MA exception report reads the stored `salesOrderStageId`. toga2-supply only labels `fulfilled` / `partiallyFulfilled`.
107
75
 
108
76
  ## Gotchas
109
- - **⚠ Any future USA per-line rule MUST keep an order-total guard** (or equivalent non-vacuous check), because of the 1,550 zero-expected-line orders.
110
- - **⚠ Do NOT port Canada's clean per-line rule to USA.** Canada has no order-total condition at all; on USA that omission alone promotes thousands of orders to a *new* false Fulfilled.
111
- - **Duplicate ASNs over-credit a line — this is the blocker, not a footnote.** Confirmed on `SA135272`: PO line 1 credited twice (ordered 4 / shipped 8) while line 2 was never mentioned (ordered 4 / shipped 0), and the order-total rule said fulfilled. Both ASN payloads referenced `lineNumber 1`. Full analysis in [ASN → ItemFulfillment](asn-to-item-fulfillment.md#office-depot-sends-duplicate-asn-messages-measured-2026-08-27).
112
- - **4.3% of USA ASN shipped quantity cannot be attributed to a sales-order line at all**: **5,536** `AdvanceShippingNoticeItems` have a NULL `purchaseOrderItemId` and **12,163** more point at a `purchaseOrderItemId` with no `SalesOrderItems_PurchaseOrderItems` link (**26,770 of 617,166 units**). Treat per-line shipped quantity as a floor, not a truth.
113
- - **⚠ `self::` defeats the region seam.** `_Model_Compass_SalesOrder` calls **`self::_status()`** (not `static::`) in its ApprovalDecision notification query, so that query always evaluates the parent rule even for Canada orders.
114
- - **`salesOrderStageId = 2` is a dormant, USA-impossible branch.** `_status` hardcodes stage 2 as a recompute trigger, but USA's `SalesOrderStages` table contains **only ids 5 to 9** (Pending Billing, Pending Billing/Partially Fulfilled, Billed, Canceled, Closed) — stage 2 does not exist there, so setting it would be an orphan. Canada has all ten. No order in either client currently uses stage 2.
115
- - **The same `partNumber` can exist as several `Items` rows, some purchased and some not.** Any such test must be **per line**, never per part number.
116
- - **No stage means Fulfilled, so you cannot set it.** USA stages 5 to 9 are all post-fulfillment or terminal. To move an order to Fulfilled, give it shipped quantity (an ASN). Bulk procedure: [ODP ASN replay backfill](../workflows/odp-asn-replay-backfill.md).
77
+ - **⚠ A line with `isFulfillable` NULL or 1 must ship, or the order never reaches Fulfilled.** The daily NetSuite item sync can flip a type-derived 0 back to 1 (no write-time guard yet) — see [isFulfillable data quality](isfulfillable-data-quality-and-type-rule.md). Re-check fee items after deploy.
78
+ - **Order status now depends on IFs, not ASNs.** An ASN with no IF (broken chain, ASN flow failure) reads as not shipped. Fix the IF, not the rule — per-order tool: [fix_item_fulfillment_chain](../../../1.0/apps/test/features/compass-fix-item-fulfillment-chain.md).
79
+ - **No stage means Fulfilled is computed, not set.** USA `SalesOrderStages` holds only ids 5–9 (post-fulfillment / terminal); stage 2 is a dormant recompute trigger that does not exist in USA. To make an order Fulfilled, give it shipped IF quantity.
80
+ - **Superseded — do not resurrect:** the USA "order-total kept on purpose (2026-08-27)" decision and Canada's ASN-bridge per-line rule (90 orders corrected, `ITEM_TYPES__NOT_SHIPPABLE`). Order totals let one line's surplus hide another's gap; the base per-line rule replaces both.
117
81
 
118
82
  ## Change history
83
+ - 2026-10-15 — TRUE-79669: Compass USA + Canada switched to the standard base `_fulfillmentStatus()` (per line, skips `isFulfillable = 0`, adds Picked/Packed); deleted USA order-total rule and Canada ASN override; FEE / service item data fixes. Supersedes the 2026-08-27 "USA keeps order-total" decision. Not deployed. (jcardinal)
119
84
  - 2026-10-13 — Noted that Fulfilled has no stage and is reached only through shipped quantity. (bala)
85
+ - 2026-08-27 — Per-line USA rule built, measured (5,223 orders) and reverted; Canada got its own ASN per-line rule. (bala)
120
86
 
121
87
  ## Related
122
- - [Compass Canada — per-line, ASN-only rule](../../compass-canada/features/order-fulfillment-status-per-line.md) — the region that did change, and why its rule is safe there.
123
- - [Compass ASN → ItemFulfillment](asn-to-item-fulfillment.md) — the ODP duplicate-ASN evidence that blocks the USA change.
124
- - [Compass MR/MA Order Approval & Status Gate](mr-ma-order-approval-and-status.md) — the approval gate that strands more USA orders than the fulfillment rule mislabels.
88
+ - [Compass Canada — order fulfillment status](../../compass-canada/features/order-fulfillment-status-per-line.md)
125
89
  - [IF Stage Lifecycle & Order Status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md)
126
- - [FIELD_SQL calculated fields](../../../2.0/apps/_underscore/features/calculated-sql-fields.md) — why a region-subclass override of a calculated field is picked up at all.
90
+ - [Recursive Item Fulfillments](../../../2.0/apps/_underscore/features/recursive-item-fulfillments.md)
127
91
  - [isFulfillable — Data Quality & the Type-Derived Rule](isfulfillable-data-quality-and-type-rule.md)
128
- - [Sales-order status filter surface](../../../2.0/apps/_underscore/features/sales-order-status-filter-surface.md)
129
- - [Compass partial in-transit/delivered emails](../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md)
92
+ - [MR/MA Order Approval & Status Gate](mr-ma-order-approval-and-status.md)
@@ -23,7 +23,7 @@ project: _Underscore
23
23
  client: compass-usa
24
24
  type: profile
25
25
  status: active
26
- updated: 2026-10-13
26
+ updated: 2026-10-15
27
27
  owners: [jcardinal, bala, tcox, apeterson, dfranks, akhokhani, ajean, rgirish]
28
28
  files: []
29
29
  related:
@@ -263,22 +263,12 @@ separate, related client (see its own profile).
263
263
  #31, whole modal route). See
264
264
  [Surface Frontend](../../2.0/apps/toga25-supply/features/surface-frontend.md) and
265
265
  [Surface Layer Schema](../../2.0/apps/dbchanges2/features/surface-layer-schema.md).
266
- - **Order status is shipped-only (2026-06-30).** Compass imports all IF stages
267
- (picked/packed/shipped) but its order `_status` (`_underscore/Model/Compass/SalesOrder.php`,
268
- shared by Compass USA + Canada) counts **shipped only** — picked/packed never advance a Compass
269
- order. Contrast Quad, which gets the full picked/packed machine. See
270
- [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
271
- - **⚠ Fulfilled vs Partially Fulfilled is still the old order-total rule, on purpose (2026-08-27).**
272
- The order-level ordered-total vs shipped-total comparison lets one line's surplus hide another
273
- line's gap, so USA orders read **Fulfilled** while partly shipped. A per-line fix was built,
274
- measured (5,223 orders would move) and **deliberately reverted**, because Office Depot sends
275
- **duplicate ASN messages** — 10,012 ODP PO lines are credited over quantity against only 8 genuine
276
- split shipments — so line-level shipped quantity cannot be trusted yet. `_status()`'s fulfillment
277
- branch does live in `_fulfillmentStatus()`, but **only Compass Canada overrides it**; the USA body
278
- is the original logic verbatim. **⚠ Do not port Canada's clean per-line rule:** 1,550 USA orders
279
- have no expected-to-ship line at all, so it passes vacuously and would create *new* false
280
- Fulfilleds. Any earlier "5,234 orders fixed" note is void — that commit was reverted in git.
281
- [Fulfilled vs Partially Fulfilled](features/order-fulfillment-status-per-line.md).
266
+ - **Order status = the standard base per-line rule (TRUE-79669, 2026-10-15, not yet deployed).**
267
+ Compass USA + Canada keep only their approval gates in `_Model_Compass_SalesOrder::_status`; the
268
+ fulfillment half is the base `_Model_Client_SalesOrder::_fulfillmentStatus()` (per line, shipped
269
+ IF qty, skips `isFulfillable = 0`, shows Picked/Packed). Replaces the order-total rule kept on
270
+ purpose since 2026-08-27. Needs fee items at `isFulfillable = 0`.
271
+ [Order fulfillment status](features/order-fulfillment-status-per-line.md).
282
272
  - **⚠ The approval gate strands far more orders than the fulfillment rule mislabels (2026-08-27).**
283
273
  **11,341** Compass USA orders sit at a pending-approval status and never reach the fulfillment
284
274
  branch at all (Office Depot 9,926, Compass 950, Agilant 465), growing 1,200–2,000 a month. MR is
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-10-14
9
+ updated: 2026-10-15
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
@@ -93,7 +93,7 @@ Every step of 3a is gated by a `totalRecordCount == 0` existence check, so a re-
93
93
  | ODP SalesOrder | `:162-227` existence check | reuses the existing SO |
94
94
  | SO items | `:470-498` | matches existing items **case-insensitively by `partNumber`**; creates only unmatched ones |
95
95
  | ODP PurchaseOrder | `:356-436` | creates only if missing |
96
- | `PurchaseOrders_SalesOrders` + `PurchaseOrderItems_SalesOrderItems` bridges | `:632` | creates only if missing |
96
+ | `PurchaseOrders_SalesOrders` + `PurchaseOrderItems_SalesOrderItems` bridges | `:632` | creates only if missing (a re-drop does **not** fix a wrong existing link) |
97
97
 
98
98
  **KNOWN GAP: 3a never updates the SalesOrder HEADER on an existing order.** So `customerPurchaseOrder` stays `NULL` on a repaired order and must be set manually. See [Recovering a lost ODP EDI import](odp-edi-import-recovery.md).
99
99
 
@@ -193,6 +193,7 @@ The NetSuite writeback lands on the **downstream ODP SalesOrder (customerId=1)**
193
193
  - **To confirm a PO actually reached ODP:** check `PurchaseOrders.dtSubmitted` is set **AND** that downstream ODP SO(s) exist via the `PurchaseOrders_SalesOrders` join — their existence proves ODP received the PO and 850'd back.
194
194
 
195
195
  ## Gotchas
196
+ - **⚠ Cron 3a linked every Compass PO line to the LAST ODP SO line (2026-04-28 → 2026-10-06; fixed `worker` `02cc2473`).** Commit `afb520c1` left a scalar `$officeDepotSalesOrderItemUuid` that the customer-PO link loop reused, so all `PurchaseOrderItems_SalesOrderItems` rows for a PO pointed at the last ODP SO line. The recursive IF walk then broke at the ODP→Compass tier and Compass orders got no fulfillment or tracking (proven on SA130861 / SA130762). Fix: link via `$lookupSalesOrderItemUuidFromPurchaseOrderLine[lineNumber]`; repeated vendor part numbers pair one-to-one (`array_shift` on the lookup). Zero bad links on ~440 orders since 2026-10-07. History is repaired by [retrofix2](../../../1.0/apps/test/features/compass-retrofix2-sql-generator.md) (bulk) or [fix_item_fulfillment_chain](../../../1.0/apps/test/features/compass-fix-item-fulfillment-chain.md) (one order). **Since 3a resolves 850 lines through ODP `VendorItems`, the ODP SO line is often the catalog-2 twin of the Compass item — match these tiers by part number, not `itemId`.**
196
197
  - **⚠ 2026-10-10 → 10-12: ODP POs stopped arriving because the Lambda stopped, not 3a.** An attacker used a leaked long-lived IAM key; AWS suspended services including Lambda, so `rename_edi_files` stopped at **07:29 CDT Sat 2026-10-10** and **52 POs** piled up in `processed/`. 67 `.net` files (850s + 997s) were moved by hand on 2026-10-12; 3a created all 52 POs (0 rejects) and cron 5 sent them to NetSuite. The leaked key was deleted the same day; 3a and the move cron now run on the `WorkloadsRuntime` role ([AWS credentials](../../../1.0/apps/library/features/app-cloud-aws-credentials.md)). All stuck POs imported. The `OfficeDepot/` backlog monitor stayed green throughout (its folder was empty); the [inbound files monitor](../features/oneuptime-office-depot-inbound-files-monitor.md) now covers that gap. Symptom of a dead mover: files pile up in `processed/`, `OfficeDepot/` stays empty, no new ODP SalesOrders.
197
198
  - **⚠ `edi/1_download_edi_s3_create_po_toga.php` is DEAD CODE (correction 2026-08-04).** It appears in **no** schedule file. The scheduled entry *named* "Download EDI From S3 Files & Create PO - EDI Office Depot" (`*/5 * * * *`, `worker/schedules/cron.worker.sync.json` `:254-259`) actually points at **`toga2/compass/workflow/3a_import_office_depot_purchase_orders.php`**. The two scripts have **different idempotency and different API call shapes** — `edi/1`'s create branch POSTs the SO with *nested* items and a `customer.name`, while **3a POSTs the header alone (uuids) and then POSTs the items in a separate request**. Reasoning from `edi/1` gives wrong answers. **General rule: never infer which script runs from its filename or folder** — verify against `worker/schedules/cron.*.json`, and note the schedule entry's **name can be actively misleading**. The folders `compass/edi/`, `compass/DOA_edi_odp_flow/`, and `compass/DOA_Scripts/` are **legacy / inactive**; `compass/edi/3_send_edi_855(reject).php` is an abandoned sketch using **non-standard** reject codes (`BAK01=A1` / `BAK02=RE`) — **do not** follow it.
198
199
  - **⚠ The `OfficeDepot/` listing makes 3a runs overlap (NOT fixed).** An instrumented prod run 2026-08-25 showed the S3 listing returning **138,472 objects** and taking **32 seconds** before any PO was processed, each PO then ~40 seconds. A file-bearing run blows past the `*/5` schedule and the following runs are **silently skipped** by `App_Framework::exitIfProcessRunning()` — leaving **no `CronJobExecutions` row at all**, which reads as "the cron never fired". The object count is driven by `OfficeDepot/SENT/` and `OfficeDepot/OUTBOX/` accumulating: 3a skips those prefixes when *processing* but still *lists* them. Fix candidates (separate work): prune `SENT/`, or list with a tighter prefix. See [Tracing a worker cron run in production](../../1.0/apps/worker/workflows/tracing-a-worker-cron-run-in-production.md).
@@ -230,6 +231,7 @@ The NetSuite writeback lands on the **downstream ODP SalesOrder (customerId=1)**
230
231
  - **Editing cron recipient lists — a grep hit is not a live hit.** Email recipients are hardcoded in the cron scripts, and **several dormant copies of the same script exist** under `crons/toga2/compass/edi/` and `crons/toga2/compass/DOA_edi_odp_flow/`. Before changing a recipient list, cross-check the file against `worker/schedules/*.json` for a referencing entry whose `active` is not `0`. Only the scheduled copies matter.
231
232
 
232
233
  ## Change history
234
+ - 2026-10-15 — Cron 3a last-ODP-line link bug (since `afb520c1`) root-caused and fixed in `02cc2473`; re-drop does not repair a wrong link. (jcardinal)
233
235
  - 2026-10-14 — TRUE-82797 (pending deploy): cron 5 merged only one ODP SO per Compass SO, so siblings went out as a 2nd NetSuite SO (41 Compass SOs in 60 days). Fix merges all ODP SOs of a Compass SO, splits tablets by `TABLET_KIT_PART_NUMBERS`, externalId = lowest ODP SO id, duplicate-part guard emails devteam. Decided against category grouping. (rgirish)
234
236
  - 2026-10-13 — Stop-gap move cron live on the role (Lambda still suspended); all stuck POs imported; linked the new `OfficeDepotInboundFiles` monitor. (bala)
235
237
  - 2026-10-12 — Lambda `rename_edi_files` suspended after the IAM key leak; documented the stop-gap mover cron (TRUE-82705) (jcardinal)
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: compass-usa
6
6
  type: workflow
7
7
  status: active
8
- updated: 2026-10-09
8
+ updated: 2026-10-15
9
9
  owners: ["jcardinal", "bala", "dfranks"]
10
10
  files: []
11
11
  related:
@@ -58,7 +58,7 @@ NetSuite delivers asset tags on **separate service-item lines** whose part numbe
58
58
  1. **Links:** every SOI that should be procured has exactly one PURE bridge to the matching POI on the correct PO; spurious (itemId-mismatch) bridges should not exist; the full header chain (SalesOrders_PurchaseOrders / PurchaseOrders_SalesOrders) should be intact at each present layer.
59
59
  2. **Never over-fulfill:** `SUM(ItemFulfillmentItems.quantity)` per SOI must be `≤ SalesOrderItems.quantity`. A shipped POI fanning out to many linked SOIs must not produce IF items totaling more than ordered.
60
60
  3. **ASN/IF tracking is maintained at all three levels and rolls up:** a tracking number on a unit must also be linked at the item level and the header level — on BOTH the ASN side (`AdvanceShippingNoticeItemUnits_TrackingNumbers` → `AdvanceShippingNoticeItems_TrackingNumbers` → `AdvanceShippingNotices_TrackingNumbers`) and the IF side (`ItemFulfillmentItemUnits_TrackingNumbers` → `ItemFulfillmentItems_TrackingNumbers` → `ItemFulfillments_TrackingNumbers`). Always link existing `TrackingNumbers`; never create them.
61
- 4. **Stage is now mandatory:** as of 2026-06-30 `ItemFulfillments.itemFulfillmentStageId` is NOT NULL and the picked/packed/shipped stages are all in use (resolved by status `slug`, not the legacy numeric "stage 3 = Shipped"; stages 1/2 are no longer "unused"). The 2026-06-30a migration backfills any NULL-stage IF to the shipped stage. Compass order `_status` still counts **shipped only** even though all stages are imported — see the [IF stage lifecycle doc](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
61
+ 4. **Stage is now mandatory:** as of 2026-06-30 `ItemFulfillments.itemFulfillmentStageId` is NOT NULL and the picked/packed/shipped stages are all in use (resolved by status `slug`, not the legacy numeric "stage 3 = Shipped"; stages 1/2 are no longer "unused"). The 2026-06-30a migration backfills any NULL-stage IF to the shipped stage. Since TRUE-79669 (2026-10-15, not yet deployed) Compass order `_status` uses the base per-line rule incl. Picked/Packed — see the [IF stage lifecycle doc](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
62
62
  5. **Mirror back-links:** every IF on a non-source SO (ODP/Agilant) should have `upstreamItemFulfillmentId` set, and each of its items `upstreamItemFulfillmentItemId` set.
63
63
 
64
64
  ### Detecting "empty shell" orders left by a fatal mid-sequence
@@ -91,6 +91,9 @@ The current repair script is **`test/@jeff/compass/retrofix2.php`** (1.0 `App_`)
91
91
  - **High-multiplier over-fulfillment (5×–20×) does not fit the split-PO spurious-link pattern** — separate cause.
92
92
  - **Incomplete orders (no PO/ASN/Agilant leg yet) are normal** — repair only the layers that exist.
93
93
 
94
+ ## Change history
95
+ - 2026-10-15 — Order status now the base per-line rule incl. Picked/Packed (TRUE-79669). (jcardinal)
96
+
94
97
  ## Related
95
98
  - [retrofix2 SQL generator](../../../1.0/apps/test/features/compass-retrofix2-sql-generator.md)
96
99
  - [Compass USA profile](clients/compass-usa/profile.md)
@@ -82,7 +82,7 @@ Client-specific DB change-sets live in `dbchanges2/Client_Quad/`.
82
82
  - **Order status: full picked/packed lifecycle (2026-06-30).** Quad gets the full
83
83
  picked → packed → partially-fulfilled → fulfilled order `_status` machine
84
84
  (`_underscore/Model/Quad/SalesOrder.php`): non-SA orders use the inline machine, `SA%` orders
85
- delegate to the base `_Model_Client_SalesOrder`. Contrast Compass, which is shipped-only. See
85
+ delegate to the base `_Model_Client_SalesOrder` (Compass also uses the base rule since TRUE-79669). See
86
86
  [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
87
87
  - **Surface layer (beta):** the surfaces `meta-group` script had **no** `AclRecordScripts` dispatch
88
88
  grant for `Client_Quad` (only `meta` was granted, to client role 1), so `GET /v2/surfaces/meta-group`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.1028",
3
+ "version": "1.0.1029",
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",