feat(checkout): route carts to replacement variants
This commit is contained in:
@@ -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);
|
||||
|
||||
99
app/Domains/Cart/Services/CartVariantReplacementService.php
Normal file
99
app/Domains/Cart/Services/CartVariantReplacementService.php
Normal 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')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
Reference in New Issue
Block a user