From c830812af36342f0bb5f4171b04059cd9c51222e Mon Sep 17 00:00:00 2001 From: Eduardo Date: Fri, 19 Dec 2025 17:21:50 -0300 Subject: [PATCH] Feat: Adiciona endpoints de agrupamento, listagem de clientes e filtros no LinesController --- Controllers/LinesController.cs | 249 ++++++++++++++++++++------------- Dtos/ClientGroupDto.cs | 10 ++ Dtos/MobileLineDtos.cs | 38 ++++- 3 files changed, 196 insertions(+), 101 deletions(-) create mode 100644 Dtos/ClientGroupDto.cs diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index f657a7e..7e61ad5 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -1,8 +1,7 @@ using ClosedXML.Excel; using line_gestao_api.Data; -using line_gestao_api.Dtos; +using line_gestao_api.Dtos; // Certifique-se que ClientGroupDto está neste namespace using line_gestao_api.Models; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Globalization; @@ -12,7 +11,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/[controller]")] - //[Authorize] + //[Authorize] // Descomente se estiver usando autenticação public class LinesController : ControllerBase { private readonly AppDbContext _db; @@ -22,15 +21,63 @@ namespace line_gestao_api.Controllers _db = db; } - // ✅ DTO do form (pra Swagger entender multipart/form-data) + // Classe auxiliar apenas para o upload (não é DTO de banco) public class ImportExcelForm { public IFormFile File { get; set; } = default!; } + // ========================================================== + // ✅ 1. NOVO ENDPOINT: AGRUPAR POR CLIENTE (Resumo para Aba 'Todos') + // ========================================================== + [HttpGet("groups")] + public async Task>> GetClientGroups() + { + // Agrupa por nome do cliente e calcula os totais + var groups = await _db.MobileLines + .AsNoTracking() + .Where(x => !string.IsNullOrEmpty(x.Cliente)) + .GroupBy(x => x.Cliente) + .Select(g => new ClientGroupDto + { + Cliente = g.Key!, + TotalLinhas = g.Count(), + // Conta quantos contêm "ativo" (ignorando maiúsculas/minúsculas) + Ativos = g.Count(x => EF.Functions.ILike(x.Status ?? "", "%ativo%")), + // Conta quantos contêm "bloque" ou similar + Bloqueados = g.Count(x => EF.Functions.ILike(x.Status ?? "", "%bloque%") || EF.Functions.ILike(x.Status ?? "", "%perda%") || EF.Functions.ILike(x.Status ?? "", "%roubo%")) + }) + .OrderBy(x => x.Cliente) + .ToListAsync(); + + return Ok(groups); + } + + // ========================================================== + // ✅ 2. ENDPOINT: LISTAR NOMES DE CLIENTES (Para o Dropdown) + // ========================================================== + [HttpGet("clients")] + public async Task>> GetClients() + { + var clients = await _db.MobileLines + .AsNoTracking() + .Select(x => x.Cliente) + .Where(x => !string.IsNullOrEmpty(x)) + .Distinct() + .OrderBy(x => x) + .ToListAsync(); + + return Ok(clients); + } + + // ========================================================== + // ✅ 3. GET ALL (TABELA PRINCIPAL - Com todos os filtros) + // ========================================================== [HttpGet] public async Task>> GetAll( [FromQuery] string? search, + [FromQuery] string? skil, // Filtro PF/PJ + [FromQuery] string? client, // Filtro Cliente Específico [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? sortBy = "item", @@ -41,6 +88,21 @@ namespace line_gestao_api.Controllers var q = _db.MobileLines.AsNoTracking(); + // 1. Filtro por SKIL (PF/PJ) + if (!string.IsNullOrWhiteSpace(skil)) + { + var sSkil = skil.Trim(); + q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); + } + + // 2. Filtro por Cliente Específico (usado no dropdown e no accordion) + if (!string.IsNullOrWhiteSpace(client)) + { + var sClient = client.Trim(); + q = q.Where(x => EF.Functions.ILike(x.Cliente ?? "", sClient)); + } + + // 3. Busca Genérica (Barra de pesquisa) if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); @@ -55,48 +117,26 @@ namespace line_gestao_api.Controllers var total = await q.CountAsync(); - // ===== ORDENAÇÃO COMPLETA ===== + // Ordenação var sb = (sortBy ?? "item").Trim().ToLowerInvariant(); var desc = string.Equals((sortDir ?? "asc").Trim(), "desc", StringComparison.OrdinalIgnoreCase); - // sinônimos (pra não quebrar se vier diferente do front) 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), - - "linha" => desc ? q.OrderByDescending(x => x.Linha ?? "").ThenBy(x => x.Item) - : q.OrderBy(x => x.Linha ?? "").ThenBy(x => x.Item), - - "chip" => desc ? q.OrderByDescending(x => x.Chip ?? "").ThenBy(x => x.Item) - : q.OrderBy(x => x.Chip ?? "").ThenBy(x => x.Item), - - "cliente" => desc ? q.OrderByDescending(x => x.Cliente ?? "").ThenBy(x => x.Item) - : q.OrderBy(x => x.Cliente ?? "").ThenBy(x => x.Item), - - "usuario" => desc ? q.OrderByDescending(x => x.Usuario ?? "").ThenBy(x => x.Item) - : q.OrderBy(x => x.Usuario ?? "").ThenBy(x => x.Item), - - "planocontrato" => desc ? q.OrderByDescending(x => x.PlanoContrato ?? "").ThenBy(x => x.Item) - : q.OrderBy(x => x.PlanoContrato ?? "").ThenBy(x => x.Item), - - "vencconta" => desc ? q.OrderByDescending(x => x.VencConta ?? "").ThenBy(x => x.Item) - : q.OrderBy(x => x.VencConta ?? "").ThenBy(x => x.Item), - - "status" => desc ? q.OrderByDescending(x => x.Status ?? "").ThenBy(x => x.Item) - : q.OrderBy(x => x.Status ?? "").ThenBy(x => x.Item), - - "skil" => desc ? q.OrderByDescending(x => x.Skil ?? "").ThenBy(x => x.Item) - : q.OrderBy(x => x.Skil ?? "").ThenBy(x => x.Item), - - "modalidade" => desc ? q.OrderByDescending(x => x.Modalidade ?? "").ThenBy(x => x.Item) - : q.OrderBy(x => x.Modalidade ?? "").ThenBy(x => x.Item), - - _ => desc ? q.OrderByDescending(x => x.Item) - : q.OrderBy(x => x.Item) + "conta" => desc ? q.OrderByDescending(x => x.Conta ?? "").ThenBy(x => x.Item) : q.OrderBy(x => x.Conta ?? "").ThenBy(x => x.Item), + "linha" => desc ? q.OrderByDescending(x => x.Linha ?? "").ThenBy(x => x.Item) : q.OrderBy(x => x.Linha ?? "").ThenBy(x => x.Item), + "chip" => desc ? q.OrderByDescending(x => x.Chip ?? "").ThenBy(x => x.Item) : q.OrderBy(x => x.Chip ?? "").ThenBy(x => x.Item), + "cliente" => desc ? q.OrderByDescending(x => x.Cliente ?? "").ThenBy(x => x.Item) : q.OrderBy(x => x.Cliente ?? "").ThenBy(x => x.Item), + "usuario" => desc ? q.OrderByDescending(x => x.Usuario ?? "").ThenBy(x => x.Item) : q.OrderBy(x => x.Usuario ?? "").ThenBy(x => x.Item), + "planocontrato" => desc ? q.OrderByDescending(x => x.PlanoContrato ?? "").ThenBy(x => x.Item) : q.OrderBy(x => x.PlanoContrato ?? "").ThenBy(x => x.Item), + "vencconta" => desc ? q.OrderByDescending(x => x.VencConta ?? "").ThenBy(x => x.Item) : q.OrderBy(x => x.VencConta ?? "").ThenBy(x => x.Item), + "status" => desc ? q.OrderByDescending(x => x.Status ?? "").ThenBy(x => x.Item) : q.OrderBy(x => x.Status ?? "").ThenBy(x => x.Item), + "skil" => desc ? q.OrderByDescending(x => x.Skil ?? "").ThenBy(x => x.Item) : q.OrderBy(x => x.Skil ?? "").ThenBy(x => x.Item), + "modalidade" => desc ? q.OrderByDescending(x => x.Modalidade ?? "").ThenBy(x => x.Item) : q.OrderBy(x => x.Modalidade ?? "").ThenBy(x => x.Item), + _ => desc ? q.OrderByDescending(x => x.Item) : q.OrderBy(x => x.Item) }; var items = await q @@ -128,6 +168,9 @@ namespace line_gestao_api.Controllers }); } + // ========================================================== + // OBTER DETALHES POR ID + // ========================================================== [HttpGet("{id:guid}")] public async Task> GetById(Guid id) { @@ -137,19 +180,43 @@ namespace line_gestao_api.Controllers return Ok(ToDetailDto(x)); } + // ========================================================== + // ATUALIZAR (PUT) + // ========================================================== [HttpPut("{id:guid}")] public async Task Update(Guid id, [FromBody] UpdateMobileLineRequest req) { var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); if (x == null) return NotFound(); + var newLinha = OnlyDigits(req.Linha); + var newChip = OnlyDigits(req.Chip); + + // Verifica duplicidade de linha (se alterou a linha) + if (!string.IsNullOrWhiteSpace(newLinha) && + !string.Equals((x.Linha ?? ""), newLinha, StringComparison.Ordinal)) + { + var exists = await _db.MobileLines.AsNoTracking() + .AnyAsync(m => m.Linha == newLinha && m.Id != id); + + if (exists) + { + return Conflict(new + { + message = "Já existe um registro com essa LINHA. Não é possível salvar duplicado.", + linha = newLinha + }); + } + } + + // Atualiza campos x.Item = req.Item; - x.Conta = req.Conta; - x.Linha = req.Linha; - x.Chip = req.Chip; - x.Cliente = req.Cliente; - x.Usuario = req.Usuario; - x.PlanoContrato = req.PlanoContrato; + x.Conta = req.Conta?.Trim(); + x.Linha = string.IsNullOrWhiteSpace(newLinha) ? null : newLinha; + x.Chip = string.IsNullOrWhiteSpace(newChip) ? null : newChip; + x.Cliente = req.Cliente?.Trim(); + x.Usuario = req.Usuario?.Trim(); + x.PlanoContrato = req.PlanoContrato?.Trim(); x.FranquiaVivo = req.FranquiaVivo; x.ValorPlanoVivo = req.ValorPlanoVivo; @@ -168,27 +235,40 @@ namespace line_gestao_api.Controllers x.Desconto = req.Desconto; x.Lucro = req.Lucro; - x.Status = req.Status; + x.Status = req.Status?.Trim(); x.DataBloqueio = ToUtc(req.DataBloqueio); - x.Skil = req.Skil; - x.Modalidade = req.Modalidade; - x.Cedente = req.Cedente; - x.Solicitante = req.Solicitante; + x.Skil = req.Skil?.Trim(); + x.Modalidade = req.Modalidade?.Trim(); + x.Cedente = req.Cedente?.Trim(); + x.Solicitante = req.Solicitante?.Trim(); x.DataEntregaOpera = ToUtc(req.DataEntregaOpera); x.DataEntregaCliente = ToUtc(req.DataEntregaCliente); - x.VencConta = req.VencConta; + x.VencConta = req.VencConta?.Trim(); - // regra RESERVA ApplyReservaRule(x); x.UpdatedAt = DateTime.UtcNow; - await _db.SaveChangesAsync(); + + try + { + await _db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + return Conflict(new + { + message = "Conflito ao salvar. Verifique se a LINHA já existe em outro registro." + }); + } return NoContent(); } + // ========================================================== + // DELETAR (DELETE) + // ========================================================== [HttpDelete("{id:guid}")] public async Task Delete(Guid id) { @@ -200,6 +280,9 @@ namespace line_gestao_api.Controllers return NoContent(); } + // ========================================================== + // IMPORTAR EXCEL + // ========================================================== [HttpPost("import-excel")] [Consumes("multipart/form-data")] [RequestSizeLimit(50_000_000)] @@ -217,14 +300,12 @@ namespace line_gestao_api.Controllers if (ws == null) return BadRequest("Aba 'GERAL' não encontrada."); - // acha a linha do cabeçalho (onde existe ITÉM) var headerRow = ws.RowsUsed().FirstOrDefault(r => r.CellsUsed().Any(c => NormalizeHeader(c.GetString()) == "ITEM")); if (headerRow == null) return BadRequest("Cabeçalho da planilha (linha com 'ITÉM') não encontrado."); - // mapa header -> coluna var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var cell in headerRow.CellsUsed()) { @@ -238,7 +319,7 @@ namespace line_gestao_api.Controllers var startRow = headerRow.RowNumber() + 1; - // REPLACE: apaga tudo e reimporta (pra espelhar 100% o Excel) + // Opção: Deletar tudo antes de importar (Cuidado em produção!) await _db.MobileLines.ExecuteDeleteAsync(); var imported = 0; @@ -252,15 +333,12 @@ namespace line_gestao_api.Controllers var entity = new MobileLine { Item = TryInt(itemStr), - Conta = GetCellByHeader(ws, r, map, "CONTA"), Linha = OnlyDigits(GetCellByHeader(ws, r, map, "LINHA")), Chip = OnlyDigits(GetCellByHeader(ws, r, map, "CHIP")), - Cliente = GetCellByHeader(ws, r, map, "CLIENTE"), Usuario = GetCellByHeader(ws, r, map, "USUARIO"), PlanoContrato = GetCellByHeader(ws, r, map, "PLANO CONTRATO"), - FranquiaVivo = TryDecimal(GetCellByHeader(ws, r, map, "FRAQUIA")), ValorPlanoVivo = TryDecimal(GetCellByHeader(ws, r, map, "VALOR DO PLANO R$")), GestaoVozDados = TryDecimal(GetCellByHeader(ws, r, map, "GESTAO VOZ E DADOS R$")), @@ -269,30 +347,24 @@ namespace line_gestao_api.Controllers VivoTravelMundo = TryDecimal(GetCellByHeader(ws, r, map, "VIVO TRAVEL MUNDO")), VivoGestaoDispositivo = TryDecimal(GetCellByHeader(ws, r, map, "VIVO GESTAO DISPOSITIVO")), ValorContratoVivo = TryDecimal(GetCellByHeader(ws, r, map, "VALOR CONTRATO VIVO")), - FranquiaLine = TryDecimal(GetCellByHeader(ws, r, map, "FRANQUIA LINE")), FranquiaGestao = TryDecimal(GetCellByHeader(ws, r, map, "FRANQUIA GESTAO")), LocacaoAp = TryDecimal(GetCellByHeader(ws, r, map, "LOCACAO AP.")), ValorContratoLine = TryDecimal(GetCellByHeader(ws, r, map, "VALOR CONTRATO LINE")), - Desconto = TryDecimal(GetCellByHeader(ws, r, map, "DESCONTO")), Lucro = TryDecimal(GetCellByHeader(ws, r, map, "LUCRO")), - Status = GetCellByHeader(ws, r, map, "STATUS"), DataBloqueio = TryDate(ws, r, map, "DATA DO BLOQUEIO"), - Skil = GetCellByHeader(ws, r, map, "SKIL"), Modalidade = GetCellByHeader(ws, r, map, "MODALIDADE"), Cedente = GetCellByHeader(ws, r, map, "CEDENTE"), Solicitante = GetCellByHeader(ws, r, map, "SOLICITANTE"), - DataEntregaOpera = TryDate(ws, r, map, "DATA DA ENTREGA OPERA."), DataEntregaCliente = TryDate(ws, r, map, "DATA DA ENTREGA CLIENTE"), VencConta = GetCellByHeader(ws, r, map, "VENC. DA CONTA"), }; ApplyReservaRule(entity); - buffer.Add(entity); imported++; @@ -313,19 +385,19 @@ namespace line_gestao_api.Controllers return Ok(new ImportResultDto { Imported = imported }); } - // ================= helpers ================= + // ========================================================== + // HELPERS + // ========================================================== private static DateTime? ToUtc(DateTime? dt) { if (dt == null) return null; - var v = dt.Value; - return v.Kind switch { DateTimeKind.Utc => v, DateTimeKind.Local => v.ToUniversalTime(), - _ => DateTime.SpecifyKind(v, DateTimeKind.Utc) // Unspecified -> UTC (sem shift) + _ => DateTime.SpecifyKind(v, DateTimeKind.Utc) }; } @@ -339,7 +411,6 @@ namespace line_gestao_api.Controllers Cliente = x.Cliente, Usuario = x.Usuario, PlanoContrato = x.PlanoContrato, - FranquiaVivo = x.FranquiaVivo, ValorPlanoVivo = x.ValorPlanoVivo, GestaoVozDados = x.GestaoVozDados, @@ -348,15 +419,12 @@ namespace line_gestao_api.Controllers VivoTravelMundo = x.VivoTravelMundo, VivoGestaoDispositivo = x.VivoGestaoDispositivo, ValorContratoVivo = x.ValorContratoVivo, - FranquiaLine = x.FranquiaLine, FranquiaGestao = x.FranquiaGestao, LocacaoAp = x.LocacaoAp, ValorContratoLine = x.ValorContratoLine, - Desconto = x.Desconto, Lucro = x.Lucro, - Status = x.Status, DataBloqueio = x.DataBloqueio, Skil = x.Skil, @@ -372,7 +440,6 @@ namespace line_gestao_api.Controllers { var cliente = (x.Cliente ?? "").Trim(); var usuario = (x.Usuario ?? "").Trim(); - if (cliente.Equals("RESERVA", StringComparison.OrdinalIgnoreCase) || usuario.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) { @@ -382,8 +449,7 @@ namespace line_gestao_api.Controllers } } - private static int GetCol(Dictionary map, string name) - => map.TryGetValue(NormalizeHeader(name), out var c) ? c : 0; + private static int GetCol(Dictionary map, string name) => map.TryGetValue(NormalizeHeader(name), out var c) ? c : 0; private static string GetCellByHeader(IXLWorksheet ws, int row, Dictionary map, string header) { @@ -406,7 +472,6 @@ namespace line_gestao_api.Controllers if (!map.TryGetValue(key, out var col)) return null; var cell = ws.Cell(row, col); - if (cell.DataType == XLDataType.DateTime) return ToUtc(cell.GetDateTime()); @@ -425,28 +490,19 @@ namespace line_gestao_api.Controllers private static decimal? TryDecimal(string? s) { if (string.IsNullOrWhiteSpace(s)) return null; - - // remove "R$", espaços etc. s = s.Replace("R$", "", StringComparison.OrdinalIgnoreCase).Trim(); - - if (decimal.TryParse(s, NumberStyles.Any, new CultureInfo("pt-BR"), out var d)) - return d; - - if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out d)) - return d; - + if (decimal.TryParse(s, NumberStyles.Any, new CultureInfo("pt-BR"), out var d)) return d; + if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out d)) return d; return null; } - private static int TryInt(string s) - => int.TryParse(OnlyDigits(s), out var n) ? n : 0; + private static int TryInt(string s) => int.TryParse(OnlyDigits(s), out var n) ? n : 0; private static string OnlyDigits(string? s) { if (string.IsNullOrWhiteSpace(s)) return ""; var sb = new StringBuilder(); - foreach (var ch in s) - if (char.IsDigit(ch)) sb.Append(ch); + foreach (var ch in s) if (char.IsDigit(ch)) sb.Append(ch); return sb.ToString(); } @@ -454,8 +510,6 @@ namespace line_gestao_api.Controllers { if (string.IsNullOrWhiteSpace(s)) return ""; s = s.Trim().ToUpperInvariant(); - - // remove acentos var formD = s.Normalize(NormalizationForm.FormD); var sb = new StringBuilder(); foreach (var ch in formD) @@ -463,16 +517,13 @@ namespace line_gestao_api.Controllers sb.Append(ch); s = sb.ToString().Normalize(NormalizationForm.FormC); + s = s.Replace("ITEM", "ITEM") + .Replace("USUARIO", "USUARIO") + .Replace("GESTAO", "GESTAO") + .Replace("LOCACAO", "LOCACAO"); - // normalizações pra casar com a planilha - s = s.Replace("ITÉM", "ITEM") - .Replace("USUÁRIO", "USUARIO") - .Replace("GESTÃO", "GESTAO") - .Replace("LOCAÇÃO", "LOCACAO"); - - // remove espaços duplicados s = string.Join(" ", s.Split(' ', StringSplitOptions.RemoveEmptyEntries)); return s; } } -} +} \ No newline at end of file diff --git a/Dtos/ClientGroupDto.cs b/Dtos/ClientGroupDto.cs new file mode 100644 index 0000000..63204de --- /dev/null +++ b/Dtos/ClientGroupDto.cs @@ -0,0 +1,10 @@ +namespace line_gestao_api.Dtos +{ + public class ClientGroupDto + { + public string Cliente { get; set; } = string.Empty; + public int TotalLinhas { get; set; } + public int Ativos { get; set; } + public int Bloqueados { get; set; } + } +} diff --git a/Dtos/MobileLineDtos.cs b/Dtos/MobileLineDtos.cs index 5edaa33..247d860 100644 --- a/Dtos/MobileLineDtos.cs +++ b/Dtos/MobileLineDtos.cs @@ -55,9 +55,43 @@ public string? VencConta { get; set; } } - public class UpdateMobileLineRequest : MobileLineDetailDto + // ✅ UPDATE REQUEST (SEM Id) + public class UpdateMobileLineRequest { - // reaproveita os campos; Id vem na rota + public int Item { get; set; } + public string? Conta { get; set; } + public string? Linha { get; set; } + public string? Chip { get; set; } + public string? Cliente { get; set; } + public string? Usuario { get; set; } + public string? PlanoContrato { get; set; } + + public decimal? FranquiaVivo { get; set; } + public decimal? ValorPlanoVivo { get; set; } + public decimal? GestaoVozDados { get; set; } + public decimal? Skeelo { get; set; } + public decimal? VivoNewsPlus { get; set; } + public decimal? VivoTravelMundo { get; set; } + public decimal? VivoGestaoDispositivo { get; set; } + public decimal? ValorContratoVivo { get; set; } + + public decimal? FranquiaLine { get; set; } + public decimal? FranquiaGestao { get; set; } + public decimal? LocacaoAp { get; set; } + public decimal? ValorContratoLine { get; set; } + + public decimal? Desconto { get; set; } + public decimal? Lucro { get; set; } + + public string? Status { get; set; } + public DateTime? DataBloqueio { get; set; } + public string? Skil { get; set; } + public string? Modalidade { get; set; } + public string? Cedente { get; set; } + public string? Solicitante { get; set; } + public DateTime? DataEntregaOpera { get; set; } + public DateTime? DataEntregaCliente { get; set; } + public string? VencConta { get; set; } } public class ImportResultDto