|
|
|
@@ -1,4 +1,5 @@
|
|
|
|
|
using Distribution.Features.ShopPO;
|
|
|
|
|
using Distribution.Features.Scheme;
|
|
|
|
|
using Distribution.Features.ShopPO;
|
|
|
|
|
using Distribution.Features.Stock;
|
|
|
|
|
using Distribution.Infra.Authorization;
|
|
|
|
|
using Distribution.Infra.UoW;
|
|
|
|
@@ -9,6 +10,7 @@ namespace Distribution.Features.Invoice;
|
|
|
|
|
public class InvoiceService(
|
|
|
|
|
IInvoiceRepository repo,
|
|
|
|
|
IShopPORepository shopPoRepo,
|
|
|
|
|
SchemeService schemeService,
|
|
|
|
|
IUnitOfWork uow,
|
|
|
|
|
ICurrentUserService currentUser)
|
|
|
|
|
{
|
|
|
|
@@ -25,20 +27,53 @@ public class InvoiceService(
|
|
|
|
|
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());
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// TotalAmount = billable lines - scheme discount redeemed on the PO. Re-derived whenever lines change
|
|
|
|
|
// and at finalize, so a scheme applied after the invoice was drafted is still picked up before it locks.
|
|
|
|
|
// 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 billable = BillableTotal(items);
|
|
|
|
|
invoice.DiscountAmount = Math.Min(await repo.GetSchemeDiscountAsync(invoice.FK_ShopPOId), billable);
|
|
|
|
|
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()
|
|
|
|
@@ -83,13 +118,17 @@ public class InvoiceService(
|
|
|
|
|
FK_ShopPOItemId = i.Id,
|
|
|
|
|
FK_ItemId = i.FK_ItemId,
|
|
|
|
|
Quantity = i.Quantity,
|
|
|
|
|
UnitPrice = i.UnitPrice
|
|
|
|
|
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);
|
|
|
|
|
return invoice is null ? null : ToResponse(invoice);
|
|
|
|
|
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)
|
|
|
|
@@ -172,7 +211,12 @@ public class InvoiceService(
|
|
|
|
|
ValidateQuantity(item, edit.Quantity);
|
|
|
|
|
item.Quantity = edit.Quantity;
|
|
|
|
|
}
|
|
|
|
|
if (edit.UnitPrice is decimal price) item.UnitPrice = price;
|
|
|
|
|
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)
|
|
|
|
|
{
|
|
|
|
@@ -201,7 +245,10 @@ public class InvoiceService(
|
|
|
|
|
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ApplyTotalsAsync(invoice, invoice.Items.Concat(newLines));
|
|
|
|
|
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
|
|
|
|
@@ -213,7 +260,7 @@ public class InvoiceService(
|
|
|
|
|
}
|
|
|
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
|
|
|
|
|
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
|
|
|
|
return (await GetByIdAsync(id))!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void ValidateQuantity(InvoiceItem item, int quantity)
|
|
|
|
|