Fix GRN pricing

This commit is contained in:
2026-10-08 10:37:19 +05:30
parent ff6f3d3218
commit 9908a552d1
11 changed files with 9171 additions and 30 deletions
+66 -18
View File
@@ -17,16 +17,51 @@ public class GrnService(
{
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
var item = i.Item ?? i.PurchaseOrderItem!.Item;
var (gross, discount, vat, total) = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.VatPercent);
return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name,
i.AcceptedQuantity, i.RejectedQuantity,
i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(),
i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList());
i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList(),
i.UnitCost, i.DiscountPercent, i.VatPercent, gross, discount, vat, total, i.FreeQuantity);
}
private static GrnResponse Map(Models.GoodsReceipt gr) =>
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
/// <summary>Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this.</summary>
private static int StockQty(GrnItemRequest line) => line.AcceptedQuantity + line.FreeQuantity;
private static GrnResponse Map(Models.GoodsReceipt gr)
{
var items = gr.Items.Where(i => i.IsActive).Select(MapItem).ToList();
var subTotal = items.Sum(i => i.GrossAmount);
var discount = items.Sum(i => i.DiscountAmount);
var vat = items.Sum(i => i.VatAmount);
return new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.FK_SupplierId, gr.Supplier?.Name,
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
gr.ReceivedAt, gr.CreatedAt, items,
gr.DiscountPercent, gr.VatPercent,
subTotal, discount, subTotal - discount, vat, subTotal - discount + vat);
}
/// <summary>Validates and resolves the GRN-wide percentages that lines fall back to.</summary>
private static (decimal Discount, decimal Vat) ResolveHeader(decimal? discountPercent, decimal? vatPercent)
{
var discount = discountPercent ?? 0m;
var vat = vatPercent ?? 0m;
GrnPricing.ValidatePercent(discount, "GRN discount %");
GrnPricing.ValidatePercent(vat, "GRN VAT %");
return (discount, vat);
}
/// <summary>Copies a line's resolved cost, discount % and VAT % onto the entity after validating them.</summary>
private static void ApplyPricing(Models.GoodsReceiptItem entity, GrnItemRequest line, decimal fallbackUnitCost,
decimal headerDiscount, decimal headerVat, string sku)
{
entity.UnitCost = line.UnitCost ?? fallbackUnitCost;
entity.DiscountPercent = line.DiscountPercent ?? headerDiscount;
entity.VatPercent = line.VatPercent ?? headerVat;
GrnPricing.ValidateCost(entity.UnitCost, $"Unit cost for item {sku}");
GrnPricing.ValidatePercent(entity.DiscountPercent, $"Discount % for item {sku}");
GrnPricing.ValidatePercent(entity.VatPercent, $"VAT % for item {sku}");
}
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
{
@@ -55,9 +90,9 @@ public class GrnService(
{
if (item.TracksSerialNumbers)
{
if (line.AcceptedQuantity > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != line.AcceptedQuantity))
if (StockQty(line) > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != StockQty(line)))
throw new InvalidOperationException(
$"Item {item.Sku} tracks serial numbers — expected {line.AcceptedQuantity} serial number(s).");
$"Item {item.Sku} tracks serial numbers — expected {StockQty(line)} serial number(s).");
if (line.SerialNumbers is { Count: > 0 } && line.SerialNumbers.Distinct().Count() != line.SerialNumbers.Count)
throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}.");
@@ -68,11 +103,11 @@ public class GrnService(
}
else if (item.TracksBatches || item.TracksExpiryDate)
{
if (line.AcceptedQuantity > 0)
if (StockQty(line) > 0)
{
if (line.Batches is null || line.Batches.Count == 0)
throw new InvalidOperationException($"Item {item.Sku} requires batch/expiry information.");
if (line.Batches.Sum(b => b.Quantity) != line.AcceptedQuantity)
if (line.Batches.Sum(b => b.Quantity) != StockQty(line))
throw new InvalidOperationException($"Batch quantities for item {item.Sku} must sum to the accepted quantity.");
}
@@ -96,7 +131,8 @@ public class GrnService(
/// <summary>
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), creates
/// StockMovement for AcceptedQuantity + FreeQuantity (RejectedQuantity never touches stock; free
/// units don't count against the PO's ordered quantity), creates
/// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to
/// PartiallyReceived/Received. All in one unit of work.
/// </summary>
@@ -115,12 +151,16 @@ public class GrnService(
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item.");
var (headerDiscount, headerVat) = ResolveHeader(request.DiscountPercent, request.VatPercent);
var goodsReceipt = new Models.GoodsReceipt
{
FK_PurchaseOrderId = po.Id,
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now
ReceivedAt = DateTime.Now,
DiscountPercent = headerDiscount,
VatPercent = headerVat
};
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
@@ -136,7 +176,7 @@ public class GrnService(
if (poItem.IsCancelled)
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0)
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
@@ -160,8 +200,10 @@ public class GrnService(
{
FK_PurchaseOrderItemId = poItem.Id,
AcceptedQuantity = line.AcceptedQuantity,
FreeQuantity = line.FreeQuantity,
RejectedQuantity = line.RejectedQuantity
};
ApplyPricing(entity, line, poItem.UnitCost, headerDiscount, headerVat, poItem.Item.Sku);
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, poItem.Item));
}
@@ -185,13 +227,17 @@ public class GrnService(
if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true })
throw new InvalidOperationException("Supplier not found.");
var (headerDiscount, headerVat) = ResolveHeader(request.DiscountPercent, request.VatPercent);
var goodsReceipt = new Models.GoodsReceipt
{
FK_PurchaseOrderId = null,
FK_SupplierId = request.SupplierId,
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now
ReceivedAt = DateTime.Now,
DiscountPercent = headerDiscount,
VatPercent = headerVat
};
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
@@ -206,10 +252,10 @@ public class GrnService(
if (item is null || !item.IsActive)
throw new InvalidOperationException($"Item {line.ItemId} not found.");
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0)
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity == 0)
if (line.AcceptedQuantity + line.RejectedQuantity + line.FreeQuantity == 0)
throw new InvalidOperationException($"Line for item {item.Sku} has no quantity.");
ValidateLineShape(item, line);
@@ -228,9 +274,11 @@ public class GrnService(
var entity = new Models.GoodsReceiptItem
{
FK_ItemId = item.Id,
FreeQuantity = line.FreeQuantity,
AcceptedQuantity = line.AcceptedQuantity,
RejectedQuantity = line.RejectedQuantity
};
ApplyPricing(entity, line, 0m, headerDiscount, headerVat, item.Sku);
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, item));
}
@@ -255,7 +303,7 @@ public class GrnService(
{
await repo.AddAsync(goodsReceipt);
foreach (var (entity, line, item) in grLines.Where(x => x.Line.AcceptedQuantity > 0))
foreach (var (entity, line, item) in grLines.Where(x => StockQty(x.Line) > 0))
{
if (item.TracksSerialNumbers)
{
@@ -369,12 +417,12 @@ public class GrnService(
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity
Quantity = StockQty(line)
});
}
else
{
balance.Quantity += line.AcceptedQuantity;
balance.Quantity += StockQty(line);
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
@@ -386,7 +434,7 @@ public class GrnService(
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity,
Quantity = StockQty(line),
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId