Add data consistency sync and gap reporting

This commit is contained in:
Eduardo Lopes 2026-01-26 17:42:29 -03:00
parent 9ff61cf937
commit dc0f3e4c9d
12 changed files with 384 additions and 11 deletions

View File

@ -72,16 +72,34 @@ public class AuthController : ControllerBase
[HttpPost("login")] [HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest req) public async Task<IActionResult> Login(LoginRequest req)
{ {
// ✅ normaliza e evita null
var email = (req.Email ?? "").Trim().ToLowerInvariant(); var email = (req.Email ?? "").Trim().ToLowerInvariant();
var password = req.Password ?? ""; var password = req.Password ?? "";
var normalizedEmail = _userManager.NormalizeEmail(email); var normalizedEmail = _userManager.NormalizeEmail(email);
// ✅ SOLUÇÃO A: ignora filtros globais (multi-tenant / HasQueryFilter) var tenantId = ResolveTenantId(req);
// e pega 1 usuário (pra você logar logo). ApplicationUser? user;
var user = await _userManager.Users
if (tenantId == null)
{
var users = await _userManager.Users
.IgnoreQueryFilters() .IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail); .Where(u => u.NormalizedEmail == normalizedEmail)
.ToListAsync();
if (users.Count == 0)
return Unauthorized("Credenciais inválidas.");
if (users.Count > 1)
return BadRequest("Informe o tenant para realizar o login.");
user = users[0];
}
else
{
user = await _userManager.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail && u.TenantId == tenantId);
}
if (user == null) if (user == null)
return Unauthorized("Credenciais inválidas."); return Unauthorized("Credenciais inválidas.");
@ -97,6 +115,15 @@ public class AuthController : ControllerBase
return Ok(new AuthResponse(token)); return Ok(new AuthResponse(token));
} }
private Guid? ResolveTenantId(LoginRequest req)
{
if (req.TenantId.HasValue)
return req.TenantId.Value;
var headerValue = Request.Headers["X-Tenant-Id"].FirstOrDefault();
return Guid.TryParse(headerValue, out var parsed) ? parsed : null;
}
private async Task<string> GenerateJwtAsync(ApplicationUser user) private async Task<string> GenerateJwtAsync(ApplicationUser user)
{ {
var key = _config["Jwt:Key"]!; var key = _config["Jwt:Key"]!;

View File

@ -0,0 +1,101 @@
using line_gestao_api.Data;
using line_gestao_api.Dtos;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace line_gestao_api.Controllers;
[ApiController]
[Route("api/consistency")]
[Authorize(Roles = "admin")]
public class ConsistencyController : ControllerBase
{
private readonly AppDbContext _db;
public ConsistencyController(AppDbContext db)
{
_db = db;
}
[HttpGet("gaps")]
public async Task<ActionResult<ConsistencyReportDto>> GetGaps()
{
var totalLinhas = await _db.MobileLines.AsNoTracking().CountAsync();
var totalClientes = await _db.MobileLines.AsNoTracking()
.Where(x => x.Cliente != null && x.Cliente != "")
.Select(x => x.Cliente!)
.Distinct()
.CountAsync();
var muregLinhas = await _db.MuregLines.AsNoTracking()
.Select(x => x.MobileLineId)
.Distinct()
.CountAsync();
var trocaLinhas = await _db.TrocaNumeroLines.AsNoTracking()
.Select(x => x.Item)
.Distinct()
.CountAsync();
var userDataLinhas = await _db.UserDatas.AsNoTracking()
.Select(x => x.Item)
.Distinct()
.CountAsync();
var vigenciaLinhas = await _db.VigenciaLines.AsNoTracking()
.Select(x => x.Item)
.Distinct()
.CountAsync();
var billingClientes = await _db.BillingClients.AsNoTracking()
.Where(x => x.Cliente != null && x.Cliente != "")
.Select(x => x.Cliente)
.Distinct()
.CountAsync();
var report = new ConsistencyReportDto
{
TotalLinhasGeral = totalLinhas,
TotalClientesGeral = totalClientes,
LinhasPorAba = new List<ConsistencyGapDto>
{
new()
{
Nome = "Mureg",
TotalGeral = totalLinhas,
TotalAtual = muregLinhas
},
new()
{
Nome = "TrocaNumero",
TotalGeral = totalLinhas,
TotalAtual = trocaLinhas
},
new()
{
Nome = "UserData",
TotalGeral = totalLinhas,
TotalAtual = userDataLinhas
},
new()
{
Nome = "Vigencia",
TotalGeral = totalLinhas,
TotalAtual = vigenciaLinhas
}
},
ClientesPorAba = new List<ConsistencyGapDto>
{
new()
{
Nome = "Faturamento",
TotalGeral = totalClientes,
TotalAtual = billingClientes
}
}
};
return Ok(report);
}
}

View File

@ -2,6 +2,7 @@
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.Models;
using line_gestao_api.Services;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -20,10 +21,12 @@ namespace line_gestao_api.Controllers
public class LinesController : ControllerBase public class LinesController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IDataConsistencyService _consistencyService;
public LinesController(AppDbContext db) public LinesController(AppDbContext db, IDataConsistencyService consistencyService)
{ {
_db = db; _db = db;
_consistencyService = consistencyService;
} }
public class ImportExcelForm public class ImportExcelForm
@ -480,6 +483,8 @@ namespace line_gestao_api.Controllers
try { await _db.SaveChangesAsync(); } try { await _db.SaveChangesAsync(); }
catch (DbUpdateException) { return Conflict(new { message = "Conflito ao salvar." }); } catch (DbUpdateException) { return Conflict(new { message = "Conflito ao salvar." }); }
await _consistencyService.SyncFromMobileLineAsync(x);
return NoContent(); return NoContent();
} }

View File

@ -1,6 +1,7 @@
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.Models;
using line_gestao_api.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Globalization; using System.Globalization;
@ -13,10 +14,12 @@ namespace line_gestao_api.Controllers
public class TrocaNumeroController : ControllerBase public class TrocaNumeroController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IDataConsistencyService _consistencyService;
public TrocaNumeroController(AppDbContext db) public TrocaNumeroController(AppDbContext db, IDataConsistencyService consistencyService)
{ {
_db = db; _db = db;
_consistencyService = consistencyService;
} }
// ========================================================== // ==========================================================
@ -124,6 +127,8 @@ namespace line_gestao_api.Controllers
_db.TrocaNumeroLines.Add(e); _db.TrocaNumeroLines.Add(e);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
await _consistencyService.SyncFromTrocaNumeroAsync(e);
return CreatedAtAction(nameof(GetById), new { id = e.Id }, ToDetailDto(e)); return CreatedAtAction(nameof(GetById), new { id = e.Id }, ToDetailDto(e));
} }
@ -149,6 +154,7 @@ namespace line_gestao_api.Controllers
x.UpdatedAt = DateTime.UtcNow; x.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
await _consistencyService.SyncFromTrocaNumeroAsync(x);
return NoContent(); return NoContent();
} }

View File

@ -14,6 +14,12 @@ namespace line_gestao_api.Controllers;
[Authorize] [Authorize]
public class UsersController : ControllerBase public class UsersController : ControllerBase
{ {
private static readonly HashSet<string> AllowedRoles = new(StringComparer.OrdinalIgnoreCase)
{
"admin",
"gestor"
};
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole<Guid>> _roleManager; private readonly RoleManager<IdentityRole<Guid>> _roleManager;
@ -65,7 +71,7 @@ public class UsersController : ControllerBase
} }
var role = req.Permissao.Trim().ToLowerInvariant(); var role = req.Permissao.Trim().ToLowerInvariant();
if (!await _roleManager.RoleExistsAsync(role)) if (!AllowedRoles.Contains(role) || !await _roleManager.RoleExistsAsync(role))
{ {
return BadRequest(new ValidationErrorResponse return BadRequest(new ValidationErrorResponse
{ {
@ -211,12 +217,33 @@ public class UsersController : ControllerBase
[Authorize(Roles = "admin")] [Authorize(Roles = "admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UserUpdateRequest req) public async Task<IActionResult> Update(Guid id, [FromBody] UserUpdateRequest req)
{ {
var errors = await ValidateUpdateAsync(id, req);
if (errors.Count > 0)
{
return BadRequest(new ValidationErrorResponse { Errors = errors });
}
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.Id == id); var user = await _userManager.Users.FirstOrDefaultAsync(u => u.Id == id);
if (user == null) if (user == null)
{ {
return NotFound(); return NotFound();
} }
if (!string.IsNullOrWhiteSpace(req.Nome))
{
user.Name = req.Nome.Trim();
}
if (!string.IsNullOrWhiteSpace(req.Email))
{
var email = req.Email.Trim().ToLowerInvariant();
if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
{
await _userManager.SetEmailAsync(user, email);
await _userManager.SetUserNameAsync(user, email);
}
}
if (req.Ativo.HasValue) if (req.Ativo.HasValue)
{ {
user.IsActive = req.Ativo.Value; user.IsActive = req.Ativo.Value;
@ -225,7 +252,7 @@ public class UsersController : ControllerBase
if (!string.IsNullOrWhiteSpace(req.Permissao)) if (!string.IsNullOrWhiteSpace(req.Permissao))
{ {
var roleName = req.Permissao.Trim().ToLowerInvariant(); var roleName = req.Permissao.Trim().ToLowerInvariant();
if (!await _roleManager.RoleExistsAsync(roleName)) if (!AllowedRoles.Contains(roleName) || !await _roleManager.RoleExistsAsync(roleName))
{ {
return BadRequest(new ValidationErrorResponse return BadRequest(new ValidationErrorResponse
{ {
@ -245,6 +272,23 @@ public class UsersController : ControllerBase
await _userManager.AddToRoleAsync(user, roleName); await _userManager.AddToRoleAsync(user, roleName);
} }
if (!string.IsNullOrWhiteSpace(req.Senha))
{
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var resetResult = await _userManager.ResetPasswordAsync(user, token, req.Senha);
if (!resetResult.Succeeded)
{
return BadRequest(new ValidationErrorResponse
{
Errors = resetResult.Errors.Select(e => new ValidationErrorDto
{
Field = "senha",
Message = e.Description
}).ToList()
});
}
}
await _userManager.UpdateAsync(user); await _userManager.UpdateAsync(user);
return NoContent(); return NoContent();
} }
@ -277,6 +321,64 @@ public class UsersController : ControllerBase
{ {
errors.Add(new ValidationErrorDto { Field = "permissao", Message = "Permissão é obrigatória." }); errors.Add(new ValidationErrorDto { Field = "permissao", Message = "Permissão é obrigatória." });
} }
else if (!AllowedRoles.Contains(req.Permissao.Trim()))
{
errors.Add(new ValidationErrorDto { Field = "permissao", Message = "Permissão inválida." });
}
return errors;
}
private async Task<List<ValidationErrorDto>> ValidateUpdateAsync(Guid userId, UserUpdateRequest req)
{
var errors = new List<ValidationErrorDto>();
if (!string.IsNullOrWhiteSpace(req.Nome) && req.Nome.Trim().Length < 2)
{
errors.Add(new ValidationErrorDto { Field = "nome", Message = "Nome inválido." });
}
if (!string.IsNullOrWhiteSpace(req.Email))
{
var email = req.Email.Trim().ToLowerInvariant();
var normalized = _userManager.NormalizeEmail(email);
var tenantId = _tenantProvider.TenantId;
if (tenantId == null)
{
errors.Add(new ValidationErrorDto { Field = "email", Message = "Tenant inválido." });
}
else
{
var exists = await _userManager.Users.AnyAsync(u =>
u.Id != userId &&
u.TenantId == tenantId &&
u.NormalizedEmail == normalized);
if (exists)
{
errors.Add(new ValidationErrorDto { Field = "email", Message = "E-mail já cadastrado." });
}
}
}
if (!string.IsNullOrWhiteSpace(req.Senha))
{
if (req.Senha.Length < 6)
{
errors.Add(new ValidationErrorDto { Field = "senha", Message = "Senha deve ter pelo menos 6 caracteres." });
}
if (req.Senha != req.ConfirmarSenha)
{
errors.Add(new ValidationErrorDto { Field = "confirmarSenha", Message = "Confirmação de senha inválida." });
}
}
if (!string.IsNullOrWhiteSpace(req.Permissao) && !AllowedRoles.Contains(req.Permissao.Trim()))
{
errors.Add(new ValidationErrorDto { Field = "permissao", Message = "Permissão inválida." });
}
return errors; return errors;
} }

17
Dtos/ConsistencyDtos.cs Normal file
View File

@ -0,0 +1,17 @@
namespace line_gestao_api.Dtos;
public class ConsistencyGapDto
{
public string Nome { get; set; } = string.Empty;
public int TotalGeral { get; set; }
public int TotalAtual { get; set; }
public int Faltando => Math.Max(0, TotalGeral - TotalAtual);
}
public class ConsistencyReportDto
{
public int TotalLinhasGeral { get; set; }
public int TotalClientesGeral { get; set; }
public List<ConsistencyGapDto> LinhasPorAba { get; set; } = new();
public List<ConsistencyGapDto> ClientesPorAba { get; set; } = new();
}

View File

@ -1,3 +1,3 @@
namespace line_gestao_api.Dtos; namespace line_gestao_api.Dtos;
public record LoginRequest(string Email, string Password); public record LoginRequest(string Email, string Password, Guid? TenantId = null);

View File

@ -11,6 +11,10 @@ public class UserCreateRequest
public class UserUpdateRequest public class UserUpdateRequest
{ {
public string? Nome { get; set; }
public string? Email { get; set; }
public string? Senha { get; set; }
public string? ConfirmarSenha { get; set; }
public string? Permissao { get; set; } public string? Permissao { get; set; }
public bool? Ativo { get; set; } public bool? Ativo { get; set; }
} }

View File

@ -35,6 +35,7 @@ builder.Services.AddDbContext<AppDbContext>(options =>
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, TenantProvider>(); builder.Services.AddScoped<ITenantProvider, TenantProvider>();
builder.Services.AddScoped<IDataConsistencyService, DataConsistencyService>();
builder.Services.AddIdentityCore<ApplicationUser>(options => builder.Services.AddIdentityCore<ApplicationUser>(options =>
{ {

View File

@ -0,0 +1,96 @@
using line_gestao_api.Data;
using line_gestao_api.Models;
using Microsoft.EntityFrameworkCore;
namespace line_gestao_api.Services;
public class DataConsistencyService : IDataConsistencyService
{
private readonly AppDbContext _db;
public DataConsistencyService(AppDbContext db)
{
_db = db;
}
public async Task SyncFromMobileLineAsync(MobileLine line, CancellationToken cancellationToken = default)
{
await ApplyMobileLineToSatellitesAsync(line, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
}
public async Task SyncFromTrocaNumeroAsync(TrocaNumeroLine trocaNumero, CancellationToken cancellationToken = default)
{
var linhaAntiga = OnlyDigits(trocaNumero.LinhaAntiga);
if (string.IsNullOrWhiteSpace(linhaAntiga))
{
return;
}
var mobile = await _db.MobileLines
.FirstOrDefaultAsync(x => x.Linha != null && OnlyDigits(x.Linha) == linhaAntiga, cancellationToken);
if (mobile == null)
{
return;
}
var linhaNova = OnlyDigits(trocaNumero.LinhaNova);
if (!string.IsNullOrWhiteSpace(linhaNova))
{
mobile.Linha = linhaNova;
}
var iccid = OnlyDigits(trocaNumero.ICCID);
if (!string.IsNullOrWhiteSpace(iccid))
{
mobile.Chip = iccid;
}
mobile.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
await ApplyMobileLineToSatellitesAsync(mobile, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
}
private static string OnlyDigits(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var chars = value.Where(char.IsDigit).ToArray();
return new string(chars);
}
private async Task ApplyMobileLineToSatellitesAsync(MobileLine line, CancellationToken cancellationToken)
{
var item = line.Item;
var vigencias = await _db.VigenciaLines
.Where(x => x.Item == item)
.ToListAsync(cancellationToken);
foreach (var vigencia in vigencias)
{
vigencia.Conta = line.Conta;
vigencia.Linha = line.Linha;
vigencia.Cliente = line.Cliente;
vigencia.Usuario = line.Usuario;
vigencia.PlanoContrato = line.PlanoContrato;
vigencia.UpdatedAt = DateTime.UtcNow;
}
var userDatas = await _db.UserDatas
.Where(x => x.Item == item)
.ToListAsync(cancellationToken);
foreach (var userData in userDatas)
{
userData.Linha = line.Linha;
userData.Cliente = line.Cliente;
userData.UpdatedAt = DateTime.UtcNow;
}
}
}

View File

@ -0,0 +1,9 @@
using line_gestao_api.Models;
namespace line_gestao_api.Services;
public interface IDataConsistencyService
{
Task SyncFromMobileLineAsync(MobileLine line, CancellationToken cancellationToken = default);
Task SyncFromTrocaNumeroAsync(TrocaNumeroLine trocaNumero, CancellationToken cancellationToken = default);
}

View File

@ -16,11 +16,16 @@ public class TenantMiddleware
Guid? tenantId = null; Guid? tenantId = null;
var claim = context.User.FindFirst("tenantId")?.Value var claim = context.User.FindFirst("tenantId")?.Value
?? context.User.FindFirst("tenant")?.Value; ?? context.User.FindFirst("tenant")?.Value;
var headerValue = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (Guid.TryParse(claim, out var parsed)) if (Guid.TryParse(claim, out var parsed))
{ {
tenantId = parsed; tenantId = parsed;
} }
else if (Guid.TryParse(headerValue, out var headerTenant))
{
tenantId = headerTenant;
}
tenantProvider.SetTenantId(tenantId); tenantProvider.SetTenantId(tenantId);