using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text.Json; using System.Text.Json.Serialization; using line_gestao_api.Data; using line_gestao_api.Dtos; using line_gestao_api.Models; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; namespace line_gestao_api.Services; public sealed class MveAuditService { private static readonly JsonSerializerOptions JsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly AppDbContext _db; private readonly IHttpContextAccessor _httpContextAccessor; private readonly ITenantProvider _tenantProvider; private readonly MveCsvParserService _parser; private readonly MveReconciliationService _reconciliation; private readonly IVigenciaNotificationSyncService _vigenciaNotificationSyncService; public MveAuditService( AppDbContext db, IHttpContextAccessor httpContextAccessor, ITenantProvider tenantProvider, MveCsvParserService parser, MveReconciliationService reconciliation, IVigenciaNotificationSyncService vigenciaNotificationSyncService) { _db = db; _httpContextAccessor = httpContextAccessor; _tenantProvider = tenantProvider; _parser = parser; _reconciliation = reconciliation; _vigenciaNotificationSyncService = vigenciaNotificationSyncService; } public async Task CreateRunAsync(IFormFile file, CancellationToken cancellationToken = default) { ValidateInputFile(file); var parsedFile = await _parser.ParseAsync(file, cancellationToken); var reconciliation = await _reconciliation.BuildAsync(parsedFile, cancellationToken); var run = new MveAuditRun { Id = Guid.NewGuid(), FileName = parsedFile.FileName, FileHashSha256 = parsedFile.FileHashSha256, FileEncoding = parsedFile.FileEncoding, Status = "READY", TotalSystemLines = reconciliation.TotalSystemLines, TotalReportLines = reconciliation.TotalReportLines, TotalConciliated = reconciliation.TotalConciliated, TotalStatusDivergences = reconciliation.TotalStatusDivergences, TotalDataDivergences = reconciliation.TotalDataDivergences, TotalOnlyInSystem = reconciliation.TotalOnlyInSystem, TotalOnlyInReport = reconciliation.TotalOnlyInReport, TotalDuplicateReportLines = reconciliation.TotalDuplicateReportLines, TotalDuplicateSystemLines = reconciliation.TotalDuplicateSystemLines, TotalInvalidRows = reconciliation.TotalInvalidRows, TotalUnknownStatuses = reconciliation.TotalUnknownStatuses, TotalSyncableIssues = reconciliation.TotalSyncableIssues, ImportedAtUtc = DateTime.UtcNow }; foreach (var issue in reconciliation.Issues) { run.Issues.Add(new MveAuditIssue { Id = Guid.NewGuid(), AuditRunId = run.Id, SourceRowNumber = issue.SourceRowNumber, NumeroLinha = string.IsNullOrWhiteSpace(issue.NumeroLinha) ? "-" : issue.NumeroLinha.Trim(), MobileLineId = issue.MobileLineId, SystemItem = issue.SystemItem, IssueType = issue.IssueType, Situation = issue.Situation, Severity = issue.Severity, Syncable = issue.Syncable, ActionSuggestion = issue.ActionSuggestion, Notes = issue.Notes, SystemStatus = issue.SystemStatus, ReportStatus = issue.ReportStatus, SystemPlan = issue.SystemPlan, ReportPlan = issue.ReportPlan, SystemSnapshotJson = JsonSerializer.Serialize(issue.SystemSnapshot, JsonOptions), ReportSnapshotJson = JsonSerializer.Serialize(issue.ReportSnapshot, JsonOptions), DifferencesJson = JsonSerializer.Serialize(issue.Differences, JsonOptions), CreatedAtUtc = DateTime.UtcNow }); } _db.MveAuditRuns.Add(run); _db.AuditLogs.Add(BuildAuditLog( action: "MVE_AUDIT_RUN", runId: run.Id, fileName: run.FileName, changes: new List { new() { Field = "TotalLinhasSistema", ChangeType = "captured", NewValue = run.TotalSystemLines.ToString() }, new() { Field = "TotalLinhasRelatorio", ChangeType = "captured", NewValue = run.TotalReportLines.ToString() }, new() { Field = "DivergenciasStatus", ChangeType = "captured", NewValue = run.TotalStatusDivergences.ToString() }, new() { Field = "DivergenciasCadastro", ChangeType = "captured", NewValue = run.TotalDataDivergences.ToString() }, new() { Field = "ItensSincronizaveis", ChangeType = "captured", NewValue = run.TotalSyncableIssues.ToString() } }, metadata: new { run.FileHashSha256, run.FileEncoding, run.TotalOnlyInSystem, run.TotalOnlyInReport, run.TotalDuplicateReportLines, run.TotalDuplicateSystemLines, run.TotalInvalidRows, parsedFile.SourceRowCount })); await _db.SaveChangesAsync(cancellationToken); return ToDto(run); } public async Task GetByIdAsync(Guid runId, CancellationToken cancellationToken = default) { var run = await _db.MveAuditRuns .AsNoTracking() .Include(x => x.Issues) .FirstOrDefaultAsync(x => x.Id == runId, cancellationToken); return run == null ? null : ToDto(run); } public async Task GetLatestAsync(CancellationToken cancellationToken = default) { var run = await _db.MveAuditRuns .AsNoTracking() .Include(x => x.Issues) .OrderByDescending(x => x.ImportedAtUtc) .ThenByDescending(x => x.Id) .FirstOrDefaultAsync(cancellationToken); return run == null ? null : ToDto(run); } public async Task ApplyAsync( Guid runId, IReadOnlyCollection? issueIds, CancellationToken cancellationToken = default) { var run = await _db.MveAuditRuns .Include(x => x.Issues) .FirstOrDefaultAsync(x => x.Id == runId, cancellationToken); if (run == null) { return null; } var requestedIds = issueIds? .Where(x => x != Guid.Empty) .Distinct() .ToHashSet() ?? new HashSet(); var selectedIssues = run.Issues .Where(x => x.Syncable && !x.Applied) .Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id)) .ToList(); var result = new ApplyMveAuditResultDto { AuditRunId = run.Id, RequestedIssues = requestedIds.Count == 0 ? selectedIssues.Count : requestedIds.Count }; if (selectedIssues.Count == 0) { result.SkippedIssues = result.RequestedIssues; return result; } var lineIds = selectedIssues .Where(x => x.MobileLineId.HasValue) .Select(x => x.MobileLineId!.Value) .Distinct() .ToList(); var linesById = await _db.MobileLines .Where(x => lineIds.Contains(x.Id)) .ToDictionaryAsync(x => x.Id, cancellationToken); var reportSnapshotsByIssueId = selectedIssues .ToDictionary(x => x.Id, x => DeserializeSnapshot(x.ReportSnapshotJson)); var lineNumbers = linesById.Values .Select(x => MveAuditNormalization.NullIfEmptyDigits(x.Linha)) .Where(x => !string.IsNullOrWhiteSpace(x)) .Cast() .Concat(reportSnapshotsByIssueId.Values .Select(x => MveAuditNormalization.NullIfEmptyDigits(x?.NumeroLinha)) .Where(x => !string.IsNullOrWhiteSpace(x)) .Cast()) .Distinct(StringComparer.Ordinal) .ToList(); var items = linesById.Values .Where(x => x.Item > 0) .Select(x => x.Item) .Distinct() .ToList(); var vigencias = await _db.VigenciaLines .Where(x => (x.Item > 0 && items.Contains(x.Item)) || (x.Linha != null && lineNumbers.Contains(x.Linha))) .ToListAsync(cancellationToken); var userDatas = await _db.UserDatas .Where(x => (x.Item > 0 && items.Contains(x.Item)) || (x.Linha != null && lineNumbers.Contains(x.Linha))) .ToListAsync(cancellationToken); var vigenciaByLine = vigencias .Where(x => !string.IsNullOrWhiteSpace(x.Linha)) .GroupBy(x => 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 => 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()); var now = DateTime.UtcNow; var updatedLineIds = new HashSet(); var updatedFields = 0; var appliedIssues = 0; var skippedIssues = 0; await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); foreach (var issue in selectedIssues) { if (!issue.MobileLineId.HasValue || !linesById.TryGetValue(issue.MobileLineId.Value, out var line)) { skippedIssues++; continue; } if (!reportSnapshotsByIssueId.TryGetValue(issue.Id, out var reportSnapshot)) { skippedIssues++; continue; } if (reportSnapshot == null) { skippedIssues++; continue; } var differences = DeserializeDifferences(issue.DifferencesJson); var lineChanged = false; var hasLineDifference = differences.Any(x => x.Syncable && x.FieldKey == "line"); var hasChipDifference = differences.Any(x => x.Syncable && x.FieldKey == "chip"); var hasStatusDifference = differences.Any(x => x.Syncable && x.FieldKey == "status"); var previousLine = MveAuditNormalization.NullIfEmptyDigits(line.Linha); var nextLine = hasLineDifference ? MveAuditNormalization.NullIfEmptyDigits(reportSnapshot.NumeroLinha) : previousLine; if (hasLineDifference) { if (string.IsNullOrWhiteSpace(nextLine)) { skippedIssues++; continue; } var hasConflict = await _db.MobileLines .AsNoTracking() .AnyAsync( x => x.TenantId == line.TenantId && x.Id != line.Id && x.Linha == nextLine, cancellationToken); if (hasConflict) { skippedIssues++; continue; } } if (hasLineDifference && SetString(line.Linha, nextLine, value => line.Linha = value)) { lineChanged = true; updatedFields++; SyncLinkedLineRecords( line, previousLine, nextLine, vigenciaByLine, vigenciaByItem, userDataByLine, userDataByItem, now); AddTrocaNumeroHistory( line, previousLine, nextLine, MveAuditNormalization.NullIfEmptyDigits(reportSnapshot.Chip) ?? MveAuditNormalization.NullIfEmptyDigits(line.Chip), now); } if (hasChipDifference) { var nextChip = MveAuditNormalization.NullIfEmptyDigits(reportSnapshot.Chip); if (SetString(line.Chip, nextChip, value => line.Chip = value)) { lineChanged = true; updatedFields++; } } if (hasStatusDifference) { var systemStatus = MveAuditNormalization.NormalizeStatusForSystem(reportSnapshot.StatusLinha); if (SetString(line.Status, systemStatus, value => line.Status = value)) { ApplyBlockedLineContext(line); lineChanged = true; updatedFields++; } } if (lineChanged) { line.UpdatedAt = now; updatedLineIds.Add(line.Id); } issue.Applied = true; issue.AppliedAtUtc = now; appliedIssues++; } run.AppliedIssuesCount = run.Issues.Count(x => x.Applied); run.AppliedLinesCount += updatedLineIds.Count; run.AppliedFieldsCount += updatedFields; run.AppliedAtUtc = now; run.AppliedByUserId = ResolveUserId(_httpContextAccessor.HttpContext?.User); run.AppliedByUserName = ResolveUserName(_httpContextAccessor.HttpContext?.User); run.AppliedByUserEmail = ResolveUserEmail(_httpContextAccessor.HttpContext?.User); run.Status = run.AppliedIssuesCount >= run.TotalSyncableIssues ? "APPLIED" : run.AppliedIssuesCount > 0 ? "PARTIAL_APPLIED" : "READY"; _db.AuditLogs.Add(BuildAuditLog( action: "MVE_AUDIT_APPLY", runId: run.Id, fileName: run.FileName, changes: new List { new() { Field = "IssuesAplicadas", ChangeType = "modified", NewValue = appliedIssues.ToString() }, new() { Field = "LinhasAtualizadas", ChangeType = "modified", NewValue = updatedLineIds.Count.ToString() }, new() { Field = "CamposAtualizados", ChangeType = "modified", NewValue = updatedFields.ToString() } }, metadata: new { requestedIssues = result.RequestedIssues, appliedIssues, updatedLines = updatedLineIds.Count, updatedFields, skippedIssues })); await _db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); if (appliedIssues > 0) { await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); } result.AppliedIssues = appliedIssues; result.UpdatedLines = updatedLineIds.Count; result.UpdatedFields = updatedFields; result.SkippedIssues = skippedIssues; return result; } private static void ValidateInputFile(IFormFile file) { if (file == null || file.Length <= 0) { throw new InvalidOperationException("Selecione um arquivo CSV do MVE para continuar."); } if (file.Length > 20_000_000) { throw new InvalidOperationException("O arquivo do MVE excede o limite de 20 MB."); } var extension = Path.GetExtension(file.FileName ?? string.Empty); if (!string.Equals(extension, ".csv", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("O relatório MVE deve ser enviado em formato CSV."); } } private MveAuditRunDto ToDto(MveAuditRun run) { return new MveAuditRunDto { Id = run.Id, FileName = run.FileName, FileEncoding = run.FileEncoding, Status = run.Status, ImportedAtUtc = run.ImportedAtUtc, AppliedAtUtc = run.AppliedAtUtc, AppliedByUserName = run.AppliedByUserName, AppliedByUserEmail = run.AppliedByUserEmail, Summary = new MveAuditSummaryDto { TotalSystemLines = run.TotalSystemLines, TotalReportLines = run.TotalReportLines, TotalConciliated = run.TotalConciliated, TotalStatusDivergences = run.TotalStatusDivergences, TotalDataDivergences = run.TotalDataDivergences, TotalOnlyInSystem = run.TotalOnlyInSystem, TotalOnlyInReport = run.TotalOnlyInReport, TotalDuplicateReportLines = run.TotalDuplicateReportLines, TotalDuplicateSystemLines = run.TotalDuplicateSystemLines, TotalInvalidRows = run.TotalInvalidRows, TotalUnknownStatuses = run.TotalUnknownStatuses, TotalSyncableIssues = run.TotalSyncableIssues, AppliedIssuesCount = run.AppliedIssuesCount, AppliedLinesCount = run.AppliedLinesCount, AppliedFieldsCount = run.AppliedFieldsCount }, Issues = run.Issues .OrderByDescending(x => x.Syncable) .ThenByDescending(x => x.Severity) .ThenBy(x => x.NumeroLinha) .Select(issue => new MveAuditIssueDto { Id = issue.Id, SourceRowNumber = issue.SourceRowNumber, NumeroLinha = issue.NumeroLinha, MobileLineId = issue.MobileLineId, SystemItem = issue.SystemItem, IssueType = issue.IssueType, Situation = issue.Situation, Severity = issue.Severity, Syncable = issue.Syncable, Applied = issue.Applied, ActionSuggestion = issue.ActionSuggestion, Notes = issue.Notes, SystemStatus = issue.SystemStatus, ReportStatus = issue.ReportStatus, SystemPlan = issue.SystemPlan, ReportPlan = issue.ReportPlan, SystemSnapshot = DeserializeSnapshot(issue.SystemSnapshotJson), ReportSnapshot = DeserializeSnapshot(issue.ReportSnapshotJson), Differences = DeserializeDifferences(issue.DifferencesJson) }) .ToList() }; } private AuditLog BuildAuditLog( string action, Guid runId, string? fileName, IReadOnlyCollection changes, object metadata) { var actorTenantId = _tenantProvider.ActorTenantId; if (!actorTenantId.HasValue || actorTenantId.Value == Guid.Empty) { throw new InvalidOperationException("Tenant inválido para registrar auditoria MVE."); } var user = _httpContextAccessor.HttpContext?.User; var request = _httpContextAccessor.HttpContext?.Request; return new AuditLog { TenantId = actorTenantId.Value, ActorTenantId = actorTenantId.Value, TargetTenantId = actorTenantId.Value, ActorUserId = ResolveUserId(user), UserId = ResolveUserId(user), UserName = ResolveUserName(user), UserEmail = ResolveUserEmail(user), OccurredAtUtc = DateTime.UtcNow, Action = action, Page = "Geral", EntityName = "MveAudit", EntityId = runId.ToString(), EntityLabel = string.IsNullOrWhiteSpace(fileName) ? "Auditoria MVE" : fileName.Trim(), ChangesJson = JsonSerializer.Serialize(changes, JsonOptions), MetadataJson = JsonSerializer.Serialize(metadata, JsonOptions), RequestPath = request?.Path.Value, RequestMethod = request?.Method, IpAddress = _httpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString() }; } private static MveAuditSnapshotDto? DeserializeSnapshot(string? json) { if (string.IsNullOrWhiteSpace(json)) { return null; } try { return JsonSerializer.Deserialize(json, JsonOptions); } catch { return null; } } private static List DeserializeDifferences(string? json) { if (string.IsNullOrWhiteSpace(json)) { return new List(); } try { return JsonSerializer.Deserialize>(json, JsonOptions) ?? new List(); } catch { return new List(); } } private void SyncLinkedLineRecords( MobileLine line, string? previousLine, string? nextLine, IDictionary vigenciaByLine, IDictionary vigenciaByItem, IDictionary userDataByLine, IDictionary userDataByItem, DateTime now) { if (string.IsNullOrWhiteSpace(nextLine)) { return; } var lookupLine = !string.IsNullOrWhiteSpace(previousLine) ? previousLine : nextLine; var vigencia = ResolveVigencia(line, lookupLine, vigenciaByLine, vigenciaByItem); if (vigencia != null && SetString(vigencia.Linha, nextLine, value => vigencia.Linha = value)) { vigencia.UpdatedAt = now; RefreshLineLookup(vigenciaByLine, previousLine, nextLine, vigencia); } var userData = ResolveUserData(line, lookupLine, userDataByLine, userDataByItem); if (userData != null && SetString(userData.Linha, nextLine, value => userData.Linha = value)) { userData.UpdatedAt = now; RefreshLineLookup(userDataByLine, previousLine, nextLine, userData); } } private void AddTrocaNumeroHistory( MobileLine line, string? previousLine, string? nextLine, string? chip, DateTime now) { if (string.IsNullOrWhiteSpace(previousLine) || string.IsNullOrWhiteSpace(nextLine) || string.Equals(previousLine, nextLine, StringComparison.Ordinal)) { return; } _db.TrocaNumeroLines.Add(new TrocaNumeroLine { Id = Guid.NewGuid(), TenantId = line.TenantId, Item = line.Item, LinhaAntiga = previousLine, LinhaNova = nextLine, ICCID = MveAuditNormalization.NullIfEmptyDigits(chip), DataTroca = now, Motivo = "Auditoria MVE", Observacao = "Linha atualizada automaticamente a partir do relatório MVE.", CreatedAt = now, UpdatedAt = now }); } private static void RefreshLineLookup( IDictionary lookup, string? previousLine, string? nextLine, T entity) where T : class { if (!string.IsNullOrWhiteSpace(previousLine)) { lookup.Remove(previousLine); } if (!string.IsNullOrWhiteSpace(nextLine)) { lookup[nextLine] = entity; } } private VigenciaLine? ResolveVigencia( MobileLine line, string? numeroLinha, IDictionary vigenciaByLine, IDictionary vigenciaByItem) { if (!string.IsNullOrWhiteSpace(numeroLinha) && vigenciaByLine.TryGetValue(numeroLinha, out var byLine)) { return byLine; } if (line.Item > 0 && vigenciaByItem.TryGetValue(line.Item, out var byItem)) { return byItem; } return null; } private UserData? ResolveUserData( MobileLine line, string? numeroLinha, IDictionary userDataByLine, IDictionary userDataByItem) { if (!string.IsNullOrWhiteSpace(numeroLinha) && userDataByLine.TryGetValue(numeroLinha, out var byLine)) { return byLine; } if (line.Item > 0 && userDataByItem.TryGetValue(line.Item, out var byItem)) { return byItem; } return null; } private VigenciaLine CreateVigencia(MobileLine line) { var now = DateTime.UtcNow; var vigencia = new VigenciaLine { Id = Guid.NewGuid(), TenantId = line.TenantId, Item = line.Item, Linha = MveAuditNormalization.NullIfEmptyDigits(line.Linha), Conta = line.Conta, Cliente = line.Cliente, Usuario = line.Usuario, PlanoContrato = line.PlanoContrato, CreatedAt = now, UpdatedAt = now }; _db.VigenciaLines.Add(vigencia); return vigencia; } private UserData CreateUserData(MobileLine line) { var now = DateTime.UtcNow; var userData = new UserData { Id = Guid.NewGuid(), TenantId = line.TenantId, Item = line.Item, Linha = MveAuditNormalization.NullIfEmptyDigits(line.Linha), Cliente = line.Cliente, CreatedAt = now, UpdatedAt = now }; _db.UserDatas.Add(userData); return userData; } private Aparelho EnsureAparelho(MobileLine line) { if (line.Aparelho != null) { return line.Aparelho; } var now = DateTime.UtcNow; var aparelho = new Aparelho { Id = Guid.NewGuid(), TenantId = line.TenantId, CreatedAt = now, UpdatedAt = now }; _db.Aparelhos.Add(aparelho); line.AparelhoId = aparelho.Id; line.Aparelho = aparelho; return aparelho; } private static void ApplyBlockedLineContext(MobileLine line) { var normalized = MveAuditNormalization.NormalizeSystemStatus(line.Status).Key; if (normalized is not "BLOQUEIO_PERDA_ROUBO" and not "BLOQUEIO_120_DIAS") { return; } line.Usuario = "RESERVA"; line.Skil = "RESERVA"; if (string.IsNullOrWhiteSpace(line.Cliente)) { line.Cliente = "RESERVA"; } } private static bool SetString(string? currentValue, string? nextValue, Action assign) { var normalizedNext = string.IsNullOrWhiteSpace(nextValue) ? null : MveAuditNormalization.CleanTextValue(nextValue); var normalizedCurrent = string.IsNullOrWhiteSpace(currentValue) ? null : MveAuditNormalization.CleanTextValue(currentValue); if (string.Equals(normalizedCurrent, normalizedNext, StringComparison.Ordinal)) { return false; } assign(normalizedNext); return true; } private static bool SetDate(DateTime? currentValue, DateTime? nextValue, Action assign) { var normalizedCurrent = currentValue?.Date; var normalizedNext = nextValue?.Date; if (normalizedCurrent == normalizedNext) { return false; } assign(nextValue); return true; } private static Guid? ResolveUserId(ClaimsPrincipal? user) { var raw = user?.FindFirstValue(ClaimTypes.NameIdentifier) ?? user?.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? user?.FindFirstValue("sub"); return Guid.TryParse(raw, out var parsed) ? parsed : null; } private static string? ResolveUserName(ClaimsPrincipal? user) { return user?.FindFirstValue("name") ?? user?.FindFirstValue(ClaimTypes.Name) ?? user?.Identity?.Name; } private static string? ResolveUserEmail(ClaimsPrincipal? user) { return user?.FindFirstValue(ClaimTypes.Email) ?? user?.FindFirstValue(JwtRegisteredClaimNames.Email) ?? user?.FindFirstValue("email"); } }