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..d2955608d --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs @@ -0,0 +1,824 @@ +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.Common; +using Grand.Domain.Localization; +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, + IAdminDataScope orderScope) + : BaseShipmentController(shipmentViewModelService, orderService, translationService, + contextAccessor, pdfService, shipmentService, dateTimeService, mediator, scope, orderScope) + { + 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; + private Mock> _orderScopeMock; + private Mock _mediatorMock; + private Mock _pdfServiceMock; + + [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); + _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"); + 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); + _pdfServiceMock = new Mock(); + var dateTimeServiceMock = new Mock(); + _mediatorMock = new Mock(); + + _controller = new TestShipmentController( + _shipmentViewModelServiceMock.Object, + _orderServiceMock.Object, + translationServiceMock.Object, + contextAccessorMock.Object, + _pdfServiceMock.Object, + _shipmentServiceMock.Object, + dateTimeServiceMock.Object, + _mediatorMock.Object, + _scopeMock.Object, + _orderScopeMock.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())); + } + + [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); + } + + [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); + } + + [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); + _pdfServiceMock.Verify( + p => p.PrintPackagingSlipsToPdf( + It.IsAny(), + It.Is>(list => list.Count == 1 && list.Contains(accessibleShipment) && !list.Contains(deniedShipment)), + It.IsAny()), + 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); + } + + [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); + } + + [TestMethod] + public async Task EditUserFields_Denied_RedirectsToList_UpdateShipmentNeverCalled() + { + _shipmentServiceMock.Setup(s => s.GetShipmentById("s1")).ReturnsAsync((Shipment)null); + + var result = await _controller.EditUserFields("s1", new ShipmentModel { Id = "s1" }); + + 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 EditUserFields_Authorized_UpdatesUserFieldsAndRedirectsToShipmentDetails() + { + 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 userFields = new List { new() { Key = "k1", Value = "v1" } }; + var model = new ShipmentModel { Id = "s1", UserFields = userFields }; + + // SaveSelectedTabIndex() reads Request.Form; give it a well-formed empty form body. + _controller.ControllerContext.HttpContext.Request.ContentType = "application/x-www-form-urlencoded"; + + var result = await _controller.EditUserFields("s1", model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("ShipmentDetails", redirect.ActionName); + Assert.AreEqual("s1", redirect.RouteValues["id"]); + Assert.AreSame(userFields, shipment.UserFields); + _shipmentServiceMock.Verify(s => s.UpdateShipment(shipment), Times.Once); + } +} 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..9ba5e8f5f --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreShipmentDataScopeTests.cs @@ -0,0 +1,65 @@ +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 async Task HasAccess_EmptyStaffStoreIdAndEmptyEntityStoreId_False() + { + var scope = Build(string.Empty); + Assert.IsFalse(await scope.HasAccess(new Shipment { StoreId = string.Empty })); + } + + [TestMethod] + public async Task HasAccess_NullStaffStoreIdAndNullEntityStoreId_False() + { + var scope = Build(null); + Assert.IsFalse(await scope.HasAccess(new Shipment { StoreId = 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..ffa3cea1d --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/VendorShipmentDataScopeTests.cs @@ -0,0 +1,81 @@ +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 async Task HasAccess_EmptyCurrentVendorIdAndEmptyEntityVendorId_False() + { + var scope = Build(string.Empty); + Assert.IsFalse(await scope.HasAccess(new Shipment { VendorId = string.Empty })); + } + + [TestMethod] + public async Task HasAccess_NullCurrentVendorIdAndNullEntityVendorId_False() + { + var scope = Build(null); + Assert.IsFalse(await scope.HasAccess(new Shipment { VendorId = 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/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.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/Controllers/ShipmentController.cs b/src/Web/Grand.Web.Admin/Controllers/ShipmentController.cs index cd8348952..483fc6fd3 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ShipmentController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ShipmentController.cs @@ -1,572 +1,40 @@ -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.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 -{ - 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 }); - } - } - - [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"); - - 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"); - - 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 +// 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); 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..5193a2f16 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs @@ -0,0 +1,573 @@ +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, + IAdminDataScope orderScope) + : 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; + 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 + /// 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), + /// 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 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, since a + /// redirect response is meaningless to the AJAX grid caller expecting JSON). + [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 + + #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 + + #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 }); + } + } + + /// Shared across all 3 hosts: the "User fields" tab on ShipmentDetails.cshtml posts + /// here with no host gating, so Store and Vendor need this action too, not just Admin. + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task EditUserFields(string id, ShipmentModel model) + { + var (shipment, denied) = await LoadAuthorizedShipment(id); + if (denied != null) return denied; + + shipment.UserFields = model.UserFields; + await shipmentService.UpdateShipment(shipment); + + //selected tab + await SaveSelectedTabIndex(); + + return RedirectToAction("ShipmentDetails", new { id = shipment.Id }); + } + + #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($"{scope.ResourceKeyPrefix}.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 (not + //accessibleShipments.Count) so a request that only names shipments the caller can't + //access reports "no shipments selected" rather than silently producing an empty PDF, + //matching all 3 originals' pre-existing (and slightly inconsistent) behavior + if (shipments.Count == 0) + { + Error(translationService.GetResource($"{scope.ResourceKeyPrefix}.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 + + #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 +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs index 9310c9829..32fcbaf95 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs @@ -54,7 +54,8 @@ public interface IAdminDataScope /// Order line items visible to the current host — e.g. Vendor sees only its own /// items within a mixed-vendor order. Identity (no filtering) for hosts with no such /// restriction (Admin, Store, and every non-Order entity). Only - /// overrides this. Lives on + /// and + /// override this. Lives on /// the shared interface rather than a separate Order-only interface because /// IAdminDataScope<TEntity> is already the single per-host strategy object injected /// into BaseOrderController/OrderViewModelService — see ARCH-001 Order consolidation spec 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.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/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.AdminShared/Services/StoreShipmentDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreShipmentDataScope.cs new file mode 100644 index 000000000..2d1684726 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/StoreShipmentDataScope.cs @@ -0,0 +1,33 @@ +#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 — +/// is simply inherited from the interface +/// default, which delegates to . +/// +public class StoreShipmentDataScope(IContextAccessor contextAccessor) : IAdminDataScope +{ + public Task HasAccess(Shipment entity) => + Task.FromResult(entity is not null && + !string.IsNullOrEmpty(contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) && + 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..b51bce154 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/VendorShipmentDataScope.cs @@ -0,0 +1,38 @@ +#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 && + !string.IsNullOrEmpty(contextAccessor.WorkContext.CurrentVendor.Id) && + 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) 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 75% 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..60bad230d 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,14 +135,14 @@ } } else { -
@Loc["Admin.Orders.Shipments.Products.Warehouse.NotAvailabe"]
+
@Loc[$"{prefix}.Orders.Shipments.Products.Warehouse.NotAvailabe"]
} } else @@ -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.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/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
- +
- +