Feat: adicionando auditoria completa MVE
This commit is contained in:
parent
d37ea0c377
commit
6af6674468
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,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)
|
||||
{
|
||||
return query.Where(x =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue