From 2f85a40fdb9a01f446a9d9e0a09ee97473be4261 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:10:33 -0300 Subject: [PATCH 1/6] Add geral dashboard insights endpoint --- Controllers/DashboardGeralController.cs | 25 + Dtos/GeralDashboardInsightsDto.cs | 93 ++ Program.cs | 1 + Services/GeralDashboardInsightsService.cs | 917 ++++++++++++++++++ .../GeralDashboardInsightsServiceTests.cs | 116 +++ .../line-gestao-api.Tests.csproj | 24 + 6 files changed, 1176 insertions(+) create mode 100644 Controllers/DashboardGeralController.cs create mode 100644 Dtos/GeralDashboardInsightsDto.cs create mode 100644 Services/GeralDashboardInsightsService.cs create mode 100644 line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs create mode 100644 line-gestao-api.Tests/line-gestao-api.Tests.csproj diff --git a/Controllers/DashboardGeralController.cs b/Controllers/DashboardGeralController.cs new file mode 100644 index 0000000..6c70287 --- /dev/null +++ b/Controllers/DashboardGeralController.cs @@ -0,0 +1,25 @@ +using line_gestao_api.Dtos; +using line_gestao_api.Services; +using Microsoft.AspNetCore.Mvc; + +namespace line_gestao_api.Controllers +{ + [ApiController] + [Route("api/dashboard/geral")] + public class DashboardGeralController : ControllerBase + { + private readonly GeralDashboardInsightsService _service; + + public DashboardGeralController(GeralDashboardInsightsService service) + { + _service = service; + } + + [HttpGet("insights")] + public async Task> GetInsights() + { + var dto = await _service.GetInsightsAsync(); + return Ok(dto); + } + } +} diff --git a/Dtos/GeralDashboardInsightsDto.cs b/Dtos/GeralDashboardInsightsDto.cs new file mode 100644 index 0000000..f700225 --- /dev/null +++ b/Dtos/GeralDashboardInsightsDto.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; + +namespace line_gestao_api.Dtos +{ + public class GeralDashboardInsightsDto + { + public GeralDashboardKpisDto Kpis { get; set; } = new(); + public GeralDashboardChartsDto Charts { get; set; } = new(); + public List ClientGroups { get; set; } = new(); + } + + public class GeralDashboardKpisDto + { + public int TotalLinhas { get; set; } + public int TotalAtivas { get; set; } + public GeralDashboardVivoKpiDto Vivo { get; set; } = new(); + public GeralDashboardTravelKpiDto TravelMundo { get; set; } = new(); + public GeralDashboardAdditionalKpiDto Adicionais { get; set; } = new(); + } + + public class GeralDashboardVivoKpiDto + { + public int QtdLinhas { get; set; } + public decimal TotalBaseMensal { get; set; } + public decimal TotalAdicionaisMensal { get; set; } + public decimal TotalGeralMensal { get; set; } + public decimal MediaPorLinha { get; set; } + public decimal? MinPorLinha { get; set; } + public decimal? MaxPorLinha { get; set; } + } + + public class GeralDashboardTravelKpiDto + { + public int ComTravel { get; set; } + public int SemTravel { get; set; } + public decimal TotalValue { get; set; } + } + + public class GeralDashboardAdditionalKpiDto + { + public int TotalLinesWithAnyPaidAdditional { get; set; } + public int TotalLinesWithNoPaidAdditional { get; set; } + public List ServicesPaid { get; set; } = new(); + public List ServicesNotPaid { get; set; } = new(); + } + + public class GeralDashboardServiceKpiDto + { + public string ServiceName { get; set; } = string.Empty; + public int CountLines { get; set; } + public decimal TotalValue { get; set; } + } + + public class GeralDashboardChartsDto + { + public GeralDashboardChartDto LinhasPorFranquia { get; set; } = new(); + public GeralDashboardChartDto AdicionaisPagosPorServico { get; set; } = new(); + public GeralDashboardChartDto TravelMundo { get; set; } = new(); + } + + public class GeralDashboardChartDto + { + public List Labels { get; set; } = new(); + public List Values { get; set; } = new(); + public List? Totals { get; set; } + } + + public class GeralDashboardClientGroupDto + { + public string Cliente { get; set; } = string.Empty; + public int TotalLinhas { get; set; } + public int Ativos { get; set; } + public int Bloqueados { get; set; } + public int LinhasVivo { get; set; } + public List TagsBase { get; set; } = new(); + public List TagsExtras { get; set; } = new(); + public List AdicionaisPorServico { get; set; } = new(); + } + + public class GeralDashboardClientServiceDto + { + public string ServiceName { get; set; } = string.Empty; + public int PaidCount { get; set; } + public int NotPaidCount { get; set; } + public decimal TotalValue { get; set; } + } + + public class GeralDashboardTagDto + { + public string Label { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + } +} diff --git a/Program.cs b/Program.cs index 58b50b5..31f68dd 100644 --- a/Program.cs +++ b/Program.cs @@ -36,6 +36,7 @@ builder.Services.AddDbContext(options => builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddIdentityCore(options => { diff --git a/Services/GeralDashboardInsightsService.cs b/Services/GeralDashboardInsightsService.cs new file mode 100644 index 0000000..5ccfbe7 --- /dev/null +++ b/Services/GeralDashboardInsightsService.cs @@ -0,0 +1,917 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using line_gestao_api.Data; +using line_gestao_api.Dtos; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Services +{ + public class GeralDashboardInsightsService + { + private static readonly CultureInfo PtBr = new("pt-BR"); + + private const string ServiceGestaoVozDados = "GESTÃO VOZ E DADOS"; + private const string ServiceSkeelo = "SKEELO"; + private const string ServiceVivoNewsPlus = "VIVO NEWS PLUS"; + private const string ServiceVivoTravelMundo = "VIVO TRAVEL MUNDO"; + private const string ServiceVivoGestaoDispositivo = "VIVO GESTÃO DISPOSITIVO"; + + private readonly AppDbContext _db; + + public GeralDashboardInsightsService(AppDbContext db) + { + _db = db; + } + + public async Task GetInsightsAsync() + { + var qLines = _db.MobileLines.AsNoTracking(); + + var totals = await qLines + .GroupBy(_ => 1) + .Select(g => new TotalsProjection + { + TotalLinhas = g.Count(), + TotalAtivas = g.Count(x => (x.Status ?? "").ToLower().Contains("ativo")), + TotalBloqueados = g.Count(x => + (x.Status ?? "").ToLower().Contains("bloque") || + (x.Status ?? "").ToLower().Contains("perda") || + (x.Status ?? "").ToLower().Contains("roubo")), + VivoLinhas = g.Count(x => + x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null), + VivoBaseTotal = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.ValorPlanoVivo ?? 0m) + : 0m), + VivoAdicionaisTotal = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.GestaoVozDados ?? 0m) + (x.Skeelo ?? 0m) + (x.VivoNewsPlus ?? 0m) + + (x.VivoTravelMundo ?? 0m) + (x.VivoGestaoDispositivo ?? 0m) + : 0m), + VivoMinBase = g.Where(x => + x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + .Select(x => x.ValorPlanoVivo ?? 0m) + .DefaultIfEmpty() + .Min(), + VivoMaxBase = g.Where(x => + x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + .Select(x => x.ValorPlanoVivo ?? 0m) + .DefaultIfEmpty() + .Max(), + TravelCom = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + x.VivoTravelMundo != null), + TravelSem = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + x.VivoTravelMundo == null), + TravelTotal = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.VivoTravelMundo ?? 0m) + : 0m), + PaidGestaoVozDados = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.GestaoVozDados ?? 0m) > 0m), + PaidSkeelo = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.Skeelo ?? 0m) > 0m), + PaidNews = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoNewsPlus ?? 0m) > 0m), + PaidTravel = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoTravelMundo ?? 0m) > 0m), + PaidGestaoDispositivo = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoGestaoDispositivo ?? 0m) > 0m), + NotPaidGestaoVozDados = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.GestaoVozDados ?? 0m) <= 0m), + NotPaidSkeelo = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.Skeelo ?? 0m) <= 0m), + NotPaidNews = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoNewsPlus ?? 0m) <= 0m), + NotPaidTravel = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoTravelMundo ?? 0m) <= 0m), + NotPaidGestaoDispositivo = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoGestaoDispositivo ?? 0m) <= 0m), + TotalLinesWithAnyPaidAdditional = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + ((x.GestaoVozDados ?? 0m) > 0m || + (x.Skeelo ?? 0m) > 0m || + (x.VivoNewsPlus ?? 0m) > 0m || + (x.VivoTravelMundo ?? 0m) > 0m || + (x.VivoGestaoDispositivo ?? 0m) > 0m)), + TotalLinesWithNoPaidAdditional = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.GestaoVozDados ?? 0m) <= 0m && + (x.Skeelo ?? 0m) <= 0m && + (x.VivoNewsPlus ?? 0m) <= 0m && + (x.VivoTravelMundo ?? 0m) <= 0m && + (x.VivoGestaoDispositivo ?? 0m) <= 0m), + TotalGestaoVozDados = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.GestaoVozDados ?? 0m) + : 0m), + TotalSkeelo = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.Skeelo ?? 0m) + : 0m), + TotalNews = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.VivoNewsPlus ?? 0m) + : 0m), + TotalTravel = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.VivoTravelMundo ?? 0m) + : 0m), + TotalGestaoDispositivo = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.VivoGestaoDispositivo ?? 0m) + : 0m) + }) + .FirstOrDefaultAsync(); + + var franquiasRaw = await qLines + .Select(x => new FranquiaProjection { FranquiaVivo = x.FranquiaVivo, PlanoContrato = x.PlanoContrato }) + .ToListAsync(); + + var linhasPorFranquia = BuildFranquiaBuckets(franquiasRaw); + + var clientGroupsRaw = await qLines + .Where(x => x.Cliente != null && x.Cliente != "") + .GroupBy(x => x.Cliente!) + .Select(g => new ClientGroupProjection + { + Cliente = g.Key, + TotalLinhas = g.Count(), + Ativos = g.Count(x => (x.Status ?? "").ToLower().Contains("ativo")), + Bloqueados = g.Count(x => + (x.Status ?? "").ToLower().Contains("bloque") || + (x.Status ?? "").ToLower().Contains("perda") || + (x.Status ?? "").ToLower().Contains("roubo")), + LinhasVivo = g.Count(x => + x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null), + TravelCom = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + x.VivoTravelMundo != null), + TravelSem = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + x.VivoTravelMundo == null), + PaidGestaoVozDados = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.GestaoVozDados ?? 0m) > 0m), + PaidSkeelo = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.Skeelo ?? 0m) > 0m), + PaidNews = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoNewsPlus ?? 0m) > 0m), + PaidTravel = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoTravelMundo ?? 0m) > 0m), + PaidGestaoDispositivo = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoGestaoDispositivo ?? 0m) > 0m), + NotPaidGestaoVozDados = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.GestaoVozDados ?? 0m) <= 0m), + NotPaidSkeelo = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.Skeelo ?? 0m) <= 0m), + NotPaidNews = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoNewsPlus ?? 0m) <= 0m), + NotPaidTravel = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoTravelMundo ?? 0m) <= 0m), + NotPaidGestaoDispositivo = g.Count(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) && + (x.VivoGestaoDispositivo ?? 0m) <= 0m), + TotalGestaoVozDados = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.GestaoVozDados ?? 0m) + : 0m), + TotalSkeelo = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.Skeelo ?? 0m) + : 0m), + TotalNews = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.VivoNewsPlus ?? 0m) + : 0m), + TotalTravel = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.VivoTravelMundo ?? 0m) + : 0m), + TotalGestaoDispositivo = g.Sum(x => + (x.ValorPlanoVivo != null || + x.FranquiaVivo != null || + x.ValorContratoVivo != null || + x.GestaoVozDados != null || + x.Skeelo != null || + x.VivoNewsPlus != null || + x.VivoTravelMundo != null || + x.VivoGestaoDispositivo != null) + ? (x.VivoGestaoDispositivo ?? 0m) + : 0m) + }) + .OrderBy(x => x.Cliente) + .ToListAsync(); + + var dto = new GeralDashboardInsightsDto + { + Kpis = BuildKpis(totals), + Charts = BuildCharts(totals, linhasPorFranquia), + ClientGroups = BuildClientGroups(clientGroupsRaw) + }; + + return dto; + } + + private static GeralDashboardKpisDto BuildKpis(TotalsProjection? totals) + { + if (totals == null) + { + return new GeralDashboardKpisDto + { + Vivo = new GeralDashboardVivoKpiDto(), + TravelMundo = new GeralDashboardTravelKpiDto(), + Adicionais = new GeralDashboardAdditionalKpiDto() + }; + } + + var totalGeralMensal = totals.VivoBaseTotal + totals.VivoAdicionaisTotal; + var media = totals.VivoLinhas > 0 ? totalGeralMensal / totals.VivoLinhas : 0m; + + return new GeralDashboardKpisDto + { + TotalLinhas = totals.TotalLinhas, + TotalAtivas = totals.TotalAtivas, + Vivo = new GeralDashboardVivoKpiDto + { + QtdLinhas = totals.VivoLinhas, + TotalBaseMensal = totals.VivoBaseTotal, + TotalAdicionaisMensal = totals.VivoAdicionaisTotal, + TotalGeralMensal = totalGeralMensal, + MediaPorLinha = media, + MinPorLinha = totals.VivoLinhas > 0 ? totals.VivoMinBase : null, + MaxPorLinha = totals.VivoLinhas > 0 ? totals.VivoMaxBase : null + }, + TravelMundo = new GeralDashboardTravelKpiDto + { + ComTravel = totals.TravelCom, + SemTravel = totals.TravelSem, + TotalValue = totals.TravelTotal + }, + Adicionais = new GeralDashboardAdditionalKpiDto + { + TotalLinesWithAnyPaidAdditional = totals.TotalLinesWithAnyPaidAdditional, + TotalLinesWithNoPaidAdditional = totals.TotalLinesWithNoPaidAdditional, + ServicesPaid = new List + { + new() { ServiceName = ServiceGestaoVozDados, CountLines = totals.PaidGestaoVozDados, TotalValue = totals.TotalGestaoVozDados }, + new() { ServiceName = ServiceSkeelo, CountLines = totals.PaidSkeelo, TotalValue = totals.TotalSkeelo }, + new() { ServiceName = ServiceVivoNewsPlus, CountLines = totals.PaidNews, TotalValue = totals.TotalNews }, + new() { ServiceName = ServiceVivoTravelMundo, CountLines = totals.PaidTravel, TotalValue = totals.TotalTravel }, + new() { ServiceName = ServiceVivoGestaoDispositivo, CountLines = totals.PaidGestaoDispositivo, TotalValue = totals.TotalGestaoDispositivo } + }, + ServicesNotPaid = new List + { + new() { ServiceName = ServiceGestaoVozDados, CountLines = totals.NotPaidGestaoVozDados, TotalValue = 0m }, + new() { ServiceName = ServiceSkeelo, CountLines = totals.NotPaidSkeelo, TotalValue = 0m }, + new() { ServiceName = ServiceVivoNewsPlus, CountLines = totals.NotPaidNews, TotalValue = 0m }, + new() { ServiceName = ServiceVivoTravelMundo, CountLines = totals.NotPaidTravel, TotalValue = 0m }, + new() { ServiceName = ServiceVivoGestaoDispositivo, CountLines = totals.NotPaidGestaoDispositivo, TotalValue = 0m } + } + } + }; + } + + private static GeralDashboardChartsDto BuildCharts(TotalsProjection? totals, FranquiaBuckets franquias) + { + var adicionaisLabels = new List + { + ServiceGestaoVozDados, + ServiceSkeelo, + ServiceVivoNewsPlus, + ServiceVivoTravelMundo, + ServiceVivoGestaoDispositivo + }; + + var adicionaisValues = totals == null + ? new List { 0, 0, 0, 0, 0 } + : new List + { + totals.PaidGestaoVozDados, + totals.PaidSkeelo, + totals.PaidNews, + totals.PaidTravel, + totals.PaidGestaoDispositivo + }; + + var adicionaisTotals = totals == null + ? new List { 0m, 0m, 0m, 0m, 0m } + : new List + { + totals.TotalGestaoVozDados, + totals.TotalSkeelo, + totals.TotalNews, + totals.TotalTravel, + totals.TotalGestaoDispositivo + }; + + return new GeralDashboardChartsDto + { + LinhasPorFranquia = new GeralDashboardChartDto + { + Labels = franquias.Labels, + Values = franquias.Values + }, + AdicionaisPagosPorServico = new GeralDashboardChartDto + { + Labels = adicionaisLabels, + Values = adicionaisValues, + Totals = adicionaisTotals + }, + TravelMundo = new GeralDashboardChartDto + { + Labels = new List { "Com", "Sem" }, + Values = totals == null + ? new List { 0, 0 } + : new List { totals.TravelCom, totals.TravelSem } + } + }; + } + + private static List BuildClientGroups(IEnumerable rows) + { + var list = new List(); + + foreach (var row in rows) + { + var baseTags = new List + { + new() { Label = "LINHAS", Value = row.TotalLinhas.ToString(PtBr) }, + new() { Label = "ATIVAS", Value = row.Ativos.ToString(PtBr) }, + new() { Label = "BLOQUEADAS", Value = row.Bloqueados.ToString(PtBr) }, + new() { Label = "LINHAS VIVO", Value = row.LinhasVivo.ToString(PtBr) } + }; + + var extras = new List + { + new() { Label = "TRAVEL MUNDO", Value = row.TravelCom.ToString(PtBr) }, + new() { Label = "SEM TRAVEL", Value = row.TravelSem.ToString(PtBr) }, + new() { Label = ServiceGestaoVozDados, Value = row.PaidGestaoVozDados.ToString(PtBr) }, + new() { Label = $"R$ {ServiceGestaoVozDados}", Value = FormatCurrency(row.TotalGestaoVozDados) }, + new() { Label = ServiceSkeelo, Value = row.PaidSkeelo.ToString(PtBr) }, + new() { Label = $"R$ {ServiceSkeelo}", Value = FormatCurrency(row.TotalSkeelo) }, + new() { Label = ServiceVivoNewsPlus, Value = row.PaidNews.ToString(PtBr) }, + new() { Label = $"R$ {ServiceVivoNewsPlus}", Value = FormatCurrency(row.TotalNews) }, + new() { Label = ServiceVivoTravelMundo, Value = row.PaidTravel.ToString(PtBr) }, + new() { Label = $"R$ {ServiceVivoTravelMundo}", Value = FormatCurrency(row.TotalTravel) }, + new() { Label = ServiceVivoGestaoDispositivo, Value = row.PaidGestaoDispositivo.ToString(PtBr) }, + new() { Label = $"R$ {ServiceVivoGestaoDispositivo}", Value = FormatCurrency(row.TotalGestaoDispositivo) } + }; + + var serviceDetails = new List + { + new() + { + ServiceName = ServiceGestaoVozDados, + PaidCount = row.PaidGestaoVozDados, + NotPaidCount = row.NotPaidGestaoVozDados, + TotalValue = row.TotalGestaoVozDados + }, + new() + { + ServiceName = ServiceSkeelo, + PaidCount = row.PaidSkeelo, + NotPaidCount = row.NotPaidSkeelo, + TotalValue = row.TotalSkeelo + }, + new() + { + ServiceName = ServiceVivoNewsPlus, + PaidCount = row.PaidNews, + NotPaidCount = row.NotPaidNews, + TotalValue = row.TotalNews + }, + new() + { + ServiceName = ServiceVivoTravelMundo, + PaidCount = row.PaidTravel, + NotPaidCount = row.NotPaidTravel, + TotalValue = row.TotalTravel + }, + new() + { + ServiceName = ServiceVivoGestaoDispositivo, + PaidCount = row.PaidGestaoDispositivo, + NotPaidCount = row.NotPaidGestaoDispositivo, + TotalValue = row.TotalGestaoDispositivo + } + }; + + list.Add(new GeralDashboardClientGroupDto + { + Cliente = row.Cliente, + TotalLinhas = row.TotalLinhas, + Ativos = row.Ativos, + Bloqueados = row.Bloqueados, + LinhasVivo = row.LinhasVivo, + TagsBase = baseTags, + TagsExtras = extras, + AdicionaisPorServico = serviceDetails + }); + } + + return list; + } + + private static FranquiaBuckets BuildFranquiaBuckets(IEnumerable rows) + { + var map = new Dictionary(); + + foreach (var row in rows) + { + var label = NormalizeFranquiaLabel(row.FranquiaVivo, row.PlanoContrato); + var key = label; + if (!map.TryGetValue(key, out var bucket)) + { + bucket = new FranquiaBucket { Label = label, SortKey = BuildFranquiaSortKey(label) }; + map[key] = bucket; + } + + bucket.Count++; + } + + var ordered = map.Values + .OrderBy(x => x.SortKey) + .ThenBy(x => x.Label) + .ToList(); + + return new FranquiaBuckets + { + Labels = ordered.Select(x => x.Label).ToList(), + Values = ordered.Select(x => x.Count).ToList() + }; + } + + private static string NormalizeFranquiaLabel(decimal? value, string? planoContrato) + { + if (value.HasValue && value.Value > 0) + { + return NormalizeNumericFranquia(value.Value); + } + + var parsed = ParseFranquiaFromPlano(planoContrato); + return string.IsNullOrWhiteSpace(parsed) ? "Sem Franquia" : parsed; + } + + private static string? ParseFranquiaFromPlano(string? planoContrato) + { + if (string.IsNullOrWhiteSpace(planoContrato)) return null; + + var match = Regex.Match(planoContrato, @"(?\d+(?:[.,]\d+)?)\s*(?GB|MB)", RegexOptions.IgnoreCase); + if (!match.Success) return null; + + var valueRaw = match.Groups["val"].Value.Replace(",", "."); + if (!decimal.TryParse(valueRaw, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) return null; + + var unit = match.Groups["unit"].Value.ToUpperInvariant(); + if (unit == "GB") + { + return $"{TrimDecimal(value)}GB"; + } + + return $"{Math.Round(value):0}MB"; + } + + private static string NormalizeNumericFranquia(decimal value) + { + if (value < 1m) + { + var mb = Math.Round(value * 1000m); + return $"{mb:0}MB"; + } + + if (value >= 100m && value < 1024m) + { + return $"{Math.Round(value):0}MB"; + } + + if (value >= 1024m) + { + var gb = value / 1024m; + return $"{TrimDecimal(gb)}GB"; + } + + return $"{TrimDecimal(value)}GB"; + } + + private static decimal BuildFranquiaSortKey(string label) + { + if (label.Equals("Sem Franquia", StringComparison.OrdinalIgnoreCase)) + { + return decimal.MaxValue; + } + + var match = Regex.Match(label, @"(?\d+(?:[.,]\d+)?)\s*(?GB|MB)", RegexOptions.IgnoreCase); + if (!match.Success) return decimal.MaxValue - 1; + + var valueRaw = match.Groups["val"].Value.Replace(",", "."); + if (!decimal.TryParse(valueRaw, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) + { + return decimal.MaxValue - 1; + } + + var unit = match.Groups["unit"].Value.ToUpperInvariant(); + return unit == "MB" ? value / 1000m : value; + } + + private static string TrimDecimal(decimal value) + { + return value % 1 == 0 ? value.ToString("0", CultureInfo.InvariantCulture) : value.ToString("0.#", CultureInfo.InvariantCulture); + } + + private static string FormatCurrency(decimal value) + { + return value.ToString("C", PtBr); + } + + private sealed class FranquiaBucket + { + public string Label { get; set; } = string.Empty; + public int Count { get; set; } + public decimal SortKey { get; set; } + } + + private sealed class FranquiaBuckets + { + public List Labels { get; set; } = new(); + public List Values { get; set; } = new(); + } + + private sealed class FranquiaProjection + { + public decimal? FranquiaVivo { get; set; } + public string? PlanoContrato { get; set; } + } + + private sealed class TotalsProjection + { + public int TotalLinhas { get; set; } + public int TotalAtivas { get; set; } + public int TotalBloqueados { get; set; } + public int VivoLinhas { get; set; } + public decimal VivoBaseTotal { get; set; } + public decimal VivoAdicionaisTotal { get; set; } + public decimal VivoMinBase { get; set; } + public decimal VivoMaxBase { get; set; } + public int TravelCom { get; set; } + public int TravelSem { get; set; } + public decimal TravelTotal { get; set; } + public int PaidGestaoVozDados { get; set; } + public int PaidSkeelo { get; set; } + public int PaidNews { get; set; } + public int PaidTravel { get; set; } + public int PaidGestaoDispositivo { get; set; } + public int NotPaidGestaoVozDados { get; set; } + public int NotPaidSkeelo { get; set; } + public int NotPaidNews { get; set; } + public int NotPaidTravel { get; set; } + public int NotPaidGestaoDispositivo { get; set; } + public int TotalLinesWithAnyPaidAdditional { get; set; } + public int TotalLinesWithNoPaidAdditional { get; set; } + public decimal TotalGestaoVozDados { get; set; } + public decimal TotalSkeelo { get; set; } + public decimal TotalNews { get; set; } + public decimal TotalTravel { get; set; } + public decimal TotalGestaoDispositivo { get; set; } + } + + private sealed class ClientGroupProjection + { + public string Cliente { get; set; } = string.Empty; + public int TotalLinhas { get; set; } + public int Ativos { get; set; } + public int Bloqueados { get; set; } + public int LinhasVivo { get; set; } + public int TravelCom { get; set; } + public int TravelSem { get; set; } + public int PaidGestaoVozDados { get; set; } + public int PaidSkeelo { get; set; } + public int PaidNews { get; set; } + public int PaidTravel { get; set; } + public int PaidGestaoDispositivo { get; set; } + public int NotPaidGestaoVozDados { get; set; } + public int NotPaidSkeelo { get; set; } + public int NotPaidNews { get; set; } + public int NotPaidTravel { get; set; } + public int NotPaidGestaoDispositivo { get; set; } + public decimal TotalGestaoVozDados { get; set; } + public decimal TotalSkeelo { get; set; } + public decimal TotalNews { get; set; } + public decimal TotalTravel { get; set; } + public decimal TotalGestaoDispositivo { get; set; } + } + } +} diff --git a/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs b/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs new file mode 100644 index 0000000..ca2b5e6 --- /dev/null +++ b/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs @@ -0,0 +1,116 @@ +using line_gestao_api.Data; +using line_gestao_api.Models; +using line_gestao_api.Services; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace line_gestao_api.Tests +{ + public class GeralDashboardInsightsServiceTests + { + [Fact] + public async Task GetInsightsAsync_ReturnsZerosWhenNoLines() + { + var tenantId = Guid.NewGuid(); + var provider = new TestTenantProvider(tenantId); + var db = BuildContext(provider); + + var service = new GeralDashboardInsightsService(db); + var result = await service.GetInsightsAsync(); + + Assert.NotNull(result); + Assert.Equal(0, result.Kpis.TotalLinhas); + Assert.Equal(0, result.Kpis.Vivo.QtdLinhas); + Assert.NotNull(result.Charts.LinhasPorFranquia); + Assert.NotNull(result.ClientGroups); + } + + [Fact] + public async Task GetInsightsAsync_RespectsTenantIsolation() + { + var tenantA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); + var provider = new TestTenantProvider(tenantA); + var db = BuildContext(provider); + + db.MobileLines.AddRange( + new MobileLine + { + TenantId = tenantA, + Cliente = "Cliente A", + Status = "Ativo", + ValorPlanoVivo = 100m, + GestaoVozDados = 10m + }, + new MobileLine + { + TenantId = tenantB, + Cliente = "Cliente B", + Status = "Ativo", + ValorPlanoVivo = 200m, + GestaoVozDados = 20m + }); + + await db.SaveChangesAsync(); + + var service = new GeralDashboardInsightsService(db); + var result = await service.GetInsightsAsync(); + + Assert.Equal(1, result.Kpis.TotalLinhas); + Assert.Equal(1, result.Kpis.Vivo.QtdLinhas); + Assert.Equal("Cliente A", result.ClientGroups.Single().Cliente); + } + + [Fact] + public async Task GetInsightsAsync_ReturnsNonNullCollections() + { + var tenantId = Guid.NewGuid(); + var provider = new TestTenantProvider(tenantId); + var db = BuildContext(provider); + + db.MobileLines.Add(new MobileLine + { + TenantId = tenantId, + Cliente = "Cliente X", + Status = "Ativo", + ValorPlanoVivo = 120m, + Skeelo = 5m, + VivoTravelMundo = 0m + }); + + await db.SaveChangesAsync(); + + var service = new GeralDashboardInsightsService(db); + var result = await service.GetInsightsAsync(); + + Assert.NotNull(result.Kpis.Adicionais.ServicesPaid); + Assert.NotNull(result.Kpis.Adicionais.ServicesNotPaid); + Assert.NotEmpty(result.ClientGroups); + Assert.NotEmpty(result.Charts.AdicionaisPagosPorServico.Labels); + } + + private static AppDbContext BuildContext(TestTenantProvider provider) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new AppDbContext(options, provider); + } + + private sealed class TestTenantProvider : ITenantProvider + { + public TestTenantProvider(Guid tenantId) + { + TenantId = tenantId; + } + + public Guid? TenantId { get; private set; } + + public void SetTenantId(Guid? tenantId) + { + TenantId = tenantId; + } + } + } +} diff --git a/line-gestao-api.Tests/line-gestao-api.Tests.csproj b/line-gestao-api.Tests/line-gestao-api.Tests.csproj new file mode 100644 index 0000000..c799d69 --- /dev/null +++ b/line-gestao-api.Tests/line-gestao-api.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + false + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + From dfce34f64d0534e63b7b0eecbf333e3dce147af3 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:31:25 -0300 Subject: [PATCH 2/6] Exclude test project from API build --- line-gestao-api.csproj | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/line-gestao-api.csproj b/line-gestao-api.csproj index 00b820a..2577bc3 100644 --- a/line-gestao-api.csproj +++ b/line-gestao-api.csproj @@ -16,6 +16,13 @@ + + + + + + + From c1740335832aa8784e38d1a2cae371e78f6288bb Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:33:37 -0300 Subject: [PATCH 3/6] Fix resumo reserva DDD forward fill --- Controllers/LinesController.cs | 116 +++++++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 12 deletions(-) diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index d9269d1..36dcf60 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -1814,34 +1814,78 @@ namespace line_gestao_api.Controllers private async Task ImportResumoTabela6(IXLWorksheet ws, DateTime now) { - const int headerRow = 91; - const int totalRow = 139; - var lastRow = Math.Min(totalRow - 1, ws.LastRowUsed()?.RowNumber() ?? totalRow - 1); + var lastRowUsed = ws.LastRowUsed()?.RowNumber() ?? 1; + var sectionRow = FindSectionRow(ws, "LINHAS NA RESERVA"); + if (sectionRow == 0) return; + + var headerRow = FindHeaderRowForReserva(ws, sectionRow + 1, lastRowUsed); + if (headerRow == 0) return; var map = BuildHeaderMap(ws.Row(headerRow)); var colDdd = GetCol(map, "DDD"); var colFranquiaGb = GetColAny(map, "FRANQUIA GB", "FRAQUIA GB"); - var colQtdLinhas = GetColAny(map, "QTD. DE LINHAS", "QTD DE LINHAS", "QTD. LINHAS"); + var colQtdLinhas = GetColAny(map, "QTD. DE LINHAS", "QTD DE LINHAS", "QTD. LINHAS", "QTDLINHAS"); var colTotal = GetCol(map, "TOTAL"); var buffer = new List(200); decimal? lastTotal = null; + string? lastDddValid = null; + var dataStarted = false; + var emptyRowStreak = 0; + int? totalRowIndex = null; - for (int r = headerRow + 1; r <= lastRow; r++) + for (int r = headerRow + 1; r <= lastRowUsed; r++) { var ddd = GetCellString(ws, r, colDdd); var franquia = GetCellString(ws, r, colFranquiaGb); var qtdLinhas = GetCellString(ws, r, colQtdLinhas); var total = GetCellString(ws, r, colTotal); - if (string.IsNullOrWhiteSpace(ddd) + var hasAnyValue = !(string.IsNullOrWhiteSpace(ddd) && string.IsNullOrWhiteSpace(franquia) && string.IsNullOrWhiteSpace(qtdLinhas) - && string.IsNullOrWhiteSpace(total)) + && string.IsNullOrWhiteSpace(total)); + + if (!hasAnyValue) { + if (dataStarted) + { + emptyRowStreak++; + if (emptyRowStreak >= 2) break; + } continue; } + emptyRowStreak = 0; + + var franquiaValue = TryDecimal(franquia); + var qtdValue = TryNullableInt(qtdLinhas); + var isDataRow = franquiaValue.HasValue || qtdValue.HasValue; + var dddCandidate = NullIfEmptyDigits(ddd); + + if (!string.IsNullOrWhiteSpace(dddCandidate)) + { + lastDddValid = dddCandidate; + } + + var isTotalRow = !isDataRow && !string.IsNullOrWhiteSpace(total); + if (isTotalRow) + { + totalRowIndex = r; + break; + } + + if (!isDataRow && dataStarted) + { + break; + } + + if (isDataRow) dataStarted = true; + + var resolvedDdd = isDataRow + ? (dddCandidate ?? lastDddValid) + : dddCandidate; + var totalValue = TryDecimal(total); if (!totalValue.HasValue && lastTotal.HasValue) { @@ -1854,25 +1898,36 @@ namespace line_gestao_api.Controllers buffer.Add(new ResumoReservaLine { - Ddd = string.IsNullOrWhiteSpace(ddd) ? null : ddd.Trim(), - FranquiaGb = TryDecimal(franquia), - QtdLinhas = TryNullableInt(qtdLinhas), + Ddd = string.IsNullOrWhiteSpace(resolvedDdd) ? null : resolvedDdd, + FranquiaGb = franquiaValue, + QtdLinhas = qtdValue, Total = totalValue, CreatedAt = now, UpdatedAt = now }); } + var missingDddCount = buffer.Count(x => x.Ddd == null && (x.FranquiaGb.HasValue || x.QtdLinhas.HasValue)); + if (missingDddCount > 0) + { + throw new InvalidOperationException($"Import RESUMO/RESERVA: {missingDddCount} linhas de dados ficaram sem DDD."); + } + if (buffer.Count > 0) { await _db.ResumoReservaLines.AddRangeAsync(buffer); await _db.SaveChangesAsync(); } + if (totalRowIndex == null) + { + return; + } + var totalEntity = new ResumoReservaTotal { - QtdLinhasTotal = TryNullableInt(GetCellString(ws, totalRow, colQtdLinhas)), - Total = TryDecimal(GetCellString(ws, totalRow, colTotal)), + QtdLinhasTotal = TryNullableInt(GetCellString(ws, totalRowIndex.Value, colQtdLinhas)), + Total = TryDecimal(GetCellString(ws, totalRowIndex.Value, colTotal)), CreatedAt = now, UpdatedAt = now }; @@ -1881,6 +1936,43 @@ namespace line_gestao_api.Controllers await _db.SaveChangesAsync(); } + private static int FindSectionRow(IXLWorksheet ws, string sectionName) + { + var normalizedTarget = NormalizeHeader(sectionName); + foreach (var row in ws.RowsUsed()) + { + foreach (var cell in row.CellsUsed()) + { + var key = NormalizeHeader(cell.GetString()); + if (string.IsNullOrWhiteSpace(key)) continue; + if (key.Contains(normalizedTarget)) return row.RowNumber(); + } + } + + return 0; + } + + private static int FindHeaderRowForReserva(IXLWorksheet ws, int startRow, int lastRow) + { + for (int r = startRow; r <= lastRow; r++) + { + var row = ws.Row(r); + if (!row.CellsUsed().Any()) continue; + + var map = BuildHeaderMap(row); + var hasDdd = GetCol(map, "DDD") > 0; + var hasFranquia = GetColAny(map, "FRANQUIA GB", "FRAQUIA GB") > 0; + var hasQtd = GetColAny(map, "QTD. DE LINHAS", "QTD DE LINHAS", "QTD. LINHAS", "QTDLINHAS") > 0; + + if (hasDdd && hasFranquia && hasQtd) + { + return r; + } + } + + return 0; + } + private async Task ImportControleRecebidosSheet(IXLWorksheet ws, int year) { var buffer = new List(500); From 382aece077205ec3882300f50277c43607c52d96 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:10:32 -0300 Subject: [PATCH 4/6] Forward-fill plano contrato in resumo import --- Controllers/LinesController.cs | 102 ++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index 36dcf60..fa1335d 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -1714,9 +1714,9 @@ namespace line_gestao_api.Controllers private async Task ImportResumoTabela4(IXLWorksheet ws, DateTime now) { - const int headerRow = 74; - const int totalRow = 81; - var lastRow = Math.Min(totalRow - 1, ws.LastRowUsed()?.RowNumber() ?? totalRow - 1); + var lastRowUsed = ws.LastRowUsed()?.RowNumber() ?? 1; + var headerRow = FindHeaderRowForPlanoContratoResumo(ws, 1, lastRowUsed); + if (headerRow == 0) return; var map = BuildHeaderMap(ws.Row(headerRow)); var colPlano = GetCol(map, "PLANO CONTRATO"); @@ -1725,10 +1725,17 @@ namespace line_gestao_api.Controllers var colFranquiaGb = GetColAny(map, "FRANQUIA GB", "FRAQUIA GB"); var colTotalLinhas = GetColAny(map, "TOTAL DE LINHAS", "TOTAL LINHAS"); var colValorTotal = GetCol(map, "VALOR TOTAL"); + var colCliente = GetCol(map, "CLIENTE"); + var colQtdLinhas = GetColAny(map, "QTD DE LINHAS", "QTD. DE LINHAS", "QTD LINHAS"); var buffer = new List(200); + string? lastPlanoContrato = null; + var dataStarted = false; + var emptyDataStreak = 0; + int? totalRowIndex = null; + var missingPlanoCount = 0; - for (int r = headerRow + 1; r <= lastRow; r++) + for (int r = headerRow + 1; r <= lastRowUsed; r++) { var plano = GetCellString(ws, r, colPlano); var gb = GetCellString(ws, r, colGb); @@ -1736,20 +1743,68 @@ namespace line_gestao_api.Controllers var franquia = GetCellString(ws, r, colFranquiaGb); var totalLinhas = GetCellString(ws, r, colTotalLinhas); var valorTotal = GetCellString(ws, r, colValorTotal); + var cliente = GetCellString(ws, r, colCliente); + var qtdLinhas = GetCellString(ws, r, colQtdLinhas); - if (string.IsNullOrWhiteSpace(plano) + var isPlanoTotal = !string.IsNullOrWhiteSpace(plano) + && NormalizeHeader(plano) == NormalizeHeader("TOTAL"); + + if (isPlanoTotal) + { + totalRowIndex = r; + break; + } + + var hasAnyValue = !(string.IsNullOrWhiteSpace(plano) && string.IsNullOrWhiteSpace(gb) && string.IsNullOrWhiteSpace(valorInd) && string.IsNullOrWhiteSpace(franquia) && string.IsNullOrWhiteSpace(totalLinhas) - && string.IsNullOrWhiteSpace(valorTotal)) + && string.IsNullOrWhiteSpace(valorTotal) + && string.IsNullOrWhiteSpace(cliente) + && string.IsNullOrWhiteSpace(qtdLinhas)); + + if (!hasAnyValue) { + if (dataStarted) + { + emptyDataStreak++; + if (emptyDataStreak >= 2) break; + } continue; } + emptyDataStreak = 0; + + var isDataRow = !string.IsNullOrWhiteSpace(cliente) || TryNullableInt(qtdLinhas).HasValue; + + var planoNormalized = NormalizeHeader(plano); + if (!string.IsNullOrWhiteSpace(plano) + && planoNormalized != NormalizeHeader("PLANO CONTRATO") + && planoNormalized != NormalizeHeader("TOTAL")) + { + lastPlanoContrato = plano.Trim(); + } + + if (!isDataRow && dataStarted) + { + break; + } + + if (isDataRow) dataStarted = true; + + var resolvedPlano = isDataRow + ? (string.IsNullOrWhiteSpace(plano) ? lastPlanoContrato : plano.Trim()) + : (string.IsNullOrWhiteSpace(plano) ? null : plano.Trim()); + + if (isDataRow && string.IsNullOrWhiteSpace(resolvedPlano)) + { + missingPlanoCount++; + } + buffer.Add(new ResumoPlanoContratoResumo { - PlanoContrato = string.IsNullOrWhiteSpace(plano) ? null : plano.Trim(), + PlanoContrato = string.IsNullOrWhiteSpace(resolvedPlano) ? null : resolvedPlano, Gb = TryDecimal(gb), ValorIndividualComSvas = TryDecimal(valorInd), FranquiaGb = TryDecimal(franquia), @@ -1766,9 +1821,19 @@ namespace line_gestao_api.Controllers await _db.SaveChangesAsync(); } + if (missingPlanoCount > 0) + { + throw new InvalidOperationException($"Import RESUMO/PLANO CONTRATO: {missingPlanoCount} linhas de dados ficaram sem PLANO CONTRATO."); + } + + if (totalRowIndex == null) + { + return; + } + var total = new ResumoPlanoContratoTotal { - ValorTotal = TryDecimal(ws.Cell(totalRow, 7).GetString()), + ValorTotal = TryDecimal(GetCellString(ws, totalRowIndex.Value, colValorTotal)), CreatedAt = now, UpdatedAt = now }; @@ -1777,6 +1842,27 @@ namespace line_gestao_api.Controllers await _db.SaveChangesAsync(); } + private static int FindHeaderRowForPlanoContratoResumo(IXLWorksheet ws, int startRow, int lastRow) + { + for (int r = startRow; r <= lastRow; r++) + { + var row = ws.Row(r); + if (!row.CellsUsed().Any()) continue; + + var map = BuildHeaderMap(row); + var hasPlano = GetCol(map, "PLANO CONTRATO") > 0; + var hasCliente = GetCol(map, "CLIENTE") > 0; + var hasQtd = GetColAny(map, "QTD DE LINHAS", "QTD. DE LINHAS", "QTD LINHAS") > 0; + + if (hasPlano && hasCliente && hasQtd) + { + return r; + } + } + + return 0; + } + private async Task ImportResumoTabela5(IXLWorksheet ws, DateTime now) { const int headerRow = 83; From 9b4271d2a54499150495900733f760cd5434f8da Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:23:01 -0300 Subject: [PATCH 5/6] Forward-fill plano/GB in macrophony resumo import --- Controllers/LinesController.cs | 127 +++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 13 deletions(-) diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index fa1335d..992275c 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -1520,9 +1520,9 @@ namespace line_gestao_api.Controllers private async Task ImportResumoTabela1(IXLWorksheet ws, DateTime now) { - const int headerRow = 5; - const int totalRow = 72; - var lastRow = Math.Min(totalRow - 1, ws.LastRowUsed()?.RowNumber() ?? totalRow - 1); + var lastRowUsed = ws.LastRowUsed()?.RowNumber() ?? 1; + var headerRow = FindHeaderRowForMacrophonyPlans(ws, 1, lastRowUsed); + if (headerRow == 0) return; var map = BuildHeaderMap(ws.Row(headerRow)); var colPlano = GetCol(map, "PLANO CONTRATO"); @@ -1533,8 +1533,15 @@ namespace line_gestao_api.Controllers var colValorTotal = GetCol(map, "VALOR TOTAL"); var buffer = new List(200); + string? lastPlanoContrato = null; + decimal? lastGb = null; + var dataStarted = false; + var emptyDataStreak = 0; + int? totalRowIndex = null; + var missingPlanoCount = 0; + var missingGbCount = 0; - for (int r = headerRow + 1; r <= lastRow; r++) + for (int r = headerRow + 1; r <= lastRowUsed; r++) { var plano = GetCellString(ws, r, colPlano); var gb = GetCellString(ws, r, colGb); @@ -1543,27 +1550,90 @@ namespace line_gestao_api.Controllers var totalLinhas = GetCellString(ws, r, colTotalLinhas); var valorTotal = GetCellString(ws, r, colValorTotal); - if (string.IsNullOrWhiteSpace(plano) + var isPlanoTotal = !string.IsNullOrWhiteSpace(plano) + && NormalizeHeader(plano) == NormalizeHeader("TOTAL"); + + if (isPlanoTotal) + { + totalRowIndex = r; + break; + } + + var hasAnyValue = !(string.IsNullOrWhiteSpace(plano) && string.IsNullOrWhiteSpace(gb) && string.IsNullOrWhiteSpace(valorInd) && string.IsNullOrWhiteSpace(franquia) && string.IsNullOrWhiteSpace(totalLinhas) - && string.IsNullOrWhiteSpace(valorTotal)) + && string.IsNullOrWhiteSpace(valorTotal)); + + if (!hasAnyValue) + { + if (dataStarted) + { + emptyDataStreak++; + if (emptyDataStreak >= 2) break; + } + continue; + } + + emptyDataStreak = 0; + + var franquiaValue = TryDecimal(franquia); + var totalLinhasValue = TryNullableInt(totalLinhas); + var isDataRow = franquiaValue.HasValue || totalLinhasValue.HasValue; + if (isDataRow) dataStarted = true; + if (!isDataRow && dataStarted) + { + break; + } + if (!isDataRow) { continue; } + var planoNormalized = NormalizeHeader(plano); + if (!string.IsNullOrWhiteSpace(plano) + && planoNormalized != NormalizeHeader("PLANO CONTRATO") + && planoNormalized != NormalizeHeader("TOTAL")) + { + lastPlanoContrato = plano.Trim(); + } + + var gbValue = TryDecimal(gb); + if (gbValue.HasValue) + { + lastGb = gbValue; + } + + var resolvedPlano = isDataRow + ? (string.IsNullOrWhiteSpace(plano) ? lastPlanoContrato : plano.Trim()) + : (string.IsNullOrWhiteSpace(plano) ? null : plano.Trim()); + + var resolvedGb = isDataRow + ? (gbValue ?? lastGb) + : gbValue; + + if (isDataRow && string.IsNullOrWhiteSpace(resolvedPlano)) + { + missingPlanoCount++; + } + + if (isDataRow && !resolvedGb.HasValue) + { + missingGbCount++; + } + var vivoTravelCell = ws.Cell(r, 8).GetString(); var vivoTravel = !string.IsNullOrWhiteSpace(vivoTravelCell) && vivoTravelCell.Contains("VIVO TRAVEL", StringComparison.OrdinalIgnoreCase); buffer.Add(new ResumoMacrophonyPlan { - PlanoContrato = string.IsNullOrWhiteSpace(plano) ? null : plano.Trim(), - Gb = TryDecimal(gb), + PlanoContrato = string.IsNullOrWhiteSpace(resolvedPlano) ? null : resolvedPlano, + Gb = resolvedGb, ValorIndividualComSvas = TryDecimal(valorInd), - FranquiaGb = TryDecimal(franquia), - TotalLinhas = TryNullableInt(totalLinhas), + FranquiaGb = franquiaValue, + TotalLinhas = totalLinhasValue, ValorTotal = TryDecimal(valorTotal), VivoTravel = vivoTravel, CreatedAt = now, @@ -1577,11 +1647,21 @@ namespace line_gestao_api.Controllers await _db.SaveChangesAsync(); } + if (missingPlanoCount > 0 || missingGbCount > 0) + { + throw new InvalidOperationException($"Import RESUMO/MACROPHONY: {missingPlanoCount} linhas sem PLANO CONTRATO e {missingGbCount} linhas sem GB."); + } + + if (totalRowIndex == null) + { + return; + } + var total = new ResumoMacrophonyTotal { - FranquiaGbTotal = TryDecimal(GetCellString(ws, totalRow, colFranquiaGb)), - TotalLinhasTotal = TryNullableInt(GetCellString(ws, totalRow, colTotalLinhas)), - ValorTotal = TryDecimal(GetCellString(ws, totalRow, colValorTotal)), + FranquiaGbTotal = TryDecimal(GetCellString(ws, totalRowIndex.Value, colFranquiaGb)), + TotalLinhasTotal = TryNullableInt(GetCellString(ws, totalRowIndex.Value, colTotalLinhas)), + ValorTotal = TryDecimal(GetCellString(ws, totalRowIndex.Value, colValorTotal)), CreatedAt = now, UpdatedAt = now }; @@ -1590,6 +1670,27 @@ namespace line_gestao_api.Controllers await _db.SaveChangesAsync(); } + private static int FindHeaderRowForMacrophonyPlans(IXLWorksheet ws, int startRow, int lastRow) + { + for (int r = startRow; r <= lastRow; r++) + { + var row = ws.Row(r); + if (!row.CellsUsed().Any()) continue; + + var map = BuildHeaderMap(row); + var hasPlano = GetCol(map, "PLANO CONTRATO") > 0; + var hasGb = GetCol(map, "GB") > 0; + var hasTotalLinhas = GetColAny(map, "TOTAL DE LINHAS", "TOTAL LINHAS") > 0; + + if (hasPlano && hasGb && hasTotalLinhas) + { + return r; + } + } + + return 0; + } + private async Task ImportResumoTabela2(IXLWorksheet ws, DateTime now) { const int headerRow = 5; From 5c61fee989aa7f5aa7b5cd10df7ad3540baf72d1 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:29:49 -0300 Subject: [PATCH 6/6] Add Vivo Sync and tipo de chip fields --- Controllers/LinesController.cs | 10 +++++++++- Dtos/CreateMobileLineDto.cs | 8 +++++++- Dtos/MobileLineDtos.cs | 4 ++++ Models/MobileLine.cs | 3 +++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index 992275c..ceb0b21 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -400,6 +400,7 @@ namespace line_gestao_api.Controllers Skeelo = req.Skeelo, VivoNewsPlus = req.VivoNewsPlus, VivoTravelMundo = req.VivoTravelMundo, + VivoSync = req.VivoSync, VivoGestaoDispositivo = req.VivoGestaoDispositivo, ValorContratoVivo = req.ValorContratoVivo, FranquiaLine = req.FranquiaLine, @@ -408,6 +409,7 @@ namespace line_gestao_api.Controllers ValorContratoLine = req.ValorContratoLine, Desconto = req.Desconto, Lucro = req.Lucro, + TipoDeChip = req.TipoDeChip?.Trim(), CreatedAt = now, UpdatedAt = now @@ -460,6 +462,7 @@ namespace line_gestao_api.Controllers x.Skeelo = req.Skeelo; x.VivoNewsPlus = req.VivoNewsPlus; x.VivoTravelMundo = req.VivoTravelMundo; + x.VivoSync = req.VivoSync; x.VivoGestaoDispositivo = req.VivoGestaoDispositivo; x.ValorContratoVivo = req.ValorContratoVivo; x.FranquiaLine = req.FranquiaLine; @@ -477,6 +480,7 @@ namespace line_gestao_api.Controllers x.DataEntregaOpera = ToUtc(req.DataEntregaOpera); x.DataEntregaCliente = ToUtc(req.DataEntregaCliente); x.VencConta = req.VencConta?.Trim(); + x.TipoDeChip = req.TipoDeChip?.Trim(); ApplyReservaRule(x); x.UpdatedAt = DateTime.UtcNow; @@ -576,6 +580,7 @@ namespace line_gestao_api.Controllers Skeelo = TryDecimal(GetCellByHeaderAny(ws, r, map, "SKEELO")), VivoNewsPlus = TryDecimal(GetCellByHeaderAny(ws, r, map, "VIVO NEWS PLUS")), VivoTravelMundo = TryDecimal(GetCellByHeaderAny(ws, r, map, "VIVO TRAVEL MUNDO")), + VivoSync = TryDecimal(GetCellByHeaderAny(ws, r, map, "VIVO SYNC")), VivoGestaoDispositivo = TryDecimal(GetCellByHeaderAny(ws, r, map, "VIVO GESTAO DISPOSITIVO")), ValorContratoVivo = TryDecimal(GetCellByHeaderAny(ws, r, map, "VALOR CONTRATO VIVO", "VALOR DO CONTRATO VIVO")), FranquiaLine = TryDecimal(GetCellByHeaderAny(ws, r, map, "FRANQUIA LINE", "FRAQUIA LINE")), @@ -593,6 +598,7 @@ namespace line_gestao_api.Controllers DataEntregaOpera = TryDate(ws, r, map, "DATA DA ENTREGA OPERA."), DataEntregaCliente = TryDate(ws, r, map, "DATA DA ENTREGA CLIENTE"), VencConta = GetCellByHeader(ws, r, map, "VENC. DA CONTA"), + TipoDeChip = GetCellByHeaderAny(ws, r, map, "TIPO DE CHIP", "TIPO CHIP"), CreatedAt = now, UpdatedAt = now }; @@ -2438,6 +2444,7 @@ namespace line_gestao_api.Controllers Skeelo = x.Skeelo, VivoNewsPlus = x.VivoNewsPlus, VivoTravelMundo = x.VivoTravelMundo, + VivoSync = x.VivoSync, VivoGestaoDispositivo = x.VivoGestaoDispositivo, ValorContratoVivo = x.ValorContratoVivo, FranquiaLine = x.FranquiaLine, @@ -2454,7 +2461,8 @@ namespace line_gestao_api.Controllers Solicitante = x.Solicitante, DataEntregaOpera = x.DataEntregaOpera, DataEntregaCliente = x.DataEntregaCliente, - VencConta = x.VencConta + VencConta = x.VencConta, + TipoDeChip = x.TipoDeChip }; private static void ApplyReservaRule(MobileLine x) diff --git a/Dtos/CreateMobileLineDto.cs b/Dtos/CreateMobileLineDto.cs index ee9ce98..4661a26 100644 --- a/Dtos/CreateMobileLineDto.cs +++ b/Dtos/CreateMobileLineDto.cs @@ -49,6 +49,7 @@ namespace line_gestao_api.Dtos 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 decimal? ValorContratoVivo { get; set; } @@ -65,5 +66,10 @@ namespace line_gestao_api.Dtos // ========================== public decimal? Desconto { get; set; } public decimal? Lucro { get; set; } + + // ========================== + // Identificação adicional + // ========================== + public string? TipoDeChip { get; set; } } -} \ No newline at end of file +} diff --git a/Dtos/MobileLineDtos.cs b/Dtos/MobileLineDtos.cs index 16b550c..48f7329 100644 --- a/Dtos/MobileLineDtos.cs +++ b/Dtos/MobileLineDtos.cs @@ -33,6 +33,7 @@ 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 decimal? ValorContratoVivo { get; set; } @@ -53,6 +54,7 @@ public DateTime? DataEntregaOpera { get; set; } public DateTime? DataEntregaCliente { get; set; } public string? VencConta { get; set; } + public string? TipoDeChip { get; set; } } // ✅ UPDATE REQUEST (SEM Id) @@ -72,6 +74,7 @@ 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 decimal? ValorContratoVivo { get; set; } @@ -92,6 +95,7 @@ public DateTime? DataEntregaOpera { get; set; } public DateTime? DataEntregaCliente { get; set; } public string? VencConta { get; set; } + public string? TipoDeChip { get; set; } } public class ImportResultDto diff --git a/Models/MobileLine.cs b/Models/MobileLine.cs index 35bf9c5..b21c6e3 100644 --- a/Models/MobileLine.cs +++ b/Models/MobileLine.cs @@ -29,6 +29,7 @@ namespace line_gestao_api.Models 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 decimal? ValorContratoVivo { get; set; } @@ -59,6 +60,8 @@ namespace line_gestao_api.Models [MaxLength(50)] public string? VencConta { get; set; } + [MaxLength(80)] + public string? TipoDeChip { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;