diff --git a/Distribution/Features/Grn/GrnController.cs b/Distribution/Features/Grn/GrnController.cs new file mode 100644 index 0000000..05bc233 --- /dev/null +++ b/Distribution/Features/Grn/GrnController.cs @@ -0,0 +1,33 @@ +using Distribution.Infra.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.Grn; + +[ApiController] +[Authorize] +public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase +{ + [HttpGet("api/goods-receipts/{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}/goods-receipts")] + public async Task GetByPurchaseOrder(Guid id) => + Ok(await service.GetByPurchaseOrderIdAsync(id)); + + [HttpPost("api/purchase-orders/{id:guid}/goods-receipts")] + public async Task Create(Guid id, CreateGrnRequest 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/Grn/GrnDTOs.cs b/Distribution/Features/Grn/GrnDTOs.cs new file mode 100644 index 0000000..1fa768d --- /dev/null +++ b/Distribution/Features/Grn/GrnDTOs.cs @@ -0,0 +1,24 @@ +namespace Distribution.Features.Grn; + +public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity); + +public record CreateGrnRequest(Guid WarehouseId, List Items); + +public record GrnItemResponse( + Guid Id, + Guid PurchaseOrderItemId, + Guid ItemId, + string Sku, + string ItemName, + int AcceptedQuantity, + int RejectedQuantity); + +public record GrnResponse( + Guid Id, + Guid PurchaseOrderId, + Guid ReceivedByUserId, + Guid WarehouseId, + string WarehouseName, + DateTime ReceivedAt, + DateTime CreatedAt, + List Items); diff --git a/Distribution/Features/Grn/GrnRepository.cs b/Distribution/Features/Grn/GrnRepository.cs new file mode 100644 index 0000000..40b6572 --- /dev/null +++ b/Distribution/Features/Grn/GrnRepository.cs @@ -0,0 +1,35 @@ +using Distribution.Data; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.Grn; + +public class GrnRepository(AppDBContext context) : IGrnRepository +{ + public async Task GetByIdAsync(Guid id) => + await context.GoodsReceipts + .Include(gr => gr.Warehouse) + .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item) + .FirstOrDefaultAsync(gr => gr.Id == id); + + public async Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => + await context.GoodsReceipts + .Include(gr => gr.Warehouse) + .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item) + .Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive) + .OrderByDescending(gr => gr.ReceivedAt) + .ToListAsync(); + + public async Task AddAsync(Models.GoodsReceipt entity) => + await context.GoodsReceipts.AddAsync(entity); + + public async Task HasAnyForPurchaseOrderAsync(Guid purchaseOrderId) => + await context.GoodsReceipts.AnyAsync(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive); + + public async Task> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) => + await context.GoodsReceiptItems + .Include(i => i.PurchaseOrderItem) + .Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive) + .GroupBy(i => i.PurchaseOrderItem.FK_ItemId) + .Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) }) + .ToDictionaryAsync(x => x.ItemId, x => x.Total); +} diff --git a/Distribution/Features/Grn/GrnService.cs b/Distribution/Features/Grn/GrnService.cs new file mode 100644 index 0000000..349cc1f --- /dev/null +++ b/Distribution/Features/Grn/GrnService.cs @@ -0,0 +1,137 @@ +using Distribution.Features.Po; +using Distribution.Features.Stock; +using Distribution.Infra.UoW; +using Distribution.Models; + +namespace Distribution.Features.Grn; + +public class GrnService( + IGrnRepository repo, + IPoRepository poRepo, + IStockRepository stockRepo, + IUnitOfWork uow) +{ + private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) => + new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku, + i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity); + + private static GrnResponse Map(Models.GoodsReceipt gr) => + new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty, + gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList()); + + public async Task GetByIdAsync(Guid id) + { + var gr = await repo.GetByIdAsync(id); + if (gr is null || !gr.IsActive) return null; + return Map(gr); + } + + public async Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => + (await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList(); + + /// + /// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/ + /// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and + /// transitions the Po to PartiallyReceived/Received. All in one unit of work. + /// + public async Task CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default) + { + var po = await poRepo.GetByIdAsync(purchaseOrderId) + ?? throw new InvalidOperationException("Purchase order not found."); + + if (!po.IsActive) + throw new InvalidOperationException("Purchase order not found."); + + if (po.Status is not (PurchaseOrderStatus.Issued or PurchaseOrderStatus.PartiallyReceived)) + throw new InvalidOperationException( + $"Cannot receive goods against a purchase order in {po.Status} status."); + + if (request.Items is null || request.Items.Count == 0) + throw new InvalidOperationException("A goods receipt must have at least one line item."); + + var goodsReceipt = new Models.GoodsReceipt + { + FK_PurchaseOrderId = po.Id, + FK_ReceivedByUserId = receivedByUserId, + FK_WarehouseId = request.WarehouseId, + ReceivedAt = DateTime.Now + }; + + foreach (var line in request.Items) + { + var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive) + ?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po."); + + if (poItem.IsCancelled) + throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock."); + + if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0) + throw new InvalidOperationException("Accepted/rejected quantities cannot be negative."); + + if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity) + throw new InvalidOperationException( + $"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity."); + + poItem.ReceivedQuantity += line.AcceptedQuantity; + + goodsReceipt.Items.Add(new Models.GoodsReceiptItem + { + FK_PurchaseOrderItemId = poItem.Id, + AcceptedQuantity = line.AcceptedQuantity, + RejectedQuantity = line.RejectedQuantity + }); + } + + var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList(); + po.Status = activeLines.Count > 0 && activeLines.All(i => i.ReceivedQuantity >= i.Quantity) + ? PurchaseOrderStatus.Received + : PurchaseOrderStatus.PartiallyReceived; + + await uow.BeginAsync(ct); + try + { + await repo.AddAsync(goodsReceipt); + + foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0)) + { + var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId); + + var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId); + if (balance is null) + { + await stockRepo.AddBalanceAsync(new StockBalance + { + FK_ItemId = poItem.FK_ItemId, + LocationId = request.WarehouseId, + LocationType = LocationType.Warehouse, + Quantity = line.AcceptedQuantity + }); + } + else + { + balance.Quantity += line.AcceptedQuantity; + balance.UpdatedAt = DateTime.Now; + stockRepo.UpdateBalance(balance); + } + + await stockRepo.AddMovementAsync(new StockMovement + { + FK_ItemId = poItem.FK_ItemId, + ToLocationId = request.WarehouseId, + ToLocationType = LocationType.Warehouse, + Quantity = line.AcceptedQuantity, + Type = MovementType.PurchaseReceipt, + ReferenceId = goodsReceipt.Id, + CreatedBy = receivedByUserId + }); + } + + poRepo.Update(po); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + var saved = await repo.GetByIdAsync(goodsReceipt.Id); + return Map(saved!); + } +} diff --git a/Distribution/Features/Grn/IGrnRepository.cs b/Distribution/Features/Grn/IGrnRepository.cs new file mode 100644 index 0000000..2a30abf --- /dev/null +++ b/Distribution/Features/Grn/IGrnRepository.cs @@ -0,0 +1,14 @@ +namespace Distribution.Features.Grn; + +public interface IGrnRepository +{ + Task GetByIdAsync(Guid id); + Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId); + Task AddAsync(Models.GoodsReceipt entity); + + /// True if at least one GoodsReceipt exists against this Po (createPurchaseReturn guard). + Task HasAnyForPurchaseOrderAsync(Guid purchaseOrderId); + + /// Sum of AcceptedQuantity per ItemId across all receipts for a Po. + Task> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId); +}