Consolidate region management APIs
This commit is contained in:
@@ -11,17 +11,31 @@ public interface IRegionRepository
|
||||
Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period);
|
||||
Task AddTargetAsync(Models.RegionalTarget target);
|
||||
void UpdateTarget(Models.RegionalTarget target);
|
||||
Task<List<Models.RegionalTarget>> GetAllTargetsAsync();
|
||||
Task<Models.RegionalTarget?> GetTargetByIdAsync(Guid id);
|
||||
void RemoveTarget(Models.RegionalTarget target);
|
||||
|
||||
Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period);
|
||||
Task AddBudgetAsync(Models.RegionalBudget budget);
|
||||
void UpdateBudget(Models.RegionalBudget budget);
|
||||
Task<List<Models.RegionalBudget>> GetAllBudgetsAsync();
|
||||
Task<Models.RegionalBudget?> GetBudgetByIdAsync(Guid id);
|
||||
void RemoveBudget(Models.RegionalBudget budget);
|
||||
|
||||
Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId);
|
||||
Task AddAgencyMapAsync(Models.RegionAgencyMap map);
|
||||
void UpdateAgencyMap(Models.RegionAgencyMap map);
|
||||
Task<List<Models.RegionAgencyMap>> GetAgencyMapsForRegionAsync(Guid regionId);
|
||||
Task<List<Models.RegionAgencyMap>> GetAllAgencyMapsAsync();
|
||||
Task<Models.RegionAgencyMap?> GetAgencyMapByIdAsync(Guid id);
|
||||
void RemoveAgencyMap(Models.RegionAgencyMap map);
|
||||
|
||||
Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId);
|
||||
Task AddTaxRuleAsync(Models.RegionalTaxRule rule);
|
||||
void UpdateTaxRule(Models.RegionalTaxRule rule);
|
||||
Task<List<Models.RegionalTaxRule>> GetAllTaxRulesAsync();
|
||||
Task<Models.RegionalTaxRule?> GetTaxRuleByIdAsync(Guid id);
|
||||
void RemoveTaxRule(Models.RegionalTaxRule rule);
|
||||
|
||||
Task<int?> GetUserRoleLevelAsync(Guid userId);
|
||||
|
||||
|
||||
@@ -38,31 +38,11 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/targets/{period}")]
|
||||
public async Task<IActionResult> SetTarget(Guid id, string period, SetRegionalTargetRequest request, CancellationToken ct)
|
||||
//targets/budgets/agencies/tax-rule.
|
||||
[HttpPut("{id:guid}/manage")]
|
||||
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest 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);
|
||||
var result = await service.ManageAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
@@ -79,4 +59,16 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
var result = await service.GetDashboardAsync(id, period);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
// Flat CRUD-list view over targets/budgets/tax-rules/agency-maps across all regions.
|
||||
[HttpGet("manage-entries")]
|
||||
public async Task<IActionResult> GetManageEntries() =>
|
||||
Ok(await service.GetManageEntriesAsync());
|
||||
|
||||
[HttpDelete("manage-entries/{entryType}/{id:guid}")]
|
||||
public async Task<IActionResult> DeleteManageEntry(RegionManageEntryType entryType, Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteManageEntryAsync(entryType, id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,6 @@ 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);
|
||||
@@ -22,6 +14,23 @@ public record RegionAgencyMapResponse(Guid RegionId, Guid AgencyUserId);
|
||||
|
||||
public record RegionalTaxRuleResponse(Guid RegionId, string RuleConfig);
|
||||
|
||||
// Consolidated "beyond CRUD" management endpoint — replaces the separate
|
||||
// SetTarget/SetBudget/MapAgency/SetTaxRule endpoints. Every field is optional; only the
|
||||
// sub-actions whose fields are supplied get applied. Target/Budget both need Period.
|
||||
public record ManageRegionRequest(
|
||||
string? Period,
|
||||
decimal? TargetValue,
|
||||
decimal? BudgetAmount,
|
||||
Guid? AgencyUserId,
|
||||
string? RuleConfig);
|
||||
|
||||
public record ManageRegionResponse(
|
||||
Guid RegionId,
|
||||
RegionalTargetResponse? Target,
|
||||
RegionalBudgetResponse? Budget,
|
||||
RegionAgencyMapResponse? AgencyMap,
|
||||
RegionalTaxRuleResponse? TaxRule);
|
||||
|
||||
public record RegionHierarchyRouteItem(Guid RouteId, string RouteName, List<Guid> StoreIds);
|
||||
|
||||
public record RegionHierarchyResponse(Guid RegionId, string RegionName, List<RegionHierarchyRouteItem> Routes);
|
||||
@@ -33,4 +42,22 @@ public record RegionDashboardResponse(
|
||||
decimal? BudgetAmount,
|
||||
int OrderCount,
|
||||
decimal ActualSales,
|
||||
DateTime? LastOrderDate);
|
||||
DateTime? LastOrderDate,
|
||||
string? TaxRuleConfig,
|
||||
List<Guid> AgencyUserIds);
|
||||
|
||||
// Flat, CRUD-list-friendly view over the four "manage" sub-resources (RegionalTarget,
|
||||
// RegionalBudget, RegionalTaxRule, RegionAgencyMap), so the frontend can render one table like
|
||||
// any other resource list (e.g. Rides) instead of one row per region with derived columns.
|
||||
public enum RegionManageEntryType { Target, Budget, TaxRule, AgencyMap }
|
||||
|
||||
public record RegionManageEntryResponse(
|
||||
Guid Id,
|
||||
Guid RegionId,
|
||||
string RegionName,
|
||||
RegionManageEntryType EntryType,
|
||||
string? Period,
|
||||
decimal? Amount,
|
||||
string? RuleConfig,
|
||||
Guid? AgencyUserId,
|
||||
DateTime CreatedAt);
|
||||
|
||||
@@ -23,9 +23,13 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
|
||||
context.Regions.Update(entity);
|
||||
}
|
||||
|
||||
// Note: no IsActive filter on any RegionalTarget lookup below — this entity is hard-deleted
|
||||
// (see RemoveTarget), so IsActive is always true for rows that still exist. Querying without
|
||||
// the filter also means a pre-existing orphaned row (e.g. soft-deleted before this fix
|
||||
// shipped) is found and reused/cleaned up here instead of silently blocking a re-add.
|
||||
public async Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period) =>
|
||||
await context.RegionalTargets.FirstOrDefaultAsync(t =>
|
||||
t.FK_RegionId == regionId && t.Period == period && t.IsActive);
|
||||
t.FK_RegionId == regionId && t.Period == period);
|
||||
|
||||
public async Task AddTargetAsync(Models.RegionalTarget target) =>
|
||||
await context.RegionalTargets.AddAsync(target);
|
||||
@@ -33,9 +37,22 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
|
||||
public void UpdateTarget(Models.RegionalTarget target) =>
|
||||
context.RegionalTargets.Update(target);
|
||||
|
||||
public async Task<List<Models.RegionalTarget>> GetAllTargetsAsync() =>
|
||||
await context.RegionalTargets.ToListAsync();
|
||||
|
||||
public async Task<Models.RegionalTarget?> GetTargetByIdAsync(Guid id) =>
|
||||
await context.RegionalTargets.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
// Hard delete, not the usual IsActive soft-delete: RegionalTarget has a unique index on
|
||||
// (FK_RegionId, Period) at the DB level, which doesn't know about IsActive — soft-deleting
|
||||
// and then re-adding a target for the same region+period would violate that constraint.
|
||||
public void RemoveTarget(Models.RegionalTarget target) =>
|
||||
context.RegionalTargets.Remove(target);
|
||||
|
||||
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionalBudget.
|
||||
public async Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period) =>
|
||||
await context.RegionalBudgets.FirstOrDefaultAsync(b =>
|
||||
b.FK_RegionId == regionId && b.Period == period && b.IsActive);
|
||||
b.FK_RegionId == regionId && b.Period == period);
|
||||
|
||||
public async Task AddBudgetAsync(Models.RegionalBudget budget) =>
|
||||
await context.RegionalBudgets.AddAsync(budget);
|
||||
@@ -43,15 +60,45 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
|
||||
public void UpdateBudget(Models.RegionalBudget budget) =>
|
||||
context.RegionalBudgets.Update(budget);
|
||||
|
||||
public async Task<List<Models.RegionalBudget>> GetAllBudgetsAsync() =>
|
||||
await context.RegionalBudgets.ToListAsync();
|
||||
|
||||
public async Task<Models.RegionalBudget?> GetBudgetByIdAsync(Guid id) =>
|
||||
await context.RegionalBudgets.FirstOrDefaultAsync(b => b.Id == id);
|
||||
|
||||
// Hard delete — see RemoveTarget's comment; same unique-index-vs-soft-delete conflict
|
||||
// applies to RegionalBudget's (FK_RegionId, Period) unique index.
|
||||
public void RemoveBudget(Models.RegionalBudget budget) =>
|
||||
context.RegionalBudgets.Remove(budget);
|
||||
|
||||
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionAgencyMap.
|
||||
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);
|
||||
m.FK_RegionId == regionId && m.FK_AgencyUserId == agencyUserId);
|
||||
|
||||
public async Task AddAgencyMapAsync(Models.RegionAgencyMap map) =>
|
||||
await context.RegionAgencyMaps.AddAsync(map);
|
||||
|
||||
public void UpdateAgencyMap(Models.RegionAgencyMap map) =>
|
||||
context.RegionAgencyMaps.Update(map);
|
||||
|
||||
public async Task<List<Models.RegionAgencyMap>> GetAgencyMapsForRegionAsync(Guid regionId) =>
|
||||
await context.RegionAgencyMaps.Where(m => m.FK_RegionId == regionId).ToListAsync();
|
||||
|
||||
public async Task<List<Models.RegionAgencyMap>> GetAllAgencyMapsAsync() =>
|
||||
await context.RegionAgencyMaps.ToListAsync();
|
||||
|
||||
public async Task<Models.RegionAgencyMap?> GetAgencyMapByIdAsync(Guid id) =>
|
||||
await context.RegionAgencyMaps.FirstOrDefaultAsync(m => m.Id == id);
|
||||
|
||||
// Hard delete — see RemoveTarget's comment; same conflict applies to RegionAgencyMap's
|
||||
// (FK_RegionId, FK_AgencyUserId) unique index.
|
||||
public void RemoveAgencyMap(Models.RegionAgencyMap map) =>
|
||||
context.RegionAgencyMaps.Remove(map);
|
||||
|
||||
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionalTaxRule.
|
||||
public async Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId) =>
|
||||
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId && r.IsActive);
|
||||
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId);
|
||||
|
||||
public async Task AddTaxRuleAsync(Models.RegionalTaxRule rule) =>
|
||||
await context.RegionalTaxRules.AddAsync(rule);
|
||||
@@ -59,6 +106,17 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
|
||||
public void UpdateTaxRule(Models.RegionalTaxRule rule) =>
|
||||
context.RegionalTaxRules.Update(rule);
|
||||
|
||||
public async Task<List<Models.RegionalTaxRule>> GetAllTaxRulesAsync() =>
|
||||
await context.RegionalTaxRules.ToListAsync();
|
||||
|
||||
public async Task<Models.RegionalTaxRule?> GetTaxRuleByIdAsync(Guid id) =>
|
||||
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
// Hard delete — see RemoveTarget's comment; same conflict applies to RegionalTaxRule's
|
||||
// unique index on FK_RegionId alone (one tax rule per region).
|
||||
public void RemoveTaxRule(Models.RegionalTaxRule rule) =>
|
||||
context.RegionalTaxRules.Remove(rule);
|
||||
|
||||
public async Task<int?> GetUserRoleLevelAsync(Guid userId) =>
|
||||
await context.Users
|
||||
.Where(u => u.Id == userId && u.IsActive)
|
||||
|
||||
@@ -5,15 +5,11 @@ namespace Distribution.Features.Region;
|
||||
|
||||
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);
|
||||
}
|
||||
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once
|
||||
// the role model for this endpoint is finalized. Left open (allow-all) for now on purpose.
|
||||
private Task<bool> CanManageRegionAsync(Guid regionId) => Task.FromResult(true);
|
||||
|
||||
public async Task<List<RegionResponse>> GetAllAsync() =>
|
||||
(await repo.GetAllAsync())
|
||||
@@ -82,26 +78,97 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<RegionalTargetResponse?> SetTargetAsync(Guid regionId, string period, SetRegionalTargetRequest request, CancellationToken ct = default)
|
||||
// Consolidated replacement for the old separate SetTarget/SetBudget/MapAgency/SetTaxRule
|
||||
// endpoints. Each sub-action only runs if its fields are present on the request, so callers
|
||||
// can update just one thing (as the UI does) or several at once in a single round trip.
|
||||
public async Task<ManageRegionResponse?> ManageAsync(Guid regionId, ManageRegionRequest 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);
|
||||
RegionalTargetResponse? targetResponse = null;
|
||||
RegionalBudgetResponse? budgetResponse = null;
|
||||
RegionAgencyMapResponse? agencyMapResponse = null;
|
||||
RegionalTaxRuleResponse? taxRuleResponse = null;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (target is null)
|
||||
if (request.TargetValue is not null && !string.IsNullOrWhiteSpace(request.Period))
|
||||
{
|
||||
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = period, TargetValue = request.TargetValue };
|
||||
await repo.AddTargetAsync(target);
|
||||
var target = await repo.GetTargetAsync(regionId, request.Period);
|
||||
if (target is null)
|
||||
{
|
||||
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = request.Period, TargetValue = request.TargetValue.Value };
|
||||
await repo.AddTargetAsync(target);
|
||||
}
|
||||
else
|
||||
{
|
||||
target.TargetValue = request.TargetValue.Value;
|
||||
target.IsActive = true;
|
||||
repo.UpdateTarget(target);
|
||||
}
|
||||
targetResponse = new RegionalTargetResponse(regionId, request.Period, target.TargetValue);
|
||||
}
|
||||
else
|
||||
|
||||
if (request.BudgetAmount is not null && !string.IsNullOrWhiteSpace(request.Period))
|
||||
{
|
||||
target.TargetValue = request.TargetValue;
|
||||
repo.UpdateTarget(target);
|
||||
var budget = await repo.GetBudgetAsync(regionId, request.Period);
|
||||
if (budget is null)
|
||||
{
|
||||
budget = new Models.RegionalBudget { FK_RegionId = regionId, Period = request.Period, BudgetAmount = request.BudgetAmount.Value };
|
||||
await repo.AddBudgetAsync(budget);
|
||||
}
|
||||
else
|
||||
{
|
||||
budget.BudgetAmount = request.BudgetAmount.Value;
|
||||
budget.IsActive = true;
|
||||
repo.UpdateBudget(budget);
|
||||
}
|
||||
budgetResponse = new RegionalBudgetResponse(regionId, request.Period, budget.BudgetAmount);
|
||||
}
|
||||
|
||||
if (request.AgencyUserId is not null)
|
||||
{
|
||||
var agencyRoleLevel = await repo.GetUserRoleLevelAsync(request.AgencyUserId.Value);
|
||||
if (agencyRoleLevel is null || agencyRoleLevel < RegionalAgencyRoleLevel)
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
var existing = await repo.GetAgencyMapAsync(regionId, request.AgencyUserId.Value);
|
||||
if (existing is null)
|
||||
{
|
||||
var map = new Models.RegionAgencyMap { FK_RegionId = regionId, FK_AgencyUserId = request.AgencyUserId.Value };
|
||||
await repo.AddAgencyMapAsync(map);
|
||||
}
|
||||
else if (!existing.IsActive)
|
||||
{
|
||||
existing.IsActive = true;
|
||||
repo.UpdateAgencyMap(existing);
|
||||
}
|
||||
agencyMapResponse = new RegionAgencyMapResponse(regionId, request.AgencyUserId.Value);
|
||||
}
|
||||
|
||||
if (request.RuleConfig is not null)
|
||||
{
|
||||
var rule = await repo.GetTaxRuleAsync(regionId);
|
||||
if (rule is null)
|
||||
{
|
||||
rule = new Models.RegionalTaxRule { FK_RegionId = regionId, RuleConfig = request.RuleConfig };
|
||||
await repo.AddTaxRuleAsync(rule);
|
||||
}
|
||||
else
|
||||
{
|
||||
rule.RuleConfig = request.RuleConfig;
|
||||
rule.IsActive = true;
|
||||
repo.UpdateTaxRule(rule);
|
||||
}
|
||||
taxRuleResponse = new RegionalTaxRuleResponse(regionId, rule.RuleConfig);
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
@@ -109,94 +176,8 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
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);
|
||||
return new ManageRegionResponse(regionId, targetResponse, budgetResponse, agencyMapResponse, taxRuleResponse);
|
||||
}
|
||||
|
||||
public async Task<RegionHierarchyResponse?> GetHierarchyAsync(Guid regionId)
|
||||
@@ -228,6 +209,8 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
var target = await repo.GetTargetAsync(regionId, normalizedPeriod);
|
||||
var budget = await repo.GetBudgetAsync(regionId, normalizedPeriod);
|
||||
var orders = await repo.GetOrdersForDashboardAsync(regionId, periodStart, periodEndExclusive);
|
||||
var taxRule = await repo.GetTaxRuleAsync(regionId);
|
||||
var agencyMaps = await repo.GetAgencyMapsForRegionAsync(regionId);
|
||||
|
||||
return new RegionDashboardResponse(
|
||||
regionId,
|
||||
@@ -236,6 +219,79 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
budget?.BudgetAmount,
|
||||
orders.Count,
|
||||
orders.Sum(o => o.TotalAmount),
|
||||
orders.Count == 0 ? null : orders.Max(o => o.OrderDate));
|
||||
orders.Count == 0 ? null : orders.Max(o => o.OrderDate),
|
||||
taxRule?.RuleConfig,
|
||||
agencyMaps.Select(m => m.FK_AgencyUserId).ToList());
|
||||
}
|
||||
|
||||
public async Task<List<RegionManageEntryResponse>> GetManageEntriesAsync()
|
||||
{
|
||||
var regionNames = (await repo.GetAllAsync()).ToDictionary(r => r.Id, r => r.Name);
|
||||
string RegionName(Guid id) => regionNames.TryGetValue(id, out var name) ? name : "Unknown";
|
||||
|
||||
var entries = new List<RegionManageEntryResponse>();
|
||||
|
||||
entries.AddRange((await repo.GetAllTargetsAsync()).Select(t =>
|
||||
new RegionManageEntryResponse(t.Id, t.FK_RegionId, RegionName(t.FK_RegionId), RegionManageEntryType.Target, t.Period, t.TargetValue, null, null, t.CreatedAt)));
|
||||
|
||||
entries.AddRange((await repo.GetAllBudgetsAsync()).Select(b =>
|
||||
new RegionManageEntryResponse(b.Id, b.FK_RegionId, RegionName(b.FK_RegionId), RegionManageEntryType.Budget, b.Period, b.BudgetAmount, null, null, b.CreatedAt)));
|
||||
|
||||
entries.AddRange((await repo.GetAllTaxRulesAsync()).Select(r =>
|
||||
new RegionManageEntryResponse(r.Id, r.FK_RegionId, RegionName(r.FK_RegionId), RegionManageEntryType.TaxRule, null, null, r.RuleConfig, null, r.CreatedAt)));
|
||||
|
||||
entries.AddRange((await repo.GetAllAgencyMapsAsync()).Select(m =>
|
||||
new RegionManageEntryResponse(m.Id, m.FK_RegionId, RegionName(m.FK_RegionId), RegionManageEntryType.AgencyMap, null, null, null, m.FK_AgencyUserId, m.CreatedAt)));
|
||||
|
||||
return entries.OrderByDescending(e => e.CreatedAt).ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteManageEntryAsync(RegionManageEntryType entryType, Guid id, CancellationToken ct = default)
|
||||
{
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
bool found;
|
||||
switch (entryType)
|
||||
{
|
||||
case RegionManageEntryType.Target:
|
||||
var target = await repo.GetTargetByIdAsync(id);
|
||||
found = target is not null;
|
||||
if (target is not null) repo.RemoveTarget(target);
|
||||
break;
|
||||
case RegionManageEntryType.Budget:
|
||||
var budget = await repo.GetBudgetByIdAsync(id);
|
||||
found = budget is not null;
|
||||
if (budget is not null) repo.RemoveBudget(budget);
|
||||
break;
|
||||
case RegionManageEntryType.TaxRule:
|
||||
var rule = await repo.GetTaxRuleByIdAsync(id);
|
||||
found = rule is not null;
|
||||
if (rule is not null) repo.RemoveTaxRule(rule);
|
||||
break;
|
||||
case RegionManageEntryType.AgencyMap:
|
||||
var map = await repo.GetAgencyMapByIdAsync(id);
|
||||
found = map is not null;
|
||||
if (map is not null) repo.RemoveAgencyMap(map);
|
||||
break;
|
||||
default:
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,29 +50,39 @@ it's stored for a future pricing-rule integration.
|
||||
| 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)). 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}` | Implemented |
|
||||
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` | Implemented |
|
||||
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` | Implemented |
|
||||
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` | Implemented |
|
||||
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
|
||||
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
|
||||
| `mapAgencyToRegion(agencyId, regionId)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
|
||||
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
|
||||
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` | Implemented (region → routes → store ids; no `Area` level) |
|
||||
| `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`.
|
||||
|
||||
The four `set*`/`mapAgencyToRegion` actions above were later consolidated into a single
|
||||
`PUT /api/regions/{id}/manage` endpoint (`RegionService.ManageAsync`) — every field on the request
|
||||
is optional, and only the sub-action(s) whose fields are present get applied, so one call can
|
||||
update just one thing (as the frontend's `region-manage-sheet.tsx` does, one button per field) or
|
||||
several at once. See [BACKEND_API_REFERENCE.md §6.5](../../sharedDocs/BACKEND_API_REFERENCE.md#65-regions)
|
||||
for the exact request/response shape.
|
||||
|
||||
---
|
||||
|
||||
## Business Rules
|
||||
|
||||
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
|
||||
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.
|
||||
- ⚠️ **Temporarily disabled**: `RegionService.CanManageRegionAsync` (the gate on `ManageAsync`) was
|
||||
stubbed to always return `true` — role-based restriction is deliberately deferred pending a
|
||||
decision on the role model for this endpoint (tracked as a `TODO` in `RegionService.cs`). The
|
||||
design below is what it enforced before being stubbed out, and what it should return to:
|
||||
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
|
||||
set targets/budgets/tax-rule for their own region; only `RegionalAgency` can set them across
|
||||
regions. Role level ≥ 60 (`RegionalAgency`) allowed unconditionally, role level ≥ 50
|
||||
(`RegionalManager`) requires a matching `UserRegionScope` row for that region, anything lower
|
||||
rejected.
|
||||
- `agencyUserId` (when supplied to `manage`) still 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. This check was not part of the disabled gate and remains active.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -117,10 +117,10 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
|---|---|---|
|
||||
| `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 (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. | 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. | 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. | 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. | 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). |
|
||||
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Implemented — `PUT /api/regions/{id}/manage` (consolidated with the three rows below), backed by the `RegionalTarget` entity. Role-level restriction (`RegionalManager`+ with region scope, or `RegionalAgency` across any region) is temporarily disabled — see `10_region_management.md`. |
|
||||
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), backed by the `RegionalBudget` entity. Same endpoint/access note as `setRegionalTarget`. |
|
||||
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), 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 (this check remains active even with the role gate disabled). |
|
||||
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Implemented — `PUT /api/regions/{id}/manage` (consolidated), 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. | 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. | 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. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user