Add order invoice lifecycle

This commit is contained in:
2026-09-16 16:25:50 +05:30
parent 72f850ea4d
commit 4b56443d1a
+251 -30
View File
@@ -1,11 +1,13 @@
using Distribution.Features.Sale; using Distribution.Features.Invoice;
using Distribution.Features.Item; using Distribution.Features.Item;
using Distribution.Features.PriceList; using Distribution.Features.PriceList;
using Distribution.Features.Sale;
using Distribution.Features.Store; using Distribution.Features.Store;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Order; namespace Distribution.Features.Order;
@@ -16,19 +18,30 @@ public class OrderService(
IStockRepository stockRepo, IStockRepository stockRepo,
PriceListService priceListService, PriceListService priceListService,
ISaleRepository saleRepo, ISaleRepository saleRepo,
IInvoiceRepository invoiceRepo,
IUnitOfWork uow, IUnitOfWork uow,
ICurrentUserService currentUser, ICurrentUserService currentUser,
IScopeAccessService scopeAccess) IScopeAccessService scopeAccess,
IConfiguration configuration)
{ {
private const int GlobalScopeRoleLevel = 50; private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) => 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, new(o.Id, o.FK_SaleId, o.InvoiceId, o.OrderDate, o.FK_StoreId, o.CreatedByUserId, o.TotalAmount, o.Status,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(), 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); o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel; 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) private async Task<bool> HasStoreScopeAsync(Guid storeId)
{ {
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope; if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
@@ -76,45 +89,152 @@ public class OrderService(
return result; 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); var orders = await repo.GetConfirmedAsync(agentId, routeId, storeId);
if (sale is null || !sale.IsActive) return null; var result = new List<OrderResponse>();
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null; foreach (var order in orders)
{
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId) var items = await repo.GetItemsByOrderIdAsync(order.Id);
?? throw new InvalidOperationException("Orders are created automatically from sales"); result.Add(ToResponse(order, items));
}
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id); return result;
return ToResponse(existingOrder, items);
} }
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); var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null; 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) var currentItems = await repo.GetItemsByOrderIdAsync(id);
?? throw new InvalidOperationException("Sale not found for this order"); foreach (var item in currentItems)
item.IsActive = false;
order.Status = request.Status; var newItems = new List<Models.OrderItem>();
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null; foreach (var line in request.Items)
sale.Status = request.Status switch
{ {
OrderStatus.Fulfilled => SaleStatus.Completed, var unitPrice = await ResolveUnitPriceAsync(line.ItemId, line.UnitPrice, order.FK_StoreId, order.OrderDate);
OrderStatus.Cancelled => SaleStatus.Cancelled, newItems.Add(new Models.OrderItem
_ => SaleStatus.Pending {
}; FK_OrderId = order.Id,
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null; 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); await uow.BeginAsync(ct);
try try
{ {
repo.Update(order); repo.Update(order);
saleRepo.Update(sale);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch catch
@@ -127,6 +247,107 @@ public class OrderService(
return ToResponse(order, items); 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) public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{ {
// TEMPORARY: see SaleService — store-scope enforcement depends on the broken auth // TEMPORARY: see SaleService — store-scope enforcement depends on the broken auth
@@ -134,7 +355,7 @@ public class OrderService(
var order = await repo.GetByIdAsync(id); var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false; 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); await uow.BeginAsync(ct);
try try