Dev #9

Merged
DeepnaPooja merged 10 commits from dev into production 2026-08-24 16:59:34 +05:30
5 changed files with 211 additions and 0 deletions
Showing only changes of commit 81a49e1a98 - Show all commits
@@ -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!);
}
}