From 197cc661693f58f0c596d281c324998b7a2a1b00 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Fri, 25 Sep 2026 16:54:51 -0400 Subject: [PATCH] feat(stage-11c): custom order tracking and fulfillment Adds the workshop-side counterpart to Stage 11B's intake form: a tracking workspace filtered by fulfillment stage, an auditable timeline of stage changes, and inventory deduction on completion. Closes audit finding P2.2; Stage 11 is complete. Domain: FulfillmentEvent (append-only transition record with operator notes and a malformed-line-tolerant wire format), MaterialUsage, MaterialConsumptionPort + MaterialDeductionResult. Order gains fulfillmentHistory, materialUsage, derived stage accessors, and withFulfillmentEvent(). All existing fields, defaults, and copyWith semantics preserved. Data: advanceFulfillmentStatus() records an event with an optional note and injectable clock; recordMaterialUsage() added. WooCommerce round-trips both through _kc_ meta keys, skipping malformed lines and never blanking remote values. Application/presentation: AdvanceFulfillment, CompleteCustomOrder, CustomOrderTrackingController, CustomOrderTrackingPage, FulfillmentTimeline, showAdvanceStageDialog. Illegal transitions surface the domain rejection reason verbatim. Architecture: inventory deduction crosses the feature boundary through an outbound port, not a package dependency. feature_orders still has no dependency on feature_inventory; each app supplies InventoryMaterialConsumptionAdapter. Completion advances first and deducts second, so a rejected transition can never decrement stock, and a deduction failure warns rather than failing the commission. Tests: 329 new (293 feature_orders, 22 kell_mobile, 14 kell_web). 1085/1085 feature_orders, 109/109 kell_mobile, 81/81 kell_web. dart analyze --fatal-infos clean. --- PROJECT_STATE_AUDIT.md | 23 +- docs/development/build_execution_tracker.md | 80 ++- docs/development/master_development_brief.md | 139 ++++- ...nventory_material_consumption_adapter.dart | 104 ++++ .../lib/composition/mobile_app_services.dart | 25 +- .../mobile_custom_order_tracking_page.dart | 50 ++ .../lib/pages/mobile_orders_page.dart | 51 +- .../kell_mobile/lib/shell/mobile_shell.dart | 1 + ...ory_material_consumption_adapter_test.dart | 151 ++++++ ...obile_custom_order_tracking_page_test.dart | 100 ++++ .../lib/composition/app_services.dart | 25 +- ...nventory_material_consumption_adapter.dart | 98 ++++ .../lib/navigation/app_navigation.dart | 16 + .../apps/kell_web/lib/routing/app_routes.dart | 31 ++ .../dashboard_controller_test.dart | 10 +- .../custom_order_tracking_route_test.dart | 157 ++++++ .../packages/feature_orders/README.md | 65 ++- .../feature_orders/lib/feature_orders.dart | 11 + .../src/application/advance_fulfillment.dart | 25 + .../application/complete_custom_order.dart | 92 ++++ .../custom_order_tracking_controller.dart | 259 ++++++++++ .../lib/src/data/fake_orders_repository.dart | 169 +++++- .../src/data/woo_commerce_order_mapper.dart | 60 +++ .../data/woo_commerce_orders_repository.dart | 50 +- .../lib/src/domain/fulfillment_event.dart | 155 ++++++ .../src/domain/material_consumption_port.dart | 96 ++++ .../lib/src/domain/material_usage.dart | 90 ++++ .../feature_orders/lib/src/domain/order.dart | 75 +++ .../lib/src/domain/orders_repository.dart | 32 +- .../custom_order_tracking_page.dart | 401 ++++++++++++++ .../lib/src/presentation/orders_page.dart | 36 +- .../widgets/advance_stage_dialog.dart | 152 ++++++ .../widgets/custom_order_tracking_card.dart | 119 +++++ .../widgets/custom_order_tracking_detail.dart | 232 +++++++++ .../widgets/fulfillment_timeline.dart | 130 +++++ .../test/complete_custom_order_test.dart | 258 ++++++++++ ...custom_order_tracking_controller_test.dart | 487 ++++++++++++++++++ .../fake_orders_repository_tracking_test.dart | 341 ++++++++++++ .../test/fulfillment_event_test.dart | 297 +++++++++++ .../test/material_consumption_port_test.dart | 170 ++++++ .../test/material_usage_test.dart | 131 +++++ .../test/order_tracking_test.dart | 262 ++++++++++ .../widgets/advance_stage_dialog_test.dart | 222 ++++++++ .../custom_order_tracking_detail_test.dart | 255 +++++++++ .../custom_order_tracking_page_test.dart | 164 ++++++ .../widgets/fulfillment_timeline_test.dart | 126 +++++ .../test/widgets/orders_page_test.dart | 10 +- .../test/woo_commerce_tracking_meta_test.dart | 264 ++++++++++ 48 files changed, 6221 insertions(+), 76 deletions(-) create mode 100644 kell_creations_apps/apps/kell_mobile/lib/composition/inventory_material_consumption_adapter.dart create mode 100644 kell_creations_apps/apps/kell_mobile/lib/pages/mobile_custom_order_tracking_page.dart create mode 100644 kell_creations_apps/apps/kell_mobile/test/composition/inventory_material_consumption_adapter_test.dart create mode 100644 kell_creations_apps/apps/kell_mobile/test/pages/mobile_custom_order_tracking_page_test.dart create mode 100644 kell_creations_apps/apps/kell_web/lib/composition/inventory_material_consumption_adapter.dart create mode 100644 kell_creations_apps/apps/kell_web/test/routing/custom_order_tracking_route_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/application/advance_fulfillment.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/application/complete_custom_order.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/application/custom_order_tracking_controller.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/domain/fulfillment_event.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/domain/material_consumption_port.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/domain/material_usage.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/presentation/custom_order_tracking_page.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/advance_stage_dialog.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/custom_order_tracking_card.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/custom_order_tracking_detail.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/fulfillment_timeline.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/complete_custom_order_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/custom_order_tracking_controller_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/fake_orders_repository_tracking_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/fulfillment_event_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/material_consumption_port_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/material_usage_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/order_tracking_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/widgets/advance_stage_dialog_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/widgets/custom_order_tracking_detail_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/widgets/custom_order_tracking_page_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/widgets/fulfillment_timeline_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/woo_commerce_tracking_meta_test.dart diff --git a/PROJECT_STATE_AUDIT.md b/PROJECT_STATE_AUDIT.md index daf65d6..d408ef5 100644 --- a/PROJECT_STATE_AUDIT.md +++ b/PROJECT_STATE_AUDIT.md @@ -439,13 +439,13 @@ kell_creations/ | `design_system` | 51 | 100% | Excellent | | `feature_wordpress` | 545 | ~84.7% | Good — broad coverage across all layers | | `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 | | `auth` | 42 | Unknown | Good for contracts and fakes | | `data` | 63 | Unknown | Good for contracts and in-memory impls | | `integrations` | 123 | Unknown | Good — API client and mapper tests present | -| `kell_web` | 62 | ~54.1% | **Gap** — low coverage; routing and shell untested | -| `kell_mobile` | 81 | Unknown | Adequate — shell, page, and checkout tests | +| `kell_web` | 81 | ~54.1% | **Gap** — low coverage; shell largely untested | +| `kell_mobile` | 109 | Unknown | Adequate — shell, page, checkout, and adapter tests | | `feature_finance` | 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 | @@ -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. **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). > 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 (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). -**Dependencies:** Stage 9A ✅, Stage 11A ✅, Stage 11B ✅ +**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. + +**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) @@ -646,13 +651,13 @@ These items harden the platform for production deployment. | `integrations` | ⚠️ PARTIAL | 70% | 123 | RetryPolicy/RateLimiter not wired; `ChannelAdapter` not implemented | | `feature_wordpress` | ✅ COMPLETE | 100% | 545 | None — `SquareSyncController` landed (Stage 10E) | | `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_finance` | ❌ MISSING | 0% | 1 | Pure stub | | `feature_mrp` | ❌ 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_mobile` | ⚠️ PARTIAL | 85% | 81 | Finance placeholder remains; POS/checkout landed (Stage 10C) | +| `kell_web` | ⚠️ PARTIAL | 85% | 81 | Finance placeholder remains; custom order tracking route landed (11C) | +| `kell_mobile` | ⚠️ PARTIAL | 85% | 109 | Finance placeholder remains; custom order tracking landed (11C) | | Architecture docs | ⚠️ PARTIAL | 80% | — | Square not represented; multi-channel model missing | | Policy register | ❌ MISSING | 10% | — | All CSVs empty | | CI/CD | ✅ COMPLETE | 85% | — | No coverage thresholds enforced | diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index ac8cb43..e52b392 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -11,18 +11,90 @@ - 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) - 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 -- next branch: `feat/custom-order-tracking` (Stage 11C) — Custom order tracking and fulfillment -- 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 +- merged: `feat/custom-order-ui` (Stage 11B) — custom order intake UI; merged to main 2026-09-25, commit `6c299e8` +- main baseline updated through: `feat/custom-order-ui` (Stage 11B complete, merged 2026-09-25) +- 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 - 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` ## Slice tracker -### feat/custom-order-ui +### feat/custom-order-tracking - 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` - stage: 11B — Custom order creation UI - date: 2026-09-25 diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 1c5aeb9..2671b0f 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -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). - ✅ 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. +- ✅ 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` @@ -143,25 +146,25 @@ Rules: ### 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 - All packages passing - latest reported count for `core`: `90/90 passed` - latest reported count for `design_system`: `51/51 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 `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 `data`: `63/63 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) -- total: `2,044/2,044 passed` (+146 new from Stage 11B branch `feat/custom-order-ui`) -- main baseline updated through: `feat/custom-order-domain` (Stage 11A complete, merged 2026-08-29, commit `85501fd`) -- current branch work: `feat/custom-order-ui` (Stage 11B) — complete, pending merge -- next branch: `feat/custom-order-tracking` (Stage 11C) — Custom order tracking and fulfillment +- total: `2,373/2,373 passed` (+329 new from Stage 11C branch `feat/custom-order-tracking`) +- main baseline updated through: `feat/custom-order-ui` (Stage 11B complete, merged 2026-09-25, commit `6c299e8`) +- current branch work: `feat/custom-order-tracking` (Stage 11C) — complete, pending merge +- 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. @@ -405,16 +408,84 @@ Validation: > actually needs. Reference images are captured as URLs to already-uploaded > 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 -**`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 -- Status progression UI driving `advanceFulfillmentStatus()`, with illegal - transitions surfaced as the domain's own rejection reasons -- Order history / timeline view of status changes with timestamps -- Link custom order completion to inventory deduction for materials used -- Tests for the tracking UI and the status progression paths +- `ItemType` enum and an expanded `InventoryItem` (reorder point, supplier, + cost per unit, location, last count date) +- `InventoryAdjustment` value object and the adjustment history it implies +- `InventoryRepository` write operations: `addItem()`, `updateItem()`, + `getAdjustmentHistory()`, `getLowStockItems()`, `getItemsByType()` +- `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 | | ----- | ---------------------------------------------------------------------------------- | --------------------------- | | 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.4 | Multi-Channel Inventory Sync Engine (`InventorySyncEngine`, `ChannelStockMapping`) | Stage 12C | | 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) - ✅ 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 @@ -1162,12 +1236,31 @@ Provide order tracking dashboard and status management for custom orders. ##### Requirements -- Create `CustomOrderTrackingPage` — filtered view of custom orders by status -- Add status progression: `quoted` → `depositReceived` → `inProduction` → `qualityCheck` → `readyForPickup` → `completed` -- Add notification triggers (future: email/SMS when status changes) -- Add order history/timeline view showing all status changes with timestamps -- Link custom order completion to inventory deduction (materials used) -- Tests for tracking UI and status progression +- ✅ Create `CustomOrderTrackingPage` — custom orders filtered by **fulfillment + stage**, with live per-stage counts, free-text search over customer / ID / + specification, and a master-detail layout above `KcBreakpoints.medium` +- ✅ Status progression `quoted` → `depositReceived` → `inProduction` → + `qualityCheck` → `readyForPickup` → `completed` driven through + `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. --- diff --git a/kell_creations_apps/apps/kell_mobile/lib/composition/inventory_material_consumption_adapter.dart b/kell_creations_apps/apps/kell_mobile/lib/composition/inventory_material_consumption_adapter.dart new file mode 100644 index 0000000..bbd877f --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/lib/composition/inventory_material_consumption_adapter.dart @@ -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 deductMaterials( + String orderId, + List usages, + ) async { + final deducted = []; + final failures = {}; + + 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); + } +} diff --git a/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart b/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart index 3685bc2..049f0db 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart @@ -5,6 +5,8 @@ import 'package:feature_policy/feature_policy.dart'; import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:integrations/integrations.dart'; +import 'inventory_material_consumption_adapter.dart'; + /// Default catalogue items used in fake/wordpress mode where no Square catalog /// is available. These seed the [MobileCheckoutPage] product list. const _fakeCatalogue = [ @@ -47,6 +49,16 @@ class MobileAppServices extends KcAppServices { /// and the bug report service backing the shell's "Report a problem" action. 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({ required this.inventoryRepository, required this.ordersRepository, @@ -69,13 +81,12 @@ class MobileAppServices extends KcAppServices { /// Convenience accessor for the bug report service. KcBugReportService get bugReportService => observability.bugReportService; - static KcObservability _defaultObservability(String environmentLabel) => - KcObservability.standard( - appVersion: appVersion, - environment: environmentLabel, - platform: 'android', - source: 'kell_mobile', - ); + static KcObservability _defaultObservability(String environmentLabel) => KcObservability.standard( + appVersion: appVersion, + environment: environmentLabel, + platform: 'android', + source: 'kell_mobile', + ); /// Creates a [MobileAppServices] backed by fake, in-memory repositories. factory MobileAppServices.fake() { diff --git a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_custom_order_tracking_page.dart b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_custom_order_tracking_page.dart new file mode 100644 index 0000000..35e5641 --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_custom_order_tracking_page.dart @@ -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, + ), + ), + ); + } +} diff --git a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_orders_page.dart b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_orders_page.dart index 46fd4b4..01b5ae2 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_orders_page.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_orders_page.dart @@ -3,6 +3,7 @@ import 'package:feature_orders/feature_orders.dart'; import 'package:flutter/material.dart'; import 'mobile_custom_order_page.dart'; +import 'mobile_custom_order_tracking_page.dart'; import 'mobile_order_detail_page.dart'; /// 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. 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({ super.key, required this.repository, this.onViewProduct, this.onViewInventory, + this.materialPort, }); @override @@ -109,11 +117,26 @@ class _MobileOrdersPageState extends State { Positioned( right: KcSpacing.md, bottom: KcSpacing.md, - child: FloatingActionButton.extended( - heroTag: 'new-custom-order', - onPressed: _navigateToCustomOrderForm, - icon: const Icon(Icons.design_services_outlined), - label: const Text('Custom Order'), + 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', + onPressed: _navigateToCustomOrderForm, + icon: const Icon(Icons.design_services_outlined), + label: const Text('Custom Order'), + ), + ], ), ), ], @@ -136,6 +159,24 @@ class _MobileOrdersPageState extends State { 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 _navigateToCustomOrderTracking() async { + setState(() => _detailPageActive = true); + await Navigator.of(context).push( + MaterialPageRoute( + 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. Widget _buildSearchBar() { return Padding( diff --git a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart index d1f7551..b9de6af 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart @@ -177,6 +177,7 @@ class _MobileShellState extends State { case 3: return MobileOrdersPage( repository: services.ordersRepository, + materialPort: services.materialConsumptionPort, onViewProduct: (_) { setState(() => _selectedIndex = 2); }, diff --git a/kell_creations_apps/apps/kell_mobile/test/composition/inventory_material_consumption_adapter_test.dart b/kell_creations_apps/apps/kell_mobile/test/composition/inventory_material_consumption_adapter_test.dart new file mode 100644 index 0000000..5d3ada1 --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/test/composition/inventory_material_consumption_adapter_test.dart @@ -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 getItemBySku(String sku) async => throw Exception('inventory offline'); +} + +/// An inventory repository that finds items but rejects every adjustment. +class _RejectingInventory extends FakeInventoryRepository { + @override + Future 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); + }); + }); +} diff --git a/kell_creations_apps/apps/kell_mobile/test/pages/mobile_custom_order_tracking_page_test.dart b/kell_creations_apps/apps/kell_mobile/test/pages/mobile_custom_order_tracking_page_test.dart new file mode 100644 index 0000000..337cf86 --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/test/pages/mobile_custom_order_tracking_page_test.dart @@ -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 _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); + }); + }); +} diff --git a/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart b/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart index e5318d8..6e009dc 100644 --- a/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart @@ -5,6 +5,8 @@ import 'package:feature_policy/feature_policy.dart'; import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:integrations/integrations.dart'; +import 'inventory_material_consumption_adapter.dart'; + /// Holds the concrete service implementations used by `kell_web`. /// /// 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. 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({ required this.inventoryRepository, required this.ordersRepository, @@ -52,13 +64,12 @@ class AppServices extends KcAppServices { /// Convenience accessor for the bug report service. KcBugReportService get bugReportService => observability.bugReportService; - static KcObservability _defaultObservability(String environmentLabel) => - KcObservability.standard( - appVersion: appVersion, - environment: environmentLabel, - platform: 'web', - source: 'kell_web', - ); + static KcObservability _defaultObservability(String environmentLabel) => KcObservability.standard( + appVersion: appVersion, + environment: environmentLabel, + platform: 'web', + source: 'kell_web', + ); /// Creates an [AppServices] backed by fake, in-memory repositories. factory AppServices.fake() { diff --git a/kell_creations_apps/apps/kell_web/lib/composition/inventory_material_consumption_adapter.dart b/kell_creations_apps/apps/kell_web/lib/composition/inventory_material_consumption_adapter.dart new file mode 100644 index 0000000..73f6c68 --- /dev/null +++ b/kell_creations_apps/apps/kell_web/lib/composition/inventory_material_consumption_adapter.dart @@ -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 deductMaterials( + String orderId, + List usages, + ) async { + final deducted = []; + final failures = {}; + + 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); + } +} diff --git a/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart b/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart index 053e440..aed4646 100644 --- a/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart +++ b/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart @@ -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 ───────────────────────────────────────────────── /// Product draft → Policy page filtered to "Product Compliance". diff --git a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart index c9656bb..bad6ca8 100644 --- a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart +++ b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart @@ -19,6 +19,7 @@ abstract final class AppRoutes { static const String products = '/products'; static const String orders = '/orders'; static const String customOrder = '/orders/custom'; + static const String customOrderTracking = '/orders/tracking'; static const String finance = '/finance'; static const String policy = '/policy'; static const String integrations = '/integrations'; @@ -85,6 +86,7 @@ abstract final class AppRoutes { onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku), onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku), onCreateCustomOrder: () => AppNavigation.ordersToCustomOrder(context), + onTrackCustomOrders: () => AppNavigation.ordersToCustomOrderTracking(context), initialFilter: args['filter'], initialQuery: args['query'], 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: return _buildRoute( settings, @@ -161,6 +178,20 @@ abstract final class AppRoutes { return MaterialPageRoute(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` arguments from [RouteSettings]. /// /// Returns an empty map when no arguments are present or the type doesn't diff --git a/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart index f41e639..69c6bab 100644 --- a/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart +++ b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart @@ -145,7 +145,15 @@ class _StubOrdersRepository implements OrdersRepository { }) => throw UnimplementedError(); @override - Future advanceFulfillmentStatus(String id, FulfillmentStatus next) => + Future advanceFulfillmentStatus( + String id, + FulfillmentStatus next, { + String? note, + DateTime? occurredAt, + }) => throw UnimplementedError(); + + @override + Future recordMaterialUsage(String id, List usages) => throw UnimplementedError(); } diff --git a/kell_creations_apps/apps/kell_web/test/routing/custom_order_tracking_route_test.dart b/kell_creations_apps/apps/kell_web/test/routing/custom_order_tracking_route_test.dart new file mode 100644 index 0000000..526213f --- /dev/null +++ b/kell_creations_apps/apps/kell_web/test/routing/custom_order_tracking_route_test.dart @@ -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 _go(WidgetTester tester, String route, {Map? args}) async { + final navigator = tester.state(find.byType(Navigator)); + unawaited(navigator.pushReplacementNamed(route, arguments: args)); + await tester.pumpAndSettle(); +} + +Future _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()); + }); + + test('returns the same port instance on repeated access', () { + final services = AppServices.fake(); + + expect(identical(services.materialConsumptionPort, services.materialConsumptionPort), isTrue); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/README.md b/kell_creations_apps/packages/feature_orders/README.md index 96f5dae..f56155e 100644 --- a/kell_creations_apps/packages/feature_orders/README.md +++ b/kell_creations_apps/packages/feature_orders/README.md @@ -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 `qualityCheck → inProduction` rework path. Illegal jumps are rejected by the 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 create operations plus `createCustomOrder`, `updateCustomOrderSpec`, - `recordDeposit`, and `advanceFulfillmentStatus`. + `recordDeposit`, `advanceFulfillmentStatus`, and `recordMaterialUsage`. - **Implementations** — `FakeOrdersRepository` (seeded standard and custom orders across every pipeline stage) and `WooCommerceOrdersRepository` (REST API v3, custom order state round-tripped through `_kc_`-prefixed order meta-data). - **Application layer** — `OrdersController`, `CheckoutController`, - `CustomOrderFormController`, and the `GetOrders` / `UpdateOrderStatus` / - `AddOrderNote` / `CreateOrder` / `CreateCustomOrder` / `RecordDeposit` use - cases. + `CustomOrderFormController`, `CustomOrderTrackingController`, and the + `GetOrders` / `UpdateOrderStatus` / `AddOrderNote` / `CreateOrder` / + `CreateCustomOrder` / `RecordDeposit` / `AdvanceFulfillment` / + `CompleteCustomOrder` use cases. - **Presentation** — `OrdersPage`, `OrderCard`, `OrderDetailPanel`, `OrderStatusChip`, `CustomOrderFormPage`, `CustomOrderConfirmationPage`, - `FulfillmentStatusChip`, `showReferenceImageDialog`. + `CustomOrderTrackingPage`, `CustomOrderTrackingCard`, + `CustomOrderTrackingDetail`, `FulfillmentStatusChip`, `FulfillmentTimeline`, + `showReferenceImageDialog`, `showAdvanceStageDialog`. ## 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 `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 deductMaterials( + String orderId, + List 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 Repository implementations accept an optional `KcLogger` from `core`. Failed diff --git a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart index 6a3e87b..7a9055f 100644 --- a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart +++ b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart @@ -1,10 +1,13 @@ library; export 'src/application/add_order_note.dart'; +export 'src/application/advance_fulfillment.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_order.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/orders_controller.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/domain/custom_order_spec.dart'; export 'src/domain/deposit_info.dart'; +export 'src/domain/fulfillment_event.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_action_result.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/presentation/custom_order_confirmation_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_page.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_timeline.dart'; export 'src/presentation/widgets/order_card.dart'; export 'src/presentation/widgets/order_detail_panel.dart'; export 'src/presentation/widgets/order_status_chip.dart'; diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/advance_fulfillment.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/advance_fulfillment.dart new file mode 100644 index 0000000..51be86c --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/advance_fulfillment.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 call( + String id, + FulfillmentStatus next, { + String? note, + DateTime? occurredAt, + }) => repository.advanceFulfillmentStatus(id, next, note: note, occurredAt: occurredAt); +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/complete_custom_order.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/complete_custom_order.dart new file mode 100644 index 0000000..eed1d76 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/complete_custom_order.dart @@ -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 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), + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/custom_order_tracking_controller.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/custom_order_tracking_controller.dart new file mode 100644 index 0000000..2f9656a --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/custom_order_tracking_controller.dart @@ -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 _allCustomOrders = []; + + /// The commissions visible after applying [activeStage] and [searchQuery]. + List 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 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 get stageCounts { + final counts = {}; + 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 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 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 _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 _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; + } + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart index 5433986..a456f76 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart @@ -2,7 +2,9 @@ import 'package:core/core.dart'; import '../domain/custom_order_spec.dart'; import '../domain/deposit_info.dart'; +import '../domain/fulfillment_event.dart'; import '../domain/fulfillment_status.dart'; +import '../domain/material_usage.dart'; import '../domain/order.dart'; import '../domain/order_action_result.dart'; import '../domain/order_item.dart'; @@ -28,8 +30,16 @@ class FakeOrdersRepository implements OrdersRepository { /// care about observability need not supply one. final KcLogger logger; - FakeOrdersRepository({KcLogger? logger}) - : logger = (logger ?? KcLogger.silent()).forSource(_logSource); + /// 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; + + FakeOrdersRepository({KcLogger? logger, DateTime Function()? now}) + : logger = (logger ?? KcLogger.silent()).forSource(_logSource), + now = now ?? DateTime.now; static const String _logSource = 'FakeOrdersRepository'; @@ -193,6 +203,10 @@ class FakeOrdersRepository implements OrdersRepository { notes: 'Matching set of four for a housewarming gift.', ), 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( id: 'KC-C2002', @@ -226,6 +240,19 @@ class FakeOrdersRepository implements OrdersRepository { depositPaid: true, 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( id: 'KC-C2003', @@ -264,6 +291,24 @@ class FakeOrdersRepository implements OrdersRepository { depositPaid: true, 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( id: 'KC-C2004', @@ -297,6 +342,42 @@ class FakeOrdersRepository implements OrdersRepository { depositPaid: true, 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( id: 'KC-C2005', @@ -332,6 +413,34 @@ class FakeOrdersRepository implements OrdersRepository { depositPaid: true, 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( orderType: OrderType.custom, customOrderSpec: spec, depositInfo: deposit, fulfillmentStatus: FulfillmentStatus.quoted, + fulfillmentHistory: [FulfillmentEvent.intake(occurredAt: order.orderDate)], ); _orders.add(created); return OrderActionResult.success(created); @@ -505,7 +618,12 @@ class FakeOrdersRepository implements OrdersRepository { } @override - Future advanceFulfillmentStatus(String id, FulfillmentStatus next) async { + Future advanceFulfillmentStatus( + String id, + FulfillmentStatus next, { + String? note, + DateTime? occurredAt, + }) async { await Future.delayed(const Duration(milliseconds: 200)); 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 recordMaterialUsage(String id, List usages) async { + await Future.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; return OrderActionResult.success(updated); } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_order_mapper.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_order_mapper.dart index d5419b6..412b230 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_order_mapper.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_order_mapper.dart @@ -1,6 +1,8 @@ import '../domain/custom_order_spec.dart'; import '../domain/deposit_info.dart'; +import '../domain/fulfillment_event.dart'; import '../domain/fulfillment_status.dart'; +import '../domain/material_usage.dart'; import '../domain/order.dart'; import '../domain/order_item.dart'; import '../domain/order_source.dart'; @@ -35,6 +37,8 @@ import '../domain/order_type.dart'; /// | `_kc_deposit_paid` | `DepositInfo.depositPaid` | `true` \| `false` | /// | `_kc_deposit_date` | `DepositInfo.depositDate` | 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. /// 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. 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 /// meta value. 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]. Order fromJson(Map json) { final billing = json['billing'] as Map? ?? {}; @@ -143,9 +163,32 @@ class WooCommerceOrderMapper { fulfillmentStatus: isCustom ? FulfillmentStatus.fromWire(internalMeta[metaFulfillmentStatus]) : 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 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 materialUsageFromMeta(String? packed) => + _unpack(packed, MaterialUsage.fromWire); + + static List _unpack(String? packed, T? Function(String) parse) { + if (packed == null || packed.isEmpty) return const []; + return packed.split(recordSeparator).map(parse).whereType().toList(); + } + /// Converts a list of WooCommerce order JSON maps to [Order]s. List fromJsonList(List> jsonList) { return jsonList.map(fromJson).toList(); @@ -225,6 +268,7 @@ class WooCommerceOrderMapper { metaFulfillmentStatus: order.fulfillmentStatus!.wireValue, ...specMetaEntries(order.customOrderSpec), ...depositMetaEntries(order.depositInfo), + ...trackingMetaEntries(order), }; 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 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. static List> toMetaDataList(Map entries) => entries.entries.map((e) => {'key': e.key, 'value': e.value}).toList(); diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_orders_repository.dart index d0a7f51..6e6a08d 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_orders_repository.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_orders_repository.dart @@ -3,7 +3,9 @@ import 'package:feature_wordpress/feature_wordpress.dart'; import '../domain/custom_order_spec.dart'; import '../domain/deposit_info.dart'; +import '../domain/fulfillment_event.dart'; import '../domain/fulfillment_status.dart'; +import '../domain/material_usage.dart'; import '../domain/order.dart'; import '../domain/order_action_result.dart'; import '../domain/order_status.dart'; @@ -45,13 +47,22 @@ class WooCommerceOrdersRepository implements OrdersRepository { /// Defaults to [KcLogger.silent] so tests need not supply one. 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({ required WooCommerceApiClient apiClient, WooCommerceOrderMapper mapper = const WooCommerceOrderMapper(), KcLogger? logger, + DateTime Function()? now, }) : _apiClient = apiClient, _mapper = mapper, - logger = (logger ?? KcLogger.silent()).forSource(_logSource); + logger = (logger ?? KcLogger.silent()).forSource(_logSource), + now = now ?? DateTime.now; static const String _logSource = 'WooCommerceOrdersRepository'; @@ -140,11 +151,14 @@ class WooCommerceOrdersRepository implements OrdersRepository { required CustomOrderSpec spec, DepositInfo? deposit, }) { + // Seed the timeline with an intake event so the commission arrives in + // WooCommerce with a history, matching FakeOrdersRepository. final custom = order.copyWith( orderType: OrderType.custom, customOrderSpec: spec, depositInfo: deposit, fulfillmentStatus: FulfillmentStatus.quoted, + fulfillmentHistory: [FulfillmentEvent.intake(occurredAt: order.orderDate)], ); return _guard( @@ -211,7 +225,12 @@ class WooCommerceOrdersRepository implements OrdersRepository { } @override - Future advanceFulfillmentStatus(String id, FulfillmentStatus next) async { + Future advanceFulfillmentStatus( + String id, + FulfillmentStatus next, { + String? note, + DateTime? occurredAt, + }) async { final loaded = await _loadCustomOrder(id, operation: 'advanceFulfillmentStatus'); 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'); } + @override + Future recordMaterialUsage(String id, List 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 ────────────────────────────────────────────────────── /// Fetches [id] and verifies it is a custom order. diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/fulfillment_event.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/fulfillment_event.dart new file mode 100644 index 0000000..8254d5c --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/fulfillment_event.dart @@ -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' : ''})'; +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/material_consumption_port.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/material_consumption_port.dart new file mode 100644 index 0000000..9a3791d --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/material_consumption_port.dart @@ -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 deducted; + + /// Failure messages keyed by SKU for the usages that could not be deducted. + final Map failures; + + MaterialDeductionResult({ + List deducted = const [], + Map 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 deductMaterials(String orderId, List 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 usages})> requests = []; + + @override + Future deductMaterials( + String orderId, + List usages, + ) async { + requests.add((orderId: orderId, usages: List.unmodifiable(usages))); + return MaterialDeductionResult.empty(); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/material_usage.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/material_usage.dart new file mode 100644 index 0000000..8e0295a --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/material_usage.dart @@ -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)'; +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart index a1b1f5c..404b92a 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart @@ -1,6 +1,8 @@ import 'custom_order_spec.dart'; import 'deposit_info.dart'; +import 'fulfillment_event.dart'; import 'fulfillment_status.dart'; +import 'material_usage.dart'; import 'order_item.dart'; import 'order_source.dart'; import 'order_status.dart'; @@ -66,6 +68,25 @@ class Order { /// transitions defined by [FulfillmentTransition]. 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 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; + const Order({ required this.id, required this.customerName, @@ -84,6 +105,8 @@ class Order { this.customOrderSpec, this.depositInfo, this.fulfillmentStatus, + this.fulfillmentHistory = const [], + this.materialUsage = const [], }); /// The total value of the order. @@ -102,6 +125,44 @@ class Order { /// exists (standard orders, or custom orders that have not been quoted). 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 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 get deductibleMaterials => + materialUsage.where((usage) => usage.isValid).toList(); + /// Returns a copy of this order with the given fields replaced. /// /// Nullable fields cannot be cleared through [copyWith]; construct a new @@ -124,6 +185,8 @@ class Order { CustomOrderSpec? customOrderSpec, DepositInfo? depositInfo, FulfillmentStatus? fulfillmentStatus, + List? fulfillmentHistory, + List? materialUsage, }) { return Order( id: id ?? this.id, @@ -143,6 +206,18 @@ class Order { customOrderSpec: customOrderSpec ?? this.customOrderSpec, depositInfo: depositInfo ?? this.depositInfo, 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]); } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart index 59e2556..cd7dde1 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart @@ -1,6 +1,8 @@ import 'custom_order_spec.dart'; import 'deposit_info.dart'; +import 'fulfillment_event.dart'; import 'fulfillment_status.dart'; +import 'material_usage.dart'; import 'order.dart'; import 'order_action_result.dart'; import 'order_status.dart'; @@ -82,7 +84,35 @@ abstract class OrdersRepository { /// jumps are rejected with an explanatory failure result and leave the /// 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 /// order, or the transition is illegal. - Future advanceFulfillmentStatus(String id, FulfillmentStatus next); + Future 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 recordMaterialUsage(String id, List usages); } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/custom_order_tracking_page.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/custom_order_tracking_page.dart new file mode 100644 index 0000000..e948a13 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/custom_order_tracking_page.dart @@ -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 createState() => _CustomOrderTrackingPageState(); +} + +class _CustomOrderTrackingPageState extends State { + 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 _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 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 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( + 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 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), + ); + }, + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart index 1c84f80..0f94cc8 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart @@ -32,6 +32,12 @@ class OrdersPage extends StatefulWidget { /// stays usable in contexts that have no route for the intake flow. 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'`). final String? initialFilter; @@ -47,6 +53,7 @@ class OrdersPage extends StatefulWidget { this.onViewProduct, this.onViewInventory, this.onCreateCustomOrder, + this.onTrackCustomOrders, this.initialFilter, this.initialQuery, this.initialSelectedId, @@ -145,6 +152,7 @@ class _OrdersPageState extends State { onSearchChanged: _controller.setSearchQuery, onFilterChanged: _controller.setFilter, onCreateCustomOrder: widget.onCreateCustomOrder, + onTrackCustomOrders: widget.onTrackCustomOrders, ), Expanded( child: isWide @@ -181,6 +189,7 @@ class _OrdersToolbar extends StatelessWidget { final ValueChanged onSearchChanged; final ValueChanged onFilterChanged; final VoidCallback? onCreateCustomOrder; + final VoidCallback? onTrackCustomOrders; const _OrdersToolbar({ required this.searchController, @@ -189,6 +198,7 @@ class _OrdersToolbar extends StatelessWidget { required this.onSearchChanged, required this.onFilterChanged, this.onCreateCustomOrder, + this.onTrackCustomOrders, }); static const _filters = [ @@ -207,14 +217,28 @@ class _OrdersToolbar extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // New custom order action - if (onCreateCustomOrder != null) ...[ + // Custom order actions — each omitted when the host has no route. + if (onCreateCustomOrder != null || onTrackCustomOrders != null) ...[ Align( alignment: Alignment.centerRight, - child: FilledButton.icon( - onPressed: onCreateCustomOrder, - icon: const Icon(Icons.design_services_outlined), - label: const Text('New Custom Order'), + 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, + icon: const Icon(Icons.design_services_outlined), + label: const Text('New Custom Order'), + ), + ], ), ), const SizedBox(height: KcSpacing.sm), diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/advance_stage_dialog.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/advance_stage_dialog.dart new file mode 100644 index 0000000..4e0a701 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/advance_stage_dialog.dart @@ -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 showAdvanceStageDialog( + BuildContext context, { + required FulfillmentStatus current, + required List allowedNext, + FulfillmentStatus? initialSelection, +}) { + return showDialog( + context: context, + builder: (_) => _AdvanceStageDialog( + current: current, + allowedNext: allowedNext, + initialSelection: initialSelection, + ), + ); +} + +class _AdvanceStageDialog extends StatefulWidget { + final FulfillmentStatus current; + final List 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( + initialValue: _selected, + decoration: const InputDecoration( + labelText: 'Move to', + border: OutlineInputBorder(), + isDense: true, + ), + items: [ + for (final stage in widget.allowedNext) + DropdownMenuItem(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')), + ], + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/custom_order_tracking_card.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/custom_order_tracking_card.dart new file mode 100644 index 0000000..4a383cb --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/custom_order_tracking_card.dart @@ -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, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/custom_order_tracking_detail.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/custom_order_tracking_detail.dart new file mode 100644 index 0000000..be54920 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/custom_order_tracking_detail.dart @@ -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)), + ], + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/fulfillment_timeline.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/fulfillment_timeline.dart new file mode 100644 index 0000000..daa2763 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/fulfillment_timeline.dart @@ -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 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; + } +} diff --git a/kell_creations_apps/packages/feature_orders/test/complete_custom_order_test.dart b/kell_creations_apps/packages/feature_orders/test/complete_custom_order_test.dart new file mode 100644 index 0000000..ab4c18a --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/complete_custom_order_test.dart @@ -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 failFor; + final List calls = []; + + _PartialPort([this.failFor = const {}]); + + @override + Future deductMaterials( + String orderId, + List 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 _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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/custom_order_tracking_controller_test.dart b/kell_creations_apps/packages/feature_orders/test/custom_order_tracking_controller_test.dart new file mode 100644 index 0000000..b0cd49e --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/custom_order_tracking_controller_test.dart @@ -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> getOrders() async => throw Exception('network error'); +} + +/// A port that fails for every SKU in [failFor]. +class _PartialPort implements MaterialConsumptionPort { + final Set failFor; + + _PartialPort([this.failFor = const {}]); + + @override + Future deductMaterials( + String orderId, + List 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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_tracking_test.dart b/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_tracking_test.dart new file mode 100644 index 0000000..4849ba2 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_tracking_test.dart @@ -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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/fulfillment_event_test.dart b/kell_creations_apps/packages/feature_orders/test/fulfillment_event_test.dart new file mode 100644 index 0000000..42a8349 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/fulfillment_event_test.dart @@ -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')); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/material_consumption_port_test.dart b/kell_creations_apps/packages/feature_orders/test/material_consumption_port_test.dart new file mode 100644 index 0000000..e485002 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/material_consumption_port_test.dart @@ -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 failFor; + + _PartialPort(this.failFor); + + @override + Future deductMaterials( + String orderId, + List 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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/material_usage_test.dart b/kell_creations_apps/packages/feature_orders/test/material_usage_test.dart new file mode 100644 index 0000000..6892d66 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/material_usage_test.dart @@ -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')); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/order_tracking_test.dart b/kell_creations_apps/packages/feature_orders/test/order_tracking_test.dart new file mode 100644 index 0000000..927c6e7 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/order_tracking_test.dart @@ -0,0 +1,262 @@ +import 'package:feature_orders/feature_orders.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Order _custom({ + FulfillmentStatus? stage, + List history = const [], + List 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)); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/advance_stage_dialog_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/advance_stage_dialog_test.dart new file mode 100644 index 0000000..dfc5262 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/widgets/advance_stage_dialog_test.dart @@ -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 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), 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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_tracking_detail_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_tracking_detail_test.dart new file mode 100644 index 0000000..c18735f --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_tracking_detail_test.dart @@ -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 materials = const [], + DepositInfo? deposit, + List 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 _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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_tracking_page_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_tracking_page_test.dart new file mode 100644 index 0000000..7b497f5 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/widgets/custom_order_tracking_page_test.dart @@ -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 _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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/fulfillment_timeline_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/fulfillment_timeline_test.dart new file mode 100644 index 0000000..37d4d78 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/widgets/fulfillment_timeline_test.dart @@ -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'); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart index 6279efe..3f9075d 100644 --- a/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart +++ b/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart @@ -101,7 +101,15 @@ class _EmptyOrdersRepository implements OrdersRepository { }) async => const OrderActionResult.failure('Not implemented.'); @override - Future advanceFulfillmentStatus(String id, FulfillmentStatus next) async => + Future advanceFulfillmentStatus( + String id, + FulfillmentStatus next, { + String? note, + DateTime? occurredAt, + }) async => const OrderActionResult.failure('Not implemented.'); + + @override + Future recordMaterialUsage(String id, List usages) async => const OrderActionResult.failure('Not implemented.'); } diff --git a/kell_creations_apps/packages/feature_orders/test/woo_commerce_tracking_meta_test.dart b/kell_creations_apps/packages/feature_orders/test/woo_commerce_tracking_meta_test.dart new file mode 100644 index 0000000..5eb33c1 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/woo_commerce_tracking_meta_test.dart @@ -0,0 +1,264 @@ +import 'package:feature_orders/feature_orders.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _mapper = WooCommerceOrderMapper(); + +Order _custom({ + List history = const [], + List 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 _json(Map meta) => { + 'id': 5001, + 'status': 'processing', + 'date_created': '2026-04-01T00:00:00', + 'billing': {'first_name': 'Test', 'last_name': 'Customer', 'email': 'test@example.com'}, + 'shipping': {}, + 'line_items': [], + '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 _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, + ); + }); + }); +}