add shared pricing resolution with fixed yem price,GRN weighted and FIFO valuation cost fallback

This commit is contained in:
2026-07-27 15:00:44 +05:30
committed by ImanThiyanga
parent 1f9e12e84b
commit 4f722432cd
7 changed files with 110 additions and 38 deletions
@@ -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; }
@@ -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; }
+1
View File
@@ -90,6 +90,7 @@ builder.Services.AddScoped<IStockService, StockService>();
builder.Services.AddScoped<IGrnService, GrnService>();
// Sales (Phase 1)
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
@@ -0,0 +1,16 @@
namespace ERPCore.Services.Interfaces;
public interface ISalesPricingService
{
Task<SalesPriceResolution> ResolveAsync(
int itemId,
int warehouseId,
decimal? requestedUnitPrice,
bool allowManualOverride,
CancellationToken ct = default);
}
public sealed record SalesPriceResolution(
decimal UnitPrice,
string PriceSource,
decimal BaseCost);
@@ -21,6 +21,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _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<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IFifoCostingService fifo,
IRepository<Uom> uoms, IRepository<Warehouse> 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);
@@ -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<Item> _items;
private readonly IRepository<GrnLine> _grnLines;
private readonly IFifoCostingService _fifo;
public SalesPricingService(IRepository<Item> items, IRepository<GrnLine> grnLines, IFifoCostingService fifo)
{
_items = items;
_grnLines = grnLines;
_fifo = fifo;
}
public async Task<SalesPriceResolution> 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<decimal?> 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<decimal> GetFifoFallbackPriceAsync(int itemId, int warehouseId, CancellationToken ct)
{
var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct);
return valuation.TotalQty > 0 ? valuation.TotalValue / valuation.TotalQty : 0m;
}
}
+6 -20
View File
@@ -22,6 +22,7 @@ public sealed class SalesSlipService : ISalesSlipService
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _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<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, IFifoCostingService fifo,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> 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);