feat: novas implementações e ajustes

This commit is contained in:
Eduardo Lopes 2026-03-09 15:14:34 -03:00
parent 64ffb9f2e5
commit 22ab6997d3
17 changed files with 2735 additions and 4 deletions

View File

@ -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( public LinesController(
AppDbContext db, AppDbContext db,
ITenantProvider tenantProvider, ITenantProvider tenantProvider,
@ -748,6 +779,10 @@ namespace line_gestao_api.Controllers
if (string.IsNullOrWhiteSpace(linhaLimpa)) if (string.IsNullOrWhiteSpace(linhaLimpa))
return BadRequest(new { message = "Número de linha inválido." }); 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; MobileLine? lineToPersist = null;
if (req.ReservaLineId.HasValue && req.ReservaLineId.Value != Guid.Empty) 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})." }); 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++; nextItem++;
var planSuggestion = await AutoFillRules.ResolvePlanSuggestionAsync(_db, entry.PlanoContrato); 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 }); 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.Conta = req.Conta?.Trim();
x.Linha = string.IsNullOrWhiteSpace(newLinha) ? null : newLinha; x.Linha = string.IsNullOrWhiteSpace(newLinha) ? null : newLinha;
@ -5277,6 +5320,7 @@ namespace line_gestao_api.Controllers
{ {
Id = x.Id, Id = x.Id,
Item = x.Item, Item = x.Item,
ContaEmpresa = FindEmpresaByConta(x.Conta),
Conta = x.Conta, Conta = x.Conta,
Linha = x.Linha, Linha = x.Linha,
Chip = x.Chip, Chip = x.Chip,

View File

@ -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<ActionResult<MveAuditRunDto>> 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<ActionResult<MveAuditRunDto>> GetById(Guid id, CancellationToken cancellationToken)
{
var result = await _mveAuditService.GetByIdAsync(id, cancellationToken);
return result == null ? NotFound() : Ok(result);
}
[HttpGet("latest")]
public async Task<ActionResult<MveAuditRunDto>> GetLatest(CancellationToken cancellationToken)
{
var result = await _mveAuditService.GetLatestAsync(cancellationToken);
return result == null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/apply")]
public async Task<ActionResult<ApplyMveAuditResultDto>> 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 });
}
}
}

View File

@ -77,6 +77,10 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
public DbSet<ImportAuditRun> ImportAuditRuns => Set<ImportAuditRun>(); public DbSet<ImportAuditRun> ImportAuditRuns => Set<ImportAuditRun>();
public DbSet<ImportAuditIssue> ImportAuditIssues => Set<ImportAuditIssue>(); public DbSet<ImportAuditIssue> ImportAuditIssues => Set<ImportAuditIssue>();
// ✅ tabelas de auditoria MVE
public DbSet<MveAuditRun> MveAuditRuns => Set<MveAuditRun>();
public DbSet<MveAuditIssue> MveAuditIssues => Set<MveAuditIssue>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@ -100,6 +104,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
modelBuilder.Entity<Aparelho>(e => modelBuilder.Entity<Aparelho>(e =>
{ {
e.Property(x => x.Nome).HasMaxLength(160); 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.Cor).HasMaxLength(80);
e.Property(x => x.Imei).HasMaxLength(80); e.Property(x => x.Imei).HasMaxLength(80);
e.Property(x => x.NotaFiscalArquivoPath).HasMaxLength(500); e.Property(x => x.NotaFiscalArquivoPath).HasMaxLength(500);
@ -107,6 +112,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
e.HasIndex(x => x.TenantId); e.HasIndex(x => x.TenantId);
e.HasIndex(x => x.Imei); e.HasIndex(x => x.Imei);
e.HasIndex(x => new { x.TenantId, x.Nome, x.Cor }); 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<ApplicationUser, IdentityRole<Guid
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity<MveAuditRun>(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<MveAuditIssue>(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<MobileLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<MobileLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<MuregLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<MuregLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<Setor>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<Setor>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
@ -419,6 +467,8 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
modelBuilder.Entity<AuditLog>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<AuditLog>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ImportAuditRun>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ImportAuditRun>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ImportAuditIssue>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ImportAuditIssue>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<MveAuditRun>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<MveAuditIssue>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ApplicationUser>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ApplicationUser>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
} }

View File

@ -34,6 +34,7 @@
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public int Item { get; set; } public int Item { get; set; }
public string? ContaEmpresa { get; set; }
public string? Conta { get; set; } public string? Conta { get; set; }
public string? Linha { get; set; } public string? Linha { get; set; }
public string? Chip { get; set; } public string? Chip { get; set; }

158
Dtos/MveAuditDtos.cs Normal file
View File

@ -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<MveAuditIssueDto> 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<MveAuditDifferenceDto> 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<string> 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<Guid>? 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; }
}

View File

@ -0,0 +1,160 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace line_gestao_api.Migrations
{
/// <inheritdoc />
public partial class AddMveAuditHistoryAndAparelhoFabricante : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Fabricante",
table: "Aparelhos",
type: "character varying(120)",
maxLength: 120,
nullable: true);
migrationBuilder.CreateTable(
name: "MveAuditRuns",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: true),
FileHashSha256 = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
FileEncoding = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: true),
Status = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
TotalSystemLines = table.Column<int>(type: "integer", nullable: false),
TotalReportLines = table.Column<int>(type: "integer", nullable: false),
TotalConciliated = table.Column<int>(type: "integer", nullable: false),
TotalStatusDivergences = table.Column<int>(type: "integer", nullable: false),
TotalDataDivergences = table.Column<int>(type: "integer", nullable: false),
TotalOnlyInSystem = table.Column<int>(type: "integer", nullable: false),
TotalOnlyInReport = table.Column<int>(type: "integer", nullable: false),
TotalDuplicateReportLines = table.Column<int>(type: "integer", nullable: false),
TotalDuplicateSystemLines = table.Column<int>(type: "integer", nullable: false),
TotalInvalidRows = table.Column<int>(type: "integer", nullable: false),
TotalUnknownStatuses = table.Column<int>(type: "integer", nullable: false),
TotalSyncableIssues = table.Column<int>(type: "integer", nullable: false),
AppliedIssuesCount = table.Column<int>(type: "integer", nullable: false),
AppliedLinesCount = table.Column<int>(type: "integer", nullable: false),
AppliedFieldsCount = table.Column<int>(type: "integer", nullable: false),
ImportedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
AppliedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
AppliedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
AppliedByUserName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
AppliedByUserEmail = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
AuditRunId = table.Column<Guid>(type: "uuid", nullable: false),
SourceRowNumber = table.Column<int>(type: "integer", nullable: true),
NumeroLinha = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
MobileLineId = table.Column<Guid>(type: "uuid", nullable: true),
SystemItem = table.Column<int>(type: "integer", nullable: true),
IssueType = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
Situation = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
Severity = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
Syncable = table.Column<bool>(type: "boolean", nullable: false),
Applied = table.Column<bool>(type: "boolean", nullable: false),
AppliedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ActionSuggestion = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
Notes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
SystemStatus = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
ReportStatus = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
SystemPlan = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
ReportPlan = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SystemSnapshotJson = table.Column<string>(type: "jsonb", nullable: false),
ReportSnapshotJson = table.Column<string>(type: "jsonb", nullable: false),
DifferencesJson = table.Column<string>(type: "jsonb", nullable: false),
CreatedAtUtc = table.Column<DateTime>(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");
}
/// <inheritdoc />
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");
}
}
}

View File

@ -343,6 +343,10 @@ namespace line_gestao_api.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("Fabricante")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Imei") b.Property<string>("Imei")
.HasMaxLength(80) .HasMaxLength(80)
.HasColumnType("character varying(80)"); .HasColumnType("character varying(80)");
@ -371,6 +375,8 @@ namespace line_gestao_api.Migrations
b.HasIndex("TenantId"); b.HasIndex("TenantId");
b.HasIndex("TenantId", "Fabricante");
b.HasIndex("TenantId", "Nome", "Cor"); b.HasIndex("TenantId", "Nome", "Cor");
b.ToTable("Aparelhos"); b.ToTable("Aparelhos");
@ -664,6 +670,209 @@ namespace line_gestao_api.Migrations
b.ToTable("ImportAuditRuns"); b.ToTable("ImportAuditRuns");
}); });
modelBuilder.Entity("line_gestao_api.Models.MveAuditIssue", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuditRunId")
.HasColumnType("uuid");
b.Property<string>("ActionSuggestion")
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<bool>("Applied")
.HasColumnType("boolean");
b.Property<DateTime?>("AppliedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DifferencesJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("IssueType")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<Guid?>("MobileLineId")
.HasColumnType("uuid");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("NumeroLinha")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ReportPlan")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ReportSnapshotJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("ReportStatus")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<int?>("SourceRowNumber")
.HasColumnType("integer");
b.Property<string>("Situation")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<bool>("Syncable")
.HasColumnType("boolean");
b.Property<string>("SystemPlan")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("SystemItem")
.HasColumnType("integer");
b.Property<string>("SystemSnapshotJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("SystemStatus")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AppliedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("AppliedByUserEmail")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("AppliedByUserId")
.HasColumnType("uuid");
b.Property<string>("AppliedByUserName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("AppliedFieldsCount")
.HasColumnType("integer");
b.Property<int>("AppliedIssuesCount")
.HasColumnType("integer");
b.Property<int>("AppliedLinesCount")
.HasColumnType("integer");
b.Property<string>("FileEncoding")
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("FileHashSha256")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("FileName")
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<DateTime>("ImportedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<int>("TotalConciliated")
.HasColumnType("integer");
b.Property<int>("TotalDataDivergences")
.HasColumnType("integer");
b.Property<int>("TotalDuplicateReportLines")
.HasColumnType("integer");
b.Property<int>("TotalDuplicateSystemLines")
.HasColumnType("integer");
b.Property<int>("TotalInvalidRows")
.HasColumnType("integer");
b.Property<int>("TotalOnlyInReport")
.HasColumnType("integer");
b.Property<int>("TotalOnlyInSystem")
.HasColumnType("integer");
b.Property<int>("TotalReportLines")
.HasColumnType("integer");
b.Property<int>("TotalStatusDivergences")
.HasColumnType("integer");
b.Property<int>("TotalSyncableIssues")
.HasColumnType("integer");
b.Property<int>("TotalSystemLines")
.HasColumnType("integer");
b.Property<int>("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 => modelBuilder.Entity("line_gestao_api.Models.MobileLine", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1864,6 +2073,17 @@ namespace line_gestao_api.Migrations
b.Navigation("AuditRun"); 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 => modelBuilder.Entity("line_gestao_api.Models.Notification", b =>
{ {
b.HasOne("line_gestao_api.Models.ApplicationUser", "User") b.HasOne("line_gestao_api.Models.ApplicationUser", "User")
@ -1924,6 +2144,11 @@ namespace line_gestao_api.Migrations
b.Navigation("Issues"); b.Navigation("Issues");
}); });
modelBuilder.Entity("line_gestao_api.Models.MveAuditRun", b =>
{
b.Navigation("Issues");
});
modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b => modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b =>
{ {
b.Navigation("MonthValues"); b.Navigation("MonthValues");

View File

@ -11,6 +11,9 @@ public class Aparelho : ITenantEntity
[MaxLength(160)] [MaxLength(160)]
public string? Nome { get; set; } public string? Nome { get; set; }
[MaxLength(120)]
public string? Fabricante { get; set; }
[MaxLength(80)] [MaxLength(80)]
public string? Cor { get; set; } public string? Cor { get; set; }

52
Models/MveAuditIssue.cs Normal file
View File

@ -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;
}

58
Models/MveAuditRun.cs Normal file
View File

@ -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<MveAuditIssue> Issues { get; set; } = new List<MveAuditIssue>();
}

View File

@ -16,6 +16,7 @@ using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true);
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var dataProtectionKeyPath = builder.Environment.IsProduction() var dataProtectionKeyPath = builder.Environment.IsProduction()
? "/var/www/html/line-gestao-api/publish/.aspnet-keys" ? "/var/www/html/line-gestao-api/publish/.aspnet-keys"
@ -98,6 +99,10 @@ builder.Services.AddScoped<ParcelamentosImportService>();
builder.Services.AddScoped<GeralDashboardInsightsService>(); builder.Services.AddScoped<GeralDashboardInsightsService>();
builder.Services.AddScoped<GeralSpreadsheetTemplateService>(); builder.Services.AddScoped<GeralSpreadsheetTemplateService>();
builder.Services.AddScoped<SpreadsheetImportAuditService>(); builder.Services.AddScoped<SpreadsheetImportAuditService>();
builder.Services.AddScoped<MveCsvParserService>();
builder.Services.AddScoped<MveReconciliationService>();
builder.Services.AddScoped<MveAuditService>();
builder.Services.AddScoped<MveAuditSchemaBootstrapper>();
builder.Services.AddIdentityCore<ApplicationUser>(options => builder.Services.AddIdentityCore<ApplicationUser>(options =>
{ {
@ -197,6 +202,11 @@ app.UseMiddleware<TenantMiddleware>();
app.UseAuthorization(); app.UseAuthorization();
await SeedData.EnsureSeedDataAsync(app.Services); await SeedData.EnsureSeedDataAsync(app.Services);
using (var scope = app.Services.CreateScope())
{
var schemaBootstrapper = scope.ServiceProvider.GetRequiredService<MveAuditSchemaBootstrapper>();
await schemaBootstrapper.EnsureSchemaAsync();
}
app.MapControllers(); app.MapControllers();
app.MapGet("/health", () => Results.Ok(new { status = "ok" })); app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

View File

@ -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);

View File

@ -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);
}
}

613
Services/MveAuditService.cs Normal file
View File

@ -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<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");
}
}

View File

@ -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<MveParsedFileResult> ParseAsync(IFormFile file, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(file);
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory, cancellationToken);
return Parse(memory.ToArray(), file.FileName);
}
internal MveParsedFileResult Parse(byte[] bytes, string? fileName)
{
ArgumentNullException.ThrowIfNull(bytes);
var decoded = Decode(bytes);
var rows = ParseCsvRows(decoded.Content);
if (rows.Count == 0)
{
throw new InvalidOperationException("O arquivo CSV do MVE está vazio.");
}
var headerRow = rows[0];
var headerMap = BuildHeaderMap(headerRow);
var missingHeaders = RequiredHeaders
.Where(header => !headerMap.ContainsKey(MveAuditNormalization.NormalizeHeader(header)))
.ToList();
if (missingHeaders.Count > 0)
{
throw new InvalidOperationException(
$"O relatório MVE não contém as colunas obrigatórias: {string.Join(", ", missingHeaders)}.");
}
var serviceColumns = headerMap
.Where(entry => entry.Key.StartsWith("SERVICO_ATIVOS", StringComparison.Ordinal))
.OrderBy(entry => entry.Value)
.Select(entry => entry.Value)
.ToList();
var result = new MveParsedFileResult
{
FileName = string.IsNullOrWhiteSpace(fileName) ? null : fileName.Trim(),
FileEncoding = decoded.Encoding.WebName,
FileHashSha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(),
SourceRowCount = Math.Max(rows.Count - 1, 0)
};
for (var rowIndex = 1; rowIndex < rows.Count; rowIndex++)
{
var row = rows[rowIndex];
if (IsEmptyRow(row))
{
continue;
}
var sourceRowNumber = rowIndex + 1;
var ddd = GetValue(row, headerMap, "DDD");
var numero = GetValue(row, headerMap, "NUMERO");
var numeroNormalizado = MveAuditNormalization.OnlyDigits($"{ddd}{numero}");
if (string.IsNullOrWhiteSpace(ddd) || string.IsNullOrWhiteSpace(numero) || string.IsNullOrWhiteSpace(numeroNormalizado))
{
result.Issues.Add(new MveParsedIssue(
sourceRowNumber,
string.Empty,
"INVALID_ROW",
"Linha sem DDD e/ou número válido no relatório MVE."));
continue;
}
var status = MveAuditNormalization.NormalizeReportStatus(GetValue(row, headerMap, "STATUS_LINHA"));
var line = new MveParsedLine
{
SourceRowNumber = sourceRowNumber,
Ddd = ddd,
Numero = numero,
NumeroNormalizado = numeroNormalizado,
StatusLinha = status.DisplayValue,
StatusLinhaKey = status.Key,
StatusLinhaRecognized = status.Recognized,
StatusConta = GetValue(row, headerMap, "STATUS_CONTA"),
PlanoLinha = GetValue(row, headerMap, "PLANO_LINHA"),
DataAtivacao = MveAuditNormalization.ParseDateValue(GetValue(row, headerMap, "DATA_ATIVACAO")),
TerminoContrato = MveAuditNormalization.ParseDateValue(GetValue(row, headerMap, "TERMINO_CONTRATO")),
Chip = MveAuditNormalization.NullIfEmptyDigits(GetValue(row, headerMap, "CHIP")),
Conta = NullIfEmpty(MveAuditNormalization.CleanTextValue(GetValue(row, headerMap, "CONTA"))),
Cnpj = MveAuditNormalization.NullIfEmptyDigits(GetValue(row, headerMap, "CNPJ")),
ModeloAparelho = NullIfEmpty(MveAuditNormalization.CleanTextValue(GetValue(row, headerMap, "MODELO_APARELHO"))),
Fabricante = NullIfEmpty(MveAuditNormalization.CleanTextValue(GetValue(row, headerMap, "FABRICANTE")))
};
foreach (var columnIndex in serviceColumns)
{
if (columnIndex < 0 || columnIndex >= row.Count)
{
continue;
}
var serviceValue = NullIfEmpty(MveAuditNormalization.CleanTextValue(row[columnIndex]));
if (!string.IsNullOrWhiteSpace(serviceValue))
{
line.ServicosAtivos.Add(serviceValue);
}
}
result.Lines.Add(line);
}
return result;
}
private static DecodedContent Decode(byte[] bytes)
{
foreach (var encoding in new[] { StrictUtf8, Windows1252, Latin1 })
{
try
{
var content = encoding.GetString(bytes);
if (!string.IsNullOrWhiteSpace(content))
{
return new DecodedContent(encoding, content.TrimStart('\uFEFF'));
}
}
catch
{
// tenta o próximo encoding
}
}
return new DecodedContent(Latin1, Latin1.GetString(bytes).TrimStart('\uFEFF'));
}
private static List<List<string>> ParseCsvRows(string content)
{
var rows = new List<List<string>>();
var currentRow = new List<string>();
var currentField = new StringBuilder();
var inQuotes = false;
for (var i = 0; i < content.Length; i++)
{
var ch = content[i];
if (inQuotes)
{
if (ch == '"')
{
if (i + 1 < content.Length && content[i + 1] == '"')
{
currentField.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
currentField.Append(ch);
}
continue;
}
switch (ch)
{
case '"':
inQuotes = true;
break;
case ';':
currentRow.Add(currentField.ToString());
currentField.Clear();
break;
case '\r':
if (i + 1 < content.Length && content[i + 1] == '\n')
{
i++;
}
currentRow.Add(currentField.ToString());
currentField.Clear();
rows.Add(currentRow);
currentRow = new List<string>();
break;
case '\n':
currentRow.Add(currentField.ToString());
currentField.Clear();
rows.Add(currentRow);
currentRow = new List<string>();
break;
default:
currentField.Append(ch);
break;
}
}
currentRow.Add(currentField.ToString());
if (currentRow.Count > 1 || !string.IsNullOrWhiteSpace(currentRow[0]))
{
rows.Add(currentRow);
}
return rows;
}
private static Dictionary<string, int> BuildHeaderMap(IReadOnlyList<string> headerRow)
{
var map = new Dictionary<string, int>(StringComparer.Ordinal);
for (var i = 0; i < headerRow.Count; i++)
{
var key = MveAuditNormalization.NormalizeHeader(headerRow[i]);
if (string.IsNullOrWhiteSpace(key) || key.StartsWith("UNNAMED", StringComparison.Ordinal))
{
continue;
}
if (!map.ContainsKey(key))
{
map[key] = i;
}
}
return map;
}
private static string GetValue(IReadOnlyList<string> row, IReadOnlyDictionary<string, int> headerMap, string header)
{
var normalizedHeader = MveAuditNormalization.NormalizeHeader(header);
if (!headerMap.TryGetValue(normalizedHeader, out var index))
{
return string.Empty;
}
if (index < 0 || index >= row.Count)
{
return string.Empty;
}
return MveAuditNormalization.CleanTextValue(row[index]);
}
private static bool IsEmptyRow(IReadOnlyList<string> row)
{
return row.Count == 0 || row.All(cell => string.IsNullOrWhiteSpace(MveAuditNormalization.CleanTextValue(cell)));
}
private static string? NullIfEmpty(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private sealed record DecodedContent(Encoding Encoding, string Content);
}
internal sealed class MveParsedFileResult
{
public string? FileName { get; init; }
public string FileEncoding { get; init; } = string.Empty;
public string FileHashSha256 { get; init; } = string.Empty;
public int SourceRowCount { get; init; }
public List<MveParsedLine> Lines { get; } = new();
public List<MveParsedIssue> Issues { get; } = new();
}
internal sealed class MveParsedLine
{
public int SourceRowNumber { get; init; }
public string Ddd { get; init; } = string.Empty;
public string Numero { get; init; } = string.Empty;
public string NumeroNormalizado { get; init; } = string.Empty;
public string StatusLinha { get; init; } = string.Empty;
public string StatusLinhaKey { get; init; } = string.Empty;
public bool StatusLinhaRecognized { get; init; }
public string? StatusConta { get; init; }
public string? PlanoLinha { get; init; }
public DateTime? DataAtivacao { get; init; }
public DateTime? TerminoContrato { get; init; }
public string? Chip { get; init; }
public string? Conta { get; init; }
public string? Cnpj { get; init; }
public string? ModeloAparelho { get; init; }
public string? Fabricante { get; init; }
public List<string> ServicosAtivos { get; } = new();
}
internal sealed record MveParsedIssue(
int SourceRowNumber,
string NumeroLinha,
string IssueType,
string Message);

View File

@ -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<MveReconciliationResult> 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<string>(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<MveSystemLineAggregate> BuildSystemAggregates(
IReadOnlyCollection<MobileLine> mobileLines,
IReadOnlyCollection<VigenciaLine> vigencias,
IReadOnlyCollection<UserData> 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<MveAuditDifferenceDto>();
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<string>();
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<MveAuditDifferenceDto> differences,
string fieldKey,
string label,
string? systemValue,
string? reportValue,
bool syncable,
Func<string?, string> 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<MveReconciliationIssueResult> 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<MveAuditDifferenceDto> Differences { get; init; } = new();
}
internal sealed record MveSystemLineAggregate(
MobileLine MobileLine,
VigenciaLine? Vigencia,
UserData? UserData,
string NumeroNormalizado);

View File

@ -27,11 +27,18 @@ public class VigenciaNotificationBackgroundService : BackgroundService
var intervalMinutes = _options.CheckIntervalMinutes <= 0 ? 60 : _options.CheckIntervalMinutes; var intervalMinutes = _options.CheckIntervalMinutes <= 0 ? 60 : _options.CheckIntervalMinutes;
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(intervalMinutes)); using var timer = new PeriodicTimer(TimeSpan.FromMinutes(intervalMinutes));
await RunOnceAsync(stoppingToken); try
while (await timer.WaitForNextTickAsync(stoppingToken))
{ {
await RunOnceAsync(stoppingToken); 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); 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."); _logger.LogError(ex, "Erro ao gerar notificações de vigência.");
} }