Skip to content

Commit 1d11147

Browse files
authored
Merge pull request #10020 from magento-lynx/graphql-api-enhancements
2 parents 27147ce + c23cd8d commit 1d11147

File tree

15 files changed

+1840
-55
lines changed

15 files changed

+1840
-55
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Checkout\Model;
9+
10+
use Magento\Framework\App\Config\ScopeConfigInterface;
11+
12+
class Config
13+
{
14+
public const CART_PREFERENCE_CUSTOMER = "customer";
15+
public const CART_PREFERENCE_GUEST = "guest";
16+
private const XML_PATH_CART_MERGE_PREFERENCE = 'checkout/cart/cart_merge_preference';
17+
18+
/**
19+
* Config Constructor
20+
*
21+
* @param ScopeConfigInterface $scopeConfig
22+
*/
23+
public function __construct(
24+
private readonly ScopeConfigInterface $scopeConfig
25+
) {
26+
}
27+
28+
/**
29+
* Get Cart Merge Preference config to update cart quantities
30+
*
31+
* @return string
32+
*/
33+
public function getCartMergePreference(): string
34+
{
35+
return $this->scopeConfig->getValue(self::XML_PATH_CART_MERGE_PREFERENCE);
36+
}
37+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Checkout\Model\Config\Source;
9+
10+
use Magento\Framework\Data\OptionSourceInterface;
11+
12+
class CartMergePreference implements OptionSourceInterface
13+
{
14+
/**
15+
* Retrieve options for cart merge preference
16+
*
17+
* @return array[]
18+
*/
19+
public function toOptionArray(): array
20+
{
21+
return [
22+
['value' => 'guest', 'label' => __('Guest Priority – Override with guest cart quantity')],
23+
['value' => 'customer', 'label' => __('Customer Priority – Override with customer cart quantity')],
24+
['value' => 'merge', 'label' => __('Merge Quantities – Merge quantities of customer and guest cart')]
25+
];
26+
}
27+
}

app/code/Magento/Checkout/etc/adminhtml/system.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@
5757
<label>Enable Clear Shopping Cart</label>
5858
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
5959
</field>
60+
<field id="cart_merge_preference" translate="label" type="select" sortOrder="100" showInDefault="1" showInWebsite="0" showInStore="0">
61+
<label>Cart Merge Preference</label>
62+
<source_model>Magento\Checkout\Model\Config\Source\CartMergePreference</source_model>
63+
<comment>Select how cart item quantities should be merged</comment>
64+
</field>
6065
</group>
6166
<group id="cart_link" translate="label" sortOrder="3" showInDefault="1" showInWebsite="1">
6267
<label>My Cart Link</label>

app/code/Magento/Checkout/etc/config.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<number_items_to_display_pager>20</number_items_to_display_pager>
2222
<crosssell_enabled>1</crosssell_enabled>
2323
<enable_clear_shopping_cart>0</enable_clear_shopping_cart>
24+
<cart_merge_preference>merge</cart_merge_preference>
2425
</cart>
2526
<cart_link>
2627
<use_qty>1</use_qty>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\CustomerGraphQl\Model\Resolver;
9+
10+
use Magento\Framework\Exception\LocalizedException;
11+
use Magento\Framework\GraphQl\Config\Element\Field;
12+
use Magento\Framework\GraphQl\Query\ResolverInterface;
13+
use Magento\Framework\GraphQl\Query\Uid;
14+
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
15+
16+
class CustomerAddressUid implements ResolverInterface
17+
{
18+
/**
19+
* CustomerAddressUid Constructor
20+
*
21+
* @param Uid $idEncoder
22+
*/
23+
public function __construct(
24+
private readonly Uid $idEncoder
25+
) {
26+
}
27+
28+
/**
29+
* @inheritDoc
30+
*/
31+
public function resolve(
32+
Field $field,
33+
$context,
34+
ResolveInfo $info,
35+
?array $value = null,
36+
?array $args = null
37+
): string {
38+
if (!isset($value['id'])) {
39+
throw new LocalizedException(__('Missing required address ID.'));
40+
}
41+
42+
return $this->idEncoder->encode((string) $value['id']);
43+
}
44+
}

app/code/Magento/CustomerGraphQl/etc/schema.graphqls

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ type CustomerAddresses {
166166

167167
type CustomerAddress @doc(description: "Contains detailed information about a customer's billing or shipping address."){
168168
id: Int @doc(description: "The ID of a `CustomerAddress` object.")
169+
uid: ID! @doc(description: "The unique ID for a `CustomerAddress` object.") @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\CustomerAddressUid")
169170
customer_id: Int @doc(description: "The customer ID") @deprecated(reason: "`customer_id` is not needed as part of `CustomerAddress`. The `id` is a unique identifier for the addresses.")
170171
region: CustomerAddressRegion @doc(description: "An object containing the region name, region code, and region ID.")
171172
region_id: Int @doc(description: "The unique ID for a pre-defined region.")

app/code/Magento/QuoteGraphQl/Model/Cart/MergeCarts/CartQuantityValidator.php

Lines changed: 177 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,33 +15,38 @@
1515
use Magento\Quote\Api\CartItemRepositoryInterface;
1616
use Magento\Quote\Api\Data\CartInterface;
1717
use Magento\Quote\Api\Data\CartItemInterface;
18+
use Magento\Quote\Model\Quote\Item;
19+
use Magento\Checkout\Model\Config;
20+
use Psr\Log\LoggerInterface;
21+
use Magento\Catalog\Api\Data\ProductInterface;
1822

1923
class CartQuantityValidator implements CartQuantityValidatorInterface
2024
{
2125
/**
22-
* @var CartItemRepositoryInterface
23-
*/
24-
private $cartItemRepository;
25-
26-
/**
27-
* @var StockRegistryInterface
26+
* Array to hold cumulative quantities for each SKU
27+
*
28+
* @var array
2829
*/
29-
private $stockRegistry;
30+
private array $cumulativeQty = [];
3031

3132
/**
33+
* CartQuantityValidator Constructor
34+
*
3235
* @param CartItemRepositoryInterface $cartItemRepository
3336
* @param StockRegistryInterface $stockRegistry
37+
* @param Config $config
38+
* @param LoggerInterface $logger
3439
*/
3540
public function __construct(
36-
CartItemRepositoryInterface $cartItemRepository,
37-
StockRegistryInterface $stockRegistry
41+
private readonly CartItemRepositoryInterface $cartItemRepository,
42+
private readonly StockRegistryInterface $stockRegistry,
43+
private readonly Config $config,
44+
private readonly LoggerInterface $logger
3845
) {
39-
$this->cartItemRepository = $cartItemRepository;
40-
$this->stockRegistry = $stockRegistry;
4146
}
4247

4348
/**
44-
* Validate combined cart quantities to make sure they are within available stock
49+
* Validate combined cart quantities to ensure they are within available stock
4550
*
4651
* @param CartInterface $customerCart
4752
* @param CartInterface $guestCart
@@ -50,33 +55,174 @@ public function __construct(
5055
public function validateFinalCartQuantities(CartInterface $customerCart, CartInterface $guestCart): bool
5156
{
5257
$modified = false;
53-
/** @var CartItemInterface $guestCartItem */
58+
$this->cumulativeQty = [];
59+
60+
/** @var \Magento\Quote\Model\Quote $guestCart */
61+
/** @var \Magento\Quote\Model\Quote $customerCart */
62+
/** @var \Magento\Quote\Model\Quote\Item $guestCartItem */
5463
foreach ($guestCart->getAllVisibleItems() as $guestCartItem) {
5564
foreach ($customerCart->getAllItems() as $customerCartItem) {
56-
if ($customerCartItem->compare($guestCartItem)) {
57-
$product = $customerCartItem->getProduct();
58-
$stockCurrentQty = $this->stockRegistry->getStockStatus(
59-
$product->getId(),
60-
$product->getStore()->getWebsiteId()
61-
)->getQty();
62-
63-
if (($stockCurrentQty < $guestCartItem->getQty() + $customerCartItem->getQty())
64-
&& !$this->isBackordersEnabled($product)) {
65-
try {
66-
$this->cartItemRepository->deleteById($guestCart->getId(), $guestCartItem->getItemId());
67-
$modified = true;
68-
} catch (NoSuchEntityException $e) {
69-
continue;
70-
} catch (CouldNotSaveException $e) {
71-
continue;
72-
}
73-
}
65+
if (!$customerCartItem->compare($guestCartItem)) {
66+
continue;
67+
}
68+
69+
$mergePreference = $this->config->getCartMergePreference();
70+
71+
if ($mergePreference === Config::CART_PREFERENCE_CUSTOMER) {
72+
$this->safeDeleteCartItem((int)$guestCart->getId(), (int)$guestCartItem->getItemId());
73+
$modified = true;
74+
break;
7475
}
76+
77+
$product = $customerCartItem->getProduct();
78+
$sku = $product->getSku();
79+
$websiteId = (int) $product->getStore()->getWebsiteId();
80+
81+
$isQtyValid = $customerCartItem->getChildren()
82+
? $this->validateCompositeProductQty($guestCartItem, $customerCartItem)
83+
: $this->validateProductQty(
84+
$product,
85+
$sku,
86+
$guestCartItem->getQty(),
87+
$customerCartItem->getQty(),
88+
$websiteId
89+
);
90+
91+
if ($mergePreference === Config::CART_PREFERENCE_GUEST) {
92+
$this->safeDeleteCartItem((int)$customerCart->getId(), (int)$customerCartItem->getItemId());
93+
$modified = true;
94+
}
95+
96+
if (!$isQtyValid) {
97+
$this->safeDeleteCartItem((int)$guestCart->getId(), (int)$guestCartItem->getItemId());
98+
$modified = true;
99+
}
100+
101+
break;
75102
}
76103
}
104+
105+
$this->cumulativeQty = [];
106+
77107
return $modified;
78108
}
79109

110+
/**
111+
* Validate product quantity against available stock
112+
*
113+
* @param ProductInterface $product
114+
* @param string $sku
115+
* @param float $guestItemQty
116+
* @param float $customerItemQty
117+
* @param int $websiteId
118+
* @return bool
119+
*/
120+
private function validateProductQty(
121+
ProductInterface $product,
122+
string $sku,
123+
float $guestItemQty,
124+
float $customerItemQty,
125+
int $websiteId
126+
): bool {
127+
$salableQty = $this->stockRegistry->getStockStatus($product->getId(), $websiteId)->getQty();
128+
129+
$this->cumulativeQty[$sku] ??= 0;
130+
$this->cumulativeQty[$sku] += $this->getCurrentCartItemQty($guestItemQty, $customerItemQty);
131+
132+
// If backorders are enabled, allow quantities beyond available stock
133+
if ($this->isBackordersEnabled($product)) {
134+
return true;
135+
}
136+
137+
return $salableQty >= $this->cumulativeQty[$sku];
138+
}
139+
140+
/**
141+
* Validate composite product quantities against available stock
142+
*
143+
* @param Item $guestItem
144+
* @param Item $customerItem
145+
* @return bool
146+
*/
147+
private function validateCompositeProductQty(Item $guestItem, Item $customerItem): bool
148+
{
149+
$guestChildren = $guestItem->getChildren();
150+
$customerChildren = $customerItem->getChildren();
151+
152+
foreach ($customerChildren as $customerChild) {
153+
$sku = $customerChild->getProduct()->getSku();
154+
$guestChild = $this->retrieveChildItem($guestChildren, $sku);
155+
156+
$guestQty = $guestChild ? $guestItem->getQty() * $guestChild->getQty() : 0;
157+
$customerQty = $customerItem->getQty() * $customerChild->getQty();
158+
159+
$product = $customerChild->getProduct();
160+
$websiteId = (int) $product->getStore()->getWebsiteId();
161+
162+
// If backorders are enabled for this product, skip quantity validation
163+
if ($this->isBackordersEnabled($product)) {
164+
continue;
165+
}
166+
167+
if (!$this->validateProductQty($product, $sku, $guestQty, $customerQty, $websiteId)) {
168+
return false;
169+
}
170+
}
171+
172+
return true;
173+
}
174+
175+
/**
176+
* Find a child item by SKU in the list of children
177+
*
178+
* @param CartItemInterface[] $children
179+
* @param string $sku
180+
* @return CartItemInterface|null
181+
*/
182+
private function retrieveChildItem(array $children, string $sku): ?CartItemInterface
183+
{
184+
foreach ($children as $child) {
185+
/** @var \Magento\Quote\Model\Quote\Item $child */
186+
if ($child->getProduct()->getSku() === $sku) {
187+
return $child;
188+
}
189+
}
190+
191+
return null;
192+
}
193+
194+
/**
195+
* Get the current cart item quantity based on the merge preference
196+
*
197+
* @param float $guestCartItemQty
198+
* @param float $customerCartItemQty
199+
* @return float
200+
*/
201+
private function getCurrentCartItemQty(float $guestCartItemQty, float $customerCartItemQty): float
202+
{
203+
return match ($this->config->getCartMergePreference()) {
204+
Config::CART_PREFERENCE_CUSTOMER => $customerCartItemQty,
205+
Config::CART_PREFERENCE_GUEST => $guestCartItemQty,
206+
default => $guestCartItemQty + $customerCartItemQty
207+
};
208+
}
209+
210+
/**
211+
* Safely delete a cart item by ID, logging any exceptions
212+
*
213+
* @param int $cartId
214+
* @param int $itemId
215+
* @return void
216+
*/
217+
private function safeDeleteCartItem(int $cartId, int $itemId): void
218+
{
219+
try {
220+
$this->cartItemRepository->deleteById($cartId, $itemId);
221+
} catch (NoSuchEntityException | CouldNotSaveException $e) {
222+
$this->logger->error($e->getMessage());
223+
}
224+
}
225+
80226
/**
81227
* Check if backorders are enabled for the stock item
82228
*

0 commit comments

Comments
 (0)