diff --git a/Distribution/Features/PurchaseReturn/IPurchaseReturnRepository.cs b/Distribution/Features/PurchaseReturn/IPurchaseReturnRepository.cs new file mode 100644 index 0000000..3449ff2 --- /dev/null +++ b/Distribution/Features/PurchaseReturn/IPurchaseReturnRepository.cs @@ -0,0 +1,11 @@ +namespace Distribution.Features.PurchaseReturn; + +public interface IPurchaseReturnRepository +{ + Task GetByIdAsync(Guid id); + Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId); + Task AddAsync(Models.PurchaseReturn entity); + + /// Sum of Quantity per ItemId across all prior returns for a Po. + Task> GetReturnedTotalsByItemAsync(Guid purchaseOrderId); +} diff --git a/Distribution/Features/PurchaseReturn/PurchaseReturnController.cs b/Distribution/Features/PurchaseReturn/PurchaseReturnController.cs new file mode 100644 index 0000000..f125024 --- /dev/null +++ b/Distribution/Features/PurchaseReturn/PurchaseReturnController.cs @@ -0,0 +1,33 @@ +using Distribution.Infra.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.PurchaseReturn; + +[ApiController] +[Authorize] +public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase +{ + [HttpGet("api/purchase-returns/{id:guid}")] + public async Task GetById(Guid id) + { + var result = await service.GetByIdAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("api/purchase-orders/{id:guid}/returns")] + public async Task GetByPurchaseOrder(Guid id) => + Ok(await service.GetByPurchaseOrderIdAsync(id)); + + [HttpPost("api/purchase-orders/{id:guid}/returns")] + public async Task Create(Guid id, CreatePurchaseReturnRequest request, CancellationToken ct) + { + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + try + { + var result = await service.CreateAsync(id, request, userId, ct); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } + } +} diff --git a/Distribution/Features/PurchaseReturn/PurchaseReturnDTOs.cs b/Distribution/Features/PurchaseReturn/PurchaseReturnDTOs.cs new file mode 100644 index 0000000..cc5ed88 --- /dev/null +++ b/Distribution/Features/PurchaseReturn/PurchaseReturnDTOs.cs @@ -0,0 +1,15 @@ +namespace Distribution.Features.PurchaseReturn; + +public record PurchaseReturnItemRequest(Guid ItemId, int Quantity); + +public record CreatePurchaseReturnRequest(string Reason, List Items); + +public record PurchaseReturnItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, int Quantity); + +public record PurchaseReturnResponse( + Guid Id, + Guid PurchaseOrderId, + string Reason, + bool IsActive, + DateTime CreatedAt, + List Items); diff --git a/Distribution/Features/PurchaseReturn/PurchaseReturnRepository.cs b/Distribution/Features/PurchaseReturn/PurchaseReturnRepository.cs new file mode 100644 index 0000000..f082aa6 --- /dev/null +++ b/Distribution/Features/PurchaseReturn/PurchaseReturnRepository.cs @@ -0,0 +1,30 @@ +using Distribution.Data; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.PurchaseReturn; + +public class PurchaseReturnRepository(AppDBContext context) : IPurchaseReturnRepository +{ + public async Task GetByIdAsync(Guid id) => + await context.PurchaseReturns + .Include(pr => pr.Items).ThenInclude(i => i.Item) + .FirstOrDefaultAsync(pr => pr.Id == id); + + public async Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => + await context.PurchaseReturns + .Include(pr => pr.Items).ThenInclude(i => i.Item) + .Where(pr => pr.FK_PurchaseOrderId == purchaseOrderId && pr.IsActive) + .OrderByDescending(pr => pr.CreatedAt) + .ToListAsync(); + + public async Task AddAsync(Models.PurchaseReturn entity) => + await context.PurchaseReturns.AddAsync(entity); + + public async Task> GetReturnedTotalsByItemAsync(Guid purchaseOrderId) => + await context.PurchaseReturnItems + .Include(i => i.PurchaseReturn) + .Where(i => i.PurchaseReturn.FK_PurchaseOrderId == purchaseOrderId && i.IsActive) + .GroupBy(i => i.FK_ItemId) + .Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.Quantity) }) + .ToDictionaryAsync(x => x.ItemId, x => x.Total); +} diff --git a/Distribution/Features/PurchaseReturn/PurchaseReturnService.cs b/Distribution/Features/PurchaseReturn/PurchaseReturnService.cs new file mode 100644 index 0000000..1e08795 --- /dev/null +++ b/Distribution/Features/PurchaseReturn/PurchaseReturnService.cs @@ -0,0 +1,122 @@ +using Distribution.Features.Grn; +using Distribution.Features.Po; +using Distribution.Features.Stock; +using Distribution.Infra.UoW; +using Distribution.Models; + +namespace Distribution.Features.PurchaseReturn; + +public class PurchaseReturnService( + IPurchaseReturnRepository repo, + IPoRepository poRepo, + IGrnRepository goodsReceiptRepo, + IStockRepository stockRepo, + IUnitOfWork uow) +{ + private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) => + new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity); + + private static PurchaseReturnResponse Map(Models.PurchaseReturn pr) => + new(pr.Id, pr.FK_PurchaseOrderId, pr.Reason, pr.IsActive, pr.CreatedAt, + pr.Items.Where(i => i.IsActive).Select(MapItem).ToList()); + + public async Task GetByIdAsync(Guid id) + { + var pr = await repo.GetByIdAsync(id); + if (pr is null || !pr.IsActive) return null; + return Map(pr); + } + + public async Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => + (await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList(); + + /// + /// Requires at least one prior GoodsReceipt against the Po; return quantities per item cannot + /// exceed the received-but-not-already-returned quantity for that item. Decrements StockBalance + /// and writes a StockMovement in the same unit of work. + /// + public async Task CreateAsync(Guid purchaseOrderId, CreatePurchaseReturnRequest request, Guid createdByUserId, CancellationToken ct = default) + { + var po = await poRepo.GetByIdAsync(purchaseOrderId); + if (po is null || !po.IsActive) + throw new InvalidOperationException("Purchase order not found."); + + if (!await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(purchaseOrderId)) + throw new InvalidOperationException("Cannot return items for a purchase order with no goods receipt against it."); + + if (request.Items is null || request.Items.Count == 0) + throw new InvalidOperationException("A purchase return must have at least one line item."); + + var receivedTotals = await goodsReceiptRepo.GetAcceptedTotalsByItemAsync(purchaseOrderId); + var returnedTotals = await repo.GetReturnedTotalsByItemAsync(purchaseOrderId); + + // The receipt(s) for this Po share a single receiving warehouse per the current model + // (each GoodsReceipt carries FK_WarehouseId); use the most recent receipt's warehouse + // as the stock-decrement location for the return. + var receipts = await goodsReceiptRepo.GetByPurchaseOrderIdAsync(purchaseOrderId); + var warehouseId = receipts.First().FK_WarehouseId; + + var purchaseReturn = new Models.PurchaseReturn + { + FK_PurchaseOrderId = purchaseOrderId, + Reason = request.Reason + }; + + foreach (var line in request.Items) + { + if (line.Quantity <= 0) + throw new InvalidOperationException("Return quantity must be greater than zero."); + + receivedTotals.TryGetValue(line.ItemId, out var received); + returnedTotals.TryGetValue(line.ItemId, out var alreadyReturned); + var available = received - alreadyReturned; + + if (line.Quantity > available) + throw new InvalidOperationException( + $"Return quantity for item {line.ItemId} ({line.Quantity}) exceeds the received-but-not-already-returned quantity ({available})."); + + purchaseReturn.Items.Add(new Models.PurchaseReturnItem + { + FK_ItemId = line.ItemId, + Quantity = line.Quantity + }); + } + + await uow.BeginAsync(ct); + try + { + await repo.AddAsync(purchaseReturn); + + foreach (var line in purchaseReturn.Items) + { + var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, warehouseId) + ?? throw new InvalidOperationException($"No stock balance found for item {line.FK_ItemId} at the receiving warehouse."); + + if (balance.Quantity < line.Quantity) + throw new InvalidOperationException($"Insufficient warehouse stock for item {line.FK_ItemId} to return."); + + balance.Quantity -= line.Quantity; + balance.UpdatedAt = DateTime.Now; + stockRepo.UpdateBalance(balance); + + await stockRepo.AddMovementAsync(new StockMovement + { + FK_ItemId = line.FK_ItemId, + FromLocationId = warehouseId, + FromLocationType = LocationType.Warehouse, + Quantity = line.Quantity, + Type = MovementType.PurchaseReturn, + ReasonCode = request.Reason, + ReferenceId = purchaseReturn.Id, + CreatedBy = createdByUserId + }); + } + + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + var saved = await repo.GetByIdAsync(purchaseReturn.Id); + return Map(saved!); + } +}