Skip to content

Commit

Permalink
Resolves #651 if AddProductsToBasketInSinglePositions is activated, p…
Browse files Browse the repository at this point in the history
…roducts can be added to cart even though the stock limit has been reached
  • Loading branch information
mgesing committed Apr 24, 2023
1 parent c4a05e5 commit 773f6bc
Show file tree
Hide file tree
Showing 6 changed files with 100 additions and 85 deletions.
Expand Up @@ -65,11 +65,12 @@ public interface IShoppingCartValidator
/// Validates product settings, authorization and availability.
/// </summary>
/// <param name="cartItem">Shopping cart item with product and settings.</param>
/// <param name="cartItems">Shopping cart items of customer to validate.</param>
/// <param name="warnings">List of errors as string.</param>
/// <param name="storeId">Store identifier.</param>
/// <param name="quantity">Quantity to validate. If <c>null</c>, <see cref="ShoppingCartItem.Quantity"/> is instead.</param>
/// <returns><c>True</c> when product is valid, otherwise <c>false</c>.</returns>
Task<bool> ValidateProductAsync(ShoppingCartItem cartItem, IList<string> warnings, int? storeId = null, int? quantity = null);
Task<bool> ValidateProductAsync(ShoppingCartItem cartItem, IEnumerable<OrganizedShoppingCartItem> cartItems, IList<string> warnings, int? storeId = null, int? quantity = null);

/// <summary>
/// Validates selected product attributes.
Expand Down
165 changes: 89 additions & 76 deletions src/Smartstore.Core/Checkout/Cart/Services/ShoppingCartValidator.cs
Expand Up @@ -58,8 +58,8 @@ public partial class ShoppingCartValidator : IShoppingCartValidator

public virtual async Task<bool> ValidateAccessPermissionsAsync(Customer customer, ShoppingCartType cartType, IList<string> warnings)
{
Guard.NotNull(customer, nameof(customer));
Guard.NotNull(warnings, nameof(warnings));
Guard.NotNull(customer);
Guard.NotNull(warnings);

var isValid = true;

Expand All @@ -82,11 +82,10 @@ public virtual async Task<bool> ValidateAccessPermissionsAsync(Customer customer

public virtual bool ValidateBundleItem(ProductBundleItem bundleItem, IList<string> warnings)
{
Guard.NotNull(bundleItem, nameof(bundleItem));
Guard.NotNull(warnings, nameof(warnings));
Guard.NotNull(bundleItem);
Guard.NotNull(warnings);

var currentWarnings = new List<string>();

var name = bundleItem.GetLocalizedName();

if (!bundleItem.Published)
Expand Down Expand Up @@ -118,8 +117,8 @@ public virtual bool ValidateBundleItem(ProductBundleItem bundleItem, IList<strin

public virtual async Task<bool> ValidateCartAsync(ShoppingCart cart, IList<string> warnings, bool validateCheckoutAttributes = false)
{
Guard.NotNull(cart, nameof(cart));
Guard.NotNull(warnings, nameof(warnings));
Guard.NotNull(cart);
Guard.NotNull(warnings);

var currentWarnings = new List<string>();

Expand Down Expand Up @@ -185,18 +184,18 @@ public virtual async Task<bool> ValidateCartAsync(ShoppingCart cart, IList<strin

public virtual async Task<bool> ValidateAddToCartItemAsync(AddToCartContext ctx, ShoppingCartItem cartItem, IEnumerable<OrganizedShoppingCartItem> cartItems)
{
Guard.NotNull(ctx, nameof(ctx));
Guard.NotNull(cartItem, nameof(cartItem));
Guard.NotNull(cartItems, nameof(cartItems));
Guard.NotNull(ctx);
Guard.NotNull(cartItem);
Guard.NotNull(cartItems);

var warnings = new List<string>();

await ValidateProductAsync(cartItem, warnings, ctx.StoreId);
await ValidateProductAsync(cartItem, cartItems, warnings, ctx.StoreId);
await this.ValidateProductAttributesAsync(cartItem, cartItems, warnings);

ValidateGiftCardInfo(cartItem.Product, cartItem.AttributeSelection, warnings);

// Bundle and bundle items (child items) warnings
// Bundle and bundle items (child items) warnings.
if (ctx.BundleItem != null || !ctx.ChildItems.IsNullOrEmpty())
{
var bundleItem = ctx.BundleItem ?? ctx.ChildItems.Select(x => x.BundleItem).FirstOrDefault();
Expand All @@ -212,7 +211,7 @@ public virtual async Task<bool> ValidateAddToCartItemAsync(AddToCartContext ctx,

public virtual bool ValidateItemsMaximumCartQuantity(ShoppingCartType cartType, int cartItemsCount, IList<string> warnings)
{
Guard.NotNull(warnings, nameof(warnings));
Guard.NotNull(warnings);

var isValid = true;

Expand All @@ -232,9 +231,9 @@ public virtual bool ValidateItemsMaximumCartQuantity(ShoppingCartType cartType,

public virtual bool ValidateGiftCardInfo(Product product, ProductVariantAttributeSelection selection, IList<string> warnings)
{
Guard.NotNull(product, nameof(product));
Guard.NotNull(selection, nameof(selection));
Guard.NotNull(warnings, nameof(warnings));
Guard.NotNull(product);
Guard.NotNull(selection);
Guard.NotNull(warnings);

if (!product.IsGiftCard)
{
Expand Down Expand Up @@ -276,19 +275,24 @@ public virtual bool ValidateGiftCardInfo(Product product, ProductVariantAttribut
return !currentWarnings.Any();
}

public virtual async Task<bool> ValidateProductAsync(ShoppingCartItem cartItem, IList<string> warnings, int? storeId = null, int? quantity = null)
public virtual async Task<bool> ValidateProductAsync(
ShoppingCartItem cartItem,
IEnumerable<OrganizedShoppingCartItem> cartItems,
IList<string> warnings,
int? storeId = null,
int? quantity = null)
{
Guard.NotNull(cartItem, nameof(cartItem));
Guard.NotNull(warnings, nameof(warnings));
Guard.NotNull(cartItem);
Guard.NotNull(warnings);

var product = cartItem.Product;
if (product == null)
var p = cartItem.Product;
if (p == null)
{
warnings.Add(T("Products.NotFound", cartItem.ProductId));
return false;
}

if (product.Deleted)
if (p.Deleted)
{
warnings.Add(T("ShoppingCart.ProductDeleted"));
return false;
Expand All @@ -297,135 +301,144 @@ public virtual async Task<bool> ValidateProductAsync(ShoppingCartItem cartItem,
var currentWarnings = new List<string>();

// Grouped products are not available for order
if (product.ProductType == ProductType.GroupedProduct)
if (p.ProductType == ProductType.GroupedProduct)
{
currentWarnings.Add(T("ShoppingCart.ProductNotAvailableForOrder"));
}

// Validate product bundle, no customer entered price allowed
if (product.ProductType == ProductType.BundledProduct
&& product.BundlePerItemPricing
if (p.ProductType == ProductType.BundledProduct
&& p.BundlePerItemPricing
&& cartItem.CustomerEnteredPrice != decimal.Zero)
{
currentWarnings.Add(T("ShoppingCart.Bundle.NoCustomerEnteredPrice"));
}

// Not published or no permissions for customer or store
if (!product.Published
|| !await _aclService.AuthorizeAsync(product, cartItem.Customer)
|| !await _storeMappingService.AuthorizeAsync(product, storeId ?? _storeContext.CurrentStore.Id))
if (!p.Published
|| !await _aclService.AuthorizeAsync(p, cartItem.Customer)
|| !await _storeMappingService.AuthorizeAsync(p, storeId ?? _storeContext.CurrentStore.Id))
{
currentWarnings.Add(T("ShoppingCart.ProductUnpublished"));
}

// Disabled buy button
if (cartItem.ShoppingCartType == ShoppingCartType.ShoppingCart && product.DisableBuyButton)
if (cartItem.ShoppingCartType == ShoppingCartType.ShoppingCart && p.DisableBuyButton)
{
currentWarnings.Add(T("ShoppingCart.BuyingDisabled"));
}

// Disabled wishlist button
if (cartItem.ShoppingCartType == ShoppingCartType.Wishlist && product.DisableWishlistButton)
if (cartItem.ShoppingCartType == ShoppingCartType.Wishlist && p.DisableWishlistButton)
{
currentWarnings.Add(T("ShoppingCart.WishlistDisabled"));
}

// Call for price
if (cartItem.ShoppingCartType == ShoppingCartType.ShoppingCart && product.CallForPrice)
if (cartItem.ShoppingCartType == ShoppingCartType.ShoppingCart && p.CallForPrice)
{
currentWarnings.Add(T("Products.CallForPrice"));
}

// Customer entered price
if (product.CustomerEntersPrice &&
(cartItem.CustomerEnteredPrice < product.MinimumCustomerEnteredPrice
|| cartItem.CustomerEnteredPrice > product.MaximumCustomerEnteredPrice))
if (p.CustomerEntersPrice &&
(cartItem.CustomerEnteredPrice < p.MinimumCustomerEnteredPrice
|| cartItem.CustomerEnteredPrice > p.MaximumCustomerEnteredPrice))
{
var min = _currencyService.ConvertToWorkingCurrency(product.MinimumCustomerEnteredPrice);
var max = _currencyService.ConvertToWorkingCurrency(product.MaximumCustomerEnteredPrice);
var min = _currencyService.ConvertToWorkingCurrency(p.MinimumCustomerEnteredPrice);
var max = _currencyService.ConvertToWorkingCurrency(p.MaximumCustomerEnteredPrice);

currentWarnings.Add(T("ShoppingCart.CustomerEnteredPrice.RangeError", min, max));
}

// Quantity validation
var hasQuantityWarnings = false;
var quanitityToValidate = quantity ?? cartItem.Quantity;
if (quanitityToValidate <= 0)
var quantityToValidate = quantity ?? cartItem.Quantity;
if (quantityToValidate <= 0)
{
currentWarnings.Add(T("ShoppingCart.QuantityShouldPositive"));
hasQuantityWarnings = true;
}

if (quanitityToValidate < product.OrderMinimumQuantity)
if (quantityToValidate < p.OrderMinimumQuantity)
{
currentWarnings.Add(T("ShoppingCart.MinimumQuantity", product.OrderMinimumQuantity));
currentWarnings.Add(T("ShoppingCart.MinimumQuantity", p.OrderMinimumQuantity));
hasQuantityWarnings = true;
}

if (quanitityToValidate > product.OrderMaximumQuantity)
if (quantityToValidate > p.OrderMaximumQuantity)
{
currentWarnings.Add(T("ShoppingCart.MaximumQuantity", product.OrderMaximumQuantity));
currentWarnings.Add(T("ShoppingCart.MaximumQuantity", p.OrderMaximumQuantity));
hasQuantityWarnings = true;
}

var allowedQuantities = product.ParseAllowedQuantities();
if (allowedQuantities.Length > 0 && !allowedQuantities.Contains(quanitityToValidate))
var allowedQuantities = p.ParseAllowedQuantities();
if (allowedQuantities.Length > 0 && !allowedQuantities.Contains(quantityToValidate))
{
currentWarnings.Add(T("ShoppingCart.AllowedQuantities", string.Join(", ", allowedQuantities)));
}

// Stock validation
var validateOutOfStock = cartItem.ShoppingCartType == ShoppingCartType.ShoppingCart || !_cartSettings.AllowOutOfStockItemsToBeAddedToWishlist;
if (validateOutOfStock && !hasQuantityWarnings)
var validateStock = cartItem.ShoppingCartType == ShoppingCartType.ShoppingCart || !_cartSettings.AllowOutOfStockItemsToBeAddedToWishlist;
if (validateStock && !hasQuantityWarnings)
{
switch (product.ManageInventoryMethod)
if (p.ManageInventoryMethod == ManageInventoryMethod.ManageStock && p.BackorderMode == BackorderMode.NoBackorders)
{
case ManageInventoryMethod.ManageStock:
// INFO: bundles with per-item-pricing are always added in single positions (see ShoppingCart.FindItemInCart).
if (cartItems != null
&& (_cartSettings.AddProductsToBasketInSinglePositions || (p.ProductType == ProductType.BundledProduct && p.BundlePerItemPricing)))
{
if (product.BackorderMode != BackorderMode.NoBackorders || product.StockQuantity >= quanitityToValidate)
break;

var warning = product.StockQuantity > 0
? T("ShoppingCart.QuantityExceedsStock", product.StockQuantity)
: T("ShoppingCart.OutOfStock");
quantityToValidate += cartItems
.Select(x => x.Item)
.Where(x => x.ProductId == p.Id && x.ParentItemId == null)
.Sum(x => x.Quantity);
}

currentWarnings.Add(warning);
if (p.StockQuantity < quantityToValidate)
{
currentWarnings.Add(p.StockQuantity > 0
? T("ShoppingCart.QuantityExceedsStock", p.StockQuantity)
: T("ShoppingCart.OutOfStock"));
}
break;
case ManageInventoryMethod.ManageStockByAttributes:
}
else if (p.ManageInventoryMethod == ManageInventoryMethod.ManageStockByAttributes)
{
var combination = await _productAttributeMaterializer.FindAttributeCombinationAsync(p.Id, cartItem.AttributeSelection);
if (combination != null && !combination.AllowOutOfStockOrders)
{
var combination = await _productAttributeMaterializer.FindAttributeCombinationAsync(product.Id, cartItem.AttributeSelection);
if (combination == null || combination.AllowOutOfStockOrders || combination.StockQuantity >= quanitityToValidate)
break;

var warning = combination.StockQuantity > 0
? T("ShoppingCart.QuantityExceedsStock", combination.StockQuantity)
: T("ShoppingCart.OutOfStock");
if (cartItems != null && _cartSettings.AddProductsToBasketInSinglePositions)
{
quantityToValidate += cartItems
.Select(x => x.Item)
.Where(x => x.ProductId == p.Id && x.ParentItemId == null && x.AttributeSelection.Equals(cartItem.AttributeSelection))
.Sum(x => x.Quantity);
}

currentWarnings.Add(warning);
if (combination.StockQuantity < quantityToValidate)
{
currentWarnings.Add(combination.StockQuantity > 0
? T("ShoppingCart.QuantityExceedsStock", combination.StockQuantity)
: T("ShoppingCart.OutOfStock"));
}
}
break;
case ManageInventoryMethod.DontManageStock:
default:
break;
}
}

// Validate availability
var availableStartDateError = false;
if (product.AvailableStartDateTimeUtc.HasValue)
var invalidStartDate = false;
if (p.AvailableStartDateTimeUtc.HasValue)
{
var availableStartDate = DateTime.SpecifyKind(product.AvailableStartDateTimeUtc.Value, DateTimeKind.Utc);
var availableStartDate = DateTime.SpecifyKind(p.AvailableStartDateTimeUtc.Value, DateTimeKind.Utc);
if (availableStartDate.CompareTo(DateTime.UtcNow) > 0)
{
currentWarnings.Add(T("ShoppingCart.NotAvailable"));
availableStartDateError = true;
invalidStartDate = true;
}
}

if (product.AvailableEndDateTimeUtc.HasValue && !availableStartDateError)
if (p.AvailableEndDateTimeUtc.HasValue && !invalidStartDate)
{
var availableEndDate = DateTime.SpecifyKind(product.AvailableEndDateTimeUtc.Value, DateTimeKind.Utc);
var availableEndDate = DateTime.SpecifyKind(p.AvailableEndDateTimeUtc.Value, DateTimeKind.Utc);
if (availableEndDate.CompareTo(DateTime.UtcNow) < 0)
{
currentWarnings.Add(T("ShoppingCart.NotAvailable"));
Expand Down Expand Up @@ -600,7 +613,7 @@ public virtual async Task<bool> ValidateProductAsync(ShoppingCartItem cartItem,

public virtual async Task<bool> ValidateRequiredProductsAsync(Product product, IEnumerable<OrganizedShoppingCartItem> cartItems, IList<string> warnings)
{
Guard.NotNull(product, nameof(product));
Guard.NotNull(product);

if (!product.RequireOtherProducts)
return true;
Expand Down
Expand Up @@ -424,6 +424,7 @@ public async Task<IActionResult> BundleItemList(GridCommand command, int product
ProductName = x.Product.Name,
ProductTypeName = x.Product.GetProductTypeLabel(Services.Localization),
ProductTypeLabelHint = x.Product.ProductTypeLabelHint,
ProductEditUrl = Url.Action(nameof(ProductController.Edit), "Product", new { id = x.Product.Id, area = "Admin" }),
Sku = x.Product.Sku,
Quantity = x.Quantity,
Discount = x.Discount,
Expand Down
Expand Up @@ -417,6 +417,7 @@ public partial class BundleItemModel : EntityModelBase
[LocalizedDisplay("Admin.Catalog.Products.Fields.ProductType")]
public string ProductTypeName { get; set; }
public string ProductTypeLabelHint { get; set; }
public string ProductEditUrl { get; set; }

[LocalizedDisplay("Admin.Catalog.Products.Fields.Sku")]
public string Sku { get; set; }
Expand Down
Expand Up @@ -44,7 +44,7 @@
<columns>
<column for="ProductName" hideable="false" width="4fr" readonly entity-member="Product.Name">
<display-template>
@Html.LabeledProductName()
@Html.LabeledProductName(urlExpression: "item.row.ProductEditUrl", valueExpression: "item.row.ProductName")
</display-template>
</column>
<column for="Sku" width="1fr" entity-member="Product.Sku" />
Expand Down

0 comments on commit 773f6bc

Please sign in to comment.