diff --git a/src/VirtoCommerce.PricingModule.Core/Model/MergedPrice.cs b/src/VirtoCommerce.PricingModule.Core/Model/MergedPrice.cs new file mode 100644 index 00000000..26fbc7f5 --- /dev/null +++ b/src/VirtoCommerce.PricingModule.Core/Model/MergedPrice.cs @@ -0,0 +1,21 @@ +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.PricingModule.Core.Model +{ + public class MergedPrice : Entity + { + public string Currency { get; set; } + + public string PricelistId { get; set; } + + public string ProductId { get; set; } + + public decimal? Sale { get; set; } + + public decimal List { get; set; } + + public int MinQuantity { get; set; } + + public MergedPriceState State { get; set; } + } +} diff --git a/src/VirtoCommerce.PricingModule.Core/Model/MergedPriceGroup.cs b/src/VirtoCommerce.PricingModule.Core/Model/MergedPriceGroup.cs new file mode 100644 index 00000000..88717542 --- /dev/null +++ b/src/VirtoCommerce.PricingModule.Core/Model/MergedPriceGroup.cs @@ -0,0 +1,23 @@ +namespace VirtoCommerce.PricingModule.Core.Model +{ + public class MergedPriceGroup + { + public string ProductId { get; set; } + + public string ProductName { get; set; } + + public string ProductCode { get; set; } + + public string ProductImgSrc { get; set; } + + public int GroupPricesCount { get; set; } + + public MergedPriceState GroupState { get; set; } + + public decimal? MinSalePrice { get; set; } + public decimal? MaxSalePrice { get; set; } + + public decimal MinListPrice { get; set; } + public decimal MaxListPrice { get; set; } + } +} diff --git a/src/VirtoCommerce.PricingModule.Core/Model/MergedPriceState.cs b/src/VirtoCommerce.PricingModule.Core/Model/MergedPriceState.cs new file mode 100644 index 00000000..367ec435 --- /dev/null +++ b/src/VirtoCommerce.PricingModule.Core/Model/MergedPriceState.cs @@ -0,0 +1,9 @@ +namespace VirtoCommerce.PricingModule.Core.Model +{ + public enum MergedPriceState + { + Base = 0, + New = 1, + Updated = 2, + } +} diff --git a/src/VirtoCommerce.PricingModule.Core/Model/Search/MergedPriceGroupSearchResult.cs b/src/VirtoCommerce.PricingModule.Core/Model/Search/MergedPriceGroupSearchResult.cs new file mode 100644 index 00000000..6116c02c --- /dev/null +++ b/src/VirtoCommerce.PricingModule.Core/Model/Search/MergedPriceGroupSearchResult.cs @@ -0,0 +1,8 @@ +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.PricingModule.Core.Model.Search +{ + public class MergedPriceGroupSearchResult : GenericSearchResult + { + } +} diff --git a/src/VirtoCommerce.PricingModule.Core/Model/Search/MergedPriceSearchCriteria.cs b/src/VirtoCommerce.PricingModule.Core/Model/Search/MergedPriceSearchCriteria.cs new file mode 100644 index 00000000..567ad588 --- /dev/null +++ b/src/VirtoCommerce.PricingModule.Core/Model/Search/MergedPriceSearchCriteria.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.PricingModule.Core.Model.Search +{ + public class MergedPriceSearchCriteria : SearchCriteriaBase + { + public bool All { get; set; } + + public string BasePriceListId { get; set; } + + public string PriorityPriceListId { get; set; } + + public List ProductIds { get; set; } + } +} diff --git a/src/VirtoCommerce.PricingModule.Core/Model/Search/MergedPriceSearchResult.cs b/src/VirtoCommerce.PricingModule.Core/Model/Search/MergedPriceSearchResult.cs new file mode 100644 index 00000000..edde7830 --- /dev/null +++ b/src/VirtoCommerce.PricingModule.Core/Model/Search/MergedPriceSearchResult.cs @@ -0,0 +1,8 @@ +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.PricingModule.Core.Model.Search +{ + public class MergedPriceSearchResult : GenericSearchResult + { + } +} diff --git a/src/VirtoCommerce.PricingModule.Core/Services/IMergedPriceSearchService.cs b/src/VirtoCommerce.PricingModule.Core/Services/IMergedPriceSearchService.cs new file mode 100644 index 00000000..b523102f --- /dev/null +++ b/src/VirtoCommerce.PricingModule.Core/Services/IMergedPriceSearchService.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using VirtoCommerce.PricingModule.Core.Model.Search; + +namespace VirtoCommerce.PricingModule.Core.Services +{ + public interface IMergedPriceSearchService + { + Task SearchGroupPricesAsync(MergedPriceSearchCriteria criteria); + + Task SearchGroupsAsync(MergedPriceSearchCriteria criteria); + } +} diff --git a/src/VirtoCommerce.PricingModule.Data/Model/MergedPriceEntity.cs b/src/VirtoCommerce.PricingModule.Data/Model/MergedPriceEntity.cs new file mode 100644 index 00000000..53f2f79f --- /dev/null +++ b/src/VirtoCommerce.PricingModule.Data/Model/MergedPriceEntity.cs @@ -0,0 +1,34 @@ +using VirtoCommerce.Platform.Core.Common; +using VirtoCommerce.PricingModule.Core.Model; + +namespace VirtoCommerce.PricingModule.Data.Model +{ + public class MergedPriceEntity : Entity + { + public decimal? Sale { get; set; } + + public decimal List { get; set; } + + public string ProductId { get; set; } + + public decimal MinQuantity { get; set; } + + public string PricelistId { get; set; } + + public int State { get; set; } + + public MergedPrice ToModel(MergedPrice model) + { + model.Id = Id; + + model.List = List; + model.MinQuantity = (int)MinQuantity; + model.PricelistId = PricelistId; + model.ProductId = ProductId; + model.Sale = Sale; + model.State = (MergedPriceState)State; + + return model; + } + } +} diff --git a/src/VirtoCommerce.PricingModule.Data/Model/MergedPriceGroupEntity.cs b/src/VirtoCommerce.PricingModule.Data/Model/MergedPriceGroupEntity.cs new file mode 100644 index 00000000..62e7834e --- /dev/null +++ b/src/VirtoCommerce.PricingModule.Data/Model/MergedPriceGroupEntity.cs @@ -0,0 +1,32 @@ +using VirtoCommerce.PricingModule.Core.Model; + +namespace VirtoCommerce.PricingModule.Data.Model +{ + public class MergedPriceGroupEntity + { + public string ProductId { get; set; } + + public int GroupPricesCount { get; set; } + + public int GroupState { get; set; } + + public decimal? MinSalePrice { get; set; } + public decimal? MaxSalePrice { get; set; } + + public decimal MinListPrice { get; set; } + public decimal MaxListPrice { get; set; } + + public MergedPriceGroup ToModel(MergedPriceGroup model) + { + model.ProductId = ProductId; + model.GroupPricesCount = GroupPricesCount; + model.GroupState = (MergedPriceState)GroupState; + model.MinSalePrice = MinSalePrice; + model.MaxSalePrice = MaxSalePrice; + model.MinListPrice = MinListPrice; + model.MaxListPrice = MaxListPrice; + + return model; + } + } +} diff --git a/src/VirtoCommerce.PricingModule.Data/Repositories/IPricingRepository.cs b/src/VirtoCommerce.PricingModule.Data/Repositories/IPricingRepository.cs index bbb987b1..3ab16e5c 100644 --- a/src/VirtoCommerce.PricingModule.Data/Repositories/IPricingRepository.cs +++ b/src/VirtoCommerce.PricingModule.Data/Repositories/IPricingRepository.cs @@ -20,5 +20,7 @@ public interface IPricingRepository : IRepository Task DeletePricesAsync(IEnumerable ids); Task DeletePricelistsAsync(IEnumerable ids); Task DeletePricelistAssignmentsAsync(IEnumerable ids); + + IQueryable GetMergedPrices(string basePriceListId, string priorityPriceListId); } } diff --git a/src/VirtoCommerce.PricingModule.Data/Repositories/PricingDbContext.cs b/src/VirtoCommerce.PricingModule.Data/Repositories/PricingDbContext.cs index d614bb44..df1ca9c4 100644 --- a/src/VirtoCommerce.PricingModule.Data/Repositories/PricingDbContext.cs +++ b/src/VirtoCommerce.PricingModule.Data/Repositories/PricingDbContext.cs @@ -32,6 +32,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().HasOne(x => x.Pricelist).WithMany(x => x.Assignments).IsRequired().HasForeignKey(x => x.PricelistId); modelBuilder.Entity().Property(x => x.Id).HasMaxLength(128).ValueGeneratedOnAdd(); + // ugly hack because EFCore removed ultra useful DbQuery type in 3.0 + modelBuilder.Entity().HasNoKey().ToView("empty"); + base.OnModelCreating(modelBuilder); } } diff --git a/src/VirtoCommerce.PricingModule.Data/Repositories/PricingRepositoryImpl.cs b/src/VirtoCommerce.PricingModule.Data/Repositories/PricingRepositoryImpl.cs index 59748de2..8d7d5bd4 100644 --- a/src/VirtoCommerce.PricingModule.Data/Repositories/PricingRepositoryImpl.cs +++ b/src/VirtoCommerce.PricingModule.Data/Repositories/PricingRepositoryImpl.cs @@ -68,7 +68,61 @@ public Task DeletePricelistAssignmentsAsync(IEnumerable ids) return ExecuteSqlCommandAsync("DELETE FROM PricelistAssignment WHERE Id IN ({0})", ids); } + /// + /// Readonly only DBSet, do not try to insert/update/delete anything here + /// + public IQueryable GetMergedPrices(string basePriceListId, string priorityPriceListId) + { + var command = GetSearchMergedPricesCommand(basePriceListId, priorityPriceListId); + var query = DbContext.Set().FromSqlRaw(command.Text, command.Parameters.ToArray()); + return query; + } + + #region Raw queries + private static Command GetSearchMergedPricesCommand(string basePriceListId, string priorityPriceListId) + { + var template = @" + select a.* from + (select p.Id, p.ProductId, p.List, p.Sale, p.MinQuantity, p.PricelistId, 2 as [State] + FROM Price AS p + JOIN Price AS b + on p.ProductId = b.ProductId and p.MinQuantity = b.MinQuantity + WHERE b.PricelistId = @basePriceListId AND p.PricelistId = @priorityPriceListId + union + select Id, ProductId, List, Sale, MinQuantity, PricelistId, 0 as [State] + from Price c + where c.PricelistId = @basePriceListId and NOT EXISTS + ( + select p.ProductId, p.MinQuantity + FROM Price AS p + JOIN Price AS b + on p.ProductId = c.ProductId and p.MinQuantity = c.MinQuantity + WHERE b.PricelistId = @basePriceListId AND p.PricelistId = @priorityPriceListId + ) + union + select Id, ProductId, List, Sale, MinQuantity, PricelistId, 1 as [State] + from Price s + where s.PricelistId = @priorityPriceListId and NOT EXISTS + ( + select p.ProductId, p.MinQuantity + FROM Price AS p + JOIN Price AS b + on b.ProductId = s.ProductId and b.MinQuantity = s.MinQuantity + WHERE b.PricelistId = @basePriceListId AND p.PricelistId = @priorityPriceListId + )) a"; + + var basePriceListIdParam = new SqlParameter("@basePriceListId", basePriceListId); + var priorityPriceListIdParam = new SqlParameter("@priorityPriceListId", priorityPriceListId); + + return new Command + { + Text = template, + Parameters = new List { basePriceListIdParam, priorityPriceListIdParam } + }; + } + #endregion + #region Commands protected virtual Task ExecuteSqlCommandAsync(string commandTemplate, IEnumerable parameterValues) { if (parameterValues?.Count() > 0) @@ -96,5 +150,6 @@ protected class Command public string Text { get; set; } public IEnumerable Parameters { get; set; } } + #endregion } } diff --git a/src/VirtoCommerce.PricingModule.Data/Services/Search/MergedPriceSearchService.cs b/src/VirtoCommerce.PricingModule.Data/Services/Search/MergedPriceSearchService.cs new file mode 100644 index 00000000..fa1c3518 --- /dev/null +++ b/src/VirtoCommerce.PricingModule.Data/Services/Search/MergedPriceSearchService.cs @@ -0,0 +1,190 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using VirtoCommerce.CatalogModule.Core.Model; +using VirtoCommerce.CatalogModule.Core.Model.Search; +using VirtoCommerce.CatalogModule.Core.Search; +using VirtoCommerce.CatalogModule.Core.Services; +using VirtoCommerce.Platform.Caching; +using VirtoCommerce.Platform.Core.Caching; +using VirtoCommerce.Platform.Core.Common; +using VirtoCommerce.Platform.Core.GenericCrud; +using VirtoCommerce.Platform.Data.Infrastructure; +using VirtoCommerce.PricingModule.Core.Model; +using VirtoCommerce.PricingModule.Core.Model.Search; +using VirtoCommerce.PricingModule.Core.Services; +using VirtoCommerce.PricingModule.Data.Model; +using VirtoCommerce.PricingModule.Data.Repositories; + +namespace VirtoCommerce.PricingModule.Data.Services.Search +{ + public class MergedPriceSearchService : IMergedPriceSearchService + { + private readonly Func _repositoryFactory; + private readonly ICrudService _pricelistService; + private readonly IItemService _itemService; + private readonly IProductIndexedSearchService _productIndexedSearchService; + private readonly IPlatformMemoryCache _platformMemoryCache; + + public MergedPriceSearchService(Func repositoryFactory, + ICrudService pricelistService, + IItemService itemService, + IProductIndexedSearchService productIndexedSearchService, + IPlatformMemoryCache platformMemoryCache) + { + _repositoryFactory = repositoryFactory; + _pricelistService = pricelistService; + _itemService = itemService; + _productIndexedSearchService = productIndexedSearchService; + _platformMemoryCache = platformMemoryCache; + } + + public async Task SearchGroupsAsync(MergedPriceSearchCriteria criteria) + { + var cacheKey = CacheKey.With(GetType(), nameof(SearchGroupsAsync), criteria.GetCacheKey()); + return await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, async cacheEntry => + { + cacheEntry.AddExpirationToken(GenericCachingRegion.CreateChangeToken()); + cacheEntry.AddExpirationToken(GenericSearchCachingRegion.CreateChangeToken()); + + var result = AbstractTypeFactory.TryCreateInstance(); + + using (var repository = _repositoryFactory()) + { + repository.DisableChangesTracking(); + + var query = await BuildQueryAsync(repository, criteria); + + var groupedQuery = query.GroupBy(x => x.ProductId); + + result.TotalCount = await groupedQuery.CountAsync(); + + if (criteria.Take > 0) + { + groupedQuery = groupedQuery + .OrderBy(x => x.Key) + .Skip(criteria.Skip) + .Take(criteria.Take); + + var resultGroups = await groupedQuery.Select(x => new MergedPriceGroupEntity + { + ProductId = x.Key, + GroupPricesCount = x.Count(), + MinListPrice = x.Min(g => g.List), + MaxListPrice = x.Max(g => g.List), + MinSalePrice = x.Min(g => g.Sale), + MaxSalePrice = x.Max(g => g.Sale), + GroupState = x.Max(g => g.State) + }).ToListAsync(); + + result.Results = resultGroups + .Select(x => x.ToModel(AbstractTypeFactory.TryCreateInstance())) + .ToList(); + } + } + + if (result.Results.Any()) + { + var products = await _itemService.GetByIdsAsync(result.Results.Select(x => x.ProductId).ToArray(), ItemResponseGroup.ItemInfo.ToString()); + foreach (var group in result.Results) + { + var product = products.FirstOrDefault(x => x.Id == group.ProductId); + if (product != null) + { + group.ProductCode = product.Code; + group.ProductName = product.Name; + group.ProductImgSrc = product.ImgSrc; + } + } + } + + return result; + }); + } + + public async Task SearchGroupPricesAsync(MergedPriceSearchCriteria criteria) + { + var cacheKey = CacheKey.With(GetType(), nameof(SearchGroupPricesAsync), criteria.GetCacheKey()); + return await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, async cacheEntry => + { + cacheEntry.AddExpirationToken(GenericCachingRegion.CreateChangeToken()); + cacheEntry.AddExpirationToken(GenericSearchCachingRegion.CreateChangeToken()); + + var result = AbstractTypeFactory.TryCreateInstance(); + + using (var repository = _repositoryFactory()) + { + repository.DisableChangesTracking(); + + var query = await BuildQueryAsync(repository, criteria); + + if (criteria.All) + { + query = query.OrderBy(x => x.MinQuantity); + } + else + { + result.TotalCount = await query.CountAsync(); + + query = query + .OrderBy(x => x.ProductId) + .ThenBy(x => x.MinQuantity) + .Skip(criteria.Skip) + .Take(criteria.Take); + } + + var results = await query.ToListAsync(); + + result.TotalCount = criteria.All ? results.Count : result.TotalCount; + result.Results = results + .Select(x => x.ToModel(AbstractTypeFactory.TryCreateInstance())) + .ToList(); + } + + if (result.Results.Any()) + { + // get currency from base pricelist + var priceList = await _pricelistService.GetByIdAsync(criteria.BasePriceListId, PriceListResponseGroup.NoDetails.ToString()); + if (priceList != null) + { + foreach (var price in result.Results) + { + price.Currency = priceList.Currency; + } + } + } + + return result; + }); + } + + protected async Task> BuildQueryAsync(IPricingRepository repository, MergedPriceSearchCriteria criteria) + { + var query = repository.GetMergedPrices(criteria.BasePriceListId, criteria.PriorityPriceListId); + + if (!criteria.ProductIds.IsNullOrEmpty()) + { + query = query.Where(x => criteria.ProductIds.Contains(x.ProductId)); + } + + if (!string.IsNullOrEmpty(criteria.Keyword)) + { + var searchCriteria = AbstractTypeFactory.TryCreateInstance(); + searchCriteria.Keyword = criteria.Keyword; + searchCriteria.Skip = criteria.Skip; + searchCriteria.Take = criteria.Take; + searchCriteria.ResponseGroup = ItemResponseGroup.ItemInfo.ToString(); + searchCriteria.WithHidden = true; + var searchResult = await _productIndexedSearchService.SearchAsync(searchCriteria); + + var productIds = searchResult.Items.Select(x => x.Id); + + query = query.Where(x => productIds.Contains(x.ProductId)); + } + + return query; + } + } +} diff --git a/src/VirtoCommerce.PricingModule.Web/Module.cs b/src/VirtoCommerce.PricingModule.Web/Module.cs index a7003000..c9bfc706 100644 --- a/src/VirtoCommerce.PricingModule.Web/Module.cs +++ b/src/VirtoCommerce.PricingModule.Web/Module.cs @@ -36,6 +36,7 @@ using VirtoCommerce.PricingModule.Data.Repositories; using VirtoCommerce.PricingModule.Data.Search; using VirtoCommerce.PricingModule.Data.Services; +using VirtoCommerce.PricingModule.Data.Services.Search; using VirtoCommerce.PricingModule.Data.Validators; #pragma warning disable CS0618 // Allow to use obsoleted @@ -80,6 +81,8 @@ public void Initialize(IServiceCollection serviceCollection) serviceCollection.AddTransient>, PricelistAssignmentsValidator>(); serviceCollection.AddTransient, PriceListValidator>(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); serviceCollection.AddTransient();