feat(checkout): route carts to replacement variants
This commit is contained in:
parent
e42bc545a5
commit
2bd3bf9dc1
|
|
@ -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;
|
return $catalogItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -430,6 +441,12 @@ class Cart extends Model
|
||||||
throw new NotFoundHttpException('Variant not found for catalog item.');
|
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);
|
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
|
||||||
$variant->setRelation('catalogItem', $catalogItem);
|
$variant->setRelation('catalogItem', $catalogItem);
|
||||||
$variant->setRelation('inventory', $inventory);
|
$variant->setRelation('inventory', $inventory);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
return $catalogItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +81,12 @@ class CatalogSelectionResolver
|
||||||
throw new NotFoundHttpException('Variant not found for catalog item.');
|
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('catalogItem', $catalogItem);
|
||||||
$variant->setRelation(
|
$variant->setRelation(
|
||||||
'inventory',
|
'inventory',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout;
|
||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Cart\Services\CartVariantReplacementService;
|
||||||
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
|
@ -29,6 +30,7 @@ class StartCheckoutService
|
||||||
private readonly InsufficientStockMessageBuilder $stockMessages,
|
private readonly InsufficientStockMessageBuilder $stockMessages,
|
||||||
private readonly PurchaseResponseLoader $responses,
|
private readonly PurchaseResponseLoader $responses,
|
||||||
private readonly PurchaseItemSnapshotFactory $snapshots,
|
private readonly PurchaseItemSnapshotFactory $snapshots,
|
||||||
|
private readonly CartVariantReplacementService $variantReplacements,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @param array<string, mixed> $purchaseData */
|
/** @param array<string, mixed> $purchaseData */
|
||||||
|
|
@ -250,6 +252,7 @@ class StartCheckoutService
|
||||||
int $cartId,
|
int $cartId,
|
||||||
): Purchase {
|
): Purchase {
|
||||||
$cart = $this->resolveCart($tenant, $userId, $cartId);
|
$cart = $this->resolveCart($tenant, $userId, $cartId);
|
||||||
|
$this->variantReplacements->replaceHistoricalVariants($cart);
|
||||||
$cartItems = $cart->items()->lockForUpdate()->get();
|
$cartItems = $cart->items()->lockForUpdate()->get();
|
||||||
|
|
||||||
if ($cartItems->isEmpty()) {
|
if ($cartItems->isEmpty()) {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,10 @@ return [
|
||||||
'max_quantity' => 'You can add a maximum of :max.',
|
'max_quantity' => 'You can add a maximum of :max.',
|
||||||
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
|
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
|
||||||
'empty_bundle' => 'The bundle has no components.',
|
'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_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.',
|
'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.',
|
||||||
],
|
],
|
||||||
'purchase' => [
|
'purchase' => [
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,10 @@ return [
|
||||||
'max_quantity' => 'El máximo que se puede agregar es :max.',
|
'max_quantity' => 'El máximo que se puede agregar es :max.',
|
||||||
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
|
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
|
||||||
'empty_bundle' => 'El bundle no tiene componentes.',
|
'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_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.',
|
'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.',
|
||||||
],
|
],
|
||||||
'purchase' => [
|
'purchase' => [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue