From 6877f535a9320401b367383d6394440bd2e19367 Mon Sep 17 00:00:00 2001 From: support Date: Wed, 21 Oct 2026 21:06:55 +0200 Subject: [PATCH 1/3] Run the admin report queries asynchronously and page bestsellers in the aggregation --- .../Services/Orders/OrderReportService.cs | 52 +++--- .../Services/CustomerReportService.cs | 11 +- .../Orders/OrderReportServiceTests.cs | 150 ++++++++++++++++++ .../Services/CustomerReportServiceTests.cs | 84 ++++++++++ 4 files changed, 263 insertions(+), 34 deletions(-) create mode 100644 src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs create mode 100644 src/Tests/Grand.Business.Customers.Tests/Services/CustomerReportServiceTests.cs diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs index a27a190bb9..4e9689e008 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs @@ -103,7 +103,7 @@ into result SumOrders = r.SumOrders }); - return await Task.FromResult(report.ToList()); + return await _orderRepository.ToListAsync(report); } var vendorQuery = from p in query @@ -133,7 +133,7 @@ into result TotalOrders = r.TotalOrders, SumOrders = r.SumOrders }); - return await Task.FromResult(vendorReport.ToList()); + return await _orderRepository.ToListAsync(vendorReport); } @@ -166,14 +166,14 @@ public virtual async Task> GetOrderByTimeReport(str var daydiff = (endTimeUtc.Value - startTimeUtc.Value).TotalDays; if (daydiff > 31) { - var query = builderquery.GroupBy(x => + var query = await _orderRepository.ToListAsync(builderquery.GroupBy(x => new { x.CreatedOnUtc.Year, x.CreatedOnUtc.Month }) .Select(g => new OrderStats { Year = g.Key.Year, Month = g.Key.Month, Count = g.Count(), Amount = g.Sum(x => x.OrderTotal * x.CurrencyRate) - }).ToList(); + })); foreach (var item in query) report.Add(new OrderByTimeReportLine { @@ -184,7 +184,7 @@ public virtual async Task> GetOrderByTimeReport(str } else { - var query = builderquery.GroupBy(x => + var query = await _orderRepository.ToListAsync(builderquery.GroupBy(x => new { x.CreatedOnUtc.Year, x.CreatedOnUtc.Month, x.CreatedOnUtc.Day }) .Select(g => new OrderStats { Year = g.Key.Year, @@ -192,7 +192,7 @@ public virtual async Task> GetOrderByTimeReport(str Day = g.Key.Day, Count = g.Count(), Amount = g.Sum(x => x.OrderTotal * x.CurrencyRate) - }).ToList(); + })); foreach (var item in query) report.Add(new OrderByTimeReportLine { @@ -203,7 +203,7 @@ public virtual async Task> GetOrderByTimeReport(str }); } - return await Task.FromResult(report); + return report; } /// @@ -299,18 +299,14 @@ public virtual async Task GetOrderAverageReportLine(stri SumShippingExclTax = g.Sum(o => o.OrderShippingExclTax / o.CurrencyRate), SumTax = g.Sum(o => o.OrderTax / o.CurrencyRate), SumOrders = g.Sum(o => o.OrderTotal / o.CurrencyRate) - }).ToList(); - + }); - var item2 = query.Count > 0 - ? query.FirstOrDefault() - : new OrderAverageReportLine { - CountOrders = 0, - SumShippingExclTax = 0, - SumTax = 0, - SumOrders = 0 - }; - return await Task.FromResult(item2); + return await _orderRepository.FirstOrDefaultAsync(query) ?? new OrderAverageReportLine { + CountOrders = 0, + SumShippingExclTax = 0, + SumTax = 0, + SumOrders = 0 + }; } /// @@ -458,11 +454,10 @@ from item in p.OrderItems }); var queryItemOrdered = orderBy == 1 - ? queryItem.OrderByDescending(x => x.TotalQuantity).ToList() - : queryItem.OrderByDescending(x => x.TotalAmount).ToList(); + ? queryItem.OrderByDescending(x => x.TotalQuantity) + : queryItem.OrderByDescending(x => x.TotalAmount); - var result = new PagedList(queryItemOrdered, pageIndex, pageSize); - return await Task.FromResult(result); + return await _orderRepository.PagedAsync(queryItemOrdered, pageIndex, pageSize); } @@ -489,9 +484,9 @@ group o by 1 into g select new ReportPeriodOrder { Amount = g.Sum(x => x.OrderTotal / x.CurrencyRate), Count = g.Count() }; - var report = query.FirstOrDefault() ?? new ReportPeriodOrder(); + var report = await _orderRepository.FirstOrDefaultAsync(query) ?? new ReportPeriodOrder(); report.Date = date; - return await Task.FromResult(report); + return report; } @@ -518,9 +513,9 @@ into g if (recordsToReturn > 0) product = product.Take(recordsToReturn); - var report = product.ToList(); + var report = await _productAlsoPurchasedRepository.ToListAsync(product.Select(x => x.ProductId)); - return await Task.FromResult(report.Select(reportLine => reportLine.ProductId).ToArray()); + return report.ToArray(); } /// @@ -548,11 +543,12 @@ public virtual async Task> ProductsNeverSold(string storeId createdToUtc.Value >= order.CreatedOnUtc && !order.Deleted from orderItem in order.OrderItems - select new { orderItem.ProductId }).ToList().Distinct().Select(x => x.ProductId); + select orderItem.ProductId).Distinct(); + var soldProductIds = await _orderRepository.ToListAsync(query); var qproducts = from p in _productRepository.Table orderby p.Name - where !query.Contains(p.Id) && + where !soldProductIds.Contains(p.Id) && //include only simple products p.ProductTypeId == ProductType.SimpleProduct && (vendorId == "" || p.VendorId == vendorId) && diff --git a/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs b/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs index fe752a202d..9c7e6989af 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs @@ -165,8 +165,7 @@ public virtual async Task GetRegisteredCustomersReport(string storeId, int c.CreatedOnUtc >= date //&& c.CreatedOnUtc <= DateTime.UtcNow select c; - var count = query.Count(); - return count; + return await _customerRepository.CountAsync(query); } @@ -201,7 +200,7 @@ public virtual async Task> GetCustomerByTimeRepo var daydiff = (endTimeUtc.Value - startTimeUtc.Value).TotalDays; if (daydiff > 31) { - var query = builderquery.GroupBy(x => new { + var query = await _customerRepository.ToListAsync(builderquery.GroupBy(x => new { x.CreatedOnUtc.Year, x.CreatedOnUtc.Month }) @@ -209,7 +208,7 @@ public virtual async Task> GetCustomerByTimeRepo Year = g.Key.Year, Month = g.Key.Month, Count = g.Count() - }).ToList(); + })); foreach (var item in query) report.Add(new CustomerByTimeReportLine { Time = item.Year + "-" + item.Month.ToString().PadLeft(2, '0'), @@ -218,14 +217,14 @@ public virtual async Task> GetCustomerByTimeRepo } else { - var query = builderquery.GroupBy(x => + var query = await _customerRepository.ToListAsync(builderquery.GroupBy(x => new { x.CreatedOnUtc.Year, x.CreatedOnUtc.Month, x.CreatedOnUtc.Day }) .Select(g => new CustomerStats { Year = g.Key.Year, Month = g.Key.Month, Day = g.Key.Day, Count = g.Count() - }).ToList(); + })); foreach (var item in query) report.Add(new CustomerByTimeReportLine { Time = item.Year + "-" + item.Month.ToString().PadLeft(2, '0') + "-" + diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs new file mode 100644 index 0000000000..778fbeb1d4 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs @@ -0,0 +1,150 @@ +using Grand.Business.Checkout.Services.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Catalog; +using Grand.Domain.Orders; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Services.Orders; + +[TestClass] +public class OrderReportServiceTests +{ + private IRepository _orderRepository; + private IRepository _productAlsoPurchasedRepository; + private IRepository _productRepository; + private OrderReportService _service; + + [TestInitialize] + public void Init() + { + _orderRepository = new MongoDBRepositoryTest(); + _productRepository = new MongoDBRepositoryTest(); + _productAlsoPurchasedRepository = new MongoDBRepositoryTest(); + _service = new OrderReportService(_orderRepository, _productAlsoPurchasedRepository, _productRepository, + new Mock().Object); + } + + private Task InsertOrder(string storeId, params OrderItem[] items) + { + var order = new Order { StoreId = storeId, Rate = 1, CurrencyRate = 1 }; + foreach (var item in items) + order.OrderItems.Add(item); + return _orderRepository.InsertAsync(order); + } + + private static OrderItem Item(string productId, int quantity, double priceInclTax, string vendorId = "") + { + return new OrderItem { + ProductId = productId, Quantity = quantity, PriceInclTax = priceInclTax, VendorId = vendorId + }; + } + + [TestMethod] + public async Task BestSellersReport_GroupsByProduct_OrderedByQuantity() + { + await InsertOrder("store", Item("p1", 1, 100), Item("p2", 3, 30)); + await InsertOrder("store", Item("p2", 2, 20), Item("p3", 4, 500)); + + var result = await _service.BestSellersReport(orderBy: 1); + + CollectionAssert.AreEqual(new[] { "p2", "p3", "p1" }, result.Select(x => x.ProductId).ToArray()); + Assert.AreEqual(5, result[0].TotalQuantity); + Assert.AreEqual(50, result[0].TotalAmount); + Assert.AreEqual(3, result.TotalCount); + } + + [TestMethod] + public async Task BestSellersReport_OrderedByAmount() + { + await InsertOrder("store", Item("p1", 1, 100), Item("p2", 3, 30)); + await InsertOrder("store", Item("p3", 1, 500)); + + var result = await _service.BestSellersReport(orderBy: 2); + + CollectionAssert.AreEqual(new[] { "p3", "p1", "p2" }, result.Select(x => x.ProductId).ToArray()); + } + + [TestMethod] + public async Task BestSellersReport_ReturnsRequestedPage_WithTotalOverAllGroups() + { + for (var i = 1; i <= 5; i++) + await InsertOrder("store", Item($"p{i}", i, i * 10)); + + var result = await _service.BestSellersReport(orderBy: 1, pageIndex: 1, pageSize: 2); + + CollectionAssert.AreEqual(new[] { "p3", "p2" }, result.Select(x => x.ProductId).ToArray()); + Assert.AreEqual(5, result.TotalCount); + Assert.AreEqual(3, result.TotalPages); + } + + [TestMethod] + public async Task BestSellersReport_VendorAndStoreFilters_CountOnlyMatchingItems() + { + await InsertOrder("store", Item("p1", 4, 40, "v1"), Item("p2", 9, 90, "v2")); + await InsertOrder("other", Item("p1", 7, 70, "v1")); + + var result = await _service.BestSellersReport("store", "v1"); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("p1", result[0].ProductId); + Assert.AreEqual(4, result[0].TotalQuantity); + } + + [TestMethod] + public async Task GetOrderAverageReportLine_NoOrders_ReturnsZeros() + { + var result = await _service.GetOrderAverageReportLine(); + + Assert.AreEqual(0, result.CountOrders); + Assert.AreEqual(0, result.SumOrders); + } + + [TestMethod] + public async Task GetOrderAverageReportLine_SumsOrdersInPrimaryCurrency() + { + await _orderRepository.InsertAsync(new Order { OrderTotal = 10, CurrencyRate = 1 }); + await _orderRepository.InsertAsync(new Order { OrderTotal = 30, CurrencyRate = 2 }); + + var result = await _service.GetOrderAverageReportLine(); + + Assert.AreEqual(2, result.CountOrders); + Assert.AreEqual(25, result.SumOrders); + } + + [TestMethod] + public async Task ProductsNeverSold_ExcludesSoldProducts() + { + var sold = new Product { Name = "a", Published = true, ProductTypeId = ProductType.SimpleProduct }; + var unsold = new Product { Name = "b", Published = true, ProductTypeId = ProductType.SimpleProduct }; + await _productRepository.InsertAsync(sold); + await _productRepository.InsertAsync(unsold); + await InsertOrder("store", Item(sold.Id, 1, 10)); + await InsertOrder("store", Item(sold.Id, 2, 20)); + + var result = await _service.ProductsNeverSold(); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(unsold.Id, result[0].Id); + } + + [TestMethod] + public async Task GetAlsoPurchasedProductsIds_OrderedByQuantity_LimitedToRecords() + { + await _productAlsoPurchasedRepository.InsertAsync(new ProductAlsoPurchased { + ProductId = "p", ProductId2 = "a", Quantity = 1 + }); + await _productAlsoPurchasedRepository.InsertAsync(new ProductAlsoPurchased { + ProductId = "p", ProductId2 = "b", Quantity = 5 + }); + await _productAlsoPurchasedRepository.InsertAsync(new ProductAlsoPurchased { + ProductId = "p", ProductId2 = "c", Quantity = 3 + }); + + var result = await _service.GetAlsoPurchasedProductsIds("", "p", 2); + + CollectionAssert.AreEqual(new[] { "b", "c" }, result); + } +} diff --git a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerReportServiceTests.cs b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerReportServiceTests.cs new file mode 100644 index 0000000000..088d3dca1c --- /dev/null +++ b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerReportServiceTests.cs @@ -0,0 +1,84 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Customers.Services; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Customers.Tests.Services; + +[TestClass] +public class CustomerReportServiceTests +{ + private readonly CustomerGroup _registered = new() { SystemName = SystemCustomerGroupNames.Registered }; + private IRepository _customerRepository; + private IRepository _orderRepository; + private CustomerReportService _service; + + [TestInitialize] + public void Init() + { + _customerRepository = new MongoDBRepositoryTest(); + _orderRepository = new MongoDBRepositoryTest(); + + var groupServiceMock = new Mock(); + groupServiceMock.Setup(x => x.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Registered)) + .ReturnsAsync(_registered); + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock.Setup(x => x.ConvertToUserTime(It.IsAny())).Returns(x => x); + + _service = new CustomerReportService(_customerRepository, _orderRepository, groupServiceMock.Object, + dateTimeServiceMock.Object); + } + + private Task InsertRegistered(string storeId) + { + var customer = new Customer { StoreId = storeId }; + customer.Groups.Add(_registered.Id); + return _customerRepository.InsertAsync(customer); + } + + [TestMethod] + public async Task GetRegisteredCustomersReport_CountsRegisteredInStore() + { + await InsertRegistered("store"); + await InsertRegistered("store"); + await InsertRegistered("other"); + await _customerRepository.InsertAsync(new Customer { StoreId = "store" }); + + var result = await _service.GetRegisteredCustomersReport("store", 7); + + Assert.AreEqual(2, result); + } + + [TestMethod] + public async Task GetCustomerByTimeReport_GroupsRegisteredByDay() + { + await InsertRegistered("store"); + await InsertRegistered("store"); + + var result = await _service.GetCustomerByTimeReport("", DateTime.UtcNow.AddDays(-1), DateTime.UtcNow); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(2, result[0].Registered); + Assert.AreEqual(DateTime.UtcNow.ToString("yyyy-MM-dd"), result[0].Time); + } + + [TestMethod] + public async Task GetBestCustomersReport_OrderedByTotal_Paged() + { + await _orderRepository.InsertAsync(new Order { CustomerId = "c1", OrderTotal = 10, CurrencyRate = 1 }); + await _orderRepository.InsertAsync(new Order { CustomerId = "c2", OrderTotal = 50, CurrencyRate = 1 }); + await _orderRepository.InsertAsync(new Order { CustomerId = "c1", OrderTotal = 20, CurrencyRate = 1 }); + await _orderRepository.InsertAsync(new Order { CustomerId = "c3", OrderTotal = 5, CurrencyRate = 1 }); + + var result = await _service.GetBestCustomersReport(orderBy: 1, pageIndex: 0, pageSize: 2); + + CollectionAssert.AreEqual(new[] { "c2", "c1" }, result.Select(x => x.CustomerId).ToArray()); + Assert.AreEqual(30, result[1].OrderTotal); + Assert.AreEqual(2, result[1].OrderCount); + Assert.AreEqual(3, result.TotalCount); + } +} From 7260e0a833030c3386f3710d1daf986af444151c Mon Sep 17 00:00:00 2001 From: support Date: Wed, 21 Oct 2026 21:37:57 +0200 Subject: [PATCH 2/3] Fix paging ties, currency and date bounds in the admin reports - Bestsellers and best customers sort with a unique tie-breaker, so pages paged in the aggregation no longer repeat or skip tied rows - Bestseller and vendor amounts divide by CurrencyRate, and the order-by-time report divides instead of multiplying, matching the other reports - Vendor country and best-customer reports count orders, not order items - By-time reports are sorted by time and include the whole end day - Registered customers report uses a UTC cutoff; customer-by-time report returns empty when the Registered group is missing --- .../Services/Orders/OrderReportService.cs | 38 ++++++----- .../Services/CustomerReportService.cs | 43 +++++++----- .../Orders/OrderReportServiceTests.cs | 67 ++++++++++++++++++- .../Services/CustomerReportServiceTests.cs | 55 +++++++++++---- 4 files changed, 156 insertions(+), 47 deletions(-) diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs index 4e9689e008..9d735f3e80 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs @@ -109,22 +109,30 @@ into result var vendorQuery = from p in query from item in p.OrderItems select new { - item.VendorId, OrderCode = p.Code, + item.VendorId, OrderId = p.Id, p.BillingAddress.CountryId, - item.Quantity, item.PriceInclTax, - p.Rate + p.CurrencyRate }; vendorQuery = vendorQuery.Where(x => x.VendorId == vendorId); - var vendorReport = (from oq in vendorQuery - group oq by oq.CountryId + //one row per order first, so an order with several vendor items is counted once + var vendorOrders = from oq in vendorQuery + group oq by new { oq.OrderId, oq.CountryId } + into result + select new { + result.Key.CountryId, + Amount = result.Sum(y => y.PriceInclTax / y.CurrencyRate) + }; + + var vendorReport = (from vo in vendorOrders + group vo by vo.CountryId into result select new { CountryId = result.Key, TotalOrders = result.Count(), - SumOrders = result.Sum(y => y.PriceInclTax / y.Rate) + SumOrders = result.Sum(y => y.Amount) } ) .OrderByDescending(x => x.SumOrders) @@ -152,13 +160,13 @@ public virtual async Task> GetOrderByTimeReport(str startTimeUtc ??= DateTime.MinValue; endTimeUtc ??= DateTime.UtcNow; - var endTime = new DateTime(endTimeUtc.Value.Year, endTimeUtc.Value.Month, endTimeUtc.Value.Day, 23, 59, 00); + var endTime = endTimeUtc.Value.Date.AddDays(1); var builderquery = from p in _orderRepository.Table select p; builderquery = builderquery.Where(o => !o.Deleted); - builderquery = builderquery.Where(o => o.CreatedOnUtc >= startTimeUtc.Value && o.CreatedOnUtc <= endTime); + builderquery = builderquery.Where(o => o.CreatedOnUtc >= startTimeUtc.Value && o.CreatedOnUtc < endTime); if (!string.IsNullOrEmpty(storeId)) builderquery = builderquery.Where(o => o.StoreId == storeId); @@ -172,7 +180,7 @@ public virtual async Task> GetOrderByTimeReport(str Year = g.Key.Year, Month = g.Key.Month, Count = g.Count(), - Amount = g.Sum(x => x.OrderTotal * x.CurrencyRate) + Amount = g.Sum(x => x.OrderTotal / x.CurrencyRate) })); foreach (var item in query) @@ -191,7 +199,7 @@ public virtual async Task> GetOrderByTimeReport(str Month = g.Key.Month, Day = g.Key.Day, Count = g.Count(), - Amount = g.Sum(x => x.OrderTotal * x.CurrencyRate) + Amount = g.Sum(x => x.OrderTotal / x.CurrencyRate) })); foreach (var item in query) @@ -203,7 +211,7 @@ public virtual async Task> GetOrderByTimeReport(str }); } - return report; + return report.OrderBy(x => x.Time).ToList(); } /// @@ -442,20 +450,20 @@ from item in p.OrderItems item.ProductId, item.Quantity, item.PriceInclTax, - p.Rate + p.CurrencyRate }; if (!string.IsNullOrEmpty(vendorId)) query = query.Where(x => x.VendorId == vendorId); var queryItem = query.GroupBy(x => new { x.ProductId }).Select(x => new BestsellersReportLine { ProductId = x.Key.ProductId, - TotalAmount = x.Sum(y => y.PriceInclTax / y.Rate), + TotalAmount = x.Sum(y => y.PriceInclTax / y.CurrencyRate), TotalQuantity = x.Sum(y => y.Quantity) }); var queryItemOrdered = orderBy == 1 - ? queryItem.OrderByDescending(x => x.TotalQuantity) - : queryItem.OrderByDescending(x => x.TotalAmount); + ? queryItem.OrderByDescending(x => x.TotalQuantity).ThenBy(x => x.ProductId) + : queryItem.OrderByDescending(x => x.TotalAmount).ThenBy(x => x.ProductId); return await _orderRepository.PagedAsync(queryItemOrdered, pageIndex, pageSize); } diff --git a/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs b/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs index 9c7e6989af..62e1d36d87 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs @@ -22,17 +22,14 @@ public class CustomerReportService : ICustomerReportService /// /// Customer repository /// Order repository - /// Date time helper /// group service public CustomerReportService(IRepository customerRepository, IRepository orderRepository, - IGroupService groupService, - IDateTimeService dateTimeService) + IGroupService groupService) { _customerRepository = customerRepository; _orderRepository = orderRepository; _groupService = groupService; - _dateTimeService = dateTimeService; } #endregion @@ -42,7 +39,6 @@ public CustomerReportService(IRepository customerRepository, private readonly IRepository _customerRepository; private readonly IRepository _orderRepository; private readonly IGroupService _groupService; - private readonly IDateTimeService _dateTimeService; #endregion @@ -96,8 +92,8 @@ into g OrderCount = g.Count() }; query2 = orderBy switch { - 1 => query2.OrderByDescending(x => x.OrderTotal), - 2 => query2.OrderByDescending(x => x.OrderCount), + 1 => query2.OrderByDescending(x => x.OrderTotal).ThenBy(x => x.CustomerId), + 2 => query2.OrderByDescending(x => x.OrderCount).ThenBy(x => x.CustomerId), _ => throw new ArgumentException("Wrong orderBy parameter", nameof(orderBy)) }; @@ -113,25 +109,33 @@ into g from item in p.OrderItems select new { item.VendorId, - p.CustomerId, OrderCode = p.Code, - item.Quantity, + p.CustomerId, OrderId = p.Id, item.PriceInclTax, - p.Rate + p.CurrencyRate }; vendorQuery = vendorQuery.Where(x => x.VendorId == vendorId); - var vendorQueryGroup = from co in vendorQuery - group co by co.CustomerId + //one row per order first, so an order with several vendor items is counted once + var vendorOrders = from co in vendorQuery + group co by new { co.OrderId, co.CustomerId } + into g + select new { + g.Key.CustomerId, + Amount = g.Sum(x => x.PriceInclTax / x.CurrencyRate) + }; + + var vendorQueryGroup = from vo in vendorOrders + group vo by vo.CustomerId into g select new { CustomerId = g.Key, - OrderTotal = g.Sum(x => x.PriceInclTax / x.Rate), + OrderTotal = g.Sum(x => x.Amount), OrderCount = g.Count() }; vendorQueryGroup = orderBy switch { - 1 => vendorQueryGroup.OrderByDescending(x => x.OrderTotal), - 2 => vendorQueryGroup.OrderByDescending(x => x.OrderCount), + 1 => vendorQueryGroup.OrderByDescending(x => x.OrderTotal).ThenBy(x => x.CustomerId), + 2 => vendorQueryGroup.OrderByDescending(x => x.OrderCount).ThenBy(x => x.CustomerId), _ => throw new ArgumentException("Wrong orderBy parameter", nameof(orderBy)) }; @@ -151,7 +155,7 @@ into g /// Number of registered customers public virtual async Task GetRegisteredCustomersReport(string storeId, int days) { - var date = _dateTimeService.ConvertToUserTime(DateTime.Now).AddDays(-days); + var date = DateTime.UtcNow.AddDays(-days); var registeredCustomerGroup = await _groupService.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Registered); @@ -185,14 +189,17 @@ public virtual async Task> GetCustomerByTimeRepo startTimeUtc ??= DateTime.MinValue; endTimeUtc ??= DateTime.UtcNow; - var endTime = new DateTime(endTimeUtc.Value.Year, endTimeUtc.Value.Month, endTimeUtc.Value.Day, 23, 59, 00); + var endTime = endTimeUtc.Value.Date.AddDays(1); var builderquery = from p in _customerRepository.Table select p; var customergroup = await _groupService.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Registered); + if (customergroup == null) + return report; + var customerGroupRegister = customergroup.Id; builderquery = builderquery.Where(o => !o.Deleted); - builderquery = builderquery.Where(o => o.CreatedOnUtc >= startTimeUtc.Value && o.CreatedOnUtc <= endTime); + builderquery = builderquery.Where(o => o.CreatedOnUtc >= startTimeUtc.Value && o.CreatedOnUtc < endTime); builderquery = builderquery.Where(o => o.Groups.Any(y => y == customerGroupRegister)); if (!string.IsNullOrEmpty(storeId)) builderquery = builderquery.Where(o => o.StoreId == storeId); diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs index 778fbeb1d4..6233d9ff32 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs @@ -3,6 +3,7 @@ using Grand.Data; using Grand.Data.Tests.MongoDb; using Grand.Domain.Catalog; +using Grand.Domain.Common; using Grand.Domain.Orders; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -12,7 +13,7 @@ namespace Grand.Business.Checkout.Tests.Services.Orders; [TestClass] public class OrderReportServiceTests { - private IRepository _orderRepository; + private MongoDBRepositoryTest _orderRepository; private IRepository _productAlsoPurchasedRepository; private IRepository _productRepository; private OrderReportService _service; @@ -80,6 +81,70 @@ public async Task BestSellersReport_ReturnsRequestedPage_WithTotalOverAllGroups( Assert.AreEqual(3, result.TotalPages); } + [TestMethod] + public async Task BestSellersReport_TiedProducts_PagesDoNotOverlap() + { + for (var i = 1; i <= 6; i++) + await InsertOrder("store", Item($"p{i}", 1, 10)); + + var pages = new List(); + for (var page = 0; page < 3; page++) + pages.AddRange((await _service.BestSellersReport(orderBy: 1, pageIndex: page, pageSize: 2)) + .Select(x => x.ProductId)); + + CollectionAssert.AreEqual(new[] { "p1", "p2", "p3", "p4", "p5", "p6" }, pages); + } + + [TestMethod] + public async Task BestSellersReport_AmountInPrimaryCurrency() + { + var order = new Order { CurrencyRate = 2, Rate = 8 }; + order.OrderItems.Add(Item("p1", 1, 40)); + await _orderRepository.InsertAsync(order); + + var result = await _service.BestSellersReport(); + + Assert.AreEqual(20, result[0].TotalAmount); + } + + [TestMethod] + public async Task GetCountryReport_Vendor_CountsOrdersNotItems() + { + var order = new Order { CurrencyRate = 2, BillingAddress = new Address { CountryId = "pl" } }; + order.OrderItems.Add(Item("p1", 1, 10, "v1")); + order.OrderItems.Add(Item("p2", 1, 30, "v1")); + order.OrderItems.Add(Item("p3", 1, 99, "v2")); + await _orderRepository.InsertAsync(order); + + var result = await _service.GetCountryReport(vendorId: "v1"); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("pl", result[0].CountryId); + Assert.AreEqual(1, result[0].TotalOrders); + Assert.AreEqual(20, result[0].SumOrders); + } + + [TestMethod] + public async Task GetOrderByTimeReport_PrimaryCurrency_SortedByDay_IncludesWholeEndDay() + { + //written to the collection directly, InsertAsync overwrites CreatedOnUtc + var end = new DateTime(2026, 3, 10, 0, 0, 0, DateTimeKind.Utc); + await _orderRepository.Collection.InsertOneAsync(new Order { + OrderTotal = 40, CurrencyRate = 2, CreatedOnUtc = end.AddHours(23).AddMinutes(59).AddSeconds(30) + }); + await _orderRepository.Collection.InsertOneAsync(new Order { + OrderTotal = 10, CurrencyRate = 1, CreatedOnUtc = end.AddDays(-2) + }); + await _orderRepository.Collection.InsertOneAsync(new Order { + OrderTotal = 10, CurrencyRate = 1, CreatedOnUtc = end.AddDays(1) + }); + + var result = await _service.GetOrderByTimeReport("", end.AddDays(-5), end); + + CollectionAssert.AreEqual(new[] { "2026-03-08", "2026-03-10" }, result.Select(x => x.Time).ToArray()); + Assert.AreEqual(20, result[1].SumOrders); + } + [TestMethod] public async Task BestSellersReport_VendorAndStoreFilters_CountOnlyMatchingItems() { diff --git a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerReportServiceTests.cs b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerReportServiceTests.cs index 088d3dca1c..1da239c422 100644 --- a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerReportServiceTests.cs +++ b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerReportServiceTests.cs @@ -13,7 +13,7 @@ namespace Grand.Business.Customers.Tests.Services; public class CustomerReportServiceTests { private readonly CustomerGroup _registered = new() { SystemName = SystemCustomerGroupNames.Registered }; - private IRepository _customerRepository; + private MongoDBRepositoryTest _customerRepository; private IRepository _orderRepository; private CustomerReportService _service; @@ -26,18 +26,19 @@ public void Init() var groupServiceMock = new Mock(); groupServiceMock.Setup(x => x.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Registered)) .ReturnsAsync(_registered); - var dateTimeServiceMock = new Mock(); - dateTimeServiceMock.Setup(x => x.ConvertToUserTime(It.IsAny())).Returns(x => x); - - _service = new CustomerReportService(_customerRepository, _orderRepository, groupServiceMock.Object, - dateTimeServiceMock.Object); + _service = new CustomerReportService(_customerRepository, _orderRepository, groupServiceMock.Object); } - private Task InsertRegistered(string storeId) + private Task InsertRegistered(string storeId, DateTime? createdOnUtc = null) { var customer = new Customer { StoreId = storeId }; customer.Groups.Add(_registered.Id); - return _customerRepository.InsertAsync(customer); + if (createdOnUtc == null) + return _customerRepository.InsertAsync(customer); + + //written to the collection directly, InsertAsync overwrites CreatedOnUtc + customer.CreatedOnUtc = createdOnUtc.Value; + return _customerRepository.Collection.InsertOneAsync(customer); } [TestMethod] @@ -56,14 +57,42 @@ public async Task GetRegisteredCustomersReport_CountsRegisteredInStore() [TestMethod] public async Task GetCustomerByTimeReport_GroupsRegisteredByDay() { - await InsertRegistered("store"); - await InsertRegistered("store"); + var end = new DateTime(2026, 3, 10, 0, 0, 0, DateTimeKind.Utc); + await InsertRegistered("store", end.AddHours(23).AddMinutes(59).AddSeconds(30)); + await InsertRegistered("store", end.AddHours(1)); + await InsertRegistered("store", end.AddDays(-2)); + await InsertRegistered("store", end.AddDays(1)); + + var result = await _service.GetCustomerByTimeReport("", end.AddDays(-5), end); + + CollectionAssert.AreEqual(new[] { "2026-03-08", "2026-03-10" }, result.Select(x => x.Time).ToArray()); + Assert.AreEqual(2, result[1].Registered); + } + + [TestMethod] + public async Task GetCustomerByTimeReport_NoRegisteredGroup_ReturnsEmpty() + { + var service = new CustomerReportService(_customerRepository, _orderRepository, new Mock().Object); + + var result = await service.GetCustomerByTimeReport(""); + + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public async Task GetBestCustomersReport_Vendor_CountsOrdersNotItems() + { + var order = new Order { CustomerId = "c1", CurrencyRate = 2 }; + order.OrderItems.Add(new OrderItem { VendorId = "v1", PriceInclTax = 10 }); + order.OrderItems.Add(new OrderItem { VendorId = "v1", PriceInclTax = 30 }); + order.OrderItems.Add(new OrderItem { VendorId = "v2", PriceInclTax = 99 }); + await _orderRepository.InsertAsync(order); - var result = await _service.GetCustomerByTimeReport("", DateTime.UtcNow.AddDays(-1), DateTime.UtcNow); + var result = await _service.GetBestCustomersReport(vendorId: "v1", orderBy: 1); Assert.AreEqual(1, result.Count); - Assert.AreEqual(2, result[0].Registered); - Assert.AreEqual(DateTime.UtcNow.ToString("yyyy-MM-dd"), result[0].Time); + Assert.AreEqual(1, result[0].OrderCount); + Assert.AreEqual(20, result[0].OrderTotal); } [TestMethod] From 769713eb035a0c3f365e6282cf84835bab7e0eac Mon Sep 17 00:00:00 2001 From: support Date: Wed, 21 Oct 2026 22:07:29 +0200 Subject: [PATCH 3/3] Read the by-time report bounds into non-nullable locals --- .../Services/Orders/OrderReportService.cs | 10 +++++----- .../Services/CustomerReportService.cs | 10 +++++----- .../Services/Orders/OrderReportServiceTests.cs | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs index 9d735f3e80..3cae87eb07 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderReportService.cs @@ -157,21 +157,21 @@ public virtual async Task> GetOrderByTimeReport(str DateTime? endTimeUtc = null) { var report = new List(); - startTimeUtc ??= DateTime.MinValue; - endTimeUtc ??= DateTime.UtcNow; + var startTime = startTimeUtc ?? DateTime.MinValue; + var endDate = endTimeUtc ?? DateTime.UtcNow; - var endTime = endTimeUtc.Value.Date.AddDays(1); + var endTime = endDate.Date.AddDays(1); var builderquery = from p in _orderRepository.Table select p; builderquery = builderquery.Where(o => !o.Deleted); - builderquery = builderquery.Where(o => o.CreatedOnUtc >= startTimeUtc.Value && o.CreatedOnUtc < endTime); + builderquery = builderquery.Where(o => o.CreatedOnUtc >= startTime && o.CreatedOnUtc < endTime); if (!string.IsNullOrEmpty(storeId)) builderquery = builderquery.Where(o => o.StoreId == storeId); - var daydiff = (endTimeUtc.Value - startTimeUtc.Value).TotalDays; + var daydiff = (endDate - startTime).TotalDays; if (daydiff > 31) { var query = await _orderRepository.ToListAsync(builderquery.GroupBy(x => diff --git a/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs b/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs index 62e1d36d87..7d69aa72a6 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerReportService.cs @@ -186,10 +186,10 @@ public virtual async Task> GetCustomerByTimeRepo { var report = new List(); - startTimeUtc ??= DateTime.MinValue; - endTimeUtc ??= DateTime.UtcNow; + var startTime = startTimeUtc ?? DateTime.MinValue; + var endDate = endTimeUtc ?? DateTime.UtcNow; - var endTime = endTimeUtc.Value.Date.AddDays(1); + var endTime = endDate.Date.AddDays(1); var builderquery = from p in _customerRepository.Table select p; @@ -199,12 +199,12 @@ public virtual async Task> GetCustomerByTimeRepo var customerGroupRegister = customergroup.Id; builderquery = builderquery.Where(o => !o.Deleted); - builderquery = builderquery.Where(o => o.CreatedOnUtc >= startTimeUtc.Value && o.CreatedOnUtc < endTime); + builderquery = builderquery.Where(o => o.CreatedOnUtc >= startTime && o.CreatedOnUtc < endTime); builderquery = builderquery.Where(o => o.Groups.Any(y => y == customerGroupRegister)); if (!string.IsNullOrEmpty(storeId)) builderquery = builderquery.Where(o => o.StoreId == storeId); - var daydiff = (endTimeUtc.Value - startTimeUtc.Value).TotalDays; + var daydiff = (endDate - startTime).TotalDays; if (daydiff > 31) { var query = await _customerRepository.ToListAsync(builderquery.GroupBy(x => new { diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs index 6233d9ff32..53f8f9dce1 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderReportServiceTests.cs @@ -72,7 +72,7 @@ public async Task BestSellersReport_OrderedByAmount() public async Task BestSellersReport_ReturnsRequestedPage_WithTotalOverAllGroups() { for (var i = 1; i <= 5; i++) - await InsertOrder("store", Item($"p{i}", i, i * 10)); + await InsertOrder("store", Item($"p{i}", i, i * 10d)); var result = await _service.BestSellersReport(orderBy: 1, pageIndex: 1, pageSize: 2);