diff --git a/Controllers/HistoricoController.cs b/Controllers/HistoricoController.cs index c6676d5..3a05e6c 100644 --- a/Controllers/HistoricoController.cs +++ b/Controllers/HistoricoController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Globalization; +using System.Text; namespace line_gestao_api.Controllers; @@ -24,6 +25,14 @@ public class HistoricoController : ControllerBase nameof(ParcelamentoLine) }; + private static readonly HashSet ChipRelatedEntities = new(StringComparer.OrdinalIgnoreCase) + { + nameof(MobileLine), + nameof(TrocaNumeroLine), + nameof(ChipVirgemLine), + nameof(ControleRecebidoLine) + }; + private readonly AppDbContext _db; public HistoricoController(AppDbContext db) @@ -152,10 +161,7 @@ public class HistoricoController : ControllerBase var lineTerm = (line ?? string.Empty).Trim(); var normalizedLineDigits = DigitsOnly(lineTerm); - if (string.IsNullOrWhiteSpace(lineTerm) && string.IsNullOrWhiteSpace(normalizedLineDigits)) - { - return BadRequest(new { message = "Informe uma linha para consultar o histórico." }); - } + var hasLineFilter = !string.IsNullOrWhiteSpace(lineTerm) || !string.IsNullOrWhiteSpace(normalizedLineDigits); var q = _db.AuditLogs .AsNoTracking() @@ -213,7 +219,116 @@ public class HistoricoController : ControllerBase foreach (var log in candidateLogs) { var changes = ParseChanges(log.ChangesJson); - if (!MatchesLine(log, changes, lineTerm, normalizedLineDigits)) + if (hasLineFilter && !MatchesLine(log, changes, lineTerm, normalizedLineDigits)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(searchTerm) && !MatchesSearch(log, changes, searchTerm, searchDigits)) + { + continue; + } + + matchedLogs.Add((log, changes)); + } + + var total = matchedLogs.Count; + var items = matchedLogs + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => ToDto(x.Log, x.Changes)) + .ToList(); + + return Ok(new PagedResult + { + Page = page, + PageSize = pageSize, + Total = total, + Items = items + }); + } + + [HttpGet("chips")] + public async Task>> GetChipHistory( + [FromQuery] string? chip, + [FromQuery] string? pageName, + [FromQuery] string? action, + [FromQuery] string? user, + [FromQuery] string? search, + [FromQuery] DateTime? dateFrom, + [FromQuery] DateTime? dateTo, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + page = page < 1 ? 1 : page; + pageSize = pageSize < 1 ? 20 : pageSize; + + var chipTerm = (chip ?? string.Empty).Trim(); + var normalizedChipDigits = DigitsOnly(chipTerm); + var hasChipFilter = !string.IsNullOrWhiteSpace(chipTerm) || !string.IsNullOrWhiteSpace(normalizedChipDigits); + + var q = _db.AuditLogs + .AsNoTracking() + .Where(x => ChipRelatedEntities.Contains(x.EntityName)) + .Where(x => + !EF.Functions.ILike(x.RequestPath ?? "", "%import-excel%") || + x.Page == AuditLogBuilder.SpreadsheetImportPageName); + + if (!string.IsNullOrWhiteSpace(pageName)) + { + var p = pageName.Trim(); + q = q.Where(x => EF.Functions.ILike(x.Page, $"%{p}%")); + } + + if (!string.IsNullOrWhiteSpace(action)) + { + var a = action.Trim().ToUpperInvariant(); + q = q.Where(x => x.Action == a); + } + + if (!string.IsNullOrWhiteSpace(user)) + { + var u = user.Trim(); + q = q.Where(x => + EF.Functions.ILike(x.UserName ?? "", $"%{u}%") || + EF.Functions.ILike(x.UserEmail ?? "", $"%{u}%")); + } + + if (dateFrom.HasValue) + { + var fromUtc = ToUtc(dateFrom.Value); + q = q.Where(x => x.OccurredAtUtc >= fromUtc); + } + + if (dateTo.HasValue) + { + var toUtc = ToUtc(dateTo.Value); + if (dateTo.Value.TimeOfDay == TimeSpan.Zero) + { + toUtc = toUtc.Date.AddDays(1).AddTicks(-1); + } + + q = q.Where(x => x.OccurredAtUtc <= toUtc); + } + + var candidateLogs = await q + .OrderByDescending(x => x.OccurredAtUtc) + .ThenByDescending(x => x.Id) + .ToListAsync(); + + var searchTerm = (search ?? string.Empty).Trim(); + var searchDigits = DigitsOnly(searchTerm); + var matchedLogs = new List<(AuditLog Log, List Changes)>(); + + foreach (var log in candidateLogs) + { + var changes = ParseChanges(log.ChangesJson); + if (!HasChipContext(log, changes)) + { + continue; + } + + if (hasChipFilter && !MatchesChip(log, changes, chipTerm, normalizedChipDigits)) { continue; } @@ -348,6 +463,51 @@ public class HistoricoController : ControllerBase return false; } + private static bool MatchesChip( + AuditLog log, + List changes, + string chipTerm, + string normalizedChipDigits) + { + if (MatchesTerm(log.EntityLabel, chipTerm, normalizedChipDigits) || + MatchesTerm(log.EntityId, chipTerm, normalizedChipDigits)) + { + return true; + } + + foreach (var change in changes) + { + if (MatchesTerm(change.Field, chipTerm, normalizedChipDigits) || + MatchesTerm(change.OldValue, chipTerm, normalizedChipDigits) || + MatchesTerm(change.NewValue, chipTerm, normalizedChipDigits)) + { + return true; + } + } + + return false; + } + + private static bool HasChipContext(AuditLog log, List changes) + { + if ((log.EntityName ?? string.Empty).Equals(nameof(ChipVirgemLine), StringComparison.OrdinalIgnoreCase) || + (log.Page ?? string.Empty).Contains("chip", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + foreach (var change in changes) + { + var normalizedField = NormalizeField(change.Field); + if (normalizedField is "chip" or "iccid" or "numerodochip") + { + return true; + } + } + + return false; + } + private static bool MatchesTerm(string? source, string term, string digitsTerm) { if (string.IsNullOrWhiteSpace(source)) @@ -375,6 +535,21 @@ public class HistoricoController : ControllerBase return sourceDigits.Contains(digitsTerm, StringComparison.Ordinal); } + private static string NormalizeField(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return new string(value + .Normalize(NormalizationForm.FormD) + .Where(ch => CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark) + .Where(char.IsLetterOrDigit) + .Select(char.ToLowerInvariant) + .ToArray()); + } + private static string DigitsOnly(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index 23a00c7..9b5ce68 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -31,6 +31,7 @@ namespace line_gestao_api.Controllers private readonly IVigenciaNotificationSyncService _vigenciaNotificationSyncService; private readonly ParcelamentosImportService _parcelamentosImportService; private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService; + private readonly DashboardKpiSnapshotService _dashboardKpiSnapshotService; private readonly string _aparelhoAttachmentsRootPath; private static readonly FileExtensionContentTypeProvider FileContentTypeProvider = new(); @@ -71,6 +72,7 @@ namespace line_gestao_api.Controllers IVigenciaNotificationSyncService vigenciaNotificationSyncService, ParcelamentosImportService parcelamentosImportService, SpreadsheetImportAuditService spreadsheetImportAuditService, + DashboardKpiSnapshotService dashboardKpiSnapshotService, IWebHostEnvironment webHostEnvironment) { _db = db; @@ -78,6 +80,7 @@ namespace line_gestao_api.Controllers _vigenciaNotificationSyncService = vigenciaNotificationSyncService; _parcelamentosImportService = parcelamentosImportService; _spreadsheetImportAuditService = spreadsheetImportAuditService; + _dashboardKpiSnapshotService = dashboardKpiSnapshotService; _aparelhoAttachmentsRootPath = Path.Combine(webHostEnvironment.ContentRootPath, "uploads", "aparelhos"); } @@ -98,6 +101,7 @@ namespace line_gestao_api.Controllers [HttpGet("groups")] public async Task>> GetClientGroups( [FromQuery] string? skil, + [FromQuery] string? reservaMode, [FromQuery] string? search, [FromQuery] string? additionalMode, [FromQuery] string? additionalServices, @@ -117,10 +121,7 @@ namespace line_gestao_api.Controllers if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) { reservaFilter = true; - query = query.Where(x => - EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || - EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || - EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); + query = ApplyReservaContextFilter(query); } else query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); @@ -135,37 +136,7 @@ namespace line_gestao_api.Controllers if (reservaFilter) { - var userDataClientByLine = BuildUserDataClientByLineQuery(); - var userDataClientByItem = BuildUserDataClientByItemQuery(); - var reservaRows = - from line in query - join udLine in userDataClientByLine on line.Linha equals udLine.Linha into udLineJoin - from udLine in udLineJoin.DefaultIfEmpty() - join udItem in userDataClientByItem on line.Item equals udItem.Item into udItemJoin - from udItem in udItemJoin.DefaultIfEmpty() - let clienteOriginal = (line.Cliente ?? "").Trim() - let skilOriginal = (line.Skil ?? "").Trim() - let clientePorLinha = (udLine.Cliente ?? "").Trim() - let clientePorItem = (udItem.Cliente ?? "").Trim() - let reservaEstrita = EF.Functions.ILike(clienteOriginal, "RESERVA") && - EF.Functions.ILike(skilOriginal, "RESERVA") - let clienteEfetivo = reservaEstrita - ? "RESERVA" - : (!string.IsNullOrEmpty(clienteOriginal) && - !EF.Functions.ILike(clienteOriginal, "RESERVA")) - ? clienteOriginal - : (!string.IsNullOrEmpty(clientePorLinha) && - !EF.Functions.ILike(clientePorLinha, "RESERVA")) - ? clientePorLinha - : (!string.IsNullOrEmpty(clientePorItem) && - !EF.Functions.ILike(clientePorItem, "RESERVA")) - ? clientePorItem - : "" - select new - { - Cliente = string.IsNullOrEmpty(clienteEfetivo) ? "RESERVA" : clienteEfetivo, - line.Status - }; + var reservaRows = ApplyReservaMode(BuildReservaLineProjection(query), reservaMode); if (!string.IsNullOrWhiteSpace(search)) { @@ -211,7 +182,8 @@ namespace line_gestao_api.Controllers var orderedGroupedQuery = reservaFilter ? groupedQuery - .OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) + .OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "ESTOQUE")) + .ThenByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) .ThenBy(x => x.Cliente) : groupedQuery.OrderBy(x => x.Cliente); @@ -235,6 +207,7 @@ namespace line_gestao_api.Controllers [HttpGet("clients")] public async Task>> GetClients( [FromQuery] string? skil, + [FromQuery] string? reservaMode, [FromQuery] string? additionalMode, [FromQuery] string? additionalServices) { @@ -247,10 +220,7 @@ namespace line_gestao_api.Controllers if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) { reservaFilter = true; - query = query.Where(x => - EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || - EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || - EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); + query = ApplyReservaContextFilter(query); } else query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); @@ -264,37 +234,11 @@ namespace line_gestao_api.Controllers List clients; if (reservaFilter) { - var userDataClientByLine = BuildUserDataClientByLineQuery(); - var userDataClientByItem = BuildUserDataClientByItemQuery(); - clients = await ( - from line in query - join udLine in userDataClientByLine on line.Linha equals udLine.Linha into udLineJoin - from udLine in udLineJoin.DefaultIfEmpty() - join udItem in userDataClientByItem on line.Item equals udItem.Item into udItemJoin - from udItem in udItemJoin.DefaultIfEmpty() - let clienteOriginal = (line.Cliente ?? "").Trim() - let skilOriginal = (line.Skil ?? "").Trim() - let clientePorLinha = (udLine.Cliente ?? "").Trim() - let clientePorItem = (udItem.Cliente ?? "").Trim() - let reservaEstrita = EF.Functions.ILike(clienteOriginal, "RESERVA") && - EF.Functions.ILike(skilOriginal, "RESERVA") - let clienteEfetivo = reservaEstrita - ? "RESERVA" - : (!string.IsNullOrEmpty(clienteOriginal) && - !EF.Functions.ILike(clienteOriginal, "RESERVA")) - ? clienteOriginal - : (!string.IsNullOrEmpty(clientePorLinha) && - !EF.Functions.ILike(clientePorLinha, "RESERVA")) - ? clientePorLinha - : (!string.IsNullOrEmpty(clientePorItem) && - !EF.Functions.ILike(clientePorItem, "RESERVA")) - ? clientePorItem - : "" - let clienteExibicao = string.IsNullOrEmpty(clienteEfetivo) ? "RESERVA" : clienteEfetivo - select clienteExibicao - ) + clients = await ApplyReservaMode(BuildReservaLineProjection(query), reservaMode) + .Select(x => x.Cliente) .Distinct() - .OrderByDescending(x => EF.Functions.ILike((x ?? "").Trim(), "RESERVA")) + .OrderByDescending(x => EF.Functions.ILike((x ?? "").Trim(), "ESTOQUE")) + .ThenByDescending(x => EF.Functions.ILike((x ?? "").Trim(), "RESERVA")) .ThenBy(x => x) .ToListAsync(); } @@ -449,6 +393,7 @@ namespace line_gestao_api.Controllers public async Task>> GetAll( [FromQuery] string? search, [FromQuery] string? skil, + [FromQuery] string? reservaMode, [FromQuery] string? client, [FromQuery] string? operadora, [FromQuery] string? additionalMode, @@ -470,10 +415,7 @@ namespace line_gestao_api.Controllers if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) { reservaFilter = true; - q = q.Where(x => - EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || - EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || - EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); + q = ApplyReservaContextFilter(q); } else q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); @@ -493,61 +435,7 @@ namespace line_gestao_api.Controllers if (reservaFilter) { - var userDataClientByLine = BuildUserDataClientByLineQuery(); - var userDataClientByItem = BuildUserDataClientByItemQuery(); - var rq = - from line in q - join udLine in userDataClientByLine on line.Linha equals udLine.Linha into udLineJoin - from udLine in udLineJoin.DefaultIfEmpty() - join udItem in userDataClientByItem on line.Item equals udItem.Item into udItemJoin - from udItem in udItemJoin.DefaultIfEmpty() - let clienteOriginal = (line.Cliente ?? "").Trim() - let skilOriginal = (line.Skil ?? "").Trim() - let clientePorLinha = (udLine.Cliente ?? "").Trim() - let clientePorItem = (udItem.Cliente ?? "").Trim() - let reservaEstrita = EF.Functions.ILike(clienteOriginal, "RESERVA") && - EF.Functions.ILike(skilOriginal, "RESERVA") - let clienteEfetivo = reservaEstrita - ? "RESERVA" - : (!string.IsNullOrEmpty(clienteOriginal) && - !EF.Functions.ILike(clienteOriginal, "RESERVA")) - ? clienteOriginal - : (!string.IsNullOrEmpty(clientePorLinha) && - !EF.Functions.ILike(clientePorLinha, "RESERVA")) - ? clientePorLinha - : (!string.IsNullOrEmpty(clientePorItem) && - !EF.Functions.ILike(clientePorItem, "RESERVA")) - ? clientePorItem - : "" - let clienteExibicao = string.IsNullOrEmpty(clienteEfetivo) ? "RESERVA" : clienteEfetivo - select new - { - line.Id, - line.Item, - line.Conta, - line.Linha, - line.Chip, - Cliente = clienteExibicao, - line.Usuario, - line.CentroDeCustos, - SetorNome = line.Setor != null ? line.Setor!.Nome : null, - AparelhoNome = line.Aparelho != null ? line.Aparelho!.Nome : null, - AparelhoCor = line.Aparelho != null ? line.Aparelho!.Cor : null, - line.PlanoContrato, - line.Status, - line.Skil, - line.Modalidade, - line.VencConta, - line.FranquiaVivo, - line.FranquiaLine, - line.GestaoVozDados, - line.Skeelo, - line.VivoNewsPlus, - line.VivoTravelMundo, - line.VivoSync, - line.VivoGestaoDispositivo, - line.TipoDeChip - }; + var rq = ApplyReservaMode(BuildReservaLineProjection(q), reservaMode); if (!string.IsNullOrWhiteSpace(client)) rq = rq.Where(x => EF.Functions.ILike(x.Cliente ?? "", client.Trim())); @@ -572,10 +460,12 @@ namespace line_gestao_api.Controllers "linha" => desc ? rq.OrderByDescending(x => x.Linha ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Linha ?? "").ThenBy(x => x.Item), "chip" => desc ? rq.OrderByDescending(x => x.Chip ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Chip ?? "").ThenBy(x => x.Item), "cliente" => desc - ? rq.OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) + ? rq.OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "ESTOQUE")) + .ThenByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) .ThenByDescending(x => x.Cliente ?? "") .ThenBy(x => x.Item) - : rq.OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) + : rq.OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "ESTOQUE")) + .ThenByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) .ThenBy(x => x.Cliente ?? "") .ThenBy(x => x.Item), "usuario" => desc ? rq.OrderByDescending(x => x.Usuario ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Usuario ?? "").ThenBy(x => x.Item), @@ -1984,6 +1874,7 @@ namespace line_gestao_api.Controllers } await AddSpreadsheetImportHistoryAsync(file.FileName, imported, parcelamentosSummary); + await _dashboardKpiSnapshotService.CaptureAfterSpreadsheetImportAsync(User, HttpContext); await tx.CommitAsync(); return Ok(new ImportResultDto { Imported = imported, Parcelamentos = parcelamentosSummary }); } @@ -5443,6 +5334,36 @@ namespace line_gestao_api.Controllers public string Cliente { get; set; } = ""; } + private sealed class ReservaLineProjection + { + public Guid Id { get; set; } + public int Item { get; set; } + public string? Conta { get; set; } + public string? Linha { get; set; } + public string? Chip { get; set; } + public string Cliente { get; set; } = ""; + public string? Usuario { get; set; } + public string? CentroDeCustos { get; set; } + public string? SetorNome { get; set; } + public string? AparelhoNome { get; set; } + public string? AparelhoCor { get; set; } + public string? PlanoContrato { get; set; } + public string? Status { get; set; } + public string? Skil { get; set; } + public string? Modalidade { get; set; } + public string? VencConta { get; set; } + public decimal? FranquiaVivo { get; set; } + public decimal? FranquiaLine { get; set; } + public decimal? GestaoVozDados { get; set; } + public decimal? Skeelo { get; set; } + public decimal? VivoNewsPlus { get; set; } + public decimal? VivoTravelMundo { get; set; } + public decimal? VivoSync { get; set; } + public decimal? VivoGestaoDispositivo { get; set; } + public string? TipoDeChip { get; set; } + public bool IsStock { get; set; } + } + private IQueryable BuildUserDataClientByLineQuery() { return _db.UserDatas @@ -5473,6 +5394,98 @@ namespace line_gestao_api.Controllers }); } + private IQueryable BuildReservaLineProjection(IQueryable query) + { + var userDataClientByLine = BuildUserDataClientByLineQuery(); + var userDataClientByItem = BuildUserDataClientByItemQuery(); + + return + from line in query + join udLine in userDataClientByLine on line.Linha equals udLine.Linha into udLineJoin + from udLine in udLineJoin.DefaultIfEmpty() + join udItem in userDataClientByItem on line.Item equals udItem.Item into udItemJoin + from udItem in udItemJoin.DefaultIfEmpty() + let clienteOriginal = (line.Cliente ?? "").Trim() + let clientePorLinha = (udLine.Cliente ?? "").Trim() + let clientePorItem = (udItem.Cliente ?? "").Trim() + let isStock = EF.Functions.ILike(clienteOriginal, "RESERVA") + let clienteEfetivo = isStock + ? "ESTOQUE" + : (!string.IsNullOrEmpty(clienteOriginal) && + !EF.Functions.ILike(clienteOriginal, "RESERVA")) + ? clienteOriginal + : (!string.IsNullOrEmpty(clientePorLinha) && + !EF.Functions.ILike(clientePorLinha, "RESERVA")) + ? clientePorLinha + : (!string.IsNullOrEmpty(clientePorItem) && + !EF.Functions.ILike(clientePorItem, "RESERVA")) + ? clientePorItem + : "" + select new ReservaLineProjection + { + Id = line.Id, + Item = line.Item, + Conta = line.Conta, + Linha = line.Linha, + Chip = line.Chip, + Cliente = string.IsNullOrEmpty(clienteEfetivo) ? (isStock ? "ESTOQUE" : "RESERVA") : clienteEfetivo, + Usuario = line.Usuario, + CentroDeCustos = line.CentroDeCustos, + SetorNome = line.Setor != null ? line.Setor!.Nome : null, + AparelhoNome = line.Aparelho != null ? line.Aparelho!.Nome : null, + AparelhoCor = line.Aparelho != null ? line.Aparelho!.Cor : null, + PlanoContrato = line.PlanoContrato, + Status = line.Status, + Skil = line.Skil, + Modalidade = line.Modalidade, + VencConta = line.VencConta, + FranquiaVivo = line.FranquiaVivo, + FranquiaLine = line.FranquiaLine, + GestaoVozDados = line.GestaoVozDados, + Skeelo = line.Skeelo, + VivoNewsPlus = line.VivoNewsPlus, + VivoTravelMundo = line.VivoTravelMundo, + VivoSync = line.VivoSync, + VivoGestaoDispositivo = line.VivoGestaoDispositivo, + TipoDeChip = line.TipoDeChip, + IsStock = isStock + }; + } + + 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 ApplyReservaMode( + IQueryable query, + string? reservaMode) + { + var mode = NormalizeReservaMode(reservaMode); + return mode switch + { + "stock" => query.Where(x => x.IsStock), + "assigned" => query.Where(x => + !x.IsStock && + !EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")), + _ => query + }; + } + + private static string NormalizeReservaMode(string? reservaMode) + { + var token = (reservaMode ?? "").Trim().ToLowerInvariant(); + return token switch + { + "stock" or "estoque" => "stock", + "assigned" or "reservas" or "reserva" => "assigned", + _ => "all" + }; + } + private static IQueryable ExcludeReservaContext(IQueryable query) { return query.Where(x => diff --git a/Controllers/RelatoriosController.cs b/Controllers/RelatoriosController.cs index 3d916f2..c2e73e0 100644 --- a/Controllers/RelatoriosController.cs +++ b/Controllers/RelatoriosController.cs @@ -13,10 +13,14 @@ namespace line_gestao_api.Controllers public class RelatoriosController : ControllerBase { private readonly AppDbContext _db; + private readonly DashboardKpiSnapshotService _dashboardKpiSnapshotService; - public RelatoriosController(AppDbContext db) + public RelatoriosController( + AppDbContext db, + DashboardKpiSnapshotService dashboardKpiSnapshotService) { _db = db; + _dashboardKpiSnapshotService = dashboardKpiSnapshotService; } [HttpGet("dashboard")] @@ -226,6 +230,7 @@ namespace line_gestao_api.Controllers UserDataComCpf = userDataComCpf, UserDataComEmail = userDataComEmail }, + KpiTrends = await _dashboardKpiSnapshotService.GetTrendMapAsync(operadora), TopClientes = topClientes, SerieMuregUltimos12Meses = serieMureg12, SerieTrocaUltimos12Meses = serieTroca12, diff --git a/Dtos/RelatoriosDashboardDto.cs b/Dtos/RelatoriosDashboardDto.cs index 90a916d..437519f 100644 --- a/Dtos/RelatoriosDashboardDto.cs +++ b/Dtos/RelatoriosDashboardDto.cs @@ -6,6 +6,7 @@ namespace line_gestao_api.Dtos public class RelatoriosDashboardDto { public DashboardKpisDto Kpis { get; set; } = new(); + public Dictionary KpiTrends { get; set; } = new(); public List TopClientes { get; set; } = new(); public List SerieMuregUltimos12Meses { get; set; } = new(); public List SerieTrocaUltimos12Meses { get; set; } = new(); diff --git a/Program.cs b/Program.cs index 98f7b66..01903d1 100644 --- a/Program.cs +++ b/Program.cs @@ -97,6 +97,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Services/DashboardKpiSnapshotService.cs b/Services/DashboardKpiSnapshotService.cs new file mode 100644 index 0000000..3703e87 --- /dev/null +++ b/Services/DashboardKpiSnapshotService.cs @@ -0,0 +1,380 @@ +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); + } +} diff --git a/Services/MveAuditNormalization.cs b/Services/MveAuditNormalization.cs index 2aab672..2bb62ca 100644 --- a/Services/MveAuditNormalization.cs +++ b/Services/MveAuditNormalization.cs @@ -79,6 +79,34 @@ internal static class MveAuditNormalization return string.IsNullOrWhiteSpace(digits) ? null : digits; } + public static string NormalizePhoneDigits(string? value) + { + var digits = OnlyDigits(value); + if (string.IsNullOrWhiteSpace(digits)) + { + return string.Empty; + } + + if (digits.StartsWith("55", StringComparison.Ordinal) && digits.Length is 12 or 13) + { + digits = digits[2..]; + } + + return digits.Length > 11 ? digits[^11..] : digits; + } + + public static string ExtractPhoneDdd(string? value) + { + var digits = NormalizePhoneDigits(value); + return digits.Length >= 10 ? digits[..2] : string.Empty; + } + + public static string ExtractPhoneLocalNumber(string? value) + { + var digits = NormalizePhoneDigits(value); + return digits.Length >= 10 ? digits[2..] : digits; + } + public static string NormalizeComparableText(string? value) { var cleaned = CleanTextValue(value); @@ -207,6 +235,7 @@ internal static class MveAuditNormalization { var text when text.Contains("ATIVO", StringComparison.Ordinal) || text == "ATIVA" => "ATIVO", var text when text.Contains("BLOQUEIO PARCIAL", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO", + var text when text.Contains("PRE ATIVACAO", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO", var text when text.Contains("CANCEL", StringComparison.Ordinal) => "BLOQUEIO_120_DIAS", var text when text.Contains("SUSPENS", StringComparison.Ordinal) => "SUSPENSO", var text when text.Contains("PENDENTE", StringComparison.Ordinal) && @@ -250,6 +279,7 @@ internal static class MveAuditNormalization var text when text.Contains("ATIVO", StringComparison.Ordinal) || text == "ATIVA" => "ATIVO", var text when text.Contains("PERDA", StringComparison.Ordinal) || text.Contains("ROUBO", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO", var text when text.Contains("BLOQUEIO PARCIAL", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO", + var text when text.Contains("PRE ATIVACAO", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO", var text when text.Contains("BLOQUEIO", StringComparison.Ordinal) && text.Contains("120", StringComparison.Ordinal) => "BLOQUEIO_120_DIAS", var text when text.Contains("CANCEL", StringComparison.Ordinal) => "CANCELADO", var text when text.Contains("SUSPENS", StringComparison.Ordinal) => "SUSPENSO", diff --git a/Services/MveAuditService.cs b/Services/MveAuditService.cs index aa94125..feec32f 100644 --- a/Services/MveAuditService.cs +++ b/Services/MveAuditService.cs @@ -194,6 +194,64 @@ public sealed class MveAuditService .Where(x => lineIds.Contains(x.Id)) .ToDictionaryAsync(x => x.Id, cancellationToken); + var reportSnapshotsByIssueId = selectedIssues + .ToDictionary(x => x.Id, x => DeserializeSnapshot(x.ReportSnapshotJson)); + + var lineNumbers = linesById.Values + .Select(x => MveAuditNormalization.NullIfEmptyDigits(x.Linha)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Cast() + .Concat(reportSnapshotsByIssueId.Values + .Select(x => MveAuditNormalization.NullIfEmptyDigits(x?.NumeroLinha)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Cast()) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var items = linesById.Values + .Where(x => x.Item > 0) + .Select(x => x.Item) + .Distinct() + .ToList(); + + var vigencias = await _db.VigenciaLines + .Where(x => (x.Item > 0 && items.Contains(x.Item)) || (x.Linha != null && lineNumbers.Contains(x.Linha))) + .ToListAsync(cancellationToken); + + var userDatas = await _db.UserDatas + .Where(x => (x.Item > 0 && items.Contains(x.Item)) || (x.Linha != null && lineNumbers.Contains(x.Linha))) + .ToListAsync(cancellationToken); + + var vigenciaByLine = vigencias + .Where(x => !string.IsNullOrWhiteSpace(x.Linha)) + .GroupBy(x => x.Linha!, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First(), + StringComparer.Ordinal); + + var vigenciaByItem = vigencias + .Where(x => x.Item > 0) + .GroupBy(x => x.Item) + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First()); + + var userDataByLine = userDatas + .Where(x => !string.IsNullOrWhiteSpace(x.Linha)) + .GroupBy(x => x.Linha!, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First(), + StringComparer.Ordinal); + + var userDataByItem = userDatas + .Where(x => x.Item > 0) + .GroupBy(x => x.Item) + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First()); + var now = DateTime.UtcNow; var updatedLineIds = new HashSet(); var updatedFields = 0; @@ -210,7 +268,12 @@ public sealed class MveAuditService continue; } - var reportSnapshot = DeserializeSnapshot(issue.ReportSnapshotJson); + if (!reportSnapshotsByIssueId.TryGetValue(issue.Id, out var reportSnapshot)) + { + skippedIssues++; + continue; + } + if (reportSnapshot == null) { skippedIssues++; @@ -220,7 +283,69 @@ public sealed class MveAuditService var differences = DeserializeDifferences(issue.DifferencesJson); var lineChanged = false; - foreach (var difference in differences.Where(x => x.Syncable && x.FieldKey == "status")) + var hasLineDifference = differences.Any(x => x.Syncable && x.FieldKey == "line"); + var hasChipDifference = differences.Any(x => x.Syncable && x.FieldKey == "chip"); + var hasStatusDifference = differences.Any(x => x.Syncable && x.FieldKey == "status"); + + var previousLine = MveAuditNormalization.NullIfEmptyDigits(line.Linha); + var nextLine = hasLineDifference ? MveAuditNormalization.NullIfEmptyDigits(reportSnapshot.NumeroLinha) : previousLine; + if (hasLineDifference) + { + if (string.IsNullOrWhiteSpace(nextLine)) + { + skippedIssues++; + continue; + } + + var hasConflict = await _db.MobileLines + .AsNoTracking() + .AnyAsync( + x => x.TenantId == line.TenantId && + x.Id != line.Id && + x.Linha == nextLine, + cancellationToken); + + if (hasConflict) + { + skippedIssues++; + continue; + } + } + + if (hasLineDifference && SetString(line.Linha, nextLine, value => line.Linha = value)) + { + lineChanged = true; + updatedFields++; + + SyncLinkedLineRecords( + line, + previousLine, + nextLine, + vigenciaByLine, + vigenciaByItem, + userDataByLine, + userDataByItem, + now); + + AddTrocaNumeroHistory( + line, + previousLine, + nextLine, + MveAuditNormalization.NullIfEmptyDigits(reportSnapshot.Chip) ?? MveAuditNormalization.NullIfEmptyDigits(line.Chip), + now); + } + + if (hasChipDifference) + { + var nextChip = MveAuditNormalization.NullIfEmptyDigits(reportSnapshot.Chip); + if (SetString(line.Chip, nextChip, value => line.Chip = value)) + { + lineChanged = true; + updatedFields++; + } + } + + if (hasStatusDifference) { var systemStatus = MveAuditNormalization.NormalizeStatusForSystem(reportSnapshot.StatusLinha); if (SetString(line.Status, systemStatus, value => line.Status = value)) @@ -441,9 +566,89 @@ public sealed class MveAuditService } } + private void SyncLinkedLineRecords( + MobileLine line, + string? previousLine, + string? nextLine, + IDictionary vigenciaByLine, + IDictionary vigenciaByItem, + IDictionary userDataByLine, + IDictionary userDataByItem, + DateTime now) + { + if (string.IsNullOrWhiteSpace(nextLine)) + { + return; + } + + var lookupLine = !string.IsNullOrWhiteSpace(previousLine) ? previousLine : nextLine; + + var vigencia = ResolveVigencia(line, lookupLine, vigenciaByLine, vigenciaByItem); + if (vigencia != null && SetString(vigencia.Linha, nextLine, value => vigencia.Linha = value)) + { + vigencia.UpdatedAt = now; + RefreshLineLookup(vigenciaByLine, previousLine, nextLine, vigencia); + } + + var userData = ResolveUserData(line, lookupLine, userDataByLine, userDataByItem); + if (userData != null && SetString(userData.Linha, nextLine, value => userData.Linha = value)) + { + userData.UpdatedAt = now; + RefreshLineLookup(userDataByLine, previousLine, nextLine, userData); + } + } + + private void AddTrocaNumeroHistory( + MobileLine line, + string? previousLine, + string? nextLine, + string? chip, + DateTime now) + { + if (string.IsNullOrWhiteSpace(previousLine) || + string.IsNullOrWhiteSpace(nextLine) || + string.Equals(previousLine, nextLine, StringComparison.Ordinal)) + { + return; + } + + _db.TrocaNumeroLines.Add(new TrocaNumeroLine + { + Id = Guid.NewGuid(), + TenantId = line.TenantId, + Item = line.Item, + LinhaAntiga = previousLine, + LinhaNova = nextLine, + ICCID = MveAuditNormalization.NullIfEmptyDigits(chip), + DataTroca = now, + Motivo = "Auditoria MVE", + Observacao = "Linha atualizada automaticamente a partir do relatório MVE.", + CreatedAt = now, + UpdatedAt = now + }); + } + + private static void RefreshLineLookup( + IDictionary lookup, + string? previousLine, + string? nextLine, + T entity) + where T : class + { + if (!string.IsNullOrWhiteSpace(previousLine)) + { + lookup.Remove(previousLine); + } + + if (!string.IsNullOrWhiteSpace(nextLine)) + { + lookup[nextLine] = entity; + } + } + private VigenciaLine? ResolveVigencia( MobileLine line, - string numeroLinha, + string? numeroLinha, IDictionary vigenciaByLine, IDictionary vigenciaByItem) { @@ -462,7 +667,7 @@ public sealed class MveAuditService private UserData? ResolveUserData( MobileLine line, - string numeroLinha, + string? numeroLinha, IDictionary userDataByLine, IDictionary userDataByItem) { diff --git a/Services/MveReconciliationService.cs b/Services/MveReconciliationService.cs index c2961f0..efb0180 100644 --- a/Services/MveReconciliationService.cs +++ b/Services/MveReconciliationService.cs @@ -119,8 +119,10 @@ public sealed class MveReconciliationService }); } - var blockedKeys = new HashSet(duplicateReportKeys, StringComparer.Ordinal); - blockedKeys.UnionWith(duplicateSystemKeys); + var blockedSystemKeys = new HashSet(duplicateSystemKeys, StringComparer.Ordinal); + var blockedReportKeys = new HashSet(duplicateReportKeys, StringComparer.Ordinal); + var matchedSystemLineIds = new HashSet(); + var matchedReportRows = new HashSet(); var allKeys = reportByNumber.Keys .Concat(systemByNumber.Keys) @@ -131,7 +133,7 @@ public sealed class MveReconciliationService foreach (var key in allKeys) { - if (blockedKeys.Contains(key)) + if (blockedSystemKeys.Contains(key) || blockedReportKeys.Contains(key)) { continue; } @@ -141,74 +143,102 @@ public sealed class MveReconciliationService var reportLine = hasReport ? reportLines![0] : null; var systemLine = hasSystem ? systemLines![0] : null; - if (reportLine == null && systemLine != null) - { - result.TotalOnlyInSystem++; - result.Issues.Add(new MveReconciliationIssueResult - { - NumeroLinha = key, - MobileLineId = systemLine.MobileLine.Id, - SystemItem = systemLine.MobileLine.Item, - IssueType = "ONLY_IN_SYSTEM", - Situation = "ausente no relatório", - Severity = "WARNING", - Syncable = false, - ActionSuggestion = "Validar com a Vivo antes de alterar o cadastro", - Notes = "A linha existe no sistema, mas não foi encontrada no relatório MVE.", - SystemStatus = systemLine.MobileLine.Status, - SystemPlan = systemLine.MobileLine.PlanoContrato, - SystemSnapshot = BuildSystemSnapshot(systemLine) - }); - continue; - } - - if (reportLine != null && systemLine == null) - { - result.TotalOnlyInReport++; - result.Issues.Add(new MveReconciliationIssueResult - { - SourceRowNumber = reportLine.SourceRowNumber, - NumeroLinha = key, - IssueType = "ONLY_IN_REPORT", - Situation = "ausente no sistema", - Severity = "WARNING", - Syncable = false, - ActionSuggestion = "Avaliar cadastro manual dessa linha", - Notes = "A linha existe no relatório MVE, mas não foi encontrada na página Geral.", - ReportStatus = reportLine.StatusLinha, - ReportPlan = reportLine.PlanoLinha, - ReportSnapshot = BuildReportSnapshot(reportLine) - }); - continue; - } - if (reportLine == null || systemLine == null) { continue; } + matchedSystemLineIds.Add(systemLine.MobileLine.Id); + matchedReportRows.Add(reportLine.SourceRowNumber); + var comparison = CompareMatchedLine(systemLine, reportLine); - if (comparison == null) + RegisterComparisonResult(result, comparison); + } + + var unmatchedSystemLines = systemAggregates + .Where(x => !blockedSystemKeys.Contains(x.NumeroNormalizado)) + .Where(x => !matchedSystemLineIds.Contains(x.MobileLine.Id)) + .ToList(); + + var unmatchedReportLines = parsedFile.Lines + .Where(x => !blockedReportKeys.Contains(x.NumeroNormalizado)) + .Where(x => !matchedReportRows.Contains(x.SourceRowNumber)) + .ToList(); + + var chipMatchedSystemLineIds = new HashSet(); + var chipMatchedReportRows = new HashSet(); + + var systemByChip = unmatchedSystemLines + .Where(x => !string.IsNullOrWhiteSpace(MveAuditNormalization.NullIfEmptyDigits(x.MobileLine.Chip))) + .GroupBy(x => MveAuditNormalization.OnlyDigits(x.MobileLine.Chip), StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + + var reportByChip = unmatchedReportLines + .Where(x => !string.IsNullOrWhiteSpace(MveAuditNormalization.NullIfEmptyDigits(x.Chip))) + .GroupBy(x => MveAuditNormalization.OnlyDigits(x.Chip), StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + + var chipKeys = systemByChip.Keys + .Intersect(reportByChip.Keys, StringComparer.Ordinal) + .OrderBy(key => key, StringComparer.Ordinal) + .ToList(); + + foreach (var chipKey in chipKeys) + { + var systemCandidates = systemByChip[chipKey]; + var reportCandidates = reportByChip[chipKey]; + if (systemCandidates.Count != 1 || reportCandidates.Count != 1) { - result.TotalConciliated++; continue; } - result.Issues.Add(comparison); - if (comparison.Differences.Any(x => x.FieldKey == "status")) - { - result.TotalStatusDivergences++; - } + var systemLine = systemCandidates[0]; + var reportLine = reportCandidates[0]; - if (comparison.Differences.Any(x => x.FieldKey != "status" && x.Syncable)) - { - result.TotalDataDivergences++; - } + chipMatchedSystemLineIds.Add(systemLine.MobileLine.Id); + chipMatchedReportRows.Add(reportLine.SourceRowNumber); - if (comparison.Syncable) + var comparison = CompareMatchedByChip(systemLine, reportLine); + RegisterComparisonResult(result, comparison); + } + + foreach (var systemLine in unmatchedSystemLines.Where(x => !chipMatchedSystemLineIds.Contains(x.MobileLine.Id))) + { + result.TotalOnlyInSystem++; + result.Issues.Add(new MveReconciliationIssueResult { - result.TotalSyncableIssues++; - } + NumeroLinha = systemLine.NumeroNormalizado, + MobileLineId = systemLine.MobileLine.Id, + SystemItem = systemLine.MobileLine.Item, + IssueType = "ONLY_IN_SYSTEM", + Situation = "ausente no relatório", + Severity = "WARNING", + Syncable = false, + ActionSuggestion = "Validar com a Vivo antes de alterar o cadastro", + Notes = "A linha existe no sistema, mas não foi encontrada no relatório MVE.", + SystemStatus = systemLine.MobileLine.Status, + SystemPlan = systemLine.MobileLine.PlanoContrato, + SystemSnapshot = BuildSystemSnapshot(systemLine) + }); + } + + foreach (var reportLine in unmatchedReportLines.Where(x => !chipMatchedReportRows.Contains(x.SourceRowNumber))) + { + result.TotalOnlyInReport++; + result.Issues.Add(new MveReconciliationIssueResult + { + SourceRowNumber = reportLine.SourceRowNumber, + NumeroLinha = reportLine.NumeroNormalizado, + IssueType = "ONLY_IN_REPORT", + Situation = "ausente no sistema", + Severity = "WARNING", + Syncable = false, + ActionSuggestion = "Avaliar cadastro manual dessa linha", + Notes = "A linha existe no relatório MVE, mas não foi encontrada na página Geral.", + ReportStatus = reportLine.StatusLinha, + ReportPlan = reportLine.PlanoLinha, + ReportSnapshot = BuildReportSnapshot(reportLine) + }); } return result; @@ -291,12 +321,104 @@ public sealed class MveReconciliationService }); } + AddDifference( + differences, + "chip", + "Chip da linha", + systemSnapshot.Chip, + reportSnapshot.Chip, + syncable: true, + comparer: MveAuditNormalization.OnlyDigits); + var hasUnknownStatus = !reportLine.StatusLinhaRecognized; if (differences.Count == 0 && !hasUnknownStatus) { return null; } + return BuildIssue(systemLine, reportLine, systemSnapshot, reportSnapshot, differences, hasUnknownStatus); + } + + private static MveReconciliationIssueResult? CompareMatchedByChip( + MveSystemLineAggregate systemLine, + MveParsedLine reportLine) + { + var systemSnapshot = BuildSystemSnapshot(systemLine); + var reportSnapshot = BuildReportSnapshot(reportLine); + var systemLocalNumber = MveAuditNormalization.ExtractPhoneLocalNumber(systemSnapshot.NumeroLinha); + var reportLocalNumber = MveAuditNormalization.ExtractPhoneLocalNumber(reportSnapshot.NumeroLinha); + + if (!string.IsNullOrWhiteSpace(systemLocalNumber) && + string.Equals(systemLocalNumber, reportLocalNumber, StringComparison.Ordinal)) + { + return BuildDddReviewIssue(systemLine, reportLine, systemSnapshot, reportSnapshot); + } + + var differences = new List(); + AddDifference( + differences, + "line", + "Número da linha", + systemSnapshot.NumeroLinha, + reportSnapshot.NumeroLinha, + syncable: true, + comparer: MveAuditNormalization.OnlyDigits); + + var systemStatus = MveAuditNormalization.NormalizeSystemStatus(systemSnapshot.StatusLinha); + if (!string.Equals(systemStatus.Key, reportLine.StatusLinhaKey, StringComparison.Ordinal)) + { + differences.Add(new MveAuditDifferenceDto + { + FieldKey = "status", + Label = "Status da linha", + SystemValue = NullIfEmpty(systemSnapshot.StatusLinha), + ReportValue = NullIfEmpty(reportSnapshot.StatusLinha), + Syncable = true + }); + } + + var hasUnknownStatus = !reportLine.StatusLinhaRecognized; + if (differences.Count == 0 && !hasUnknownStatus) + { + return null; + } + + return BuildIssue(systemLine, reportLine, systemSnapshot, reportSnapshot, differences, hasUnknownStatus); + } + + private static void RegisterComparisonResult(MveReconciliationResult result, MveReconciliationIssueResult? comparison) + { + if (comparison == null) + { + result.TotalConciliated++; + return; + } + + result.Issues.Add(comparison); + if (comparison.Differences.Any(x => x.FieldKey == "status")) + { + result.TotalStatusDivergences++; + } + + if (comparison.Differences.Any(x => x.FieldKey != "status" && x.Syncable)) + { + result.TotalDataDivergences++; + } + + if (comparison.Syncable) + { + result.TotalSyncableIssues++; + } + } + + private static MveReconciliationIssueResult BuildIssue( + MveSystemLineAggregate systemLine, + MveParsedLine reportLine, + MveAuditSnapshotDto systemSnapshot, + MveAuditSnapshotDto reportSnapshot, + List differences, + bool hasUnknownStatus) + { var notes = new List(); if (hasUnknownStatus) { @@ -304,8 +426,9 @@ public sealed class MveReconciliationService } var hasStatusDifference = differences.Any(x => x.FieldKey == "status"); - var hasDataDifference = false; - var issueType = hasStatusDifference ? "STATUS_DIVERGENCE" : "UNKNOWN_STATUS"; + var hasLineDifference = differences.Any(x => x.FieldKey == "line"); + var hasChipDifference = differences.Any(x => x.FieldKey == "chip"); + var hasDataDifference = differences.Any(x => x.FieldKey != "status" && x.Syncable); return new MveReconciliationIssueResult { @@ -313,11 +436,11 @@ public sealed class MveReconciliationService NumeroLinha = reportLine.NumeroNormalizado, MobileLineId = systemLine.MobileLine.Id, SystemItem = systemLine.MobileLine.Item, - IssueType = issueType, - Situation = ResolveSituation(hasStatusDifference, hasDataDifference, hasUnknownStatus), + IssueType = ResolveIssueType(hasStatusDifference, hasLineDifference, hasChipDifference, hasUnknownStatus), + Situation = ResolveSituation(hasStatusDifference, hasLineDifference, hasChipDifference, hasUnknownStatus), Severity = ResolveSeverity(hasStatusDifference, hasDataDifference, hasUnknownStatus), Syncable = differences.Any(x => x.Syncable), - ActionSuggestion = ResolveActionSuggestion(hasStatusDifference, hasDataDifference, hasUnknownStatus), + ActionSuggestion = ResolveActionSuggestion(hasStatusDifference, hasLineDifference, hasChipDifference, hasUnknownStatus), Notes = notes.Count == 0 ? null : string.Join(" ", notes), SystemStatus = systemSnapshot.StatusLinha, ReportStatus = reportSnapshot.StatusLinha, @@ -329,11 +452,92 @@ public sealed class MveReconciliationService }; } - private static string ResolveSituation(bool hasStatusDifference, bool hasDataDifference, bool hasUnknownStatus) + private static MveReconciliationIssueResult BuildDddReviewIssue( + MveSystemLineAggregate systemLine, + MveParsedLine reportLine, + MveAuditSnapshotDto systemSnapshot, + MveAuditSnapshotDto reportSnapshot) { - if (hasStatusDifference && hasDataDifference) + return new MveReconciliationIssueResult { - return "divergência de status e cadastro"; + SourceRowNumber = reportLine.SourceRowNumber, + NumeroLinha = reportLine.NumeroNormalizado, + MobileLineId = systemLine.MobileLine.Id, + SystemItem = systemLine.MobileLine.Item, + IssueType = "DDD_CHANGE_REVIEW", + Situation = "mudança de DDD detectada", + Severity = "WARNING", + Syncable = false, + ActionSuggestion = "Revisar manualmente na página Mureg antes de aplicar alterações", + Notes = "O mesmo chip foi encontrado com o mesmo número base, mas com DDD diferente. Esse cenário ainda não é atualizado automaticamente pelo MVE.", + SystemStatus = systemSnapshot.StatusLinha, + ReportStatus = reportSnapshot.StatusLinha, + SystemPlan = systemSnapshot.PlanoLinha, + ReportPlan = reportSnapshot.PlanoLinha, + SystemSnapshot = systemSnapshot, + ReportSnapshot = reportSnapshot, + Differences = new List + { + new() + { + FieldKey = "ddd", + Label = "DDD da linha", + SystemValue = NullIfEmpty(MveAuditNormalization.ExtractPhoneDdd(systemSnapshot.NumeroLinha)), + ReportValue = NullIfEmpty(MveAuditNormalization.ExtractPhoneDdd(reportSnapshot.NumeroLinha)), + Syncable = false + } + } + }; + } + + private static string ResolveIssueType( + bool hasStatusDifference, + bool hasLineDifference, + bool hasChipDifference, + bool hasUnknownStatus) + { + if (hasLineDifference) + { + return "LINE_CHANGE_DETECTED"; + } + + if (hasChipDifference) + { + return "CHIP_CHANGE_DETECTED"; + } + + if (hasStatusDifference) + { + return "STATUS_DIVERGENCE"; + } + + return hasUnknownStatus ? "UNKNOWN_STATUS" : "ALIGNED"; + } + + private static string ResolveSituation( + bool hasStatusDifference, + bool hasLineDifference, + bool hasChipDifference, + bool hasUnknownStatus) + { + if (hasLineDifference && hasStatusDifference) + { + return "troca de número e status diferente"; + } + + if (hasLineDifference) + { + return "troca de número detectada"; + } + + if (hasChipDifference && hasStatusDifference) + { + return "troca de chip e status diferente"; + } + + if (hasChipDifference) + { + return "troca de chip detectada"; } if (hasStatusDifference) @@ -341,11 +545,6 @@ public sealed class MveReconciliationService return "divergência de status"; } - if (hasDataDifference) - { - return "divergência de cadastro"; - } - return hasUnknownStatus ? "status desconhecido no relatório" : "alinhada"; } @@ -364,8 +563,32 @@ public sealed class MveReconciliationService return hasUnknownStatus ? "WARNING" : "INFO"; } - private static string ResolveActionSuggestion(bool hasStatusDifference, bool hasDataDifference, bool hasUnknownStatus) + private static string ResolveActionSuggestion( + bool hasStatusDifference, + bool hasLineDifference, + bool hasChipDifference, + bool hasUnknownStatus) { + if (hasLineDifference && hasStatusDifference) + { + return "Atualizar linha e status da linha com base no MVE"; + } + + if (hasLineDifference) + { + return "Atualizar a linha cadastrada com base no chip informado no MVE"; + } + + if (hasChipDifference && hasStatusDifference) + { + return "Atualizar chip e status da linha com base no MVE"; + } + + if (hasChipDifference) + { + return "Atualizar o chip da linha com base no MVE"; + } + if (hasStatusDifference) { return "Atualizar status da linha com base no MVE"; diff --git a/uploads/aparelhos/7d8ded83ce6a4055870b3ad02424af64/5e4a0a51602d4f51bf95d039e17d45e7/nota-fiscal/20260309210946974_f431bd2599cc41daa2061b0e47328716.pdf b/uploads/aparelhos/7d8ded83ce6a4055870b3ad02424af64/5e4a0a51602d4f51bf95d039e17d45e7/nota-fiscal/20260309210946974_f431bd2599cc41daa2061b0e47328716.pdf new file mode 100644 index 0000000..b44ab61 Binary files /dev/null and b/uploads/aparelhos/7d8ded83ce6a4055870b3ad02424af64/5e4a0a51602d4f51bf95d039e17d45e7/nota-fiscal/20260309210946974_f431bd2599cc41daa2061b0e47328716.pdf differ diff --git a/uploads/aparelhos/7d8ded83ce6a4055870b3ad02424af64/5e4a0a51602d4f51bf95d039e17d45e7/recibo/20260309210946983_757eaa924e2e472ab530b15dc7685360.pdf b/uploads/aparelhos/7d8ded83ce6a4055870b3ad02424af64/5e4a0a51602d4f51bf95d039e17d45e7/recibo/20260309210946983_757eaa924e2e472ab530b15dc7685360.pdf new file mode 100644 index 0000000..c507e9e Binary files /dev/null and b/uploads/aparelhos/7d8ded83ce6a4055870b3ad02424af64/5e4a0a51602d4f51bf95d039e17d45e7/recibo/20260309210946983_757eaa924e2e472ab530b15dc7685360.pdf differ