php-commerce/tests/Feature/WarehouseServiceTest.php
Claude 922655cb41
test: add comprehensive tests for WarehouseService
Cover warehouse management (create, list, primary), inventory operations
(add/remove stock, reserve/release/fulfil), stock transfers between
warehouses, stock count adjustments, query methods (total stock, available
stock, low/out of stock, movement history, best warehouse), and full
order lifecycle integration tests.

Fixes #8

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:30:02 +00:00

1017 lines
41 KiB
PHP

<?php
declare(strict_types=1);
use Core\Mod\Commerce\Models\Entity;
use Core\Mod\Commerce\Models\Inventory;
use Core\Mod\Commerce\Models\InventoryMovement;
use Core\Mod\Commerce\Models\Product;
use Core\Mod\Commerce\Models\Warehouse;
use Core\Mod\Commerce\Services\WarehouseService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
beforeEach(function () {
// Ensure warehouse table has all columns the model expects
if (! Schema::hasColumn('commerce_warehouses', 'is_primary')) {
Schema::table('commerce_warehouses', function ($table) {
if (! Schema::hasColumn('commerce_warehouses', 'description')) {
$table->string('description')->nullable()->after('name');
}
if (! Schema::hasColumn('commerce_warehouses', 'county')) {
$table->string('county')->nullable()->after('city');
}
if (! Schema::hasColumn('commerce_warehouses', 'postcode')) {
$table->string('postcode')->nullable()->after('county');
}
if (! Schema::hasColumn('commerce_warehouses', 'contact_name')) {
$table->string('contact_name')->nullable()->after('country');
}
if (! Schema::hasColumn('commerce_warehouses', 'contact_email')) {
$table->string('contact_email')->nullable()->after('contact_name');
}
if (! Schema::hasColumn('commerce_warehouses', 'contact_phone')) {
$table->string('contact_phone')->nullable()->after('contact_email');
}
if (! Schema::hasColumn('commerce_warehouses', 'type')) {
$table->string('type')->default(Warehouse::TYPE_OWNED)->after('contact_phone');
}
if (! Schema::hasColumn('commerce_warehouses', 'can_ship')) {
$table->boolean('can_ship')->default(true)->after('type');
}
if (! Schema::hasColumn('commerce_warehouses', 'can_pickup')) {
$table->boolean('can_pickup')->default(false)->after('can_ship');
}
$table->boolean('is_primary')->default(false)->after('can_pickup');
if (! Schema::hasColumn('commerce_warehouses', 'operating_hours')) {
$table->json('operating_hours')->nullable()->after('is_primary');
}
});
}
// Ensure inventory table has all columns the model expects
if (! Schema::hasColumn('commerce_inventory', 'reserved_quantity')) {
Schema::table('commerce_inventory', function ($table) {
$table->renameColumn('reserved', 'reserved_quantity');
});
Schema::table('commerce_inventory', function ($table) {
if (! Schema::hasColumn('commerce_inventory', 'incoming_quantity')) {
$table->integer('incoming_quantity')->default(0)->after('reserved_quantity');
}
if (! Schema::hasColumn('commerce_inventory', 'bin_location')) {
$table->string('bin_location')->nullable()->after('low_stock_threshold');
}
if (! Schema::hasColumn('commerce_inventory', 'zone')) {
$table->string('zone')->nullable()->after('bin_location');
}
if (! Schema::hasColumn('commerce_inventory', 'last_restocked_at')) {
$table->timestamp('last_restocked_at')->nullable()->after('last_counted_at');
}
if (! Schema::hasColumn('commerce_inventory', 'unit_cost')) {
$table->integer('unit_cost')->nullable()->after('last_restocked_at');
}
if (! Schema::hasColumn('commerce_inventory', 'metadata')) {
$table->json('metadata')->nullable()->after('unit_cost');
}
});
}
// Create inventory movements table if it does not exist
if (! Schema::hasTable('commerce_inventory_movements')) {
Schema::create('commerce_inventory_movements', function ($table) {
$table->id();
$table->foreignId('inventory_id')->nullable()->constrained('commerce_inventory')->nullOnDelete();
$table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
$table->foreignId('warehouse_id')->constrained('commerce_warehouses')->cascadeOnDelete();
$table->string('type', 32);
$table->integer('quantity');
$table->integer('balance_after')->default(0);
$table->string('reference')->nullable();
$table->text('notes')->nullable();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->integer('unit_cost')->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['product_id', 'type']);
$table->index(['warehouse_id', 'type']);
$table->index(['reference']);
});
}
// Create M1 entity
$this->entity = Entity::createMaster('WH', 'Warehouse Test Co');
// Create a non-M1 entity for negative testing
$this->m2Entity = $this->entity->createFacade('SHOP', 'Shop Front');
// Create product owned by M1
$this->product = Product::create([
'sku' => 'WH-TEST-001',
'owner_entity_id' => $this->entity->id,
'name' => 'Test Widget',
'price' => 1500,
'currency' => 'GBP',
'type' => Product::TYPE_SIMPLE,
]);
// Create a second product for multi-product tests
$this->productB = Product::create([
'sku' => 'WH-TEST-002',
'owner_entity_id' => $this->entity->id,
'name' => 'Test Gadget',
'price' => 2500,
'currency' => 'GBP',
'type' => Product::TYPE_SIMPLE,
]);
$this->service = new WarehouseService;
});
// ──────────────────────────────────────────────
// Warehouse Management
// ──────────────────────────────────────────────
describe('WarehouseService', function () {
describe('createWarehouse()', function () {
it('creates a warehouse for an M1 entity', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'main',
'name' => 'Main Warehouse',
'country' => 'GB',
'is_active' => true,
]);
expect($warehouse)->toBeInstanceOf(Warehouse::class)
->and($warehouse->entity_id)->toBe($this->entity->id)
->and($warehouse->code)->toBe('MAIN')
->and($warehouse->name)->toBe('Main Warehouse')
->and($warehouse->country)->toBe('GB')
->and($warehouse->is_active)->toBeTrue();
});
it('uppercases the warehouse code', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'london-south',
'name' => 'London South',
]);
expect($warehouse->code)->toBe('LONDON-SOUTH');
});
it('throws exception for non-M1 entity', function () {
expect(fn () => $this->service->createWarehouse($this->m2Entity, [
'code' => 'nope',
'name' => 'Should Fail',
]))->toThrow(InvalidArgumentException::class, 'Only M1 (Master) entities can own warehouses.');
});
});
describe('getWarehousesForEntity()', function () {
it('returns active warehouses ordered by primary first', function () {
$warehouseA = $this->service->createWarehouse($this->entity, [
'code' => 'wh-a',
'name' => 'Warehouse A',
'is_active' => true,
'is_primary' => false,
]);
$warehouseB = $this->service->createWarehouse($this->entity, [
'code' => 'wh-b',
'name' => 'Warehouse B',
'is_active' => true,
'is_primary' => true,
]);
// Inactive warehouse should not appear
$this->service->createWarehouse($this->entity, [
'code' => 'wh-c',
'name' => 'Inactive Warehouse',
'is_active' => false,
]);
$warehouses = $this->service->getWarehousesForEntity($this->entity);
expect($warehouses)->toHaveCount(2)
->and($warehouses->first()->id)->toBe($warehouseB->id)
->and($warehouses->last()->id)->toBe($warehouseA->id);
});
it('returns empty collection when no warehouses exist', function () {
$warehouses = $this->service->getWarehousesForEntity($this->entity);
expect($warehouses)->toHaveCount(0);
});
});
describe('getPrimaryWarehouse()', function () {
it('returns the primary warehouse', function () {
$this->service->createWarehouse($this->entity, [
'code' => 'secondary',
'name' => 'Secondary',
'is_active' => true,
'is_primary' => false,
]);
$primary = $this->service->createWarehouse($this->entity, [
'code' => 'primary',
'name' => 'Primary HQ',
'is_active' => true,
'is_primary' => true,
]);
$result = $this->service->getPrimaryWarehouse($this->entity);
expect($result)->not->toBeNull()
->and($result->id)->toBe($primary->id);
});
it('returns null when no primary warehouse is set', function () {
$this->service->createWarehouse($this->entity, [
'code' => 'noprimary',
'name' => 'No Primary',
'is_active' => true,
'is_primary' => false,
]);
$result = $this->service->getPrimaryWarehouse($this->entity);
expect($result)->toBeNull();
});
});
describe('setPrimaryWarehouse()', function () {
it('sets a warehouse as primary and unsets others', function () {
$warehouseA = $this->service->createWarehouse($this->entity, [
'code' => 'wh-a',
'name' => 'Warehouse A',
'is_active' => true,
'is_primary' => true,
]);
$warehouseB = $this->service->createWarehouse($this->entity, [
'code' => 'wh-b',
'name' => 'Warehouse B',
'is_active' => true,
'is_primary' => false,
]);
$this->service->setPrimaryWarehouse($warehouseB);
$warehouseA->refresh();
$warehouseB->refresh();
expect($warehouseB->is_primary)->toBeTrue()
->and($warehouseA->is_primary)->toBeFalse();
});
});
// ──────────────────────────────────────────────
// Inventory Operations
// ──────────────────────────────────────────────
describe('getOrCreateInventory()', function () {
it('creates new inventory record with zero quantities', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'inv',
'name' => 'Inventory Warehouse',
'is_active' => true,
]);
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory)->toBeInstanceOf(Inventory::class)
->and($inventory->product_id)->toBe($this->product->id)
->and($inventory->warehouse_id)->toBe($warehouse->id)
->and($inventory->quantity)->toBe(0)
->and($inventory->reserved_quantity)->toBe(0)
->and($inventory->incoming_quantity)->toBe(0);
});
it('returns existing inventory record on second call', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'inv2',
'name' => 'Inventory Warehouse 2',
'is_active' => true,
]);
$first = $this->service->getOrCreateInventory($this->product, $warehouse);
$second = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($first->id)->toBe($second->id);
});
});
describe('addStock()', function () {
it('adds stock and records a movement', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'add',
'name' => 'Add Stock Warehouse',
'is_active' => true,
]);
$inventory = $this->service->addStock(
$this->product,
$warehouse,
50,
InventoryMovement::TYPE_PURCHASE,
'PO-001',
'Initial stock purchase'
);
expect($inventory->quantity)->toBe(50)
->and($inventory->reserved_quantity)->toBe(0);
// Verify movement was recorded
$movement = InventoryMovement::where('inventory_id', $inventory->id)->first();
expect($movement)->not->toBeNull()
->and($movement->type)->toBe(InventoryMovement::TYPE_PURCHASE)
->and($movement->quantity)->toBe(50)
->and($movement->reference)->toBe('PO-001')
->and($movement->notes)->toBe('Initial stock purchase');
});
it('accumulates stock across multiple additions', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'accum',
'name' => 'Accumulate Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 30);
$inventory = $this->service->addStock($this->product, $warehouse, 20);
expect($inventory->quantity)->toBe(50);
// Two movements should exist
$movements = InventoryMovement::where('inventory_id', $inventory->id)->count();
expect($movements)->toBe(2);
});
it('stores unit cost when provided', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'cost',
'name' => 'Cost Warehouse',
'is_active' => true,
]);
$inventory = $this->service->addStock(
$this->product,
$warehouse,
10,
InventoryMovement::TYPE_PURCHASE,
null,
null,
750
);
expect($inventory->unit_cost)->toBe(750);
});
});
describe('removeStock()', function () {
it('removes stock when available quantity is sufficient', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'rem',
'name' => 'Remove Stock Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 100);
$result = $this->service->removeStock(
$this->product,
$warehouse,
30,
InventoryMovement::TYPE_SALE,
'ORD-001'
);
expect($result)->toBeTrue();
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory->quantity)->toBe(70);
// Verify outbound movement
$movement = InventoryMovement::where('reference', 'ORD-001')->first();
expect($movement)->not->toBeNull()
->and($movement->quantity)->toBe(-30)
->and($movement->type)->toBe(InventoryMovement::TYPE_SALE);
});
it('returns false when insufficient stock', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'insuf',
'name' => 'Insufficient Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 10);
$result = $this->service->removeStock($this->product, $warehouse, 20);
expect($result)->toBeFalse();
// Quantity should remain unchanged
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory->quantity)->toBe(10);
});
it('accounts for reserved quantity in available check', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'resavail',
'name' => 'Reserved Availability Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 20);
$this->service->reserveStock($this->product, $warehouse, 15, 'ORD-RES');
// Available = 20 - 15 = 5, so removing 10 should fail
$result = $this->service->removeStock($this->product, $warehouse, 10);
expect($result)->toBeFalse();
// Removing 5 should succeed
$result = $this->service->removeStock($this->product, $warehouse, 5);
expect($result)->toBeTrue();
});
});
// ──────────────────────────────────────────────
// Stock Reservation & Fulfilment
// ──────────────────────────────────────────────
describe('reserveStock()', function () {
it('reserves stock for an order', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'res',
'name' => 'Reserve Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 50);
$result = $this->service->reserveStock($this->product, $warehouse, 10, 'ORD-100');
expect($result)->toBeTrue();
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory->reserved_quantity)->toBe(10)
->and($inventory->getAvailableQuantity())->toBe(40);
// Verify reservation movement
$movement = InventoryMovement::where('reference', 'ORD-100')
->where('type', InventoryMovement::TYPE_RESERVED)
->first();
expect($movement)->not->toBeNull()
->and($movement->quantity)->toBe(-10)
->and($movement->notes)->toContain('Reserved for order ORD-100');
});
it('returns false when insufficient available stock', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'res2',
'name' => 'Reserve Warehouse 2',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 5);
$result = $this->service->reserveStock($this->product, $warehouse, 10, 'ORD-BIG');
expect($result)->toBeFalse();
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory->reserved_quantity)->toBe(0);
});
it('allows multiple reservations up to available stock', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'multi',
'name' => 'Multi Reserve Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 30);
$this->service->reserveStock($this->product, $warehouse, 10, 'ORD-A');
$this->service->reserveStock($this->product, $warehouse, 10, 'ORD-B');
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory->reserved_quantity)->toBe(20)
->and($inventory->getAvailableQuantity())->toBe(10);
// Third reservation for 15 should fail (only 10 available)
$result = $this->service->reserveStock($this->product, $warehouse, 15, 'ORD-C');
expect($result)->toBeFalse();
});
});
describe('releaseStock()', function () {
it('releases reserved stock', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'rel',
'name' => 'Release Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 50);
$this->service->reserveStock($this->product, $warehouse, 20, 'ORD-REL');
$this->service->releaseStock($this->product, $warehouse, 20, 'ORD-REL');
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory->reserved_quantity)->toBe(0)
->and($inventory->getAvailableQuantity())->toBe(50);
// Verify release movement
$movement = InventoryMovement::where('reference', 'ORD-REL')
->where('type', InventoryMovement::TYPE_RELEASED)
->first();
expect($movement)->not->toBeNull()
->and($movement->quantity)->toBe(20)
->and($movement->notes)->toContain('Released from order ORD-REL');
});
});
describe('fulfillStock()', function () {
it('converts reserved stock to a sale', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'ful',
'name' => 'Fulfil Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 100);
$this->service->reserveStock($this->product, $warehouse, 25, 'ORD-FUL');
$result = $this->service->fulfillStock($this->product, $warehouse, 25, 'ORD-FUL');
expect($result)->toBeTrue();
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
// quantity should decrease by 25, reserved should decrease by 25
expect($inventory->quantity)->toBe(75)
->and($inventory->reserved_quantity)->toBe(0);
// Verify fulfilment movement
$movement = InventoryMovement::where('reference', 'ORD-FUL')
->where('type', InventoryMovement::TYPE_SALE)
->first();
expect($movement)->not->toBeNull()
->and($movement->quantity)->toBe(-25);
});
it('returns false when reserved quantity is insufficient', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'ful2',
'name' => 'Fulfil Warehouse 2',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 50);
$this->service->reserveStock($this->product, $warehouse, 10, 'ORD-SMALL');
$result = $this->service->fulfillStock($this->product, $warehouse, 20, 'ORD-SMALL');
expect($result)->toBeFalse();
// Nothing should change
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory->quantity)->toBe(50)
->and($inventory->reserved_quantity)->toBe(10);
});
});
// ──────────────────────────────────────────────
// Stock Transfer
// ──────────────────────────────────────────────
describe('transferStock()', function () {
it('transfers stock between warehouses', function () {
$warehouseA = $this->service->createWarehouse($this->entity, [
'code' => 'src',
'name' => 'Source Warehouse',
'is_active' => true,
]);
$warehouseB = $this->service->createWarehouse($this->entity, [
'code' => 'dst',
'name' => 'Destination Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouseA, 100);
$result = $this->service->transferStock(
$this->product,
$warehouseA,
$warehouseB,
40,
'Redistribution to new warehouse'
);
expect($result)->toBeTrue();
$fromInventory = $this->service->getOrCreateInventory($this->product, $warehouseA);
$toInventory = $this->service->getOrCreateInventory($this->product, $warehouseB);
expect($fromInventory->quantity)->toBe(60)
->and($toInventory->quantity)->toBe(40);
// Verify transfer movements
$outMovement = InventoryMovement::where('warehouse_id', $warehouseA->id)
->where('type', InventoryMovement::TYPE_TRANSFER_OUT)
->first();
$inMovement = InventoryMovement::where('warehouse_id', $warehouseB->id)
->where('type', InventoryMovement::TYPE_TRANSFER_IN)
->first();
expect($outMovement)->not->toBeNull()
->and($outMovement->quantity)->toBe(-40)
->and($outMovement->notes)->toBe('Redistribution to new warehouse')
->and($inMovement)->not->toBeNull()
->and($inMovement->quantity)->toBe(40)
->and($inMovement->notes)->toBe('Redistribution to new warehouse');
// Both should share the same TRANSFER reference
expect($outMovement->reference)->toStartWith('TRANSFER-')
->and($outMovement->reference)->toBe($inMovement->reference);
});
it('returns false when source has insufficient stock', function () {
$warehouseA = $this->service->createWarehouse($this->entity, [
'code' => 'src2',
'name' => 'Source 2',
'is_active' => true,
]);
$warehouseB = $this->service->createWarehouse($this->entity, [
'code' => 'dst2',
'name' => 'Destination 2',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouseA, 10);
$result = $this->service->transferStock($this->product, $warehouseA, $warehouseB, 50);
expect($result)->toBeFalse();
// Source stock should be unchanged
$fromInventory = $this->service->getOrCreateInventory($this->product, $warehouseA);
expect($fromInventory->quantity)->toBe(10);
});
});
// ──────────────────────────────────────────────
// Stock Count Adjustment
// ──────────────────────────────────────────────
describe('adjustStock()', function () {
it('adjusts stock upward and returns positive difference', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'adj',
'name' => 'Adjustment Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 40);
$difference = $this->service->adjustStock($this->product, $warehouse, 55, 'Physical count +15');
expect($difference)->toBe(15);
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory->quantity)->toBe(55);
// Verify count movement
$movement = InventoryMovement::where('type', InventoryMovement::TYPE_COUNT)
->where('inventory_id', $inventory->id)
->first();
expect($movement)->not->toBeNull()
->and($movement->quantity)->toBe(15)
->and($movement->reference)->toStartWith('COUNT-');
});
it('adjusts stock downward and returns negative difference', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'adj2',
'name' => 'Adjustment Warehouse 2',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 50);
$difference = $this->service->adjustStock($this->product, $warehouse, 42);
expect($difference)->toBe(-8);
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory->quantity)->toBe(42);
});
it('uses default notes when none provided', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'adj3',
'name' => 'Adjustment Warehouse 3',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 10);
$this->service->adjustStock($this->product, $warehouse, 15);
$movement = InventoryMovement::where('type', InventoryMovement::TYPE_COUNT)->first();
expect($movement->notes)->toBe('Physical count adjustment');
});
});
// ──────────────────────────────────────────────
// Query Methods
// ──────────────────────────────────────────────
describe('getTotalStock()', function () {
it('sums stock across all warehouses', function () {
$warehouseA = $this->service->createWarehouse($this->entity, [
'code' => 'ts-a',
'name' => 'Total Stock A',
'is_active' => true,
]);
$warehouseB = $this->service->createWarehouse($this->entity, [
'code' => 'ts-b',
'name' => 'Total Stock B',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouseA, 30);
$this->service->addStock($this->product, $warehouseB, 70);
$total = $this->service->getTotalStock($this->product);
expect($total)->toBe(100);
});
it('returns zero when no inventory exists', function () {
$total = $this->service->getTotalStock($this->product);
expect($total)->toBe(0);
});
});
describe('getTotalAvailableStock()', function () {
it('subtracts reserved quantities from total', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'avail',
'name' => 'Available Stock Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 80);
$this->service->reserveStock($this->product, $warehouse, 30, 'ORD-AVL');
$available = $this->service->getTotalAvailableStock($this->product);
expect($available)->toBe(50);
});
it('returns zero when all stock is reserved', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'allres',
'name' => 'All Reserved Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 20);
$this->service->reserveStock($this->product, $warehouse, 20, 'ORD-ALL');
$available = $this->service->getTotalAvailableStock($this->product);
expect($available)->toBe(0);
});
});
describe('getStockByWarehouse()', function () {
it('returns inventory records with warehouse relation', function () {
$warehouseA = $this->service->createWarehouse($this->entity, [
'code' => 'sbw-a',
'name' => 'Stock By WH A',
'is_active' => true,
]);
$warehouseB = $this->service->createWarehouse($this->entity, [
'code' => 'sbw-b',
'name' => 'Stock By WH B',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouseA, 25);
$this->service->addStock($this->product, $warehouseB, 75);
$stock = $this->service->getStockByWarehouse($this->product);
expect($stock)->toHaveCount(2);
// Each record should have warehouse loaded
$stock->each(function ($inv) {
expect($inv->relationLoaded('warehouse'))->toBeTrue()
->and($inv->warehouse)->toBeInstanceOf(Warehouse::class);
});
});
});
describe('findBestWarehouse()', function () {
it('returns primary warehouse when it has sufficient stock', function () {
$primary = $this->service->createWarehouse($this->entity, [
'code' => 'best-p',
'name' => 'Primary Best',
'is_active' => true,
'is_primary' => true,
'can_ship' => true,
]);
$secondary = $this->service->createWarehouse($this->entity, [
'code' => 'best-s',
'name' => 'Secondary Best',
'is_active' => true,
'is_primary' => false,
'can_ship' => true,
]);
$this->service->addStock($this->product, $primary, 50);
$this->service->addStock($this->product, $secondary, 50);
$best = $this->service->findBestWarehouse($this->product, 10);
expect($best)->not->toBeNull()
->and($best->id)->toBe($primary->id);
});
it('returns null when no warehouse can fulfil the quantity', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'best-n',
'name' => 'Not Enough',
'is_active' => true,
'can_ship' => true,
]);
$this->service->addStock($this->product, $warehouse, 5);
$best = $this->service->findBestWarehouse($this->product, 50);
expect($best)->toBeNull();
});
it('excludes warehouses that cannot ship', function () {
$noShip = $this->service->createWarehouse($this->entity, [
'code' => 'nosh',
'name' => 'No Ship Warehouse',
'is_active' => true,
'can_ship' => false,
]);
$this->service->addStock($this->product, $noShip, 100);
$best = $this->service->findBestWarehouse($this->product, 10);
expect($best)->toBeNull();
});
});
describe('getLowStockProducts()', function () {
it('returns inventory records below threshold', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'low',
'name' => 'Low Stock Warehouse',
'is_active' => true,
]);
// Product with 3 units (default threshold is 5)
$this->service->addStock($this->product, $warehouse, 3);
// Product with plenty of stock
$this->service->addStock($this->productB, $warehouse, 100);
$lowStock = $this->service->getLowStockProducts($this->entity);
expect($lowStock)->toHaveCount(1)
->and($lowStock->first()->product_id)->toBe($this->product->id);
});
});
describe('getOutOfStockProducts()', function () {
it('returns inventory records with zero or negative available quantity', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'oos',
'name' => 'Out of Stock Warehouse',
'is_active' => true,
]);
// Product with zero stock
$this->service->getOrCreateInventory($this->product, $warehouse);
// Product with stock
$this->service->addStock($this->productB, $warehouse, 50);
$outOfStock = $this->service->getOutOfStockProducts($this->entity);
expect($outOfStock)->toHaveCount(1)
->and($outOfStock->first()->product_id)->toBe($this->product->id);
});
});
describe('getMovementHistory()', function () {
it('returns movements ordered by newest first', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'hist',
'name' => 'History Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 100);
$this->service->removeStock($this->product, $warehouse, 20, InventoryMovement::TYPE_SALE, 'ORD-H1');
$this->service->reserveStock($this->product, $warehouse, 10, 'ORD-H2');
$history = $this->service->getMovementHistory($this->product);
expect($history)->toHaveCount(3)
->and($history->first()->created_at->gte($history->last()->created_at))->toBeTrue();
});
it('respects the limit parameter', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'lim',
'name' => 'Limit Warehouse',
'is_active' => true,
]);
// Create several movements
$this->service->addStock($this->product, $warehouse, 100);
$this->service->removeStock($this->product, $warehouse, 10);
$this->service->removeStock($this->product, $warehouse, 10);
$this->service->removeStock($this->product, $warehouse, 10);
$history = $this->service->getMovementHistory($this->product, 2);
expect($history)->toHaveCount(2);
});
});
// ──────────────────────────────────────────────
// Full Lifecycle Integration
// ──────────────────────────────────────────────
describe('full order lifecycle', function () {
it('handles reserve -> fulfil workflow', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'life',
'name' => 'Lifecycle Warehouse',
'is_active' => true,
]);
// Receive stock
$this->service->addStock($this->product, $warehouse, 50);
// Customer places order: reserve
$reserved = $this->service->reserveStock($this->product, $warehouse, 5, 'ORD-LIFE');
expect($reserved)->toBeTrue();
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory->quantity)->toBe(50)
->and($inventory->reserved_quantity)->toBe(5)
->and($inventory->getAvailableQuantity())->toBe(45);
// Ship order: fulfil
$fulfilled = $this->service->fulfillStock($this->product, $warehouse, 5, 'ORD-LIFE');
expect($fulfilled)->toBeTrue();
$inventory->refresh();
expect($inventory->quantity)->toBe(45)
->and($inventory->reserved_quantity)->toBe(0)
->and($inventory->getAvailableQuantity())->toBe(45);
});
it('handles reserve -> release workflow (cancelled order)', function () {
$warehouse = $this->service->createWarehouse($this->entity, [
'code' => 'canc',
'name' => 'Cancel Warehouse',
'is_active' => true,
]);
$this->service->addStock($this->product, $warehouse, 30);
$this->service->reserveStock($this->product, $warehouse, 10, 'ORD-CANC');
$inventory = $this->service->getOrCreateInventory($this->product, $warehouse);
expect($inventory->getAvailableQuantity())->toBe(20);
// Order cancelled: release
$this->service->releaseStock($this->product, $warehouse, 10, 'ORD-CANC');
$inventory->refresh();
expect($inventory->quantity)->toBe(30)
->and($inventory->reserved_quantity)->toBe(0)
->and($inventory->getAvailableQuantity())->toBe(30);
});
});
});