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 {
|
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
|
||||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||||
|
|
||||||
if ($variant->stock < $quantity) {
|
if ($variant->stock_tecnico < $quantity) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cantidad' => 'Stock insuficiente para la variante solicitada.',
|
'cantidad' => 'Stock insuficiente para la variante solicitada.',
|
||||||
]);
|
]);
|
||||||
@@ -90,7 +90,7 @@ class Cart extends Model
|
|||||||
$item->save();
|
$item->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
$variant->decrement('stock', $quantity);
|
$variant->incrementReservedStock($quantity);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
@@ -114,7 +114,7 @@ class Cart extends Model
|
|||||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||||
$delta = $quantity - $item->cantidad;
|
$delta = $quantity - $item->cantidad;
|
||||||
|
|
||||||
if ($delta > 0 && $variant->stock < $delta) {
|
if ($delta > 0 && $variant->stock_tecnico < $delta) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cantidad' => 'Stock insuficiente para actualizar la cantidad solicitada.',
|
'cantidad' => 'Stock insuficiente para actualizar la cantidad solicitada.',
|
||||||
]);
|
]);
|
||||||
@@ -124,11 +124,11 @@ class Cart extends Model
|
|||||||
$item->save();
|
$item->save();
|
||||||
|
|
||||||
if ($delta > 0) {
|
if ($delta > 0) {
|
||||||
$variant->decrement('stock', $delta);
|
$variant->incrementReservedStock($delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($delta < 0) {
|
if ($delta < 0) {
|
||||||
$variant->increment('stock', abs($delta));
|
$variant->decrementReservedStock(abs($delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
@@ -145,7 +145,7 @@ class Cart extends Model
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||||
$variant->increment('stock', $item->cantidad);
|
$variant->decrementReservedStock($item->cantidad);
|
||||||
$item->delete();
|
$item->delete();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'producto_id',
|
'producto_id',
|
||||||
|
'stock_real',
|
||||||
|
'stock_reservado',
|
||||||
'stock',
|
'stock',
|
||||||
'is_placeholder',
|
'is_placeholder',
|
||||||
])]
|
])]
|
||||||
@@ -21,11 +25,87 @@ class ProductVariant extends Model
|
|||||||
|
|
||||||
protected $table = 'productos_variantes';
|
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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'producto_id' => 'integer',
|
'producto_id' => 'integer',
|
||||||
'stock' => 'integer',
|
'stock_real' => 'integer',
|
||||||
|
'stock_reservado' => 'integer',
|
||||||
'is_placeholder' => 'boolean',
|
'is_placeholder' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ class ProductResource extends JsonResource
|
|||||||
'variants_map' => $this->whenLoaded('variants', fn () => $this->variants
|
'variants_map' => $this->whenLoaded('variants', fn () => $this->variants
|
||||||
->map(fn ($variant) => [
|
->map(fn ($variant) => [
|
||||||
'variant_id' => $variant->id,
|
'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
|
'attributes' => $variant->definitions
|
||||||
->mapWithKeys(fn ($definition) => [
|
->mapWithKeys(fn ($definition) => [
|
||||||
$definition->productAttribute?->attribute?->codigo => $definition->value,
|
$definition->productAttribute?->attribute?->codigo => $definition->value,
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ class ProductVariantResource extends JsonResource
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'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')),
|
'product' => ProductResource::make($this->whenLoaded('product')),
|
||||||
'definitions' => $this->whenLoaded(
|
'definitions' => $this->whenLoaded(
|
||||||
'definitions',
|
'definitions',
|
||||||
|
|||||||
@@ -327,13 +327,13 @@ class ProductService
|
|||||||
|
|
||||||
$selectedVariant = $variantId !== null
|
$selectedVariant = $variantId !== null
|
||||||
? $product->variants->firstWhere('id', $variantId)
|
? $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) {
|
if ($variantId !== null && $selectedVariant === null) {
|
||||||
throw new NotFoundHttpException('Product variant not found for product.');
|
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([
|
throw ValidationException::withMessages([
|
||||||
'variant_id' => 'La variante seleccionada no tiene stock.',
|
'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', [
|
$this->assertDatabaseHas('productos_variantes', [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'stock' => 8,
|
'stock_real' => 10,
|
||||||
|
'stock_reservado' => 2,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +125,8 @@ class CartControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
$this->assertDatabaseHas('productos_variantes', [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'stock' => 7,
|
'stock_real' => 12,
|
||||||
|
'stock_reservado' => 5,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +164,8 @@ class CartControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
$this->assertDatabaseHas('productos_variantes', [
|
||||||
'id' => $variant->id,
|
'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->assertDatabaseCount('carrito_items', 0);
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
$this->assertDatabaseHas('productos_variantes', [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'stock' => 10,
|
'stock_real' => 10,
|
||||||
|
'stock_reservado' => 0,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,10 +143,10 @@ class ProductControllerTest extends TestCase
|
|||||||
|
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
$this->assertDatabaseHas('productos_variantes', [
|
||||||
'producto_id' => $product->id,
|
'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', [
|
$this->assertDatabaseHas('productos_variantes_values', [
|
||||||
'producto_variante_id' => $variantS->id,
|
'producto_variante_id' => $variantS->id,
|
||||||
'products_attribute_id' => $productAttributes['size']->id,
|
'products_attribute_id' => $productAttributes['size']->id,
|
||||||
@@ -321,7 +321,7 @@ class ProductControllerTest extends TestCase
|
|||||||
// Variant should still exist untouched
|
// Variant should still exist untouched
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
$this->assertDatabaseHas('productos_variantes', [
|
||||||
'id' => $v1->id,
|
'id' => $v1->id,
|
||||||
'stock' => 5,
|
'stock_real' => 5,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user