Dev #12

Merged
DeepnaPooja merged 5 commits from dev into production 2026-08-25 15:45:20 +05:30
36 changed files with 7282 additions and 95 deletions
Submodule .claude/worktrees/agent-a203fc082069b1faf added at ade41b4a60
+91
View File
@@ -50,6 +50,12 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
public DbSet<SaleReturn> SaleReturns { get; set; }
public DbSet<SaleReturnItem> SaleReturnItems { get; set; }
public DbSet<CreditNote> CreditNotes { get; set; }
public DbSet<Claim> Claims { get; set; }
public DbSet<SupplierClaim> SupplierClaims { get; set; }
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -270,6 +276,91 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId);
modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.CreatedByUser)
.WithMany()
.HasForeignKey(r => r.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturnItem>()
.HasOne(i => i.SaleReturn)
.WithMany(r => r.Items)
.HasForeignKey(i => i.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturnItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturnItem>()
.HasIndex(i => i.FK_SaleReturnId);
modelBuilder.Entity<CreditNote>()
.HasOne(n => n.SaleReturn)
.WithMany()
.HasForeignKey(n => n.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Claim>()
.HasOne(c => c.AgencyUser)
.WithMany()
.HasForeignKey(c => c.FK_AgencyUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Claim>()
.HasOne(c => c.SettledByUser)
.WithMany()
.HasForeignKey(c => c.SettledByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.PurchaseOrder)
.WithMany()
.HasForeignKey(c => c.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.GoodsReceipt)
.WithMany()
.HasForeignKey(c => c.FK_GoodsReceiptId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.RaisedByUser)
.WithMany()
.HasForeignKey(c => c.RaisedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.ResolvedByUser)
.WithMany()
.HasForeignKey(c => c.ResolvedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaimItem>()
.HasOne(i => i.SupplierClaim)
.WithMany(c => c.Items)
.HasForeignKey(i => i.FK_SupplierClaimId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaimItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaimItem>()
.HasIndex(i => i.FK_SupplierClaimId);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Ride)
.WithMany()
@@ -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 }); }
}
}
+17
View File
@@ -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);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddReturnsAndClaims : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Claims",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_AgencyUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Details = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Status = table.Column<int>(type: "int", nullable: false),
SettlementAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
SettledByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
SettledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Claims", x => x.Id);
table.ForeignKey(
name: "FK_Claims_Users_FK_AgencyUserId",
column: x => x.FK_AgencyUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Claims_Users_SettledByUserId",
column: x => x.SettledByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SaleReturns",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Mode = table.Column<int>(type: "int", nullable: false),
TotalCreditAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SaleReturns", x => x.Id);
table.ForeignKey(
name: "FK_SaleReturns_Orders_FK_OrderId",
column: x => x.FK_OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SaleReturns_Users_CreatedByUserId",
column: x => x.CreatedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "CreditNotes",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SaleReturnId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Amount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
IssuedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CreditNotes", x => x.Id);
table.ForeignKey(
name: "FK_CreditNotes_SaleReturns_FK_SaleReturnId",
column: x => x.FK_SaleReturnId,
principalTable: "SaleReturns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SaleReturnItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SaleReturnId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Quantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
ReasonCode = table.Column<int>(type: "int", nullable: false),
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SaleReturnItems", x => x.Id);
table.ForeignKey(
name: "FK_SaleReturnItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SaleReturnItems_SaleReturns_FK_SaleReturnId",
column: x => x.FK_SaleReturnId,
principalTable: "SaleReturns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Claims_FK_AgencyUserId",
table: "Claims",
column: "FK_AgencyUserId");
migrationBuilder.CreateIndex(
name: "IX_Claims_SettledByUserId",
table: "Claims",
column: "SettledByUserId");
migrationBuilder.CreateIndex(
name: "IX_CreditNotes_FK_SaleReturnId",
table: "CreditNotes",
column: "FK_SaleReturnId");
migrationBuilder.CreateIndex(
name: "IX_SaleReturnItems_FK_ItemId",
table: "SaleReturnItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_SaleReturnItems_FK_SaleReturnId",
table: "SaleReturnItems",
column: "FK_SaleReturnId");
migrationBuilder.CreateIndex(
name: "IX_SaleReturns_CreatedByUserId",
table: "SaleReturns",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_SaleReturns_FK_OrderId",
table: "SaleReturns",
column: "FK_OrderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Claims");
migrationBuilder.DropTable(
name: "CreditNotes");
migrationBuilder.DropTable(
name: "SaleReturnItems");
migrationBuilder.DropTable(
name: "SaleReturns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddSupplierClaims : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SupplierClaims",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_GoodsReceiptId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Details = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
EvidenceUrl = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Status = table.Column<int>(type: "int", nullable: false),
ResolutionType = table.Column<int>(type: "int", nullable: true),
ResolutionAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
ResolutionNotes = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
RaisedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ResolvedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
ResolvedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SupplierClaims", x => x.Id);
table.ForeignKey(
name: "FK_SupplierClaims_GoodsReceipts_FK_GoodsReceiptId",
column: x => x.FK_GoodsReceiptId,
principalTable: "GoodsReceipts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SupplierClaims_PurchaseOrders_FK_PurchaseOrderId",
column: x => x.FK_PurchaseOrderId,
principalTable: "PurchaseOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SupplierClaims_Users_RaisedByUserId",
column: x => x.RaisedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SupplierClaims_Users_ResolvedByUserId",
column: x => x.ResolvedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SupplierClaimItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SupplierClaimId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Quantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
IssueType = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SupplierClaimItems", x => x.Id);
table.ForeignKey(
name: "FK_SupplierClaimItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SupplierClaimItems_SupplierClaims_FK_SupplierClaimId",
column: x => x.FK_SupplierClaimId,
principalTable: "SupplierClaims",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaimItems_FK_ItemId",
table: "SupplierClaimItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaimItems_FK_SupplierClaimId",
table: "SupplierClaimItems",
column: "FK_SupplierClaimId");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaims_FK_GoodsReceiptId",
table: "SupplierClaims",
column: "FK_GoodsReceiptId");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaims_FK_PurchaseOrderId",
table: "SupplierClaims",
column: "FK_PurchaseOrderId");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaims_RaisedByUserId",
table: "SupplierClaims",
column: "RaisedByUserId");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaims_ResolvedByUserId",
table: "SupplierClaims",
column: "ResolvedByUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SupplierClaimItems");
migrationBuilder.DropTable(
name: "SupplierClaims");
}
}
}
@@ -73,6 +73,74 @@ namespace Distribution.Migrations
b.ToTable("Categories");
});
modelBuilder.Entity("Distribution.Models.Claim", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("FK_AgencyUserId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("SettledAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("SettledByUserId")
.HasColumnType("char(36)");
b.Property<decimal?>("SettlementAmount")
.HasColumnType("decimal(65,30)");
b.Property<int>("Status")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_AgencyUserId");
b.HasIndex("SettledByUserId");
b.ToTable("Claims");
});
modelBuilder.Entity("Distribution.Models.CreditNote", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("Amount")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_SaleReturnId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("IssuedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("FK_SaleReturnId");
b.ToTable("CreditNotes");
});
modelBuilder.Entity("Distribution.Models.DeliveryRun", b =>
{
b.Property<Guid>("Id")
@@ -1006,6 +1074,75 @@ namespace Distribution.Migrations
b.ToTable("Sales");
});
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("Mode")
.HasColumnType("int");
b.Property<decimal>("TotalCreditAmount")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("FK_OrderId");
b.ToTable("SaleReturns");
});
modelBuilder.Entity("Distribution.Models.SaleReturnItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_SaleReturnId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<decimal>("Quantity")
.HasColumnType("decimal(65,30)");
b.Property<int>("ReasonCode")
.HasColumnType("int");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_SaleReturnId");
b.ToTable("SaleReturnItems");
});
modelBuilder.Entity("Distribution.Models.SaleRoute", b =>
{
b.Property<Guid>("Id")
@@ -1377,6 +1514,98 @@ namespace Distribution.Migrations
b.ToTable("Suppliers");
});
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("EvidenceUrl")
.HasColumnType("longtext");
b.Property<Guid?>("FK_GoodsReceiptId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_PurchaseOrderId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<Guid>("RaisedByUserId")
.HasColumnType("char(36)");
b.Property<decimal?>("ResolutionAmount")
.HasColumnType("decimal(65,30)");
b.Property<string>("ResolutionNotes")
.HasColumnType("longtext");
b.Property<int?>("ResolutionType")
.HasColumnType("int");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ResolvedByUserId")
.HasColumnType("char(36)");
b.Property<int>("Status")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_GoodsReceiptId");
b.HasIndex("FK_PurchaseOrderId");
b.HasIndex("RaisedByUserId");
b.HasIndex("ResolvedByUserId");
b.ToTable("SupplierClaims");
});
modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_SupplierClaimId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("IssueType")
.HasColumnType("int");
b.Property<decimal>("Quantity")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_SupplierClaimId");
b.ToTable("SupplierClaimItems");
});
modelBuilder.Entity("Distribution.Models.Uom", b =>
{
b.Property<Guid>("Id")
@@ -1610,6 +1839,35 @@ namespace Distribution.Migrations
b.ToTable("Warehouses");
});
modelBuilder.Entity("Distribution.Models.Claim", b =>
{
b.HasOne("Distribution.Models.User", "AgencyUser")
.WithMany()
.HasForeignKey("FK_AgencyUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.User", "SettledByUser")
.WithMany()
.HasForeignKey("SettledByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("AgencyUser");
b.Navigation("SettledByUser");
});
modelBuilder.Entity("Distribution.Models.CreditNote", b =>
{
b.HasOne("Distribution.Models.SaleReturn", "SaleReturn")
.WithMany()
.HasForeignKey("FK_SaleReturnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SaleReturn");
});
modelBuilder.Entity("Distribution.Models.DeliveryRun", b =>
{
b.HasOne("Distribution.Models.DriverProfile", "DriverProfile")
@@ -1994,6 +2252,44 @@ namespace Distribution.Migrations
b.Navigation("Store");
});
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
{
b.HasOne("Distribution.Models.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CreatedByUser");
b.Navigation("Order");
});
modelBuilder.Entity("Distribution.Models.SaleReturnItem", b =>
{
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.SaleReturn", "SaleReturn")
.WithMany("Items")
.HasForeignKey("FK_SaleReturnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("SaleReturn");
});
modelBuilder.Entity("Distribution.Models.Scheme", b =>
{
b.HasOne("Distribution.Models.Item", "FreeItem")
@@ -2116,6 +2412,58 @@ namespace Distribution.Migrations
b.Navigation("Category");
});
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
{
b.HasOne("Distribution.Models.GoodsReceipt", "GoodsReceipt")
.WithMany()
.HasForeignKey("FK_GoodsReceiptId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
.WithMany()
.HasForeignKey("FK_PurchaseOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.User", "RaisedByUser")
.WithMany()
.HasForeignKey("RaisedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.User", "ResolvedByUser")
.WithMany()
.HasForeignKey("ResolvedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("GoodsReceipt");
b.Navigation("PurchaseOrder");
b.Navigation("RaisedByUser");
b.Navigation("ResolvedByUser");
});
modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b =>
{
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.SupplierClaim", "SupplierClaim")
.WithMany("Items")
.HasForeignKey("FK_SupplierClaimId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("SupplierClaim");
});
modelBuilder.Entity("Distribution.Models.UomConversion", b =>
{
b.HasOne("Distribution.Models.Uom", "FromUom")
@@ -2270,12 +2618,22 @@ namespace Distribution.Migrations
b.Navigation("Items");
});
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("Distribution.Models.Scheme", b =>
{
b.Navigation("BundleComponents");
b.Navigation("Redemptions");
});
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Claim : Template
{
/// <summary>User holding the RegionalAgency role that this claim is raised against.</summary>
public Guid FK_AgencyUserId { get; set; }
[ForeignKey("FK_AgencyUserId")]
public User AgencyUser { get; set; }
public string Details { get; set; }
public ClaimStatus Status { get; set; } = ClaimStatus.Open;
public decimal? SettlementAmount { get; set; }
public Guid? SettledByUserId { get; set; }
[ForeignKey("SettledByUserId")]
public User? SettledByUser { get; set; }
public DateTime? SettledAt { get; set; }
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
/// <summary>Audit record of the credit amount netted off an order by a CreditDeduction-mode SaleReturn.</summary>
public class CreditNote : Template
{
public Guid FK_SaleReturnId { get; set; }
[ForeignKey("FK_SaleReturnId")]
public SaleReturn SaleReturn { get; set; }
public decimal Amount { get; set; }
public DateTime IssuedAt { get; set; } = DateTime.Now;
}
}
+17 -34
View File
@@ -1,37 +1,13 @@
namespace Distribution.Models
{
public enum LocationType
{
Warehouse,
Vehicle
}
{ Warehouse, Vehicle }
public enum KycStatus
{
Pending,
Verified,
Rejected }
public enum KycStatus { Pending,Verified, Rejected }
public enum MovementType
{
Sale,
CustomerReturn,
Transfer,
Damage,
Adjustment,
Load,
PurchaseReceipt,
PurchaseReturn
}
public enum MovementType { Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn }
public enum LoadingSheetStatus
{
Pending,
DispatchConfirmed,
ReceiptConfirmed,
DiscrepancyFlagged,
Resolved
}
public enum LoadingSheetStatus { Pending, DispatchConfirmed, ReceiptConfirmed, DiscrepancyFlagged, Resolved }
public enum RideType { Delivery, Sale }
@@ -52,10 +28,17 @@ namespace Distribution.Models
public enum PaymentMethod { Cash, Credit }
public enum DeliveryTaskStatus
{
Pending,
Delivered,
PartiallyDelivered,
Failed
}
{ Pending, Delivered, PartiallyDelivered, Failed}
public enum ReturnMode { CreditDeduction, ItemExchange }
public enum ReturnReasonCode { Expired, Damaged, OrderError, Other }
public enum ClaimStatus { Open, Settled }
public enum SupplierClaimStatus { Open, Approved, Rejected }
public enum SupplierClaimIssueType { Damaged, Short, Expired, WrongItem, Other }
public enum SupplierClaimResolutionType { Replacement, Credit, Refund, PriceAdjustment }
}
+26
View File
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
/// <summary>
/// A return recorded by a sales rep against an open Order during the same store visit.
/// Settled immediately on creation — no supervisor approval step.
/// </summary>
public class SaleReturn : Template
{
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public ReturnMode Mode { get; set; }
/// <summary>Amount netted off the order's TotalAmount. Zero for ItemExchange mode.</summary>
public decimal TotalCreditAmount { get; set; }
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
public ICollection<SaleReturnItem> Items { get; set; } = [];
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class SaleReturnItem : Template
{
public Guid FK_SaleReturnId { get; set; }
[ForeignKey("FK_SaleReturnId")]
public SaleReturn SaleReturn { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public decimal Quantity { get; set; }
public ReturnReasonCode ReasonCode { get; set; }
/// <summary>Snapshot of the item's unit price at return time, used to compute credit amount.</summary>
public decimal UnitPrice { get; set; }
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class SupplierClaim : Template
{
public Guid FK_PurchaseOrderId { get; set; }
[ForeignKey("FK_PurchaseOrderId")]
public PurchaseOrder PurchaseOrder { get; set; }
public Guid? FK_GoodsReceiptId { get; set; }
[ForeignKey("FK_GoodsReceiptId")]
public GoodsReceipt? GoodsReceipt { get; set; }
public string Details { get; set; }
public string? EvidenceUrl { get; set; }
public SupplierClaimStatus Status { get; set; } = SupplierClaimStatus.Open;
public SupplierClaimResolutionType? ResolutionType { get; set; }
public decimal? ResolutionAmount { get; set; }
public string? ResolutionNotes { get; set; }
public Guid RaisedByUserId { get; set; }
[ForeignKey("RaisedByUserId")]
public User RaisedByUser { get; set; }
public Guid? ResolvedByUserId { get; set; }
[ForeignKey("ResolvedByUserId")]
public User? ResolvedByUser { get; set; }
public DateTime? ResolvedAt { get; set; }
public ICollection<SupplierClaimItem> Items { get; set; } = [];
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class SupplierClaimItem : Template
{
public Guid FK_SupplierClaimId { get; set; }
[ForeignKey("FK_SupplierClaimId")]
public SupplierClaim SupplierClaim { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public decimal Quantity { get; set; }
public SupplierClaimIssueType IssueType { get; set; }
}
}
+9
View File
@@ -13,6 +13,9 @@ using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Po;
using Distribution.Features.PurchaseReturn;
using Distribution.Features.Returns;
using Distribution.Features.Claims;
using Distribution.Features.SupplierClaims;
using Distribution.Features.Region;
using Distribution.Features.LoadingEvent;
using Distribution.Features.Ride;
@@ -73,6 +76,9 @@ builder.Services.AddScoped<ISchemeRepository, SchemeRepository>();
builder.Services.AddScoped<IPoRepository, PoRepository>();
builder.Services.AddScoped<IGrnRepository, GrnRepository>();
builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>();
builder.Services.AddScoped<IReturnRepository, ReturnRepository>();
builder.Services.AddScoped<IClaimRepository, ClaimRepository>();
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
@@ -103,6 +109,9 @@ builder.Services.AddScoped<SchemeService>();
builder.Services.AddScoped<PoService>();
builder.Services.AddScoped<GrnService>();
builder.Services.AddScoped<PurchaseReturnService>();
builder.Services.AddScoped<ReturnService>();
builder.Services.AddScoped<ClaimService>();
builder.Services.AddScoped<SupplierClaimService>();
builder.Services.AddScoped<DeliveryTaskService>();
builder.Services.AddScoped<PaymentService>();
+1 -1
View File
@@ -1,6 +1,6 @@
# 00 — Existing Gap Fixes
**Status**: Not Started
**Status**: Done
**Depends On**: —
---
+1 -1
View File
@@ -1,6 +1,6 @@
# 01 — Item Catalog
**Status**: Ready
**Status**: Done
**Depends On**: 00 — Existing Gap Fixes (gaps 1 & 2 done)
---
+1 -1
View File
@@ -1,6 +1,6 @@
# 04 - Loading Event
**Status**: Planning
**Status**: Done
**Depends On**: 03 - Ride Lifecycle
---
+9
View File
@@ -122,6 +122,15 @@ GoodsReceiptItem
```
### PurchaseReturn
> **Not the same as `SaleReturn`** ([18_returns_claims.md](./18_returns_claims.md)), despite the
> similar name. `PurchaseReturn` moves goods **Distributor → Supplier** (debits the receiving
> warehouse); `SaleReturn` moves goods **Shop → Distributor** (credits a delivery vehicle). Also
> distinct from `SupplierClaim` ([18_returns_claims.md](./18_returns_claims.md)), which is for
> supplier disputes that don't involve physically returning goods (short-shipment, price dispute)
> and — unlike this entity — requires approval. See that doc's "Four distinct entities" table for
> the full comparison.
```
PurchaseReturn
├── Id (Guid) — PK, from Template
+424 -29
View File
@@ -1,74 +1,469 @@
# 18 — Returns & Claims Management
**Status**: Not Started
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution
**Status**: Done — migrations applied to the database. Inspection/classification for customer
returns is deferred (see Open Questions).
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution, 15 — Procurement (for
`SupplierClaim`)
---
## Goal
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) in
full. [05_delivery_ride.md](./05_delivery_ride.md) and [09_fraud_controls.md](./09_fraud_controls.md)
both reference this step's credit note and approval shapes — this is where those are actually
defined.
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) — a customer-side flow where
a sales rep records a return during a store visit and a claim workflow against a Regional Agency,
plus a supplier-side flow for claims against a `PurchaseOrder`/`GoodsReceipt` that don't involve
physically returning goods.
### Four distinct entities — don't conflate them
`SaleReturn` and `PurchaseReturn` are both named "...Return" but move goods in opposite
directions, and neither is the same as the two `Claim` entities, which never move stock at all:
| Entity | Direction | Where it lives | Approval? | Stock effect |
|---|---|---|---|---|
| `SaleReturn` | Shop → Distributor | This step (`Features/Returns`) | No — settles immediately | Credits the ride's **vehicle** |
| `Claim` | vs. Regional Agency | This step (`Features/Claims`) | Settle only (`claims.settle`) | None |
| `PurchaseReturn` | Distributor → Supplier | [15_procurement.md](./15_procurement.md) (`Features/PurchaseReturn`) | No — settles immediately | Debits the receiving **warehouse** |
| `SupplierClaim` | vs. Supplier | This step (`Features/SupplierClaims`) | Yes (`suppliers.claims.approve`) | None — financial/administrative record only |
`PurchaseReturn` is for physically shipping goods back to a supplier; `SupplierClaim` is for
disputes that don't (or can't yet) involve moving goods back — a short-shipment, damage, or a
price dispute.
---
## Background / Context
No return, credit note, or claim entity exists today. `createBackorder` (§1,
[05_delivery_ride.md](./05_delivery_ride.md)) needs a credit note target once this step lands.
No return, credit note, or claim entity existed before this step. `createBackorder` (§1,
[05_delivery_ride.md](./05_delivery_ride.md)) needed a credit note target once this step landed.
The original spec (mainPlan §12) framed customer returns as an async, supervisor-approved
workflow — `createReturnRequest` → `approveReturn` → stock credited only on approval. **That model
was dropped during planning.** In practice a sales rep records a return on the spot, during the
same store visit as a new order, and it must settle immediately (stock back in the vehicle, the
new order's amount adjusted) — there is no point in the field workflow where a return sits
"pending" a remote supervisor's sign-off. See Business Rules for what this changes.
The existing `PurchaseReturn` ([15_procurement.md](./15_procurement.md)) only covers physically
returning received goods to a supplier and debiting stock — it has no place for a claim that
doesn't (or can't yet) involve moving goods back: a short-shipment (500 ordered, 480 received),
goods damaged in transit, or a price dispute. `SupplierClaim` was added alongside `PurchaseReturn`
for this, not as a rework of it.
### Connections to existing modules
- **Order Management**: a `SaleReturn` is always filed against a specific open `Order`
(`Features/Order`) and immediately nets its effect onto that order's `TotalAmount` /
`OrderItem.Quantity` — no `Invoice` entity exists yet (Finance, step 19, not built), so there is
nothing else for a credit note to net against.
- **Stock Ledger** ([02_stock_ledger.md](./02_stock_ledger.md)): `SaleReturn` credits
`StockBalance` at the active ride's vehicle (`MovementType.CustomerReturn`), the same enum value
`StockService.RecordCustomerReturnAsync` already defined but that no sales-side flow was wiring
up until this step. `SupplierClaim` writes no stock movement at all (see Business Rules).
- **Ride Execution** ([05_delivery_ride.md](./05_delivery_ride.md)): a `SaleReturn` can only be
recorded against an order created during an active ride (`Sale.FK_RideId` must be set), since
stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`).
- **Region Management** ([10_region_management.md](./10_region_management.md)): there is no
separate Agency entity in the codebase — "Regional Agency" is a `Role`
(`Data/Seed/RoleAndPermissionSeed.cs`), so `Claim` references the `User` holding that role.
- **Procurement** ([15_procurement.md](./15_procurement.md)): `SupplierClaim.FK_PurchaseOrderId`
is required, `FK_GoodsReceiptId` optional; a short-shipment is already excluded from stock at
receipt time (`GoodsReceiptItem.RejectedQuantity`), so `SupplierClaim` never needs to touch
`StockBalance` itself.
---
## Data Model Changes
### Return Request
Six new tables.
`OrderId`, `Items[]` (`ItemId`, `Quantity`, `ReasonCode`), `Status` (`Pending`, `Approved`,
`Rejected`), `RequestedByUserId`.
### SaleReturn
```
SaleReturn
├── Id (Guid) — PK, from Template
├── FK_OrderId (Guid) — required, the order this return is filed against
├── Mode (enum) — CreditDeduction, ItemExchange — chosen by the rep per return
├── TotalCreditAmount (decimal)— sum(Quantity * UnitPrice) for CreditDeduction mode; 0 for ItemExchange
├── CreatedByUserId (Guid) — required, the rep who recorded it
├── IsActive, CreatedAt — from Template
```
### Credit Note
### SaleReturnItem
```
SaleReturnItem
├── Id (Guid) — PK, from Template
├── FK_SaleReturnId (Guid) — required
├── FK_ItemId (Guid) — required
├── Quantity (decimal) — returned quantity, credited back to the ride's vehicle
├── ReasonCode (enum) — Expired, Damaged, OrderError, Other
├── UnitPrice (decimal) — snapshot at return time (from the matching OrderItem if one
│ exists on this order, else Item.SalePrice)
```
`ReturnRequestId`, `InvoiceId`, `Amount`, `IssuedAt`.
### CreditNote
```
CreditNote
├── Id (Guid) — PK, from Template
├── FK_SaleReturnId (Guid) — required
├── Amount (decimal) — amount netted off the order's TotalAmount
├── IssuedAt (DateTime)
├── IsActive, CreatedAt — from Template
```
Audit-trail only — written for `CreditDeduction`-mode returns, not itself an approval gate or an
invoice-linked document (no `Invoice` entity exists yet; see Background/Context).
### Claim
```
Claim
├── Id (Guid) — PK, from Template
├── FK_AgencyUserId (Guid) — required, the User holding the RegionalAgency role this claim is against
├── Details (string) — required
├── Status (enum) — Open, Settled
├── SettlementAmount (decimal?)— set once settled
├── SettledByUserId (Guid?), SettledAt (DateTime?)
├── IsActive, CreatedAt — from Template
```
Independent of `SaleReturn`/stock — its own open/settle workflow.
`AgencyId`, `Details`, `Status` (`Open`, `Settled`), `SettlementAmount` (nullable until settled).
### SupplierClaim
```
SupplierClaim
├── Id (Guid) — PK, from Template
├── FK_PurchaseOrderId (Guid) — required
├── FK_GoodsReceiptId (Guid?) — optional; set when the claim stems from a specific receipt
│ (e.g. short/damaged at receipt)
├── Details (string) — description of the issue
├── EvidenceUrl (string?) — link/path to a photo or document; no file-upload storage
│ exists yet, so this is a plain string field for now
├── Status (enum) — Open, Approved, Rejected
├── ResolutionType (enum?) — Replacement, Credit, Refund, PriceAdjustment — set on approve
├── ResolutionAmount (decimal?) — for Credit / Refund / PriceAdjustment
├── ResolutionNotes (string?)
├── RaisedByUserId (Guid)
├── ResolvedByUserId (Guid?), ResolvedAt (DateTime?)
├── IsActive, CreatedAt — from Template
```
### SupplierClaimItem
```
SupplierClaimItem
├── Id (Guid) — PK, from Template
├── FK_SupplierClaimId (Guid) — required
├── FK_ItemId (Guid) — required
├── Quantity (decimal)
├── IssueType (enum) — Damaged, Short, Expired, WrongItem, Other
```
---
## Migrations
Two migrations, applied:
1. **AddReturnsAndClaims** (`20260825073453_AddReturnsAndClaims`) — created `SaleReturns`,
`SaleReturnItems`, `CreditNotes`, `Claims`, with FKs to `Orders`/`Items`/`Users` and indexes on
every FK column.
2. **AddSupplierClaims** (`20260825084218_AddSupplierClaims`) — created `SupplierClaims`,
`SupplierClaimItems`, with FKs to `PurchaseOrders`/`GoodsReceipts`/`Items`/`Users` and indexes on
every FK column.
Both applied via `dotnet ef database update`.
---
## API Endpoints
| Function reference | Endpoint intent |
> Return routes use bare `[Authorize]` — matches the Supplier/PriceList/Procurement convention (no
> per-action policy). `claims.settle` and `suppliers.claims.approve` are the two enforced policies,
> since those are the two steps with real approval weight. See Permissions to Add to Seed.
### Returns (`Features/Returns`)
| Function reference | Endpoint |
|---|---|
| `createReturnRequest(orderId, items[], reasonCode)` | `POST /api/returns` |
| `approveReturn(returnId, approverId)` | `POST /api/returns/{id}/approve` |
| `generateCreditNote(returnId)` | `POST /api/returns/{id}/credit-note` — auto-triggered on approval |
| `createClaim(agencyId, claimDetails)` | `POST /api/claims` |
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` |
| `createSaleReturn(orderId, mode, items[])` | `POST /api/orders/{id}/returns` — creates and immediately settles |
| `getSaleReturn(returnId)` | `GET /api/returns/{id}` |
| `listSaleReturns(orderId)` | `GET /api/orders/{id}/returns` |
### Claims (`Features/Claims`)
| Function reference | Endpoint |
|---|---|
| `createClaim(agencyUserId, details)` | `POST /api/claims` |
| `getClaim(claimId)` | `GET /api/claims/{id}` |
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` — `[Authorize(Policy = "claims.settle")]` |
### Supplier Claims (`Features/SupplierClaims`)
| Function reference | Endpoint |
|---|---|
| `createSupplierClaim(poId, grnId?, items[], details, evidenceUrl?)` | `POST /api/purchase-orders/{id}/claims` |
| `getSupplierClaim(claimId)` | `GET /api/supplier-claims/{id}` |
| `listSupplierClaims(poId)` | `GET /api/purchase-orders/{id}/claims` |
| `approveSupplierClaim(claimId, resolutionType, resolutionAmount?, notes?)` | `POST /api/supplier-claims/{id}/approve` — `[Authorize(Policy = "suppliers.claims.approve")]` |
| `rejectSupplierClaim(claimId, notes?)` | `POST /api/supplier-claims/{id}/reject` — `[Authorize(Policy = "suppliers.claims.approve")]` |
`approve`/`reject` combine the "inspect the claim" and "decide the outcome" steps into one call —
unlike the customer-side flow there's no separate physical-inspection step to wait on here, since
the evidence (photos/notes, and `GoodsReceiptItem.RejectedQuantity` if applicable) is already
attached at claim-creation time.
---
## Request / Response Shapes
### POST `/api/orders/{id}/returns`
```json
{
"mode": "CreditDeduction",
"items": [{ "itemId": "...", "quantity": 5, "reasonCode": "Expired" }]
}
```
`mode` is `CreditDeduction` or `ItemExchange`, `reasonCode` is `Expired` | `Damaged` |
`OrderError` | `Other`.
### POST `/api/claims`
```json
{ "agencyUserId": "...", "details": "Short-shipped 20 units on last consignment" }
```
### POST `/api/claims/{id}/settle`
```json
{ "settlementAmount": 1500.00 }
```
### POST `/api/purchase-orders/{id}/claims`
```json
{
"goodsReceiptId": "...",
"details": "20 units arrived damaged, 480 of 500 received",
"evidenceUrl": "https://...",
"items": [{ "itemId": "...", "quantity": 20, "issueType": "Damaged" }]
}
```
`goodsReceiptId` and `evidenceUrl` are optional. `issueType` is `Damaged` | `Short` | `Expired` |
`WrongItem` | `Other`.
### POST `/api/supplier-claims/{id}/approve`
```json
{ "resolutionType": "Credit", "resolutionAmount": 850.00, "resolutionNotes": "Agreed with supplier" }
```
`resolutionType` is `Replacement` | `Credit` | `Refund` | `PriceAdjustment`. `resolutionAmount` is
required unless `resolutionType` is `Replacement`.
### POST `/api/supplier-claims/{id}/reject`
```json
{ "resolutionNotes": "Discrepancy within accepted tolerance" }
```
---
## Business Rules
- `approveReturn` requires supervisor sign-off before stock is credited back — matches the rule
already stated in [09_fraud_controls.md](./09_fraud_controls.md).
- A `Return Request` must reference a real prior sale/delivery line — the absence of one is exactly
the `ReturnWithoutSale` anomaly flagged automatically in
[09_fraud_controls.md](./09_fraud_controls.md).
- Approving a return creates a `Return` stock movement crediting the relevant location, per
[02_stock_ledger.md](./02_stock_ledger.md).
1. `createSaleReturn` is the entry point — no prior approval step, settlement is immediate. This
deliberately supersedes the original spec's `Pending → Approved/Rejected` flow (see
Background/Context) since a rep records the return on the spot during a live store visit.
2. A return can only be filed against an order that is still open (`OrderStatus.Pending` or
`PartiallyFulfilled`) — not `Fulfilled`/`Cancelled` — and whose `Sale.FK_RideId` is set, since
stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`,
`MovementType.CustomerReturn`).
3. `createSaleReturn` rejects (409 Conflict) any line item the store has never had on **any** of
its past orders — checked across the store's full order history, not just the order the return
is filed against. This lets an expired or damaged item bought weeks ago still be returned; it
only blocks an item that store never bought at all — the real fraud case originally scoped as
`ReturnWithoutSale` in [09_fraud_controls.md](./09_fraud_controls.md), which is not otherwise
built.
4. `Mode = CreditDeduction`: the returned lines' value (`Quantity * UnitPrice`) is subtracted from
`Order.TotalAmount` (floored at 0), and a `CreditNote` audit row is written.
5. `Mode = ItemExchange`: the returned quantity is subtracted directly from the matching
`OrderItem.Quantity` on the same order (a physical swap of the same item), `TotalAmount`
adjusted by the offset amount. No `CreditNote` is written. A returned item with no matching line
on the order is credited to stock but does not offset anything on the order.
6. Stock credit, the `SaleReturn`/`SaleReturnItem` insert, and the order-total/credit-note update
all run inside one `IUnitOfWork` transaction — a failure anywhere rolls back the whole return.
7. `settleClaim` can only run once per claim — rejects (409) if already `Settled`. Claims never
touch `StockBalance` or any `Order`.
8. `createSupplierClaim` requires the referenced `PurchaseOrder` to exist and be active;
`FK_GoodsReceiptId`, if given, must belong to that PO (rejects with 409 otherwise).
9. Only `Open` supplier claims can be approved or rejected; both are terminal (no re-opening) —
enforced by `ApproveAsync`/`RejectAsync` throwing if `Status != Open`.
10. `approveSupplierClaim` requires `ResolutionType`; `ResolutionAmount` is required for `Credit`,
`Refund`, and `PriceAdjustment` (409 if missing), and is discarded (`null`) for `Replacement`
even if supplied.
11. Supplier claim approval/rejection is a real sign-off step (unlike the customer-side
`SaleReturn`, which settles immediately) — gated behind
`[Authorize(Policy = "suppliers.claims.approve")]`, following the Ride cancel-request pattern
(`RideController.cs:120-146`).
12. No automatic `StockBalance`/`StockMovement` write from any `SupplierClaim` action.
`ResolutionType = Replacement` records that new stock is expected; the actual stock credit
happens through an ordinary follow-up `GoodsReceipt` once it arrives (see Open Questions).
`Credit`/`Refund`/`PriceAdjustment` have no `Invoice`/Finance ledger to post against yet
(step 19, not built) — `ResolutionAmount` is captured as an audit field only, the same
limitation `CreditNote` has on the customer side.
---
## Permissions to Add to Seed
**Status: `claims.settle` and `suppliers.claims.approve` enforced; return-creation routes
intentionally left as bare `[Authorize]`**, matching the Supplier/PriceList/Procurement convention
(steps 14/15) rather than introducing enforcement piecemeal. Both enforced policies use the same
dynamic policy-provider pattern as `rides.cancel.approve` (`PermissionPolicyProvider`) — no
explicit policy registration needed, just a seeded `Permission` row and a `RolePermission` grant.
No role assignments seeded yet; `suppliers.claims.approve` likely AreaManager (30) and above,
matching the Supplier/Procurement tier.
---
## Open Questions
- Should return-creation routes eventually get a real permission policy (`returns.create`) once
the Supplier/PriceList/Procurement group does, or do they stay bare `[Authorize]` indefinitely
since a rep needs to record a return without waiting on a scoped grant?
- `ItemExchange` mode clamps the offset to `min(returnedQty, orderItem.Quantity)` — if a rep
returns more of an item than is on the current order, the excess is credited to stock but not
reflected anywhere on the order. Worth a `CreditNote`-style fallback for the excess once this
edge case actually comes up.
- No `Invoice` entity exists yet (Finance, step 19) — `CreditNote` and `SupplierClaim`'s
`ResolutionAmount` are standalone audit fields for now, not linked to any invoice/ledger
document. Revisit once Finance & Accounting is built.
- Are `Claim`s only against Regional Agencies (as the reference doc frames them), or can a claim
also be raised against an internal warehouse/supplier discrepancy?
also be raised against an internal warehouse/supplier discrepancy? Currently only
`FK_AgencyUserId` (a User with the RegionalAgency role) is supported.
- `SupplierClaim.ResolutionType = Replacement` has no link back to the eventual replacement
`GoodsReceipt` — a nullable `FK_SupplierClaimId` on `GoodsReceipt` would close that loop but
isn't built yet.
- **Inspection & Classification (deferred)**: the fuller process — inspect returned goods,
classify each line as Sellable / Damaged / Expired / Quarantine / Disposal, route sellable stock
back to available inventory while damaged/expired stock moves to a separate stock category, and
only then decide refund vs. replacement vs. credit vs. reject — is explicitly out of scope for
now. The current `SaleReturn` flow settles immediately with no inspection step (see
Background/Context for why). When it's picked up, it will need: an `InspectionResult` per
`SaleReturnItem` recorded after the item is physically back at a warehouse (not at the point of
return); a stock-location/category concept for quarantined or condemned stock (today
`StockBalance` only distinguishes `LocationType.Warehouse`/`Vehicle`); a real cash-refund concept
once Finance/Payments (step 19) exists to reverse a `Payment`; and a decision on whether
inspection reopens the immediate-settlement model into a provisional-then-confirmed one — a
bigger behavioral change than adding a field, needing its own planning pass first.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
### File structure (as built)
```
Features/
Returns/
ReturnController.cs — /api/orders/{id}/returns (POST/GET), /api/returns/{id} (GET)
ReturnDTOs.cs — CreateSaleReturnRequest, ReturnItemRequest, SaleReturnResponse(+Item)
ReturnService.cs — CreateAsync (validate → credit stock → net order → commit),
GetByIdAsync, GetByOrderIdAsync
IReturnRepository.cs / ReturnRepository.cs — includes StoreHasPriorSaleAsync
Claims/
ClaimController.cs — /api/claims (POST/GET), /api/claims/{id}/settle (POST)
ClaimDTOs.cs — CreateClaimRequest, SettleClaimRequest, ClaimResponse
ClaimService.cs — CreateAsync, SettleAsync, GetByIdAsync, GetByAgencyUserIdAsync
IClaimRepository.cs / ClaimRepository.cs
SupplierClaims/
SupplierClaimController.cs — /api/purchase-orders/{id}/claims (POST/GET),
/api/supplier-claims/{id} (GET),
/api/supplier-claims/{id}/approve|reject (POST)
SupplierClaimDTOs.cs — CreateSupplierClaimRequest, ApproveSupplierClaimRequest,
RejectSupplierClaimRequest, SupplierClaimResponse(+Item)
SupplierClaimService.cs — CreateAsync, ApproveAsync, RejectAsync, GetByIdAsync,
GetByPurchaseOrderIdAsync
ISupplierClaimRepository.cs / SupplierClaimRepository.cs
Models/
SaleReturn.cs, SaleReturnItem.cs, CreditNote.cs, Claim.cs (new)
SupplierClaim.cs, SupplierClaimItem.cs (new)
Enums.cs (extended — ReturnMode, ReturnReasonCode, ClaimStatus, SupplierClaimStatus,
SupplierClaimIssueType, SupplierClaimResolutionType added)
Data/AppDBContext.cs
DbSets added for all six entities; FK relationships via Fluent API, DeleteBehavior.Restrict
(SupplierClaim.FK_GoodsReceiptId, ResolvedByUserId configured as optional)
Migrations/
20260825073453_AddReturnsAndClaims (applied)
20260825084218_AddSupplierClaims (applied)
Program.cs
IReturnRepository/ReturnRepository, IClaimRepository/ClaimRepository,
ISupplierClaimRepository/SupplierClaimRepository registered;
ReturnService, ClaimService, SupplierClaimService registered
```
### How the models connect
```
Order (existing) ◄── FK_OrderId ── SaleReturn ── Mode: CreditDeduction | ItemExchange
│
├─ SaleReturnItem (FK_ItemId, Quantity, ReasonCode, UnitPrice)
│ └─► StockBalance/StockMovement (credited: vehicle of
│ the order's ride, MovementType.CustomerReturn)
│
└─ CreditNote (only when Mode = CreditDeduction;
Amount = sum(Quantity * UnitPrice))
Claim ── FK_AgencyUserId ──► User (holding the RegionalAgency role, seeded in
Data/Seed/RoleAndPermissionSeed.cs) — no separate Agency entity
PurchaseOrder (existing, step 15) ◄── FK_PurchaseOrderId ── SupplierClaim
│
GoodsReceipt (existing, step 15) ◄── FK_GoodsReceiptId (optional) ┘
│
├─ SupplierClaimItem (FK_ItemId,
│ Quantity, IssueType)
└─ ResolutionType: Replacement |
Credit | Refund | PriceAdjustment
(no stock/ledger write — see
Business Rules)
```
### Return flow (as implemented)
```
ReturnService.CreateAsync(orderId, request, createdByUserId):
order = load Order, must be Pending/PartiallyFulfilled else throw
sale = load order's Sale, must have FK_RideId set else throw
ride = load Sale's Ride → vehicleId = ride.FK_VehicleId
orderItems = load order's OrderItems (for price snapshot + ItemExchange offset)
for each (itemId, qty, reasonCode) in request.Items:
guard StoreHasPriorSaleAsync(order.FK_StoreId, itemId) else throw 409
unitPrice = matching OrderItem.UnitPrice if present, else Item.SalePrice
begin transaction:
for each line: credit StockBalance(itemId, vehicleId, Vehicle) += qty
add StockMovement(Type = CustomerReturn, ToLocation = vehicle, ReferenceId = return.Id)
insert SaleReturn + SaleReturnItems
if Mode == CreditDeduction:
total = sum(qty * unitPrice); SaleReturn.TotalCreditAmount = total
Order.TotalAmount = max(0, Order.TotalAmount - total)
insert CreditNote(Amount = total)
else (ItemExchange):
for each line with a matching OrderItem:
offset = min(qty, orderItem.Quantity); orderItem.Quantity -= offset
Order.TotalAmount = max(0, Order.TotalAmount - offset * orderItem.UnitPrice)
commit
```
### Claim flow (as implemented)
```
ClaimService.CreateAsync(request): insert Claim(Status = Open)
ClaimService.SettleAsync(claimId, settlementAmount, approverId):
guard claim exists and Status != Settled else throw
Claim.Status = Settled; SettlementAmount = settlementAmount
Claim.SettledByUserId = approverId; SettledAt = now
```
### Supplier claim flow (as implemented)
```
SupplierClaimService.CreateAsync(purchaseOrderId, request, raisedByUserId):
po = load PurchaseOrder, must exist and be active else throw
guard Details non-empty, Items non-empty
if GoodsReceiptId given: guard it exists and belongs to this PO else throw
insert SupplierClaim(Status = Open) + SupplierClaimItems
SupplierClaimService.ApproveAsync(id, request, approverId):
guard claim exists and Status == Open else throw
guard ResolutionAmount present unless ResolutionType == Replacement else throw
Status = Approved; ResolutionType/Amount/Notes set; ResolvedByUserId = approverId; ResolvedAt = now
SupplierClaimService.RejectAsync(id, request, approverId):
guard claim exists and Status == Open else throw
Status = Rejected; ResolutionNotes set; ResolvedByUserId = approverId; ResolvedAt = now
```
+75 -29
View File
@@ -11,26 +11,32 @@
| Region / Route / Vehicle CRUD | Done | Full API surface with permission checks |
| Store / Warehouse CRUD | Done | Full API surface with permission checks |
| Store Management (Beyond CRUD) | Done | Credit limit, KYC, segment, dashboard, scheme eligibility; see [12_store_management.md](./12_store_management.md) |
| Order CRUD | Done | Scope-aware (user only sees their assigned stores) |
| Order CRUD | Done | Scope-aware (user only sees their assigned stores); an `Order` is now created only as a side effect of `Sale` creation, not standalone — see §1 Order Management notes below |
| Sale & Payment | Done | `Features/Sale` creates a `Sale`+linked `Order` together (pricing, KYC threshold, immediate-handover stock check); `Features/Payment` records collections against a `Sale` and drives its `PaymentStatus` |
| Unit of Work + Repositories | Done | Transaction support throughout |
| Soft Deletes | Done | IsActive pattern on all entities |
| Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
| Promotions & Trade Schemes | Done | BuyXGetY/FreeBundle/SlabDiscount/FlatPercent schemes, free-goods fulfillment, spend/ROI tracking; see [17_promotions_schemes.md](./17_promotions_schemes.md) |
| Procurement / Purchasing | Done | Purchase Order → Goods Receipt → Purchase Return, supplier evaluation; see [15_procurement.md](./15_procurement.md) |
| Returns & Claims Management | Done | Sale returns, claims, supplier claims; see [18_returns_claims.md](./18_returns_claims.md) |
## Known Gaps in Existing System (Pre-Work)
## Known Gaps in Existing System (Pre-Work) — Resolved
These are not new features — they are fixable holes in what's already built.
They must be resolved before new feature steps begin because later steps depend on correct foundations.
Step 00 closed these. Kept here for history; all are fixed in the current codebase.
| Gap | Impact | Resolution |
|---|---|---|
| Route DTOs missing RegionId | Can't link routes to regions via API | Fixed — `CreateRouteRequest`/`UpdateRouteRequest`/`RouteResponse` all carry `RegionId` (`Features/Route/RouteDTOs.cs`). |
| Store DTOs missing RouteId | Can't link stores to routes via API | Fixed — all Store DTOs carry `RouteId` (`Features/Store/StoreDTOs.cs`). |
| Role endpoints have no [Authorize] | Anyone can read roles without authentication | Fixed — `[Authorize]` on `RoleController` (`Features/Role/RoleController.cs`). |
| Item model is empty (no properties) | OrderItems reference items that have no data | Fixed — `Item` has `Sku`/`Name`/`Description`/category/brand/UOM/pricing fields (`Models/Item.cs`). |
### Still open (not a step-00 blocker, tracked separately)
| Gap | Impact |
|---|---|
| Route DTOs missing RegionId | Can't link routes to regions via API |
| Store DTOs missing RouteId | Can't link stores to routes via API |
| Role endpoints have no [Authorize] | Anyone can read roles without authentication |
| JWT key & DB credentials in appsettings.json | Security risk in any shared/production environment |
| No input validation on any DTO | Bad data can enter the system silently |
| Item model is empty (no properties) | OrderItems reference items that have no data |
| JWT key & DB credentials in appsettings.json | Security risk in any shared/production environment — `appsettings.json` still has a literal DB password and placeholder JWT key. |
| No input validation on any DTO | Bad data can enter the system silently. |
---
@@ -56,7 +62,7 @@ They must be resolved before new feature steps begin because later steps depend
| 15 | Procurement / Purchasing | Done | 01, 14 | [15_procurement.md](./15_procurement.md) |
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
| 17 | Promotions & Trade Scheme Management | Done | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
| 18 | Returns & Claims Management | Not Started | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) |
| 18 | Returns & Claims Management | Done | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) |
| 19 | Finance & Accounting | Not Started | 05, 06, 18 | [19_finance_accounting.md](./19_finance_accounting.md) |
| 20 | Reporting, Analytics & BI | Not Started | all prior | [20_reporting_analytics.md](./20_reporting_analytics.md) |
| 21 | Notifications & Alerts | Not Started | 02, 12, 08 | [21_notifications_alerts.md](./21_notifications_alerts.md) |
@@ -81,18 +87,27 @@ here — see `SRS_User_Management_Module.pdf`.
### 1. Order Management
> **Design change since this section was first written**: `Order` is no longer created directly
> from `storeId/items/salesRepId`. Order creation was folded into `Features/Sale` — every `Order`
> is now a 1:1 side effect of `SaleService.CreateAsync` (see §6 Sale Ride Execution). `POST
> /api/orders` (`OrderService.CreateAsync`) is now just a lookup — it throws
> `"Orders are created automatically from sales"` if called for a sale that doesn't already have
> one, rather than creating a standalone order. All the validation described below (stock check,
> KYC threshold, pricing) now happens in `SaleService.CreateAsync`
> (`Features/Sale/SaleService.cs`), not `OrderService`.
| Function | What it does | Status |
|---|---|---|
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented - `OrderService.CreateAsync` exists, enforces store scope, blocks inactive stores, applies the unverified-KYC order threshold from config, and now checks stock availability (see `checkStockAvailability` below). It still has no credit-limit validation, approval flow, or order state machine. |
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` now has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or order-time credit-limit enforcement yet. |
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Implemented — `OrderService.CreateAsync` rejects an order if any line's requested quantity exceeds total `StockBalance` for that item summed across every location (added alongside [15_procurement.md](./15_procurement.md)). Not location-specific yet (no fulfilling-warehouse concept on `Store`/`Order`), and does not reserve or decrement stock — that stays deferred to loading/delivery (§05-06). |
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `OrderService.CreateAsync` resolves the price-list half via `PriceListService.ResolveUnitPriceAsync` (region-scoped → global → `Item.SalePrice`, or a caller-supplied `UnitPrice`). The scheme-discount half now exists as a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — but is not yet auto-invoked from `OrderService.CreateAsync`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after order creation. |
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Superseded — see design-change note above. The equivalent entry point is `SaleService.CreateAsync` (`createSale`, §6), which creates the `Sale` and its linked `Order` together. `OrderService.CreateAsync` itself only fetches the existing order for a given `SaleId`. |
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or sale/order-time credit-limit enforcement yet. |
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Partially implemented — `SaleService.CreateAsync` checks `StockBalance` at the ride's vehicle, but **only** when `FulfillImmediately = true` and a `RideId` is supplied (immediate van-sale handover); a deferred/pending sale does no stock check at create time at all. Not warehouse-wide, not reserved — a failed check simply returns `null` (400) rather than a typed error. |
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `SaleService.CreateAsync` resolves the price-list half via `ResolveUnitPriceAsync` → `PriceListService.ResolvePriceAsync` (region-scoped via the store's route → `Item.SalePrice` fallback, or a caller-supplied `UnitPrice` validated against `[MinPrice, Price]`). The scheme-discount half is a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — not auto-invoked from `createSale`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after the order exists. |
| `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). |
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. |
| `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Not implemented — `Order` has no status field or state machine today. |
| `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Implemented, with a smaller state machine than described — `Models/Enums.cs` defines `OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }`; `OrderService.UpdateAsync` (`PUT /api/orders/{id}`) sets `Order.Status` directly (any status accepted, no transition guard) and mirrors it onto the linked `Sale.Status`/`CompletedAt`. No `approved/picked/dispatched/invoiced` states exist. |
| `convertOrderToInvoice(orderId)` | Generates the invoice once the order is dispatched/delivered. | Not implemented — depends on §13 Finance & Accounting. |
| `createBackorder(orderId, unfulfilledItems[])` | Splits unfulfillable quantity into a linked backorder. | Not implemented. |
| `cancelOrder(orderId, reason)` | Cancels an order pre-dispatch and releases any reserved stock. | Not implemented — `OrderController` supports soft delete only, no reason code or stock release. |
| `cancelOrder(orderId, reason)` | Cancels an order pre-dispatch and releases any reserved stock. | Partially implemented — `OrderService.DeleteAsync` (`DELETE /api/orders/{id}`) soft-deletes the order and cascades `Sale.Status = Cancelled`, but takes no reason code and releases no reserved stock (nothing is reserved at create time to release). |
| `getOrdersByStore(storeId)` / `getOrdersBySalesRep(salesRepId)` | Scoped order listings. | `getOrdersByStore` implemented (`GET /api/orders/store/{storeId}`). `getOrdersBySalesRep` not implemented. |
| `getOrderAnalytics(filters)` | Returns fill rate, average order value, rejection rate over a period. | Not implemented. |
@@ -129,8 +144,11 @@ here — see `SRS_User_Management_Module.pdf`.
| Function | What it does | Status |
|---|---|---|
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{id}/kyc-documents` endpoints rather than inline on create. |
| `uploadStoreKycDocument(storeId, documentType, file)` | Appends a KYC document to a store. | Implemented — `POST /api/stores/{id}/kyc-documents`. |
| `listStoreKycDocuments(storeId)` | Lists KYC documents for a store. | Implemented — `GET /api/stores/{id}/kyc-documents`. |
| `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. |
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Implemented - `PUT /api/stores/{id}/credit-limit` updates `Store.CreditLimit` and requires the `stores.credit.update` permission plus role level >= 20. |
| `updateStoreOutstandingBalance(storeId, amount, reason?)` | Manually adjusts the cached outstanding balance. | Implemented — `PUT /api/stores/{id}/outstanding-balance`. |
| `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Partially implemented - `GET /api/stores/{id}/outstanding-balance` returns the cached `OutstandingBalance`, `CreditLimit`, and available credit. It is not yet driven by Finance events or invoice/payment records. |
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. |
| `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. |
@@ -177,9 +195,18 @@ here — see `SRS_User_Management_Module.pdf`.
|---|---|---|
| `createProduct(skuData)` | Creates a SKU with category, UOM, barcode, pack hierarchy. | Not implemented — `Item` model exists but has no properties; see [01_item_catalog.md](./01_item_catalog.md). |
| `deactivateSku(sku, reason)` | Retires a SKU from active ordering. | Not implemented. |
| `listPriceLists(regionId?)` | Lists price lists, optionally filtered by region. | Implemented — `GET /api/price-lists`. |
| `getPriceList(priceListId)` | Fetches a price list and its items. | Implemented — `GET /api/price-lists/{id}`. |
| `createPriceList(name, region, effectiveDates)` | Defines a new price list scoped to a region/date range. | Implemented — `POST /api/price-lists`, see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md). |
| `updateProductPricing(sku, priceListId, price)` | Sets/updates the price of a SKU within a price list. | Implemented — `PUT /api/price-lists/{id}/items/{sku}`. |
| `updatePriceList(priceListId, name, region, effectiveDates)` | Updates price-list metadata. | Implemented — `PUT /api/price-lists/{id}`. |
| `deletePriceList(priceListId)` | Soft-deletes a price list. | Implemented — `DELETE /api/price-lists/{id}`. |
| `updateProductPricing(sku, priceListId, price, minPrice?)` | Sets/updates the price of a SKU within a price list. | Implemented — `PUT /api/price-lists/{id}/items/{sku}`. |
| `removeProductPricing(sku, priceListId)` | Removes a SKU override from a price list. | Implemented — `DELETE /api/price-lists/{id}/items/{sku}`. |
| `listSuppliers()` | Lists suppliers. | Implemented — `GET /api/suppliers`. |
| `getSupplier(supplierId)` | Fetches a supplier by ID. | Implemented — `GET /api/suppliers/{id}`. |
| `createSupplierMaster(supplierData)` | Registers a new supplier/vendor. | Implemented — `POST /api/suppliers`. `PerformanceScore` stays null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier` exists. |
| `updateSupplier(supplierId, supplierData)` | Updates supplier master data. | Implemented — `PUT /api/suppliers/{id}`. `PerformanceScore` remains read-only here. |
| `deleteSupplier(supplierId)` | Soft-deletes a supplier. | Implemented — `DELETE /api/suppliers/{id}`. Blocked if an active PurchaseOrder references the supplier. |
### 8. Enterprise-wide Inventory & Stock
@@ -197,9 +224,21 @@ here — see `SRS_User_Management_Module.pdf`.
|---|---|---|
| `createPurchaseRequisition(items[], requestedBy)` | Raises an internal request to purchase stock. | Not implemented — no procurement entities exist. |
| `createPurchaseOrder(supplierId, items[])` | Issues a PO to a supplier. | Implemented — `POST /api/purchase-orders`, see [15_procurement.md](./15_procurement.md). Also has `updatePurchaseOrder`, `closePurchaseOrder`, `cancelPurchaseOrder`, `cancelPurchaseOrderLine`. |
| `getPurchaseOrder(poId)` | Fetches a purchase order. | Implemented — `GET /api/purchase-orders/{id}`. |
| `listPurchaseOrders(filters)` | Lists purchase orders. | Implemented — `GET /api/purchase-orders`. |
| `updatePurchaseOrder(poId, items[])` | Updates an open purchase order. | Implemented — `PUT /api/purchase-orders/{id}`. |
| `closePurchaseOrder(poId)` | Closes a received purchase order. | Implemented — `POST /api/purchase-orders/{id}/close`. |
| `cancelPurchaseOrder(poId, reason)` | Cancels a purchase order before receipts exist. | Implemented — `POST /api/purchase-orders/{id}/cancel`. |
| `cancelPurchaseOrderLine(poId, poItemId, reason)` | Cancels a single PO line. | Implemented — `POST /api/purchase-orders/{id}/items/{itemId}/cancel`. |
| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Partially implemented — `POST /api/purchase-orders/{id}/match-invoice` reconciles PurchaseOrderItem vs GoodsReceiptItem quantities/cost only; full 3-way match activates once Finance's `Invoice` entity (§13, not built) exists. `resolveInvoiceVariance` records the mismatch and its resolution. |
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Implemented — `POST /api/suppliers/{id}/evaluate`, the only writer of `Supplier.PerformanceScore`. Scored from accepted-vs-rejected quantity ratio and cancelled-vs-fulfilled line ratio across that supplier's PurchaseOrders/GoodsReceipts. |
| `resolveInvoiceVariance(poId, variance, resolution)` | Records how a mismatch should be handled. | Implemented — `POST /api/purchase-orders/{id}/resolve-variance`. |
| `getGoodsReceipt(grnId)` | Fetches a goods receipt. | Implemented — `GET /api/goods-receipts/{id}`. |
| `listGoodsReceipts(poId)` | Lists goods receipts for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/goods-receipts`. |
| `createGoodsReceipt(poId, warehouseId, items[])` | Records inbound stock against a purchase order. | Implemented — `POST /api/purchase-orders/{id}/goods-receipts`. |
| `getPurchaseReturn(returnId)` | Fetches a purchase return. | Implemented — `GET /api/purchase-returns/{id}`. |
| `listPurchaseReturns(poId)` | Lists purchase returns for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/returns`. |
| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Implemented — `POST /api/purchase-orders/{id}/returns`, see [15_procurement.md](./15_procurement.md). Debits `StockBalance`/`StockMovement` at the PO's receiving warehouse. |
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Implemented — `POST /api/suppliers/{id}/evaluate`, the only writer of `Supplier.PerformanceScore`. Scored from accepted-vs-rejected quantity ratio and cancelled-vs-fulfilled line ratio across that supplier's PurchaseOrders/GoodsReceipts. |
### 10. Sales Force Automation (SFA) / CRM
@@ -225,19 +264,25 @@ here — see `SRS_User_Management_Module.pdf`.
| Function | What it does | Status |
|---|---|---|
| `createReturnRequest(orderId, items[], reasonCode)` | Initiates a return against a delivered order. | Not implemented — see [09_fraud_controls.md](./09_fraud_controls.md) for the fraud-control angle on returns. |
| `approveReturn(returnId, approverId)` | Approves/rejects the return request. | Not implemented. |
| `generateCreditNote(returnId)` | Issues a credit note against the original invoice. | Not implemented. |
| `createClaim(agencyId, claimDetails)` | Raises a claim against a Regional Agency (e.g., damaged stock). | Not implemented. |
| `settleClaim(claimId, settlementAmount)` | Closes out a claim with a final settlement amount. | Not implemented. |
| `createSaleReturn(orderId, mode, items[])` | Records a customer return against a delivered order. | Implemented — `POST /api/orders/{id}/returns`, settles immediately and credits the ride's vehicle. No approval step. |
| `getSaleReturn(returnId)` | Fetches a sale return by ID. | Implemented — `GET /api/returns/{id}`. |
| `listSaleReturns(orderId)` | Lists sale returns for an order. | Implemented — `GET /api/orders/{id}/returns`. |
| `createClaim(agencyUserId, details)` | Raises a claim against a Regional Agency user. | Implemented — `POST /api/claims`. |
| `getClaim(claimId)` | Fetches a claim by ID. | Implemented — `GET /api/claims/{id}`. |
| `settleClaim(claimId, settlementAmount)` | Closes out a claim with a final settlement amount. | Implemented — `POST /api/claims/{id}/settle`, requires `claims.settle`. |
| `createSupplierClaim(poId, grnId?, items[], details, evidenceUrl?)` | Raises a supplier claim against a purchase order. | Implemented — `POST /api/purchase-orders/{id}/claims`. |
| `getSupplierClaim(claimId)` | Fetches a supplier claim by ID. | Implemented — `GET /api/supplier-claims/{id}`. |
| `listSupplierClaims(poId)` | Lists supplier claims for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/claims`. |
| `approveSupplierClaim(claimId, resolutionType, resolutionAmount?, notes?)` | Approves a supplier claim. | Implemented — `POST /api/supplier-claims/{id}/approve`, requires `suppliers.claims.approve`. |
| `rejectSupplierClaim(claimId, notes?)` | Rejects a supplier claim. | Implemented — `POST /api/supplier-claims/{id}/reject`, requires `suppliers.claims.approve`. |
### 13. Finance & Accounting
| Function | What it does | Status |
|---|---|---|
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no invoice entity exists. |
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Not implemented. |
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented. |
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no `Invoice` entity exists; `Sale`/`Order` remain the billable record. |
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Implemented against `Sale` rather than an invoice — `POST /api/sales/{saleId}/payments` (`Features/Payment`). Each payment sums against `Sale.TotalAmount` and updates `Sale.PaymentStatus` (`Pending`/`PartiallyPaid`/`Paid`); rejects (400) if the sale is `Cancelled`. `Method` is `Cash` or `Credit` (`Models/Enums.cs`). |
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented — no GL/ledger entity exists. |
| `generateTaxInvoice(invoiceId, jurisdiction)` | Produces a tax-compliant invoice (GST/VAT e-invoicing). | Not implemented. |
| `getFinancialReport(entityId, period, reportType)` | Returns P&L/cash-flow reports scoped to a region/area. | Not implemented. |
@@ -359,8 +404,9 @@ and approval chains for exceptions. Supervisors can grant one-time overrides log
A dedicated audit log records every sensitive event (who, what, when, from where).
Automated checks flag: returns without a matching prior sale, sales outside the geofenced route,
agents with consistently high variance rates, and cash collected below invoiced amounts.
Return approval requires supervisor sign-off before stock is credited. Anomaly reports are
surfaced to area managers on a scheduled basis.
Return-related processing is handled in §12 as an immediate-settlement flow; this step focuses on
audit logging and anomaly detection around those events. Anomaly reports are surfaced to area
managers on a scheduled basis.
**Gate**: Every sensitive event is logged immutably. Automated flags surface to the right level of management.
---