From 514c7ba8cd2559ea246cdb170d7836c9fbd79446 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Fri, 6 Feb 2026 16:59:22 -0300 Subject: [PATCH] =?UTF-8?q?Minha=20altera=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Controllers/AuthController.cs | 44 +++++- Controllers/BillingController.cs | 1 + Controllers/ChipsVirgensController.cs | 1 + Controllers/ControleRecebidosController.cs | 1 + Controllers/DashboardGeralController.cs | 2 + Controllers/HistoricoController.cs | 7 +- Controllers/ImportAuditController.cs | 86 +++++++++++ Controllers/LinesController.cs | 119 +++++++++++++-- Controllers/MuregController.cs | 3 + Controllers/RelatoriosController.cs | 2 + Controllers/ResumoController.cs | 16 +- Controllers/TrocaNumeroController.cs | 3 + Controllers/UserDataController.cs | 1 + Controllers/VigenciaController.cs | 1 + Data/AppDbContext.cs | 35 +++++ Data/SeedData.cs | 133 ++++++++++++++++ Dtos/ImportAuditDtos.cs | 26 ++++ ...213120000_AddImportAuditTables.Designer.cs | 19 +++ .../20260213120000_AddImportAuditTables.cs | 101 +++++++++++++ ...30000_RemoveDetailedImportLogs.Designer.cs | 19 +++ ...20260213130000_RemoveDetailedImportLogs.cs | 27 ++++ Migrations/AppDbContextModelSnapshot.cs | 118 +++++++++++++++ Models/ImportAuditIssue.cs | 25 +++ Models/ImportAuditRun.cs | 25 +++ Program.cs | 1 + Services/AuditLogBuilder.cs | 18 +++ Services/GeralDashboardInsightsService.cs | 35 +++++ Services/ParcelamentosImportService.cs | 2 +- Services/SpreadsheetImportAuditService.cs | 142 ++++++++++++++++++ Services/TenantMiddleware.cs | 59 +++++++- .../GeralDashboardInsightsServiceTests.cs | 10 +- 31 files changed, 1052 insertions(+), 30 deletions(-) create mode 100644 Controllers/ImportAuditController.cs create mode 100644 Dtos/ImportAuditDtos.cs create mode 100644 Migrations/20260213120000_AddImportAuditTables.Designer.cs create mode 100644 Migrations/20260213120000_AddImportAuditTables.cs create mode 100644 Migrations/20260213130000_RemoveDetailedImportLogs.Designer.cs create mode 100644 Migrations/20260213130000_RemoveDetailedImportLogs.cs create mode 100644 Models/ImportAuditIssue.cs create mode 100644 Models/ImportAuditRun.cs create mode 100644 Services/SpreadsheetImportAuditService.cs diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs index c5b3269..b4a8008 100644 --- a/Controllers/AuthController.cs +++ b/Controllers/AuthController.cs @@ -1,6 +1,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; +using line_gestao_api.Data; using line_gestao_api.Dtos; using line_gestao_api.Models; using line_gestao_api.Services; @@ -17,15 +18,18 @@ namespace line_gestao_api.Controllers; public class AuthController : ControllerBase { private readonly UserManager _userManager; + private readonly AppDbContext _db; private readonly ITenantProvider _tenantProvider; private readonly IConfiguration _config; public AuthController( UserManager userManager, + AppDbContext db, ITenantProvider tenantProvider, IConfiguration config) { _userManager = userManager; + _db = db; _tenantProvider = tenantProvider; _config = config; } @@ -38,7 +42,7 @@ public class AuthController : ControllerBase return BadRequest("As senhas não conferem."); var tenantId = _tenantProvider.TenantId; - if (tenantId == null) + if (tenantId == null || tenantId == Guid.Empty) return Unauthorized("Tenant inválido."); var email = req.Email.Trim().ToLowerInvariant(); @@ -65,7 +69,11 @@ public class AuthController : ControllerBase await _userManager.AddToRoleAsync(user, "leitura"); - var token = await GenerateJwtAsync(user); + var effectiveTenantId = await EnsureValidTenantIdAsync(user); + if (!effectiveTenantId.HasValue) + return Unauthorized("Tenant inválido."); + + var token = await GenerateJwtAsync(user, effectiveTenantId.Value); return Ok(new AuthResponse(token)); } @@ -111,7 +119,11 @@ public class AuthController : ControllerBase if (!valid) return Unauthorized("Credenciais inválidas."); - var token = await GenerateJwtAsync(user); + var effectiveTenantId = await EnsureValidTenantIdAsync(user); + if (!effectiveTenantId.HasValue) + return Unauthorized("Tenant inválido."); + + var token = await GenerateJwtAsync(user, effectiveTenantId.Value); return Ok(new AuthResponse(token)); } @@ -124,7 +136,7 @@ public class AuthController : ControllerBase return Guid.TryParse(headerValue, out var parsed) ? parsed : null; } - private async Task GenerateJwtAsync(ApplicationUser user) + private async Task GenerateJwtAsync(ApplicationUser user, Guid tenantId) { var key = _config["Jwt:Key"]!; var issuer = _config["Jwt:Issuer"]!; @@ -138,7 +150,7 @@ public class AuthController : ControllerBase new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty), new("name", user.Name), - new("tenantId", user.TenantId.ToString()) + new("tenantId", tenantId.ToString()) }; claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); @@ -156,4 +168,26 @@ public class AuthController : ControllerBase return new JwtSecurityTokenHandler().WriteToken(token); } + + private async Task EnsureValidTenantIdAsync(ApplicationUser user) + { + if (user.TenantId != Guid.Empty) + return user.TenantId; + + var fallbackTenantId = await _db.Tenants + .AsNoTracking() + .OrderBy(t => t.CreatedAt) + .Select(t => (Guid?)t.Id) + .FirstOrDefaultAsync(); + + if (!fallbackTenantId.HasValue || fallbackTenantId.Value == Guid.Empty) + return null; + + user.TenantId = fallbackTenantId.Value; + var updateResult = await _userManager.UpdateAsync(user); + if (!updateResult.Succeeded) + return null; + + return user.TenantId; + } } diff --git a/Controllers/BillingController.cs b/Controllers/BillingController.cs index 3067afa..44e438d 100644 --- a/Controllers/BillingController.cs +++ b/Controllers/BillingController.cs @@ -8,6 +8,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/[controller]")] + [Authorize] public class BillingController : ControllerBase { private readonly AppDbContext _db; diff --git a/Controllers/ChipsVirgensController.cs b/Controllers/ChipsVirgensController.cs index 572a917..6c91eac 100644 --- a/Controllers/ChipsVirgensController.cs +++ b/Controllers/ChipsVirgensController.cs @@ -10,6 +10,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/chips-virgens")] + [Authorize] public class ChipsVirgensController : ControllerBase { private readonly AppDbContext _db; diff --git a/Controllers/ControleRecebidosController.cs b/Controllers/ControleRecebidosController.cs index 0d72fc4..35877ca 100644 --- a/Controllers/ControleRecebidosController.cs +++ b/Controllers/ControleRecebidosController.cs @@ -10,6 +10,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/controle-recebidos")] + [Authorize] public class ControleRecebidosController : ControllerBase { private readonly AppDbContext _db; diff --git a/Controllers/DashboardGeralController.cs b/Controllers/DashboardGeralController.cs index 6c70287..52987c8 100644 --- a/Controllers/DashboardGeralController.cs +++ b/Controllers/DashboardGeralController.cs @@ -1,11 +1,13 @@ using line_gestao_api.Dtos; using line_gestao_api.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace line_gestao_api.Controllers { [ApiController] [Route("api/dashboard/geral")] + [Authorize] public class DashboardGeralController : ControllerBase { private readonly GeralDashboardInsightsService _service; diff --git a/Controllers/HistoricoController.cs b/Controllers/HistoricoController.cs index 9540da6..64ed2ca 100644 --- a/Controllers/HistoricoController.cs +++ b/Controllers/HistoricoController.cs @@ -1,6 +1,7 @@ using System.Text.Json; 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; @@ -34,7 +35,11 @@ public class HistoricoController : ControllerBase page = page < 1 ? 1 : page; pageSize = pageSize < 1 ? 20 : pageSize; - var q = _db.AuditLogs.AsNoTracking(); + var q = _db.AuditLogs + .AsNoTracking() + .Where(x => + !EF.Functions.ILike(x.RequestPath ?? "", "%import-excel%") || + x.Page == AuditLogBuilder.SpreadsheetImportPageName); if (!string.IsNullOrWhiteSpace(pageName)) { diff --git a/Controllers/ImportAuditController.cs b/Controllers/ImportAuditController.cs new file mode 100644 index 0000000..44fefa4 --- /dev/null +++ b/Controllers/ImportAuditController.cs @@ -0,0 +1,86 @@ +using line_gestao_api.Data; +using line_gestao_api.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Controllers; + +[ApiController] +[Route("api/import-audit")] +[Authorize] +public class ImportAuditController : ControllerBase +{ + private readonly AppDbContext _db; + + public ImportAuditController(AppDbContext db) + { + _db = db; + } + + [HttpGet("latest")] + public async Task> GetLatest() + { + var run = await _db.ImportAuditRuns + .AsNoTracking() + .Include(x => x.Issues) + .OrderByDescending(x => x.ImportedAt) + .ThenByDescending(x => x.Id) + .FirstOrDefaultAsync(); + + if (run == null) + { + return NotFound(); + } + + return Ok(ToDto(run)); + } + + [HttpGet("{id:guid}")] + public async Task> GetById(Guid id) + { + var run = await _db.ImportAuditRuns + .AsNoTracking() + .Include(x => x.Issues) + .FirstOrDefaultAsync(x => x.Id == id); + + if (run == null) + { + return NotFound(); + } + + return Ok(ToDto(run)); + } + + private static ImportAuditRunDto ToDto(Models.ImportAuditRun run) + { + var issues = run.Issues + .OrderBy(x => x.CreatedAt) + .ThenBy(x => x.Entity) + .Select(x => new ImportAuditIssueDto + { + Id = x.Id, + Entity = x.Entity, + FieldName = x.FieldName, + SourceValue = x.SourceValue, + CanonicalValue = x.CanonicalValue, + Resolution = x.Resolution, + Severity = x.Severity, + CreatedAt = x.CreatedAt + }) + .ToList(); + + return new ImportAuditRunDto + { + Id = run.Id, + ImportedAt = run.ImportedAt, + FileName = run.FileName, + Status = run.Status, + CanonicalTotalLinhas = run.CanonicalTotalLinhas, + SourceMaxItemGeral = run.SourceMaxItemGeral, + SourceValidCountGeral = run.SourceValidCountGeral, + IssueCount = issues.Count, + Issues = issues + }; + } +} diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index b181a93..580252a 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -7,22 +7,26 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Security.Claims; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; +using System.Text.Json; using System.Threading.Tasks; namespace line_gestao_api.Controllers { [ApiController] [Route("api/[controller]")] - //[Authorize] + [Authorize] public class LinesController : ControllerBase { private readonly AppDbContext _db; + private readonly ITenantProvider _tenantProvider; private readonly ParcelamentosImportService _parcelamentosImportService; + private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService; private static readonly List AccountCompanies = new() { new AccountCompanyDto @@ -47,10 +51,16 @@ namespace line_gestao_api.Controllers } }; - public LinesController(AppDbContext db, ParcelamentosImportService parcelamentosImportService) + public LinesController( + AppDbContext db, + ITenantProvider tenantProvider, + ParcelamentosImportService parcelamentosImportService, + SpreadsheetImportAuditService spreadsheetImportAuditService) { _db = db; + _tenantProvider = tenantProvider; _parcelamentosImportService = parcelamentosImportService; + _spreadsheetImportAuditService = spreadsheetImportAuditService; } public class ImportExcelForm @@ -581,6 +591,7 @@ namespace line_gestao_api.Controllers // ✅ 7. DELETE // ========================================================== [HttpDelete("{id:guid}")] + [Authorize(Roles = "admin")] public async Task Delete(Guid id) { var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); @@ -599,10 +610,15 @@ namespace line_gestao_api.Controllers [RequestSizeLimit(50_000_000)] public async Task> ImportExcel([FromForm] ImportExcelForm form) { + var tenantId = _tenantProvider.TenantId; + if (!tenantId.HasValue || tenantId.Value == Guid.Empty) + return Unauthorized("Tenant inválido."); + var file = form.File; if (file == null || file.Length == 0) return BadRequest("Arquivo inválido."); await using var tx = await _db.Database.BeginTransactionAsync(); + SpreadsheetImportAuditSession? auditSession = null; try { @@ -632,6 +648,7 @@ namespace line_gestao_api.Controllers var buffer = new List(600); var imported = 0; + var maxItemFromGeral = 0; var lastRow = ws.LastRowUsed()?.RowNumber() ?? startRow; @@ -639,6 +656,7 @@ namespace line_gestao_api.Controllers { var itemStr = GetCellString(ws, r, colItem); if (string.IsNullOrWhiteSpace(itemStr)) break; + var item = TryInt(itemStr); var linhaDigits = OnlyDigits(GetCellByHeader(ws, r, map, "LINHA")); var chipDigits = OnlyDigits(GetCellByHeader(ws, r, map, "CHIP")); @@ -652,7 +670,7 @@ namespace line_gestao_api.Controllers var e = new MobileLine { Id = Guid.NewGuid(), - Item = TryInt(itemStr), + Item = item, Conta = GetCellByHeader(ws, r, map, "CONTA"), Linha = linhaVal, Chip = chipVal, @@ -693,6 +711,7 @@ namespace line_gestao_api.Controllers buffer.Add(e); imported++; + if (item > maxItemFromGeral) maxItemFromGeral = item; if (buffer.Count >= 500) { @@ -708,6 +727,11 @@ namespace line_gestao_api.Controllers await _db.SaveChangesAsync(); } + auditSession = _spreadsheetImportAuditService.StartRun( + file.FileName, + maxItemFromGeral, + imported); + // ========================= // ✅ IMPORTA MUREG (ALTERADO: NÃO ESTOURA ERRO SE LINHANOVA JÁ EXISTIR) // ========================= @@ -746,13 +770,23 @@ namespace line_gestao_api.Controllers // ========================= // ✅ IMPORTA RESUMO // ========================= - await ImportResumoFromWorkbook(wb); + if (auditSession == null) + { + throw new InvalidOperationException("Sessão de auditoria não iniciada."); + } + + await ImportResumoFromWorkbook(wb, auditSession); // ========================= // ✅ IMPORTA PARCELAMENTOS // ========================= var parcelamentosSummary = await _parcelamentosImportService.ImportFromWorkbookAsync(wb, replaceAll: true); + if (auditSession != null) + { + await _spreadsheetImportAuditService.SaveRunAsync(auditSession); + } + await AddSpreadsheetImportHistoryAsync(file.FileName, imported, parcelamentosSummary); await tx.CommitAsync(); return Ok(new ImportResultDto { Imported = imported, Parcelamentos = parcelamentosSummary }); } @@ -763,6 +797,57 @@ namespace line_gestao_api.Controllers } } + private async Task AddSpreadsheetImportHistoryAsync( + string? fileName, + int imported, + ParcelamentosImportSummaryDto? parcelamentosSummary) + { + var tenantId = _tenantProvider.TenantId; + if (!tenantId.HasValue) + { + return; + } + + var claimNameId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var userId = Guid.TryParse(claimNameId, out var parsedUserId) ? parsedUserId : (Guid?)null; + + var userName = User.FindFirst("name")?.Value + ?? User.FindFirst(ClaimTypes.Name)?.Value + ?? User.Identity?.Name; + + var userEmail = User.FindFirst(ClaimTypes.Email)?.Value + ?? User.FindFirst("email")?.Value; + + var changes = new List + { + new() { Field = "Arquivo", ChangeType = "imported", NewValue = string.IsNullOrWhiteSpace(fileName) ? "-" : fileName }, + new() { Field = "LinhasImportadasGeral", ChangeType = "imported", NewValue = imported.ToString(CultureInfo.InvariantCulture) }, + new() { Field = "ParcelamentosLidos", ChangeType = "imported", NewValue = (parcelamentosSummary?.Lidos ?? 0).ToString(CultureInfo.InvariantCulture) }, + new() { Field = "ParcelamentosInseridos", ChangeType = "imported", NewValue = (parcelamentosSummary?.Inseridos ?? 0).ToString(CultureInfo.InvariantCulture) }, + new() { Field = "ParcelamentosAtualizados", ChangeType = "imported", NewValue = (parcelamentosSummary?.Atualizados ?? 0).ToString(CultureInfo.InvariantCulture) } + }; + + _db.AuditLogs.Add(new AuditLog + { + TenantId = tenantId.Value, + OccurredAtUtc = DateTime.UtcNow, + UserId = userId, + UserName = string.IsNullOrWhiteSpace(userName) ? "USUARIO" : userName, + UserEmail = userEmail, + Action = "IMPORT", + Page = AuditLogBuilder.SpreadsheetImportPageName, + EntityName = "SpreadsheetImport", + EntityId = null, + EntityLabel = "Importação Excel", + ChangesJson = JsonSerializer.Serialize(changes), + RequestPath = HttpContext.Request.Path.Value, + RequestMethod = HttpContext.Request.Method, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() + }); + + await _db.SaveChangesAsync(); + } + // ========================================================== // ✅ IMPORTAÇÃO DA ABA MUREG // ✅ NOVA REGRA: @@ -1620,7 +1705,7 @@ namespace line_gestao_api.Controllers // ========================================================== // ✅ IMPORTAÇÃO DA ABA RESUMO // ========================================================== - private async Task ImportResumoFromWorkbook(XLWorkbook wb) + private async Task ImportResumoFromWorkbook(XLWorkbook wb, SpreadsheetImportAuditSession auditSession) { var ws = wb.Worksheets.FirstOrDefault(w => NormalizeHeader(w.Name) == NormalizeHeader("RESUMO")); if (ws == null) return; @@ -1638,15 +1723,15 @@ namespace line_gestao_api.Controllers var now = DateTime.UtcNow; - await ImportResumoTabela1(ws, now); - await ImportResumoTabela2(ws, now); + await ImportResumoTabela1(ws, now, auditSession); + await ImportResumoTabela2(ws, now, auditSession); await ImportResumoTabela3(ws, now); await ImportResumoTabela4(ws, now); await ImportResumoTabela5(ws, now); await ImportResumoTabela6(ws, now); } - private async Task ImportResumoTabela1(IXLWorksheet ws, DateTime now) + private async Task ImportResumoTabela1(IXLWorksheet ws, DateTime now, SpreadsheetImportAuditSession auditSession) { var lastRowUsed = ws.LastRowUsed()?.RowNumber() ?? 1; var headerRow = FindHeaderRowForMacrophonyPlans(ws, 1, lastRowUsed); @@ -1785,10 +1870,16 @@ namespace line_gestao_api.Controllers return; } + var totalLinhasSource = TryNullableInt(GetCellString(ws, totalRowIndex.Value, colTotalLinhas)); var total = new ResumoMacrophonyTotal { FranquiaGbTotal = TryDecimal(GetCellString(ws, totalRowIndex.Value, colFranquiaGb)), - TotalLinhasTotal = TryNullableInt(GetCellString(ws, totalRowIndex.Value, colTotalLinhas)), + TotalLinhasTotal = _spreadsheetImportAuditService.CanonicalizeTotalLinhas( + auditSession, + "RESUMO.MACROPHONY_TOTAL", + "TotalLinhasTotal", + totalLinhasSource, + "HIGH"), ValorTotal = TryDecimal(GetCellString(ws, totalRowIndex.Value, colValorTotal)), CreatedAt = now, UpdatedAt = now @@ -1819,7 +1910,7 @@ namespace line_gestao_api.Controllers return 0; } - private async Task ImportResumoTabela2(IXLWorksheet ws, DateTime now) + private async Task ImportResumoTabela2(IXLWorksheet ws, DateTime now, SpreadsheetImportAuditSession auditSession) { const int headerRow = 5; const int totalRow = 219; @@ -1881,9 +1972,15 @@ namespace line_gestao_api.Controllers await _db.SaveChangesAsync(); } + var qtdLinhasTotalSource = TryNullableInt(GetCellString(ws, totalRow, colQtdLinhas)); var total = new ResumoVivoLineTotal { - QtdLinhasTotal = TryNullableInt(GetCellString(ws, totalRow, colQtdLinhas)), + QtdLinhasTotal = _spreadsheetImportAuditService.CanonicalizeTotalLinhas( + auditSession, + "RESUMO.VIVO_LINE_TOTAL", + "QtdLinhasTotal", + qtdLinhasTotalSource, + "HIGH"), FranquiaTotal = TryDecimal(GetCellString(ws, totalRow, colFranquiaTotal)), ValorContratoVivo = TryDecimal(GetCellString(ws, totalRow, colValorContratoVivo)), FranquiaLine = TryDecimal(GetCellString(ws, totalRow, colFranquiaLine)), diff --git a/Controllers/MuregController.cs b/Controllers/MuregController.cs index 937464f..41bed3a 100644 --- a/Controllers/MuregController.cs +++ b/Controllers/MuregController.cs @@ -1,6 +1,7 @@ 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; @@ -8,6 +9,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/mureg")] + [Authorize] public class MuregController : ControllerBase { private readonly AppDbContext _db; @@ -345,6 +347,7 @@ namespace line_gestao_api.Controllers // Exclui registro MUREG // ========================================================== [HttpDelete("{id:guid}")] + [Authorize(Roles = "admin")] public async Task Delete(Guid id) { var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == id); diff --git a/Controllers/RelatoriosController.cs b/Controllers/RelatoriosController.cs index 5e56512..b849aaf 100644 --- a/Controllers/RelatoriosController.cs +++ b/Controllers/RelatoriosController.cs @@ -1,5 +1,6 @@ using line_gestao_api.Data; using line_gestao_api.Dtos; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -7,6 +8,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/relatorios")] + [Authorize] public class RelatoriosController : ControllerBase { private readonly AppDbContext _db; diff --git a/Controllers/ResumoController.cs b/Controllers/ResumoController.cs index 670ef8c..415e70d 100644 --- a/Controllers/ResumoController.cs +++ b/Controllers/ResumoController.cs @@ -1,5 +1,6 @@ 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; @@ -12,10 +13,12 @@ namespace line_gestao_api.Controllers; public class ResumoController : ControllerBase { private readonly AppDbContext _db; + private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService; - public ResumoController(AppDbContext db) + public ResumoController(AppDbContext db, SpreadsheetImportAuditService spreadsheetImportAuditService) { _db = db; + _spreadsheetImportAuditService = spreadsheetImportAuditService; } [HttpGet] @@ -46,6 +49,7 @@ public class ResumoController : ControllerBase var reservaTotalEntity = await _db.ResumoReservaTotals.AsNoTracking() .FirstOrDefaultAsync(); + var canonicalTotalLinhas = await _spreadsheetImportAuditService.GetCanonicalTotalLinhasForReadAsync(); var response = new ResumoResponseDto { @@ -66,7 +70,7 @@ public class ResumoController : ControllerBase .Select(x => new ResumoMacrophonyTotalDto { FranquiaGbTotal = x.FranquiaGbTotal, - TotalLinhasTotal = x.TotalLinhasTotal, + TotalLinhasTotal = canonicalTotalLinhas, ValorTotal = x.ValorTotal }) .FirstOrDefaultAsync(), @@ -87,7 +91,7 @@ public class ResumoController : ControllerBase VivoLineTotals = await _db.ResumoVivoLineTotals.AsNoTracking() .Select(x => new ResumoVivoLineTotalDto { - QtdLinhasTotal = x.QtdLinhasTotal, + QtdLinhasTotal = canonicalTotalLinhas, FranquiaTotal = x.FranquiaTotal, ValorContratoVivo = x.ValorContratoVivo, FranquiaLine = x.FranquiaLine, @@ -151,6 +155,12 @@ public class ResumoController : ControllerBase } }; + response.MacrophonyTotals ??= new ResumoMacrophonyTotalDto(); + response.MacrophonyTotals.TotalLinhasTotal = canonicalTotalLinhas; + + response.VivoLineTotals ??= new ResumoVivoLineTotalDto(); + response.VivoLineTotals.QtdLinhasTotal = canonicalTotalLinhas; + return Ok(response); } } diff --git a/Controllers/TrocaNumeroController.cs b/Controllers/TrocaNumeroController.cs index 7edbf7c..09f3ef5 100644 --- a/Controllers/TrocaNumeroController.cs +++ b/Controllers/TrocaNumeroController.cs @@ -1,6 +1,7 @@ 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; using System.Globalization; @@ -10,6 +11,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/[controller]")] + [Authorize] public class TrocaNumeroController : ControllerBase { private readonly AppDbContext _db; @@ -156,6 +158,7 @@ namespace line_gestao_api.Controllers // ✅ DELETE // ========================================================== [HttpDelete("{id:guid}")] + [Authorize(Roles = "admin")] public async Task Delete(Guid id) { var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Controllers/UserDataController.cs b/Controllers/UserDataController.cs index e23bac3..29187b1 100644 --- a/Controllers/UserDataController.cs +++ b/Controllers/UserDataController.cs @@ -10,6 +10,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/user-data")] + [Authorize] public class UserDataController : ControllerBase { private readonly AppDbContext _db; diff --git a/Controllers/VigenciaController.cs b/Controllers/VigenciaController.cs index 1b391c4..6aac67f 100644 --- a/Controllers/VigenciaController.cs +++ b/Controllers/VigenciaController.cs @@ -11,6 +11,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/lines/vigencia")] + [Authorize] public class VigenciaController : ControllerBase { private readonly AppDbContext _db; diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index c88d60a..5f9a698 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -68,6 +68,10 @@ public class AppDbContext : IdentityDbContext AuditLogs => Set(); + // ✅ tabelas de auditoria de importação + public DbSet ImportAuditRuns => Set(); + public DbSet ImportAuditIssues => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -283,6 +287,35 @@ public class AppDbContext : IdentityDbContext x.EntityName); }); + modelBuilder.Entity(e => + { + e.Property(x => x.FileName).HasMaxLength(260); + e.Property(x => x.Status).HasMaxLength(40); + + e.HasIndex(x => x.TenantId); + e.HasIndex(x => x.ImportedAt); + e.HasIndex(x => x.Status); + }); + + modelBuilder.Entity(e => + { + e.Property(x => x.Entity).HasMaxLength(120); + e.Property(x => x.FieldName).HasMaxLength(120); + e.Property(x => x.SourceValue).HasMaxLength(120); + e.Property(x => x.CanonicalValue).HasMaxLength(120); + e.Property(x => x.Resolution).HasMaxLength(80); + e.Property(x => x.Severity).HasMaxLength(40); + + e.HasIndex(x => x.TenantId); + e.HasIndex(x => x.AuditRunId); + e.HasIndex(x => x.Entity); + + e.HasOne(x => x.AuditRun) + .WithMany(x => x.Issues) + .HasForeignKey(x => x.AuditRunId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); @@ -305,6 +338,8 @@ public class AppDbContext : IdentityDbContext().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); } diff --git a/Data/SeedData.cs b/Data/SeedData.cs index fe8108a..cf97836 100644 --- a/Data/SeedData.cs +++ b/Data/SeedData.cs @@ -52,6 +52,8 @@ public static class SeedData await db.SaveChangesAsync(); } + await NormalizeLegacyTenantDataAsync(db, tenant.Id); + tenantProvider.SetTenantId(tenant.Id); var normalizedEmail = userManager.NormalizeEmail(options.AdminEmail); @@ -79,4 +81,135 @@ public static class SeedData tenantProvider.SetTenantId(null); } + + private static async Task NormalizeLegacyTenantDataAsync(AppDbContext db, Guid defaultTenantId) + { + if (defaultTenantId == Guid.Empty) + return; + + 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)); + } } diff --git a/Dtos/ImportAuditDtos.cs b/Dtos/ImportAuditDtos.cs new file mode 100644 index 0000000..b4e5b3d --- /dev/null +++ b/Dtos/ImportAuditDtos.cs @@ -0,0 +1,26 @@ +namespace line_gestao_api.Dtos; + +public sealed class ImportAuditRunDto +{ + public Guid Id { get; set; } + public DateTime ImportedAt { get; set; } + public string? FileName { get; set; } + public string Status { get; set; } = string.Empty; + public int CanonicalTotalLinhas { get; set; } + public int SourceMaxItemGeral { get; set; } + public int SourceValidCountGeral { get; set; } + public int IssueCount { get; set; } + public List Issues { get; set; } = new(); +} + +public sealed class ImportAuditIssueDto +{ + public Guid Id { get; set; } + public string Entity { get; set; } = string.Empty; + public string FieldName { get; set; } = string.Empty; + public string? SourceValue { get; set; } + public string CanonicalValue { get; set; } = string.Empty; + public string Resolution { get; set; } = string.Empty; + public string Severity { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} diff --git a/Migrations/20260213120000_AddImportAuditTables.Designer.cs b/Migrations/20260213120000_AddImportAuditTables.Designer.cs new file mode 100644 index 0000000..06c41ff --- /dev/null +++ b/Migrations/20260213120000_AddImportAuditTables.Designer.cs @@ -0,0 +1,19 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260213120000_AddImportAuditTables")] + partial class AddImportAuditTables + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + } + } +} diff --git a/Migrations/20260213120000_AddImportAuditTables.cs b/Migrations/20260213120000_AddImportAuditTables.cs new file mode 100644 index 0000000..d518516 --- /dev/null +++ b/Migrations/20260213120000_AddImportAuditTables.cs @@ -0,0 +1,101 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + /// + public partial class AddImportAuditTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ImportAuditRuns", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + ImportedAt = table.Column(type: "timestamp with time zone", nullable: false), + FileName = table.Column(type: "character varying(260)", maxLength: 260, nullable: true), + Status = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + CanonicalTotalLinhas = table.Column(type: "integer", nullable: false), + SourceMaxItemGeral = table.Column(type: "integer", nullable: false), + SourceValidCountGeral = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ImportAuditRuns", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ImportAuditIssues", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + AuditRunId = table.Column(type: "uuid", nullable: false), + Entity = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + FieldName = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + SourceValue = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + CanonicalValue = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Resolution = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + Severity = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ImportAuditIssues", x => x.Id); + table.ForeignKey( + name: "FK_ImportAuditIssues_ImportAuditRuns_AuditRunId", + column: x => x.AuditRunId, + principalTable: "ImportAuditRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ImportAuditIssues_AuditRunId", + table: "ImportAuditIssues", + column: "AuditRunId"); + + migrationBuilder.CreateIndex( + name: "IX_ImportAuditIssues_Entity", + table: "ImportAuditIssues", + column: "Entity"); + + migrationBuilder.CreateIndex( + name: "IX_ImportAuditIssues_TenantId", + table: "ImportAuditIssues", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_ImportAuditRuns_ImportedAt", + table: "ImportAuditRuns", + column: "ImportedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ImportAuditRuns_Status", + table: "ImportAuditRuns", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_ImportAuditRuns_TenantId", + table: "ImportAuditRuns", + column: "TenantId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ImportAuditIssues"); + + migrationBuilder.DropTable( + name: "ImportAuditRuns"); + } + } +} diff --git a/Migrations/20260213130000_RemoveDetailedImportLogs.Designer.cs b/Migrations/20260213130000_RemoveDetailedImportLogs.Designer.cs new file mode 100644 index 0000000..423669c --- /dev/null +++ b/Migrations/20260213130000_RemoveDetailedImportLogs.Designer.cs @@ -0,0 +1,19 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260213130000_RemoveDetailedImportLogs")] + partial class RemoveDetailedImportLogs + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + } + } +} diff --git a/Migrations/20260213130000_RemoveDetailedImportLogs.cs b/Migrations/20260213130000_RemoveDetailedImportLogs.cs new file mode 100644 index 0000000..aa0722d --- /dev/null +++ b/Migrations/20260213130000_RemoveDetailedImportLogs.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + /// + public partial class RemoveDetailedImportLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DELETE FROM "AuditLogs" + WHERE "RequestPath" ILIKE '%import-excel%' + AND COALESCE("Page", '') <> 'Importação de Planilha'; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Nao ha restauracao segura para os logs removidos. + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index 4aed7b2..8d21a62 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -497,6 +497,108 @@ namespace line_gestao_api.Migrations b.ToTable("ControleRecebidoLines"); }); + modelBuilder.Entity("line_gestao_api.Models.ImportAuditIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditRunId") + .HasColumnType("uuid"); + + b.Property("CanonicalValue") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Entity") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Resolution") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SourceValue") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuditRunId"); + + b.HasIndex("Entity"); + + b.HasIndex("TenantId"); + + b.ToTable("ImportAuditIssues"); + }); + + modelBuilder.Entity("line_gestao_api.Models.ImportAuditRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanonicalTotalLinhas") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .HasMaxLength(260) + .HasColumnType("character varying(260)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceMaxItemGeral") + .HasColumnType("integer"); + + b.Property("SourceValidCountGeral") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportedAt"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.ToTable("ImportAuditRuns"); + }); + modelBuilder.Entity("line_gestao_api.Models.MobileLine", b => { b.Property("Id") @@ -1466,6 +1568,17 @@ namespace line_gestao_api.Migrations b.Navigation("MobileLine"); }); + modelBuilder.Entity("line_gestao_api.Models.ImportAuditIssue", b => + { + b.HasOne("line_gestao_api.Models.ImportAuditRun", "AuditRun") + .WithMany("Issues") + .HasForeignKey("AuditRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AuditRun"); + }); + modelBuilder.Entity("line_gestao_api.Models.Notification", b => { b.HasOne("line_gestao_api.Models.ApplicationUser", "User") @@ -1499,6 +1612,11 @@ namespace line_gestao_api.Migrations b.Navigation("Muregs"); }); + modelBuilder.Entity("line_gestao_api.Models.ImportAuditRun", b => + { + b.Navigation("Issues"); + }); + modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b => { b.Navigation("MonthValues"); diff --git a/Models/ImportAuditIssue.cs b/Models/ImportAuditIssue.cs new file mode 100644 index 0000000..75371af --- /dev/null +++ b/Models/ImportAuditIssue.cs @@ -0,0 +1,25 @@ +namespace line_gestao_api.Models; + +public class ImportAuditIssue : ITenantEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TenantId { get; set; } + + public Guid AuditRunId { get; set; } + public ImportAuditRun AuditRun { get; set; } = default!; + + public string Entity { get; set; } = string.Empty; + + public string FieldName { get; set; } = string.Empty; + + public string? SourceValue { get; set; } + + public string CanonicalValue { get; set; } = string.Empty; + + public string Resolution { get; set; } = "OVERRIDDEN_BY_GERAL"; + + public string Severity { get; set; } = "WARNING"; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Models/ImportAuditRun.cs b/Models/ImportAuditRun.cs new file mode 100644 index 0000000..d18ee38 --- /dev/null +++ b/Models/ImportAuditRun.cs @@ -0,0 +1,25 @@ +namespace line_gestao_api.Models; + +public class ImportAuditRun : ITenantEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TenantId { get; set; } + + public DateTime ImportedAt { get; set; } = DateTime.UtcNow; + + public string? FileName { get; set; } + + public string Status { get; set; } = "SUCCESS"; + + public int CanonicalTotalLinhas { get; set; } + + public int SourceMaxItemGeral { get; set; } + + public int SourceValidCountGeral { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection Issues { get; set; } = new List(); +} diff --git a/Program.cs b/Program.cs index 4a18b3d..6ce9b5a 100644 --- a/Program.cs +++ b/Program.cs @@ -38,6 +38,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddIdentityCore(options => { diff --git a/Services/AuditLogBuilder.cs b/Services/AuditLogBuilder.cs index 13dc141..3cfac9a 100644 --- a/Services/AuditLogBuilder.cs +++ b/Services/AuditLogBuilder.cs @@ -16,6 +16,8 @@ namespace line_gestao_api.Services; public class AuditLogBuilder : IAuditLogBuilder { + public const string SpreadsheetImportPageName = "Importação de Planilha"; + private static readonly Dictionary PageByEntity = new(StringComparer.OrdinalIgnoreCase) { { nameof(MobileLine), "Geral" }, @@ -80,6 +82,12 @@ public class AuditLogBuilder : IAuditLogBuilder var requestMethod = request?.Method; var ipAddress = httpContext?.Connection?.RemoteIpAddress?.ToString(); + if (IsSpreadsheetImportRequest(requestPath)) + { + // Importacoes de planilha nao geram historico detalhado por entidade. + return new List(); + } + var logs = new List(); foreach (var entry in changeTracker.Entries()) @@ -120,6 +128,16 @@ public class AuditLogBuilder : IAuditLogBuilder return logs; } + private static bool IsSpreadsheetImportRequest(string? requestPath) + { + if (string.IsNullOrWhiteSpace(requestPath)) + { + return false; + } + + return requestPath.Contains("/import-excel", StringComparison.OrdinalIgnoreCase); + } + private static string ResolveAction(EntityState state) => state switch { diff --git a/Services/GeralDashboardInsightsService.cs b/Services/GeralDashboardInsightsService.cs index 5ccfbe7..9e29ed0 100644 --- a/Services/GeralDashboardInsightsService.cs +++ b/Services/GeralDashboardInsightsService.cs @@ -523,9 +523,44 @@ namespace line_gestao_api.Services ClientGroups = BuildClientGroups(clientGroupsRaw) }; + dto.Kpis.TotalLinhas = await ResolveCanonicalTotalLinhasAsync(dto.Kpis.TotalLinhas); + return dto; } + private async Task ResolveCanonicalTotalLinhasAsync(int fallback) + { + try + { + var fromLatestRun = await _db.ImportAuditRuns + .AsNoTracking() + .OrderByDescending(x => x.ImportedAt) + .ThenByDescending(x => x.Id) + .Select(x => (int?)x.CanonicalTotalLinhas) + .FirstOrDefaultAsync(); + + if (fromLatestRun.HasValue && fromLatestRun.Value > 0) + { + return fromLatestRun.Value; + } + } + catch + { + // Fallback para ambientes em que a migration ainda não foi aplicada. + } + + var fromMaxItem = await _db.MobileLines + .AsNoTracking() + .MaxAsync(x => (int?)x.Item); + + if (fromMaxItem.HasValue && fromMaxItem.Value > 0) + { + return fromMaxItem.Value; + } + + return fallback; + } + private static GeralDashboardKpisDto BuildKpis(TotalsProjection? totals) { if (totals == null) diff --git a/Services/ParcelamentosImportService.cs b/Services/ParcelamentosImportService.cs index cb5c0b7..d5b3cc8 100644 --- a/Services/ParcelamentosImportService.cs +++ b/Services/ParcelamentosImportService.cs @@ -275,7 +275,7 @@ public sealed class ParcelamentosImportService var block1 = ExtractMonthBlock(monthHeaders, firstJanIdx); var block2 = block1.NextIndex < monthHeaders.Count ? ExtractMonthBlock(monthHeaders, block1.NextIndex) - : (new List<(int Col, int Month)>(), block1.NextIndex); + : (Block: new List<(int Col, int Month)>(), NextIndex: block1.NextIndex); var yearRow = headerRow - 1; var year1 = block1.Block.Count > 0 diff --git a/Services/SpreadsheetImportAuditService.cs b/Services/SpreadsheetImportAuditService.cs new file mode 100644 index 0000000..9d2f2ef --- /dev/null +++ b/Services/SpreadsheetImportAuditService.cs @@ -0,0 +1,142 @@ +using System.Globalization; +using line_gestao_api.Data; +using line_gestao_api.Models; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Services; + +public sealed class SpreadsheetImportAuditService +{ + private const string DefaultResolution = "OVERRIDDEN_BY_GERAL"; + + private readonly AppDbContext _db; + + public SpreadsheetImportAuditService(AppDbContext db) + { + _db = db; + } + + public SpreadsheetImportAuditSession StartRun(string? fileName, int sourceMaxItemGeral, int sourceValidCountGeral) + { + var canonicalTotalLinhas = sourceMaxItemGeral > 0 ? sourceMaxItemGeral : sourceValidCountGeral; + + var run = new ImportAuditRun + { + Id = Guid.NewGuid(), + ImportedAt = DateTime.UtcNow, + FileName = string.IsNullOrWhiteSpace(fileName) ? null : fileName.Trim(), + Status = "RUNNING", + CanonicalTotalLinhas = canonicalTotalLinhas, + SourceMaxItemGeral = sourceMaxItemGeral, + SourceValidCountGeral = sourceValidCountGeral, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + return new SpreadsheetImportAuditSession(run); + } + + public int CanonicalizeTotalLinhas( + SpreadsheetImportAuditSession session, + string entity, + string fieldName, + int? sourceValue, + string severity = "WARNING") + { + ArgumentNullException.ThrowIfNull(session); + + var canonicalValue = session.Run.CanonicalTotalLinhas; + + if (sourceValue != canonicalValue) + { + session.Issues.Add(new ImportAuditIssue + { + Id = Guid.NewGuid(), + AuditRunId = session.Run.Id, + Entity = entity, + FieldName = fieldName, + SourceValue = sourceValue.HasValue + ? sourceValue.Value.ToString(CultureInfo.InvariantCulture) + : null, + CanonicalValue = canonicalValue.ToString(CultureInfo.InvariantCulture), + Resolution = DefaultResolution, + Severity = string.IsNullOrWhiteSpace(severity) ? "WARNING" : severity.Trim().ToUpperInvariant(), + CreatedAt = DateTime.UtcNow + }); + } + + return canonicalValue; + } + + public async Task SaveRunAsync( + SpreadsheetImportAuditSession session, + string status = "SUCCESS", + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + + session.Run.Status = string.IsNullOrWhiteSpace(status) ? "SUCCESS" : status.Trim().ToUpperInvariant(); + session.Run.UpdatedAt = DateTime.UtcNow; + + _db.ImportAuditRuns.Add(session.Run); + + if (session.Issues.Count > 0) + { + _db.ImportAuditIssues.AddRange(session.Issues); + } + + await _db.SaveChangesAsync(cancellationToken); + } + + public async Task GetCanonicalTotalLinhasForReadAsync(CancellationToken cancellationToken = default) + { + try + { + var fromLatestRun = await _db.ImportAuditRuns + .AsNoTracking() + .OrderByDescending(x => x.ImportedAt) + .ThenByDescending(x => x.Id) + .Select(x => (int?)x.CanonicalTotalLinhas) + .FirstOrDefaultAsync(cancellationToken); + + if (fromLatestRun.HasValue && fromLatestRun.Value > 0) + { + return fromLatestRun.Value; + } + } + catch + { + // Fallback para evitar indisponibilidade caso a migration ainda não tenha sido aplicada. + } + + return await CalculateCanonicalTotalLinhasFromGeralAsync(cancellationToken); + } + + public async Task CalculateCanonicalTotalLinhasFromGeralAsync(CancellationToken cancellationToken = default) + { + var maxItem = await _db.MobileLines + .AsNoTracking() + .MaxAsync(x => (int?)x.Item, cancellationToken); + + if (maxItem.HasValue && maxItem.Value > 0) + { + return maxItem.Value; + } + + return await _db.MobileLines.AsNoTracking().CountAsync(cancellationToken); + } +} + +public sealed class SpreadsheetImportAuditSession +{ + internal SpreadsheetImportAuditSession(ImportAuditRun run) + { + Run = run; + } + + public ImportAuditRun Run { get; } + + public List Issues { get; } = new(); + + public int CanonicalTotalLinhas => Run.CanonicalTotalLinhas; +} diff --git a/Services/TenantMiddleware.cs b/Services/TenantMiddleware.cs index f978eed..8096653 100644 --- a/Services/TenantMiddleware.cs +++ b/Services/TenantMiddleware.cs @@ -1,4 +1,6 @@ using System.Security.Claims; +using line_gestao_api.Data; +using Microsoft.EntityFrameworkCore; namespace line_gestao_api.Services; @@ -11,20 +13,61 @@ public class TenantMiddleware _next = next; } - public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider) + public async Task InvokeAsync( + HttpContext context, + ITenantProvider tenantProvider, + AppDbContext db) { Guid? tenantId = null; - var claim = context.User.FindFirst("tenantId")?.Value - ?? context.User.FindFirst("tenant")?.Value; - var headerValue = context.Request.Headers["X-Tenant-Id"].FirstOrDefault(); - if (Guid.TryParse(claim, out var parsed)) + // Usuário autenticado: tenant vem do token. + // Se token legado vier sem tenantId, tenta resolver pelo UserId no banco. + if (context.User.Identity?.IsAuthenticated == true) { - tenantId = parsed; + var claim = context.User.FindFirst("tenantId")?.Value + ?? context.User.FindFirst("tenant")?.Value; + + if (Guid.TryParse(claim, out var parsedFromClaim) && parsedFromClaim != Guid.Empty) + { + tenantId = parsedFromClaim; + } + else + { + var userIdRaw = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? context.User.FindFirst("sub")?.Value; + + if (Guid.TryParse(userIdRaw, out var userId)) + { + var tenantFromUser = await db.Users + .AsNoTracking() + .IgnoreQueryFilters() + .Where(u => u.Id == userId) + .Select(u => (Guid?)u.TenantId) + .FirstOrDefaultAsync(); + + if (tenantFromUser.HasValue && tenantFromUser.Value != Guid.Empty) + { + tenantId = tenantFromUser.Value; + } + } + } + + // Evita comportamento silencioso de "dados vazios" quando token não traz tenant válido. + if (!tenantId.HasValue) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsync("Tenant inválido."); + return; + } } - else if (Guid.TryParse(headerValue, out var headerTenant)) + else { - tenantId = headerTenant; + // Usuário anônimo: permite header para fluxos como login multi-tenant. + var headerValue = context.Request.Headers["X-Tenant-Id"].FirstOrDefault(); + if (Guid.TryParse(headerValue, out var headerTenant) && headerTenant != Guid.Empty) + { + tenantId = headerTenant; + } } tenantProvider.SetTenantId(tenantId); diff --git a/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs b/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs index ca2b5e6..9ea088c 100644 --- a/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs +++ b/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs @@ -95,7 +95,7 @@ namespace line_gestao_api.Tests .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; - return new AppDbContext(options, provider); + return new AppDbContext(options, provider, new NullAuditLogBuilder()); } private sealed class TestTenantProvider : ITenantProvider @@ -112,5 +112,13 @@ namespace line_gestao_api.Tests TenantId = tenantId; } } + + private sealed class NullAuditLogBuilder : IAuditLogBuilder + { + public List BuildAuditLogs(Microsoft.EntityFrameworkCore.ChangeTracking.ChangeTracker changeTracker) + { + return new List(); + } + } } }