Add NBT support to GRN processing and pricing calculations
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user