toga-ai 1.0.669 → 1.0.670

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.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-08-27
10
10
  owners: [bala]
11
11
  files:
12
12
  - _underscore/Model/Client/SalesOrder.php
@@ -47,8 +47,11 @@ This makes the *picked/packed* portion of the fulfillment lifecycle visible (pre
47
47
  - **Compass USA + Compass Canada:** all IF stages are *imported*, but order status counts
48
48
  **shipped only** — picked/packed never advance a Compass order's status. Compass walks
49
49
  Pending → Partially Fulfilled → Fulfilled by shipped quantity alone. **The shipped-only *stage*
50
- policy is shared, but since 2026-08-26 the fulfilled-vs-partiallyFulfilled *rule* is per region:**
51
- the fulfillment branch lives in `_fulfillmentStatus()` and Canada overrides it. See
50
+ policy is shared, but the fulfilled-vs-partiallyFulfilled *rule* is now per region.** **Only Canada
51
+ actually changed:** the fulfillment branch lives in `_fulfillmentStatus()`, Canada overrides it with
52
+ a pure per-line rule, and **Compass USA still runs the original order-total logic on purpose**
53
+ (its ASN feed is not trustworthy at line level). The seam was reverted in git on 2026-08-26 and
54
+ rebuilt uncommitted. See
52
55
  [Compass USA](../../../clients/compass-usa/features/order-fulfillment-status-per-line.md) and
53
56
  [Compass Canada](../../../clients/compass-canada/features/order-fulfillment-status-per-line.md).
54
57
 
@@ -67,7 +70,8 @@ shipped-only for ALL clients** — picked/packed quantities never count as inven
67
70
  `COUNT` and the shipped-qty `SUM` subqueries `INNER JOIN ItemFulfillmentStages →
68
71
  ItemFulfillmentStatuses` filtered to the shipped status slug. Shared by Compass USA + Canada.
69
72
  Its **fulfillment half is a separate `protected static _fulfillmentStatus()`** called via
70
- `static::` — that is the per-region seam (see the Compass docs above). The approval gate stays in
73
+ `static::` — that is the per-region seam (see the Compass docs above), and its **body is the original
74
+ order-total logic that USA still runs**. The approval gate stays in
71
75
  `_status()` and is region-independent.
72
76
  - **`Model/Compass/Canada/SalesOrder.php`** — Canada's `_fulfillmentStatus()` override (per-line,
73
77
  ASN-bridge only).
@@ -177,11 +181,15 @@ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is
177
181
  the Compass order-lifecycle workflow.
178
182
  - **⚠ An order-level "ordered total vs shipped total" comparison is not a fulfillment test.** A
179
183
  grand total lets one line's surplus cancel another line's gap, so an order with one line short and
180
- another over-shipped reads **Fulfilled**. This is exactly what the Compass `_status` did until
181
- 2026-08-26 (its two sides did not even count the same lines — the ordered side filtered
182
- `parentSalesOrderItemId IS NULL`, the shipped side did not), and it mislabeled **5,234** USA and
183
- **115** Canada orders on prod. Any client `_status` that judges completeness must compare **per
184
- line**. See the two Compass docs linked above.
184
+ another over-shipped reads **Fulfilled**. This is what the Compass `_status` does for **USA
185
+ today** — and its two sides do not even count the same lines (the ordered side filters
186
+ `parentSalesOrderItemId IS NULL`, the shipped side does not). Measured on prod: **90** Canada orders
187
+ were mislabeled and have been corrected by a per-line rule, and **5,223** USA orders would move —
188
+ but the USA change was **deliberately not made**, because duplicate Office Depot ASNs make its
189
+ per-line shipped quantity unreliable. Any client `_status` that judges completeness must compare
190
+ **per line**, and must not do so on a feed that double-credits lines. **⚠ Earlier figures of 5,234
191
+ USA / 115 Canada are void** (the first attempt was reverted in git). See the two Compass docs
192
+ linked above.
185
193
  - **A `self::`-qualified call to a calculated field inside the same model always uses the parent's
186
194
  logic.** `_Model_Compass_SalesOrder` calls `self::_status()` in its ApprovalDecision notification
187
195
  query, so that one call site never sees a region subclass override. Use `static::` unless you
@@ -191,6 +199,15 @@ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is
191
199
  picked/packed constant would let the literal be removed.
192
200
 
193
201
  ## Change history
202
+ - 2026-08-27 — **Corrected the 2026-08-26 entry below: that work was reverted in git (`90c964f1`
203
+ reverted by `43049d1d`) and the "5,234 USA / 115 Canada" figures are void.** Rebuilt state: the
204
+ `_fulfillmentStatus()` per-region seam exists again but is **uncommitted**, `_fulfillmentStatus()`'s
205
+ body is the **original order-total logic that Compass USA still runs deliberately**, and **only
206
+ Compass Canada overrides it** — with a pure per-line rule, no order-total condition, correcting **90**
207
+ prod orders (not 115). A USA per-line rule was measured (5,223 orders would move) and rejected
208
+ because duplicate Office Depot ASNs make USA's per-line shipped quantity unreliable. The
209
+ order-total-is-not-a-fulfillment-test gotcha now records that a per-line test also needs a
210
+ trustworthy line-level feed. (bala)
194
211
  - 2026-08-26 — Recorded that the Compass **fulfilled-vs-partiallyFulfilled** rule is now **per
195
212
  region**: the fulfillment branch of `_Model_Compass_SalesOrder::_status()` was extracted into a
196
213
  `protected static _fulfillmentStatus()` invoked via `static::` (approval gate untouched, emitted
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: compass-canada
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-08-27
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - _underscore/Model/Compass/Canada/SalesOrder.php
@@ -25,43 +25,62 @@ TOGa Supply showed **Fulfilled** on Compass Canada orders that were only partly
25
25
  `SAC100664` (`SalesOrders.id 665`): line 1 the ZBOOK `C40QYUC#ABA` ordered 1 / shipped 0, line 2 the
26
26
  mouse `9VA80AA#ABA` ordered 1 / shipped 1, and the badge read **Fulfilled**.
27
27
 
28
- Root cause was in the shared parent `_Model_Compass_SalesOrder::_status()`, which compared two
29
- **order-level grand totals** whose two sides did not count the same lines: the ordered side filtered
30
- `SalesOrderItems.parentSalesOrderItemId IS NULL` (dropping bundle child lines) while the shipped side
31
- summed **every** ASN quantity on the order with no such filter. On `SAC100664` the mouse is a child
32
- of the ZBOOK (`parentSalesOrderItemId = 1131`), so ordered = 1 and shipped = 1 and it read fulfilled.
33
- Confirmed by running the exact expression on prod: orderedSide `1.00`, shippedSide `1`.
34
-
35
- Canada's fix is a clean **per-line** rule in its own subclass. Prod impact: **115 Canada orders
36
- corrected Fulfilled to Partially Fulfilled, zero orders moving the other way**, and
37
- pendingFulfillment / canceled / pendingApproval / pendingInitialApproval counts all unchanged.
38
-
39
- > **Canada's rule is NOT the USA rule, and the two must not be merged.** Compass USA needs a second,
40
- > aggregate condition on top of the per-line check; porting Canada's clean rule to USA creates
41
- > thousands of *new* false Fulfilleds. See
28
+ Root cause is in the shared parent `_Model_Compass_SalesOrder`, which decided fulfilled vs.
29
+ partiallyFulfilled by comparing two **order-level grand totals** whose two sides did not count the
30
+ same lines: the ordered side filtered `SalesOrderItems.parentSalesOrderItemId IS NULL` (dropping
31
+ bundle child lines) while the shipped side summed **every** ASN quantity on the order with no such
32
+ filter. On `SAC100664` the mouse is a child of the ZBOOK (`parentSalesOrderItemId = 1131`), so
33
+ ordered = 1 and shipped = 1 and it read fulfilled. Confirmed by running the exact expression on prod:
34
+ orderedSide `1.00`, shippedSide `1`.
35
+
36
+ Canada's fix is a **pure per-line rule** in its own subclass, with **no order-total check at all**.
37
+ Measured on prod after the rule: **489 fulfilled / 184 pendingFulfillment / 109 partiallyFulfilled**,
38
+ which is **90 orders corrected** from Fulfilled to Partially Fulfilled, plus 3 corrected from
39
+ partiallyFulfilled to pendingFulfillment.
40
+
41
+ > **⚠ Read this before trusting any older note.** An earlier version of this work was **reverted in
42
+ > git** and its published numbers were wrong — see *History: the first attempt was reverted* below.
43
+ > The rule described on this page was rebuilt from scratch and is **uncommitted working-tree code**.
44
+
45
+ > **Canada's rule is NOT the USA rule, and the two must not be merged.** Compass **USA is
46
+ > deliberately unchanged** and still runs the order-total logic, because the Office Depot ASN feed is
47
+ > not trustworthy at line level. See
42
48
  > [the USA doc](../../compass-usa/features/order-fulfillment-status-per-line.md).
43
49
 
44
50
  ## Key files / entry points
45
51
 
46
52
  - **`_underscore/Model/Compass/Canada/SalesOrder.php`** — `_fulfillmentStatus()`, a `protected static`
47
- override of the shared parent method. This is the whole Canada rule.
48
- - **`_underscore/Model/Compass/SalesOrder.php`** — the parent `_status()` (approval gate untouched)
49
- now calls `static::_fulfillmentStatus()`, which is what makes the Canada override reachable. It
50
- also supplies the `_qtyShippedByAsn()` helper Canada reuses.
53
+ override. **Self-contained:** it builds its own expected-line set and its own per-line shipped
54
+ quantity and borrows nothing conditional from the parent. This is the whole Canada rule.
55
+ - **`_underscore/Model/Compass/SalesOrder.php`** — the parent. The fulfillment branch of `_status()`
56
+ was extracted into `protected static _fulfillmentStatus()` and is called via `static::`, which is
57
+ what makes the Canada override reachable. **The parent body is the ORIGINAL order-total logic,
58
+ moved verbatim** — the seam exists only so Canada can override. Proved behaviour-neutral by
59
+ diffing the emitted `_status` SQL: whitespace-identical, **7,548 normalized characters** both ways.
60
+ - `const ITEM_TYPES__NOT_SHIPPABLE` (Canada) — `SERVICE`, `SERVICES`, `CONSULTING`, `WARRANTY`.
51
61
 
52
62
  ## How it works
53
63
 
54
64
  ```
55
- pendingFulfillment WHEN the order has no ASN at all
56
- partiallyFulfilled WHEN any line with price > 0 has quantity > that line's own shipped quantity
65
+ pendingFulfillment WHEN every expected-to-ship line has received ZERO quantity
66
+ partiallyFulfilled WHEN any expected-to-ship line has received LESS than its ordered quantity
57
67
  fulfilled otherwise
58
68
  ```
59
69
 
60
- - **No ASN** is `COUNT(*) = 0` over `AdvanceShippingNotices` joined to the order through
61
- `SalesOrders_PurchaseOrders`.
62
- - **Per-line shipped quantity** comes from `_qtyShippedByAsn()` on the shared parent, correlated to
63
- the individual `SalesOrderItems.id`.
64
- - No parent/child line filter, so bundle children are judged on their own merits — that is the fix.
70
+ **Both branches read the same expected-line set and the same per-line quantity**, so the gate and
71
+ the shortfall test can never disagree. That symmetry is the design; keep it if you edit the method.
72
+
73
+ ### "Expected to ship" — all four conditions
74
+
75
+ A `SalesOrderItems` row counts only when:
76
+
77
+ 1. `price > 0`;
78
+ 2. it has a `SalesOrderItems_PurchaseOrderItems` link (it was actually purchased);
79
+ 3. its `ItemTypes.name` is **not** in `ITEM_TYPES__NOT_SHIPPABLE`; and
80
+ 4. `COALESCE(Items.isFulfillable, 1) = 1`.
81
+
82
+ No parent/child line filter, so bundle children are judged on their own merits — that is the
83
+ original fix.
65
84
 
66
85
  ### The per-line shipped source (ASN bridge, and only the ASN bridge)
67
86
 
@@ -74,57 +93,136 @@ AdvanceShippingNoticeItems.purchaseOrderItemId
74
93
  This mapping is **100% complete in `Client_CompassCanada`**: all **947** ASN items have a
75
94
  `purchaseOrderItemId` and every one resolves to a sales-order line. Both join columns are indexed.
76
95
  Canada ships through Grand & Toy ASNs only (see
77
- [G&T ASN Import](grand-and-toy-asn-import.md)), so this single source is sufficient here — Canada
78
- needs none of USA's TOGa Tech / Office Depot fulfillment chain.
96
+ [G&T ASN Import](grand-and-toy-asn-import.md)), so this single source is sufficient — Canada needs
97
+ none of USA's TOGa Tech / Office Depot fulfillment chain.
98
+
99
+ ## Why each condition is there (measured, prod)
100
+
101
+ - **Condition 3 (item type) is what corrected the over-correction.** Canada has SERVICE and
102
+ CONSULTING lines that **do carry PO links but never ship**: `COMPASS-IPAD-ABM` (Apple Business
103
+ Manager enrolment — 53 lines, only 9 ever on an ASN), `SDU42Z/A` and `SDRU2Z/A` (AppleCare for
104
+ Enterprise), `CN-COMP-DED-IN` / `CN-CONE-SHR-WS1` (Profile SKUs), and `COMPASS-MACBOOK-ABM-ACE`.
105
+ Requiring them to ship held **25 fully-delivered orders** at partiallyFulfilled — e.g. `SAC100041`,
106
+ `SAC100088`, `SAC100523`. Excluding the item types fixed all 25. **This is the 115 to 90 correction.**
107
+ - **Condition 2 (purchased line) is still required.** Canada has **100 priced lines with no PO link
108
+ across 48 orders**; without the link test those lines can never ship and would strand their orders.
109
+ - **Condition 4 (`isFulfillable`) is a no-op in Canada today.** `Items.isFulfillable` is **NULL on
110
+ every Canada item**, so the `COALESCE(...,1)` always passes. It is in the expression so the rule
111
+ starts honouring the flag automatically once Canada is stamped — the **item-type list does all the
112
+ real work right now**. See
113
+ [isFulfillable — Data Quality & the Type-Derived Rule](../../compass-usa/features/isfulfillable-data-quality-and-type-rule.md).
114
+ - **`ItemTypes` is the only usable column in Canada.** Canada's `AssetTypes` are only
115
+ `Equipment` / `Hardware` / `Tablets` — **there is no `FEE` asset type in Canada at all**, so
116
+ `AssetTypes` gives zero signal here. USA is the opposite case; see the USA doc.
117
+ - **Keep the `price > 0` filter.** Only **9** of Canada's **69** zero-price lines ever receive an
118
+ ASN, so requiring them to ship would strand orders in partiallyFulfilled forever.
119
+
120
+ ## Why per-line QUANTITY is the right unit (the counter-examples)
121
+
122
+ - **Order totals fail.** `SAC100664`: the mouse's shipment paid for the ZBOOK's quantity.
123
+ - **Line counts fail.** `SAC100108`: both expected lines had an ASN, so a line-count check matched —
124
+ but only 1 of 2 iPads and 1 of 2 accessories actually shipped. Also **96 Canada lines carry 2 to 3
125
+ ASN lines each**, so an ASN-line count can exceed the order-line count while the order is still
126
+ short.
127
+ - **There is no line-coverage gap to catch.** Across **601** Canada orders past approval with
128
+ shipments, **zero** read fulfilled while an expected line had no ASN line — a line with no ASN has
129
+ shipped 0 and is always caught as short. A line-count check would add nothing and only weaken the
130
+ rule.
131
+ - **Going per line also neutralises duplicate ASNs for the verdict.** A surplus stays on its own line
132
+ and can no longer cover another line's gap, so no clamping or de-duplication is needed.
133
+
134
+ ## The pendingFulfillment gate reads quantity, not "an ASN exists"
135
+
136
+ The gate is **not** `COUNT(ASNs on the order) = 0`. Three orders (`SAC100131`, `SAC100509`,
137
+ `SAC100522`) had an ASN raised only against an AppleCare / ABM line while **zero physical goods had
138
+ shipped**, and therefore read partiallyFulfilled. Reading the expected-line quantity instead makes
139
+ them correctly read **pendingFulfillment**.
79
140
 
80
141
  ## Design decisions worth keeping
81
142
 
82
143
  - **⚠ Do NOT switch Canada to the local `ItemFulfillments` / `ItemFulfillmentItems` tables**, even
83
144
  though the line-level `_qtyFulfilled` UI column does read them. They are **incomplete** in Canada:
84
145
  **47** orders have ASN shipments with no local `ItemFulfillment` at all and **52** more disagree on
85
- quantity, so moving the order-level rule onto the base `_Model_Client_SalesOrder` logic would have
86
- regressed roughly **99** orders.
87
- - **Keep the `price > 0` filter.** Only **9** of Canada's **69** zero-price lines ever receive an ASN,
88
- so requiring them to ship would strand orders in partiallyFulfilled forever.
146
+ quantity, so moving this rule onto the base `_Model_Client_SalesOrder` logic would regress roughly
147
+ **99** orders.
148
+ - **Canada deliberately has NO order-total condition.** USA keeps one; Canada does not need it,
149
+ because its ASN bridge is complete and its expected-line set is never empty in the way USA's is.
89
150
  - **Canada deliberately omits any Compass-vendor exclusion.** `VENDOR_ID__COMPASS = 26` on the parent
90
151
  is the **US** Compass vendor id. In `Client_CompassCanada` all **901** POs are GRAND & TOY
91
- (`vendorId 1`), and "COMPASS CANADA" is `vendorId 4` with **zero** POs — so the inherited constant
92
- was a latent wrong-id no-op. Do not "restore" it here.
93
- - **Canada needs no purchased-line filter** (unlike USA) precisely because the ASN bridge is complete
94
- and the vendor set is a single vendor.
152
+ (`vendorId 1`), and "COMPASS CANADA" is `vendorId 4` with **zero** POs — the inherited constant is
153
+ a latent wrong-id no-op. Do not "restore" it here.
95
154
 
96
155
  ## Gotchas / known issues
97
156
 
157
+ - **⚠ Uncommitted.** Both touched files pass `php -l`, nothing was committed or pushed, and the repo
158
+ is sitting on **`_production`** — a branch is required before committing. Canada has **not** been
159
+ exercised through the Supply UI yet.
98
160
  - **The badge is display-only and never stored.** The status is computed live, so there is no
99
- backfill and reverting the code fully reverts the behaviour. Canada's in-transit email cron
161
+ backfill and reverting the code fully reverts the behaviour. **No cron, email or report reads the
162
+ fulfilled vs partiallyFulfilled distinction.** Canada's in-transit email cron
100
163
  (`worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php`) keeps its **own
101
164
  duplicated copy** of the `_status` CASE, but it only ever produces canceled /
102
165
  pendingApprovalUnknown / pendingFulfillment / shipped and filters on
103
- `computedStatusSlug = 'shipped'` — and the pendingFulfillment gate was left byte-identical, so it
104
- is unaffected.
166
+ `computedStatusSlug = 'shipped'`. Note the Canada pendingFulfillment gate is **no longer
167
+ byte-identical** to that copy (it now reads quantity, not ASN existence). The cron's copy is
168
+ unchanged and still only acts on `'shipped'`, so behaviour is unaffected, but the two are now a
169
+ genuine divergence.
105
170
  - **⚠ Canada's order status is no longer identical to Compass USA.** Older notes (and the profile)
106
171
  said "order status is shipped-only, same as Compass USA". The *stage* policy is still shared
107
- (shipped-only), but the **fulfilled vs partiallyFulfilled rule now diverges by region.**
172
+ (shipped-only), but the **fulfilled vs partiallyFulfilled rule now diverges by region** — and it
173
+ diverges because **only Canada changed**.
108
174
  - **The ApprovalDecision notification query on the parent calls `self::_status()`**, not `static::`,
109
- so it always evaluates the **parent** fulfillment rule even for Canada. Harmless today (it only
110
- branches on pendingApproval / canceled / pendingFulfillment, and pendingFulfillment is identical
111
- in both regions) but it is a trap if Canada ever changes those.
175
+ so it always evaluates the **parent** fulfillment rule even for Canada. Because Canada's
176
+ pendingFulfillment gate now differs from the parent's, that query sees the **parent's** gate for
177
+ Canada orders. Harmless today (it only branches on pendingApproval / canceled / pendingFulfillment
178
+ and acts on approvals) but it is now a real divergence, not a theoretical one.
179
+ - **Canada's approval gate is healthy**, unlike USA's: 733 approved, 30 denied, only **19** stuck at a
180
+ pending-approval status. Canada also has all ten `SalesOrderStages` rows, where USA has only ids
181
+ 5 to 9.
182
+
183
+ ## History: the first attempt was reverted
184
+
185
+ - The original fix, commit **`90c964f1`** "Updating the status logic as per the client", was
186
+ **reverted by commit `43049d1d`** ("Revert …", kmaramreddy08, 2026-08-26 16:19). The revert is on
187
+ **`#sprint85`, `TRUE-81284`, `_beta`, `_production` and `_sandbox-client`**; only **`_sandbox-dev`**
188
+ still carries the original fix.
189
+ - Every refinement made after `90c964f1` was **never committed** and is gone from git. The rule on
190
+ this page was therefore **rebuilt from scratch** on `_production`.
191
+ - The ticket branch for this work is **`TRUE-81284`**, not `TRUE-80431`.
192
+ - **Any statement that this shipped is wrong**, including the previously published "115 Canada
193
+ orders corrected" figure.
112
194
 
113
195
  ## Change history
114
- - 2026-08-26 — Fixed the false **Fulfilled** on partly-shipped Canada orders (reported via
115
- `SAC100664`): the shared parent compared two order-level totals whose ordered side dropped bundle
116
- child lines while the shipped side did not. Added a `_fulfillmentStatus()` override in
117
- `_Model_Compass_Canada_SalesOrder` — pendingFulfillment with no ASN, otherwise partiallyFulfilled
118
- if any `price > 0` line is short of its **own** ASN-bridge shipped quantity. Prod: 115 orders
119
- corrected Fulfilled to Partially Fulfilled, zero promoted, all other status counts unchanged.
120
- Recorded why the ASN bridge (100% complete, 947 items) is the source and the local
121
- `ItemFulfillments` tables are not (would regress ~99 orders), why `price > 0` stays, and that the
122
- inherited `VENDOR_ID__COMPASS = 26` is a US-only id and a no-op here. Not committed or pushed; not
123
- yet exercised through the Supply UI. (bala)
196
+ - 2026-08-27 — **Corrects the 2026-08-26 entry below, which described work that was reverted in git
197
+ (`90c964f1` reverted by `43049d1d`) and published a wrong count.** Canada's `_fulfillmentStatus()`
198
+ rebuilt from scratch as a self-contained pure per-line rule with **no order-total check**:
199
+ pendingFulfillment when every expected-to-ship line has received zero, partiallyFulfilled when any
200
+ expected line is short, else fulfilled — both branches reading the same expected-line set and the
201
+ same per-line quantity. "Expected to ship" now also requires `ItemTypes.name NOT IN
202
+ ITEM_TYPES__NOT_SHIPPABLE` (SERVICE/SERVICES/CONSULTING/WARRANTY) and
203
+ `COALESCE(Items.isFulfillable,1) = 1`, which released **25 fully-delivered orders** wrongly held at
204
+ partiallyFulfilled by PO-linked-but-never-shipped AppleCare/ABM/Profile SKUs — so the real impact is
205
+ **90 orders corrected, not 115**. The pendingFulfillment gate now reads expected-line quantity
206
+ instead of "any ASN exists", correcting `SAC100131` / `SAC100509` / `SAC100522`. Prod after:
207
+ 489 fulfilled / 184 pendingFulfillment / 109 partiallyFulfilled. Recorded that `isFulfillable` is
208
+ NULL on every Canada item (so the item-type list does the work), that Canada has no `FEE` asset
209
+ type, and why per-line **quantity** beats order totals (`SAC100664`) and line counts (`SAC100108`).
210
+ Uncommitted; repo on `_production`; not yet exercised through the Supply UI. (bala)
211
+ - 2026-08-26 — **REVERTED IN GIT — do not rely on this entry.** Original per-line
212
+ `_fulfillmentStatus()` override for Canada (pendingFulfillment when the order had no ASN at all,
213
+ otherwise partiallyFulfilled if any `price > 0` line was short of its own ASN-bridge quantity),
214
+ reported as 115 orders corrected. Commit `90c964f1` was reverted by `43049d1d` on 2026-08-26; the
215
+ rule over-corrected by 25 orders and its gate tested ASN existence rather than quantity. Superseded
216
+ by the 2026-08-27 entry. Still valid from this work: the ASN bridge is the correct source (100%
217
+ complete, 947 items), the local `ItemFulfillments` tables are not (would regress ~99 orders),
218
+ `price > 0` stays, and the inherited `VENDOR_ID__COMPASS = 26` is a US-only id and a no-op here.
219
+ (bala)
124
220
 
125
221
  ## Related docs
126
- - [Compass USA — order aggregate AND per-line](../../compass-usa/features/order-fulfillment-status-per-line.md)
127
- — the shared parent rule, and why USA cannot use this page's rule.
222
+ - [Compass USA — order aggregate, deliberately unchanged](../../compass-usa/features/order-fulfillment-status-per-line.md)
223
+ — the parent rule, and why USA was not touched.
128
224
  - [Grand & Toy ASN Import](grand-and-toy-asn-import.md) — where Canada's ASN rows come from.
129
225
  - [IF Stage Lifecycle & Order Status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md)
130
226
  — the shipped-only stage policy shared by both Compass regions.
227
+ - [FIELD_SQL calculated fields](../../../2.0/apps/_underscore/features/calculated-sql-fields.md)
228
+ — why a region-subclass override of a calculated field is picked up at all.
@@ -16,7 +16,7 @@ project: _Underscore
16
16
  client: compass-canada
17
17
  type: profile
18
18
  status: active
19
- updated: 2026-08-26
19
+ updated: 2026-08-27
20
20
  owners: [jcardinal, bala, tcox, apeterson]
21
21
  files: []
22
22
  related:
@@ -148,14 +148,20 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
148
148
  lifecycle stages (picked/packed/shipped) + shipped backfill are seeded by
149
149
  `dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql`.
150
150
  See [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
151
- - **⚠ Order status is no longer identical to Compass USA — the fulfilled-vs-partiallyFulfilled rule
152
- diverged 2026-08-26.** Canada overrides `_fulfillmentStatus()` in
153
- `_Model_Compass_Canada_SalesOrder` with a clean **per-line** rule sourced from the **ASN bridge
154
- only** (`AdvanceShippingNoticeItems.purchaseOrderItemId` -> `SalesOrderItems_PurchaseOrderItems`,
155
- 100% complete here). Do not read Canada's local `ItemFulfillments` tables for this (incomplete:
156
- ~99 orders would regress), and do not port USA's rule or its `VENDOR_ID__COMPASS = 26` (a US
157
- vendor id; all 901 Canada POs are Grand & Toy `vendorId 1`). Fixed 115 falsely-Fulfilled prod
158
- orders. See
151
+ - **⚠ Order status is no longer identical to Compass USA — Canada is the only region whose
152
+ fulfilled-vs-partiallyFulfilled rule changed (2026-08-27).** Canada overrides
153
+ `_fulfillmentStatus()` in `_Model_Compass_Canada_SalesOrder` with a **pure per-line** rule and **no
154
+ order-total condition**, sourced from the **ASN bridge only**
155
+ (`AdvanceShippingNoticeItems.purchaseOrderItemId` -> `SalesOrderItems_PurchaseOrderItems`, 100%
156
+ complete here). A line counts as expected-to-ship only when `price > 0`, it has a PO-item link, its
157
+ `ItemTypes.name` is not in `ITEM_TYPES__NOT_SHIPPABLE` (SERVICE/SERVICES/CONSULTING/WARRANTY) and
158
+ `COALESCE(Items.isFulfillable,1) = 1` — the item-type list matters because Canada has PO-linked
159
+ AppleCare/ABM SKUs that never ship, and `isFulfillable` is NULL on every Canada item. Do not read
160
+ Canada's local `ItemFulfillments` tables for this (incomplete: ~99 orders would regress), and do
161
+ not port USA's rule or its `VENDOR_ID__COMPASS = 26` (a US vendor id; all 901 Canada POs are Grand
162
+ & Toy `vendorId 1`). **Corrects 90 prod orders, not the 115 previously published** — the first
163
+ attempt was reverted in git and over-corrected by 25 orders. **Uncommitted** (repo on
164
+ `_production`). See
159
165
  [Fulfilled vs Partially Fulfilled](features/order-fulfillment-status-per-line.md).
160
166
  - **Item titles in customer emails** come from the `ItemTranslations` sidecar (prod: 220 fr-CA rows,
161
167
  100% coverage of every item ever ordered). Resolve the English/translated fallback **in PHP**, never
@@ -5,7 +5,7 @@
5
5
  | [Compass Approval-Decision Flow (Notifications & Manager Reassignment)](features/approval-decision-flow.md) | 2.0 | Compass's sales-order approval flow — approval/notification email lists, **manager reassignment**, VIP auto-approve, and EN/FR localization — lives **entirely i | _underscore/Model/Compass/ApprovalDecision.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/Usa/ApprovalDecision.php, _underscore/Model/Compass/Canada/ApprovalDecision.php, _underscore/Model/Client/ApprovalTemplateStage.php, _underscore/Model/Quad/SalesOrder.php |
6
6
  | [Compass ASN → ItemFulfillment Auto-Creation](features/asn-to-item-fulfillment.md) | 2.0 | For Compass USA, posting an AdvanceShippingNotice (ASN) auto-creates the ItemFulfillment (IF) on the upstream SalesOrder. | _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Model/Compass/PurchaseOrder.php, api2/Component/Api/Cxml/Cxml.php, dbchanges2/Client_Compass/2026-06-11 - AsnItemTrackingNumberAcl.sql, dbchanges2/Client_Compass/2026-06-15b - BackfillSA132781ItemFulfillmentTracking.sql, dbchanges2/Client_Compass/2026-06-16 - CleanupSA132763CrossLineTracking.sql, dbchanges2/Client_Compass/2026-06-16b - CleanupSA132743CrossLineTracking.sql, dbchanges2/Client_Compass/2026-06-16c - BackfillSA132763C40QYUCTracking.sql, dbchanges2/Client_Compass/2026-06-18a - CleanupSA132898DuplicateTracking.sql, dbchanges2/Client_Compass/2026-06-18b - CleanupSA132881DuplicateTracking.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql |
7
7
  | [Cost Centers — Unit Locations, numeric-only policy](features/cost-centers.md) | 2.0 | A Compass "cost center" — the value a user picks in commerce and that lands on an order — is **not** a `CostCenters` row. | toga2-commerce/src/pages/Cart/api/CartApi.ts, worker1.5/crons/toga2/compass/import_locations.php, _underscore/Model/Compass/SalesOrder.php, api2/Component/Api/V2/V2.php, dbchanges2/Client_Compass/2026-07-06 - RemoveNonNumericCostCenters.sql |
8
- | [Compass isFulfillable — Data Quality & the Type-Derived Rule](features/isfulfillable-data-quality-and-type-rule.md) | 2.0 | A **prod-data investigation (2026-08-12, read-only)** into why so many Compass storefront lines still have a dead **Qty Fulfilled** cell. | _underscore/Model/Client/Item.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/backfill_isfulfillable_all_clients.php, toga2-supply/src/pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx |
8
+ | [Compass isFulfillable — Data Quality & the Type-Derived Rule](features/isfulfillable-data-quality-and-type-rule.md) | 2.0 | A **prod-data investigation (2026-08-12, read-only)** into why so many Compass storefront lines still have a dead **Qty Fulfilled** cell. | _underscore/Model/Client/Item.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/backfill_isfulfillable_all_clients.php, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, toga2-supply/src/pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx |
9
9
  | [Compass: Item-Fulfillment TableViews (for-sales-order-items & for-sales-orders, tracking via bridge)](features/item-fulfillment-tracking-tableview.md) | 2.0 | Two sibling Compass TableViews in `Client_Compass` display fulfilled items in toga2-supply, both driven by `TableViews` / `TableViewJoins` / `TableViewFields` c | dbchanges2/Client_Compass/2026-06-10 - ItemFulfillmentsForSalesOrderItemsTableView.sql, dbchanges2/Client_Compass/2026-06-11 - ItemFulfillmentsForSalesOrdersTableView.sql, dbchanges2/Client_Compass/2026-06-15a - FixItemFulfillmentTrackingNumberJoins.sql, dbchanges2/Client/2026-07-15a - ExcludeFeeItemsFromItemFulfillmentsForSalesOrdersView.sql |
10
10
  | [Compass MITS PO → SO Item Linking](features/mits-po-to-so-item-linking.md) | 2.0 | MITS sends Compass inbound Purchase Orders (`POST /v2/purchase-orders`) against a Sales Order (`mitsSalesOrder`). | _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
11
11
  | [Compass MITS PO Transmission to Vendors](features/mits-po-transmission-to-vendors.md) | 2.0 | The 1.0 worker cron `2_transmit_mits_purchase_orders_to_vendors.php` transmits Compass PurchaseOrders to their vendors (Office Depot, Strategic Systems, Compass | worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, worker/crons/toga2/compasscanada/workflow/2_transmit_mits_purchase_orders_to_vendors.php, library/app/client/compass.php |
@@ -14,7 +14,7 @@
14
14
  | [Compass MR/MA Order Auto-Approval & Status Gate](features/mr-ma-order-approval-and-status.md) | 2.0 | Compass **MR** and **MA** sales orders are system-generated from the MITS / Office Depot EDI pipeline (they do not originate as user-entered SA orders) and must | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
15
15
  | [Compass ODP EDI 850 Line-Item Resolution (VA part number to IN SKU fallback)](features/odp-edi-850-item-resolution.md) | 1.0 | How each **PO1 line** on an inbound Office Depot (ODP) **EDI 850** is resolved to a real `Client_Compass` catalog item before cron `3a_import_office_depot_purch | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, library/app/Edi.php |
16
16
  | [Compass Office Depot EDI 855 Acknowledgement + Over-Quantity PO Guard](features/odp-edi-855-acknowledgement-and-overquantity-guard.md) | 1.0 | How Compass acknowledges Office Depot (ODP) inbound **EDI 850** purchase orders with an **X12 855**, and the **over-quantity guard** that rejects a duplicate PO | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/4_transmit_office_depot_po_acknowledgements.php, library/app/edi.php |
17
- | [Compass USA — Fulfilled vs Partially Fulfilled (order aggregate AND per-line)](features/order-fulfillment-status-per-line.md) | 2.0 | TOGa Supply showed **Fulfilled** on Compass orders that were only partly shipped. | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/Canada/SalesOrder.php |
17
+ | [Compass USA — Fulfilled vs Partially Fulfilled (order total kept; per-line rule built then rejected)](features/order-fulfillment-status-per-line.md) | 2.0 | TOGa Supply shows **Fulfilled** on Compass USA orders that are only partly shipped. | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/Canada/SalesOrder.php |
18
18
  | [Compass PEOPLE-File User Lifecycle (duplicate accounts, reactivation grace window, raw-SQL deactivation)](features/people-file-user-lifecycle.md) | 2.0 | The nightly **PEOPLE** file cron (`_Worker_Client_Compass_PeopleFile`, `worker2/Worker/Client/Compass/PeopleFile.php`) owns the whole `Users` row lifecycle for | worker2/Worker/Client/Compass/PeopleFile.php |
19
19
  | [Persona Model & Levy-Sector Gating (worker2 PEOPLE cron)](features/persona-model-and-levy-gating.md) | 2.0 | Compass USA catalogue visibility is driven by **personas** in `Client_Compass`. | worker2/Worker/Client/Compass/PeopleFile.php |
20
20
  | [Compass Sales-Order Line-Number Renumbering (and why it hangs off the SO hooks)](features/sales-order-line-renumbering.md) | 2.0 | Compass sales-order lines must stay numbered **1..N with no gaps** after any add, edit, or delete — downstream MITS/PO linking reads `lineNumber` as an identity | _underscore/Model/Compass/SalesOrderItem.php, _underscore/Model/Compass/SalesOrder.php |
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: compass-usa
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-08-20
8
+ updated: 2026-08-27
9
9
  owners: [jcardinal, bala]
10
10
  files:
11
11
  - _underscore/Model/Compass/AdvanceShippingNotice.php
@@ -26,6 +26,7 @@ related:
26
26
  - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
27
27
  - ../../../2.0/apps/_underscore/features/model-save-parent-cascade-stored-field-deadlock.md
28
28
  - ../../../2.0/apps/api2/features/v2-deadlock-retry.md
29
+ - order-fulfillment-status-per-line.md
29
30
  ---
30
31
 
31
32
  ## Summary
@@ -244,8 +245,88 @@ Compass Canada (`Model/Compass/Canada/`) is a separate sub-client. The Compass c
244
245
  Forward-only; SA132898 repaired by `2026-06-18a` (delete the later tracking + orphaned package
245
246
  row, keep the original; no backfill needed since the correct number is already present).
246
247
 
248
+ ## Office Depot sends duplicate ASN messages (measured 2026-08-27)
249
+
250
+ **When an Office Depot PO line has more than one shipping notice, it is a DUPLICATE, not a partial
251
+ shipment.** This removes the "maybe it is a split shipment" caveat that earlier notes on this page
252
+ left open. Line-level shape of **every** ODP PO line on prod:
253
+
254
+ | Shape | ODP PO lines |
255
+ |---|---|
256
+ | never notified | 132,520 |
257
+ | exactly one notice, quantity matches | 35,362 |
258
+ | **multiple notices that EXCEED the PO line quantity** | **10,012** |
259
+ | multiple notices that legitimately add up to the quantity | **8** |
260
+
261
+ Order-level, of **30,431** ODP POs that have an ASN: **5,621** have a line credited over its PO
262
+ quantity, **6,874** have a line that was never notified, and **1,260** have **both** — that last
263
+ shape is the one that can mislead an order status, because a surplus on one line masks a gap on
264
+ another.
265
+
266
+ ### It is being fixed, but ~7% remains
267
+
268
+ Share of ASN items landing on an over-credited line, by month:
269
+
270
+ | Month | Over-credited | Share |
271
+ |---|---|---|
272
+ | 2026-02 | 10,812 / 10,870 | 99% |
273
+ | 2026-03 | — | 66% |
274
+ | 2026-04 | — | 69% |
275
+ | 2026-05 | — | 41% |
276
+ | 2026-06 | — | 9% |
277
+ | 2026-07 | — | 8% |
278
+ | 2026-08 | — | 7% |
279
+
280
+ Something was clearly fixed around **May/June 2026**. It is not fully fixed.
281
+
282
+ ### Worked example — SA135272 (raw payload evidence)
283
+
284
+ PO `50309979-1` (`PurchaseOrders.id 108979`): line 1 = `9X3V1UT` qty 4, line 2 = `9D9L6UT` qty 4,
285
+ both cleanly linked 1:1 to their sales-order lines. **Both ASNs (99473, 99685) referenced
286
+ `lineNumber 1`; line 2 was never mentioned.** Verified from the raw payloads in
287
+ `Logs_Compass.Api` ids **9111442** and **9152991**.
288
+
289
+ The second payload is a **bad message**, not a second shipment: same line, same qty 4, but **no
290
+ `shipToAddress`, no `trackingUrl`**, and `dateShipped = 2026-08-04T06:01:34` which is the earlier
291
+ batch run's timestamp, where the first carried a real date of `2026-08-11`. The tracking numbers
292
+ differ, so it is also not one shipment logged twice.
293
+
294
+ **Each ASN was ALSO POSTed twice, seconds apart, with identical payloads** (`9111442`/`9111477` and
295
+ `9152991`/`9152994`) — the sender double-fires. That is a second, separate duplication on top of the
296
+ wrong-line problem, and it is the same traffic pattern behind the
297
+ [same-PO deadlocks](../../../2.0/apps/_underscore/features/model-save-parent-cascade-stored-field-deadlock.md).
298
+
299
+ ### Ruled out: "ODP numbers lines per shipment"
300
+
301
+ ODP does use **real PO line numbers**, so the drift is not a numbering convention: 45,995 ASN items
302
+ land on line 1, 12,774 on line 2, 5,772 on line 3, continuing past line 12. On two-line POs it
303
+ referenced line 2 **correctly in 3,447 of 4,797 cases**.
304
+
305
+ ### ⚠ Contract weakness to record
306
+
307
+ The ASN payload identifies the PO item **positionally**, by `purchaseOrderItem.lineNumber`, **not by
308
+ uuid**. So any line-numbering drift — on either side — silently credits the wrong item, with no
309
+ error. This is the same positional-identity class as the PO-local `lineNumber` collision fixed in
310
+ 2026-06 (see the change history below) and it is why
311
+ [Compass USA's per-line fulfillment rule was rejected](order-fulfillment-status-per-line.md): the
312
+ feed cannot currently support a line-level completeness test.
313
+
247
314
  ## Change history
248
315
  Dated one-liners, newest first.
316
+ - 2026-08-27 — Quantified the **Office Depot duplicate-ASN** problem on prod and settled what it is:
317
+ of ODP PO lines with more than one shipping notice, **10,012 exceed the PO line quantity and only 8
318
+ are genuine split shipments**, so a second notice on an ODP line is a **duplicate**, not a partial.
319
+ 1,260 ODP POs carry both an over-credited line and a never-notified line — the shape that misleads
320
+ order status. Trend shows a fix around May/June 2026 (99% of Feb ASN items landed on over-credited
321
+ lines, down to ~7% in August). Worked `SA135272` back to the raw `Logs_Compass.Api` payloads
322
+ (9111442, 9152991): both ASNs referenced `lineNumber 1` and line 2 was never mentioned, and the
323
+ second payload is malformed (no `shipToAddress`, no `trackingUrl`, `dateShipped` = the earlier batch
324
+ run timestamp). Each ASN was also POSTed twice seconds apart with identical payloads
325
+ (9111442/9111477, 9152991/9152994) — the sender double-fires. Ruled out the "per-shipment line
326
+ numbering" theory (ODP uses real PO line numbers; line 2 referenced correctly in 3,447 of 4,797
327
+ two-line POs). Recorded the underlying contract weakness: the payload identifies the PO item
328
+ **positionally by `purchaseOrderItem.lineNumber`, not by uuid**. Investigation only, no code change;
329
+ this is the evidence that blocked the Compass USA per-line fulfillment rule. (bala)
249
330
  - 2026-08-20 — Recorded an **open, deterministic 500 `EO-1`** in `postPost`: an ASN item whose
250
331
  SalesOrderItem has one already-tracked `ItemFulfillmentItem` takes the reconcile path, the IFI is
251
332
  skipped by `itemFulfillmentItemHasTracking()`, and the handler then fails rather than completing —
@@ -6,12 +6,13 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-12
9
+ updated: 2026-08-27
10
10
  owners: ["bala"]
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
+ - dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql
15
16
  - toga2-supply/src/pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx
16
17
  related:
17
18
  - ../profile.md
@@ -19,6 +20,7 @@ related:
19
20
  - ../../../2.0/apps/_underscore/features/fulfillable-item-propagation.md
20
21
  - ../../../1.0/apps/library/features/netsuite-item-isfulfillable-sync.md
21
22
  - ../../../1.0/apps/worker/workflows/isfulfillable-multi-client-backfill.md
23
+ - ../../compass-canada/features/order-fulfillment-status-per-line.md
22
24
  ---
23
25
 
24
26
  ## Summary
@@ -30,8 +32,9 @@ set are partly wrong. This doc records the measured state of `Client_Compass` /
30
32
  **decision** to override NetSuite with a type-derived rule for Compass, and the **write-time guard**
31
33
  that decision requires in order to survive the daily NetSuite sync.
32
34
 
33
- No source files were changed and **no migration has been written yet**. All figures below were read
34
- from **production** on **2026-08-12**.
35
+ No source files were changed and **no migration has been written yet**. Figures in the sections dated
36
+ 2026-08-12 were read from **production** on that date; the later sections (retraction, column choice,
37
+ data-fix status) were measured on **2026-08-27** against prod and **client-sandbox** as marked.
35
38
 
36
39
  ## Measured state (prod, 2026-08-12)
37
40
  - **`Client_Compass`, catalog 1:** **770 NULL / 25 zero / 697 one.**
@@ -164,7 +167,103 @@ Because these tables are `utf8mb4_0900_ai_ci` (**case-insensitive**), the single
164
167
  data. **Do not hand-maintain two string lists**, but **do** re-confirm the Canada vocabulary
165
168
  separately, since the names genuinely differ and a US list *looks* wrong for Canada.
166
169
 
170
+ ## ⚠ RETRACTED (2026-08-27) — "it appears on an ASN, so it must be fulfillable"
171
+
172
+ An in-session proposal to run `UPDATE Items SET isFulfillable = 1` for **7 Compass items — 691, 237,
173
+ 661, 2004, 2005, 703, 657** — on the grounds that they appeared to ship was **wrong and was
174
+ withdrawn.**
175
+
176
+ All 7 have `AssetTypes.name = 'FEE'` and their own descriptions confirm it: *"5-Year Comprehensive
177
+ Coverage"*, *"Printer Warranty 3 Year Plan"*, *"Staging and Kitting Charge"*, *"Tablet Lab fee"*,
178
+ *"Config fee for Samsung Configurations"*. **`isFulfillable = 0` is CORRECT for all of them.**
179
+
180
+ They "ship" only because the vendor lists the warranty / staging charge as a **line on the shipping
181
+ notice next to the hardware**. This is the same ride-along phenomenon measured above, seen from the
182
+ other direction:
183
+
184
+ > **Appearing on an ASN is NOT the same as being a physical good.** Never promote an item to
185
+ > `isFulfillable = 1` on shipment evidence alone.
186
+
187
+ ## Never hand-edit `Items.isFulfillable`
188
+
189
+ Two independent reasons, both verified:
190
+
191
+ 1. **It is written by the NetSuite sync.** Beyond the Phase 1 existing-item refresh already
192
+ documented, `dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql`
193
+ (`recordId 21`) registers interceptors that **copy the value up the SO / PO chain**. A manual
194
+ value is therefore transient by design.
195
+ 2. **It drives UI behaviour, not just data.** `toga2-supply`
196
+ `pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx` gates the
197
+ **Qty Fulfilled** cell's clickability on `String(item?.Items?.isFulfillable) === "1"`, alongside a
198
+ separate Compass-only check on `AssetTypes.name === "FEE"`. Flipping the flag silently changes what
199
+ a user can click.
200
+
201
+ ## Which column does code read — `ItemTypes` or `AssetTypes`? (USA)
202
+
203
+ The two columns disagree, and the gap matters:
204
+
205
+ | Signal | Priced Compass USA order lines | Lines ever on an ASN |
206
+ |---|---|---|
207
+ | `AssetTypes.name = 'FEE'` | 90,857 | 732 |
208
+ | `ItemTypes.name = 'FEE'` | 89,921 | 70 |
209
+
210
+ **936 lines are assetType FEE but NOT itemType FEE.**
211
+
212
+ **Decision (developer's call): code reads `ItemTypes`, and lets `Items.isFulfillable` carry the
213
+ assetType meaning**, because the data fix below folds **both** columns into the flag. So the flag is
214
+ the single source of truth for consumers and `ItemTypes` is only a secondary, code-side signal.
215
+
216
+ **Compass Canada has no such choice.** Canada's `AssetTypes` are only `Equipment` / `Hardware` /
217
+ `Tablets` — **there is no `FEE` asset type in Canada at all** — so `ItemTypes` is the only usable
218
+ column there, and Canada's fulfillment rule uses an item-type exclusion list
219
+ (`SERVICE`, `SERVICES`, `CONSULTING`, `WARRANTY`) rather than the flag. See
220
+ [Compass Canada — Fulfilled vs Partially Fulfilled](../../compass-canada/features/order-fulfillment-status-per-line.md).
221
+
222
+ ## Status of the type-derived data fix (verified 2026-08-27)
223
+
224
+ The developer's two-statement `UPDATE`, scoped to **`catalogId = 1`**, sets `isFulfillable = 0` where
225
+ `ItemTypes.name` **or** `AssetTypes.name` `IN ('FEE','SERVICEFEES')` and `1` otherwise. It is
226
+ **pending Alex's sign-off before prod**. Verified environment state:
227
+
228
+ | Environment | State |
229
+ |---|---|
230
+ | **client-sandbox** | **applied** — 58 fee items now `0` |
231
+ | **dev-sandbox** | **NOT applied** — fee items still NULL or 1 |
232
+ | prod | not applied |
233
+
234
+ Two gaps to carry forward:
235
+
236
+ - **⚠ The NetSuite sync rewrites some of them back.** On client-sandbox **6 fee items are back at
237
+ `isFulfillable = 1` despite matching the WHERE** — `US-AND-CROTHALL-PO`, `LVY_MobileOps`,
238
+ `US-TASKS-TECHS-IN`, `US-IOS-ESFM-NUVOLO`, `HDL-TASKUP-IN`, `Unidine-Knox` — with `dtUpdated`
239
+ 18–25 Aug, i.e. **after** the fix ran. Their `itemType` is `SOFTWARE` / `SERVICES` / `HARDWARE`
240
+ while only their `assetType` says FEE, so **an itemType-based code test will not catch these if the
241
+ flag drifts.** This is the concrete failure mode of the column decision above, and it re-proves the
242
+ write-time-guard requirement recorded further up this page.
243
+ - **⚠ The `catalogId = 1` scope misses 49 items carrying 52,367 priced order lines** —
244
+ `PC-KIT-OPT2` (33,259 lines), `CG-MEDKITTING` (6,317), `CG-MEDKITTING-NWN-APPLE`, `ASI-WAREHAND`,
245
+ `ODP-COMPASS_KIOSKBUILD` — all on **catalog 2 or NULL**, all flagged `isFulfillable = 1`, and
246
+ **none has ever shipped**. They cause no problem *today* only because none of them has a PO link,
247
+ so a purchased-line test excludes them anyway. Widen the scope or accept that the flag is wrong on
248
+ the largest-volume non-shipping SKU in the catalog.
249
+
167
250
  ## Change history
251
+ - 2026-08-27 — **Retracted a wrong in-session proposal** to set `isFulfillable = 1` on 7 Compass items
252
+ (691, 237, 661, 2004, 2005, 703, 657) because they appeared on shipping notices: all 7 are
253
+ `AssetTypes.name = 'FEE'` warranty/staging/config charges, so `0` is correct — **appearing on an ASN
254
+ is not the same as being a physical good.** Recorded two hard reasons never to hand-edit the column:
255
+ the NetSuite sync owns it (`dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql`,
256
+ `recordId 21`, copies the value up the SO/PO chain) and it gates the toga2-supply Qty Fulfilled
257
+ cell's clickability. Settled the **column question** — code reads `ItemTypes` and lets the flag carry
258
+ the assetType meaning (USA: 90,857 assetType-FEE priced lines vs 89,921 itemType-FEE, **936 lines
259
+ assetType-only**); Canada has no `FEE` assetType at all, so it uses an item-type exclusion list
260
+ instead. Verified the **data fix state**: applied on **client-sandbox** (58 fee items now 0), **not**
261
+ in effect on dev-sandbox, not on prod. Two gaps: 6 client-sandbox fee items are **back at 1** with
262
+ `dtUpdated` 18–25 Aug (`US-AND-CROTHALL-PO`, `LVY_MobileOps`, `US-TASKS-TECHS-IN`,
263
+ `US-IOS-ESFM-NUVOLO`, `HDL-TASKUP-IN`, `Unidine-Knox` — all assetType-only FEE, so an itemType code
264
+ test misses them), and the `catalogId = 1` scope **misses 49 items carrying 52,367 priced lines**
265
+ (`PC-KIT-OPT2` at 33,259, `CG-MEDKITTING` at 6,317, …) on catalog 2 or NULL, all flagged 1 and never
266
+ shipped. Investigation only, no code or migration written. (bala)
168
267
  - 2026-08-12 — Prod read-only investigation of `Items.isFulfillable` across `Client_Compass` /
169
268
  `Client_CompassCanada`. Established that remaining NULLs are **structurally unreachable**
170
269
  (vendor-direct POs have no Agilant-tier SO, so the chain walk has nothing to walk — 0 NULL items
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-08-27
10
10
  owners: ["rgirish", "bala"]
11
11
  files:
12
12
  - _underscore/Model/Compass/SalesOrder.php
@@ -31,11 +31,19 @@ the active `ApprovalTemplate` has no `ApprovalDecision`, `_status` returns the p
31
31
  Two independent root causes produced the stuck-order population, and both are addressed at the
32
32
  source (auto-approval + item population), **not** in the status SQL.
33
33
 
34
+ > **⚠ Scope update (2026-08-27).** This page started as an MR/MA story, but the approval gate strands
35
+ > **11,341** Compass USA orders overall — MR is now clean, and Office Depot (9,926) plus a live
36
+ > Agilant Drop Ship spike account for most of it. Read
37
+ > [The stuck population is no longer MR/MA-shaped](#-the-stuck-population-is-no-longer-mrma-shaped-re-measured-2026-08-27)
38
+ > before acting on the older figures on this page.
39
+
34
40
  ## Key files / entry points
35
41
  - **`_underscore/Model/Compass/SalesOrder.php`**
36
- - `_status` calculated field — approval gate runs before the fulfillment machine. **Since
37
- 2026-08-26 the fulfillment half is a separate `protected static _fulfillmentStatus()` called via
38
- `static::`;** the approval gate described on this page is unchanged and stays in `_status()`. See
42
+ - `_status` calculated field — the approval gate runs **before** the fulfillment machine (gates 1–3
43
+ of 4). The fulfillment half sits in a separate `protected static _fulfillmentStatus()` called via
44
+ `static::`, which **only Compass Canada overrides — Compass USA's rule is unchanged**. That seam
45
+ was reverted in git on 2026-08-26 and rebuilt uncommitted; either way **the approval gate
46
+ described on this page is untouched and stays in `_status()`.** See
39
47
  [Fulfilled vs Partially Fulfilled](order-fulfillment-status-per-line.md) and
40
48
  [IF stage lifecycle & order status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
41
49
  - `postPost` — the `if ($isMrOrder)` block loops `foreach ([1,2] as $step)` and issues two
@@ -56,6 +64,16 @@ row on **any** order whenever an active template exists. Compass `_status` then
56
64
  `ApprovalDecision` for every active-template stage before it will look at fulfillment. No
57
65
  decision on a stage ⇒ `_status` short-circuits to that stage's slug.
58
66
 
67
+
68
+ `_status` evaluates **four gates in this order**, and only the last one looks at shipping:
69
+
70
+ 1. an explicit `salesOrderStageId` (and not 2) wins outright;
71
+ 2. then any **denied** `ApprovalDecision` returns `canceled`;
72
+ 3. then any active-template stage with **no decision** returns that stage's slug — and **never looks
73
+ at shipping**;
74
+ 4. only then fulfillment (`static::_fulfillmentStatus()`).
75
+
76
+ Gate 3 is where the stuck orders live.
59
77
  ### Auto-approval (the intended MR path)
60
78
  `_Model_Compass_SalesOrder::postPost` auto-approves MR orders by POSTing an approval decision for
61
79
  each of the two stages (`isApproved=true`). `_status` only checks `isApproved` on the decision —
@@ -92,7 +110,61 @@ columns is fully valid.
92
110
  `_String::parseBetween()` — see
93
111
  [_String helpers](../../../2.0/apps/_underscore/features/string-html-entity-helpers.md).
94
112
 
95
- ## Prod status buckets (2026-07-13)
113
+ ## ⚠ The stuck population is no longer MR/MA-shaped (re-measured 2026-08-27)
114
+
115
+ **The MR/MA framing on this page now understates and mislocates the problem.** The 2026-07-13 entry
116
+ below recorded **3,119** stuck orders scoped to MR/MA. Re-measured across **all** Compass USA orders
117
+ on the computed path:
118
+
119
+ | `_status` outcome (USA, computed path) | Orders |
120
+ |---|---|
121
+ | fully approved, reaches fulfillment | 37,173 |
122
+ | **stuck at a pending-approval status** | **11,341** |
123
+ | correctly has no `Approval` row at all | 21,362 |
124
+ | denied (returns `canceled`) | 353 |
125
+
126
+ **The true figure is ~3.5x the recorded one**, and most of it is not MR/MA. Stuck orders by customer:
127
+
128
+ | Customer | Stuck |
129
+ |---|---|
130
+ | Office Depot | 9,926 |
131
+ | Compass | 950 |
132
+ | Agilant | 465 |
133
+
134
+ - **MR is now clean — 0 stuck against 4,584 approved.** The 2026-07-13 decisions-only backfill worked
135
+ and the 2026-06-03 auto-approval holds.
136
+ - **MA is 222 and growing.**
137
+ - The problem is **ongoing**, generating **1,200–2,000 new stuck orders a month**, and the
138
+ **Compass-customer share is rising**: 280 in August against 70–148 in earlier months.
139
+
140
+ ### ⚠ NEW LIVE SIGNAL — Agilant stuck orders spiked
141
+
142
+ Agilant stuck orders ran at roughly **5 a month**, then **268 appeared on 2026-08-11**, and the rate
143
+ has been **17–51 a day since 2026-08-24**. Every one is **`salesOrderTypeId 2` (Drop Ship)** with
144
+ **`createdByUserId NULL`** — i.e. system-created orders on a path with no auto-approval, the same
145
+ class of defect as the original MR problem but on a different order type. This is live and
146
+ unresolved.
147
+
148
+ ### Compass Canada's gate is healthy
149
+
150
+ For contrast: **733 approved, 30 denied, only 19 stuck.** Canada does not share this problem.
151
+
152
+ ### Verified clean (negatives worth recording)
153
+
154
+ Audited in both Compass clients on 2026-08-27:
155
+
156
+ - each client has exactly **one** active `ApprovalTemplate` (id 1) with two stages mapping to
157
+ `pendingInitialApproval` / `pendingApproval`, so the approval gate **cannot return NULL**;
158
+ - **no order has more than one `Approval` row**, and no stage has duplicate decisions;
159
+ - every `salesOrderStageId` in use resolves to a real `SalesOrderStages` row — no orphans;
160
+ - therefore **`_status` cannot return NULL in either client today.**
161
+
162
+ So the stuck orders are a **data-population** problem (missing decisions), not a schema or
163
+ template-integrity problem. The 2026-07-13 ruling still stands: **do not fix them in `_status`.** The
164
+ fulfillment rule is gate 4 and never runs for any of these 11,341 orders — see
165
+ [Fulfilled vs Partially Fulfilled](order-fulfillment-status-per-line.md).
166
+
167
+ ## Prod status buckets (2026-07-13 — historical, superseded by the section above)
96
168
  | Type | NO_APPROVAL (correct — pre-template, gate falls through) | APPROVAL_NO_DECISION (STUCK) | FULLY_APPROVED |
97
169
  |---|---|---|---|
98
170
  | MR | 5922 (Sep 2025–Jan 2026) | 2927 | 583 |
@@ -123,6 +195,24 @@ Safe scoping baked in — **decisions only, never Approvals**:
123
195
  from the sibling ODP SO first).
124
196
 
125
197
  ## Change history
198
+ - 2026-08-27 — **Re-measured the stuck-order population and reframed this page: the true figure is
199
+ 11,341, ~3.5x the 3,119 recorded on 2026-07-13, and it is no longer MR/MA-shaped.** USA
200
+ computed-path split is 37,173 approved / 11,341 stuck / 21,362 correctly with no `Approval` row /
201
+ 353 denied; by customer the stuck are Office Depot 9,926, Compass 950, Agilant 465. **MR is now
202
+ clean (0 stuck vs 4,584 approved), so the backfill worked**, while MA is 222 and growing and the
203
+ whole problem still generates 1,200–2,000 new stuck orders a month with the Compass-customer share
204
+ rising (280 in August vs 70–148 earlier). **New live signal: Agilant went from ~5/month to 268 on
205
+ 2026-08-11 and 17–51/day since 2026-08-24, all `salesOrderTypeId 2` (Drop Ship) with
206
+ `createdByUserId NULL`.** Canada's gate is healthy (733 approved, 30 denied, 19 stuck). Recorded
207
+ `_status`'s explicit four-gate order, and the clean-audit negatives that make this a data-population
208
+ problem rather than a template one: one active template per client, no duplicate `Approval` rows or
209
+ decisions, no orphan `salesOrderStageId`, so `_status` cannot return NULL in either client.
210
+ Investigation only, no code change. (bala)
211
+ - 2026-08-27 — Corrected the entry below: the `_fulfillmentStatus()` extraction it describes was
212
+ **reverted in git** (`90c964f1` reverted by `43049d1d`) and rebuilt this session. The seam is real
213
+ again in the working tree but **uncommitted**, and **only Compass Canada overrides it — Compass USA
214
+ is deliberately unchanged**. The approval gate this page documents remains untouched either way.
215
+ (bala)
126
216
  - 2026-08-26 — Noted that `_status()`'s fulfillment half moved into `_fulfillmentStatus()` (per-region
127
217
  seam); the approval gate this page documents is untouched, and the "do not fix stuck MR/MA orders in
128
218
  `_status`" ruling still stands — the stuck orders are empty, so no fulfillment rule helps them.
@@ -1,12 +1,12 @@
1
1
  ---
2
- title: Compass USA — Fulfilled vs Partially Fulfilled (order aggregate AND per-line)
2
+ title: Compass USA — Fulfilled vs Partially Fulfilled (order total kept; per-line rule built then rejected)
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-08-26
9
+ updated: 2026-08-27
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - _underscore/Model/Compass/SalesOrder.php
@@ -18,79 +18,127 @@ related:
18
18
  - ../../../2.0/apps/_underscore/features/sales-order-status-filter-surface.md
19
19
  - mr-ma-order-approval-and-status.md
20
20
  - asn-to-item-fulfillment.md
21
+ - isfulfillable-data-quality-and-type-rule.md
21
22
  - ../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md
22
23
  ---
23
24
 
24
25
  ## Summary
25
26
 
26
- TOGa Supply showed **Fulfilled** on Compass orders that were only partly shipped. The cause was
27
- structural, not a data problem: the fulfillment half of `_Model_Compass_SalesOrder::_status()`
28
- decided fulfilled vs. partiallyFulfilled by comparing **two order-level grand totals** — an
29
- "ordered" sum against a "shipped" sum. A grand total lets one line's surplus cancel another line's
30
- gap, so an order with line 1 short and line 2 over-shipped read as complete.
31
-
32
- **"Everything shipped" is a per-line fact.** As of 2026-08-26 an order is Fulfilled only when the
33
- legacy aggregate condition still passes **AND** no purchased line is short of its own ordered
34
- quantity. Prod impact across all ~112k `Client_Compass` orders: **Fulfilled fell by exactly 5,234**
35
- (26,856 to 21,622), partiallyFulfilled rose by the same amount, and **nothing** moved
36
- partiallyFulfilled to Fulfilled.
37
-
38
- Compass Canada has the same false-Fulfilled bug but a **different** correct rule — see the
39
- [Canada doc](../../compass-canada/features/order-fulfillment-status-per-line.md). USA cannot use
40
- Canada's rule; that is the subject of the design gotcha below and is the most important thing on
41
- this page.
27
+ TOGa Supply shows **Fulfilled** on Compass USA orders that are only partly shipped. The cause is
28
+ structural: the fulfillment half of `_Model_Compass_SalesOrder` decides fulfilled vs.
29
+ partiallyFulfilled by comparing **two order-level grand totals** — an "ordered" sum against a
30
+ "shipped" sum. A grand total lets one line's surplus cancel another line's gap, so an order with
31
+ line 1 short and line 2 over-shipped reads as complete.
32
+
33
+ **As of 2026-08-27 Compass USA is deliberately NOT fixed.** A per-line rule was written, measured
34
+ against prod, and then **reverted on purpose**. The parent method now holds the **original
35
+ order-total logic moved verbatim**; the only change to USA's code path is a refactor seam so
36
+ Compass **Canada** can override the rule.
37
+
38
+ > **The reason USA was left alone is the Office Depot ASN feed, not the SQL.** ODP sends
39
+ > **duplicate ASN messages** that credit the same PO line twice: of ODP PO lines with more than one
40
+ > shipping notice, **10,012 exceed the PO line quantity and only 8 are legitimate split shipments**.
41
+ > A per-line rule fed by that data would move ~5,223 orders to Partially Fulfilled, many of which
42
+ > were probably delivered. Full evidence lives in
43
+ > [ASN → ItemFulfillment](asn-to-item-fulfillment.md#office-depot-sends-duplicate-asn-messages-measured-2026-08-27).
44
+ > **Revisit this page only after the ODP feed is fixed.**
45
+
46
+ Compass Canada has the same false-Fulfilled bug and **has** been changed, to a pure per-line rule —
47
+ see the [Canada doc](../../compass-canada/features/order-fulfillment-status-per-line.md). USA cannot
48
+ adopt it as-is; that is the subject of the design gotchas below.
49
+
50
+ ## Status of the code (read this first)
51
+
52
+ - **USA behaviour is unchanged.** The extraction was proved behaviour-neutral by simulating the PHP
53
+ concatenation and diffing the emitted `_status` SQL: **whitespace-identical, 7,548 normalized
54
+ characters both ways**.
55
+ - An earlier attempt at a real USA change shipped as commit **`90c964f1`** and was **reverted by
56
+ `43049d1d`** (2026-08-26 16:19) on `#sprint85`, `TRUE-81284`, `_beta`, `_production` and
57
+ `_sandbox-client`. Only **`_sandbox-dev`** still carries it. So **any claim that a USA per-line
58
+ rule shipped — including the previously published "Fulfilled fell by exactly 5,234" — is wrong.**
59
+ - The current working tree is **uncommitted**, on branch `_production`, and both touched files pass
60
+ `php -l`. The ticket branch for this work is **`TRUE-81284`**.
42
61
 
43
62
  ## Key files / entry points
44
63
 
45
64
  - **`_underscore/Model/Compass/SalesOrder.php`**
46
- - `_status()` — unchanged approval gate; its fulfillment branch now calls
47
- **`static::_fulfillmentStatus($alias_SalesOrders)`**.
48
- - `_fulfillmentStatus()` (`protected static`, new) — the pendingFulfillment / partiallyFulfilled /
49
- fulfilled decision. Shared parent, used by Compass USA; overridden by Canada.
50
- - `_isPurchasedLine()` — is this line expected to ship at all?
51
- - `_qtyShippedByAsn()` — per-line shipped qty from the ASN bridge.
52
- - `_qtyShippedByTogaTech()` — per-line shipped qty through the Compass to ODP to TOGa Tech item chain.
53
- - `const VENDOR_ID__COMPASS = 26` — the **US** Compass vendor id, excluded from "purchased" lines.
65
+ - `_status()` — four gates, in order (see *The `_status` gate order* below). Its fulfillment branch
66
+ now calls **`static::_fulfillmentStatus($alias_SalesOrders)`**.
67
+ - `_fulfillmentStatus()` (`protected static`) — **the original order-total logic, verbatim.** This
68
+ is what Compass USA runs. Canada overrides it.
69
+ - `const VENDOR_ID__COMPASS = 26` — the **US** Compass vendor id, excluded from "purchased" lines.
70
+ - **`_underscore/Model/Compass/Canada/SalesOrder.php`** — the only region that actually overrides the
71
+ rule.
54
72
  - Front end (labels only): toga2-supply `FILTERFIELDS.ts` (user-selectable status filter) and
55
73
  `renderBadge.tsx` (badge text).
56
74
 
57
- ## How it works
58
-
59
- ### The seam: `_status()` vs `_fulfillmentStatus()`
75
+ ## The `_status` gate order (unchanged, and the reason fulfillment often never runs)
60
76
 
61
- `_status()` keeps its original three-part shape — an explicit `salesOrderStageId` wins; then the
62
- approval gate (denials to canceled, any undecided stage to that stage's slug); only then fulfillment.
63
- The refactor moved **only** the fulfillment branch into `_fulfillmentStatus()` and calls it with
64
- `static::`, so a region subclass can replace the rule without touching the approval logic. The
65
- extraction was proved behaviour-preserving by simulating the PHP concatenation and diffing the
66
- emitted `_status` SQL: **whitespace-identical, same 7,548 normalized characters.**
77
+ `_status` evaluates four gates in this order:
67
78
 
68
- ### pendingFulfillment (byte-identical to the old logic)
79
+ 1. an explicit `salesOrderStageId` (and not 2) wins outright;
80
+ 2. then any **denied** `ApprovalDecision` returns `canceled`;
81
+ 3. then any active-template stage with **no decision** returns that stage's slug and **never looks at
82
+ shipping**;
83
+ 4. only then fulfillment.
69
84
 
70
- Still `COUNT(shipped TOGa Tech ItemFulfillments) + COUNT(ASNs on the order) = 0`. This gate was
71
- deliberately left untouched, which is why the in-transit email crons are unaffected (see
72
- *Blast radius*).
85
+ **Gate 3 is the bigger live problem on USA.** Of USA orders on the computed path, **37,173** are
86
+ fully approved and reach fulfillment while **11,341 are stuck at a pending-approval status** — so
87
+ roughly a quarter of computed-path orders never reach any fulfillment rule at all. That population,
88
+ its per-customer breakdown, and the live Agilant spike are documented in
89
+ [MR/MA Order Approval & Status Gate](mr-ma-order-approval-and-status.md). Fixing the fulfillment rule
90
+ would not touch a single one of them.
73
91
 
74
- ### Fulfilled now requires two independent conditions
92
+ ## What USA runs today
75
93
 
76
94
  ```
77
- partiallyFulfilled WHEN NOT ( legacyAggregateCondition ) OR ( any purchased line is short )
95
+ pendingFulfillment WHEN COUNT(shipped TOGa Tech ItemFulfillments) + COUNT(ASNs on the order) = 0
96
+ partiallyFulfilled WHEN NOT ( orderedSum <= shippedSum ) # order-level grand totals
78
97
  fulfilled otherwise
79
98
  ```
80
99
 
81
- - **legacyAggregateCondition** — the original order-level `orderedSum <= shippedSum`, kept verbatim.
82
- - **per-line condition** — `COUNT(*) > 0` over `SalesOrderItems` where `price > 0` **AND**
83
- `_isPurchasedLine()` **AND** `quantity > (_qtyShippedByAsn() + _qtyShippedByTogaTech())`.
100
+ Both the ordered and shipped sides are the legacy expressions, with all their known defects intact:
101
+
102
+ - the **ordered side filters `parentSalesOrderItemId IS NULL`** while the shipped side does not, so
103
+ the two sides never count the same lines. USA has **62,865** child lines, so this is widespread.
104
+ Canada's `SAC100664` is the minimal reproduction.
105
+ - the **ordered side explicitly INCLUDES lines with no PO link**, i.e. it expects tax, freight and
106
+ service lines to ship — which is what makes the comparison unreachable for many orders.
107
+ - the **order-level TOGa Tech shipped sum is inflated by bridge fan-out**, because it correlates
108
+ nothing at item level: on `SA136116` the order-level chain returns **48** where a per-item chain
109
+ returns **32**.
110
+
111
+ These are recorded as known-wrong, not fixed. They are the case *for* a per-line rule; the ODP feed
112
+ is the case against it.
84
113
 
85
- Because *new-fulfilled implies legacy-fulfilled* by construction, the change is **strictly
86
- corrective**: it can only downgrade a wrong Fulfilled, never promote an order that used to read
87
- Partially Fulfilled. That property is the whole reason the change was safe to make against 112k
88
- live orders, and **anyone editing this method must preserve it.**
114
+ ## The per-line rule that was built and rejected
115
+
116
+ Shape tested (kept the order total as an AND, exactly so the change could only ever downgrade):
117
+
118
+ ```
119
+ partiallyFulfilled WHEN NOT ( legacyOrderTotalCondition ) OR ( any expected-to-ship line is short )
120
+ ```
89
121
 
90
- ### Telling a shippable line from a service line
122
+ Measured on prod:
91
123
 
92
- A line is expected to ship only when it has a `SalesOrderItems_PurchaseOrderItems` link reaching a
93
- `PurchaseOrders` row whose `vendorId <> VENDOR_ID__COMPASS`.
124
+ - **5,223 orders** would move fulfilled → partiallyFulfilled.
125
+ - **3,728 orders** were correctly held back **by keeping the order-total check as an AND** — without
126
+ it they would have been *promoted* to Fulfilled.
127
+ - **1,550 USA orders have ZERO expected-to-ship lines** (priced hardware with no PO-item link at
128
+ all). A pure per-line rule passes **vacuously** on those and returns fulfilled on nothing. Worked
129
+ example: `SA127967` has five priced hardware lines (`AW5M5UT-EW`, `S24D402GAN`, `HDMM6---ODP`,
130
+ `9VA80AA`, `9SR37UT`), none PO-linked.
131
+
132
+ **Decision (2026-08-27): reverted, not shipped.** The 5,223 cannot be trusted as corrections while
133
+ ODP duplicate ASNs make a line's shipped quantity unreliable. Two independent conclusions follow, and
134
+ both must survive any future attempt:
135
+
136
+ - **⚠ Any future USA per-line rule MUST keep an order-total guard** (or an equivalent non-vacuous
137
+ check), because of the 1,550 zero-expected-line orders.
138
+ - **⚠ Do NOT port Canada's clean per-line rule to USA.** Canada has no order-total condition at all;
139
+ on USA that omission alone promotes thousands of orders to a *new* false Fulfilled.
140
+
141
+ ## Telling a shippable line from a service line (USA column choice)
94
142
 
95
143
  **Neither `price > 0` nor `Items.inventoryType` separates shippable from non-shippable** — HYBRID
96
144
  dominates both groups. Verified on prod: `PC-KIT-OPT2`, `Tech Support`, `LEASE REFRESH`,
@@ -98,43 +146,59 @@ dominates both groups. Verified on prod: `PC-KIT-OPT2`, `Tech Support`, `LEASE R
98
146
  `CG-MEDKITTING-NWN-APPLE` and `ODP-COMPASS_MERAKI` have **zero** PO links, while real hardware does
99
147
  (`C40QYUC`: 1,208 of 1,362 lines; `MD4A4LL/A-S`: 130 of 137).
100
148
 
101
- > **The same `partNumber` can exist as several `Items` rows, some purchased and some not.** The test
102
- > must be **per line**, never per part number.
149
+ USA fees are identifiable through **either** type column, and the two do not agree:
150
+
151
+ | Signal | Priced order lines | Lines ever on an ASN |
152
+ |---|---|---|
153
+ | `AssetTypes.name = 'FEE'` | 90,857 | 732 |
154
+ | `ItemTypes.name = 'FEE'` | 89,921 | 70 |
155
+
156
+ **936 lines are assetType FEE but not itemType FEE.** The developer's decision is that **code reads
157
+ `ItemTypes` and lets `Items.isFulfillable` carry the assetType meaning**, because the planned data
158
+ fix folds both columns into the flag — see
159
+ [isFulfillable — Data Quality & the Type-Derived Rule](isfulfillable-data-quality-and-type-rule.md).
160
+ Note the risk that leaves: if the flag drifts back to 1, an itemType-only code test will not catch an
161
+ assetType-only FEE item.
162
+
163
+ > **The same `partNumber` can exist as several `Items` rows, some purchased and some not.** Any such
164
+ > test must be **per line**, never per part number.
103
165
 
104
166
  ## Gotchas / known issues
105
167
 
106
- - **⚠ Do NOT "simplify" this to Canada's clean per-line rule.** Tested against prod: a straight port
107
- of the Canada rule to USA would move **3,727 orders from partiallyFulfilled to Fulfilled** — i.e.
108
- create thousands of *new* false Fulfilleds. Cause: many USA **priced hardware** lines have no
109
- purchase-order-item link at all, so the purchased-line filter skips them and an order with zero
110
- eligible lines passes **vacuously**. Worked example: `SA127967` has five priced hardware lines
111
- (`AW5M5UT-EW`, `S24D402GAN`, `HDMM6---ODP`, `9VA80AA`, `9SR37UT`), none PO-linked, and a pure
112
- per-line rule returns fulfilled. Keeping **both** conditions is what makes the direction of change
113
- one-way.
114
- - **Duplicate ASNs over-credit a line.** A line can be credited twice, so one line shipping double
115
- masks another shipping nothing. Confirmed on `SA135272` (line 1 ordered 4 / shipped 8, line 2
116
- ordered 4 / shipped 0 — the old rule said fulfilled) and `SA135308` (line 3 ordered 1 / shipped 0);
117
- PO item **207281** carries two ASNs each claiming qty 4. Neither order has bundle children, so
118
- this is a defect distinct from the parent/child line mismatch. The per-line rule reduces but does
119
- not eliminate it — a genuinely short line can still read fulfilled when its own ASNs double-count.
120
- - **The old ordered side explicitly INCLUDED lines with no PO link**, i.e. it expected tax, freight
121
- and service lines to ship. That clause is what made the aggregate unreachable for many orders.
122
- - **The old ordered side filtered `parentSalesOrderItemId IS NULL` while the shipped side did not** —
123
- the two sides never counted the same lines. USA has **62,865** child lines, so this was widespread.
124
- Canada's `SAC100664` is the minimal reproduction; see the Canada doc.
125
- - **4.3% of USA ASN shipped quantity cannot be attributed to a sales-order line**, and this fix does
126
- not solve it: **5,536** `AdvanceShippingNoticeItems` have a NULL `purchaseOrderItemId`, and
127
- **12,163** more point at a `purchaseOrderItemId` with no `SalesOrderItems_PurchaseOrderItems` link
128
- (**26,770 of 617,166 units**). Treat per-line shipped quantity as a floor, not a truth.
129
- - **The legacy order-level TOGa Tech shipped sum is inflated by bridge fan-out**, because it
130
- correlates nothing at item level: on `SA136116` the order-level chain returns **48** where the
131
- per-item chain returns **32**. `_qtyShippedByTogaTech()` is the correlated (correct) version; the
132
- aggregate inside `_fulfillmentStatus()` is the legacy one and was kept only because removing it
133
- would break the one-way-change guarantee.
168
+ - **Duplicate ASNs over-credit a line — this is the blocker, not a footnote.** Confirmed on
169
+ `SA135272`: PO line 1 was credited twice (ordered 4 / shipped 8) while line 2 was never mentioned
170
+ (ordered 4 / shipped 0), and the order-total rule said fulfilled. Both ASN payloads referenced
171
+ `lineNumber 1`. Full analysis, the trend by month, and the payload evidence are in
172
+ [ASN → ItemFulfillment](asn-to-item-fulfillment.md#office-depot-sends-duplicate-asn-messages-measured-2026-08-27).
173
+ - **4.3% of USA ASN shipped quantity cannot be attributed to a sales-order line at all**: **5,536**
174
+ `AdvanceShippingNoticeItems` have a NULL `purchaseOrderItemId` and **12,163** more point at a
175
+ `purchaseOrderItemId` with no `SalesOrderItems_PurchaseOrderItems` link (**26,770 of 617,166
176
+ units**). Treat per-line shipped quantity as a floor, not a truth.
177
+ - **⚠ `self::` defeats the region seam.** `_Model_Compass_SalesOrder` calls **`self::_status()`** (not
178
+ `static::`) in its ApprovalDecision notification query, so that query always evaluates the parent
179
+ rule even for Canada orders.
180
+ - **`salesOrderStageId = 2` is a dormant, USA-impossible branch.** `_status` hardcodes stage 2 as a
181
+ recompute trigger, but USA's `SalesOrderStages` table contains **only ids 5 to 9** (Pending
182
+ Billing, Pending Billing/Partially Fulfilled, Billed, Canceled, Closed) — stage 2 does not exist
183
+ there, so setting it would be an orphan. Canada has all ten. No order in either client currently
184
+ uses stage 2.
185
+
186
+ ## Verified clean (worth recording as negatives)
187
+
188
+ Audited across both Compass clients on 2026-08-27:
189
+
190
+ - every `salesOrderStageId` in use resolves to a real `SalesOrderStages` row — **no orphans**;
191
+ - the approval half is structurally sound too (one active template, no duplicate `Approval` rows or
192
+ decisions), so **`_status` cannot return NULL in either client today** — details in
193
+ [MR/MA Order Approval & Status Gate](mr-ma-order-approval-and-status.md).
194
+
195
+ Post-fulfillment tail (USA, explicit stage): **473** Pending Billing, **39,869** Billed, **1,592**
196
+ Canceled, **809** Closed — the lifecycle continues Fulfilled → Pending Billing → Billed via the
197
+ stored stage, not the computed status.
134
198
 
135
199
  ## Blast radius — fulfilled vs partiallyFulfilled is display-only
136
200
 
137
- Every consumer was audited before shipping, which is why this carries no downstream risk:
201
+ Re-confirmed 2026-08-27: **no cron, email or report reads the distinction.**
138
202
 
139
203
  - **The status is computed live and never stored.** No backfill; reverting the code fully reverts the
140
204
  behaviour.
@@ -143,39 +207,47 @@ Every consumer was audited before shipping, which is why this carries no downstr
143
207
  `worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php` and
144
208
  `compass/workflow/test_partial_in_transit_email.php` each keep their **own duplicated copy** of the
145
209
  `_status` CASE, but those copies only ever produce canceled / pendingApprovalUnknown /
146
- pendingFulfillment / shipped and they filter on `computedStatusSlug = 'shipped'`. They never
147
- compute fulfilled or partiallyFulfilled, and the "has an ASN" condition they rely on was left
148
- byte-identical. **That duplication is itself a divergence risk** — see the
210
+ pendingFulfillment / shipped and they filter on `computedStatusSlug = 'shipped'`. **That
211
+ duplication is itself a divergence risk** — see the
149
212
  [in-transit email doc](../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md).
150
213
  - **The MA sales-order exception report**
151
214
  (`compass/workflow/7_generate_ma_sales_order_exception_report.php`) filters the **stored**
152
215
  `salesOrderStageId` column, not the computed status.
153
216
  - **toga2-supply** sends only the approvals filter values (`pendingInitialApproval` /
154
217
  `pendingApproval`) to the server; `fulfilled` and `partiallyFulfilled` appear only in
155
- `FILTERFIELDS.ts` (user-selectable filter) and `renderBadge.tsx` (a label).
156
- - **⚠ One call site always uses the parent rule.** `_Model_Compass_SalesOrder` calls
157
- **`self::_status()`** (not `static::`) in its ApprovalDecision notification query, so that query
158
- never picks up a region override. Harmless today — it only branches on pendingApproval / canceled /
159
- pendingFulfillment, and the pendingFulfillment gate is byte-identical across both regions — but it
160
- is a live trap if a region ever changes those three.
218
+ `FILTERFIELDS.ts` and `renderBadge.tsx`.
161
219
 
162
220
  ## Change history
163
- - 2026-08-26 — Fixed the false **Fulfilled** on partly-shipped orders. Split the fulfillment half of
164
- `_status()` into `protected static _fulfillmentStatus()` called via `static::` (proved
165
- SQL-identical), then made Fulfilled require the legacy order-level aggregate **AND** no short
166
- purchased line, with new helpers `_isPurchasedLine()`, `_qtyShippedByAsn()`,
167
- `_qtyShippedByTogaTech()`. Fixed two USA-only defects along the way: duplicate ASNs double-crediting
168
- a line, and the ordered side explicitly *including* non-PO-linked tax/freight/service lines. Prod:
169
- Fulfilled 26,856 to 21,622 (down 5,234), zero orders promoted. Recorded why a clean per-line port
170
- of Canada's rule is **wrong** here (3,727 new false Fulfilleds), how to identify a shippable line
171
- (PO link, not price or `inventoryType`), and the ASN attribution limits that bound accuracy. Not
172
- committed or pushed; not yet exercised through the Supply UI. (bala)
221
+ - 2026-08-27 — **Corrects the 2026-08-26 entry below: no USA rule change ever shipped, and the
222
+ "Fulfilled fell by exactly 5,234" figure is void.** Commit `90c964f1` was reverted by `43049d1d`
223
+ (2026-08-26), and this session **deliberately kept USA unchanged**: `_fulfillmentStatus()` holds the
224
+ original order-total logic verbatim and the seam exists only for Canada's override (emitted `_status`
225
+ SQL whitespace-identical, 7,548 chars). A per-line USA rule was built and measured (**5,223** orders
226
+ would move fulfilled → partiallyFulfilled; **3,728** correctly held back by keeping the order total
227
+ as an AND) then reverted, because the **Office Depot ASN feed is not trustworthy at line level** —
228
+ 10,012 ODP PO lines are credited over quantity by duplicate notices against only 8 genuine split
229
+ shipments. Recorded the two constraints on any future attempt: **1,550 USA orders have zero
230
+ expected-to-ship lines** (so a pure per-line rule passes vacuously) and Canada's rule must not be
231
+ ported. Also recorded the full `_status` gate order and that gate 3 (approvals) strands **11,341**
232
+ USA orders before fulfillment is ever evaluated, the USA FEE column choice (`ItemTypes` in code,
233
+ 936 lines are assetType-FEE only), the dormant `salesOrderStageId = 2` branch that cannot exist in
234
+ USA, and the clean-audit negatives (`_status` cannot return NULL in either client).
235
+ Uncommitted; on `_production`; not exercised through the Supply UI. (bala)
236
+ - 2026-08-26 — **REVERTED IN GIT — do not rely on this entry.** Reported USA as fixed by requiring the
237
+ legacy order-level aggregate **AND** no short purchased line, with helpers `_isPurchasedLine()`,
238
+ `_qtyShippedByAsn()`, `_qtyShippedByTogaTech()`, and claimed prod impact "Fulfilled 26,856 → 21,622
239
+ (down 5,234)". Commit `90c964f1` was reverted by `43049d1d`; the refinements after it were never
240
+ committed. Superseded by the 2026-08-27 entry. Still valid from this work: the false-Fulfilled root
241
+ cause, the shippable-line test (PO link, not price or `inventoryType`), the ASN attribution limits,
242
+ and that a clean per-line port of Canada's rule is wrong here. (bala)
173
243
 
174
244
  ## Related docs
175
245
  - [Compass Canada — per-line, ASN-only rule](../../compass-canada/features/order-fulfillment-status-per-line.md)
246
+ — the region that did change, and why its rule is safe there.
247
+ - [Compass ASN → ItemFulfillment](asn-to-item-fulfillment.md) — the ODP duplicate-ASN evidence that
248
+ blocks the USA change.
249
+ - [Compass MR/MA Order Approval & Status Gate](mr-ma-order-approval-and-status.md) — the approval
250
+ gate that strands more USA orders than the fulfillment rule mislabels.
176
251
  - [IF Stage Lifecycle & Order Status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md)
177
- — the shipped-only stage policy that feeds both sides of this comparison.
178
252
  - [FIELD_SQL calculated fields](../../../2.0/apps/_underscore/features/calculated-sql-fields.md)
179
253
  — why a region-subclass override of a calculated field is picked up at all.
180
- - [Compass MR/MA Order Approval & Status Gate](mr-ma-order-approval-and-status.md) — the approval
181
- half of `_status()`, which this change did not touch.
@@ -165,15 +165,22 @@ separate, related client (see its own profile).
165
165
  shared by Compass USA + Canada) counts **shipped only** — picked/packed never advance a Compass
166
166
  order. Contrast Quad, which gets the full picked/packed machine. See
167
167
  [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
168
- - **Fulfilled requires the order aggregate AND every purchased line (2026-08-26).** The old
169
- order-level ordered-total vs shipped-total comparison let one line's surplus hide another line's
170
- gap, so 5,234 prod orders read **Fulfilled** while partly shipped. `_status()`'s fulfillment branch
171
- now lives in `_fulfillmentStatus()` and requires the legacy aggregate **plus** no short purchased
172
- line — strictly corrective, it can only downgrade. **⚠ Do not "simplify" it to Compass Canada's
173
- clean per-line rule:** many USA priced hardware lines have no PO-item link, so a pure per-line rule
174
- passes vacuously and would create 3,727 *new* false Fulfilleds. Full evidence, the shippable-line
175
- test, and the ASN attribution limits:
168
+ - **⚠ Fulfilled vs Partially Fulfilled is still the old order-total rule, on purpose (2026-08-27).**
169
+ The order-level ordered-total vs shipped-total comparison lets one line's surplus hide another
170
+ line's gap, so USA orders read **Fulfilled** while partly shipped. A per-line fix was built,
171
+ measured (5,223 orders would move) and **deliberately reverted**, because Office Depot sends
172
+ **duplicate ASN messages** — 10,012 ODP PO lines are credited over quantity against only 8 genuine
173
+ split shipments — so line-level shipped quantity cannot be trusted yet. `_status()`'s fulfillment
174
+ branch does live in `_fulfillmentStatus()`, but **only Compass Canada overrides it**; the USA body
175
+ is the original logic verbatim. **⚠ Do not port Canada's clean per-line rule:** 1,550 USA orders
176
+ have no expected-to-ship line at all, so it passes vacuously and would create *new* false
177
+ Fulfilleds. Any earlier "5,234 orders fixed" note is void — that commit was reverted in git.
176
178
  [Fulfilled vs Partially Fulfilled](features/order-fulfillment-status-per-line.md).
179
+ - **⚠ The approval gate strands far more orders than the fulfillment rule mislabels (2026-08-27).**
180
+ **11,341** Compass USA orders sit at a pending-approval status and never reach the fulfillment
181
+ branch at all (Office Depot 9,926, Compass 950, Agilant 465), growing 1,200–2,000 a month. MR is
182
+ clean; a **live Agilant Drop Ship spike** started 2026-08-11.
183
+ [MR/MA Order Approval & Status Gate](features/mr-ma-order-approval-and-status.md).
177
184
  - Built on the shared 2.0 Recursive Item Fulfillments engine (upstream mirroring).
178
185
  - **`Items.isFulfillable` gates the storefront Qty Fulfilled cell (2026-07).** Sourced from
179
186
  NetSuite onto the Agilant source item during the 1.0 item sync
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.669",
3
+ "version": "1.0.670",
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",