Add store management workflows and KYC order gating

This commit is contained in:
2026-08-20 13:08:00 +05:30
parent c1961ebea2
commit e88668beca
8 changed files with 465 additions and 37 deletions
+18 -1
View File
@@ -1,9 +1,18 @@
using Distribution.Features.Store;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Order;
public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
public class OrderService(
IOrderRepository repo,
IStoreRepository storeRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
private const int GlobalScopeRoleLevel = 50;
@@ -63,6 +72,14 @@ public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserSe
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var store = await storeRepo.GetByIdAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var order = new Models.Order
{
FK_StoreId = request.StoreId,
@@ -7,4 +7,5 @@ public interface IStoreRepository
Task AddAsync(Models.Store entity);
void Update(Models.Store entity);
void Remove(Models.Store entity);
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
}
@@ -44,4 +44,68 @@ public class StoreController(StoreService service) : ControllerBase
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/credit-limit")]
[Authorize(Policy = "stores.credit.update")]
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
{
var result = await service.UpdateCreditLimitAsync(id, request, ct);
return result is null ? Forbid() : Ok(result);
}
[HttpPut("{id:guid}/outstanding-balance")]
[Authorize(Policy = "stores.credit.update")]
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
{
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
return result is null ? Forbid() : Ok(result);
}
[HttpGet("{id:guid}/outstanding-balance")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetOutstandingBalance(Guid id)
{
var result = await service.GetOutstandingBalanceAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/kyc/verify")]
[Authorize(Policy = "stores.kyc.verify")]
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
{
var result = await service.VerifyKycAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/segment")]
[Authorize(Policy = "stores.update")]
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
{
var result = await service.ClassifySegmentAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/visit-history")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetVisitHistory(Guid id)
{
var result = await service.GetVisitHistoryAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
{
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/dashboard")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
}
+22
View File
@@ -1,3 +1,5 @@
using Distribution.Models;
namespace Distribution.Features.Store;
public record CreateStoreRequest(
@@ -30,5 +32,25 @@ public record StoreResponse(
decimal Longitude,
decimal OneTimeAmount,
decimal CreditAmount,
decimal CreditLimit,
decimal OutstandingBalance,
string? Segment,
KycStatus KycStatus,
bool IsActive,
DateTime CreatedAt);
public record UpdateCreditLimitRequest(decimal NewLimit, Guid ApproverId);
public record UpdateOutstandingBalanceRequest(decimal NewBalance);
public record VerifyKycRequest(KycStatus Status, string? Notes);
public record ClassifySegmentRequest(string Segment);
public record OutstandingBalanceResponse(Guid StoreId, decimal CreditLimit, decimal OutstandingBalance, decimal AvailableCredit);
public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUserId, string Outcome);
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
@@ -22,4 +22,9 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
entity.IsActive = false;
context.Stores.Update(entity);
}
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive) =>
await context.Orders
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive)
.ToListAsync();
}
+137 -4
View File
@@ -1,11 +1,13 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Store;
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
{
private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
@@ -15,6 +17,10 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private static StoreResponse ToResponse(Models.Store s) =>
new(s.Id, s.FK_RouteId, s.OwnerName, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount,
s.CreditLimit, s.OutstandingBalance, s.Segment, s.KycStatus, s.IsActive, s.CreatedAt);
public async Task<List<StoreResponse>> GetAllAsync()
{
var stores = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
@@ -23,7 +29,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
stores = stores.Where(s => accessibleStoreIds.Contains(s.Id)).ToList();
}
return stores.Select(s => new StoreResponse(s.Id, s.FK_RouteId, s.OwnerName, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount, s.IsActive, s.CreatedAt)).ToList();
return stores.Select(ToResponse).ToList();
}
public async Task<StoreResponse?> GetByIdAsync(Guid id)
@@ -31,7 +37,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt);
return ToResponse(store);
}
public async Task<StoreResponse> CreateAsync(CreateStoreRequest request, CancellationToken ct = default)
@@ -58,7 +64,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
await uow.RollbackAsync(ct);
throw;
}
return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt);
return ToResponse(store);
}
public async Task<StoreResponse?> UpdateAsync(Guid id, UpdateStoreRequest request, CancellationToken ct = default)
@@ -85,7 +91,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
await uow.RollbackAsync(ct);
throw;
}
return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt);
return ToResponse(store);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
@@ -106,4 +112,131 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
}
return true;
}
public async Task<StoreResponse?> UpdateCreditLimitAsync(Guid id, UpdateCreditLimitRequest request, CancellationToken ct = default)
{
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.CreditLimit = request.NewLimit;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<StoreResponse?> UpdateOutstandingBalanceAsync(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct = default)
{
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.OutstandingBalance = request.NewBalance;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<OutstandingBalanceResponse?> GetOutstandingBalanceAsync(Guid id)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, store.OutstandingBalance, store.CreditLimit - store.OutstandingBalance);
}
public async Task<StoreResponse?> VerifyKycAsync(Guid id, VerifyKycRequest request, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.KycStatus = request.Status;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<StoreResponse?> ClassifySegmentAsync(Guid id, ClassifySegmentRequest request, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.Segment = request.Segment;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<List<StoreVisitHistoryItem>?> GetVisitHistoryAsync(Guid id)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
// Wired to Step 11 (Route & Sales Management) once ride/visit tracking exists.
return [];
}
public async Task<SchemeEligibilityResponse?> CheckSchemeEligibilityAsync(Guid id, Guid schemeId)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
// Wired to Step 17 (Promotions & Schemes) once scheme rules exist.
return new SchemeEligibilityResponse(true, null);
}
public async Task<StoreDashboardResponse?> GetDashboardAsync(Guid id, string period)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
if (!DateTime.TryParseExact(period, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var periodStart))
periodStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
var periodEndExclusive = periodStart.AddMonths(1);
var orders = await repo.GetOrdersForDashboardAsync(id, periodStart, periodEndExclusive);
return new StoreDashboardResponse(
id,
periodStart.ToString("yyyy-MM"),
orders.Count,
orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.OrderDate));
}
}