diff --git a/Distribution/Features/PurchaseOrder/IPoRepository.cs b/Distribution/Features/PurchaseOrder/IPoRepository.cs new file mode 100644 index 0000000..9844faf --- /dev/null +++ b/Distribution/Features/PurchaseOrder/IPoRepository.cs @@ -0,0 +1,19 @@ +using Distribution.Models; + +namespace Distribution.Features.Po; + +public interface IPoRepository +{ + Task GetByIdAsync(Guid id); + Task> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status); + Task AddAsync(Models.PurchaseOrder entity); + void Update(Models.PurchaseOrder entity); + + Task AddItemAsync(PurchaseOrderItem entity); + + /// True if any active (non-cancelled, non-closed-and-void) PurchaseOrder references this supplier. + Task HasActiveForSupplierAsync(Guid supplierId); + + /// All active PurchaseOrders for a supplier, with items, used by evaluateSupplier. + Task> GetBySupplierAsync(Guid supplierId); +} diff --git a/Distribution/Features/PurchaseOrder/PoController.cs b/Distribution/Features/PurchaseOrder/PoController.cs new file mode 100644 index 0000000..c1fa3dc --- /dev/null +++ b/Distribution/Features/PurchaseOrder/PoController.cs @@ -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 GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) => + Ok(await service.GetAllAsync(supplierId, status)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var result = await service.GetByIdAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + public async Task 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 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 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 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 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 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 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 }); } + } +} diff --git a/Distribution/Features/PurchaseOrder/PoDTOs.cs b/Distribution/Features/PurchaseOrder/PoDTOs.cs new file mode 100644 index 0000000..7c9868b --- /dev/null +++ b/Distribution/Features/PurchaseOrder/PoDTOs.cs @@ -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 Items); + +/// +/// 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. +/// +public record UpdatePoLineRequest(Guid? PurchaseOrderItemId, Guid ItemId, int Quantity, decimal UnitCost); + +public record UpdatePoRequest(List 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 Items); + +/// +/// 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. +/// +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 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); diff --git a/Distribution/Features/PurchaseOrder/PoRepository.cs b/Distribution/Features/PurchaseOrder/PoRepository.cs new file mode 100644 index 0000000..334dc6e --- /dev/null +++ b/Distribution/Features/PurchaseOrder/PoRepository.cs @@ -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 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> 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 HasActiveForSupplierAsync(Guid supplierId) => + await context.PurchaseOrders.AnyAsync(po => + po.FK_SupplierId == supplierId && + po.IsActive && + po.Status != PurchaseOrderStatus.Cancelled); + + public async Task> GetBySupplierAsync(Guid supplierId) => + await context.PurchaseOrders + .Include(po => po.Items) + .Where(po => po.FK_SupplierId == supplierId && po.IsActive) + .ToListAsync(); +} diff --git a/Distribution/Features/PurchaseOrder/PoService.cs b/Distribution/Features/PurchaseOrder/PoService.cs new file mode 100644 index 0000000..11184d0 --- /dev/null +++ b/Distribution/Features/PurchaseOrder/PoService.cs @@ -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> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status) => + (await repo.GetAllAsync(supplierId, status)).Select(Map).ToList(); + + public async Task GetByIdAsync(Guid id) + { + var po = await repo.GetByIdAsync(id); + if (po is null || !po.IsActive) return null; + return Map(po); + } + + public async Task 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))!; + } + + /// + /// 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. + /// + public async Task 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))!; + } + + /// + /// Only valid once a Po is Received, or manually closed early (outstanding quantity written off). + /// + public async Task 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); + } + + /// Only valid before any goods receipt exists against the Po. + public async Task 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); + } + + /// + /// 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. + /// + public async Task 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); + } + + /// + /// 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. + /// + public async Task 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."); + } + + /// + /// 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. + /// + public async Task 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); + } +}