Add sales invoice document

This commit is contained in:
2026-07-27 14:32:09 +05:30
committed by ImanThiyanga
parent ffbd47f6f9
commit 8b8e79e0fe
12 changed files with 584 additions and 0 deletions
@@ -0,0 +1,16 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Domain.Enums;
namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService
{
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default);
}
@@ -0,0 +1,249 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class SalesInvoiceService : ISalesInvoiceService
{
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IFifoCostingService _fifo;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
public SalesInvoiceService(
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IFifoCostingService fifo,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
{
_invoices = invoices;
_customers = customers;
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_fifo = fifo;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
}
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
{
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.InvoiceNo, $"%{term}%") || EF.Functions.ILike(x.CustomerSnapshotName, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
return invoice is null ? null : new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
}
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
{
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
var invoice = new SalesInvoice
{
InvoiceNo = await _numbers.NextAsync(DocumentTypes.SalesInvoice, ct),
InvoiceDate = DateTime.UtcNow,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
InvoiceType = request.InvoiceType,
Status = SalesInvoiceStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow
};
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
invoice.Lines = await BuildLinesAsync(request.Lines, ct);
Recalculate(invoice);
await _invoices.AddAsync(invoice, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
}
public async Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales invoice was modified by another request.", 412);
if (invoice.Status != SalesInvoiceStatus.Draft)
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be edited.");
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
invoice.CustomerId = request.CustomerId;
invoice.WarehouseId = request.WarehouseId;
invoice.InvoiceType = request.InvoiceType;
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
invoice.Lines.Clear();
foreach (var line in await BuildLinesAsync(request.Lines, ct)) invoice.Lines.Add(line);
Recalculate(invoice);
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
}
public async Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.Status != SalesInvoiceStatus.Draft)
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be posted.");
var posted = await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var line in invoice.Lines)
{
if (line.Qty <= 0) continue;
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, token);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
Direction.Out, line.Qty + line.FreeQty, cost, 0m, nameof(SalesInvoice), invoice.SalesInvoiceId, DateTime.UtcNow, token);
}
invoice.Status = SalesInvoiceStatus.Posted;
invoice.UpdatedAt = DateTime.UtcNow;
return invoice;
}, ct);
return Map(posted);
}
public async Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.Status != SalesInvoiceStatus.Draft)
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be cancelled.");
invoice.Status = SalesInvoiceStatus.Cancelled;
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(invoice);
}
private async Task ValidateReferencesAsync(int customerId, int warehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
{
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
throw new NotFoundException($"Customer {customerId} was not found.");
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
foreach (var line in lines)
{
if (!await _items.Query().AnyAsync(x => x.ItemId == line.ItemId, ct))
throw new NotFoundException($"Item {line.ItemId} was not found.");
if (!await _uoms.Query().AnyAsync(x => x.UomId == line.UomId, ct))
throw new NotFoundException($"UOM {line.UomId} was not found.");
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct))
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
}
}
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
{
var lines = new List<SalesInvoiceLine>();
foreach (var r in requests)
{
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
var priceSource = "SALE_PRICE";
decimal unitPrice;
if (r.UnitPrice is not null)
{
unitPrice = r.UnitPrice.Value;
priceSource = "MANUAL";
}
else if (item.SalePrice.HasValue)
{
unitPrice = item.SalePrice.Value;
}
else
{
var valuation = await _fifo.GetValuationAsync(r.ItemId, r.WarehouseId, ct);
unitPrice = valuation.TotalQty > 0 ? valuation.TotalValue / valuation.TotalQty : 0m;
priceSource = "FIFO_AVG";
}
var gross = r.Qty * unitPrice;
var discountAfterPct = gross * (r.DiscountPct / 100m);
var discountTotal = discountAfterPct + r.DiscountAmount;
var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m;
var lineTotal = gross - discountTotal;
var taxAmount = lineTotal * (r.TaxPct / 100m);
lines.Add(new SalesInvoiceLine
{
ItemId = r.ItemId,
Description = item.Name,
Qty = r.Qty,
FreeQty = r.FreeQty,
UomId = r.UomId,
WarehouseId = r.WarehouseId,
UnitPrice = unitPrice,
BaseCost = unitPrice,
PriceSource = priceSource,
DiscountPct = r.DiscountPct,
DiscountAmount = discountTotal,
NetUnitPrice = netUnit,
LineTotal = lineTotal,
TaxPct = r.TaxPct,
TaxAmount = taxAmount,
IsFreeIssue = r.IsFreeIssue,
ParentLineId = r.ParentLineId
});
}
return lines;
}
private static void Recalculate(SalesInvoice invoice)
{
invoice.Subtotal = invoice.Lines.Sum(x => x.Qty * x.UnitPrice);
invoice.DiscountTotal = invoice.Lines.Sum(x => x.DiscountAmount);
invoice.TaxTotal = invoice.Lines.Sum(x => x.TaxAmount);
invoice.GrandTotal = invoice.Lines.Sum(x => x.LineTotal) + invoice.TaxTotal;
invoice.RoundOff = 0m;
invoice.NetPayable = invoice.GrandTotal + invoice.RoundOff;
invoice.PaidAmount = 0m;
invoice.BalanceAmount = invoice.NetPayable;
}
private static SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
x.WarehouseId, x.InvoiceType, x.Status, new SalesInvoiceTotalsDto(
x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount), x.CreatedAt);
private static SalesInvoiceDto Map(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, x.CustomerSnapshotTaxNo,
x.WarehouseId, x.InvoiceType, x.Status, x.CreatedBy, x.CreatedAt, x.UpdatedAt,
new SalesInvoiceTotalsDto(x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount),
x.Lines.Select(l => new SalesInvoiceLineDto(l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
}