From c6351d706f03a73901ab385d32864a3dcb4fa34e Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 10 Aug 2026 12:08:09 -0300 Subject: [PATCH] feat(cart): enhance item update functionality to support variant changes and stock management; update related request validation and resource formatting; add tests for variant handling in cart --- .../Cart/Controllers/CartController.php | 16 +++- app/Domains/Cart/Models/Cart.php | 74 +++++++++++++-- .../UpdateCartItemQuantityRequest.php | 2 +- .../Cart/Resources/CartItemResource.php | 35 +++++++ app/Domains/Cart/Services/CartService.php | 16 +++- lang/en/api.php | 1 + lang/es/api.php | 1 + tests/Feature/Cart/CartControllerTest.php | 95 +++++++++++++++++++ 8 files changed, 225 insertions(+), 15 deletions(-) diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php index 88524ba..368a77c 100644 --- a/app/Domains/Cart/Controllers/CartController.php +++ b/app/Domains/Cart/Controllers/CartController.php @@ -54,16 +54,26 @@ class CartController extends Controller Tenant $tenant, CartItem $cartItem, ): CartResource { + $updatesVariant = $request->exists('variant_id'); + return CartResource::make( - $this->cartService->updateItemQuantity( + $this->cartService->updateItem( $tenant, $request, $cartItem->getKey(), (int) $request->validated('cantidad'), + $updatesVariant + ? ($request->validated('variant_id') !== null + ? (int) $request->validated('variant_id') + : null) + : $cartItem->variant_id, + $updatesVariant, ) )->additional([ - 'code' => 'cart.quantity_updated', - 'message' => __('api.cart.quantity_updated'), + 'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated', + 'message' => $updatesVariant + ? __('api.cart.item_updated') + : __('api.cart.quantity_updated'), ]); } diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 56122a7..cfddda5 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -124,27 +124,85 @@ class Cart extends Model }); } - public function updateItem(int $cartItemId, int $quantity): CartItem - { + 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): CartItem { + return DB::transaction(function () use ( + $cartItemId, + $quantity, + $variantId, + $updateVariant, + ): CartItem { /** @var CartItem $item */ $item = $this->items() ->where('id', $cartItemId) ->lockForUpdate() ->firstOrFail(); - $selectedItem = $this->resolveScopedItem( + $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) { @@ -153,12 +211,12 @@ class Cart extends Model ->whereKeyNot($item->getKey()) ->sum('cantidad'); $this->assertUserPurchaseLimit( - $selectedItem, + $currentSelection, $otherVariantsQuantity + $quantity, ); } - $availableQuantity = $inventoryService->availableQuantity($selectedItem); + $availableQuantity = $inventoryService->availableQuantity($currentSelection); if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) { $maxAvailable = $availableQuantity + $item->cantidad; @@ -171,11 +229,11 @@ class Cart extends Model $item->save(); if ($delta > 0) { - $inventoryService->reserve($selectedItem, $delta); + $inventoryService->reserve($currentSelection, $delta); } if ($delta < 0) { - $inventoryService->release($selectedItem, abs($delta)); + $inventoryService->release($currentSelection, abs($delta)); } return $item->fresh(); diff --git a/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php b/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php index 73e55a9..5518642 100644 --- a/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php +++ b/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php @@ -19,7 +19,7 @@ class UpdateCartItemQuantityRequest extends FormRequest return [ 'cantidad' => ['required', 'integer', 'min:1'], 'catalog_item_id' => ['prohibited'], - 'variant_id' => ['prohibited'], + 'variant_id' => ['sometimes', 'nullable', 'integer'], ]; } } diff --git a/app/Domains/Cart/Resources/CartItemResource.php b/app/Domains/Cart/Resources/CartItemResource.php index 67e8316..6256601 100644 --- a/app/Domains/Cart/Resources/CartItemResource.php +++ b/app/Domains/Cart/Resources/CartItemResource.php @@ -3,8 +3,11 @@ namespace App\Domains\Cart\Resources; use App\Domains\Cart\Models\CartItem; +use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Models\Variant; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Collection; /** * @mixin CartItem @@ -36,10 +39,42 @@ class CartItemResource extends JsonResource 'product' => $selectedItem === null ? null : [ 'nombre' => $selectedItem->getName(), 'imagen' => $imageUrl, + 'variants' => $this->catalogItem->variants + ->map(fn (Variant $variant): array => [ + 'id' => $variant->id, + 'precio' => $this->formatMoney($variant->getPrice()), + 'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory->availableStock(), + 'values' => $this->variantValues($variant), + ]) + ->values(), ], ]; } + /** @return Collection> */ + private function variantValues(Variant $variant): Collection + { + $values = $variant->selectionValues(); + $eventDates = $variant->selectedEventDates(); + + if ($eventDates->isNotEmpty()) { + $labels = $eventDates + ->map(fn ($eventDate): string => $eventDate->date->format('d/m/Y').' · ' + .substr($eventDate->time_start, 0, 5).' a ' + .substr($eventDate->time_end, 0, 5)) + ->values(); + + $values->put( + 'event_date', + $labels->count() === 1 ? $labels->first() : $labels->all(), + ); + } + + return $values; + } + protected function formatMoney(float|int|string|null $amount): string { return number_format((float) ($amount ?? 0), 2, '.', ''); diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index db3fdb5..1142944 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -51,11 +51,17 @@ class CartService ]; } - public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart - { + public function updateItem( + Tenant $tenant, + Request $request, + int $cartItemId, + int $quantity, + ?int $variantId, + bool $updateVariant, + ): Cart { $identity = $this->requireIdentity($request); $cart = $this->findCartOrFail($tenant, $identity); - $cart->updateItem($cartItemId, $quantity); + $cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant); return $this->loadCart($cart); } @@ -101,6 +107,10 @@ class CartService return $cart->fresh()->load([ 'items.catalogItem.attachments', 'items.catalogItem.inventory', + 'items.catalogItem.variants.inventory', + 'items.catalogItem.variants.definitions.itemAttribute.attribute', + 'items.catalogItem.variants.eventDates', + 'items.catalogItem.variants.eventDate', 'items.variant.attachments', 'items.variant.inventory', 'items.variant.definitions.itemAttribute.attribute', diff --git a/lang/en/api.php b/lang/en/api.php index 8651e67..980aae2 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -24,6 +24,7 @@ return [ 'cart' => [ 'item_added' => 'Product added to cart.', 'quantity_updated' => 'Product quantity updated.', + 'item_updated' => 'Product updated.', 'item_removed' => 'Product removed from cart.', 'positive_quantity' => 'The quantity must be greater than zero.', 'insufficient_stock' => 'There is not enough stock for the requested product. Maximum available: :max.', diff --git a/lang/es/api.php b/lang/es/api.php index 1b9c6f8..d758a0c 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -24,6 +24,7 @@ return [ 'cart' => [ 'item_added' => 'Producto agregado al carrito.', 'quantity_updated' => 'Cantidad de producto actualizada.', + 'item_updated' => 'Producto actualizado.', 'item_removed' => 'Producto eliminado del carrito.', 'positive_quantity' => 'La cantidad debe ser mayor a cero.', 'insufficient_stock' => 'Stock insuficiente para el producto solicitado. Máximo disponible: :max.', diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 1a6f7f9..e1e26b4 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -256,6 +256,101 @@ class CartControllerTest extends TestCase ]); } + public function test_it_changes_an_item_variant_and_moves_the_stock_reservation(): void + { + $tenant = $this->createTenant('acme'); + [$item, $firstVariant] = $this->createVariantItem($tenant, 10, '15.00'); + $secondInventory = Inventory::query()->create(['real_stock' => 8]); + $secondVariant = $item->variants()->create([ + 'inventory_id' => $secondInventory->id, + 'precio' => '20.00', + ]); + $createResponse = $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $firstVariant->id, + 'cantidad' => 2, + ]); + $guestToken = $createResponse->getCookie('guest_token', false)?->getValue(); + $cartItemId = $createResponse->json('data.items.0.id'); + + $this->call( + 'PATCH', + "/api/tenants/acme/cart/items/{$cartItemId}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], + json_encode(['cantidad' => 2, 'variant_id' => $secondVariant->id]), + ) + ->assertOk() + ->assertJsonPath('data.items.0.variant_id', $secondVariant->id) + ->assertJsonPath('data.items.0.precio_unitario', '20.00') + ->assertJsonCount(2, 'data.items.0.product.variants'); + + $this->assertDatabaseHas('inventories', [ + 'id' => $firstVariant->inventory_id, + 'reserved_stock' => 0, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $secondInventory->id, + 'reserved_stock' => 2, + ]); + } + + public function test_changing_to_a_variant_already_in_the_cart_merges_both_rows(): void + { + $tenant = $this->createTenant('acme'); + [$item, $firstVariant] = $this->createVariantItem($tenant, 10, '15.00'); + $secondInventory = Inventory::query()->create(['real_stock' => 10]); + $secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]); + + $firstResponse = $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $firstVariant->id, + 'cantidad' => 2, + ]); + $guestToken = $firstResponse->getCookie('guest_token', false)?->getValue(); + $firstCartItemId = $firstResponse->json('data.items.0.id'); + + $this->call( + 'POST', + '/api/tenants/acme/cart/items', + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], + json_encode([ + 'catalog_item_id' => $item->id, + 'variant_id' => $secondVariant->id, + 'cantidad' => 3, + ]), + )->assertOk(); + + $this->call( + 'PATCH', + "/api/tenants/acme/cart/items/{$firstCartItemId}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], + json_encode(['cantidad' => 2, 'variant_id' => $secondVariant->id]), + ) + ->assertOk() + ->assertJsonCount(1, 'data.items') + ->assertJsonPath('data.items.0.variant_id', $secondVariant->id) + ->assertJsonPath('data.items.0.cantidad', 5); + + $this->assertDatabaseCount('carrito_items', 1); + $this->assertDatabaseHas('inventories', [ + 'id' => $firstVariant->inventory_id, + 'reserved_stock' => 0, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $secondInventory->id, + 'reserved_stock' => 5, + ]); + } + public function test_it_requires_a_variant_when_the_item_has_variant_inventory(): void { $tenant = $this->createTenant('acme');