From b4a5525578e28f38acfe90bac52dfc0ef46339a0 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Wed, 17 Dec 2025 18:03:07 -0300 Subject: [PATCH 1/3] Ajustes API: import excel, CORS/Swagger e endpoints lines --- Controllers/LinesController.cs | 478 ++++++++++++++++++ Data/AppDbContext.cs | 9 + Dtos/ImportExcelForm.cs | 9 + Dtos/MobileLineDtos.cs | 67 +++ Dtos/PagedResult.cs | 10 + .../20251217170445_AddMobileLines.Designer.cs | 188 +++++++ Migrations/20251217170445_AddMobileLines.cs | 71 +++ Migrations/AppDbContextModelSnapshot.cs | 122 +++++ Models/MobileLine.cs | 67 +++ Program.cs | 19 +- line-gestao-api.csproj | 1 + 11 files changed, 1035 insertions(+), 6 deletions(-) create mode 100644 Controllers/LinesController.cs create mode 100644 Dtos/ImportExcelForm.cs create mode 100644 Dtos/MobileLineDtos.cs create mode 100644 Dtos/PagedResult.cs create mode 100644 Migrations/20251217170445_AddMobileLines.Designer.cs create mode 100644 Migrations/20251217170445_AddMobileLines.cs create mode 100644 Models/MobileLine.cs diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs new file mode 100644 index 0000000..f657a7e --- /dev/null +++ b/Controllers/LinesController.cs @@ -0,0 +1,478 @@ +using ClosedXML.Excel; +using line_gestao_api.Data; +using line_gestao_api.Dtos; +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 +{ + [ApiController] + [Route("api/[controller]")] + //[Authorize] + public class LinesController : ControllerBase + { + private readonly AppDbContext _db; + + public LinesController(AppDbContext db) + { + _db = db; + } + + // ✅ DTO do form (pra Swagger entender multipart/form-data) + public class ImportExcelForm + { + public IFormFile File { get; set; } = default!; + } + + [HttpGet] + public async Task>> GetAll( + [FromQuery] string? search, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? sortBy = "item", + [FromQuery] string? sortDir = "asc") + { + page = page < 1 ? 1 : page; + pageSize = pageSize < 1 ? 20 : pageSize; + + var q = _db.MobileLines.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim(); + q = q.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 total = await q.CountAsync(); + + // ===== ORDENAÇÃO COMPLETA ===== + 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) + }; + + var items = await q + .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 + }) + .ToListAsync(); + + return Ok(new PagedResult + { + Page = page, + PageSize = pageSize, + Total = total, + Items = items + }); + } + + [HttpGet("{id:guid}")] + public async Task> GetById(Guid id) + { + var x = await _db.MobileLines.AsNoTracking().FirstOrDefaultAsync(a => a.Id == id); + if (x == null) return NotFound(); + + return Ok(ToDetailDto(x)); + } + + [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(); + + 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.FranquiaVivo = req.FranquiaVivo; + x.ValorPlanoVivo = req.ValorPlanoVivo; + x.GestaoVozDados = req.GestaoVozDados; + x.Skeelo = req.Skeelo; + x.VivoNewsPlus = req.VivoNewsPlus; + x.VivoTravelMundo = req.VivoTravelMundo; + x.VivoGestaoDispositivo = req.VivoGestaoDispositivo; + x.ValorContratoVivo = req.ValorContratoVivo; + + x.FranquiaLine = req.FranquiaLine; + x.FranquiaGestao = req.FranquiaGestao; + x.LocacaoAp = req.LocacaoAp; + x.ValorContratoLine = req.ValorContratoLine; + + x.Desconto = req.Desconto; + x.Lucro = req.Lucro; + + x.Status = req.Status; + x.DataBloqueio = ToUtc(req.DataBloqueio); + + x.Skil = req.Skil; + x.Modalidade = req.Modalidade; + x.Cedente = req.Cedente; + x.Solicitante = req.Solicitante; + + x.DataEntregaOpera = ToUtc(req.DataEntregaOpera); + x.DataEntregaCliente = ToUtc(req.DataEntregaCliente); + x.VencConta = req.VencConta; + + // regra RESERVA + ApplyReservaRule(x); + + x.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + + return NoContent(); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id) + { + var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); + if (x == null) return NotFound(); + + _db.MobileLines.Remove(x); + await _db.SaveChangesAsync(); + return NoContent(); + } + + [HttpPost("import-excel")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(50_000_000)] + public async Task> ImportExcel([FromForm] ImportExcelForm form) + { + var file = form.File; + + if (file == null || file.Length == 0) + return BadRequest("Arquivo inválido."); + + using var stream = file.OpenReadStream(); + using var wb = new XLWorkbook(stream); + + var ws = wb.Worksheets.FirstOrDefault(w => w.Name.Trim().Equals("GERAL", StringComparison.OrdinalIgnoreCase)); + 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()) + { + var key = NormalizeHeader(cell.GetString()); + if (!string.IsNullOrWhiteSpace(key) && !map.ContainsKey(key)) + map[key] = cell.Address.ColumnNumber; + } + + int colItem = GetCol(map, "ITEM"); + if (colItem == 0) return BadRequest("Coluna 'ITÉM' não encontrada."); + + var startRow = headerRow.RowNumber() + 1; + + // REPLACE: apaga tudo e reimporta (pra espelhar 100% o Excel) + await _db.MobileLines.ExecuteDeleteAsync(); + + var imported = 0; + var buffer = new List(600); + + for (int r = startRow; r <= ws.LastRowUsed().RowNumber(); r++) + { + var itemStr = GetCellString(ws, r, colItem); + if (string.IsNullOrWhiteSpace(itemStr)) break; + + 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$")), + Skeelo = TryDecimal(GetCellByHeader(ws, r, map, "SKEELO")), + VivoNewsPlus = TryDecimal(GetCellByHeader(ws, r, map, "VIVO NEWS PLUS")), + 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++; + + if (buffer.Count >= 500) + { + await _db.MobileLines.AddRangeAsync(buffer); + await _db.SaveChangesAsync(); + buffer.Clear(); + } + } + + if (buffer.Count > 0) + { + await _db.MobileLines.AddRangeAsync(buffer); + await _db.SaveChangesAsync(); + } + + return Ok(new ImportResultDto { Imported = imported }); + } + + // ================= 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) + }; + } + + private static MobileLineDetailDto ToDetailDto(MobileLine x) => new() + { + Id = x.Id, + Item = x.Item, + Conta = x.Conta, + Linha = x.Linha, + Chip = x.Chip, + Cliente = x.Cliente, + Usuario = x.Usuario, + PlanoContrato = x.PlanoContrato, + + FranquiaVivo = x.FranquiaVivo, + ValorPlanoVivo = x.ValorPlanoVivo, + GestaoVozDados = x.GestaoVozDados, + Skeelo = x.Skeelo, + VivoNewsPlus = x.VivoNewsPlus, + 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, + Modalidade = x.Modalidade, + Cedente = x.Cedente, + Solicitante = x.Solicitante, + DataEntregaOpera = x.DataEntregaOpera, + DataEntregaCliente = x.DataEntregaCliente, + VencConta = x.VencConta + }; + + private static void ApplyReservaRule(MobileLine x) + { + var cliente = (x.Cliente ?? "").Trim(); + var usuario = (x.Usuario ?? "").Trim(); + + if (cliente.Equals("RESERVA", StringComparison.OrdinalIgnoreCase) || + usuario.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) + { + x.Cliente = "RESERVA"; + x.Usuario = "RESERVA"; + x.Skil = "RESERVA"; + } + } + + 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) + { + var key = NormalizeHeader(header); + if (!map.TryGetValue(key, out var col)) return ""; + return GetCellString(ws, row, col); + } + + private static string GetCellString(IXLWorksheet ws, int row, int col) + { + var cell = ws.Cell(row, col); + if (cell == null) return ""; + var v = cell.GetValue() ?? ""; + return v.Trim(); + } + + private static DateTime? TryDate(IXLWorksheet ws, int row, Dictionary map, string header) + { + var key = NormalizeHeader(header); + if (!map.TryGetValue(key, out var col)) return null; + + var cell = ws.Cell(row, col); + + if (cell.DataType == XLDataType.DateTime) + return ToUtc(cell.GetDateTime()); + + var s = cell.GetValue()?.Trim(); + if (string.IsNullOrWhiteSpace(s)) return null; + + if (DateTime.TryParse(s, new CultureInfo("pt-BR"), DateTimeStyles.None, out var d)) + return ToUtc(d); + + if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out d)) + return ToUtc(d); + + return null; + } + + 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; + + return null; + } + + 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); + return sb.ToString(); + } + + private static string NormalizeHeader(string? s) + { + 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) + if (System.Globalization.CharUnicodeInfo.GetUnicodeCategory(ch) != System.Globalization.UnicodeCategory.NonSpacingMark) + sb.Append(ch); + + s = sb.ToString().Normalize(NormalizationForm.FormC); + + // 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; + } + } +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index d45530a..bb7499b 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -9,12 +9,21 @@ public class AppDbContext : DbContext public DbSet Users => Set(); + // ✅ NOVO: tabela para espelhar a planilha (GERAL) + public DbSet MobileLines => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + // ✅ MANTIDO: índice único do User (não mexi em nada aqui) modelBuilder.Entity() .HasIndex(u => u.Email) .IsUnique(); + + // ✅ NOVO: índice único para evitar duplicar a mesma linha (telefone) + modelBuilder.Entity() + .HasIndex(x => x.Linha) + .IsUnique(); } } diff --git a/Dtos/ImportExcelForm.cs b/Dtos/ImportExcelForm.cs new file mode 100644 index 0000000..7e39a13 --- /dev/null +++ b/Dtos/ImportExcelForm.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Http; + +namespace line_gestao_api.Dtos +{ + public class ImportExcelForm + { + public IFormFile File { get; set; } = default!; + } +} diff --git a/Dtos/MobileLineDtos.cs b/Dtos/MobileLineDtos.cs new file mode 100644 index 0000000..5edaa33 --- /dev/null +++ b/Dtos/MobileLineDtos.cs @@ -0,0 +1,67 @@ +namespace line_gestao_api.Dtos +{ + public class MobileLineListDto + { + public Guid Id { get; set; } + public int Item { get; set; } + public string? Conta { get; set; } + public string? Linha { get; set; } + public string? Chip { get; set; } + public string? Cliente { get; set; } + public string? Usuario { get; set; } + public string? PlanoContrato { get; set; } + public string? Status { get; set; } + public string? Skil { get; set; } + public string? Modalidade { get; set; } + public string? VencConta { get; set; } + } + + public class MobileLineDetailDto + { + public Guid Id { get; set; } + public int Item { get; set; } + public string? Conta { get; set; } + public string? Linha { get; set; } + public string? Chip { get; set; } + public string? Cliente { get; set; } + public string? Usuario { get; set; } + public string? 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 UpdateMobileLineRequest : MobileLineDetailDto + { + // reaproveita os campos; Id vem na rota + } + + public class ImportResultDto + { + public int Imported { get; set; } + } +} diff --git a/Dtos/PagedResult.cs b/Dtos/PagedResult.cs new file mode 100644 index 0000000..cebdcfd --- /dev/null +++ b/Dtos/PagedResult.cs @@ -0,0 +1,10 @@ +namespace line_gestao_api.Dtos +{ + public class PagedResult + { + public int Page { get; set; } + public int PageSize { get; set; } + public int Total { get; set; } + public List Items { get; set; } = new(); + } +} diff --git a/Migrations/20251217170445_AddMobileLines.Designer.cs b/Migrations/20251217170445_AddMobileLines.Designer.cs new file mode 100644 index 0000000..d4982d9 --- /dev/null +++ b/Migrations/20251217170445_AddMobileLines.Designer.cs @@ -0,0 +1,188 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251217170445_AddMobileLines")] + partial class AddMobileLines + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("line_gestao_api.Models.MobileLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cedente") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Chip") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Cliente") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Conta") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataBloqueio") + .HasColumnType("timestamp with time zone"); + + b.Property("DataEntregaCliente") + .HasColumnType("timestamp with time zone"); + + b.Property("DataEntregaOpera") + .HasColumnType("timestamp with time zone"); + + b.Property("Desconto") + .HasColumnType("numeric"); + + b.Property("FranquiaGestao") + .HasColumnType("numeric"); + + b.Property("FranquiaLine") + .HasColumnType("numeric"); + + b.Property("FranquiaVivo") + .HasColumnType("numeric"); + + b.Property("GestaoVozDados") + .HasColumnType("numeric"); + + b.Property("Item") + .HasColumnType("integer"); + + b.Property("Linha") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("LocacaoAp") + .HasColumnType("numeric"); + + b.Property("Lucro") + .HasColumnType("numeric"); + + b.Property("Modalidade") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlanoContrato") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Skeelo") + .HasColumnType("numeric"); + + b.Property("Skil") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Solicitante") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Status") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Usuario") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ValorContratoLine") + .HasColumnType("numeric"); + + b.Property("ValorContratoVivo") + .HasColumnType("numeric"); + + b.Property("ValorPlanoVivo") + .HasColumnType("numeric"); + + b.Property("VencConta") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("VivoGestaoDispositivo") + .HasColumnType("numeric"); + + b.Property("VivoNewsPlus") + .HasColumnType("numeric"); + + b.Property("VivoTravelMundo") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("Linha") + .IsUnique(); + + b.ToTable("MobileLines"); + }); + + modelBuilder.Entity("line_gestao_api.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20251217170445_AddMobileLines.cs b/Migrations/20251217170445_AddMobileLines.cs new file mode 100644 index 0000000..33b6ec3 --- /dev/null +++ b/Migrations/20251217170445_AddMobileLines.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + /// + public partial class AddMobileLines : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MobileLines", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Item = table.Column(type: "integer", nullable: false), + Conta = table.Column(type: "character varying(80)", maxLength: 80, nullable: true), + Linha = table.Column(type: "character varying(30)", maxLength: 30, nullable: true), + Chip = table.Column(type: "character varying(40)", maxLength: 40, nullable: true), + Cliente = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Usuario = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + PlanoContrato = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + FranquiaVivo = table.Column(type: "numeric", nullable: true), + ValorPlanoVivo = table.Column(type: "numeric", nullable: true), + GestaoVozDados = table.Column(type: "numeric", nullable: true), + Skeelo = table.Column(type: "numeric", nullable: true), + VivoNewsPlus = table.Column(type: "numeric", nullable: true), + VivoTravelMundo = table.Column(type: "numeric", nullable: true), + VivoGestaoDispositivo = table.Column(type: "numeric", nullable: true), + ValorContratoVivo = table.Column(type: "numeric", nullable: true), + FranquiaLine = table.Column(type: "numeric", nullable: true), + FranquiaGestao = table.Column(type: "numeric", nullable: true), + LocacaoAp = table.Column(type: "numeric", nullable: true), + ValorContratoLine = table.Column(type: "numeric", nullable: true), + Desconto = table.Column(type: "numeric", nullable: true), + Lucro = table.Column(type: "numeric", nullable: true), + Status = table.Column(type: "character varying(80)", maxLength: 80, nullable: true), + DataBloqueio = table.Column(type: "timestamp with time zone", nullable: true), + Skil = table.Column(type: "character varying(80)", maxLength: 80, nullable: true), + Modalidade = table.Column(type: "character varying(80)", maxLength: 80, nullable: true), + Cedente = table.Column(type: "character varying(150)", maxLength: 150, nullable: true), + Solicitante = table.Column(type: "character varying(150)", maxLength: 150, nullable: true), + DataEntregaOpera = table.Column(type: "timestamp with time zone", nullable: true), + DataEntregaCliente = table.Column(type: "timestamp with time zone", nullable: true), + VencConta = table.Column(type: "character varying(50)", maxLength: 50, 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_MobileLines", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_MobileLines_Linha", + table: "MobileLines", + column: "Linha", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MobileLines"); + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index c29a2bc..b45f421 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -22,6 +22,128 @@ namespace line_gestao_api.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("line_gestao_api.Models.MobileLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cedente") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Chip") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Cliente") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Conta") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataBloqueio") + .HasColumnType("timestamp with time zone"); + + b.Property("DataEntregaCliente") + .HasColumnType("timestamp with time zone"); + + b.Property("DataEntregaOpera") + .HasColumnType("timestamp with time zone"); + + b.Property("Desconto") + .HasColumnType("numeric"); + + b.Property("FranquiaGestao") + .HasColumnType("numeric"); + + b.Property("FranquiaLine") + .HasColumnType("numeric"); + + b.Property("FranquiaVivo") + .HasColumnType("numeric"); + + b.Property("GestaoVozDados") + .HasColumnType("numeric"); + + b.Property("Item") + .HasColumnType("integer"); + + b.Property("Linha") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("LocacaoAp") + .HasColumnType("numeric"); + + b.Property("Lucro") + .HasColumnType("numeric"); + + b.Property("Modalidade") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlanoContrato") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Skeelo") + .HasColumnType("numeric"); + + b.Property("Skil") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Solicitante") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Status") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Usuario") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ValorContratoLine") + .HasColumnType("numeric"); + + b.Property("ValorContratoVivo") + .HasColumnType("numeric"); + + b.Property("ValorPlanoVivo") + .HasColumnType("numeric"); + + b.Property("VencConta") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("VivoGestaoDispositivo") + .HasColumnType("numeric"); + + b.Property("VivoNewsPlus") + .HasColumnType("numeric"); + + b.Property("VivoTravelMundo") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("Linha") + .IsUnique(); + + b.ToTable("MobileLines"); + }); + modelBuilder.Entity("line_gestao_api.Models.User", b => { b.Property("Id") diff --git a/Models/MobileLine.cs b/Models/MobileLine.cs new file mode 100644 index 0000000..6a3320f --- /dev/null +++ b/Models/MobileLine.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; + +namespace line_gestao_api.Models +{ + public class MobileLine + { + public Guid Id { get; set; } = Guid.NewGuid(); + + // ===== Planilha (GERAL) ===== + public int Item { get; set; } // ITÉM + [MaxLength(80)] + public string? Conta { get; set; } // CONTA + [MaxLength(30)] + public string? Linha { get; set; } // LINHA (telefone) + [MaxLength(40)] + public string? Chip { get; set; } // CHIP + + [MaxLength(200)] + public string? Cliente { get; set; } // CLIENTE + [MaxLength(200)] + public string? Usuario { get; set; } // USUÁRIO + [MaxLength(200)] + public string? PlanoContrato { get; set; } // PLANO CONTRATO + + // ===== Valores Vivo (ROXO no modal do front) ===== + public decimal? FranquiaVivo { get; set; } // FRAQUIA + public decimal? ValorPlanoVivo { get; set; } // VALOR DO PLANO R$ + public decimal? GestaoVozDados { get; set; } // GESTÃO VOZ E DADOS R$ + public decimal? Skeelo { get; set; } // SKEELO + public decimal? VivoNewsPlus { get; set; } // VIVO NEWS PLUS + public decimal? VivoTravelMundo { get; set; } // VIVO TRAVEL MUNDO + public decimal? VivoGestaoDispositivo { get; set; } // VIVO GESTÃO DISPOSITIVO + public decimal? ValorContratoVivo { get; set; } // VALOR CONTRATO VIVO + + // ===== Valores Line Móvel (paleta do sistema no modal) ===== + public decimal? FranquiaLine { get; set; } // FRANQUIA LINE + public decimal? FranquiaGestao { get; set; } // FRANQUIA GESTÃO + public decimal? LocacaoAp { get; set; } // LOCAÇÃO AP. + public decimal? ValorContratoLine { get; set; } // VALOR CONTRATO LINE + + public decimal? Desconto { get; set; } // DESCONTO + public decimal? Lucro { get; set; } // LUCRO + + [MaxLength(80)] + public string? Status { get; set; } // STATUS + public DateTime? DataBloqueio { get; set; } // DATA DO BLOQUEIO + + [MaxLength(80)] + public string? Skil { get; set; } // SKIL + [MaxLength(80)] + public string? Modalidade { get; set; } // MODALIDADE + + [MaxLength(150)] + public string? Cedente { get; set; } // CEDENTE + [MaxLength(150)] + public string? Solicitante { get; set; } // SOLICITANTE + + public DateTime? DataEntregaOpera { get; set; } // DATA DA ENTREGA OPERA. + public DateTime? DataEntregaCliente { get; set; } // DATA DA ENTREGA CLIENTE + + [MaxLength(50)] + public string? VencConta { get; set; } // VENC. DA CONTA + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Program.cs b/Program.cs index 9c5adba..1de23df 100644 --- a/Program.cs +++ b/Program.cs @@ -1,6 +1,7 @@ -using System.Text; +using System.Text; using line_gestao_api.Data; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http.Features; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -8,7 +9,13 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); -// ? CORS (Angular) +// ✅ Upload (Excel / multipart) - seguro e não quebra nada +builder.Services.Configure(o => +{ + o.MultipartBodyLengthLimit = 50_000_000; // 50MB (mesmo do seu endpoint) +}); + +// ✅ CORS (Angular) builder.Services.AddCors(options => { options.AddPolicy("Front", p => @@ -18,16 +25,16 @@ builder.Services.AddCors(options => ); }); -// EF Core (PostgreSQL) +// ✅ EF Core (PostgreSQL) builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("Default")) ); -// Swagger +// ✅ Swagger builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -// JWT +// ✅ JWT var jwtKey = builder.Configuration["Jwt:Key"]!; var issuer = builder.Configuration["Jwt:Issuer"]; var audience = builder.Configuration["Jwt:Audience"]; @@ -60,7 +67,7 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); -// ? CORS precisa vir antes de Auth/Authorization +// ✅ CORS precisa vir antes de Auth/Authorization app.UseCors("Front"); app.UseAuthentication(); diff --git a/line-gestao-api.csproj b/line-gestao-api.csproj index 297e29f..71b042d 100644 --- a/line-gestao-api.csproj +++ b/line-gestao-api.csproj @@ -20,6 +20,7 @@ + From c830812af36342f0bb5f4171b04059cd9c51222e Mon Sep 17 00:00:00 2001 From: Eduardo Date: Fri, 19 Dec 2025 17:21:50 -0300 Subject: [PATCH 2/3] 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 From 3dc1eac09791eddb1845843319c7990d15d59adc Mon Sep 17 00:00:00 2001 From: Eduardo Date: Mon, 22 Dec 2025 17:42:46 -0300 Subject: [PATCH 3/3] Refactor: Ajuste na logica de busca por cliente, paginacao e layout do header --- Controllers/LinesController.cs | 426 +++++++++------------------------ 1 file changed, 108 insertions(+), 318 deletions(-) diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index 7e61ad5..cfadd9a 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -1,6 +1,6 @@ using ClosedXML.Excel; using line_gestao_api.Data; -using line_gestao_api.Dtos; // Certifique-se que ClientGroupDto está neste namespace +using line_gestao_api.Dtos; using line_gestao_api.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/[controller]")] - //[Authorize] // Descomente se estiver usando autenticação + //[Authorize] public class LinesController : ControllerBase { private readonly AppDbContext _db; @@ -21,40 +21,85 @@ namespace line_gestao_api.Controllers _db = db; } - // 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') + // ✅ 1. ENDPOINT: AGRUPAR POR CLIENTE (COM BUSCA E PAGINAÇÃO) // ========================================================== + // Alterado para aceitar 'search', 'page' e retornar PagedResult [HttpGet("groups")] - public async Task>> GetClientGroups() + public async Task>> GetClientGroups( + [FromQuery] string? skil, + [FromQuery] string? search, // 🔍 Busca por Nome do Cliente + [FromQuery] int page = 1, // 📄 Paginação + [FromQuery] int pageSize = 10) { - // Agrupa por nome do cliente e calcula os totais - var groups = await _db.MobileLines - .AsNoTracking() - .Where(x => !string.IsNullOrEmpty(x.Cliente)) + page = page < 1 ? 1 : page; + pageSize = pageSize < 1 ? 10 : pageSize; + + var query = _db.MobileLines.AsNoTracking().Where(x => !string.IsNullOrEmpty(x.Cliente)); + + // 1. Filtro SKIL (PF, PJ ou RESERVA) + if (!string.IsNullOrWhiteSpace(skil)) + { + var sSkil = skil.Trim(); + + if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(x => x.Skil == "RESERVA" || EF.Functions.ILike(x.Skil ?? "", "%RESERVA%")); + } + else + { + query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); + } + } + + // 2. Filtro SEARCH (Busca pelo Nome do Cliente nos grupos) + // Aqui garantimos que se o usuário digitar "ADR", filtramos apenas os clientes que tem ADR no nome + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim(); + query = query.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%")); + } + + // Montagem do Agrupamento + var groupedQuery = query .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%")) - }) + Bloqueados = g.Count(x => EF.Functions.ILike(x.Status ?? "", "%bloque%") || + EF.Functions.ILike(x.Status ?? "", "%perda%") || + EF.Functions.ILike(x.Status ?? "", "%roubo%")) + }); + + // Contagem total para a paginação + var totalGroups = await groupedQuery.CountAsync(); + + // Aplicação da Paginação + var items = await groupedQuery .OrderBy(x => x.Cliente) + .Skip((page - 1) * pageSize) + .Take(pageSize) .ToListAsync(); - return Ok(groups); + // Retorna formato paginado + return Ok(new PagedResult + { + Page = page, + PageSize = pageSize, + Total = totalGroups, + Items = items + }); } // ========================================================== - // ✅ 2. ENDPOINT: LISTAR NOMES DE CLIENTES (Para o Dropdown) + // ✅ 2. ENDPOINT: LISTAR NOMES DE CLIENTES // ========================================================== [HttpGet("clients")] public async Task>> GetClients() @@ -71,13 +116,13 @@ namespace line_gestao_api.Controllers } // ========================================================== - // ✅ 3. GET ALL (TABELA PRINCIPAL - Com todos os filtros) + // ✅ 3. GET ALL (TABELA / DETALHES DO GRUPO / BUSCA ESPECÍFICA) // ========================================================== [HttpGet] public async Task>> GetAll( [FromQuery] string? search, - [FromQuery] string? skil, // Filtro PF/PJ - [FromQuery] string? client, // Filtro Cliente Específico + [FromQuery] string? skil, + [FromQuery] string? client, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? sortBy = "item", @@ -88,28 +133,31 @@ namespace line_gestao_api.Controllers var q = _db.MobileLines.AsNoTracking(); - // 1. Filtro por SKIL (PF/PJ) + // Filtro SKIL if (!string.IsNullOrWhiteSpace(skil)) { var sSkil = skil.Trim(); - q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); + if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) + q = q.Where(x => x.Skil == "RESERVA" || EF.Functions.ILike(x.Skil ?? "", "%RESERVA%")); + else + q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); } - // 2. Filtro por Cliente Específico (usado no dropdown e no accordion) + // Filtro Cliente Específico (usado ao expandir o grupo) 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) + // Busca Genérica (usada quando o frontend detecta números ou busca específica) if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); q = q.Where(x => EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || EF.Functions.ILike(x.Chip ?? "", $"%{s}%") || - EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || + EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || // Busca cliente aqui também caso seja modo tabela EF.Functions.ILike(x.Usuario ?? "", $"%{s}%") || EF.Functions.ILike(x.Conta ?? "", $"%{s}%") || EF.Functions.ILike(x.Status ?? "", $"%{s}%")); @@ -117,7 +165,6 @@ namespace line_gestao_api.Controllers var total = await q.CountAsync(); - // Ordenação var sb = (sortBy ?? "item").Trim().ToLowerInvariant(); var desc = string.Equals((sortDir ?? "asc").Trim(), "desc", StringComparison.OrdinalIgnoreCase); @@ -169,168 +216,54 @@ namespace line_gestao_api.Controllers } // ========================================================== - // OBTER DETALHES POR ID + // DEMAIS MÉTODOS (CRUD, IMPORT, HELPERS) - MANTIDOS // ========================================================== - [HttpGet("{id:guid}")] - public async Task> GetById(Guid id) - { - var x = await _db.MobileLines.AsNoTracking().FirstOrDefaultAsync(a => a.Id == id); - if (x == null) return NotFound(); + [HttpGet("{id:guid}")] public async Task> GetById(Guid id) { var x = await _db.MobileLines.AsNoTracking().FirstOrDefaultAsync(a => a.Id == id); if (x == null) return NotFound(); return Ok(ToDetailDto(x)); } - 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); + 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 registro com essa LINHA.", linha = newLinha }); } - // 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?.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; - x.GestaoVozDados = req.GestaoVozDados; - x.Skeelo = req.Skeelo; - x.VivoNewsPlus = req.VivoNewsPlus; - x.VivoTravelMundo = req.VivoTravelMundo; - x.VivoGestaoDispositivo = req.VivoGestaoDispositivo; - x.ValorContratoVivo = req.ValorContratoVivo; - - x.FranquiaLine = req.FranquiaLine; - x.FranquiaGestao = req.FranquiaGestao; - x.LocacaoAp = req.LocacaoAp; - x.ValorContratoLine = req.ValorContratoLine; - - x.Desconto = req.Desconto; - x.Lucro = req.Lucro; - - x.Status = req.Status?.Trim(); - x.DataBloqueio = ToUtc(req.DataBloqueio); - - 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?.Trim(); - - ApplyReservaRule(x); - - x.UpdatedAt = DateTime.UtcNow; - - try - { - await _db.SaveChangesAsync(); - } - catch (DbUpdateException) - { - return Conflict(new - { - message = "Conflito ao salvar. Verifique se a LINHA já existe em outro registro." - }); - } + x.Item = req.Item; x.Conta = req.Conta?.Trim(); x.Linha = newLinha; x.Chip = OnlyDigits(req.Chip); x.Cliente = req.Cliente?.Trim(); x.Usuario = req.Usuario?.Trim(); + x.PlanoContrato = req.PlanoContrato?.Trim(); x.FranquiaVivo = req.FranquiaVivo; x.ValorPlanoVivo = req.ValorPlanoVivo; x.GestaoVozDados = req.GestaoVozDados; + x.Skeelo = req.Skeelo; x.VivoNewsPlus = req.VivoNewsPlus; x.VivoTravelMundo = req.VivoTravelMundo; x.VivoGestaoDispositivo = req.VivoGestaoDispositivo; + x.ValorContratoVivo = req.ValorContratoVivo; x.FranquiaLine = req.FranquiaLine; x.FranquiaGestao = req.FranquiaGestao; x.LocacaoAp = req.LocacaoAp; + x.ValorContratoLine = req.ValorContratoLine; x.Desconto = req.Desconto; x.Lucro = req.Lucro; x.Status = req.Status?.Trim(); + x.DataBloqueio = ToUtc(req.DataBloqueio); 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?.Trim(); ApplyReservaRule(x); x.UpdatedAt = DateTime.UtcNow; + try { await _db.SaveChangesAsync(); } catch (DbUpdateException) { return Conflict(new { message = "Conflito ao salvar." }); } return NoContent(); } - // ========================================================== - // DELETAR (DELETE) - // ========================================================== - [HttpDelete("{id:guid}")] - public async Task Delete(Guid id) - { - var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); - if (x == null) return NotFound(); + [HttpDelete("{id:guid}")] public async Task Delete(Guid id) { var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); if (x == null) return NotFound(); _db.MobileLines.Remove(x); await _db.SaveChangesAsync(); return NoContent(); } - _db.MobileLines.Remove(x); - await _db.SaveChangesAsync(); - return NoContent(); - } - - // ========================================================== - // IMPORTAR EXCEL - // ========================================================== [HttpPost("import-excel")] [Consumes("multipart/form-data")] [RequestSizeLimit(50_000_000)] public async Task> ImportExcel([FromForm] ImportExcelForm form) { - var file = form.File; - - if (file == null || file.Length == 0) - return BadRequest("Arquivo inválido."); - - using var stream = file.OpenReadStream(); - using var wb = new XLWorkbook(stream); - + var file = form.File; if (file == null || file.Length == 0) return BadRequest("Arquivo inválido."); + using var stream = file.OpenReadStream(); using var wb = new XLWorkbook(stream); var ws = wb.Worksheets.FirstOrDefault(w => w.Name.Trim().Equals("GERAL", StringComparison.OrdinalIgnoreCase)); - if (ws == null) - return BadRequest("Aba 'GERAL' não encontrada."); - - 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."); - + if (ws == null) return BadRequest("Aba 'GERAL' não encontrada."); + var headerRow = ws.RowsUsed().FirstOrDefault(r => r.CellsUsed().Any(c => NormalizeHeader(c.GetString()) == "ITEM")); + if (headerRow == null) return BadRequest("Cabeçalho 'ITEM' não encontrado."); var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var cell in headerRow.CellsUsed()) - { - var key = NormalizeHeader(cell.GetString()); - if (!string.IsNullOrWhiteSpace(key) && !map.ContainsKey(key)) - map[key] = cell.Address.ColumnNumber; - } - - int colItem = GetCol(map, "ITEM"); - if (colItem == 0) return BadRequest("Coluna 'ITÉM' não encontrada."); - + foreach (var cell in headerRow.CellsUsed()) { var k = NormalizeHeader(cell.GetString()); if (!string.IsNullOrWhiteSpace(k) && !map.ContainsKey(k)) map[k] = cell.Address.ColumnNumber; } + int colItem = GetCol(map, "ITEM"); if (colItem == 0) return BadRequest("Coluna 'ITEM' não encontrada."); var startRow = headerRow.RowNumber() + 1; - - // Opção: Deletar tudo antes de importar (Cuidado em produção!) await _db.MobileLines.ExecuteDeleteAsync(); - - var imported = 0; - var buffer = new List(600); - + var buffer = new List(600); var imported = 0; for (int r = startRow; r <= ws.LastRowUsed().RowNumber(); r++) { - var itemStr = GetCellString(ws, r, colItem); - if (string.IsNullOrWhiteSpace(itemStr)) break; - - var entity = new MobileLine + var itemStr = GetCellString(ws, r, colItem); if (string.IsNullOrWhiteSpace(itemStr)) break; + var e = new MobileLine { Item = TryInt(itemStr), Conta = GetCellByHeader(ws, r, map, "CONTA"), @@ -361,169 +294,26 @@ namespace line_gestao_api.Controllers 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"), + VencConta = GetCellByHeader(ws, r, map, "VENC. DA CONTA") }; - - ApplyReservaRule(entity); - buffer.Add(entity); - imported++; - - if (buffer.Count >= 500) - { - await _db.MobileLines.AddRangeAsync(buffer); - await _db.SaveChangesAsync(); - buffer.Clear(); - } + ApplyReservaRule(e); buffer.Add(e); imported++; + if (buffer.Count >= 500) { await _db.MobileLines.AddRangeAsync(buffer); await _db.SaveChangesAsync(); buffer.Clear(); } } - - if (buffer.Count > 0) - { - await _db.MobileLines.AddRangeAsync(buffer); - await _db.SaveChangesAsync(); - } - + if (buffer.Count > 0) { await _db.MobileLines.AddRangeAsync(buffer); await _db.SaveChangesAsync(); } return Ok(new ImportResultDto { Imported = imported }); } - // ========================================================== - // 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) - }; - } - - private static MobileLineDetailDto ToDetailDto(MobileLine x) => new() - { - Id = x.Id, - Item = x.Item, - Conta = x.Conta, - Linha = x.Linha, - Chip = x.Chip, - Cliente = x.Cliente, - Usuario = x.Usuario, - PlanoContrato = x.PlanoContrato, - FranquiaVivo = x.FranquiaVivo, - ValorPlanoVivo = x.ValorPlanoVivo, - GestaoVozDados = x.GestaoVozDados, - Skeelo = x.Skeelo, - VivoNewsPlus = x.VivoNewsPlus, - 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, - Modalidade = x.Modalidade, - Cedente = x.Cedente, - Solicitante = x.Solicitante, - DataEntregaOpera = x.DataEntregaOpera, - DataEntregaCliente = x.DataEntregaCliente, - VencConta = x.VencConta - }; - - private static void ApplyReservaRule(MobileLine x) - { - var cliente = (x.Cliente ?? "").Trim(); - var usuario = (x.Usuario ?? "").Trim(); - if (cliente.Equals("RESERVA", StringComparison.OrdinalIgnoreCase) || - usuario.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) - { - x.Cliente = "RESERVA"; - x.Usuario = "RESERVA"; - x.Skil = "RESERVA"; - } - } - + // Helpers + private static DateTime? ToUtc(DateTime? dt) { if (dt == null) return null; var v = dt.Value; return v.Kind == DateTimeKind.Utc ? v : (v.Kind == DateTimeKind.Local ? v.ToUniversalTime() : DateTime.SpecifyKind(v, DateTimeKind.Utc)); } + private static MobileLineDetailDto ToDetailDto(MobileLine x) => new() { Id = x.Id, Item = x.Item, Conta = x.Conta, Linha = x.Linha, Chip = x.Chip, Cliente = x.Cliente, Usuario = x.Usuario, PlanoContrato = x.PlanoContrato, FranquiaVivo = x.FranquiaVivo, ValorPlanoVivo = x.ValorPlanoVivo, GestaoVozDados = x.GestaoVozDados, Skeelo = x.Skeelo, VivoNewsPlus = x.VivoNewsPlus, 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, Modalidade = x.Modalidade, Cedente = x.Cedente, Solicitante = x.Solicitante, DataEntregaOpera = x.DataEntregaOpera, DataEntregaCliente = x.DataEntregaCliente, VencConta = x.VencConta }; + private static void ApplyReservaRule(MobileLine x) { if ((x.Cliente ?? "").Trim().ToUpper() == "RESERVA" || (x.Usuario ?? "").Trim().ToUpper() == "RESERVA") { x.Cliente = "RESERVA"; x.Usuario = "RESERVA"; x.Skil = "RESERVA"; } } 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) - { - var key = NormalizeHeader(header); - if (!map.TryGetValue(key, out var col)) return ""; - return GetCellString(ws, row, col); - } - - private static string GetCellString(IXLWorksheet ws, int row, int col) - { - var cell = ws.Cell(row, col); - if (cell == null) return ""; - var v = cell.GetValue() ?? ""; - return v.Trim(); - } - - private static DateTime? TryDate(IXLWorksheet ws, int row, Dictionary map, string header) - { - var key = NormalizeHeader(header); - if (!map.TryGetValue(key, out var col)) return null; - - var cell = ws.Cell(row, col); - if (cell.DataType == XLDataType.DateTime) - return ToUtc(cell.GetDateTime()); - - var s = cell.GetValue()?.Trim(); - if (string.IsNullOrWhiteSpace(s)) return null; - - if (DateTime.TryParse(s, new CultureInfo("pt-BR"), DateTimeStyles.None, out var d)) - return ToUtc(d); - - if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out d)) - return ToUtc(d); - - return null; - } - - private static decimal? TryDecimal(string? s) - { - if (string.IsNullOrWhiteSpace(s)) return null; - 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; - return null; - } - + private static string GetCellByHeader(IXLWorksheet ws, int row, Dictionary map, string header) { var k = NormalizeHeader(header); return map.TryGetValue(k, out var c) ? GetCellString(ws, row, c) : ""; } + private static string GetCellString(IXLWorksheet ws, int row, int col) { var c = ws.Cell(row, col); return c == null ? "" : (c.GetValue() ?? "").Trim(); } + private static DateTime? TryDate(IXLWorksheet ws, int row, Dictionary map, string header) { var k = NormalizeHeader(header); if (!map.TryGetValue(k, out var c)) return null; var cell = ws.Cell(row, c); if (cell.DataType == XLDataType.DateTime) return ToUtc(cell.GetDateTime()); var s = cell.GetValue()?.Trim(); if (string.IsNullOrWhiteSpace(s)) return null; if (DateTime.TryParse(s, new CultureInfo("pt-BR"), DateTimeStyles.None, out var d)) return ToUtc(d); return null; } + private static decimal? TryDecimal(string? s) { if (string.IsNullOrWhiteSpace(s)) return null; s = s.Replace("R$", "").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; return null; } 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); - return sb.ToString(); - } - - private static string NormalizeHeader(string? s) - { - if (string.IsNullOrWhiteSpace(s)) return ""; - s = s.Trim().ToUpperInvariant(); - var formD = s.Normalize(NormalizationForm.FormD); - var sb = new StringBuilder(); - foreach (var ch in formD) - if (System.Globalization.CharUnicodeInfo.GetUnicodeCategory(ch) != System.Globalization.UnicodeCategory.NonSpacingMark) - sb.Append(ch); - - s = sb.ToString().Normalize(NormalizationForm.FormC); - s = s.Replace("ITEM", "ITEM") - .Replace("USUARIO", "USUARIO") - .Replace("GESTAO", "GESTAO") - .Replace("LOCACAO", "LOCACAO"); - - s = string.Join(" ", s.Split(' ', StringSplitOptions.RemoveEmptyEntries)); - return s; - } + private static string OnlyDigits(string? s) { if (string.IsNullOrWhiteSpace(s)) return ""; var sb = new StringBuilder(); foreach (var c in s) if (char.IsDigit(c)) sb.Append(c); return sb.ToString(); } + private static string NormalizeHeader(string? s) { if (string.IsNullOrWhiteSpace(s)) return ""; s = s.Trim().ToUpperInvariant().Normalize(NormalizationForm.FormD); var sb = new StringBuilder(); foreach (var c in s) if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) sb.Append(c); return sb.ToString().Normalize(NormalizationForm.FormC).Replace("ITEM", "ITEM").Replace("USUARIO", "USUARIO").Replace("GESTAO", "GESTAO").Replace("LOCACAO", "LOCACAO").Replace(" ", ""); } } } \ No newline at end of file