diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs index 8d7ac24..5add27e 100644 --- a/Controllers/AuthController.cs +++ b/Controllers/AuthController.cs @@ -1,4 +1,5 @@ -using System.IdentityModel.Tokens.Jwt; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using line_gestao_api.Data; @@ -22,17 +23,20 @@ public class AuthController : ControllerBase private readonly AppDbContext _db; private readonly ITenantProvider _tenantProvider; private readonly IConfiguration _config; + private readonly ILogger _logger; public AuthController( UserManager userManager, AppDbContext db, ITenantProvider tenantProvider, - IConfiguration config) + IConfiguration config, + ILogger logger) { _userManager = userManager; _db = db; _tenantProvider = tenantProvider; _config = config; + _logger = logger; } [HttpPost("register")] @@ -75,6 +79,8 @@ public class AuthController : ControllerBase if (!effectiveTenantId.HasValue) return Unauthorized("Tenant inválido."); + await EnsureClientTenantDataBoundAsync(user, effectiveTenantId.Value); + var token = await GenerateJwtAsync(user, effectiveTenantId.Value); return Ok(new AuthResponse(token)); } @@ -141,6 +147,8 @@ public class AuthController : ControllerBase if (!effectiveTenantId.HasValue) return Unauthorized("Tenant inválido."); + await EnsureClientTenantDataBoundAsync(user, effectiveTenantId.Value); + var token = await GenerateJwtAsync(user, effectiveTenantId.Value); return Ok(new AuthResponse(token)); } @@ -208,4 +216,200 @@ public class AuthController : ControllerBase return user.TenantId; } + + private async Task EnsureClientTenantDataBoundAsync(ApplicationUser user, Guid tenantId) + { + var roles = await _userManager.GetRolesAsync(user); + if (!roles.Any(r => string.Equals(r, AppRoles.Cliente, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + try + { + await RebindMobileLinesToTenantBySourceKeyAsync(tenantId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Falha ao sincronizar linhas para tenant {TenantId} durante login de cliente.", tenantId); + } + } + + private async Task RebindMobileLinesToTenantBySourceKeyAsync(Guid tenantId) + { + if (tenantId == Guid.Empty) + { + return 0; + } + + var tenant = await _db.Tenants + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == tenantId && t.Ativo && !t.IsSystem); + + if (tenant == null) + { + return 0; + } + + if (!string.Equals( + tenant.SourceType, + SystemTenantConstants.MobileLinesClienteSourceType, + StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + var normalizedKeys = new HashSet(StringComparer.Ordinal); + AddNormalizedTenantKey(normalizedKeys, tenant.SourceKey); + AddNormalizedTenantKey(normalizedKeys, tenant.NomeOficial); + + if (normalizedKeys.Count == 0) + { + return 0; + } + + var candidates = await _db.MobileLines + .IgnoreQueryFilters() + .Where(x => x.TenantId != tenant.Id) + .Where(x => x.Cliente != null && x.Cliente != string.Empty) + .ToListAsync(); + + if (candidates.Count == 0) + { + return 0; + } + + var hasAnyTenantLine = await _db.MobileLines + .IgnoreQueryFilters() + .AsNoTracking() + .AnyAsync(x => x.TenantId == tenant.Id); + + var now = DateTime.UtcNow; + var reassigned = ReassignByMatcher( + candidates, + normalizedKeys, + tenant.Id, + now, + isRelaxedMatch: false); + + if (reassigned == 0 && !hasAnyTenantLine) + { + reassigned = ReassignByMatcher( + candidates, + normalizedKeys, + tenant.Id, + now, + isRelaxedMatch: true); + } + + if (reassigned > 0) + { + await _db.SaveChangesAsync(); + } + + return reassigned; + } + + private static int ReassignByMatcher( + IReadOnlyList candidates, + IReadOnlyCollection normalizedKeys, + Guid tenantId, + DateTime now, + bool isRelaxedMatch) + { + if (normalizedKeys.Count == 0) + { + return 0; + } + + var keys = isRelaxedMatch + ? normalizedKeys.Where(k => k.Length >= 6).ToList() + : normalizedKeys.ToList(); + + if (keys.Count == 0) + { + return 0; + } + + var reassigned = 0; + + foreach (var line in candidates) + { + var normalizedClient = NormalizeTenantKey(line.Cliente ?? string.Empty); + if (string.IsNullOrWhiteSpace(normalizedClient) || + string.Equals(normalizedClient, "RESERVA", StringComparison.Ordinal)) + { + continue; + } + + var matches = !isRelaxedMatch + ? keys.Contains(normalizedClient, StringComparer.Ordinal) + : keys.Any(k => + normalizedClient.Contains(k, StringComparison.Ordinal) || + k.Contains(normalizedClient, StringComparison.Ordinal)); + + if (!matches) + { + continue; + } + + line.TenantId = tenantId; + line.UpdatedAt = now; + reassigned++; + } + + return reassigned; + } + + private static void AddNormalizedTenantKey(ISet keys, string? rawKey) + { + var normalized = NormalizeTenantKey(rawKey ?? string.Empty); + if (string.IsNullOrWhiteSpace(normalized)) + { + return; + } + + if (string.Equals(normalized, "RESERVA", StringComparison.Ordinal)) + { + return; + } + + keys.Add(normalized); + } + + private static string NormalizeTenantKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var normalized = value.Trim().Normalize(NormalizationForm.FormD); + var sb = new StringBuilder(normalized.Length); + var previousWasSpace = false; + + foreach (var ch in normalized) + { + var category = CharUnicodeInfo.GetUnicodeCategory(ch); + if (category == UnicodeCategory.NonSpacingMark) + { + continue; + } + + if (char.IsWhiteSpace(ch)) + { + if (!previousWasSpace) + { + sb.Append(' '); + previousWasSpace = true; + } + continue; + } + + sb.Append(char.ToUpperInvariant(ch)); + previousWasSpace = false; + } + + return sb.ToString().Trim(); + } } diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index 7418142..ed23f17 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -528,6 +528,7 @@ namespace line_gestao_api.Controllers line.Skil, line.Modalidade, line.VencConta, + line.FranquiaLine, line.GestaoVozDados, line.Skeelo, line.VivoNewsPlus, @@ -592,6 +593,7 @@ namespace line_gestao_api.Controllers Skil = x.Skil, Modalidade = x.Modalidade, VencConta = x.VencConta, + FranquiaLine = x.FranquiaLine, GestaoVozDados = x.GestaoVozDados, Skeelo = x.Skeelo, VivoNewsPlus = x.VivoNewsPlus, @@ -660,6 +662,7 @@ namespace line_gestao_api.Controllers Skil = x.Skil, Modalidade = x.Modalidade, VencConta = x.VencConta, + FranquiaLine = x.FranquiaLine, GestaoVozDados = x.GestaoVozDados, Skeelo = x.Skeelo, VivoNewsPlus = x.VivoNewsPlus, diff --git a/Controllers/ResumoController.cs b/Controllers/ResumoController.cs index c8a94e7..a86eb8f 100644 --- a/Controllers/ResumoController.cs +++ b/Controllers/ResumoController.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; +using System.Linq; using line_gestao_api.Data; using line_gestao_api.Dtos; using line_gestao_api.Services; @@ -12,6 +14,8 @@ namespace line_gestao_api.Controllers; [Authorize] public class ResumoController : ControllerBase { + private static readonly Regex PlanGbRegex = new(@"(\d+(?:[.,]\d+)?)\s*(GB|MB)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private readonly AppDbContext _db; private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService; @@ -23,6 +27,36 @@ public class ResumoController : ControllerBase [HttpGet] public async Task> GetResumo() + { + var spreadsheetResponse = await BuildSpreadsheetResumoAsync(); + var hasLiveLines = await _db.MobileLines.AsNoTracking().AnyAsync(); + + if (hasLiveLines) + { + var live = await BuildLiveResumoAsync(); + + spreadsheetResponse.MacrophonyPlans = live.MacrophonyPlans; + spreadsheetResponse.MacrophonyTotals = live.MacrophonyTotals; + spreadsheetResponse.VivoLineResumos = live.VivoLineResumos; + spreadsheetResponse.VivoLineTotals = live.VivoLineTotals; + spreadsheetResponse.PlanoContratoResumos = live.PlanoContratoResumos; + spreadsheetResponse.PlanoContratoTotal = live.PlanoContratoTotal; + spreadsheetResponse.LineTotais = live.LineTotais; + spreadsheetResponse.GbDistribuicao = live.GbDistribuicao; + spreadsheetResponse.GbDistribuicaoTotal = live.GbDistribuicaoTotal; + spreadsheetResponse.ReservaLines = live.ReservaLines; + spreadsheetResponse.ReservaPorDdd = live.ReservaPorDdd; + spreadsheetResponse.TotalGeralLinhasReserva = live.TotalGeralLinhasReserva; + spreadsheetResponse.ReservaTotal = live.ReservaTotal; + } + + spreadsheetResponse.MacrophonyTotals ??= new ResumoMacrophonyTotalDto(); + spreadsheetResponse.VivoLineTotals ??= new ResumoVivoLineTotalDto(); + + return Ok(spreadsheetResponse); + } + + private async Task BuildSpreadsheetResumoAsync() { var reservaLines = await _db.ResumoReservaLines.AsNoTracking() .OrderBy(x => x.Ddd) @@ -177,6 +211,302 @@ public class ResumoController : ControllerBase response.VivoLineTotals ??= new ResumoVivoLineTotalDto(); response.VivoLineTotals.QtdLinhasTotal = canonicalTotalLinhas; - return Ok(response); + return response; + } + + private async Task BuildLiveResumoAsync() + { + var allLines = _db.MobileLines.AsNoTracking(); + var nonReservaLines = allLines.Where(x => + !EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") && + !EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") && + !EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); + var reservaLinesQuery = allLines.Where(x => + EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); + + var planosAgg = await nonReservaLines + .GroupBy(x => (x.PlanoContrato ?? "").Trim()) + .Select(g => new + { + Plano = g.Key, + TotalLinhas = g.Count(), + FranquiaMedia = g.Average(x => (decimal?)x.FranquiaVivo), + ValorTotal = g.Sum(x => x.ValorContratoVivo ?? 0m), + TravelCount = g.Count(x => (x.VivoTravelMundo ?? 0m) > 0m) + }) + .ToListAsync(); + + var planoRows = planosAgg + .Select(row => + { + var planoLabel = string.IsNullOrWhiteSpace(row.Plano) ? "SEM PLANO" : row.Plano; + var franquiaGb = (row.FranquiaMedia ?? 0m) > 0m + ? row.FranquiaMedia + : ExtractGbFromPlanName(planoLabel); + var valorIndividual = row.TotalLinhas > 0 ? row.ValorTotal / row.TotalLinhas : (decimal?)null; + + return new ResumoPlanoContratoResumoDto + { + PlanoContrato = planoLabel, + Gb = franquiaGb, + FranquiaGb = franquiaGb, + TotalLinhas = row.TotalLinhas, + ValorTotal = row.ValorTotal, + ValorIndividualComSvas = valorIndividual + }; + }) + .OrderByDescending(x => x.TotalLinhas ?? 0) + .ThenBy(x => x.PlanoContrato) + .ToList(); + + var macrophonyRows = planosAgg + .Select(row => + { + var planoLabel = string.IsNullOrWhiteSpace(row.Plano) ? "SEM PLANO" : row.Plano; + var franquiaGb = (row.FranquiaMedia ?? 0m) > 0m + ? row.FranquiaMedia + : ExtractGbFromPlanName(planoLabel); + var valorIndividual = row.TotalLinhas > 0 ? row.ValorTotal / row.TotalLinhas : (decimal?)null; + + return new ResumoMacrophonyPlanDto + { + PlanoContrato = planoLabel, + Gb = franquiaGb, + FranquiaGb = franquiaGb, + TotalLinhas = row.TotalLinhas, + ValorTotal = row.ValorTotal, + ValorIndividualComSvas = valorIndividual, + VivoTravel = row.TravelCount > 0 + }; + }) + .OrderByDescending(x => x.TotalLinhas ?? 0) + .ThenBy(x => x.PlanoContrato) + .ToList(); + + var clientesAgg = await nonReservaLines + .GroupBy(x => (x.Cliente ?? "").Trim()) + .Select(g => new + { + Cliente = g.Key, + QtdLinhas = g.Count(), + FranquiaTotal = g.Sum(x => x.FranquiaVivo ?? 0m), + ValorContratoVivo = g.Sum(x => x.ValorContratoVivo ?? 0m), + FranquiaLine = g.Sum(x => x.FranquiaLine ?? 0m), + ValorContratoLine = g.Sum(x => x.ValorContratoLine ?? 0m), + Lucro = g.Sum(x => x.Lucro ?? 0m) + }) + .ToListAsync(); + + var clientesRows = clientesAgg + .Select(row => new ResumoVivoLineResumoDto + { + Cliente = string.IsNullOrWhiteSpace(row.Cliente) ? "SEM CLIENTE" : row.Cliente, + QtdLinhas = row.QtdLinhas, + FranquiaTotal = row.FranquiaTotal, + ValorContratoVivo = row.ValorContratoVivo, + FranquiaLine = row.FranquiaLine, + ValorContratoLine = row.ValorContratoLine, + Lucro = row.Lucro + }) + .OrderByDescending(x => x.QtdLinhas ?? 0) + .ThenBy(x => x.Cliente) + .ToList(); + + var totalLinhasNaoReserva = clientesRows.Sum(x => x.QtdLinhas ?? 0); + var totalFranquiaNaoReserva = clientesRows.Sum(x => x.FranquiaTotal ?? 0m); + var totalValorContratoVivo = clientesRows.Sum(x => x.ValorContratoVivo ?? 0m); + var totalFranquiaLine = clientesRows.Sum(x => x.FranquiaLine ?? 0m); + var totalValorContratoLine = clientesRows.Sum(x => x.ValorContratoLine ?? 0m); + var totalLucro = clientesRows.Sum(x => x.Lucro ?? 0m); + + var pfAtivas = await nonReservaLines + .Where(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%")) + .Where(x => + EF.Functions.ILike((x.Skil ?? "").Trim(), "%fís%") || + EF.Functions.ILike((x.Skil ?? "").Trim(), "%fis%") || + EF.Functions.ILike((x.Skil ?? "").Trim(), "%pf%")) + .GroupBy(_ => 1) + .Select(g => new + { + QtdLinhas = g.Count(), + ValorTotal = g.Sum(x => x.ValorContratoLine ?? 0m), + LucroTotal = g.Sum(x => x.Lucro ?? 0m) + }) + .FirstOrDefaultAsync(); + + var pjAtivas = await nonReservaLines + .Where(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%")) + .Where(x => + EF.Functions.ILike((x.Skil ?? "").Trim(), "%jur%") || + EF.Functions.ILike((x.Skil ?? "").Trim(), "%pj%")) + .GroupBy(_ => 1) + .Select(g => new + { + QtdLinhas = g.Count(), + ValorTotal = g.Sum(x => x.ValorContratoLine ?? 0m), + LucroTotal = g.Sum(x => x.Lucro ?? 0m) + }) + .FirstOrDefaultAsync(); + + var totaisLine = new List + { + new() + { + Tipo = "PF", + QtdLinhas = pfAtivas?.QtdLinhas ?? 0, + ValorTotalLine = pfAtivas?.ValorTotal ?? 0m, + LucroTotalLine = pfAtivas?.LucroTotal ?? 0m + }, + new() + { + Tipo = "PJ", + QtdLinhas = pjAtivas?.QtdLinhas ?? 0, + ValorTotalLine = pjAtivas?.ValorTotal ?? 0m, + LucroTotalLine = pjAtivas?.LucroTotal ?? 0m + }, + new() + { + Tipo = "TOTAL", + QtdLinhas = totalLinhasNaoReserva, + ValorTotalLine = totalValorContratoLine, + LucroTotalLine = totalLucro + } + }; + + var gbDistribuicao = await nonReservaLines + .Where(x => (x.FranquiaVivo ?? 0m) > 0m) + .GroupBy(x => x.FranquiaVivo ?? 0m) + .Select(g => new ResumoGbDistribuicaoDto + { + Gb = g.Key, + Qtd = g.Count(), + Soma = g.Sum(x => x.FranquiaVivo ?? 0m) + }) + .OrderBy(x => x.Gb) + .ToListAsync(); + + var gbDistribuicaoTotal = new ResumoGbDistribuicaoTotalDto + { + TotalLinhas = gbDistribuicao.Sum(x => x.Qtd ?? 0), + SomaTotal = gbDistribuicao.Sum(x => x.Soma ?? 0m) + }; + + var reservaSnapshot = await reservaLinesQuery + .Select(x => new + { + x.Linha, + x.FranquiaVivo, + x.ValorContratoVivo + }) + .ToListAsync(); + + var reservaGroupRaw = reservaSnapshot + .Select(row => new + { + Ddd = ExtractDddFromLine(row.Linha) ?? "-", + FranquiaGb = row.FranquiaVivo + }) + .GroupBy(x => new { x.Ddd, x.FranquiaGb }) + .Select(g => new ResumoReservaLineDto + { + Ddd = g.Key.Ddd, + FranquiaGb = g.Key.FranquiaGb, + QtdLinhas = g.Count(), + Total = null + }) + .OrderBy(x => x.Ddd) + .ThenBy(x => x.FranquiaGb ?? 0m) + .ToList(); + + var reservaPorDdd = reservaGroupRaw + .GroupBy(x => x.Ddd ?? "-") + .Select(g => new ResumoReservaPorDddDto + { + Ddd = g.Key, + TotalLinhas = g.Sum(x => x.QtdLinhas ?? 0), + PorFranquia = g + .GroupBy(x => x.FranquiaGb) + .Select(fg => new ResumoReservaPorFranquiaDto + { + FranquiaGb = fg.Key, + TotalLinhas = fg.Sum(x => x.QtdLinhas ?? 0) + }) + .OrderBy(x => x.FranquiaGb ?? 0m) + .ToList() + }) + .OrderBy(x => x.Ddd) + .ToList(); + + var reservaTotalLinhas = reservaSnapshot.Count; + var reservaTotalValor = reservaSnapshot.Sum(x => x.ValorContratoVivo ?? 0m); + + return new ResumoResponseDto + { + MacrophonyPlans = macrophonyRows, + MacrophonyTotals = new ResumoMacrophonyTotalDto + { + TotalLinhasTotal = totalLinhasNaoReserva, + FranquiaGbTotal = totalFranquiaNaoReserva, + ValorTotal = totalValorContratoVivo + }, + VivoLineResumos = clientesRows, + VivoLineTotals = new ResumoVivoLineTotalDto + { + QtdLinhasTotal = totalLinhasNaoReserva, + FranquiaTotal = totalFranquiaNaoReserva, + ValorContratoVivo = totalValorContratoVivo, + FranquiaLine = totalFranquiaLine, + ValorContratoLine = totalValorContratoLine, + Lucro = totalLucro + }, + PlanoContratoResumos = planoRows, + PlanoContratoTotal = new ResumoPlanoContratoTotalDto + { + ValorTotal = planoRows.Sum(x => x.ValorTotal ?? 0m) + }, + LineTotais = totaisLine, + GbDistribuicao = gbDistribuicao, + GbDistribuicaoTotal = gbDistribuicaoTotal, + ReservaLines = reservaGroupRaw, + ReservaPorDdd = reservaPorDdd, + TotalGeralLinhasReserva = reservaTotalLinhas, + ReservaTotal = new ResumoReservaTotalDto + { + QtdLinhasTotal = reservaTotalLinhas, + Total = reservaTotalValor + } + }; + } + + private static decimal? ExtractGbFromPlanName(string? planoContrato) + { + if (string.IsNullOrWhiteSpace(planoContrato)) + return null; + + var match = PlanGbRegex.Match(planoContrato); + if (!match.Success) + return null; + + var normalized = match.Groups[1].Value.Replace(',', '.'); + if (!decimal.TryParse(normalized, out var rawValue)) + return null; + + var unit = match.Groups[2].Value.ToUpperInvariant(); + return unit == "MB" ? rawValue / 1000m : rawValue; + } + + private static string? ExtractDddFromLine(string? linha) + { + if (string.IsNullOrWhiteSpace(linha)) + return null; + + var digits = new string(linha.Where(char.IsDigit).ToArray()); + if (digits.Length >= 12 && digits.StartsWith("55")) + return digits.Substring(2, 2); + if (digits.Length >= 10) + return digits.Substring(0, 2); + return null; } } diff --git a/Controllers/SystemTenantUsersController.cs b/Controllers/SystemTenantUsersController.cs index 6e176a5..f713eff 100644 --- a/Controllers/SystemTenantUsersController.cs +++ b/Controllers/SystemTenantUsersController.cs @@ -6,6 +6,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Globalization; +using System.Text; namespace line_gestao_api.Controllers; @@ -85,6 +87,27 @@ public class SystemTenantUsersController : ControllerBase "unsupported_roles"); } + if (request.ClientCredentialsOnly) + { + if (tenant.IsSystem) + { + return await RejectAsync( + tenantId, + StatusCodes.Status400BadRequest, + "Credenciais de cliente não podem ser criadas no SystemTenant.", + "invalid_client_credentials_on_system_tenant"); + } + + if (normalizedRoles.Count != 1 || !normalizedRoles.Contains(AppRoles.Cliente, StringComparer.OrdinalIgnoreCase)) + { + return await RejectAsync( + tenantId, + StatusCodes.Status400BadRequest, + "Neste fluxo, somente a role cliente é permitida.", + "invalid_roles_for_client_credentials_flow"); + } + } + if (!tenant.IsSystem && normalizedRoles.Contains(SystemTenantConstants.SystemRole)) { return await RejectAsync( @@ -189,6 +212,12 @@ public class SystemTenantUsersController : ControllerBase return BadRequest(addRolesResult.Errors.Select(e => e.Description).ToList()); } + var linesReassigned = 0; + if (request.ClientCredentialsOnly) + { + linesReassigned = await RebindMobileLinesToTenantBySourceKeyAsync(tenant); + } + await _systemAuditService.LogAsync( action: SystemAuditActions.CreateTenantUser, targetTenantId: tenantId, @@ -196,7 +225,8 @@ public class SystemTenantUsersController : ControllerBase { createdUserId = user.Id, email, - roles = normalizedRoles + roles = normalizedRoles, + linesReassigned }); var response = new SystemTenantUserCreatedDto @@ -223,4 +253,172 @@ public class SystemTenantUsersController : ControllerBase return StatusCode(statusCode, message); } + + private async Task RebindMobileLinesToTenantBySourceKeyAsync(Tenant tenant) + { + if (tenant.Id == Guid.Empty) + { + return 0; + } + + if (!string.Equals( + tenant.SourceType, + SystemTenantConstants.MobileLinesClienteSourceType, + StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + var normalizedKeys = new HashSet(StringComparer.Ordinal); + AddNormalizedTenantKey(normalizedKeys, tenant.SourceKey); + AddNormalizedTenantKey(normalizedKeys, tenant.NomeOficial); + + if (normalizedKeys.Count == 0) + { + return 0; + } + + var candidates = await _db.MobileLines + .IgnoreQueryFilters() + .Where(x => x.TenantId != tenant.Id) + .Where(x => x.Cliente != null && x.Cliente != string.Empty) + .ToListAsync(); + + if (candidates.Count == 0) + { + return 0; + } + + var hasAnyTenantLine = await _db.MobileLines + .IgnoreQueryFilters() + .AsNoTracking() + .AnyAsync(x => x.TenantId == tenant.Id); + + var now = DateTime.UtcNow; + var reassigned = ReassignByMatcher( + candidates, + normalizedKeys, + tenant.Id, + now, + isRelaxedMatch: false); + + if (reassigned == 0 && !hasAnyTenantLine) + { + reassigned = ReassignByMatcher( + candidates, + normalizedKeys, + tenant.Id, + now, + isRelaxedMatch: true); + } + + if (reassigned > 0) + { + await _db.SaveChangesAsync(); + } + + return reassigned; + } + + private static int ReassignByMatcher( + IReadOnlyList candidates, + IReadOnlyCollection normalizedKeys, + Guid tenantId, + DateTime now, + bool isRelaxedMatch) + { + if (normalizedKeys.Count == 0) + { + return 0; + } + + var keys = isRelaxedMatch + ? normalizedKeys.Where(k => k.Length >= 6).ToList() + : normalizedKeys.ToList(); + + if (keys.Count == 0) + { + return 0; + } + + var reassigned = 0; + + foreach (var line in candidates) + { + var normalizedClient = NormalizeTenantKey(line.Cliente ?? string.Empty); + if (string.IsNullOrWhiteSpace(normalizedClient) || + string.Equals(normalizedClient, "RESERVA", StringComparison.Ordinal)) + { + continue; + } + + var matches = !isRelaxedMatch + ? keys.Contains(normalizedClient, StringComparer.Ordinal) + : keys.Any(k => + normalizedClient.Contains(k, StringComparison.Ordinal) || + k.Contains(normalizedClient, StringComparison.Ordinal)); + + if (!matches) + { + continue; + } + + line.TenantId = tenantId; + line.UpdatedAt = now; + reassigned++; + } + + return reassigned; + } + + private static void AddNormalizedTenantKey(ISet keys, string? rawKey) + { + var normalized = NormalizeTenantKey(rawKey ?? string.Empty); + if (string.IsNullOrWhiteSpace(normalized)) + { + return; + } + + if (string.Equals(normalized, "RESERVA", StringComparison.Ordinal)) + { + return; + } + + keys.Add(normalized); + } + + private static string NormalizeTenantKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var normalized = value.Trim().Normalize(NormalizationForm.FormD); + var sb = new StringBuilder(normalized.Length); + var previousWasSpace = false; + foreach (var ch in normalized) + { + var category = CharUnicodeInfo.GetUnicodeCategory(ch); + if (category == UnicodeCategory.NonSpacingMark) + { + continue; + } + + if (char.IsWhiteSpace(ch)) + { + if (!previousWasSpace) + { + sb.Append(' '); + previousWasSpace = true; + } + continue; + } + + sb.Append(char.ToUpperInvariant(ch)); + previousWasSpace = false; + } + + return sb.ToString().Trim(); + } } diff --git a/Controllers/UsersController.cs b/Controllers/UsersController.cs index e37e6b1..b65f733 100644 --- a/Controllers/UsersController.cs +++ b/Controllers/UsersController.cs @@ -133,7 +133,9 @@ public class UsersController : ControllerBase page = page < 1 ? 1 : page; pageSize = pageSize < 1 ? 20 : pageSize; - var usersQuery = _userManager.Users.AsNoTracking(); + var usersQuery = _userManager.Users + .IgnoreQueryFilters() + .AsNoTracking(); if (!string.IsNullOrWhiteSpace(search)) { @@ -195,7 +197,10 @@ public class UsersController : ControllerBase [Authorize(Roles = "sysadmin")] public async Task> GetById(Guid id) { - var user = await _userManager.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id); + var user = await _userManager.Users + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == id); if (user == null) { return NotFound(); @@ -225,7 +230,9 @@ public class UsersController : ControllerBase return BadRequest(new ValidationErrorResponse { Errors = errors }); } - var user = await _userManager.Users.FirstOrDefaultAsync(u => u.Id == id); + var user = await _userManager.Users + .IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.Id == id); if (user == null) { return NotFound(); @@ -299,11 +306,6 @@ public class UsersController : ControllerBase [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { - if (_tenantProvider.TenantId == null) - { - return Unauthorized(); - } - var currentUserId = GetCurrentUserId(); if (currentUserId.HasValue && currentUserId.Value == id) { @@ -316,12 +318,14 @@ public class UsersController : ControllerBase }); } - var tenantId = _tenantProvider.TenantId.Value; - var user = await _userManager.Users.FirstOrDefaultAsync(u => u.Id == id && u.TenantId == tenantId); + var user = await _userManager.Users + .IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.Id == id); if (user == null) { return NotFound(); } + var tenantId = user.TenantId; if (user.IsActive) { @@ -423,6 +427,10 @@ public class UsersController : ControllerBase private async Task> ValidateUpdateAsync(Guid userId, UserUpdateRequest req) { var errors = new List(); + var targetUser = await _userManager.Users + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == userId); if (!string.IsNullOrWhiteSpace(req.Nome) && req.Nome.Trim().Length < 2) { @@ -434,22 +442,22 @@ public class UsersController : ControllerBase var email = req.Email.Trim().ToLowerInvariant(); var normalized = _userManager.NormalizeEmail(email); - var tenantId = _tenantProvider.TenantId; - if (tenantId == null) + if (targetUser == null) { - errors.Add(new ValidationErrorDto { Field = "email", Message = "Tenant inválido." }); + return errors; } - else - { - var exists = await _userManager.Users.AnyAsync(u => - u.Id != userId && - u.TenantId == tenantId && - u.NormalizedEmail == normalized); - if (exists) - { - errors.Add(new ValidationErrorDto { Field = "email", Message = "E-mail já cadastrado." }); - } + var tenantId = targetUser.TenantId; + var exists = await _userManager.Users + .IgnoreQueryFilters() + .AnyAsync(u => + u.Id != userId && + u.TenantId == tenantId && + u.NormalizedEmail == normalized); + + if (exists) + { + errors.Add(new ValidationErrorDto { Field = "email", Message = "E-mail já cadastrado." }); } } diff --git a/Dtos/MobileLineDtos.cs b/Dtos/MobileLineDtos.cs index a35a500..e84bd6b 100644 --- a/Dtos/MobileLineDtos.cs +++ b/Dtos/MobileLineDtos.cs @@ -14,6 +14,7 @@ public string? Skil { get; set; } public string? Modalidade { get; set; } public string? VencConta { get; set; } + public decimal? FranquiaLine { get; set; } // Campos para filtro deterministico de adicionais no frontend public decimal? GestaoVozDados { get; set; } diff --git a/Dtos/SystemTenantDtos.cs b/Dtos/SystemTenantDtos.cs index 723d9ef..f008c43 100644 --- a/Dtos/SystemTenantDtos.cs +++ b/Dtos/SystemTenantDtos.cs @@ -12,6 +12,7 @@ public class CreateSystemTenantUserRequest public string Email { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; public List Roles { get; set; } = new(); + public bool ClientCredentialsOnly { get; set; } } public class SystemTenantUserCreatedDto