Fix GRN pricing
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user