line-gestao-api/Services/MveCsvParserService.cs

331 lines
11 KiB
C#

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
namespace line_gestao_api.Services;
public sealed class MveCsvParserService
{
private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, true);
private static readonly Encoding Latin1 = Encoding.GetEncoding("ISO-8859-1");
private static readonly Encoding Windows1252 = Encoding.GetEncoding(1252);
private static readonly string[] RequiredHeaders = ["DDD", "NUMERO", "STATUS_LINHA"];
static MveCsvParserService()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
internal async Task<MveParsedFileResult> ParseAsync(IFormFile file, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(file);
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory, cancellationToken);
return Parse(memory.ToArray(), file.FileName);
}
internal MveParsedFileResult Parse(byte[] bytes, string? fileName)
{
ArgumentNullException.ThrowIfNull(bytes);
var decoded = Decode(bytes);
var rows = ParseCsvRows(decoded.Content);
if (rows.Count == 0)
{
throw new InvalidOperationException("O arquivo CSV do MVE está vazio.");
}
var headerRow = rows[0];
var headerMap = BuildHeaderMap(headerRow);
var missingHeaders = RequiredHeaders
.Where(header => !headerMap.ContainsKey(MveAuditNormalization.NormalizeHeader(header)))
.ToList();
if (missingHeaders.Count > 0)
{
throw new InvalidOperationException(
$"O relatório MVE não contém as colunas obrigatórias: {string.Join(", ", missingHeaders)}.");
}
var serviceColumns = headerMap
.Where(entry => entry.Key.StartsWith("SERVICO_ATIVOS", StringComparison.Ordinal))
.OrderBy(entry => entry.Value)
.Select(entry => entry.Value)
.ToList();
var result = new MveParsedFileResult
{
FileName = string.IsNullOrWhiteSpace(fileName) ? null : fileName.Trim(),
FileEncoding = decoded.Encoding.WebName,
FileHashSha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(),
SourceRowCount = Math.Max(rows.Count - 1, 0)
};
for (var rowIndex = 1; rowIndex < rows.Count; rowIndex++)
{
var row = rows[rowIndex];
if (IsEmptyRow(row))
{
continue;
}
var sourceRowNumber = rowIndex + 1;
var ddd = GetValue(row, headerMap, "DDD");
var numero = GetValue(row, headerMap, "NUMERO");
var numeroNormalizado = MveAuditNormalization.OnlyDigits($"{ddd}{numero}");
if (string.IsNullOrWhiteSpace(ddd) || string.IsNullOrWhiteSpace(numero) || string.IsNullOrWhiteSpace(numeroNormalizado))
{
result.Issues.Add(new MveParsedIssue(
sourceRowNumber,
string.Empty,
"INVALID_ROW",
"Linha sem DDD e/ou número válido no relatório MVE."));
continue;
}
var status = MveAuditNormalization.NormalizeReportStatus(GetValue(row, headerMap, "STATUS_LINHA"));
var line = new MveParsedLine
{
SourceRowNumber = sourceRowNumber,
Ddd = ddd,
Numero = numero,
NumeroNormalizado = numeroNormalizado,
StatusLinha = status.DisplayValue,
StatusLinhaKey = status.Key,
StatusLinhaRecognized = status.Recognized,
StatusConta = GetValue(row, headerMap, "STATUS_CONTA"),
PlanoLinha = GetValue(row, headerMap, "PLANO_LINHA"),
DataAtivacao = MveAuditNormalization.ParseDateValue(GetValue(row, headerMap, "DATA_ATIVACAO")),
TerminoContrato = MveAuditNormalization.ParseDateValue(GetValue(row, headerMap, "TERMINO_CONTRATO")),
Chip = MveAuditNormalization.NullIfEmptyDigits(GetValue(row, headerMap, "CHIP")),
Conta = NullIfEmpty(MveAuditNormalization.CleanTextValue(GetValue(row, headerMap, "CONTA"))),
Cnpj = MveAuditNormalization.NullIfEmptyDigits(GetValue(row, headerMap, "CNPJ")),
ModeloAparelho = NullIfEmpty(MveAuditNormalization.CleanTextValue(GetValue(row, headerMap, "MODELO_APARELHO"))),
Fabricante = NullIfEmpty(MveAuditNormalization.CleanTextValue(GetValue(row, headerMap, "FABRICANTE")))
};
foreach (var columnIndex in serviceColumns)
{
if (columnIndex < 0 || columnIndex >= row.Count)
{
continue;
}
var serviceValue = NullIfEmpty(MveAuditNormalization.CleanTextValue(row[columnIndex]));
if (!string.IsNullOrWhiteSpace(serviceValue))
{
line.ServicosAtivos.Add(serviceValue);
}
}
result.Lines.Add(line);
}
return result;
}
private static DecodedContent Decode(byte[] bytes)
{
foreach (var encoding in new[] { StrictUtf8, Windows1252, Latin1 })
{
try
{
var content = encoding.GetString(bytes);
if (!string.IsNullOrWhiteSpace(content))
{
return new DecodedContent(encoding, content.TrimStart('\uFEFF'));
}
}
catch
{
// tenta o próximo encoding
}
}
return new DecodedContent(Latin1, Latin1.GetString(bytes).TrimStart('\uFEFF'));
}
private static List<List<string>> ParseCsvRows(string content)
{
var rows = new List<List<string>>();
var currentRow = new List<string>();
var currentField = new StringBuilder();
var inQuotes = false;
for (var i = 0; i < content.Length; i++)
{
var ch = content[i];
if (inQuotes)
{
if (ch == '"')
{
if (i + 1 < content.Length && content[i + 1] == '"')
{
currentField.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
currentField.Append(ch);
}
continue;
}
switch (ch)
{
case '"':
inQuotes = true;
break;
case ';':
currentRow.Add(currentField.ToString());
currentField.Clear();
break;
case '\r':
if (i + 1 < content.Length && content[i + 1] == '\n')
{
i++;
}
currentRow.Add(currentField.ToString());
currentField.Clear();
rows.Add(currentRow);
currentRow = new List<string>();
break;
case '\n':
currentRow.Add(currentField.ToString());
currentField.Clear();
rows.Add(currentRow);
currentRow = new List<string>();
break;
default:
currentField.Append(ch);
break;
}
}
currentRow.Add(currentField.ToString());
if (currentRow.Count > 1 || !string.IsNullOrWhiteSpace(currentRow[0]))
{
rows.Add(currentRow);
}
return rows;
}
private static Dictionary<string, int> BuildHeaderMap(IReadOnlyList<string> headerRow)
{
var map = new Dictionary<string, int>(StringComparer.Ordinal);
for (var i = 0; i < headerRow.Count; i++)
{
var key = MveAuditNormalization.NormalizeHeader(headerRow[i]);
if (string.IsNullOrWhiteSpace(key) || key.StartsWith("UNNAMED", StringComparison.Ordinal))
{
continue;
}
if (!map.ContainsKey(key))
{
map[key] = i;
}
}
return map;
}
private static string GetValue(IReadOnlyList<string> row, IReadOnlyDictionary<string, int> headerMap, string header)
{
var normalizedHeader = MveAuditNormalization.NormalizeHeader(header);
if (!headerMap.TryGetValue(normalizedHeader, out var index))
{
return string.Empty;
}
if (index < 0 || index >= row.Count)
{
return string.Empty;
}
return MveAuditNormalization.CleanTextValue(row[index]);
}
private static bool IsEmptyRow(IReadOnlyList<string> row)
{
return row.Count == 0 || row.All(cell => string.IsNullOrWhiteSpace(MveAuditNormalization.CleanTextValue(cell)));
}
private static string? NullIfEmpty(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private sealed record DecodedContent(Encoding Encoding, string Content);
}
internal sealed class MveParsedFileResult
{
public string? FileName { get; init; }
public string FileEncoding { get; init; } = string.Empty;
public string FileHashSha256 { get; init; } = string.Empty;
public int SourceRowCount { get; init; }
public List<MveParsedLine> Lines { get; } = new();
public List<MveParsedIssue> Issues { get; } = new();
}
internal sealed class MveParsedLine
{
public int SourceRowNumber { get; init; }
public string Ddd { get; init; } = string.Empty;
public string Numero { get; init; } = string.Empty;
public string NumeroNormalizado { get; init; } = string.Empty;
public string StatusLinha { get; init; } = string.Empty;
public string StatusLinhaKey { get; init; } = string.Empty;
public bool StatusLinhaRecognized { get; init; }
public string? StatusConta { get; init; }
public string? PlanoLinha { get; init; }
public DateTime? DataAtivacao { get; init; }
public DateTime? TerminoContrato { get; init; }
public string? Chip { get; init; }
public string? Conta { get; init; }
public string? Cnpj { get; init; }
public string? ModeloAparelho { get; init; }
public string? Fabricante { get; init; }
public List<string> ServicosAtivos { get; } = new();
}
internal sealed record MveParsedIssue(
int SourceRowNumber,
string NumeroLinha,
string IssueType,
string Message);