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 maxFutureDays = _options.MaxFutureDays <= 0 ? 30 : _options.MaxFutureDays; 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); await ApplyAutoRenewalsAsync(tenantId, today, userByName, userByEmail, cancellationToken); var vigencias = await _db.VigenciaLines.AsNoTracking() .Where(v => v.DtTerminoFidelizacao != null) .ToListAsync(cancellationToken); await CleanupOutdatedNotificationsAsync(vigencias, notifyAllFutureDates, reminderDays, today, maxFutureDays, 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 (daysUntil > maxFutureDays) { continue; } 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 ApplyAutoRenewalsAsync( Guid tenantId, DateTime todayUtc, IReadOnlyDictionary userByName, IReadOnlyDictionary userByEmail, CancellationToken cancellationToken) { var scheduledLines = await _db.VigenciaLines .Where(v => v.AutoRenewYears != null && v.AutoRenewReferenceEndDate != null && v.DtTerminoFidelizacao != null) .ToListAsync(cancellationToken); if (scheduledLines.Count == 0) { return; } var changed = false; var autoRenewNotifications = new List(); var nowUtc = DateTime.UtcNow; foreach (var vigencia in scheduledLines) { var years = NormalizeAutoRenewYears(vigencia.AutoRenewYears); if (!years.HasValue || !vigencia.DtTerminoFidelizacao.HasValue || !vigencia.AutoRenewReferenceEndDate.HasValue) { if (vigencia.AutoRenewYears.HasValue || vigencia.AutoRenewReferenceEndDate.HasValue || vigencia.AutoRenewConfiguredAt.HasValue) { ClearAutoRenewSchedule(vigencia); vigencia.UpdatedAt = nowUtc; changed = true; } continue; } var currentEndUtc = ToUtcDate(vigencia.DtTerminoFidelizacao.Value); var referenceEndUtc = ToUtcDate(vigencia.AutoRenewReferenceEndDate.Value); // As datas de vigência foram alteradas manualmente após o agendamento: // não renova automaticamente e limpa o agendamento. if (currentEndUtc != referenceEndUtc) { ClearAutoRenewSchedule(vigencia); vigencia.UpdatedAt = nowUtc; changed = true; continue; } // Só executa a renovação no vencimento (ou se já passou e segue sem alteração manual). if (currentEndUtc > todayUtc) { continue; } var newStartUtc = currentEndUtc.AddDays(1); var newEndUtc = currentEndUtc.AddYears(years.Value); vigencia.DtEfetivacaoServico = newStartUtc; vigencia.DtTerminoFidelizacao = newEndUtc; vigencia.LastAutoRenewedAt = nowUtc; ClearAutoRenewSchedule(vigencia); vigencia.UpdatedAt = nowUtc; changed = true; autoRenewNotifications.Add(BuildAutoRenewNotification( vigencia, years.Value, currentEndUtc, newEndUtc, ResolveUserId(vigencia.Usuario, userByName, userByEmail), tenantId)); } if (!changed && autoRenewNotifications.Count == 0) { return; } if (autoRenewNotifications.Count > 0) { var dedupKeys = autoRenewNotifications .Select(n => n.DedupKey) .Distinct(StringComparer.Ordinal) .ToList(); var existingDedupKeys = await _db.Notifications.AsNoTracking() .Where(n => dedupKeys.Contains(n.DedupKey)) .Select(n => n.DedupKey) .ToListAsync(cancellationToken); var existingSet = existingDedupKeys.ToHashSet(StringComparer.Ordinal); autoRenewNotifications = autoRenewNotifications .Where(n => !existingSet.Contains(n.DedupKey)) .ToList(); if (autoRenewNotifications.Count > 0) { await _db.Notifications.AddRangeAsync(autoRenewNotifications, cancellationToken); } } await _db.SaveChangesAsync(cancellationToken); } private async Task CleanupOutdatedNotificationsAsync( IReadOnlyCollection vigencias, bool notifyAllFutureDates, IReadOnlyCollection reminderDays, DateTime today, int maxFutureDays, CancellationToken cancellationToken) { if (maxFutureDays <= 0) { maxFutureDays = 30; } 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 (daysUntil > maxFutureDays) { idsToDelete.Add(notification.Id); continue; } 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 Notification BuildAutoRenewNotification( VigenciaLine vigencia, int years, DateTime previousEndUtc, DateTime newEndUtc, Guid? userId, Guid tenantId) { var linha = vigencia.Linha?.Trim(); var cliente = vigencia.Cliente?.Trim(); var usuario = vigencia.Usuario?.Trim(); var dedupKey = BuildAutoRenewDedupKey(tenantId, vigencia.Id, previousEndUtc, years); return new Notification { Tipo = "RenovacaoAutomatica", Titulo = $"Renovação automática concluída{FormatLinha(linha)}", Mensagem = $"A linha{FormatLinha(linha)} do cliente {cliente ?? "(sem cliente)"} foi renovada automaticamente por {years} ano(s): {previousEndUtc:dd/MM/yyyy} → {newEndUtc:dd/MM/yyyy}.", Data = DateTime.UtcNow, ReferenciaData = newEndUtc, DiasParaVencer = null, Lida = false, DedupKey = dedupKey, UserId = userId, Usuario = usuario, Cliente = cliente, Linha = linha, VigenciaLineId = vigencia.Id, 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 BuildAutoRenewDedupKey(Guid tenantId, Guid vigenciaLineId, DateTime referenceEndDateUtc, int years) { return string.Join('|', new[] { "renovacaoautomatica", tenantId.ToString("N"), vigenciaLineId.ToString("N"), referenceEndDateUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), years.ToString(CultureInfo.InvariantCulture) }); } private static Guid? ResolveUserId( string? usuario, IReadOnlyDictionary userByName, IReadOnlyDictionary userByEmail) { var key = usuario?.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(key)) { return null; } if (userByEmail.TryGetValue(key, out var byEmail)) { return byEmail; } if (userByName.TryGetValue(key, out var byName)) { return byName; } return null; } private static int? NormalizeAutoRenewYears(int? years) { return years == 2 ? years : null; } private static DateTime ToUtcDate(DateTime value) { return DateTime.SpecifyKind(value.Date, DateTimeKind.Utc); } private static void ClearAutoRenewSchedule(VigenciaLine vigencia) { vigencia.AutoRenewYears = null; vigencia.AutoRenewReferenceEndDate = null; vigencia.AutoRenewConfiguredAt = null; } private static string FormatLinha(string? linha) { return string.IsNullOrWhiteSpace(linha) ? string.Empty : $" {linha}"; } }