using Distribution.Infra.Authorization; using Distribution.Infra.UoW; namespace Distribution.Features.Region; public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess) { private const int RegionalAgencyRoleLevel = 60; private const string CodePrefix = "REG-"; // 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 CanManageRegionAsync(Guid regionId) => Task.FromResult(true); public async Task> GetAllAsync() => (await repo.GetAllAsync()) .Where(r => r.IsActive) .Select(r => new RegionResponse(r.Id, r.Code, r.Name, r.IsActive, r.CreatedAt)) .ToList(); public async Task GetByIdAsync(Guid id) { var region = await repo.GetByIdAsync(id); if (region is null || !region.IsActive) return null; return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt); } // Next code = highest existing REG-#### number + 1 (soft-deleted regions included so codes are never reused). public async Task GetNextCodeAsync() => new(await GenerateCodeAsync()); private async Task GenerateCodeAsync() { var max = (await repo.GetAllCodesAsync()) .Where(c => c.StartsWith(CodePrefix, StringComparison.OrdinalIgnoreCase) && int.TryParse(c[CodePrefix.Length..], out _)) .Select(c => int.Parse(c[CodePrefix.Length..])) .DefaultIfEmpty(0) .Max(); return $"{CodePrefix}{max + 1:D4}"; } public async Task CreateAsync(CreateRegionRequest request, CancellationToken ct = default) { string code; if (string.IsNullOrWhiteSpace(request.Code)) code = await GenerateCodeAsync(); else { code = request.Code.Trim(); if (await repo.CodeExistsAsync(code)) throw new InvalidOperationException($"Region code '{code}' is already in use."); } var region = new Models.Region { Name = request.Name, Code = code }; await uow.BeginAsync(ct); try { await repo.AddAsync(region); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt); } public async Task UpdateAsync(Guid id, UpdateRegionRequest request, CancellationToken ct = default) { var region = await repo.GetByIdAsync(id); if (region is null || !region.IsActive) return null; region.Name = request.Name; if (!string.IsNullOrWhiteSpace(request.Code)) { var code = request.Code.Trim(); if (code != region.Code && await repo.CodeExistsAsync(code, id)) throw new InvalidOperationException($"Region code '{code}' is already in use."); region.Code = code; } await uow.BeginAsync(ct); try { repo.Update(region); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt); } private const string RegionalManagerRole = "RegionalManager"; public async Task GetManagerAsync(Guid regionId) { var region = await repo.GetByIdAsync(regionId); if (region is null || !region.IsActive) return null; var scope = (await repo.GetManagerScopesAsync(regionId)).FirstOrDefault(); return scope is null ? null : new RegionManagerResponse(regionId, scope.FK_UserId, scope.User.Username, scope.User.FullName); } // A region has exactly one manager: assigning replaces any existing RegionalManager scoped to it. // Returns null when the region does not exist; throws InvalidOperationException for an invalid user. public async Task SetManagerAsync(Guid regionId, SetRegionManagerRequest request, CancellationToken ct = default) { var region = await repo.GetByIdAsync(regionId); if (region is null || !region.IsActive) return null; var user = await repo.GetUserWithRoleAsync(request.UserId); if (user is null || !user.IsActive) throw new InvalidOperationException("User not found."); if (user.Role?.Name != RegionalManagerRole) throw new InvalidOperationException($"User must have the {RegionalManagerRole} role to manage a region."); await uow.BeginAsync(ct); try { var existing = await repo.GetManagerScopesAsync(regionId); repo.RemoveRegionScopes(existing.Where(s => s.FK_UserId != user.Id)); if (await repo.GetRegionScopeAsync(user.Id, regionId) is null) await repo.AddRegionScopeAsync(new Models.UserRegionScope { FK_UserId = user.Id, FK_RegionId = regionId }); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return new RegionManagerResponse(regionId, user.Id, user.Username, user.FullName); } public async Task RemoveManagerAsync(Guid regionId, CancellationToken ct = default) { var region = await repo.GetByIdAsync(regionId); if (region is null || !region.IsActive) return false; var existing = await repo.GetManagerScopesAsync(regionId); if (existing.Count == 0) return false; await uow.BeginAsync(ct); try { repo.RemoveRegionScopes(existing); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return true; } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var region = await repo.GetByIdAsync(id); if (region is null || !region.IsActive) return false; await uow.BeginAsync(ct); try { repo.Remove(region); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return true; } // 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 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; RegionalTargetResponse? targetResponse = null; RegionalBudgetResponse? budgetResponse = null; RegionAgencyMapResponse? agencyMapResponse = null; RegionalTaxRuleResponse? taxRuleResponse = null; await uow.BeginAsync(ct); try { if (request.TargetValue is not null && !string.IsNullOrWhiteSpace(request.Period)) { 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); } if (request.BudgetAmount is not null && !string.IsNullOrWhiteSpace(request.Period)) { 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 { await uow.RollbackAsync(ct); throw; } return new ManageRegionResponse(regionId, targetResponse, budgetResponse, agencyMapResponse, taxRuleResponse); } 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); var taxRule = await repo.GetTaxRuleAsync(regionId); var agencyMaps = await repo.GetAgencyMapsForRegionAsync(regionId); return new RegionDashboardResponse( regionId, normalizedPeriod, target?.TargetValue, budget?.BudgetAmount, orders.Count, orders.Sum(o => o.TotalAmount), orders.Count == 0 ? null : orders.Max(o => o.CreatedAt), taxRule?.RuleConfig, agencyMaps.Select(m => m.FK_AgencyUserId).ToList()); } public async Task> 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(); 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 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; } } }