toga-ai 1.0.255 → 1.0.257

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: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-25
9
+ updated: 2026-06-30
10
10
  owners: ["dfranks"]
11
11
  files:
12
12
  - library/app/api/netsuite/rest.php
@@ -330,9 +330,23 @@ numbers. Budget hours for multi-year runs and launch them under `nohup`/`tmux`.
330
330
  (PHP 8.0) is for running probes on the laptop only, **not** for compat checking.
331
331
  - Field/relationship availability varies by NetSuite account **and** record type — probe the live
332
332
  account before assuming a column exists.
333
+ - **`IS NOT NULL` on a custom `transactionline` column inside an aggregate query is a ~15× planner
334
+ trap — prefer `> 0` (or `NVL(col,0) <> 0`).** Filtering a custom employee-ref column
335
+ (`custcol_sales_rep_line`) with `AND tl.custcol_sales_rep_line IS NOT NULL` inside a
336
+ `GROUP BY` / power-sum aggregate (`MOD(SUM(MOD(id*id,P)),P)`) made the planner scan
337
+ pathologically: **~122s** vs **~8s** for the semantically identical `AND tl.custcol_sales_rep_line
338
+ > 0` (employee internalIds are always positive) — a ~15× regression purely from the predicate form.
339
+ Measured against the Forecast2 reconciliation checker (TRUE-79968). Use `> 0` / `NVL(col,0) <> 0`
340
+ for a custom ref column you only need to test for presence in an aggregate; reserve `IS NOT NULL`
341
+ for non-aggregate row filters.
333
342
 
334
343
  ## Change history
335
344
 
345
+ - 2026-06-30 — **Recorded the `IS NOT NULL` vs `> 0` planner trap for custom `transactionline`
346
+ columns in aggregate queries** (TRUE-79968): a custom employee-ref column filtered with
347
+ `IS NOT NULL` inside a power-sum `GROUP BY` aggregate scanned ~15× slower (~122s) than the
348
+ identical `> 0` predicate (~8s). Prefer `> 0` / `NVL(col,0) <> 0` for presence tests in
349
+ aggregates. (dfranks)
336
350
  - 2026-06-25 — **Documented Journal Entry line structure + the JE→Forecast.Sales deferral.** JEs
337
351
  invert the `mainline` rule — **all** JE `transactionline` rows are `mainline='T'` (no `'F'` detail
338
352
  split), so a `mainline='F'` revenue query over a JE returns empty. Also: `tl.salesrep` does not
@@ -233,6 +233,22 @@ by reconciling a chosen tranDate range directly against NetSuite.
233
233
  are correctly excluded **by design** (e.g. JE 7213407 = a Bank↔Equity reclass, 0 allowlist lines → never
234
234
  imported — not a miss). An accrual JE and its NetSuite auto-reversal are **separate transactions**, each
235
235
  reconciled independently by its own `internalId`.
236
+ - **The rep-only-NS-side / unfiltered-FC-side asymmetry is DELIBERATE and load-bearing — do NOT add
237
+ a rep filter to any Forecast-side query (TRUE-79968, dfranks 2026-06-30).** The importer now keeps
238
+ **only JE lines that carry a raw `custcol_sales_rep_line`** (see the sale-import doc). To make the
239
+ fixer be the **cleanup mechanism** for any pre-existing no-rep JE rows (there is no separate delete
240
+ script), the **NS-side** JE SuiteQL in both `checker.php` (`journalEntryNsTotals` arm of the sales
241
+ `nsSub`) and `fixer.php` (`journalEntryNsTotals` FIND + `fixJournalEntries` FIX) is scoped to
242
+ **rep-bearing lines** (mirroring the importer), while the **Forecast-side** reads in both tools stay
243
+ **UNFILTERED** (still SUM all JE rows, incl. any `salesRepEmployeeId IS NULL`). That asymmetry is
244
+ what surfaces the stale rows: NS (rep-only) **<** FC (still holds no-rep rows) → FIND flags the JE →
245
+ the existing per-`'journalEntry'` orphan-delete in `fixJournalEntries` removes them. **If the FC side
246
+ were ALSO rep-filtered, both sides would match and the stale rows would be invisible / never purged.**
247
+ A blanket `DELETE WHERE salesRepEmployeeId IS NULL` is the wrong fix — locally-null also means
248
+ "rep present in NetSuite but unmapped to `Forecast.Employees`," which must survive; the fixer sweep
249
+ correctly re-derives "no raw rep" from NetSuite. One-time cleanup =
250
+ `fixer.php --commit --prod --category sales` over the JE window, then `checker.php` to confirm
251
+ (in current prod this was a no-op — 0 no-rep JE rows — so the importer filter is preventative).
236
252
  - **JE item dimension is NULL today; here is how to enable it later.** `itemId` is null on JE Sales rows
237
253
  because the JE import path reads item **only** via the configurable `JE_LINE_ITEM_FIELD` (currently null)
238
254
  — it does **not** read native `transactionline.item`, so even a populated native `tl.item` would NOT flow
@@ -468,11 +484,32 @@ None — Forecast2 is a single shared dataset.
468
484
  invariant, any field added to the open-order **importer** (TRUE-79162) must also be added to this
469
485
  OOI path (and ideally behind the same `forecastColumnExists` guard pattern) or `fixer.php`/`looper`
470
486
  will overwrite the importer's value to NULL on its next run.
487
+ - **`IS NOT NULL` on a CUSTOM `transactionline` column inside a SuiteQL aggregate is a ~15× planner
488
+ trap — use `> 0` instead.** Filtering `custcol_sales_rep_line` (an employee-ref custom column) with
489
+ `AND tl.custcol_sales_rep_line IS NOT NULL` inside checker's UNION'd moment-fingerprint (the
490
+ `MOD(SUM(MOD(id*id,P)),P)` power-sum aggregates) made the planner scan pathologically: a full-window
491
+ checker run measured **~122s** with `IS NOT NULL` vs **~8s** with the semantically identical
492
+ `AND tl.custcol_sales_rep_line > 0` (employee internalIds are always positive). Applied to
493
+ `checker.php` (JE arm) and `fixer.php` (both JE queries) with inline comments warning against
494
+ reverting. See the SuiteQL API reference for the general rule. (Use `> 0`, or `NVL(col,0) <> 0`,
495
+ never `IS NOT NULL`, for a custom employee-ref `transactionline` column in a SuiteQL aggregate.)
471
496
  - These tools live in `test/@dave/` (developer tooling), but `trueup_open_orders` has been run
472
497
  against production. The `Defaults`/checkpoint mechanics of the scheduled sync are separate.
473
498
 
474
499
  ## Change history
475
500
 
501
+ - 2026-06-30 — **Made the fixer the cleanup mechanism for no-rep JE rows + killed a ~15× SuiteQL
502
+ planner trap (TRUE-79968, dfranks).** The importer now keeps only rep-bearing JE lines (see the
503
+ sale-import doc); to let `fixer.php` purge any pre-existing no-rep `Forecast.Sales` JE rows with no
504
+ separate delete script, the **NS-side** JE SuiteQL in both `checker.php` and `fixer.php` was scoped
505
+ to rep-bearing lines while the **FC-side reads stay UNFILTERED** — a deliberate, load-bearing
506
+ asymmetry (NS rep-only < FC still-has-no-rep → FIND flags → existing per-`'journalEntry'` orphan
507
+ delete removes them; filtering the FC side too would hide them forever). A blanket
508
+ `DELETE WHERE salesRepEmployeeId IS NULL` is wrong (locally-null also = NS-present-but-unmapped rep,
509
+ which must survive). **Perf:** replaced `AND tl.custcol_sales_rep_line IS NOT NULL` with
510
+ `AND ... > 0` in checker (JE arm) + fixer (both JE queries) — a clean full-window checker run dropped
511
+ from **~122s to ~8s** purely from the predicate form, with inline anti-revert comments. Current prod
512
+ had 0 no-rep JE rows, so the cleanup sweep was a no-op (filter is preventative). (dfranks)
476
513
  - 2026-06-30 — **Folded JOURNAL ENTRIES into the SALES reconciliation in `fixer.php` + `checker.php`
477
514
  (TRUE-79862).** Architecture decision (dfranks): a JE is **not its own category** — JE rows live in the
478
515
  same `Forecast.Sales` table (`netsuiteTransactionType='journalEntry'`) as the four sale types, so the
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-17
10
- owners: ["dfranks"]
9
+ updated: 2026-06-30
10
+ owners: ["dfranks", "bala"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
13
13
  - worker/crons/toga2/netsuite/sync_togasupply_canon.php
@@ -17,6 +17,7 @@ related:
17
17
  - ../architecture.md
18
18
  - forecast2-netsuite-reconciliation.md
19
19
  - ../../library/features/toga2-api-client-and-bridge.md
20
+ - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
20
21
  ---
21
22
 
22
23
  ## Summary
@@ -150,6 +151,10 @@ Parameters are stored **per client DB** but accessed **through the TOGa2 API**,
150
151
 
151
152
  ## Change history
152
153
 
154
+ - 2026-06-30 — The IF section's `syncItemFulfillmentFromNetsuite` (`library/app/api/toga2.php`) now
155
+ imports **all** fulfillment statuses (`['_picked','_packed','_shipped']`, not just shipped),
156
+ stamping/advancing the IF stage from `c_netsuiteInternalItemFulfillmentStatus`. Full mechanics
157
+ live in the [IF stage lifecycle doc](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md). (bala)
153
158
  - 2026-06-17 — Documented the full onboarding prerequisites (client→NetSuite customer link +
154
159
  per-client `c_netsuite*` custom fields) after AIG ran but imported nothing. Added AIG + Growrk
155
160
  thin wrappers, schedule entries, and dbchanges2 Parameters seeds; built AIG's
@@ -9,6 +9,7 @@
9
9
  | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
10
10
  | [Record-Changed Event Publishing (_Event::publish to SQS)](features/event-publish-sqs.md) | `_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event pipeline. | _underscore/Event.php |
11
11
  | [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). | _underscore/Component/Forecast/SaleImport/SaleImport.php, _underscore/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/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
12
+ | [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/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 |
12
13
  | [_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 |
13
14
  | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
14
15
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-29
9
+ updated: 2026-06-30
10
10
  owners: [dfranks]
11
11
  files:
12
12
  - _underscore/Component/Forecast/SaleImport/SaleImport.php
@@ -163,6 +163,19 @@ location, memo). Handler `_Worker_Netsuite_JournalEntry`: `post`/`put` → `sync
163
163
  item-line upsert/reconcile machinery (`syncLines`/`guardedInsert`/`deleteRows`).
164
164
 
165
165
  **Mapping model (durable design):**
166
+ - **Only JE lines that carry a sales rep are imported (TRUE-79968, dfranks 2026-06-30).**
167
+ `buildJournalEntryRows` applies a **PER-LINE** filter **before** the (salesRep,item)
168
+ bucketing: a GL line whose **raw** `custcol_sales_rep_line` is empty is **skipped**
169
+ (`continue`). Consequences — a JE with no rep on any line yields **zero** `Forecast.Sales`
170
+ rows; a mixed JE keeps only its rep-bearing groups; offset/balancing lines (which omit the
171
+ custom column) drop out naturally. **The test is on the RAW NetSuite line field, NOT the
172
+ mapped local `salesRepEmployeeId`** — so a rep present in NetSuite but **not yet mapped** to
173
+ a local `Forecast.Employees` row **still qualifies** (its group keeps a null
174
+ `salesRepEmployeeId`). This is deliberate: it keeps no-rep JE noise out of Forecast revenue
175
+ while not silently dropping a real-but-unmapped rep's revenue. (A blanket
176
+ `DELETE WHERE salesRepEmployeeId IS NULL` would be **wrong** for the same reason — locally
177
+ null also means "rep present in NS but unmapped"; any cleanup must re-derive "no raw rep"
178
+ from NetSuite. See the reconciliation doc for how the fixer sweep does exactly that.)
166
179
  - **Sales rep is now LIVE on JE lines** as the custom column **`custcol_sales_rep_line`**
167
180
  (`JE_LINE_SALESREP_FIELD = 'custcol_sales_rep_line'`, wired 2026-06-29 after live Apr–May
168
181
  2026 probing). The value is a NetSuite **employee REFERENCE object** `{links, id, refName}`;
@@ -429,6 +442,20 @@ record is deleted in NetSuite.)
429
442
  - The cron's sign handling is not portable here — see Sign convention.
430
443
 
431
444
  ## Change history
445
+ - 2026-06-30 — **JE import now keeps ONLY rep-bearing lines** (TRUE-79968, dfranks).
446
+ `buildJournalEntryRows` skips any GL line whose **raw** `custcol_sales_rep_line` is empty,
447
+ applied **per-line before** the (salesRep,item) bucketing: a no-rep JE → 0 Sales rows, a
448
+ mixed JE keeps only rep-bearing groups. The filter tests the **RAW NS field**, not the
449
+ mapped local `salesRepEmployeeId`, so a rep present in NetSuite but unmapped to
450
+ `Forecast.Employees` still qualifies (group stays null `salesRepEmployeeId`) — which is also
451
+ why a blanket `DELETE WHERE salesRepEmployeeId IS NULL` is wrong; cleanup must re-derive
452
+ "no raw rep" from NetSuite. No-rep cleanup is handled by the existing `fixer.php` orphan-
453
+ delete sweep (see the reconciliation doc), not a separate delete script — in current prod
454
+ there were 0 no-rep JE rows so the filter is preventative going forward.
455
+ **Confirmed the JE webhook handler `_Worker_Netsuite_JournalEntry`
456
+ (worker2/Worker/Netsuite/JournalEntry.php) holds no business logic** — post/put delegate to
457
+ `_Component_Forecast_SaleImport::syncJournalEntry`, delete to `removeAllJournalEntry`; editing
458
+ the importer in `_underscore` is the single correct entry point for JE import changes. (dfranks)
432
459
  - 2026-06-29 — **Wired JE sales-rep ingestion live** (TRUE-79862): set
433
460
  `JE_LINE_SALESREP_FIELD = 'custcol_sales_rep_line'` after live Apr–May 2026 probing
434
461
  confirmed the rep is now present on JE lines as that custom column — a NetSuite employee
@@ -0,0 +1,174 @@
1
+ ---
2
+ title: Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-30
10
+ owners: [bala]
11
+ files:
12
+ - _underscore/Model/Client/SalesOrder.php
13
+ - _underscore/Model/Quad/SalesOrder.php
14
+ - _underscore/Model/Compass/SalesOrder.php
15
+ - _underscore/Model/Compass/SalesOrderStatus.php
16
+ - _underscore/Model/Client/SalesOrderItem.php
17
+ - _underscore/Model/Client/Item.php
18
+ - _underscore/Model/Client/PurchaseOrderItem.php
19
+ - library/app/api/toga2.php
20
+ - dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql
21
+ - dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql
22
+ - dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql
23
+ - dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql
24
+ related:
25
+ - recursive-item-fulfillments.md
26
+ - carrier-shipping-labels.md
27
+ - ../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
28
+ - ../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
29
+ - ../../../clients/compass-usa/features/asn-to-item-fulfillment.md
30
+ ---
31
+
32
+ ## Summary
33
+
34
+ Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped —
35
+ resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (matched by status `slug`).
36
+ This makes the *picked/packed* portion of the fulfillment lifecycle visible (previously only
37
+ *shipped* IFs were imported, so picked/packed were invisible) and drives a calculated order
38
+ `_status` state machine. Two deliberately different status policies exist:
39
+
40
+ - **Base + Quad:** order status walks the full machine — Pending → Picked → Packed →
41
+ Partially Fulfilled → Fulfilled — so non-Compass clients see the picked/packed lifecycle.
42
+ - **Compass USA + Compass Canada:** all IF stages are *imported*, but order status counts
43
+ **shipped only** — picked/packed never advance a Compass order's status. Compass walks
44
+ Pending → Partially Fulfilled → Fulfilled by shipped quantity alone.
45
+
46
+ Independently of order status, **inventory movement (`qtyFulfilled` / `qtyCommitted`) is
47
+ shipped-only for ALL clients** — picked/packed quantities never count as inventory movement.
48
+
49
+ ## Key files / entry points
50
+
51
+ - **`Model/Client/SalesOrder.php`** — base `_status` calculated field. With `salesOrderStageId`
52
+ NULL and a non-SA order: 0 fulfillments → Pending Fulfillment; nothing shipped → Picked (if
53
+ any line picked) else Packed; something shipped → Partially Fulfilled (shipped qty < ordered)
54
+ else Fulfilled. An explicit `salesOrderStageId` overrides everything.
55
+ - **`Model/Quad/SalesOrder.php`** — Quad `_status`. `SA%` orders delegate to the parent; non-SA
56
+ orders use the inline picked/packed/partial/fulfilled machine.
57
+ - **`Model/Compass/SalesOrder.php`** — Compass `_status`, shipped-only: both the existence
58
+ `COUNT` and the shipped-qty `SUM` subqueries `INNER JOIN ItemFulfillmentStages →
59
+ ItemFulfillmentStatuses` filtered to the shipped status slug. Shared by Compass USA + Canada.
60
+ - **`Model/Compass/SalesOrderStatus.php`** — adds `const SLUG__PICKED = 'picked'` and
61
+ `SLUG__PACKED = 'packed'`.
62
+ - **`Model/Client/{SalesOrderItem,Item,PurchaseOrderItem}.php`** — the `_qtyFulfilled` /
63
+ `_qtyCommitted` calculated SQL fields (5 sites) gate the IF-item sum to the shipped stage via
64
+ the `ItemFulfillmentStages → ItemFulfillmentStatuses` join.
65
+ - **`library/app/api/toga2.php`** — `App_Api_Toga2::syncItemFulfillmentFromNetsuite` imports the
66
+ IF stage from NetSuite (see *How it works* / the per-client-sync doc).
67
+
68
+ ## How it works
69
+
70
+ ### IF stage resolution
71
+
72
+ A stage is resolved by joining `ItemFulfillmentStages → ItemFulfillmentStatuses` and matching the
73
+ status **`slug`** (`'picked'` / `'packed'` / `'shipped'`). The shipped checks in the status
74
+ machines use the constant `_Model_Client_ItemFulfillmentStatus::SLUG__SHIPPED` (never a hardcoded
75
+ `'shipped'`); the *picked* check stays a string literal because no `SLUG__PICKED` constant exists
76
+ on `ItemFulfillmentStatus` (the picked/packed slug constants live on the **Compass**
77
+ `SalesOrderStatus`, not the base IF status).
78
+
79
+ > **Stage resolution must use a single-row INNER JOIN on the status `slug`, not a scalar
80
+ > subquery on stage `name`** — a scalar subquery can throw "subquery returns more than 1 row".
81
+ > Compass USA + Canada lack the `c_netsuite*` column on stages, so slug is the only portable key.
82
+
83
+ ### NetSuite import widening (change 2026-06-30)
84
+
85
+ `syncItemFulfillmentFromNetsuite` previously imported only *shipped* IFs. It now imports **all**
86
+ fulfillment statuses — filter widened to `['_picked','_packed','_shipped']`:
87
+ - **On create** it stamps the IF stage from the NetSuite ship status
88
+ (`c_netsuiteInternalItemFulfillmentStatus`) instead of hardcoding shipped.
89
+ - **On re-sync** it compares the current stage's `c_netsuiteInternalItemFulfillmentStatus` against
90
+ the NetSuite status and PUTs a new stage, so a fulfillment advances picked → packed → shipped.
91
+ - Applies to **all** clients including Compass / Compass Canada (which import all stages but still
92
+ count shipped-only for order status — see below).
93
+
94
+ ### Order-status machines
95
+
96
+ | Client | Statuses walked | Driven by |
97
+ |---|---|---|
98
+ | Base, Quad (non-SA) | Pending → Picked → Packed → Partially Fulfilled → Fulfilled | all IF stages |
99
+ | Compass USA + Canada | Pending → Partially Fulfilled → Fulfilled | **shipped IF stage only** |
100
+
101
+ Quad `SA%` orders delegate to the base machine; an explicit `salesOrderStageId` overrides the
102
+ calculated status everywhere.
103
+
104
+ ### Inventory movement (all clients)
105
+
106
+ `_qtyFulfilled` and `_qtyCommitted` sum only IF items whose stage is **shipped** — picked/packed
107
+ quantities are never counted as inventory movement, regardless of the new picked/packed
108
+ visibility in order status.
109
+
110
+ ## Data model / schema (dbchanges2, 2026-06-30)
111
+
112
+ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is alphabetical, so
113
+ `30a` (seed + backfill) runs before `30c` (NOT NULL alter):
114
+
115
+ - **`Client/2026-06-30a` — BackfillNullStageItemFulfillmentsToShipped:** idempotently seeds a
116
+ shipped `ItemFulfillmentStatus` + `ItemFulfillmentStage` (**unconditional** — only an
117
+ `INSERT…SELECT WHERE NOT EXISTS` slug guard, so fresh empty client DBs also get it), then
118
+ backfills NULL-stage `ItemFulfillments` to the shipped stage via `UPDATE…INNER JOIN` resolving
119
+ the stage through `ItemFulfillmentStatuses.slug = 'shipped' LIMIT 1` (single-row join, not a
120
+ scalar subquery).
121
+ - **`Client/2026-06-30b` — SalesOrderStatusesPickedPacked:** idempotent `INSERT…SELECT WHERE NOT
122
+ EXISTS` of picked + packed `SalesOrderStatuses`, `colorHex` stored **without** the leading `#`
123
+ (picked `F5A623`, packed `4DBCC4`).
124
+ - **`Client/2026-06-30c` — ItemFulfillmentStageIdNotNull:** `ALTER ItemFulfillments MODIFY
125
+ itemFulfillmentStageId INT UNSIGNED NOT NULL`. Safe because `30a` (alphabetically first)
126
+ guarantees every fulfillment already has a stage.
127
+ - **`Client_CompassCanada/2026-06-30a` — ItemFulfillmentLifecycleAndShippedBackfill:** creates
128
+ Canada's 3 `ItemFulfillmentStatuses` (picked/packed/shipped) and 3 `ItemFulfillmentStages`, all
129
+ idempotent (`INSERT…SELECT WHERE NOT EXISTS` on slug for statuses; stages guarded by `NOT
130
+ EXISTS` of a stage for that status slug), then the same single-row-join shipped backfill. Status
131
+ slug is UNIQUE and stage name is UNIQUE, so the guards align with the unique keys.
132
+
133
+ ## Migration correctness rules (review-driven, baked into the above)
134
+
135
+ - **Seed migrations must be retry-safe:** use `INSERT…SELECT WHERE NOT EXISTS`, and resolve the
136
+ shipped stage via a single-row `INNER JOIN` on `slug` — never a scalar subquery on `name`
137
+ (which can throw "subquery returns more than 1 row" when more than one stage shares a name).
138
+ - **The shipped seed must be UNCONDITIONAL** (no `EXISTS`-fulfillments gate) so fresh client DBs
139
+ get a stage *before* the NOT NULL alter and *before* any future fulfillment insert.
140
+
141
+ ## Gotchas / known issues
142
+
143
+ - **NULL-SUM correctness bug (fixed):** the base shipped-qty subquery returned NULL when no rows
144
+ matched (empty `SUM` = NULL), and `NULL < orderedQty` is not true, so empty orders read
145
+ **Fulfilled**. Wrap the shipped-qty subquery with `IFNULL(…, 0)` before comparing to the ordered
146
+ quantity.
147
+ - **Compass imports all stages but counts shipped-only.** Do not assume importing picked/packed
148
+ IFs changes a Compass order's status — by design it does not (the Compass supply UI must show
149
+ shipped-only progression). This supersedes the old "stages 1/2 are unused in prod" assumption in
150
+ the Compass order-lifecycle workflow.
151
+ - The base/Compass picked check is a string literal `'picked'` (no `SLUG__PICKED` on
152
+ `_Model_Client_ItemFulfillmentStatus`); only the shipped check uses a constant. Adding a base IF
153
+ picked/packed constant would let the literal be removed.
154
+
155
+ ## Change history
156
+ - 2026-06-30 — Built the IF stage lifecycle end-to-end: NetSuite sync imports all
157
+ picked/packed/shipped statuses and advances stages on re-sync; base + Quad order `_status`
158
+ picked/packed/partial/fulfilled machine; Compass USA + Canada order status shipped-only;
159
+ `qtyFulfilled`/`qtyCommitted` shipped-only for all clients; dbchanges2 stage seed + backfill +
160
+ `itemFulfillmentStageId` NOT NULL (Client fan-out + Compass Canada). Fixed the NULL-SUM
161
+ Fulfilled bug (`IFNULL`). Baked in the retry-safe seed + single-row-join + unconditional-seed
162
+ migration rules. (bala)
163
+
164
+ ## Related docs
165
+ - [Recursive Item Fulfillments](recursive-item-fulfillments.md) — the engine that mirrors IFs (and
166
+ now their stages) up the chain.
167
+ - [Carrier Shipping Labels & NetSuite IF](carrier-shipping-labels.md) — IF creation on the
168
+ fulfill-and-ship path.
169
+ - [NetSuite → TOGa Supply Per-Client Sync](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md)
170
+ — the importer surface; `syncItemFulfillmentFromNetsuite` is the stage-stamping entry point.
171
+ - [Compass Order Lifecycle & Data-Integrity Invariants](../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md)
172
+ — the stage-3-Shipped invariant updated by this work.
173
+ - [Compass ASN → ItemFulfillment Auto-Creation](../../../clients/compass-usa/features/asn-to-item-fulfillment.md)
174
+ — ASN-created IFs now resolve the shipped stage.
@@ -16,7 +16,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
16
16
 
17
17
  ## 2.0 framework
18
18
 
19
- - **_underscore** (_Underscore) _(framework core)_ — 20 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
19
+ - **_underscore** (_Underscore) _(framework core)_ — 21 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
20
20
  - **worker2** (Worker) — 24 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
21
21
  - **api2** (API) — 7 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
22
22
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -20,6 +20,7 @@ files: []
20
20
  related:
21
21
  - ../compass-usa/profile.md
22
22
  - ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
23
+ - ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
23
24
  ---
24
25
 
25
26
  ## Summary
@@ -56,4 +57,8 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
56
57
  values, which were then cleaned to English-only. See
57
58
  [Assortment Name Translation](../../2.0/apps/_underscore/features/assortment-name-translation.md).
58
59
  - The 2026-06-08 ASN → ItemFulfillment work was for **Compass USA**, not Compass Canada.
60
+ - **Order status is shipped-only**, same as Compass USA (shared `_Model_Compass_SalesOrder`).
61
+ Compass Canada's own IF lifecycle stages (picked/packed/shipped) + shipped backfill are seeded by
62
+ `dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql`.
63
+ See [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
59
64
  - Related: [Compass USA](../compass-usa/profile.md).
@@ -5,8 +5,8 @@ project: _Underscore
5
5
  client: compass-usa
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-06-18
9
- owners: [jcardinal]
8
+ updated: 2026-06-30
9
+ owners: [jcardinal, bala]
10
10
  files:
11
11
  - _underscore/Model/Compass/AdvanceShippingNotice.php
12
12
  - _underscore/Model/Compass/PurchaseOrder.php
@@ -20,6 +20,7 @@ files:
20
20
  - dbchanges2/Client_Compass/2026-06-18b - CleanupSA132881DuplicateTracking.sql
21
21
  related:
22
22
  - ../../../2.0/apps/_underscore/features/recursive-item-fulfillments.md
23
+ - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
23
24
  ---
24
25
 
25
26
  ## Summary
@@ -149,6 +150,11 @@ Compass Canada (`Model/Compass/Canada/`) is a separate sub-client. The Compass c
149
150
  - Interceptor is **DB-driven**: `postPost` does nothing without the Compass
150
151
  `ApiPayloadInterceptors` rows in that env.
151
152
  - The IF is named after the SO number; an already-existing IF with that number is reused.
153
+ - **ASN-created fulfillments land at the shipped stage (2026-06-30).** `resolveOrCreateItemFulfillment`
154
+ resolves the shipped `ItemFulfillmentStage` by status `slug` (portable across Compass USA + Canada,
155
+ which lack the `c_netsuite` column on stages) and sets `itemFulfillmentStage` on the payload — an
156
+ ASN means the goods shipped, so its IF must not be stage-less. See the
157
+ [IF stage lifecycle](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md) doc.
152
158
  - **cXML SQL-injection hardening (2026-06-11):** the `ShipNoticeRequest` path interpolated the
153
159
  cXML `Sender` Identity / SharedSecret and the OrderReference `orderID` (purchase order
154
160
  number) straight into queries. These are now passed through `_Database::escape()`.
@@ -201,6 +207,10 @@ Compass Canada (`Model/Compass/Canada/`) is a separate sub-client. The Compass c
201
207
 
202
208
  ## Change history
203
209
  Dated one-liners, newest first.
210
+ - 2026-06-30 — `resolveOrCreateItemFulfillment` now resolves the shipped `ItemFulfillmentStage` by
211
+ status slug and sets `itemFulfillmentStage` on the IF payload, so ASN-created fulfillments land
212
+ shipped (not stage-less) on both Compass USA + Canada. Part of the platform-wide IF stage
213
+ lifecycle work. (bala)
204
214
  - 2026-06-18 — Reconcile path now skips IFIs that already carry item-level tracking
205
215
  (`itemFulfillmentItemHasTracking()`), stopping duplicate/different tracking numbers from
206
216
  vendor ASN re-transmissions piling onto fully-fulfilled lines (qty-1 SA132898 / USB31CC1 had
@@ -19,6 +19,7 @@ files: []
19
19
  related:
20
20
  - features/asn-to-item-fulfillment.md
21
21
  - ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
22
+ - ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
22
23
  ---
23
24
 
24
25
  ## Summary
@@ -57,5 +58,10 @@ separate, related client (see its own profile).
57
58
  `_Model_Compass_AdvanceShippingNotice::postPost` handler.
58
59
 
59
60
  ## Notes
61
+ - **Order status is shipped-only (2026-06-30).** Compass imports all IF stages
62
+ (picked/packed/shipped) but its order `_status` (`_underscore/Model/Compass/SalesOrder.php`,
63
+ shared by Compass USA + Canada) counts **shipped only** — picked/packed never advance a Compass
64
+ order. Contrast Quad, which gets the full picked/packed machine. See
65
+ [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
60
66
  - Built on the shared 2.0 Recursive Item Fulfillments engine (upstream mirroring).
61
67
  - This profile is a starting point; expand as more Compass-specific behavior is captured.
@@ -5,8 +5,8 @@ project: _Underscore
5
5
  client: compass-usa
6
6
  type: workflow
7
7
  status: active
8
- updated: 2026-06-12
9
- owners: ["jcardinal"]
8
+ updated: 2026-06-30
9
+ owners: ["jcardinal", "bala"]
10
10
  files: []
11
11
  related:
12
12
  - clients/compass-usa/profile.md
@@ -14,6 +14,7 @@ related:
14
14
  - clients/compass-usa/features/mits-po-to-so-item-linking.md
15
15
  - clients/compass-usa/features/item-fulfillment-tracking-tableview.md
16
16
  - clients/compass-usa/features/mits-po-transmission-to-vendors.md
17
+ - 2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
17
18
  ---
18
19
 
19
20
  ## Summary
@@ -82,8 +83,12 @@ one-upstream for bundles.
82
83
  `AdvanceShippingNotices_TrackingNumbers`) and the IF side
83
84
  (`ItemFulfillmentItemUnits_TrackingNumbers` → `ItemFulfillmentItems_TrackingNumbers` →
84
85
  `ItemFulfillments_TrackingNumbers`). Always link existing `TrackingNumbers`; never create them.
85
- 4. **Legacy stage:** a shipped IF (has items) with `itemFulfillmentStageId IS NULL` should be set to 3
86
- ("Shipped"). Stage 3 = Shipped; stages 1/2 are unused in prod.
86
+ 4. **Stage is now mandatory:** as of 2026-06-30 `ItemFulfillments.itemFulfillmentStageId` is NOT NULL
87
+ and the picked/packed/shipped stages are all in use (resolved by status `slug`, not the legacy
88
+ numeric "stage 3 = Shipped"; stages 1/2 are no longer "unused"). The 2026-06-30a migration backfills
89
+ any NULL-stage IF to the shipped stage. Compass order `_status` still counts **shipped only** even
90
+ though all stages are imported — see the
91
+ [IF stage lifecycle doc](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
87
92
  5. **Mirror back-links:** every IF on a non-source SO (ODP/Agilant) should have
88
93
  `upstreamItemFulfillmentId` set, and each of its items `upstreamItemFulfillmentItemId` set.
89
94
 
@@ -114,5 +119,8 @@ runtime workflow.
114
119
  - High-multiplier over-fulfillment (5×–20×) does not fit the split-PO spurious-link pattern — separate cause.
115
120
 
116
121
  ## Change history
122
+ - 2026-06-30 — Updated the IF-stage invariant: `itemFulfillmentStageId` is now NOT NULL and
123
+ picked/packed/shipped stages are all in use (resolved by slug), superseding "stage 3 = Shipped;
124
+ stages 1/2 unused". Compass order status remains shipped-only. (bala)
117
125
  - 2026-06-12 — Documented full lifecycle, both fulfillment flows, verified upstream-mirror direction,
118
126
  the expected-shape invariants, and the retrofix tool (jcardinal)
@@ -18,6 +18,7 @@ owners: ["jcardinal", "bala"]
18
18
  files: []
19
19
  related:
20
20
  - ../../2.0/apps/_underscore/features/tracking-number-bridges.md
21
+ - ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
21
22
  ---
22
23
 
23
24
  ## Summary
@@ -42,6 +43,11 @@ Client-specific DB change-sets live in `dbchanges2/Client_Quad/`.
42
43
  [Tracking-Number Bridge Migration](../../2.0/apps/_underscore/features/tracking-number-bridges.md).
43
44
 
44
45
  ## Notes
46
+ - **Order status: full picked/packed lifecycle (2026-06-30).** Quad gets the full
47
+ picked → packed → partially-fulfilled → fulfilled order `_status` machine
48
+ (`_underscore/Model/Quad/SalesOrder.php`): non-SA orders use the inline machine, `SA%` orders
49
+ delegate to the base `_Model_Client_SalesOrder`. Contrast Compass, which is shipped-only. See
50
+ [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
45
51
  - Tracking data: record 318 (item-level) is currently empty for this client; their tracking
46
52
  populates the IF/shipment level (record 317, ~98% coverage) and unit level (record 319). The
47
53
  rebuilt views use 318 (per the Compass pattern) and will show tracking once item-level
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.255",
3
+ "version": "1.0.257",
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",