Files
ERP_Distribution/Distribution/Features/Order/OrderService.cs

380 lines
15 KiB
C#

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<Models.OrderItem> 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;
/// <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;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
public async Task<List<OrderResponse>> 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<OrderResponse>();
foreach (var order in orders)
{
var items = await repo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(order, items));
}
return result;
}
public async Task<OrderResponse?> 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<List<OrderResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var orders = await repo.GetByStoreIdAsync(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;
}
/// <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 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;
}
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)
{
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<Models.OrderItem>();
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);
}
/// <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);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
var items = await repo.GetItemsByOrderIdAsync(id);
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
// 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;
}
}