diff --git a/Backend/ERPCore/Controllers/SalesSlipsController.cs b/Backend/ERPCore/Controllers/SalesSlipsController.cs new file mode 100644 index 0000000..37601c8 --- /dev/null +++ b/Backend/ERPCore/Controllers/SalesSlipsController.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-slips")] +public sealed class SalesSlipsController : ApiControllerBase +{ + private readonly ISalesSlipService _slips; + + public SalesSlipsController(ISalesSlipService slips) => _slips = slips; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, + [FromQuery] SalesSlipStatus? status, + [FromQuery] int? customerId, + [FromQuery] int? warehouseId, + CancellationToken ct) + => Ok(await _slips.ListAsync(query, status, customerId, warehouseId, ct)); + + [HttpGet("{salesSlipId:int}")] + [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int salesSlipId, CancellationToken ct) + { + var result = await _slips.GetAsync(salesSlipId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)] + public async Task> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct) + { + var result = await _slips.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/sales-slips/{result.Value.SalesSlipId}", result.Value); + } + + [HttpPut("{salesSlipId:int}")] + [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int salesSlipId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _slips.UpdateAsync(salesSlipId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost("{salesSlipId:int}/post")] + [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)] + public async Task> Post(int salesSlipId, CancellationToken ct) + => Ok(await _slips.PostAsync(salesSlipId, ct)); + + [HttpPost("{salesSlipId:int}/cancel")] + [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)] + public async Task> Cancel(int salesSlipId, CancellationToken ct) + => Ok(await _slips.CancelAsync(salesSlipId, ct)); +} diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs index b87e7e9..4e1f4ee 100644 --- a/Backend/ERPCore/Domain/DocumentTypes.cs +++ b/Backend/ERPCore/Domain/DocumentTypes.cs @@ -18,4 +18,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"; + public const string SalesSlip = "SSL"; } diff --git a/Backend/ERPCore/Domain/Entities/SalesSlip.cs b/Backend/ERPCore/Domain/Entities/SalesSlip.cs new file mode 100644 index 0000000..46f697a --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesSlip.cs @@ -0,0 +1,36 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +public class SalesSlip +{ + public int SalesSlipId { get; set; } + public string SlipNo { get; set; } = string.Empty; + public DateTime SlipDate { get; set; } + + public int CustomerId { get; set; } + public Customer? Customer { get; set; } + + public string CustomerSnapshotName { get; set; } = string.Empty; + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public int CashierUserId { get; set; } + public User? CashierUser { get; set; } + + public SalesSlipStatus Status { get; set; } = SalesSlipStatus.Draft; + + public decimal Subtotal { get; set; } + public decimal DiscountTotal { get; set; } + public decimal TaxTotal { get; set; } + public decimal GrandTotal { get; set; } + public decimal PaidAmount { get; set; } + public decimal BalanceAmount { 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/SalesSlipLine.cs b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs new file mode 100644 index 0000000..c0079e3 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs @@ -0,0 +1,35 @@ +namespace ERPCore.Domain.Entities; + +public class SalesSlipLine +{ + public int SalesSlipLineId { get; set; } + + public int SalesSlipId { get; set; } + public SalesSlip? SalesSlip { 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/SalesSlipStatus.cs b/Backend/ERPCore/Domain/Enums/SalesSlipStatus.cs new file mode 100644 index 0000000..400d11f --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/SalesSlipStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +public enum SalesSlipStatus +{ + Draft = 1, + Posted = 2, + Cancelled = 3 +} diff --git a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs new file mode 100644 index 0000000..29cf86a --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Sales; + +public sealed record SalesSlipLineDto( + int SalesSlipLineId, 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 SalesSlipTotalsDto( + decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, + decimal PaidAmount, decimal BalanceAmount); + +public sealed record SalesSlipDto( + int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId, + string CustomerSnapshotName, int WarehouseId, int CashierUserId, SalesSlipStatus Status, + DateTime CreatedAt, DateTime? UpdatedAt, SalesSlipTotalsDto Totals, IReadOnlyList Lines); + +public sealed record SalesSlipSummaryDto( + int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId, + string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status, + SalesSlipTotalsDto Totals, DateTime CreatedAt); + +public sealed class CreateSalesSlipLineRequest +{ + [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 CreateSalesSlipRequest +{ + [Required] public int CustomerId { get; set; } + [Required] public int WarehouseId { get; set; } + [Required] public int CashierUserId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class UpdateSalesSlipRequest +{ + [Required] public int CustomerId { get; set; } + [Required] public int WarehouseId { get; set; } + [Required] public int CashierUserId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs new file mode 100644 index 0000000..deeeed0 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.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 SalesSlipConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_slips"); + builder.HasKey(x => x.SalesSlipId); + + builder.Property(x => x.SlipNo).IsRequired().HasMaxLength(50); + builder.HasIndex(x => x.SlipNo).IsUnique(); + + builder.Property(x => x.SlipDate).IsRequired(); + + builder.HasOne(x => x.Customer) + .WithMany() + .HasForeignKey(x => x.CustomerId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200); + + builder.HasOne(x => x.Warehouse) + .WithMany() + .HasForeignKey(x => x.WarehouseId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(x => x.CashierUser) + .WithMany() + .HasForeignKey(x => x.CashierUserId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(x => x.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(SalesSlipStatus.Draft); + + foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.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.SlipDate); + + builder.HasMany(x => x.Lines) + .WithOne(x => x.SalesSlip) + .HasForeignKey(x => x.SalesSlipId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_slip_lines"); + builder.HasKey(x => x.SalesSlipLineId); + + 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 0719513..ec71934 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -86,6 +86,8 @@ public class ErpDbContext : DbContext // --- Sales (Phase 1) --- public DbSet SalesInvoices => Set(); public DbSet SalesInvoiceLines => Set(); + public DbSet SalesSlips => Set(); + public DbSet SalesSlipLines => 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 25e963f..0ace813 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -91,6 +91,7 @@ builder.Services.AddScoped(); // Sales (Phase 1) builder.Services.AddScoped(); +builder.Services.AddScoped(); // Stock transactions + reference data (docs/11 §5–6) builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs b/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs new file mode 100644 index 0000000..bd94715 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesSlipService.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 ISalesSlipService +{ + Task> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default); + Task?> GetAsync(int salesSlipId, CancellationToken ct = default); + Task> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default); + Task> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task PostAsync(int salesSlipId, CancellationToken ct = default); + Task CancelAsync(int salesSlipId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/SalesSlipService.cs b/Backend/ERPCore/Services/SalesSlipService.cs new file mode 100644 index 0000000..3dfb1d8 --- /dev/null +++ b/Backend/ERPCore/Services/SalesSlipService.cs @@ -0,0 +1,248 @@ +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 SalesSlipService : ISalesSlipService +{ + private readonly IRepository _slips; + private readonly IRepository _customers; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly IRepository _warehouses; + private readonly IRepository _users; + private readonly IFifoCostingService _fifo; + private readonly ICurrentUser _currentUser; + private readonly INumberSequenceService _numbers; + private readonly IUnitOfWork _uow; + + public SalesSlipService( + IRepository slips, IRepository customers, IRepository items, + IRepository uoms, IRepository warehouses, IRepository users, IFifoCostingService fifo, + ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow) + { + _slips = slips; + _customers = customers; + _items = items; + _uoms = uoms; + _warehouses = warehouses; + _users = users; + _fifo = fifo; + _currentUser = currentUser; + _numbers = numbers; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) + { + IQueryable q = _slips.Query().AsNoTracking().Include(x => x.Lines); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(x => EF.Functions.ILike(x.SlipNo, $"%{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.SalesSlipId).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 salesSlipId, CancellationToken ct = default) + { + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct); + return slip is null ? null : new ETagged(Map(slip), slip.RowVersion); + } + + public async Task> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default) + { + await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct); + + var slip = new SalesSlip + { + SlipNo = await _numbers.NextAsync(DocumentTypes.SalesSlip, ct), + SlipDate = DateTime.UtcNow, + CustomerId = request.CustomerId, + WarehouseId = request.WarehouseId, + CashierUserId = request.CashierUserId, + Status = SalesSlipStatus.Draft, + CreatedAt = DateTime.UtcNow + }; + slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); + slip.Lines = await BuildLinesAsync(request.Lines, ct); + Recalculate(slip); + + await _slips.AddAsync(slip, ct); + await _uow.SaveChangesAsync(ct); + return new ETagged(Map(slip), slip.RowVersion); + } + + public async Task> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); + if (slip.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales slip was modified by another request.", 412); + if (slip.Status != SalesSlipStatus.Draft) + throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be edited."); + + await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct); + slip.CustomerId = request.CustomerId; + slip.WarehouseId = request.WarehouseId; + slip.CashierUserId = request.CashierUserId; + slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); + slip.Lines.Clear(); + foreach (var line in await BuildLinesAsync(request.Lines, ct)) slip.Lines.Add(line); + Recalculate(slip); + slip.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return new ETagged(Map(slip), slip.RowVersion); + } + + public async Task PostAsync(int salesSlipId, CancellationToken ct = default) + { + var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); + if (slip.Status != SalesSlipStatus.Draft) + throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be posted."); + + var posted = await _uow.ExecuteInTransactionAsync(async token => + { + foreach (var line in slip.Lines) + { + if (line.Qty <= 0 && line.FreeQty <= 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(SalesSlip), slip.SalesSlipId, DateTime.UtcNow, token); + } + + slip.Status = SalesSlipStatus.Posted; + slip.UpdatedAt = DateTime.UtcNow; + return slip; + }, ct); + + return Map(posted); + } + + public async Task CancelAsync(int salesSlipId, CancellationToken ct = default) + { + var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); + if (slip.Status != SalesSlipStatus.Draft) + throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be cancelled."); + slip.Status = SalesSlipStatus.Cancelled; + slip.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return Map(slip); + } + + private async Task ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, 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."); + if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct)) + throw new NotFoundException($"Customer {customerId} was not found."); + if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct)) + throw new NotFoundException($"User {cashierUserId} 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 SalesSlipLine + { + 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(SalesSlip slip) + { + slip.Subtotal = slip.Lines.Sum(x => x.Qty * x.UnitPrice); + slip.DiscountTotal = slip.Lines.Sum(x => x.DiscountAmount); + slip.TaxTotal = slip.Lines.Sum(x => x.TaxAmount); + slip.GrandTotal = slip.Lines.Sum(x => x.LineTotal) + slip.TaxTotal; + slip.PaidAmount = 0m; + slip.BalanceAmount = slip.GrandTotal - slip.PaidAmount; + } + + private static SalesSlipSummaryDto MapSummary(SalesSlip x) => new( + x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.Status, + new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), x.CreatedAt); + + private static SalesSlipDto Map(SalesSlip x) => new( + x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.Status, x.CreatedAt, x.UpdatedAt, + new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), + x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, 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()); +}