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:
commit
07e7e083c0
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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".
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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' : ''})';
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue