346 lines
16 KiB
C#
346 lines
16 KiB
C#
using Distribution.Features.Scheme;
|
|
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,
|
|
SchemeService schemeService,
|
|
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.DiscountAmount,
|
|
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, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList());
|
|
|
|
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
|
|
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
|
|
|
|
// The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the
|
|
// scheme rules ignore them.
|
|
private static List<ShopPOItem> DiscountableLines(IEnumerable<InvoiceItem> items) =>
|
|
items.Where(i => i.IsActive && !i.IsRemoved)
|
|
.Select(i => new ShopPOItem { FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, UnitPrice = i.UnitPrice, IsFree = i.IsFree })
|
|
.ToList();
|
|
|
|
// TotalAmount = billable lines - scheme discount. The discount is re-evaluated against the current lines
|
|
// (so adding/removing/re-pricing items moves it, and a slab level can be gained or lost) whenever lines change
|
|
// and at print/finalize. The redemption amount is updated with it.
|
|
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
|
|
{
|
|
var lines = items.ToList();
|
|
// Lowering/removing the paid item that earned a free issue lowers the free quantity with it.
|
|
await schemeService.ReconcileInvoiceFreeLinesAsync(invoice.FK_ShopPOId, lines);
|
|
var billable = BillableTotal(lines);
|
|
var discount = await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, DiscountableLines(lines), persist: true);
|
|
invoice.DiscountAmount = Math.Min(discount, billable);
|
|
invoice.TotalAmount = billable - invoice.DiscountAmount;
|
|
}
|
|
|
|
// Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing.
|
|
public async Task<InvoiceTotalsPreview?> PreviewTotalsAsync(Guid id, PreviewInvoiceTotalsRequest request)
|
|
{
|
|
var invoice = await repo.GetByIdAsync(id);
|
|
if (invoice is null) return null;
|
|
|
|
var lines = request.Items.Where(l => !l.IsRemoved)
|
|
.Select(l => new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, IsFree = l.IsFree, FK_SchemeId = l.SchemeId })
|
|
.ToList();
|
|
var billable = lines.Sum(l => l.Quantity * l.UnitPrice);
|
|
var chosen = request.DiscountSchemeIds is null
|
|
? await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, lines, persist: false)
|
|
: await schemeService.CalculateDiscountForSchemesAsync(request.DiscountSchemeIds, lines);
|
|
var discount = Math.Min(chosen, billable);
|
|
|
|
var schemeIds = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue).Select(l => l.FK_SchemeId!.Value).ToList();
|
|
var entitlements = (await schemeService.GetFreeEntitlementsAsync(schemeIds, lines))
|
|
.Select(e => new FreeEntitlement(e.Key, e.Value)).ToList();
|
|
return new InvoiceTotalsPreview(discount, billable - discount, entitlements);
|
|
}
|
|
|
|
// 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,
|
|
IsFree = i.IsFree,
|
|
FK_SchemeId = i.FK_SchemeId,
|
|
SchemeName = i.SchemeName
|
|
}).ToList();
|
|
|
|
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var invoice = await repo.GetByIdAsync(id);
|
|
if (invoice is null) return null;
|
|
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
|
|
}
|
|
|
|
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
|
|
};
|
|
var lines = BuildItemsFromShopPO(po, invoice.Id);
|
|
await ApplyTotalsAsync(invoice, lines);
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await repo.AddAsync(invoice);
|
|
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)
|
|
{
|
|
if (item.IsFree && price != 0m)
|
|
throw new InvalidOperationException("A free-issue line must stay at zero 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)");
|
|
}
|
|
|
|
var allLines = invoice.Items.Concat(newLines).ToList();
|
|
await schemeService.ApplyInvoiceDiscountSelectionAsync(
|
|
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
|
|
await ApplyTotalsAsync(invoice, allLines);
|
|
|
|
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 (await 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");
|
|
|
|
// Printing fixes the amount the customer sees, so pick up any scheme discount applied since the last edit.
|
|
var lines = invoice.Items.Where(i => i.IsActive).ToList();
|
|
if (lines.Count > 0) await ApplyTotalsAsync(invoice, lines);
|
|
|
|
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)
|
|
await ApplyTotalsAsync(invoice, 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);
|
|
}
|
|
}
|