Compare commits

...

4 Commits

Author SHA1 Message Date
Leon d37ea0c377 feat: corrigindo erro 2026-03-09 17:03:20 -03:00
Leon d4ec1f72a6 Merge remote-tracking branch 'origin/novas-implementacoes-dudu' into dev 2026-03-09 15:30:20 -03:00
Leon 78f403105c Aplicando filtros por operadora e endpoints para filtragem 2026-03-09 15:17:28 -03:00
Eduardo Lopes 22ab6997d3 feat: novas implementações e ajustes 2026-03-09 15:14:34 -03:00
23 changed files with 3068 additions and 74 deletions

View File

@ -18,9 +18,9 @@ namespace line_gestao_api.Controllers
} }
[HttpGet("insights")] [HttpGet("insights")]
public async Task<ActionResult<GeralDashboardInsightsDto>> GetInsights() public async Task<ActionResult<GeralDashboardInsightsDto>> GetInsights([FromQuery] string? operadora = null)
{ {
var dto = await _service.GetInsightsAsync(); var dto = await _service.GetInsightsAsync(operadora);
return Ok(dto); return Ok(dto);
} }
} }

View File

@ -33,29 +33,37 @@ namespace line_gestao_api.Controllers
private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService; private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService;
private readonly string _aparelhoAttachmentsRootPath; private readonly string _aparelhoAttachmentsRootPath;
private static readonly FileExtensionContentTypeProvider FileContentTypeProvider = new(); private static readonly FileExtensionContentTypeProvider FileContentTypeProvider = new();
private static readonly List<AccountCompanyDto> AccountCompanies = new()
private static string NormalizeContaValue(string? conta)
{ {
new AccountCompanyDto var raw = (conta ?? string.Empty).Trim();
{ if (string.IsNullOrWhiteSpace(raw)) return string.Empty;
Empresa = "CLARO LINE MÓVEL", if (!raw.All(char.IsDigit)) return raw.ToUpperInvariant();
Contas = new List<string> { "172593311", "172593840" }
}, var withoutLeadingZero = raw.TrimStart('0');
new AccountCompanyDto return string.IsNullOrWhiteSpace(withoutLeadingZero) ? "0" : withoutLeadingZero;
{ }
Empresa = "VIVO MACROPHONY",
Contas = new List<string> { "0430237019", "0437488125", "0449508564", "0454371844" } private static string? FindEmpresaByConta(string? conta)
}, {
new AccountCompanyDto var normalizedConta = NormalizeContaValue(conta);
{ if (string.IsNullOrWhiteSpace(normalizedConta)) return null;
Empresa = "VIVO LINE MÓVEL",
Contas = new List<string> { "0435288088" } return OperadoraContaResolver.GetAccountCompanies()
}, .FirstOrDefault(group => group.Contas.Any(c => NormalizeContaValue(c) == normalizedConta))
new AccountCompanyDto ?.Empresa;
{ }
Empresa = "TIM LINE MÓVEL",
Contas = new List<string> { "0072046192" } 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,
@ -390,31 +398,14 @@ namespace line_gestao_api.Controllers
[HttpGet("account-companies")] [HttpGet("account-companies")]
public ActionResult<List<AccountCompanyDto>> GetAccountCompanies() public ActionResult<List<AccountCompanyDto>> GetAccountCompanies()
{ {
var items = AccountCompanies var items = OperadoraContaResolver.GetAccountCompanies();
.Select(x => new AccountCompanyDto
{
Empresa = x.Empresa,
Contas = x.Contas.ToList()
})
.ToList();
return Ok(items); return Ok(items);
} }
[HttpGet("accounts")] [HttpGet("accounts")]
public ActionResult<List<string>> GetAccounts([FromQuery] string? empresa) public ActionResult<List<string>> GetAccounts([FromQuery] string? empresa)
{ {
if (string.IsNullOrWhiteSpace(empresa)) var contas = OperadoraContaResolver.GetAccountsByEmpresa(empresa);
return Ok(new List<string>());
var target = empresa.Trim();
var contas = AccountCompanies
.FirstOrDefault(x => string.Equals(x.Empresa, target, StringComparison.OrdinalIgnoreCase))
?.Contas
?.ToList()
?? new List<string>();
return Ok(contas); return Ok(contas);
} }
@ -459,6 +450,7 @@ namespace line_gestao_api.Controllers
[FromQuery] string? search, [FromQuery] string? search,
[FromQuery] string? skil, [FromQuery] string? skil,
[FromQuery] string? client, [FromQuery] string? client,
[FromQuery] string? operadora,
[FromQuery] string? additionalMode, [FromQuery] string? additionalMode,
[FromQuery] string? additionalServices, [FromQuery] string? additionalServices,
[FromQuery] int page = 1, [FromQuery] int page = 1,
@ -491,6 +483,7 @@ namespace line_gestao_api.Controllers
q = ExcludeReservaContext(q); q = ExcludeReservaContext(q);
q = ApplyAdditionalFilters(q, additionalMode, additionalServices); q = ApplyAdditionalFilters(q, additionalMode, additionalServices);
q = OperadoraContaResolver.ApplyOperadoraFilter(q, operadora);
var sb = (sortBy ?? "item").Trim().ToLowerInvariant(); var sb = (sortBy ?? "item").Trim().ToLowerInvariant();
var desc = string.Equals((sortDir ?? "asc").Trim(), "desc", StringComparison.OrdinalIgnoreCase); var desc = string.Equals((sortDir ?? "asc").Trim(), "desc", StringComparison.OrdinalIgnoreCase);
@ -537,14 +530,15 @@ namespace line_gestao_api.Controllers
Cliente = clienteExibicao, Cliente = clienteExibicao,
line.Usuario, line.Usuario,
line.CentroDeCustos, line.CentroDeCustos,
SetorNome = line.Setor != null ? line.Setor.Nome : null, SetorNome = line.Setor != null ? line.Setor!.Nome : null,
AparelhoNome = line.Aparelho != null ? line.Aparelho.Nome : null, AparelhoNome = line.Aparelho != null ? line.Aparelho!.Nome : null,
AparelhoCor = line.Aparelho != null ? line.Aparelho.Cor : null, AparelhoCor = line.Aparelho != null ? line.Aparelho!.Cor : null,
line.PlanoContrato, line.PlanoContrato,
line.Status, line.Status,
line.Skil, line.Skil,
line.Modalidade, line.Modalidade,
line.VencConta, line.VencConta,
line.FranquiaVivo,
line.FranquiaLine, line.FranquiaLine,
line.GestaoVozDados, line.GestaoVozDados,
line.Skeelo, line.Skeelo,
@ -614,6 +608,7 @@ namespace line_gestao_api.Controllers
Skil = x.Skil, Skil = x.Skil,
Modalidade = x.Modalidade, Modalidade = x.Modalidade,
VencConta = x.VencConta, VencConta = x.VencConta,
FranquiaVivo = x.FranquiaVivo,
FranquiaLine = x.FranquiaLine, FranquiaLine = x.FranquiaLine,
GestaoVozDados = x.GestaoVozDados, GestaoVozDados = x.GestaoVozDados,
Skeelo = x.Skeelo, Skeelo = x.Skeelo,
@ -625,6 +620,8 @@ namespace line_gestao_api.Controllers
}) })
.ToListAsync(); .ToListAsync();
EnrichOperadoraContext(itemsReserva);
return Ok(new PagedResult<MobileLineListDto> return Ok(new PagedResult<MobileLineListDto>
{ {
Page = page, Page = page,
@ -687,6 +684,7 @@ namespace line_gestao_api.Controllers
Skil = x.Skil, Skil = x.Skil,
Modalidade = x.Modalidade, Modalidade = x.Modalidade,
VencConta = x.VencConta, VencConta = x.VencConta,
FranquiaVivo = x.FranquiaVivo,
FranquiaLine = x.FranquiaLine, FranquiaLine = x.FranquiaLine,
GestaoVozDados = x.GestaoVozDados, GestaoVozDados = x.GestaoVozDados,
Skeelo = x.Skeelo, Skeelo = x.Skeelo,
@ -698,6 +696,8 @@ namespace line_gestao_api.Controllers
}) })
.ToListAsync(); .ToListAsync();
EnrichOperadoraContext(items);
return Ok(new PagedResult<MobileLineListDto> return Ok(new PagedResult<MobileLineListDto>
{ {
Page = page, Page = page,
@ -748,6 +748,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 +944,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);
@ -1329,21 +1337,24 @@ namespace line_gestao_api.Controllers
// ========================================================== // ==========================================================
[HttpPost("batch-status-update")] [HttpPost("batch-status-update")]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<BatchLineStatusUpdateResultDto>> BatchStatusUpdate([FromBody] BatchLineStatusUpdateRequestDto req) public async Task<ActionResult<BatchLineStatusUpdateResultDto>> BatchStatusUpdate([FromBody] BatchLineStatusUpdateRequestDto? req)
{ {
var action = (req?.Action ?? "").Trim().ToLowerInvariant(); if (req is null)
return BadRequest(new { message = "Payload inválido para processamento em lote." });
var action = (req.Action ?? "").Trim().ToLowerInvariant();
var isBlockAction = action is "block" or "bloquear"; var isBlockAction = action is "block" or "bloquear";
var isUnblockAction = action is "unblock" or "desbloquear"; var isUnblockAction = action is "unblock" or "desbloquear";
if (!isBlockAction && !isUnblockAction) if (!isBlockAction && !isUnblockAction)
return BadRequest(new { message = "Ação inválida. Use 'block' ou 'unblock'." }); return BadRequest(new { message = "Ação inválida. Use 'block' ou 'unblock'." });
var blockStatus = NormalizeOptionalText(req?.BlockStatus); var blockStatus = NormalizeOptionalText(req.BlockStatus);
if (isBlockAction && string.IsNullOrWhiteSpace(blockStatus)) if (isBlockAction && string.IsNullOrWhiteSpace(blockStatus))
return BadRequest(new { message = "Informe o tipo de bloqueio para bloqueio em lote." }); return BadRequest(new { message = "Informe o tipo de bloqueio para bloqueio em lote." });
var applyToAllFiltered = req?.ApplyToAllFiltered ?? false; var applyToAllFiltered = req.ApplyToAllFiltered;
var ids = (req?.LineIds ?? new List<Guid>()) var ids = (req.LineIds ?? new List<Guid>())
.Where(x => x != Guid.Empty) .Where(x => x != Guid.Empty)
.Distinct() .Distinct()
.ToList(); .ToList();
@ -1370,7 +1381,7 @@ namespace line_gestao_api.Controllers
baseQuery = _db.MobileLines.Where(x => ids.Contains(x.Id)); baseQuery = _db.MobileLines.Where(x => ids.Contains(x.Id));
} }
var userFilter = (req?.Usuario ?? "").Trim(); var userFilter = (req.Usuario ?? "").Trim();
if (!applyToAllFiltered && !string.IsNullOrWhiteSpace(userFilter)) if (!applyToAllFiltered && !string.IsNullOrWhiteSpace(userFilter))
{ {
baseQuery = baseQuery.Where(x => EF.Functions.ILike(x.Usuario ?? "", $"%{userFilter}%")); baseQuery = baseQuery.Where(x => EF.Functions.ILike(x.Usuario ?? "", $"%{userFilter}%"));
@ -1550,6 +1561,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;
@ -5273,10 +5288,21 @@ namespace line_gestao_api.Controllers
return vigencia; return vigencia;
} }
private static void EnrichOperadoraContext(IEnumerable<MobileLineListDto> items)
{
foreach (var item in items ?? Enumerable.Empty<MobileLineListDto>())
{
var context = OperadoraContaResolver.Resolve(item.Conta);
item.ContaEmpresa = context.Empresa;
item.Operadora = context.Operadora;
}
}
private static MobileLineDetailDto ToDetailDto(MobileLine x, VigenciaLine? vigencia = null) => new() private static MobileLineDetailDto ToDetailDto(MobileLine x, VigenciaLine? vigencia = null) => new()
{ {
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,
@ -5330,7 +5356,7 @@ namespace line_gestao_api.Controllers
private static void ApplyBlockedLineToReservaContext(MobileLine line) private static void ApplyBlockedLineToReservaContext(MobileLine line)
{ {
if (!ShouldAutoMoveBlockedLineToReserva(line?.Status)) return; if (!ShouldAutoMoveBlockedLineToReserva(line.Status)) return;
line.Usuario = "RESERVA"; line.Usuario = "RESERVA";
line.Skil = "RESERVA"; line.Skil = "RESERVA";
if (string.IsNullOrWhiteSpace(line.Cliente)) if (string.IsNullOrWhiteSpace(line.Cliente))

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

@ -1,5 +1,6 @@
using line_gestao_api.Data; using line_gestao_api.Data;
using line_gestao_api.Dtos; using line_gestao_api.Dtos;
using line_gestao_api.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -19,7 +20,7 @@ namespace line_gestao_api.Controllers
} }
[HttpGet("dashboard")] [HttpGet("dashboard")]
public async Task<ActionResult<RelatoriosDashboardDto>> GetDashboard() public async Task<ActionResult<RelatoriosDashboardDto>> GetDashboard([FromQuery] string? operadora = null)
{ {
var todayUtcStart = DateTime.SpecifyKind(DateTime.UtcNow.Date, DateTimeKind.Utc); var todayUtcStart = DateTime.SpecifyKind(DateTime.UtcNow.Date, DateTimeKind.Utc);
var tomorrowUtcStart = todayUtcStart.AddDays(1); var tomorrowUtcStart = todayUtcStart.AddDays(1);
@ -31,32 +32,40 @@ namespace line_gestao_api.Controllers
// ========================= // =========================
// GERAL (MobileLines) // GERAL (MobileLines)
// ========================= // =========================
var qLines = _db.MobileLines.AsNoTracking(); var qLines = OperadoraContaResolver.ApplyOperadoraFilter(_db.MobileLines.AsNoTracking(), operadora);
var qLinesWithClient = qLines.Where(x => x.Cliente != null && x.Cliente != ""); var qReserva = qLines.Where(x =>
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
var qOperacionais = qLines.Where(x =>
!EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") &&
!EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") &&
!EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
var qOperacionaisWithClient = qOperacionais.Where(x => x.Cliente != null && x.Cliente != "");
var totalLinhas = await qLines.CountAsync(); var totalLinhas = await qLines.CountAsync();
var clientesUnicos = await qLines var clientesUnicos = await qOperacionais
.Where(x => x.Cliente != null && x.Cliente != "") .Where(x => x.Cliente != null && x.Cliente != "")
.Select(x => x.Cliente!) .Select(x => x.Cliente!)
.Distinct() .Distinct()
.CountAsync(); .CountAsync();
var ativos = await qLines.CountAsync(x => var ativos = await qOperacionais.CountAsync(x =>
EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%")); EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%"));
var bloqueadosPerdaRoubo = await qLinesWithClient.CountAsync(x => var bloqueadosPerdaRoubo = await qOperacionaisWithClient.CountAsync(x =>
EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") ||
EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%")); EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%"));
var bloqueados120Dias = await qLinesWithClient.CountAsync(x => var bloqueados120Dias = await qOperacionaisWithClient.CountAsync(x =>
EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") && EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") &&
EF.Functions.ILike((x.Status ?? "").Trim(), "%120%") && EF.Functions.ILike((x.Status ?? "").Trim(), "%120%") &&
EF.Functions.ILike((x.Status ?? "").Trim(), "%dia%") && EF.Functions.ILike((x.Status ?? "").Trim(), "%dia%") &&
!(EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || !(EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") ||
EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%"))); EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%")));
var bloqueadosOutros = await qLinesWithClient.CountAsync(x => var bloqueadosOutros = await qOperacionaisWithClient.CountAsync(x =>
EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") && EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") &&
!(EF.Functions.ILike((x.Status ?? "").Trim(), "%120%") && EF.Functions.ILike((x.Status ?? "").Trim(), "%dia%")) && !(EF.Functions.ILike((x.Status ?? "").Trim(), "%120%") && EF.Functions.ILike((x.Status ?? "").Trim(), "%dia%")) &&
!(EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%")) !(EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%"))
@ -64,16 +73,13 @@ namespace line_gestao_api.Controllers
// Regra do KPI "Bloqueadas" alinhada ao critério da página Geral: // Regra do KPI "Bloqueadas" alinhada ao critério da página Geral:
// status contendo "bloque", "perda" ou "roubo". // status contendo "bloque", "perda" ou "roubo".
var bloqueados = await qLinesWithClient.CountAsync(x => var bloqueados = await qOperacionaisWithClient.CountAsync(x =>
EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") ||
EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") ||
EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%")); EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%"));
// Regra alinhada ao filtro "Reservas" da página Geral. // Regra alinhada ao filtro "Reservas" da página Geral.
var reservas = await qLines.CountAsync(x => var reservas = await qReserva.CountAsync();
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
var topClientes = await qLines var topClientes = await qLines
.Where(x => x.Cliente != null && x.Cliente != "") .Where(x => x.Cliente != null && x.Cliente != "")
@ -173,7 +179,16 @@ namespace line_gestao_api.Controllers
// ========================= // =========================
// USER DATA // USER DATA
// ========================= // =========================
var qUserData = _db.UserDatas.AsNoTracking(); var qLineItems = qLines
.Where(x => x.Item > 0)
.Select(x => x.Item);
var qLineNumbers = qLines
.Where(x => x.Linha != null && x.Linha != "")
.Select(x => x.Linha!);
var qUserData = _db.UserDatas.AsNoTracking()
.Where(x =>
(x.Item > 0 && qLineItems.Contains(x.Item)) ||
(x.Linha != null && x.Linha != "" && qLineNumbers.Contains(x.Linha)));
var userDataRegistros = await qUserData.CountAsync(); var userDataRegistros = await qUserData.CountAsync();
var userDataComCpf = await qUserData.CountAsync(x => x.Cpf != null && x.Cpf != ""); var userDataComCpf = await qUserData.CountAsync(x => x.Cpf != null && x.Cpf != "");

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

@ -31,6 +31,7 @@ namespace line_gestao_api.Dtos
{ {
public int QtdLinhas { get; set; } public int QtdLinhas { get; set; }
public decimal TotalFranquiaGb { get; set; } public decimal TotalFranquiaGb { get; set; }
public decimal TotalFranquiaLine { get; set; }
public decimal TotalBaseMensal { get; set; } public decimal TotalBaseMensal { get; set; }
public decimal TotalAdicionaisMensal { get; set; } public decimal TotalAdicionaisMensal { get; set; }
public decimal TotalGeralMensal { get; set; } public decimal TotalGeralMensal { get; set; }

View File

@ -18,6 +18,9 @@
public string? Skil { get; set; } public string? Skil { get; set; }
public string? Modalidade { get; set; } public string? Modalidade { get; set; }
public string? VencConta { get; set; } public string? VencConta { get; set; }
public string? ContaEmpresa { get; set; }
public string? Operadora { get; set; }
public decimal? FranquiaVivo { get; set; }
public decimal? FranquiaLine { get; set; } public decimal? FranquiaLine { get; set; }
// Campos para filtro deterministico de adicionais no frontend // Campos para filtro deterministico de adicionais no frontend
@ -34,6 +37,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; }

View File

@ -75,7 +75,6 @@ namespace line_gestao_api.Models
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// ✅ Navegação (1 MobileLine -> N Muregs)
public ICollection<MuregLine> Muregs { get; set; } = new List<MuregLine>(); public ICollection<MuregLine> Muregs { get; set; } = new List<MuregLine>();
} }
} }

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

@ -25,9 +25,9 @@ namespace line_gestao_api.Services
_db = db; _db = db;
} }
public async Task<GeralDashboardInsightsDto> GetInsightsAsync() public async Task<GeralDashboardInsightsDto> GetInsightsAsync(string? operadora = null)
{ {
var qLines = _db.MobileLines.AsNoTracking(); var qLines = OperadoraContaResolver.ApplyOperadoraFilter(_db.MobileLines.AsNoTracking(), operadora);
var totals = await qLines var totals = await qLines
.GroupBy(_ => 1) .GroupBy(_ => 1)
@ -99,6 +99,7 @@ namespace line_gestao_api.Services
x.VivoGestaoDispositivo != null) x.VivoGestaoDispositivo != null)
? (x.FranquiaVivo ?? 0m) ? (x.FranquiaVivo ?? 0m)
: 0m), : 0m),
TotalFranquiaLine = g.Sum(x => x.FranquiaLine ?? 0m),
VivoAdicionaisTotal = g.Sum(x => VivoAdicionaisTotal = g.Sum(x =>
(x.ValorPlanoVivo != null || (x.ValorPlanoVivo != null ||
x.FranquiaVivo != null || x.FranquiaVivo != null ||
@ -653,6 +654,7 @@ namespace line_gestao_api.Services
{ {
QtdLinhas = totals.VivoLinhas, QtdLinhas = totals.VivoLinhas,
TotalFranquiaGb = totals.VivoFranquiaTotalGb, TotalFranquiaGb = totals.VivoFranquiaTotalGb,
TotalFranquiaLine = totals.TotalFranquiaLine,
TotalBaseMensal = totals.VivoBaseTotal, TotalBaseMensal = totals.VivoBaseTotal,
TotalAdicionaisMensal = totals.VivoAdicionaisTotal, TotalAdicionaisMensal = totals.VivoAdicionaisTotal,
TotalGeralMensal = totalGeralMensal, TotalGeralMensal = totalGeralMensal,
@ -1110,6 +1112,7 @@ namespace line_gestao_api.Services
public int TotalBloqueados { get; set; } public int TotalBloqueados { get; set; }
public int VivoLinhas { get; set; } public int VivoLinhas { get; set; }
public decimal VivoFranquiaTotalGb { get; set; } public decimal VivoFranquiaTotalGb { get; set; }
public decimal TotalFranquiaLine { get; set; }
public decimal VivoBaseTotal { get; set; } public decimal VivoBaseTotal { get; set; }
public decimal VivoAdicionaisTotal { get; set; } public decimal VivoAdicionaisTotal { get; set; }
public decimal VivoMinBase { get; set; } public decimal VivoMinBase { get; set; }

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

@ -0,0 +1,260 @@
using System.Globalization;
using System.Text;
using line_gestao_api.Dtos;
using line_gestao_api.Models;
using Microsoft.EntityFrameworkCore;
namespace line_gestao_api.Services
{
public sealed record OperadoraContaContext(string Operadora, string Empresa, string? VivoEmpresaGrupo = null);
public static class OperadoraContaResolver
{
private const string OperadoraVivo = "VIVO";
private const string OperadoraClaro = "CLARO";
private const string OperadoraTim = "TIM";
private const string OperadoraOutra = "OUTRA";
private const string EmpresaVivoMacrophony = "VIVO MACROPHONY";
private const string EmpresaVivoLineMovel = "VIVO LINE MÓVEL";
private const string EmpresaClaroLineMovel = "CLARO LINE MÓVEL";
private const string EmpresaTimLineMovel = "TIM LINE MÓVEL";
private static readonly IReadOnlyList<AccountCompanyDto> CompanyRules = new List<AccountCompanyDto>
{
new()
{
Empresa = EmpresaClaroLineMovel,
Contas = new List<string> { "172593311", "172593840", "187890982" }
},
new()
{
Empresa = EmpresaVivoMacrophony,
Contas = new List<string>
{
"0430237019",
"0437488125",
"0449508564",
"0454371844",
"455371844",
"460161507"
}
},
new()
{
Empresa = EmpresaVivoLineMovel,
Contas = new List<string> { "0435288088" }
},
new()
{
Empresa = EmpresaTimLineMovel,
Contas = new List<string> { "TIM" }
}
};
private static readonly string[] VivoContaFilters = BuildContaFilterSet(EmpresaVivoMacrophony, EmpresaVivoLineMovel);
private static readonly string[] ClaroContaFilters = BuildContaFilterSet(EmpresaClaroLineMovel);
private static readonly string[] TimContaFilters = BuildContaFilterSet(EmpresaTimLineMovel);
private static readonly Dictionary<string, string> EmpresaByConta = BuildEmpresaByConta();
public static List<AccountCompanyDto> GetAccountCompanies()
{
return CompanyRules
.Select(x => new AccountCompanyDto
{
Empresa = x.Empresa,
Contas = x.Contas.ToList()
})
.ToList();
}
public static List<string> GetAccountsByEmpresa(string? empresa)
{
if (string.IsNullOrWhiteSpace(empresa)) return new List<string>();
var target = NormalizeToken(empresa);
if (string.IsNullOrWhiteSpace(target)) return new List<string>();
return CompanyRules
.FirstOrDefault(x => NormalizeToken(x.Empresa) == target)
?.Contas
?.ToList()
?? new List<string>();
}
public static OperadoraContaContext Resolve(string? conta)
{
var contaRaw = (conta ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(contaRaw))
{
return new OperadoraContaContext(OperadoraOutra, string.Empty, null);
}
var normalizedConta = NormalizeConta(contaRaw);
if (!string.IsNullOrWhiteSpace(normalizedConta) && EmpresaByConta.TryGetValue(normalizedConta, out var empresaDeterministica))
{
return BuildContextByEmpresa(empresaDeterministica);
}
var token = NormalizeToken(contaRaw);
if (token.Contains("TIM", StringComparison.Ordinal))
{
return BuildContextByEmpresa(EmpresaTimLineMovel);
}
if (token.Contains("CLARO", StringComparison.Ordinal))
{
return BuildContextByEmpresa(EmpresaClaroLineMovel);
}
if (token.Contains("MACROPHONY", StringComparison.Ordinal))
{
return BuildContextByEmpresa(EmpresaVivoMacrophony);
}
if (token.Contains("VIVO", StringComparison.Ordinal))
{
return BuildContextByEmpresa(EmpresaVivoLineMovel);
}
return new OperadoraContaContext(OperadoraOutra, string.Empty, null);
}
public static IQueryable<MobileLine> ApplyOperadoraFilter(IQueryable<MobileLine> query, string? operadora)
{
var token = NormalizeToken(operadora);
if (string.IsNullOrWhiteSpace(token) || token == "TODOS" || token == "ALL")
{
return query;
}
return token switch
{
OperadoraVivo => query.Where(x =>
(x.Conta != null && VivoContaFilters.Contains(x.Conta.Trim()))
|| EF.Functions.ILike((x.Conta ?? string.Empty).Trim(), "%VIVO%")
|| EF.Functions.ILike((x.Conta ?? string.Empty).Trim(), "%MACROPHONY%")),
OperadoraClaro => query.Where(x =>
(x.Conta != null && ClaroContaFilters.Contains(x.Conta.Trim()))
|| EF.Functions.ILike((x.Conta ?? string.Empty).Trim(), "%CLARO%")),
OperadoraTim => query.Where(x =>
(x.Conta != null && TimContaFilters.Contains(x.Conta.Trim()))
|| EF.Functions.ILike((x.Conta ?? string.Empty).Trim(), "%TIM%")),
_ => query
};
}
public static string NormalizeConta(string? conta)
{
var raw = (conta ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(raw)) return string.Empty;
if (raw.All(char.IsDigit))
{
var noLeadingZero = raw.TrimStart('0');
return string.IsNullOrWhiteSpace(noLeadingZero) ? "0" : noLeadingZero;
}
return RemoveDiacritics(raw).ToUpperInvariant();
}
private static OperadoraContaContext BuildContextByEmpresa(string empresa)
{
var token = NormalizeToken(empresa);
var operadora = token.Contains("CLARO", StringComparison.Ordinal)
? OperadoraClaro
: token.Contains("TIM", StringComparison.Ordinal)
? OperadoraTim
: token.Contains("VIVO", StringComparison.Ordinal) || token.Contains("MACROPHONY", StringComparison.Ordinal)
? OperadoraVivo
: OperadoraOutra;
var vivoGrupo = operadora == OperadoraVivo
? token.Contains("MACROPHONY", StringComparison.Ordinal)
? "MACROPHONY"
: token.Contains("LINEMOVEL", StringComparison.Ordinal)
? "LINE MOVEL"
: null
: null;
return new OperadoraContaContext(operadora, empresa, vivoGrupo);
}
private static Dictionary<string, string> BuildEmpresaByConta()
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in CompanyRules)
{
foreach (var conta in group.Contas ?? new List<string>())
{
var normalized = NormalizeConta(conta);
if (string.IsNullOrWhiteSpace(normalized)) continue;
map[normalized] = group.Empresa;
}
}
return map;
}
private static string[] BuildContaFilterSet(params string[] empresas)
{
var empresaTokens = new HashSet<string>(empresas.Select(NormalizeToken), StringComparer.OrdinalIgnoreCase);
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in CompanyRules)
{
if (!empresaTokens.Contains(NormalizeToken(group.Empresa))) continue;
foreach (var conta in group.Contas ?? new List<string>())
{
var trimmed = (conta ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(trimmed)) continue;
set.Add(trimmed);
if (trimmed.All(char.IsDigit))
{
var noLeading = trimmed.TrimStart('0');
if (!string.IsNullOrWhiteSpace(noLeading))
{
set.Add(noLeading);
}
}
}
}
return set.ToArray();
}
private static string NormalizeToken(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var raw = RemoveDiacritics(value);
var sb = new StringBuilder(raw.Length);
foreach (var ch in raw)
{
if (char.IsLetterOrDigit(ch)) sb.Append(char.ToUpperInvariant(ch));
}
return sb.ToString();
}
private static string RemoveDiacritics(string value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var normalized = value.Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(normalized.Length);
foreach (var c in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(c);
if (category != UnicodeCategory.NonSpacingMark)
{
sb.Append(c);
}
}
return sb.ToString().Normalize(NormalizationForm.FormC);
}
}
}

View File

@ -27,6 +27,8 @@ 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));
try
{
await RunOnceAsync(stoppingToken); await RunOnceAsync(stoppingToken);
while (await timer.WaitForNextTickAsync(stoppingToken)) while (await timer.WaitForNextTickAsync(stoppingToken))
@ -34,6 +36,11 @@ public class VigenciaNotificationBackgroundService : BackgroundService
await RunOnceAsync(stoppingToken); await RunOnceAsync(stoppingToken);
} }
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Host finalizando; evita ruído de log durante shutdown/startup interrompido.
}
}
private async Task RunOnceAsync(CancellationToken stoppingToken) private async Task RunOnceAsync(CancellationToken stoppingToken)
{ {
@ -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.");
} }