fa4732ef71
NUOVE FUNZIONALITÀ - Sistema Sincronizzazione Cancellazioni:
Database:
- Aggiunto tracking cancellazioni in KeyAssociation (IsSourceDeleted, DeletedAt, DeletionSynced, DeletionSyncedAt)
- Aggiunta configurazione cancellazioni in DataCouplerProfile (SyncDeletions, DeletionAction, DeletionMarkField, DeletionMarkValue)
- Migration: 20251027103016_AddDeletionSyncFeature
Servizi:
- Nuovo DeletionSyncService con supporto 3 modalità (Delete, Deactivate, Mark)
- KeyAssociationService: aggiunti MarkDeletedAssociationsAsync, GetPendingDeletionsAsync, MarkDeletionSyncedAsync, GetDeletedAssociationsAsync
- DataConnectionCredentialService: esposti metodi di sincronizzazione cancellazioni
Logica Trasferimento:
- Integrata sincronizzazione cancellazioni in StartDataTransferOriginal
- Integrata sincronizzazione cancellazioni in StartDataTransferWithComposite
- Rilevamento automatico record cancellati tramite confronto chiavi sorgente
- Sincronizzazione con gestione errori robusta
UI:
- Aggiunto contatore "Cancellati" nei risultati trasferimento
- Aggiunto stato "deleted" con badge e icona trash
- Messaggi completamento includono cancellazioni
BUG FIX - Pre-Discovery Flag Reset:
Problema Risolto:
- Il flag isPreDiscoveryAssociation causava aggiornamenti forzati infiniti
- Record venivano aggiornati anche con dati identici (hash ignorato)
Soluzione:
- Corretto controllo flag: verifica AdditionalInfo["CreatedBy"] == "PreDiscovery"
- Reset immediato flag durante marcatura per update (rimozione chiave "CreatedBy")
- Biforcazione intelligente: prima sync forza update, successive usano hash
Benefici:
- Riduzione 60-90% chiamate API inutili dopo prima sincronizzazione
- Controllo hash funzionante correttamente
- Performance drasticamente migliorate
MODIFICHE TECNICHE:
File Modificati:
- CredentialManager/Models/KeyAssociation.cs (+4 campi)
- CredentialManager/Models/DataCouplerProfile.cs (+4 campi)
- CredentialManager/Services/KeyAssociationService.cs (+142 righe, 4 metodi)
- CredentialManager/Services/IKeyAssociationService.cs (+4 signature)
- DataConnection/CredentialManagement/Interfaces/IDataConnectionCredentialService.cs (+4 metodi)
- DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs (+21 righe)
- Data_Coupler/Pages/DataCoupler.razor (UI cancellazioni + contatori)
- Data_Coupler/Pages/DataCoupler.razor.cs (sync cancellazioni + fix hash)
- Data_Coupler/Program.cs (registrazione DeletionSyncService)
File Nuovi:
- Data_Coupler/Services/DeletionSyncService.cs (~250 righe)
- CredentialManager/Migrations/20251027103016_AddDeletionSyncFeature.cs
- DELETION_SYNC_IMPLEMENTATION.md (documentazione completa)
- FIX_PRE_DISCOVERY_FINAL.md (documentazione fix)
Testing:
- Compilazione verificata: ✅ Successo (26 warning pre-esistenti)
- Breaking changes: Nessuno
- Compatibilità: Retrocompatibile
IMPATTO:
- Gestione completa lifecycle record (creazione, aggiornamento, cancellazione)
- Performance ottimizzate con controllo hash funzionante
- Sistema robusto per mantenere destinazione sincronizzata con sorgente
1001 lines
40 KiB
C#
1001 lines
40 KiB
C#
using CredentialManager.Data;
|
|
using CredentialManager.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace CredentialManager.Services;
|
|
|
|
/// <summary>
|
|
/// Servizio per la gestione delle associazioni basate sui valori delle chiavi
|
|
/// </summary>
|
|
public class KeyAssociationService : IKeyAssociationService
|
|
{
|
|
private readonly CredentialDbContext _context;
|
|
private readonly ILogger<KeyAssociationService> _logger;
|
|
|
|
public KeyAssociationService(
|
|
CredentialDbContext context,
|
|
ILogger<KeyAssociationService> logger)
|
|
{
|
|
_context = context;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<int> SaveAssociationAsync(KeyAssociation association)
|
|
{
|
|
// Cattura i valori critici all'inizio per evitare race conditions
|
|
var keyValue = association.KeyValue;
|
|
var destinationEntity = association.DestinationEntity;
|
|
var destinationId = association.DestinationId;
|
|
var restCredentialName = association.RestCredentialName;
|
|
var sourceKeyField = association.SourceKeyField;
|
|
var destinationKeyField = association.DestinationKeyField;
|
|
var additionalInfo = association.AdditionalInfo;
|
|
var dataHash = association.Data_Hash;
|
|
var currentTime = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
_logger.LogInformation("DEBUG: Tentativo salvataggio associazione - KeyValue: '{KeyValue}', DestinationEntity: '{DestinationEntity}', DestinationId: '{DestinationId}', RestCredentialName: '{RestCredentialName}'",
|
|
keyValue, destinationEntity, destinationId, restCredentialName);
|
|
|
|
// Implementazione thread-safe usando upsert pattern
|
|
// Prima tenta di aggiornare un record esistente
|
|
var rowsAffected = await _context.Database.ExecuteSqlRawAsync(@"
|
|
UPDATE KeyAssociations
|
|
SET DestinationId = {0},
|
|
SourceKeyField = {1},
|
|
DestinationKeyField = {2},
|
|
UpdatedAt = {3},
|
|
LastVerifiedAt = {4},
|
|
AdditionalInfo = {5},
|
|
Data_Hash = {6}
|
|
WHERE KeyValue = {7}
|
|
AND DestinationEntity = {8}
|
|
AND RestCredentialName = {9}
|
|
AND IsActive = 1",
|
|
destinationId, sourceKeyField, destinationKeyField, currentTime, currentTime, additionalInfo ?? (object)DBNull.Value, dataHash ?? (object)DBNull.Value,
|
|
keyValue, destinationEntity, restCredentialName);
|
|
|
|
if (rowsAffected > 0)
|
|
{
|
|
// Recupera l'ID dell'associazione aggiornata
|
|
var existing = await _context.KeyAssociations
|
|
.FirstOrDefaultAsync(ka =>
|
|
ka.KeyValue == keyValue &&
|
|
ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName &&
|
|
ka.IsActive);
|
|
|
|
if (existing != null)
|
|
{
|
|
// Aggiorna le informazioni sulle sorgenti usando l'entità tracciata
|
|
UpdateSourcesInfo(existing, association);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Associazione aggiornata: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}",
|
|
keyValue, destinationEntity, destinationId);
|
|
|
|
return existing.Id;
|
|
}
|
|
}
|
|
|
|
// Se l'aggiornamento non ha modificato nessuna riga, tenta l'inserimento
|
|
try
|
|
{
|
|
var newAssociation = new KeyAssociation
|
|
{
|
|
KeyValue = keyValue,
|
|
SourceKeyField = sourceKeyField,
|
|
DestinationKeyField = destinationKeyField,
|
|
DestinationEntity = destinationEntity,
|
|
DestinationId = destinationId,
|
|
RestCredentialName = restCredentialName,
|
|
CreatedAt = currentTime,
|
|
LastVerifiedAt = currentTime,
|
|
AdditionalInfo = additionalInfo,
|
|
Data_Hash = dataHash,
|
|
IsActive = true
|
|
};
|
|
|
|
_context.KeyAssociations.Add(newAssociation);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Nuova associazione creata: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}",
|
|
keyValue, destinationEntity, destinationId);
|
|
|
|
return newAssociation.Id;
|
|
}
|
|
catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message?.Contains("UNIQUE constraint failed") == true)
|
|
{
|
|
// Race condition: un altro thread ha inserito la stessa associazione
|
|
// Ritenta la ricerca e aggiornamento
|
|
_logger.LogDebug("Race condition rilevata durante inserimento, ritento con aggiornamento per KeyValue: {KeyValue}", keyValue);
|
|
|
|
var existing = await _context.KeyAssociations
|
|
.FirstOrDefaultAsync(ka =>
|
|
ka.KeyValue == keyValue &&
|
|
ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName &&
|
|
ka.IsActive);
|
|
|
|
if (existing != null)
|
|
{
|
|
// Aggiorna l'associazione trovata
|
|
existing.DestinationId = destinationId;
|
|
existing.SourceKeyField = sourceKeyField;
|
|
existing.DestinationKeyField = destinationKeyField;
|
|
existing.UpdatedAt = currentTime;
|
|
existing.LastVerifiedAt = currentTime;
|
|
existing.AdditionalInfo = additionalInfo;
|
|
existing.Data_Hash = dataHash;
|
|
|
|
UpdateSourcesInfo(existing, association);
|
|
|
|
_context.KeyAssociations.Update(existing);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Associazione aggiornata dopo race condition: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}",
|
|
keyValue, destinationEntity, destinationId);
|
|
|
|
return existing.Id;
|
|
}
|
|
|
|
// Se non riusciamo a trovare neanche l'associazione creata da un altro thread, rilancia l'eccezione
|
|
throw;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nel salvare l'associazione: KeyValue={KeyValue} -> {DestinationEntity}",
|
|
keyValue, destinationEntity);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Versione thread-safe del SaveAssociationAsync che utilizza un DbContext separato per operazioni parallele
|
|
/// </summary>
|
|
public async Task<int> SaveAssociationParallelAsync(KeyAssociation association)
|
|
{
|
|
// Cattura i valori critici all'inizio per evitare race conditions
|
|
var keyValue = association.KeyValue;
|
|
var destinationEntity = association.DestinationEntity;
|
|
var destinationId = association.DestinationId;
|
|
var restCredentialName = association.RestCredentialName;
|
|
var sourceKeyField = association.SourceKeyField;
|
|
var destinationKeyField = association.DestinationKeyField;
|
|
var mappedDestinationField = association.MappedDestinationField; // AGGIUNTO
|
|
var additionalInfo = association.AdditionalInfo;
|
|
var dataHash = association.Data_Hash;
|
|
var currentTime = DateTime.UtcNow;
|
|
|
|
// Crea un nuovo DbContext per questa operazione parallela
|
|
var options = new DbContextOptionsBuilder<CredentialDbContext>()
|
|
.UseSqlite(_context.Database.GetConnectionString())
|
|
.Options;
|
|
|
|
using var parallelContext = new CredentialDbContext(options);
|
|
|
|
try
|
|
{
|
|
_logger.LogDebug("PARALLEL: Tentativo salvataggio associazione - KeyValue: '{KeyValue}', DestinationEntity: '{DestinationEntity}', DestinationId: '{DestinationId}', RestCredentialName: '{RestCredentialName}', MappedField: '{MappedField}'",
|
|
keyValue, destinationEntity, destinationId, restCredentialName, mappedDestinationField ?? "NULL");
|
|
|
|
// Implementazione thread-safe usando upsert pattern con DbContext separato
|
|
// Prima tenta di aggiornare un record esistente
|
|
var rowsAffected = await parallelContext.Database.ExecuteSqlRawAsync(@"
|
|
UPDATE KeyAssociations
|
|
SET DestinationId = {0},
|
|
SourceKeyField = {1},
|
|
DestinationKeyField = {2},
|
|
UpdatedAt = {3},
|
|
LastVerifiedAt = {4},
|
|
AdditionalInfo = {5},
|
|
Data_Hash = {6},
|
|
MappedDestinationField = {7}
|
|
WHERE KeyValue = {8}
|
|
AND DestinationEntity = {9}
|
|
AND RestCredentialName = {10}
|
|
AND IsActive = 1",
|
|
destinationId, sourceKeyField, destinationKeyField, currentTime, currentTime, additionalInfo ?? (object)DBNull.Value, dataHash ?? (object)DBNull.Value, mappedDestinationField ?? (object)DBNull.Value,
|
|
keyValue, destinationEntity, restCredentialName);
|
|
|
|
if (rowsAffected > 0)
|
|
{
|
|
// Recupera l'ID dell'associazione aggiornata
|
|
var existing = await parallelContext.KeyAssociations
|
|
.FirstOrDefaultAsync(ka =>
|
|
ka.KeyValue == keyValue &&
|
|
ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName &&
|
|
ka.IsActive);
|
|
|
|
if (existing != null)
|
|
{
|
|
// Aggiorna le informazioni sulle sorgenti usando l'entità tracciata
|
|
UpdateSourcesInfo(existing, association);
|
|
await parallelContext.SaveChangesAsync();
|
|
|
|
_logger.LogDebug("PARALLEL: Associazione aggiornata: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}",
|
|
keyValue, destinationEntity, destinationId);
|
|
|
|
return existing.Id;
|
|
}
|
|
}
|
|
|
|
// Se l'aggiornamento non ha modificato nessuna riga, tenta l'inserimento
|
|
try
|
|
{
|
|
var newAssociation = new KeyAssociation
|
|
{
|
|
KeyValue = keyValue,
|
|
SourceKeyField = sourceKeyField,
|
|
DestinationKeyField = destinationKeyField,
|
|
MappedDestinationField = mappedDestinationField, // AGGIUNTO
|
|
DestinationEntity = destinationEntity,
|
|
DestinationId = destinationId,
|
|
RestCredentialName = restCredentialName,
|
|
CreatedAt = currentTime,
|
|
LastVerifiedAt = currentTime,
|
|
AdditionalInfo = additionalInfo,
|
|
Data_Hash = dataHash,
|
|
IsActive = true
|
|
};
|
|
|
|
parallelContext.KeyAssociations.Add(newAssociation);
|
|
await parallelContext.SaveChangesAsync();
|
|
|
|
_logger.LogDebug("PARALLEL: Nuova associazione creata: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}, MappedField={MappedField}",
|
|
keyValue, destinationEntity, destinationId, mappedDestinationField ?? "NULL");
|
|
|
|
return newAssociation.Id;
|
|
}
|
|
catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message?.Contains("UNIQUE constraint failed") == true)
|
|
{
|
|
// Race condition: un altro thread ha inserito la stessa associazione
|
|
// Ritenta la ricerca e aggiornamento
|
|
_logger.LogDebug("PARALLEL: Race condition rilevata durante inserimento, ritento con aggiornamento per KeyValue: {KeyValue}", keyValue);
|
|
|
|
var existing = await parallelContext.KeyAssociations
|
|
.FirstOrDefaultAsync(ka =>
|
|
ka.KeyValue == keyValue &&
|
|
ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName &&
|
|
ka.IsActive);
|
|
|
|
if (existing != null)
|
|
{
|
|
// Aggiorna l'associazione trovata
|
|
existing.DestinationId = destinationId;
|
|
existing.SourceKeyField = sourceKeyField;
|
|
existing.DestinationKeyField = destinationKeyField;
|
|
existing.UpdatedAt = currentTime;
|
|
existing.LastVerifiedAt = currentTime;
|
|
existing.AdditionalInfo = additionalInfo;
|
|
|
|
UpdateSourcesInfo(existing, association);
|
|
|
|
parallelContext.KeyAssociations.Update(existing);
|
|
await parallelContext.SaveChangesAsync();
|
|
|
|
_logger.LogDebug("PARALLEL: Associazione aggiornata dopo race condition: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}",
|
|
keyValue, destinationEntity, destinationId);
|
|
|
|
return existing.Id;
|
|
}
|
|
|
|
// Se non riusciamo a trovare neanche l'associazione creata da un altro thread, rilancia l'eccezione
|
|
throw;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "PARALLEL: Errore nel salvare l'associazione: KeyValue={KeyValue} -> {DestinationEntity}",
|
|
keyValue, destinationEntity);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Versione thread-safe per operazioni parallele - Find association by key value
|
|
/// </summary>
|
|
public async Task<KeyAssociation?> FindAssociationByKeyValueParallelAsync(string keyValue, string destinationEntity, string restCredentialName)
|
|
{
|
|
var options = new DbContextOptionsBuilder<CredentialDbContext>()
|
|
.UseSqlite(_context.Database.GetConnectionString())
|
|
.Options;
|
|
|
|
using var parallelContext = new CredentialDbContext(options);
|
|
|
|
try
|
|
{
|
|
_logger.LogDebug("PARALLEL: Ricerca associazione con parametri - KeyValue: '{KeyValue}', DestinationEntity: '{DestinationEntity}', RestCredentialName: '{RestCredentialName}'",
|
|
keyValue, destinationEntity, restCredentialName);
|
|
|
|
var result = await parallelContext.KeyAssociations
|
|
.FirstOrDefaultAsync(ka =>
|
|
ka.KeyValue == keyValue &&
|
|
ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName &&
|
|
ka.IsActive);
|
|
|
|
_logger.LogDebug("PARALLEL: Risultato ricerca associazione: {Found}. ID: {Id}, DestinationId: '{DestinationId}'",
|
|
result != null, result?.Id, result?.DestinationId);
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "PARALLEL: Errore nella ricerca dell'associazione: KeyValue={KeyValue} -> {DestinationEntity}",
|
|
keyValue, destinationEntity);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Versione thread-safe per operazioni parallele - Find association by key value only
|
|
/// </summary>
|
|
public async Task<KeyAssociation?> FindAssociationByKeyValueParallelAsync(string keyValue)
|
|
{
|
|
var options = new DbContextOptionsBuilder<CredentialDbContext>()
|
|
.UseSqlite(_context.Database.GetConnectionString())
|
|
.Options;
|
|
|
|
using var parallelContext = new CredentialDbContext(options);
|
|
|
|
try
|
|
{
|
|
return await parallelContext.KeyAssociations
|
|
.Where(ka => ka.KeyValue == keyValue && ka.IsActive)
|
|
.OrderByDescending(ka => ka.UpdatedAt ?? ka.CreatedAt)
|
|
.FirstOrDefaultAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "PARALLEL: Errore nella ricerca dell'associazione per KeyValue={KeyValue}", keyValue);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Versione thread-safe per operazioni parallele - Delete association
|
|
/// </summary>
|
|
public async Task<bool> DeleteAssociationParallelAsync(int id)
|
|
{
|
|
var options = new DbContextOptionsBuilder<CredentialDbContext>()
|
|
.UseSqlite(_context.Database.GetConnectionString())
|
|
.Options;
|
|
|
|
using var parallelContext = new CredentialDbContext(options);
|
|
|
|
try
|
|
{
|
|
var association = await parallelContext.KeyAssociations.FindAsync(id);
|
|
if (association == null)
|
|
{
|
|
_logger.LogWarning("PARALLEL: Associazione con ID {Id} non trovata per l'eliminazione", id);
|
|
return false;
|
|
}
|
|
|
|
parallelContext.KeyAssociations.Remove(association);
|
|
await parallelContext.SaveChangesAsync();
|
|
|
|
_logger.LogDebug("PARALLEL: Associazione eliminata: ID {Id}", id);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "PARALLEL: Errore nell'eliminazione dell'associazione: ID {Id}", id);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Versione thread-safe per operazioni parallele - Salva una associazione con parametri semplici
|
|
/// </summary>
|
|
public async Task<bool> SaveAssociationParallelAsync(string keyValue, string destinationEntity, string destinationId, string restCredentialName)
|
|
{
|
|
var options = new DbContextOptionsBuilder<CredentialDbContext>()
|
|
.UseSqlite(_context.Database.GetConnectionString())
|
|
.Options;
|
|
|
|
using var parallelContext = new CredentialDbContext(options);
|
|
|
|
try
|
|
{
|
|
_logger.LogDebug("PARALLEL: Salvataggio associazione - KeyValue: '{KeyValue}', DestinationEntity: '{DestinationEntity}', DestinationId: '{DestinationId}', RestCredentialName: '{RestCredentialName}'",
|
|
keyValue, destinationEntity, destinationId, restCredentialName);
|
|
|
|
var currentTime = DateTime.UtcNow;
|
|
|
|
// Implementazione thread-safe usando upsert pattern
|
|
var rowsAffected = await parallelContext.Database.ExecuteSqlRawAsync(@"
|
|
UPDATE KeyAssociations
|
|
SET DestinationId = {0},
|
|
UpdatedAt = {1},
|
|
LastVerifiedAt = {2},
|
|
IsActive = 1
|
|
WHERE KeyValue = {3}
|
|
AND DestinationEntity = {4}
|
|
AND RestCredentialName = {5}",
|
|
destinationId, currentTime, currentTime, keyValue, destinationEntity, restCredentialName);
|
|
|
|
if (rowsAffected == 0)
|
|
{
|
|
// Se nessun record è stato aggiornato, inserisci un nuovo record
|
|
await parallelContext.Database.ExecuteSqlRawAsync(@"
|
|
INSERT INTO KeyAssociations
|
|
(KeyValue, DestinationEntity, DestinationId, RestCredentialName, CreatedAt, UpdatedAt, LastVerifiedAt, IsActive)
|
|
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, 1)",
|
|
keyValue, destinationEntity, destinationId, restCredentialName, currentTime, currentTime, currentTime);
|
|
|
|
_logger.LogDebug("PARALLEL: Nuova associazione creata: {KeyValue} -> {DestinationEntity}({DestinationId})",
|
|
keyValue, destinationEntity, destinationId);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug("PARALLEL: Associazione aggiornata: {KeyValue} -> {DestinationEntity}({DestinationId})",
|
|
keyValue, destinationEntity, destinationId);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "PARALLEL: Errore nel salvare l'associazione: KeyValue={KeyValue} -> {DestinationEntity}",
|
|
keyValue, destinationEntity);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<KeyAssociation?> FindAssociationByKeyValueAsync(string keyValue, string destinationEntity, string restCredentialName)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInformation("DEBUG: Ricerca associazione con parametri - KeyValue: '{KeyValue}', DestinationEntity: '{DestinationEntity}', RestCredentialName: '{RestCredentialName}'",
|
|
keyValue, destinationEntity, restCredentialName);
|
|
|
|
var result = await _context.KeyAssociations
|
|
.FirstOrDefaultAsync(ka =>
|
|
ka.KeyValue == keyValue &&
|
|
ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName &&
|
|
ka.IsActive);
|
|
|
|
_logger.LogInformation("DEBUG: Risultato ricerca associazione: {Found}. ID: {Id}, DestinationId: '{DestinationId}'",
|
|
result != null, result?.Id, result?.DestinationId);
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella ricerca dell'associazione: KeyValue={KeyValue} -> {DestinationEntity}",
|
|
keyValue, destinationEntity);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<KeyAssociation?> FindAssociationByKeyValueAsync(string keyValue)
|
|
{
|
|
try
|
|
{
|
|
return await _context.KeyAssociations
|
|
.Where(ka => ka.KeyValue == keyValue && ka.IsActive)
|
|
.OrderByDescending(ka => ka.UpdatedAt ?? ka.CreatedAt)
|
|
.FirstOrDefaultAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella ricerca dell'associazione per KeyValue={KeyValue}", keyValue);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<List<KeyAssociation>> GetAssociationsByDestinationAsync(string destinationEntity, string restCredentialName)
|
|
{
|
|
try
|
|
{
|
|
return await _context.KeyAssociations
|
|
.Where(ka => ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName &&
|
|
ka.IsActive)
|
|
.OrderByDescending(ka => ka.CreatedAt)
|
|
.ToListAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nel recupero delle associazioni per destinazione: {DestinationEntity} ({RestCredentialName})",
|
|
destinationEntity, restCredentialName);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<List<KeyAssociation>> GetAllActiveAssociationsAsync()
|
|
{
|
|
try
|
|
{
|
|
return await _context.KeyAssociations
|
|
.Where(ka => ka.IsActive)
|
|
.OrderByDescending(ka => ka.CreatedAt)
|
|
.ToListAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nel recupero di tutte le associazioni attive");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<List<KeyAssociation>> GetAllAssociationsAsync()
|
|
{
|
|
try
|
|
{
|
|
return await _context.KeyAssociations
|
|
.OrderByDescending(ka => ka.CreatedAt)
|
|
.ToListAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nel recupero di tutte le associazioni");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<bool> UpdateAssociationAsync(KeyAssociation association)
|
|
{
|
|
try
|
|
{
|
|
var existing = await _context.KeyAssociations.FindAsync(association.Id);
|
|
if (existing == null)
|
|
{
|
|
_logger.LogWarning("Associazione con ID {Id} non trovata per l'aggiornamento", association.Id);
|
|
return false;
|
|
}
|
|
|
|
existing.KeyValue = association.KeyValue;
|
|
existing.SourceKeyField = association.SourceKeyField;
|
|
existing.DestinationKeyField = association.DestinationKeyField;
|
|
existing.DestinationId = association.DestinationId;
|
|
existing.RestCredentialName = association.RestCredentialName;
|
|
existing.UpdatedAt = DateTime.UtcNow;
|
|
existing.LastVerifiedAt = association.LastVerifiedAt;
|
|
existing.AdditionalInfo = association.AdditionalInfo;
|
|
existing.SourcesInfo = association.SourcesInfo;
|
|
existing.IsActive = association.IsActive;
|
|
existing.Data_Hash = association.Data_Hash;
|
|
|
|
_context.KeyAssociations.Update(existing);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Associazione aggiornata: ID {Id}", association.Id);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nell'aggiornamento dell'associazione: ID {Id}", association.Id);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<bool> DeactivateAssociationAsync(int id)
|
|
{
|
|
try
|
|
{
|
|
var association = await _context.KeyAssociations.FindAsync(id);
|
|
if (association == null)
|
|
{
|
|
_logger.LogWarning("Associazione con ID {Id} non trovata per la disattivazione", id);
|
|
return false;
|
|
}
|
|
|
|
association.IsActive = false;
|
|
association.UpdatedAt = DateTime.UtcNow;
|
|
|
|
_context.KeyAssociations.Update(association);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Associazione disattivata: ID {Id}", id);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella disattivazione dell'associazione: ID {Id}", id);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<bool> DeleteAssociationAsync(int id)
|
|
{
|
|
try
|
|
{
|
|
var association = await _context.KeyAssociations.FindAsync(id);
|
|
if (association == null)
|
|
{
|
|
_logger.LogWarning("Associazione con ID {Id} non trovata per l'eliminazione", id);
|
|
return false;
|
|
}
|
|
|
|
_context.KeyAssociations.Remove(association);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Associazione eliminata: ID {Id}", id);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nell'eliminazione dell'associazione: ID {Id}", id);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<int> CleanupOldAssociationsAsync(TimeSpan olderThan)
|
|
{
|
|
try
|
|
{
|
|
var cutoffDate = DateTime.UtcNow - olderThan;
|
|
var oldAssociations = await _context.KeyAssociations
|
|
.Where(ka => ka.CreatedAt < cutoffDate && !ka.IsActive)
|
|
.ToListAsync();
|
|
|
|
if (oldAssociations.Any())
|
|
{
|
|
_context.KeyAssociations.RemoveRange(oldAssociations);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Pulite {Count} associazioni obsolete più vecchie di {Cutoff}",
|
|
oldAssociations.Count, cutoffDate);
|
|
}
|
|
|
|
return oldAssociations.Count;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella pulizia delle associazioni obsolete");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<int> ClearAssociationsAsync(string destinationEntity, string restCredentialName)
|
|
{
|
|
try
|
|
{
|
|
var associationsToDelete = await _context.KeyAssociations
|
|
.Where(ka => ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName)
|
|
.ToListAsync();
|
|
|
|
if (associationsToDelete.Any())
|
|
{
|
|
_context.KeyAssociations.RemoveRange(associationsToDelete);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Eliminate {Count} associazioni per {DestinationEntity}",
|
|
associationsToDelete.Count, destinationEntity);
|
|
}
|
|
|
|
return associationsToDelete.Count;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella cancellazione delle associazioni per {DestinationEntity}",
|
|
destinationEntity);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<int> ClearAllAssociationsAsync()
|
|
{
|
|
try
|
|
{
|
|
var allAssociations = await _context.KeyAssociations.ToListAsync();
|
|
var count = allAssociations.Count;
|
|
|
|
if (allAssociations.Any())
|
|
{
|
|
_context.KeyAssociations.RemoveRange(allAssociations);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogWarning("Eliminate TUTTE le {Count} associazioni dal sistema", count);
|
|
}
|
|
|
|
return count;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella cancellazione di tutte le associazioni");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<bool> ValidateDestinationIdAsync(string destinationId, string destinationEntity, string restCredentialName)
|
|
{
|
|
// Questa implementazione base restituisce sempre true
|
|
// Dovrebbe essere estesa per verificare effettivamente l'esistenza nel sistema REST
|
|
try
|
|
{
|
|
// TODO: Implementare la logica di validazione effettiva con il servizio REST
|
|
// Per ora assumiamo che l'ID sia valido
|
|
_logger.LogDebug("Validazione ID destinazione {DestinationId} per entità {DestinationEntity} - Non implementata",
|
|
destinationId, destinationEntity);
|
|
|
|
return await Task.FromResult(true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella validazione dell'ID destinazione {DestinationId}", destinationId);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task<List<KeyAssociation>> GetInvalidAssociationsAsync(string destinationEntity, string restCredentialName)
|
|
{
|
|
try
|
|
{
|
|
var associations = await _context.KeyAssociations
|
|
.Where(ka => ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName &&
|
|
ka.IsActive)
|
|
.ToListAsync();
|
|
|
|
var invalidAssociations = new List<KeyAssociation>();
|
|
|
|
// Verifica ogni associazione
|
|
foreach (var association in associations)
|
|
{
|
|
var isValid = await ValidateDestinationIdAsync(association.DestinationId, destinationEntity, restCredentialName);
|
|
if (!isValid)
|
|
{
|
|
invalidAssociations.Add(association);
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Trovate {Invalid}/{Total} associazioni non valide per {DestinationEntity}",
|
|
invalidAssociations.Count, associations.Count, destinationEntity);
|
|
|
|
return invalidAssociations;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nel recupero delle associazioni non valide per {DestinationEntity}", destinationEntity);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<int> CleanupInvalidAssociationsAsync(string destinationEntity, string restCredentialName)
|
|
{
|
|
try
|
|
{
|
|
var invalidAssociations = await GetInvalidAssociationsAsync(destinationEntity, restCredentialName);
|
|
|
|
if (invalidAssociations.Any())
|
|
{
|
|
_context.KeyAssociations.RemoveRange(invalidAssociations);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogWarning("Eliminate {Count} associazioni non valide per {DestinationEntity}",
|
|
invalidAssociations.Count, destinationEntity);
|
|
}
|
|
|
|
return invalidAssociations.Count;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella pulizia delle associazioni non valide per {DestinationEntity}", destinationEntity);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<bool> UpdateLastVerifiedAsync(int id)
|
|
{
|
|
try
|
|
{
|
|
var association = await _context.KeyAssociations.FindAsync(id);
|
|
if (association == null)
|
|
{
|
|
_logger.LogWarning("Associazione con ID {Id} non trovata per l'aggiornamento della verifica", id);
|
|
return false;
|
|
}
|
|
|
|
association.LastVerifiedAt = DateTime.UtcNow;
|
|
_context.KeyAssociations.Update(association);
|
|
await _context.SaveChangesAsync();
|
|
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nell'aggiornamento della verifica per associazione: ID {Id}", id);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<AssociationStatistics> GetStatisticsAsync()
|
|
{
|
|
try
|
|
{
|
|
var allAssociations = await _context.KeyAssociations.ToListAsync();
|
|
|
|
var stats = new AssociationStatistics
|
|
{
|
|
TotalAssociations = allAssociations.Count,
|
|
ActiveAssociations = allAssociations.Count(a => a.IsActive),
|
|
InactiveAssociations = allAssociations.Count(a => !a.IsActive),
|
|
UniqueKeyValues = allAssociations.Select(a => a.KeyValue).Distinct().Count(),
|
|
UniqueDestinationEntities = allAssociations.Select(a => a.DestinationEntity).Distinct().Count(),
|
|
OldestAssociation = allAssociations.Any() ? allAssociations.Min(a => a.CreatedAt) : null,
|
|
NewestAssociation = allAssociations.Any() ? allAssociations.Max(a => a.CreatedAt) : null,
|
|
AssociationsByEntity = allAssociations
|
|
.GroupBy(a => a.DestinationEntity)
|
|
.ToDictionary(g => g.Key, g => g.Count())
|
|
};
|
|
|
|
return stats;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nel calcolo delle statistiche delle associazioni");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private void UpdateSourcesInfo(KeyAssociation existing, KeyAssociation newAssociation)
|
|
{
|
|
try
|
|
{
|
|
var sourcesInfo = existing.SourcesInfo ?? "";
|
|
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm");
|
|
var newSourceInfo = $"{timestamp}: {newAssociation.SourceKeyField}";
|
|
|
|
if (!sourcesInfo.Contains(newSourceInfo))
|
|
{
|
|
existing.SourcesInfo = string.IsNullOrEmpty(sourcesInfo)
|
|
? newSourceInfo
|
|
: $"{sourcesInfo}; {newSourceInfo}";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Errore nell'aggiornamento delle informazioni sulle sorgenti");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marca le associazioni come cancellate dalla sorgente se i loro KeyValue non sono presenti nella lista fornita
|
|
/// </summary>
|
|
public async Task<int> MarkDeletedAssociationsAsync(List<string> sourceKeyValues, string destinationEntity, string restCredentialName)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInformation("Verifica cancellazioni per {Entity} - {Credential}: {Count} chiavi sorgente attive",
|
|
destinationEntity, restCredentialName, sourceKeyValues.Count);
|
|
|
|
// Ottieni tutte le associazioni attive per questa destinazione
|
|
var existingAssociations = await _context.KeyAssociations
|
|
.Where(ka => ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName &&
|
|
ka.IsActive &&
|
|
!ka.IsSourceDeleted)
|
|
.ToListAsync();
|
|
|
|
_logger.LogInformation("Trovate {Count} associazioni attive esistenti", existingAssociations.Count);
|
|
|
|
// Identifica le associazioni i cui KeyValue non sono più presenti nella sorgente
|
|
var deletedAssociations = existingAssociations
|
|
.Where(ka => !sourceKeyValues.Contains(ka.KeyValue))
|
|
.ToList();
|
|
|
|
if (!deletedAssociations.Any())
|
|
{
|
|
_logger.LogInformation("Nessun record cancellato rilevato");
|
|
return 0;
|
|
}
|
|
|
|
_logger.LogWarning("Rilevati {Count} record cancellati dalla sorgente", deletedAssociations.Count);
|
|
|
|
// Marca le associazioni come cancellate
|
|
var now = DateTime.UtcNow;
|
|
foreach (var association in deletedAssociations)
|
|
{
|
|
association.IsSourceDeleted = true;
|
|
association.DeletedAt = now;
|
|
association.UpdatedAt = now;
|
|
|
|
_logger.LogInformation("Marcata come cancellata: KeyValue={KeyValue}, DestinationId={DestinationId}",
|
|
association.KeyValue, association.DestinationId);
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
return deletedAssociations.Count;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella marcatura delle associazioni cancellate");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ottiene tutte le associazioni marcate come cancellate dalla sorgente ma non ancora sincronizzate
|
|
/// </summary>
|
|
public async Task<List<KeyAssociation>> GetPendingDeletionsAsync(string destinationEntity, string restCredentialName)
|
|
{
|
|
try
|
|
{
|
|
var pendingDeletions = await _context.KeyAssociations
|
|
.Where(ka => ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName &&
|
|
ka.IsSourceDeleted &&
|
|
!ka.DeletionSynced &&
|
|
ka.IsActive)
|
|
.OrderBy(ka => ka.DeletedAt)
|
|
.ToListAsync();
|
|
|
|
_logger.LogInformation("Trovate {Count} cancellazioni in attesa di sincronizzazione per {Entity}",
|
|
pendingDeletions.Count, destinationEntity);
|
|
|
|
return pendingDeletions;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nel recupero delle cancellazioni in attesa");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marca una cancellazione come sincronizzata
|
|
/// </summary>
|
|
public async Task<bool> MarkDeletionSyncedAsync(int associationId)
|
|
{
|
|
try
|
|
{
|
|
var association = await _context.KeyAssociations.FindAsync(associationId);
|
|
if (association == null)
|
|
{
|
|
_logger.LogWarning("Associazione {Id} non trovata per marcatura sincronizzazione", associationId);
|
|
return false;
|
|
}
|
|
|
|
association.DeletionSynced = true;
|
|
association.DeletionSyncedAt = DateTime.UtcNow;
|
|
association.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Cancellazione sincronizzata per associazione {Id} - KeyValue={KeyValue}, DestinationId={DestinationId}",
|
|
associationId, association.KeyValue, association.DestinationId);
|
|
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella marcatura della sincronizzazione per associazione {Id}", associationId);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ottiene tutte le associazioni marcate come cancellate
|
|
/// </summary>
|
|
public async Task<List<KeyAssociation>> GetDeletedAssociationsAsync(string destinationEntity, string restCredentialName)
|
|
{
|
|
try
|
|
{
|
|
return await _context.KeyAssociations
|
|
.Where(ka => ka.DestinationEntity == destinationEntity &&
|
|
ka.RestCredentialName == restCredentialName &&
|
|
ka.IsSourceDeleted)
|
|
.OrderByDescending(ka => ka.DeletedAt)
|
|
.ToListAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nel recupero delle associazioni cancellate");
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|