Add GRN batch and serial tracking
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,48 +171,230 @@ 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)
|
||||||
|
|
||||||
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
|
|
||||||
if (balance is null)
|
|
||||||
{
|
{
|
||||||
await stockRepo.AddBalanceAsync(new StockBalance
|
foreach (var serialNumber in line.SerialNumbers!)
|
||||||
{
|
{
|
||||||
FK_ItemId = poItem.FK_ItemId,
|
var serial = new ItemSerialNumber
|
||||||
LocationId = request.WarehouseId,
|
{
|
||||||
LocationType = LocationType.Warehouse,
|
FK_ItemId = item.Id,
|
||||||
Quantity = line.AcceptedQuantity
|
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
|
else
|
||||||
{
|
{
|
||||||
balance.Quantity += line.AcceptedQuantity;
|
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId);
|
||||||
balance.UpdatedAt = DateTime.Now;
|
if (balance is null)
|
||||||
stockRepo.UpdateBalance(balance);
|
{
|
||||||
}
|
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
|
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,
|
||||||
ReferenceId = goodsReceipt.Id,
|
ReferenceId = goodsReceipt.Id,
|
||||||
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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user