From 4b56443d1a1bc2d23f4a7403455e144662f30f83 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Wed, 16 Sep 2026 16:25:50 +0530 Subject: [PATCH] Add order invoice lifecycle --- Distribution/Features/Order/OrderService.cs | 281 +++++++++++++++++--- 1 file changed, 251 insertions(+), 30 deletions(-) diff --git a/Distribution/Features/Order/OrderService.cs b/Distribution/Features/Order/OrderService.cs index 9e9f8b7..fd5f575 100644 --- a/Distribution/Features/Order/OrderService.cs +++ b/Distribution/Features/Order/OrderService.cs @@ -1,11 +1,13 @@ -using Distribution.Features.Sale; +using Distribution.Features.Invoice; using Distribution.Features.Item; using Distribution.Features.PriceList; +using Distribution.Features.Sale; using Distribution.Features.Store; using Distribution.Features.Stock; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; using Distribution.Models; +using Microsoft.Extensions.Configuration; namespace Distribution.Features.Order; @@ -16,19 +18,30 @@ public class OrderService( IStockRepository stockRepo, PriceListService priceListService, ISaleRepository saleRepo, + IInvoiceRepository invoiceRepo, IUnitOfWork uow, ICurrentUserService currentUser, - IScopeAccessService scopeAccess) + IScopeAccessService scopeAccess, + IConfiguration configuration) { private const int GlobalScopeRoleLevel = 50; private static OrderResponse ToResponse(Models.Order o, List items) => - new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt, - items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(), + new(o.Id, o.FK_SaleId, o.InvoiceId, o.OrderDate, o.FK_StoreId, o.CreatedByUserId, o.TotalAmount, o.Status, + o.ConfirmedAt, o.FulfilledAt, + items.Select(i => new OrderItemResponse(i.Id, i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(), o.IsActive, o.CreatedAt); private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel; + /// The active InvoiceCreationMode ("A" = combined confirm+create-invoice, + /// "B" = separate confirm then create-invoice) per order_invoice_lifecycle.md. Exactly one is + /// reachable at a time. + private InvoiceCreationMode ActiveMode => + configuration.GetValue("OrderInvoice:InvoiceCreationMode") == "A" + ? InvoiceCreationMode.A + : InvoiceCreationMode.B; + private async Task HasStoreScopeAsync(Guid storeId) { if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope; @@ -76,45 +89,152 @@ public class OrderService( return result; } - public async Task CreateAsync(CreateOrderRequest request, CancellationToken ct = default) + /// Confirmed POs matching the given filters — Mode B's "office browses Confirmed POs" + /// step. Also usable as a general Confirmed-PO listing regardless of active mode. + public async Task> GetConfirmedAsync(Guid? agentId, Guid? routeId, Guid? storeId) { - var sale = await saleRepo.GetByIdAsync(request.SaleId); - if (sale is null || !sale.IsActive) return null; - if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null; - - var existingOrder = await repo.GetBySaleIdAsync(request.SaleId) - ?? throw new InvalidOperationException("Orders are created automatically from sales"); - - var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id); - return ToResponse(existingOrder, items); + var orders = await repo.GetConfirmedAsync(agentId, routeId, storeId); + var result = new List(); + foreach (var order in orders) + { + var items = await repo.GetItemsByOrderIdAsync(order.Id); + result.Add(ToResponse(order, items)); + } + return result; } - public async Task UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default) + private async Task ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Guid storeId, DateTime orderDate) + { + if (requestedUnitPrice.HasValue) return requestedUnitPrice.Value; + var resolution = await priceListService.ResolveByStoreAsync(itemId, storeId, orderDate); + if (resolution is not null) return resolution.Price; + var item = await itemRepo.GetByIdAsync(itemId) + ?? throw new InvalidOperationException($"Item {itemId} not found"); + return item.SalePrice + ?? throw new InvalidOperationException($"Could not resolve a price for item {itemId} — no price list covers it and it has no catalogue SalePrice"); + } + + /// Creates a Shop PO independent of any Sale — the entry point to the pipeline per + /// order_invoice_lifecycle.md. Items are freely editable until Confirm. + public async Task CreateAsync(CreateOrderRequest request, CancellationToken ct = default) + { + var store = await storeRepo.GetByIdAsync(request.StoreId); + if (store is null || !store.IsActive) return null; + if (!await HasStoreScopeAsync(request.StoreId)) return null; + if (request.Items is null || request.Items.Count == 0) + throw new InvalidOperationException("A PO must have at least one line item."); + + var order = new Models.Order + { + FK_StoreId = request.StoreId, + OrderDate = DateTime.Now, + CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."), + Status = OrderStatus.Pending, + }; + + var items = new List(); + foreach (var line in request.Items) + { + var unitPrice = await ResolveUnitPriceAsync(line.ItemId, line.UnitPrice, request.StoreId, order.OrderDate); + items.Add(new Models.OrderItem + { + FK_OrderId = order.Id, + FK_ItemId = line.ItemId, + Quantity = line.Quantity, + UnitPrice = unitPrice, + IsActive = true, + }); + } + order.TotalAmount = items.Sum(i => i.Quantity * i.UnitPrice); + + await uow.BeginAsync(ct); + try + { + await repo.AddAsync(order); + await repo.AddItemsAsync(items); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + + return ToResponse(order, items); + } + + /// Rewrites a Shop PO's line items — every edit soft-deletes the current active rows + /// and writes a fresh set (active = latest version, soft-deleted = history). Only allowed before + /// confirmation. + public async Task ReplaceItemsAsync(Guid id, UpdateOrderItemsRequest request, CancellationToken ct = default) { - // TEMPORARY: see DeleteAsync/SaleService — store-scope enforcement depends on the broken - // auth middleware (testbug.md #28) and would reject every update. Skipped for now. var order = await repo.GetByIdAsync(id); if (order is null || !order.IsActive) return null; + if (!await HasStoreScopeAsync(order.FK_StoreId)) return null; + if (order.ConfirmedAt is not null) + throw new InvalidOperationException("Items are locked once the PO is confirmed."); + if (request.Items is null || request.Items.Count == 0) + throw new InvalidOperationException("A PO must have at least one line item."); - var sale = await saleRepo.GetByIdAsync(order.FK_SaleId) - ?? throw new InvalidOperationException("Sale not found for this order"); + var currentItems = await repo.GetItemsByOrderIdAsync(id); + foreach (var item in currentItems) + item.IsActive = false; - order.Status = request.Status; - order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null; - - sale.Status = request.Status switch + var newItems = new List(); + foreach (var line in request.Items) { - OrderStatus.Fulfilled => SaleStatus.Completed, - OrderStatus.Cancelled => SaleStatus.Cancelled, - _ => SaleStatus.Pending - }; - sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null; + var unitPrice = await ResolveUnitPriceAsync(line.ItemId, line.UnitPrice, order.FK_StoreId, order.OrderDate); + newItems.Add(new Models.OrderItem + { + FK_OrderId = order.Id, + FK_ItemId = line.ItemId, + Quantity = line.Quantity, + UnitPrice = unitPrice, + IsActive = true, + }); + } + order.TotalAmount = newItems.Sum(i => i.Quantity * i.UnitPrice); + + await uow.BeginAsync(ct); + try + { + // currentItems came from the tracked DbContext (OrderRepository does not use + // AsNoTracking), so flipping IsActive above is enough for SaveChanges to persist it — + // no explicit repo.Update call needed for the soft-deleted rows. + await repo.AddItemsAsync(newItems); + repo.Update(order); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + + return ToResponse(order, newItems); + } + + /// Mode B — confirms the PO (locks items) without creating an Invoice. The Invoice is + /// created separately later via InvoiceService.CreateAsync. + public async Task ConfirmAsync(Guid id, CancellationToken ct = default) + { + if (ActiveMode != InvoiceCreationMode.B) + throw new InvalidOperationException("Separate confirm is not enabled — this system uses InvoiceCreationMode A."); + + var order = await repo.GetByIdAsync(id); + if (order is null || !order.IsActive) return null; + if (!await HasStoreScopeAsync(order.FK_StoreId)) return null; + if (order.Status == OrderStatus.Cancelled) + throw new InvalidOperationException("Cannot confirm a cancelled PO."); + if (order.ConfirmedAt is not null) return ToResponse(order, await repo.GetItemsByOrderIdAsync(id)); + + order.ConfirmedAt = DateTime.Now; + order.ConfirmedByUserId = currentUser.UserId; await uow.BeginAsync(ct); try { repo.Update(order); - saleRepo.Update(sale); await uow.CommitAsync(ct); } catch @@ -127,6 +247,107 @@ public class OrderService( return ToResponse(order, items); } + /// Mode A — confirms the PO and creates its Invoice (Draft) atomically in one + /// transaction. + public async Task ConfirmAndCreateInvoiceAsync(Guid id, CancellationToken ct = default) + { + if (ActiveMode != InvoiceCreationMode.A) + throw new InvalidOperationException("Combined confirm-and-create-invoice is not enabled — this system uses InvoiceCreationMode B."); + + var order = await repo.GetByIdAsync(id); + if (order is null || !order.IsActive) return null; + if (!await HasStoreScopeAsync(order.FK_StoreId)) return null; + if (order.Status == OrderStatus.Cancelled) + throw new InvalidOperationException("Cannot confirm a cancelled PO."); + if (order.InvoiceId is not null) + throw new InvalidOperationException("An Invoice already exists for this PO."); + + var items = await repo.GetItemsByOrderIdAsync(id); + var invoice = new Models.Invoice + { + FK_OrderId = order.Id, + Status = InvoiceStatus.Draft, + CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."), + TotalAmount = order.TotalAmount, + }; + + order.ConfirmedAt ??= DateTime.Now; + order.ConfirmedByUserId ??= currentUser.UserId; + order.InvoiceId = invoice.Id; + + await uow.BeginAsync(ct); + try + { + await invoiceRepo.AddAsync(invoice); + repo.Update(order); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + + return ToResponse(order, items); + } + + public async Task UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default) + { + // TEMPORARY: see DeleteAsync/SaleService — store-scope enforcement depends on the broken + // auth middleware (testbug.md #28) and would reject every update. Skipped for now. + var order = await repo.GetByIdAsync(id); + if (order is null || !order.IsActive) return null; + + order.Status = request.Status; + order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null; + + if (order.FK_SaleId is Guid saleId) + { + var sale = await saleRepo.GetByIdAsync(saleId) + ?? throw new InvalidOperationException("Sale not found for this order"); + sale.Status = request.Status switch + { + OrderStatus.Fulfilled => SaleStatus.Completed, + OrderStatus.Cancelled => SaleStatus.Cancelled, + _ => SaleStatus.Pending + }; + sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null; + + await uow.BeginAsync(ct); + try + { + repo.Update(order); + saleRepo.Update(sale); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + } + else + { + await uow.BeginAsync(ct); + try + { + repo.Update(order); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + } + + var items = await repo.GetItemsByOrderIdAsync(id); + return ToResponse(order, items); + } + + /// Cancels a Shop PO or Confirmed PO. If an Invoice was already created for this PO, + /// the Invoice/loading-sheet cascade is an explicitly unresolved open question in + /// order_invoice_lifecycle.md (Open Question 5) — the Invoice is left untouched here. public async Task DeleteAsync(Guid id, CancellationToken ct = default) { // TEMPORARY: see SaleService — store-scope enforcement depends on the broken auth @@ -134,7 +355,7 @@ public class OrderService( var order = await repo.GetByIdAsync(id); if (order is null || !order.IsActive) return false; - var sale = await saleRepo.GetByIdAsync(order.FK_SaleId); + var sale = order.FK_SaleId is Guid saleId ? await saleRepo.GetByIdAsync(saleId) : null; await uow.BeginAsync(ct); try