From 0834788954c72bea134255321cbcda8f9bb470cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= Date: Sun, 26 May 2024 13:31:26 +1000 Subject: [PATCH] Remove item (#2) * Remove item Let the cashier remove/cancel an item from the order - Add a trash button to the order summary - Add a new endpoint to cashier API - Add RemoveItem and Apply (OrderItemRemoved) methods to Order entity - Add Total property to OrderItem ValueObject and use it to calculate the Order Total instead of Price - Update AntDesign package to latest version - Change the Place order modal to use Ant component - Removed custom Modal component * Add test case --------- Co-authored-by: Andre Foresti --- Directory.Packages.props | 2 +- src/Cashier/NCafe.Cashier.Api/Program.cs | 7 + .../Commands/RemoveItemTests.cs | 123 ++++++++++++++++++ .../Commands/RemoveItemFromOrder.cs | 25 ++++ .../NCafe.Cashier.Domain/Entities/Order.cs | 45 ++++++- .../Events/OrderItemRemoved.cs | 16 +++ .../CannotRemoveItemFromOrderException.cs | 10 ++ ...nnotRemoveMoreItemsThanOrderedException.cs | 11 ++ .../Exceptions/OrderItemNotFoundException.cs | 10 ++ .../ValueObjects/OrderItem.cs | 15 ++- src/UI/NCafe.Web/Models/Order.cs | 5 +- src/UI/NCafe.Web/Pages/Cashier/Index.razor | 78 +++++++---- src/UI/NCafe.Web/Shared/Modal.razor | 42 ------ 13 files changed, 321 insertions(+), 68 deletions(-) create mode 100644 src/Cashier/NCafe.Cashier.Domain.Tests/Commands/RemoveItemTests.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Commands/RemoveItemFromOrder.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Events/OrderItemRemoved.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotRemoveItemFromOrderException.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotRemoveMoreItemsThanOrderedException.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Exceptions/OrderItemNotFoundException.cs delete mode 100644 src/UI/NCafe.Web/Shared/Modal.razor diff --git a/Directory.Packages.props b/Directory.Packages.props index 78b2c3c..576f167 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,7 @@ - + diff --git a/src/Cashier/NCafe.Cashier.Api/Program.cs b/src/Cashier/NCafe.Cashier.Api/Program.cs index 19e9276..9289f9e 100644 --- a/src/Cashier/NCafe.Cashier.Api/Program.cs +++ b/src/Cashier/NCafe.Cashier.Api/Program.cs @@ -70,6 +70,13 @@ }) .WithName("AddItemToOrder"); +app.MapPost("/orders/remove-item", async (IMediator mediator, RemoveItemFromOrder command) => + { + await mediator.Send(command); + return Results.Accepted(); + }) + .WithName("RemoveItemFromOrder"); + app.MapPost("/orders/place", async (IMediator mediator, PlaceOrder command) => { await mediator.Send(command); diff --git a/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/RemoveItemTests.cs b/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/RemoveItemTests.cs new file mode 100644 index 0000000..bb26e37 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/RemoveItemTests.cs @@ -0,0 +1,123 @@ +using NCafe.Cashier.Domain.Commands; +using NCafe.Cashier.Domain.Entities; +using NCafe.Cashier.Domain.Exceptions; +using NCafe.Cashier.Domain.ValueObjects; +using NCafe.Core.Repositories; +using System.Threading; + +namespace NCafe.Cashier.Domain.Tests.Commands; + +public class RemoveItemTests +{ + private readonly RemoveItemFromOrderHandler _sut; + private readonly IRepository _repository; + + public RemoveItemTests() + { + _repository = A.Fake(); + _sut = new RemoveItemFromOrderHandler(_repository); + } + + [Fact] + public async Task ShouldSaveOrder() + { + // Arrange + var orderId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + var command = new RemoveItemFromOrder(orderId, productId, Quantity: 1); + var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow); + order.AddItem(new OrderItem(productId, "product1", 1, 5)); + + A.CallTo(() => _repository.GetById(orderId)) + .Returns(Task.FromResult(order)); + + // Act + await _sut.Handle(command, CancellationToken.None); + + // Assert + A.CallTo(() => _repository.Save( + A.That.Matches(o => o.Id == orderId && + o.Items.All(i => i.ProductId != productId)))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GivenOrderNotFound_ShouldThrow() + { + // Arrange + var orderId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + var command = new RemoveItemFromOrder(orderId, productId, Quantity: 1); + + // Act + var exception = await Record.ExceptionAsync(() => _sut.Handle(command, CancellationToken.None)); + + // Assert + exception.ShouldBeOfType(); + } + + [Fact] + public async Task GivenNotNewOrder_ShouldThrow() + { + // Arrange + var orderId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + var command = new RemoveItemFromOrder(orderId, productId, Quantity: 1); + var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow); + order.AddItem(new OrderItem(productId, "product1", quantity: 1, 5)); + order.PlaceOrder(new Customer("John Doe"), DateTimeOffset.UtcNow); + + A.CallTo(() => _repository.GetById(orderId)) + .Returns(Task.FromResult(order)); + + // Act + var exception = await Record.ExceptionAsync(() => _sut.Handle(command, CancellationToken.None)); + + // Assert + exception.ShouldBeOfType(); + } + + [Fact] + public async Task GivenRemovingMoreItemsThanOrdered_ShouldThrow() + { + // Arrange + var orderId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + var command = new RemoveItemFromOrder(orderId, productId, Quantity: 2); + var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow); + order.AddItem(new OrderItem(productId, "product1", 1, 5)); + + A.CallTo(() => _repository.GetById(orderId)) + .Returns(Task.FromResult(order)); + + // Act + var exception = await Record.ExceptionAsync(() => _sut.Handle(command, CancellationToken.None)); + + // Assert + exception.ShouldBeOfType(); + } + + [Fact] + public async Task GivenRemovingLessItemsThanOrdered_ShouldSaveOrder() + { + // Arrange + var orderId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + var command = new RemoveItemFromOrder(orderId, productId, Quantity: 1); + var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow); + order.AddItem(new OrderItem(productId, "product1", 2, 5)); + + A.CallTo(() => _repository.GetById(orderId)) + .Returns(Task.FromResult(order)); + + // Act + await _sut.Handle(command, CancellationToken.None); + + // Assert + A.CallTo(() => _repository.Save( + A.That.Matches(o => o.Id == orderId && + o.Items.Any(i => i.ProductId == productId && i.Quantity == 1)))) + .MustHaveHappenedOnceExactly(); + } + +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Commands/RemoveItemFromOrder.cs b/src/Cashier/NCafe.Cashier.Domain/Commands/RemoveItemFromOrder.cs new file mode 100644 index 0000000..45dd971 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Commands/RemoveItemFromOrder.cs @@ -0,0 +1,25 @@ +using MediatR; +using NCafe.Cashier.Domain.Entities; +using NCafe.Cashier.Domain.Exceptions; +using NCafe.Cashier.Domain.ReadModels; +using NCafe.Cashier.Domain.ValueObjects; +using NCafe.Core.ReadModels; +using NCafe.Core.Repositories; + +namespace NCafe.Cashier.Domain.Commands; + +public record RemoveItemFromOrder(Guid OrderId, Guid ProductId, int Quantity) : IRequest; + +internal sealed class RemoveItemFromOrderHandler(IRepository repository) : IRequestHandler +{ + private readonly IRepository _repository = repository; + + public async Task Handle(RemoveItemFromOrder command, CancellationToken cancellationToken) + { + var order = await _repository.GetById(command.OrderId) ?? throw new OrderNotFoundException(command.OrderId); + + order.RemoveItem(command.ProductId, command.Quantity); + + await _repository.Save(order); + } +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Entities/Order.cs b/src/Cashier/NCafe.Cashier.Domain/Entities/Order.cs index f1cbd36..1b75bc7 100644 --- a/src/Cashier/NCafe.Cashier.Domain/Entities/Order.cs +++ b/src/Cashier/NCafe.Cashier.Domain/Entities/Order.cs @@ -52,6 +52,28 @@ public void AddItem(OrderItem orderItem) RaiseEvent(new OrderItemAdded(Id, orderItem.ProductId, orderItem.Quantity, orderItem.Name, orderItem.Price)); } + public void RemoveItem(Guid productId, int quantity) + { + if (Status != OrderStatus.New) + { + throw new CannotRemoveItemFromOrderException(Id, productId); + } + + var item = Items.FirstOrDefault(i => i.ProductId == productId); + + if (item is null) + { + throw new OrderItemNotFoundException(Id, productId); + } + + if (item.Quantity < quantity) + { + throw new CannotRemoveMoreItemsThanOrderedException(Id, productId, quantity); + } + + RaiseEvent(new OrderItemRemoved(Id, productId, quantity)); + } + public void PlaceOrder(Customer customer, DateTimeOffset placedAt) { Guard.Against.Null(customer); @@ -79,8 +101,29 @@ private void Apply(OrderCreated @event) private void Apply(OrderItemAdded @event) { + var item = _items.FirstOrDefault(i => i.ProductId == @event.ProductId); + if (item is not null) + { + item.IncreaseQuantity(@event.Quantity); + Total = Items.Sum(i => i.Total); + return; + } + _items.Add(new OrderItem(@event.ProductId, @event.Name, @event.Quantity, @event.Price)); - Total = Items.Sum(i => i.Price); + Total = Items.Sum(i => i.Total); + } + + private void Apply(OrderItemRemoved @event) + { + var item = _items.First(i => i.ProductId == @event.ProductId); + if (item.Quantity == @event.Quantity) + { + _items.Remove(item); + Total = Items.Sum(i => i.Total); + return; + } + item.DecreaseQuantity(@event.Quantity); + Total = Items.Sum(i => i.Total); } private void Apply(OrderPlaced @event) diff --git a/src/Cashier/NCafe.Cashier.Domain/Events/OrderItemRemoved.cs b/src/Cashier/NCafe.Cashier.Domain/Events/OrderItemRemoved.cs new file mode 100644 index 0000000..248ab59 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Events/OrderItemRemoved.cs @@ -0,0 +1,16 @@ +using NCafe.Core.Domain; + +namespace NCafe.Cashier.Domain.Events; + +public sealed record OrderItemRemoved : Event +{ + public OrderItemRemoved(Guid id, Guid productId, int quantity) + { + Id = id; + ProductId = productId; + Quantity = quantity; + } + + public Guid ProductId { get; } + public int Quantity { get; } +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotRemoveItemFromOrderException.cs b/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotRemoveItemFromOrderException.cs new file mode 100644 index 0000000..54765f8 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotRemoveItemFromOrderException.cs @@ -0,0 +1,10 @@ +using NCafe.Core.Exceptions; + +namespace NCafe.Cashier.Domain.Exceptions; + +public class CannotRemoveItemFromOrderException(Guid orderId, Guid productId) + : DomainException($"Cannot remove item '{productId}' from order '{orderId}' when status is not New.") +{ + public Guid OrderId { get; } = orderId; + public Guid ProductId { get; } = productId; +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotRemoveMoreItemsThanOrderedException.cs b/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotRemoveMoreItemsThanOrderedException.cs new file mode 100644 index 0000000..b907a74 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotRemoveMoreItemsThanOrderedException.cs @@ -0,0 +1,11 @@ +using NCafe.Core.Exceptions; + +namespace NCafe.Cashier.Domain.Exceptions; + +public class CannotRemoveMoreItemsThanOrderedException(Guid orderId, Guid productId, int quantity) + : DomainException($"Cannot remove more items ({quantity}) than existing in the order '{orderId}'.") +{ + public Guid OrderId { get; } = orderId; + public Guid ProductId { get; } = productId; + public int Quantity { get; } = quantity; +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Exceptions/OrderItemNotFoundException.cs b/src/Cashier/NCafe.Cashier.Domain/Exceptions/OrderItemNotFoundException.cs new file mode 100644 index 0000000..c382ade --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Exceptions/OrderItemNotFoundException.cs @@ -0,0 +1,10 @@ +using NCafe.Core.Exceptions; + +namespace NCafe.Cashier.Domain.Exceptions; + +public class OrderItemNotFoundException(Guid orderId, Guid productId) + : DomainException($"Order item {productId} was not found in order '{orderId}'.") +{ + public Guid OrderId { get; } = orderId; + public Guid ProductId { get; } = productId; +} diff --git a/src/Cashier/NCafe.Cashier.Domain/ValueObjects/OrderItem.cs b/src/Cashier/NCafe.Cashier.Domain/ValueObjects/OrderItem.cs index 98cb41c..ad8989b 100644 --- a/src/Cashier/NCafe.Cashier.Domain/ValueObjects/OrderItem.cs +++ b/src/Cashier/NCafe.Cashier.Domain/ValueObjects/OrderItem.cs @@ -1,4 +1,5 @@ using Ardalis.GuardClauses; +using NCafe.Cashier.Domain.Exceptions; namespace NCafe.Cashier.Domain.ValueObjects; @@ -14,6 +15,18 @@ public OrderItem(Guid productId, string name, int quantity, decimal price) public Guid ProductId { get; } public string Name { get; } - public int Quantity { get; } + public int Quantity { get; private set; } public decimal Price { get; } + + public decimal Total => Quantity * Price; + + public void IncreaseQuantity(int quantity) + { + Quantity += quantity; + } + + public void DecreaseQuantity(int quantity) + { + Quantity -= quantity; + } } diff --git a/src/UI/NCafe.Web/Models/Order.cs b/src/UI/NCafe.Web/Models/Order.cs index d5c1129..0abdf16 100644 --- a/src/UI/NCafe.Web/Models/Order.cs +++ b/src/UI/NCafe.Web/Models/Order.cs @@ -1,8 +1,11 @@ -namespace NCafe.Web.Models; +using System.ComponentModel.DataAnnotations; + +namespace NCafe.Web.Models; public class Order { public Guid Id { get; set; } + [Required] public string CustomerName { get; set; } public List Items { get; set; } = new(); public decimal Total => Items.Sum(x => x.Total); diff --git a/src/UI/NCafe.Web/Pages/Cashier/Index.razor b/src/UI/NCafe.Web/Pages/Cashier/Index.razor index c901874..1c4c279 100644 --- a/src/UI/NCafe.Web/Pages/Cashier/Index.razor +++ b/src/UI/NCafe.Web/Pages/Cashier/Index.razor @@ -28,9 +28,13 @@
    @foreach (var item in _order.Items) { -
  • +
  • @item.Quantity x @(_products.FirstOrDefault(p => p.Id == item.ProductId)?.Name) @(item.Total.ToString("C")) + + +
  • }
@@ -53,26 +57,26 @@ } - - - - - -
- Total - @(_order.Total.ToString("C")) -
-
+ +
+ + + +
+
@code { private Product[] _products; private readonly Order _order = new(); private string _baseAddress; - private bool _isModalVisible; + private Form _form; + private bool _modalVisible; + Input _inputCustomerName; protected override async Task OnInitializedAsync() { @@ -81,13 +85,36 @@ _products = await Http.GetFromJsonAsync($"{_baseAddress}/products"); } - void ShowModal() => _isModalVisible = true; - void HideModal() => _isModalVisible = false; + private async Task RemoveItem(Guid productId) + { + try + { + var item = _order.Items.First(i => i.ProductId == productId); + var response = await Http.PostAsJsonAsync( + $"{_baseAddress}/orders/remove-item", + new { OrderId = _order.Id, productId, item.Quantity }); + response.EnsureSuccessStatusCode(); + _order.Items.Remove(item); + } + catch (Exception ex) + { + _ = NotificationService.Open(new NotificationConfig + { + Message = "Error", + Duration = 0, + Description = $"There was an error while attempting to remove item: {ex.Message}.", + NotificationType = NotificationType.Error + }); + } + } - void CancelOrder() + private void HandleOk() { - _order.Clear(); - _isModalVisible = false; + if (!_form.Validate()) + { + return; + } + _form.Submit(); } async Task AddItemToOrder(Guid productId) @@ -160,14 +187,14 @@ _ = NotificationService.Open(new NotificationConfig { - Message = "Order Created", + Message = $"{_order.CustomerName}'s Order Created", Description = "Order created successfully.", - NotificationType = NotificationType.Success + NotificationType = NotificationType.Success, }); _order.Clear(); - _isModalVisible = false; - + _form.Reset(); + _modalVisible = false; } catch (Exception ex) { @@ -176,10 +203,17 @@ Message = "Error", Duration = 0, Description = $"There was an error while attempting to place order: {ex.Message}.", - NotificationType = NotificationType.Error + NotificationType = NotificationType.Error, }); } } + private async Task ShowModal() + { + _modalVisible = true; + await Task.Delay(500); // wait for modal to show + await _inputCustomerName.Focus(); + } + void GoAdmin() => NavigationManager.NavigateTo("admin"); } diff --git a/src/UI/NCafe.Web/Shared/Modal.razor b/src/UI/NCafe.Web/Shared/Modal.razor deleted file mode 100644 index e973e61..0000000 --- a/src/UI/NCafe.Web/Shared/Modal.razor +++ /dev/null @@ -1,42 +0,0 @@ -@code { - [Parameter] - public bool IsVisible { get; set; } - - [Parameter] - public EventCallback OnClose { get; set; } - - [Parameter] - public EventCallback OnConfirm { get; set; } - - [Parameter] - public EventCallback OnCancel { get; set; } - - [Parameter] - public string Title { get; set; } - - [Parameter] - public RenderFragment ChildContent { get; set; } - - private void Close() => OnClose.InvokeAsync(); - private void Confirm() => OnConfirm.InvokeAsync(); - private void Cancel() => OnCancel.InvokeAsync(); -} - -@if (IsVisible) -{ -
-
-
-
@Title
- -
-
- @ChildContent -
-
- - -
-
-
-}