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; public class OrderService( IOrderRepository repo, IStoreRepository storeRepo, IItemRepository itemRepo, IStockRepository stockRepo, PriceListService priceListService, ISaleRepository saleRepo, IInvoiceRepository invoiceRepo, IUnitOfWork uow, ICurrentUserService currentUser, 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.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; return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId); } public async Task> GetAllAsync() { // Deleted orders stay IsActive=false but remain visible with a Cancelled status — // Delete never removes the row from the list, only marks it cancelled. var orders = (await repo.GetAllAsync()).ToList(); if (!HasGlobalScope && currentUser.UserId is not null) { var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet(); orders = orders.Where(o => accessibleStoreIds.Contains(o.FK_StoreId)).ToList(); } 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 GetByIdAsync(Guid id) { var order = await repo.GetByIdAsync(id); if (order is null || !order.IsActive) return null; if (!await HasStoreScopeAsync(order.FK_StoreId)) return null; var items = await repo.GetItemsByOrderIdAsync(id); return ToResponse(order, items); } public async Task?> GetByStoreIdAsync(Guid storeId) { if (!await HasStoreScopeAsync(storeId)) return null; var orders = await repo.GetByStoreIdAsync(storeId); var result = new List(); foreach (var order in orders) { var items = await repo.GetItemsByOrderIdAsync(order.Id); result.Add(ToResponse(order, items)); } return result; } /// 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 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; } 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) { 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 currentItems = await repo.GetItemsByOrderIdAsync(id); foreach (var item in currentItems) item.IsActive = false; var newItems = new List(); foreach (var line in request.Items) { 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); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } var items = await repo.GetItemsByOrderIdAsync(id); 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 // middleware (testbug.md #28) and would reject every delete. Skipped for now. var order = await repo.GetByIdAsync(id); if (order is null || !order.IsActive) return false; var sale = order.FK_SaleId is Guid saleId ? await saleRepo.GetByIdAsync(saleId) : null; await uow.BeginAsync(ct); try { repo.Remove(order); if (sale is not null) { sale.Status = SaleStatus.Cancelled; sale.CompletedAt = null; saleRepo.Update(sale); } await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return true; } }