feat: implement multi-tenant cart system with stock reservation and inventory tracking
This commit is contained in:
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user