286 lines
12 KiB
C#
286 lines
12 KiB
C#
using Distribution.Features.ShopPO;
|
|
using Distribution.Features.Stock;
|
|
using Distribution.Infra.Authorization;
|
|
using Distribution.Infra.UoW;
|
|
using Distribution.Models;
|
|
|
|
namespace Distribution.Features.Invoice;
|
|
|
|
public class InvoiceService(
|
|
IInvoiceRepository repo,
|
|
IShopPORepository shopPoRepo,
|
|
IUnitOfWork uow,
|
|
ICurrentUserService currentUser)
|
|
{
|
|
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
|
new(i.Id, i.FK_ShopPOId,
|
|
i.ShopPO?.FK_StoreId ?? Guid.Empty,
|
|
i.ShopPO?.Store?.StoreName ?? string.Empty,
|
|
i.Status, i.PaymentStatus, i.CreatedBy,
|
|
i.PrintedAt, i.PrintedBy,
|
|
i.FinalizedAt, i.FinalizedBy,
|
|
i.TotalAmount,
|
|
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
|
i.IsActive, i.CreatedAt,
|
|
i.Items.Where(x => x.IsActive).Select(x =>
|
|
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
|
|
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
|
|
x.Quantity, x.UnitPrice, x.IsRemoved)).ToList());
|
|
|
|
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
|
|
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
|
|
|
|
// Immutable copy of the invoice lines as they stand right now.
|
|
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
|
|
new()
|
|
{
|
|
FK_InvoiceId = invoice.Id,
|
|
VersionNumber = versionNumber,
|
|
Stage = stage,
|
|
TotalAmount = invoice.TotalAmount,
|
|
CreatedBy = userId,
|
|
Items = items.Select(i => new InvoiceVersionItem
|
|
{
|
|
FK_ShopPOItemId = i.FK_ShopPOItemId,
|
|
FK_ItemId = i.FK_ItemId,
|
|
Quantity = i.Quantity,
|
|
UnitPrice = i.UnitPrice,
|
|
IsRemoved = i.IsRemoved
|
|
}).ToList()
|
|
};
|
|
|
|
public async Task<List<InvoiceVersionResponse>?> GetVersionsAsync(Guid id)
|
|
{
|
|
if (await repo.GetByIdAsync(id) is null) return null;
|
|
var versions = await repo.GetVersionsAsync(id);
|
|
return versions.Select(v => new InvoiceVersionResponse(
|
|
v.Id, v.VersionNumber, v.Stage, v.TotalAmount, v.CreatedBy, v.CreatedAt,
|
|
v.Items.Select(i => new InvoiceVersionItemResponse(
|
|
i.FK_ShopPOItemId, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.UnitPrice, i.IsRemoved)).ToList())).ToList();
|
|
}
|
|
|
|
private async Task AddVersionAsync(Models.Invoice invoice, InvoiceVersionStage stage, Guid userId, IEnumerable<InvoiceItem>? addedLines = null)
|
|
{
|
|
var next = await repo.GetLatestVersionNumberAsync(invoice.Id) + 1;
|
|
// Added lines may or may not be in invoice.Items yet, so merge by reference.
|
|
var lines = invoice.Items.Concat(addedLines ?? []).Where(i => i.IsActive).Distinct();
|
|
await repo.AddVersionAsync(BuildVersion(invoice, lines, next, stage, userId));
|
|
}
|
|
|
|
public static List<InvoiceItem> BuildItemsFromShopPO(Models.ShopPO po, Guid invoiceId) =>
|
|
po.Items.Where(i => i.IsActive).Select(i => new InvoiceItem
|
|
{
|
|
FK_InvoiceId = invoiceId,
|
|
FK_ShopPOItemId = i.Id,
|
|
FK_ItemId = i.FK_ItemId,
|
|
Quantity = i.Quantity,
|
|
UnitPrice = i.UnitPrice
|
|
}).ToList();
|
|
|
|
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var invoice = await repo.GetByIdAsync(id);
|
|
return invoice is null ? null : ToResponse(invoice);
|
|
}
|
|
|
|
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
|
|
{
|
|
var total = await repo.GetCountAsync(agentId, routeId, shopId, status, paymentStatus);
|
|
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
|
|
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
|
}
|
|
|
|
public async Task<OutstandingPaymentsResponse> GetOutstandingSummaryAsync()
|
|
{
|
|
var invoices = await repo.GetOutstandingAsync();
|
|
var count = invoices.Count;
|
|
var outstanding = invoices.Sum(i => i.TotalAmount - i.Payments.Sum(p => p.Amount));
|
|
return new OutstandingPaymentsResponse(count, outstanding);
|
|
}
|
|
|
|
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default)
|
|
{
|
|
var po = await shopPoRepo.GetByIdAsync(request.ShopPOId)
|
|
?? throw new KeyNotFoundException("Shop PO not found");
|
|
|
|
if (po.ConfirmedAt is null)
|
|
throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created");
|
|
if (po.Status == ShopPOStatus.Cancelled)
|
|
throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO");
|
|
if (po.InvoiceId is not null)
|
|
throw new InvalidOperationException("An invoice already exists for this Shop PO");
|
|
|
|
var invoice = new Models.Invoice
|
|
{
|
|
FK_ShopPOId = po.Id,
|
|
Status = InvoiceStatus.Draft,
|
|
PaymentStatus = PaymentStatus.Pending,
|
|
CreatedBy = createdBy,
|
|
TotalAmount = po.TotalAmount
|
|
};
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await repo.AddAsync(invoice);
|
|
var lines = BuildItemsFromShopPO(po, invoice.Id);
|
|
await repo.AddItemsAsync(lines);
|
|
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
|
|
po.InvoiceId = invoice.Id;
|
|
shopPoRepo.Update(po);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
var saved = await repo.GetByIdAsync(invoice.Id);
|
|
return ToResponse(saved!);
|
|
}
|
|
|
|
public async Task<InvoiceResponse> UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default)
|
|
{
|
|
var invoice = await repo.GetByIdAsync(id)
|
|
?? throw new KeyNotFoundException("Invoice not found");
|
|
|
|
if (invoice.Status != InvoiceStatus.Draft)
|
|
throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})");
|
|
|
|
var newLines = new List<InvoiceItem>();
|
|
|
|
foreach (var edit in request.Items)
|
|
{
|
|
if (edit.UnitPrice is < 0)
|
|
throw new InvalidOperationException("Unit price cannot be negative");
|
|
|
|
if (edit.InvoiceItemId is Guid lineId)
|
|
{
|
|
// Existing line: edit quantity/price, or strike it (IsRemoved) / restore it.
|
|
var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive)
|
|
?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice");
|
|
|
|
item.IsRemoved = edit.IsRemoved;
|
|
if (!edit.IsRemoved)
|
|
{
|
|
ValidateQuantity(item, edit.Quantity);
|
|
item.Quantity = edit.Quantity;
|
|
}
|
|
if (edit.UnitPrice is decimal price) item.UnitPrice = price;
|
|
}
|
|
else if (edit.ItemId is Guid itemId)
|
|
{
|
|
// New line for an item that was not on the PO.
|
|
if (edit.IsRemoved) continue;
|
|
if (edit.Quantity <= 0)
|
|
throw new InvalidOperationException("Quantity for an added item must be greater than zero");
|
|
if (edit.UnitPrice is null)
|
|
throw new InvalidOperationException("Unit price is required for an added item");
|
|
if (invoice.Items.Concat(newLines).Any(i => i.IsActive && !i.IsRemoved && i.FK_ItemId == itemId))
|
|
throw new InvalidOperationException("This item is already on the invoice — edit its existing line instead");
|
|
|
|
var catalogItem = await repo.GetItemAsync(itemId)
|
|
?? throw new InvalidOperationException($"Item {itemId} not found");
|
|
|
|
newLines.Add(new InvoiceItem
|
|
{
|
|
FK_InvoiceId = invoice.Id,
|
|
FK_ItemId = catalogItem.Id,
|
|
Item = catalogItem,
|
|
Quantity = edit.Quantity,
|
|
UnitPrice = edit.UnitPrice.Value
|
|
});
|
|
}
|
|
else
|
|
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
|
|
}
|
|
|
|
invoice.TotalAmount = BillableTotal(invoice.Items.Concat(newLines));
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(invoice);
|
|
if (newLines.Count > 0) await repo.AddItemsAsync(newLines);
|
|
await AddVersionAsync(invoice, InvoiceVersionStage.DraftEdited, editedBy, newLines);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
|
}
|
|
|
|
private static void ValidateQuantity(InvoiceItem item, int quantity)
|
|
{
|
|
var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue;
|
|
if (quantity < 0 || quantity > ordered)
|
|
throw new InvalidOperationException(
|
|
$"Quantity for item {item.Id} must be between 0 and {ordered}");
|
|
}
|
|
|
|
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
|
{
|
|
var invoice = await repo.GetByIdAsync(id)
|
|
?? throw new KeyNotFoundException("Invoice not found");
|
|
|
|
if (invoice.Status != InvoiceStatus.Draft)
|
|
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
|
|
|
invoice.Status = InvoiceStatus.Printed;
|
|
invoice.PrintedAt = DateTime.Now;
|
|
invoice.PrintedBy = printedBy;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(invoice);
|
|
await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return ToResponse(invoice);
|
|
}
|
|
|
|
public async Task<InvoiceResponse> FinalizeAsync(Guid id, FinalizeInvoiceRequest request, Guid finalizedBy, CancellationToken ct = default)
|
|
{
|
|
var invoice = await repo.GetByIdAsync(id)
|
|
?? throw new KeyNotFoundException("Invoice not found");
|
|
|
|
if (invoice.Status != InvoiceStatus.Printed)
|
|
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
|
|
|
var items = invoice.Items.Where(i => i.IsActive).ToList();
|
|
var adjustments = request.Adjustments ?? [];
|
|
|
|
// Agent's handwritten edits from the printed copy are written onto the invoice lines.
|
|
foreach (var adjustment in adjustments)
|
|
{
|
|
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
|
|
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
|
|
|
|
item.IsRemoved = adjustment.IsRemoved;
|
|
if (!adjustment.IsRemoved)
|
|
{
|
|
ValidateQuantity(item, adjustment.AdjustedQuantity);
|
|
item.Quantity = adjustment.AdjustedQuantity;
|
|
}
|
|
}
|
|
|
|
if (items.Count > 0)
|
|
invoice.TotalAmount = BillableTotal(items);
|
|
|
|
invoice.Status = InvoiceStatus.Finalized;
|
|
invoice.FinalizedAt = DateTime.Now;
|
|
invoice.FinalizedBy = finalizedBy;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(invoice);
|
|
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return ToResponse(invoice);
|
|
}
|
|
}
|