406 lines
19 KiB
C#
406 lines
19 KiB
C#
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<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
|
{
|
|
var total = await repo.GetCountAsync(purchaseOrderId);
|
|
var items = await repo.GetAllAsync(purchaseOrderId, page, pageSize);
|
|
return new PagedResponse<GrnResponse>(page, pageSize, total, items.Select(Map).ToList());
|
|
}
|
|
|
|
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var gr = await repo.GetByIdAsync(id);
|
|
if (gr is null || !gr.IsActive) return null;
|
|
return Map(gr);
|
|
}
|
|
|
|
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
|
(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>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<GrnResponse> 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);
|
|
}
|
|
/// <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);
|
|
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!);
|
|
}
|
|
}
|