Compare commits
8 Commits
5101c3665a
...
9d7306c395
| Author | SHA1 | Date |
|---|---|---|
|
|
9d7306c395 | |
|
|
1f255888b0 | |
|
|
024b7d299d | |
|
|
242f8bc707 | |
|
|
7a7b5db73e | |
|
|
8f0fa83b78 | |
|
|
0d51d39c5c | |
|
|
0ab7fa955f |
|
|
@ -6,6 +6,9 @@
|
|||
# dotenv files
|
||||
.env
|
||||
appsettings.Local.json
|
||||
appsettings*.json
|
||||
line-gestao-api.csproj
|
||||
line-gestao-api.http
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Globalization;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using line_gestao_api.Data;
|
||||
|
|
@ -22,21 +23,24 @@ public class AuthController : ControllerBase
|
|||
private readonly AppDbContext _db;
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
AppDbContext db,
|
||||
ITenantProvider tenantProvider,
|
||||
IConfiguration config)
|
||||
IConfiguration config,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_db = db;
|
||||
_tenantProvider = tenantProvider;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<IActionResult> Register(RegisterRequest req)
|
||||
{
|
||||
if (req.Password != req.ConfirmPassword)
|
||||
|
|
@ -69,12 +73,14 @@ public class AuthController : ControllerBase
|
|||
if (!createResult.Succeeded)
|
||||
return BadRequest(createResult.Errors.Select(e => e.Description).ToList());
|
||||
|
||||
await _userManager.AddToRoleAsync(user, "leitura");
|
||||
await _userManager.AddToRoleAsync(user, AppRoles.Cliente);
|
||||
|
||||
var effectiveTenantId = await EnsureValidTenantIdAsync(user);
|
||||
if (!effectiveTenantId.HasValue)
|
||||
return Unauthorized("Tenant inválido.");
|
||||
|
||||
await EnsureClientTenantDataBoundAsync(user, effectiveTenantId.Value);
|
||||
|
||||
var token = await GenerateJwtAsync(user, effectiveTenantId.Value);
|
||||
return Ok(new AuthResponse(token));
|
||||
}
|
||||
|
|
@ -141,6 +147,8 @@ public class AuthController : ControllerBase
|
|||
if (!effectiveTenantId.HasValue)
|
||||
return Unauthorized("Tenant inválido.");
|
||||
|
||||
await EnsureClientTenantDataBoundAsync(user, effectiveTenantId.Value);
|
||||
|
||||
var token = await GenerateJwtAsync(user, effectiveTenantId.Value);
|
||||
return Ok(new AuthResponse(token));
|
||||
}
|
||||
|
|
@ -192,23 +200,216 @@ public class AuthController : ControllerBase
|
|||
|
||||
private async Task<Guid?> EnsureValidTenantIdAsync(ApplicationUser user)
|
||||
{
|
||||
if (user.TenantId != Guid.Empty)
|
||||
return user.TenantId;
|
||||
if (user.TenantId == Guid.Empty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fallbackTenantId = await _db.Tenants
|
||||
var existsAndActive = await _db.Tenants
|
||||
.AsNoTracking()
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
.Select(t => (Guid?)t.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
.AnyAsync(t => t.Id == user.TenantId && t.Ativo);
|
||||
|
||||
if (!fallbackTenantId.HasValue || fallbackTenantId.Value == Guid.Empty)
|
||||
return null;
|
||||
|
||||
user.TenantId = fallbackTenantId.Value;
|
||||
var updateResult = await _userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
if (!existsAndActive)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return user.TenantId;
|
||||
}
|
||||
|
||||
private async Task EnsureClientTenantDataBoundAsync(ApplicationUser user, Guid tenantId)
|
||||
{
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
if (!roles.Any(r => string.Equals(r, AppRoles.Cliente, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await RebindMobileLinesToTenantBySourceKeyAsync(tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Falha ao sincronizar linhas para tenant {TenantId} durante login de cliente.", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> RebindMobileLinesToTenantBySourceKeyAsync(Guid tenantId)
|
||||
{
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var tenant = await _db.Tenants
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == tenantId && t.Ativo && !t.IsSystem);
|
||||
|
||||
if (tenant == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
tenant.SourceType,
|
||||
SystemTenantConstants.MobileLinesClienteSourceType,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var normalizedKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
AddNormalizedTenantKey(normalizedKeys, tenant.SourceKey);
|
||||
AddNormalizedTenantKey(normalizedKeys, tenant.NomeOficial);
|
||||
|
||||
if (normalizedKeys.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var candidates = await _db.MobileLines
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId != tenant.Id)
|
||||
.Where(x => x.Cliente != null && x.Cliente != string.Empty)
|
||||
.ToListAsync();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var hasAnyTenantLine = await _db.MobileLines
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.AnyAsync(x => x.TenantId == tenant.Id);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var reassigned = ReassignByMatcher(
|
||||
candidates,
|
||||
normalizedKeys,
|
||||
tenant.Id,
|
||||
now,
|
||||
isRelaxedMatch: false);
|
||||
|
||||
if (reassigned == 0 && !hasAnyTenantLine)
|
||||
{
|
||||
reassigned = ReassignByMatcher(
|
||||
candidates,
|
||||
normalizedKeys,
|
||||
tenant.Id,
|
||||
now,
|
||||
isRelaxedMatch: true);
|
||||
}
|
||||
|
||||
if (reassigned > 0)
|
||||
{
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return reassigned;
|
||||
}
|
||||
|
||||
private static int ReassignByMatcher(
|
||||
IReadOnlyList<MobileLine> candidates,
|
||||
IReadOnlyCollection<string> normalizedKeys,
|
||||
Guid tenantId,
|
||||
DateTime now,
|
||||
bool isRelaxedMatch)
|
||||
{
|
||||
if (normalizedKeys.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var keys = isRelaxedMatch
|
||||
? normalizedKeys.Where(k => k.Length >= 6).ToList()
|
||||
: normalizedKeys.ToList();
|
||||
|
||||
if (keys.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var reassigned = 0;
|
||||
|
||||
foreach (var line in candidates)
|
||||
{
|
||||
var normalizedClient = NormalizeTenantKey(line.Cliente ?? string.Empty);
|
||||
if (string.IsNullOrWhiteSpace(normalizedClient) ||
|
||||
string.Equals(normalizedClient, "RESERVA", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var matches = !isRelaxedMatch
|
||||
? keys.Contains(normalizedClient, StringComparer.Ordinal)
|
||||
: keys.Any(k =>
|
||||
normalizedClient.Contains(k, StringComparison.Ordinal) ||
|
||||
k.Contains(normalizedClient, StringComparison.Ordinal));
|
||||
|
||||
if (!matches)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
line.TenantId = tenantId;
|
||||
line.UpdatedAt = now;
|
||||
reassigned++;
|
||||
}
|
||||
|
||||
return reassigned;
|
||||
}
|
||||
|
||||
private static void AddNormalizedTenantKey(ISet<string> keys, string? rawKey)
|
||||
{
|
||||
var normalized = NormalizeTenantKey(rawKey ?? string.Empty);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(normalized, "RESERVA", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
keys.Add(normalized);
|
||||
}
|
||||
|
||||
private static string NormalizeTenantKey(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().Normalize(NormalizationForm.FormD);
|
||||
var sb = new StringBuilder(normalized.Length);
|
||||
var previousWasSpace = false;
|
||||
|
||||
foreach (var ch in normalized)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(ch);
|
||||
if (category == UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsWhiteSpace(ch))
|
||||
{
|
||||
if (!previousWasSpace)
|
||||
{
|
||||
sb.Append(' ');
|
||||
previousWasSpace = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(char.ToUpperInvariant(ch));
|
||||
previousWasSpace = false;
|
||||
}
|
||||
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace line_gestao_api.Controllers
|
|||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public class BillingController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
|
@ -43,11 +43,20 @@ namespace line_gestao_api.Controllers
|
|||
var s = search.Trim();
|
||||
var hasNumberSearch = TryParseSearchDecimal(s, out var searchNumber);
|
||||
var hasIntSearch = int.TryParse(new string(s.Where(char.IsDigit).ToArray()), out var searchInt);
|
||||
var matchingClientsByLineOrChip = _db.MobileLines.AsNoTracking()
|
||||
.Where(m =>
|
||||
EF.Functions.ILike(m.Linha ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))
|
||||
.Where(m => m.Cliente != null && m.Cliente != "")
|
||||
.Select(m => m.Cliente!)
|
||||
.Distinct();
|
||||
|
||||
q = q.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%")
|
||||
|| EF.Functions.ILike(x.Tipo ?? "", $"%{s}%")
|
||||
|| EF.Functions.ILike(x.Item.ToString(), $"%{s}%")
|
||||
|| EF.Functions.ILike(x.Aparelho ?? "", $"%{s}%")
|
||||
|| EF.Functions.ILike(x.FormaPagamento ?? "", $"%{s}%")
|
||||
|| (x.Cliente != null && matchingClientsByLineOrChip.Contains(x.Cliente))
|
||||
|| (hasIntSearch && (x.QtdLinhas ?? 0) == searchInt)
|
||||
|| (hasNumberSearch &&
|
||||
(((x.FranquiaVivo ?? 0m) == searchNumber) ||
|
||||
|
|
@ -188,7 +197,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateBillingClientRequest req)
|
||||
{
|
||||
var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
|
@ -221,7 +230,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers
|
|||
{
|
||||
[ApiController]
|
||||
[Route("api/chips-virgens")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public class ChipsVirgensController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
|
@ -93,7 +93,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "admin,gestor")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<ActionResult<ChipVirgemDetailDto>> Create([FromBody] CreateChipVirgemDto req)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
|
@ -122,7 +122,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateChipVirgemRequest req)
|
||||
{
|
||||
var x = await _db.ChipVirgemLines.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
|
@ -139,7 +139,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var x = await _db.ChipVirgemLines.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers
|
|||
{
|
||||
[ApiController]
|
||||
[Route("api/controle-recebidos")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public class ControleRecebidosController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
|
@ -138,7 +138,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "admin,gestor")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<ActionResult<ControleRecebidoDetailDto>> Create([FromBody] CreateControleRecebidoDto req)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
|
@ -195,7 +195,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateControleRecebidoRequest req)
|
||||
{
|
||||
var x = await _db.ControleRecebidoLines.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
|
@ -223,7 +223,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var x = await _db.ControleRecebidoLines.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers;
|
|||
|
||||
[ApiController]
|
||||
[Route("api/historico")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public class HistoricoController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
|
@ -26,7 +26,7 @@ public class HistoricoController : ControllerBase
|
|||
[FromQuery] string? pageName,
|
||||
[FromQuery] string? action,
|
||||
[FromQuery] string? entity,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? user,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] DateTime? dateFrom,
|
||||
[FromQuery] DateTime? dateTo,
|
||||
|
|
@ -60,15 +60,17 @@ public class HistoricoController : ControllerBase
|
|||
q = q.Where(x => EF.Functions.ILike(x.EntityName, $"%{e}%"));
|
||||
}
|
||||
|
||||
if (userId.HasValue)
|
||||
if (!string.IsNullOrWhiteSpace(user))
|
||||
{
|
||||
q = q.Where(x => x.UserId == userId.Value);
|
||||
var u = user.Trim();
|
||||
q = q.Where(x =>
|
||||
EF.Functions.ILike(x.UserName ?? "", $"%{u}%") ||
|
||||
EF.Functions.ILike(x.UserEmail ?? "", $"%{u}%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var s = search.Trim();
|
||||
var hasGuidSearch = Guid.TryParse(s, out var searchGuid);
|
||||
var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc);
|
||||
var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue;
|
||||
q = q.Where(x =>
|
||||
|
|
@ -83,7 +85,6 @@ public class HistoricoController : ControllerBase
|
|||
EF.Functions.ILike(x.RequestMethod ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.IpAddress ?? "", $"%{s}%") ||
|
||||
// ChangesJson is stored as jsonb; applying ILIKE directly causes PostgreSQL 42883.
|
||||
(hasGuidSearch && (x.Id == searchGuid || x.UserId == searchGuid)) ||
|
||||
(hasDateSearch &&
|
||||
x.OccurredAtUtc >= searchDateStartUtc &&
|
||||
x.OccurredAtUtc < searchDateEndUtc));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -187,7 +187,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "admin,gestor")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<ActionResult<MuregDetailDto>> Create([FromBody] CreateMuregDto req)
|
||||
{
|
||||
if (req.MobileLineId == Guid.Empty)
|
||||
|
|
@ -289,7 +289,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "admin,gestor")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateMuregDto req)
|
||||
{
|
||||
var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
|
@ -361,7 +361,7 @@ namespace line_gestao_api.Controllers
|
|||
// Exclui registro MUREG
|
||||
// ==========================================================
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
|
@ -377,7 +377,7 @@ namespace line_gestao_api.Controllers
|
|||
// ✅ POST: /api/mureg/import-excel (mantido)
|
||||
// ==========================================================
|
||||
[HttpPost("import-excel")]
|
||||
[Authorize(Roles = "admin,gestor")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(50_000_000)]
|
||||
public async Task<IActionResult> ImportExcel([FromForm] ImportExcelForm form)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,9 @@ public class NotificationsController : ControllerBase
|
|||
DiasParaVencer = notification.DiasParaVencer,
|
||||
Lida = notification.Lida,
|
||||
LidaEm = notification.LidaEm,
|
||||
VigenciaLineId = notification.VigenciaLineId,
|
||||
VigenciaLineId = notification.VigenciaLineId
|
||||
?? (vigencia != null ? (Guid?)vigencia.Id : null)
|
||||
?? (vigenciaByLinha != null ? (Guid?)vigenciaByLinha.Id : null),
|
||||
Cliente = notification.Cliente
|
||||
?? (vigencia != null ? vigencia.Cliente : null)
|
||||
?? (vigenciaByLinha != null ? vigenciaByLinha.Cliente : null),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ namespace line_gestao_api.Controllers;
|
|||
|
||||
[ApiController]
|
||||
[Route("api/parcelamentos")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public class ParcelamentosController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
|
@ -165,7 +165,7 @@ public class ParcelamentosController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "admin,gestor")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<ActionResult<ParcelamentoDetailDto>> Create([FromBody] ParcelamentoUpsertDto req)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
|
@ -202,7 +202,7 @@ public class ParcelamentosController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "admin,gestor")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] ParcelamentoUpsertDto req)
|
||||
{
|
||||
var entity = await _db.ParcelamentoLines
|
||||
|
|
@ -239,7 +239,7 @@ public class ParcelamentosController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var entity = await _db.ParcelamentoLines.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ namespace line_gestao_api.Controllers
|
|||
// GERAL (MobileLines)
|
||||
// =========================
|
||||
var qLines = _db.MobileLines.AsNoTracking();
|
||||
var qLinesWithClient = qLines.Where(x => x.Cliente != null && x.Cliente != "");
|
||||
|
||||
var totalLinhas = await qLines.CountAsync();
|
||||
|
||||
|
|
@ -44,27 +45,35 @@ namespace line_gestao_api.Controllers
|
|||
var ativos = await qLines.CountAsync(x =>
|
||||
EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%"));
|
||||
|
||||
var bloqueadosPerdaRoubo = await qLines.CountAsync(x =>
|
||||
var bloqueadosPerdaRoubo = await qLinesWithClient.CountAsync(x =>
|
||||
EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") ||
|
||||
EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%"));
|
||||
|
||||
var bloqueados120Dias = await qLines.CountAsync(x =>
|
||||
var bloqueados120Dias = await qLinesWithClient.CountAsync(x =>
|
||||
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(), "%dia%") &&
|
||||
!(EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") ||
|
||||
EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%")));
|
||||
|
||||
var bloqueadosOutros = await qLines.CountAsync(x =>
|
||||
var bloqueadosOutros = await qLinesWithClient.CountAsync(x =>
|
||||
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(), "%perda%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%"))
|
||||
);
|
||||
|
||||
var bloqueados = bloqueadosPerdaRoubo + bloqueados120Dias + bloqueadosOutros;
|
||||
// Regra do KPI "Bloqueadas" alinhada ao critério da página Geral:
|
||||
// status contendo "bloque", "perda" ou "roubo".
|
||||
var bloqueados = await qLinesWithClient.CountAsync(x =>
|
||||
EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") ||
|
||||
EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") ||
|
||||
EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%"));
|
||||
|
||||
// Regra alinhada ao filtro "Reservas" da página Geral.
|
||||
var reservas = await qLines.CountAsync(x =>
|
||||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "%RESERVA%") ||
|
||||
EF.Functions.ILike((x.Usuario ?? "").Trim(), "%RESERVA%") ||
|
||||
EF.Functions.ILike((x.Skil ?? "").Trim(), "%RESERVA%"));
|
||||
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
|
||||
.Where(x => x.Cliente != null && x.Cliente != "")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using System.Linq;
|
||||
using line_gestao_api.Data;
|
||||
using line_gestao_api.Dtos;
|
||||
using line_gestao_api.Services;
|
||||
|
|
@ -12,6 +14,8 @@ namespace line_gestao_api.Controllers;
|
|||
[Authorize]
|
||||
public class ResumoController : ControllerBase
|
||||
{
|
||||
private static readonly Regex PlanGbRegex = new(@"(\d+(?:[.,]\d+)?)\s*(GB|MB)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService;
|
||||
|
||||
|
|
@ -23,6 +27,36 @@ public class ResumoController : ControllerBase
|
|||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ResumoResponseDto>> GetResumo()
|
||||
{
|
||||
var spreadsheetResponse = await BuildSpreadsheetResumoAsync();
|
||||
var hasLiveLines = await _db.MobileLines.AsNoTracking().AnyAsync();
|
||||
|
||||
if (hasLiveLines)
|
||||
{
|
||||
var live = await BuildLiveResumoAsync();
|
||||
|
||||
spreadsheetResponse.MacrophonyPlans = live.MacrophonyPlans;
|
||||
spreadsheetResponse.MacrophonyTotals = live.MacrophonyTotals;
|
||||
spreadsheetResponse.VivoLineResumos = live.VivoLineResumos;
|
||||
spreadsheetResponse.VivoLineTotals = live.VivoLineTotals;
|
||||
spreadsheetResponse.PlanoContratoResumos = live.PlanoContratoResumos;
|
||||
spreadsheetResponse.PlanoContratoTotal = live.PlanoContratoTotal;
|
||||
spreadsheetResponse.LineTotais = live.LineTotais;
|
||||
spreadsheetResponse.GbDistribuicao = live.GbDistribuicao;
|
||||
spreadsheetResponse.GbDistribuicaoTotal = live.GbDistribuicaoTotal;
|
||||
spreadsheetResponse.ReservaLines = live.ReservaLines;
|
||||
spreadsheetResponse.ReservaPorDdd = live.ReservaPorDdd;
|
||||
spreadsheetResponse.TotalGeralLinhasReserva = live.TotalGeralLinhasReserva;
|
||||
spreadsheetResponse.ReservaTotal = live.ReservaTotal;
|
||||
}
|
||||
|
||||
spreadsheetResponse.MacrophonyTotals ??= new ResumoMacrophonyTotalDto();
|
||||
spreadsheetResponse.VivoLineTotals ??= new ResumoVivoLineTotalDto();
|
||||
|
||||
return Ok(spreadsheetResponse);
|
||||
}
|
||||
|
||||
private async Task<ResumoResponseDto> BuildSpreadsheetResumoAsync()
|
||||
{
|
||||
var reservaLines = await _db.ResumoReservaLines.AsNoTracking()
|
||||
.OrderBy(x => x.Ddd)
|
||||
|
|
@ -177,6 +211,302 @@ public class ResumoController : ControllerBase
|
|||
response.VivoLineTotals ??= new ResumoVivoLineTotalDto();
|
||||
response.VivoLineTotals.QtdLinhasTotal = canonicalTotalLinhas;
|
||||
|
||||
return Ok(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<ResumoResponseDto> BuildLiveResumoAsync()
|
||||
{
|
||||
var allLines = _db.MobileLines.AsNoTracking();
|
||||
var nonReservaLines = allLines.Where(x =>
|
||||
!EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") &&
|
||||
!EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") &&
|
||||
!EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
|
||||
var reservaLinesQuery = allLines.Where(x =>
|
||||
EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") ||
|
||||
EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") ||
|
||||
EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
|
||||
|
||||
var planosAgg = await nonReservaLines
|
||||
.GroupBy(x => (x.PlanoContrato ?? "").Trim())
|
||||
.Select(g => new
|
||||
{
|
||||
Plano = g.Key,
|
||||
TotalLinhas = g.Count(),
|
||||
FranquiaMedia = g.Average(x => (decimal?)x.FranquiaVivo),
|
||||
ValorTotal = g.Sum(x => x.ValorContratoVivo ?? 0m),
|
||||
TravelCount = g.Count(x => (x.VivoTravelMundo ?? 0m) > 0m)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var planoRows = planosAgg
|
||||
.Select(row =>
|
||||
{
|
||||
var planoLabel = string.IsNullOrWhiteSpace(row.Plano) ? "SEM PLANO" : row.Plano;
|
||||
var franquiaGb = (row.FranquiaMedia ?? 0m) > 0m
|
||||
? row.FranquiaMedia
|
||||
: ExtractGbFromPlanName(planoLabel);
|
||||
var valorIndividual = row.TotalLinhas > 0 ? row.ValorTotal / row.TotalLinhas : (decimal?)null;
|
||||
|
||||
return new ResumoPlanoContratoResumoDto
|
||||
{
|
||||
PlanoContrato = planoLabel,
|
||||
Gb = franquiaGb,
|
||||
FranquiaGb = franquiaGb,
|
||||
TotalLinhas = row.TotalLinhas,
|
||||
ValorTotal = row.ValorTotal,
|
||||
ValorIndividualComSvas = valorIndividual
|
||||
};
|
||||
})
|
||||
.OrderByDescending(x => x.TotalLinhas ?? 0)
|
||||
.ThenBy(x => x.PlanoContrato)
|
||||
.ToList();
|
||||
|
||||
var macrophonyRows = planosAgg
|
||||
.Select(row =>
|
||||
{
|
||||
var planoLabel = string.IsNullOrWhiteSpace(row.Plano) ? "SEM PLANO" : row.Plano;
|
||||
var franquiaGb = (row.FranquiaMedia ?? 0m) > 0m
|
||||
? row.FranquiaMedia
|
||||
: ExtractGbFromPlanName(planoLabel);
|
||||
var valorIndividual = row.TotalLinhas > 0 ? row.ValorTotal / row.TotalLinhas : (decimal?)null;
|
||||
|
||||
return new ResumoMacrophonyPlanDto
|
||||
{
|
||||
PlanoContrato = planoLabel,
|
||||
Gb = franquiaGb,
|
||||
FranquiaGb = franquiaGb,
|
||||
TotalLinhas = row.TotalLinhas,
|
||||
ValorTotal = row.ValorTotal,
|
||||
ValorIndividualComSvas = valorIndividual,
|
||||
VivoTravel = row.TravelCount > 0
|
||||
};
|
||||
})
|
||||
.OrderByDescending(x => x.TotalLinhas ?? 0)
|
||||
.ThenBy(x => x.PlanoContrato)
|
||||
.ToList();
|
||||
|
||||
var clientesAgg = await nonReservaLines
|
||||
.GroupBy(x => (x.Cliente ?? "").Trim())
|
||||
.Select(g => new
|
||||
{
|
||||
Cliente = g.Key,
|
||||
QtdLinhas = g.Count(),
|
||||
FranquiaTotal = g.Sum(x => x.FranquiaVivo ?? 0m),
|
||||
ValorContratoVivo = g.Sum(x => x.ValorContratoVivo ?? 0m),
|
||||
FranquiaLine = g.Sum(x => x.FranquiaLine ?? 0m),
|
||||
ValorContratoLine = g.Sum(x => x.ValorContratoLine ?? 0m),
|
||||
Lucro = g.Sum(x => x.Lucro ?? 0m)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var clientesRows = clientesAgg
|
||||
.Select(row => new ResumoVivoLineResumoDto
|
||||
{
|
||||
Cliente = string.IsNullOrWhiteSpace(row.Cliente) ? "SEM CLIENTE" : row.Cliente,
|
||||
QtdLinhas = row.QtdLinhas,
|
||||
FranquiaTotal = row.FranquiaTotal,
|
||||
ValorContratoVivo = row.ValorContratoVivo,
|
||||
FranquiaLine = row.FranquiaLine,
|
||||
ValorContratoLine = row.ValorContratoLine,
|
||||
Lucro = row.Lucro
|
||||
})
|
||||
.OrderByDescending(x => x.QtdLinhas ?? 0)
|
||||
.ThenBy(x => x.Cliente)
|
||||
.ToList();
|
||||
|
||||
var totalLinhasNaoReserva = clientesRows.Sum(x => x.QtdLinhas ?? 0);
|
||||
var totalFranquiaNaoReserva = clientesRows.Sum(x => x.FranquiaTotal ?? 0m);
|
||||
var totalValorContratoVivo = clientesRows.Sum(x => x.ValorContratoVivo ?? 0m);
|
||||
var totalFranquiaLine = clientesRows.Sum(x => x.FranquiaLine ?? 0m);
|
||||
var totalValorContratoLine = clientesRows.Sum(x => x.ValorContratoLine ?? 0m);
|
||||
var totalLucro = clientesRows.Sum(x => x.Lucro ?? 0m);
|
||||
|
||||
var pfAtivas = await nonReservaLines
|
||||
.Where(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%"))
|
||||
.Where(x =>
|
||||
EF.Functions.ILike((x.Skil ?? "").Trim(), "%fís%") ||
|
||||
EF.Functions.ILike((x.Skil ?? "").Trim(), "%fis%") ||
|
||||
EF.Functions.ILike((x.Skil ?? "").Trim(), "%pf%"))
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new
|
||||
{
|
||||
QtdLinhas = g.Count(),
|
||||
ValorTotal = g.Sum(x => x.ValorContratoLine ?? 0m),
|
||||
LucroTotal = g.Sum(x => x.Lucro ?? 0m)
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var pjAtivas = await nonReservaLines
|
||||
.Where(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%"))
|
||||
.Where(x =>
|
||||
EF.Functions.ILike((x.Skil ?? "").Trim(), "%jur%") ||
|
||||
EF.Functions.ILike((x.Skil ?? "").Trim(), "%pj%"))
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new
|
||||
{
|
||||
QtdLinhas = g.Count(),
|
||||
ValorTotal = g.Sum(x => x.ValorContratoLine ?? 0m),
|
||||
LucroTotal = g.Sum(x => x.Lucro ?? 0m)
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var totaisLine = new List<ResumoLineTotaisDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Tipo = "PF",
|
||||
QtdLinhas = pfAtivas?.QtdLinhas ?? 0,
|
||||
ValorTotalLine = pfAtivas?.ValorTotal ?? 0m,
|
||||
LucroTotalLine = pfAtivas?.LucroTotal ?? 0m
|
||||
},
|
||||
new()
|
||||
{
|
||||
Tipo = "PJ",
|
||||
QtdLinhas = pjAtivas?.QtdLinhas ?? 0,
|
||||
ValorTotalLine = pjAtivas?.ValorTotal ?? 0m,
|
||||
LucroTotalLine = pjAtivas?.LucroTotal ?? 0m
|
||||
},
|
||||
new()
|
||||
{
|
||||
Tipo = "TOTAL",
|
||||
QtdLinhas = totalLinhasNaoReserva,
|
||||
ValorTotalLine = totalValorContratoLine,
|
||||
LucroTotalLine = totalLucro
|
||||
}
|
||||
};
|
||||
|
||||
var gbDistribuicao = await nonReservaLines
|
||||
.Where(x => (x.FranquiaVivo ?? 0m) > 0m)
|
||||
.GroupBy(x => x.FranquiaVivo ?? 0m)
|
||||
.Select(g => new ResumoGbDistribuicaoDto
|
||||
{
|
||||
Gb = g.Key,
|
||||
Qtd = g.Count(),
|
||||
Soma = g.Sum(x => x.FranquiaVivo ?? 0m)
|
||||
})
|
||||
.OrderBy(x => x.Gb)
|
||||
.ToListAsync();
|
||||
|
||||
var gbDistribuicaoTotal = new ResumoGbDistribuicaoTotalDto
|
||||
{
|
||||
TotalLinhas = gbDistribuicao.Sum(x => x.Qtd ?? 0),
|
||||
SomaTotal = gbDistribuicao.Sum(x => x.Soma ?? 0m)
|
||||
};
|
||||
|
||||
var reservaSnapshot = await reservaLinesQuery
|
||||
.Select(x => new
|
||||
{
|
||||
x.Linha,
|
||||
x.FranquiaVivo,
|
||||
x.ValorContratoVivo
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var reservaGroupRaw = reservaSnapshot
|
||||
.Select(row => new
|
||||
{
|
||||
Ddd = ExtractDddFromLine(row.Linha) ?? "-",
|
||||
FranquiaGb = row.FranquiaVivo
|
||||
})
|
||||
.GroupBy(x => new { x.Ddd, x.FranquiaGb })
|
||||
.Select(g => new ResumoReservaLineDto
|
||||
{
|
||||
Ddd = g.Key.Ddd,
|
||||
FranquiaGb = g.Key.FranquiaGb,
|
||||
QtdLinhas = g.Count(),
|
||||
Total = null
|
||||
})
|
||||
.OrderBy(x => x.Ddd)
|
||||
.ThenBy(x => x.FranquiaGb ?? 0m)
|
||||
.ToList();
|
||||
|
||||
var reservaPorDdd = reservaGroupRaw
|
||||
.GroupBy(x => x.Ddd ?? "-")
|
||||
.Select(g => new ResumoReservaPorDddDto
|
||||
{
|
||||
Ddd = g.Key,
|
||||
TotalLinhas = g.Sum(x => x.QtdLinhas ?? 0),
|
||||
PorFranquia = g
|
||||
.GroupBy(x => x.FranquiaGb)
|
||||
.Select(fg => new ResumoReservaPorFranquiaDto
|
||||
{
|
||||
FranquiaGb = fg.Key,
|
||||
TotalLinhas = fg.Sum(x => x.QtdLinhas ?? 0)
|
||||
})
|
||||
.OrderBy(x => x.FranquiaGb ?? 0m)
|
||||
.ToList()
|
||||
})
|
||||
.OrderBy(x => x.Ddd)
|
||||
.ToList();
|
||||
|
||||
var reservaTotalLinhas = reservaSnapshot.Count;
|
||||
var reservaTotalValor = reservaSnapshot.Sum(x => x.ValorContratoVivo ?? 0m);
|
||||
|
||||
return new ResumoResponseDto
|
||||
{
|
||||
MacrophonyPlans = macrophonyRows,
|
||||
MacrophonyTotals = new ResumoMacrophonyTotalDto
|
||||
{
|
||||
TotalLinhasTotal = totalLinhasNaoReserva,
|
||||
FranquiaGbTotal = totalFranquiaNaoReserva,
|
||||
ValorTotal = totalValorContratoVivo
|
||||
},
|
||||
VivoLineResumos = clientesRows,
|
||||
VivoLineTotals = new ResumoVivoLineTotalDto
|
||||
{
|
||||
QtdLinhasTotal = totalLinhasNaoReserva,
|
||||
FranquiaTotal = totalFranquiaNaoReserva,
|
||||
ValorContratoVivo = totalValorContratoVivo,
|
||||
FranquiaLine = totalFranquiaLine,
|
||||
ValorContratoLine = totalValorContratoLine,
|
||||
Lucro = totalLucro
|
||||
},
|
||||
PlanoContratoResumos = planoRows,
|
||||
PlanoContratoTotal = new ResumoPlanoContratoTotalDto
|
||||
{
|
||||
ValorTotal = planoRows.Sum(x => x.ValorTotal ?? 0m)
|
||||
},
|
||||
LineTotais = totaisLine,
|
||||
GbDistribuicao = gbDistribuicao,
|
||||
GbDistribuicaoTotal = gbDistribuicaoTotal,
|
||||
ReservaLines = reservaGroupRaw,
|
||||
ReservaPorDdd = reservaPorDdd,
|
||||
TotalGeralLinhasReserva = reservaTotalLinhas,
|
||||
ReservaTotal = new ResumoReservaTotalDto
|
||||
{
|
||||
QtdLinhasTotal = reservaTotalLinhas,
|
||||
Total = reservaTotalValor
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal? ExtractGbFromPlanName(string? planoContrato)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(planoContrato))
|
||||
return null;
|
||||
|
||||
var match = PlanGbRegex.Match(planoContrato);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
var normalized = match.Groups[1].Value.Replace(',', '.');
|
||||
if (!decimal.TryParse(normalized, out var rawValue))
|
||||
return null;
|
||||
|
||||
var unit = match.Groups[2].Value.ToUpperInvariant();
|
||||
return unit == "MB" ? rawValue / 1000m : rawValue;
|
||||
}
|
||||
|
||||
private static string? ExtractDddFromLine(string? linha)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(linha))
|
||||
return null;
|
||||
|
||||
var digits = new string(linha.Where(char.IsDigit).ToArray());
|
||||
if (digits.Length >= 12 && digits.StartsWith("55"))
|
||||
return digits.Substring(2, 2);
|
||||
if (digits.Length >= 10)
|
||||
return digits.Substring(0, 2);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,424 @@
|
|||
using line_gestao_api.Data;
|
||||
using line_gestao_api.Dtos;
|
||||
using line_gestao_api.Models;
|
||||
using line_gestao_api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace line_gestao_api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/system/tenants/{tenantId:guid}/users")]
|
||||
[Authorize(Policy = "SystemAdmin")]
|
||||
public class SystemTenantUsersController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly RoleManager<IdentityRole<Guid>> _roleManager;
|
||||
private readonly ISystemAuditService _systemAuditService;
|
||||
|
||||
public SystemTenantUsersController(
|
||||
AppDbContext db,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
RoleManager<IdentityRole<Guid>> roleManager,
|
||||
ISystemAuditService systemAuditService)
|
||||
{
|
||||
_db = db;
|
||||
_userManager = userManager;
|
||||
_roleManager = roleManager;
|
||||
_systemAuditService = systemAuditService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SystemTenantUserCreatedDto>> CreateUserForTenant(
|
||||
[FromRoute] Guid tenantId,
|
||||
[FromBody] CreateSystemTenantUserRequest request)
|
||||
{
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "TenantId inválido.", "invalid_tenant_id");
|
||||
}
|
||||
|
||||
var tenant = await _db.Tenants.AsNoTracking().FirstOrDefaultAsync(t => t.Id == tenantId);
|
||||
if (tenant == null)
|
||||
{
|
||||
return await RejectAsync(tenantId, StatusCodes.Status404NotFound, "Tenant não encontrado.", "tenant_not_found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Email))
|
||||
{
|
||||
return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Email é obrigatório.", "missing_email");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Password))
|
||||
{
|
||||
return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Password é obrigatória.", "missing_password");
|
||||
}
|
||||
|
||||
if (request.Roles == null || request.Roles.Count == 0)
|
||||
{
|
||||
return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Informe ao menos uma role.", "missing_roles");
|
||||
}
|
||||
|
||||
var normalizedRoles = request.Roles
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r))
|
||||
.Select(r => r.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (normalizedRoles.Count == 0)
|
||||
{
|
||||
return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Roles inválidas.", "invalid_roles");
|
||||
}
|
||||
|
||||
var unsupportedRoles = normalizedRoles
|
||||
.Where(role => !AppRoles.All.Contains(role, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (unsupportedRoles.Count > 0)
|
||||
{
|
||||
return await RejectAsync(
|
||||
tenantId,
|
||||
StatusCodes.Status400BadRequest,
|
||||
$"Roles não suportadas: {string.Join(", ", unsupportedRoles)}. Use apenas: {string.Join(", ", AppRoles.All)}.",
|
||||
"unsupported_roles");
|
||||
}
|
||||
|
||||
if (request.ClientCredentialsOnly)
|
||||
{
|
||||
if (tenant.IsSystem)
|
||||
{
|
||||
return await RejectAsync(
|
||||
tenantId,
|
||||
StatusCodes.Status400BadRequest,
|
||||
"Credenciais de cliente não podem ser criadas no SystemTenant.",
|
||||
"invalid_client_credentials_on_system_tenant");
|
||||
}
|
||||
|
||||
if (normalizedRoles.Count != 1 || !normalizedRoles.Contains(AppRoles.Cliente, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return await RejectAsync(
|
||||
tenantId,
|
||||
StatusCodes.Status400BadRequest,
|
||||
"Neste fluxo, somente a role cliente é permitida.",
|
||||
"invalid_roles_for_client_credentials_flow");
|
||||
}
|
||||
}
|
||||
|
||||
if (!tenant.IsSystem && normalizedRoles.Contains(SystemTenantConstants.SystemRole))
|
||||
{
|
||||
return await RejectAsync(
|
||||
tenantId,
|
||||
StatusCodes.Status400BadRequest,
|
||||
"A role sysadmin só pode ser usada no SystemTenant.",
|
||||
"invalid_sysadmin_outside_system_tenant");
|
||||
}
|
||||
|
||||
if (tenant.IsSystem && normalizedRoles.Any(r => r != SystemTenantConstants.SystemRole))
|
||||
{
|
||||
return await RejectAsync(
|
||||
tenantId,
|
||||
StatusCodes.Status400BadRequest,
|
||||
"No SystemTenant é permitido apenas a role sysadmin.",
|
||||
"invalid_non_system_role_for_system_tenant");
|
||||
}
|
||||
|
||||
foreach (var role in normalizedRoles)
|
||||
{
|
||||
if (!await _roleManager.RoleExistsAsync(role))
|
||||
{
|
||||
return await RejectAsync(
|
||||
tenantId,
|
||||
StatusCodes.Status400BadRequest,
|
||||
$"Role inexistente: {role}",
|
||||
"role_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
var email = request.Email.Trim().ToLowerInvariant();
|
||||
var normalizedEmail = _userManager.NormalizeEmail(email);
|
||||
|
||||
var alreadyExists = await _userManager.Users
|
||||
.IgnoreQueryFilters()
|
||||
.AnyAsync(u => u.TenantId == tenantId && u.NormalizedEmail == normalizedEmail);
|
||||
|
||||
if (alreadyExists)
|
||||
{
|
||||
return await RejectAsync(tenantId, StatusCodes.Status409Conflict, "Já existe usuário com este email neste tenant.", "email_exists");
|
||||
}
|
||||
|
||||
var name = string.IsNullOrWhiteSpace(request.Name)
|
||||
? email
|
||||
: request.Name.Trim();
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Name = name,
|
||||
Email = email,
|
||||
UserName = email,
|
||||
TenantId = tenantId,
|
||||
EmailConfirmed = true,
|
||||
IsActive = true,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
|
||||
IdentityResult createResult;
|
||||
try
|
||||
{
|
||||
createResult = await _userManager.CreateAsync(user, request.Password);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return await RejectAsync(
|
||||
tenantId,
|
||||
StatusCodes.Status409Conflict,
|
||||
"Não foi possível criar usuário. Email/username já em uso.",
|
||||
"db_conflict");
|
||||
}
|
||||
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
await _systemAuditService.LogAsync(
|
||||
action: SystemAuditActions.CreateTenantUserRejected,
|
||||
targetTenantId: tenantId,
|
||||
metadata: new
|
||||
{
|
||||
reason = "identity_create_failed",
|
||||
email,
|
||||
errors = createResult.Errors.Select(e => e.Description).ToList()
|
||||
});
|
||||
|
||||
return BadRequest(createResult.Errors.Select(e => e.Description).ToList());
|
||||
}
|
||||
|
||||
var addRolesResult = await _userManager.AddToRolesAsync(user, normalizedRoles);
|
||||
if (!addRolesResult.Succeeded)
|
||||
{
|
||||
await _userManager.DeleteAsync(user);
|
||||
await _systemAuditService.LogAsync(
|
||||
action: SystemAuditActions.CreateTenantUserRejected,
|
||||
targetTenantId: tenantId,
|
||||
metadata: new
|
||||
{
|
||||
reason = "identity_add_roles_failed",
|
||||
email,
|
||||
roles = normalizedRoles,
|
||||
errors = addRolesResult.Errors.Select(e => e.Description).ToList()
|
||||
});
|
||||
|
||||
return BadRequest(addRolesResult.Errors.Select(e => e.Description).ToList());
|
||||
}
|
||||
|
||||
var linesReassigned = 0;
|
||||
if (request.ClientCredentialsOnly)
|
||||
{
|
||||
linesReassigned = await RebindMobileLinesToTenantBySourceKeyAsync(tenant);
|
||||
}
|
||||
|
||||
await _systemAuditService.LogAsync(
|
||||
action: SystemAuditActions.CreateTenantUser,
|
||||
targetTenantId: tenantId,
|
||||
metadata: new
|
||||
{
|
||||
createdUserId = user.Id,
|
||||
email,
|
||||
roles = normalizedRoles,
|
||||
linesReassigned
|
||||
});
|
||||
|
||||
var response = new SystemTenantUserCreatedDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = tenantId,
|
||||
Email = email,
|
||||
Roles = normalizedRoles
|
||||
};
|
||||
|
||||
return StatusCode(StatusCodes.Status201Created, response);
|
||||
}
|
||||
|
||||
private async Task<ActionResult<SystemTenantUserCreatedDto>> RejectAsync(
|
||||
Guid targetTenantId,
|
||||
int statusCode,
|
||||
string message,
|
||||
string reason)
|
||||
{
|
||||
await _systemAuditService.LogAsync(
|
||||
action: SystemAuditActions.CreateTenantUserRejected,
|
||||
targetTenantId: targetTenantId,
|
||||
metadata: new { reason, message });
|
||||
|
||||
return StatusCode(statusCode, message);
|
||||
}
|
||||
|
||||
private async Task<int> RebindMobileLinesToTenantBySourceKeyAsync(Tenant tenant)
|
||||
{
|
||||
if (tenant.Id == Guid.Empty)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
tenant.SourceType,
|
||||
SystemTenantConstants.MobileLinesClienteSourceType,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var normalizedKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
AddNormalizedTenantKey(normalizedKeys, tenant.SourceKey);
|
||||
AddNormalizedTenantKey(normalizedKeys, tenant.NomeOficial);
|
||||
|
||||
if (normalizedKeys.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var candidates = await _db.MobileLines
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId != tenant.Id)
|
||||
.Where(x => x.Cliente != null && x.Cliente != string.Empty)
|
||||
.ToListAsync();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var hasAnyTenantLine = await _db.MobileLines
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.AnyAsync(x => x.TenantId == tenant.Id);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var reassigned = ReassignByMatcher(
|
||||
candidates,
|
||||
normalizedKeys,
|
||||
tenant.Id,
|
||||
now,
|
||||
isRelaxedMatch: false);
|
||||
|
||||
if (reassigned == 0 && !hasAnyTenantLine)
|
||||
{
|
||||
reassigned = ReassignByMatcher(
|
||||
candidates,
|
||||
normalizedKeys,
|
||||
tenant.Id,
|
||||
now,
|
||||
isRelaxedMatch: true);
|
||||
}
|
||||
|
||||
if (reassigned > 0)
|
||||
{
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return reassigned;
|
||||
}
|
||||
|
||||
private static int ReassignByMatcher(
|
||||
IReadOnlyList<MobileLine> candidates,
|
||||
IReadOnlyCollection<string> normalizedKeys,
|
||||
Guid tenantId,
|
||||
DateTime now,
|
||||
bool isRelaxedMatch)
|
||||
{
|
||||
if (normalizedKeys.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var keys = isRelaxedMatch
|
||||
? normalizedKeys.Where(k => k.Length >= 6).ToList()
|
||||
: normalizedKeys.ToList();
|
||||
|
||||
if (keys.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var reassigned = 0;
|
||||
|
||||
foreach (var line in candidates)
|
||||
{
|
||||
var normalizedClient = NormalizeTenantKey(line.Cliente ?? string.Empty);
|
||||
if (string.IsNullOrWhiteSpace(normalizedClient) ||
|
||||
string.Equals(normalizedClient, "RESERVA", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var matches = !isRelaxedMatch
|
||||
? keys.Contains(normalizedClient, StringComparer.Ordinal)
|
||||
: keys.Any(k =>
|
||||
normalizedClient.Contains(k, StringComparison.Ordinal) ||
|
||||
k.Contains(normalizedClient, StringComparison.Ordinal));
|
||||
|
||||
if (!matches)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
line.TenantId = tenantId;
|
||||
line.UpdatedAt = now;
|
||||
reassigned++;
|
||||
}
|
||||
|
||||
return reassigned;
|
||||
}
|
||||
|
||||
private static void AddNormalizedTenantKey(ISet<string> keys, string? rawKey)
|
||||
{
|
||||
var normalized = NormalizeTenantKey(rawKey ?? string.Empty);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(normalized, "RESERVA", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
keys.Add(normalized);
|
||||
}
|
||||
|
||||
private static string NormalizeTenantKey(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().Normalize(NormalizationForm.FormD);
|
||||
var sb = new StringBuilder(normalized.Length);
|
||||
var previousWasSpace = false;
|
||||
foreach (var ch in normalized)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(ch);
|
||||
if (category == UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsWhiteSpace(ch))
|
||||
{
|
||||
if (!previousWasSpace)
|
||||
{
|
||||
sb.Append(' ');
|
||||
previousWasSpace = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(char.ToUpperInvariant(ch));
|
||||
previousWasSpace = false;
|
||||
}
|
||||
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
using line_gestao_api.Data;
|
||||
using line_gestao_api.Dtos;
|
||||
using line_gestao_api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace line_gestao_api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/system/tenants")]
|
||||
[Authorize(Policy = "SystemAdmin")]
|
||||
public class SystemTenantsController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ISystemAuditService _systemAuditService;
|
||||
|
||||
public SystemTenantsController(AppDbContext db, ISystemAuditService systemAuditService)
|
||||
{
|
||||
_db = db;
|
||||
_systemAuditService = systemAuditService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<SystemTenantListItemDto>>> GetTenants(
|
||||
[FromQuery] string source = SystemTenantConstants.MobileLinesClienteSourceType,
|
||||
[FromQuery] bool active = true)
|
||||
{
|
||||
var query = _db.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(t => !t.IsSystem);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
query = query.Where(t => t.SourceType == source);
|
||||
}
|
||||
|
||||
query = query.Where(t => t.Ativo == active);
|
||||
|
||||
var tenants = await query
|
||||
.OrderBy(t => t.NomeOficial)
|
||||
.Select(t => new SystemTenantListItemDto
|
||||
{
|
||||
TenantId = t.Id,
|
||||
NomeOficial = t.NomeOficial
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
await _systemAuditService.LogAsync(
|
||||
action: SystemAuditActions.ListTenants,
|
||||
targetTenantId: SystemTenantConstants.SystemTenantId,
|
||||
metadata: new { source, active, returnedCount = tenants.Count });
|
||||
|
||||
return Ok(tenants);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using line_gestao_api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace line_gestao_api.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/templates")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public class TemplatesController : ControllerBase
|
||||
{
|
||||
private readonly GeralSpreadsheetTemplateService _geralSpreadsheetTemplateService;
|
||||
|
||||
public TemplatesController(GeralSpreadsheetTemplateService geralSpreadsheetTemplateService)
|
||||
{
|
||||
_geralSpreadsheetTemplateService = geralSpreadsheetTemplateService;
|
||||
}
|
||||
|
||||
[HttpGet("planilha-geral")]
|
||||
public IActionResult DownloadPlanilhaGeral()
|
||||
{
|
||||
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
|
||||
Response.Headers["Pragma"] = "no-cache";
|
||||
Response.Headers["Expires"] = "0";
|
||||
|
||||
var bytes = _geralSpreadsheetTemplateService.BuildPlanilhaGeralTemplate();
|
||||
return File(
|
||||
bytes,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"MODELO_GERAL_LINEGESTAO.xlsx");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -112,7 +112,7 @@ namespace line_gestao_api.Controllers
|
|||
// ✅ CREATE
|
||||
// ==========================================================
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "admin,gestor")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<ActionResult<TrocaNumeroDetailDto>> Create([FromBody] CreateTrocaNumeroDto req)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
|
@ -141,7 +141,7 @@ namespace line_gestao_api.Controllers
|
|||
// ✅ UPDATE
|
||||
// ==========================================================
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "admin,gestor")]
|
||||
[Authorize(Roles = "sysadmin,gestor")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateTrocaNumeroRequest req)
|
||||
{
|
||||
var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
|
@ -167,7 +167,7 @@ namespace line_gestao_api.Controllers
|
|||
// ✅ DELETE
|
||||
// ==========================================================
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ namespace line_gestao_api.Controllers
|
|||
var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue;
|
||||
q = q.Where(x =>
|
||||
EF.Functions.ILike(x.Linha ?? "", $"%{s}%") ||
|
||||
(x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) ||
|
||||
EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.TipoPessoa ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.Nome ?? "", $"%{s}%") ||
|
||||
|
|
@ -151,6 +152,7 @@ namespace line_gestao_api.Controllers
|
|||
q = q.Where(x =>
|
||||
EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.Linha ?? "", $"%{s}%") ||
|
||||
(x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) ||
|
||||
EF.Functions.ILike(x.TipoPessoa ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.Nome ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.RazaoSocial ?? "", $"%{s}%") ||
|
||||
|
|
@ -261,7 +263,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<ActionResult<UserDataDetailDto>> Create([FromBody] CreateUserDataRequest req)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
|
@ -363,7 +365,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateUserDataRequest req)
|
||||
{
|
||||
var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
|
@ -395,7 +397,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ public class UsersController : ControllerBase
|
|||
{
|
||||
private static readonly HashSet<string> AllowedRoles = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"admin",
|
||||
"gestor"
|
||||
AppRoles.SysAdmin,
|
||||
AppRoles.Gestor,
|
||||
AppRoles.Cliente
|
||||
};
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
|
|
@ -39,7 +40,7 @@ public class UsersController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<ActionResult<UserListItemDto>> Create([FromBody] UserCreateRequest req)
|
||||
{
|
||||
var errors = ValidateCreate(req);
|
||||
|
|
@ -122,7 +123,7 @@ public class UsersController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<ActionResult<PagedResult<UserListItemDto>>> GetAll(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? permissao,
|
||||
|
|
@ -132,7 +133,9 @@ public class UsersController : ControllerBase
|
|||
page = page < 1 ? 1 : page;
|
||||
pageSize = pageSize < 1 ? 20 : pageSize;
|
||||
|
||||
var usersQuery = _userManager.Users.AsNoTracking();
|
||||
var usersQuery = _userManager.Users
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
|
|
@ -191,10 +194,13 @@ public class UsersController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<ActionResult<UserListItemDto>> GetById(Guid id)
|
||||
{
|
||||
var user = await _userManager.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
|
||||
var user = await _userManager.Users
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.Id == id);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound();
|
||||
|
|
@ -215,7 +221,7 @@ public class UsersController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpPatch("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UserUpdateRequest req)
|
||||
{
|
||||
var errors = await ValidateUpdateAsync(id, req);
|
||||
|
|
@ -224,7 +230,9 @@ public class UsersController : ControllerBase
|
|||
return BadRequest(new ValidationErrorResponse { Errors = errors });
|
||||
}
|
||||
|
||||
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.Id == id);
|
||||
var user = await _userManager.Users
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.Id == id);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound();
|
||||
|
|
@ -295,14 +303,9 @@ public class UsersController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
if (_tenantProvider.TenantId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var currentUserId = GetCurrentUserId();
|
||||
if (currentUserId.HasValue && currentUserId.Value == id)
|
||||
{
|
||||
|
|
@ -315,12 +318,14 @@ public class UsersController : ControllerBase
|
|||
});
|
||||
}
|
||||
|
||||
var tenantId = _tenantProvider.TenantId.Value;
|
||||
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.Id == id && u.TenantId == tenantId);
|
||||
var user = await _userManager.Users
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.Id == id);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
var tenantId = user.TenantId;
|
||||
|
||||
if (user.IsActive)
|
||||
{
|
||||
|
|
@ -334,12 +339,12 @@ public class UsersController : ControllerBase
|
|||
}
|
||||
|
||||
var targetRoles = await _userManager.GetRolesAsync(user);
|
||||
var isAdmin = targetRoles.Any(r => string.Equals(r, "admin", StringComparison.OrdinalIgnoreCase));
|
||||
var isAdmin = targetRoles.Any(r => string.Equals(r, AppRoles.SysAdmin, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (isAdmin)
|
||||
{
|
||||
var adminRoleId = await _roleManager.Roles
|
||||
.Where(r => r.Name == "admin")
|
||||
.Where(r => r.Name == AppRoles.SysAdmin)
|
||||
.Select(r => (Guid?)r.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
|
|
@ -360,7 +365,7 @@ public class UsersController : ControllerBase
|
|||
{
|
||||
Errors = new List<ValidationErrorDto>
|
||||
{
|
||||
new() { Field = "usuario", Message = "Não é permitido excluir o último administrador." }
|
||||
new() { Field = "usuario", Message = "Não é permitido excluir o último sysadmin." }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -422,6 +427,10 @@ public class UsersController : ControllerBase
|
|||
private async Task<List<ValidationErrorDto>> ValidateUpdateAsync(Guid userId, UserUpdateRequest req)
|
||||
{
|
||||
var errors = new List<ValidationErrorDto>();
|
||||
var targetUser = await _userManager.Users
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.Nome) && req.Nome.Trim().Length < 2)
|
||||
{
|
||||
|
|
@ -433,22 +442,22 @@ public class UsersController : ControllerBase
|
|||
var email = req.Email.Trim().ToLowerInvariant();
|
||||
var normalized = _userManager.NormalizeEmail(email);
|
||||
|
||||
var tenantId = _tenantProvider.TenantId;
|
||||
if (tenantId == null)
|
||||
if (targetUser == null)
|
||||
{
|
||||
errors.Add(new ValidationErrorDto { Field = "email", Message = "Tenant inválido." });
|
||||
return errors;
|
||||
}
|
||||
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." });
|
||||
}
|
||||
var tenantId = targetUser.TenantId;
|
||||
var exists = await _userManager.Users
|
||||
.IgnoreQueryFilters()
|
||||
.AnyAsync(u =>
|
||||
u.Id != userId &&
|
||||
u.TenantId == tenantId &&
|
||||
u.NormalizedEmail == normalized);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
errors.Add(new ValidationErrorDto { Field = "email", Message = "E-mail já cadastrado." });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ namespace line_gestao_api.Controllers
|
|||
q = q.Where(x =>
|
||||
EF.Functions.ILike(x.Conta ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.Linha ?? "", $"%{s}%") ||
|
||||
(x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) ||
|
||||
EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.Usuario ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.PlanoContrato ?? "", $"%{s}%") ||
|
||||
|
|
@ -98,6 +99,10 @@ namespace line_gestao_api.Controllers
|
|||
PlanoContrato = x.PlanoContrato,
|
||||
DtEfetivacaoServico = x.DtEfetivacaoServico,
|
||||
DtTerminoFidelizacao = x.DtTerminoFidelizacao,
|
||||
AutoRenewYears = x.AutoRenewYears,
|
||||
AutoRenewReferenceEndDate = x.AutoRenewReferenceEndDate,
|
||||
AutoRenewConfiguredAt = x.AutoRenewConfiguredAt,
|
||||
LastAutoRenewedAt = x.LastAutoRenewedAt,
|
||||
Total = x.Total
|
||||
})
|
||||
.ToListAsync();
|
||||
|
|
@ -142,6 +147,7 @@ namespace line_gestao_api.Controllers
|
|||
q = q.Where(x =>
|
||||
EF.Functions.ILike(x.Conta ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.Linha ?? "", $"%{s}%") ||
|
||||
(x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) ||
|
||||
EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.Usuario ?? "", $"%{s}%") ||
|
||||
EF.Functions.ILike(x.PlanoContrato ?? "", $"%{s}%") ||
|
||||
|
|
@ -239,6 +245,10 @@ namespace line_gestao_api.Controllers
|
|||
PlanoContrato = x.PlanoContrato,
|
||||
DtEfetivacaoServico = x.DtEfetivacaoServico,
|
||||
DtTerminoFidelizacao = x.DtTerminoFidelizacao,
|
||||
AutoRenewYears = x.AutoRenewYears,
|
||||
AutoRenewReferenceEndDate = x.AutoRenewReferenceEndDate,
|
||||
AutoRenewConfiguredAt = x.AutoRenewConfiguredAt,
|
||||
LastAutoRenewedAt = x.LastAutoRenewedAt,
|
||||
Total = x.Total,
|
||||
CreatedAt = x.CreatedAt,
|
||||
UpdatedAt = x.UpdatedAt
|
||||
|
|
@ -246,7 +256,7 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<ActionResult<VigenciaLineDetailDto>> Create([FromBody] CreateVigenciaRequest req)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
|
@ -333,6 +343,10 @@ namespace line_gestao_api.Controllers
|
|||
PlanoContrato = e.PlanoContrato,
|
||||
DtEfetivacaoServico = e.DtEfetivacaoServico,
|
||||
DtTerminoFidelizacao = e.DtTerminoFidelizacao,
|
||||
AutoRenewYears = e.AutoRenewYears,
|
||||
AutoRenewReferenceEndDate = e.AutoRenewReferenceEndDate,
|
||||
AutoRenewConfiguredAt = e.AutoRenewConfiguredAt,
|
||||
LastAutoRenewedAt = e.LastAutoRenewedAt,
|
||||
Total = e.Total,
|
||||
CreatedAt = e.CreatedAt,
|
||||
UpdatedAt = e.UpdatedAt
|
||||
|
|
@ -340,12 +354,15 @@ namespace line_gestao_api.Controllers
|
|||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateVigenciaRequest req)
|
||||
{
|
||||
var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id);
|
||||
if (x == null) return NotFound();
|
||||
|
||||
var previousEfetivacao = x.DtEfetivacaoServico;
|
||||
var previousTermino = x.DtTerminoFidelizacao;
|
||||
|
||||
if (req.Item.HasValue) x.Item = req.Item.Value;
|
||||
if (req.Conta != null) x.Conta = TrimOrNull(req.Conta);
|
||||
if (req.Linha != null) x.Linha = TrimOrNull(req.Linha);
|
||||
|
|
@ -358,6 +375,13 @@ namespace line_gestao_api.Controllers
|
|||
|
||||
if (req.Total.HasValue) x.Total = req.Total.Value;
|
||||
|
||||
var efetivacaoChanged = req.DtEfetivacaoServico.HasValue && !IsSameUtcDate(previousEfetivacao, x.DtEfetivacaoServico);
|
||||
var terminoChanged = req.DtTerminoFidelizacao.HasValue && !IsSameUtcDate(previousTermino, x.DtTerminoFidelizacao);
|
||||
if (efetivacaoChanged || terminoChanged)
|
||||
{
|
||||
ClearAutoRenewSchedule(x);
|
||||
}
|
||||
|
||||
x.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
|
@ -365,8 +389,41 @@ namespace line_gestao_api.Controllers
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/renew")]
|
||||
public async Task<IActionResult> ConfigureAutoRenew(Guid id, [FromBody] ConfigureVigenciaRenewalRequest req)
|
||||
{
|
||||
if (req.Years != 2)
|
||||
{
|
||||
return BadRequest(new { message = "A renovação automática permite somente 2 anos." });
|
||||
}
|
||||
|
||||
var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id);
|
||||
if (x == null) return NotFound();
|
||||
|
||||
if (!x.DtTerminoFidelizacao.HasValue)
|
||||
{
|
||||
return BadRequest(new { message = "A linha não possui data de término de fidelização para programar renovação." });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
x.AutoRenewYears = 2;
|
||||
x.AutoRenewReferenceEndDate = DateTime.SpecifyKind(x.DtTerminoFidelizacao.Value.Date, DateTimeKind.Utc);
|
||||
x.AutoRenewConfiguredAt = now;
|
||||
x.UpdatedAt = now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _vigenciaNotificationSyncService.SyncCurrentTenantAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Renovação automática de +2 anos programada para o vencimento.",
|
||||
autoRenewYears = x.AutoRenewYears,
|
||||
autoRenewReferenceEndDate = x.AutoRenewReferenceEndDate
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize(Roles = "sysadmin")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
|
@ -390,6 +447,20 @@ namespace line_gestao_api.Controllers
|
|||
: (dt.Kind == DateTimeKind.Local ? dt.ToUniversalTime() : DateTime.SpecifyKind(dt, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
private static void ClearAutoRenewSchedule(VigenciaLine line)
|
||||
{
|
||||
line.AutoRenewYears = null;
|
||||
line.AutoRenewReferenceEndDate = null;
|
||||
line.AutoRenewConfiguredAt = null;
|
||||
}
|
||||
|
||||
private static bool IsSameUtcDate(DateTime? a, DateTime? b)
|
||||
{
|
||||
if (!a.HasValue && !b.HasValue) return true;
|
||||
if (!a.HasValue || !b.HasValue) return false;
|
||||
return DateTime.SpecifyKind(a.Value.Date, DateTimeKind.Utc) == DateTime.SpecifyKind(b.Value.Date, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private static string OnlyDigits(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return "";
|
||||
|
|
|
|||
|
|
@ -78,6 +78,15 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
|
|||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Tenant>(e =>
|
||||
{
|
||||
e.Property(x => x.NomeOficial).HasMaxLength(200);
|
||||
e.Property(x => x.SourceType).HasMaxLength(80);
|
||||
e.Property(x => x.SourceKey).HasMaxLength(300);
|
||||
e.HasIndex(x => new { x.SourceType, x.SourceKey }).IsUnique();
|
||||
e.HasIndex(x => new { x.IsSystem, x.Ativo });
|
||||
});
|
||||
|
||||
// =========================
|
||||
// ✅ USER (Identity)
|
||||
// =========================
|
||||
|
|
@ -170,6 +179,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
|
|||
e.HasIndex(x => x.Cliente);
|
||||
e.HasIndex(x => x.Linha);
|
||||
e.HasIndex(x => x.DtTerminoFidelizacao);
|
||||
e.HasIndex(x => x.AutoRenewReferenceEndDate);
|
||||
e.HasIndex(x => x.TenantId);
|
||||
});
|
||||
|
||||
|
|
@ -270,6 +280,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
|
|||
// =========================
|
||||
modelBuilder.Entity<AuditLog>(e =>
|
||||
{
|
||||
e.Property(x => x.MetadataJson).HasColumnType("jsonb");
|
||||
e.Property(x => x.Action).HasMaxLength(20);
|
||||
e.Property(x => x.Page).HasMaxLength(80);
|
||||
e.Property(x => x.EntityName).HasMaxLength(120);
|
||||
|
|
@ -281,8 +292,13 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
|
|||
e.Property(x => x.RequestMethod).HasMaxLength(10);
|
||||
e.Property(x => x.IpAddress).HasMaxLength(80);
|
||||
e.Property(x => x.ChangesJson).HasColumnType("jsonb");
|
||||
e.Property(x => x.ActorTenantId);
|
||||
e.Property(x => x.TargetTenantId);
|
||||
|
||||
e.HasIndex(x => x.TenantId);
|
||||
e.HasIndex(x => x.ActorTenantId);
|
||||
e.HasIndex(x => x.TargetTenantId);
|
||||
e.HasIndex(x => x.ActorUserId);
|
||||
e.HasIndex(x => x.OccurredAtUtc);
|
||||
e.HasIndex(x => x.Page);
|
||||
e.HasIndex(x => x.UserId);
|
||||
|
|
@ -318,33 +334,33 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
|
|||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MobileLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<MuregLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<BillingClient>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<UserData>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<VigenciaLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<TrocaNumeroLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ChipVirgemLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ControleRecebidoLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<Notification>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoMacrophonyPlan>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoMacrophonyTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoVivoLineResumo>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoVivoLineTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoClienteEspecial>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoPlanoContratoResumo>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoPlanoContratoTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoLineTotais>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoGbDistribuicao>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoGbDistribuicaoTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoReservaLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ResumoReservaTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ParcelamentoLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ParcelamentoMonthValue>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<AuditLog>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ImportAuditRun>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ImportAuditIssue>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
modelBuilder.Entity<ApplicationUser>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
|
||||
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<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<VigenciaLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<TrocaNumeroLine>().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<Notification>().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<ResumoVivoLineResumo>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<ResumoVivoLineTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<ResumoClienteEspecial>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<ResumoPlanoContratoResumo>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<ResumoPlanoContratoTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<ResumoLineTotais>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<ResumoGbDistribuicao>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<ResumoGbDistribuicaoTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<ResumoReservaLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<ResumoReservaTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<ParcelamentoLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
|
||||
modelBuilder.Entity<ParcelamentoMonthValue>().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<ImportAuditIssue>().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));
|
||||
}
|
||||
|
||||
public override int SaveChanges()
|
||||
|
|
@ -363,12 +379,12 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
|
|||
|
||||
private void ApplyTenantIds()
|
||||
{
|
||||
if (_tenantProvider.TenantId == null)
|
||||
if (_tenantProvider.ActorTenantId == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = _tenantProvider.TenantId.Value;
|
||||
var tenantId = _tenantProvider.ActorTenantId.Value;
|
||||
foreach (var entry in ChangeTracker.Entries<ITenantEntity>().Where(e => e.State == EntityState.Added))
|
||||
{
|
||||
if (entry.Entity.TenantId == Guid.Empty)
|
||||
|
|
|
|||
385
Data/SeedData.cs
385
Data/SeedData.cs
|
|
@ -9,17 +9,14 @@ namespace line_gestao_api.Data;
|
|||
public class SeedOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string DefaultTenantName { get; set; } = "Default";
|
||||
public string AdminName { get; set; } = "Administrador";
|
||||
public string AdminEmail { get; set; } = "admin@linegestao.local";
|
||||
public string AdminPassword { get; set; } = "DevAdmin123!";
|
||||
public string AdminMasterName { get; set; } = "System Admin";
|
||||
public string? AdminMasterEmail { get; set; } = "sysadmin@linegestao.local";
|
||||
public string? AdminMasterPassword { get; set; } = "DevSysAdmin123!";
|
||||
public bool ReapplyAdminCredentialsOnStartup { get; set; } = false;
|
||||
}
|
||||
|
||||
public static class SeedData
|
||||
{
|
||||
public static readonly Guid DefaultTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
public static async Task EnsureSeedDataAsync(IServiceProvider services)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
|
|
@ -29,224 +26,236 @@ public static class SeedData
|
|||
var tenantProvider = scope.ServiceProvider.GetRequiredService<ITenantProvider>();
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<SeedOptions>>().Value;
|
||||
|
||||
await db.Database.MigrateAsync();
|
||||
if (db.Database.IsRelational())
|
||||
{
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
}
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var roles = new[] { "admin", "gestor", "operador", "leitura" };
|
||||
var systemTenantId = SystemTenantConstants.SystemTenantId;
|
||||
var roles = AppRoles.All;
|
||||
foreach (var role in roles)
|
||||
{
|
||||
if (!await roleManager.RoleExistsAsync(role))
|
||||
{
|
||||
await roleManager.CreateAsync(new IdentityRole<Guid>(role));
|
||||
var roleResult = await roleManager.CreateAsync(new IdentityRole<Guid>(role));
|
||||
EnsureIdentitySucceeded(roleResult, $"Falha ao criar role '{role}'.");
|
||||
}
|
||||
}
|
||||
|
||||
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Id == DefaultTenantId);
|
||||
if (tenant == null)
|
||||
await MigrateLegacyRolesAsync(db, roleManager);
|
||||
|
||||
var systemTenant = await db.Tenants.FirstOrDefaultAsync(t => t.Id == systemTenantId);
|
||||
if (systemTenant == null)
|
||||
{
|
||||
tenant = new Tenant
|
||||
systemTenant = new Tenant
|
||||
{
|
||||
Id = DefaultTenantId,
|
||||
Name = options.DefaultTenantName,
|
||||
Id = systemTenantId,
|
||||
NomeOficial = SystemTenantConstants.SystemTenantNomeOficial,
|
||||
IsSystem = true,
|
||||
Ativo = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Tenants.Add(tenant);
|
||||
await db.SaveChangesAsync();
|
||||
db.Tenants.Add(systemTenant);
|
||||
}
|
||||
else
|
||||
{
|
||||
systemTenant.NomeOficial = SystemTenantConstants.SystemTenantNomeOficial;
|
||||
systemTenant.IsSystem = true;
|
||||
systemTenant.Ativo = true;
|
||||
}
|
||||
|
||||
await NormalizeLegacyTenantDataAsync(db, tenant.Id);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
tenantProvider.SetTenantId(tenant.Id);
|
||||
var emailFromEnv = Environment.GetEnvironmentVariable("SYSADMIN_EMAIL")
|
||||
?? Environment.GetEnvironmentVariable("ADMIN_MASTER_EMAIL");
|
||||
var passwordFromEnv = Environment.GetEnvironmentVariable("SYSADMIN_PASSWORD")
|
||||
?? Environment.GetEnvironmentVariable("ADMIN_MASTER_PASSWORD");
|
||||
|
||||
var normalizedEmail = userManager.NormalizeEmail(options.AdminEmail);
|
||||
var existingAdmin = await userManager.Users
|
||||
.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail && u.TenantId == tenant.Id);
|
||||
var adminMasterEmail = (emailFromEnv ?? options.AdminMasterEmail ?? string.Empty).Trim().ToLowerInvariant();
|
||||
var adminMasterPassword = passwordFromEnv ?? options.AdminMasterPassword ?? string.Empty;
|
||||
|
||||
if (existingAdmin == null)
|
||||
if (string.IsNullOrWhiteSpace(adminMasterEmail) || string.IsNullOrWhiteSpace(adminMasterPassword))
|
||||
{
|
||||
var adminUser = new ApplicationUser
|
||||
{
|
||||
UserName = options.AdminEmail,
|
||||
Email = options.AdminEmail,
|
||||
Name = options.AdminName,
|
||||
TenantId = tenant.Id,
|
||||
EmailConfirmed = true,
|
||||
IsActive = true,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
|
||||
var createResult = await userManager.CreateAsync(adminUser, options.AdminPassword);
|
||||
if (createResult.Succeeded)
|
||||
{
|
||||
await userManager.AddToRoleAsync(adminUser, "admin");
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
"Credenciais do sysadmin ausentes. Defina SYSADMIN_EMAIL e SYSADMIN_PASSWORD (ou Seed:AdminMasterEmail/Seed:AdminMasterPassword).");
|
||||
}
|
||||
else if (options.ReapplyAdminCredentialsOnStartup)
|
||||
|
||||
var normalizedEmail = userManager.NormalizeEmail(adminMasterEmail);
|
||||
|
||||
var previousTenant = tenantProvider.TenantId;
|
||||
tenantProvider.SetTenantId(systemTenantId);
|
||||
|
||||
try
|
||||
{
|
||||
existingAdmin.Name = options.AdminName;
|
||||
existingAdmin.Email = options.AdminEmail;
|
||||
existingAdmin.UserName = options.AdminEmail;
|
||||
existingAdmin.EmailConfirmed = true;
|
||||
existingAdmin.IsActive = true;
|
||||
existingAdmin.LockoutEnabled = true;
|
||||
var existingAdminMaster = await userManager.Users
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.TenantId == systemTenantId && u.NormalizedEmail == normalizedEmail);
|
||||
|
||||
await userManager.SetLockoutEndDateAsync(existingAdmin, null);
|
||||
await userManager.ResetAccessFailedCountAsync(existingAdmin);
|
||||
await userManager.UpdateAsync(existingAdmin);
|
||||
|
||||
var resetToken = await userManager.GeneratePasswordResetTokenAsync(existingAdmin);
|
||||
var resetPasswordResult = await userManager.ResetPasswordAsync(existingAdmin, resetToken, options.AdminPassword);
|
||||
if (!resetPasswordResult.Succeeded)
|
||||
if (existingAdminMaster == null)
|
||||
{
|
||||
var removePasswordResult = await userManager.RemovePasswordAsync(existingAdmin);
|
||||
if (removePasswordResult.Succeeded)
|
||||
var adminMaster = new ApplicationUser
|
||||
{
|
||||
await userManager.AddPasswordAsync(existingAdmin, options.AdminPassword);
|
||||
Name = options.AdminMasterName,
|
||||
Email = adminMasterEmail,
|
||||
UserName = adminMasterEmail,
|
||||
TenantId = systemTenantId,
|
||||
EmailConfirmed = true,
|
||||
IsActive = true,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
|
||||
var createResult = await userManager.CreateAsync(adminMaster, adminMasterPassword);
|
||||
EnsureIdentitySucceeded(createResult, "Falha ao criar usuário sysadmin.");
|
||||
|
||||
var addRoleResult = await userManager.AddToRoleAsync(adminMaster, SystemTenantConstants.SystemRole);
|
||||
EnsureIdentitySucceeded(addRoleResult, "Falha ao associar role sysadmin ao usuário inicial.");
|
||||
}
|
||||
else
|
||||
{
|
||||
existingAdminMaster.Name = options.AdminMasterName;
|
||||
existingAdminMaster.Email = adminMasterEmail;
|
||||
existingAdminMaster.UserName = adminMasterEmail;
|
||||
existingAdminMaster.EmailConfirmed = true;
|
||||
existingAdminMaster.IsActive = true;
|
||||
existingAdminMaster.LockoutEnabled = true;
|
||||
|
||||
var updateResult = await userManager.UpdateAsync(existingAdminMaster);
|
||||
EnsureIdentitySucceeded(updateResult, "Falha ao atualizar usuário sysadmin.");
|
||||
|
||||
if (options.ReapplyAdminCredentialsOnStartup)
|
||||
{
|
||||
await userManager.SetLockoutEndDateAsync(existingAdminMaster, null);
|
||||
await userManager.ResetAccessFailedCountAsync(existingAdminMaster);
|
||||
|
||||
var resetToken = await userManager.GeneratePasswordResetTokenAsync(existingAdminMaster);
|
||||
var resetPasswordResult = await userManager.ResetPasswordAsync(existingAdminMaster, resetToken, adminMasterPassword);
|
||||
if (!resetPasswordResult.Succeeded)
|
||||
{
|
||||
var removePasswordResult = await userManager.RemovePasswordAsync(existingAdminMaster);
|
||||
if (removePasswordResult.Succeeded)
|
||||
{
|
||||
var addPasswordResult = await userManager.AddPasswordAsync(existingAdminMaster, adminMasterPassword);
|
||||
EnsureIdentitySucceeded(addPasswordResult, "Falha ao definir senha do sysadmin.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var addPasswordResult = await userManager.AddPasswordAsync(existingAdminMaster, adminMasterPassword);
|
||||
EnsureIdentitySucceeded(addPasswordResult, "Falha ao definir senha do sysadmin.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!await userManager.IsInRoleAsync(existingAdminMaster, SystemTenantConstants.SystemRole))
|
||||
{
|
||||
var addRoleResult = await userManager.AddToRoleAsync(existingAdminMaster, SystemTenantConstants.SystemRole);
|
||||
EnsureIdentitySucceeded(addRoleResult, "Falha ao associar role sysadmin ao usuário inicial.");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantProvider.SetTenantId(previousTenant);
|
||||
}
|
||||
}
|
||||
|
||||
if (!await userManager.IsInRoleAsync(existingAdmin, "admin"))
|
||||
private static void EnsureIdentitySucceeded(IdentityResult result, string message)
|
||||
{
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var details = string.Join("; ", result.Errors.Select(e => e.Description));
|
||||
throw new InvalidOperationException($"{message} Detalhes: {details}");
|
||||
}
|
||||
|
||||
private static async Task MigrateLegacyRolesAsync(AppDbContext db, RoleManager<IdentityRole<Guid>> roleManager)
|
||||
{
|
||||
await MigrateLegacyRoleAsync(db, roleManager, "admin_master", AppRoles.SysAdmin);
|
||||
await MigrateLegacyRoleAsync(db, roleManager, "admin", AppRoles.SysAdmin);
|
||||
await MigrateLegacyRoleAsync(db, roleManager, "leitura", AppRoles.Cliente);
|
||||
await MigrateLegacyRoleAsync(db, roleManager, "operador", AppRoles.Cliente);
|
||||
}
|
||||
|
||||
private static async Task MigrateLegacyRoleAsync(
|
||||
AppDbContext db,
|
||||
RoleManager<IdentityRole<Guid>> roleManager,
|
||||
string legacyRole,
|
||||
string newRole)
|
||||
{
|
||||
var legacyRoleId = await roleManager.Roles
|
||||
.Where(r => r.Name == legacyRole)
|
||||
.Select(r => (Guid?)r.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (!legacyRoleId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var newRoleId = await roleManager.Roles
|
||||
.Where(r => r.Name == newRole)
|
||||
.Select(r => (Guid?)r.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (!newRoleId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var legacyUserIds = await db.UserRoles
|
||||
.Where(ur => ur.RoleId == legacyRoleId.Value)
|
||||
.Select(ur => ur.UserId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
if (legacyUserIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var alreadyInNewRole = await db.UserRoles
|
||||
.Where(ur => ur.RoleId == newRoleId.Value && legacyUserIds.Contains(ur.UserId))
|
||||
.Select(ur => ur.UserId)
|
||||
.ToListAsync();
|
||||
var existingSet = alreadyInNewRole.ToHashSet();
|
||||
|
||||
foreach (var userId in legacyUserIds)
|
||||
{
|
||||
if (!existingSet.Contains(userId))
|
||||
{
|
||||
await userManager.AddToRoleAsync(existingAdmin, "admin");
|
||||
db.UserRoles.Add(new IdentityUserRole<Guid>
|
||||
{
|
||||
UserId = userId,
|
||||
RoleId = newRoleId.Value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tenantProvider.SetTenantId(null);
|
||||
}
|
||||
var legacyAssignments = await db.UserRoles
|
||||
.Where(ur => ur.RoleId == legacyRoleId.Value)
|
||||
.ToListAsync();
|
||||
if (legacyAssignments.Count > 0)
|
||||
{
|
||||
db.UserRoles.RemoveRange(legacyAssignments);
|
||||
}
|
||||
|
||||
private static async Task NormalizeLegacyTenantDataAsync(AppDbContext db, Guid defaultTenantId)
|
||||
{
|
||||
if (defaultTenantId == Guid.Empty)
|
||||
return;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.Users
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.MobileLines
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.MuregLines
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.BillingClients
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.UserDatas
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.VigenciaLines
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.TrocaNumeroLines
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ChipVirgemLines
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ControleRecebidoLines
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.Notifications
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ResumoMacrophonyPlans
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ResumoMacrophonyTotals
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ResumoVivoLineResumos
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ResumoVivoLineTotals
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ResumoClienteEspeciais
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ResumoPlanoContratoResumos
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ResumoPlanoContratoTotals
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ResumoLineTotais
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ResumoReservaLines
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ResumoReservaTotals
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ParcelamentoLines
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ParcelamentoMonthValues
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.AuditLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ImportAuditRuns
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
|
||||
await db.ImportAuditIssues
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == Guid.Empty)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
|
||||
var legacyRoleStillUsed = await db.UserRoles.AnyAsync(ur => ur.RoleId == legacyRoleId.Value);
|
||||
if (!legacyRoleStillUsed)
|
||||
{
|
||||
var legacyRoleEntity = await roleManager.Roles.FirstOrDefaultAsync(r => r.Id == legacyRoleId.Value);
|
||||
if (legacyRoleEntity != null)
|
||||
{
|
||||
await roleManager.DeleteAsync(legacyRoleEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace line_gestao_api.Dtos
|
||||
{
|
||||
public class CreateMobileLinesBatchRequestDto
|
||||
{
|
||||
public List<CreateMobileLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreateMobileLinesBatchResultDto
|
||||
{
|
||||
public int Created { get; set; }
|
||||
public List<CreateMobileLinesBatchCreatedItemDto> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreateMobileLinesBatchCreatedItemDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public int Item { get; set; }
|
||||
public string? Linha { get; set; }
|
||||
public string? Cliente { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace line_gestao_api.Dtos
|
||||
{
|
||||
public sealed class LinesBatchExcelPreviewResultDto
|
||||
{
|
||||
public string? FileName { get; set; }
|
||||
public string? SheetName { get; set; }
|
||||
public int NextItemStart { get; set; }
|
||||
public int TotalRows { get; set; }
|
||||
public int ValidRows { get; set; }
|
||||
public int InvalidRows { get; set; }
|
||||
public int DuplicateRows { get; set; }
|
||||
public bool CanProceed { get; set; }
|
||||
public List<LinesBatchExcelIssueDto> HeaderErrors { get; set; } = new();
|
||||
public List<LinesBatchExcelIssueDto> HeaderWarnings { get; set; } = new();
|
||||
public List<LinesBatchExcelPreviewRowDto> Rows { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class LinesBatchExcelPreviewRowDto
|
||||
{
|
||||
public int SourceRowNumber { get; set; }
|
||||
public int? SourceItem { get; set; }
|
||||
public int? GeneratedItemPreview { get; set; }
|
||||
public bool Valid { get; set; }
|
||||
public bool DuplicateLinhaInFile { get; set; }
|
||||
public bool DuplicateChipInFile { get; set; }
|
||||
public bool DuplicateLinhaInSystem { get; set; }
|
||||
public bool DuplicateChipInSystem { get; set; }
|
||||
public CreateMobileLineDto Data { get; set; } = new();
|
||||
public List<LinesBatchExcelIssueDto> Errors { get; set; } = new();
|
||||
public List<LinesBatchExcelIssueDto> Warnings { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class LinesBatchExcelIssueDto
|
||||
{
|
||||
public string? Column { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class AssignReservaLinesRequestDto
|
||||
{
|
||||
public string? ClienteDestino { get; set; }
|
||||
public string? UsuarioDestino { get; set; }
|
||||
public string? SkilDestino { get; set; }
|
||||
public List<Guid> LineIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class MoveLinesToReservaRequestDto
|
||||
{
|
||||
public List<Guid> LineIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class AssignReservaLinesResultDto
|
||||
{
|
||||
public int Requested { get; set; }
|
||||
public int Updated { get; set; }
|
||||
public int Failed { get; set; }
|
||||
public List<AssignReservaLineItemResultDto> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class AssignReservaLineItemResultDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public int Item { get; set; }
|
||||
public string? Linha { get; set; }
|
||||
public string? Chip { get; set; }
|
||||
public string? ClienteAnterior { get; set; }
|
||||
public string? ClienteNovo { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
public string? Skil { get; set; }
|
||||
public string? Modalidade { get; set; }
|
||||
public string? VencConta { get; set; }
|
||||
public decimal? FranquiaLine { get; set; }
|
||||
|
||||
// Campos para filtro deterministico de adicionais no frontend
|
||||
public decimal? GestaoVozDados { get; set; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
namespace line_gestao_api.Dtos;
|
||||
|
||||
public class SystemTenantListItemDto
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public string NomeOficial { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class CreateSystemTenantUserRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public List<string> Roles { get; set; } = new();
|
||||
public bool ClientCredentialsOnly { get; set; }
|
||||
}
|
||||
|
||||
public class SystemTenantUserCreatedDto
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public IReadOnlyList<string> Roles { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
|
|
@ -14,6 +14,10 @@ namespace line_gestao_api.Dtos
|
|||
public string? PlanoContrato { get; set; }
|
||||
public DateTime? DtEfetivacaoServico { get; set; }
|
||||
public DateTime? DtTerminoFidelizacao { get; set; }
|
||||
public int? AutoRenewYears { get; set; }
|
||||
public DateTime? AutoRenewReferenceEndDate { get; set; }
|
||||
public DateTime? AutoRenewConfiguredAt { get; set; }
|
||||
public DateTime? LastAutoRenewedAt { get; set; }
|
||||
public decimal? Total { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +53,11 @@ namespace line_gestao_api.Dtos
|
|||
public decimal? Total { get; set; }
|
||||
}
|
||||
|
||||
public class ConfigureVigenciaRenewalRequest
|
||||
{
|
||||
public int Years { get; set; }
|
||||
}
|
||||
|
||||
public class VigenciaClientGroupDto
|
||||
{
|
||||
public string Cliente { get; set; } = "";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using line_gestao_api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace line_gestao_api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260226130000_CreateTenantsAndAuditLogsSystemContracts")]
|
||||
public partial class CreateTenantsAndAuditLogsSystemContracts : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'Tenants'
|
||||
AND column_name = 'Name'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'Tenants'
|
||||
AND column_name = 'NomeOficial'
|
||||
) THEN
|
||||
ALTER TABLE "Tenants" RENAME COLUMN "Name" TO "NomeOficial";
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "IsSystem" boolean NOT NULL DEFAULT FALSE;""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "Ativo" boolean NOT NULL DEFAULT TRUE;""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "SourceType" character varying(80) NULL;""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "SourceKey" character varying(300) NULL;""");
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE "Tenants"
|
||||
SET "NomeOficial" = COALESCE(NULLIF("NomeOficial", ''), 'TENANT_SEM_NOME')
|
||||
WHERE "NomeOficial" IS NULL OR "NomeOficial" = '';
|
||||
""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "Tenants" ALTER COLUMN "NomeOficial" SET NOT NULL;""");
|
||||
migrationBuilder.Sql("""CREATE UNIQUE INDEX IF NOT EXISTS "IX_Tenants_SourceType_SourceKey" ON "Tenants" ("SourceType", "SourceKey");""");
|
||||
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Tenants_IsSystem_Ativo" ON "Tenants" ("IsSystem", "Ativo");""");
|
||||
|
||||
migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "ActorUserId" uuid NULL;""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "ActorTenantId" uuid NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "TargetTenantId" uuid NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "MetadataJson" jsonb NOT NULL DEFAULT '{}'::jsonb;""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE "AuditLogs"
|
||||
SET "ActorUserId" = COALESCE("ActorUserId", "UserId"),
|
||||
"ActorTenantId" = CASE
|
||||
WHEN "ActorTenantId" = '00000000-0000-0000-0000-000000000000' THEN "TenantId"
|
||||
ELSE "ActorTenantId"
|
||||
END,
|
||||
"TargetTenantId" = CASE
|
||||
WHEN "TargetTenantId" = '00000000-0000-0000-0000-000000000000' THEN "TenantId"
|
||||
ELSE "TargetTenantId"
|
||||
END,
|
||||
"MetadataJson" = COALESCE("MetadataJson", '{}'::jsonb);
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_AuditLogs_ActorTenantId" ON "AuditLogs" ("ActorTenantId");""");
|
||||
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_AuditLogs_TargetTenantId" ON "AuditLogs" ("TargetTenantId");""");
|
||||
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_AuditLogs_ActorUserId" ON "AuditLogs" ("ActorUserId");""");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_AuditLogs_ActorUserId";""");
|
||||
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_AuditLogs_TargetTenantId";""");
|
||||
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_AuditLogs_ActorTenantId";""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "MetadataJson";""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "TargetTenantId";""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "ActorTenantId";""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "ActorUserId";""");
|
||||
|
||||
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Tenants_IsSystem_Ativo";""");
|
||||
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Tenants_SourceType_SourceKey";""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "SourceKey";""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "SourceType";""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "Ativo";""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "IsSystem";""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'Tenants'
|
||||
AND column_name = 'NomeOficial'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'Tenants'
|
||||
AND column_name = 'Name'
|
||||
) THEN
|
||||
ALTER TABLE "Tenants" RENAME COLUMN "NomeOficial" TO "Name";
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using line_gestao_api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace line_gestao_api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260226130100_AddTenantIdToMobileLinesIfNeeded")]
|
||||
public partial class AddTenantIdToMobileLinesIfNeeded : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'MobileLines'
|
||||
AND column_name = 'TenantId'
|
||||
) THEN
|
||||
ALTER TABLE "MobileLines" ADD COLUMN "TenantId" uuid NULL;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
""");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// No-op intencional para evitar perda de dados em bancos legados.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using line_gestao_api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace line_gestao_api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260226130200_BackfillTenantsFromDistinctMobileLinesCliente")]
|
||||
public partial class BackfillTenantsFromDistinctMobileLinesCliente : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "MobileLines"
|
||||
WHERE "Cliente" IS NULL OR btrim("Cliente") = ''
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Backfill abortado: MobileLines.Cliente possui valores NULL/vazios. Corrija os dados antes de migrar.';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""CREATE EXTENSION IF NOT EXISTS pgcrypto;""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO "Tenants" (
|
||||
"Id",
|
||||
"NomeOficial",
|
||||
"IsSystem",
|
||||
"Ativo",
|
||||
"SourceType",
|
||||
"SourceKey",
|
||||
"CreatedAt"
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
src."Cliente",
|
||||
FALSE,
|
||||
TRUE,
|
||||
'MobileLines.Cliente',
|
||||
src."Cliente",
|
||||
NOW()
|
||||
FROM (
|
||||
SELECT DISTINCT "Cliente"
|
||||
FROM "MobileLines"
|
||||
) src
|
||||
LEFT JOIN "Tenants" t
|
||||
ON t."SourceType" = 'MobileLines.Cliente'
|
||||
AND t."SourceKey" = src."Cliente"
|
||||
WHERE t."Id" IS NULL;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE "Tenants"
|
||||
SET "NomeOficial" = "SourceKey",
|
||||
"IsSystem" = FALSE,
|
||||
"Ativo" = TRUE
|
||||
WHERE "SourceType" = 'MobileLines.Cliente'
|
||||
AND "SourceKey" IS NOT NULL;
|
||||
""");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// No-op intencional. Evita remover tenants já em uso.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using line_gestao_api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace line_gestao_api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260226130300_BackfillMobileLinesTenantIdFromTenantSourceKey")]
|
||||
public partial class BackfillMobileLinesTenantIdFromTenantSourceKey : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE "MobileLines" m
|
||||
SET "TenantId" = t."Id"
|
||||
FROM "Tenants" t
|
||||
WHERE t."SourceType" = 'MobileLines.Cliente'
|
||||
AND t."SourceKey" = m."Cliente"
|
||||
AND (m."TenantId" IS NULL OR m."TenantId" <> t."Id");
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "MobileLines" m
|
||||
LEFT JOIN "Tenants" t
|
||||
ON t."SourceType" = 'MobileLines.Cliente'
|
||||
AND t."SourceKey" = m."Cliente"
|
||||
WHERE t."Id" IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Backfill abortado: existem MobileLines sem tenant correspondente por SourceKey exato.';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
""");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// No-op intencional.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using line_gestao_api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace line_gestao_api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260226130400_MakeMobileLinesTenantIdNotNullAndIndexes")]
|
||||
public partial class MakeMobileLinesTenantIdNotNullAndIndexes : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""ALTER TABLE "MobileLines" ALTER COLUMN "TenantId" DROP DEFAULT;""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "MobileLines" ALTER COLUMN "TenantId" SET NOT NULL;""");
|
||||
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_MobileLines_TenantId" ON "MobileLines" ("TenantId");""");
|
||||
migrationBuilder.Sql("""CREATE UNIQUE INDEX IF NOT EXISTS "IX_MobileLines_TenantId_Linha" ON "MobileLines" ("TenantId", "Linha");""");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_MobileLines_TenantId";""");
|
||||
migrationBuilder.Sql("""ALTER TABLE "MobileLines" ALTER COLUMN "TenantId" DROP NOT NULL;""");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
using line_gestao_api.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace line_gestao_api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260227120000_AddVigenciaAutoRenewal")]
|
||||
public partial class AddVigenciaAutoRenewal : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "AutoRenewConfiguredAt",
|
||||
table: "VigenciaLines",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "AutoRenewReferenceEndDate",
|
||||
table: "VigenciaLines",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AutoRenewYears",
|
||||
table: "VigenciaLines",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastAutoRenewedAt",
|
||||
table: "VigenciaLines",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VigenciaLines_AutoRenewReferenceEndDate",
|
||||
table: "VigenciaLines",
|
||||
column: "AutoRenewReferenceEndDate");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VigenciaLines_AutoRenewReferenceEndDate",
|
||||
table: "VigenciaLines");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AutoRenewConfiguredAt",
|
||||
table: "VigenciaLines");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AutoRenewReferenceEndDate",
|
||||
table: "VigenciaLines");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AutoRenewYears",
|
||||
table: "VigenciaLines");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastAutoRenewedAt",
|
||||
table: "VigenciaLines");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -245,6 +245,12 @@ namespace line_gestao_api.Migrations
|
|||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid?>("ActorUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ActorTenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ChangesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
|
@ -274,6 +280,10 @@ namespace line_gestao_api.Migrations
|
|||
.HasMaxLength(80)
|
||||
.HasColumnType("character varying(80)");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("RequestMethod")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
|
@ -285,6 +295,9 @@ namespace line_gestao_api.Migrations
|
|||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TargetTenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("UserEmail")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
|
@ -298,6 +311,10 @@ namespace line_gestao_api.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ActorUserId");
|
||||
|
||||
b.HasIndex("ActorTenantId");
|
||||
|
||||
b.HasIndex("EntityName");
|
||||
|
||||
b.HasIndex("OccurredAtUtc");
|
||||
|
|
@ -306,6 +323,8 @@ namespace line_gestao_api.Migrations
|
|||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("TargetTenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AuditLogs");
|
||||
|
|
@ -1357,15 +1376,35 @@ namespace line_gestao_api.Migrations
|
|||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Ativo")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("NomeOficial")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("SourceKey")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("SourceType")
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("character varying(80)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsSystem", "Ativo");
|
||||
|
||||
b.HasIndex("SourceType", "SourceKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tenants");
|
||||
});
|
||||
|
||||
|
|
@ -1510,6 +1549,15 @@ namespace line_gestao_api.Migrations
|
|||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("AutoRenewConfiguredAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("AutoRenewReferenceEndDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("AutoRenewYears")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Cliente")
|
||||
.HasColumnType("text");
|
||||
|
||||
|
|
@ -1528,6 +1576,9 @@ namespace line_gestao_api.Migrations
|
|||
b.Property<int>("Item")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("LastAutoRenewedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Linha")
|
||||
.HasColumnType("text");
|
||||
|
||||
|
|
@ -1550,6 +1601,8 @@ namespace line_gestao_api.Migrations
|
|||
|
||||
b.HasIndex("Cliente");
|
||||
|
||||
b.HasIndex("AutoRenewReferenceEndDate");
|
||||
|
||||
b.HasIndex("DtTerminoFidelizacao");
|
||||
|
||||
b.HasIndex("Item");
|
||||
|
|
|
|||
|
|
@ -6,10 +6,16 @@ public class AuditLog : ITenantEntity
|
|||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
// Compatibilidade com histórico atual + filtro global.
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public Guid? ActorUserId { get; set; }
|
||||
public Guid ActorTenantId { get; set; }
|
||||
public Guid TargetTenantId { get; set; }
|
||||
|
||||
public DateTime OccurredAtUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Campos legados usados pela tela de histórico.
|
||||
public Guid? UserId { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string? UserEmail { get; set; }
|
||||
|
|
@ -21,6 +27,7 @@ public class AuditLog : ITenantEntity
|
|||
public string? EntityLabel { get; set; }
|
||||
|
||||
public string ChangesJson { get; set; } = "[]";
|
||||
public string MetadataJson { get; set; } = "{}";
|
||||
|
||||
public string? RequestPath { get; set; }
|
||||
public string? RequestMethod { get; set; }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ namespace line_gestao_api.Models;
|
|||
public class Tenant
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string NomeOficial { get; set; } = string.Empty;
|
||||
public bool IsSystem { get; set; }
|
||||
public bool Ativo { get; set; } = true;
|
||||
public string? SourceType { get; set; }
|
||||
public string? SourceKey { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ namespace line_gestao_api.Models
|
|||
|
||||
public DateTime? DtEfetivacaoServico { get; set; }
|
||||
public DateTime? DtTerminoFidelizacao { get; set; }
|
||||
public int? AutoRenewYears { get; set; }
|
||||
public DateTime? AutoRenewReferenceEndDate { get; set; }
|
||||
public DateTime? AutoRenewConfiguredAt { get; set; }
|
||||
public DateTime? LastAutoRenewedAt { get; set; }
|
||||
|
||||
public decimal? Total { get; set; }
|
||||
|
||||
|
|
|
|||
15
Program.cs
15
Program.cs
|
|
@ -1,4 +1,5 @@
|
|||
using System.IO;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
using line_gestao_api.Data;
|
||||
|
|
@ -91,9 +92,11 @@ builder.Services.AddDbContext<AppDbContext>(options =>
|
|||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ITenantProvider, TenantProvider>();
|
||||
builder.Services.AddScoped<IAuditLogBuilder, AuditLogBuilder>();
|
||||
builder.Services.AddScoped<ISystemAuditService, SystemAuditService>();
|
||||
builder.Services.AddScoped<IVigenciaNotificationSyncService, VigenciaNotificationSyncService>();
|
||||
builder.Services.AddScoped<ParcelamentosImportService>();
|
||||
builder.Services.AddScoped<GeralDashboardInsightsService>();
|
||||
builder.Services.AddScoped<GeralSpreadsheetTemplateService>();
|
||||
builder.Services.AddScoped<SpreadsheetImportAuditService>();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
|
|
@ -143,7 +146,13 @@ builder.Services
|
|||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("SystemAdmin", policy =>
|
||||
{
|
||||
policy.RequireRole(SystemTenantConstants.SystemRole);
|
||||
});
|
||||
});
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
|
@ -193,3 +202,7 @@ app.MapControllers();
|
|||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
namespace line_gestao_api.Services;
|
||||
|
||||
public static class AppRoles
|
||||
{
|
||||
public const string SysAdmin = "sysadmin";
|
||||
public const string Gestor = "gestor";
|
||||
public const string Cliente = "cliente";
|
||||
|
||||
public static readonly string[] All = [SysAdmin, Gestor, Cliente];
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ public class AuditLogBuilder : IAuditLogBuilder
|
|||
|
||||
public List<AuditLog> BuildAuditLogs(ChangeTracker changeTracker)
|
||||
{
|
||||
var tenantId = _tenantProvider.TenantId;
|
||||
var tenantId = _tenantProvider.ActorTenantId;
|
||||
if (tenantId == null)
|
||||
{
|
||||
return new List<AuditLog>();
|
||||
|
|
@ -88,6 +88,12 @@ public class AuditLogBuilder : IAuditLogBuilder
|
|||
return new List<AuditLog>();
|
||||
}
|
||||
|
||||
if (IsSystemRequest(requestPath))
|
||||
{
|
||||
// Endpoints system usam auditoria explicita com actor/target.
|
||||
return new List<AuditLog>();
|
||||
}
|
||||
|
||||
var logs = new List<AuditLog>();
|
||||
|
||||
foreach (var entry in changeTracker.Entries())
|
||||
|
|
@ -109,6 +115,9 @@ public class AuditLogBuilder : IAuditLogBuilder
|
|||
logs.Add(new AuditLog
|
||||
{
|
||||
TenantId = tenantId.Value,
|
||||
ActorUserId = userInfo.UserId,
|
||||
ActorTenantId = tenantId.Value,
|
||||
TargetTenantId = tenantId.Value,
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
UserId = userInfo.UserId,
|
||||
UserName = userInfo.UserName,
|
||||
|
|
@ -119,6 +128,7 @@ public class AuditLogBuilder : IAuditLogBuilder
|
|||
EntityId = BuildEntityId(entry),
|
||||
EntityLabel = BuildEntityLabel(entry),
|
||||
ChangesJson = JsonSerializer.Serialize(changes, JsonOptions),
|
||||
MetadataJson = "{}",
|
||||
RequestPath = requestPath,
|
||||
RequestMethod = requestMethod,
|
||||
IpAddress = ipAddress
|
||||
|
|
@ -138,6 +148,16 @@ public class AuditLogBuilder : IAuditLogBuilder
|
|||
return requestPath.Contains("/import-excel", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsSystemRequest(string? requestPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(requestPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return requestPath.StartsWith("/api/system", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ResolveAction(EntityState state)
|
||||
=> state switch
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace line_gestao_api.Services;
|
||||
|
||||
public static class DeterministicGuid
|
||||
{
|
||||
public static Guid FromString(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
throw new ArgumentException("Valor obrigatório para gerar Guid determinístico.", nameof(input));
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
hash.AsSpan(0, 16).CopyTo(bytes);
|
||||
return new Guid(bytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
using ClosedXML.Excel;
|
||||
|
||||
namespace line_gestao_api.Services
|
||||
{
|
||||
public class GeralSpreadsheetTemplateService
|
||||
{
|
||||
private const string WorksheetName = "GERAL";
|
||||
private const int HeaderRow = 1;
|
||||
private const int FirstDataRow = 2;
|
||||
private const int LastColumn = 31; // A..AE
|
||||
|
||||
private static readonly string[] Headers =
|
||||
{
|
||||
"CONTA",
|
||||
"LINHA",
|
||||
"CHIP",
|
||||
"CLIENTE",
|
||||
"USUÁRIO",
|
||||
"PLANO CONTRATO",
|
||||
"FRAQUIA",
|
||||
"VALOR DO PLANO R$",
|
||||
"GESTÃO VOZ E DADOS R$",
|
||||
"SKEELO",
|
||||
"VIVO NEWS PLUS",
|
||||
"VIVO TRAVEL MUNDO",
|
||||
"VIVO SYNC",
|
||||
"VIVO GESTÃO DISPOSITIVO",
|
||||
"VALOR CONTRATO VIVO",
|
||||
"FRANQUIA LINE",
|
||||
"FRANQUIA GESTÃO",
|
||||
"LOCAÇÃO AP.",
|
||||
"VALOR CONTRATO LINE",
|
||||
"DESCONTO",
|
||||
"LUCRO",
|
||||
"STATUS",
|
||||
"DATA DO BLOQUEIO",
|
||||
"SKIL",
|
||||
"MODALIDADE",
|
||||
"CEDENTE",
|
||||
"SOLICITANTE",
|
||||
"DATA DA ENTREGA OPERA.",
|
||||
"DATA DA ENTREGA CLIENTE",
|
||||
"VENC. DA CONTA",
|
||||
"TIPO DE CHIP"
|
||||
};
|
||||
|
||||
private static readonly double[] ColumnWidths =
|
||||
{
|
||||
11.00, 12.00, 21.43, 70.14, 58.29, 27.71, 11.00, 18.14,
|
||||
20.71, 11.00, 14.57, 18.14, 11.00, 19.57, 21.43, 14.57,
|
||||
16.29, 13.00, 20.71, 13.00, 13.00, 14.57, 16.29, 13.00,
|
||||
16.29, 39.43, 27.86, 25.00, 27.71, 16.29, 13.00
|
||||
};
|
||||
|
||||
private static readonly int[] TextColumns =
|
||||
{
|
||||
1, 2, 3, 4, 5, 6, 7, 16, 17, 22, 24, 25, 26, 27, 31
|
||||
};
|
||||
|
||||
private static readonly int[] CurrencyColumns =
|
||||
{
|
||||
8, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21
|
||||
};
|
||||
|
||||
private static readonly int[] DateColumns =
|
||||
{
|
||||
23, 28, 29, 30
|
||||
};
|
||||
|
||||
public byte[] BuildPlanilhaGeralTemplate()
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
var ws = workbook.Worksheets.Add(WorksheetName);
|
||||
|
||||
BuildHeader(ws);
|
||||
ConfigureColumns(ws);
|
||||
ConfigureDataFormatting(ws);
|
||||
ConfigureSheetView(ws);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void BuildHeader(IXLWorksheet ws)
|
||||
{
|
||||
for (var i = 0; i < Headers.Length; i++)
|
||||
{
|
||||
ws.Cell(HeaderRow, i + 1).Value = Headers[i];
|
||||
}
|
||||
|
||||
var headerRange = ws.Range(HeaderRow, 1, HeaderRow, LastColumn);
|
||||
headerRange.Style.Font.FontName = "Calibri";
|
||||
headerRange.Style.Font.FontSize = 11;
|
||||
headerRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||
headerRange.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
headerRange.Style.Border.TopBorder = XLBorderStyleValues.Thin;
|
||||
headerRange.Style.Border.BottomBorder = XLBorderStyleValues.Thin;
|
||||
headerRange.Style.Border.LeftBorder = XLBorderStyleValues.Thin;
|
||||
headerRange.Style.Border.RightBorder = XLBorderStyleValues.Thin;
|
||||
|
||||
ws.Row(HeaderRow).Height = 14.25;
|
||||
|
||||
var navy = XLColor.FromHtml("#002060");
|
||||
var purple = XLColor.FromHtml("#7030A0");
|
||||
var orange = XLColor.FromHtml("#D9A87E");
|
||||
var red = XLColor.FromHtml("#FF0000");
|
||||
var yellow = XLColor.FromHtml("#FFFF00");
|
||||
var white = XLColor.White;
|
||||
var black = XLColor.Black;
|
||||
|
||||
ApplyHeaderBlock(ws, 1, 6, navy, white, bold: true); // A-F
|
||||
ApplyHeaderBlock(ws, 22, 31, navy, white, bold: true); // V-AE
|
||||
ApplyHeaderBlock(ws, 7, 15, purple, white, bold: true); // G-O
|
||||
ApplyHeaderBlock(ws, 16, 19, orange, white, bold: true);// P-S
|
||||
ApplyHeaderBlock(ws, 20, 20, red, white, bold: true); // T
|
||||
ApplyHeaderBlock(ws, 21, 21, yellow, black, bold: true);// U
|
||||
|
||||
// Exceções no bloco azul (sem negrito): CHIP, CLIENTE, USUÁRIO => C, D, E
|
||||
ws.Cell(1, 3).Style.Font.Bold = false;
|
||||
ws.Cell(1, 4).Style.Font.Bold = false;
|
||||
ws.Cell(1, 5).Style.Font.Bold = false;
|
||||
}
|
||||
|
||||
private static void ApplyHeaderBlock(
|
||||
IXLWorksheet ws,
|
||||
int startCol,
|
||||
int endCol,
|
||||
XLColor bgColor,
|
||||
XLColor fontColor,
|
||||
bool bold)
|
||||
{
|
||||
var range = ws.Range(HeaderRow, startCol, HeaderRow, endCol);
|
||||
range.Style.Fill.BackgroundColor = bgColor;
|
||||
range.Style.Font.FontColor = fontColor;
|
||||
range.Style.Font.Bold = bold;
|
||||
}
|
||||
|
||||
private static void ConfigureColumns(IXLWorksheet ws)
|
||||
{
|
||||
for (var i = 0; i < ColumnWidths.Length; i++)
|
||||
{
|
||||
ws.Column(i + 1).Width = ColumnWidths[i];
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureDataFormatting(IXLWorksheet ws)
|
||||
{
|
||||
// Prepara um range vazio com estilo base para facilitar preenchimento manual
|
||||
var dataPreviewRange = ws.Range(FirstDataRow, 1, 1000, LastColumn);
|
||||
dataPreviewRange.Style.Font.FontName = "Calibri";
|
||||
dataPreviewRange.Style.Font.FontSize = 11;
|
||||
dataPreviewRange.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
|
||||
foreach (var col in TextColumns)
|
||||
{
|
||||
ws.Column(col).Style.NumberFormat.Format = "@";
|
||||
}
|
||||
|
||||
foreach (var col in CurrencyColumns)
|
||||
{
|
||||
ws.Column(col).Style.NumberFormat.Format = "\"R$\" #,##0.00";
|
||||
}
|
||||
|
||||
foreach (var col in DateColumns)
|
||||
{
|
||||
ws.Column(col).Style.DateFormat.Format = "dd/MM/yyyy";
|
||||
}
|
||||
|
||||
// O campo ITÉM é gerado internamente pelo sistema e não faz parte do template.
|
||||
}
|
||||
|
||||
private static void ConfigureSheetView(IXLWorksheet ws)
|
||||
{
|
||||
ws.ShowGridLines = false;
|
||||
ws.SheetView.FreezeRows(1); // Freeze em A2 (mantém linha 1 fixa)
|
||||
ws.Range(1, 1, 1, LastColumn).SetAutoFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace line_gestao_api.Services;
|
||||
|
||||
public interface ISystemAuditService
|
||||
{
|
||||
Task LogAsync(string action, Guid targetTenantId, object? metadata = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ namespace line_gestao_api.Services;
|
|||
|
||||
public interface ITenantProvider
|
||||
{
|
||||
Guid? ActorTenantId { get; }
|
||||
Guid? TenantId { get; }
|
||||
bool HasGlobalViewAccess { get; }
|
||||
void SetTenantId(Guid? tenantId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
namespace line_gestao_api.Services;
|
||||
|
||||
public static class SystemAuditActions
|
||||
{
|
||||
public const string ListTenants = "SYSTEM_LIST_TENANTS";
|
||||
public const string CreateTenantUser = "SYS_CREATE_USER";
|
||||
public const string CreateTenantUserRejected = "SYS_CREATE_USER_ERR";
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
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.Models;
|
||||
|
||||
namespace line_gestao_api.Services;
|
||||
|
||||
public class SystemAuditService : ISystemAuditService
|
||||
{
|
||||
private const int ActionMaxLength = 20;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
|
||||
public SystemAuditService(
|
||||
AppDbContext db,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ITenantProvider tenantProvider)
|
||||
{
|
||||
_db = db;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_tenantProvider = tenantProvider;
|
||||
}
|
||||
|
||||
public async Task LogAsync(string action, Guid targetTenantId, object? metadata = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var actorTenantId = _tenantProvider.ActorTenantId;
|
||||
if (!actorTenantId.HasValue || actorTenantId.Value == Guid.Empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var user = _httpContextAccessor.HttpContext?.User;
|
||||
var userId = ResolveUserId(user);
|
||||
var userName = ResolveUserName(user);
|
||||
var userEmail = ResolveUserEmail(user);
|
||||
|
||||
var request = _httpContextAccessor.HttpContext?.Request;
|
||||
var requestPath = request?.Path.Value;
|
||||
var requestMethod = request?.Method;
|
||||
var ipAddress = _httpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString();
|
||||
var safeMetadataJson = JsonSerializer.Serialize(metadata ?? new { }, JsonOptions);
|
||||
var normalizedAction = NormalizeAction(action);
|
||||
|
||||
_db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
TenantId = actorTenantId.Value,
|
||||
ActorUserId = userId,
|
||||
ActorTenantId = actorTenantId.Value,
|
||||
TargetTenantId = targetTenantId,
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Action = normalizedAction,
|
||||
Page = "System",
|
||||
EntityName = "System",
|
||||
EntityId = targetTenantId.ToString(),
|
||||
EntityLabel = null,
|
||||
ChangesJson = "[]",
|
||||
MetadataJson = safeMetadataJson,
|
||||
UserId = userId,
|
||||
UserName = userName,
|
||||
UserEmail = userEmail,
|
||||
RequestPath = requestPath,
|
||||
RequestMethod = requestMethod,
|
||||
IpAddress = ipAddress
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string NormalizeAction(string? action)
|
||||
{
|
||||
var normalized = (action ?? string.Empty).Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
if (normalized.Length <= ActionMaxLength)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized[..ActionMaxLength];
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
namespace line_gestao_api.Services;
|
||||
|
||||
public static class SystemTenantConstants
|
||||
{
|
||||
public const string SystemTenantSeed = "SYSTEM_TENANT";
|
||||
public const string SystemTenantNomeOficial = "SystemTenant";
|
||||
public const string SystemRole = AppRoles.SysAdmin;
|
||||
public const string MobileLinesClienteSourceType = "MobileLines.Cliente";
|
||||
|
||||
public static readonly Guid SystemTenantId = DeterministicGuid.FromString(SystemTenantSeed);
|
||||
}
|
||||
|
|
@ -13,8 +13,14 @@ public class TenantProvider : ITenantProvider
|
|||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public Guid? ActorTenantId => TenantId;
|
||||
|
||||
public Guid? TenantId => CurrentTenant.Value ?? ResolveFromClaims();
|
||||
|
||||
public bool HasGlobalViewAccess =>
|
||||
HasRole(AppRoles.SysAdmin) ||
|
||||
HasRole(AppRoles.Gestor);
|
||||
|
||||
public void SetTenantId(Guid? tenantId)
|
||||
{
|
||||
CurrentTenant.Value = tenantId;
|
||||
|
|
@ -27,4 +33,21 @@ public class TenantProvider : ITenantProvider
|
|||
|
||||
return Guid.TryParse(claim, out var tenantId) ? tenantId : null;
|
||||
}
|
||||
|
||||
private bool HasRole(string role)
|
||||
{
|
||||
var principal = _httpContextAccessor.HttpContext?.User;
|
||||
if (principal?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var roleClaims = principal.FindAll(ClaimTypes.Role)
|
||||
.Select(c => c.Value)
|
||||
.Concat(principal.FindAll("role").Select(c => c.Value))
|
||||
.Concat(principal.FindAll("roles").Select(c => c.Value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return roleClaims.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ public class VigenciaNotificationBackgroundService : BackgroundService
|
|||
return;
|
||||
}
|
||||
|
||||
var tenants = await db.Tenants.AsNoTracking().ToListAsync(stoppingToken);
|
||||
var tenants = await db.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(t => !t.IsSystem && t.Ativo)
|
||||
.ToListAsync(stoppingToken);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Nenhum tenant encontrado para gerar notificações.");
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService
|
|||
.Where(u => !string.IsNullOrWhiteSpace(u.Email))
|
||||
.ToDictionary(u => u.Email!.Trim().ToLowerInvariant(), u => u.Id);
|
||||
|
||||
await ApplyAutoRenewalsAsync(tenantId, today, userByName, userByEmail, cancellationToken);
|
||||
|
||||
var vigencias = await _db.VigenciaLines.AsNoTracking()
|
||||
.Where(v => v.DtTerminoFidelizacao != null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
|
@ -213,6 +215,112 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService
|
|||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyAutoRenewalsAsync(
|
||||
Guid tenantId,
|
||||
DateTime todayUtc,
|
||||
IReadOnlyDictionary<string, Guid> userByName,
|
||||
IReadOnlyDictionary<string, Guid> userByEmail,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scheduledLines = await _db.VigenciaLines
|
||||
.Where(v =>
|
||||
v.AutoRenewYears != null &&
|
||||
v.AutoRenewReferenceEndDate != null &&
|
||||
v.DtTerminoFidelizacao != null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (scheduledLines.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
var autoRenewNotifications = new List<Notification>();
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
|
||||
foreach (var vigencia in scheduledLines)
|
||||
{
|
||||
var years = NormalizeAutoRenewYears(vigencia.AutoRenewYears);
|
||||
if (!years.HasValue || !vigencia.DtTerminoFidelizacao.HasValue || !vigencia.AutoRenewReferenceEndDate.HasValue)
|
||||
{
|
||||
if (vigencia.AutoRenewYears.HasValue || vigencia.AutoRenewReferenceEndDate.HasValue || vigencia.AutoRenewConfiguredAt.HasValue)
|
||||
{
|
||||
ClearAutoRenewSchedule(vigencia);
|
||||
vigencia.UpdatedAt = nowUtc;
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var currentEndUtc = ToUtcDate(vigencia.DtTerminoFidelizacao.Value);
|
||||
var referenceEndUtc = ToUtcDate(vigencia.AutoRenewReferenceEndDate.Value);
|
||||
|
||||
// As datas de vigência foram alteradas manualmente após o agendamento:
|
||||
// não renova automaticamente e limpa o agendamento.
|
||||
if (currentEndUtc != referenceEndUtc)
|
||||
{
|
||||
ClearAutoRenewSchedule(vigencia);
|
||||
vigencia.UpdatedAt = nowUtc;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Só executa a renovação no vencimento (ou se já passou e segue sem alteração manual).
|
||||
if (currentEndUtc > todayUtc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var newStartUtc = currentEndUtc.AddDays(1);
|
||||
var newEndUtc = currentEndUtc.AddYears(years.Value);
|
||||
|
||||
vigencia.DtEfetivacaoServico = newStartUtc;
|
||||
vigencia.DtTerminoFidelizacao = newEndUtc;
|
||||
vigencia.LastAutoRenewedAt = nowUtc;
|
||||
ClearAutoRenewSchedule(vigencia);
|
||||
vigencia.UpdatedAt = nowUtc;
|
||||
changed = true;
|
||||
|
||||
autoRenewNotifications.Add(BuildAutoRenewNotification(
|
||||
vigencia,
|
||||
years.Value,
|
||||
currentEndUtc,
|
||||
newEndUtc,
|
||||
ResolveUserId(vigencia.Usuario, userByName, userByEmail),
|
||||
tenantId));
|
||||
}
|
||||
|
||||
if (!changed && autoRenewNotifications.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoRenewNotifications.Count > 0)
|
||||
{
|
||||
var dedupKeys = autoRenewNotifications
|
||||
.Select(n => n.DedupKey)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var existingDedupKeys = await _db.Notifications.AsNoTracking()
|
||||
.Where(n => dedupKeys.Contains(n.DedupKey))
|
||||
.Select(n => n.DedupKey)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var existingSet = existingDedupKeys.ToHashSet(StringComparer.Ordinal);
|
||||
autoRenewNotifications = autoRenewNotifications
|
||||
.Where(n => !existingSet.Contains(n.DedupKey))
|
||||
.ToList();
|
||||
|
||||
if (autoRenewNotifications.Count > 0)
|
||||
{
|
||||
await _db.Notifications.AddRangeAsync(autoRenewNotifications, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task CleanupOutdatedNotificationsAsync(
|
||||
IReadOnlyCollection<VigenciaLine> vigencias,
|
||||
bool notifyAllFutureDates,
|
||||
|
|
@ -349,6 +457,38 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService
|
|||
};
|
||||
}
|
||||
|
||||
private static Notification BuildAutoRenewNotification(
|
||||
VigenciaLine vigencia,
|
||||
int years,
|
||||
DateTime previousEndUtc,
|
||||
DateTime newEndUtc,
|
||||
Guid? userId,
|
||||
Guid tenantId)
|
||||
{
|
||||
var linha = vigencia.Linha?.Trim();
|
||||
var cliente = vigencia.Cliente?.Trim();
|
||||
var usuario = vigencia.Usuario?.Trim();
|
||||
var dedupKey = BuildAutoRenewDedupKey(tenantId, vigencia.Id, previousEndUtc, years);
|
||||
|
||||
return new Notification
|
||||
{
|
||||
Tipo = "RenovacaoAutomatica",
|
||||
Titulo = $"Renovação automática concluída{FormatLinha(linha)}",
|
||||
Mensagem = $"A linha{FormatLinha(linha)} do cliente {cliente ?? "(sem cliente)"} foi renovada automaticamente por {years} ano(s): {previousEndUtc:dd/MM/yyyy} → {newEndUtc:dd/MM/yyyy}.",
|
||||
Data = DateTime.UtcNow,
|
||||
ReferenciaData = newEndUtc,
|
||||
DiasParaVencer = null,
|
||||
Lida = false,
|
||||
DedupKey = dedupKey,
|
||||
UserId = userId,
|
||||
Usuario = usuario,
|
||||
Cliente = cliente,
|
||||
Linha = linha,
|
||||
VigenciaLineId = vigencia.Id,
|
||||
TenantId = tenantId
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildDedupKey(
|
||||
string tipo,
|
||||
DateTime referenciaData,
|
||||
|
|
@ -370,6 +510,59 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService
|
|||
return string.Join('|', parts);
|
||||
}
|
||||
|
||||
private static string BuildAutoRenewDedupKey(Guid tenantId, Guid vigenciaLineId, DateTime referenceEndDateUtc, int years)
|
||||
{
|
||||
return string.Join('|', new[]
|
||||
{
|
||||
"renovacaoautomatica",
|
||||
tenantId.ToString("N"),
|
||||
vigenciaLineId.ToString("N"),
|
||||
referenceEndDateUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
years.ToString(CultureInfo.InvariantCulture)
|
||||
});
|
||||
}
|
||||
|
||||
private static Guid? ResolveUserId(
|
||||
string? usuario,
|
||||
IReadOnlyDictionary<string, Guid> userByName,
|
||||
IReadOnlyDictionary<string, Guid> userByEmail)
|
||||
{
|
||||
var key = usuario?.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (userByEmail.TryGetValue(key, out var byEmail))
|
||||
{
|
||||
return byEmail;
|
||||
}
|
||||
|
||||
if (userByName.TryGetValue(key, out var byName))
|
||||
{
|
||||
return byName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? NormalizeAutoRenewYears(int? years)
|
||||
{
|
||||
return years == 2 ? years : null;
|
||||
}
|
||||
|
||||
private static DateTime ToUtcDate(DateTime value)
|
||||
{
|
||||
return DateTime.SpecifyKind(value.Date, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private static void ClearAutoRenewSchedule(VigenciaLine vigencia)
|
||||
{
|
||||
vigencia.AutoRenewYears = null;
|
||||
vigencia.AutoRenewReferenceEndDate = null;
|
||||
vigencia.AutoRenewConfiguredAt = null;
|
||||
}
|
||||
|
||||
private static string FormatLinha(string? linha)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(linha) ? string.Empty : $" {linha}";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"ConnectionStrings": {
|
||||
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=linegestao_app;Password=1231234512Ed@"
|
||||
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "linegestao-local-jwt-key-2026-dev-strong-123456789",
|
||||
|
|
@ -31,9 +31,8 @@
|
|||
"Seed": {
|
||||
"Enabled": true,
|
||||
"ReapplyAdminCredentialsOnStartup": true,
|
||||
"DefaultTenantName": "Default",
|
||||
"AdminName": "Administrador",
|
||||
"AdminEmail": "admin@linegestao.local",
|
||||
"AdminPassword": "DevAdmin123!"
|
||||
"AdminMasterName": "Admin Master",
|
||||
"AdminMasterEmail": "admin.master@linegestao.local",
|
||||
"AdminMasterPassword": "DevAdminMaster123!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"ConnectionStrings": {
|
||||
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=linegestao_app;Password=1231234512Ed@"
|
||||
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "YOUR_LOCAL_STRONG_JWT_KEY_MIN_32_CHARS",
|
||||
|
|
@ -11,9 +11,8 @@
|
|||
"Seed": {
|
||||
"Enabled": true,
|
||||
"ReapplyAdminCredentialsOnStartup": false,
|
||||
"DefaultTenantName": "Default",
|
||||
"AdminName": "Administrador",
|
||||
"AdminEmail": "admin@linegestao.local",
|
||||
"AdminPassword": "YOUR_LOCAL_ADMIN_SEED_PASSWORD"
|
||||
"AdminMasterName": "Admin Master",
|
||||
"AdminMasterEmail": "admin.master@linegestao.local",
|
||||
"AdminMasterPassword": "YOUR_LOCAL_ADMIN_MASTER_SEED_PASSWORD"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"ConnectionStrings": {
|
||||
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=linegestao_app;Password=1231234512Ed@"
|
||||
"Default": "Host=localhost;Port=5432;Database=linegestao;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "linegestao-local-jwt-key-2026-dev-strong-123456789",
|
||||
|
|
@ -31,9 +31,8 @@
|
|||
"Seed": {
|
||||
"Enabled": true,
|
||||
"ReapplyAdminCredentialsOnStartup": true,
|
||||
"DefaultTenantName": "Default",
|
||||
"AdminName": "Administrador",
|
||||
"AdminEmail": "admin@linegestao.local",
|
||||
"AdminPassword": "DevAdmin123!"
|
||||
"AdminMasterName": "Admin Master",
|
||||
"AdminMasterEmail": "admin.master@linegestao.local",
|
||||
"AdminMasterPassword": "DevAdminMaster123!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,8 +148,12 @@ namespace line_gestao_api.Tests
|
|||
TenantId = tenantId;
|
||||
}
|
||||
|
||||
public Guid? ActorTenantId => TenantId;
|
||||
|
||||
public Guid? TenantId { get; private set; }
|
||||
|
||||
public bool HasGlobalViewAccess => false;
|
||||
|
||||
public void SetTenantId(Guid? tenantId)
|
||||
{
|
||||
TenantId = tenantId;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using line_gestao_api.Data;
|
||||
using line_gestao_api.Dtos;
|
||||
using line_gestao_api.Models;
|
||||
using line_gestao_api.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
namespace line_gestao_api.Tests;
|
||||
|
||||
public class SystemTenantIntegrationTests
|
||||
{
|
||||
private static readonly Guid TenantAId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
private static readonly Guid TenantBId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
|
||||
private const string TenantAClientName = "CLIENTE-ALFA LTDA";
|
||||
private const string TenantBClientName = "CLIENTE-BETA S/A";
|
||||
|
||||
[Fact]
|
||||
public async Task CommonUser_OnlySeesOwnTenantData()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
await SeedTenantsAndLinesAsync(factory.Services);
|
||||
await UpsertUserAsync(factory.Services, TenantAId, "tenanta.user@test.local", "TenantA123!", "cliente");
|
||||
|
||||
var token = await LoginAndGetTokenAsync(client, "tenanta.user@test.local", "TenantA123!", TenantAId);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await client.GetAsync("/api/lines/clients");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var clients = await response.Content.ReadFromJsonAsync<List<string>>();
|
||||
Assert.NotNull(clients);
|
||||
Assert.Contains(TenantAClientName, clients!);
|
||||
Assert.DoesNotContain(TenantBClientName, clients);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommonUser_CannotAccessSystemEndpoints()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
await SeedTenantsAndLinesAsync(factory.Services);
|
||||
await UpsertUserAsync(factory.Services, TenantAId, "tenanta.block@test.local", "TenantA123!", "cliente");
|
||||
|
||||
var token = await LoginAndGetTokenAsync(client, "tenanta.block@test.local", "TenantA123!", TenantAId);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await client.GetAsync("/api/system/tenants?source=MobileLines.Cliente&active=true");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SysAdmin_CanListClientTenants()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
await SeedTenantsAndLinesAsync(factory.Services);
|
||||
|
||||
var token = await LoginAndGetTokenAsync(
|
||||
client,
|
||||
"admin.master@test.local",
|
||||
"AdminMaster123!",
|
||||
SystemTenantConstants.SystemTenantId);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var response = await client.GetAsync("/api/system/tenants?source=MobileLines.Cliente&active=true");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var tenants = await response.Content.ReadFromJsonAsync<List<SystemTenantListItemDto>>();
|
||||
Assert.NotNull(tenants);
|
||||
Assert.Contains(tenants!, t => t.TenantId == TenantAId && t.NomeOficial == TenantAClientName);
|
||||
Assert.Contains(tenants, t => t.TenantId == TenantBId && t.NomeOficial == TenantBClientName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SysAdmin_CreatesTenantUser_AndNewUserSeesOnlyOwnTenant()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
await SeedTenantsAndLinesAsync(factory.Services);
|
||||
|
||||
var adminToken = await LoginAndGetTokenAsync(
|
||||
client,
|
||||
"admin.master@test.local",
|
||||
"AdminMaster123!",
|
||||
SystemTenantConstants.SystemTenantId);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||
|
||||
var request = new CreateSystemTenantUserRequest
|
||||
{
|
||||
Name = "Usuário Cliente A",
|
||||
Email = "novo.clientea@test.local",
|
||||
Password = "ClienteA123!",
|
||||
Roles = new List<string> { "cliente" }
|
||||
};
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync($"/api/system/tenants/{TenantAId}/users", request);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<SystemTenantUserCreatedDto>();
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal(TenantAId, created!.TenantId);
|
||||
Assert.Equal("novo.clientea@test.local", created.Email);
|
||||
Assert.Contains("cliente", created.Roles);
|
||||
|
||||
var userToken = await LoginAndGetTokenAsync(client, "novo.clientea@test.local", "ClienteA123!", TenantAId);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", userToken);
|
||||
|
||||
var visibleClientsResponse = await client.GetAsync("/api/lines/clients");
|
||||
visibleClientsResponse.EnsureSuccessStatusCode();
|
||||
var clients = await visibleClientsResponse.Content.ReadFromJsonAsync<List<string>>();
|
||||
|
||||
Assert.NotNull(clients);
|
||||
Assert.Contains(TenantAClientName, clients!);
|
||||
Assert.DoesNotContain(TenantBClientName, clients);
|
||||
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var systemAudit = await db.AuditLogs
|
||||
.IgnoreQueryFilters()
|
||||
.OrderByDescending(x => x.OccurredAtUtc)
|
||||
.FirstOrDefaultAsync(x => x.Action == SystemAuditActions.CreateTenantUser);
|
||||
|
||||
Assert.NotNull(systemAudit);
|
||||
Assert.Equal(SystemTenantConstants.SystemTenantId, systemAudit!.ActorTenantId);
|
||||
Assert.Equal(TenantAId, systemAudit.TargetTenantId);
|
||||
Assert.DoesNotContain("ClienteA123!", systemAudit.MetadataJson);
|
||||
}
|
||||
|
||||
private static async Task SeedTenantsAndLinesAsync(IServiceProvider services)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var tenantA = await db.Tenants.FirstOrDefaultAsync(t => t.Id == TenantAId);
|
||||
if (tenantA == null)
|
||||
{
|
||||
db.Tenants.Add(new Tenant
|
||||
{
|
||||
Id = TenantAId,
|
||||
NomeOficial = TenantAClientName,
|
||||
IsSystem = false,
|
||||
Ativo = true,
|
||||
SourceType = SystemTenantConstants.MobileLinesClienteSourceType,
|
||||
SourceKey = TenantAClientName,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
var tenantB = await db.Tenants.FirstOrDefaultAsync(t => t.Id == TenantBId);
|
||||
if (tenantB == null)
|
||||
{
|
||||
db.Tenants.Add(new Tenant
|
||||
{
|
||||
Id = TenantBId,
|
||||
NomeOficial = TenantBClientName,
|
||||
IsSystem = false,
|
||||
Ativo = true,
|
||||
SourceType = SystemTenantConstants.MobileLinesClienteSourceType,
|
||||
SourceKey = TenantBClientName,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
var currentLines = await db.MobileLines.IgnoreQueryFilters().ToListAsync();
|
||||
if (currentLines.Count > 0)
|
||||
{
|
||||
db.MobileLines.RemoveRange(currentLines);
|
||||
}
|
||||
|
||||
db.MobileLines.AddRange(
|
||||
new MobileLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Item = 1,
|
||||
Linha = "5511999990001",
|
||||
Cliente = TenantAClientName,
|
||||
TenantId = TenantAId
|
||||
},
|
||||
new MobileLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Item = 2,
|
||||
Linha = "5511888880002",
|
||||
Cliente = TenantBClientName,
|
||||
TenantId = TenantBId
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task UpsertUserAsync(
|
||||
IServiceProvider services,
|
||||
Guid tenantId,
|
||||
string email,
|
||||
string password,
|
||||
params string[] roles)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var tenantProvider = scope.ServiceProvider.GetRequiredService<ITenantProvider>();
|
||||
|
||||
var previousTenant = tenantProvider.ActorTenantId;
|
||||
tenantProvider.SetTenantId(tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
var normalizedEmail = userManager.NormalizeEmail(email);
|
||||
var user = await userManager.Users
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.TenantId == tenantId && u.NormalizedEmail == normalizedEmail);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
user = new ApplicationUser
|
||||
{
|
||||
Name = email,
|
||||
Email = email,
|
||||
UserName = email,
|
||||
TenantId = tenantId,
|
||||
EmailConfirmed = true,
|
||||
IsActive = true,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
|
||||
var createResult = await userManager.CreateAsync(user, password);
|
||||
Assert.True(createResult.Succeeded, string.Join("; ", createResult.Errors.Select(e => e.Description)));
|
||||
}
|
||||
else
|
||||
{
|
||||
var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var reset = await userManager.ResetPasswordAsync(user, resetToken, password);
|
||||
Assert.True(reset.Succeeded, string.Join("; ", reset.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
var existingRoles = await userManager.GetRolesAsync(user);
|
||||
if (existingRoles.Count > 0)
|
||||
{
|
||||
var removeRolesResult = await userManager.RemoveFromRolesAsync(user, existingRoles);
|
||||
Assert.True(removeRolesResult.Succeeded, string.Join("; ", removeRolesResult.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
var addRolesResult = await userManager.AddToRolesAsync(user, roles);
|
||||
Assert.True(addRolesResult.Succeeded, string.Join("; ", addRolesResult.Errors.Select(e => e.Description)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantProvider.SetTenantId(previousTenant);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> LoginAndGetTokenAsync(HttpClient client, string email, string password, Guid tenantId)
|
||||
{
|
||||
var previousAuth = client.DefaultRequestHeaders.Authorization;
|
||||
client.DefaultRequestHeaders.Authorization = null;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/auth/login", new
|
||||
{
|
||||
email,
|
||||
password,
|
||||
tenantId
|
||||
});
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var auth = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
Assert.NotNull(auth);
|
||||
Assert.False(string.IsNullOrWhiteSpace(auth!.Token));
|
||||
return auth.Token;
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = previousAuth;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _databaseName = $"line-gestao-tests-{Guid.NewGuid()}";
|
||||
|
||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["App:UseHttpsRedirection"] = "false",
|
||||
["Seed:Enabled"] = "true",
|
||||
["Seed:AdminMasterName"] = "Admin Master",
|
||||
["Seed:AdminMasterEmail"] = "admin.master@test.local",
|
||||
["Seed:AdminMasterPassword"] = "AdminMaster123!",
|
||||
["Seed:ReapplyAdminCredentialsOnStartup"] = "true"
|
||||
});
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var notificationHostedService = services
|
||||
.Where(d => d.ServiceType == typeof(IHostedService) &&
|
||||
d.ImplementationType == typeof(VigenciaNotificationBackgroundService))
|
||||
.ToList();
|
||||
foreach (var descriptor in notificationHostedService)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
services.RemoveAll<AppDbContext>();
|
||||
services.RemoveAll<DbContextOptions<AppDbContext>>();
|
||||
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
{
|
||||
options.UseInMemoryDatabase(_databaseName);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,395 @@
|
|||
{
|
||||
"info": {
|
||||
"name": "Line Gestao - SystemTenant Multi-tenant Tests",
|
||||
"_postman_id": "c4c0b7d9-7f11-4a0c-b8ca-332633f12601",
|
||||
"description": "Fluxo de testes para sysadmin, endpoints /api/system/* e isolamento por tenant.",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"variable": [
|
||||
{ "key": "adminMasterToken", "value": "" },
|
||||
{ "key": "tenantAUserToken", "value": "" },
|
||||
{ "key": "newTenantAUserToken", "value": "" },
|
||||
{ "key": "tenantAUserId", "value": "" },
|
||||
{ "key": "newTenantAUserId", "value": "" },
|
||||
{ "key": "newTenantAUserEmail", "value": "" },
|
||||
{ "key": "newTenantAUserPassword", "value": "" },
|
||||
{ "key": "newTenantAUserName", "value": "" }
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "1) Login sysadmin",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"email\": \"{{adminMasterEmail}}\",\n \"password\": \"{{adminMasterPassword}}\",\n \"tenantId\": \"{{systemTenantId}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/auth/login",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["auth", "login"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"const json = pm.response.json();",
|
||||
"pm.test('Retorna token JWT', function () {",
|
||||
" pm.expect(json.token).to.be.a('string').and.not.empty;",
|
||||
"});",
|
||||
"pm.collectionVariables.set('adminMasterToken', json.token);"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "2) GET /api/system/tenants (sysadmin)",
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{ "key": "token", "value": "{{adminMasterToken}}", "type": "string" }
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/system/tenants?source=MobileLines.Cliente&active=true",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "system", "tenants"],
|
||||
"query": [
|
||||
{ "key": "source", "value": "MobileLines.Cliente" },
|
||||
{ "key": "active", "value": "true" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"const tenants = pm.response.json();",
|
||||
"pm.test('Retorna array de tenants', function () {",
|
||||
" pm.expect(Array.isArray(tenants)).to.eql(true);",
|
||||
"});",
|
||||
"const tenantAClientName = pm.environment.get('tenantAClientName');",
|
||||
"const tenantBClientName = pm.environment.get('tenantBClientName');",
|
||||
"if (tenantAClientName) {",
|
||||
" const tenantA = tenants.find(t => t.nomeOficial === tenantAClientName || t.NomeOficial === tenantAClientName);",
|
||||
" pm.test('Tenant A encontrado por nomeOficial', function () {",
|
||||
" pm.expect(tenantA).to.exist;",
|
||||
" });",
|
||||
" if (tenantA && (tenantA.tenantId || tenantA.TenantId)) {",
|
||||
" pm.environment.set('tenantAId', tenantA.tenantId || tenantA.TenantId);",
|
||||
" }",
|
||||
"}",
|
||||
"if (tenantBClientName) {",
|
||||
" const tenantB = tenants.find(t => t.nomeOficial === tenantBClientName || t.NomeOficial === tenantBClientName);",
|
||||
" pm.test('Tenant B encontrado por nomeOficial', function () {",
|
||||
" pm.expect(tenantB).to.exist;",
|
||||
" });",
|
||||
" if (tenantB && (tenantB.tenantId || tenantB.TenantId)) {",
|
||||
" pm.environment.set('tenantBId', tenantB.tenantId || tenantB.TenantId);",
|
||||
" }",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "3) POST /api/system/tenants/{tenantId}/users (criar usuário comum tenant A)",
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{ "key": "token", "value": "{{adminMasterToken}}", "type": "string" }
|
||||
]
|
||||
},
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"{{tenantAUserName}}\",\n \"email\": \"{{tenantAUserEmail}}\",\n \"password\": \"{{tenantAUserPassword}}\",\n \"roles\": [\"cliente\"]\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/system/tenants/{{tenantAId}}/users",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "system", "tenants", "{{tenantAId}}", "users"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 201 (criado) ou 409 (já existe)', function () {",
|
||||
" pm.expect([201, 409]).to.include(pm.response.code);",
|
||||
"});",
|
||||
"if (pm.response.code === 201) {",
|
||||
" const json = pm.response.json();",
|
||||
" pm.collectionVariables.set('tenantAUserId', json.userId || json.UserId || '');",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "4) Login usuário comum tenant A",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"email\": \"{{tenantAUserEmail}}\",\n \"password\": \"{{tenantAUserPassword}}\",\n \"tenantId\": \"{{tenantAId}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/auth/login",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["auth", "login"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"const json = pm.response.json();",
|
||||
"pm.collectionVariables.set('tenantAUserToken', json.token);"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "5) Usuário comum NÃO acessa /api/system/*",
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{ "key": "token", "value": "{{tenantAUserToken}}", "type": "string" }
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/system/tenants?source=MobileLines.Cliente&active=true",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "system", "tenants"],
|
||||
"query": [
|
||||
{ "key": "source", "value": "MobileLines.Cliente" },
|
||||
{ "key": "active", "value": "true" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 403 Forbidden', function () {",
|
||||
" pm.response.to.have.status(403);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "6) Usuário comum tenant A vê apenas seu tenant",
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{ "key": "token", "value": "{{tenantAUserToken}}", "type": "string" }
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/lines/clients",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "lines", "clients"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"const clients = pm.response.json();",
|
||||
"pm.test('Retorna lista de clientes', function () {",
|
||||
" pm.expect(Array.isArray(clients)).to.eql(true);",
|
||||
"});",
|
||||
"const tenantAClientName = pm.environment.get('tenantAClientName');",
|
||||
"const tenantBClientName = pm.environment.get('tenantBClientName');",
|
||||
"if (tenantAClientName) {",
|
||||
" pm.test('Contém cliente do tenant A', function () {",
|
||||
" pm.expect(clients).to.include(tenantAClientName);",
|
||||
" });",
|
||||
"}",
|
||||
"if (tenantBClientName) {",
|
||||
" pm.test('Não contém cliente do tenant B', function () {",
|
||||
" pm.expect(clients).to.not.include(tenantBClientName);",
|
||||
" });",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "7) POST /api/system/tenants/{tenantId}/users (novo usuário tenant A)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"const suffix = Date.now().toString().slice(-8);",
|
||||
"pm.collectionVariables.set('newTenantAUserEmail', `novo.tenant.a.${suffix}@test.local`);",
|
||||
"pm.collectionVariables.set('newTenantAUserPassword', 'ClienteA123!');",
|
||||
"pm.collectionVariables.set('newTenantAUserName', `Novo Tenant A ${suffix}`);"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 201', function () {",
|
||||
" pm.response.to.have.status(201);",
|
||||
"});",
|
||||
"const json = pm.response.json();",
|
||||
"pm.collectionVariables.set('newTenantAUserId', json.userId || json.UserId || '');"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{ "key": "token", "value": "{{adminMasterToken}}", "type": "string" }
|
||||
]
|
||||
},
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"{{newTenantAUserName}}\",\n \"email\": \"{{newTenantAUserEmail}}\",\n \"password\": \"{{newTenantAUserPassword}}\",\n \"roles\": [\"cliente\"]\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/system/tenants/{{tenantAId}}/users",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "system", "tenants", "{{tenantAId}}", "users"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "8) Login novo usuário tenant A",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"email\": \"{{newTenantAUserEmail}}\",\n \"password\": \"{{newTenantAUserPassword}}\",\n \"tenantId\": \"{{tenantAId}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/auth/login",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["auth", "login"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"const json = pm.response.json();",
|
||||
"pm.collectionVariables.set('newTenantAUserToken', json.token);"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "9) Novo usuário tenant A vê apenas seu tenant",
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{ "key": "token", "value": "{{newTenantAUserToken}}", "type": "string" }
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/lines/clients",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "lines", "clients"]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"const clients = pm.response.json();",
|
||||
"const tenantAClientName = pm.environment.get('tenantAClientName');",
|
||||
"const tenantBClientName = pm.environment.get('tenantBClientName');",
|
||||
"if (tenantAClientName) {",
|
||||
" pm.test('Contém cliente do tenant A', function () {",
|
||||
" pm.expect(clients).to.include(tenantAClientName);",
|
||||
" });",
|
||||
"}",
|
||||
"if (tenantBClientName) {",
|
||||
" pm.test('Não contém cliente do tenant B', function () {",
|
||||
" pm.expect(clients).to.not.include(tenantBClientName);",
|
||||
" });",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"id": "d1d8e905-e4b8-40c5-a62e-afb27c59b685",
|
||||
"name": "Line Gestao - Local",
|
||||
"values": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "http://localhost:5000",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "systemTenantId",
|
||||
"value": "562617c4-90dc-cfce-ddf4-64b6284dc4f2",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "adminMasterEmail",
|
||||
"value": "sysadmin@linegestao.local",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "adminMasterPassword",
|
||||
"value": "",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "tenantAId",
|
||||
"value": "",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "tenantBId",
|
||||
"value": "",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "tenantAClientName",
|
||||
"value": "",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "tenantBClientName",
|
||||
"value": "",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "tenantAUserName",
|
||||
"value": "Usuario Tenant A",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "tenantAUserEmail",
|
||||
"value": "tenanta.user@test.local",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "tenantAUserPassword",
|
||||
"value": "TenantA123!",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"_postman_variable_scope": "environment",
|
||||
"_postman_exported_at": "2026-02-26T12:00:00.000Z",
|
||||
"_postman_exported_using": "Codex GPT-5"
|
||||
}
|
||||
Loading…
Reference in New Issue