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.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;
using Nop.Services.Vendors;

namespace Nop.Services.Catalog;

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

    protected readonly CatalogSettings _catalogSettings;
    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<Category> _categoryRepository;
    protected readonly IRepository<CrossSellProduct> _crossSellProductRepository;
    protected readonly IRepository<DiscountProductMapping> _discountProductMappingRepository;
    protected readonly IRepository<LocalizedProperty> _localizedPropertyRepository;
    protected readonly IRepository<Manufacturer> _manufacturerRepository;
    protected readonly IRepository<Product> _productRepository;
    protected readonly IRepository<ProductAttributeCombination> _productAttributeCombinationRepository;
    protected readonly IRepository<ProductAttributeMapping> _productAttributeMappingRepository;
    protected readonly IRepository<ProductCategory> _productCategoryRepository;
    protected readonly IRepository<ProductManufacturer> _productManufacturerRepository;
    protected readonly IRepository<ProductPicture> _productPictureRepository;
    protected readonly IRepository<ProductProductTagMapping> _productTagMappingRepository;
    protected readonly IRepository<ProductSpecificationAttribute> _productSpecificationAttributeRepository;
    protected readonly IRepository<ProductTag> _productTagRepository;
    protected readonly IRepository<ProductVideo> _productVideoRepository;
    protected readonly IRepository<ProductWarehouseInventory> _productWarehouseInventoryRepository;
    protected readonly IRepository<RelatedProduct> _relatedProductRepository;
    protected readonly IRepository<Shipment> _shipmentRepository;
    protected readonly IRepository<StockQuantityHistory> _stockQuantityHistoryRepository;
    protected readonly IRepository<TierPrice> _tierPriceRepository;
    protected readonly ISearchPluginManager _searchPluginManager;
    protected readonly IStaticCacheManager _staticCacheManager;
    protected readonly IStoreMappingService _storeMappingService;
    protected readonly IVendorService _vendorService;
    protected readonly IWorkContext _workContext;
    protected readonly LocalizationSettings _localizationSettings;
    private static readonly char[] _separator = [','];

    #endregion

    #region Ctor

    public ProductService(CatalogSettings catalogSettings,
        IAclService aclService,
        ICustomerService customerService,
        IDateRangeService dateRangeService,
        ILanguageService languageService,
        ILocalizationService localizationService,
        IProductAttributeParser productAttributeParser,
        IProductAttributeService productAttributeService,
        IRepository<Category> categoryRepository,
        IRepository<CrossSellProduct> crossSellProductRepository,
        IRepository<DiscountProductMapping> discountProductMappingRepository,
        IRepository<LocalizedProperty> localizedPropertyRepository,
        IRepository<Manufacturer> manufacturerRepository,
        IRepository<Product> productRepository,
        IRepository<ProductAttributeCombination> productAttributeCombinationRepository,
        IRepository<ProductAttributeMapping> productAttributeMappingRepository,
        IRepository<ProductCategory> productCategoryRepository,
        IRepository<ProductManufacturer> productManufacturerRepository,
        IRepository<ProductPicture> productPictureRepository,
        IRepository<ProductProductTagMapping> productTagMappingRepository,
        IRepository<ProductSpecificationAttribute> productSpecificationAttributeRepository,
        IRepository<ProductTag> productTagRepository,
        IRepository<ProductVideo> productVideoRepository,
        IRepository<ProductWarehouseInventory> productWarehouseInventoryRepository,
        IRepository<RelatedProduct> relatedProductRepository,
        IRepository<Shipment> shipmentRepository,
        IRepository<StockQuantityHistory> stockQuantityHistoryRepository,
        IRepository<TierPrice> tierPriceRepository,
        ISearchPluginManager searchPluginManager,
        IStaticCacheManager staticCacheManager,
        IVendorService vendorService,
        IStoreMappingService storeMappingService,
        IWorkContext workContext,
        LocalizationSettings localizationSettings)
    {
        _catalogSettings = catalogSettings;
        _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;
        _productSpecificationAttributeRepository = productSpecificationAttributeRepository;
        _productTagRepository = productTagRepository;
        _productVideoRepository = productVideoRepository;
        _productWarehouseInventoryRepository = productWarehouseInventoryRepository;
        _relatedProductRepository = relatedProductRepository;
        _shipmentRepository = shipmentRepository;
        _stockQuantityHistoryRepository = stockQuantityHistoryRepository;
        _tierPriceRepository = tierPriceRepository;
        _searchPluginManager = searchPluginManager;
        _staticCacheManager = staticCacheManager;
        _storeMappingService = storeMappingService;
        _vendorService = vendorService;
        _workContext = workContext;
        _localizationSettings = localizationSettings;
    }

    #endregion

    #region Utilities

    /// <summary>
    /// Applies the low stock activity to specified product by the total stock quantity
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="totalStock">Total stock</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    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;
        }
    }

    /// <summary>
    /// Gets SKU, Manufacturer part number and GTIN
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="attributesXml">Attributes in XML format</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the sKU, Manufacturer part number, GTIN
    /// </returns>
    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);
    }

    /// <summary>
    /// Get stock message for a product with attributes
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="attributesXml">Attributes in XML format</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the message
    /// </returns>
    protected virtual async Task<string> 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;
    }

    /// <summary>
    /// Get stock message
    /// </summary>
    /// <param name="product">Product</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the message
    /// </returns>
    protected virtual async Task<string> 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;
    }

    /// <summary>
    /// Reserve the given quantity in the warehouses.
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="quantity">Quantity, must be negative</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    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 = await _productWarehouseInventoryRepository.Table.Where(pwi => pwi.ProductId == product.Id)
            .OrderByDescending(pwi => pwi.StockQuantity - pwi.ReservedQuantity)
            .ToListAsync();

        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);
    }

    /// <summary>
    /// Unblocks the given quantity reserved items in the warehouses
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="quantity">Quantity, must be positive</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    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);
    }

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

        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;
    }

    #endregion

    #region Methods

    #region Products

    /// <summary>
    /// Delete a product
    /// </summary>
    /// <param name="product">Product</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task DeleteProductAsync(Product product)
    {
        await _productRepository.DeleteAsync(product);
    }

    /// <summary>
    /// Delete products
    /// </summary>
    /// <param name="products">Products</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task DeleteProductsAsync(IList<Product> products)
    {
        await _productRepository.DeleteAsync(products);
    }

    /// <summary>
    /// Gets all products displayed on the home page
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// </returns>
    public virtual async Task<IList<Product>> 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;
    }

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

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

    /// <summary>
    /// Inserts a product
    /// </summary>
    /// <param name="product">Product</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task InsertProductAsync(Product product)
    {
        await _productRepository.InsertAsync(product);
    }

    /// <summary>
    /// Inserts products
    /// </summary>
    /// <param name="products">Products to insert</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task InsertProductsAsync(IList<Product> products)
    {
        await _productRepository.InsertAsync(products);
    }

    /// <summary>
    /// Updates the product
    /// </summary>
    /// <param name="product">Product</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task UpdateProductAsync(Product product)
    {
        await _productRepository.UpdateAsync(product);
    }

    /// <summary>
    /// Update products
    /// </summary>
    /// <param name="products">Products to update</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task UpdateProductsAsync(IList<Product> products)
    {
        await _productRepository.UpdateAsync(products);
    }

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

        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 = await query.ToListAsync();

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

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

        return featuredProducts;
    }

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

        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 await query.Select(p => p.Id).ToListAsync();
        });

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

        return featuredProducts;
    }

    /// <summary>
    /// Gets products which marked as new
    /// </summary>
    /// <param name="storeId">Store identifier; 0 if you want to get all records</param>
    /// <param name="pageIndex">Page index</param>
    /// <param name="pageSize">Page size</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the list of new products
    /// </returns>
    public virtual async Task<IPagedList<Product>> 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);
    }

    /// <summary>
    /// Get number of product (published and visible) in certain category
    /// </summary>
    /// <param name="categoryIds">Category identifiers</param>
    /// <param name="storeId">Store identifier; 0 to load all records</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the number of products
    /// </returns>
    public virtual async Task<int> GetNumberOfProductsInCategoryAsync(IList<int> 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).CountAsync());
    }

    /// <summary>
    /// Search products
    /// </summary>
    /// <param name="pageIndex">Page index</param>
    /// <param name="pageSize">Page size</param>
    /// <param name="categoryIds">Category identifiers</param>
    /// <param name="manufacturerIds">Manufacturer identifiers</param>
    /// <param name="storeId">Store identifier; 0 to load all records</param>
    /// <param name="vendorId">Vendor identifier; 0 to load all records</param>
    /// <param name="warehouseId">Warehouse identifier; 0 to load all records</param>
    /// <param name="productType">Product type; 0 to load all records</param>
    /// <param name="visibleIndividuallyOnly">A values indicating whether to load only products marked as "visible individually"; "false" to load all records; "true" to load "visible individually" only</param>
    /// <param name="excludeFeaturedProducts">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</param>
    /// <param name="priceMin">Minimum price; null to load all records</param>
    /// <param name="priceMax">Maximum price; null to load all records</param>
    /// <param name="productTagId">Product tag identifier; 0 to load all records</param>
    /// <param name="keywords">Keywords</param>
    /// <param name="searchDescriptions">A value indicating whether to search by a specified "keyword" in product descriptions</param>
    /// <param name="searchManufacturerPartNumber">A value indicating whether to search by a specified "keyword" in manufacturer part number</param>
    /// <param name="searchSku">A value indicating whether to search by a specified "keyword" in product SKU</param>
    /// <param name="searchProductTags">A value indicating whether to search by a specified "keyword" in product tags</param>
    /// <param name="languageId">Language identifier (search for text searching)</param>
    /// <param name="filteredSpecOptions">Specification options list to filter products; null to load all records</param>
    /// <param name="orderBy">Order by</param>
    /// <param name="showHidden">A value indicating whether to show hidden records</param>
    /// <param name="overridePublished">
    /// null - process "Published" property according to "showHidden" parameter
    /// true - load only "Published" products
    /// false - load only "Unpublished" products
    /// </param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// </returns>
    public virtual async Task<IPagedList<Product>> SearchProductsAsync(
        int pageIndex = 0,
        int pageSize = int.MaxValue,
        IList<int> categoryIds = null,
        IList<int> 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<SpecificationAttributeOption> 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<int>();

        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<int>().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;
            }
        }

        if (providerResults.Any() && orderBy == ProductSortingEnum.Position && !showHidden)
        {
            var sortedProducts = from p in productsQuery
                                 join pr in providerResults.Select((id, ind) => new { ind, id }) on p.Id equals pr.id into orderSeq
                                 from os in orderSeq.DefaultIfEmpty()
                                 orderby os == null ? int.MaxValue : os.ind
                                 select p;
                                 

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

        return await productsQuery.OrderBy(_localizedPropertyRepository, await _workContext.GetWorkingLanguageAsync(), orderBy).ToPagedListAsync(pageIndex, pageSize);
    }

    /// <summary>
    /// Gets products by product attribute
    /// </summary>
    /// <param name="productAttributeId">Product attribute identifier</param>
    /// <param name="pageIndex">Page index</param>
    /// <param name="pageSize">Page size</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// </returns>
    public virtual async Task<IPagedList<Product>> 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);
    }

    /// <summary>
    /// Gets associated products
    /// </summary>
    /// <param name="parentGroupedProductId">Parent product identifier (used with grouped products)</param>
    /// <param name="storeId">Store identifier; 0 to load all records</param>
    /// <param name="vendorId">Vendor identifier; 0 to load all records</param>
    /// <param name="showHidden">A value indicating whether to show hidden records</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// </returns>
    public virtual async Task<IList<Product>> 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);
        }

        //apply store mapping constraints
        if (!showHidden && storeId > 0)
            query = await _storeMappingService.ApplyStoreMapping(query, storeId);

        if (!showHidden)
        {
            //apply ACL constraints
            var customer = await _workContext.GetCurrentCustomerAsync();
            query = await _aclService.ApplyAcl(query, customer);
        }

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

        return await query.ToListAsync();
    }

    /// <summary>
    /// Get low stock products
    /// </summary>
    /// <param name="vendorId">Vendor identifier; pass null to load all records</param>
    /// <param name="loadPublishedOnly">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</param>
    /// <param name="pageIndex">Page index</param>
    /// <param name="pageSize">Page size</param>
    /// <param name="getOnlyTotalCount">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</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// </returns>
    public virtual async Task<IPagedList<Product>> 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);
    }

    /// <summary>
    /// Get low stock product combinations
    /// </summary>
    /// <param name="vendorId">Vendor identifier; pass null to load all records</param>
    /// <param name="loadPublishedOnly">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</param>
    /// <param name="pageIndex">Page index</param>
    /// <param name="pageSize">Page size</param>
    /// <param name="getOnlyTotalCount">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</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the product combinations
    /// </returns>
    public virtual async Task<IPagedList<ProductAttributeCombination>> 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);
    }

    /// <summary>
    /// Gets a product by SKU
    /// </summary>
    /// <param name="sku">SKU</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the product
    /// </returns>
    public virtual async Task<Product> 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;
    }

    /// <summary>
    /// Gets a products by SKU array
    /// </summary>
    /// <param name="skuArray">SKU array</param>
    /// <param name="vendorId">Vendor ID; 0 to load all records</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the products
    /// </returns>
    public virtual async Task<IList<Product>> 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();
    }

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

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

    /// <summary>
    /// Parse "required product Ids" property
    /// </summary>
    /// <param name="product">Product</param>
    /// <returns>A list of required product IDs</returns>
    public virtual int[] ParseRequiredProductIds(Product product)
    {
        ArgumentNullException.ThrowIfNull(product);

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

        var ids = new List<int>();

        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();
    }

    /// <summary>
    /// Get a value indicating whether a product is available now (availability dates)
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="dateTime">Datetime to check; pass null to use current date</param>
    /// <returns>Result</returns>
    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;
    }

    /// <summary>
    /// Get a list of allowed quantities (parse 'AllowedQuantities' property)
    /// </summary>
    /// <param name="product">Product</param>
    /// <returns>Result</returns>
    public virtual int[] ParseAllowedQuantities(Product product)
    {
        ArgumentNullException.ThrowIfNull(product);

        var result = new List<int>();
        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();
    }

    /// <summary>
    /// Get total quantity
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="useReservedQuantity">
    /// A value indicating whether we should consider "Reserved Quantity" property 
    /// when "multiple warehouses" are used
    /// </param>
    /// <param name="warehouseId">
    /// Warehouse identifier. Used to limit result to certain warehouse.
    /// Used only with "multiple warehouses" enabled.
    /// </param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the result
    /// </returns>
    public virtual async Task<int> 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;
    }

    /// <summary>
    /// Get number of rental periods (price ratio)
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="startDate">Start date</param>
    /// <param name="endDate">End date</param>
    /// <returns>Number of rental periods</returns>
    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;
    }

    /// <summary>
    /// Formats the stock availability/quantity message
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="attributesXml">Selected product attributes in XML format (if specified)</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the stock message
    /// </returns>
    public virtual async Task<string> 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;
    }

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

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

        return sku;
    }

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

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

        return manufacturerPartNumber;
    }

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

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

        return gtin;
    }

    /// <summary>
    /// Formats start/end date for rental product
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="date">Date</param>
    /// <returns>Formatted date</returns>
    public virtual string FormatRentalDate(Product product, DateTime date)
    {
        ArgumentNullException.ThrowIfNull(product);

        if (!product.IsRental)
            return null;

        return date.ToShortDateString();
    }

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

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

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

    /// <summary>
    /// Returns a list of sku of not existing products
    /// </summary>
    /// <param name="productSku">The sku of the products to check</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the list of sku not existing products
    /// </returns>
    public virtual async Task<string[]> 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

    /// <summary>
    /// Adjust inventory
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="quantityToChange">Quantity to increase or decrease</param>
    /// <param name="attributesXml">Attributes in XML format</param>
    /// <param name="message">Message for the stock quantity history</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    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<IWorkflowMessageService>();
                await workflowMessageService.SendQuantityBelowStoreOwnerNotificationAsync(product, _localizationSettings.DefaultAdminLanguageId);

                if (product.VendorId != 0)
                {
                    var vendor = await _vendorService.GetVendorByIdAsync(product.VendorId);
                    await workflowMessageService.SendQuantityBelowVendorNotificationAsync(product, vendor, _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<IWorkflowMessageService>();
                    await workflowMessageService.SendQuantityBelowStoreOwnerNotificationAsync(combination, _localizationSettings.DefaultAdminLanguageId);

                    if (product.VendorId != 0)
                    {
                        var vendor = await _vendorService.GetVendorByIdAsync(product.VendorId);
                        await workflowMessageService.SendQuantityBelowVendorNotificationAsync(combination, vendor, _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);
            }
        }
    }

    /// <summary>
    /// Book the reserved quantity
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="warehouseId">Warehouse identifier</param>
    /// <param name="quantity">Quantity, must be negative</param>
    /// <param name="message">Message for the stock quantity history</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    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);
    }

    /// <summary>
    /// Reverse booked inventory (if acceptable)
    /// </summary>
    /// <param name="product">product</param>
    /// <param name="shipmentItem">Shipment item</param>
    /// <param name="message">Message for the stock quantity history</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the quantity reversed
    /// </returns>
    public virtual async Task<int> 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

    /// <summary>
    /// Deletes a related product
    /// </summary>
    /// <param name="relatedProduct">Related product</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task DeleteRelatedProductAsync(RelatedProduct relatedProduct)
    {
        await _relatedProductRepository.DeleteAsync(relatedProduct);
    }

    /// <summary>
    /// Gets related products by product identifier
    /// </summary>
    /// <param name="productId">The first product identifier</param>
    /// <param name="showHidden">A value indicating whether to show hidden records</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the related products
    /// </returns>
    public virtual async Task<IList<RelatedProduct>> 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;
    }

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

    /// <summary>
    /// Inserts a related product
    /// </summary>
    /// <param name="relatedProduct">Related product</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task InsertRelatedProductAsync(RelatedProduct relatedProduct)
    {
        await _relatedProductRepository.InsertAsync(relatedProduct);
    }

    /// <summary>
    /// Updates a related product
    /// </summary>
    /// <param name="relatedProduct">Related product</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task UpdateRelatedProductAsync(RelatedProduct relatedProduct)
    {
        await _relatedProductRepository.UpdateAsync(relatedProduct);
    }

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

    #endregion

    #region Cross-sell products

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

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

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

    /// <summary>
    /// Inserts a cross-sell product
    /// </summary>
    /// <param name="crossSellProduct">Cross-sell product</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task InsertCrossSellProductAsync(CrossSellProduct crossSellProduct)
    {
        await _crossSellProductRepository.InsertAsync(crossSellProduct);
    }

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

        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();
    }

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

    #endregion

    #region Tier prices

    /// <summary>
    /// Gets a product tier prices for customer
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="customer">Customer</param>
    /// <param name="store">Store</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task<IList<TierPrice>> GetTierPricesAsync(Product product, Customer customer, Store store)
    {
        ArgumentNullException.ThrowIfNull(product);
        ArgumentNullException.ThrowIfNull(customer);

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

    /// <summary>
    /// Gets a tier prices by product identifier
    /// </summary>
    /// <param name="productId">Product identifier</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task<IList<TierPrice>> GetTierPricesByProductAsync(int productId)
    {
        return await _staticCacheManager.GetAsync(
            _staticCacheManager.PrepareKeyForDefaultCache(NopCatalogDefaults.TierPricesByProductCacheKey, productId),
            async () => await _tierPriceRepository.Table.Where(tp => tp.ProductId == productId).ToListAsync());
    }

    /// <summary>
    /// Deletes a tier price
    /// </summary>
    /// <param name="tierPrice">Tier price</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task DeleteTierPriceAsync(TierPrice tierPrice)
    {
        await _tierPriceRepository.DeleteAsync(tierPrice);
    }

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

    /// <summary>
    /// Inserts a tier price
    /// </summary>
    /// <param name="tierPrice">Tier price</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task InsertTierPriceAsync(TierPrice tierPrice)
    {
        await _tierPriceRepository.InsertAsync(tierPrice);
    }

    /// <summary>
    /// Updates the tier price
    /// </summary>
    /// <param name="tierPrice">Tier price</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task UpdateTierPriceAsync(TierPrice tierPrice)
    {
        await _tierPriceRepository.UpdateAsync(tierPrice);
    }

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

        //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

    /// <summary>
    /// Deletes a product picture
    /// </summary>
    /// <param name="productPicture">Product picture</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task DeleteProductPictureAsync(ProductPicture productPicture)
    {
        await _productPictureRepository.DeleteAsync(productPicture);
    }

    /// <summary>
    /// Gets a product pictures by product identifier
    /// </summary>
    /// <param name="productId">The product identifier</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the product pictures
    /// </returns>
    public virtual async Task<IList<ProductPicture>> 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;
    }

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

    /// <summary>
    /// Inserts a product picture
    /// </summary>
    /// <param name="productPicture">Product picture</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task InsertProductPictureAsync(ProductPicture productPicture)
    {
        await _productPictureRepository.InsertAsync(productPicture);
    }

    /// <summary>
    /// Updates a product picture
    /// </summary>
    /// <param name="productPicture">Product picture</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task UpdateProductPictureAsync(ProductPicture productPicture)
    {
        await _productPictureRepository.UpdateAsync(productPicture);
    }

    /// <summary>
    /// Get the IDs of all product images 
    /// </summary>
    /// <param name="productsIds">Products IDs</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the all picture identifiers grouped by product ID
    /// </returns>
    public virtual async Task<IDictionary<int, int[]>> 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());
    }

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

        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

    /// <summary>
    /// Deletes a product video
    /// </summary>
    /// <param name="productVideo">Product video</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task DeleteProductVideoAsync(ProductVideo productVideo)
    {
        await _productVideoRepository.DeleteAsync(productVideo);
    }

    /// <summary>
    /// Gets a product videos by product identifier
    /// </summary>
    /// <param name="productId">The product identifier</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the product videos
    /// </returns>
    public virtual async Task<IList<ProductVideo>> 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;
    }

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

    /// <summary>
    /// Inserts a product video
    /// </summary>
    /// <param name="productVideo">Product picture</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task InsertProductVideoAsync(ProductVideo productVideo)
    {
        await _productVideoRepository.InsertAsync(productVideo);
    }

    /// <summary>
    /// Updates a product video
    /// </summary>
    /// <param name="productVideo">Product video</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task UpdateProductVideoAsync(ProductVideo productVideo)
    {
        await _productVideoRepository.UpdateAsync(productVideo);
    }

    #endregion

    #region Product warehouses

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

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

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

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

    /// <summary>
    /// Updates a records to manage product inventory per warehouse
    /// </summary>
    /// <param name="pwis">Records to manage product inventory per warehouse</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task UpdateProductWarehouseInventoryAsync(IList<ProductWarehouseInventory> pwis)
    {
        await _productWarehouseInventoryRepository.UpdateAsync(pwis);
    }

    #endregion

    #region Stock quantity history

    /// <summary>
    /// Add stock quantity change entry
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="quantityAdjustment">Quantity adjustment</param>
    /// <param name="stockQuantity">Current stock quantity</param>
    /// <param name="warehouseId">Warehouse identifier</param>
    /// <param name="message">Message</param>
    /// <param name="combinationId">Product attribute combination identifier</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    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);
    }

    /// <summary>
    /// Get the history of the product stock quantity changes
    /// </summary>
    /// <param name="product">Product</param>
    /// <param name="warehouseId">Warehouse identifier; pass 0 to load all entries</param>
    /// <param name="combinationId">Product attribute combination identifier; pass 0 to load all entries</param>
    /// <param name="pageIndex">Page index</param>
    /// <param name="pageSize">Page size</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the list of stock quantity change entries
    /// </returns>
    public virtual async Task<IPagedList<StockQuantityHistory>> 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

    /// <summary>
    /// Clean up product references for a specified discount
    /// </summary>
    /// <param name="discount">Discount</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task ClearDiscountProductMappingAsync(Discount discount)
    {
        ArgumentNullException.ThrowIfNull(discount);

        var mappingsWithProducts = await _discountProductMappingRepository.Table
            .Where(dpm => dpm.DiscountId == discount.Id)
            .ToListAsync();

        await _discountProductMappingRepository.DeleteAsync(mappingsWithProducts);
    }

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

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

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

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

    #endregion

    #endregion
}