toga-ai 1.0.657 → 1.0.659

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.
@@ -3,6 +3,7 @@
3
3
  | Doc | Summary | Files |
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
+ | [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 |
6
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
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/ |
8
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 |
@@ -0,0 +1,139 @@
1
+ ---
2
+ title: Re-runnable additive INSERTs (uuid4 in SQL, guards, and the DISTINCT trap)
3
+ framework: "2.0"
4
+ repo: dbchanges2
5
+ project: Database Changes
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-26
10
+ owners: [bala]
11
+ files:
12
+ - dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
13
+ related:
14
+ - ../architecture.md
15
+ - ../../_underscore/features/acl-permission-chain.md
16
+ - ../../_underscore/features/tracking-number-bridges.md
17
+ - ../../../../clients/compass-usa/workflows/granting-persona-bundle-access.md
18
+ ---
19
+
20
+ ## Summary
21
+
22
+ Most `dbchanges2` files are **additive data grants** run by hand against production, often more than
23
+ once (once per environment, or twice because someone was not sure it took). Two mechanics decide
24
+ whether that is safe: how you generate the `uuid` every 2.0 table demands, and how you guard the
25
+ insert. Both have a trap that fails **silently** rather than erroring.
26
+
27
+ Every 2.0 table carries a `uuid` column that is `char(36) NOT NULL` **with no default** — the model
28
+ layer fills it in application code, so a hand-written `INSERT` must supply it itself.
29
+
30
+ ## How it works
31
+
32
+ ### Generating a real UUID4 inline
33
+
34
+ The standard is "UUID4, random — never MySQL `UUID()`" (`UUID()` is v1: MAC address + timestamp, and
35
+ sequential). MySQL 8's `RANDOM_BYTES()` gives a genuinely random, correctly **version-4-shaped** value
36
+ in one expression:
37
+
38
+ ```sql
39
+ LOWER(CONCAT(HEX(RANDOM_BYTES(4)), '-', HEX(RANDOM_BYTES(2)), '-4', RIGHT(HEX(RANDOM_BYTES(2)), 3), '-', SUBSTRING('89ab', FLOOR(1 + RAND() * 4), 1), RIGHT(HEX(RANDOM_BYTES(2)), 3), '-', HEX(RANDOM_BYTES(6))))
40
+ ```
41
+
42
+ The two literals are what make it v4 rather than just random hex: the `'-4'` pins the version nibble,
43
+ and `SUBSTRING('89ab', …)` picks the variant nibble. Verified working on prod MySQL **8.0.39**.
44
+
45
+ **Existing rows are not the pattern to copy.** Plenty of live rows across these tables are random hex
46
+ that is *not* v4-shaped (older migrations concatenated `RANDOM_BYTES` without the version/variant
47
+ nibbles). They are fine and must not be "fixed" — but do not use them as the template for new SQL.
48
+
49
+ ### ⚠ `DISTINCT` cannot dedupe a row that carries a random uuid
50
+
51
+ This is the silent one. In an `INSERT … SELECT`, putting the uuid expression inside a
52
+ `SELECT DISTINCT` **disables the dedupe entirely** — the per-row random uuid makes every candidate
53
+ row unique, so `DISTINCT` has nothing to collapse and every duplicate id passes through.
54
+
55
+ ```sql
56
+ # WRONG — DISTINCT never removes anything; the uuid differs on every row
57
+ INSERT INTO Personas_Items (uuid, personaId, itemId)
58
+ SELECT DISTINCT
59
+ LOWER(CONCAT(HEX(RANDOM_BYTES(4)), …)),
60
+ @personaId,
61
+ bi.itemId
62
+ FROM BundleItems bi
63
+ INNER JOIN Bundles b ON b.id = bi.bundleId
64
+ WHERE b.number = '243';
65
+
66
+ # CORRECT — dedupe the id set in a derived table, generate the uuid in the OUTER select
67
+ INSERT INTO Personas_Items (uuid, personaId, itemId)
68
+ SELECT
69
+ LOWER(CONCAT(HEX(RANDOM_BYTES(4)), …)),
70
+ @personaId,
71
+ src.itemId
72
+ FROM (
73
+ SELECT DISTINCT bi.itemId
74
+ FROM BundleItems bi
75
+ INNER JOIN Bundles b ON b.id = bi.bundleId
76
+ WHERE
77
+ b.number = '243' AND
78
+ bi.isActive = 1 AND
79
+ bi.itemId IS NOT NULL
80
+ ) src;
81
+ ```
82
+
83
+ The same shape is the fix for the self-referencing `INSERT … NOT EXISTS` error 1093 (MySQL will not
84
+ let you read the table you are inserting into) — wrap the read in a derived table.
85
+
86
+ ### Guarding the insert: know whether the table has a unique key
87
+
88
+ Whether a re-run is loud or silent depends entirely on the target table's indexes, and bridge tables
89
+ in 2.0 are **inconsistent** about this — some have a composite unique key, some do not. Check
90
+ before you write, not after.
91
+
92
+ - **Unique key present** — a re-run errors. Loud, recoverable, but it aborts the rest of the file.
93
+ Add a guard if you want a clean no-op.
94
+ - **No unique key** — a re-run **silently duplicates every row**, and nothing tells you. Always guard
95
+ these.
96
+
97
+ Both guard shapes are in use and equivalent:
98
+
99
+ ```sql
100
+ # LEFT JOIN … IS NULL
101
+ FROM Users u
102
+ INNER JOIN Roles r ON r.name = 'SuperUser'
103
+ LEFT JOIN Users_Roles ur ON ur.userId = u.id AND ur.roleId = r.id
104
+ WHERE
105
+ u.email = '…' AND
106
+ ur.id IS NULL;
107
+ ```
108
+
109
+ **A guarded insert that inserts zero rows is a success, not a wasted file** — see
110
+ [Client-DB grant migrations are re-runnable NO-OPs](../../_underscore/features/acl-permission-chain.md).
111
+
112
+ ### `LAST_INSERT_ID()` into a session variable ties the whole file to one session
113
+
114
+ The common shape — `INSERT` a parent, `SET @parentId = LAST_INSERT_ID();`, then insert children
115
+ against `@parentId` — means **every phase must run in the same connection**. Reconnecting between
116
+ phases leaves `@parentId` `NULL` and the child inserts either fail on the FK or write `NULL`. Say so
117
+ in the file header, and make the unguarded parent insert the one phase you skip on a re-run (set
118
+ `@parentId` to the existing row by hand instead).
119
+
120
+ ## Gotchas
121
+
122
+ - **A "re-run safe" file is usually only re-run safe *per phase*.** If Phase 1 creates the parent row
123
+ with no `NOT EXISTS` guard, re-running the *whole file* creates a **second parent** and then hangs
124
+ a full set of children off it. Re-run safety of the child phases does not make the file idempotent.
125
+ - **`number` columns are strings.** `Bundles.number`, `Personas.number` and friends are varchar, not
126
+ int — quote them (`WHERE b.number = '243'`) and `CAST(… AS UNSIGNED)` when you need numeric sort
127
+ order. An unquoted comparison forces a string→number coercion that will not use the index.
128
+ - **`Client_*` files may not reach into `Core`.** Unrelated to uuids but it bites the same kind of
129
+ file: separate production clusters mean a `Core.` reference is unrunnable in prod even though it
130
+ works locally. Hardcode `Core.Records` / `Core.RecordFields` ids as literals — see
131
+ [dbchanges2 architecture](../architecture.md).
132
+
133
+ ## Change history
134
+ - 2026-08-26 — Initial: recorded the inline **UUID4** expression built on MySQL 8 `RANDOM_BYTES()`
135
+ (version/variant nibbles pinned; verified on prod 8.0.39) and the silent
136
+ **`SELECT DISTINCT` + random-uuid** trap that lets duplicate rows through — dedupe the id set in a
137
+ derived table and generate the uuid in the outer select. Added the unique-key/guard rule for bridge
138
+ tables (no unique key = silent duplicates on re-run) and the `LAST_INSERT_ID()` one-session
139
+ constraint. Extracted from the Compass Creative Studio persona grant. (bala)
@@ -18,10 +18,10 @@ _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
- - **dbchanges2** (Database Changes) _(framework core)_ — 9 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
+ - **dbchanges2** (Database Changes) _(framework core)_ — 11 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
25
  - **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
26
26
  - **saml** (SAML SSO Gateway) — 4 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
27
27
  - **toga2-view** (TOGa View Frontend) — 12 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/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,12 +14,14 @@
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 |
20
21
  | [Stranded Approval Reassignment (repointing approvals off dead duplicate Compass Users rows)](features/stranded-approval-reassignment.md) | 2.0 | A Compass employee who leaves and comes back after more than `CONTACT_UNLINK_GRACE_DAYS` (5) is **inserted as a brand-new `Users` row** by the PEOPLE importer i | _underscore/Model/Compass/ApprovalDecision.php, worker2/Worker/Client/Compass/ApprovalReassignment.php, worker2/Worker/Client/Compass/PeopleFile.php |
21
22
  | [Compass USA](profile.md) | 2.0 | Compass USA is a TOGA client running a multi-tier supply-chain commerce operation. | |
22
23
  | [Compass Cross-Kit Bundle Corruption — Detection & Repair](workflows/cross-kit-bundle-corruption.md) | 2.0 | A frontend regression in `toga2-commerce`'s edit-order bundle submission mis-attributed bundle (kit) line items and **fees/warranties** to the **wrong kit**, pe | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts |
24
+ | [Granting a Compass user access to a bundle (persona grant) and the SuperUser role](workflows/granting-persona-bundle-access.md) | 2.0 | "Give user X sight of kit N" is a **recurring** Compass request, usually paired with "and make them a super user". | dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql |
23
25
  | [Compass Office Depot Duplicate PO-Line Cleanup (dual-catalog SKU)](workflows/odp-duplicate-po-line-cleanup.md) | 1.0 | The NetSuite fulfillment-sales-order importer duplicated Office Depot purchase-order lines on Compass USA because it reconciled the fulfillment SO against the e | library/app/api/toga2.php, dbchanges2/Client_Compass/2026-07-22a - CleanupOfficeDepotDuplicatePurchaseOrderItems.sql |
24
26
  | [Recovering a Lost Compass ODP EDI 850 Import (re-drop from Logs.FileLog)](workflows/odp-edi-import-recovery.md) | 1.0 | How to recover a Compass **Office Depot EDI 850** import that failed partway — the case where cron **3a** created the ODP SalesOrder header, the follow-up item | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, worker/schedules/cron.worker.sync.json |
25
27
  | [Compass ODP Order Pipeline to NetSuite (numbered worker crons)](workflows/odp-order-pipeline-to-netsuite.md) | 1.0 | The end-to-end **Compass Office Depot (ODP) order → NetSuite** pipeline as it actually runs through the 1.0 `worker` crons under `worker/crons/toga2/compass/`, | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, worker/crons/toga2/compass/edi/1_download_edi_s3_create_po_toga.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/4_transmit_office_depot_po_acknowledgements.php, worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, worker/schedules/cron.worker.sync.json, library/app/client/compass.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.
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-13
9
+ updated: 2026-08-26
10
10
  owners: [bala]
11
11
  files:
12
12
  - worker2/Worker/Client/Compass/PeopleFile.php
@@ -15,6 +15,7 @@ related:
15
15
  - ../profile.md
16
16
  - ../workflows/persona-refactor-migration.md
17
17
  - ../workflows/persona-population-env-comparison.md
18
+ - ../workflows/granting-persona-bundle-access.md
18
19
  - ../../compass-canada/profile.md
19
20
  ---
20
21
 
@@ -40,16 +41,25 @@ below.
40
41
 
41
42
  | Persona | id | Meaning |
42
43
  |---|---|---|
43
- | Base (was "All") | **1** | Printers only, after the refactor. Every US user holds it. |
44
+ | Base (was "All") | **1** | Printers only, after the refactor. Every US user holds it. **Still named "All" in prod** — the refactor is not live there (verified 2026-08-26). |
44
45
  | Levy | **24** | Levy-sector catalogue. |
45
- | Non-Levy | **resolved by name** — 38 on dev-sandbox *and* prod | Everything that is not a printer. Granted to non-Levy users only. |
46
+ | Non-Levy | **resolved by name** — **38 on dev-sandbox only; does not exist in prod** | Everything that is not a printer. Granted to non-Levy users only. |
46
47
  | VIP | 35 | VIP add-on. |
47
48
  | CDL (MacBook / Surface) | 30 | `@compassdigital.io` + VIP users. |
48
49
 
49
- **Never hardcode the Non-Levy id.** `Personas.AUTO_INCREMENT` is 38 on both dev-sandbox and prod
50
- (max existing id 37), so the migration's `number = '40'` yields **id 38**, not 40. The original
51
- plan and the (now closed) worker2 PR both hardcoded **40** and would have written to the wrong
52
- persona. `PeopleFile` resolves it at runtime:
50
+ **Never hardcode the Non-Levy id.** On dev-sandbox `Personas.AUTO_INCREMENT` was 38 (max existing id
51
+ 37), so the migration's `number = '40'` yielded **id 38**, not 40. The original plan and the (now
52
+ closed) worker2 PR both hardcoded **40** and would have written to the wrong persona.
53
+
54
+ > **⚠ Correction (verified read-only against prod, 2026-08-26): "id 38" was never a prod fact, and is
55
+ > now definitively wrong there.** Prod has **no Non-Levy persona**, and `Personas.id` **38** is
56
+ > **"MyDining- KDS"** — prod's auto-increment moved past 38 after 2026-08-06. Persona **number 40** is
57
+ > taken by that same row. Whenever the migration does run in prod the persona will land on some higher
58
+ > id, which is precisely what name-resolution protects against. Current prod number map and the
59
+ > knock-on effect on the migration's Phase 1:
60
+ > [Persona Refactor](../workflows/persona-refactor-migration.md).
61
+
62
+ `PeopleFile` resolves it at runtime:
53
63
 
54
64
  ```php
55
65
  const PERSONA_NAME_US_NON_LEVY = 'Non-Levy';
@@ -128,6 +138,14 @@ VIP (35) and CDL (30) handling is orthogonal and applies to both tenants unchang
128
138
  constants from this file into tickets, docs, or chat transcripts.
129
139
 
130
140
  ## Change history
141
+ - 2026-08-26 — **Correction from a read-only prod check.** The "Non-Levy = id **38** on dev-sandbox
142
+ *and* prod" claim is wrong: prod has **no Non-Levy persona** and id 38 is **"MyDining- KDS"**, so
143
+ prod's auto-increment is past 38 and persona 1 is still named **"All"**. Reinforces resolve-by-name.
144
+ Also linked the new [bundle-grant workflow](../workflows/granting-persona-bundle-access.md), which
145
+ covers the *other* half of the persona model this doc does not: what a persona grant physically
146
+ consists of (`Personas_Bundles` + **`Personas_Items`** + `Personas_Assortments`) and that the
147
+ effective persona set is a UNION of the user's own personas, their location's, and their sector's.
148
+ (bala)
131
149
  - 2026-08-13 — Split the `Users` row lifecycle out of this doc into
132
150
  [PEOPLE-file User Lifecycle](./people-file-user-lifecycle.md) (duplicate accounts from the 5-day
133
151
  lookup window; raw-SQL deactivation that fires no model hook). This doc stays scoped to persona /
@@ -18,7 +18,7 @@ project: _Underscore
18
18
  client: compass-usa
19
19
  type: profile
20
20
  status: active
21
- updated: 2026-08-25
21
+ updated: 2026-08-26
22
22
  owners: [jcardinal, bala, tcox, apeterson, dfranks]
23
23
  files: []
24
24
  related:
@@ -27,8 +27,10 @@ related:
27
27
  - features/people-file-user-lifecycle.md
28
28
  - workflows/persona-refactor-migration.md
29
29
  - workflows/persona-population-env-comparison.md
30
+ - workflows/granting-persona-bundle-access.md
30
31
  - features/mits-sales-order-transmission-alerting.md
31
32
  - features/asn-to-item-fulfillment.md
33
+ - features/order-fulfillment-status-per-line.md
32
34
  - features/cost-centers.md
33
35
  - features/isfulfillable-data-quality-and-type-rule.md
34
36
  - features/approval-decision-flow.md
@@ -132,8 +134,17 @@ separate, related client (see its own profile).
132
134
  users got the full catalogue). Never trust pre-2026-08 Levy persona state as intentional.
133
135
  - [Persona Refactor — 8-phase migration & deploy order](workflows/persona-refactor-migration.md)
134
136
  (TRUE-75705) — splits persona 1 into Base (printers, everyone) + Non-Levy. **Run on dev-sandbox
135
- only; production is NOT migrated.** worker2 must deploy first; Phase 8 (ACL expression 235) ships
136
- with the toga2-commerce hack removal.
137
+ only; production is NOT migrated** (re-verified 2026-08-26: prod persona 1 is still "All", there is
138
+ no Non-Levy persona, and id 38 is now "MyDining- KDS"). worker2 must deploy first; Phase 8 (ACL
139
+ expression 235) ships with the toga2-commerce hack removal.
140
+ - [Granting a user access to a bundle (persona grant) + SuperUser](workflows/granting-persona-bundle-access.md)
141
+ — the recurring "give user X sight of kit N" request. **Five tables, not one**: a bundle grant
142
+ without `Personas_Items` renders a **half-visible kit**, because item-level ACL hides kit lines
143
+ whose item the persona cannot see. Also: a Compass persona needs **no** settings rows (all nine
144
+ persona-settings tables are empty across all 38 prod personas), "bundle 243" is `Bundles.number`
145
+ not `id`, and **`Users` has no `username` column** — it is `c_hrEmpUsername`, stored lowercase.
146
+ Super user = `Roles.id 5` added **additively** via `Users_Roles`, never replacing Base /
147
+ Compass Base.
137
148
  - [Prod ↔ dev-sandbox persona comparison](workflows/persona-population-env-comparison.md) — the
138
149
  full-population diff technique, the **join-on-email** rule (`Users.id` means different people per
139
150
  environment), and the drift bundles (188/191/242) that mimic a catalogue grant.
@@ -153,6 +164,15 @@ separate, related client (see its own profile).
153
164
  shared by Compass USA + Canada) counts **shipped only** — picked/packed never advance a Compass
154
165
  order. Contrast Quad, which gets the full picked/packed machine. See
155
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).
156
176
  - Built on the shared 2.0 Recursive Item Fulfillments engine (upstream mirroring).
157
177
  - **`Items.isFulfillable` gates the storefront Qty Fulfilled cell (2026-07).** Sourced from
158
178
  NetSuite onto the Agilant source item during the 1.0 item sync
@@ -0,0 +1,163 @@
1
+ ---
2
+ title: Granting a Compass user access to a bundle (persona grant) and the SuperUser role
3
+ framework: "2.0"
4
+ repo: dbchanges2
5
+ project: Database Changes
6
+ client: compass-usa
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-08-26
10
+ owners: [bala]
11
+ files:
12
+ - dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
13
+ related:
14
+ - ../features/persona-model-and-levy-gating.md
15
+ - ./persona-refactor-migration.md
16
+ - ./persona-population-env-comparison.md
17
+ - ../profile.md
18
+ - ../../../2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md
19
+ - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ "Give user X sight of kit N" is a **recurring** Compass request, usually paired with "and make them a
25
+ super user". It looks like a one-row insert into `Personas_Bundles`. It is not — a bundle grant
26
+ without the matching **item** grant renders a **half-visible kit**, because item-level ACL hides any
27
+ kit line whose item the persona cannot see.
28
+
29
+ A complete grant touches **five tables**. `dbchanges2/Client_Compass/2026-08-26a -
30
+ CompassCreativeStudioPersona.sql` is the working template: a 6-phase additive script that creates the
31
+ persona "Compass Creative Studio" (number 41), grants bundle 243, its 19 items and the 6 assortments
32
+ those items live in, assigns the persona to one user, and adds SuperUser.
33
+
34
+ All facts below were verified read-only against **production `Client_Compass` on 2026-08-26**.
35
+
36
+ ## The five tables
37
+
38
+ | Table | Grants | Composite unique key? | Optional? |
39
+ |---|---|---|---|
40
+ | `Personas` (`id`, `uuid`, `name`, `number`) | the persona itself | — | no |
41
+ | `Personas_Bundles` (`personaId`, `bundleId`) | the kit appears | **yes** — re-run **errors** | no |
42
+ | `Personas_Items` (`personaId`, `itemId`) | the kit's **lines** are visible | **no** — re-run **silently duplicates** | **no — see below** |
43
+ | `Personas_Assortments` (`personaId`, `assortmentId`) | browsing the accessory categories | **no** — re-run **silently duplicates** | no |
44
+ | `Users_Personas` (`userId`, `personaId`) | the user holds the persona | **yes** — re-run **errors** | no |
45
+
46
+ That asymmetry is the thing to remember: the two bridges that most need a guard are exactly the two
47
+ that will not protect themselves.
48
+
49
+ ### `Personas_Items` is not optional
50
+
51
+ Every `BundleItems.itemId` of the bundle must also be granted to the persona. Concretely: of bundle
52
+ 243's **19** active line items, persona 1 ("All", which the user already held) covered only **2** —
53
+ so a bundle-only grant would have shown the kit with 17 of its lines missing.
54
+
55
+ ### Derive the assortment set — never hardcode it
56
+
57
+ An assortment qualifies when it holds one of the bundle's items **and** the persona that owns the
58
+ bundle today already grants it. Joining it out means the new persona inherits exactly the browse
59
+ scope the current owner has, with no list to maintain:
60
+
61
+ ```sql
62
+ SELECT DISTINCT ai.assortmentId
63
+ FROM Bundles b
64
+ INNER JOIN BundleItems bi ON bi.bundleId = b.id AND bi.isActive = 1
65
+ INNER JOIN AssortmentItems ai ON ai.itemId = bi.itemId
66
+ INNER JOIN Personas_Bundles pbSource ON pbSource.bundleId = b.id AND pbSource.personaId <> @personaId
67
+ INNER JOIN Personas_Assortments pa ON pa.assortmentId = ai.assortmentId AND pa.personaId = pbSource.personaId
68
+ WHERE b.number = '243'
69
+ ```
70
+
71
+ The `personaId <> @personaId` exclusion matters: by the time this phase runs, the **new** persona
72
+ also owns the bundle, and without it the join would feed on itself.
73
+
74
+ For bundle 243 this resolves to assortments **2** (Monitors), **4** (Computer Accessories), **6**
75
+ (Apple Accessories), **8** (Keyboard/Keypad & Mouse), **10** (Cables) and **19** (Computers).
76
+ **An inactive assortment in the set is normal** — 19 is `isActive = 0` and is still granted, because
77
+ the grant mirrors the source persona rather than judging the data.
78
+
79
+ ## A new Compass persona needs NO settings rows
80
+
81
+ Verified across **all 38** personas in prod: `PersonaAppSettings`, `PersonaGlobalSettings`,
82
+ `PersonaPageSettings`, `PersonaRecordSettings`, `PersonaSectionSettings`,
83
+ `PersonaRecordFieldSettings`, `PersonaTranslations`, `Personas_Currencies` and
84
+ `Personas_VendorItems` are **all empty — 0 rows**. A Compass persona is purely **catalogue scoping**
85
+ (bundles / items / assortments). Do not go hunting for supporting configuration; there is none.
86
+
87
+ ## Persona numbers in prod (2026-08-26)
88
+
89
+ **38 personas.** Numbers in use: **1-23, 27-40, and 100** ("QA Full Access", id 37). **The next free
90
+ number is 41.** `Personas.number` is a *string* column, so quote it.
91
+
92
+ ## "Bundle 243" is `Bundles.number`, not `Bundles.id`
93
+
94
+ The business always quotes the **number**. Bundle **243** is `Bundles.id` **171** ("Compass Creative
95
+ Studio MacBook"). Resolve it in the query (`WHERE b.number = '243'`) rather than pasting an id, and
96
+ never assume the two match.
97
+
98
+ ## Finding the user
99
+
100
+ **`Client_Compass.Users` has no `username` column** — the natural first query fails with
101
+ `Unknown column 'username'`. The Compass login / personnel username lives in **`c_hrEmpUsername`**,
102
+ and it is stored **lowercase**: people quote it uppercase ("KINGK01") but the row says `kingk01`.
103
+ Look users up by `email` or `c_hrEmpUsername` (lowercased), which is also how the nightly PEOPLE cron
104
+ matches them — see [PEOPLE-File User Lifecycle](../features/people-file-user-lifecycle.md).
105
+
106
+ ## Making someone a super user
107
+
108
+ There is **no `isSuperUser` flag on `Users`** — it is a role row. `Client_Compass.Roles.id` **5**,
109
+ name **`SuperUser`** (113 holders as of 2026-08-26), granted **additively** through
110
+ `Users_Roles (userId, roleId)`, which is unique on the pair.
111
+
112
+ **SuperUser is never a replacement for the base roles.** The established prod pattern for a Compass
113
+ super user is **Base (1) + SuperUser (5) + Compass Base (7)**, optionally **+ Manager (8)**. Role
114
+ permissions resolve as a **union** (see
115
+ [ACL Permission Chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md)), so adding
116
+ role 5 while removing 1 or 7 takes access *away*.
117
+
118
+ ## Verify the way the ACL actually resolves personas
119
+
120
+ A user's **effective** persona set is three UNIONed sources, not just `Users_Personas`:
121
+
122
+ 1. `Users_Personas` — personas held directly.
123
+ 2. `Locations_Personas` for the user's own `locationId`.
124
+ 3. `Locations_Personas` on the user's **sector** — five `parentLocationId` hops up
125
+ (location → complex → district → region → division → sector).
126
+
127
+ **Check all three before adding anything** — the user may already reach the bundle through their
128
+ location, in which case the right answer is "no change needed". After the grant, re-run the same
129
+ resolution and confirm the bundle number appears; the verification block at the end of the template
130
+ file does exactly this. The same three-source resolution is what the full-population comparison
131
+ tooling uses — see
132
+ [Prod vs dev-sandbox persona comparison](./persona-population-env-comparison.md).
133
+
134
+ ## Gotchas
135
+
136
+ - **⚠ Re-running the whole file creates a SECOND persona.** Phase 1's `INSERT INTO Personas` has no
137
+ guard, so a second run inserts another persona with the same name and number, and the child phases
138
+ then hang a full set of rows off it. The child phases are individually re-run safe; the **file is
139
+ not idempotent**. To repeat any phase, set `@personaId` to the existing persona id by hand and skip
140
+ Phase 1.
141
+ - **`@personaId` is a session variable — run every phase in ONE session.** A reconnect between phases
142
+ leaves it `NULL`.
143
+ - **`SELECT DISTINCT` will not dedupe a row carrying a generated uuid.** The per-row random value
144
+ makes every row distinct. Dedupe the id set in a derived table and generate the uuid in the outer
145
+ select — full explanation and the UUID4 expression in
146
+ [Re-runnable additive INSERTs](../../../2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md).
147
+ - **⚠ Do not write this SQL against the persona-refactor target state.** As of 2026-08-26 the refactor
148
+ has **not** shipped to prod: persona 1 is still named **"All"** (not "Base"), there is **no
149
+ "Non-Levy" persona**, and persona **number 40 belongs to id 38 "MyDining- KDS"**. See
150
+ [Persona Refactor](./persona-refactor-migration.md).
151
+ - **Everything here is additive.** The user keeps persona 1 and their existing roles; nothing is
152
+ removed. A "remove access" request is a different, destructive job and needs its own review.
153
+
154
+ ## Change history
155
+ - 2026-08-26 — Initial: documented the five-table bundle grant (and that **`Personas_Items` is
156
+ mandatory** — item-level ACL hides kit lines whose item the persona cannot see; persona 1 covered
157
+ only 2 of bundle 243's 19 items), the unique-key asymmetry that makes `Personas_Items` /
158
+ `Personas_Assortments` duplicate silently on re-run, the **derived** assortment join, and that
159
+ **every persona settings table in prod is empty across all 38 personas** so a new persona needs no
160
+ config. Added the `Bundles.number` vs `Bundles.id` trap, the missing `Users.username` column
161
+ (`c_hrEmpUsername`, lowercase), the SuperUser role recipe (`Roles.id 5`, additive, Base + SuperUser
162
+ + Compass Base), and the three-source effective-persona verification. Template file:
163
+ `2026-08-26a - CompassCreativeStudioPersona.sql` (written, not executed). (bala)
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: compass-usa
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-06
9
+ updated: 2026-08-26
10
10
  owners: [bala]
11
11
  files:
12
12
  - dbchanges2/Client_Compass/2026-08-06 - PersonaRefactor.sql
@@ -16,6 +16,7 @@ files:
16
16
  related:
17
17
  - ../features/persona-model-and-levy-gating.md
18
18
  - ./persona-population-env-comparison.md
19
+ - ./granting-persona-bundle-access.md
19
20
  - ../profile.md
20
21
  ---
21
22
 
@@ -29,6 +30,32 @@ Levy-sector users). One SQL file, **8 phases**, plus a companion verification sc
29
30
  > (toga2-commerce #306, dbchanges2 #368, worker2 #43) were closed and the work rewritten against
30
31
  > current code and data — treat any plan or skill text predating 2026-08-06 as stale.
31
32
 
33
+ ### ⚠ Everything below Phase 1 describes a TARGET state. Prod is not in it (re-verified 2026-08-26)
34
+
35
+ The phase table, the persona ids and the post-migration figures in this doc describe what prod will
36
+ look like **after** the migration. Read read-only against production `Client_Compass` on
37
+ **2026-08-26**, prod is still pre-migration:
38
+
39
+ | Claim you might carry over | Production reality, 2026-08-26 |
40
+ |---|---|
41
+ | Persona 1 is named "Base" | Persona 1 is still named **"All"** |
42
+ | A "Non-Levy" persona exists | **It does not exist in prod at all** |
43
+ | Non-Levy will land on `Personas.id` **38** | **Id 38 is already taken** — it is **"MyDining- KDS"** |
44
+ | Phase 1's hardcoded `number = '40'` is free | **Number 40 is in use** (by id 38). Numbers **1-23, 27-40 and 100** are taken across **38** personas; **41 is the next free number** |
45
+
46
+ Two consequences before this is ever run in prod:
47
+
48
+ 1. **Phase 1's hardcoded `number = '40'` must be re-checked** — it would create a second persona
49
+ carrying a number that already belongs to "MyDining- KDS".
50
+ 2. **The "id will be 38" reasoning no longer holds in prod.** `Personas.AUTO_INCREMENT` has moved
51
+ past 38 since 2026-08-06, so the new persona will get some higher id. This is exactly why
52
+ `PeopleFile` resolves Non-Levy **by name** — see
53
+ [Persona Model & Levy-Sector Gating](../features/persona-model-and-levy-gating.md). Never hardcode
54
+ the id, and re-read prod rather than this table before running.
55
+
56
+ **Any other Compass persona work must target prod-as-it-is, not this target state** — see
57
+ [Granting a Compass user access to a bundle](./granting-persona-bundle-access.md).
58
+
32
59
  The persona semantics and the Levy-detection code this depends on live in
33
60
  [Persona Model & Levy-Sector Gating](../features/persona-model-and-levy-gating.md). Read that first;
34
61
  the migration is meaningless without the `isLevySector` fix.
@@ -130,6 +157,12 @@ All checks are written as **invariants** or as comparisons against a captured **
130
157
  | Fixed verification user list (HAQIKAH.AARON, KYLE.AARON) | HAQIKAH.AARON is inactive, KYLE.AARON does not exist — **find test users dynamically** |
131
158
 
132
159
  ## Change history
160
+ - 2026-08-26 — **Correction, not new work.** Re-verified read-only against prod: the refactor is
161
+ **still not live**. Prod persona 1 is named **"All"** (not "Base"), there is **no Non-Levy
162
+ persona**, and **id 38 is now "MyDining- KDS"** — so the "Non-Levy = id 38" reasoning and Phase 1's
163
+ hardcoded `number = '40'` (number 40 belongs to id 38) are both stale for prod. Prod holds 38
164
+ personas using numbers 1-23, 27-40 and 100; next free number is 41. Added the target-state warning
165
+ block so nobody writes prod SQL against the post-migration table. (bala)
133
166
  - 2026-08-06 — Initial: rewritten 8-phase migration (earlier draft's Phase 5+9 collapsed into one
134
167
  Levy-excluding assignment; Phase 6 restricted to active users; Phase 7f deletes the retired persona
135
168
  by name; Phase 8 makes ACL expression 235 purely additive). Documented the B2 verification-order
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.657",
3
+ "version": "1.0.659",
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",