From 8cb0b72474034bd6c5615094b3e0367df1306b42 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:41:43 -0300 Subject: [PATCH] =?UTF-8?q?Adicionar=20importa=C3=A7=C3=A3o=20e=20API=20de?= =?UTF-8?q?=20parcelamentos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Controllers/LinesController.cs | 12 +- Controllers/ParcelamentosController.cs | 128 +++++++ Data/AppDbContext.cs | 35 ++ Dtos/MobileLineDtos.cs | 1 + Dtos/ParcelamentosDtos.cs | 43 +++ Models/ParcelamentoLine.cs | 24 ++ Models/ParcelamentoMonthValue.cs | 16 + Program.cs | 1 + Services/ParcelamentosImportService.cs | 447 +++++++++++++++++++++++++ 9 files changed, 705 insertions(+), 2 deletions(-) create mode 100644 Controllers/ParcelamentosController.cs create mode 100644 Dtos/ParcelamentosDtos.cs create mode 100644 Models/ParcelamentoLine.cs create mode 100644 Models/ParcelamentoMonthValue.cs create mode 100644 Services/ParcelamentosImportService.cs diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index af982ec..ed8ce63 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -2,6 +2,7 @@ using line_gestao_api.Data; using line_gestao_api.Dtos; using line_gestao_api.Models; +using line_gestao_api.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -20,10 +21,12 @@ namespace line_gestao_api.Controllers public class LinesController : ControllerBase { private readonly AppDbContext _db; + private readonly ParcelamentosImportService _parcelamentosImportService; - public LinesController(AppDbContext db) + public LinesController(AppDbContext db, ParcelamentosImportService parcelamentosImportService) { _db = db; + _parcelamentosImportService = parcelamentosImportService; } public class ImportExcelForm @@ -651,8 +654,13 @@ namespace line_gestao_api.Controllers // ========================= await ImportResumoFromWorkbook(wb); + // ========================= + // ✅ IMPORTA PARCELAMENTOS + // ========================= + var parcelamentosSummary = await _parcelamentosImportService.ImportFromWorkbookAsync(wb, replaceAll: true); + await tx.CommitAsync(); - return Ok(new ImportResultDto { Imported = imported }); + return Ok(new ImportResultDto { Imported = imported, Parcelamentos = parcelamentosSummary }); } catch (Exception ex) { diff --git a/Controllers/ParcelamentosController.cs b/Controllers/ParcelamentosController.cs new file mode 100644 index 0000000..989d143 --- /dev/null +++ b/Controllers/ParcelamentosController.cs @@ -0,0 +1,128 @@ +using line_gestao_api.Data; +using line_gestao_api.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Controllers; + +[ApiController] +[Route("api/parcelamentos")] +[Authorize] +public class ParcelamentosController : ControllerBase +{ + private readonly AppDbContext _db; + + public ParcelamentosController(AppDbContext db) + { + _db = db; + } + + [HttpGet] + public async Task>> GetAll( + [FromQuery] int? anoRef, + [FromQuery] string? linha, + [FromQuery] string? cliente, + [FromQuery] int? competenciaAno, + [FromQuery] int? competenciaMes, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + page = page < 1 ? 1 : page; + pageSize = pageSize < 1 ? 20 : pageSize; + + var query = _db.ParcelamentoLines.AsNoTracking(); + + if (anoRef.HasValue) + { + query = query.Where(x => x.AnoRef == anoRef.Value); + } + + if (!string.IsNullOrWhiteSpace(linha)) + { + var l = linha.Trim(); + query = query.Where(x => x.Linha == l); + } + + if (!string.IsNullOrWhiteSpace(cliente)) + { + var c = cliente.Trim(); + query = query.Where(x => x.Cliente != null && EF.Functions.ILike(x.Cliente, $"%{c}%")); + } + + if (competenciaAno.HasValue && competenciaMes.HasValue) + { + var competencia = new DateOnly(competenciaAno.Value, competenciaMes.Value, 1); + query = query.Where(x => x.MonthValues.Any(m => m.Competencia == competencia)); + } + + var total = await query.CountAsync(); + + var items = await query + .OrderBy(x => x.Item) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new ParcelamentoListDto + { + Id = x.Id, + AnoRef = x.AnoRef, + Item = x.Item, + Linha = x.Linha, + Cliente = x.Cliente, + QtParcelas = x.QtParcelas, + ParcelaAtual = x.ParcelaAtual, + TotalParcelas = x.TotalParcelas, + ValorCheio = x.ValorCheio, + Desconto = x.Desconto, + ValorComDesconto = x.ValorComDesconto + }) + .ToListAsync(); + + return Ok(new PagedResult + { + Page = page, + PageSize = pageSize, + Total = total, + Items = items + }); + } + + [HttpGet("{id:guid}")] + public async Task> GetById(Guid id) + { + var item = await _db.ParcelamentoLines + .AsNoTracking() + .Include(x => x.MonthValues) + .FirstOrDefaultAsync(x => x.Id == id); + + if (item == null) + { + return NotFound(); + } + + var dto = new ParcelamentoDetailDto + { + Id = item.Id, + AnoRef = item.AnoRef, + Item = item.Item, + Linha = item.Linha, + Cliente = item.Cliente, + QtParcelas = item.QtParcelas, + ParcelaAtual = item.ParcelaAtual, + TotalParcelas = item.TotalParcelas, + ValorCheio = item.ValorCheio, + Desconto = item.Desconto, + ValorComDesconto = item.ValorComDesconto, + MonthValues = item.MonthValues + .OrderBy(x => x.Competencia) + .Select(x => new ParcelamentoMonthDto + { + Competencia = x.Competencia, + Valor = x.Valor + }) + .ToList() + }; + + return Ok(dto); + } +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index c3f9bee..6b81d02 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -56,6 +56,10 @@ public class AppDbContext : IdentityDbContext ResumoReservaLines => Set(); public DbSet ResumoReservaTotals => Set(); + // ✅ tabela PARCELAMENTOS + public DbSet ParcelamentoLines => Set(); + public DbSet ParcelamentoMonthValues => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -215,6 +219,35 @@ public class AppDbContext : IdentityDbContext(e => + { + e.Property(x => x.Linha).HasMaxLength(32); + e.Property(x => x.Cliente).HasMaxLength(120); + e.Property(x => x.QtParcelas).HasMaxLength(32); + e.Property(x => x.ValorCheio).HasPrecision(18, 2); + e.Property(x => x.Desconto).HasPrecision(18, 2); + e.Property(x => x.ValorComDesconto).HasPrecision(18, 2); + + e.HasIndex(x => new { x.AnoRef, x.Item }).IsUnique(); + e.HasIndex(x => x.Linha); + e.HasIndex(x => x.TenantId); + }); + + modelBuilder.Entity(e => + { + e.Property(x => x.Valor).HasPrecision(18, 2); + e.HasIndex(x => new { x.ParcelamentoLineId, x.Competencia }).IsUnique(); + e.HasIndex(x => x.TenantId); + + e.HasOne(x => x.ParcelamentoLine) + .WithMany(x => x.MonthValues) + .HasForeignKey(x => x.ParcelamentoLineId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); @@ -234,6 +267,8 @@ public class AppDbContext : IdentityDbContext().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); } diff --git a/Dtos/MobileLineDtos.cs b/Dtos/MobileLineDtos.cs index 42d9e5c..16b550c 100644 --- a/Dtos/MobileLineDtos.cs +++ b/Dtos/MobileLineDtos.cs @@ -97,6 +97,7 @@ public class ImportResultDto { public int Imported { get; set; } + public ParcelamentosImportSummaryDto? Parcelamentos { get; set; } } public class LineOptionDto diff --git a/Dtos/ParcelamentosDtos.cs b/Dtos/ParcelamentosDtos.cs new file mode 100644 index 0000000..53d73b6 --- /dev/null +++ b/Dtos/ParcelamentosDtos.cs @@ -0,0 +1,43 @@ +namespace line_gestao_api.Dtos; + +public sealed class ParcelamentoListDto +{ + public Guid Id { get; set; } + public int? AnoRef { get; set; } + public int? Item { get; set; } + public string? Linha { get; set; } + public string? Cliente { get; set; } + public string? QtParcelas { get; set; } + public int? ParcelaAtual { get; set; } + public int? TotalParcelas { get; set; } + public decimal? ValorCheio { get; set; } + public decimal? Desconto { get; set; } + public decimal? ValorComDesconto { get; set; } +} + +public sealed class ParcelamentoMonthDto +{ + public DateOnly Competencia { get; set; } + public decimal? Valor { get; set; } +} + +public sealed class ParcelamentoDetailDto : ParcelamentoListDto +{ + public List MonthValues { get; set; } = new(); +} + +public sealed class ParcelamentosImportErrorDto +{ + public int LinhaExcel { get; set; } + public string Motivo { get; set; } = string.Empty; + public string? Valor { get; set; } +} + +public sealed class ParcelamentosImportSummaryDto +{ + public int Lidos { get; set; } + public int Inseridos { get; set; } + public int Atualizados { get; set; } + public int ParcelasInseridas { get; set; } + public List Erros { get; set; } = new(); +} diff --git a/Models/ParcelamentoLine.cs b/Models/ParcelamentoLine.cs new file mode 100644 index 0000000..b16e152 --- /dev/null +++ b/Models/ParcelamentoLine.cs @@ -0,0 +1,24 @@ +namespace line_gestao_api.Models; + +public class ParcelamentoLine : ITenantEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TenantId { get; set; } + + public int? AnoRef { get; set; } + public int? Item { get; set; } + public string? Linha { get; set; } + public string? Cliente { get; set; } + public string? QtParcelas { get; set; } + public int? ParcelaAtual { get; set; } + public int? TotalParcelas { get; set; } + public decimal? ValorCheio { get; set; } + public decimal? Desconto { get; set; } + public decimal? ValorComDesconto { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + public List MonthValues { get; set; } = new(); +} diff --git a/Models/ParcelamentoMonthValue.cs b/Models/ParcelamentoMonthValue.cs new file mode 100644 index 0000000..e8af6b6 --- /dev/null +++ b/Models/ParcelamentoMonthValue.cs @@ -0,0 +1,16 @@ +namespace line_gestao_api.Models; + +public class ParcelamentoMonthValue : ITenantEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TenantId { get; set; } + + public Guid ParcelamentoLineId { get; set; } + public ParcelamentoLine? ParcelamentoLine { get; set; } + + public DateOnly Competencia { get; set; } + public decimal? Valor { get; set; } + + public DateTime CreatedAt { get; set; } +} diff --git a/Program.cs b/Program.cs index 6376507..58b50b5 100644 --- a/Program.cs +++ b/Program.cs @@ -35,6 +35,7 @@ builder.Services.AddDbContext(options => builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddIdentityCore(options => { diff --git a/Services/ParcelamentosImportService.cs b/Services/ParcelamentosImportService.cs new file mode 100644 index 0000000..c1a5671 --- /dev/null +++ b/Services/ParcelamentosImportService.cs @@ -0,0 +1,447 @@ +using System.Globalization; +using System.Text; +using ClosedXML.Excel; +using line_gestao_api.Data; +using line_gestao_api.Dtos; +using line_gestao_api.Models; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Services; + +public sealed class ParcelamentosImportService +{ + private readonly AppDbContext _db; + + public ParcelamentosImportService(AppDbContext db) + { + _db = db; + } + + public async Task ImportFromWorkbookAsync(XLWorkbook wb, bool replaceAll, CancellationToken cancellationToken = default) + { + var ws = FindWorksheet(wb); + if (ws == null) + { + return new ParcelamentosImportSummaryDto + { + Erros = + { + new ParcelamentosImportErrorDto + { + LinhaExcel = 0, + Motivo = "Aba 'PARCELAMENTOS DE APARELHOS' ou 'PARCELAMENTOS' não encontrada." + } + } + }; + } + + if (replaceAll) + { + await _db.ParcelamentoMonthValues.ExecuteDeleteAsync(cancellationToken); + await _db.ParcelamentoLines.ExecuteDeleteAsync(cancellationToken); + } + + var headerRowIndex = FindHeaderRow(ws); + if (headerRowIndex == 0) + { + return new ParcelamentosImportSummaryDto + { + Erros = + { + new ParcelamentosImportErrorDto + { + LinhaExcel = 0, + Motivo = "Cabeçalho 'LINHA' não encontrado na aba de parcelamentos." + } + } + }; + } + + var yearRowIndex = headerRowIndex - 1; + var headerRow = ws.Row(headerRowIndex); + var map = BuildHeaderMap(headerRow); + + var colLinha = GetCol(map, "LINHA"); + var colCliente = GetCol(map, "CLIENTE"); + var colQtParcelas = GetColAny(map, "QT PARCELAS", "QT. PARCELAS", "QT PARCELAS (NN/TT)", "QTDE PARCELAS"); + var colValorCheio = GetColAny(map, "VALOR CHEIO"); + var colDesconto = GetColAny(map, "DESCONTO"); + var colValorComDesconto = GetColAny(map, "VALOR C/ DESCONTO", "VALOR COM DESCONTO"); + + if (colLinha == 0 || colValorComDesconto == 0) + { + return new ParcelamentosImportSummaryDto + { + Erros = + { + new ParcelamentosImportErrorDto + { + LinhaExcel = headerRowIndex, + Motivo = "Colunas obrigatórias não encontradas (LINHA / VALOR C/ DESCONTO)." + } + } + }; + } + + var yearMap = BuildYearMap(ws, yearRowIndex, headerRow); + var monthColumns = BuildMonthColumns(headerRow, colValorComDesconto + 1); + + var existing = await _db.ParcelamentoLines + .AsNoTracking() + .ToListAsync(cancellationToken); + var existingByKey = existing + .Where(x => x.AnoRef.HasValue && x.Item.HasValue) + .ToDictionary(x => (x.AnoRef!.Value, x.Item!.Value), x => x.Id); + + var summary = new ParcelamentosImportSummaryDto(); + var lastRow = ws.LastRowUsed()?.RowNumber() ?? headerRowIndex; + + for (int row = headerRowIndex + 1; row <= lastRow; row++) + { + var linhaValue = GetCellString(ws, row, colLinha); + var itemStr = GetCellString(ws, row, 4); + if (string.IsNullOrWhiteSpace(itemStr) && string.IsNullOrWhiteSpace(linhaValue)) + { + break; + } + + summary.Lidos++; + + var anoRef = TryNullableInt(GetCellString(ws, row, 3)); + var item = TryNullableInt(itemStr); + + if (!item.HasValue) + { + summary.Erros.Add(new ParcelamentosImportErrorDto + { + LinhaExcel = row, + Motivo = "Item inválido ou vazio.", + Valor = itemStr + }); + continue; + } + + if (!anoRef.HasValue) + { + summary.Erros.Add(new ParcelamentosImportErrorDto + { + LinhaExcel = row, + Motivo = "AnoRef inválido ou vazio.", + Valor = GetCellString(ws, row, 3) + }); + continue; + } + + var qtParcelas = GetCellString(ws, row, colQtParcelas); + ParseParcelas(qtParcelas, out var parcelaAtual, out var totalParcelas); + + var parcelamento = new ParcelamentoLine + { + AnoRef = anoRef, + Item = item, + Linha = string.IsNullOrWhiteSpace(linhaValue) ? null : linhaValue.Trim(), + Cliente = NormalizeText(GetCellString(ws, row, colCliente)), + QtParcelas = string.IsNullOrWhiteSpace(qtParcelas) ? null : qtParcelas.Trim(), + ParcelaAtual = parcelaAtual, + TotalParcelas = totalParcelas, + ValorCheio = TryDecimal(GetCellString(ws, row, colValorCheio)), + Desconto = TryDecimal(GetCellString(ws, row, colDesconto)), + ValorComDesconto = TryDecimal(GetCellString(ws, row, colValorComDesconto)), + UpdatedAt = DateTime.UtcNow + }; + + if (existingByKey.TryGetValue((anoRef.Value, item.Value), out var existingId)) + { + var existingEntity = await _db.ParcelamentoLines + .FirstOrDefaultAsync(x => x.Id == existingId, cancellationToken); + if (existingEntity == null) + { + existingByKey.Remove((anoRef ?? 0, item.Value)); + } + else + { + existingEntity.AnoRef = parcelamento.AnoRef; + existingEntity.Item = parcelamento.Item; + existingEntity.Linha = parcelamento.Linha; + existingEntity.Cliente = parcelamento.Cliente; + existingEntity.QtParcelas = parcelamento.QtParcelas; + existingEntity.ParcelaAtual = parcelamento.ParcelaAtual; + existingEntity.TotalParcelas = parcelamento.TotalParcelas; + existingEntity.ValorCheio = parcelamento.ValorCheio; + existingEntity.Desconto = parcelamento.Desconto; + existingEntity.ValorComDesconto = parcelamento.ValorComDesconto; + existingEntity.UpdatedAt = parcelamento.UpdatedAt; + + await _db.ParcelamentoMonthValues + .Where(x => x.ParcelamentoLineId == existingEntity.Id) + .ExecuteDeleteAsync(cancellationToken); + + var monthValues = BuildMonthValues(ws, headerRowIndex, row, monthColumns, yearMap, existingEntity.Id, summary); + if (monthValues.Count > 0) + { + await _db.ParcelamentoMonthValues.AddRangeAsync(monthValues, cancellationToken); + } + + summary.Atualizados++; + continue; + } + } + + parcelamento.CreatedAt = DateTime.UtcNow; + parcelamento.MonthValues = BuildMonthValues(ws, headerRowIndex, row, monthColumns, yearMap, parcelamento.Id, summary); + summary.Inseridos++; + + await _db.ParcelamentoLines.AddAsync(parcelamento, cancellationToken); + } + + await _db.SaveChangesAsync(cancellationToken); + return summary; + } + + private static IXLWorksheet? FindWorksheet(XLWorkbook wb) + { + return wb.Worksheets.FirstOrDefault(w => NormalizeHeader(w.Name) == NormalizeHeader("PARCELAMENTOS DE APARELHOS")) + ?? wb.Worksheets.FirstOrDefault(w => NormalizeHeader(w.Name) == NormalizeHeader("PARCELAMENTOS")); + } + + private static int FindHeaderRow(IXLWorksheet ws) + { + var firstRow = ws.FirstRowUsed()?.RowNumber() ?? 1; + var lastRow = Math.Min(firstRow + 20, ws.LastRowUsed()?.RowNumber() ?? firstRow); + + for (int r = firstRow; r <= lastRow; r++) + { + var row = ws.Row(r); + foreach (var cell in row.CellsUsed()) + { + if (NormalizeHeader(cell.GetString()) == NormalizeHeader("LINHA")) + { + return r; + } + } + } + + return 0; + } + + private static Dictionary BuildYearMap(IXLWorksheet ws, int yearRowIndex, IXLRow headerRow) + { + var yearMap = new Dictionary(); + var lastCol = headerRow.LastCellUsed()?.Address.ColumnNumber ?? 1; + + if (yearRowIndex <= 0) + { + return yearMap; + } + + for (int col = 1; col <= lastCol; col++) + { + var yearCell = ws.Cell(yearRowIndex, col); + var yearText = yearCell.GetString(); + if (string.IsNullOrWhiteSpace(yearText)) + { + var merged = ws.MergedRanges.FirstOrDefault(r => + r.RangeAddress.FirstAddress.RowNumber == yearRowIndex && + r.RangeAddress.FirstAddress.ColumnNumber <= col && + r.RangeAddress.LastAddress.ColumnNumber >= col); + + if (merged != null) + { + yearText = merged.FirstCell().GetString(); + } + } + + if (int.TryParse(OnlyDigits(yearText), out var year)) + { + yearMap[col] = year; + } + } + + return yearMap; + } + + private static List BuildMonthColumns(IXLRow headerRow, int startCol) + { + var lastCol = headerRow.LastCellUsed()?.Address.ColumnNumber ?? startCol; + var months = new List(); + for (int col = startCol; col <= lastCol; col++) + { + var header = NormalizeHeader(headerRow.Cell(col).GetString()); + if (ToMonthNumber(header).HasValue) + { + months.Add(col); + } + } + + return months; + } + + private static List BuildMonthValues( + IXLWorksheet ws, + int headerRowIndex, + int row, + List monthColumns, + Dictionary yearMap, + Guid parcelamentoId, + ParcelamentosImportSummaryDto summary) + { + var monthValues = new List(); + foreach (var col in monthColumns) + { + if (!yearMap.TryGetValue(col, out var year)) + { + continue; + } + + var header = NormalizeHeader(ws.Cell(headerRowIndex, col).GetString()); + var monthNumber = ToMonthNumber(header); + if (!monthNumber.HasValue) + { + continue; + } + + var valueStr = ws.Cell(row, col).GetString(); + var value = TryDecimal(valueStr); + if (!value.HasValue) + { + continue; + } + + monthValues.Add(new ParcelamentoMonthValue + { + ParcelamentoLineId = parcelamentoId, + Competencia = new DateOnly(year, monthNumber.Value, 1), + Valor = value, + CreatedAt = DateTime.UtcNow + }); + summary.ParcelasInseridas++; + } + + return monthValues; + } + + private static void ParseParcelas(string? qtParcelas, out int? parcelaAtual, out int? totalParcelas) + { + parcelaAtual = null; + totalParcelas = null; + if (string.IsNullOrWhiteSpace(qtParcelas)) + { + return; + } + + var parts = qtParcelas.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length >= 1 && int.TryParse(OnlyDigits(parts[0]), out var atual)) + { + parcelaAtual = atual; + } + + if (parts.Length >= 2 && int.TryParse(OnlyDigits(parts[1]), out var total)) + { + totalParcelas = total; + } + } + + private static Dictionary BuildHeaderMap(IXLRow headerRow) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cell in headerRow.CellsUsed()) + { + var k = NormalizeHeader(cell.GetString()); + if (!string.IsNullOrWhiteSpace(k) && !map.ContainsKey(k)) + map[k] = cell.Address.ColumnNumber; + } + return map; + } + + private static int GetCol(Dictionary map, string name) + => map.TryGetValue(NormalizeHeader(name), out var c) ? c : 0; + + private static int GetColAny(Dictionary map, params string[] headers) + { + foreach (var h in headers) + { + var k = NormalizeHeader(h); + if (map.TryGetValue(k, out var c)) return c; + } + return 0; + } + + private static string GetCellString(IXLWorksheet ws, int row, int col) + { + if (col <= 0) return ""; + return (ws.Cell(row, col).GetValue() ?? "").Trim(); + } + + private static string? NormalizeText(string value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + 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; + + var s2 = s.Replace(".", "").Replace(",", "."); + if (decimal.TryParse(s2, NumberStyles.Any, CultureInfo.InvariantCulture, out d)) return d; + + return null; + } + + private static int? TryNullableInt(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return null; + var d = OnlyDigits(s); + if (string.IsNullOrWhiteSpace(d)) return null; + return int.TryParse(d, out var n) ? n : null; + } + + 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 int? ToMonthNumber(string? month) + { + if (string.IsNullOrWhiteSpace(month)) return null; + return NormalizeHeader(month) switch + { + "JAN" => 1, + "FEV" => 2, + "MAR" => 3, + "ABR" => 4, + "MAI" => 5, + "JUN" => 6, + "JUL" => 7, + "AGO" => 8, + "SET" => 9, + "OUT" => 10, + "NOV" => 11, + "DEZ" => 12, + _ => null + }; + } + + 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(" ", "") + .Replace("\t", "") + .Replace("\n", "") + .Replace("\r", ""); + } +}