Add order invoice lifecycle
This commit is contained in:
@@ -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<Models.OrderItem> 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;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private InvoiceCreationMode ActiveMode =>
|
||||
configuration.GetValue<string>("OrderInvoice:InvoiceCreationMode") == "A"
|
||||
? InvoiceCreationMode.A
|
||||
: InvoiceCreationMode.B;
|
||||
|
||||
private async Task<bool> HasStoreScopeAsync(Guid storeId)
|
||||
{
|
||||
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
|
||||
@@ -76,45 +89,152 @@ public class OrderService(
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
|
||||
/// <summary>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.</summary>
|
||||
public async Task<List<OrderResponse>> 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<OrderResponse>();
|
||||
foreach (var order in orders)
|
||||
{
|
||||
var items = await repo.GetItemsByOrderIdAsync(order.Id);
|
||||
result.Add(ToResponse(order, items));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
|
||||
private async Task<decimal> 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");
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public async Task<OrderResponse?> 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<Models.OrderItem>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public async Task<OrderResponse?> 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<Models.OrderItem>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Mode B — confirms the PO (locks items) without creating an Invoice. The Invoice is
|
||||
/// created separately later via InvoiceService.CreateAsync.</summary>
|
||||
public async Task<OrderResponse?> 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);
|
||||
}
|
||||
|
||||
/// <summary>Mode A — confirms the PO and creates its Invoice (Draft) atomically in one
|
||||
/// transaction.</summary>
|
||||
public async Task<OrderResponse?> 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<OrderResponse?> 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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public async Task<bool> 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
|
||||
|
||||
Reference in New Issue
Block a user