diff --git a/Distribution/Data/AppDBContext.cs b/Distribution/Data/AppDBContext.cs index 44b39fb..26ba8f7 100644 --- a/Distribution/Data/AppDBContext.cs +++ b/Distribution/Data/AppDBContext.cs @@ -303,6 +303,13 @@ public class AppDBContext(DbContextOptions options) : DbContext(op .HasForeignKey(gr => gr.FK_PurchaseOrderId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(gr => gr.Supplier) + .WithMany() + .HasForeignKey(gr => gr.FK_SupplierId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + modelBuilder.Entity() .HasOne(gr => gr.Warehouse) .WithMany() @@ -321,6 +328,13 @@ public class AppDBContext(DbContextOptions options) : DbContext(op .HasForeignKey(i => i.FK_PurchaseOrderItemId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(i => i.Item) + .WithMany() + .HasForeignKey(i => i.FK_ItemId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + modelBuilder.Entity() .HasIndex(i => i.FK_GoodsReceiptId); diff --git a/Distribution/Features/Grn/GrnController.cs b/Distribution/Features/Grn/GrnController.cs index f87f56a..7f43379 100644 --- a/Distribution/Features/Grn/GrnController.cs +++ b/Distribution/Features/Grn/GrnController.cs @@ -39,6 +39,20 @@ public class GrnController(GrnService service, ICurrentUserService currentUser) catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } } + /// Goods received without a purchase order. + [HttpPost("api/goods-receipts/direct")] + public async Task CreateDirect(CreateDirectGrnRequest request, CancellationToken ct) + { + var userId = currentUser.UserId ?? request.ReceivedByUserId; + if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" }); + try + { + var result = await service.CreateDirectAsync(request, userId.Value, ct); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } + } + [HttpGet("api/purchase-orders/{id:guid}/goods-receipts")] public async Task GetByPurchaseOrder(Guid id) => Ok(await service.GetByPurchaseOrderIdAsync(id)); diff --git a/Distribution/Features/Grn/GrnDTOs.cs b/Distribution/Features/Grn/GrnDTOs.cs index b5d8a02..d4b9692 100644 --- a/Distribution/Features/Grn/GrnDTOs.cs +++ b/Distribution/Features/Grn/GrnDTOs.cs @@ -1,6 +1,19 @@ namespace Distribution.Features.Grn; -public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity); +/// BatchNumber is null for an expiry-only lot (TracksExpiryDate without TracksBatches). +public record GrnBatchLineRequest(string? BatchNumber, DateTime? ExpiryDate, int Quantity); + +public record GrnItemRequest( + // Set for a PO-based receipt; null for a direct receipt (ItemId is used instead). + Guid? PurchaseOrderItemId, + int AcceptedQuantity, + int RejectedQuantity, + // Populated only when the item has TracksBatches and/or TracksExpiryDate set. + List? Batches = null, + // Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries. + List? SerialNumbers = null, + // Set for a direct receipt line (no purchase order). + Guid? ItemId = null); public record CreateGrnRequest( Guid WarehouseId, @@ -15,21 +28,35 @@ public record CreateFlatGrnRequest( // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. Guid? ReceivedByUserId = null); +/// A goods receipt with no purchase order. SupplierId is optional. +public record CreateDirectGrnRequest( + Guid WarehouseId, + List Items, + Guid? SupplierId = null, + // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. + Guid? ReceivedByUserId = null); + +public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity); + public record GrnItemResponse( Guid Id, - Guid PurchaseOrderItemId, + Guid? PurchaseOrderItemId, Guid ItemId, string Sku, string ItemName, int AcceptedQuantity, - int RejectedQuantity); + int RejectedQuantity, + List Batches, + List SerialNumbers); public record GrnResponse( Guid Id, - Guid PurchaseOrderId, + Guid? PurchaseOrderId, Guid ReceivedByUserId, Guid WarehouseId, string WarehouseName, + Guid? SupplierId, + string? SupplierName, DateTime ReceivedAt, DateTime CreatedAt, List Items); diff --git a/Distribution/Features/Grn/GrnRepository.cs b/Distribution/Features/Grn/GrnRepository.cs index 22cc657..01181f7 100644 --- a/Distribution/Features/Grn/GrnRepository.cs +++ b/Distribution/Features/Grn/GrnRepository.cs @@ -8,13 +8,21 @@ 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) + .Include(gr => gr.Supplier) + .Include(gr => gr.Items).ThenInclude(i => i.Item) + .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item) + .Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch) + .Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber) .FirstOrDefaultAsync(gr => gr.Id == id); public async Task> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) => await context.GoodsReceipts .Include(gr => gr.Warehouse) - .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item) + .Include(gr => gr.Supplier) + .Include(gr => gr.Items).ThenInclude(i => i.Item) + .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item) + .Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch) + .Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber) .Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId)) .OrderByDescending(gr => gr.ReceivedAt) .Skip((page - 1) * pageSize) @@ -29,7 +37,11 @@ public class GrnRepository(AppDBContext context) : IGrnRepository 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) + .Include(gr => gr.Supplier) + .Include(gr => gr.Items).ThenInclude(i => i.Item) + .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item) + .Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch) + .Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber) .Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive) .OrderByDescending(gr => gr.ReceivedAt) .ToListAsync(); @@ -43,8 +55,8 @@ public class GrnRepository(AppDBContext context) : IGrnRepository 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) + .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 index 10bae5f..dd5df45 100644 --- a/Distribution/Features/Grn/GrnService.cs +++ b/Distribution/Features/Grn/GrnService.cs @@ -9,14 +9,23 @@ public class GrnService( IGrnRepository repo, IPoRepository poRepo, IStockRepository stockRepo, - IUnitOfWork uow) + IUnitOfWork uow, + Distribution.Features.Item.IItemRepository itemRepo, + Distribution.Features.Supplier.ISupplierRepository supplierRepo) { - 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 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) @@ -36,10 +45,60 @@ public class GrnService( 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), and - /// transitions the Po to PartiallyReceived/Received. All in one unit of work. + /// 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) { @@ -64,8 +123,13 @@ public class GrnService( 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."); @@ -79,14 +143,27 @@ public class GrnService( 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; - goodsReceipt.Items.Add(new Models.GoodsReceiptItem + 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(); @@ -94,48 +171,230 @@ public class GrnService( ? 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 line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0)) + foreach (var (entity, line, item) in grLines.Where(x => x.Line.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) + if (item.TracksSerialNumbers) { - await stockRepo.AddBalanceAsync(new StockBalance + foreach (var serialNumber in line.SerialNumbers!) { - FK_ItemId = poItem.FK_ItemId, - LocationId = request.WarehouseId, - LocationType = LocationType.Warehouse, - Quantity = line.AcceptedQuantity - }); + 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 { - balance.Quantity += line.AcceptedQuantity; - balance.UpdatedAt = DateTime.Now; - stockRepo.UpdateBalance(balance); - } + 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 = 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 - }); + 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 + }); + } } - poRepo.Update(po); + if (po is not null) poRepo.Update(po); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } diff --git a/Distribution/Models/GoodsReceipt.cs b/Distribution/Models/GoodsReceipt.cs index 4ce1368..cee1549 100644 --- a/Distribution/Models/GoodsReceipt.cs +++ b/Distribution/Models/GoodsReceipt.cs @@ -4,9 +4,15 @@ namespace Distribution.Models { public class GoodsReceipt : Template { - public Guid FK_PurchaseOrderId { get; set; } + /// Null for a direct receipt (goods received without a purchase order). + public Guid? FK_PurchaseOrderId { get; set; } [ForeignKey("FK_PurchaseOrderId")] - public PurchaseOrder PurchaseOrder { get; set; } + public PurchaseOrder? PurchaseOrder { get; set; } + + /// Optional supplier for a direct receipt; for PO receipts the supplier comes from the PO. + public Guid? FK_SupplierId { get; set; } + [ForeignKey("FK_SupplierId")] + public Supplier? Supplier { get; set; } public Guid FK_ReceivedByUserId { get; set; } diff --git a/Distribution/Models/GoodsReceiptItem.cs b/Distribution/Models/GoodsReceiptItem.cs index 7a04ebb..b905f69 100644 --- a/Distribution/Models/GoodsReceiptItem.cs +++ b/Distribution/Models/GoodsReceiptItem.cs @@ -8,14 +8,26 @@ namespace Distribution.Models [ForeignKey("FK_GoodsReceiptId")] public GoodsReceipt GoodsReceipt { get; set; } - public Guid FK_PurchaseOrderItemId { get; set; } + /// Null for a direct receipt line (no purchase order); FK_ItemId is set instead. + public Guid? FK_PurchaseOrderItemId { get; set; } [ForeignKey("FK_PurchaseOrderItemId")] - public PurchaseOrderItem PurchaseOrderItem { get; set; } + public PurchaseOrderItem? PurchaseOrderItem { get; set; } + + /// Set for direct receipt lines. PO-based lines resolve the item through PurchaseOrderItem. + public Guid? FK_ItemId { get; set; } + [ForeignKey("FK_ItemId")] + public Item? Item { get; set; } /// Added to warehouse stock. public int AcceptedQuantity { get; set; } /// Damaged/short at receipt, not added to stock. public int RejectedQuantity { get; set; } = 0; + + /// Populated when the received item tracks batches and/or expiry dates. + public ICollection Batches { get; set; } = []; + + /// Populated when the received item tracks serial numbers. + public ICollection Serials { get; set; } = []; } } diff --git a/Distribution/Models/GoodsReceiptItemBatch.cs b/Distribution/Models/GoodsReceiptItemBatch.cs new file mode 100644 index 0000000..2d0ae9d --- /dev/null +++ b/Distribution/Models/GoodsReceiptItemBatch.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Distribution.Models +{ + /// One row per batch/expiry-lot split within a single GRN line. + public class GoodsReceiptItemBatch : Template + { + public Guid FK_GoodsReceiptItemId { get; set; } + [ForeignKey("FK_GoodsReceiptItemId")] + public GoodsReceiptItem GoodsReceiptItem { get; set; } + + public Guid FK_ItemBatchId { get; set; } + [ForeignKey("FK_ItemBatchId")] + public ItemBatch ItemBatch { get; set; } + + public int Quantity { get; set; } + } +} diff --git a/Distribution/Models/GoodsReceiptItemSerial.cs b/Distribution/Models/GoodsReceiptItemSerial.cs new file mode 100644 index 0000000..62d7180 --- /dev/null +++ b/Distribution/Models/GoodsReceiptItemSerial.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Distribution.Models +{ + /// One row per serial number received within a single GRN line. + public class GoodsReceiptItemSerial : Template + { + public Guid FK_GoodsReceiptItemId { get; set; } + [ForeignKey("FK_GoodsReceiptItemId")] + public GoodsReceiptItem GoodsReceiptItem { get; set; } + + public Guid FK_ItemSerialNumberId { get; set; } + [ForeignKey("FK_ItemSerialNumberId")] + public ItemSerialNumber ItemSerialNumber { get; set; } + } +}