331 lines
11 KiB
C#
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);
|