Feat/procurement #8
@@ -0,0 +1,19 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
|
||||
public interface IPoRepository
|
||||
{
|
||||
Task<Models.PurchaseOrder?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status);
|
||||
Task AddAsync(Models.PurchaseOrder entity);
|
||||
void Update(Models.PurchaseOrder entity);
|
||||
|
||||
Task AddItemAsync(PurchaseOrderItem entity);
|
||||
|
||||
/// <summary>True if any active (non-cancelled, non-closed-and-void) PurchaseOrder references this supplier.</summary>
|
||||
Task<bool> HasActiveForSupplierAsync(Guid supplierId);
|
||||
|
||||
/// <summary>All active PurchaseOrders for a supplier, with items, used by evaluateSupplier.</summary>
|
||||
Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/purchase-orders")]
|
||||
[Authorize]
|
||||
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) =>
|
||||
Ok(await service.GetAllAsync(supplierId, status));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreatePoRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, userId, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdatePoRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/close")]
|
||||
public async Task<IActionResult> Close(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CloseAsync(id, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/cancel")]
|
||||
public async Task<IActionResult> Cancel(Guid id, CancelPoRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CancelAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/items/{itemId:guid}/cancel")]
|
||||
public async Task<IActionResult> CancelLine(Guid id, Guid itemId, CancelPoLineRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CancelLineAsync(id, itemId, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/match-invoice")]
|
||||
public async Task<IActionResult> MatchInvoice(Guid id, [FromQuery] Guid? invoiceId, [FromQuery] Guid? grnId)
|
||||
{
|
||||
var result = await service.MatchInvoiceToPoAsync(id, invoiceId, grnId);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/resolve-variance")]
|
||||
public async Task<IActionResult> ResolveVariance(Guid id, ResolveInvoiceVarianceRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.ResolveInvoiceVarianceAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
|
||||
public record PoItemRequest(Guid ItemId, int Quantity, decimal UnitCost);
|
||||
|
||||
public record CreatePoRequest(Guid SupplierId, List<PoItemRequest> Items);
|
||||
|
||||
/// <summary>
|
||||
/// Update line: PurchaseOrderItemId set = update an existing unreceived line (Quantity/UnitCost),
|
||||
/// null = add a new line to the Po. Only valid while the Po is Issued.
|
||||
/// </summary>
|
||||
public record UpdatePoLineRequest(Guid? PurchaseOrderItemId, Guid ItemId, int Quantity, decimal UnitCost);
|
||||
|
||||
public record UpdatePoRequest(List<UpdatePoLineRequest> Items);
|
||||
|
||||
public record CancelPoRequest(string Reason);
|
||||
|
||||
public record CancelPoLineRequest(string? Reason);
|
||||
|
||||
public record PoItemResponse(
|
||||
Guid Id,
|
||||
Guid ItemId,
|
||||
string Sku,
|
||||
string ItemName,
|
||||
int Quantity,
|
||||
decimal UnitCost,
|
||||
int ReceivedQuantity,
|
||||
int CancelledQuantity,
|
||||
bool IsCancelled);
|
||||
|
||||
public record PoResponse(
|
||||
Guid Id,
|
||||
Guid SupplierId,
|
||||
string SupplierName,
|
||||
Guid CreatedByUserId,
|
||||
PurchaseOrderStatus Status,
|
||||
string? CancelReason,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
List<PoItemResponse> Items);
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Po-vs-Grn quantity/cost reconciliation. Full 3-way match (Po + Grn + Invoice) activates
|
||||
/// once Finance's Invoice entity (step 19, not yet built) exists — until then this compares only
|
||||
/// PurchaseOrderItem vs GoodsReceiptItem.
|
||||
/// </summary>
|
||||
public record InvoiceMatchLineResult(Guid PurchaseOrderItemId, Guid ItemId, int OrderedQuantity, int AcceptedQuantity, decimal UnitCost, bool QuantityMatches);
|
||||
|
||||
public record InvoiceMatchResult(Guid PurchaseOrderId, Guid? GoodsReceiptId, Guid? InvoiceId, bool IsMatched, List<InvoiceMatchLineResult> Lines, string Note);
|
||||
|
||||
public record ResolveInvoiceVarianceRequest(string Variance, string Resolution, decimal? AdjustedUnitCost, Guid? PurchaseOrderItemId);
|
||||
|
||||
public record InvoiceVarianceResolutionResponse(Guid PurchaseOrderId, string Variance, string Resolution, DateTime ResolvedAt);
|
||||
@@ -0,0 +1,49 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
|
||||
public class PoRepository(AppDBContext context) : IPoRepository
|
||||
{
|
||||
public async Task<Models.PurchaseOrder?> GetByIdAsync(Guid id) =>
|
||||
await context.PurchaseOrders
|
||||
.Include(po => po.Supplier)
|
||||
.Include(po => po.Items).ThenInclude(i => i.Item)
|
||||
.FirstOrDefaultAsync(po => po.Id == id);
|
||||
|
||||
public async Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status)
|
||||
{
|
||||
var query = context.PurchaseOrders
|
||||
.Include(po => po.Supplier)
|
||||
.Include(po => po.Items).ThenInclude(i => i.Item)
|
||||
.Where(po => po.IsActive)
|
||||
.AsQueryable();
|
||||
|
||||
if (supplierId.HasValue) query = query.Where(po => po.FK_SupplierId == supplierId.Value);
|
||||
if (status.HasValue) query = query.Where(po => po.Status == status.Value);
|
||||
|
||||
return await query.OrderByDescending(po => po.CreatedAt).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task AddAsync(Models.PurchaseOrder entity) =>
|
||||
await context.PurchaseOrders.AddAsync(entity);
|
||||
|
||||
public void Update(Models.PurchaseOrder entity) =>
|
||||
context.PurchaseOrders.Update(entity);
|
||||
|
||||
public async Task AddItemAsync(PurchaseOrderItem entity) =>
|
||||
await context.PurchaseOrderItems.AddAsync(entity);
|
||||
|
||||
public async Task<bool> HasActiveForSupplierAsync(Guid supplierId) =>
|
||||
await context.PurchaseOrders.AnyAsync(po =>
|
||||
po.FK_SupplierId == supplierId &&
|
||||
po.IsActive &&
|
||||
po.Status != PurchaseOrderStatus.Cancelled);
|
||||
|
||||
public async Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId) =>
|
||||
await context.PurchaseOrders
|
||||
.Include(po => po.Items)
|
||||
.Where(po => po.FK_SupplierId == supplierId && po.IsActive)
|
||||
.ToListAsync();
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
|
||||
public class PoService(
|
||||
IPoRepository repo,
|
||||
IGrnRepository goodsReceiptRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static PoItemResponse MapItem(PurchaseOrderItem i) =>
|
||||
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.UnitCost, i.ReceivedQuantity, i.CancelledQuantity, i.IsCancelled);
|
||||
|
||||
private static PoResponse Map(Models.PurchaseOrder po) =>
|
||||
new(po.Id, po.FK_SupplierId, po.Supplier?.Name ?? string.Empty, po.FK_CreatedByUserId,
|
||||
po.Status, po.CancelReason, po.IsActive, po.CreatedAt,
|
||||
po.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||
|
||||
public async Task<List<PoResponse>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status) =>
|
||||
(await repo.GetAllAsync(supplierId, status)).Select(Map).ToList();
|
||||
|
||||
public async Task<PoResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
public async Task<PoResponse> CreateAsync(CreatePoRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||
{
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
throw new InvalidOperationException("A purchase order must have at least one line item.");
|
||||
|
||||
var po = new Models.PurchaseOrder
|
||||
{
|
||||
FK_SupplierId = request.SupplierId,
|
||||
FK_CreatedByUserId = createdByUserId,
|
||||
Status = PurchaseOrderStatus.Issued
|
||||
};
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
po.Items.Add(new PurchaseOrderItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
UnitCost = line.UnitCost
|
||||
});
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { await repo.AddAsync(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(po.Id))!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// May only change unreceived lines (ReceivedQuantity == 0) and only while the Po is Issued.
|
||||
/// Lines with a PurchaseOrderItemId update an existing line; lines without one add a new line.
|
||||
/// </summary>
|
||||
public async Task<PoResponse?> UpdateAsync(Guid id, UpdatePoRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
if (po.Status != PurchaseOrderStatus.Issued)
|
||||
throw new InvalidOperationException($"Cannot update a purchase order in {po.Status} status; only an Issued Po can be updated.");
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
if (line.PurchaseOrderItemId.HasValue)
|
||||
{
|
||||
var existing = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId.Value && i.IsActive)
|
||||
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
||||
|
||||
if (existing.ReceivedQuantity > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Line for item {existing.FK_ItemId} has already received stock and cannot be edited; use cancelPurchaseOrderLine or createPurchaseReturn instead.");
|
||||
|
||||
existing.FK_ItemId = line.ItemId;
|
||||
existing.Quantity = line.Quantity;
|
||||
existing.UnitCost = line.UnitCost;
|
||||
}
|
||||
else
|
||||
{
|
||||
await repo.AddItemAsync(new PurchaseOrderItem
|
||||
{
|
||||
FK_PurchaseOrderId = po.Id,
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
UnitCost = line.UnitCost
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
repo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(id))!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only valid once a Po is Received, or manually closed early (outstanding quantity written off).
|
||||
/// </summary>
|
||||
public async Task<PoResponse?> CloseAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
|
||||
throw new InvalidOperationException($"Cannot close a purchase order already in {po.Status} status.");
|
||||
|
||||
po.Status = PurchaseOrderStatus.Closed;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
/// <summary>Only valid before any goods receipt exists against the Po.</summary>
|
||||
public async Task<PoResponse?> CancelAsync(Guid id, CancelPoRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
|
||||
throw new InvalidOperationException($"Cannot cancel a purchase order already in {po.Status} status.");
|
||||
|
||||
if (await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(id))
|
||||
throw new InvalidOperationException("Cannot cancel a purchase order that already has a goods receipt against it.");
|
||||
|
||||
po.Status = PurchaseOrderStatus.Cancelled;
|
||||
po.CancelReason = request.Reason;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a single line instead of the whole Po. Only valid while ReceivedQuantity < Quantity
|
||||
/// and the line isn't already fully received or cancelled. Auto-completes the Po to Received once
|
||||
/// every non-cancelled line is fully received.
|
||||
/// </summary>
|
||||
public async Task<PoResponse?> CancelLineAsync(Guid id, Guid poItemId, CancelPoLineRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
var line = po.Items.FirstOrDefault(i => i.Id == poItemId && i.IsActive)
|
||||
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
|
||||
|
||||
if (line.IsCancelled)
|
||||
throw new InvalidOperationException("This line is already cancelled.");
|
||||
|
||||
if (line.ReceivedQuantity >= line.Quantity)
|
||||
throw new InvalidOperationException("This line is already fully received and cannot be cancelled.");
|
||||
|
||||
line.IsCancelled = true;
|
||||
line.CancelledQuantity = line.Quantity - line.ReceivedQuantity;
|
||||
|
||||
var activeLines = po.Items.Where(i => i.IsActive).ToList();
|
||||
if (activeLines.Where(i => !i.IsCancelled).All(i => i.ReceivedQuantity >= i.Quantity)
|
||||
&& activeLines.Any(i => !i.IsCancelled))
|
||||
{
|
||||
po.Status = PurchaseOrderStatus.Received;
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Po-vs-Grn reconciliation (matchInvoiceToPo). Full 3-way match against an Invoice
|
||||
/// entity activates once Finance (step 19) exists; this only detects and reports, never adjusts.
|
||||
/// </summary>
|
||||
public async Task<InvoiceMatchResult?> MatchInvoiceToPoAsync(Guid poId, Guid? invoiceId, Guid? grnId)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(poId);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
var receipts = grnId.HasValue
|
||||
? (await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId)).Where(g => g.Id == grnId.Value).ToList()
|
||||
: await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId);
|
||||
|
||||
var acceptedByItem = receipts
|
||||
.SelectMany(g => g.Items.Where(i => i.IsActive))
|
||||
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(i => i.AcceptedQuantity));
|
||||
|
||||
var lines = po.Items.Where(i => i.IsActive).Select(i =>
|
||||
{
|
||||
acceptedByItem.TryGetValue(i.FK_ItemId, out var accepted);
|
||||
return new InvoiceMatchLineResult(i.Id, i.FK_ItemId, i.Quantity, accepted, i.UnitCost, accepted == i.Quantity);
|
||||
}).ToList();
|
||||
|
||||
var isMatched = lines.All(l => l.QuantityMatches);
|
||||
|
||||
return new InvoiceMatchResult(
|
||||
poId, grnId, invoiceId, isMatched, lines,
|
||||
"Compares PurchaseOrderItem.Quantity against GoodsReceiptItem.AcceptedQuantity only; " +
|
||||
"invoice line comparison activates once Finance's Invoice entity (step 19) exists.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a Po/Grn(/invoice) variance and its resolution. Never silently adjusts anything except
|
||||
/// when the resolution explicitly requests a retroactive UnitCost adjustment on a named line.
|
||||
/// </summary>
|
||||
public async Task<InvoiceVarianceResolutionResponse?> ResolveInvoiceVarianceAsync(Guid poId, ResolveInvoiceVarianceRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(poId);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
if (request.AdjustedUnitCost.HasValue)
|
||||
{
|
||||
if (!request.PurchaseOrderItemId.HasValue)
|
||||
throw new InvalidOperationException("PurchaseOrderItemId is required when adjusting UnitCost.");
|
||||
|
||||
var line = po.Items.FirstOrDefault(i => i.Id == request.PurchaseOrderItemId.Value && i.IsActive)
|
||||
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
|
||||
|
||||
line.UnitCost = request.AdjustedUnitCost.Value;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
}
|
||||
|
||||
return new InvoiceVarianceResolutionResponse(poId, request.Variance, request.Resolution, DateTime.Now);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user