Add returns and claims
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Claims;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/claims/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("api/claims")]
|
||||
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("api/claims/{id:guid}/settle")]
|
||||
[Authorize(Policy = "claims.settle")]
|
||||
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.SettleAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Claims;
|
||||
|
||||
public record CreateClaimRequest(Guid AgencyUserId, string Details);
|
||||
|
||||
public record SettleClaimRequest(decimal SettlementAmount);
|
||||
|
||||
public record ClaimResponse(
|
||||
Guid Id,
|
||||
Guid AgencyUserId,
|
||||
string Details,
|
||||
ClaimStatus Status,
|
||||
decimal? SettlementAmount,
|
||||
DateTime? SettledAt,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,22 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Claims;
|
||||
|
||||
public class ClaimRepository(AppDBContext context) : IClaimRepository
|
||||
{
|
||||
public async Task<Models.Claim?> GetByIdAsync(Guid id) =>
|
||||
await context.Claims.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
public async Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
|
||||
await context.Claims
|
||||
.Where(c => c.FK_AgencyUserId == agencyUserId && c.IsActive)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.Claim entity) =>
|
||||
await context.Claims.AddAsync(entity);
|
||||
|
||||
public void Update(Models.Claim entity) =>
|
||||
context.Claims.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Claims;
|
||||
|
||||
public class ClaimService(IClaimRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
private static ClaimResponse Map(Claim c) =>
|
||||
new(c.Id, c.FK_AgencyUserId, c.Details, c.Status, c.SettlementAmount, c.SettledAt, c.IsActive, c.CreatedAt);
|
||||
|
||||
public async Task<ClaimResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var c = await repo.GetByIdAsync(id);
|
||||
if (c is null || !c.IsActive) return null;
|
||||
return Map(c);
|
||||
}
|
||||
|
||||
public async Task<List<ClaimResponse>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
|
||||
(await repo.GetByAgencyUserIdAsync(agencyUserId)).Select(Map).ToList();
|
||||
|
||||
public async Task<ClaimResponse> CreateAsync(CreateClaimRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Details))
|
||||
throw new InvalidOperationException("Claim details are required.");
|
||||
|
||||
var claim = new Claim
|
||||
{
|
||||
FK_AgencyUserId = request.AgencyUserId,
|
||||
Details = request.Details
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(claim);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
|
||||
public async Task<ClaimResponse> SettleAsync(Guid id, SettleClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||
{
|
||||
var claim = await repo.GetByIdAsync(id);
|
||||
if (claim is null || !claim.IsActive)
|
||||
throw new KeyNotFoundException("Claim not found.");
|
||||
if (claim.Status == ClaimStatus.Settled)
|
||||
throw new InvalidOperationException("Claim is already settled.");
|
||||
if (request.SettlementAmount < 0)
|
||||
throw new InvalidOperationException("Settlement amount cannot be negative.");
|
||||
|
||||
claim.Status = ClaimStatus.Settled;
|
||||
claim.SettlementAmount = request.SettlementAmount;
|
||||
claim.SettledByUserId = approverId;
|
||||
claim.SettledAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(claim);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Distribution.Features.Claims;
|
||||
|
||||
public interface IClaimRepository
|
||||
{
|
||||
Task<Models.Claim?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId);
|
||||
Task AddAsync(Models.Claim entity);
|
||||
void Update(Models.Claim entity);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Distribution.Features.Returns;
|
||||
|
||||
public interface IReturnRepository
|
||||
{
|
||||
Task<Models.SaleReturn?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId);
|
||||
Task AddAsync(Models.SaleReturn entity);
|
||||
Task AddCreditNoteAsync(Models.CreditNote entity);
|
||||
|
||||
/// <summary>Whether this store has ever had this item on any of its (active) orders.</summary>
|
||||
Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Returns;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/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/orders/{id:guid}/returns")]
|
||||
public async Task<IActionResult> GetByOrder(Guid id) =>
|
||||
Ok(await service.GetByOrderIdAsync(id));
|
||||
|
||||
[HttpPost("api/orders/{id:guid}/returns")]
|
||||
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest 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,26 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Returns;
|
||||
|
||||
public record ReturnItemRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
|
||||
|
||||
public record CreateSaleReturnRequest(ReturnMode Mode, List<ReturnItemRequest> Items);
|
||||
|
||||
public record SaleReturnItemResponse(
|
||||
Guid Id,
|
||||
Guid ItemId,
|
||||
string Sku,
|
||||
string ItemName,
|
||||
decimal Quantity,
|
||||
ReturnReasonCode ReasonCode,
|
||||
decimal UnitPrice);
|
||||
|
||||
public record SaleReturnResponse(
|
||||
Guid Id,
|
||||
Guid OrderId,
|
||||
ReturnMode Mode,
|
||||
decimal TotalCreditAmount,
|
||||
decimal OrderTotalAmountAfter,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
List<SaleReturnItemResponse> Items);
|
||||
@@ -0,0 +1,32 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Returns;
|
||||
|
||||
public class ReturnRepository(AppDBContext context) : IReturnRepository
|
||||
{
|
||||
public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) =>
|
||||
await context.SaleReturns
|
||||
.Include(r => r.Order)
|
||||
.Include(r => r.Items).ThenInclude(i => i.Item)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
public async Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId) =>
|
||||
await context.SaleReturns
|
||||
.Include(r => r.Order)
|
||||
.Include(r => r.Items).ThenInclude(i => i.Item)
|
||||
.Where(r => r.FK_OrderId == orderId && r.IsActive)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.SaleReturn entity) =>
|
||||
await context.SaleReturns.AddAsync(entity);
|
||||
|
||||
public async Task AddCreditNoteAsync(Models.CreditNote entity) =>
|
||||
await context.CreditNotes.AddAsync(entity);
|
||||
|
||||
public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) =>
|
||||
await context.OrderItems
|
||||
.Include(oi => oi.Order)
|
||||
.AnyAsync(oi => oi.Order.FK_StoreId == storeId && oi.Order.IsActive && oi.FK_ItemId == itemId);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using Distribution.Features.Item;
|
||||
using Distribution.Features.Order;
|
||||
using Distribution.Features.Ride;
|
||||
using Distribution.Features.Sale;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Returns;
|
||||
|
||||
public class ReturnService(
|
||||
IReturnRepository repo,
|
||||
IOrderRepository orderRepo,
|
||||
ISaleRepository saleRepo,
|
||||
IRideRepository rideRepo,
|
||||
IItemRepository itemRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static SaleReturnItemResponse MapItem(SaleReturnItem i) =>
|
||||
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.ReasonCode, i.UnitPrice);
|
||||
|
||||
private static SaleReturnResponse Map(SaleReturn r) =>
|
||||
new(r.Id, r.FK_OrderId, r.Mode, r.TotalCreditAmount, r.Order.TotalAmount, r.IsActive, r.CreatedAt,
|
||||
r.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||
|
||||
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var r = await repo.GetByIdAsync(id);
|
||||
if (r is null || !r.IsActive) return null;
|
||||
return Map(r);
|
||||
}
|
||||
|
||||
public async Task<List<SaleReturnResponse>> GetByOrderIdAsync(Guid orderId) =>
|
||||
(await repo.GetByOrderIdAsync(orderId)).Select(Map).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Records a return recognized during the same store visit as an open order and settles it
|
||||
/// immediately: stock is credited back to the rep's vehicle, and either the order total is
|
||||
/// reduced (CreditDeduction) or the matching order line quantities are reduced (ItemExchange).
|
||||
/// A return is only allowed for an item the store has previously ordered.
|
||||
/// </summary>
|
||||
public async Task<SaleReturnResponse> CreateAsync(Guid orderId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||
{
|
||||
var order = await orderRepo.GetByIdAsync(orderId);
|
||||
if (order is null || !order.IsActive)
|
||||
throw new InvalidOperationException("Order not found.");
|
||||
if (order.Status is OrderStatus.Cancelled or OrderStatus.Fulfilled)
|
||||
throw new InvalidOperationException("Cannot record a return against a closed order.");
|
||||
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
throw new InvalidOperationException("A return must have at least one line item.");
|
||||
|
||||
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
||||
?? throw new InvalidOperationException("Sale not found for this order.");
|
||||
if (sale.FK_RideId is null)
|
||||
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(sale.FK_RideId.Value)
|
||||
?? throw new InvalidOperationException("Ride not found.");
|
||||
var vehicleId = ride.FK_VehicleId;
|
||||
|
||||
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
|
||||
var orderItemsByItemId = orderItems.ToDictionary(oi => oi.FK_ItemId);
|
||||
|
||||
var saleReturn = new SaleReturn
|
||||
{
|
||||
FK_OrderId = orderId,
|
||||
Mode = request.Mode,
|
||||
CreatedByUserId = createdByUserId
|
||||
};
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
if (line.Quantity <= 0)
|
||||
throw new InvalidOperationException("Return quantity must be greater than zero.");
|
||||
|
||||
var item = await itemRepo.GetByIdAsync(line.ItemId)
|
||||
?? throw new InvalidOperationException($"Item {line.ItemId} not found.");
|
||||
|
||||
if (!await repo.StoreHasPriorSaleAsync(order.FK_StoreId, line.ItemId))
|
||||
throw new InvalidOperationException($"No prior sale of item '{item.Name}' found for this store.");
|
||||
|
||||
var unitPrice = orderItemsByItemId.TryGetValue(line.ItemId, out var matchingOrderItem)
|
||||
? matchingOrderItem.UnitPrice
|
||||
: item.SalePrice ?? 0;
|
||||
|
||||
saleReturn.Items.Add(new SaleReturnItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
ReasonCode = line.ReasonCode,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var line in saleReturn.Items)
|
||||
{
|
||||
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, vehicleId);
|
||||
if (balance is null)
|
||||
{
|
||||
await stockRepo.AddBalanceAsync(new StockBalance
|
||||
{
|
||||
FK_ItemId = line.FK_ItemId,
|
||||
LocationId = vehicleId,
|
||||
LocationType = LocationType.Vehicle,
|
||||
Quantity = line.Quantity
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
balance.Quantity += line.Quantity;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(balance);
|
||||
}
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = line.FK_ItemId,
|
||||
ToLocationId = vehicleId,
|
||||
ToLocationType = LocationType.Vehicle,
|
||||
Quantity = line.Quantity,
|
||||
Type = MovementType.CustomerReturn,
|
||||
ReasonCode = line.ReasonCode.ToString(),
|
||||
ReferenceId = saleReturn.Id,
|
||||
CreatedBy = createdByUserId
|
||||
});
|
||||
}
|
||||
|
||||
await repo.AddAsync(saleReturn);
|
||||
|
||||
if (request.Mode == ReturnMode.CreditDeduction)
|
||||
{
|
||||
var total = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
|
||||
saleReturn.TotalCreditAmount = total;
|
||||
order.TotalAmount = Math.Max(0, order.TotalAmount - total);
|
||||
orderRepo.Update(order);
|
||||
|
||||
await repo.AddCreditNoteAsync(new CreditNote
|
||||
{
|
||||
FK_SaleReturnId = saleReturn.Id,
|
||||
Amount = total
|
||||
});
|
||||
}
|
||||
else // ItemExchange
|
||||
{
|
||||
foreach (var line in saleReturn.Items)
|
||||
{
|
||||
if (!orderItemsByItemId.TryGetValue(line.FK_ItemId, out var orderItem))
|
||||
continue; // no matching line on this order — nothing to offset
|
||||
|
||||
var offsetQty = Math.Min(line.Quantity, orderItem.Quantity);
|
||||
orderItem.Quantity -= (int)offsetQty;
|
||||
order.TotalAmount = Math.Max(0, order.TotalAmount - offsetQty * orderItem.UnitPrice);
|
||||
}
|
||||
orderRepo.Update(order);
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(saleReturn.Id);
|
||||
return Map(saved!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
public interface ISupplierClaimRepository
|
||||
{
|
||||
Task<Models.SupplierClaim?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.SupplierClaim>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||
Task AddAsync(Models.SupplierClaim entity);
|
||||
void Update(Models.SupplierClaim entity);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/supplier-claims/{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}/claims")]
|
||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||
|
||||
[HttpPost("api/purchase-orders/{id:guid}/claims")]
|
||||
public async Task<IActionResult> Create(Guid id, CreateSupplierClaimRequest 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 }); }
|
||||
}
|
||||
|
||||
[HttpPost("api/supplier-claims/{id:guid}/approve")]
|
||||
[Authorize(Policy = "suppliers.claims.approve")]
|
||||
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.ApproveAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("api/supplier-claims/{id:guid}/reject")]
|
||||
[Authorize(Policy = "suppliers.claims.approve")]
|
||||
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.RejectAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
public record SupplierClaimItemRequest(Guid ItemId, decimal Quantity, SupplierClaimIssueType IssueType);
|
||||
|
||||
public record CreateSupplierClaimRequest(
|
||||
Guid? GoodsReceiptId,
|
||||
string Details,
|
||||
string? EvidenceUrl,
|
||||
List<SupplierClaimItemRequest> Items);
|
||||
|
||||
public record ApproveSupplierClaimRequest(
|
||||
SupplierClaimResolutionType ResolutionType,
|
||||
decimal? ResolutionAmount,
|
||||
string? ResolutionNotes);
|
||||
|
||||
public record RejectSupplierClaimRequest(string? ResolutionNotes);
|
||||
|
||||
public record SupplierClaimItemResponse(
|
||||
Guid Id,
|
||||
Guid ItemId,
|
||||
string Sku,
|
||||
string ItemName,
|
||||
decimal Quantity,
|
||||
SupplierClaimIssueType IssueType);
|
||||
|
||||
public record SupplierClaimResponse(
|
||||
Guid Id,
|
||||
Guid PurchaseOrderId,
|
||||
Guid? GoodsReceiptId,
|
||||
string Details,
|
||||
string? EvidenceUrl,
|
||||
SupplierClaimStatus Status,
|
||||
SupplierClaimResolutionType? ResolutionType,
|
||||
decimal? ResolutionAmount,
|
||||
string? ResolutionNotes,
|
||||
DateTime? ResolvedAt,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
List<SupplierClaimItemResponse> Items);
|
||||
@@ -0,0 +1,25 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
public class SupplierClaimRepository(AppDBContext context) : ISupplierClaimRepository
|
||||
{
|
||||
public async Task<Models.SupplierClaim?> GetByIdAsync(Guid id) =>
|
||||
await context.SupplierClaims
|
||||
.Include(c => c.Items).ThenInclude(i => i.Item)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
public async Task<List<Models.SupplierClaim>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
await context.SupplierClaims
|
||||
.Include(c => c.Items).ThenInclude(i => i.Item)
|
||||
.Where(c => c.FK_PurchaseOrderId == purchaseOrderId && c.IsActive)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.SupplierClaim entity) =>
|
||||
await context.SupplierClaims.AddAsync(entity);
|
||||
|
||||
public void Update(Models.SupplierClaim entity) =>
|
||||
context.SupplierClaims.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
public class SupplierClaimService(
|
||||
ISupplierClaimRepository repo,
|
||||
IPoRepository poRepo,
|
||||
IGrnRepository grnRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
||||
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.IssueType);
|
||||
|
||||
private static SupplierClaimResponse Map(SupplierClaim c) =>
|
||||
new(c.Id, c.FK_PurchaseOrderId, c.FK_GoodsReceiptId, c.Details, c.EvidenceUrl, c.Status,
|
||||
c.ResolutionType, c.ResolutionAmount, c.ResolutionNotes, c.ResolvedAt, c.IsActive, c.CreatedAt,
|
||||
c.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||
|
||||
public async Task<SupplierClaimResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var c = await repo.GetByIdAsync(id);
|
||||
if (c is null || !c.IsActive) return null;
|
||||
return Map(c);
|
||||
}
|
||||
|
||||
public async Task<List<SupplierClaimResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||
|
||||
public async Task<SupplierClaimResponse> CreateAsync(Guid purchaseOrderId, CreateSupplierClaimRequest request, Guid raisedByUserId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await poRepo.GetByIdAsync(purchaseOrderId);
|
||||
if (po is null || !po.IsActive)
|
||||
throw new InvalidOperationException("Purchase order not found.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Details))
|
||||
throw new InvalidOperationException("Claim details are required.");
|
||||
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
throw new InvalidOperationException("A supplier claim must have at least one line item.");
|
||||
|
||||
if (request.GoodsReceiptId is not null)
|
||||
{
|
||||
var grn = await grnRepo.GetByIdAsync(request.GoodsReceiptId.Value)
|
||||
?? throw new InvalidOperationException("Goods receipt not found.");
|
||||
if (grn.FK_PurchaseOrderId != purchaseOrderId)
|
||||
throw new InvalidOperationException("Goods receipt does not belong to this purchase order.");
|
||||
}
|
||||
|
||||
var claim = new SupplierClaim
|
||||
{
|
||||
FK_PurchaseOrderId = purchaseOrderId,
|
||||
FK_GoodsReceiptId = request.GoodsReceiptId,
|
||||
Details = request.Details,
|
||||
EvidenceUrl = request.EvidenceUrl,
|
||||
RaisedByUserId = raisedByUserId
|
||||
};
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
if (line.Quantity <= 0)
|
||||
throw new InvalidOperationException("Claim quantity must be greater than zero.");
|
||||
|
||||
claim.Items.Add(new SupplierClaimItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
IssueType = line.IssueType
|
||||
});
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(claim);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(claim.Id);
|
||||
return Map(saved!);
|
||||
}
|
||||
|
||||
public async Task<SupplierClaimResponse> ApproveAsync(Guid id, ApproveSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||
{
|
||||
var claim = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Supplier claim not found.");
|
||||
if (claim.Status != SupplierClaimStatus.Open)
|
||||
throw new InvalidOperationException("Only an open claim can be approved.");
|
||||
|
||||
if (request.ResolutionType != SupplierClaimResolutionType.Replacement && request.ResolutionAmount is null)
|
||||
throw new InvalidOperationException("ResolutionAmount is required for Credit, Refund, and PriceAdjustment.");
|
||||
|
||||
claim.Status = SupplierClaimStatus.Approved;
|
||||
claim.ResolutionType = request.ResolutionType;
|
||||
claim.ResolutionAmount = request.ResolutionType == SupplierClaimResolutionType.Replacement ? null : request.ResolutionAmount;
|
||||
claim.ResolutionNotes = request.ResolutionNotes;
|
||||
claim.ResolvedByUserId = approverId;
|
||||
claim.ResolvedAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(claim);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
|
||||
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||
{
|
||||
var claim = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Supplier claim not found.");
|
||||
if (claim.Status != SupplierClaimStatus.Open)
|
||||
throw new InvalidOperationException("Only an open claim can be rejected.");
|
||||
|
||||
claim.Status = SupplierClaimStatus.Rejected;
|
||||
claim.ResolutionNotes = request.ResolutionNotes;
|
||||
claim.ResolvedByUserId = approverId;
|
||||
claim.ResolvedAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(claim);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user