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