From 55aa35308aff907176d8926a944439d8e32bd2ef Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Thu, 8 Oct 2026 12:41:44 +0530 Subject: [PATCH] Add NBT support to GRN processing and pricing calculations --- Distribution/Features/Grn/GrnController.cs | 2 +- Distribution/Features/Grn/GrnDTOs.cs | 23 +++++-- Distribution/Features/Grn/GrnPricing.cs | 15 +++-- Distribution/Features/Grn/GrnService.cs | 76 ++++++++++++++++------ Distribution/Models/Enums.cs | 3 + Distribution/Models/GoodsReceipt.cs | 4 ++ Distribution/Models/GoodsReceiptItem.cs | 3 + 7 files changed, 92 insertions(+), 34 deletions(-) diff --git a/Distribution/Features/Grn/GrnController.cs b/Distribution/Features/Grn/GrnController.cs index a68c5a6..cf49af1 100644 --- a/Distribution/Features/Grn/GrnController.cs +++ b/Distribution/Features/Grn/GrnController.cs @@ -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); } diff --git a/Distribution/Features/Grn/GrnDTOs.cs b/Distribution/Features/Grn/GrnDTOs.cs index 6c5f7b4..1f7d910 100644 --- a/Distribution/Features/Grn/GrnDTOs.cs +++ b/Distribution/Features/Grn/GrnDTOs.cs @@ -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); /// A goods receipt with no purchase order. SupplierId is optional. 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); diff --git a/Distribution/Features/Grn/GrnPricing.cs b/Distribution/Features/Grn/GrnPricing.cs index da64363..9b98b94 100644 --- a/Distribution/Features/Grn/GrnPricing.cs +++ b/Distribution/Features/Grn/GrnPricing.cs @@ -1,21 +1,26 @@ namespace Distribution.Features.Grn; +public record LineAmounts(decimal Gross, decimal Discount, decimal Nbt, decimal Vat, decimal Total); + /// /// 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. /// 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) diff --git a/Distribution/Features/Grn/GrnService.cs b/Distribution/Features/Grn/GrnService.cs index 40ebb0e..2e871cd 100644 --- a/Distribution/Features/Grn/GrnService.cs +++ b/Distribution/Features/Grn/GrnService.cs @@ -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); } /// Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this. @@ -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); } - /// Validates and resolves the GRN-wide percentages that lines fall back to. - private static (decimal Discount, decimal Vat) ResolveHeader(decimal? discountPercent, decimal? vatPercent) + /// The percentages a receipt is saved with, after the configured tax rules were applied. + private record GrnHeader(decimal Discount, decimal Vat, decimal Nbt, decimal? RequestedVat, decimal? RequestedNbt); + + /// + /// Validates and resolves the GRN-wide percentages that lines fall back to. A disabled tax is + /// forced to 0 whatever the client sent. + /// + 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); } - /// Copies a line's resolved cost, discount % and VAT % onto the entity after validating them. + /// + /// 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. + /// + 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; + } + + /// Copies a line's resolved cost, discount %, NBT % and VAT % onto the entity after validating them. 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> 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)); } diff --git a/Distribution/Models/Enums.cs b/Distribution/Models/Enums.cs index 26a62c1..f74a70b 100644 --- a/Distribution/Models/Enums.cs +++ b/Distribution/Models/Enums.cs @@ -45,5 +45,8 @@ namespace Distribution.Models /// public enum InvoiceCreationMode { ModeA, ModeB } + /// How a tax applies on a document: not at all, may be left at 0, or must be filled in. + public enum TaxMode { Disabled, Optional, Mandatory } + public enum SerialStatus { InStock, Sold, Returned, Damaged } } diff --git a/Distribution/Models/GoodsReceipt.cs b/Distribution/Models/GoodsReceipt.cs index 025103b..9587c03 100644 --- a/Distribution/Models/GoodsReceipt.cs +++ b/Distribution/Models/GoodsReceipt.cs @@ -36,6 +36,10 @@ namespace Distribution.Models [Precision(5, 2)] public decimal VatPercent { get; set; } + /// GRN-wide default NBT %, applied to any line that doesn't set its own. + [Precision(5, 2)] + public decimal NbtPercent { get; set; } + public ICollection Items { get; set; } = []; } } diff --git a/Distribution/Models/GoodsReceiptItem.cs b/Distribution/Models/GoodsReceiptItem.cs index 734e8f7..55a33bc 100644 --- a/Distribution/Models/GoodsReceiptItem.cs +++ b/Distribution/Models/GoodsReceiptItem.cs @@ -41,6 +41,9 @@ namespace Distribution.Models [Precision(5, 2)] public decimal VatPercent { get; set; } + [Precision(5, 2)] + public decimal NbtPercent { get; set; } + /// Populated when the received item tracks batches and/or expiry dates. public ICollection Batches { get; set; } = [];