Feat: adicionando auditoria completa MVE

This commit is contained in:
Leon 2026-03-10 17:06:06 -03:00
parent d37ea0c377
commit 6af6674468
11 changed files with 1246 additions and 213 deletions

View File

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Globalization; using System.Globalization;
using System.Text;
namespace line_gestao_api.Controllers; namespace line_gestao_api.Controllers;
@ -24,6 +25,14 @@ public class HistoricoController : ControllerBase
nameof(ParcelamentoLine) nameof(ParcelamentoLine)
}; };
private static readonly HashSet<string> ChipRelatedEntities = new(StringComparer.OrdinalIgnoreCase)
{
nameof(MobileLine),
nameof(TrocaNumeroLine),
nameof(ChipVirgemLine),
nameof(ControleRecebidoLine)
};
private readonly AppDbContext _db; private readonly AppDbContext _db;
public HistoricoController(AppDbContext db) public HistoricoController(AppDbContext db)
@ -152,10 +161,7 @@ public class HistoricoController : ControllerBase
var lineTerm = (line ?? string.Empty).Trim(); var lineTerm = (line ?? string.Empty).Trim();
var normalizedLineDigits = DigitsOnly(lineTerm); var normalizedLineDigits = DigitsOnly(lineTerm);
if (string.IsNullOrWhiteSpace(lineTerm) && string.IsNullOrWhiteSpace(normalizedLineDigits)) var hasLineFilter = !string.IsNullOrWhiteSpace(lineTerm) || !string.IsNullOrWhiteSpace(normalizedLineDigits);
{
return BadRequest(new { message = "Informe uma linha para consultar o histórico." });
}
var q = _db.AuditLogs var q = _db.AuditLogs
.AsNoTracking() .AsNoTracking()
@ -213,7 +219,116 @@ public class HistoricoController : ControllerBase
foreach (var log in candidateLogs) foreach (var log in candidateLogs)
{ {
var changes = ParseChanges(log.ChangesJson); var changes = ParseChanges(log.ChangesJson);
if (!MatchesLine(log, changes, lineTerm, normalizedLineDigits)) if (hasLineFilter && !MatchesLine(log, changes, lineTerm, normalizedLineDigits))
{
continue;
}
if (!string.IsNullOrWhiteSpace(searchTerm) && !MatchesSearch(log, changes, searchTerm, searchDigits))
{
continue;
}
matchedLogs.Add((log, changes));
}
var total = matchedLogs.Count;
var items = matchedLogs
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => ToDto(x.Log, x.Changes))
.ToList();
return Ok(new PagedResult<AuditLogDto>
{
Page = page,
PageSize = pageSize,
Total = total,
Items = items
});
}
[HttpGet("chips")]
public async Task<ActionResult<PagedResult<AuditLogDto>>> GetChipHistory(
[FromQuery] string? chip,
[FromQuery] string? pageName,
[FromQuery] string? action,
[FromQuery] string? user,
[FromQuery] string? search,
[FromQuery] DateTime? dateFrom,
[FromQuery] DateTime? dateTo,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
page = page < 1 ? 1 : page;
pageSize = pageSize < 1 ? 20 : pageSize;
var chipTerm = (chip ?? string.Empty).Trim();
var normalizedChipDigits = DigitsOnly(chipTerm);
var hasChipFilter = !string.IsNullOrWhiteSpace(chipTerm) || !string.IsNullOrWhiteSpace(normalizedChipDigits);
var q = _db.AuditLogs
.AsNoTracking()
.Where(x => ChipRelatedEntities.Contains(x.EntityName))
.Where(x =>
!EF.Functions.ILike(x.RequestPath ?? "", "%import-excel%") ||
x.Page == AuditLogBuilder.SpreadsheetImportPageName);
if (!string.IsNullOrWhiteSpace(pageName))
{
var p = pageName.Trim();
q = q.Where(x => EF.Functions.ILike(x.Page, $"%{p}%"));
}
if (!string.IsNullOrWhiteSpace(action))
{
var a = action.Trim().ToUpperInvariant();
q = q.Where(x => x.Action == a);
}
if (!string.IsNullOrWhiteSpace(user))
{
var u = user.Trim();
q = q.Where(x =>
EF.Functions.ILike(x.UserName ?? "", $"%{u}%") ||
EF.Functions.ILike(x.UserEmail ?? "", $"%{u}%"));
}
if (dateFrom.HasValue)
{
var fromUtc = ToUtc(dateFrom.Value);
q = q.Where(x => x.OccurredAtUtc >= fromUtc);
}
if (dateTo.HasValue)
{
var toUtc = ToUtc(dateTo.Value);
if (dateTo.Value.TimeOfDay == TimeSpan.Zero)
{
toUtc = toUtc.Date.AddDays(1).AddTicks(-1);
}
q = q.Where(x => x.OccurredAtUtc <= toUtc);
}
var candidateLogs = await q
.OrderByDescending(x => x.OccurredAtUtc)
.ThenByDescending(x => x.Id)
.ToListAsync();
var searchTerm = (search ?? string.Empty).Trim();
var searchDigits = DigitsOnly(searchTerm);
var matchedLogs = new List<(AuditLog Log, List<AuditFieldChangeDto> Changes)>();
foreach (var log in candidateLogs)
{
var changes = ParseChanges(log.ChangesJson);
if (!HasChipContext(log, changes))
{
continue;
}
if (hasChipFilter && !MatchesChip(log, changes, chipTerm, normalizedChipDigits))
{ {
continue; continue;
} }
@ -348,6 +463,51 @@ public class HistoricoController : ControllerBase
return false; return false;
} }
private static bool MatchesChip(
AuditLog log,
List<AuditFieldChangeDto> changes,
string chipTerm,
string normalizedChipDigits)
{
if (MatchesTerm(log.EntityLabel, chipTerm, normalizedChipDigits) ||
MatchesTerm(log.EntityId, chipTerm, normalizedChipDigits))
{
return true;
}
foreach (var change in changes)
{
if (MatchesTerm(change.Field, chipTerm, normalizedChipDigits) ||
MatchesTerm(change.OldValue, chipTerm, normalizedChipDigits) ||
MatchesTerm(change.NewValue, chipTerm, normalizedChipDigits))
{
return true;
}
}
return false;
}
private static bool HasChipContext(AuditLog log, List<AuditFieldChangeDto> changes)
{
if ((log.EntityName ?? string.Empty).Equals(nameof(ChipVirgemLine), StringComparison.OrdinalIgnoreCase) ||
(log.Page ?? string.Empty).Contains("chip", StringComparison.OrdinalIgnoreCase))
{
return true;
}
foreach (var change in changes)
{
var normalizedField = NormalizeField(change.Field);
if (normalizedField is "chip" or "iccid" or "numerodochip")
{
return true;
}
}
return false;
}
private static bool MatchesTerm(string? source, string term, string digitsTerm) private static bool MatchesTerm(string? source, string term, string digitsTerm)
{ {
if (string.IsNullOrWhiteSpace(source)) if (string.IsNullOrWhiteSpace(source))
@ -375,6 +535,21 @@ public class HistoricoController : ControllerBase
return sourceDigits.Contains(digitsTerm, StringComparison.Ordinal); return sourceDigits.Contains(digitsTerm, StringComparison.Ordinal);
} }
private static string NormalizeField(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return new string(value
.Normalize(NormalizationForm.FormD)
.Where(ch => CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)
.Where(char.IsLetterOrDigit)
.Select(char.ToLowerInvariant)
.ToArray());
}
private static string DigitsOnly(string? value) private static string DigitsOnly(string? value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))

View File

@ -31,6 +31,7 @@ namespace line_gestao_api.Controllers
private readonly IVigenciaNotificationSyncService _vigenciaNotificationSyncService; private readonly IVigenciaNotificationSyncService _vigenciaNotificationSyncService;
private readonly ParcelamentosImportService _parcelamentosImportService; private readonly ParcelamentosImportService _parcelamentosImportService;
private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService; private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService;
private readonly DashboardKpiSnapshotService _dashboardKpiSnapshotService;
private readonly string _aparelhoAttachmentsRootPath; private readonly string _aparelhoAttachmentsRootPath;
private static readonly FileExtensionContentTypeProvider FileContentTypeProvider = new(); private static readonly FileExtensionContentTypeProvider FileContentTypeProvider = new();
@ -71,6 +72,7 @@ namespace line_gestao_api.Controllers
IVigenciaNotificationSyncService vigenciaNotificationSyncService, IVigenciaNotificationSyncService vigenciaNotificationSyncService,
ParcelamentosImportService parcelamentosImportService, ParcelamentosImportService parcelamentosImportService,
SpreadsheetImportAuditService spreadsheetImportAuditService, SpreadsheetImportAuditService spreadsheetImportAuditService,
DashboardKpiSnapshotService dashboardKpiSnapshotService,
IWebHostEnvironment webHostEnvironment) IWebHostEnvironment webHostEnvironment)
{ {
_db = db; _db = db;
@ -78,6 +80,7 @@ namespace line_gestao_api.Controllers
_vigenciaNotificationSyncService = vigenciaNotificationSyncService; _vigenciaNotificationSyncService = vigenciaNotificationSyncService;
_parcelamentosImportService = parcelamentosImportService; _parcelamentosImportService = parcelamentosImportService;
_spreadsheetImportAuditService = spreadsheetImportAuditService; _spreadsheetImportAuditService = spreadsheetImportAuditService;
_dashboardKpiSnapshotService = dashboardKpiSnapshotService;
_aparelhoAttachmentsRootPath = Path.Combine(webHostEnvironment.ContentRootPath, "uploads", "aparelhos"); _aparelhoAttachmentsRootPath = Path.Combine(webHostEnvironment.ContentRootPath, "uploads", "aparelhos");
} }
@ -98,6 +101,7 @@ namespace line_gestao_api.Controllers
[HttpGet("groups")] [HttpGet("groups")]
public async Task<ActionResult<PagedResult<ClientGroupDto>>> GetClientGroups( public async Task<ActionResult<PagedResult<ClientGroupDto>>> GetClientGroups(
[FromQuery] string? skil, [FromQuery] string? skil,
[FromQuery] string? reservaMode,
[FromQuery] string? search, [FromQuery] string? search,
[FromQuery] string? additionalMode, [FromQuery] string? additionalMode,
[FromQuery] string? additionalServices, [FromQuery] string? additionalServices,
@ -117,10 +121,7 @@ namespace line_gestao_api.Controllers
if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase))
{ {
reservaFilter = true; reservaFilter = true;
query = query.Where(x => query = ApplyReservaContextFilter(query);
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
} }
else else
query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%"));
@ -135,37 +136,7 @@ namespace line_gestao_api.Controllers
if (reservaFilter) if (reservaFilter)
{ {
var userDataClientByLine = BuildUserDataClientByLineQuery(); var reservaRows = ApplyReservaMode(BuildReservaLineProjection(query), reservaMode);
var userDataClientByItem = BuildUserDataClientByItemQuery();
var reservaRows =
from line in query
join udLine in userDataClientByLine on line.Linha equals udLine.Linha into udLineJoin
from udLine in udLineJoin.DefaultIfEmpty()
join udItem in userDataClientByItem on line.Item equals udItem.Item into udItemJoin
from udItem in udItemJoin.DefaultIfEmpty()
let clienteOriginal = (line.Cliente ?? "").Trim()
let skilOriginal = (line.Skil ?? "").Trim()
let clientePorLinha = (udLine.Cliente ?? "").Trim()
let clientePorItem = (udItem.Cliente ?? "").Trim()
let reservaEstrita = EF.Functions.ILike(clienteOriginal, "RESERVA") &&
EF.Functions.ILike(skilOriginal, "RESERVA")
let clienteEfetivo = reservaEstrita
? "RESERVA"
: (!string.IsNullOrEmpty(clienteOriginal) &&
!EF.Functions.ILike(clienteOriginal, "RESERVA"))
? clienteOriginal
: (!string.IsNullOrEmpty(clientePorLinha) &&
!EF.Functions.ILike(clientePorLinha, "RESERVA"))
? clientePorLinha
: (!string.IsNullOrEmpty(clientePorItem) &&
!EF.Functions.ILike(clientePorItem, "RESERVA"))
? clientePorItem
: ""
select new
{
Cliente = string.IsNullOrEmpty(clienteEfetivo) ? "RESERVA" : clienteEfetivo,
line.Status
};
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
{ {
@ -211,7 +182,8 @@ namespace line_gestao_api.Controllers
var orderedGroupedQuery = reservaFilter var orderedGroupedQuery = reservaFilter
? groupedQuery ? groupedQuery
.OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) .OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "ESTOQUE"))
.ThenByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"))
.ThenBy(x => x.Cliente) .ThenBy(x => x.Cliente)
: groupedQuery.OrderBy(x => x.Cliente); : groupedQuery.OrderBy(x => x.Cliente);
@ -235,6 +207,7 @@ namespace line_gestao_api.Controllers
[HttpGet("clients")] [HttpGet("clients")]
public async Task<ActionResult<List<string>>> GetClients( public async Task<ActionResult<List<string>>> GetClients(
[FromQuery] string? skil, [FromQuery] string? skil,
[FromQuery] string? reservaMode,
[FromQuery] string? additionalMode, [FromQuery] string? additionalMode,
[FromQuery] string? additionalServices) [FromQuery] string? additionalServices)
{ {
@ -247,10 +220,7 @@ namespace line_gestao_api.Controllers
if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase))
{ {
reservaFilter = true; reservaFilter = true;
query = query.Where(x => query = ApplyReservaContextFilter(query);
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
} }
else else
query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%"));
@ -264,37 +234,11 @@ namespace line_gestao_api.Controllers
List<string> clients; List<string> clients;
if (reservaFilter) if (reservaFilter)
{ {
var userDataClientByLine = BuildUserDataClientByLineQuery(); clients = await ApplyReservaMode(BuildReservaLineProjection(query), reservaMode)
var userDataClientByItem = BuildUserDataClientByItemQuery(); .Select(x => x.Cliente)
clients = await (
from line in query
join udLine in userDataClientByLine on line.Linha equals udLine.Linha into udLineJoin
from udLine in udLineJoin.DefaultIfEmpty()
join udItem in userDataClientByItem on line.Item equals udItem.Item into udItemJoin
from udItem in udItemJoin.DefaultIfEmpty()
let clienteOriginal = (line.Cliente ?? "").Trim()
let skilOriginal = (line.Skil ?? "").Trim()
let clientePorLinha = (udLine.Cliente ?? "").Trim()
let clientePorItem = (udItem.Cliente ?? "").Trim()
let reservaEstrita = EF.Functions.ILike(clienteOriginal, "RESERVA") &&
EF.Functions.ILike(skilOriginal, "RESERVA")
let clienteEfetivo = reservaEstrita
? "RESERVA"
: (!string.IsNullOrEmpty(clienteOriginal) &&
!EF.Functions.ILike(clienteOriginal, "RESERVA"))
? clienteOriginal
: (!string.IsNullOrEmpty(clientePorLinha) &&
!EF.Functions.ILike(clientePorLinha, "RESERVA"))
? clientePorLinha
: (!string.IsNullOrEmpty(clientePorItem) &&
!EF.Functions.ILike(clientePorItem, "RESERVA"))
? clientePorItem
: ""
let clienteExibicao = string.IsNullOrEmpty(clienteEfetivo) ? "RESERVA" : clienteEfetivo
select clienteExibicao
)
.Distinct() .Distinct()
.OrderByDescending(x => EF.Functions.ILike((x ?? "").Trim(), "RESERVA")) .OrderByDescending(x => EF.Functions.ILike((x ?? "").Trim(), "ESTOQUE"))
.ThenByDescending(x => EF.Functions.ILike((x ?? "").Trim(), "RESERVA"))
.ThenBy(x => x) .ThenBy(x => x)
.ToListAsync(); .ToListAsync();
} }
@ -449,6 +393,7 @@ namespace line_gestao_api.Controllers
public async Task<ActionResult<PagedResult<MobileLineListDto>>> GetAll( public async Task<ActionResult<PagedResult<MobileLineListDto>>> GetAll(
[FromQuery] string? search, [FromQuery] string? search,
[FromQuery] string? skil, [FromQuery] string? skil,
[FromQuery] string? reservaMode,
[FromQuery] string? client, [FromQuery] string? client,
[FromQuery] string? operadora, [FromQuery] string? operadora,
[FromQuery] string? additionalMode, [FromQuery] string? additionalMode,
@ -470,10 +415,7 @@ namespace line_gestao_api.Controllers
if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase)) if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase))
{ {
reservaFilter = true; reservaFilter = true;
q = q.Where(x => q = ApplyReservaContextFilter(q);
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
} }
else else
q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%"));
@ -493,61 +435,7 @@ namespace line_gestao_api.Controllers
if (reservaFilter) if (reservaFilter)
{ {
var userDataClientByLine = BuildUserDataClientByLineQuery(); var rq = ApplyReservaMode(BuildReservaLineProjection(q), reservaMode);
var userDataClientByItem = BuildUserDataClientByItemQuery();
var rq =
from line in q
join udLine in userDataClientByLine on line.Linha equals udLine.Linha into udLineJoin
from udLine in udLineJoin.DefaultIfEmpty()
join udItem in userDataClientByItem on line.Item equals udItem.Item into udItemJoin
from udItem in udItemJoin.DefaultIfEmpty()
let clienteOriginal = (line.Cliente ?? "").Trim()
let skilOriginal = (line.Skil ?? "").Trim()
let clientePorLinha = (udLine.Cliente ?? "").Trim()
let clientePorItem = (udItem.Cliente ?? "").Trim()
let reservaEstrita = EF.Functions.ILike(clienteOriginal, "RESERVA") &&
EF.Functions.ILike(skilOriginal, "RESERVA")
let clienteEfetivo = reservaEstrita
? "RESERVA"
: (!string.IsNullOrEmpty(clienteOriginal) &&
!EF.Functions.ILike(clienteOriginal, "RESERVA"))
? clienteOriginal
: (!string.IsNullOrEmpty(clientePorLinha) &&
!EF.Functions.ILike(clientePorLinha, "RESERVA"))
? clientePorLinha
: (!string.IsNullOrEmpty(clientePorItem) &&
!EF.Functions.ILike(clientePorItem, "RESERVA"))
? clientePorItem
: ""
let clienteExibicao = string.IsNullOrEmpty(clienteEfetivo) ? "RESERVA" : clienteEfetivo
select new
{
line.Id,
line.Item,
line.Conta,
line.Linha,
line.Chip,
Cliente = clienteExibicao,
line.Usuario,
line.CentroDeCustos,
SetorNome = line.Setor != null ? line.Setor!.Nome : null,
AparelhoNome = line.Aparelho != null ? line.Aparelho!.Nome : null,
AparelhoCor = line.Aparelho != null ? line.Aparelho!.Cor : null,
line.PlanoContrato,
line.Status,
line.Skil,
line.Modalidade,
line.VencConta,
line.FranquiaVivo,
line.FranquiaLine,
line.GestaoVozDados,
line.Skeelo,
line.VivoNewsPlus,
line.VivoTravelMundo,
line.VivoSync,
line.VivoGestaoDispositivo,
line.TipoDeChip
};
if (!string.IsNullOrWhiteSpace(client)) if (!string.IsNullOrWhiteSpace(client))
rq = rq.Where(x => EF.Functions.ILike(x.Cliente ?? "", client.Trim())); rq = rq.Where(x => EF.Functions.ILike(x.Cliente ?? "", client.Trim()));
@ -572,10 +460,12 @@ namespace line_gestao_api.Controllers
"linha" => desc ? rq.OrderByDescending(x => x.Linha ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Linha ?? "").ThenBy(x => x.Item), "linha" => desc ? rq.OrderByDescending(x => x.Linha ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Linha ?? "").ThenBy(x => x.Item),
"chip" => desc ? rq.OrderByDescending(x => x.Chip ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Chip ?? "").ThenBy(x => x.Item), "chip" => desc ? rq.OrderByDescending(x => x.Chip ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Chip ?? "").ThenBy(x => x.Item),
"cliente" => desc "cliente" => desc
? rq.OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) ? rq.OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "ESTOQUE"))
.ThenByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"))
.ThenByDescending(x => x.Cliente ?? "") .ThenByDescending(x => x.Cliente ?? "")
.ThenBy(x => x.Item) .ThenBy(x => x.Item)
: rq.OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")) : rq.OrderByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "ESTOQUE"))
.ThenByDescending(x => EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"))
.ThenBy(x => x.Cliente ?? "") .ThenBy(x => x.Cliente ?? "")
.ThenBy(x => x.Item), .ThenBy(x => x.Item),
"usuario" => desc ? rq.OrderByDescending(x => x.Usuario ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Usuario ?? "").ThenBy(x => x.Item), "usuario" => desc ? rq.OrderByDescending(x => x.Usuario ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Usuario ?? "").ThenBy(x => x.Item),
@ -1984,6 +1874,7 @@ namespace line_gestao_api.Controllers
} }
await AddSpreadsheetImportHistoryAsync(file.FileName, imported, parcelamentosSummary); await AddSpreadsheetImportHistoryAsync(file.FileName, imported, parcelamentosSummary);
await _dashboardKpiSnapshotService.CaptureAfterSpreadsheetImportAsync(User, HttpContext);
await tx.CommitAsync(); await tx.CommitAsync();
return Ok(new ImportResultDto { Imported = imported, Parcelamentos = parcelamentosSummary }); return Ok(new ImportResultDto { Imported = imported, Parcelamentos = parcelamentosSummary });
} }
@ -5443,6 +5334,36 @@ namespace line_gestao_api.Controllers
public string Cliente { get; set; } = ""; public string Cliente { get; set; } = "";
} }
private sealed class ReservaLineProjection
{
public Guid Id { get; set; }
public int Item { get; set; }
public string? Conta { get; set; }
public string? Linha { get; set; }
public string? Chip { get; set; }
public string Cliente { get; set; } = "";
public string? Usuario { get; set; }
public string? CentroDeCustos { get; set; }
public string? SetorNome { get; set; }
public string? AparelhoNome { get; set; }
public string? AparelhoCor { get; set; }
public string? PlanoContrato { get; set; }
public string? Status { get; set; }
public string? Skil { get; set; }
public string? Modalidade { get; set; }
public string? VencConta { get; set; }
public decimal? FranquiaVivo { get; set; }
public decimal? FranquiaLine { get; set; }
public decimal? GestaoVozDados { get; set; }
public decimal? Skeelo { get; set; }
public decimal? VivoNewsPlus { get; set; }
public decimal? VivoTravelMundo { get; set; }
public decimal? VivoSync { get; set; }
public decimal? VivoGestaoDispositivo { get; set; }
public string? TipoDeChip { get; set; }
public bool IsStock { get; set; }
}
private IQueryable<UserDataClientByLine> BuildUserDataClientByLineQuery() private IQueryable<UserDataClientByLine> BuildUserDataClientByLineQuery()
{ {
return _db.UserDatas return _db.UserDatas
@ -5473,6 +5394,98 @@ namespace line_gestao_api.Controllers
}); });
} }
private IQueryable<ReservaLineProjection> BuildReservaLineProjection(IQueryable<MobileLine> query)
{
var userDataClientByLine = BuildUserDataClientByLineQuery();
var userDataClientByItem = BuildUserDataClientByItemQuery();
return
from line in query
join udLine in userDataClientByLine on line.Linha equals udLine.Linha into udLineJoin
from udLine in udLineJoin.DefaultIfEmpty()
join udItem in userDataClientByItem on line.Item equals udItem.Item into udItemJoin
from udItem in udItemJoin.DefaultIfEmpty()
let clienteOriginal = (line.Cliente ?? "").Trim()
let clientePorLinha = (udLine.Cliente ?? "").Trim()
let clientePorItem = (udItem.Cliente ?? "").Trim()
let isStock = EF.Functions.ILike(clienteOriginal, "RESERVA")
let clienteEfetivo = isStock
? "ESTOQUE"
: (!string.IsNullOrEmpty(clienteOriginal) &&
!EF.Functions.ILike(clienteOriginal, "RESERVA"))
? clienteOriginal
: (!string.IsNullOrEmpty(clientePorLinha) &&
!EF.Functions.ILike(clientePorLinha, "RESERVA"))
? clientePorLinha
: (!string.IsNullOrEmpty(clientePorItem) &&
!EF.Functions.ILike(clientePorItem, "RESERVA"))
? clientePorItem
: ""
select new ReservaLineProjection
{
Id = line.Id,
Item = line.Item,
Conta = line.Conta,
Linha = line.Linha,
Chip = line.Chip,
Cliente = string.IsNullOrEmpty(clienteEfetivo) ? (isStock ? "ESTOQUE" : "RESERVA") : clienteEfetivo,
Usuario = line.Usuario,
CentroDeCustos = line.CentroDeCustos,
SetorNome = line.Setor != null ? line.Setor!.Nome : null,
AparelhoNome = line.Aparelho != null ? line.Aparelho!.Nome : null,
AparelhoCor = line.Aparelho != null ? line.Aparelho!.Cor : null,
PlanoContrato = line.PlanoContrato,
Status = line.Status,
Skil = line.Skil,
Modalidade = line.Modalidade,
VencConta = line.VencConta,
FranquiaVivo = line.FranquiaVivo,
FranquiaLine = line.FranquiaLine,
GestaoVozDados = line.GestaoVozDados,
Skeelo = line.Skeelo,
VivoNewsPlus = line.VivoNewsPlus,
VivoTravelMundo = line.VivoTravelMundo,
VivoSync = line.VivoSync,
VivoGestaoDispositivo = line.VivoGestaoDispositivo,
TipoDeChip = line.TipoDeChip,
IsStock = isStock
};
}
private static IQueryable<MobileLine> ApplyReservaContextFilter(IQueryable<MobileLine> query)
{
return query.Where(x =>
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
}
private static IQueryable<ReservaLineProjection> ApplyReservaMode(
IQueryable<ReservaLineProjection> query,
string? reservaMode)
{
var mode = NormalizeReservaMode(reservaMode);
return mode switch
{
"stock" => query.Where(x => x.IsStock),
"assigned" => query.Where(x =>
!x.IsStock &&
!EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")),
_ => query
};
}
private static string NormalizeReservaMode(string? reservaMode)
{
var token = (reservaMode ?? "").Trim().ToLowerInvariant();
return token switch
{
"stock" or "estoque" => "stock",
"assigned" or "reservas" or "reserva" => "assigned",
_ => "all"
};
}
private static IQueryable<MobileLine> ExcludeReservaContext(IQueryable<MobileLine> query) private static IQueryable<MobileLine> ExcludeReservaContext(IQueryable<MobileLine> query)
{ {
return query.Where(x => return query.Where(x =>

View File

@ -13,10 +13,14 @@ namespace line_gestao_api.Controllers
public class RelatoriosController : ControllerBase public class RelatoriosController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly DashboardKpiSnapshotService _dashboardKpiSnapshotService;
public RelatoriosController(AppDbContext db) public RelatoriosController(
AppDbContext db,
DashboardKpiSnapshotService dashboardKpiSnapshotService)
{ {
_db = db; _db = db;
_dashboardKpiSnapshotService = dashboardKpiSnapshotService;
} }
[HttpGet("dashboard")] [HttpGet("dashboard")]
@ -226,6 +230,7 @@ namespace line_gestao_api.Controllers
UserDataComCpf = userDataComCpf, UserDataComCpf = userDataComCpf,
UserDataComEmail = userDataComEmail UserDataComEmail = userDataComEmail
}, },
KpiTrends = await _dashboardKpiSnapshotService.GetTrendMapAsync(operadora),
TopClientes = topClientes, TopClientes = topClientes,
SerieMuregUltimos12Meses = serieMureg12, SerieMuregUltimos12Meses = serieMureg12,
SerieTrocaUltimos12Meses = serieTroca12, SerieTrocaUltimos12Meses = serieTroca12,

View File

@ -6,6 +6,7 @@ namespace line_gestao_api.Dtos
public class RelatoriosDashboardDto public class RelatoriosDashboardDto
{ {
public DashboardKpisDto Kpis { get; set; } = new(); public DashboardKpisDto Kpis { get; set; } = new();
public Dictionary<string, string> KpiTrends { get; set; } = new();
public List<TopClienteDto> TopClientes { get; set; } = new(); public List<TopClienteDto> TopClientes { get; set; } = new();
public List<SerieMesDto> SerieMuregUltimos12Meses { get; set; } = new(); public List<SerieMesDto> SerieMuregUltimos12Meses { get; set; } = new();
public List<SerieMesDto> SerieTrocaUltimos12Meses { get; set; } = new(); public List<SerieMesDto> SerieTrocaUltimos12Meses { get; set; } = new();

View File

@ -97,6 +97,7 @@ builder.Services.AddScoped<ISystemAuditService, SystemAuditService>();
builder.Services.AddScoped<IVigenciaNotificationSyncService, VigenciaNotificationSyncService>(); builder.Services.AddScoped<IVigenciaNotificationSyncService, VigenciaNotificationSyncService>();
builder.Services.AddScoped<ParcelamentosImportService>(); builder.Services.AddScoped<ParcelamentosImportService>();
builder.Services.AddScoped<GeralDashboardInsightsService>(); builder.Services.AddScoped<GeralDashboardInsightsService>();
builder.Services.AddScoped<DashboardKpiSnapshotService>();
builder.Services.AddScoped<GeralSpreadsheetTemplateService>(); builder.Services.AddScoped<GeralSpreadsheetTemplateService>();
builder.Services.AddScoped<SpreadsheetImportAuditService>(); builder.Services.AddScoped<SpreadsheetImportAuditService>();
builder.Services.AddScoped<MveCsvParserService>(); builder.Services.AddScoped<MveCsvParserService>();

View File

@ -0,0 +1,380 @@
using System.Globalization;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using line_gestao_api.Data;
using line_gestao_api.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
namespace line_gestao_api.Services;
public sealed class DashboardKpiSnapshotService
{
private const string SnapshotEntityName = "DashboardKpiSnapshot";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly AppDbContext _db;
private readonly ITenantProvider _tenantProvider;
public DashboardKpiSnapshotService(AppDbContext db, ITenantProvider tenantProvider)
{
_db = db;
_tenantProvider = tenantProvider;
}
public async Task CaptureAfterSpreadsheetImportAsync(
ClaimsPrincipal user,
HttpContext httpContext,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantProvider.TenantId;
if (!tenantId.HasValue)
{
return;
}
var userId = TryParseUserId(user);
var userName = user.FindFirst("name")?.Value
?? user.FindFirst(ClaimTypes.Name)?.Value
?? user.Identity?.Name
?? "USUARIO";
var userEmail = user.FindFirst(ClaimTypes.Email)?.Value
?? user.FindFirst("email")?.Value;
foreach (var scope in EnumerateScopes())
{
var snapshot = await BuildSnapshotAsync(scope.Operadora, cancellationToken);
_db.AuditLogs.Add(new AuditLog
{
TenantId = tenantId.Value,
ActorUserId = userId,
ActorTenantId = tenantId.Value,
TargetTenantId = tenantId.Value,
OccurredAtUtc = DateTime.UtcNow,
UserId = userId,
UserName = userName,
UserEmail = userEmail,
Action = "SNAPSHOT",
Page = AuditLogBuilder.SpreadsheetImportPageName,
EntityName = SnapshotEntityName,
EntityId = snapshot.Scope,
EntityLabel = $"Snapshot Dashboard {snapshot.Scope}",
ChangesJson = "[]",
MetadataJson = JsonSerializer.Serialize(snapshot, JsonOptions),
RequestPath = httpContext.Request.Path.Value,
RequestMethod = httpContext.Request.Method,
IpAddress = httpContext.Connection.RemoteIpAddress?.ToString()
});
}
await _db.SaveChangesAsync(cancellationToken);
}
public async Task<Dictionary<string, string>> GetTrendMapAsync(
string? operadora = null,
CancellationToken cancellationToken = default)
{
var scope = NormalizeScope(operadora);
var logs = await _db.AuditLogs
.AsNoTracking()
.Where(x =>
x.Page == AuditLogBuilder.SpreadsheetImportPageName &&
x.EntityName == SnapshotEntityName &&
x.EntityId == scope)
.OrderByDescending(x => x.OccurredAtUtc)
.Take(8)
.Select(x => new { x.OccurredAtUtc, x.MetadataJson })
.ToListAsync(cancellationToken);
if (logs.Count < 2)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var latest = DeserializeSnapshot(logs[0].MetadataJson);
if (latest == null)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var baselineThreshold = logs[0].OccurredAtUtc.AddHours(-24);
var previous = logs
.Skip(1)
.Select(x => DeserializeSnapshot(x.MetadataJson))
.FirstOrDefault(x => x != null && x.CapturedAtUtc >= baselineThreshold);
if (previous == null)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var keys = latest.Metrics.Keys
.Concat(previous.Metrics.Keys)
.Distinct(StringComparer.OrdinalIgnoreCase);
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in keys)
{
latest.Metrics.TryGetValue(key, out var currentValue);
previous.Metrics.TryGetValue(key, out var previousValue);
result[key] = currentValue > previousValue
? "up"
: currentValue < previousValue
? "down"
: "stable";
}
return result;
}
private async Task<DashboardKpiSnapshotPayload> BuildSnapshotAsync(
string? operadora,
CancellationToken cancellationToken)
{
var todayUtcStart = DateTime.SpecifyKind(DateTime.UtcNow.Date, DateTimeKind.Utc);
var tomorrowUtcStart = todayUtcStart.AddDays(1);
var last30UtcStart = todayUtcStart.AddDays(-30);
var limit30ExclusiveUtcStart = todayUtcStart.AddDays(31);
var qLines = OperadoraContaResolver.ApplyOperadoraFilter(_db.MobileLines.AsNoTracking(), operadora);
var qReserva = ApplyReservaContextFilter(qLines);
var qOperacionais = ExcludeReservaContext(qLines);
var qOperacionaisWithClient = qOperacionais.Where(x => x.Cliente != null && x.Cliente != "");
var qNonReserva = ExcludeReservaContext(qLines);
var totalLinhas = await qLines.CountAsync(cancellationToken);
var linhasAtivas = await qOperacionais.CountAsync(x =>
EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%"), cancellationToken);
var linhasBloqueadas = await qOperacionaisWithClient.CountAsync(x =>
EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") ||
EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") ||
EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%"), cancellationToken);
var linhasReserva = await qReserva.CountAsync(cancellationToken);
var franquiaVivoTotal = await qLines.SumAsync(x => x.FranquiaVivo ?? 0m, cancellationToken);
var franquiaLineTotal = await qLines.SumAsync(x => x.FranquiaLine ?? 0m, cancellationToken);
var vigVencidos = await _db.VigenciaLines.AsNoTracking()
.CountAsync(x =>
x.DtTerminoFidelizacao != null &&
x.DtTerminoFidelizacao.Value < todayUtcStart, cancellationToken);
var vigencia30 = await _db.VigenciaLines.AsNoTracking()
.CountAsync(x =>
x.DtTerminoFidelizacao != null &&
x.DtTerminoFidelizacao.Value >= todayUtcStart &&
x.DtTerminoFidelizacao.Value < limit30ExclusiveUtcStart, cancellationToken);
var mureg30 = await _db.MuregLines.AsNoTracking()
.CountAsync(x =>
x.DataDaMureg != null &&
x.DataDaMureg.Value >= last30UtcStart &&
x.DataDaMureg.Value < tomorrowUtcStart, cancellationToken);
var troca30 = await _db.TrocaNumeroLines.AsNoTracking()
.CountAsync(x =>
x.DataTroca != null &&
x.DataTroca.Value >= last30UtcStart &&
x.DataTroca.Value < tomorrowUtcStart, cancellationToken);
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 cadastrosTotal = await qUserData.CountAsync(cancellationToken);
var travelCom = await qLines.CountAsync(x => (x.VivoTravelMundo ?? 0m) > 0m, cancellationToken);
var adicionalPago = await qLines.CountAsync(x =>
(x.GestaoVozDados ?? 0m) > 0m ||
(x.Skeelo ?? 0m) > 0m ||
(x.VivoNewsPlus ?? 0m) > 0m ||
(x.VivoTravelMundo ?? 0m) > 0m ||
(x.VivoSync ?? 0m) > 0m ||
(x.VivoGestaoDispositivo ?? 0m) > 0m, cancellationToken);
var planosContratados = await qNonReserva
.Where(x => x.PlanoContrato != null && x.PlanoContrato != "")
.Select(x => (x.PlanoContrato ?? "").Trim())
.Distinct()
.CountAsync(cancellationToken);
var usuariosRaw = await qNonReserva
.Where(x => x.Usuario != null && x.Usuario != "")
.Select(x => new
{
Usuario = (x.Usuario ?? "").Trim(),
Status = (x.Status ?? "").Trim()
})
.ToListAsync(cancellationToken);
var usuariosComLinha = usuariosRaw
.Where(x => ShouldIncludeUsuario(x.Usuario, x.Status))
.Select(x => x.Usuario)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
return new DashboardKpiSnapshotPayload
{
Scope = NormalizeScope(operadora),
CapturedAtUtc = DateTime.UtcNow,
Metrics = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["linhas_total"] = totalLinhas,
["linhas_ativas"] = linhasAtivas,
["linhas_bloqueadas"] = linhasBloqueadas,
["linhas_reserva"] = linhasReserva,
["franquia_vivo_total"] = franquiaVivoTotal,
["franquia_line_total"] = franquiaLineTotal,
["vig_vencidos"] = vigVencidos,
["vig_30"] = vigencia30,
["mureg_30"] = mureg30,
["troca_30"] = troca30,
["cadastros_total"] = cadastrosTotal,
["travel_com"] = travelCom,
["adicional_pago"] = adicionalPago,
["planos_contratados"] = planosContratados,
["usuarios_com_linha"] = usuariosComLinha
}
};
}
private static Guid? TryParseUserId(ClaimsPrincipal user)
{
var claim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(claim, out var userId) ? userId : null;
}
private static IEnumerable<(string Scope, string? Operadora)> EnumerateScopes()
{
yield return ("TODOS", null);
yield return ("VIVO", "VIVO");
yield return ("CLARO", "CLARO");
yield return ("TIM", "TIM");
}
private static string NormalizeScope(string? operadora)
{
var token = NormalizeToken(operadora);
if (string.IsNullOrWhiteSpace(token))
{
return "TODOS";
}
return token;
}
private static DashboardKpiSnapshotPayload? DeserializeSnapshot(string? metadataJson)
{
if (string.IsNullOrWhiteSpace(metadataJson))
{
return null;
}
try
{
return JsonSerializer.Deserialize<DashboardKpiSnapshotPayload>(metadataJson, JsonOptions);
}
catch
{
return null;
}
}
private static IQueryable<MobileLine> ApplyReservaContextFilter(IQueryable<MobileLine> query)
{
return query.Where(x =>
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
}
private static IQueryable<MobileLine> ExcludeReservaContext(IQueryable<MobileLine> query)
{
return query.Where(x =>
!EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") &&
!EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") &&
!EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
}
private static bool ShouldIncludeUsuario(string usuario, string status)
{
var usuarioKey = NormalizeToken(usuario);
if (string.IsNullOrWhiteSpace(usuarioKey))
{
return false;
}
var invalidUserTokens = new[]
{
"SEMUSUARIO",
"AGUARDANDOUSUARIO",
"AGUARDANDO",
"BLOQUEAR",
"BLOQUEAD",
"BLOQUEADO",
"BLOQUEIO",
"BLOQ120",
"RESERVA",
"NAOATRIBUIDO",
"PENDENTE",
"COBRANCA",
"FATURAMENTO",
"FINANCEIRO",
"BACKOFFICE",
"ADMINISTRATIVO",
};
if (invalidUserTokens.Any(usuarioKey.Contains))
{
return false;
}
var statusKey = NormalizeToken(status);
var blockedStatusTokens = new[] { "BLOQUE", "PERDA", "ROUBO", "SUSPEN", "CANCEL", "AGUARD" };
return !blockedStatusTokens.Any(statusKey.Contains);
}
private static string NormalizeToken(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var ch in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(ch);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
if (char.IsLetterOrDigit(ch))
{
builder.Append(char.ToUpperInvariant(ch));
}
}
return builder.ToString();
}
private sealed class DashboardKpiSnapshotPayload
{
public string Scope { get; set; } = "TODOS";
public DateTime CapturedAtUtc { get; set; }
public Dictionary<string, decimal> Metrics { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
}

View File

@ -79,6 +79,34 @@ internal static class MveAuditNormalization
return string.IsNullOrWhiteSpace(digits) ? null : digits; return string.IsNullOrWhiteSpace(digits) ? null : digits;
} }
public static string NormalizePhoneDigits(string? value)
{
var digits = OnlyDigits(value);
if (string.IsNullOrWhiteSpace(digits))
{
return string.Empty;
}
if (digits.StartsWith("55", StringComparison.Ordinal) && digits.Length is 12 or 13)
{
digits = digits[2..];
}
return digits.Length > 11 ? digits[^11..] : digits;
}
public static string ExtractPhoneDdd(string? value)
{
var digits = NormalizePhoneDigits(value);
return digits.Length >= 10 ? digits[..2] : string.Empty;
}
public static string ExtractPhoneLocalNumber(string? value)
{
var digits = NormalizePhoneDigits(value);
return digits.Length >= 10 ? digits[2..] : digits;
}
public static string NormalizeComparableText(string? value) public static string NormalizeComparableText(string? value)
{ {
var cleaned = CleanTextValue(value); var cleaned = CleanTextValue(value);
@ -207,6 +235,7 @@ internal static class MveAuditNormalization
{ {
var text when text.Contains("ATIVO", StringComparison.Ordinal) || text == "ATIVA" => "ATIVO", 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("BLOQUEIO PARCIAL", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO",
var text when text.Contains("PRE ATIVACAO", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO",
var text when text.Contains("CANCEL", StringComparison.Ordinal) => "BLOQUEIO_120_DIAS", 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("SUSPENS", StringComparison.Ordinal) => "SUSPENSO",
var text when text.Contains("PENDENTE", StringComparison.Ordinal) && var text when text.Contains("PENDENTE", StringComparison.Ordinal) &&
@ -250,6 +279,7 @@ internal static class MveAuditNormalization
var text when text.Contains("ATIVO", StringComparison.Ordinal) || text == "ATIVA" => "ATIVO", 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("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 PARCIAL", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO",
var text when text.Contains("PRE ATIVACAO", 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("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("CANCEL", StringComparison.Ordinal) => "CANCELADO",
var text when text.Contains("SUSPENS", StringComparison.Ordinal) => "SUSPENSO", var text when text.Contains("SUSPENS", StringComparison.Ordinal) => "SUSPENSO",

View File

@ -194,6 +194,64 @@ public sealed class MveAuditService
.Where(x => lineIds.Contains(x.Id)) .Where(x => lineIds.Contains(x.Id))
.ToDictionaryAsync(x => x.Id, cancellationToken); .ToDictionaryAsync(x => x.Id, cancellationToken);
var reportSnapshotsByIssueId = selectedIssues
.ToDictionary(x => x.Id, x => DeserializeSnapshot(x.ReportSnapshotJson));
var lineNumbers = linesById.Values
.Select(x => MveAuditNormalization.NullIfEmptyDigits(x.Linha))
.Where(x => !string.IsNullOrWhiteSpace(x))
.Cast<string>()
.Concat(reportSnapshotsByIssueId.Values
.Select(x => MveAuditNormalization.NullIfEmptyDigits(x?.NumeroLinha))
.Where(x => !string.IsNullOrWhiteSpace(x))
.Cast<string>())
.Distinct(StringComparer.Ordinal)
.ToList();
var items = linesById.Values
.Where(x => x.Item > 0)
.Select(x => x.Item)
.Distinct()
.ToList();
var vigencias = await _db.VigenciaLines
.Where(x => (x.Item > 0 && items.Contains(x.Item)) || (x.Linha != null && lineNumbers.Contains(x.Linha)))
.ToListAsync(cancellationToken);
var userDatas = await _db.UserDatas
.Where(x => (x.Item > 0 && items.Contains(x.Item)) || (x.Linha != null && lineNumbers.Contains(x.Linha)))
.ToListAsync(cancellationToken);
var vigenciaByLine = vigencias
.Where(x => !string.IsNullOrWhiteSpace(x.Linha))
.GroupBy(x => x.Linha!, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First(),
StringComparer.Ordinal);
var vigenciaByItem = vigencias
.Where(x => x.Item > 0)
.GroupBy(x => x.Item)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First());
var userDataByLine = userDatas
.Where(x => !string.IsNullOrWhiteSpace(x.Linha))
.GroupBy(x => x.Linha!, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First(),
StringComparer.Ordinal);
var userDataByItem = userDatas
.Where(x => x.Item > 0)
.GroupBy(x => x.Item)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First());
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var updatedLineIds = new HashSet<Guid>(); var updatedLineIds = new HashSet<Guid>();
var updatedFields = 0; var updatedFields = 0;
@ -210,7 +268,12 @@ public sealed class MveAuditService
continue; continue;
} }
var reportSnapshot = DeserializeSnapshot(issue.ReportSnapshotJson); if (!reportSnapshotsByIssueId.TryGetValue(issue.Id, out var reportSnapshot))
{
skippedIssues++;
continue;
}
if (reportSnapshot == null) if (reportSnapshot == null)
{ {
skippedIssues++; skippedIssues++;
@ -220,7 +283,69 @@ public sealed class MveAuditService
var differences = DeserializeDifferences(issue.DifferencesJson); var differences = DeserializeDifferences(issue.DifferencesJson);
var lineChanged = false; var lineChanged = false;
foreach (var difference in differences.Where(x => x.Syncable && x.FieldKey == "status")) var hasLineDifference = differences.Any(x => x.Syncable && x.FieldKey == "line");
var hasChipDifference = differences.Any(x => x.Syncable && x.FieldKey == "chip");
var hasStatusDifference = differences.Any(x => x.Syncable && x.FieldKey == "status");
var previousLine = MveAuditNormalization.NullIfEmptyDigits(line.Linha);
var nextLine = hasLineDifference ? MveAuditNormalization.NullIfEmptyDigits(reportSnapshot.NumeroLinha) : previousLine;
if (hasLineDifference)
{
if (string.IsNullOrWhiteSpace(nextLine))
{
skippedIssues++;
continue;
}
var hasConflict = await _db.MobileLines
.AsNoTracking()
.AnyAsync(
x => x.TenantId == line.TenantId &&
x.Id != line.Id &&
x.Linha == nextLine,
cancellationToken);
if (hasConflict)
{
skippedIssues++;
continue;
}
}
if (hasLineDifference && SetString(line.Linha, nextLine, value => line.Linha = value))
{
lineChanged = true;
updatedFields++;
SyncLinkedLineRecords(
line,
previousLine,
nextLine,
vigenciaByLine,
vigenciaByItem,
userDataByLine,
userDataByItem,
now);
AddTrocaNumeroHistory(
line,
previousLine,
nextLine,
MveAuditNormalization.NullIfEmptyDigits(reportSnapshot.Chip) ?? MveAuditNormalization.NullIfEmptyDigits(line.Chip),
now);
}
if (hasChipDifference)
{
var nextChip = MveAuditNormalization.NullIfEmptyDigits(reportSnapshot.Chip);
if (SetString(line.Chip, nextChip, value => line.Chip = value))
{
lineChanged = true;
updatedFields++;
}
}
if (hasStatusDifference)
{ {
var systemStatus = MveAuditNormalization.NormalizeStatusForSystem(reportSnapshot.StatusLinha); var systemStatus = MveAuditNormalization.NormalizeStatusForSystem(reportSnapshot.StatusLinha);
if (SetString(line.Status, systemStatus, value => line.Status = value)) if (SetString(line.Status, systemStatus, value => line.Status = value))
@ -441,9 +566,89 @@ public sealed class MveAuditService
} }
} }
private void SyncLinkedLineRecords(
MobileLine line,
string? previousLine,
string? nextLine,
IDictionary<string, VigenciaLine> vigenciaByLine,
IDictionary<int, VigenciaLine> vigenciaByItem,
IDictionary<string, UserData> userDataByLine,
IDictionary<int, UserData> userDataByItem,
DateTime now)
{
if (string.IsNullOrWhiteSpace(nextLine))
{
return;
}
var lookupLine = !string.IsNullOrWhiteSpace(previousLine) ? previousLine : nextLine;
var vigencia = ResolveVigencia(line, lookupLine, vigenciaByLine, vigenciaByItem);
if (vigencia != null && SetString(vigencia.Linha, nextLine, value => vigencia.Linha = value))
{
vigencia.UpdatedAt = now;
RefreshLineLookup(vigenciaByLine, previousLine, nextLine, vigencia);
}
var userData = ResolveUserData(line, lookupLine, userDataByLine, userDataByItem);
if (userData != null && SetString(userData.Linha, nextLine, value => userData.Linha = value))
{
userData.UpdatedAt = now;
RefreshLineLookup(userDataByLine, previousLine, nextLine, userData);
}
}
private void AddTrocaNumeroHistory(
MobileLine line,
string? previousLine,
string? nextLine,
string? chip,
DateTime now)
{
if (string.IsNullOrWhiteSpace(previousLine) ||
string.IsNullOrWhiteSpace(nextLine) ||
string.Equals(previousLine, nextLine, StringComparison.Ordinal))
{
return;
}
_db.TrocaNumeroLines.Add(new TrocaNumeroLine
{
Id = Guid.NewGuid(),
TenantId = line.TenantId,
Item = line.Item,
LinhaAntiga = previousLine,
LinhaNova = nextLine,
ICCID = MveAuditNormalization.NullIfEmptyDigits(chip),
DataTroca = now,
Motivo = "Auditoria MVE",
Observacao = "Linha atualizada automaticamente a partir do relatório MVE.",
CreatedAt = now,
UpdatedAt = now
});
}
private static void RefreshLineLookup<T>(
IDictionary<string, T> lookup,
string? previousLine,
string? nextLine,
T entity)
where T : class
{
if (!string.IsNullOrWhiteSpace(previousLine))
{
lookup.Remove(previousLine);
}
if (!string.IsNullOrWhiteSpace(nextLine))
{
lookup[nextLine] = entity;
}
}
private VigenciaLine? ResolveVigencia( private VigenciaLine? ResolveVigencia(
MobileLine line, MobileLine line,
string numeroLinha, string? numeroLinha,
IDictionary<string, VigenciaLine> vigenciaByLine, IDictionary<string, VigenciaLine> vigenciaByLine,
IDictionary<int, VigenciaLine> vigenciaByItem) IDictionary<int, VigenciaLine> vigenciaByItem)
{ {
@ -462,7 +667,7 @@ public sealed class MveAuditService
private UserData? ResolveUserData( private UserData? ResolveUserData(
MobileLine line, MobileLine line,
string numeroLinha, string? numeroLinha,
IDictionary<string, UserData> userDataByLine, IDictionary<string, UserData> userDataByLine,
IDictionary<int, UserData> userDataByItem) IDictionary<int, UserData> userDataByItem)
{ {

View File

@ -119,8 +119,10 @@ public sealed class MveReconciliationService
}); });
} }
var blockedKeys = new HashSet<string>(duplicateReportKeys, StringComparer.Ordinal); var blockedSystemKeys = new HashSet<string>(duplicateSystemKeys, StringComparer.Ordinal);
blockedKeys.UnionWith(duplicateSystemKeys); var blockedReportKeys = new HashSet<string>(duplicateReportKeys, StringComparer.Ordinal);
var matchedSystemLineIds = new HashSet<Guid>();
var matchedReportRows = new HashSet<int>();
var allKeys = reportByNumber.Keys var allKeys = reportByNumber.Keys
.Concat(systemByNumber.Keys) .Concat(systemByNumber.Keys)
@ -131,7 +133,7 @@ public sealed class MveReconciliationService
foreach (var key in allKeys) foreach (var key in allKeys)
{ {
if (blockedKeys.Contains(key)) if (blockedSystemKeys.Contains(key) || blockedReportKeys.Contains(key))
{ {
continue; continue;
} }
@ -141,12 +143,71 @@ public sealed class MveReconciliationService
var reportLine = hasReport ? reportLines![0] : null; var reportLine = hasReport ? reportLines![0] : null;
var systemLine = hasSystem ? systemLines![0] : null; var systemLine = hasSystem ? systemLines![0] : null;
if (reportLine == null && systemLine != null) if (reportLine == null || systemLine == null)
{
continue;
}
matchedSystemLineIds.Add(systemLine.MobileLine.Id);
matchedReportRows.Add(reportLine.SourceRowNumber);
var comparison = CompareMatchedLine(systemLine, reportLine);
RegisterComparisonResult(result, comparison);
}
var unmatchedSystemLines = systemAggregates
.Where(x => !blockedSystemKeys.Contains(x.NumeroNormalizado))
.Where(x => !matchedSystemLineIds.Contains(x.MobileLine.Id))
.ToList();
var unmatchedReportLines = parsedFile.Lines
.Where(x => !blockedReportKeys.Contains(x.NumeroNormalizado))
.Where(x => !matchedReportRows.Contains(x.SourceRowNumber))
.ToList();
var chipMatchedSystemLineIds = new HashSet<Guid>();
var chipMatchedReportRows = new HashSet<int>();
var systemByChip = unmatchedSystemLines
.Where(x => !string.IsNullOrWhiteSpace(MveAuditNormalization.NullIfEmptyDigits(x.MobileLine.Chip)))
.GroupBy(x => MveAuditNormalization.OnlyDigits(x.MobileLine.Chip), StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
var reportByChip = unmatchedReportLines
.Where(x => !string.IsNullOrWhiteSpace(MveAuditNormalization.NullIfEmptyDigits(x.Chip)))
.GroupBy(x => MveAuditNormalization.OnlyDigits(x.Chip), StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
var chipKeys = systemByChip.Keys
.Intersect(reportByChip.Keys, StringComparer.Ordinal)
.OrderBy(key => key, StringComparer.Ordinal)
.ToList();
foreach (var chipKey in chipKeys)
{
var systemCandidates = systemByChip[chipKey];
var reportCandidates = reportByChip[chipKey];
if (systemCandidates.Count != 1 || reportCandidates.Count != 1)
{
continue;
}
var systemLine = systemCandidates[0];
var reportLine = reportCandidates[0];
chipMatchedSystemLineIds.Add(systemLine.MobileLine.Id);
chipMatchedReportRows.Add(reportLine.SourceRowNumber);
var comparison = CompareMatchedByChip(systemLine, reportLine);
RegisterComparisonResult(result, comparison);
}
foreach (var systemLine in unmatchedSystemLines.Where(x => !chipMatchedSystemLineIds.Contains(x.MobileLine.Id)))
{ {
result.TotalOnlyInSystem++; result.TotalOnlyInSystem++;
result.Issues.Add(new MveReconciliationIssueResult result.Issues.Add(new MveReconciliationIssueResult
{ {
NumeroLinha = key, NumeroLinha = systemLine.NumeroNormalizado,
MobileLineId = systemLine.MobileLine.Id, MobileLineId = systemLine.MobileLine.Id,
SystemItem = systemLine.MobileLine.Item, SystemItem = systemLine.MobileLine.Item,
IssueType = "ONLY_IN_SYSTEM", IssueType = "ONLY_IN_SYSTEM",
@ -159,16 +220,15 @@ public sealed class MveReconciliationService
SystemPlan = systemLine.MobileLine.PlanoContrato, SystemPlan = systemLine.MobileLine.PlanoContrato,
SystemSnapshot = BuildSystemSnapshot(systemLine) SystemSnapshot = BuildSystemSnapshot(systemLine)
}); });
continue;
} }
if (reportLine != null && systemLine == null) foreach (var reportLine in unmatchedReportLines.Where(x => !chipMatchedReportRows.Contains(x.SourceRowNumber)))
{ {
result.TotalOnlyInReport++; result.TotalOnlyInReport++;
result.Issues.Add(new MveReconciliationIssueResult result.Issues.Add(new MveReconciliationIssueResult
{ {
SourceRowNumber = reportLine.SourceRowNumber, SourceRowNumber = reportLine.SourceRowNumber,
NumeroLinha = key, NumeroLinha = reportLine.NumeroNormalizado,
IssueType = "ONLY_IN_REPORT", IssueType = "ONLY_IN_REPORT",
Situation = "ausente no sistema", Situation = "ausente no sistema",
Severity = "WARNING", Severity = "WARNING",
@ -179,36 +239,6 @@ public sealed class MveReconciliationService
ReportPlan = reportLine.PlanoLinha, ReportPlan = reportLine.PlanoLinha,
ReportSnapshot = BuildReportSnapshot(reportLine) 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; return result;
@ -291,12 +321,104 @@ public sealed class MveReconciliationService
}); });
} }
AddDifference(
differences,
"chip",
"Chip da linha",
systemSnapshot.Chip,
reportSnapshot.Chip,
syncable: true,
comparer: MveAuditNormalization.OnlyDigits);
var hasUnknownStatus = !reportLine.StatusLinhaRecognized; var hasUnknownStatus = !reportLine.StatusLinhaRecognized;
if (differences.Count == 0 && !hasUnknownStatus) if (differences.Count == 0 && !hasUnknownStatus)
{ {
return null; return null;
} }
return BuildIssue(systemLine, reportLine, systemSnapshot, reportSnapshot, differences, hasUnknownStatus);
}
private static MveReconciliationIssueResult? CompareMatchedByChip(
MveSystemLineAggregate systemLine,
MveParsedLine reportLine)
{
var systemSnapshot = BuildSystemSnapshot(systemLine);
var reportSnapshot = BuildReportSnapshot(reportLine);
var systemLocalNumber = MveAuditNormalization.ExtractPhoneLocalNumber(systemSnapshot.NumeroLinha);
var reportLocalNumber = MveAuditNormalization.ExtractPhoneLocalNumber(reportSnapshot.NumeroLinha);
if (!string.IsNullOrWhiteSpace(systemLocalNumber) &&
string.Equals(systemLocalNumber, reportLocalNumber, StringComparison.Ordinal))
{
return BuildDddReviewIssue(systemLine, reportLine, systemSnapshot, reportSnapshot);
}
var differences = new List<MveAuditDifferenceDto>();
AddDifference(
differences,
"line",
"Número da linha",
systemSnapshot.NumeroLinha,
reportSnapshot.NumeroLinha,
syncable: true,
comparer: MveAuditNormalization.OnlyDigits);
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;
}
return BuildIssue(systemLine, reportLine, systemSnapshot, reportSnapshot, differences, hasUnknownStatus);
}
private static void RegisterComparisonResult(MveReconciliationResult result, MveReconciliationIssueResult? comparison)
{
if (comparison == null)
{
result.TotalConciliated++;
return;
}
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++;
}
}
private static MveReconciliationIssueResult BuildIssue(
MveSystemLineAggregate systemLine,
MveParsedLine reportLine,
MveAuditSnapshotDto systemSnapshot,
MveAuditSnapshotDto reportSnapshot,
List<MveAuditDifferenceDto> differences,
bool hasUnknownStatus)
{
var notes = new List<string>(); var notes = new List<string>();
if (hasUnknownStatus) if (hasUnknownStatus)
{ {
@ -304,8 +426,9 @@ public sealed class MveReconciliationService
} }
var hasStatusDifference = differences.Any(x => x.FieldKey == "status"); var hasStatusDifference = differences.Any(x => x.FieldKey == "status");
var hasDataDifference = false; var hasLineDifference = differences.Any(x => x.FieldKey == "line");
var issueType = hasStatusDifference ? "STATUS_DIVERGENCE" : "UNKNOWN_STATUS"; var hasChipDifference = differences.Any(x => x.FieldKey == "chip");
var hasDataDifference = differences.Any(x => x.FieldKey != "status" && x.Syncable);
return new MveReconciliationIssueResult return new MveReconciliationIssueResult
{ {
@ -313,11 +436,11 @@ public sealed class MveReconciliationService
NumeroLinha = reportLine.NumeroNormalizado, NumeroLinha = reportLine.NumeroNormalizado,
MobileLineId = systemLine.MobileLine.Id, MobileLineId = systemLine.MobileLine.Id,
SystemItem = systemLine.MobileLine.Item, SystemItem = systemLine.MobileLine.Item,
IssueType = issueType, IssueType = ResolveIssueType(hasStatusDifference, hasLineDifference, hasChipDifference, hasUnknownStatus),
Situation = ResolveSituation(hasStatusDifference, hasDataDifference, hasUnknownStatus), Situation = ResolveSituation(hasStatusDifference, hasLineDifference, hasChipDifference, hasUnknownStatus),
Severity = ResolveSeverity(hasStatusDifference, hasDataDifference, hasUnknownStatus), Severity = ResolveSeverity(hasStatusDifference, hasDataDifference, hasUnknownStatus),
Syncable = differences.Any(x => x.Syncable), Syncable = differences.Any(x => x.Syncable),
ActionSuggestion = ResolveActionSuggestion(hasStatusDifference, hasDataDifference, hasUnknownStatus), ActionSuggestion = ResolveActionSuggestion(hasStatusDifference, hasLineDifference, hasChipDifference, hasUnknownStatus),
Notes = notes.Count == 0 ? null : string.Join(" ", notes), Notes = notes.Count == 0 ? null : string.Join(" ", notes),
SystemStatus = systemSnapshot.StatusLinha, SystemStatus = systemSnapshot.StatusLinha,
ReportStatus = reportSnapshot.StatusLinha, ReportStatus = reportSnapshot.StatusLinha,
@ -329,11 +452,92 @@ public sealed class MveReconciliationService
}; };
} }
private static string ResolveSituation(bool hasStatusDifference, bool hasDataDifference, bool hasUnknownStatus) private static MveReconciliationIssueResult BuildDddReviewIssue(
MveSystemLineAggregate systemLine,
MveParsedLine reportLine,
MveAuditSnapshotDto systemSnapshot,
MveAuditSnapshotDto reportSnapshot)
{ {
if (hasStatusDifference && hasDataDifference) return new MveReconciliationIssueResult
{ {
return "divergência de status e cadastro"; SourceRowNumber = reportLine.SourceRowNumber,
NumeroLinha = reportLine.NumeroNormalizado,
MobileLineId = systemLine.MobileLine.Id,
SystemItem = systemLine.MobileLine.Item,
IssueType = "DDD_CHANGE_REVIEW",
Situation = "mudança de DDD detectada",
Severity = "WARNING",
Syncable = false,
ActionSuggestion = "Revisar manualmente na página Mureg antes de aplicar alterações",
Notes = "O mesmo chip foi encontrado com o mesmo número base, mas com DDD diferente. Esse cenário ainda não é atualizado automaticamente pelo MVE.",
SystemStatus = systemSnapshot.StatusLinha,
ReportStatus = reportSnapshot.StatusLinha,
SystemPlan = systemSnapshot.PlanoLinha,
ReportPlan = reportSnapshot.PlanoLinha,
SystemSnapshot = systemSnapshot,
ReportSnapshot = reportSnapshot,
Differences = new List<MveAuditDifferenceDto>
{
new()
{
FieldKey = "ddd",
Label = "DDD da linha",
SystemValue = NullIfEmpty(MveAuditNormalization.ExtractPhoneDdd(systemSnapshot.NumeroLinha)),
ReportValue = NullIfEmpty(MveAuditNormalization.ExtractPhoneDdd(reportSnapshot.NumeroLinha)),
Syncable = false
}
}
};
}
private static string ResolveIssueType(
bool hasStatusDifference,
bool hasLineDifference,
bool hasChipDifference,
bool hasUnknownStatus)
{
if (hasLineDifference)
{
return "LINE_CHANGE_DETECTED";
}
if (hasChipDifference)
{
return "CHIP_CHANGE_DETECTED";
}
if (hasStatusDifference)
{
return "STATUS_DIVERGENCE";
}
return hasUnknownStatus ? "UNKNOWN_STATUS" : "ALIGNED";
}
private static string ResolveSituation(
bool hasStatusDifference,
bool hasLineDifference,
bool hasChipDifference,
bool hasUnknownStatus)
{
if (hasLineDifference && hasStatusDifference)
{
return "troca de número e status diferente";
}
if (hasLineDifference)
{
return "troca de número detectada";
}
if (hasChipDifference && hasStatusDifference)
{
return "troca de chip e status diferente";
}
if (hasChipDifference)
{
return "troca de chip detectada";
} }
if (hasStatusDifference) if (hasStatusDifference)
@ -341,11 +545,6 @@ public sealed class MveReconciliationService
return "divergência de status"; return "divergência de status";
} }
if (hasDataDifference)
{
return "divergência de cadastro";
}
return hasUnknownStatus ? "status desconhecido no relatório" : "alinhada"; return hasUnknownStatus ? "status desconhecido no relatório" : "alinhada";
} }
@ -364,8 +563,32 @@ public sealed class MveReconciliationService
return hasUnknownStatus ? "WARNING" : "INFO"; return hasUnknownStatus ? "WARNING" : "INFO";
} }
private static string ResolveActionSuggestion(bool hasStatusDifference, bool hasDataDifference, bool hasUnknownStatus) private static string ResolveActionSuggestion(
bool hasStatusDifference,
bool hasLineDifference,
bool hasChipDifference,
bool hasUnknownStatus)
{ {
if (hasLineDifference && hasStatusDifference)
{
return "Atualizar linha e status da linha com base no MVE";
}
if (hasLineDifference)
{
return "Atualizar a linha cadastrada com base no chip informado no MVE";
}
if (hasChipDifference && hasStatusDifference)
{
return "Atualizar chip e status da linha com base no MVE";
}
if (hasChipDifference)
{
return "Atualizar o chip da linha com base no MVE";
}
if (hasStatusDifference) if (hasStatusDifference)
{ {
return "Atualizar status da linha com base no MVE"; return "Atualizar status da linha com base no MVE";