diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs index adad697..b90fd30 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs @@ -32,6 +32,7 @@ public sealed class CreateSalesInvoiceLineRequest [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; } + public bool AllowManualPriceOverride { 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; } diff --git a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs index 29cf86a..eab27c9 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs @@ -31,6 +31,7 @@ public sealed class CreateSalesSlipLineRequest [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; } + public bool AllowManualPriceOverride { 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; } diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 0ace813..f18e277 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -90,6 +90,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); // Sales (Phase 1) +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/Interfaces/ISalesPricingService.cs b/Backend/ERPCore/Services/Interfaces/ISalesPricingService.cs new file mode 100644 index 0000000..87617e4 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesPricingService.cs @@ -0,0 +1,16 @@ +namespace ERPCore.Services.Interfaces; + +public interface ISalesPricingService +{ + Task ResolveAsync( + int itemId, + int warehouseId, + decimal? requestedUnitPrice, + bool allowManualOverride, + CancellationToken ct = default); +} + +public sealed record SalesPriceResolution( + decimal UnitPrice, + string PriceSource, + decimal BaseCost); diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs index 3bd7067..d49252e 100644 --- a/Backend/ERPCore/Services/SalesInvoiceService.cs +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -21,6 +21,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService private readonly IRepository _items; private readonly IRepository _uoms; private readonly IRepository _warehouses; + private readonly ISalesPricingService _pricing; private readonly IFifoCostingService _fifo; private readonly ICurrentUser _currentUser; private readonly INumberSequenceService _numbers; @@ -28,7 +29,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService public SalesInvoiceService( IRepository invoices, IRepository customers, IRepository items, - IRepository uoms, IRepository warehouses, IFifoCostingService fifo, + IRepository uoms, IRepository warehouses, ISalesPricingService pricing, IFifoCostingService fifo, ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow) { _invoices = invoices; @@ -36,6 +37,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService _items = items; _uoms = uoms; _warehouses = warehouses; + _pricing = pricing; _fifo = fifo; _currentUser = currentUser; _numbers = numbers; @@ -175,23 +177,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService 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 resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); + var unitPrice = resolved.UnitPrice; + var priceSource = resolved.PriceSource; var gross = r.Qty * unitPrice; var discountAfterPct = gross * (r.DiscountPct / 100m); diff --git a/Backend/ERPCore/Services/SalesPricingService.cs b/Backend/ERPCore/Services/SalesPricingService.cs new file mode 100644 index 0000000..b8d09b4 --- /dev/null +++ b/Backend/ERPCore/Services/SalesPricingService.cs @@ -0,0 +1,79 @@ +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.Services.Stock; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesPricingService : ISalesPricingService +{ + private readonly IRepository _items; + private readonly IRepository _grnLines; + private readonly IFifoCostingService _fifo; + + public SalesPricingService(IRepository items, IRepository grnLines, IFifoCostingService fifo) + { + _items = items; + _grnLines = grnLines; + _fifo = fifo; + } + + public async Task ResolveAsync( + int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default) + { + var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(x => x.ItemId == itemId, ct) + ?? throw new InvalidOperationException($"Item {itemId} was not found."); + + if (requestedUnitPrice is not null) + { + if (!allowManualOverride) + { + if (item.SalePrice.HasValue) + return new SalesPriceResolution(item.SalePrice.Value, "SALE_PRICE", item.SalePrice.Value); + + var grnPrice = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct); + if (grnPrice is not null) + return new SalesPriceResolution(grnPrice.Value, "GRN_WEIGHTED_AVG", grnPrice.Value); + + return new SalesPriceResolution(await GetFifoFallbackPriceAsync(itemId, warehouseId, ct), "FIFO_AVG", 0m); + } + + return new SalesPriceResolution(requestedUnitPrice.Value, "MANUAL", requestedUnitPrice.Value); + } + + if (item.SalePrice.HasValue) + return new SalesPriceResolution(item.SalePrice.Value, "SALE_PRICE", item.SalePrice.Value); + + var weighted = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct); + if (weighted is not null) + return new SalesPriceResolution(weighted.Value, "GRN_WEIGHTED_AVG", weighted.Value); + + return new SalesPriceResolution(await GetFifoFallbackPriceAsync(itemId, warehouseId, ct), "FIFO_AVG", 0m); + } + + private async Task GetWeightedGrnPriceAsync(int itemId, int warehouseId, CancellationToken ct) + { + var rows = await _grnLines.Query().AsNoTracking() + .Where(l => l.ItemId == itemId + && l.Grn != null + && l.Grn.WarehouseId == warehouseId + && l.Grn.Status == GrnStatus.Confirmed) + .Select(l => new { l.Qty, l.ReceivedValue }) + .ToListAsync(ct); + + var totalQty = rows.Sum(x => x.Qty); + if (totalQty <= 0) return null; + + var totalValue = rows.Sum(x => x.ReceivedValue); + return totalValue / totalQty; + } + + private async Task GetFifoFallbackPriceAsync(int itemId, int warehouseId, CancellationToken ct) + { + var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct); + return valuation.TotalQty > 0 ? valuation.TotalValue / valuation.TotalQty : 0m; + } +} diff --git a/Backend/ERPCore/Services/SalesSlipService.cs b/Backend/ERPCore/Services/SalesSlipService.cs index 3dfb1d8..96c6a6b 100644 --- a/Backend/ERPCore/Services/SalesSlipService.cs +++ b/Backend/ERPCore/Services/SalesSlipService.cs @@ -22,6 +22,7 @@ public sealed class SalesSlipService : ISalesSlipService private readonly IRepository _uoms; private readonly IRepository _warehouses; private readonly IRepository _users; + private readonly ISalesPricingService _pricing; private readonly IFifoCostingService _fifo; private readonly ICurrentUser _currentUser; private readonly INumberSequenceService _numbers; @@ -29,7 +30,7 @@ public sealed class SalesSlipService : ISalesSlipService public SalesSlipService( IRepository slips, IRepository customers, IRepository items, - IRepository uoms, IRepository warehouses, IRepository users, IFifoCostingService fifo, + IRepository uoms, IRepository warehouses, IRepository users, ISalesPricingService pricing, IFifoCostingService fifo, ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow) { _slips = slips; @@ -38,6 +39,7 @@ public sealed class SalesSlipService : ISalesSlipService _uoms = uoms; _warehouses = warehouses; _users = users; + _pricing = pricing; _fifo = fifo; _currentUser = currentUser; _numbers = numbers; @@ -157,8 +159,6 @@ public sealed class SalesSlipService : ISalesSlipService 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) @@ -178,23 +178,9 @@ public sealed class SalesSlipService : ISalesSlipService 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 resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); + var unitPrice = resolved.UnitPrice; + var priceSource = resolved.PriceSource; var gross = r.Qty * unitPrice; var discountAfterPct = gross * (r.DiscountPct / 100m);