Merge feat/custom-order-tracking: Stage 11C custom order tracking and fulfillment (2373/2373 tests passing)

Completes Stage 11 and closes audit finding P2.2. Adds a tracking workspace filtered by fulfillment stage, an append-only timeline of stage changes, and inventory deduction on completion.

Inventory deduction crosses the feature boundary through an outbound port (MaterialConsumptionPort), not a package dependency: feature_orders still has no dependency on feature_inventory, and each app supplies InventoryMaterialConsumptionAdapter from its composition layer. Completion advances the order before deducting, so a rejected transition can never decrement stock.

Verified across all 11 packages and apps: core 90, design_system 51, auth 42, data 63, integrations 123, feature_policy 17, feature_inventory 167, feature_wordpress 545, feature_orders 1085, kell_web 81, kell_mobile 109 = 2373. dart analyze --fatal-infos clean.
This commit is contained in:
Mike Kell 2026-09-25 16:58:47 -04:00
commit 07e7e083c0
48 changed files with 6221 additions and 76 deletions

View File

@ -439,13 +439,13 @@ kell_creations/
| `design_system` | 51 | 100% | Excellent | | `design_system` | 51 | 100% | Excellent |
| `feature_wordpress` | 545 | ~84.7% | Good — broad coverage across all layers | | `feature_wordpress` | 545 | ~84.7% | Good — broad coverage across all layers |
| `feature_inventory` | 167 | Unknown | Adequate — domain, data, and widget tests present | | `feature_inventory` | 167 | Unknown | Adequate — domain, data, and widget tests present |
| `feature_orders` | 657 | Unknown | Excellent — custom order domain exhaustively tested | | `feature_orders` | 1,085 | Unknown | Excellent — custom order domain, UI, and tracking |
| `feature_policy` | 17 | Very low | **Gap** — thin; no controller/widget tests | | `feature_policy` | 17 | Very low | **Gap** — thin; no controller/widget tests |
| `auth` | 42 | Unknown | Good for contracts and fakes | | `auth` | 42 | Unknown | Good for contracts and fakes |
| `data` | 63 | Unknown | Good for contracts and in-memory impls | | `data` | 63 | Unknown | Good for contracts and in-memory impls |
| `integrations` | 123 | Unknown | Good — API client and mapper tests present | | `integrations` | 123 | Unknown | Good — API client and mapper tests present |
| `kell_web` | 62 | ~54.1% | **Gap** — low coverage; routing and shell untested | | `kell_web` | 81 | ~54.1% | **Gap** — low coverage; shell largely untested |
| `kell_mobile` | 81 | Unknown | Adequate — shell, page, and checkout tests | | `kell_mobile` | 109 | Unknown | Adequate — shell, page, checkout, and adapter tests |
| `feature_finance` | 1 (placeholder) | ~0% | **Critical gap** — stub only | | `feature_finance` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
| `feature_mrp` | 1 (placeholder) | ~0% | **Critical gap** — stub only | | `feature_mrp` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
| `feature_social` | 1 (placeholder) | ~0% | **Critical gap** — stub only | | `feature_social` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
@ -517,17 +517,22 @@ These items complete planned stages and wire existing contracts into production
**Scope:** `PaymentService` abstraction, `PaymentResult` domain model, `MobileCheckoutPage` (product selection → cart → payment), Square Reader SDK integration for Android, cash recording, digital receipt, post-payment order creation and inventory deduction, `Sales` tab in mobile navigation. **Scope:** `PaymentService` abstraction, `PaymentResult` domain model, `MobileCheckoutPage` (product selection → cart → payment), Square Reader SDK integration for Android, cash recording, digital receipt, post-payment order creation and inventory deduction, `Sales` tab in mobile navigation.
**Dependencies:** Stage 10A ✅, Stage 10B ✅, Stage 10D ✅, P1.1, P1.4 **Dependencies:** Stage 10A ✅, Stage 10B ✅, Stage 10D ✅, P1.1, P1.4
#### P2.2 — Custom Orders Workflow (Stage 11) — ⚠️ PARTIALLY RESOLVED #### P2.2 — Custom Orders Workflow (Stage 11) — ✅ RESOLVED
> Domain and data layers delivered on `feat/custom-order-domain` (Stage 11A, 2026-08-29). > Domain and data layers delivered on `feat/custom-order-domain` (Stage 11A, 2026-08-29).
> Intake UI delivered on `feat/custom-order-ui` (Stage 11B, 2026-09-25). > Intake UI delivered on `feat/custom-order-ui` (Stage 11B, 2026-09-25).
> Tracking and fulfillment delivered on `feat/custom-order-tracking` (Stage 11C, 2026-09-25).
**Delivered (11A):** `OrderType` enum, `CustomOrderSpec` value object, `DepositInfo` value object (money as integer cents), `FulfillmentStatus` pipeline with domain-enforced legal transitions (`quoted → depositReceived → inProduction → qualityCheck → readyForPickup → completed`, plus `cancelled` and a `qualityCheck → inProduction` rework path), extended `Order` model, `createCustomOrder()` / `updateCustomOrderSpec()` / `recordDeposit()` / `advanceFulfillmentStatus()` on `OrdersRepository`, implemented in both `FakeOrdersRepository` (seeded across every stage) and `WooCommerceOrdersRepository` (`_kc_`-prefixed order meta-data). 423 new tests. **Delivered (11A):** `OrderType` enum, `CustomOrderSpec` value object, `DepositInfo` value object (money as integer cents), `FulfillmentStatus` pipeline with domain-enforced legal transitions (`quoted → depositReceived → inProduction → qualityCheck → readyForPickup → completed`, plus `cancelled` and a `qualityCheck → inProduction` rework path), extended `Order` model, `createCustomOrder()` / `updateCustomOrderSpec()` / `recordDeposit()` / `advanceFulfillmentStatus()` on `OrdersRepository`, implemented in both `FakeOrdersRepository` (seeded across every stage) and `WooCommerceOrdersRepository` (`_kc_`-prefixed order meta-data). 423 new tests.
**Delivered (11B):** `CreateCustomOrder` / `RecordDeposit` use cases, `CustomOrderFormController` (field-keyed validation, integer-cent money parsing, injectable clock and ID generator), `CustomOrderFormPage`, `CustomOrderConfirmationPage` with a deposit receipt that carries no payment identifier, `FulfillmentStatusChip`, `showReferenceImageDialog()`, an optional `onCreateCustomOrder` action on `OrdersPage`, the `/orders/custom` web route, and `MobileCustomOrderPage` behind an orders FAB. 146 new tests. **Delivered (11B):** `CreateCustomOrder` / `RecordDeposit` use cases, `CustomOrderFormController` (field-keyed validation, integer-cent money parsing, injectable clock and ID generator), `CustomOrderFormPage`, `CustomOrderConfirmationPage` with a deposit receipt that carries no payment identifier, `FulfillmentStatusChip`, `showReferenceImageDialog()`, an optional `onCreateCustomOrder` action on `OrdersPage`, the `/orders/custom` web route, and `MobileCustomOrderPage` behind an orders FAB. 146 new tests.
**Still open:** `CustomOrderTrackingPage`, the status-change timeline, catalogue product-base selection, platform camera / file-picker image capture, and inventory deduction on completion (Stage 11C). **Delivered (11C):** `FulfillmentEvent` (append-only transition record with operator notes and a malformed-line-tolerant wire format), `MaterialUsage` + `MaterialConsumptionPort` + `MaterialDeductionResult`, `Order.fulfillmentHistory` / `materialUsage` and their derived accessors, `advanceFulfillmentStatus()` extended with note and timestamp, `recordMaterialUsage()`, `AdvanceFulfillment` / `CompleteCustomOrder` use cases, `CustomOrderTrackingController`, `CustomOrderTrackingPage` / `CustomOrderTrackingCard` / `CustomOrderTrackingDetail` / `FulfillmentTimeline` / `showAdvanceStageDialog`, an optional `onTrackCustomOrders` action on `OrdersPage`, the `/orders/tracking` web route, `MobileCustomOrderTrackingPage`, and `InventoryMaterialConsumptionAdapter` in both composition layers. 329 new tests.
**Dependencies:** Stage 9A ✅, Stage 11A ✅, Stage 11B ✅
**Architecture note:** inventory deduction crosses the feature boundary through an **outbound port**, not a package dependency. `feature_orders` declares `MaterialConsumptionPort`; each app's composition layer supplies the adapter over its own `InventoryRepository`. `feature_orders` still has no dependency on `feature_inventory`. `CompleteCustomOrder` advances the order before deducting, so a rejected transition can never decrement stock.
**Deferred (not blocking):** status-change notification triggers need an outbound messaging channel the platform does not have yet — `FulfillmentEvent` is the hook when one lands. Catalogue product-base selection and platform camera / file-picker capture remain with the media pipeline. A material-usage editing UI belongs with Stage 12B's supplies workflows.
**Dependencies:** Stage 9A ✅, Stage 11A ✅, Stage 11B ✅, Stage 11C ✅
#### P2.3 — Inventory Write Operations & Supplies Tracking (Stage 12A/12B) #### P2.3 — Inventory Write Operations & Supplies Tracking (Stage 12A/12B)
@ -646,13 +651,13 @@ These items harden the platform for production deployment.
| `integrations` | ⚠️ PARTIAL | 70% | 123 | RetryPolicy/RateLimiter not wired; `ChannelAdapter` not implemented | | `integrations` | ⚠️ PARTIAL | 70% | 123 | RetryPolicy/RateLimiter not wired; `ChannelAdapter` not implemented |
| `feature_wordpress` | ✅ COMPLETE | 100% | 545 | None — `SquareSyncController` landed (Stage 10E) | | `feature_wordpress` | ✅ COMPLETE | 100% | 545 | None — `SquareSyncController` landed (Stage 10E) |
| `feature_inventory` | ⚠️ PARTIAL | 65% | 167 | No unified sync engine; ItemType/Supplier/PurchaseOrder missing | | `feature_inventory` | ⚠️ PARTIAL | 65% | 167 | No unified sync engine; ItemType/Supplier/PurchaseOrder missing |
| `feature_orders` | ⚠️ PARTIAL | 85% | 657 | Custom order **domain+data** landed (11A); custom order **UI** open | | `feature_orders` | ✅ COMPLETE | 100% | 1,085 | None — Stage 11 complete (domain 11A, intake UI 11B, tracking 11C) |
| `feature_policy` | ⚠️ PARTIAL | 50% | 17 | Fake-only; minimal tests; policy register empty | | `feature_policy` | ⚠️ PARTIAL | 50% | 17 | Fake-only; minimal tests; policy register empty |
| `feature_finance` | ❌ MISSING | 0% | 1 | Pure stub | | `feature_finance` | ❌ MISSING | 0% | 1 | Pure stub |
| `feature_mrp` | ❌ MISSING | 0% | 1 | Pure stub | | `feature_mrp` | ❌ MISSING | 0% | 1 | Pure stub |
| `feature_social` | ❌ MISSING | 0% | 1 | Pure stub | | `feature_social` | ❌ MISSING | 0% | 1 | Pure stub |
| `kell_web` | ⚠️ PARTIAL | 85% | 62 | Finance placeholder remains; Integrations page now real | | `kell_web` | ⚠️ PARTIAL | 85% | 81 | Finance placeholder remains; custom order tracking route landed (11C) |
| `kell_mobile` | ⚠️ PARTIAL | 85% | 81 | Finance placeholder remains; POS/checkout landed (Stage 10C) | | `kell_mobile` | ⚠️ PARTIAL | 85% | 109 | Finance placeholder remains; custom order tracking landed (11C) |
| Architecture docs | ⚠️ PARTIAL | 80% | — | Square not represented; multi-channel model missing | | Architecture docs | ⚠️ PARTIAL | 80% | — | Square not represented; multi-channel model missing |
| Policy register | ❌ MISSING | 10% | — | All CSVs empty | | Policy register | ❌ MISSING | 10% | — | All CSVs empty |
| CI/CD | ✅ COMPLETE | 85% | — | No coverage thresholds enforced | | CI/CD | ✅ COMPLETE | 85% | — | No coverage thresholds enforced |

View File

@ -11,18 +11,90 @@
- merged: `feat/custom-order-domain` (Stage 11A) — custom order domain and data; merged to main 2026-08-29, commit `85501fd` - merged: `feat/custom-order-domain` (Stage 11A) — custom order domain and data; merged to main 2026-08-29, commit `85501fd`
- main baseline updated through: `feat/custom-order-domain` (Stage 11A complete, merged 2026-08-29) - main baseline updated through: `feat/custom-order-domain` (Stage 11A complete, merged 2026-08-29)
- reconciled: `chore/state-reconciliation-flutter-347` — working tree vs. `main` reconciled 2026-09-25; toolchain moved to Flutter 3.47.0 / Dart 3.13.0; master documents re-baselined - reconciled: `chore/state-reconciliation-flutter-347` — working tree vs. `main` reconciled 2026-09-25; toolchain moved to Flutter 3.47.0 / Dart 3.13.0; master documents re-baselined
- in progress: `feat/custom-order-ui` (Stage 11B) — Custom order creation UI; complete, pending merge - merged: `feat/custom-order-ui` (Stage 11B) — custom order intake UI; merged to main 2026-09-25, commit `6c299e8`
- next branch: `feat/custom-order-tracking` (Stage 11C) — Custom order tracking and fulfillment - main baseline updated through: `feat/custom-order-ui` (Stage 11B complete, merged 2026-09-25)
- total tests on branch: 2044/2044 passing (+146 new: 135 in feature_orders, 6 in kell_mobile, 5 in kell_web); dart analyze --fatal-infos clean across all 11 packages and apps - in progress: `feat/custom-order-tracking` (Stage 11C) — Custom order tracking and fulfillment; complete, pending merge
- next branch: `feat/inventory-write-ops` (Stage 12A) — Inventory write operations and domain expansion
- total tests on branch: 2373/2373 passing (+329 new: 293 in feature_orders, 22 in kell_mobile, 14 in kell_web); dart analyze --fatal-infos clean across all 11 packages and apps
- note: previously quoted totals omitted `feature_policy` (17 tests); it is now included in the count - note: previously quoted totals omitted `feature_policy` (17 tests); it is now included in the count
- release review: `docs/operations/release-checklist.md` created 2026-08-29 — per-environment release gates for Development, Test/QA, Staging, and Production - release review: `docs/operations/release-checklist.md` created 2026-08-29 — per-environment release gates for Development, Test/QA, Staging, and Production
- toolchain: Flutter 3.47.0 / Dart 3.13.0. Flutter 3.47 auto-generates an `analyzer.exclude` block in every `analysis_options.yaml` on each `pub get` / `analyze` / `run` / `build` with no opt-out ([flutter#191160](https://github.com/flutter/flutter/issues/191160)). **Do not open a slice to revert it** — see "Toolchain baseline" in `master_development_brief.md` - toolchain: Flutter 3.47.0 / Dart 3.13.0. Flutter 3.47 auto-generates an `analyzer.exclude` block in every `analysis_options.yaml` on each `pub get` / `analyze` / `run` / `build` with no opt-out ([flutter#191160](https://github.com/flutter/flutter/issues/191160)). **Do not open a slice to revert it** — see "Toolchain baseline" in `master_development_brief.md`
## Slice tracker ## Slice tracker
### feat/custom-order-ui ### feat/custom-order-tracking
- status: complete, pending merge - status: complete, pending merge
- branch: `feat/custom-order-tracking`
- stage: 11C — Custom order tracking and fulfillment
- date: 2026-09-25
- baseline: `6c299e8` (main, Stage 11B complete)
- inspection: complete
- implementation: complete
- closes audit findings: P2.2 (tracking portion — closes the finding entirely; Stage 11 is now complete)
- scope: domain, data, application, presentation, and app wiring across `feature_orders`, `kell_web`, and `kell_mobile`
- files added — `feature_orders` domain:
- `lib/src/domain/fulfillment_event.dart` — `FulfillmentEvent` (from/status/occurredAt/note) with `intake()`, `isIntake`, `isRework`, `hasNote`, `description`, and a `from|to|iso|note` wire format that sanitises separators out of notes; `fromWire()` tolerates malformed lines
- `lib/src/domain/material_usage.dart` — `MaterialUsage` (sku, quantity, displayName) with `isValid`, `label`, and a `sku|qty|name` wire format
- `lib/src/domain/material_consumption_port.dart` — `MaterialDeductionResult` (per-SKU `failures`, `isPartial`, `totalUnitsDeducted`, `summary`) and the `MaterialConsumptionPort` outbound port, plus `NoopMaterialConsumptionPort`
- files added — `feature_orders` application:
- `lib/src/application/advance_fulfillment.dart` — `AdvanceFulfillment` use case; adds no rule of its own, so the pipeline is defined in exactly one place
- `lib/src/application/complete_custom_order.dart` — `CompleteCustomOrder` + `CompleteCustomOrderResult`; advances **first**, deducts second, and never lets a stock failure fail the completion
- `lib/src/application/custom_order_tracking_controller.dart` — `CustomOrderTrackingController` (`ChangeNotifier`): custom-order-only load, stage filter, search over customer/ID/spec summary, `stageCounts`, `activeCount`, `advanceTo()` (routing completions through `CompleteCustomOrder`), and `consumeActionResult()` / `consumeDeductionWarning()`
- files added — `feature_orders` presentation:
- `lib/src/presentation/custom_order_tracking_page.dart` — `CustomOrderTrackingPage`; master-detail above `KcBreakpoints.medium`, pushed detail below it
- `lib/src/presentation/widgets/fulfillment_timeline.dart` — `FulfillmentTimeline`; dot-and-rail rows, rework marker, colour-coded by outcome
- `lib/src/presentation/widgets/custom_order_tracking_card.dart` — `CustomOrderTrackingCard`; leads with the **workshop stage**, spec summary, balance, and last update
- `lib/src/presentation/widgets/custom_order_tracking_detail.dart` — `CustomOrderTrackingDetail`; stage action, spec, deposit/balance, materials, timeline
- `lib/src/presentation/widgets/advance_stage_dialog.dart` — `showAdvanceStageDialog()` + `AdvanceStageResult`; offers only domain-legal stages and an operator progress note
- files changed — `feature_orders`:
- `lib/src/domain/order.dart` — added `fulfillmentHistory` and `materialUsage` (both default `const []`); added `currentStage`, `hasFulfillmentHistory`, `latestFulfillmentEvent`, `lastFulfillmentUpdate`, `allowedNextStages`, `isFulfillmentComplete`, `hasMaterialUsage`, `deductibleMaterials`, and the append-only `withFulfillmentEvent()`. **All existing fields, defaults, and `copyWith` semantics preserved** — every Stage 11A/11B test passed unchanged (792/792 verified before any new test was added)
- `lib/src/domain/orders_repository.dart` — `advanceFulfillmentStatus()` gained optional `note` and `occurredAt`; added `recordMaterialUsage()`
- `lib/src/data/fake_orders_repository.dart` — injectable `now` clock; `advanceFulfillmentStatus` appends an event; `createCustomOrder` seeds an intake event; `recordMaterialUsage` implemented; all five seeded commissions now carry realistic histories (KC-C2004 includes a rework loop) and material usage
- `lib/src/data/woo_commerce_orders_repository.dart` — injectable `now` clock; same history and material behaviour as the fake
- `lib/src/data/woo_commerce_order_mapper.dart` — two new meta keys (`_kc_fulfillment_history`, `_kc_material_usage`), `trackingMetaEntries()`, `historyFromMeta()`, `materialUsageFromMeta()`; malformed lines are skipped, empty lists are omitted rather than blanking remote values
- `lib/src/presentation/orders_page.dart` — optional `onTrackCustomOrders` callback adds a "Track Custom Orders" action beside the existing intake action; both omitted when the host has no route
- `lib/feature_orders.dart` — barrel exports for all new types
- `README.md` — documents the tracking workspace, the timeline, and the inventory-deduction port
- files changed — apps:
- `kell_web/lib/composition/inventory_material_consumption_adapter.dart` — **new** adapter bridging `InventoryRepository` to `MaterialConsumptionPort`; tolerant by contract, never throws, logs per-SKU failures
- `kell_web/lib/composition/app_services.dart` — `materialConsumptionPort` built from the inventory repository
- `kell_web/lib/routing/app_routes.dart` — new `/orders/tracking` route; `_parseStage()` returns `null` for an unknown stage rather than `FulfillmentStatus.fromWire`'s `quoted` default, which would be the wrong behaviour for a filter
- `kell_web/lib/navigation/app_navigation.dart` — `ordersToCustomOrderTracking()`
- `kell_mobile/lib/composition/inventory_material_consumption_adapter.dart` — **new**; deliberately duplicated rather than shared, matching the existing `get_dashboard_summary.dart` convention
- `kell_mobile/lib/composition/mobile_app_services.dart` — `materialConsumptionPort`
- `kell_mobile/lib/pages/mobile_custom_order_tracking_page.dart` — **new** full-screen `SafeArea` host for the shared page
- `kell_mobile/lib/pages/mobile_orders_page.dart` — small tracking FAB above the existing intake FAB; reloads the list on return
- `kell_mobile/lib/shell/mobile_shell.dart` — passes the material port through
- tests updated (contract widening):
- `feature_orders/test/widgets/orders_page_test.dart` — `_EmptyOrdersRepository` satisfies the widened contract
- `kell_web/test/dashboard/application/dashboard_controller_test.dart` — `_StubOrdersRepository` satisfies the widened contract
- tests added (329 new):
- `feature_orders/test/fulfillment_event_test.dart` — 32 tests (construction, intake, rework detection, description, wire round trip, separator/newline sanitisation, malformed-line tolerance, equality)
- `feature_orders/test/material_usage_test.dart` + `material_consumption_port_test.dart` — 41 tests (validity, labels, wire format, deduction-result shape, summaries, unmodifiable collections, noop port, partial failure)
- `feature_orders/test/order_tracking_test.dart` — 33 tests (defaults, `currentStage`, history accessors, allowed stages, deductible materials, `withFulfillmentEvent` immutability and append-only behaviour, `copyWith`)
- `feature_orders/test/fake_orders_repository_tracking_test.dart` — 31 tests (seeded-history integrity, incl. checks that **every** seeded transition is domain-legal and that each history ends at the stamped stage; note trimming, clock injection, illegal-advance no-op, `recordMaterialUsage` validation)
- `feature_orders/test/woo_commerce_tracking_meta_test.dart` — 22 tests (meta keys, packing, read/write round trip, malformed-line skipping, no payment identifier on the wire)
- `feature_orders/test/complete_custom_order_test.dart` — 22 tests (ordering — no deduction on a rejected transition; partial-failure warnings, unwired port, empty materials, `AdvanceFulfillment` delegation and verbatim rejection reasons)
- `feature_orders/test/custom_order_tracking_controller_test.dart` — 45 tests (load, stage filter, counts, search, selection, advance, rework, cancel, completion routing, deduction warnings, unwired use cases)
- `feature_orders/test/widgets/fulfillment_timeline_test.dart` — 14 tests (empty state, rows, timestamps, notes, rework marker, ordering, `newestFirst`, privacy)
- `feature_orders/test/widgets/custom_order_tracking_page_test.dart`, `custom_order_tracking_detail_test.dart`, `advance_stage_dialog_test.dart` — 53 tests (listing, stage chips with counts, search, intake action, header, stage-action states, spec, money, materials, timeline, dialog selection/notes/cancel)
- `kell_mobile/test/pages/mobile_custom_order_tracking_page_test.dart` — 9 tests (app bar, shared host, `SafeArea`, listing, chips, filtering, intake action, port injection)
- `kell_mobile/test/composition/inventory_material_consumption_adapter_test.dart` — 13 tests (real stock decrement, missing SKU, invalid record, rejected adjustment, throwing repository, plus **end-to-end** completion decrementing real inventory and a rejected completion leaving it untouched)
- `kell_web/test/routing/custom_order_tracking_route_test.dart` — 14 tests (route constant, shell rendering, listing, chips, timeline, stage argument, unknown stage ignored, `selectedId`, orders entry point, port identity)
- architecture: `feature_orders` still has **no dependency on `feature_inventory`**. The orders layer declares `MaterialConsumptionPort`; each app's composition layer supplies the adapter — the same "feature states what it needs, app decides how" pattern already used for cross-feature navigation callbacks
- ordering guarantee: `CompleteCustomOrder` advances the order **before** deducting, so an illegal jump to `completed` can never decrement stock. Asserted by test in both the use case suite and the end-to-end adapter suite
- privacy: operator progress notes are workshop-only and the dialog labels them as such; the timeline and tracking detail render **no payment identifier anywhere** — asserted by test. Log context is limited to order IDs, SKUs, stage names, and quantities, and still passes through `KcRedactor`
- tests: passed (1085/1085 feature_orders +293, 109/109 kell_mobile +22, 81/81 kell_web +14; all other packages unchanged — core 90, design_system 51, auth 42, integrations 123, data 63, feature_wordpress 545, feature_inventory 167, feature_policy 17)
- analyze: passed (dart analyze --fatal-infos — no issues found in `feature_orders`, `kell_web`, or `kell_mobile`)
- format: `dart format --line-length 100` applied to every file touched by this slice
- test-harness notes: the stage chip row scrolls horizontally, so tests `scrollUntilVisible` before tapping a later chip rather than working around a layout defect; the detail panel's indeterminate `LinearProgressIndicator` never settles, so the `isUpdating` test uses `pump()` rather than `pumpAndSettle()`
- defect found and fixed while testing: `CustomOrderTrackingCard`'s footer row overflowed at the 340 px master-list width; both halves are now `Flexible` with ellipsis
- brief updated: yes
### feat/custom-order-ui
- status: merged to main
- branch: `feat/custom-order-ui` - branch: `feat/custom-order-ui`
- stage: 11B — Custom order creation UI - stage: 11B — Custom order creation UI
- date: 2026-09-25 - date: 2026-09-25

View File

@ -132,6 +132,9 @@ Rules:
- ✅ `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, and `squareSyncController` wired into both shells landed (Stage 10F complete — branch `feat/square-sync-ui` ready for merge, 2026-07-25). - ✅ `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, and `squareSyncController` wired into both shells landed (Stage 10F complete — branch `feat/square-sync-ui` ready for merge, 2026-07-25).
- ✅ Square Mobile POS / Checkout landed (Stage 10C-POS complete — merged `feat/square-mobile-pos` → `main`, commit `7bf8f55`, 2026-07-25). - ✅ Square Mobile POS / Checkout landed (Stage 10C-POS complete — merged `feat/square-mobile-pos` → `main`, commit `7bf8f55`, 2026-07-25).
- ✅ Platform observability activated (Stage 16A complete — merged `feat/observability-error-logging` → `main`, 2026-08-29). Structured logging (`KcLogger`, `KcLogRecord`, `KcLogLevel`), log sinks (`KcConsoleLogSink`, `KcInMemoryLogSink`, `KcMultiLogSink`), credential redaction (`KcRedactor`), global uncaught-error capture (`KcErrorReporter`), and operator bug reporting (`KcBugReport`, `KcBugReportService`, `KcBugReportDialog`) wired into both app shells. - ✅ Platform observability activated (Stage 16A complete — merged `feat/observability-error-logging` → `main`, 2026-08-29). Structured logging (`KcLogger`, `KcLogRecord`, `KcLogLevel`), log sinks (`KcConsoleLogSink`, `KcInMemoryLogSink`, `KcMultiLogSink`), credential redaction (`KcRedactor`), global uncaught-error capture (`KcErrorReporter`), and operator bug reporting (`KcBugReport`, `KcBugReportService`, `KcBugReportDialog`) wired into both app shells.
- ✅ Custom order domain and data landed (Stage 11A complete — merged `feat/custom-order-domain` → `main`, commit `85501fd`, 2026-08-29). `OrderType`, `CustomOrderSpec`, `DepositInfo` (integer cents), the `FulfillmentStatus` pipeline with domain-enforced transitions, and four new `OrdersRepository` methods across both implementations.
- ✅ Custom order intake UI landed (Stage 11B complete — merged `feat/custom-order-ui` → `main`, commit `6c299e8`, 2026-09-25). `CustomOrderFormController`, `CustomOrderFormPage`, `CustomOrderConfirmationPage`, the `/orders/custom` web route, and `MobileCustomOrderPage`.
- ✅ Custom order tracking and fulfillment landed (Stage 11C complete — branch `feat/custom-order-tracking` ready for merge, 2026-09-25). `FulfillmentEvent` timeline, `MaterialUsage` + `MaterialConsumptionPort`, `CustomOrderTrackingController`, `CustomOrderTrackingPage`, `FulfillmentTimeline`, `showAdvanceStageDialog`, the `/orders/tracking` web route, `MobileCustomOrderTrackingPage`, and `InventoryMaterialConsumptionAdapter` in both composition layers. **Stage 11 complete; audit finding P2.2 closed.**
### Current narrow edit capabilities on `main` ### Current narrow edit capabilities on `main`
@ -143,25 +146,25 @@ Rules:
### Latest known validation state on `main` ### Latest known validation state on `main`
> **Last verified:** 2026-09-25 — Stage 11B validation run (`feat/custom-order-ui`) > **Last verified:** 2026-09-25 — Stage 11C validation run (`feat/custom-order-tracking`)
- `dart analyze --fatal-infos` clean across all 11 packages/apps - `dart analyze --fatal-infos` clean across all 11 packages/apps
- All packages passing - All packages passing
- latest reported count for `core`: `90/90 passed` - latest reported count for `core`: `90/90 passed`
- latest reported count for `design_system`: `51/51 passed` - latest reported count for `design_system`: `51/51 passed`
- latest reported count for `feature_inventory`: `167/167 passed` - latest reported count for `feature_inventory`: `167/167 passed`
- latest reported count for `feature_orders`: `792/792 passed` ← +135 new (Stage 11B custom order UI) - latest reported count for `feature_orders`: `1,085/1,085 passed` ← +293 new (Stage 11C custom order tracking)
- latest reported count for `feature_wordpress`: `545/545 passed` - latest reported count for `feature_wordpress`: `545/545 passed`
- latest reported count for `kell_web`: `67/67 passed` ← +5 new (custom order route) - latest reported count for `kell_web`: `81/81 passed` ← +14 new (custom order tracking route)
- latest reported count for `integrations`: `123/123 passed` - latest reported count for `integrations`: `123/123 passed`
- latest reported count for `data`: `63/63 passed` - latest reported count for `data`: `63/63 passed`
- latest reported count for `auth`: `42/42 passed` - latest reported count for `auth`: `42/42 passed`
- latest reported count for `kell_mobile`: `87/87 passed` ← +6 new (`MobileCustomOrderPage`) - latest reported count for `kell_mobile`: `109/109 passed` ← +22 new (`MobileCustomOrderTrackingPage`, inventory adapter)
- latest reported count for `feature_policy`: `17/17 passed` (single smoke-test file) - latest reported count for `feature_policy`: `17/17 passed` (single smoke-test file)
- total: `2,044/2,044 passed` (+146 new from Stage 11B branch `feat/custom-order-ui`) - total: `2,373/2,373 passed` (+329 new from Stage 11C branch `feat/custom-order-tracking`)
- main baseline updated through: `feat/custom-order-domain` (Stage 11A complete, merged 2026-08-29, commit `85501fd`) - main baseline updated through: `feat/custom-order-ui` (Stage 11B complete, merged 2026-09-25, commit `6c299e8`)
- current branch work: `feat/custom-order-ui` (Stage 11B) — complete, pending merge - current branch work: `feat/custom-order-tracking` (Stage 11C) — complete, pending merge
- next branch: `feat/custom-order-tracking` (Stage 11C) — Custom order tracking and fulfillment - next branch: `feat/inventory-write-ops` (Stage 12A) — Inventory write operations and domain expansion
> **Note:** The historically quoted total of `1,458` excluded `feature_policy`, which was not listed in the tracker's counted set. Including its 17 tests, the true grand total is `1,475`. `feature_policy` coverage remains thin — all 17 tests live in one smoke-test file with no controller or widget tests. Expanding it is tracked as P3.6 and remains a Test/QA release blocker. > **Note:** The historically quoted total of `1,458` excluded `feature_policy`, which was not listed in the tracker's counted set. Including its 17 tests, the true grand total is `1,475`. `feature_policy` coverage remains thin — all 17 tests live in one smoke-test file with no controller or widget tests. Expanding it is tracked as P3.6 and remains a Test/QA release blocker.
@ -405,16 +408,84 @@ Validation:
> actually needs. Reference images are captured as URLs to already-uploaded > actually needs. Reference images are captured as URLs to already-uploaded
> media; the platform camera and file pickers land with the media pipeline. > media; the platform camera and file pickers land with the media pipeline.
### Stage 11C — complete (2026-09-25)
**`feat/custom-order-tracking`** — Custom order tracking and fulfillment
Closes audit finding P2.2 entirely. **Stage 11 is now complete.**
Delivered in `feature_orders` — domain:
- `FulfillmentEvent` — an append-only record of every accepted transition
(stage from, stage to, timestamp, optional operator note). Its wire format
strips separators and newlines out of notes, and `fromWire()` skips a
malformed line rather than discarding a whole timeline — order meta is
editable by any store administrator
- `MaterialUsage` — what a commission consumes, as SKU and quantity
- `MaterialConsumptionPort` + `MaterialDeductionResult` — the outbound port for
stock deduction, with **per-SKU** failures so one short count cannot discard
the other deductions
- `Order` gained `fulfillmentHistory`, `materialUsage`, and the derived
`currentStage` / `allowedNextStages` / `isFulfillmentComplete` /
`deductibleMaterials`, plus the append-only `withFulfillmentEvent()`. Every
existing field, default, and `copyWith` semantic is preserved
Delivered in `feature_orders` — data:
- `advanceFulfillmentStatus()` now records an event, with an optional operator
note and an injectable clock; `recordMaterialUsage()` added
- Both repositories implement the widened contract; WooCommerce round-trips the
timeline and materials through two further `_kc_` meta keys
- All five seeded commissions carry realistic histories — KC-C2004 includes a
**rework loop**, so the timeline surface has a backwards transition to render
Delivered in `feature_orders` — application and presentation:
- `AdvanceFulfillment`, `CompleteCustomOrder`, `CustomOrderTrackingController`
- `CustomOrderTrackingPage`, `CustomOrderTrackingCard`,
`CustomOrderTrackingDetail`, `FulfillmentTimeline`, `showAdvanceStageDialog`
- `OrdersPage` gains an optional `onTrackCustomOrders` action
Wired into both app targets:
- `kell_web` — `/orders/tracking` route plus
`AppNavigation.ordersToCustomOrderTracking()`
- `kell_mobile` — `MobileCustomOrderTrackingPage` behind a tracking FAB
- Both — `InventoryMaterialConsumptionAdapter` in the composition layer
Two decisions worth recording:
1. **Inventory deduction goes through a port, not a dependency.**
`feature_orders` and `feature_inventory` are siblings; a direct dependency
would couple the custom order pipeline to a stock implementation. The orders
layer declares `MaterialConsumptionPort` and each app supplies the adapter.
2. **Completion advances first, deducts second.** Deducting first would
decrement stock for a completion the domain then rejected. A deduction
failure yields a warning, never a failed commission — an operator handing
over finished work must not be blocked by a stock discrepancy.
Validation:
- 329 new tests (293 `feature_orders`, 22 `kell_mobile`, 14 `kell_web`); 2,373
total across all packages
- `dart analyze --fatal-infos` clean across all 11 packages and apps
### Next recommended branch ### Next recommended branch
**`feat/custom-order-tracking`** — Stage 11C: Custom order tracking and fulfillment **`feat/inventory-write-ops`** — Stage 12A: Inventory write operations and domain expansion
- `CustomOrderTrackingPage` — custom orders filtered by fulfillment stage - `ItemType` enum and an expanded `InventoryItem` (reorder point, supplier,
- Status progression UI driving `advanceFulfillmentStatus()`, with illegal cost per unit, location, last count date)
transitions surfaced as the domain's own rejection reasons - `InventoryAdjustment` value object and the adjustment history it implies
- Order history / timeline view of status changes with timestamps - `InventoryRepository` write operations: `addItem()`, `updateItem()`,
- Link custom order completion to inventory deduction for materials used `getAdjustmentHistory()`, `getLowStockItems()`, `getItemsByType()`
- Tests for the tracking UI and the status progression paths - `InventoryController` mirroring the `ProductPublishingController` pattern
- Tests for the new domain models, repository methods, and controller
> **Note:** Stage 11C's `InventoryMaterialConsumptionAdapter` already calls
> `adjustQuantity()` with a custom-order reason string. Stage 12A's adjustment
> history should capture those calls, giving the workshop an audit trail of
> which commission consumed which material.
--- ---
@ -470,7 +541,7 @@ These items complete planned stages and wire existing contracts into production
| ID | Item | Target Stage | | ID | Item | Target Stage |
| ----- | ---------------------------------------------------------------------------------- | --------------------------- | | ----- | ---------------------------------------------------------------------------------- | --------------------------- |
| P2.1 | Square Mobile POS / Checkout (`MobileCheckoutPage`, Square Reader SDK) | Stage 10C | | P2.1 | Square Mobile POS / Checkout (`MobileCheckoutPage`, Square Reader SDK) | Stage 10C |
| P2.2 | Custom Orders Workflow — domain + data (11A) and intake UI (11B) DONE; tracking open | Stage 11C | | P2.2 | ~~Custom Orders Workflow~~ - DONE: domain + data (11A), intake UI (11B), tracking (11C) | Complete |
| P2.3 | Inventory Write Ops & Supplies Tracking (`ItemType`, `Supplier`, `PurchaseOrder`) | Stage 12A/B | | P2.3 | Inventory Write Ops & Supplies Tracking (`ItemType`, `Supplier`, `PurchaseOrder`) | Stage 12A/B |
| P2.4 | Multi-Channel Inventory Sync Engine (`InventorySyncEngine`, `ChannelStockMapping`) | Stage 12C | | P2.4 | Multi-Channel Inventory Sync Engine (`InventorySyncEngine`, `ChannelStockMapping`) | Stage 12C |
| P2.5 | Wire `WooCommerceOrdersRepository` into `AppServices.wordpress()` | Stage 10E or standalone | | P2.5 | Wire `WooCommerceOrdersRepository` into `AppServices.wordpress()` | Stage 10E or standalone |
@ -1154,7 +1225,10 @@ Provide mobile and web interfaces for creating custom orders, especially at mark
and web routing (`/orders/custom` + `OrdersPage` action) and web routing (`/orders/custom` + `OrdersPage` action)
- ✅ Tests for form validation and the creation flow (146 new) - ✅ Tests for form validation and the creation flow (146 new)
#### Stage 11C — Custom order tracking and fulfillment #### ~~Stage 11C — Custom order tracking and fulfillment~~ ✅ COMPLETE
> Delivered on `feat/custom-order-tracking` (2026-09-25).
> See "Stage 11C — complete (2026-09-25)" above for the full manifest.
##### Goal ##### Goal
@ -1162,12 +1236,31 @@ Provide order tracking dashboard and status management for custom orders.
##### Requirements ##### Requirements
- Create `CustomOrderTrackingPage` — filtered view of custom orders by status - ✅ Create `CustomOrderTrackingPage` — custom orders filtered by **fulfillment
- Add status progression: `quoted` → `depositReceived` → `inProduction` → `qualityCheck` → `readyForPickup` → `completed` stage**, with live per-stage counts, free-text search over customer / ID /
- Add notification triggers (future: email/SMS when status changes) specification, and a master-detail layout above `KcBreakpoints.medium`
- Add order history/timeline view showing all status changes with timestamps - ✅ Status progression `quoted` → `depositReceived` → `inProduction` →
- Link custom order completion to inventory deduction (materials used) `qualityCheck` → `readyForPickup` → `completed` driven through
- Tests for tracking UI and status progression `showAdvanceStageDialog`, which offers only domain-legal stages. Illegal
transitions are rejected by the repository and surfaced as
`FulfillmentTransition.rejectionReason` **verbatim** — the UI never invents
wording for a rule it does not own
- ⚠️ Notification triggers not built. They need an outbound messaging channel
the platform does not yet have; `FulfillmentEvent` is the natural hook when
one lands. Deferred rather than stubbed
- ✅ Order history / timeline view — `FulfillmentEvent` is appended on every
accepted transition and rendered by `FulfillmentTimeline` with timestamps,
operator notes, and a rework marker
- ✅ Custom order completion linked to inventory deduction via
`MaterialConsumptionPort` and each app's
`InventoryMaterialConsumptionAdapter`. Completion advances **first** and
deducts second, so a rejected transition can never decrement stock
- ✅ Tests for the tracking UI and every status progression path (329 new)
> **Deferred:** notification triggers (above). `MaterialUsage` is recorded
> through `recordMaterialUsage()` but has no editing UI yet — the natural home
> for one is Stage 12B's supplies workflows, where materials and suppliers are
> already on screen.
--- ---

View File

@ -0,0 +1,104 @@
import 'package:core/core.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
/// Adapts an [InventoryRepository] to the orders layer's
/// [MaterialConsumptionPort].
///
/// This adapter lives in the app's composition layer on purpose.
/// `feature_orders` and `feature_inventory` are sibling features; neither may
/// depend on the other, or the custom order pipeline becomes coupled to a
/// stock implementation. The orders layer declares the narrow port it needs
/// and the app decides which repository satisfies it — the same arrangement
/// already used for cross-feature navigation callbacks.
///
/// Deliberately duplicated from `kell_web`'s composition layer rather than
/// shared. The two apps have no common composition package, and the existing
/// `get_dashboard_summary.dart` follows the same convention — a shared
/// package for two twenty-line adapters would couple the app targets for no
/// benefit.
///
/// ## Failure behaviour
///
/// Deduction is **tolerant by contract**: one material failing must not abort
/// the rest, and nothing here may throw. A missing SKU, an unsuccessful
/// adjustment, and an unexpected exception all become entries in
/// [MaterialDeductionResult.failures] so the operator sees exactly which
/// material could not be decremented while the commission still completes.
class InventoryMaterialConsumptionAdapter implements MaterialConsumptionPort {
final InventoryRepository _inventory;
/// Structured logger for deduction failures.
///
/// Defaults to [KcLogger.silent] so tests need not supply one. Log context
/// carries SKUs, order IDs, and quantities only — never customer identity,
/// money amounts, or payment identifiers.
final KcLogger logger;
InventoryMaterialConsumptionAdapter({required InventoryRepository inventory, KcLogger? logger})
: _inventory = inventory,
logger = (logger ?? KcLogger.silent()).forSource(_logSource);
static const String _logSource = 'InventoryMaterialConsumptionAdapter';
@override
Future<MaterialDeductionResult> deductMaterials(
String orderId,
List<MaterialUsage> usages,
) async {
final deducted = <MaterialUsage>[];
final failures = <String, String>{};
for (final usage in usages) {
if (!usage.isValid) {
failures[usage.sku] = 'Invalid material record.';
continue;
}
try {
final item = await _inventory.getItemBySku(usage.sku);
if (item == null) {
failures[usage.sku] = 'No inventory item with this SKU.';
logger.warning(
'Material deduction skipped: SKU not in inventory.',
context: {'operation': 'deductMaterials', 'orderId': orderId, 'sku': usage.sku},
);
continue;
}
final result = await _inventory.adjustQuantity(
item.id,
-usage.quantity,
'Custom order $orderId materials',
);
if (result.success) {
deducted.add(usage);
} else {
failures[usage.sku] = result.errorMessage ?? 'Adjustment rejected.';
logger.warning(
'Material deduction rejected by inventory.',
context: {
'operation': 'deductMaterials',
'orderId': orderId,
'sku': usage.sku,
'quantity': usage.quantity,
},
);
}
} catch (e, stackTrace) {
// Tolerant by contract: record the failure and keep going so one bad
// SKU cannot abandon the remaining deductions.
failures[usage.sku] = 'Inventory error: $e';
logger.error(
'Unexpected error while deducting a material.',
context: {'operation': 'deductMaterials', 'orderId': orderId, 'sku': usage.sku},
error: e,
stackTrace: stackTrace,
);
}
}
return MaterialDeductionResult(deducted: deducted, failures: failures);
}
}

View File

@ -5,6 +5,8 @@ import 'package:feature_policy/feature_policy.dart';
import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:integrations/integrations.dart'; import 'package:integrations/integrations.dart';
import 'inventory_material_consumption_adapter.dart';
/// Default catalogue items used in fake/wordpress mode where no Square catalog /// Default catalogue items used in fake/wordpress mode where no Square catalog
/// is available. These seed the [MobileCheckoutPage] product list. /// is available. These seed the [MobileCheckoutPage] product list.
const _fakeCatalogue = [ const _fakeCatalogue = [
@ -47,6 +49,16 @@ class MobileAppServices extends KcAppServices {
/// and the bug report service backing the shell's "Report a problem" action. /// and the bug report service backing the shell's "Report a problem" action.
final KcObservability observability; final KcObservability observability;
/// Deducts raw materials from stock when a custom order completes.
///
/// Built here rather than inside `feature_orders` because the orders and
/// inventory features must stay independent — the orders layer declares the
/// port, the composition layer supplies the adapter.
late final MaterialConsumptionPort materialConsumptionPort = InventoryMaterialConsumptionAdapter(
inventory: inventoryRepository,
logger: observability.logger,
);
MobileAppServices({ MobileAppServices({
required this.inventoryRepository, required this.inventoryRepository,
required this.ordersRepository, required this.ordersRepository,
@ -69,8 +81,7 @@ class MobileAppServices extends KcAppServices {
/// Convenience accessor for the bug report service. /// Convenience accessor for the bug report service.
KcBugReportService get bugReportService => observability.bugReportService; KcBugReportService get bugReportService => observability.bugReportService;
static KcObservability _defaultObservability(String environmentLabel) => static KcObservability _defaultObservability(String environmentLabel) => KcObservability.standard(
KcObservability.standard(
appVersion: appVersion, appVersion: appVersion,
environment: environmentLabel, environment: environmentLabel,
platform: 'android', platform: 'android',

View File

@ -0,0 +1,50 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
/// Full-screen mobile host for the shared [CustomOrderTrackingPage].
///
/// Workshop tracking is a bench activity, so the mobile surface gets a
/// dedicated route rather than an inline panel. The list, stage filters,
/// advance dialog, and timeline all come from `feature_orders` — no business
/// logic is forked for mobile.
///
/// The body is wrapped in a [SafeArea] so the stage action is never clipped
/// by the system gesture bar.
class MobileCustomOrderTrackingPage extends StatelessWidget {
final OrdersRepository repository;
/// Deducts raw materials from stock when a commission completes.
///
/// Supplied by `MobileAppServices`; `null` disables deduction without
/// blocking completion.
final MaterialConsumptionPort? materialPort;
/// Optional callback that opens the custom order intake form.
final VoidCallback? onCreateCustomOrder;
/// Optional commission to pre-select on first load.
final String? initialSelectedId;
const MobileCustomOrderTrackingPage({
super.key,
required this.repository,
this.materialPort,
this.onCreateCustomOrder,
this.initialSelectedId,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Custom Order Tracking')),
body: SafeArea(
child: CustomOrderTrackingPage(
repository: repository,
materialPort: materialPort,
onCreateCustomOrder: onCreateCustomOrder,
initialSelectedId: initialSelectedId,
),
),
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'mobile_custom_order_page.dart'; import 'mobile_custom_order_page.dart';
import 'mobile_custom_order_tracking_page.dart';
import 'mobile_order_detail_page.dart'; import 'mobile_order_detail_page.dart';
/// Mobile-optimized orders workspace page. /// Mobile-optimized orders workspace page.
@ -22,11 +23,18 @@ class MobileOrdersPage extends StatefulWidget {
/// Optional callback to navigate to the Inventory page for a given SKU. /// Optional callback to navigate to the Inventory page for a given SKU.
final void Function(String sku)? onViewInventory; final void Function(String sku)? onViewInventory;
/// Deducts raw materials from stock when a commission completes.
///
/// Passed through to [MobileCustomOrderTrackingPage]; `null` disables
/// deduction without blocking completion.
final MaterialConsumptionPort? materialPort;
const MobileOrdersPage({ const MobileOrdersPage({
super.key, super.key,
required this.repository, required this.repository,
this.onViewProduct, this.onViewProduct,
this.onViewInventory, this.onViewInventory,
this.materialPort,
}); });
@override @override
@ -109,12 +117,27 @@ class _MobileOrdersPageState extends State<MobileOrdersPage> {
Positioned( Positioned(
right: KcSpacing.md, right: KcSpacing.md,
bottom: KcSpacing.md, bottom: KcSpacing.md,
child: FloatingActionButton.extended( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Tracking sits above intake: at a market the operator
// creates commissions; at the bench they track them.
FloatingActionButton.small(
heroTag: 'track-custom-orders',
tooltip: 'Track custom orders',
onPressed: _navigateToCustomOrderTracking,
child: const Icon(Icons.timeline_outlined),
),
const SizedBox(height: KcSpacing.sm),
FloatingActionButton.extended(
heroTag: 'new-custom-order', heroTag: 'new-custom-order',
onPressed: _navigateToCustomOrderForm, onPressed: _navigateToCustomOrderForm,
icon: const Icon(Icons.design_services_outlined), icon: const Icon(Icons.design_services_outlined),
label: const Text('Custom Order'), label: const Text('Custom Order'),
), ),
],
),
), ),
], ],
); );
@ -136,6 +159,24 @@ class _MobileOrdersPageState extends State<MobileOrdersPage> {
await _controller.load(); await _controller.load();
} }
/// Pushes the custom order tracking workspace and refreshes the list on
/// return, because advancing a commission can change its order status.
Future<void> _navigateToCustomOrderTracking() async {
setState(() => _detailPageActive = true);
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => MobileCustomOrderTrackingPage(
repository: widget.repository,
materialPort: widget.materialPort,
onCreateCustomOrder: _navigateToCustomOrderForm,
),
),
);
if (!mounted) return;
setState(() => _detailPageActive = false);
await _controller.load();
}
/// Search bar with real-time filtering. /// Search bar with real-time filtering.
Widget _buildSearchBar() { Widget _buildSearchBar() {
return Padding( return Padding(

View File

@ -177,6 +177,7 @@ class _MobileShellState extends State<MobileShell> {
case 3: case 3:
return MobileOrdersPage( return MobileOrdersPage(
repository: services.ordersRepository, repository: services.ordersRepository,
materialPort: services.materialConsumptionPort,
onViewProduct: (_) { onViewProduct: (_) {
setState(() => _selectedIndex = 2); setState(() => _selectedIndex = 2);
}, },

View File

@ -0,0 +1,151 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/composition/inventory_material_consumption_adapter.dart';
/// An inventory repository whose every operation throws.
class _ThrowingInventory extends FakeInventoryRepository {
@override
Future<InventoryItem?> getItemBySku(String sku) async => throw Exception('inventory offline');
}
/// An inventory repository that finds items but rejects every adjustment.
class _RejectingInventory extends FakeInventoryRepository {
@override
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) async =>
const InventoryAdjustmentResult.failure('Insufficient stock.');
}
InventoryMaterialConsumptionAdapter _adapter([InventoryRepository? inventory]) =>
InventoryMaterialConsumptionAdapter(inventory: inventory ?? FakeInventoryRepository());
void main() {
// 'BC-FLR-001' is a seeded SKU in FakeInventoryRepository.
const known = MaterialUsage(sku: 'BC-FLR-001', quantity: 2, displayName: 'Floral Bowl Cozy');
const alsoKnown = MaterialUsage(sku: 'CS-CIT-002', quantity: 1);
const unknown = MaterialUsage(sku: 'SUP-DOES-NOT-EXIST', quantity: 3);
group('InventoryMaterialConsumptionAdapter — success', () {
test('deducts a known SKU', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [known]);
expect(result.success, isTrue);
expect(result.deducted, [known]);
});
test('actually decrements the stock level', () async {
final inventory = FakeInventoryRepository();
final before = (await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand;
await _adapter(inventory).deductMaterials('KC-C2001', const [known]);
final after = (await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand;
expect(after, before - 2);
});
test('deducts every supplied material', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [known, alsoKnown]);
expect(result.deducted, hasLength(2));
expect(result.totalUnitsDeducted, 3);
});
test('an empty request succeeds with nothing deducted', () async {
final result = await _adapter().deductMaterials('KC-C2001', const []);
expect(result.success, isTrue);
expect(result.deducted, isEmpty);
});
});
group('InventoryMaterialConsumptionAdapter — failure tolerance', () {
test('records a missing SKU as a failure', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [unknown]);
expect(result.success, isFalse);
expect(result.failures['SUP-DOES-NOT-EXIST'], contains('No inventory item'));
});
test('one missing SKU does not abort the others', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [unknown, known]);
expect(result.deducted, [known]);
expect(result.failures.keys, ['SUP-DOES-NOT-EXIST']);
expect(result.isPartial, isTrue);
});
test('records an invalid usage record as a failure without calling inventory', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [
MaterialUsage(sku: 'SUP-BAD', quantity: 0),
]);
expect(result.failures['SUP-BAD'], contains('Invalid material record'));
});
test('records a rejected adjustment as a failure', () async {
final result = await _adapter(
_RejectingInventory(),
).deductMaterials('KC-C2001', const [known]);
expect(result.success, isFalse);
expect(result.failures['BC-FLR-001'], contains('Insufficient stock'));
});
test('never throws when the repository throws', () async {
final result = await _adapter(
_ThrowingInventory(),
).deductMaterials('KC-C2001', const [known]);
expect(result.success, isFalse);
expect(result.failures['BC-FLR-001'], contains('Inventory error'));
});
test('a throwing repository still processes every SKU', () async {
final result = await _adapter(
_ThrowingInventory(),
).deductMaterials('KC-C2001', const [known, alsoKnown]);
expect(result.failures, hasLength(2));
});
test('the failure summary names the SKUs but no customer or money detail', () async {
final result = await _adapter().deductMaterials('KC-C2001', const [unknown, known]);
expect(result.summary, contains('SUP-DOES-NOT-EXIST'));
expect(result.summary, isNot(contains(r'$')));
});
});
group('CompleteCustomOrder — end-to-end with the real adapter', () {
test('completing a commission decrements real inventory', () async {
final inventory = FakeInventoryRepository();
final orders = FakeOrdersRepository(now: () => DateTime(2026, 5, 1));
await orders.recordMaterialUsage('KC-C2005', const [known]);
final before = (await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand;
final result = await CompleteCustomOrder(orders, materialPort: _adapter(inventory))(
'KC-C2005',
);
expect(result.success, isTrue);
expect((await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand, before - 2);
});
test('a rejected completion leaves inventory untouched', () async {
final inventory = FakeInventoryRepository();
final orders = FakeOrdersRepository(now: () => DateTime(2026, 5, 1));
await orders.recordMaterialUsage('KC-C2001', const [known]);
final before = (await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand;
// KC-C2001 is only quoted; it cannot jump straight to completed.
final result = await CompleteCustomOrder(orders, materialPort: _adapter(inventory))(
'KC-C2001',
);
expect(result.success, isFalse);
expect((await inventory.getItemBySku('BC-FLR-001'))!.quantityOnHand, before);
});
});
}

View File

@ -0,0 +1,100 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/composition/inventory_material_consumption_adapter.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:kell_mobile/pages/mobile_custom_order_tracking_page.dart';
DateTime _fixedNow() => DateTime(2026, 5, 1, 12);
Widget _buildPage({MaterialConsumptionPort? port, VoidCallback? onCreateCustomOrder}) {
return MaterialApp(
home: MobileCustomOrderTrackingPage(
repository: FakeOrdersRepository(now: _fixedNow),
materialPort: port,
onCreateCustomOrder: onCreateCustomOrder,
),
);
}
/// Mounts the page tall enough that the master list and detail both render.
Future<void> _pump(WidgetTester tester, {Widget? page}) async {
tester.view.physicalSize = const Size(900, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(page ?? _buildPage());
await tester.pumpAndSettle();
}
void main() {
group('MobileCustomOrderTrackingPage', () {
testWidgets('shows the tracking app bar title', (tester) async {
await _pump(tester);
expect(find.byType(AppBar), findsOneWidget);
expect(find.text('Custom Order Tracking'), findsOneWidget);
});
testWidgets('hosts the shared CustomOrderTrackingPage', (tester) async {
await _pump(tester);
expect(find.byType(CustomOrderTrackingPage), findsOneWidget);
});
testWidgets('wraps the body in a SafeArea', (tester) async {
await _pump(tester);
expect(find.byType(SafeArea), findsWidgets);
});
testWidgets('lists the seeded commissions', (tester) async {
await _pump(tester);
expect(find.byType(CustomOrderTrackingCard), findsNWidgets(5));
});
testWidgets('renders the stage filter chips with counts', (tester) async {
await _pump(tester);
expect(find.text('All (5)'), findsOneWidget);
expect(find.text('Quoted (1)'), findsOneWidget);
});
testWidgets('filtering by stage narrows the list', (tester) async {
await _pump(tester);
// The chip row scrolls horizontally; at mobile width the later stages
// sit under the count label until scrolled into view.
await tester.scrollUntilVisible(
find.text('In Production (1)'),
200,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('In Production (1)'));
await tester.pumpAndSettle();
expect(find.byType(CustomOrderTrackingCard), findsOneWidget);
});
testWidgets('omits the intake action when no callback is supplied', (tester) async {
await _pump(tester);
expect(find.text('New Custom Order'), findsNothing);
});
testWidgets('renders the intake action when a callback is supplied', (tester) async {
await _pump(tester, page: _buildPage(onCreateCustomOrder: () {}));
expect(find.text('New Custom Order'), findsOneWidget);
});
testWidgets('accepts a material consumption port', (tester) async {
final port = InventoryMaterialConsumptionAdapter(inventory: FakeInventoryRepository());
await _pump(tester, page: _buildPage(port: port));
expect(find.byType(CustomOrderTrackingPage), findsOneWidget);
});
});
}

View File

@ -5,6 +5,8 @@ import 'package:feature_policy/feature_policy.dart';
import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:integrations/integrations.dart'; import 'package:integrations/integrations.dart';
import 'inventory_material_consumption_adapter.dart';
/// Holds the concrete service implementations used by `kell_web`. /// Holds the concrete service implementations used by `kell_web`.
/// ///
/// Extends [KcAppServices] from the shared `core` package so that the /// Extends [KcAppServices] from the shared `core` package so that the
@ -32,6 +34,16 @@ class AppServices extends KcAppServices {
/// and the bug report service backing the shell's "Report a problem" action. /// and the bug report service backing the shell's "Report a problem" action.
final KcObservability observability; final KcObservability observability;
/// Deducts raw materials from stock when a custom order completes.
///
/// Built here rather than inside `feature_orders` because the orders and
/// inventory features must stay independent — the orders layer declares the
/// port, the composition layer supplies the adapter.
late final MaterialConsumptionPort materialConsumptionPort = InventoryMaterialConsumptionAdapter(
inventory: inventoryRepository,
logger: observability.logger,
);
AppServices({ AppServices({
required this.inventoryRepository, required this.inventoryRepository,
required this.ordersRepository, required this.ordersRepository,
@ -52,8 +64,7 @@ class AppServices extends KcAppServices {
/// Convenience accessor for the bug report service. /// Convenience accessor for the bug report service.
KcBugReportService get bugReportService => observability.bugReportService; KcBugReportService get bugReportService => observability.bugReportService;
static KcObservability _defaultObservability(String environmentLabel) => static KcObservability _defaultObservability(String environmentLabel) => KcObservability.standard(
KcObservability.standard(
appVersion: appVersion, appVersion: appVersion,
environment: environmentLabel, environment: environmentLabel,
platform: 'web', platform: 'web',

View File

@ -0,0 +1,98 @@
import 'package:core/core.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
/// Adapts an [InventoryRepository] to the orders layer's
/// [MaterialConsumptionPort].
///
/// This adapter lives in the app's composition layer on purpose.
/// `feature_orders` and `feature_inventory` are sibling features; neither may
/// depend on the other, or the custom order pipeline becomes coupled to a
/// stock implementation. The orders layer declares the narrow port it needs
/// and the app decides which repository satisfies it — the same arrangement
/// already used for cross-feature navigation callbacks.
///
/// ## Failure behaviour
///
/// Deduction is **tolerant by contract**: one material failing must not abort
/// the rest, and nothing here may throw. A missing SKU, an unsuccessful
/// adjustment, and an unexpected exception all become entries in
/// [MaterialDeductionResult.failures] so the operator sees exactly which
/// material could not be decremented while the commission still completes.
class InventoryMaterialConsumptionAdapter implements MaterialConsumptionPort {
final InventoryRepository _inventory;
/// Structured logger for deduction failures.
///
/// Defaults to [KcLogger.silent] so tests need not supply one. Log context
/// carries SKUs, order IDs, and quantities only — never customer identity,
/// money amounts, or payment identifiers.
final KcLogger logger;
InventoryMaterialConsumptionAdapter({required InventoryRepository inventory, KcLogger? logger})
: _inventory = inventory,
logger = (logger ?? KcLogger.silent()).forSource(_logSource);
static const String _logSource = 'InventoryMaterialConsumptionAdapter';
@override
Future<MaterialDeductionResult> deductMaterials(
String orderId,
List<MaterialUsage> usages,
) async {
final deducted = <MaterialUsage>[];
final failures = <String, String>{};
for (final usage in usages) {
if (!usage.isValid) {
failures[usage.sku] = 'Invalid material record.';
continue;
}
try {
final item = await _inventory.getItemBySku(usage.sku);
if (item == null) {
failures[usage.sku] = 'No inventory item with this SKU.';
logger.warning(
'Material deduction skipped: SKU not in inventory.',
context: {'operation': 'deductMaterials', 'orderId': orderId, 'sku': usage.sku},
);
continue;
}
final result = await _inventory.adjustQuantity(
item.id,
-usage.quantity,
'Custom order $orderId materials',
);
if (result.success) {
deducted.add(usage);
} else {
failures[usage.sku] = result.errorMessage ?? 'Adjustment rejected.';
logger.warning(
'Material deduction rejected by inventory.',
context: {
'operation': 'deductMaterials',
'orderId': orderId,
'sku': usage.sku,
'quantity': usage.quantity,
},
);
}
} catch (e, stackTrace) {
// Tolerant by contract: record the failure and keep going so one bad
// SKU cannot abandon the remaining deductions.
failures[usage.sku] = 'Inventory error: $e';
logger.error(
'Unexpected error while deducting a material.',
context: {'operation': 'deductMaterials', 'orderId': orderId, 'sku': usage.sku},
error: e,
stackTrace: stackTrace,
);
}
}
return MaterialDeductionResult(deducted: deducted, failures: failures);
}
}

View File

@ -92,6 +92,22 @@ abstract final class AppNavigation {
); );
} }
// ── Orders → Custom order tracking ────────────────────────────────────
/// Orders page "Track Custom Orders" action → the tracking workspace.
///
/// [stage] is a `FulfillmentStatus.wireValue`; an absent or unrecognised
/// value leaves the tracking page unfiltered.
static void ordersToCustomOrderTracking(BuildContext context, {String? stage, String? orderId}) {
navigateTo(
context,
NavigationTarget(
route: AppRoutes.customOrderTracking,
arguments: {'stage': ?stage, 'selectedId': ?orderId},
),
);
}
// ── Products → Policy ───────────────────────────────────────────────── // ── Products → Policy ─────────────────────────────────────────────────
/// Product draft → Policy page filtered to "Product Compliance". /// Product draft → Policy page filtered to "Product Compliance".

View File

@ -19,6 +19,7 @@ abstract final class AppRoutes {
static const String products = '/products'; static const String products = '/products';
static const String orders = '/orders'; static const String orders = '/orders';
static const String customOrder = '/orders/custom'; static const String customOrder = '/orders/custom';
static const String customOrderTracking = '/orders/tracking';
static const String finance = '/finance'; static const String finance = '/finance';
static const String policy = '/policy'; static const String policy = '/policy';
static const String integrations = '/integrations'; static const String integrations = '/integrations';
@ -85,6 +86,7 @@ abstract final class AppRoutes {
onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku), onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku),
onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku), onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku),
onCreateCustomOrder: () => AppNavigation.ordersToCustomOrder(context), onCreateCustomOrder: () => AppNavigation.ordersToCustomOrder(context),
onTrackCustomOrders: () => AppNavigation.ordersToCustomOrderTracking(context),
initialFilter: args['filter'], initialFilter: args['filter'],
initialQuery: args['query'], initialQuery: args['query'],
initialSelectedId: args['selectedId'], initialSelectedId: args['selectedId'],
@ -103,6 +105,21 @@ abstract final class AppRoutes {
), ),
), ),
); );
case customOrderTracking:
return _buildRoute(settings, (context) {
final services = AppScope.of(context);
return AppShell(
selectedRoute: orders,
title: 'Custom Order Tracking',
child: CustomOrderTrackingPage(
repository: services.ordersRepository,
materialPort: services.materialConsumptionPort,
onCreateCustomOrder: () => AppNavigation.ordersToCustomOrder(context),
initialStage: _parseStage(args['stage']),
initialSelectedId: args['selectedId'],
),
);
});
case finance: case finance:
return _buildRoute( return _buildRoute(
settings, settings,
@ -161,6 +178,20 @@ abstract final class AppRoutes {
return MaterialPageRoute<dynamic>(settings: settings, builder: pageBuilder); return MaterialPageRoute<dynamic>(settings: settings, builder: pageBuilder);
} }
/// Parses a fulfillment stage from a route argument.
///
/// Returns `null` for an absent or unrecognised value so the tracking page
/// falls back to "all stages" rather than silently showing an arbitrary
/// filter. [FulfillmentStatus.fromWire] defaults to `quoted`, which is the
/// wrong behaviour for a filter, so the lookup is done explicitly here.
static FulfillmentStatus? _parseStage(String? value) {
if (value == null || value.isEmpty) return null;
for (final stage in FulfillmentStatus.values) {
if (stage.wireValue == value) return stage;
}
return null;
}
/// Safely extracts the `Map<String, String>` arguments from [RouteSettings]. /// Safely extracts the `Map<String, String>` arguments from [RouteSettings].
/// ///
/// Returns an empty map when no arguments are present or the type doesn't /// Returns an empty map when no arguments are present or the type doesn't

View File

@ -145,7 +145,15 @@ class _StubOrdersRepository implements OrdersRepository {
}) => throw UnimplementedError(); }) => throw UnimplementedError();
@override @override
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) => Future<OrderActionResult> advanceFulfillmentStatus(
String id,
FulfillmentStatus next, {
String? note,
DateTime? occurredAt,
}) => throw UnimplementedError();
@override
Future<OrderActionResult> recordMaterialUsage(String id, List<MaterialUsage> usages) =>
throw UnimplementedError(); throw UnimplementedError();
} }

View File

@ -0,0 +1,157 @@
import 'dart:async';
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/app.dart';
import 'package:kell_web/composition/app_config.dart';
import 'package:kell_web/composition/app_scope.dart';
import 'package:kell_web/composition/app_services.dart';
import 'package:kell_web/routing/app_routes.dart';
Widget _buildTestApp() {
const config = AppConfig(
environment: AppEnvironment.fake,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
);
return AppScope(services: AppServices.fake(), config: config, child: const KellWebApp());
}
/// Navigates the running app to [route] with optional arguments.
Future<void> _go(WidgetTester tester, String route, {Map<String, String>? args}) async {
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
unawaited(navigator.pushReplacementNamed(route, arguments: args));
await tester.pumpAndSettle();
}
Future<void> _boot(WidgetTester tester) async {
tester.view.physicalSize = const Size(1600, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
}
void main() {
group('AppRoutes — custom order tracking route', () {
test('exposes the tracking path', () {
expect(AppRoutes.customOrderTracking, '/orders/tracking');
});
testWidgets('renders the tracking page inside the shell', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
expect(find.byType(CustomOrderTrackingPage), findsOneWidget);
expect(find.text('Custom Order Tracking'), findsOneWidget);
});
testWidgets('lists the seeded commissions', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
expect(find.byType(CustomOrderTrackingCard), findsNWidgets(5));
});
testWidgets('renders the stage filter chips', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
expect(find.text('All (5)'), findsOneWidget);
expect(find.text('Quoted (1)'), findsOneWidget);
});
testWidgets('renders the timeline for the auto-selected commission', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
expect(find.text('Fulfillment Timeline'), findsOneWidget);
expect(find.byType(FulfillmentTimeline), findsOneWidget);
});
testWidgets('applies a stage argument as the initial filter', (tester) async {
await _boot(tester);
await _go(
tester,
AppRoutes.customOrderTracking,
args: {'stage': FulfillmentStatus.inProduction.wireValue},
);
expect(find.byType(CustomOrderTrackingCard), findsOneWidget);
});
testWidgets('ignores an unrecognised stage argument', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking, args: {'stage': 'not-a-stage'});
expect(find.byType(CustomOrderTrackingCard), findsNWidgets(5));
});
testWidgets('pre-selects a commission from selectedId', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking, args: {'selectedId': 'KC-C2004'});
expect(find.text('KC-C2004'), findsWidgets);
});
});
group('AppRoutes — orders page tracking entry point', () {
testWidgets('the orders route offers the tracking action', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.orders);
expect(find.text('Track Custom Orders'), findsOneWidget);
});
testWidgets('tapping the tracking action opens the workspace', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.orders);
await tester.tap(find.text('Track Custom Orders'));
await tester.pumpAndSettle();
expect(find.byType(CustomOrderTrackingPage), findsOneWidget);
});
testWidgets('the tracking page offers the intake action', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
expect(find.text('New Custom Order'), findsWidgets);
});
testWidgets('the intake action from tracking opens the form', (tester) async {
await _boot(tester);
await _go(tester, AppRoutes.customOrderTracking);
await tester.tap(find.text('New Custom Order').first);
await tester.pumpAndSettle();
expect(find.byType(CustomOrderFormPage), findsOneWidget);
});
});
group('AppServices — material consumption port', () {
test('exposes a port wired to the inventory repository', () {
expect(AppServices.fake().materialConsumptionPort, isA<MaterialConsumptionPort>());
});
test('returns the same port instance on repeated access', () {
final services = AppServices.fake();
expect(identical(services.materialConsumptionPort, services.materialConsumptionPort), isTrue);
});
});
}

View File

@ -27,20 +27,29 @@ standard catalogue orders and made-to-order custom commissions behind a single
completed`, plus `cancelled` from any non-terminal stage and a completed`, plus `cancelled` from any non-terminal stage and a
`qualityCheck → inProduction` rework path. Illegal jumps are rejected by the `qualityCheck → inProduction` rework path. Illegal jumps are rejected by the
domain, not silently applied. domain, not silently applied.
- **Fulfillment tracking** — `FulfillmentEvent` records every accepted
transition (stage from, stage to, timestamp, optional operator note) on an
append-only `Order.fulfillmentHistory`.
- **Material consumption** — `MaterialUsage` itemises what a commission
consumes; `MaterialConsumptionPort` is the outbound port the app's
composition layer implements to decrement stock on completion.
- **Repository contract** — `OrdersRepository` with read, status, note, and - **Repository contract** — `OrdersRepository` with read, status, note, and
create operations plus `createCustomOrder`, `updateCustomOrderSpec`, create operations plus `createCustomOrder`, `updateCustomOrderSpec`,
`recordDeposit`, and `advanceFulfillmentStatus`. `recordDeposit`, `advanceFulfillmentStatus`, and `recordMaterialUsage`.
- **Implementations** — `FakeOrdersRepository` (seeded standard and custom - **Implementations** — `FakeOrdersRepository` (seeded standard and custom
orders across every pipeline stage) and `WooCommerceOrdersRepository` orders across every pipeline stage) and `WooCommerceOrdersRepository`
(REST API v3, custom order state round-tripped through `_kc_`-prefixed order (REST API v3, custom order state round-tripped through `_kc_`-prefixed order
meta-data). meta-data).
- **Application layer** — `OrdersController`, `CheckoutController`, - **Application layer** — `OrdersController`, `CheckoutController`,
`CustomOrderFormController`, and the `GetOrders` / `UpdateOrderStatus` / `CustomOrderFormController`, `CustomOrderTrackingController`, and the
`AddOrderNote` / `CreateOrder` / `CreateCustomOrder` / `RecordDeposit` use `GetOrders` / `UpdateOrderStatus` / `AddOrderNote` / `CreateOrder` /
cases. `CreateCustomOrder` / `RecordDeposit` / `AdvanceFulfillment` /
`CompleteCustomOrder` use cases.
- **Presentation** — `OrdersPage`, `OrderCard`, `OrderDetailPanel`, - **Presentation** — `OrdersPage`, `OrderCard`, `OrderDetailPanel`,
`OrderStatusChip`, `CustomOrderFormPage`, `CustomOrderConfirmationPage`, `OrderStatusChip`, `CustomOrderFormPage`, `CustomOrderConfirmationPage`,
`FulfillmentStatusChip`, `showReferenceImageDialog`. `CustomOrderTrackingPage`, `CustomOrderTrackingCard`,
`CustomOrderTrackingDetail`, `FulfillmentStatusChip`, `FulfillmentTimeline`,
`showReferenceImageDialog`, `showAdvanceStageDialog`.
## Getting started ## Getting started
@ -102,6 +111,52 @@ Money is typed as free text and converted to integer cents by
no `double` arithmetic ever decides a price. On success the page swaps to no `double` arithmetic ever decides a price. On success the page swaps to
`CustomOrderConfirmationPage`, which renders the deposit receipt. `CustomOrderConfirmationPage`, which renders the deposit receipt.
### Custom order tracking
`CustomOrderTrackingPage` is the workshop-side counterpart to the intake form.
It lists commissions filtered by **fulfillment stage** (not the commercial
`OrderStatus`, which `OrdersPage` already covers), and shows the full timeline
of recorded stage changes for the selected commission:
```dart
CustomOrderTrackingPage(
repository: services.ordersRepository,
materialPort: services.materialConsumptionPort,
onCreateCustomOrder: () => openIntakeForm(),
);
```
Advancing a commission opens `showAdvanceStageDialog`, which offers only the
stages the domain permits from the current one. The repository re-validates
regardless, and an illegal transition surfaces the domain's own
`FulfillmentTransition.rejectionReason` verbatim — the UI never invents its own
wording for a rule it does not own.
### Inventory deduction on completion
`feature_orders` must not depend on `feature_inventory`; the two are sibling
features. Instead the orders layer declares a narrow outbound port:
```dart
abstract class MaterialConsumptionPort {
Future<MaterialDeductionResult> deductMaterials(
String orderId,
List<MaterialUsage> usages,
);
}
```
Each app's composition layer supplies an adapter backed by its own
`InventoryRepository` (`InventoryMaterialConsumptionAdapter` in `kell_web` and
`kell_mobile`). `CompleteCustomOrder` advances the order **first** and deducts
only after the pipeline accepted the transition, so a rejected completion can
never decrement stock.
Deduction never blocks completion. A missing SKU, a rejected adjustment, or an
unwired port yields a per-SKU entry in `MaterialDeductionResult.failures` and a
warning for the operator — an operator handing over a finished piece must not
be stopped by a stock discrepancy.
## Observability and privacy ## Observability and privacy
Repository implementations accept an optional `KcLogger` from `core`. Failed Repository implementations accept an optional `KcLogger` from `core`. Failed

View File

@ -1,10 +1,13 @@
library; library;
export 'src/application/add_order_note.dart'; export 'src/application/add_order_note.dart';
export 'src/application/advance_fulfillment.dart';
export 'src/application/checkout_controller.dart'; export 'src/application/checkout_controller.dart';
export 'src/application/complete_custom_order.dart';
export 'src/application/create_custom_order.dart'; export 'src/application/create_custom_order.dart';
export 'src/application/create_order.dart'; export 'src/application/create_order.dart';
export 'src/application/custom_order_form_controller.dart'; export 'src/application/custom_order_form_controller.dart';
export 'src/application/custom_order_tracking_controller.dart';
export 'src/application/get_orders.dart'; export 'src/application/get_orders.dart';
export 'src/application/orders_controller.dart'; export 'src/application/orders_controller.dart';
export 'src/application/payment_service.dart'; export 'src/application/payment_service.dart';
@ -16,7 +19,10 @@ export 'src/data/woo_commerce_order_mapper.dart';
export 'src/data/woo_commerce_orders_repository.dart'; export 'src/data/woo_commerce_orders_repository.dart';
export 'src/domain/custom_order_spec.dart'; export 'src/domain/custom_order_spec.dart';
export 'src/domain/deposit_info.dart'; export 'src/domain/deposit_info.dart';
export 'src/domain/fulfillment_event.dart';
export 'src/domain/fulfillment_status.dart'; export 'src/domain/fulfillment_status.dart';
export 'src/domain/material_consumption_port.dart';
export 'src/domain/material_usage.dart';
export 'src/domain/order.dart'; export 'src/domain/order.dart';
export 'src/domain/order_action_result.dart'; export 'src/domain/order_action_result.dart';
export 'src/domain/order_item.dart'; export 'src/domain/order_item.dart';
@ -28,10 +34,15 @@ export 'src/domain/payment_method.dart';
export 'src/domain/payment_result.dart'; export 'src/domain/payment_result.dart';
export 'src/presentation/custom_order_confirmation_page.dart'; export 'src/presentation/custom_order_confirmation_page.dart';
export 'src/presentation/custom_order_form_page.dart'; export 'src/presentation/custom_order_form_page.dart';
export 'src/presentation/custom_order_tracking_page.dart';
export 'src/presentation/orders_action_snack_bar.dart'; export 'src/presentation/orders_action_snack_bar.dart';
export 'src/presentation/orders_page.dart'; export 'src/presentation/orders_page.dart';
export 'src/presentation/widgets/add_note_dialog.dart'; export 'src/presentation/widgets/add_note_dialog.dart';
export 'src/presentation/widgets/advance_stage_dialog.dart';
export 'src/presentation/widgets/custom_order_tracking_card.dart';
export 'src/presentation/widgets/custom_order_tracking_detail.dart';
export 'src/presentation/widgets/fulfillment_status_chip.dart'; export 'src/presentation/widgets/fulfillment_status_chip.dart';
export 'src/presentation/widgets/fulfillment_timeline.dart';
export 'src/presentation/widgets/order_card.dart'; export 'src/presentation/widgets/order_card.dart';
export 'src/presentation/widgets/order_detail_panel.dart'; export 'src/presentation/widgets/order_detail_panel.dart';
export 'src/presentation/widgets/order_status_chip.dart'; export 'src/presentation/widgets/order_status_chip.dart';

View File

@ -0,0 +1,25 @@
import '../domain/fulfillment_status.dart';
import '../domain/order_action_result.dart';
import '../domain/orders_repository.dart';
/// Use case: move a custom order to the next stage of the workshop pipeline.
///
/// Transition legality is decided by the domain (`FulfillmentTransition`) and
/// enforced by the repository — this use case adds no rule of its own, so
/// there is exactly one place where the pipeline is defined.
///
/// [note] is an **operator** note about production progress. It is persisted
/// to channel meta-data, so it must never carry customer identity, money
/// amounts, or payment identifiers.
class AdvanceFulfillment {
final OrdersRepository repository;
AdvanceFulfillment(this.repository);
Future<OrderActionResult> call(
String id,
FulfillmentStatus next, {
String? note,
DateTime? occurredAt,
}) => repository.advanceFulfillmentStatus(id, next, note: note, occurredAt: occurredAt);
}

View File

@ -0,0 +1,92 @@
import '../domain/fulfillment_status.dart';
import '../domain/material_consumption_port.dart';
import '../domain/order_action_result.dart';
import '../domain/orders_repository.dart';
/// The outcome of completing a custom order.
///
/// Separates the *order* outcome from the *inventory* outcome, because the
/// two can legitimately diverge: the commission is handed to the customer
/// whether or not a raw material could be decremented. Reporting one boolean
/// would force a choice between blocking completion on a stock error and
/// hiding the stock error entirely — both are wrong.
class CompleteCustomOrderResult {
/// The result of advancing the order to [FulfillmentStatus.completed].
final OrderActionResult orderResult;
/// The result of deducting the commission's materials from stock, or `null`
/// when completion failed and no deduction was attempted.
final MaterialDeductionResult? deductionResult;
const CompleteCustomOrderResult({required this.orderResult, this.deductionResult});
/// Whether the order itself reached [FulfillmentStatus.completed].
bool get success => orderResult.success;
/// Whether the order completed but one or more materials failed to deduct.
///
/// The operator must be told about this — stock is now wrong — but the
/// commission is still closed.
bool get hasDeductionFailures => success && (deductionResult?.success == false);
/// A message for the operator, or `null` when there is nothing to add
/// beyond the standard success confirmation.
///
/// Contains SKUs, counts, and stage names only.
String? get warning => hasDeductionFailures ? deductionResult?.summary : null;
@override
String toString() =>
'CompleteCustomOrderResult(success: $success, '
'deductionFailures: ${deductionResult?.failures.length ?? 0})';
}
/// Use case: complete a custom order and deduct the materials it consumed.
///
/// Ordering matters. The order is advanced **first**; materials are deducted
/// only after the pipeline accepted the transition. Deducting first would
/// decrement stock for a completion that the domain then rejected — for
/// example an illegal jump from `inProduction` straight to `completed`.
///
/// Deduction never blocks completion. A [MaterialConsumptionPort] that fails,
/// or that is not wired at all, yields a warning on the result rather than a
/// failed commission: an operator handing over a finished piece must not be
/// stopped by a stock discrepancy.
class CompleteCustomOrder {
final OrdersRepository repository;
/// The outbound port used to decrement raw materials.
///
/// `null` when the app target has not wired supplies tracking; completion
/// then proceeds with no deduction attempted.
final MaterialConsumptionPort? materialPort;
CompleteCustomOrder(this.repository, {this.materialPort});
Future<CompleteCustomOrderResult> call(String id, {String? note, DateTime? occurredAt}) async {
final orderResult = await repository.advanceFulfillmentStatus(
id,
FulfillmentStatus.completed,
note: note,
occurredAt: occurredAt,
);
if (!orderResult.success) {
return CompleteCustomOrderResult(orderResult: orderResult);
}
final port = materialPort;
final materials = orderResult.updatedOrder?.deductibleMaterials ?? const [];
if (port == null || materials.isEmpty) {
return CompleteCustomOrderResult(
orderResult: orderResult,
deductionResult: MaterialDeductionResult.empty(),
);
}
return CompleteCustomOrderResult(
orderResult: orderResult,
deductionResult: await port.deductMaterials(id, materials),
);
}
}

View File

@ -0,0 +1,259 @@
import 'package:flutter/foundation.dart';
import '../domain/fulfillment_status.dart';
import '../domain/order.dart';
import '../domain/order_action_result.dart';
import 'advance_fulfillment.dart';
import 'complete_custom_order.dart';
import 'get_orders.dart';
/// Controller for the custom order tracking workspace.
///
/// Loads every order, keeps only the made-to-order commissions, and exposes
/// them filtered by workshop stage. Advancing an order goes through
/// [AdvanceFulfillment]; reaching [FulfillmentStatus.completed] goes through
/// [CompleteCustomOrder] instead, so material deduction is never skipped by
/// taking the generic path.
///
/// This is deliberately separate from `OrdersController`, which filters by
/// the commercial `OrderStatus`. A commission carries both, and conflating
/// the two filters would make "In Production" and "Processing" fight for the
/// same chip row.
class CustomOrderTrackingController extends ChangeNotifier {
final GetOrders _getOrders;
final AdvanceFulfillment? _advanceFulfillment;
final CompleteCustomOrder? _completeCustomOrder;
CustomOrderTrackingController(
this._getOrders, {
AdvanceFulfillment? advanceFulfillment,
CompleteCustomOrder? completeCustomOrder,
}) : _advanceFulfillment = advanceFulfillment,
_completeCustomOrder = completeCustomOrder;
/// Whether a load is in flight.
bool isLoading = false;
/// Whether a stage advance is in flight.
bool isUpdating = false;
/// The error thrown by the last [load], or `null`.
Object? error;
/// The result of the last write action, or `null` when none is pending.
OrderActionResult? lastActionResult;
/// A non-blocking warning from the last completion — set when the order
/// completed but one or more materials failed to deduct from stock.
String? lastDeductionWarning;
/// Every custom order returned by the repository, unfiltered.
List<Order> _allCustomOrders = [];
/// The commissions visible after applying [activeStage] and [searchQuery].
List<Order> orders = [];
/// The commission selected for the detail / timeline view.
Order? selectedOrder;
/// The active pipeline-stage filter, or `null` for "all stages".
FulfillmentStatus? activeStage;
/// The current free-text query applied to customer name, order ID, and the
/// specification summary.
String searchQuery = '';
// ── Read operations ────────────────────────────────────────────────────
/// Loads all orders, keeps the custom ones, and applies the current filter.
Future<void> load() async {
isLoading = true;
error = null;
notifyListeners();
try {
final all = await _getOrders();
_allCustomOrders = all.where((order) => order.isCustom).toList();
_applyFilters();
selectedOrder ??= orders.isNotEmpty ? orders.first : null;
} catch (e) {
error = e;
} finally {
isLoading = false;
notifyListeners();
}
}
/// Sets the pipeline-stage filter and recomputes the visible list.
void setStageFilter(FulfillmentStatus? stage) {
activeStage = stage;
_applyFilters();
notifyListeners();
}
/// Sets the search query and recomputes the visible list.
void setSearchQuery(String query) {
searchQuery = query;
_applyFilters();
notifyListeners();
}
/// Selects a commission for the detail / timeline view.
void selectOrder(Order order) {
selectedOrder = order;
notifyListeners();
}
/// Attempts to select a commission by ID. Returns `true` when found.
bool selectById(String id) {
final match = _allCustomOrders.where((o) => o.id == id).firstOrNull;
if (match == null) return false;
selectedOrder = match;
notifyListeners();
return true;
}
// ── Derived views ──────────────────────────────────────────────────────
/// How many commissions sit at each stage, across the unfiltered set.
///
/// Used for the stage filter chips, so the counts do not change as the
/// operator moves between filters.
Map<FulfillmentStatus, int> get stageCounts {
final counts = <FulfillmentStatus, int>{};
for (final status in FulfillmentStatus.values) {
counts[status] = 0;
}
for (final order in _allCustomOrders) {
final stage = order.currentStage;
if (stage != null) counts[stage] = (counts[stage] ?? 0) + 1;
}
return counts;
}
/// The total number of custom orders, ignoring any filter.
int get totalCustomOrders => _allCustomOrders.length;
/// The commissions that are still moving through the workshop.
int get activeCount => _allCustomOrders.where((o) => !o.isFulfillmentComplete).length;
/// The stages the selected commission may legally move to next.
List<FulfillmentStatus> get allowedNextStages => selectedOrder?.allowedNextStages ?? const [];
// ── Write operations ───────────────────────────────────────────────────
/// Moves the commission identified by [id] to [next].
///
/// Routes through [CompleteCustomOrder] when [next] is
/// [FulfillmentStatus.completed] so the materials deduction always happens;
/// every other stage goes through [AdvanceFulfillment].
///
/// Illegal transitions are rejected by the repository and surfaced verbatim
/// through [lastActionResult] — the domain's own rejection reason is the
/// message the operator sees, so the UI never invents its own wording.
Future<void> advanceTo(String id, FulfillmentStatus next, {String? note}) async {
if (next == FulfillmentStatus.completed) {
return _complete(id, note: note);
}
final advance = _advanceFulfillment;
if (advance == null) return;
isUpdating = true;
lastActionResult = null;
lastDeductionWarning = null;
notifyListeners();
try {
final result = await advance(id, next, note: note);
lastActionResult = result;
if (result.success) await _reloadAndRefresh(id);
} finally {
isUpdating = false;
notifyListeners();
}
}
/// Completes the commission identified by [id] and deducts its materials.
///
/// A material that cannot be deducted produces [lastDeductionWarning]; it
/// never turns the completion itself into a failure, because the piece has
/// already been handed over.
Future<void> _complete(String id, {String? note}) async {
final complete = _completeCustomOrder;
if (complete == null) return;
isUpdating = true;
lastActionResult = null;
lastDeductionWarning = null;
notifyListeners();
try {
final result = await complete(id, note: note);
lastActionResult = result.orderResult;
lastDeductionWarning = result.warning;
if (result.success) await _reloadAndRefresh(id);
} finally {
isUpdating = false;
notifyListeners();
}
}
/// Consumes and returns [lastActionResult], clearing it to `null`.
///
/// Mirrors `OrdersController.consumeActionResult` so hosts can use the same
/// "show once" SnackBar pattern on both surfaces.
OrderActionResult? consumeActionResult() {
final result = lastActionResult;
lastActionResult = null;
return result;
}
/// Consumes and returns [lastDeductionWarning], clearing it to `null`.
String? consumeDeductionWarning() {
final warning = lastDeductionWarning;
lastDeductionWarning = null;
return warning;
}
// ── Private helpers ────────────────────────────────────────────────────
/// Reloads from the repository and re-selects [selectedId].
Future<void> _reloadAndRefresh(String? selectedId) async {
final all = await _getOrders();
_allCustomOrders = all.where((order) => order.isCustom).toList();
_applyFilters();
final refreshId = selectedId ?? selectedOrder?.id;
if (refreshId == null) return;
final refreshed = _allCustomOrders.where((o) => o.id == refreshId).firstOrNull;
if (refreshed != null) selectedOrder = refreshed;
}
void _applyFilters() {
var result = _allCustomOrders;
if (activeStage != null) {
result = result.where((o) => o.currentStage == activeStage).toList();
}
if (searchQuery.isNotEmpty) {
final q = searchQuery.toLowerCase();
result = result.where((o) {
final spec = o.customOrderSpec?.summary.toLowerCase() ?? '';
return o.customerName.toLowerCase().contains(q) ||
o.id.toLowerCase().contains(q) ||
spec.contains(q);
}).toList();
}
orders = result;
// Keep the selection valid, but never clear it just because the stage
// filter moved past it — the operator advancing an order out of the
// filtered stage should still see its timeline.
if (selectedOrder != null && !_allCustomOrders.any((o) => o.id == selectedOrder!.id)) {
selectedOrder = null;
}
}
}

View File

@ -2,7 +2,9 @@ import 'package:core/core.dart';
import '../domain/custom_order_spec.dart'; import '../domain/custom_order_spec.dart';
import '../domain/deposit_info.dart'; import '../domain/deposit_info.dart';
import '../domain/fulfillment_event.dart';
import '../domain/fulfillment_status.dart'; import '../domain/fulfillment_status.dart';
import '../domain/material_usage.dart';
import '../domain/order.dart'; import '../domain/order.dart';
import '../domain/order_action_result.dart'; import '../domain/order_action_result.dart';
import '../domain/order_item.dart'; import '../domain/order_item.dart';
@ -28,8 +30,16 @@ class FakeOrdersRepository implements OrdersRepository {
/// care about observability need not supply one. /// care about observability need not supply one.
final KcLogger logger; final KcLogger logger;
FakeOrdersRepository({KcLogger? logger}) /// Clock used to stamp [FulfillmentEvent]s when the caller supplies no
: logger = (logger ?? KcLogger.silent()).forSource(_logSource); /// explicit timestamp.
///
/// Injectable so tracking tests are deterministic rather than dependent on
/// wall-clock time.
final DateTime Function() now;
FakeOrdersRepository({KcLogger? logger, DateTime Function()? now})
: logger = (logger ?? KcLogger.silent()).forSource(_logSource),
now = now ?? DateTime.now;
static const String _logSource = 'FakeOrdersRepository'; static const String _logSource = 'FakeOrdersRepository';
@ -193,6 +203,10 @@ class FakeOrdersRepository implements OrdersRepository {
notes: 'Matching set of four for a housewarming gift.', notes: 'Matching set of four for a housewarming gift.',
), ),
depositInfo: const DepositInfo.quoted(totalAmountCents: 7200, depositAmountCents: 2400), depositInfo: const DepositInfo.quoted(totalAmountCents: 7200, depositAmountCents: 2400),
fulfillmentHistory: [FulfillmentEvent.intake(occurredAt: DateTime(2026, 4, 6))],
materialUsage: const [
MaterialUsage(sku: 'SUP-YRN-COT-SAGE', quantity: 4, displayName: 'Organic cotton — sage'),
],
), ),
Order( Order(
id: 'KC-C2002', id: 'KC-C2002',
@ -226,6 +240,19 @@ class FakeOrdersRepository implements OrdersRepository {
depositPaid: true, depositPaid: true,
depositDate: DateTime(2026, 4, 5), depositDate: DateTime(2026, 4, 5),
), ),
fulfillmentHistory: [
FulfillmentEvent.intake(occurredAt: DateTime(2026, 4, 5)),
FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5, 14, 20),
note: 'Deposit taken at the stall.',
),
],
materialUsage: const [
MaterialUsage(sku: 'SUP-SLT-WELSH-16', quantity: 1, displayName: 'Welsh slate blank 16"'),
MaterialUsage(sku: 'SUP-FELT-PAD', quantity: 4, displayName: 'Felt foot pad'),
],
), ),
Order( Order(
id: 'KC-C2003', id: 'KC-C2003',
@ -264,6 +291,24 @@ class FakeOrdersRepository implements OrdersRepository {
depositPaid: true, depositPaid: true,
depositDate: DateTime(2026, 3, 30), depositDate: DateTime(2026, 3, 30),
), ),
fulfillmentHistory: [
FulfillmentEvent.intake(occurredAt: DateTime(2026, 3, 30)),
FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 3, 30, 11, 5),
),
FulfillmentEvent(
from: FulfillmentStatus.depositReceived,
status: FulfillmentStatus.inProduction,
occurredAt: DateTime(2026, 4, 2, 9, 30),
note: 'Resin poured; curing for 72 hours.',
),
],
materialUsage: const [
MaterialUsage(sku: 'SUP-RSN-CAST-1L', quantity: 2, displayName: 'Cast resin 1L'),
MaterialUsage(sku: 'SUP-LED-WARM', quantity: 2, displayName: 'Warm-white LED module'),
],
), ),
Order( Order(
id: 'KC-C2004', id: 'KC-C2004',
@ -297,6 +342,42 @@ class FakeOrdersRepository implements OrdersRepository {
depositPaid: true, depositPaid: true,
depositDate: DateTime(2026, 3, 22), depositDate: DateTime(2026, 3, 22),
), ),
// Includes a rework loop so the timeline surface has a backwards
// transition to render, not only a clean forward run.
fulfillmentHistory: [
FulfillmentEvent.intake(occurredAt: DateTime(2026, 3, 22)),
FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 3, 22, 16, 45),
),
FulfillmentEvent(
from: FulfillmentStatus.depositReceived,
status: FulfillmentStatus.inProduction,
occurredAt: DateTime(2026, 3, 25, 8, 15),
),
FulfillmentEvent(
from: FulfillmentStatus.inProduction,
status: FulfillmentStatus.qualityCheck,
occurredAt: DateTime(2026, 4, 1, 17, 0),
),
FulfillmentEvent(
from: FulfillmentStatus.qualityCheck,
status: FulfillmentStatus.inProduction,
occurredAt: DateTime(2026, 4, 2, 10, 0),
note: 'Seam allowance short on the second sleeve; reworking.',
),
FulfillmentEvent(
from: FulfillmentStatus.inProduction,
status: FulfillmentStatus.qualityCheck,
occurredAt: DateTime(2026, 4, 4, 15, 30),
note: 'Rework complete; re-inspecting.',
),
],
materialUsage: const [
MaterialUsage(sku: 'SUP-CNV-DUCK-RUST', quantity: 2, displayName: 'Duck canvas — rust'),
MaterialUsage(sku: 'SUP-BAT-HEAT', quantity: 4, displayName: 'Heat-resistant batting'),
],
), ),
Order( Order(
id: 'KC-C2005', id: 'KC-C2005',
@ -332,6 +413,34 @@ class FakeOrdersRepository implements OrdersRepository {
depositPaid: true, depositPaid: true,
depositDate: DateTime(2026, 3, 14), depositDate: DateTime(2026, 3, 14),
), ),
fulfillmentHistory: [
FulfillmentEvent.intake(occurredAt: DateTime(2026, 3, 14)),
FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 3, 14, 13, 10),
),
FulfillmentEvent(
from: FulfillmentStatus.depositReceived,
status: FulfillmentStatus.inProduction,
occurredAt: DateTime(2026, 3, 18, 9, 0),
),
FulfillmentEvent(
from: FulfillmentStatus.inProduction,
status: FulfillmentStatus.qualityCheck,
occurredAt: DateTime(2026, 4, 5, 11, 45),
),
FulfillmentEvent(
from: FulfillmentStatus.qualityCheck,
status: FulfillmentStatus.readyForPickup,
occurredAt: DateTime(2026, 4, 7, 10, 0),
note: 'Boxed in sets of four; awaiting collection.',
),
],
materialUsage: const [
MaterialUsage(sku: 'SUP-PLY-BIRCH-4R', quantity: 12, displayName: 'Birch ply round 4"'),
MaterialUsage(sku: 'SUP-BOX-FAVOUR', quantity: 3, displayName: 'Favour box'),
],
), ),
]; ];
@ -415,11 +524,15 @@ class FakeOrdersRepository implements OrdersRepository {
); );
} }
// Seed the timeline with an intake event so a freshly created commission
// already has a history to render, rather than appearing to have arrived
// at "quoted" from nowhere.
final created = order.copyWith( final created = order.copyWith(
orderType: OrderType.custom, orderType: OrderType.custom,
customOrderSpec: spec, customOrderSpec: spec,
depositInfo: deposit, depositInfo: deposit,
fulfillmentStatus: FulfillmentStatus.quoted, fulfillmentStatus: FulfillmentStatus.quoted,
fulfillmentHistory: [FulfillmentEvent.intake(occurredAt: order.orderDate)],
); );
_orders.add(created); _orders.add(created);
return OrderActionResult.success(created); return OrderActionResult.success(created);
@ -505,7 +618,12 @@ class FakeOrdersRepository implements OrdersRepository {
} }
@override @override
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) async { Future<OrderActionResult> advanceFulfillmentStatus(
String id,
FulfillmentStatus next, {
String? note,
DateTime? occurredAt,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 200)); await Future<void>.delayed(const Duration(milliseconds: 200));
final index = _orders.indexWhere((o) => o.id == id); final index = _orders.indexWhere((o) => o.id == id);
@ -536,7 +654,50 @@ class FakeOrdersRepository implements OrdersRepository {
); );
} }
final updated = existing.copyWith(fulfillmentStatus: next); final updated = existing.withFulfillmentEvent(
FulfillmentEvent(
from: current,
status: next,
occurredAt: occurredAt ?? now(),
note: (note != null && note.trim().isNotEmpty) ? note.trim() : null,
),
);
_orders[index] = updated;
return OrderActionResult.success(updated);
}
@override
Future<OrderActionResult> recordMaterialUsage(String id, List<MaterialUsage> usages) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
final index = _orders.indexWhere((o) => o.id == id);
if (index == -1) {
return _rejected(
'Order not found.',
operation: 'recordMaterialUsage',
context: {'orderId': id, 'usageCount': usages.length},
);
}
final existing = _orders[index];
if (!existing.isCustom) {
return _rejected(
'Order $id is not a custom order.',
operation: 'recordMaterialUsage',
context: {'orderId': id, 'orderType': existing.orderType.name},
);
}
final invalid = usages.where((usage) => !usage.isValid).toList();
if (invalid.isNotEmpty) {
return _rejected(
'Every material usage needs a SKU and a quantity greater than zero.',
operation: 'recordMaterialUsage',
context: {'orderId': id, 'invalidCount': invalid.length},
);
}
final updated = existing.copyWith(materialUsage: List.of(usages));
_orders[index] = updated; _orders[index] = updated;
return OrderActionResult.success(updated); return OrderActionResult.success(updated);
} }

View File

@ -1,6 +1,8 @@
import '../domain/custom_order_spec.dart'; import '../domain/custom_order_spec.dart';
import '../domain/deposit_info.dart'; import '../domain/deposit_info.dart';
import '../domain/fulfillment_event.dart';
import '../domain/fulfillment_status.dart'; import '../domain/fulfillment_status.dart';
import '../domain/material_usage.dart';
import '../domain/order.dart'; import '../domain/order.dart';
import '../domain/order_item.dart'; import '../domain/order_item.dart';
import '../domain/order_source.dart'; import '../domain/order_source.dart';
@ -35,6 +37,8 @@ import '../domain/order_type.dart';
/// | `_kc_deposit_paid` | `DepositInfo.depositPaid` | `true` \| `false` | /// | `_kc_deposit_paid` | `DepositInfo.depositPaid` | `true` \| `false` |
/// | `_kc_deposit_date` | `DepositInfo.depositDate` | ISO 8601 | /// | `_kc_deposit_date` | `DepositInfo.depositDate` | ISO 8601 |
/// | `_kc_settlement_date` | `DepositInfo.settlementDate` | ISO 8601 | /// | `_kc_settlement_date` | `DepositInfo.settlementDate` | ISO 8601 |
/// | `_kc_fulfillment_history` | `Order.fulfillmentHistory` | newline-separated `from\|to\|iso\|note` |
/// | `_kc_material_usage` | `Order.materialUsage` | newline-separated `sku\|qty\|name` |
/// ///
/// **Deliberately not mapped:** the deposit payment / transaction identifier. /// **Deliberately not mapped:** the deposit payment / transaction identifier.
/// It lives on `PaymentResult` only and is never written to order meta-data, /// It lives on `PaymentResult` only and is never written to order meta-data,
@ -93,10 +97,26 @@ class WooCommerceOrderMapper {
/// Meta key holding [DepositInfo.settlementDate] as ISO 8601. /// Meta key holding [DepositInfo.settlementDate] as ISO 8601.
static const String metaSettlementDate = '_kc_settlement_date'; static const String metaSettlementDate = '_kc_settlement_date';
/// Meta key holding [Order.fulfillmentHistory], one event per line.
///
/// Each line is produced by [FulfillmentEvent.toWire]. Operator notes are
/// included; customer identity, money amounts, and payment identifiers are
/// not, and must never be written through an operator note.
static const String metaFulfillmentHistory = '_kc_fulfillment_history';
/// Meta key holding [Order.materialUsage], one usage per line.
///
/// Each line is produced by [MaterialUsage.toWire].
static const String metaMaterialUsage = '_kc_material_usage';
/// Separator used to pack [CustomOrderSpec.referenceImageUrls] into a single /// Separator used to pack [CustomOrderSpec.referenceImageUrls] into a single
/// meta value. /// meta value.
static const String referenceImageSeparator = '\n'; static const String referenceImageSeparator = '\n';
/// Separator used to pack multi-record meta values
/// ([metaFulfillmentHistory], [metaMaterialUsage]) into a single string.
static const String recordSeparator = '\n';
/// Converts a single WooCommerce order JSON map to an [Order]. /// Converts a single WooCommerce order JSON map to an [Order].
Order fromJson(Map<String, dynamic> json) { Order fromJson(Map<String, dynamic> json) {
final billing = json['billing'] as Map<String, dynamic>? ?? {}; final billing = json['billing'] as Map<String, dynamic>? ?? {};
@ -143,9 +163,32 @@ class WooCommerceOrderMapper {
fulfillmentStatus: isCustom fulfillmentStatus: isCustom
? FulfillmentStatus.fromWire(internalMeta[metaFulfillmentStatus]) ? FulfillmentStatus.fromWire(internalMeta[metaFulfillmentStatus])
: null, : null,
fulfillmentHistory: isCustom
? historyFromMeta(internalMeta[metaFulfillmentHistory])
: const [],
materialUsage: isCustom ? materialUsageFromMeta(internalMeta[metaMaterialUsage]) : const [],
); );
} }
/// Parses the packed [metaFulfillmentHistory] value into events.
///
/// Malformed lines are skipped rather than throwing — order meta is
/// editable by any store administrator, and one bad row must not discard a
/// whole timeline.
static List<FulfillmentEvent> historyFromMeta(String? packed) =>
_unpack(packed, FulfillmentEvent.fromWire);
/// Parses the packed [metaMaterialUsage] value into usage records.
///
/// Malformed lines are skipped, for the same reason as [historyFromMeta].
static List<MaterialUsage> materialUsageFromMeta(String? packed) =>
_unpack(packed, MaterialUsage.fromWire);
static List<T> _unpack<T>(String? packed, T? Function(String) parse) {
if (packed == null || packed.isEmpty) return const [];
return packed.split(recordSeparator).map(parse).whereType<T>().toList();
}
/// Converts a list of WooCommerce order JSON maps to [Order]s. /// Converts a list of WooCommerce order JSON maps to [Order]s.
List<Order> fromJsonList(List<Map<String, dynamic>> jsonList) { List<Order> fromJsonList(List<Map<String, dynamic>> jsonList) {
return jsonList.map(fromJson).toList(); return jsonList.map(fromJson).toList();
@ -225,6 +268,7 @@ class WooCommerceOrderMapper {
metaFulfillmentStatus: order.fulfillmentStatus!.wireValue, metaFulfillmentStatus: order.fulfillmentStatus!.wireValue,
...specMetaEntries(order.customOrderSpec), ...specMetaEntries(order.customOrderSpec),
...depositMetaEntries(order.depositInfo), ...depositMetaEntries(order.depositInfo),
...trackingMetaEntries(order),
}; };
return entries.entries.map((e) => {'key': e.key, 'value': e.value}).toList(); return entries.entries.map((e) => {'key': e.key, 'value': e.value}).toList();
@ -262,6 +306,22 @@ class WooCommerceOrderMapper {
}; };
} }
/// Builds the `meta_data` entries for [order]'s fulfillment timeline and
/// material usage.
///
/// Empty lists are omitted rather than written as empty strings, so a
/// partial update never blanks a timeline that already exists remotely.
Map<String, String> trackingMetaEntries(Order order) {
return {
if (order.fulfillmentHistory.isNotEmpty)
metaFulfillmentHistory: order.fulfillmentHistory
.map((event) => event.toWire())
.join(recordSeparator),
if (order.materialUsage.isNotEmpty)
metaMaterialUsage: order.materialUsage.map((usage) => usage.toWire()).join(recordSeparator),
};
}
/// Converts a `key → value` map into WooCommerce `meta_data` list form. /// Converts a `key → value` map into WooCommerce `meta_data` list form.
static List<Map<String, dynamic>> toMetaDataList(Map<String, String> entries) => static List<Map<String, dynamic>> toMetaDataList(Map<String, String> entries) =>
entries.entries.map((e) => {'key': e.key, 'value': e.value}).toList(); entries.entries.map((e) => {'key': e.key, 'value': e.value}).toList();

View File

@ -3,7 +3,9 @@ import 'package:feature_wordpress/feature_wordpress.dart';
import '../domain/custom_order_spec.dart'; import '../domain/custom_order_spec.dart';
import '../domain/deposit_info.dart'; import '../domain/deposit_info.dart';
import '../domain/fulfillment_event.dart';
import '../domain/fulfillment_status.dart'; import '../domain/fulfillment_status.dart';
import '../domain/material_usage.dart';
import '../domain/order.dart'; import '../domain/order.dart';
import '../domain/order_action_result.dart'; import '../domain/order_action_result.dart';
import '../domain/order_status.dart'; import '../domain/order_status.dart';
@ -45,13 +47,22 @@ class WooCommerceOrdersRepository implements OrdersRepository {
/// Defaults to [KcLogger.silent] so tests need not supply one. /// Defaults to [KcLogger.silent] so tests need not supply one.
final KcLogger logger; final KcLogger logger;
/// Clock used to stamp [FulfillmentEvent]s when the caller supplies no
/// explicit timestamp.
///
/// Injectable so tracking tests are deterministic rather than dependent on
/// wall-clock time.
final DateTime Function() now;
WooCommerceOrdersRepository({ WooCommerceOrdersRepository({
required WooCommerceApiClient apiClient, required WooCommerceApiClient apiClient,
WooCommerceOrderMapper mapper = const WooCommerceOrderMapper(), WooCommerceOrderMapper mapper = const WooCommerceOrderMapper(),
KcLogger? logger, KcLogger? logger,
DateTime Function()? now,
}) : _apiClient = apiClient, }) : _apiClient = apiClient,
_mapper = mapper, _mapper = mapper,
logger = (logger ?? KcLogger.silent()).forSource(_logSource); logger = (logger ?? KcLogger.silent()).forSource(_logSource),
now = now ?? DateTime.now;
static const String _logSource = 'WooCommerceOrdersRepository'; static const String _logSource = 'WooCommerceOrdersRepository';
@ -140,11 +151,14 @@ class WooCommerceOrdersRepository implements OrdersRepository {
required CustomOrderSpec spec, required CustomOrderSpec spec,
DepositInfo? deposit, DepositInfo? deposit,
}) { }) {
// Seed the timeline with an intake event so the commission arrives in
// WooCommerce with a history, matching FakeOrdersRepository.
final custom = order.copyWith( final custom = order.copyWith(
orderType: OrderType.custom, orderType: OrderType.custom,
customOrderSpec: spec, customOrderSpec: spec,
depositInfo: deposit, depositInfo: deposit,
fulfillmentStatus: FulfillmentStatus.quoted, fulfillmentStatus: FulfillmentStatus.quoted,
fulfillmentHistory: [FulfillmentEvent.intake(occurredAt: order.orderDate)],
); );
return _guard( return _guard(
@ -211,7 +225,12 @@ class WooCommerceOrdersRepository implements OrdersRepository {
} }
@override @override
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) async { Future<OrderActionResult> advanceFulfillmentStatus(
String id,
FulfillmentStatus next, {
String? note,
DateTime? occurredAt,
}) async {
final loaded = await _loadCustomOrder(id, operation: 'advanceFulfillmentStatus'); final loaded = await _loadCustomOrder(id, operation: 'advanceFulfillmentStatus');
if (loaded.failure != null) return loaded.failure!; if (loaded.failure != null) return loaded.failure!;
@ -226,10 +245,35 @@ class WooCommerceOrdersRepository implements OrdersRepository {
); );
} }
final updated = order.copyWith(fulfillmentStatus: next); final updated = order.withFulfillmentEvent(
FulfillmentEvent(
from: current,
status: next,
occurredAt: occurredAt ?? now(),
note: (note != null && note.trim().isNotEmpty) ? note.trim() : null,
),
);
return _pushCustomOrderMeta(id, updated, operation: 'advanceFulfillmentStatus'); return _pushCustomOrderMeta(id, updated, operation: 'advanceFulfillmentStatus');
} }
@override
Future<OrderActionResult> recordMaterialUsage(String id, List<MaterialUsage> usages) async {
final invalid = usages.where((usage) => !usage.isValid).toList();
if (invalid.isNotEmpty) {
return _rejected(
'Every material usage needs a SKU and a quantity greater than zero.',
operation: 'recordMaterialUsage',
context: {'orderId': id, 'invalidCount': invalid.length},
);
}
final loaded = await _loadCustomOrder(id, operation: 'recordMaterialUsage');
if (loaded.failure != null) return loaded.failure!;
final updated = loaded.order!.copyWith(materialUsage: List.of(usages));
return _pushCustomOrderMeta(id, updated, operation: 'recordMaterialUsage');
}
// ── Private helpers ────────────────────────────────────────────────────── // ── Private helpers ──────────────────────────────────────────────────────
/// Fetches [id] and verifies it is a custom order. /// Fetches [id] and verifies it is a custom order.

View File

@ -0,0 +1,155 @@
import 'fulfillment_status.dart';
/// A single recorded transition in a custom order's fulfillment pipeline.
///
/// Immutable value object. One event is appended every time
/// `OrdersRepository.advanceFulfillmentStatus` accepts a transition, giving
/// the tracking timeline an auditable record of *what* changed, *when*, and
/// *why* — without ever reading back through the channel.
///
/// ## Privacy
///
/// [note] is an **operator** note about production progress ("glaze fired",
/// "awaiting yarn delivery"). It is rendered in the operator-facing timeline
/// and written to channel meta-data, so it must never be used to carry
/// customer identity, addresses, money amounts, or payment identifiers. The
/// same rule that governs `DepositInfo` applies here.
class FulfillmentEvent {
/// The stage the order moved *from*.
///
/// For the very first event — the one recorded at intake — this equals
/// [status], because the order entered the pipeline rather than moving
/// within it.
final FulfillmentStatus from;
/// The stage the order moved *to*.
final FulfillmentStatus status;
/// When the transition happened.
final DateTime occurredAt;
/// An optional operator note explaining the transition.
///
/// `null` when the operator advanced the order without comment.
final String? note;
const FulfillmentEvent({
required this.from,
required this.status,
required this.occurredAt,
this.note,
});
/// The event recorded when an order first enters the pipeline.
///
/// [from] and [status] are both [FulfillmentStatus.quoted] because there is
/// no prior stage to move away from.
FulfillmentEvent.intake({required DateTime occurredAt, String? note})
: this(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.quoted,
occurredAt: occurredAt,
note: note,
);
/// Whether this event records the order entering the pipeline rather than
/// moving within it.
bool get isIntake => from == status;
/// Whether this event moved the order backwards for rework
/// (`qualityCheck → inProduction`).
bool get isRework => !isIntake && status.stageIndex < from.stageIndex && status.stageIndex >= 0;
/// Whether the operator attached a note to this transition.
bool get hasNote => note != null && note!.trim().isNotEmpty;
/// A human-readable description of the transition.
///
/// Contains only stage labels — never customer or payment detail — so it is
/// safe to surface in the UI and to attach to log context.
String get description =>
isIntake ? 'Order ${status.label.toLowerCase()}' : '${from.label} → ${status.label}';
// ── Wire format ──────────────────────────────────────────────────────────
/// Field separator used by [toWire].
static const String fieldSeparator = '|';
/// Encodes this event as a single line for channel meta-data.
///
/// Format: `from|status|iso8601|note`. Newlines and [fieldSeparator]
/// characters inside [note] are replaced with spaces so a note can never
/// corrupt the surrounding record or the newline-separated history block.
String toWire() {
final safeNote = (note ?? '').replaceAll(RegExp(r'[\r\n|]'), ' ').trim();
return [
from.wireValue,
status.wireValue,
occurredAt.toIso8601String(),
safeNote,
].join(fieldSeparator);
}
/// Parses a line produced by [toWire], or returns `null` when the line is
/// malformed.
///
/// Tolerating malformed lines matters: order meta-data is editable by any
/// store administrator, and one bad row must not discard an entire history.
static FulfillmentEvent? fromWire(String? line) {
if (line == null) return null;
final trimmed = line.trim();
if (trimmed.isEmpty) return null;
final parts = trimmed.split(fieldSeparator);
if (parts.length < 3) return null;
final occurredAt = DateTime.tryParse(parts[2].trim());
if (occurredAt == null) return null;
final rawNote = parts.length > 3 ? parts.sublist(3).join(fieldSeparator).trim() : '';
return FulfillmentEvent(
from: FulfillmentStatus.fromWire(parts[0].trim()),
status: FulfillmentStatus.fromWire(parts[1].trim()),
occurredAt: occurredAt,
note: rawNote.isEmpty ? null : rawNote,
);
}
/// Returns a copy of this event with the given fields replaced.
///
/// [note] cannot be cleared through [copyWith]; construct a new
/// [FulfillmentEvent] when it must be removed. This mirrors `Order.copyWith`
/// and `CustomOrderSpec.copyWith`.
FulfillmentEvent copyWith({
FulfillmentStatus? from,
FulfillmentStatus? status,
DateTime? occurredAt,
String? note,
}) {
return FulfillmentEvent(
from: from ?? this.from,
status: status ?? this.status,
occurredAt: occurredAt ?? this.occurredAt,
note: note ?? this.note,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FulfillmentEvent &&
runtimeType == other.runtimeType &&
from == other.from &&
status == other.status &&
occurredAt == other.occurredAt &&
note == other.note;
@override
int get hashCode => Object.hash(from, status, occurredAt, note);
@override
String toString() =>
'FulfillmentEvent($description at ${occurredAt.toIso8601String()}'
'${hasNote ? ', note: $note' : ''})';
}

View File

@ -0,0 +1,96 @@
import 'material_usage.dart';
/// The outcome of deducting materials for a completed custom order.
///
/// Carries per-SKU failures rather than a single boolean so the operator can
/// be told exactly which material could not be deducted — a short count on
/// one yarn must not silently discard the other four deductions.
class MaterialDeductionResult {
/// The usages that were deducted from inventory.
final List<MaterialUsage> deducted;
/// Failure messages keyed by SKU for the usages that could not be deducted.
final Map<String, String> failures;
MaterialDeductionResult({
List<MaterialUsage> deducted = const [],
Map<String, String> failures = const {},
}) : deducted = List.unmodifiable(deducted),
failures = Map.unmodifiable(failures);
/// A result in which nothing was requested and nothing failed.
///
/// Returned when a commission records no material usage at all, which is
/// the normal case for a shop that has not yet adopted supplies tracking.
MaterialDeductionResult.empty() : this();
/// Whether every requested deduction succeeded.
bool get success => failures.isEmpty;
/// Whether some deductions succeeded and others failed.
bool get isPartial => deducted.isNotEmpty && failures.isNotEmpty;
/// The number of units deducted across all successful usages.
int get totalUnitsDeducted => deducted.fold(0, (sum, usage) => sum + usage.quantity);
/// A single-line summary for the UI, or `null` when nothing was requested.
///
/// Contains SKUs and counts only — never customer or money detail.
String? get summary {
if (deducted.isEmpty && failures.isEmpty) return null;
if (success) {
return 'Deducted $totalUnitsDeducted unit${totalUnitsDeducted == 1 ? '' : 's'} '
'across ${deducted.length} material${deducted.length == 1 ? '' : 's'}.';
}
final failed = failures.keys.join(', ');
return deducted.isEmpty
? 'Material deduction failed for: $failed.'
: 'Deducted ${deducted.length} material${deducted.length == 1 ? '' : 's'}; '
'failed for: $failed.';
}
@override
String toString() =>
'MaterialDeductionResult(deducted: ${deducted.length}, failures: ${failures.length})';
}
/// Outbound port for deducting raw materials when a commission completes.
///
/// `feature_orders` must not depend on `feature_inventory` — orders and
/// inventory are sibling features, and a direct dependency would couple the
/// custom order pipeline to a stock implementation. Instead the orders layer
/// declares this narrow port and each app's composition layer supplies an
/// adapter backed by its own `InventoryRepository`.
///
/// This mirrors the callback pattern already used for cross-feature
/// navigation: the feature states what it needs, the app decides how.
abstract class MaterialConsumptionPort {
/// Deducts [usages] from stock on behalf of the order identified by
/// [orderId].
///
/// Implementations must be **tolerant**: a failure to deduct one material
/// must not abort the others, and must never throw. Report per-SKU problems
/// through [MaterialDeductionResult.failures] instead.
///
/// [orderId] is passed for the adjustment reason and for log context only.
Future<MaterialDeductionResult> deductMaterials(String orderId, List<MaterialUsage> usages);
}
/// A [MaterialConsumptionPort] that records requests and deducts nothing.
///
/// Used by app targets that have not wired supplies tracking yet, and by
/// tests. Completion still succeeds — an unwired inventory must never block
/// an operator from closing a finished commission.
class NoopMaterialConsumptionPort implements MaterialConsumptionPort {
/// Every `(orderId, usages)` pair this port was asked to deduct.
final List<({String orderId, List<MaterialUsage> usages})> requests = [];
@override
Future<MaterialDeductionResult> deductMaterials(
String orderId,
List<MaterialUsage> usages,
) async {
requests.add((orderId: orderId, usages: List.unmodifiable(usages)));
return MaterialDeductionResult.empty();
}
}

View File

@ -0,0 +1,90 @@
/// A quantity of a raw material consumed by a custom order.
///
/// Immutable value object. This is the *orders* view of material
/// consumption — it names a SKU and a quantity and nothing else. The
/// inventory package owns what a SKU actually is, what units it is held in,
/// and how stock is adjusted; `feature_orders` deliberately knows none of
/// that, so the two features stay independent.
///
/// See `MaterialConsumptionPort` for how a usage record reaches inventory.
class MaterialUsage {
/// The SKU of the consumed inventory item.
final String sku;
/// How many units were consumed. Always positive.
final int quantity;
/// An optional human-readable name for display in the timeline.
///
/// Purely cosmetic — the [sku] is the identifier.
final String? displayName;
const MaterialUsage({required this.sku, required this.quantity, this.displayName});
/// Whether this record is usable as a deduction instruction.
///
/// A blank SKU or a non-positive quantity cannot deduct anything, so the
/// port rejects it rather than issuing a meaningless adjustment.
bool get isValid => sku.trim().isNotEmpty && quantity > 0;
/// The label shown in the timeline — [displayName] when present, else [sku].
String get label =>
(displayName != null && displayName!.trim().isNotEmpty) ? displayName!.trim() : sku;
// ── Wire format ──────────────────────────────────────────────────────────
/// Field separator used by [toWire].
static const String fieldSeparator = '|';
/// Encodes this usage as a single line for channel meta-data.
///
/// Format: `sku|quantity|displayName`. Separator and newline characters are
/// stripped from the text fields so one record cannot corrupt the block.
String toWire() {
final safeSku = _sanitise(sku);
final safeName = _sanitise(displayName ?? '');
return [safeSku, quantity.toString(), safeName].join(fieldSeparator);
}
/// Parses a line produced by [toWire], or returns `null` when the line is
/// malformed or names no SKU.
static MaterialUsage? fromWire(String? line) {
if (line == null) return null;
final trimmed = line.trim();
if (trimmed.isEmpty) return null;
final parts = trimmed.split(fieldSeparator);
if (parts.length < 2) return null;
final sku = parts[0].trim();
final quantity = int.tryParse(parts[1].trim());
if (sku.isEmpty || quantity == null) return null;
final name = parts.length > 2 ? parts[2].trim() : '';
return MaterialUsage(sku: sku, quantity: quantity, displayName: name.isEmpty ? null : name);
}
static String _sanitise(String value) => value.replaceAll(RegExp(r'[\r\n|]'), ' ').trim();
/// Returns a copy of this record with the given fields replaced.
MaterialUsage copyWith({String? sku, int? quantity, String? displayName}) => MaterialUsage(
sku: sku ?? this.sku,
quantity: quantity ?? this.quantity,
displayName: displayName ?? this.displayName,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MaterialUsage &&
runtimeType == other.runtimeType &&
sku == other.sku &&
quantity == other.quantity &&
displayName == other.displayName;
@override
int get hashCode => Object.hash(sku, quantity, displayName);
@override
String toString() => 'MaterialUsage(sku: $sku, quantity: $quantity)';
}

View File

@ -1,6 +1,8 @@
import 'custom_order_spec.dart'; import 'custom_order_spec.dart';
import 'deposit_info.dart'; import 'deposit_info.dart';
import 'fulfillment_event.dart';
import 'fulfillment_status.dart'; import 'fulfillment_status.dart';
import 'material_usage.dart';
import 'order_item.dart'; import 'order_item.dart';
import 'order_source.dart'; import 'order_source.dart';
import 'order_status.dart'; import 'order_status.dart';
@ -66,6 +68,25 @@ class Order {
/// transitions defined by [FulfillmentTransition]. /// transitions defined by [FulfillmentTransition].
final FulfillmentStatus? fulfillmentStatus; final FulfillmentStatus? fulfillmentStatus;
/// The ordered record of every accepted fulfillment transition.
///
/// Empty for standard orders and for custom orders created before tracking
/// was introduced. Appended to — never rewritten — by
/// `OrdersRepository.advanceFulfillmentStatus`, so the timeline is an audit
/// trail rather than a derived view.
///
/// Held in chronological order. Follows the same convention as [items] and
/// [orderNotes]: the list is stored as supplied and replaced wholesale by
/// [copyWith] rather than mutated in place.
final List<FulfillmentEvent> fulfillmentHistory;
/// The raw materials consumed by this commission.
///
/// Empty for standard orders and for commissions whose materials have not
/// been itemised. Deducted from stock through `MaterialConsumptionPort`
/// when the order reaches [FulfillmentStatus.completed].
final List<MaterialUsage> materialUsage;
const Order({ const Order({
required this.id, required this.id,
required this.customerName, required this.customerName,
@ -84,6 +105,8 @@ class Order {
this.customOrderSpec, this.customOrderSpec,
this.depositInfo, this.depositInfo,
this.fulfillmentStatus, this.fulfillmentStatus,
this.fulfillmentHistory = const [],
this.materialUsage = const [],
}); });
/// The total value of the order. /// The total value of the order.
@ -102,6 +125,44 @@ class Order {
/// exists (standard orders, or custom orders that have not been quoted). /// exists (standard orders, or custom orders that have not been quoted).
int? get balanceDueCents => depositInfo?.balanceDueCents; int? get balanceDueCents => depositInfo?.balanceDueCents;
// ── Fulfillment tracking ─────────────────────────────────────────────────
/// The current pipeline stage, defaulting to [FulfillmentStatus.quoted] for
/// custom orders that have not been stamped yet.
///
/// `null` for standard orders, which have no pipeline.
FulfillmentStatus? get currentStage =>
isCustom ? (fulfillmentStatus ?? FulfillmentStatus.quoted) : null;
/// Whether this commission has a recorded transition history.
bool get hasFulfillmentHistory => fulfillmentHistory.isNotEmpty;
/// The most recent recorded transition, or `null` when none exists.
FulfillmentEvent? get latestFulfillmentEvent =>
fulfillmentHistory.isEmpty ? null : fulfillmentHistory.last;
/// When the order last moved through the pipeline, or `null` when it has
/// no recorded history.
DateTime? get lastFulfillmentUpdate => latestFulfillmentEvent?.occurredAt;
/// The stages this commission may legally move to next.
///
/// Empty for standard orders and for commissions at a terminal stage.
List<FulfillmentStatus> get allowedNextStages => currentStage?.allowedNextStatuses ?? const [];
/// Whether the commission has finished, successfully or otherwise.
bool get isFulfillmentComplete => currentStage?.isTerminal ?? false;
/// Whether this commission itemises the materials it consumed.
bool get hasMaterialUsage => materialUsage.isNotEmpty;
/// The materials that can actually be deducted from stock.
///
/// Records with a blank SKU or a non-positive quantity are excluded rather
/// than issued as meaningless adjustments.
List<MaterialUsage> get deductibleMaterials =>
materialUsage.where((usage) => usage.isValid).toList();
/// Returns a copy of this order with the given fields replaced. /// Returns a copy of this order with the given fields replaced.
/// ///
/// Nullable fields cannot be cleared through [copyWith]; construct a new /// Nullable fields cannot be cleared through [copyWith]; construct a new
@ -124,6 +185,8 @@ class Order {
CustomOrderSpec? customOrderSpec, CustomOrderSpec? customOrderSpec,
DepositInfo? depositInfo, DepositInfo? depositInfo,
FulfillmentStatus? fulfillmentStatus, FulfillmentStatus? fulfillmentStatus,
List<FulfillmentEvent>? fulfillmentHistory,
List<MaterialUsage>? materialUsage,
}) { }) {
return Order( return Order(
id: id ?? this.id, id: id ?? this.id,
@ -143,6 +206,18 @@ class Order {
customOrderSpec: customOrderSpec ?? this.customOrderSpec, customOrderSpec: customOrderSpec ?? this.customOrderSpec,
depositInfo: depositInfo ?? this.depositInfo, depositInfo: depositInfo ?? this.depositInfo,
fulfillmentStatus: fulfillmentStatus ?? this.fulfillmentStatus, fulfillmentStatus: fulfillmentStatus ?? this.fulfillmentStatus,
fulfillmentHistory: fulfillmentHistory ?? this.fulfillmentHistory,
materialUsage: materialUsage ?? this.materialUsage,
); );
} }
/// Returns a copy of this order with [event] appended to
/// [fulfillmentHistory] and [fulfillmentStatus] moved to `event.status`.
///
/// The history is append-only: no existing event is ever rewritten, so the
/// timeline stays an audit trail. Transition legality is **not** checked
/// here — that is the repository's job, via [FulfillmentTransition], so the
/// rule lives in exactly one place.
Order withFulfillmentEvent(FulfillmentEvent event) =>
copyWith(fulfillmentStatus: event.status, fulfillmentHistory: [...fulfillmentHistory, event]);
} }

View File

@ -1,6 +1,8 @@
import 'custom_order_spec.dart'; import 'custom_order_spec.dart';
import 'deposit_info.dart'; import 'deposit_info.dart';
import 'fulfillment_event.dart';
import 'fulfillment_status.dart'; import 'fulfillment_status.dart';
import 'material_usage.dart';
import 'order.dart'; import 'order.dart';
import 'order_action_result.dart'; import 'order_action_result.dart';
import 'order_status.dart'; import 'order_status.dart';
@ -82,7 +84,35 @@ abstract class OrdersRepository {
/// jumps are rejected with an explanatory failure result and leave the /// jumps are rejected with an explanatory failure result and leave the
/// order unchanged. /// order unchanged.
/// ///
/// On success a [FulfillmentEvent] is appended to
/// [Order.fulfillmentHistory] recording the stage moved from, the stage
/// moved to, [occurredAt] (defaulting to the implementation's clock), and
/// the optional operator [note]. The history is append-only — an existing
/// event is never rewritten.
///
/// [note] is an **operator** note about production progress. It is written
/// to channel meta-data, so implementations must not accept customer
/// identity, money amounts, or payment identifiers through it.
///
/// Returns a failure result when the order does not exist, is not a custom /// Returns a failure result when the order does not exist, is not a custom
/// order, or the transition is illegal. /// order, or the transition is illegal.
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next); Future<OrderActionResult> advanceFulfillmentStatus(
String id,
FulfillmentStatus next, {
String? note,
DateTime? occurredAt,
});
/// Replaces the recorded material consumption on the custom order
/// identified by [id].
///
/// This records *what was used*; it does not touch stock. Deducting from
/// inventory is the job of a `MaterialConsumptionPort` adapter supplied by
/// the app's composition layer, because `feature_orders` must not depend on
/// `feature_inventory`.
///
/// Returns a failure result when the order does not exist, is not a custom
/// order, or any supplied usage names a blank SKU or a non-positive
/// quantity.
Future<OrderActionResult> recordMaterialUsage(String id, List<MaterialUsage> usages);
} }

View File

@ -0,0 +1,401 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../application/advance_fulfillment.dart';
import '../application/complete_custom_order.dart';
import '../application/custom_order_tracking_controller.dart';
import '../application/get_orders.dart';
import '../domain/fulfillment_status.dart';
import '../domain/material_consumption_port.dart';
import '../domain/order.dart';
import '../domain/orders_repository.dart';
import 'orders_action_snack_bar.dart';
import 'widgets/advance_stage_dialog.dart';
import 'widgets/custom_order_tracking_card.dart';
import 'widgets/custom_order_tracking_detail.dart';
/// The custom order tracking workspace.
///
/// Lists made-to-order commissions filtered by workshop stage, and lets the
/// operator move one forward through the pipeline. Selecting a commission
/// shows its specification, deposit state, materials, and the full timeline
/// of recorded stage changes.
///
/// Completing a commission routes through [CompleteCustomOrder] so the
/// materials it consumed are deducted from stock via the injected
/// [materialPort]. When no port is supplied the commission still completes —
/// an unwired inventory must not stop an operator handing over finished work.
class CustomOrderTrackingPage extends StatefulWidget {
final OrdersRepository repository;
/// Outbound port used to deduct materials when a commission completes.
///
/// Supplied by the app's composition layer, because `feature_orders` must
/// not depend on `feature_inventory`. `null` disables deduction.
final MaterialConsumptionPort? materialPort;
/// Optional controller override. When omitted the page owns one.
final CustomOrderTrackingController? controller;
/// Optional callback that opens the custom order intake form.
///
/// When `null` the "New Custom Order" action is not rendered.
final VoidCallback? onCreateCustomOrder;
/// Optional initial stage filter.
final FulfillmentStatus? initialStage;
/// Optional order ID to pre-select on first load.
final String? initialSelectedId;
const CustomOrderTrackingPage({
super.key,
required this.repository,
this.materialPort,
this.controller,
this.onCreateCustomOrder,
this.initialStage,
this.initialSelectedId,
});
@override
State<CustomOrderTrackingPage> createState() => _CustomOrderTrackingPageState();
}
class _CustomOrderTrackingPageState extends State<CustomOrderTrackingPage> {
late final CustomOrderTrackingController _controller;
late final bool _ownsController;
late final TextEditingController _searchController;
@override
void initState() {
super.initState();
_ownsController = widget.controller == null;
_controller =
widget.controller ??
CustomOrderTrackingController(
GetOrders(widget.repository),
advanceFulfillment: AdvanceFulfillment(widget.repository),
completeCustomOrder: CompleteCustomOrder(
widget.repository,
materialPort: widget.materialPort,
),
);
_searchController = TextEditingController(text: _controller.searchQuery);
if (widget.initialStage != null) {
_controller.activeStage = widget.initialStage;
}
_controller.addListener(_onControllerChanged);
_controller.load().then((_) {
final id = widget.initialSelectedId;
if (id != null) _controller.selectById(id);
});
}
@override
void dispose() {
_controller.removeListener(_onControllerChanged);
if (_ownsController) _controller.dispose();
_searchController.dispose();
super.dispose();
}
void _onControllerChanged() {
if (_controller.isUpdating) return;
final result = _controller.consumeActionResult();
if (result != null) {
showOrderActionSnackBar(context, result, successMessage: 'Fulfillment stage updated.');
}
// A completion that could not decrement stock is reported separately: the
// commission closed, but the operator must know inventory is now wrong.
final warning = _controller.consumeDeductionWarning();
if (warning != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(warning),
backgroundColor: KcColors.warning,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 5),
),
);
}
}
Future<void> _handleAdvance(Order order) async {
final current = order.currentStage ?? FulfillmentStatus.quoted;
final choice = await showAdvanceStageDialog(
context,
current: current,
allowedNext: order.allowedNextStages,
);
if (choice == null) return;
await _controller.advanceTo(order.id, choice.next, note: choice.note);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
if (_controller.isLoading) {
return const KcLoadingState(message: 'Loading custom orders…');
}
if (_controller.error != null) {
return KcErrorState(message: 'Failed to load custom orders.', onRetry: _controller.load);
}
return LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= KcBreakpoints.medium;
return Column(
children: [
_TrackingToolbar(
searchController: _searchController,
controller: _controller,
onCreateCustomOrder: widget.onCreateCustomOrder,
),
Expanded(
child: isWide
? _WideLayout(controller: _controller, onAdvance: _handleAdvance)
: _NarrowLayout(controller: _controller, onAdvance: _handleAdvance),
),
],
);
},
);
},
);
}
}
// ── Toolbar ────────────────────────────────────────────────────────────────────
/// Search field, stage filter chips with live counts, and the intake action.
class _TrackingToolbar extends StatelessWidget {
final TextEditingController searchController;
final CustomOrderTrackingController controller;
final VoidCallback? onCreateCustomOrder;
const _TrackingToolbar({
required this.searchController,
required this.controller,
this.onCreateCustomOrder,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final counts = controller.stageCounts;
final visible = controller.orders.length;
return Padding(
padding: const EdgeInsets.fromLTRB(KcSpacing.md, KcSpacing.md, KcSpacing.md, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: searchController,
onChanged: controller.setSearchQuery,
decoration: InputDecoration(
hintText: 'Search commissions by customer, ID, or specification',
prefixIcon: const Icon(Icons.search),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Clear search',
onPressed: () {
searchController.clear();
controller.setSearchQuery('');
},
)
: null,
border: const OutlineInputBorder(),
isDense: true,
),
),
),
if (onCreateCustomOrder != null) ...[
const SizedBox(width: KcSpacing.sm),
FilledButton.icon(
onPressed: onCreateCustomOrder,
icon: const Icon(Icons.add),
label: const Text('New Custom Order'),
),
],
],
),
const SizedBox(height: KcSpacing.sm),
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: KcSpacing.xs),
child: FilterChip(
label: Text('All (${controller.totalCustomOrders})'),
selected: controller.activeStage == null,
onSelected: (_) => controller.setStageFilter(null),
),
),
// Pipeline order, with cancelled last — it is outside the
// linear run and should not interrupt the sequence.
for (final stage in [
...FulfillmentStatus.pipeline,
FulfillmentStatus.cancelled,
])
Padding(
padding: const EdgeInsets.only(right: KcSpacing.xs),
child: FilterChip(
label: Text('${stage.label} (${counts[stage] ?? 0})'),
selected: controller.activeStage == stage,
onSelected: (_) => controller.setStageFilter(stage),
),
),
],
),
),
),
const SizedBox(width: KcSpacing.sm),
Text(
'$visible shown · ${controller.activeCount} active',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
],
),
const SizedBox(height: KcSpacing.sm),
],
),
);
}
}
// ── Wide layout (list + timeline side by side) ─────────────────────────────────
class _WideLayout extends StatelessWidget {
final CustomOrderTrackingController controller;
final Future<void> Function(Order) onAdvance;
const _WideLayout({required this.controller, required this.onAdvance});
@override
Widget build(BuildContext context) {
final selected = controller.selectedOrder;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 340, child: _TrackingList(controller: controller)),
const VerticalDivider(width: 1),
Expanded(
child: selected == null
? const KcEmptyState(
icon: Icons.design_services_outlined,
message: 'Select a commission to view its fulfillment timeline.',
)
: CustomOrderTrackingDetail(
order: selected,
isUpdating: controller.isUpdating,
onAdvance: () => onAdvance(selected),
),
),
],
);
}
}
// ── Narrow layout (list only; tap pushes the timeline) ─────────────────────────
class _NarrowLayout extends StatelessWidget {
final CustomOrderTrackingController controller;
final Future<void> Function(Order) onAdvance;
const _NarrowLayout({required this.controller, required this.onAdvance});
@override
Widget build(BuildContext context) {
return _TrackingList(
controller: controller,
onTap: (order) {
controller.selectOrder(order);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => _TrackingDetailPage(controller: controller, onAdvance: onAdvance),
),
);
},
);
}
}
// ── Shared list ────────────────────────────────────────────────────────────────
class _TrackingList extends StatelessWidget {
final CustomOrderTrackingController controller;
final void Function(Order order)? onTap;
const _TrackingList({required this.controller, this.onTap});
@override
Widget build(BuildContext context) {
if (controller.orders.isEmpty) {
return const KcEmptyState(
icon: Icons.design_services_outlined,
message: 'No custom orders match this stage or search.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(KcSpacing.md),
itemCount: controller.orders.length,
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
itemBuilder: (context, index) {
final order = controller.orders[index];
return CustomOrderTrackingCard(
order: order,
isSelected: order.id == controller.selectedOrder?.id,
onTap: () {
controller.selectOrder(order);
onTap?.call(order);
},
);
},
);
}
}
// ── Narrow detail page ─────────────────────────────────────────────────────────
class _TrackingDetailPage extends StatelessWidget {
final CustomOrderTrackingController controller;
final Future<void> Function(Order) onAdvance;
const _TrackingDetailPage({required this.controller, required this.onAdvance});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(controller.selectedOrder?.id ?? 'Custom Order')),
body: AnimatedBuilder(
animation: controller,
builder: (context, _) {
final order = controller.selectedOrder;
if (order == null) return const SizedBox.shrink();
return CustomOrderTrackingDetail(
order: order,
isUpdating: controller.isUpdating,
onAdvance: () => onAdvance(order),
);
},
),
);
}
}

View File

@ -32,6 +32,12 @@ class OrdersPage extends StatefulWidget {
/// stays usable in contexts that have no route for the intake flow. /// stays usable in contexts that have no route for the intake flow.
final VoidCallback? onCreateCustomOrder; final VoidCallback? onCreateCustomOrder;
/// Optional callback that opens the custom order tracking workspace.
///
/// When `null`, the "Track Custom Orders" action is not rendered — the page
/// stays usable in contexts that have no tracking route.
final VoidCallback? onTrackCustomOrders;
/// Optional initial status filter to apply on first load (e.g. `'pending'`). /// Optional initial status filter to apply on first load (e.g. `'pending'`).
final String? initialFilter; final String? initialFilter;
@ -47,6 +53,7 @@ class OrdersPage extends StatefulWidget {
this.onViewProduct, this.onViewProduct,
this.onViewInventory, this.onViewInventory,
this.onCreateCustomOrder, this.onCreateCustomOrder,
this.onTrackCustomOrders,
this.initialFilter, this.initialFilter,
this.initialQuery, this.initialQuery,
this.initialSelectedId, this.initialSelectedId,
@ -145,6 +152,7 @@ class _OrdersPageState extends State<OrdersPage> {
onSearchChanged: _controller.setSearchQuery, onSearchChanged: _controller.setSearchQuery,
onFilterChanged: _controller.setFilter, onFilterChanged: _controller.setFilter,
onCreateCustomOrder: widget.onCreateCustomOrder, onCreateCustomOrder: widget.onCreateCustomOrder,
onTrackCustomOrders: widget.onTrackCustomOrders,
), ),
Expanded( Expanded(
child: isWide child: isWide
@ -181,6 +189,7 @@ class _OrdersToolbar extends StatelessWidget {
final ValueChanged<String> onSearchChanged; final ValueChanged<String> onSearchChanged;
final ValueChanged<String?> onFilterChanged; final ValueChanged<String?> onFilterChanged;
final VoidCallback? onCreateCustomOrder; final VoidCallback? onCreateCustomOrder;
final VoidCallback? onTrackCustomOrders;
const _OrdersToolbar({ const _OrdersToolbar({
required this.searchController, required this.searchController,
@ -189,6 +198,7 @@ class _OrdersToolbar extends StatelessWidget {
required this.onSearchChanged, required this.onSearchChanged,
required this.onFilterChanged, required this.onFilterChanged,
this.onCreateCustomOrder, this.onCreateCustomOrder,
this.onTrackCustomOrders,
}); });
static const _filters = [ static const _filters = [
@ -207,15 +217,29 @@ class _OrdersToolbar extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// New custom order action // Custom order actions — each omitted when the host has no route.
if (onCreateCustomOrder != null) ...[ if (onCreateCustomOrder != null || onTrackCustomOrders != null) ...[
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: FilledButton.icon( child: Wrap(
spacing: KcSpacing.sm,
runSpacing: KcSpacing.sm,
alignment: WrapAlignment.end,
children: [
if (onTrackCustomOrders != null)
OutlinedButton.icon(
onPressed: onTrackCustomOrders,
icon: const Icon(Icons.timeline_outlined),
label: const Text('Track Custom Orders'),
),
if (onCreateCustomOrder != null)
FilledButton.icon(
onPressed: onCreateCustomOrder, onPressed: onCreateCustomOrder,
icon: const Icon(Icons.design_services_outlined), icon: const Icon(Icons.design_services_outlined),
label: const Text('New Custom Order'), label: const Text('New Custom Order'),
), ),
],
),
), ),
const SizedBox(height: KcSpacing.sm), const SizedBox(height: KcSpacing.sm),
], ],

View File

@ -0,0 +1,152 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../domain/fulfillment_status.dart';
import 'fulfillment_status_chip.dart';
/// The operator's answer from [showAdvanceStageDialog].
class AdvanceStageResult {
/// The stage the operator chose to move to.
final FulfillmentStatus next;
/// The optional operator note about production progress.
///
/// `null` when the operator advanced without comment. Never carries
/// customer identity, money amounts, or payment identifiers — the field is
/// labelled and hinted for workshop progress only.
final String? note;
const AdvanceStageResult({required this.next, this.note});
}
/// Asks the operator to confirm a pipeline advance and optionally attach a
/// note.
///
/// [allowedNext] must come from the domain (`Order.allowedNextStages`), so
/// the dialog can only ever offer legal transitions. The repository still
/// re-validates — this is a convenience, not the rule.
///
/// Returns `null` when the operator cancels.
Future<AdvanceStageResult?> showAdvanceStageDialog(
BuildContext context, {
required FulfillmentStatus current,
required List<FulfillmentStatus> allowedNext,
FulfillmentStatus? initialSelection,
}) {
return showDialog<AdvanceStageResult>(
context: context,
builder: (_) => _AdvanceStageDialog(
current: current,
allowedNext: allowedNext,
initialSelection: initialSelection,
),
);
}
class _AdvanceStageDialog extends StatefulWidget {
final FulfillmentStatus current;
final List<FulfillmentStatus> allowedNext;
final FulfillmentStatus? initialSelection;
const _AdvanceStageDialog({
required this.current,
required this.allowedNext,
this.initialSelection,
});
@override
State<_AdvanceStageDialog> createState() => _AdvanceStageDialogState();
}
class _AdvanceStageDialogState extends State<_AdvanceStageDialog> {
late FulfillmentStatus? _selected;
late final TextEditingController _noteController;
@override
void initState() {
super.initState();
_noteController = TextEditingController();
final initial = widget.initialSelection;
_selected = (initial != null && widget.allowedNext.contains(initial))
? initial
: (widget.allowedNext.isNotEmpty ? widget.allowedNext.first : null);
}
@override
void dispose() {
_noteController.dispose();
super.dispose();
}
void _submit() {
final next = _selected;
if (next == null) return;
final note = _noteController.text.trim();
Navigator.of(context).pop(AdvanceStageResult(next: next, note: note.isEmpty ? null : note));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: const Text('Update Fulfillment Stage'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Currently',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
const SizedBox(width: KcSpacing.sm),
FulfillmentStatusChip(status: widget.current),
],
),
const SizedBox(height: KcSpacing.md),
if (widget.allowedNext.isEmpty)
Text(
'This commission is ${widget.current.label.toLowerCase()} and cannot '
'be moved any further.',
style: theme.textTheme.bodyMedium,
)
else ...[
DropdownButtonFormField<FulfillmentStatus>(
initialValue: _selected,
decoration: const InputDecoration(
labelText: 'Move to',
border: OutlineInputBorder(),
isDense: true,
),
items: [
for (final stage in widget.allowedNext)
DropdownMenuItem<FulfillmentStatus>(value: stage, child: Text(stage.label)),
],
onChanged: (value) => setState(() => _selected = value),
),
const SizedBox(height: KcSpacing.sm),
TextField(
controller: _noteController,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Progress note (optional)',
hintText: 'e.g. Glaze fired; cooling overnight',
helperText: 'Workshop progress only — no customer or payment detail.',
border: OutlineInputBorder(),
),
),
],
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
if (widget.allowedNext.isNotEmpty)
FilledButton(onPressed: _selected == null ? null : _submit, child: const Text('Update')),
],
);
}
}

View File

@ -0,0 +1,119 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../domain/fulfillment_status.dart';
import '../../domain/order.dart';
import 'fulfillment_status_chip.dart';
/// A list row summarising a custom order's fulfillment state.
///
/// Distinct from `OrderCard`, which leads with the commercial `OrderStatus`
/// and the order total. This card leads with the **workshop stage**, the
/// specification, the balance still owed, and the date of the last stage
/// change — the four things an operator scans for when working the bench.
class CustomOrderTrackingCard extends StatelessWidget {
final Order order;
final bool isSelected;
final VoidCallback? onTap;
const CustomOrderTrackingCard({
super.key,
required this.order,
this.isSelected = false,
this.onTap,
});
/// Formats a date as `YYYY-MM-DD`.
static String formatDate(DateTime date) =>
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stage = order.currentStage ?? FulfillmentStatus.quoted;
final spec = order.customOrderSpec;
final deposit = order.depositInfo;
final lastUpdate = order.lastFulfillmentUpdate;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(KcSpacing.md),
decoration: BoxDecoration(
color: KcColors.surface,
border: Border.all(
color: isSelected ? KcColors.denimBlue : KcColors.border,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
order.id,
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
FulfillmentStatusChip(status: stage),
],
),
const SizedBox(height: KcSpacing.xs),
Text(
order.customerName,
style: theme.textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (spec != null) ...[
const SizedBox(height: KcSpacing.xs),
Text(
spec.summary,
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: KcSpacing.sm),
// Both halves are flexible: a long balance and a long date must
// shrink rather than overflow on a narrow master list.
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (deposit != null)
Flexible(
child: Text(
'Balance ${deposit.balanceDueFormatted}',
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: Text(
lastUpdate != null
? 'Updated ${formatDate(lastUpdate)}'
: formatDate(order.orderDate),
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
),
),
],
),
],
),
),
);
}
}

View File

@ -0,0 +1,232 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../domain/fulfillment_status.dart';
import '../../domain/order.dart';
import 'fulfillment_status_chip.dart';
import 'fulfillment_timeline.dart';
/// The detail view for one custom order in the tracking workspace.
///
/// Shows the current stage, the specification, deposit and balance, the
/// materials the commission consumes, and the full timeline of recorded
/// stage changes. The advance action is offered only while the pipeline has
/// a legal next stage.
///
/// No payment identifier appears here — that stays on `PaymentResult`, the
/// same rule the confirmation receipt follows.
class CustomOrderTrackingDetail extends StatelessWidget {
final Order order;
/// Whether a stage advance is currently in flight.
final bool isUpdating;
/// Called when the operator opens the advance dialog.
final VoidCallback onAdvance;
const CustomOrderTrackingDetail({
super.key,
required this.order,
required this.onAdvance,
this.isUpdating = false,
});
/// Formats a date as `YYYY-MM-DD`.
static String formatDate(DateTime date) =>
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stage = order.currentStage ?? FulfillmentStatus.quoted;
return SingleChildScrollView(
padding: const EdgeInsets.all(KcSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Header ───────────────────────────────────────────────────────
Row(
children: [
Expanded(child: Text(order.id, style: theme.textTheme.headlineSmall)),
FulfillmentStatusChip(status: stage),
],
),
const SizedBox(height: KcSpacing.xs),
Text(
order.customerName,
style: theme.textTheme.titleMedium?.copyWith(color: KcColors.neutral),
),
const SizedBox(height: KcSpacing.md),
_StageAction(order: order, stage: stage, isUpdating: isUpdating, onAdvance: onAdvance),
const SizedBox(height: KcSpacing.lg),
_SpecificationSection(order: order),
const SizedBox(height: KcSpacing.md),
_MoneySection(order: order),
_MaterialsSection(order: order),
const SizedBox(height: KcSpacing.md),
// ── Timeline ─────────────────────────────────────────────────────
const KcSectionHeader(title: 'Fulfillment Timeline'),
const SizedBox(height: KcSpacing.sm),
FulfillmentTimeline(events: order.fulfillmentHistory),
],
),
);
}
}
/// The advance button, a progress bar while a write is in flight, or an
/// explanation when the pipeline has nowhere left to go.
class _StageAction extends StatelessWidget {
final Order order;
final FulfillmentStatus stage;
final bool isUpdating;
final VoidCallback onAdvance;
const _StageAction({
required this.order,
required this.stage,
required this.isUpdating,
required this.onAdvance,
});
@override
Widget build(BuildContext context) {
if (isUpdating) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: KcSpacing.sm),
child: LinearProgressIndicator(),
);
}
if (order.allowedNextStages.isNotEmpty) {
return FilledButton.icon(
onPressed: onAdvance,
icon: const Icon(Icons.arrow_forward),
label: const Text('Update Stage'),
);
}
return Text(
'This commission is ${stage.label.toLowerCase()}; no further stage changes '
'are possible.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
);
}
}
class _SpecificationSection extends StatelessWidget {
final Order order;
const _SpecificationSection({required this.order});
@override
Widget build(BuildContext context) {
final spec = order.customOrderSpec;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const KcSectionHeader(title: 'Specification'),
if (order.items.isNotEmpty) _DetailRow(label: 'Item', value: order.items.first.productName),
if (spec != null) ...[
_DetailRow(label: 'Material', value: spec.material),
_DetailRow(label: 'Colour', value: spec.color),
if (spec.size != null) _DetailRow(label: 'Size', value: spec.size!),
if (spec.dimensions != null) _DetailRow(label: 'Dimensions', value: spec.dimensions!),
if (spec.notes != null) _DetailRow(label: 'Instructions', value: spec.notes!),
if (spec.estimatedCompletionDate != null)
_DetailRow(
label: 'Estimated Completion',
value: CustomOrderTrackingDetail.formatDate(spec.estimatedCompletionDate!),
),
],
],
);
}
}
/// Deposit and balance. Amounts only — never a transaction identifier.
class _MoneySection extends StatelessWidget {
final Order order;
const _MoneySection({required this.order});
@override
Widget build(BuildContext context) {
final deposit = order.depositInfo;
if (deposit == null) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const KcSectionHeader(title: 'Deposit & Balance'),
_DetailRow(label: 'Agreed Total', value: deposit.totalFormatted),
_DetailRow(
label: deposit.depositPaid ? 'Deposit Paid' : 'Deposit Due',
value: deposit.depositFormatted,
),
_DetailRow(label: 'Balance Due', value: deposit.balanceDueFormatted),
const SizedBox(height: KcSpacing.md),
],
);
}
}
class _MaterialsSection extends StatelessWidget {
final Order order;
const _MaterialsSection({required this.order});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const KcSectionHeader(title: 'Materials Used'),
if (!order.hasMaterialUsage)
Text(
'No materials itemised for this commission.',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
)
else ...[
for (final usage in order.materialUsage)
_DetailRow(label: usage.label, value: '${usage.quantity} × ${usage.sku}'),
const SizedBox(height: KcSpacing.xs),
Text(
'Deducted from stock when the commission is completed.',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
],
],
);
}
}
class _DetailRow extends StatelessWidget {
final String label;
final String value;
const _DetailRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 160,
child: Text(label, style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral)),
),
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
],
),
);
}
}

View File

@ -0,0 +1,130 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../domain/fulfillment_event.dart';
import '../../domain/fulfillment_status.dart';
/// A vertical, chronological timeline of a custom order's stage changes.
///
/// Renders one row per [FulfillmentEvent] — newest last — showing the
/// transition, its timestamp, and any operator note. Rework transitions
/// (`qualityCheck → inProduction`) are marked so a reader can tell a loop
/// from forward progress at a glance.
///
/// Shows only what the events carry: stage labels, timestamps, and operator
/// notes. No customer identity, money amount, or payment identifier appears
/// here.
class FulfillmentTimeline extends StatelessWidget {
/// The events to render, in chronological order.
final List<FulfillmentEvent> events;
/// Whether to render newest-first instead of oldest-first.
final bool newestFirst;
const FulfillmentTimeline({super.key, required this.events, this.newestFirst = false});
/// Formats a timestamp as `YYYY-MM-DD HH:MM`.
static String formatTimestamp(DateTime at) {
String two(int value) => value.toString().padLeft(2, '0');
return '${at.year}-${two(at.month)}-${two(at.day)} ${two(at.hour)}:${two(at.minute)}';
}
@override
Widget build(BuildContext context) {
if (events.isEmpty) {
return const KcEmptyState(
icon: Icons.history_toggle_off,
message: 'No status changes recorded yet.',
);
}
final ordered = newestFirst ? events.reversed.toList() : events;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < ordered.length; i++)
_TimelineRow(event: ordered[i], isLast: i == ordered.length - 1),
],
);
}
}
/// One event row: a status dot, a connecting rail, and the event detail.
class _TimelineRow extends StatelessWidget {
final FulfillmentEvent event;
final bool isLast;
const _TimelineRow({required this.event, required this.isLast});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accentFor(event);
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Rail: dot plus the line running down to the next event.
Column(
children: [
Container(
width: 14,
height: 14,
margin: const EdgeInsets.only(top: KcSpacing.xs),
decoration: BoxDecoration(color: accent, shape: BoxShape.circle),
),
if (!isLast) Expanded(child: Container(width: 2, color: KcColors.border)),
],
),
const SizedBox(width: KcSpacing.sm),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : KcSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
event.description,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
if (event.isRework)
const KcStatusChip(
label: 'Rework',
background: Color(0xFFFFF8E1),
foreground: KcColors.warning,
),
],
),
const SizedBox(height: KcSpacing.xs),
Text(
FulfillmentTimeline.formatTimestamp(event.occurredAt),
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
if (event.hasNote) ...[
const SizedBox(height: KcSpacing.xs),
Text(event.note!, style: theme.textTheme.bodySmall),
],
],
),
),
),
],
),
);
}
/// The dot colour: red for cancellation, amber for rework, green for a
/// completed commission, blue for ordinary forward progress.
static Color _accentFor(FulfillmentEvent event) {
if (event.status == FulfillmentStatus.cancelled) return KcColors.danger;
if (event.isRework) return KcColors.warning;
if (event.status == FulfillmentStatus.completed) return KcColors.success;
return KcColors.denimBlue;
}
}

View File

@ -0,0 +1,258 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
/// A port that fails for every SKU in [failFor] and succeeds for the rest.
class _PartialPort implements MaterialConsumptionPort {
final Set<String> failFor;
final List<String> calls = [];
_PartialPort([this.failFor = const {}]);
@override
Future<MaterialDeductionResult> deductMaterials(
String orderId,
List<MaterialUsage> usages,
) async {
calls.add(orderId);
final deducted = usages.where((u) => !failFor.contains(u.sku)).toList();
final failures = {
for (final u in usages.where((u) => failFor.contains(u.sku))) u.sku: 'Insufficient stock.',
};
return MaterialDeductionResult(deducted: deducted, failures: failures);
}
}
DateTime _fixedNow() => DateTime(2026, 5, 1, 12);
FakeOrdersRepository _repo() => FakeOrdersRepository(now: _fixedNow);
/// Moves a seeded commission all the way to readyForPickup.
Future<void> _bringToReady(FakeOrdersRepository repo, String id) async {
var stage = (await repo.getOrder(id))!.currentStage!;
const path = [
FulfillmentStatus.depositReceived,
FulfillmentStatus.inProduction,
FulfillmentStatus.qualityCheck,
FulfillmentStatus.readyForPickup,
];
for (final next in path) {
if (next.stageIndex <= stage.stageIndex) continue;
await repo.advanceFulfillmentStatus(id, next);
stage = next;
}
}
Order _plainOrder() => Order(
id: 'KC-C1',
customerName: 'A',
customerEmail: 'a@example.com',
orderDate: DateTime(2026, 4, 1),
status: OrderStatus.processing,
items: const [],
shippingAddress: '1 Way',
);
void main() {
// ── Result shape ──────────────────────────────────────────────────────────
group('CompleteCustomOrderResult', () {
test('success mirrors the order result', () {
final result = CompleteCustomOrderResult(
orderResult: const OrderActionResult.failure('nope'),
);
expect(result.success, isFalse);
});
test('hasDeductionFailures is false when there is no deduction result', () {
final result = CompleteCustomOrderResult(
orderResult: OrderActionResult.success(_plainOrder()),
);
expect(result.hasDeductionFailures, isFalse);
expect(result.warning, isNull);
});
test('hasDeductionFailures is false when the order itself failed', () {
final result = CompleteCustomOrderResult(
orderResult: const OrderActionResult.failure('nope'),
deductionResult: MaterialDeductionResult(failures: {'A': 'boom'}),
);
expect(result.hasDeductionFailures, isFalse);
});
test('toString reports the failure count', () {
final result = CompleteCustomOrderResult(
orderResult: const OrderActionResult.failure('nope'),
deductionResult: MaterialDeductionResult(failures: {'A': 'boom'}),
);
expect(result.toString(), contains('deductionFailures: 1'));
});
});
// ── AdvanceFulfillment ────────────────────────────────────────────────────
group('AdvanceFulfillment', () {
test('delegates a legal advance to the repository', () async {
final result = await AdvanceFulfillment(_repo())(
'KC-C2001',
FulfillmentStatus.depositReceived,
);
expect(result.success, isTrue);
});
test('forwards the note and timestamp', () async {
final when = DateTime(2026, 4, 15, 8);
final result = await AdvanceFulfillment(_repo())(
'KC-C2001',
FulfillmentStatus.depositReceived,
note: 'Bank transfer cleared.',
occurredAt: when,
);
final event = result.updatedOrder!.fulfillmentHistory.last;
expect(event.note, 'Bank transfer cleared.');
expect(event.occurredAt, when);
});
test('surfaces the domain rejection reason verbatim', () async {
final result = await AdvanceFulfillment(_repo())('KC-C2001', FulfillmentStatus.completed);
expect(result.success, isFalse);
expect(result.errorMessage, contains('Cannot move from quoted to completed'));
});
});
// ── Completion ────────────────────────────────────────────────────────────
group('CompleteCustomOrder', () {
test('completes an order that is ready for pickup', () async {
final result = await CompleteCustomOrder(_repo(), materialPort: _PartialPort())('KC-C2005');
expect(result.success, isTrue);
expect(result.orderResult.updatedOrder!.fulfillmentStatus, FulfillmentStatus.completed);
});
test('appends a completion event to the timeline', () async {
final result = await CompleteCustomOrder(_repo())('KC-C2005', note: 'Collected in person.');
final event = result.orderResult.updatedOrder!.fulfillmentHistory.last;
expect(event.status, FulfillmentStatus.completed);
expect(event.note, 'Collected in person.');
});
test('deducts the commission materials through the port', () async {
final port = _PartialPort();
final result = await CompleteCustomOrder(_repo(), materialPort: port)('KC-C2005');
expect(port.calls, ['KC-C2005']);
expect(result.deductionResult!.deducted, hasLength(2));
});
test('reports the deducted unit total', () async {
final result = await CompleteCustomOrder(_repo(), materialPort: _PartialPort())('KC-C2005');
// KC-C2005 consumes 12 birch ply rounds plus 3 favour boxes.
expect(result.deductionResult!.totalUnitsDeducted, 15);
});
test('does not deduct when the transition is rejected', () async {
final port = _PartialPort();
// KC-C2001 is only quoted; it cannot jump straight to completed.
final result = await CompleteCustomOrder(_repo(), materialPort: port)('KC-C2001');
expect(result.success, isFalse);
expect(port.calls, isEmpty);
expect(result.deductionResult, isNull);
});
test('completes without a port and reports an empty deduction', () async {
final result = await CompleteCustomOrder(_repo())('KC-C2005');
expect(result.success, isTrue);
expect(result.deductionResult!.success, isTrue);
expect(result.deductionResult!.deducted, isEmpty);
});
test('skips the port when the commission itemises no materials', () async {
final repo = _repo();
await repo.recordMaterialUsage('KC-C2005', const []);
final port = _PartialPort();
final result = await CompleteCustomOrder(repo, materialPort: port)('KC-C2005');
expect(result.success, isTrue);
expect(port.calls, isEmpty);
});
test('a deduction failure does not fail the completion', () async {
final port = _PartialPort({'SUP-PLY-BIRCH-4R'});
final result = await CompleteCustomOrder(_repo(), materialPort: port)('KC-C2005');
expect(result.success, isTrue);
expect(result.hasDeductionFailures, isTrue);
});
test('a deduction failure surfaces a warning naming the SKU', () async {
final port = _PartialPort({'SUP-PLY-BIRCH-4R'});
final result = await CompleteCustomOrder(_repo(), materialPort: port)('KC-C2005');
expect(result.warning, contains('SUP-PLY-BIRCH-4R'));
});
test('a fully successful deduction produces no warning', () async {
final result = await CompleteCustomOrder(_repo(), materialPort: _PartialPort())('KC-C2005');
expect(result.warning, isNull);
});
test('rejects an unknown order', () async {
final result = await CompleteCustomOrder(_repo())('KC-NOPE');
expect(result.success, isFalse);
expect(result.orderResult.errorMessage, contains('not found'));
});
test('rejects a standard order', () async {
final result = await CompleteCustomOrder(_repo())('KC-1001');
expect(result.success, isFalse);
});
test('completes a commission walked all the way up the pipeline', () async {
final repo = _repo();
await _bringToReady(repo, 'KC-C2001');
final result = await CompleteCustomOrder(repo, materialPort: _PartialPort())('KC-C2001');
expect(result.success, isTrue);
expect(result.orderResult.updatedOrder!.fulfillmentHistory, hasLength(6));
});
test('honours an explicit completion timestamp', () async {
final when = DateTime(2026, 4, 30, 16);
final result = await CompleteCustomOrder(_repo())('KC-C2005', occurredAt: when);
expect(result.orderResult.updatedOrder!.fulfillmentHistory.last.occurredAt, when);
});
test('never passes an invalid material record to the port', () async {
final repo = _repo();
await repo.recordMaterialUsage('KC-C2005', const [MaterialUsage(sku: 'SUP-OK', quantity: 2)]);
final port = _PartialPort();
final result = await CompleteCustomOrder(repo, materialPort: port)('KC-C2005');
expect(result.deductionResult!.deducted.every((u) => u.isValid), isTrue);
});
});
}

View File

@ -0,0 +1,487 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
/// A repository whose reads always throw, to exercise the error path.
class _FailingRepository extends FakeOrdersRepository {
@override
Future<List<Order>> getOrders() async => throw Exception('network error');
}
/// A port that fails for every SKU in [failFor].
class _PartialPort implements MaterialConsumptionPort {
final Set<String> failFor;
_PartialPort([this.failFor = const {}]);
@override
Future<MaterialDeductionResult> deductMaterials(
String orderId,
List<MaterialUsage> usages,
) async {
final deducted = usages.where((u) => !failFor.contains(u.sku)).toList();
final failures = {
for (final u in usages.where((u) => failFor.contains(u.sku))) u.sku: 'Insufficient stock.',
};
return MaterialDeductionResult(deducted: deducted, failures: failures);
}
}
DateTime _fixedNow() => DateTime(2026, 5, 1, 12);
CustomOrderTrackingController _controller({
OrdersRepository? repository,
MaterialConsumptionPort? port,
}) {
final repo = repository ?? FakeOrdersRepository(now: _fixedNow);
return CustomOrderTrackingController(
GetOrders(repo),
advanceFulfillment: AdvanceFulfillment(repo),
completeCustomOrder: CompleteCustomOrder(repo, materialPort: port),
);
}
void main() {
// ── Loading ───────────────────────────────────────────────────────────────
group('CustomOrderTrackingController — load', () {
test('starts empty and not loading', () {
final controller = _controller();
expect(controller.orders, isEmpty);
expect(controller.isLoading, isFalse);
});
test('loads only the custom orders', () async {
final controller = _controller();
await controller.load();
expect(controller.orders, isNotEmpty);
expect(controller.orders.every((o) => o.isCustom), isTrue);
});
test('loads all five seeded commissions', () async {
final controller = _controller();
await controller.load();
expect(controller.totalCustomOrders, 5);
});
test('auto-selects the first commission', () async {
final controller = _controller();
await controller.load();
expect(controller.selectedOrder, controller.orders.first);
});
test('clears isLoading when finished', () async {
final controller = _controller();
await controller.load();
expect(controller.isLoading, isFalse);
});
test('captures a repository failure', () async {
final controller = _controller(repository: _FailingRepository());
await controller.load();
expect(controller.error, isNotNull);
expect(controller.orders, isEmpty);
});
test('notifies listeners', () async {
final controller = _controller();
var notifications = 0;
controller.addListener(() => notifications++);
await controller.load();
expect(notifications, greaterThanOrEqualTo(2));
});
});
// ── Filtering ─────────────────────────────────────────────────────────────
group('CustomOrderTrackingController — stage filter', () {
test('filters to a single stage', () async {
final controller = _controller();
await controller.load();
controller.setStageFilter(FulfillmentStatus.inProduction);
expect(controller.orders, hasLength(1));
expect(controller.orders.single.currentStage, FulfillmentStatus.inProduction);
});
test('a null filter restores every commission', () async {
final controller = _controller();
await controller.load();
controller.setStageFilter(FulfillmentStatus.inProduction);
controller.setStageFilter(null);
expect(controller.orders, hasLength(5));
});
test('an unused stage yields an empty list', () async {
final controller = _controller();
await controller.load();
controller.setStageFilter(FulfillmentStatus.completed);
expect(controller.orders, isEmpty);
});
test('stageCounts covers every stage, including empty ones', () async {
final controller = _controller();
await controller.load();
expect(controller.stageCounts.keys, containsAll(FulfillmentStatus.values));
expect(controller.stageCounts[FulfillmentStatus.completed], 0);
});
test('stageCounts sums to the total', () async {
final controller = _controller();
await controller.load();
final sum = controller.stageCounts.values.fold(0, (a, b) => a + b);
expect(sum, controller.totalCustomOrders);
});
test('stageCounts ignores the active filter', () async {
final controller = _controller();
await controller.load();
controller.setStageFilter(FulfillmentStatus.inProduction);
expect(controller.stageCounts[FulfillmentStatus.quoted], 1);
});
test('activeCount excludes terminal commissions', () async {
final controller = _controller();
await controller.load();
expect(controller.activeCount, 5);
});
});
// ── Search ────────────────────────────────────────────────────────────────
group('CustomOrderTrackingController — search', () {
test('matches on the order ID', () async {
final controller = _controller();
await controller.load();
controller.setSearchQuery('KC-C2003');
expect(controller.orders.single.id, 'KC-C2003');
});
test('matches on the customer name, case-insensitively', () async {
final controller = _controller();
await controller.load();
controller.setSearchQuery('priya');
expect(controller.orders.single.customerName, contains('Priya'));
});
test('matches on the specification summary', () async {
final controller = _controller();
await controller.load();
controller.setSearchQuery('welsh slate');
expect(controller.orders.single.id, 'KC-C2002');
});
test('an empty query restores everything', () async {
final controller = _controller();
await controller.load();
controller.setSearchQuery('priya');
controller.setSearchQuery('');
expect(controller.orders, hasLength(5));
});
test('combines with the stage filter', () async {
final controller = _controller();
await controller.load();
controller.setStageFilter(FulfillmentStatus.quoted);
controller.setSearchQuery('priya');
expect(controller.orders, isEmpty);
});
test('a non-matching query yields an empty list', () async {
final controller = _controller();
await controller.load();
controller.setSearchQuery('zzzz-no-match');
expect(controller.orders, isEmpty);
});
});
// ── Selection ─────────────────────────────────────────────────────────────
group('CustomOrderTrackingController — selection', () {
test('selectOrder sets the selection', () async {
final controller = _controller();
await controller.load();
final target = controller.orders.last;
controller.selectOrder(target);
expect(controller.selectedOrder, target);
});
test('selectById finds a known commission', () async {
final controller = _controller();
await controller.load();
expect(controller.selectById('KC-C2004'), isTrue);
expect(controller.selectedOrder!.id, 'KC-C2004');
});
test('selectById returns false for an unknown ID', () async {
final controller = _controller();
await controller.load();
expect(controller.selectById('KC-NOPE'), isFalse);
});
test('selectById refuses a standard order, which has no pipeline', () async {
final controller = _controller();
await controller.load();
expect(controller.selectById('KC-1001'), isFalse);
});
test('the selection survives a stage filter that hides it', () async {
final controller = _controller();
await controller.load();
controller.selectById('KC-C2001');
controller.setStageFilter(FulfillmentStatus.inProduction);
expect(controller.selectedOrder!.id, 'KC-C2001');
});
test('allowedNextStages reflects the selected commission', () async {
final controller = _controller();
await controller.load();
controller.selectById('KC-C2003');
expect(controller.allowedNextStages, [
FulfillmentStatus.qualityCheck,
FulfillmentStatus.cancelled,
]);
});
test('allowedNextStages is empty with no selection', () {
expect(_controller().allowedNextStages, isEmpty);
});
});
// ── Advancing ─────────────────────────────────────────────────────────────
group('CustomOrderTrackingController — advanceTo', () {
test('advances a commission to the next stage', () async {
final controller = _controller();
await controller.load();
await controller.advanceTo('KC-C2001', FulfillmentStatus.depositReceived);
expect(controller.lastActionResult!.success, isTrue);
expect(controller.selectedOrder!.currentStage, FulfillmentStatus.depositReceived);
});
test('records the operator note on the timeline', () async {
final controller = _controller();
await controller.load();
await controller.advanceTo(
'KC-C2001',
FulfillmentStatus.depositReceived,
note: 'Card payment taken.',
);
expect(controller.selectedOrder!.fulfillmentHistory.last.note, 'Card payment taken.');
});
test('reloads so the list reflects the new stage', () async {
final controller = _controller();
await controller.load();
await controller.advanceTo('KC-C2001', FulfillmentStatus.depositReceived);
expect(controller.stageCounts[FulfillmentStatus.depositReceived], 2);
expect(controller.stageCounts[FulfillmentStatus.quoted], 0);
});
test('surfaces the domain rejection reason for an illegal jump', () async {
final controller = _controller();
await controller.load();
await controller.advanceTo('KC-C2001', FulfillmentStatus.readyForPickup);
expect(controller.lastActionResult!.success, isFalse);
expect(
controller.lastActionResult!.errorMessage,
contains('Cannot move from quoted to ready for pickup'),
);
});
test('leaves the stage unchanged after an illegal jump', () async {
final controller = _controller();
await controller.load();
controller.selectById('KC-C2001');
await controller.advanceTo('KC-C2001', FulfillmentStatus.readyForPickup);
expect(controller.selectedOrder!.currentStage, FulfillmentStatus.quoted);
});
test('clears isUpdating when finished', () async {
final controller = _controller();
await controller.load();
await controller.advanceTo('KC-C2001', FulfillmentStatus.depositReceived);
expect(controller.isUpdating, isFalse);
});
test('supports the rework path back to inProduction', () async {
final controller = _controller();
await controller.load();
await controller.advanceTo(
'KC-C2004',
FulfillmentStatus.inProduction,
note: 'Failed inspection.',
);
expect(controller.lastActionResult!.success, isTrue);
});
test('supports cancelling a commission', () async {
final controller = _controller();
await controller.load();
await controller.advanceTo('KC-C2001', FulfillmentStatus.cancelled);
expect(controller.lastActionResult!.success, isTrue);
expect(controller.activeCount, 4);
});
test('does nothing when no advance use case is wired', () async {
final repo = FakeOrdersRepository(now: _fixedNow);
final controller = CustomOrderTrackingController(GetOrders(repo));
await controller.load();
await controller.advanceTo('KC-C2001', FulfillmentStatus.depositReceived);
expect(controller.lastActionResult, isNull);
});
test('consumeActionResult returns then clears the result', () async {
final controller = _controller();
await controller.load();
await controller.advanceTo('KC-C2001', FulfillmentStatus.depositReceived);
expect(controller.consumeActionResult(), isNotNull);
expect(controller.lastActionResult, isNull);
});
});
// ── Completion and material deduction ─────────────────────────────────────
group('CustomOrderTrackingController — completion', () {
test('completing routes through CompleteCustomOrder', () async {
final controller = _controller(port: _PartialPort());
await controller.load();
await controller.advanceTo('KC-C2005', FulfillmentStatus.completed);
expect(controller.lastActionResult!.success, isTrue);
expect(controller.stageCounts[FulfillmentStatus.completed], 1);
});
test('a clean deduction leaves no warning', () async {
final controller = _controller(port: _PartialPort());
await controller.load();
await controller.advanceTo('KC-C2005', FulfillmentStatus.completed);
expect(controller.lastDeductionWarning, isNull);
});
test('a failed deduction sets a warning but still completes', () async {
final controller = _controller(port: _PartialPort({'SUP-PLY-BIRCH-4R'}));
await controller.load();
await controller.advanceTo('KC-C2005', FulfillmentStatus.completed);
expect(controller.lastActionResult!.success, isTrue);
expect(controller.lastDeductionWarning, contains('SUP-PLY-BIRCH-4R'));
});
test('the deduction warning names no customer or money detail', () async {
final controller = _controller(port: _PartialPort({'SUP-PLY-BIRCH-4R'}));
await controller.load();
await controller.advanceTo('KC-C2005', FulfillmentStatus.completed);
expect(controller.lastDeductionWarning, isNot(contains(r'$')));
expect(controller.lastDeductionWarning, isNot(contains('Hannah')));
});
test('consumeDeductionWarning returns then clears the warning', () async {
final controller = _controller(port: _PartialPort({'SUP-PLY-BIRCH-4R'}));
await controller.load();
await controller.advanceTo('KC-C2005', FulfillmentStatus.completed);
expect(controller.consumeDeductionWarning(), isNotNull);
expect(controller.lastDeductionWarning, isNull);
});
test('an illegal completion does not deduct and reports the rejection', () async {
final controller = _controller(port: _PartialPort());
await controller.load();
await controller.advanceTo('KC-C2001', FulfillmentStatus.completed);
expect(controller.lastActionResult!.success, isFalse);
expect(controller.lastDeductionWarning, isNull);
});
test('does nothing when no completion use case is wired', () async {
final repo = FakeOrdersRepository(now: _fixedNow);
final controller = CustomOrderTrackingController(
GetOrders(repo),
advanceFulfillment: AdvanceFulfillment(repo),
);
await controller.load();
await controller.advanceTo('KC-C2005', FulfillmentStatus.completed);
expect(controller.lastActionResult, isNull);
});
test('a completed commission is no longer active', () async {
final controller = _controller(port: _PartialPort());
await controller.load();
await controller.advanceTo('KC-C2005', FulfillmentStatus.completed);
expect(controller.activeCount, 4);
});
});
}

View File

@ -0,0 +1,341 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
/// A fixed clock so every stamped event is deterministic.
DateTime _fixedNow() => DateTime(2026, 5, 1, 12);
FakeOrdersRepository _repo() => FakeOrdersRepository(now: _fixedNow);
void main() {
// ── Seeded data ───────────────────────────────────────────────────────────
group('FakeOrdersRepository — seeded tracking data', () {
test('every seeded custom order carries a fulfillment history', () async {
final orders = await _repo().getOrders();
final custom = orders.where((o) => o.isCustom);
expect(custom, isNotEmpty);
for (final order in custom) {
expect(order.fulfillmentHistory, isNotEmpty, reason: '${order.id} has no history');
}
});
test('standard orders carry no fulfillment history', () async {
final orders = await _repo().getOrders();
for (final order in orders.where((o) => !o.isCustom)) {
expect(order.fulfillmentHistory, isEmpty);
}
});
test('every seeded custom order itemises its materials', () async {
final orders = await _repo().getOrders();
for (final order in orders.where((o) => o.isCustom)) {
expect(order.materialUsage, isNotEmpty, reason: '${order.id} itemises no materials');
}
});
test('each history starts with an intake event', () async {
final orders = await _repo().getOrders();
for (final order in orders.where((o) => o.isCustom)) {
expect(order.fulfillmentHistory.first.isIntake, isTrue, reason: order.id);
}
});
test('each history ends at the stamped current stage', () async {
final orders = await _repo().getOrders();
for (final order in orders.where((o) => o.isCustom)) {
expect(
order.fulfillmentHistory.last.status,
order.currentStage,
reason: '${order.id} history disagrees with its stage',
);
}
});
test('histories are in chronological order', () async {
final orders = await _repo().getOrders();
for (final order in orders.where((o) => o.isCustom)) {
final times = order.fulfillmentHistory.map((e) => e.occurredAt).toList();
final sorted = [...times]..sort();
expect(times, sorted, reason: '${order.id} history is out of order');
}
});
test('KC-C2004 records a rework loop', () async {
final order = await _repo().getOrder('KC-C2004');
expect(order!.fulfillmentHistory.any((e) => e.isRework), isTrue);
});
test('every transition in every seeded history is legal', () async {
final orders = await _repo().getOrders();
for (final order in orders.where((o) => o.isCustom)) {
for (final event in order.fulfillmentHistory.where((e) => !e.isIntake)) {
expect(
event.from.canTransitionTo(event.status),
isTrue,
reason: '${order.id}: ${event.description} is not a legal transition',
);
}
}
});
});
// ── advanceFulfillmentStatus ──────────────────────────────────────────────
group('FakeOrdersRepository — advanceFulfillmentStatus records history', () {
test('appends an event on a legal advance', () async {
final repo = _repo();
final before = (await repo.getOrder('KC-C2001'))!;
final result = await repo.advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.depositReceived,
);
expect(result.success, isTrue);
expect(result.updatedOrder!.fulfillmentHistory.length, before.fulfillmentHistory.length + 1);
});
test('the appended event names the stage moved from and to', () async {
final result = await _repo().advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.depositReceived,
);
final event = result.updatedOrder!.fulfillmentHistory.last;
expect(event.from, FulfillmentStatus.quoted);
expect(event.status, FulfillmentStatus.depositReceived);
});
test('stamps the event with the injected clock when no time is supplied', () async {
final result = await _repo().advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.depositReceived,
);
expect(result.updatedOrder!.fulfillmentHistory.last.occurredAt, _fixedNow());
});
test('honours an explicit occurredAt', () async {
final when = DateTime(2026, 4, 20, 9, 30);
final result = await _repo().advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.depositReceived,
occurredAt: when,
);
expect(result.updatedOrder!.fulfillmentHistory.last.occurredAt, when);
});
test('records the operator note', () async {
final result = await _repo().advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.depositReceived,
note: 'Cash deposit at the stall.',
);
expect(result.updatedOrder!.fulfillmentHistory.last.note, 'Cash deposit at the stall.');
});
test('trims a note before recording it', () async {
final result = await _repo().advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.depositReceived,
note: ' spaced ',
);
expect(result.updatedOrder!.fulfillmentHistory.last.note, 'spaced');
});
test('stores a whitespace-only note as null', () async {
final result = await _repo().advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.depositReceived,
note: ' ',
);
expect(result.updatedOrder!.fulfillmentHistory.last.note, isNull);
});
test('leaves the history untouched when the transition is illegal', () async {
final repo = _repo();
final before = (await repo.getOrder('KC-C2001'))!;
final result = await repo.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
expect(result.success, isFalse);
final after = (await repo.getOrder('KC-C2001'))!;
expect(after.fulfillmentHistory, before.fulfillmentHistory);
});
test('the history survives a second advance', () async {
final repo = _repo();
await repo.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.depositReceived);
final result = await repo.advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.inProduction,
);
final history = result.updatedOrder!.fulfillmentHistory;
expect(history.length, 3);
expect(history[1].status, FulfillmentStatus.depositReceived);
expect(history[2].status, FulfillmentStatus.inProduction);
});
test('records a rework transition as rework', () async {
final result = await _repo().advanceFulfillmentStatus(
'KC-C2004',
FulfillmentStatus.inProduction,
note: 'Failed inspection.',
);
expect(result.success, isTrue);
expect(result.updatedOrder!.fulfillmentHistory.last.isRework, isTrue);
});
test('rejects a standard order without recording anything', () async {
final result = await _repo().advanceFulfillmentStatus(
'KC-1001',
FulfillmentStatus.depositReceived,
);
expect(result.success, isFalse);
expect(result.errorMessage, contains('not a custom order'));
});
});
// ── createCustomOrder ─────────────────────────────────────────────────────
group('FakeOrdersRepository — createCustomOrder seeds a timeline', () {
Order newOrder() => Order(
id: 'KC-C9999',
customerName: 'New Customer',
customerEmail: 'new@example.com',
orderDate: DateTime(2026, 4, 30),
status: OrderStatus.pending,
items: const [],
shippingAddress: '1 New Way',
);
test('records an intake event', () async {
final result = await _repo().createCustomOrder(
newOrder(),
spec: CustomOrderSpec(material: 'Cotton', color: 'Sage'),
);
expect(result.updatedOrder!.fulfillmentHistory, hasLength(1));
expect(result.updatedOrder!.fulfillmentHistory.single.isIntake, isTrue);
});
test('stamps the intake event with the order date', () async {
final result = await _repo().createCustomOrder(
newOrder(),
spec: CustomOrderSpec(material: 'Cotton', color: 'Sage'),
);
expect(result.updatedOrder!.fulfillmentHistory.single.occurredAt, DateTime(2026, 4, 30));
});
test('the new commission starts with no material usage', () async {
final result = await _repo().createCustomOrder(
newOrder(),
spec: CustomOrderSpec(material: 'Cotton', color: 'Sage'),
);
expect(result.updatedOrder!.materialUsage, isEmpty);
});
});
// ── recordMaterialUsage ───────────────────────────────────────────────────
group('FakeOrdersRepository — recordMaterialUsage', () {
const usages = [
MaterialUsage(sku: 'SUP-YRN-NEW', quantity: 3, displayName: 'New yarn'),
MaterialUsage(sku: 'SUP-BTN-NEW', quantity: 8),
];
test('replaces the recorded materials', () async {
final result = await _repo().recordMaterialUsage('KC-C2001', usages);
expect(result.success, isTrue);
expect(result.updatedOrder!.materialUsage, usages);
});
test('persists across a subsequent read', () async {
final repo = _repo();
await repo.recordMaterialUsage('KC-C2001', usages);
expect((await repo.getOrder('KC-C2001'))!.materialUsage, usages);
});
test('accepts an empty list, clearing the record', () async {
final result = await _repo().recordMaterialUsage('KC-C2001', const []);
expect(result.success, isTrue);
expect(result.updatedOrder!.materialUsage, isEmpty);
});
test('rejects a blank SKU', () async {
final result = await _repo().recordMaterialUsage('KC-C2001', const [
MaterialUsage(sku: ' ', quantity: 3),
]);
expect(result.success, isFalse);
expect(result.errorMessage, contains('quantity greater than zero'));
});
test('rejects a non-positive quantity', () async {
final result = await _repo().recordMaterialUsage('KC-C2001', const [
MaterialUsage(sku: 'SUP-YRN-NEW', quantity: 0),
]);
expect(result.success, isFalse);
});
test('rejects the whole batch when any record is invalid', () async {
final repo = _repo();
final before = (await repo.getOrder('KC-C2001'))!.materialUsage;
final result = await repo.recordMaterialUsage('KC-C2001', const [
MaterialUsage(sku: 'SUP-YRN-NEW', quantity: 3),
MaterialUsage(sku: 'SUP-BAD', quantity: -1),
]);
expect(result.success, isFalse);
expect((await repo.getOrder('KC-C2001'))!.materialUsage, before);
});
test('rejects an unknown order', () async {
final result = await _repo().recordMaterialUsage('KC-NOPE', usages);
expect(result.success, isFalse);
expect(result.errorMessage, contains('not found'));
});
test('rejects a standard order', () async {
final result = await _repo().recordMaterialUsage('KC-1001', usages);
expect(result.success, isFalse);
expect(result.errorMessage, contains('not a custom order'));
});
test('does not disturb the fulfillment history', () async {
final repo = _repo();
final before = (await repo.getOrder('KC-C2001'))!.fulfillmentHistory;
await repo.recordMaterialUsage('KC-C2001', usages);
expect((await repo.getOrder('KC-C2001'))!.fulfillmentHistory, before);
});
});
}

View File

@ -0,0 +1,297 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// ── Construction ──────────────────────────────────────────────────────────
group('FulfillmentEvent — construction', () {
test('holds the supplied from, status, timestamp, and note', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5, 14, 20),
note: 'Deposit taken at the stall.',
);
expect(event.from, FulfillmentStatus.quoted);
expect(event.status, FulfillmentStatus.depositReceived);
expect(event.occurredAt, DateTime(2026, 4, 5, 14, 20));
expect(event.note, 'Deposit taken at the stall.');
});
test('note defaults to null', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5),
);
expect(event.note, isNull);
expect(event.hasNote, isFalse);
});
test('intake constructor sets from and status to quoted', () {
final event = FulfillmentEvent.intake(occurredAt: DateTime(2026, 4, 6));
expect(event.from, FulfillmentStatus.quoted);
expect(event.status, FulfillmentStatus.quoted);
expect(event.isIntake, isTrue);
});
test('intake constructor accepts a note', () {
final event = FulfillmentEvent.intake(
occurredAt: DateTime(2026, 4, 6),
note: 'Captured at the Saturday market.',
);
expect(event.note, 'Captured at the Saturday market.');
});
});
// ── Derived state ─────────────────────────────────────────────────────────
group('FulfillmentEvent — derived state', () {
test('isIntake is false for a real transition', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5),
);
expect(event.isIntake, isFalse);
});
test('isRework is true for qualityCheck back to inProduction', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.qualityCheck,
status: FulfillmentStatus.inProduction,
occurredAt: DateTime(2026, 4, 2),
);
expect(event.isRework, isTrue);
});
test('isRework is false for forward progress', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.inProduction,
status: FulfillmentStatus.qualityCheck,
occurredAt: DateTime(2026, 4, 1),
);
expect(event.isRework, isFalse);
});
test('isRework is false for an intake event', () {
expect(FulfillmentEvent.intake(occurredAt: DateTime(2026, 4, 6)).isRework, isFalse);
});
test('isRework is false for cancellation, which is not a pipeline step', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.inProduction,
status: FulfillmentStatus.cancelled,
occurredAt: DateTime(2026, 4, 3),
);
expect(event.isRework, isFalse);
});
test('hasNote is false for a whitespace-only note', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5),
note: ' ',
);
expect(event.hasNote, isFalse);
});
test('description names both stages for a transition', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.inProduction,
status: FulfillmentStatus.qualityCheck,
occurredAt: DateTime(2026, 4, 1),
);
expect(event.description, 'In Production → Quality Check');
});
test('description for intake names only the entry stage', () {
expect(FulfillmentEvent.intake(occurredAt: DateTime(2026, 4, 6)).description, 'Order quoted');
});
test('description carries no customer or money detail', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.readyForPickup,
status: FulfillmentStatus.completed,
occurredAt: DateTime(2026, 4, 9),
);
expect(event.description, isNot(contains(r'$')));
expect(event.description, 'Ready for Pickup → Completed');
});
});
// ── Wire format ───────────────────────────────────────────────────────────
group('FulfillmentEvent — toWire', () {
test('packs from, status, timestamp, and note in order', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5, 14, 20),
note: 'Deposit taken.',
);
final parts = event.toWire().split('|');
expect(parts[0], 'quoted');
expect(parts[1], 'depositReceived');
expect(DateTime.parse(parts[2]), DateTime(2026, 4, 5, 14, 20));
expect(parts[3], 'Deposit taken.');
});
test('emits an empty note field when there is no note', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5),
);
expect(event.toWire().split('|').last, isEmpty);
});
test('strips separators from the note so a record cannot be corrupted', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5),
note: 'a|b',
);
expect(event.toWire().split('|').length, 4);
expect(event.toWire().split('|')[3], 'a b');
});
test('strips newlines from the note so the history block stays intact', () {
final event = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5),
note: 'line one\nline two',
);
expect(event.toWire(), isNot(contains('\n')));
expect(event.toWire().split('|')[3], 'line one line two');
});
});
group('FulfillmentEvent — fromWire', () {
test('round trips an event with a note', () {
final original = FulfillmentEvent(
from: FulfillmentStatus.inProduction,
status: FulfillmentStatus.qualityCheck,
occurredAt: DateTime(2026, 4, 1, 17),
note: 'Ready for inspection.',
);
expect(FulfillmentEvent.fromWire(original.toWire()), original);
});
test('round trips an event without a note', () {
final original = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5, 9, 15),
);
expect(FulfillmentEvent.fromWire(original.toWire()), original);
});
test('returns null for a null line', () {
expect(FulfillmentEvent.fromWire(null), isNull);
});
test('returns null for an empty line', () {
expect(FulfillmentEvent.fromWire(' '), isNull);
});
test('returns null when there are too few fields', () {
expect(FulfillmentEvent.fromWire('quoted|depositReceived'), isNull);
});
test('returns null when the timestamp is unparseable', () {
expect(FulfillmentEvent.fromWire('quoted|depositReceived|not-a-date|note'), isNull);
});
test('falls back to quoted for an unknown stage name', () {
final event = FulfillmentEvent.fromWire('bogus|alsoBogus|2026-04-05T00:00:00.000|');
expect(event, isNotNull);
expect(event!.from, FulfillmentStatus.quoted);
expect(event.status, FulfillmentStatus.quoted);
});
test('rejoins a note that contains extra separators', () {
final event = FulfillmentEvent.fromWire(
'quoted|depositReceived|2026-04-05T00:00:00.000|a|b|c',
);
expect(event!.note, 'a|b|c');
});
});
// ── copyWith and equality ─────────────────────────────────────────────────
group('FulfillmentEvent — copyWith and equality', () {
final base = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5),
note: 'original',
);
test('copyWith replaces the status', () {
expect(
base.copyWith(status: FulfillmentStatus.cancelled).status,
FulfillmentStatus.cancelled,
);
});
test('copyWith preserves unspecified fields', () {
final copy = base.copyWith(status: FulfillmentStatus.cancelled);
expect(copy.from, base.from);
expect(copy.occurredAt, base.occurredAt);
expect(copy.note, base.note);
});
test('copyWith cannot clear the note', () {
expect(base.copyWith().note, 'original');
});
test('equal events compare equal and share a hash code', () {
final other = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 5),
note: 'original',
);
expect(base, other);
expect(base.hashCode, other.hashCode);
});
test('differing notes are not equal', () {
expect(base, isNot(base.copyWith(note: 'different')));
});
test('differing timestamps are not equal', () {
expect(base, isNot(base.copyWith(occurredAt: DateTime(2026, 4, 6))));
});
test('toString names the transition and the timestamp', () {
expect(base.toString(), contains('Quoted → Deposit Received'));
expect(base.toString(), contains('2026-04-05'));
});
});
}

View File

@ -0,0 +1,170 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
/// A port that fails for every SKU in [failFor] and succeeds for the rest.
class _PartialPort implements MaterialConsumptionPort {
final Set<String> failFor;
_PartialPort(this.failFor);
@override
Future<MaterialDeductionResult> deductMaterials(
String orderId,
List<MaterialUsage> usages,
) async {
final deducted = usages.where((u) => !failFor.contains(u.sku)).toList();
final failures = {
for (final u in usages.where((u) => failFor.contains(u.sku))) u.sku: 'Insufficient stock.',
};
return MaterialDeductionResult(deducted: deducted, failures: failures);
}
}
void main() {
const yarn = MaterialUsage(sku: 'SUP-YRN-01', quantity: 4, displayName: 'Cotton yarn');
const slate = MaterialUsage(sku: 'SUP-SLT-16', quantity: 1, displayName: 'Slate blank');
// ── MaterialDeductionResult ───────────────────────────────────────────────
group('MaterialDeductionResult', () {
test('empty reports success with nothing deducted', () {
final result = MaterialDeductionResult.empty();
expect(result.success, isTrue);
expect(result.deducted, isEmpty);
expect(result.failures, isEmpty);
});
test('empty has no summary, because nothing was requested', () {
expect(MaterialDeductionResult.empty().summary, isNull);
});
test('succeeds when every usage deducted', () {
final result = MaterialDeductionResult(deducted: [yarn, slate]);
expect(result.success, isTrue);
expect(result.isPartial, isFalse);
});
test('fails when any SKU failed', () {
final result = MaterialDeductionResult(failures: {'SUP-YRN-01': 'Insufficient stock.'});
expect(result.success, isFalse);
});
test('is partial when some succeeded and some failed', () {
final result = MaterialDeductionResult(
deducted: [slate],
failures: {'SUP-YRN-01': 'Insufficient stock.'},
);
expect(result.isPartial, isTrue);
});
test('totalUnitsDeducted sums the deducted quantities', () {
expect(MaterialDeductionResult(deducted: [yarn, slate]).totalUnitsDeducted, 5);
});
test('success summary reports units and material count', () {
final summary = MaterialDeductionResult(deducted: [yarn, slate]).summary;
expect(summary, contains('5 units'));
expect(summary, contains('2 materials'));
});
test('success summary singularises a single unit and material', () {
final summary = MaterialDeductionResult(deducted: [slate]).summary;
expect(summary, contains('1 unit '));
expect(summary, contains('1 material'));
});
test('total-failure summary names the failed SKUs', () {
final summary = MaterialDeductionResult(
failures: {'SUP-YRN-01': 'Insufficient stock.'},
).summary;
expect(summary, contains('SUP-YRN-01'));
expect(summary, contains('failed'));
});
test('partial summary reports both sides', () {
final summary = MaterialDeductionResult(
deducted: [slate],
failures: {'SUP-YRN-01': 'Insufficient stock.'},
).summary;
expect(summary, contains('Deducted 1 material'));
expect(summary, contains('SUP-YRN-01'));
});
test('summary carries no customer or money detail', () {
final summary = MaterialDeductionResult(deducted: [yarn, slate]).summary!;
expect(summary, isNot(contains(r'$')));
});
test('deducted list is unmodifiable', () {
final result = MaterialDeductionResult(deducted: [yarn]);
expect(() => result.deducted.add(slate), throwsUnsupportedError);
});
test('failures map is unmodifiable', () {
final result = MaterialDeductionResult(failures: {'A': 'boom'});
expect(() => result.failures['B'] = 'boom', throwsUnsupportedError);
});
test('toString reports both counts', () {
final result = MaterialDeductionResult(deducted: [yarn], failures: {'B': 'boom'});
expect(result.toString(), contains('deducted: 1'));
expect(result.toString(), contains('failures: 1'));
});
});
// ── NoopMaterialConsumptionPort ───────────────────────────────────────────
group('NoopMaterialConsumptionPort', () {
test('records the request it was given', () async {
final port = NoopMaterialConsumptionPort();
await port.deductMaterials('KC-C2001', [yarn, slate]);
expect(port.requests, hasLength(1));
expect(port.requests.single.orderId, 'KC-C2001');
expect(port.requests.single.usages, [yarn, slate]);
});
test('reports success so an unwired inventory never blocks completion', () async {
final result = await NoopMaterialConsumptionPort().deductMaterials('KC-C2001', [yarn]);
expect(result.success, isTrue);
expect(result.deducted, isEmpty);
});
test('accumulates across calls', () async {
final port = NoopMaterialConsumptionPort();
await port.deductMaterials('KC-C2001', [yarn]);
await port.deductMaterials('KC-C2002', [slate]);
expect(port.requests, hasLength(2));
});
});
// ── A failing port ────────────────────────────────────────────────────────
group('MaterialConsumptionPort — partial failure', () {
test('reports per-SKU failures without discarding the successes', () async {
final port = _PartialPort({'SUP-YRN-01'});
final result = await port.deductMaterials('KC-C2001', [yarn, slate]);
expect(result.deducted, [slate]);
expect(result.failures.keys, ['SUP-YRN-01']);
expect(result.isPartial, isTrue);
});
});
}

View File

@ -0,0 +1,131 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// ── MaterialUsage ─────────────────────────────────────────────────────────
group('MaterialUsage — construction and validity', () {
test('holds the supplied SKU, quantity, and display name', () {
const usage = MaterialUsage(sku: 'SUP-YRN-01', quantity: 4, displayName: 'Cotton yarn');
expect(usage.sku, 'SUP-YRN-01');
expect(usage.quantity, 4);
expect(usage.displayName, 'Cotton yarn');
});
test('displayName defaults to null', () {
const usage = MaterialUsage(sku: 'SUP-YRN-01', quantity: 4);
expect(usage.displayName, isNull);
});
test('is valid with a SKU and a positive quantity', () {
expect(const MaterialUsage(sku: 'SUP-YRN-01', quantity: 1).isValid, isTrue);
});
test('is invalid with a blank SKU', () {
expect(const MaterialUsage(sku: ' ', quantity: 4).isValid, isFalse);
});
test('is invalid with a zero quantity', () {
expect(const MaterialUsage(sku: 'SUP-YRN-01', quantity: 0).isValid, isFalse);
});
test('is invalid with a negative quantity', () {
expect(const MaterialUsage(sku: 'SUP-YRN-01', quantity: -2).isValid, isFalse);
});
test('label prefers the display name', () {
const usage = MaterialUsage(sku: 'SUP-YRN-01', quantity: 4, displayName: 'Cotton yarn');
expect(usage.label, 'Cotton yarn');
});
test('label falls back to the SKU when no display name is set', () {
expect(const MaterialUsage(sku: 'SUP-YRN-01', quantity: 4).label, 'SUP-YRN-01');
});
test('label falls back to the SKU for a blank display name', () {
const usage = MaterialUsage(sku: 'SUP-YRN-01', quantity: 4, displayName: ' ');
expect(usage.label, 'SUP-YRN-01');
});
});
group('MaterialUsage — wire format', () {
test('packs SKU, quantity, and display name in order', () {
const usage = MaterialUsage(sku: 'SUP-YRN-01', quantity: 4, displayName: 'Cotton yarn');
expect(usage.toWire().split('|'), ['SUP-YRN-01', '4', 'Cotton yarn']);
});
test('round trips through fromWire', () {
const original = MaterialUsage(sku: 'SUP-SLT-16', quantity: 1, displayName: 'Slate blank');
expect(MaterialUsage.fromWire(original.toWire()), original);
});
test('round trips a record with no display name', () {
const original = MaterialUsage(sku: 'SUP-SLT-16', quantity: 1);
expect(MaterialUsage.fromWire(original.toWire()), original);
});
test('strips separators from text fields', () {
const usage = MaterialUsage(sku: 'A|B', quantity: 2, displayName: 'c|d');
expect(usage.toWire().split('|').length, 3);
});
test('returns null for a null line', () {
expect(MaterialUsage.fromWire(null), isNull);
});
test('returns null for an empty line', () {
expect(MaterialUsage.fromWire(' '), isNull);
});
test('returns null when the quantity is unparseable', () {
expect(MaterialUsage.fromWire('SUP-YRN-01|many|Cotton'), isNull);
});
test('returns null when the SKU is blank', () {
expect(MaterialUsage.fromWire(' |4|Cotton'), isNull);
});
test('returns null when there are too few fields', () {
expect(MaterialUsage.fromWire('SUP-YRN-01'), isNull);
});
});
group('MaterialUsage — copyWith and equality', () {
const base = MaterialUsage(sku: 'SUP-YRN-01', quantity: 4, displayName: 'Cotton yarn');
test('copyWith replaces the quantity', () {
expect(base.copyWith(quantity: 7).quantity, 7);
});
test('copyWith preserves unspecified fields', () {
final copy = base.copyWith(quantity: 7);
expect(copy.sku, base.sku);
expect(copy.displayName, base.displayName);
});
test('equal records compare equal and share a hash code', () {
const other = MaterialUsage(sku: 'SUP-YRN-01', quantity: 4, displayName: 'Cotton yarn');
expect(base, other);
expect(base.hashCode, other.hashCode);
});
test('differing quantities are not equal', () {
expect(base, isNot(base.copyWith(quantity: 5)));
});
test('toString names the SKU and quantity', () {
expect(base.toString(), contains('SUP-YRN-01'));
expect(base.toString(), contains('4'));
});
});
}

View File

@ -0,0 +1,262 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
Order _custom({
FulfillmentStatus? stage,
List<FulfillmentEvent> history = const [],
List<MaterialUsage> materials = const [],
}) {
return Order(
id: 'KC-C9001',
customerName: 'Test Customer',
customerEmail: 'test@example.com',
orderDate: DateTime(2026, 4, 1),
status: OrderStatus.processing,
items: const [],
shippingAddress: '1 Test Way',
orderType: OrderType.custom,
fulfillmentStatus: stage,
fulfillmentHistory: history,
materialUsage: materials,
);
}
Order _standard() => Order(
id: 'KC-1001',
customerName: 'Test Customer',
customerEmail: 'test@example.com',
orderDate: DateTime(2026, 4, 1),
status: OrderStatus.processing,
items: const [],
shippingAddress: '1 Test Way',
);
void main() {
// ── Defaults ──────────────────────────────────────────────────────────────
group('Order — tracking field defaults', () {
test('fulfillmentHistory defaults to empty', () {
expect(_standard().fulfillmentHistory, isEmpty);
});
test('materialUsage defaults to empty', () {
expect(_standard().materialUsage, isEmpty);
});
test('a pre-tracking custom order still has no history', () {
expect(_custom(stage: FulfillmentStatus.inProduction).fulfillmentHistory, isEmpty);
});
});
// ── currentStage ──────────────────────────────────────────────────────────
group('Order — currentStage', () {
test('is null for a standard order', () {
expect(_standard().currentStage, isNull);
});
test('returns the stamped stage for a custom order', () {
expect(
_custom(stage: FulfillmentStatus.inProduction).currentStage,
FulfillmentStatus.inProduction,
);
});
test('defaults to quoted for a custom order with no stage stamped', () {
expect(_custom().currentStage, FulfillmentStatus.quoted);
});
});
// ── History accessors ─────────────────────────────────────────────────────
group('Order — history accessors', () {
final events = [
FulfillmentEvent.intake(occurredAt: DateTime(2026, 4, 1)),
FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 2),
),
];
test('hasFulfillmentHistory is false when empty', () {
expect(_custom().hasFulfillmentHistory, isFalse);
});
test('hasFulfillmentHistory is true when events exist', () {
expect(_custom(history: events).hasFulfillmentHistory, isTrue);
});
test('latestFulfillmentEvent is the last event', () {
expect(_custom(history: events).latestFulfillmentEvent, events.last);
});
test('latestFulfillmentEvent is null with no history', () {
expect(_custom().latestFulfillmentEvent, isNull);
});
test('lastFulfillmentUpdate is the latest timestamp', () {
expect(_custom(history: events).lastFulfillmentUpdate, DateTime(2026, 4, 2));
});
test('lastFulfillmentUpdate is null with no history', () {
expect(_custom().lastFulfillmentUpdate, isNull);
});
});
// ── Stage progression helpers ─────────────────────────────────────────────
group('Order — stage progression helpers', () {
test('allowedNextStages is empty for a standard order', () {
expect(_standard().allowedNextStages, isEmpty);
});
test('allowedNextStages mirrors the domain transition rules', () {
expect(_custom(stage: FulfillmentStatus.inProduction).allowedNextStages, [
FulfillmentStatus.qualityCheck,
FulfillmentStatus.cancelled,
]);
});
test('allowedNextStages is empty at a terminal stage', () {
expect(_custom(stage: FulfillmentStatus.completed).allowedNextStages, isEmpty);
});
test('isFulfillmentComplete is true when completed', () {
expect(_custom(stage: FulfillmentStatus.completed).isFulfillmentComplete, isTrue);
});
test('isFulfillmentComplete is true when cancelled', () {
expect(_custom(stage: FulfillmentStatus.cancelled).isFulfillmentComplete, isTrue);
});
test('isFulfillmentComplete is false mid-pipeline', () {
expect(_custom(stage: FulfillmentStatus.qualityCheck).isFulfillmentComplete, isFalse);
});
test('isFulfillmentComplete is false for a standard order', () {
expect(_standard().isFulfillmentComplete, isFalse);
});
});
// ── Material usage ────────────────────────────────────────────────────────
group('Order — material usage', () {
const valid = MaterialUsage(sku: 'SUP-YRN-01', quantity: 4);
const blankSku = MaterialUsage(sku: ' ', quantity: 4);
const zeroQty = MaterialUsage(sku: 'SUP-SLT-16', quantity: 0);
test('hasMaterialUsage is false when nothing is itemised', () {
expect(_custom().hasMaterialUsage, isFalse);
});
test('hasMaterialUsage is true when materials exist', () {
expect(_custom(materials: const [valid]).hasMaterialUsage, isTrue);
});
test('deductibleMaterials excludes a blank SKU', () {
expect(_custom(materials: const [valid, blankSku]).deductibleMaterials, [valid]);
});
test('deductibleMaterials excludes a non-positive quantity', () {
expect(_custom(materials: const [valid, zeroQty]).deductibleMaterials, [valid]);
});
test('deductibleMaterials is empty when every record is invalid', () {
expect(_custom(materials: const [blankSku, zeroQty]).deductibleMaterials, isEmpty);
});
});
// ── withFulfillmentEvent ──────────────────────────────────────────────────
group('Order — withFulfillmentEvent', () {
final advance = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 2),
note: 'Deposit taken.',
);
test('moves the order to the event status', () {
final order = _custom(stage: FulfillmentStatus.quoted).withFulfillmentEvent(advance);
expect(order.fulfillmentStatus, FulfillmentStatus.depositReceived);
});
test('appends the event to the history', () {
final order = _custom(stage: FulfillmentStatus.quoted).withFulfillmentEvent(advance);
expect(order.fulfillmentHistory, [advance]);
});
test('preserves existing history rather than replacing it', () {
final intake = FulfillmentEvent.intake(occurredAt: DateTime(2026, 4, 1));
final order = _custom(
stage: FulfillmentStatus.quoted,
history: [intake],
).withFulfillmentEvent(advance);
expect(order.fulfillmentHistory, [intake, advance]);
});
test('does not mutate the source order', () {
final original = _custom(stage: FulfillmentStatus.quoted);
original.withFulfillmentEvent(advance);
expect(original.fulfillmentHistory, isEmpty);
expect(original.fulfillmentStatus, FulfillmentStatus.quoted);
});
test('does not enforce transition legality — that is the repository job', () {
final illegal = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.completed,
occurredAt: DateTime(2026, 4, 3),
);
final order = _custom(stage: FulfillmentStatus.quoted).withFulfillmentEvent(illegal);
expect(order.fulfillmentStatus, FulfillmentStatus.completed);
});
test('preserves unrelated fields', () {
final order = _custom(
stage: FulfillmentStatus.quoted,
materials: const [MaterialUsage(sku: 'SUP-YRN-01', quantity: 4)],
).withFulfillmentEvent(advance);
expect(order.id, 'KC-C9001');
expect(order.materialUsage, hasLength(1));
});
});
// ── copyWith ──────────────────────────────────────────────────────────────
group('Order — copyWith with tracking fields', () {
test('replaces fulfillmentHistory', () {
final events = [FulfillmentEvent.intake(occurredAt: DateTime(2026, 4, 1))];
expect(_custom().copyWith(fulfillmentHistory: events).fulfillmentHistory, events);
});
test('replaces materialUsage', () {
const materials = [MaterialUsage(sku: 'SUP-YRN-01', quantity: 4)];
expect(_custom().copyWith(materialUsage: materials).materialUsage, materials);
});
test('preserves tracking fields when not specified', () {
final events = [FulfillmentEvent.intake(occurredAt: DateTime(2026, 4, 1))];
final order = _custom(
history: events,
materials: const [MaterialUsage(sku: 'SUP-YRN-01', quantity: 4)],
);
final copy = order.copyWith(customerName: 'Renamed');
expect(copy.fulfillmentHistory, events);
expect(copy.materialUsage, hasLength(1));
});
});
}

View File

@ -0,0 +1,222 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// Captures whatever [showAdvanceStageDialog] returns.
///
/// A mutable box is used rather than a return value because the dialog only
/// resolves once the operator dismisses it, which happens after the helper
/// has already returned.
class _Capture {
AdvanceStageResult? result;
bool closed = false;
}
Future<_Capture> _open(
WidgetTester tester, {
required FulfillmentStatus current,
required List<FulfillmentStatus> allowedNext,
FulfillmentStatus? initialSelection,
}) async {
final capture = _Capture();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () async {
capture.result = await showAdvanceStageDialog(
context,
current: current,
allowedNext: allowedNext,
initialSelection: initialSelection,
);
capture.closed = true;
},
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
return capture;
}
const _midPipeline = [FulfillmentStatus.qualityCheck, FulfillmentStatus.cancelled];
void main() {
group('showAdvanceStageDialog — rendering', () {
testWidgets('shows the current stage as a chip', (tester) async {
await _open(tester, current: FulfillmentStatus.inProduction, allowedNext: _midPipeline);
expect(find.byType(FulfillmentStatusChip), findsOneWidget);
expect(find.text('In Production'), findsOneWidget);
});
testWidgets('offers a dropdown of the legal next stages', (tester) async {
await _open(tester, current: FulfillmentStatus.inProduction, allowedNext: _midPipeline);
expect(find.byType(DropdownButtonFormField<FulfillmentStatus>), findsOneWidget);
});
testWidgets('offers an optional progress note field', (tester) async {
await _open(tester, current: FulfillmentStatus.inProduction, allowedNext: _midPipeline);
expect(find.text('Progress note (optional)'), findsOneWidget);
});
testWidgets('warns the operator not to put customer detail in the note', (tester) async {
await _open(tester, current: FulfillmentStatus.inProduction, allowedNext: _midPipeline);
expect(find.textContaining('no customer or payment detail'), findsOneWidget);
});
testWidgets('explains that a terminal stage cannot move', (tester) async {
await _open(tester, current: FulfillmentStatus.completed, allowedNext: const []);
expect(find.textContaining('cannot be moved any further'), findsOneWidget);
});
testWidgets('hides the Update action when nothing is allowed', (tester) async {
await _open(tester, current: FulfillmentStatus.completed, allowedNext: const []);
expect(find.text('Update'), findsNothing);
expect(find.text('Cancel'), findsOneWidget);
});
testWidgets('offers no note field at a terminal stage', (tester) async {
await _open(tester, current: FulfillmentStatus.cancelled, allowedNext: const []);
expect(find.byType(TextField), findsNothing);
});
});
group('showAdvanceStageDialog — result', () {
testWidgets('defaults to the first allowed stage', (tester) async {
final capture = await _open(
tester,
current: FulfillmentStatus.inProduction,
allowedNext: _midPipeline,
);
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
expect(capture.result!.next, FulfillmentStatus.qualityCheck);
});
testWidgets('honours a valid initialSelection', (tester) async {
final capture = await _open(
tester,
current: FulfillmentStatus.inProduction,
allowedNext: _midPipeline,
initialSelection: FulfillmentStatus.cancelled,
);
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
expect(capture.result!.next, FulfillmentStatus.cancelled);
});
testWidgets('ignores an initialSelection that is not allowed', (tester) async {
final capture = await _open(
tester,
current: FulfillmentStatus.inProduction,
allowedNext: _midPipeline,
initialSelection: FulfillmentStatus.completed,
);
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
expect(capture.result!.next, FulfillmentStatus.qualityCheck);
});
testWidgets('returns no note when the field is left empty', (tester) async {
final capture = await _open(
tester,
current: FulfillmentStatus.inProduction,
allowedNext: _midPipeline,
);
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
expect(capture.result!.note, isNull);
});
testWidgets('returns the typed note, trimmed', (tester) async {
final capture = await _open(
tester,
current: FulfillmentStatus.inProduction,
allowedNext: _midPipeline,
);
await tester.enterText(find.byType(TextField), ' Glaze fired. ');
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
expect(capture.result!.note, 'Glaze fired.');
});
testWidgets('treats a whitespace-only note as no note', (tester) async {
final capture = await _open(
tester,
current: FulfillmentStatus.inProduction,
allowedNext: _midPipeline,
);
await tester.enterText(find.byType(TextField), ' ');
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
expect(capture.result!.note, isNull);
});
testWidgets('returns null on Cancel', (tester) async {
final capture = await _open(
tester,
current: FulfillmentStatus.inProduction,
allowedNext: _midPipeline,
);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(capture.closed, isTrue);
expect(capture.result, isNull);
});
testWidgets('returns null on Cancel from a terminal stage', (tester) async {
final capture = await _open(
tester,
current: FulfillmentStatus.completed,
allowedNext: const [],
);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(capture.result, isNull);
});
});
group('AdvanceStageResult', () {
test('holds the stage and the note', () {
const result = AdvanceStageResult(next: FulfillmentStatus.completed, note: 'Collected.');
expect(result.next, FulfillmentStatus.completed);
expect(result.note, 'Collected.');
});
test('note defaults to null', () {
expect(const AdvanceStageResult(next: FulfillmentStatus.completed).note, isNull);
});
});
}

View File

@ -0,0 +1,255 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Order _order({
FulfillmentStatus stage = FulfillmentStatus.inProduction,
List<MaterialUsage> materials = const [],
DepositInfo? deposit,
List<FulfillmentEvent> history = const [],
}) {
return Order(
id: 'KC-C2003',
customerName: 'Priya Raman',
customerEmail: 'priya@example.com',
orderDate: DateTime(2026, 3, 30),
status: OrderStatus.processing,
shippingAddress: '88 Laurel Ct',
orderType: OrderType.custom,
fulfillmentStatus: stage,
items: const [
OrderItem(productName: 'Custom Nightlight', sku: 'CUSTOM-NL', quantity: 2, unitPrice: 42.5),
],
customOrderSpec: CustomOrderSpec(
material: 'Cast resin',
color: 'Dusk lavender',
dimensions: '5" x 5" x 7"',
notes: 'Warm-white LED.',
estimatedCompletionDate: DateTime(2026, 4, 24),
),
depositInfo: deposit,
fulfillmentHistory: history,
materialUsage: materials,
);
}
Future<void> _pump(
WidgetTester tester,
Order order, {
bool isUpdating = false,
VoidCallback? onAdvance,
}) async {
tester.view.physicalSize = const Size(1000, 1600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CustomOrderTrackingDetail(
order: order,
isUpdating: isUpdating,
onAdvance: onAdvance ?? () {},
),
),
),
);
// An indeterminate LinearProgressIndicator animates forever, so
// pumpAndSettle would time out while a write is in flight.
if (isUpdating) {
await tester.pump();
} else {
await tester.pumpAndSettle();
}
}
void main() {
group('CustomOrderTrackingDetail — header', () {
testWidgets('shows the order ID and customer', (tester) async {
await _pump(tester, _order());
expect(find.text('KC-C2003'), findsOneWidget);
expect(find.text('Priya Raman'), findsOneWidget);
});
testWidgets('shows the current stage chip', (tester) async {
await _pump(tester, _order());
expect(find.byType(FulfillmentStatusChip), findsOneWidget);
expect(find.text('In Production'), findsOneWidget);
});
});
group('CustomOrderTrackingDetail — stage action', () {
testWidgets('offers the advance action mid-pipeline', (tester) async {
await _pump(tester, _order());
expect(find.text('Update Stage'), findsOneWidget);
});
testWidgets('invokes the callback when tapped', (tester) async {
var tapped = false;
await _pump(tester, _order(), onAdvance: () => tapped = true);
await tester.tap(find.text('Update Stage'));
await tester.pump();
expect(tapped, isTrue);
});
testWidgets('hides the action at a terminal stage', (tester) async {
await _pump(tester, _order(stage: FulfillmentStatus.completed));
expect(find.text('Update Stage'), findsNothing);
expect(find.textContaining('no further stage changes'), findsOneWidget);
});
testWidgets('hides the action when cancelled', (tester) async {
await _pump(tester, _order(stage: FulfillmentStatus.cancelled));
expect(find.text('Update Stage'), findsNothing);
});
testWidgets('shows a progress bar while updating', (tester) async {
await _pump(tester, _order(), isUpdating: true);
expect(find.byType(LinearProgressIndicator), findsOneWidget);
expect(find.text('Update Stage'), findsNothing);
});
});
group('CustomOrderTrackingDetail — specification', () {
testWidgets('renders the spec fields', (tester) async {
await _pump(tester, _order());
expect(find.text('Cast resin'), findsOneWidget);
expect(find.text('Dusk lavender'), findsOneWidget);
expect(find.text('5" x 5" x 7"'), findsOneWidget);
expect(find.text('Warm-white LED.'), findsOneWidget);
});
testWidgets('renders the estimated completion date', (tester) async {
await _pump(tester, _order());
expect(find.text('2026-04-24'), findsOneWidget);
});
testWidgets('renders the line item name', (tester) async {
await _pump(tester, _order());
expect(find.text('Custom Nightlight'), findsOneWidget);
});
});
group('CustomOrderTrackingDetail — deposit and balance', () {
final deposit = DepositInfo(
depositAmountCents: 3000,
totalAmountCents: 8500,
depositPaid: true,
depositDate: DateTime(2026, 3, 30),
);
testWidgets('renders the totals and balance', (tester) async {
await _pump(tester, _order(deposit: deposit));
expect(find.text(r'$85.00'), findsOneWidget);
expect(find.text(r'$30.00'), findsOneWidget);
expect(find.text(r'$55.00'), findsOneWidget);
});
testWidgets('labels a paid deposit', (tester) async {
await _pump(tester, _order(deposit: deposit));
expect(find.text('Deposit Paid'), findsOneWidget);
});
testWidgets('labels an unpaid deposit as due', (tester) async {
await _pump(
tester,
_order(deposit: const DepositInfo.quoted(totalAmountCents: 8500, depositAmountCents: 3000)),
);
expect(find.text('Deposit Due'), findsOneWidget);
});
testWidgets('omits the money section when there is no deposit record', (tester) async {
await _pump(tester, _order());
expect(find.text('Deposit & Balance'), findsNothing);
});
testWidgets('renders no payment or transaction identifier', (tester) async {
await _pump(tester, _order(deposit: deposit));
expect(find.textContaining('Transaction'), findsNothing);
expect(find.textContaining('transaction'), findsNothing);
});
});
group('CustomOrderTrackingDetail — materials', () {
const materials = [
MaterialUsage(sku: 'SUP-RSN-CAST-1L', quantity: 2, displayName: 'Cast resin 1L'),
MaterialUsage(sku: 'SUP-LED-WARM', quantity: 2),
];
testWidgets('lists each material with its quantity and SKU', (tester) async {
await _pump(tester, _order(materials: materials));
expect(find.text('Cast resin 1L'), findsOneWidget);
expect(find.text('2 × SUP-RSN-CAST-1L'), findsOneWidget);
});
testWidgets('falls back to the SKU as the label', (tester) async {
await _pump(tester, _order(materials: materials));
expect(find.text('SUP-LED-WARM'), findsOneWidget);
});
testWidgets('explains when deduction happens', (tester) async {
await _pump(tester, _order(materials: materials));
expect(find.textContaining('Deducted from stock'), findsOneWidget);
});
testWidgets('says so when nothing is itemised', (tester) async {
await _pump(tester, _order());
expect(find.text('No materials itemised for this commission.'), findsOneWidget);
});
});
group('CustomOrderTrackingDetail — timeline', () {
testWidgets('renders the timeline section', (tester) async {
await _pump(tester, _order());
expect(find.text('Fulfillment Timeline'), findsOneWidget);
expect(find.byType(FulfillmentTimeline), findsOneWidget);
});
testWidgets('renders each recorded event', (tester) async {
await _pump(
tester,
_order(
history: [
FulfillmentEvent.intake(occurredAt: DateTime(2026, 3, 30)),
FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 3, 30, 11, 5),
),
],
),
);
expect(find.text('Order quoted'), findsOneWidget);
expect(find.text('Quoted → Deposit Received'), findsOneWidget);
});
testWidgets('shows the timeline empty state with no history', (tester) async {
await _pump(tester, _order());
expect(find.text('No status changes recorded yet.'), findsOneWidget);
});
});
}

View File

@ -0,0 +1,164 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
DateTime _fixedNow() => DateTime(2026, 5, 1, 12);
/// Mounts the tracking page at a size wide enough for the master-detail layout.
Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
Future<void> _pumpPage(
WidgetTester tester, {
VoidCallback? onCreateCustomOrder,
FulfillmentStatus? initialStage,
MaterialConsumptionPort? port,
Size size = const Size(1400, 1000),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
_wrap(
CustomOrderTrackingPage(
repository: FakeOrdersRepository(now: _fixedNow),
materialPort: port,
onCreateCustomOrder: onCreateCustomOrder,
initialStage: initialStage,
),
),
);
await tester.pumpAndSettle();
}
void main() {
// ── Loading and listing ───────────────────────────────────────────────────
group('CustomOrderTrackingPage — listing', () {
testWidgets('shows a loading state before data arrives', (tester) async {
await tester.pumpWidget(
_wrap(CustomOrderTrackingPage(repository: FakeOrdersRepository(now: _fixedNow))),
);
await tester.pump();
expect(find.text('Loading custom orders…'), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('lists the seeded custom orders once loaded', (tester) async {
await _pumpPage(tester);
expect(find.byType(CustomOrderTrackingCard), findsNWidgets(5));
});
testWidgets('does not list standard orders', (tester) async {
await _pumpPage(tester);
expect(find.text('KC-1001'), findsNothing);
});
testWidgets('reports the visible and active counts', (tester) async {
await _pumpPage(tester);
expect(find.text('5 shown · 5 active'), findsOneWidget);
});
});
// ── Stage filtering ───────────────────────────────────────────────────────
group('CustomOrderTrackingPage — stage filters', () {
testWidgets('renders a chip per stage plus All', (tester) async {
await _pumpPage(tester);
expect(find.byType(FilterChip), findsNWidgets(FulfillmentStatus.values.length + 1));
});
testWidgets('chips carry live counts', (tester) async {
await _pumpPage(tester);
expect(find.text('All (5)'), findsOneWidget);
expect(find.text('Quoted (1)'), findsOneWidget);
expect(find.text('Completed (0)'), findsOneWidget);
});
testWidgets('tapping a stage chip filters the list', (tester) async {
await _pumpPage(tester);
await tester.tap(find.text('In Production (1)'));
await tester.pumpAndSettle();
expect(find.byType(CustomOrderTrackingCard), findsOneWidget);
});
testWidgets('an empty stage shows the empty state', (tester) async {
await _pumpPage(tester);
// The chip row scrolls horizontally, so the later stages may sit
// outside the viewport at this width.
await tester.scrollUntilVisible(
find.text('Completed (0)'),
200,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('Completed (0)'));
await tester.pumpAndSettle();
expect(find.text('No custom orders match this stage or search.'), findsOneWidget);
});
testWidgets('initialStage applies the filter on first load', (tester) async {
await _pumpPage(tester, initialStage: FulfillmentStatus.qualityCheck);
expect(find.byType(CustomOrderTrackingCard), findsOneWidget);
});
});
// ── Search ────────────────────────────────────────────────────────────────
group('CustomOrderTrackingPage — search', () {
testWidgets('filters the list as the operator types', (tester) async {
await _pumpPage(tester);
await tester.enterText(find.byType(TextField).first, 'KC-C2003');
await tester.pumpAndSettle();
expect(find.byType(CustomOrderTrackingCard), findsOneWidget);
});
testWidgets('a non-matching query shows the empty state', (tester) async {
await _pumpPage(tester);
await tester.enterText(find.byType(TextField).first, 'zzzz');
await tester.pumpAndSettle();
expect(find.text('No custom orders match this stage or search.'), findsOneWidget);
});
});
// ── Intake action ─────────────────────────────────────────────────────────
group('CustomOrderTrackingPage — intake action', () {
testWidgets('omits the action when no callback is supplied', (tester) async {
await _pumpPage(tester);
expect(find.text('New Custom Order'), findsNothing);
});
testWidgets('renders the action when a callback is supplied', (tester) async {
await _pumpPage(tester, onCreateCustomOrder: () {});
expect(find.text('New Custom Order'), findsOneWidget);
});
testWidgets('tapping the action invokes the callback', (tester) async {
var tapped = false;
await _pumpPage(tester, onCreateCustomOrder: () => tapped = true);
await tester.tap(find.text('New Custom Order'));
await tester.pump();
expect(tapped, isTrue);
});
});
}

View File

@ -0,0 +1,126 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _wrap(Widget child) => MaterialApp(
home: Scaffold(body: SingleChildScrollView(child: child)),
);
final _intake = FulfillmentEvent.intake(occurredAt: DateTime(2026, 4, 1, 9));
final _deposit = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 2, 14, 5),
note: 'Card payment taken.',
);
final _rework = FulfillmentEvent(
from: FulfillmentStatus.qualityCheck,
status: FulfillmentStatus.inProduction,
occurredAt: DateTime(2026, 4, 5, 10),
note: 'Seam short; reworking.',
);
final _completed = FulfillmentEvent(
from: FulfillmentStatus.readyForPickup,
status: FulfillmentStatus.completed,
occurredAt: DateTime(2026, 4, 9, 16, 30),
);
void main() {
group('FulfillmentTimeline — empty state', () {
testWidgets('renders an empty state with no events', (tester) async {
await tester.pumpWidget(_wrap(const FulfillmentTimeline(events: [])));
expect(find.text('No status changes recorded yet.'), findsOneWidget);
});
});
group('FulfillmentTimeline — rendering', () {
testWidgets('renders one row per event', (tester) async {
await tester.pumpWidget(_wrap(FulfillmentTimeline(events: [_intake, _deposit])));
expect(find.text('Order quoted'), findsOneWidget);
expect(find.text('Quoted → Deposit Received'), findsOneWidget);
});
testWidgets('renders the timestamp for each event', (tester) async {
await tester.pumpWidget(_wrap(FulfillmentTimeline(events: [_deposit])));
expect(find.text('2026-04-02 14:05'), findsOneWidget);
});
testWidgets('renders the operator note when present', (tester) async {
await tester.pumpWidget(_wrap(FulfillmentTimeline(events: [_deposit])));
expect(find.text('Card payment taken.'), findsOneWidget);
});
testWidgets('renders no note text for an event without one', (tester) async {
await tester.pumpWidget(_wrap(FulfillmentTimeline(events: [_intake])));
expect(find.text('Card payment taken.'), findsNothing);
});
testWidgets('marks a rework transition', (tester) async {
await tester.pumpWidget(_wrap(FulfillmentTimeline(events: [_rework])));
expect(find.text('Rework'), findsOneWidget);
});
testWidgets('does not mark forward progress as rework', (tester) async {
await tester.pumpWidget(_wrap(FulfillmentTimeline(events: [_deposit])));
expect(find.text('Rework'), findsNothing);
});
testWidgets('renders a completion event', (tester) async {
await tester.pumpWidget(_wrap(FulfillmentTimeline(events: [_completed])));
expect(find.text('Ready for Pickup → Completed'), findsOneWidget);
});
testWidgets('renders a full history in order, oldest first by default', (tester) async {
await tester.pumpWidget(
_wrap(FulfillmentTimeline(events: [_intake, _deposit, _rework, _completed])),
);
final first = tester.getTopLeft(find.text('Order quoted')).dy;
final last = tester.getTopLeft(find.text('Ready for Pickup → Completed')).dy;
expect(first, lessThan(last));
});
testWidgets('newestFirst reverses the order', (tester) async {
await tester.pumpWidget(
_wrap(FulfillmentTimeline(events: [_intake, _completed], newestFirst: true)),
);
final intakeY = tester.getTopLeft(find.text('Order quoted')).dy;
final completedY = tester.getTopLeft(find.text('Ready for Pickup → Completed')).dy;
expect(completedY, lessThan(intakeY));
});
});
group('FulfillmentTimeline — privacy', () {
testWidgets('renders no money amount', (tester) async {
await tester.pumpWidget(_wrap(FulfillmentTimeline(events: [_intake, _deposit, _completed])));
expect(find.textContaining(r'$'), findsNothing);
});
testWidgets('renders no payment or transaction identifier', (tester) async {
await tester.pumpWidget(_wrap(FulfillmentTimeline(events: [_deposit])));
expect(find.textContaining('transaction', findRichText: true), findsNothing);
expect(find.textContaining('Transaction', findRichText: true), findsNothing);
});
});
group('FulfillmentTimeline — formatTimestamp', () {
test('pads every component to two digits', () {
expect(FulfillmentTimeline.formatTimestamp(DateTime(2026, 1, 2, 3, 4)), '2026-01-02 03:04');
});
test('renders midnight as 00:00', () {
expect(FulfillmentTimeline.formatTimestamp(DateTime(2026, 4, 1)), '2026-04-01 00:00');
});
});
}

View File

@ -101,7 +101,15 @@ class _EmptyOrdersRepository implements OrdersRepository {
}) async => const OrderActionResult.failure('Not implemented.'); }) async => const OrderActionResult.failure('Not implemented.');
@override @override
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) async => Future<OrderActionResult> advanceFulfillmentStatus(
String id,
FulfillmentStatus next, {
String? note,
DateTime? occurredAt,
}) async => const OrderActionResult.failure('Not implemented.');
@override
Future<OrderActionResult> recordMaterialUsage(String id, List<MaterialUsage> usages) async =>
const OrderActionResult.failure('Not implemented.'); const OrderActionResult.failure('Not implemented.');
} }

View File

@ -0,0 +1,264 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
const _mapper = WooCommerceOrderMapper();
Order _custom({
List<FulfillmentEvent> history = const [],
List<MaterialUsage> materials = const [],
}) {
return Order(
id: '5001',
customerName: 'Test Customer',
customerEmail: 'test@example.com',
orderDate: DateTime(2026, 4, 1),
status: OrderStatus.processing,
items: const [],
shippingAddress: '1 Test Way',
orderType: OrderType.custom,
fulfillmentStatus: FulfillmentStatus.inProduction,
customOrderSpec: CustomOrderSpec(material: 'Cotton', color: 'Sage'),
fulfillmentHistory: history,
materialUsage: materials,
);
}
/// Builds a WooCommerce order JSON payload carrying [meta].
Map<String, dynamic> _json(Map<String, String> meta) => {
'id': 5001,
'status': 'processing',
'date_created': '2026-04-01T00:00:00',
'billing': {'first_name': 'Test', 'last_name': 'Customer', 'email': 'test@example.com'},
'shipping': <String, dynamic>{},
'line_items': <dynamic>[],
'meta_data': [
for (final entry in meta.entries) {'key': entry.key, 'value': entry.value},
],
};
/// Flattens [order]'s custom order meta into a `key → value` map.
Map<String, String> _metaOf(Order order) => {
for (final entry in _mapper.customOrderMetaData(order))
entry['key'] as String: entry['value'] as String,
};
final _intake = FulfillmentEvent.intake(occurredAt: DateTime(2026, 4, 1));
final _advance = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 2, 11),
note: 'Deposit taken.',
);
const _yarn = MaterialUsage(sku: 'SUP-YRN-01', quantity: 4, displayName: 'Cotton yarn');
void main() {
// ── Meta keys ─────────────────────────────────────────────────────────────
group('WooCommerceOrderMapper — tracking meta keys', () {
test('history key is namespaced and internal', () {
expect(WooCommerceOrderMapper.metaFulfillmentHistory, '_kc_fulfillment_history');
});
test('material usage key is namespaced and internal', () {
expect(WooCommerceOrderMapper.metaMaterialUsage, '_kc_material_usage');
});
test('both keys start with an underscore so they stay hidden from customers', () {
expect(WooCommerceOrderMapper.metaFulfillmentHistory.startsWith('_'), isTrue);
expect(WooCommerceOrderMapper.metaMaterialUsage.startsWith('_'), isTrue);
});
});
// ── Write side ────────────────────────────────────────────────────────────
group('WooCommerceOrderMapper — trackingMetaEntries', () {
test('packs the history newline-separated, one event per line', () {
final entries = _mapper.trackingMetaEntries(_custom(history: [_intake, _advance]));
expect(entries[WooCommerceOrderMapper.metaFulfillmentHistory]!.split('\n'), hasLength(2));
});
test('packs material usage newline-separated', () {
final entries = _mapper.trackingMetaEntries(
_custom(
materials: const [
_yarn,
MaterialUsage(sku: 'SUP-BTN', quantity: 8),
],
),
);
expect(entries[WooCommerceOrderMapper.metaMaterialUsage]!.split('\n'), hasLength(2));
});
test('omits the history key entirely when there is none', () {
expect(
_mapper
.trackingMetaEntries(_custom())
.containsKey(WooCommerceOrderMapper.metaFulfillmentHistory),
isFalse,
);
});
test('omits the material usage key entirely when there is none', () {
expect(
_mapper
.trackingMetaEntries(_custom())
.containsKey(WooCommerceOrderMapper.metaMaterialUsage),
isFalse,
);
});
test('customOrderMetaData includes the tracking entries', () {
final keys = _metaOf(_custom(history: [_intake], materials: const [_yarn])).keys;
expect(keys, contains(WooCommerceOrderMapper.metaFulfillmentHistory));
expect(keys, contains(WooCommerceOrderMapper.metaMaterialUsage));
});
test('tracking meta never carries a payment identifier', () {
final serialised = _metaOf(
_custom(history: [_intake, _advance]),
).entries.map((e) => '${e.key}=${e.value}').join(';').toLowerCase();
expect(serialised, isNot(contains('transaction')));
expect(serialised, isNot(contains('payment_id')));
});
});
// ── Read side ─────────────────────────────────────────────────────────────
group('WooCommerceOrderMapper — reading tracking meta', () {
test('rebuilds the history from packed meta', () {
final order = _mapper.fromJson(
_json({
WooCommerceOrderMapper.metaOrderType: 'custom',
WooCommerceOrderMapper.metaFulfillmentHistory: [
_intake.toWire(),
_advance.toWire(),
].join('\n'),
}),
);
expect(order.fulfillmentHistory, [_intake, _advance]);
});
test('rebuilds material usage from packed meta', () {
final order = _mapper.fromJson(
_json({
WooCommerceOrderMapper.metaOrderType: 'custom',
WooCommerceOrderMapper.metaMaterialUsage: _yarn.toWire(),
}),
);
expect(order.materialUsage, [_yarn]);
});
test('a standard order gets no history even if the meta is present', () {
final order = _mapper.fromJson(
_json({WooCommerceOrderMapper.metaFulfillmentHistory: _intake.toWire()}),
);
expect(order.fulfillmentHistory, isEmpty);
});
test('a custom order with no tracking meta gets empty lists', () {
final order = _mapper.fromJson(_json({WooCommerceOrderMapper.metaOrderType: 'custom'}));
expect(order.fulfillmentHistory, isEmpty);
expect(order.materialUsage, isEmpty);
});
test('skips a malformed history line rather than discarding the timeline', () {
final order = _mapper.fromJson(
_json({
WooCommerceOrderMapper.metaOrderType: 'custom',
WooCommerceOrderMapper.metaFulfillmentHistory: [
_intake.toWire(),
'garbage',
_advance.toWire(),
].join('\n'),
}),
);
expect(order.fulfillmentHistory, [_intake, _advance]);
});
test('skips a malformed material usage line', () {
final order = _mapper.fromJson(
_json({
WooCommerceOrderMapper.metaOrderType: 'custom',
WooCommerceOrderMapper.metaMaterialUsage: ['garbage', _yarn.toWire()].join('\n'),
}),
);
expect(order.materialUsage, [_yarn]);
});
test('tolerates blank lines in the packed block', () {
final order = _mapper.fromJson(
_json({
WooCommerceOrderMapper.metaOrderType: 'custom',
WooCommerceOrderMapper.metaFulfillmentHistory: '${_intake.toWire()}\n\n',
}),
);
expect(order.fulfillmentHistory, [_intake]);
});
test('historyFromMeta returns empty for a null value', () {
expect(WooCommerceOrderMapper.historyFromMeta(null), isEmpty);
});
test('materialUsageFromMeta returns empty for an empty value', () {
expect(WooCommerceOrderMapper.materialUsageFromMeta(''), isEmpty);
});
});
// ── Round trip ────────────────────────────────────────────────────────────
group('WooCommerceOrderMapper — tracking round trip', () {
test('history survives write then read', () {
final source = _custom(history: [_intake, _advance]);
expect(
_mapper.fromJson(_json(_metaOf(source))).fulfillmentHistory,
source.fulfillmentHistory,
);
});
test('material usage survives write then read', () {
final source = _custom(
materials: const [
_yarn,
MaterialUsage(sku: 'SUP-BTN', quantity: 8),
],
);
expect(_mapper.fromJson(_json(_metaOf(source))).materialUsage, source.materialUsage);
});
test('a note containing separators survives the round trip sanitised', () {
final risky = FulfillmentEvent(
from: FulfillmentStatus.quoted,
status: FulfillmentStatus.depositReceived,
occurredAt: DateTime(2026, 4, 2),
note: 'a|b\nc',
);
final restored = _mapper
.fromJson(_json(_metaOf(_custom(history: [risky]))))
.fulfillmentHistory;
expect(restored, hasLength(1));
expect(restored.single.note, 'a b c');
});
test('an empty history does not blank an existing remote value', () {
expect(
_metaOf(_custom()).containsKey(WooCommerceOrderMapper.metaFulfillmentHistory),
isFalse,
);
});
});
}