using System.Globalization; using System.Security.Claims; using System.Text; using System.Text.Json; using line_gestao_api.Data; using line_gestao_api.Models; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; namespace line_gestao_api.Services; public sealed class DashboardKpiSnapshotService { private const string SnapshotEntityName = "DashboardKpiSnapshot"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly AppDbContext _db; private readonly ITenantProvider _tenantProvider; public DashboardKpiSnapshotService(AppDbContext db, ITenantProvider tenantProvider) { _db = db; _tenantProvider = tenantProvider; } public async Task CaptureAfterSpreadsheetImportAsync( ClaimsPrincipal user, HttpContext httpContext, CancellationToken cancellationToken = default) { var tenantId = _tenantProvider.TenantId; if (!tenantId.HasValue) { return; } var userId = TryParseUserId(user); var userName = user.FindFirst("name")?.Value ?? user.FindFirst(ClaimTypes.Name)?.Value ?? user.Identity?.Name ?? "USUARIO"; var userEmail = user.FindFirst(ClaimTypes.Email)?.Value ?? user.FindFirst("email")?.Value; foreach (var scope in EnumerateScopes()) { var snapshot = await BuildSnapshotAsync(scope.Operadora, cancellationToken); _db.AuditLogs.Add(new AuditLog { TenantId = tenantId.Value, ActorUserId = userId, ActorTenantId = tenantId.Value, TargetTenantId = tenantId.Value, OccurredAtUtc = DateTime.UtcNow, UserId = userId, UserName = userName, UserEmail = userEmail, Action = "SNAPSHOT", Page = AuditLogBuilder.SpreadsheetImportPageName, EntityName = SnapshotEntityName, EntityId = snapshot.Scope, EntityLabel = $"Snapshot Dashboard {snapshot.Scope}", ChangesJson = "[]", MetadataJson = JsonSerializer.Serialize(snapshot, JsonOptions), RequestPath = httpContext.Request.Path.Value, RequestMethod = httpContext.Request.Method, IpAddress = httpContext.Connection.RemoteIpAddress?.ToString() }); } await _db.SaveChangesAsync(cancellationToken); } public async Task> GetTrendMapAsync( string? operadora = null, CancellationToken cancellationToken = default) { var scope = NormalizeScope(operadora); var logs = await _db.AuditLogs .AsNoTracking() .Where(x => x.Page == AuditLogBuilder.SpreadsheetImportPageName && x.EntityName == SnapshotEntityName && x.EntityId == scope) .OrderByDescending(x => x.OccurredAtUtc) .Take(8) .Select(x => new { x.OccurredAtUtc, x.MetadataJson }) .ToListAsync(cancellationToken); if (logs.Count < 2) { return new Dictionary(StringComparer.OrdinalIgnoreCase); } var latest = DeserializeSnapshot(logs[0].MetadataJson); if (latest == null) { return new Dictionary(StringComparer.OrdinalIgnoreCase); } var baselineThreshold = logs[0].OccurredAtUtc.AddHours(-24); var previous = logs .Skip(1) .Select(x => DeserializeSnapshot(x.MetadataJson)) .FirstOrDefault(x => x != null && x.CapturedAtUtc >= baselineThreshold); if (previous == null) { return new Dictionary(StringComparer.OrdinalIgnoreCase); } var keys = latest.Metrics.Keys .Concat(previous.Metrics.Keys) .Distinct(StringComparer.OrdinalIgnoreCase); var result = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var key in keys) { latest.Metrics.TryGetValue(key, out var currentValue); previous.Metrics.TryGetValue(key, out var previousValue); result[key] = currentValue > previousValue ? "up" : currentValue < previousValue ? "down" : "stable"; } return result; } private async Task BuildSnapshotAsync( string? operadora, CancellationToken cancellationToken) { var todayUtcStart = DateTime.SpecifyKind(DateTime.UtcNow.Date, DateTimeKind.Utc); var tomorrowUtcStart = todayUtcStart.AddDays(1); var last30UtcStart = todayUtcStart.AddDays(-30); var limit30ExclusiveUtcStart = todayUtcStart.AddDays(31); var qLines = OperadoraContaResolver.ApplyOperadoraFilter(_db.MobileLines.AsNoTracking(), operadora); var qReserva = ApplyReservaContextFilter(qLines); var qOperacionais = ExcludeReservaContext(qLines); var qOperacionaisWithClient = qOperacionais.Where(x => x.Cliente != null && x.Cliente != ""); var qNonReserva = ExcludeReservaContext(qLines); var totalLinhas = await qLines.CountAsync(cancellationToken); var linhasAtivas = await qOperacionais.CountAsync(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%"), cancellationToken); var linhasBloqueadas = await qOperacionaisWithClient.CountAsync(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%"), cancellationToken); var linhasReserva = await qReserva.CountAsync(cancellationToken); var franquiaVivoTotal = await qLines.SumAsync(x => x.FranquiaVivo ?? 0m, cancellationToken); var franquiaLineTotal = await qLines.SumAsync(x => x.FranquiaLine ?? 0m, cancellationToken); var vigVencidos = await _db.VigenciaLines.AsNoTracking() .CountAsync(x => x.DtTerminoFidelizacao != null && x.DtTerminoFidelizacao.Value < todayUtcStart, cancellationToken); var vigencia30 = await _db.VigenciaLines.AsNoTracking() .CountAsync(x => x.DtTerminoFidelizacao != null && x.DtTerminoFidelizacao.Value >= todayUtcStart && x.DtTerminoFidelizacao.Value < limit30ExclusiveUtcStart, cancellationToken); var mureg30 = await _db.MuregLines.AsNoTracking() .CountAsync(x => x.DataDaMureg != null && x.DataDaMureg.Value >= last30UtcStart && x.DataDaMureg.Value < tomorrowUtcStart, cancellationToken); var troca30 = await _db.TrocaNumeroLines.AsNoTracking() .CountAsync(x => x.DataTroca != null && x.DataTroca.Value >= last30UtcStart && x.DataTroca.Value < tomorrowUtcStart, cancellationToken); var qLineItems = qLines .Where(x => x.Item > 0) .Select(x => x.Item); var qLineNumbers = qLines .Where(x => x.Linha != null && x.Linha != "") .Select(x => x.Linha!); var qUserData = _db.UserDatas.AsNoTracking() .Where(x => (x.Item > 0 && qLineItems.Contains(x.Item)) || (x.Linha != null && x.Linha != "" && qLineNumbers.Contains(x.Linha))); var cadastrosTotal = await qUserData.CountAsync(cancellationToken); var travelCom = await qLines.CountAsync(x => (x.VivoTravelMundo ?? 0m) > 0m, cancellationToken); var adicionalPago = await qLines.CountAsync(x => (x.GestaoVozDados ?? 0m) > 0m || (x.Skeelo ?? 0m) > 0m || (x.VivoNewsPlus ?? 0m) > 0m || (x.VivoTravelMundo ?? 0m) > 0m || (x.VivoSync ?? 0m) > 0m || (x.VivoGestaoDispositivo ?? 0m) > 0m, cancellationToken); var planosContratados = await qNonReserva .Where(x => x.PlanoContrato != null && x.PlanoContrato != "") .Select(x => (x.PlanoContrato ?? "").Trim()) .Distinct() .CountAsync(cancellationToken); var usuariosRaw = await qNonReserva .Where(x => x.Usuario != null && x.Usuario != "") .Select(x => new { Usuario = (x.Usuario ?? "").Trim(), Status = (x.Status ?? "").Trim() }) .ToListAsync(cancellationToken); var usuariosComLinha = usuariosRaw .Where(x => ShouldIncludeUsuario(x.Usuario, x.Status)) .Select(x => x.Usuario) .Distinct(StringComparer.OrdinalIgnoreCase) .Count(); return new DashboardKpiSnapshotPayload { Scope = NormalizeScope(operadora), CapturedAtUtc = DateTime.UtcNow, Metrics = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["linhas_total"] = totalLinhas, ["linhas_ativas"] = linhasAtivas, ["linhas_bloqueadas"] = linhasBloqueadas, ["linhas_reserva"] = linhasReserva, ["franquia_vivo_total"] = franquiaVivoTotal, ["franquia_line_total"] = franquiaLineTotal, ["vig_vencidos"] = vigVencidos, ["vig_30"] = vigencia30, ["mureg_30"] = mureg30, ["troca_30"] = troca30, ["cadastros_total"] = cadastrosTotal, ["travel_com"] = travelCom, ["adicional_pago"] = adicionalPago, ["planos_contratados"] = planosContratados, ["usuarios_com_linha"] = usuariosComLinha } }; } private static Guid? TryParseUserId(ClaimsPrincipal user) { var claim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return Guid.TryParse(claim, out var userId) ? userId : null; } private static IEnumerable<(string Scope, string? Operadora)> EnumerateScopes() { yield return ("TODOS", null); yield return ("VIVO", "VIVO"); yield return ("CLARO", "CLARO"); yield return ("TIM", "TIM"); } private static string NormalizeScope(string? operadora) { var token = NormalizeToken(operadora); if (string.IsNullOrWhiteSpace(token)) { return "TODOS"; } return token; } private static DashboardKpiSnapshotPayload? DeserializeSnapshot(string? metadataJson) { if (string.IsNullOrWhiteSpace(metadataJson)) { return null; } try { return JsonSerializer.Deserialize(metadataJson, JsonOptions); } catch { return null; } } private static IQueryable ApplyReservaContextFilter(IQueryable query) { return query.Where(x => EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); } private static IQueryable ExcludeReservaContext(IQueryable query) { return query.Where(x => !EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") && !EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") && !EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); } private static bool ShouldIncludeUsuario(string usuario, string status) { var usuarioKey = NormalizeToken(usuario); if (string.IsNullOrWhiteSpace(usuarioKey)) { return false; } var invalidUserTokens = new[] { "SEMUSUARIO", "AGUARDANDOUSUARIO", "AGUARDANDO", "BLOQUEAR", "BLOQUEAD", "BLOQUEADO", "BLOQUEIO", "BLOQ120", "RESERVA", "NAOATRIBUIDO", "PENDENTE", "COBRANCA", "FATURAMENTO", "FINANCEIRO", "BACKOFFICE", "ADMINISTRATIVO", }; if (invalidUserTokens.Any(usuarioKey.Contains)) { return false; } var statusKey = NormalizeToken(status); var blockedStatusTokens = new[] { "BLOQUE", "PERDA", "ROUBO", "SUSPEN", "CANCEL", "AGUARD" }; return !blockedStatusTokens.Any(statusKey.Contains); } private static string NormalizeToken(string? value) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } var normalized = value.Normalize(NormalizationForm.FormD); var builder = new StringBuilder(normalized.Length); foreach (var ch in normalized) { var category = CharUnicodeInfo.GetUnicodeCategory(ch); if (category == UnicodeCategory.NonSpacingMark) { continue; } if (char.IsLetterOrDigit(ch)) { builder.Append(char.ToUpperInvariant(ch)); } } return builder.ToString(); } private sealed class DashboardKpiSnapshotPayload { public string Scope { get; set; } = "TODOS"; public DateTime CapturedAtUtc { get; set; } public Dictionary Metrics { get; set; } = new(StringComparer.OrdinalIgnoreCase); } }