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); } if (!TryFindParcelamentosHeader(ws, out var headerRow, out var headerMap, out var lastCol)) { return new ParcelamentosImportSummaryDto { Erros = { new ParcelamentosImportErrorDto { LinhaExcel = 0, Motivo = "Cabeçalho da aba PARCELAMENTOS não encontrado (LINHA/CLIENTE/QT PARCELAS/VALOR CHEIO/DESCONTO/VALOR C/ DESCONTO + meses)." } } }; } var colLinha = GetColAny(headerMap, "LINHA"); var colCliente = GetColAny(headerMap, "CLIENTE"); var colQtParcelas = GetColAny(headerMap, "QT PARCELAS", "QT. PARCELAS", "QTPARCELAS"); var colValorCheio = GetColAny(headerMap, "VALOR CHEIO", "VALORCHEIO"); var colDesconto = GetColAny(headerMap, "DESCONTO"); var colValorComDesconto = GetColAny(headerMap, "VALOR C/ DESCONTO", "VALOR C\\ DESCONTO", "VALORCOMDESCONTO", "VALOR C DESCONTO"); var colItem = GetColAny(headerMap, "ITEM"); var colAnoRef = GetColAny(headerMap, "ANO REF", "ANOREF", "ANO"); if (colItem == 0 && colLinha > 1) colItem = colLinha - 1; if (colAnoRef == 0 && colLinha > 2) colAnoRef = colLinha - 2; var monthMap = BuildDynamicMonthMap(ws, headerRow, colValorComDesconto, lastCol); if (monthMap.Count == 0) { return new ParcelamentosImportSummaryDto { Erros = { new ParcelamentosImportErrorDto { LinhaExcel = headerRow, Motivo = "Meses (JAN..DEZ) não identificados no cabeçalho da aba PARCELAMENTOS." } } }; } 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 startRow = headerRow + 1; var lastRow = ws.LastRowUsed()?.RowNumber() ?? startRow; for (int row = startRow; row <= lastRow; row++) { var linhaValue = GetCellStringValue(ws, row, colLinha); var qtParcelas = GetCellStringValue(ws, row, colQtParcelas); if (!IsValidParcelamentoRow(linhaValue, qtParcelas)) { continue; } summary.Lidos++; var itemStr = GetCellStringValue(ws, row, colItem); var anoRefStr = GetCellStringValue(ws, row, colAnoRef); var anoRef = TryNullableInt(anoRefStr); 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 = anoRefStr }); continue; } ParseParcelas(qtParcelas, out var parcelaAtual, out var totalParcelas); var valorCheio = GetCellDecimalValue(ws, row, colValorCheio); var desconto = GetCellDecimalValue(ws, row, colDesconto); var valorComDesconto = GetCellDecimalValue(ws, row, colValorComDesconto); var parcelamento = new ParcelamentoLine { AnoRef = anoRef, Item = item, Linha = string.IsNullOrWhiteSpace(linhaValue) ? null : linhaValue.Trim(), Cliente = NormalizeText(GetCellStringValue(ws, row, colCliente)), QtParcelas = string.IsNullOrWhiteSpace(qtParcelas) ? null : qtParcelas.Trim(), ParcelaAtual = parcelaAtual, TotalParcelas = totalParcelas, ValorCheio = valorCheio, Desconto = desconto, ValorComDesconto = valorComDesconto, 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 = BuildMonthValuesFromMap(ws, row, monthMap, existingEntity.Id, summary, out var rawMonths); AuditParcelamentoRow(row, valorCheio, desconto, valorComDesconto, rawMonths, summary); if (monthValues.Count > 0) { await _db.ParcelamentoMonthValues.AddRangeAsync(monthValues, cancellationToken); } summary.Atualizados++; continue; } } parcelamento.CreatedAt = DateTime.UtcNow; parcelamento.MonthValues = BuildMonthValuesFromMap(ws, row, monthMap, parcelamento.Id, summary, out var rawMonthsNew); AuditParcelamentoRow(row, valorCheio, desconto, valorComDesconto, rawMonthsNew, 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")) ?? wb.Worksheets.FirstOrDefault(w => { var normalized = NormalizeHeader(w.Name); return normalized.Contains("PARCELAMENTO") || normalized.Contains("PARCELAMENTOS") || normalized.Contains("PARECALEMENTO"); }); } private static List BuildMonthValuesFromMap( IXLWorksheet ws, int row, List<(int Column, int Year, int Month)> monthMap, Guid parcelamentoId, ParcelamentosImportSummaryDto summary, out Dictionary rawValues) { var monthValues = new List(); rawValues = new Dictionary(); foreach (var (column, year, month) in monthMap) { var value = GetCellDecimalValue(ws, row, column); rawValues[$"C{column}:{year}-{month:00}"] = value; if (!value.HasValue) continue; monthValues.Add(new ParcelamentoMonthValue { ParcelamentoLineId = parcelamentoId, Competencia = new DateOnly(year, month, 1), Valor = value, CreatedAt = DateTime.UtcNow }); summary.ParcelasInseridas++; } return monthValues; } private static List<(int Column, int Year, int Month)> BuildDynamicMonthMap( IXLWorksheet ws, int headerRow, int colValorComDesconto, int lastCol) { var header = ws.Row(headerRow); var monthHeaders = new List<(int Col, int Month)>(); foreach (var cell in header.CellsUsed()) { var col = cell.Address.ColumnNumber; if (colValorComDesconto > 0 && col <= colValorComDesconto) continue; var month = ToMonthNumber(cell.GetString()); if (month.HasValue) { monthHeaders.Add((col, month.Value)); } } monthHeaders = monthHeaders.OrderBy(x => x.Col).ToList(); if (monthHeaders.Count == 0) return new List<(int Column, int Year, int Month)>(); var firstJanIdx = monthHeaders.FindIndex(x => x.Month == 1); if (firstJanIdx < 0) return new List<(int Column, int Year, int Month)>(); var block1 = ExtractMonthBlock(monthHeaders, firstJanIdx); var block2 = block1.NextIndex < monthHeaders.Count ? ExtractMonthBlock(monthHeaders, block1.NextIndex) : (new List<(int Col, int Month)>(), block1.NextIndex); var yearRow = headerRow - 1; var year1 = block1.Block.Count > 0 ? FindYearForBlock(ws, yearRow, block1.Block.First().Col, block1.Block.Last().Col, lastCol) : null; var year2 = block2.Block.Count > 0 ? FindYearForBlock(ws, yearRow, block2.Block.First().Col, block2.Block.Last().Col, lastCol) : null; var map = new List<(int Column, int Year, int Month)>(); if (year1.HasValue) { foreach (var (col, month) in block1.Block) map.Add((col, year1.Value, month)); } if (year2.HasValue) { foreach (var (col, month) in block2.Block) map.Add((col, year2.Value, month)); } return map; } private static (List<(int Col, int Month)> Block, int NextIndex) ExtractMonthBlock( List<(int Col, int Month)> monthHeaders, int startIndex) { var block = new List<(int Col, int Month)>(); var idx = startIndex; for (int m = 1; m <= 12; m++) { while (idx < monthHeaders.Count && monthHeaders[idx].Month != m) idx++; if (idx >= monthHeaders.Count) break; block.Add(monthHeaders[idx]); idx++; } return (block, idx); } private static int? FindYearForBlock(IXLWorksheet ws, int yearRow, int startCol, int endCol, int lastCol) { if (yearRow < 1) return null; for (int col = startCol; col <= endCol; col++) { var year = TryParseYear(GetCellStringValue(ws, yearRow, col)); if (year.HasValue) return year; } int? found = null; var bestDistance = int.MaxValue; for (int col = 1; col <= lastCol; col++) { var year = TryParseYear(GetCellStringValue(ws, yearRow, col)); if (!year.HasValue) continue; var dist = Math.Abs(col - startCol); if (dist < bestDistance) { bestDistance = dist; found = year; } } return found; } private static int? TryParseYear(string? s) { if (string.IsNullOrWhiteSpace(s)) return null; var digits = new string(s.Where(char.IsDigit).ToArray()); if (digits.Length == 4 && int.TryParse(digits, out var year)) return year; if (digits.Length > 4) { for (int i = 0; i <= digits.Length - 4; i++) { if (int.TryParse(digits.Substring(i, 4), out var y) && y >= 2000 && y <= 2100) return y; } } return null; } private static bool TryFindParcelamentosHeader( IXLWorksheet ws, out int headerRow, out Dictionary map, out int lastCol) { headerRow = 0; map = new Dictionary(StringComparer.OrdinalIgnoreCase); var firstRow = ws.FirstRowUsed()?.RowNumber() ?? 1; var lastRow = ws.LastRowUsed()?.RowNumber() ?? firstRow; lastCol = ws.LastColumnUsed()?.ColumnNumber() ?? 1; for (int r = firstRow; r <= lastRow; r++) { var row = ws.Row(r); if (!row.CellsUsed().Any()) continue; var headerMap = BuildHeaderMap(row); if (!HasParcelamentosHeaders(row, headerMap)) continue; headerRow = r; map = headerMap; return true; } return false; } private static bool HasParcelamentosHeaders(IXLRow row, Dictionary map) { var hasLinha = GetColAny(map, "LINHA") > 0; var hasCliente = GetColAny(map, "CLIENTE") > 0; var hasQt = GetColAny(map, "QT PARCELAS", "QT. PARCELAS", "QTPARCELAS") > 0; var hasCheio = GetColAny(map, "VALOR CHEIO", "VALORCHEIO") > 0; var hasDesc = GetColAny(map, "DESCONTO") > 0; var hasComDesc = GetColAny(map, "VALOR C/ DESCONTO", "VALOR C\\ DESCONTO", "VALORCOMDESCONTO", "VALOR C DESCONTO") > 0; var hasMonth = row.CellsUsed().Any(c => ToMonthNumber(c.GetString()).HasValue); return hasLinha && hasCliente && hasQt && hasCheio && hasDesc && hasComDesc && hasMonth; } private static bool IsValidParcelamentoRow(string? linhaValue, string? qtParcelas) { if (string.IsNullOrWhiteSpace(linhaValue)) return false; var digits = OnlyDigits(linhaValue); if (string.IsNullOrWhiteSpace(digits) || digits.Length < 8) return false; return !string.IsNullOrWhiteSpace(qtParcelas); } private static decimal? GetCellDecimalValue(IXLWorksheet ws, int row, int col) { if (col <= 0) return null; var cell = ws.Cell(row, col); if (cell.IsEmpty()) return null; if (cell.DataType == XLDataType.Number) { if (cell.TryGetValue(out var dbl)) return Convert.ToDecimal(dbl); } if (cell.TryGetValue(out var dec)) return dec; var s = cell.GetValue(); return TryDecimal(s); } private static void AuditParcelamentoRow( int row, decimal? valorCheio, decimal? desconto, decimal? valorComDesconto, Dictionary monthValues, ParcelamentosImportSummaryDto summary) { var issues = new List(); if (valorCheio.HasValue && desconto.HasValue && desconto.Value > 0 && valorComDesconto.HasValue && Math.Abs(valorCheio.Value - valorComDesconto.Value) < 0.01m) { issues.Add("ValorCheio == ValorComDesconto com desconto > 0"); } if (valorCheio.HasValue && desconto.HasValue && valorComDesconto.HasValue) { var esperado = valorCheio.Value - desconto.Value; if (Math.Abs(esperado - valorComDesconto.Value) > 0.05m) { issues.Add("ValorComDesconto difere de ValorCheio - Desconto"); } } if (valorCheio.HasValue && monthValues.Values.Any(v => v.HasValue && v.Value == valorCheio.Value)) { issues.Add("ValorCheio igual a valor mensal (possível shift de coluna)"); } var monthNonNull = monthValues.Values.Where(v => v.HasValue).Select(v => v!.Value).ToList(); if (monthNonNull.Count >= 4 && monthNonNull.Distinct().Count() == 1) { issues.Add("Meses iguais (possível parse/shift)"); } if (issues.Count == 0) return; var mapped = $"ValorCheio={valorCheio} Desconto={desconto} ValorComDesconto={valorComDesconto}"; var raw = string.Join(" ", monthValues.Select(k => $"{k.Key}={k.Value}")); Console.WriteLine($"[AUDITORIA PARCELAMENTOS] Linha {row} | {string.Join(" | ", issues)} | {mapped} | {raw}"); summary.Erros.Add(new ParcelamentosImportErrorDto { LinhaExcel = row, Motivo = $"Auditoria: {string.Join(" | ", issues)}", Valor = mapped }); } private static string GetCellStringValue(IXLWorksheet ws, int row, int col) { if (col <= 0) return ""; var cell = ws.Cell(row, col); if (cell.IsEmpty()) return ""; return (cell.GetValue() ?? "").Trim(); } 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", ""); } }