diff --git a/CredentialManager/Migrations/20250721072200_AddDataHashField.Designer.cs b/CredentialManager/Migrations/20250721072200_AddDataHashField.Designer.cs new file mode 100644 index 0000000..85c3e63 --- /dev/null +++ b/CredentialManager/Migrations/20250721072200_AddDataHashField.Designer.cs @@ -0,0 +1,345 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CommandTimeout") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("ConnectionString") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EncryptedApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedAuthToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .HasColumnType("TEXT"); + + b.Property("Headers") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IgnoreSslErrors") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RestServiceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(100); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationCredentialId") + .HasColumnType("INTEGER"); + + b.Property("DestinationEndpoint") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("FieldMappingJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceCredentialId") + .HasColumnType("INTEGER"); + + b.Property("SourceCustomQuery") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data_Hash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DestinationEntity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastVerifiedAt") + .HasColumnType("TEXT"); + + b.Property("RestCredentialName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourcesInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/CredentialManager/Migrations/20250721072200_AddDataHashField.cs b/CredentialManager/Migrations/20250721072200_AddDataHashField.cs new file mode 100644 index 0000000..042dec8 --- /dev/null +++ b/CredentialManager/Migrations/20250721072200_AddDataHashField.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CredentialManager.Migrations +{ + /// + public partial class AddDataHashField : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Data_Hash", + table: "KeyAssociations", + type: "TEXT", + maxLength: 64, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Data_Hash", + table: "KeyAssociations"); + } + } +} diff --git a/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs b/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs index 4f0b450..cf770a2 100644 --- a/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs +++ b/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs @@ -249,6 +249,10 @@ namespace CredentialManager.Migrations b.Property("CreatedAt") .HasColumnType("TEXT"); + b.Property("Data_Hash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.Property("DestinationEntity") .IsRequired() .HasMaxLength(200) diff --git a/CredentialManager/Models/KeyAssociation.cs b/CredentialManager/Models/KeyAssociation.cs index f3591f8..a3db5d4 100644 --- a/CredentialManager/Models/KeyAssociation.cs +++ b/CredentialManager/Models/KeyAssociation.cs @@ -88,4 +88,11 @@ public class KeyAssociation /// [MaxLength(2000)] public string? AdditionalInfo { get; set; } + + /// + /// Hash SHA256 dei dati dei campi sorgente mappati. + /// Utilizzato per rilevare cambiamenti nei dati sorgente e ottimizzare il trasferimento + /// + [MaxLength(64)] + public string? Data_Hash { get; set; } } diff --git a/CredentialManager/Services/KeyAssociationService.cs b/CredentialManager/Services/KeyAssociationService.cs index ab03a96..e8404e3 100644 --- a/CredentialManager/Services/KeyAssociationService.cs +++ b/CredentialManager/Services/KeyAssociationService.cs @@ -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(); diff --git a/CredentialManager/design_time_temp.db b/CredentialManager/design_time_temp.db new file mode 100644 index 0000000..26d93bd Binary files /dev/null and b/CredentialManager/design_time_temp.db differ diff --git a/Data_Coupler/Pages/DataCoupler.razor.cs b/Data_Coupler/Pages/DataCoupler.razor.cs index 2215a4e..f555451 100644 --- a/Data_Coupler/Pages/DataCoupler.razor.cs +++ b/Data_Coupler/Pages/DataCoupler.razor.cs @@ -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 } } + /// + /// 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. + /// + private string GenerateDataHash(Dictionary 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(); + + // 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; + } + } + /// /// Gestisce la connessione al database con schema specifico /// @@ -2313,19 +2372,6 @@ public partial class DataCoupler : ComponentBase } } - - - - - - - - - - - - - private async Task 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 transformedData, Dictionary originalRecord, int recordNumber)>(); - var recordsForUpdate = new ConcurrentBag<(Dictionary transformedData, string entityId, Dictionary originalRecord, int recordNumber)>(); + var recordsForUpdate = new ConcurrentBag<(Dictionary transformedData, string entityId, Dictionary originalRecord, int recordNumber, string newDataHash)>(); + var recordsSkipped = new ConcurrentBag<(Dictionary originalRecord, int recordNumber, string reason)>(); var recordErrors = new ConcurrentBag(); // 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()); @@ -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 originalRecord, string entityId, int recordNumber) + private async Task CreateAssociationAsync(Dictionary 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 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}"); diff --git a/Data_Coupler/Pages/KeyAssociations.razor b/Data_Coupler/Pages/KeyAssociations.razor index 54452e9..f9d9992 100644 --- a/Data_Coupler/Pages/KeyAssociations.razor +++ b/Data_Coupler/Pages/KeyAssociations.razor @@ -242,6 +242,7 @@ Entità Destinazione ID Destinazione Credenziale + Hash Dati Stato Creata Verificata @@ -270,6 +271,20 @@ @association.RestCredentialName + + @if (!string.IsNullOrEmpty(association.Data_Hash)) + { + + @(association.Data_Hash.Substring(0, Math.Min(12, association.Data_Hash.Length)))... + + } + else + { + + Non disponibile + + } + @if (association.IsActive) {