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
@@ -0,0 +1,345 @@
// <auto-generated />
using System;
using CredentialManager.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CredentialManager.Migrations
{
[DbContext(typeof(CredentialDbContext))]
[Migration("20250721072200_AddDataHashField")]
partial class AddDataHashField
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.0");
modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdditionalParameters")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("CommandTimeout")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<string>("ConnectionString")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DatabaseName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DatabaseType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("EncryptedApiKey")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EncryptedAuthToken")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EncryptedPassword")
.HasColumnType("TEXT");
b.Property<string>("Headers")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Host")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IgnoreSslErrors")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int?>("Port")
.HasColumnType("INTEGER");
b.Property<string>("RestServiceType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("TimeoutSeconds")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(100);
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("DatabaseType");
b.HasIndex("IsActive");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Type");
b.ToTable("Credentials", (string)null);
});
modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<int?>("DestinationCredentialId")
.HasColumnType("INTEGER");
b.Property<string>("DestinationEndpoint")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("DestinationSchema")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationTable")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("FieldMappingJson")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int?>("SourceCredentialId")
.HasColumnType("INTEGER");
b.Property<string>("SourceCustomQuery")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("SourceDatabaseName")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceFilePath")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SourceKeyField")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceSchema")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceTable")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<bool>("UseRecordAssociations")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("DestinationCredentialId");
b.HasIndex("DestinationType");
b.HasIndex("IsActive");
b.HasIndex("LastUsedAt");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("SourceCredentialId");
b.HasIndex("SourceType");
b.ToTable("DataCouplerProfiles", (string)null);
});
modelBuilder.Entity("CredentialManager.Models.KeyAssociation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdditionalInfo")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Data_Hash")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("DestinationEntity")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationKeyField")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("KeyValue")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime?>("LastVerifiedAt")
.HasColumnType("TEXT");
b.Property<string>("RestCredentialName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("SourceKeyField")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourcesInfo")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("DestinationEntity");
b.HasIndex("IsActive");
b.HasIndex("KeyValue")
.HasDatabaseName("IX_KeyAssociations_KeyValue");
b.HasIndex("LastVerifiedAt");
b.HasIndex("RestCredentialName");
b.HasIndex("KeyValue", "DestinationEntity", "RestCredentialName")
.IsUnique()
.HasDatabaseName("IX_KeyAssociations_Unique");
b.ToTable("KeyAssociations", (string)null);
});
modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b =>
{
b.HasOne("CredentialManager.Models.CredentialEntity", "DestinationCredential")
.WithMany()
.HasForeignKey("DestinationCredentialId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("CredentialManager.Models.CredentialEntity", "SourceCredential")
.WithMany()
.HasForeignKey("SourceCredentialId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("DestinationCredential");
b.Navigation("SourceCredential");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CredentialManager.Migrations
{
/// <inheritdoc />
public partial class AddDataHashField : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Data_Hash",
table: "KeyAssociations",
type: "TEXT",
maxLength: 64,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Data_Hash",
table: "KeyAssociations");
}
}
}
@@ -249,6 +249,10 @@ namespace CredentialManager.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Data_Hash")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("DestinationEntity")
.IsRequired()
.HasMaxLength(200)
@@ -88,4 +88,11 @@ public class KeyAssociation
/// </summary>
[MaxLength(2000)]
public string? AdditionalInfo { get; set; }
/// <summary>
/// Hash SHA256 dei dati dei campi sorgente mappati.
/// Utilizzato per rilevare cambiamenti nei dati sorgente e ottimizzare il trasferimento
/// </summary>
[MaxLength(64)]
public string? Data_Hash { get; set; }
}
@@ -31,6 +31,7 @@ public class KeyAssociationService : IKeyAssociationService
var sourceKeyField = association.SourceKeyField;
var destinationKeyField = association.DestinationKeyField;
var additionalInfo = association.AdditionalInfo;
var dataHash = association.Data_Hash;
var currentTime = DateTime.UtcNow;
try
@@ -47,12 +48,13 @@ public class KeyAssociationService : IKeyAssociationService
DestinationKeyField = {2},
UpdatedAt = {3},
LastVerifiedAt = {4},
AdditionalInfo = {5}
WHERE KeyValue = {6}
AND DestinationEntity = {7}
AND RestCredentialName = {8}
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,
destinationId, sourceKeyField, destinationKeyField, currentTime, currentTime, additionalInfo ?? (object)DBNull.Value, dataHash ?? (object)DBNull.Value,
keyValue, destinationEntity, restCredentialName);
if (rowsAffected > 0)
@@ -92,6 +94,7 @@ public class KeyAssociationService : IKeyAssociationService
CreatedAt = currentTime,
LastVerifiedAt = currentTime,
AdditionalInfo = additionalInfo,
Data_Hash = dataHash,
IsActive = true
};
@@ -125,6 +128,7 @@ public class KeyAssociationService : IKeyAssociationService
existing.UpdatedAt = currentTime;
existing.LastVerifiedAt = currentTime;
existing.AdditionalInfo = additionalInfo;
existing.Data_Hash = dataHash;
UpdateSourcesInfo(existing, association);
@@ -162,6 +166,7 @@ public class KeyAssociationService : IKeyAssociationService
var sourceKeyField = association.SourceKeyField;
var destinationKeyField = association.DestinationKeyField;
var additionalInfo = association.AdditionalInfo;
var dataHash = association.Data_Hash;
var currentTime = DateTime.UtcNow;
// Crea un nuovo DbContext per questa operazione parallela
@@ -185,12 +190,13 @@ public class KeyAssociationService : IKeyAssociationService
DestinationKeyField = {2},
UpdatedAt = {3},
LastVerifiedAt = {4},
AdditionalInfo = {5}
WHERE KeyValue = {6}
AND DestinationEntity = {7}
AND RestCredentialName = {8}
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,
destinationId, sourceKeyField, destinationKeyField, currentTime, currentTime, additionalInfo ?? (object)DBNull.Value, dataHash ?? (object)DBNull.Value,
keyValue, destinationEntity, restCredentialName);
if (rowsAffected > 0)
@@ -230,6 +236,7 @@ public class KeyAssociationService : IKeyAssociationService
CreatedAt = currentTime,
LastVerifiedAt = currentTime,
AdditionalInfo = additionalInfo,
Data_Hash = dataHash,
IsActive = true
};
@@ -549,9 +556,11 @@ public class KeyAssociationService : IKeyAssociationService
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();
Binary file not shown.
+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
{
// Record da creare
// 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 (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}");
+15
View File
@@ -242,6 +242,7 @@
<th>Entità Destinazione</th>
<th>ID Destinazione</th>
<th>Credenziale</th>
<th>Hash Dati</th>
<th>Stato</th>
<th>Creata</th>
<th>Verificata</th>
@@ -270,6 +271,20 @@
<td>
<span class="badge bg-secondary">@association.RestCredentialName</span>
</td>
<td>
@if (!string.IsNullOrEmpty(association.Data_Hash))
{
<code class="small text-truncate d-inline-block" style="max-width: 120px;" title="@association.Data_Hash">
@(association.Data_Hash.Substring(0, Math.Min(12, association.Data_Hash.Length)))...
</code>
}
else
{
<span class="text-muted">
<i class="fas fa-minus"></i> Non disponibile
</span>
}
</td>
<td>
@if (association.IsActive)
{