feat(inventory): Stage 9B — WooCommerce stock/inventory sync
This commit is contained in:
parent
bb51db7d35
commit
5df3d8beac
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
- main baseline updated through: feat/wc-orders-integration (Stage 9A complete)
|
- main baseline updated through: feat/wc-inventory-sync (Stage 9B complete)
|
||||||
- next branch: feat/wc-inventory-sync (Stage 9B)
|
- next branch: feat/wc-catalog-expansion (Stage 9C)
|
||||||
- current stage: Stage 9A complete — WooCommerce Orders API integration activated
|
- current stage: Stage 9B complete — WooCommerce stock/inventory sync activated
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -484,3 +484,25 @@
|
||||||
- tests: passed (319/319 feature_wordpress)
|
- tests: passed (319/319 feature_wordpress)
|
||||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||||
- brief updated: yes
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,13 @@ class _StubInventoryRepository implements InventoryRepository {
|
||||||
@override
|
@override
|
||||||
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) =>
|
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) =>
|
||||||
throw UnimplementedError();
|
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 {
|
class _StubProductPublishingRepository implements ProductPublishingRepository {
|
||||||
|
|
@ -105,6 +112,13 @@ class _FailingInventoryRepository implements InventoryRepository {
|
||||||
@override
|
@override
|
||||||
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) =>
|
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) =>
|
||||||
throw UnimplementedError();
|
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 ────────────────────────────────────────────────────
|
// ── Shared test helpers ────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ export 'src/application/adjust_inventory_quantity.dart';
|
||||||
export 'src/application/create_inventory_item.dart';
|
export 'src/application/create_inventory_item.dart';
|
||||||
export 'src/application/get_inventory_items.dart';
|
export 'src/application/get_inventory_items.dart';
|
||||||
export 'src/application/inventory_controller.dart';
|
export 'src/application/inventory_controller.dart';
|
||||||
|
export 'src/application/inventory_sync_service.dart';
|
||||||
export 'src/application/update_inventory_item.dart';
|
export 'src/application/update_inventory_item.dart';
|
||||||
export 'src/data/fake_inventory_repository.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_adjustment_result.dart';
|
||||||
export 'src/domain/inventory_item.dart';
|
export 'src/domain/inventory_item.dart';
|
||||||
export 'src/domain/inventory_repository.dart';
|
export 'src/domain/inventory_repository.dart';
|
||||||
export 'src/domain/inventory_status.dart';
|
export 'src/domain/inventory_status.dart';
|
||||||
|
export 'src/domain/inventory_sync_status.dart';
|
||||||
export 'src/presentation/inventory_action_snack_bar.dart';
|
export 'src/presentation/inventory_action_snack_bar.dart';
|
||||||
export 'src/presentation/inventory_page.dart';
|
export 'src/presentation/inventory_page.dart';
|
||||||
export 'src/presentation/widgets/adjust_quantity_dialog.dart';
|
export 'src/presentation/widgets/adjust_quantity_dialog.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<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;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import '../domain/inventory_adjustment_result.dart';
|
||||||
import '../domain/inventory_item.dart';
|
import '../domain/inventory_item.dart';
|
||||||
import '../domain/inventory_repository.dart';
|
import '../domain/inventory_repository.dart';
|
||||||
import '../domain/inventory_status.dart';
|
import '../domain/inventory_status.dart';
|
||||||
|
import '../domain/inventory_sync_status.dart';
|
||||||
|
|
||||||
class FakeInventoryRepository implements InventoryRepository {
|
class FakeInventoryRepository implements InventoryRepository {
|
||||||
/// Mutable in-memory store — seeded on construction.
|
/// Mutable in-memory store — seeded on construction.
|
||||||
|
|
@ -24,6 +25,9 @@ class FakeInventoryRepository implements InventoryRepository {
|
||||||
location: 'Shelf A1',
|
location: 'Shelf A1',
|
||||||
status: InventoryStatus.inStock,
|
status: InventoryStatus.inStock,
|
||||||
lastUpdated: now,
|
lastUpdated: now,
|
||||||
|
wooCommerceProductId: 101,
|
||||||
|
syncStatus: InventorySyncStatus.synced,
|
||||||
|
lastSyncedAt: now,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
InventoryItem(
|
||||||
id: '2',
|
id: '2',
|
||||||
|
|
@ -39,6 +43,8 @@ class FakeInventoryRepository implements InventoryRepository {
|
||||||
location: 'Shelf B2',
|
location: 'Shelf B2',
|
||||||
status: InventoryStatus.lowStock,
|
status: InventoryStatus.lowStock,
|
||||||
lastUpdated: now,
|
lastUpdated: now,
|
||||||
|
wooCommerceProductId: 102,
|
||||||
|
syncStatus: InventorySyncStatus.pending,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
InventoryItem(
|
||||||
id: '3',
|
id: '3',
|
||||||
|
|
@ -54,6 +60,9 @@ class FakeInventoryRepository implements InventoryRepository {
|
||||||
location: 'Shelf C1',
|
location: 'Shelf C1',
|
||||||
status: InventoryStatus.outOfStock,
|
status: InventoryStatus.outOfStock,
|
||||||
lastUpdated: now,
|
lastUpdated: now,
|
||||||
|
wooCommerceProductId: 103,
|
||||||
|
syncStatus: InventorySyncStatus.synced,
|
||||||
|
lastSyncedAt: now,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
InventoryItem(
|
||||||
id: '4',
|
id: '4',
|
||||||
|
|
@ -69,6 +78,8 @@ class FakeInventoryRepository implements InventoryRepository {
|
||||||
location: 'Shelf A2',
|
location: 'Shelf A2',
|
||||||
status: InventoryStatus.inStock,
|
status: InventoryStatus.inStock,
|
||||||
lastUpdated: now,
|
lastUpdated: now,
|
||||||
|
// No WooCommerce product — sold in-person only
|
||||||
|
syncStatus: InventorySyncStatus.notSynced,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
InventoryItem(
|
||||||
id: '5',
|
id: '5',
|
||||||
|
|
@ -84,6 +95,8 @@ class FakeInventoryRepository implements InventoryRepository {
|
||||||
location: null,
|
location: null,
|
||||||
status: InventoryStatus.lowStock,
|
status: InventoryStatus.lowStock,
|
||||||
lastUpdated: now,
|
lastUpdated: now,
|
||||||
|
wooCommerceProductId: 105,
|
||||||
|
syncStatus: InventorySyncStatus.error,
|
||||||
),
|
),
|
||||||
InventoryItem(
|
InventoryItem(
|
||||||
id: '6',
|
id: '6',
|
||||||
|
|
@ -99,6 +112,7 @@ class FakeInventoryRepository implements InventoryRepository {
|
||||||
location: null,
|
location: null,
|
||||||
status: InventoryStatus.draft,
|
status: InventoryStatus.draft,
|
||||||
lastUpdated: now,
|
lastUpdated: now,
|
||||||
|
syncStatus: InventorySyncStatus.notSynced,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +195,42 @@ class FakeInventoryRepository implements InventoryRepository {
|
||||||
return InventoryAdjustmentResult.success(updated);
|
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 ────────────────────────────────────────────────────────
|
// ── Private helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static InventoryStatus _deriveStatus(int quantity, int reorderPoint) {
|
static InventoryStatus _deriveStatus(int quantity, int reorderPoint) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'inventory_status.dart';
|
import 'inventory_status.dart';
|
||||||
|
import 'inventory_sync_status.dart';
|
||||||
|
|
||||||
/// A single inventory item tracked by the Kell Creations operations platform.
|
/// A single inventory item tracked by the Kell Creations operations platform.
|
||||||
class InventoryItem {
|
class InventoryItem {
|
||||||
|
|
@ -16,6 +17,25 @@ class InventoryItem {
|
||||||
final InventoryStatus status;
|
final InventoryStatus status;
|
||||||
final DateTime lastUpdated;
|
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({
|
const InventoryItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.sku,
|
required this.sku,
|
||||||
|
|
@ -30,6 +50,9 @@ class InventoryItem {
|
||||||
this.location,
|
this.location,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.lastUpdated,
|
required this.lastUpdated,
|
||||||
|
this.wooCommerceProductId,
|
||||||
|
this.lastSyncedAt,
|
||||||
|
this.syncStatus = InventorySyncStatus.notSynced,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Returns a copy of this [InventoryItem] with the given fields replaced.
|
/// Returns a copy of this [InventoryItem] with the given fields replaced.
|
||||||
|
|
@ -47,6 +70,9 @@ class InventoryItem {
|
||||||
String? location,
|
String? location,
|
||||||
InventoryStatus? status,
|
InventoryStatus? status,
|
||||||
DateTime? lastUpdated,
|
DateTime? lastUpdated,
|
||||||
|
int? wooCommerceProductId,
|
||||||
|
DateTime? lastSyncedAt,
|
||||||
|
InventorySyncStatus? syncStatus,
|
||||||
}) {
|
}) {
|
||||||
return InventoryItem(
|
return InventoryItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
|
|
@ -62,6 +88,9 @@ class InventoryItem {
|
||||||
location: location ?? this.location,
|
location: location ?? this.location,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
lastUpdated: lastUpdated ?? this.lastUpdated,
|
lastUpdated: lastUpdated ?? this.lastUpdated,
|
||||||
|
wooCommerceProductId: wooCommerceProductId ?? this.wooCommerceProductId,
|
||||||
|
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
|
||||||
|
syncStatus: syncStatus ?? this.syncStatus,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,20 @@ abstract class InventoryRepository {
|
||||||
/// Updates the image URL for an inventory item.
|
/// Updates the image URL for an inventory item.
|
||||||
/// Returns an [InventoryAdjustmentResult] indicating success or failure.
|
/// Returns an [InventoryAdjustmentResult] indicating success or failure.
|
||||||
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
name: feature_inventory
|
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
|
version: 0.0.1
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
homepage:
|
homepage:
|
||||||
|
|
@ -13,11 +13,14 @@ dependencies:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
design_system:
|
design_system:
|
||||||
path: ../design_system
|
path: ../design_system
|
||||||
|
feature_wordpress:
|
||||||
|
path: ../feature_wordpress
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
http: ^1.4.0
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -277,6 +277,135 @@ class WooCommerceApiClient implements ApiClient {
|
||||||
return decoded;
|
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 ──────────────────────────────────────────────────────────
|
// ── Products API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Sends a partial update (PUT) to a single WooCommerce product.
|
/// Sends a partial update (PUT) to a single WooCommerce product.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue