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 MveReconciliationService { private readonly AppDbContext _db; public MveReconciliationService(AppDbContext db) { _db = db; } internal async Task BuildAsync( MveParsedFileResult parsedFile, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(parsedFile); var mobileLines = await _db.MobileLines .AsNoTracking() .Include(x => x.Aparelho) .ToListAsync(cancellationToken); var vigencias = await _db.VigenciaLines .AsNoTracking() .ToListAsync(cancellationToken); var userDatas = await _db.UserDatas .AsNoTracking() .ToListAsync(cancellationToken); var systemAggregates = BuildSystemAggregates(mobileLines, vigencias, userDatas); var systemByNumber = systemAggregates .Where(x => !string.IsNullOrWhiteSpace(x.NumeroNormalizado)) .GroupBy(x => x.NumeroNormalizado, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); var reportByNumber = parsedFile.Lines .GroupBy(x => x.NumeroNormalizado, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); var result = new MveReconciliationResult { TotalSystemLines = mobileLines.Count, TotalReportLines = parsedFile.Lines.Count, TotalInvalidRows = parsedFile.Issues.Count(x => x.IssueType == "INVALID_ROW"), TotalUnknownStatuses = parsedFile.Lines.Count(x => !x.StatusLinhaRecognized) }; foreach (var parserIssue in parsedFile.Issues) { result.Issues.Add(new MveReconciliationIssueResult { SourceRowNumber = parserIssue.SourceRowNumber, NumeroLinha = parserIssue.NumeroLinha, IssueType = parserIssue.IssueType, Situation = "linha inválida no relatório", Severity = "WARNING", Syncable = false, ActionSuggestion = "Corrigir o arquivo MVE e refazer a auditoria", Notes = parserIssue.Message }); } var duplicateReportKeys = reportByNumber .Where(entry => entry.Value.Count > 1) .Select(entry => entry.Key) .ToHashSet(StringComparer.Ordinal); foreach (var duplicateKey in duplicateReportKeys) { var duplicates = reportByNumber[duplicateKey]; var first = duplicates[0]; result.TotalDuplicateReportLines++; result.Issues.Add(new MveReconciliationIssueResult { SourceRowNumber = first.SourceRowNumber, NumeroLinha = duplicateKey, IssueType = "DUPLICATE_REPORT", Situation = "duplicidade no relatório", Severity = "WARNING", Syncable = false, ActionSuggestion = "Corrigir a duplicidade no relatório MVE", Notes = $"A linha {duplicateKey} apareceu {duplicates.Count} vezes no arquivo MVE.", ReportStatus = first.StatusLinha, ReportPlan = first.PlanoLinha, ReportSnapshot = BuildReportSnapshot(first) }); } var duplicateSystemKeys = systemByNumber .Where(entry => entry.Value.Count > 1) .Select(entry => entry.Key) .ToHashSet(StringComparer.Ordinal); foreach (var duplicateKey in duplicateSystemKeys) { var duplicates = systemByNumber[duplicateKey]; var first = duplicates[0]; result.TotalDuplicateSystemLines++; result.Issues.Add(new MveReconciliationIssueResult { NumeroLinha = duplicateKey, MobileLineId = first.MobileLine.Id, SystemItem = first.MobileLine.Item, IssueType = "DUPLICATE_SYSTEM", Situation = "duplicidade no sistema", Severity = "WARNING", Syncable = false, ActionSuggestion = "Corrigir a duplicidade interna antes de sincronizar", Notes = $"A linha {duplicateKey} possui {duplicates.Count} registros no sistema.", SystemStatus = first.MobileLine.Status, SystemPlan = first.MobileLine.PlanoContrato, SystemSnapshot = BuildSystemSnapshot(first) }); } var blockedSystemKeys = new HashSet(duplicateSystemKeys, StringComparer.Ordinal); var blockedReportKeys = new HashSet(duplicateReportKeys, StringComparer.Ordinal); var matchedSystemLineIds = new HashSet(); var matchedReportRows = new HashSet(); var allKeys = reportByNumber.Keys .Concat(systemByNumber.Keys) .Where(key => !string.IsNullOrWhiteSpace(key)) .Distinct(StringComparer.Ordinal) .OrderBy(key => key, StringComparer.Ordinal) .ToList(); foreach (var key in allKeys) { if (blockedSystemKeys.Contains(key) || blockedReportKeys.Contains(key)) { continue; } var hasReport = reportByNumber.TryGetValue(key, out var reportLines); var hasSystem = systemByNumber.TryGetValue(key, out var systemLines); var reportLine = hasReport ? reportLines![0] : null; var systemLine = hasSystem ? systemLines![0] : null; if (reportLine == null || systemLine == null) { continue; } matchedSystemLineIds.Add(systemLine.MobileLine.Id); matchedReportRows.Add(reportLine.SourceRowNumber); var comparison = CompareMatchedLine(systemLine, reportLine); RegisterComparisonResult(result, comparison); } var unmatchedSystemLines = systemAggregates .Where(x => !blockedSystemKeys.Contains(x.NumeroNormalizado)) .Where(x => !matchedSystemLineIds.Contains(x.MobileLine.Id)) .ToList(); var unmatchedReportLines = parsedFile.Lines .Where(x => !blockedReportKeys.Contains(x.NumeroNormalizado)) .Where(x => !matchedReportRows.Contains(x.SourceRowNumber)) .ToList(); var chipMatchedSystemLineIds = new HashSet(); var chipMatchedReportRows = new HashSet(); var systemByChip = unmatchedSystemLines .Where(x => !string.IsNullOrWhiteSpace(MveAuditNormalization.NullIfEmptyDigits(x.MobileLine.Chip))) .GroupBy(x => MveAuditNormalization.OnlyDigits(x.MobileLine.Chip), StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); var reportByChip = unmatchedReportLines .Where(x => !string.IsNullOrWhiteSpace(MveAuditNormalization.NullIfEmptyDigits(x.Chip))) .GroupBy(x => MveAuditNormalization.OnlyDigits(x.Chip), StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); var chipKeys = systemByChip.Keys .Intersect(reportByChip.Keys, StringComparer.Ordinal) .OrderBy(key => key, StringComparer.Ordinal) .ToList(); foreach (var chipKey in chipKeys) { var systemCandidates = systemByChip[chipKey]; var reportCandidates = reportByChip[chipKey]; if (systemCandidates.Count != 1 || reportCandidates.Count != 1) { continue; } var systemLine = systemCandidates[0]; var reportLine = reportCandidates[0]; chipMatchedSystemLineIds.Add(systemLine.MobileLine.Id); chipMatchedReportRows.Add(reportLine.SourceRowNumber); var comparison = CompareMatchedByChip(systemLine, reportLine); RegisterComparisonResult(result, comparison); } foreach (var systemLine in unmatchedSystemLines.Where(x => !chipMatchedSystemLineIds.Contains(x.MobileLine.Id))) { result.TotalOnlyInSystem++; result.Issues.Add(new MveReconciliationIssueResult { NumeroLinha = systemLine.NumeroNormalizado, MobileLineId = systemLine.MobileLine.Id, SystemItem = systemLine.MobileLine.Item, IssueType = "ONLY_IN_SYSTEM", Situation = "ausente no relatório", Severity = "WARNING", Syncable = false, ActionSuggestion = "Validar com a Vivo antes de alterar o cadastro", Notes = "A linha existe no sistema, mas não foi encontrada no relatório MVE.", SystemStatus = systemLine.MobileLine.Status, SystemPlan = systemLine.MobileLine.PlanoContrato, SystemSnapshot = BuildSystemSnapshot(systemLine) }); } foreach (var reportLine in unmatchedReportLines.Where(x => !chipMatchedReportRows.Contains(x.SourceRowNumber))) { result.TotalOnlyInReport++; result.Issues.Add(new MveReconciliationIssueResult { SourceRowNumber = reportLine.SourceRowNumber, NumeroLinha = reportLine.NumeroNormalizado, IssueType = "ONLY_IN_REPORT", Situation = "ausente no sistema", Severity = "WARNING", Syncable = false, ActionSuggestion = "Avaliar cadastro manual dessa linha", Notes = "A linha existe no relatório MVE, mas não foi encontrada na página Geral.", ReportStatus = reportLine.StatusLinha, ReportPlan = reportLine.PlanoLinha, ReportSnapshot = BuildReportSnapshot(reportLine) }); } return result; } private static List BuildSystemAggregates( IReadOnlyCollection mobileLines, IReadOnlyCollection vigencias, IReadOnlyCollection userDatas) { var vigenciaByLine = vigencias .Where(x => !string.IsNullOrWhiteSpace(x.Linha)) .GroupBy(x => MveAuditNormalization.OnlyDigits(x.Linha), StringComparer.Ordinal) .ToDictionary( g => g.Key, g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First(), StringComparer.Ordinal); var vigenciaByItem = vigencias .Where(x => x.Item > 0) .GroupBy(x => x.Item) .ToDictionary( g => g.Key, g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First()); var userDataByLine = userDatas .Where(x => !string.IsNullOrWhiteSpace(x.Linha)) .GroupBy(x => MveAuditNormalization.OnlyDigits(x.Linha), StringComparer.Ordinal) .ToDictionary( g => g.Key, g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First(), StringComparer.Ordinal); var userDataByItem = userDatas .Where(x => x.Item > 0) .GroupBy(x => x.Item) .ToDictionary( g => g.Key, g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First()); return mobileLines .Select(line => { var numeroNormalizado = MveAuditNormalization.OnlyDigits(line.Linha); vigenciaByLine.TryGetValue(numeroNormalizado, out var vigencia); if (vigencia == null && line.Item > 0) { vigenciaByItem.TryGetValue(line.Item, out vigencia); } userDataByLine.TryGetValue(numeroNormalizado, out var userData); if (userData == null && line.Item > 0) { userDataByItem.TryGetValue(line.Item, out userData); } return new MveSystemLineAggregate(line, vigencia, userData, numeroNormalizado); }) .ToList(); } private static MveReconciliationIssueResult? CompareMatchedLine( MveSystemLineAggregate systemLine, MveParsedLine reportLine) { var systemSnapshot = BuildSystemSnapshot(systemLine); var reportSnapshot = BuildReportSnapshot(reportLine); var differences = new List(); var systemStatus = MveAuditNormalization.NormalizeSystemStatus(systemSnapshot.StatusLinha); if (!string.Equals(systemStatus.Key, reportLine.StatusLinhaKey, StringComparison.Ordinal)) { differences.Add(new MveAuditDifferenceDto { FieldKey = "status", Label = "Status da linha", SystemValue = NullIfEmpty(systemSnapshot.StatusLinha), ReportValue = NullIfEmpty(reportSnapshot.StatusLinha), Syncable = true }); } AddDifference( differences, "chip", "Chip da linha", systemSnapshot.Chip, reportSnapshot.Chip, syncable: true, comparer: MveAuditNormalization.OnlyDigits); var hasUnknownStatus = !reportLine.StatusLinhaRecognized; if (differences.Count == 0 && !hasUnknownStatus) { return null; } return BuildIssue(systemLine, reportLine, systemSnapshot, reportSnapshot, differences, hasUnknownStatus); } private static MveReconciliationIssueResult? CompareMatchedByChip( MveSystemLineAggregate systemLine, MveParsedLine reportLine) { var systemSnapshot = BuildSystemSnapshot(systemLine); var reportSnapshot = BuildReportSnapshot(reportLine); var systemLocalNumber = MveAuditNormalization.ExtractPhoneLocalNumber(systemSnapshot.NumeroLinha); var reportLocalNumber = MveAuditNormalization.ExtractPhoneLocalNumber(reportSnapshot.NumeroLinha); if (!string.IsNullOrWhiteSpace(systemLocalNumber) && string.Equals(systemLocalNumber, reportLocalNumber, StringComparison.Ordinal)) { return BuildDddReviewIssue(systemLine, reportLine, systemSnapshot, reportSnapshot); } var differences = new List(); AddDifference( differences, "line", "Número da linha", systemSnapshot.NumeroLinha, reportSnapshot.NumeroLinha, syncable: true, comparer: MveAuditNormalization.OnlyDigits); var systemStatus = MveAuditNormalization.NormalizeSystemStatus(systemSnapshot.StatusLinha); if (!string.Equals(systemStatus.Key, reportLine.StatusLinhaKey, StringComparison.Ordinal)) { differences.Add(new MveAuditDifferenceDto { FieldKey = "status", Label = "Status da linha", SystemValue = NullIfEmpty(systemSnapshot.StatusLinha), ReportValue = NullIfEmpty(reportSnapshot.StatusLinha), Syncable = true }); } var hasUnknownStatus = !reportLine.StatusLinhaRecognized; if (differences.Count == 0 && !hasUnknownStatus) { return null; } return BuildIssue(systemLine, reportLine, systemSnapshot, reportSnapshot, differences, hasUnknownStatus); } private static void RegisterComparisonResult(MveReconciliationResult result, MveReconciliationIssueResult? comparison) { if (comparison == null) { result.TotalConciliated++; return; } result.Issues.Add(comparison); if (comparison.Differences.Any(x => x.FieldKey == "status")) { result.TotalStatusDivergences++; } if (comparison.Differences.Any(x => x.FieldKey != "status" && x.Syncable)) { result.TotalDataDivergences++; } if (comparison.Syncable) { result.TotalSyncableIssues++; } } private static MveReconciliationIssueResult BuildIssue( MveSystemLineAggregate systemLine, MveParsedLine reportLine, MveAuditSnapshotDto systemSnapshot, MveAuditSnapshotDto reportSnapshot, List differences, bool hasUnknownStatus) { var notes = new List(); if (hasUnknownStatus) { notes.Add("O STATUS_LINHA do relatório MVE não foi reconhecido pelo mapa de normalização."); } var hasStatusDifference = differences.Any(x => x.FieldKey == "status"); var hasLineDifference = differences.Any(x => x.FieldKey == "line"); var hasChipDifference = differences.Any(x => x.FieldKey == "chip"); var hasDataDifference = differences.Any(x => x.FieldKey != "status" && x.Syncable); return new MveReconciliationIssueResult { SourceRowNumber = reportLine.SourceRowNumber, NumeroLinha = reportLine.NumeroNormalizado, MobileLineId = systemLine.MobileLine.Id, SystemItem = systemLine.MobileLine.Item, IssueType = ResolveIssueType(hasStatusDifference, hasLineDifference, hasChipDifference, hasUnknownStatus), Situation = ResolveSituation(hasStatusDifference, hasLineDifference, hasChipDifference, hasUnknownStatus), Severity = ResolveSeverity(hasStatusDifference, hasDataDifference, hasUnknownStatus), Syncable = differences.Any(x => x.Syncable), ActionSuggestion = ResolveActionSuggestion(hasStatusDifference, hasLineDifference, hasChipDifference, hasUnknownStatus), Notes = notes.Count == 0 ? null : string.Join(" ", notes), SystemStatus = systemSnapshot.StatusLinha, ReportStatus = reportSnapshot.StatusLinha, SystemPlan = systemSnapshot.PlanoLinha, ReportPlan = reportSnapshot.PlanoLinha, SystemSnapshot = systemSnapshot, ReportSnapshot = reportSnapshot, Differences = differences }; } private static MveReconciliationIssueResult BuildDddReviewIssue( MveSystemLineAggregate systemLine, MveParsedLine reportLine, MveAuditSnapshotDto systemSnapshot, MveAuditSnapshotDto reportSnapshot) { return new MveReconciliationIssueResult { SourceRowNumber = reportLine.SourceRowNumber, NumeroLinha = reportLine.NumeroNormalizado, MobileLineId = systemLine.MobileLine.Id, SystemItem = systemLine.MobileLine.Item, IssueType = "DDD_CHANGE_REVIEW", Situation = "mudança de DDD detectada", Severity = "WARNING", Syncable = false, ActionSuggestion = "Revisar manualmente na página Mureg antes de aplicar alterações", Notes = "O mesmo chip foi encontrado com o mesmo número base, mas com DDD diferente. Esse cenário ainda não é atualizado automaticamente pelo MVE.", SystemStatus = systemSnapshot.StatusLinha, ReportStatus = reportSnapshot.StatusLinha, SystemPlan = systemSnapshot.PlanoLinha, ReportPlan = reportSnapshot.PlanoLinha, SystemSnapshot = systemSnapshot, ReportSnapshot = reportSnapshot, Differences = new List { new() { FieldKey = "ddd", Label = "DDD da linha", SystemValue = NullIfEmpty(MveAuditNormalization.ExtractPhoneDdd(systemSnapshot.NumeroLinha)), ReportValue = NullIfEmpty(MveAuditNormalization.ExtractPhoneDdd(reportSnapshot.NumeroLinha)), Syncable = false } } }; } private static string ResolveIssueType( bool hasStatusDifference, bool hasLineDifference, bool hasChipDifference, bool hasUnknownStatus) { if (hasLineDifference) { return "LINE_CHANGE_DETECTED"; } if (hasChipDifference) { return "CHIP_CHANGE_DETECTED"; } if (hasStatusDifference) { return "STATUS_DIVERGENCE"; } return hasUnknownStatus ? "UNKNOWN_STATUS" : "ALIGNED"; } private static string ResolveSituation( bool hasStatusDifference, bool hasLineDifference, bool hasChipDifference, bool hasUnknownStatus) { if (hasLineDifference && hasStatusDifference) { return "troca de número e status diferente"; } if (hasLineDifference) { return "troca de número detectada"; } if (hasChipDifference && hasStatusDifference) { return "troca de chip e status diferente"; } if (hasChipDifference) { return "troca de chip detectada"; } if (hasStatusDifference) { return "divergência de status"; } return hasUnknownStatus ? "status desconhecido no relatório" : "alinhada"; } private static string ResolveSeverity(bool hasStatusDifference, bool hasDataDifference, bool hasUnknownStatus) { if (hasStatusDifference) { return "HIGH"; } if (hasDataDifference) { return "MEDIUM"; } return hasUnknownStatus ? "WARNING" : "INFO"; } private static string ResolveActionSuggestion( bool hasStatusDifference, bool hasLineDifference, bool hasChipDifference, bool hasUnknownStatus) { if (hasLineDifference && hasStatusDifference) { return "Atualizar linha e status da linha com base no MVE"; } if (hasLineDifference) { return "Atualizar a linha cadastrada com base no chip informado no MVE"; } if (hasChipDifference && hasStatusDifference) { return "Atualizar chip e status da linha com base no MVE"; } if (hasChipDifference) { return "Atualizar o chip da linha com base no MVE"; } if (hasStatusDifference) { return "Atualizar status da linha com base no MVE"; } return hasUnknownStatus ? "Revisar o status recebido e ajustar o mapa de normalização se necessário" : "Nenhuma ação"; } private static MveAuditSnapshotDto BuildSystemSnapshot(MveSystemLineAggregate systemLine) { return new MveAuditSnapshotDto { NumeroLinha = NullIfEmpty(systemLine.MobileLine.Linha), StatusLinha = NullIfEmpty(systemLine.MobileLine.Status), PlanoLinha = NullIfEmpty(systemLine.MobileLine.PlanoContrato), DataAtivacao = systemLine.Vigencia?.DtEfetivacaoServico, TerminoContrato = systemLine.Vigencia?.DtTerminoFidelizacao, Chip = NullIfEmpty(systemLine.MobileLine.Chip), Conta = NullIfEmpty(systemLine.MobileLine.Conta), Cnpj = NullIfEmpty(systemLine.UserData?.Cnpj), ModeloAparelho = NullIfEmpty(systemLine.MobileLine.Aparelho?.Nome), Fabricante = NullIfEmpty(systemLine.MobileLine.Aparelho?.Fabricante) }; } private static MveAuditSnapshotDto BuildReportSnapshot(MveParsedLine reportLine) { return new MveAuditSnapshotDto { NumeroLinha = NullIfEmpty(reportLine.NumeroNormalizado), StatusLinha = NullIfEmpty(reportLine.StatusLinha), StatusConta = NullIfEmpty(reportLine.StatusConta), PlanoLinha = NullIfEmpty(reportLine.PlanoLinha), DataAtivacao = reportLine.DataAtivacao, TerminoContrato = reportLine.TerminoContrato, Chip = NullIfEmpty(reportLine.Chip), Conta = NullIfEmpty(reportLine.Conta), Cnpj = NullIfEmpty(reportLine.Cnpj), ModeloAparelho = NullIfEmpty(reportLine.ModeloAparelho), Fabricante = NullIfEmpty(reportLine.Fabricante), ServicosAtivos = reportLine.ServicosAtivos .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList() }; } private static void AddDifference( ICollection differences, string fieldKey, string label, string? systemValue, string? reportValue, bool syncable, Func comparer) { var normalizedSystem = comparer(systemValue); var normalizedReport = comparer(reportValue); if (string.Equals(normalizedSystem, normalizedReport, StringComparison.Ordinal)) { return; } differences.Add(new MveAuditDifferenceDto { FieldKey = fieldKey, Label = label, SystemValue = NullIfEmpty(systemValue), ReportValue = NullIfEmpty(reportValue), Syncable = syncable }); } private static string? NullIfEmpty(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } } public sealed class MveReconciliationResult { public int TotalSystemLines { get; init; } public int TotalReportLines { get; init; } public int TotalConciliated { get; set; } public int TotalStatusDivergences { get; set; } public int TotalDataDivergences { get; set; } public int TotalOnlyInSystem { get; set; } public int TotalOnlyInReport { get; set; } public int TotalDuplicateReportLines { get; set; } public int TotalDuplicateSystemLines { get; set; } public int TotalInvalidRows { get; init; } public int TotalUnknownStatuses { get; init; } public int TotalSyncableIssues { get; set; } public List Issues { get; } = new(); } public sealed class MveReconciliationIssueResult { public int? SourceRowNumber { get; init; } public string NumeroLinha { get; init; } = string.Empty; public Guid? MobileLineId { get; init; } public int? SystemItem { get; init; } public string IssueType { get; init; } = string.Empty; public string Situation { get; init; } = string.Empty; public string Severity { get; init; } = "INFO"; public bool Syncable { get; init; } public string? ActionSuggestion { get; init; } public string? Notes { get; init; } public string? SystemStatus { get; init; } public string? ReportStatus { get; init; } public string? SystemPlan { get; init; } public string? ReportPlan { get; init; } public MveAuditSnapshotDto? SystemSnapshot { get; init; } public MveAuditSnapshotDto? ReportSnapshot { get; init; } public List Differences { get; init; } = new(); } internal sealed record MveSystemLineAggregate( MobileLine MobileLine, VigenciaLine? Vigencia, UserData? UserData, string NumeroNormalizado);