diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index bdefe31..0a0cd0a 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -4,13 +4,16 @@ using line_gestao_api.Dtos; using line_gestao_api.Models; using line_gestao_api.Services; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; using Microsoft.EntityFrameworkCore; using System.Security.Claims; using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Text; using System.Text.Json; @@ -28,6 +31,8 @@ namespace line_gestao_api.Controllers private readonly IVigenciaNotificationSyncService _vigenciaNotificationSyncService; private readonly ParcelamentosImportService _parcelamentosImportService; private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService; + private readonly string _aparelhoAttachmentsRootPath; + private static readonly FileExtensionContentTypeProvider FileContentTypeProvider = new(); private static readonly List AccountCompanies = new() { new AccountCompanyDto @@ -57,13 +62,15 @@ namespace line_gestao_api.Controllers ITenantProvider tenantProvider, IVigenciaNotificationSyncService vigenciaNotificationSyncService, ParcelamentosImportService parcelamentosImportService, - SpreadsheetImportAuditService spreadsheetImportAuditService) + SpreadsheetImportAuditService spreadsheetImportAuditService, + IWebHostEnvironment webHostEnvironment) { _db = db; _tenantProvider = tenantProvider; _vigenciaNotificationSyncService = vigenciaNotificationSyncService; _parcelamentosImportService = parcelamentosImportService; _spreadsheetImportAuditService = spreadsheetImportAuditService; + _aparelhoAttachmentsRootPath = Path.Combine(webHostEnvironment.ContentRootPath, "uploads", "aparelhos"); } public class ImportExcelForm @@ -71,6 +78,12 @@ namespace line_gestao_api.Controllers public IFormFile File { get; set; } = default!; } + public class UploadAparelhoAnexosForm + { + public IFormFile? NotaFiscal { get; set; } + public IFormFile? Recibo { get; set; } + } + // ========================================================== // ✅ 1. ENDPOINT: AGRUPAR POR CLIENTE // ========================================================== @@ -523,6 +536,10 @@ namespace line_gestao_api.Controllers line.Chip, Cliente = clienteExibicao, line.Usuario, + line.CentroDeCustos, + SetorNome = line.Setor != null ? line.Setor.Nome : null, + AparelhoNome = line.Aparelho != null ? line.Aparelho.Nome : null, + AparelhoCor = line.Aparelho != null ? line.Aparelho.Cor : null, line.PlanoContrato, line.Status, line.Skil, @@ -588,6 +605,10 @@ namespace line_gestao_api.Controllers Chip = x.Chip, Cliente = x.Cliente, Usuario = x.Usuario, + CentroDeCustos = x.CentroDeCustos, + SetorNome = x.SetorNome, + AparelhoNome = x.AparelhoNome, + AparelhoCor = x.AparelhoCor, PlanoContrato = x.PlanoContrato, Status = x.Status, Skil = x.Skil, @@ -657,6 +678,10 @@ namespace line_gestao_api.Controllers Chip = x.Chip, Cliente = x.Cliente, Usuario = x.Usuario, + CentroDeCustos = x.CentroDeCustos, + SetorNome = x.Setor != null ? x.Setor.Nome : null, + AparelhoNome = x.Aparelho != null ? x.Aparelho.Nome : null, + AparelhoCor = x.Aparelho != null ? x.Aparelho.Cor : null, PlanoContrato = x.PlanoContrato, Status = x.Status, Skil = x.Skil, @@ -688,7 +713,11 @@ namespace line_gestao_api.Controllers [HttpGet("{id:guid}")] public async Task> GetById(Guid id) { - var x = await _db.MobileLines.AsNoTracking().FirstOrDefaultAsync(a => a.Id == id); + var x = await _db.MobileLines + .AsNoTracking() + .Include(a => a.Setor) + .Include(a => a.Aparelho) + .FirstOrDefaultAsync(a => a.Id == id); if (x == null) return NotFound(); var vigencia = await FindVigenciaByMobileLineAsync(x, null, asNoTracking: true); return Ok(ToDetailDto(x, vigencia)); @@ -790,6 +819,8 @@ namespace line_gestao_api.Controllers lineToPersist.Cliente = ensuredTenant.NomeOficial; } + await ApplySetorAndAparelhoToLineAsync(lineToPersist, req); + var vigencia = await UpsertVigenciaFromMobileLineAsync( lineToPersist, req.DtEfetivacaoServico, @@ -923,6 +954,7 @@ namespace line_gestao_api.Controllers Linha = linhaLimpa, Chip = string.IsNullOrWhiteSpace(chipLimpo) ? null : chipLimpo, Usuario = entry.Usuario?.Trim(), + CentroDeCustos = NormalizeOptionalText(entry.CentroDeCustos), Status = entry.Status?.Trim(), Skil = entry.Skil?.Trim(), Modalidade = entry.Modalidade?.Trim(), @@ -973,6 +1005,8 @@ namespace line_gestao_api.Controllers newLine.Cliente = ensuredTenant.NomeOficial; } + await ApplySetorAndAparelhoToLineAsync(newLine, entry); + _db.MobileLines.Add(newLine); var vigencia = await UpsertVigenciaFromMobileLineAsync( @@ -1293,11 +1327,49 @@ namespace line_gestao_api.Controllers // ✅ 6. UPDATE // ========================================================== [HttpPut("{id:guid}")] - [Authorize(Roles = "sysadmin,gestor")] + [Authorize(Roles = "sysadmin,gestor,cliente")] public async Task Update(Guid id, [FromBody] UpdateMobileLineRequest req) { - var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); + var x = await _db.MobileLines + .Include(a => a.Setor) + .Include(a => a.Aparelho) + .FirstOrDefaultAsync(a => a.Id == id); if (x == null) return NotFound(); + + var canManageFullLine = User.IsInRole(AppRoles.SysAdmin) || User.IsInRole(AppRoles.Gestor); + if (!canManageFullLine) + { + x.Usuario = NormalizeOptionalText(req.Usuario); + x.CentroDeCustos = NormalizeOptionalText(req.CentroDeCustos); + await ApplyAparelhoToLineAsync( + x, + x.TenantId, + req.AparelhoId, + req.AparelhoNome, + req.AparelhoCor, + req.AparelhoImei); + + await UpsertVigenciaFromMobileLineAsync( + x, + dtEfetivacaoServico: null, + dtTerminoFidelizacao: null, + overrideDates: false, + previousLinha: null); + x.UpdatedAt = DateTime.UtcNow; + + try + { + await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); + } + catch (DbUpdateException) + { + return Conflict(new { message = "Conflito ao salvar." }); + } + + return NoContent(); + } + var previousLinha = x.Linha; var previousCliente = x.Cliente; @@ -1314,9 +1386,10 @@ namespace line_gestao_api.Controllers var newChip = OnlyDigits(req.Chip); x.Chip = string.IsNullOrWhiteSpace(newChip) ? null : newChip; - x.Cliente = req.Cliente?.Trim(); - x.Usuario = req.Usuario?.Trim(); - x.PlanoContrato = req.PlanoContrato?.Trim(); + x.Cliente = NormalizeOptionalText(req.Cliente); + x.Usuario = NormalizeOptionalText(req.Usuario); + x.CentroDeCustos = NormalizeOptionalText(req.CentroDeCustos); + x.PlanoContrato = NormalizeOptionalText(req.PlanoContrato); x.FranquiaVivo = req.FranquiaVivo; x.ValorPlanoVivo = req.ValorPlanoVivo; x.GestaoVozDados = req.GestaoVozDados; @@ -1332,16 +1405,16 @@ namespace line_gestao_api.Controllers x.ValorContratoLine = req.ValorContratoLine; x.Desconto = req.Desconto; x.Lucro = req.Lucro; - x.Status = req.Status?.Trim(); + x.Status = NormalizeOptionalText(req.Status); x.DataBloqueio = ToUtc(req.DataBloqueio); - x.Skil = req.Skil?.Trim(); - x.Modalidade = req.Modalidade?.Trim(); - x.Cedente = req.Cedente?.Trim(); - x.Solicitante = req.Solicitante?.Trim(); + x.Skil = NormalizeOptionalText(req.Skil); + x.Modalidade = NormalizeOptionalText(req.Modalidade); + x.Cedente = NormalizeOptionalText(req.Cedente); + x.Solicitante = NormalizeOptionalText(req.Solicitante); x.DataEntregaOpera = ToUtc(req.DataEntregaOpera); x.DataEntregaCliente = ToUtc(req.DataEntregaCliente); - x.VencConta = req.VencConta?.Trim(); - x.TipoDeChip = req.TipoDeChip?.Trim(); + x.VencConta = NormalizeOptionalText(req.VencConta); + x.TipoDeChip = NormalizeOptionalText(req.TipoDeChip); var previousClienteNormalized = string.IsNullOrWhiteSpace(previousCliente) ? null : previousCliente.Trim(); var clienteAtualIsReserva = IsReservaValue(x.Cliente); @@ -1371,6 +1444,15 @@ namespace line_gestao_api.Controllers x.Cliente = ensuredTenant.NomeOficial; } + await ApplySetorToLineAsync(x, x.TenantId, req.SetorId, req.SetorNome); + await ApplyAparelhoToLineAsync( + x, + x.TenantId, + req.AparelhoId, + req.AparelhoNome, + req.AparelhoCor, + req.AparelhoImei); + await UpsertVigenciaFromMobileLineAsync( x, req.DtEfetivacaoServico, @@ -1389,6 +1471,128 @@ namespace line_gestao_api.Controllers return NoContent(); } + [HttpPost("{id:guid}/aparelho/anexos")] + [Authorize(Roles = "sysadmin,gestor,cliente")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(25_000_000)] + public async Task> UploadAparelhoAnexos( + Guid id, + [FromForm] UploadAparelhoAnexosForm form) + { + var hasNotaFiscal = form.NotaFiscal != null && form.NotaFiscal.Length > 0; + var hasRecibo = form.Recibo != null && form.Recibo.Length > 0; + + if (!hasNotaFiscal && !hasRecibo) + { + return BadRequest(new { message = "Envie ao menos um arquivo (Nota Fiscal ou Recibo)." }); + } + + var line = await _db.MobileLines + .Include(x => x.Aparelho) + .FirstOrDefaultAsync(x => x.Id == id); + if (line == null) + { + return NotFound(); + } + + var tenantId = line.TenantId != Guid.Empty + ? line.TenantId + : (_tenantProvider.ActorTenantId ?? Guid.Empty); + if (tenantId == Guid.Empty) + { + return BadRequest(new { message = "Tenant inválido para salvar anexo." }); + } + + if (line.TenantId == Guid.Empty) + { + line.TenantId = tenantId; + } + + var aparelho = await EnsureLineHasAparelhoAsync(line, tenantId); + + try + { + if (hasNotaFiscal && form.NotaFiscal != null) + { + aparelho.NotaFiscalArquivoPath = await SaveAparelhoAttachmentAsync( + form.NotaFiscal, + tenantId, + line.Id, + "nota-fiscal", + aparelho.NotaFiscalArquivoPath); + } + + if (hasRecibo && form.Recibo != null) + { + aparelho.ReciboArquivoPath = await SaveAparelhoAttachmentAsync( + form.Recibo, + tenantId, + line.Id, + "recibo", + aparelho.ReciboArquivoPath); + } + } + catch (InvalidOperationException ex) + { + return BadRequest(new { message = ex.Message }); + } + catch (IOException) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { message = "Falha ao salvar anexo no servidor." }); + } + + line.UpdatedAt = DateTime.UtcNow; + aparelho.UpdatedAt = DateTime.UtcNow; + + await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); + + var vigencia = await FindVigenciaByMobileLineAsync(line, null, asNoTracking: true); + return Ok(ToDetailDto(line, vigencia)); + } + + [HttpGet("{id:guid}/aparelho/anexos/{tipo}")] + [Authorize(Roles = "sysadmin,gestor,cliente")] + public async Task DownloadAparelhoAnexo(Guid id, string tipo) + { + var line = await _db.MobileLines + .AsNoTracking() + .Include(x => x.Aparelho) + .FirstOrDefaultAsync(x => x.Id == id); + if (line == null || line.Aparelho == null) + { + return NotFound(); + } + + var normalizedTipo = (tipo ?? string.Empty).Trim().ToLowerInvariant(); + var relativePath = normalizedTipo switch + { + "nota-fiscal" or "nota_fiscal" or "notafiscal" => line.Aparelho.NotaFiscalArquivoPath, + "recibo" => line.Aparelho.ReciboArquivoPath, + _ => null + }; + + if (string.IsNullOrWhiteSpace(relativePath)) + { + return NotFound(); + } + + var fullPath = TryResolveAttachmentFullPath(relativePath); + if (string.IsNullOrWhiteSpace(fullPath) || !System.IO.File.Exists(fullPath)) + { + return NotFound(); + } + + if (!FileContentTypeProvider.TryGetContentType(fullPath, out var contentType)) + { + contentType = "application/octet-stream"; + } + + var downloadName = Path.GetFileName(fullPath); + var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); + return File(stream, contentType, downloadName); + } + // ========================================================== // ✅ 7. DELETE // ========================================================== @@ -4098,6 +4302,7 @@ namespace line_gestao_api.Controllers line.Linha = linhaLimpa; line.Chip = string.IsNullOrWhiteSpace(chipLimpo) ? null : chipLimpo; line.Usuario = req.Usuario?.Trim(); + line.CentroDeCustos = NormalizeOptionalText(req.CentroDeCustos); line.Status = req.Status?.Trim(); line.Skil = req.Skil?.Trim(); line.Modalidade = req.Modalidade?.Trim(); @@ -4131,6 +4336,330 @@ namespace line_gestao_api.Controllers line.UpdatedAt = now; } + private async Task ApplySetorAndAparelhoToLineAsync(MobileLine line, CreateMobileLineDto req) + { + var tenantId = line.TenantId != Guid.Empty + ? line.TenantId + : (_tenantProvider.ActorTenantId ?? Guid.Empty); + if (tenantId == Guid.Empty) + { + return; + } + + if (line.TenantId == Guid.Empty) + { + line.TenantId = tenantId; + } + + await ApplySetorToLineAsync(line, tenantId, req.SetorId, req.SetorNome); + await ApplyAparelhoToLineAsync( + line, + tenantId, + req.AparelhoId, + req.AparelhoNome, + req.AparelhoCor, + req.AparelhoImei); + } + + private async Task ApplySetorToLineAsync(MobileLine line, Guid tenantId, Guid? setorId, string? setorNome) + { + var hasSetorId = setorId.HasValue && setorId.Value != Guid.Empty; + var setorNomeInformado = setorNome != null; + var setorNomeNormalizado = NormalizeOptionalText(setorNome); + + if (!hasSetorId && !setorNomeInformado) + { + return; + } + + if (!hasSetorId && setorNomeInformado && string.IsNullOrWhiteSpace(setorNomeNormalizado)) + { + line.SetorId = null; + line.Setor = null; + return; + } + + var setor = await ResolveSetorAsync(tenantId, setorId, setorNomeNormalizado); + if (setor == null) + { + if (setorNomeInformado) + { + line.SetorId = null; + line.Setor = null; + } + return; + } + + line.SetorId = setor.Id; + line.Setor = setor; + } + + private async Task ResolveSetorAsync(Guid tenantId, Guid? setorId, string? setorNome) + { + if (setorId.HasValue && setorId.Value != Guid.Empty) + { + var byId = await _db.Setores.FirstOrDefaultAsync(s => s.TenantId == tenantId && s.Id == setorId.Value); + if (byId != null) + { + if (!string.IsNullOrWhiteSpace(setorNome) && + !string.Equals(byId.Nome, setorNome, StringComparison.Ordinal)) + { + byId.Nome = setorNome; + byId.UpdatedAt = DateTime.UtcNow; + } + return byId; + } + } + + if (string.IsNullOrWhiteSpace(setorNome)) + { + return null; + } + + var normalized = NormalizeTenantKeyValue(setorNome); + var candidates = await _db.Setores.Where(s => s.TenantId == tenantId).ToListAsync(); + var existing = candidates.FirstOrDefault(s => + string.Equals(NormalizeTenantKeyValue(s.Nome), normalized, StringComparison.Ordinal)); + if (existing != null) + { + if (!string.Equals(existing.Nome, setorNome, StringComparison.Ordinal)) + { + existing.Nome = setorNome; + existing.UpdatedAt = DateTime.UtcNow; + } + return existing; + } + + var now = DateTime.UtcNow; + var created = new Setor + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Nome = setorNome, + CreatedAt = now, + UpdatedAt = now + }; + + _db.Setores.Add(created); + return created; + } + + private async Task ApplyAparelhoToLineAsync( + MobileLine line, + Guid tenantId, + Guid? aparelhoId, + string? aparelhoNome, + string? aparelhoCor, + string? aparelhoImei) + { + var hasAparelhoId = aparelhoId.HasValue && aparelhoId.Value != Guid.Empty; + var hasAnyPayload = + aparelhoNome != null || + aparelhoCor != null || + aparelhoImei != null; + + if (!hasAparelhoId && !hasAnyPayload) + { + return; + } + + var normalizedNome = NormalizeOptionalText(aparelhoNome); + var normalizedCor = NormalizeOptionalText(aparelhoCor); + var normalizedImei = NormalizeOptionalText(aparelhoImei); + + var shouldDetach = + !hasAparelhoId && + hasAnyPayload && + string.IsNullOrWhiteSpace(normalizedNome) && + string.IsNullOrWhiteSpace(normalizedCor) && + string.IsNullOrWhiteSpace(normalizedImei); + + if (shouldDetach) + { + line.AparelhoId = null; + line.Aparelho = null; + return; + } + + var aparelho = await ResolveAparelhoAsync(tenantId, aparelhoId, line.AparelhoId); + if (aparelho == null) + { + var now = DateTime.UtcNow; + aparelho = new Aparelho + { + Id = Guid.NewGuid(), + TenantId = tenantId, + CreatedAt = now, + UpdatedAt = now + }; + _db.Aparelhos.Add(aparelho); + } + + if (aparelhoNome != null) aparelho.Nome = normalizedNome; + if (aparelhoCor != null) aparelho.Cor = normalizedCor; + if (aparelhoImei != null) aparelho.Imei = normalizedImei; + + aparelho.UpdatedAt = DateTime.UtcNow; + + line.AparelhoId = aparelho.Id; + line.Aparelho = aparelho; + } + + private async Task ResolveAparelhoAsync(Guid tenantId, Guid? aparelhoId, Guid? lineAparelhoId) + { + if (aparelhoId.HasValue && aparelhoId.Value != Guid.Empty) + { + var byRequestId = await _db.Aparelhos + .FirstOrDefaultAsync(a => a.TenantId == tenantId && a.Id == aparelhoId.Value); + if (byRequestId != null) + { + return byRequestId; + } + } + + if (lineAparelhoId.HasValue && lineAparelhoId.Value != Guid.Empty) + { + return await _db.Aparelhos + .FirstOrDefaultAsync(a => a.TenantId == tenantId && a.Id == lineAparelhoId.Value); + } + + return null; + } + + private async Task EnsureLineHasAparelhoAsync(MobileLine line, Guid tenantId) + { + if (line.Aparelho != null && line.Aparelho.TenantId == tenantId) + { + if (line.AparelhoId != line.Aparelho.Id) + { + line.AparelhoId = line.Aparelho.Id; + } + return line.Aparelho; + } + + var aparelho = await ResolveAparelhoAsync(tenantId, line.AparelhoId, line.AparelhoId); + if (aparelho == null) + { + var now = DateTime.UtcNow; + aparelho = new Aparelho + { + Id = Guid.NewGuid(), + TenantId = tenantId, + CreatedAt = now, + UpdatedAt = now + }; + _db.Aparelhos.Add(aparelho); + } + + line.AparelhoId = aparelho.Id; + line.Aparelho = aparelho; + return aparelho; + } + + private async Task SaveAparelhoAttachmentAsync( + IFormFile file, + Guid tenantId, + Guid lineId, + string attachmentKind, + string? previousRelativePath) + { + const long maxBytes = 15 * 1024 * 1024; + if (file.Length <= 0) + { + throw new InvalidOperationException("Arquivo de anexo inválido."); + } + + if (file.Length > maxBytes) + { + throw new InvalidOperationException("O anexo excede o limite de 15MB."); + } + + var extension = (Path.GetExtension(file.FileName) ?? string.Empty).Trim().ToLowerInvariant(); + var allowedExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".pdf", ".png", ".jpg", ".jpeg", ".webp" + }; + + if (string.IsNullOrWhiteSpace(extension) || !allowedExtensions.Contains(extension)) + { + throw new InvalidOperationException("Formato de arquivo inválido. Use PDF, PNG, JPG, JPEG ou WEBP."); + } + + var targetDirectory = Path.Combine( + _aparelhoAttachmentsRootPath, + tenantId.ToString("N"), + lineId.ToString("N"), + attachmentKind); + + Directory.CreateDirectory(targetDirectory); + + var safeName = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{Guid.NewGuid():N}{extension}"; + var fullPath = Path.Combine(targetDirectory, safeName); + + await using (var stream = new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + await file.CopyToAsync(stream); + } + + if (!string.IsNullOrWhiteSpace(previousRelativePath)) + { + var oldPath = TryResolveAttachmentFullPath(previousRelativePath); + if (!string.IsNullOrWhiteSpace(oldPath) && + !string.Equals(oldPath, fullPath, StringComparison.OrdinalIgnoreCase) && + System.IO.File.Exists(oldPath)) + { + try + { + System.IO.File.Delete(oldPath); + } + catch + { + // Falha ao remover arquivo antigo não deve impedir o fluxo principal. + } + } + } + + var relativePath = Path.GetRelativePath(_aparelhoAttachmentsRootPath, fullPath); + return relativePath.Replace('\\', '/'); + } + + private string? TryResolveAttachmentFullPath(string? relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + return null; + } + + var normalized = relativePath + .Replace('\\', Path.DirectorySeparatorChar) + .TrimStart(Path.DirectorySeparatorChar, '/'); + + if (normalized.Contains("..", StringComparison.Ordinal)) + { + return null; + } + + var root = Path.GetFullPath(_aparelhoAttachmentsRootPath); + var fullPath = Path.GetFullPath(Path.Combine(root, normalized)); + if (!fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return fullPath; + } + + private static string? NormalizeOptionalText(string? value) + { + if (value == null) + { + return null; + } + + var trimmed = value.Trim(); + return trimmed.Length == 0 ? null : trimmed; + } + private async Task EnsureTenantForClientAsync(string? rawClientName) { var clientName = (rawClientName ?? string.Empty).Trim(); @@ -4408,6 +4937,15 @@ namespace line_gestao_api.Controllers Chip = x.Chip, Cliente = x.Cliente, Usuario = x.Usuario, + CentroDeCustos = x.CentroDeCustos, + SetorId = x.SetorId, + SetorNome = x.Setor?.Nome, + AparelhoId = x.AparelhoId, + AparelhoNome = x.Aparelho?.Nome, + AparelhoCor = x.Aparelho?.Cor, + AparelhoImei = x.Aparelho?.Imei, + AparelhoNotaFiscalTemArquivo = !string.IsNullOrWhiteSpace(x.Aparelho?.NotaFiscalArquivoPath), + AparelhoReciboTemArquivo = !string.IsNullOrWhiteSpace(x.Aparelho?.ReciboArquivoPath), PlanoContrato = x.PlanoContrato, FranquiaVivo = x.FranquiaVivo, ValorPlanoVivo = x.ValorPlanoVivo, diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index c086ae5..a20efd2 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -24,6 +24,8 @@ public class AppDbContext : IdentityDbContext MobileLines => Set(); + public DbSet Setores => Set(); + public DbSet Aparelhos => Set(); // ✅ tabela para espelhar a aba MUREG public DbSet MuregLines => Set(); @@ -87,6 +89,25 @@ public class AppDbContext : IdentityDbContext new { x.IsSystem, x.Ativo }); }); + modelBuilder.Entity(e => + { + e.Property(x => x.Nome).HasMaxLength(160); + e.HasIndex(x => x.TenantId); + e.HasIndex(x => new { x.TenantId, x.Nome }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.Property(x => x.Nome).HasMaxLength(160); + e.Property(x => x.Cor).HasMaxLength(80); + e.Property(x => x.Imei).HasMaxLength(80); + e.Property(x => x.NotaFiscalArquivoPath).HasMaxLength(500); + e.Property(x => x.ReciboArquivoPath).HasMaxLength(500); + e.HasIndex(x => x.TenantId); + e.HasIndex(x => x.Imei); + e.HasIndex(x => new { x.TenantId, x.Nome, x.Cor }); + }); + // ========================= // ✅ USER (Identity) // ========================= @@ -104,13 +125,27 @@ public class AppDbContext : IdentityDbContext new { x.TenantId, x.Linha }).IsUnique(); + e.Property(x => x.CentroDeCustos).HasMaxLength(180); // performance e.HasIndex(x => x.Chip); e.HasIndex(x => x.Cliente); e.HasIndex(x => x.Usuario); + e.HasIndex(x => x.CentroDeCustos); e.HasIndex(x => x.Skil); e.HasIndex(x => x.Status); + e.HasIndex(x => x.SetorId); + e.HasIndex(x => x.AparelhoId); + + e.HasOne(x => x.Setor) + .WithMany(x => x.MobileLines) + .HasForeignKey(x => x.SetorId) + .OnDelete(DeleteBehavior.SetNull); + + e.HasOne(x => x.Aparelho) + .WithMany(x => x.MobileLines) + .HasForeignKey(x => x.AparelhoId) + .OnDelete(DeleteBehavior.SetNull); }); // ========================= @@ -336,6 +371,8 @@ public class AppDbContext : IdentityDbContext().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); diff --git a/Dtos/CreateMobileLineDto.cs b/Dtos/CreateMobileLineDto.cs index ccc9e89..1ee0a5e 100644 --- a/Dtos/CreateMobileLineDto.cs +++ b/Dtos/CreateMobileLineDto.cs @@ -12,6 +12,13 @@ namespace line_gestao_api.Dtos public string? Chip { get; set; } // ICCID public string? Cliente { get; set; } // Obrigatório na validação do Controller public string? Usuario { get; set; } + public string? CentroDeCustos { get; set; } + public Guid? SetorId { get; set; } + public string? SetorNome { get; set; } + public Guid? AparelhoId { get; set; } + public string? AparelhoNome { get; set; } + public string? AparelhoCor { get; set; } + public string? AparelhoImei { get; set; } public Guid? ReservaLineId { get; set; } // Reaproveita linha já existente na Reserva // ========================== diff --git a/Dtos/MobileLineDtos.cs b/Dtos/MobileLineDtos.cs index e84bd6b..3d9afab 100644 --- a/Dtos/MobileLineDtos.cs +++ b/Dtos/MobileLineDtos.cs @@ -9,6 +9,10 @@ public string? Chip { get; set; } public string? Cliente { get; set; } public string? Usuario { get; set; } + public string? CentroDeCustos { get; set; } + public string? SetorNome { get; set; } + public string? AparelhoNome { get; set; } + public string? AparelhoCor { get; set; } public string? PlanoContrato { get; set; } public string? Status { get; set; } public string? Skil { get; set; } @@ -35,6 +39,15 @@ public string? Chip { get; set; } public string? Cliente { get; set; } public string? Usuario { get; set; } + public string? CentroDeCustos { get; set; } + public Guid? SetorId { get; set; } + public string? SetorNome { get; set; } + public Guid? AparelhoId { get; set; } + public string? AparelhoNome { get; set; } + public string? AparelhoCor { get; set; } + public string? AparelhoImei { get; set; } + public bool AparelhoNotaFiscalTemArquivo { get; set; } + public bool AparelhoReciboTemArquivo { get; set; } public string? PlanoContrato { get; set; } public decimal? FranquiaVivo { get; set; } @@ -78,6 +91,13 @@ public string? Chip { get; set; } public string? Cliente { get; set; } public string? Usuario { get; set; } + public string? CentroDeCustos { get; set; } + public Guid? SetorId { get; set; } + public string? SetorNome { get; set; } + public Guid? AparelhoId { get; set; } + public string? AparelhoNome { get; set; } + public string? AparelhoCor { get; set; } + public string? AparelhoImei { get; set; } public string? PlanoContrato { get; set; } public decimal? FranquiaVivo { get; set; } diff --git a/Migrations/20260303120000_AddSetoresAparelhosAndMobileLineCostCenter.cs b/Migrations/20260303120000_AddSetoresAparelhosAndMobileLineCostCenter.cs new file mode 100644 index 0000000..9dfc635 --- /dev/null +++ b/Migrations/20260303120000_AddSetoresAparelhosAndMobileLineCostCenter.cs @@ -0,0 +1,114 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260303120000_AddSetoresAparelhosAndMobileLineCostCenter")] + public partial class AddSetoresAparelhosAndMobileLineCostCenter : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + CREATE TABLE IF NOT EXISTS "Setores" ( + "Id" uuid NOT NULL, + "TenantId" uuid NOT NULL, + "Nome" character varying(160) NOT NULL, + "CreatedAt" timestamp with time zone NOT NULL, + "UpdatedAt" timestamp with time zone NOT NULL, + CONSTRAINT "PK_Setores" PRIMARY KEY ("Id") + ); + """); + + migrationBuilder.Sql(""" + CREATE TABLE IF NOT EXISTS "Aparelhos" ( + "Id" uuid NOT NULL, + "TenantId" uuid NOT NULL, + "Nome" character varying(160) NULL, + "Cor" character varying(80) NULL, + "Imei" character varying(80) NULL, + "NotaFiscalArquivoPath" character varying(500) NULL, + "ReciboArquivoPath" character varying(500) NULL, + "CreatedAt" timestamp with time zone NOT NULL, + "UpdatedAt" timestamp with time zone NOT NULL, + CONSTRAINT "PK_Aparelhos" PRIMARY KEY ("Id") + ); + """); + + migrationBuilder.Sql("""ALTER TABLE "MobileLines" ADD COLUMN IF NOT EXISTS "CentroDeCustos" character varying(180) NULL;"""); + migrationBuilder.Sql("""ALTER TABLE "MobileLines" ADD COLUMN IF NOT EXISTS "SetorId" uuid NULL;"""); + migrationBuilder.Sql("""ALTER TABLE "MobileLines" ADD COLUMN IF NOT EXISTS "AparelhoId" uuid NULL;"""); + + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Setores_TenantId" ON "Setores" ("TenantId");"""); + migrationBuilder.Sql("""CREATE UNIQUE INDEX IF NOT EXISTS "IX_Setores_TenantId_Nome" ON "Setores" ("TenantId", "Nome");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Aparelhos_TenantId" ON "Aparelhos" ("TenantId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Aparelhos_Imei" ON "Aparelhos" ("Imei");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Aparelhos_TenantId_Nome_Cor" ON "Aparelhos" ("TenantId", "Nome", "Cor");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_MobileLines_CentroDeCustos" ON "MobileLines" ("CentroDeCustos");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_MobileLines_SetorId" ON "MobileLines" ("SetorId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_MobileLines_AparelhoId" ON "MobileLines" ("AparelhoId");"""); + + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_name = 'FK_MobileLines_Setores_SetorId' + AND table_name = 'MobileLines' + ) THEN + ALTER TABLE "MobileLines" + ADD CONSTRAINT "FK_MobileLines_Setores_SetorId" + FOREIGN KEY ("SetorId") REFERENCES "Setores" ("Id") + ON DELETE SET NULL; + END IF; + END + $$; + """); + + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_name = 'FK_MobileLines_Aparelhos_AparelhoId' + AND table_name = 'MobileLines' + ) THEN + ALTER TABLE "MobileLines" + ADD CONSTRAINT "FK_MobileLines_Aparelhos_AparelhoId" + FOREIGN KEY ("AparelhoId") REFERENCES "Aparelhos" ("Id") + ON DELETE SET NULL; + END IF; + END + $$; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("""ALTER TABLE "MobileLines" DROP CONSTRAINT IF EXISTS "FK_MobileLines_Setores_SetorId";"""); + migrationBuilder.Sql("""ALTER TABLE "MobileLines" DROP CONSTRAINT IF EXISTS "FK_MobileLines_Aparelhos_AparelhoId";"""); + + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_MobileLines_AparelhoId";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_MobileLines_SetorId";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_MobileLines_CentroDeCustos";"""); + + migrationBuilder.Sql("""ALTER TABLE "MobileLines" DROP COLUMN IF EXISTS "AparelhoId";"""); + migrationBuilder.Sql("""ALTER TABLE "MobileLines" DROP COLUMN IF EXISTS "SetorId";"""); + migrationBuilder.Sql("""ALTER TABLE "MobileLines" DROP COLUMN IF EXISTS "CentroDeCustos";"""); + + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Aparelhos_TenantId_Nome_Cor";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Aparelhos_Imei";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Aparelhos_TenantId";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Setores_TenantId_Nome";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Setores_TenantId";"""); + + migrationBuilder.Sql("""DROP TABLE IF EXISTS "Aparelhos";"""); + migrationBuilder.Sql("""DROP TABLE IF EXISTS "Setores";"""); + } + } +} diff --git a/Migrations/20260303193000_FixAparelhosArquivoPathColumns.cs b/Migrations/20260303193000_FixAparelhosArquivoPathColumns.cs new file mode 100644 index 0000000..5335ee7 --- /dev/null +++ b/Migrations/20260303193000_FixAparelhosArquivoPathColumns.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260303193000_FixAparelhosArquivoPathColumns")] + public partial class FixAparelhosArquivoPathColumns : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + ALTER TABLE "Aparelhos" + ADD COLUMN IF NOT EXISTS "NotaFiscalArquivoPath" character varying(500) NULL; + """); + + migrationBuilder.Sql(""" + ALTER TABLE "Aparelhos" + ADD COLUMN IF NOT EXISTS "ReciboArquivoPath" character varying(500) NULL; + """); + + // Backfill seguro para bancos que já tinham os campos antigos de URL/nome. + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'Aparelhos' + AND column_name = 'NotaFiscalAnexoUrl' + ) THEN + UPDATE "Aparelhos" + SET "NotaFiscalArquivoPath" = COALESCE("NotaFiscalArquivoPath", "NotaFiscalAnexoUrl") + WHERE "NotaFiscalAnexoUrl" IS NOT NULL + AND "NotaFiscalAnexoUrl" <> ''; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'Aparelhos' + AND column_name = 'ReciboAnexoUrl' + ) THEN + UPDATE "Aparelhos" + SET "ReciboArquivoPath" = COALESCE("ReciboArquivoPath", "ReciboAnexoUrl") + WHERE "ReciboAnexoUrl" IS NOT NULL + AND "ReciboAnexoUrl" <> ''; + END IF; + END + $$; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("""ALTER TABLE "Aparelhos" DROP COLUMN IF EXISTS "ReciboArquivoPath";"""); + migrationBuilder.Sql("""ALTER TABLE "Aparelhos" DROP COLUMN IF EXISTS "NotaFiscalArquivoPath";"""); + } + } +} + diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index 6d4fcfb..2a0857a 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -330,6 +330,52 @@ namespace line_gestao_api.Migrations b.ToTable("AuditLogs"); }); + modelBuilder.Entity("line_gestao_api.Models.Aparelho", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cor") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Imei") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Nome") + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("NotaFiscalArquivoPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReciboArquivoPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Imei"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Nome", "Cor"); + + b.ToTable("Aparelhos"); + }); + modelBuilder.Entity("line_gestao_api.Models.BillingClient", b => { b.Property("Id") @@ -624,10 +670,17 @@ namespace line_gestao_api.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("AparelhoId") + .HasColumnType("uuid"); + b.Property("Cedente") .HasMaxLength(150) .HasColumnType("character varying(150)"); + b.Property("CentroDeCustos") + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + b.Property("Chip") .HasMaxLength(40) .HasColumnType("character varying(40)"); @@ -691,6 +744,9 @@ namespace line_gestao_api.Migrations b.Property("Skeelo") .HasColumnType("numeric"); + b.Property("SetorId") + .HasColumnType("uuid"); + b.Property("Skil") .HasMaxLength(80) .HasColumnType("character varying(80)"); @@ -744,10 +800,16 @@ namespace line_gestao_api.Migrations b.HasKey("Id"); + b.HasIndex("AparelhoId"); + b.HasIndex("Chip"); + b.HasIndex("CentroDeCustos"); + b.HasIndex("Cliente"); + b.HasIndex("SetorId"); + b.HasIndex("Skil"); b.HasIndex("Status"); @@ -1370,6 +1432,36 @@ namespace line_gestao_api.Migrations b.ToTable("ResumoVivoLineTotals"); }); + modelBuilder.Entity("line_gestao_api.Models.Setor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Nome") + .IsUnique(); + + b.ToTable("Setores"); + }); + modelBuilder.Entity("line_gestao_api.Models.Tenant", b => { b.Property("Id") @@ -1665,6 +1757,23 @@ namespace line_gestao_api.Migrations .IsRequired(); }); + modelBuilder.Entity("line_gestao_api.Models.MobileLine", b => + { + b.HasOne("line_gestao_api.Models.Aparelho", "Aparelho") + .WithMany("MobileLines") + .HasForeignKey("AparelhoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("line_gestao_api.Models.Setor", "Setor") + .WithMany("MobileLines") + .HasForeignKey("SetorId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Aparelho"); + + b.Navigation("Setor"); + }); + modelBuilder.Entity("line_gestao_api.Models.MuregLine", b => { b.HasOne("line_gestao_api.Models.MobileLine", "MobileLine") @@ -1715,6 +1824,11 @@ namespace line_gestao_api.Migrations b.Navigation("ParcelamentoLine"); }); + modelBuilder.Entity("line_gestao_api.Models.Aparelho", b => + { + b.Navigation("MobileLines"); + }); + modelBuilder.Entity("line_gestao_api.Models.MobileLine", b => { b.Navigation("Muregs"); @@ -1729,6 +1843,11 @@ namespace line_gestao_api.Migrations { b.Navigation("MonthValues"); }); + + modelBuilder.Entity("line_gestao_api.Models.Setor", b => + { + b.Navigation("MobileLines"); + }); #pragma warning restore 612, 618 } } diff --git a/Models/Aparelho.cs b/Models/Aparelho.cs new file mode 100644 index 0000000..eb689d4 --- /dev/null +++ b/Models/Aparelho.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace line_gestao_api.Models; + +public class Aparelho : ITenantEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TenantId { get; set; } + + [MaxLength(160)] + public string? Nome { get; set; } + + [MaxLength(80)] + public string? Cor { get; set; } + + [MaxLength(80)] + public string? Imei { get; set; } + + [MaxLength(500)] + public string? NotaFiscalArquivoPath { get; set; } + + [MaxLength(500)] + public string? ReciboArquivoPath { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection MobileLines { get; set; } = new List(); +} diff --git a/Models/MobileLine.cs b/Models/MobileLine.cs index b21c6e3..db8bef5 100644 --- a/Models/MobileLine.cs +++ b/Models/MobileLine.cs @@ -20,6 +20,15 @@ namespace line_gestao_api.Models public string? Cliente { get; set; } [MaxLength(200)] public string? Usuario { get; set; } + [MaxLength(180)] + public string? CentroDeCustos { get; set; } + + public Guid? SetorId { get; set; } + public Setor? Setor { get; set; } + + public Guid? AparelhoId { get; set; } + public Aparelho? Aparelho { get; set; } + [MaxLength(200)] public string? PlanoContrato { get; set; } diff --git a/Models/Setor.cs b/Models/Setor.cs new file mode 100644 index 0000000..4efdbc6 --- /dev/null +++ b/Models/Setor.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace line_gestao_api.Models; + +public class Setor : ITenantEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid TenantId { get; set; } + + [MaxLength(160)] + public string Nome { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection MobileLines { get; set; } = new List(); +} diff --git a/uploads/aparelhos/39fbf134d67b4b83a23f620b7f70789b/f3930329c2064c55b5dbdb4be0f1cf86/nota-fiscal/20260303203447061_60cc0a41d55f41dda8fd371becdec429.pdf b/uploads/aparelhos/39fbf134d67b4b83a23f620b7f70789b/f3930329c2064c55b5dbdb4be0f1cf86/nota-fiscal/20260303203447061_60cc0a41d55f41dda8fd371becdec429.pdf new file mode 100644 index 0000000..b44ab61 Binary files /dev/null and b/uploads/aparelhos/39fbf134d67b4b83a23f620b7f70789b/f3930329c2064c55b5dbdb4be0f1cf86/nota-fiscal/20260303203447061_60cc0a41d55f41dda8fd371becdec429.pdf differ diff --git a/uploads/aparelhos/39fbf134d67b4b83a23f620b7f70789b/f3930329c2064c55b5dbdb4be0f1cf86/recibo/20260303203447072_3f383cb1fc9c4ead821c6df37b706dcf.pdf b/uploads/aparelhos/39fbf134d67b4b83a23f620b7f70789b/f3930329c2064c55b5dbdb4be0f1cf86/recibo/20260303203447072_3f383cb1fc9c4ead821c6df37b706dcf.pdf new file mode 100644 index 0000000..c507e9e Binary files /dev/null and b/uploads/aparelhos/39fbf134d67b4b83a23f620b7f70789b/f3930329c2064c55b5dbdb4be0f1cf86/recibo/20260303203447072_3f383cb1fc9c4ead821c6df37b706dcf.pdf differ