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:
@@ -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) {
|
||||
|
||||
9
app/Domains/Catalog/Enums/InventoryPolicy.php
Normal file
9
app/Domains/Catalog/Enums/InventoryPolicy.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Enums;
|
||||
|
||||
enum InventoryPolicy: string
|
||||
{
|
||||
case Tracked = 'tracked';
|
||||
case Unlimited = 'unlimited';
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
]);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user