feat(inventory): Stage 9B — WooCommerce stock/inventory sync

This commit is contained in:
Mike Kell 2026-07-11 10:00:55 -04:00
parent bb51db7d35
commit 5df3d8beac
12 changed files with 1300 additions and 4 deletions

View File

@ -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

View File

@ -32,6 +32,13 @@ class _StubInventoryRepository implements InventoryRepository {
@override
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) =>
throw UnimplementedError();
@override
Future<InventoryItem?> getItemBySku(String sku) => throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> syncWithRemote(String id, int remoteQuantity, String reason) =>
throw UnimplementedError();
}
class _StubProductPublishingRepository implements ProductPublishingRepository {
@ -105,6 +112,13 @@ class _FailingInventoryRepository implements InventoryRepository {
@override
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) =>
throw UnimplementedError();
@override
Future<InventoryItem?> getItemBySku(String sku) => throw UnimplementedError();
@override
Future<InventoryAdjustmentResult> syncWithRemote(String id, int remoteQuantity, String reason) =>
throw UnimplementedError();
}
// Shared test helpers

View File

@ -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';

View File

@ -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<String, InventoryAdjustmentResult> 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<int> 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<void> 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<InventorySyncResult> syncAll() async {
final items = await _inventoryRepository.getInventoryItems();
final syncable = items.where((i) => i.wooCommerceProductId != null).toList();
final skipped = items.length - syncable.length;
final results = <String, InventoryAdjustmentResult>{};
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<InventoryAdjustmentResult> 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<InventoryAdjustmentResult> _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<StockDifference?> 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;
}

View File

@ -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<InventoryItem?> getItemBySku(String sku) async {
await Future<void>.delayed(const Duration(milliseconds: 100));
try {
return _items.firstWhere((i) => i.sku == sku);
} catch (_) {
return null;
}
}
@override
Future<InventoryAdjustmentResult> syncWithRemote(
String id,
int remoteQuantity,
String reason,
) async {
await Future<void>.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) {

View File

@ -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<InventoryItem> _items;
WooCommerceInventoryRepository({
required WooCommerceApiClient apiClient,
List<InventoryItem> initialItems = const [],
}) : _apiClient = apiClient,
_items = List.of(initialItems);
// Read operations
@override
Future<List<InventoryItem>> getInventoryItems() async {
return List.unmodifiable(_items);
}
@override
Future<InventoryItem?> getItemBySku(String sku) async {
try {
return _items.firstWhere((i) => i.sku == sku);
} catch (_) {
return null;
}
}
// Write operations
@override
Future<InventoryAdjustmentResult> 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<InventoryAdjustmentResult> 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<InventoryItem> createItem(InventoryItem item) async {
_items.add(item);
return item;
}
@override
Future<InventoryItem> 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<InventoryAdjustmentResult> 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<InventoryAdjustmentResult> 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;
}
}

View File

@ -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,
);
}
}

View File

@ -24,4 +24,20 @@ abstract class InventoryRepository {
/// Updates the image URL for an inventory item.
/// Returns an [InventoryAdjustmentResult] indicating success or failure.
Future<InventoryAdjustmentResult> 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<InventoryItem?> 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<InventoryAdjustmentResult> syncWithRemote(String id, int remoteQuantity, String reason);
}

View File

@ -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,
}

View File

@ -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

View File

@ -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<String, dynamic> 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<String, dynamic> _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<WooCommerceApiException>()));
});
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<WooCommerceApiException>()));
});
});
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<WooCommerceApiException>()),
);
});
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<WooCommerceApiException>()),
);
});
});
// 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);
});
});
}

View File

@ -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<Map<String, dynamic>> 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<String, dynamic>) {
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<Map<String, dynamic>> 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<String, dynamic>) {
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<List<Map<String, dynamic>>> batchUpdateStock(List<Map<String, dynamic>> 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<String, dynamic>) {
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<String, dynamic>>()
.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.