Add NBT support to GRN processing and pricing calculations

This commit is contained in:
2026-10-08 12:41:44 +05:30
parent 40e890d4ff
commit 55aa35308a
7 changed files with 92 additions and 34 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
try
{
var inner = new CreateGrnRequest(request.WarehouseId, request.Items,
DiscountPercent: request.DiscountPercent, VatPercent: request.VatPercent);
DiscountPercent: request.DiscountPercent, VatPercent: request.VatPercent, NbtPercent: request.NbtPercent);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
+16 -7
View File
@@ -20,7 +20,9 @@ public record GrnItemRequest(
decimal? DiscountPercent = null,
decimal? VatPercent = null,
// Free-issue units: stocked but not costed. Serial/batch entries must cover accepted + free.
int FreeQuantity = 0);
int FreeQuantity = 0,
// Ignored when NBT is disabled in configuration; null falls back to the GRN-wide percentage.
decimal? NbtPercent = null);
public record CreateGrnRequest(
Guid WarehouseId,
@@ -29,7 +31,8 @@ public record CreateGrnRequest(
Guid? ReceivedByUserId = null,
// GRN-wide defaults for lines that don't set their own.
decimal? DiscountPercent = null,
decimal? VatPercent = null);
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record CreateFlatGrnRequest(
Guid PurchaseOrderId,
@@ -38,7 +41,8 @@ public record CreateFlatGrnRequest(
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null,
decimal? VatPercent = null);
decimal? VatPercent = null,
decimal? NbtPercent = null);
/// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary>
public record CreateDirectGrnRequest(
@@ -48,7 +52,8 @@ public record CreateDirectGrnRequest(
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null,
decimal? VatPercent = null);
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
@@ -71,7 +76,9 @@ public record GrnItemResponse(
decimal VatAmount,
// Gross - discount + VAT.
decimal LineTotal,
int FreeQuantity);
int FreeQuantity,
decimal NbtPercent,
decimal NbtAmount);
public record GrnResponse(
Guid Id,
@@ -89,7 +96,9 @@ public record GrnResponse(
// Sum of line gross amounts.
decimal SubTotal,
decimal DiscountAmount,
// SubTotal - DiscountAmount (the base VAT is charged on).
// SubTotal - DiscountAmount (the base NBT is charged on; VAT is charged on this plus NBT).
decimal TaxableAmount,
decimal VatAmount,
decimal TotalAmount);
decimal TotalAmount,
decimal NbtPercent,
decimal NbtAmount);
+10 -5
View File
@@ -1,21 +1,26 @@
namespace Distribution.Features.Grn;
public record LineAmounts(decimal Gross, decimal Discount, decimal Nbt, decimal Vat, decimal Total);
/// <summary>
/// GRN costing. Per line: gross = accepted qty x unit cost; discount = gross x disc%;
/// VAT = (gross - discount) x VAT%; total = gross - discount + VAT. Rejected units are never costed.
/// NBT = (gross - discount) x NBT%; VAT = (gross - discount + NBT) x VAT%;
/// total = gross - discount + NBT + VAT. Rejected units are never costed.
/// Each amount is rounded to 2 dp per line so the GRN totals equal the sum of what the lines show.
/// </summary>
public static class GrnPricing
{
public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
public static (decimal Gross, decimal Discount, decimal Vat, decimal Total) Line(
int acceptedQty, decimal unitCost, decimal discountPercent, decimal vatPercent)
public static LineAmounts Line(
int acceptedQty, decimal unitCost, decimal discountPercent, decimal nbtPercent, decimal vatPercent)
{
var gross = Round(acceptedQty * unitCost);
var discount = Round(gross * discountPercent / 100m);
var vat = Round((gross - discount) * vatPercent / 100m);
return (gross, discount, vat, gross - discount + vat);
var net = gross - discount;
var nbt = Round(net * nbtPercent / 100m);
var vat = Round((net + nbt) * vatPercent / 100m);
return new LineAmounts(gross, discount, nbt, vat, net + nbt + vat);
}
public static void ValidatePercent(decimal value, string label)
+55 -21
View File
@@ -1,5 +1,6 @@
using Distribution.Features.Po;
using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -11,18 +12,20 @@ public class GrnService(
IStockRepository stockRepo,
IUnitOfWork uow,
Distribution.Features.Item.IItemRepository itemRepo,
Distribution.Features.Supplier.ISupplierRepository supplierRepo)
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
Distribution.Features.SysConfig.SysConfigService sysConfig)
{
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
{
// 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);
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, 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.UnitCost, i.DiscountPercent, i.VatPercent, gross, discount, vat, total, i.FreeQuantity);
i.UnitCost, i.DiscountPercent, i.VatPercent, a.Gross, a.Discount, a.Vat, a.Total, i.FreeQuantity,
i.NbtPercent, a.Nbt);
}
/// <summary>Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this.</summary>
@@ -34,33 +37,60 @@ public class GrnService(
var subTotal = items.Sum(i => i.GrossAmount);
var discount = items.Sum(i => i.DiscountAmount);
var vat = items.Sum(i => i.VatAmount);
var nbt = items.Sum(i => i.NbtAmount);
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, items,
gr.DiscountPercent, gr.VatPercent,
subTotal, discount, subTotal - discount, vat, subTotal - discount + vat);
subTotal, discount, subTotal - discount, vat, subTotal - discount + nbt + vat,
gr.NbtPercent, nbt);
}
/// <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)
/// <summary>The percentages a receipt is saved with, after the configured tax rules were applied.</summary>
private record GrnHeader(decimal Discount, decimal Vat, decimal Nbt, decimal? RequestedVat, decimal? RequestedNbt);
/// <summary>
/// Validates and resolves the GRN-wide percentages that lines fall back to. A disabled tax is
/// forced to 0 whatever the client sent.
/// </summary>
private static GrnHeader ResolveHeader(GrnTaxSettings tax, decimal? discountPercent, decimal? vatPercent, decimal? nbtPercent)
{
var discount = discountPercent ?? 0m;
var vat = vatPercent ?? 0m;
var vat = tax.VatMode == TaxMode.Disabled ? 0m : vatPercent ?? 0m;
var nbt = tax.NbtMode == TaxMode.Disabled ? 0m : nbtPercent ?? 0m;
GrnPricing.ValidatePercent(discount, "GRN discount %");
GrnPricing.ValidatePercent(vat, "GRN VAT %");
return (discount, vat);
GrnPricing.ValidatePercent(nbt, "GRN NBT %");
return new GrnHeader(discount, vat, nbt, vatPercent, nbtPercent);
}
/// <summary>Copies a line's resolved cost, discount % and VAT % onto the entity after validating them.</summary>
/// <summary>
/// A line's rate for one tax: 0 when the tax is disabled; otherwise the line's own value, else the
/// GRN-wide one. A Mandatory tax must be supplied somewhere; an Optional one defaults to 0.
/// </summary>
private static decimal ResolveTax(TaxMode mode, decimal? headerValue, decimal? lineValue, string label)
{
if (mode == TaxMode.Disabled) return 0m;
var value = lineValue ?? headerValue;
if (value is null)
{
if (mode == TaxMode.Mandatory) throw new InvalidOperationException($"{label} is mandatory.");
return 0m;
}
GrnPricing.ValidatePercent(value.Value, label);
return value.Value;
}
/// <summary>Copies a line's resolved cost, discount %, NBT % 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)
GrnHeader header, GrnTaxSettings tax, string sku)
{
entity.UnitCost = line.UnitCost ?? fallbackUnitCost;
entity.DiscountPercent = line.DiscountPercent ?? headerDiscount;
entity.VatPercent = line.VatPercent ?? headerVat;
entity.DiscountPercent = line.DiscountPercent ?? header.Discount;
entity.VatPercent = ResolveTax(tax.VatMode, header.RequestedVat, line.VatPercent, $"VAT % for item {sku}");
entity.NbtPercent = ResolveTax(tax.NbtMode, header.RequestedNbt, line.NbtPercent, $"NBT % for item {sku}");
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)
@@ -151,7 +181,8 @@ 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 tax = await sysConfig.GetGrnTaxSettingsAsync();
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
var goodsReceipt = new Models.GoodsReceipt
{
@@ -159,8 +190,9 @@ public class GrnService(
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now,
DiscountPercent = headerDiscount,
VatPercent = headerVat
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
};
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
@@ -203,7 +235,7 @@ public class GrnService(
FreeQuantity = line.FreeQuantity,
RejectedQuantity = line.RejectedQuantity
};
ApplyPricing(entity, line, poItem.UnitCost, headerDiscount, headerVat, poItem.Item.Sku);
ApplyPricing(entity, line, poItem.UnitCost, header, tax, poItem.Item.Sku);
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, poItem.Item));
}
@@ -227,7 +259,8 @@ 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 tax = await sysConfig.GetGrnTaxSettingsAsync();
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
var goodsReceipt = new Models.GoodsReceipt
{
@@ -236,8 +269,9 @@ public class GrnService(
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now,
DiscountPercent = headerDiscount,
VatPercent = headerVat
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
};
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
@@ -278,7 +312,7 @@ public class GrnService(
AcceptedQuantity = line.AcceptedQuantity,
RejectedQuantity = line.RejectedQuantity
};
ApplyPricing(entity, line, 0m, headerDiscount, headerVat, item.Sku);
ApplyPricing(entity, line, 0m, header, tax, item.Sku);
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, item));
}