diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index 2cfc246..933c1c6 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -57,6 +57,37 @@ namespace line_gestao_api.Controllers } }; + private static string NormalizeContaValue(string? conta) + { + var raw = (conta ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(raw)) return string.Empty; + if (!raw.All(char.IsDigit)) return raw.ToUpperInvariant(); + + var withoutLeadingZero = raw.TrimStart('0'); + return string.IsNullOrWhiteSpace(withoutLeadingZero) ? "0" : withoutLeadingZero; + } + + private static string? FindEmpresaByConta(string? conta) + { + var normalizedConta = NormalizeContaValue(conta); + if (string.IsNullOrWhiteSpace(normalizedConta)) return null; + + return AccountCompanies + .FirstOrDefault(group => group.Contas.Any(c => NormalizeContaValue(c) == normalizedConta)) + ?.Empresa; + } + + private static string? ValidateContaEmpresaBinding(string? conta) + { + var contaTrimmed = (conta ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(contaTrimmed)) + return "A Conta é obrigatória."; + + return string.IsNullOrWhiteSpace(FindEmpresaByConta(contaTrimmed)) + ? $"A conta {contaTrimmed} não está vinculada a nenhuma Empresa (Conta) cadastrada." + : null; + } + public LinesController( AppDbContext db, ITenantProvider tenantProvider, @@ -748,6 +779,10 @@ namespace line_gestao_api.Controllers if (string.IsNullOrWhiteSpace(linhaLimpa)) return BadRequest(new { message = "Número de linha inválido." }); + var contaValidationMessage = ValidateContaEmpresaBinding(req.Conta); + if (!string.IsNullOrWhiteSpace(contaValidationMessage)) + return BadRequest(new { message = contaValidationMessage }); + MobileLine? lineToPersist = null; if (req.ReservaLineId.HasValue && req.ReservaLineId.Value != Guid.Empty) @@ -940,6 +975,10 @@ namespace line_gestao_api.Controllers return Conflict(new { message = $"O Chip (ICCID) {entry.Chip} já está cadastrado no sistema (registro #{lineNo})." }); } + var contaValidationMessage = ValidateContaEmpresaBinding(entry.Conta); + if (!string.IsNullOrWhiteSpace(contaValidationMessage)) + return BadRequest(new { message = $"Linha do lote #{lineNo}: {contaValidationMessage}" }); + nextItem++; var planSuggestion = await AutoFillRules.ResolvePlanSuggestionAsync(_db, entry.PlanoContrato); @@ -1550,6 +1589,10 @@ namespace line_gestao_api.Controllers if (exists) return Conflict(new { message = "Já existe registro com essa LINHA.", linha = newLinha }); } + var contaValidationMessage = ValidateContaEmpresaBinding(req.Conta); + if (!string.IsNullOrWhiteSpace(contaValidationMessage)) + return BadRequest(new { message = contaValidationMessage }); + x.Conta = req.Conta?.Trim(); x.Linha = string.IsNullOrWhiteSpace(newLinha) ? null : newLinha; @@ -5277,6 +5320,7 @@ namespace line_gestao_api.Controllers { Id = x.Id, Item = x.Item, + ContaEmpresa = FindEmpresaByConta(x.Conta), Conta = x.Conta, Linha = x.Linha, Chip = x.Chip, diff --git a/Controllers/MveAuditController.cs b/Controllers/MveAuditController.cs new file mode 100644 index 0000000..4e3aa96 --- /dev/null +++ b/Controllers/MveAuditController.cs @@ -0,0 +1,74 @@ +using line_gestao_api.Dtos; +using line_gestao_api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace line_gestao_api.Controllers; + +[ApiController] +[Route("api/mve-audit")] +[Authorize(Roles = "sysadmin,gestor")] +public class MveAuditController : ControllerBase +{ + private readonly MveAuditService _mveAuditService; + + public MveAuditController(MveAuditService mveAuditService) + { + _mveAuditService = mveAuditService; + } + + public sealed class MveAuditUploadForm + { + public IFormFile File { get; set; } = default!; + } + + [HttpPost("preview")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(20_000_000)] + public async Task> Preview( + [FromForm] MveAuditUploadForm form, + CancellationToken cancellationToken) + { + try + { + var result = await _mveAuditService.CreateRunAsync(form.File, cancellationToken); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpGet("{id:guid}")] + public async Task> GetById(Guid id, CancellationToken cancellationToken) + { + var result = await _mveAuditService.GetByIdAsync(id, cancellationToken); + return result == null ? NotFound() : Ok(result); + } + + [HttpGet("latest")] + public async Task> GetLatest(CancellationToken cancellationToken) + { + var result = await _mveAuditService.GetLatestAsync(cancellationToken); + return result == null ? NotFound() : Ok(result); + } + + [HttpPost("{id:guid}/apply")] + public async Task> Apply( + Guid id, + [FromBody] ApplyMveAuditRequestDto request, + CancellationToken cancellationToken) + { + try + { + var result = await _mveAuditService.ApplyAsync(id, request?.IssueIds, cancellationToken); + return result == null ? NotFound() : Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { message = ex.Message }); + } + } +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 876adf8..09ccd4d 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -77,6 +77,10 @@ public class AppDbContext : IdentityDbContext ImportAuditRuns => Set(); public DbSet ImportAuditIssues => Set(); + // ✅ tabelas de auditoria MVE + public DbSet MveAuditRuns => Set(); + public DbSet MveAuditIssues => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -100,6 +104,7 @@ public class AppDbContext : IdentityDbContext(e => { e.Property(x => x.Nome).HasMaxLength(160); + e.Property(x => x.Fabricante).HasMaxLength(120); e.Property(x => x.Cor).HasMaxLength(80); e.Property(x => x.Imei).HasMaxLength(80); e.Property(x => x.NotaFiscalArquivoPath).HasMaxLength(500); @@ -107,6 +112,7 @@ public class AppDbContext : IdentityDbContext x.TenantId); e.HasIndex(x => x.Imei); e.HasIndex(x => new { x.TenantId, x.Nome, x.Cor }); + e.HasIndex(x => new { x.TenantId, x.Fabricante }); }); // ========================= @@ -390,6 +396,48 @@ public class AppDbContext : IdentityDbContext(e => + { + e.Property(x => x.FileName).HasMaxLength(260); + e.Property(x => x.FileHashSha256).HasMaxLength(64); + e.Property(x => x.FileEncoding).HasMaxLength(40); + e.Property(x => x.Status).HasMaxLength(40); + e.Property(x => x.AppliedByUserName).HasMaxLength(200); + e.Property(x => x.AppliedByUserEmail).HasMaxLength(200); + + e.HasIndex(x => x.TenantId); + e.HasIndex(x => x.ImportedAtUtc); + e.HasIndex(x => x.Status); + }); + + modelBuilder.Entity(e => + { + e.Property(x => x.NumeroLinha).HasMaxLength(64); + e.Property(x => x.IssueType).HasMaxLength(60); + e.Property(x => x.Situation).HasMaxLength(80); + e.Property(x => x.Severity).HasMaxLength(40); + e.Property(x => x.ActionSuggestion).HasMaxLength(160); + e.Property(x => x.Notes).HasMaxLength(500); + e.Property(x => x.SystemStatus).HasMaxLength(120); + e.Property(x => x.ReportStatus).HasMaxLength(120); + e.Property(x => x.SystemPlan).HasMaxLength(200); + e.Property(x => x.ReportPlan).HasMaxLength(200); + e.Property(x => x.SystemSnapshotJson).HasColumnType("jsonb"); + e.Property(x => x.ReportSnapshotJson).HasColumnType("jsonb"); + e.Property(x => x.DifferencesJson).HasColumnType("jsonb"); + + e.HasIndex(x => x.TenantId); + e.HasIndex(x => x.AuditRunId); + e.HasIndex(x => x.IssueType); + e.HasIndex(x => x.NumeroLinha); + e.HasIndex(x => x.Syncable); + + e.HasOne(x => x.AuditRun) + .WithMany(x => x.Issues) + .HasForeignKey(x => x.AuditRunId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); @@ -419,6 +467,8 @@ public class AppDbContext : IdentityDbContext().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); } diff --git a/Dtos/MobileLineDtos.cs b/Dtos/MobileLineDtos.cs index 3d9afab..ccf0c13 100644 --- a/Dtos/MobileLineDtos.cs +++ b/Dtos/MobileLineDtos.cs @@ -34,6 +34,7 @@ { public Guid Id { get; set; } public int Item { get; set; } + public string? ContaEmpresa { get; set; } public string? Conta { get; set; } public string? Linha { get; set; } public string? Chip { get; set; } diff --git a/Dtos/MveAuditDtos.cs b/Dtos/MveAuditDtos.cs new file mode 100644 index 0000000..cc339c0 --- /dev/null +++ b/Dtos/MveAuditDtos.cs @@ -0,0 +1,158 @@ +namespace line_gestao_api.Dtos; + +public class MveAuditRunDto +{ + public Guid Id { get; set; } + + public string? FileName { get; set; } + + public string? FileEncoding { get; set; } + + public string Status { get; set; } = string.Empty; + + public DateTime ImportedAtUtc { get; set; } + + public DateTime? AppliedAtUtc { get; set; } + + public string? AppliedByUserName { get; set; } + + public string? AppliedByUserEmail { get; set; } + + public MveAuditSummaryDto Summary { get; set; } = new(); + + public List Issues { get; set; } = new(); +} + +public class MveAuditSummaryDto +{ + public int TotalSystemLines { get; set; } + + public int TotalReportLines { get; set; } + + 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; set; } + + public int TotalUnknownStatuses { get; set; } + + public int TotalSyncableIssues { get; set; } + + public int AppliedIssuesCount { get; set; } + + public int AppliedLinesCount { get; set; } + + public int AppliedFieldsCount { get; set; } +} + +public class MveAuditIssueDto +{ + public Guid Id { get; set; } + + public int? SourceRowNumber { get; set; } + + public string NumeroLinha { get; set; } = string.Empty; + + public Guid? MobileLineId { get; set; } + + public int? SystemItem { get; set; } + + public string IssueType { get; set; } = string.Empty; + + public string Situation { get; set; } = string.Empty; + + public string Severity { get; set; } = "INFO"; + + public bool Syncable { get; set; } + + public bool Applied { get; set; } + + public string? ActionSuggestion { get; set; } + + public string? Notes { get; set; } + + public string? SystemStatus { get; set; } + + public string? ReportStatus { get; set; } + + public string? SystemPlan { get; set; } + + public string? ReportPlan { get; set; } + + public MveAuditSnapshotDto? SystemSnapshot { get; set; } + + public MveAuditSnapshotDto? ReportSnapshot { get; set; } + + public List Differences { get; set; } = new(); +} + +public class MveAuditSnapshotDto +{ + public string? NumeroLinha { get; set; } + + public string? StatusLinha { get; set; } + + public string? StatusConta { get; set; } + + public string? PlanoLinha { get; set; } + + public DateTime? DataAtivacao { get; set; } + + public DateTime? TerminoContrato { get; set; } + + public string? Chip { get; set; } + + public string? Conta { get; set; } + + public string? Cnpj { get; set; } + + public string? ModeloAparelho { get; set; } + + public string? Fabricante { get; set; } + + public List ServicosAtivos { get; set; } = new(); +} + +public class MveAuditDifferenceDto +{ + public string FieldKey { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; + + public string? SystemValue { get; set; } + + public string? ReportValue { get; set; } + + public bool Syncable { get; set; } +} + +public class ApplyMveAuditRequestDto +{ + public List? IssueIds { get; set; } +} + +public class ApplyMveAuditResultDto +{ + public Guid AuditRunId { get; set; } + + public int RequestedIssues { get; set; } + + public int AppliedIssues { get; set; } + + public int UpdatedLines { get; set; } + + public int UpdatedFields { get; set; } + + public int SkippedIssues { get; set; } +} diff --git a/Migrations/20260309120000_AddMveAuditHistoryAndAparelhoFabricante.cs b/Migrations/20260309120000_AddMveAuditHistoryAndAparelhoFabricante.cs new file mode 100644 index 0000000..76e9e02 --- /dev/null +++ b/Migrations/20260309120000_AddMveAuditHistoryAndAparelhoFabricante.cs @@ -0,0 +1,160 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + /// + public partial class AddMveAuditHistoryAndAparelhoFabricante : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Fabricante", + table: "Aparelhos", + type: "character varying(120)", + maxLength: 120, + nullable: true); + + migrationBuilder.CreateTable( + name: "MveAuditRuns", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + FileName = table.Column(type: "character varying(260)", maxLength: 260, nullable: true), + FileHashSha256 = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + FileEncoding = table.Column(type: "character varying(40)", maxLength: 40, nullable: true), + Status = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + TotalSystemLines = table.Column(type: "integer", nullable: false), + TotalReportLines = table.Column(type: "integer", nullable: false), + TotalConciliated = table.Column(type: "integer", nullable: false), + TotalStatusDivergences = table.Column(type: "integer", nullable: false), + TotalDataDivergences = table.Column(type: "integer", nullable: false), + TotalOnlyInSystem = table.Column(type: "integer", nullable: false), + TotalOnlyInReport = table.Column(type: "integer", nullable: false), + TotalDuplicateReportLines = table.Column(type: "integer", nullable: false), + TotalDuplicateSystemLines = table.Column(type: "integer", nullable: false), + TotalInvalidRows = table.Column(type: "integer", nullable: false), + TotalUnknownStatuses = table.Column(type: "integer", nullable: false), + TotalSyncableIssues = table.Column(type: "integer", nullable: false), + AppliedIssuesCount = table.Column(type: "integer", nullable: false), + AppliedLinesCount = table.Column(type: "integer", nullable: false), + AppliedFieldsCount = table.Column(type: "integer", nullable: false), + ImportedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + AppliedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AppliedByUserId = table.Column(type: "uuid", nullable: true), + AppliedByUserName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + AppliedByUserEmail = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MveAuditRuns", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MveAuditIssues", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + AuditRunId = table.Column(type: "uuid", nullable: false), + SourceRowNumber = table.Column(type: "integer", nullable: true), + NumeroLinha = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + MobileLineId = table.Column(type: "uuid", nullable: true), + SystemItem = table.Column(type: "integer", nullable: true), + IssueType = table.Column(type: "character varying(60)", maxLength: 60, nullable: false), + Situation = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + Severity = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + Syncable = table.Column(type: "boolean", nullable: false), + Applied = table.Column(type: "boolean", nullable: false), + AppliedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + ActionSuggestion = table.Column(type: "character varying(160)", maxLength: 160, nullable: true), + Notes = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + SystemStatus = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + ReportStatus = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + SystemPlan = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + ReportPlan = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + SystemSnapshotJson = table.Column(type: "jsonb", nullable: false), + ReportSnapshotJson = table.Column(type: "jsonb", nullable: false), + DifferencesJson = table.Column(type: "jsonb", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MveAuditIssues", x => x.Id); + table.ForeignKey( + name: "FK_MveAuditIssues_MveAuditRuns_AuditRunId", + column: x => x.AuditRunId, + principalTable: "MveAuditRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Aparelhos_TenantId_Fabricante", + table: "Aparelhos", + columns: new[] { "TenantId", "Fabricante" }); + + migrationBuilder.CreateIndex( + name: "IX_MveAuditIssues_AuditRunId", + table: "MveAuditIssues", + column: "AuditRunId"); + + migrationBuilder.CreateIndex( + name: "IX_MveAuditIssues_IssueType", + table: "MveAuditIssues", + column: "IssueType"); + + migrationBuilder.CreateIndex( + name: "IX_MveAuditIssues_NumeroLinha", + table: "MveAuditIssues", + column: "NumeroLinha"); + + migrationBuilder.CreateIndex( + name: "IX_MveAuditIssues_Syncable", + table: "MveAuditIssues", + column: "Syncable"); + + migrationBuilder.CreateIndex( + name: "IX_MveAuditIssues_TenantId", + table: "MveAuditIssues", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_MveAuditRuns_ImportedAtUtc", + table: "MveAuditRuns", + column: "ImportedAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_MveAuditRuns_Status", + table: "MveAuditRuns", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_MveAuditRuns_TenantId", + table: "MveAuditRuns", + column: "TenantId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MveAuditIssues"); + + migrationBuilder.DropTable( + name: "MveAuditRuns"); + + migrationBuilder.DropIndex( + name: "IX_Aparelhos_TenantId_Fabricante", + table: "Aparelhos"); + + migrationBuilder.DropColumn( + name: "Fabricante", + table: "Aparelhos"); + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index a48fcf6..75f4715 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -343,6 +343,10 @@ namespace line_gestao_api.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Fabricante") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + b.Property("Imei") .HasMaxLength(80) .HasColumnType("character varying(80)"); @@ -371,6 +375,8 @@ namespace line_gestao_api.Migrations b.HasIndex("TenantId"); + b.HasIndex("TenantId", "Fabricante"); + b.HasIndex("TenantId", "Nome", "Cor"); b.ToTable("Aparelhos"); @@ -664,6 +670,209 @@ namespace line_gestao_api.Migrations b.ToTable("ImportAuditRuns"); }); + modelBuilder.Entity("line_gestao_api.Models.MveAuditIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditRunId") + .HasColumnType("uuid"); + + b.Property("ActionSuggestion") + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("Applied") + .HasColumnType("boolean"); + + b.Property("AppliedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DifferencesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IssueType") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("MobileLineId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("NumeroLinha") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ReportPlan") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReportSnapshotJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ReportStatus") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SourceRowNumber") + .HasColumnType("integer"); + + b.Property("Situation") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Syncable") + .HasColumnType("boolean"); + + b.Property("SystemPlan") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemItem") + .HasColumnType("integer"); + + b.Property("SystemSnapshotJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SystemStatus") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuditRunId"); + + b.HasIndex("IssueType"); + + b.HasIndex("NumeroLinha"); + + b.HasIndex("Syncable"); + + b.HasIndex("TenantId"); + + b.ToTable("MveAuditIssues"); + }); + + modelBuilder.Entity("line_gestao_api.Models.MveAuditRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AppliedByUserEmail") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AppliedByUserId") + .HasColumnType("uuid"); + + b.Property("AppliedByUserName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AppliedFieldsCount") + .HasColumnType("integer"); + + b.Property("AppliedIssuesCount") + .HasColumnType("integer"); + + b.Property("AppliedLinesCount") + .HasColumnType("integer"); + + b.Property("FileEncoding") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("FileHashSha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FileName") + .HasMaxLength(260) + .HasColumnType("character varying(260)"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalConciliated") + .HasColumnType("integer"); + + b.Property("TotalDataDivergences") + .HasColumnType("integer"); + + b.Property("TotalDuplicateReportLines") + .HasColumnType("integer"); + + b.Property("TotalDuplicateSystemLines") + .HasColumnType("integer"); + + b.Property("TotalInvalidRows") + .HasColumnType("integer"); + + b.Property("TotalOnlyInReport") + .HasColumnType("integer"); + + b.Property("TotalOnlyInSystem") + .HasColumnType("integer"); + + b.Property("TotalReportLines") + .HasColumnType("integer"); + + b.Property("TotalStatusDivergences") + .HasColumnType("integer"); + + b.Property("TotalSyncableIssues") + .HasColumnType("integer"); + + b.Property("TotalSystemLines") + .HasColumnType("integer"); + + b.Property("TotalUnknownStatuses") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportedAtUtc"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.ToTable("MveAuditRuns"); + }); + modelBuilder.Entity("line_gestao_api.Models.MobileLine", b => { b.Property("Id") @@ -1864,6 +2073,17 @@ namespace line_gestao_api.Migrations b.Navigation("AuditRun"); }); + modelBuilder.Entity("line_gestao_api.Models.MveAuditIssue", b => + { + b.HasOne("line_gestao_api.Models.MveAuditRun", "AuditRun") + .WithMany("Issues") + .HasForeignKey("AuditRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AuditRun"); + }); + modelBuilder.Entity("line_gestao_api.Models.Notification", b => { b.HasOne("line_gestao_api.Models.ApplicationUser", "User") @@ -1924,6 +2144,11 @@ namespace line_gestao_api.Migrations b.Navigation("Issues"); }); + modelBuilder.Entity("line_gestao_api.Models.MveAuditRun", b => + { + b.Navigation("Issues"); + }); + modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b => { b.Navigation("MonthValues"); diff --git a/Models/Aparelho.cs b/Models/Aparelho.cs index eb689d4..83208ea 100644 --- a/Models/Aparelho.cs +++ b/Models/Aparelho.cs @@ -11,6 +11,9 @@ public class Aparelho : ITenantEntity [MaxLength(160)] public string? Nome { get; set; } + [MaxLength(120)] + public string? Fabricante { get; set; } + [MaxLength(80)] public string? Cor { get; set; } diff --git a/Models/MveAuditIssue.cs b/Models/MveAuditIssue.cs new file mode 100644 index 0000000..3230d98 --- /dev/null +++ b/Models/MveAuditIssue.cs @@ -0,0 +1,52 @@ +namespace line_gestao_api.Models; + +public class MveAuditIssue : ITenantEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TenantId { get; set; } + + public Guid AuditRunId { get; set; } + + public MveAuditRun? AuditRun { get; set; } + + public int? SourceRowNumber { get; set; } + + public string NumeroLinha { get; set; } = string.Empty; + + public Guid? MobileLineId { get; set; } + + public int? SystemItem { get; set; } + + public string IssueType { get; set; } = string.Empty; + + public string Situation { get; set; } = string.Empty; + + public string Severity { get; set; } = "INFO"; + + public bool Syncable { get; set; } + + public bool Applied { get; set; } + + public DateTime? AppliedAtUtc { get; set; } + + public string? ActionSuggestion { get; set; } + + public string? Notes { get; set; } + + public string? SystemStatus { get; set; } + + public string? ReportStatus { get; set; } + + public string? SystemPlan { get; set; } + + public string? ReportPlan { get; set; } + + public string SystemSnapshotJson { get; set; } = "{}"; + + public string ReportSnapshotJson { get; set; } = "{}"; + + public string DifferencesJson { get; set; } = "[]"; + + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/Models/MveAuditRun.cs b/Models/MveAuditRun.cs new file mode 100644 index 0000000..c1732c4 --- /dev/null +++ b/Models/MveAuditRun.cs @@ -0,0 +1,58 @@ +namespace line_gestao_api.Models; + +public class MveAuditRun : ITenantEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TenantId { get; set; } + + public string? FileName { get; set; } + + public string? FileHashSha256 { get; set; } + + public string? FileEncoding { get; set; } + + public string Status { get; set; } = "READY"; + + public int TotalSystemLines { get; set; } + + public int TotalReportLines { get; set; } + + 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; set; } + + public int TotalUnknownStatuses { get; set; } + + public int TotalSyncableIssues { get; set; } + + public int AppliedIssuesCount { get; set; } + + public int AppliedLinesCount { get; set; } + + public int AppliedFieldsCount { get; set; } + + public DateTime ImportedAtUtc { get; set; } = DateTime.UtcNow; + + public DateTime? AppliedAtUtc { get; set; } + + public Guid? AppliedByUserId { get; set; } + + public string? AppliedByUserName { get; set; } + + public string? AppliedByUserEmail { get; set; } + + public ICollection Issues { get; set; } = new List(); +} diff --git a/Program.cs b/Program.cs index beec0cf..98f7b66 100644 --- a/Program.cs +++ b/Program.cs @@ -16,6 +16,7 @@ using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); +Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); var dataProtectionKeyPath = builder.Environment.IsProduction() ? "/var/www/html/line-gestao-api/publish/.aspnet-keys" @@ -98,6 +99,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddIdentityCore(options => { @@ -197,6 +202,11 @@ app.UseMiddleware(); app.UseAuthorization(); await SeedData.EnsureSeedDataAsync(app.Services); +using (var scope = app.Services.CreateScope()) +{ + var schemaBootstrapper = scope.ServiceProvider.GetRequiredService(); + await schemaBootstrapper.EnsureSchemaAsync(); +} app.MapControllers(); app.MapGet("/health", () => Results.Ok(new { status = "ok" })); diff --git a/Services/MveAuditNormalization.cs b/Services/MveAuditNormalization.cs new file mode 100644 index 0000000..2aab672 --- /dev/null +++ b/Services/MveAuditNormalization.cs @@ -0,0 +1,293 @@ +using System.Globalization; +using System.Text; + +namespace line_gestao_api.Services; + +internal static class MveAuditNormalization +{ + public static string NormalizeHeader(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var normalized = value.Trim().ToUpperInvariant().Normalize(NormalizationForm.FormD); + var builder = new StringBuilder(normalized.Length); + foreach (var ch in normalized) + { + if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark) + { + builder.Append(ch); + } + } + + return builder.ToString() + .Replace("\u00A0", " ") + .Trim(); + } + + public static string CleanTextValue(string? value, bool removeSingleQuotes = true) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var cleaned = value + .Replace("\u00A0", " ") + .Replace("\t", " ") + .Replace("\r", " ") + .Replace("\n", " ") + .Trim(); + + if (removeSingleQuotes) + { + cleaned = cleaned.Replace("'", string.Empty); + } + + while (cleaned.Contains(" ", StringComparison.Ordinal)) + { + cleaned = cleaned.Replace(" ", " ", StringComparison.Ordinal); + } + + return cleaned.Trim(); + } + + public static string OnlyDigits(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var builder = new StringBuilder(value.Length); + foreach (var ch in value) + { + if (char.IsDigit(ch)) + { + builder.Append(ch); + } + } + + return builder.ToString(); + } + + public static string? NullIfEmptyDigits(string? value) + { + var digits = OnlyDigits(value); + return string.IsNullOrWhiteSpace(digits) ? null : digits; + } + + public static string NormalizeComparableText(string? value) + { + var cleaned = CleanTextValue(value); + if (string.IsNullOrWhiteSpace(cleaned)) + { + return string.Empty; + } + + return NormalizeHeader(cleaned) + .Replace(" ", " ", StringComparison.Ordinal) + .Trim(); + } + + public static string NormalizeAccountLike(string? value) + { + var digits = OnlyDigits(value); + if (!string.IsNullOrWhiteSpace(digits)) + { + return digits; + } + + return NormalizeComparableText(value); + } + + public static DateTime? ParseDateValue(string? rawValue) + { + var cleaned = CleanTextValue(rawValue); + if (string.IsNullOrWhiteSpace(cleaned)) + { + return null; + } + + if (double.TryParse( + cleaned.Replace(",", ".", StringComparison.Ordinal), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var oaValue) && + oaValue > 10_000 && + oaValue < 90_000) + { + try + { + return ToUtcDateOnly(DateTime.FromOADate(oaValue)); + } + catch + { + // segue para os demais formatos + } + } + + var formats = new[] + { + "dd/MM/yyyy", + "d/M/yyyy", + "dd/MM/yy", + "d/M/yy", + "yyyy-MM-dd", + "dd-MM-yyyy", + "d-M-yyyy", + "yyyyMMdd" + }; + + foreach (var format in formats) + { + if (DateTime.TryParseExact( + cleaned, + format, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var exact)) + { + return ToUtcDateOnly(exact); + } + } + + if (DateTime.TryParse(cleaned, new CultureInfo("pt-BR"), DateTimeStyles.None, out var parsedBr)) + { + return ToUtcDateOnly(parsedBr); + } + + if (DateTime.TryParse(cleaned, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedInvariant)) + { + return ToUtcDateOnly(parsedInvariant); + } + + return null; + } + + public static DateTime ToUtcDateOnly(DateTime date) + { + return new DateTime(date.Year, date.Month, date.Day, 12, 0, 0, DateTimeKind.Utc); + } + + public static string FormatDate(DateTime? value) + { + return value.HasValue ? value.Value.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture) : string.Empty; + } + + public static MveNormalizedStatus NormalizeReportStatus(string? rawValue) + { + var displayValue = CleanTextValue(rawValue); + if (string.IsNullOrWhiteSpace(displayValue)) + { + return new MveNormalizedStatus(string.Empty, string.Empty, false); + } + + var headSegment = NormalizeComparableText(displayValue.Split(':', 2)[0]) + .Replace("/", " ", StringComparison.Ordinal) + .Replace("-", " ", StringComparison.Ordinal) + .Replace(".", " ", StringComparison.Ordinal) + .Replace("(", " ", StringComparison.Ordinal) + .Replace(")", " ", StringComparison.Ordinal) + .Replace(" ", " ", StringComparison.Ordinal) + .Trim(); + + var canonical = NormalizeComparableText(displayValue) + .Replace("/", " ", StringComparison.Ordinal) + .Replace("-", " ", StringComparison.Ordinal) + .Replace(".", " ", StringComparison.Ordinal) + .Replace("(", " ", StringComparison.Ordinal) + .Replace(")", " ", StringComparison.Ordinal) + .Replace(" ", " ", StringComparison.Ordinal) + .Trim(); + + var key = headSegment switch + { + var text when text.Contains("ATIVO", StringComparison.Ordinal) || text == "ATIVA" => "ATIVO", + var text when text.Contains("BLOQUEIO PARCIAL", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO", + var text when text.Contains("CANCEL", StringComparison.Ordinal) => "BLOQUEIO_120_DIAS", + var text when text.Contains("SUSPENS", StringComparison.Ordinal) => "SUSPENSO", + var text when text.Contains("PENDENTE", StringComparison.Ordinal) && + text.Contains("TROCA", StringComparison.Ordinal) && + text.Contains("NUMERO", StringComparison.Ordinal) => "PENDENTE_TROCA_NUMERO", + var text when text.Contains("PERDA", StringComparison.Ordinal) || text.Contains("ROUBO", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO", + var text when text.Contains("BLOQUEIO", StringComparison.Ordinal) && text.Contains("120", StringComparison.Ordinal) => "BLOQUEIO_120_DIAS", + _ => canonical.Replace(" ", "_", StringComparison.Ordinal) + }; + + var recognized = key is + "ATIVO" or + "BLOQUEIO_PERDA_ROUBO" or + "BLOQUEIO_120_DIAS" or + "SUSPENSO" or + "PENDENTE_TROCA_NUMERO"; + + return new MveNormalizedStatus(displayValue, key, recognized); + } + + public static MveNormalizedStatus NormalizeSystemStatus(string? rawValue) + { + var displayValue = CleanTextValue(rawValue); + if (string.IsNullOrWhiteSpace(displayValue)) + { + return new MveNormalizedStatus(string.Empty, string.Empty, false); + } + + var canonical = NormalizeComparableText(displayValue) + .Replace("/", " ", StringComparison.Ordinal) + .Replace("-", " ", StringComparison.Ordinal) + .Replace(".", " ", StringComparison.Ordinal) + .Replace(":", " ", StringComparison.Ordinal) + .Replace("(", " ", StringComparison.Ordinal) + .Replace(")", " ", StringComparison.Ordinal) + .Replace(" ", " ", StringComparison.Ordinal) + .Trim(); + + var key = canonical switch + { + var text when text.Contains("ATIVO", StringComparison.Ordinal) || text == "ATIVA" => "ATIVO", + var text when text.Contains("PERDA", StringComparison.Ordinal) || text.Contains("ROUBO", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO", + var text when text.Contains("BLOQUEIO PARCIAL", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO", + var text when text.Contains("BLOQUEIO", StringComparison.Ordinal) && text.Contains("120", StringComparison.Ordinal) => "BLOQUEIO_120_DIAS", + var text when text.Contains("CANCEL", StringComparison.Ordinal) => "CANCELADO", + var text when text.Contains("SUSPENS", StringComparison.Ordinal) => "SUSPENSO", + var text when text.Contains("PENDENTE", StringComparison.Ordinal) && + text.Contains("TROCA", StringComparison.Ordinal) && + text.Contains("NUMERO", StringComparison.Ordinal) => "PENDENTE_TROCA_NUMERO", + _ => canonical.Replace(" ", "_", StringComparison.Ordinal) + }; + + var recognized = key is + "ATIVO" or + "BLOQUEIO_PERDA_ROUBO" or + "BLOQUEIO_120_DIAS" or + "CANCELADO" or + "SUSPENSO" or + "PENDENTE_TROCA_NUMERO"; + + return new MveNormalizedStatus(displayValue, key, recognized); + } + + public static MveNormalizedStatus NormalizeStatus(string? rawValue) + { + return NormalizeSystemStatus(rawValue); + } + + public static string NormalizeStatusForSystem(string? rawValue) + { + var normalized = NormalizeReportStatus(rawValue); + return normalized.Key switch + { + "ATIVO" => "ATIVO", + "BLOQUEIO_PERDA_ROUBO" => "BLOQUEIO PERDA/ROUBO", + "BLOQUEIO_120_DIAS" => "BLOQUEIO 120 DIAS", + "SUSPENSO" => "SUSPENSO", + "PENDENTE_TROCA_NUMERO" => "PENDENTE TROCA NUMERO", + _ => normalized.DisplayValue + }; + } +} + +internal sealed record MveNormalizedStatus(string DisplayValue, string Key, bool Recognized); diff --git a/Services/MveAuditSchemaBootstrapper.cs b/Services/MveAuditSchemaBootstrapper.cs new file mode 100644 index 0000000..8cfdabe --- /dev/null +++ b/Services/MveAuditSchemaBootstrapper.cs @@ -0,0 +1,129 @@ +using line_gestao_api.Data; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Services; + +public sealed class MveAuditSchemaBootstrapper +{ + private readonly AppDbContext _db; + + public MveAuditSchemaBootstrapper(AppDbContext db) + { + _db = db; + } + + public async Task EnsureSchemaAsync(CancellationToken cancellationToken = default) + { + await _db.Database.ExecuteSqlRawAsync( + """ + ALTER TABLE "Aparelhos" + ADD COLUMN IF NOT EXISTS "Fabricante" character varying(120) NULL; + """, + cancellationToken); + + await _db.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Aparelhos_TenantId_Fabricante" + ON "Aparelhos" ("TenantId", "Fabricante"); + """, + cancellationToken); + + await _db.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "MveAuditRuns" ( + "Id" uuid NOT NULL, + "TenantId" uuid NOT NULL, + "FileName" character varying(260) NULL, + "FileHashSha256" character varying(64) NULL, + "FileEncoding" character varying(40) NULL, + "Status" character varying(40) NOT NULL, + "TotalSystemLines" integer NOT NULL, + "TotalReportLines" integer NOT NULL, + "TotalConciliated" integer NOT NULL, + "TotalStatusDivergences" integer NOT NULL, + "TotalDataDivergences" integer NOT NULL, + "TotalOnlyInSystem" integer NOT NULL, + "TotalOnlyInReport" integer NOT NULL, + "TotalDuplicateReportLines" integer NOT NULL, + "TotalDuplicateSystemLines" integer NOT NULL, + "TotalInvalidRows" integer NOT NULL, + "TotalUnknownStatuses" integer NOT NULL, + "TotalSyncableIssues" integer NOT NULL, + "AppliedIssuesCount" integer NOT NULL, + "AppliedLinesCount" integer NOT NULL, + "AppliedFieldsCount" integer NOT NULL, + "ImportedAtUtc" timestamp with time zone NOT NULL, + "AppliedAtUtc" timestamp with time zone NULL, + "AppliedByUserId" uuid NULL, + "AppliedByUserName" character varying(200) NULL, + "AppliedByUserEmail" character varying(200) NULL, + CONSTRAINT "PK_MveAuditRuns" PRIMARY KEY ("Id") + ); + """, + cancellationToken); + + await _db.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "MveAuditIssues" ( + "Id" uuid NOT NULL, + "TenantId" uuid NOT NULL, + "AuditRunId" uuid NOT NULL, + "SourceRowNumber" integer NULL, + "NumeroLinha" character varying(64) NOT NULL, + "MobileLineId" uuid NULL, + "SystemItem" integer NULL, + "IssueType" character varying(60) NOT NULL, + "Situation" character varying(80) NOT NULL, + "Severity" character varying(40) NOT NULL, + "Syncable" boolean NOT NULL, + "Applied" boolean NOT NULL, + "AppliedAtUtc" timestamp with time zone NULL, + "ActionSuggestion" character varying(160) NULL, + "Notes" character varying(500) NULL, + "SystemStatus" character varying(120) NULL, + "ReportStatus" character varying(120) NULL, + "SystemPlan" character varying(200) NULL, + "ReportPlan" character varying(200) NULL, + "SystemSnapshotJson" jsonb NOT NULL, + "ReportSnapshotJson" jsonb NOT NULL, + "DifferencesJson" jsonb NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_MveAuditIssues" PRIMARY KEY ("Id") + ); + """, + cancellationToken); + + await _db.Database.ExecuteSqlRawAsync( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'FK_MveAuditIssues_MveAuditRuns_AuditRunId' + ) THEN + ALTER TABLE "MveAuditIssues" + ADD CONSTRAINT "FK_MveAuditIssues_MveAuditRuns_AuditRunId" + FOREIGN KEY ("AuditRunId") + REFERENCES "MveAuditRuns" ("Id") + ON DELETE CASCADE; + END IF; + END + $$; + """, + cancellationToken); + + await _db.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_MveAuditIssues_AuditRunId" ON "MveAuditIssues" ("AuditRunId"); + CREATE INDEX IF NOT EXISTS "IX_MveAuditIssues_IssueType" ON "MveAuditIssues" ("IssueType"); + CREATE INDEX IF NOT EXISTS "IX_MveAuditIssues_NumeroLinha" ON "MveAuditIssues" ("NumeroLinha"); + CREATE INDEX IF NOT EXISTS "IX_MveAuditIssues_Syncable" ON "MveAuditIssues" ("Syncable"); + CREATE INDEX IF NOT EXISTS "IX_MveAuditIssues_TenantId" ON "MveAuditIssues" ("TenantId"); + CREATE INDEX IF NOT EXISTS "IX_MveAuditRuns_ImportedAtUtc" ON "MveAuditRuns" ("ImportedAtUtc"); + CREATE INDEX IF NOT EXISTS "IX_MveAuditRuns_Status" ON "MveAuditRuns" ("Status"); + CREATE INDEX IF NOT EXISTS "IX_MveAuditRuns_TenantId" ON "MveAuditRuns" ("TenantId"); + """, + cancellationToken); + } +} diff --git a/Services/MveAuditService.cs b/Services/MveAuditService.cs new file mode 100644 index 0000000..aa94125 --- /dev/null +++ b/Services/MveAuditService.cs @@ -0,0 +1,613 @@ +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 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; + } + + 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 + { + 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 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"); + } +} diff --git a/Services/MveCsvParserService.cs b/Services/MveCsvParserService.cs new file mode 100644 index 0000000..bcfb71a --- /dev/null +++ b/Services/MveCsvParserService.cs @@ -0,0 +1,330 @@ +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 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> ParseCsvRows(string content) + { + var rows = new List>(); + var currentRow = new List(); + 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(); + break; + case '\n': + currentRow.Add(currentField.ToString()); + currentField.Clear(); + rows.Add(currentRow); + currentRow = new List(); + 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 BuildHeaderMap(IReadOnlyList headerRow) + { + var map = new Dictionary(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 row, IReadOnlyDictionary 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 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 Lines { get; } = new(); + + public List 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 ServicosAtivos { get; } = new(); +} + +internal sealed record MveParsedIssue( + int SourceRowNumber, + string NumeroLinha, + string IssueType, + string Message); diff --git a/Services/MveReconciliationService.cs b/Services/MveReconciliationService.cs new file mode 100644 index 0000000..c2961f0 --- /dev/null +++ b/Services/MveReconciliationService.cs @@ -0,0 +1,520 @@ +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 blockedKeys = new HashSet(duplicateReportKeys, StringComparer.Ordinal); + blockedKeys.UnionWith(duplicateSystemKeys); + + 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 (blockedKeys.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) + { + result.TotalOnlyInSystem++; + result.Issues.Add(new MveReconciliationIssueResult + { + NumeroLinha = key, + 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) + }); + continue; + } + + if (reportLine != null && systemLine == null) + { + result.TotalOnlyInReport++; + result.Issues.Add(new MveReconciliationIssueResult + { + SourceRowNumber = reportLine.SourceRowNumber, + NumeroLinha = key, + 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) + }); + continue; + } + + if (reportLine == null || systemLine == null) + { + continue; + } + + var comparison = CompareMatchedLine(systemLine, reportLine); + if (comparison == null) + { + result.TotalConciliated++; + continue; + } + + 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++; + } + } + + 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 + }); + } + + var hasUnknownStatus = !reportLine.StatusLinhaRecognized; + if (differences.Count == 0 && !hasUnknownStatus) + { + return null; + } + + 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 hasDataDifference = false; + var issueType = hasStatusDifference ? "STATUS_DIVERGENCE" : "UNKNOWN_STATUS"; + + return new MveReconciliationIssueResult + { + SourceRowNumber = reportLine.SourceRowNumber, + NumeroLinha = reportLine.NumeroNormalizado, + MobileLineId = systemLine.MobileLine.Id, + SystemItem = systemLine.MobileLine.Item, + IssueType = issueType, + Situation = ResolveSituation(hasStatusDifference, hasDataDifference, hasUnknownStatus), + Severity = ResolveSeverity(hasStatusDifference, hasDataDifference, hasUnknownStatus), + Syncable = differences.Any(x => x.Syncable), + ActionSuggestion = ResolveActionSuggestion(hasStatusDifference, hasDataDifference, 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 string ResolveSituation(bool hasStatusDifference, bool hasDataDifference, bool hasUnknownStatus) + { + if (hasStatusDifference && hasDataDifference) + { + return "divergência de status e cadastro"; + } + + if (hasStatusDifference) + { + return "divergência de status"; + } + + if (hasDataDifference) + { + return "divergência de cadastro"; + } + + 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 hasDataDifference, bool hasUnknownStatus) + { + 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); diff --git a/Services/VigenciaNotificationBackgroundService.cs b/Services/VigenciaNotificationBackgroundService.cs index 4491e5e..2d610c7 100644 --- a/Services/VigenciaNotificationBackgroundService.cs +++ b/Services/VigenciaNotificationBackgroundService.cs @@ -27,11 +27,18 @@ public class VigenciaNotificationBackgroundService : BackgroundService var intervalMinutes = _options.CheckIntervalMinutes <= 0 ? 60 : _options.CheckIntervalMinutes; using var timer = new PeriodicTimer(TimeSpan.FromMinutes(intervalMinutes)); - await RunOnceAsync(stoppingToken); - - while (await timer.WaitForNextTickAsync(stoppingToken)) + try { await RunOnceAsync(stoppingToken); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await RunOnceAsync(stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Host finalizando; evita ruído de log durante shutdown/startup interrompido. } } @@ -64,7 +71,11 @@ public class VigenciaNotificationBackgroundService : BackgroundService await notificationSyncService.SyncTenantAsync(tenant.Id, stoppingToken); } } - catch (Exception ex) + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Host finalizando; evita erro em cascata no logger. + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) { _logger.LogError(ex, "Erro ao gerar notificações de vigência."); }