From cd62b2fa10f0f972954f7c276f4368f10d485446 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 25 Aug 2026 20:27:10 +0200 Subject: [PATCH 01/12] feat(arch001): add IAdminDataScope implementations (Store/Vendor/Routed) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../RoutedShipmentDataScopeTests.cs | 81 +++++++++++++++++++ .../StoreShipmentDataScopeTests.cs | 51 ++++++++++++ .../VendorShipmentDataScopeTests.cs | 67 +++++++++++++++ .../Services/RoutedShipmentDataScope.cs | 50 ++++++++++++ .../Services/StoreShipmentDataScope.cs | 30 +++++++ .../Services/VendorShipmentDataScope.cs | 37 +++++++++ .../Startup/StartupApplication.cs | 10 +++ 7 files changed, 326 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedShipmentDataScopeTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/StoreShipmentDataScopeTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/VendorShipmentDataScopeTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/RoutedShipmentDataScope.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/StoreShipmentDataScope.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/VendorShipmentDataScope.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedShipmentDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedShipmentDataScopeTests.cs new file mode 100644 index 000000000..1f01eaf51 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedShipmentDataScopeTests.cs @@ -0,0 +1,81 @@ +#nullable enable + +using Grand.Domain.Customers; +using Grand.Domain.Shipping; +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 RoutedShipmentDataScopeTests +{ + private const string StaffStoreId = "store-1"; + private const string VendorId = "vendor-1"; + + private GlobalAdminDataScope _adminScope = null!; + private StoreShipmentDataScope _storeScope = null!; + private VendorShipmentDataScope _vendorScope = null!; + + [TestInitialize] + public void Setup() + { + var workContext = new Mock(); + workContext.Setup(x => x.CurrentCustomer).Returns(new Customer { StaffStoreId = StaffStoreId }); + workContext.Setup(x => x.CurrentVendor).Returns(new Vendor { Id = VendorId }); + var contextAccessor = new Mock(); + contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); + + _adminScope = new GlobalAdminDataScope(); + _storeScope = new StoreShipmentDataScope(contextAccessor.Object); + _vendorScope = new VendorShipmentDataScope(contextAccessor.Object); + } + + private RoutedShipmentDataScope 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 RoutedShipmentDataScope(httpContextAccessor.Object, _adminScope, _storeScope, _vendorScope); + } + + [TestMethod] + public void AdminArea_ResolvesToAdminScope() + { + var resolver = ResolverForArea("Admin"); + Assert.IsNull(resolver.DefaultStoreId); + Assert.IsNull(resolver.DefaultVendorId); + } + + [TestMethod] + public void StoreArea_ResolvesToStoreScope() + { + var resolver = ResolverForArea("Store"); + Assert.AreEqual(StaffStoreId, resolver.DefaultStoreId); + Assert.IsNull(resolver.DefaultVendorId); + } + + [TestMethod] + public void VendorArea_ResolvesToVendorScope() + { + var resolver = ResolverForArea("Vendor"); + Assert.AreEqual("Vendor", resolver.ResourceKeyPrefix); + Assert.AreEqual(VendorId, resolver.DefaultVendorId); + Assert.IsFalse(resolver.ShowStoreSelector); + } + + [TestMethod] + public void UnrecognizedOrMissingArea_ThrowsFailClosed() + { + var resolver = ResolverForArea("Vue"); + Assert.Throws(() => _ = resolver.ResourceKeyPrefix); + + var resolverNoArea = ResolverForArea(null); + Assert.Throws(() => _ = resolverNoArea.ResourceKeyPrefix); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreShipmentDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreShipmentDataScopeTests.cs new file mode 100644 index 000000000..3fc769762 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreShipmentDataScopeTests.cs @@ -0,0 +1,51 @@ +using Grand.Domain.Customers; +using Grand.Domain.Shipping; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class StoreShipmentDataScopeTests +{ + private static StoreShipmentDataScope 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 StoreShipmentDataScope(contextAccessorMock.Object); + } + + [TestMethod] + public async Task HasAccess_MatchingStoreId_True() + { + var scope = Build("store-1"); + Assert.IsTrue(await scope.HasAccess(new Shipment { StoreId = "store-1" })); + } + + [TestMethod] + public async Task HasAccess_MismatchedStoreId_False() + { + var scope = Build("store-1"); + Assert.IsFalse(await scope.HasAccess(new Shipment { StoreId = "store-2" })); + } + + [TestMethod] + public async Task HasAccess_NullEntity_False() + { + var scope = Build("store-1"); + Assert.IsFalse(await scope.HasAccess(null)); + } + + [TestMethod] + public void DefaultStoreId_ReturnsStaffStoreId() + { + var scope = Build("store-1"); + Assert.AreEqual("store-1", scope.DefaultStoreId); + Assert.IsNull(scope.DefaultVendorId); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/VendorShipmentDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/VendorShipmentDataScopeTests.cs new file mode 100644 index 000000000..030c44194 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/VendorShipmentDataScopeTests.cs @@ -0,0 +1,67 @@ +using Grand.Domain.Orders; +using Grand.Domain.Shipping; +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 VendorShipmentDataScopeTests +{ + private static VendorShipmentDataScope 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 VendorShipmentDataScope(contextAccessorMock.Object); + } + + [TestMethod] + public async Task HasAccess_MatchingVendorId_True() + { + var scope = Build("vendor-A"); + Assert.IsTrue(await scope.HasAccess(new Shipment { VendorId = "vendor-A" })); + } + + [TestMethod] + public async Task HasAccess_MismatchedVendorId_False() + { + var scope = Build("vendor-A"); + Assert.IsFalse(await scope.HasAccess(new Shipment { VendorId = "vendor-B" })); + } + + [TestMethod] + public async Task HasAccess_NullEntity_False() + { + var scope = Build("vendor-A"); + Assert.IsFalse(await scope.HasAccess(null)); + } + + [TestMethod] + public void FilterOrderItems_MixedVendorOrder_ReturnsOnlyOwnItems() + { + var scope = Build("vendor-A"); + var itemA1 = new OrderItem { Id = "i1", VendorId = "vendor-A" }; + var itemB = new OrderItem { Id = "i2", VendorId = "vendor-B" }; + var itemA2 = new OrderItem { Id = "i3", VendorId = "vendor-A" }; + + var filtered = scope.FilterOrderItems([itemA1, itemB, itemA2]).ToList(); + + CollectionAssert.AreEqual(new[] { itemA1, itemA2 }, filtered); + } + + [TestMethod] + public void ScopeDefaults_VendorScoped() + { + var scope = Build("vendor-A"); + Assert.IsNull(scope.DefaultStoreId); + Assert.AreEqual("vendor-A", scope.DefaultVendorId); + Assert.AreEqual("Vendor", scope.ResourceKeyPrefix); + Assert.IsFalse(scope.ShowStoreSelector); + Assert.IsFalse(scope.CanFeatureOnHomepage); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/RoutedShipmentDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/RoutedShipmentDataScope.cs new file mode 100644 index 000000000..30a916a64 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/RoutedShipmentDataScope.cs @@ -0,0 +1,50 @@ +#nullable enable + +using Grand.Domain.Orders; +using Grand.Domain.Shipping; +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) loads all three hosts 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 RoutedShipmentDataScope( + IHttpContextAccessor httpContextAccessor, + GlobalAdminDataScope adminScope, + StoreShipmentDataScope storeScope, + VendorShipmentDataScope vendorScope) : IAdminDataScope +{ + private IAdminDataScope 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( + $"RoutedShipmentDataScope: unrecognized or missing area '{area}'.") + }; + } + } + + public Task HasAccess(Shipment entity) => Resolved.HasAccess(entity); + public Task CanView(Shipment entity) => Resolved.CanView(entity); + public IEnumerable FilterOrderItems(IEnumerable orderItems) => + Resolved.FilterOrderItems(orderItems); + public string? DefaultStoreId => Resolved.DefaultStoreId; + public string ResourceKeyPrefix => Resolved.ResourceKeyPrefix; + public bool ShowStoreSelector => Resolved.ShowStoreSelector; + public string? DefaultVendorId => Resolved.DefaultVendorId; + public bool CanFeatureOnHomepage => Resolved.CanFeatureOnHomepage; +} diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreShipmentDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreShipmentDataScope.cs new file mode 100644 index 000000000..6a6ffeb21 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/StoreShipmentDataScope.cs @@ -0,0 +1,30 @@ +#nullable enable + +using Grand.Domain.Shipping; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Store's . Bespoke, not the generic +/// : Shipment is a plain +/// with a single StoreId field, not IStoreLinkEntity (no Stores/ +/// LimitedToStores list), so the generic class's where TEntity : BaseEntity, +/// IStoreLinkEntity constraint doesn't apply. Mirrors Store's original controller's +/// shipment.StoreId != StaffStoreId check, repeated at every action site in that file. +/// No override: Store's original code has one uniform check for both +/// viewing and mutating, unlike Category/Product's loose/strict split. +/// +public class StoreShipmentDataScope(IContextAccessor contextAccessor) : IAdminDataScope +{ + public Task HasAccess(Shipment entity) => + Task.FromResult(entity is not null && + entity.StoreId == contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + + public string? DefaultStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + public string ResourceKeyPrefix => "Admin"; + public bool ShowStoreSelector => true; + public string? DefaultVendorId => null; + public bool CanFeatureOnHomepage => true; // unused for Shipment; required interface member +} diff --git a/src/Web/Grand.Web.AdminShared/Services/VendorShipmentDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/VendorShipmentDataScope.cs new file mode 100644 index 000000000..edaa95d3a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/VendorShipmentDataScope.cs @@ -0,0 +1,37 @@ +#nullable enable + +using Grand.Domain.Orders; +using Grand.Domain.Shipping; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Vendor's . Bespoke: ownership is a flat +/// VendorId field directly on the entity, simpler than Order's child-collection +/// ownership — ports the existing HasAccessToShipment/HasAccessToOrderItem +/// extension methods (Grand.Web.Vendor/Extensions/HasAccess.cs) inline, the same way +/// / do — not imported +/// directly, since Grand.Web.Vendor already references Grand.Web.AdminShared and +/// a reference the other way would be circular. Also overrides , +/// reusing the interface member the Order phase already added: ports Vendor's original +/// order.OrderItems.Where(HasAccessToOrderItem) filter (used when building the +/// AddShipment order-item picker) so a vendor can only ship its own line items on a +/// mixed-vendor order. +/// +public class VendorShipmentDataScope(IContextAccessor contextAccessor) : IAdminDataScope +{ + public Task HasAccess(Shipment entity) => + Task.FromResult(entity is not null && + entity.VendorId == contextAccessor.WorkContext.CurrentVendor.Id); + + public IEnumerable FilterOrderItems(IEnumerable orderItems) => + orderItems.Where(i => i.VendorId == contextAccessor.WorkContext.CurrentVendor.Id); + + public string? DefaultStoreId => null; + public string ResourceKeyPrefix => "Vendor"; + public bool ShowStoreSelector => false; + public string? DefaultVendorId => contextAccessor.WorkContext.CurrentVendor.Id; + public bool CanFeatureOnHomepage => false; +} diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index cd4355fcc..b3d250217 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -2,6 +2,7 @@ using elFinder.Net.Drivers.FileSystem.Extensions; using Grand.Domain.Catalog; using Grand.Domain.Orders; +using Grand.Domain.Shipping; using Grand.Infrastructure; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Services; @@ -90,6 +91,15 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped, RoutedOrderDataScope>(); + + // IAdminDataScope: registered once here for the same reason as Order above — see + // RoutedShipmentDataScope's doc comment. Admin reuses the generic GlobalAdminDataScope + // unmodified (no Sales-Manager restriction on Shipment); Store/Vendor are bespoke because + // Shipment isn't IStoreLinkEntity and Vendor ownership is a flat VendorId field. + services.AddScoped>(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped, RoutedShipmentDataScope>(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) From a236bc50a38a00a13b058ebc5295a1935135455d Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 25 Aug 2026 20:31:15 +0200 Subject: [PATCH 02/12] refactor(arch001): move Vendor's lighter Shipment models into AdminShared, fix _ViewImports --- .../Orders}/ShipmentAdminCommentModel.cs | 4 +- .../Orders}/ShipmentDeliveryDateModel.cs | 4 +- .../Orders}/ShipmentShippedDateModel.cs | 4 +- .../Models/Orders}/ShipmentTrackingModel.cs | 4 +- .../Areas/Vendor/Views/_ViewImports.cshtml | 24 ++-- .../Models/Shipment/AddShipmentModel.cs | 19 --- .../Models/Shipment/ShipmentListModel.cs | 35 ----- .../Models/Shipment/ShipmentModel.cs | 134 ------------------ 8 files changed, 23 insertions(+), 205 deletions(-) rename src/Web/{Grand.Web.Vendor/Models/Shipment => Grand.Web.AdminShared/Models/Orders}/ShipmentAdminCommentModel.cs (53%) rename src/Web/{Grand.Web.Vendor/Models/Shipment => Grand.Web.AdminShared/Models/Orders}/ShipmentDeliveryDateModel.cs (52%) rename src/Web/{Grand.Web.Vendor/Models/Shipment => Grand.Web.AdminShared/Models/Orders}/ShipmentShippedDateModel.cs (53%) rename src/Web/{Grand.Web.Vendor/Models/Shipment => Grand.Web.AdminShared/Models/Orders}/ShipmentTrackingModel.cs (54%) delete mode 100644 src/Web/Grand.Web.Vendor/Models/Shipment/AddShipmentModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentListModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentModel.cs diff --git a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentAdminCommentModel.cs b/src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentAdminCommentModel.cs similarity index 53% rename from src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentAdminCommentModel.cs rename to src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentAdminCommentModel.cs index 0d620da92..040b69f0e 100644 --- a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentAdminCommentModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentAdminCommentModel.cs @@ -1,3 +1,3 @@ -namespace Grand.Web.Vendor.Models.Shipment; +namespace Grand.Web.AdminShared.Models.Orders; -public record ShipmentAdminCommentModel(string Id, string AdminComment); \ No newline at end of file +public record ShipmentAdminCommentModel(string Id, string AdminComment); diff --git a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentDeliveryDateModel.cs b/src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentDeliveryDateModel.cs similarity index 52% rename from src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentDeliveryDateModel.cs rename to src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentDeliveryDateModel.cs index 32b5543ea..4dd6411a2 100644 --- a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentDeliveryDateModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentDeliveryDateModel.cs @@ -1,3 +1,3 @@ -namespace Grand.Web.Vendor.Models.Shipment; +namespace Grand.Web.AdminShared.Models.Orders; -public record ShipmentDeliveryDateModel(string Id, DateTime? DeliveryDate); \ No newline at end of file +public record ShipmentDeliveryDateModel(string Id, DateTime? DeliveryDate); diff --git a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentShippedDateModel.cs b/src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentShippedDateModel.cs similarity index 53% rename from src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentShippedDateModel.cs rename to src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentShippedDateModel.cs index 041d6888c..cb8328436 100644 --- a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentShippedDateModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentShippedDateModel.cs @@ -1,3 +1,3 @@ -namespace Grand.Web.Vendor.Models.Shipment; +namespace Grand.Web.AdminShared.Models.Orders; -public record ShipmentShippedDateModel(string Id, DateTime? ShippedDate); \ No newline at end of file +public record ShipmentShippedDateModel(string Id, DateTime? ShippedDate); diff --git a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentTrackingModel.cs b/src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentTrackingModel.cs similarity index 54% rename from src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentTrackingModel.cs rename to src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentTrackingModel.cs index a29d76995..6641075cc 100644 --- a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentTrackingModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Orders/ShipmentTrackingModel.cs @@ -1,3 +1,3 @@ -namespace Grand.Web.Vendor.Models.Shipment; +namespace Grand.Web.AdminShared.Models.Orders; -public record ShipmentTrackingModel(string Id, string TrackingNumber); \ No newline at end of file +public record ShipmentTrackingModel(string Id, string TrackingNumber); 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 7459fba57..65a6de0cc 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml @@ -30,17 +30,23 @@ Grand.Web.Vendor.Models.Catalog's own ProductModel etc. are no longer used by any controller/view and importing both would make bare "ProductModel" ambiguous. *@ @using Grand.Web.AdminShared.Models.Catalog; -@* Order views bind to Grand.Web.AdminShared's OrderModel/OrderListModel (ARCH-001 Phase 5 Order - consolidation) - Grand.Web.Vendor.Models.Orders's own OrderModel/OrderListModel are no longer - used by any controller/view. Unlike the ProductModel case above, this can't be a blanket - "@using Grand.Web.AdminShared.Models.Orders" - that namespace also holds ShipmentModel, - MerchandiseReturnModel, *ReportModel etc., which Vendor's Shipment/MerchandiseReturn/Reports - views still bind to their own Grand.Web.Vendor.Models.* equivalents (not yet consolidated), so - a wildcard import here would make those bare names ambiguous instead. Alias just the two types - that are actually consolidated. *@ +@* Order and Shipment views bind to Grand.Web.AdminShared's Order/Shipment model families + (ARCH-001 Phase 5 Order, Phase 6 Shipment consolidation) - Grand.Web.Vendor.Models.Orders's own + OrderModel/OrderListModel and Grand.Web.Vendor.Models.Shipment (the whole namespace) are no + longer used by any controller/view. This can't be a blanket + "@using Grand.Web.AdminShared.Models.Orders" - that namespace also holds MerchandiseReturnModel/ + *ReportModel etc., which Vendor's MerchandiseReturn/Reports views still bind to their own + Grand.Web.Vendor.Models.* equivalents (not yet consolidated), so a wildcard import here would + make those bare names ambiguous instead. Alias just the types that are actually consolidated. *@ @using OrderModel = Grand.Web.AdminShared.Models.Orders.OrderModel; @using OrderListModel = Grand.Web.AdminShared.Models.Orders.OrderListModel; -@using Grand.Web.Vendor.Models.Shipment; +@using ShipmentModel = Grand.Web.AdminShared.Models.Orders.ShipmentModel; +@using ShipmentListModel = Grand.Web.AdminShared.Models.Orders.ShipmentListModel; +@using AddShipmentModel = Grand.Web.AdminShared.Models.Orders.AddShipmentModel; +@using ShipmentTrackingModel = Grand.Web.AdminShared.Models.Orders.ShipmentTrackingModel; +@using ShipmentAdminCommentModel = Grand.Web.AdminShared.Models.Orders.ShipmentAdminCommentModel; +@using ShipmentDeliveryDateModel = Grand.Web.AdminShared.Models.Orders.ShipmentDeliveryDateModel; +@using ShipmentShippedDateModel = Grand.Web.AdminShared.Models.Orders.ShipmentShippedDateModel; @using Grand.Web.Vendor.Models.MerchandiseReturn; @using Grand.Web.Vendor.Models.Vendor; @using Grand.Web.Vendor.Models.VendorReview; diff --git a/src/Web/Grand.Web.Vendor/Models/Shipment/AddShipmentModel.cs b/src/Web/Grand.Web.Vendor/Models/Shipment/AddShipmentModel.cs deleted file mode 100644 index 53aeee068..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Shipment/AddShipmentModel.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Grand.Infrastructure.Validators; -namespace Grand.Web.Vendor.Models.Shipment; - -public class AddShipmentModel -{ - public string OrderId { get; set; } - public string TrackingNumber { get; set; } - [NoHtml] - public string AdminComment { get; set; } - - public IList Items { get; set; } = new List(); - - public class ShipmentItemModel - { - public string OrderItemId { get; set; } - public int QuantityToAdd { get; set; } - public string WarehouseId { get; set; } - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentListModel.cs b/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentListModel.cs deleted file mode 100644 index b99871d9d..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentListModel.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Shipment; - -public class ShipmentListModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Orders.Shipments.List.StartDate")] - [UIHint("DateNullable")] - public DateTime? StartDate { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.List.EndDate")] - [UIHint("DateNullable")] - public DateTime? EndDate { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.List.TrackingNumber")] - - public string TrackingNumber { get; set; } - - - [GrandResourceDisplayName("Vendor.Orders.Shipments.List.City")] - - public string City { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.List.LoadNotShipped")] - public bool LoadNotShipped { get; set; } - - - [GrandResourceDisplayName("Vendor.Orders.Shipments.List.Warehouse")] - public string WarehouseId { get; set; } - - public IList AvailableWarehouses { get; set; } = new List(); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentModel.cs b/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentModel.cs deleted file mode 100644 index 837a37be7..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Shipment/ShipmentModel.cs +++ /dev/null @@ -1,134 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Grand.Infrastructure.Validators; - -namespace Grand.Web.Vendor.Models.Shipment; - -public class ShipmentModel : BaseEntityModel -{ - [GrandResourceDisplayName("Vendor.Orders.Shipments.ID")] - public override string Id { get; set; } - - public int ShipmentNumber { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.OrderID")] - public string OrderId { get; set; } - - public int OrderNumber { get; set; } - public string OrderCode { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.TotalWeight")] - public string TotalWeight { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.TrackingNumber")] - public string TrackingNumber { get; set; } - - public string TrackingNumberUrl { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.ShippedDate")] - public DateTime? ShippedDate { get; set; } - - public bool CanShip { get; set; } - public DateTime? ShippedDateUtc { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.DeliveryDate")] - public DateTime? DeliveryDate { get; set; } - - public bool CanDeliver { get; set; } - public DateTime? DeliveryDateUtc { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Orders.Shipments.AdminComment")] - public string AdminComment { get; set; } - - public List Items { get; set; } = new(); - - public IList ShipmentStatusEvents { get; set; } = new List(); - - //shipment notes - [GrandResourceDisplayName("Vendor.Orders.Shipments.ShipmentNotes.Fields.DisplayToCustomer")] - public bool AddShipmentNoteDisplayToCustomer { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.ShipmentNotes.Fields.Note")] - [NoHtml] - public string AddShipmentNoteMessage { get; set; } - - - #region Nested classes - - public class ShipmentItemModel : BaseEntityModel - { - public string OrderItemId { get; set; } - public string ProductId { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.Products.ProductName")] - public string ProductName { get; set; } - - public string Sku { get; set; } - public string AttributeInfo { get; set; } - public string RentalInfo { get; set; } - public bool ShipSeparately { get; set; } - - //weight of one item (product) - [GrandResourceDisplayName("Vendor.Orders.Shipments.Products.ItemWeight")] - public string ItemWeight { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.Products.ItemDimensions")] - public string ItemDimensions { get; set; } - - public int QuantityToAdd { get; set; } - public int QuantityOrdered { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.Products.QtyShipped")] - public int QuantityInThisShipment { get; set; } - - public int QuantityInAllShipments { get; set; } - - public string ShippedFromWarehouse { get; set; } - - public bool AllowToChooseWarehouse { get; set; } - - //used before a shipment is created - public List AvailableWarehouses { get; set; } = new(); - public string WarehouseId { get; set; } - - #region Nested Classes - - public class WarehouseInfo : BaseModel - { - public string WarehouseId { get; set; } - public string WarehouseCode { get; set; } - public string WarehouseName { get; set; } - public int StockQuantity { get; set; } - public int ReservedQuantity { get; set; } - } - - #endregion - } - - public class ShipmentNote : BaseEntityModel - { - public string ShipmentId { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.ShipmentNotes.Fields.DisplayToCustomer")] - public bool DisplayToCustomer { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.ShipmentNotes.Fields.Note")] - public string Note { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.ShipmentNotes.Fields.CreatedOn")] - public DateTime CreatedOn { get; set; } - - [GrandResourceDisplayName("Vendor.Orders.Shipments.ShipmentNotes.Fields.CreatedByCustomer")] - public bool CreatedByCustomer { get; set; } - } - - public class ShipmentStatusEventModel : BaseModel - { - public string EventName { get; set; } - public string Location { get; set; } - public DateTime? Date { get; set; } - } - - #endregion -} \ No newline at end of file From 3fd2fda23cd4f49691bbdb34bb4c0df130f392f4 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 25 Aug 2026 20:38:51 +0200 Subject: [PATCH 03/12] refactor(arch001): reconcile IShipmentViewModelService onto scope, delete Vendor's duplicate Injects IAdminDataScope into Grand.Web.AdminShared's ShipmentViewModelService and merges in the two pieces of Vendor-specific behavior that Vendor's now-deleted duplicate service used to do on its own: - PrepareShipmentModel(Shipment,...): order-item visibility now goes through scope.FilterOrderItems(order.OrderItems) instead of an unfiltered lookup. - PrepareShipmentModel(Order): same scope.FilterOrderItems() filter applied to the AddShipment order-item picker (Vendor previously additionally gated this on a "not IsStoreManager" check via IGroupService - dropped deliberately, not silently: see below). - PrepareShipment(Order,...): new Shipment.VendorId now set from scope.DefaultVendorId (null for Admin/Store, CurrentVendor.Id for Vendor - matches Vendor's prior hardcoding). Deletes Grand.Web.Vendor/Interfaces/IShipmentViewModelService.cs and Grand.Web.Vendor/Services/ShipmentViewModelService.cs, and removes the now-redundant DI registration in Grand.Web.Vendor/Startup/StartupApplication.cs (AdminShared's own StartupApplication registers the single implementation, discovered via the existing IStartupApplication assembly scan). Disclosed, deliberate behavior note: Vendor's original GET-path service method gated its order-item filter on a "not IsStoreManager" check, while Vendor's own POST-path controller action (AddShipment) filtered unconditionally with no IsStoreManager check at all - the two call sites already disagreed before this consolidation. VendorShipmentDataScope.FilterOrderItems (Task 1) has no IsStoreManager branch, so this change makes the GET path consistent with the already-existing POST path behavior, not the other way around. Not resolving which of the two pre-existing behaviors was actually intended - flagging for product owner input, out of scope here. Grand.Web.Vendor/Controllers/ShipmentController.cs is left in its already-broken state (Task 2) referencing the now-deleted types; that cutover is Task 9's job. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../Services/ShipmentViewModelServiceTests.cs | 195 ++++++ .../Services/ShipmentViewModelService.cs | 19 +- .../Interfaces/IShipmentViewModelService.cs | 28 - .../Services/ShipmentViewModelService.cs | 557 ------------------ .../Startup/StartupApplication.cs | 2 +- 5 files changed, 211 insertions(+), 590 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Services/ShipmentViewModelServiceTests.cs delete mode 100644 src/Web/Grand.Web.Vendor/Interfaces/IShipmentViewModelService.cs delete mode 100644 src/Web/Grand.Web.Vendor/Services/ShipmentViewModelService.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ShipmentViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ShipmentViewModelServiceTests.cs new file mode 100644 index 000000000..3d9cf5d43 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ShipmentViewModelServiceTests.cs @@ -0,0 +1,195 @@ +using Grand.Business.Core.Interfaces.Catalog.Directory; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Shipping; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Catalog; +using Grand.Domain.Directory; +using Grand.Domain.Orders; +using Grand.Domain.Shipping; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Assert = Microsoft.VisualStudio.TestTools.UnitTesting.Assert; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class ShipmentViewModelServiceTests +{ + private Mock _orderServiceMock; + private Mock _productServiceMock; + private Mock _shipmentServiceMock; + private Mock _warehouseServiceMock; + private Mock _measureServiceMock; + private Mock> _scopeMock; + private ShipmentViewModelService _service; + + [TestInitialize] + public void Setup() + { + _orderServiceMock = new Mock(); + _productServiceMock = new Mock(); + _shipmentServiceMock = new Mock(); + _warehouseServiceMock = new Mock(); + _measureServiceMock = new Mock(); + _scopeMock = new Mock>(); + + _measureServiceMock.Setup(m => m.GetMeasureWeightById(It.IsAny())).ReturnsAsync((MeasureWeight)null); + _measureServiceMock.Setup(m => m.GetMeasureDimensionById(It.IsAny())) + .ReturnsAsync((MeasureDimension)null); + + _warehouseServiceMock.Setup(w => w.GetWarehouseById(It.IsAny())).ReturnsAsync((Warehouse)null); + + // Default: Admin's Global scope - identity passthrough, no default vendor. + _scopeMock.Setup(s => s.FilterOrderItems(It.IsAny>())) + .Returns((IEnumerable items) => items); + _scopeMock.Setup(s => s.DefaultVendorId).Returns((string)null); + + _service = new ShipmentViewModelService( + _orderServiceMock.Object, + new Mock().Object, + _productServiceMock.Object, + _shipmentServiceMock.Object, + _warehouseServiceMock.Object, + _measureServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new MeasureSettings(), + new ShippingSettings(), + new ShippingProviderSettings(), + _scopeMock.Object); + } + + [TestMethod] + public async Task PrepareShipmentModel_VendorScopeFiltersToOwnItems() + { + // Arrange + var order = new Order { Id = "order1" }; + order.OrderItems.Add(new OrderItem { Id = "oi-A", ProductId = "p-A", VendorId = "vendor-A" }); + order.OrderItems.Add(new OrderItem { Id = "oi-B", ProductId = "p-B", VendorId = "vendor-B" }); + _orderServiceMock.Setup(o => o.GetOrderById(order.Id)).ReturnsAsync(order); + + _scopeMock.Setup(s => s.FilterOrderItems(order.OrderItems)) + .Returns(order.OrderItems.Where(i => i.VendorId == "vendor-A")); + + var productA = new Product { Id = "p-A", Name = "Product A" }; + _productServiceMock.Setup(p => p.GetProductByIdIncludeArch("p-A")).ReturnsAsync(productA); + + var shipment = new Shipment { Id = "shipment1", OrderId = order.Id }; + shipment.ShipmentItems.Add(new ShipmentItem { Id = "si-A", OrderItemId = "oi-A", Quantity = 1 }); + shipment.ShipmentItems.Add(new ShipmentItem { Id = "si-B", OrderItemId = "oi-B", Quantity = 1 }); + + // Act + var model = await _service.PrepareShipmentModel(shipment, prepareProducts: true); + + // Assert + Assert.AreEqual(1, model.Items.Count); + Assert.AreEqual("oi-A", model.Items[0].OrderItemId); + Assert.AreEqual("p-A", model.Items[0].ProductId); + } + + [TestMethod] + public async Task PrepareShipmentModel_GlobalScopeDoesNotFilterItems() + { + // Arrange + var order = new Order { Id = "order1" }; + order.OrderItems.Add(new OrderItem { Id = "oi-A", ProductId = "p-A", VendorId = "vendor-A" }); + order.OrderItems.Add(new OrderItem { Id = "oi-B", ProductId = "p-B", VendorId = "vendor-B" }); + _orderServiceMock.Setup(o => o.GetOrderById(order.Id)).ReturnsAsync(order); + + // Default Setup() scope: identity passthrough (no filtering). + + var productA = new Product { Id = "p-A", Name = "Product A" }; + var productB = new Product { Id = "p-B", Name = "Product B" }; + _productServiceMock.Setup(p => p.GetProductByIdIncludeArch("p-A")).ReturnsAsync(productA); + _productServiceMock.Setup(p => p.GetProductByIdIncludeArch("p-B")).ReturnsAsync(productB); + + var shipment = new Shipment { Id = "shipment1", OrderId = order.Id }; + shipment.ShipmentItems.Add(new ShipmentItem { Id = "si-A", OrderItemId = "oi-A", Quantity = 1 }); + shipment.ShipmentItems.Add(new ShipmentItem { Id = "si-B", OrderItemId = "oi-B", Quantity = 1 }); + + // Act + var model = await _service.PrepareShipmentModel(shipment, prepareProducts: true); + + // Assert + Assert.AreEqual(2, model.Items.Count); + Assert.IsTrue(model.Items.Any(i => i.OrderItemId == "oi-A")); + Assert.IsTrue(model.Items.Any(i => i.OrderItemId == "oi-B")); + } + + [TestMethod] + public async Task PrepareShipment_SetsVendorIdFromScope() + { + // Arrange + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor-A"); + + var order = new Order { Id = "order1", SeId = "se1", StoreId = "store1" }; + var orderItem = new OrderItem { + Id = "oi-A", + ProductId = "p-A", + IsShipEnabled = true, + OpenQty = 1, + Quantity = 1 + }; + + var product = new Product { Id = "p-A", IsShipEnabled = true }; + _productServiceMock.Setup(p => p.GetProductById("p-A")).ReturnsAsync(product); + + var model = new AddShipmentModel { + OrderId = order.Id, + Items = new List { + new() { OrderItemId = "oi-A", QuantityToAdd = 1 } + } + }; + + // Act + var (shipment, _) = await _service.PrepareShipment(order, new[] { orderItem }, model); + + // Assert + Assert.IsNotNull(shipment); + Assert.AreEqual("vendor-A", shipment.VendorId); + } + + [TestMethod] + public async Task PrepareShipment_NullDefaultVendorId_LeavesVendorIdNull() + { + // Arrange + _scopeMock.Setup(s => s.DefaultVendorId).Returns((string)null); + + var order = new Order { Id = "order1", SeId = "se1", StoreId = "store1" }; + var orderItem = new OrderItem { + Id = "oi-A", + ProductId = "p-A", + IsShipEnabled = true, + OpenQty = 1, + Quantity = 1 + }; + + var product = new Product { Id = "p-A", IsShipEnabled = true }; + _productServiceMock.Setup(p => p.GetProductById("p-A")).ReturnsAsync(product); + + var model = new AddShipmentModel { + OrderId = order.Id, + Items = new List { + new() { OrderItemId = "oi-A", QuantityToAdd = 1 } + } + }; + + // Act + var (shipment, _) = await _service.PrepareShipment(order, new[] { orderItem }, model); + + // Assert + Assert.IsNotNull(shipment); + Assert.IsNull(shipment.VendorId); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ShipmentViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ShipmentViewModelService.cs index 88ecfc278..8acc461ef 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ShipmentViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ShipmentViewModelService.cs @@ -34,6 +34,7 @@ public class ShipmentViewModelService : IShipmentViewModelService private readonly ITranslationService _translationService; private readonly IWarehouseService _warehouseService; private readonly IContextAccessor _contextAccessor; + private readonly IAdminDataScope _scope; public ShipmentViewModelService( IOrderService orderService, @@ -50,7 +51,8 @@ public ShipmentViewModelService( IStockQuantityService stockQuantityService, MeasureSettings measureSettings, ShippingSettings shippingSettings, - ShippingProviderSettings shippingProviderSettings) + ShippingProviderSettings shippingProviderSettings, + IAdminDataScope scope) { _orderService = orderService; _contextAccessor = contextAccessor; @@ -67,6 +69,7 @@ public ShipmentViewModelService( _measureSettings = measureSettings; _shippingSettings = shippingSettings; _shippingProviderSettings = shippingProviderSettings; + _scope = scope; } public virtual async Task PrepareShipmentModel(Shipment shipment, bool prepareProducts, @@ -102,9 +105,13 @@ public virtual async Task PrepareShipmentModel(Shipment shipment, }; if (prepareProducts) + { + var visibleOrderItems = order != null + ? _scope.FilterOrderItems(order.OrderItems).ToList() + : []; foreach (var shipmentItem in shipment.ShipmentItems) { - var orderItem = order?.OrderItems.FirstOrDefault(x => x.Id == shipmentItem.OrderItemId); + var orderItem = visibleOrderItems.FirstOrDefault(x => x.Id == shipmentItem.OrderItemId); if (orderItem == null) continue; @@ -140,6 +147,7 @@ public virtual async Task PrepareShipmentModel(Shipment shipment, model.Items.Add(shipmentItemModel); } } + } if (prepareShipmentEvent && !string.IsNullOrEmpty(shipment.TrackingNumber)) { @@ -331,7 +339,9 @@ public virtual async Task PrepareShipmentModel(Order order) var baseDimension = await _measureService.GetMeasureDimensionById(_measureSettings.BaseDimensionId); var baseDimensionIn = baseDimension != null ? baseDimension.Name : ""; - foreach (var orderItem in order.OrderItems) + var orderItems = _scope.FilterOrderItems(order.OrderItems); + + foreach (var orderItem in orderItems) { var product = await _productService.GetProductByIdIncludeArch(orderItem.ProductId); //we can ship only shippable products @@ -571,7 +581,8 @@ public virtual async Task PrepareShipmentModel(Order order) ShippedDateUtc = null, DeliveryDateUtc = null, AdminComment = adminComment, - StoreId = order.StoreId + StoreId = order.StoreId, + VendorId = _scope.DefaultVendorId }; } diff --git a/src/Web/Grand.Web.Vendor/Interfaces/IShipmentViewModelService.cs b/src/Web/Grand.Web.Vendor/Interfaces/IShipmentViewModelService.cs deleted file mode 100644 index 6c24adf6a..000000000 --- a/src/Web/Grand.Web.Vendor/Interfaces/IShipmentViewModelService.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Grand.Domain.Catalog; -using Grand.Domain.Orders; -using Grand.Domain.Shipping; -using Grand.Web.Vendor.Models.Shipment; - -namespace Grand.Web.Vendor.Interfaces; - -public interface IShipmentViewModelService -{ - Task PrepareShipmentModel(Shipment shipment, bool prepareProducts, - bool prepareShipmentEvent = false); - - Task GetStockQty(Product product, string warehouseId); - Task GetReservedQty(Product product, string warehouseId); - Task> PrepareShipmentNotes(Shipment shipment); - Task InsertShipmentNote(Shipment shipment, bool displayToCustomer, string message); - Task DeleteShipmentNote(Shipment shipment, string id); - Task PrepareShipmentListModel(); - Task PrepareShipmentModel(Order order); - - Task<(Shipment shipment, double? totalWeight)> PrepareShipment(Order order, IEnumerable orderItems, - AddShipmentModel model); - - Task<(bool valid, string message)> ValidStockShipment(Shipment shipment); - - Task<(IEnumerable shipments, int totalCount)> PrepareShipments(ShipmentListModel model, int pageIndex, - int pageSize); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Services/ShipmentViewModelService.cs b/src/Web/Grand.Web.Vendor/Services/ShipmentViewModelService.cs deleted file mode 100644 index de40c522c..000000000 --- a/src/Web/Grand.Web.Vendor/Services/ShipmentViewModelService.cs +++ /dev/null @@ -1,557 +0,0 @@ -using Grand.Business.Core.Extensions; -using Grand.Business.Core.Interfaces.Catalog.Directory; -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Checkout.Orders; -using Grand.Business.Core.Interfaces.Checkout.Shipping; -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Domain.Catalog; -using Grand.Domain.Directory; -using Grand.Domain.Orders; -using Grand.Domain.Shipping; -using Grand.Infrastructure; -using Grand.Web.Vendor.Extensions; -using Grand.Web.Vendor.Interfaces; -using Grand.Web.Vendor.Models.Shipment; -using Microsoft.AspNetCore.Mvc.Rendering; - -namespace Grand.Web.Vendor.Services; - -public class ShipmentViewModelService : IShipmentViewModelService -{ - private readonly IDateTimeService _dateTimeService; - private readonly IGroupService _groupService; - private readonly IMeasureService _measureService; - private readonly MeasureSettings _measureSettings; - private readonly IOrderService _orderService; - private readonly IProductService _productService; - private readonly IShipmentService _shipmentService; - private readonly ShippingProviderSettings _shippingProviderSettings; - private readonly IShippingService _shippingService; - private readonly ShippingSettings _shippingSettings; - private readonly IStockQuantityService _stockQuantityService; - private readonly ITranslationService _translationService; - private readonly IWarehouseService _warehouseService; - private readonly IContextAccessor _contextAccessor; - - public ShipmentViewModelService( - IOrderService orderService, - IContextAccessor contextAccessor, - IGroupService groupService, - IProductService productService, - IShipmentService shipmentService, - IWarehouseService warehouseService, - IMeasureService measureService, - IDateTimeService dateTimeService, - ITranslationService translationService, - IShippingService shippingService, - IStockQuantityService stockQuantityService, - MeasureSettings measureSettings, - ShippingSettings shippingSettings, - ShippingProviderSettings shippingProviderSettings) - { - _orderService = orderService; - _contextAccessor = contextAccessor; - _groupService = groupService; - _productService = productService; - _shipmentService = shipmentService; - _warehouseService = warehouseService; - _measureService = measureService; - _dateTimeService = dateTimeService; - _translationService = translationService; - _shippingService = shippingService; - _stockQuantityService = stockQuantityService; - _measureSettings = measureSettings; - _shippingSettings = shippingSettings; - _shippingProviderSettings = shippingProviderSettings; - } - - public virtual async Task PrepareShipmentModel(Shipment shipment, bool prepareProducts, - bool prepareShipmentEvent = false) - { - //measures - var baseWeight = await _measureService.GetMeasureWeightById(_measureSettings.BaseWeightId); - var baseWeightIn = baseWeight != null ? baseWeight.Name : ""; - var baseDimension = await _measureService.GetMeasureDimensionById(_measureSettings.BaseDimensionId); - var baseDimensionIn = baseDimension != null ? baseDimension.Name : ""; - var order = await _orderService.GetOrderById(shipment.OrderId); - - var model = new ShipmentModel { - Id = shipment.Id, - ShipmentNumber = shipment.ShipmentNumber, - OrderId = shipment.OrderId, - OrderNumber = order?.OrderNumber ?? 0, - OrderCode = order != null ? order.Code : "", - TrackingNumber = shipment.TrackingNumber, - TotalWeight = shipment.TotalWeight.HasValue ? $"{shipment.TotalWeight:F2} [{baseWeightIn}]" : "", - ShippedDate = shipment.ShippedDateUtc.HasValue - ? _dateTimeService.ConvertToUserTime(shipment.ShippedDateUtc.Value, DateTimeKind.Utc) - : new DateTime?(), - ShippedDateUtc = shipment.ShippedDateUtc, - CanShip = !shipment.ShippedDateUtc.HasValue, - DeliveryDate = shipment.DeliveryDateUtc.HasValue - ? _dateTimeService.ConvertToUserTime(shipment.DeliveryDateUtc.Value, DateTimeKind.Utc) - : new DateTime?(), - DeliveryDateUtc = shipment.DeliveryDateUtc, - CanDeliver = shipment.ShippedDateUtc.HasValue && !shipment.DeliveryDateUtc.HasValue, - AdminComment = shipment.AdminComment - }; - - if (prepareProducts) - foreach (var shipmentItem in shipment.ShipmentItems) - { - var orderItem = order?.OrderItems.FirstOrDefault(x => x.Id == shipmentItem.OrderItemId); - if (orderItem == null) - continue; - - if (!_contextAccessor.WorkContext.HasAccessToOrderItem(orderItem)) - continue; - - //quantities - var qtyInThisShipment = shipmentItem.Quantity; - var maxQtyToAdd = orderItem.OpenQty; - var qtyOrdered = shipmentItem.Quantity; - var qtyInAllShipments = orderItem.ShipQty; - var product = await _productService.GetProductByIdIncludeArch(orderItem.ProductId); - if (product != null) - { - var warehouse = await _warehouseService.GetWarehouseById(shipmentItem.WarehouseId); - var shipmentItemModel = new ShipmentModel.ShipmentItemModel { - Id = shipmentItem.Id, - OrderItemId = orderItem.Id, - ProductId = orderItem.ProductId, - ProductName = product.Name, - Sku = product.FormatSku(orderItem.Attributes), - AttributeInfo = orderItem.AttributeDescription, - ShippedFromWarehouse = warehouse?.Name, - ShipSeparately = product.ShipSeparately, - ItemWeight = orderItem.ItemWeight.HasValue - ? $"{orderItem.ItemWeight:F2} [{baseWeightIn}]" - : "", - ItemDimensions = - $"{product.Length:F2} x {product.Width:F2} x {product.Height:F2} [{baseDimensionIn}]", - QuantityOrdered = qtyOrdered, - QuantityInThisShipment = qtyInThisShipment, - QuantityInAllShipments = qtyInAllShipments, - QuantityToAdd = maxQtyToAdd - }; - - model.Items.Add(shipmentItemModel); - } - } - - if (prepareShipmentEvent && !string.IsNullOrEmpty(shipment.TrackingNumber)) - { - var srcm = _shippingService.LoadShippingRateCalculationProviderBySystemName(order?.ShippingRateProviderSystemName); - if (srcm != null && - srcm.IsShippingRateMethodActive(_shippingProviderSettings)) - { - var shipmentTracker = srcm.ShipmentTracker; - if (shipmentTracker != null) - { - model.TrackingNumberUrl = await shipmentTracker.GetUrl(shipment.TrackingNumber); - if (_shippingSettings.DisplayShipmentEventsToStoreOwner) - { - var shipmentEvents = await shipmentTracker.GetShipmentEvents(shipment.TrackingNumber); - if (shipmentEvents != null) - foreach (var shipmentEvent in shipmentEvents) - { - var shipmentStatusEventModel = new ShipmentModel.ShipmentStatusEventModel { - Date = shipmentEvent.Date, - EventName = shipmentEvent.EventName, - Location = shipmentEvent.Location - }; - model.ShipmentStatusEvents.Add(shipmentStatusEventModel); - } - } - } - } - } - - return model; - } - - - public virtual async Task GetStockQty(Product product, string warehouseId) - { - var qty = new List(); - foreach (var item in product.BundleProducts) - { - var p1 = await _productService.GetProductById(item.ProductId); - if (p1.UseMultipleWarehouses) - { - var stock = p1.ProductWarehouseInventory.FirstOrDefault(x => x.WarehouseId == warehouseId); - if (stock != null) qty.Add(stock.StockQuantity / item.Quantity); - } - else - { - qty.Add(p1.StockQuantity / item.Quantity); - } - } - - return qty.Count > 0 ? qty.Min() : 0; - } - - public virtual async Task GetReservedQty(Product product, string warehouseId) - { - var qty = new List(); - foreach (var item in product.BundleProducts) - { - var p1 = await _productService.GetProductById(item.ProductId); - if (p1.UseMultipleWarehouses) - { - var stock = p1.ProductWarehouseInventory.FirstOrDefault(x => x.WarehouseId == warehouseId); - if (stock != null) qty.Add(stock.ReservedQuantity / item.Quantity); - } - } - - return qty.Count > 0 ? qty.Min() : 0; - } - - public virtual async Task> PrepareShipmentNotes(Shipment shipment) - { - //shipment notes - var shipmentNoteModels = new List(); - foreach (var shipmentNote in (await _shipmentService.GetShipmentNotes(shipment.Id)) - .OrderByDescending(on => on.CreatedOnUtc)) - shipmentNoteModels.Add(new ShipmentModel.ShipmentNote { - Id = shipmentNote.Id, - ShipmentId = shipment.Id, - DisplayToCustomer = shipmentNote.DisplayToCustomer, - Note = shipmentNote.Note, - CreatedOn = _dateTimeService.ConvertToUserTime(shipmentNote.CreatedOnUtc, DateTimeKind.Utc), - CreatedByCustomer = shipmentNote.CreatedByCustomer - }); - - return shipmentNoteModels; - } - - public virtual async Task InsertShipmentNote(Shipment shipment, bool displayToCustomer, - string message) - { - var shipmentNote = new ShipmentNote { - DisplayToCustomer = displayToCustomer, - Note = message, - ShipmentId = shipment.Id - }; - await _shipmentService.InsertShipmentNote(shipmentNote); - } - - public virtual async Task DeleteShipmentNote(Shipment shipment, string id) - { - var shipmentNote = (await _shipmentService.GetShipmentNotes(shipment.Id)).FirstOrDefault(on => on.Id == id); - if (shipmentNote == null) - throw new ArgumentException("No shipment note found with the specified id"); - - shipmentNote.ShipmentId = shipment.Id; - await _shipmentService.DeleteShipmentNote(shipmentNote); - } - - public virtual async Task<(IEnumerable shipments, int totalCount)> PrepareShipments( - ShipmentListModel model, int pageIndex, int pageSize) - { - 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); - - //load shipments - var shipments = await _shipmentService.GetAllShipments( - vendorId: _contextAccessor.WorkContext.CurrentVendor.Id, - warehouseId: model.WarehouseId, - shippingCity: model.City, - trackingNumber: model.TrackingNumber, - loadNotShipped: model.LoadNotShipped, - createdFromUtc: startDateValue, - createdToUtc: endDateValue, - pageIndex: pageIndex - 1, - pageSize: pageSize); - - return (shipments.ToList(), shipments.TotalCount); - } - - public virtual async Task PrepareShipmentListModel() - { - var model = new ShipmentListModel(); - //warehouses - model.AvailableWarehouses.Add(new SelectListItem - { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); - foreach (var w in await _warehouseService.GetAllWarehouses()) - model.AvailableWarehouses.Add(new SelectListItem { Text = w.Name, Value = w.Id }); - - return model; - } - - public virtual async Task PrepareShipmentModel(Order order) - { - var model = new ShipmentModel { - OrderId = order.Id, - OrderNumber = order.OrderNumber - }; - - //measures - var baseWeight = await _measureService.GetMeasureWeightById(_measureSettings.BaseWeightId); - var baseWeightIn = baseWeight != null ? baseWeight.Name : ""; - var baseDimension = await _measureService.GetMeasureDimensionById(_measureSettings.BaseDimensionId); - var baseDimensionIn = baseDimension != null ? baseDimension.Name : ""; - - var orderItems = order.OrderItems; - //a vendor should have access only to his products - if (_contextAccessor.WorkContext.CurrentVendor != null && !await _groupService.IsStoreManager(_contextAccessor.WorkContext.CurrentCustomer)) - orderItems = orderItems.Where(_contextAccessor.WorkContext.HasAccessToOrderItem).ToList(); - - foreach (var orderItem in orderItems) - { - var product = await _productService.GetProductByIdIncludeArch(orderItem.ProductId); - //we can ship only shippable products - if (!product.IsShipEnabled) - continue; - - //quantities - var qtyInThisShipment = 0; - var maxQtyToAdd = orderItem.OpenQty; - var qtyOrdered = orderItem.Quantity; - var qtyInAllShipments = orderItem.ShipQty; - - //ensure that this product can be added to a shipment - if (maxQtyToAdd <= 0) - continue; - - var shipmentItemModel = new ShipmentModel.ShipmentItemModel { - OrderItemId = orderItem.Id, - ProductId = orderItem.ProductId, - ProductName = product.Name, - WarehouseId = orderItem.WarehouseId, - Sku = product.FormatSku(orderItem.Attributes), - AttributeInfo = orderItem.AttributeDescription, - ShipSeparately = product.ShipSeparately, - ItemWeight = orderItem.ItemWeight.HasValue ? $"{orderItem.ItemWeight:F2} [{baseWeightIn}]" : "", - ItemDimensions = - $"{product.Length:F2} x {product.Width:F2} x {product.Height:F2} [{baseDimensionIn}]", - QuantityOrdered = qtyOrdered, - QuantityInThisShipment = qtyInThisShipment, - QuantityInAllShipments = qtyInAllShipments, - QuantityToAdd = maxQtyToAdd - }; - - switch (product.ManageInventoryMethodId) - { - case ManageInventoryMethod.ManageStock when product.UseMultipleWarehouses: - { - //multiple warehouses supported - shipmentItemModel.AllowToChooseWarehouse = true; - foreach (var pwi in product.ProductWarehouseInventory - .OrderBy(w => w.WarehouseId).ToList()) - { - var warehouse = await _warehouseService.GetWarehouseById(pwi.WarehouseId); - if (warehouse != null) - shipmentItemModel.AvailableWarehouses.Add( - new ShipmentModel.ShipmentItemModel.WarehouseInfo { - WarehouseId = warehouse.Id, - WarehouseName = warehouse.Name, - WarehouseCode = warehouse.Code, - StockQuantity = pwi.StockQuantity, - ReservedQuantity = pwi.ReservedQuantity - }); - } - - break; - } - case ManageInventoryMethod.ManageStock: - { - //multiple warehouses are not supported - var warehouse = await _warehouseService.GetWarehouseById(product.WarehouseId); - if (warehouse != null) - shipmentItemModel.AvailableWarehouses.Add(new ShipmentModel.ShipmentItemModel.WarehouseInfo { - WarehouseId = warehouse.Id, - WarehouseName = warehouse.Name, - WarehouseCode = warehouse.Code, - StockQuantity = product.StockQuantity - }); - - break; - } - case ManageInventoryMethod.ManageStockByAttributes when product.UseMultipleWarehouses: - { - //multiple warehouses supported - shipmentItemModel.AllowToChooseWarehouse = true; - var comb = product.FindProductAttributeCombination(orderItem.Attributes); - if (comb != null) - foreach (var pwi in comb.WarehouseInventory - .OrderBy(w => w.WarehouseId).ToList()) - { - var warehouse = await _warehouseService.GetWarehouseById(pwi.WarehouseId); - if (warehouse != null) - shipmentItemModel.AvailableWarehouses.Add( - new ShipmentModel.ShipmentItemModel.WarehouseInfo { - WarehouseId = warehouse.Id, - WarehouseName = warehouse.Name, - StockQuantity = pwi.StockQuantity, - WarehouseCode = warehouse.Code, - ReservedQuantity = pwi.ReservedQuantity - }); - } - - break; - } - case ManageInventoryMethod.ManageStockByAttributes: - { - //multiple warehouses are not supported - var warehouse = await _warehouseService.GetWarehouseById(product.WarehouseId); - if (warehouse != null) - shipmentItemModel.AvailableWarehouses.Add(new ShipmentModel.ShipmentItemModel.WarehouseInfo { - WarehouseId = warehouse.Id, - WarehouseName = warehouse.Name, - WarehouseCode = warehouse.Code, - StockQuantity = product.StockQuantity - }); - - break; - } - } - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByBundleProducts) - { - if (!string.IsNullOrEmpty(orderItem.WarehouseId)) - { - var warehouse = await _warehouseService.GetWarehouseById(product.WarehouseId); - if (warehouse != null) - shipmentItemModel.AvailableWarehouses.Add(new ShipmentModel.ShipmentItemModel.WarehouseInfo { - WarehouseId = warehouse.Id, - WarehouseName = warehouse.Name, - WarehouseCode = warehouse.Code, - StockQuantity = await GetStockQty(product, orderItem.WarehouseId), - ReservedQuantity = await GetReservedQty(product, orderItem.WarehouseId) - }); - } - else - { - shipmentItemModel.AllowToChooseWarehouse = false; - if (shipmentItemModel.AllowToChooseWarehouse) - { - var warehouses = await _warehouseService.GetAllWarehouses(); - foreach (var warehouse in warehouses) - shipmentItemModel.AvailableWarehouses.Add( - new ShipmentModel.ShipmentItemModel.WarehouseInfo { - WarehouseId = warehouse.Id, - WarehouseName = warehouse.Name, - WarehouseCode = warehouse.Code, - StockQuantity = await GetStockQty(product, warehouse.Id), - ReservedQuantity = await GetReservedQty(product, warehouse.Id) - }); - } - } - } - - model.Items.Add(shipmentItemModel); - } - - return model; - } - - public virtual async Task<(bool valid, string message)> ValidStockShipment(Shipment shipment) - { - foreach (var item in shipment.ShipmentItems) - { - var product = await _productService.GetProductById(item.ProductId); - switch (product.ManageInventoryMethodId) - { - case ManageInventoryMethod.ManageStock: - { - var stock = _stockQuantityService.GetTotalStockQuantity(product, false, - item.WarehouseId); - if (stock - item.Quantity < 0) - return (false, $"Out of stock for product {product.Name}"); - break; - } - case ManageInventoryMethod.ManageStockByAttributes: - { - var combination = product.FindProductAttributeCombination(item.Attributes); - if (combination == null) - return (false, $"Can't find combination for product {product.Name}"); - - var stock = _stockQuantityService.GetTotalStockQuantityForCombination(product, combination, - false, item.WarehouseId); - if (stock - item.Quantity < 0) - return (false, $"Out of stock for product {product.Name}"); - break; - } - } - } - - return (true, string.Empty); - } - - public virtual async Task<(Shipment shipment, double? totalWeight)> PrepareShipment(Order order, - IEnumerable orderItems, AddShipmentModel model) - { - var shipment = new Shipment { - OrderId = order.Id, - SeId = order.SeId, - TrackingNumber = model.TrackingNumber, - TotalWeight = null, - ShippedDateUtc = null, - DeliveryDateUtc = null, - AdminComment = model.AdminComment, - StoreId = order.StoreId, - VendorId = _contextAccessor.WorkContext.CurrentVendor.Id - }; - double? totalWeight = null; - foreach (var orderItem in orderItems) - { - //is shippable - if (!orderItem.IsShipEnabled) - continue; - - //ensure that this product can be shipped (have at least one item to ship) - if (orderItem.OpenQty <= 0) - continue; - - var shipmentItemModel = model.Items.FirstOrDefault(x => x.OrderItemId == orderItem.Id); - if (shipmentItemModel == null) - continue; - - var product = await _productService.GetProductById(orderItem.ProductId); - string warehouseId; - if (product != null && ((product.ManageInventoryMethodId is ManageInventoryMethod.ManageStock - or ManageInventoryMethod.ManageStockByAttributes && - product.UseMultipleWarehouses) || product.ManageInventoryMethodId == - ManageInventoryMethod.ManageStockByBundleProducts)) - //multiple warehouses supported - //warehouse is chosen by a store owner - warehouseId = shipmentItemModel.WarehouseId; - else - //multiple warehouses are not supported - warehouseId = orderItem.WarehouseId; - - //validate quantity - if (shipmentItemModel.QuantityToAdd <= 0) - continue; - if (shipmentItemModel.QuantityToAdd > orderItem.OpenQty) - shipmentItemModel.QuantityToAdd = orderItem.OpenQty; - - //ok. we have at least one item. create a shipment (if it does not exist) - var orderItemTotalWeight = orderItem.ItemWeight * shipmentItemModel.QuantityToAdd; - if (orderItemTotalWeight.HasValue) - { - totalWeight ??= 0; - totalWeight += orderItemTotalWeight.Value; - } - - //create a shipment item - var shipmentItem = new ShipmentItem { - ProductId = orderItem.ProductId, - OrderItemId = orderItem.Id, - Quantity = shipmentItemModel.QuantityToAdd, - WarehouseId = warehouseId, - Attributes = orderItem.Attributes - }; - shipment.ShipmentItems.Add(shipmentItem); - } - - return (shipment, totalWeight); - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs b/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs index 3bcacfa8c..08f735ef8 100644 --- a/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs @@ -24,7 +24,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config // host too via the IStartupApplication assembly scan in StartupBase, since Vendor references // AdminShared. Registering it again here would just be a redundant duplicate of that line. // IOrderViewModelService is likewise registered by Grand.Web.AdminShared's StartupApplication. - services.AddScoped(); + // IShipmentViewModelService is likewise registered by Grand.Web.AdminShared's StartupApplication. services.AddScoped(); services.AddScoped(); } From c01b8d8b1a6e724df4a04b3d8274b499e5761867 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 25 Aug 2026 20:44:44 +0200 Subject: [PATCH 04/12] feat(arch001): add BaseShipmentController List region Adds BaseShipmentController.cs to Grand.Web.AdminShared/Controllers with the List/ShipmentListSelect/ShipmentsByOrder/ShipmentsItemsByShipmentId region only; later tasks (5-8) append further regions to the same file, and Task 9 does the per-host cutover. Behavior notes carried over from the design: - ShipmentsByOrder filters per-shipment via scope.HasAccess rather than gating on the parent order (see in-file doc comment for the equivalence argument per host). - ShipmentsItemsByShipmentId is a deliberate, disclosed behavior change for Store only: Store's original returned a soft Content("") on a store mismatch; unified onto the throwing ArgumentException form used by Admin/Vendor's originals (2 of 3 hosts), since LoadAuthorizedShipment's redirect is the wrong fit for a JSON-grid endpoint. Adds BaseShipmentControllerTests.cs covering List, the conditional DefaultStoreId/DefaultVendorId forcing in ShipmentListSelect (global vs. store vs. vendor scope), per-shipment access filtering in ShipmentsByOrder, and the ArgumentException thrown by ShipmentsItemsByShipmentId on denied access. Grand.Web.AdminShared/Grand.Web.AdminShared.csproj builds with 0 errors. All 6 new tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../BaseShipmentControllerTests.cs | 194 ++++++++++++++++++ .../Controllers/BaseShipmentController.cs | 150 ++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs new file mode 100644 index 000000000..6044c0002 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs @@ -0,0 +1,194 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Shipping; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Pdf; +using Grand.Domain.Orders; +using Grand.Domain.Shipping; +using Grand.Infrastructure; +using Grand.Mediator; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseShipmentControllerTests +{ + // BaseShipmentController is abstract; minimal subclass so actions can be invoked directly. + private class TestShipmentController( + IShipmentViewModelService shipmentViewModelService, + IOrderService orderService, + ITranslationService translationService, + IContextAccessor contextAccessor, + IPdfService pdfService, + IShipmentService shipmentService, + IDateTimeService dateTimeService, + IMediator mediator, + IAdminDataScope scope) + : BaseShipmentController(shipmentViewModelService, orderService, translationService, + contextAccessor, pdfService, shipmentService, dateTimeService, mediator, scope) + { + public Task<(Shipment shipment, IActionResult denied)> LoadAuthorizedShipmentPublic(string id) => + LoadAuthorizedShipment(id); + } + + private TestShipmentController _controller; + private Mock _shipmentViewModelServiceMock; + private Mock _orderServiceMock; + private Mock _shipmentServiceMock; + private Mock> _scopeMock; + + [TestInitialize] + public void Setup() + { + _shipmentViewModelServiceMock = new Mock(); + _orderServiceMock = new Mock(); + _shipmentServiceMock = new Mock(); + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.DefaultVendorId).Returns((string)null); + + var translationServiceMock = new Mock(); + translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + var contextAccessorMock = new Mock(); + var pdfServiceMock = new Mock(); + var dateTimeServiceMock = new Mock(); + var mediatorMock = new Mock(); + + _controller = new TestShipmentController( + _shipmentViewModelServiceMock.Object, + _orderServiceMock.Object, + translationServiceMock.Object, + contextAccessorMock.Object, + pdfServiceMock.Object, + _shipmentServiceMock.Object, + dateTimeServiceMock.Object, + mediatorMock.Object, + _scopeMock.Object); + + var httpContext = new DefaultHttpContext(); + var loggerFactoryMock = new Mock(); + loggerFactoryMock.Setup(l => l.CreateLogger(It.IsAny())).Returns(new Mock().Object); + 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(ILoggerFactory))).Returns(loggerFactoryMock.Object); + requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); + httpContext.RequestServices = requestServicesMock.Object; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + } + + [TestMethod] + public async Task List_ReturnsViewWithPreparedModel() + { + var model = new ShipmentListModel(); + _shipmentViewModelServiceMock.Setup(v => v.PrepareShipmentListModel()).ReturnsAsync(model); + + var result = await _controller.List(); + + var viewResult = result as ViewResult; + Assert.IsNotNull(viewResult); + Assert.AreSame(model, viewResult.Model); + _shipmentViewModelServiceMock.Verify(v => v.PrepareShipmentListModel(), Times.Once); + } + + [TestMethod] + public async Task ShipmentListSelect_GlobalScope_DoesNotForceStoreOrVendorId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.DefaultVendorId).Returns((string)null); + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipments(It.IsAny(), 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var model = new ShipmentListModel { StoreId = "submitted-store", VendorId = "submitted-vendor" }; + await _controller.ShipmentListSelect(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("submitted-store", model.StoreId); + Assert.AreEqual("submitted-vendor", model.VendorId); + } + + [TestMethod] + public async Task ShipmentListSelect_StoreScope_ForcesStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _scopeMock.Setup(s => s.DefaultVendorId).Returns((string)null); + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipments(It.IsAny(), 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var model = new ShipmentListModel { StoreId = "attacker-supplied" }; + await _controller.ShipmentListSelect(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("store-1", model.StoreId); + } + + [TestMethod] + public async Task ShipmentListSelect_VendorScope_ForcesVendorId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor-1"); + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipments(It.IsAny(), 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var model = new ShipmentListModel { VendorId = "attacker-supplied" }; + await _controller.ShipmentListSelect(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("vendor-1", model.VendorId); + } + + [TestMethod] + public async Task ShipmentsByOrder_FiltersToAccessibleShipmentsOnly() + { + var order = new Order { Id = "o1" }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + + var accessibleShipment = new Shipment { Id = "s1", OrderId = "o1", CreatedOnUtc = DateTime.UtcNow }; + var deniedShipment = new Shipment { Id = "s2", OrderId = "o1", CreatedOnUtc = DateTime.UtcNow.AddMinutes(1) }; + _shipmentServiceMock.Setup(s => s.GetShipmentsByOrder("o1")) + .ReturnsAsync((IList)new List { accessibleShipment, deniedShipment }); + + _scopeMock.Setup(s => s.HasAccess(accessibleShipment)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(deniedShipment)).ReturnsAsync(false); + + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipmentModel(accessibleShipment, false, false)) + .ReturnsAsync(new ShipmentModel { Id = "s1" }); + + var result = await _controller.ShipmentsByOrder("o1", new DataSourceRequest()); + + var jsonResult = result as JsonResult; + Assert.IsNotNull(jsonResult); + var gridModel = jsonResult.Value as DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + var data = gridModel.Data as List; + Assert.IsNotNull(data); + Assert.AreEqual(1, data.Count); + Assert.AreEqual("s1", data[0].Id); + _shipmentViewModelServiceMock.Verify(v => v.PrepareShipmentModel(deniedShipment, false, false), Times.Never); + } + + [TestMethod] + public async Task ShipmentsItemsByShipmentId_DeniedAccess_Throws() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(false); + + await Assert.ThrowsExactlyAsync(() => + _controller.ShipmentsItemsByShipmentId("s1", new DataSourceRequest())); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs new file mode 100644 index 000000000..844c1958a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs @@ -0,0 +1,150 @@ +using Grand.Business.Core.Commands.Checkout.Shipping; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Shipping; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Pdf; +using Grand.Domain.Orders; +using Grand.Domain.Permissions; +using Grand.Domain.Shipping; +using Grand.Infrastructure; +using Grand.Mediator; +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.Extensions; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Shipments)] +[AutoValidateAntiforgeryToken] +public abstract class BaseShipmentController( + IShipmentViewModelService shipmentViewModelService, + IOrderService orderService, + ITranslationService translationService, + IContextAccessor contextAccessor, + IPdfService pdfService, + IShipmentService shipmentService, + IDateTimeService dateTimeService, + IMediator mediator, + IAdminDataScope scope) + : BaseController +{ + // Exposed for host-specific concrete subclasses (Admin's EditUserFields action needs these + // same fields — primary-constructor parameters aren't visible to derived classes by name in + // C#). + protected IShipmentViewModelService ShipmentViewModelService => shipmentViewModelService; + protected IOrderService OrderService => orderService; + protected ITranslationService TranslationService => translationService; + protected IContextAccessor ContextAccessor => contextAccessor; + protected IPdfService PdfService => pdfService; + protected IShipmentService ShipmentService => shipmentService; + protected IDateTimeService DateTimeService => dateTimeService; + protected IMediator Mediator => mediator; + protected IAdminDataScope Scope => scope; + + /// DRY replacement for the repeated "load shipment, redirect to List if not found or + /// not authorized" pattern found in all 3 original controllers. Not a behavior change — every + /// call site below still individually returns RedirectToAction("List") exactly as the + /// originals did. + protected async Task<(Shipment shipment, IActionResult denied)> LoadAuthorizedShipment(string id) + { + var shipment = await shipmentService.GetShipmentById(id); + if (shipment == null) return (null, RedirectToAction("List")); + if (!await scope.HasAccess(shipment)) return (null, RedirectToAction("List")); + return (shipment, null); + } + + #region Shipments + + public async Task List() + { + var model = await shipmentViewModelService.PrepareShipmentListModel(); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task ShipmentListSelect(DataSourceRequest command, ShipmentListModel model) + { + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + if (scope.DefaultVendorId is not null) model.VendorId = scope.DefaultVendorId; + + var shipments = await shipmentViewModelService.PrepareShipments(model, command.Page, command.PageSize); + var items = new List(); + foreach (var item in shipments.shipments) + items.Add(await shipmentViewModelService.PrepareShipmentModel(item, false)); + + var gridModel = new DataSourceResult { + Data = items, + Total = shipments.totalCount + }; + return Json(gridModel); + } + + /// Filters per-shipment via scope.HasAccess rather than gating on the parent order. + /// Admin: GlobalAdminDataScope.HasAccess is always true, so this is a no-op filter — matches + /// Admin's original, which had no check at all. Store: every shipment under a given order + /// always shares that order's StoreId (PrepareShipment always sets StoreId = order.StoreId, + /// see Task 3 Step 5), so per-shipment filtering produces the same user-visible result as + /// Store's original whole-order Content("") denial, with no possible mixed-store shipment set + /// under one order. Vendor: this is the literal mechanical equivalent of Vendor's original + /// per-shipment HasAccessToShipment loop. + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task ShipmentsByOrder(string orderId, DataSourceRequest command) + { + var order = await orderService.GetOrderById(orderId); + if (order == null || order.Deleted) + throw new ArgumentException("No order found with the specified id"); + + //shipments + var shipmentModels = new List(); + var shipments = (await shipmentService.GetShipmentsByOrder(orderId)) + .OrderBy(s => s.CreatedOnUtc) + .ToList(); + var accessibleShipments = new List(); + foreach (var shipment in shipments) + if (await scope.HasAccess(shipment)) + accessibleShipments.Add(shipment); + + foreach (var shipment in accessibleShipments) + shipmentModels.Add(await shipmentViewModelService.PrepareShipmentModel(shipment, false)); + + var gridModel = new DataSourceResult { + Data = shipmentModels, + Total = shipmentModels.Count + }; + return Json(gridModel); + } + + /// Deliberate, disclosed behavior change for Store only: Store's original returned a + /// soft Content("") on a store mismatch; Admin/Vendor's originals both threw + /// ArgumentException. Unified on the throwing form (2 of 3 hosts' original shape) rather than + /// using LoadAuthorizedShipment (which redirects — wrong fit for a JSON-grid endpoint). Flag + /// this explicitly in this task's commit message and for the final review. + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task ShipmentsItemsByShipmentId(string shipmentId, DataSourceRequest command) + { + var shipment = await shipmentService.GetShipmentById(shipmentId) ?? throw new ArgumentException("No shipment found with the specified id"); + if (!await scope.HasAccess(shipment)) + throw new ArgumentException("No shipment found with the specified id"); + var order = await orderService.GetOrderById(shipment.OrderId) ?? throw new ArgumentException("No order found with the specified id"); + + //shipments + var shipmentModel = await shipmentViewModelService.PrepareShipmentModel(shipment, true); + var gridModel = new DataSourceResult { + Data = shipmentModel.Items, + Total = shipmentModel.Items.Count + }; + + return Json(gridModel); + } + + #endregion +} From 6b1c2187cf3f1793e7ebf0fe5c3fb4ec373f1a91 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 25 Aug 2026 20:51:11 +0200 Subject: [PATCH 05/12] feat(arch001): add BaseShipmentController AddShipment region, thread IAdminDataScope Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../BaseShipmentControllerTests.cs | 176 +++++++++++++++++- .../Controllers/BaseShipmentController.cs | 63 ++++++- 2 files changed, 235 insertions(+), 4 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs index 6044c0002..2997b4d0e 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs @@ -34,9 +34,10 @@ private class TestShipmentController( IShipmentService shipmentService, IDateTimeService dateTimeService, IMediator mediator, - IAdminDataScope scope) + IAdminDataScope scope, + IAdminDataScope orderScope) : BaseShipmentController(shipmentViewModelService, orderService, translationService, - contextAccessor, pdfService, shipmentService, dateTimeService, mediator, scope) + contextAccessor, pdfService, shipmentService, dateTimeService, mediator, scope, orderScope) { public Task<(Shipment shipment, IActionResult denied)> LoadAuthorizedShipmentPublic(string id) => LoadAuthorizedShipment(id); @@ -47,6 +48,7 @@ private class TestShipmentController( private Mock _orderServiceMock; private Mock _shipmentServiceMock; private Mock> _scopeMock; + private Mock> _orderScopeMock; [TestInitialize] public void Setup() @@ -57,6 +59,8 @@ public void Setup() _scopeMock = new Mock>(); _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); _scopeMock.Setup(s => s.DefaultVendorId).Returns((string)null); + _orderScopeMock = new Mock>(); + _orderScopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(true); var translationServiceMock = new Mock(); translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); @@ -74,7 +78,8 @@ public void Setup() _shipmentServiceMock.Object, dateTimeServiceMock.Object, mediatorMock.Object, - _scopeMock.Object); + _scopeMock.Object, + _orderScopeMock.Object); var httpContext = new DefaultHttpContext(); var loggerFactoryMock = new Mock(); @@ -191,4 +196,169 @@ public async Task ShipmentsItemsByShipmentId_DeniedAccess_Throws() await Assert.ThrowsExactlyAsync(() => _controller.ShipmentsItemsByShipmentId("s1", new DataSourceRequest())); } + + [TestMethod] + public async Task AddShipmentGet_OrderNotFound_RedirectsToList() + { + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync((Order)null); + + var result = await _controller.AddShipment("o1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _shipmentViewModelServiceMock.Verify(v => v.PrepareShipmentModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AddShipmentGet_OrderDenied_RedirectsToList() + { + var order = new Order { Id = "o1" }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + _orderScopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(false); + + var result = await _controller.AddShipment("o1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _shipmentViewModelServiceMock.Verify(v => v.PrepareShipmentModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AddShipmentPost_NoItemsSelected_ShowsErrorAndRedirects() + { + var order = new Order { Id = "o1" }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + _orderScopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); + _scopeMock.Setup(s => s.FilterOrderItems(It.IsAny>())) + .Returns((IEnumerable items) => items); + + var emptyShipment = new Shipment { Id = "s1" }; + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipment(order, It.IsAny>(), It.IsAny())) + .ReturnsAsync((emptyShipment, (double?)null)); + + var model = new AddShipmentModel { OrderId = "o1" }; + var result = await _controller.AddShipment(model, false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("AddShipment", redirect.ActionName); + Assert.AreEqual("o1", redirect.RouteValues["orderId"]); + _shipmentServiceMock.Verify(s => s.InsertShipment(It.IsAny()), Times.Never); + _shipmentViewModelServiceMock.Verify(v => v.ValidStockShipment(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AddShipmentPost_OutOfStock_ShowsErrorAndRedirects() + { + var order = new Order { Id = "o1" }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + _orderScopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); + _scopeMock.Setup(s => s.FilterOrderItems(It.IsAny>())) + .Returns((IEnumerable items) => items); + + var shipment = new Shipment { Id = "s1" }; + shipment.ShipmentItems.Add(new ShipmentItem { OrderItemId = "oi1" }); + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipment(order, It.IsAny>(), It.IsAny())) + .ReturnsAsync((shipment, (double?)10)); + _shipmentViewModelServiceMock + .Setup(v => v.ValidStockShipment(shipment)) + .ReturnsAsync((false, "Out of stock")); + + var model = new AddShipmentModel { OrderId = "o1" }; + var result = await _controller.AddShipment(model, false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("AddShipment", redirect.ActionName); + Assert.AreEqual("o1", redirect.RouteValues["orderId"]); + _shipmentServiceMock.Verify(s => s.InsertShipment(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AddShipmentPost_Success_ContinueEditing_RedirectsToShipmentDetails() + { + var order = new Order { Id = "o1" }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + _orderScopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); + _scopeMock.Setup(s => s.FilterOrderItems(It.IsAny>())) + .Returns((IEnumerable items) => items); + + var shipment = new Shipment { Id = "s1" }; + shipment.ShipmentItems.Add(new ShipmentItem { OrderItemId = "oi1" }); + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipment(order, It.IsAny>(), It.IsAny())) + .ReturnsAsync((shipment, (double?)10)); + _shipmentViewModelServiceMock + .Setup(v => v.ValidStockShipment(shipment)) + .ReturnsAsync((true, (string)null)); + + var model = new AddShipmentModel { OrderId = "o1" }; + var result = await _controller.AddShipment(model, true); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("ShipmentDetails", redirect.ActionName); + Assert.AreEqual("s1", redirect.RouteValues["id"]); + Assert.AreEqual(10, shipment.TotalWeight); + _shipmentServiceMock.Verify(s => s.InsertShipment(shipment), Times.Once); + _orderServiceMock.Verify(s => s.InsertOrderNote(It.Is(n => n.OrderId == "o1")), Times.Once); + } + + [TestMethod] + public async Task AddShipmentPost_Success_NotContinueEditing_RedirectsToList() + { + var order = new Order { Id = "o1" }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + _orderScopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); + _scopeMock.Setup(s => s.FilterOrderItems(It.IsAny>())) + .Returns((IEnumerable items) => items); + + var shipment = new Shipment { Id = "s1" }; + shipment.ShipmentItems.Add(new ShipmentItem { OrderItemId = "oi1" }); + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipment(order, It.IsAny>(), It.IsAny())) + .ReturnsAsync((shipment, (double?)10)); + _shipmentViewModelServiceMock + .Setup(v => v.ValidStockShipment(shipment)) + .ReturnsAsync((true, (string)null)); + + var model = new AddShipmentModel { OrderId = "o1" }; + var result = await _controller.AddShipment(model, false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _shipmentServiceMock.Verify(s => s.InsertShipment(shipment), Times.Once); + } + + [TestMethod] + public async Task AddShipmentPost_FiltersOrderItemsThroughScope() + { + var itemKept = new OrderItem { Id = "oi1" }; + var itemFiltered = new OrderItem { Id = "oi2" }; + var order = new Order { Id = "o1" }; + order.OrderItems.Add(itemKept); + order.OrderItems.Add(itemFiltered); + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + _orderScopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); + + var filtered = new List { itemKept }; + _scopeMock.Setup(s => s.FilterOrderItems(order.OrderItems)).Returns(filtered); + + var shipment = new Shipment { Id = "s1" }; + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipment(order, filtered, It.IsAny())) + .ReturnsAsync((shipment, (double?)null)); + + var model = new AddShipmentModel { OrderId = "o1" }; + await _controller.AddShipment(model, false); + + _scopeMock.Verify(s => s.FilterOrderItems(order.OrderItems), Times.Once); + _shipmentViewModelServiceMock.Verify( + v => v.PrepareShipment(order, filtered, It.IsAny()), Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs index 844c1958a..a755910a0 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs @@ -31,7 +31,8 @@ public abstract class BaseShipmentController( IShipmentService shipmentService, IDateTimeService dateTimeService, IMediator mediator, - IAdminDataScope scope) + IAdminDataScope scope, + IAdminDataScope orderScope) : BaseController { // Exposed for host-specific concrete subclasses (Admin's EditUserFields action needs these @@ -46,6 +47,7 @@ public abstract class BaseShipmentController( protected IDateTimeService DateTimeService => dateTimeService; protected IMediator Mediator => mediator; protected IAdminDataScope Scope => scope; + protected IAdminDataScope OrderScope => orderScope; /// DRY replacement for the repeated "load shipment, redirect to List if not found or /// not authorized" pattern found in all 3 original controllers. Not a behavior change — every @@ -147,4 +149,63 @@ public async Task ShipmentsItemsByShipmentId(string shipmentId, D } #endregion + + #region AddShipment + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task AddShipment(string orderId) + { + var order = await orderService.GetOrderById(orderId); + if (order == null || order.Deleted || !await orderScope.HasAccess(order)) + //No order found with the specified id + return RedirectToAction("List"); + + var model = await shipmentViewModelService.PrepareShipmentModel(order); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task AddShipment(AddShipmentModel model, bool continueEditing) + { + var order = await orderService.GetOrderById(model.OrderId); + if (order == null || order.Deleted || !await orderScope.HasAccess(order)) + //No order found with the specified id + return RedirectToAction("List"); + + var orderItems = scope.FilterOrderItems(order.OrderItems).ToList(); + + var (shipment, totalWeight) = await shipmentViewModelService.PrepareShipment(order, orderItems, model); + if (shipment == null || !shipment.ShipmentItems.Any()) + { + Error(translationService.GetResource("Admin.Orders.Shipments.NoProductsSelected")); + return RedirectToAction("AddShipment", new { orderId = model.OrderId }); + } + + //check stock + var (valid, message) = await shipmentViewModelService.ValidStockShipment(shipment); + if (!valid) + { + Error(message); + return RedirectToAction("AddShipment", new { orderId = model.OrderId }); + } + + shipment.TotalWeight = totalWeight; + await shipmentService.InsertShipment(shipment); + + //add a note + await orderService.InsertOrderNote(new OrderNote { + Note = $"A shipment #{shipment.ShipmentNumber} has been added", + DisplayToCustomer = false, + OrderId = order.Id + }); + + Success(translationService.GetResource("Admin.Orders.Shipments.Added")); + return continueEditing + ? RedirectToAction("ShipmentDetails", new { id = shipment.Id }) + : RedirectToAction("List", new { id = shipment.Id }); + } + + #endregion } From d921c4eb1487db59f0680f1236f4ad6611983080 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 25 Aug 2026 20:58:57 +0200 Subject: [PATCH 06/12] feat(arch001): add BaseShipmentController details/tracking/ship/deliver region Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../BaseShipmentControllerTests.cs | 211 +++++++++++++++++- .../Controllers/BaseShipmentController.cs | 153 +++++++++++++ 2 files changed, 362 insertions(+), 2 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs index 2997b4d0e..e072b658d 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Commands.Checkout.Shipping; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Shipping; using Grand.Business.Core.Interfaces.Common.Directory; @@ -49,6 +50,7 @@ private class TestShipmentController( private Mock _shipmentServiceMock; private Mock> _scopeMock; private Mock> _orderScopeMock; + private Mock _mediatorMock; [TestInitialize] public void Setup() @@ -67,7 +69,7 @@ public void Setup() var contextAccessorMock = new Mock(); var pdfServiceMock = new Mock(); var dateTimeServiceMock = new Mock(); - var mediatorMock = new Mock(); + _mediatorMock = new Mock(); _controller = new TestShipmentController( _shipmentViewModelServiceMock.Object, @@ -77,7 +79,7 @@ public void Setup() pdfServiceMock.Object, _shipmentServiceMock.Object, dateTimeServiceMock.Object, - mediatorMock.Object, + _mediatorMock.Object, _scopeMock.Object, _orderScopeMock.Object); @@ -361,4 +363,209 @@ public async Task AddShipmentPost_FiltersOrderItemsThroughScope() _shipmentViewModelServiceMock.Verify( v => v.PrepareShipment(order, filtered, It.IsAny()), Times.Once); } + + [TestMethod] + public async Task ShipmentDetails_Denied_RedirectsToList() + { + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync((Shipment)null); + + var result = await _controller.ShipmentDetails("s1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ShipmentDetails_Authorized_ReturnsViewWithModel() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + var order = new Order { Id = "o1" }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + + var model = new ShipmentModel { Id = "s1" }; + _shipmentViewModelServiceMock.Setup(v => v.PrepareShipmentModel(shipment, true, true)).ReturnsAsync(model); + + var result = await _controller.ShipmentDetails("s1"); + + var viewResult = result as ViewResult; + Assert.IsNotNull(viewResult); + Assert.AreSame(model, viewResult.Model); + } + + [TestMethod] + public async Task DeleteShipment_Authorized_DeletesAndAddsOrderNote() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1", ShipmentNumber = 5 }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + var order = new Order { Id = "o1" }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + + var result = await _controller.DeleteShipment("s1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("Order", redirect.ControllerName); + Assert.AreEqual("o1", redirect.RouteValues["Id"]); + _shipmentServiceMock.Verify(s => s.DeleteShipment(shipment), Times.Once); + _orderServiceMock.Verify(s => s.InsertOrderNote(It.Is(n => n.OrderId == "o1")), Times.Once); + } + + [TestMethod] + public async Task SetTrackingNumber_Denied_RedirectsToList() + { + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync((Shipment)null); + + var result = await _controller.SetTrackingNumber(new ShipmentTrackingModel("s1", "TRACK1")); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _shipmentServiceMock.Verify(s => s.UpdateShipment(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SetTrackingNumber_Authorized_UpdatesShipment() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + var result = await _controller.SetTrackingNumber(new ShipmentTrackingModel("s1", "TRACK1")); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("ShipmentDetails", redirect.ActionName); + Assert.AreEqual("s1", redirect.RouteValues["id"]); + Assert.AreEqual("TRACK1", shipment.TrackingNumber); + _shipmentServiceMock.Verify(s => s.UpdateShipment(shipment), Times.Once); + } + + [TestMethod] + public async Task SetShipmentAdminComment_Authorized_UpdatesShipment() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + var result = await _controller.SetShipmentAdminComment(new ShipmentAdminCommentModel("s1", "a comment")); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("ShipmentDetails", redirect.ActionName); + Assert.AreEqual("s1", redirect.RouteValues["id"]); + Assert.AreEqual("a comment", shipment.AdminComment); + _shipmentServiceMock.Verify(s => s.UpdateShipment(shipment), Times.Once); + } + + [TestMethod] + public async Task SetAsShipped_MediatorThrows_ShowsErrorAndRedirects() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + _mediatorMock.Setup(m => m.Send(It.IsAny(), It.IsAny())).ThrowsAsync(new Exception("boom")); + + var result = await _controller.SetAsShipped("s1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("ShipmentDetails", redirect.ActionName); + Assert.AreEqual("s1", redirect.RouteValues["id"]); + } + + [TestMethod] + public async Task SetAsShipped_Success_RedirectsToShipmentDetails() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + _mediatorMock.Setup(m => m.Send(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + var result = await _controller.SetAsShipped("s1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("ShipmentDetails", redirect.ActionName); + Assert.AreEqual("s1", redirect.RouteValues["id"]); + _mediatorMock.Verify(m => m.Send(It.Is(c => c.Shipment == shipment && c.NotifyCustomer), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task EditShippedDate_MissingDate_ShowsErrorAndRedirects() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + var result = await _controller.EditShippedDate(new ShipmentShippedDateModel("s1", null)); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("ShipmentDetails", redirect.ActionName); + Assert.AreEqual("s1", redirect.RouteValues["id"]); + _shipmentServiceMock.Verify(s => s.UpdateShipment(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EditShippedDate_ValidDate_UpdatesAndRedirects() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + var shippedDate = DateTime.UtcNow; + var result = await _controller.EditShippedDate(new ShipmentShippedDateModel("s1", shippedDate)); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("ShipmentDetails", redirect.ActionName); + Assert.AreEqual("s1", redirect.RouteValues["id"]); + Assert.AreEqual(shippedDate, shipment.ShippedDateUtc); + _shipmentServiceMock.Verify(s => s.UpdateShipment(shipment), Times.Once); + } + + [TestMethod] + public async Task SetAsDelivered_Success_RedirectsToShipmentDetails() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + _mediatorMock.Setup(m => m.Send(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + var result = await _controller.SetAsDelivered("s1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("ShipmentDetails", redirect.ActionName); + Assert.AreEqual("s1", redirect.RouteValues["id"]); + _mediatorMock.Verify(m => m.Send(It.Is(c => c.Shipment == shipment && c.NotifyCustomer), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task EditDeliveryDate_ValidDate_UpdatesAndRedirects() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + var deliveryDate = DateTime.UtcNow; + var result = await _controller.EditDeliveryDate(new ShipmentDeliveryDateModel("s1", deliveryDate)); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("ShipmentDetails", redirect.ActionName); + Assert.AreEqual("s1", redirect.RouteValues["id"]); + Assert.AreEqual(deliveryDate, shipment.DeliveryDateUtc); + _shipmentServiceMock.Verify(s => s.UpdateShipment(shipment), Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs index a755910a0..d9ed3cb0d 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs @@ -208,4 +208,157 @@ await orderService.InsertOrderNote(new OrderNote { } #endregion + + #region Shipment details + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task ShipmentDetails(string id) + { + var (shipment, denied) = await LoadAuthorizedShipment(id); + if (denied != null) return denied; + + var order = await orderService.GetOrderById(shipment.OrderId); + if (order == null) + //No order found with the specified id + return RedirectToAction("List"); + + var model = await shipmentViewModelService.PrepareShipmentModel(shipment, true, true); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task DeleteShipment(string id) + { + var (shipment, denied) = await LoadAuthorizedShipment(id); + if (denied != null) return denied; + + var order = await orderService.GetOrderById(shipment.OrderId); + if (order == null) + //No order found with the specified id + return RedirectToAction("List"); + + await shipmentService.DeleteShipment(shipment); + + //add a note + await orderService.InsertOrderNote(new OrderNote { + Note = $"A shipment #{shipment.ShipmentNumber} has been deleted", + DisplayToCustomer = false, + OrderId = order.Id + }); + + Success(translationService.GetResource("Admin.Orders.Shipments.Deleted")); + + return RedirectToAction("Edit", "Order", new { order.Id }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SetTrackingNumber(ShipmentTrackingModel model) + { + var (shipment, denied) = await LoadAuthorizedShipment(model.Id); + if (denied != null) return denied; + + shipment.TrackingNumber = model.TrackingNumber; + await shipmentService.UpdateShipment(shipment); + + return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SetShipmentAdminComment(ShipmentAdminCommentModel model) + { + var (shipment, denied) = await LoadAuthorizedShipment(model.Id); + if (denied != null) return denied; + + shipment.AdminComment = model.AdminComment; + await shipmentService.UpdateShipment(shipment); + + return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SetAsShipped(string id) + { + var (shipment, denied) = await LoadAuthorizedShipment(id); + if (denied != null) return denied; + + try + { + await mediator.Send(new ShipCommand { Shipment = shipment, NotifyCustomer = true }); + return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task EditShippedDate(ShipmentShippedDateModel model) + { + var (shipment, denied) = await LoadAuthorizedShipment(model.Id); + if (denied != null) return denied; + + try + { + if (!model.ShippedDate.HasValue) throw new Exception("Enter shipped date"); + + shipment.ShippedDateUtc = model.ShippedDate.ConvertToUtcTime(dateTimeService); + await shipmentService.UpdateShipment(shipment); + return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SetAsDelivered(string id) + { + var (shipment, denied) = await LoadAuthorizedShipment(id); + if (denied != null) return denied; + + try + { + await mediator.Send(new DeliveryCommand { Shipment = shipment, NotifyCustomer = true }); + return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task EditDeliveryDate(ShipmentDeliveryDateModel model) + { + var (shipment, denied) = await LoadAuthorizedShipment(model.Id); + if (denied != null) return denied; + + try + { + if (!model.DeliveryDate.HasValue) throw new Exception("Enter delivery date"); + + shipment.DeliveryDateUtc = model.DeliveryDate.ConvertToUtcTime(dateTimeService); + await shipmentService.UpdateShipment(shipment); + return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); + } + } + + #endregion } From 2cd2dbd2568bd51bd8e91189219560a54ce32880 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 25 Aug 2026 21:04:26 +0200 Subject: [PATCH 07/12] feat(arch001): add BaseShipmentController PDF export + bulk-action region Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../BaseShipmentControllerTests.cs | 119 +++++++++++++++ .../Controllers/BaseShipmentController.cs | 143 ++++++++++++++++++ 2 files changed, 262 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs index e072b658d..16f44c431 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs @@ -4,6 +4,7 @@ using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Pdf; +using Grand.Domain.Localization; using Grand.Domain.Orders; using Grand.Domain.Shipping; using Grand.Infrastructure; @@ -67,6 +68,9 @@ public void Setup() var translationServiceMock = new Mock(); translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); var contextAccessorMock = new Mock(); + var workContextMock = new Mock(); + workContextMock.Setup(w => w.WorkingLanguage).Returns(new Language { Id = "lang-1" }); + contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); var pdfServiceMock = new Mock(); var dateTimeServiceMock = new Mock(); _mediatorMock = new Mock(); @@ -568,4 +572,119 @@ public async Task EditDeliveryDate_ValidDate_UpdatesAndRedirects() Assert.AreEqual(deliveryDate, shipment.DeliveryDateUtc); _shipmentServiceMock.Verify(s => s.UpdateShipment(shipment), Times.Once); } + + [TestMethod] + public async Task PdfPackagingSlip_Denied_RedirectsToList() + { + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync((Shipment)null); + + var result = await _controller.PdfPackagingSlip("s1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _orderServiceMock.Verify(s => s.GetOrderById(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PdfPackagingSlipAll_NoShipments_ShowsErrorAndRedirects() + { + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipments(It.IsAny(), 1, 100)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var result = await _controller.PdfPackagingSlipAll(new ShipmentListModel()); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task PdfPackagingSlipAll_ForcesStoreAndVendorIdConditionally() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor-1"); + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipments(It.IsAny(), 1, 100)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var model = new ShipmentListModel { StoreId = "attacker-store", VendorId = "attacker-vendor" }; + await _controller.PdfPackagingSlipAll(model); + + Assert.AreEqual("store-1", model.StoreId); + Assert.AreEqual("vendor-1", model.VendorId); + } + + [TestMethod] + public async Task PdfPackagingSlipSelected_FiltersToAccessibleShipments() + { + var accessibleShipment = new Shipment { Id = "s1" }; + var deniedShipment = new Shipment { Id = "s2" }; + _shipmentServiceMock + .Setup(s => s.GetShipmentsByIds(new[] { "s1", "s2" })) + .ReturnsAsync((IList)new List { accessibleShipment, deniedShipment }); + _scopeMock.Setup(s => s.HasAccess(accessibleShipment)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(deniedShipment)).ReturnsAsync(false); + + var result = await _controller.PdfPackagingSlipSelected("s1,s2"); + + var fileResult = result as FileContentResult; + Assert.IsNotNull(fileResult); + Assert.AreEqual("packagingslips.pdf", fileResult.FileDownloadName); + _scopeMock.Verify(s => s.HasAccess(accessibleShipment), Times.Once); + _scopeMock.Verify(s => s.HasAccess(deniedShipment), Times.Once); + } + + [TestMethod] + public async Task SetAsShippedSelected_FiltersToAccessibleShipments_IgnoresPerItemExceptions() + { + var accessibleShipment1 = new Shipment { Id = "s1" }; + var accessibleShipment2 = new Shipment { Id = "s2" }; + var deniedShipment = new Shipment { Id = "s3" }; + _shipmentServiceMock + .Setup(s => s.GetShipmentsByIds(new[] { "s1", "s2", "s3" })) + .ReturnsAsync((IList)new List { accessibleShipment1, accessibleShipment2, deniedShipment }); + _scopeMock.Setup(s => s.HasAccess(accessibleShipment1)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(accessibleShipment2)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(deniedShipment)).ReturnsAsync(false); + + _mediatorMock + .Setup(m => m.Send(It.Is(c => c.Shipment == accessibleShipment1), It.IsAny())) + .ThrowsAsync(new Exception("boom")); + _mediatorMock + .Setup(m => m.Send(It.Is(c => c.Shipment == accessibleShipment2), It.IsAny())) + .ReturnsAsync(true); + + var result = await _controller.SetAsShippedSelected(new List { "s1", "s2", "s3" }); + + var jsonResult = result as JsonResult; + Assert.IsNotNull(jsonResult); + _mediatorMock.Verify(m => m.Send(It.Is(c => c.Shipment == accessibleShipment1), It.IsAny()), Times.Once); + _mediatorMock.Verify(m => m.Send(It.Is(c => c.Shipment == accessibleShipment2), It.IsAny()), Times.Once); + _mediatorMock.Verify(m => m.Send(It.Is(c => c.Shipment == deniedShipment), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SetAsDeliveredSelected_FiltersToAccessibleShipments() + { + var accessibleShipment = new Shipment { Id = "s1" }; + var deniedShipment = new Shipment { Id = "s2" }; + _shipmentServiceMock + .Setup(s => s.GetShipmentsByIds(new[] { "s1", "s2" })) + .ReturnsAsync((IList)new List { accessibleShipment, deniedShipment }); + _scopeMock.Setup(s => s.HasAccess(accessibleShipment)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(deniedShipment)).ReturnsAsync(false); + + _mediatorMock + .Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await _controller.SetAsDeliveredSelected(new List { "s1", "s2" }); + + var jsonResult = result as JsonResult; + Assert.IsNotNull(jsonResult); + _mediatorMock.Verify(m => m.Send(It.Is(c => c.Shipment == accessibleShipment), It.IsAny()), Times.Once); + _mediatorMock.Verify(m => m.Send(It.Is(c => c.Shipment == deniedShipment), It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs index d9ed3cb0d..bf8e4b5d6 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs @@ -361,4 +361,147 @@ public async Task EditDeliveryDate(ShipmentDeliveryDateModel mode } #endregion + + #region PDF export and bulk actions + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task PdfPackagingSlip(string shipmentId) + { + var (shipment, denied) = await LoadAuthorizedShipment(shipmentId); + if (denied != null) return denied; + + var order = await orderService.GetOrderById(shipment.OrderId); + if (order == null) + //No order found with the specified id + return RedirectToAction("List"); + + var shipments = new List { shipment }; + + byte[] bytes; + using (var stream = new MemoryStream()) + { + await pdfService.PrintPackagingSlipsToPdf(stream, shipments, contextAccessor.WorkContext.WorkingLanguage.Id); + bytes = stream.ToArray(); + } + + return File(bytes, "application/pdf", $"packagingslip_{shipment.Id}.pdf"); + } + + [PermissionAuthorizeAction(PermissionActionName.Export)] + [HttpPost] + public async Task PdfPackagingSlipAll(ShipmentListModel model) + { + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + if (scope.DefaultVendorId is not null) model.VendorId = scope.DefaultVendorId; + + //load shipments + var shipments = await shipmentViewModelService.PrepareShipments(model, 1, 100); + + //ensure that we at least one shipment selected + if (shipments.totalCount == 0) + { + Error(translationService.GetResource("Admin.Orders.Shipments.NoShipmentsSelected")); + return RedirectToAction("List"); + } + + byte[] bytes; + using (var stream = new MemoryStream()) + { + await pdfService.PrintPackagingSlipsToPdf(stream, shipments.shipments.ToList(), + contextAccessor.WorkContext.WorkingLanguage.Id); + bytes = stream.ToArray(); + } + + return File(bytes, "application/pdf", "packagingslips.pdf"); + } + + [PermissionAuthorizeAction(PermissionActionName.Export)] + [HttpPost] + public async Task PdfPackagingSlipSelected(string selectedIds) + { + var shipments = new List(); + if (selectedIds != null) + { + var ids = selectedIds + .Split([','], StringSplitOptions.RemoveEmptyEntries) + .Select(x => x) + .ToArray(); + shipments.AddRange(await shipmentService.GetShipmentsByIds(ids)); + } + + var accessibleShipments = new List(); + foreach (var s in shipments) + if (await scope.HasAccess(s)) + accessibleShipments.Add(s); + + //ensure that we at least one shipment selected — checks the unfiltered count, matching + //all 3 originals' pre-existing (and slightly inconsistent) behavior; see the disclosed, + //not-fixed note below this method + if (shipments.Count == 0) + { + Error(translationService.GetResource("Admin.Orders.Shipments.NoShipmentsSelected")); + return RedirectToAction("List"); + } + + byte[] bytes; + using (var stream = new MemoryStream()) + { + await pdfService.PrintPackagingSlipsToPdf(stream, accessibleShipments, contextAccessor.WorkContext.WorkingLanguage.Id); + bytes = stream.ToArray(); + } + + return File(bytes, "application/pdf", "packagingslips.pdf"); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SetAsShippedSelected(ICollection selectedIds) + { + var shipments = new List(); + if (selectedIds != null) shipments.AddRange(await shipmentService.GetShipmentsByIds(selectedIds.ToArray())); + + var accessibleShipments = new List(); + foreach (var s in shipments) + if (await scope.HasAccess(s)) + accessibleShipments.Add(s); + + foreach (var shipment in accessibleShipments) + try + { + await mediator.Send(new ShipCommand { Shipment = shipment, NotifyCustomer = true }); + } + catch + { + //ignore any exception + } + + return Json(new { Result = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SetAsDeliveredSelected(ICollection selectedIds) + { + var shipments = new List(); + if (selectedIds != null) shipments.AddRange(await shipmentService.GetShipmentsByIds(selectedIds.ToArray())); + + var accessibleShipments = new List(); + foreach (var s in shipments) + if (await scope.HasAccess(s)) + accessibleShipments.Add(s); + + foreach (var shipment in accessibleShipments) + try + { + await mediator.Send(new DeliveryCommand { Shipment = shipment, NotifyCustomer = true }); + } + catch + { + //ignore any exception + } + + return Json(new { Result = true }); + } + + #endregion } From bc1ac203888d2f2bdfe0397c34a2d11b71cee1ec Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 25 Aug 2026 21:09:19 +0200 Subject: [PATCH 08/12] =?UTF-8?q?feat(arch001):=20add=20BaseShipmentContro?= =?UTF-8?q?ller=20notes=20region=20=E2=80=94=20controller/service=20layer?= =?UTF-8?q?=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../BaseShipmentControllerTests.cs | 90 +++++++++++++++++++ .../Controllers/BaseShipmentController.cs | 47 ++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs index 16f44c431..d599c1569 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs @@ -687,4 +687,94 @@ public async Task SetAsDeliveredSelected_FiltersToAccessibleShipments() _mediatorMock.Verify(m => m.Send(It.Is(c => c.Shipment == accessibleShipment), It.IsAny()), Times.Once); _mediatorMock.Verify(m => m.Send(It.Is(c => c.Shipment == deniedShipment), It.IsAny()), Times.Never); } + + [TestMethod] + public async Task ShipmentNotesSelect_Denied_Throws() + { + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync((Shipment)null); + + await Assert.ThrowsExactlyAsync(() => + _controller.ShipmentNotesSelect("s1", new DataSourceRequest())); + } + + [TestMethod] + public async Task ShipmentNotesSelect_Authorized_ReturnsNotes() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + var notes = new List { new() { Id = "n1" } }; + _shipmentViewModelServiceMock.Setup(v => v.PrepareShipmentNotes(shipment)).ReturnsAsync(notes); + + var result = await _controller.ShipmentNotesSelect("s1", new DataSourceRequest()); + + var jsonResult = result as JsonResult; + Assert.IsNotNull(jsonResult); + var gridModel = jsonResult.Value as DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + Assert.AreSame(notes, gridModel.Data); + } + + [TestMethod] + public async Task ShipmentNoteAdd_Denied_ReturnsResultFalse() + { + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync((Shipment)null); + + var result = await _controller.ShipmentNoteAdd("s1", "download-1", true, "hello"); + + var jsonResult = result as JsonResult; + Assert.IsNotNull(jsonResult); + var value = jsonResult.Value; + var resultProperty = value.GetType().GetProperty("Result"); + Assert.IsNotNull(resultProperty); + Assert.AreEqual(false, resultProperty.GetValue(value)); + _shipmentViewModelServiceMock.Verify( + v => v.InsertShipmentNote(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ShipmentNoteAdd_Authorized_PassesDownloadIdThrough() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + var result = await _controller.ShipmentNoteAdd("s1", "download-1", true, "hello"); + + var jsonResult = result as JsonResult; + Assert.IsNotNull(jsonResult); + var value = jsonResult.Value; + var resultProperty = value.GetType().GetProperty("Result"); + Assert.IsNotNull(resultProperty); + Assert.AreEqual(true, resultProperty.GetValue(value)); + _shipmentViewModelServiceMock.Verify( + v => v.InsertShipmentNote(shipment, "download-1", true, "hello"), Times.Once); + } + + [TestMethod] + public async Task ShipmentNoteDelete_Denied_Throws() + { + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync((Shipment)null); + + await Assert.ThrowsExactlyAsync(() => + _controller.ShipmentNoteDelete("n1", "s1")); + } + + [TestMethod] + public async Task ShipmentNoteDelete_Authorized_DeletesNote() + { + var shipment = new Shipment { Id = "s1", OrderId = "o1" }; + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync(shipment); + _scopeMock.Setup(s => s.HasAccess(shipment)).ReturnsAsync(true); + + var result = await _controller.ShipmentNoteDelete("n1", "s1"); + + var jsonResult = result as JsonResult; + Assert.IsNotNull(jsonResult); + Assert.AreEqual("", jsonResult.Value); + _shipmentViewModelServiceMock.Verify(v => v.DeleteShipmentNote(shipment, "n1"), Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs index bf8e4b5d6..5df1f1bf9 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs @@ -504,4 +504,51 @@ public async Task SetAsDeliveredSelected(ICollection sele } #endregion + + #region Shipment notes + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ShipmentNotesSelect(string shipmentId, DataSourceRequest command) + { + var shipment = await shipmentService.GetShipmentById(shipmentId); + if (shipment == null || !await scope.HasAccess(shipment)) + throw new ArgumentException("No shipment found with the specified id"); + + //shipment notes + var shipmentNoteModels = await shipmentViewModelService.PrepareShipmentNotes(shipment); + var gridModel = new DataSourceResult { + Data = shipmentNoteModels, + Total = shipmentNoteModels.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ShipmentNoteAdd(string shipmentId, string downloadId, bool displayToCustomer, + string message) + { + var shipment = await shipmentService.GetShipmentById(shipmentId); + if (shipment == null || !await scope.HasAccess(shipment)) + return Json(new { Result = false }); + + await shipmentViewModelService.InsertShipmentNote(shipment, downloadId, displayToCustomer, message); + + return Json(new { Result = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task ShipmentNoteDelete(string id, string shipmentId) + { + var shipment = await shipmentService.GetShipmentById(shipmentId); + if (shipment == null || !await scope.HasAccess(shipment)) + throw new ArgumentException("No shipment found with the specified id"); + + await shipmentViewModelService.DeleteShipmentNote(shipment, id); + + return new JsonResult(""); + } + + #endregion } From 3fab6ee9821bca760073f43daa5e2e2aecfc2b78 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 25 Aug 2026 21:20:10 +0200 Subject: [PATCH 09/12] feat(arch001): cut Admin/Store/Vendor ShipmentController over to thin BaseShipmentController subclasses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite Admin/Store/Vendor ShipmentController.cs as thin subclasses of Grand.Web.AdminShared.Controllers.BaseShipmentController (Tasks 4-8). - Admin keeps its own EditUserFields action (never shared, per spec). - Attribute sets verified against each host's real, already-merged OrderController.cs rather than the plan brief's transcription: Admin: [AuthorizeAdmin][AutoValidateAntiforgeryToken][Area(Constants.AreaAdmin)][AuthorizeMenu] Store: [AutoValidateAntiforgeryToken][Area(Constants.AreaStore)][AuthorizeStore][AuthorizeMenu] Vendor: [AutoValidateAntiforgeryToken][Area(Constants.AreaVendor)][AuthorizeVendor][AuthorizeMenu] (Vendor's OrderController.cs does declare [AuthorizeMenu], unlike the brief's Vendor ShipmentController snippet which omitted it.) - None of the three restate [PermissionAuthorize(PermissionSystemName.Shipments)] at the subclass level, matching BaseOrderController's established precedent — it lives once on BaseShipmentController. - No per-host ShipmentControllerTests.cs existed to trim; BaseShipmentControllerTests.cs (behavioral, base-level) is untouched. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../Controllers/ShipmentController.cs | 572 +--------------- .../Controllers/ShipmentController.cs | 641 +----------------- .../Controllers/ShipmentController.cs | 553 +-------------- 3 files changed, 80 insertions(+), 1686 deletions(-) diff --git a/src/Web/Grand.Web.Admin/Controllers/ShipmentController.cs b/src/Web/Grand.Web.Admin/Controllers/ShipmentController.cs index cd8348952..eac3cbc0d 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ShipmentController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ShipmentController.cs @@ -1,4 +1,3 @@ -using Grand.Business.Core.Commands.Checkout.Shipping; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Shipping; using Grand.Business.Core.Interfaces.Common.Directory; @@ -8,565 +7,54 @@ using Grand.Domain.Permissions; using Grand.Domain.Shipping; using Grand.Infrastructure; +using Grand.Mediator; +using Grand.Web.Admin.Extensions; +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.Extensions; using Grand.Web.Common.Filters; using Grand.Web.Common.Security.Authorization; -using Grand.Mediator; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Admin.Controllers; -[PermissionAuthorize(PermissionSystemName.Shipments)] -public class ShipmentController : BaseAdminController +// Concrete host subclass of BaseShipmentController (ARCH-001 Shipment consolidation). This class +// supplies Admin's DI wiring plus the attributes that used to arrive transitively via +// BaseAdminController - BaseShipmentController can't inherit any single host's base controller +// (it's shared across Admin/Store/Vendor, each with a different [Area]/[Authorize*] pair), so each +// subclass restates its own host's attribute set explicitly, same pattern as OrderController. +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ShipmentController( + IShipmentViewModelService shipmentViewModelService, + IOrderService orderService, + ITranslationService translationService, + IContextAccessor contextAccessor, + IPdfService pdfService, + IShipmentService shipmentService, + IDateTimeService dateTimeService, + IMediator mediator, + IAdminDataScope scope, + IAdminDataScope orderScope) + : BaseShipmentController(shipmentViewModelService, orderService, translationService, + contextAccessor, pdfService, shipmentService, dateTimeService, mediator, scope, orderScope) { - public ShipmentController( - IShipmentViewModelService shipmentViewModelService, - IOrderService orderService, - ITranslationService translationService, - IContextAccessor contextAccessor, - IPdfService pdfService, - IShipmentService shipmentService, - IDateTimeService dateTimeService, - IMediator mediator) - { - _shipmentViewModelService = shipmentViewModelService; - _orderService = orderService; - _translationService = translationService; - _contextAccessor = contextAccessor; - _pdfService = pdfService; - _shipmentService = shipmentService; - _dateTimeService = dateTimeService; - _mediator = mediator; - } - - #region Fields - - private readonly IShipmentViewModelService _shipmentViewModelService; - private readonly IOrderService _orderService; - private readonly ITranslationService _translationService; - private readonly IContextAccessor _contextAccessor; - private readonly IPdfService _pdfService; - private readonly IShipmentService _shipmentService; - private readonly IDateTimeService _dateTimeService; - private readonly IMediator _mediator; - - #endregion - - #region Shipments - - public async Task List() - { - var model = await _shipmentViewModelService.PrepareShipmentListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ShipmentListSelect(DataSourceRequest command, ShipmentListModel model) - { - var shipments = await _shipmentViewModelService.PrepareShipments(model, command.Page, command.PageSize); - var items = new List(); - foreach (var item in shipments.shipments) - items.Add(await _shipmentViewModelService.PrepareShipmentModel(item, false)); - - var gridModel = new DataSourceResult { - Data = items, - Total = shipments.totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ShipmentsByOrder(string orderId, DataSourceRequest command) - { - var order = await _orderService.GetOrderById(orderId) ?? throw new ArgumentException("No order found with the specified id"); - - //shipments - var shipmentModels = new List(); - var shipments = (await _shipmentService.GetShipmentsByOrder(orderId)) - .OrderBy(s => s.CreatedOnUtc) - .ToList(); - foreach (var shipment in shipments) - shipmentModels.Add(await _shipmentViewModelService.PrepareShipmentModel(shipment, false)); - - var gridModel = new DataSourceResult { - Data = shipmentModels, - Total = shipmentModels.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ShipmentsItemsByShipmentId(string shipmentId, DataSourceRequest command) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId) ?? throw new ArgumentException("No shipment found with the specified id"); - var order = await _orderService.GetOrderById(shipment.OrderId) ?? throw new ArgumentException("No order found with the specified id"); - - //shipments - var shipmentModel = await _shipmentViewModelService.PrepareShipmentModel(shipment, true); - var gridModel = new DataSourceResult { - Data = shipmentModel.Items, - Total = shipmentModel.Items.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task AddShipment(string orderId) - { - var order = await _orderService.GetOrderById(orderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - var model = await _shipmentViewModelService.PrepareShipmentModel(order); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task AddShipment(AddShipmentModel model, bool continueEditing) - { - var order = await _orderService.GetOrderById(model.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - var orderItems = order.OrderItems; - - var sh = await _shipmentViewModelService.PrepareShipment(order, orderItems.ToList(), model); - if (sh.shipment == null) - return RedirectToAction("AddShipment", new { orderId = model.OrderId }); - - var shipment = sh.shipment; - //check stock - var (valid, message) = await _shipmentViewModelService.ValidStockShipment(shipment); - if (!valid) - { - Error(message); - return RedirectToAction("AddShipment", new { orderId = model.OrderId }); - } - - //if we have at least one item in the shipment, then save it - if (shipment.ShipmentItems.Count > 0) - { - shipment.TotalWeight = sh.totalWeight; - await _shipmentService.InsertShipment(shipment); - - //add a note - await _orderService.InsertOrderNote(new OrderNote { - Note = $"A shipment #{shipment.ShipmentNumber} has been added", - DisplayToCustomer = false, - OrderId = order.Id - }); - - Success(_translationService.GetResource("Admin.Orders.Shipments.Added")); - return continueEditing - ? RedirectToAction("ShipmentDetails", new { id = shipment.Id }) - : RedirectToAction("List", new { id = shipment.Id }); - } - - Error(_translationService.GetResource("Admin.Orders.Shipments.NoProductsSelected")); - return RedirectToAction("AddShipment", new { orderId = model.OrderId }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task ShipmentDetails(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - var orderId = shipment.OrderId; - var order = await _orderService.GetOrderById(orderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - var model = await _shipmentViewModelService.PrepareShipmentModel(shipment, true, true); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task DeleteShipment(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - var orderId = shipment.OrderId; - var order = await _orderService.GetOrderById(orderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - //delete shipment - await _shipmentService.DeleteShipment(shipment); - - //add a note - await _orderService.InsertOrderNote(new OrderNote { - Note = $"A shipment #{shipment.ShipmentNumber} has been deleted", - DisplayToCustomer = false, - OrderId = order.Id - }); - - Success(_translationService.GetResource("Admin.Orders.Shipments.Deleted")); - - return RedirectToAction("Edit", "Order", new { order.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetTrackingNumber(ShipmentModel model) - { - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - shipment.TrackingNumber = model.TrackingNumber; - await _shipmentService.UpdateShipment(shipment); - - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetShipmentAdminComment(ShipmentModel model) - { - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - shipment.AdminComment = model.AdminComment; - await _shipmentService.UpdateShipment(shipment); - - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsShipped(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - try - { - await _mediator.Send(new ShipCommand { Shipment = shipment, NotifyCustomer = true }); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task EditShippedDate(ShipmentModel model) - { - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - try - { - if (!model.ShippedDate.HasValue) throw new Exception("Enter shipped date"); - - shipment.ShippedDateUtc = model.ShippedDate.ConvertToUtcTime(_dateTimeService); - await _shipmentService.UpdateShipment(shipment); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsDelivered(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - try - { - await _mediator.Send(new DeliveryCommand { Shipment = shipment, NotifyCustomer = true }); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task EditDeliveryDate(ShipmentModel model) - { - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - try - { - if (!model.DeliveryDate.HasValue) throw new Exception("Enter delivery date"); - - shipment.DeliveryDateUtc = model.DeliveryDate.ConvertToUtcTime(_dateTimeService); - await _shipmentService.UpdateShipment(shipment); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - + // Admin-exclusive action - not shared via BaseShipmentController per the consolidation spec. [PermissionAuthorizeAction(PermissionActionName.Edit)] [HttpPost] public async Task EditUserFields(string id, ShipmentModel model) { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null) - //No order found with the specified id - return RedirectToAction("List"); + var (shipment, denied) = await LoadAuthorizedShipment(id); + if (denied != null) return denied; shipment.UserFields = model.UserFields; - await _shipmentService.UpdateShipment(shipment); + await ShipmentService.UpdateShipment(shipment); //selected tab await SaveSelectedTabIndex(); return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task PdfPackagingSlip(string shipmentId) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null) - //no shipment found with the specified id - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - var shipments = new List { - shipment - }; - - byte[] bytes; - using (var stream = new MemoryStream()) - { - await _pdfService.PrintPackagingSlipsToPdf(stream, shipments, _contextAccessor.WorkContext.WorkingLanguage.Id); - bytes = stream.ToArray(); - } - - return File(bytes, "application/pdf", $"packagingslip_{shipment.Id}.pdf"); - } - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task PdfPackagingSlipAll(ShipmentListModel model) - { - //load shipments - var shipments = await _shipmentViewModelService.PrepareShipments(model, 1, 100); - - //ensure that we at least one shipment selected - if (shipments.totalCount == 0) - { - Error(_translationService.GetResource("Admin.Orders.Shipments.NoShipmentsSelected")); - return RedirectToAction("List"); - } - - byte[] bytes; - using (var stream = new MemoryStream()) - { - await _pdfService.PrintPackagingSlipsToPdf(stream, shipments.shipments.ToList(), - _contextAccessor.WorkContext.WorkingLanguage.Id); - bytes = stream.ToArray(); - } - - return File(bytes, "application/pdf", "packagingslips.pdf"); - } - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task PdfPackagingSlipSelected(string selectedIds) - { - var shipments = new List(); - if (selectedIds != null) - { - var ids = selectedIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x) - .ToArray(); - shipments.AddRange(await _shipmentService.GetShipmentsByIds(ids)); - } - - //ensure that we at least one shipment selected - if (shipments.Count == 0) - { - Error(_translationService.GetResource("Admin.Orders.Shipments.NoShipmentsSelected")); - return RedirectToAction("List"); - } - - byte[] bytes; - using (var stream = new MemoryStream()) - { - await _pdfService.PrintPackagingSlipsToPdf(stream, shipments, _contextAccessor.WorkContext.WorkingLanguage.Id); - bytes = stream.ToArray(); - } - - return File(bytes, "application/pdf", "packagingslips.pdf"); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsShippedSelected(ICollection selectedIds) - { - var shipments = new List(); - - if (selectedIds != null) shipments.AddRange(await _shipmentService.GetShipmentsByIds(selectedIds.ToArray())); - - foreach (var shipment in shipments) - try - { - await _mediator.Send(new ShipCommand { Shipment = shipment, NotifyCustomer = true }); - } - catch - { - //ignore any exception - } - - return Json(new { Result = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsDeliveredSelected(ICollection selectedIds) - { - var shipments = new List(); - if (selectedIds != null) shipments.AddRange(await _shipmentService.GetShipmentsByIds(selectedIds.ToArray())); - - foreach (var shipment in shipments) - try - { - await _mediator.Send(new DeliveryCommand { Shipment = shipment, NotifyCustomer = true }); - } - catch - { - //ignore any exception - } - - return Json(new { Result = true }); - } - - #region Shipment notes - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ShipmentNotesSelect(string shipmentId, DataSourceRequest command) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null) - throw new ArgumentException("No shipment found with the specified id"); - - //shipment notes - var shipmentNoteModels = await _shipmentViewModelService.PrepareShipmentNotes(shipment); - var gridModel = new DataSourceResult { - Data = shipmentNoteModels, - Total = shipmentNoteModels.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ShipmentNoteAdd(string shipmentId, string downloadId, bool displayToCustomer, - string message) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null) - return Json(new { Result = false }); - - await _shipmentViewModelService.InsertShipmentNote(shipment, downloadId, displayToCustomer, message); - - return Json(new { Result = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task ShipmentNoteDelete(string id, string shipmentId) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null) - throw new ArgumentException("No shipment found with the specified id"); - - await _shipmentViewModelService.DeleteShipmentNote(shipment, id); - - return new JsonResult(""); - } - - #endregion - - #endregion -} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.Store/Controllers/ShipmentController.cs b/src/Web/Grand.Web.Store/Controllers/ShipmentController.cs index 0e9c542ae..f632e9dfe 100644 --- a/src/Web/Grand.Web.Store/Controllers/ShipmentController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ShipmentController.cs @@ -1,630 +1,39 @@ -using Grand.Business.Core.Commands.Checkout.Shipping; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Shipping; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Pdf; using Grand.Domain.Orders; -using Grand.Domain.Permissions; using Grand.Domain.Shipping; using Grand.Infrastructure; +using Grand.Mediator; +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.Extensions; using Grand.Web.Common.Filters; -using Grand.Web.Common.Security.Authorization; -using Grand.Mediator; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Store.Controllers; -[PermissionAuthorize(PermissionSystemName.Shipments)] -public class ShipmentController : BaseStoreController -{ - public ShipmentController( - IShipmentViewModelService shipmentViewModelService, - IOrderService orderService, - ITranslationService translationService, - IContextAccessor contextAccessor, - IPdfService pdfService, - IShipmentService shipmentService, - IDateTimeService dateTimeService, - IMediator mediator) - { - _shipmentViewModelService = shipmentViewModelService; - _orderService = orderService; - _translationService = translationService; - _contextAccessor = contextAccessor; - _pdfService = pdfService; - _shipmentService = shipmentService; - _dateTimeService = dateTimeService; - _mediator = mediator; - } - - #region Fields - - private readonly IShipmentViewModelService _shipmentViewModelService; - private readonly IOrderService _orderService; - private readonly ITranslationService _translationService; - private readonly IContextAccessor _contextAccessor; - private readonly IPdfService _pdfService; - private readonly IShipmentService _shipmentService; - private readonly IDateTimeService _dateTimeService; - private readonly IMediator _mediator; - - #endregion - - #region Shipments - - public async Task List() - { - var model = await _shipmentViewModelService.PrepareShipmentListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ShipmentListSelect(DataSourceRequest command, ShipmentListModel model) - { - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var shipments = await _shipmentViewModelService.PrepareShipments(model, command.Page, command.PageSize); - var items = new List(); - foreach (var item in shipments.shipments) - items.Add(await _shipmentViewModelService.PrepareShipmentModel(item, false)); - - var gridModel = new DataSourceResult { - Data = items, - Total = shipments.totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ShipmentsByOrder(string orderId, DataSourceRequest command) - { - var order = await _orderService.GetOrderById(orderId) ?? throw new ArgumentException("No order found with the specified id"); - if (order.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return Content(""); - - //shipments - var shipmentModels = new List(); - var shipments = (await _shipmentService.GetShipmentsByOrder(orderId)) - .OrderBy(s => s.CreatedOnUtc) - .ToList(); - foreach (var shipment in shipments) - shipmentModels.Add(await _shipmentViewModelService.PrepareShipmentModel(shipment, false)); - - var gridModel = new DataSourceResult { - Data = shipmentModels, - Total = shipmentModels.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ShipmentsItemsByShipmentId(string shipmentId, DataSourceRequest command) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId) ?? throw new ArgumentException("No shipment found with the specified id"); - var order = await _orderService.GetOrderById(shipment.OrderId) ?? throw new ArgumentException("No order found with the specified id"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return Content(""); - - //shipments - var shipmentModel = await _shipmentViewModelService.PrepareShipmentModel(shipment, true); - var gridModel = new DataSourceResult { - Data = shipmentModel.Items, - Total = shipmentModel.Items.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task AddShipment(string orderId) - { - var order = await _orderService.GetOrderById(orderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - if (order.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List"); - - var model = await _shipmentViewModelService.PrepareShipmentModel(order); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task AddShipment(AddShipmentModel model, bool continueEditing) - { - var order = await _orderService.GetOrderById(model.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - if (order.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List"); - - var orderItems = order.OrderItems; - - var sh = await _shipmentViewModelService.PrepareShipment(order, orderItems.ToList(), model); - if (sh.shipment == null) - return RedirectToAction("AddShipment", new { orderId = model.OrderId }); - - var shipment = sh.shipment; - //check stock - var (valid, message) = await _shipmentViewModelService.ValidStockShipment(shipment); - if (!valid) - { - Error(message); - return RedirectToAction("AddShipment", new { orderId = model.OrderId }); - } - - //if we have at least one item in the shipment, then save it - if (shipment.ShipmentItems.Count > 0) - { - shipment.TotalWeight = sh.totalWeight; - await _shipmentService.InsertShipment(shipment); - - //add a note - await _orderService.InsertOrderNote(new OrderNote { - Note = $"A shipment #{shipment.ShipmentNumber} has been added", - DisplayToCustomer = false, - OrderId = order.Id - }); - - Success(_translationService.GetResource("Admin.Orders.Shipments.Added")); - return continueEditing - ? RedirectToAction("ShipmentDetails", new { id = shipment.Id }) - : RedirectToAction("List", new { id = shipment.Id }); - } - - Error(_translationService.GetResource("Admin.Orders.Shipments.NoProductsSelected")); - return RedirectToAction("AddShipment", new { orderId = model.OrderId }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task ShipmentDetails(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List"); - - var orderId = shipment.OrderId; - var order = await _orderService.GetOrderById(orderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - var model = await _shipmentViewModelService.PrepareShipmentModel(shipment, true, true); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task DeleteShipment(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List"); - - var orderId = shipment.OrderId; - var order = await _orderService.GetOrderById(orderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - //delete shipment - await _shipmentService.DeleteShipment(shipment); - - //add a note - await _orderService.InsertOrderNote(new OrderNote { - Note = $"A shipment #{shipment.ShipmentNumber} has been deleted", - DisplayToCustomer = false, - OrderId = order.Id - }); - - Success(_translationService.GetResource("Admin.Orders.Shipments.Deleted")); - - return RedirectToAction("Edit", "Order", new { order.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetTrackingNumber(ShipmentModel model) - { - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - shipment.TrackingNumber = model.TrackingNumber; - await _shipmentService.UpdateShipment(shipment); - - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetShipmentAdminComment(ShipmentModel model) - { - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - shipment.AdminComment = model.AdminComment; - await _shipmentService.UpdateShipment(shipment); - - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsShipped(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - try - { - await _mediator.Send(new ShipCommand { Shipment = shipment, NotifyCustomer = true }); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task EditShippedDate(ShipmentModel model) - { - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - try - { - if (!model.ShippedDate.HasValue) throw new Exception("Enter shipped date"); - - shipment.ShippedDateUtc = model.ShippedDate.ConvertToUtcTime(_dateTimeService); - await _shipmentService.UpdateShipment(shipment); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsDelivered(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - try - { - await _mediator.Send(new DeliveryCommand { Shipment = shipment, NotifyCustomer = true }); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task EditDeliveryDate(ShipmentModel model) - { - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null) - //No shipment found with the specified id - return RedirectToAction("List"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - try - { - if (!model.DeliveryDate.HasValue) throw new Exception("Enter delivery date"); - - shipment.DeliveryDateUtc = model.DeliveryDate.ConvertToUtcTime(_dateTimeService); - await _shipmentService.UpdateShipment(shipment); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task EditUserFields(string id, ShipmentModel model) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null) - //No order found with the specified id - return RedirectToAction("List"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - - shipment.UserFields = model.UserFields; - await _shipmentService.UpdateShipment(shipment); - - //selected tab - await SaveSelectedTabIndex(); - - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task PdfPackagingSlip(string shipmentId) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null) - //no shipment found with the specified id - return RedirectToAction("List"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List"); - - var order = await _orderService.GetOrderById(shipment.OrderId); - if (order == null) - //No order found with the specified id - return RedirectToAction("List"); - - var shipments = new List { - shipment - }; - - byte[] bytes; - using (var stream = new MemoryStream()) - { - await _pdfService.PrintPackagingSlipsToPdf(stream, shipments, _contextAccessor.WorkContext.WorkingLanguage.Id); - bytes = stream.ToArray(); - } - - return File(bytes, "application/pdf", $"packagingslip_{shipment.Id}.pdf"); - } - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task PdfPackagingSlipAll(ShipmentListModel model) - { - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - //load shipments - var shipments = await _shipmentViewModelService.PrepareShipments(model, 1, 100); - - //ensure that we at least one shipment selected - if (shipments.totalCount == 0) - { - Error(_translationService.GetResource("Admin.Orders.Shipments.NoShipmentsSelected")); - return RedirectToAction("List"); - } - - byte[] bytes; - using (var stream = new MemoryStream()) - { - await _pdfService.PrintPackagingSlipsToPdf(stream, shipments.shipments.ToList(), - _contextAccessor.WorkContext.WorkingLanguage.Id); - bytes = stream.ToArray(); - } - - return File(bytes, "application/pdf", "packagingslips.pdf"); - } - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task PdfPackagingSlipSelected(string selectedIds) - { - var shipments = new List(); - var shipments_access = new List(); - if (selectedIds != null) - { - var ids = selectedIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x) - .ToArray(); - shipments.AddRange(await _shipmentService.GetShipmentsByIds(ids)); - } - - shipments_access = shipments.Where(x => x.StoreId == _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId).ToList(); - //ensure that we at least one shipment selected - if (shipments.Count == 0) - { - Error(_translationService.GetResource("Admin.Orders.Shipments.NoShipmentsSelected")); - return RedirectToAction("List"); - } - - byte[] bytes; - using (var stream = new MemoryStream()) - { - await _pdfService.PrintPackagingSlipsToPdf(stream, shipments_access, _contextAccessor.WorkContext.WorkingLanguage.Id); - bytes = stream.ToArray(); - } - - return File(bytes, "application/pdf", "packagingslips.pdf"); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsShippedSelected(ICollection selectedIds) - { - var shipments = new List(); - var shipments_access = new List(); - if (selectedIds != null) shipments.AddRange(await _shipmentService.GetShipmentsByIds(selectedIds.ToArray())); - - shipments_access = shipments.Where(x => x.StoreId == _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId).ToList(); - foreach (var shipment in shipments_access) - try - { - await _mediator.Send(new ShipCommand { Shipment = shipment, NotifyCustomer = true }); - } - catch - { - //ignore any exception - } - - return Json(new { Result = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsDeliveredSelected(ICollection selectedIds) - { - var shipments = new List(); - var shipments_access = new List(); - if (selectedIds != null) shipments.AddRange(await _shipmentService.GetShipmentsByIds(selectedIds.ToArray())); - - shipments_access = shipments.Where(x => x.StoreId == _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId).ToList(); - foreach (var shipment in shipments_access) - try - { - await _mediator.Send(new DeliveryCommand { Shipment = shipment, NotifyCustomer = true }); - } - catch - { - //ignore any exception - } - - return Json(new { Result = true }); - } - - #region Shipment notes - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ShipmentNotesSelect(string shipmentId, DataSourceRequest command) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null) - throw new ArgumentException("No shipment found with the specified id"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return Content(""); - - //shipment notes - var shipmentNoteModels = await _shipmentViewModelService.PrepareShipmentNotes(shipment); - var gridModel = new DataSourceResult { - Data = shipmentNoteModels, - Total = shipmentNoteModels.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ShipmentNoteAdd(string shipmentId, string downloadId, bool displayToCustomer, - string message) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null) - return Json(new { Result = false }); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return Json(new { Result = false }); - - await _shipmentViewModelService.InsertShipmentNote(shipment, downloadId, displayToCustomer, message); - - return Json(new { Result = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task ShipmentNoteDelete(string id, string shipmentId) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null) - throw new ArgumentException("No shipment found with the specified id"); - - if (shipment.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return Json(new { Result = false }); - - await _shipmentViewModelService.DeleteShipmentNote(shipment, id); - - return new JsonResult(""); - } - - #endregion - - #endregion -} \ No newline at end of file +// Concrete host subclass of BaseShipmentController (ARCH-001 Shipment consolidation). This class +// supplies Store's DI wiring plus the attributes that used to arrive transitively via +// BaseStoreController - BaseShipmentController can't inherit any single host's base controller +// (it's shared across Admin/Store/Vendor, each with a different [Area]/[Authorize*] pair), so each +// subclass restates its own host's attribute set explicitly, same pattern as OrderController. +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ShipmentController( + IShipmentViewModelService shipmentViewModelService, + IOrderService orderService, + ITranslationService translationService, + IContextAccessor contextAccessor, + IPdfService pdfService, + IShipmentService shipmentService, + IDateTimeService dateTimeService, + IMediator mediator, + IAdminDataScope scope, + IAdminDataScope orderScope) + : BaseShipmentController(shipmentViewModelService, orderService, translationService, + contextAccessor, pdfService, shipmentService, dateTimeService, mediator, scope, orderScope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ShipmentController.cs b/src/Web/Grand.Web.Vendor/Controllers/ShipmentController.cs index 7f23e0dd1..3a5d10106 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ShipmentController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ShipmentController.cs @@ -1,542 +1,39 @@ -using Grand.Business.Core.Commands.Checkout.Shipping; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Shipping; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Pdf; -using Grand.Domain.Permissions; using Grand.Domain.Orders; using Grand.Domain.Shipping; using Grand.Infrastructure; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Extensions; +using Grand.Mediator; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; using Grand.Web.Common.Filters; -using Grand.Web.Common.Security.Authorization; using Grand.Web.Vendor.Extensions; -using Grand.Web.Vendor.Interfaces; -using Grand.Web.Vendor.Models.Shipment; -using Grand.Mediator; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Vendor.Controllers; -[PermissionAuthorize(PermissionSystemName.Shipments)] -public class ShipmentController : BaseVendorController -{ - public ShipmentController( - IShipmentViewModelService shipmentViewModelService, - IOrderService orderService, - ITranslationService translationService, - IContextAccessor contextAccessor, - IPdfService pdfService, - IShipmentService shipmentService, - IDateTimeService dateTimeService, - IMediator mediator) - { - _shipmentViewModelService = shipmentViewModelService; - _orderService = orderService; - _translationService = translationService; - _contextAccessor = contextAccessor; - _pdfService = pdfService; - _shipmentService = shipmentService; - _dateTimeService = dateTimeService; - _mediator = mediator; - } - - #region Fields - - private readonly IShipmentViewModelService _shipmentViewModelService; - private readonly IOrderService _orderService; - private readonly ITranslationService _translationService; - private readonly IContextAccessor _contextAccessor; - private readonly IPdfService _pdfService; - private readonly IShipmentService _shipmentService; - private readonly IDateTimeService _dateTimeService; - private readonly IMediator _mediator; - - #endregion - - #region Shipments - - public async Task List() - { - var model = await _shipmentViewModelService.PrepareShipmentListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ShipmentListSelect(DataSourceRequest command, ShipmentListModel model) - { - var shipments = await _shipmentViewModelService.PrepareShipments(model, command.Page, command.PageSize); - var items = new List(); - foreach (var item in shipments.shipments) - items.Add(await _shipmentViewModelService.PrepareShipmentModel(item, false)); - - var gridModel = new DataSourceResult { - Data = items, - Total = shipments.totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ShipmentsByOrder(string orderId) - { - var order = await _orderService.GetOrderById(orderId); - if (order == null || order.Deleted || !_contextAccessor.WorkContext.HasAccessToOrder(order)) - throw new ArgumentException("No order found with the specified id"); - - //shipments - var shipmentModels = new List(); - var shipments = (await _shipmentService.GetShipmentsByOrder(orderId)) - //a vendor should have access only to his products - .Where(s => _contextAccessor.WorkContext.HasAccessToShipment(s)) - .OrderBy(s => s.CreatedOnUtc) - .ToList(); - - foreach (var shipment in shipments) - shipmentModels.Add(await _shipmentViewModelService.PrepareShipmentModel(shipment, false)); - - var gridModel = new DataSourceResult { - Data = shipmentModels, - Total = shipmentModels.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ShipmentsItemsByShipmentId(string shipmentId) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - throw new ArgumentException("No shipment found with the specified id"); - - //shipments - var shipmentModel = await _shipmentViewModelService.PrepareShipmentModel(shipment, true); - var gridModel = new DataSourceResult { - Data = shipmentModel.Items, - Total = shipmentModel.Items.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task AddShipment(string orderId) - { - var order = await _orderService.GetOrderById(orderId); - if (order == null || order.Deleted || !_contextAccessor.WorkContext.HasAccessToOrder(order)) - //No order found with the specified id - return RedirectToAction("List"); - - var model = await _shipmentViewModelService.PrepareShipmentModel(order); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task AddShipment(AddShipmentModel model, bool continueEditing) - { - if (!ModelState.IsValid) - return RedirectToAction("AddShipment", new { orderId = model.OrderId }); - - var order = await _orderService.GetOrderById(model.OrderId); - if (order == null || order.Deleted || !_contextAccessor.WorkContext.HasAccessToOrder(order)) - //No order found with the specified id - return RedirectToAction("List"); - - //a vendor should have access only to his products - var orderItems = order.OrderItems.Where(_contextAccessor.WorkContext.HasAccessToOrderItem).ToList(); - - var (shipment, totalWeight) = - await _shipmentViewModelService.PrepareShipment(order, orderItems.ToList(), model); - if (!shipment.ShipmentItems.Any()) - { - Error(_translationService.GetResource("Admin.Orders.Shipments.NoProductsSelected")); - return RedirectToAction("AddShipment", new { orderId = model.OrderId }); - } - - //check stock - var (valid, message) = await _shipmentViewModelService.ValidStockShipment(shipment); - if (!valid) - { - Error(message); - return RedirectToAction("AddShipment", new { orderId = model.OrderId }); - } - - shipment.TotalWeight = totalWeight; - await _shipmentService.InsertShipment(shipment); - - //add a note - await _orderService.InsertOrderNote(new OrderNote { - Note = $"A shipment #{shipment.ShipmentNumber} has been added", - DisplayToCustomer = false, - OrderId = order.Id - }); - Success(_translationService.GetResource("Admin.Orders.Shipments.Added")); - return continueEditing - ? RedirectToAction("ShipmentDetails", new { id = shipment.Id }) - : RedirectToAction("List", new { id = shipment.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task ShipmentDetails(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - //No shipment found with the specified id - return RedirectToAction("List"); - - var model = await _shipmentViewModelService.PrepareShipmentModel(shipment, true, true); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task DeleteShipment(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - //No shipment found with the specified id - return RedirectToAction("List"); - - await _shipmentService.DeleteShipment(shipment); - - //add a note - await _orderService.InsertOrderNote(new OrderNote { - Note = $"A shipment #{shipment.ShipmentNumber} has been deleted", - DisplayToCustomer = false, - OrderId = shipment.OrderId - }); - Success(_translationService.GetResource("Admin.Orders.Shipments.Deleted")); - - return RedirectToAction("Edit", "Order", new { Id = shipment.OrderId }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetTrackingNumber(ShipmentTrackingModel model) - { - if (!ModelState.IsValid) - return RedirectToAction("ShipmentDetails", new { id = model.Id }); - - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - //No shipment found with the specified id - return RedirectToAction("List"); - - shipment.TrackingNumber = model.TrackingNumber; - await _shipmentService.UpdateShipment(shipment); - - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetShipmentAdminComment(ShipmentAdminCommentModel model) - { - if (!ModelState.IsValid) - return RedirectToAction("ShipmentDetails", new { id = model.Id }); - - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - //No shipment found with the specified id - return RedirectToAction("List"); - - shipment.AdminComment = model.AdminComment; - await _shipmentService.UpdateShipment(shipment); - - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsShipped(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - //No shipment found with the specified id - return RedirectToAction("List"); - - try - { - await _mediator.Send(new ShipCommand { Shipment = shipment, NotifyCustomer = true }); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task EditShippedDate(ShipmentShippedDateModel model) - { - if (!ModelState.IsValid) - return RedirectToAction("ShipmentDetails", new { id = model.Id }); - - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - //No shipment found with the specified id - return RedirectToAction("List"); - - try - { - if (!model.ShippedDate.HasValue) throw new Exception("Enter shipped date"); - - shipment.ShippedDateUtc = model.ShippedDate.ConvertToUtcTime(_dateTimeService); - await _shipmentService.UpdateShipment(shipment); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsDelivered(string id) - { - var shipment = await _shipmentService.GetShipmentById(id); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - //No shipment found with the specified id - return RedirectToAction("List"); - - try - { - await _mediator.Send(new DeliveryCommand { Shipment = shipment, NotifyCustomer = true }); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task EditDeliveryDate(ShipmentDeliveryDateModel model) - { - if (!ModelState.IsValid) - return RedirectToAction("ShipmentDetails", new { id = model.Id }); - - var shipment = await _shipmentService.GetShipmentById(model.Id); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - //No shipment found with the specified id - return RedirectToAction("List"); - - try - { - if (!model.DeliveryDate.HasValue) throw new Exception("Enter delivery date"); - - shipment.DeliveryDateUtc = model.DeliveryDate.ConvertToUtcTime(_dateTimeService); - await _shipmentService.UpdateShipment(shipment); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - catch (Exception exc) - { - //error - Error(exc); - return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task PdfPackagingSlip(string shipmentId) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - //no shipment found with the specified id - return RedirectToAction("List"); - - var shipments = new List { - shipment - }; - - byte[] bytes; - using (var stream = new MemoryStream()) - { - await _pdfService.PrintPackagingSlipsToPdf(stream, shipments, _contextAccessor.WorkContext.WorkingLanguage.Id); - bytes = stream.ToArray(); - } - - return File(bytes, "application/pdf", $"packagingslip_{shipment.Id}.pdf"); - } - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task PdfPackagingSlipAll(ShipmentListModel model) - { - //load shipments - var shipments = await _shipmentViewModelService.PrepareShipments(model, 1, 100); - - //ensure that we at least one shipment selected - if (shipments.totalCount == 0) - { - Error(_translationService.GetResource("Admin.Orders.Shipments.NoShipmentsSelected")); - return RedirectToAction("List"); - } - - byte[] bytes; - using (var stream = new MemoryStream()) - { - await _pdfService.PrintPackagingSlipsToPdf(stream, shipments.shipments.ToList(), - _contextAccessor.WorkContext.WorkingLanguage.Id); - bytes = stream.ToArray(); - } - - return File(bytes, "application/pdf", "packagingslips.pdf"); - } - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task PdfPackagingSlipSelected(string selectedIds) - { - var shipments = new List(); - if (selectedIds != null) - { - var ids = selectedIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x) - .ToArray(); - shipments.AddRange(await _shipmentService.GetShipmentsByIds(ids)); - } - - //a vendor should have access only to his shipments - var shipmentsAccess = - (from item in shipments - where _contextAccessor.WorkContext.HasAccessToShipment(item) - select item).ToList(); - - //ensure that we at least one shipment selected - if (shipments.Count == 0) - { - Error(_translationService.GetResource("Vendor.Orders.Shipments.NoShipmentsSelected")); - return RedirectToAction("List"); - } - - byte[] bytes; - using (var stream = new MemoryStream()) - { - await _pdfService.PrintPackagingSlipsToPdf(stream, shipmentsAccess, _contextAccessor.WorkContext.WorkingLanguage.Id); - bytes = stream.ToArray(); - } - - return File(bytes, "application/pdf", "packagingslips.pdf"); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsShippedSelected(ICollection selectedIds) - { - var shipments = new List(); - var shipmentsAccess = new List(); - if (selectedIds != null) shipments.AddRange(await _shipmentService.GetShipmentsByIds(selectedIds.ToArray())); - - //a vendor should have access only to his shipments - shipmentsAccess.AddRange(from item in shipments - where _contextAccessor.WorkContext.HasAccessToShipment(item) - select item); - - foreach (var shipment in shipmentsAccess) - try - { - await _mediator.Send(new ShipCommand { Shipment = shipment, NotifyCustomer = true }); - } - catch - { - //ignore any exception - } - - return Json(new { Result = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SetAsDeliveredSelected(ICollection selectedIds) - { - var shipments = new List(); - var shipmentsAccess = new List(); - if (selectedIds != null) shipments.AddRange(await _shipmentService.GetShipmentsByIds(selectedIds.ToArray())); - - //a vendor should have access only to his shipments - shipmentsAccess.AddRange( - from item in shipments - where _contextAccessor.WorkContext.HasAccessToShipment(item) - select item); - - foreach (var shipment in shipmentsAccess) - try - { - await _mediator.Send(new DeliveryCommand { Shipment = shipment, NotifyCustomer = true }); - } - catch - { - //ignore any exception - } - - return Json(new { Result = true }); - } - - #region Shipment notes - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ShipmentNotesSelect(string shipmentId) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - throw new ArgumentException("No shipment found with the specified id"); - - //shipment notes - var shipmentNoteModels = await _shipmentViewModelService.PrepareShipmentNotes(shipment); - var gridModel = new DataSourceResult { - Data = shipmentNoteModels, - Total = shipmentNoteModels.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ShipmentNoteAdd(string shipmentId, bool displayToCustomer, - string message) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - return Json(new { Result = false }); - - await _shipmentViewModelService.InsertShipmentNote(shipment, displayToCustomer, message); - - return Json(new { Result = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task ShipmentNoteDelete(string id, string shipmentId) - { - var shipment = await _shipmentService.GetShipmentById(shipmentId); - if (shipment == null || !_contextAccessor.WorkContext.HasAccessToShipment(shipment)) - throw new ArgumentException("No shipment found with the specified id"); - - await _shipmentViewModelService.DeleteShipmentNote(shipment, id); - - return new JsonResult(""); - } - - #endregion - - #endregion -} \ No newline at end of file +// Concrete host subclass of BaseShipmentController (ARCH-001 Shipment consolidation). This class +// supplies Vendor's DI wiring plus the attributes that used to arrive transitively via +// BaseVendorController - BaseShipmentController can't inherit any single host's base controller +// (it's shared across Admin/Store/Vendor, each with a different [Area]/[Authorize*] pair), so each +// subclass restates its own host's attribute set explicitly, same pattern as OrderController. +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ShipmentController( + IShipmentViewModelService shipmentViewModelService, + IOrderService orderService, + ITranslationService translationService, + IContextAccessor contextAccessor, + IPdfService pdfService, + IShipmentService shipmentService, + IDateTimeService dateTimeService, + IMediator mediator, + IAdminDataScope scope, + IAdminDataScope orderScope) + : BaseShipmentController(shipmentViewModelService, orderService, translationService, + contextAccessor, pdfService, shipmentService, dateTimeService, mediator, scope, orderScope); From cb8fcf3fe99a72568a58f358e9a6c45029249188 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 25 Aug 2026 21:38:30 +0200 Subject: [PATCH 10/12] refactor(arch001): migrate Shipment views to Grand.Web.AdminShared, add per-host WidgetZone overrides Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../Areas/Admin/Views/Shipment/List.cshtml | 404 ------------------ .../Views/Shipment/Partials/Documents.cshtml | 73 ---- .../Partials/WidgetZone.AddButtons.cshtml | 1 + .../Partials/WidgetZone.DetailsButtons.cshtml | 2 + .../Partials/WidgetZone.DetailsTabs.cshtml | 2 + .../WidgetZone.Documents.Bottom.cshtml | 2 + .../Partials/WidgetZone.Documents.Top.cshtml | 2 + .../Partials/WidgetZone.ListButtons.cshtml | 1 + .../Partials/WidgetZone.Notes.Bottom.cshtml | 2 + .../Partials/WidgetZone.Notes.Top.cshtml | 2 + .../Views/Shipment/ShipmentDetails.cshtml | 125 ------ .../AdminShared}/Shipment/AddShipment.cshtml | 48 ++- .../Views/AdminShared}/Shipment/List.cshtml | 27 +- .../Shipment/Partials/Documents.cshtml | 19 +- .../Shipment/Partials/Info.cshtml | 11 +- .../Shipment/Partials/ShipmentNotes.cshtml | 17 +- .../Partials/WidgetZone.AddButtons.cshtml | 1 + .../Partials/WidgetZone.DetailsButtons.cshtml | 1 + .../Partials/WidgetZone.DetailsTabs.cshtml | 1 + .../WidgetZone.Documents.Bottom.cshtml | 1 + .../Partials/WidgetZone.Documents.Top.cshtml | 1 + .../Partials/WidgetZone.ListButtons.cshtml | 1 + .../Partials/WidgetZone.Notes.Bottom.cshtml | 1 + .../Partials/WidgetZone.Notes.Top.cshtml | 1 + .../Shipment/ShipmentDetails.cshtml | 13 +- .../Store/Views/Shipment/AddShipment.cshtml | 182 -------- .../Store/Views/Shipment/Partials/Info.cshtml | 245 ----------- .../Shipment/Partials/ShipmentNotes.cshtml | 204 --------- .../Partials/WidgetZone.AddButtons.cshtml | 1 + .../Partials/WidgetZone.DetailsButtons.cshtml | 2 + .../Partials/WidgetZone.DetailsTabs.cshtml | 2 + .../WidgetZone.Documents.Bottom.cshtml | 2 + .../Partials/WidgetZone.Documents.Top.cshtml | 2 + .../Partials/WidgetZone.ListButtons.cshtml | 1 + .../Partials/WidgetZone.Notes.Bottom.cshtml | 2 + .../Partials/WidgetZone.Notes.Top.cshtml | 2 + .../Vendor/Views/Shipment/AddShipment.cshtml | 182 -------- .../Areas/Vendor/Views/Shipment/List.cshtml | 2 +- .../Shipment/Partials/ShipmentNotes.cshtml | 4 +- .../Partials/WidgetZone.AddButtons.cshtml | 1 + .../Partials/WidgetZone.DetailsButtons.cshtml | 2 + .../Partials/WidgetZone.DetailsTabs.cshtml | 2 + .../Partials/WidgetZone.ListButtons.cshtml | 1 + .../Partials/WidgetZone.Notes.Bottom.cshtml | 2 + .../Partials/WidgetZone.Notes.Top.cshtml | 2 + .../Views/Shipment/ShipmentDetails.cshtml | 4 +- 46 files changed, 125 insertions(+), 1481 deletions(-) delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/List.cshtml delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/Documents.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.AddButtons.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.DetailsButtons.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.DetailsTabs.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Documents.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.ListButtons.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Notes.Top.cshtml delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/ShipmentDetails.cshtml rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Shipment/AddShipment.cshtml (76%) rename src/Web/{Grand.Web.Store/Areas/Store/Views => Grand.Web.AdminShared/Views/AdminShared}/Shipment/List.cshtml (94%) rename src/Web/{Grand.Web.Store/Areas/Store/Views => Grand.Web.AdminShared/Views/AdminShared}/Shipment/Partials/Documents.cshtml (79%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Shipment/Partials/Info.cshtml (97%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Shipment/Partials/ShipmentNotes.cshtml (92%) create mode 100644 src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.AddButtons.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.DetailsButtons.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.DetailsTabs.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Documents.Top.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.ListButtons.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Notes.Top.cshtml rename src/Web/{Grand.Web.Store/Areas/Store/Views => Grand.Web.AdminShared/Views/AdminShared}/Shipment/ShipmentDetails.cshtml (91%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/AddShipment.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/Info.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/ShipmentNotes.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.AddButtons.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.DetailsButtons.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.DetailsTabs.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Documents.Top.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.ListButtons.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Notes.Top.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/AddShipment.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/Partials/WidgetZone.AddButtons.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/Partials/WidgetZone.DetailsButtons.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/Partials/WidgetZone.DetailsTabs.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/Partials/WidgetZone.ListButtons.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/Partials/WidgetZone.Notes.Top.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/List.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/List.cshtml deleted file mode 100644 index 6d9e1d96b..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/List.cshtml +++ /dev/null @@ -1,404 +0,0 @@ -@model ShipmentListModel -@inject AdminAreaSettings adminAreaSettings -@{ - ViewBag.Title = Loc["Admin.Orders.Shipments.List"]; -} - -
- -
-
- -
-
- - - - - -
-
- - -
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/Documents.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/Documents.cshtml deleted file mode 100644 index e59b6db41..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/Documents.cshtml +++ /dev/null @@ -1,73 +0,0 @@ -@model ShipmentModel -@inject AdminAreaSettings adminAreaSettings -
- -
-
-
- - -
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.AddButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.AddButtons.cshtml new file mode 100644 index 000000000..e8bec5d70 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.AddButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..25b76f962 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.DetailsTabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.DetailsTabs.cshtml new file mode 100644 index 000000000..67b270354 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.DetailsTabs.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml new file mode 100644 index 000000000..718eb3b6b --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Documents.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Documents.Top.cshtml new file mode 100644 index 000000000..117b570d7 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Documents.Top.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.ListButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.ListButtons.cshtml new file mode 100644 index 000000000..5762141a4 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.ListButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml new file mode 100644 index 000000000..03ba04ce8 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Notes.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Notes.Top.cshtml new file mode 100644 index 000000000..7ad875e51 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/WidgetZone.Notes.Top.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/ShipmentDetails.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/ShipmentDetails.cshtml deleted file mode 100644 index 3a439f29e..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/ShipmentDetails.cshtml +++ /dev/null @@ -1,125 +0,0 @@ -@using Grand.Business.Core.Interfaces.Common.Security -@using Grand.Domain.Permissions -@model ShipmentModel -@inject IPermissionService permissionService -@{ - //page title - ViewBag.Title = Loc["Admin.Orders.Shipments.ViewDetails"]; - var canManageDocuments = await permissionService.Authorize(StandardPermission.ManageDocuments); -} -
- - -
-
-
-
-
-
- - @Loc["Admin.Orders.Shipments.ViewDetails"] - @Model.ShipmentNumber - - - @Html.ActionLink(Loc["Admin.Orders.Shipments.BackToList"], "List") - -
-
-
- - @Loc["Admin.Orders.Shipments.PrintPackagingSlip"] - - - @Loc["Admin.Common.Delete"] - - -
-
-
-
- - - - -
- -
-
-
- @if (canManageDocuments) - { - - -
- -
-
-
- } - - -
- -
-
-
- - -
-
- -
-
- -
-
-
-
- -
-
-
-
- -
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/AddShipment.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/AddShipment.cshtml similarity index 76% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/AddShipment.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/AddShipment.cshtml index cf0cd20ce..6c067720f 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/AddShipment.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/AddShipment.cshtml @@ -1,9 +1,13 @@ -@model ShipmentModel +@using Grand.Domain.Shipping +@model ShipmentModel +@inject IAdminDataScope Scope @{ //page title - ViewBag.Title = string.Format(Loc["Admin.Orders.Shipments.AddNew.Title"], Model.OrderId); + var prefix = Scope.ResourceKeyPrefix; + var area = ViewContext.RouteData.Values["area"]?.ToString(); + ViewBag.Title = string.Format(Loc[$"{prefix}.Orders.Shipments.AddNew.Title"], Model.OrderId); } -
@@ -13,21 +17,21 @@
- @string.Format(Loc["Admin.Orders.Shipments.AddNew.Title"], Model.OrderNumber) + @string.Format(Loc[$"{prefix}.Orders.Shipments.AddNew.Title"], Model.OrderNumber) - @Html.ActionLink(Loc["Admin.Orders.Shipments.BackToOrder"], "Edit", "Order", new { Id = Model.OrderId }) + @Html.ActionLink(Loc[$"{prefix}.Orders.Shipments.BackToOrder"], "Edit", "Order", new { Id = Model.OrderId })
- +
@@ -54,7 +58,7 @@

- @Loc["Admin.Orders.Shipments.Products"] + @Loc[$"{prefix}.Orders.Shipments.Products"]

@@ -62,28 +66,28 @@ - @Loc["Admin.Orders.Shipments.Products.ProductName"] + @Loc[$"{prefix}.Orders.Shipments.Products.ProductName"] - @Loc["Admin.Orders.Shipments.Products.SKU"] + @Loc[$"{prefix}.Orders.Shipments.Products.SKU"] - @Loc["Admin.Orders.Shipments.Products.Warehouse"] + @Loc[$"{prefix}.Orders.Shipments.Products.Warehouse"] - @Loc["Admin.Orders.Shipments.Products.ItemWeight"] + @Loc[$"{prefix}.Orders.Shipments.Products.ItemWeight"] - @Loc["Admin.Orders.Shipments.Products.ItemDimensions"] + @Loc[$"{prefix}.Orders.Shipments.Products.ItemDimensions"] - @Loc["Admin.Orders.Shipments.Products.QtyOrdered"] + @Loc[$"{prefix}.Orders.Shipments.Products.QtyOrdered"] - @Loc["Admin.Orders.Shipments.Products.QtyShipped"] + @Loc[$"{prefix}.Orders.Shipments.Products.QtyShipped"] - @Loc["Admin.Orders.Shipments.Products.QtyToShip"] + @Loc[$"{prefix}.Orders.Shipments.Products.QtyToShip"] @@ -96,7 +100,7 @@
- @item.ProductName + @item.ProductName @if (!string.IsNullOrEmpty(item.AttributeInfo)) { @@ -107,8 +111,8 @@ @if (item.ShipSeparately) {

- @Loc["Admin.Orders.Shipments.Products.ShipSeparately.Warning"] - @Loc["Admin.Orders.Shipments.Products.ShipSeparately"] + @Loc[$"{prefix}.Orders.Shipments.Products.ShipSeparately.Warning"] + @Loc[$"{prefix}.Orders.Shipments.Products.ShipSeparately"]

}
@@ -131,7 +135,7 @@ } @@ -179,4 +183,4 @@
-
\ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/List.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/List.cshtml similarity index 94% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/List.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/List.cshtml index 0d65f4125..254e0f663 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/List.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/List.cshtml @@ -1,10 +1,11 @@ -@model ShipmentListModel +@model ShipmentListModel @inject AdminAreaSettings adminAreaSettings @{ ViewBag.Title = Loc["Admin.Orders.Shipments.List"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -30,7 +31,7 @@ - +
@@ -99,7 +100,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("ShipmentListSelect", "Shipment", new { area = Constants.AreaStore }))", + url: "@Html.Raw(Url.Action("ShipmentListSelect", "Shipment", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -137,17 +138,17 @@ field: "ShipmentNumber", title: "@Loc["Admin.Orders.Shipments.ID"]", width: 100, - template: '#=ShipmentNumber#' + template: '#=ShipmentNumber#' }, { field: "OrderNumber", title: "@Loc["Admin.Orders.Shipments.OrderID"]", width: 100, - template: '#=OrderNumber#' + template: '#=OrderNumber#' }, { field: "TrackingNumber", title: "@Loc["Admin.Orders.Shipments.TrackingNumber"]", width: 100, - template: '#=kendo.htmlEncode(TrackingNumber)#' + template: '#=kendo.htmlEncode(TrackingNumber)#' }, { field: "TotalWeight", title: "@Loc["Admin.Orders.Shipments.TotalWeight"]", @@ -174,7 +175,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("ShipmentsItemsByShipmentId", "Shipment", new { area = Constants.AreaStore }))?shipmentId="+e.data.Id, + url: "@Html.Raw(Url.Action("ShipmentsItemsByShipmentId", "Shipment", new { area = area }))?shipmentId="+e.data.Id, type: "POST", dataType: "json", data: addAntiForgeryToken @@ -241,7 +242,7 @@ $.ajax({ cache: false, type: "GET", - url: "@(Url.Action("GetStatesByCountryId", "Home", new { area = Constants.AreaStore }))", + url: "@(Url.Action("GetStatesByCountryId", "Home", new { area = area }))", data: { "countryId": selectedItem, "addAsterisk": "true" }, success: function (data) { ddlStates.html(''); @@ -287,7 +288,7 @@ $.ajax({ cache: false, type: "POST", - url: "@(Url.Action("SetAsShippedSelected", "Shipment", new { area = Constants.AreaStore }))", + url: "@(Url.Action("SetAsShippedSelected", "Shipment", new { area = area }))", data: postData, complete: function(data) { var grid = $('#shipments-grid').data('kendoGrid'); @@ -312,7 +313,7 @@ $.ajax({ cache: false, type: "POST", - url: "@(Url.Action("SetAsDeliveredSelected", "Shipment", new { area = Constants.AreaStore }))", + url: "@(Url.Action("SetAsDeliveredSelected", "Shipment", new { area = area }))", data: postData, complete: function(data) { var grid = $('#shipments-grid').data('kendoGrid'); @@ -384,7 +385,7 @@
-
+
@@ -401,4 +402,4 @@ }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/Documents.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/Documents.cshtml similarity index 79% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/Documents.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/Documents.cshtml index 3a2255401..9a36db4df 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/Documents.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/Documents.cshtml @@ -1,14 +1,17 @@ -@model ShipmentModel +@model ShipmentModel @inject AdminAreaSettings adminAreaSettings +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +}
- +
- +
\ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/Info.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/Info.cshtml similarity index 97% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/Info.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/Info.cshtml index ba7dcd41f..342b12855 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/Info.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/Info.cshtml @@ -1,4 +1,7 @@ -@model ShipmentModel +@model ShipmentModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +}
@@ -140,7 +143,7 @@
- @item.ProductName + @item.ProductName @if (!string.IsNullOrEmpty(item.AttributeInfo)) { @@ -242,4 +245,4 @@
-} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/ShipmentNotes.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/ShipmentNotes.cshtml similarity index 92% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/ShipmentNotes.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/ShipmentNotes.cshtml index 0eb0d57a4..3788bf1ef 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipment/Partials/ShipmentNotes.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/ShipmentNotes.cshtml @@ -1,15 +1,16 @@ -@using Grand.Domain.Media +@using Grand.Domain.Media @model ShipmentModel @{ ViewData["DownloadType"] = DownloadType.Shipment; ViewData["ReferenceId"] = Model.Id; + var area = ViewContext.RouteData.Values["area"]?.ToString(); }
- +
- +
\ No newline at end of file + diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.AddButtons.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.AddButtons.cshtml new file mode 100644 index 000000000..691131b1a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.AddButtons.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and Vendor (where applicable) all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 6 spec. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..691131b1a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and Vendor (where applicable) all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 6 spec. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.DetailsTabs.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.DetailsTabs.cshtml new file mode 100644 index 000000000..691131b1a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.DetailsTabs.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and Vendor (where applicable) all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 6 spec. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml new file mode 100644 index 000000000..691131b1a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and Vendor (where applicable) all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 6 spec. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Documents.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Documents.Top.cshtml new file mode 100644 index 000000000..691131b1a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Documents.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and Vendor (where applicable) all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 6 spec. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.ListButtons.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.ListButtons.cshtml new file mode 100644 index 000000000..691131b1a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.ListButtons.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and Vendor (where applicable) all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 6 spec. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml new file mode 100644 index 000000000..691131b1a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and Vendor (where applicable) all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 6 spec. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Notes.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Notes.Top.cshtml new file mode 100644 index 000000000..691131b1a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/Partials/WidgetZone.Notes.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and Vendor (where applicable) all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 6 spec. *@ diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/ShipmentDetails.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/ShipmentDetails.cshtml similarity index 91% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/ShipmentDetails.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/ShipmentDetails.cshtml index 67fa15e92..13f0e27b6 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/ShipmentDetails.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/ShipmentDetails.cshtml @@ -1,4 +1,4 @@ -@using Grand.Business.Core.Interfaces.Common.Security +@using Grand.Business.Core.Interfaces.Common.Security @using Grand.Domain.Permissions @model ShipmentModel @inject IPermissionService permissionService @@ -6,8 +6,9 @@ //page title ViewBag.Title = Loc["Admin.Orders.Shipments.ViewDetails"]; var canManageDocuments = await permissionService.Authorize(StandardPermission.ManageDocuments); + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+ -

- - @Loc["Admin.Orders.Shipments.ShipmentNotes.AddTitle"] - -

- - -
-
-
- -
- - -
-
-
- -
- -
- - -
-
-
-
- -
- - -
-
-
-
- -
-
-
-
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.AddButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.AddButtons.cshtml new file mode 100644 index 000000000..cedcdc6c1 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.AddButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..25940a822 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.DetailsTabs.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.DetailsTabs.cshtml new file mode 100644 index 000000000..37276fbad --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.DetailsTabs.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml new file mode 100644 index 000000000..feab39f96 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Documents.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Documents.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Documents.Top.cshtml new file mode 100644 index 000000000..d70a24d3b --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Documents.Top.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.ListButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.ListButtons.cshtml new file mode 100644 index 000000000..6837cee21 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.ListButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml new file mode 100644 index 000000000..d11bb8837 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Notes.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Notes.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Notes.Top.cshtml new file mode 100644 index 000000000..8cc575437 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipment/Partials/WidgetZone.Notes.Top.cshtml @@ -0,0 +1,2 @@ +@model ShipmentModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/AddShipment.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/AddShipment.cshtml deleted file mode 100644 index 9f9c1bf91..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/AddShipment.cshtml +++ /dev/null @@ -1,182 +0,0 @@ -@model ShipmentModel -@{ - //page title - ViewBag.Title = string.Format(Loc["Vendor.Orders.Shipments.AddNew.Title"], Model.OrderId); -} - - -
- -
-
-
-
- - @string.Format(Loc["Vendor.Orders.Shipments.AddNew.Title"], Model.OrderNumber) - - - @Html.ActionLink(Loc["Vendor.Orders.Shipments.BackToOrder"], "Edit", "Order", new { Id = Model.OrderId }) - -
-
-
- - - -
-
-
-
-
-
-
- -
- -
-
-
- -
- -
-
-
-
-
-
-
- - -

- @Loc["Vendor.Orders.Shipments.Products"] -

- -
- - - - - - - - - - - - - - - @for (var j = 0; j < Model.Items.Count; j++) - { - var item = Model.Items[j]; - - - - - - - - - - - - } - -
- @Loc["Vendor.Orders.Shipments.Products.ProductName"] - - @Loc["Vendor.Orders.Shipments.Products.SKU"] - - @Loc["Vendor.Orders.Shipments.Products.Warehouse"] - - @Loc["Vendor.Orders.Shipments.Products.ItemWeight"] - - @Loc["Vendor.Orders.Shipments.Products.ItemDimensions"] - - @Loc["Vendor.Orders.Shipments.Products.QtyOrdered"] - - @Loc["Vendor.Orders.Shipments.Products.QtyShipped"] - - @Loc["Vendor.Orders.Shipments.Products.QtyToShip"] -
-
- - @item.ProductName - - @if (!string.IsNullOrEmpty(item.AttributeInfo)) - { -

- @Html.Raw(item.AttributeInfo) -

- } - @if (item.ShipSeparately) - { -

- @Loc["Vendor.Orders.Shipments.Products.ShipSeparately.Warning"] - @Loc["Vendor.Orders.Shipments.Products.ShipSeparately"] -

- } -
-
-
- @item.Sku -
-
-
- @if (item.AllowToChooseWarehouse) - { - if (item.AvailableWarehouses.Count > 0) - { - - } - else - { -
@Loc["Vendor.Orders.Shipments.Products.Warehouse.NotAvailable"]
- } - } - else - { - //display warehouses - for (var i = 0; i < item.AvailableWarehouses.Count; i++) - { - var warehouse = item.AvailableWarehouses[i]; - @warehouse.WarehouseName - if (i != item.AvailableWarehouses.Count - 1) - { -
- } - } - } -
-
- @item.ItemWeight - - @item.ItemDimensions - - @item.QuantityOrdered - - @item.QuantityInAllShipments - - -
-
- - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/List.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/List.cshtml index 1ab7a552d..509fc682b 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/List.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/List.cshtml @@ -30,7 +30,7 @@ - + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/Partials/ShipmentNotes.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/Partials/ShipmentNotes.cshtml index 267595e1e..e365dfd6d 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/Partials/ShipmentNotes.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Shipment/Partials/ShipmentNotes.cshtml @@ -1,11 +1,11 @@ @model ShipmentModel
- +
- +