line-gestao-api/Services/MveAuditService.cs

614 lines
22 KiB
C#

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<MveAuditRunDto> 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<AuditFieldChangeDto>
{
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<MveAuditRunDto?> 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<MveAuditRunDto?> 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<ApplyMveAuditResultDto?> ApplyAsync(
Guid runId,
IReadOnlyCollection<Guid>? 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<Guid>();
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 now = DateTime.UtcNow;
var updatedLineIds = new HashSet<Guid>();
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;
}
var reportSnapshot = DeserializeSnapshot(issue.ReportSnapshotJson);
if (reportSnapshot == null)
{
skippedIssues++;
continue;
}
var differences = DeserializeDifferences(issue.DifferencesJson);
var lineChanged = false;
foreach (var difference in differences.Where(x => x.Syncable && x.FieldKey == "status"))
{
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<AuditFieldChangeDto>
{
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<AuditFieldChangeDto> 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<MveAuditSnapshotDto>(json, JsonOptions);
}
catch
{
return null;
}
}
private static List<MveAuditDifferenceDto> DeserializeDifferences(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new List<MveAuditDifferenceDto>();
}
try
{
return JsonSerializer.Deserialize<List<MveAuditDifferenceDto>>(json, JsonOptions) ?? new List<MveAuditDifferenceDto>();
}
catch
{
return new List<MveAuditDifferenceDto>();
}
}
private VigenciaLine? ResolveVigencia(
MobileLine line,
string numeroLinha,
IDictionary<string, VigenciaLine> vigenciaByLine,
IDictionary<int, VigenciaLine> 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<string, UserData> userDataByLine,
IDictionary<int, UserData> 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<string?> 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<DateTime?> 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");
}
}