Webiant Logo Webiant Logo
  1. No results found.

    Try your search with a different keyword or use * as a wildcard.

ProductService.cs

using System.Data.SqlTypes;
using Nop.Core;
using Nop.Core.Caching;
using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Discounts;
using Nop.Core.Domain.Localization;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Shipping;
using Nop.Core.Domain.Stores;
using Nop.Core.Infrastructure;
using Nop.Data;
using Nop.Services.Customers;
using Nop.Services.Localization;
using Nop.Services.Messages;
using Nop.Services.Security;
using Nop.Services.Shipping.Date;
using Nop.Services.Stores;

namespace Nop.Services.Catalog;

/// 
/// Product service
/// 
public partial class ProductService : IProductService
{
    #region Fields

    protected readonly CatalogSettings _catalogSettings;
    protected readonly CommonSettings _commonSettings;
    protected readonly IAclService _aclService;
    protected readonly ICustomerService _customerService;
    protected readonly IDateRangeService _dateRangeService;
    protected readonly ILanguageService _languageService;
    protected readonly ILocalizationService _localizationService;
    protected readonly IProductAttributeParser _productAttributeParser;
    protected readonly IProductAttributeService _productAttributeService;
    protected readonly IRepository _categoryRepository;
    protected readonly IRepository _crossSellProductRepository;
    protected readonly IRepository _discountProductMappingRepository;
    protected readonly IRepository _localizedPropertyRepository;
    protected readonly IRepository _manufacturerRepository;
    protected readonly IRepository _productRepository;
    protected readonly IRepository _productAttributeCombinationRepository;
    protected readonly IRepository _productAttributeMappingRepository;
    protected readonly IRepository _productCategoryRepository;
    protected readonly IRepository _productManufacturerRepository;
    protected readonly IRepository _productPictureRepository;
    protected readonly IRepository _productTagMappingRepository;
    protected readonly IRepository _productReviewRepository;
    protected readonly IRepository _productReviewHelpfulnessRepository;
    protected readonly IRepository _productSpecificationAttributeRepository;
    protected readonly IRepository _productTagRepository;
    protected readonly IRepository _productVideoRepository;
    protected readonly IRepository _productWarehouseInventoryRepository;
    protected readonly IRepository _relatedProductRepository;
    protected readonly IRepository _shipmentRepository;
    protected readonly IRepository _stockQuantityHistoryRepository;
    protected readonly IRepository _tierPriceRepository;
    protected readonly ISearchPluginManager _searchPluginManager;
    protected readonly IStaticCacheManager _staticCacheManager;
    protected readonly IStoreMappingService _storeMappingService;
    protected readonly IStoreService _storeService;
    protected readonly IWorkContext _workContext;
    protected readonly LocalizationSettings _localizationSettings;
    private static readonly char[] _separator = [','];

    #endregion

    #region Ctor

    public ProductService(CatalogSettings catalogSettings,
        CommonSettings commonSettings,
        IAclService aclService,
        ICustomerService customerService,
        IDateRangeService dateRangeService,
        ILanguageService languageService,
        ILocalizationService localizationService,
        IProductAttributeParser productAttributeParser,
        IProductAttributeService productAttributeService,
        IRepository categoryRepository,
        IRepository crossSellProductRepository,
        IRepository discountProductMappingRepository,
        IRepository localizedPropertyRepository,
        IRepository manufacturerRepository,
        IRepository productRepository,
        IRepository productAttributeCombinationRepository,
        IRepository productAttributeMappingRepository,
        IRepository productCategoryRepository,
        IRepository productManufacturerRepository,
        IRepository productPictureRepository,
        IRepository productTagMappingRepository,
        IRepository productReviewRepository,
        IRepository productReviewHelpfulnessRepository,
        IRepository productSpecificationAttributeRepository,
        IRepository productTagRepository,
        IRepository productVideoRepository,
        IRepository productWarehouseInventoryRepository,
        IRepository relatedProductRepository,
        IRepository shipmentRepository,
        IRepository stockQuantityHistoryRepository,
        IRepository tierPriceRepository,
        ISearchPluginManager searchPluginManager,
        IStaticCacheManager staticCacheManager,
        IStoreService storeService,
        IStoreMappingService storeMappingService,
        IWorkContext workContext,
        LocalizationSettings localizationSettings)
    {
        _catalogSettings = catalogSettings;
        _commonSettings = commonSettings;
        _aclService = aclService;
        _customerService = customerService;
        _dateRangeService = dateRangeService;
        _languageService = languageService;
        _localizationService = localizationService;
        _productAttributeParser = productAttributeParser;
        _productAttributeService = productAttributeService;
        _categoryRepository = categoryRepository;
        _crossSellProductRepository = crossSellProductRepository;
        _discountProductMappingRepository = discountProductMappingRepository;
        _localizedPropertyRepository = localizedPropertyRepository;
        _manufacturerRepository = manufacturerRepository;
        _productRepository = productRepository;
        _productAttributeCombinationRepository = productAttributeCombinationRepository;
        _productAttributeMappingRepository = productAttributeMappingRepository;
        _productCategoryRepository = productCategoryRepository;
        _productManufacturerRepository = productManufacturerRepository;
        _productPictureRepository = productPictureRepository;
        _productTagMappingRepository = productTagMappingRepository;
        _productReviewRepository = productReviewRepository;
        _productReviewHelpfulnessRepository = productReviewHelpfulnessRepository;
        _productSpecificationAttributeRepository = productSpecificationAttributeRepository;
        _productTagRepository = productTagRepository;
        _productVideoRepository = productVideoRepository;
        _productWarehouseInventoryRepository = productWarehouseInventoryRepository;
        _relatedProductRepository = relatedProductRepository;
        _shipmentRepository = shipmentRepository;
        _stockQuantityHistoryRepository = stockQuantityHistoryRepository;
        _tierPriceRepository = tierPriceRepository;
        _searchPluginManager = searchPluginManager;
        _staticCacheManager = staticCacheManager;
        _storeMappingService = storeMappingService;
        _storeService = storeService;
        _workContext = workContext;
        _localizationSettings = localizationSettings;
    }

    #endregion

    #region Utilities

    /// 
    /// Applies the low stock activity to specified product by the total stock quantity
    /// 
    /// Product
    /// Total stock
    /// A task that represents the asynchronous operation
    protected virtual async Task ApplyLowStockActivityAsync(Product product, int totalStock)
    {
        var isMinimumStockReached = totalStock <= product.MinStockQuantity;

        if (!isMinimumStockReached && !_catalogSettings.PublishBackProductWhenCancellingOrders)
            return;

        switch (product.LowStockActivity)
        {
            case LowStockActivity.DisableBuyButton:
                product.DisableBuyButton = isMinimumStockReached;
                product.DisableWishlistButton = isMinimumStockReached;
                await UpdateProductAsync(product);
                break;

            case LowStockActivity.Unpublish:
                product.Published = !isMinimumStockReached;
                await UpdateProductAsync(product);
                break;

            default:
                break;
        }
    }

    /// 
    /// Gets SKU, Manufacturer part number and GTIN
    /// 
    /// Product
    /// Attributes in XML format
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the sKU, Manufacturer part number, GTIN
    /// 
    protected virtual async Task<(string sku, string manufacturerPartNumber, string gtin)> GetSkuMpnGtinAsync(Product product, string attributesXml)
    {
        ArgumentNullException.ThrowIfNull(product);

        string sku = null;
        string manufacturerPartNumber = null;
        string gtin = null;

        if (!string.IsNullOrEmpty(attributesXml) &&
            product.ManageInventoryMethod == ManageInventoryMethod.ManageStockByAttributes)
        {
            //manage stock by attribute combinations
            //let's find appropriate record
            var combination = await _productAttributeParser.FindProductAttributeCombinationAsync(product, attributesXml);
            if (combination != null)
            {
                sku = combination.Sku;
                manufacturerPartNumber = combination.ManufacturerPartNumber;
                gtin = combination.Gtin;
            }
        }

        if (string.IsNullOrEmpty(sku))
            sku = product.Sku;
        if (string.IsNullOrEmpty(manufacturerPartNumber))
            manufacturerPartNumber = product.ManufacturerPartNumber;
        if (string.IsNullOrEmpty(gtin))
            gtin = product.Gtin;

        return (sku, manufacturerPartNumber, gtin);
    }

    /// 
    /// Get stock message for a product with attributes
    /// 
    /// Product
    /// Attributes in XML format
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the message
    /// 
    protected virtual async Task GetStockMessageForAttributesAsync(Product product, string attributesXml)
    {
        if (!product.DisplayStockAvailability)
            return string.Empty;

        string stockMessage;

        var combination = await _productAttributeParser.FindProductAttributeCombinationAsync(product, attributesXml);
        if (combination != null)
        {
            //combination exists
            var stockQuantity = combination.StockQuantity;
            if (stockQuantity > 0)
            {
                if (product.MinStockQuantity >= stockQuantity && product.LowStockActivity == LowStockActivity.Nothing)
                {
                    stockMessage = product.DisplayStockQuantity
                        ?
                        //display "low stock" with stock quantity
                        string.Format(await _localizationService.GetResourceAsync("Products.Availability.LowStockWithQuantity"), stockQuantity)
                        :
                        //display "low stock" without stock quantity
                        await _localizationService.GetResourceAsync("Products.Availability.LowStock");
                }
                else
                {
                    stockMessage = product.DisplayStockQuantity
                        ?
                        //display "in stock" with stock quantity
                        string.Format(await _localizationService.GetResourceAsync("Products.Availability.InStockWithQuantity"), stockQuantity)
                        :
                        //display "in stock" without stock quantity
                        await _localizationService.GetResourceAsync("Products.Availability.InStock");
                }
            }
            else
            {
                if (combination.AllowOutOfStockOrders)
                {
                    stockMessage = await _localizationService.GetResourceAsync("Products.Availability.InStock");
                }
                else
                {
                    var productAvailabilityRange = await
                        _dateRangeService.GetProductAvailabilityRangeByIdAsync(product.ProductAvailabilityRangeId);
                    stockMessage = productAvailabilityRange == null
                        ? await _localizationService.GetResourceAsync("Products.Availability.OutOfStock")
                        : string.Format(await _localizationService.GetResourceAsync("Products.Availability.AvailabilityRange"),
                            await _localizationService.GetLocalizedAsync(productAvailabilityRange, range => range.Name));
                }
            }
        }
        else
        {
            //no combination configured
            if (product.AllowAddingOnlyExistingAttributeCombinations)
            {
                var allIds = (await _productAttributeService.GetProductAttributeMappingsByProductIdAsync(product.Id)).Where(pa => pa.IsRequired).Select(pa => pa.Id).ToList();
                var exIds = (await _productAttributeParser.ParseProductAttributeMappingsAsync(attributesXml)).Select(pa => pa.Id).ToList();

                var selectedIds = allIds.Intersect(exIds).ToList();

                if (selectedIds.Count != allIds.Count)
                    if (_catalogSettings.AttributeValueOutOfStockDisplayType == AttributeValueOutOfStockDisplayType.AlwaysDisplay)
                        return await _localizationService.GetResourceAsync("Products.Availability.SelectRequiredAttributes");
                    else
                    {
                        var combinations = await _productAttributeService.GetAllProductAttributeCombinationsAsync(product.Id);

                        combinations = combinations.Where(p => p.StockQuantity >= 0 || p.AllowOutOfStockOrders).ToList();

                        var attributes = await combinations.SelectAwait(async c => await _productAttributeParser.ParseProductAttributeMappingsAsync(c.AttributesXml)).ToListAsync();

                        var flag = attributes.SelectMany(a => a).Any(a => selectedIds.Contains(a.Id));

                        if (flag)
                            return await _localizationService.GetResourceAsync("Products.Availability.SelectRequiredAttributes");
                    }

                var productAvailabilityRange = await
                    _dateRangeService.GetProductAvailabilityRangeByIdAsync(product.ProductAvailabilityRangeId);
                stockMessage = productAvailabilityRange == null
                    ? await _localizationService.GetResourceAsync("Products.Availability.OutOfStock")
                    : string.Format(await _localizationService.GetResourceAsync("Products.Availability.AvailabilityRange"),
                        await _localizationService.GetLocalizedAsync(productAvailabilityRange, range => range.Name));
            }
            else
            {
                stockMessage = await _localizationService.GetResourceAsync("Products.Availability.InStock");
            }
        }

        return stockMessage;
    }

    /// 
    /// Get stock message
    /// 
    /// Product
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the message
    /// 
    protected virtual async Task GetStockMessageAsync(Product product)
    {
        if (!product.DisplayStockAvailability)
            return string.Empty;

        var stockMessage = string.Empty;
        var stockQuantity = await GetTotalStockQuantityAsync(product);

        if (stockQuantity > 0)
        {
            if (product.MinStockQuantity >= stockQuantity && product.LowStockActivity == LowStockActivity.Nothing)
            {
                stockMessage = product.DisplayStockQuantity
                    ?
                    //display "low stock" with stock quantity
                    string.Format(await _localizationService.GetResourceAsync("Products.Availability.LowStockWithQuantity"), stockQuantity)
                    :
                    //display "low stock" without stock quantity
                    await _localizationService.GetResourceAsync("Products.Availability.LowStock");
            }
            else
            {
                stockMessage = product.DisplayStockQuantity
                    ?
                    //display "in stock" with stock quantity
                    string.Format(await _localizationService.GetResourceAsync("Products.Availability.InStockWithQuantity"), stockQuantity)
                    :
                    //display "in stock" without stock quantity
                    await _localizationService.GetResourceAsync("Products.Availability.InStock");
            }
        }
        else
        {
            //out of stock
            var productAvailabilityRange = await _dateRangeService.GetProductAvailabilityRangeByIdAsync(product.ProductAvailabilityRangeId);
            switch (product.BackorderMode)
            {
                case BackorderMode.NoBackorders:
                    stockMessage = productAvailabilityRange == null
                        ? await _localizationService.GetResourceAsync("Products.Availability.OutOfStock")
                        : string.Format(await _localizationService.GetResourceAsync("Products.Availability.AvailabilityRange"),
                            await _localizationService.GetLocalizedAsync(productAvailabilityRange, range => range.Name));
                    break;
                case BackorderMode.AllowQtyBelow0:
                    stockMessage = await _localizationService.GetResourceAsync("Products.Availability.InStock");
                    break;
                case BackorderMode.AllowQtyBelow0AndNotifyCustomer:
                    stockMessage = productAvailabilityRange == null
                        ? await _localizationService.GetResourceAsync("Products.Availability.Backordering")
                        : string.Format(await _localizationService.GetResourceAsync("Products.Availability.BackorderingWithDate"),
                            await _localizationService.GetLocalizedAsync(productAvailabilityRange, range => range.Name));
                    break;
            }
        }

        return stockMessage;
    }

    /// 
    /// Reserve the given quantity in the warehouses.
    /// 
    /// Product
    /// Quantity, must be negative
    /// A task that represents the asynchronous operation
    protected virtual async Task ReserveInventoryAsync(Product product, int quantity)
    {
        ArgumentNullException.ThrowIfNull(product);

        if (quantity >= 0)
            throw new ArgumentException("Value must be negative.", nameof(quantity));

        var qty = -quantity;

        var productInventory = _productWarehouseInventoryRepository.Table.Where(pwi => pwi.ProductId == product.Id)
            .OrderByDescending(pwi => pwi.StockQuantity - pwi.ReservedQuantity)
            .ToList();

        if (productInventory.Count <= 0)
            return;

        // 1st pass: Applying reserved
        foreach (var item in productInventory)
        {
            var selectQty = Math.Min(Math.Max(0, item.StockQuantity - item.ReservedQuantity), qty);
            item.ReservedQuantity += selectQty;
            qty -= selectQty;

            if (qty <= 0)
                break;
        }

        if (qty > 0)
        {
            // 2rd pass: Booking negative stock!
            var pwi = productInventory[0];
            pwi.ReservedQuantity += qty;
        }

        await UpdateProductWarehouseInventoryAsync(productInventory);
    }

    /// 
    /// Unblocks the given quantity reserved items in the warehouses
    /// 
    /// Product
    /// Quantity, must be positive
    /// A task that represents the asynchronous operation
    protected virtual async Task UnblockReservedInventoryAsync(Product product, int quantity)
    {
        ArgumentNullException.ThrowIfNull(product);

        if (quantity < 0)
            throw new ArgumentException("Value must be positive.", nameof(quantity));

        var productInventory = await _productWarehouseInventoryRepository.Table.Where(pwi => pwi.ProductId == product.Id)
            .OrderByDescending(pwi => pwi.ReservedQuantity)
            .ThenByDescending(pwi => pwi.StockQuantity)
            .ToListAsync();

        if (!productInventory.Any())
            return;

        var qty = quantity;

        foreach (var item in productInventory)
        {
            var selectQty = Math.Min(item.ReservedQuantity, qty);
            item.ReservedQuantity -= selectQty;
            qty -= selectQty;

            if (qty <= 0)
                break;
        }

        if (qty > 0)
        {
            var pwi = productInventory[0];
            pwi.StockQuantity += qty;
        }

        await UpdateProductWarehouseInventoryAsync(productInventory);
    }

    /// 
    /// Gets cross-sell products by product identifier
    /// 
    /// The first product identifiers
    /// A value indicating whether to show hidden records
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the cross-sell products
    /// 
    protected virtual async Task> GetCrossSellProductsByProductIdsAsync(int[] productIds, bool showHidden = false)
    {
        if (productIds == null || productIds.Length == 0)
            return new List();

        var query = from csp in _crossSellProductRepository.Table
            join p in _productRepository.Table on csp.ProductId2 equals p.Id
            where productIds.Contains(csp.ProductId1) &&
                  !p.Deleted &&
                  (showHidden || p.Published)
            orderby csp.Id
            select csp;
        var crossSellProducts = await query.ToListAsync();

        return crossSellProducts;
    }

    /// 
    /// Gets ratio of useful and not useful product reviews 
    /// 
    /// Product review
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the result
    /// 
    protected virtual async Task<(int usefulCount, int notUsefulCount)> GetHelpfulnessCountsAsync(ProductReview productReview)
    {
        ArgumentNullException.ThrowIfNull(productReview);

        var productReviewHelpfulness = _productReviewHelpfulnessRepository.Table.Where(prh => prh.ProductReviewId == productReview.Id);

        return (await productReviewHelpfulness.CountAsync(prh => prh.WasHelpful),
            await productReviewHelpfulness.CountAsync(prh => !prh.WasHelpful));
    }

    /// 
    /// Inserts a product review helpfulness record
    /// 
    /// Product review helpfulness record
    /// A task that represents the asynchronous operation
    protected virtual async Task InsertProductReviewHelpfulnessAsync(ProductReviewHelpfulness productReviewHelpfulness)
    {
        await _productReviewHelpfulnessRepository.InsertAsync(productReviewHelpfulness);
    }

    #endregion

    #region Methods

    #region Products

    /// 
    /// Delete a product
    /// 
    /// Product
    /// A task that represents the asynchronous operation
    public virtual async Task DeleteProductAsync(Product product)
    {
        await _productRepository.DeleteAsync(product);
    }

    /// 
    /// Delete products
    /// 
    /// Products
    /// A task that represents the asynchronous operation
    public virtual async Task DeleteProductsAsync(IList products)
    {
        await _productRepository.DeleteAsync(products);
    }

    /// 
    /// Gets all products displayed on the home page
    /// 
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// 
    public virtual async Task> GetAllProductsDisplayedOnHomepageAsync()
    {
        var products = await _productRepository.GetAllAsync(query =>
        {
            return from p in query
                orderby p.DisplayOrder, p.Id
                where p.Published &&
                      !p.Deleted &&
                      p.ShowOnHomepage
                select p;
        }, cache => cache.PrepareKeyForDefaultCache(NopCatalogDefaults.ProductsHomepageCacheKey));

        return products;
    }

    /// 
    /// Gets product
    /// 
    /// Product identifier
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the product
    /// 
    public virtual async Task GetProductByIdAsync(int productId)
    {
        return await _productRepository.GetByIdAsync(productId, cache => default);
    }

    /// 
    /// Get products by identifiers
    /// 
    /// Product identifiers
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// 
    public virtual async Task> GetProductsByIdsAsync(int[] productIds)
    {
        return await _productRepository.GetByIdsAsync(productIds, cache => default, false);
    }

    /// 
    /// Inserts a product
    /// 
    /// Product
    /// A task that represents the asynchronous operation
    public virtual async Task InsertProductAsync(Product product)
    {
        await _productRepository.InsertAsync(product);
    }

    /// 
    /// Updates the product
    /// 
    /// Product
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateProductAsync(Product product)
    {
        await _productRepository.UpdateAsync(product);
    }

    /// 
    /// Gets featured products by a category identifier
    /// 
    /// Category identifier
    /// Store identifier; 0 if you want to get all records
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the list of featured products
    /// 
    public virtual async Task> GetCategoryFeaturedProductsAsync(int categoryId, int storeId = 0)
    {
        IList featuredProducts = new List();

        if (categoryId == 0)
            return featuredProducts;

        var customer = await _workContext.GetCurrentCustomerAsync();
        var customerRoleIds = await _customerService.GetCustomerRoleIdsAsync(customer);
        var cacheKey = _staticCacheManager.PrepareKeyForDefaultCache(NopCatalogDefaults.CategoryFeaturedProductsIdsKey, categoryId, customerRoleIds, storeId);

        var featuredProductIds = await _staticCacheManager.GetAsync(cacheKey, async () =>
        {
            var query = from p in _productRepository.Table
                join pc in _productCategoryRepository.Table on p.Id equals pc.ProductId
                where p.Published && !p.Deleted && p.VisibleIndividually &&
                      (!p.AvailableStartDateTimeUtc.HasValue || p.AvailableStartDateTimeUtc.Value < DateTime.UtcNow) &&
                      (!p.AvailableEndDateTimeUtc.HasValue || p.AvailableEndDateTimeUtc.Value > DateTime.UtcNow) &&
                      pc.IsFeaturedProduct && categoryId == pc.CategoryId
                select p;

            //apply store mapping constraints
            query = await _storeMappingService.ApplyStoreMapping(query, storeId);

            //apply ACL constraints
            query = await _aclService.ApplyAcl(query, customerRoleIds);

            featuredProducts = query.ToList();

            return featuredProducts.Select(p => p.Id).ToList();
        });

        if (!featuredProducts.Any() && featuredProductIds.Any())
            featuredProducts = await _productRepository.GetByIdsAsync(featuredProductIds, cache => default, false);

        return featuredProducts;
    }

    /// 
    /// Gets featured products by manufacturer identifier
    /// 
    /// Manufacturer identifier
    /// Store identifier; 0 if you want to get all records
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the list of featured products
    /// 
    public virtual async Task> GetManufacturerFeaturedProductsAsync(int manufacturerId, int storeId = 0)
    {
        IList featuredProducts = new List();

        if (manufacturerId == 0)
            return featuredProducts;

        var customer = await _workContext.GetCurrentCustomerAsync();
        var customerRoleIds = await _customerService.GetCustomerRoleIdsAsync(customer);
        var cacheKey = _staticCacheManager.PrepareKeyForDefaultCache(NopCatalogDefaults.ManufacturerFeaturedProductIdsKey, manufacturerId, customerRoleIds, storeId);

        var featuredProductIds = await _staticCacheManager.GetAsync(cacheKey, async () =>
        {
            var query = from p in _productRepository.Table
                join pm in _productManufacturerRepository.Table on p.Id equals pm.ProductId
                where p.Published && !p.Deleted && p.VisibleIndividually &&
                      (!p.AvailableStartDateTimeUtc.HasValue || p.AvailableStartDateTimeUtc.Value < DateTime.UtcNow) &&
                      (!p.AvailableEndDateTimeUtc.HasValue || p.AvailableEndDateTimeUtc.Value > DateTime.UtcNow) &&
                      pm.IsFeaturedProduct && manufacturerId == pm.ManufacturerId
                select p;

            //apply store mapping constraints
            query = await _storeMappingService.ApplyStoreMapping(query, storeId);

            //apply ACL constraints
            query = await _aclService.ApplyAcl(query, customerRoleIds);

            return query.Select(p => p.Id).ToList();
        });

        if (!featuredProducts.Any() && featuredProductIds.Any())
            featuredProducts = await _productRepository.GetByIdsAsync(featuredProductIds, cache => default, false);

        return featuredProducts;
    }

    /// 
    /// Gets products which marked as new
    /// 
    /// Store identifier; 0 if you want to get all records
    /// Page index
    /// Page size
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the list of new products
    /// 
    public virtual async Task> GetProductsMarkedAsNewAsync(int storeId = 0, int pageIndex = 0, int pageSize = int.MaxValue)
    {
        var query = from p in _productRepository.Table
            where p.Published && p.VisibleIndividually && p.MarkAsNew && !p.Deleted &&
                  DateTime.UtcNow >= (p.MarkAsNewStartDateTimeUtc ?? SqlDateTime.MinValue.Value) &&
                  DateTime.UtcNow <= (p.MarkAsNewEndDateTimeUtc ?? SqlDateTime.MaxValue.Value)
            select p;

        //apply store mapping constraints
        query = await _storeMappingService.ApplyStoreMapping(query, storeId);

        //apply ACL constraints
        var customer = await _workContext.GetCurrentCustomerAsync();
        query = await _aclService.ApplyAcl(query, customer);

        query = query.OrderByDescending(p => p.CreatedOnUtc);

        return await query.ToPagedListAsync(pageIndex, pageSize);
    }

    /// 
    /// Get number of product (published and visible) in certain category
    /// 
    /// Category identifiers
    /// Store identifier; 0 to load all records
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the number of products
    /// 
    public virtual async Task GetNumberOfProductsInCategoryAsync(IList categoryIds = null, int storeId = 0)
    {
        //validate "categoryIds" parameter
        if (categoryIds != null && categoryIds.Contains(0))
            categoryIds.Remove(0);

        var query = _productRepository.Table.Where(p => p.Published && !p.Deleted && p.VisibleIndividually);

        //apply store mapping constraints
        query = await _storeMappingService.ApplyStoreMapping(query, storeId);

        //apply ACL constraints
        var customer = await _workContext.GetCurrentCustomerAsync();
        var customerRoleIds = await _customerService.GetCustomerRoleIdsAsync(customer);
        query = await _aclService.ApplyAcl(query, customerRoleIds);

        //category filtering
        if (categoryIds != null && categoryIds.Any())
        {
            query = from p in query
                join pc in _productCategoryRepository.Table on p.Id equals pc.ProductId
                where categoryIds.Contains(pc.CategoryId)
                select p;
        }

        var cacheKey = _staticCacheManager
            .PrepareKeyForDefaultCache(NopCatalogDefaults.CategoryProductsNumberCacheKey, customerRoleIds, storeId, categoryIds);

        //only distinct products
        return await _staticCacheManager.GetAsync(cacheKey, () => query.Select(p => p.Id).Count());
    }

    /// 
    /// Search products
    /// 
    /// Page index
    /// Page size
    /// Category identifiers
    /// Manufacturer identifiers
    /// Store identifier; 0 to load all records
    /// Vendor identifier; 0 to load all records
    /// Warehouse identifier; 0 to load all records
    /// Product type; 0 to load all records
    /// A values indicating whether to load only products marked as "visible individually"; "false" to load all records; "true" to load "visible individually" only
    /// A value indicating whether loaded products are marked as featured (relates only to categories and manufacturers); "false" (by default) to load all records; "true" to exclude featured products from results
    /// Minimum price; null to load all records
    /// Maximum price; null to load all records
    /// Product tag identifier; 0 to load all records
    /// Keywords
    /// A value indicating whether to search by a specified "keyword" in product descriptions
    /// A value indicating whether to search by a specified "keyword" in manufacturer part number
    /// A value indicating whether to search by a specified "keyword" in product SKU
    /// A value indicating whether to search by a specified "keyword" in product tags
    /// Language identifier (search for text searching)
    /// Specification options list to filter products; null to load all records
    /// Order by
    /// A value indicating whether to show hidden records
    /// 
    /// null - process "Published" property according to "showHidden" parameter
    /// true - load only "Published" products
    /// false - load only "Unpublished" products
    /// 
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// 
    public virtual async Task> SearchProductsAsync(
        int pageIndex = 0,
        int pageSize = int.MaxValue,
        IList categoryIds = null,
        IList manufacturerIds = null,
        int storeId = 0,
        int vendorId = 0,
        int warehouseId = 0,
        ProductType? productType = null,
        bool visibleIndividuallyOnly = false,
        bool excludeFeaturedProducts = false,
        decimal? priceMin = null,
        decimal? priceMax = null,
        int productTagId = 0,
        string keywords = null,
        bool searchDescriptions = false,
        bool searchManufacturerPartNumber = true,
        bool searchSku = true,
        bool searchProductTags = false,
        int languageId = 0,
        IList filteredSpecOptions = null,
        ProductSortingEnum orderBy = ProductSortingEnum.Position,
        bool showHidden = false,
        bool? overridePublished = null)
    {
        //some databases don't support int.MaxValue
        if (pageSize == int.MaxValue)
            pageSize = int.MaxValue - 1;

        var productsQuery = _productRepository.Table;

        if (!showHidden)
            productsQuery = productsQuery.Where(p => p.Published);
        else if (overridePublished.HasValue)
            productsQuery = productsQuery.Where(p => p.Published == overridePublished.Value);

        var customer = await _workContext.GetCurrentCustomerAsync();

        if (!showHidden || storeId > 0)
        {
            //apply store mapping constraints
            productsQuery = await _storeMappingService.ApplyStoreMapping(productsQuery, storeId);
        }

        if (!showHidden)
        {
            //apply ACL constraints
            productsQuery = await _aclService.ApplyAcl(productsQuery, customer);
        }

        productsQuery =
            from p in productsQuery
            where !p.Deleted &&
                  (!visibleIndividuallyOnly || p.VisibleIndividually) &&
                  (vendorId == 0 || p.VendorId == vendorId) &&
                  (
                      warehouseId == 0 ||
                      (
                          !p.UseMultipleWarehouses ? p.WarehouseId == warehouseId :
                              _productWarehouseInventoryRepository.Table.Any(pwi => pwi.WarehouseId == warehouseId && pwi.ProductId == p.Id)
                      )
                  ) &&
                  (productType == null || p.ProductTypeId == (int)productType) &&
                  (showHidden ||
                   DateTime.UtcNow >= (p.AvailableStartDateTimeUtc ?? SqlDateTime.MinValue.Value) &&
                   DateTime.UtcNow <= (p.AvailableEndDateTimeUtc ?? SqlDateTime.MaxValue.Value)
                  ) &&
                  (priceMin == null || p.Price >= priceMin) &&
                  (priceMax == null || p.Price <= priceMax)
            select p;

        var activeSearchProvider = await _searchPluginManager.LoadPrimaryPluginAsync(customer, storeId);
        var providerResults = new List();

        if (!string.IsNullOrEmpty(keywords))
        {
            var langs = await _languageService.GetAllLanguagesAsync(showHidden: true);

            //Set a flag which will to points need to search in localized properties. If showHidden doesn't set to true should be at least two published languages.
            var searchLocalizedValue = languageId > 0 && langs.Count >= 2 && (showHidden || langs.Count(l => l.Published) >= 2);
            var productsByKeywords = new List().AsQueryable();
            var runStandardSearch = activeSearchProvider is null || showHidden;

            try
            {
                if (!runStandardSearch)
                {
                    providerResults = await activeSearchProvider.SearchProductsAsync(keywords, searchLocalizedValue);
                    productsByKeywords = providerResults.AsQueryable();
                }
            }
            catch
            {
                runStandardSearch = _catalogSettings.UseStandardSearchWhenSearchProviderThrowsException;
            }

            if (runStandardSearch)
            {
                productsByKeywords =
                    from p in _productRepository.Table
                    where p.Name.Contains(keywords) ||
                          (searchDescriptions &&
                           (p.ShortDescription.Contains(keywords) || p.FullDescription.Contains(keywords))) ||
                          (searchManufacturerPartNumber && p.ManufacturerPartNumber == keywords) ||
                          (searchSku && p.Sku == keywords)
                    select p.Id;

                if (searchLocalizedValue)
                {
                    productsByKeywords = productsByKeywords.Union(
                        from lp in _localizedPropertyRepository.Table
                        let checkName = lp.LocaleKey == nameof(Product.Name) &&
                                        lp.LocaleValue.Contains(keywords)
                        let checkShortDesc = searchDescriptions &&
                                             lp.LocaleKey == nameof(Product.ShortDescription) &&
                                             lp.LocaleValue.Contains(keywords)
                        where
                            lp.LocaleKeyGroup == nameof(Product) && lp.LanguageId == languageId && (checkName || checkShortDesc)

                        select lp.EntityId);
                }
            }

            //search by SKU for ProductAttributeCombination
            if (searchSku)
            {
                productsByKeywords = productsByKeywords.Union(
                    from pac in _productAttributeCombinationRepository.Table
                    where pac.Sku == keywords
                    select pac.ProductId);
            }

            //search by category name if admin allows
            if (_catalogSettings.AllowCustomersToSearchWithCategoryName)
            {
                var categoryQuery = _categoryRepository.Table;

                if (!showHidden)
                    categoryQuery = categoryQuery.Where(p => p.Published);
                else if (overridePublished.HasValue)
                    categoryQuery = categoryQuery.Where(p => p.Published == overridePublished.Value);

                if (!showHidden || storeId > 0)
                    categoryQuery = await _storeMappingService.ApplyStoreMapping(categoryQuery, storeId);

                if (!showHidden)
                    categoryQuery = await _aclService.ApplyAcl(categoryQuery, customer);

                productsByKeywords = productsByKeywords.Union(
                    from pc in _productCategoryRepository.Table
                    join c in categoryQuery on pc.CategoryId equals c.Id
                    where c.Name.Contains(keywords) && !c.Deleted
                    select pc.ProductId
                );

                if (searchLocalizedValue)
                {
                    productsByKeywords = productsByKeywords.Union(
                        from pc in _productCategoryRepository.Table
                        join lp in _localizedPropertyRepository.Table on pc.CategoryId equals lp.EntityId
                        where lp.LocaleKeyGroup == nameof(Category) &&
                              lp.LocaleKey == nameof(Category.Name) &&
                              lp.LocaleValue.Contains(keywords) &&
                              lp.LanguageId == languageId
                        select pc.ProductId);
                }
            }

            //search by manufacturer name if admin allows
            if (_catalogSettings.AllowCustomersToSearchWithManufacturerName)
            {
                var manufacturerQuery = _manufacturerRepository.Table;

                if (!showHidden)
                    manufacturerQuery = manufacturerQuery.Where(p => p.Published);
                else if (overridePublished.HasValue)
                    manufacturerQuery = manufacturerQuery.Where(p => p.Published == overridePublished.Value);

                if (!showHidden || storeId > 0)
                    manufacturerQuery = await _storeMappingService.ApplyStoreMapping(manufacturerQuery, storeId);

                if (!showHidden)
                    manufacturerQuery = await _aclService.ApplyAcl(manufacturerQuery, customer);

                productsByKeywords = productsByKeywords.Union(
                    from pm in _productManufacturerRepository.Table
                    join m in manufacturerQuery on pm.ManufacturerId equals m.Id
                    where m.Name.Contains(keywords) && !m.Deleted
                    select pm.ProductId
                );

                if (searchLocalizedValue)
                {
                    productsByKeywords = productsByKeywords.Union(
                        from pm in _productManufacturerRepository.Table
                        join lp in _localizedPropertyRepository.Table on pm.ManufacturerId equals lp.EntityId
                        where lp.LocaleKeyGroup == nameof(Manufacturer) &&
                              lp.LocaleKey == nameof(Manufacturer.Name) &&
                              lp.LocaleValue.Contains(keywords) &&
                              lp.LanguageId == languageId
                        select pm.ProductId);
                }
            }

            if (searchProductTags)
            {
                productsByKeywords = productsByKeywords.Union(
                    from pptm in _productTagMappingRepository.Table
                    join pt in _productTagRepository.Table on pptm.ProductTagId equals pt.Id
                    where pt.Name.Contains(keywords)
                    select pptm.ProductId
                );

                if (searchLocalizedValue)
                {
                    productsByKeywords = productsByKeywords.Union(
                        from pptm in _productTagMappingRepository.Table
                        join lp in _localizedPropertyRepository.Table on pptm.ProductTagId equals lp.EntityId
                        where lp.LocaleKeyGroup == nameof(ProductTag) &&
                              lp.LocaleKey == nameof(ProductTag.Name) &&
                              lp.LocaleValue.Contains(keywords) &&
                              lp.LanguageId == languageId
                        select pptm.ProductId);
                }
            }

            productsQuery =
                from p in productsQuery
                join pbk in productsByKeywords on p.Id equals pbk
                select p;
        }

        if (categoryIds is not null)
        {
            categoryIds.Remove(0);

            if (categoryIds.Any())
            {
                var productCategoryQuery =
                    from pc in _productCategoryRepository.Table
                    join c in _categoryRepository.Table on pc.CategoryId equals c.Id
                    where (!excludeFeaturedProducts || !pc.IsFeaturedProduct) &&
                          categoryIds.Contains(pc.CategoryId)
                    orderby c.DisplayOrder
                    group pc by pc.ProductId into pc
                    select new
                    {
                        ProductId = pc.Key,
                        DisplayOrder = pc.First().DisplayOrder
                    };

                productsQuery =
                    from p in productsQuery
                    join pc in productCategoryQuery on p.Id equals pc.ProductId
                    orderby pc.DisplayOrder, p.Name
                    select p;
            }
        }

        if (manufacturerIds is not null)
        {
            manufacturerIds.Remove(0);

            if (manufacturerIds.Any())
            {
                var productManufacturerQuery =
                    from pm in _productManufacturerRepository.Table
                    where (!excludeFeaturedProducts || !pm.IsFeaturedProduct) &&
                          manufacturerIds.Contains(pm.ManufacturerId)
                    group pm by pm.ProductId into pm
                    select new
                    {
                        ProductId = pm.Key,
                        DisplayOrder = pm.First().DisplayOrder
                    };

                productsQuery =
                    from p in productsQuery
                    join pm in productManufacturerQuery on p.Id equals pm.ProductId
                    orderby pm.DisplayOrder, p.Name
                    select p;
            }
        }

        if (productTagId > 0)
        {
            productsQuery =
                from p in productsQuery
                join ptm in _productTagMappingRepository.Table on p.Id equals ptm.ProductId
                where ptm.ProductTagId == productTagId
                select p;
        }

        if (filteredSpecOptions?.Count > 0)
        {
            var specificationAttributeIds = filteredSpecOptions
                .Select(sao => sao.SpecificationAttributeId)
                .Distinct();

            foreach (var specificationAttributeId in specificationAttributeIds)
            {
                var optionIdsBySpecificationAttribute = filteredSpecOptions
                    .Where(o => o.SpecificationAttributeId == specificationAttributeId)
                    .Select(o => o.Id);

                var productSpecificationQuery =
                    from psa in _productSpecificationAttributeRepository.Table
                    where psa.AllowFiltering && optionIdsBySpecificationAttribute.Contains(psa.SpecificationAttributeOptionId)
                    select psa;

                productsQuery =
                    from p in productsQuery
                    where productSpecificationQuery.Any(pc => pc.ProductId == p.Id)
                    select p;
            }
        }

        var products = await productsQuery.OrderBy(_localizedPropertyRepository, await _workContext.GetWorkingLanguageAsync(), orderBy).ToPagedListAsync(pageIndex, pageSize);

        if (providerResults.Any() && orderBy == ProductSortingEnum.Position && !showHidden)
        {
            var sortedProducts = products.OrderBy(p => 
            {
                var index = providerResults.IndexOf(p.Id);
                return index == -1 ? products.TotalCount : index;
            }).ToList();

            return new PagedList(sortedProducts, pageIndex, pageSize, products.TotalCount);
        }

        return products;
    }

    /// 
    /// Gets products by product attribute
    /// 
    /// Product attribute identifier
    /// Page index
    /// Page size
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// 
    public virtual async Task> GetProductsByProductAttributeIdAsync(int productAttributeId,
        int pageIndex = 0, int pageSize = int.MaxValue)
    {
        var query = from p in _productRepository.Table
            join pam in _productAttributeMappingRepository.Table on p.Id equals pam.ProductId
            where
                pam.ProductAttributeId == productAttributeId &&
                !p.Deleted
            orderby p.Name
            select p;

        return await query.ToPagedListAsync(pageIndex, pageSize);
    }

    /// 
    /// Gets associated products
    /// 
    /// Parent product identifier (used with grouped products)
    /// Store identifier; 0 to load all records
    /// Vendor identifier; 0 to load all records
    /// A value indicating whether to show hidden records
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// 
    public virtual async Task> GetAssociatedProductsAsync(int parentGroupedProductId,
        int storeId = 0, int vendorId = 0, bool showHidden = false)
    {
        var query = _productRepository.Table;
        query = query.Where(x => x.ParentGroupedProductId == parentGroupedProductId);
        if (!showHidden)
        {
            query = query.Where(x => x.Published);

            //available dates
            query = query.Where(p =>
                (!p.AvailableStartDateTimeUtc.HasValue || p.AvailableStartDateTimeUtc.Value < DateTime.UtcNow) &&
                (!p.AvailableEndDateTimeUtc.HasValue || p.AvailableEndDateTimeUtc.Value > DateTime.UtcNow));
        }
        //vendor filtering
        if (vendorId > 0)
        {
            query = query.Where(p => p.VendorId == vendorId);
        }

        query = query.Where(x => !x.Deleted);
        query = query.OrderBy(x => x.DisplayOrder).ThenBy(x => x.Id);

        var products = await query.ToListAsync();

        //ACL mapping
        if (!showHidden)
            products = await products.WhereAwait(async x => await _aclService.AuthorizeAsync(x)).ToListAsync();

        //Store mapping
        if (!showHidden && storeId > 0)
            products = await products.WhereAwait(async x => await _storeMappingService.AuthorizeAsync(x, storeId)).ToListAsync();

        return products;
    }

    /// 
    /// Update product review totals
    /// 
    /// Product
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateProductReviewTotalsAsync(Product product)
    {
        ArgumentNullException.ThrowIfNull(product);

        var approvedRatingSum = 0;
        var notApprovedRatingSum = 0;
        var approvedTotalReviews = 0;
        var notApprovedTotalReviews = 0;

        var reviews = _productReviewRepository.Table
            .Where(r => r.ProductId == product.Id)
            .ToAsyncEnumerable();
        await foreach (var pr in reviews)
            if (pr.IsApproved)
            {
                approvedRatingSum += pr.Rating;
                approvedTotalReviews++;
            }
            else
            {
                notApprovedRatingSum += pr.Rating;
                notApprovedTotalReviews++;
            }

        product.ApprovedRatingSum = approvedRatingSum;
        product.NotApprovedRatingSum = notApprovedRatingSum;
        product.ApprovedTotalReviews = approvedTotalReviews;
        product.NotApprovedTotalReviews = notApprovedTotalReviews;
        await UpdateProductAsync(product);
    }

    /// 
    /// Get low stock products
    /// 
    /// Vendor identifier; pass null to load all records
    /// Whether to load published products only; pass null to load all products, pass true to load only published products, pass false to load only unpublished products
    /// Page index
    /// Page size
    /// A value in indicating whether you want to load only total number of records. Set to "true" if you don't want to load data from database
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// 
    public virtual async Task> GetLowStockProductsAsync(int? vendorId = null, bool? loadPublishedOnly = true,
        int pageIndex = 0, int pageSize = int.MaxValue, bool getOnlyTotalCount = false)
    {
        var query = _productRepository.Table;

        //filter by products with tracking inventory
        query = query.Where(product => product.ManageInventoryMethodId == (int)ManageInventoryMethod.ManageStock);

        //filter by products with stock quantity less than the minimum
        query = query.Where(product =>
            (product.UseMultipleWarehouses ? _productWarehouseInventoryRepository.Table.Where(pwi => pwi.ProductId == product.Id).Sum(pwi => pwi.StockQuantity - pwi.ReservedQuantity)
                : product.StockQuantity) <= product.MinStockQuantity);

        //ignore deleted products
        query = query.Where(product => !product.Deleted);

        //ignore grouped products
        query = query.Where(product => product.ProductTypeId != (int)ProductType.GroupedProduct);

        //filter by vendor
        if (vendorId.HasValue && vendorId.Value > 0)
            query = query.Where(product => product.VendorId == vendorId.Value);

        //whether to load published products only
        if (loadPublishedOnly.HasValue)
            query = query.Where(product => product.Published == loadPublishedOnly.Value);

        query = query.OrderBy(product => product.MinStockQuantity).ThenBy(product => product.DisplayOrder).ThenBy(product => product.Id);

        return await query.ToPagedListAsync(pageIndex, pageSize, getOnlyTotalCount);
    }

    /// 
    /// Get low stock product combinations
    /// 
    /// Vendor identifier; pass null to load all records
    /// Whether to load combinations of published products only; pass null to load all products, pass true to load only published products, pass false to load only unpublished products
    /// Page index
    /// Page size
    /// A value in indicating whether you want to load only total number of records. Set to "true" if you don't want to load data from database
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the product combinations
    /// 
    public virtual async Task> GetLowStockProductCombinationsAsync(int? vendorId = null, bool? loadPublishedOnly = true,
        int pageIndex = 0, int pageSize = int.MaxValue, bool getOnlyTotalCount = false)
    {
        var combinations = from pac in _productAttributeCombinationRepository.Table
            join p in _productRepository.Table on pac.ProductId equals p.Id
            where
                //filter by combinations with stock quantity less than the minimum
                pac.StockQuantity <= pac.MinStockQuantity &&
                //filter by products with tracking inventory by attributes
                p.ManageInventoryMethodId == (int)ManageInventoryMethod.ManageStockByAttributes &&
                //ignore deleted products
                !p.Deleted &&
                //ignore grouped products
                p.ProductTypeId != (int)ProductType.GroupedProduct &&
                //filter by vendor
                ((vendorId ?? 0) == 0 || p.VendorId == vendorId) &&
                //whether to load published products only
                (loadPublishedOnly == null || p.Published == loadPublishedOnly)
            orderby pac.ProductId, pac.Id
            select pac;

        return await combinations.ToPagedListAsync(pageIndex, pageSize, getOnlyTotalCount);
    }

    /// 
    /// Gets a product by SKU
    /// 
    /// SKU
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the product
    /// 
    public virtual async Task GetProductBySkuAsync(string sku)
    {
        if (string.IsNullOrEmpty(sku))
            return null;

        sku = sku.Trim();

        var query = from p in _productRepository.Table
            orderby p.Id
            where !p.Deleted &&
                  p.Sku == sku
            select p;
        var product = await query.FirstOrDefaultAsync();

        return product;
    }

    /// 
    /// Gets a products by SKU array
    /// 
    /// SKU array
    /// Vendor ID; 0 to load all records
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// 
    public async Task> GetProductsBySkuAsync(string[] skuArray, int vendorId = 0)
    {
        ArgumentNullException.ThrowIfNull(skuArray);

        var query = _productRepository.Table;
        query = query.Where(p => !p.Deleted && skuArray.Contains(p.Sku));

        if (vendorId != 0)
            query = query.Where(p => p.VendorId == vendorId);

        return await query.ToListAsync();
    }

    /// 
    /// Update HasTierPrices property (used for performance optimization)
    /// 
    /// Product
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateHasTierPricesPropertyAsync(Product product)
    {
        ArgumentNullException.ThrowIfNull(product);

        product.HasTierPrices = (await GetTierPricesByProductAsync(product.Id)).Any();
        await UpdateProductAsync(product);
    }

    /// 
    /// Update HasDiscountsApplied property (used for performance optimization)
    /// 
    /// Product
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateHasDiscountsAppliedAsync(Product product)
    {
        ArgumentNullException.ThrowIfNull(product);

        product.HasDiscountsApplied = _discountProductMappingRepository.Table.Any(dpm => dpm.EntityId == product.Id);
        await UpdateProductAsync(product);
    }

    /// 
    /// Gets number of products by vendor identifier
    /// 
    /// Vendor identifier
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the number of products
    /// 
    public async Task GetNumberOfProductsByVendorIdAsync(int vendorId)
    {
        if (vendorId == 0)
            return 0;

        return await _productRepository.Table.CountAsync(p => p.VendorId == vendorId && !p.Deleted);
    }

    /// 
    /// Parse "required product Ids" property
    /// 
    /// Product
    /// A list of required product IDs
    public virtual int[] ParseRequiredProductIds(Product product)
    {
        ArgumentNullException.ThrowIfNull(product);

        if (string.IsNullOrEmpty(product.RequiredProductIds))
            return Array.Empty();

        var ids = new List();

        foreach (var idStr in product.RequiredProductIds
                     .Split(_separator, StringSplitOptions.RemoveEmptyEntries)
                     .Select(x => x.Trim()))
            if (int.TryParse(idStr, out var id))
                ids.Add(id);

        return ids.ToArray();
    }

    /// 
    /// Get a value indicating whether a product is available now (availability dates)
    /// 
    /// Product
    /// Datetime to check; pass null to use current date
    /// Result
    public virtual bool ProductIsAvailable(Product product, DateTime? dateTime = null)
    {
        ArgumentNullException.ThrowIfNull(product);

        dateTime ??= DateTime.UtcNow;

        if (product.AvailableStartDateTimeUtc.HasValue && product.AvailableStartDateTimeUtc.Value > dateTime)
            return false;

        if (product.AvailableEndDateTimeUtc.HasValue && product.AvailableEndDateTimeUtc.Value < dateTime)
            return false;

        return true;
    }

    /// 
    /// Get a list of allowed quantities (parse 'AllowedQuantities' property)
    /// 
    /// Product
    /// Result
    public virtual int[] ParseAllowedQuantities(Product product)
    {
        ArgumentNullException.ThrowIfNull(product);

        var result = new List();
        if (!string.IsNullOrWhiteSpace(product.AllowedQuantities))
        {
            var quantities = product.AllowedQuantities
                .Split(_separator, StringSplitOptions.RemoveEmptyEntries)
                .ToList();
            foreach (var qtyStr in quantities)
            {
                if (int.TryParse(qtyStr.Trim(), out var qty))
                    result.Add(qty);
            }
        }

        return result.ToArray();
    }

    /// 
    /// Get total quantity
    /// 
    /// Product
    /// 
    /// A value indicating whether we should consider "Reserved Quantity" property 
    /// when "multiple warehouses" are used
    /// 
    /// 
    /// Warehouse identifier. Used to limit result to certain warehouse.
    /// Used only with "multiple warehouses" enabled.
    /// 
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the result
    /// 
    public virtual async Task GetTotalStockQuantityAsync(Product product, bool useReservedQuantity = true, int warehouseId = 0)
    {
        ArgumentNullException.ThrowIfNull(product);

        if (product.ManageInventoryMethod != ManageInventoryMethod.ManageStock)
            //We can calculate total stock quantity when 'Manage inventory' property is set to 'Track inventory'
            return 0;

        if (!product.UseMultipleWarehouses)
            return product.StockQuantity;

        var pwi = _productWarehouseInventoryRepository.Table.Where(wi => wi.ProductId == product.Id);

        if (warehouseId > 0)
            pwi = pwi.Where(x => x.WarehouseId == warehouseId);

        var result = await pwi.SumAsync(x => x.StockQuantity);
        if (useReservedQuantity)
            result -= await pwi.SumAsync(x => x.ReservedQuantity);

        return result;
    }

    /// 
    /// Get number of rental periods (price ratio)
    /// 
    /// Product
    /// Start date
    /// End date
    /// Number of rental periods
    public virtual int GetRentalPeriods(Product product, DateTime startDate, DateTime endDate)
    {
        ArgumentNullException.ThrowIfNull(product);

        if (!product.IsRental)
            return 1;

        if (startDate.CompareTo(endDate) >= 0)
            return 1;

        int totalPeriods;
        switch (product.RentalPricePeriod)
        {
            case RentalPricePeriod.Days:
            {
                var totalDaysToRent = Math.Max((endDate - startDate).TotalDays, 1);
                var configuredPeriodDays = product.RentalPriceLength;
                totalPeriods = Convert.ToInt32(Math.Ceiling(totalDaysToRent / configuredPeriodDays));
            }

                break;
            case RentalPricePeriod.Weeks:
            {
                var totalDaysToRent = Math.Max((endDate - startDate).TotalDays, 1);
                var configuredPeriodDays = 7 * product.RentalPriceLength;
                totalPeriods = Convert.ToInt32(Math.Ceiling(totalDaysToRent / configuredPeriodDays));
            }

                break;
            case RentalPricePeriod.Months:
            {
                //Source: http://stackoverflow.com/questions/4638993/difference-in-months-between-two-dates
                var totalMonthsToRent = (endDate.Year - startDate.Year) * 12 + endDate.Month - startDate.Month;
                if (startDate.AddMonths(totalMonthsToRent) < endDate)
                    //several days added (not full month)
                    totalMonthsToRent++;

                var configuredPeriodMonths = product.RentalPriceLength;
                totalPeriods = Convert.ToInt32(Math.Ceiling((double)totalMonthsToRent / configuredPeriodMonths));
            }

                break;
            case RentalPricePeriod.Years:
            {
                var totalDaysToRent = Math.Max((endDate - startDate).TotalDays, 1);
                var configuredPeriodDays = 365 * product.RentalPriceLength;
                totalPeriods = Convert.ToInt32(Math.Ceiling(totalDaysToRent / configuredPeriodDays));
            }

                break;
            default:
                throw new Exception("Not supported rental period");
        }

        return totalPeriods;
    }

    /// 
    /// Formats the stock availability/quantity message
    /// 
    /// Product
    /// Selected product attributes in XML format (if specified)
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the stock message
    /// 
    public virtual async Task FormatStockMessageAsync(Product product, string attributesXml)
    {
        ArgumentNullException.ThrowIfNull(product);

        var stockMessage = string.Empty;

        switch (product.ManageInventoryMethod)
        {
            case ManageInventoryMethod.ManageStock:
                stockMessage = await GetStockMessageAsync(product);
                break;
            case ManageInventoryMethod.ManageStockByAttributes:
                stockMessage = await GetStockMessageForAttributesAsync(product, attributesXml);
                break;
        }

        return stockMessage;
    }

    /// 
    /// Formats SKU
    /// 
    /// Product
    /// Attributes in XML format
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the sKU
    /// 
    public virtual async Task FormatSkuAsync(Product product, string attributesXml = null)
    {
        ArgumentNullException.ThrowIfNull(product);

        var (sku, _, _) = await GetSkuMpnGtinAsync(product, attributesXml);

        return sku;
    }

    /// 
    /// Formats manufacturer part number
    /// 
    /// Product
    /// Attributes in XML format
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the manufacturer part number
    /// 
    public virtual async Task FormatMpnAsync(Product product, string attributesXml = null)
    {
        ArgumentNullException.ThrowIfNull(product);

        var (_, manufacturerPartNumber, _) = await GetSkuMpnGtinAsync(product, attributesXml);

        return manufacturerPartNumber;
    }

    /// 
    /// Formats GTIN
    /// 
    /// Product
    /// Attributes in XML format
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the gTIN
    /// 
    public virtual async Task FormatGtinAsync(Product product, string attributesXml = null)
    {
        ArgumentNullException.ThrowIfNull(product);

        var (_, _, gtin) = await GetSkuMpnGtinAsync(product, attributesXml);

        return gtin;
    }

    /// 
    /// Formats start/end date for rental product
    /// 
    /// Product
    /// Date
    /// Formatted date
    public virtual string FormatRentalDate(Product product, DateTime date)
    {
        ArgumentNullException.ThrowIfNull(product);

        if (!product.IsRental)
            return null;

        return date.ToShortDateString();
    }

    /// 
    /// Update product store mappings
    /// 
    /// Product
    /// A list of store ids for mapping
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateProductStoreMappingsAsync(Product product, IList limitedToStoresIds)
    {
        product.LimitedToStores = limitedToStoresIds.Any();

        var limitedToStoresIdsSet = limitedToStoresIds.ToHashSet();
        var existingStoreMappingsByStoreId = (await _storeMappingService.GetStoreMappingsAsync(product))
            .ToDictionary(sm => sm.StoreId);
        var allStores = await _storeService.GetAllStoresAsync();
        foreach (var store in allStores)
        {
            if (limitedToStoresIdsSet.Contains(store.Id))
            {
                //new store
                if (!existingStoreMappingsByStoreId.ContainsKey(store.Id))
                    await _storeMappingService.InsertStoreMappingAsync(product, store.Id);
            }
            else
            {
                //remove store
                if (existingStoreMappingsByStoreId.TryGetValue(store.Id, out var storeMappingToDelete))
                    await _storeMappingService.DeleteStoreMappingAsync(storeMappingToDelete);
            }
        }
    }

    /// 
    /// Gets the value whether the sequence contains downloadable products
    /// 
    /// Product identifiers
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the result
    /// 
    public virtual async Task HasAnyDownloadableProductAsync(int[] productIds)
    {
        return await _productRepository.Table
            .AnyAsync(p => productIds.Contains(p.Id) && p.IsDownload);
    }

    /// 
    /// Gets the value whether the sequence contains gift card products
    /// 
    /// Product identifiers
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the result
    /// 
    public virtual async Task HasAnyGiftCardProductAsync(int[] productIds)
    {
        return await _productRepository.Table
            .AnyAsync(p => productIds.Contains(p.Id) && p.IsGiftCard);
    }

    /// 
    /// Gets the value whether the sequence contains recurring products
    /// 
    /// Product identifiers
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the result
    /// 
    public virtual async Task HasAnyRecurringProductAsync(int[] productIds)
    {
        return await _productRepository.Table
            .AnyAsync(p => productIds.Contains(p.Id) && p.IsRecurring);
    }

    /// 
    /// Returns a list of sku of not existing products
    /// 
    /// The sku of the products to check
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the list of sku not existing products
    /// 
    public virtual async Task GetNotExistingProductsAsync(string[] productSku)
    {
        ArgumentNullException.ThrowIfNull(productSku);

        var query = _productRepository.Table;
        var queryFilter = productSku.Distinct().ToArray();
        //filtering by SKU
        var filter = await query.Select(p => p.Sku)
            .Where(p => queryFilter.Contains(p))
            .ToListAsync();

        return queryFilter.Except(filter).ToArray();
    }

    #endregion

    #region Inventory management methods

    /// 
    /// Adjust inventory
    /// 
    /// Product
    /// Quantity to increase or decrease
    /// Attributes in XML format
    /// Message for the stock quantity history
    /// A task that represents the asynchronous operation
    public virtual async Task AdjustInventoryAsync(Product product, int quantityToChange, string attributesXml = "", string message = "")
    {
        ArgumentNullException.ThrowIfNull(product);

        if (quantityToChange == 0)
            return;

        if (product.ManageInventoryMethod == ManageInventoryMethod.ManageStock)
        {
            //update stock quantity
            if (product.UseMultipleWarehouses)
            {
                //use multiple warehouses
                if (quantityToChange < 0)
                    await ReserveInventoryAsync(product, quantityToChange);
                else
                    await UnblockReservedInventoryAsync(product, quantityToChange);
            }
            else
            {
                //do not use multiple warehouses
                //simple inventory management
                product.StockQuantity += quantityToChange;
                await UpdateProductAsync(product);

                //quantity change history
                await AddStockQuantityHistoryEntryAsync(product, quantityToChange, product.StockQuantity, product.WarehouseId, message);
            }

            var totalStock = await GetTotalStockQuantityAsync(product);

            await ApplyLowStockActivityAsync(product, totalStock);

            //send email notification
            if (quantityToChange < 0 && totalStock < product.NotifyAdminForQuantityBelow)
            {
                //do not inject IWorkflowMessageService via constructor because it'll cause circular references
                var workflowMessageService = EngineContext.Current.Resolve();
                await workflowMessageService.SendQuantityBelowStoreOwnerNotificationAsync(product, _localizationSettings.DefaultAdminLanguageId);
            }
        }

        if (product.ManageInventoryMethod == ManageInventoryMethod.ManageStockByAttributes)
        {
            var combination = await _productAttributeParser.FindProductAttributeCombinationAsync(product, attributesXml);
            if (combination != null)
            {
                combination.StockQuantity += quantityToChange;
                await _productAttributeService.UpdateProductAttributeCombinationAsync(combination);

                //quantity change history
                await AddStockQuantityHistoryEntryAsync(product, quantityToChange, combination.StockQuantity, message: message, combinationId: combination.Id);

                if (product.AllowAddingOnlyExistingAttributeCombinations)
                {
                    var totalStockByAllCombinations = await (await _productAttributeService.GetAllProductAttributeCombinationsAsync(product.Id))
                        .ToAsyncEnumerable()
                        .SumAsync(c => c.StockQuantity);

                    await ApplyLowStockActivityAsync(product, totalStockByAllCombinations);
                }

                //send email notification
                if (quantityToChange < 0 && combination.StockQuantity < combination.NotifyAdminForQuantityBelow)
                {
                    //do not inject IWorkflowMessageService via constructor because it'll cause circular references
                    var workflowMessageService = EngineContext.Current.Resolve();
                    await workflowMessageService.SendQuantityBelowStoreOwnerNotificationAsync(combination, _localizationSettings.DefaultAdminLanguageId);
                }
            }
        }

        //bundled products
        var attributeValues = await _productAttributeParser.ParseProductAttributeValuesAsync(attributesXml);
        foreach (var attributeValue in attributeValues)
        {
            if (attributeValue.AttributeValueType != AttributeValueType.AssociatedToProduct)
                continue;

            //associated product (bundle)
            var associatedProduct = await GetProductByIdAsync(attributeValue.AssociatedProductId);
            if (associatedProduct != null)
            {
                await AdjustInventoryAsync(associatedProduct, quantityToChange * attributeValue.Quantity, message);
            }
        }
    }

    /// 
    /// Book the reserved quantity
    /// 
    /// Product
    /// Warehouse identifier
    /// Quantity, must be negative
    /// Message for the stock quantity history
    /// A task that represents the asynchronous operation
    public virtual async Task BookReservedInventoryAsync(Product product, int warehouseId, int quantity, string message = "")
    {
        ArgumentNullException.ThrowIfNull(product);

        if (quantity >= 0)
            throw new ArgumentException("Value must be negative.", nameof(quantity));

        //only products with "use multiple warehouses" are handled this way
        if (product.ManageInventoryMethod != ManageInventoryMethod.ManageStock || !product.UseMultipleWarehouses)
            return;

        var pwi = await _productWarehouseInventoryRepository.Table

            .FirstOrDefaultAsync(wi => wi.ProductId == product.Id && wi.WarehouseId == warehouseId);
        if (pwi == null)
            return;

        pwi.ReservedQuantity = Math.Max(pwi.ReservedQuantity + quantity, 0);
        pwi.StockQuantity += quantity;

        await UpdateProductWarehouseInventoryAsync(pwi);

        //quantity change history
        await AddStockQuantityHistoryEntryAsync(product, quantity, pwi.StockQuantity, warehouseId, message);
    }

    /// 
    /// Reverse booked inventory (if acceptable)
    /// 
    /// product
    /// Shipment item
    /// Message for the stock quantity history
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the quantity reversed
    /// 
    public virtual async Task ReverseBookedInventoryAsync(Product product, ShipmentItem shipmentItem, string message = "")
    {
        ArgumentNullException.ThrowIfNull(product);
        ArgumentNullException.ThrowIfNull(shipmentItem);

        //only products with "use multiple warehouses" are handled this way
        if (product.ManageInventoryMethod != ManageInventoryMethod.ManageStock || !product.UseMultipleWarehouses)
            return 0;

        var pwi = await _productWarehouseInventoryRepository.Table
            .FirstOrDefaultAsync(wi => wi.ProductId == product.Id && wi.WarehouseId == shipmentItem.WarehouseId);
        if (pwi == null)
            return 0;

        var shipment = await _shipmentRepository.GetByIdAsync(shipmentItem.ShipmentId, cache => default);

        //not shipped yet? hence "BookReservedInventory" method was not invoked
        if (!shipment.ShippedDateUtc.HasValue)
            return 0;

        var qty = shipmentItem.Quantity;

        pwi.StockQuantity += qty;
        pwi.ReservedQuantity += qty;

        await UpdateProductWarehouseInventoryAsync(pwi);

        //quantity change history
        await AddStockQuantityHistoryEntryAsync(product, qty, pwi.StockQuantity, shipmentItem.WarehouseId, message);

        return qty;
    }

    #endregion

    #region Related products

    /// 
    /// Deletes a related product
    /// 
    /// Related product
    /// A task that represents the asynchronous operation
    public virtual async Task DeleteRelatedProductAsync(RelatedProduct relatedProduct)
    {
        await _relatedProductRepository.DeleteAsync(relatedProduct);
    }

    /// 
    /// Gets related products by product identifier
    /// 
    /// The first product identifier
    /// A value indicating whether to show hidden records
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the related products
    /// 
    public virtual async Task> GetRelatedProductsByProductId1Async(int productId, bool showHidden = false)
    {
        var query = from rp in _relatedProductRepository.Table
            join p in _productRepository.Table on rp.ProductId2 equals p.Id
            where rp.ProductId1 == productId &&
                  !p.Deleted &&
                  (showHidden || p.Published)
            orderby rp.DisplayOrder, rp.Id
            select rp;

        var relatedProducts = await _staticCacheManager.GetAsync(_staticCacheManager.PrepareKeyForDefaultCache(NopCatalogDefaults.RelatedProductsCacheKey, productId, showHidden), async () => await query.ToListAsync());

        return relatedProducts;
    }

    /// 
    /// Gets a related product
    /// 
    /// Related product identifier
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the related product
    /// 
    public virtual async Task GetRelatedProductByIdAsync(int relatedProductId)
    {
        return await _relatedProductRepository.GetByIdAsync(relatedProductId, cache => default);
    }

    /// 
    /// Inserts a related product
    /// 
    /// Related product
    /// A task that represents the asynchronous operation
    public virtual async Task InsertRelatedProductAsync(RelatedProduct relatedProduct)
    {
        await _relatedProductRepository.InsertAsync(relatedProduct);
    }

    /// 
    /// Updates a related product
    /// 
    /// Related product
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateRelatedProductAsync(RelatedProduct relatedProduct)
    {
        await _relatedProductRepository.UpdateAsync(relatedProduct);
    }

    /// 
    /// Finds a related product item by specified identifiers
    /// 
    /// Source
    /// The first product identifier
    /// The second product identifier
    /// Related product
    public virtual RelatedProduct FindRelatedProduct(IList source, int productId1, int productId2)
    {
        return source.FirstOrDefault(rp => rp.ProductId1 == productId1 && rp.ProductId2 == productId2);
    }

    #endregion

    #region Cross-sell products

    /// 
    /// Deletes a cross-sell product
    /// 
    /// Cross-sell identifier
    /// A task that represents the asynchronous operation
    public virtual async Task DeleteCrossSellProductAsync(CrossSellProduct crossSellProduct)
    {
        await _crossSellProductRepository.DeleteAsync(crossSellProduct);
    }

    /// 
    /// Gets cross-sell products by product identifier
    /// 
    /// The first product identifier
    /// A value indicating whether to show hidden records
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the cross-sell products
    /// 
    public virtual async Task> GetCrossSellProductsByProductId1Async(int productId1, bool showHidden = false)
    {
        return await GetCrossSellProductsByProductIdsAsync([productId1], showHidden);
    }

    /// 
    /// Gets a cross-sell product
    /// 
    /// Cross-sell product identifier
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the cross-sell product
    /// 
    public virtual async Task GetCrossSellProductByIdAsync(int crossSellProductId)
    {
        return await _crossSellProductRepository.GetByIdAsync(crossSellProductId, cache => default);
    }

    /// 
    /// Inserts a cross-sell product
    /// 
    /// Cross-sell product
    /// A task that represents the asynchronous operation
    public virtual async Task InsertCrossSellProductAsync(CrossSellProduct crossSellProduct)
    {
        await _crossSellProductRepository.InsertAsync(crossSellProduct);
    }

    /// 
    /// Gets a cross-sells
    /// 
    /// Shopping cart
    /// Number of products to return
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the cross-sells
    /// 
    public virtual async Task> GetCrossSellProductsByShoppingCartAsync(IList cart, int numberOfProducts)
    {
        var result = new List();

        if (numberOfProducts == 0 || cart?.Any() != true)
            return result;

        var cartProductIds = cart.Select(sci => sci.ProductId).ToHashSet();
        return await (await GetCrossSellProductsByProductIdsAsync(cartProductIds.ToArray()))
            .Select(cs => cs.ProductId2)
            .Except(cartProductIds)
            .SelectAwait(async cs => await GetProductByIdAsync(cs))
            .Where(p => p != null && !p.Deleted && p.Published)
            .Take(numberOfProducts)
            .ToListAsync();
    }

    /// 
    /// Finds a cross-sell product item by specified identifiers
    /// 
    /// Source
    /// The first product identifier
    /// The second product identifier
    /// Cross-sell product
    public virtual CrossSellProduct FindCrossSellProduct(IList source, int productId1, int productId2)
    {
        return source.FirstOrDefault(csp => csp.ProductId1 == productId1 && csp.ProductId2 == productId2);
    }

    #endregion

    #region Tier prices

    /// 
    /// Gets a product tier prices for customer
    /// 
    /// Product
    /// Customer
    /// Store
    /// A task that represents the asynchronous operation
    public virtual async Task> GetTierPricesAsync(Product product, Customer customer, Store store)
    {
        ArgumentNullException.ThrowIfNull(product);
        ArgumentNullException.ThrowIfNull(customer);

        if (!product.HasTierPrices)
            return null;

        //get actual tier prices
        return (await GetTierPricesByProductAsync(product.Id))
            .OrderBy(price => price.Quantity)
            .FilterByStore(store)
            .FilterByCustomerRole(await _customerService.GetCustomerRoleIdsAsync(customer))
            .FilterByDate()
            .RemoveDuplicatedQuantities()
            .ToList();
    }

    /// 
    /// Gets a tier prices by product identifier
    /// 
    /// Product identifier
    /// A task that represents the asynchronous operation
    public virtual async Task> GetTierPricesByProductAsync(int productId)
    {
        var query = _tierPriceRepository.Table.Where(tp => tp.ProductId == productId);

        return await _staticCacheManager.GetAsync(
            _staticCacheManager.PrepareKeyForDefaultCache(NopCatalogDefaults.TierPricesByProductCacheKey, productId),
            async () => await query.ToListAsync());
    }

    /// 
    /// Deletes a tier price
    /// 
    /// Tier price
    /// A task that represents the asynchronous operation
    public virtual async Task DeleteTierPriceAsync(TierPrice tierPrice)
    {
        await _tierPriceRepository.DeleteAsync(tierPrice);
    }

    /// 
    /// Gets a tier price
    /// 
    /// Tier price identifier
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the ier price
    /// 
    public virtual async Task GetTierPriceByIdAsync(int tierPriceId)
    {
        return await _tierPriceRepository.GetByIdAsync(tierPriceId, cache => default);
    }

    /// 
    /// Inserts a tier price
    /// 
    /// Tier price
    /// A task that represents the asynchronous operation
    public virtual async Task InsertTierPriceAsync(TierPrice tierPrice)
    {
        await _tierPriceRepository.InsertAsync(tierPrice);
    }

    /// 
    /// Updates the tier price
    /// 
    /// Tier price
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateTierPriceAsync(TierPrice tierPrice)
    {
        await _tierPriceRepository.UpdateAsync(tierPrice);
    }

    /// 
    /// Gets a preferred tier price
    /// 
    /// Product
    /// Customer
    /// Store
    /// Quantity
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the tier price
    /// 
    public virtual async Task GetPreferredTierPriceAsync(Product product, Customer customer, Store store, int quantity)
    {
        ArgumentNullException.ThrowIfNull(product);
        ArgumentNullException.ThrowIfNull(customer);

        if (!product.HasTierPrices)
            return null;

        //get the most suitable tier price based on the passed quantity
        return (await GetTierPricesAsync(product, customer, store))?.LastOrDefault(price => quantity >= price.Quantity);
    }

    #endregion

    #region Product pictures

    /// 
    /// Deletes a product picture
    /// 
    /// Product picture
    /// A task that represents the asynchronous operation
    public virtual async Task DeleteProductPictureAsync(ProductPicture productPicture)
    {
        await _productPictureRepository.DeleteAsync(productPicture);
    }

    /// 
    /// Gets a product pictures by product identifier
    /// 
    /// The product identifier
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the product pictures
    /// 
    public virtual async Task> GetProductPicturesByProductIdAsync(int productId)
    {
        var query = from pp in _productPictureRepository.Table
            where pp.ProductId == productId
            orderby pp.DisplayOrder, pp.Id
            select pp;

        var productPictures = await query.ToListAsync();

        return productPictures;
    }

    /// 
    /// Gets a product picture
    /// 
    /// Product picture identifier
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the product picture
    /// 
    public virtual async Task GetProductPictureByIdAsync(int productPictureId)
    {
        return await _productPictureRepository.GetByIdAsync(productPictureId, cache => default);
    }

    /// 
    /// Inserts a product picture
    /// 
    /// Product picture
    /// A task that represents the asynchronous operation
    public virtual async Task InsertProductPictureAsync(ProductPicture productPicture)
    {
        await _productPictureRepository.InsertAsync(productPicture);
    }

    /// 
    /// Updates a product picture
    /// 
    /// Product picture
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateProductPictureAsync(ProductPicture productPicture)
    {
        await _productPictureRepository.UpdateAsync(productPicture);
    }

    /// 
    /// Get the IDs of all product images 
    /// 
    /// Products IDs
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the all picture identifiers grouped by product ID
    /// 
    public async Task> GetProductsImagesIdsAsync(int[] productsIds)
    {
        var productPictures = await _productPictureRepository.Table
            .Where(p => productsIds.Contains(p.ProductId))
            .ToListAsync();

        return productPictures.GroupBy(p => p.ProductId).ToDictionary(p => p.Key, p => p.Select(p1 => p1.PictureId).ToArray());
    }

    /// 
    /// Get products for which a discount is applied
    /// 
    /// Discount identifier; pass null to load all records
    /// A value indicating whether to load deleted products
    /// Page index
    /// Page size
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the list of products
    /// 
    public virtual async Task> GetProductsWithAppliedDiscountAsync(int? discountId = null,
        bool showHidden = false, int pageIndex = 0, int pageSize = int.MaxValue)
    {
        var products = _productRepository.Table.Where(product => product.HasDiscountsApplied);

        if (discountId.HasValue)
            products = from product in products
                join dpm in _discountProductMappingRepository.Table on product.Id equals dpm.EntityId
                where dpm.DiscountId == discountId.Value
                select product;

        if (!showHidden)
            products = products.Where(product => !product.Deleted);

        products = products.OrderBy(product => product.DisplayOrder).ThenBy(product => product.Id);

        return await products.ToPagedListAsync(pageIndex, pageSize);
    }

    #endregion

    #region Product videos

    /// 
    /// Deletes a product video
    /// 
    /// Product video
    /// A task that represents the asynchronous operation
    public virtual async Task DeleteProductVideoAsync(ProductVideo productVideo)
    {
        await _productVideoRepository.DeleteAsync(productVideo);
    }

    /// 
    /// Gets a product videos by product identifier
    /// 
    /// The product identifier
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the product videos
    /// 
    public virtual async Task> GetProductVideosByProductIdAsync(int productId)
    {
        var query = from pvm in _productVideoRepository.Table
            where pvm.ProductId == productId
            orderby pvm.DisplayOrder, pvm.Id
            select pvm;

        var productVideos = await query.ToListAsync();

        return productVideos;
    }

    /// 
    /// Gets a product video
    /// 
    /// Product video identifier
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the product video
    /// 
    public virtual async Task GetProductVideoByIdAsync(int productVideoId)
    {
        return await _productVideoRepository.GetByIdAsync(productVideoId, cache => default);
    }

    /// 
    /// Inserts a product video
    /// 
    /// Product picture
    /// A task that represents the asynchronous operation
    public virtual async Task InsertProductVideoAsync(ProductVideo productVideo)
    {
        await _productVideoRepository.InsertAsync(productVideo);
    }

    /// 
    /// Updates a product video
    /// 
    /// Product video
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateProductVideoAsync(ProductVideo productVideo)
    {
        await _productVideoRepository.UpdateAsync(productVideo);
    }

    #endregion

    #region Product reviews

    /// 
    /// Gets all product reviews
    /// 
    /// Customer identifier (who wrote a review); 0 to load all records
    /// A value indicating whether to content is approved; null to load all records 
    /// Item creation from; null to load all records
    /// Item item creation to; null to load all records
    /// Search title or review text; null to load all records
    /// The store identifier, where a review has been created; pass 0 to load all records
    /// The product identifier; pass 0 to load all records
    /// The vendor identifier (limit to products of this vendor); pass 0 to load all records
    /// A value indicating whether to show hidden records
    /// Page index
    /// Page size
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the reviews
    /// 
    public virtual async Task> GetAllProductReviewsAsync(int customerId = 0, bool? approved = null,
        DateTime? fromUtc = null, DateTime? toUtc = null,
        string message = null, int storeId = 0, int productId = 0, int vendorId = 0, bool showHidden = false,
        int pageIndex = 0, int pageSize = int.MaxValue)
    {
        var productReviews = await _productReviewRepository.GetAllPagedAsync(async query =>
        {
            if (!showHidden)
            {
                var productsQuery = _productRepository.Table.Where(p => p.Published);

                //apply store mapping constraints
                productsQuery = await _storeMappingService.ApplyStoreMapping(productsQuery, storeId);

                //apply ACL constraints
                var customer = await _workContext.GetCurrentCustomerAsync();
                productsQuery = await _aclService.ApplyAcl(productsQuery, customer);

                query = query.Where(review => productsQuery.Any(product => product.Id == review.ProductId));
            }

            if (approved.HasValue)
                query = query.Where(pr => pr.IsApproved == approved);
            if (customerId > 0)
                query = query.Where(pr => pr.CustomerId == customerId);
            if (fromUtc.HasValue)
                query = query.Where(pr => fromUtc.Value <= pr.CreatedOnUtc);
            if (toUtc.HasValue)
                query = query.Where(pr => toUtc.Value >= pr.CreatedOnUtc);
            if (!string.IsNullOrEmpty(message))
                query = query.Where(pr => pr.Title.Contains(message) || pr.ReviewText.Contains(message));
            if (storeId > 0)
                query = query.Where(pr => pr.StoreId == storeId);
            if (productId > 0)
                query = query.Where(pr => pr.ProductId == productId);

            query = from productReview in query
                join product in _productRepository.Table on productReview.ProductId equals product.Id
                where
                    (vendorId == 0 || product.VendorId == vendorId) &&
                    //ignore deleted products
                    !product.Deleted
                select productReview;

            query = _catalogSettings.ProductReviewsSortByCreatedDateAscending
                ? query.OrderBy(pr => pr.CreatedOnUtc).ThenBy(pr => pr.Id)
                : query.OrderByDescending(pr => pr.CreatedOnUtc).ThenBy(pr => pr.Id);

            return query;
        }, pageIndex, pageSize);

        return productReviews;
    }

    /// 
    /// Gets product review
    /// 
    /// Product review identifier
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the product review
    /// 
    public virtual async Task GetProductReviewByIdAsync(int productReviewId)
    {
        return await _productReviewRepository.GetByIdAsync(productReviewId, cache => default);
    }

    /// 
    /// Get product reviews by identifiers
    /// 
    /// Product review identifiers
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the product reviews
    /// 
    public virtual async Task> GetProductReviewsByIdsAsync(int[] productReviewIds)
    {
        return await _productReviewRepository.GetByIdsAsync(productReviewIds);
    }

    /// 
    /// Inserts a product review
    /// 
    /// Product review
    /// A task that represents the asynchronous operation
    public virtual async Task InsertProductReviewAsync(ProductReview productReview)
    {
        await _productReviewRepository.InsertAsync(productReview);
    }

    /// 
    /// Deletes a product review
    /// 
    /// Product review
    /// A task that represents the asynchronous operation
    public virtual async Task DeleteProductReviewAsync(ProductReview productReview)
    {
        await _productReviewRepository.DeleteAsync(productReview);
    }

    /// 
    /// Deletes product reviews
    /// 
    /// Product reviews
    /// A task that represents the asynchronous operation
    public virtual async Task DeleteProductReviewsAsync(IList productReviews)
    {
        await _productReviewRepository.DeleteAsync(productReviews);
    }

    /// 
    /// Sets or create a product review helpfulness record
    /// 
    /// Product review
    /// Value indicating whether a review a helpful
    /// A task that represents the asynchronous operation
    public virtual async Task SetProductReviewHelpfulnessAsync(ProductReview productReview, bool helpfulness)
    {
        ArgumentNullException.ThrowIfNull(productReview);

        var customer = await _workContext.GetCurrentCustomerAsync();
        var prh = _productReviewHelpfulnessRepository.Table
            .SingleOrDefault(h => h.ProductReviewId == productReview.Id && h.CustomerId == customer.Id);

        if (prh is null)
        {
            //insert new helpfulness
            prh = new ProductReviewHelpfulness
            {
                ProductReviewId = productReview.Id,
                CustomerId = customer.Id,
                WasHelpful = helpfulness,
            };

            await InsertProductReviewHelpfulnessAsync(prh);
        }
        else
        {
            //existing one
            prh.WasHelpful = helpfulness;

            await _productReviewHelpfulnessRepository.UpdateAsync(prh);
        }
    }

    /// 
    /// Updates a product review
    /// 
    /// Product review
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateProductReviewAsync(ProductReview productReview)
    {
        await _productReviewRepository.UpdateAsync(productReview);
    }

    /// 
    /// Updates a totals helpfulness count for product review
    /// 
    /// Product review
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the result
    /// 
    public virtual async Task UpdateProductReviewHelpfulnessTotalsAsync(ProductReview productReview)
    {
        ArgumentNullException.ThrowIfNull(productReview);

        (productReview.HelpfulYesTotal, productReview.HelpfulNoTotal) = await GetHelpfulnessCountsAsync(productReview);

        await _productReviewRepository.UpdateAsync(productReview);
    }

    /// 
    /// Check possibility added review for current customer
    /// 
    /// Current product
    /// The store identifier; pass 0 to load all records
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the 
    /// 
    public virtual async Task CanAddReviewAsync(int productId, int storeId = 0)
    {
        var customer = await _workContext.GetCurrentCustomerAsync();

        if (_catalogSettings.OneReviewPerProductFromCustomer)
            return (await GetAllProductReviewsAsync(customerId: customer.Id, productId: productId, storeId: storeId)).TotalCount == 0;

        return true;
    }

    #endregion

    #region Product warehouses

    /// 
    /// Get a product warehouse-inventory records by product identifier
    /// 
    /// Product identifier
    /// A task that represents the asynchronous operation
    public virtual async Task> GetAllProductWarehouseInventoryRecordsAsync(int productId)
    {
        return await _productWarehouseInventoryRepository.GetAllAsync(query => query.Where(pwi => pwi.ProductId == productId));
    }

    /// 
    /// Deletes a record to manage product inventory per warehouse
    /// 
    /// Record to manage product inventory per warehouse
    /// A task that represents the asynchronous operation
    public virtual async Task DeleteProductWarehouseInventoryAsync(ProductWarehouseInventory pwi)
    {
        await _productWarehouseInventoryRepository.DeleteAsync(pwi);
    }

    /// 
    /// Inserts a record to manage product inventory per warehouse
    /// 
    /// Record to manage product inventory per warehouse
    /// A task that represents the asynchronous operation
    public virtual async Task InsertProductWarehouseInventoryAsync(ProductWarehouseInventory pwi)
    {
        await _productWarehouseInventoryRepository.InsertAsync(pwi);
    }

    /// 
    /// Updates a record to manage product inventory per warehouse
    /// 
    /// Record to manage product inventory per warehouse
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateProductWarehouseInventoryAsync(ProductWarehouseInventory pwi)
    {
        await _productWarehouseInventoryRepository.UpdateAsync(pwi);
    }

    /// 
    /// Updates a records to manage product inventory per warehouse
    /// 
    /// Records to manage product inventory per warehouse
    /// A task that represents the asynchronous operation
    public virtual async Task UpdateProductWarehouseInventoryAsync(IList pwis)
    {
        await _productWarehouseInventoryRepository.UpdateAsync(pwis);
    }

    #endregion

    #region Stock quantity history

    /// 
    /// Add stock quantity change entry
    /// 
    /// Product
    /// Quantity adjustment
    /// Current stock quantity
    /// Warehouse identifier
    /// Message
    /// Product attribute combination identifier
    /// A task that represents the asynchronous operation
    public virtual async Task AddStockQuantityHistoryEntryAsync(Product product, int quantityAdjustment, int stockQuantity,
        int warehouseId = 0, string message = "", int? combinationId = null)
    {
        ArgumentNullException.ThrowIfNull(product);

        if (quantityAdjustment == 0)
            return;

        var historyEntry = new StockQuantityHistory
        {
            ProductId = product.Id,
            CombinationId = combinationId,
            WarehouseId = warehouseId > 0 ? (int?)warehouseId : null,
            QuantityAdjustment = quantityAdjustment,
            StockQuantity = stockQuantity,
            Message = message,
            CreatedOnUtc = DateTime.UtcNow
        };

        await _stockQuantityHistoryRepository.InsertAsync(historyEntry);
    }

    /// 
    /// Get the history of the product stock quantity changes
    /// 
    /// Product
    /// Warehouse identifier; pass 0 to load all entries
    /// Product attribute combination identifier; pass 0 to load all entries
    /// Page index
    /// Page size
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the list of stock quantity change entries
    /// 
    public virtual async Task> GetStockQuantityHistoryAsync(Product product, int warehouseId = 0, int combinationId = 0,
        int pageIndex = 0, int pageSize = int.MaxValue)
    {
        ArgumentNullException.ThrowIfNull(product);

        var query = _stockQuantityHistoryRepository.Table.Where(historyEntry => historyEntry.ProductId == product.Id);

        if (warehouseId > 0)
            query = query.Where(historyEntry => historyEntry.WarehouseId == warehouseId);

        if (combinationId > 0)
            query = query.Where(historyEntry => historyEntry.CombinationId == combinationId);

        query = query.OrderByDescending(historyEntry => historyEntry.CreatedOnUtc).ThenByDescending(historyEntry => historyEntry.Id);

        return await query.ToPagedListAsync(pageIndex, pageSize);
    }

    #endregion

    #region Product discounts

    /// 
    /// Clean up product references for a specified discount
    /// 
    /// Discount
    /// A task that represents the asynchronous operation
    public virtual async Task ClearDiscountProductMappingAsync(Discount discount)
    {
        ArgumentNullException.ThrowIfNull(discount);

        var mappingsWithProducts =
            from dcm in _discountProductMappingRepository.Table
            join p in _productRepository.Table on dcm.EntityId equals p.Id
            where dcm.DiscountId == discount.Id
            select new { product = p, dcm };

        var mappingsToDelete = new List();
        await foreach (var pdcm in mappingsWithProducts.ToAsyncEnumerable())
        {
            mappingsToDelete.Add(pdcm.dcm);

            //update "HasDiscountsApplied" property
            await UpdateHasDiscountsAppliedAsync(pdcm.product);
        }
        await _discountProductMappingRepository.DeleteAsync(mappingsToDelete);
    }

    /// 
    /// Get a discount-product mapping records by product identifier
    /// 
    /// Product identifier
    /// A task that represents the asynchronous operation
    public virtual async Task> GetAllDiscountsAppliedToProductAsync(int productId)
    {
        return await _discountProductMappingRepository.GetAllAsync(query => query.Where(dcm => dcm.EntityId == productId));
    }

    /// 
    /// Get a discount-product mapping record
    /// 
    /// Product identifier
    /// Discount identifier
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the result
    /// 
    public virtual async Task GetDiscountAppliedToProductAsync(int productId, int discountId)
    {
        return await _discountProductMappingRepository.Table
            .FirstOrDefaultAsync(dcm => dcm.EntityId == productId && dcm.DiscountId == discountId);
    }

    /// 
    /// Inserts a discount-product mapping record
    /// 
    /// Discount-product mapping
    /// A task that represents the asynchronous operation
    public virtual async Task InsertDiscountProductMappingAsync(DiscountProductMapping discountProductMapping)
    {
        await _discountProductMappingRepository.InsertAsync(discountProductMapping);
    }

    /// 
    /// Deletes a discount-product mapping record
    /// 
    /// Discount-product mapping
    /// A task that represents the asynchronous operation
    public virtual async Task DeleteDiscountProductMappingAsync(DiscountProductMapping discountProductMapping)
    {
        await _discountProductMappingRepository.DeleteAsync(discountProductMapping);
    }

    #endregion

    #endregion
}