diff --git a/routes/api.php b/routes/api.php index 23d1b63..1c0e3fc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -52,10 +52,12 @@ Route::prefix('webhooks')->group(function () { // }); // ───────────────────────────────────────────────────────────────────────────── -// Commerce Billing API (authenticated) +// Commerce Billing API (authenticated + verified) // ───────────────────────────────────────────────────────────────────────────── -Route::middleware('auth')->prefix('commerce')->group(function () { +Route::middleware(['auth', 'verified'])->prefix('commerce')->group(function () { + // ── Read-only endpoints ────────────────────────────────────────────── + // Billing overview Route::get('/billing', [CommerceController::class, 'billing']) ->name('api.commerce.billing'); @@ -74,21 +76,27 @@ Route::middleware('auth')->prefix('commerce')->group(function () { Route::get('/invoices/{invoice}/download', [CommerceController::class, 'downloadInvoice']) ->name('api.commerce.invoices.download'); - // Subscription + // Subscription (read) Route::get('/subscription', [CommerceController::class, 'subscription']) ->name('api.commerce.subscription'); - Route::post('/cancel', [CommerceController::class, 'cancelSubscription']) - ->name('api.commerce.cancel'); - Route::post('/resume', [CommerceController::class, 'resumeSubscription']) - ->name('api.commerce.resume'); // Usage Route::get('/usage', [CommerceController::class, 'usage']) ->name('api.commerce.usage'); - // Plan changes - Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade']) - ->name('api.commerce.upgrade.preview'); - Route::post('/upgrade', [CommerceController::class, 'executeUpgrade']) - ->name('api.commerce.upgrade'); + // ── State-changing endpoints (rate-limited) ────────────────────────── + + Route::middleware('throttle:6,1')->group(function () { + // Subscription management + Route::post('/cancel', [CommerceController::class, 'cancelSubscription']) + ->name('api.commerce.cancel'); + Route::post('/resume', [CommerceController::class, 'resumeSubscription']) + ->name('api.commerce.resume'); + + // Plan changes + Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade']) + ->name('api.commerce.upgrade.preview'); + Route::post('/upgrade', [CommerceController::class, 'executeUpgrade']) + ->name('api.commerce.upgrade'); + }); }); diff --git a/tests/Feature/WarehouseServiceTest.php b/tests/Feature/WarehouseServiceTest.php new file mode 100644 index 0000000..a2a8811 --- /dev/null +++ b/tests/Feature/WarehouseServiceTest.php @@ -0,0 +1,1017 @@ +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); + }); + }); +});