From d36c2d1e7384e6fd6f38b71c00aa6d2f4b7044c1 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Fri, 10 Jul 2026 18:33:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20Stage=205A=20=E2=80=94=20inv?= =?UTF-8?q?entory=20domain=20expansion=20with=20write=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development/build_execution_tracker.md | 32 +- .../apps/kell_mobile/pubspec.lock | 12 +- .../apps/kell_web/pubspec.lock | 12 +- .../dashboard_controller_test.dart | 70 +++- .../domain/dashboard_summary_test.dart | 52 ++- .../lib/feature_inventory.dart | 6 + .../adjust_inventory_quantity.dart | 14 + .../application/create_inventory_item.dart | 11 + .../src/application/inventory_controller.dart | 98 +++++- .../application/update_inventory_item.dart | 11 + .../src/data/fake_inventory_repository.dart | 138 +++++++- .../domain/inventory_adjustment_result.dart | 23 ++ .../lib/src/domain/inventory_item.dart | 48 +++ .../lib/src/domain/inventory_repository.dart | 22 ++ .../lib/src/presentation/inventory_page.dart | 10 +- .../packages/feature_inventory/pubspec.yaml | 2 +- .../test/feature_inventory_test.dart | 333 +++++++++++++++++- 17 files changed, 850 insertions(+), 44 deletions(-) create mode 100644 kell_creations_apps/packages/feature_inventory/lib/src/application/adjust_inventory_quantity.dart create mode 100644 kell_creations_apps/packages/feature_inventory/lib/src/application/create_inventory_item.dart create mode 100644 kell_creations_apps/packages/feature_inventory/lib/src/application/update_inventory_item.dart create mode 100644 kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_adjustment_result.dart diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 0af6c38..4eddc88 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -2,9 +2,9 @@ ## Current status -- main baseline updated through: bulk-status-actions (Stage 7A complete) -- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/integrations-contracts (Stage 8A) -- current stage: Stage 7A complete — deciding next slice +- main baseline updated through: feat/inventory-domain-expansion (Stage 5A complete) +- next branch: feat/inventory-page-ui (Stage 5B) or feat/orders-domain (Stage 5C) +- current stage: Stage 5A complete — inventory domain expansion with write actions ## Slice tracker @@ -242,9 +242,33 @@ - analyze: passed (info-level only — 8 pre-existing unnecessary_import in test files) - brief updated: yes +### feat/inventory-domain-expansion + +- status: merged to main +- date: 2026-07-10 +- inspection: complete +- implementation: complete +- files changed: + - `feature_inventory/lib/src/domain/inventory_item.dart` — expanded model with `description`, `imageUrl`, `category`, `reorderPoint`, `supplier`, `location`, `lastUpdated` fields; added `copyWith()` + - `feature_inventory/lib/src/domain/inventory_adjustment_result.dart` — new value object with `success`, `updatedItem`, `errorMessage`; named constructors `success()` and `failure()` + - `feature_inventory/lib/src/domain/inventory_repository.dart` — expanded contract with `adjustQuantity()`, `setQuantity()`, `createItem()`, `updateItem()`, `updateItemImage()` + - `feature_inventory/lib/src/data/fake_inventory_repository.dart` — expanded seed data (6 items with full fields, supplier/location, reorderPoint); implemented all write methods with status auto-update logic + - `feature_inventory/lib/src/application/adjust_inventory_quantity.dart` — new use case wrapping `adjustQuantity()` + - `feature_inventory/lib/src/application/create_inventory_item.dart` — new use case wrapping `createItem()` + - `feature_inventory/lib/src/application/update_inventory_item.dart` — new use case wrapping `updateItem()` + - `feature_inventory/lib/src/application/inventory_controller.dart` — added `isUpdating`, `lastActionResult`, `adjustQuantity()`, `createItem()`, `updateItem()`, `_refreshItem()` with pre-filter selectedItem refresh + - `feature_inventory/lib/src/presentation/inventory_page.dart` — updated `InventoryController` constructor call to pass all 4 use cases + - `feature_inventory/lib/feature_inventory.dart` — expanded barrel exports for all new types and use cases + - `feature_inventory/test/feature_inventory_test.dart` — 49 tests covering InventoryStatus, InventoryItem, InventoryAdjustmentResult, FakeInventoryRepository (all write methods), and InventoryController (read + write actions) + - `kell_web/test/dashboard/domain/dashboard_summary_test.dart` — updated InventoryItem construction to use expanded model + - `kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated stub InventoryRepository to implement expanded contract +- tests: passed (49/49 feature_inventory, 24/24 kell_web, 20/20 core, 41/41 design_system, 311/311 feature_wordpress — 445 total) +- analyze: passed (dart analyze — no issues found) +- brief updated: yes + ### chore/analyze-cleanup-and-tracker-sync -- status: in progress +- status: merged to main - date: 2026-05-30 - inspection: complete - implementation: complete diff --git a/kell_creations_apps/apps/kell_mobile/pubspec.lock b/kell_creations_apps/apps/kell_mobile/pubspec.lock index 7f9e278..3724724 100644 --- a/kell_creations_apps/apps/kell_mobile/pubspec.lock +++ b/kell_creations_apps/apps/kell_mobile/pubspec.lock @@ -185,10 +185,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" path: dependency: transitive description: @@ -246,10 +246,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" typed_data: dependency: transitive description: @@ -270,10 +270,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.2.0" web: dependency: transitive description: diff --git a/kell_creations_apps/apps/kell_web/pubspec.lock b/kell_creations_apps/apps/kell_web/pubspec.lock index 7f9e278..3724724 100644 --- a/kell_creations_apps/apps/kell_web/pubspec.lock +++ b/kell_creations_apps/apps/kell_web/pubspec.lock @@ -185,10 +185,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" path: dependency: transitive description: @@ -246,10 +246,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" typed_data: dependency: transitive description: @@ -270,10 +270,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.2.0" web: dependency: transitive description: 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 d8f22f9..705f6e3 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 @@ -14,6 +14,24 @@ class _StubInventoryRepository implements InventoryRepository { @override Future> getInventoryItems() async => items; + + @override + Future adjustQuantity(String id, int delta, String reason) => + throw UnimplementedError(); + + @override + Future setQuantity(String id, int quantity, String reason) => + throw UnimplementedError(); + + @override + Future createItem(InventoryItem item) => throw UnimplementedError(); + + @override + Future updateItem(InventoryItem item) => throw UnimplementedError(); + + @override + Future updateItemImage(String id, String imageUrl) => + throw UnimplementedError(); } class _StubProductPublishingRepository implements ProductPublishingRepository { @@ -56,6 +74,52 @@ class _StubOrdersRepository implements OrdersRepository { class _FailingInventoryRepository implements InventoryRepository { @override Future> getInventoryItems() => throw Exception('network error'); + + @override + Future adjustQuantity(String id, int delta, String reason) => + throw UnimplementedError(); + + @override + Future setQuantity(String id, int quantity, String reason) => + throw UnimplementedError(); + + @override + Future createItem(InventoryItem item) => throw UnimplementedError(); + + @override + Future updateItem(InventoryItem item) => throw UnimplementedError(); + + @override + Future updateItemImage(String id, String imageUrl) => + throw UnimplementedError(); +} + +// ── Shared test helpers ──────────────────────────────────────────────────── + +final _baseDate = DateTime(2026, 7, 1); + +InventoryItem _inv({ + required String id, + required String sku, + required String name, + required int quantityOnHand, + required double unitPrice, + required InventoryStatus status, + int reorderPoint = 5, +}) { + return InventoryItem( + id: id, + sku: sku, + name: name, + description: '', + imageUrl: '', + category: 'Test', + quantityOnHand: quantityOnHand, + unitPrice: unitPrice, + reorderPoint: reorderPoint, + status: status, + lastUpdated: _baseDate, + ); } // ── Tests ────────────────────────────────────────────────────────────────── @@ -78,7 +142,7 @@ void main() { test('load() sets isLoading then populates summary', () async { final inventoryItems = [ - const InventoryItem( + _inv( id: '1', sku: 'A', name: 'A', @@ -86,7 +150,7 @@ void main() { unitPrice: 5.0, status: InventoryStatus.inStock, ), - const InventoryItem( + _inv( id: '2', sku: 'B', name: 'B', @@ -138,7 +202,7 @@ void main() { test('load() aggregates data from all three repositories', () async { final inventoryItems = [ - const InventoryItem( + _inv( id: '1', sku: 'A', name: 'A', diff --git a/kell_creations_apps/apps/kell_web/test/dashboard/domain/dashboard_summary_test.dart b/kell_creations_apps/apps/kell_web/test/dashboard/domain/dashboard_summary_test.dart index 3b4ce7d..d61bede 100644 --- a/kell_creations_apps/apps/kell_web/test/dashboard/domain/dashboard_summary_test.dart +++ b/kell_creations_apps/apps/kell_web/test/dashboard/domain/dashboard_summary_test.dart @@ -4,6 +4,34 @@ import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:kell_web/dashboard/domain/dashboard_summary.dart'; +// ── Shared test helpers ──────────────────────────────────────────────────── + +final _baseDate = DateTime(2026, 7, 1); + +InventoryItem _inv({ + required String id, + required String sku, + required String name, + required int quantityOnHand, + required double unitPrice, + required InventoryStatus status, + int reorderPoint = 5, +}) { + return InventoryItem( + id: id, + sku: sku, + name: name, + description: '', + imageUrl: '', + category: 'Test', + quantityOnHand: quantityOnHand, + unitPrice: unitPrice, + reorderPoint: reorderPoint, + status: status, + lastUpdated: _baseDate, + ); +} + void main() { group('DashboardSummary.empty', () { test('has all zeroes', () { @@ -23,7 +51,7 @@ void main() { group('DashboardSummary.fromData', () { test('counts inventory statuses correctly', () { final items = [ - const InventoryItem( + _inv( id: '1', sku: 'A', name: 'A', @@ -31,7 +59,7 @@ void main() { unitPrice: 5.0, status: InventoryStatus.inStock, ), - const InventoryItem( + _inv( id: '2', sku: 'B', name: 'B', @@ -39,7 +67,7 @@ void main() { unitPrice: 5.0, status: InventoryStatus.lowStock, ), - const InventoryItem( + _inv( id: '3', sku: 'C', name: 'C', @@ -47,7 +75,7 @@ void main() { unitPrice: 5.0, status: InventoryStatus.outOfStock, ), - const InventoryItem( + _inv( id: '4', sku: 'D', name: 'D', @@ -177,10 +205,9 @@ void main() { }); test('computes full summary from fake repository data', () { - // Use the same data the fake repositories return. final summary = DashboardSummary.fromData( - inventoryItems: const [ - InventoryItem( + inventoryItems: [ + _inv( id: '1', sku: 'BC-FLR-001', name: 'Floral Bowl Cozy', @@ -188,15 +215,16 @@ void main() { unitPrice: 12.99, status: InventoryStatus.inStock, ), - InventoryItem( + _inv( id: '2', sku: 'CS-CIT-002', name: 'Citrus Coaster Set', quantityOnHand: 7, unitPrice: 16.50, status: InventoryStatus.lowStock, + reorderPoint: 10, ), - InventoryItem( + _inv( id: '3', sku: 'NL-OCN-003', name: 'Ocean Nightlight', @@ -204,7 +232,7 @@ void main() { unitPrice: 19.99, status: InventoryStatus.outOfStock, ), - InventoryItem( + _inv( id: '4', sku: 'JG-BLU-004', name: 'Fabric Jar Gripper', @@ -212,7 +240,7 @@ void main() { unitPrice: 8.50, status: InventoryStatus.inStock, ), - InventoryItem( + _inv( id: '5', sku: 'SH-SUN-005', name: 'Skillet Handle Sleeve', @@ -220,7 +248,7 @@ void main() { unitPrice: 10.99, status: InventoryStatus.lowStock, ), - InventoryItem( + _inv( id: '6', sku: 'SC-SUB-006', name: 'Sublimated Slate Coaster', diff --git a/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart b/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart index a71cdbe..a6cf36d 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart @@ -1,6 +1,12 @@ library; +export 'src/application/adjust_inventory_quantity.dart'; +export 'src/application/create_inventory_item.dart'; +export 'src/application/get_inventory_items.dart'; +export 'src/application/inventory_controller.dart'; +export 'src/application/update_inventory_item.dart'; export 'src/data/fake_inventory_repository.dart'; +export 'src/domain/inventory_adjustment_result.dart'; export 'src/domain/inventory_item.dart'; export 'src/domain/inventory_repository.dart'; export 'src/domain/inventory_status.dart'; diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/application/adjust_inventory_quantity.dart b/kell_creations_apps/packages/feature_inventory/lib/src/application/adjust_inventory_quantity.dart new file mode 100644 index 0000000..d10cc72 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/application/adjust_inventory_quantity.dart @@ -0,0 +1,14 @@ +import '../domain/inventory_adjustment_result.dart'; +import '../domain/inventory_repository.dart'; + +/// Use case: adjust the quantity of an inventory item by a delta. +/// +/// A positive [delta] adds stock; a negative [delta] removes stock. +class AdjustInventoryQuantity { + final InventoryRepository repository; + + AdjustInventoryQuantity(this.repository); + + Future call(String id, int delta, String reason) => + repository.adjustQuantity(id, delta, reason); +} diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/application/create_inventory_item.dart b/kell_creations_apps/packages/feature_inventory/lib/src/application/create_inventory_item.dart new file mode 100644 index 0000000..b5e7476 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/application/create_inventory_item.dart @@ -0,0 +1,11 @@ +import '../domain/inventory_item.dart'; +import '../domain/inventory_repository.dart'; + +/// Use case: create a new inventory item. +class CreateInventoryItem { + final InventoryRepository repository; + + CreateInventoryItem(this.repository); + + Future call(InventoryItem item) => repository.createItem(item); +} diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_controller.dart b/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_controller.dart index 65047b2..1925afb 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_controller.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_controller.dart @@ -1,18 +1,34 @@ import 'package:flutter/foundation.dart'; +import '../domain/inventory_adjustment_result.dart'; import '../domain/inventory_item.dart'; import '../domain/inventory_status.dart'; +import 'adjust_inventory_quantity.dart'; +import 'create_inventory_item.dart'; import 'get_inventory_items.dart'; +import 'update_inventory_item.dart'; /// Controller that manages the inventory workspace state, including -/// filtering by status, free-text search, and item selection. +/// filtering by status, free-text search, item selection, and write actions. class InventoryController extends ChangeNotifier { final GetInventoryItems _getInventoryItems; + final AdjustInventoryQuantity _adjustInventoryQuantity; + final CreateInventoryItem _createInventoryItem; + final UpdateInventoryItem _updateInventoryItem; - InventoryController(this._getInventoryItems); + InventoryController( + this._getInventoryItems, + this._adjustInventoryQuantity, + this._createInventoryItem, + this._updateInventoryItem, + ); bool isLoading = false; + bool isUpdating = false; Object? error; + /// The last write action result, or `null` if no write has been performed. + InventoryAdjustmentResult? lastActionResult; + /// All items returned by the repository (unfiltered). List _allItems = []; @@ -78,7 +94,83 @@ class InventoryController extends ChangeNotifier { return false; } - // ── Private helpers ──────────────────────────────────────────────────── + // ── Write actions ────────────────────────────────────────────────────────── + + /// Adjusts the quantity of the item with [id] by [delta]. + /// + /// A positive [delta] adds stock; a negative [delta] removes stock. + /// Sets [lastActionResult] and refreshes the list on success. + Future adjustQuantity(String id, int delta, String reason) async { + isUpdating = true; + lastActionResult = null; + notifyListeners(); + + try { + final result = await _adjustInventoryQuantity(id, delta, reason); + lastActionResult = result; + if (result.success) { + await _refreshItem(result.updatedItem!); + } + } catch (e) { + lastActionResult = InventoryAdjustmentResult.failure(e.toString()); + } finally { + isUpdating = false; + notifyListeners(); + } + } + + /// Creates a new inventory item and adds it to the list. + Future createItem(InventoryItem item) async { + isUpdating = true; + lastActionResult = null; + notifyListeners(); + + try { + final created = await _createInventoryItem(item); + _allItems = [..._allItems, created]; + _applyFilters(); + lastActionResult = InventoryAdjustmentResult.success(created); + } catch (e) { + lastActionResult = InventoryAdjustmentResult.failure(e.toString()); + } finally { + isUpdating = false; + notifyListeners(); + } + } + + /// Updates an existing inventory item and refreshes the list. + Future updateItem(InventoryItem item) async { + isUpdating = true; + lastActionResult = null; + notifyListeners(); + + try { + final updated = await _updateInventoryItem(item); + await _refreshItem(updated); + lastActionResult = InventoryAdjustmentResult.success(updated); + } catch (e) { + lastActionResult = InventoryAdjustmentResult.failure(e.toString()); + } finally { + isUpdating = false; + notifyListeners(); + } + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + /// Replaces the item in [_allItems] and re-applies filters. + /// Also refreshes [selectedItem] if it matches the updated item. + Future _refreshItem(InventoryItem updated) async { + // Update selectedItem BEFORE _applyFilters so the contains() check passes. + if (selectedItem?.id == updated.id) { + selectedItem = updated; + } + _allItems = [ + for (final i in _allItems) + if (i.id == updated.id) updated else i, + ]; + _applyFilters(); + } void _applyFilters() { var result = _allItems; diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/application/update_inventory_item.dart b/kell_creations_apps/packages/feature_inventory/lib/src/application/update_inventory_item.dart new file mode 100644 index 0000000..18143ea --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/application/update_inventory_item.dart @@ -0,0 +1,11 @@ +import '../domain/inventory_item.dart'; +import '../domain/inventory_repository.dart'; + +/// Use case: update an existing inventory item. +class UpdateInventoryItem { + final InventoryRepository repository; + + UpdateInventoryItem(this.repository); + + Future call(InventoryItem item) => repository.updateItem(item); +} diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/data/fake_inventory_repository.dart b/kell_creations_apps/packages/feature_inventory/lib/src/data/fake_inventory_repository.dart index 9d09ec7..b69d920 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/src/data/fake_inventory_repository.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/src/data/fake_inventory_repository.dart @@ -1,61 +1,191 @@ +import '../domain/inventory_adjustment_result.dart'; import '../domain/inventory_item.dart'; import '../domain/inventory_repository.dart'; import '../domain/inventory_status.dart'; class FakeInventoryRepository implements InventoryRepository { - @override - Future> getInventoryItems() async { - await Future.delayed(const Duration(milliseconds: 300)); + /// Mutable in-memory store — seeded on construction. + final List _items = _buildSeedData(); - return const [ + static List _buildSeedData() { + final now = DateTime(2026, 7, 1); + return [ InventoryItem( id: '1', sku: 'BC-FLR-001', name: 'Floral Bowl Cozy', + description: 'Hand-sewn fabric bowl cozy with floral print. Microwave safe.', + imageUrl: 'https://example.com/images/bc-flr-001.jpg', + category: 'Kitchen Accessories', quantityOnHand: 18, unitPrice: 12.99, + reorderPoint: 5, + supplier: 'Kell Creations', + location: 'Shelf A1', status: InventoryStatus.inStock, + lastUpdated: now, ), InventoryItem( id: '2', sku: 'CS-CIT-002', name: 'Citrus Coaster Set', + description: 'Set of 4 sublimated slate coasters with citrus fruit designs.', + imageUrl: 'https://example.com/images/cs-cit-002.jpg', + category: 'Home Decor', quantityOnHand: 7, unitPrice: 16.50, + reorderPoint: 10, + supplier: 'Kell Creations', + location: 'Shelf B2', status: InventoryStatus.lowStock, + lastUpdated: now, ), InventoryItem( id: '3', sku: 'NL-OCN-003', name: 'Ocean Nightlight', + description: 'Sublimated acrylic nightlight with ocean wave design.', + imageUrl: 'https://example.com/images/nl-ocn-003.jpg', + category: 'Home Decor', quantityOnHand: 0, unitPrice: 19.99, + reorderPoint: 3, + supplier: null, + location: 'Shelf C1', status: InventoryStatus.outOfStock, + lastUpdated: now, ), InventoryItem( id: '4', sku: 'JG-BLU-004', name: 'Fabric Jar Gripper', + description: 'Quilted fabric jar gripper for easy lid opening.', + imageUrl: 'https://example.com/images/jg-blu-004.jpg', + category: 'Kitchen Accessories', quantityOnHand: 23, unitPrice: 8.50, + reorderPoint: 8, + supplier: 'Kell Creations', + location: 'Shelf A2', status: InventoryStatus.inStock, + lastUpdated: now, ), InventoryItem( id: '5', sku: 'SH-SUN-005', name: 'Skillet Handle Sleeve', + description: 'Heat-resistant fabric sleeve for cast iron skillet handles.', + imageUrl: 'https://example.com/images/sh-sun-005.jpg', + category: 'Kitchen Accessories', quantityOnHand: 5, unitPrice: 10.99, + reorderPoint: 8, + supplier: 'Kell Creations', + location: null, status: InventoryStatus.lowStock, + lastUpdated: now, ), InventoryItem( id: '6', sku: 'SC-SUB-006', name: 'Sublimated Slate Coaster', + description: 'Single sublimated slate coaster — prototype/draft item.', + imageUrl: '', + category: 'Home Decor', quantityOnHand: 0, unitPrice: 14.99, + reorderPoint: 5, + supplier: null, + location: null, status: InventoryStatus.draft, + lastUpdated: now, ), ]; } + + @override + Future> getInventoryItems() async { + await Future.delayed(const Duration(milliseconds: 300)); + return List.unmodifiable(_items); + } + + @override + Future adjustQuantity(String id, int delta, String reason) async { + await Future.delayed(const Duration(milliseconds: 150)); + final index = _items.indexWhere((i) => i.id == id); + if (index == -1) { + return const InventoryAdjustmentResult.failure('Item not found'); + } + final item = _items[index]; + final newQty = item.quantityOnHand + delta; + if (newQty < 0) { + return const InventoryAdjustmentResult.failure('Quantity cannot go below zero'); + } + final updated = item.copyWith( + quantityOnHand: newQty, + status: _deriveStatus(newQty, item.reorderPoint), + lastUpdated: DateTime.now(), + ); + _items[index] = updated; + return InventoryAdjustmentResult.success(updated); + } + + @override + Future setQuantity(String id, int quantity, String reason) async { + await Future.delayed(const Duration(milliseconds: 150)); + if (quantity < 0) { + return const InventoryAdjustmentResult.failure('Quantity cannot be negative'); + } + final index = _items.indexWhere((i) => i.id == id); + if (index == -1) { + return const InventoryAdjustmentResult.failure('Item not found'); + } + final item = _items[index]; + final updated = item.copyWith( + quantityOnHand: quantity, + status: _deriveStatus(quantity, item.reorderPoint), + lastUpdated: DateTime.now(), + ); + _items[index] = updated; + return InventoryAdjustmentResult.success(updated); + } + + @override + Future createItem(InventoryItem item) async { + await Future.delayed(const Duration(milliseconds: 150)); + _items.add(item); + return item; + } + + @override + Future updateItem(InventoryItem item) async { + await Future.delayed(const Duration(milliseconds: 150)); + final index = _items.indexWhere((i) => i.id == item.id); + if (index == -1) { + throw StateError('Item with id ${item.id} not found'); + } + final updated = item.copyWith(lastUpdated: DateTime.now()); + _items[index] = updated; + return updated; + } + + @override + Future updateItemImage(String id, String imageUrl) async { + await Future.delayed(const Duration(milliseconds: 150)); + final index = _items.indexWhere((i) => i.id == id); + if (index == -1) { + return const InventoryAdjustmentResult.failure('Item not found'); + } + final updated = _items[index].copyWith(imageUrl: imageUrl, lastUpdated: DateTime.now()); + _items[index] = updated; + return InventoryAdjustmentResult.success(updated); + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + static InventoryStatus _deriveStatus(int quantity, int reorderPoint) { + if (quantity == 0) return InventoryStatus.outOfStock; + if (quantity <= reorderPoint) return InventoryStatus.lowStock; + return InventoryStatus.inStock; + } } diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_adjustment_result.dart b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_adjustment_result.dart new file mode 100644 index 0000000..e162c9d --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_adjustment_result.dart @@ -0,0 +1,23 @@ +import 'inventory_item.dart'; + +/// The result of an inventory quantity adjustment or write operation. +class InventoryAdjustmentResult { + /// Whether the operation succeeded. + final bool success; + + /// The updated [InventoryItem] after the operation, or `null` on failure. + final InventoryItem? updatedItem; + + /// A human-readable error message when [success] is `false`. + final String? errorMessage; + + const InventoryAdjustmentResult.success(InventoryItem item) + : success = true, + updatedItem = item, + errorMessage = null; + + const InventoryAdjustmentResult.failure(String message) + : success = false, + updatedItem = null, + errorMessage = message; +} diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart index 5e9e393..c8b70fa 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart @@ -1,19 +1,67 @@ import 'inventory_status.dart'; +/// A single inventory item tracked by the Kell Creations operations platform. class InventoryItem { final String id; final String sku; final String name; + final String description; + final String imageUrl; + final String category; final int quantityOnHand; final double unitPrice; + final int reorderPoint; + final String? supplier; + final String? location; final InventoryStatus status; + final DateTime lastUpdated; const InventoryItem({ required this.id, required this.sku, required this.name, + required this.description, + required this.imageUrl, + required this.category, required this.quantityOnHand, required this.unitPrice, + required this.reorderPoint, + this.supplier, + this.location, required this.status, + required this.lastUpdated, }); + + /// Returns a copy of this [InventoryItem] with the given fields replaced. + InventoryItem copyWith({ + String? id, + String? sku, + String? name, + String? description, + String? imageUrl, + String? category, + int? quantityOnHand, + double? unitPrice, + int? reorderPoint, + String? supplier, + String? location, + InventoryStatus? status, + DateTime? lastUpdated, + }) { + return InventoryItem( + id: id ?? this.id, + sku: sku ?? this.sku, + name: name ?? this.name, + description: description ?? this.description, + imageUrl: imageUrl ?? this.imageUrl, + category: category ?? this.category, + quantityOnHand: quantityOnHand ?? this.quantityOnHand, + unitPrice: unitPrice ?? this.unitPrice, + reorderPoint: reorderPoint ?? this.reorderPoint, + supplier: supplier ?? this.supplier, + location: location ?? this.location, + status: status ?? this.status, + lastUpdated: lastUpdated ?? this.lastUpdated, + ); + } } diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_repository.dart b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_repository.dart index f5c716f..3cd2d81 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_repository.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_repository.dart @@ -1,5 +1,27 @@ +import 'inventory_adjustment_result.dart'; import 'inventory_item.dart'; abstract class InventoryRepository { + /// Returns all inventory items. Future> getInventoryItems(); + + /// Adjusts the quantity of an item by [delta] (positive = add, negative = remove). + /// Returns an [InventoryAdjustmentResult] indicating success or failure. + Future adjustQuantity(String id, int delta, String reason); + + /// Sets the quantity of an item to an absolute [quantity]. + /// Returns an [InventoryAdjustmentResult] indicating success or failure. + Future setQuantity(String id, int quantity, String reason); + + /// Creates a new inventory item. + /// Returns the created [InventoryItem]. + Future createItem(InventoryItem item); + + /// Updates an existing inventory item with all fields from [item]. + /// Returns the updated [InventoryItem]. + Future updateItem(InventoryItem item); + + /// Updates the image URL for an inventory item. + /// Returns an [InventoryAdjustmentResult] indicating success or failure. + Future updateItemImage(String id, String imageUrl); } diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/presentation/inventory_page.dart b/kell_creations_apps/packages/feature_inventory/lib/src/presentation/inventory_page.dart index fab2842..8f1267d 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/src/presentation/inventory_page.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/src/presentation/inventory_page.dart @@ -1,8 +1,11 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import '../application/adjust_inventory_quantity.dart'; +import '../application/create_inventory_item.dart'; import '../application/get_inventory_items.dart'; import '../application/inventory_controller.dart'; +import '../application/update_inventory_item.dart'; import '../domain/inventory_repository.dart'; import 'widgets/inventory_item_card.dart'; @@ -41,7 +44,12 @@ class _InventoryPageState extends State { @override void initState() { super.initState(); - controller = InventoryController(GetInventoryItems(widget.repository)); + controller = InventoryController( + GetInventoryItems(widget.repository), + AdjustInventoryQuantity(widget.repository), + CreateInventoryItem(widget.repository), + UpdateInventoryItem(widget.repository), + ); // Apply any initial filter / query before loading. if (widget.initialFilter != null) { diff --git a/kell_creations_apps/packages/feature_inventory/pubspec.yaml b/kell_creations_apps/packages/feature_inventory/pubspec.yaml index 24490b2..1fdf208 100644 --- a/kell_creations_apps/packages/feature_inventory/pubspec.yaml +++ b/kell_creations_apps/packages/feature_inventory/pubspec.yaml @@ -1,5 +1,5 @@ name: feature_inventory -description: "A new Flutter package project." +description: "Inventory management feature for the Kell Creations operations platform. Provides domain model, repository contract, fake data layer, and write operations for inventory items." version: 0.0.1 publish_to: "none" homepage: diff --git a/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart b/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart index fbd261a..af108cd 100644 --- a/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart +++ b/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart @@ -1,10 +1,52 @@ +import 'package:feature_inventory/feature_inventory.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:feature_inventory/feature_inventory.dart'; -import 'package:feature_inventory/src/application/get_inventory_items.dart'; -import 'package:feature_inventory/src/application/inventory_controller.dart'; +// ── Helpers ──────────────────────────────────────────────────────────────── + +InventoryController _makeController(FakeInventoryRepository repo) { + return InventoryController( + GetInventoryItems(repo), + AdjustInventoryQuantity(repo), + CreateInventoryItem(repo), + UpdateInventoryItem(repo), + ); +} + +InventoryItem _makeItem({ + String id = 'test-1', + String sku = 'TEST-001', + String name = 'Test Item', + String description = 'A test item', + String imageUrl = '', + String category = 'Test Category', + int quantityOnHand = 10, + double unitPrice = 9.99, + int reorderPoint = 3, + String? supplier, + String? location, + InventoryStatus status = InventoryStatus.inStock, + DateTime? lastUpdated, +}) { + return InventoryItem( + id: id, + sku: sku, + name: name, + description: description, + imageUrl: imageUrl, + category: category, + quantityOnHand: quantityOnHand, + unitPrice: unitPrice, + reorderPoint: reorderPoint, + supplier: supplier, + location: location, + status: status, + lastUpdated: lastUpdated ?? DateTime(2026, 7, 1), + ); +} void main() { + // ── InventoryStatus ──────────────────────────────────────────────────────── + group('InventoryStatus', () { test('has four values', () { expect(InventoryStatus.values.length, 4); @@ -18,6 +60,88 @@ void main() { }); }); + // ── InventoryItem ────────────────────────────────────────────────────────── + + group('InventoryItem', () { + test('constructs with all required fields', () { + final item = _makeItem(); + expect(item.id, 'test-1'); + expect(item.sku, 'TEST-001'); + expect(item.name, 'Test Item'); + expect(item.description, 'A test item'); + expect(item.imageUrl, ''); + expect(item.category, 'Test Category'); + expect(item.quantityOnHand, 10); + expect(item.unitPrice, 9.99); + expect(item.reorderPoint, 3); + expect(item.supplier, isNull); + expect(item.location, isNull); + expect(item.status, InventoryStatus.inStock); + }); + + test('copyWith returns new instance with updated fields', () { + final original = _makeItem(); + final copy = original.copyWith(name: 'Updated', quantityOnHand: 20); + expect(copy.name, 'Updated'); + expect(copy.quantityOnHand, 20); + // Unchanged fields preserved + expect(copy.id, original.id); + expect(copy.sku, original.sku); + expect(copy.description, original.description); + expect(copy.category, original.category); + expect(copy.unitPrice, original.unitPrice); + expect(copy.reorderPoint, original.reorderPoint); + expect(copy.status, original.status); + }); + + test('copyWith preserves nullable fields when not specified', () { + final item = _makeItem(supplier: 'Kell Creations', location: 'Shelf A1'); + final copy = item.copyWith(name: 'New Name'); + expect(copy.supplier, 'Kell Creations'); + expect(copy.location, 'Shelf A1'); + }); + + test('copyWith can update nullable fields', () { + final item = _makeItem(); + final copy = item.copyWith(supplier: 'New Supplier', location: 'Bin 3'); + expect(copy.supplier, 'New Supplier'); + expect(copy.location, 'Bin 3'); + }); + + test('copyWith can update status', () { + final item = _makeItem(status: InventoryStatus.inStock); + final copy = item.copyWith(status: InventoryStatus.lowStock); + expect(copy.status, InventoryStatus.lowStock); + }); + + test('copyWith can update imageUrl', () { + final item = _makeItem(imageUrl: ''); + final copy = item.copyWith(imageUrl: 'https://example.com/img.jpg'); + expect(copy.imageUrl, 'https://example.com/img.jpg'); + }); + }); + + // ── InventoryAdjustmentResult ────────────────────────────────────────────── + + group('InventoryAdjustmentResult', () { + test('success result has correct fields', () { + final item = _makeItem(); + final result = InventoryAdjustmentResult.success(item); + expect(result.success, true); + expect(result.updatedItem, item); + expect(result.errorMessage, isNull); + }); + + test('failure result has correct fields', () { + const result = InventoryAdjustmentResult.failure('Something went wrong'); + expect(result.success, false); + expect(result.updatedItem, isNull); + expect(result.errorMessage, 'Something went wrong'); + }); + }); + + // ── FakeInventoryRepository ──────────────────────────────────────────────── + group('FakeInventoryRepository', () { late FakeInventoryRepository repository; @@ -37,15 +161,134 @@ void main() { expect(names, contains('Citrus Coaster Set')); expect(names, contains('Ocean Nightlight')); }); + + test('seed data includes expanded fields', () async { + final items = await repository.getInventoryItems(); + final first = items.first; + expect(first.description, isNotEmpty); + expect(first.category, isNotEmpty); + expect(first.reorderPoint, greaterThan(0)); + expect(first.lastUpdated, isNotNull); + }); + + test('seed data has items with supplier and location', () async { + final items = await repository.getInventoryItems(); + final withSupplier = items.where((i) => i.supplier != null).toList(); + expect(withSupplier, isNotEmpty); + }); + + test('adjustQuantity adds stock successfully', () async { + final result = await repository.adjustQuantity('1', 5, 'restock'); + expect(result.success, true); + expect(result.updatedItem!.quantityOnHand, 23); // 18 + 5 + }); + + test('adjustQuantity removes stock successfully', () async { + final result = await repository.adjustQuantity('1', -3, 'sold'); + expect(result.success, true); + expect(result.updatedItem!.quantityOnHand, 15); // 18 - 3 + }); + + test('adjustQuantity fails when result would be negative', () async { + final result = await repository.adjustQuantity('1', -100, 'error'); + expect(result.success, false); + expect(result.errorMessage, isNotEmpty); + }); + + test('adjustQuantity fails for unknown id', () async { + final result = await repository.adjustQuantity('UNKNOWN', 1, 'test'); + expect(result.success, false); + }); + + test('adjustQuantity updates status to outOfStock when qty reaches 0', () async { + // Item 3 has qty 0 already; use item 4 (qty 23, reorderPoint 8) + // Reduce to 0 + final result = await repository.adjustQuantity('4', -23, 'clear'); + expect(result.success, true); + expect(result.updatedItem!.status, InventoryStatus.outOfStock); + }); + + test('adjustQuantity updates status to lowStock when qty at or below reorderPoint', () async { + // Item 1: qty 18, reorderPoint 5 — reduce to 4 (below reorderPoint) + final result = await repository.adjustQuantity('1', -14, 'reduce'); + expect(result.success, true); + expect(result.updatedItem!.status, InventoryStatus.lowStock); + }); + + test('adjustQuantity updates status to inStock when qty above reorderPoint', () async { + // Item 2: qty 7, reorderPoint 10 — add enough to go above reorderPoint + final result = await repository.adjustQuantity('2', 10, 'restock'); + expect(result.success, true); + expect(result.updatedItem!.status, InventoryStatus.inStock); + }); + + test('setQuantity sets absolute quantity', () async { + final result = await repository.setQuantity('1', 50, 'inventory count'); + expect(result.success, true); + expect(result.updatedItem!.quantityOnHand, 50); + }); + + test('setQuantity fails for negative quantity', () async { + final result = await repository.setQuantity('1', -1, 'error'); + expect(result.success, false); + }); + + test('setQuantity fails for unknown id', () async { + final result = await repository.setQuantity('UNKNOWN', 10, 'test'); + expect(result.success, false); + }); + + test('createItem adds item to repository', () async { + final newItem = _makeItem(id: 'new-1', sku: 'NEW-001', name: 'New Item'); + await repository.createItem(newItem); + final items = await repository.getInventoryItems(); + expect(items.length, 7); + expect(items.any((i) => i.id == 'new-1'), true); + }); + + test('updateItem updates existing item', () async { + final items = await repository.getInventoryItems(); + final original = items.first; + final updated = original.copyWith(name: 'Updated Name', unitPrice: 99.99); + final result = await repository.updateItem(updated); + expect(result.name, 'Updated Name'); + expect(result.unitPrice, 99.99); + }); + + test('updateItem throws for unknown id', () async { + final unknown = _makeItem(id: 'UNKNOWN'); + expect(() => repository.updateItem(unknown), throwsStateError); + }); + + test('updateItemImage updates image URL', () async { + const newUrl = 'https://example.com/new-image.jpg'; + final result = await repository.updateItemImage('1', newUrl); + expect(result.success, true); + expect(result.updatedItem!.imageUrl, newUrl); + }); + + test('updateItemImage fails for unknown id', () async { + final result = await repository.updateItemImage('UNKNOWN', 'url'); + expect(result.success, false); + }); + + test('adjustQuantity persists change across calls', () async { + await repository.adjustQuantity('1', -5, 'sold'); + final items = await repository.getInventoryItems(); + final item = items.firstWhere((i) => i.id == '1'); + expect(item.quantityOnHand, 13); // 18 - 5 + }); }); + // ── InventoryController ──────────────────────────────────────────────────── + group('InventoryController', () { late FakeInventoryRepository repository; late InventoryController controller; setUp(() { repository = FakeInventoryRepository(); - controller = InventoryController(GetInventoryItems(repository)); + controller = _makeController(repository); }); tearDown(() { @@ -54,11 +297,13 @@ void main() { test('starts with empty state', () { expect(controller.isLoading, false); + expect(controller.isUpdating, false); expect(controller.items, isEmpty); expect(controller.selectedItem, isNull); expect(controller.activeFilter, isNull); expect(controller.searchQuery, ''); expect(controller.error, isNull); + expect(controller.lastActionResult, isNull); }); test('load populates items', () async { @@ -155,5 +400,85 @@ void main() { controller.setFilter('lowStock'); expect(controller.selectedItem, isNull); }); + + // ── Write action tests ───────────────────────────────────────────────── + + test('adjustQuantity succeeds and updates item in list', () async { + await controller.load(); + + await controller.adjustQuantity('1', 5, 'restock'); + + expect(controller.isUpdating, false); + expect(controller.lastActionResult, isNotNull); + expect(controller.lastActionResult!.success, true); + final item = controller.items.firstWhere((i) => i.id == '1'); + expect(item.quantityOnHand, 23); // 18 + 5 + }); + + test('adjustQuantity failure sets lastActionResult with error', () async { + await controller.load(); + + await controller.adjustQuantity('1', -1000, 'error'); + + expect(controller.lastActionResult!.success, false); + expect(controller.lastActionResult!.errorMessage, isNotEmpty); + }); + + test('adjustQuantity refreshes selectedItem when it is the adjusted item', () async { + await controller.load(); + controller.selectBySku('BC-FLR-001'); + expect(controller.selectedItem!.quantityOnHand, 18); + + await controller.adjustQuantity('1', 2, 'restock'); + + expect(controller.selectedItem!.quantityOnHand, 20); + }); + + test('createItem adds item to list', () async { + await controller.load(); + expect(controller.items.length, 6); + + final newItem = _makeItem(id: 'new-1', sku: 'NEW-001', name: 'New Item'); + await controller.createItem(newItem); + + expect(controller.items.length, 7); + expect(controller.lastActionResult!.success, true); + }); + + test('updateItem updates item in list', () async { + await controller.load(); + final original = controller.items.firstWhere((i) => i.id == '1'); + final updated = original.copyWith(name: 'Updated Name'); + + await controller.updateItem(updated); + + expect(controller.lastActionResult!.success, true); + final inList = controller.items.firstWhere((i) => i.id == '1'); + expect(inList.name, 'Updated Name'); + }); + + test('updateItem refreshes selectedItem when it is the updated item', () async { + await controller.load(); + controller.selectBySku('BC-FLR-001'); + final original = controller.selectedItem!; + final updated = original.copyWith(name: 'Refreshed Name'); + + await controller.updateItem(updated); + + expect(controller.selectedItem!.name, 'Refreshed Name'); + }); + + test('isUpdating is true during write and false after', () async { + await controller.load(); + + final states = []; + controller.addListener(() => states.add(controller.isUpdating)); + + await controller.adjustQuantity('1', 1, 'test'); + + // First notification: isUpdating = true, last: isUpdating = false + expect(states.first, true); + expect(states.last, false); + }); }); }