feat: implement multi-tenant cart system with stock reservation and inventory tracking

This commit is contained in:
2026-07-01 16:21:53 -03:00
parent 230ea57ce0
commit 6a0b08c9d7
8 changed files with 144 additions and 18 deletions

View File

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

View File

@@ -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',
];
}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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.',
]);

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('productos_variantes', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -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,
]);
}

View File

@@ -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,
]);
}