'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(['catalogItem', 'variant'])->get(); return (float) $items->reduce( fn (float $carry, CartItem $item): float => $carry + (($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad), 0.0, ); } public function addItem(int $catalogItemId, ?int $variantId, int $quantity): CartItem { if ($quantity <= 0) { throw ValidationException::withMessages([ 'cantidad' => __('api.cart.positive_quantity'), ]); } return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem { self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail(); $selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true); $cartQuantity = (int) $this->items() ->where('catalog_item_id', $catalogItemId) ->sum('cantidad'); $this->assertUserPurchaseLimit($selectedItem, $cartQuantity + $quantity); $inventoryService = app(CatalogInventoryService::class); $availableQuantity = $inventoryService->availableQuantity($selectedItem); if ($availableQuantity !== null && $availableQuantity < $quantity) { throw ValidationException::withMessages([ 'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]), ]); } /** @var CartItem|null $item */ $item = $this->items() ->where('catalog_item_id', $catalogItemId) ->where('variant_id', $variantId) ->lockForUpdate() ->first(); if ($item === null) { $item = $this->items()->create([ 'catalog_item_id' => $catalogItemId, 'variant_id' => $variantId, 'cantidad' => $quantity, ]); } else { $item->cantidad += $quantity; $item->save(); } $inventoryService->reserve($selectedItem, $quantity); return $item->fresh(); }); } public function updateItem( int $cartItemId, int $quantity, ?int $variantId = null, bool $updateVariant = false, ): CartItem { if ($quantity <= 0) { throw ValidationException::withMessages([ 'cantidad' => __('api.cart.positive_quantity'), ]); } return DB::transaction(function () use ( $cartItemId, $quantity, $variantId, $updateVariant, ): CartItem { /** @var CartItem $item */ $item = $this->items() ->where('id', $cartItemId) ->lockForUpdate() ->firstOrFail(); $currentSelection = $this->resolveScopedItem( $item->catalog_item_id, $item->variant_id, true, ); $inventoryService = app(CatalogInventoryService::class); if ($updateVariant && $variantId !== $item->variant_id) { $nextSelection = $this->resolveScopedItem( $item->catalog_item_id, $variantId, true, ); $otherVariantsQuantity = (int) $this->items() ->where('catalog_item_id', $item->catalog_item_id) ->whereKeyNot($item->getKey()) ->sum('cantidad'); $this->assertUserPurchaseLimit( $nextSelection, $otherVariantsQuantity + $quantity, ); $inventoryService->release($currentSelection, $item->cantidad); $availableQuantity = $inventoryService->availableQuantity($nextSelection); if ($availableQuantity !== null && $availableQuantity < $quantity) { throw ValidationException::withMessages([ 'variant_id' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]), ]); } $targetItem = $this->items() ->where('catalog_item_id', $item->catalog_item_id) ->where('variant_id', $variantId) ->whereKeyNot($item->getKey()) ->lockForUpdate() ->first(); $inventoryService->reserve($nextSelection, $quantity); if ($targetItem !== null) { $targetItem->cantidad += $quantity; $targetItem->save(); $item->delete(); return $targetItem->fresh(); } $item->variant_id = $variantId; $item->cantidad = $quantity; $item->save(); return $item->fresh(); } $delta = $quantity - $item->cantidad; if ($delta > 0) { $otherVariantsQuantity = (int) $this->items() ->where('catalog_item_id', $item->catalog_item_id) ->whereKeyNot($item->getKey()) ->sum('cantidad'); $this->assertUserPurchaseLimit( $currentSelection, $otherVariantsQuantity + $quantity, ); } $availableQuantity = $inventoryService->availableQuantity($currentSelection); if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) { $maxAvailable = $availableQuantity + $item->cantidad; throw ValidationException::withMessages([ 'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]), ]); } $item->cantidad = $quantity; $item->save(); if ($delta > 0) { $inventoryService->reserve($currentSelection, $delta); } if ($delta < 0) { $inventoryService->release($currentSelection, 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(); $selectedItem = $this->resolveScopedItem( $item->catalog_item_id, $item->variant_id, true, ); app(CatalogInventoryService::class)->release( $selectedItem, $item->cantidad, ); $item->delete(); }); } protected function resolveScopedItem( int $catalogItemId, ?int $variantId, bool $lockForUpdate = false, ): CatalogItem|Variant { $catalogItemQuery = CatalogItem::query() ->whereKey($catalogItemId) ->where('tenant_code', $this->tenant_codigo); if ($lockForUpdate) { $catalogItemQuery->lockForUpdate(); } $catalogItem = $catalogItemQuery->first(); if ($catalogItem === null) { throw new NotFoundHttpException('Catalog item not found for tenant.'); } if ($catalogItem->isBundle()) { if ($variantId !== null) { throw ValidationException::withMessages([ 'variant_id' => __('api.cart.bundle_variant_forbidden'), ]); } if (! $catalogItem->bundleComponents()->exists()) { throw ValidationException::withMessages([ 'catalog_item_id' => __('api.cart.empty_bundle'), ]); } return $catalogItem; } if ($variantId === null) { if ($catalogItem->inventory_id === null) { throw ValidationException::withMessages([ 'variant_id' => __('api.cart.variant_required'), ]); } $inventory = $this->resolveInventory($catalogItem->inventory_id, $lockForUpdate); $catalogItem->setRelation('inventory', $inventory); return $catalogItem; } $variantQuery = Variant::query() ->whereKey($variantId) ->where('catalog_item_id', $catalogItem->id); if ($lockForUpdate) { $variantQuery->lockForUpdate(); } $variant = $variantQuery->first(); if ($variant === null) { throw new NotFoundHttpException('Variant not found for catalog item.'); } $inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate); $variant->setRelation('catalogItem', $catalogItem); $variant->setRelation('inventory', $inventory); return $variant; } private function assertUserPurchaseLimit( CatalogItem|Variant $selectedItem, int $cartQuantity, ): void { if ($this->user_id === null) { return; } $catalogItem = $selectedItem instanceof Variant ? $selectedItem->catalogItem : $selectedItem; app(UserPurchaseLimitService::class)->assertCanPurchase( $catalogItem, $this->user_id, $cartQuantity, field: 'cantidad', ); } protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory { $query = Inventory::query()->whereKey($inventoryId); if ($lockForUpdate) { $query->lockForUpdate(); } return $query->firstOrFail(); } }