Add GRN batch and serial tracking

This commit is contained in:
2026-10-06 13:26:17 +05:30
parent d578cbad20
commit a8725bc473
9 changed files with 427 additions and 49 deletions
+14
View File
@@ -303,6 +303,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(gr => gr.FK_PurchaseOrderId) .HasForeignKey(gr => gr.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Supplier)
.WithMany()
.HasForeignKey(gr => gr.FK_SupplierId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<GoodsReceipt>() modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Warehouse) .HasOne(gr => gr.Warehouse)
.WithMany() .WithMany()
@@ -321,6 +328,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_PurchaseOrderItemId) .HasForeignKey(i => i.FK_PurchaseOrderItemId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<GoodsReceiptItem>() modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId); .HasIndex(i => i.FK_GoodsReceiptId);
@@ -39,6 +39,20 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
} }
/// <summary>Goods received without a purchase order.</summary>
[HttpPost("api/goods-receipts/direct")]
public async Task<IActionResult> 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")] [HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) => public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id)); Ok(await service.GetByPurchaseOrderIdAsync(id));
+31 -4
View File
@@ -1,6 +1,19 @@
namespace Distribution.Features.Grn; namespace Distribution.Features.Grn;
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity); /// <summary>BatchNumber is null for an expiry-only lot (TracksExpiryDate without TracksBatches).</summary>
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<GrnBatchLineRequest>? Batches = null,
// Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries.
List<string>? SerialNumbers = null,
// Set for a direct receipt line (no purchase order).
Guid? ItemId = null);
public record CreateGrnRequest( public record CreateGrnRequest(
Guid WarehouseId, 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. // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null); Guid? ReceivedByUserId = null);
/// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary>
public record CreateDirectGrnRequest(
Guid WarehouseId,
List<GrnItemRequest> 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( public record GrnItemResponse(
Guid Id, Guid Id,
Guid PurchaseOrderItemId, Guid? PurchaseOrderItemId,
Guid ItemId, Guid ItemId,
string Sku, string Sku,
string ItemName, string ItemName,
int AcceptedQuantity, int AcceptedQuantity,
int RejectedQuantity); int RejectedQuantity,
List<GrnBatchLineResponse> Batches,
List<string> SerialNumbers);
public record GrnResponse( public record GrnResponse(
Guid Id, Guid Id,
Guid PurchaseOrderId, Guid? PurchaseOrderId,
Guid ReceivedByUserId, Guid ReceivedByUserId,
Guid WarehouseId, Guid WarehouseId,
string WarehouseName, string WarehouseName,
Guid? SupplierId,
string? SupplierName,
DateTime ReceivedAt, DateTime ReceivedAt,
DateTime CreatedAt, DateTime CreatedAt,
List<GrnItemResponse> Items); List<GrnItemResponse> Items);
+17 -5
View File
@@ -8,13 +8,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) => public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
await context.GoodsReceipts await context.GoodsReceipts
.Include(gr => gr.Warehouse) .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); .FirstOrDefaultAsync(gr => gr.Id == id);
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) => public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
await context.GoodsReceipts await context.GoodsReceipts
.Include(gr => gr.Warehouse) .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)) .Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
.OrderByDescending(gr => gr.ReceivedAt) .OrderByDescending(gr => gr.ReceivedAt)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
@@ -29,7 +37,11 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.GoodsReceipts await context.GoodsReceipts
.Include(gr => gr.Warehouse) .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) .Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
.OrderByDescending(gr => gr.ReceivedAt) .OrderByDescending(gr => gr.ReceivedAt)
.ToListAsync(); .ToListAsync();
@@ -43,8 +55,8 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) => public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
await context.GoodsReceiptItems await context.GoodsReceiptItems
.Include(i => i.PurchaseOrderItem) .Include(i => i.PurchaseOrderItem)
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive) .Where(i => i.PurchaseOrderItem!.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId) .GroupBy(i => i.PurchaseOrderItem!.FK_ItemId)
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) }) .Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
.ToDictionaryAsync(x => x.ItemId, x => x.Total); .ToDictionaryAsync(x => x.ItemId, x => x.Total);
} }
+275 -16
View File
@@ -9,14 +9,23 @@ public class GrnService(
IGrnRepository repo, IGrnRepository repo,
IPoRepository poRepo, IPoRepository poRepo,
IStockRepository stockRepo, IStockRepository stockRepo,
IUnitOfWork uow) IUnitOfWork uow,
Distribution.Features.Item.IItemRepository itemRepo,
Distribution.Features.Supplier.ISupplierRepository supplierRepo)
{ {
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) => 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); // 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) => private static GrnResponse Map(Models.GoodsReceipt gr) =>
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty, 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()); gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
@@ -36,10 +45,60 @@ public class GrnService(
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList(); (await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
/// <summary>
/// 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).
/// </summary>
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.");
}
}
/// <summary> /// <summary>
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/ /// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and /// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), creates
/// transitions the Po to PartiallyReceived/Received. All in one unit of work. /// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to
/// PartiallyReceived/Received. All in one unit of work.
/// </summary> /// </summary>
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default) public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
{ {
@@ -64,8 +123,13 @@ public class GrnService(
ReceivedAt = DateTime.Now ReceivedAt = DateTime.Now
}; };
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
foreach (var line in request.Items) 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) 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."); ?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
@@ -79,14 +143,27 @@ public class GrnService(
throw new InvalidOperationException( throw new InvalidOperationException(
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity."); $"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; poItem.ReceivedQuantity += line.AcceptedQuantity;
goodsReceipt.Items.Add(new Models.GoodsReceiptItem var entity = new Models.GoodsReceiptItem
{ {
FK_PurchaseOrderItemId = poItem.Id, FK_PurchaseOrderItemId = poItem.Id,
AcceptedQuantity = line.AcceptedQuantity, AcceptedQuantity = line.AcceptedQuantity,
RejectedQuantity = line.RejectedQuantity RejectedQuantity = line.RejectedQuantity
}); };
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, poItem.Item));
} }
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList(); var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
@@ -94,22 +171,203 @@ public class GrnService(
? PurchaseOrderStatus.Received ? PurchaseOrderStatus.Received
: PurchaseOrderStatus.PartiallyReceived; : PurchaseOrderStatus.PartiallyReceived;
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, po, ct);
}
/// <summary>
/// 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.
/// </summary>
public async Task<GrnResponse> 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);
}
/// <summary>
/// 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.
/// </summary>
private async Task<GrnResponse> 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); await uow.BeginAsync(ct);
try try
{ {
await repo.AddAsync(goodsReceipt); 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); 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
};
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId); // 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) if (balance is null)
{ {
await stockRepo.AddBalanceAsync(new StockBalance await stockRepo.AddBalanceAsync(new StockBalance
{ {
FK_ItemId = poItem.FK_ItemId, FK_ItemId = item.Id,
LocationId = request.WarehouseId, 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, LocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity Quantity = line.AcceptedQuantity
}); });
@@ -123,10 +381,10 @@ public class GrnService(
await stockRepo.AddMovementAsync(new StockMovement await stockRepo.AddMovementAsync(new StockMovement
{ {
FK_ItemId = poItem.FK_ItemId, FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId, FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External, FromLocationType = LocationType.External,
ToLocationId = request.WarehouseId, ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse, ToLocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity, Quantity = line.AcceptedQuantity,
Type = MovementType.PurchaseReceipt, Type = MovementType.PurchaseReceipt,
@@ -134,8 +392,9 @@ public class GrnService(
CreatedBy = receivedByUserId CreatedBy = receivedByUserId
}); });
} }
}
poRepo.Update(po); if (po is not null) poRepo.Update(po);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
+8 -2
View File
@@ -4,9 +4,15 @@ namespace Distribution.Models
{ {
public class GoodsReceipt : Template public class GoodsReceipt : Template
{ {
public Guid FK_PurchaseOrderId { get; set; } /// <summary>Null for a direct receipt (goods received without a purchase order).</summary>
public Guid? FK_PurchaseOrderId { get; set; }
[ForeignKey("FK_PurchaseOrderId")] [ForeignKey("FK_PurchaseOrderId")]
public PurchaseOrder PurchaseOrder { get; set; } public PurchaseOrder? PurchaseOrder { get; set; }
/// <summary>Optional supplier for a direct receipt; for PO receipts the supplier comes from the PO.</summary>
public Guid? FK_SupplierId { get; set; }
[ForeignKey("FK_SupplierId")]
public Supplier? Supplier { get; set; }
public Guid FK_ReceivedByUserId { get; set; } public Guid FK_ReceivedByUserId { get; set; }
+14 -2
View File
@@ -8,14 +8,26 @@ namespace Distribution.Models
[ForeignKey("FK_GoodsReceiptId")] [ForeignKey("FK_GoodsReceiptId")]
public GoodsReceipt GoodsReceipt { get; set; } public GoodsReceipt GoodsReceipt { get; set; }
public Guid FK_PurchaseOrderItemId { get; set; } /// <summary>Null for a direct receipt line (no purchase order); FK_ItemId is set instead.</summary>
public Guid? FK_PurchaseOrderItemId { get; set; }
[ForeignKey("FK_PurchaseOrderItemId")] [ForeignKey("FK_PurchaseOrderItemId")]
public PurchaseOrderItem PurchaseOrderItem { get; set; } public PurchaseOrderItem? PurchaseOrderItem { get; set; }
/// <summary>Set for direct receipt lines. PO-based lines resolve the item through PurchaseOrderItem.</summary>
public Guid? FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item? Item { get; set; }
/// <summary>Added to warehouse stock.</summary> /// <summary>Added to warehouse stock.</summary>
public int AcceptedQuantity { get; set; } public int AcceptedQuantity { get; set; }
/// <summary>Damaged/short at receipt, not added to stock.</summary> /// <summary>Damaged/short at receipt, not added to stock.</summary>
public int RejectedQuantity { get; set; } = 0; public int RejectedQuantity { get; set; } = 0;
/// <summary>Populated when the received item tracks batches and/or expiry dates.</summary>
public ICollection<GoodsReceiptItemBatch> Batches { get; set; } = [];
/// <summary>Populated when the received item tracks serial numbers.</summary>
public ICollection<GoodsReceiptItemSerial> Serials { get; set; } = [];
} }
} }
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
/// <summary>One row per batch/expiry-lot split within a single GRN line.</summary>
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; }
}
}
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
/// <summary>One row per serial number received within a single GRN line.</summary>
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; }
}
}