feat: implementa campo Data_Hash per ottimizzazione trasferimenti

- Aggiunge colonna "Hash Dati" nella tabella delle associazioni con visualizzazione troncata
- Implementa generazione hash SHA256 che include signature dei mapping per rilevare modifiche configurazione
- Modifica logica trasferimento per saltare record con hash identico (ottimizzazione prestazioni)
- Corregge UpdateAssociationAsync per persistere correttamente Data_Hash e LastVerifiedAt nel database
- Aggiorna hash solo in caso di trasferimento riuscito, mantenendo coerenza tra Salesforce e database locale
- Migliora logging per debug del sistema di hash e associazioni

Risolve il problema dei trasferimenti continui quando i mapping cambiano e ottimizza le prestazioni saltando record non modificati.
This commit is contained in:
Alessio Dal Santo
2025-07-21 10:59:50 +02:00
parent e21e87dff9
commit 20a514068a
8 changed files with 578 additions and 51 deletions
+159 -41
View File
@@ -1727,6 +1727,7 @@ public partial class DataCoupler : ComponentBase
"success" => "",
"updated" => "table-info",
"duplicate" => "table-warning",
"skipped" => "table-secondary",
"error" => "table-danger",
_ => ""
};
@@ -1739,6 +1740,7 @@ public partial class DataCoupler : ComponentBase
"success" => "bg-success",
"updated" => "bg-info",
"duplicate" => "bg-warning text-dark",
"skipped" => "bg-secondary",
"error" => "bg-danger",
_ => "bg-secondary"
};
@@ -1751,6 +1753,7 @@ public partial class DataCoupler : ComponentBase
"success" => "fa-check-circle",
"updated" => "fa-edit",
"duplicate" => "fa-exclamation-triangle",
"skipped" => "fa-forward",
"error" => "fa-times-circle",
_ => "fa-question-circle"
};
@@ -1763,6 +1766,7 @@ public partial class DataCoupler : ComponentBase
"success" => "Inserito",
"updated" => "Aggiornato",
"duplicate" => "Duplicato",
"skipped" => "Saltato",
"error" => "Errore",
_ => "Sconosciuto"
};
@@ -1802,6 +1806,61 @@ public partial class DataCoupler : ComponentBase
}
}
/// <summary>
/// Genera un hash SHA256 dei dati dei campi sorgente mappati.
/// Utilizzato per rilevare cambiamenti nei dati e ottimizzare il trasferimento.
/// Include anche una signature dei campi mappati per rilevare cambi di configurazione.
/// </summary>
private string GenerateDataHash(Dictionary<string, object> record)
{
try
{
// Raccoglie i valori dei campi mappati in ordine alfabetico per garantire consistenza
var mappedFields = fieldMappings.Keys.OrderBy(k => k).ToList();
var valuesForHash = new List<string>();
// PRIMO: Aggiungi la signature dei mapping per rilevare cambi di configurazione
var mappingSignature = string.Join(",", fieldMappings.OrderBy(m => m.Key).Select(m => $"{m.Key}->{m.Value}"));
valuesForHash.Add($"MAPPING_SIGNATURE={mappingSignature}");
// SECONDO: Aggiungi i valori dei dati per ogni campo mappato
foreach (var sourceField in mappedFields)
{
if (record.ContainsKey(sourceField))
{
var value = record[sourceField];
var normalizedValue = value?.ToString()?.Trim() ?? "";
valuesForHash.Add($"{sourceField}={normalizedValue}");
}
else
{
// Se il campo non è presente nel record, aggiungi una stringa vuota
valuesForHash.Add($"{sourceField}=");
}
}
// Combina tutti i valori in una stringa unica
var combinedData = string.Join("|", valuesForHash);
Logger.LogDebug("Hash dei dati generato da: {CombinedData}", combinedData);
// Calcola l'hash SHA256
using (var sha256 = System.Security.Cryptography.SHA256.Create())
{
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combinedData));
var hashString = Convert.ToHexString(hashBytes);
Logger.LogDebug("Hash SHA256 generato: {Hash} (include signature mapping)", hashString);
return hashString;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore nella generazione dell'hash dei dati");
throw;
}
}
/// <summary>
/// Gestisce la connessione al database con schema specifico
/// </summary>
@@ -2313,19 +2372,6 @@ public partial class DataCoupler : ComponentBase
}
}
private async Task<string> GenerateUniqueProfileName(string baseName)
{
var uniqueName = baseName;
@@ -2499,7 +2545,8 @@ public partial class DataCoupler : ComponentBase
// 2. Trasforma i record e analizza le associazioni IN PARALLELO
var recordsForCreate = new ConcurrentBag<(Dictionary<string, object> transformedData, Dictionary<string, object> originalRecord, int recordNumber)>();
var recordsForUpdate = new ConcurrentBag<(Dictionary<string, object> transformedData, string entityId, Dictionary<string, object> originalRecord, int recordNumber)>();
var recordsForUpdate = new ConcurrentBag<(Dictionary<string, object> transformedData, string entityId, Dictionary<string, object> originalRecord, int recordNumber, string newDataHash)>();
var recordsSkipped = new ConcurrentBag<(Dictionary<string, object> originalRecord, int recordNumber, string reason)>();
var recordErrors = new ConcurrentBag<TransferResult>();
// Cattura i valori condivisi per evitare race conditions
@@ -2524,10 +2571,11 @@ public partial class DataCoupler : ComponentBase
// Trasforma il record in base ai mapping (operazione locale, thread-safe)
var restData = TransformRecordToRestEntity(record);
// Genera la chiave sorgente per questo record (operazione locale, thread-safe)
// Genera la chiave sorgente e l'hash dei dati per questo record (operazioni locali, thread-safe)
var sourceKey = GenerateSourceKey(record);
var currentDataHash = GenerateDataHash(record);
// Analizza le associazioni per capire se aggiornare o creare
// Analizza le associazioni per capire se aggiornare, creare o saltare
if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey))
{
Logger.LogDebug("COMPOSITE PARALLEL: Cerco associazione per KeyValue: '{KeyValue}', Entity: '{Entity}', Credential: '{Credential}'",
@@ -2554,14 +2602,27 @@ public partial class DataCoupler : ComponentBase
if (existingAssociation != null && existingAssociation.IsActive)
{
// Record da aggiornare
recordsForUpdate.Add((restData, existingAssociation.DestinationId, record, recordNumber));
Logger.LogDebug("COMPOSITE PARALLEL: Record {RecordNumber} marcato per aggiornamento (EntityId: {EntityId})",
recordNumber, existingAssociation.DestinationId);
// CONTROLLO HASH: Verifica se i dati sono cambiati
var existingHash = existingAssociation.Data_Hash;
if (!string.IsNullOrEmpty(existingHash) && existingHash.Equals(currentDataHash, StringComparison.OrdinalIgnoreCase))
{
// I dati non sono cambiati, salta questo record
recordsSkipped.Add((record, recordNumber, "Dati non modificati (hash identico)"));
Logger.LogDebug("COMPOSITE PARALLEL: Record {RecordNumber} saltato - hash identico: {Hash}",
recordNumber, currentDataHash);
}
else
{
// I dati sono cambiati o l'hash è vuoto, procedi con l'aggiornamento
recordsForUpdate.Add((restData, existingAssociation.DestinationId, record, recordNumber, currentDataHash));
Logger.LogDebug("COMPOSITE PARALLEL: Record {RecordNumber} marcato per aggiornamento (EntityId: {EntityId}) - hash diverso: old={OldHash}, new={NewHash}",
recordNumber, existingAssociation.DestinationId, existingHash ?? "NULL", currentDataHash);
}
}
else
{
// Record da creare
// Record da creare (nessuna associazione esistente)
recordsForCreate.Add((restData, record, recordNumber));
Logger.LogDebug("COMPOSITE PARALLEL: Record {RecordNumber} marcato per creazione", recordNumber);
}
@@ -2601,9 +2662,22 @@ public partial class DataCoupler : ComponentBase
// Converti i ConcurrentBag in liste per il resto del processing
var finalRecordsForCreate = recordsForCreate.ToList();
var finalRecordsForUpdate = recordsForUpdate.ToList();
var finalRecordsSkipped = recordsSkipped.ToList();
Logger.LogInformation("COMPOSITE: Analisi parallela completata in {ElapsedMs}ms - {CreateCount} record da creare, {UpdateCount} record da aggiornare, {ErrorCount} errori",
analysisElapsed, finalRecordsForCreate.Count, finalRecordsForUpdate.Count, recordErrors.Count);
Logger.LogInformation("COMPOSITE: Analisi parallela completata in {ElapsedMs}ms - {CreateCount} record da creare, {UpdateCount} record da aggiornare, {SkippedCount} record saltati, {ErrorCount} errori",
analysisElapsed, finalRecordsForCreate.Count, finalRecordsForUpdate.Count, finalRecordsSkipped.Count, recordErrors.Count);
// Aggiungi i record saltati ai risultati di trasferimento
foreach (var skipped in finalRecordsSkipped)
{
transferResults.Add(new TransferResult
{
RecordNumber = skipped.recordNumber,
RecordData = skipped.originalRecord,
Status = "skipped",
Message = skipped.reason
});
}
// 3. Esegui le chiamate composite in parallelo
var createTask = Task.FromResult(new List<DataConnection.REST.Implementations.SalesforceServiceClient.CompositeOperationResult>());
@@ -2659,7 +2733,9 @@ public partial class DataCoupler : ComponentBase
if (useRecordAssociations && !string.IsNullOrEmpty(transferResult.EntityId))
{
// IMPORTANTE: Non awaita qui, solo crea il task per esecuzione parallela
var associationTask = CreateAssociationAsync(originalData.originalRecord, transferResult.EntityId, originalData.recordNumber);
// Genera l'hash per questo record per salvarlo nell'associazione
var dataHashForAssociation = GenerateDataHash(originalData.originalRecord);
var associationTask = CreateAssociationAsync(originalData.originalRecord, transferResult.EntityId, originalData.recordNumber, dataHashForAssociation);
createAssociationTasks.Add(associationTask);
}
}
@@ -2699,8 +2775,8 @@ public partial class DataCoupler : ComponentBase
if (useRecordAssociations && !string.IsNullOrEmpty(result.EntityId))
{
// IMPORTANTE: Non awaita qui, solo crea il task per esecuzione parallela
var verificationTask = UpdateAssociationVerificationAsync(result.EntityId);
updateAssociationTasks.Add(verificationTask);
var updateHashTask = UpdateAssociationHashAsync(originalData.originalRecord, result.EntityId, originalData.newDataHash);
updateAssociationTasks.Add(updateHashTask);
}
}
else
@@ -2709,13 +2785,9 @@ public partial class DataCoupler : ComponentBase
transferResult.Status = "error";
transferResult.Message = $"Errore aggiornamento (Composite): {result.ErrorMessage}";
// Aggiungi task di gestione fallimento alla lista (esecuzione parallela)
if (useRecordAssociations)
{
// IMPORTANTE: Non awaita qui, solo crea il task per esecuzione parallela
var failureTask = HandleFailedUpdateAsync(originalData.originalRecord, originalData.recordNumber);
updateAssociationTasks.Add(failureTask);
}
// NON aggiornare l'hash in caso di errore nel trasferimento
Logger.LogWarning("COMPOSITE: Trasferimento fallito per record {RecordNumber} - EntityId: {EntityId}. Hash non aggiornato.",
originalData.recordNumber, result.EntityId ?? "N/A");
}
transferResults.Add(transferResult);
@@ -2740,11 +2812,12 @@ public partial class DataCoupler : ComponentBase
Logger.LogInformation("COMPOSITE: Nessuna operazione di associazione da eseguire");
}
// 7. Mostra risultati
ShowTransferResults(successCount, updatedCount, 0, errorCount);
// 7. Mostra risultati (inclusi i record saltati)
var skippedCount = finalRecordsSkipped.Count;
ShowTransferResults(successCount, updatedCount, 0, errorCount, skippedCount);
Logger.LogInformation("Trasferimento COMPOSITE completato. Inserimenti: {SuccessCount}, Aggiornamenti: {UpdatedCount}, Errori: {ErrorCount}",
successCount, updatedCount, errorCount);
Logger.LogInformation("Trasferimento COMPOSITE completato. Inserimenti: {SuccessCount}, Aggiornamenti: {UpdatedCount}, Saltati: {SkippedCount}, Errori: {ErrorCount}",
successCount, updatedCount, skippedCount, errorCount);
}
catch (Exception ex)
{
@@ -2758,7 +2831,7 @@ public partial class DataCoupler : ComponentBase
}
}
private async Task CreateAssociationAsync(Dictionary<string, object> originalRecord, string entityId, int recordNumber)
private async Task CreateAssociationAsync(Dictionary<string, object> originalRecord, string entityId, int recordNumber, string? dataHash = null)
{
try
{
@@ -2772,6 +2845,9 @@ public partial class DataCoupler : ComponentBase
var sourceKey = GenerateSourceKey(originalRecord);
if (string.IsNullOrEmpty(sourceKey)) return;
// Usa l'hash passato come parametro o genera uno nuovo se non fornito
var finalDataHash = dataHash ?? GenerateDataHash(originalRecord);
var destinationKeyField = GetEntityIdField();
var association = new KeyAssociation
{
@@ -2783,18 +2859,21 @@ public partial class DataCoupler : ComponentBase
RestCredentialName = currentCredentialName,
CreatedAt = DateTime.UtcNow,
LastVerifiedAt = DateTime.UtcNow,
Data_Hash = finalDataHash,
AdditionalInfo = System.Text.Json.JsonSerializer.Serialize(new
{
TransferDate = DateTime.UtcNow,
RecordNumber = recordNumber,
MappingCount = currentMappingCount,
SourceType = currentSourceType,
CompositeTransfer = true
CompositeTransfer = true,
DataHashGenerated = true
})
};
var associationId = await CredentialService.SaveKeyAssociationParallelAsync(association);
Logger.LogDebug("COMPOSITE: Associazione creata con ID: {AssociationId} per record {RecordNumber} (PARALLEL)", associationId, recordNumber);
Logger.LogDebug("COMPOSITE: Associazione creata con ID: {AssociationId} per record {RecordNumber} (PARALLEL) - Hash: {Hash}",
associationId, recordNumber, finalDataHash);
}
catch (Exception ex)
{
@@ -2802,6 +2881,43 @@ public partial class DataCoupler : ComponentBase
}
}
private async Task UpdateAssociationHashAsync(Dictionary<string, object> originalRecord, string entityId, string newDataHash)
{
try
{
// Cattura i valori condivisi per evitare race conditions
var currentEntityName = selectedRestEntity?.Name ?? "";
var currentCredentialName = selectedRestCredential ?? "";
var sourceKey = GenerateSourceKey(originalRecord);
if (string.IsNullOrEmpty(sourceKey)) return;
// Trova l'associazione esistente e aggiorna l'hash
var existingAssociation = await CredentialService.FindKeyAssociationByValueParallelAsync(
sourceKey, currentEntityName, currentCredentialName);
if (existingAssociation != null)
{
existingAssociation.Data_Hash = newDataHash;
existingAssociation.LastVerifiedAt = DateTime.UtcNow;
existingAssociation.UpdatedAt = DateTime.UtcNow;
await CredentialService.UpdateKeyAssociationAsync(existingAssociation);
Logger.LogDebug("COMPOSITE: Hash associazione aggiornato per entityId {EntityId} - Nuovo hash: {Hash}",
entityId, newDataHash);
}
else
{
Logger.LogWarning("COMPOSITE: Associazione non trovata per aggiornamento hash - EntityId: {EntityId}, SourceKey: {SourceKey}",
entityId, sourceKey);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Errore nell'aggiornamento dell'hash dell'associazione per entityId {EntityId}", entityId);
}
}
private Task UpdateAssociationVerificationAsync(string entityId)
{
try
@@ -2840,7 +2956,7 @@ public partial class DataCoupler : ComponentBase
}
}
private void ShowTransferResults(int successCount, int updatedCount, int duplicateCount, int errorCount)
private void ShowTransferResults(int successCount, int updatedCount, int duplicateCount, int errorCount, int skippedCount = 0)
{
if (errorCount == 0)
{
@@ -2849,6 +2965,7 @@ public partial class DataCoupler : ComponentBase
if (successCount > 0) messageParts.Add($"{successCount} record inseriti");
if (updatedCount > 0) messageParts.Add($"{updatedCount} record aggiornati");
if (skippedCount > 0) messageParts.Add($"{skippedCount} record saltati (dati non modificati)");
if (duplicateCount > 0) messageParts.Add($"{duplicateCount} duplicati rilevati (warning)");
message += string.Join(", ", messageParts) + ".";
@@ -2862,6 +2979,7 @@ public partial class DataCoupler : ComponentBase
if (successCount > 0) messageParts.Add($"Inserimenti: {successCount}");
if (updatedCount > 0) messageParts.Add($"Aggiornamenti: {updatedCount}");
if (skippedCount > 0) messageParts.Add($"Saltati: {skippedCount}");
if (duplicateCount > 0) messageParts.Add($"Duplicati (warning): {duplicateCount}");
messageParts.Add($"Errori: {errorCount}");