147 lines
6.2 KiB
C#
147 lines
6.2 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)
|
|
{
|
|
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 GrnResponse Map(Models.GoodsReceipt gr) =>
|
|
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
|
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 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.
|
|
/// </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
|
|
};
|
|
|
|
foreach (var line in request.Items)
|
|
{
|
|
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.");
|
|
|
|
poItem.ReceivedQuantity += line.AcceptedQuantity;
|
|
|
|
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
|
|
{
|
|
FK_PurchaseOrderItemId = poItem.Id,
|
|
AcceptedQuantity = line.AcceptedQuantity,
|
|
RejectedQuantity = line.RejectedQuantity
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await repo.AddAsync(goodsReceipt);
|
|
|
|
foreach (var line in goodsReceipt.Items.Where(i => i.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)
|
|
{
|
|
await stockRepo.AddBalanceAsync(new StockBalance
|
|
{
|
|
FK_ItemId = poItem.FK_ItemId,
|
|
LocationId = request.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
|
|
});
|
|
}
|
|
|
|
poRepo.Update(po);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
|
return Map(saved!);
|
|
}
|
|
}
|