Feat: Aplicando Alterações/Ajustes

This commit is contained in:
Eduardo 2026-03-02 13:27:07 -03:00
parent 1f255888b0
commit 9d7306c395
7 changed files with 772 additions and 27 deletions

View File

@ -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<AuthController> _logger;
public AuthController(
UserManager<ApplicationUser> userManager,
AppDbContext db,
ITenantProvider tenantProvider,
IConfiguration config)
IConfiguration config,
ILogger<AuthController> 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<int> 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<string>(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<MobileLine> candidates,
IReadOnlyCollection<string> 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<string> 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();
}
}

View File

@ -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,

View File

@ -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<ActionResult<ResumoResponseDto>> 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<ResumoResponseDto> 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<ResumoResponseDto> 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<ResumoLineTotaisDto>
{
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;
}
}

View File

@ -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<int> 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<string>(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<MobileLine> candidates,
IReadOnlyCollection<string> 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<string> 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();
}
}

View File

@ -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<ActionResult<UserListItemDto>> 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<IActionResult> 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<List<ValidationErrorDto>> ValidateUpdateAsync(Guid userId, UserUpdateRequest req)
{
var errors = new List<ValidationErrorDto>();
var targetUser = await _userManager.Users
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == userId);
if (!string.IsNullOrWhiteSpace(req.Nome) && req.Nome.Trim().Length < 2)
{
@ -434,14 +442,15 @@ 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 =>
var tenantId = targetUser.TenantId;
var exists = await _userManager.Users
.IgnoreQueryFilters()
.AnyAsync(u =>
u.Id != userId &&
u.TenantId == tenantId &&
u.NormalizedEmail == normalized);
@ -451,7 +460,6 @@ public class UsersController : ControllerBase
errors.Add(new ValidationErrorDto { Field = "email", Message = "E-mail já cadastrado." });
}
}
}
if (!string.IsNullOrWhiteSpace(req.Senha))
{

View File

@ -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; }

View File

@ -12,6 +12,7 @@ public class CreateSystemTenantUserRequest
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public List<string> Roles { get; set; } = new();
public bool ClientCredentialsOnly { get; set; }
}
public class SystemTenantUserCreatedDto