From 8b8e79e0fec80de1327177b49986b369e8e2a3e2 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 27 Jul 2026 14:32:09 +0530 Subject: [PATCH] Add sales invoice document --- .../Controllers/SalesInvoicesController.cs | 67 +++++ Backend/ERPCore/Domain/DocumentTypes.cs | 1 + .../ERPCore/Domain/Entities/SalesInvoice.cs | 41 +++ .../Domain/Entities/SalesInvoiceLine.cs | 35 +++ .../Domain/Enums/SalesInvoiceStatus.cs | 8 + .../ERPCore/Domain/Enums/SalesInvoiceType.cs | 9 + .../ERPCore/Dtos/Sales/SalesInvoiceDtos.cs | 56 ++++ .../SalesInvoiceConfiguration.cs | 95 +++++++ .../ERPCore/Infra/Persistence/ErpDbContext.cs | 4 + Backend/ERPCore/Program.cs | 3 + .../Interfaces/ISalesInvoiceService.cs | 16 ++ .../ERPCore/Services/SalesInvoiceService.cs | 249 ++++++++++++++++++ 12 files changed, 584 insertions(+) create mode 100644 Backend/ERPCore/Controllers/SalesInvoicesController.cs create mode 100644 Backend/ERPCore/Domain/Entities/SalesInvoice.cs create mode 100644 Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs create mode 100644 Backend/ERPCore/Domain/Enums/SalesInvoiceStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/SalesInvoiceType.cs create mode 100644 Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs create mode 100644 Backend/ERPCore/Services/SalesInvoiceService.cs diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs new file mode 100644 index 0000000..e27dca0 --- /dev/null +++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs @@ -0,0 +1,67 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +[Route("api/v1/sales-invoices")] +public sealed class SalesInvoicesController : ApiControllerBase +{ + private readonly ISalesInvoiceService _invoices; + + public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, + [FromQuery] SalesInvoiceStatus? status, + [FromQuery] int? customerId, + [FromQuery] int? warehouseId, + CancellationToken ct) + => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct)); + + [HttpGet("{salesInvoiceId:int}")] + [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int salesInvoiceId, CancellationToken ct) + { + var result = await _invoices.GetAsync(salesInvoiceId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)] + public async Task> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct) + { + var result = await _invoices.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/sales-invoices/{result.Value.SalesInvoiceId}", result.Value); + } + + [HttpPut("{salesInvoiceId:int}")] + [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int salesInvoiceId, [FromBody] UpdateSalesInvoiceRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _invoices.UpdateAsync(salesInvoiceId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost("{salesInvoiceId:int}/post")] + [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] + public async Task> Post(int salesInvoiceId, CancellationToken ct) + => Ok(await _invoices.PostAsync(salesInvoiceId, ct)); + + [HttpPost("{salesInvoiceId:int}/cancel")] + [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] + public async Task> Cancel(int salesInvoiceId, CancellationToken ct) + => Ok(await _invoices.CancelAsync(salesInvoiceId, ct)); +} diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs index 564e516..b87e7e9 100644 --- a/Backend/ERPCore/Domain/DocumentTypes.cs +++ b/Backend/ERPCore/Domain/DocumentTypes.cs @@ -17,4 +17,5 @@ public static class DocumentTypes /// Production run (docs/30 FR-MFG-08) — PRD-2026-00001. public const string Production = "PRD"; + public const string SalesInvoice = "SI"; } diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoice.cs b/Backend/ERPCore/Domain/Entities/SalesInvoice.cs new file mode 100644 index 0000000..15f40de --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesInvoice.cs @@ -0,0 +1,41 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +public class SalesInvoice +{ + public int SalesInvoiceId { get; set; } + public string InvoiceNo { get; set; } = string.Empty; + public DateTime InvoiceDate { get; set; } + + public int CustomerId { get; set; } + public Customer? Customer { get; set; } + + public string CustomerSnapshotName { get; set; } = string.Empty; + public string? CustomerSnapshotTaxNo { get; set; } + + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C; + public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft; + + public decimal Subtotal { get; set; } + public decimal DiscountTotal { get; set; } + public decimal TaxTotal { get; set; } + public decimal GrandTotal { get; set; } + public decimal RoundOff { get; set; } + public decimal NetPayable { get; set; } + public decimal PaidAmount { get; set; } + public decimal BalanceAmount { get; set; } + + public int CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + public uint RowVersion { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs new file mode 100644 index 0000000..05310de --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs @@ -0,0 +1,35 @@ +namespace ERPCore.Domain.Entities; + +public class SalesInvoiceLine +{ + public int SalesInvoiceLineId { get; set; } + + public int SalesInvoiceId { get; set; } + public SalesInvoice? SalesInvoice { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public string Description { get; set; } = string.Empty; + + public decimal Qty { get; set; } + public decimal FreeQty { get; set; } + public int UomId { get; set; } + public Uom? Uom { get; set; } + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public decimal UnitPrice { get; set; } + public decimal BaseCost { get; set; } + public string PriceSource { get; set; } = string.Empty; + public decimal DiscountPct { get; set; } + public decimal DiscountAmount { get; set; } + public decimal NetUnitPrice { get; set; } + public decimal LineTotal { get; set; } + public decimal TaxPct { get; set; } + public decimal TaxAmount { get; set; } + public bool IsFreeIssue { get; set; } + public int? ParentLineId { get; set; } + + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Enums/SalesInvoiceStatus.cs b/Backend/ERPCore/Domain/Enums/SalesInvoiceStatus.cs new file mode 100644 index 0000000..b3c4aab --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/SalesInvoiceStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +public enum SalesInvoiceStatus +{ + Draft = 1, + Posted = 2, + Cancelled = 3 +} diff --git a/Backend/ERPCore/Domain/Enums/SalesInvoiceType.cs b/Backend/ERPCore/Domain/Enums/SalesInvoiceType.cs new file mode 100644 index 0000000..375c497 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/SalesInvoiceType.cs @@ -0,0 +1,9 @@ +namespace ERPCore.Domain.Enums; + +public enum SalesInvoiceType +{ + B2B = 1, + B2C = 2, + Cash = 3, + Credit = 4 +} diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs new file mode 100644 index 0000000..adad697 --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Sales; + +public sealed record SalesInvoiceLineDto( + int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId, + int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, + decimal DiscountAmount, decimal NetUnitPrice, decimal LineTotal, decimal TaxPct, decimal TaxAmount, + bool IsFreeIssue, int? ParentLineId); + +public sealed record SalesInvoiceTotalsDto( + decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, + decimal RoundOff, decimal NetPayable, decimal PaidAmount, decimal BalanceAmount); + +public sealed record SalesInvoiceDto( + int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId, + string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId, + SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt, + DateTime? UpdatedAt, SalesInvoiceTotalsDto Totals, IReadOnlyList Lines); + +public sealed record SalesInvoiceSummaryDto( + int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId, + string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType, + SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt); + +public sealed class CreateSalesInvoiceLineRequest +{ + [Required] public int ItemId { get; set; } + [Required] public int UomId { get; set; } + [Required] public int WarehouseId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + [Range(0, double.MaxValue)] public decimal FreeQty { get; set; } + [Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; } + [Range(0, 100)] public decimal DiscountPct { get; set; } + [Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; } + [Range(0, 100)] public decimal TaxPct { get; set; } + public bool IsFreeIssue { get; set; } + public int? ParentLineId { get; set; } +} + +public sealed class CreateSalesInvoiceRequest +{ + [Required] public int CustomerId { get; set; } + [Required] public int WarehouseId { get; set; } + [Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C; + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class UpdateSalesInvoiceRequest +{ + [Required] public int CustomerId { get; set; } + [Required] public int WarehouseId { get; set; } + [Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C; + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs new file mode 100644 index 0000000..e8f27ed --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs @@ -0,0 +1,95 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_invoices"); + builder.HasKey(x => x.SalesInvoiceId); + + builder.Property(x => x.InvoiceNo).IsRequired().HasMaxLength(50); + builder.HasIndex(x => x.InvoiceNo).IsUnique(); + + builder.Property(x => x.InvoiceDate).IsRequired(); + + builder.HasOne(x => x.Customer) + .WithMany() + .HasForeignKey(x => x.CustomerId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200); + builder.Property(x => x.CustomerSnapshotTaxNo).HasMaxLength(50); + + builder.HasOne(x => x.Warehouse) + .WithMany() + .HasForeignKey(x => x.WarehouseId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.InvoiceType) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(SalesInvoiceType.B2C); + + builder.Property(x => x.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(SalesInvoiceStatus.Draft); + + foreach (var p in new[] { nameof(SalesInvoice.Subtotal), nameof(SalesInvoice.DiscountTotal), nameof(SalesInvoice.TaxTotal), nameof(SalesInvoice.GrandTotal), nameof(SalesInvoice.RoundOff), nameof(SalesInvoice.NetPayable), nameof(SalesInvoice.PaidAmount), nameof(SalesInvoice.BalanceAmount) }) + builder.Property(p).HasPrecision(18, 4); + + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.RowVersion).IsRowVersion(); + + builder.HasIndex(x => x.Status); + builder.HasIndex(x => x.InvoiceDate); + + builder.HasMany(x => x.Lines) + .WithOne(x => x.SalesInvoice) + .HasForeignKey(x => x.SalesInvoiceId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_invoice_lines"); + builder.HasKey(x => x.SalesInvoiceLineId); + + builder.HasOne(x => x.Item) + .WithMany() + .HasForeignKey(x => x.ItemId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.Description).IsRequired().HasMaxLength(200); + + builder.Property(x => x.Qty).HasPrecision(18, 4); + builder.Property(x => x.FreeQty).HasPrecision(18, 4); + builder.Property(x => x.UnitPrice).HasPrecision(18, 4); + builder.Property(x => x.BaseCost).HasPrecision(18, 4); + builder.Property(x => x.DiscountPct).HasPrecision(9, 4); + builder.Property(x => x.DiscountAmount).HasPrecision(18, 4); + builder.Property(x => x.NetUnitPrice).HasPrecision(18, 4); + builder.Property(x => x.LineTotal).HasPrecision(18, 4); + builder.Property(x => x.TaxPct).HasPrecision(9, 4); + builder.Property(x => x.TaxAmount).HasPrecision(18, 4); + + builder.HasOne(x => x.Uom) + .WithMany() + .HasForeignKey(x => x.UomId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(x => x.Warehouse) + .WithMany() + .HasForeignKey(x => x.WarehouseId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.PriceSource).HasMaxLength(50); + builder.Property(x => x.RowVersion).IsRowVersion(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index 1f4d2ce..0719513 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -83,6 +83,10 @@ public class ErpDbContext : DbContext public DbSet PurchaseReturns => Set(); public DbSet PurchaseReturnLines => Set(); + // --- Sales (Phase 1) --- + public DbSet SalesInvoices => Set(); + public DbSet SalesInvoiceLines => Set(); + // --- Reference data (docs/10 Part C.7) --- public DbSet ReasonCodes => Set(); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 5497874..25e963f 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -89,6 +89,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Sales (Phase 1) +builder.Services.AddScoped(); + // Stock transactions + reference data (docs/11 §5–6) builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs new file mode 100644 index 0000000..a0a987b --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs @@ -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> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default); + Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default); + Task> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); + Task> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task PostAsync(int salesInvoiceId, CancellationToken ct = default); + Task CancelAsync(int salesInvoiceId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs new file mode 100644 index 0000000..3bd7067 --- /dev/null +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -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 _invoices; + private readonly IRepository _customers; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly IRepository _warehouses; + private readonly IFifoCostingService _fifo; + private readonly ICurrentUser _currentUser; + private readonly INumberSequenceService _numbers; + private readonly IUnitOfWork _uow; + + public SalesInvoiceService( + IRepository invoices, IRepository customers, IRepository items, + IRepository uoms, IRepository 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> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) + { + IQueryable 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.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); + } + + public async Task?> 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(Map(invoice), invoice.RowVersion); + } + + public async Task> 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(Map(invoice), invoice.RowVersion); + } + + public async Task> 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(Map(invoice), invoice.RowVersion); + } + + public async Task 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 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 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> BuildLinesAsync(List requests, CancellationToken ct) + { + var lines = new List(); + 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()); +}