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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user