diff --git a/Controllers/BillingController.cs b/Controllers/BillingController.cs index 44e438d..13b7964 100644 --- a/Controllers/BillingController.cs +++ b/Controllers/BillingController.cs @@ -3,6 +3,8 @@ using line_gestao_api.Dtos; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Globalization; +using System.Linq; namespace line_gestao_api.Controllers { @@ -39,9 +41,20 @@ namespace line_gestao_api.Controllers if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); + var hasNumberSearch = TryParseSearchDecimal(s, out var searchNumber); + var hasIntSearch = int.TryParse(new string(s.Where(char.IsDigit).ToArray()), out var searchInt); q = q.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") + || EF.Functions.ILike(x.Tipo ?? "", $"%{s}%") + || EF.Functions.ILike(x.Item.ToString(), $"%{s}%") || EF.Functions.ILike(x.Aparelho ?? "", $"%{s}%") - || EF.Functions.ILike(x.FormaPagamento ?? "", $"%{s}%")); + || EF.Functions.ILike(x.FormaPagamento ?? "", $"%{s}%") + || (hasIntSearch && (x.QtdLinhas ?? 0) == searchInt) + || (hasNumberSearch && + (((x.FranquiaVivo ?? 0m) == searchNumber) || + ((x.ValorContratoVivo ?? 0m) == searchNumber) || + ((x.FranquiaLine ?? 0m) == searchNumber) || + ((x.ValorContratoLine ?? 0m) == searchNumber) || + ((x.Lucro ?? 0m) == searchNumber)))); } if (!string.IsNullOrWhiteSpace(client)) @@ -218,5 +231,16 @@ namespace line_gestao_api.Controllers await _db.SaveChangesAsync(); return NoContent(); } + + private static bool TryParseSearchDecimal(string value, out decimal parsed) + { + parsed = 0m; + if (string.IsNullOrWhiteSpace(value)) return false; + + var s = value.Trim().Replace("R$", "", StringComparison.OrdinalIgnoreCase).Trim(); + + return decimal.TryParse(s, NumberStyles.Any, new CultureInfo("pt-BR"), out parsed) || + decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out parsed); + } } } diff --git a/Controllers/ChipsVirgensController.cs b/Controllers/ChipsVirgensController.cs index 6c91eac..57c8d49 100644 --- a/Controllers/ChipsVirgensController.cs +++ b/Controllers/ChipsVirgensController.cs @@ -4,6 +4,7 @@ using line_gestao_api.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Globalization; using System.Text; namespace line_gestao_api.Controllers @@ -36,10 +37,15 @@ namespace line_gestao_api.Controllers if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); + var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc); + var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; q = q.Where(x => EF.Functions.ILike(x.NumeroDoChip ?? "", $"%{s}%") || EF.Functions.ILike(x.Observacoes ?? "", $"%{s}%") || - EF.Functions.ILike(x.Item.ToString(), $"%{s}%")); + EF.Functions.ILike(x.Item.ToString(), $"%{s}%") || + (hasDateSearch && + ((x.CreatedAt >= searchDateStartUtc && x.CreatedAt < searchDateEndUtc) || + (x.UpdatedAt >= searchDateStartUtc && x.UpdatedAt < searchDateEndUtc)))); } var total = await q.CountAsync(); @@ -169,5 +175,23 @@ namespace line_gestao_api.Controllers } return sb.ToString(); } + + private static bool TryParseSearchDateUtcStart(string value, out DateTime utcStart) + { + utcStart = default; + if (string.IsNullOrWhiteSpace(value)) return false; + + var s = value.Trim(); + DateTime parsed; + + if (DateTime.TryParse(s, new CultureInfo("pt-BR"), DateTimeStyles.None, out parsed) || + DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) + { + utcStart = DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc); + return true; + } + + return false; + } } } diff --git a/Controllers/ControleRecebidosController.cs b/Controllers/ControleRecebidosController.cs index 35877ca..0b33c57 100644 --- a/Controllers/ControleRecebidosController.cs +++ b/Controllers/ControleRecebidosController.cs @@ -4,6 +4,7 @@ using line_gestao_api.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Globalization; using System.Text; namespace line_gestao_api.Controllers @@ -44,6 +45,14 @@ namespace line_gestao_api.Controllers if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); + var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc); + var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; + var hasDecimalSearch = TryParseSearchDecimal(s, out var searchDecimal); + var hasIntSearch = int.TryParse(OnlyDigits(s), out var searchInt); + var searchResumo = s.Equals("resumo", StringComparison.OrdinalIgnoreCase); + var searchDetalhado = s.Equals("detalhado", StringComparison.OrdinalIgnoreCase) || + s.Equals("detalhe", StringComparison.OrdinalIgnoreCase); + q = q.Where(x => EF.Functions.ILike(x.NotaFiscal ?? "", $"%{s}%") || EF.Functions.ILike(x.Chip ?? "", $"%{s}%") || @@ -51,7 +60,19 @@ namespace line_gestao_api.Controllers EF.Functions.ILike(x.ConteudoDaNf ?? "", $"%{s}%") || EF.Functions.ILike(x.NumeroDaLinha ?? "", $"%{s}%") || EF.Functions.ILike(x.Item.ToString(), $"%{s}%") || - EF.Functions.ILike(x.Ano.ToString(), $"%{s}%")); + EF.Functions.ILike(x.Ano.ToString(), $"%{s}%") || + (hasIntSearch && ((x.Quantidade ?? 0) == searchInt)) || + (hasDecimalSearch && + (((x.ValorUnit ?? 0m) == searchDecimal) || ((x.ValorDaNf ?? 0m) == searchDecimal))) || + (searchResumo && x.IsResumo) || + (searchDetalhado && !x.IsResumo) || + (hasDateSearch && + ((x.DataDaNf != null && + x.DataDaNf.Value >= searchDateStartUtc && + x.DataDaNf.Value < searchDateEndUtc) || + (x.DataDoRecebimento != null && + x.DataDoRecebimento.Value >= searchDateStartUtc && + x.DataDoRecebimento.Value < searchDateEndUtc)))); } var total = await q.CountAsync(); @@ -262,5 +283,33 @@ namespace line_gestao_api.Controllers } return sb.ToString(); } + + private static bool TryParseSearchDateUtcStart(string value, out DateTime utcStart) + { + utcStart = default; + if (string.IsNullOrWhiteSpace(value)) return false; + + var s = value.Trim(); + DateTime parsed; + + if (DateTime.TryParse(s, new CultureInfo("pt-BR"), DateTimeStyles.None, out parsed) || + DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) + { + utcStart = DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc); + return true; + } + + return false; + } + + private static bool TryParseSearchDecimal(string value, out decimal parsed) + { + parsed = 0m; + if (string.IsNullOrWhiteSpace(value)) return false; + + var s = value.Trim().Replace("R$", "", StringComparison.OrdinalIgnoreCase).Trim(); + return decimal.TryParse(s, NumberStyles.Any, new CultureInfo("pt-BR"), out parsed) || + decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out parsed); + } } } diff --git a/Controllers/HistoricoController.cs b/Controllers/HistoricoController.cs index 64ed2ca..101b793 100644 --- a/Controllers/HistoricoController.cs +++ b/Controllers/HistoricoController.cs @@ -5,6 +5,7 @@ using line_gestao_api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Globalization; namespace line_gestao_api.Controllers; @@ -67,13 +68,25 @@ public class HistoricoController : ControllerBase if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); + var hasGuidSearch = Guid.TryParse(s, out var searchGuid); + var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc); + var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; q = q.Where(x => EF.Functions.ILike(x.UserName ?? "", $"%{s}%") || EF.Functions.ILike(x.UserEmail ?? "", $"%{s}%") || + EF.Functions.ILike(x.Action ?? "", $"%{s}%") || EF.Functions.ILike(x.EntityName ?? "", $"%{s}%") || EF.Functions.ILike(x.EntityLabel ?? "", $"%{s}%") || EF.Functions.ILike(x.EntityId ?? "", $"%{s}%") || - EF.Functions.ILike(x.Page ?? "", $"%{s}%")); + EF.Functions.ILike(x.Page ?? "", $"%{s}%") || + EF.Functions.ILike(x.RequestPath ?? "", $"%{s}%") || + EF.Functions.ILike(x.RequestMethod ?? "", $"%{s}%") || + EF.Functions.ILike(x.IpAddress ?? "", $"%{s}%") || + // ChangesJson is stored as jsonb; applying ILIKE directly causes PostgreSQL 42883. + (hasGuidSearch && (x.Id == searchGuid || x.UserId == searchGuid)) || + (hasDateSearch && + x.OccurredAtUtc >= searchDateStartUtc && + x.OccurredAtUtc < searchDateEndUtc)); } if (dateFrom.HasValue) @@ -158,4 +171,22 @@ public class HistoricoController : ControllerBase return DateTime.SpecifyKind(value, DateTimeKind.Utc); } + + private static bool TryParseSearchDateUtcStart(string value, out DateTime utcStart) + { + utcStart = default; + if (string.IsNullOrWhiteSpace(value)) return false; + + var s = value.Trim(); + DateTime parsed; + + if (DateTime.TryParse(s, new CultureInfo("pt-BR"), DateTimeStyles.None, out parsed) || + DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) + { + utcStart = DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc); + return true; + } + + return false; + } } diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index 580252a..4e6815f 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -75,6 +75,8 @@ namespace line_gestao_api.Controllers public async Task>> GetClientGroups( [FromQuery] string? skil, [FromQuery] string? search, + [FromQuery] string? additionalMode, + [FromQuery] string? additionalServices, [FromQuery] int page = 1, [FromQuery] int pageSize = 10) { @@ -92,38 +94,60 @@ namespace line_gestao_api.Controllers { reservaFilter = true; query = query.Where(x => - EF.Functions.ILike((x.Cliente ?? "").Trim(), "%RESERVA%") || - EF.Functions.ILike((x.Usuario ?? "").Trim(), "%RESERVA%") || - EF.Functions.ILike((x.Skil ?? "").Trim(), "%RESERVA%")); + EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || + (EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA") && + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA"))); } else query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); } - if (!reservaFilter) - { - query = query.Where(x => !string.IsNullOrEmpty(x.Cliente)); - } + query = ApplyAdditionalFilters(query, additionalMode, additionalServices); - // Filtro SEARCH (Busca pelo Nome do Cliente) - if (!string.IsNullOrWhiteSpace(search)) - { - var s = search.Trim(); - query = query.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%")); - } + IQueryable groupedQuery; - var groupedQuery = reservaFilter - ? query.GroupBy(_ => "RESERVA") - .Select(g => new ClientGroupDto + 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 = g.Key, - TotalLinhas = g.Count(), - Ativos = g.Count(x => EF.Functions.ILike(x.Status ?? "", "%ativo%")), - Bloqueados = g.Count(x => EF.Functions.ILike(x.Status ?? "", "%bloque%") || - EF.Functions.ILike(x.Status ?? "", "%perda%") || - EF.Functions.ILike(x.Status ?? "", "%roubo%")) - }) - : query.GroupBy(x => x.Cliente) + Cliente = string.IsNullOrEmpty(clienteEfetivo) ? "RESERVA" : clienteEfetivo, + line.Status + }; + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim(); + reservaRows = reservaRows.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%")); + } + + groupedQuery = reservaRows + .GroupBy(x => x.Cliente) .Select(g => new ClientGroupDto { Cliente = g.Key!, @@ -133,11 +157,38 @@ namespace line_gestao_api.Controllers EF.Functions.ILike(x.Status ?? "", "%perda%") || EF.Functions.ILike(x.Status ?? "", "%roubo%")) }); + } + else + { + query = query.Where(x => !string.IsNullOrEmpty(x.Cliente)); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim(); + query = query.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%")); + } + + groupedQuery = query.GroupBy(x => x.Cliente) + .Select(g => new ClientGroupDto + { + Cliente = g.Key!, + TotalLinhas = g.Count(), + Ativos = g.Count(x => EF.Functions.ILike(x.Status ?? "", "%ativo%")), + Bloqueados = g.Count(x => EF.Functions.ILike(x.Status ?? "", "%bloque%") || + EF.Functions.ILike(x.Status ?? "", "%perda%") || + EF.Functions.ILike(x.Status ?? "", "%roubo%")) + }); + } var totalGroups = await groupedQuery.CountAsync(); - var items = await groupedQuery - .OrderBy(x => x.Cliente) + var orderedGroupedQuery = reservaFilter + ? groupedQuery + .OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) + .ThenBy(x => x.Cliente) + : groupedQuery.OrderBy(x => x.Cliente); + + var items = await orderedGroupedQuery .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); @@ -155,28 +206,77 @@ namespace line_gestao_api.Controllers // ✅ 2. ENDPOINT: LISTAR NOMES DE CLIENTES (ACEITA SKIL) // ========================================================== [HttpGet("clients")] - public async Task>> GetClients([FromQuery] string? skil) + public async Task>> GetClients( + [FromQuery] string? skil, + [FromQuery] string? additionalMode, + [FromQuery] string? additionalServices) { var query = _db.MobileLines.AsNoTracking(); + var reservaFilter = false; if (!string.IsNullOrWhiteSpace(skil)) { var sSkil = skil.Trim(); if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) + { + reservaFilter = true; query = query.Where(x => - EF.Functions.ILike((x.Cliente ?? "").Trim(), "%RESERVA%") || - EF.Functions.ILike((x.Usuario ?? "").Trim(), "%RESERVA%") || - EF.Functions.ILike((x.Skil ?? "").Trim(), "%RESERVA%")); + EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || + (EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA") && + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA"))); + } else query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); } - var clients = await query - .Where(x => !string.IsNullOrEmpty(x.Cliente)) - .Select(x => x.Cliente!) + query = ApplyAdditionalFilters(query, additionalMode, additionalServices); + + 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 + ) .Distinct() - .OrderBy(x => x) + .OrderByDescending(x => EF.Functions.ILike((x ?? "").Trim(), "RESERVA")) + .ThenBy(x => x) .ToListAsync(); + } + else + { + clients = await query + .Where(x => !string.IsNullOrEmpty(x.Cliente)) + .Select(x => x.Cliente!) + .Distinct() + .OrderBy(x => x) + .ToListAsync(); + } return Ok(clients); } @@ -337,6 +437,8 @@ namespace line_gestao_api.Controllers [FromQuery] string? search, [FromQuery] string? skil, [FromQuery] string? client, + [FromQuery] string? additionalMode, + [FromQuery] string? additionalServices, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? sortBy = "item", @@ -346,19 +448,157 @@ namespace line_gestao_api.Controllers pageSize = pageSize < 1 ? 20 : pageSize; var q = _db.MobileLines.AsNoTracking(); + var reservaFilter = false; if (!string.IsNullOrWhiteSpace(skil)) { var sSkil = skil.Trim(); if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) + { + reservaFilter = true; q = q.Where(x => - EF.Functions.ILike((x.Cliente ?? "").Trim(), "%RESERVA%") || - EF.Functions.ILike((x.Usuario ?? "").Trim(), "%RESERVA%") || - EF.Functions.ILike((x.Skil ?? "").Trim(), "%RESERVA%")); + EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || + (EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA") && + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA"))); + } else q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); } + q = ApplyAdditionalFilters(q, additionalMode, additionalServices); + + var sb = (sortBy ?? "item").Trim().ToLowerInvariant(); + var desc = string.Equals((sortDir ?? "asc").Trim(), "desc", StringComparison.OrdinalIgnoreCase); + + if (sb == "plano") sb = "planocontrato"; + if (sb == "contrato") sb = "vencconta"; + + 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.PlanoContrato, + line.Status, + line.Skil, + line.Modalidade, + line.VencConta, + line.GestaoVozDados, + line.Skeelo, + line.VivoNewsPlus, + line.VivoTravelMundo, + line.VivoSync, + line.VivoGestaoDispositivo, + line.TipoDeChip + }; + + if (!string.IsNullOrWhiteSpace(client)) + rq = rq.Where(x => EF.Functions.ILike(x.Cliente ?? "", client.Trim())); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim(); + rq = rq.Where(x => + EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || + EF.Functions.ILike(x.Chip ?? "", $"%{s}%") || + EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || + EF.Functions.ILike(x.Usuario ?? "", $"%{s}%") || + EF.Functions.ILike(x.Conta ?? "", $"%{s}%") || + EF.Functions.ILike(x.Status ?? "", $"%{s}%")); + } + + var totalReserva = await rq.CountAsync(); + + rq = sb switch + { + "conta" => desc ? rq.OrderByDescending(x => x.Conta ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Conta ?? "").ThenBy(x => x.Item), + "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")) + .ThenByDescending(x => x.Cliente ?? "") + .ThenBy(x => x.Item) + : rq.OrderByDescending(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), + "planocontrato" => desc ? rq.OrderByDescending(x => x.PlanoContrato ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.PlanoContrato ?? "").ThenBy(x => x.Item), + "vencconta" => desc ? rq.OrderByDescending(x => x.VencConta ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.VencConta ?? "").ThenBy(x => x.Item), + "status" => desc ? rq.OrderByDescending(x => x.Status ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Status ?? "").ThenBy(x => x.Item), + "skil" => desc ? rq.OrderByDescending(x => x.Skil ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Skil ?? "").ThenBy(x => x.Item), + "modalidade" => desc ? rq.OrderByDescending(x => x.Modalidade ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Modalidade ?? "").ThenBy(x => x.Item), + _ => desc ? rq.OrderByDescending(x => x.Item) : rq.OrderBy(x => x.Item) + }; + + var itemsReserva = await rq + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new MobileLineListDto + { + Id = x.Id, + Item = x.Item, + Conta = x.Conta, + Linha = x.Linha, + Chip = x.Chip, + Cliente = x.Cliente, + Usuario = x.Usuario, + PlanoContrato = x.PlanoContrato, + Status = x.Status, + Skil = x.Skil, + Modalidade = x.Modalidade, + VencConta = x.VencConta, + GestaoVozDados = x.GestaoVozDados, + Skeelo = x.Skeelo, + VivoNewsPlus = x.VivoNewsPlus, + VivoTravelMundo = x.VivoTravelMundo, + VivoSync = x.VivoSync, + VivoGestaoDispositivo = x.VivoGestaoDispositivo, + TipoDeChip = x.TipoDeChip + }) + .ToListAsync(); + + return Ok(new PagedResult + { + Page = page, + PageSize = pageSize, + Total = totalReserva, + Items = itemsReserva + }); + } + if (!string.IsNullOrWhiteSpace(client)) q = q.Where(x => EF.Functions.ILike(x.Cliente ?? "", client.Trim())); @@ -376,12 +616,6 @@ namespace line_gestao_api.Controllers var total = await q.CountAsync(); - var sb = (sortBy ?? "item").Trim().ToLowerInvariant(); - var desc = string.Equals((sortDir ?? "asc").Trim(), "desc", StringComparison.OrdinalIgnoreCase); - - if (sb == "plano") sb = "planocontrato"; - if (sb == "contrato") sb = "vencconta"; - q = sb switch { "conta" => desc ? q.OrderByDescending(x => x.Conta ?? "").ThenBy(x => x.Item) : q.OrderBy(x => x.Conta ?? "").ThenBy(x => x.Item), @@ -413,7 +647,14 @@ namespace line_gestao_api.Controllers Status = x.Status, Skil = x.Skil, Modalidade = x.Modalidade, - VencConta = x.VencConta + VencConta = x.VencConta, + GestaoVozDados = x.GestaoVozDados, + Skeelo = x.Skeelo, + VivoNewsPlus = x.VivoNewsPlus, + VivoTravelMundo = x.VivoTravelMundo, + VivoSync = x.VivoSync, + VivoGestaoDispositivo = x.VivoGestaoDispositivo, + TipoDeChip = x.TipoDeChip }) .ToListAsync(); @@ -434,7 +675,8 @@ namespace line_gestao_api.Controllers { var x = await _db.MobileLines.AsNoTracking().FirstOrDefaultAsync(a => a.Id == id); if (x == null) return NotFound(); - return Ok(ToDetailDto(x)); + var vigencia = await FindVigenciaByMobileLineAsync(x, null, asNoTracking: true); + return Ok(ToDetailDto(x, vigencia)); } // ========================================================== @@ -449,6 +691,12 @@ namespace line_gestao_api.Controllers if (string.IsNullOrWhiteSpace(req.Linha)) return BadRequest(new { message = "O número da Linha é obrigatório." }); + if (!req.DtEfetivacaoServico.HasValue) + return BadRequest(new { message = "A Dt. Efetivação Serviço é obrigatória." }); + + if (!req.DtTerminoFidelizacao.HasValue) + return BadRequest(new { message = "A Dt. Término Fidelização é obrigatória." }); + var linhaLimpa = OnlyDigits(req.Linha); var chipLimpo = OnlyDigits(req.Chip); @@ -514,6 +762,11 @@ namespace line_gestao_api.Controllers ApplyReservaRule(newLine); _db.MobileLines.Add(newLine); + var vigencia = await UpsertVigenciaFromMobileLineAsync( + newLine, + req.DtEfetivacaoServico, + req.DtTerminoFidelizacao, + overrideDates: false); try { @@ -524,7 +777,7 @@ namespace line_gestao_api.Controllers return StatusCode(500, new { message = "Erro ao salvar no banco de dados." }); } - return CreatedAtAction(nameof(GetById), new { id = newLine.Id }, ToDetailDto(newLine)); + return CreatedAtAction(nameof(GetById), new { id = newLine.Id }, ToDetailDto(newLine, vigencia)); } // ========================================================== @@ -535,6 +788,7 @@ namespace line_gestao_api.Controllers { var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); if (x == null) return NotFound(); + var previousLinha = x.Linha; var newLinha = OnlyDigits(req.Linha); if (!string.IsNullOrWhiteSpace(newLinha) && !string.Equals((x.Linha ?? ""), newLinha, StringComparison.Ordinal)) @@ -579,6 +833,13 @@ namespace line_gestao_api.Controllers x.TipoDeChip = req.TipoDeChip?.Trim(); ApplyReservaRule(x); + + await UpsertVigenciaFromMobileLineAsync( + x, + req.DtEfetivacaoServico, + req.DtTerminoFidelizacao, + overrideDates: false, + previousLinha: previousLinha); x.UpdatedAt = DateTime.UtcNow; try { await _db.SaveChangesAsync(); } @@ -605,7 +866,7 @@ namespace line_gestao_api.Controllers // ✅ 8. IMPORT EXCEL // ========================================================== [HttpPost("import-excel")] - [Authorize] + [Authorize(Roles = "admin")] [Consumes("multipart/form-data")] [RequestSizeLimit(50_000_000)] public async Task> ImportExcel([FromForm] ImportExcelForm form) @@ -745,7 +1006,11 @@ namespace line_gestao_api.Controllers // ========================= // ✅ IMPORTA DADOS DOS USUÁRIOS (UserDatas) // ========================= - await ImportUserDatasFromWorkbook(wb); + var userDataImported = await ImportUserDatasFromWorkbook(wb); + if (userDataImported) + { + await RepairReservaClientAssignmentsAsync(); + } // ========================= // ✅ IMPORTA VIGÊNCIA @@ -1233,7 +1498,7 @@ namespace line_gestao_api.Controllers // ========================================================== // ✅ IMPORTAÇÃO: DADOS DOS USUÁRIOS (UserDatas) // ========================================================== - private async Task ImportUserDatasFromWorkbook(XLWorkbook wb) + private async Task ImportUserDatasFromWorkbook(XLWorkbook wb) { var ws = wb.Worksheets.FirstOrDefault(w => NormalizeHeader(w.Name) == NormalizeHeader("DADOS DOS USUÁRIOS")) ?? wb.Worksheets.FirstOrDefault(w => NormalizeHeader(w.Name) == NormalizeHeader("DADOS DOS USUARIOS")) @@ -1241,7 +1506,7 @@ namespace line_gestao_api.Controllers NormalizeHeader(w.Name).Contains("DADOS") && NormalizeHeader(w.Name).Contains("USUAR")); - if (ws == null) return; + if (ws == null) return false; var headerRow = ws.RowsUsed().FirstOrDefault(r => r.CellsUsed().Any(c => @@ -1250,7 +1515,7 @@ namespace line_gestao_api.Controllers NormalizeHeader(c.GetString()) == "RAZAO SOCIAL" || NormalizeHeader(c.GetString()) == "NOME")); - if (headerRow == null) return; + if (headerRow == null) return false; var map = BuildHeaderMap(headerRow); @@ -1260,7 +1525,7 @@ namespace line_gestao_api.Controllers var colNome = GetColAny(map, "NOME", "NOME COMPLETO"); var colLinha = GetCol(map, "LINHA"); - if (colCliente == 0 && colRazao == 0 && colNome == 0) return; + if (colCliente == 0 && colRazao == 0 && colNome == 0) return false; await _db.UserDatas.ExecuteDeleteAsync(); @@ -1374,6 +1639,68 @@ namespace line_gestao_api.Controllers await _db.UserDatas.AddRangeAsync(buffer); await _db.SaveChangesAsync(); } + + return true; + } + + private async Task RepairReservaClientAssignmentsAsync() + { + var reservaLines = await _db.MobileLines + .Where(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) + .Where(x => EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA")) + .Where(x => !EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA")) + .ToListAsync(); + + if (reservaLines.Count == 0) return 0; + + var userDataByLine = await BuildUserDataClientByLineQuery() + .ToDictionaryAsync(x => x.Linha, x => x.Cliente); + var userDataByItem = await BuildUserDataClientByItemQuery() + .ToDictionaryAsync(x => x.Item, x => x.Cliente); + + var updated = 0; + var now = DateTime.UtcNow; + + foreach (var line in reservaLines) + { + var resolvedClient = ""; + + if (!string.IsNullOrWhiteSpace(line.Linha) && + userDataByLine.TryGetValue(line.Linha, out var clientByLine) && + !string.IsNullOrWhiteSpace(clientByLine)) + { + resolvedClient = clientByLine.Trim(); + } + + if (string.IsNullOrWhiteSpace(resolvedClient) && + userDataByItem.TryGetValue(line.Item, out var clientByItem) && + !string.IsNullOrWhiteSpace(clientByItem)) + { + resolvedClient = clientByItem.Trim(); + } + + if (string.IsNullOrWhiteSpace(resolvedClient) || + string.Equals(resolvedClient, "RESERVA", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals((line.Cliente ?? "").Trim(), resolvedClient, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + line.Cliente = resolvedClient; + line.UpdatedAt = now; + updated++; + } + + if (updated > 0) + { + await _db.SaveChangesAsync(); + } + + return updated; } // ========================================================== @@ -1718,6 +2045,8 @@ namespace line_gestao_api.Controllers await _db.ResumoPlanoContratoResumos.ExecuteDeleteAsync(); await _db.ResumoPlanoContratoTotals.ExecuteDeleteAsync(); await _db.ResumoLineTotais.ExecuteDeleteAsync(); + await _db.ResumoGbDistribuicoes.ExecuteDeleteAsync(); + await _db.ResumoGbDistribuicaoTotais.ExecuteDeleteAsync(); await _db.ResumoReservaLines.ExecuteDeleteAsync(); await _db.ResumoReservaTotals.ExecuteDeleteAsync(); @@ -1727,6 +2056,7 @@ namespace line_gestao_api.Controllers await ImportResumoTabela2(ws, now, auditSession); await ImportResumoTabela3(ws, now); await ImportResumoTabela4(ws, now); + await ImportResumoTabela7(ws, now); await ImportResumoTabela5(ws, now); await ImportResumoTabela6(ws, now); } @@ -2189,29 +2519,81 @@ namespace line_gestao_api.Controllers return 0; } - private async Task ImportResumoTabela5(IXLWorksheet ws, DateTime now) + private async Task ImportResumoTabela7(IXLWorksheet ws, DateTime now) { - const int headerRow = 83; - var map = BuildHeaderMap(ws.Row(headerRow)); - var colValorTotalLine = GetColAny(map, "VALOR TOTAL LINE", "VALOR TOTAL LINE R$", "VALOR TOTAL LINE R$"); - var colLucroTotalLine = GetColAny(map, "LUCRO TOTAL LINE", "LUCRO TOTAL LINE R$", "LUCRO TOTAL LINE R$"); - var colQtdLinhas = GetColAny(map, "QTD. LINHAS", "QTD LINHAS", "QTD. DE LINHAS"); - - var buffer = new List(3); - for (int r = headerRow + 1; r <= headerRow + 3; r++) + var lastRowUsed = ws.LastRowUsed()?.RowNumber() ?? 1; + var headerRow = FindHeaderRowForGbDistribuicao(ws, 1, lastRowUsed); + if (headerRow == 0) { - var tipo = ws.Cell(r, 2).GetString(); - if (string.IsNullOrWhiteSpace(tipo)) + Console.WriteLine("[WARN] RESUMO/GB_QTD_SOMA: cabeçalho GB/QTD/SOMA não encontrado."); + return; + } + + var map = BuildHeaderMap(ws.Row(headerRow)); + var colGb = GetCol(map, "GB"); + var colQtd = GetColAny(map, "QTD", "QUANTIDADE"); + var colSoma = GetCol(map, "SOMA"); + + if (colGb == 0 || colQtd == 0 || colSoma == 0) + { + Console.WriteLine("[WARN] RESUMO/GB_QTD_SOMA: colunas obrigatórias incompletas no cabeçalho."); + return; + } + + var buffer = new List(120); + var dataStarted = false; + var emptyRowStreak = 0; + int? totalRowIndex = null; + + for (int r = headerRow + 1; r <= lastRowUsed; r++) + { + var gbText = GetCellString(ws, r, colGb); + var qtdText = GetCellString(ws, r, colQtd); + var somaText = GetCellString(ws, r, colSoma); + + var hasAnyValue = !(string.IsNullOrWhiteSpace(gbText) + && string.IsNullOrWhiteSpace(qtdText) + && string.IsNullOrWhiteSpace(somaText)); + + if (!hasAnyValue) { + if (dataStarted) + { + emptyRowStreak++; + if (emptyRowStreak >= 2) break; + } continue; } - buffer.Add(new ResumoLineTotais + emptyRowStreak = 0; + + var gbKey = NormalizeHeader(gbText); + var isTotalRow = gbKey == NormalizeHeader("TOTAL") + || gbKey == NormalizeHeader("TOTAL GERAL"); + if (isTotalRow) { - Tipo = tipo.Trim(), - ValorTotalLine = TryDecimal(GetCellString(ws, r, colValorTotalLine)), - LucroTotalLine = TryDecimal(GetCellString(ws, r, colLucroTotalLine)), - QtdLinhas = TryNullableInt(GetCellString(ws, r, colQtdLinhas)), + totalRowIndex = r; + break; + } + + var gbValue = TryDecimal(gbText); + var qtdValue = TryNullableInt(qtdText); + var somaValue = TryDecimal(somaText); + var isDataRow = gbValue.HasValue || qtdValue.HasValue || somaValue.HasValue; + + if (!isDataRow) + { + if (dataStarted) break; + continue; + } + + dataStarted = true; + + buffer.Add(new ResumoGbDistribuicao + { + Gb = gbValue, + Qtd = qtdValue, + Soma = somaValue, CreatedAt = now, UpdatedAt = now }); @@ -2219,9 +2601,156 @@ namespace line_gestao_api.Controllers if (buffer.Count > 0) { - await _db.ResumoLineTotais.AddRangeAsync(buffer); + await _db.ResumoGbDistribuicoes.AddRangeAsync(buffer); await _db.SaveChangesAsync(); } + + int? totalLinhas = null; + decimal? somaTotal = null; + if (totalRowIndex.HasValue) + { + totalLinhas = TryNullableInt(GetCellString(ws, totalRowIndex.Value, colQtd)); + somaTotal = TryDecimal(GetCellString(ws, totalRowIndex.Value, colSoma)); + } + + totalLinhas ??= buffer.Sum(x => x.Qtd ?? 0); + somaTotal ??= buffer.Sum(x => x.Soma ?? 0m); + + if (totalLinhas.HasValue || somaTotal.HasValue) + { + await _db.ResumoGbDistribuicaoTotais.AddAsync(new ResumoGbDistribuicaoTotal + { + TotalLinhas = totalLinhas, + SomaTotal = somaTotal, + CreatedAt = now, + UpdatedAt = now + }); + await _db.SaveChangesAsync(); + } + } + + private static int FindHeaderRowForGbDistribuicao(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 hasGb = GetCol(map, "GB") > 0; + var hasQtd = GetColAny(map, "QTD", "QUANTIDADE") > 0; + var hasSoma = GetCol(map, "SOMA") > 0; + + if (hasGb && hasQtd && hasSoma) + { + return r; + } + } + + return 0; + } + + private async Task ImportResumoTabela5(IXLWorksheet ws, DateTime now) + { + var lastRowUsed = ws.LastRowUsed()?.RowNumber() ?? 1; + var headerRow = FindHeaderRowForLineTotais(ws, 1, lastRowUsed); + if (headerRow == 0) + { + Console.WriteLine("[WARN] RESUMO/TOTAIS_LINE: cabeçalho VALOR TOTAL LINE/LUCRO TOTAL LINE/QTD. LINHAS não encontrado."); + return; + } + + var map = BuildHeaderMap(ws.Row(headerRow)); + var colValorTotalLine = GetColAny(map, "VALOR TOTAL LINE", "VALOR TOTAL LINE R$"); + var colLucroTotalLine = GetColAny(map, "LUCRO TOTAL LINE", "LUCRO TOTAL LINE R$"); + var colQtdLinhas = GetColAny(map, "QTD. LINHAS", "QTD LINHAS", "QTD. DE LINHAS", "QTDLINHAS"); + var firstMetricCol = new[] { colValorTotalLine, colLucroTotalLine, colQtdLinhas } + .Where(c => c > 0) + .DefaultIfEmpty(2) + .Min(); + var lastLabelCol = Math.Max(1, firstMetricCol - 1); + + var buffer = new List(8); + var dataStarted = false; + var emptyRowStreak = 0; + + for (int r = headerRow + 1; r <= lastRowUsed; r++) + { + var tipo = GetFirstNonEmptyCellInRange(ws, r, 1, lastLabelCol); + var valorRaw = GetCellString(ws, r, colValorTotalLine); + var lucroRaw = GetCellString(ws, r, colLucroTotalLine); + var qtdRaw = GetCellString(ws, r, colQtdLinhas); + + var hasAnyValue = !(string.IsNullOrWhiteSpace(tipo) + && string.IsNullOrWhiteSpace(valorRaw) + && string.IsNullOrWhiteSpace(lucroRaw) + && string.IsNullOrWhiteSpace(qtdRaw)); + + if (!hasAnyValue) + { + if (dataStarted) + { + emptyRowStreak++; + if (emptyRowStreak >= 2) break; + } + continue; + } + + emptyRowStreak = 0; + + var valor = TryDecimal(valorRaw); + var lucro = TryDecimal(lucroRaw); + var qtd = TryNullableInt(qtdRaw); + var isDataRow = valor.HasValue || lucro.HasValue || qtd.HasValue; + + if (!isDataRow) + { + if (dataStarted) break; + continue; + } + + dataStarted = true; + + buffer.Add(new ResumoLineTotais + { + Tipo = string.IsNullOrWhiteSpace(tipo) ? null : tipo.Trim(), + ValorTotalLine = valor, + LucroTotalLine = lucro, + QtdLinhas = qtd, + CreatedAt = now, + UpdatedAt = now + }); + } + + if (buffer.Count == 0) + { + Console.WriteLine("[WARN] RESUMO/TOTAIS_LINE: cabeçalho encontrado, mas nenhuma linha de PF/PJ/DIFERENÇA foi importada."); + return; + } + + await _db.ResumoLineTotais.AddRangeAsync(buffer); + await _db.SaveChangesAsync(); + } + + private static int FindHeaderRowForLineTotais(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 hasValor = GetColAny(map, "VALOR TOTAL LINE", "VALOR TOTAL LINE R$") > 0; + var hasLucro = GetColAny(map, "LUCRO TOTAL LINE", "LUCRO TOTAL LINE R$") > 0; + var hasQtd = GetColAny(map, "QTD. LINHAS", "QTD LINHAS", "QTD. DE LINHAS", "QTDLINHAS") > 0; + + if (hasValor && hasLucro && hasQtd) + { + return r; + } + } + + return 0; } private async Task ImportResumoTabela6(IXLWorksheet ws, DateTime now) @@ -2695,7 +3224,100 @@ namespace line_gestao_api.Controllers (v.Kind == DateTimeKind.Local ? v.ToUniversalTime() : DateTime.SpecifyKind(v, DateTimeKind.Utc)); } - private static MobileLineDetailDto ToDetailDto(MobileLine x) => new() + private async Task FindVigenciaByMobileLineAsync(MobileLine mobileLine, string? previousLinha, bool asNoTracking) + { + var currentLinha = NullIfEmptyDigits(mobileLine.Linha); + var oldLinha = NullIfEmptyDigits(previousLinha); + if (!string.IsNullOrWhiteSpace(currentLinha) && + string.Equals(currentLinha, oldLinha, StringComparison.Ordinal)) + { + oldLinha = null; + } + + IQueryable q = _db.VigenciaLines; + if (asNoTracking) + { + q = q.AsNoTracking(); + } + + if (!string.IsNullOrWhiteSpace(currentLinha) && !string.IsNullOrWhiteSpace(oldLinha)) + { + var byEitherLinha = await q.FirstOrDefaultAsync(v => v.Linha == currentLinha || v.Linha == oldLinha); + if (byEitherLinha != null) return byEitherLinha; + } + + if (!string.IsNullOrWhiteSpace(currentLinha)) + { + var byLinha = await q.FirstOrDefaultAsync(v => v.Linha == currentLinha); + if (byLinha != null) return byLinha; + } + + if (!string.IsNullOrWhiteSpace(oldLinha)) + { + var byPreviousLinha = await q.FirstOrDefaultAsync(v => v.Linha == oldLinha); + if (byPreviousLinha != null) return byPreviousLinha; + } + + if (mobileLine.Item > 0 && string.IsNullOrWhiteSpace(currentLinha) && string.IsNullOrWhiteSpace(oldLinha)) + { + return await q.FirstOrDefaultAsync(v => v.Item == mobileLine.Item); + } + + return null; + } + + private async Task UpsertVigenciaFromMobileLineAsync( + MobileLine mobileLine, + DateTime? dtEfetivacaoServico, + DateTime? dtTerminoFidelizacao, + bool overrideDates, + string? previousLinha = null) + { + var now = DateTime.UtcNow; + var vigencia = await FindVigenciaByMobileLineAsync(mobileLine, previousLinha, asNoTracking: false); + + if (vigencia == null) + { + vigencia = new VigenciaLine + { + Id = Guid.NewGuid(), + CreatedAt = now + }; + _db.VigenciaLines.Add(vigencia); + } + + vigencia.Item = mobileLine.Item; + vigencia.Conta = string.IsNullOrWhiteSpace(mobileLine.Conta) ? null : mobileLine.Conta.Trim(); + vigencia.Cliente = string.IsNullOrWhiteSpace(mobileLine.Cliente) ? null : mobileLine.Cliente.Trim(); + vigencia.Usuario = string.IsNullOrWhiteSpace(mobileLine.Usuario) ? null : mobileLine.Usuario.Trim(); + vigencia.PlanoContrato = string.IsNullOrWhiteSpace(mobileLine.PlanoContrato) ? null : mobileLine.PlanoContrato.Trim(); + + var linhaDigits = NullIfEmptyDigits(mobileLine.Linha) ?? NullIfEmptyDigits(previousLinha); + if (!string.IsNullOrWhiteSpace(linhaDigits)) + { + vigencia.Linha = linhaDigits; + } + + var totalFromLine = mobileLine.ValorPlanoVivo ?? mobileLine.ValorContratoVivo; + if (totalFromLine.HasValue) + { + vigencia.Total = totalFromLine.Value; + } + + if (overrideDates || dtEfetivacaoServico.HasValue) + { + vigencia.DtEfetivacaoServico = ToUtc(dtEfetivacaoServico); + } + if (overrideDates || dtTerminoFidelizacao.HasValue) + { + vigencia.DtTerminoFidelizacao = ToUtc(dtTerminoFidelizacao); + } + + vigencia.UpdatedAt = now; + return vigencia; + } + + private static MobileLineDetailDto ToDetailDto(MobileLine x, VigenciaLine? vigencia = null) => new() { Id = x.Id, Item = x.Item, @@ -2728,18 +3350,17 @@ namespace line_gestao_api.Controllers Solicitante = x.Solicitante, DataEntregaOpera = x.DataEntregaOpera, DataEntregaCliente = x.DataEntregaCliente, + DtEfetivacaoServico = vigencia?.DtEfetivacaoServico, + DtTerminoFidelizacao = vigencia?.DtTerminoFidelizacao, VencConta = x.VencConta, TipoDeChip = x.TipoDeChip }; private static void ApplyReservaRule(MobileLine x) { - if (IsReservaValue(x.Cliente) || IsReservaValue(x.Usuario) || IsReservaValue(x.Skil)) - { - x.Cliente = "RESERVA"; - x.Usuario = "RESERVA"; - x.Skil = "RESERVA"; - } + if (IsReservaValue(x.Cliente)) x.Cliente = "RESERVA"; + if (IsReservaValue(x.Usuario)) x.Usuario = "RESERVA"; + if (IsReservaValue(x.Skil)) x.Skil = "RESERVA"; } private static bool IsReservaValue(string? value) @@ -2788,6 +3409,176 @@ namespace line_gestao_api.Controllers return (ws.Cell(row, col).GetValue() ?? "").Trim(); } + private static string GetFirstNonEmptyCellInRange(IXLWorksheet ws, int row, int startCol, int endCol) + { + if (endCol < startCol) return ""; + for (int col = startCol; col <= endCol; col++) + { + var value = GetCellString(ws, row, col); + if (!string.IsNullOrWhiteSpace(value)) return value; + } + + return ""; + } + + private sealed class UserDataClientByLine + { + public string Linha { get; set; } = ""; + public string Cliente { get; set; } = ""; + } + + private sealed class UserDataClientByItem + { + public int Item { get; set; } + public string Cliente { get; set; } = ""; + } + + private IQueryable BuildUserDataClientByLineQuery() + { + return _db.UserDatas + .AsNoTracking() + .Where(x => x.Linha != null && x.Linha != "") + .Where(x => x.Cliente != null && x.Cliente != "") + .Where(x => !EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) + .GroupBy(x => x.Linha!) + .Select(g => new UserDataClientByLine + { + Linha = g.Key, + Cliente = g.Max(x => x.Cliente!)! + }); + } + + private IQueryable BuildUserDataClientByItemQuery() + { + return _db.UserDatas + .AsNoTracking() + .Where(x => x.Item > 0) + .Where(x => x.Cliente != null && x.Cliente != "") + .Where(x => !EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) + .GroupBy(x => x.Item) + .Select(g => new UserDataClientByItem + { + Item = g.Key, + Cliente = g.Max(x => x.Cliente!)! + }); + } + + private static IQueryable ApplyAdditionalFilters( + IQueryable query, + string? additionalMode, + string? additionalServices) + { + var mode = (additionalMode ?? "").Trim().ToLowerInvariant(); + var withOnly = mode is "with" or "com"; + var withoutOnly = mode is "without" or "sem"; + + var selected = ParseAdditionalServices(additionalServices); + var hasServiceFilter = selected.Count > 0; + + var includeGvd = selected.Contains("gvd"); + var includeSkeelo = selected.Contains("skeelo"); + var includeNews = selected.Contains("news"); + var includeTravel = selected.Contains("travel"); + var includeSync = selected.Contains("sync"); + var includeDispositivo = selected.Contains("dispositivo"); + + if (hasServiceFilter) + { + if (withoutOnly) + { + return query.Where(x => + (!includeGvd || (x.GestaoVozDados ?? 0m) <= 0m) && + (!includeSkeelo || (x.Skeelo ?? 0m) <= 0m) && + (!includeNews || (x.VivoNewsPlus ?? 0m) <= 0m) && + (!includeTravel || (x.VivoTravelMundo ?? 0m) <= 0m) && + (!includeSync || (x.VivoSync ?? 0m) <= 0m) && + (!includeDispositivo || (x.VivoGestaoDispositivo ?? 0m) <= 0m)); + } + + // "with" e também "all" com serviços selecionados: + // filtra linhas que tenham qualquer um dos adicionais selecionados com valor > 0. + return query.Where(x => + (includeGvd && (x.GestaoVozDados ?? 0m) > 0m) || + (includeSkeelo && (x.Skeelo ?? 0m) > 0m) || + (includeNews && (x.VivoNewsPlus ?? 0m) > 0m) || + (includeTravel && (x.VivoTravelMundo ?? 0m) > 0m) || + (includeSync && (x.VivoSync ?? 0m) > 0m) || + (includeDispositivo && (x.VivoGestaoDispositivo ?? 0m) > 0m)); + } + + if (withOnly) + { + return query.Where(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); + } + + if (withoutOnly) + { + return query.Where(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); + } + + return query; + } + + private static HashSet ParseAdditionalServices(string? raw) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(raw)) return set; + + var chunks = raw.Split(new[] { ',', ';', '|', ' ' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var chunk in chunks) + { + var normalized = NormalizeAdditionalServiceKey(chunk); + if (!string.IsNullOrWhiteSpace(normalized)) + set.Add(normalized); + } + + return set; + } + + private static string NormalizeAdditionalServiceKey(string? raw) + { + var key = (raw ?? "").Trim().ToLowerInvariant() + .Replace("-", "") + .Replace("_", "") + .Replace(" ", ""); + + return key switch + { + "gvd" => "gvd", + "gestaovozedados" => "gvd", + "gestaovozdados" => "gvd", + + "skeelo" => "skeelo", + + "news" => "news", + "vivonewsplus" => "news", + + "travel" => "travel", + "vivotravelmundo" => "travel", + + "sync" => "sync", + "vivosync" => "sync", + + "dispositivo" => "dispositivo", + "gestaodispositivo" => "dispositivo", + "vivogestaodispositivo" => "dispositivo", + + _ => string.Empty + }; + } + private static DateTime? TryDate(IXLWorksheet ws, int row, Dictionary map, string header) { var k = NormalizeHeader(header); diff --git a/Controllers/MuregController.cs b/Controllers/MuregController.cs index 41bed3a..111ba4a 100644 --- a/Controllers/MuregController.cs +++ b/Controllers/MuregController.cs @@ -4,6 +4,7 @@ using line_gestao_api.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Globalization; namespace line_gestao_api.Controllers { @@ -54,6 +55,8 @@ namespace line_gestao_api.Controllers if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); + var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc); + var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; q = q.Where(x => EF.Functions.ILike((x.LinhaAntiga ?? ""), $"%{s}%") || EF.Functions.ILike((x.LinhaNova ?? ""), $"%{s}%") || @@ -61,7 +64,16 @@ namespace line_gestao_api.Controllers EF.Functions.ILike((x.MobileLine.Cliente ?? ""), $"%{s}%") || EF.Functions.ILike((x.MobileLine.Usuario ?? ""), $"%{s}%") || EF.Functions.ILike((x.MobileLine.Skil ?? ""), $"%{s}%") || - EF.Functions.ILike(x.Item.ToString(), $"%{s}%")); + EF.Functions.ILike((x.MobileLine.Conta ?? ""), $"%{s}%") || + EF.Functions.ILike((x.MobileLine.Status ?? ""), $"%{s}%") || + EF.Functions.ILike((x.MobileLine.PlanoContrato ?? ""), $"%{s}%") || + EF.Functions.ILike((x.MobileLine.VencConta ?? ""), $"%{s}%") || + EF.Functions.ILike((x.MobileLine.Chip ?? ""), $"%{s}%") || + EF.Functions.ILike(x.Item.ToString(), $"%{s}%") || + (hasDateSearch && + x.DataDaMureg != null && + x.DataDaMureg.Value >= searchDateStartUtc && + x.DataDaMureg.Value < searchDateEndUtc)); } var total = await q.CountAsync(); @@ -384,6 +396,24 @@ namespace line_gestao_api.Controllers (v.Kind == DateTimeKind.Local ? v.ToUniversalTime() : DateTime.SpecifyKind(v, DateTimeKind.Utc)); } + private static bool TryParseSearchDateUtcStart(string value, out DateTime utcStart) + { + utcStart = default; + if (string.IsNullOrWhiteSpace(value)) return false; + + var s = value.Trim(); + DateTime parsed; + + if (DateTime.TryParse(s, new CultureInfo("pt-BR"), DateTimeStyles.None, out parsed) || + DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) + { + utcStart = DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc); + return true; + } + + return false; + } + private static string OnlyDigits(string? s) { if (string.IsNullOrWhiteSpace(s)) return ""; diff --git a/Controllers/ProfileController.cs b/Controllers/ProfileController.cs new file mode 100644 index 0000000..21776b8 --- /dev/null +++ b/Controllers/ProfileController.cs @@ -0,0 +1,264 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using line_gestao_api.Dtos; +using line_gestao_api.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Controllers; + +[ApiController] +[Route("api/profile")] +[Authorize] +public class ProfileController : ControllerBase +{ + private static readonly EmailAddressAttribute EmailValidator = new(); + private readonly UserManager _userManager; + + public ProfileController(UserManager userManager) + { + _userManager = userManager; + } + + [HttpGet("me")] + public async Task> GetMe() + { + var user = await GetAuthenticatedUserAsync(); + if (user == null) + { + return Unauthorized(new { message = "Usuário não autenticado." }); + } + + return Ok(ToProfileDto(user)); + } + + [HttpPatch] + public async Task> UpdateProfile([FromBody] UpdateProfileRequest req) + { + var user = await GetAuthenticatedUserAsync(); + if (user == null) + { + return Unauthorized(new { message = "Usuário não autenticado." }); + } + + var errors = await ValidateProfileUpdateAsync(user.Id, req); + if (errors.Count > 0) + { + return BadRequest(new ValidationErrorResponse { Errors = errors }); + } + + var nome = req.Nome.Trim(); + var email = req.Email.Trim().ToLowerInvariant(); + + user.Name = nome; + + if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase)) + { + var setEmailResult = await _userManager.SetEmailAsync(user, email); + if (!setEmailResult.Succeeded) + { + return BadRequest(ToValidationErrorResponse("email", setEmailResult.Errors)); + } + + var setUserNameResult = await _userManager.SetUserNameAsync(user, email); + if (!setUserNameResult.Succeeded) + { + return BadRequest(ToValidationErrorResponse("email", setUserNameResult.Errors)); + } + } + + var updateResult = await _userManager.UpdateAsync(user); + if (!updateResult.Succeeded) + { + return BadRequest(ToValidationErrorResponse("perfil", updateResult.Errors)); + } + + return Ok(ToProfileDto(user)); + } + + [HttpPost("change-password")] + public async Task ChangePassword([FromBody] ChangeMyPasswordRequest req) + { + var errors = ValidatePasswordChange(req); + if (errors.Count > 0) + { + return BadRequest(new ValidationErrorResponse { Errors = errors }); + } + + var user = await GetAuthenticatedUserAsync(); + if (user == null) + { + return Unauthorized(new { message = "Usuário não autenticado." }); + } + + var result = await _userManager.ChangePasswordAsync(user, req.CredencialAtual, req.NovaCredencial); + if (!result.Succeeded) + { + return BadRequest(MapPasswordChangeErrors(result.Errors)); + } + + return NoContent(); + } + + private async Task GetAuthenticatedUserAsync() + { + var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub"); + + if (!Guid.TryParse(userIdRaw, out var userId)) + { + return null; + } + + return await _userManager.Users + .FirstOrDefaultAsync(u => u.Id == userId && u.IsActive); + } + + private async Task> ValidateProfileUpdateAsync(Guid userId, UpdateProfileRequest req) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(req.Nome) || req.Nome.Trim().Length < 2) + { + errors.Add(new ValidationErrorDto + { + Field = "nome", + Message = "Nome é obrigatório e deve ter pelo menos 2 caracteres." + }); + } + + if (string.IsNullOrWhiteSpace(req.Email)) + { + errors.Add(new ValidationErrorDto + { + Field = "email", + Message = "Email é obrigatório." + }); + } + else + { + var email = req.Email.Trim().ToLowerInvariant(); + if (!EmailValidator.IsValid(email)) + { + errors.Add(new ValidationErrorDto + { + Field = "email", + Message = "Email inválido." + }); + } + else + { + var normalized = _userManager.NormalizeEmail(email); + var exists = await _userManager.Users + .AnyAsync(u => u.Id != userId && u.NormalizedEmail == normalized); + + if (exists) + { + errors.Add(new ValidationErrorDto + { + Field = "email", + Message = "E-mail já cadastrado." + }); + } + } + } + + return errors; + } + + private static List ValidatePasswordChange(ChangeMyPasswordRequest req) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(req.CredencialAtual)) + { + errors.Add(new ValidationErrorDto + { + Field = "credencialAtual", + Message = "Credencial atual é obrigatória." + }); + } + + if (string.IsNullOrWhiteSpace(req.NovaCredencial)) + { + errors.Add(new ValidationErrorDto + { + Field = "novaCredencial", + Message = "Nova credencial é obrigatória." + }); + } + else if (req.NovaCredencial.Length < 8) + { + errors.Add(new ValidationErrorDto + { + Field = "novaCredencial", + Message = "Nova credencial deve ter pelo menos 8 caracteres." + }); + } + + if (string.IsNullOrWhiteSpace(req.ConfirmarNovaCredencial)) + { + errors.Add(new ValidationErrorDto + { + Field = "confirmarNovaCredencial", + Message = "Confirmação da nova credencial é obrigatória." + }); + } + else if (!string.Equals(req.NovaCredencial, req.ConfirmarNovaCredencial, StringComparison.Ordinal)) + { + errors.Add(new ValidationErrorDto + { + Field = "confirmarNovaCredencial", + Message = "Confirmação da nova credencial inválida." + }); + } + + return errors; + } + + private static ValidationErrorResponse ToValidationErrorResponse(string field, IEnumerable identityErrors) + { + return new ValidationErrorResponse + { + Errors = identityErrors.Select(e => new ValidationErrorDto + { + Field = field, + Message = e.Description + }).ToList() + }; + } + + private static ValidationErrorResponse MapPasswordChangeErrors(IEnumerable identityErrors) + { + var errors = identityErrors.Select(e => + { + var field = string.Equals(e.Code, "PasswordMismatch", StringComparison.OrdinalIgnoreCase) + ? "credencialAtual" + : "novaCredencial"; + + var message = string.Equals(e.Code, "PasswordMismatch", StringComparison.OrdinalIgnoreCase) + ? "Credencial atual inválida." + : e.Description; + + return new ValidationErrorDto + { + Field = field, + Message = message + }; + }).ToList(); + + return new ValidationErrorResponse { Errors = errors }; + } + + private static ProfileMeDto ToProfileDto(ApplicationUser user) + { + return new ProfileMeDto + { + Id = user.Id, + Nome = user.Name, + Email = user.Email ?? string.Empty + }; + } +} diff --git a/Controllers/RelatoriosController.cs b/Controllers/RelatoriosController.cs index b849aaf..bf4827e 100644 --- a/Controllers/RelatoriosController.cs +++ b/Controllers/RelatoriosController.cs @@ -21,9 +21,10 @@ namespace line_gestao_api.Controllers [HttpGet("dashboard")] public async Task> GetDashboard() { - var today = DateTime.UtcNow.Date; - var last30 = today.AddDays(-30); - var limit30 = today.AddDays(30); + 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 minUtc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -86,7 +87,9 @@ namespace line_gestao_api.Controllers var totalMuregs = await qMureg.CountAsync(); var muregsUltimos30 = await qMureg.CountAsync(x => - x.DataDaMureg != null && x.DataDaMureg.Value.Date >= last30); + x.DataDaMureg != null && + x.DataDaMureg.Value >= last30UtcStart && + x.DataDaMureg.Value < tomorrowUtcStart); var muregsRecentes = await qMureg .OrderByDescending(x => x.DataDaMureg ?? minUtc) @@ -105,7 +108,7 @@ namespace line_gestao_api.Controllers }) .ToListAsync(); - var serieMureg12 = await BuildSerieUltimos12Meses_Mureg(today); + var serieMureg12 = await BuildSerieUltimos12Meses_Mureg(todayUtcStart); // ========================= // TROCA DE NÚMERO @@ -115,7 +118,9 @@ namespace line_gestao_api.Controllers var totalTrocas = await qTroca.CountAsync(); var trocasUltimos30 = await qTroca.CountAsync(x => - x.DataTroca != null && x.DataTroca.Value.Date >= last30); + x.DataTroca != null && + x.DataTroca.Value >= last30UtcStart && + x.DataTroca.Value < tomorrowUtcStart); var trocasRecentes = await qTroca .OrderByDescending(x => x.DataTroca ?? minUtc) @@ -133,7 +138,7 @@ namespace line_gestao_api.Controllers }) .ToListAsync(); - var serieTroca12 = await BuildSerieUltimos12Meses_Troca(today); + var serieTroca12 = await BuildSerieUltimos12Meses_Troca(todayUtcStart); // ========================= // VIGÊNCIA @@ -143,18 +148,18 @@ namespace line_gestao_api.Controllers var totalVig = await qVig.CountAsync(); var vigVencidos = await qVig.CountAsync(x => - x.DtTerminoFidelizacao != null && x.DtTerminoFidelizacao.Value.Date < today); + x.DtTerminoFidelizacao != null && x.DtTerminoFidelizacao.Value < todayUtcStart); var vigAVencer30 = await qVig.CountAsync(x => x.DtTerminoFidelizacao != null && - x.DtTerminoFidelizacao.Value.Date >= today && - x.DtTerminoFidelizacao.Value.Date <= limit30); + x.DtTerminoFidelizacao.Value >= todayUtcStart && + x.DtTerminoFidelizacao.Value < limit30ExclusiveUtcStart); // ✅ NOVO: série próximos 12 meses (mês/ano) - var serieVigProx12 = await BuildSerieProximos12Meses_VigenciaEncerramentos(today); + var serieVigProx12 = await BuildSerieProximos12Meses_VigenciaEncerramentos(todayUtcStart); // ✅ NOVO: buckets de supervisão - var vigBuckets = await BuildVigenciaBuckets(today); + var vigBuckets = await BuildVigenciaBuckets(todayUtcStart); // ========================= // USER DATA diff --git a/Controllers/ResumoController.cs b/Controllers/ResumoController.cs index 415e70d..c8a94e7 100644 --- a/Controllers/ResumoController.cs +++ b/Controllers/ResumoController.cs @@ -49,6 +49,8 @@ public class ResumoController : ControllerBase var reservaTotalEntity = await _db.ResumoReservaTotals.AsNoTracking() .FirstOrDefaultAsync(); + var gbDistribuicaoTotalEntity = await _db.ResumoGbDistribuicaoTotais.AsNoTracking() + .FirstOrDefaultAsync(); var canonicalTotalLinhas = await _spreadsheetImportAuditService.GetCanonicalTotalLinhasForReadAsync(); var response = new ResumoResponseDto @@ -135,6 +137,20 @@ public class ResumoController : ControllerBase QtdLinhas = x.QtdLinhas }) .ToListAsync(), + GbDistribuicao = await _db.ResumoGbDistribuicoes.AsNoTracking() + .OrderBy(x => x.Gb) + .Select(x => new ResumoGbDistribuicaoDto + { + Gb = x.Gb, + Qtd = x.Qtd, + Soma = x.Soma + }) + .ToListAsync(), + GbDistribuicaoTotal = gbDistribuicaoTotalEntity == null ? null : new ResumoGbDistribuicaoTotalDto + { + TotalLinhas = gbDistribuicaoTotalEntity.TotalLinhas, + SomaTotal = gbDistribuicaoTotalEntity.SomaTotal + }, ReservaLines = reservaLines .Select(x => new ResumoReservaLineDto { diff --git a/Controllers/TrocaNumeroController.cs b/Controllers/TrocaNumeroController.cs index 09f3ef5..00e44f5 100644 --- a/Controllers/TrocaNumeroController.cs +++ b/Controllers/TrocaNumeroController.cs @@ -40,12 +40,19 @@ namespace line_gestao_api.Controllers if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); + var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc); + var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; q = q.Where(x => EF.Functions.ILike(x.LinhaAntiga ?? "", $"%{s}%") || EF.Functions.ILike(x.LinhaNova ?? "", $"%{s}%") || EF.Functions.ILike(x.ICCID ?? "", $"%{s}%") || EF.Functions.ILike(x.Motivo ?? "", $"%{s}%") || - EF.Functions.ILike(x.Observacao ?? "", $"%{s}%")); + EF.Functions.ILike(x.Observacao ?? "", $"%{s}%") || + EF.Functions.ILike(x.Item.ToString(), $"%{s}%") || + (hasDateSearch && + x.DataTroca != null && + x.DataTroca.Value >= searchDateStartUtc && + x.DataTroca.Value < searchDateEndUtc)); } var total = await q.CountAsync(); @@ -201,5 +208,23 @@ namespace line_gestao_api.Controllers foreach (var c in s) if (char.IsDigit(c)) sb.Append(c); return sb.ToString(); } + + private static bool TryParseSearchDateUtcStart(string value, out DateTime utcStart) + { + utcStart = default; + if (string.IsNullOrWhiteSpace(value)) return false; + + var s = value.Trim(); + DateTime parsed; + + if (DateTime.TryParse(s, new CultureInfo("pt-BR"), DateTimeStyles.None, out parsed) || + DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) + { + utcStart = DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc); + return true; + } + + return false; + } } } diff --git a/Controllers/UserDataController.cs b/Controllers/UserDataController.cs index 29187b1..40ad72c 100644 --- a/Controllers/UserDataController.cs +++ b/Controllers/UserDataController.cs @@ -4,6 +4,7 @@ using line_gestao_api.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Globalization; using System.Linq; namespace line_gestao_api.Controllers @@ -51,15 +52,26 @@ namespace line_gestao_api.Controllers if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); + var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc); + var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; q = q.Where(x => EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || + EF.Functions.ILike(x.TipoPessoa ?? "", $"%{s}%") || EF.Functions.ILike(x.Nome ?? "", $"%{s}%") || EF.Functions.ILike(x.RazaoSocial ?? "", $"%{s}%") || EF.Functions.ILike(x.Cpf ?? "", $"%{s}%") || EF.Functions.ILike(x.Cnpj ?? "", $"%{s}%") || + EF.Functions.ILike(x.Rg ?? "", $"%{s}%") || EF.Functions.ILike(x.Email ?? "", $"%{s}%") || - EF.Functions.ILike(x.Celular ?? "", $"%{s}%")); + EF.Functions.ILike(x.Celular ?? "", $"%{s}%") || + EF.Functions.ILike(x.Endereco ?? "", $"%{s}%") || + EF.Functions.ILike(x.TelefoneFixo ?? "", $"%{s}%") || + EF.Functions.ILike(x.Item.ToString(), $"%{s}%") || + (hasDateSearch && + x.DataNascimento != null && + x.DataNascimento.Value >= searchDateStartUtc && + x.DataNascimento.Value < searchDateEndUtc)); } var total = await q.CountAsync(); @@ -134,7 +146,26 @@ namespace line_gestao_api.Controllers if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); - q = q.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%")); + var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc); + var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; + q = q.Where(x => + EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || + EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || + EF.Functions.ILike(x.TipoPessoa ?? "", $"%{s}%") || + EF.Functions.ILike(x.Nome ?? "", $"%{s}%") || + EF.Functions.ILike(x.RazaoSocial ?? "", $"%{s}%") || + EF.Functions.ILike(x.Cpf ?? "", $"%{s}%") || + EF.Functions.ILike(x.Cnpj ?? "", $"%{s}%") || + EF.Functions.ILike(x.Rg ?? "", $"%{s}%") || + EF.Functions.ILike(x.Email ?? "", $"%{s}%") || + EF.Functions.ILike(x.Celular ?? "", $"%{s}%") || + EF.Functions.ILike(x.Endereco ?? "", $"%{s}%") || + EF.Functions.ILike(x.TelefoneFixo ?? "", $"%{s}%") || + EF.Functions.ILike(x.Item.ToString(), $"%{s}%") || + (hasDateSearch && + x.DataNascimento != null && + x.DataNascimento.Value >= searchDateStartUtc && + x.DataNascimento.Value < searchDateEndUtc)); } // ✅ 1. CÁLCULO DOS KPIS GERAIS (Baseado em todos os dados filtrados, sem paginação) @@ -407,5 +438,23 @@ namespace line_gestao_api.Controllers if (string.IsNullOrWhiteSpace(s)) return ""; return new string(s.Where(char.IsDigit).ToArray()); } + + private static bool TryParseSearchDateUtcStart(string value, out DateTime utcStart) + { + utcStart = default; + if (string.IsNullOrWhiteSpace(value)) return false; + + var s = value.Trim(); + DateTime parsed; + + if (DateTime.TryParse(s, new CultureInfo("pt-BR"), DateTimeStyles.None, out parsed) || + DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) + { + utcStart = DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc); + return true; + } + + return false; + } } } diff --git a/Controllers/VigenciaController.cs b/Controllers/VigenciaController.cs index 6aac67f..d470f2f 100644 --- a/Controllers/VigenciaController.cs +++ b/Controllers/VigenciaController.cs @@ -5,6 +5,7 @@ using line_gestao_api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Globalization; using System.Linq; namespace line_gestao_api.Controllers @@ -45,12 +46,24 @@ namespace line_gestao_api.Controllers if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); + var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc); + var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; + var hasTotalSearch = TryParseSearchDecimal(s, out var searchTotal); q = q.Where(x => EF.Functions.ILike(x.Conta ?? "", $"%{s}%") || EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || EF.Functions.ILike(x.Usuario ?? "", $"%{s}%") || - EF.Functions.ILike(x.PlanoContrato ?? "", $"%{s}%")); + EF.Functions.ILike(x.PlanoContrato ?? "", $"%{s}%") || + EF.Functions.ILike(x.Item.ToString(), $"%{s}%") || + (hasTotalSearch && x.Total != null && x.Total.Value == searchTotal) || + (hasDateSearch && + ((x.DtEfetivacaoServico != null && + x.DtEfetivacaoServico.Value >= searchDateStartUtc && + x.DtEfetivacaoServico.Value < searchDateEndUtc) || + (x.DtTerminoFidelizacao != null && + x.DtTerminoFidelizacao.Value >= searchDateStartUtc && + x.DtTerminoFidelizacao.Value < searchDateEndUtc)))); } var total = await q.CountAsync(); @@ -108,8 +121,8 @@ namespace line_gestao_api.Controllers page = page < 1 ? 1 : page; pageSize = pageSize < 1 ? 20 : pageSize; - var today = DateTime.UtcNow.Date; // UTC para evitar erro no PostgreSQL - var limit30 = today.AddDays(30); + var todayUtcStart = DateTime.SpecifyKind(DateTime.UtcNow.Date, DateTimeKind.Utc); + var limit30ExclusiveUtcStart = todayUtcStart.AddDays(31); // Query Base (Linhas) var q = _db.VigenciaLines.AsNoTracking() @@ -118,7 +131,25 @@ namespace line_gestao_api.Controllers if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); - q = q.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%")); + var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc); + var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; + var hasTotalSearch = TryParseSearchDecimal(s, out var searchTotal); + + q = q.Where(x => + EF.Functions.ILike(x.Conta ?? "", $"%{s}%") || + EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || + EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || + EF.Functions.ILike(x.Usuario ?? "", $"%{s}%") || + EF.Functions.ILike(x.PlanoContrato ?? "", $"%{s}%") || + EF.Functions.ILike(x.Item.ToString(), $"%{s}%") || + (hasTotalSearch && x.Total != null && x.Total.Value == searchTotal) || + (hasDateSearch && + ((x.DtEfetivacaoServico != null && + x.DtEfetivacaoServico.Value >= searchDateStartUtc && + x.DtEfetivacaoServico.Value < searchDateEndUtc) || + (x.DtTerminoFidelizacao != null && + x.DtTerminoFidelizacao.Value >= searchDateStartUtc && + x.DtTerminoFidelizacao.Value < searchDateEndUtc)))); } // ✅ CÁLCULO DOS KPIS GERAIS (Antes do agrupamento/paginação) @@ -128,7 +159,7 @@ namespace line_gestao_api.Controllers TotalLinhas = await q.CountAsync(), // Clientes distintos TotalClientes = await q.Select(x => x.Cliente).Distinct().CountAsync(), - TotalVencidos = await q.CountAsync(x => x.DtTerminoFidelizacao != null && x.DtTerminoFidelizacao.Value.Date < today), + TotalVencidos = await q.CountAsync(x => x.DtTerminoFidelizacao != null && x.DtTerminoFidelizacao.Value < todayUtcStart), ValorTotal = await q.SumAsync(x => x.Total ?? 0m) }; @@ -140,10 +171,10 @@ namespace line_gestao_api.Controllers Cliente = g.Key, Linhas = g.Count(), Total = g.Sum(x => x.Total ?? 0m), - Vencidos = g.Count(x => x.DtTerminoFidelizacao != null && x.DtTerminoFidelizacao.Value.Date < today), - AVencer30 = g.Count(x => x.DtTerminoFidelizacao != null && x.DtTerminoFidelizacao.Value.Date >= today && x.DtTerminoFidelizacao.Value.Date <= limit30), - ProximoVencimento = g.Where(x => x.DtTerminoFidelizacao >= today).Min(x => x.DtTerminoFidelizacao), - UltimoVencimento = g.Where(x => x.DtTerminoFidelizacao < today).Max(x => x.DtTerminoFidelizacao) + Vencidos = g.Count(x => x.DtTerminoFidelizacao != null && x.DtTerminoFidelizacao.Value < todayUtcStart), + AVencer30 = g.Count(x => x.DtTerminoFidelizacao != null && x.DtTerminoFidelizacao.Value >= todayUtcStart && x.DtTerminoFidelizacao.Value < limit30ExclusiveUtcStart), + ProximoVencimento = g.Where(x => x.DtTerminoFidelizacao >= todayUtcStart).Min(x => x.DtTerminoFidelizacao), + UltimoVencimento = g.Where(x => x.DtTerminoFidelizacao < todayUtcStart).Max(x => x.DtTerminoFidelizacao) }); // Contagem para paginação @@ -357,5 +388,33 @@ namespace line_gestao_api.Controllers if (string.IsNullOrWhiteSpace(s)) return ""; return new string(s.Where(char.IsDigit).ToArray()); } + + private static bool TryParseSearchDateUtcStart(string value, out DateTime utcStart) + { + utcStart = default; + if (string.IsNullOrWhiteSpace(value)) return false; + + var s = value.Trim(); + DateTime parsed; + + if (DateTime.TryParse(s, new CultureInfo("pt-BR"), DateTimeStyles.None, out parsed) || + DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) + { + utcStart = DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc); + return true; + } + + return false; + } + + private static bool TryParseSearchDecimal(string value, out decimal parsed) + { + parsed = 0m; + if (string.IsNullOrWhiteSpace(value)) return false; + + var s = value.Trim().Replace("R$", "", StringComparison.OrdinalIgnoreCase).Trim(); + return decimal.TryParse(s, NumberStyles.Any, new CultureInfo("pt-BR"), out parsed) || + decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out parsed); + } } } diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 5f9a698..0ce9824 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -58,6 +58,8 @@ public class AppDbContext : IdentityDbContext ResumoPlanoContratoResumos => Set(); public DbSet ResumoPlanoContratoTotals => Set(); public DbSet ResumoLineTotais => Set(); + public DbSet ResumoGbDistribuicoes => Set(); + public DbSet ResumoGbDistribuicaoTotais => Set(); public DbSet ResumoReservaLines => Set(); public DbSet ResumoReservaTotals => Set(); @@ -333,6 +335,8 @@ public class AppDbContext : IdentityDbContext().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); diff --git a/Dtos/CreateMobileLineDto.cs b/Dtos/CreateMobileLineDto.cs index 4661a26..933981f 100644 --- a/Dtos/CreateMobileLineDto.cs +++ b/Dtos/CreateMobileLineDto.cs @@ -33,6 +33,8 @@ namespace line_gestao_api.Dtos public DateTime? DataBloqueio { get; set; } public DateTime? DataEntregaOpera { get; set; } public DateTime? DataEntregaCliente { get; set; } + public DateTime? DtEfetivacaoServico { get; set; } + public DateTime? DtTerminoFidelizacao { get; set; } // ========================== // Responsáveis / Logística diff --git a/Dtos/GeralDashboardInsightsDto.cs b/Dtos/GeralDashboardInsightsDto.cs index f700225..a2597ad 100644 --- a/Dtos/GeralDashboardInsightsDto.cs +++ b/Dtos/GeralDashboardInsightsDto.cs @@ -21,6 +21,7 @@ namespace line_gestao_api.Dtos public class GeralDashboardVivoKpiDto { public int QtdLinhas { get; set; } + public decimal TotalFranquiaGb { get; set; } public decimal TotalBaseMensal { get; set; } public decimal TotalAdicionaisMensal { get; set; } public decimal TotalGeralMensal { get; set; } @@ -56,6 +57,7 @@ namespace line_gestao_api.Dtos public GeralDashboardChartDto LinhasPorFranquia { get; set; } = new(); public GeralDashboardChartDto AdicionaisPagosPorServico { get; set; } = new(); public GeralDashboardChartDto TravelMundo { get; set; } = new(); + public GeralDashboardChartDto TipoChip { get; set; } = new(); } public class GeralDashboardChartDto diff --git a/Dtos/MobileLineDtos.cs b/Dtos/MobileLineDtos.cs index 48f7329..a35a500 100644 --- a/Dtos/MobileLineDtos.cs +++ b/Dtos/MobileLineDtos.cs @@ -14,6 +14,15 @@ public string? Skil { get; set; } public string? Modalidade { get; set; } public string? VencConta { get; set; } + + // Campos para filtro deterministico de adicionais no frontend + 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 class MobileLineDetailDto @@ -53,6 +62,8 @@ public string? Solicitante { get; set; } public DateTime? DataEntregaOpera { get; set; } public DateTime? DataEntregaCliente { get; set; } + public DateTime? DtEfetivacaoServico { get; set; } + public DateTime? DtTerminoFidelizacao { get; set; } public string? VencConta { get; set; } public string? TipoDeChip { get; set; } } @@ -94,6 +105,8 @@ public string? Solicitante { get; set; } public DateTime? DataEntregaOpera { get; set; } public DateTime? DataEntregaCliente { get; set; } + public DateTime? DtEfetivacaoServico { get; set; } + public DateTime? DtTerminoFidelizacao { get; set; } public string? VencConta { get; set; } public string? TipoDeChip { get; set; } } diff --git a/Dtos/ProfileDtos.cs b/Dtos/ProfileDtos.cs new file mode 100644 index 0000000..350fefd --- /dev/null +++ b/Dtos/ProfileDtos.cs @@ -0,0 +1,21 @@ +namespace line_gestao_api.Dtos; + +public class ProfileMeDto +{ + public Guid Id { get; set; } + public string Nome { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} + +public class UpdateProfileRequest +{ + public string Nome { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} + +public class ChangeMyPasswordRequest +{ + public string CredencialAtual { get; set; } = string.Empty; + public string NovaCredencial { get; set; } = string.Empty; + public string ConfirmarNovaCredencial { get; set; } = string.Empty; +} diff --git a/Dtos/ResumoDtos.cs b/Dtos/ResumoDtos.cs index 9fc132b..d49f84e 100644 --- a/Dtos/ResumoDtos.cs +++ b/Dtos/ResumoDtos.cs @@ -10,6 +10,8 @@ public sealed class ResumoResponseDto public List PlanoContratoResumos { get; set; } = new(); public ResumoPlanoContratoTotalDto? PlanoContratoTotal { get; set; } public List LineTotais { get; set; } = new(); + public List GbDistribuicao { get; set; } = new(); + public ResumoGbDistribuicaoTotalDto? GbDistribuicaoTotal { get; set; } public List ReservaLines { get; set; } = new(); public List ReservaPorDdd { get; set; } = new(); public int? TotalGeralLinhasReserva { get; set; } @@ -85,6 +87,19 @@ public sealed class ResumoLineTotaisDto public int? QtdLinhas { get; set; } } +public sealed class ResumoGbDistribuicaoDto +{ + public decimal? Gb { get; set; } + public int? Qtd { get; set; } + public decimal? Soma { get; set; } +} + +public sealed class ResumoGbDistribuicaoTotalDto +{ + public int? TotalLinhas { get; set; } + public decimal? SomaTotal { get; set; } +} + public sealed class ResumoReservaLineDto { public string? Ddd { get; set; } diff --git a/Migrations/20260214120000_AddResumoGbDistribuicao.Designer.cs b/Migrations/20260214120000_AddResumoGbDistribuicao.Designer.cs new file mode 100644 index 0000000..f31c6ca --- /dev/null +++ b/Migrations/20260214120000_AddResumoGbDistribuicao.Designer.cs @@ -0,0 +1,19 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260214120000_AddResumoGbDistribuicao")] + partial class AddResumoGbDistribuicao + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + } + } +} diff --git a/Migrations/20260214120000_AddResumoGbDistribuicao.cs b/Migrations/20260214120000_AddResumoGbDistribuicao.cs new file mode 100644 index 0000000..47cf8ae --- /dev/null +++ b/Migrations/20260214120000_AddResumoGbDistribuicao.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + /// + public partial class AddResumoGbDistribuicao : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ResumoGbDistribuicoes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + Gb = table.Column(type: "numeric", nullable: true), + Qtd = table.Column(type: "integer", nullable: true), + Soma = table.Column(type: "numeric", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ResumoGbDistribuicoes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ResumoGbDistribuicaoTotais", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + TotalLinhas = table.Column(type: "integer", nullable: true), + SomaTotal = table.Column(type: "numeric", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ResumoGbDistribuicaoTotais", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ResumoGbDistribuicoes"); + + migrationBuilder.DropTable( + name: "ResumoGbDistribuicaoTotais"); + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index 8d21a62..0d13ba2 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -990,6 +990,61 @@ namespace line_gestao_api.Migrations b.ToTable("ResumoClienteEspeciais"); }); + modelBuilder.Entity("line_gestao_api.Models.ResumoGbDistribuicao", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Gb") + .HasColumnType("numeric"); + + b.Property("Qtd") + .HasColumnType("integer"); + + b.Property("Soma") + .HasColumnType("numeric"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ResumoGbDistribuicoes"); + }); + + modelBuilder.Entity("line_gestao_api.Models.ResumoGbDistribuicaoTotal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SomaTotal") + .HasColumnType("numeric"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalLinhas") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ResumoGbDistribuicaoTotais"); + }); + modelBuilder.Entity("line_gestao_api.Models.ResumoLineTotais", b => { b.Property("Id") diff --git a/Models/ResumoGbDistribuicao.cs b/Models/ResumoGbDistribuicao.cs new file mode 100644 index 0000000..ef4cfa2 --- /dev/null +++ b/Models/ResumoGbDistribuicao.cs @@ -0,0 +1,15 @@ +namespace line_gestao_api.Models; + +public class ResumoGbDistribuicao : ITenantEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TenantId { get; set; } + + public decimal? Gb { get; set; } + public int? Qtd { get; set; } + public decimal? Soma { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Models/ResumoGbDistribuicaoTotal.cs b/Models/ResumoGbDistribuicaoTotal.cs new file mode 100644 index 0000000..a2efdc9 --- /dev/null +++ b/Models/ResumoGbDistribuicaoTotal.cs @@ -0,0 +1,14 @@ +namespace line_gestao_api.Models; + +public class ResumoGbDistribuicaoTotal : ITenantEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TenantId { get; set; } + + public int? TotalLinhas { get; set; } + public decimal? SomaTotal { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Services/GeralDashboardInsightsService.cs b/Services/GeralDashboardInsightsService.cs index 9e29ed0..dc705ed 100644 --- a/Services/GeralDashboardInsightsService.cs +++ b/Services/GeralDashboardInsightsService.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using System.Text.RegularExpressions; using line_gestao_api.Data; using line_gestao_api.Dtos; @@ -14,6 +15,7 @@ namespace line_gestao_api.Services private const string ServiceSkeelo = "SKEELO"; private const string ServiceVivoNewsPlus = "VIVO NEWS PLUS"; private const string ServiceVivoTravelMundo = "VIVO TRAVEL MUNDO"; + private const string ServiceVivoSync = "VIVO SYNC"; private const string ServiceVivoGestaoDispositivo = "VIVO GESTÃO DISPOSITIVO"; private readonly AppDbContext _db; @@ -45,6 +47,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null), VivoBaseTotal = g.Sum(x => (x.ValorPlanoVivo != null || @@ -54,9 +57,22 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.ValorPlanoVivo ?? 0m) : 0m), + VivoFranquiaTotalGb = 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.VivoSync != null || + x.VivoGestaoDispositivo != null) + ? (x.FranquiaVivo ?? 0m) + : 0m), VivoAdicionaisTotal = g.Sum(x => (x.ValorPlanoVivo != null || x.FranquiaVivo != null || @@ -65,9 +81,10 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.GestaoVozDados ?? 0m) + (x.Skeelo ?? 0m) + (x.VivoNewsPlus ?? 0m) + - (x.VivoTravelMundo ?? 0m) + (x.VivoGestaoDispositivo ?? 0m) + (x.VivoTravelMundo ?? 0m) + (x.VivoSync ?? 0m) + (x.VivoGestaoDispositivo ?? 0m) : 0m), VivoMinBase = g.Where(x => x.ValorPlanoVivo != null || @@ -77,6 +94,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) .Select(x => x.ValorPlanoVivo ?? 0m) .DefaultIfEmpty() @@ -89,41 +107,14 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != 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), + TravelCom = g.Count(x => (x.VivoTravelMundo ?? 0m) > 0m), + TravelSem = g.Count(x => (x.VivoTravelMundo ?? 0m) <= 0m), + TravelTotal = g.Sum(x => x.VivoTravelMundo ?? 0m), PaidGestaoVozDados = g.Count(x => (x.ValorPlanoVivo != null || x.FranquiaVivo != null || @@ -132,6 +123,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.GestaoVozDados ?? 0m) > 0m), PaidSkeelo = g.Count(x => @@ -142,6 +134,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.Skeelo ?? 0m) > 0m), PaidNews = g.Count(x => @@ -152,6 +145,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoNewsPlus ?? 0m) > 0m), PaidTravel = g.Count(x => @@ -162,6 +156,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoTravelMundo ?? 0m) > 0m), PaidGestaoDispositivo = g.Count(x => @@ -172,8 +167,20 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoGestaoDispositivo ?? 0m) > 0m), + PaidSync = 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.VivoSync != null || + x.VivoGestaoDispositivo != null) && + (x.VivoSync ?? 0m) > 0m), NotPaidGestaoVozDados = g.Count(x => (x.ValorPlanoVivo != null || x.FranquiaVivo != null || @@ -182,6 +189,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.GestaoVozDados ?? 0m) <= 0m), NotPaidSkeelo = g.Count(x => @@ -192,6 +200,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.Skeelo ?? 0m) <= 0m), NotPaidNews = g.Count(x => @@ -202,6 +211,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoNewsPlus ?? 0m) <= 0m), NotPaidTravel = g.Count(x => @@ -212,6 +222,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoTravelMundo ?? 0m) <= 0m), NotPaidGestaoDispositivo = g.Count(x => @@ -222,8 +233,20 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoGestaoDispositivo ?? 0m) <= 0m), + NotPaidSync = 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.VivoSync != null || + x.VivoGestaoDispositivo != null) && + (x.VivoSync ?? 0m) <= 0m), TotalLinesWithAnyPaidAdditional = g.Count(x => (x.ValorPlanoVivo != null || x.FranquiaVivo != null || @@ -232,11 +255,13 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && ((x.GestaoVozDados ?? 0m) > 0m || (x.Skeelo ?? 0m) > 0m || (x.VivoNewsPlus ?? 0m) > 0m || (x.VivoTravelMundo ?? 0m) > 0m || + (x.VivoSync ?? 0m) > 0m || (x.VivoGestaoDispositivo ?? 0m) > 0m)), TotalLinesWithNoPaidAdditional = g.Count(x => (x.ValorPlanoVivo != null || @@ -246,11 +271,13 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.GestaoVozDados ?? 0m) <= 0m && (x.Skeelo ?? 0m) <= 0m && (x.VivoNewsPlus ?? 0m) <= 0m && (x.VivoTravelMundo ?? 0m) <= 0m && + (x.VivoSync ?? 0m) <= 0m && (x.VivoGestaoDispositivo ?? 0m) <= 0m), TotalGestaoVozDados = g.Sum(x => (x.ValorPlanoVivo != null || @@ -260,6 +287,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.GestaoVozDados ?? 0m) : 0m), @@ -271,6 +299,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.Skeelo ?? 0m) : 0m), @@ -282,6 +311,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.VivoNewsPlus ?? 0m) : 0m), @@ -293,6 +323,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.VivoTravelMundo ?? 0m) : 0m), @@ -304,8 +335,21 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.VivoGestaoDispositivo ?? 0m) + : 0m), + TotalSync = 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.VivoSync != null || + x.VivoGestaoDispositivo != null) + ? (x.VivoSync ?? 0m) : 0m) }) .FirstOrDefaultAsync(); @@ -315,6 +359,8 @@ namespace line_gestao_api.Services .ToListAsync(); var linhasPorFranquia = BuildFranquiaBuckets(franquiasRaw); + var tipoChip = await qLines.Select(x => x.TipoDeChip).ToListAsync(); + var tipoChipBuckets = BuildTipoChipBuckets(tipoChip); var clientGroupsRaw = await qLines .Where(x => x.Cliente != null && x.Cliente != "") @@ -336,27 +382,10 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != 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), + TravelCom = g.Count(x => (x.VivoTravelMundo ?? 0m) > 0m), + TravelSem = g.Count(x => (x.VivoTravelMundo ?? 0m) <= 0m), PaidGestaoVozDados = g.Count(x => (x.ValorPlanoVivo != null || x.FranquiaVivo != null || @@ -365,6 +394,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.GestaoVozDados ?? 0m) > 0m), PaidSkeelo = g.Count(x => @@ -375,6 +405,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.Skeelo ?? 0m) > 0m), PaidNews = g.Count(x => @@ -385,6 +416,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoNewsPlus ?? 0m) > 0m), PaidTravel = g.Count(x => @@ -395,6 +427,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoTravelMundo ?? 0m) > 0m), PaidGestaoDispositivo = g.Count(x => @@ -405,8 +438,20 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoGestaoDispositivo ?? 0m) > 0m), + PaidSync = 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.VivoSync != null || + x.VivoGestaoDispositivo != null) && + (x.VivoSync ?? 0m) > 0m), NotPaidGestaoVozDados = g.Count(x => (x.ValorPlanoVivo != null || x.FranquiaVivo != null || @@ -415,6 +460,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.GestaoVozDados ?? 0m) <= 0m), NotPaidSkeelo = g.Count(x => @@ -425,6 +471,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.Skeelo ?? 0m) <= 0m), NotPaidNews = g.Count(x => @@ -435,6 +482,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoNewsPlus ?? 0m) <= 0m), NotPaidTravel = g.Count(x => @@ -445,6 +493,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoTravelMundo ?? 0m) <= 0m), NotPaidGestaoDispositivo = g.Count(x => @@ -455,8 +504,20 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) && (x.VivoGestaoDispositivo ?? 0m) <= 0m), + NotPaidSync = 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.VivoSync != null || + x.VivoGestaoDispositivo != null) && + (x.VivoSync ?? 0m) <= 0m), TotalGestaoVozDados = g.Sum(x => (x.ValorPlanoVivo != null || x.FranquiaVivo != null || @@ -465,6 +526,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.GestaoVozDados ?? 0m) : 0m), @@ -476,6 +538,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.Skeelo ?? 0m) : 0m), @@ -487,6 +550,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.VivoNewsPlus ?? 0m) : 0m), @@ -498,6 +562,7 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.VivoTravelMundo ?? 0m) : 0m), @@ -509,8 +574,21 @@ namespace line_gestao_api.Services x.Skeelo != null || x.VivoNewsPlus != null || x.VivoTravelMundo != null || + x.VivoSync != null || x.VivoGestaoDispositivo != null) ? (x.VivoGestaoDispositivo ?? 0m) + : 0m), + TotalSync = 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.VivoSync != null || + x.VivoGestaoDispositivo != null) + ? (x.VivoSync ?? 0m) : 0m) }) .OrderBy(x => x.Cliente) @@ -519,48 +597,13 @@ namespace line_gestao_api.Services var dto = new GeralDashboardInsightsDto { Kpis = BuildKpis(totals), - Charts = BuildCharts(totals, linhasPorFranquia), + Charts = BuildCharts(totals, linhasPorFranquia, tipoChipBuckets), ClientGroups = BuildClientGroups(clientGroupsRaw) }; - dto.Kpis.TotalLinhas = await ResolveCanonicalTotalLinhasAsync(dto.Kpis.TotalLinhas); - return dto; } - private async Task ResolveCanonicalTotalLinhasAsync(int fallback) - { - try - { - var fromLatestRun = await _db.ImportAuditRuns - .AsNoTracking() - .OrderByDescending(x => x.ImportedAt) - .ThenByDescending(x => x.Id) - .Select(x => (int?)x.CanonicalTotalLinhas) - .FirstOrDefaultAsync(); - - if (fromLatestRun.HasValue && fromLatestRun.Value > 0) - { - return fromLatestRun.Value; - } - } - catch - { - // Fallback para ambientes em que a migration ainda não foi aplicada. - } - - var fromMaxItem = await _db.MobileLines - .AsNoTracking() - .MaxAsync(x => (int?)x.Item); - - if (fromMaxItem.HasValue && fromMaxItem.Value > 0) - { - return fromMaxItem.Value; - } - - return fallback; - } - private static GeralDashboardKpisDto BuildKpis(TotalsProjection? totals) { if (totals == null) @@ -583,6 +626,7 @@ namespace line_gestao_api.Services Vivo = new GeralDashboardVivoKpiDto { QtdLinhas = totals.VivoLinhas, + TotalFranquiaGb = totals.VivoFranquiaTotalGb, TotalBaseMensal = totals.VivoBaseTotal, TotalAdicionaisMensal = totals.VivoAdicionaisTotal, TotalGeralMensal = totalGeralMensal, @@ -606,6 +650,7 @@ namespace line_gestao_api.Services 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 = ServiceVivoSync, CountLines = totals.PaidSync, TotalValue = totals.TotalSync }, new() { ServiceName = ServiceVivoGestaoDispositivo, CountLines = totals.PaidGestaoDispositivo, TotalValue = totals.TotalGestaoDispositivo } }, ServicesNotPaid = new List @@ -614,13 +659,14 @@ namespace line_gestao_api.Services 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 = ServiceVivoSync, CountLines = totals.NotPaidSync, TotalValue = 0m }, new() { ServiceName = ServiceVivoGestaoDispositivo, CountLines = totals.NotPaidGestaoDispositivo, TotalValue = 0m } } } }; } - private static GeralDashboardChartsDto BuildCharts(TotalsProjection? totals, FranquiaBuckets franquias) + private static GeralDashboardChartsDto BuildCharts(TotalsProjection? totals, FranquiaBuckets franquias, TipoChipBuckets tipoChip) { var adicionaisLabels = new List { @@ -628,28 +674,31 @@ namespace line_gestao_api.Services ServiceSkeelo, ServiceVivoNewsPlus, ServiceVivoTravelMundo, + ServiceVivoSync, ServiceVivoGestaoDispositivo }; var adicionaisValues = totals == null - ? new List { 0, 0, 0, 0, 0 } + ? new List { 0, 0, 0, 0, 0, 0 } : new List { totals.PaidGestaoVozDados, totals.PaidSkeelo, totals.PaidNews, totals.PaidTravel, + totals.PaidSync, totals.PaidGestaoDispositivo }; var adicionaisTotals = totals == null - ? new List { 0m, 0m, 0m, 0m, 0m } + ? new List { 0m, 0m, 0m, 0m, 0m, 0m } : new List { totals.TotalGestaoVozDados, totals.TotalSkeelo, totals.TotalNews, totals.TotalTravel, + totals.TotalSync, totals.TotalGestaoDispositivo }; @@ -672,6 +721,11 @@ namespace line_gestao_api.Services Values = totals == null ? new List { 0, 0 } : new List { totals.TravelCom, totals.TravelSem } + }, + TipoChip = new GeralDashboardChartDto + { + Labels = tipoChip.Labels, + Values = tipoChip.Values } }; } @@ -702,6 +756,8 @@ namespace line_gestao_api.Services 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 = ServiceVivoSync, Value = row.PaidSync.ToString(PtBr) }, + new() { Label = $"R$ {ServiceVivoSync}", Value = FormatCurrency(row.TotalSync) }, new() { Label = ServiceVivoGestaoDispositivo, Value = row.PaidGestaoDispositivo.ToString(PtBr) }, new() { Label = $"R$ {ServiceVivoGestaoDispositivo}", Value = FormatCurrency(row.TotalGestaoDispositivo) } }; @@ -737,6 +793,13 @@ namespace line_gestao_api.Services TotalValue = row.TotalTravel }, new() + { + ServiceName = ServiceVivoSync, + PaidCount = row.PaidSync, + NotPaidCount = row.NotPaidSync, + TotalValue = row.TotalSync + }, + new() { ServiceName = ServiceVivoGestaoDispositivo, PaidCount = row.PaidGestaoDispositivo, @@ -761,6 +824,79 @@ namespace line_gestao_api.Services return list; } + private static TipoChipBuckets BuildTipoChipBuckets(IEnumerable chipTypes) + { + var esim = 0; + var simcard = 0; + + foreach (var raw in chipTypes) + { + var normalized = NormalizeChipType(raw); + if (normalized == "ESIM") + { + esim++; + continue; + } + + if (normalized == "SIMCARD") + { + simcard++; + } + } + + return new TipoChipBuckets + { + Labels = new List { "e-SIM", "SIMCARD" }, + Values = new List { esim, simcard } + }; + } + + private static string NormalizeChipType(string? value) + { + var normalized = NormalizeText(value); + if (string.IsNullOrWhiteSpace(normalized)) + { + return string.Empty; + } + + if (normalized.Contains("ESIM")) + { + return "ESIM"; + } + + if (normalized.Contains("SIM") || + normalized.Contains("CHIP") || + normalized.Contains("CARD") || + normalized.Contains("FISIC")) + { + return "SIMCARD"; + } + + return string.Empty; + } + + private static string NormalizeText(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + + var decomposed = value + .Trim() + .ToUpperInvariant() + .Normalize(NormalizationForm.FormD); + + var builder = new StringBuilder(decomposed.Length); + foreach (var ch in decomposed) + { + if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.NonSpacingMark) + continue; + + if (char.IsLetterOrDigit(ch)) + builder.Append(ch); + } + + return builder.ToString(); + } + private static FranquiaBuckets BuildFranquiaBuckets(IEnumerable rows) { var map = new Dictionary(); @@ -885,6 +1021,12 @@ namespace line_gestao_api.Services public List Values { get; set; } = new(); } + private sealed class TipoChipBuckets + { + public List Labels { get; set; } = new(); + public List Values { get; set; } = new(); + } + private sealed class FranquiaProjection { public decimal? FranquiaVivo { get; set; } @@ -897,6 +1039,7 @@ namespace line_gestao_api.Services public int TotalAtivas { get; set; } public int TotalBloqueados { get; set; } public int VivoLinhas { get; set; } + public decimal VivoFranquiaTotalGb { get; set; } public decimal VivoBaseTotal { get; set; } public decimal VivoAdicionaisTotal { get; set; } public decimal VivoMinBase { get; set; } @@ -909,11 +1052,13 @@ namespace line_gestao_api.Services public int PaidNews { get; set; } public int PaidTravel { get; set; } public int PaidGestaoDispositivo { get; set; } + public int PaidSync { 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 NotPaidSync { get; set; } public int TotalLinesWithAnyPaidAdditional { get; set; } public int TotalLinesWithNoPaidAdditional { get; set; } public decimal TotalGestaoVozDados { get; set; } @@ -921,6 +1066,7 @@ namespace line_gestao_api.Services public decimal TotalNews { get; set; } public decimal TotalTravel { get; set; } public decimal TotalGestaoDispositivo { get; set; } + public decimal TotalSync { get; set; } } private sealed class ClientGroupProjection @@ -937,16 +1083,19 @@ namespace line_gestao_api.Services public int PaidNews { get; set; } public int PaidTravel { get; set; } public int PaidGestaoDispositivo { get; set; } + public int PaidSync { 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 NotPaidSync { 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; } + public decimal TotalSync { get; set; } } } } diff --git a/Services/VigenciaNotificationBackgroundService.cs b/Services/VigenciaNotificationBackgroundService.cs index c4f6517..01ed00c 100644 --- a/Services/VigenciaNotificationBackgroundService.cs +++ b/Services/VigenciaNotificationBackgroundService.cs @@ -96,7 +96,7 @@ public class VigenciaNotificationBackgroundService : BackgroundService private async Task ProcessTenantAsync(AppDbContext db, Guid tenantId, CancellationToken stoppingToken) { - var today = DateTime.UtcNow.Date; + var today = DateTime.SpecifyKind(DateTime.UtcNow.Date, DateTimeKind.Utc); var reminderDays = _options.ReminderDays .Distinct() .Where(d => d > 0) @@ -132,7 +132,7 @@ public class VigenciaNotificationBackgroundService : BackgroundService continue; } - var endDate = vigencia.DtTerminoFidelizacao.Value.Date; + var endDate = DateTime.SpecifyKind(vigencia.DtTerminoFidelizacao.Value.Date, DateTimeKind.Utc); var usuario = vigencia.Usuario?.Trim(); var cliente = vigencia.Cliente?.Trim(); var linha = vigencia.Linha?.Trim(); @@ -198,14 +198,32 @@ public class VigenciaNotificationBackgroundService : BackgroundService var candidateTipos = candidates.Select(c => c.Tipo).Distinct().ToList(); var candidateDates = candidates .Where(c => c.ReferenciaData.HasValue) - .Select(c => c.ReferenciaData!.Value.Date) + .Select(c => DateTime.SpecifyKind(c.ReferenciaData!.Value.Date, DateTimeKind.Utc)) .Distinct() .ToList(); - var existingNotifications = await db.Notifications.AsNoTracking() - .Where(n => n.TenantId == tenantId) - .Where(n => candidateTipos.Contains(n.Tipo)) - .Where(n => n.ReferenciaData != null && candidateDates.Contains(n.ReferenciaData.Value.Date)) - .ToListAsync(stoppingToken); + + List existingNotifications = new(); + if (candidateDates.Count > 0) + { + var minCandidateUtc = candidateDates.Min(); + var maxCandidateUtcExclusive = candidateDates.Max().AddDays(1); + var candidateDateKeys = candidateDates + .Select(d => d.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)) + .ToHashSet(); + + existingNotifications = await db.Notifications.AsNoTracking() + .Where(n => n.TenantId == tenantId) + .Where(n => candidateTipos.Contains(n.Tipo)) + .Where(n => n.ReferenciaData != null && + n.ReferenciaData.Value >= minCandidateUtc && + n.ReferenciaData.Value < maxCandidateUtcExclusive) + .ToListAsync(stoppingToken); + + existingNotifications = existingNotifications + .Where(n => n.ReferenciaData != null && + candidateDateKeys.Contains(n.ReferenciaData.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))) + .ToList(); + } var existingSet = new HashSet(existingNotifications.Select(n => BuildDedupKey( @@ -260,7 +278,7 @@ public class VigenciaNotificationBackgroundService : BackgroundService continue; } - var endDate = vigencia.DtTerminoFidelizacao.Value.Date; + var endDate = DateTime.SpecifyKind(vigencia.DtTerminoFidelizacao.Value.Date, DateTimeKind.Utc); if (endDate < today) { if (notification.Tipo != "Vencido") diff --git a/appsettings.json b/appsettings.json index dcdcbdd..7b7b12f 100644 --- a/appsettings.json +++ b/appsettings.json @@ -6,7 +6,7 @@ "Key": "vI8/oEYEWN5sBDTisNuZFjZAl+YFvXEJ96POb73/eoq3NaFPkOFXyPRdf/HWGAFnUsF3e3QpYL6Wl4Bc2v+l3g==", "Issuer": "LineGestao", "Audience": "LineGestao", - "ExpiresMinutes": 180 + "ExpiresMinutes": 360 }, "Notifications": { "CheckIntervalMinutes": 60, diff --git a/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs b/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs index 9ea088c..1ff2f1b 100644 --- a/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs +++ b/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs @@ -89,6 +89,49 @@ namespace line_gestao_api.Tests Assert.NotEmpty(result.Charts.AdicionaisPagosPorServico.Labels); } + [Fact] + public async Task GetInsightsAsync_TravelZeroIsCountedAsSemTravel() + { + var tenantId = Guid.NewGuid(); + var provider = new TestTenantProvider(tenantId); + var db = BuildContext(provider); + + db.MobileLines.AddRange( + new MobileLine + { + TenantId = tenantId, + Cliente = "Cliente Travel", + Status = "Ativo", + ValorPlanoVivo = 120m, + VivoTravelMundo = 0m + }, + new MobileLine + { + TenantId = tenantId, + Cliente = "Cliente Travel", + Status = "Ativo", + ValorPlanoVivo = 120m, + VivoTravelMundo = 15m + }, + new MobileLine + { + TenantId = tenantId, + Cliente = "Cliente Travel", + Status = "Ativo", + ValorPlanoVivo = null, + VivoTravelMundo = null + }); + + await db.SaveChangesAsync(); + + var service = new GeralDashboardInsightsService(db); + var result = await service.GetInsightsAsync(); + + Assert.Equal(1, result.Kpis.TravelMundo.ComTravel); + Assert.Equal(2, result.Kpis.TravelMundo.SemTravel); + Assert.Equal(new[] { 1, 2 }, result.Charts.TravelMundo.Values); + } + private static AppDbContext BuildContext(TestTenantProvider provider) { var options = new DbContextOptionsBuilder()