feat: Cliente poder alterar e solicitar alteracoes

This commit is contained in:
Leon 2026-03-05 18:32:05 -03:00
parent 208c201156
commit 8a562777df
10 changed files with 489 additions and 7 deletions

View File

@ -1339,22 +1339,31 @@ namespace line_gestao_api.Controllers
var canManageFullLine = User.IsInRole(AppRoles.SysAdmin) || User.IsInRole(AppRoles.Gestor); var canManageFullLine = User.IsInRole(AppRoles.SysAdmin) || User.IsInRole(AppRoles.Gestor);
if (!canManageFullLine) if (!canManageFullLine)
{ {
var tenantId = x.TenantId != Guid.Empty
? x.TenantId
: (_tenantProvider.ActorTenantId ?? Guid.Empty);
if (tenantId == Guid.Empty)
{
return BadRequest(new { message = "Tenant inválido para atualizar linha." });
}
if (x.TenantId == Guid.Empty)
{
x.TenantId = tenantId;
}
x.Usuario = NormalizeOptionalText(req.Usuario); x.Usuario = NormalizeOptionalText(req.Usuario);
x.CentroDeCustos = NormalizeOptionalText(req.CentroDeCustos); x.CentroDeCustos = NormalizeOptionalText(req.CentroDeCustos);
await ApplySetorToLineAsync(x, tenantId, req.SetorId, req.SetorNome);
await ApplyAparelhoToLineAsync( await ApplyAparelhoToLineAsync(
x, x,
x.TenantId, tenantId,
req.AparelhoId, req.AparelhoId,
req.AparelhoNome, req.AparelhoNome,
req.AparelhoCor, req.AparelhoCor,
req.AparelhoImei); req.AparelhoImei);
await UpsertVigenciaFromMobileLineAsync(
x,
dtEfetivacaoServico: null,
dtTerminoFidelizacao: null,
overrideDates: false,
previousLinha: null);
x.UpdatedAt = DateTime.UtcNow; x.UpdatedAt = DateTime.UtcNow;
try try

View File

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

View File

@ -50,6 +50,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
// ✅ tabela NOTIFICAÇÕES // ✅ tabela NOTIFICAÇÕES
public DbSet<Notification> Notifications => Set<Notification>(); public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<SolicitacaoLinha> SolicitacaoLinhas => Set<SolicitacaoLinha>();
// ✅ tabela RESUMO // ✅ tabela RESUMO
public DbSet<ResumoMacrophonyPlan> ResumoMacrophonyPlans => Set<ResumoMacrophonyPlan>(); public DbSet<ResumoMacrophonyPlan> ResumoMacrophonyPlans => Set<ResumoMacrophonyPlan>();
@ -281,6 +282,26 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<SolicitacaoLinha>(e =>
{
e.HasIndex(x => x.TenantId);
e.HasIndex(x => x.CreatedAt);
e.HasIndex(x => x.TipoSolicitacao);
e.HasIndex(x => x.Status);
e.HasIndex(x => x.MobileLineId);
e.HasIndex(x => x.SolicitanteUserId);
e.HasOne(x => x.MobileLine)
.WithMany()
.HasForeignKey(x => x.MobileLineId)
.OnDelete(DeleteBehavior.SetNull);
e.HasOne(x => x.SolicitanteUser)
.WithMany()
.HasForeignKey(x => x.SolicitanteUserId)
.OnDelete(DeleteBehavior.SetNull);
});
// ========================= // =========================
// ✅ PARCELAMENTOS // ✅ PARCELAMENTOS
// ========================= // =========================
@ -380,6 +401,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
modelBuilder.Entity<ChipVirgemLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ChipVirgemLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ControleRecebidoLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ControleRecebidoLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<Notification>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<Notification>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<SolicitacaoLinha>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoMacrophonyPlan>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ResumoMacrophonyPlan>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoMacrophonyTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ResumoMacrophonyTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoVivoLineResumo>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity<ResumoVivoLineResumo>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));

View File

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

View File

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

View File

@ -950,6 +950,74 @@ namespace line_gestao_api.Migrations
b.ToTable("Notifications"); b.ToTable("Notifications");
}); });
modelBuilder.Entity("line_gestao_api.Models.SolicitacaoLinha", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("FranquiaLineAtual")
.HasColumnType("numeric");
b.Property<decimal?>("FranquiaLineNova")
.HasColumnType("numeric");
b.Property<string>("Linha")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("Mensagem")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("MobileLineId")
.HasColumnType("uuid");
b.Property<string>("SolicitanteNome")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("SolicitanteUserId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("TipoSolicitacao")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("UsuarioLinha")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("MobileLineId");
b.HasIndex("SolicitanteUserId");
b.HasIndex("Status");
b.HasIndex("TenantId");
b.HasIndex("TipoSolicitacao");
b.ToTable("SolicitacaoLinhas");
});
modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b => modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1813,6 +1881,23 @@ namespace line_gestao_api.Migrations
b.Navigation("VigenciaLine"); b.Navigation("VigenciaLine");
}); });
modelBuilder.Entity("line_gestao_api.Models.SolicitacaoLinha", b =>
{
b.HasOne("line_gestao_api.Models.MobileLine", "MobileLine")
.WithMany()
.HasForeignKey("MobileLineId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("line_gestao_api.Models.ApplicationUser", "SolicitanteUser")
.WithMany()
.HasForeignKey("SolicitanteUserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("MobileLine");
b.Navigation("SolicitanteUser");
});
modelBuilder.Entity("line_gestao_api.Models.ParcelamentoMonthValue", b => modelBuilder.Entity("line_gestao_api.Models.ParcelamentoMonthValue", b =>
{ {
b.HasOne("line_gestao_api.Models.ParcelamentoLine", "ParcelamentoLine") b.HasOne("line_gestao_api.Models.ParcelamentoLine", "ParcelamentoLine")

View File

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