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
+2 -1
View File
@@ -32,7 +32,8 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
try
{
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
var inner = new CreateGrnRequest(request.WarehouseId, request.Items,
DiscountPercent: request.DiscountPercent, VatPercent: request.VatPercent);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
+39 -6
View File
@@ -13,20 +13,32 @@ public record GrnItemRequest(
// Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries.
List<string>? SerialNumbers = null,
// Set for a direct receipt line (no purchase order).
Guid? ItemId = null);
Guid? ItemId = null,
// Cost per base-UOM unit. Null on a PO line falls back to the PO line's unit cost; null on a direct line is 0.
decimal? UnitCost = null,
// Null falls back to the GRN-wide percentage.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
// Free-issue units: stocked but not costed. Serial/batch entries must cover accepted + free.
int FreeQuantity = 0);
public record CreateGrnRequest(
Guid WarehouseId,
List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null);
Guid? ReceivedByUserId = null,
// GRN-wide defaults for lines that don't set their own.
decimal? DiscountPercent = null,
decimal? VatPercent = null);
public record CreateFlatGrnRequest(
Guid PurchaseOrderId,
Guid WarehouseId,
List<GrnItemRequest> Items,
// 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? VatPercent = null);
/// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary>
public record CreateDirectGrnRequest(
@@ -34,7 +46,9 @@ public record CreateDirectGrnRequest(
List<GrnItemRequest> Items,
Guid? SupplierId = null,
// 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? VatPercent = null);
public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
@@ -47,7 +61,17 @@ public record GrnItemResponse(
int AcceptedQuantity,
int RejectedQuantity,
List<GrnBatchLineResponse> Batches,
List<string> SerialNumbers);
List<string> SerialNumbers,
decimal UnitCost,
decimal DiscountPercent,
decimal VatPercent,
// Accepted qty x unit cost, before discount.
decimal GrossAmount,
decimal DiscountAmount,
decimal VatAmount,
// Gross - discount + VAT.
decimal LineTotal,
int FreeQuantity);
public record GrnResponse(
Guid Id,
@@ -59,4 +83,13 @@ public record GrnResponse(
string? SupplierName,
DateTime ReceivedAt,
DateTime CreatedAt,
List<GrnItemResponse> Items);
List<GrnItemResponse> Items,
decimal DiscountPercent,
decimal VatPercent,
// Sum of line gross amounts.
decimal SubTotal,
decimal DiscountAmount,
// SubTotal - DiscountAmount (the base VAT is charged on).
decimal TaxableAmount,
decimal VatAmount,
decimal TotalAmount);
+32
View File
@@ -0,0 +1,32 @@
namespace Distribution.Features.Grn;
/// <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.
/// 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)
{
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);
}
public static void ValidatePercent(decimal value, string label)
{
if (value < 0 || value > 100)
throw new InvalidOperationException($"{label} must be between 0 and 100.");
}
public static void ValidateCost(decimal value, string label)
{
if (value < 0)
throw new InvalidOperationException($"{label} cannot be negative.");
}
}
+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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddGrnPricing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "DiscountPercent",
table: "GoodsReceipts",
type: "decimal(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "VatPercent",
table: "GoodsReceipts",
type: "decimal(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "DiscountPercent",
table: "GoodsReceiptItems",
type: "decimal(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "UnitCost",
table: "GoodsReceiptItems",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "VatPercent",
table: "GoodsReceiptItems",
type: "decimal(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.UpdateData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000001"),
columns: new[] { "DiscountPercent", "UnitCost", "VatPercent" },
values: new object[] { 0m, 0m, 0m });
migrationBuilder.UpdateData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000002"),
columns: new[] { "DiscountPercent", "UnitCost", "VatPercent" },
values: new object[] { 0m, 0m, 0m });
migrationBuilder.UpdateData(
table: "GoodsReceipts",
keyColumn: "Id",
keyValue: new Guid("43000000-0000-0000-0000-000000000001"),
columns: new[] { "DiscountPercent", "VatPercent" },
values: new object[] { 0m, 0m });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DiscountPercent",
table: "GoodsReceipts");
migrationBuilder.DropColumn(
name: "VatPercent",
table: "GoodsReceipts");
migrationBuilder.DropColumn(
name: "DiscountPercent",
table: "GoodsReceiptItems");
migrationBuilder.DropColumn(
name: "UnitCost",
table: "GoodsReceiptItems");
migrationBuilder.DropColumn(
name: "VatPercent",
table: "GoodsReceiptItems");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddGrnFreeQuantity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FreeQuantity",
table: "GoodsReceiptItems",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.UpdateData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000001"),
column: "FreeQuantity",
value: 0);
migrationBuilder.UpdateData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000002"),
column: "FreeQuantity",
value: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FreeQuantity",
table: "GoodsReceiptItems");
}
}
}
@@ -427,6 +427,10 @@ namespace Distribution.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("DiscountPercent")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<Guid?>("FK_PurchaseOrderId")
.HasColumnType("char(36)");
@@ -445,6 +449,10 @@ namespace Distribution.Migrations
b.Property<DateTime>("ReceivedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("VatPercent")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.HasKey("Id");
b.HasIndex("FK_PurchaseOrderId");
@@ -460,11 +468,13 @@ namespace Distribution.Migrations
{
Id = new Guid("43000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
DiscountPercent = 0m,
FK_PurchaseOrderId = new Guid("41000000-0000-0000-0000-000000000001"),
FK_ReceivedByUserId = new Guid("29000000-0000-0000-0000-000000000001"),
FK_WarehouseId = new Guid("27000000-0000-0000-0000-000000000001"),
IsActive = true,
ReceivedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified)
ReceivedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
VatPercent = 0m
});
});
@@ -480,6 +490,10 @@ namespace Distribution.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("DiscountPercent")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<Guid>("FK_GoodsReceiptId")
.HasColumnType("char(36)");
@@ -489,12 +503,23 @@ namespace Distribution.Migrations
b.Property<Guid?>("FK_PurchaseOrderItemId")
.HasColumnType("char(36)");
b.Property<int>("FreeQuantity")
.HasColumnType("int");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("RejectedQuantity")
.HasColumnType("int");
b.Property<decimal>("UnitCost")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal>("VatPercent")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.HasKey("Id");
b.HasIndex("FK_GoodsReceiptId");
@@ -511,20 +536,28 @@ namespace Distribution.Migrations
Id = new Guid("44000000-0000-0000-0000-000000000001"),
AcceptedQuantity = 200,
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
DiscountPercent = 0m,
FK_GoodsReceiptId = new Guid("43000000-0000-0000-0000-000000000001"),
FK_PurchaseOrderItemId = new Guid("42000000-0000-0000-0000-000000000001"),
FreeQuantity = 0,
IsActive = true,
RejectedQuantity = 0
RejectedQuantity = 0,
UnitCost = 0m,
VatPercent = 0m
},
new
{
Id = new Guid("44000000-0000-0000-0000-000000000002"),
AcceptedQuantity = 98,
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
DiscountPercent = 0m,
FK_GoodsReceiptId = new Guid("43000000-0000-0000-0000-000000000001"),
FK_PurchaseOrderItemId = new Guid("42000000-0000-0000-0000-000000000002"),
FreeQuantity = 0,
IsActive = true,
RejectedQuantity = 2
RejectedQuantity = 2,
UnitCost = 0m,
VatPercent = 0m
});
});
@@ -1500,17 +1533,17 @@ namespace Distribution.Migrations
new
{
Id = new Guid("24000000-0000-0000-0000-000000000001"),
Code = "REG-0001",
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
IsActive = true,
Code = "REG-0001",
Name = "Western Province"
},
new
{
Id = new Guid("24000000-0000-0000-0000-000000000002"),
Code = "REG-0002",
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
IsActive = true,
Code = "REG-0002",
Name = "Central Province"
});
});
+9
View File
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Models
{
@@ -27,6 +28,14 @@ namespace Distribution.Models
public DateTime ReceivedAt { get; set; } = DateTime.Now;
/// <summary>GRN-wide default discount %, applied to any line that doesn't set its own.</summary>
[Precision(5, 2)]
public decimal DiscountPercent { get; set; }
/// <summary>GRN-wide default VAT %, applied to any line that doesn't set its own.</summary>
[Precision(5, 2)]
public decimal VatPercent { get; set; }
public ICollection<GoodsReceiptItem> Items { get; set; } = [];
}
}
+17
View File
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Models
{
@@ -24,6 +25,22 @@ namespace Distribution.Models
/// <summary>Damaged/short at receipt, not added to stock.</summary>
public int RejectedQuantity { get; set; } = 0;
/// <summary>
/// Units the supplier gave at no charge. Received into stock like accepted units, but never
/// costed and never counted against the purchase order's ordered quantity.
/// </summary>
public int FreeQuantity { get; set; } = 0;
/// <summary>Cost per base-UOM unit. Only accepted units are costed.</summary>
[Precision(18, 2)]
public decimal UnitCost { get; set; }
[Precision(5, 2)]
public decimal DiscountPercent { get; set; }
[Precision(5, 2)]
public decimal VatPercent { get; set; }
/// <summary>Populated when the received item tracks batches and/or expiry dates.</summary>
public ICollection<GoodsReceiptItemBatch> Batches { get; set; } = [];