Compare commits

...

11 Commits

54 changed files with 6322 additions and 239 deletions

View File

@ -10,7 +10,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin,financeiro")]
public class BillingController : ControllerBase public class BillingController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
@ -197,7 +197,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateBillingClientRequest req) public async Task<IActionResult> Update(Guid id, [FromBody] UpdateBillingClientRequest req)
{ {
var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id);
@ -230,7 +230,7 @@ namespace line_gestao_api.Controllers
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/chips-virgens")] [Route("api/chips-virgens")]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin,gestor,financeiro")]
public class ChipsVirgensController : ControllerBase public class ChipsVirgensController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;

View File

@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/controle-recebidos")] [Route("api/controle-recebidos")]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin,gestor,financeiro")]
public class ControleRecebidosController : ControllerBase public class ControleRecebidosController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;

View File

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

View File

@ -1,19 +1,38 @@
using System.Text.Json; using System.Text.Json;
using line_gestao_api.Data; using line_gestao_api.Data;
using line_gestao_api.Dtos; using line_gestao_api.Dtos;
using line_gestao_api.Models;
using line_gestao_api.Services; using line_gestao_api.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Globalization; using System.Globalization;
using System.Text;
namespace line_gestao_api.Controllers; namespace line_gestao_api.Controllers;
[ApiController] [ApiController]
[Route("api/historico")] [Route("api/historico")]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin,gestor,financeiro")]
public class HistoricoController : ControllerBase public class HistoricoController : ControllerBase
{ {
private static readonly HashSet<string> LineRelatedEntities = new(StringComparer.OrdinalIgnoreCase)
{
nameof(MobileLine),
nameof(MuregLine),
nameof(TrocaNumeroLine),
nameof(VigenciaLine),
nameof(ParcelamentoLine)
};
private static readonly HashSet<string> ChipRelatedEntities = new(StringComparer.OrdinalIgnoreCase)
{
nameof(MobileLine),
nameof(TrocaNumeroLine),
nameof(ChipVirgemLine),
nameof(ControleRecebidoLine)
};
private readonly AppDbContext _db; private readonly AppDbContext _db;
public HistoricoController(AppDbContext db) public HistoricoController(AppDbContext db)
@ -121,11 +140,224 @@ public class HistoricoController : ControllerBase
Page = page, Page = page,
PageSize = pageSize, PageSize = pageSize,
Total = total, Total = total,
Items = items.Select(ToDto).ToList() Items = items.Select(log => ToDto(log)).ToList()
}); });
} }
private static AuditLogDto ToDto(Models.AuditLog log) [HttpGet("linhas")]
public async Task<ActionResult<PagedResult<AuditLogDto>>> GetLineHistory(
[FromQuery] string? line,
[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 lineTerm = (line ?? string.Empty).Trim();
var normalizedLineDigits = DigitsOnly(lineTerm);
var hasLineFilter = !string.IsNullOrWhiteSpace(lineTerm) || !string.IsNullOrWhiteSpace(normalizedLineDigits);
var q = _db.AuditLogs
.AsNoTracking()
.Where(x => LineRelatedEntities.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 (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;
}
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
});
}
private static AuditLogDto ToDto(AuditLog log, List<AuditFieldChangeDto>? parsedChanges = null)
{ {
return new AuditLogDto return new AuditLogDto
{ {
@ -142,7 +374,7 @@ public class HistoricoController : ControllerBase
RequestPath = log.RequestPath, RequestPath = log.RequestPath,
RequestMethod = log.RequestMethod, RequestMethod = log.RequestMethod,
IpAddress = log.IpAddress, IpAddress = log.IpAddress,
Changes = ParseChanges(log.ChangesJson) Changes = parsedChanges ?? ParseChanges(log.ChangesJson)
}; };
} }
@ -173,6 +405,162 @@ public class HistoricoController : ControllerBase
return DateTime.SpecifyKind(value, DateTimeKind.Utc); return DateTime.SpecifyKind(value, DateTimeKind.Utc);
} }
private static bool MatchesLine(
AuditLog log,
List<AuditFieldChangeDto> changes,
string lineTerm,
string normalizedLineDigits)
{
if (MatchesTerm(log.EntityLabel, lineTerm, normalizedLineDigits) ||
MatchesTerm(log.EntityId, lineTerm, normalizedLineDigits))
{
return true;
}
foreach (var change in changes)
{
if (MatchesTerm(change.Field, lineTerm, normalizedLineDigits) ||
MatchesTerm(change.OldValue, lineTerm, normalizedLineDigits) ||
MatchesTerm(change.NewValue, lineTerm, normalizedLineDigits))
{
return true;
}
}
return false;
}
private static bool MatchesSearch(
AuditLog log,
List<AuditFieldChangeDto> changes,
string searchTerm,
string searchDigits)
{
if (MatchesTerm(log.UserName, searchTerm, searchDigits) ||
MatchesTerm(log.UserEmail, searchTerm, searchDigits) ||
MatchesTerm(log.Action, searchTerm, searchDigits) ||
MatchesTerm(log.Page, searchTerm, searchDigits) ||
MatchesTerm(log.EntityName, searchTerm, searchDigits) ||
MatchesTerm(log.EntityId, searchTerm, searchDigits) ||
MatchesTerm(log.EntityLabel, searchTerm, searchDigits) ||
MatchesTerm(log.RequestMethod, searchTerm, searchDigits) ||
MatchesTerm(log.RequestPath, searchTerm, searchDigits) ||
MatchesTerm(log.IpAddress, searchTerm, searchDigits))
{
return true;
}
foreach (var change in changes)
{
if (MatchesTerm(change.Field, searchTerm, searchDigits) ||
MatchesTerm(change.OldValue, searchTerm, searchDigits) ||
MatchesTerm(change.NewValue, searchTerm, searchDigits))
{
return true;
}
}
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))
{
return false;
}
if (!string.IsNullOrWhiteSpace(term) &&
source.Contains(term, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (string.IsNullOrWhiteSpace(digitsTerm))
{
return false;
}
var sourceDigits = DigitsOnly(source);
if (string.IsNullOrWhiteSpace(sourceDigits))
{
return false;
}
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))
{
return string.Empty;
}
var chars = value.Where(char.IsDigit).ToArray();
return chars.Length == 0 ? string.Empty : new string(chars);
}
private static bool TryParseSearchDateUtcStart(string value, out DateTime utcStart) private static bool TryParseSearchDateUtcStart(string value, out DateTime utcStart)
{ {
utcStart = default; utcStart = default;

File diff suppressed because it is too large Load Diff

View File

@ -183,7 +183,7 @@ namespace line_gestao_api.Controllers
public string? LinhaAntiga { get; set; } // opcional (snapshot) public string? LinhaAntiga { get; set; } // opcional (snapshot)
public string? LinhaNova { get; set; } // opcional public string? LinhaNova { get; set; } // opcional
public string? ICCID { get; set; } // opcional public string? ICCID { get; set; } // opcional
public DateTime? DataDaMureg { get; set; } // opcional public DateTime? DataDaMureg { get; set; } // ignorado no create (sistema define automaticamente)
} }
[HttpPost] [HttpPost]
@ -234,7 +234,8 @@ namespace line_gestao_api.Controllers
LinhaAntiga = linhaAntigaSnapshot, LinhaAntiga = linhaAntigaSnapshot,
LinhaNova = linhaNova, LinhaNova = linhaNova,
ICCID = iccid, ICCID = iccid,
DataDaMureg = ToUtc(req.DataDaMureg), // Data automática no momento da criação da Mureg
DataDaMureg = now,
CreatedAt = now, CreatedAt = now,
UpdatedAt = now UpdatedAt = now
}; };

View File

@ -0,0 +1,74 @@
using line_gestao_api.Dtos;
using line_gestao_api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace line_gestao_api.Controllers;
[ApiController]
[Route("api/mve-audit")]
[Authorize(Roles = "sysadmin,gestor")]
public class MveAuditController : ControllerBase
{
private readonly MveAuditService _mveAuditService;
public MveAuditController(MveAuditService mveAuditService)
{
_mveAuditService = mveAuditService;
}
public sealed class MveAuditUploadForm
{
public IFormFile File { get; set; } = default!;
}
[HttpPost("preview")]
[Consumes("multipart/form-data")]
[RequestSizeLimit(20_000_000)]
public async Task<ActionResult<MveAuditRunDto>> Preview(
[FromForm] MveAuditUploadForm form,
CancellationToken cancellationToken)
{
try
{
var result = await _mveAuditService.CreateRunAsync(form.File, cancellationToken);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<MveAuditRunDto>> GetById(Guid id, CancellationToken cancellationToken)
{
var result = await _mveAuditService.GetByIdAsync(id, cancellationToken);
return result == null ? NotFound() : Ok(result);
}
[HttpGet("latest")]
public async Task<ActionResult<MveAuditRunDto>> GetLatest(CancellationToken cancellationToken)
{
var result = await _mveAuditService.GetLatestAsync(cancellationToken);
return result == null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/apply")]
public async Task<ActionResult<ApplyMveAuditResultDto>> Apply(
Guid id,
[FromBody] ApplyMveAuditRequestDto request,
CancellationToken cancellationToken)
{
try
{
var result = await _mveAuditService.ApplyAsync(id, request?.IssueIds, cancellationToken);
return result == null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
}

View File

@ -9,7 +9,7 @@ namespace line_gestao_api.Controllers;
[ApiController] [ApiController]
[Route("api/parcelamentos")] [Route("api/parcelamentos")]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin,financeiro")]
public class ParcelamentosController : ControllerBase public class ParcelamentosController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
@ -165,7 +165,7 @@ public class ParcelamentosController : ControllerBase
} }
[HttpPost] [HttpPost]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin")]
public async Task<ActionResult<ParcelamentoDetailDto>> Create([FromBody] ParcelamentoUpsertDto req) public async Task<ActionResult<ParcelamentoDetailDto>> Create([FromBody] ParcelamentoUpsertDto req)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@ -202,7 +202,7 @@ public class ParcelamentosController : ControllerBase
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] ParcelamentoUpsertDto req) public async Task<IActionResult> Update(Guid id, [FromBody] ParcelamentoUpsertDto req)
{ {
var entity = await _db.ParcelamentoLines var entity = await _db.ParcelamentoLines
@ -239,7 +239,7 @@ public class ParcelamentosController : ControllerBase
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var entity = await _db.ParcelamentoLines.FirstOrDefaultAsync(x => x.Id == id); var entity = await _db.ParcelamentoLines.FirstOrDefaultAsync(x => x.Id == id);

View File

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

View File

@ -0,0 +1,240 @@
using System.Globalization;
using System.Security.Claims;
using line_gestao_api.Data;
using line_gestao_api.Dtos;
using line_gestao_api.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace line_gestao_api.Controllers;
[ApiController]
[Route("api/solicitacoes-linhas")]
[Authorize]
public class SolicitacoesLinhasController : ControllerBase
{
private const string TipoAlteracaoFranquia = "ALTERACAO_FRANQUIA";
private const string TipoBloqueio = "BLOQUEIO";
private readonly AppDbContext _db;
public SolicitacoesLinhasController(AppDbContext db)
{
_db = db;
}
[HttpPost]
[Authorize(Roles = "sysadmin,gestor,cliente")]
public async Task<ActionResult<SolicitacaoLinhaListDto>> Create([FromBody] CreateSolicitacaoLinhaRequestDto req)
{
if (req.LineId == Guid.Empty)
{
return BadRequest(new { message = "Linha inválida para solicitação." });
}
var line = await _db.MobileLines
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == req.LineId);
if (line == null)
{
return NotFound(new { message = "Linha não encontrada." });
}
var tipoSolicitacao = NormalizeTipoSolicitacao(req.TipoSolicitacao);
if (tipoSolicitacao == null)
{
return BadRequest(new { message = "Tipo de solicitação inválido. Use 'alteracao-franquia' ou 'bloqueio'." });
}
decimal? franquiaLineNova = null;
if (tipoSolicitacao == TipoAlteracaoFranquia)
{
if (!req.FranquiaLineNova.HasValue)
{
return BadRequest(new { message = "Informe a nova franquia para solicitar alteração." });
}
franquiaLineNova = decimal.Round(req.FranquiaLineNova.Value, 2, MidpointRounding.AwayFromZero);
if (franquiaLineNova < 0)
{
return BadRequest(new { message = "A nova franquia não pode ser negativa." });
}
}
var solicitanteNome = ResolveSolicitanteNome();
var usuarioLinha = NormalizeOptionalText(line.Usuario) ?? solicitanteNome;
var linha = NormalizeOptionalText(line.Linha) ?? "-";
var mensagem = tipoSolicitacao == TipoAlteracaoFranquia
? $"O Usuário \"{usuarioLinha}\" solicitou alteração da linha \"{linha}\" \"{FormatFranquia(line.FranquiaLine)}\" -> \"{FormatFranquia(franquiaLineNova)}\""
: $"O Usuário \"{usuarioLinha}\" solicitou bloqueio da linha \"{linha}\"";
var solicitacao = new SolicitacaoLinha
{
TenantId = line.TenantId,
MobileLineId = line.Id,
Linha = NormalizeOptionalText(line.Linha),
UsuarioLinha = NormalizeOptionalText(line.Usuario),
TipoSolicitacao = tipoSolicitacao,
FranquiaLineAtual = line.FranquiaLine,
FranquiaLineNova = franquiaLineNova,
SolicitanteUserId = ResolveSolicitanteUserId(),
SolicitanteNome = solicitanteNome,
Mensagem = mensagem,
Status = "PENDENTE",
CreatedAt = DateTime.UtcNow
};
_db.SolicitacaoLinhas.Add(solicitacao);
await _db.SaveChangesAsync();
var tenantNome = await _db.Tenants
.AsNoTracking()
.Where(t => t.Id == solicitacao.TenantId)
.Select(t => t.NomeOficial)
.FirstOrDefaultAsync();
return Ok(ToDto(solicitacao, tenantNome));
}
[HttpGet]
[Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<PagedResult<SolicitacaoLinhaListDto>>> List(
[FromQuery] string? search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
page = page < 1 ? 1 : page;
pageSize = pageSize < 1 ? 20 : Math.Min(pageSize, 200);
var query =
from solicitacao in _db.SolicitacaoLinhas.AsNoTracking()
join tenant in _db.Tenants.AsNoTracking()
on solicitacao.TenantId equals tenant.Id into tenantJoin
from tenant in tenantJoin.DefaultIfEmpty()
select new
{
Solicitacao = solicitacao,
TenantNome = tenant != null ? tenant.NomeOficial : null
};
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim();
query = query.Where(x =>
EF.Functions.ILike(x.Solicitacao.Linha ?? "", $"%{term}%") ||
EF.Functions.ILike(x.Solicitacao.UsuarioLinha ?? "", $"%{term}%") ||
EF.Functions.ILike(x.Solicitacao.SolicitanteNome ?? "", $"%{term}%") ||
EF.Functions.ILike(x.Solicitacao.Mensagem ?? "", $"%{term}%"));
}
var total = await query.CountAsync();
var items = await query
.OrderByDescending(x => x.Solicitacao.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new SolicitacaoLinhaListDto
{
Id = x.Solicitacao.Id,
TenantId = x.Solicitacao.TenantId,
TenantNome = x.TenantNome,
MobileLineId = x.Solicitacao.MobileLineId,
Linha = x.Solicitacao.Linha,
UsuarioLinha = x.Solicitacao.UsuarioLinha,
TipoSolicitacao = x.Solicitacao.TipoSolicitacao,
FranquiaLineAtual = x.Solicitacao.FranquiaLineAtual,
FranquiaLineNova = x.Solicitacao.FranquiaLineNova,
SolicitanteNome = x.Solicitacao.SolicitanteNome,
Mensagem = x.Solicitacao.Mensagem,
Status = x.Solicitacao.Status,
CreatedAt = x.Solicitacao.CreatedAt
})
.ToListAsync();
return Ok(new PagedResult<SolicitacaoLinhaListDto>
{
Page = page,
PageSize = pageSize,
Total = total,
Items = items
});
}
private static string? NormalizeTipoSolicitacao(string? tipoSolicitacao)
{
var value = (tipoSolicitacao ?? string.Empty).Trim().ToLowerInvariant();
return value switch
{
"alteracao-franquia" => TipoAlteracaoFranquia,
"alteracao_franquia" => TipoAlteracaoFranquia,
"alteracaofranquia" => TipoAlteracaoFranquia,
"franquia" => TipoAlteracaoFranquia,
"bloqueio" => TipoBloqueio,
"solicitar-bloqueio" => TipoBloqueio,
_ => null
};
}
private string ResolveSolicitanteNome()
{
var fromClaim = User.FindFirstValue("name");
if (!string.IsNullOrWhiteSpace(fromClaim))
{
return fromClaim.Trim();
}
var fromIdentity = User.Identity?.Name;
if (!string.IsNullOrWhiteSpace(fromIdentity))
{
return fromIdentity.Trim();
}
var fromEmail = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
if (!string.IsNullOrWhiteSpace(fromEmail))
{
return fromEmail.Trim();
}
return "Usuário";
}
private Guid? ResolveSolicitanteUserId()
{
var raw =
User.FindFirstValue(ClaimTypes.NameIdentifier) ??
User.FindFirstValue("sub");
return Guid.TryParse(raw, out var parsed) ? parsed : null;
}
private static string? NormalizeOptionalText(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static string FormatFranquia(decimal? value)
{
return value.HasValue
? value.Value.ToString("0.##", CultureInfo.GetCultureInfo("pt-BR"))
: "-";
}
private static SolicitacaoLinhaListDto ToDto(SolicitacaoLinha solicitacao, string? tenantNome)
{
return new SolicitacaoLinhaListDto
{
Id = solicitacao.Id,
TenantId = solicitacao.TenantId,
TenantNome = tenantNome,
MobileLineId = solicitacao.MobileLineId,
Linha = solicitacao.Linha,
UsuarioLinha = solicitacao.UsuarioLinha,
TipoSolicitacao = solicitacao.TipoSolicitacao,
FranquiaLineAtual = solicitacao.FranquiaLineAtual,
FranquiaLineNova = solicitacao.FranquiaLineNova,
SolicitanteNome = solicitacao.SolicitanteNome,
Mensagem = solicitacao.Mensagem,
Status = solicitacao.Status,
CreatedAt = solicitacao.CreatedAt
};
}
}

View File

@ -6,7 +6,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/templates")] [Route("api/templates")]
[Authorize(Roles = "sysadmin,gestor")] [Authorize(Roles = "sysadmin,gestor,financeiro")]
public class TemplatesController : ControllerBase public class TemplatesController : ControllerBase
{ {
private readonly GeralSpreadsheetTemplateService _geralSpreadsheetTemplateService; private readonly GeralSpreadsheetTemplateService _geralSpreadsheetTemplateService;

View File

@ -19,6 +19,7 @@ public class UsersController : ControllerBase
{ {
AppRoles.SysAdmin, AppRoles.SysAdmin,
AppRoles.Gestor, AppRoles.Gestor,
AppRoles.Financeiro,
AppRoles.Cliente AppRoles.Cliente
}; };

View File

@ -24,6 +24,8 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
// ✅ tabela para espelhar a planilha (GERAL) // ✅ tabela para espelhar a planilha (GERAL)
public DbSet<MobileLine> MobileLines => Set<MobileLine>(); public DbSet<MobileLine> MobileLines => Set<MobileLine>();
public DbSet<Setor> Setores => Set<Setor>();
public DbSet<Aparelho> Aparelhos => Set<Aparelho>();
// ✅ tabela para espelhar a aba MUREG // ✅ tabela para espelhar a aba MUREG
public DbSet<MuregLine> MuregLines => Set<MuregLine>(); public DbSet<MuregLine> MuregLines => Set<MuregLine>();
@ -48,6 +50,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
// ✅ tabela NOTIFICAÇÕES // ✅ tabela NOTIFICAÇÕES
public DbSet<Notification> Notifications => Set<Notification>(); public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<SolicitacaoLinha> SolicitacaoLinhas => Set<SolicitacaoLinha>();
// ✅ tabela RESUMO // ✅ tabela RESUMO
public DbSet<ResumoMacrophonyPlan> ResumoMacrophonyPlans => Set<ResumoMacrophonyPlan>(); public DbSet<ResumoMacrophonyPlan> ResumoMacrophonyPlans => Set<ResumoMacrophonyPlan>();
@ -74,6 +77,10 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
public DbSet<ImportAuditRun> ImportAuditRuns => Set<ImportAuditRun>(); public DbSet<ImportAuditRun> ImportAuditRuns => Set<ImportAuditRun>();
public DbSet<ImportAuditIssue> ImportAuditIssues => Set<ImportAuditIssue>(); public DbSet<ImportAuditIssue> ImportAuditIssues => Set<ImportAuditIssue>();
// ✅ tabelas de auditoria MVE
public DbSet<MveAuditRun> MveAuditRuns => Set<MveAuditRun>();
public DbSet<MveAuditIssue> MveAuditIssues => Set<MveAuditIssue>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@ -87,6 +94,27 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
e.HasIndex(x => new { x.IsSystem, x.Ativo }); e.HasIndex(x => new { x.IsSystem, x.Ativo });
}); });
modelBuilder.Entity<Setor>(e =>
{
e.Property(x => x.Nome).HasMaxLength(160);
e.HasIndex(x => x.TenantId);
e.HasIndex(x => new { x.TenantId, x.Nome }).IsUnique();
});
modelBuilder.Entity<Aparelho>(e =>
{
e.Property(x => x.Nome).HasMaxLength(160);
e.Property(x => x.Fabricante).HasMaxLength(120);
e.Property(x => x.Cor).HasMaxLength(80);
e.Property(x => x.Imei).HasMaxLength(80);
e.Property(x => x.NotaFiscalArquivoPath).HasMaxLength(500);
e.Property(x => x.ReciboArquivoPath).HasMaxLength(500);
e.HasIndex(x => x.TenantId);
e.HasIndex(x => x.Imei);
e.HasIndex(x => new { x.TenantId, x.Nome, x.Cor });
e.HasIndex(x => new { x.TenantId, x.Fabricante });
});
// ========================= // =========================
// ✅ USER (Identity) // ✅ USER (Identity)
// ========================= // =========================
@ -104,13 +132,27 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
{ {
// Mantém UNIQUE por Linha por tenant (se Linha puder ser null no banco, Postgres aceita múltiplos nulls) // Mantém UNIQUE por Linha por tenant (se Linha puder ser null no banco, Postgres aceita múltiplos nulls)
e.HasIndex(x => new { x.TenantId, x.Linha }).IsUnique(); e.HasIndex(x => new { x.TenantId, x.Linha }).IsUnique();
e.Property(x => x.CentroDeCustos).HasMaxLength(180);
// performance // performance
e.HasIndex(x => x.Chip); e.HasIndex(x => x.Chip);
e.HasIndex(x => x.Cliente); e.HasIndex(x => x.Cliente);
e.HasIndex(x => x.Usuario); e.HasIndex(x => x.Usuario);
e.HasIndex(x => x.CentroDeCustos);
e.HasIndex(x => x.Skil); e.HasIndex(x => x.Skil);
e.HasIndex(x => x.Status); e.HasIndex(x => x.Status);
e.HasIndex(x => x.SetorId);
e.HasIndex(x => x.AparelhoId);
e.HasOne(x => x.Setor)
.WithMany(x => x.MobileLines)
.HasForeignKey(x => x.SetorId)
.OnDelete(DeleteBehavior.SetNull);
e.HasOne(x => x.Aparelho)
.WithMany(x => x.MobileLines)
.HasForeignKey(x => x.AparelhoId)
.OnDelete(DeleteBehavior.SetNull);
}); });
// ========================= // =========================
@ -246,6 +288,26 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<SolicitacaoLinha>(e =>
{
e.HasIndex(x => x.TenantId);
e.HasIndex(x => x.CreatedAt);
e.HasIndex(x => x.TipoSolicitacao);
e.HasIndex(x => x.Status);
e.HasIndex(x => x.MobileLineId);
e.HasIndex(x => x.SolicitanteUserId);
e.HasOne(x => x.MobileLine)
.WithMany()
.HasForeignKey(x => x.MobileLineId)
.OnDelete(DeleteBehavior.SetNull);
e.HasOne(x => x.SolicitanteUser)
.WithMany()
.HasForeignKey(x => x.SolicitanteUserId)
.OnDelete(DeleteBehavior.SetNull);
});
// ========================= // =========================
// ✅ PARCELAMENTOS // ✅ PARCELAMENTOS
// ========================= // =========================
@ -334,8 +396,52 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity<MveAuditRun>(e =>
{
e.Property(x => x.FileName).HasMaxLength(260);
e.Property(x => x.FileHashSha256).HasMaxLength(64);
e.Property(x => x.FileEncoding).HasMaxLength(40);
e.Property(x => x.Status).HasMaxLength(40);
e.Property(x => x.AppliedByUserName).HasMaxLength(200);
e.Property(x => x.AppliedByUserEmail).HasMaxLength(200);
e.HasIndex(x => x.TenantId);
e.HasIndex(x => x.ImportedAtUtc);
e.HasIndex(x => x.Status);
});
modelBuilder.Entity<MveAuditIssue>(e =>
{
e.Property(x => x.NumeroLinha).HasMaxLength(64);
e.Property(x => x.IssueType).HasMaxLength(60);
e.Property(x => x.Situation).HasMaxLength(80);
e.Property(x => x.Severity).HasMaxLength(40);
e.Property(x => x.ActionSuggestion).HasMaxLength(160);
e.Property(x => x.Notes).HasMaxLength(500);
e.Property(x => x.SystemStatus).HasMaxLength(120);
e.Property(x => x.ReportStatus).HasMaxLength(120);
e.Property(x => x.SystemPlan).HasMaxLength(200);
e.Property(x => x.ReportPlan).HasMaxLength(200);
e.Property(x => x.SystemSnapshotJson).HasColumnType("jsonb");
e.Property(x => x.ReportSnapshotJson).HasColumnType("jsonb");
e.Property(x => x.DifferencesJson).HasColumnType("jsonb");
e.HasIndex(x => x.TenantId);
e.HasIndex(x => x.AuditRunId);
e.HasIndex(x => x.IssueType);
e.HasIndex(x => x.NumeroLinha);
e.HasIndex(x => x.Syncable);
e.HasOne(x => x.AuditRun)
.WithMany(x => x.Issues)
.HasForeignKey(x => x.AuditRunId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<MobileLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<MobileLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<MuregLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<MuregLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<Setor>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<Aparelho>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<BillingClient>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<BillingClient>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<UserData>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<UserData>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<VigenciaLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<VigenciaLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
@ -343,6 +449,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
modelBuilder.Entity<ChipVirgemLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ChipVirgemLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ControleRecebidoLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ControleRecebidoLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<Notification>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<Notification>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<SolicitacaoLinha>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoMacrophonyPlan>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ResumoMacrophonyPlan>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoMacrophonyTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ResumoMacrophonyTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoVivoLineResumo>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ResumoVivoLineResumo>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
@ -360,6 +467,8 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
modelBuilder.Entity<AuditLog>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<AuditLog>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ImportAuditRun>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ImportAuditRun>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ImportAuditIssue>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ImportAuditIssue>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<MveAuditRun>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<MveAuditIssue>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ApplicationUser>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ApplicationUser>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
} }

View File

@ -12,6 +12,13 @@ namespace line_gestao_api.Dtos
public string? Chip { get; set; } // ICCID public string? Chip { get; set; } // ICCID
public string? Cliente { get; set; } // Obrigatório na validação do Controller public string? Cliente { get; set; } // Obrigatório na validação do Controller
public string? Usuario { get; set; } public string? Usuario { get; set; }
public string? CentroDeCustos { get; set; }
public Guid? SetorId { get; set; }
public string? SetorNome { get; set; }
public Guid? AparelhoId { get; set; }
public string? AparelhoNome { get; set; }
public string? AparelhoCor { get; set; }
public string? AparelhoImei { get; set; }
public Guid? ReservaLineId { get; set; } // Reaproveita linha já existente na Reserva public Guid? ReservaLineId { get; set; } // Reaproveita linha já existente na Reserva
// ========================== // ==========================

View File

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

View File

@ -71,4 +71,42 @@ namespace line_gestao_api.Dtos
public bool Success { get; set; } public bool Success { get; set; }
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
} }
public sealed class BatchLineStatusUpdateRequestDto
{
// "block" | "unblock"
public string? Action { get; set; }
public string? BlockStatus { get; set; }
public bool ApplyToAllFiltered { get; set; }
public List<Guid> LineIds { get; set; } = new();
// Filtros da tela Geral
public string? Search { get; set; }
public string? Skil { get; set; }
public List<string> Clients { get; set; } = new();
public string? AdditionalMode { get; set; }
public string? AdditionalServices { get; set; }
public string? Usuario { get; set; }
}
public sealed class BatchLineStatusUpdateResultDto
{
public int Requested { get; set; }
public int Updated { get; set; }
public int Failed { get; set; }
public List<BatchLineStatusUpdateItemResultDto> Items { get; set; } = new();
}
public sealed class BatchLineStatusUpdateItemResultDto
{
public Guid Id { get; set; }
public int Item { get; set; }
public string? Linha { get; set; }
public string? Usuario { get; set; }
public string? StatusAnterior { get; set; }
public string? StatusNovo { get; set; }
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
}
} }

View File

@ -9,11 +9,18 @@
public string? Chip { get; set; } public string? Chip { get; set; }
public string? Cliente { get; set; } public string? Cliente { get; set; }
public string? Usuario { 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? PlanoContrato { get; set; }
public string? Status { get; set; } public string? Status { get; set; }
public string? Skil { get; set; } public string? Skil { get; set; }
public string? Modalidade { get; set; } public string? Modalidade { get; set; }
public string? VencConta { get; set; } public string? VencConta { get; set; }
public string? ContaEmpresa { get; set; }
public string? Operadora { get; set; }
public decimal? FranquiaVivo { get; set; }
public decimal? FranquiaLine { get; set; } public decimal? FranquiaLine { get; set; }
// Campos para filtro deterministico de adicionais no frontend // Campos para filtro deterministico de adicionais no frontend
@ -30,11 +37,21 @@
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public int Item { get; set; } public int Item { get; set; }
public string? ContaEmpresa { get; set; }
public string? Conta { get; set; } public string? Conta { get; set; }
public string? Linha { get; set; } public string? Linha { get; set; }
public string? Chip { get; set; } public string? Chip { get; set; }
public string? Cliente { get; set; } public string? Cliente { get; set; }
public string? Usuario { get; set; } public string? Usuario { get; set; }
public string? CentroDeCustos { get; set; }
public Guid? SetorId { get; set; }
public string? SetorNome { get; set; }
public Guid? AparelhoId { get; set; }
public string? AparelhoNome { get; set; }
public string? AparelhoCor { get; set; }
public string? AparelhoImei { get; set; }
public bool AparelhoNotaFiscalTemArquivo { get; set; }
public bool AparelhoReciboTemArquivo { get; set; }
public string? PlanoContrato { get; set; } public string? PlanoContrato { get; set; }
public decimal? FranquiaVivo { get; set; } public decimal? FranquiaVivo { get; set; }
@ -78,6 +95,13 @@
public string? Chip { get; set; } public string? Chip { get; set; }
public string? Cliente { get; set; } public string? Cliente { get; set; }
public string? Usuario { get; set; } public string? Usuario { get; set; }
public string? CentroDeCustos { get; set; }
public Guid? SetorId { get; set; }
public string? SetorNome { get; set; }
public Guid? AparelhoId { get; set; }
public string? AparelhoNome { get; set; }
public string? AparelhoCor { get; set; }
public string? AparelhoImei { get; set; }
public string? PlanoContrato { get; set; } public string? PlanoContrato { get; set; }
public decimal? FranquiaVivo { get; set; } public decimal? FranquiaVivo { get; set; }

158
Dtos/MveAuditDtos.cs Normal file
View File

@ -0,0 +1,158 @@
namespace line_gestao_api.Dtos;
public class MveAuditRunDto
{
public Guid Id { get; set; }
public string? FileName { get; set; }
public string? FileEncoding { get; set; }
public string Status { get; set; } = string.Empty;
public DateTime ImportedAtUtc { get; set; }
public DateTime? AppliedAtUtc { get; set; }
public string? AppliedByUserName { get; set; }
public string? AppliedByUserEmail { get; set; }
public MveAuditSummaryDto Summary { get; set; } = new();
public List<MveAuditIssueDto> Issues { get; set; } = new();
}
public class MveAuditSummaryDto
{
public int TotalSystemLines { get; set; }
public int TotalReportLines { get; set; }
public int TotalConciliated { get; set; }
public int TotalStatusDivergences { get; set; }
public int TotalDataDivergences { get; set; }
public int TotalOnlyInSystem { get; set; }
public int TotalOnlyInReport { get; set; }
public int TotalDuplicateReportLines { get; set; }
public int TotalDuplicateSystemLines { get; set; }
public int TotalInvalidRows { get; set; }
public int TotalUnknownStatuses { get; set; }
public int TotalSyncableIssues { get; set; }
public int AppliedIssuesCount { get; set; }
public int AppliedLinesCount { get; set; }
public int AppliedFieldsCount { get; set; }
}
public class MveAuditIssueDto
{
public Guid Id { get; set; }
public int? SourceRowNumber { get; set; }
public string NumeroLinha { get; set; } = string.Empty;
public Guid? MobileLineId { get; set; }
public int? SystemItem { get; set; }
public string IssueType { get; set; } = string.Empty;
public string Situation { get; set; } = string.Empty;
public string Severity { get; set; } = "INFO";
public bool Syncable { get; set; }
public bool Applied { get; set; }
public string? ActionSuggestion { get; set; }
public string? Notes { get; set; }
public string? SystemStatus { get; set; }
public string? ReportStatus { get; set; }
public string? SystemPlan { get; set; }
public string? ReportPlan { get; set; }
public MveAuditSnapshotDto? SystemSnapshot { get; set; }
public MveAuditSnapshotDto? ReportSnapshot { get; set; }
public List<MveAuditDifferenceDto> Differences { get; set; } = new();
}
public class MveAuditSnapshotDto
{
public string? NumeroLinha { get; set; }
public string? StatusLinha { get; set; }
public string? StatusConta { get; set; }
public string? PlanoLinha { get; set; }
public DateTime? DataAtivacao { get; set; }
public DateTime? TerminoContrato { get; set; }
public string? Chip { get; set; }
public string? Conta { get; set; }
public string? Cnpj { get; set; }
public string? ModeloAparelho { get; set; }
public string? Fabricante { get; set; }
public List<string> ServicosAtivos { get; set; } = new();
}
public class MveAuditDifferenceDto
{
public string FieldKey { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string? SystemValue { get; set; }
public string? ReportValue { get; set; }
public bool Syncable { get; set; }
}
public class ApplyMveAuditRequestDto
{
public List<Guid>? IssueIds { get; set; }
}
public class ApplyMveAuditResultDto
{
public Guid AuditRunId { get; set; }
public int RequestedIssues { get; set; }
public int AppliedIssues { get; set; }
public int UpdatedLines { get; set; }
public int UpdatedFields { get; set; }
public int SkippedIssues { get; set; }
}

View File

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

View File

@ -0,0 +1,25 @@
namespace line_gestao_api.Dtos;
public class CreateSolicitacaoLinhaRequestDto
{
public Guid LineId { get; set; }
public string TipoSolicitacao { get; set; } = string.Empty;
public decimal? FranquiaLineNova { get; set; }
}
public class SolicitacaoLinhaListDto
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string? TenantNome { get; set; }
public Guid? MobileLineId { get; set; }
public string? Linha { get; set; }
public string? UsuarioLinha { get; set; }
public string TipoSolicitacao { get; set; } = string.Empty;
public decimal? FranquiaLineAtual { get; set; }
public decimal? FranquiaLineNova { get; set; }
public string? SolicitanteNome { get; set; }
public string Mensagem { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}

View File

@ -0,0 +1,114 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using line_gestao_api.Data;
#nullable disable
namespace line_gestao_api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260303120000_AddSetoresAparelhosAndMobileLineCostCenter")]
public partial class AddSetoresAparelhosAndMobileLineCostCenter : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
CREATE TABLE IF NOT EXISTS "Setores" (
"Id" uuid NOT NULL,
"TenantId" uuid NOT NULL,
"Nome" character varying(160) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"UpdatedAt" timestamp with time zone NOT NULL,
CONSTRAINT "PK_Setores" PRIMARY KEY ("Id")
);
""");
migrationBuilder.Sql("""
CREATE TABLE IF NOT EXISTS "Aparelhos" (
"Id" uuid NOT NULL,
"TenantId" uuid NOT NULL,
"Nome" character varying(160) NULL,
"Cor" character varying(80) NULL,
"Imei" character varying(80) NULL,
"NotaFiscalArquivoPath" character varying(500) NULL,
"ReciboArquivoPath" character varying(500) NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"UpdatedAt" timestamp with time zone NOT NULL,
CONSTRAINT "PK_Aparelhos" PRIMARY KEY ("Id")
);
""");
migrationBuilder.Sql("""ALTER TABLE "MobileLines" ADD COLUMN IF NOT EXISTS "CentroDeCustos" character varying(180) NULL;""");
migrationBuilder.Sql("""ALTER TABLE "MobileLines" ADD COLUMN IF NOT EXISTS "SetorId" uuid NULL;""");
migrationBuilder.Sql("""ALTER TABLE "MobileLines" ADD COLUMN IF NOT EXISTS "AparelhoId" uuid NULL;""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Setores_TenantId" ON "Setores" ("TenantId");""");
migrationBuilder.Sql("""CREATE UNIQUE INDEX IF NOT EXISTS "IX_Setores_TenantId_Nome" ON "Setores" ("TenantId", "Nome");""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Aparelhos_TenantId" ON "Aparelhos" ("TenantId");""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Aparelhos_Imei" ON "Aparelhos" ("Imei");""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Aparelhos_TenantId_Nome_Cor" ON "Aparelhos" ("TenantId", "Nome", "Cor");""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_MobileLines_CentroDeCustos" ON "MobileLines" ("CentroDeCustos");""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_MobileLines_SetorId" ON "MobileLines" ("SetorId");""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_MobileLines_AparelhoId" ON "MobileLines" ("AparelhoId");""");
migrationBuilder.Sql("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_name = 'FK_MobileLines_Setores_SetorId'
AND table_name = 'MobileLines'
) THEN
ALTER TABLE "MobileLines"
ADD CONSTRAINT "FK_MobileLines_Setores_SetorId"
FOREIGN KEY ("SetorId") REFERENCES "Setores" ("Id")
ON DELETE SET NULL;
END IF;
END
$$;
""");
migrationBuilder.Sql("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_name = 'FK_MobileLines_Aparelhos_AparelhoId'
AND table_name = 'MobileLines'
) THEN
ALTER TABLE "MobileLines"
ADD CONSTRAINT "FK_MobileLines_Aparelhos_AparelhoId"
FOREIGN KEY ("AparelhoId") REFERENCES "Aparelhos" ("Id")
ON DELETE SET NULL;
END IF;
END
$$;
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""ALTER TABLE "MobileLines" DROP CONSTRAINT IF EXISTS "FK_MobileLines_Setores_SetorId";""");
migrationBuilder.Sql("""ALTER TABLE "MobileLines" DROP CONSTRAINT IF EXISTS "FK_MobileLines_Aparelhos_AparelhoId";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_MobileLines_AparelhoId";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_MobileLines_SetorId";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_MobileLines_CentroDeCustos";""");
migrationBuilder.Sql("""ALTER TABLE "MobileLines" DROP COLUMN IF EXISTS "AparelhoId";""");
migrationBuilder.Sql("""ALTER TABLE "MobileLines" DROP COLUMN IF EXISTS "SetorId";""");
migrationBuilder.Sql("""ALTER TABLE "MobileLines" DROP COLUMN IF EXISTS "CentroDeCustos";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Aparelhos_TenantId_Nome_Cor";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Aparelhos_Imei";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Aparelhos_TenantId";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Setores_TenantId_Nome";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Setores_TenantId";""");
migrationBuilder.Sql("""DROP TABLE IF EXISTS "Aparelhos";""");
migrationBuilder.Sql("""DROP TABLE IF EXISTS "Setores";""");
}
}
}

View File

@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using line_gestao_api.Data;
#nullable disable
namespace line_gestao_api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260303193000_FixAparelhosArquivoPathColumns")]
public partial class FixAparelhosArquivoPathColumns : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE "Aparelhos"
ADD COLUMN IF NOT EXISTS "NotaFiscalArquivoPath" character varying(500) NULL;
""");
migrationBuilder.Sql("""
ALTER TABLE "Aparelhos"
ADD COLUMN IF NOT EXISTS "ReciboArquivoPath" character varying(500) NULL;
""");
// Backfill seguro para bancos que já tinham os campos antigos de URL/nome.
migrationBuilder.Sql("""
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'Aparelhos'
AND column_name = 'NotaFiscalAnexoUrl'
) THEN
UPDATE "Aparelhos"
SET "NotaFiscalArquivoPath" = COALESCE("NotaFiscalArquivoPath", "NotaFiscalAnexoUrl")
WHERE "NotaFiscalAnexoUrl" IS NOT NULL
AND "NotaFiscalAnexoUrl" <> '';
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'Aparelhos'
AND column_name = 'ReciboAnexoUrl'
) THEN
UPDATE "Aparelhos"
SET "ReciboArquivoPath" = COALESCE("ReciboArquivoPath", "ReciboAnexoUrl")
WHERE "ReciboAnexoUrl" IS NOT NULL
AND "ReciboAnexoUrl" <> '';
END IF;
END
$$;
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""ALTER TABLE "Aparelhos" DROP COLUMN IF EXISTS "ReciboArquivoPath";""");
migrationBuilder.Sql("""ALTER TABLE "Aparelhos" DROP COLUMN IF EXISTS "NotaFiscalArquivoPath";""");
}
}
}

View File

@ -0,0 +1,59 @@
using line_gestao_api.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace line_gestao_api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260305193000_AddSolicitacaoLinhas")]
public class AddSolicitacaoLinhas : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
CREATE TABLE IF NOT EXISTS "SolicitacaoLinhas" (
"Id" uuid NOT NULL,
"TenantId" uuid NOT NULL,
"MobileLineId" uuid NULL,
"Linha" character varying(30) NULL,
"UsuarioLinha" character varying(200) NULL,
"TipoSolicitacao" character varying(60) NOT NULL,
"FranquiaLineAtual" numeric NULL,
"FranquiaLineNova" numeric NULL,
"SolicitanteUserId" uuid NULL,
"SolicitanteNome" character varying(200) NULL,
"Mensagem" character varying(1000) NOT NULL,
"Status" character varying(30) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
CONSTRAINT "PK_SolicitacaoLinhas" PRIMARY KEY ("Id"),
CONSTRAINT "FK_SolicitacaoLinhas_AspNetUsers_SolicitanteUserId"
FOREIGN KEY ("SolicitanteUserId") REFERENCES "AspNetUsers" ("Id")
ON DELETE SET NULL,
CONSTRAINT "FK_SolicitacaoLinhas_MobileLines_MobileLineId"
FOREIGN KEY ("MobileLineId") REFERENCES "MobileLines" ("Id")
ON DELETE SET NULL
);
""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_SolicitacaoLinhas_TenantId" ON "SolicitacaoLinhas" ("TenantId");""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_SolicitacaoLinhas_CreatedAt" ON "SolicitacaoLinhas" ("CreatedAt");""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_SolicitacaoLinhas_TipoSolicitacao" ON "SolicitacaoLinhas" ("TipoSolicitacao");""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_SolicitacaoLinhas_Status" ON "SolicitacaoLinhas" ("Status");""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_SolicitacaoLinhas_MobileLineId" ON "SolicitacaoLinhas" ("MobileLineId");""");
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_SolicitacaoLinhas_SolicitanteUserId" ON "SolicitacaoLinhas" ("SolicitanteUserId");""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_SolicitacaoLinhas_SolicitanteUserId";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_SolicitacaoLinhas_MobileLineId";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_SolicitacaoLinhas_Status";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_SolicitacaoLinhas_TipoSolicitacao";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_SolicitacaoLinhas_CreatedAt";""");
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_SolicitacaoLinhas_TenantId";""");
migrationBuilder.Sql("""DROP TABLE IF EXISTS "SolicitacaoLinhas";""");
}
}
}

View File

@ -0,0 +1,160 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace line_gestao_api.Migrations
{
/// <inheritdoc />
public partial class AddMveAuditHistoryAndAparelhoFabricante : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Fabricante",
table: "Aparelhos",
type: "character varying(120)",
maxLength: 120,
nullable: true);
migrationBuilder.CreateTable(
name: "MveAuditRuns",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: true),
FileHashSha256 = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
FileEncoding = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: true),
Status = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
TotalSystemLines = table.Column<int>(type: "integer", nullable: false),
TotalReportLines = table.Column<int>(type: "integer", nullable: false),
TotalConciliated = table.Column<int>(type: "integer", nullable: false),
TotalStatusDivergences = table.Column<int>(type: "integer", nullable: false),
TotalDataDivergences = table.Column<int>(type: "integer", nullable: false),
TotalOnlyInSystem = table.Column<int>(type: "integer", nullable: false),
TotalOnlyInReport = table.Column<int>(type: "integer", nullable: false),
TotalDuplicateReportLines = table.Column<int>(type: "integer", nullable: false),
TotalDuplicateSystemLines = table.Column<int>(type: "integer", nullable: false),
TotalInvalidRows = table.Column<int>(type: "integer", nullable: false),
TotalUnknownStatuses = table.Column<int>(type: "integer", nullable: false),
TotalSyncableIssues = table.Column<int>(type: "integer", nullable: false),
AppliedIssuesCount = table.Column<int>(type: "integer", nullable: false),
AppliedLinesCount = table.Column<int>(type: "integer", nullable: false),
AppliedFieldsCount = table.Column<int>(type: "integer", nullable: false),
ImportedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
AppliedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
AppliedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
AppliedByUserName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
AppliedByUserEmail = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MveAuditRuns", x => x.Id);
});
migrationBuilder.CreateTable(
name: "MveAuditIssues",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
AuditRunId = table.Column<Guid>(type: "uuid", nullable: false),
SourceRowNumber = table.Column<int>(type: "integer", nullable: true),
NumeroLinha = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
MobileLineId = table.Column<Guid>(type: "uuid", nullable: true),
SystemItem = table.Column<int>(type: "integer", nullable: true),
IssueType = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
Situation = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
Severity = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
Syncable = table.Column<bool>(type: "boolean", nullable: false),
Applied = table.Column<bool>(type: "boolean", nullable: false),
AppliedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ActionSuggestion = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
Notes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
SystemStatus = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
ReportStatus = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
SystemPlan = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
ReportPlan = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SystemSnapshotJson = table.Column<string>(type: "jsonb", nullable: false),
ReportSnapshotJson = table.Column<string>(type: "jsonb", nullable: false),
DifferencesJson = table.Column<string>(type: "jsonb", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MveAuditIssues", x => x.Id);
table.ForeignKey(
name: "FK_MveAuditIssues_MveAuditRuns_AuditRunId",
column: x => x.AuditRunId,
principalTable: "MveAuditRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Aparelhos_TenantId_Fabricante",
table: "Aparelhos",
columns: new[] { "TenantId", "Fabricante" });
migrationBuilder.CreateIndex(
name: "IX_MveAuditIssues_AuditRunId",
table: "MveAuditIssues",
column: "AuditRunId");
migrationBuilder.CreateIndex(
name: "IX_MveAuditIssues_IssueType",
table: "MveAuditIssues",
column: "IssueType");
migrationBuilder.CreateIndex(
name: "IX_MveAuditIssues_NumeroLinha",
table: "MveAuditIssues",
column: "NumeroLinha");
migrationBuilder.CreateIndex(
name: "IX_MveAuditIssues_Syncable",
table: "MveAuditIssues",
column: "Syncable");
migrationBuilder.CreateIndex(
name: "IX_MveAuditIssues_TenantId",
table: "MveAuditIssues",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_MveAuditRuns_ImportedAtUtc",
table: "MveAuditRuns",
column: "ImportedAtUtc");
migrationBuilder.CreateIndex(
name: "IX_MveAuditRuns_Status",
table: "MveAuditRuns",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_MveAuditRuns_TenantId",
table: "MveAuditRuns",
column: "TenantId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MveAuditIssues");
migrationBuilder.DropTable(
name: "MveAuditRuns");
migrationBuilder.DropIndex(
name: "IX_Aparelhos_TenantId_Fabricante",
table: "Aparelhos");
migrationBuilder.DropColumn(
name: "Fabricante",
table: "Aparelhos");
}
}
}

View File

@ -330,6 +330,58 @@ namespace line_gestao_api.Migrations
b.ToTable("AuditLogs"); b.ToTable("AuditLogs");
}); });
modelBuilder.Entity("line_gestao_api.Models.Aparelho", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Cor")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Fabricante")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Imei")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Nome")
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("NotaFiscalArquivoPath")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ReciboArquivoPath")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Imei");
b.HasIndex("TenantId");
b.HasIndex("TenantId", "Fabricante");
b.HasIndex("TenantId", "Nome", "Cor");
b.ToTable("Aparelhos");
});
modelBuilder.Entity("line_gestao_api.Models.BillingClient", b => modelBuilder.Entity("line_gestao_api.Models.BillingClient", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -618,16 +670,226 @@ namespace line_gestao_api.Migrations
b.ToTable("ImportAuditRuns"); b.ToTable("ImportAuditRuns");
}); });
modelBuilder.Entity("line_gestao_api.Models.MveAuditIssue", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuditRunId")
.HasColumnType("uuid");
b.Property<string>("ActionSuggestion")
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<bool>("Applied")
.HasColumnType("boolean");
b.Property<DateTime?>("AppliedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DifferencesJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("IssueType")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<Guid?>("MobileLineId")
.HasColumnType("uuid");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("NumeroLinha")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ReportPlan")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ReportSnapshotJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("ReportStatus")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<int?>("SourceRowNumber")
.HasColumnType("integer");
b.Property<string>("Situation")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<bool>("Syncable")
.HasColumnType("boolean");
b.Property<string>("SystemPlan")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("SystemItem")
.HasColumnType("integer");
b.Property<string>("SystemSnapshotJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("SystemStatus")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AuditRunId");
b.HasIndex("IssueType");
b.HasIndex("NumeroLinha");
b.HasIndex("Syncable");
b.HasIndex("TenantId");
b.ToTable("MveAuditIssues");
});
modelBuilder.Entity("line_gestao_api.Models.MveAuditRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AppliedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("AppliedByUserEmail")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("AppliedByUserId")
.HasColumnType("uuid");
b.Property<string>("AppliedByUserName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("AppliedFieldsCount")
.HasColumnType("integer");
b.Property<int>("AppliedIssuesCount")
.HasColumnType("integer");
b.Property<int>("AppliedLinesCount")
.HasColumnType("integer");
b.Property<string>("FileEncoding")
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("FileHashSha256")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("FileName")
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<DateTime>("ImportedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<int>("TotalConciliated")
.HasColumnType("integer");
b.Property<int>("TotalDataDivergences")
.HasColumnType("integer");
b.Property<int>("TotalDuplicateReportLines")
.HasColumnType("integer");
b.Property<int>("TotalDuplicateSystemLines")
.HasColumnType("integer");
b.Property<int>("TotalInvalidRows")
.HasColumnType("integer");
b.Property<int>("TotalOnlyInReport")
.HasColumnType("integer");
b.Property<int>("TotalOnlyInSystem")
.HasColumnType("integer");
b.Property<int>("TotalReportLines")
.HasColumnType("integer");
b.Property<int>("TotalStatusDivergences")
.HasColumnType("integer");
b.Property<int>("TotalSyncableIssues")
.HasColumnType("integer");
b.Property<int>("TotalSystemLines")
.HasColumnType("integer");
b.Property<int>("TotalUnknownStatuses")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ImportedAtUtc");
b.HasIndex("Status");
b.HasIndex("TenantId");
b.ToTable("MveAuditRuns");
});
modelBuilder.Entity("line_gestao_api.Models.MobileLine", b => modelBuilder.Entity("line_gestao_api.Models.MobileLine", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid?>("AparelhoId")
.HasColumnType("uuid");
b.Property<string>("Cedente") b.Property<string>("Cedente")
.HasMaxLength(150) .HasMaxLength(150)
.HasColumnType("character varying(150)"); .HasColumnType("character varying(150)");
b.Property<string>("CentroDeCustos")
.HasMaxLength(180)
.HasColumnType("character varying(180)");
b.Property<string>("Chip") b.Property<string>("Chip")
.HasMaxLength(40) .HasMaxLength(40)
.HasColumnType("character varying(40)"); .HasColumnType("character varying(40)");
@ -691,6 +953,9 @@ namespace line_gestao_api.Migrations
b.Property<decimal?>("Skeelo") b.Property<decimal?>("Skeelo")
.HasColumnType("numeric"); .HasColumnType("numeric");
b.Property<Guid?>("SetorId")
.HasColumnType("uuid");
b.Property<string>("Skil") b.Property<string>("Skil")
.HasMaxLength(80) .HasMaxLength(80)
.HasColumnType("character varying(80)"); .HasColumnType("character varying(80)");
@ -744,10 +1009,16 @@ namespace line_gestao_api.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AparelhoId");
b.HasIndex("Chip"); b.HasIndex("Chip");
b.HasIndex("CentroDeCustos");
b.HasIndex("Cliente"); b.HasIndex("Cliente");
b.HasIndex("SetorId");
b.HasIndex("Skil"); b.HasIndex("Skil");
b.HasIndex("Status"); b.HasIndex("Status");
@ -888,6 +1159,74 @@ namespace line_gestao_api.Migrations
b.ToTable("Notifications"); b.ToTable("Notifications");
}); });
modelBuilder.Entity("line_gestao_api.Models.SolicitacaoLinha", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("FranquiaLineAtual")
.HasColumnType("numeric");
b.Property<decimal?>("FranquiaLineNova")
.HasColumnType("numeric");
b.Property<string>("Linha")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("Mensagem")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("MobileLineId")
.HasColumnType("uuid");
b.Property<string>("SolicitanteNome")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("SolicitanteUserId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("TipoSolicitacao")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("UsuarioLinha")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("MobileLineId");
b.HasIndex("SolicitanteUserId");
b.HasIndex("Status");
b.HasIndex("TenantId");
b.HasIndex("TipoSolicitacao");
b.ToTable("SolicitacaoLinhas");
});
modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b => modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1370,6 +1709,36 @@ namespace line_gestao_api.Migrations
b.ToTable("ResumoVivoLineTotals"); b.ToTable("ResumoVivoLineTotals");
}); });
modelBuilder.Entity("line_gestao_api.Models.Setor", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Nome")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("TenantId", "Nome")
.IsUnique();
b.ToTable("Setores");
});
modelBuilder.Entity("line_gestao_api.Models.Tenant", b => modelBuilder.Entity("line_gestao_api.Models.Tenant", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1665,6 +2034,23 @@ namespace line_gestao_api.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("line_gestao_api.Models.MobileLine", b =>
{
b.HasOne("line_gestao_api.Models.Aparelho", "Aparelho")
.WithMany("MobileLines")
.HasForeignKey("AparelhoId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("line_gestao_api.Models.Setor", "Setor")
.WithMany("MobileLines")
.HasForeignKey("SetorId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Aparelho");
b.Navigation("Setor");
});
modelBuilder.Entity("line_gestao_api.Models.MuregLine", b => modelBuilder.Entity("line_gestao_api.Models.MuregLine", b =>
{ {
b.HasOne("line_gestao_api.Models.MobileLine", "MobileLine") b.HasOne("line_gestao_api.Models.MobileLine", "MobileLine")
@ -1687,6 +2073,17 @@ namespace line_gestao_api.Migrations
b.Navigation("AuditRun"); b.Navigation("AuditRun");
}); });
modelBuilder.Entity("line_gestao_api.Models.MveAuditIssue", b =>
{
b.HasOne("line_gestao_api.Models.MveAuditRun", "AuditRun")
.WithMany("Issues")
.HasForeignKey("AuditRunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AuditRun");
});
modelBuilder.Entity("line_gestao_api.Models.Notification", b => modelBuilder.Entity("line_gestao_api.Models.Notification", b =>
{ {
b.HasOne("line_gestao_api.Models.ApplicationUser", "User") b.HasOne("line_gestao_api.Models.ApplicationUser", "User")
@ -1704,6 +2101,23 @@ namespace line_gestao_api.Migrations
b.Navigation("VigenciaLine"); b.Navigation("VigenciaLine");
}); });
modelBuilder.Entity("line_gestao_api.Models.SolicitacaoLinha", b =>
{
b.HasOne("line_gestao_api.Models.MobileLine", "MobileLine")
.WithMany()
.HasForeignKey("MobileLineId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("line_gestao_api.Models.ApplicationUser", "SolicitanteUser")
.WithMany()
.HasForeignKey("SolicitanteUserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("MobileLine");
b.Navigation("SolicitanteUser");
});
modelBuilder.Entity("line_gestao_api.Models.ParcelamentoMonthValue", b => modelBuilder.Entity("line_gestao_api.Models.ParcelamentoMonthValue", b =>
{ {
b.HasOne("line_gestao_api.Models.ParcelamentoLine", "ParcelamentoLine") b.HasOne("line_gestao_api.Models.ParcelamentoLine", "ParcelamentoLine")
@ -1715,6 +2129,11 @@ namespace line_gestao_api.Migrations
b.Navigation("ParcelamentoLine"); b.Navigation("ParcelamentoLine");
}); });
modelBuilder.Entity("line_gestao_api.Models.Aparelho", b =>
{
b.Navigation("MobileLines");
});
modelBuilder.Entity("line_gestao_api.Models.MobileLine", b => modelBuilder.Entity("line_gestao_api.Models.MobileLine", b =>
{ {
b.Navigation("Muregs"); b.Navigation("Muregs");
@ -1725,10 +2144,20 @@ namespace line_gestao_api.Migrations
b.Navigation("Issues"); b.Navigation("Issues");
}); });
modelBuilder.Entity("line_gestao_api.Models.MveAuditRun", b =>
{
b.Navigation("Issues");
});
modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b => modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b =>
{ {
b.Navigation("MonthValues"); b.Navigation("MonthValues");
}); });
modelBuilder.Entity("line_gestao_api.Models.Setor", b =>
{
b.Navigation("MobileLines");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

33
Models/Aparelho.cs Normal file
View File

@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace line_gestao_api.Models;
public class Aparelho : ITenantEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
[MaxLength(160)]
public string? Nome { get; set; }
[MaxLength(120)]
public string? Fabricante { get; set; }
[MaxLength(80)]
public string? Cor { get; set; }
[MaxLength(80)]
public string? Imei { get; set; }
[MaxLength(500)]
public string? NotaFiscalArquivoPath { get; set; }
[MaxLength(500)]
public string? ReciboArquivoPath { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<MobileLine> MobileLines { get; set; } = new List<MobileLine>();
}

View File

@ -20,6 +20,15 @@ namespace line_gestao_api.Models
public string? Cliente { get; set; } public string? Cliente { get; set; }
[MaxLength(200)] [MaxLength(200)]
public string? Usuario { get; set; } public string? Usuario { get; set; }
[MaxLength(180)]
public string? CentroDeCustos { get; set; }
public Guid? SetorId { get; set; }
public Setor? Setor { get; set; }
public Guid? AparelhoId { get; set; }
public Aparelho? Aparelho { get; set; }
[MaxLength(200)] [MaxLength(200)]
public string? PlanoContrato { get; set; } public string? PlanoContrato { get; set; }
@ -66,7 +75,6 @@ namespace line_gestao_api.Models
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// ✅ Navegação (1 MobileLine -> N Muregs)
public ICollection<MuregLine> Muregs { get; set; } = new List<MuregLine>(); public ICollection<MuregLine> Muregs { get; set; } = new List<MuregLine>();
} }
} }

52
Models/MveAuditIssue.cs Normal file
View File

@ -0,0 +1,52 @@
namespace line_gestao_api.Models;
public class MveAuditIssue : ITenantEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public Guid AuditRunId { get; set; }
public MveAuditRun? AuditRun { get; set; }
public int? SourceRowNumber { get; set; }
public string NumeroLinha { get; set; } = string.Empty;
public Guid? MobileLineId { get; set; }
public int? SystemItem { get; set; }
public string IssueType { get; set; } = string.Empty;
public string Situation { get; set; } = string.Empty;
public string Severity { get; set; } = "INFO";
public bool Syncable { get; set; }
public bool Applied { get; set; }
public DateTime? AppliedAtUtc { get; set; }
public string? ActionSuggestion { get; set; }
public string? Notes { get; set; }
public string? SystemStatus { get; set; }
public string? ReportStatus { get; set; }
public string? SystemPlan { get; set; }
public string? ReportPlan { get; set; }
public string SystemSnapshotJson { get; set; } = "{}";
public string ReportSnapshotJson { get; set; } = "{}";
public string DifferencesJson { get; set; } = "[]";
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}

58
Models/MveAuditRun.cs Normal file
View File

@ -0,0 +1,58 @@
namespace line_gestao_api.Models;
public class MveAuditRun : ITenantEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public string? FileName { get; set; }
public string? FileHashSha256 { get; set; }
public string? FileEncoding { get; set; }
public string Status { get; set; } = "READY";
public int TotalSystemLines { get; set; }
public int TotalReportLines { get; set; }
public int TotalConciliated { get; set; }
public int TotalStatusDivergences { get; set; }
public int TotalDataDivergences { get; set; }
public int TotalOnlyInSystem { get; set; }
public int TotalOnlyInReport { get; set; }
public int TotalDuplicateReportLines { get; set; }
public int TotalDuplicateSystemLines { get; set; }
public int TotalInvalidRows { get; set; }
public int TotalUnknownStatuses { get; set; }
public int TotalSyncableIssues { get; set; }
public int AppliedIssuesCount { get; set; }
public int AppliedLinesCount { get; set; }
public int AppliedFieldsCount { get; set; }
public DateTime ImportedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime? AppliedAtUtc { get; set; }
public Guid? AppliedByUserId { get; set; }
public string? AppliedByUserName { get; set; }
public string? AppliedByUserEmail { get; set; }
public ICollection<MveAuditIssue> Issues { get; set; } = new List<MveAuditIssue>();
}

18
Models/Setor.cs Normal file
View File

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace line_gestao_api.Models;
public class Setor : ITenantEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
[MaxLength(160)]
public string Nome { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<MobileLine> MobileLines { get; set; } = new List<MobileLine>();
}

View File

@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
namespace line_gestao_api.Models;
public class SolicitacaoLinha : ITenantEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public Guid? MobileLineId { get; set; }
public MobileLine? MobileLine { get; set; }
[MaxLength(30)]
public string? Linha { get; set; }
[MaxLength(200)]
public string? UsuarioLinha { get; set; }
[Required]
[MaxLength(60)]
public string TipoSolicitacao { get; set; } = string.Empty;
public decimal? FranquiaLineAtual { get; set; }
public decimal? FranquiaLineNova { get; set; }
public Guid? SolicitanteUserId { get; set; }
public ApplicationUser? SolicitanteUser { get; set; }
[MaxLength(200)]
public string? SolicitanteNome { get; set; }
[Required]
[MaxLength(1000)]
public string Mensagem { get; set; } = string.Empty;
[Required]
[MaxLength(30)]
public string Status { get; set; } = "PENDENTE";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

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

View File

@ -4,7 +4,8 @@ public static class AppRoles
{ {
public const string SysAdmin = "sysadmin"; public const string SysAdmin = "sysadmin";
public const string Gestor = "gestor"; public const string Gestor = "gestor";
public const string Financeiro = "financeiro";
public const string Cliente = "cliente"; public const string Cliente = "cliente";
public static readonly string[] All = [SysAdmin, Gestor, Cliente]; public static readonly string[] All = [SysAdmin, Gestor, Financeiro, Cliente];
} }

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

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

View File

@ -0,0 +1,323 @@
using System.Globalization;
using System.Text;
namespace line_gestao_api.Services;
internal static class MveAuditNormalization
{
public static string NormalizeHeader(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Trim().ToUpperInvariant().Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var ch in normalized)
{
if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)
{
builder.Append(ch);
}
}
return builder.ToString()
.Replace("\u00A0", " ")
.Trim();
}
public static string CleanTextValue(string? value, bool removeSingleQuotes = true)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var cleaned = value
.Replace("\u00A0", " ")
.Replace("\t", " ")
.Replace("\r", " ")
.Replace("\n", " ")
.Trim();
if (removeSingleQuotes)
{
cleaned = cleaned.Replace("'", string.Empty);
}
while (cleaned.Contains(" ", StringComparison.Ordinal))
{
cleaned = cleaned.Replace(" ", " ", StringComparison.Ordinal);
}
return cleaned.Trim();
}
public static string OnlyDigits(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
if (char.IsDigit(ch))
{
builder.Append(ch);
}
}
return builder.ToString();
}
public static string? NullIfEmptyDigits(string? value)
{
var digits = OnlyDigits(value);
return string.IsNullOrWhiteSpace(digits) ? null : digits;
}
public static string 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);
if (string.IsNullOrWhiteSpace(cleaned))
{
return string.Empty;
}
return NormalizeHeader(cleaned)
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
}
public static string NormalizeAccountLike(string? value)
{
var digits = OnlyDigits(value);
if (!string.IsNullOrWhiteSpace(digits))
{
return digits;
}
return NormalizeComparableText(value);
}
public static DateTime? ParseDateValue(string? rawValue)
{
var cleaned = CleanTextValue(rawValue);
if (string.IsNullOrWhiteSpace(cleaned))
{
return null;
}
if (double.TryParse(
cleaned.Replace(",", ".", StringComparison.Ordinal),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var oaValue) &&
oaValue > 10_000 &&
oaValue < 90_000)
{
try
{
return ToUtcDateOnly(DateTime.FromOADate(oaValue));
}
catch
{
// segue para os demais formatos
}
}
var formats = new[]
{
"dd/MM/yyyy",
"d/M/yyyy",
"dd/MM/yy",
"d/M/yy",
"yyyy-MM-dd",
"dd-MM-yyyy",
"d-M-yyyy",
"yyyyMMdd"
};
foreach (var format in formats)
{
if (DateTime.TryParseExact(
cleaned,
format,
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var exact))
{
return ToUtcDateOnly(exact);
}
}
if (DateTime.TryParse(cleaned, new CultureInfo("pt-BR"), DateTimeStyles.None, out var parsedBr))
{
return ToUtcDateOnly(parsedBr);
}
if (DateTime.TryParse(cleaned, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedInvariant))
{
return ToUtcDateOnly(parsedInvariant);
}
return null;
}
public static DateTime ToUtcDateOnly(DateTime date)
{
return new DateTime(date.Year, date.Month, date.Day, 12, 0, 0, DateTimeKind.Utc);
}
public static string FormatDate(DateTime? value)
{
return value.HasValue ? value.Value.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture) : string.Empty;
}
public static MveNormalizedStatus NormalizeReportStatus(string? rawValue)
{
var displayValue = CleanTextValue(rawValue);
if (string.IsNullOrWhiteSpace(displayValue))
{
return new MveNormalizedStatus(string.Empty, string.Empty, false);
}
var headSegment = NormalizeComparableText(displayValue.Split(':', 2)[0])
.Replace("/", " ", StringComparison.Ordinal)
.Replace("-", " ", StringComparison.Ordinal)
.Replace(".", " ", StringComparison.Ordinal)
.Replace("(", " ", StringComparison.Ordinal)
.Replace(")", " ", StringComparison.Ordinal)
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
var canonical = NormalizeComparableText(displayValue)
.Replace("/", " ", StringComparison.Ordinal)
.Replace("-", " ", StringComparison.Ordinal)
.Replace(".", " ", StringComparison.Ordinal)
.Replace("(", " ", StringComparison.Ordinal)
.Replace(")", " ", StringComparison.Ordinal)
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
var key = headSegment switch
{
var text when text.Contains("ATIVO", StringComparison.Ordinal) || text == "ATIVA" => "ATIVO",
var text when text.Contains("BLOQUEIO PARCIAL", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO",
var text when text.Contains("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) &&
text.Contains("TROCA", StringComparison.Ordinal) &&
text.Contains("NUMERO", StringComparison.Ordinal) => "PENDENTE_TROCA_NUMERO",
var text when text.Contains("PERDA", StringComparison.Ordinal) || text.Contains("ROUBO", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO",
var text when text.Contains("BLOQUEIO", StringComparison.Ordinal) && text.Contains("120", StringComparison.Ordinal) => "BLOQUEIO_120_DIAS",
_ => canonical.Replace(" ", "_", StringComparison.Ordinal)
};
var recognized = key is
"ATIVO" or
"BLOQUEIO_PERDA_ROUBO" or
"BLOQUEIO_120_DIAS" or
"SUSPENSO" or
"PENDENTE_TROCA_NUMERO";
return new MveNormalizedStatus(displayValue, key, recognized);
}
public static MveNormalizedStatus NormalizeSystemStatus(string? rawValue)
{
var displayValue = CleanTextValue(rawValue);
if (string.IsNullOrWhiteSpace(displayValue))
{
return new MveNormalizedStatus(string.Empty, string.Empty, false);
}
var canonical = NormalizeComparableText(displayValue)
.Replace("/", " ", StringComparison.Ordinal)
.Replace("-", " ", StringComparison.Ordinal)
.Replace(".", " ", StringComparison.Ordinal)
.Replace(":", " ", StringComparison.Ordinal)
.Replace("(", " ", StringComparison.Ordinal)
.Replace(")", " ", StringComparison.Ordinal)
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
var key = canonical switch
{
var text when text.Contains("ATIVO", StringComparison.Ordinal) || text == "ATIVA" => "ATIVO",
var text when text.Contains("PERDA", StringComparison.Ordinal) || text.Contains("ROUBO", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO",
var text when text.Contains("BLOQUEIO PARCIAL", StringComparison.Ordinal) => "BLOQUEIO_PERDA_ROUBO",
var text when text.Contains("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",
var text when text.Contains("PENDENTE", StringComparison.Ordinal) &&
text.Contains("TROCA", StringComparison.Ordinal) &&
text.Contains("NUMERO", StringComparison.Ordinal) => "PENDENTE_TROCA_NUMERO",
_ => canonical.Replace(" ", "_", StringComparison.Ordinal)
};
var recognized = key is
"ATIVO" or
"BLOQUEIO_PERDA_ROUBO" or
"BLOQUEIO_120_DIAS" or
"CANCELADO" or
"SUSPENSO" or
"PENDENTE_TROCA_NUMERO";
return new MveNormalizedStatus(displayValue, key, recognized);
}
public static MveNormalizedStatus NormalizeStatus(string? rawValue)
{
return NormalizeSystemStatus(rawValue);
}
public static string NormalizeStatusForSystem(string? rawValue)
{
var normalized = NormalizeReportStatus(rawValue);
return normalized.Key switch
{
"ATIVO" => "ATIVO",
"BLOQUEIO_PERDA_ROUBO" => "BLOQUEIO PERDA/ROUBO",
"BLOQUEIO_120_DIAS" => "BLOQUEIO 120 DIAS",
"SUSPENSO" => "SUSPENSO",
"PENDENTE_TROCA_NUMERO" => "PENDENTE TROCA NUMERO",
_ => normalized.DisplayValue
};
}
}
internal sealed record MveNormalizedStatus(string DisplayValue, string Key, bool Recognized);

View File

@ -0,0 +1,134 @@
using line_gestao_api.Data;
using Microsoft.EntityFrameworkCore;
namespace line_gestao_api.Services;
public sealed class MveAuditSchemaBootstrapper
{
private readonly AppDbContext _db;
public MveAuditSchemaBootstrapper(AppDbContext db)
{
_db = db;
}
public async Task EnsureSchemaAsync(CancellationToken cancellationToken = default)
{
if (!_db.Database.IsRelational())
{
return;
}
await _db.Database.ExecuteSqlRawAsync(
"""
ALTER TABLE "Aparelhos"
ADD COLUMN IF NOT EXISTS "Fabricante" character varying(120) NULL;
""",
cancellationToken);
await _db.Database.ExecuteSqlRawAsync(
"""
CREATE INDEX IF NOT EXISTS "IX_Aparelhos_TenantId_Fabricante"
ON "Aparelhos" ("TenantId", "Fabricante");
""",
cancellationToken);
await _db.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS "MveAuditRuns" (
"Id" uuid NOT NULL,
"TenantId" uuid NOT NULL,
"FileName" character varying(260) NULL,
"FileHashSha256" character varying(64) NULL,
"FileEncoding" character varying(40) NULL,
"Status" character varying(40) NOT NULL,
"TotalSystemLines" integer NOT NULL,
"TotalReportLines" integer NOT NULL,
"TotalConciliated" integer NOT NULL,
"TotalStatusDivergences" integer NOT NULL,
"TotalDataDivergences" integer NOT NULL,
"TotalOnlyInSystem" integer NOT NULL,
"TotalOnlyInReport" integer NOT NULL,
"TotalDuplicateReportLines" integer NOT NULL,
"TotalDuplicateSystemLines" integer NOT NULL,
"TotalInvalidRows" integer NOT NULL,
"TotalUnknownStatuses" integer NOT NULL,
"TotalSyncableIssues" integer NOT NULL,
"AppliedIssuesCount" integer NOT NULL,
"AppliedLinesCount" integer NOT NULL,
"AppliedFieldsCount" integer NOT NULL,
"ImportedAtUtc" timestamp with time zone NOT NULL,
"AppliedAtUtc" timestamp with time zone NULL,
"AppliedByUserId" uuid NULL,
"AppliedByUserName" character varying(200) NULL,
"AppliedByUserEmail" character varying(200) NULL,
CONSTRAINT "PK_MveAuditRuns" PRIMARY KEY ("Id")
);
""",
cancellationToken);
await _db.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS "MveAuditIssues" (
"Id" uuid NOT NULL,
"TenantId" uuid NOT NULL,
"AuditRunId" uuid NOT NULL,
"SourceRowNumber" integer NULL,
"NumeroLinha" character varying(64) NOT NULL,
"MobileLineId" uuid NULL,
"SystemItem" integer NULL,
"IssueType" character varying(60) NOT NULL,
"Situation" character varying(80) NOT NULL,
"Severity" character varying(40) NOT NULL,
"Syncable" boolean NOT NULL,
"Applied" boolean NOT NULL,
"AppliedAtUtc" timestamp with time zone NULL,
"ActionSuggestion" character varying(160) NULL,
"Notes" character varying(500) NULL,
"SystemStatus" character varying(120) NULL,
"ReportStatus" character varying(120) NULL,
"SystemPlan" character varying(200) NULL,
"ReportPlan" character varying(200) NULL,
"SystemSnapshotJson" jsonb NOT NULL,
"ReportSnapshotJson" jsonb NOT NULL,
"DifferencesJson" jsonb NOT NULL,
"CreatedAtUtc" timestamp with time zone NOT NULL,
CONSTRAINT "PK_MveAuditIssues" PRIMARY KEY ("Id")
);
""",
cancellationToken);
await _db.Database.ExecuteSqlRawAsync(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_MveAuditIssues_MveAuditRuns_AuditRunId'
) THEN
ALTER TABLE "MveAuditIssues"
ADD CONSTRAINT "FK_MveAuditIssues_MveAuditRuns_AuditRunId"
FOREIGN KEY ("AuditRunId")
REFERENCES "MveAuditRuns" ("Id")
ON DELETE CASCADE;
END IF;
END
$$;
""",
cancellationToken);
await _db.Database.ExecuteSqlRawAsync(
"""
CREATE INDEX IF NOT EXISTS "IX_MveAuditIssues_AuditRunId" ON "MveAuditIssues" ("AuditRunId");
CREATE INDEX IF NOT EXISTS "IX_MveAuditIssues_IssueType" ON "MveAuditIssues" ("IssueType");
CREATE INDEX IF NOT EXISTS "IX_MveAuditIssues_NumeroLinha" ON "MveAuditIssues" ("NumeroLinha");
CREATE INDEX IF NOT EXISTS "IX_MveAuditIssues_Syncable" ON "MveAuditIssues" ("Syncable");
CREATE INDEX IF NOT EXISTS "IX_MveAuditIssues_TenantId" ON "MveAuditIssues" ("TenantId");
CREATE INDEX IF NOT EXISTS "IX_MveAuditRuns_ImportedAtUtc" ON "MveAuditRuns" ("ImportedAtUtc");
CREATE INDEX IF NOT EXISTS "IX_MveAuditRuns_Status" ON "MveAuditRuns" ("Status");
CREATE INDEX IF NOT EXISTS "IX_MveAuditRuns_TenantId" ON "MveAuditRuns" ("TenantId");
""",
cancellationToken);
}
}

818
Services/MveAuditService.cs Normal file
View File

@ -0,0 +1,818 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using line_gestao_api.Data;
using line_gestao_api.Dtos;
using line_gestao_api.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
namespace line_gestao_api.Services;
public sealed class MveAuditService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly AppDbContext _db;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ITenantProvider _tenantProvider;
private readonly MveCsvParserService _parser;
private readonly MveReconciliationService _reconciliation;
private readonly IVigenciaNotificationSyncService _vigenciaNotificationSyncService;
public MveAuditService(
AppDbContext db,
IHttpContextAccessor httpContextAccessor,
ITenantProvider tenantProvider,
MveCsvParserService parser,
MveReconciliationService reconciliation,
IVigenciaNotificationSyncService vigenciaNotificationSyncService)
{
_db = db;
_httpContextAccessor = httpContextAccessor;
_tenantProvider = tenantProvider;
_parser = parser;
_reconciliation = reconciliation;
_vigenciaNotificationSyncService = vigenciaNotificationSyncService;
}
public async Task<MveAuditRunDto> CreateRunAsync(IFormFile file, CancellationToken cancellationToken = default)
{
ValidateInputFile(file);
var parsedFile = await _parser.ParseAsync(file, cancellationToken);
var reconciliation = await _reconciliation.BuildAsync(parsedFile, cancellationToken);
var run = new MveAuditRun
{
Id = Guid.NewGuid(),
FileName = parsedFile.FileName,
FileHashSha256 = parsedFile.FileHashSha256,
FileEncoding = parsedFile.FileEncoding,
Status = "READY",
TotalSystemLines = reconciliation.TotalSystemLines,
TotalReportLines = reconciliation.TotalReportLines,
TotalConciliated = reconciliation.TotalConciliated,
TotalStatusDivergences = reconciliation.TotalStatusDivergences,
TotalDataDivergences = reconciliation.TotalDataDivergences,
TotalOnlyInSystem = reconciliation.TotalOnlyInSystem,
TotalOnlyInReport = reconciliation.TotalOnlyInReport,
TotalDuplicateReportLines = reconciliation.TotalDuplicateReportLines,
TotalDuplicateSystemLines = reconciliation.TotalDuplicateSystemLines,
TotalInvalidRows = reconciliation.TotalInvalidRows,
TotalUnknownStatuses = reconciliation.TotalUnknownStatuses,
TotalSyncableIssues = reconciliation.TotalSyncableIssues,
ImportedAtUtc = DateTime.UtcNow
};
foreach (var issue in reconciliation.Issues)
{
run.Issues.Add(new MveAuditIssue
{
Id = Guid.NewGuid(),
AuditRunId = run.Id,
SourceRowNumber = issue.SourceRowNumber,
NumeroLinha = string.IsNullOrWhiteSpace(issue.NumeroLinha) ? "-" : issue.NumeroLinha.Trim(),
MobileLineId = issue.MobileLineId,
SystemItem = issue.SystemItem,
IssueType = issue.IssueType,
Situation = issue.Situation,
Severity = issue.Severity,
Syncable = issue.Syncable,
ActionSuggestion = issue.ActionSuggestion,
Notes = issue.Notes,
SystemStatus = issue.SystemStatus,
ReportStatus = issue.ReportStatus,
SystemPlan = issue.SystemPlan,
ReportPlan = issue.ReportPlan,
SystemSnapshotJson = JsonSerializer.Serialize(issue.SystemSnapshot, JsonOptions),
ReportSnapshotJson = JsonSerializer.Serialize(issue.ReportSnapshot, JsonOptions),
DifferencesJson = JsonSerializer.Serialize(issue.Differences, JsonOptions),
CreatedAtUtc = DateTime.UtcNow
});
}
_db.MveAuditRuns.Add(run);
_db.AuditLogs.Add(BuildAuditLog(
action: "MVE_AUDIT_RUN",
runId: run.Id,
fileName: run.FileName,
changes: new List<AuditFieldChangeDto>
{
new() { Field = "TotalLinhasSistema", ChangeType = "captured", NewValue = run.TotalSystemLines.ToString() },
new() { Field = "TotalLinhasRelatorio", ChangeType = "captured", NewValue = run.TotalReportLines.ToString() },
new() { Field = "DivergenciasStatus", ChangeType = "captured", NewValue = run.TotalStatusDivergences.ToString() },
new() { Field = "DivergenciasCadastro", ChangeType = "captured", NewValue = run.TotalDataDivergences.ToString() },
new() { Field = "ItensSincronizaveis", ChangeType = "captured", NewValue = run.TotalSyncableIssues.ToString() }
},
metadata: new
{
run.FileHashSha256,
run.FileEncoding,
run.TotalOnlyInSystem,
run.TotalOnlyInReport,
run.TotalDuplicateReportLines,
run.TotalDuplicateSystemLines,
run.TotalInvalidRows,
parsedFile.SourceRowCount
}));
await _db.SaveChangesAsync(cancellationToken);
return ToDto(run);
}
public async Task<MveAuditRunDto?> GetByIdAsync(Guid runId, CancellationToken cancellationToken = default)
{
var run = await _db.MveAuditRuns
.AsNoTracking()
.Include(x => x.Issues)
.FirstOrDefaultAsync(x => x.Id == runId, cancellationToken);
return run == null ? null : ToDto(run);
}
public async Task<MveAuditRunDto?> GetLatestAsync(CancellationToken cancellationToken = default)
{
var run = await _db.MveAuditRuns
.AsNoTracking()
.Include(x => x.Issues)
.OrderByDescending(x => x.ImportedAtUtc)
.ThenByDescending(x => x.Id)
.FirstOrDefaultAsync(cancellationToken);
return run == null ? null : ToDto(run);
}
public async Task<ApplyMveAuditResultDto?> ApplyAsync(
Guid runId,
IReadOnlyCollection<Guid>? issueIds,
CancellationToken cancellationToken = default)
{
var run = await _db.MveAuditRuns
.Include(x => x.Issues)
.FirstOrDefaultAsync(x => x.Id == runId, cancellationToken);
if (run == null)
{
return null;
}
var requestedIds = issueIds?
.Where(x => x != Guid.Empty)
.Distinct()
.ToHashSet()
?? new HashSet<Guid>();
var selectedIssues = run.Issues
.Where(x => x.Syncable && !x.Applied)
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
.ToList();
var result = new ApplyMveAuditResultDto
{
AuditRunId = run.Id,
RequestedIssues = requestedIds.Count == 0 ? selectedIssues.Count : requestedIds.Count
};
if (selectedIssues.Count == 0)
{
result.SkippedIssues = result.RequestedIssues;
return result;
}
var lineIds = selectedIssues
.Where(x => x.MobileLineId.HasValue)
.Select(x => x.MobileLineId!.Value)
.Distinct()
.ToList();
var linesById = await _db.MobileLines
.Where(x => lineIds.Contains(x.Id))
.ToDictionaryAsync(x => x.Id, cancellationToken);
var 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;
var appliedIssues = 0;
var skippedIssues = 0;
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
foreach (var issue in selectedIssues)
{
if (!issue.MobileLineId.HasValue || !linesById.TryGetValue(issue.MobileLineId.Value, out var line))
{
skippedIssues++;
continue;
}
if (!reportSnapshotsByIssueId.TryGetValue(issue.Id, out var reportSnapshot))
{
skippedIssues++;
continue;
}
if (reportSnapshot == null)
{
skippedIssues++;
continue;
}
var differences = DeserializeDifferences(issue.DifferencesJson);
var lineChanged = false;
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))
{
ApplyBlockedLineContext(line);
lineChanged = true;
updatedFields++;
}
}
if (lineChanged)
{
line.UpdatedAt = now;
updatedLineIds.Add(line.Id);
}
issue.Applied = true;
issue.AppliedAtUtc = now;
appliedIssues++;
}
run.AppliedIssuesCount = run.Issues.Count(x => x.Applied);
run.AppliedLinesCount += updatedLineIds.Count;
run.AppliedFieldsCount += updatedFields;
run.AppliedAtUtc = now;
run.AppliedByUserId = ResolveUserId(_httpContextAccessor.HttpContext?.User);
run.AppliedByUserName = ResolveUserName(_httpContextAccessor.HttpContext?.User);
run.AppliedByUserEmail = ResolveUserEmail(_httpContextAccessor.HttpContext?.User);
run.Status = run.AppliedIssuesCount >= run.TotalSyncableIssues
? "APPLIED"
: run.AppliedIssuesCount > 0
? "PARTIAL_APPLIED"
: "READY";
_db.AuditLogs.Add(BuildAuditLog(
action: "MVE_AUDIT_APPLY",
runId: run.Id,
fileName: run.FileName,
changes: new List<AuditFieldChangeDto>
{
new() { Field = "IssuesAplicadas", ChangeType = "modified", NewValue = appliedIssues.ToString() },
new() { Field = "LinhasAtualizadas", ChangeType = "modified", NewValue = updatedLineIds.Count.ToString() },
new() { Field = "CamposAtualizados", ChangeType = "modified", NewValue = updatedFields.ToString() }
},
metadata: new
{
requestedIssues = result.RequestedIssues,
appliedIssues,
updatedLines = updatedLineIds.Count,
updatedFields,
skippedIssues
}));
await _db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
if (appliedIssues > 0)
{
await _vigenciaNotificationSyncService.SyncCurrentTenantAsync();
}
result.AppliedIssues = appliedIssues;
result.UpdatedLines = updatedLineIds.Count;
result.UpdatedFields = updatedFields;
result.SkippedIssues = skippedIssues;
return result;
}
private static void ValidateInputFile(IFormFile file)
{
if (file == null || file.Length <= 0)
{
throw new InvalidOperationException("Selecione um arquivo CSV do MVE para continuar.");
}
if (file.Length > 20_000_000)
{
throw new InvalidOperationException("O arquivo do MVE excede o limite de 20 MB.");
}
var extension = Path.GetExtension(file.FileName ?? string.Empty);
if (!string.Equals(extension, ".csv", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("O relatório MVE deve ser enviado em formato CSV.");
}
}
private MveAuditRunDto ToDto(MveAuditRun run)
{
return new MveAuditRunDto
{
Id = run.Id,
FileName = run.FileName,
FileEncoding = run.FileEncoding,
Status = run.Status,
ImportedAtUtc = run.ImportedAtUtc,
AppliedAtUtc = run.AppliedAtUtc,
AppliedByUserName = run.AppliedByUserName,
AppliedByUserEmail = run.AppliedByUserEmail,
Summary = new MveAuditSummaryDto
{
TotalSystemLines = run.TotalSystemLines,
TotalReportLines = run.TotalReportLines,
TotalConciliated = run.TotalConciliated,
TotalStatusDivergences = run.TotalStatusDivergences,
TotalDataDivergences = run.TotalDataDivergences,
TotalOnlyInSystem = run.TotalOnlyInSystem,
TotalOnlyInReport = run.TotalOnlyInReport,
TotalDuplicateReportLines = run.TotalDuplicateReportLines,
TotalDuplicateSystemLines = run.TotalDuplicateSystemLines,
TotalInvalidRows = run.TotalInvalidRows,
TotalUnknownStatuses = run.TotalUnknownStatuses,
TotalSyncableIssues = run.TotalSyncableIssues,
AppliedIssuesCount = run.AppliedIssuesCount,
AppliedLinesCount = run.AppliedLinesCount,
AppliedFieldsCount = run.AppliedFieldsCount
},
Issues = run.Issues
.OrderByDescending(x => x.Syncable)
.ThenByDescending(x => x.Severity)
.ThenBy(x => x.NumeroLinha)
.Select(issue => new MveAuditIssueDto
{
Id = issue.Id,
SourceRowNumber = issue.SourceRowNumber,
NumeroLinha = issue.NumeroLinha,
MobileLineId = issue.MobileLineId,
SystemItem = issue.SystemItem,
IssueType = issue.IssueType,
Situation = issue.Situation,
Severity = issue.Severity,
Syncable = issue.Syncable,
Applied = issue.Applied,
ActionSuggestion = issue.ActionSuggestion,
Notes = issue.Notes,
SystemStatus = issue.SystemStatus,
ReportStatus = issue.ReportStatus,
SystemPlan = issue.SystemPlan,
ReportPlan = issue.ReportPlan,
SystemSnapshot = DeserializeSnapshot(issue.SystemSnapshotJson),
ReportSnapshot = DeserializeSnapshot(issue.ReportSnapshotJson),
Differences = DeserializeDifferences(issue.DifferencesJson)
})
.ToList()
};
}
private AuditLog BuildAuditLog(
string action,
Guid runId,
string? fileName,
IReadOnlyCollection<AuditFieldChangeDto> changes,
object metadata)
{
var actorTenantId = _tenantProvider.ActorTenantId;
if (!actorTenantId.HasValue || actorTenantId.Value == Guid.Empty)
{
throw new InvalidOperationException("Tenant inválido para registrar auditoria MVE.");
}
var user = _httpContextAccessor.HttpContext?.User;
var request = _httpContextAccessor.HttpContext?.Request;
return new AuditLog
{
TenantId = actorTenantId.Value,
ActorTenantId = actorTenantId.Value,
TargetTenantId = actorTenantId.Value,
ActorUserId = ResolveUserId(user),
UserId = ResolveUserId(user),
UserName = ResolveUserName(user),
UserEmail = ResolveUserEmail(user),
OccurredAtUtc = DateTime.UtcNow,
Action = action,
Page = "Geral",
EntityName = "MveAudit",
EntityId = runId.ToString(),
EntityLabel = string.IsNullOrWhiteSpace(fileName) ? "Auditoria MVE" : fileName.Trim(),
ChangesJson = JsonSerializer.Serialize(changes, JsonOptions),
MetadataJson = JsonSerializer.Serialize(metadata, JsonOptions),
RequestPath = request?.Path.Value,
RequestMethod = request?.Method,
IpAddress = _httpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString()
};
}
private static MveAuditSnapshotDto? DeserializeSnapshot(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JsonSerializer.Deserialize<MveAuditSnapshotDto>(json, JsonOptions);
}
catch
{
return null;
}
}
private static List<MveAuditDifferenceDto> DeserializeDifferences(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new List<MveAuditDifferenceDto>();
}
try
{
return JsonSerializer.Deserialize<List<MveAuditDifferenceDto>>(json, JsonOptions) ?? new List<MveAuditDifferenceDto>();
}
catch
{
return new List<MveAuditDifferenceDto>();
}
}
private 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,
IDictionary<string, VigenciaLine> vigenciaByLine,
IDictionary<int, VigenciaLine> vigenciaByItem)
{
if (!string.IsNullOrWhiteSpace(numeroLinha) && vigenciaByLine.TryGetValue(numeroLinha, out var byLine))
{
return byLine;
}
if (line.Item > 0 && vigenciaByItem.TryGetValue(line.Item, out var byItem))
{
return byItem;
}
return null;
}
private UserData? ResolveUserData(
MobileLine line,
string? numeroLinha,
IDictionary<string, UserData> userDataByLine,
IDictionary<int, UserData> userDataByItem)
{
if (!string.IsNullOrWhiteSpace(numeroLinha) && userDataByLine.TryGetValue(numeroLinha, out var byLine))
{
return byLine;
}
if (line.Item > 0 && userDataByItem.TryGetValue(line.Item, out var byItem))
{
return byItem;
}
return null;
}
private VigenciaLine CreateVigencia(MobileLine line)
{
var now = DateTime.UtcNow;
var vigencia = new VigenciaLine
{
Id = Guid.NewGuid(),
TenantId = line.TenantId,
Item = line.Item,
Linha = MveAuditNormalization.NullIfEmptyDigits(line.Linha),
Conta = line.Conta,
Cliente = line.Cliente,
Usuario = line.Usuario,
PlanoContrato = line.PlanoContrato,
CreatedAt = now,
UpdatedAt = now
};
_db.VigenciaLines.Add(vigencia);
return vigencia;
}
private UserData CreateUserData(MobileLine line)
{
var now = DateTime.UtcNow;
var userData = new UserData
{
Id = Guid.NewGuid(),
TenantId = line.TenantId,
Item = line.Item,
Linha = MveAuditNormalization.NullIfEmptyDigits(line.Linha),
Cliente = line.Cliente,
CreatedAt = now,
UpdatedAt = now
};
_db.UserDatas.Add(userData);
return userData;
}
private Aparelho EnsureAparelho(MobileLine line)
{
if (line.Aparelho != null)
{
return line.Aparelho;
}
var now = DateTime.UtcNow;
var aparelho = new Aparelho
{
Id = Guid.NewGuid(),
TenantId = line.TenantId,
CreatedAt = now,
UpdatedAt = now
};
_db.Aparelhos.Add(aparelho);
line.AparelhoId = aparelho.Id;
line.Aparelho = aparelho;
return aparelho;
}
private static void ApplyBlockedLineContext(MobileLine line)
{
var normalized = MveAuditNormalization.NormalizeSystemStatus(line.Status).Key;
if (normalized is not "BLOQUEIO_PERDA_ROUBO" and not "BLOQUEIO_120_DIAS")
{
return;
}
line.Usuario = "RESERVA";
line.Skil = "RESERVA";
if (string.IsNullOrWhiteSpace(line.Cliente))
{
line.Cliente = "RESERVA";
}
}
private static bool SetString(string? currentValue, string? nextValue, Action<string?> assign)
{
var normalizedNext = string.IsNullOrWhiteSpace(nextValue)
? null
: MveAuditNormalization.CleanTextValue(nextValue);
var normalizedCurrent = string.IsNullOrWhiteSpace(currentValue)
? null
: MveAuditNormalization.CleanTextValue(currentValue);
if (string.Equals(normalizedCurrent, normalizedNext, StringComparison.Ordinal))
{
return false;
}
assign(normalizedNext);
return true;
}
private static bool SetDate(DateTime? currentValue, DateTime? nextValue, Action<DateTime?> assign)
{
var normalizedCurrent = currentValue?.Date;
var normalizedNext = nextValue?.Date;
if (normalizedCurrent == normalizedNext)
{
return false;
}
assign(nextValue);
return true;
}
private static Guid? ResolveUserId(ClaimsPrincipal? user)
{
var raw = user?.FindFirstValue(ClaimTypes.NameIdentifier)
?? user?.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? user?.FindFirstValue("sub");
return Guid.TryParse(raw, out var parsed) ? parsed : null;
}
private static string? ResolveUserName(ClaimsPrincipal? user)
{
return user?.FindFirstValue("name")
?? user?.FindFirstValue(ClaimTypes.Name)
?? user?.Identity?.Name;
}
private static string? ResolveUserEmail(ClaimsPrincipal? user)
{
return user?.FindFirstValue(ClaimTypes.Email)
?? user?.FindFirstValue(JwtRegisteredClaimNames.Email)
?? user?.FindFirstValue("email");
}
}

View File

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

View File

@ -0,0 +1,743 @@
using line_gestao_api.Data;
using line_gestao_api.Dtos;
using line_gestao_api.Models;
using Microsoft.EntityFrameworkCore;
namespace line_gestao_api.Services;
public sealed class MveReconciliationService
{
private readonly AppDbContext _db;
public MveReconciliationService(AppDbContext db)
{
_db = db;
}
internal async Task<MveReconciliationResult> BuildAsync(
MveParsedFileResult parsedFile,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(parsedFile);
var mobileLines = await _db.MobileLines
.AsNoTracking()
.Include(x => x.Aparelho)
.ToListAsync(cancellationToken);
var vigencias = await _db.VigenciaLines
.AsNoTracking()
.ToListAsync(cancellationToken);
var userDatas = await _db.UserDatas
.AsNoTracking()
.ToListAsync(cancellationToken);
var systemAggregates = BuildSystemAggregates(mobileLines, vigencias, userDatas);
var systemByNumber = systemAggregates
.Where(x => !string.IsNullOrWhiteSpace(x.NumeroNormalizado))
.GroupBy(x => x.NumeroNormalizado, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
var reportByNumber = parsedFile.Lines
.GroupBy(x => x.NumeroNormalizado, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
var result = new MveReconciliationResult
{
TotalSystemLines = mobileLines.Count,
TotalReportLines = parsedFile.Lines.Count,
TotalInvalidRows = parsedFile.Issues.Count(x => x.IssueType == "INVALID_ROW"),
TotalUnknownStatuses = parsedFile.Lines.Count(x => !x.StatusLinhaRecognized)
};
foreach (var parserIssue in parsedFile.Issues)
{
result.Issues.Add(new MveReconciliationIssueResult
{
SourceRowNumber = parserIssue.SourceRowNumber,
NumeroLinha = parserIssue.NumeroLinha,
IssueType = parserIssue.IssueType,
Situation = "linha inválida no relatório",
Severity = "WARNING",
Syncable = false,
ActionSuggestion = "Corrigir o arquivo MVE e refazer a auditoria",
Notes = parserIssue.Message
});
}
var duplicateReportKeys = reportByNumber
.Where(entry => entry.Value.Count > 1)
.Select(entry => entry.Key)
.ToHashSet(StringComparer.Ordinal);
foreach (var duplicateKey in duplicateReportKeys)
{
var duplicates = reportByNumber[duplicateKey];
var first = duplicates[0];
result.TotalDuplicateReportLines++;
result.Issues.Add(new MveReconciliationIssueResult
{
SourceRowNumber = first.SourceRowNumber,
NumeroLinha = duplicateKey,
IssueType = "DUPLICATE_REPORT",
Situation = "duplicidade no relatório",
Severity = "WARNING",
Syncable = false,
ActionSuggestion = "Corrigir a duplicidade no relatório MVE",
Notes = $"A linha {duplicateKey} apareceu {duplicates.Count} vezes no arquivo MVE.",
ReportStatus = first.StatusLinha,
ReportPlan = first.PlanoLinha,
ReportSnapshot = BuildReportSnapshot(first)
});
}
var duplicateSystemKeys = systemByNumber
.Where(entry => entry.Value.Count > 1)
.Select(entry => entry.Key)
.ToHashSet(StringComparer.Ordinal);
foreach (var duplicateKey in duplicateSystemKeys)
{
var duplicates = systemByNumber[duplicateKey];
var first = duplicates[0];
result.TotalDuplicateSystemLines++;
result.Issues.Add(new MveReconciliationIssueResult
{
NumeroLinha = duplicateKey,
MobileLineId = first.MobileLine.Id,
SystemItem = first.MobileLine.Item,
IssueType = "DUPLICATE_SYSTEM",
Situation = "duplicidade no sistema",
Severity = "WARNING",
Syncable = false,
ActionSuggestion = "Corrigir a duplicidade interna antes de sincronizar",
Notes = $"A linha {duplicateKey} possui {duplicates.Count} registros no sistema.",
SystemStatus = first.MobileLine.Status,
SystemPlan = first.MobileLine.PlanoContrato,
SystemSnapshot = BuildSystemSnapshot(first)
});
}
var 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)
.Where(key => !string.IsNullOrWhiteSpace(key))
.Distinct(StringComparer.Ordinal)
.OrderBy(key => key, StringComparer.Ordinal)
.ToList();
foreach (var key in allKeys)
{
if (blockedSystemKeys.Contains(key) || blockedReportKeys.Contains(key))
{
continue;
}
var hasReport = reportByNumber.TryGetValue(key, out var reportLines);
var hasSystem = systemByNumber.TryGetValue(key, out var systemLines);
var reportLine = hasReport ? reportLines![0] : null;
var systemLine = hasSystem ? systemLines![0] : null;
if (reportLine == null || systemLine == null)
{
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 = systemLine.NumeroNormalizado,
MobileLineId = systemLine.MobileLine.Id,
SystemItem = systemLine.MobileLine.Item,
IssueType = "ONLY_IN_SYSTEM",
Situation = "ausente no relatório",
Severity = "WARNING",
Syncable = false,
ActionSuggestion = "Validar com a Vivo antes de alterar o cadastro",
Notes = "A linha existe no sistema, mas não foi encontrada no relatório MVE.",
SystemStatus = systemLine.MobileLine.Status,
SystemPlan = systemLine.MobileLine.PlanoContrato,
SystemSnapshot = BuildSystemSnapshot(systemLine)
});
}
foreach (var reportLine in unmatchedReportLines.Where(x => !chipMatchedReportRows.Contains(x.SourceRowNumber)))
{
result.TotalOnlyInReport++;
result.Issues.Add(new MveReconciliationIssueResult
{
SourceRowNumber = reportLine.SourceRowNumber,
NumeroLinha = reportLine.NumeroNormalizado,
IssueType = "ONLY_IN_REPORT",
Situation = "ausente no sistema",
Severity = "WARNING",
Syncable = false,
ActionSuggestion = "Avaliar cadastro manual dessa linha",
Notes = "A linha existe no relatório MVE, mas não foi encontrada na página Geral.",
ReportStatus = reportLine.StatusLinha,
ReportPlan = reportLine.PlanoLinha,
ReportSnapshot = BuildReportSnapshot(reportLine)
});
}
return result;
}
private static List<MveSystemLineAggregate> BuildSystemAggregates(
IReadOnlyCollection<MobileLine> mobileLines,
IReadOnlyCollection<VigenciaLine> vigencias,
IReadOnlyCollection<UserData> userDatas)
{
var vigenciaByLine = vigencias
.Where(x => !string.IsNullOrWhiteSpace(x.Linha))
.GroupBy(x => MveAuditNormalization.OnlyDigits(x.Linha), StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First(),
StringComparer.Ordinal);
var vigenciaByItem = vigencias
.Where(x => x.Item > 0)
.GroupBy(x => x.Item)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First());
var userDataByLine = userDatas
.Where(x => !string.IsNullOrWhiteSpace(x.Linha))
.GroupBy(x => MveAuditNormalization.OnlyDigits(x.Linha), StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First(),
StringComparer.Ordinal);
var userDataByItem = userDatas
.Where(x => x.Item > 0)
.GroupBy(x => x.Item)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.CreatedAt).First());
return mobileLines
.Select(line =>
{
var numeroNormalizado = MveAuditNormalization.OnlyDigits(line.Linha);
vigenciaByLine.TryGetValue(numeroNormalizado, out var vigencia);
if (vigencia == null && line.Item > 0)
{
vigenciaByItem.TryGetValue(line.Item, out vigencia);
}
userDataByLine.TryGetValue(numeroNormalizado, out var userData);
if (userData == null && line.Item > 0)
{
userDataByItem.TryGetValue(line.Item, out userData);
}
return new MveSystemLineAggregate(line, vigencia, userData, numeroNormalizado);
})
.ToList();
}
private static MveReconciliationIssueResult? CompareMatchedLine(
MveSystemLineAggregate systemLine,
MveParsedLine reportLine)
{
var systemSnapshot = BuildSystemSnapshot(systemLine);
var reportSnapshot = BuildReportSnapshot(reportLine);
var differences = new List<MveAuditDifferenceDto>();
var systemStatus = MveAuditNormalization.NormalizeSystemStatus(systemSnapshot.StatusLinha);
if (!string.Equals(systemStatus.Key, reportLine.StatusLinhaKey, StringComparison.Ordinal))
{
differences.Add(new MveAuditDifferenceDto
{
FieldKey = "status",
Label = "Status da linha",
SystemValue = NullIfEmpty(systemSnapshot.StatusLinha),
ReportValue = NullIfEmpty(reportSnapshot.StatusLinha),
Syncable = true
});
}
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)
{
notes.Add("O STATUS_LINHA do relatório MVE não foi reconhecido pelo mapa de normalização.");
}
var hasStatusDifference = differences.Any(x => x.FieldKey == "status");
var 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
{
SourceRowNumber = reportLine.SourceRowNumber,
NumeroLinha = reportLine.NumeroNormalizado,
MobileLineId = systemLine.MobileLine.Id,
SystemItem = systemLine.MobileLine.Item,
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, hasLineDifference, hasChipDifference, hasUnknownStatus),
Notes = notes.Count == 0 ? null : string.Join(" ", notes),
SystemStatus = systemSnapshot.StatusLinha,
ReportStatus = reportSnapshot.StatusLinha,
SystemPlan = systemSnapshot.PlanoLinha,
ReportPlan = reportSnapshot.PlanoLinha,
SystemSnapshot = systemSnapshot,
ReportSnapshot = reportSnapshot,
Differences = differences
};
}
private static MveReconciliationIssueResult BuildDddReviewIssue(
MveSystemLineAggregate systemLine,
MveParsedLine reportLine,
MveAuditSnapshotDto systemSnapshot,
MveAuditSnapshotDto reportSnapshot)
{
return new MveReconciliationIssueResult
{
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)
{
return "divergência de status";
}
return hasUnknownStatus ? "status desconhecido no relatório" : "alinhada";
}
private static string ResolveSeverity(bool hasStatusDifference, bool hasDataDifference, bool hasUnknownStatus)
{
if (hasStatusDifference)
{
return "HIGH";
}
if (hasDataDifference)
{
return "MEDIUM";
}
return hasUnknownStatus ? "WARNING" : "INFO";
}
private static string ResolveActionSuggestion(
bool hasStatusDifference,
bool 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";
}
return hasUnknownStatus
? "Revisar o status recebido e ajustar o mapa de normalização se necessário"
: "Nenhuma ação";
}
private static MveAuditSnapshotDto BuildSystemSnapshot(MveSystemLineAggregate systemLine)
{
return new MveAuditSnapshotDto
{
NumeroLinha = NullIfEmpty(systemLine.MobileLine.Linha),
StatusLinha = NullIfEmpty(systemLine.MobileLine.Status),
PlanoLinha = NullIfEmpty(systemLine.MobileLine.PlanoContrato),
DataAtivacao = systemLine.Vigencia?.DtEfetivacaoServico,
TerminoContrato = systemLine.Vigencia?.DtTerminoFidelizacao,
Chip = NullIfEmpty(systemLine.MobileLine.Chip),
Conta = NullIfEmpty(systemLine.MobileLine.Conta),
Cnpj = NullIfEmpty(systemLine.UserData?.Cnpj),
ModeloAparelho = NullIfEmpty(systemLine.MobileLine.Aparelho?.Nome),
Fabricante = NullIfEmpty(systemLine.MobileLine.Aparelho?.Fabricante)
};
}
private static MveAuditSnapshotDto BuildReportSnapshot(MveParsedLine reportLine)
{
return new MveAuditSnapshotDto
{
NumeroLinha = NullIfEmpty(reportLine.NumeroNormalizado),
StatusLinha = NullIfEmpty(reportLine.StatusLinha),
StatusConta = NullIfEmpty(reportLine.StatusConta),
PlanoLinha = NullIfEmpty(reportLine.PlanoLinha),
DataAtivacao = reportLine.DataAtivacao,
TerminoContrato = reportLine.TerminoContrato,
Chip = NullIfEmpty(reportLine.Chip),
Conta = NullIfEmpty(reportLine.Conta),
Cnpj = NullIfEmpty(reportLine.Cnpj),
ModeloAparelho = NullIfEmpty(reportLine.ModeloAparelho),
Fabricante = NullIfEmpty(reportLine.Fabricante),
ServicosAtivos = reportLine.ServicosAtivos
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
private static void AddDifference(
ICollection<MveAuditDifferenceDto> differences,
string fieldKey,
string label,
string? systemValue,
string? reportValue,
bool syncable,
Func<string?, string> comparer)
{
var normalizedSystem = comparer(systemValue);
var normalizedReport = comparer(reportValue);
if (string.Equals(normalizedSystem, normalizedReport, StringComparison.Ordinal))
{
return;
}
differences.Add(new MveAuditDifferenceDto
{
FieldKey = fieldKey,
Label = label,
SystemValue = NullIfEmpty(systemValue),
ReportValue = NullIfEmpty(reportValue),
Syncable = syncable
});
}
private static string? NullIfEmpty(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
public sealed class MveReconciliationResult
{
public int TotalSystemLines { get; init; }
public int TotalReportLines { get; init; }
public int TotalConciliated { get; set; }
public int TotalStatusDivergences { get; set; }
public int TotalDataDivergences { get; set; }
public int TotalOnlyInSystem { get; set; }
public int TotalOnlyInReport { get; set; }
public int TotalDuplicateReportLines { get; set; }
public int TotalDuplicateSystemLines { get; set; }
public int TotalInvalidRows { get; init; }
public int TotalUnknownStatuses { get; init; }
public int TotalSyncableIssues { get; set; }
public List<MveReconciliationIssueResult> Issues { get; } = new();
}
public sealed class MveReconciliationIssueResult
{
public int? SourceRowNumber { get; init; }
public string NumeroLinha { get; init; } = string.Empty;
public Guid? MobileLineId { get; init; }
public int? SystemItem { get; init; }
public string IssueType { get; init; } = string.Empty;
public string Situation { get; init; } = string.Empty;
public string Severity { get; init; } = "INFO";
public bool Syncable { get; init; }
public string? ActionSuggestion { get; init; }
public string? Notes { get; init; }
public string? SystemStatus { get; init; }
public string? ReportStatus { get; init; }
public string? SystemPlan { get; init; }
public string? ReportPlan { get; init; }
public MveAuditSnapshotDto? SystemSnapshot { get; init; }
public MveAuditSnapshotDto? ReportSnapshot { get; init; }
public List<MveAuditDifferenceDto> Differences { get; init; } = new();
}
internal sealed record MveSystemLineAggregate(
MobileLine MobileLine,
VigenciaLine? Vigencia,
UserData? UserData,
string NumeroNormalizado);

View File

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

View File

@ -19,7 +19,8 @@ public class TenantProvider : ITenantProvider
public bool HasGlobalViewAccess => public bool HasGlobalViewAccess =>
HasRole(AppRoles.SysAdmin) || HasRole(AppRoles.SysAdmin) ||
HasRole(AppRoles.Gestor); HasRole(AppRoles.Gestor) ||
HasRole(AppRoles.Financeiro);
public void SetTenantId(Guid? tenantId) public void SetTenantId(Guid? tenantId)
{ {

View File

@ -27,11 +27,18 @@ public class VigenciaNotificationBackgroundService : BackgroundService
var intervalMinutes = _options.CheckIntervalMinutes <= 0 ? 60 : _options.CheckIntervalMinutes; var intervalMinutes = _options.CheckIntervalMinutes <= 0 ? 60 : _options.CheckIntervalMinutes;
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(intervalMinutes)); using var timer = new PeriodicTimer(TimeSpan.FromMinutes(intervalMinutes));
await RunOnceAsync(stoppingToken); try
while (await timer.WaitForNextTickAsync(stoppingToken))
{ {
await RunOnceAsync(stoppingToken); await RunOnceAsync(stoppingToken);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RunOnceAsync(stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Host finalizando; evita ruído de log durante shutdown/startup interrompido.
} }
} }
@ -64,7 +71,11 @@ public class VigenciaNotificationBackgroundService : BackgroundService
await notificationSyncService.SyncTenantAsync(tenant.Id, stoppingToken); await notificationSyncService.SyncTenantAsync(tenant.Id, stoppingToken);
} }
} }
catch (Exception ex) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Host finalizando; evita erro em cascata no logger.
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{ {
_logger.LogError(ex, "Erro ao gerar notificações de vigência."); _logger.LogError(ex, "Erro ao gerar notificações de vigência.");
} }

View File

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