'integer', ]; } /** * @return BelongsTo */ public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); } /** * @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo(User::class); } /** * @return HasMany */ public function items(): HasMany { return $this->hasMany(CartItem::class, 'cart_id'); } public function getTotalAmount(): float { $items = $this->relationLoaded('items') ? $this->getRelation('items') : $this->items()->with('buyable')->get(); return (float) $items->reduce( fn (float $carry, $item): float => $carry + ($item->buyable?->getPrice() * $item->cantidad), 0.0, ); } public function addItem(string $buyableType, int $buyableId, int $quantity): CartItem { if ($quantity <= 0) { throw ValidationException::withMessages([ 'cantidad' => 'La cantidad debe ser mayor a cero.', ]); } return DB::transaction(function () use ($buyableType, $buyableId, $quantity): CartItem { $buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true); $canonicalType = $buyable::class; $availableQuantity = $buyable->availableQuantity(); if ($availableQuantity !== null && $availableQuantity < $quantity) { throw ValidationException::withMessages([ 'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.", ]); } /** @var CartItem|null $item */ $item = $this->items() ->where('buyable_type', $canonicalType) ->where('buyable_id', $buyable->getKey()) ->lockForUpdate() ->first(); if ($item === null) { $item = $this->items()->create([ 'buyable_type' => $canonicalType, 'buyable_id' => $buyable->getKey(), 'cantidad' => $quantity, ]); } else { $item->cantidad += $quantity; $item->save(); } $buyable->reserveStock($quantity); return $item->fresh(); }); } public function updateItem(int $cartItemId, int $quantity): CartItem { if ($quantity <= 0) { throw ValidationException::withMessages([ 'cantidad' => 'La cantidad debe ser mayor a cero.', ]); } return DB::transaction(function () use ($cartItemId, $quantity): CartItem { /** @var CartItem $item */ $item = $this->items() ->where('id', $cartItemId) ->lockForUpdate() ->firstOrFail(); $buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true); $delta = $quantity - $item->cantidad; $availableQuantity = $buyable->availableQuantity(); if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) { $maxAvailable = $availableQuantity + $item->cantidad; throw ValidationException::withMessages([ 'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.", ]); } $item->cantidad = $quantity; $item->save(); if ($delta > 0) { $buyable->reserveStock($delta); } if ($delta < 0) { $buyable->decrementReservedStock(abs($delta)); } return $item->fresh(); }); } public function removeItem(int $cartItemId): void { DB::transaction(function () use ($cartItemId): void { /** @var CartItem $item */ $item = $this->items() ->where('id', $cartItemId) ->lockForUpdate() ->firstOrFail(); $buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true); $buyable->decrementReservedStock($item->cantidad); $item->delete(); }); } protected function resolveScopedBuyable(string $buyableType, int $buyableId, bool $lockForUpdate = false): Buyable { $buyableClass = $this->resolveBuyableClass($buyableType); if ($buyableClass === ProductVariant::class) { $query = ProductVariant::query() ->whereKey($buyableId) ->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo)); } else { $query = Bundle::query() ->whereKey($buyableId) ->where('tenant_codigo', $this->tenant_codigo); } if ($lockForUpdate) { $query->lockForUpdate(); } $buyable = $query->first(); if ($buyable === null) { throw new NotFoundHttpException('Buyable not found for tenant.'); } return $buyable; } protected function resolveBuyableClass(string $buyableType): string { return match ($buyableType) { 'variant', ProductVariant::class => ProductVariant::class, 'bundle', Bundle::class => Bundle::class, default => throw new \InvalidArgumentException('Invalid buyable type'), }; } }