diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/AdminReportDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/AdminReportDataScopeTests.cs new file mode 100644 index 000000000..63b09eded --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/AdminReportDataScopeTests.cs @@ -0,0 +1,29 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class AdminReportDataScopeTests +{ + [TestMethod] + public void ScopeDefaults_UnscopedWithBothSelectorsShown() + { + var scope = new AdminReportDataScope(); + + Assert.AreEqual("", scope.StoreId); + Assert.AreEqual("", scope.VendorId); + Assert.IsTrue(scope.ShowStoreSelector); + Assert.IsTrue(scope.ShowVendorSelector); + Assert.AreEqual("Admin", scope.ResourceKeyPrefix); + } + + [TestMethod] + public void CanIncludeProduct_NotOverridden_AlwaysTrue() + { + var scope = new AdminReportDataScope(); + Assert.IsTrue(scope.CanIncludeProduct(new Product { Id = "p1" })); + Assert.IsTrue(scope.CanIncludeProduct(null)); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs new file mode 100644 index 000000000..977bbd6e1 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs @@ -0,0 +1,285 @@ +#nullable enable + +using Grand.Business.Core.Interfaces.Catalog.Directory; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.System.Reports; +using Grand.Business.Core.Utilities.System; +using Grand.Domain; +using Grand.Domain.Directory; +using Grand.Domain.Orders; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Customers; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseFullReportsControllerTests +{ + private class TestFullReportsController( + IOrderReportService orderReportService, + IProductsReportService productsReportService, + ICustomerReportViewModelService customerReportViewModelService, + IPriceFormatter priceFormatter, + ICurrencyService currencyService, + IProductService productService, + IProductAttributeFormatter productAttributeFormatter, + IStockQuantityService stockQuantityService, + ITranslationService translationService, + IStoreService storeService, + ICountryService countryService, + IVendorService vendorService, + IDateTimeService dateTimeService, + IOrderStatusService orderStatusService, + IEnumTranslationService enumTranslationService, + IContextAccessor contextAccessor, + IReportDataScope scope, + IOrderService orderService, + ICustomerReportService customerReportService, + IPermissionService permissionService) + : BaseFullReportsController(orderReportService, productsReportService, customerReportViewModelService, + priceFormatter, currencyService, productService, productAttributeFormatter, stockQuantityService, + translationService, storeService, countryService, vendorService, dateTimeService, + orderStatusService, enumTranslationService, contextAccessor, scope, orderService, + customerReportService, permissionService); + + private TestFullReportsController _controller = null!; + private Mock _orderReportServiceMock = null!; + private Mock _orderServiceMock = null!; + private Mock _permissionServiceMock = null!; + private Mock _scopeMock = null!; + private Mock _customerReportViewModelServiceMock = null!; + private Mock _customerReportServiceMock = null!; + + [TestInitialize] + public void Setup() + { + _orderReportServiceMock = new Mock(); + var productsReportServiceMock = new Mock(); + _customerReportViewModelServiceMock = new Mock(); + var priceFormatterMock = new Mock(); + priceFormatterMock.Setup(p => p.FormatPrice(It.IsAny(), It.IsAny())).Returns("$0.00"); + var currencyServiceMock = new Mock(); + currencyServiceMock.Setup(c => c.GetPrimaryStoreCurrency()).ReturnsAsync(new Currency()); + var productServiceMock = new Mock(); + var productAttributeFormatterMock = new Mock(); + var stockQuantityServiceMock = new Mock(); + var translationServiceMock = new Mock(); + translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + var storeServiceMock = new Mock(); + var countryServiceMock = new Mock(); + var vendorServiceMock = new Mock(); + var dateTimeServiceMock = new Mock(); + var orderStatusServiceMock = new Mock(); + orderStatusServiceMock.Setup(o => o.GetAll()).ReturnsAsync(new List()); + var enumTranslationServiceMock = new Mock(); + var contextAccessorMock = new Mock(); + _scopeMock = new Mock(); + _scopeMock.Setup(s => s.StoreId).Returns(""); + _scopeMock.Setup(s => s.VendorId).Returns(""); + _orderServiceMock = new Mock(); + _customerReportServiceMock = new Mock(); + _permissionServiceMock = new Mock(); + _permissionServiceMock.Setup(p => p.Authorize(StandardPermission.ManageOrders)).ReturnsAsync(true); + + _controller = new TestFullReportsController(_orderReportServiceMock.Object, productsReportServiceMock.Object, + _customerReportViewModelServiceMock.Object, priceFormatterMock.Object, currencyServiceMock.Object, + productServiceMock.Object, productAttributeFormatterMock.Object, stockQuantityServiceMock.Object, + translationServiceMock.Object, storeServiceMock.Object, countryServiceMock.Object, + vendorServiceMock.Object, dateTimeServiceMock.Object, orderStatusServiceMock.Object, + enumTranslationServiceMock.Object, contextAccessorMock.Object, _scopeMock.Object, + _orderServiceMock.Object, _customerReportServiceMock.Object, _permissionServiceMock.Object); + + var httpContext = new DefaultHttpContext(); + var urlHelperFactoryMock = new Mock(); + urlHelperFactoryMock.Setup(f => f.GetUrlHelper(It.IsAny())).Returns(new Mock().Object); + var requestServicesMock = new Mock(); + requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); + httpContext.RequestServices = requestServicesMock.Object; + var routeData = new RouteData(); + routeData.Values["area"] = "Admin"; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext, RouteData = routeData }; + } + + [TestMethod] + public async Task BestsellersBriefReportByQuantityList_ManageOrdersDenied_ReturnsEmptyContent() + { + _permissionServiceMock.Setup(p => p.Authorize(StandardPermission.ManageOrders)).ReturnsAsync(false); + + var result = await _controller.BestsellersBriefReportByQuantityList(new DataSourceRequest { Page = 1, PageSize = 10 }) as ContentResult; + + Assert.IsNotNull(result); + Assert.AreEqual("", result!.Content); + _orderReportServiceMock.Verify(o => o.BestSellersReport(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BestsellersBriefReportByAmountList_ManageOrdersAllowed_DelegatesToBase() + { + _orderReportServiceMock.Setup(o => o.BestSellersReport("", "", null, null, null, null, null, "", 2, 0, 10, true)) + .ReturnsAsync(new PagedList(new List(), 0, 0)); + + var result = await _controller.BestsellersBriefReportByAmountList(new DataSourceRequest { Page = 1, PageSize = 10 }); + + Assert.IsInstanceOfType(result, typeof(JsonResult)); + } + + [TestMethod] + public async Task BestsellersBriefReportByAmountList_ScopeValuesThreadedIntoQuery() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _scopeMock.Setup(s => s.VendorId).Returns("vendor-1"); + _orderReportServiceMock.Setup(o => o.BestSellersReport("store-1", "vendor-1", null, null, null, null, null, + "", 2, 0, 10, true)) + .ReturnsAsync(new PagedList(new List(), 0, 0)); + + await _controller.BestsellersBriefReportByAmountList(new DataSourceRequest { Page = 1, PageSize = 10 }); + + _orderReportServiceMock.Verify(o => o.BestSellersReport("store-1", "vendor-1", null, null, null, null, null, + "", 2, 0, 10, true), Times.Once); + } + + [TestMethod] + public async Task BestsellersBriefReportByAmountList_CanIncludeProductFalse_DropsRow() + { + var line = new BestsellersReportLine { ProductId = "p1", TotalAmount = 1, TotalQuantity = 1 }; + _orderReportServiceMock.Setup(o => o.BestSellersReport("", "", null, null, null, null, null, "", 2, 0, 10, true)) + .ReturnsAsync(new PagedList(new List { line }, 0, 1)); + _scopeMock.Setup(s => s.CanIncludeProduct(It.IsAny())).Returns(false); + + var result = await _controller.BestsellersBriefReportByAmountList(new DataSourceRequest { Page = 1, PageSize = 10 }) as JsonResult; + + var gridModel = (DataSourceResult)result!.Value!; + Assert.AreEqual(0, ((List)gridModel.Data).Count); + } + + [TestMethod] + public async Task ReportOrderPeriodList_ManageOrdersDenied_ReturnsEmptyContent() + { + _permissionServiceMock.Setup(p => p.Authorize(StandardPermission.ManageOrders)).ReturnsAsync(false); + + var result = await _controller.ReportOrderPeriodList(new DataSourceRequest { Page = 1, PageSize = 10 }) as ContentResult; + + Assert.IsNotNull(result); + Assert.AreEqual("", result!.Content); + } + + [TestMethod] + public async Task ReportOrderTimeChart_ManageOrdersAllowed_PassesScopeStoreId() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _orderReportServiceMock.Setup(o => o.GetOrderByTimeReport("store-1", null, null)) + .ReturnsAsync(new List()); + + await _controller.ReportOrderTimeChart(new DataSourceRequest { Page = 1, PageSize = 10 }, null, null); + + _orderReportServiceMock.Verify(o => o.GetOrderByTimeReport("store-1", null, null), Times.Once); + } + + [TestMethod] + public async Task OrderAverageReportList_ManageOrdersAllowed_UsesScopeStoreIdForAllFourStatuses() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _orderReportServiceMock.Setup(o => o.OrderAverageReport("store-1", It.IsAny())) + .ReturnsAsync(new OrderAverageReportLineSummary()); + + var result = await _controller.OrderAverageReportList(new DataSourceRequest { Page = 1, PageSize = 10 }); + + Assert.IsInstanceOfType(result, typeof(JsonResult)); + _orderReportServiceMock.Verify(o => o.OrderAverageReport("store-1", It.IsAny()), Times.Exactly(4)); + } + + [TestMethod] + public async Task ReportLatestOrder_ManageOrdersAllowed_PassesScopeStoreIdToSearchOrders() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _orderServiceMock.Setup(o => o.SearchOrders("store-1", "", "", "", "", "", "", "", "", + null, null, null, null, null, null, null, "", null, null, 0, 10, "")) + .ReturnsAsync(new PagedList(new List(), 0, 0)); + + await _controller.ReportLatestOrder(new DataSourceRequest { Page = 1, PageSize = 10 }, null, null); + + _orderServiceMock.Verify(o => o.SearchOrders("store-1", "", "", "", "", "", "", "", "", + null, null, null, null, null, null, null, "", null, null, 0, 10, ""), Times.Once); + } + + [TestMethod] + public async Task OrderIncompleteReportList_ManageOrdersAllowed_ReturnsThreeRows() + { + _orderReportServiceMock.Setup(o => o.GetOrderAverageReportLine(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), true, It.IsAny())) + .ReturnsAsync(new OrderAverageReportLine()); + + var result = await _controller.OrderIncompleteReportList(new DataSourceRequest { Page = 1, PageSize = 10 }) as JsonResult; + + var gridModel = (DataSourceResult)result!.Value!; + Assert.AreEqual(3, ((List)gridModel.Data).Count); + } + + [TestMethod] + public async Task ReportBestCustomersByNumberOfOrdersList_PassesScopeVendorIdToService() + { + _scopeMock.Setup(s => s.VendorId).Returns("vendor-1"); + _customerReportViewModelServiceMock.Setup(s => + s.PrepareBestCustomerReportLineModel(It.IsAny(), 2, 1, 10, "vendor-1")) + .ReturnsAsync((new List(), 0)); + + await _controller.ReportBestCustomersByNumberOfOrdersList(new DataSourceRequest { Page = 1, PageSize = 10 }, + new BestCustomersReportModel()); + + _customerReportViewModelServiceMock.Verify(s => + s.PrepareBestCustomerReportLineModel(It.IsAny(), 2, 1, 10, "vendor-1"), Times.Once); + } + + /// Verifies both scope values are passed to the service call, not that vendorId changes + /// behavior — GetReportRegisteredCustomersModel's vendorId parameter is a documented no-op today + /// (see its XML doc / ICustomerReportViewModelService), kept for signature symmetry and + /// forward-compatibility. scope.VendorId is always "" for this Full-tier-only action anyway. + [TestMethod] + public async Task ReportRegisteredCustomersList_ScopeStoreIdAndVendorIdAcceptedByServiceCall() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _scopeMock.Setup(s => s.VendorId).Returns("vendor-1"); + _customerReportViewModelServiceMock.Setup(s => s.GetReportRegisteredCustomersModel("store-1", "vendor-1")) + .ReturnsAsync(new List()); + + await _controller.ReportRegisteredCustomersList(new DataSourceRequest { Page = 1, PageSize = 10 }); + + _customerReportViewModelServiceMock.Verify(s => s.GetReportRegisteredCustomersModel("store-1", "vendor-1"), Times.Once); + } + + [TestMethod] + public async Task ReportCustomerTimeChart_PassesScopeStoreIdToService() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _customerReportServiceMock.Setup(s => s.GetCustomerByTimeReport("store-1", null, null)) + .ReturnsAsync(new List()); + + await _controller.ReportCustomerTimeChart(new DataSourceRequest { Page = 1, PageSize = 10 }, null, null); + + _customerReportServiceMock.Verify(s => s.GetCustomerByTimeReport("store-1", null, null), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs new file mode 100644 index 000000000..dee78901a --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs @@ -0,0 +1,312 @@ +#nullable enable + +using Grand.Business.Core.Interfaces.Catalog.Directory; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.System.Reports; +using Grand.Business.Core.Utilities.System; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Domain.Directory; +using Grand.Domain.Stores; +using Grand.Domain.Vendors; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Customers; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseReportsControllerTests +{ + private class TestReportsController( + IOrderReportService orderReportService, + IProductsReportService productsReportService, + ICustomerReportViewModelService customerReportViewModelService, + IPriceFormatter priceFormatter, + ICurrencyService currencyService, + IProductService productService, + IProductAttributeFormatter productAttributeFormatter, + IStockQuantityService stockQuantityService, + ITranslationService translationService, + IStoreService storeService, + ICountryService countryService, + IVendorService vendorService, + IDateTimeService dateTimeService, + IOrderStatusService orderStatusService, + IEnumTranslationService enumTranslationService, + IContextAccessor contextAccessor, + IReportDataScope scope) + : BaseReportsController(orderReportService, productsReportService, customerReportViewModelService, + priceFormatter, currencyService, productService, productAttributeFormatter, stockQuantityService, + translationService, storeService, countryService, vendorService, dateTimeService, + orderStatusService, enumTranslationService, contextAccessor, scope); + + private TestReportsController _controller = null!; + private Mock _orderReportServiceMock = null!; + private Mock _productsReportServiceMock = null!; + private Mock _customerReportViewModelServiceMock = null!; + private Mock _productServiceMock = null!; + private Mock _storeServiceMock = null!; + private Mock _vendorServiceMock = null!; + private Mock _scopeMock = null!; + + [TestInitialize] + public void Setup() + { + _orderReportServiceMock = new Mock(); + _productsReportServiceMock = new Mock(); + _customerReportViewModelServiceMock = new Mock(); + _customerReportViewModelServiceMock.Setup(s => s.PrepareCustomerReportsModel()).ReturnsAsync(new CustomerReportsModel()); + var priceFormatterMock = new Mock(); + priceFormatterMock.Setup(p => p.FormatPrice(It.IsAny(), It.IsAny())).Returns("$0.00"); + var currencyServiceMock = new Mock(); + currencyServiceMock.Setup(c => c.GetPrimaryStoreCurrency()).ReturnsAsync(new Currency()); + _productServiceMock = new Mock(); + var productAttributeFormatterMock = new Mock(); + var stockQuantityServiceMock = new Mock(); + var translationServiceMock = new Mock(); + translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + _storeServiceMock = new Mock(); + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List()); + var countryServiceMock = new Mock(); + countryServiceMock.Setup(c => c.GetAllCountriesForBilling("", "", true)).ReturnsAsync(new List()); + _vendorServiceMock = new Mock(); + _vendorServiceMock.Setup(v => v.GetAllVendors("", 0, int.MaxValue, true)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + var dateTimeServiceMock = new Mock(); + var orderStatusServiceMock = new Mock(); + orderStatusServiceMock.Setup(o => o.GetAll()).ReturnsAsync(new List()); + var enumTranslationServiceMock = new Mock(); + enumTranslationServiceMock.Setup(e => e.ToSelectList(Grand.Domain.Payments.PaymentStatus.Pending, false, null)) + .Returns(new Microsoft.AspNetCore.Mvc.Rendering.SelectList(new List())); + var contextAccessorMock = new Mock(); + _scopeMock = new Mock(); + _scopeMock.Setup(s => s.StoreId).Returns(""); + _scopeMock.Setup(s => s.VendorId).Returns(""); + _scopeMock.Setup(s => s.ShowStoreSelector).Returns(true); + _scopeMock.Setup(s => s.ShowVendorSelector).Returns(true); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); + _scopeMock.Setup(s => s.CanIncludeProduct(It.IsAny())).Returns(true); + + _controller = new TestReportsController(_orderReportServiceMock.Object, _productsReportServiceMock.Object, + _customerReportViewModelServiceMock.Object, priceFormatterMock.Object, currencyServiceMock.Object, + _productServiceMock.Object, productAttributeFormatterMock.Object, stockQuantityServiceMock.Object, + translationServiceMock.Object, _storeServiceMock.Object, countryServiceMock.Object, + _vendorServiceMock.Object, dateTimeServiceMock.Object, orderStatusServiceMock.Object, + enumTranslationServiceMock.Object, contextAccessorMock.Object, _scopeMock.Object); + } + + [TestMethod] + public async Task BestsellersBriefReportByQuantityList_NoPermissionCheck_AlwaysReturnsJson() + { + _orderReportServiceMock.Setup(o => o.BestSellersReport("", "", null, null, null, null, null, "", 1, 0, + 10, true)) + .ReturnsAsync(new PagedList(new List(), 0, 10)); + + var result = await _controller.BestsellersBriefReportByQuantityList(new DataSourceRequest { Page = 1, PageSize = 10 }); + + Assert.IsInstanceOfType(result, typeof(JsonResult)); + } + + [TestMethod] + public async Task BestsellersBriefReportByQuantityList_ScopeValuesThreadedIntoQuery() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _scopeMock.Setup(s => s.VendorId).Returns("vendor-1"); + _orderReportServiceMock.Setup(o => o.BestSellersReport("store-1", "vendor-1", null, null, null, null, null, + "", 1, 0, 10, true)) + .ReturnsAsync(new PagedList(new List(), 0, 0)); + + await _controller.BestsellersBriefReportByQuantityList(new DataSourceRequest { Page = 1, PageSize = 10 }); + + _orderReportServiceMock.Verify(o => o.BestSellersReport("store-1", "vendor-1", null, null, null, null, null, + "", 1, 0, 10, true), Times.Once); + } + + [TestMethod] + public async Task BestsellersBriefReportByAmountList_ScopeValuesThreadedIntoQuery() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _scopeMock.Setup(s => s.VendorId).Returns("vendor-1"); + _orderReportServiceMock.Setup(o => o.BestSellersReport("store-1", "vendor-1", null, null, null, null, null, + "", 2, 0, 10, true)) + .ReturnsAsync(new PagedList(new List(), 0, 0)); + + await _controller.BestsellersBriefReportByAmountList(new DataSourceRequest { Page = 1, PageSize = 10 }); + + _orderReportServiceMock.Verify(o => o.BestSellersReport("store-1", "vendor-1", null, null, null, null, null, + "", 2, 0, 10, true), Times.Once); + } + + [TestMethod] + public async Task BestsellersBriefReportByQuantityList_CanIncludeProductFalse_DropsRow() + { + var line = new BestsellersReportLine { ProductId = "p1", TotalAmount = 1, TotalQuantity = 1 }; + _orderReportServiceMock.Setup(o => o.BestSellersReport("", "", null, null, null, null, null, "", 1, 0, 10, true)) + .ReturnsAsync(new PagedList(new List { line }, 0, 1)); + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(new Product { Id = "p1" }); + _scopeMock.Setup(s => s.CanIncludeProduct(It.IsAny())).Returns(false); + + var result = await _controller.BestsellersBriefReportByQuantityList(new DataSourceRequest { Page = 1, PageSize = 10 }) as JsonResult; + + var gridModel = (DataSourceResult)result!.Value!; + Assert.AreEqual(0, ((List)gridModel.Data).Count); + } + + [TestMethod] + public async Task BestsellersReport_ShowStoreSelectorTrue_PopulatesAvailableStores() + { + var result = await _controller.BestsellersReport() as ViewResult; + + Assert.IsNotNull(result); + var model = (BestsellersReportModel)result!.Model!; + Assert.IsTrue(model.AvailableStores.Count > 0); + Assert.IsTrue(model.AvailableVendors.Count > 0); + } + + [TestMethod] + public async Task BestsellersReport_ShowStoreSelectorFalse_SkipsAvailableStoresAndVendors() + { + _scopeMock.Setup(s => s.ShowStoreSelector).Returns(false); + _scopeMock.Setup(s => s.ShowVendorSelector).Returns(false); + + var result = await _controller.BestsellersReport() as ViewResult; + + var model = (BestsellersReportModel)result!.Model!; + Assert.AreEqual(0, model.AvailableStores.Count); + Assert.AreEqual(0, model.AvailableVendors.Count); + _storeServiceMock.Verify(s => s.GetAllStores(), Times.Never); + _vendorServiceMock.Verify(v => v.GetAllVendors(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BestsellersReportList_ScopeStoreIdNonEmpty_OverwritesPostedStoreId() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _orderReportServiceMock.Setup(o => o.BestSellersReport("store-1", "", null, null, null, null, null, "", 2, + 0, 10, true)) + .ReturnsAsync(new PagedList(new List(), 0, 0)); + + var model = new BestsellersReportModel { StoreId = "posted-store-should-be-overwritten" }; + await _controller.BestsellersReportList(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + _orderReportServiceMock.Verify(o => o.BestSellersReport("store-1", "", null, null, null, null, null, "", 2, + 0, 10, true), Times.Once); + } + + [TestMethod] + public async Task BestsellersReportList_CanIncludeProductFalse_DropsRow() + { + var line = new BestsellersReportLine { ProductId = "p1", TotalAmount = 1, TotalQuantity = 1 }; + _orderReportServiceMock.Setup(o => o.BestSellersReport("", "", null, null, null, null, null, "", 2, 0, 10, true)) + .ReturnsAsync(new PagedList(new List { line }, 0, 1)); + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(new Product { Id = "p1" }); + _scopeMock.Setup(s => s.CanIncludeProduct(It.IsAny())).Returns(false); + + var result = await _controller.BestsellersReportList(new DataSourceRequest { Page = 1, PageSize = 10 }, + new BestsellersReportModel()) as JsonResult; + + var gridModel = (DataSourceResult)result!.Value!; + Assert.AreEqual(0, ((List)gridModel.Data).Count); + } + + [TestMethod] + public void NeverSoldReport_ReturnsViewWithModel() + { + var result = _controller.NeverSoldReport() as ViewResult; + + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result!.Model, typeof(NeverSoldReportModel)); + } + + [TestMethod] + public async Task NeverSoldReportList_ScopeValuesThreadedIntoQuery() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _scopeMock.Setup(s => s.VendorId).Returns("vendor-1"); + _orderReportServiceMock.Setup(o => o.ProductsNeverSold("store-1", "vendor-1", null, null, 0, 10, true)) + .ReturnsAsync(new PagedList(new List(), 0, 0)); + + await _controller.NeverSoldReportList(new DataSourceRequest { Page = 1, PageSize = 10 }, new NeverSoldReportModel()); + + _orderReportServiceMock.Verify(o => o.ProductsNeverSold("store-1", "vendor-1", null, null, 0, 10, true), Times.Once); + } + + [TestMethod] + public async Task CountryReport_NoPermissionCheck_ReturnsViewWithModel() + { + var result = await _controller.CountryReport() as ViewResult; + + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result!.Model, typeof(CountryReportModel)); + } + + [TestMethod] + public async Task CountryReportList_ScopeValuesThreadedIntoQuery() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _scopeMock.Setup(s => s.VendorId).Returns("vendor-1"); + _orderReportServiceMock.Setup(o => o.GetCountryReport("store-1", "vendor-1", null, null, null, null, null)) + .ReturnsAsync(new List()); + + await _controller.CountryReportList(new DataSourceRequest { Page = 1, PageSize = 10 }, new CountryReportModel()); + + _orderReportServiceMock.Verify(o => o.GetCountryReport("store-1", "vendor-1", null, null, null, null, null), Times.Once); + } + + [TestMethod] + public void LowStockReport_ReturnsView() + { + var result = _controller.LowStockReport(); + Assert.IsInstanceOfType(result, typeof(ViewResult)); + } + + [TestMethod] + public async Task LowStockReportList_ScopeValuesThreadedIntoQuery() + { + _scopeMock.Setup(s => s.StoreId).Returns("store-1"); + _scopeMock.Setup(s => s.VendorId).Returns("vendor-1"); + _productsReportServiceMock.Setup(p => p.LowStockProducts("vendor-1", "store-1")) + .ReturnsAsync((new List(), new List())); + + await _controller.LowStockReportList(new DataSourceRequest { Page = 1, PageSize = 10 }); + + _productsReportServiceMock.Verify(p => p.LowStockProducts("vendor-1", "store-1"), Times.Once); + } + + [TestMethod] + public async Task Customer_NoPermissionCheck_ReturnsViewWithModel() + { + var result = await _controller.Customer() as ViewResult; + + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result!.Model, typeof(CustomerReportsModel)); + } + + [TestMethod] + public async Task ReportBestCustomersByOrderTotalList_PassesScopeVendorIdToService() + { + _scopeMock.Setup(s => s.VendorId).Returns("vendor-1"); + _customerReportViewModelServiceMock.Setup(s => s.PrepareBestCustomerReportLineModel(It.IsAny(), 1, 1, 10, "vendor-1")) + .ReturnsAsync((new List(), 0)); + + await _controller.ReportBestCustomersByOrderTotalList(new DataSourceRequest { Page = 1, PageSize = 10 }, + new BestCustomersReportModel()); + + _customerReportViewModelServiceMock.Verify(s => s.PrepareBestCustomerReportLineModel(It.IsAny(), 1, 1, 10, "vendor-1"), + Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/CustomerReportViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/CustomerReportViewModelServiceTests.cs new file mode 100644 index 000000000..56a763548 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/CustomerReportViewModelServiceTests.cs @@ -0,0 +1,80 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.System.Reports; +using Grand.Business.Core.Utilities.System; +using Grand.Domain; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Web.AdminShared.Models.Customers; +using Grand.Web.AdminShared.Services; +using Grand.Web.Common.Localization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class CustomerReportViewModelServiceTests +{ + private CustomerReportViewModelService _service = null!; + private Mock _customerReportServiceMock = null!; + + [TestInitialize] + public void Setup() + { + var customerServiceMock = new Mock(); + var translationServiceMock = new Mock(); + translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + _customerReportServiceMock = new Mock(); + var dateTimeServiceMock = new Mock(); + var priceFormatterMock = new Mock(); + var orderStatusServiceMock = new Mock(); + var currencyServiceMock = new Mock(); + currencyServiceMock.Setup(c => c.GetPrimaryStoreCurrency()).ReturnsAsync(new Currency()); + var enumTranslationServiceMock = new Mock(); + + _service = new CustomerReportViewModelService(customerServiceMock.Object, translationServiceMock.Object, + _customerReportServiceMock.Object, dateTimeServiceMock.Object, priceFormatterMock.Object, + orderStatusServiceMock.Object, currencyServiceMock.Object, enumTranslationServiceMock.Object); + } + + [TestMethod] + public async Task PrepareBestCustomerReportLineModel_DefaultVendorId_PassesEmptyStringToService() + { + _customerReportServiceMock.Setup(s => s.GetBestCustomersReport(It.IsAny(), "", null, null, null, null, null, 2, 0, 10)) + .ReturnsAsync(new PagedList(new List(), 0, 0)); + + await _service.PrepareBestCustomerReportLineModel(new BestCustomersReportModel(), 1, 1, 10); + + _customerReportServiceMock.Verify(s => s.GetBestCustomersReport(It.IsAny(), "", null, null, null, null, null, 2, 0, 10), Times.Once); + } + + [TestMethod] + public async Task PrepareBestCustomerReportLineModel_ExplicitVendorId_PassesItToService() + { + _customerReportServiceMock.Setup(s => s.GetBestCustomersReport(It.IsAny(), "vendor-1", null, null, null, null, null, 2, 0, 10)) + .ReturnsAsync(new PagedList(new List(), 0, 0)); + + await _service.PrepareBestCustomerReportLineModel(new BestCustomersReportModel(), 1, 1, 10, "vendor-1"); + + _customerReportServiceMock.Verify(s => s.GetBestCustomersReport(It.IsAny(), "vendor-1", null, null, null, null, null, 2, 0, 10), Times.Once); + } + + [TestMethod] + public async Task GetReportRegisteredCustomersModel_DefaultVendorId_DoesNotThrow() + { + // GetRegisteredCustomersReport itself has no vendorId parameter (confirmed on + // ICustomerReportService — registered-customer counts are never vendor-scoped in the business + // layer); the new vendorId parameter on GetReportRegisteredCustomersModel exists purely for + // Global Constraint 8's "both methods" symmetry (Task 9's header note) and is accepted but + // not yet forwarded anywhere further. This test documents that "not forwarded" is intentional, + // not a missed wire-up. + _customerReportServiceMock.Setup(s => s.GetRegisteredCustomersReport("store-1", It.IsAny())).ReturnsAsync(5); + + var result = await _service.GetReportRegisteredCustomersModel("store-1", "vendor-1"); + + Assert.AreEqual(4, result.Count); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ReportsControllerRoutingTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ReportsControllerRoutingTests.cs new file mode 100644 index 000000000..c51643b7c --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ReportsControllerRoutingTests.cs @@ -0,0 +1,53 @@ +using Grand.Web.Admin.Controllers; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Grand.Domain.Permissions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class ReportsControllerRoutingTests +{ + [TestMethod] + public void AdminReportsController_InheritsBaseFullReportsController() => + Assert.IsTrue(typeof(BaseFullReportsController).IsAssignableFrom(typeof(ReportsController))); + + [TestMethod] + public void AdminReportsController_HasAreaAttributeWithAdminArea() + { + var areaAttr = (AreaAttribute)Attribute.GetCustomAttribute(typeof(ReportsController), typeof(AreaAttribute), false); + Assert.IsNotNull(areaAttr, "Missing [Area]."); + Assert.AreEqual(Constants.AreaAdmin, areaAttr.RouteValue); + } + + [TestMethod] + public void AdminReportsController_HasAuthorizeAdminAttribute() => + Assert.IsTrue(typeof(ReportsController).IsDefined(typeof(AuthorizeAdminAttribute), false), + "Missing [AuthorizeAdmin]."); + + [TestMethod] + public void AdminReportsController_HasPermissionAuthorizeReportsAttribute() + { + var attr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(typeof(ReportsController), + typeof(PermissionAuthorizeAttribute), false); + Assert.IsNotNull(attr, "Missing [PermissionAuthorize]."); + Assert.AreEqual(PermissionSystemName.Reports, attr!.Permission); + } + + [TestMethod] + public void AdminReportsController_DeclaresPopularSearchTermsReport_NotOnEitherSharedBase() + { + var declaredDirectly = typeof(ReportsController).GetMethod("PopularSearchTermsReport", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly); + Assert.IsNotNull(declaredDirectly, "PopularSearchTermsReport must be declared directly on Admin's ReportsController."); + Assert.IsNull(typeof(BaseReportsController).GetMethod("PopularSearchTermsReport"), + "PopularSearchTermsReport must not exist on BaseReportsController."); + Assert.IsNull(typeof(BaseFullReportsController).GetMethod("PopularSearchTermsReport", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly), + "PopularSearchTermsReport must not be (re)declared on BaseFullReportsController."); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedReportDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedReportDataScopeTests.cs new file mode 100644 index 000000000..ad009edbb --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedReportDataScopeTests.cs @@ -0,0 +1,81 @@ +#nullable enable + +using Grand.Domain.Customers; +using Grand.Domain.Vendors; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class RoutedReportDataScopeTests +{ + private AdminReportDataScope _adminScope = null!; + private StoreReportDataScope _storeScope = null!; + private VendorReportDataScope _vendorScope = null!; + + [TestInitialize] + public void Setup() + { + var storeWorkContext = new Mock(); + storeWorkContext.Setup(w => w.CurrentCustomer).Returns(new Customer { StaffStoreId = "store-1" }); + var storeContextAccessor = new Mock(); + storeContextAccessor.Setup(c => c.WorkContext).Returns(storeWorkContext.Object); + + var vendorWorkContext = new Mock(); + vendorWorkContext.Setup(w => w.CurrentVendor).Returns(new Vendor { Id = "vendor-A" }); + var vendorContextAccessor = new Mock(); + vendorContextAccessor.Setup(c => c.WorkContext).Returns(vendorWorkContext.Object); + + _adminScope = new AdminReportDataScope(); + _storeScope = new StoreReportDataScope(storeContextAccessor.Object); + _vendorScope = new VendorReportDataScope(vendorContextAccessor.Object); + } + + private RoutedReportDataScope ResolverForArea(string? area) + { + var httpContext = new DefaultHttpContext(); + if (area is not null) httpContext.Request.RouteValues["area"] = area; + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext); + return new RoutedReportDataScope(httpContextAccessor.Object, _adminScope, _storeScope, _vendorScope); + } + + [TestMethod] + public void AdminArea_ResolvesToAdminScope() + { + var resolver = ResolverForArea("Admin"); + Assert.AreEqual("", resolver.StoreId); + Assert.IsTrue(resolver.ShowStoreSelector); + Assert.AreEqual("Admin", resolver.ResourceKeyPrefix); + } + + [TestMethod] + public void StoreArea_ResolvesToStoreScope() + { + var resolver = ResolverForArea("Store"); + Assert.AreEqual("store-1", resolver.StoreId); + Assert.IsFalse(resolver.ShowStoreSelector); + } + + [TestMethod] + public void VendorArea_ResolvesToVendorScope() + { + var resolver = ResolverForArea("Vendor"); + Assert.AreEqual("vendor-A", resolver.VendorId); + Assert.AreEqual("Vendor", resolver.ResourceKeyPrefix); + } + + [TestMethod] + public void UnrecognizedOrMissingArea_ThrowsFailClosed() + { + var typo = ResolverForArea("Vendorr"); + Assert.Throws(() => _ = typo.ResourceKeyPrefix); + + var missing = ResolverForArea(null); + Assert.Throws(() => _ = missing.ResourceKeyPrefix); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreReportDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreReportDataScopeTests.cs new file mode 100644 index 000000000..05902bec4 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreReportDataScopeTests.cs @@ -0,0 +1,59 @@ +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class StoreReportDataScopeTests +{ + private static StoreReportDataScope Build(string staffStoreId) + { + var customer = new Customer { StaffStoreId = staffStoreId }; + var workContextMock = new Mock(); + workContextMock.Setup(w => w.CurrentCustomer).Returns(customer); + var contextAccessorMock = new Mock(); + contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); + return new StoreReportDataScope(contextAccessorMock.Object); + } + + [TestMethod] + public void StoreId_ReturnsCurrentStaffStoreId() + { + var scope = Build("store-1"); + Assert.AreEqual("store-1", scope.StoreId); + } + + [TestMethod] + public void VendorId_AlwaysEmpty() + { + var scope = Build("store-1"); + Assert.AreEqual("", scope.VendorId); + } + + [TestMethod] + public void Selectors_BothHidden() + { + var scope = Build("store-1"); + Assert.IsFalse(scope.ShowStoreSelector); + Assert.IsFalse(scope.ShowVendorSelector); + } + + [TestMethod] + public void ResourceKeyPrefix_IsAdmin() + { + // Store reuses Admin's resource keys today — same precedent as every prior phase. + var scope = Build("store-1"); + Assert.AreEqual("Admin", scope.ResourceKeyPrefix); + } + + [TestMethod] + public void CanIncludeProduct_NotOverridden_AlwaysTrue() + { + var scope = Build("store-1"); + Assert.IsTrue(scope.CanIncludeProduct(new Product { Id = "p1" })); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/VendorReportDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/VendorReportDataScopeTests.cs new file mode 100644 index 000000000..9e40a1d07 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/VendorReportDataScopeTests.cs @@ -0,0 +1,64 @@ +using Grand.Domain.Catalog; +using Grand.Domain.Vendors; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class VendorReportDataScopeTests +{ + private static VendorReportDataScope Build(string currentVendorId) + { + var workContextMock = new Mock(); + workContextMock.Setup(w => w.CurrentVendor).Returns(new Vendor { Id = currentVendorId }); + var contextAccessorMock = new Mock(); + contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); + return new VendorReportDataScope(contextAccessorMock.Object); + } + + [TestMethod] + public void StoreId_AlwaysEmpty() + { + var scope = Build("vendor-A"); + Assert.AreEqual("", scope.StoreId); + } + + [TestMethod] + public void VendorId_ReturnsCurrentVendorId() + { + var scope = Build("vendor-A"); + Assert.AreEqual("vendor-A", scope.VendorId); + } + + [TestMethod] + public void Selectors_BothHidden() + { + var scope = Build("vendor-A"); + Assert.IsFalse(scope.ShowStoreSelector); + Assert.IsFalse(scope.ShowVendorSelector); + } + + [TestMethod] + public void ResourceKeyPrefix_IsVendor() + { + var scope = Build("vendor-A"); + Assert.AreEqual("Vendor", scope.ResourceKeyPrefix); + } + + [TestMethod] + public void CanIncludeProduct_MatchingVendorId_True() + { + var scope = Build("vendor-A"); + Assert.IsTrue(scope.CanIncludeProduct(new Product { Id = "p1", VendorId = "vendor-A" })); + } + + [TestMethod] + public void CanIncludeProduct_MismatchedVendorId_False() + { + var scope = Build("vendor-A"); + Assert.IsFalse(scope.CanIncludeProduct(new Product { Id = "p1", VendorId = "vendor-B" })); + } +} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/StoreReportsControllerRoutingTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/StoreReportsControllerRoutingTests.cs new file mode 100644 index 000000000..ce01d17e2 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/StoreReportsControllerRoutingTests.cs @@ -0,0 +1,45 @@ +using Grand.Domain.Permissions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using StoreReportsController = Grand.Web.Store.Controllers.ReportsController; + +namespace Grand.Web.Store.Tests.Controllers; + +[TestClass] +public class StoreReportsControllerRoutingTests +{ + [TestMethod] + public void StoreReportsController_InheritsBaseFullReportsController() => + Assert.IsTrue(typeof(BaseFullReportsController).IsAssignableFrom(typeof(StoreReportsController))); + + [TestMethod] + public void StoreReportsController_HasAreaAttributeWithStoreArea() + { + var areaAttr = (AreaAttribute)Attribute.GetCustomAttribute(typeof(StoreReportsController), typeof(AreaAttribute), false); + Assert.IsNotNull(areaAttr, "Missing [Area]."); + Assert.AreEqual("Store", areaAttr.RouteValue); + } + + [TestMethod] + public void StoreReportsController_HasAuthorizeStoreAttribute() => + Assert.IsTrue(typeof(StoreReportsController).IsDefined(typeof(AuthorizeStoreAttribute), false), + "Missing [AuthorizeStore]."); + + [TestMethod] + public void StoreReportsController_HasPermissionAuthorizeReportsAttribute() + { + var attr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(typeof(StoreReportsController), + typeof(PermissionAuthorizeAttribute), false); + Assert.IsNotNull(attr, "Missing [PermissionAuthorize]."); + Assert.AreEqual(PermissionSystemName.Reports, attr!.Permission); + } + + [TestMethod] + public void StoreReportsController_HasNoPopularSearchTermsReport() => + Assert.IsNull(typeof(StoreReportsController).GetMethod("PopularSearchTermsReport"), + "PopularSearchTermsReport is Admin-only (Task 10) and must not exist on Store's controller."); +} diff --git a/src/Tests/Grand.Web.Vendor.Tests/Controllers/VendorReportsControllerRoutingTests.cs b/src/Tests/Grand.Web.Vendor.Tests/Controllers/VendorReportsControllerRoutingTests.cs new file mode 100644 index 000000000..4f61883a8 --- /dev/null +++ b/src/Tests/Grand.Web.Vendor.Tests/Controllers/VendorReportsControllerRoutingTests.cs @@ -0,0 +1,83 @@ +using Grand.Domain.Permissions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Grand.Web.Vendor.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using VendorReportsController = Grand.Web.Vendor.Controllers.ReportsController; + +namespace Grand.Web.Vendor.Tests.Controllers; + +[TestClass] +public class VendorReportsControllerRoutingTests +{ + /// The core guard for this phase's one novel risk (spec §11, "the two-tier split + /// leaking Admin/Store-only actions onto Vendor"): confirm Vendor's concrete type inherits + /// BaseReportsController but NOT BaseFullReportsController, and therefore exposes none of the 8 + /// Admin/Store-only actions as public instance methods at all — not merely "hidden from Vendor's + /// menu", genuinely absent from the type, so no route/Url.Action/reflection-based route dump can + /// ever reach them on the Vendor host (Global Constraint 2/10, spec §4/§11). + [TestMethod] + public void VendorReportsController_InheritsBaseReportsController_NotBaseFullReportsController() + { + Assert.IsTrue(typeof(BaseReportsController).IsAssignableFrom(typeof(VendorReportsController))); + Assert.IsFalse(typeof(BaseFullReportsController).IsAssignableFrom(typeof(VendorReportsController)), + "Vendor must not inherit BaseFullReportsController — that would silently add the 8 " + + "Admin/Store-only report routes to the Vendor host."); + } + + /// These 8 actions are declared on BaseFullReportsController. They are absent from Vendor + /// specifically because Vendor's concrete type inherits BaseReportsController directly, not + /// BaseFullReportsController (asserted separately above). Do not lump PopularSearchTermsReport in + /// here: it is absent from Vendor for an unrelated reason (see + /// VendorReportsController_HasNoPopularSearchTermsReport below). + [TestMethod] + public void VendorReportsController_HasNoBaseFullReportsControllerActions() + { + string[] baseFullReportsControllerOnlyActions = [ + "ReportOrderPeriodList", "ReportOrderTimeChart", "OrderAverageReportList", "ReportLatestOrder", + "OrderIncompleteReportList", "ReportBestCustomersByNumberOfOrdersList", + "ReportRegisteredCustomersList", "ReportCustomerTimeChart" + ]; + foreach (var actionName in baseFullReportsControllerOnlyActions) + Assert.IsNull(typeof(VendorReportsController).GetMethod(actionName), + $"{actionName} is declared on BaseFullReportsController and must not exist on Vendor's " + + "ReportsController (or any of its base types), because Vendor does not inherit " + + "BaseFullReportsController."); + } + + /// Unlike the 8 actions above, PopularSearchTermsReport is never declared on either shared + /// base (see AdminReportsController_DeclaresPopularSearchTermsReport_NotOnEitherSharedBase in + /// ReportsControllerRoutingTests.cs) — it is written directly on Admin's own concrete controller + /// only, and is Admin-only (also absent from Store's controller, not Admin/Store-shared). It is + /// absent from Vendor for this distinct reason, not because of the BaseFullReportsController + /// split. + [TestMethod] + public void VendorReportsController_HasNoPopularSearchTermsReport() => + Assert.IsNull(typeof(VendorReportsController).GetMethod("PopularSearchTermsReport"), + "PopularSearchTermsReport is Admin-only (never declared on any shared base) and must not " + + "exist on Vendor's ReportsController."); + + [TestMethod] + public void VendorReportsController_HasAreaAttributeWithVendorArea() + { + var areaAttr = (AreaAttribute)Attribute.GetCustomAttribute(typeof(VendorReportsController), typeof(AreaAttribute), false); + Assert.IsNotNull(areaAttr, "Missing [Area]."); + Assert.AreEqual("Vendor", areaAttr.RouteValue); + } + + [TestMethod] + public void VendorReportsController_HasAuthorizeVendorAttribute() => + Assert.IsTrue(typeof(VendorReportsController).IsDefined(typeof(AuthorizeVendorAttribute), false), + "Missing [AuthorizeVendor]."); + + [TestMethod] + public void VendorReportsController_HasPermissionAuthorizeReportsAttribute() + { + var attr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute( + typeof(VendorReportsController), typeof(PermissionAuthorizeAttribute), false); + Assert.IsNotNull(attr, "Missing [PermissionAuthorize]."); + Assert.AreEqual(PermissionSystemName.Reports, attr!.Permission); + } +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/CountryReport.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/CountryReport.cshtml deleted file mode 100644 index b45930084..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/CountryReport.cshtml +++ /dev/null @@ -1,153 +0,0 @@ -@model CountryReportModel -@{ - //page title - ViewBag.Title = Loc["Admin.Reports.Country"]; -} - - -
-
- -
-
- - - - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Customer.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Customer.cshtml deleted file mode 100644 index 65d565978..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Customer.cshtml +++ /dev/null @@ -1,53 +0,0 @@ -@model CustomerReportsModel - -@{ - //page title - ViewBag.Title = Loc["Admin.Reports.Customers"]; -} - -
-
-
-
-
- - @Loc["Admin.Reports.Customers"] -
-
-
-
-
-
- - - - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- -
-
-
-
-
-
-
-
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/LowStockReport.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/LowStockReport.cshtml deleted file mode 100644 index 82e6ec77a..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/LowStockReport.cshtml +++ /dev/null @@ -1,88 +0,0 @@ -@inject AdminAreaSettings adminAreaSettings -@{ - //page title - ViewBag.Title = Loc["Admin.Reports.LowStockReport"]; -} - -
-
-
-
-
- - @Loc["Admin.Reports.LowStockReport"] -
-
-
-
-
-
-
-
-
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/Customer.TabRegisteredCustomers.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/CustomerReportRegisteredComponent.cshtml similarity index 66% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/Customer.TabRegisteredCustomers.cshtml rename to src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/CustomerReportRegisteredComponent.cshtml index 5ddd9c3c9..99984ead8 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/Customer.TabRegisteredCustomers.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/CustomerReportRegisteredComponent.cshtml @@ -1,4 +1,3 @@ -@model CustomerReportsModel @{ @await Component.InvokeAsync("CustomerReportRegistered") -} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/ReportBestCustomersByNumberOfOrders.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/ReportBestCustomersByNumberOfOrders.cshtml deleted file mode 100644 index 03e65dbc8..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/ReportBestCustomersByNumberOfOrders.cshtml +++ /dev/null @@ -1,135 +0,0 @@ -@model BestCustomersReportModel -@inject AdminAreaSettings adminAreaSettings - - - - - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.Bestsellers.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.Bestsellers.cshtml new file mode 100644 index 000000000..b841151ed --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.Bestsellers.cshtml @@ -0,0 +1,2 @@ +@model BestsellersReportModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.Country.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.Country.cshtml new file mode 100644 index 000000000..9a759dba1 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.Country.cshtml @@ -0,0 +1,2 @@ +@model CountryReportModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByNumberOfOrdersBottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByNumberOfOrdersBottom.cshtml new file mode 100644 index 000000000..6c7cf1a0b --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByNumberOfOrdersBottom.cshtml @@ -0,0 +1,2 @@ +@model BestCustomersReportModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByNumberOfOrdersTop.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByNumberOfOrdersTop.cshtml new file mode 100644 index 000000000..224d0bf6e --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByNumberOfOrdersTop.cshtml @@ -0,0 +1,2 @@ +@model BestCustomersReportModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByOrderTotalBottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByOrderTotalBottom.cshtml new file mode 100644 index 000000000..efbe51d77 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByOrderTotalBottom.cshtml @@ -0,0 +1,2 @@ +@model BestCustomersReportModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByOrderTotalTop.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByOrderTotalTop.cshtml new file mode 100644 index 000000000..7f71b1f82 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByOrderTotalTop.cshtml @@ -0,0 +1,2 @@ +@model BestCustomersReportModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.DetailsTabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.DetailsTabs.cshtml new file mode 100644 index 000000000..1d55b260d --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.DetailsTabs.cshtml @@ -0,0 +1,2 @@ +@model CustomerReportsModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.NeverSold.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.NeverSold.cshtml new file mode 100644 index 000000000..dd693bec3 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.NeverSold.cshtml @@ -0,0 +1,2 @@ +@model NeverSoldReportModel + diff --git a/src/Web/Grand.Web.Admin/Controllers/ReportsController.cs b/src/Web/Grand.Web.Admin/Controllers/ReportsController.cs index 58b0bcfa3..79ff93d9b 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ReportsController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ReportsController.cs @@ -1,4 +1,4 @@ -using Grand.Business.Core.Interfaces.Catalog.Directory; +using Grand.Business.Core.Interfaces.Catalog.Directory; using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Checkout.Orders; @@ -8,654 +8,90 @@ using Grand.Business.Core.Interfaces.Common.Stores; using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Interfaces.System.Reports; -using Grand.Business.Core.Utilities.System; -using Grand.Domain.Orders; -using Grand.Domain.Payments; using Grand.Domain.Permissions; -using Grand.Domain.Shipping; using Grand.Infrastructure; using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.AdminShared.Models.Common; -using Grand.Web.AdminShared.Models.Customers; -using Grand.Web.AdminShared.Models.Orders; using Grand.Web.Common.DataSource; -using Grand.Web.Common.Extensions; +using Grand.Web.Common.Filters; using Grand.Web.Common.Localization; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; namespace Grand.Web.Admin.Controllers; +// Reduced to a thin subclass of BaseFullReportsController (ARCH-001 Reports consolidation). All 12 +// shared + 8 Admin/Store-only actions live in the shared bases; this class supplies Admin's DI wiring, +// its own [Area]/[Authorize*]/[PermissionAuthorize] attributes (BaseFullReportsController can't +// inherit any single host's base controller - see this task's header note), the ManageCustomers +// overrides on CountryReport/Customer neither shared base carries (Tasks 5/6), and +// PopularSearchTermsReport, which stays declared here only (Task 10) - not on either shared base. +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] [PermissionAuthorize(PermissionSystemName.Reports)] -public class ReportsController : BaseAdminController +public class ReportsController( + IOrderReportService orderReportService, + IProductsReportService productsReportService, + ICustomerReportViewModelService customerReportViewModelService, + IPriceFormatter priceFormatter, + ICurrencyService currencyService, + IProductService productService, + IProductAttributeFormatter productAttributeFormatter, + IStockQuantityService stockQuantityService, + ITranslationService translationService, + IStoreService storeService, + ICountryService countryService, + IVendorService vendorService, + IDateTimeService dateTimeService, + IOrderStatusService orderStatusService, + IEnumTranslationService enumTranslationService, + IContextAccessor contextAccessor, + IReportDataScope scope, + IOrderService orderService, + ICustomerReportService customerReportService, + IPermissionService permissionService, + ISearchTermService searchTermService) + : BaseFullReportsController(orderReportService, productsReportService, customerReportViewModelService, + priceFormatter, currencyService, productService, productAttributeFormatter, stockQuantityService, + translationService, storeService, countryService, vendorService, dateTimeService, orderStatusService, + enumTranslationService, contextAccessor, scope, orderService, customerReportService, permissionService) { - private readonly ICountryService _countryService; - private readonly ICurrencyService _currencyService; - private readonly ICustomerReportService _customerReportService; - private readonly ICustomerReportViewModelService _customerReportViewModelService; - private readonly IDateTimeService _dateTimeService; - private readonly IOrderReportService _orderReportService; - private readonly IOrderService _orderService; - private readonly IOrderStatusService _orderStatusService; - private readonly IPermissionService _permissionService; - private readonly IPriceFormatter _priceFormatter; - private readonly IProductAttributeFormatter _productAttributeFormatter; - private readonly IProductService _productService; - private readonly IProductsReportService _productsReportService; - private readonly ISearchTermService _searchTermService; - private readonly IStockQuantityService _stockQuantityService; - private readonly IStoreService _storeService; - private readonly ITranslationService _translationService; - private readonly IVendorService _vendorService; - private readonly IContextAccessor _contextAccessor; - private readonly IEnumTranslationService _enumTranslationService; - public ReportsController(IOrderService orderService, - IOrderReportService orderReportService, - IProductsReportService productsReportService, - ICustomerReportService customerReportService, - ICustomerReportViewModelService customerReportViewModelService, - IPermissionService permissionService, - IContextAccessor contextAccessor, - IPriceFormatter priceFormatter, - IProductService productService, - IProductAttributeFormatter productAttributeFormatter, - IStockQuantityService stockQuantityService, - ITranslationService translationService, - IStoreService storeService, - ICountryService countryService, - IVendorService vendorService, - IDateTimeService dateTimeService, - ISearchTermService searchTermService, - IOrderStatusService orderStatusService, - ICurrencyService currencyService, - IEnumTranslationService enumTranslationService) + /// Admin-only ManageCustomers gate — absent on Store/Vendor. Global Constraint 5 names + /// this check explicitly. + public override async Task CountryReport() { - _orderService = orderService; - _orderReportService = orderReportService; - _productsReportService = productsReportService; - _customerReportService = customerReportService; - _customerReportViewModelService = customerReportViewModelService; - _permissionService = permissionService; - _contextAccessor = contextAccessor; - _priceFormatter = priceFormatter; - _productService = productService; - _productAttributeFormatter = productAttributeFormatter; - _stockQuantityService = stockQuantityService; - _translationService = translationService; - _storeService = storeService; - _countryService = countryService; - _vendorService = vendorService; - _dateTimeService = dateTimeService; - _searchTermService = searchTermService; - _orderStatusService = orderStatusService; - _currencyService = currencyService; - _enumTranslationService = enumTranslationService; - } - - [NonAction] - protected async Task GetBestsellersBriefReportModel(int pageIndex, - int pageSize, int orderBy) - { - var items = await _orderReportService.BestSellersReport( - orderBy: orderBy, - pageIndex: pageIndex, - pageSize: pageSize, - showHidden: true); - var result = new List(); - foreach (var x in items) - { - var m = new BestsellersReportLineModel { - ProductId = x.ProductId, - TotalAmount = - _priceFormatter.FormatPrice(x.TotalAmount, await _currencyService.GetPrimaryStoreCurrency()), - TotalQuantity = x.TotalQuantity - }; - var product = await _productService.GetProductById(x.ProductId); - if (product != null) - m.ProductName = product.Name; - result.Add(m); - } - - var gridModel = new DataSourceResult { - Data = result, - Total = items.TotalCount - }; - return gridModel; - } - - [NonAction] - protected virtual async Task> GetReportOrderPeriodModel() - { - var report = new List(); - var reportperiod7days = - await _orderReportService.GetOrderPeriodReport(7, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - report.Add(new OrderPeriodReportLineModel { - Period = _translationService.GetResource("Admin.Reports.Period.7days"), - Count = reportperiod7days.Count, - Amount = reportperiod7days.Amount - }); - - var reportperiod14days = - await _orderReportService.GetOrderPeriodReport(14, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - report.Add(new OrderPeriodReportLineModel { - Period = _translationService.GetResource("Admin.Reports.Period.14days"), - Count = reportperiod14days.Count, - Amount = reportperiod14days.Amount - }); - - var reportperiodmonth = - await _orderReportService.GetOrderPeriodReport(30, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - report.Add(new OrderPeriodReportLineModel { - Period = _translationService.GetResource("Admin.Reports.Period.month"), - Count = reportperiodmonth.Count, - Amount = reportperiodmonth.Amount - }); - - var reportperiodyear = - await _orderReportService.GetOrderPeriodReport(365, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - report.Add(new OrderPeriodReportLineModel { - Period = _translationService.GetResource("Admin.Reports.Period.year"), - Count = reportperiodyear.Count, - Amount = reportperiodyear.Amount - }); - - return report; - } - - - [HttpPost] - public async Task BestsellersBriefReportByQuantityList(DataSourceRequest command) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Content(""); - - var gridModel = await GetBestsellersBriefReportModel(command.Page - 1, - command.PageSize, 1); - - return Json(gridModel); - } - - [HttpPost] - public async Task BestsellersBriefReportByAmountList(DataSourceRequest command) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Content(""); - - var gridModel = await GetBestsellersBriefReportModel(command.Page - 1, - command.PageSize, 2); - - return Json(gridModel); - } - - public async Task BestsellersReport() - { - var model = new BestsellersReportModel(); - //stores - model.AvailableStores.Add(new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); - foreach (var s in await _storeService.GetAllStores()) - model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); - - var status = await _orderStatusService.GetAll(); - //order statuses - model.AvailableOrderStatuses = - status.Select(x => new SelectListItem { Value = x.StatusId.ToString(), Text = x.Name }).ToList(); - model.AvailableOrderStatuses.Insert(0, - new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); - - //payment statuses - model.AvailablePaymentStatuses = _enumTranslationService.ToSelectList(PaymentStatus.Pending, false).ToList(); - model.AvailablePaymentStatuses.Insert(0, - new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); - - //billing countries - foreach (var c in await _countryService.GetAllCountriesForBilling(showHidden: true)) - model.AvailableCountries.Add(new SelectListItem { Text = c.Name, Value = c.Id }); - model.AvailableCountries.Insert(0, - new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); - - //vendors - model.AvailableVendors.Add(new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); - var vendors = await _vendorService.GetAllVendors(showHidden: true); - foreach (var v in vendors) - model.AvailableVendors.Add(new SelectListItem { Text = v.Name, Value = v.Id }); - - return View(model); - } - - [HttpPost] - public async Task BestsellersReportList(DataSourceRequest command, BestsellersReportModel model) - { - DateTime? startDateValue = model.StartDate == null - ? null - : _dateTimeService.ConvertToUtcTime(model.StartDate.Value, _dateTimeService.CurrentTimeZone); - - DateTime? endDateValue = model.EndDate == null - ? null - : _dateTimeService.ConvertToUtcTime(model.EndDate.Value, _dateTimeService.CurrentTimeZone).AddDays(1); - - int? orderStatus = model.OrderStatusId > 0 ? model.OrderStatusId : null; - var paymentStatus = model.PaymentStatusId > 0 ? (PaymentStatus?)model.PaymentStatusId : null; - - var items = await _orderReportService.BestSellersReport( - createdFromUtc: startDateValue, - createdToUtc: endDateValue, - os: orderStatus, - ps: paymentStatus, - billingCountryId: model.BillingCountryId, - orderBy: 2, - vendorId: model.VendorId, - pageIndex: command.Page - 1, - pageSize: command.PageSize, - showHidden: true, - storeId: model.StoreId); - - var result = new List(); - foreach (var x in items) - { - var m = new BestsellersReportLineModel { - ProductId = x.ProductId, - TotalAmount = - _priceFormatter.FormatPrice(x.TotalAmount, await _currencyService.GetPrimaryStoreCurrency()), - TotalQuantity = x.TotalQuantity - }; - var product = await _productService.GetProductById(x.ProductId); - if (product != null) - m.ProductName = product.Name; - - result.Add(m); - } - - var gridModel = new DataSourceResult { - Data = result, - Total = items.TotalCount - }; - - return Json(gridModel); - } - - [HttpPost] - public async Task ReportOrderPeriodList(DataSourceRequest command) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Content(""); - - var model = await GetReportOrderPeriodModel(); - var gridModel = new DataSourceResult { - Data = model, - Total = model.Count - }; - - return Json(gridModel); - } - - [HttpPost] - public async Task ReportOrderTimeChart(DataSourceRequest command, DateTime? startDate, - DateTime? endDate) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Content(""); - - var model = await _orderReportService.GetOrderByTimeReport("", startDate, endDate); - var gridModel = new DataSourceResult { - Data = model - }; - return Json(gridModel); - } - - public IActionResult NeverSoldReport() - { - var model = new NeverSoldReportModel(); - return View(model); - } - - [HttpPost] - public async Task NeverSoldReportList(DataSourceRequest command, NeverSoldReportModel model) - { - DateTime? startDateValue = model.StartDate == null - ? null - : _dateTimeService.ConvertToUtcTime(model.StartDate.Value, _dateTimeService.CurrentTimeZone); - - DateTime? endDateValue = model.EndDate == null - ? null - : _dateTimeService.ConvertToUtcTime(model.EndDate.Value, _dateTimeService.CurrentTimeZone).AddDays(1); - - var items = await _orderReportService.ProductsNeverSold("", "", - startDateValue, endDateValue, - command.Page - 1, command.PageSize, true); - var gridModel = new DataSourceResult { - Data = items.Select(x => - new NeverSoldReportLineModel { - ProductId = x.Id, - ProductName = x.Name - }), - Total = items.TotalCount - }; - - return Json(gridModel); - } - - [HttpPost] - public async Task OrderAverageReportList(DataSourceRequest command) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Content(""); - - var report = new List { - await _orderReportService.OrderAverageReport("", (int)OrderStatusSystem.Pending), - await _orderReportService.OrderAverageReport("", (int)OrderStatusSystem.Processing), - await _orderReportService.OrderAverageReport("", (int)OrderStatusSystem.Complete), - await _orderReportService.OrderAverageReport("", (int)OrderStatusSystem.Cancelled) - }; - - var statuses = await _orderStatusService.GetAll(); - var model = new List(); - foreach (var x in report.ToList()) - model.Add(new OrderAverageReportLineSummaryModel { - OrderStatus = statuses.FirstOrDefault(y => y.StatusId == x.OrderStatus)?.Name, - SumTodayOrders = - _priceFormatter.FormatPrice(x.SumTodayOrders, await _currencyService.GetPrimaryStoreCurrency()), - SumThisWeekOrders = _priceFormatter.FormatPrice(x.SumThisWeekOrders, - await _currencyService.GetPrimaryStoreCurrency()), - SumThisMonthOrders = _priceFormatter.FormatPrice(x.SumThisMonthOrders, - await _currencyService.GetPrimaryStoreCurrency()), - SumThisYearOrders = _priceFormatter.FormatPrice(x.SumThisYearOrders, - await _currencyService.GetPrimaryStoreCurrency()), - SumAllTimeOrders = _priceFormatter.FormatPrice(x.SumAllTimeOrders, - await _currencyService.GetPrimaryStoreCurrency()) - }); - var gridModel = new DataSourceResult { - Data = model, - Total = model.Count - }; - - return Json(gridModel); - } - - [HttpPost] - public async Task ReportLatestOrder(DataSourceRequest command, DateTime? startDate, - DateTime? endDate) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Content(""); - - //load orders - var orders = await _orderService.SearchOrders( - createdFromUtc: startDate, - createdToUtc: endDate, - pageIndex: command.Page - 1, - pageSize: command.PageSize); - - var statuses = await _orderStatusService.GetAll(); - - var items = new List(); - foreach (var x in orders) - { - var store = await _storeService.GetStoreById(x.StoreId); - items.Add(new OrderModel { - Id = x.Id, - OrderNumber = x.OrderNumber, - StoreName = store != null ? store.Shortcut : "Unknown", - OrderTotal = - _priceFormatter.FormatPrice(x.OrderTotal, await _currencyService.GetPrimaryStoreCurrency()), - OrderStatus = statuses.FirstOrDefault(y => y.StatusId == x.OrderStatusId)?.Name, - PaymentStatus = _enumTranslationService.GetTranslationEnum(x.PaymentStatusId), - ShippingStatus = _enumTranslationService.GetTranslationEnum(x.ShippingStatusId), - CustomerEmail = x.BillingAddress.Email, - CustomerFullName = $"{x.BillingAddress.FirstName} {x.BillingAddress.LastName}", - CreatedOn = _dateTimeService.ConvertToUserTime(x.CreatedOnUtc, DateTimeKind.Utc) - }); - } - - var gridModel = new DataSourceResult { - Data = items, - Total = orders.TotalCount - }; - return Json(gridModel); - } - - [HttpPost] - public async Task OrderIncompleteReportList(DataSourceRequest command) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Content(""); - - var model = new List(); - //not paid - var psPending = - await _orderReportService.GetOrderAverageReportLine("", ps: PaymentStatus.Pending, - ignoreCancelledOrders: true); - model.Add(new OrderIncompleteReportLineModel { - Item = _translationService.GetResource("Admin.Reports.Incomplete.TotalUnpaidOrders"), - Count = psPending.CountOrders, - Total = _priceFormatter.FormatPrice(psPending.SumOrders, await _currencyService.GetPrimaryStoreCurrency()), - ViewLink = Url.Action("List", "Order", - new { paymentStatusId = ((int)PaymentStatus.Pending).ToString(), area = Constants.AreaAdmin }) - }); - //not shipped - var ssPending = - await _orderReportService.GetOrderAverageReportLine("", ss: ShippingStatus.Pending, - ignoreCancelledOrders: true); - model.Add(new OrderIncompleteReportLineModel { - Item = _translationService.GetResource("Admin.Reports.Incomplete.TotalNotShippedOrders"), - Count = ssPending.CountOrders, - Total = _priceFormatter.FormatPrice(ssPending.SumOrders, await _currencyService.GetPrimaryStoreCurrency()), - ViewLink = Url.Action("List", "Order", - new { shippingStatusId = ((int)ShippingStatus.Pending).ToString(), area = Constants.AreaAdmin }) - }); - //pending - var osPending = await _orderReportService.GetOrderAverageReportLine("", os: (int)OrderStatusSystem.Pending, - ignoreCancelledOrders: true); - model.Add(new OrderIncompleteReportLineModel { - Item = _translationService.GetResource("Admin.Reports.Incomplete.TotalIncompleteOrders"), - Count = osPending.CountOrders, - Total = _priceFormatter.FormatPrice(osPending.SumOrders, await _currencyService.GetPrimaryStoreCurrency()), - ViewLink = Url.Action("List", "Order", - new { orderStatusId = ((int)OrderStatusSystem.Pending).ToString(), area = Constants.AreaAdmin }) - }); - - var gridModel = new DataSourceResult { - Data = model, - Total = model.Count - }; - - return Json(gridModel); - } - - public async Task CountryReport() - { - if (!await _permissionService.Authorize(StandardPermission.ManageCustomers)) + if (!await permissionService.Authorize(StandardPermission.ManageCustomers)) return AccessDeniedView(); - - var status = await _orderStatusService.GetAll(); - var model = new CountryReportModel { - //order statuses - AvailableOrderStatuses = - status.Select(x => new SelectListItem { Value = x.StatusId.ToString(), Text = x.Name }).ToList() - }; - - model.AvailableOrderStatuses.Insert(0, - new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); - - //payment statuses - model.AvailablePaymentStatuses = _enumTranslationService.ToSelectList(PaymentStatus.Pending, false).ToList(); - model.AvailablePaymentStatuses.Insert(0, - new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); - - return View(model); + return await base.CountryReport(); } - [HttpPost] - public async Task CountryReportList(DataSourceRequest command, CountryReportModel model) + /// Admin-only ManageCustomers gate — absent on Store/Vendor. Confirmed present on Admin's + /// original Customer() action (Task 5's header note flags that the spec's own §2.2 table omits + /// this one) but not on Store's or Vendor's. + public override async Task Customer() { - DateTime? startDateValue = model.StartDate == null - ? null - : _dateTimeService.ConvertToUtcTime(model.StartDate.Value, _dateTimeService.CurrentTimeZone); - - DateTime? endDateValue = model.EndDate == null - ? null - : _dateTimeService.ConvertToUtcTime(model.EndDate.Value, _dateTimeService.CurrentTimeZone).AddDays(1); - - int? orderStatus = model.OrderStatusId > 0 ? model.OrderStatusId : null; - var paymentStatus = model.PaymentStatusId > 0 ? (PaymentStatus?)model.PaymentStatusId : null; - - var items = await _orderReportService.GetCountryReport( - os: orderStatus, - ps: paymentStatus, - startTimeUtc: startDateValue, - endTimeUtc: endDateValue); - var result = new List(); - foreach (var x in items) - { - var country = await _countryService.GetCountryById(!string.IsNullOrEmpty(x.CountryId) ? x.CountryId : ""); - var m = new CountryReportLineModel { - CountryName = country != null ? country.Name : "Unknown", - SumOrders = _priceFormatter.FormatPrice(x.SumOrders, await _currencyService.GetPrimaryStoreCurrency()), - TotalOrders = x.TotalOrders - }; - result.Add(m); - } - - var gridModel = new DataSourceResult { - Data = result, - Total = items.Count - }; - - return Json(gridModel); + if (!await permissionService.Authorize(StandardPermission.ManageCustomers)) + return AccessDeniedView(); + return await base.Customer(); } + /// Admin-only, no Store/Vendor equivalent at all (Task 10) — not declared on either + /// shared base. [HttpPost] public async Task PopularSearchTermsReport(DataSourceRequest command) { - if (!await _permissionService.Authorize(StandardPermission.ManageProducts)) + if (!await permissionService.Authorize(StandardPermission.ManageProducts)) return AccessDeniedView(); - var searchTermRecordLines = await _searchTermService.GetStats(command.Page - 1, command.PageSize); + var searchTermRecordLines = await searchTermService.GetStats(command.Page - 1, command.PageSize); var gridModel = new DataSourceResult { - Data = searchTermRecordLines.Select(x => new SearchTermReportLineModel { - Keyword = x.Keyword, - Count = x.Count - }), + Data = searchTermRecordLines.Select(x => new SearchTermReportLineModel { Keyword = x.Keyword, Count = x.Count }), Total = searchTermRecordLines.TotalCount }; return Json(gridModel); } - - #region Low stock reports - - public IActionResult LowStockReport() - { - return View(); - } - - [HttpPost] - public async Task LowStockReportList(DataSourceRequest command) - { - var lowStockProducts = await _productsReportService.LowStockProducts(); - - var models = new List(); - //products - foreach (var product in lowStockProducts.products) - { - var lowStockModel = new LowStockProductModel { - Id = product.Id, - Name = product.Name, - ManageInventoryMethod = _enumTranslationService.GetTranslationEnum(product.ManageInventoryMethodId), - StockQuantity = _stockQuantityService.GetTotalStockQuantity(product, total: true), - Published = product.Published - }; - models.Add(lowStockModel); - } - - //combinations - foreach (var combination in lowStockProducts.combinations) - { - var product = await _productService.GetProductById(combination.ProductId); - var lowStockModel = new LowStockProductModel { - Id = product.Id, - Name = product.Name, - Attributes = await _productAttributeFormatter.FormatAttributes(product, combination.Attributes, - _contextAccessor.WorkContext.CurrentCustomer, "
", true, true, true, false), - ManageInventoryMethod = _enumTranslationService.GetTranslationEnum(product.ManageInventoryMethodId), - StockQuantity = combination.StockQuantity, - Published = product.Published - }; - models.Add(lowStockModel); - } - - var gridModel = new DataSourceResult { - Data = models.PagedForCommand(command), - Total = models.Count - }; - - return Json(gridModel); - } - - #endregion - - #region Customer Reports - - public async Task Customer() - { - if (!await _permissionService.Authorize(StandardPermission.ManageCustomers)) - return AccessDeniedView(); - - var model = await _customerReportViewModelService.PrepareCustomerReportsModel(); - return View(model); - } - - [HttpPost] - public async Task ReportBestCustomersByOrderTotalList(DataSourceRequest command, - BestCustomersReportModel model) - { - var (bestCustomerReportLineModels, totalCount) = - await _customerReportViewModelService.PrepareBestCustomerReportLineModel(model, 1, command.Page, - command.PageSize); - var gridModel = new DataSourceResult { - Data = bestCustomerReportLineModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [HttpPost] - public async Task ReportBestCustomersByNumberOfOrdersList(DataSourceRequest command, - BestCustomersReportModel model) - { - var (bestCustomerReportLineModels, totalCount) = - await _customerReportViewModelService.PrepareBestCustomerReportLineModel(model, 2, command.Page, - command.PageSize); - var gridModel = new DataSourceResult { - Data = bestCustomerReportLineModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [HttpPost] - public async Task ReportRegisteredCustomersList(DataSourceRequest command) - { - var model = await _customerReportViewModelService.GetReportRegisteredCustomersModel(""); - var gridModel = new DataSourceResult { - Data = model, - Total = model.Count - }; - - return Json(gridModel); - } - - [HttpPost] - public async Task ReportCustomerTimeChart(DataSourceRequest command, DateTime? startDate, - DateTime? endDate) - { - var model = await _customerReportService.GetCustomerByTimeReport("", startDate, endDate); - var gridModel = new DataSourceResult { - Data = model - }; - return Json(gridModel); - } - - #endregion -} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs new file mode 100644 index 000000000..975fff310 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs @@ -0,0 +1,286 @@ +#nullable enable + +using Grand.Business.Core.Interfaces.Catalog.Directory; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.System.Reports; +using Grand.Business.Core.Utilities.System; +using Grand.Domain.Orders; +using Grand.Domain.Payments; +using Grand.Domain.Permissions; +using Grand.Domain.Shipping; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Customers; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +/// +/// The 8 report actions unique to Admin/Store (see ARCH-001 Reports consolidation spec §4). +/// Only Admin's and Store's concrete controllers inherit this class (Task 12) — Vendor's inherits +/// directly, so none of these 8 actions, and neither of this +/// class's two ManageOrders-gated overrides of the inherited Bestsellers-brief actions +/// (Task 5's header note), become routable on the Vendor host. +/// +/// +/// is restated here for the same reason as on +/// — see that class's remarks. +/// +[AutoValidateAntiforgeryToken] +public abstract class BaseFullReportsController( + IOrderReportService orderReportService, + IProductsReportService productsReportService, + ICustomerReportViewModelService customerReportViewModelService, + IPriceFormatter priceFormatter, + ICurrencyService currencyService, + IProductService productService, + IProductAttributeFormatter productAttributeFormatter, + IStockQuantityService stockQuantityService, + ITranslationService translationService, + IStoreService storeService, + ICountryService countryService, + IVendorService vendorService, + IDateTimeService dateTimeService, + IOrderStatusService orderStatusService, + IEnumTranslationService enumTranslationService, + IContextAccessor contextAccessor, + IReportDataScope scope, + IOrderService orderService, + ICustomerReportService customerReportService, + IPermissionService permissionService) + : BaseReportsController(orderReportService, productsReportService, customerReportViewModelService, + priceFormatter, currencyService, productService, productAttributeFormatter, stockQuantityService, + translationService, storeService, countryService, vendorService, dateTimeService, orderStatusService, + enumTranslationService, contextAccessor, scope) +{ + protected IOrderService OrderService => orderService; + protected ICustomerReportService CustomerReportService => customerReportService; + protected IPermissionService PermissionService => permissionService; + + #region Bestsellers-brief ManageOrders overrides + + /// Adds the ManageOrders check Admin/Store both have (identical between the two, so it + /// belongs here once rather than duplicated per-host — see Task 5's header note) on top of + /// BaseReportsController's check-free implementation. Never reached on the Vendor host — Vendor's + /// thin subclass (Task 12) inherits BaseReportsController directly, not this class. + public override async Task BestsellersBriefReportByQuantityList(DataSourceRequest command) + { + if (!await permissionService.Authorize(StandardPermission.ManageOrders)) + return Content(""); + return await base.BestsellersBriefReportByQuantityList(command); + } + + /// Same as above. + public override async Task BestsellersBriefReportByAmountList(DataSourceRequest command) + { + if (!await permissionService.Authorize(StandardPermission.ManageOrders)) + return Content(""); + return await base.BestsellersBriefReportByAmountList(command); + } + + #endregion + + #region Order reports + + [NonAction] + protected virtual async Task> GetReportOrderPeriodModel() + { + var report = new List(); + foreach (var (days, resourceKey) in new (int, string)[] { + (7, "Admin.Reports.Period.7days"), (14, "Admin.Reports.Period.14days"), + (30, "Admin.Reports.Period.month"), (365, "Admin.Reports.Period.year") + }) + { + var reportPeriod = await orderReportService.GetOrderPeriodReport(days, scope.StoreId); + report.Add(new OrderPeriodReportLineModel { + Period = translationService.GetResource(resourceKey), + Count = reportPeriod.Count, + Amount = reportPeriod.Amount + }); + } + + return report; + } + + [HttpPost] + public virtual async Task ReportOrderPeriodList(DataSourceRequest command) + { + if (!await permissionService.Authorize(StandardPermission.ManageOrders)) + return Content(""); + + var model = await GetReportOrderPeriodModel(); + var gridModel = new DataSourceResult { Data = model, Total = model.Count }; + return Json(gridModel); + } + + [HttpPost] + public virtual async Task ReportOrderTimeChart(DataSourceRequest command, DateTime? startDate, + DateTime? endDate) + { + if (!await permissionService.Authorize(StandardPermission.ManageOrders)) + return Content(""); + + var model = await orderReportService.GetOrderByTimeReport(scope.StoreId, startDate, endDate); + return Json(new DataSourceResult { Data = model }); + } + + [HttpPost] + public virtual async Task OrderAverageReportList(DataSourceRequest command) + { + if (!await permissionService.Authorize(StandardPermission.ManageOrders)) + return Content(""); + + var report = new List { + await orderReportService.OrderAverageReport(scope.StoreId, (int)OrderStatusSystem.Pending), + await orderReportService.OrderAverageReport(scope.StoreId, (int)OrderStatusSystem.Processing), + await orderReportService.OrderAverageReport(scope.StoreId, (int)OrderStatusSystem.Complete), + await orderReportService.OrderAverageReport(scope.StoreId, (int)OrderStatusSystem.Cancelled) + }; + + var statuses = await orderStatusService.GetAll(); + var model = new List(); + foreach (var x in report) + model.Add(new OrderAverageReportLineSummaryModel { + OrderStatus = statuses.FirstOrDefault(y => y.StatusId == x.OrderStatus)?.Name, + SumTodayOrders = priceFormatter.FormatPrice(x.SumTodayOrders, await currencyService.GetPrimaryStoreCurrency()), + SumThisWeekOrders = priceFormatter.FormatPrice(x.SumThisWeekOrders, await currencyService.GetPrimaryStoreCurrency()), + SumThisMonthOrders = priceFormatter.FormatPrice(x.SumThisMonthOrders, await currencyService.GetPrimaryStoreCurrency()), + SumThisYearOrders = priceFormatter.FormatPrice(x.SumThisYearOrders, await currencyService.GetPrimaryStoreCurrency()), + SumAllTimeOrders = priceFormatter.FormatPrice(x.SumAllTimeOrders, await currencyService.GetPrimaryStoreCurrency()) + }); + + var gridModel = new DataSourceResult { Data = model, Total = model.Count }; + return Json(gridModel); + } + + [HttpPost] + public virtual async Task ReportLatestOrder(DataSourceRequest command, DateTime? startDate, + DateTime? endDate) + { + if (!await permissionService.Authorize(StandardPermission.ManageOrders)) + return Content(""); + + var orders = await orderService.SearchOrders( + storeId: scope.StoreId, + createdFromUtc: startDate, + createdToUtc: endDate, + pageIndex: command.Page - 1, + pageSize: command.PageSize); + + var statuses = await orderStatusService.GetAll(); + var items = new List(); + foreach (var x in orders) + { + var store = await storeService.GetStoreById(x.StoreId); + items.Add(new OrderModel { + Id = x.Id, + OrderNumber = x.OrderNumber, + StoreName = store != null ? store.Shortcut : "Unknown", + OrderTotal = priceFormatter.FormatPrice(x.OrderTotal, await currencyService.GetPrimaryStoreCurrency()), + OrderStatus = statuses.FirstOrDefault(y => y.StatusId == x.OrderStatusId)?.Name, + PaymentStatus = enumTranslationService.GetTranslationEnum(x.PaymentStatusId), + ShippingStatus = enumTranslationService.GetTranslationEnum(x.ShippingStatusId), + CustomerEmail = x.BillingAddress.Email, + CustomerFullName = $"{x.BillingAddress.FirstName} {x.BillingAddress.LastName}", + CreatedOn = dateTimeService.ConvertToUserTime(x.CreatedOnUtc, DateTimeKind.Utc) + }); + } + + var gridModel = new DataSourceResult { Data = items, Total = orders.TotalCount }; + return Json(gridModel); + } + + /// Area constant for the "View" link on each row: reproduces each host's original + /// literal (Admin used Constants.AreaAdmin, Store used Constants.AreaStore) via + /// scope.ResourceKeyPrefix, which happens to already equal "Admin" for both Admin and Store (Store + /// reuses Admin's resource keys — Task 2's StoreReportDataScope) — but the *area* value must be + /// the actual routing area, not the resource-key prefix, so this uses + /// ViewContext.RouteData.Values["area"] directly instead, matching the same + /// `ViewContext.RouteData.Values["area"]` pattern already used elsewhere in migrated AdminShared + /// views/controllers (e.g. Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/Edit.cshtml) + /// rather than introducing a third scope-object member just for this one Url.Action call. + [HttpPost] + public virtual async Task OrderIncompleteReportList(DataSourceRequest command) + { + if (!await permissionService.Authorize(StandardPermission.ManageOrders)) + return Content(""); + + var area = ControllerContext.RouteData.Values["area"]?.ToString(); + var model = new List(); + + var psPending = await orderReportService.GetOrderAverageReportLine(scope.StoreId, ps: PaymentStatus.Pending, + ignoreCancelledOrders: true); + model.Add(new OrderIncompleteReportLineModel { + Item = translationService.GetResource("Admin.Reports.Incomplete.TotalUnpaidOrders"), + Count = psPending.CountOrders, + Total = priceFormatter.FormatPrice(psPending.SumOrders, await currencyService.GetPrimaryStoreCurrency()), + ViewLink = Url.Action("List", "Order", new { paymentStatusId = ((int)PaymentStatus.Pending).ToString(), area }) + }); + + var ssPending = await orderReportService.GetOrderAverageReportLine(scope.StoreId, ss: ShippingStatus.Pending, + ignoreCancelledOrders: true); + model.Add(new OrderIncompleteReportLineModel { + Item = translationService.GetResource("Admin.Reports.Incomplete.TotalNotShippedOrders"), + Count = ssPending.CountOrders, + Total = priceFormatter.FormatPrice(ssPending.SumOrders, await currencyService.GetPrimaryStoreCurrency()), + ViewLink = Url.Action("List", "Order", new { shippingStatusId = ((int)ShippingStatus.Pending).ToString(), area }) + }); + + var osPending = await orderReportService.GetOrderAverageReportLine(scope.StoreId, os: (int)OrderStatusSystem.Pending, + ignoreCancelledOrders: true); + model.Add(new OrderIncompleteReportLineModel { + Item = translationService.GetResource("Admin.Reports.Incomplete.TotalIncompleteOrders"), + Count = osPending.CountOrders, + Total = priceFormatter.FormatPrice(osPending.SumOrders, await currencyService.GetPrimaryStoreCurrency()), + ViewLink = Url.Action("List", "Order", new { orderStatusId = ((int)OrderStatusSystem.Pending).ToString(), area }) + }); + + var gridModel = new DataSourceResult { Data = model, Total = model.Count }; + return Json(gridModel); + } + + #endregion + + #region Customer reports + + [HttpPost] + public virtual async Task ReportBestCustomersByNumberOfOrdersList(DataSourceRequest command, + BestCustomersReportModel model) + { + if (!string.IsNullOrEmpty(scope.StoreId)) model.StoreId = scope.StoreId; + + var (bestCustomerReportLineModels, totalCount) = await customerReportViewModelService + .PrepareBestCustomerReportLineModel(model, 2, command.Page, command.PageSize, scope.VendorId); + + var gridModel = new DataSourceResult { Data = bestCustomerReportLineModels.ToList(), Total = totalCount }; + return Json(gridModel); + } + + [HttpPost] + public virtual async Task ReportRegisteredCustomersList(DataSourceRequest command) + { + var model = await customerReportViewModelService.GetReportRegisteredCustomersModel(scope.StoreId, scope.VendorId); + var gridModel = new DataSourceResult { Data = model, Total = model.Count }; + return Json(gridModel); + } + + [HttpPost] + public virtual async Task ReportCustomerTimeChart(DataSourceRequest command, DateTime? startDate, + DateTime? endDate) + { + var model = await customerReportService.GetCustomerByTimeReport(scope.StoreId, startDate, endDate); + return Json(new DataSourceResult { Data = model }); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs new file mode 100644 index 000000000..98320d500 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs @@ -0,0 +1,406 @@ +#nullable enable + +using Grand.Business.Core.Interfaces.Catalog.Directory; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.System.Reports; +using Grand.Business.Core.Utilities.System; +using Grand.Domain.Payments; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Customers; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Grand.Web.AdminShared.Controllers; + +/// +/// The 12 report actions all three hosts (Admin, Store, Vendor) share. See ARCH-001 Reports +/// consolidation spec §4. adds the 8 actions +/// unique to Admin/Store; Vendor's concrete controller inherits this class directly so those 8 +/// never become routable on the Vendor host at all. +/// +/// +/// is restated here even though this class is +/// never directly routable (Admin/Store/Vendor's concrete controllers already restate it too, per +/// Task 12) — static analysis (CodeQL) doesn't follow the attribute across the base/derived, +/// cross-project boundary and flags every POST action here as missing CSRF validation. Runtime +/// behavior is unchanged; this closes the same class of false positive already hardened against on +/// BaseProductController (ARCH-001 Product phase). +/// +[AutoValidateAntiforgeryToken] +public abstract class BaseReportsController( + IOrderReportService orderReportService, + IProductsReportService productsReportService, + ICustomerReportViewModelService customerReportViewModelService, + IPriceFormatter priceFormatter, + ICurrencyService currencyService, + IProductService productService, + IProductAttributeFormatter productAttributeFormatter, + IStockQuantityService stockQuantityService, + ITranslationService translationService, + IStoreService storeService, + ICountryService countryService, + IVendorService vendorService, + IDateTimeService dateTimeService, + IOrderStatusService orderStatusService, + IEnumTranslationService enumTranslationService, + IContextAccessor contextAccessor, + IReportDataScope scope) + : BaseController +{ + // Exposed for BaseFullReportsController (Task 8/9) and for host-specific concrete-controller + // overrides (Task 12) — same accessor pattern as BasePaymentTransactionController. + protected IOrderReportService OrderReportService => orderReportService; + protected IProductsReportService ProductsReportService => productsReportService; + protected ICustomerReportViewModelService CustomerReportViewModelService => customerReportViewModelService; + protected IPriceFormatter PriceFormatter => priceFormatter; + protected ICurrencyService CurrencyService => currencyService; + protected IProductService ProductService => productService; + protected IProductAttributeFormatter ProductAttributeFormatter => productAttributeFormatter; + protected IStockQuantityService StockQuantityService => stockQuantityService; + protected ITranslationService TranslationService => translationService; + protected IStoreService StoreService => storeService; + protected ICountryService CountryService => countryService; + protected IVendorService VendorService => vendorService; + protected IDateTimeService DateTimeService => dateTimeService; + protected IOrderStatusService OrderStatusService => orderStatusService; + protected IEnumTranslationService EnumTranslationService => enumTranslationService; + protected IContextAccessor ContextAccessor => contextAccessor; + protected IReportDataScope Scope => scope; + + #region Bestsellers + + [NonAction] + protected virtual async Task GetBestsellersBriefReportModel(int pageIndex, int pageSize, + int orderBy) + { + var items = await orderReportService.BestSellersReport( + storeId: scope.StoreId, + vendorId: scope.VendorId, + orderBy: orderBy, + pageIndex: pageIndex, + pageSize: pageSize, + showHidden: true); + var result = new List(); + foreach (var x in items) + { + var m = new BestsellersReportLineModel { + ProductId = x.ProductId, + TotalAmount = priceFormatter.FormatPrice(x.TotalAmount, await currencyService.GetPrimaryStoreCurrency()), + TotalQuantity = x.TotalQuantity + }; + var product = await productService.GetProductById(x.ProductId); + if (product != null) + m.ProductName = product.Name; + if (scope.CanIncludeProduct(product)) + result.Add(m); + } + + return new DataSourceResult { Data = result, Total = items.TotalCount }; + } + + /// No inline permission check here — matches Vendor's actual current behavior exactly + /// (Vendor never had a ManageOrders gate on this action; Admin/Store do). `virtual` so + /// BaseFullReportsController (Task 8) can override and add the check for exactly the two hosts + /// that have it, without leaking it onto Vendor. See this task's header note. + [HttpPost] + public virtual async Task BestsellersBriefReportByQuantityList(DataSourceRequest command) + { + var gridModel = await GetBestsellersBriefReportModel(command.Page - 1, command.PageSize, 1); + return Json(gridModel); + } + + /// Same as — no inline check here. + [HttpPost] + public virtual async Task BestsellersBriefReportByAmountList(DataSourceRequest command) + { + var gridModel = await GetBestsellersBriefReportModel(command.Page - 1, command.PageSize, 2); + return Json(gridModel); + } + + /// Store/vendor picker population gated on scope.ShowStoreSelector/ShowVendorSelector — + /// true for Admin only. AvailableOrderStatuses/AvailablePaymentStatuses/AvailableCountries are + /// populated unconditionally for all three hosts: Vendor's pre-consolidation model never carried + /// AvailableOrderStatuses at all, but populating it here is the same accepted "unused select list" + /// tolerance already established for Store's AvailableVendors (spec §2.4/§5) — Vendor's view + /// simply never renders the field. scope.ResourceKeyPrefix ("Admin" vs "Vendor") reproduces each + /// host's original resource-key choice for the "(all)" placeholder text exactly (Admin/Store used + /// "Admin.Common.All", Vendor used "Vendor.Common.All" — confirmed in the three original + /// controllers). + public virtual async Task BestsellersReport() + { + var model = new BestsellersReportModel(); + + if (scope.ShowStoreSelector) + { + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "" }); + foreach (var s in await storeService.GetAllStores()) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + } + + var status = await orderStatusService.GetAll(); + model.AvailableOrderStatuses = status.Select(x => new SelectListItem { Value = x.StatusId.ToString(), Text = x.Name }).ToList(); + model.AvailableOrderStatuses.Insert(0, + new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = "" }); + + model.AvailablePaymentStatuses = enumTranslationService.ToSelectList(PaymentStatus.Pending, false).ToList(); + model.AvailablePaymentStatuses.Insert(0, + new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = "" }); + + foreach (var c in await countryService.GetAllCountriesForBilling(showHidden: true)) + model.AvailableCountries.Add(new SelectListItem { Text = c.Name, Value = c.Id }); + model.AvailableCountries.Insert(0, + new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = "" }); + + if (scope.ShowVendorSelector) + { + model.AvailableVendors.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "" }); + foreach (var v in await vendorService.GetAllVendors(showHidden: true)) + model.AvailableVendors.Add(new SelectListItem { Text = v.Name, Value = v.Id }); + } + + return View(model); + } + + /// storeId/vendorId: scope value wins when non-empty (Store/Vendor force it), otherwise + /// the posted model value is used unmodified (Admin, whose scope.StoreId/.VendorId are always ""). + /// Mirrors Store's original unconditional `model.StoreId = StaffStoreId` assignment exactly for + /// Store, and Vendor's original explicit `vendorId: CurrentVendor.Id` argument exactly for Vendor, + /// while leaving Admin's posted values untouched. Row filtering via scope.CanIncludeProduct + /// reproduces Vendor's original `HasAccessToProduct` post-filter exactly (default-true for + /// Admin/Store, including when product is null — same as the original "if CurrentVendor == null, + /// always add" branch; VendorReportDataScope.CanIncludeProduct(null) is false, matching Vendor's + /// original `product != null && HasAccessToProduct(product)` guard). model.StoreId/model.VendorId + /// are normalized to "" when unposted (`null`, the default for an unbound `string` model property) + /// because `IOrderReportService.BestSellersReport`'s storeId/vendorId parameters are non-nullable + /// and default to "" — a `null` model value must not reach them as `null`. + [HttpPost] + public virtual async Task BestsellersReportList(DataSourceRequest command, BestsellersReportModel model) + { + if (!string.IsNullOrEmpty(scope.StoreId)) model.StoreId = scope.StoreId; + if (!string.IsNullOrEmpty(scope.VendorId)) model.VendorId = scope.VendorId; + + DateTime? startDateValue = model.StartDate == null + ? null + : dateTimeService.ConvertToUtcTime(model.StartDate.Value, dateTimeService.CurrentTimeZone); + + DateTime? endDateValue = model.EndDate == null + ? null + : dateTimeService.ConvertToUtcTime(model.EndDate.Value, dateTimeService.CurrentTimeZone).AddDays(1); + + int? orderStatus = model.OrderStatusId > 0 ? model.OrderStatusId : null; + var paymentStatus = model.PaymentStatusId > 0 ? (PaymentStatus?)model.PaymentStatusId : null; + + var items = await orderReportService.BestSellersReport( + storeId: model.StoreId ?? "", + vendorId: model.VendorId ?? "", + createdFromUtc: startDateValue, + createdToUtc: endDateValue, + os: orderStatus, + ps: paymentStatus, + billingCountryId: model.BillingCountryId ?? "", + orderBy: 2, + pageIndex: command.Page - 1, + pageSize: command.PageSize, + showHidden: true); + + var result = new List(); + foreach (var x in items) + { + var m = new BestsellersReportLineModel { + ProductId = x.ProductId, + TotalAmount = priceFormatter.FormatPrice(x.TotalAmount, await currencyService.GetPrimaryStoreCurrency()), + TotalQuantity = x.TotalQuantity + }; + var product = await productService.GetProductById(x.ProductId); + if (product != null) + m.ProductName = product.Name; + if (scope.CanIncludeProduct(product)) + result.Add(m); + } + + var gridModel = new DataSourceResult { Data = result, Total = items.TotalCount }; + return Json(gridModel); + } + + #endregion + + #region Never sold + + public virtual IActionResult NeverSoldReport() => View(new NeverSoldReportModel()); + + /// storeId/vendorId: same "scope wins when non-empty, else posted model value" rule as + /// BestsellersReportList — NeverSoldReportModel has no StoreId/VendorId fields of its own (none of + /// the three original controllers ever posted one for this action), so the "posted value" side is + /// always "" here; this reduces to "scope value, or '' for Admin", which is exactly each of the + /// three originals' literal argument (Admin: "", ""; Store: storeId, ""; Vendor: "", vendorId). + [HttpPost] + public virtual async Task NeverSoldReportList(DataSourceRequest command, NeverSoldReportModel model) + { + DateTime? startDateValue = model.StartDate == null + ? null + : dateTimeService.ConvertToUtcTime(model.StartDate.Value, dateTimeService.CurrentTimeZone); + + DateTime? endDateValue = model.EndDate == null + ? null + : dateTimeService.ConvertToUtcTime(model.EndDate.Value, dateTimeService.CurrentTimeZone).AddDays(1); + + var items = await orderReportService.ProductsNeverSold(scope.StoreId, scope.VendorId, + startDateValue, endDateValue, command.Page - 1, command.PageSize, true); + + var gridModel = new DataSourceResult { + Data = items.Select(x => new NeverSoldReportLineModel { ProductId = x.Id, ProductName = x.Name }), + Total = items.TotalCount + }; + return Json(gridModel); + } + + #endregion + + #region Country + + /// No inline permission check here — matches Store's/Vendor's actual current behavior + /// (only Admin gates this GET on ManageCustomers; see this task's header note and Global + /// Constraint 5). `virtual` so Admin's thin subclass (Task 12) can override and add the check. + public virtual async Task CountryReport() + { + var status = await orderStatusService.GetAll(); + var model = new CountryReportModel { + AvailableOrderStatuses = status.Select(x => new SelectListItem { Value = x.StatusId.ToString(), Text = x.Name }).ToList() + }; + model.AvailableOrderStatuses.Insert(0, + new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = "" }); + + model.AvailablePaymentStatuses = enumTranslationService.ToSelectList(PaymentStatus.Pending, false).ToList(); + model.AvailablePaymentStatuses.Insert(0, + new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = "" }); + + return View(model); + } + + [HttpPost] + public virtual async Task CountryReportList(DataSourceRequest command, CountryReportModel model) + { + DateTime? startDateValue = model.StartDate == null + ? null + : dateTimeService.ConvertToUtcTime(model.StartDate.Value, dateTimeService.CurrentTimeZone); + + DateTime? endDateValue = model.EndDate == null + ? null + : dateTimeService.ConvertToUtcTime(model.EndDate.Value, dateTimeService.CurrentTimeZone).AddDays(1); + + int? orderStatus = model.OrderStatusId > 0 ? model.OrderStatusId : null; + var paymentStatus = model.PaymentStatusId > 0 ? (PaymentStatus?)model.PaymentStatusId : null; + + var items = await orderReportService.GetCountryReport( + storeId: scope.StoreId, + vendorId: scope.VendorId, + os: orderStatus, + ps: paymentStatus, + startTimeUtc: startDateValue, + endTimeUtc: endDateValue); + + var result = new List(); + foreach (var x in items) + { + var country = await countryService.GetCountryById(!string.IsNullOrEmpty(x.CountryId) ? x.CountryId : ""); + result.Add(new CountryReportLineModel { + CountryName = country != null ? country.Name : "Unknown", + SumOrders = priceFormatter.FormatPrice(x.SumOrders, await currencyService.GetPrimaryStoreCurrency()), + TotalOrders = x.TotalOrders + }); + } + + var gridModel = new DataSourceResult { Data = result, Total = items.Count }; + return Json(gridModel); + } + + #endregion + + #region Low stock reports + + public virtual IActionResult LowStockReport() => View(); + + [HttpPost] + public virtual async Task LowStockReportList(DataSourceRequest command) + { + var lowStockProducts = await productsReportService.LowStockProducts(scope.VendorId, scope.StoreId); + + var models = new List(); + foreach (var product in lowStockProducts.products) + models.Add(new LowStockProductModel { + Id = product.Id, + Name = product.Name, + ManageInventoryMethod = enumTranslationService.GetTranslationEnum(product.ManageInventoryMethodId), + StockQuantity = stockQuantityService.GetTotalStockQuantity(product, total: true), + Published = product.Published + }); + + foreach (var combination in lowStockProducts.combinations) + { + var product = await productService.GetProductById(combination.ProductId); + models.Add(new LowStockProductModel { + Id = product.Id, + Name = product.Name, + Attributes = await productAttributeFormatter.FormatAttributes(product, combination.Attributes, + contextAccessor.WorkContext.CurrentCustomer, "
", true, true, true, false), + ManageInventoryMethod = enumTranslationService.GetTranslationEnum(product.ManageInventoryMethodId), + StockQuantity = combination.StockQuantity, + Published = product.Published + }); + } + + var gridModel = new DataSourceResult { Data = models.PagedForCommand(command), Total = models.Count }; + return Json(gridModel); + } + + #endregion + + #region Customer reports + + /// No inline permission check here — matches Store's/Vendor's actual current behavior + /// (only Admin gates this GET on ManageCustomers; see Task 5's header note and Global Constraint + /// 5, which names CountryReport's twin of this same check explicitly but not this one). `virtual` + /// so Admin's thin subclass (Task 12) can override and add the check. Reuses + /// PrepareCustomerReportsModel() as-is for all three hosts, including Vendor, accepting the + /// unused-select-list over-population documented at spec §5/§2.4 as a disclosed, accepted + /// tradeoff rather than adding a Vendor-specific thinner variant. + public virtual async Task Customer() + { + var model = await customerReportViewModelService.PrepareCustomerReportsModel(); + return View(model); + } + + /// `scope.VendorId` is the new 5th argument added to + /// `PrepareBestCustomerReportLineModel` by Task 11 (Global Constraint 8) — "" for Admin/Store + /// (unchanged behavior), the current vendor's id for Vendor (replaces Vendor's original inline + /// `GetBestCustomersReport("", vendorId, ...)` reimplementation entirely; see Task 11). + /// model.StoreId is still threaded the same "scope wins when non-empty" way as every other action + /// in this file, for the storeId half of the same call. + [HttpPost] + public virtual async Task ReportBestCustomersByOrderTotalList(DataSourceRequest command, + BestCustomersReportModel model) + { + if (!string.IsNullOrEmpty(scope.StoreId)) model.StoreId = scope.StoreId; + + var (bestCustomerReportLineModels, totalCount) = await customerReportViewModelService + .PrepareBestCustomerReportLineModel(model, 1, command.Page, command.PageSize, scope.VendorId); + + var gridModel = new DataSourceResult { Data = bestCustomerReportLineModels.ToList(), Total = totalCount }; + return Json(gridModel); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/ICustomerReportViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/ICustomerReportViewModelService.cs index 9fc675faf..7d07a4a37 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/ICustomerReportViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/ICustomerReportViewModelService.cs @@ -5,8 +5,15 @@ namespace Grand.Web.AdminShared.Interfaces; public interface ICustomerReportViewModelService { Task PrepareCustomerReportsModel(); - Task> GetReportRegisteredCustomersModel(string storeId); + + /// Store scope applied to the underlying registered-customers report. + /// Intentionally accepted but currently unused: the underlying report has no vendor + /// dimension. Kept for signature symmetry with and for + /// forward-compatibility should a vendor dimension ever be added. + Task> GetReportRegisteredCustomersModel(string storeId, + string vendorId = ""); Task<(IEnumerable bestCustomerReportLineModels, int totalCount)> - PrepareBestCustomerReportLineModel(BestCustomersReportModel model, int orderBy, int pageIndex, int pageSize); + PrepareBestCustomerReportLineModel(BestCustomersReportModel model, int orderBy, int pageIndex, + int pageSize, string vendorId = ""); } \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IReportDataScope.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IReportDataScope.cs new file mode 100644 index 000000000..9ac616e8f --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IReportDataScope.cs @@ -0,0 +1,44 @@ +#nullable enable + +namespace Grand.Web.AdminShared.Interfaces; + +/// +/// Per-host data-access strategy for the read-only Reports screens. Deliberately separate from +/// IAdminDataScope<TEntity>: Reports has no entity to load and access-check — every report is +/// an aggregation query parameterized by storeId/vendorId at the business-service layer (see +/// ARCH-001 Reports consolidation spec §2.3). Forcing Reports through IAdminDataScope<TEntity> +/// would require a fake TEntity and leave HasAccess/CanView permanently unused — a worse fit than a +/// second, smaller interface. +/// +public interface IReportDataScope +{ + /// Store id to force into report queries. "" (all stores) for Admin when the caller + /// supplies no explicit store filter; the current staff store for Store; "" (not store-scoped) + /// for Vendor. + string StoreId { get; } + + /// Vendor id to force into report queries. "" for Admin/Store (unless the caller + /// supplies an explicit vendor filter — Admin only); the current vendor's id for Vendor. + string VendorId { get; } + + /// Whether the host's Bestsellers/report screens should render a store-picker field. + /// True for Admin only (Store is implicitly scoped to its own store with no picker; Vendor has + /// no store concept on these screens). + bool ShowStoreSelector { get; } + + /// Whether the host's Bestsellers screen should render a vendor-picker field. True for + /// Admin only. + bool ShowVendorSelector { get; } + + /// Prefix used to build host-specific localization keys ("Admin" or "Vendor"). Store + /// uses "Admin" — same precedent as every prior phase's ResourceKeyPrefix, Store has no distinct + /// Reports resource set. + string ResourceKeyPrefix { get; } + + /// Post-filter applied to bestsellers/product-bearing report rows after the underlying + /// query returns, beyond the storeId/vendorId already passed into the query. Identity (no + /// filtering) for Admin and Store. Vendor overrides this to additionally drop rows whose product + /// the current vendor sub-account has no access to (WorkContext.HasAccessToProduct) — preserves + /// Vendor's existing BestsellersReportList behavior exactly (see spec §2.3). + bool CanIncludeProduct(Grand.Domain.Catalog.Product product) => true; +} diff --git a/src/Web/Grand.Web.AdminShared/Services/AdminReportDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/AdminReportDataScope.cs new file mode 100644 index 000000000..f3a9540a6 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/AdminReportDataScope.cs @@ -0,0 +1,23 @@ +#nullable enable + +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Admin's . Admin's report screens accept an explicit +/// storeId/vendorId filter from the posted grid model itself (or default to "" = unscoped) — the +/// scope object's job for Admin is purely to advertise the *capability* to filter +/// (/ = true), not to supply a +/// forced value the way Store/Vendor's scopes do. See ARCH-001 Reports consolidation spec §3. +/// +public class AdminReportDataScope : IReportDataScope +{ + public string StoreId => ""; + public string VendorId => ""; + public bool ShowStoreSelector => true; + public bool ShowVendorSelector => true; + public string ResourceKeyPrefix => "Admin"; + public bool CanIncludeProduct(Product product) => true; +} diff --git a/src/Web/Grand.Web.AdminShared/Services/CustomerReportViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CustomerReportViewModelService.cs index 72813b9e3..2c67419ca 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CustomerReportViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CustomerReportViewModelService.cs @@ -77,8 +77,15 @@ public virtual async Task PrepareCustomerReportsModel() return model; } + /// Store scope applied to . + /// Intentionally accepted but currently unused: + /// has no vendor dimension at all (registered customers aren't owned by a vendor), so there is nothing to filter by here. + /// The parameter exists for signature symmetry with (which DOES use its + /// own vendorId) and for forward-compatibility should a vendor dimension ever be added to the underlying report. + /// The only caller () is Full-tier/Admin-Store + /// only, where scope.VendorId is always "" anyway, so this is currently a documented no-op, not a bug. public virtual async Task> GetReportRegisteredCustomersModel( - string storeId) + string storeId, string vendorId = "") { var report = new List { new() { @@ -108,7 +115,8 @@ public virtual async Task> GetReportReg } public virtual async Task<(IEnumerable bestCustomerReportLineModels, int totalCount)> - PrepareBestCustomerReportLineModel(BestCustomersReportModel model, int orderBy, int pageIndex, int pageSize) + PrepareBestCustomerReportLineModel(BestCustomersReportModel model, int orderBy, int pageIndex, int pageSize, + string vendorId = "") { DateTime? startDateValue = model.StartDate == null ? null @@ -124,6 +132,7 @@ public virtual async Task> GetReportReg var items = await _customerReportService.GetBestCustomersReport( model.StoreId, + vendorId, createdFromUtc: startDateValue, createdToUtc: endDateValue, os: orderStatus, diff --git a/src/Web/Grand.Web.AdminShared/Services/RoutedReportDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/RoutedReportDataScope.cs new file mode 100644 index 000000000..4051f92db --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/RoutedReportDataScope.cs @@ -0,0 +1,47 @@ +#nullable enable + +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Resolves the correct per-host implementation at request time, +/// based on the current request's "area" route value — same fix and same reason as +/// /: +/// Grand.Web (the combined host) ProjectReferences Admin, Store, and Vendor +/// directly and loads all three StartupApplications into one DI container, so a plain +/// per-host registration would let whichever host's StartupApplication ran last win for every +/// area in that process. +/// +public class RoutedReportDataScope( + IHttpContextAccessor httpContextAccessor, + AdminReportDataScope adminScope, + StoreReportDataScope storeScope, + VendorReportDataScope vendorScope) : IReportDataScope +{ + private IReportDataScope Resolved + { + get + { + var area = httpContextAccessor.HttpContext?.Request.RouteValues["area"] as string; + return area switch { + "Admin" => adminScope, + "Store" => storeScope, + "Vendor" => vendorScope, + //fail closed: this object fronts store/vendor tenant isolation, so an unrecognized + //or missing area must never silently resolve to any concrete scope + _ => throw new InvalidOperationException( + $"RoutedReportDataScope: unrecognized or missing area '{area}'.") + }; + } + } + + public string StoreId => Resolved.StoreId; + public string VendorId => Resolved.VendorId; + public bool ShowStoreSelector => Resolved.ShowStoreSelector; + public bool ShowVendorSelector => Resolved.ShowVendorSelector; + public string ResourceKeyPrefix => Resolved.ResourceKeyPrefix; + public bool CanIncludeProduct(Product product) => Resolved.CanIncludeProduct(product); +} diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreReportDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreReportDataScope.cs new file mode 100644 index 000000000..5c933dbce --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/StoreReportDataScope.cs @@ -0,0 +1,23 @@ +#nullable enable + +using Grand.Domain.Catalog; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Store's . Forces every report query to the current staff +/// store; never a vendor concept. No store/vendor picker (Store is implicitly scoped, same +/// shape as every prior phase's Store scope). Reuses Admin's resource keys ("Admin" prefix) — +/// Store has no distinct Reports resource set today. +/// +public class StoreReportDataScope(IContextAccessor contextAccessor) : IReportDataScope +{ + public string StoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + public string VendorId => ""; + public bool ShowStoreSelector => false; + public bool ShowVendorSelector => false; + public string ResourceKeyPrefix => "Admin"; + public bool CanIncludeProduct(Product product) => true; +} diff --git a/src/Web/Grand.Web.AdminShared/Services/VendorReportDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/VendorReportDataScope.cs new file mode 100644 index 000000000..115ed84f4 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/VendorReportDataScope.cs @@ -0,0 +1,29 @@ +#nullable enable + +using Grand.Domain.Catalog; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Vendor's . Forces every report query to the current vendor; +/// never a store concept on these screens. reimplements the +/// equivalent of Grand.Web.Vendor/Extensions/HasAccess.cs's +/// HasAccessToProduct(Product) rather than calling it: Grand.Web.AdminShared has no +/// project reference to Grand.Web.Vendor (the reference direction is Vendor→AdminShared), +/// same constraint VendorMerchandiseReturnDataScope/VendorOrderDataScope already +/// work around. Preserves Vendor's existing BestsellersReportList product-ownership +/// post-filter verbatim (ARCH-001 Reports consolidation spec §2.3/§9). +/// +public class VendorReportDataScope(IContextAccessor contextAccessor) : IReportDataScope +{ + public string StoreId => ""; + public string VendorId => contextAccessor.WorkContext.CurrentVendor.Id; + public bool ShowStoreSelector => false; + public bool ShowVendorSelector => false; + public string ResourceKeyPrefix => "Vendor"; + + public bool CanIncludeProduct(Product product) => + product is not null && product.VendorId == contextAccessor.WorkContext.CurrentVendor.Id; +} diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 8eaf3fdc9..6d67af6f5 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -115,6 +115,14 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped, RoutedMerchandiseReturnDataScope>(); + + // IReportDataScope: NOT an IAdminDataScope registration (Reports has no entity — + // see IReportDataScope's doc comment and ARCH-001 Reports consolidation spec §3). All three + // hosts have a Reports screen, so all three concrete scopes are registered. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/BestsellersBriefReportByAmount.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/BestsellersBriefReportByAmount.cshtml similarity index 87% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/BestsellersBriefReportByAmount.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/BestsellersBriefReportByAmount.cshtml index fe9cc1edf..d4231128b 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/BestsellersBriefReportByAmount.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/BestsellersBriefReportByAmount.cshtml @@ -1,4 +1,5 @@ -@{ +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); var gridPageSize = 5; }
@@ -8,7 +9,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("BestsellersBriefReportByAmountList", "Reports", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("BestsellersBriefReportByAmountList", "Reports", new { area }))", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -50,8 +51,8 @@ field: "ProductId", title: "@Loc["Admin.Common.View"]", width: 100, - template: ' @Loc["Admin.Common.View"]' + template: ' @Loc["Admin.Common.View"]' }] }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/BestsellersBriefReportByQuantity.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/BestsellersBriefReportByQuantity.cshtml similarity index 87% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/BestsellersBriefReportByQuantity.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/BestsellersBriefReportByQuantity.cshtml index da6b5c12b..373b2a3fd 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/BestsellersBriefReportByQuantity.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/BestsellersBriefReportByQuantity.cshtml @@ -1,4 +1,5 @@ -@{ +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); var gridPageSize = 5; }
@@ -8,7 +9,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("BestsellersBriefReportByQuantityList", "Reports", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("BestsellersBriefReportByQuantityList", "Reports", new { area }))", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -50,8 +51,8 @@ field: "ProductId", title: "@Loc["Admin.Common.View"]", width: 100, - template: ' @Loc["Admin.Common.View"]' + template: ' @Loc["Admin.Common.View"]' }] }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/BestsellersReport.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/BestsellersReport.cshtml similarity index 83% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/BestsellersReport.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/BestsellersReport.cshtml index 27d96a774..bfa067455 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/BestsellersReport.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/BestsellersReport.cshtml @@ -1,8 +1,10 @@ -@model BestsellersReportModel +@model BestsellersReportModel @inject AdminAreaSettings adminAreaSettings +@inject IReportDataScope Scope @{ //page title - ViewBag.Title = Loc["Admin.Reports.Bestsellers"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Reports.Bestsellers"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); }
@@ -11,12 +13,12 @@
- @Loc["Admin.Reports.Bestsellers"] + @Loc[$"{Scope.ResourceKeyPrefix}.Reports.Bestsellers"]
- +
@@ -24,12 +26,15 @@
-
-
- - + @if (Scope.ShowStoreSelector) + { +
+
+ + +
-
+ }
@@ -76,12 +81,15 @@
-
- -
- + @if (Scope.ShowVendorSelector) + { +
+ +
+ +
-
+ }
@@ -104,7 +112,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("BestsellersReportList", "Reports", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("BestsellersReportList", "Reports", new { area }))", type: "POST", dataType: "json", data: additionalData @@ -137,7 +145,7 @@ columns: [{ field: "ProductName", title: "@Loc["Admin.Reports.Bestsellers.Fields.Name"]", - template: '#=kendo.htmlEncode(ProductName)#' + template: '#=kendo.htmlEncode(ProductName)#' }, { field: "TotalQuantity", title: "@Loc["Admin.Reports.Bestsellers.Fields.TotalQuantity"]" @@ -175,4 +183,4 @@ return data; } - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Reports/CountryReport.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/CountryReport.cshtml similarity index 94% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Reports/CountryReport.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/CountryReport.cshtml index 59a405ff8..924457927 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Reports/CountryReport.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/CountryReport.cshtml @@ -1,7 +1,9 @@ -@model CountryReportModel +@model CountryReportModel +@inject IReportDataScope Scope @{ //page title - ViewBag.Title = Loc["Admin.Reports.Country"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Reports.Country"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } @@ -11,12 +13,12 @@
- @Loc["Admin.Reports.Country"] + @Loc[$"{Scope.ResourceKeyPrefix}.Reports.Country"]
- +
@@ -86,7 +88,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("CountryReportList", "Reports", new { area = Constants.AreaStore }))", + url: "@Html.Raw(Url.Action("CountryReportList", "Reports", new { area }))", type: "POST", dataType: "json", data: additionalData @@ -150,4 +152,4 @@ return data; } - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Customer.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Customer.cshtml similarity index 88% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Customer.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Customer.cshtml index 65d565978..58b1c5f5c 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Customer.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Customer.cshtml @@ -1,8 +1,9 @@ -@model CustomerReportsModel +@model CustomerReportsModel +@inject IReportDataScope Scope @{ //page title - ViewBag.Title = Loc["Admin.Reports.Customers"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Reports.Customers"]; }
@@ -11,7 +12,7 @@
- @Loc["Admin.Reports.Customers"] + @Loc[$"{Scope.ResourceKeyPrefix}.Reports.Customers"]
@@ -41,7 +42,7 @@
- +
@@ -50,4 +51,4 @@
-
\ No newline at end of file +
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Reports/LowStockReport.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/LowStockReport.cshtml similarity index 84% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Reports/LowStockReport.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/LowStockReport.cshtml index b72473b8e..b7592ce6a 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Reports/LowStockReport.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/LowStockReport.cshtml @@ -1,7 +1,9 @@ -@inject AdminAreaSettings adminAreaSettings +@inject AdminAreaSettings adminAreaSettings +@inject IReportDataScope Scope @{ //page title - ViewBag.Title = Loc["Admin.Reports.LowStockReport"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Reports.LowStockReport"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); }
@@ -10,7 +12,7 @@
- @Loc["Admin.Reports.LowStockReport"] + @Loc[$"{Scope.ResourceKeyPrefix}.Reports.LowStockReport"]
@@ -29,7 +31,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("LowStockReportList", "Reports", new { area = Constants.AreaStore }))", + url: "@Html.Raw(Url.Action("LowStockReportList", "Reports", new { area }))", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -64,8 +66,8 @@ field: "Name", title: "@Loc["Admin.Catalog.Products.Fields.Name"]", width: 300, - //if not a grouped product, then display - template: '#:Name# # if(Attributes !== null) {#
#=Attributes# #} #
', + //if not a grouped product, then display + template: '#:Name# # if(Attributes !== null) {#
#=Attributes# #} #
', }, { field: "ManageInventoryMethod", title: "@Loc["Admin.Catalog.Products.Fields.ManageInventoryMethod"]", @@ -85,4 +87,4 @@ ] }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/NeverSoldReport.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/NeverSoldReport.cshtml similarity index 90% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/NeverSoldReport.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/NeverSoldReport.cshtml index 0c36b4543..27684fb7b 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/NeverSoldReport.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/NeverSoldReport.cshtml @@ -1,8 +1,10 @@ -@model NeverSoldReportModel +@model NeverSoldReportModel @inject AdminAreaSettings adminAreaSettings +@inject IReportDataScope Scope @{ //page title - ViewBag.Title = Loc["Admin.Reports.NeverSold"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Reports.NeverSold"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); }
@@ -11,11 +13,11 @@
- @Loc["Admin.Reports.NeverSold"] + @Loc[$"{Scope.ResourceKeyPrefix}.Reports.NeverSold"]
- +
@@ -62,7 +64,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("NeverSoldReportList", "Reports", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("NeverSoldReportList", "Reports", new { area }))", type: "POST", dataType: "json", data: additionalData @@ -95,7 +97,7 @@ columns: [{ field: "ProductName", title: "@Loc["Admin.Reports.NeverSold.Fields.Name"]", - template: '#=kendo.htmlEncode(ProductName)#' + template: '#=kendo.htmlEncode(ProductName)#' }] }); }); @@ -122,4 +124,4 @@ return data; } - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/Customer.TabBestByNumberOfOrders.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/Customer.TabBestByNumberOfOrders.cshtml similarity index 90% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/Customer.TabBestByNumberOfOrders.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/Customer.TabBestByNumberOfOrders.cshtml index bcf131ac1..a8f3b0487 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/Customer.TabBestByNumberOfOrders.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/Customer.TabBestByNumberOfOrders.cshtml @@ -1,4 +1,4 @@ -@model CustomerReportsModel +@model CustomerReportsModel @{ var dataDictAttributes = new ViewDataDictionary(ViewData) { @@ -8,4 +8,4 @@ } }; -} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/Customer.TabBestByOrderTotal.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/Customer.TabBestByOrderTotal.cshtml similarity index 90% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/Customer.TabBestByOrderTotal.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/Customer.TabBestByOrderTotal.cshtml index 1cfd5ef1b..7955f124e 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/Customer.TabBestByOrderTotal.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/Customer.TabBestByOrderTotal.cshtml @@ -1,4 +1,4 @@ -@model CustomerReportsModel +@model CustomerReportsModel @{ var dataDictAttributes = new ViewDataDictionary(ViewData) { @@ -8,4 +8,4 @@ } }; -} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/Customer.TabRegisteredCustomers.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/Customer.TabRegisteredCustomers.cshtml new file mode 100644 index 000000000..6ca8a9a83 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/Customer.TabRegisteredCustomers.cshtml @@ -0,0 +1,4 @@ +@model CustomerReportsModel +@{ + +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/ReportBestCustomersByNumberOfOrders.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/ReportBestCustomersByNumberOfOrders.cshtml similarity index 93% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/ReportBestCustomersByNumberOfOrders.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/ReportBestCustomersByNumberOfOrders.cshtml index 81e9b0fc6..af70fca45 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/ReportBestCustomersByNumberOfOrders.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/ReportBestCustomersByNumberOfOrders.cshtml @@ -1,7 +1,10 @@ -@model BestCustomersReportModel +@model BestCustomersReportModel @inject AdminAreaSettings adminAreaSettings +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} - +