diff --git a/app/Domains/Commerce/Catalog/Enums/InventoryMovementOperation.php b/app/Domains/Commerce/Catalog/Enums/InventoryMovementOperation.php new file mode 100644 index 00000000..53870d5e --- /dev/null +++ b/app/Domains/Commerce/Catalog/Enums/InventoryMovementOperation.php @@ -0,0 +1,18 @@ + $query */ public function scopeWhereAvailable(Builder $query): Builder { + $availableInventory = static fn (Builder $inventoryQuery): Builder => Schema::hasColumn('inventories', 'available_stock') + ? $inventoryQuery->where('inventories.available_stock', '>', 0) + : $inventoryQuery->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock'); + return $query->where(function (Builder $query): void { $query->whereNull('catalog_items.sales_end_at') ->orWhere('catalog_items.sales_end_at', '>', now()); - })->where(function (Builder $query): void { + })->where(function (Builder $query) use ($availableInventory): void { $query ->where(function (Builder $unlimitedQuery): void { $unlimitedQuery @@ -236,20 +241,18 @@ class CatalogItem extends Model ->whereNull('replaced_by_variant_id') ->whereHas( 'inventory', - fn (Builder $inventoryQuery): Builder => $inventoryQuery - ->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock') + $availableInventory ) ) - ->orWhere(function (Builder $directItemQuery): void { + ->orWhere(function (Builder $directItemQuery) use ($availableInventory): void { $directItemQuery ->whereDoesntHave('variants') - ->where(function (Builder $inventoryQuery): void { + ->where(function (Builder $inventoryQuery) use ($availableInventory): void { $inventoryQuery ->whereNull('catalog_items.inventory_id') ->orWhereHas( 'inventory', - fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery - ->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock') + $availableInventory ); }); }); diff --git a/app/Domains/Commerce/Catalog/Models/Inventory.php b/app/Domains/Commerce/Catalog/Models/Inventory.php index d18af516..137dca39 100644 --- a/app/Domains/Commerce/Catalog/Models/Inventory.php +++ b/app/Domains/Commerce/Catalog/Models/Inventory.php @@ -2,11 +2,17 @@ namespace App\Domains\Commerce\Catalog\Models; +use App\Domains\Commerce\Catalog\Enums\InventoryMovementOperation; +use App\Domains\Core\Auth\Models\User; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use Illuminate\Validation\ValidationException; #[Fillable([ 'sold_units', @@ -38,6 +44,7 @@ class Inventory extends Model 'reserved_stock' => 'integer', 'entry_reserved_stock' => 'integer', 'real_stock' => 'integer', + 'available_stock' => 'integer', ]; } @@ -59,70 +66,295 @@ class Inventory extends Model return $this->hasMany(StockReservationLine::class); } + /** @return HasMany */ + public function movements(): HasMany + { + return $this->hasMany(InventoryMovement::class); + } + public function availableStock(): int { - return max(0, $this->real_stock - $this->reserved_stock - $this->entry_reserved_stock); + if (! array_key_exists('available_stock', $this->attributes)) { + return max(0, $this->real_stock - $this->reserved_stock - ($this->entry_reserved_stock ?? 0)); + } + + return (int) $this->getAttribute('available_stock'); } public function reserveEntry(int $amount, bool $tracksInventory): void { - if ($amount < 1 || ($tracksInventory && $this->availableStock() < $amount)) { - throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.'); - } - $this->entry_reserved_stock += $amount; - $this->save(); + $this->mutate( + InventoryMovementOperation::EntryStockReserved, + ['entry_reserved_stock' => $amount], + function (self $inventory) use ($amount, $tracksInventory): void { + if ($amount < 1 || ($tracksInventory && $inventory->availableStock() < $amount)) { + throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.'); + } + }, + ); } public function releaseEntry(int $amount): void { - if ($amount < 1 || $this->entry_reserved_stock < $amount) { - throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.'); - } - - $this->entry_reserved_stock -= $amount; - $this->save(); + $this->mutate( + InventoryMovementOperation::EntryStockReleased, + ['entry_reserved_stock' => -$amount], + function (self $inventory) use ($amount): void { + if ($amount < 1 || $inventory->entry_reserved_stock < $amount) { + throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.'); + } + }, + ); } public function reserve(int $amount, bool $tracksInventory): void { - if ($amount < 0) { - throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.'); - } - - if ($tracksInventory && $this->availableStock() < $amount) { - throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.'); - } - - $this->reserved_stock += $amount; - $this->save(); + $this->mutate( + InventoryMovementOperation::StockReserved, + ['reserved_stock' => $amount], + function (self $inventory) use ($amount, $tracksInventory): void { + if ($amount < 0) { + throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.'); + } + if ($tracksInventory && $inventory->availableStock() < $amount) { + throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.'); + } + }, + ); } public function release(int $amount): void { - if ($amount < 0 || $this->reserved_stock < $amount) { - throw new \InvalidArgumentException('La cantidad reservada no es válida.'); - } - - $this->reserved_stock -= $amount; - $this->save(); + $this->mutate( + InventoryMovementOperation::StockReleased, + ['reserved_stock' => -$amount], + function (self $inventory) use ($amount): void { + if ($amount < 0 || $inventory->reserved_stock < $amount) { + throw new \InvalidArgumentException('La cantidad reservada no es válida.'); + } + }, + ); } public function buy(int $amount, bool $tracksInventory): void { - if ($amount < 0 || $this->reserved_stock < $amount) { - throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.'); + $counterDeltas = ['reserved_stock' => -$amount, 'sold_units' => $amount]; + if (! array_key_exists('available_stock', $this->attributes) && $tracksInventory) { + $counterDeltas['real_stock'] = -$amount; } - if ($tracksInventory && $this->real_stock - $this->entry_reserved_stock < $amount) { - throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.'); + $this->mutate( + InventoryMovementOperation::PurchaseCommitted, + $counterDeltas, + function (self $inventory) use ($amount, $tracksInventory): void { + if ($amount < 0 || $inventory->reserved_stock < $amount) { + throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.'); + } + if ($tracksInventory && $inventory->availableStock() < 0) { + throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.'); + } + }, + ); + } + + public function adjustAvailableStock( + int $delta, + ?User $responsibleUser = null, + ?string $idempotencyKey = null, + ): void { + if ($delta === 0) { + return; } - if ($tracksInventory) { - $this->real_stock -= $amount; + $operation = $delta > 0 + ? InventoryMovementOperation::StockIncreased + : InventoryMovementOperation::StockDecreased; + + $this->mutate( + $operation, + ['real_stock' => $delta], + function (self $inventory) use ($delta): void { + if ($inventory->real_stock + $delta < 0 || $inventory->availableStock() + $delta < 0) { + throw ValidationException::withMessages([ + 'stock_difference' => ['El ajuste dejaría el stock en un valor inválido.'], + ]); + } + }, + $responsibleUser, + $idempotencyKey, + ); + } + + public function refundStock(int $amount, ?User $responsibleUser = null): void + { + $counterDeltas = ['refunded_units' => $amount]; + if (! array_key_exists('available_stock', $this->attributes)) { + $counterDeltas['real_stock'] = $amount; } - $this->reserved_stock -= $amount; - $this->sold_units += $amount; - $this->save(); + $this->mutate( + InventoryMovementOperation::StockRefunded, + $counterDeltas, + function () use ($amount): void { + if ($amount < 1) { + throw new \InvalidArgumentException('La cantidad devuelta debe ser positiva.'); + } + }, + $responsibleUser, + ); + } + + public function commitDirectSale(int $amount, bool $tracksInventory = true): void + { + $this->mutate( + InventoryMovementOperation::PurchaseCommitted, + ['sold_units' => $amount], + function (self $inventory) use ($amount, $tracksInventory): void { + if ($amount < 1 || ($tracksInventory && $inventory->availableStock() < $amount)) { + throw new \InvalidArgumentException('No hay suficiente stock para confirmar la compra.'); + } + }, + ); + } + + public function recordInitialization(?User $responsibleUser = null): void + { + $this->recordCurrentState(InventoryMovementOperation::StockInitialized, $responsibleUser); + } + + public function recordTransferInitialization(?User $responsibleUser = null): void + { + $this->recordCurrentState(InventoryMovementOperation::InventoryTransferred, $responsibleUser); + } + + /** @param array $counterDeltas */ + public function transferCounters(array $counterDeltas, ?User $responsibleUser = null): void + { + $counterDeltas = array_filter($counterDeltas, fn (int $delta): bool => $delta !== 0); + if ($counterDeltas === []) { + return; + } + + $this->mutate( + InventoryMovementOperation::InventoryTransferred, + $counterDeltas, + function (self $inventory) use ($counterDeltas): void { + foreach ($counterDeltas as $counter => $delta) { + if ((int) $inventory->getAttribute($counter) + $delta < 0) { + throw new \LogicException("El traslado dejaría {$counter} en un valor negativo."); + } + } + }, + $responsibleUser, + ); + } + + private function recordCurrentState( + InventoryMovementOperation $operation, + ?User $responsibleUser, + ): void { + if (! Schema::hasTable('inventory_movements')) { + return; + } + + $this->refresh(); + $counterDeltas = array_filter([ + 'real_stock' => $this->real_stock, + 'sold_units' => $this->sold_units, + 'reserved_stock' => $this->reserved_stock, + 'entry_reserved_stock' => $this->entry_reserved_stock, + 'refunded_units' => $this->refunded_units, + ], fn (int $delta): bool => $delta !== 0); + + $this->movements()->create([ + 'operation' => $operation, + 'available_stock_delta' => $this->availableStock(), + 'available_stock_before' => 0, + 'available_stock_after' => $this->availableStock(), + 'counter_deltas' => $counterDeltas, + 'responsible_user_id' => $responsibleUser?->getKey() ?? Auth::id(), + ]); + } + + public function resetTransactionCounters(?User $responsibleUser = null): void + { + $this->refresh(); + $counterDeltas = array_filter([ + 'sold_units' => -$this->sold_units, + 'reserved_stock' => -$this->reserved_stock, + 'entry_reserved_stock' => -$this->entry_reserved_stock, + 'refunded_units' => -$this->refunded_units, + ], fn (int $delta): bool => $delta !== 0); + + if ($counterDeltas === []) { + return; + } + + $this->mutate( + InventoryMovementOperation::InventoryReset, + $counterDeltas, + static function (): void {}, + $responsibleUser, + ); + } + + /** + * @param array $counterDeltas + * @param callable(self): void $validate + */ + private function mutate( + InventoryMovementOperation $operation, + array $counterDeltas, + callable $validate, + ?User $responsibleUser = null, + ?string $idempotencyKey = null, + ): void { + DB::transaction(function () use ($operation, $counterDeltas, $validate, $responsibleUser, $idempotencyKey): void { + /** @var self $inventory */ + $inventory = self::query()->lockForUpdate()->findOrFail($this->getKey()); + $this->setRawAttributes($inventory->getAttributes(), true); + + $recordsMovements = Schema::hasTable('inventory_movements'); + if ($recordsMovements && $idempotencyKey !== null) { + $existing = $this->movements() + ->where('idempotency_key', $idempotencyKey) + ->first(); + if ($existing !== null) { + if ($existing->counter_deltas !== $counterDeltas) { + throw ValidationException::withMessages([ + 'stock_adjustment_id' => ['La operación ya fue utilizada con otro ajuste.'], + ]); + } + + return; + } + } + + $validate($this); + $counterDeltas = array_filter($counterDeltas, fn (int $delta): bool => $delta !== 0); + if ($counterDeltas === []) { + return; + } + + $availableBefore = $this->availableStock(); + foreach ($counterDeltas as $counter => $delta) { + $this->setAttribute($counter, (int) $this->getAttribute($counter) + $delta); + } + $this->save(); + $this->refresh(); + + $availableAfter = $this->availableStock(); + if ($recordsMovements) { + $this->movements()->create([ + 'operation' => $operation, + 'available_stock_delta' => $availableAfter - $availableBefore, + 'available_stock_before' => $availableBefore, + 'available_stock_after' => $availableAfter, + 'counter_deltas' => $counterDeltas, + 'responsible_user_id' => $responsibleUser?->getKey() ?? Auth::id(), + 'idempotency_key' => $idempotencyKey, + ]); + } + }); } } diff --git a/app/Domains/Commerce/Catalog/Models/InventoryMovement.php b/app/Domains/Commerce/Catalog/Models/InventoryMovement.php new file mode 100644 index 00000000..d2d65de9 --- /dev/null +++ b/app/Domains/Commerce/Catalog/Models/InventoryMovement.php @@ -0,0 +1,45 @@ + InventoryMovementOperation::class, + 'available_stock_delta' => 'integer', + 'available_stock_before' => 'integer', + 'available_stock_after' => 'integer', + 'counter_deltas' => 'array', + ]; + } + + /** @return BelongsTo */ + public function inventory(): BelongsTo + { + return $this->belongsTo(Inventory::class); + } + + /** @return BelongsTo */ + public function responsibleUser(): BelongsTo + { + return $this->belongsTo(User::class, 'responsible_user_id'); + } +} diff --git a/database/migrations/2026_09_25_000000_generate_available_inventory_stock.php b/database/migrations/2026_09_25_000000_generate_available_inventory_stock.php new file mode 100644 index 00000000..fe0694f0 --- /dev/null +++ b/database/migrations/2026_09_25_000000_generate_available_inventory_stock.php @@ -0,0 +1,59 @@ +assertNonNegative($conversion, 'la conversión produciría un stock real negativo'); + $this->assertNonNegative( + "({$conversion}) - CAST(sold_units AS SIGNED) - CAST(reserved_stock AS SIGNED) - CAST(entry_reserved_stock AS SIGNED) + CAST(refunded_units AS SIGNED)", + 'el stock disponible actual es negativo', + ); + + if (DB::getDriverName() === 'sqlite') { + Schema::table('inventories', function (Blueprint $table): void { + $table->bigInteger('available_stock')->virtualAs(self::FORMULA); + }); + DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]); + } else { + Schema::table('inventories', function (Blueprint $table): void { + $table->unsignedBigInteger('real_stock')->default(0)->change(); + $table->bigInteger('available_stock')->storedAs(self::FORMULA); + }); + DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]); + } + } + + public function down(): void + { + $conversion = 'CAST(real_stock AS SIGNED) - CAST(sold_units AS SIGNED) + CAST(refunded_units AS SIGNED)'; + $this->assertNonNegative($conversion, 'el rollback produciría un stock real negativo'); + + if (DB::getDriverName() === 'sqlite') { + DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]); + Schema::table('inventories', fn (Blueprint $table) => $table->dropColumn('available_stock')); + + return; + } + + DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]); + Schema::table('inventories', fn (Blueprint $table) => $table->dropColumn('available_stock')); + } + + private function assertNonNegative(string $expression, string $reason): void + { + if (DB::table('inventories')->whereRaw("({$expression}) < 0")->exists()) { + throw new RuntimeException("Hay inventarios inconsistentes: {$reason}."); + } + } +}; diff --git a/database/migrations/2026_09_25_010000_create_inventory_movements_table.php b/database/migrations/2026_09_25_010000_create_inventory_movements_table.php new file mode 100644 index 00000000..b43c57c1 --- /dev/null +++ b/database/migrations/2026_09_25_010000_create_inventory_movements_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('inventory_id')->constrained('inventories')->restrictOnDelete(); + $table->string('operation', 40); + $table->bigInteger('available_stock_delta'); + $table->bigInteger('available_stock_before'); + $table->bigInteger('available_stock_after'); + $table->json('counter_deltas'); + $table->foreignId('responsible_user_id')->nullable()->constrained('users')->restrictOnDelete(); + $table->uuid('idempotency_key')->nullable(); + $table->timestamps(); + + $table->unique(['inventory_id', 'idempotency_key']); + $table->index(['inventory_id', 'created_at']); + $table->index(['operation', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inventory_movements'); + } +}; diff --git a/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php b/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php new file mode 100644 index 00000000..c70c29ee --- /dev/null +++ b/tests/Feature/Migrations/InventoryStockMovementMigrationTest.php @@ -0,0 +1,135 @@ +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'); + } +}