571 lines
19 KiB
C#
571 lines
19 KiB
C#
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<VigenciaNotificationSyncService> _logger;
|
|
|
|
public VigenciaNotificationSyncService(
|
|
AppDbContext db,
|
|
ITenantProvider tenantProvider,
|
|
IOptions<NotificationOptions> options,
|
|
ILogger<VigenciaNotificationSyncService> 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<Notification>();
|
|
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<Notification> 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<string>(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<string, Guid> userByName,
|
|
IReadOnlyDictionary<string, Guid> 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<Notification>();
|
|
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<VigenciaLine> vigencias,
|
|
bool notifyAllFutureDates,
|
|
IReadOnlyCollection<int> 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<Guid>();
|
|
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<Guid, VigenciaLine> vigenciasById,
|
|
IReadOnlyDictionary<string, VigenciaLine> 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<string, Guid> userByName,
|
|
IReadOnlyDictionary<string, Guid> 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}";
|
|
}
|
|
}
|