id(); $table->softDeletes(); }); Schema::create('inventories', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('sold_units')->default(0); $table->unsignedInteger('reserved_stock')->default(0); $table->unsignedInteger('real_stock')->default(0); $table->unsignedBigInteger('refunded_units')->default(0); $table->unsignedInteger('entry_reserved_stock')->default(0); }); } protected function tearDown(): void { Schema::disableForeignKeyConstraints(); Schema::dropIfExists('inventory_movements'); Schema::dropIfExists('inventories'); Schema::dropIfExists('users'); Schema::enableForeignKeyConstraints(); parent::tearDown(); } public function test_migration_and_rollback_preserve_the_available_balance(): void { DB::table('inventories')->insert([ 'id' => 17, 'real_stock' => 10, 'sold_units' => 3, 'refunded_units' => 1, 'reserved_stock' => 2, 'entry_reserved_stock' => 1, ]); $stockMigration = $this->stockMigration(); $movementMigration = $this->movementMigration(); $stockMigration->up(); $movementMigration->up(); $inventory = Inventory::findOrFail(17); $this->assertSame(12, $inventory->real_stock); $this->assertSame(7, $inventory->availableStock()); $this->assertTrue(Schema::hasTable('inventory_movements')); $inventory->adjustAvailableStock(2); $this->assertSame(9, $inventory->availableStock()); $movementMigration->down(); $stockMigration->down(); $this->assertFalse(Schema::hasColumn('inventories', 'available_stock')); $this->assertFalse(Schema::hasTable('inventory_movements')); $this->assertSame(12, Inventory::findOrFail(17)->real_stock); $this->assertSame(9, Inventory::findOrFail(17)->availableStock()); } public function test_movements_capture_counters_actor_and_idempotency(): void { $this->stockMigration()->up(); $this->movementMigration()->up(); DB::table('users')->insert(['id' => 5]); $user = User::query()->findOrFail(5); $inventory = Inventory::query()->create(['real_stock' => 10]); $inventory->recordInitialization($user); $inventory->adjustAvailableStock(3, $user, '955d2afb-1e36-46df-a9db-d9ee31ed5eb4'); $inventory->adjustAvailableStock(3, $user, '955d2afb-1e36-46df-a9db-d9ee31ed5eb4'); $inventory->reserve(2, true); $inventory->buy(2, true); $inventory->refundStock(1, $user); $this->assertSame(12, $inventory->availableStock()); $this->assertCount(5, $inventory->movements()->get()); $adjustment = InventoryMovement::query() ->where('operation', InventoryMovementOperation::StockIncreased->value) ->firstOrFail(); $this->assertSame(3, $adjustment->available_stock_delta); $this->assertSame(10, $adjustment->available_stock_before); $this->assertSame(13, $adjustment->available_stock_after); $this->assertSame(['real_stock' => 3], $adjustment->counter_deltas); $this->assertSame(5, $adjustment->responsible_user_id); $purchase = InventoryMovement::query() ->where('operation', InventoryMovementOperation::PurchaseCommitted->value) ->firstOrFail(); $this->assertSame(0, $purchase->available_stock_delta); $this->assertSame(['reserved_stock' => -2, 'sold_units' => 2], $purchase->counter_deltas); } public function test_migration_rejects_an_inventory_with_negative_available_stock(): void { DB::table('inventories')->insert([ 'real_stock' => 1, 'reserved_stock' => 2, ]); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('stock disponible actual es negativo'); $this->stockMigration()->up(); } private function stockMigration(): Migration { return require database_path('migrations/2026_09_25_000000_generate_available_inventory_stock.php'); } private function movementMigration(): Migration { return require database_path('migrations/2026_09_25_010000_create_inventory_movements_table.php'); } }