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 try
{ {
var inner = new CreateGrnRequest(request.WarehouseId, request.Items, 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); var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
} }
+16 -7
View File
@@ -20,7 +20,9 @@ public record GrnItemRequest(
decimal? DiscountPercent = null, decimal? DiscountPercent = null,
decimal? VatPercent = null, decimal? VatPercent = null,
// Free-issue units: stocked but not costed. Serial/batch entries must cover accepted + free. // 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( public record CreateGrnRequest(
Guid WarehouseId, Guid WarehouseId,
@@ -29,7 +31,8 @@ public record CreateGrnRequest(
Guid? ReceivedByUserId = null, Guid? ReceivedByUserId = null,
// GRN-wide defaults for lines that don't set their own. // GRN-wide defaults for lines that don't set their own.
decimal? DiscountPercent = null, decimal? DiscountPercent = null,
decimal? VatPercent = null); decimal? VatPercent = null,
decimal? NbtPercent = null);
public record CreateFlatGrnRequest( public record CreateFlatGrnRequest(
Guid PurchaseOrderId, 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. // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null, Guid? ReceivedByUserId = null,
decimal? DiscountPercent = 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> /// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary>
public record CreateDirectGrnRequest( 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. // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null, Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null, decimal? DiscountPercent = null,
decimal? VatPercent = null); decimal? VatPercent = null,
decimal? NbtPercent = null);
public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity); public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
@@ -71,7 +76,9 @@ public record GrnItemResponse(
decimal VatAmount, decimal VatAmount,
// Gross - discount + VAT. // Gross - discount + VAT.
decimal LineTotal, decimal LineTotal,
int FreeQuantity); int FreeQuantity,
decimal NbtPercent,
decimal NbtAmount);
public record GrnResponse( public record GrnResponse(
Guid Id, Guid Id,
@@ -89,7 +96,9 @@ public record GrnResponse(
// Sum of line gross amounts. // Sum of line gross amounts.
decimal SubTotal, decimal SubTotal,
decimal DiscountAmount, 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 TaxableAmount,
decimal VatAmount, decimal VatAmount,
decimal TotalAmount); decimal TotalAmount,
decimal NbtPercent,
decimal NbtAmount);
+10 -5
View File
@@ -1,21 +1,26 @@
namespace Distribution.Features.Grn; namespace Distribution.Features.Grn;
public record LineAmounts(decimal Gross, decimal Discount, decimal Nbt, decimal Vat, decimal Total);
/// <summary> /// <summary>
/// GRN costing. Per line: gross = accepted qty x unit cost; discount = gross x disc%; /// 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. /// Each amount is rounded to 2 dp per line so the GRN totals equal the sum of what the lines show.
/// </summary> /// </summary>
public static class GrnPricing public static class GrnPricing
{ {
public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero); public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
public static (decimal Gross, decimal Discount, decimal Vat, decimal Total) Line( public static LineAmounts Line(
int acceptedQty, decimal unitCost, decimal discountPercent, decimal vatPercent) int acceptedQty, decimal unitCost, decimal discountPercent, decimal nbtPercent, decimal vatPercent)
{ {
var gross = Round(acceptedQty * unitCost); var gross = Round(acceptedQty * unitCost);
var discount = Round(gross * discountPercent / 100m); var discount = Round(gross * discountPercent / 100m);
var vat = Round((gross - discount) * vatPercent / 100m); var net = gross - discount;
return (gross, discount, vat, gross - discount + vat); 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) public static void ValidatePercent(decimal value, string label)
+55 -21
View File
@@ -1,5 +1,6 @@
using Distribution.Features.Po; using Distribution.Features.Po;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
@@ -11,18 +12,20 @@ public class GrnService(
IStockRepository stockRepo, IStockRepository stockRepo,
IUnitOfWork uow, IUnitOfWork uow,
Distribution.Features.Item.IItemRepository itemRepo, 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) private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
{ {
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line. // Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
var item = i.Item ?? i.PurchaseOrderItem!.Item; 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, return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name,
i.AcceptedQuantity, i.RejectedQuantity, i.AcceptedQuantity, i.RejectedQuantity,
i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(), 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); 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> /// <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 subTotal = items.Sum(i => i.GrossAmount);
var discount = items.Sum(i => i.DiscountAmount); var discount = items.Sum(i => i.DiscountAmount);
var vat = items.Sum(i => i.VatAmount); 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, 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.FK_SupplierId, gr.Supplier?.Name,
gr.ReceivedAt, gr.CreatedAt, items, gr.ReceivedAt, gr.CreatedAt, items,
gr.DiscountPercent, gr.VatPercent, 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> /// <summary>The percentages a receipt is saved with, after the configured tax rules were applied.</summary>
private static (decimal Discount, decimal Vat) ResolveHeader(decimal? discountPercent, decimal? vatPercent) 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 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(discount, "GRN discount %");
GrnPricing.ValidatePercent(vat, "GRN VAT %"); 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, 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.UnitCost = line.UnitCost ?? fallbackUnitCost;
entity.DiscountPercent = line.DiscountPercent ?? headerDiscount; entity.DiscountPercent = line.DiscountPercent ?? header.Discount;
entity.VatPercent = line.VatPercent ?? headerVat; 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.ValidateCost(entity.UnitCost, $"Unit cost for item {sku}");
GrnPricing.ValidatePercent(entity.DiscountPercent, $"Discount % 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) 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) if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item."); 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 var goodsReceipt = new Models.GoodsReceipt
{ {
@@ -159,8 +190,9 @@ public class GrnService(
FK_ReceivedByUserId = receivedByUserId, FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId, FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now, ReceivedAt = DateTime.Now,
DiscountPercent = headerDiscount, DiscountPercent = header.Discount,
VatPercent = headerVat VatPercent = header.Vat,
NbtPercent = header.Nbt
}; };
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>(); var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
@@ -203,7 +235,7 @@ public class GrnService(
FreeQuantity = line.FreeQuantity, FreeQuantity = line.FreeQuantity,
RejectedQuantity = line.RejectedQuantity 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); goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, poItem.Item)); 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 }) if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true })
throw new InvalidOperationException("Supplier not found."); 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 var goodsReceipt = new Models.GoodsReceipt
{ {
@@ -236,8 +269,9 @@ public class GrnService(
FK_ReceivedByUserId = receivedByUserId, FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId, FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now, ReceivedAt = DateTime.Now,
DiscountPercent = headerDiscount, DiscountPercent = header.Discount,
VatPercent = headerVat VatPercent = header.Vat,
NbtPercent = header.Nbt
}; };
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>(); var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
@@ -278,7 +312,7 @@ public class GrnService(
AcceptedQuantity = line.AcceptedQuantity, AcceptedQuantity = line.AcceptedQuantity,
RejectedQuantity = line.RejectedQuantity RejectedQuantity = line.RejectedQuantity
}; };
ApplyPricing(entity, line, 0m, headerDiscount, headerVat, item.Sku); ApplyPricing(entity, line, 0m, header, tax, item.Sku);
goodsReceipt.Items.Add(entity); goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, item)); grLines.Add((entity, line, item));
} }
+3
View File
@@ -45,5 +45,8 @@ namespace Distribution.Models
/// </summary> /// </summary>
public enum InvoiceCreationMode { ModeA, ModeB } public enum InvoiceCreationMode { ModeA, ModeB }
/// <summary>How a tax applies on a document: not at all, may be left at 0, or must be filled in.</summary>
public enum TaxMode { Disabled, Optional, Mandatory }
public enum SerialStatus { InStock, Sold, Returned, Damaged } public enum SerialStatus { InStock, Sold, Returned, Damaged }
} }
+4
View File
@@ -36,6 +36,10 @@ namespace Distribution.Models
[Precision(5, 2)] [Precision(5, 2)]
public decimal VatPercent { get; set; } public decimal VatPercent { get; set; }
/// <summary>GRN-wide default NBT %, applied to any line that doesn't set its own.</summary>
[Precision(5, 2)]
public decimal NbtPercent { get; set; }
public ICollection<GoodsReceiptItem> Items { get; set; } = []; public ICollection<GoodsReceiptItem> Items { get; set; } = [];
} }
} }
+3
View File
@@ -41,6 +41,9 @@ namespace Distribution.Models
[Precision(5, 2)] [Precision(5, 2)]
public decimal VatPercent { get; set; } public decimal VatPercent { get; set; }
[Precision(5, 2)]
public decimal NbtPercent { get; set; }
/// <summary>Populated when the received item tracks batches and/or expiry dates.</summary> /// <summary>Populated when the received item tracks batches and/or expiry dates.</summary>
public ICollection<GoodsReceiptItemBatch> Batches { get; set; } = []; public ICollection<GoodsReceiptItemBatch> Batches { get; set; } = [];