feat(procurement): enhance purchase order and GRN functionalities

- Updated purchase order descriptions for clarity on draft and submission processes.
- Implemented submit and delete functionalities for draft purchase orders, allowing users to manage their orders more effectively.
- Added discount and VAT fields to GRN lines, enabling better cost tracking and reporting.
- Enhanced validation for GRN lines to ensure discount and VAT percentages are within acceptable ranges.
- Updated API to support new functionalities, including submitting and deleting purchase orders.
- Improved UI components for better user experience in managing purchase orders and GRNs.
- Documented changes in security and backend phase documentation to reflect new processes and requirements.
This commit is contained in:
2026-07-21 10:08:32 +05:30
parent fe9e8a780f
commit f02c89b3cb
28 changed files with 594 additions and 66 deletions
@@ -57,6 +57,25 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return Ok(result.Value);
}
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
[HttpPost("{poId:int}/submit")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Submit(int poId, CancellationToken ct)
=> Ok(await _pos.SubmitAsync(poId, ct));
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
[HttpDelete("{poId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int poId, CancellationToken ct)
{
await _pos.DeleteAsync(poId, ct);
return NoContent();
}
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
[HttpPost("{poId:int}/approve")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
+30 -3
View File
@@ -3,9 +3,13 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the PO-derived cost for
/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
/// for direct receipts. <see cref="ReceivedValue"/> = qty × unitCost.
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the gross cost received at:
/// entered on the line, defaulting to the PO price when omitted (a per-receipt price
/// override is now permitted — see docs/02-SECURITY C.3, revised). <see cref="PoUnitPrice"/>
/// snapshots the PO price at receipt so the variance survives later PO edits.
/// <see cref="NetUnitCost"/> = unitCost after trade discount — this is what the FIFO layer
/// costs at (VAT never enters stock value; it is recoverable input tax).
/// <see cref="ReceivedValue"/> = qty × netUnitCost (after discount, before VAT).
/// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
/// </summary>
public class GrnLine
@@ -31,7 +35,30 @@ public class GrnLine
public Batch? Batch { get; set; }
public decimal Qty { get; set; }
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
public decimal UnitCost { get; set; }
/// <summary>Snapshot of the PO line price at receipt; null for direct receipts.</summary>
public decimal? PoUnitPrice { get; set; }
/// <summary>Trade discount percentage (0100), entered.</summary>
public decimal DiscountPct { get; set; }
/// <summary>UnitCost × (1 DiscountPct/100) — the inventory (FIFO layer) cost.</summary>
public decimal NetUnitCost { get; set; }
/// <summary>VAT percentage (0100), entered. Recoverable — does not affect stock value.</summary>
public decimal VatPct { get; set; }
/// <summary>Qty × NetUnitCost × VatPct/100.</summary>
public decimal VatAmount { get; set; }
/// <summary>Qty × NetUnitCost (after discount, before VAT).</summary>
public decimal ReceivedValue { get; set; }
/// <summary>Qty × NetUnitCost + VatAmount — payable to the vendor.</summary>
public decimal LineTotal { get; set; }
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
}
+1 -1
View File
@@ -22,7 +22,7 @@ public class PoLine
public Warehouse? Warehouse { get; set; }
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }
public decimal UnitPrice { get; set; }//
public decimal Tax { get; set; }
public decimal QtyReceived { get; set; }
}
+13 -2
View File
@@ -7,7 +7,10 @@ namespace ERPCore.Dtos.Grn;
public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId);
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
HoldStatus HoldStatus, int? BatchId);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
@@ -44,8 +47,16 @@ public sealed class CreateGrnLineInput
[Required] public int UomId { get; set; }
public int? BinId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>Used only for direct (no-PO) receipts; ignored when <see cref="PoLineId"/> is set.</summary>
/// <summary>
/// Gross unit cost. Required for direct (no-PO) receipts. For a PO line it is an optional
/// per-receipt price override — when 0/omitted the PO line price is used; when supplied it
/// wins and a variance is recorded against the PO snapshot (docs/02-SECURITY C.3, revised).
/// </summary>
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
/// <summary>Trade discount percentage (0100). Reduces the inventory cost.</summary>
[Range(0, 100)] public decimal DiscountPct { get; set; }
/// <summary>VAT percentage (0100). Recoverable — does not affect stock value.</summary>
[Range(0, 100)] public decimal VatPct { get; set; }
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; }
}
@@ -37,6 +37,13 @@ public sealed class CreatePurchaseOrderRequest
[Required] public int VendorId { get; set; }
public int? RequisitionId { get; set; }
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
/// <summary>
/// When true the PO is created in <c>Draft</c> (editable/deletable, not yet issued).
/// When false (default) it auto-approves on creation, preserving the Requisition→PO
/// and RFQ→PO flows unchanged (docs/11 §3.3).
/// </summary>
public bool SaveAsDraft { get; set; }
}
public sealed class UpdatePurchaseOrderRequest
@@ -37,7 +37,13 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6);
builder.Property(l => l.DiscountPct).HasPrecision(9, 4);
builder.Property(l => l.NetUnitCost).HasPrecision(18, 6);
builder.Property(l => l.VatPct).HasPrecision(9, 4);
builder.Property(l => l.VatAmount).HasPrecision(18, 4);
builder.Property(l => l.ReceivedValue).HasPrecision(18, 4);
builder.Property(l => l.LineTotal).HasPrecision(18, 4);
builder.Property(l => l.HoldStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
@@ -41,7 +41,11 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 15, Code = "NAV:products.uom", SubNavItemId = 5 },
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 }
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 },
new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 },
new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 },
new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 },
new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 }
);
}
}
@@ -32,7 +32,12 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 }
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
// Procurement (NavItemId 4) children — mirror the hub page order.
new SubNavItem { SubNavItemId = 9, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
new SubNavItem { SubNavItemId = 10, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 11, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 12, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }
);
}
}
@@ -277,6 +277,10 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int?>("BinId")
.HasColumnType("integer");
b.Property<decimal>("DiscountPct")
.HasPrecision(9, 4)
.HasColumnType("numeric(9,4)");
b.Property<int>("GrnId")
.HasColumnType("integer");
@@ -288,9 +292,21 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("NetUnitCost")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<int?>("PoLineId")
.HasColumnType("integer");
b.Property<decimal?>("PoUnitPrice")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
@@ -306,6 +322,14 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<decimal>("VatAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("VatPct")
.HasPrecision(9, 4)
.HasColumnType("numeric(9,4)");
b.HasKey("GrnLineId");
b.HasIndex("BatchId");
@@ -828,6 +852,30 @@ namespace ERPCore.Infra.Persistence.Migrations
PermissionId = 18,
Code = "NAV:settings.users",
SubNavItemId = 8
},
new
{
PermissionId = 19,
Code = "NAV:procurement.requisitions",
SubNavItemId = 9
},
new
{
PermissionId = 20,
Code = "NAV:procurement.rfqs",
SubNavItemId = 10
},
new
{
PermissionId = 21,
Code = "NAV:procurement.purchase-orders",
SubNavItemId = 11
},
new
{
PermissionId = 22,
Code = "NAV:procurement.purchase-returns",
SubNavItemId = 12
});
});
@@ -1916,6 +1964,46 @@ namespace ERPCore.Infra.Persistence.Migrations
NavItemId = 9,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 9,
Code = "procurement.requisitions",
Href = "/dashboard/procurement/requisitions",
Label = "Requisitions",
NavItemId = 4,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 10,
Code = "procurement.rfqs",
Href = "/dashboard/procurement/rfqs",
Label = "RFQs",
NavItemId = 4,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 11,
Code = "procurement.purchase-orders",
Href = "/dashboard/procurement/purchase-orders",
Label = "Purchase Orders",
NavItemId = 4,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 12,
Code = "procurement.purchase-returns",
Href = "/dashboard/procurement/purchase-returns",
Label = "Purchase Returns",
NavItemId = 4,
SortOrder = 4,
Status = "Active"
});
});
+25 -5
View File
@@ -138,8 +138,11 @@ public sealed class GrnService : IGrnService
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
// Cost: PO-derived for PO lines (client cost ignored, C.3); entered for direct.
// Cost: for a PO line, the PO price is used unless an override is entered (then it
// wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3,
// revised). Direct receipts always use the entered cost.
decimal unitCost;
decimal? poUnitPrice = null;
if (input.PoLineId is not null)
{
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
@@ -152,13 +155,19 @@ public sealed class GrnService : IGrnService
throw new DomainException(ErrorCodes.OverReceiptTolerance,
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
unitCost = poLine.UnitPrice;
poUnitPrice = poLine.UnitPrice;
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
}
else
{
unitCost = input.UnitCost;
}
// Derived figures are always computed server-side, never accepted from the client.
var netUnitCost = Math.Round(unitCost * (1 - input.DiscountPct / 100m), 6, MidpointRounding.AwayFromZero);
var receivedValue = Math.Round(input.Qty * netUnitCost, 4, MidpointRounding.AwayFromZero);
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
lines.Add(new GrnLine
@@ -170,7 +179,13 @@ public sealed class GrnService : IGrnService
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
Qty = input.Qty,
UnitCost = unitCost,
ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero),
PoUnitPrice = poUnitPrice,
DiscountPct = input.DiscountPct,
NetUnitCost = netUnitCost,
VatPct = input.VatPct,
VatAmount = vatAmount,
ReceivedValue = receivedValue,
LineTotal = receivedValue + vatAmount,
HoldStatus = input.HoldStatus
});
}
@@ -220,7 +235,9 @@ public sealed class GrnService : IGrnService
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.UnitCost, token);
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
// enters stock value (docs/10 FR-GRN-06, revised).
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token);
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
@@ -380,5 +397,8 @@ public sealed class GrnService : IGrnService
private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.ReceivedValue, l.HoldStatus, l.BatchId)).ToList());
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
l.HoldStatus, l.BatchId)).ToList());
}
@@ -16,4 +16,10 @@ public interface IPurchaseOrderService
Task<ETagged<PurchaseOrderDto>> UpdateAsync(int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<PurchaseOrderDto> ApproveAsync(int poId, CancellationToken ct = default);
Task<PurchaseOrderDto> CancelAsync(int poId, string? reason, CancellationToken ct = default);
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
Task<PurchaseOrderDto> SubmitAsync(int poId, CancellationToken ct = default);
/// <summary>Delete a PO — permitted only while Draft, else 409 PO_NOT_EDITABLE.</summary>
Task DeleteAsync(int poId, CancellationToken ct = default);
}
@@ -92,9 +92,10 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
DocNo = docNo,
VendorId = request.VendorId,
RequisitionId = request.RequisitionId,
// Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04).
// Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04),
// unless the caller explicitly saves a Draft (editable/deletable until submitted).
ApprovalRequired = false,
Status = PurchaseOrderStatus.Approved,
Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(ToLine).ToList()
@@ -182,8 +183,41 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
return Map(po);
}
private static bool IsEditable(PurchaseOrderStatus status) => status is not (
PurchaseOrderStatus.FullyReceived or PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled);
public async Task<PurchaseOrderDto> SubmitAsync(int poId, CancellationToken ct = default)
{
var po = await _pos.Query()
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (po.Status != PurchaseOrderStatus.Draft)
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409);
// Phase 1: no value gate, so a submitted draft goes straight to Approved (FR-PROC-04).
po.Status = PurchaseOrderStatus.Approved;
po.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(po);
}
public async Task DeleteAsync(int poId, CancellationToken ct = default)
{
var po = await _pos.Query()
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (po.Status != PurchaseOrderStatus.Draft)
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be deleted; only a Draft can be deleted.", 409);
_pos.Remove(po);
await _uow.SaveChangesAsync(ct);
}
// FR-PROC-05 (revised): a PO is editable/deletable only while Draft. Submitting locks it.
// Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05.
private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft;
private static PoLine ToLine(CreatePoLineInput l) => new()
{