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, Distribution.Features.Item.IItemRepository itemRepo, Distribution.Features.Supplier.ISupplierRepository supplierRepo) { private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) { // Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line. var item = i.Item ?? i.PurchaseOrderItem!.Item; return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name, i.AcceptedQuantity, i.RejectedQuantity, i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(), i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList()); } 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.FK_SupplierId, gr.Supplier?.Name, 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 that a GRN line's batch/serial payload matches the item's tracking flags. When an /// item tracks both serial numbers and batches/expiry, serial tracking takes precedence for /// stock-ledger granularity in this phase — batch/expiry metadata for that combination is not /// yet linked into the ledger (follow-up work). /// private static void ValidateLineShape(Models.Item item, GrnItemRequest line) { if (item.TracksSerialNumbers) { if (line.AcceptedQuantity > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != line.AcceptedQuantity)) throw new InvalidOperationException( $"Item {item.Sku} tracks serial numbers — expected {line.AcceptedQuantity} serial number(s)."); if (line.SerialNumbers is { Count: > 0 } && line.SerialNumbers.Distinct().Count() != line.SerialNumbers.Count) throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}."); } else if (line.SerialNumbers is { Count: > 0 }) { throw new InvalidOperationException($"Item {item.Sku} does not track serial numbers."); } else if (item.TracksBatches || item.TracksExpiryDate) { if (line.AcceptedQuantity > 0) { if (line.Batches is null || line.Batches.Count == 0) throw new InvalidOperationException($"Item {item.Sku} requires batch/expiry information."); if (line.Batches.Sum(b => b.Quantity) != line.AcceptedQuantity) throw new InvalidOperationException($"Batch quantities for item {item.Sku} must sum to the accepted quantity."); } foreach (var b in line.Batches ?? []) { if (b.Quantity <= 0) throw new InvalidOperationException($"Batch quantity for item {item.Sku} must be positive."); if (item.TracksBatches && string.IsNullOrWhiteSpace(b.BatchNumber)) throw new InvalidOperationException($"Item {item.Sku} requires a batch number."); if (!item.TracksBatches && !string.IsNullOrWhiteSpace(b.BatchNumber)) throw new InvalidOperationException($"Item {item.Sku} does not track batch numbers."); if (item.TracksExpiryDate && b.ExpiryDate is null) throw new InvalidOperationException($"Item {item.Sku} requires an expiry date."); } } else if (line.Batches is { Count: > 0 }) { throw new InvalidOperationException($"Item {item.Sku} does not track batches or expiry dates."); } } /// /// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/ /// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), creates /// ItemBatch/ItemSerialNumber records for tracked items, 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 }; var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>(); foreach (var line in request.Items) { if (line.PurchaseOrderItemId is null) throw new InvalidOperationException("Each line of a PO receipt needs a PurchaseOrderItemId."); 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."); ValidateLineShape(poItem.Item, line); if (poItem.Item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 }) { foreach (var serialNumber in line.SerialNumbers) { if (await stockRepo.SerialNumberExistsAsync(poItem.FK_ItemId, serialNumber)) throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {poItem.Item.Sku}."); } } poItem.ReceivedQuantity += line.AcceptedQuantity; var entity = new Models.GoodsReceiptItem { FK_PurchaseOrderItemId = poItem.Id, AcceptedQuantity = line.AcceptedQuantity, RejectedQuantity = line.RejectedQuantity }; goodsReceipt.Items.Add(entity); grLines.Add((entity, line, poItem.Item)); } 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; return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, po, ct); } /// /// A receipt with no purchase order: lines name the item directly. Stock, serial and batch /// handling is identical to a PO receipt; there is no PO quantity or status to update. /// public async Task CreateDirectAsync(CreateDirectGrnRequest request, Guid receivedByUserId, CancellationToken ct = default) { if (request.Items is null || request.Items.Count == 0) throw new InvalidOperationException("A goods receipt must have at least one line item."); if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true }) throw new InvalidOperationException("Supplier not found."); var goodsReceipt = new Models.GoodsReceipt { FK_PurchaseOrderId = null, FK_SupplierId = request.SupplierId, FK_ReceivedByUserId = receivedByUserId, FK_WarehouseId = request.WarehouseId, ReceivedAt = DateTime.Now }; var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>(); var serialsInRequest = new HashSet<(Guid ItemId, string Serial)>(); foreach (var line in request.Items) { if (line.ItemId is null) throw new InvalidOperationException("Each line of a direct receipt needs an ItemId."); var item = await itemRepo.GetByIdAsync(line.ItemId.Value); if (item is null || !item.IsActive) throw new InvalidOperationException($"Item {line.ItemId} not found."); if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0) throw new InvalidOperationException("Accepted/rejected quantities cannot be negative."); if (line.AcceptedQuantity + line.RejectedQuantity == 0) throw new InvalidOperationException($"Line for item {item.Sku} has no quantity."); ValidateLineShape(item, line); if (item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 }) { foreach (var serialNumber in line.SerialNumbers) { if (!serialsInRequest.Add((item.Id, serialNumber))) throw new InvalidOperationException($"Serial number '{serialNumber}' appears on more than one line for item {item.Sku}."); if (await stockRepo.SerialNumberExistsAsync(item.Id, serialNumber)) throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {item.Sku}."); } } var entity = new Models.GoodsReceiptItem { FK_ItemId = item.Id, AcceptedQuantity = line.AcceptedQuantity, RejectedQuantity = line.RejectedQuantity }; goodsReceipt.Items.Add(entity); grLines.Add((entity, line, item)); } return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, null, ct); } /// /// Writes the receipt, its serials/batches, the stock balances and movements, and (for a PO /// receipt) the PO update in one unit of work. Shared by PO-based and direct receipts. /// private async Task PostReceiptAsync( Models.GoodsReceipt goodsReceipt, List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)> grLines, Guid warehouseId, Guid receivedByUserId, Models.PurchaseOrder? po, CancellationToken ct) { await uow.BeginAsync(ct); try { await repo.AddAsync(goodsReceipt); foreach (var (entity, line, item) in grLines.Where(x => x.Line.AcceptedQuantity > 0)) { if (item.TracksSerialNumbers) { foreach (var serialNumber in line.SerialNumbers!) { var serial = new ItemSerialNumber { FK_ItemId = item.Id, SerialNumber = serialNumber, Status = SerialStatus.InStock, CurrentLocationId = warehouseId, CurrentLocationType = LocationType.Warehouse, ReceivedAt = goodsReceipt.ReceivedAt }; // Warranty is a customer-facing promise: the clock starts when the serial is sold, // not at receipt. Only snapshot the item's custom period here; WarrantyExpiresAt // stays null until the sale/delivery flow sets it. if (item.HasWarranty && item.AllowCustomWarrantyPeriod) serial.WarrantyPeriodDaysOverride = item.WarrantyPeriodDays; await stockRepo.AddItemSerialNumberAsync(serial); entity.Serials.Add(new GoodsReceiptItemSerial { FK_GoodsReceiptItemId = entity.Id, ItemSerialNumber = serial }); await stockRepo.AddBalanceAsync(new StockBalance { FK_ItemId = item.Id, LocationId = warehouseId, LocationType = LocationType.Warehouse, FK_ItemSerialNumberId = serial.Id, Quantity = 1 }); await stockRepo.AddMovementAsync(new StockMovement { FK_ItemId = item.Id, FromLocationId = StockService.ExternalLocationId, FromLocationType = LocationType.External, ToLocationId = warehouseId, ToLocationType = LocationType.Warehouse, FK_ItemSerialNumberId = serial.Id, Quantity = 1, Type = MovementType.PurchaseReceipt, ReferenceId = goodsReceipt.Id, CreatedBy = receivedByUserId }); } } else if (item.TracksBatches || item.TracksExpiryDate) { foreach (var b in line.Batches!) { var batch = !string.IsNullOrWhiteSpace(b.BatchNumber) ? await stockRepo.FindBatchAsync(item.Id, b.BatchNumber) : null; if (batch is null) { batch = new ItemBatch { FK_ItemId = item.Id, BatchNumber = b.BatchNumber, ExpiryDate = b.ExpiryDate }; await stockRepo.AddItemBatchAsync(batch); } entity.Batches.Add(new GoodsReceiptItemBatch { FK_GoodsReceiptItemId = entity.Id, ItemBatch = batch, Quantity = b.Quantity }); var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId, batch.Id, null); if (balance is null) { await stockRepo.AddBalanceAsync(new StockBalance { FK_ItemId = item.Id, LocationId = warehouseId, LocationType = LocationType.Warehouse, FK_ItemBatchId = batch.Id, Quantity = b.Quantity }); } else { balance.Quantity += b.Quantity; balance.UpdatedAt = DateTime.Now; stockRepo.UpdateBalance(balance); } await stockRepo.AddMovementAsync(new StockMovement { FK_ItemId = item.Id, FromLocationId = StockService.ExternalLocationId, FromLocationType = LocationType.External, ToLocationId = warehouseId, ToLocationType = LocationType.Warehouse, FK_ItemBatchId = batch.Id, Quantity = b.Quantity, Type = MovementType.PurchaseReceipt, ReferenceId = goodsReceipt.Id, CreatedBy = receivedByUserId }); } } else { var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId); if (balance is null) { await stockRepo.AddBalanceAsync(new StockBalance { FK_ItemId = item.Id, LocationId = 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 = item.Id, FromLocationId = StockService.ExternalLocationId, FromLocationType = LocationType.External, ToLocationId = warehouseId, ToLocationType = LocationType.Warehouse, Quantity = line.AcceptedQuantity, Type = MovementType.PurchaseReceipt, ReferenceId = goodsReceipt.Id, CreatedBy = receivedByUserId }); } } if (po is not null) poRepo.Update(po); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } var saved = await repo.GetByIdAsync(goodsReceipt.Id); return Map(saved!); } }