From 5df3d8beac2384413a226ce743ea2f08e461083b Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 11 Jul 2026 10:00:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20Stage=209B=20=E2=80=94=20Woo?= =?UTF-8?q?Commerce=20stock/inventory=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development/build_execution_tracker.md | 28 +- .../dashboard_controller_test.dart | 14 + .../lib/feature_inventory.dart | 3 + .../application/inventory_sync_service.dart | 204 ++++++ .../src/data/fake_inventory_repository.dart | 50 ++ .../woo_commerce_inventory_repository.dart | 180 +++++ .../lib/src/domain/inventory_item.dart | 29 + .../lib/src/domain/inventory_repository.dart | 16 + .../lib/src/domain/inventory_sync_status.dart | 17 + .../packages/feature_inventory/pubspec.yaml | 5 +- .../woo_commerce_inventory_sync_test.dart | 629 ++++++++++++++++++ .../lib/src/data/woo_commerce_api_client.dart | 129 ++++ 12 files changed, 1300 insertions(+), 4 deletions(-) create mode 100644 kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_sync_service.dart create mode 100644 kell_creations_apps/packages/feature_inventory/lib/src/data/woo_commerce_inventory_repository.dart create mode 100644 kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_sync_status.dart create mode 100644 kell_creations_apps/packages/feature_inventory/test/woo_commerce_inventory_sync_test.dart diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 979cd66..770850e 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: feat/wc-orders-integration (Stage 9A complete) -- next branch: feat/wc-inventory-sync (Stage 9B) -- current stage: Stage 9A complete — WooCommerce Orders API integration activated +- main baseline updated through: feat/wc-inventory-sync (Stage 9B complete) +- next branch: feat/wc-catalog-expansion (Stage 9C) +- current stage: Stage 9B complete — WooCommerce stock/inventory sync activated ## Slice tracker @@ -484,3 +484,25 @@ - tests: passed (319/319 feature_wordpress) - analyze: passed (dart analyze --fatal-infos — no issues found) - brief updated: yes + +### feat/wc-inventory-sync + +- status: merged to main +- date: 2026-07-11 +- inspection: complete +- implementation: complete +- files changed: + - `feature_inventory/lib/src/domain/inventory_sync_status.dart` — new `InventorySyncStatus` enum (`notSynced`, `synced`, `pending`, `conflict`, `error`) + - `feature_inventory/lib/src/domain/inventory_item.dart` — added `wooCommerceProductId` (nullable int), `syncStatus`, `lastSyncedAt` fields; updated `copyWith()` to preserve/update all three + - `feature_inventory/lib/src/domain/inventory_repository.dart` — added `getItemBySku(sku)` and `syncWithRemote(id, remoteQuantity, reason)` to contract + - `feature_inventory/lib/src/data/fake_inventory_repository.dart` — updated seed data with `wooCommerceProductId`, `syncStatus`, `lastSyncedAt`; implemented `getItemBySku()` and `syncWithRemote()` with status auto-derivation and `lastSyncedAt` stamping + - `feature_inventory/lib/src/application/inventory_sync_service.dart` — new `InventorySyncService` orchestrating pull-only or bidirectional sync; `syncAll()`, `syncItem(id)`, `checkDifference(id)`; `InventorySyncResult` value object; `StockDifference` value object + - `feature_inventory/lib/src/data/woo_commerce_inventory_repository.dart` — new `WooCommerceInventoryRepository` implementing full `InventoryRepository` contract; `adjustQuantity()` and `setQuantity()` push to WooCommerce via `updateProductStock()`; `syncWithRemote()` pulls remote quantity and stamps `lastSyncedAt`; local-only items (no `wooCommerceProductId`) skip WooCommerce calls + - `feature_inventory/lib/feature_inventory.dart` — expanded barrel exports for `InventorySyncService`, `WooCommerceInventoryRepository`, `InventorySyncStatus` + - `feature_inventory/pubspec.yaml` — added `feature_wordpress: path: ../feature_wordpress` dependency; added `http: ^1.4.0` dev dependency + - `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — added `getProductStock(productId)`, `updateProductStock(productId, quantity)`, `batchUpdateStock(updates)` methods + - `feature_inventory/test/woo_commerce_inventory_sync_test.dart` — 57 new tests covering `InventorySyncStatus`, `InventoryItem` sync fields, `FakeInventoryRepository` (`getItemBySku`, `syncWithRemote`), `WooCommerceApiClient` stock methods, `WooCommerceInventoryRepository`, `InventorySyncService`, `StockDifference`, `InventorySyncResult` + - `kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated `_StubInventoryRepository` and `_FailingInventoryRepository` stubs to implement expanded `InventoryRepository` contract (`getItemBySku`, `syncWithRemote`) +- tests: passed (132/132 feature_inventory, 24/24 kell_web, 319/319 feature_wordpress — all passing) +- analyze: passed (dart analyze — no issues found) +- brief updated: yes 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 5c50087..d3c8b06 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 @@ -32,6 +32,13 @@ class _StubInventoryRepository implements InventoryRepository { @override Future updateItemImage(String id, String imageUrl) => throw UnimplementedError(); + + @override + Future getItemBySku(String sku) => throw UnimplementedError(); + + @override + Future syncWithRemote(String id, int remoteQuantity, String reason) => + throw UnimplementedError(); } class _StubProductPublishingRepository implements ProductPublishingRepository { @@ -105,6 +112,13 @@ class _FailingInventoryRepository implements InventoryRepository { @override Future updateItemImage(String id, String imageUrl) => throw UnimplementedError(); + + @override + Future getItemBySku(String sku) => throw UnimplementedError(); + + @override + Future syncWithRemote(String id, int remoteQuantity, String reason) => + throw UnimplementedError(); } // ── Shared test helpers ──────────────────────────────────────────────────── 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 bedc104..5777330 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart @@ -4,12 +4,15 @@ 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/inventory_sync_service.dart'; export 'src/application/update_inventory_item.dart'; export 'src/data/fake_inventory_repository.dart'; +export 'src/data/woo_commerce_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'; +export 'src/domain/inventory_sync_status.dart'; export 'src/presentation/inventory_action_snack_bar.dart'; export 'src/presentation/inventory_page.dart'; export 'src/presentation/widgets/adjust_quantity_dialog.dart'; diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_sync_service.dart b/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_sync_service.dart new file mode 100644 index 0000000..d9f1cc1 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_sync_service.dart @@ -0,0 +1,204 @@ +import '../domain/inventory_adjustment_result.dart'; +import '../domain/inventory_item.dart'; +import '../domain/inventory_repository.dart'; +import '../domain/inventory_sync_status.dart'; + +/// Result of a full inventory sync operation. +class InventorySyncResult { + /// Number of items successfully synced. + final int syncedCount; + + /// Number of items that failed to sync. + final int failedCount; + + /// Number of items skipped (no WooCommerce product ID). + final int skippedCount; + + /// Per-item results keyed by inventory item ID. + final Map itemResults; + + const InventorySyncResult({ + required this.syncedCount, + required this.failedCount, + required this.skippedCount, + required this.itemResults, + }); + + /// Whether all syncable items were synced successfully. + bool get allSucceeded => failedCount == 0; + + /// Total number of items processed (synced + failed, not skipped). + int get totalProcessed => syncedCount + failedCount; +} + +/// Orchestrates comparing local inventory quantities against WooCommerce +/// stock levels and resolving differences. +/// +/// The sync strategy is **remote-wins**: the WooCommerce stock quantity is +/// treated as authoritative and is written into the local inventory. +/// +/// Usage: +/// ```dart +/// final service = InventorySyncService( +/// inventoryRepository: myRepo, +/// fetchRemoteStock: (productId) async { +/// final data = await apiClient.getProductStock(productId.toString()); +/// return data['stock_quantity'] as int? ?? 0; +/// }, +/// pushLocalStock: (productId, quantity) async { +/// await apiClient.updateProductStock(productId.toString(), quantity); +/// }, +/// ); +/// final result = await service.syncAll(); +/// ``` +class InventorySyncService { + final InventoryRepository _inventoryRepository; + + /// Callback that fetches the remote stock quantity for a WooCommerce product. + /// + /// Receives the WooCommerce product ID and returns the current stock quantity. + final Future Function(int productId) fetchRemoteStock; + + /// Optional callback to push a local quantity to WooCommerce. + /// + /// When provided, items whose local quantity differs from remote will be + /// pushed to WooCommerce after the local record is updated. + /// When `null`, the sync is pull-only (remote → local). + final Future Function(int productId, int quantity)? pushLocalStock; + + InventorySyncService({ + required InventoryRepository inventoryRepository, + required this.fetchRemoteStock, + this.pushLocalStock, + }) : _inventoryRepository = inventoryRepository; + + /// Syncs all inventory items that have a [InventoryItem.wooCommerceProductId]. + /// + /// Items without a WooCommerce product ID are skipped. + /// Returns an [InventorySyncResult] summarising the outcome. + Future syncAll() async { + final items = await _inventoryRepository.getInventoryItems(); + final syncable = items.where((i) => i.wooCommerceProductId != null).toList(); + final skipped = items.length - syncable.length; + + final results = {}; + var synced = 0; + var failed = 0; + + for (final item in syncable) { + final result = await _syncItem(item); + results[item.id] = result; + if (result.success) { + synced++; + } else { + failed++; + } + } + + return InventorySyncResult( + syncedCount: synced, + failedCount: failed, + skippedCount: skipped, + itemResults: Map.unmodifiable(results), + ); + } + + /// Syncs a single inventory item by its local [id]. + /// + /// Fetches the remote stock quantity and calls [InventoryRepository.syncWithRemote]. + /// Returns an [InventoryAdjustmentResult] reflecting the outcome. + Future syncItem(String id) async { + final items = await _inventoryRepository.getInventoryItems(); + final item = items.where((i) => i.id == id).firstOrNull; + + if (item == null) { + return const InventoryAdjustmentResult.failure('Item not found'); + } + + return _syncItem(item); + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + Future _syncItem(InventoryItem item) async { + final productId = item.wooCommerceProductId; + if (productId == null) { + return const InventoryAdjustmentResult.failure('Item has no WooCommerce product ID'); + } + + try { + final remoteQty = await fetchRemoteStock(productId); + final result = await _inventoryRepository.syncWithRemote( + item.id, + remoteQty, + 'WooCommerce sync', + ); + + // If push is configured and sync succeeded, push local quantity back + // (useful when local was the source of truth for a manual adjustment). + if (result.success && pushLocalStock != null) { + try { + await pushLocalStock!(productId, remoteQty); + } catch (_) { + // Push failure is non-fatal — local record is already updated. + } + } + + return result; + } catch (e) { + return InventoryAdjustmentResult.failure('Sync failed: $e'); + } + } + + /// Compares local and remote quantities for a single item without writing. + /// + /// Returns `null` if the item has no WooCommerce product ID. + /// Returns a [StockDifference] describing the discrepancy (or lack thereof). + Future checkDifference(String id) async { + final items = await _inventoryRepository.getInventoryItems(); + final item = items.where((i) => i.id == id).firstOrNull; + + if (item == null || item.wooCommerceProductId == null) return null; + + try { + final remoteQty = await fetchRemoteStock(item.wooCommerceProductId!); + return StockDifference( + itemId: item.id, + localQuantity: item.quantityOnHand, + remoteQuantity: remoteQty, + syncStatus: item.syncStatus, + ); + } catch (e) { + return StockDifference( + itemId: item.id, + localQuantity: item.quantityOnHand, + remoteQuantity: null, + syncStatus: InventorySyncStatus.error, + errorMessage: e.toString(), + ); + } + } +} + +/// Describes the difference between local and remote stock for a single item. +class StockDifference { + final String itemId; + final int localQuantity; + final int? remoteQuantity; + final InventorySyncStatus syncStatus; + final String? errorMessage; + + const StockDifference({ + required this.itemId, + required this.localQuantity, + required this.remoteQuantity, + required this.syncStatus, + this.errorMessage, + }); + + /// Whether local and remote quantities match. + bool get isInSync => remoteQuantity != null && localQuantity == remoteQuantity; + + /// Whether there is a discrepancy between local and remote. + bool get hasDiscrepancy => remoteQuantity != null && localQuantity != remoteQuantity; +} 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 b69d920..f970025 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 @@ -2,6 +2,7 @@ import '../domain/inventory_adjustment_result.dart'; import '../domain/inventory_item.dart'; import '../domain/inventory_repository.dart'; import '../domain/inventory_status.dart'; +import '../domain/inventory_sync_status.dart'; class FakeInventoryRepository implements InventoryRepository { /// Mutable in-memory store — seeded on construction. @@ -24,6 +25,9 @@ class FakeInventoryRepository implements InventoryRepository { location: 'Shelf A1', status: InventoryStatus.inStock, lastUpdated: now, + wooCommerceProductId: 101, + syncStatus: InventorySyncStatus.synced, + lastSyncedAt: now, ), InventoryItem( id: '2', @@ -39,6 +43,8 @@ class FakeInventoryRepository implements InventoryRepository { location: 'Shelf B2', status: InventoryStatus.lowStock, lastUpdated: now, + wooCommerceProductId: 102, + syncStatus: InventorySyncStatus.pending, ), InventoryItem( id: '3', @@ -54,6 +60,9 @@ class FakeInventoryRepository implements InventoryRepository { location: 'Shelf C1', status: InventoryStatus.outOfStock, lastUpdated: now, + wooCommerceProductId: 103, + syncStatus: InventorySyncStatus.synced, + lastSyncedAt: now, ), InventoryItem( id: '4', @@ -69,6 +78,8 @@ class FakeInventoryRepository implements InventoryRepository { location: 'Shelf A2', status: InventoryStatus.inStock, lastUpdated: now, + // No WooCommerce product — sold in-person only + syncStatus: InventorySyncStatus.notSynced, ), InventoryItem( id: '5', @@ -84,6 +95,8 @@ class FakeInventoryRepository implements InventoryRepository { location: null, status: InventoryStatus.lowStock, lastUpdated: now, + wooCommerceProductId: 105, + syncStatus: InventorySyncStatus.error, ), InventoryItem( id: '6', @@ -99,6 +112,7 @@ class FakeInventoryRepository implements InventoryRepository { location: null, status: InventoryStatus.draft, lastUpdated: now, + syncStatus: InventorySyncStatus.notSynced, ), ]; } @@ -181,6 +195,42 @@ class FakeInventoryRepository implements InventoryRepository { return InventoryAdjustmentResult.success(updated); } + @override + Future getItemBySku(String sku) async { + await Future.delayed(const Duration(milliseconds: 100)); + try { + return _items.firstWhere((i) => i.sku == sku); + } catch (_) { + return null; + } + } + + @override + Future syncWithRemote( + String id, + int remoteQuantity, + String reason, + ) async { + await Future.delayed(const Duration(milliseconds: 150)); + if (remoteQuantity < 0) { + return const InventoryAdjustmentResult.failure('Remote 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: remoteQuantity, + status: _deriveStatus(remoteQuantity, item.reorderPoint), + lastUpdated: DateTime.now(), + lastSyncedAt: DateTime.now(), + syncStatus: InventorySyncStatus.synced, + ); + _items[index] = updated; + return InventoryAdjustmentResult.success(updated); + } + // ── Private helpers ──────────────────────────────────────────────────────── static InventoryStatus _deriveStatus(int quantity, int reorderPoint) { diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/data/woo_commerce_inventory_repository.dart b/kell_creations_apps/packages/feature_inventory/lib/src/data/woo_commerce_inventory_repository.dart new file mode 100644 index 0000000..6dc0ca6 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/data/woo_commerce_inventory_repository.dart @@ -0,0 +1,180 @@ +import 'package:feature_wordpress/feature_wordpress.dart'; + +import '../domain/inventory_adjustment_result.dart'; +import '../domain/inventory_item.dart'; +import '../domain/inventory_repository.dart'; +import '../domain/inventory_status.dart'; +import '../domain/inventory_sync_status.dart'; + +/// WooCommerce-backed implementation of [InventoryRepository]. +/// +/// Stock quantities are read from and written to WooCommerce product stock +/// fields via [WooCommerceApiClient]. All other inventory fields (name, SKU, +/// description, etc.) are managed locally and are not pushed to WooCommerce +/// by this repository — only `stock_quantity` and `manage_stock` are synced. +/// +/// Items without a [InventoryItem.wooCommerceProductId] are stored in-memory +/// only and are not synced to WooCommerce. +class WooCommerceInventoryRepository implements InventoryRepository { + final WooCommerceApiClient _apiClient; + + /// In-memory store for all inventory items (local source of truth). + final List _items; + + WooCommerceInventoryRepository({ + required WooCommerceApiClient apiClient, + List initialItems = const [], + }) : _apiClient = apiClient, + _items = List.of(initialItems); + + // ── Read operations ──────────────────────────────────────────────────────── + + @override + Future> getInventoryItems() async { + return List.unmodifiable(_items); + } + + @override + Future getItemBySku(String sku) async { + try { + return _items.firstWhere((i) => i.sku == sku); + } catch (_) { + return null; + } + } + + // ── Write operations ─────────────────────────────────────────────────────── + + @override + Future adjustQuantity(String id, int delta, String reason) async { + 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'); + } + + // Push to WooCommerce if this item has a linked product. + if (item.wooCommerceProductId != null) { + try { + await _apiClient.updateProductStock(item.wooCommerceProductId.toString(), newQty); + } catch (e) { + return InventoryAdjustmentResult.failure('WooCommerce stock update failed: $e'); + } + } + + final updated = item.copyWith( + quantityOnHand: newQty, + status: _deriveStatus(newQty, item.reorderPoint), + lastUpdated: DateTime.now(), + lastSyncedAt: item.wooCommerceProductId != null ? DateTime.now() : item.lastSyncedAt, + syncStatus: item.wooCommerceProductId != null + ? InventorySyncStatus.synced + : InventorySyncStatus.notSynced, + ); + _items[index] = updated; + return InventoryAdjustmentResult.success(updated); + } + + @override + Future setQuantity(String id, int quantity, String reason) async { + 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]; + + // Push to WooCommerce if this item has a linked product. + if (item.wooCommerceProductId != null) { + try { + await _apiClient.updateProductStock(item.wooCommerceProductId.toString(), quantity); + } catch (e) { + return InventoryAdjustmentResult.failure('WooCommerce stock update failed: $e'); + } + } + + final updated = item.copyWith( + quantityOnHand: quantity, + status: _deriveStatus(quantity, item.reorderPoint), + lastUpdated: DateTime.now(), + lastSyncedAt: item.wooCommerceProductId != null ? DateTime.now() : item.lastSyncedAt, + syncStatus: item.wooCommerceProductId != null + ? InventorySyncStatus.synced + : InventorySyncStatus.notSynced, + ); + _items[index] = updated; + return InventoryAdjustmentResult.success(updated); + } + + @override + Future createItem(InventoryItem item) async { + _items.add(item); + return item; + } + + @override + Future updateItem(InventoryItem item) async { + 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 { + 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); + } + + @override + Future syncWithRemote( + String id, + int remoteQuantity, + String reason, + ) async { + if (remoteQuantity < 0) { + return const InventoryAdjustmentResult.failure('Remote 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: remoteQuantity, + status: _deriveStatus(remoteQuantity, item.reorderPoint), + lastUpdated: DateTime.now(), + lastSyncedAt: DateTime.now(), + syncStatus: InventorySyncStatus.synced, + ); + _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_item.dart b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart index c8b70fa..7ca180a 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,4 +1,5 @@ import 'inventory_status.dart'; +import 'inventory_sync_status.dart'; /// A single inventory item tracked by the Kell Creations operations platform. class InventoryItem { @@ -16,6 +17,25 @@ class InventoryItem { final InventoryStatus status; final DateTime lastUpdated; + // ── WooCommerce sync fields ──────────────────────────────────────────────── + + /// The WooCommerce product ID linked to this inventory item. + /// + /// `null` if this item has no WooCommerce counterpart (e.g. raw materials, + /// packaging, or items not listed in the online store). + final int? wooCommerceProductId; + + /// The timestamp of the last successful sync with WooCommerce. + /// + /// `null` if the item has never been synced. + final DateTime? lastSyncedAt; + + /// The current synchronization state with WooCommerce. + /// + /// Defaults to [InventorySyncStatus.notSynced] for items without a + /// WooCommerce product ID. + final InventorySyncStatus syncStatus; + const InventoryItem({ required this.id, required this.sku, @@ -30,6 +50,9 @@ class InventoryItem { this.location, required this.status, required this.lastUpdated, + this.wooCommerceProductId, + this.lastSyncedAt, + this.syncStatus = InventorySyncStatus.notSynced, }); /// Returns a copy of this [InventoryItem] with the given fields replaced. @@ -47,6 +70,9 @@ class InventoryItem { String? location, InventoryStatus? status, DateTime? lastUpdated, + int? wooCommerceProductId, + DateTime? lastSyncedAt, + InventorySyncStatus? syncStatus, }) { return InventoryItem( id: id ?? this.id, @@ -62,6 +88,9 @@ class InventoryItem { location: location ?? this.location, status: status ?? this.status, lastUpdated: lastUpdated ?? this.lastUpdated, + wooCommerceProductId: wooCommerceProductId ?? this.wooCommerceProductId, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + syncStatus: syncStatus ?? this.syncStatus, ); } } 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 3cd2d81..bfb6c8d 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 @@ -24,4 +24,20 @@ abstract class InventoryRepository { /// Updates the image URL for an inventory item. /// Returns an [InventoryAdjustmentResult] indicating success or failure. Future updateItemImage(String id, String imageUrl); + + // ── Stage 9B additions ───────────────────────────────────────────────────── + + /// Looks up an inventory item by its [sku]. + /// + /// Returns `null` if no item with the given SKU exists. + Future getItemBySku(String sku); + + /// Synchronises a single item's stock quantity with a remote channel. + /// + /// [id] is the local inventory item ID. + /// [remoteQuantity] is the authoritative quantity from the remote channel. + /// [reason] is a human-readable description of why the sync occurred. + /// + /// Returns an [InventoryAdjustmentResult] reflecting the outcome. + Future syncWithRemote(String id, int remoteQuantity, String reason); } diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_sync_status.dart b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_sync_status.dart new file mode 100644 index 0000000..d9a1026 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_sync_status.dart @@ -0,0 +1,17 @@ +/// Represents the synchronization state of an inventory item with a remote channel. +enum InventorySyncStatus { + /// The item has never been synced or has no remote counterpart. + notSynced, + + /// The item is in sync with the remote channel. + synced, + + /// A sync is pending (local changes not yet pushed to remote). + pending, + + /// A sync conflict was detected (local and remote differ unexpectedly). + conflict, + + /// The last sync attempt resulted in an error. + error, +} diff --git a/kell_creations_apps/packages/feature_inventory/pubspec.yaml b/kell_creations_apps/packages/feature_inventory/pubspec.yaml index 1fdf208..e2808ec 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: "Inventory management feature for the Kell Creations operations platform. Provides domain model, repository contract, fake data layer, and write operations for inventory items." +description: "Inventory management feature for the Kell Creations operations platform. Provides domain model, repository contract, fake data layer, write operations, and WooCommerce stock sync for inventory items." version: 0.0.1 publish_to: "none" homepage: @@ -13,11 +13,14 @@ dependencies: sdk: flutter design_system: path: ../design_system + feature_wordpress: + path: ../feature_wordpress dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + http: ^1.4.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/kell_creations_apps/packages/feature_inventory/test/woo_commerce_inventory_sync_test.dart b/kell_creations_apps/packages/feature_inventory/test/woo_commerce_inventory_sync_test.dart new file mode 100644 index 0000000..02cfde5 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/test/woo_commerce_inventory_sync_test.dart @@ -0,0 +1,629 @@ +import 'dart:convert'; + +import 'package:feature_inventory/feature_inventory.dart'; +import 'package:feature_wordpress/feature_wordpress.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +InventoryItem _makeItem({ + String id = 'test-1', + String sku = 'TEST-001', + String name = 'Test Item', + int quantityOnHand = 10, + int reorderPoint = 3, + InventoryStatus status = InventoryStatus.inStock, + int? wooCommerceProductId, + InventorySyncStatus syncStatus = InventorySyncStatus.notSynced, + DateTime? lastSyncedAt, +}) { + return InventoryItem( + id: id, + sku: sku, + name: name, + description: 'A test item', + imageUrl: '', + category: 'Test', + quantityOnHand: quantityOnHand, + unitPrice: 9.99, + reorderPoint: reorderPoint, + status: status, + lastUpdated: DateTime(2026, 7, 1), + wooCommerceProductId: wooCommerceProductId, + syncStatus: syncStatus, + lastSyncedAt: lastSyncedAt, + ); +} + +/// Builds a [MockClient] that returns [statusCode] and [body] for every request. +http.Client _mockClient(int statusCode, Map body) { + return MockClient((_) async => http.Response(jsonEncode(body), statusCode)); +} + +WooCommerceApiClient _makeApiClient(http.Client httpClient) { + return WooCommerceApiClient( + siteUrl: 'https://test.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: httpClient, + ); +} + +// ── Stock fields returned by WooCommerce product endpoint ────────────────── + +Map _stockResponse({ + int id = 101, + String sku = 'TEST-001', + int? stockQuantity = 15, + String stockStatus = 'instock', + bool manageStock = true, +}) { + return { + 'id': id, + 'sku': sku, + 'stock_quantity': stockQuantity, + 'stock_status': stockStatus, + 'manage_stock': manageStock, + }; +} + +void main() { + // ── InventorySyncStatus ──────────────────────────────────────────────────── + + group('InventorySyncStatus', () { + test('has five values', () { + expect(InventorySyncStatus.values.length, 5); + }); + + test('contains expected statuses', () { + expect(InventorySyncStatus.values, contains(InventorySyncStatus.notSynced)); + expect(InventorySyncStatus.values, contains(InventorySyncStatus.synced)); + expect(InventorySyncStatus.values, contains(InventorySyncStatus.pending)); + expect(InventorySyncStatus.values, contains(InventorySyncStatus.conflict)); + expect(InventorySyncStatus.values, contains(InventorySyncStatus.error)); + }); + }); + + // ── InventoryItem sync fields ────────────────────────────────────────────── + + group('InventoryItem sync fields', () { + test('defaults to notSynced when no wooCommerceProductId', () { + final item = _makeItem(); + expect(item.wooCommerceProductId, isNull); + expect(item.syncStatus, InventorySyncStatus.notSynced); + expect(item.lastSyncedAt, isNull); + }); + + test('can be constructed with wooCommerceProductId and syncStatus', () { + final now = DateTime(2026, 7, 1); + final item = _makeItem( + wooCommerceProductId: 101, + syncStatus: InventorySyncStatus.synced, + lastSyncedAt: now, + ); + expect(item.wooCommerceProductId, 101); + expect(item.syncStatus, InventorySyncStatus.synced); + expect(item.lastSyncedAt, now); + }); + + test('copyWith preserves sync fields when not specified', () { + final now = DateTime(2026, 7, 1); + final item = _makeItem( + wooCommerceProductId: 101, + syncStatus: InventorySyncStatus.synced, + lastSyncedAt: now, + ); + final copy = item.copyWith(name: 'Updated'); + expect(copy.wooCommerceProductId, 101); + expect(copy.syncStatus, InventorySyncStatus.synced); + expect(copy.lastSyncedAt, now); + }); + + test('copyWith can update sync fields', () { + final item = _makeItem(wooCommerceProductId: 101, syncStatus: InventorySyncStatus.pending); + final now = DateTime(2026, 7, 11); + final copy = item.copyWith(syncStatus: InventorySyncStatus.synced, lastSyncedAt: now); + expect(copy.syncStatus, InventorySyncStatus.synced); + expect(copy.lastSyncedAt, now); + }); + }); + + // ── FakeInventoryRepository — new methods ────────────────────────────────── + + group('FakeInventoryRepository — getItemBySku', () { + late FakeInventoryRepository repo; + + setUp(() => repo = FakeInventoryRepository()); + + test('returns item for known SKU', () async { + final item = await repo.getItemBySku('BC-FLR-001'); + expect(item, isNotNull); + expect(item!.sku, 'BC-FLR-001'); + }); + + test('returns null for unknown SKU', () async { + final item = await repo.getItemBySku('UNKNOWN-SKU'); + expect(item, isNull); + }); + }); + + group('FakeInventoryRepository — syncWithRemote', () { + late FakeInventoryRepository repo; + + setUp(() => repo = FakeInventoryRepository()); + + test('sets quantity to remote value', () async { + final result = await repo.syncWithRemote('1', 25, 'WooCommerce sync'); + expect(result.success, true); + expect(result.updatedItem!.quantityOnHand, 25); + }); + + test('updates syncStatus to synced', () async { + final result = await repo.syncWithRemote('1', 10, 'sync'); + expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced); + }); + + test('sets lastSyncedAt', () async { + final before = DateTime.now(); + final result = await repo.syncWithRemote('1', 10, 'sync'); + expect(result.updatedItem!.lastSyncedAt, isNotNull); + expect(result.updatedItem!.lastSyncedAt!.isAfter(before), true); + }); + + test('derives correct status after sync', () async { + // Item 1: reorderPoint 5 — sync to 3 (below reorderPoint → lowStock) + final result = await repo.syncWithRemote('1', 3, 'sync'); + expect(result.updatedItem!.status, InventoryStatus.lowStock); + }); + + test('derives outOfStock when synced to 0', () async { + final result = await repo.syncWithRemote('1', 0, 'sync'); + expect(result.updatedItem!.status, InventoryStatus.outOfStock); + }); + + test('fails for unknown id', () async { + final result = await repo.syncWithRemote('UNKNOWN', 10, 'sync'); + expect(result.success, false); + expect(result.errorMessage, isNotEmpty); + }); + + test('fails for negative remote quantity', () async { + final result = await repo.syncWithRemote('1', -1, 'sync'); + expect(result.success, false); + }); + + test('persists change across calls', () async { + await repo.syncWithRemote('1', 42, 'sync'); + final items = await repo.getInventoryItems(); + final item = items.firstWhere((i) => i.id == '1'); + expect(item.quantityOnHand, 42); + }); + + test('seed data includes items with wooCommerceProductId', () async { + final items = await repo.getInventoryItems(); + final withWcId = items.where((i) => i.wooCommerceProductId != null).toList(); + expect(withWcId, isNotEmpty); + }); + + test('seed data includes items without wooCommerceProductId', () async { + final items = await repo.getInventoryItems(); + final withoutWcId = items.where((i) => i.wooCommerceProductId == null).toList(); + expect(withoutWcId, isNotEmpty); + }); + }); + + // ── WooCommerceApiClient — stock methods ─────────────────────────────────── + + group('WooCommerceApiClient — getProductStock', () { + test('returns stock fields on success', () async { + final client = _makeApiClient(_mockClient(200, _stockResponse(stockQuantity: 15))); + final result = await client.getProductStock('101'); + expect(result['id'], 101); + expect(result['stock_quantity'], 15); + expect(result['stock_status'], 'instock'); + expect(result['manage_stock'], true); + }); + + test('throws WooCommerceApiException on non-200', () async { + final client = _makeApiClient(_mockClient(404, {'message': 'Not found'})); + expect(() => client.getProductStock('999'), throwsA(isA())); + }); + + test('handles null stock_quantity (unmanaged stock)', () async { + final client = _makeApiClient( + _mockClient(200, _stockResponse(stockQuantity: null, manageStock: false)), + ); + final result = await client.getProductStock('101'); + expect(result['stock_quantity'], isNull); + expect(result['manage_stock'], false); + }); + }); + + group('WooCommerceApiClient — updateProductStock', () { + test('returns updated stock fields on success', () async { + final client = _makeApiClient(_mockClient(200, _stockResponse(stockQuantity: 20))); + final result = await client.updateProductStock('101', 20); + expect(result['stock_quantity'], 20); + expect(result['manage_stock'], true); + }); + + test('throws WooCommerceApiException on non-200', () async { + final client = _makeApiClient(_mockClient(500, {'message': 'Server error'})); + expect(() => client.updateProductStock('101', 5), throwsA(isA())); + }); + }); + + group('WooCommerceApiClient — batchUpdateStock', () { + test('returns list of updated stock maps on success', () async { + final batchResponse = { + 'update': [ + _stockResponse(id: 101, stockQuantity: 10), + _stockResponse(id: 102, stockQuantity: 5), + ], + }; + final client = _makeApiClient(_mockClient(200, batchResponse)); + final results = await client.batchUpdateStock([ + {'id': 101, 'stock_quantity': 10}, + {'id': 102, 'stock_quantity': 5}, + ]); + expect(results.length, 2); + expect(results[0]['id'], 101); + expect(results[0]['stock_quantity'], 10); + expect(results[1]['id'], 102); + expect(results[1]['stock_quantity'], 5); + }); + + test('throws WooCommerceApiException on non-200', () async { + final client = _makeApiClient(_mockClient(500, {'message': 'Server error'})); + expect( + () => client.batchUpdateStock([ + {'id': 101, 'stock_quantity': 10}, + ]), + throwsA(isA()), + ); + }); + + test('throws WooCommerceApiException when update array missing', () async { + final client = _makeApiClient(_mockClient(200, {'create': [], 'delete': []})); + expect( + () => client.batchUpdateStock([ + {'id': 101, 'stock_quantity': 10}, + ]), + throwsA(isA()), + ); + }); + }); + + // ── WooCommerceInventoryRepository ──────────────────────────────────────── + + group('WooCommerceInventoryRepository', () { + late WooCommerceInventoryRepository repo; + late InventoryItem itemWithWcId; + late InventoryItem itemWithoutWcId; + + setUp(() { + itemWithWcId = _makeItem( + id: '1', + sku: 'TEST-001', + quantityOnHand: 10, + reorderPoint: 3, + wooCommerceProductId: 101, + syncStatus: InventorySyncStatus.synced, + ); + itemWithoutWcId = _makeItem( + id: '2', + sku: 'LOCAL-001', + quantityOnHand: 5, + reorderPoint: 2, + syncStatus: InventorySyncStatus.notSynced, + ); + repo = WooCommerceInventoryRepository( + apiClient: _makeApiClient(_mockClient(200, _stockResponse())), + initialItems: [itemWithWcId, itemWithoutWcId], + ); + }); + + test('getInventoryItems returns all items', () async { + final items = await repo.getInventoryItems(); + expect(items.length, 2); + }); + + test('getItemBySku returns matching item', () async { + final item = await repo.getItemBySku('TEST-001'); + expect(item, isNotNull); + expect(item!.id, '1'); + }); + + test('getItemBySku returns null for unknown SKU', () async { + final item = await repo.getItemBySku('UNKNOWN'); + expect(item, isNull); + }); + + test('adjustQuantity pushes to WooCommerce for linked item', () async { + final client = _makeApiClient(_mockClient(200, _stockResponse(stockQuantity: 15))); + final wcRepo = WooCommerceInventoryRepository( + apiClient: client, + initialItems: [itemWithWcId], + ); + final result = await wcRepo.adjustQuantity('1', 5, 'restock'); + expect(result.success, true); + expect(result.updatedItem!.quantityOnHand, 15); + expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced); + expect(result.updatedItem!.lastSyncedAt, isNotNull); + }); + + test('adjustQuantity does not push to WooCommerce for local-only item', () async { + // No HTTP call expected — if it were made, the mock would return 200 anyway, + // but syncStatus should remain notSynced. + final result = await repo.adjustQuantity('2', 3, 'restock'); + expect(result.success, true); + expect(result.updatedItem!.syncStatus, InventorySyncStatus.notSynced); + }); + + test('adjustQuantity fails when WooCommerce returns error', () async { + final client = _makeApiClient(_mockClient(500, {'message': 'Server error'})); + final wcRepo = WooCommerceInventoryRepository( + apiClient: client, + initialItems: [itemWithWcId], + ); + final result = await wcRepo.adjustQuantity('1', 5, 'restock'); + expect(result.success, false); + expect(result.errorMessage, contains('WooCommerce stock update failed')); + }); + + test('adjustQuantity fails when result would be negative', () async { + final result = await repo.adjustQuantity('1', -100, 'error'); + expect(result.success, false); + }); + + test('setQuantity pushes to WooCommerce for linked item', () async { + final client = _makeApiClient(_mockClient(200, _stockResponse(stockQuantity: 50))); + final wcRepo = WooCommerceInventoryRepository( + apiClient: client, + initialItems: [itemWithWcId], + ); + final result = await wcRepo.setQuantity('1', 50, 'inventory count'); + expect(result.success, true); + expect(result.updatedItem!.quantityOnHand, 50); + expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced); + }); + + test('setQuantity fails for negative quantity', () async { + final result = await repo.setQuantity('1', -1, 'error'); + expect(result.success, false); + }); + + test('syncWithRemote sets quantity and marks synced', () async { + final result = await repo.syncWithRemote('1', 30, 'WooCommerce sync'); + expect(result.success, true); + expect(result.updatedItem!.quantityOnHand, 30); + expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced); + expect(result.updatedItem!.lastSyncedAt, isNotNull); + }); + + test('createItem adds item to repository', () async { + final newItem = _makeItem(id: 'new-1', sku: 'NEW-001'); + await repo.createItem(newItem); + final items = await repo.getInventoryItems(); + expect(items.length, 3); + }); + + test('updateItem updates existing item', () async { + final updated = itemWithWcId.copyWith(name: 'Updated Name'); + final result = await repo.updateItem(updated); + expect(result.name, 'Updated Name'); + }); + + test('updateItem throws for unknown id', () async { + final unknown = _makeItem(id: 'UNKNOWN'); + expect(() => repo.updateItem(unknown), throwsStateError); + }); + + test('updateItemImage updates image URL', () async { + const newUrl = 'https://example.com/new.jpg'; + final result = await repo.updateItemImage('1', newUrl); + expect(result.success, true); + expect(result.updatedItem!.imageUrl, newUrl); + }); + }); + + // ── InventorySyncService ─────────────────────────────────────────────────── + + group('InventorySyncService', () { + late FakeInventoryRepository repo; + + setUp(() => repo = FakeInventoryRepository()); + + test('syncAll syncs items with wooCommerceProductId', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => 20, + ); + final result = await service.syncAll(); + expect(result.syncedCount, greaterThan(0)); + expect(result.allSucceeded, true); + }); + + test('syncAll skips items without wooCommerceProductId', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => 20, + ); + final result = await service.syncAll(); + // Seed data has 2 items without WC product ID (items 4 and 6) + expect(result.skippedCount, 2); + }); + + test('syncAll updates local quantities to remote values', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => 99, + ); + await service.syncAll(); + final items = await repo.getInventoryItems(); + final synced = items.where((i) => i.wooCommerceProductId != null).toList(); + for (final item in synced) { + expect(item.quantityOnHand, 99); + expect(item.syncStatus, InventorySyncStatus.synced); + } + }); + + test('syncAll records failure when fetchRemoteStock throws', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => throw Exception('network error'), + ); + final result = await service.syncAll(); + expect(result.failedCount, greaterThan(0)); + expect(result.allSucceeded, false); + }); + + test('syncAll totalProcessed equals syncedCount + failedCount', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => 10, + ); + final result = await service.syncAll(); + expect(result.totalProcessed, result.syncedCount + result.failedCount); + }); + + test('syncItem syncs a single item by id', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => 42, + ); + // Item '1' has wooCommerceProductId 101 + final result = await service.syncItem('1'); + expect(result.success, true); + expect(result.updatedItem!.quantityOnHand, 42); + }); + + test('syncItem returns failure for unknown id', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => 10, + ); + final result = await service.syncItem('UNKNOWN'); + expect(result.success, false); + }); + + test('syncItem returns failure for item without wooCommerceProductId', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => 10, + ); + // Item '4' has no wooCommerceProductId + final result = await service.syncItem('4'); + expect(result.success, false); + expect(result.errorMessage, contains('no WooCommerce product ID')); + }); + + test('checkDifference returns null for item without wooCommerceProductId', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => 10, + ); + final diff = await service.checkDifference('4'); + expect(diff, isNull); + }); + + test('checkDifference detects discrepancy', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => 999, + ); + // Item '1' has quantityOnHand 18, remote is 999 + final diff = await service.checkDifference('1'); + expect(diff, isNotNull); + expect(diff!.hasDiscrepancy, true); + expect(diff.localQuantity, 18); + expect(diff.remoteQuantity, 999); + }); + + test('checkDifference reports in-sync when quantities match', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => 18, // matches item '1' seed qty + ); + final diff = await service.checkDifference('1'); + expect(diff!.isInSync, true); + expect(diff.hasDiscrepancy, false); + }); + + test('checkDifference reports error when fetchRemoteStock throws', () async { + final service = InventorySyncService( + inventoryRepository: repo, + fetchRemoteStock: (_) async => throw Exception('timeout'), + ); + final diff = await service.checkDifference('1'); + expect(diff, isNotNull); + expect(diff!.syncStatus, InventorySyncStatus.error); + expect(diff.errorMessage, isNotEmpty); + }); + }); + + // ── StockDifference ──────────────────────────────────────────────────────── + + group('StockDifference', () { + test('isInSync true when quantities match', () { + const diff = StockDifference( + itemId: '1', + localQuantity: 10, + remoteQuantity: 10, + syncStatus: InventorySyncStatus.synced, + ); + expect(diff.isInSync, true); + expect(diff.hasDiscrepancy, false); + }); + + test('hasDiscrepancy true when quantities differ', () { + const diff = StockDifference( + itemId: '1', + localQuantity: 10, + remoteQuantity: 20, + syncStatus: InventorySyncStatus.conflict, + ); + expect(diff.hasDiscrepancy, true); + expect(diff.isInSync, false); + }); + + test('isInSync false when remoteQuantity is null', () { + const diff = StockDifference( + itemId: '1', + localQuantity: 10, + remoteQuantity: null, + syncStatus: InventorySyncStatus.error, + ); + expect(diff.isInSync, false); + expect(diff.hasDiscrepancy, false); + }); + }); + + // ── InventorySyncResult ──────────────────────────────────────────────────── + + group('InventorySyncResult', () { + test('allSucceeded true when failedCount is 0', () { + const result = InventorySyncResult( + syncedCount: 5, + failedCount: 0, + skippedCount: 1, + itemResults: {}, + ); + expect(result.allSucceeded, true); + expect(result.totalProcessed, 5); + }); + + test('allSucceeded false when failedCount > 0', () { + const result = InventorySyncResult( + syncedCount: 3, + failedCount: 2, + skippedCount: 0, + itemResults: {}, + ); + expect(result.allSucceeded, false); + expect(result.totalProcessed, 5); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart index 018254b..40b3959 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart @@ -277,6 +277,135 @@ class WooCommerceApiClient implements ApiClient { return decoded; } + // ── Stock / Inventory API ───────────────────────────────────────────────── + + /// Fetches the current stock quantity for a WooCommerce product. + /// + /// Returns a map with at minimum `stock_quantity` (int or null) and + /// `stock_status` (string: `'instock'`, `'outofstock'`, `'onbackorder'`). + Future> getProductStock(String productId) async { + final uri = Uri.parse('$_baseEndpoint/products/$productId'); + final response = await _httpClient.get(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to fetch stock for product $productId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + return { + 'id': decoded['id'], + 'sku': decoded['sku'], + 'stock_quantity': decoded['stock_quantity'], + 'stock_status': decoded['stock_status'], + 'manage_stock': decoded['manage_stock'], + }; + } + + /// Updates the stock quantity for a WooCommerce product. + /// + /// Sets `manage_stock` to `true` and `stock_quantity` to [quantity]. + /// Returns the updated product JSON (stock fields only). + Future> updateProductStock(String productId, int quantity) async { + final uri = Uri.parse('$_baseEndpoint/products/$productId'); + final response = await _httpClient.put( + uri, + headers: _authHeaders, + body: jsonEncode({'manage_stock': true, 'stock_quantity': quantity}), + ); + + if (response.statusCode != 200) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to update stock for product $productId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + return { + 'id': decoded['id'], + 'sku': decoded['sku'], + 'stock_quantity': decoded['stock_quantity'], + 'stock_status': decoded['stock_status'], + 'manage_stock': decoded['manage_stock'], + }; + } + + /// Batch-updates stock quantities for multiple WooCommerce products. + /// + /// [updates] is a list of maps, each with `id` (int) and `stock_quantity` (int). + /// Uses the WooCommerce batch update endpoint (`POST /products/batch`). + /// Returns the list of updated product stock maps. + Future>> batchUpdateStock(List> updates) async { + final uri = Uri.parse('$_baseEndpoint/products/batch'); + final body = { + 'update': updates + .map((u) => {'id': u['id'], 'manage_stock': true, 'stock_quantity': u['stock_quantity']}) + .toList(), + }; + + final response = await _httpClient.post(uri, headers: _authHeaders, body: jsonEncode(body)); + + if (response.statusCode != 200) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to batch update stock: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final updated = decoded['update']; + if (updated is! List) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected batch response: missing "update" array', + body: response.body, + ); + } + + return updated + .cast>() + .map( + (p) => { + 'id': p['id'], + 'sku': p['sku'], + 'stock_quantity': p['stock_quantity'], + 'stock_status': p['stock_status'], + 'manage_stock': p['manage_stock'], + }, + ) + .toList(); + } + // ── Products API ────────────────────────────────────────────────────────── /// Sends a partial update (PUT) to a single WooCommerce product.