feat(checkout): route carts to replacement variants

This commit is contained in:
2026-09-14 14:09:49 -03:00
parent e42bc545a5
commit 2bd3bf9dc1
6 changed files with 142 additions and 0 deletions

View File

@@ -400,6 +400,17 @@ class Cart extends Model
]);
}
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem;
}
@@ -430,6 +441,12 @@ class Cart extends Model
throw new NotFoundHttpException('Variant not found for catalog item.');
}
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
'variant_id' => [__('api.cart.variant_unavailable')],
]);
}
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('inventory', $inventory);

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use Illuminate\Validation\ValidationException;
class CartVariantReplacementService
{
public function __construct(private readonly CatalogInventoryService $inventory) {}
public function replaceHistoricalVariants(Cart $cart): void
{
$items = $cart->items()
->whereNotNull('variant_id')
->orderBy('id')
->lockForUpdate()
->get();
foreach ($items as $item) {
$variant = Variant::query()->lockForUpdate()->find($item->variant_id);
if ($variant === null) {
throw $this->unavailableVariant();
}
$replacement = $this->latestReplacement($variant);
if ($replacement->is($variant)) {
if (! $variant->isSellable()) {
throw $this->unavailableVariant();
}
continue;
}
if (! $replacement->isSellable()) {
throw $this->unavailableVariant();
}
/** @var CartItem|null $targetItem */
$targetItem = $cart->items()
->whereKeyNot($item->getKey())
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $replacement->getKey())
->lockForUpdate()
->first();
$replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0);
if ($replacement->inventory_id !== $variant->inventory_id) {
$available = $this->inventory->availableQuantity($replacement);
if ($available !== null && $available < $replacementQuantity) {
throw $this->unavailableVariant();
}
}
if ($targetItem !== null) {
$targetItem->cantidad += $item->cantidad;
$targetItem->save();
$item->delete();
continue;
}
$item->update(['variant_id' => $replacement->getKey()]);
}
}
private function latestReplacement(Variant $variant): Variant
{
$current = $variant;
$visited = [];
while ($current->replaced_by_variant_id !== null) {
if (isset($visited[$current->getKey()])) {
throw $this->unavailableVariant();
}
$visited[$current->getKey()] = true;
$current = Variant::query()
->lockForUpdate()
->find($current->replaced_by_variant_id)
?? throw $this->unavailableVariant();
}
return $current;
}
private function unavailableVariant(): ValidationException
{
return ValidationException::withMessages([
'cart_id' => [__('api.cart.cart_variant_unavailable')],
]);
}
}

View File

@@ -41,6 +41,17 @@ class CatalogSelectionResolver
]);
}
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.catalog_item_id" => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem;
}
@@ -70,6 +81,12 @@ class CatalogSelectionResolver
throw new NotFoundHttpException('Variant not found for catalog item.');
}
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.variant_id" => [__('api.cart.variant_unavailable')],
]);
}
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation(
'inventory',

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Services\CartVariantReplacementService;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
@@ -29,6 +30,7 @@ class StartCheckoutService
private readonly InsufficientStockMessageBuilder $stockMessages,
private readonly PurchaseResponseLoader $responses,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly CartVariantReplacementService $variantReplacements,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -250,6 +252,7 @@ class StartCheckoutService
int $cartId,
): Purchase {
$cart = $this->resolveCart($tenant, $userId, $cartId);
$this->variantReplacements->replaceHistoricalVariants($cart);
$cartItems = $cart->items()->lockForUpdate()->get();
if ($cartItems->isEmpty()) {

View File

@@ -34,7 +34,10 @@ return [
'max_quantity' => 'You can add a maximum of :max.',
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
'empty_bundle' => 'The bundle has no components.',
'bundle_component_unavailable' => 'The bundle contains a variant that is no longer available for sale.',
'variant_required' => 'You must select a variant for this item.',
'variant_unavailable' => 'The selected variant was replaced or is no longer available for sale.',
'cart_variant_unavailable' => 'The cart contains a replaced variant or one that is no longer available for sale.',
'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.',
],
'purchase' => [

View File

@@ -34,7 +34,10 @@ return [
'max_quantity' => 'El máximo que se puede agregar es :max.',
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
'empty_bundle' => 'El bundle no tiene componentes.',
'bundle_component_unavailable' => 'El bundle contiene una variante que ya no está disponible para la venta.',
'variant_required' => 'Debe seleccionar una variante para este ítem.',
'variant_unavailable' => 'La variante seleccionada fue reemplazada o ya no está disponible para la venta.',
'cart_variant_unavailable' => 'El carrito contiene una variante reemplazada o que ya no está disponible para la venta.',
'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.',
],
'purchase' => [