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> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) { var total = await repo.GetCountAsync(purchaseOrderId); var items = await repo.GetAllAsync(purchaseOrderId, page, pageSize); return new PagedResponse(page, pageSize, total, items.Select(Map).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, FromLocationId = StockService.ExternalLocationId, FromLocationType = LocationType.External, 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!); } }