Add region target/budget/tax-rule/agency-map/hierarchy/dashboard APIs
Extends RegionService, RegionRepository, and RegionController with the endpoints from mainPlan.md sec 2 beyond plain CRUD: setRegionalTarget, setRegionalBudget, mapAgencyToRegion, setRegionalTaxRule, getRegionHierarchy, and getRegionDashboard. Target/budget/tax-rule mutation is gated to users with region scope at RegionalManager level or RegionalAgency level across all regions; mapAgencyToRegion links an existing User holding the RegionalAgency role rather than introducing a separate agency entity, matching the convention already used by Claim.FK_AgencyUserId elsewhere in the codebase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,4 +7,26 @@ public interface IRegionRepository
|
||||
Task AddAsync(Models.Region entity);
|
||||
void Update(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);
|
||||
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 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;
|
||||
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;
|
||||
|
||||
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() =>
|
||||
(await repo.GetAllAsync())
|
||||
.Where(r => r.IsActive)
|
||||
@@ -70,4 +81,161 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow)
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user