From 39eab2e3a26121e5fe89fa2435d4becc1be66811 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Sun, 30 Aug 2026 22:45:35 +0530 Subject: [PATCH] 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 --- .../Features/Region/IRegionRepository.cs | 22 +++ .../Features/Region/RegionController.cs | 42 +++++ Distribution/Features/Region/RegionDTOs.cs | 29 +++ .../Features/Region/RegionRepository.cs | 56 ++++++ Distribution/Features/Region/RegionService.cs | 170 +++++++++++++++++- 5 files changed, 318 insertions(+), 1 deletion(-) diff --git a/Distribution/Features/Region/IRegionRepository.cs b/Distribution/Features/Region/IRegionRepository.cs index 8eccc6f..43e0382 100644 --- a/Distribution/Features/Region/IRegionRepository.cs +++ b/Distribution/Features/Region/IRegionRepository.cs @@ -7,4 +7,26 @@ public interface IRegionRepository Task AddAsync(Models.Region entity); void Update(Models.Region entity); void Remove(Models.Region entity); + + Task GetTargetAsync(Guid regionId, string period); + Task AddTargetAsync(Models.RegionalTarget target); + void UpdateTarget(Models.RegionalTarget target); + + Task GetBudgetAsync(Guid regionId, string period); + Task AddBudgetAsync(Models.RegionalBudget budget); + void UpdateBudget(Models.RegionalBudget budget); + + Task GetAgencyMapAsync(Guid regionId, Guid agencyUserId); + Task AddAgencyMapAsync(Models.RegionAgencyMap map); + + Task GetTaxRuleAsync(Guid regionId); + Task AddTaxRuleAsync(Models.RegionalTaxRule rule); + void UpdateTaxRule(Models.RegionalTaxRule rule); + + Task GetUserRoleLevelAsync(Guid userId); + + Task> GetRoutesAsync(Guid regionId); + Task> GetStoresByRouteIdsAsync(List routeIds); + + Task> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive); } diff --git a/Distribution/Features/Region/RegionController.cs b/Distribution/Features/Region/RegionController.cs index 61d33b8..7fe260a 100644 --- a/Distribution/Features/Region/RegionController.cs +++ b/Distribution/Features/Region/RegionController.cs @@ -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 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 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 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 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 GetHierarchy(Guid id) + { + var result = await service.GetHierarchyAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{id:guid}/dashboard")] + public async Task GetDashboard(Guid id, [FromQuery] string period) + { + var result = await service.GetDashboardAsync(id, period); + return result is null ? NotFound() : Ok(result); + } } diff --git a/Distribution/Features/Region/RegionDTOs.cs b/Distribution/Features/Region/RegionDTOs.cs index 828e5a3..08a1f5c 100644 --- a/Distribution/Features/Region/RegionDTOs.cs +++ b/Distribution/Features/Region/RegionDTOs.cs @@ -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 StoreIds); + +public record RegionHierarchyResponse(Guid RegionId, string RegionName, List Routes); + +public record RegionDashboardResponse( + Guid RegionId, + string Period, + decimal? TargetValue, + decimal? BudgetAmount, + int OrderCount, + decimal ActualSales, + DateTime? LastOrderDate); diff --git a/Distribution/Features/Region/RegionRepository.cs b/Distribution/Features/Region/RegionRepository.cs index f6f6efb..4e357e0 100644 --- a/Distribution/Features/Region/RegionRepository.cs +++ b/Distribution/Features/Region/RegionRepository.cs @@ -22,4 +22,60 @@ public class RegionRepository(AppDBContext context) : IRegionRepository entity.IsActive = false; context.Regions.Update(entity); } + + public async Task 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 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 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 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 GetUserRoleLevelAsync(Guid userId) => + await context.Users + .Where(u => u.Id == userId && u.IsActive) + .Select(u => (int?)u.Role.Level) + .FirstOrDefaultAsync(); + + public async Task> GetRoutesAsync(Guid regionId) => + await context.Routes.Where(r => r.FK_RegionId == regionId && r.IsActive).ToListAsync(); + + public async Task> GetStoresByRouteIdsAsync(List routeIds) => + await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync(); + + public async Task> 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(); } diff --git a/Distribution/Features/Region/RegionService.cs b/Distribution/Features/Region/RegionService.cs index 532b763..8a88406 100644 --- a/Distribution/Features/Region/RegionService.cs +++ b/Distribution/Features/Region/RegionService.cs @@ -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 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> GetAllAsync() => (await repo.GetAllAsync()) .Where(r => r.IsActive) @@ -70,4 +81,161 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow) } return true; } + + public async Task 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 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 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 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 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 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)); + } }