Add purchase return feature (returns received stock to a supplier)
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public interface IPurchaseReturnRepository
|
||||||
|
{
|
||||||
|
Task<Models.PurchaseReturn?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||||
|
Task AddAsync(Models.PurchaseReturn entity);
|
||||||
|
|
||||||
|
/// <summary>Sum of Quantity per ItemId across all prior returns for a Po.</summary>
|
||||||
|
Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/purchase-returns/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/purchase-orders/{id:guid}/returns")]
|
||||||
|
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||||
|
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||||
|
|
||||||
|
[HttpPost("api/purchase-orders/{id:guid}/returns")]
|
||||||
|
public async Task<IActionResult> Create(Guid id, CreatePurchaseReturnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(id, request, userId, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public record PurchaseReturnItemRequest(Guid ItemId, int Quantity);
|
||||||
|
|
||||||
|
public record CreatePurchaseReturnRequest(string Reason, List<PurchaseReturnItemRequest> Items);
|
||||||
|
|
||||||
|
public record PurchaseReturnItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, int Quantity);
|
||||||
|
|
||||||
|
public record PurchaseReturnResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid PurchaseOrderId,
|
||||||
|
string Reason,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<PurchaseReturnItemResponse> Items);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public class PurchaseReturnRepository(AppDBContext context) : IPurchaseReturnRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.PurchaseReturn?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.PurchaseReturns
|
||||||
|
.Include(pr => pr.Items).ThenInclude(i => i.Item)
|
||||||
|
.FirstOrDefaultAsync(pr => pr.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.PurchaseReturns
|
||||||
|
.Include(pr => pr.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(pr => pr.FK_PurchaseOrderId == purchaseOrderId && pr.IsActive)
|
||||||
|
.OrderByDescending(pr => pr.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.PurchaseReturn entity) =>
|
||||||
|
await context.PurchaseReturns.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.PurchaseReturnItems
|
||||||
|
.Include(i => i.PurchaseReturn)
|
||||||
|
.Where(i => i.PurchaseReturn.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
||||||
|
.GroupBy(i => i.FK_ItemId)
|
||||||
|
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.Quantity) })
|
||||||
|
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Features.Po;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public class PurchaseReturnService(
|
||||||
|
IPurchaseReturnRepository repo,
|
||||||
|
IPoRepository poRepo,
|
||||||
|
IGrnRepository goodsReceiptRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) =>
|
||||||
|
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity);
|
||||||
|
|
||||||
|
private static PurchaseReturnResponse Map(Models.PurchaseReturn pr) =>
|
||||||
|
new(pr.Id, pr.FK_PurchaseOrderId, pr.Reason, pr.IsActive, pr.CreatedAt,
|
||||||
|
pr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<PurchaseReturnResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var pr = await repo.GetByIdAsync(id);
|
||||||
|
if (pr is null || !pr.IsActive) return null;
|
||||||
|
return Map(pr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PurchaseReturnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requires at least one prior GoodsReceipt against the Po; return quantities per item cannot
|
||||||
|
/// exceed the received-but-not-already-returned quantity for that item. Decrements StockBalance
|
||||||
|
/// and writes a StockMovement in the same unit of work.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PurchaseReturnResponse> CreateAsync(Guid purchaseOrderId, CreatePurchaseReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await poRepo.GetByIdAsync(purchaseOrderId);
|
||||||
|
if (po is null || !po.IsActive)
|
||||||
|
throw new InvalidOperationException("Purchase order not found.");
|
||||||
|
|
||||||
|
if (!await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(purchaseOrderId))
|
||||||
|
throw new InvalidOperationException("Cannot return items for a purchase order with no goods receipt against it.");
|
||||||
|
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A purchase return must have at least one line item.");
|
||||||
|
|
||||||
|
var receivedTotals = await goodsReceiptRepo.GetAcceptedTotalsByItemAsync(purchaseOrderId);
|
||||||
|
var returnedTotals = await repo.GetReturnedTotalsByItemAsync(purchaseOrderId);
|
||||||
|
|
||||||
|
// The receipt(s) for this Po share a single receiving warehouse per the current model
|
||||||
|
// (each GoodsReceipt carries FK_WarehouseId); use the most recent receipt's warehouse
|
||||||
|
// as the stock-decrement location for the return.
|
||||||
|
var receipts = await goodsReceiptRepo.GetByPurchaseOrderIdAsync(purchaseOrderId);
|
||||||
|
var warehouseId = receipts.First().FK_WarehouseId;
|
||||||
|
|
||||||
|
var purchaseReturn = new Models.PurchaseReturn
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderId = purchaseOrderId,
|
||||||
|
Reason = request.Reason
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
if (line.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Return quantity must be greater than zero.");
|
||||||
|
|
||||||
|
receivedTotals.TryGetValue(line.ItemId, out var received);
|
||||||
|
returnedTotals.TryGetValue(line.ItemId, out var alreadyReturned);
|
||||||
|
var available = received - alreadyReturned;
|
||||||
|
|
||||||
|
if (line.Quantity > available)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Return quantity for item {line.ItemId} ({line.Quantity}) exceeds the received-but-not-already-returned quantity ({available}).");
|
||||||
|
|
||||||
|
purchaseReturn.Items.Add(new Models.PurchaseReturnItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(purchaseReturn);
|
||||||
|
|
||||||
|
foreach (var line in purchaseReturn.Items)
|
||||||
|
{
|
||||||
|
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, warehouseId)
|
||||||
|
?? throw new InvalidOperationException($"No stock balance found for item {line.FK_ItemId} at the receiving warehouse.");
|
||||||
|
|
||||||
|
if (balance.Quantity < line.Quantity)
|
||||||
|
throw new InvalidOperationException($"Insufficient warehouse stock for item {line.FK_ItemId} to return.");
|
||||||
|
|
||||||
|
balance.Quantity -= line.Quantity;
|
||||||
|
balance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(balance);
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = line.FK_ItemId,
|
||||||
|
FromLocationId = warehouseId,
|
||||||
|
FromLocationType = LocationType.Warehouse,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
Type = MovementType.PurchaseReturn,
|
||||||
|
ReasonCode = request.Reason,
|
||||||
|
ReferenceId = purchaseReturn.Id,
|
||||||
|
CreatedBy = createdByUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(purchaseReturn.Id);
|
||||||
|
return Map(saved!);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user