Merge feat/inventory-domain-expansion into main (Stage 5A)
Publish Docs / publish-docs (push) Successful in 1m37s
Details
Publish Docs / publish-docs (push) Successful in 1m37s
Details
This commit is contained in:
commit
b2df206123
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
- main baseline updated through: bulk-status-actions (Stage 7A complete)
|
- main baseline updated through: feat/inventory-domain-expansion (Stage 5A complete)
|
||||||
- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/integrations-contracts (Stage 8A)
|
- next branch: feat/inventory-page-ui (Stage 5B) or feat/orders-domain (Stage 5C)
|
||||||
- current stage: Stage 7A complete — deciding next slice
|
- current stage: Stage 5A complete — inventory domain expansion with write actions
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -242,9 +242,33 @@
|
||||||
- analyze: passed (info-level only — 8 pre-existing unnecessary_import in test files)
|
- analyze: passed (info-level only — 8 pre-existing unnecessary_import in test files)
|
||||||
- brief updated: yes
|
- 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
|
### chore/analyze-cleanup-and-tracker-sync
|
||||||
|
|
||||||
- status: in progress
|
- status: merged to main
|
||||||
- date: 2026-05-30
|
- date: 2026-05-30
|
||||||
- inspection: complete
|
- inspection: complete
|
||||||
- implementation: complete
|
- implementation: complete
|
||||||
|
|
|
||||||
|
|
@ -185,10 +185,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -246,10 +246,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.11"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -270,10 +270,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.2.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -185,10 +185,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -246,10 +246,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.11"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -270,10 +270,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.2.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,24 @@ class _StubInventoryRepository implements InventoryRepository {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<InventoryItem>> getInventoryItems() async => items;
|
Future<List<InventoryItem>> getInventoryItems() async => items;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason) =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryItem> createItem(InventoryItem item) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryItem> updateItem(InventoryItem item) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) =>
|
||||||
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StubProductPublishingRepository implements ProductPublishingRepository {
|
class _StubProductPublishingRepository implements ProductPublishingRepository {
|
||||||
|
|
@ -56,6 +74,52 @@ class _StubOrdersRepository implements OrdersRepository {
|
||||||
class _FailingInventoryRepository implements InventoryRepository {
|
class _FailingInventoryRepository implements InventoryRepository {
|
||||||
@override
|
@override
|
||||||
Future<List<InventoryItem>> getInventoryItems() => throw Exception('network error');
|
Future<List<InventoryItem>> getInventoryItems() => throw Exception('network error');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason) =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryItem> createItem(InventoryItem item) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryItem> updateItem(InventoryItem item) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryAdjustmentResult> 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 ──────────────────────────────────────────────────────────────────
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -78,7 +142,7 @@ void main() {
|
||||||
|
|
||||||
test('load() sets isLoading then populates summary', () async {
|
test('load() sets isLoading then populates summary', () async {
|
||||||
final inventoryItems = [
|
final inventoryItems = [
|
||||||
const InventoryItem(
|
_inv(
|
||||||
id: '1',
|
id: '1',
|
||||||
sku: 'A',
|
sku: 'A',
|
||||||
name: 'A',
|
name: 'A',
|
||||||
|
|
@ -86,7 +150,7 @@ void main() {
|
||||||
unitPrice: 5.0,
|
unitPrice: 5.0,
|
||||||
status: InventoryStatus.inStock,
|
status: InventoryStatus.inStock,
|
||||||
),
|
),
|
||||||
const InventoryItem(
|
_inv(
|
||||||
id: '2',
|
id: '2',
|
||||||
sku: 'B',
|
sku: 'B',
|
||||||
name: 'B',
|
name: 'B',
|
||||||
|
|
@ -138,7 +202,7 @@ void main() {
|
||||||
|
|
||||||
test('load() aggregates data from all three repositories', () async {
|
test('load() aggregates data from all three repositories', () async {
|
||||||
final inventoryItems = [
|
final inventoryItems = [
|
||||||
const InventoryItem(
|
_inv(
|
||||||
id: '1',
|
id: '1',
|
||||||
sku: 'A',
|
sku: 'A',
|
||||||
name: 'A',
|
name: 'A',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,34 @@ import 'package:feature_wordpress/feature_wordpress.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:kell_web/dashboard/domain/dashboard_summary.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() {
|
void main() {
|
||||||
group('DashboardSummary.empty', () {
|
group('DashboardSummary.empty', () {
|
||||||
test('has all zeroes', () {
|
test('has all zeroes', () {
|
||||||
|
|
@ -23,7 +51,7 @@ void main() {
|
||||||
group('DashboardSummary.fromData', () {
|
group('DashboardSummary.fromData', () {
|
||||||
test('counts inventory statuses correctly', () {
|
test('counts inventory statuses correctly', () {
|
||||||
final items = [
|
final items = [
|
||||||
const InventoryItem(
|
_inv(
|
||||||
id: '1',
|
id: '1',
|
||||||
sku: 'A',
|
sku: 'A',
|
||||||
name: 'A',
|
name: 'A',
|
||||||
|
|
@ -31,7 +59,7 @@ void main() {
|
||||||
unitPrice: 5.0,
|
unitPrice: 5.0,
|
||||||
status: InventoryStatus.inStock,
|
status: InventoryStatus.inStock,
|
||||||
),
|
),
|
||||||
const InventoryItem(
|
_inv(
|
||||||
id: '2',
|
id: '2',
|
||||||
sku: 'B',
|
sku: 'B',
|
||||||
name: 'B',
|
name: 'B',
|
||||||
|
|
@ -39,7 +67,7 @@ void main() {
|
||||||
unitPrice: 5.0,
|
unitPrice: 5.0,
|
||||||
status: InventoryStatus.lowStock,
|
status: InventoryStatus.lowStock,
|
||||||
),
|
),
|
||||||
const InventoryItem(
|
_inv(
|
||||||
id: '3',
|
id: '3',
|
||||||
sku: 'C',
|
sku: 'C',
|
||||||
name: 'C',
|
name: 'C',
|
||||||
|
|
@ -47,7 +75,7 @@ void main() {
|
||||||
unitPrice: 5.0,
|
unitPrice: 5.0,
|
||||||
status: InventoryStatus.outOfStock,
|
status: InventoryStatus.outOfStock,
|
||||||
),
|
),
|
||||||
const InventoryItem(
|
_inv(
|
||||||
id: '4',
|
id: '4',
|
||||||
sku: 'D',
|
sku: 'D',
|
||||||
name: 'D',
|
name: 'D',
|
||||||
|
|
@ -177,10 +205,9 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('computes full summary from fake repository data', () {
|
test('computes full summary from fake repository data', () {
|
||||||
// Use the same data the fake repositories return.
|
|
||||||
final summary = DashboardSummary.fromData(
|
final summary = DashboardSummary.fromData(
|
||||||
inventoryItems: const [
|
inventoryItems: [
|
||||||
InventoryItem(
|
_inv(
|
||||||
id: '1',
|
id: '1',
|
||||||
sku: 'BC-FLR-001',
|
sku: 'BC-FLR-001',
|
||||||
name: 'Floral Bowl Cozy',
|
name: 'Floral Bowl Cozy',
|
||||||
|
|
@ -188,15 +215,16 @@ void main() {
|
||||||
unitPrice: 12.99,
|
unitPrice: 12.99,
|
||||||
status: InventoryStatus.inStock,
|
status: InventoryStatus.inStock,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
_inv(
|
||||||
id: '2',
|
id: '2',
|
||||||
sku: 'CS-CIT-002',
|
sku: 'CS-CIT-002',
|
||||||
name: 'Citrus Coaster Set',
|
name: 'Citrus Coaster Set',
|
||||||
quantityOnHand: 7,
|
quantityOnHand: 7,
|
||||||
unitPrice: 16.50,
|
unitPrice: 16.50,
|
||||||
status: InventoryStatus.lowStock,
|
status: InventoryStatus.lowStock,
|
||||||
|
reorderPoint: 10,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
_inv(
|
||||||
id: '3',
|
id: '3',
|
||||||
sku: 'NL-OCN-003',
|
sku: 'NL-OCN-003',
|
||||||
name: 'Ocean Nightlight',
|
name: 'Ocean Nightlight',
|
||||||
|
|
@ -204,7 +232,7 @@ void main() {
|
||||||
unitPrice: 19.99,
|
unitPrice: 19.99,
|
||||||
status: InventoryStatus.outOfStock,
|
status: InventoryStatus.outOfStock,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
_inv(
|
||||||
id: '4',
|
id: '4',
|
||||||
sku: 'JG-BLU-004',
|
sku: 'JG-BLU-004',
|
||||||
name: 'Fabric Jar Gripper',
|
name: 'Fabric Jar Gripper',
|
||||||
|
|
@ -212,7 +240,7 @@ void main() {
|
||||||
unitPrice: 8.50,
|
unitPrice: 8.50,
|
||||||
status: InventoryStatus.inStock,
|
status: InventoryStatus.inStock,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
_inv(
|
||||||
id: '5',
|
id: '5',
|
||||||
sku: 'SH-SUN-005',
|
sku: 'SH-SUN-005',
|
||||||
name: 'Skillet Handle Sleeve',
|
name: 'Skillet Handle Sleeve',
|
||||||
|
|
@ -220,7 +248,7 @@ void main() {
|
||||||
unitPrice: 10.99,
|
unitPrice: 10.99,
|
||||||
status: InventoryStatus.lowStock,
|
status: InventoryStatus.lowStock,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
_inv(
|
||||||
id: '6',
|
id: '6',
|
||||||
sku: 'SC-SUB-006',
|
sku: 'SC-SUB-006',
|
||||||
name: 'Sublimated Slate Coaster',
|
name: 'Sublimated Slate Coaster',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
library;
|
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/data/fake_inventory_repository.dart';
|
||||||
|
export 'src/domain/inventory_adjustment_result.dart';
|
||||||
export 'src/domain/inventory_item.dart';
|
export 'src/domain/inventory_item.dart';
|
||||||
export 'src/domain/inventory_repository.dart';
|
export 'src/domain/inventory_repository.dart';
|
||||||
export 'src/domain/inventory_status.dart';
|
export 'src/domain/inventory_status.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<InventoryAdjustmentResult> call(String id, int delta, String reason) =>
|
||||||
|
repository.adjustQuantity(id, delta, reason);
|
||||||
|
}
|
||||||
|
|
@ -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<InventoryItem> call(InventoryItem item) => repository.createItem(item);
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,34 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../domain/inventory_adjustment_result.dart';
|
||||||
import '../domain/inventory_item.dart';
|
import '../domain/inventory_item.dart';
|
||||||
import '../domain/inventory_status.dart';
|
import '../domain/inventory_status.dart';
|
||||||
|
import 'adjust_inventory_quantity.dart';
|
||||||
|
import 'create_inventory_item.dart';
|
||||||
import 'get_inventory_items.dart';
|
import 'get_inventory_items.dart';
|
||||||
|
import 'update_inventory_item.dart';
|
||||||
|
|
||||||
/// Controller that manages the inventory workspace state, including
|
/// 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 {
|
class InventoryController extends ChangeNotifier {
|
||||||
final GetInventoryItems _getInventoryItems;
|
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 isLoading = false;
|
||||||
|
bool isUpdating = false;
|
||||||
Object? error;
|
Object? error;
|
||||||
|
|
||||||
|
/// The last write action result, or `null` if no write has been performed.
|
||||||
|
InventoryAdjustmentResult? lastActionResult;
|
||||||
|
|
||||||
/// All items returned by the repository (unfiltered).
|
/// All items returned by the repository (unfiltered).
|
||||||
List<InventoryItem> _allItems = [];
|
List<InventoryItem> _allItems = [];
|
||||||
|
|
||||||
|
|
@ -78,7 +94,83 @@ class InventoryController extends ChangeNotifier {
|
||||||
return false;
|
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<void> 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<void> 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<void> 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<void> _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() {
|
void _applyFilters() {
|
||||||
var result = _allItems;
|
var result = _allItems;
|
||||||
|
|
|
||||||
|
|
@ -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<InventoryItem> call(InventoryItem item) => repository.updateItem(item);
|
||||||
|
}
|
||||||
|
|
@ -1,61 +1,191 @@
|
||||||
|
import '../domain/inventory_adjustment_result.dart';
|
||||||
import '../domain/inventory_item.dart';
|
import '../domain/inventory_item.dart';
|
||||||
import '../domain/inventory_repository.dart';
|
import '../domain/inventory_repository.dart';
|
||||||
import '../domain/inventory_status.dart';
|
import '../domain/inventory_status.dart';
|
||||||
|
|
||||||
class FakeInventoryRepository implements InventoryRepository {
|
class FakeInventoryRepository implements InventoryRepository {
|
||||||
@override
|
/// Mutable in-memory store — seeded on construction.
|
||||||
Future<List<InventoryItem>> getInventoryItems() async {
|
final List<InventoryItem> _items = _buildSeedData();
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
|
||||||
|
|
||||||
return const [
|
static List<InventoryItem> _buildSeedData() {
|
||||||
|
final now = DateTime(2026, 7, 1);
|
||||||
|
return [
|
||||||
InventoryItem(
|
InventoryItem(
|
||||||
id: '1',
|
id: '1',
|
||||||
sku: 'BC-FLR-001',
|
sku: 'BC-FLR-001',
|
||||||
name: 'Floral Bowl Cozy',
|
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,
|
quantityOnHand: 18,
|
||||||
unitPrice: 12.99,
|
unitPrice: 12.99,
|
||||||
|
reorderPoint: 5,
|
||||||
|
supplier: 'Kell Creations',
|
||||||
|
location: 'Shelf A1',
|
||||||
status: InventoryStatus.inStock,
|
status: InventoryStatus.inStock,
|
||||||
|
lastUpdated: now,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
InventoryItem(
|
||||||
id: '2',
|
id: '2',
|
||||||
sku: 'CS-CIT-002',
|
sku: 'CS-CIT-002',
|
||||||
name: 'Citrus Coaster Set',
|
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,
|
quantityOnHand: 7,
|
||||||
unitPrice: 16.50,
|
unitPrice: 16.50,
|
||||||
|
reorderPoint: 10,
|
||||||
|
supplier: 'Kell Creations',
|
||||||
|
location: 'Shelf B2',
|
||||||
status: InventoryStatus.lowStock,
|
status: InventoryStatus.lowStock,
|
||||||
|
lastUpdated: now,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
InventoryItem(
|
||||||
id: '3',
|
id: '3',
|
||||||
sku: 'NL-OCN-003',
|
sku: 'NL-OCN-003',
|
||||||
name: 'Ocean Nightlight',
|
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,
|
quantityOnHand: 0,
|
||||||
unitPrice: 19.99,
|
unitPrice: 19.99,
|
||||||
|
reorderPoint: 3,
|
||||||
|
supplier: null,
|
||||||
|
location: 'Shelf C1',
|
||||||
status: InventoryStatus.outOfStock,
|
status: InventoryStatus.outOfStock,
|
||||||
|
lastUpdated: now,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
InventoryItem(
|
||||||
id: '4',
|
id: '4',
|
||||||
sku: 'JG-BLU-004',
|
sku: 'JG-BLU-004',
|
||||||
name: 'Fabric Jar Gripper',
|
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,
|
quantityOnHand: 23,
|
||||||
unitPrice: 8.50,
|
unitPrice: 8.50,
|
||||||
|
reorderPoint: 8,
|
||||||
|
supplier: 'Kell Creations',
|
||||||
|
location: 'Shelf A2',
|
||||||
status: InventoryStatus.inStock,
|
status: InventoryStatus.inStock,
|
||||||
|
lastUpdated: now,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
InventoryItem(
|
||||||
id: '5',
|
id: '5',
|
||||||
sku: 'SH-SUN-005',
|
sku: 'SH-SUN-005',
|
||||||
name: 'Skillet Handle Sleeve',
|
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,
|
quantityOnHand: 5,
|
||||||
unitPrice: 10.99,
|
unitPrice: 10.99,
|
||||||
|
reorderPoint: 8,
|
||||||
|
supplier: 'Kell Creations',
|
||||||
|
location: null,
|
||||||
status: InventoryStatus.lowStock,
|
status: InventoryStatus.lowStock,
|
||||||
|
lastUpdated: now,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
InventoryItem(
|
||||||
id: '6',
|
id: '6',
|
||||||
sku: 'SC-SUB-006',
|
sku: 'SC-SUB-006',
|
||||||
name: 'Sublimated Slate Coaster',
|
name: 'Sublimated Slate Coaster',
|
||||||
|
description: 'Single sublimated slate coaster — prototype/draft item.',
|
||||||
|
imageUrl: '',
|
||||||
|
category: 'Home Decor',
|
||||||
quantityOnHand: 0,
|
quantityOnHand: 0,
|
||||||
unitPrice: 14.99,
|
unitPrice: 14.99,
|
||||||
|
reorderPoint: 5,
|
||||||
|
supplier: null,
|
||||||
|
location: null,
|
||||||
status: InventoryStatus.draft,
|
status: InventoryStatus.draft,
|
||||||
|
lastUpdated: now,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<InventoryItem>> getInventoryItems() async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
return List.unmodifiable(_items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) async {
|
||||||
|
await Future<void>.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<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason) async {
|
||||||
|
await Future<void>.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<InventoryItem> createItem(InventoryItem item) async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||||
|
_items.add(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryItem> updateItem(InventoryItem item) async {
|
||||||
|
await Future<void>.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<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) async {
|
||||||
|
await Future<void>.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,67 @@
|
||||||
import 'inventory_status.dart';
|
import 'inventory_status.dart';
|
||||||
|
|
||||||
|
/// A single inventory item tracked by the Kell Creations operations platform.
|
||||||
class InventoryItem {
|
class InventoryItem {
|
||||||
final String id;
|
final String id;
|
||||||
final String sku;
|
final String sku;
|
||||||
final String name;
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final String imageUrl;
|
||||||
|
final String category;
|
||||||
final int quantityOnHand;
|
final int quantityOnHand;
|
||||||
final double unitPrice;
|
final double unitPrice;
|
||||||
|
final int reorderPoint;
|
||||||
|
final String? supplier;
|
||||||
|
final String? location;
|
||||||
final InventoryStatus status;
|
final InventoryStatus status;
|
||||||
|
final DateTime lastUpdated;
|
||||||
|
|
||||||
const InventoryItem({
|
const InventoryItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.sku,
|
required this.sku,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.category,
|
||||||
required this.quantityOnHand,
|
required this.quantityOnHand,
|
||||||
required this.unitPrice,
|
required this.unitPrice,
|
||||||
|
required this.reorderPoint,
|
||||||
|
this.supplier,
|
||||||
|
this.location,
|
||||||
required this.status,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,27 @@
|
||||||
|
import 'inventory_adjustment_result.dart';
|
||||||
import 'inventory_item.dart';
|
import 'inventory_item.dart';
|
||||||
|
|
||||||
abstract class InventoryRepository {
|
abstract class InventoryRepository {
|
||||||
|
/// Returns all inventory items.
|
||||||
Future<List<InventoryItem>> getInventoryItems();
|
Future<List<InventoryItem>> getInventoryItems();
|
||||||
|
|
||||||
|
/// Adjusts the quantity of an item by [delta] (positive = add, negative = remove).
|
||||||
|
/// Returns an [InventoryAdjustmentResult] indicating success or failure.
|
||||||
|
Future<InventoryAdjustmentResult> 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<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason);
|
||||||
|
|
||||||
|
/// Creates a new inventory item.
|
||||||
|
/// Returns the created [InventoryItem].
|
||||||
|
Future<InventoryItem> createItem(InventoryItem item);
|
||||||
|
|
||||||
|
/// Updates an existing inventory item with all fields from [item].
|
||||||
|
/// Returns the updated [InventoryItem].
|
||||||
|
Future<InventoryItem> updateItem(InventoryItem item);
|
||||||
|
|
||||||
|
/// Updates the image URL for an inventory item.
|
||||||
|
/// Returns an [InventoryAdjustmentResult] indicating success or failure.
|
||||||
|
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.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/get_inventory_items.dart';
|
||||||
import '../application/inventory_controller.dart';
|
import '../application/inventory_controller.dart';
|
||||||
|
import '../application/update_inventory_item.dart';
|
||||||
import '../domain/inventory_repository.dart';
|
import '../domain/inventory_repository.dart';
|
||||||
import 'widgets/inventory_item_card.dart';
|
import 'widgets/inventory_item_card.dart';
|
||||||
|
|
||||||
|
|
@ -41,7 +44,12 @@ class _InventoryPageState extends State<InventoryPage> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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.
|
// Apply any initial filter / query before loading.
|
||||||
if (widget.initialFilter != null) {
|
if (widget.initialFilter != null) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
name: feature_inventory
|
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
|
version: 0.0.1
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
homepage:
|
homepage:
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,52 @@
|
||||||
|
import 'package:feature_inventory/feature_inventory.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
import 'package:feature_inventory/src/application/get_inventory_items.dart';
|
|
||||||
import 'package:feature_inventory/src/application/inventory_controller.dart';
|
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() {
|
void main() {
|
||||||
|
// ── InventoryStatus ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
group('InventoryStatus', () {
|
group('InventoryStatus', () {
|
||||||
test('has four values', () {
|
test('has four values', () {
|
||||||
expect(InventoryStatus.values.length, 4);
|
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', () {
|
group('FakeInventoryRepository', () {
|
||||||
late FakeInventoryRepository repository;
|
late FakeInventoryRepository repository;
|
||||||
|
|
||||||
|
|
@ -37,15 +161,134 @@ void main() {
|
||||||
expect(names, contains('Citrus Coaster Set'));
|
expect(names, contains('Citrus Coaster Set'));
|
||||||
expect(names, contains('Ocean Nightlight'));
|
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', () {
|
group('InventoryController', () {
|
||||||
late FakeInventoryRepository repository;
|
late FakeInventoryRepository repository;
|
||||||
late InventoryController controller;
|
late InventoryController controller;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
repository = FakeInventoryRepository();
|
repository = FakeInventoryRepository();
|
||||||
controller = InventoryController(GetInventoryItems(repository));
|
controller = _makeController(repository);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
|
|
@ -54,11 +297,13 @@ void main() {
|
||||||
|
|
||||||
test('starts with empty state', () {
|
test('starts with empty state', () {
|
||||||
expect(controller.isLoading, false);
|
expect(controller.isLoading, false);
|
||||||
|
expect(controller.isUpdating, false);
|
||||||
expect(controller.items, isEmpty);
|
expect(controller.items, isEmpty);
|
||||||
expect(controller.selectedItem, isNull);
|
expect(controller.selectedItem, isNull);
|
||||||
expect(controller.activeFilter, isNull);
|
expect(controller.activeFilter, isNull);
|
||||||
expect(controller.searchQuery, '');
|
expect(controller.searchQuery, '');
|
||||||
expect(controller.error, isNull);
|
expect(controller.error, isNull);
|
||||||
|
expect(controller.lastActionResult, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('load populates items', () async {
|
test('load populates items', () async {
|
||||||
|
|
@ -155,5 +400,85 @@ void main() {
|
||||||
controller.setFilter('lowStock');
|
controller.setFilter('lowStock');
|
||||||
expect(controller.selectedItem, isNull);
|
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 = <bool>[];
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue