Merge pull request 'Feat/region mngt' (#16) from feat/region_mngt into dev
Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.Region;
|
using Distribution.Features.Region;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Moq;
|
using Moq;
|
||||||
@@ -10,11 +11,14 @@ public class RegionServiceTests
|
|||||||
{
|
{
|
||||||
private readonly Mock<IRegionRepository> _repo = new();
|
private readonly Mock<IRegionRepository> _repo = new();
|
||||||
private readonly Mock<IUnitOfWork> _uow = new();
|
private readonly Mock<IUnitOfWork> _uow = new();
|
||||||
|
private readonly Mock<ICurrentUserService> _currentUser = new();
|
||||||
|
private readonly Mock<IScopeAccessService> _scopeAccess = new();
|
||||||
private readonly RegionService _sut;
|
private readonly RegionService _sut;
|
||||||
|
|
||||||
public RegionServiceTests()
|
public RegionServiceTests()
|
||||||
{
|
{
|
||||||
_sut = new RegionService(_repo.Object, _uow.Object);
|
_currentUser.Setup(u => u.RoleLevel).Returns(60);
|
||||||
|
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Region ActiveRegion(string name = "North") =>
|
private static Region ActiveRegion(string name = "North") =>
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<Claim> Claims { get; set; }
|
public DbSet<Claim> Claims { get; set; }
|
||||||
public DbSet<SupplierClaim> SupplierClaims { get; set; }
|
public DbSet<SupplierClaim> SupplierClaims { get; set; }
|
||||||
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
|
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
|
||||||
|
public DbSet<RegionalTarget> RegionalTargets { get; set; }
|
||||||
|
public DbSet<RegionalBudget> RegionalBudgets { get; set; }
|
||||||
|
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
|
||||||
|
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -373,6 +377,28 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(t => t.FK_OrderId)
|
.HasForeignKey(t => t.FK_OrderId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionalTarget>()
|
||||||
|
.HasIndex(t => new { t.FK_RegionId, t.Period })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionalBudget>()
|
||||||
|
.HasIndex(b => new { b.FK_RegionId, b.Period })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionAgencyMap>()
|
||||||
|
.HasIndex(m => new { m.FK_RegionId, m.FK_AgencyUserId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionAgencyMap>()
|
||||||
|
.HasOne(m => m.AgencyUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(m => m.FK_AgencyUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionalTaxRule>()
|
||||||
|
.HasIndex(r => r.FK_RegionId)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,26 @@ public interface IRegionRepository
|
|||||||
Task AddAsync(Models.Region entity);
|
Task AddAsync(Models.Region entity);
|
||||||
void Update(Models.Region entity);
|
void Update(Models.Region entity);
|
||||||
void Remove(Models.Region entity);
|
void Remove(Models.Region entity);
|
||||||
|
|
||||||
|
Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period);
|
||||||
|
Task AddTargetAsync(Models.RegionalTarget target);
|
||||||
|
void UpdateTarget(Models.RegionalTarget target);
|
||||||
|
|
||||||
|
Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period);
|
||||||
|
Task AddBudgetAsync(Models.RegionalBudget budget);
|
||||||
|
void UpdateBudget(Models.RegionalBudget budget);
|
||||||
|
|
||||||
|
Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId);
|
||||||
|
Task AddAgencyMapAsync(Models.RegionAgencyMap map);
|
||||||
|
|
||||||
|
Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId);
|
||||||
|
Task AddTaxRuleAsync(Models.RegionalTaxRule rule);
|
||||||
|
void UpdateTaxRule(Models.RegionalTaxRule rule);
|
||||||
|
|
||||||
|
Task<int?> GetUserRoleLevelAsync(Guid userId);
|
||||||
|
|
||||||
|
Task<List<Models.Route>> GetRoutesAsync(Guid regionId);
|
||||||
|
Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds);
|
||||||
|
|
||||||
|
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,46 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
return deleted ? NoContent() : NotFound();
|
return deleted ? NoContent() : NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/targets/{period}")]
|
||||||
|
public async Task<IActionResult> SetTarget(Guid id, string period, SetRegionalTargetRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.SetTargetAsync(id, period, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/budgets/{period}")]
|
||||||
|
public async Task<IActionResult> SetBudget(Guid id, string period, SetRegionalBudgetRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.SetBudgetAsync(id, period, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/agencies")]
|
||||||
|
public async Task<IActionResult> MapAgency(Guid id, MapAgencyToRegionRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.MapAgencyAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/tax-rule")]
|
||||||
|
public async Task<IActionResult> SetTaxRule(Guid id, SetRegionalTaxRuleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.SetTaxRuleAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/hierarchy")]
|
||||||
|
public async Task<IActionResult> GetHierarchy(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetHierarchyAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/dashboard")]
|
||||||
|
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||||
|
{
|
||||||
|
var result = await service.GetDashboardAsync(id, period);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,32 @@ public record CreateRegionRequest(string Name);
|
|||||||
public record UpdateRegionRequest(string Name);
|
public record UpdateRegionRequest(string Name);
|
||||||
|
|
||||||
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record SetRegionalTargetRequest(decimal TargetValue);
|
||||||
|
|
||||||
|
public record SetRegionalBudgetRequest(decimal BudgetAmount);
|
||||||
|
|
||||||
|
public record MapAgencyToRegionRequest(Guid AgencyUserId);
|
||||||
|
|
||||||
|
public record SetRegionalTaxRuleRequest(string RuleConfig);
|
||||||
|
|
||||||
|
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
|
||||||
|
|
||||||
|
public record RegionalBudgetResponse(Guid RegionId, string Period, decimal BudgetAmount);
|
||||||
|
|
||||||
|
public record RegionAgencyMapResponse(Guid RegionId, Guid AgencyUserId);
|
||||||
|
|
||||||
|
public record RegionalTaxRuleResponse(Guid RegionId, string RuleConfig);
|
||||||
|
|
||||||
|
public record RegionHierarchyRouteItem(Guid RouteId, string RouteName, List<Guid> StoreIds);
|
||||||
|
|
||||||
|
public record RegionHierarchyResponse(Guid RegionId, string RegionName, List<RegionHierarchyRouteItem> Routes);
|
||||||
|
|
||||||
|
public record RegionDashboardResponse(
|
||||||
|
Guid RegionId,
|
||||||
|
string Period,
|
||||||
|
decimal? TargetValue,
|
||||||
|
decimal? BudgetAmount,
|
||||||
|
int OrderCount,
|
||||||
|
decimal ActualSales,
|
||||||
|
DateTime? LastOrderDate);
|
||||||
|
|||||||
@@ -22,4 +22,60 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
|
|||||||
entity.IsActive = false;
|
entity.IsActive = false;
|
||||||
context.Regions.Update(entity);
|
context.Regions.Update(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period) =>
|
||||||
|
await context.RegionalTargets.FirstOrDefaultAsync(t =>
|
||||||
|
t.FK_RegionId == regionId && t.Period == period && t.IsActive);
|
||||||
|
|
||||||
|
public async Task AddTargetAsync(Models.RegionalTarget target) =>
|
||||||
|
await context.RegionalTargets.AddAsync(target);
|
||||||
|
|
||||||
|
public void UpdateTarget(Models.RegionalTarget target) =>
|
||||||
|
context.RegionalTargets.Update(target);
|
||||||
|
|
||||||
|
public async Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period) =>
|
||||||
|
await context.RegionalBudgets.FirstOrDefaultAsync(b =>
|
||||||
|
b.FK_RegionId == regionId && b.Period == period && b.IsActive);
|
||||||
|
|
||||||
|
public async Task AddBudgetAsync(Models.RegionalBudget budget) =>
|
||||||
|
await context.RegionalBudgets.AddAsync(budget);
|
||||||
|
|
||||||
|
public void UpdateBudget(Models.RegionalBudget budget) =>
|
||||||
|
context.RegionalBudgets.Update(budget);
|
||||||
|
|
||||||
|
public async Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId) =>
|
||||||
|
await context.RegionAgencyMaps.FirstOrDefaultAsync(m =>
|
||||||
|
m.FK_RegionId == regionId && m.FK_AgencyUserId == agencyUserId && m.IsActive);
|
||||||
|
|
||||||
|
public async Task AddAgencyMapAsync(Models.RegionAgencyMap map) =>
|
||||||
|
await context.RegionAgencyMaps.AddAsync(map);
|
||||||
|
|
||||||
|
public async Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId) =>
|
||||||
|
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId && r.IsActive);
|
||||||
|
|
||||||
|
public async Task AddTaxRuleAsync(Models.RegionalTaxRule rule) =>
|
||||||
|
await context.RegionalTaxRules.AddAsync(rule);
|
||||||
|
|
||||||
|
public void UpdateTaxRule(Models.RegionalTaxRule rule) =>
|
||||||
|
context.RegionalTaxRules.Update(rule);
|
||||||
|
|
||||||
|
public async Task<int?> GetUserRoleLevelAsync(Guid userId) =>
|
||||||
|
await context.Users
|
||||||
|
.Where(u => u.Id == userId && u.IsActive)
|
||||||
|
.Select(u => (int?)u.Role.Level)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.Route>> GetRoutesAsync(Guid regionId) =>
|
||||||
|
await context.Routes.Where(r => r.FK_RegionId == regionId && r.IsActive).ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds) =>
|
||||||
|
await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
|
||||||
|
await (from o in context.Orders
|
||||||
|
join s in context.Stores on o.FK_StoreId equals s.Id
|
||||||
|
join r in context.Routes on s.FK_RouteId equals r.Id
|
||||||
|
where r.FK_RegionId == regionId && o.IsActive && s.IsActive && r.IsActive
|
||||||
|
&& o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive
|
||||||
|
select o).ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
namespace Distribution.Features.Region;
|
namespace Distribution.Features.Region;
|
||||||
|
|
||||||
public class RegionService(IRegionRepository repo, IUnitOfWork uow)
|
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
|
||||||
{
|
{
|
||||||
|
private const int RegionalManagerRoleLevel = 50;
|
||||||
|
private const int RegionalAgencyRoleLevel = 60;
|
||||||
|
|
||||||
|
private async Task<bool> CanManageRegionAsync(Guid regionId)
|
||||||
|
{
|
||||||
|
if (currentUser.RoleLevel >= RegionalAgencyRoleLevel) return true;
|
||||||
|
if (currentUser.RoleLevel < RegionalManagerRoleLevel || currentUser.UserId is null) return false;
|
||||||
|
return await scopeAccess.HasRegionScopeAsync(currentUser.UserId.Value, regionId);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<RegionResponse>> GetAllAsync() =>
|
public async Task<List<RegionResponse>> GetAllAsync() =>
|
||||||
(await repo.GetAllAsync())
|
(await repo.GetAllAsync())
|
||||||
.Where(r => r.IsActive)
|
.Where(r => r.IsActive)
|
||||||
@@ -70,4 +81,161 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow)
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<RegionalTargetResponse?> SetTargetAsync(Guid regionId, string period, SetRegionalTargetRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var region = await repo.GetByIdAsync(regionId);
|
||||||
|
if (region is null || !region.IsActive) return null;
|
||||||
|
if (!await CanManageRegionAsync(regionId)) return null;
|
||||||
|
|
||||||
|
var target = await repo.GetTargetAsync(regionId, period);
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = period, TargetValue = request.TargetValue };
|
||||||
|
await repo.AddTargetAsync(target);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
target.TargetValue = request.TargetValue;
|
||||||
|
repo.UpdateTarget(target);
|
||||||
|
}
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
return new RegionalTargetResponse(regionId, period, target.TargetValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RegionalBudgetResponse?> SetBudgetAsync(Guid regionId, string period, SetRegionalBudgetRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var region = await repo.GetByIdAsync(regionId);
|
||||||
|
if (region is null || !region.IsActive) return null;
|
||||||
|
if (!await CanManageRegionAsync(regionId)) return null;
|
||||||
|
|
||||||
|
var budget = await repo.GetBudgetAsync(regionId, period);
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (budget is null)
|
||||||
|
{
|
||||||
|
budget = new Models.RegionalBudget { FK_RegionId = regionId, Period = period, BudgetAmount = request.BudgetAmount };
|
||||||
|
await repo.AddBudgetAsync(budget);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
budget.BudgetAmount = request.BudgetAmount;
|
||||||
|
repo.UpdateBudget(budget);
|
||||||
|
}
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
return new RegionalBudgetResponse(regionId, period, budget.BudgetAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RegionAgencyMapResponse?> MapAgencyAsync(Guid regionId, MapAgencyToRegionRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var region = await repo.GetByIdAsync(regionId);
|
||||||
|
if (region is null || !region.IsActive) return null;
|
||||||
|
if (!await CanManageRegionAsync(regionId)) return null;
|
||||||
|
|
||||||
|
var agencyRoleLevel = await repo.GetUserRoleLevelAsync(request.AgencyUserId);
|
||||||
|
if (agencyRoleLevel is null || agencyRoleLevel < RegionalAgencyRoleLevel) return null;
|
||||||
|
|
||||||
|
var existing = await repo.GetAgencyMapAsync(regionId, request.AgencyUserId);
|
||||||
|
if (existing is not null) return new RegionAgencyMapResponse(regionId, request.AgencyUserId);
|
||||||
|
|
||||||
|
var map = new Models.RegionAgencyMap { FK_RegionId = regionId, FK_AgencyUserId = request.AgencyUserId };
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAgencyMapAsync(map);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
return new RegionAgencyMapResponse(regionId, request.AgencyUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RegionalTaxRuleResponse?> SetTaxRuleAsync(Guid regionId, SetRegionalTaxRuleRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var region = await repo.GetByIdAsync(regionId);
|
||||||
|
if (region is null || !region.IsActive) return null;
|
||||||
|
if (!await CanManageRegionAsync(regionId)) return null;
|
||||||
|
|
||||||
|
var rule = await repo.GetTaxRuleAsync(regionId);
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (rule is null)
|
||||||
|
{
|
||||||
|
rule = new Models.RegionalTaxRule { FK_RegionId = regionId, RuleConfig = request.RuleConfig };
|
||||||
|
await repo.AddTaxRuleAsync(rule);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rule.RuleConfig = request.RuleConfig;
|
||||||
|
repo.UpdateTaxRule(rule);
|
||||||
|
}
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
return new RegionalTaxRuleResponse(regionId, rule.RuleConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RegionHierarchyResponse?> GetHierarchyAsync(Guid regionId)
|
||||||
|
{
|
||||||
|
var region = await repo.GetByIdAsync(regionId);
|
||||||
|
if (region is null || !region.IsActive) return null;
|
||||||
|
|
||||||
|
var routes = await repo.GetRoutesAsync(regionId);
|
||||||
|
var stores = await repo.GetStoresByRouteIdsAsync(routes.Select(r => r.Id).ToList());
|
||||||
|
var routeItems = routes
|
||||||
|
.Select(r => new RegionHierarchyRouteItem(
|
||||||
|
r.Id,
|
||||||
|
r.RouteName,
|
||||||
|
stores.Where(s => s.FK_RouteId == r.Id).Select(s => s.Id).ToList()))
|
||||||
|
.ToList();
|
||||||
|
return new RegionHierarchyResponse(region.Id, region.Name, routeItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RegionDashboardResponse?> GetDashboardAsync(Guid regionId, string period)
|
||||||
|
{
|
||||||
|
var region = await repo.GetByIdAsync(regionId);
|
||||||
|
if (region is null || !region.IsActive) 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 normalizedPeriod = periodStart.ToString("yyyy-MM");
|
||||||
|
var periodEndExclusive = periodStart.AddMonths(1);
|
||||||
|
|
||||||
|
var target = await repo.GetTargetAsync(regionId, normalizedPeriod);
|
||||||
|
var budget = await repo.GetBudgetAsync(regionId, normalizedPeriod);
|
||||||
|
var orders = await repo.GetOrdersForDashboardAsync(regionId, periodStart, periodEndExclusive);
|
||||||
|
|
||||||
|
return new RegionDashboardResponse(
|
||||||
|
regionId,
|
||||||
|
normalizedPeriod,
|
||||||
|
target?.TargetValue,
|
||||||
|
budget?.BudgetAmount,
|
||||||
|
orders.Count,
|
||||||
|
orders.Sum(o => o.TotalAmount),
|
||||||
|
orders.Count == 0 ? null : orders.Max(o => o.OrderDate));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ public class RouteController(RouteService service) : ControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/region")]
|
||||||
|
public async Task<IActionResult> AssignToRegion(Guid id, AssignRouteToRegionRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.AssignToRegionAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ public record CreateRouteRequest(Guid RegionId, string RouteName, string StartPo
|
|||||||
public record UpdateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
|
public record UpdateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
|
||||||
|
|
||||||
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, bool IsActive, DateTime CreatedAt);
|
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, bool IsActive, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record AssignRouteToRegionRequest(Guid RegionId);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
|
|||||||
|
|
||||||
namespace Distribution.Features.Route;
|
namespace Distribution.Features.Route;
|
||||||
|
|
||||||
public class RouteService(IRouteRepository repo, IUnitOfWork uow)
|
public class RouteService(IRouteRepository repo, Region.IRegionRepository regionRepo, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
public async Task<List<RouteResponse>> GetAllAsync() =>
|
public async Task<List<RouteResponse>> GetAllAsync() =>
|
||||||
(await repo.GetAllAsync())
|
(await repo.GetAllAsync())
|
||||||
@@ -64,6 +64,29 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow)
|
|||||||
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
|
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<RouteResponse?> AssignToRegionAsync(Guid id, AssignRouteToRegionRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var route = await repo.GetByIdAsync(id);
|
||||||
|
if (route is null || !route.IsActive) return null;
|
||||||
|
|
||||||
|
var region = await regionRepo.GetByIdAsync(request.RegionId);
|
||||||
|
if (region is null || !region.IsActive) return null;
|
||||||
|
|
||||||
|
route.FK_RegionId = request.RegionId;
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(route);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var route = await repo.GetByIdAsync(id);
|
var route = await repo.GetByIdAsync(id);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRegionManagement : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RegionAgencyMaps",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_RegionId = 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"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_RegionAgencyMaps", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RegionAgencyMaps_Regions_FK_RegionId",
|
||||||
|
column: x => x.FK_RegionId,
|
||||||
|
principalTable: "Regions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RegionAgencyMaps_Users_FK_AgencyUserId",
|
||||||
|
column: x => x.FK_AgencyUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RegionalBudgets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Period = table.Column<string>(type: "varchar(255)", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
BudgetAmount = 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_RegionalBudgets", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RegionalBudgets_Regions_FK_RegionId",
|
||||||
|
column: x => x.FK_RegionId,
|
||||||
|
principalTable: "Regions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RegionalTargets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Period = table.Column<string>(type: "varchar(255)", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
TargetValue = 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_RegionalTargets", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RegionalTargets_Regions_FK_RegionId",
|
||||||
|
column: x => x.FK_RegionId,
|
||||||
|
principalTable: "Regions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RegionalTaxRules",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
RuleConfig = table.Column<string>(type: "longtext", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_RegionalTaxRules", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RegionalTaxRules_Regions_FK_RegionId",
|
||||||
|
column: x => x.FK_RegionId,
|
||||||
|
principalTable: "Regions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RegionAgencyMaps_FK_AgencyUserId",
|
||||||
|
table: "RegionAgencyMaps",
|
||||||
|
column: "FK_AgencyUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RegionAgencyMaps_FK_RegionId_FK_AgencyUserId",
|
||||||
|
table: "RegionAgencyMaps",
|
||||||
|
columns: new[] { "FK_RegionId", "FK_AgencyUserId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RegionalBudgets_FK_RegionId_Period",
|
||||||
|
table: "RegionalBudgets",
|
||||||
|
columns: new[] { "FK_RegionId", "Period" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RegionalTargets_FK_RegionId_Period",
|
||||||
|
table: "RegionalTargets",
|
||||||
|
columns: new[] { "FK_RegionId", "Period" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RegionalTaxRules_FK_RegionId",
|
||||||
|
table: "RegionalTaxRules",
|
||||||
|
column: "FK_RegionId",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "RegionAgencyMaps");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "RegionalBudgets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "RegionalTargets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "RegionalTaxRules");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -851,6 +851,121 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("Regions");
|
b.ToTable("Regions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_AgencyUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_RegionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_AgencyUserId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_RegionId", "FK_AgencyUserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("RegionAgencyMaps");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.RegionalBudget", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<decimal>("BudgetAmount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_RegionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Period")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("varchar(255)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_RegionId", "Period")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("RegionalBudgets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.RegionalTarget", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_RegionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Period")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("varchar(255)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TargetValue")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_RegionId", "Period")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("RegionalTargets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_RegionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("RuleConfig")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_RegionId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("RegionalTaxRules");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
|
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -2185,6 +2300,58 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("PurchaseReturn");
|
b.Navigation("PurchaseReturn");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.User", "AgencyUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_AgencyUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.Region", "Region")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_RegionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("AgencyUser");
|
||||||
|
|
||||||
|
b.Navigation("Region");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.RegionalBudget", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Region", "Region")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_RegionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Region");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.RegionalTarget", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Region", "Region")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_RegionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Region");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Region", "Region")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_RegionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Region");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
|
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
|
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class RegionAgencyMap : Template
|
||||||
|
{
|
||||||
|
public Guid FK_RegionId { get; set; }
|
||||||
|
[ForeignKey("FK_RegionId")]
|
||||||
|
public Region Region { get; set; }
|
||||||
|
|
||||||
|
/// <summary>User holding the RegionalAgency role that is mapped to this region.</summary>
|
||||||
|
public Guid FK_AgencyUserId { get; set; }
|
||||||
|
[ForeignKey("FK_AgencyUserId")]
|
||||||
|
public User AgencyUser { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class RegionalBudget : Template
|
||||||
|
{
|
||||||
|
public Guid FK_RegionId { get; set; }
|
||||||
|
[ForeignKey("FK_RegionId")]
|
||||||
|
public Region Region { get; set; }
|
||||||
|
|
||||||
|
public string Period { get; set; }
|
||||||
|
public decimal BudgetAmount { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class RegionalTarget : Template
|
||||||
|
{
|
||||||
|
public Guid FK_RegionId { get; set; }
|
||||||
|
[ForeignKey("FK_RegionId")]
|
||||||
|
public Region Region { get; set; }
|
||||||
|
|
||||||
|
public string Period { get; set; }
|
||||||
|
public decimal TargetValue { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class RegionalTaxRule : Template
|
||||||
|
{
|
||||||
|
public Guid FK_RegionId { get; set; }
|
||||||
|
[ForeignKey("FK_RegionId")]
|
||||||
|
public Region Region { get; set; }
|
||||||
|
|
||||||
|
public string RuleConfig { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 10 — Region Management (Beyond CRUD)
|
# 10 — Region Management (Beyond CRUD)
|
||||||
|
|
||||||
**Status**: Not Started
|
**Status**: Implemented (except `assignAreaToRegion`, blocked on the `Area` entity)
|
||||||
**Depends On**: 00 — Existing Gap Fixes
|
**Depends On**: 00 — Existing Gap Fixes
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -24,48 +24,77 @@ CRUD: targets, budgets, agency mapping, tax rules, hierarchy, and dashboarding.
|
|||||||
|
|
||||||
### Regional Target / Budget
|
### Regional Target / Budget
|
||||||
|
|
||||||
`RegionId`, `Period` (e.g. `2026-08`), `TargetValue`, `BudgetAmount`.
|
Implemented as two separate entities, `RegionalTarget` and `RegionalBudget`
|
||||||
|
(`Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`), each with `FK_RegionId`, `Period`
|
||||||
|
(string, `yyyy-MM`), and `TargetValue`/`BudgetAmount` respectively. A unique index on
|
||||||
|
`(FK_RegionId, Period)` means each region has at most one target and one budget row per period;
|
||||||
|
setting the same period again updates it in place rather than inserting a duplicate.
|
||||||
|
|
||||||
### Agency-Region Map
|
### Agency-Region Map
|
||||||
|
|
||||||
`RegionId`, `AgencyId` — depends on whether "Regional Agency" becomes a distinct entity or stays a
|
Implemented as `RegionAgencyMap` (`Models/RegionAgencyMap.cs`): `FK_RegionId`, `FK_AgencyUserId`,
|
||||||
`Role` (see Open Questions).
|
unique on the pair. "Regional Agency" stayed a `Role`, not a distinct entity (see Open Questions,
|
||||||
|
resolved) — `FK_AgencyUserId` points at an existing `User` who holds the `RegionalAgency` role,
|
||||||
|
the same convention already used by `Claim.FK_AgencyUserId` (§18).
|
||||||
|
|
||||||
### Regional Tax Rule
|
### Regional Tax Rule
|
||||||
|
|
||||||
`RegionId`, `RuleConfig` (JSON) — deferred to whenever tax/pricing rule overrides are prioritized.
|
Implemented as `RegionalTaxRule` (`Models/RegionalTaxRule.cs`): `FK_RegionId` (unique — one rule
|
||||||
|
per region), `RuleConfig` (raw JSON string). Nothing in pricing/tax calculation reads this yet;
|
||||||
|
it's stored for a future pricing-rule integration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Function reference | Endpoint intent |
|
| Function reference | Endpoint | Status |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `assignAreaToRegion(areaId, regionId)` | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)) |
|
| `assignAreaToRegion(areaId, regionId)` | — | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). Stand-in: `PUT /api/routes/{id}/region` assigns/reassigns an existing route directly to a region (see [11_route_sales_management.md](./11_route_sales_management.md)). |
|
||||||
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` |
|
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` | Implemented |
|
||||||
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` |
|
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` | Implemented |
|
||||||
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` |
|
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` | Implemented |
|
||||||
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` |
|
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` | Implemented |
|
||||||
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` |
|
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` | Implemented (region → routes → store ids; no `Area` level) |
|
||||||
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` |
|
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` | Implemented (real-time aggregation; see Open Questions, resolved) |
|
||||||
|
|
||||||
|
All request/response DTOs live in `Features/Region/RegionDTOs.cs`; routing and access checks are
|
||||||
|
in `RegionController.cs` and `RegionService.cs`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Business Rules
|
## Business Rules
|
||||||
|
|
||||||
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
|
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
|
||||||
set targets/budgets for their own region; only `RegionalAgency` can set them across regions.
|
set targets/budgets/tax-rule for their own region; only `RegionalAgency` can set them across
|
||||||
|
regions. Implemented via `RegionService.CanManageRegionAsync`: role level ≥ 60 (`RegionalAgency`)
|
||||||
|
is allowed unconditionally, role level ≥ 50 (`RegionalManager`) requires a matching
|
||||||
|
`UserRegionScope` row for that region, anything lower is rejected. The same gate is applied to
|
||||||
|
`mapAgencyToRegion`.
|
||||||
|
- `mapAgencyToRegion` additionally rejects the target user if their role level is below the
|
||||||
|
`RegionalAgency` threshold (60) — you can only map a user who actually holds (or outranks) that
|
||||||
|
role.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- Is "Regional Agency" a business entity distinct from the `RegionalAgency` role (e.g. a contracted
|
- ~~Is "Regional Agency" a business entity distinct from the `RegionalAgency` role...?~~ Resolved:
|
||||||
third party with its own record), or is the role sufficient?
|
kept as a `Role` on `User`; no new agency entity, consistent with `Claim.FK_AgencyUserId`.
|
||||||
- Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?
|
- ~~Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?~~ Resolved:
|
||||||
|
real-time aggregation from `Order`/`Store`/`Route` for now; revisit if/when region-wide dashboard
|
||||||
|
queries become a performance concern at scale.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
> Fill this section in during planning discussion before writing any code.
|
- Backend: `Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`, `Models/RegionAgencyMap.cs`,
|
||||||
|
`Models/RegionalTaxRule.cs`, `Data/AppDBContext.cs` (DbSets + unique indexes),
|
||||||
|
`Features/Region/{IRegionRepository,RegionRepository,RegionService,RegionController,RegionDTOs}.cs`.
|
||||||
|
- Frontend: `distribution_fe/types/api.ts` (response types), `distribution_fe/lib/api/master.ts`
|
||||||
|
(`regions.setTarget/setBudget/mapAgency/setTaxRule/hierarchy/dashboard`), and a new
|
||||||
|
`RegionManageSheet` dialog (`distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx`)
|
||||||
|
wired into the regions table's row actions.
|
||||||
|
- The EF Core migration `20260831050805_AddRegionManagement` has been generated and applied to the
|
||||||
|
shared database — `RegionalTargets`, `RegionalBudgets`, `RegionAgencyMaps`, and `RegionalTaxRules`
|
||||||
|
all exist.
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Step 00 closed these. Kept here for history; all are fixed in the current codeba
|
|||||||
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) |
|
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) |
|
||||||
| 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) |
|
| 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) |
|
||||||
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
|
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
|
||||||
| 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) |
|
| 10 | Region Management (Beyond CRUD) | Done | 00 | [10_region_management.md](./10_region_management.md) |
|
||||||
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
|
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
|
||||||
| 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) |
|
| 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) |
|
||||||
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
|
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
|
||||||
@@ -116,19 +116,19 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. |
|
| `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. |
|
||||||
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema. |
|
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). As a stand-in, a route (the next tier down) can be assigned/reassigned to a region directly via `PUT /api/routes/{id}/region`. |
|
||||||
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Not implemented. |
|
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Implemented — `PUT /api/regions/{id}/targets/{period}`, backed by the `RegionalTarget` entity. Restricted to `RegionalManager`+ with region scope, or `RegionalAgency` across any region. |
|
||||||
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Not implemented. |
|
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Implemented — `PUT /api/regions/{id}/budgets/{period}`, backed by the `RegionalBudget` entity. Same access rule as `setRegionalTarget`. |
|
||||||
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Not implemented — `RegionalAgency` is currently a `Role`, not a distinct agency entity; scope is via `UserRegionScope`. |
|
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Implemented — `POST /api/regions/{id}/agencies`, backed by the `RegionAgencyMap` entity. `RegionalAgency` is still a `Role`, not a distinct agency entity — `agencyId` is the `Guid` of an existing `User` holding that role (consistent with `Claim.FK_AgencyUserId`, §18); the endpoint rejects a user who doesn't hold the role. |
|
||||||
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Not implemented. |
|
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Implemented — `PUT /api/regions/{id}/tax-rule`, backed by the `RegionalTaxRule` entity (`RuleConfig` stored as a raw JSON string, not yet consumed by pricing/tax logic elsewhere). |
|
||||||
| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Not implemented — see [user_management_PLAN.md](../user_management_PLAN.md) territory hierarchy prerequisite. |
|
| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Partially implemented — `GET /api/regions/{id}/hierarchy` returns the region's routes and each route's store ids; no `Area` level since that entity doesn't exist yet. |
|
||||||
| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Not implemented. |
|
| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Partially implemented — `GET /api/regions/{id}/dashboard?period=...` returns real-time order count, actual sales, target, and budget for the period; no fill-rate or agency-performance metrics yet. |
|
||||||
|
|
||||||
### 3. Route Management
|
### 3. Route Management
|
||||||
|
|
||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). |
|
| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). A route's region can also be changed after creation via `PUT /api/routes/{id}/region`. |
|
||||||
| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Partially implemented — a `Store` has a single `FK_RouteId`, set via `StoreController` create/update; no bulk-assign endpoint. |
|
| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Partially implemented — a `Store` has a single `FK_RouteId`, set via `StoreController` create/update; no bulk-assign endpoint. |
|
||||||
| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Not implemented — only `UserRouteScope` exists (scope grant), not a single-owner assignment. |
|
| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Not implemented — only `UserRouteScope` exists (scope grant), not a single-owner assignment. |
|
||||||
| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Not implemented. |
|
| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Not implemented. |
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
|
import type {
|
||||||
|
RegionResponse,
|
||||||
|
UserResponse,
|
||||||
|
RegionDashboardResponse,
|
||||||
|
RegionHierarchyResponse,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
interface RegionManageSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
region: RegionResponse | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPeriod() {
|
||||||
|
const now = new Date()
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegionManageSheet({ open, onClose, region }: RegionManageSheetProps) {
|
||||||
|
const [period, setPeriod] = useState(currentPeriod())
|
||||||
|
const [targetValue, setTargetValue] = useState('')
|
||||||
|
const [budgetAmount, setBudgetAmount] = useState('')
|
||||||
|
const [ruleConfig, setRuleConfig] = useState('')
|
||||||
|
const [agencyUserId, setAgencyUserId] = useState('')
|
||||||
|
const [agencyUsers, setAgencyUsers] = useState<UserResponse[]>([])
|
||||||
|
const [dashboard, setDashboard] = useState<RegionDashboardResponse | null>(null)
|
||||||
|
const [hierarchy, setHierarchy] = useState<RegionHierarchyResponse | null>(null)
|
||||||
|
const [saving, setSaving] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !region) return
|
||||||
|
|
||||||
|
setPeriod(currentPeriod())
|
||||||
|
setTargetValue('')
|
||||||
|
setBudgetAmount('')
|
||||||
|
setRuleConfig('')
|
||||||
|
setAgencyUserId('')
|
||||||
|
|
||||||
|
usersApi.list({ page: 1, pageSize: 200 })
|
||||||
|
.then((res) => setAgencyUsers(getPaginatedItems(res).filter((u) => u.roleName === 'RegionalAgency')))
|
||||||
|
.catch(() => toast.add({ title: 'Failed to load agency users', type: 'error' }))
|
||||||
|
|
||||||
|
regionsApi.hierarchy(region.id)
|
||||||
|
.then(setHierarchy)
|
||||||
|
.catch(() => toast.add({ title: 'Failed to load hierarchy', type: 'error' }))
|
||||||
|
}, [open, region])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !region) return
|
||||||
|
regionsApi.dashboard(region.id, period)
|
||||||
|
.then(setDashboard)
|
||||||
|
.catch(() => toast.add({ title: 'Failed to load dashboard', type: 'error' }))
|
||||||
|
}, [open, region, period])
|
||||||
|
|
||||||
|
if (!region) return null
|
||||||
|
|
||||||
|
async function handleSetTarget() {
|
||||||
|
const value = parseFloat(targetValue)
|
||||||
|
if (Number.isNaN(value)) return
|
||||||
|
setSaving('target')
|
||||||
|
try {
|
||||||
|
await regionsApi.setTarget(region!.id, period, value)
|
||||||
|
toast.add({ title: 'Target updated', type: 'success' })
|
||||||
|
regionsApi.dashboard(region!.id, period).then(setDashboard)
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to set target', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetBudget() {
|
||||||
|
const value = parseFloat(budgetAmount)
|
||||||
|
if (Number.isNaN(value)) return
|
||||||
|
setSaving('budget')
|
||||||
|
try {
|
||||||
|
await regionsApi.setBudget(region!.id, period, value)
|
||||||
|
toast.add({ title: 'Budget updated', type: 'success' })
|
||||||
|
regionsApi.dashboard(region!.id, period).then(setDashboard)
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to set budget', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetTaxRule() {
|
||||||
|
if (!ruleConfig.trim()) return
|
||||||
|
setSaving('tax-rule')
|
||||||
|
try {
|
||||||
|
await regionsApi.setTaxRule(region!.id, ruleConfig)
|
||||||
|
toast.add({ title: 'Tax rule updated', type: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to set tax rule', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMapAgency() {
|
||||||
|
if (!agencyUserId) return
|
||||||
|
setSaving('agency')
|
||||||
|
try {
|
||||||
|
await regionsApi.mapAgency(region!.id, agencyUserId)
|
||||||
|
toast.add({ title: 'Agency mapped to region', type: 'success' })
|
||||||
|
setAgencyUserId('')
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to map agency', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="overflow-y-auto sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Manage {region.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Targets, budgets, tax rules, agency mapping, and dashboard for this region.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="period">Period</Label>
|
||||||
|
<Input
|
||||||
|
id="period"
|
||||||
|
type="month"
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => setPeriod(e.target.value)}
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Regional Target</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g. 500000"
|
||||||
|
value={targetValue}
|
||||||
|
onChange={(e) => setTargetValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={handleSetTarget} disabled={saving === 'target'}>
|
||||||
|
{saving === 'target' ? 'Saving…' : 'Set Target'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{dashboard?.targetValue != null && (
|
||||||
|
<p className="text-xs text-muted-foreground">Current: {dashboard.targetValue}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Regional Budget</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g. 200000"
|
||||||
|
value={budgetAmount}
|
||||||
|
onChange={(e) => setBudgetAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={handleSetBudget} disabled={saving === 'budget'}>
|
||||||
|
{saving === 'budget' ? 'Saving…' : 'Set Budget'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{dashboard?.budgetAmount != null && (
|
||||||
|
<p className="text-xs text-muted-foreground">Current: {dashboard.budgetAmount}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Tax Rule Config (JSON)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder='e.g. {"rate": 0.15}'
|
||||||
|
value={ruleConfig}
|
||||||
|
onChange={(e) => setRuleConfig(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={handleSetTaxRule} disabled={saving === 'tax-rule'}>
|
||||||
|
{saving === 'tax-rule' ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Map Regional Agency</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={agencyUserId} onValueChange={(v) => setAgencyUserId(v ?? '')}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an agency user">
|
||||||
|
{agencyUsers.find((u) => u.id === agencyUserId)?.fullName}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{agencyUsers.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="button" onClick={handleMapAgency} disabled={saving === 'agency'}>
|
||||||
|
{saving === 'agency' ? 'Saving…' : 'Map'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{agencyUsers.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">No users with the RegionalAgency role found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Hierarchy</Label>
|
||||||
|
{hierarchy && hierarchy.routes.length > 0 ? (
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{hierarchy.routes.map((r) => (
|
||||||
|
<li key={r.routeId}>
|
||||||
|
{r.routeName} — {r.storeIds.length} store{r.storeIds.length === 1 ? '' : 's'}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No routes under this region yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Dashboard ({period})</Label>
|
||||||
|
{dashboard ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>Orders: {dashboard.orderCount}</div>
|
||||||
|
<div>Actual Sales: {dashboard.actualSales}</div>
|
||||||
|
<div>Target: {dashboard.targetValue ?? '—'}</div>
|
||||||
|
<div>Budget: {dashboard.budgetAmount ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">Loading dashboard…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api
|
|||||||
import type { RegionResponse } from '@/types/api'
|
import type { RegionResponse } from '@/types/api'
|
||||||
|
|
||||||
import { RegionSheet } from './region-sheet'
|
import { RegionSheet } from './region-sheet'
|
||||||
|
import { RegionManageSheet } from './region-manage-sheet'
|
||||||
|
|
||||||
export function RegionsClient() {
|
export function RegionsClient() {
|
||||||
const [data, setData] = useState<RegionResponse[]>([])
|
const [data, setData] = useState<RegionResponse[]>([])
|
||||||
@@ -24,6 +25,9 @@ export function RegionsClient() {
|
|||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<RegionResponse | null>(null)
|
const [editing, setEditing] = useState<RegionResponse | null>(null)
|
||||||
|
|
||||||
|
const [manageOpen, setManageOpen] = useState(false)
|
||||||
|
const [managing, setManaging] = useState<RegionResponse | null>(null)
|
||||||
|
|
||||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -51,6 +55,11 @@ export function RegionsClient() {
|
|||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openManage(region: RegionResponse) {
|
||||||
|
setManaging(region)
|
||||||
|
setManageOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleActive(region: RegionResponse) {
|
async function toggleActive(region: RegionResponse) {
|
||||||
try {
|
try {
|
||||||
await regionsApi.update(region.id, { isActive: !region.isActive })
|
await regionsApi.update(region.id, { isActive: !region.isActive })
|
||||||
@@ -103,6 +112,11 @@ export function RegionsClient() {
|
|||||||
],
|
],
|
||||||
rowActions: {
|
rowActions: {
|
||||||
actions: () => [
|
actions: () => [
|
||||||
|
{
|
||||||
|
action: 'confirm',
|
||||||
|
label: 'Manage targets, budgets & agencies',
|
||||||
|
onClick: openManage,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
label: 'Edit region',
|
label: 'Edit region',
|
||||||
@@ -122,7 +136,7 @@ export function RegionsClient() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}), [pageSize, openEdit, toggleActive])
|
}), [pageSize, openEdit, openManage, toggleActive])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -156,6 +170,12 @@ export function RegionsClient() {
|
|||||||
region={editing}
|
region={editing}
|
||||||
onSuccess={() => fetchData(page, pageSize)}
|
onSuccess={() => fetchData(page, pageSize)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RegionManageSheet
|
||||||
|
open={manageOpen}
|
||||||
|
onClose={() => setManageOpen(false)}
|
||||||
|
region={managing}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { api } from '@/lib/api'
|
|||||||
import type {
|
import type {
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
RegionResponse,
|
RegionResponse,
|
||||||
|
RegionalTargetResponse,
|
||||||
|
RegionalBudgetResponse,
|
||||||
|
RegionAgencyMapResponse,
|
||||||
|
RegionalTaxRuleResponse,
|
||||||
|
RegionHierarchyResponse,
|
||||||
|
RegionDashboardResponse,
|
||||||
RouteResponse,
|
RouteResponse,
|
||||||
VehicleResponse,
|
VehicleResponse,
|
||||||
WarehouseResponse,
|
WarehouseResponse,
|
||||||
@@ -96,6 +102,24 @@ export const regions = {
|
|||||||
|
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
api.delete(`/regions/${id}`),
|
api.delete(`/regions/${id}`),
|
||||||
|
|
||||||
|
setTarget: (id: string, period: string, targetValue: number) =>
|
||||||
|
api.put<RegionalTargetResponse>(`/regions/${id}/targets/${period}`, { targetValue }).then(r => r.data),
|
||||||
|
|
||||||
|
setBudget: (id: string, period: string, budgetAmount: number) =>
|
||||||
|
api.put<RegionalBudgetResponse>(`/regions/${id}/budgets/${period}`, { budgetAmount }).then(r => r.data),
|
||||||
|
|
||||||
|
mapAgency: (id: string, agencyUserId: string) =>
|
||||||
|
api.post<RegionAgencyMapResponse>(`/regions/${id}/agencies`, { agencyUserId }).then(r => r.data),
|
||||||
|
|
||||||
|
setTaxRule: (id: string, ruleConfig: string) =>
|
||||||
|
api.put<RegionalTaxRuleResponse>(`/regions/${id}/tax-rule`, { ruleConfig }).then(r => r.data),
|
||||||
|
|
||||||
|
hierarchy: (id: string) =>
|
||||||
|
api.get<RegionHierarchyResponse>(`/regions/${id}/hierarchy`).then(r => r.data),
|
||||||
|
|
||||||
|
dashboard: (id: string, period?: string) =>
|
||||||
|
api.get<RegionDashboardResponse>(`/regions/${id}/dashboard`, { params: { period } }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Routes ───────────────────────────────────────────────────────────────────
|
// ─── Routes ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -85,6 +85,50 @@ export interface RouteResponse {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegionalTargetResponse {
|
||||||
|
regionId: string
|
||||||
|
period: string
|
||||||
|
targetValue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionalBudgetResponse {
|
||||||
|
regionId: string
|
||||||
|
period: string
|
||||||
|
budgetAmount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionAgencyMapResponse {
|
||||||
|
regionId: string
|
||||||
|
agencyUserId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionalTaxRuleResponse {
|
||||||
|
regionId: string
|
||||||
|
ruleConfig: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionHierarchyRouteItem {
|
||||||
|
routeId: string
|
||||||
|
routeName: string
|
||||||
|
storeIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionHierarchyResponse {
|
||||||
|
regionId: string
|
||||||
|
regionName: string
|
||||||
|
routes: RegionHierarchyRouteItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionDashboardResponse {
|
||||||
|
regionId: string
|
||||||
|
period: string
|
||||||
|
targetValue: number | null
|
||||||
|
budgetAmount: number | null
|
||||||
|
orderCount: number
|
||||||
|
actualSales: number
|
||||||
|
lastOrderDate: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Vehicles / Warehouses ───────────────────────────────────────────────────
|
// ─── Vehicles / Warehouses ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface VehicleResponse {
|
export interface VehicleResponse {
|
||||||
|
|||||||
@@ -506,6 +506,84 @@ Array of:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Beyond CRUD** — targets, budgets, agency mapping, tax rules, hierarchy, and dashboard. These are
|
||||||
|
not gated by the `regions.*` permission codes above; access is checked by role level in
|
||||||
|
`RegionService`: `RegionalManager` (level 50) can act only on a region they have scope over (via
|
||||||
|
`UserRegionScope`), `RegionalAgency` (level 60) can act on any region.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `PUT` | `/api/regions/{id}/targets/{period}` | Set the sales target for a region for a period (`period` = `yyyy-MM`) |
|
||||||
|
| `PUT` | `/api/regions/{id}/budgets/{period}` | Set the budget for a region for a period |
|
||||||
|
| `POST` | `/api/regions/{id}/agencies` | Map an existing user (holding the `RegionalAgency` role) to this region |
|
||||||
|
| `PUT` | `/api/regions/{id}/tax-rule` | Set/replace the region's tax rule config |
|
||||||
|
| `GET` | `/api/regions/{id}/hierarchy` | Region's routes and each route's store ids |
|
||||||
|
| `GET` | `/api/regions/{id}/dashboard?period=yyyy-MM` | Real-time order count/sales/target/budget for the period |
|
||||||
|
|
||||||
|
**Set target request / response:**
|
||||||
|
```json
|
||||||
|
// PUT /api/regions/{id}/targets/{period}
|
||||||
|
{ "targetValue": 500000 }
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{ "regionId": "guid", "period": "2026-08", "targetValue": 500000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Set budget request / response:**
|
||||||
|
```json
|
||||||
|
// PUT /api/regions/{id}/budgets/{period}
|
||||||
|
{ "budgetAmount": 200000 }
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{ "regionId": "guid", "period": "2026-08", "budgetAmount": 200000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Map agency request / response:**
|
||||||
|
```json
|
||||||
|
// POST /api/regions/{id}/agencies
|
||||||
|
{ "agencyUserId": "guid" } // must be a User with the RegionalAgency role
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{ "regionId": "guid", "agencyUserId": "guid" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Set tax rule request / response:**
|
||||||
|
```json
|
||||||
|
// PUT /api/regions/{id}/tax-rule
|
||||||
|
{ "ruleConfig": "{\"rate\": 0.15}" } // arbitrary JSON stored as a string; not yet consumed by pricing
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{ "regionId": "guid", "ruleConfig": "{\"rate\": 0.15}" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hierarchy response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"regionId": "guid",
|
||||||
|
"regionName": "string",
|
||||||
|
"routes": [
|
||||||
|
{ "routeId": "guid", "routeName": "string", "storeIds": ["guid", "..."] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dashboard response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"regionId": "guid",
|
||||||
|
"period": "2026-08",
|
||||||
|
"targetValue": 500000, // null if no target set for the period
|
||||||
|
"budgetAmount": 200000, // null if no budget set for the period
|
||||||
|
"orderCount": 42,
|
||||||
|
"actualSales": 187500.00,
|
||||||
|
"lastOrderDate": "datetime | null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All four of these endpoints return `404` if the region doesn't exist, the caller lacks the
|
||||||
|
required region scope, or (for agency mapping) the target user doesn't hold the `RegionalAgency`
|
||||||
|
role — the response body carries no distinction between "not found" and "forbidden".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6.6 Routes
|
### 6.6 Routes
|
||||||
@@ -518,6 +596,7 @@ Array of:
|
|||||||
| `GET` | `/api/routes/{id}` | `routes.view` | Get route by ID |
|
| `GET` | `/api/routes/{id}` | `routes.view` | Get route by ID |
|
||||||
| `POST` | `/api/routes` | `routes.create` | Create route |
|
| `POST` | `/api/routes` | `routes.create` | Create route |
|
||||||
| `PUT` | `/api/routes/{id}` | `routes.update` | Update route |
|
| `PUT` | `/api/routes/{id}` | `routes.update` | Update route |
|
||||||
|
| `PUT` | `/api/routes/{id}/region` | `routes.update` | Assign/reassign the route to a region |
|
||||||
| `DELETE` | `/api/routes/{id}` | `routes.delete` | Delete route |
|
| `DELETE` | `/api/routes/{id}` | `routes.delete` | Delete route |
|
||||||
|
|
||||||
**Create/Update request:**
|
**Create/Update request:**
|
||||||
@@ -545,6 +624,14 @@ Array of:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Assign to region request:**
|
||||||
|
```json
|
||||||
|
// PUT /api/routes/{id}/region
|
||||||
|
{ "regionId": "guid" }
|
||||||
|
```
|
||||||
|
Returns the same `RouteResponse` shape as above. `404` if the route or the target region doesn't
|
||||||
|
exist (or either is inactive).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6.7 Vehicles
|
### 6.7 Vehicles
|
||||||
|
|||||||
Reference in New Issue
Block a user