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