diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index 4e6815f..441a052 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -25,6 +25,7 @@ namespace line_gestao_api.Controllers { private readonly AppDbContext _db; private readonly ITenantProvider _tenantProvider; + private readonly IVigenciaNotificationSyncService _vigenciaNotificationSyncService; private readonly ParcelamentosImportService _parcelamentosImportService; private readonly SpreadsheetImportAuditService _spreadsheetImportAuditService; private static readonly List AccountCompanies = new() @@ -54,11 +55,13 @@ namespace line_gestao_api.Controllers public LinesController( AppDbContext db, ITenantProvider tenantProvider, + IVigenciaNotificationSyncService vigenciaNotificationSyncService, ParcelamentosImportService parcelamentosImportService, SpreadsheetImportAuditService spreadsheetImportAuditService) { _db = db; _tenantProvider = tenantProvider; + _vigenciaNotificationSyncService = vigenciaNotificationSyncService; _parcelamentosImportService = parcelamentosImportService; _spreadsheetImportAuditService = spreadsheetImportAuditService; } @@ -771,6 +774,7 @@ namespace line_gestao_api.Controllers try { await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); } catch (DbUpdateException) { @@ -842,7 +846,11 @@ namespace line_gestao_api.Controllers previousLinha: previousLinha); x.UpdatedAt = DateTime.UtcNow; - try { await _db.SaveChangesAsync(); } + try + { + await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); + } catch (DbUpdateException) { return Conflict(new { message = "Conflito ao salvar." }); } return NoContent(); @@ -859,6 +867,7 @@ namespace line_gestao_api.Controllers if (x == null) return NotFound(); _db.MobileLines.Remove(x); await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); return NoContent(); } diff --git a/Controllers/NotificationsController.cs b/Controllers/NotificationsController.cs index 17536f0..0c17b55 100644 --- a/Controllers/NotificationsController.cs +++ b/Controllers/NotificationsController.cs @@ -181,10 +181,20 @@ public class NotificationsController : ControllerBase worksheet.Cell(1, i + 1).Value = headers[i]; } + var headerBackground = XLColor.FromHtml("#E8EEFC"); + var headerText = XLColor.FromHtml("#1E3A8A"); + var borderColor = XLColor.FromHtml("#DBE2EF"); + var zebraBackground = XLColor.FromHtml("#F8FAFC"); + var bodyText = XLColor.FromHtml("#0F172A"); + var headerRange = worksheet.Range(1, 1, 1, headers.Length); headerRange.Style.Font.Bold = true; - headerRange.Style.Fill.BackgroundColor = XLColor.LightGray; + headerRange.Style.Font.FontName = "Segoe UI"; + headerRange.Style.Font.FontSize = 11; + headerRange.Style.Font.FontColor = headerText; + headerRange.Style.Fill.BackgroundColor = headerBackground; headerRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + headerRange.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; for (var i = 0; i < rows.Count; i++) { @@ -198,21 +208,46 @@ public class NotificationsController : ControllerBase worksheet.Cell(rowIndex, 6).Value = row.DataInicio; worksheet.Cell(rowIndex, 7).Value = row.DataReferencia; worksheet.Cell(rowIndex, 8).Value = row.Tipo.ToUpperInvariant(); + + var rowRange = worksheet.Range(rowIndex, 1, rowIndex, headers.Length); + rowRange.Style.Fill.BackgroundColor = i % 2 == 0 ? XLColor.White : zebraBackground; + rowRange.Style.Font.FontName = "Segoe UI"; + rowRange.Style.Font.FontSize = 10.5; + rowRange.Style.Font.FontColor = bodyText; + rowRange.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + + worksheet.Cell(rowIndex, 8).Style.Font.Bold = true; + worksheet.Cell(rowIndex, 8).Style.Font.FontColor = row.Tipo.Equals("Vencido", StringComparison.OrdinalIgnoreCase) + ? XLColor.FromHtml("#B91C1C") + : XLColor.FromHtml("#047857"); } - worksheet.Column(1).Width = 18; - worksheet.Column(2).Width = 18; - worksheet.Column(3).Width = 26; - worksheet.Column(4).Width = 24; - worksheet.Column(5).Width = 22; - worksheet.Column(6).Width = 16; - worksheet.Column(7).Width = 18; - worksheet.Column(8).Width = 14; + var tableRange = worksheet.Range(1, 1, Math.Max(2, rows.Count + 1), headers.Length); + tableRange.Style.Border.OutsideBorder = XLBorderStyleValues.Thin; + tableRange.Style.Border.OutsideBorderColor = borderColor; + tableRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin; + tableRange.Style.Border.InsideBorderColor = borderColor; + tableRange.SetAutoFilter(); + + worksheet.Column(6).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Column(7).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + worksheet.Column(8).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; worksheet.Column(6).Style.DateFormat.Format = "dd/MM/yyyy"; worksheet.Column(7).Style.DateFormat.Format = "dd/MM/yyyy"; - worksheet.Columns().AdjustToContents(); + worksheet.SheetView.FreezeRows(1); + worksheet.Columns(1, headers.Length).AdjustToContents(); + + var minWidths = new[] { 14d, 14d, 22d, 20d, 20d, 14d, 14d, 12d }; + for (var i = 0; i < minWidths.Length; i++) + { + var col = worksheet.Column(i + 1); + if (col.Width < minWidths[i]) + { + col.Width = minWidths[i]; + } + } using var stream = new MemoryStream(); workbook.SaveAs(stream); diff --git a/Controllers/VigenciaController.cs b/Controllers/VigenciaController.cs index d470f2f..5c1d4ed 100644 --- a/Controllers/VigenciaController.cs +++ b/Controllers/VigenciaController.cs @@ -16,10 +16,14 @@ namespace line_gestao_api.Controllers public class VigenciaController : ControllerBase { private readonly AppDbContext _db; + private readonly IVigenciaNotificationSyncService _vigenciaNotificationSyncService; - public VigenciaController(AppDbContext db) + public VigenciaController( + AppDbContext db, + IVigenciaNotificationSyncService vigenciaNotificationSyncService) { _db = db; + _vigenciaNotificationSyncService = vigenciaNotificationSyncService; } // GET /api/lines/vigencia (Linhas - Tabela Interna) @@ -316,6 +320,7 @@ namespace line_gestao_api.Controllers _db.VigenciaLines.Add(e); await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); return CreatedAtAction(nameof(GetById), new { id = e.Id }, new VigenciaLineDetailDto { @@ -356,6 +361,7 @@ namespace line_gestao_api.Controllers x.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); return NoContent(); } @@ -368,6 +374,7 @@ namespace line_gestao_api.Controllers _db.VigenciaLines.Remove(x); await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); return NoContent(); } diff --git a/Program.cs b/Program.cs index 8f52244..039b0ac 100644 --- a/Program.cs +++ b/Program.cs @@ -47,6 +47,7 @@ builder.Services.AddDbContext(options => builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Services/IVigenciaNotificationSyncService.cs b/Services/IVigenciaNotificationSyncService.cs new file mode 100644 index 0000000..e81517e --- /dev/null +++ b/Services/IVigenciaNotificationSyncService.cs @@ -0,0 +1,7 @@ +namespace line_gestao_api.Services; + +public interface IVigenciaNotificationSyncService +{ + Task SyncCurrentTenantAsync(CancellationToken cancellationToken = default); + Task SyncTenantAsync(Guid tenantId, CancellationToken cancellationToken = default); +} diff --git a/Services/NotificationOptions.cs b/Services/NotificationOptions.cs index 6627a5f..ba20378 100644 --- a/Services/NotificationOptions.cs +++ b/Services/NotificationOptions.cs @@ -3,5 +3,6 @@ namespace line_gestao_api.Services; public class NotificationOptions { public int CheckIntervalMinutes { get; set; } = 60; + public bool NotifyAllFutureDates { get; set; } = true; public List ReminderDays { get; set; } = new() { 30, 15, 7 }; } diff --git a/Services/VigenciaNotificationBackgroundService.cs b/Services/VigenciaNotificationBackgroundService.cs index 01ed00c..b6e5d9e 100644 --- a/Services/VigenciaNotificationBackgroundService.cs +++ b/Services/VigenciaNotificationBackgroundService.cs @@ -41,7 +41,7 @@ public class VigenciaNotificationBackgroundService : BackgroundService { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var tenantProvider = scope.ServiceProvider.GetRequiredService(); + var notificationSyncService = scope.ServiceProvider.GetRequiredService(); if (!await TableExistsAsync(db, "Notifications", stoppingToken)) { @@ -58,11 +58,8 @@ public class VigenciaNotificationBackgroundService : BackgroundService foreach (var tenant in tenants) { - tenantProvider.SetTenantId(tenant.Id); - await ProcessTenantAsync(db, tenant.Id, stoppingToken); + await notificationSyncService.SyncTenantAsync(tenant.Id, stoppingToken); } - - tenantProvider.SetTenantId(null); } catch (Exception ex) { diff --git a/Services/VigenciaNotificationSyncService.cs b/Services/VigenciaNotificationSyncService.cs new file mode 100644 index 0000000..187a452 --- /dev/null +++ b/Services/VigenciaNotificationSyncService.cs @@ -0,0 +1,361 @@ +using System.Globalization; +using line_gestao_api.Data; +using line_gestao_api.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace line_gestao_api.Services; + +public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService +{ + private readonly AppDbContext _db; + private readonly ITenantProvider _tenantProvider; + private readonly NotificationOptions _options; + private readonly ILogger _logger; + + public VigenciaNotificationSyncService( + AppDbContext db, + ITenantProvider tenantProvider, + IOptions options, + ILogger logger) + { + _db = db; + _tenantProvider = tenantProvider; + _options = options.Value; + _logger = logger; + } + + public async Task SyncCurrentTenantAsync(CancellationToken cancellationToken = default) + { + var tenantId = _tenantProvider.TenantId; + if (!tenantId.HasValue || tenantId.Value == Guid.Empty) + { + return; + } + + await SyncTenantAsync(tenantId.Value, cancellationToken); + } + + public async Task SyncTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + if (tenantId == Guid.Empty) + { + return; + } + + var previousTenant = _tenantProvider.TenantId; + + try + { + _tenantProvider.SetTenantId(tenantId); + await ProcessTenantAsync(tenantId, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Falha ao sincronizar notificações de vigência para tenant {TenantId}.", tenantId); + } + finally + { + _tenantProvider.SetTenantId(previousTenant); + } + } + + private async Task ProcessTenantAsync(Guid tenantId, CancellationToken cancellationToken) + { + var today = DateTime.SpecifyKind(DateTime.UtcNow.Date, DateTimeKind.Utc); + var notifyAllFutureDates = _options.NotifyAllFutureDates; + var reminderDays = _options.ReminderDays + .Distinct() + .Where(d => d > 0) + .OrderBy(d => d) + .ToList(); + + var users = await _db.Users.AsNoTracking() + .Select(u => new { u.Id, u.Name, u.Email }) + .ToListAsync(cancellationToken); + + var userByName = users + .Where(u => !string.IsNullOrWhiteSpace(u.Name)) + .ToDictionary(u => u.Name.Trim().ToLowerInvariant(), u => u.Id); + + var userByEmail = users + .Where(u => !string.IsNullOrWhiteSpace(u.Email)) + .ToDictionary(u => u.Email!.Trim().ToLowerInvariant(), u => u.Id); + + var vigencias = await _db.VigenciaLines.AsNoTracking() + .Where(v => v.DtTerminoFidelizacao != null) + .ToListAsync(cancellationToken); + + await CleanupOutdatedNotificationsAsync(vigencias, notifyAllFutureDates, reminderDays, today, cancellationToken); + + var candidates = new List(); + foreach (var vigencia in vigencias) + { + if (vigencia.DtTerminoFidelizacao is null) + { + continue; + } + + var endDate = DateTime.SpecifyKind(vigencia.DtTerminoFidelizacao.Value.Date, DateTimeKind.Utc); + var usuario = vigencia.Usuario?.Trim(); + var cliente = vigencia.Cliente?.Trim(); + var linha = vigencia.Linha?.Trim(); + var usuarioKey = usuario?.ToLowerInvariant(); + + Guid? userId = null; + if (!string.IsNullOrWhiteSpace(usuarioKey)) + { + if (userByEmail.TryGetValue(usuarioKey, out var matchedByEmail)) + { + userId = matchedByEmail; + } + else if (userByName.TryGetValue(usuarioKey, out var matchedByName)) + { + userId = matchedByName; + } + } + + if (endDate < today) + { + candidates.Add(BuildNotification( + tipo: "Vencido", + titulo: $"Linha vencida{FormatLinha(linha)}", + mensagem: $"A linha{FormatLinha(linha)} do cliente {cliente ?? "(sem cliente)"} venceu em {endDate:dd/MM/yyyy}.", + referenciaData: endDate, + diasParaVencer: 0, + userId: userId, + usuario: usuario, + cliente: cliente, + linha: linha, + vigenciaLineId: vigencia.Id, + tenantId: tenantId)); + continue; + } + + var daysUntil = (endDate - today).Days; + if (!notifyAllFutureDates && !reminderDays.Contains(daysUntil)) + { + continue; + } + + candidates.Add(BuildNotification( + tipo: "AVencer", + titulo: $"Linha a vencer em {daysUntil} dias{FormatLinha(linha)}", + mensagem: $"A linha{FormatLinha(linha)} do cliente {cliente ?? "(sem cliente)"} vence em {endDate:dd/MM/yyyy}.", + referenciaData: endDate, + diasParaVencer: daysUntil, + userId: userId, + usuario: usuario, + cliente: cliente, + linha: linha, + vigenciaLineId: vigencia.Id, + tenantId: tenantId)); + } + + if (candidates.Count == 0) + { + return; + } + + var candidateTipos = candidates.Select(c => c.Tipo).Distinct().ToList(); + var candidateDates = candidates + .Where(c => c.ReferenciaData.HasValue) + .Select(c => DateTime.SpecifyKind(c.ReferenciaData!.Value.Date, DateTimeKind.Utc)) + .Distinct() + .ToList(); + + List existingNotifications = new(); + if (candidateDates.Count > 0) + { + var minCandidateUtc = candidateDates.Min(); + var maxCandidateUtcExclusive = candidateDates.Max().AddDays(1); + var candidateDateKeys = candidateDates + .Select(d => d.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)) + .ToHashSet(); + + existingNotifications = await _db.Notifications.AsNoTracking() + .Where(n => n.TenantId == tenantId) + .Where(n => candidateTipos.Contains(n.Tipo)) + .Where(n => n.ReferenciaData != null && + n.ReferenciaData.Value >= minCandidateUtc && + n.ReferenciaData.Value < maxCandidateUtcExclusive) + .ToListAsync(cancellationToken); + + existingNotifications = existingNotifications + .Where(n => n.ReferenciaData != null && + candidateDateKeys.Contains(n.ReferenciaData.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))) + .ToList(); + } + + var existingSet = new HashSet(existingNotifications.Select(n => + BuildDedupKey( + n.Tipo, + n.ReferenciaData!.Value, + n.DiasParaVencer ?? 0, + n.Usuario, + n.Cliente, + n.Linha))); + var toInsert = candidates + .Where(c => !existingSet.Contains(c.DedupKey)) + .ToList(); + + if (toInsert.Count == 0) + { + return; + } + + await _db.Notifications.AddRangeAsync(toInsert, cancellationToken); + await _db.SaveChangesAsync(cancellationToken); + } + + private async Task CleanupOutdatedNotificationsAsync( + IReadOnlyCollection vigencias, + bool notifyAllFutureDates, + IReadOnlyCollection reminderDays, + DateTime today, + CancellationToken cancellationToken) + { + var vigenciasById = vigencias.ToDictionary(v => v.Id, v => v); + var vigenciasByLinha = vigencias + .Where(v => !string.IsNullOrWhiteSpace(v.Linha)) + .GroupBy(v => v.Linha!) + .Select(g => g.OrderByDescending(v => v.UpdatedAt).First()) + .ToDictionary(v => v.Linha!, v => v); + + var existingNotifications = await _db.Notifications.AsNoTracking() + .Where(n => n.Tipo == "Vencido" || n.Tipo == "AVencer") + .ToListAsync(cancellationToken); + + if (existingNotifications.Count == 0) + { + return; + } + + var idsToDelete = new List(); + foreach (var notification in existingNotifications) + { + var vigencia = ResolveVigencia(notification, vigenciasById, vigenciasByLinha); + if (vigencia?.DtTerminoFidelizacao is null) + { + idsToDelete.Add(notification.Id); + continue; + } + + var endDate = DateTime.SpecifyKind(vigencia.DtTerminoFidelizacao.Value.Date, DateTimeKind.Utc); + if (endDate < today) + { + if (notification.Tipo != "Vencido") + { + idsToDelete.Add(notification.Id); + } + + continue; + } + + var daysUntil = (endDate - today).Days; + if (notification.Tipo == "Vencido") + { + idsToDelete.Add(notification.Id); + continue; + } + + if (notification.DiasParaVencer != daysUntil) + { + idsToDelete.Add(notification.Id); + continue; + } + + if (!notifyAllFutureDates && !reminderDays.Contains(daysUntil)) + { + idsToDelete.Add(notification.Id); + } + } + + if (idsToDelete.Count == 0) + { + return; + } + + await _db.Notifications + .Where(n => idsToDelete.Contains(n.Id)) + .ExecuteDeleteAsync(cancellationToken); + } + + private static VigenciaLine? ResolveVigencia( + Notification notification, + IReadOnlyDictionary vigenciasById, + IReadOnlyDictionary vigenciasByLinha) + { + if (notification.VigenciaLineId.HasValue + && vigenciasById.TryGetValue(notification.VigenciaLineId.Value, out var byId)) + { + return byId; + } + + if (!string.IsNullOrWhiteSpace(notification.Linha) + && vigenciasByLinha.TryGetValue(notification.Linha, out var byLinha)) + { + return byLinha; + } + + return null; + } + + private static Notification BuildNotification( + string tipo, + string titulo, + string mensagem, + DateTime referenciaData, + int diasParaVencer, + Guid? userId, + string? usuario, + string? cliente, + string? linha, + Guid vigenciaLineId, + Guid tenantId) + { + return new Notification + { + Tipo = tipo, + Titulo = titulo, + Mensagem = mensagem, + Data = DateTime.UtcNow, + ReferenciaData = referenciaData, + DiasParaVencer = diasParaVencer, + Lida = false, + DedupKey = BuildDedupKey(tipo, referenciaData, diasParaVencer, usuario, cliente, linha), + UserId = userId, + Usuario = usuario, + Cliente = cliente, + Linha = linha, + VigenciaLineId = vigenciaLineId, + TenantId = tenantId + }; + } + + private static string BuildDedupKey( + string tipo, + DateTime referenciaData, + int diasParaVencer, + string? usuario, + string? cliente, + string? linha) + { + var parts = new[] + { + tipo.Trim().ToLowerInvariant(), + referenciaData.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + diasParaVencer.ToString(CultureInfo.InvariantCulture), + (usuario ?? string.Empty).Trim().ToLowerInvariant(), + (cliente ?? string.Empty).Trim().ToLowerInvariant(), + (linha ?? string.Empty).Trim().ToLowerInvariant() + }; + + return string.Join('|', parts); + } + + private static string FormatLinha(string? linha) + { + return string.IsNullOrWhiteSpace(linha) ? string.Empty : $" {linha}"; + } +} diff --git a/appsettings.Development.json b/appsettings.Development.json index 0bc8a6a..2ce8bdf 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -24,6 +24,7 @@ }, "Notifications": { "CheckIntervalMinutes": 60, + "NotifyAllFutureDates": true, "ReminderDays": [30, 15, 7] }, "Seed": { diff --git a/appsettings.json b/appsettings.json index 381f9cf..f5bbc10 100644 --- a/appsettings.json +++ b/appsettings.json @@ -16,6 +16,7 @@ }, "Notifications": { "CheckIntervalMinutes": 60, + "NotifyAllFutureDates": true, "ReminderDays": [30, 15, 7] }, "Seed": {