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
This commit is contained in:
@@ -54,16 +54,26 @@ class CartController extends Controller
|
|||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
CartItem $cartItem,
|
CartItem $cartItem,
|
||||||
): CartResource {
|
): CartResource {
|
||||||
|
$updatesVariant = $request->exists('variant_id');
|
||||||
|
|
||||||
return CartResource::make(
|
return CartResource::make(
|
||||||
$this->cartService->updateItemQuantity(
|
$this->cartService->updateItem(
|
||||||
$tenant,
|
$tenant,
|
||||||
$request,
|
$request,
|
||||||
$cartItem->getKey(),
|
$cartItem->getKey(),
|
||||||
(int) $request->validated('cantidad'),
|
(int) $request->validated('cantidad'),
|
||||||
|
$updatesVariant
|
||||||
|
? ($request->validated('variant_id') !== null
|
||||||
|
? (int) $request->validated('variant_id')
|
||||||
|
: null)
|
||||||
|
: $cartItem->variant_id,
|
||||||
|
$updatesVariant,
|
||||||
)
|
)
|
||||||
)->additional([
|
)->additional([
|
||||||
'code' => 'cart.quantity_updated',
|
'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated',
|
||||||
'message' => __('api.cart.quantity_updated'),
|
'message' => $updatesVariant
|
||||||
|
? __('api.cart.item_updated')
|
||||||
|
: __('api.cart.quantity_updated'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
if ($quantity <= 0) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cantidad' => __('api.cart.positive_quantity'),
|
'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 */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
->where('id', $cartItemId)
|
->where('id', $cartItemId)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$selectedItem = $this->resolveScopedItem(
|
$currentSelection = $this->resolveScopedItem(
|
||||||
$item->catalog_item_id,
|
$item->catalog_item_id,
|
||||||
$item->variant_id,
|
$item->variant_id,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
$inventoryService = app(CatalogInventoryService::class);
|
$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;
|
$delta = $quantity - $item->cantidad;
|
||||||
|
|
||||||
if ($delta > 0) {
|
if ($delta > 0) {
|
||||||
@@ -153,12 +211,12 @@ class Cart extends Model
|
|||||||
->whereKeyNot($item->getKey())
|
->whereKeyNot($item->getKey())
|
||||||
->sum('cantidad');
|
->sum('cantidad');
|
||||||
$this->assertUserPurchaseLimit(
|
$this->assertUserPurchaseLimit(
|
||||||
$selectedItem,
|
$currentSelection,
|
||||||
$otherVariantsQuantity + $quantity,
|
$otherVariantsQuantity + $quantity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
|
||||||
|
|
||||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||||
@@ -171,11 +229,11 @@ class Cart extends Model
|
|||||||
$item->save();
|
$item->save();
|
||||||
|
|
||||||
if ($delta > 0) {
|
if ($delta > 0) {
|
||||||
$inventoryService->reserve($selectedItem, $delta);
|
$inventoryService->reserve($currentSelection, $delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($delta < 0) {
|
if ($delta < 0) {
|
||||||
$inventoryService->release($selectedItem, abs($delta));
|
$inventoryService->release($currentSelection, abs($delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class UpdateCartItemQuantityRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'cantidad' => ['required', 'integer', 'min:1'],
|
'cantidad' => ['required', 'integer', 'min:1'],
|
||||||
'catalog_item_id' => ['prohibited'],
|
'catalog_item_id' => ['prohibited'],
|
||||||
'variant_id' => ['prohibited'],
|
'variant_id' => ['sometimes', 'nullable', 'integer'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
namespace App\Domains\Cart\Resources;
|
namespace App\Domains\Cart\Resources;
|
||||||
|
|
||||||
use App\Domains\Cart\Models\CartItem;
|
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\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @mixin CartItem
|
* @mixin CartItem
|
||||||
@@ -36,10 +39,42 @@ class CartItemResource extends JsonResource
|
|||||||
'product' => $selectedItem === null ? null : [
|
'product' => $selectedItem === null ? null : [
|
||||||
'nombre' => $selectedItem->getName(),
|
'nombre' => $selectedItem->getName(),
|
||||||
'imagen' => $imageUrl,
|
'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<string, string|array<int, string>> */
|
||||||
|
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
|
protected function formatMoney(float|int|string|null $amount): string
|
||||||
{
|
{
|
||||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||||
|
|||||||
@@ -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);
|
$identity = $this->requireIdentity($request);
|
||||||
$cart = $this->findCartOrFail($tenant, $identity);
|
$cart = $this->findCartOrFail($tenant, $identity);
|
||||||
$cart->updateItem($cartItemId, $quantity);
|
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
|
||||||
|
|
||||||
return $this->loadCart($cart);
|
return $this->loadCart($cart);
|
||||||
}
|
}
|
||||||
@@ -101,6 +107,10 @@ class CartService
|
|||||||
return $cart->fresh()->load([
|
return $cart->fresh()->load([
|
||||||
'items.catalogItem.attachments',
|
'items.catalogItem.attachments',
|
||||||
'items.catalogItem.inventory',
|
'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.attachments',
|
||||||
'items.variant.inventory',
|
'items.variant.inventory',
|
||||||
'items.variant.definitions.itemAttribute.attribute',
|
'items.variant.definitions.itemAttribute.attribute',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ return [
|
|||||||
'cart' => [
|
'cart' => [
|
||||||
'item_added' => 'Product added to cart.',
|
'item_added' => 'Product added to cart.',
|
||||||
'quantity_updated' => 'Product quantity updated.',
|
'quantity_updated' => 'Product quantity updated.',
|
||||||
|
'item_updated' => 'Product updated.',
|
||||||
'item_removed' => 'Product removed from cart.',
|
'item_removed' => 'Product removed from cart.',
|
||||||
'positive_quantity' => 'The quantity must be greater than zero.',
|
'positive_quantity' => 'The quantity must be greater than zero.',
|
||||||
'insufficient_stock' => 'There is not enough stock for the requested product. Maximum available: :max.',
|
'insufficient_stock' => 'There is not enough stock for the requested product. Maximum available: :max.',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ return [
|
|||||||
'cart' => [
|
'cart' => [
|
||||||
'item_added' => 'Producto agregado al carrito.',
|
'item_added' => 'Producto agregado al carrito.',
|
||||||
'quantity_updated' => 'Cantidad de producto actualizada.',
|
'quantity_updated' => 'Cantidad de producto actualizada.',
|
||||||
|
'item_updated' => 'Producto actualizado.',
|
||||||
'item_removed' => 'Producto eliminado del carrito.',
|
'item_removed' => 'Producto eliminado del carrito.',
|
||||||
'positive_quantity' => 'La cantidad debe ser mayor a cero.',
|
'positive_quantity' => 'La cantidad debe ser mayor a cero.',
|
||||||
'insufficient_stock' => 'Stock insuficiente para el producto solicitado. Máximo disponible: :max.',
|
'insufficient_stock' => 'Stock insuficiente para el producto solicitado. Máximo disponible: :max.',
|
||||||
|
|||||||
@@ -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
|
public function test_it_requires_a_variant_when_the_item_has_variant_inventory(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('acme');
|
$tenant = $this->createTenant('acme');
|
||||||
|
|||||||
Reference in New Issue
Block a user