feat: Implement inventory policy for product variants

- Introduced InventoryPolicy enum to manage tracked and unlimited inventory types.
- Updated ProductVariant model to include inventory_policy and cantidad_vendida attributes.
- Modified addItem and updateItem methods in Cart to check available quantity based on inventory policy.
- Added migration to include inventory_policy and cantidad_vendida in productos_variantes table.
- Enhanced tests to validate inventory behavior for both tracked and unlimited policies.
- Updated various request and resource classes to handle new inventory fields.
This commit is contained in:
2026-07-16 08:36:59 -03:00
parent 716d8e447c
commit 10157d7c63
16 changed files with 547 additions and 73 deletions

View File

@@ -82,9 +82,9 @@ class Cart extends Model
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
$variant = $this->resolveScopedVariant($productVariantId, true);
if ($variant->stock_tecnico < $quantity) {
if ($variant->tracksInventory() && $variant->availableQuantity() < $quantity) {
throw ValidationException::withMessages([
'cantidad' => "Stock insuficiente para la variante solicitada. Maximo disponible: {$variant->stock_tecnico}.",
'cantidad' => "Stock insuficiente para la variante solicitada. Maximo disponible: {$variant->availableQuantity()}.",
]);
}
@@ -104,7 +104,7 @@ class Cart extends Model
$item->save();
}
$variant->incrementReservedStock($quantity);
$variant->reserveStock($quantity);
return $item->fresh();
});
@@ -128,8 +128,8 @@ class Cart extends Model
$variant = $this->resolveScopedVariant($productVariantId, true);
$delta = $quantity - $item->cantidad;
if ($delta > 0 && $variant->stock_tecnico < $delta) {
$maxAvailable = $variant->stock_tecnico + $item->cantidad;
if ($delta > 0 && $variant->tracksInventory() && $variant->availableQuantity() < $delta) {
$maxAvailable = $variant->availableQuantity() + $item->cantidad;
throw ValidationException::withMessages([
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
]);
@@ -139,7 +139,7 @@ class Cart extends Model
$item->save();
if ($delta > 0) {
$variant->incrementReservedStock($delta);
$variant->reserveStock($delta);
}
if ($delta < 0) {

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Catalog\Enums;
enum InventoryPolicy: string
{
case Tracked = 'tracked';
case Unlimited = 'unlimited';
}

View File

@@ -3,17 +3,18 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\InventoryPolicy;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
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',
'inventory_policy',
'stock_real',
'stock_reservado',
'stock',
@@ -30,9 +31,20 @@ class ProductVariant extends Model
protected $appends = ['stock_tecnico'];
protected $attributes = [
'inventory_policy' => 'tracked',
'stock_real' => 0,
'stock_reservado' => 0,
'cantidad_vendida' => 0,
];
protected static function booted(): void
{
static::saving(function (ProductVariant $variant) {
if ($variant->exists && $variant->isDirty('inventory_policy')) {
throw new \InvalidArgumentException('La politica de inventario no puede modificarse.');
}
$variant->validateStock();
});
}
@@ -47,34 +59,44 @@ class ProductVariant extends Model
throw new \InvalidArgumentException('El stock reservado no puede ser negativo.');
}
if ($this->stock_reservado > $this->stock_real) {
if ($this->cantidad_vendida < 0) {
throw new \InvalidArgumentException('La cantidad vendida no puede ser negativa.');
}
if ($this->tracksInventory() && $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
public function tracksInventory(): bool
{
return $this->inventory_policy === InventoryPolicy::Tracked;
}
public function availableQuantity(): ?int
{
if (! $this->tracksInventory()) {
return null;
}
return $this->stock_real - $this->stock_reservado;
}
public function isAvailableForSale(): bool
{
return ! $this->tracksInventory() || $this->availableQuantity() > 0;
}
public function reserveStock(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.');
if ($this->tracksInventory() && $this->availableQuantity() < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
}
$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();
}
@@ -88,13 +110,13 @@ class ProductVariant extends Model
$this->save();
}
public function confirmReservedStock(int $amount): void
public function buy(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.');
}
if ($this->stock_real < $amount) {
if ($this->tracksInventory() && $this->stock_real < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la reserva.');
}
@@ -102,14 +124,18 @@ class ProductVariant extends Model
throw new \InvalidArgumentException('No hay suficiente stock reservado para confirmar la reserva.');
}
$this->stock_real -= $amount;
if ($this->tracksInventory()) {
$this->stock_real -= $amount;
}
$this->stock_reservado -= $amount;
$this->cantidad_vendida += $amount;
$this->save();
}
protected function stockTecnico(): Attribute
{
return Attribute::get(fn () => $this->stock_real - $this->stock_reservado);
return Attribute::get(fn (): ?int => $this->availableQuantity());
}
protected function stock(): Attribute
@@ -126,8 +152,10 @@ class ProductVariant extends Model
{
return [
'producto_id' => 'integer',
'inventory_policy' => InventoryPolicy::class,
'stock_real' => 'integer',
'stock_reservado' => 'integer',
'cantidad_vendida' => 'integer',
'is_placeholder' => 'boolean',
'has_tickets' => 'boolean',
'minimum_use_date' => 'datetime',

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -32,6 +33,8 @@ class StoreProductRequest extends FormRequest
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'stock' => ['sometimes', 'integer', 'min:0'],
'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)],
'cantidad_vendida' => ['prohibited'],
'attribute_ids' => ['sometimes', 'array'],
'attribute_ids.*' => [
'required',
@@ -41,7 +44,7 @@ class StoreProductRequest extends FormRequest
),
],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
'images.*' => ['required', new ImageOrBase64Rule],
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -20,6 +21,8 @@ class StoreProductVariantRequest extends FormRequest
{
return [
'stock' => ['sometimes', 'integer', 'min:0'],
'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)],
'cantidad_vendida' => ['prohibited'],
'definitions' => ['sometimes', 'array'],
'definitions.*.products_attribute_id' => [
'required',
@@ -31,7 +34,7 @@ class StoreProductVariantRequest extends FormRequest
],
'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
'images.*' => ['required', new ImageOrBase64Rule],
'has_tickets' => ['boolean'],
'minimum_use_date' => ['nullable', 'date'],
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],

View File

@@ -20,6 +20,8 @@ class UpdateProductVariantRequest extends FormRequest
{
return [
'stock' => ['sometimes', 'integer', 'min:0'],
'inventory_policy' => ['prohibited'],
'cantidad_vendida' => ['prohibited'],
'definitions' => ['sometimes', 'array'],
'definitions.*.products_attribute_id' => [
'required',
@@ -31,7 +33,7 @@ class UpdateProductVariantRequest extends FormRequest
],
'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
'images.*' => ['required', new ImageOrBase64Rule],
'has_tickets' => ['boolean'],
'minimum_use_date' => ['nullable', 'date'],
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],

View File

@@ -35,7 +35,9 @@ class ProductResource extends JsonResource
'variants_map' => $this->whenLoaded('variants', fn () => $this->variants
->map(fn ($variant) => [
'variant_id' => $variant->id,
'inventory_policy' => $variant->inventory_policy->value,
'cantidad_maxima' => $variant->stock_tecnico,
'cantidad_vendida' => $variant->cantidad_vendida,
'attributes' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->productAttribute?->attribute?->codigo => $definition->value,

View File

@@ -18,7 +18,9 @@ class ProductVariantResource extends JsonResource
{
return [
'id' => $this->id,
'inventory_policy' => $this->inventory_policy->value,
'cantidad_maxima' => $this->stock_tecnico,
'cantidad_vendida' => $this->cantidad_vendida,
'has_tickets' => $this->has_tickets,
'minimum_use_date' => $this->minimum_use_date,
'maximum_use_date' => $this->maximum_use_date,

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Catalog\Services;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
@@ -28,7 +29,8 @@ class ProductService
$attributeIds = $data['attribute_ids'] ?? [];
$images = $data['images'] ?? [];
$stock = $data['stock'] ?? 0;
unset($data['attribute_ids'], $data['images'], $data['stock']);
$inventoryPolicy = $data['inventory_policy'] ?? InventoryPolicy::Tracked->value;
unset($data['attribute_ids'], $data['images'], $data['stock'], $data['inventory_policy']);
/** @var Product $product */
$product = Product::query()->create([
@@ -45,6 +47,7 @@ class ProductService
// Create default variant with stock
$this->createVariant($product, [
'stock' => $stock,
'inventory_policy' => $inventoryPolicy,
'is_placeholder' => true,
'definitions' => [],
]);
@@ -179,6 +182,7 @@ class ProductService
if ($product->variants()->count() === 0) {
$product->createVariant([
'stock' => 0,
'inventory_policy' => InventoryPolicy::Tracked->value,
'is_placeholder' => true,
'definitions' => [],
]);
@@ -327,13 +331,13 @@ class ProductService
$selectedVariant = $variantId !== null
? $product->variants->firstWhere('id', $variantId)
: $product->variants->first(fn (ProductVariant $variant) => $variant->stock_tecnico > 0);
: $product->variants->first(fn (ProductVariant $variant) => $variant->isAvailableForSale());
if ($variantId !== null && $selectedVariant === null) {
throw new NotFoundHttpException('Product variant not found for product.');
}
if ($variantId !== null && $selectedVariant->stock_tecnico <= 0) {
if ($variantId !== null && ! $selectedVariant->isAvailableForSale()) {
throw ValidationException::withMessages([
'variant_id' => 'La variante seleccionada no tiene stock.',
]);

View File

@@ -97,6 +97,15 @@ class CheckoutService
public function confirmPurchase(Purchase $purchase): void
{
DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if ($purchase->items()->exists()) {
return;
}
/** @var Cart|null $cart */
$cart = $purchase->cart()->lockForUpdate()->first();
@@ -114,10 +123,6 @@ class CheckoutService
]);
}
if ($purchase->items()->exists()) {
return;
}
$cartItems->load('variant.product');
$variants = $this->resolveTenantVariants($purchase->tenant, $cartItems);
$purchaseItemsPayload = $this->buildPurchaseItemsPayload($cartItems, $variants);
@@ -158,7 +163,7 @@ class CheckoutService
/**
* @param Collection<int, CartItem> $cartItems
* @return \Illuminate\Support\Collection<int, ProductVariant>
* @return Collection<int, ProductVariant>
*/
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
{
@@ -218,7 +223,7 @@ class CheckoutService
$quantity = (int) $item->cantidad;
try {
$variant->confirmReservedStock($quantity);
$variant->buy($quantity);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'cart_id' => 'The selected cart has inconsistent stock state.',