Compare commits

..

2 Commits

Author SHA1 Message Date
Eduardo Lopes ae378ba9cc fix: tornar suite da API compativel com MVE audit 2026-03-10 17:24:47 -03:00
Leon 6af6674468 Feat: adicionando auditoria completa MVE 2026-03-10 17:06:06 -03:00
13 changed files with 1257 additions and 216 deletions

View File

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Text;
namespace line_gestao_api.Controllers;
@ -24,6 +25,14 @@ public class HistoricoController : ControllerBase
nameof(ParcelamentoLine)
};
private static readonly HashSet<string> ChipRelatedEntities = new(StringComparer.OrdinalIgnoreCase)
{
nameof(MobileLine),
nameof(TrocaNumeroLine),
nameof(ChipVirgemLine),
nameof(ControleRecebidoLine)
};
private readonly AppDbContext _db;
public HistoricoController(AppDbContext db)
@ -152,10 +161,7 @@ public class HistoricoController : ControllerBase
var lineTerm = (line ?? string.Empty).Trim();
var normalizedLineDigits = DigitsOnly(lineTerm);
if (string.IsNullOrWhiteSpace(lineTerm) && string.IsNullOrWhiteSpace(normalizedLineDigits))
{
return BadRequest(new { message = "Informe uma linha para consultar o histórico." });
}
var hasLineFilter = !string.IsNullOrWhiteSpace(lineTerm) || !string.IsNullOrWhiteSpace(normalizedLineDigits);
var q = _db.AuditLogs
.AsNoTracking()
@ -213,7 +219,116 @@ public class HistoricoController : ControllerBase
foreach (var log in candidateLogs)
{
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;
}
@ -348,6 +463,51 @@ public class HistoricoController : ControllerBase
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)
{
if (string.IsNullOrWhiteSpace(source))
@ -375,6 +535,21 @@ public class HistoricoController : ControllerBase
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)
{
if (string.IsNullOrWhiteSpace(value))

View File

@ -31,6 +31,7 @@ namespace line_gestao_api.Controllers
private readonly IVigenciaNotificationSyncService _vigenciaNotificationSyncService;
private readonly ParcelamentosImportService _parcelamentosImportService;
private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService;
private readonly DashboardKpiSnapshotService _dashboardKpiSnapshotService;
private readonly string _aparelhoAttachmentsRootPath;
private static readonly FileExtensionContentTypeProvider FileContentTypeProvider = new();
@ -71,6 +72,7 @@ namespace line_gestao_api.Controllers
IVigenciaNotificationSyncService vigenciaNotificationSyncService,
ParcelamentosImportService parcelamentosImportService,
SpreadsheetImportAuditService spreadsheetImportAuditService,
DashboardKpiSnapshotService dashboardKpiSnapshotService,
IWebHostEnvironment webHostEnvironment)
{
_db = db;
@ -78,6 +80,7 @@ namespace line_gestao_api.Controllers
_vigenciaNotificationSyncService = vigenciaNotificationSyncService;
_parcelamentosImportService = parcelamentosImportService;
_spreadsheetImportAuditService = spreadsheetImportAuditService;
_dashboardKpiSnapshotService = dashboardKpiSnapshotService;
_aparelhoAttachmentsRootPath = Path.Combine(webHostEnvironment.ContentRootPath, "uploads", "aparelhos");
}
@ -98,6 +101,7 @@ namespace line_gestao_api.Controllers
[HttpGet("groups")]
public async Task<ActionResult<PagedResult<ClientGroupDto>>> GetClientGroups(
[FromQuery] string? skil,
[FromQuery] string? reservaMode,
[FromQuery] string? search,
[FromQuery] string? additionalMode,
[FromQuery] string? additionalServices,
@ -117,10 +121,7 @@ namespace line_gestao_api.Controllers
if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase))
{
reservaFilter = true;
query = query.Where(x =>
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
query = ApplyReservaContextFilter(query);
}
else
query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%"));
@ -135,37 +136,7 @@ namespace line_gestao_api.Controllers
if (reservaFilter)
{
var userDataClientByLine = BuildUserDataClientByLineQuery();
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
};
var reservaRows = ApplyReservaMode(BuildReservaLineProjection(query), reservaMode);
if (!string.IsNullOrWhiteSpace(search))
{
@ -211,7 +182,8 @@ namespace line_gestao_api.Controllers
var orderedGroupedQuery = reservaFilter
? 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)
: groupedQuery.OrderBy(x => x.Cliente);
@ -235,6 +207,7 @@ namespace line_gestao_api.Controllers
[HttpGet("clients")]
public async Task<ActionResult<List<string>>> GetClients(
[FromQuery] string? skil,
[FromQuery] string? reservaMode,
[FromQuery] string? additionalMode,
[FromQuery] string? additionalServices)
{
@ -247,10 +220,7 @@ namespace line_gestao_api.Controllers
if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase))
{
reservaFilter = true;
query = query.Where(x =>
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
query = ApplyReservaContextFilter(query);
}
else
query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%"));
@ -264,37 +234,11 @@ namespace line_gestao_api.Controllers
List<string> clients;
if (reservaFilter)
{
var userDataClientByLine = BuildUserDataClientByLineQuery();
var userDataClientByItem = BuildUserDataClientByItemQuery();
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
)
clients = await ApplyReservaMode(BuildReservaLineProjection(query), reservaMode)
.Select(x => x.Cliente)
.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)
.ToListAsync();
}
@ -449,6 +393,7 @@ namespace line_gestao_api.Controllers
public async Task<ActionResult<PagedResult<MobileLineListDto>>> GetAll(
[FromQuery] string? search,
[FromQuery] string? skil,
[FromQuery] string? reservaMode,
[FromQuery] string? client,
[FromQuery] string? operadora,
[FromQuery] string? additionalMode,
@ -470,10 +415,7 @@ namespace line_gestao_api.Controllers
if (sSkil.Equals("RESERVA", StringComparison.OrdinalIgnoreCase))
{
reservaFilter = true;
q = q.Where(x =>
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
q = ApplyReservaContextFilter(q);
}
else
q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%"));
@ -493,61 +435,7 @@ namespace line_gestao_api.Controllers
if (reservaFilter)
{
var userDataClientByLine = BuildUserDataClientByLineQuery();
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
};
var rq = ApplyReservaMode(BuildReservaLineProjection(q), reservaMode);
if (!string.IsNullOrWhiteSpace(client))
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),
"chip" => desc ? rq.OrderByDescending(x => x.Chip ?? "").ThenBy(x => x.Item) : rq.OrderBy(x => x.Chip ?? "").ThenBy(x => x.Item),
"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 ?? "")
.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.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 _dashboardKpiSnapshotService.CaptureAfterSpreadsheetImportAsync(User, HttpContext);
await tx.CommitAsync();
return Ok(new ImportResultDto { Imported = imported, Parcelamentos = parcelamentosSummary });
}
@ -5443,6 +5334,36 @@ namespace line_gestao_api.Controllers
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()
{
return _db.UserDatas
@ -5473,12 +5394,104 @@ 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 = clienteOriginal.ToUpper() == "RESERVA"
let clienteEfetivo = isStock
? "ESTOQUE"
: (!string.IsNullOrEmpty(clienteOriginal) &&
clienteOriginal.ToUpper() != "RESERVA")
? clienteOriginal
: (!string.IsNullOrEmpty(clientePorLinha) &&
clientePorLinha.ToUpper() != "RESERVA")
? clientePorLinha
: (!string.IsNullOrEmpty(clientePorItem) &&
clientePorItem.ToUpper() != "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 =>
(x.Usuario ?? "").Trim().ToUpper() == "RESERVA" ||
(x.Skil ?? "").Trim().ToUpper() == "RESERVA" ||
(x.Cliente ?? "").Trim().ToUpper() == "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 &&
(x.Cliente ?? "").Trim().ToUpper() != "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)
{
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"));
(x.Usuario ?? "").Trim().ToUpper() != "RESERVA" &&
(x.Skil ?? "").Trim().ToUpper() != "RESERVA" &&
(x.Cliente ?? "").Trim().ToUpper() != "RESERVA");
}
private static IQueryable<MobileLine> ApplyAdditionalFilters(

View File

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

View File

@ -6,6 +6,7 @@ namespace line_gestao_api.Dtos
public class RelatoriosDashboardDto
{
public DashboardKpisDto Kpis { get; set; } = new();
public Dictionary<string, string> KpiTrends { get; set; } = new();
public List<TopClienteDto> TopClientes { get; set; } = new();
public List<SerieMesDto> SerieMuregUltimos12Meses { 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<ParcelamentosImportService>();
builder.Services.AddScoped<GeralDashboardInsightsService>();
builder.Services.AddScoped<DashboardKpiSnapshotService>();
builder.Services.AddScoped<GeralSpreadsheetTemplateService>();
builder.Services.AddScoped<SpreadsheetImportAuditService>();
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;
}
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)
{
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("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("SUSPENS", StringComparison.Ordinal) => "SUSPENSO",
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("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("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("CANCEL", StringComparison.Ordinal) => "CANCELADO",
var text when text.Contains("SUSPENS", StringComparison.Ordinal) => "SUSPENSO",

View File

@ -14,6 +14,11 @@ public sealed class MveAuditSchemaBootstrapper
public async Task EnsureSchemaAsync(CancellationToken cancellationToken = default)
{
if (!_db.Database.IsRelational())
{
return;
}
await _db.Database.ExecuteSqlRawAsync(
"""
ALTER TABLE "Aparelhos"

View File

@ -194,6 +194,64 @@ public sealed class MveAuditService
.Where(x => lineIds.Contains(x.Id))
.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 updatedLineIds = new HashSet<Guid>();
var updatedFields = 0;
@ -210,7 +268,12 @@ public sealed class MveAuditService
continue;
}
var reportSnapshot = DeserializeSnapshot(issue.ReportSnapshotJson);
if (!reportSnapshotsByIssueId.TryGetValue(issue.Id, out var reportSnapshot))
{
skippedIssues++;
continue;
}
if (reportSnapshot == null)
{
skippedIssues++;
@ -220,7 +283,69 @@ public sealed class MveAuditService
var differences = DeserializeDifferences(issue.DifferencesJson);
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);
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(
MobileLine line,
string numeroLinha,
string? numeroLinha,
IDictionary<string, VigenciaLine> vigenciaByLine,
IDictionary<int, VigenciaLine> vigenciaByItem)
{
@ -462,7 +667,7 @@ public sealed class MveAuditService
private UserData? ResolveUserData(
MobileLine line,
string numeroLinha,
string? numeroLinha,
IDictionary<string, UserData> userDataByLine,
IDictionary<int, UserData> userDataByItem)
{

View File

@ -119,8 +119,10 @@ public sealed class MveReconciliationService
});
}
var blockedKeys = new HashSet<string>(duplicateReportKeys, StringComparer.Ordinal);
blockedKeys.UnionWith(duplicateSystemKeys);
var blockedSystemKeys = new HashSet<string>(duplicateSystemKeys, StringComparer.Ordinal);
var blockedReportKeys = new HashSet<string>(duplicateReportKeys, StringComparer.Ordinal);
var matchedSystemLineIds = new HashSet<Guid>();
var matchedReportRows = new HashSet<int>();
var allKeys = reportByNumber.Keys
.Concat(systemByNumber.Keys)
@ -131,7 +133,7 @@ public sealed class MveReconciliationService
foreach (var key in allKeys)
{
if (blockedKeys.Contains(key))
if (blockedSystemKeys.Contains(key) || blockedReportKeys.Contains(key))
{
continue;
}
@ -141,12 +143,71 @@ public sealed class MveReconciliationService
var reportLine = hasReport ? reportLines![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.Issues.Add(new MveReconciliationIssueResult
{
NumeroLinha = key,
NumeroLinha = systemLine.NumeroNormalizado,
MobileLineId = systemLine.MobileLine.Id,
SystemItem = systemLine.MobileLine.Item,
IssueType = "ONLY_IN_SYSTEM",
@ -159,16 +220,15 @@ public sealed class MveReconciliationService
SystemPlan = systemLine.MobileLine.PlanoContrato,
SystemSnapshot = BuildSystemSnapshot(systemLine)
});
continue;
}
if (reportLine != null && systemLine == null)
foreach (var reportLine in unmatchedReportLines.Where(x => !chipMatchedReportRows.Contains(x.SourceRowNumber)))
{
result.TotalOnlyInReport++;
result.Issues.Add(new MveReconciliationIssueResult
{
SourceRowNumber = reportLine.SourceRowNumber,
NumeroLinha = key,
NumeroLinha = reportLine.NumeroNormalizado,
IssueType = "ONLY_IN_REPORT",
Situation = "ausente no sistema",
Severity = "WARNING",
@ -179,36 +239,6 @@ public sealed class MveReconciliationService
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;
@ -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;
if (differences.Count == 0 && !hasUnknownStatus)
{
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>();
if (hasUnknownStatus)
{
@ -304,8 +426,9 @@ public sealed class MveReconciliationService
}
var hasStatusDifference = differences.Any(x => x.FieldKey == "status");
var hasDataDifference = false;
var issueType = hasStatusDifference ? "STATUS_DIVERGENCE" : "UNKNOWN_STATUS";
var hasLineDifference = differences.Any(x => x.FieldKey == "line");
var hasChipDifference = differences.Any(x => x.FieldKey == "chip");
var hasDataDifference = differences.Any(x => x.FieldKey != "status" && x.Syncable);
return new MveReconciliationIssueResult
{
@ -313,11 +436,11 @@ public sealed class MveReconciliationService
NumeroLinha = reportLine.NumeroNormalizado,
MobileLineId = systemLine.MobileLine.Id,
SystemItem = systemLine.MobileLine.Item,
IssueType = issueType,
Situation = ResolveSituation(hasStatusDifference, hasDataDifference, hasUnknownStatus),
IssueType = ResolveIssueType(hasStatusDifference, hasLineDifference, hasChipDifference, hasUnknownStatus),
Situation = ResolveSituation(hasStatusDifference, hasLineDifference, hasChipDifference, hasUnknownStatus),
Severity = ResolveSeverity(hasStatusDifference, hasDataDifference, hasUnknownStatus),
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),
SystemStatus = systemSnapshot.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)
@ -341,11 +545,6 @@ public sealed class MveReconciliationService
return "divergência de status";
}
if (hasDataDifference)
{
return "divergência de cadastro";
}
return hasUnknownStatus ? "status desconhecido no relatório" : "alinhada";
}
@ -364,8 +563,32 @@ public sealed class MveReconciliationService
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)
{
return "Atualizar status da linha com base no MVE";

View File

@ -5,9 +5,11 @@ using line_gestao_api.Data;
using line_gestao_api.Dtos;
using line_gestao_api.Models;
using line_gestao_api.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@ -324,6 +326,7 @@ public class SystemTenantIntegrationTests
services.RemoveAll<AppDbContext>();
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.RemoveAll<IDbContextOptionsConfiguration<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
{