diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 08bc5fa..05bd264 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -68,7 +68,7 @@ class Cart extends Model return DB::transaction(function () use ($productVariantId, $quantity): CartItem { $variant = $this->resolveScopedVariant($productVariantId, true); - if ($variant->stock < $quantity) { + if ($variant->stock_tecnico < $quantity) { throw ValidationException::withMessages([ 'cantidad' => 'Stock insuficiente para la variante solicitada.', ]); @@ -90,7 +90,7 @@ class Cart extends Model $item->save(); } - $variant->decrement('stock', $quantity); + $variant->incrementReservedStock($quantity); return $item->fresh(); }); @@ -114,7 +114,7 @@ class Cart extends Model $variant = $this->resolveScopedVariant($productVariantId, true); $delta = $quantity - $item->cantidad; - if ($delta > 0 && $variant->stock < $delta) { + if ($delta > 0 && $variant->stock_tecnico < $delta) { throw ValidationException::withMessages([ 'cantidad' => 'Stock insuficiente para actualizar la cantidad solicitada.', ]); @@ -124,11 +124,11 @@ class Cart extends Model $item->save(); if ($delta > 0) { - $variant->decrement('stock', $delta); + $variant->incrementReservedStock($delta); } if ($delta < 0) { - $variant->increment('stock', abs($delta)); + $variant->decrementReservedStock(abs($delta)); } return $item->fresh(); @@ -145,7 +145,7 @@ class Cart extends Model ->firstOrFail(); $variant = $this->resolveScopedVariant($productVariantId, true); - $variant->increment('stock', $item->cantidad); + $variant->decrementReservedStock($item->cantidad); $item->delete(); }); } diff --git a/app/Domains/Catalog/Models/ProductVariant.php b/app/Domains/Catalog/Models/ProductVariant.php index fad64ac..2fd2b6e 100644 --- a/app/Domains/Catalog/Models/ProductVariant.php +++ b/app/Domains/Catalog/Models/ProductVariant.php @@ -10,8 +10,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Casts\Attribute; + #[Fillable([ 'producto_id', + 'stock_real', + 'stock_reservado', 'stock', 'is_placeholder', ])] @@ -21,11 +25,87 @@ class ProductVariant extends Model protected $table = 'productos_variantes'; + protected $appends = ['stock_tecnico']; + + protected static function booted(): void + { + static::saving(function (ProductVariant $variant) { + $variant->validateStock(); + }); + } + + public function validateStock(): void + { + if ($this->stock_real < 0) { + throw new \InvalidArgumentException('El stock real no puede ser negativo.'); + } + + if ($this->stock_reservado < 0) { + throw new \InvalidArgumentException('El stock reservado no puede ser negativo.'); + } + + if ($this->stock_reservado > $this->stock_real) { + throw new \InvalidArgumentException('El stock reservado no puede ser mayor que el stock real.'); + } + } + + public function incrementRealStock(int $amount): void + { + if ($amount < 0) { + throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.'); + } + $this->stock_real += $amount; + $this->save(); + } + + public function decrementRealStock(int $amount): void + { + if ($amount < 0) { + throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.'); + } + $this->stock_real -= $amount; + $this->save(); + } + + public function incrementReservedStock(int $amount): void + { + if ($amount < 0) { + throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.'); + } + $this->stock_reservado += $amount; + $this->save(); + } + + public function decrementReservedStock(int $amount): void + { + if ($amount < 0) { + throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.'); + } + $this->stock_reservado -= $amount; + $this->save(); + } + + protected function stockTecnico(): Attribute + { + return Attribute::get(fn () => $this->stock_real - $this->stock_reservado); + } + + protected function stock(): Attribute + { + return Attribute::make( + get: fn () => $this->stock_real, + set: fn ($value) => [ + 'stock_real' => $value, + ] + ); + } + protected function casts(): array { return [ 'producto_id' => 'integer', - 'stock' => 'integer', + 'stock_real' => 'integer', + 'stock_reservado' => 'integer', 'is_placeholder' => 'boolean', ]; } diff --git a/app/Domains/Catalog/Resources/ProductResource.php b/app/Domains/Catalog/Resources/ProductResource.php index 3ca61af..7f6f541 100644 --- a/app/Domains/Catalog/Resources/ProductResource.php +++ b/app/Domains/Catalog/Resources/ProductResource.php @@ -34,7 +34,10 @@ class ProductResource extends JsonResource 'variants_map' => $this->whenLoaded('variants', fn () => $this->variants ->map(fn ($variant) => [ 'variant_id' => $variant->id, - 'stock' => $variant->stock, + 'stock' => $variant->stock_tecnico, + 'stock_real' => $variant->stock_real, + 'stock_reservado' => $variant->stock_reservado, + 'stock_tecnico' => $variant->stock_tecnico, 'attributes' => $variant->definitions ->mapWithKeys(fn ($definition) => [ $definition->productAttribute?->attribute?->codigo => $definition->value, diff --git a/app/Domains/Catalog/Resources/ProductVariantResource.php b/app/Domains/Catalog/Resources/ProductVariantResource.php index d9e5e84..dc0b2d7 100644 --- a/app/Domains/Catalog/Resources/ProductVariantResource.php +++ b/app/Domains/Catalog/Resources/ProductVariantResource.php @@ -18,7 +18,10 @@ class ProductVariantResource extends JsonResource { return [ 'id' => $this->id, - 'stock' => $this->stock, + 'stock' => $this->stock_tecnico, + 'stock_real' => $this->stock_real, + 'stock_reservado' => $this->stock_reservado, + 'stock_tecnico' => $this->stock_tecnico, 'product' => ProductResource::make($this->whenLoaded('product')), 'definitions' => $this->whenLoaded( 'definitions', diff --git a/app/Domains/Catalog/Services/ProductService.php b/app/Domains/Catalog/Services/ProductService.php index 41e82e9..9599805 100644 --- a/app/Domains/Catalog/Services/ProductService.php +++ b/app/Domains/Catalog/Services/ProductService.php @@ -327,13 +327,13 @@ class ProductService $selectedVariant = $variantId !== null ? $product->variants->firstWhere('id', $variantId) - : $product->variants->first(fn (ProductVariant $variant) => $variant->stock > 0); + : $product->variants->first(fn (ProductVariant $variant) => $variant->stock_tecnico > 0); if ($variantId !== null && $selectedVariant === null) { throw new NotFoundHttpException('Product variant not found for product.'); } - if ($variantId !== null && $selectedVariant->stock <= 0) { + if ($variantId !== null && $selectedVariant->stock_tecnico <= 0) { throw ValidationException::withMessages([ 'variant_id' => 'La variante seleccionada no tiene stock.', ]); diff --git a/database/migrations/2026_07_01_160000_add_stock_real_and_stock_reservado_to_productos_variantes_table.php b/database/migrations/2026_07_01_160000_add_stock_real_and_stock_reservado_to_productos_variantes_table.php new file mode 100644 index 0000000..14bfcb9 --- /dev/null +++ b/database/migrations/2026_07_01_160000_add_stock_real_and_stock_reservado_to_productos_variantes_table.php @@ -0,0 +1,36 @@ +renameColumn('stock', 'stock_real'); + }); + + Schema::table('productos_variantes', function (Blueprint $table) { + $table->unsignedInteger('stock_reservado')->default(0)->after('stock_real'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('productos_variantes', function (Blueprint $table) { + $table->dropColumn('stock_reservado'); + }); + + Schema::table('productos_variantes', function (Blueprint $table) { + $table->renameColumn('stock_real', 'stock'); + }); + } +}; diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index ce8e769..ecdaa39 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -83,7 +83,8 @@ class CartControllerTest extends TestCase $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, - 'stock' => 8, + 'stock_real' => 10, + 'stock_reservado' => 2, ]); } @@ -124,7 +125,8 @@ class CartControllerTest extends TestCase ]); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, - 'stock' => 7, + 'stock_real' => 12, + 'stock_reservado' => 5, ]); } @@ -162,7 +164,8 @@ class CartControllerTest extends TestCase ]); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, - 'stock' => 5, + 'stock_real' => 10, + 'stock_reservado' => 5, ]); } @@ -194,7 +197,8 @@ class CartControllerTest extends TestCase $this->assertDatabaseCount('carrito_items', 0); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, - 'stock' => 10, + 'stock_real' => 10, + 'stock_reservado' => 0, ]); } diff --git a/tests/Feature/Catalog/ProductControllerTest.php b/tests/Feature/Catalog/ProductControllerTest.php index 5fc54cc..62ad9cb 100644 --- a/tests/Feature/Catalog/ProductControllerTest.php +++ b/tests/Feature/Catalog/ProductControllerTest.php @@ -143,10 +143,10 @@ class ProductControllerTest extends TestCase $this->assertDatabaseHas('productos_variantes', [ 'producto_id' => $product->id, - 'stock' => 10, + 'stock_real' => 10, ]); - $variantS = ProductVariant::where('producto_id', $product->id)->where('stock', 10)->firstOrFail(); + $variantS = ProductVariant::where('producto_id', $product->id)->where('stock_real', 10)->firstOrFail(); $this->assertDatabaseHas('productos_variantes_values', [ 'producto_variante_id' => $variantS->id, 'products_attribute_id' => $productAttributes['size']->id, @@ -321,7 +321,7 @@ class ProductControllerTest extends TestCase // Variant should still exist untouched $this->assertDatabaseHas('productos_variantes', [ 'id' => $v1->id, - 'stock' => 5, + 'stock_real' => 5, ]); }