toga-ai 1.0.658 → 1.0.660

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: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-24
9
+ updated: 2026-08-26
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker/crons/toga2/compass/update_salesorder_status_from_odp.php
@@ -19,6 +19,8 @@ files:
19
19
  - library/app/client/compasscanada.php
20
20
  related:
21
21
  - ../../../clients/compass-canada/features/french-order-email-localization.md
22
+ - ../../../clients/compass-usa/features/order-fulfillment-status-per-line.md
23
+ - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
22
24
  ---
23
25
 
24
26
  ## Summary
@@ -134,6 +136,17 @@ the tracking number as emailed (it retries next run).
134
136
  - **Pick the template from the language of the person actually being emailed.** The manager-approval
135
137
  reminder cron (`compass_email_reminders.php`) selected the template from the **requester's**
136
138
  language even though the mail goes to the **manager**. Fixed 2026-08-03.
139
+ - **⚠ Each cron keeps its OWN duplicated copy of the 2.0 `_status` CASE — a standing divergence
140
+ risk.** `compass/update_salesorder_status_from_odp.php`,
141
+ `compasscanada/update_salesorder_status_from_odp.php` and
142
+ `compass/workflow/test_partial_in_transit_email.php` each re-implement the model's status SQL as
143
+ `computedStatusSlug`. Today that is safe by luck of scope: the copies only ever produce canceled /
144
+ pendingApprovalUnknown / pendingFulfillment / shipped and they filter on
145
+ `computedStatusSlug = 'shipped'`, so the 2026-08-26 rewrite of the fulfilled-vs-partiallyFulfilled
146
+ rule (which left the "has an ASN" pendingFulfillment gate byte-identical) did not touch them. **Any
147
+ future change to the pendingFulfillment or canceled branches of
148
+ `_Model_Compass_SalesOrder::_status()` must be mirrored into all three crons by hand.** See
149
+ [Compass Fulfilled vs Partially Fulfilled](../../../clients/compass-usa/features/order-fulfillment-status-per-line.md).
137
150
  - **⚠ Conflict with the new 2.0 centralized tracking-status refresh.** The delivered-email cron
138
151
  (`worker/crons/toga2/compass/send_delivered_email.php`) polls UPS itself, sends the delivered
139
152
  template (uuid `fe961c7c-277b-4a89-be1d-3d318d8c7718`) via api2 `GET /email-templates/sendEmail`,
@@ -148,6 +161,11 @@ the tracking number as emailed (it retries next run).
148
161
 
149
162
  ## Change history
150
163
 
164
+ - 2026-08-26 — Flagged the **duplicated `_status` CASE** in the three crons as a standing divergence
165
+ risk after the 2.0 Compass fulfillment rule was rewritten. Verified the crons are unaffected: their
166
+ copies only emit canceled / pendingApprovalUnknown / pendingFulfillment / shipped and filter on
167
+ `shipped`, and the pendingFulfillment gate was left byte-identical. No code change here. (bala)
168
+
151
169
  - 2026-08-24 — Recorded the **conflict with the new 2.0 platform-wide tracking-status refresh**: that
152
170
  refresh writes `TrackingNumbers.status` via api2 PUT for all clients, and this cron's delivered
153
171
  UPDATE is guarded by `WHERE status <> 'DELIVERED'` — so if the refresh marks a Compass row
@@ -10,7 +10,7 @@
10
10
  | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
11
11
  | [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
12
12
  | [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
13
- | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent | _underscore/Model.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/ServiceRequest.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Quad/Item.php, _underscore/Model/Quad/VendorItem.php, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql, dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql |
13
+ | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent | _underscore/Model.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/ServiceRequest.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/Canada/SalesOrder.php, api2/Component/Api/V2/V2.php, _underscore/Model/Quad/Item.php, _underscore/Model/Quad/VendorItem.php, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql, dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql |
14
14
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | test/@Mark/true-80824-fedex-inflate-test.php, dbchanges2/Client_Growrk/2026-08-10e - GrowrkUpsServiceMethodCodes.sql, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/ShipmentRequest/ShipmentRequest.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Component/Library/Carriers/Usps/Usps.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
15
15
  | [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the | _underscore/Database.php, _underscore/Environment.php, api2/Initialize.php |
16
16
  | [_Cloud S3 helpers (copy / get / delete / list)](features/cloud-s3-helpers.md) | `_Cloud` centralizes AWS SDK S3 usage for the 2.0 stack so the `S3Client` never leaks into workers or app code. | _underscore/Cloud.php |
@@ -25,7 +25,7 @@
25
25
  | [FIELD_STORAGE fields — per-row lazy hydration and the platform-wide missing-column 500](features/field-storage-row-hydration.md) | `FIELD_STORAGE` is the 2.0 field type for blob-backed columns (S3 or local folder). | _underscore/Model.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/Invoice.php, dbchanges2/Core/HISTORIC/2024/2024-11b - item-fulfillments.sql |
26
26
  | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
27
27
  | [isFulfillable Propagation Up the SO↔PO Chain](features/fulfillable-item-propagation.md) | `Items.isFulfillable` is a boolean that gates whether a storefront line's **Qty Fulfilled** cell is actionable. | _underscore/Model/Client/Item.php, _underscore/Model/Compass/Item.php, toga2-supply/src/pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx, dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql |
28
- | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
28
+ | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/Canada/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
29
29
  | [DB-free unit testing for _underscore model interceptors](features/model-interceptor-unit-testing.md) | `_underscore` shipped with **no** PHPUnit setup (no `composer.json`/`phpunit`; only vendored PhpOffice tests existed). | _underscore/Test/bootstrap.php, _underscore/Test/Prudential/ServiceRequestTest.php, test/@Bala/tests/netsuite_salesorder_payload_tests.php |
30
30
  | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php, _underscore/Model.php, _underscore/Model/Rate/Subscription.php |
31
31
  | [_Model::save() parent FK cascade — stored-SQL-field recompute deadlocks](features/model-save-parent-cascade-stored-field-deadlock.md) | `_Model::save()` runs a **generic parent foreign-key cascade**: inserting (or saving) a child row that carries an FK to a parent causes `_Model` to **re-load an | _underscore/Model.php, _underscore/Model/Client/PurchaseOrder.php, _underscore/Model/Client/AdvanceShippingNotice.php |
@@ -13,12 +13,17 @@ files:
13
13
  - _underscore/Model/Client/SalesOrder.php
14
14
  - _underscore/Model/Client/ServiceRequest.php
15
15
  - _underscore/Model/Elite/SalesOrder.php
16
+ - _underscore/Model/Compass/SalesOrder.php
17
+ - _underscore/Model/Compass/Canada/SalesOrder.php
18
+ - api2/Component/Api/V2/V2.php
16
19
  - _underscore/Model/Quad/Item.php
17
20
  - _underscore/Model/Quad/VendorItem.php
18
21
  - dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql
19
22
  - dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql
20
23
  related:
21
24
  - ../architecture.md
25
+ - ./item-fulfillment-stage-lifecycle-and-order-status.md
26
+ - ../../../clients/compass-usa/features/order-fulfillment-status-per-line.md
22
27
  - ./acl-permission-chain.md
23
28
  - ../../api2/features/v2-api-error-codes.md
24
29
  - ./model-magic-field-access.md
@@ -136,6 +141,38 @@ The ACL convention for these rows: grants go to the **`Base` and `API` roles onl
136
141
  NAME**. Most other `SalesOrders` custom fields carry `Base` alone, so `Base + API` is already the
137
142
  generous end; `Developer` and `Public` get no ACL row for any field.
138
143
 
144
+ ## Overriding a calculated field per client (and per sub-client)
145
+
146
+ **A client subclass override of a calculated field IS picked up, including a sub-client one.** Two
147
+ mechanisms have to line up, and both do:
148
+
149
+ 1. **api2 resolves the model class from `Core.Clients.clientIdentifier`** with
150
+ `str_replace('_Model_Client_', '_Model_' . $clientIdentifier . '_', get_class($model))`
151
+ (`api2/Component/Api/V2/V2.php`, ~10 call sites), falling back to the `_Model_Client_*` class when
152
+ the candidate does not exist. So `Compass_Usa` (id 2) and `Compass_Canada` (id 43) resolve to
153
+ `_Model_Compass_Usa_SalesOrder` / `_Model_Compass_Canada_SalesOrder`.
154
+ 2. **The field expression is invoked late-bound** — `get_called_class()::$field($this::TABLE)` in
155
+ `Model.php` (`getSqlFieldValue` / `buildSqlFieldValue`) — so whichever class api2 resolved supplies
156
+ the SQL.
157
+
158
+ **The safe way to give one region its own behaviour is to split the field's body into a
159
+ `protected static` helper and call it with `static::`.** The region subclass then overrides only the
160
+ helper, leaving the shared parts of the expression in one place. This is how
161
+ `_Model_Compass_SalesOrder::_status()` isolates its fulfillment branch into
162
+ `_fulfillmentStatus()` so Compass Canada can change the fulfilled-vs-partial rule with provably zero
163
+ effect on Compass USA (see
164
+ [Compass fulfillment status](../../../clients/compass-usa/features/order-fulfillment-status-per-line.md)).
165
+
166
+ **Prove an extraction is behaviour-preserving by diffing the emitted SQL**, not by reading the diff:
167
+ simulate the PHP string concatenation for both versions, normalize whitespace, and compare lengths
168
+ and content. The `_status` extraction above came out whitespace-identical at 7,548 normalized
169
+ characters — that is the standard of evidence for touching a field that runs on 112k live rows.
170
+
171
+ > **⚠ `self::` defeats the whole mechanism.** A *different* method on the parent that calls
172
+ > `self::_status()` (rather than `static::_status()`) always evaluates the **parent's** expression,
173
+ > even for a client whose subclass overrides it. `_Model_Compass_SalesOrder` has exactly this in its
174
+ > ApprovalDecision notification query. Late static binding only happens with `static::`.
175
+
139
176
  ## Gotchas / known issues
140
177
 
141
178
  - **⚠ Never add a parameter TYPE to an override whose parent declares the parameter untyped - it
@@ -173,6 +210,14 @@ generous end; `Developer` and `Public` get no ACL row for any field.
173
210
  [save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
174
211
 
175
212
  ## Change history
213
+ - 2026-08-26 - Documented **how to override a calculated field for one client / sub-client**: api2
214
+ resolves the model class by `str_replace('_Model_Client_', '_Model_' . $clientIdentifier . '_', ...)`
215
+ (`Component/Api/V2/V2.php`), and because the expression is invoked as
216
+ `get_called_class()::$field(...)` the subclass version wins - so `Compass_Usa` / `Compass_Canada`
217
+ each get their own SQL. Added the recommended shape (split the varying branch into a
218
+ `protected static` helper called via **`static::`**), the emitted-SQL diff as the standard of proof
219
+ for such an extraction, and the **`self::` trap** (another parent method calling `self::_status()`
220
+ never sees a subclass override - live example in `_Model_Compass_SalesOrder`). (bala)
176
221
  - 2026-08-26 - **`FIELDOPT_SQL_TYPE` casts the returned VALUE, not just sort/pagination cursors.**
177
222
  `getSqlFieldValue()` (`Model.php` ~L509-520) hands the fetched value to `formatValueForField()`
178
223
  (~L728), which `(float)`s a `FIELD_DECIMAL` field - so a text-returning calculated field declared
@@ -6,12 +6,13 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-26
10
10
  owners: [bala]
11
11
  files:
12
12
  - _underscore/Model/Client/SalesOrder.php
13
13
  - _underscore/Model/Quad/SalesOrder.php
14
14
  - _underscore/Model/Compass/SalesOrder.php
15
+ - _underscore/Model/Compass/Canada/SalesOrder.php
15
16
  - _underscore/Model/Compass/SalesOrderStatus.php
16
17
  - _underscore/Model/Elite/SalesOrder.php
17
18
  - _underscore/Model/Elite/SalesOrderStatus.php
@@ -29,6 +30,8 @@ related:
29
30
  - ../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
30
31
  - ../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
31
32
  - ../../../clients/compass-usa/features/asn-to-item-fulfillment.md
33
+ - ../../../clients/compass-usa/features/order-fulfillment-status-per-line.md
34
+ - ../../../clients/compass-canada/features/order-fulfillment-status-per-line.md
32
35
  ---
33
36
 
34
37
  ## Summary
@@ -43,7 +46,11 @@ This makes the *picked/packed* portion of the fulfillment lifecycle visible (pre
43
46
  Partially Fulfilled → Fulfilled — so non-Compass clients see the picked/packed lifecycle.
44
47
  - **Compass USA + Compass Canada:** all IF stages are *imported*, but order status counts
45
48
  **shipped only** — picked/packed never advance a Compass order's status. Compass walks
46
- Pending → Partially Fulfilled → Fulfilled by shipped quantity alone.
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
52
+ [Compass USA](../../../clients/compass-usa/features/order-fulfillment-status-per-line.md) and
53
+ [Compass Canada](../../../clients/compass-canada/features/order-fulfillment-status-per-line.md).
47
54
 
48
55
  Independently of order status, **inventory movement (`qtyFulfilled` / `qtyCommitted`) is
49
56
  shipped-only for ALL clients** — picked/packed quantities never count as inventory movement.
@@ -59,6 +66,11 @@ shipped-only for ALL clients** — picked/packed quantities never count as inven
59
66
  - **`Model/Compass/SalesOrder.php`** — Compass `_status`, shipped-only: both the existence
60
67
  `COUNT` and the shipped-qty `SUM` subqueries `INNER JOIN ItemFulfillmentStages →
61
68
  ItemFulfillmentStatuses` filtered to the shipped status slug. Shared by Compass USA + Canada.
69
+ 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
71
+ `_status()` and is region-independent.
72
+ - **`Model/Compass/Canada/SalesOrder.php`** — Canada's `_fulfillmentStatus()` override (per-line,
73
+ ASN-bridge only).
62
74
  - **`Model/Compass/SalesOrderStatus.php`** — adds `const SLUG__PICKED = 'picked'` and
63
75
  `SLUG__PACKED = 'packed'`.
64
76
  - **`Model/Client/{SalesOrderItem,Item,PurchaseOrderItem}.php`** — the `_qtyFulfilled` /
@@ -98,7 +110,7 @@ fulfillment statuses — filter widened to `['_picked','_packed','_shipped']`:
98
110
  | Client | Statuses walked | Driven by |
99
111
  |---|---|---|
100
112
  | Base, Quad (non-SA) | Pending → Picked → Packed → Partially Fulfilled → Fulfilled | all IF stages |
101
- | Compass USA + Canada | Pending → Partially Fulfilled → Fulfilled | **shipped IF stage only** |
113
+ | Compass USA + Canada | Pending → Partially Fulfilled → Fulfilled | **shipped IF stage only**; fulfilled-vs-partial rule differs per region (`_fulfillmentStatus()`) |
102
114
  | Elite | pendingFulfillment → partiallyFulfilled → fulfilled → shipped → delivered (+ canceled) | fulfillment + shipment state; **no approval stage** |
103
115
 
104
116
  Quad `SA%` orders delegate to the base machine; an explicit `salesOrderStageId` overrides the
@@ -163,11 +175,29 @@ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is
163
175
  IFs changes a Compass order's status — by design it does not (the Compass supply UI must show
164
176
  shipped-only progression). This supersedes the old "stages 1/2 are unused in prod" assumption in
165
177
  the Compass order-lifecycle workflow.
178
+ - **⚠ An order-level "ordered total vs shipped total" comparison is not a fulfillment test.** A
179
+ 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.
185
+ - **A `self::`-qualified call to a calculated field inside the same model always uses the parent's
186
+ logic.** `_Model_Compass_SalesOrder` calls `self::_status()` in its ApprovalDecision notification
187
+ query, so that one call site never sees a region subclass override. Use `static::` unless you
188
+ specifically want the parent.
166
189
  - The base/Compass picked check is a string literal `'picked'` (no `SLUG__PICKED` on
167
190
  `_Model_Client_ItemFulfillmentStatus`); only the shipped check uses a constant. Adding a base IF
168
191
  picked/packed constant would let the literal be removed.
169
192
 
170
193
  ## Change history
194
+ - 2026-08-26 — Recorded that the Compass **fulfilled-vs-partiallyFulfilled** rule is now **per
195
+ region**: the fulfillment branch of `_Model_Compass_SalesOrder::_status()` was extracted into a
196
+ `protected static _fulfillmentStatus()` invoked via `static::` (approval gate untouched, emitted
197
+ SQL proved identical), and `_Model_Compass_Canada_SalesOrder` overrides it. The shipped-only
198
+ **stage** policy is unchanged for both regions. Added the general gotcha that an order-level
199
+ ordered-total vs shipped-total comparison is not a fulfillment test, and the `self::` vs `static::`
200
+ trap. (bala)
171
201
  - 2026-08-18 - Added **Elite** as a third order-status policy: `_Model_Elite_SalesOrder::_status`
172
202
  walks a six-status ladder (pendingFulfillment / partiallyFulfilled / fulfilled / shipped /
173
203
  delivered / canceled) with four private helper builders and two new `SalesOrderStatuses` rows
@@ -193,3 +223,7 @@ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is
193
223
  — the stage-3-Shipped invariant updated by this work.
194
224
  - [Compass ASN → ItemFulfillment Auto-Creation](../../../clients/compass-usa/features/asn-to-item-fulfillment.md)
195
225
  — ASN-created IFs now resolve the shipped stage.
226
+ - [Compass USA — Fulfilled vs Partially Fulfilled](../../../clients/compass-usa/features/order-fulfillment-status-per-line.md)
227
+ — the per-region fulfillment rule (order aggregate AND per-line).
228
+ - [Compass Canada — Fulfilled vs Partially Fulfilled](../../../clients/compass-canada/features/order-fulfillment-status-per-line.md)
229
+ — Canada's per-line, ASN-bridge-only override.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-25
9
+ updated: 2026-08-26
10
10
  owners: [jcardinal, apeterson]
11
11
  files:
12
12
  - _underscore/Model/Client/Language.php
@@ -79,6 +79,12 @@ with `const DATABASE`/`TABLE`, typed public-prop fields, and array FKs declaring
79
79
  `FIELDOPT_FOREIGNKEY_MODEL` (cross-DB FKs are soft — no SQL constraint). Modeled on
80
80
  `RecordField.php`/`TableView.php`.
81
81
 
82
+ **That Core/Client split is a CLUSTER boundary, not just a schema one.** `SurfaceOverrides` lives in
83
+ `DB_CLIENT` (there is no `Model/Core/SurfaceOverride.php`), so a question like "how many clients
84
+ override this element?" **cannot be answered with a join** on prod — resolve the element ids in Core
85
+ first, then count per client DB with those ids as literals. Same rule that forces client override
86
+ seeds to inline Core ids (below).
87
+
82
88
  ## How `resolve()` works
83
89
 
84
90
  Inputs come from `$api` (the same accessor `_Model_Client_TableView::meta` uses):
@@ -606,6 +612,10 @@ Core record grants + their logic-group expressions all evaluate `all`/`"1"`. The
606
612
  match Compass, a follow-up migration aligning both `meta` and `meta-group` to roles 1,3,4 is needed.
607
613
 
608
614
  ## Change history
615
+ - 2026-08-26 — Recorded that the Core/Client model split is a **cluster** boundary: `SurfaceOverrides`
616
+ is a CLIENT-DB model, so override counts across clients must be a two-step (Core ids → per-client
617
+ count with literal ids), never a join. Matters when deciding whether to fork or share a surface,
618
+ since a fork starts with **zero** overrides. (apeterson)
609
619
  - 2026-08-25 — Fixed a **whole-platform gap**: Surface message translations were inert for **every
610
620
  API-authenticated request, for every client**. `_resolveLanguageId` returned `0` when the JWT carried
611
621
  neither `id.client.languageId` nor `id.language`, and `0` makes `_loadMessages` skip the
@@ -4,7 +4,7 @@
4
4
  |-----|---------|-------|
5
5
  | [Database Changes (dbchanges2) Repository Architecture](architecture.md) | `dbchanges2` is the **schema-migration / SQL change-set repository** for the entire 2.0 platform. | Core/, Client/, Client_<Tenant>/, Logs/, Logs_Client/, _modules/ |
6
6
  | [Re-runnable additive INSERTs (uuid4 in SQL, guards, and the DISTINCT trap)](features/rerunnable-additive-inserts.md) | Most `dbchanges2` files are **additive data grants** run by hand against production, often more than once (once per environment, or twice because someone was no | dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql |
7
- | [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_CompassCanada/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.sql, dbchanges2/Client_Quad/2026-07-21b - ApproveDisabledTooltipPoNumber.sql, dbchanges2/Client_Quad/2026-08-21 - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_CompassCanada/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql, dbchanges2/Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-05b - ApprovalDetailsShippingMethodConcatCharge.sql, dbchanges2/Core/2026-08-14a - NoteBadgesVocabularySurface.sql, dbchanges2/Client/2026-08-14a - NoteBadgeThemeTokens.sql, dbchanges2/Client/2026-08-14b - NoteBadgeUserColorFix.sql, dbchanges2/Client_Compass/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Client_CompassCanada/2026-08-14a - NoteBadgeDelegateThemeTokens.sql |
7
+ | [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_CompassCanada/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.sql, dbchanges2/Client_Quad/2026-07-21b - ApproveDisabledTooltipPoNumber.sql, dbchanges2/Client_Quad/2026-08-21 - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_CompassCanada/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql, dbchanges2/Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-05b - ApprovalDetailsShippingMethodConcatCharge.sql, dbchanges2/Core/2026-08-14a - NoteBadgesVocabularySurface.sql, dbchanges2/Client/2026-08-14a - NoteBadgeThemeTokens.sql, dbchanges2/Client/2026-08-14b - NoteBadgeUserColorFix.sql, dbchanges2/Client_Compass/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Client_CompassCanada/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Core/2026-08-26a - ServiceRequestRecordHeaderSurfaceSeed.sql, dbchanges2/Core/2026-08-26b - ServiceRequestDetailsSurfaceSeed.sql |
8
8
  | [2.0 New-Client Onboarding (manual process)](workflows/client-onboarding.md) | > **A local browser wizard now automates this.** Steps 2–9 below (create DBs, generate Core/API > inserts, append to `Clients_Db.txt`) — plus the dbchanges2 bla | Client/, Client_<Tenant>/, Core/, Logs_Client/ |
9
9
  | [Auditing a client DB that drifted from its models (partially applied module migration)](workflows/client-schema-drift-audit.md) | A recurring 2.0 failure mode: **one client's database drifts from what the PHP models declare**, usually because a `_modules/<module>/` migration was applied to | dbchanges2/Client_Growrk/2026-05-28.sql, dbchanges2/Client_Growrk/2026-08-10c - GrowrkServiceRequestCustomFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10d - GrowrkServiceRequestTypeAndDispositionSeeds.sql, dbchanges2/_modules/netsuite/2026-07-10a - UnitInventoryFields.sql, dbchanges2/Client_Growrk/2026-08-10 - GrowrkUnitInventoryFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10b - GrowrkUnitItemDescriptionAcl.sql, dbchanges2/Client_Growrk/_modules.txt |
10
10
  | [Local vs prod MySQL config parity — why “it passed locally” is not evidence](workflows/local-vs-prod-mysql-config-parity.md) | Several migration failures that look like "prod-only bugs" are actually **per-machine MySQL server-configuration differences**. | |
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-25
9
+ updated: 2026-08-26
10
10
  owners: [jcardinal, apeterson]
11
11
  files:
12
12
  - dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql
@@ -86,6 +86,8 @@ files:
86
86
  - dbchanges2/Client/2026-08-14b - NoteBadgeUserColorFix.sql
87
87
  - dbchanges2/Client_Compass/2026-08-14a - NoteBadgeDelegateThemeTokens.sql
88
88
  - dbchanges2/Client_CompassCanada/2026-08-14a - NoteBadgeDelegateThemeTokens.sql
89
+ - dbchanges2/Core/2026-08-26a - ServiceRequestRecordHeaderSurfaceSeed.sql
90
+ - dbchanges2/Core/2026-08-26b - ServiceRequestDetailsSurfaceSeed.sql
89
91
  related:
90
92
  - ../../_underscore/features/surface-resolver.md
91
93
  ---
@@ -730,7 +732,61 @@ rule resumes.
730
732
  Element 32 is likewise `isEnabled=0` (appears but disabled until an `IS_ENABLED`/`ENABLED_RULE`
731
733
  override is added). **Open follow-up.**
732
734
 
735
+ ## Forking a surface — Service Request record surfaces (Core 45/46, 2026-08-26)
736
+
737
+ The Service Request modal needed header chrome and a details section that **diverge** from the sales
738
+ order's, so two surfaces were forked (not the whole set — `Surfaces` are per-region, so forking one
739
+ region is the right granularity).
740
+
741
+ | File | Ids | Forked from |
742
+ |---|---|---|
743
+ | `Core/2026-08-26a - ServiceRequestRecordHeaderSurfaceSeed.sql` | `Surfaces` **45** (`service-request-record-header`), `SurfaceElements` **161–165**, `recordId` **35** (`Records` `service-requests`, hardcoded) | `sales-order-record-header` (Surfaces 11 / elements 25–28) |
744
+ | `Core/2026-08-26b - ServiceRequestDetailsSurfaceSeed.sql` | `Surfaces` **46** (`service-request-details`), `SurfaceElements` **166–169** | the `order-details` section |
745
+
746
+ The header adds a modal tag reading "Service Request" (new `Core.Messages` key
747
+ `serviceRequest.header.tag`) and a new **Type** FIELD element (165) between order date and status
748
+ badge. Element **164**'s `labelTemplate` still reads `'Order {order.number}'` — **intentional**,
749
+ confirmed by the developer; do not "fix" it.
750
+
751
+ The details surface adds Type and drops Cost Center: Customer (`contact._name`), PO Number
752
+ (`_purchaseOrders`), Type (`serviceRequest.serviceRequestType.name`), Description (`description`).
753
+ A **Provider** field was requested but **not seeded** — no `provider` field exists on
754
+ `_Model_Client_SalesOrder` or `_Model_Client_ServiceRequest` and nothing in `toga25-supply`
755
+ references one. Still unresolved.
756
+
757
+ > Valuekey trap: section elements use **bare** keys while header elements use `order.`-prefixed keys,
758
+ > and the SR modal actually fetches the **linked Sales Order** — see
759
+ > [surface-frontend](../../toga25-supply/features/surface-frontend.md#gotchas).
760
+
761
+ ### 🚨 Forking a surface silently DROPS every client override of the original
762
+ `SurfaceOverrides` rows are keyed **per surface element**, so a fork starts with zero overrides — every
763
+ per-client customization of the original section is silently gone on the new surface. **The fork-vs-share
764
+ decision should be driven by an override count**, taken before writing the seed.
765
+
766
+ Counting them is **not a join**: `Surfaces`/`SurfaceElements` are **Core** models
767
+ (`Model/Core/Surface.php`, `Model/Core/SurfaceElement.php` → `DB_CORE`) but `SurfaceOverrides` is a
768
+ **Client** model (`Model/Client/SurfaceOverride.php` → `DB_CLIENT`; there is no
769
+ `Model/Core/SurfaceOverride.php`). "How many clients override this element?" spans the prod-core and
770
+ prod-client **clusters**, so it must be two steps: resolve the element ids in Core, then count per
771
+ client DB using those ids as **literals**.
772
+
773
+ ### `Surfaces`/`SurfaceElements` ids are per-ENVIRONMENT AUTO_INCREMENT — re-verify before every run
774
+ House style seeds these with literal ids, but they are **not** among the two team-maintained-id tables
775
+ (`Records`/`RecordFields`), so the next free value **can differ per environment** and must be
776
+ re-verified before a migration runs anywhere new. On 2026-08-26 local and production happened to agree
777
+ exactly (`MAX(Surfaces.id)` 44, `MAX(SurfaceElements.id)` 160) — that was **verified, not assumed**.
778
+ Using literals is still correct: a collision then fails loudly on the primary key instead of silently
779
+ mis-wiring (which is what `INSERT IGNORE` did in the 2026-08-24 incident above).
780
+
733
781
  ## Change history
782
+ - 2026-08-26 — Seeded the **Service Request** record surfaces as a **fork** of the sales-order ones:
783
+ `Core/2026-08-26a` (Surfaces **45** / elements **161–165**, recordId 35) and `Core/2026-08-26b`
784
+ (Surfaces **46** / elements **166–169**). Recorded three durable rules: forking a surface **drops
785
+ every client `SurfaceOverride`** of the original, so fork-vs-share should be decided by an override
786
+ count; that count **cannot be a join** because `SurfaceOverrides` is a **CLIENT**-DB model while
787
+ `Surfaces`/`SurfaceElements` are **CORE**; and `Surfaces`/`SurfaceElements` ids are per-environment
788
+ `AUTO_INCREMENT` (not team-maintained), so the free block must be re-verified per environment —
789
+ local and prod agreed at 44/160 this time, verified not assumed. (apeterson)
734
790
  - 2026-08-25 — Added a **worked before/after cluster-isolation example** on a real client migration:
735
791
  `Client_Quad/2026-07-21b - ApproveDisabledTooltipPoNumber.sql` joins **`Core.Messages` to
736
792
  `Client_Quad.Languages`**, so it runs locally (all-in-one endpoint) and can **never** run on prod or
@@ -10,7 +10,7 @@
10
10
  | [Force Logout on Deployment (useDeploymentGuard)](features/force-logout-on-deployment.md) | On large deployments the backend bumps the Core parameter `META_LAST_REFRESH_DATETIME`. | toga25-supply/src/hooks/useDeploymentGuard.tsx, toga25-supply/src/App.tsx |
11
11
  | [Meta-Driven Page & Table Setup](features/meta-driven-table-data.md) | A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels, sections, ACL) and *table meta* (the columns/fields + table s | toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts, toga25-supply/src/hooks/useTablePageMeta.ts, toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts, toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts, toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts, toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts |
12
12
  | [Record Modals & Nested Tables](features/record-modals-and-nested-tables.md) | The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and `PrimaryTable*Layout`. | toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/layout/PrimaryTableServerLayout/PrimaryTableServerLayout.tsx, toga25-supply/src/layout/PrimaryTableServerLayout/types.ts, toga25-supply/src/layout/ItemRecordModalLayout/, toga25-supply/src/layout/SalesOrderRecordModalLayout/, toga25-supply/src/layout/SalesOrderItemsTableLayout/, toga25-supply/src/layout/ItemFulfillmentModal/, toga25-supply/src/layout/GenericNestedTables/GenericNestedTables.tsx, toga25-supply/src/layout/GenericNestedTables/GenericTableLayout.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/DEFAULT/inventoryGroupings.json, toga25-supply/src/hooks/useTableCellInteractions.ts, toga25-supply/src/hooks/useServerTableUrlState.ts, toga25-supply/src/layout/RecordApprovalModal/helpers/handleFormatApprovalWorkflowPayload.ts, toga25-supply/src/layout/RecordApprovalModal/api/approvalDecisionsApi.ts |
13
- | [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J | toga25-supply/src/App.tsx, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/surface/applyColSpan.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/sectionRenderers.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getVisibleSections.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderApprovalSummaryGrid.tsx, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderSummaryGrid.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getDetailSections.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderNotesSection.tsx, toga25-supply/src/pages/SalesOrders/helpers/cleanOrder.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/AdminNotesSection.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getAdminNotes.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/surface/resolveElementState.ts, toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.ts, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderRowRecordState.ts, toga25-supply/src/surface/actionRegistry.ts, toga25-supply/src/surface/componentRegistry.tsx, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/surface/SurfaceSection.tsx, toga25-supply/src/surface/resolve.ts, toga25-supply/src/surface/types.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/Login/LoginPage.tsx, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/view/SurfaceRowActions.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderVip.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/Items/ItemsPage.tsx, toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx, toga25-supply/src/pages/VendorItems/VendorItemsPage.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/surfaceBundleToItemFields.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx, toga25-supply/src/layout/ItemRecordModalLayout/ItemRecordModalLayout.tsx, toga25-supply/src/layout/ItemRecordModalLayout/components/ItemRecordView.tsx, toga25-supply/src/surface/useStatusColors.ts, toga25-supply/src/surface/SurfaceHeader.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/helpers/surfaceBundlesToDecisionFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/useApprovalModalViewModel.tsx |
13
+ | [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J | toga25-supply/src/App.tsx, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/surface/applyColSpan.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/sectionRenderers.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getVisibleSections.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderApprovalSummaryGrid.tsx, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/ServiceRequestRecordModalLayout.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/view/ServiceRequestsView.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/useServiceRequestRecordModalLayoutModel.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderSummaryGrid.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getDetailSections.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderNotesSection.tsx, toga25-supply/src/pages/SalesOrders/helpers/cleanOrder.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/AdminNotesSection.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getAdminNotes.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/surface/resolveElementState.ts, toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.ts, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderRowRecordState.ts, toga25-supply/src/surface/actionRegistry.ts, toga25-supply/src/surface/componentRegistry.tsx, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/surface/SurfaceSection.tsx, toga25-supply/src/surface/resolve.ts, toga25-supply/src/surface/types.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/Login/LoginPage.tsx, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/view/SurfaceRowActions.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderVip.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/Items/ItemsPage.tsx, toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx, toga25-supply/src/pages/VendorItems/VendorItemsPage.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/surfaceBundleToItemFields.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx, toga25-supply/src/layout/ItemRecordModalLayout/ItemRecordModalLayout.tsx, toga25-supply/src/layout/ItemRecordModalLayout/components/ItemRecordView.tsx, toga25-supply/src/surface/useStatusColors.ts, toga25-supply/src/surface/SurfaceHeader.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/helpers/surfaceBundlesToDecisionFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/useApprovalModalViewModel.tsx |
14
14
  | [Talos Integration (AppLayout host + adapter wiring)](features/talos-integration.md) | toga25-supply is the first host of the shared blox [Talos assistant](../../toga-blox/features/talos-assistant.md). | toga25-supply/src/layout/AppLayout/AppLayout.tsx, toga25-supply/src/components/Header/Header.tsx, toga25-supply/src/components/Header/Header.module.css, toga25-supply/src/index.css, toga25-supply/src/assets/talos-owl.png |
15
15
  | [AWS Amplify Multi-Environment Deployment](workflows/amplify-deployment.md) | How `toga25-supply` deploys to **all** of its environments on AWS Amplify from a **single shared `amplify.yml`**. | toga25-supply/amplify.yml, toga25-supply/src/api/api.ts, toga25-supply/src/hooks/useAuthenticationFlow.ts, toga25-supply/vite.config.ts, toga25-supply/package.json |
16
16
  | [Cypress Testing Harness (component + e2e)](workflows/cypress-testing.md) | The Cypress test harness for the `toga25-supply` frontend, bootstrapped from scratch (`cypress` was already a dependency but there was no config, no `cypress/` | toga25-supply/cypress.config.ts, toga25-supply/cypress/tsconfig.json, toga25-supply/cypress/support/component.tsx, toga25-supply/cypress/support/component-index.html, toga25-supply/cypress/support/e2e.ts, toga25-supply/cypress/support/commands.ts, toga25-supply/cypress/support/fixtures.ts, toga25-supply/cypress/support/mocks/useApprovalModalViewModel.ts, toga25-supply/cypress/component/SalesOrderApprovalModalsLayout.cy.tsx, toga25-supply/cypress/component/RecordApprovalModalLayout.cy.tsx, toga25-supply/cypress/component/EnterPoNumberModal.cy.tsx, toga25-supply/cypress/e2e/salesOrderApproval.cy.ts |
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-24
9
+ updated: 2026-08-26
10
10
  owners: [jcardinal, apeterson]
11
11
  files:
12
12
  - toga25-supply/src/App.tsx
@@ -17,6 +17,11 @@ files:
17
17
  - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderApprovalSummaryGrid.tsx
18
18
  - toga25-supply/src/surface/useFetchSurfaceMeta.ts
19
19
  - toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts
20
+ - toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/ServiceRequestRecordModalLayout.tsx
21
+ - toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/view/ServiceRequestsView.tsx
22
+ - toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/useServiceRequestRecordModalLayoutModel.tsx
23
+ - toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/FIELDS/apiFields.json
24
+ - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx
20
25
  - toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts
21
26
  - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx
22
27
  - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx
@@ -674,7 +679,75 @@ developer asked for the **whole component**, not just badges. The old
674
679
  - **`tsc` not yet run** this session (the private `@agilant/toga-blox` registry needs npm creds);
675
680
  treat type-checking as pending. Runtime `GET /v2/surfaces/{slug}/meta` also not yet exercised.
676
681
 
682
+ - **🚨 The Service Request modal renders the LINKED SALES ORDER, not the ServiceRequest record.**
683
+ `ServiceRequestRecordModalLayout.tsx` resolves the SR uuid to a SalesOrder uuid via
684
+ `SalesOrders.serviceRequestId` and fetches `/sales-orders/{uuid}`. So **any SR-specific field is one
685
+ hop further out**: the Type element was first seeded as `order.serviceRequestType.name`, which
686
+ resolves to nothing (SalesOrders has `serviceRequestId`, not `serviceRequestTypeId`) — the correct
687
+ path is `order.serviceRequest.serviceRequestType.name`. And **both halves are required**: the field
688
+ must ALSO be added to the modal's `apiFields.json` `fields` list or the API never returns it, and the
689
+ element renders blank with no error either way.
690
+ - **🚨 Header surfaces use `order.`-prefixed valueKeys; section surfaces use BARE keys.** The record
691
+ modal top bar builds its surface record as `{ order }`, so header elements read `order.dateOrder`,
692
+ `order._status`. Section surfaces (`order-details` etc.) resolve against the order **directly**, so
693
+ their keys are bare: `contact._name`, `_purchaseOrders`, `description`. The same field therefore has
694
+ two different valueKeys depending on which surface it lives in, and copying one into the other
695
+ renders blank with no error.
696
+
697
+ ## Service Request record modal — forked from the SalesOrder modal (2026-08-26)
698
+
699
+ The Service Request (SR) modal was split off the SalesOrder record modal rather than being made a
700
+ mode of it. Three seams carry the fork; nothing else in the surface plumbing changed.
701
+
702
+ **1. Its own view model.** `useServiceRequestRecordModalLayoutModel.tsx` is a fork of
703
+ `useSalesOrderRecordModalViewModel` that keeps the record fetch, items, bundle items, invoices,
704
+ shipments, surface sections and contact-VIP, and **drops** `useFetchApprovalStages` + the approvals
705
+ surface gate, `usePurchaseOrderDetails` and the `meta.surface.poNumber.isComplete` derivation, and
706
+ assignee-VIP enrichment. It uses a distinct React Query cache scope **`"service-request-modal"`** so
707
+ it can never clobber the SO modal's `"record-modal"` scope — the two modals fetch the *same* record
708
+ shape, so a shared key would cross-contaminate.
709
+
710
+ **2. Its own view.** `ServiceRequestsView.tsx` drops `SalesOrderApprovalModalsLayout`,
711
+ `EnterPoNumberModal`, `AdminNotesSection`, the `approvalActive` gate and the approval/standard
712
+ summary-grid ternary.
713
+
714
+ **3. A parameterized slug map (the FE decides which tenantFields slot a section fills).**
715
+ `surfaceBundleToTenantFields(surfaces, slugToKey = SECTION_SLUG_TO_KEY)` now takes the map as an
716
+ argument. `SERVICE_REQUEST_SECTION_SLUG_TO_KEY` maps `service-request-details` → the **same**
717
+ `orderDetails` slot, so a forked surface renders in the existing `SalesOrderSummaryGrid` with no grid
718
+ change. `SalesOrderTopBar` gained an optional `headerSurfaceSlug` prop (default
719
+ `sales-order-record-header`) so the SR view passes `service-request-record-header`.
720
+
721
+ > Interim seam, deliberately. Extracting the top bar into a generic `RecordModalTopBar` was explicitly
722
+ > deferred by the developer — do not "clean this up" unprompted.
723
+
724
+ ### Decision — FE-passed slug map, NOT a backend-declared `config.sectionKey`
725
+ Three options were weighed for how the FE maps a section surface to a tenantFields slot:
726
+ - **(a) derive the key from the slug** (kebab→camel) — rejected: breaks *silently* the moment slugs
727
+ diverge, which is exactly what forking does.
728
+ - **(b) key off `config.cardType`** — rejected: cardType is a *renderer* type shared by several
729
+ sections, so it cannot identify a slot.
730
+ - **(c) have each surface declare `config.sectionKey` and make the adapter generic** — drafted,
731
+ including a Core backfill migration for the 11 existing section surfaces, then **rejected by the
732
+ developer because of the backfill**. It remains the cleaner long-term design if the backfill is
733
+ ever accepted.
734
+
735
+ Final: the parameterized FE map above.
736
+
737
+ ### Open — the SR modal still resolves `sales-order-record-actions`
738
+ `SurfaceActionBar` in the SR modal still resolves the sales-order actions surface, so every action
739
+ button except **View Log** renders and does nothing on click. Whether the SR modal gets its own
740
+ actions surface is **still an open decision**, not an oversight.
741
+
677
742
  ## Change history
743
+ - 2026-08-26 — Forked the **Service Request record modal** off the SalesOrder modal: own view model
744
+ (`"service-request-modal"` query scope; no approvals/PO-details/assignee-VIP), own view (no approval
745
+ modals, EnterPoNumberModal or AdminNotesSection), and a parameterized
746
+ `surfaceBundleToTenantFields(surfaces, slugToKey)` + `SalesOrderTopBar.headerSurfaceSlug` so forked
747
+ surfaces render in the existing grid. Recorded the decision to use an FE-passed slug map over a
748
+ backend `config.sectionKey` (backfill rejected), and two gotchas: the SR modal actually renders the
749
+ **linked Sales Order**, and header vs section surfaces use `order.`-prefixed vs **bare** valueKeys.
750
+ SR modal still resolves `sales-order-record-actions` — open. (apeterson)
678
751
  - 2026-08-24 — **Corrected the surface-meta cache gotcha (supersedes the prior "a hard reload /
679
752
  re-login is required" wording, which was wrong enough to misdirect debugging).** The React Query
680
753
  cache is **persisted to `localStorage`**: `App.tsx` uses `PersistQueryClientProvider` +
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 67 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 69 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 57 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 11 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -5,5 +5,6 @@
5
5
  | [French (fr-CA) Item Feature Translations (Compass Canada)](features/french-item-feature-translations.md) | 2.0 | Renders item **feature** text on the Compass Canada French storefront — feature names, feature-group headers (e.g. | _underscore/Model/Compass/Canada/Feature.php, _underscore/Model/Compass/Canada/ItemCategoryFeatureGroup.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, worker2/Worker/Etilize/ItemTranslations.php, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql, dbchanges2/Client_CompassCanada/2026-07-13 - FeatureAttributeCustomFields.sql, dbchanges2/Client_CompassCanada/2026-07-13 - DedupeItemFeaturesAndGroups.sql, dbchanges2/Client_CompassCanada/2026-07-13 - SeedFrenchFeatureTranslations.sql |
6
6
  | [French (fr-CA) Order Email Localization (Compass Canada)](features/french-order-email-localization.md) | 2.0 | Compass Canada order emails (order requested, manager-approval request, approved/rejected, in-transit, delivered, reminders) are sent in **each recipient's own | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/ApprovalDecision.php, _underscore/Component/Api/Togaiq/Togaiq.php, _underscore/String.php, library/app/client/compasscanada.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/compass_email_reminders.php, worker/crons/toga2/compasscanada/compass_cancel_pending_approval_orders.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php, worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_email_previews_prod.php |
7
7
  | [Grand & Toy ASN Import (Compass Canada)](features/grand-and-toy-asn-import.md) | 2.0 | Imports Grand & Toy (G&T) Advance Shipping Notices for Compass Canada. | api2/Component/Api/Cxml/Cxml.php, worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php, worker/crons/toga2/compasscanada/workflow/import_grand_and_toy_asn_from_file.php, worker/crons/toga2/compasscanada/workflow_beta/import_grand_and_toy_asn_from_file.php, worker/schedules/cron.worker.sync.json, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Model/Compass/Canada/AdvanceShippingNotice.php, dbchanges2/Client_CompassCanada/ |
8
+ | [Compass Canada — Fulfilled vs Partially Fulfilled (per-line, ASN bridge only)](features/order-fulfillment-status-per-line.md) | 2.0 | TOGa Supply showed **Fulfilled** on Compass Canada orders that were only partly shipped. | _underscore/Model/Compass/Canada/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php |
8
9
  | [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. | |
9
10
  | [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 — used on 2026-08-20 to backfill the 67-day Grand & Toy outage (**518 shipments**: 171 via cXML r | worker/crons/toga2/compasscanada/workflow_beta/import_grand_and_toy_asn_from_file.php, api2/Component/Api/Cxml/Cxml.php, _underscore/Model/Compass/AdvanceShippingNotice.php |
@@ -0,0 +1,130 @@
1
+ ---
2
+ title: Compass Canada — Fulfilled vs Partially Fulfilled (per-line, ASN bridge only)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: compass-canada
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-26
10
+ owners: ["bala"]
11
+ files:
12
+ - _underscore/Model/Compass/Canada/SalesOrder.php
13
+ - _underscore/Model/Compass/SalesOrder.php
14
+ related:
15
+ - ../../compass-usa/features/order-fulfillment-status-per-line.md
16
+ - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
17
+ - ../../../2.0/apps/_underscore/features/calculated-sql-fields.md
18
+ - grand-and-toy-asn-import.md
19
+ - ../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ TOGa Supply showed **Fulfilled** on Compass Canada orders that were only partly shipped. Reported on
25
+ `SAC100664` (`SalesOrders.id 665`): line 1 the ZBOOK `C40QYUC#ABA` ordered 1 / shipped 0, line 2 the
26
+ mouse `9VA80AA#ABA` ordered 1 / shipped 1, and the badge read **Fulfilled**.
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
42
+ > [the USA doc](../../compass-usa/features/order-fulfillment-status-per-line.md).
43
+
44
+ ## Key files / entry points
45
+
46
+ - **`_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.
51
+
52
+ ## How it works
53
+
54
+ ```
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
57
+ fulfilled otherwise
58
+ ```
59
+
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.
65
+
66
+ ### The per-line shipped source (ASN bridge, and only the ASN bridge)
67
+
68
+ ```
69
+ AdvanceShippingNoticeItems.purchaseOrderItemId
70
+ -> SalesOrderItems_PurchaseOrderItems.purchaseOrderItemId
71
+ -> SalesOrderItems_PurchaseOrderItems.salesOrderItemId
72
+ ```
73
+
74
+ This mapping is **100% complete in `Client_CompassCanada`**: all **947** ASN items have a
75
+ `purchaseOrderItemId` and every one resolves to a sales-order line. Both join columns are indexed.
76
+ 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.
79
+
80
+ ## Design decisions worth keeping
81
+
82
+ - **⚠ Do NOT switch Canada to the local `ItemFulfillments` / `ItemFulfillmentItems` tables**, even
83
+ though the line-level `_qtyFulfilled` UI column does read them. They are **incomplete** in Canada:
84
+ **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.
89
+ - **Canada deliberately omits any Compass-vendor exclusion.** `VENDOR_ID__COMPASS = 26` on the parent
90
+ 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.
95
+
96
+ ## Gotchas / known issues
97
+
98
+ - **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
100
+ (`worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php`) keeps its **own
101
+ duplicated copy** of the `_status` CASE, but it only ever produces canceled /
102
+ pendingApprovalUnknown / pendingFulfillment / shipped and filters on
103
+ `computedStatusSlug = 'shipped'` — and the pendingFulfillment gate was left byte-identical, so it
104
+ is unaffected.
105
+ - **⚠ Canada's order status is no longer identical to Compass USA.** Older notes (and the profile)
106
+ 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.**
108
+ - **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.
112
+
113
+ ## 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)
124
+
125
+ ## 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.
128
+ - [Grand & Toy ASN Import](grand-and-toy-asn-import.md) — where Canada's ASN rows come from.
129
+ - [IF Stage Lifecycle & Order Status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md)
130
+ — the shipped-only stage policy shared by both Compass regions.
@@ -16,7 +16,7 @@ project: _Underscore
16
16
  client: compass-canada
17
17
  type: profile
18
18
  status: active
19
- updated: 2026-08-20
19
+ updated: 2026-08-26
20
20
  owners: [jcardinal, bala, tcox, apeterson]
21
21
  files: []
22
22
  related:
@@ -29,6 +29,7 @@ related:
29
29
  - ../compass-usa/features/people-file-user-lifecycle.md
30
30
  - ../compass-usa/features/isfulfillable-data-quality-and-type-rule.md
31
31
  - features/french-order-email-localization.md
32
+ - features/order-fulfillment-status-per-line.md
32
33
  - ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
33
34
  - ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
34
35
  - ../../2.0/apps/_underscore/features/surface-resolver.md
@@ -143,10 +144,19 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
143
144
  207 → 1. See
144
145
  [isFulfillable — Data Quality & the Type-Derived Rule](../compass-usa/features/isfulfillable-data-quality-and-type-rule.md).
145
146
  - The 2026-06-08 ASN → ItemFulfillment work was for **Compass USA**, not Compass Canada.
146
- - **Order status is shipped-only**, same as Compass USA (shared `_Model_Compass_SalesOrder`).
147
- Compass Canada's own IF lifecycle stages (picked/packed/shipped) + shipped backfill are seeded by
147
+ - **Order status is shipped-only** (shared `_Model_Compass_SalesOrder`). Compass Canada's own IF
148
+ lifecycle stages (picked/packed/shipped) + shipped backfill are seeded by
148
149
  `dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql`.
149
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
159
+ [Fulfilled vs Partially Fulfilled](features/order-fulfillment-status-per-line.md).
150
160
  - **Item titles in customer emails** come from the `ItemTranslations` sidecar (prod: 220 fr-CA rows,
151
161
  100% coverage of every item ever ordered). Resolve the English/translated fallback **in PHP**, never
152
162
  as a SQL `COALESCE` — the two columns' collations differ and differ *per client DB*.
@@ -14,6 +14,7 @@
14
14
  | [Compass MR/MA Order Auto-Approval & Status Gate](features/mr-ma-order-approval-and-status.md) | 2.0 | Compass **MR** and **MA** sales orders are system-generated from the MITS / Office Depot EDI pipeline (they do not originate as user-entered SA orders) and must | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
15
15
  | [Compass ODP EDI 850 Line-Item Resolution (VA part number to IN SKU fallback)](features/odp-edi-850-item-resolution.md) | 1.0 | How each **PO1 line** on an inbound Office Depot (ODP) **EDI 850** is resolved to a real `Client_Compass` catalog item before cron `3a_import_office_depot_purch | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, library/app/Edi.php |
16
16
  | [Compass Office Depot EDI 855 Acknowledgement + Over-Quantity PO Guard](features/odp-edi-855-acknowledgement-and-overquantity-guard.md) | 1.0 | How Compass acknowledges Office Depot (ODP) inbound **EDI 850** purchase orders with an **X12 855**, and the **over-quantity guard** that rejects a duplicate PO | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/4_transmit_office_depot_po_acknowledgements.php, library/app/edi.php |
17
+ | [Compass 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
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 |
18
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 |
19
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 |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-05
9
+ updated: 2026-08-26
10
10
  owners: ["rgirish", "bala"]
11
11
  files:
12
12
  - _underscore/Model/Compass/SalesOrder.php
@@ -14,6 +14,7 @@ files:
14
14
  - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
15
15
  related:
16
16
  - mits-po-to-so-item-linking.md
17
+ - order-fulfillment-status-per-line.md
17
18
  - ../workflows/odp-order-pipeline-to-netsuite.md
18
19
  - ../workflows/order-lifecycle-and-data-integrity.md
19
20
  - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
@@ -32,9 +33,11 @@ source (auto-approval + item population), **not** in the status SQL.
32
33
 
33
34
  ## Key files / entry points
34
35
  - **`_underscore/Model/Compass/SalesOrder.php`**
35
- - `_status` calculated field — approval gate runs before the shipped-only fulfillment machine
36
- (see [IF stage lifecycle & order status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md)
37
- for the fulfillment half).
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
39
+ [Fulfilled vs Partially Fulfilled](order-fulfillment-status-per-line.md) and
40
+ [IF stage lifecycle & order status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
38
41
  - `postPost` — the `if ($isMrOrder)` block loops `foreach ([1,2] as $step)` and issues two
39
42
  `internalApiRequest('POST', '/approval-decisions', …)` with `isApproved=true`,
40
43
  note `'Auto-approved: MR order'`. **Went live 2026-06-03.**
@@ -120,6 +123,10 @@ Safe scoping baked in — **decisions only, never Approvals**:
120
123
  from the sibling ODP SO first).
121
124
 
122
125
  ## Change history
126
+ - 2026-08-26 — Noted that `_status()`'s fulfillment half moved into `_fulfillmentStatus()` (per-region
127
+ seam); the approval gate this page documents is untouched, and the "do not fix stuck MR/MA orders in
128
+ `_status`" ruling still stands — the stuck orders are empty, so no fulfillment rule helps them.
129
+ (bala)
123
130
  - 2026-08-05 — Recorded an **open, unfixed** defect in the MR auto-approval path: the
124
131
  `ApprovalTemplateStage`-not-found `throw new _Exception(...)` passes 1 argument to a 5-argument
125
132
  constructor, so that failure raises `ArgumentCountError` and loses its own diagnostic message.
@@ -0,0 +1,181 @@
1
+ ---
2
+ title: Compass USA — Fulfilled vs Partially Fulfilled (order aggregate AND per-line)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: compass-usa
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-26
10
+ owners: ["bala"]
11
+ files:
12
+ - _underscore/Model/Compass/SalesOrder.php
13
+ - _underscore/Model/Compass/Canada/SalesOrder.php
14
+ related:
15
+ - ../../compass-canada/features/order-fulfillment-status-per-line.md
16
+ - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
17
+ - ../../../2.0/apps/_underscore/features/calculated-sql-fields.md
18
+ - ../../../2.0/apps/_underscore/features/sales-order-status-filter-surface.md
19
+ - mr-ma-order-approval-and-status.md
20
+ - asn-to-item-fulfillment.md
21
+ - ../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md
22
+ ---
23
+
24
+ ## Summary
25
+
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.
42
+
43
+ ## Key files / entry points
44
+
45
+ - **`_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.
54
+ - Front end (labels only): toga2-supply `FILTERFIELDS.ts` (user-selectable status filter) and
55
+ `renderBadge.tsx` (badge text).
56
+
57
+ ## How it works
58
+
59
+ ### The seam: `_status()` vs `_fulfillmentStatus()`
60
+
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.**
67
+
68
+ ### pendingFulfillment (byte-identical to the old logic)
69
+
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*).
73
+
74
+ ### Fulfilled now requires two independent conditions
75
+
76
+ ```
77
+ partiallyFulfilled WHEN NOT ( legacyAggregateCondition ) OR ( any purchased line is short )
78
+ fulfilled otherwise
79
+ ```
80
+
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())`.
84
+
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.**
89
+
90
+ ### Telling a shippable line from a service line
91
+
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`.
94
+
95
+ **Neither `price > 0` nor `Items.inventoryType` separates shippable from non-shippable** — HYBRID
96
+ dominates both groups. Verified on prod: `PC-KIT-OPT2`, `Tech Support`, `LEASE REFRESH`,
97
+ `LT-FREIGHT`, `LT-PROPERTY TAX`, `LTX-SALES TAX`, `LT-HPWARRANTY`, `TabAdminSupp`,
98
+ `CG-MEDKITTING-NWN-APPLE` and `ODP-COMPASS_MERAKI` have **zero** PO links, while real hardware does
99
+ (`C40QYUC`: 1,208 of 1,362 lines; `MD4A4LL/A-S`: 130 of 137).
100
+
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.
103
+
104
+ ## Gotchas / known issues
105
+
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.
134
+
135
+ ## Blast radius — fulfilled vs partiallyFulfilled is display-only
136
+
137
+ Every consumer was audited before shipping, which is why this carries no downstream risk:
138
+
139
+ - **The status is computed live and never stored.** No backfill; reverting the code fully reverts the
140
+ behaviour.
141
+ - **In-transit / delivered email crons are unaffected.**
142
+ `worker/crons/toga2/compass/update_salesorder_status_from_odp.php`,
143
+ `worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php` and
144
+ `compass/workflow/test_partial_in_transit_email.php` each keep their **own duplicated copy** of the
145
+ `_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
149
+ [in-transit email doc](../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md).
150
+ - **The MA sales-order exception report**
151
+ (`compass/workflow/7_generate_ma_sales_order_exception_report.php`) filters the **stored**
152
+ `salesOrderStageId` column, not the computed status.
153
+ - **toga2-supply** sends only the approvals filter values (`pendingInitialApproval` /
154
+ `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.
161
+
162
+ ## 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)
173
+
174
+ ## Related docs
175
+ - [Compass Canada — per-line, ASN-only rule](../../compass-canada/features/order-fulfillment-status-per-line.md)
176
+ - [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
+ - [FIELD_SQL calculated fields](../../../2.0/apps/_underscore/features/calculated-sql-fields.md)
179
+ — 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.
@@ -30,6 +30,7 @@ related:
30
30
  - workflows/granting-persona-bundle-access.md
31
31
  - features/mits-sales-order-transmission-alerting.md
32
32
  - features/asn-to-item-fulfillment.md
33
+ - features/order-fulfillment-status-per-line.md
33
34
  - features/cost-centers.md
34
35
  - features/isfulfillable-data-quality-and-type-rule.md
35
36
  - features/approval-decision-flow.md
@@ -163,6 +164,15 @@ separate, related client (see its own profile).
163
164
  shared by Compass USA + Canada) counts **shipped only** — picked/packed never advance a Compass
164
165
  order. Contrast Quad, which gets the full picked/packed machine. See
165
166
  [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
167
+ - **Fulfilled requires the order aggregate AND every purchased line (2026-08-26).** The old
168
+ order-level ordered-total vs shipped-total comparison let one line's surplus hide another line's
169
+ gap, so 5,234 prod orders read **Fulfilled** while partly shipped. `_status()`'s fulfillment branch
170
+ now lives in `_fulfillmentStatus()` and requires the legacy aggregate **plus** no short purchased
171
+ line — strictly corrective, it can only downgrade. **⚠ Do not "simplify" it to Compass Canada's
172
+ clean per-line rule:** many USA priced hardware lines have no PO-item link, so a pure per-line rule
173
+ passes vacuously and would create 3,727 *new* false Fulfilleds. Full evidence, the shippable-line
174
+ test, and the ASN attribution limits:
175
+ [Fulfilled vs Partially Fulfilled](features/order-fulfillment-status-per-line.md).
166
176
  - Built on the shared 2.0 Recursive Item Fulfillments engine (upstream mirroring).
167
177
  - **`Items.isFulfillable` gates the storefront Qty Fulfilled cell (2026-07).** Sourced from
168
178
  NetSuite onto the Agilant source item during the 1.0 item sync
@@ -6,6 +6,7 @@ apps:
6
6
  - worker2
7
7
  - library
8
8
  - toga2-supply
9
+ - toga25-supply
9
10
  - dbchanges2
10
11
  - _underscore
11
12
  - api2
@@ -15,7 +16,7 @@ project: Worker
15
16
  client: elite
16
17
  type: profile
17
18
  status: active
18
- updated: 2026-08-20
19
+ updated: 2026-08-26
19
20
  owners: [snaredla, apeterson, tcox, bala]
20
21
  files:
21
22
  - worker2/Worker/Elite.php
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.658",
3
+ "version": "1.0.660",
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",