From f3fe526a3cdf3f41e9306e063be7e70470615e33 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 15:21:31 +0200 Subject: [PATCH 01/17] feat(arch001): add IReportDataScope interface --- .../Interfaces/IReportDataScope.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IReportDataScope.cs 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; +} From 8cbd92877c781929be90da3aab72197bd4c20a93 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 15:31:18 +0200 Subject: [PATCH 02/17] feat(arch001): add AdminReportDataScope and StoreReportDataScope --- .../Controllers/AdminReportDataScopeTests.cs | 29 +++++++++ .../Controllers/StoreReportDataScopeTests.cs | 59 +++++++++++++++++++ .../Services/AdminReportDataScope.cs | 23 ++++++++ .../Services/StoreReportDataScope.cs | 23 ++++++++ 4 files changed, 134 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/AdminReportDataScopeTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/StoreReportDataScopeTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/AdminReportDataScope.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/StoreReportDataScope.cs 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/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/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/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; +} From 4feb4a7f666917762544954c6186819678b86d8e Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 15:35:31 +0200 Subject: [PATCH 03/17] feat(arch001): add VendorReportDataScope --- .../Controllers/VendorReportDataScopeTests.cs | 64 +++++++++++++++++++ .../Services/VendorReportDataScope.cs | 29 +++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/VendorReportDataScopeTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/VendorReportDataScope.cs 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/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; +} From b6b59bb27d5bc14f8d3f826b4923ffe4b03863b4 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 15:40:13 +0200 Subject: [PATCH 04/17] feat(arch001): add RoutedReportDataScope and register IReportDataScope Co-Authored-By: Claude Sonnet 5 --- .../Controllers/RoutedReportDataScopeTests.cs | 81 +++++++++++++++++++ .../Services/RoutedReportDataScope.cs | 47 +++++++++++ .../Startup/StartupApplication.cs | 8 ++ 3 files changed, 136 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedReportDataScopeTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/RoutedReportDataScope.cs 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/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/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index da6afbdde..e3884e53f 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -108,6 +108,14 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped>(); services.AddScoped(); services.AddScoped, RoutedPaymentTransactionDataScope>(); + + // 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) From 75c5aa1aa9f39965efc9aaeda68c7923ec252aad Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 15:48:33 +0200 Subject: [PATCH 05/17] feat(arch001): add BaseReportsController skeleton + Bestsellers region Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseReportsControllerTests.cs | 176 ++++++++++++++ .../Controllers/BaseReportsController.cs | 224 ++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs 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..41d8445fe --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs @@ -0,0 +1,176 @@ +#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.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 _productServiceMock = null!; + private Mock _storeServiceMock = null!; + private Mock _vendorServiceMock = null!; + private Mock _scopeMock = null!; + + [TestInitialize] + public void Setup() + { + _orderReportServiceMock = new Mock(); + var productsReportServiceMock = new Mock(); + var 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()); + _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 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); + } +} 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..489b8861c --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs @@ -0,0 +1,224 @@ +#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.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +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. +/// +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( + 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); + } + + 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 +} From a42a51cfd2f8759e5256107d8aafc9e2504e2e0e Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 15:54:02 +0200 Subject: [PATCH 06/17] feat(arch001): add BaseReportsController NeverSoldReport + CountryReport regions --- .../Controllers/BaseReportsControllerTests.cs | 44 +++++++++ .../Controllers/BaseReportsController.cs | 92 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs index 41d8445fe..1e292b1ff 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs @@ -173,4 +173,48 @@ public async Task BestsellersReportList_CanIncludeProductFalse_DropsRow() 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); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs index 489b8861c..8553041f9 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs @@ -221,4 +221,96 @@ public virtual async Task BestsellersReportList(DataSourceRequest } #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 } From 8eda024fc39cfa82d07f19708408b55420e56226 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 15:59:54 +0200 Subject: [PATCH 07/17] feat(arch001): add vendorId parameter to ICustomerReportViewModelService, retire Vendor's 14 duplicate report models --- .../CustomerReportViewModelServiceTests.cs | 80 +++++++++++++++++++ .../ICustomerReportViewModelService.cs | 6 +- .../CustomerReportViewModelService.cs | 6 +- .../Report/BestCustomerReportLineModel.cs | 18 ----- .../Models/Report/BestCustomersReportModel.cs | 22 ----- .../Report/BestsellersReportLineModel.cs | 18 ----- .../Models/Report/BestsellersReportModel.cs | 29 ------- .../Models/Report/CountryReportLineModel.cs | 16 ---- .../Models/Report/CountryReportModel.cs | 22 ----- .../Models/Report/CustomerReportsModel.cs | 22 ----- .../Models/Report/LowStockProductModel.cs | 21 ----- .../Models/Report/NeverSoldReportLineModel.cs | 12 --- .../Models/Report/NeverSoldReportModel.cs | 16 ---- .../OrderAverageReportLineSummaryModel.cs | 22 ----- .../Report/OrderIncompleteReportLineModel.cs | 19 ----- .../Report/OrderPeriodReportLineModel.cs | 16 ---- .../RegisteredCustomerReportLineModel.cs | 13 --- 17 files changed, 88 insertions(+), 270 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/CustomerReportViewModelServiceTests.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/BestCustomerReportLineModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/BestCustomersReportModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/BestsellersReportLineModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/BestsellersReportModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/CountryReportLineModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/CountryReportModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/CustomerReportsModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/LowStockProductModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/NeverSoldReportLineModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/NeverSoldReportModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/OrderAverageReportLineSummaryModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/OrderIncompleteReportLineModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/OrderPeriodReportLineModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Report/RegisteredCustomerReportLineModel.cs 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/Web/Grand.Web.AdminShared/Interfaces/ICustomerReportViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/ICustomerReportViewModelService.cs index 9fc675faf..c5bde7592 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/ICustomerReportViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/ICustomerReportViewModelService.cs @@ -5,8 +5,10 @@ namespace Grand.Web.AdminShared.Interfaces; public interface ICustomerReportViewModelService { Task PrepareCustomerReportsModel(); - Task> GetReportRegisteredCustomersModel(string storeId); + 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/Services/CustomerReportViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CustomerReportViewModelService.cs index 72813b9e3..2dd5728e1 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CustomerReportViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CustomerReportViewModelService.cs @@ -78,7 +78,7 @@ public virtual async Task PrepareCustomerReportsModel() } public virtual async Task> GetReportRegisteredCustomersModel( - string storeId) + string storeId, string vendorId = "") { var report = new List { new() { @@ -108,7 +108,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 +125,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.Vendor/Models/Report/BestCustomerReportLineModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/BestCustomerReportLineModel.cs deleted file mode 100644 index e54b20ecd..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/BestCustomerReportLineModel.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Report; - -public class BestCustomerReportLineModel : BaseModel -{ - public string CustomerId { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Customers.BestBy.Fields.Customer")] - public string CustomerName { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Customers.BestBy.Fields.OrderTotal")] - public string OrderTotal { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Customers.BestBy.Fields.OrderCount")] - public double OrderCount { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/BestCustomersReportModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/BestCustomersReportModel.cs deleted file mode 100644 index 883b52633..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/BestCustomersReportModel.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Report; - -public class BestCustomersReportModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Reports.Customer.BestBy.StartDate")] - [UIHint("DateNullable")] - public DateTime? StartDate { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Customer.BestBy.EndDate")] - [UIHint("DateNullable")] - public DateTime? EndDate { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Customer.BestBy.PaymentStatus")] - public int PaymentStatusId { get; set; } - - public IList AvailablePaymentStatuses { get; set; } = new List(); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/BestsellersReportLineModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/BestsellersReportLineModel.cs deleted file mode 100644 index fdafce6fa..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/BestsellersReportLineModel.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Report; - -public class BestsellersReportLineModel : BaseModel -{ - public string ProductId { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Bestsellers.Fields.Name")] - public string ProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Bestsellers.Fields.TotalAmount")] - public string TotalAmount { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Bestsellers.Fields.TotalQuantity")] - public double TotalQuantity { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/BestsellersReportModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/BestsellersReportModel.cs deleted file mode 100644 index a844f8352..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/BestsellersReportModel.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Report; - -public class BestsellersReportModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Reports.Bestsellers.Store")] - public string StoreId { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Bestsellers.StartDate")] - [UIHint("DateNullable")] - public DateTime? StartDate { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Bestsellers.EndDate")] - [UIHint("DateNullable")] - public DateTime? EndDate { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Bestsellers.PaymentStatus")] - public int PaymentStatusId { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Bestsellers.BillingCountry")] - public string BillingCountryId { get; set; } - - public IList AvailablePaymentStatuses { get; set; } = new List(); - public IList AvailableCountries { get; set; } = new List(); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/CountryReportLineModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/CountryReportLineModel.cs deleted file mode 100644 index 4f7c58db4..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/CountryReportLineModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Report; - -public class CountryReportLineModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Reports.Country.Fields.CountryName")] - public string CountryName { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Country.Fields.TotalOrders")] - public int TotalOrders { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Country.Fields.SumOrders")] - public string SumOrders { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/CountryReportModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/CountryReportModel.cs deleted file mode 100644 index 94deed08f..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/CountryReportModel.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Report; - -public class CountryReportModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Reports.Country.StartDate")] - [UIHint("DateNullable")] - public DateTime? StartDate { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Country.EndDate")] - [UIHint("DateNullable")] - public DateTime? EndDate { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Country.PaymentStatus")] - public int PaymentStatusId { get; set; } - - public IList AvailablePaymentStatuses { get; set; } = new List(); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/CustomerReportsModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/CustomerReportsModel.cs deleted file mode 100644 index bdea6ab9f..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/CustomerReportsModel.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Report; - -public class CustomerReportsModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Reports.Customer.BestBy.StartDate")] - [UIHint("DateNullable")] - public DateTime? StartDate { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Customer.BestBy.EndDate")] - [UIHint("DateNullable")] - public DateTime? EndDate { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Customer.BestBy.PaymentStatus")] - public int PaymentStatusId { get; set; } - - public IList AvailablePaymentStatuses { get; set; } = new List(); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/LowStockProductModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/LowStockProductModel.cs deleted file mode 100644 index 450118c8b..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/LowStockProductModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Report; - -public class LowStockProductModel : BaseEntityModel -{ - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Name")] - public string Name { get; set; } - - public string Attributes { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ManageInventoryMethod")] - public string ManageInventoryMethod { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Published")] - public bool Published { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/NeverSoldReportLineModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/NeverSoldReportLineModel.cs deleted file mode 100644 index 10b87ce77..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/NeverSoldReportLineModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Report; - -public class NeverSoldReportLineModel : BaseModel -{ - public string ProductId { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.NeverSold.Fields.Name")] - public string ProductName { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/NeverSoldReportModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/NeverSoldReportModel.cs deleted file mode 100644 index d61ed979c..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/NeverSoldReportModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Report; - -public class NeverSoldReportModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Reports.NeverSold.StartDate")] - [UIHint("DateNullable")] - public DateTime? StartDate { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.NeverSold.EndDate")] - [UIHint("DateNullable")] - public DateTime? EndDate { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/OrderAverageReportLineSummaryModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/OrderAverageReportLineSummaryModel.cs deleted file mode 100644 index 0cc738515..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/OrderAverageReportLineSummaryModel.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Report; - -public class OrderAverageReportLineSummaryModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Reports.Average.SumTodayOrders")] - public string SumTodayOrders { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Average.SumThisWeekOrders")] - public string SumThisWeekOrders { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Average.SumThisMonthOrders")] - public string SumThisMonthOrders { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Average.SumThisYearOrders")] - public string SumThisYearOrders { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Average.SumAllTimeOrders")] - public string SumAllTimeOrders { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/OrderIncompleteReportLineModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/OrderIncompleteReportLineModel.cs deleted file mode 100644 index e6e6cf176..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/OrderIncompleteReportLineModel.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Report; - -public class OrderIncompleteReportLineModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Reports.Incomplete.Item")] - public string Item { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Incomplete.Total")] - public string Total { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Incomplete.Count")] - public int Count { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Incomplete.View")] - public string ViewLink { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/OrderPeriodReportLineModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/OrderPeriodReportLineModel.cs deleted file mode 100644 index d918288d3..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/OrderPeriodReportLineModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Report; - -public class OrderPeriodReportLineModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Reports.Period.Name")] - public string Period { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Period.Count")] - public int Count { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Period.Amount")] - public double Amount { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Report/RegisteredCustomerReportLineModel.cs b/src/Web/Grand.Web.Vendor/Models/Report/RegisteredCustomerReportLineModel.cs deleted file mode 100644 index 973cbf057..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Report/RegisteredCustomerReportLineModel.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Report; - -public class RegisteredCustomerReportLineModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Reports.Customers.RegisteredCustomers.Fields.Period")] - public string Period { get; set; } - - [GrandResourceDisplayName("Vendor.Reports.Customers.RegisteredCustomers.Fields.Customers")] - public int Customers { get; set; } -} \ No newline at end of file From 1d89c93b45353abf3b93725872fb04ac0bdd5efa Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 17:01:43 +0200 Subject: [PATCH 08/17] feat(arch001): add BaseReportsController LowStockReport + Customer regions (12/12 shared actions complete) --- .../Controllers/BaseReportsControllerTests.cs | 55 ++++++++++++- .../Controllers/BaseReportsController.cs | 78 +++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs index 1e292b1ff..200b79717 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseReportsControllerTests.cs @@ -18,6 +18,7 @@ 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; @@ -55,6 +56,8 @@ private class TestReportsController( 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!; @@ -64,8 +67,9 @@ private class TestReportsController( public void Setup() { _orderReportServiceMock = new Mock(); - var productsReportServiceMock = new Mock(); - var customerReportViewModelServiceMock = 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(); @@ -97,8 +101,8 @@ public void Setup() _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, + _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, @@ -217,4 +221,47 @@ public async Task CountryReportList_ScopeValuesThreadedIntoQuery() _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/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs index 8553041f9..1203cb59b 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs @@ -13,9 +13,12 @@ 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; @@ -313,4 +316,79 @@ public virtual async Task CountryReportList(DataSourceRequest com } #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 } From 3f0e133ea08ee0ddfe5f8eb5daf1eb9827ae998a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 17:14:50 +0200 Subject: [PATCH 09/17] feat(arch001): add BaseFullReportsController skeleton + Bestsellers-brief overrides + order-report region --- .../BaseFullReportsControllerTests.cs | 206 +++++++++++++++ .../Controllers/BaseFullReportsController.cs | 247 ++++++++++++++++++ 2 files changed, 453 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs 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..522315ea4 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs @@ -0,0 +1,206 @@ +#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.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.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!; + + [TestInitialize] + public void Setup() + { + _orderReportServiceMock = new Mock(); + var productsReportServiceMock = new Mock(); + var 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(); + var 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; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + } + + [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 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); + } +} 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..fad6c84c8 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs @@ -0,0 +1,247 @@ +#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.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. +/// +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 +} From 88d8086cb195cbb060a72578717181331b00d5ad Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 17:19:44 +0200 Subject: [PATCH 10/17] fix(arch001): revert RouteData null-guard from production BaseFullReportsController, fix test's ControllerContext setup instead Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseFullReportsControllerTests.cs | 5 ++++- .../Controllers/BaseFullReportsController.cs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs index 522315ea4..4958bba9d 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs @@ -24,6 +24,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -110,7 +111,9 @@ public void Setup() var requestServicesMock = new Mock(); requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); httpContext.RequestServices = requestServicesMock.Object; - _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + var routeData = new RouteData(); + routeData.Values["area"] = "Admin"; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext, RouteData = routeData }; } [TestMethod] diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs index fad6c84c8..550b0c35a 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs @@ -209,7 +209,7 @@ public virtual async Task OrderIncompleteReportList(DataSourceReq if (!await permissionService.Authorize(StandardPermission.ManageOrders)) return Content(""); - var area = ControllerContext.RouteData?.Values["area"]?.ToString(); + var area = ControllerContext.RouteData.Values["area"]?.ToString(); var model = new List(); var psPending = await orderReportService.GetOrderAverageReportLine(scope.StoreId, ps: PaymentStatus.Pending, From 86379d154c23117c6a136ab170a3ff1e3edaac1b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 17:23:59 +0200 Subject: [PATCH 11/17] feat(arch001): add BaseFullReportsController customer-report region (8/8 Admin/Store-only actions complete) --- .../BaseFullReportsControllerTests.cs | 51 +++++++++++++++++-- .../Controllers/BaseFullReportsController.cs | 34 +++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs index 4958bba9d..61ba7ddb4 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseFullReportsControllerTests.cs @@ -18,6 +18,7 @@ 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; @@ -65,13 +66,15 @@ private class TestFullReportsController( 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(); - var customerReportViewModelServiceMock = 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(); @@ -93,17 +96,17 @@ public void Setup() _scopeMock.Setup(s => s.StoreId).Returns(""); _scopeMock.Setup(s => s.VendorId).Returns(""); _orderServiceMock = new Mock(); - var customerReportServiceMock = 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, + _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); + _orderServiceMock.Object, _customerReportServiceMock.Object, _permissionServiceMock.Object); var httpContext = new DefaultHttpContext(); var urlHelperFactoryMock = new Mock(); @@ -206,4 +209,44 @@ public async Task OrderIncompleteReportList_ManageOrdersAllowed_ReturnsThreeRows 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); + } + + [TestMethod] + public async Task ReportRegisteredCustomersList_PassesScopeStoreIdAndVendorIdToService() + { + _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/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs index 550b0c35a..d3ecfa931 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseFullReportsController.cs @@ -17,6 +17,7 @@ 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; @@ -244,4 +245,37 @@ public virtual async Task OrderIncompleteReportList(DataSourceReq } #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 } From 94c311b09c6e523ef7d4cf521cdf23c3bad25a52 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 17:34:15 +0200 Subject: [PATCH 12/17] feat(arch001): cut Admin/Store/Vendor ReportsController over to thin subclasses Also fixes Vendor's Views/Reports/_ViewImports.cshtml and Customer.cshtml, which still referenced the 14 report models Task 11 deleted from Grand.Web.Vendor.Models.Report - required for Grand.Web.Vendor to compile again (brief Step 7). --- .../ReportsControllerRoutingTests.cs | 53 ++ .../Controllers/ReportsController.cs | 678 ++---------------- .../Controllers/ReportsController.cs | 658 +---------------- .../Vendor/Views/Reports/Customer.cshtml | 18 +- .../Areas/Vendor/Views/_ViewImports.cshtml | 10 +- .../Controllers/ReportsController.cs | 415 ++--------- 6 files changed, 205 insertions(+), 1627 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/ReportsControllerRoutingTests.cs 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/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.Store/Controllers/ReportsController.cs b/src/Web/Grand.Web.Store/Controllers/ReportsController.cs index 2461ad3a1..8e988278f 100644 --- a/src/Web/Grand.Web.Store/Controllers/ReportsController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ReportsController.cs @@ -1,639 +1,55 @@ -using Grand.Business.Core.Interfaces.Catalog.Prices; +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.Controllers; 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.DataSource; -using Grand.Web.Common.Extensions; +using Grand.Web.Common.Filters; using Grand.Web.Common.Localization; using Grand.Web.Common.Security.Authorization; using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; namespace Grand.Web.Store.Controllers; +// Reduced to a thin subclass of BaseFullReportsController (ARCH-001 Reports consolidation). No +// overrides needed - Store has neither the CountryReport/Customer ManageCustomers gate nor +// PopularSearchTermsReport; every remaining Admin/Store difference is already expressed inside the +// shared bases via IReportDataScope. Same pattern as PaymentTransactionController/OrderController. +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] [PermissionAuthorize(PermissionSystemName.Reports)] -public class ReportsController : BaseStoreController -{ - 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 IStockQuantityService _stockQuantityService; - private readonly IStoreService _storeService; - private readonly ITranslationService _translationService; - 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, - IDateTimeService dateTimeService, - IOrderStatusService orderStatusService, - ICurrencyService currencyService, - IEnumTranslationService enumTranslationService) - { - _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; - _dateTimeService = dateTimeService; - _orderStatusService = orderStatusService; - _currencyService = currencyService; - _enumTranslationService = enumTranslationService; - } - - [NonAction] - protected async Task GetBestsellersBriefReportModel(int pageIndex, - int pageSize, int orderBy) - { - var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var items = await _orderReportService.BestSellersReport( - storeId, - 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(); - - var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - 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 = "" }); - - return View(model); - } - - [HttpPost] - public async Task BestsellersReportList(DataSourceRequest command, BestsellersReportModel model) - { - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - 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, - 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 storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var model = await _orderReportService.GetOrderByTimeReport(storeId, 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 storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var items = await _orderReportService.ProductsNeverSold(storeId, "", - 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 storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var report = new List { - await _orderReportService.OrderAverageReport(storeId, (int)OrderStatusSystem.Pending), - await _orderReportService.OrderAverageReport(storeId, (int)OrderStatusSystem.Processing), - await _orderReportService.OrderAverageReport(storeId, (int)OrderStatusSystem.Complete), - await _orderReportService.OrderAverageReport(storeId, (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(""); - - var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - //load orders - var orders = await _orderService.SearchOrders( - 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); - } - - [HttpPost] - public async Task OrderIncompleteReportList(DataSourceRequest command) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Content(""); - - var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var model = new List(); - //not paid - var psPending = - await _orderReportService.GetOrderAverageReportLine(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 = Constants.AreaStore }) - }); - //not shipped - var ssPending = - await _orderReportService.GetOrderAverageReportLine(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 = Constants.AreaStore }) - }); - //pending - var osPending = await _orderReportService.GetOrderAverageReportLine(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 = Constants.AreaStore }) - }); - - var gridModel = new DataSourceResult { - Data = model, - Total = model.Count - }; - - return Json(gridModel); - } - - public async Task CountryReport() - { - 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); - } - - [HttpPost] - public 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 storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var items = await _orderReportService.GetCountryReport( - storeId, - 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); - } - - #region Low stock reports - - public IActionResult LowStockReport() - { - return View(); - } - - [HttpPost] - public async Task LowStockReportList(DataSourceRequest command) - { - var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var lowStockProducts = await _productsReportService.LowStockProducts(storeId: storeId); - - 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() - { - var model = await _customerReportViewModelService.PrepareCustomerReportsModel(); - return View(model); - } - - [HttpPost] - public async Task ReportBestCustomersByOrderTotalList(DataSourceRequest command, - BestCustomersReportModel model) - { - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - 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) - { - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - 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 storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var model = await _customerReportViewModelService.GetReportRegisteredCustomersModel(storeId); - var gridModel = new DataSourceResult { - Data = model, - Total = model.Count - }; - - return Json(gridModel); - } - - [HttpPost] - public async Task ReportCustomerTimeChart(DataSourceRequest command, DateTime? startDate, - DateTime? endDate) - { - var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var model = await _customerReportService.GetCustomerByTimeReport(storeId, startDate, endDate); - var gridModel = new DataSourceResult { - Data = model - }; - return Json(gridModel); - } - - #endregion -} \ No newline at end of file +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) + : BaseFullReportsController(orderReportService, productsReportService, customerReportViewModelService, + priceFormatter, currencyService, productService, productAttributeFormatter, stockQuantityService, + translationService, storeService, countryService, vendorService, dateTimeService, orderStatusService, + enumTranslationService, contextAccessor, scope, orderService, customerReportService, permissionService); diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Reports/Customer.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Reports/Customer.cshtml index e6703b655..52d355653 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Reports/Customer.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Reports/Customer.cshtml @@ -28,14 +28,14 @@
- - + +
- - + +
@@ -55,9 +55,9 @@
- +
- +
@@ -138,9 +138,9 @@ function additionalData() { var data = { - StartDate: $('#@Html.IdFor(model => model.StartDate)').val(), - EndDate: $('#@Html.IdFor(model => model.EndDate)').val(), - PaymentStatusId: $('#PaymentStatusId').val() + StartDate: $('#@Html.IdFor(model => model.BestCustomersByOrderTotal.StartDate)').val(), + EndDate: $('#@Html.IdFor(model => model.BestCustomersByOrderTotal.EndDate)').val(), + PaymentStatusId: $('#@Html.IdFor(model => model.BestCustomersByOrderTotal.PaymentStatusId)').val() }; addAntiForgeryToken(data); return data; diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml index 65a6de0cc..04df48722 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml @@ -50,6 +50,14 @@ @using Grand.Web.Vendor.Models.MerchandiseReturn; @using Grand.Web.Vendor.Models.Vendor; @using Grand.Web.Vendor.Models.VendorReview; -@using Grand.Web.Vendor.Models.Report; +@* Reports views bind to Grand.Web.AdminShared's report model families (ARCH-001 Reports + consolidation, Task 11/12) - Grand.Web.Vendor.Models.Report is deleted entirely, so alias just + the 4 types Vendor's 5 Reports views actually reference (same alias-not-blanket-import rationale + as the Order/Shipment block above - Models.Orders also holds MerchandiseReturnModel, which + Vendor's MerchandiseReturn views still bind to their own Vendor.Models.MerchandiseReturn type). *@ +@using BestsellersReportModel = Grand.Web.AdminShared.Models.Orders.BestsellersReportModel; +@using CountryReportModel = Grand.Web.AdminShared.Models.Orders.CountryReportModel; +@using NeverSoldReportModel = Grand.Web.AdminShared.Models.Orders.NeverSoldReportModel; +@using CustomerReportsModel = Grand.Web.AdminShared.Models.Customers.CustomerReportsModel; @inject LocService Loc @inject IEnumTranslationService EnumTranslationService \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Controllers/ReportsController.cs b/src/Web/Grand.Web.Vendor/Controllers/ReportsController.cs index 5bc4b57b8..a86b796fe 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ReportsController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ReportsController.cs @@ -1,391 +1,56 @@ -using Grand.Business.Core.Interfaces.Catalog.Prices; +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.Domain.Permissions; -using Grand.Domain.Payments; using Grand.Infrastructure; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; using Grand.Web.Common.Localization; using Grand.Web.Common.Security.Authorization; using Grand.Web.Vendor.Extensions; -using Grand.Web.Vendor.Models.Report; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; namespace Grand.Web.Vendor.Controllers; +// Reduced to a thin subclass of BaseReportsController — deliberately NOT BaseFullReportsController +// (ARCH-001 Reports consolidation; Global Constraint 2/3). Vendor's 8-action surface is exactly the +// 12 shared actions minus the 4 it never had among the 12 (ReportOrderPeriodList/ReportOrderTimeChart/ +// OrderAverageReportList/ReportLatestOrder/OrderIncompleteReportList/ReportBestCustomersByNumberOf- +// OrdersList/ReportRegisteredCustomersList/ReportCustomerTimeChart are all on BaseFullReportsController, +// never inherited here) - confirmed against the original controller's 8 actions read during plan +// research. No overrides needed: Vendor never had the ManageCustomers/ManageOrders inline checks on +// any of the 12 shared actions, which is exactly BaseReportsController's own check-free default. +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] [PermissionAuthorize(PermissionSystemName.Reports)] -public class ReportsController : BaseVendorController -{ - private readonly ICountryService _countryService; - private readonly ICurrencyService _currencyService; - private readonly ICustomerReportService _customerReportService; - private readonly ICustomerService _customerService; - private readonly IDateTimeService _dateTimeService; - private readonly IOrderReportService _orderReportService; - private readonly IOrderStatusService _orderStatusService; - private readonly IPriceFormatter _priceFormatter; - private readonly IProductAttributeFormatter _productAttributeFormatter; - private readonly IProductService _productService; - private readonly IProductsReportService _productsReportService; - private readonly IStockQuantityService _stockQuantityService; - private readonly ITranslationService _translationService; - private readonly IContextAccessor _contextAccessor; - private readonly IEnumTranslationService _enumTranslationService; - public ReportsController(IOrderReportService orderReportService, - IProductsReportService productsReportService, - ICustomerReportService customerReportService, - IContextAccessor contextAccessor, - IPriceFormatter priceFormatter, - IProductService productService, - IProductAttributeFormatter productAttributeFormatter, - IStockQuantityService stockQuantityService, - ITranslationService translationService, - ICountryService countryService, - IDateTimeService dateTimeService, - IOrderStatusService orderStatusService, - ICurrencyService currencyService, - ICustomerService customerService, - IEnumTranslationService enumTranslationService) - { - _orderReportService = orderReportService; - _productsReportService = productsReportService; - _customerReportService = customerReportService; - _contextAccessor = contextAccessor; - _priceFormatter = priceFormatter; - _productService = productService; - _productAttributeFormatter = productAttributeFormatter; - _stockQuantityService = stockQuantityService; - _translationService = translationService; - _countryService = countryService; - _dateTimeService = dateTimeService; - _orderStatusService = orderStatusService; - _currencyService = currencyService; - _customerService = customerService; - _enumTranslationService = enumTranslationService; - } - - [NonAction] - private async Task GetBestsellersBriefReportModel(int pageIndex, - int pageSize, int orderBy) - { - var items = await _orderReportService.BestSellersReport( - vendorId: _contextAccessor.WorkContext.CurrentVendor.Id, - 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; - } - - [HttpPost] - public async Task BestsellersBriefReportByQuantityList(DataSourceRequest command) - { - var gridModel = await GetBestsellersBriefReportModel(command.Page - 1, - command.PageSize, 1); - - return Json(gridModel); - } - - [HttpPost] - public async Task BestsellersBriefReportByAmountList(DataSourceRequest command) - { - var gridModel = await GetBestsellersBriefReportModel(command.Page - 1, - command.PageSize, 2); - - return Json(gridModel); - } - - public async Task BestsellersReport() - { - var model = new BestsellersReportModel { - //payment statuses - AvailablePaymentStatuses = _enumTranslationService.ToSelectList(PaymentStatus.Pending, false).ToList() - }; - model.AvailablePaymentStatuses.Insert(0, - new SelectListItem { Text = _translationService.GetResource("Vendor.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("Vendor.Common.All"), Value = "" }); - - 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); - - var paymentStatus = model.PaymentStatusId > 0 ? (PaymentStatus?)model.PaymentStatusId : null; - - var items = await _orderReportService.BestSellersReport( - createdFromUtc: startDateValue, - createdToUtc: endDateValue, - ps: paymentStatus, - billingCountryId: model.BillingCountryId, - orderBy: 2, - vendorId: _contextAccessor.WorkContext.CurrentVendor.Id, - 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; - if (_contextAccessor.WorkContext.CurrentVendor != null) - { - if (product != null && _contextAccessor.WorkContext.HasAccessToProduct(product)) - result.Add(m); - } - else - { - result.Add(m); - } - } - - var gridModel = new DataSourceResult { - Data = result, - Total = items.TotalCount - }; - - 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("", _contextAccessor.WorkContext.CurrentVendor.Id, - 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); - } - - public IActionResult CountryReport() - { - var model = new CountryReportModel { - //payment statuses - AvailablePaymentStatuses = _enumTranslationService.ToSelectList(PaymentStatus.Pending, false).ToList() - }; - - model.AvailablePaymentStatuses.Insert(0, - new SelectListItem { Text = _translationService.GetResource("Vendor.Common.All"), Value = "" }); - - return View(model); - } - - [HttpPost] - public async Task CountryReportList(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); - - var paymentStatus = model.PaymentStatusId > 0 ? (PaymentStatus?)model.PaymentStatusId : null; - - var items = await _orderReportService.GetCountryReport( - vendorId: _contextAccessor.WorkContext.CurrentVendor.Id, - 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); - } - - #region Low stock reports - - public IActionResult LowStockReport() - { - return View(); - } - - [HttpPost] - public async Task LowStockReportList(DataSourceRequest command) - { - var lowStockProducts = await _productsReportService.LowStockProducts(_contextAccessor.WorkContext.CurrentVendor.Id); - - 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() - { - var model = new CustomerReportsModel(); - var status = await _orderStatusService.GetAll(); - - model.AvailablePaymentStatuses = _enumTranslationService.ToSelectList(PaymentStatus.Pending, false).ToList(); - model.AvailablePaymentStatuses.Insert(0, - new SelectListItem { Text = _translationService.GetResource("Vendor.Common.All"), Value = "" }); - - return View(model); - } - - [HttpPost] - public async Task ReportBestCustomersByOrderTotalList(DataSourceRequest command, - BestCustomersReportModel 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 paymentStatus = model.PaymentStatusId > 0 ? (PaymentStatus?)model.PaymentStatusId : null; - - var items = await _customerReportService.GetBestCustomersReport("", _contextAccessor.WorkContext.CurrentVendor.Id, startDateValue, - endDateValue, - null, paymentStatus, null, 2, command.Page - 1, command.PageSize); - - var report = new List(); - foreach (var x in items) - { - var m = new BestCustomerReportLineModel { - CustomerId = x.CustomerId, - OrderTotal = - _priceFormatter.FormatPrice(x.OrderTotal, await _currencyService.GetPrimaryStoreCurrency()), - OrderCount = x.OrderCount - }; - var customer = await _customerService.GetCustomerById(x.CustomerId); - if (customer != null) - m.CustomerName = !string.IsNullOrEmpty(customer.Email) - ? customer.Email - : _translationService.GetResource("Vendor.Customers.Guest"); - report.Add(m); - } - - var gridModel = new DataSourceResult { - Data = report, - Total = items.TotalCount - }; - - return Json(gridModel); - } - - #endregion -} \ No newline at end of file +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) + : BaseReportsController(orderReportService, productsReportService, customerReportViewModelService, + priceFormatter, currencyService, productService, productAttributeFormatter, stockQuantityService, + translationService, storeService, countryService, vendorService, dateTimeService, orderStatusService, + enumTranslationService, contextAccessor, scope); From b6b91f6b69714eb6b10e52ebb7159e76a7556feb Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 28 Aug 2026 20:18:18 +0200 Subject: [PATCH 13/17] feat(arch001): migrate Admin+Store Reports views into Grand.Web.AdminShared, fix Store's dead admin-widget tag, rebind Vendor's Customer.cshtml onto the shared nested model Co-Authored-By: Claude Sonnet 5 --- .../Admin/Views/Reports/CountryReport.cshtml | 153 ---------------- .../Areas/Admin/Views/Reports/Customer.cshtml | 53 ------ .../Admin/Views/Reports/LowStockReport.cshtml | 88 ---------- ... CustomerReportRegisteredComponent.cshtml} | 3 +- ...ReportBestCustomersByNumberOfOrders.cshtml | 135 -------------- .../Partials/WidgetZone.Bestsellers.cshtml | 2 + .../Partials/WidgetZone.Country.cshtml | 2 + ...Zone.CustomerByNumberOfOrdersBottom.cshtml | 2 + ...getZone.CustomerByNumberOfOrdersTop.cshtml | 2 + ...dgetZone.CustomerByOrderTotalBottom.cshtml | 2 + .../WidgetZone.CustomerByOrderTotalTop.cshtml | 2 + .../Partials/WidgetZone.DetailsTabs.cshtml | 2 + .../Partials/WidgetZone.NeverSold.cshtml | 2 + .../BestsellersBriefReportByAmount.cshtml | 9 +- .../BestsellersBriefReportByQuantity.cshtml | 9 +- .../Reports/BestsellersReport.cshtml | 42 +++-- .../AdminShared}/Reports/CountryReport.cshtml | 14 +- .../AdminShared}/Reports/Customer.cshtml | 11 +- .../Reports/LowStockReport.cshtml | 16 +- .../Reports/NeverSoldReport.cshtml | 16 +- .../Customer.TabBestByNumberOfOrders.cshtml | 4 +- .../Customer.TabBestByOrderTotal.cshtml | 4 +- .../Customer.TabRegisteredCustomers.cshtml | 4 + ...ReportBestCustomersByNumberOfOrders.cshtml | 15 +- .../ReportBestCustomersByOrderTotal.cshtml | 15 +- .../Views/AdminShared/_ViewImports.cshtml | 1 + .../BestsellersBriefReportByAmount.cshtml | 57 ------ .../BestsellersBriefReportByQuantity.cshtml | 57 ------ .../Views/Reports/BestsellersReport.cshtml | 164 ------------------ .../Views/Reports/NeverSoldReport.cshtml | 125 ------------- .../Customer.TabBestByNumberOfOrders.cshtml | 11 -- .../Customer.TabBestByOrderTotal.cshtml | 11 -- .../Customer.TabRegisteredCustomers.cshtml | 4 - .../CustomerReportRegisteredComponent.cshtml | 8 + .../ReportBestCustomersByOrderTotal.cshtml | 134 -------------- .../Partials/WidgetZone.Bestsellers.cshtml | 5 + .../Partials/WidgetZone.Country.cshtml | 5 + ...Zone.CustomerByNumberOfOrdersBottom.cshtml | 5 + ...getZone.CustomerByNumberOfOrdersTop.cshtml | 5 + ...dgetZone.CustomerByOrderTotalBottom.cshtml | 5 + .../WidgetZone.CustomerByOrderTotalTop.cshtml | 5 + .../Partials/WidgetZone.DetailsTabs.cshtml | 5 + .../Partials/WidgetZone.NeverSold.cshtml | 5 + 43 files changed, 159 insertions(+), 1060 deletions(-) delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/CountryReport.cshtml delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Customer.cshtml delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/LowStockReport.cshtml rename src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/{Customer.TabRegisteredCustomers.cshtml => CustomerReportRegisteredComponent.cshtml} (66%) delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/ReportBestCustomersByNumberOfOrders.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.Bestsellers.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.Country.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByNumberOfOrdersBottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByNumberOfOrdersTop.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByOrderTotalBottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.CustomerByOrderTotalTop.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.DetailsTabs.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Reports/Partials/WidgetZone.NeverSold.cshtml rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Reports/BestsellersBriefReportByAmount.cshtml (87%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Reports/BestsellersBriefReportByQuantity.cshtml (87%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Reports/BestsellersReport.cshtml (83%) rename src/Web/{Grand.Web.Store/Areas/Store/Views => Grand.Web.AdminShared/Views/AdminShared}/Reports/CountryReport.cshtml (94%) rename src/Web/{Grand.Web.Store/Areas/Store/Views => Grand.Web.AdminShared/Views/AdminShared}/Reports/Customer.cshtml (88%) rename src/Web/{Grand.Web.Store/Areas/Store/Views => Grand.Web.AdminShared/Views/AdminShared}/Reports/LowStockReport.cshtml (84%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Reports/NeverSoldReport.cshtml (90%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Reports/Partials/Customer.TabBestByNumberOfOrders.cshtml (90%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Reports/Partials/Customer.TabBestByOrderTotal.cshtml (90%) create mode 100644 src/Web/Grand.Web.AdminShared/Views/AdminShared/Reports/Partials/Customer.TabRegisteredCustomers.cshtml rename src/Web/{Grand.Web.Store/Areas/Store/Views => Grand.Web.AdminShared/Views/AdminShared}/Reports/Partials/ReportBestCustomersByNumberOfOrders.cshtml (93%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Reports/Partials/ReportBestCustomersByOrderTotal.cshtml (93%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/BestsellersBriefReportByAmount.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/BestsellersBriefReportByQuantity.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/BestsellersReport.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/NeverSoldReport.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/Customer.TabBestByNumberOfOrders.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/Customer.TabBestByOrderTotal.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/Customer.TabRegisteredCustomers.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/CustomerReportRegisteredComponent.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/ReportBestCustomersByOrderTotal.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/WidgetZone.Bestsellers.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/WidgetZone.Country.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/WidgetZone.CustomerByNumberOfOrdersBottom.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/WidgetZone.CustomerByNumberOfOrdersTop.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/WidgetZone.CustomerByOrderTotalBottom.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/WidgetZone.CustomerByOrderTotalTop.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/WidgetZone.DetailsTabs.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Reports/Partials/WidgetZone.NeverSold.cshtml 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/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(); +} - +