Minha alteração

This commit is contained in:
Eduardo 2026-02-06 16:59:22 -03:00
parent 0c17b5e48a
commit 514c7ba8cd
31 changed files with 1052 additions and 30 deletions

View File

@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using line_gestao_api.Data;
using line_gestao_api.Dtos; using line_gestao_api.Dtos;
using line_gestao_api.Models; using line_gestao_api.Models;
using line_gestao_api.Services; using line_gestao_api.Services;
@ -17,15 +18,18 @@ namespace line_gestao_api.Controllers;
public class AuthController : ControllerBase public class AuthController : ControllerBase
{ {
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly AppDbContext _db;
private readonly ITenantProvider _tenantProvider; private readonly ITenantProvider _tenantProvider;
private readonly IConfiguration _config; private readonly IConfiguration _config;
public AuthController( public AuthController(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
AppDbContext db,
ITenantProvider tenantProvider, ITenantProvider tenantProvider,
IConfiguration config) IConfiguration config)
{ {
_userManager = userManager; _userManager = userManager;
_db = db;
_tenantProvider = tenantProvider; _tenantProvider = tenantProvider;
_config = config; _config = config;
} }
@ -38,7 +42,7 @@ public class AuthController : ControllerBase
return BadRequest("As senhas não conferem."); return BadRequest("As senhas não conferem.");
var tenantId = _tenantProvider.TenantId; var tenantId = _tenantProvider.TenantId;
if (tenantId == null) if (tenantId == null || tenantId == Guid.Empty)
return Unauthorized("Tenant inválido."); return Unauthorized("Tenant inválido.");
var email = req.Email.Trim().ToLowerInvariant(); var email = req.Email.Trim().ToLowerInvariant();
@ -65,7 +69,11 @@ public class AuthController : ControllerBase
await _userManager.AddToRoleAsync(user, "leitura"); 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)); return Ok(new AuthResponse(token));
} }
@ -111,7 +119,11 @@ public class AuthController : ControllerBase
if (!valid) if (!valid)
return Unauthorized("Credenciais inválidas."); 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)); return Ok(new AuthResponse(token));
} }
@ -124,7 +136,7 @@ public class AuthController : ControllerBase
return Guid.TryParse(headerValue, out var parsed) ? parsed : null; return Guid.TryParse(headerValue, out var parsed) ? parsed : null;
} }
private async Task<string> GenerateJwtAsync(ApplicationUser user) private async Task<string> GenerateJwtAsync(ApplicationUser user, Guid tenantId)
{ {
var key = _config["Jwt:Key"]!; var key = _config["Jwt:Key"]!;
var issuer = _config["Jwt:Issuer"]!; var issuer = _config["Jwt:Issuer"]!;
@ -138,7 +150,7 @@ public class AuthController : ControllerBase
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty), new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
new("name", user.Name), new("name", user.Name),
new("tenantId", user.TenantId.ToString()) new("tenantId", tenantId.ToString())
}; };
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
@ -156,4 +168,26 @@ public class AuthController : ControllerBase
return new JwtSecurityTokenHandler().WriteToken(token); return new JwtSecurityTokenHandler().WriteToken(token);
} }
private async Task<Guid?> 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;
}
} }

View File

@ -8,6 +8,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize]
public class BillingController : ControllerBase public class BillingController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;

View File

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

View File

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

View File

@ -1,11 +1,13 @@
using line_gestao_api.Dtos; using line_gestao_api.Dtos;
using line_gestao_api.Services; using line_gestao_api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace line_gestao_api.Controllers namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/dashboard/geral")] [Route("api/dashboard/geral")]
[Authorize]
public class DashboardGeralController : ControllerBase public class DashboardGeralController : ControllerBase
{ {
private readonly GeralDashboardInsightsService _service; private readonly GeralDashboardInsightsService _service;

View File

@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using line_gestao_api.Data; using line_gestao_api.Data;
using line_gestao_api.Dtos; using line_gestao_api.Dtos;
using line_gestao_api.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -34,7 +35,11 @@ public class HistoricoController : ControllerBase
page = page < 1 ? 1 : page; page = page < 1 ? 1 : page;
pageSize = pageSize < 1 ? 20 : pageSize; 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)) if (!string.IsNullOrWhiteSpace(pageName))
{ {

View File

@ -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<ActionResult<ImportAuditRunDto>> 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<ActionResult<ImportAuditRunDto>> 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
};
}
}

View File

@ -7,22 +7,26 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace line_gestao_api.Controllers namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
//[Authorize] [Authorize]
public class LinesController : ControllerBase public class LinesController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly ITenantProvider _tenantProvider;
private readonly ParcelamentosImportService _parcelamentosImportService; private readonly ParcelamentosImportService _parcelamentosImportService;
private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService;
private static readonly List<AccountCompanyDto> AccountCompanies = new() private static readonly List<AccountCompanyDto> AccountCompanies = new()
{ {
new AccountCompanyDto 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; _db = db;
_tenantProvider = tenantProvider;
_parcelamentosImportService = parcelamentosImportService; _parcelamentosImportService = parcelamentosImportService;
_spreadsheetImportAuditService = spreadsheetImportAuditService;
} }
public class ImportExcelForm public class ImportExcelForm
@ -581,6 +591,7 @@ namespace line_gestao_api.Controllers
// ✅ 7. DELETE // ✅ 7. DELETE
// ========================================================== // ==========================================================
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id);
@ -599,10 +610,15 @@ namespace line_gestao_api.Controllers
[RequestSizeLimit(50_000_000)] [RequestSizeLimit(50_000_000)]
public async Task<ActionResult<ImportResultDto>> ImportExcel([FromForm] ImportExcelForm form) public async Task<ActionResult<ImportResultDto>> ImportExcel([FromForm] ImportExcelForm form)
{ {
var tenantId = _tenantProvider.TenantId;
if (!tenantId.HasValue || tenantId.Value == Guid.Empty)
return Unauthorized("Tenant inválido.");
var file = form.File; var file = form.File;
if (file == null || file.Length == 0) return BadRequest("Arquivo inválido."); if (file == null || file.Length == 0) return BadRequest("Arquivo inválido.");
await using var tx = await _db.Database.BeginTransactionAsync(); await using var tx = await _db.Database.BeginTransactionAsync();
SpreadsheetImportAuditSession? auditSession = null;
try try
{ {
@ -632,6 +648,7 @@ namespace line_gestao_api.Controllers
var buffer = new List<MobileLine>(600); var buffer = new List<MobileLine>(600);
var imported = 0; var imported = 0;
var maxItemFromGeral = 0;
var lastRow = ws.LastRowUsed()?.RowNumber() ?? startRow; var lastRow = ws.LastRowUsed()?.RowNumber() ?? startRow;
@ -639,6 +656,7 @@ namespace line_gestao_api.Controllers
{ {
var itemStr = GetCellString(ws, r, colItem); var itemStr = GetCellString(ws, r, colItem);
if (string.IsNullOrWhiteSpace(itemStr)) break; if (string.IsNullOrWhiteSpace(itemStr)) break;
var item = TryInt(itemStr);
var linhaDigits = OnlyDigits(GetCellByHeader(ws, r, map, "LINHA")); var linhaDigits = OnlyDigits(GetCellByHeader(ws, r, map, "LINHA"));
var chipDigits = OnlyDigits(GetCellByHeader(ws, r, map, "CHIP")); var chipDigits = OnlyDigits(GetCellByHeader(ws, r, map, "CHIP"));
@ -652,7 +670,7 @@ namespace line_gestao_api.Controllers
var e = new MobileLine var e = new MobileLine
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Item = TryInt(itemStr), Item = item,
Conta = GetCellByHeader(ws, r, map, "CONTA"), Conta = GetCellByHeader(ws, r, map, "CONTA"),
Linha = linhaVal, Linha = linhaVal,
Chip = chipVal, Chip = chipVal,
@ -693,6 +711,7 @@ namespace line_gestao_api.Controllers
buffer.Add(e); buffer.Add(e);
imported++; imported++;
if (item > maxItemFromGeral) maxItemFromGeral = item;
if (buffer.Count >= 500) if (buffer.Count >= 500)
{ {
@ -708,6 +727,11 @@ namespace line_gestao_api.Controllers
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
auditSession = _spreadsheetImportAuditService.StartRun(
file.FileName,
maxItemFromGeral,
imported);
// ========================= // =========================
// ✅ IMPORTA MUREG (ALTERADO: NÃO ESTOURA ERRO SE LINHANOVA JÁ EXISTIR) // ✅ IMPORTA MUREG (ALTERADO: NÃO ESTOURA ERRO SE LINHANOVA JÁ EXISTIR)
// ========================= // =========================
@ -746,13 +770,23 @@ namespace line_gestao_api.Controllers
// ========================= // =========================
// ✅ IMPORTA RESUMO // ✅ IMPORTA RESUMO
// ========================= // =========================
await ImportResumoFromWorkbook(wb); if (auditSession == null)
{
throw new InvalidOperationException("Sessão de auditoria não iniciada.");
}
await ImportResumoFromWorkbook(wb, auditSession);
// ========================= // =========================
// ✅ IMPORTA PARCELAMENTOS // ✅ IMPORTA PARCELAMENTOS
// ========================= // =========================
var parcelamentosSummary = await _parcelamentosImportService.ImportFromWorkbookAsync(wb, replaceAll: true); var parcelamentosSummary = await _parcelamentosImportService.ImportFromWorkbookAsync(wb, replaceAll: true);
if (auditSession != null)
{
await _spreadsheetImportAuditService.SaveRunAsync(auditSession);
}
await AddSpreadsheetImportHistoryAsync(file.FileName, imported, parcelamentosSummary);
await tx.CommitAsync(); await tx.CommitAsync();
return Ok(new ImportResultDto { Imported = imported, Parcelamentos = parcelamentosSummary }); 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<AuditFieldChangeDto>
{
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 // ✅ IMPORTAÇÃO DA ABA MUREG
// ✅ NOVA REGRA: // ✅ NOVA REGRA:
@ -1620,7 +1705,7 @@ namespace line_gestao_api.Controllers
// ========================================================== // ==========================================================
// ✅ IMPORTAÇÃO DA ABA RESUMO // ✅ 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")); var ws = wb.Worksheets.FirstOrDefault(w => NormalizeHeader(w.Name) == NormalizeHeader("RESUMO"));
if (ws == null) return; if (ws == null) return;
@ -1638,15 +1723,15 @@ namespace line_gestao_api.Controllers
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
await ImportResumoTabela1(ws, now); await ImportResumoTabela1(ws, now, auditSession);
await ImportResumoTabela2(ws, now); await ImportResumoTabela2(ws, now, auditSession);
await ImportResumoTabela3(ws, now); await ImportResumoTabela3(ws, now);
await ImportResumoTabela4(ws, now); await ImportResumoTabela4(ws, now);
await ImportResumoTabela5(ws, now); await ImportResumoTabela5(ws, now);
await ImportResumoTabela6(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 lastRowUsed = ws.LastRowUsed()?.RowNumber() ?? 1;
var headerRow = FindHeaderRowForMacrophonyPlans(ws, 1, lastRowUsed); var headerRow = FindHeaderRowForMacrophonyPlans(ws, 1, lastRowUsed);
@ -1785,10 +1870,16 @@ namespace line_gestao_api.Controllers
return; return;
} }
var totalLinhasSource = TryNullableInt(GetCellString(ws, totalRowIndex.Value, colTotalLinhas));
var total = new ResumoMacrophonyTotal var total = new ResumoMacrophonyTotal
{ {
FranquiaGbTotal = TryDecimal(GetCellString(ws, totalRowIndex.Value, colFranquiaGb)), 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)), ValorTotal = TryDecimal(GetCellString(ws, totalRowIndex.Value, colValorTotal)),
CreatedAt = now, CreatedAt = now,
UpdatedAt = now UpdatedAt = now
@ -1819,7 +1910,7 @@ namespace line_gestao_api.Controllers
return 0; 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 headerRow = 5;
const int totalRow = 219; const int totalRow = 219;
@ -1881,9 +1972,15 @@ namespace line_gestao_api.Controllers
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
var qtdLinhasTotalSource = TryNullableInt(GetCellString(ws, totalRow, colQtdLinhas));
var total = new ResumoVivoLineTotal 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)), FranquiaTotal = TryDecimal(GetCellString(ws, totalRow, colFranquiaTotal)),
ValorContratoVivo = TryDecimal(GetCellString(ws, totalRow, colValorContratoVivo)), ValorContratoVivo = TryDecimal(GetCellString(ws, totalRow, colValorContratoVivo)),
FranquiaLine = TryDecimal(GetCellString(ws, totalRow, colFranquiaLine)), FranquiaLine = TryDecimal(GetCellString(ws, totalRow, colFranquiaLine)),

View File

@ -1,6 +1,7 @@
using line_gestao_api.Data; using line_gestao_api.Data;
using line_gestao_api.Dtos; using line_gestao_api.Dtos;
using line_gestao_api.Models; using line_gestao_api.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -8,6 +9,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/mureg")] [Route("api/mureg")]
[Authorize]
public class MuregController : ControllerBase public class MuregController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
@ -345,6 +347,7 @@ namespace line_gestao_api.Controllers
// Exclui registro MUREG // Exclui registro MUREG
// ========================================================== // ==========================================================
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == id); var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == id);

View File

@ -1,5 +1,6 @@
using line_gestao_api.Data; using line_gestao_api.Data;
using line_gestao_api.Dtos; using line_gestao_api.Dtos;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -7,6 +8,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/relatorios")] [Route("api/relatorios")]
[Authorize]
public class RelatoriosController : ControllerBase public class RelatoriosController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;

View File

@ -1,5 +1,6 @@
using line_gestao_api.Data; using line_gestao_api.Data;
using line_gestao_api.Dtos; using line_gestao_api.Dtos;
using line_gestao_api.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -12,10 +13,12 @@ namespace line_gestao_api.Controllers;
public class ResumoController : ControllerBase public class ResumoController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService;
public ResumoController(AppDbContext db) public ResumoController(AppDbContext db, SpreadsheetImportAuditService spreadsheetImportAuditService)
{ {
_db = db; _db = db;
_spreadsheetImportAuditService = spreadsheetImportAuditService;
} }
[HttpGet] [HttpGet]
@ -46,6 +49,7 @@ public class ResumoController : ControllerBase
var reservaTotalEntity = await _db.ResumoReservaTotals.AsNoTracking() var reservaTotalEntity = await _db.ResumoReservaTotals.AsNoTracking()
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
var canonicalTotalLinhas = await _spreadsheetImportAuditService.GetCanonicalTotalLinhasForReadAsync();
var response = new ResumoResponseDto var response = new ResumoResponseDto
{ {
@ -66,7 +70,7 @@ public class ResumoController : ControllerBase
.Select(x => new ResumoMacrophonyTotalDto .Select(x => new ResumoMacrophonyTotalDto
{ {
FranquiaGbTotal = x.FranquiaGbTotal, FranquiaGbTotal = x.FranquiaGbTotal,
TotalLinhasTotal = x.TotalLinhasTotal, TotalLinhasTotal = canonicalTotalLinhas,
ValorTotal = x.ValorTotal ValorTotal = x.ValorTotal
}) })
.FirstOrDefaultAsync(), .FirstOrDefaultAsync(),
@ -87,7 +91,7 @@ public class ResumoController : ControllerBase
VivoLineTotals = await _db.ResumoVivoLineTotals.AsNoTracking() VivoLineTotals = await _db.ResumoVivoLineTotals.AsNoTracking()
.Select(x => new ResumoVivoLineTotalDto .Select(x => new ResumoVivoLineTotalDto
{ {
QtdLinhasTotal = x.QtdLinhasTotal, QtdLinhasTotal = canonicalTotalLinhas,
FranquiaTotal = x.FranquiaTotal, FranquiaTotal = x.FranquiaTotal,
ValorContratoVivo = x.ValorContratoVivo, ValorContratoVivo = x.ValorContratoVivo,
FranquiaLine = x.FranquiaLine, 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); return Ok(response);
} }
} }

View File

@ -1,6 +1,7 @@
using line_gestao_api.Data; using line_gestao_api.Data;
using line_gestao_api.Dtos; using line_gestao_api.Dtos;
using line_gestao_api.Models; using line_gestao_api.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Globalization; using System.Globalization;
@ -10,6 +11,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize]
public class TrocaNumeroController : ControllerBase public class TrocaNumeroController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
@ -156,6 +158,7 @@ namespace line_gestao_api.Controllers
// ✅ DELETE // ✅ DELETE
// ========================================================== // ==========================================================
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -10,6 +10,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/user-data")] [Route("api/user-data")]
[Authorize]
public class UserDataController : ControllerBase public class UserDataController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;

View File

@ -11,6 +11,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/lines/vigencia")] [Route("api/lines/vigencia")]
[Authorize]
public class VigenciaController : ControllerBase public class VigenciaController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;

View File

@ -68,6 +68,10 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
// ✅ tabela AUDIT LOGS (Histórico) // ✅ tabela AUDIT LOGS (Histórico)
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
// ✅ tabelas de auditoria de importação
public DbSet<ImportAuditRun> ImportAuditRuns => Set<ImportAuditRun>();
public DbSet<ImportAuditIssue> ImportAuditIssues => Set<ImportAuditIssue>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@ -283,6 +287,35 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
e.HasIndex(x => x.EntityName); e.HasIndex(x => x.EntityName);
}); });
modelBuilder.Entity<ImportAuditRun>(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<ImportAuditIssue>(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<MobileLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); 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<MuregLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<BillingClient>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<BillingClient>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
@ -305,6 +338,8 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
modelBuilder.Entity<ParcelamentoLine>().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<ParcelamentoMonthValue>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<AuditLog>().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<ApplicationUser>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId);
} }

View File

@ -52,6 +52,8 @@ public static class SeedData
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
await NormalizeLegacyTenantDataAsync(db, tenant.Id);
tenantProvider.SetTenantId(tenant.Id); tenantProvider.SetTenantId(tenant.Id);
var normalizedEmail = userManager.NormalizeEmail(options.AdminEmail); var normalizedEmail = userManager.NormalizeEmail(options.AdminEmail);
@ -79,4 +81,135 @@ public static class SeedData
tenantProvider.SetTenantId(null); 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));
}
} }

26
Dtos/ImportAuditDtos.cs Normal file
View File

@ -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<ImportAuditIssueDto> 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; }
}

View File

@ -0,0 +1,19 @@
// <auto-generated />
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)
{
}
}
}

View File

@ -0,0 +1,101 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace line_gestao_api.Migrations
{
/// <inheritdoc />
public partial class AddImportAuditTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ImportAuditRuns",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
ImportedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
FileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: true),
Status = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
CanonicalTotalLinhas = table.Column<int>(type: "integer", nullable: false),
SourceMaxItemGeral = table.Column<int>(type: "integer", nullable: false),
SourceValidCountGeral = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
AuditRunId = table.Column<Guid>(type: "uuid", nullable: false),
Entity = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
FieldName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
SourceValue = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
CanonicalValue = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Resolution = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
Severity = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
CreatedAt = table.Column<DateTime>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ImportAuditIssues");
migrationBuilder.DropTable(
name: "ImportAuditRuns");
}
}
}

View File

@ -0,0 +1,19 @@
// <auto-generated />
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)
{
}
}
}

View File

@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace line_gestao_api.Migrations
{
/// <inheritdoc />
public partial class RemoveDetailedImportLogs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DELETE FROM "AuditLogs"
WHERE "RequestPath" ILIKE '%import-excel%'
AND COALESCE("Page", '') <> 'Importação de Planilha';
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Nao ha restauracao segura para os logs removidos.
}
}
}

View File

@ -497,6 +497,108 @@ namespace line_gestao_api.Migrations
b.ToTable("ControleRecebidoLines"); b.ToTable("ControleRecebidoLines");
}); });
modelBuilder.Entity("line_gestao_api.Models.ImportAuditIssue", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuditRunId")
.HasColumnType("uuid");
b.Property<string>("CanonicalValue")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Entity")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Resolution")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("SourceValue")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("CanonicalTotalLinhas")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<DateTime>("ImportedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("SourceMaxItemGeral")
.HasColumnType("integer");
b.Property<int>("SourceValidCountGeral")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<DateTime>("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 => modelBuilder.Entity("line_gestao_api.Models.MobileLine", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1466,6 +1568,17 @@ namespace line_gestao_api.Migrations
b.Navigation("MobileLine"); 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 => modelBuilder.Entity("line_gestao_api.Models.Notification", b =>
{ {
b.HasOne("line_gestao_api.Models.ApplicationUser", "User") b.HasOne("line_gestao_api.Models.ApplicationUser", "User")
@ -1499,6 +1612,11 @@ namespace line_gestao_api.Migrations
b.Navigation("Muregs"); b.Navigation("Muregs");
}); });
modelBuilder.Entity("line_gestao_api.Models.ImportAuditRun", b =>
{
b.Navigation("Issues");
});
modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b => modelBuilder.Entity("line_gestao_api.Models.ParcelamentoLine", b =>
{ {
b.Navigation("MonthValues"); b.Navigation("MonthValues");

View File

@ -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;
}

25
Models/ImportAuditRun.cs Normal file
View File

@ -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<ImportAuditIssue> Issues { get; set; } = new List<ImportAuditIssue>();
}

View File

@ -38,6 +38,7 @@ builder.Services.AddScoped<ITenantProvider, TenantProvider>();
builder.Services.AddScoped<IAuditLogBuilder, AuditLogBuilder>(); builder.Services.AddScoped<IAuditLogBuilder, AuditLogBuilder>();
builder.Services.AddScoped<ParcelamentosImportService>(); builder.Services.AddScoped<ParcelamentosImportService>();
builder.Services.AddScoped<GeralDashboardInsightsService>(); builder.Services.AddScoped<GeralDashboardInsightsService>();
builder.Services.AddScoped<SpreadsheetImportAuditService>();
builder.Services.AddIdentityCore<ApplicationUser>(options => builder.Services.AddIdentityCore<ApplicationUser>(options =>
{ {

View File

@ -16,6 +16,8 @@ namespace line_gestao_api.Services;
public class AuditLogBuilder : IAuditLogBuilder public class AuditLogBuilder : IAuditLogBuilder
{ {
public const string SpreadsheetImportPageName = "Importação de Planilha";
private static readonly Dictionary<string, string> PageByEntity = new(StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, string> PageByEntity = new(StringComparer.OrdinalIgnoreCase)
{ {
{ nameof(MobileLine), "Geral" }, { nameof(MobileLine), "Geral" },
@ -80,6 +82,12 @@ public class AuditLogBuilder : IAuditLogBuilder
var requestMethod = request?.Method; var requestMethod = request?.Method;
var ipAddress = httpContext?.Connection?.RemoteIpAddress?.ToString(); var ipAddress = httpContext?.Connection?.RemoteIpAddress?.ToString();
if (IsSpreadsheetImportRequest(requestPath))
{
// Importacoes de planilha nao geram historico detalhado por entidade.
return new List<AuditLog>();
}
var logs = new List<AuditLog>(); var logs = new List<AuditLog>();
foreach (var entry in changeTracker.Entries()) foreach (var entry in changeTracker.Entries())
@ -120,6 +128,16 @@ public class AuditLogBuilder : IAuditLogBuilder
return logs; 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) private static string ResolveAction(EntityState state)
=> state switch => state switch
{ {

View File

@ -523,9 +523,44 @@ namespace line_gestao_api.Services
ClientGroups = BuildClientGroups(clientGroupsRaw) ClientGroups = BuildClientGroups(clientGroupsRaw)
}; };
dto.Kpis.TotalLinhas = await ResolveCanonicalTotalLinhasAsync(dto.Kpis.TotalLinhas);
return dto; return dto;
} }
private async Task<int> 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) private static GeralDashboardKpisDto BuildKpis(TotalsProjection? totals)
{ {
if (totals == null) if (totals == null)

View File

@ -275,7 +275,7 @@ public sealed class ParcelamentosImportService
var block1 = ExtractMonthBlock(monthHeaders, firstJanIdx); var block1 = ExtractMonthBlock(monthHeaders, firstJanIdx);
var block2 = block1.NextIndex < monthHeaders.Count var block2 = block1.NextIndex < monthHeaders.Count
? ExtractMonthBlock(monthHeaders, block1.NextIndex) ? 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 yearRow = headerRow - 1;
var year1 = block1.Block.Count > 0 var year1 = block1.Block.Count > 0

View File

@ -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<int> 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<int> 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<ImportAuditIssue> Issues { get; } = new();
public int CanonicalTotalLinhas => Run.CanonicalTotalLinhas;
}

View File

@ -1,4 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using line_gestao_api.Data;
using Microsoft.EntityFrameworkCore;
namespace line_gestao_api.Services; namespace line_gestao_api.Services;
@ -11,21 +13,62 @@ public class TenantMiddleware
_next = next; _next = next;
} }
public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider) public async Task InvokeAsync(
HttpContext context,
ITenantProvider tenantProvider,
AppDbContext db)
{ {
Guid? tenantId = null; Guid? tenantId = null;
// 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)
{
var claim = context.User.FindFirst("tenantId")?.Value var claim = context.User.FindFirst("tenantId")?.Value
?? context.User.FindFirst("tenant")?.Value; ?? context.User.FindFirst("tenant")?.Value;
var headerValue = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (Guid.TryParse(claim, out var parsed)) if (Guid.TryParse(claim, out var parsedFromClaim) && parsedFromClaim != Guid.Empty)
{ {
tenantId = parsed; tenantId = parsedFromClaim;
} }
else if (Guid.TryParse(headerValue, out var headerTenant)) 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
{
// 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; tenantId = headerTenant;
} }
}
tenantProvider.SetTenantId(tenantId); tenantProvider.SetTenantId(tenantId);

View File

@ -95,7 +95,7 @@ namespace line_gestao_api.Tests
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; .Options;
return new AppDbContext(options, provider); return new AppDbContext(options, provider, new NullAuditLogBuilder());
} }
private sealed class TestTenantProvider : ITenantProvider private sealed class TestTenantProvider : ITenantProvider
@ -112,5 +112,13 @@ namespace line_gestao_api.Tests
TenantId = tenantId; TenantId = tenantId;
} }
} }
private sealed class NullAuditLogBuilder : IAuditLogBuilder
{
public List<AuditLog> BuildAuditLogs(Microsoft.EntityFrameworkCore.ChangeTracking.ChangeTracker changeTracker)
{
return new List<AuditLog>();
}
}
} }
} }