using System; using System.Collections.Concurrent; using System.Data; using System.Text; using CredentialManager.Models; using CredentialManager.Services; using DataConnection.Interfaces; using DataConnection.REST.Interfaces; using DataConnection.REST.Models; using DataConnection.CredentialManagement.Interfaces; using ExcelDataReader; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; using Microsoft.JSInterop; using Microsoft.Extensions.Logging; using Data_Coupler.Services; using Data_Coupler.Models; namespace Data_Coupler.Pages; public partial class DataCoupler : ComponentBase { // Proprietà iniettate [Inject] public IDataConnectionCredentialService CredentialService { get; set; } = default!; [Inject] public IDataConnectionFactory ConnectionFactory { get; set; } = default!; [Inject] public IJSRuntime JSRuntime { get; set; } = default!; [Inject] public ILogger Logger { get; set; } = default!; [Inject] public IDataCouplerProfileService ProfileService { get; set; } = default!; [Inject] public IAssociationService AssociationService { get; set; } = default!; [Inject] public IDeletionSyncService DeletionSyncService { get; set; } = default!; // Selezione tipo fonte private string selectedSourceType = ""; // File handling private string selectedFileName = ""; private string manualFilePath = ""; // Percorso inserito manualmente dall'utente private string uploadedFilePath = ""; // Percorso completo del file validato private bool isProcessingFile = false; private string fileErrorMessage = ""; private Dictionary> fileSheets = new(); // SheetName -> Columns private Dictionary>> fileData = new(); // SheetName -> Data rows private string selectedSheet = ""; // File preview pagination private int currentPage = 1; private int pageSize = 20; private int GetTotalPages(string sheetName) => fileData.ContainsKey(sheetName) ? (int)Math.Ceiling((double)fileData[sheetName].Count / pageSize) : 0; // Mapping campi private Dictionary fieldMappings = new(); // DbColumn -> RestProperty private HashSet keyFields = new(); // REST properties marked as keys private string selectedDbColumn = ""; // Gestione chiavi sorgente e associazioni private string sourceKeyField = ""; // Campo che identifica univocamente il record sorgente private bool requiresManualKeySelection = false; // Flag per indicare se è richiesta selezione manuale private bool useRecordAssociations = true; // Se utilizzare il sistema di associazioni // Trasferimento dati private bool isTransferringData = false; private string transferMessage = ""; private string transferMessageType = ""; private List transferResults = new(); private bool showDetailedResults = false; // Gestione Profili private List availableProfiles = new(); private bool isLoadingProfiles = false; private bool showProfileManagement = false; protected override async Task OnInitializedAsync() { await LoadCredentials(); } private async Task LoadCredentials() { try { databaseCredentials = await CredentialService.GetAllDatabaseCredentialsAsync(); await LoadRestCredentials(); // Carica le credenziali REST dalla classe parziale // Carica anche i profili disponibili await LoadProfiles(); } catch (Exception ex) { Logger.LogError(ex, "Errore nel caricamento delle credenziali"); await JSRuntime.InvokeVoidAsync("alert", $"Errore nel caricamento delle credenziali: {ex.Message}"); } } private async Task LoadProfiles() { try { isLoadingProfiles = true; var profiles = await ProfileService.GetAllProfilesAsync(); availableProfiles = profiles.ToList(); } catch (Exception ex) { Logger.LogError(ex, "Errore nel caricamento dei profili"); } finally { isLoadingProfiles = false; StateHasChanged(); } } private async Task OnProfileLoaded(DataCouplerProfile profile) { try { // Aggiorna la data di ultimo utilizzo await ProfileService.UpdateLastUsedAsync(profile.Id); // Applica la configurazione del profilo await ApplyProfileConfiguration(profile); // Ricarica i profili per aggiornare la data di ultimo utilizzo await LoadProfiles(); } catch (Exception ex) { Logger.LogError(ex, "Errore nel caricamento del profilo"); await JSRuntime.InvokeVoidAsync("alert", $"Errore nel caricamento del profilo: {ex.Message}"); } } private async Task ApplyProfileConfiguration(DataCouplerProfile profile) { Logger.LogInformation("=== INIZIO APPLICAZIONE PROFILO ==="); Logger.LogInformation("Applicando configurazione profilo: {ProfileName}", profile.Name); Logger.LogInformation("Profilo - SourceType: {SourceType}, SourceCredentialId: {SourceCredentialId}, DestinationCredentialId: {DestinationCredentialId}", profile.SourceType, profile.SourceCredentialId, profile.DestinationCredentialId); try { // Step 0: Log dello stato iniziale Logger.LogInformation("Stato iniziale - SelectedSourceType: {SourceType}, DatabaseConnected: {DatabaseConnected}, RestConnected: {RestConnected}", selectedSourceType, isDatabaseConnected, isRestConnected); // Reset dello stato corrente Logger.LogInformation("Resettando stato corrente..."); ResetAllState(); Logger.LogInformation("Stato dopo reset - SelectedSourceType: {SourceType}, DatabaseConnected: {DatabaseConnected}, RestConnected: {RestConnected}", selectedSourceType, isDatabaseConnected, isRestConnected); // Step 1: Applica configurazione sorgente selectedSourceType = profile.SourceType; Logger.LogInformation("Step 1 - Tipo sorgente impostato: {SourceType}", selectedSourceType); // Force UI update for source type change StateHasChanged(); await Task.Delay(100); // Give UI time to react to source type change // Step 2: Configura e connetti la sorgente if (profile.SourceCredentialId.HasValue) { Logger.LogInformation("Step 2 - Configurazione sorgente con ID credenziale: {CredentialId}", profile.SourceCredentialId); if (profile.SourceType == "database") { var sourceCredential = await CredentialService.GetDatabaseCredentialAsync(profile.SourceCredentialId.Value); if (sourceCredential != null) { selectedDatabaseCredential = sourceCredential.Name; Logger.LogInformation("Credenziale database selezionata: {Credential}", selectedDatabaseCredential); // Force UI update for credential selection StateHasChanged(); await Task.Delay(200); // Connetti al database Logger.LogInformation("Iniziando connessione database..."); // Gestione connessione con database specifico if (!string.IsNullOrEmpty(profile.SourceDatabaseName)) { Logger.LogInformation("Connessione con database specifico: {Database}", profile.SourceDatabaseName); await ConnectToDatabaseWithSpecificDatabase(profile.SourceDatabaseName); } else if (!string.IsNullOrEmpty(profile.SourceSchema)) { Logger.LogInformation("Connessione con schema specifico: {Schema}", profile.SourceSchema); await ConnectToDatabaseWithSchema(profile.SourceSchema); } else { Logger.LogInformation("Connessione senza database/schema specifico"); await ConnectToDatabase(); } Logger.LogInformation("Stato dopo connessione database - Connected: {Connected}, Tables: {TableCount}", isDatabaseConnected, availableTableNames.Count); // Gestisci la query custom se specificata nel profilo if (!string.IsNullOrEmpty(profile.SourceCustomQuery) && isDatabaseConnected) { Logger.LogInformation("Caricamento query custom dal profilo: {Query}", profile.SourceCustomQuery); // Imposta la modalità query custom useCustomQuery = true; customQuery = profile.SourceCustomQuery; // Valida ed esegui la query await ValidateCustomQuery(); if (isQueryValid) { Logger.LogInformation("Query custom caricata e validata con successo"); // Carica l'anteprima dei dati await LoadQueryPreview(); Logger.LogInformation("Anteprima dati della query custom caricata"); } else { Logger.LogWarning("La query custom dal profilo non è valida: {ValidationMessage}", queryValidationMessage); } } // Seleziona la tabella se specificata e se la connessione è riuscita (solo se non c'è una query custom) else if (!string.IsNullOrEmpty(profile.SourceTable) && isDatabaseConnected) { Logger.LogInformation("Selezione tabella: {Table}", profile.SourceTable); await SelectTable(profile.SourceTable); Logger.LogInformation("Tabella selezionata: {SelectedTable}, Schema caricato: {SchemaLoaded}", selectedTable, databaseTables.ContainsKey(profile.SourceTable)); } else if (string.IsNullOrEmpty(profile.SourceCustomQuery) && string.IsNullOrEmpty(profile.SourceTable)) { Logger.LogInformation("Nessuna tabella o query custom specificata nel profilo"); } else { Logger.LogWarning("Impossibile selezionare tabella o caricare query custom - Table: {Table}, Query: {Query}, Connected: {Connected}", profile.SourceTable, profile.SourceCustomQuery, isDatabaseConnected); } } else { Logger.LogWarning("Credenziale database con ID {CredentialId} non trovata", profile.SourceCredentialId); } } else if (profile.SourceType == "file") { // Per i file, non possiamo ricreare il file caricato, ma possiamo impostare le informazioni if (!string.IsNullOrEmpty(profile.SourceFilePath)) { uploadedFilePath = profile.SourceFilePath; selectedFileName = Path.GetFileName(profile.SourceFilePath); Logger.LogInformation("Informazioni file impostate - Nome: {FileName}, Percorso: {FilePath}", selectedFileName, uploadedFilePath); } } } else { Logger.LogInformation("Nessuna credenziale sorgente da configurare"); } // Small delay to let source configuration settle await Task.Delay(300); // Step 3: Configura e connetti la destinazione if (profile.DestinationCredentialId.HasValue) { Logger.LogInformation("Step 3 - Configurazione destinazione con ID credenziale: {CredentialId}", profile.DestinationCredentialId); var destinationCredential = await CredentialService.GetRestApiCredentialAsync(profile.DestinationCredentialId.Value); if (destinationCredential != null) { selectedRestCredential = destinationCredential.Name; Logger.LogInformation("Credenziale REST selezionata: {Credential}", selectedRestCredential); // Force UI update for REST credential selection StateHasChanged(); await Task.Delay(200); // Connetti al servizio REST Logger.LogInformation("Iniziando connessione REST..."); await ConnectToRestApi(); Logger.LogInformation("Stato dopo connessione REST - Connected: {Connected}, Entities: {EntityCount}", isRestConnected, restEntities.Count); // Seleziona l'entità REST se la connessione è riuscita if (!string.IsNullOrEmpty(profile.DestinationEndpoint) && isRestConnected) { var entity = restEntities.FirstOrDefault(e => e.Name == profile.DestinationEndpoint); if (entity != null) { Logger.LogInformation("Selezione entità REST: {Entity}", entity.Name); await SelectRestEntity(entity); Logger.LogInformation("Entità REST selezionata: {SelectedEntity}, Dettagli caricati: {DetailsLoaded}", selectedRestEntity?.Name, restEntityDetails != null); } else { Logger.LogWarning("Entità REST non trovata: {Endpoint} - Entities disponibili: {Entities}", profile.DestinationEndpoint, string.Join(", ", restEntities.Select(e => e.Name))); } } else { Logger.LogWarning("Impossibile selezionare entità REST - Endpoint: {Endpoint}, Connected: {Connected}", profile.DestinationEndpoint, isRestConnected); } } else { Logger.LogWarning("Credenziale REST con ID {CredentialId} non trovata", profile.DestinationCredentialId); } } else { Logger.LogInformation("Nessuna credenziale destinazione da configurare"); } // Small delay to let destination configuration settle await Task.Delay(300); // Step 4: Applica mapping dei campi se disponibile if (!string.IsNullOrEmpty(profile.FieldMappingJson)) { Logger.LogInformation("Step 4 - Applicazione mapping campi..."); try { var service = new DataCouplerProfileService(null!); var mappings = service.DeserializeFieldMappings(profile.FieldMappingJson); Logger.LogInformation("Mappings deserializzati: {Count}", mappings.Count); // Applica i mapping fieldMappings.Clear(); keyFields.Clear(); foreach (var mapping in mappings) { fieldMappings[mapping.SourceField] = mapping.DestinationField; if (mapping.IsKey) { keyFields.Add(mapping.DestinationField); } Logger.LogInformation("Mapping applicato: {Source} -> {Destination} (IsKey: {IsKey})", mapping.SourceField, mapping.DestinationField, mapping.IsKey); } Logger.LogInformation("Mappings applicati - Totale: {MappingCount}, Chiavi: {KeyCount}", fieldMappings.Count, keyFields.Count); } catch (Exception ex) { Logger.LogWarning(ex, "Errore nel caricamento dei mapping dei campi dal profilo"); } } else { Logger.LogInformation("Nessun mapping campi da applicare"); } // Step 5: Applica configurazione chiave sorgente if (!string.IsNullOrEmpty(profile.SourceKeyField)) { sourceKeyField = profile.SourceKeyField; Logger.LogInformation("Step 5 - Chiave sorgente applicata: {SourceKey}", sourceKeyField); } else { Logger.LogInformation("Nessuna chiave sorgente da applicare"); } // Step 6: Applica configurazione associazioni record useRecordAssociations = profile.UseRecordAssociations; Logger.LogInformation("Step 6 - Associazioni record configurate: {UseAssociations}", useRecordAssociations); Logger.LogInformation("=== FINE APPLICAZIONE PROFILO ==="); Logger.LogInformation("Stato finale - Source: {SourceType}, DatabaseConnected: {DatabaseConnected}, RestConnected: {RestConnected}, Mappings: {MappingCount}", selectedSourceType, isDatabaseConnected, isRestConnected, fieldMappings.Count); } catch (Exception ex) { Logger.LogError(ex, "Errore nell'applicazione della configurazione del profilo {ProfileName}", profile.Name); await JSRuntime.InvokeVoidAsync("alert", $"Errore nel caricamento del profilo: {ex.Message}"); } finally { // Force final UI update StateHasChanged(); Logger.LogInformation("Aggiornamento finale UI completato"); } } private async Task OnProfileSaved(DataCouplerProfileDto profileDto) { try { Logger.LogInformation("Tentativo di salvataggio profilo: {ProfileName}", profileDto.Name); var profileService = new DataCouplerProfileService(null!); // Usa il service di conversione var profile = profileService.FromDto(profileDto, "System"); // TODO: Usa utente corrente // Validazione specifica per file CSV if (profile.SourceType == "file" && !string.IsNullOrEmpty(profile.SourceFilePath)) { Logger.LogInformation("Validazione file CSV: {FilePath}", profile.SourceFilePath); // Verifica che il file esista if (!System.IO.File.Exists(profile.SourceFilePath)) { await JSRuntime.InvokeVoidAsync("alert", $"Errore: Il file '{profile.SourceFilePath}' non esiste o non è accessibile. " + "Verifica il percorso del file prima di salvare il profilo."); return; } // Verifica che il file sia leggibile try { using var fs = new System.IO.FileStream(profile.SourceFilePath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); fs.Close(); Logger.LogInformation("File CSV validato con successo: {FilePath}", profile.SourceFilePath); } catch (Exception fileEx) { Logger.LogError(fileEx, "Errore nella lettura del file CSV: {FilePath}", profile.SourceFilePath); await JSRuntime.InvokeVoidAsync("alert", $"Errore: Il file '{profile.SourceFilePath}' non può essere letto. " + $"Dettagli: {fileEx.Message}"); return; } } // Controlla se esiste già un profilo con lo stesso nome (inclusi quelli inattivi) Logger.LogInformation("Controllo esistenza profilo con nome: {ProfileName}", profileDto.Name); var existingProfile = await ProfileService.GetProfileByNameIncludingInactiveAsync(profileDto.Name); if (existingProfile != null) { Logger.LogInformation("Trovato profilo esistente con ID: {ProfileId}, IsActive: {IsActive}", existingProfile.Id, existingProfile.IsActive); if (!existingProfile.IsActive) { // Il profilo esiste ma è inattivo - riattivalo e aggiornalo Logger.LogInformation("Riattivazione del profilo inattivo: {ProfileName}", profileDto.Name); profile.Id = existingProfile.Id; profile.IsActive = true; // Aggiorna direttamente il profilo esistente invece di creare un nuovo oggetto existingProfile.Description = profile.Description; existingProfile.SourceType = profile.SourceType; existingProfile.SourceCredentialId = profile.SourceCredentialId; existingProfile.SourceSchema = profile.SourceSchema; existingProfile.SourceTable = profile.SourceTable; existingProfile.SourceCustomQuery = profile.SourceCustomQuery; existingProfile.SourceFilePath = profile.SourceFilePath; existingProfile.DestinationType = profile.DestinationType; existingProfile.DestinationCredentialId = profile.DestinationCredentialId; existingProfile.DestinationSchema = profile.DestinationSchema; existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.FieldMappingJson = profile.FieldMappingJson; existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.UseRecordAssociations = profile.UseRecordAssociations; existingProfile.IsActive = true; await ProfileService.UpdateProfileAsync(existingProfile); await LoadProfiles(); await JSRuntime.InvokeVoidAsync("alert", $"Profilo '{profileDto.Name}' riattivato e aggiornato con successo!"); return; } // Il profilo esiste ed è attivo - chiedi conferma per sovrascrittura var confirmOverwrite = await JSRuntime.InvokeAsync("confirm", $"Esiste già un profilo attivo con il nome '{profileDto.Name}'. Vuoi sovrascriverlo?"); if (confirmOverwrite) { Logger.LogInformation("Utente ha confermato la sovrascrittura del profilo: {ProfileName}", profileDto.Name); // Aggiorna il profilo esistente direttamente existingProfile.Description = profile.Description; existingProfile.SourceType = profile.SourceType; existingProfile.SourceCredentialId = profile.SourceCredentialId; existingProfile.SourceSchema = profile.SourceSchema; existingProfile.SourceTable = profile.SourceTable; existingProfile.SourceCustomQuery = profile.SourceCustomQuery; existingProfile.SourceFilePath = profile.SourceFilePath; existingProfile.DestinationType = profile.DestinationType; existingProfile.DestinationCredentialId = profile.DestinationCredentialId; existingProfile.DestinationSchema = profile.DestinationSchema; existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.FieldMappingJson = profile.FieldMappingJson; existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.UseRecordAssociations = profile.UseRecordAssociations; await ProfileService.UpdateProfileAsync(existingProfile); await LoadProfiles(); // Ricarica la lista await JSRuntime.InvokeVoidAsync("alert", $"Profilo '{profileDto.Name}' aggiornato con successo!"); } else { Logger.LogInformation("Utente ha annullato la sovrascrittura del profilo: {ProfileName}", profileDto.Name); // Proponi di creare con un nome unico var useUniqueName = await JSRuntime.InvokeAsync("confirm", "Vuoi salvare il profilo con un nome unico automatico (es. 'Nome Profilo (1)')?"); if (useUniqueName) { var uniqueName = await GenerateUniqueProfileName(profileDto.Name); profile.Name = uniqueName; try { await ProfileService.SaveProfileAsync(profile); await LoadProfiles(); await JSRuntime.InvokeVoidAsync("alert", $"Profilo salvato con nome '{uniqueName}'!"); } catch (Exception uniqueEx) { Logger.LogError(uniqueEx, "Errore durante il salvataggio del profilo con nome unico: {UniqueName}", uniqueName); // Gestisci l'errore di unique constraint anche per il nome unico if (uniqueEx.Message.Contains("UNIQUE constraint failed")) { await JSRuntime.InvokeVoidAsync("alert", $"Errore: Non è stato possibile generare un nome unico per il profilo. " + "Prova a ricaricare la pagina e riprova."); } else { await JSRuntime.InvokeVoidAsync("alert", $"Errore nel salvataggio del profilo: {uniqueEx.Message}"); } } } // Altrimenti, non salvare nulla return; } } else { Logger.LogInformation("Nessun profilo esistente trovato, creazione nuovo profilo: {ProfileName}", profileDto.Name); // Crea un nuovo profilo try { await ProfileService.SaveProfileAsync(profile); await LoadProfiles(); // Ricarica la lista await JSRuntime.InvokeVoidAsync("alert", $"Profilo '{profileDto.Name}' salvato con successo!"); } catch (Exception saveEx) { Logger.LogError(saveEx, "Errore durante il salvataggio del nuovo profilo: {ProfileName}", profileDto.Name); // Possibile race condition - riprova con controllo duplicato if (saveEx.Message.Contains("UNIQUE constraint failed")) { Logger.LogWarning("Race condition rilevata durante il salvataggio, gestione del duplicato..."); // Chiedi se vuole sovrascrivere o creare nome unico var handleDuplicate = await JSRuntime.InvokeAsync("confirm", $"Un profilo con il nome '{profileDto.Name}' è stato creato nel frattempo. " + "Vuoi sovrascriverlo? (Clicca 'Annulla' per salvare con un nome unico)"); if (handleDuplicate) { // Trova il profilo e aggiornalo var duplicateProfile = await ProfileService.GetProfileByNameIncludingInactiveAsync(profileDto.Name); if (duplicateProfile != null) { profile.Id = duplicateProfile.Id; await ProfileService.UpdateProfileAsync(profile); await LoadProfiles(); await JSRuntime.InvokeVoidAsync("alert", $"Profilo '{profileDto.Name}' aggiornato con successo!"); } else { await JSRuntime.InvokeVoidAsync("alert", "Errore: Il profilo duplicato non è stato trovato."); } } else { // Crea con nome unico var uniqueName = await GenerateUniqueProfileName(profileDto.Name); profile.Name = uniqueName; await ProfileService.SaveProfileAsync(profile); await LoadProfiles(); await JSRuntime.InvokeVoidAsync("alert", $"Profilo salvato con nome '{uniqueName}'!"); } } else { throw; // Rilancia eccezioni non gestite } } } } catch (Exception ex) { Logger.LogError(ex, "Errore generale nel salvataggio del profilo: {ProfileName}", profileDto.Name); // Gestione generica degli errori if (ex.Message.Contains("UNIQUE constraint failed")) { await JSRuntime.InvokeVoidAsync("alert", $"Errore: Esiste già un profilo con il nome '{profileDto.Name}'. " + "Questo può accadere se ci sono stati problemi di sincronizzazione. " + "Prova a ricaricare la pagina e riprova."); } else { await JSRuntime.InvokeVoidAsync("alert", $"Errore nel salvataggio del profilo: {ex.Message}"); } } } private async Task OnProfileDeleted(int profileId) { try { var deleted = await ProfileService.DeleteProfileAsync(profileId); if (deleted) { await LoadProfiles(); // Ricarica la lista } } catch (Exception ex) { Logger.LogError(ex, "Errore nell'eliminazione del profilo"); throw; // Rilancia per gestire nell'UI } } private void OnManageProfiles() { showProfileManagement = true; } private void OnCloseProfileManagement() { showProfileManagement = false; } private bool CanSaveProfile() { return !string.IsNullOrEmpty(selectedSourceType) && (!string.IsNullOrEmpty(selectedDatabaseCredential) || !string.IsNullOrEmpty(selectedRestCredential)) && (!string.IsNullOrEmpty(selectedRestCredential) || !string.IsNullOrEmpty(selectedTable)); } private List GetCurrentFieldMappings() { var mappings = new List(); foreach (var mapping in fieldMappings) { mappings.Add(new FieldMappingDto { SourceField = mapping.Key, DestinationField = mapping.Value, IsKey = keyFields.Contains(mapping.Value), IsRequired = false, // TODO: Determina dai metadati DataType = "", // TODO: Determina dai metadati }); } return mappings; } private void ResetAllState() { ResetSourceState(); ResetDestinationState(); fieldMappings.Clear(); keyFields.Clear(); transferResults.Clear(); transferMessage = ""; } private void ResetDestinationState() { selectedRestCredential = ""; isConnectingRest = false; isRestConnected = false; restErrorMessage = ""; restEntities.Clear(); selectedRestEntity = null; restEntityDetails = null; restSearchTerm = ""; currentRestDiscovery = null; currentRestClient = null; } private void OnSourceTypeChanged(ChangeEventArgs e) { selectedSourceType = e.Value?.ToString() ?? ""; // Reset state when changing source type ResetSourceState(); } private void ResetSourceState() { // Reset database state ResetDatabaseState(); // Reset file state selectedFileName = ""; manualFilePath = ""; uploadedFilePath = ""; isProcessingFile = false; fileErrorMessage = ""; fileSheets.Clear(); fileData.Clear(); selectedSheet = ""; // Reset pagination currentPage = 1; // Reset mappings ClearAllMappings(); } /// /// Valida e carica un file dal percorso specificato manualmente /// private async Task ValidateAndLoadFileFromPath() { try { isProcessingFile = true; fileErrorMessage = ""; fileSheets.Clear(); fileData.Clear(); selectedSheet = ""; uploadedFilePath = ""; if (string.IsNullOrWhiteSpace(manualFilePath)) { fileErrorMessage = "Inserire il percorso del file"; return; } // Valida che il file esista if (!System.IO.File.Exists(manualFilePath)) { fileErrorMessage = $"Il file '{manualFilePath}' non esiste o non è accessibile"; Logger.LogWarning("File non trovato: {FilePath}", manualFilePath); return; } // Valida estensione var extension = Path.GetExtension(manualFilePath).ToLowerInvariant(); if (extension != ".xlsx" && extension != ".xls" && extension != ".csv") { fileErrorMessage = "Formato file non supportato. Utilizzare Excel (.xlsx, .xls) o CSV (.csv)"; return; } // Verifica che il file sia leggibile try { using var testStream = new System.IO.FileStream(manualFilePath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); testStream.Close(); } catch (Exception readEx) { fileErrorMessage = $"Il file non può essere letto: {readEx.Message}"; Logger.LogError(readEx, "Errore nella lettura del file: {FilePath}", manualFilePath); return; } Logger.LogInformation("Validazione file completata: {FilePath}", manualFilePath); // Carica il file dal percorso per preview selectedFileName = Path.GetFileName(manualFilePath); if (extension == ".csv") { await ProcessCsvFileFromPath(manualFilePath); } else { await ProcessExcelFileFromPath(manualFilePath); } // Se tutto è andato bene, salva il percorso validato uploadedFilePath = manualFilePath; Logger.LogInformation("File caricato con successo dal percorso: {FilePath}", uploadedFilePath); } catch (Exception ex) { Logger.LogError(ex, "Errore nella validazione/caricamento del file dal percorso: {FilePath}", manualFilePath); fileErrorMessage = $"Errore: {ex.Message}"; uploadedFilePath = ""; } finally { isProcessingFile = false; StateHasChanged(); } } /// /// Processa un file CSV dal percorso specificato /// private async Task ProcessCsvFileFromPath(string filePath) { using var stream = new System.IO.FileStream(filePath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); using var reader = new StreamReader(stream); var firstLine = await reader.ReadLineAsync(); if (string.IsNullOrEmpty(firstLine)) { fileErrorMessage = "Il file CSV è vuoto"; return; } var separator = DetectCsvSeparator(firstLine); var headers = ParseCsvLine(firstLine, separator); var sheetName = Path.GetFileNameWithoutExtension(filePath); fileSheets[sheetName] = headers; var dataRows = new List>(); string? line; while ((line = await reader.ReadLineAsync()) != null) { if (string.IsNullOrWhiteSpace(line)) continue; var values = ParseCsvLine(line, separator); var row = new Dictionary(); for (int i = 0; i < headers.Count; i++) { var value = i < values.Count ? values[i] : ""; row[headers[i]] = string.IsNullOrEmpty(value) ? "" : value; } dataRows.Add(row); } fileData[sheetName] = dataRows; selectedSheet = sheetName; Logger.LogInformation("File CSV processato: {FilePath}, Headers: {HeaderCount}, Rows: {RowCount}", filePath, headers.Count, dataRows.Count); } /// /// Processa un file Excel dal percorso specificato /// private async Task ProcessExcelFileFromPath(string filePath) { try { System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); using var stream = new System.IO.FileStream(filePath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); var extension = Path.GetExtension(filePath).ToLowerInvariant(); IExcelDataReader reader; if (extension == ".xlsx") { reader = ExcelReaderFactory.CreateOpenXmlReader(stream); } else if (extension == ".xls") { reader = ExcelReaderFactory.CreateBinaryReader(stream); } else { fileErrorMessage = "Formato Excel non supportato"; return; } using (reader) { var configuration = new ExcelDataSetConfiguration() { ConfigureDataTable = (_) => new ExcelDataTableConfiguration() { UseHeaderRow = true } }; var dataSet = reader.AsDataSet(configuration); foreach (DataTable table in dataSet.Tables) { var sheetName = table.TableName; var headers = new List(); var dataRows = new List>(); foreach (DataColumn column in table.Columns) { headers.Add(column.ColumnName); } foreach (DataRow dataRow in table.Rows) { var row = new Dictionary(); foreach (var header in headers) { var value = dataRow[header]; row[header] = value == DBNull.Value ? "" : value?.ToString() ?? ""; } dataRows.Add(row); } fileSheets[sheetName] = headers; fileData[sheetName] = dataRows; } if (fileSheets.Any()) { selectedSheet = fileSheets.First().Key; } Logger.LogInformation("File Excel processato: {FilePath}, Sheets: {SheetCount}", filePath, dataSet.Tables.Count); } await Task.CompletedTask; } catch (Exception ex) { Logger.LogError(ex, "Errore nel processing del file Excel: {FilePath}", filePath); throw; } } private async Task OnFileSelected(InputFileChangeEventArgs e) { try { isProcessingFile = true; fileErrorMessage = ""; fileSheets.Clear(); fileData.Clear(); selectedSheet = ""; var file = e.File; selectedFileName = file.Name; // Validate file type var extension = Path.GetExtension(file.Name).ToLowerInvariant(); if (extension != ".xlsx" && extension != ".xls" && extension != ".csv") { fileErrorMessage = "Formato file non supportato. Utilizzare Excel (.xlsx, .xls) o CSV (.csv)"; return; } Logger.LogInformation("File caricato per preview: {FileName}", file.Name); // Process file based on type (solo per preview, non salva sul server) if (extension == ".csv") { await ProcessCsvFile(file); } else { await ProcessExcelFile(file); } } catch (Exception ex) { Logger.LogError(ex, "Errore nell'elaborazione del file"); fileErrorMessage = $"Errore nell'elaborazione del file: {ex.Message}"; } finally { isProcessingFile = false; StateHasChanged(); } } private async Task ProcessCsvFile(IBrowserFile file) { using var stream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024); // Aumentato a 50MB using var reader = new StreamReader(stream); var firstLine = await reader.ReadLineAsync(); if (string.IsNullOrEmpty(firstLine)) { fileErrorMessage = "Il file CSV è vuoto"; return; } Logger.LogInformation("CSV first line: {FirstLine}", firstLine); // Detect separator automatically var separator = DetectCsvSeparator(firstLine); Logger.LogInformation("CSV separator detected: '{Separator}'", separator); // Parse headers (first row) - gestisce meglio i separatori var headers = ParseCsvLine(firstLine, separator); Logger.LogInformation("CSV headers parsed: {Headers}", string.Join(" | ", headers)); // For CSV, we create a single "sheet" with the filename var sheetName = Path.GetFileNameWithoutExtension(file.Name); fileSheets[sheetName] = headers; // Read data rows - rimuovo il limite di 1000 righe var dataRows = new List>(); string? line; int rowNumber = 2; // Starting from row 2 (after header) while ((line = await reader.ReadLineAsync()) != null) { if (string.IsNullOrWhiteSpace(line)) continue; var values = ParseCsvLine(line, separator); var row = new Dictionary(); for (int i = 0; i < headers.Count; i++) { var value = i < values.Count ? values[i] : ""; row[headers[i]] = string.IsNullOrEmpty(value) ? "" : value; } dataRows.Add(row); rowNumber++; // Log delle prime 3 righe per debug if (rowNumber <= 5) { Logger.LogInformation("CSV row {RowNumber}: {Values}", rowNumber - 1, string.Join(" | ", values)); } } fileData[sheetName] = dataRows; // Auto-seleziona il foglio per i CSV dato che ce n'è solo uno selectedSheet = sheetName; Logger.LogInformation("CSV file processed: {FileName}, Headers: {HeaderCount} ({Headers}), Rows: {RowCount}, Auto-selected sheet: {SheetName}", file.Name, headers.Count, string.Join(", ", headers), dataRows.Count, selectedSheet); } private List ParseCsvLine(string line, char separator = ',') { var result = new List(); var current = new StringBuilder(); bool inQuotes = false; for (int i = 0; i < line.Length; i++) { char c = line[i]; if (c == '"') { if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') { // Double quote - escaped quote current.Append('"'); i++; // Skip next quote } else { // Toggle quote mode inQuotes = !inQuotes; } } else if (c == separator && !inQuotes) { // End of field result.Add(current.ToString().Trim()); current.Clear(); } else { current.Append(c); } } // Add the last field result.Add(current.ToString().Trim()); return result; } private async Task ProcessExcelFile(IBrowserFile file) { try { using var stream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024); // 50MB max // Crea il reader Excel basato sull'estensione IExcelDataReader reader; var extension = Path.GetExtension(file.Name).ToLowerInvariant(); if (extension == ".xlsx") { reader = ExcelReaderFactory.CreateOpenXmlReader(stream); } else if (extension == ".xls") { reader = ExcelReaderFactory.CreateBinaryReader(stream); } else { fileErrorMessage = "Formato Excel non supportato. Utilizzare .xlsx o .xls"; return; } using (reader) { // Configura per utilizzare la prima riga come header var configuration = new ExcelDataSetConfiguration() { ConfigureDataTable = (_) => new ExcelDataTableConfiguration() { UseHeaderRow = true // Prima riga come header } }; // Converti in DataSet var dataSet = reader.AsDataSet(configuration); Logger.LogInformation("Excel file processed: {FileName}, Sheets: {SheetCount}", file.Name, dataSet.Tables.Count); // Processa ogni foglio foreach (DataTable table in dataSet.Tables) { var sheetName = table.TableName; var headers = new List(); var dataRows = new List>(); // Estrai i nomi delle colonne (headers) foreach (DataColumn column in table.Columns) { headers.Add(column.ColumnName); } Logger.LogInformation("Processing Excel sheet: {SheetName}, Columns: {ColumnCount}, Rows: {RowCount}", sheetName, headers.Count, table.Rows.Count); // Processa le righe di dati for (int i = 0; i < table.Rows.Count; i++) { var row = table.Rows[i]; var rowData = new Dictionary(); for (int j = 0; j < headers.Count; j++) { var cellValue = row[j]?.ToString() ?? ""; rowData[headers[j]] = string.IsNullOrEmpty(cellValue) ? "" : cellValue; } dataRows.Add(rowData); // Log delle prime 3 righe per debug if (i < 3) { Logger.LogInformation("Excel row {RowNumber} in {Sheet}: {Values}", i + 1, sheetName, string.Join(" | ", rowData.Values)); } } // Salva i dati del foglio fileSheets[sheetName] = headers; fileData[sheetName] = dataRows; Logger.LogInformation("Excel sheet completed: {SheetName}, Headers: {Headers}, Rows: {RowCount}", sheetName, string.Join(", ", headers), dataRows.Count); } // Auto-seleziona il primo foglio se non c'è una selezione if (fileSheets.Any() && string.IsNullOrEmpty(selectedSheet)) { selectedSheet = fileSheets.First().Key; Logger.LogInformation("Auto-selected first sheet: {SheetName}", selectedSheet); } Logger.LogInformation("Excel file processing completed: {FileName}, Total sheets: {SheetCount}, Selected: {SelectedSheet}", file.Name, fileSheets.Count, selectedSheet); } } catch (Exception ex) { Logger.LogError(ex, "Errore nell'elaborazione del file Excel: {FileName}", file.Name); fileErrorMessage = $"Errore nell'elaborazione del file Excel: {ex.Message}"; } await Task.CompletedTask; } private void SelectSheet(string sheetName) { selectedSheet = sheetName; // Reset pagination when changing sheet currentPage = 1; // Clear mappings when changing sheet ClearAllMappings(); // For file sources, try auto-selection and then require manual key selection if not found sourceKeyField = ""; suggestedPrimaryKey = ""; requiresManualKeySelection = true; // AUTO-SELECT della chiave per i file if (fileSheets.ContainsKey(sheetName)) { var columns = fileSheets[sheetName].ToList(); TryAutoSelectKeyForFile(columns); } StateHasChanged(); } // File preview pagination methods private void GoToPage(int page) { if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet)) return; var totalPages = GetTotalPages(selectedSheet); if (page >= 1 && page <= totalPages) { currentPage = page; StateHasChanged(); } } private void FirstPage() => GoToPage(1); private void PreviousPage() => GoToPage(currentPage - 1); private void NextPage() => GoToPage(currentPage + 1); private void LastPage() => GoToPage(GetTotalPages(selectedSheet)); private List> GetCurrentPageData() { if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet)) return new List>(); var allData = fileData[selectedSheet]; var skip = (currentPage - 1) * pageSize; return allData.Skip(skip).Take(pageSize).ToList(); } private int GetStartRecord() { if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet)) return 0; return (currentPage - 1) * pageSize + 1; } private int GetEndRecord() { if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet)) return 0; var totalRecords = fileData[selectedSheet].Count; var endRecord = currentPage * pageSize; return Math.Min(endRecord, totalRecords); } private void OnPageSizeChanged(ChangeEventArgs e) { if (int.TryParse(e.Value?.ToString(), out int newPageSize)) { pageSize = newPageSize; currentPage = 1; // Reset to first page when changing page size StateHasChanged(); } } private void CreateMapping() { if (string.IsNullOrEmpty(selectedDbColumn) || string.IsNullOrEmpty(selectedRestProperty)) return; // Rimuovi eventuali mapping esistenti per questo campo database if (fieldMappings.ContainsKey(selectedDbColumn)) { fieldMappings.Remove(selectedDbColumn); } // Crea il nuovo mapping fieldMappings[selectedDbColumn] = selectedRestProperty; Logger.LogInformation("Creato mapping: {DbColumn} -> {RestProperty}", selectedDbColumn, selectedRestProperty); // Deseleziona i campi selectedDbColumn = ""; selectedRestProperty = ""; } private void RemoveMapping() { if (string.IsNullOrEmpty(selectedDbColumn) || !fieldMappings.ContainsKey(selectedDbColumn)) return; fieldMappings.Remove(selectedDbColumn); Logger.LogInformation("Rimosso mapping per campo: {DbColumn}", selectedDbColumn); } private void RemoveSpecificMapping(string dbColumn) { if (fieldMappings.ContainsKey(dbColumn)) { fieldMappings.Remove(dbColumn); Logger.LogInformation("Rimosso mapping specifico per campo: {DbColumn}", dbColumn); } } private void ClearAllMappings() { fieldMappings.Clear(); selectedDbColumn = ""; selectedRestProperty = ""; sourceKeyField = ""; transferMessage = ""; transferMessageType = ""; Logger.LogInformation("Tutti i mapping e le configurazioni sono stati cancellati"); } private void AutoMapFields() { if (restEntityDetails == null) return; IEnumerable sourceColumns = new List(); // Ottiene le colonne in base al tipo di sorgente if (selectedSourceType == "database") { if (useCustomQuery && queryColumns.Any()) { sourceColumns = queryColumns; } else if (!useCustomQuery && databaseTables.ContainsKey(selectedTable)) { sourceColumns = databaseTables[selectedTable].Select(c => c.Name); } } else if (selectedSourceType == "file" && fileSheets.ContainsKey(selectedSheet)) { sourceColumns = fileSheets[selectedSheet]; } if (!sourceColumns.Any()) return; var restProperties = restEntityDetails.Properties; int mappingsCreated = 0; foreach (var sourceColumn in sourceColumns) { // Trova una proprietà REST con nome simile var matchingProperty = restProperties.FirstOrDefault(p => string.Equals(p.Name, sourceColumn, StringComparison.OrdinalIgnoreCase) || string.Equals(p.Name.Replace("_", ""), sourceColumn.Replace("_", ""), StringComparison.OrdinalIgnoreCase) || string.Equals(p.Name.Replace("Id", ""), sourceColumn.Replace("Id", ""), StringComparison.OrdinalIgnoreCase) ); if (matchingProperty != null && !fieldMappings.ContainsKey(sourceColumn)) { fieldMappings[sourceColumn] = matchingProperty.Name; mappingsCreated++; } } Logger.LogInformation("Auto-mapping completato. Creati {Count} mapping automatici da {SourceType}", mappingsCreated, useCustomQuery ? "query custom" : selectedSourceType); } private async Task ShowMappingSummary() { var summary = "Riepilogo Configurazione:\n\n"; summary += "=== MAPPING CAMPI ===\n"; foreach (var mapping in fieldMappings) { summary += $"• {mapping.Key} → {mapping.Value}\n"; } summary += "\n=== CONFIGURAZIONE ASSOCIAZIONI ===\n"; summary += $"• Sistema associazioni: {(useRecordAssociations ? "Abilitato" : "Disabilitato")}\n"; if (useRecordAssociations) { summary += $"• Campo chiave sorgente: {(!string.IsNullOrEmpty(sourceKeyField) ? sourceKeyField : "Rilevamento automatico")}\n"; } await JSRuntime.InvokeVoidAsync("alert", summary); } private async Task StartDataTransfer() { // Verifica se possiamo utilizzare le chiamate Composite (solo per Salesforce) if (currentRestClient is DataConnection.REST.Implementations.SalesforceServiceClient) { await StartDataTransferWithComposite(); return; } // Fallback al metodo originale per altri client REST // Se siamo con Salesforce, usa il nuovo metodo Composite if (currentRestClient is DataConnection.REST.Implementations.SalesforceServiceClient) { await StartDataTransferWithComposite(); return; } // Per altri client, usa il metodo originale await StartDataTransferOriginal(); } private async Task StartDataTransferOriginal() { if (!fieldMappings.Any() || currentRestClient == null || selectedRestEntity == null) { transferMessage = "Configurazione incompleta. Assicurati di aver selezionato la fonte dati, entità e configurato almeno una mappatura."; transferMessageType = "error"; return; } // Check source-specific requirements if (selectedSourceType == "database") { if (currentDatabaseManager == null) { transferMessage = "Database non connesso."; transferMessageType = "error"; return; } if (useCustomQuery) { if (!isQueryValid || string.IsNullOrWhiteSpace(customQuery)) { transferMessage = "Query custom non valida. Validare la query prima di procedere."; transferMessageType = "error"; return; } } else if (string.IsNullOrEmpty(selectedTable)) { transferMessage = "Tabella non selezionata."; transferMessageType = "error"; return; } } if (selectedSourceType == "file" && string.IsNullOrEmpty(selectedSheet)) { transferMessage = "File non caricato o foglio non selezionato."; transferMessageType = "error"; return; } // Validate source key field when using record associations if (useRecordAssociations && string.IsNullOrEmpty(sourceKeyField)) { transferMessage = "Campo chiave sorgente richiesto. Seleziona un campo che identifichi univocamente ogni record per utilizzare il sistema di associazioni."; transferMessageType = "error"; return; } isTransferringData = true; transferMessage = ""; transferMessageType = ""; transferResults.Clear(); try { var sourceName = selectedSourceType == "database" ? (useCustomQuery ? "custom_query" : selectedTable) : selectedSheet; Logger.LogInformation("Iniziando trasferimento dati da {SourceType} {Source} a {Entity} con {MappingCount} mappature", selectedSourceType, sourceName, selectedRestEntity.Name, fieldMappings.Count); // 1. Ottieni tutti i record dalla fonte dati var records = await GetAllRecordsFromSource(); Logger.LogInformation("Ottenuti {RecordCount} record da {SourceType} {Source}", records.Count(), selectedSourceType, sourceName); if (!records.Any()) { transferMessage = "Nessun record trovato nella fonte dati selezionata."; transferMessageType = "error"; return; } // 2. Ottieni i campi obbligatori dell'entità REST (se non ci sono campi chiave) var requiredFields = new HashSet(); if (!keyFields.Any() && restEntityDetails != null) { requiredFields = restEntityDetails.Properties .Where(p => p.IsRequired && fieldMappings.ContainsValue(p.Name)) .Select(p => p.Name) .ToHashSet(); Logger.LogInformation("Nessun campo chiave definito. Utilizzo {RequiredFieldsCount} campi obbligatori per controllo duplicati: {RequiredFields}", requiredFields.Count, string.Join(", ", requiredFields)); } // 3. Trasforma e trasferisci ogni record int successCount = 0; int errorCount = 0; int updatedCount = 0; int duplicateCount = 0; var errors = new List(); int recordNumber = 1; foreach (var record in records) { var transferResult = new TransferResult { RecordNumber = recordNumber, RecordData = new Dictionary(record) }; try { // Trasforma il record in base ai mapping var restData = TransformRecordToRestEntity(record); // Genera la chiave sorgente per questo record var sourceKey = GenerateSourceKey(record); // NUOVO SISTEMA: Cerca associazione esistente basata sul valore della chiave if (useRecordAssociations && !string.IsNullOrEmpty(sourceKey)) { Logger.LogInformation("ASSOCIATION DEBUG: Cerco associazione - KeyValue: '{KeyValue}', Entity: '{Entity}', Credential: '{Credential}'", sourceKey, selectedRestEntity?.Name ?? "Unknown", selectedRestCredential); // Cerca se esiste già un'associazione per questo valore chiave var existingAssociation = await CredentialService.FindKeyAssociationByValueAsync( sourceKey, selectedRestEntity?.Name ?? "", selectedRestCredential); // FALLBACK: Se non troviamo l'associazione con tutti i parametri, proviamo solo con il KeyValue if (existingAssociation == null) { Logger.LogWarning("ASSOCIATION DEBUG: Associazione non trovata con parametri specifici, provo solo con KeyValue: '{KeyValue}'", sourceKey); existingAssociation = await CredentialService.FindKeyAssociationByValueAsync(sourceKey); if (existingAssociation != null) { Logger.LogWarning("ASSOCIATION DEBUG: Trovata associazione con fallback - ID: {AssociationId}, Entity: '{Entity}', Credential: '{Credential}'", existingAssociation.Id, existingAssociation.DestinationEntity, existingAssociation.RestCredentialName); // Verifica se l'associazione trovata è compatibile if (existingAssociation.DestinationEntity != selectedRestEntity.Name || existingAssociation.RestCredentialName != selectedRestCredential) { Logger.LogWarning("ASSOCIATION DEBUG: Associazione non compatibile - Entity: '{FoundEntity}' vs '{ExpectedEntity}', Credential: '{FoundCredential}' vs '{ExpectedCredential}'", existingAssociation.DestinationEntity, selectedRestEntity.Name, existingAssociation.RestCredentialName, selectedRestCredential); existingAssociation = null; } } } // 🔍 PRE-DISCOVERY: Usa il servizio centralizzato if (existingAssociation == null) { var preDiscoveryRequest = new PreDiscoveryRequest { SourceKey = sourceKey, SourceKeyField = sourceKeyField, DestinationEntity = selectedRestEntity?.Name ?? "", CredentialName = selectedRestCredential, DestinationKeyField = GetEntityIdField(), FieldMappings = fieldMappings, RestClient = currentRestClient, CurrentDataHash = null, // Non serve per metodo original EnablePreDiscovery = true, UseParallelMethod = false, IsScheduledTransfer = false, SourceType = selectedSourceType }; existingAssociation = await AssociationService.FindOrCreateAssociationAsync(preDiscoveryRequest); } Logger.LogInformation("ASSOCIATION DEBUG: Associazione finale: {Found}. ID: {AssociationId}, DestinationId: '{DestinationId}', IsActive: {IsActive}", existingAssociation != null, existingAssociation?.Id, existingAssociation?.DestinationId, existingAssociation?.IsActive); if (existingAssociation != null && existingAssociation.IsActive) { // Prova direttamente l'aggiornamento - più efficiente che verificare prima l'esistenza Logger.LogInformation("ASSOCIATION DEBUG: Tentativo aggiornamento record esistente - DestinationId: '{DestinationId}'", existingAssociation.DestinationId); try { var updateResult = await currentRestClient.UpdateEntityAsync( selectedRestEntity.Name, existingAssociation.DestinationId, restData); if (updateResult != null) { updatedCount++; transferResult.Status = "updated"; transferResult.Message = $"Record aggiornato con successo tramite associazione (ID: {existingAssociation.DestinationId})"; transferResult.EntityId = existingAssociation.DestinationId; // Aggiorna l'associazione con la data di ultimo aggiornamento e verifica existingAssociation.UpdatedAt = DateTime.UtcNow; existingAssociation.LastVerifiedAt = DateTime.UtcNow; await CredentialService.UpdateKeyAssociationAsync(existingAssociation); Logger.LogInformation("ASSOCIATION DEBUG: Record aggiornato con successo tramite associazione: {EntityId} per valore chiave {KeyValue}", existingAssociation.DestinationId, sourceKey); transferResults.Add(transferResult); recordNumber++; continue; } else { // Update fallito ma senza eccezione - probabilmente l'entità non esiste più Logger.LogWarning("ASSOCIATION DEBUG: Aggiornamento fallito (result null) per associazione {AssociationId} - elimino associazione e creo nuovo record", existingAssociation.Id); goto HandleInvalidAssociation; } } catch (Exception updateEx) { // Update fallito con eccezione - probabilmente l'entità non esiste più Logger.LogWarning(updateEx, "ASSOCIATION DEBUG: Aggiornamento fallito per associazione {AssociationId} - elimino associazione e creo nuovo record", existingAssociation.Id); goto HandleInvalidAssociation; } HandleInvalidAssociation: // L'ID di destinazione non esiste più o l'update è fallito - elimina l'associazione non valida try { await CredentialService.DeleteKeyAssociationAsync(existingAssociation.Id); Logger.LogInformation("ASSOCIATION DEBUG: Associazione non valida eliminata: {AssociationId}", existingAssociation.Id); } catch (Exception delEx) { Logger.LogWarning(delEx, "Errore nell'eliminazione dell'associazione non valida {AssociationId}", existingAssociation.Id); } transferResult.Status = "info"; transferResult.Message = $"Associazione non valida eliminata (aggiornamento fallito) - creazione nuovo record"; // Procedi con la creazione di un nuovo record (non aggiungere il result qui, sarà aggiunto dopo CreateNewRecord) } } // Crea un nuovo record var result = await currentRestClient.CreateEntityAsync(selectedRestEntity.Name, restData); if (result != null) { successCount++; transferResult.Status = "success"; transferResult.Message = "Record inserito con successo"; transferResult.EntityId = result.ContainsKey("id") ? result["id"]?.ToString() : result.ContainsKey("Id") ? result["Id"]?.ToString() : result.ContainsKey("DocEntry") ? result["DocEntry"]?.ToString() : null; // Crea associazione solo se abbiamo una chiave sorgente e un ID destinazione if (useRecordAssociations && !string.IsNullOrEmpty(sourceKey) && !string.IsNullOrEmpty(transferResult.EntityId)) { try { // Determina i campi chiave automaticamente var destinationKeyField = GetEntityIdField(); // Campo chiave nella destinazione // Trova il campo destinazione (REST API) mappato al campo chiave sorgente string? mappedDestinationField = null; Logger.LogDebug("MAPPING DEBUG: Cercando il campo destinazione mappato al campo chiave sorgente '{SourceKeyField}'", sourceKeyField); Logger.LogDebug("MAPPING DEBUG: Mappings disponibili: {Mappings}", string.Join(", ", fieldMappings.Select(m => $"{m.Key} -> {m.Value}"))); // Cerca nel dizionario il campo destinazione corrispondente al campo chiave sorgente if (fieldMappings.TryGetValue(sourceKeyField, out var destinationFieldName)) { mappedDestinationField = destinationFieldName; Logger.LogDebug("MAPPING DEBUG: Trovato mapping: campo sorgente '{SourceField}' è mappato al campo destinazione '{DestField}'", sourceKeyField, mappedDestinationField); } else { Logger.LogWarning("MAPPING DEBUG: Campo chiave sorgente '{SourceKeyField}' NON trovato nei mappings! Il campo MappedDestinationField non verrà popolato.", sourceKeyField); } var association = new KeyAssociation { KeyValue = sourceKey, SourceKeyField = sourceKeyField, DestinationKeyField = destinationKeyField, MappedDestinationField = mappedDestinationField, // Campo destinazione mappato al campo chiave sorgente DestinationEntity = selectedRestEntity?.Name ?? "", DestinationId = transferResult.EntityId, RestCredentialName = selectedRestCredential, CreatedAt = DateTime.UtcNow, LastVerifiedAt = DateTime.UtcNow, AdditionalInfo = System.Text.Json.JsonSerializer.Serialize(new { TransferDate = DateTime.UtcNow, RecordNumber = recordNumber, MappingCount = fieldMappings.Count, SourceType = selectedSourceType }) }; Logger.LogInformation("ASSOCIATION DEBUG: Creazione nuova associazione - KeyValue: '{KeyValue}', Entity: '{Entity}', DestinationId: '{DestinationId}', Credential: '{Credential}', MappedField: '{MappedField}'", sourceKey, selectedRestEntity?.Name ?? "Unknown", transferResult.EntityId, selectedRestCredential, mappedDestinationField ?? "N/A"); var associationId = await CredentialService.SaveKeyAssociationAsync(association); Logger.LogInformation("DEBUG: Associazione salvata con ID: {AssociationId}", associationId); } catch (Exception assocEx) { Logger.LogWarning(assocEx, "Errore nella creazione dell'associazione per record {RecordNumber}", recordNumber); // Non interrompiamo il trasferimento per errori di associazione } } Logger.LogDebug("Record trasferito con successo: {Data}", string.Join(", ", restData.Select(kvp => $"{kvp.Key}={kvp.Value}"))); } else { errorCount++; transferResult.Status = "error"; transferResult.Message = "Errore nel trasferimento del record (result null)"; errors.Add($"Errore nel trasferimento del record {recordNumber}"); } } catch (Exception ex) { errorCount++; transferResult.Status = "error"; transferResult.Message = $"Errore: {ex.Message}"; errors.Add($"Errore nel trasferimento del record {recordNumber}: {ex.Message}"); Logger.LogError(ex, "Errore nel trasferimento del record {RecordNumber}", recordNumber); } transferResults.Add(transferResult); recordNumber++; } // 3.5 Sincronizzazione cancellazioni (DISABILITATA per trasferimenti manuali) // Questa funzionalità è disponibile solo per le schedulazioni con configurazione esplicita int deletedCount = 0; /* DELETION SYNC DISABILITATA PER TRASFERIMENTI MANUALI if (useRecordAssociations && !string.IsNullOrEmpty(sourceKeyField)) { try { Logger.LogInformation("Verifica sincronizzazione cancellazioni..."); // Estrai tutti i valori chiave presenti nella sorgente var sourceKeyValues = records .Select(r => r.ContainsKey(sourceKeyField) ? r[sourceKeyField]?.ToString() : null) .Where(k => !string.IsNullOrEmpty(k)) .Cast() .Distinct() .ToList(); Logger.LogInformation("Trovati {Count} valori chiave nella sorgente", sourceKeyValues.Count); // Sincronizza le cancellazioni var deletionOptions = new DeletionSyncOptions { Action = DeletionAction.Delete // Default: elimina fisicamente }; var deletionResult = await DeletionSyncService.SyncDeletionsAsync( sourceKeyValues, selectedRestEntity.Name, selectedRestCredential, currentRestClient, deletionOptions); deletedCount = deletionResult.DeletedRecordsSynced; if (deletionResult.DeletedRecordsDetected > 0) { Logger.LogInformation("Sincronizzazione cancellazioni: {Detected} rilevati, {Synced} sincronizzati, {Errors} errori", deletionResult.DeletedRecordsDetected, deletionResult.DeletedRecordsSynced, deletionResult.SyncErrors); // Aggiungi i dettagli delle cancellazioni ai risultati del trasferimento if (deletionResult.DeletedRecordsSynced > 0) { transferResults.Add(new TransferResult { RecordNumber = recordNumber++, Status = "deleted", Message = $"{deletionResult.DeletedRecordsSynced} record cancellati dalla destinazione" }); } // Aggiungi eventuali errori di sincronizzazione foreach (var error in deletionResult.Errors.Take(5)) // Primi 5 errori { transferResults.Add(new TransferResult { RecordNumber = recordNumber++, Status = "error", Message = $"Errore sincronizzazione cancellazione: {error}" }); } } } catch (Exception delEx) { Logger.LogError(delEx, "Errore durante la sincronizzazione delle cancellazioni"); transferResults.Add(new TransferResult { RecordNumber = recordNumber++, Status = "error", Message = $"Errore sincronizzazione cancellazioni: {delEx.Message}" }); } } */ // 4. Mostra risultati if (errorCount == 0) { var message = $"Trasferimento completato con successo! "; var messageParts = new List(); if (successCount > 0) messageParts.Add($"{successCount} record inseriti"); if (updatedCount > 0) messageParts.Add($"{updatedCount} record aggiornati"); if (deletedCount > 0) messageParts.Add($"{deletedCount} record cancellati"); if (duplicateCount > 0) messageParts.Add($"{duplicateCount} duplicati rilevati (warning)"); message += string.Join(", ", messageParts) + "."; transferMessage = message; transferMessageType = "success"; } else { var message = $"Trasferimento completato con {(duplicateCount > 0 ? "warning e " : "")}errori. "; var messageParts = new List(); if (successCount > 0) messageParts.Add($"Inserimenti: {successCount}"); if (updatedCount > 0) messageParts.Add($"Aggiornamenti: {updatedCount}"); if (deletedCount > 0) messageParts.Add($"Cancellazioni: {deletedCount}"); if (duplicateCount > 0) messageParts.Add($"Duplicati (warning): {duplicateCount}"); messageParts.Add($"Errori: {errorCount}"); message += string.Join(", ", messageParts); if (errors.Any()) { message += $". Primi errori: {string.Join("; ", errors.Take(3))}"; } transferMessage = message; transferMessageType = errorCount > 0 ? "error" : "warning"; } Logger.LogInformation("Trasferimento completato. Inserimenti: {SuccessCount}, Aggiornamenti: {UpdatedCount}, Cancellazioni: {DeletedCount}, Duplicati: {DuplicateCount}, Errori: {ErrorCount}", successCount, updatedCount, deletedCount, duplicateCount, errorCount); } catch (Exception ex) { Logger.LogError(ex, "Errore generale nel trasferimento dati"); transferMessage = $"Errore nel trasferimento dati: {ex.Message}"; transferMessageType = "error"; } finally { isTransferringData = false; } } private async Task>> GetAllRecordsFromSource() { if (selectedSourceType == "database") { return await GetAllRecordsFromDatabase(); } else if (selectedSourceType == "file") { return await GetAllRecordsFromFile(); } return new List>(); } private async Task>> GetAllRecordsFromDatabase() { if (currentDatabaseManager == null) return new List>(); try { if (useCustomQuery) { // Usa la query custom per ottenere tutti i record if (!isQueryValid || string.IsNullOrWhiteSpace(customQuery)) { throw new InvalidOperationException("Query custom non valida. Validare la query prima di procedere."); } // CONTROLLO DI SICUREZZA AGGIUNTIVO: Verifica che sia ancora una SELECT if (!IsSelectQuery(customQuery)) { throw new InvalidOperationException("ERRORE DI SICUREZZA: Tentativo di eseguire una query non SELECT. Operazione bloccata per sicurezza."); } var cleanQuery = CleanQuery(customQuery); Logger.LogInformation("Esecuzione query custom per trasferimento dati: {Query}", cleanQuery); return await currentDatabaseManager.ExecuteRawQueryAsync(cleanQuery); } else { // Usa il metodo standard per tabelle (nessun limite) if (string.IsNullOrEmpty(selectedTable)) { throw new InvalidOperationException("Nessuna tabella selezionata."); } Logger.LogInformation("Estrazione dati da tabella {Table} (nessun limite)", selectedTable); return await currentDatabaseManager.GetAllRecordsAsync(selectedTable); } } catch (Exception ex) { Logger.LogError(ex, "Errore nell'ottenere i record dal database. UseCustomQuery: {UseCustomQuery}, Table: {Table}, Query: {Query}", useCustomQuery, selectedTable, useCustomQuery ? customQuery : "N/A"); throw; } } private async Task>> GetAllRecordsFromFile() { if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet)) { return new List>(); } await Task.CompletedTask; return fileData[selectedSheet]; } private Dictionary TransformRecordToRestEntity(Dictionary dbRecord) { var restData = new Dictionary(); foreach (var mapping in fieldMappings) { string dbColumn = mapping.Key; string restProperty = mapping.Value; if (dbRecord.ContainsKey(dbColumn)) { var value = dbRecord[dbColumn]; // Trasforma il valore se necessario (es. date format, null handling, etc.) var transformedValue = TransformValue(value, dbColumn, restProperty); if (transformedValue != null) { restData[restProperty] = transformedValue; } } } Logger.LogDebug("Record trasformato: {DbColumns} → {RestProperties}", string.Join(", ", dbRecord.Keys), string.Join(", ", restData.Keys)); return restData; } private object? TransformValue(object? value, string dbColumn, string restProperty) { if (value == null || value == DBNull.Value) return null; // Ottieni informazioni sui tipi per fare trasformazioni intelligenti var dbColumnInfo = databaseTables.ContainsKey(selectedTable) ? databaseTables[selectedTable].FirstOrDefault(c => c.Name == dbColumn) : null; var restPropertyInfo = restEntityDetails?.Properties.FirstOrDefault(p => p.Name == restProperty); // Trasformazioni specifiche per tipo if (restPropertyInfo != null) { switch (restPropertyInfo.Type.ToLower()) { case "edm.string": return value.ToString(); case "edm.int32": case "edm.int64": if (int.TryParse(value.ToString(), out int intVal)) return intVal; break; case "edm.decimal": case "edm.double": // Usa InvariantCulture per garantire che i decimali usino il punto come separatore if (decimal.TryParse(value.ToString(), System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out decimal decVal)) return decVal; // Fallback: prova con la cultura corrente nel caso il valore usi la virgola if (decimal.TryParse(value.ToString(), out decVal)) return decVal; break; case "edm.boolean": if (bool.TryParse(value.ToString(), out bool boolVal)) return boolVal; // Gestisci anche valori numerici (0/1) come boolean if (value.ToString() == "1") return true; if (value.ToString() == "0") return false; break; case "edm.datetime": case "edm.datetimeoffset": if (DateTime.TryParse(value.ToString(), out DateTime dateVal)) return dateVal.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); break; } } // Fallback: restituisci il valore convertito a stringa return value.ToString(); } private string GetPropertyPlaceholder(RestPropertyInfo property) { return property.Type switch { "Edm.String" => $"Inserisci {property.Name}" + (property.MaxLength.HasValue ? $" (max {property.MaxLength})" : ""), "Edm.Int32" => "Numero intero", "Edm.Decimal" => "Numero decimale", "Edm.DateTime" => "Data/Ora (YYYY-MM-DD)", "Edm.Boolean" => "true/false", _ => $"Valore per {property.Name}" }; } public void Dispose() { currentDatabaseManager?.Dispose(); } private char DetectCsvSeparator(string line) { // Common separators to check var separators = new[] { ',', ';', '\t', '|' }; var counts = new Dictionary(); bool inQuotes = false; // Count separators outside of quotes foreach (char c in line) { if (c == '"') { inQuotes = !inQuotes; } else if (!inQuotes && separators.Contains(c)) { counts[c] = counts.GetValueOrDefault(c, 0) + 1; } } // Return the separator with the highest count, default to comma if (counts.Any()) { var mostCommon = counts.OrderByDescending(x => x.Value).First(); // Make sure we have at least one occurrence to avoid single-column files if (mostCommon.Value > 0) { return mostCommon.Key; } } return ','; // Default fallback } /// /// Verifica se il pulsante di trasferimento può essere abilitato /// private bool IsTransferButtonEnabled() { // Base requirements if (!fieldMappings.Any()) return false; // Se il sistema di associazioni è abilitato, il campo chiave sorgente è obbligatorio if (useRecordAssociations && string.IsNullOrEmpty(sourceKeyField)) return false; // Verifica che il campo chiave sia presente nei campi mappati if (useRecordAssociations && !string.IsNullOrEmpty(sourceKeyField)) { if (!fieldMappings.ContainsKey(sourceKeyField)) return false; } return true; } /// /// Ottiene il messaggio di errore che spiega perché il trasferimento non può essere avviato /// private string GetTransferDisabledReason() { if (!fieldMappings.Any()) return "Nessun campo mappato. Crea almeno un mapping tra i campi sorgente e destinazione."; if (useRecordAssociations && string.IsNullOrEmpty(sourceKeyField)) return "Campo chiave sorgente non selezionato. Seleziona un campo che identifichi univocamente i record."; if (useRecordAssociations && !string.IsNullOrEmpty(sourceKeyField)) { if (!fieldMappings.ContainsKey(sourceKeyField)) return $"Il campo chiave '{sourceKeyField}' deve essere mappato. Crea un mapping per questo campo prima di procedere con il trasferimento."; } return string.Empty; } // Helper methods per UI risultati private string GetResultRowClass(string status) { return status switch { "success" => "", "updated" => "table-info", "deleted" => "table-secondary", "duplicate" => "table-warning", "skipped" => "table-secondary", "error" => "table-danger", _ => "" }; } private string GetResultBadgeClass(string status) { return status switch { "success" => "bg-success", "updated" => "bg-info", "deleted" => "bg-secondary", "duplicate" => "bg-warning text-dark", "skipped" => "bg-secondary", "error" => "bg-danger", _ => "bg-secondary" }; } private string GetResultIcon(string status) { return status switch { "success" => "fa-check-circle", "updated" => "fa-edit", "deleted" => "fa-trash", "duplicate" => "fa-exclamation-triangle", "skipped" => "fa-forward", "error" => "fa-times-circle", _ => "fa-question-circle" }; } private string GetResultStatusText(string status) { return status switch { "success" => "Inserito", "updated" => "Aggiornato", "deleted" => "Cancellato", "duplicate" => "Duplicato", "skipped" => "Saltato", "error" => "Errore", _ => "Sconosciuto" }; } /// /// Genera una chiave univoca per il record sorgente /// private string GenerateSourceKey(Dictionary record) { try { // Il campo chiave sorgente deve essere sempre specificato if (string.IsNullOrEmpty(sourceKeyField)) { throw new InvalidOperationException("Campo chiave sorgente non specificato. La selezione del campo chiave è obbligatoria."); } if (!record.ContainsKey(sourceKeyField)) { throw new InvalidOperationException($"Il campo chiave '{sourceKeyField}' non è presente nel record sorgente."); } var keyValue = record[sourceKeyField]?.ToString(); if (string.IsNullOrEmpty(keyValue)) { throw new InvalidOperationException($"Il valore del campo chiave '{sourceKeyField}' è vuoto o null per questo record."); } // Normalizza il valore della chiave (trim e gestione case-sensitive) return keyValue.Trim(); } catch (Exception ex) { Logger.LogError(ex, "Errore nella generazione della chiave sorgente per il campo {SourceKeyField}", sourceKeyField); throw; } } /// /// Genera un hash SHA256 dei dati del record passato come parametro. /// Utilizzato per rilevare cambiamenti nei dati e ottimizzare il trasferimento. /// Calcola l'hash SOLO sui campi presenti nel record, in ordine alfabetico. /// private string GenerateDataHash(Dictionary record) { try { var valuesForHash = new List(); // Ordina le chiavi alfabeticamente per garantire consistenza var orderedKeys = record.Keys.OrderBy(k => k).ToList(); // Aggiungi i valori dei dati per ogni campo presente nel record foreach (var key in orderedKeys) { var value = record[key]; var normalizedValue = value?.ToString()?.Trim() ?? ""; valuesForHash.Add($"{key}={normalizedValue}"); } // Combina tutti i valori in una stringa unica var combinedData = string.Join("|", valuesForHash); // Log DETTAGLIATO per debugging hash Logger.LogInformation("🔍 HASH DEBUG: Generazione hash per {FieldCount} campi", orderedKeys.Count); Logger.LogInformation("🔍 HASH DEBUG: Campi ordinati: [{Fields}]", string.Join(", ", orderedKeys)); Logger.LogInformation("🔍 HASH DEBUG: Stringa combinata: {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.LogInformation("✅ HASH DEBUG: Hash finale generato: {Hash}", 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 /// private async Task ConnectToDatabaseWithSchema(string? specificSchema = null) { if (string.IsNullOrEmpty(selectedDatabaseCredential)) return; isConnectingDatabase = true; databaseErrorMessage = ""; try { // Trova la credenziale var credential = databaseCredentials.FirstOrDefault(c => c.Name == selectedDatabaseCredential); if (credential == null) { databaseErrorMessage = "Credenziale database non trovata"; return; } // Test della connessione var (success, message) = await CredentialService.TestDatabaseConnectionAsync(credential.Name); if (!success) { databaseErrorMessage = $"Connessione fallita: {message}"; return; } // Crea il database manager Logger.LogInformation("Creando database manager per credenziale: {CredentialName}", selectedDatabaseCredential); currentDatabaseManager = await ConnectionFactory.CreateDatabaseManagerAsync(selectedDatabaseCredential); Logger.LogInformation("Database manager creato con successo"); // Se è specificato uno schema, utilizzalo direttamente if (!string.IsNullOrEmpty(specificSchema)) { Logger.LogInformation("Utilizzando schema specifico: {Schema}", specificSchema); await LoadSchemaForDatabase(specificSchema); } else { // Prova il discovery automatico dello schema await DiscoverDatabaseSchema(); } if (databaseTables.Count > 0) { isDatabaseConnected = true; Logger.LogInformation("Connessione database completata con {TableCount} tabelle", databaseTables.Count); } } catch (Exception ex) { Logger.LogError(ex, "Errore nella connessione al database"); databaseErrorMessage = $"Errore: {ex.Message}"; } finally { isConnectingDatabase = false; StateHasChanged(); } } /// /// Scopre automaticamente lo schema del database /// private async Task DiscoverDatabaseSchema() { try { Logger.LogInformation("Iniziando discovery automatico dello schema"); var schema = await currentDatabaseManager!.GetDatabaseSchemaAsync(); Logger.LogInformation("Schema discovery completato. Numero elementi: {Count}", schema?.Count() ?? 0); databaseTables = schema as Dictionary> ?? (schema != null ? new Dictionary>(schema) : new Dictionary>()); if (databaseTables.Count == 0) { // Se non ci sono tabelle, potrebbe essere necessario selezionare un database specifico // Schema discovery completato senza successo databaseErrorMessage = "Impossibile rilevare le tabelle del database. Verificare le credenziali di connessione."; } else { // Rileva e salva lo schema corrente se presente nelle chiavi delle tabelle var firstTableKey = databaseTables.Keys.FirstOrDefault(); if (!string.IsNullOrEmpty(firstTableKey) && firstTableKey.Contains('.')) { var detectedSchema = firstTableKey.Split('.')[0]; Logger.LogInformation("Schema rilevato automaticamente: {Schema}", detectedSchema); } } } catch (Exception ex) { Logger.LogError(ex, "Errore durante il discovery automatico dello schema"); throw; } } /// /// Carica lo schema per un database specifico /// private async Task LoadSchemaForDatabase(string schemaName) { try { // TODO: Implementare la logica specifica per il caricamento di uno schema // Per ora utilizziamo il discovery standard e filtriamo i risultati var schema = await currentDatabaseManager!.GetDatabaseSchemaAsync(); databaseTables = schema as Dictionary> ?? new Dictionary>(); // Filtra le tabelle per lo schema specificato if (!string.IsNullOrEmpty(schemaName)) { var filteredTables = databaseTables .Where(kvp => kvp.Key.StartsWith($"{schemaName}.", StringComparison.OrdinalIgnoreCase)) .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); if (filteredTables.Any()) { databaseTables = filteredTables; Logger.LogInformation("Caricate {TableCount} tabelle per lo schema {Schema}", filteredTables.Count, schemaName); } else { Logger.LogWarning("Nessuna tabella trovata per lo schema {Schema}", schemaName); } } } catch (Exception ex) { Logger.LogError(ex, "Errore nel caricamento dello schema {Schema}", schemaName); throw; } } /// /// Estrae lo schema dal nome completo di una tabella /// private string? ExtractSchemaFromTableName(string fullTableName) { if (string.IsNullOrEmpty(fullTableName) || !fullTableName.Contains('.')) return null; var parts = fullTableName.Split('.'); return parts.Length > 1 ? parts[0] : null; } /// /// Ottiene lo schema correntemente utilizzato dal database connesso /// private string? GetCurrentDatabaseSchema() { if (!databaseTables.Any()) return null; var firstTable = databaseTables.Keys.FirstOrDefault(); return !string.IsNullOrEmpty(firstTable) ? ExtractSchemaFromTableName(firstTable) : null; } /// /// Ottiene il campo ID dell'entità REST selezionata /// private string GetEntityIdField() { if (restEntityDetails?.Properties != null) { // Cerca il campo ID (tipicamente "Id", "ID", "id", o il primo campo che contiene "id") var idProperty = restEntityDetails.Properties.FirstOrDefault(p => p.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) || p.Name.Equals("ID", StringComparison.OrdinalIgnoreCase) || p.Name.Contains("id", StringComparison.OrdinalIgnoreCase)); return idProperty?.Name ?? "Id"; // Default a "Id" se non trovato } return "Id"; } /// /// Verifica se una query è una SELECT query sicura /// private bool IsSelectQuery(string query) { if (string.IsNullOrWhiteSpace(query)) return false; var trimmedQuery = query.Trim(); // Deve iniziare con SELECT if (!trimmedQuery.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)) return false; // Lista di parole chiave vietate per sicurezza var forbiddenKeywords = new[] { "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE", "EXEC", "EXECUTE", "sp_", "xp_", "BULK", "OPENROWSET", "OPENDATASOURCE" }; var upperQuery = trimmedQuery.ToUpperInvariant(); // Verifica che non contenga parole chiave vietate foreach (var keyword in forbiddenKeywords) { if (upperQuery.Contains(keyword)) { Logger.LogWarning("Query rifiutata: contiene parola chiave vietata '{Keyword}'", keyword); return false; } } return true; } /// /// Pulisce una query SQL rimuovendo caratteri pericolosi /// private string CleanQuery(string query) { if (string.IsNullOrWhiteSpace(query)) return ""; // Rimuove caratteri potenzialmente pericolosi var cleanQuery = query.Trim(); // Rimuove eventuali terminatori multipli while (cleanQuery.EndsWith(";")) { cleanQuery = cleanQuery.Substring(0, cleanQuery.Length - 1).Trim(); } // Rimuove caratteri di controllo pericolosi cleanQuery = System.Text.RegularExpressions.Regex.Replace(cleanQuery, @"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", ""); // Normalizza spazi multipli cleanQuery = System.Text.RegularExpressions.Regex.Replace(cleanQuery, @"\s+", " "); return cleanQuery; } /// /// Gestisce il cambio di modalità tra tabelle e query custom /// private void OnQueryModeChanged(ChangeEventArgs e) { useCustomQuery = (bool)(e.Value ?? false); // Reset stato quando cambia modalità if (useCustomQuery) { // Reset selezione tabella selectedTable = ""; ClearAllMappings(); } else { // Reset query custom customQuery = ""; isQueryValid = false; queryValidationMessage = ""; queryPreviewData.Clear(); queryColumns.Clear(); showQueryPreview = false; } StateHasChanged(); } /// /// Ottiene l'ID della credenziale sorgente corrente /// private async Task GetCurrentSourceCredentialIdAsync() { if (selectedSourceType == "database" && !string.IsNullOrEmpty(selectedDatabaseCredential)) { try { // Usa il nuovo metodo per ottenere direttamente l'ID della credenziale return await CredentialService.GetCredentialIdByNameAsync(selectedDatabaseCredential, CredentialManager.Models.CredentialType.Database); } catch (Exception ex) { Logger.LogError(ex, "Errore nell'ottenere l'ID della credenziale database: {CredentialName}", selectedDatabaseCredential); return null; } } return null; } /// /// Ottiene l'ID della credenziale destinazione corrente /// private async Task GetCurrentDestinationCredentialIdAsync() { if (!string.IsNullOrEmpty(selectedRestCredential)) { try { // Usa il nuovo metodo per ottenere direttamente l'ID della credenziale return await CredentialService.GetCredentialIdByNameAsync(selectedRestCredential, CredentialManager.Models.CredentialType.RestApi); } catch (Exception ex) { Logger.LogError(ex, "Errore nell'ottenere l'ID della credenziale REST: {CredentialName}", selectedRestCredential); return null; } } return null; } /// /// Annulla la selezione dello schema /// private void CancelSchemaSelection() { showSchemaSelectionModal = false; selectedSchema = ""; StateHasChanged(); } /// /// Ottiene le colonne di una tabella specifica /// private async Task> GetTableColumns(string fullTableName, string databaseName, string tableName) { var columns = new List(); try { var credential = databaseCredentials.FirstOrDefault(c => c.Name == selectedDatabaseCredential); if (credential == null) return columns; var columnsQuery = credential.DatabaseType switch { DatabaseType.SqlServer => $@" SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, CASE WHEN CHARACTER_MAXIMUM_LENGTH IS NOT NULL THEN CHARACTER_MAXIMUM_LENGTH ELSE NUMERIC_PRECISION END as MAX_LENGTH FROM {databaseName}.INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '{tableName}' ORDER BY ORDINAL_POSITION", DatabaseType.MySql => $@" SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, CASE WHEN CHARACTER_MAXIMUM_LENGTH IS NOT NULL THEN CHARACTER_MAXIMUM_LENGTH ELSE NUMERIC_PRECISION END as MAX_LENGTH FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '{databaseName}' AND TABLE_NAME = '{tableName}' ORDER BY ORDINAL_POSITION", DatabaseType.PostgreSql => $@" SELECT column_name as COLUMN_NAME, data_type as DATA_TYPE, is_nullable as IS_NULLABLE, character_maximum_length as MAX_LENGTH FROM information_schema.columns WHERE table_schema = '{databaseName}' AND table_name = '{tableName}' ORDER BY ordinal_position", DatabaseType.Oracle => $@" SELECT COLUMN_NAME, DATA_TYPE, NULLABLE as IS_NULLABLE, DATA_LENGTH as MAX_LENGTH FROM ALL_TAB_COLUMNS WHERE OWNER = '{databaseName.ToUpper()}' AND TABLE_NAME = '{tableName.ToUpper()}' ORDER BY COLUMN_ID", _ => $@" SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH as MAX_LENGTH FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '{databaseName}' AND TABLE_NAME = '{tableName}' ORDER BY ORDINAL_POSITION" }; var columnResults = await currentDatabaseManager!.ExecuteRawQueryAsync(columnsQuery); if (columnResults != null) { foreach (var row in columnResults) { var columnName = row.GetValueOrDefault("COLUMN_NAME")?.ToString() ?? ""; var dataType = row.GetValueOrDefault("DATA_TYPE")?.ToString() ?? ""; var isNullable = row.GetValueOrDefault("IS_NULLABLE")?.ToString()?.ToUpper() == "YES"; if (int.TryParse(row.GetValueOrDefault("MAX_LENGTH")?.ToString(), out int maxLength)) { // Usa maxLength se necessario } if (!string.IsNullOrEmpty(columnName)) { columns.Add(new DbColumnInfo { Name = columnName, DataType = dataType, IsNullable = isNullable }); } } } } catch (Exception ex) { Logger.LogError(ex, "Errore nell'ottenere le colonne per la tabella {TableName}", fullTableName); } return columns; } /// /// Conferma la selezione dello schema /// private async Task OnSchemaSelected() { if (string.IsNullOrEmpty(selectedSchema)) return; showSchemaSelectionModal = false; try { Logger.LogInformation("Schema selezionato: {Schema}. Riconnessione al database...", selectedSchema); // Riconnetti al database utilizzando lo schema selezionato await ConnectToDatabaseWithSchema(selectedSchema); if (isDatabaseConnected) { Logger.LogInformation("Connessione completata con successo usando lo schema {Schema}", selectedSchema); databaseErrorMessage = ""; // Pulisci eventuali errori precedenti } } catch (Exception ex) { Logger.LogError(ex, "Errore nella connessione con lo schema selezionato"); databaseErrorMessage = $"Errore nella connessione al database {selectedSchema}: {ex.Message}"; } StateHasChanged(); } /// /// Carica la lista degli schemi disponibili /// private async Task LoadAvailableSchemas() { if (currentDatabaseManager == null) return; isLoadingSchemas = true; availableSchemas.Clear(); try { // Prova a ottenere tutti gli schemi/database disponibili // Questo metodo potrebbe non essere disponibile su tutti i database manager // In tal caso, proveremo con una query diretta try { var allSchemas = await currentDatabaseManager.GetDatabaseSchemaAsync(); if (allSchemas != null) { // Estrai i nomi degli schemi dalle chiavi delle tabelle var schemaNames = allSchemas.Keys .Where(key => key.Contains('.')) .Select(key => key.Split('.')[0]) .Distinct() .OrderBy(schema => schema) .ToList(); if (schemaNames.Any()) { availableSchemas.AddRange(schemaNames); Logger.LogInformation("Rilevati {SchemaCount} schemi dalle tabelle: {Schemas}", schemaNames.Count, string.Join(", ", schemaNames)); return; } } } catch (Exception ex) { Logger.LogWarning(ex, "Impossibile ottenere schemi dal database manager, provo con query dirette"); } // Se il metodo sopra non funziona, prova con query SQL specifiche per database await TryLoadSchemasWithDirectQuery(); } catch (Exception ex) { Logger.LogError(ex, "Errore nel caricamento degli schemi disponibili"); } finally { isLoadingSchemas = false; } } private async Task GenerateUniqueProfileName(string baseName) { var uniqueName = baseName; var counter = 1; while (await ProfileService.GetProfileByNameIncludingInactiveAsync(uniqueName) != null) { uniqueName = $"{baseName} ({counter})"; counter++; } return uniqueName; } private void TryAutoSelectKeyForFile(List columns) { try { // Reset stato chiave sourceKeyField = ""; suggestedPrimaryKey = ""; requiresManualKeySelection = true; // Pattern comuni per identificare possibili chiavi primarie nei file var keyPatterns = new[] { "id", "ID", "Id", "_id", "_ID", "_Id", "key", "KEY", "Key", "code", "CODE", "Code", "codice", "CODICE", "Codice", "number", "NUMBER", "Number", "numero", "NUMERO", "Numero", "index", "INDEX", "Index", "indice", "INDICE", "Indice" }; // Cerca colonne che potrebbero essere chiavi primarie string? detectedKey = null; // 1. Cerca esattamente "id", "ID", "Id" detectedKey = columns.FirstOrDefault(c => c.Equals("id", StringComparison.OrdinalIgnoreCase) || c.Equals("ID", StringComparison.Ordinal) || c.Equals("Id", StringComparison.Ordinal) || c.Equals("codice", StringComparison.OrdinalIgnoreCase)); // 2. Se non trovato, cerca colonne che terminano con pattern comuni if (detectedKey == null) { foreach (var pattern in keyPatterns.Take(6)) { detectedKey = columns.FirstOrDefault(c => c.EndsWith(pattern, StringComparison.OrdinalIgnoreCase)); if (detectedKey != null) break; } } // 3. Se non trovato, cerca colonne che contengono pattern di chiave if (detectedKey == null) { foreach (var pattern in keyPatterns) { detectedKey = columns.FirstOrDefault(c => c.Contains(pattern, StringComparison.OrdinalIgnoreCase)); if (detectedKey != null) break; } } // Auto-seleziona se trovato if (!string.IsNullOrEmpty(detectedKey)) { sourceKeyField = detectedKey; suggestedPrimaryKey = detectedKey; requiresManualKeySelection = false; Logger.LogInformation("Chiave auto-selezionata per file: {KeyField}", detectedKey); } else { Logger.LogInformation("Nessuna chiave rilevabile automaticamente per file, selezione manuale richiesta"); } } catch (Exception ex) { Logger.LogError(ex, "Errore nell'auto-selezione della chiave per file"); sourceKeyField = ""; suggestedPrimaryKey = ""; requiresManualKeySelection = true; } } private async Task StartDataTransferWithComposite() { if (!fieldMappings.Any() || currentRestClient == null || selectedRestEntity == null) { transferMessage = "Configurazione incompleta. Assicurati di aver selezionato la fonte dati, entità e configurato almeno una mappatura."; transferMessageType = "error"; return; } // Verifica che sia Salesforce if (!(currentRestClient is DataConnection.REST.Implementations.SalesforceServiceClient salesforceClient)) { // Fallback al metodo originale per altri client await StartDataTransfer(); return; } // Check source-specific requirements if (selectedSourceType == "database") { if (currentDatabaseManager == null) { transferMessage = "Database non connesso."; transferMessageType = "error"; return; } if (useCustomQuery) { if (!isQueryValid || string.IsNullOrWhiteSpace(customQuery)) { transferMessage = "Query custom non valida. Validare la query prima di procedere."; transferMessageType = "error"; return; } } else if (string.IsNullOrEmpty(selectedTable)) { transferMessage = "Tabella non selezionata."; transferMessageType = "error"; return; } } if (selectedSourceType == "file" && string.IsNullOrEmpty(selectedSheet)) { transferMessage = "File non caricato o foglio non selezionato."; transferMessageType = "error"; return; } // Validate source key field when using record associations if (useRecordAssociations && string.IsNullOrEmpty(sourceKeyField)) { transferMessage = "Campo chiave sorgente richiesto. Seleziona un campo che identifichi univocamente ogni record per utilizzare il sistema di associazioni."; transferMessageType = "error"; return; } isTransferringData = true; transferMessage = ""; transferMessageType = ""; transferResults.Clear(); try { var sourceName = selectedSourceType == "database" ? (useCustomQuery ? "custom_query" : selectedTable) : selectedSheet; Logger.LogInformation("Iniziando trasferimento dati COMPOSITE da {SourceType} {Source} a {Entity} con {MappingCount} mappature", selectedSourceType, sourceName, selectedRestEntity.Name, fieldMappings.Count); // 1. Ottieni tutti i record dalla fonte dati var records = await GetAllRecordsFromSource(); Logger.LogInformation("Ottenuti {RecordCount} record da {SourceType} {Source}", records.Count(), selectedSourceType, sourceName); if (!records.Any()) { transferMessage = "Nessun record trovato nella fonte dati selezionata."; transferMessageType = "error"; return; } // 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, string newDataHash)>(); var recordsSkipped = new ConcurrentBag<(Dictionary originalRecord, int recordNumber, string reason)>(); var recordErrors = new ConcurrentBag(); // Cattura i valori condivisi per evitare race conditions var currentEntityName = selectedRestEntity.Name; var currentCredentialName = selectedRestCredential; var currentUseRecordAssociations = useRecordAssociations; var currentSourceKeyField = sourceKeyField; // Cattura il campo chiave per uso parallelo var currentRestClient = this.currentRestClient; // Cattura il client REST var currentFieldMappings = new Dictionary(fieldMappings); // Copia dei mappings // Crea lista indicizzata per mantenere il record number var indexedRecords = records.Select((record, index) => new { Record = record, RecordNumber = index + 1 }).ToList(); Logger.LogInformation("COMPOSITE: Inizio analisi parallela di {RecordCount} record", indexedRecords.Count); var analysisStartTime = DateTime.UtcNow; // Processa tutti i record in parallelo var processingTasks = indexedRecords.Select(async indexedRecord => { try { var record = indexedRecord.Record; var recordNumber = indexedRecord.RecordNumber; // Trasforma il record in base ai mapping (operazione locale, thread-safe) var restData = TransformRecordToRestEntity(record); // Genera la chiave sorgente e l'hash dei dati per questo record (operazioni locali, thread-safe) var sourceKey = GenerateSourceKey(record); // ✅ Calcola l'hash SOLO sui dati trasformati/mappati che vengono effettivamente trasferiti var currentDataHash = GenerateDataHash(restData); // 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}'", sourceKey, currentEntityName, currentCredentialName); // Usa i metodi paralleli per le operazioni di database var existingAssociation = await CredentialService.FindKeyAssociationByValueParallelAsync( sourceKey, currentEntityName, currentCredentialName); // FALLBACK: Se non troviamo l'associazione con tutti i parametri, proviamo solo con il KeyValue if (existingAssociation == null) { existingAssociation = await CredentialService.FindKeyAssociationByValueParallelAsync(sourceKey); if (existingAssociation != null) { // Verifica compatibilità if (existingAssociation.DestinationEntity != currentEntityName || existingAssociation.RestCredentialName != currentCredentialName) { existingAssociation = null; } } } // 🔍 PRE-DISCOVERY: Usa il servizio centralizzato if (existingAssociation == null) { var preDiscoveryRequest = new PreDiscoveryRequest { SourceKey = sourceKey, SourceKeyField = currentSourceKeyField, DestinationEntity = currentEntityName, CredentialName = currentCredentialName, DestinationKeyField = GetEntityIdField(), FieldMappings = currentFieldMappings, RestClient = currentRestClient, CurrentDataHash = currentDataHash, EnablePreDiscovery = true, UseParallelMethod = true, // Usa metodi paralleli thread-safe IsScheduledTransfer = false }; existingAssociation = await AssociationService.FindOrCreateAssociationAsync(preDiscoveryRequest); } if (existingAssociation != null && existingAssociation.IsActive) { // 🔍 PRE-DISCOVERY: Verifica se è un'associazione Pre-Discovery var isPreDiscoveryAssociation = AssociationService.IsPreDiscoveryAssociation(existingAssociation); // Se l'associazione è Pre-Discovery (prima sincronizzazione), FORZA l'aggiornamento if (isPreDiscoveryAssociation) { // PRIMA SINCRONIZZAZIONE: Forza aggiornamento senza controllo hash recordsForUpdate.Add((restData, existingAssociation.DestinationId, record, recordNumber, currentDataHash)); Logger.LogInformation("🔄 PRIMA SINCRONIZZAZIONE (Pre-Discovery) - Record {RecordNumber} marcato per AGGIORNAMENTO FORZATO - EntityId: {EntityId}", recordNumber, existingAssociation.DestinationId); // 🔄 RESET FLAG PRE-DISCOVERY IMMEDIATO: Marca l'associazione come "normale" // così che i trasferimenti successivi usino il controllo hash standard if (!string.IsNullOrEmpty(existingAssociation.AdditionalInfo)) { try { var additionalInfo = System.Text.Json.JsonSerializer.Deserialize>(existingAssociation.AdditionalInfo); if (additionalInfo != null && additionalInfo.ContainsKey("CreatedBy")) { var createdBy = additionalInfo["CreatedBy"]?.ToString(); if (createdBy == "PreDiscovery") { // Rimuovi la chiave CreatedBy o impostala a un valore diverso additionalInfo.Remove("CreatedBy"); existingAssociation.AdditionalInfo = System.Text.Json.JsonSerializer.Serialize(additionalInfo); // Aggiorna l'associazione nel database SUBITO await CredentialService.UpdateKeyAssociationAsync(existingAssociation); Logger.LogDebug("COMPOSITE PARALLEL: Flag Pre-Discovery resettato immediatamente per entityId {EntityId}", existingAssociation.DestinationId); } } } catch (Exception ex) { Logger.LogWarning(ex, "COMPOSITE PARALLEL: Errore nel reset immediato del flag Pre-Discovery per entityId {EntityId}", existingAssociation.DestinationId); } } } else { // SINCRONIZZAZIONI SUCCESSIVE: Applica controllo hash standard var existingHash = existingAssociation.Data_Hash; Logger.LogInformation("🔍 CONFRONTO HASH - Record {RecordNumber}:", recordNumber); Logger.LogInformation(" 📌 Hash esistente: {ExistingHash}", existingHash ?? "NULL"); Logger.LogInformation(" 📌 Hash corrente: {CurrentHash}", currentDataHash); // Se l'hash esiste ed è identico, salta il record 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.LogInformation("✅ HASH IDENTICO - Record {RecordNumber} saltato", recordNumber); } else { // I dati sono cambiati o l'hash è vuoto, procedi con l'aggiornamento recordsForUpdate.Add((restData, existingAssociation.DestinationId, record, recordNumber, currentDataHash)); Logger.LogWarning("⚠️ HASH DIVERSO - Record {RecordNumber} marcato per aggiornamento (EntityId: {EntityId})", recordNumber, existingAssociation.DestinationId); } } } else { // Record da creare (nessuna associazione esistente) recordsForCreate.Add((restData, record, recordNumber)); Logger.LogDebug("COMPOSITE PARALLEL: Record {RecordNumber} marcato per creazione", recordNumber); } } else { // Record da creare (no associazioni) recordsForCreate.Add((restData, record, recordNumber)); Logger.LogDebug("COMPOSITE PARALLEL: Record {RecordNumber} marcato per creazione (no associazioni)", recordNumber); } } catch (Exception ex) { Logger.LogError(ex, "COMPOSITE PARALLEL: Errore nella trasformazione del record {RecordNumber}", indexedRecord.RecordNumber); recordErrors.Add(new TransferResult { RecordNumber = indexedRecord.RecordNumber, RecordData = new Dictionary(indexedRecord.Record), Status = "error", Message = $"Errore trasformazione: {ex.Message}" }); } }); // Attendi il completamento di tutte le operazioni parallele await Task.WhenAll(processingTasks); var analysisEndTime = DateTime.UtcNow; var analysisElapsed = (analysisEndTime - analysisStartTime).TotalMilliseconds; // Aggiungi gli errori ai risultati di trasferimento foreach (var error in recordErrors) { transferResults.Add(error); } // 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, {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()); var updateTask = Task.FromResult(new List()); if (finalRecordsForCreate.Any()) { var createData = finalRecordsForCreate.Select(r => r.transformedData).ToList(); createTask = salesforceClient.BatchCreateEntitiesAsync(selectedRestEntity.Name, createData); } if (finalRecordsForUpdate.Any()) { var updateData = finalRecordsForUpdate.ToDictionary( r => r.entityId, r => r.transformedData); updateTask = salesforceClient.BatchUpdateEntitiesAsync(selectedRestEntity.Name, updateData); } // Attendi entrambe le operazioni await Task.WhenAll(createTask, updateTask); var createResults = await createTask; var updateResults = await updateTask; // 4. Processa i risultati delle creazioni int successCount = 0; int errorCount = 0; int updatedCount = 0; // Lista per raccogliere le task di creazione associazioni var createAssociationTasks = new List(); for (int i = 0; i < createResults.Count; i++) { var result = createResults[i]; var originalData = finalRecordsForCreate[i]; var transferResult = new TransferResult { RecordNumber = originalData.recordNumber, RecordData = originalData.originalRecord }; if (result.Success) { successCount++; transferResult.Status = "success"; transferResult.Message = "Record inserito con successo (Composite)"; transferResult.EntityId = result.EntityId; // Aggiungi task di creazione associazione alla lista (esecuzione parallela) if (useRecordAssociations && !string.IsNullOrEmpty(transferResult.EntityId)) { // IMPORTANTE: Non awaita qui, solo crea il task per esecuzione parallela // Genera l'hash SOLO sui dati trasformati/mappati che sono stati effettivamente trasferiti var dataHashForAssociation = GenerateDataHash(originalData.transformedData); var associationTask = CreateAssociationAsync(originalData.originalRecord, transferResult.EntityId, originalData.recordNumber, dataHashForAssociation); createAssociationTasks.Add(associationTask); } } else { errorCount++; transferResult.Status = "error"; transferResult.Message = $"Errore creazione (Composite): {result.ErrorMessage}"; } transferResults.Add(transferResult); } // 5. Processa i risultati degli aggiornamenti // Lista per raccogliere le task di aggiornamento associazioni var updateAssociationTasks = new List(); for (int i = 0; i < updateResults.Count; i++) { var result = updateResults[i]; var originalData = finalRecordsForUpdate[i]; var transferResult = new TransferResult { RecordNumber = originalData.recordNumber, RecordData = originalData.originalRecord }; if (result.Success) { updatedCount++; transferResult.Status = "updated"; transferResult.Message = $"Record aggiornato con successo (Composite) - ID: {result.EntityId}"; transferResult.EntityId = result.EntityId; // Aggiungi task di aggiornamento associazione alla lista (esecuzione parallela) if (useRecordAssociations && !string.IsNullOrEmpty(result.EntityId)) { // IMPORTANTE: Non awaita qui, solo crea il task per esecuzione parallela var updateHashTask = UpdateAssociationHashAsync(originalData.originalRecord, result.EntityId, originalData.newDataHash); updateAssociationTasks.Add(updateHashTask); } } else { errorCount++; transferResult.Status = "error"; transferResult.Message = $"Errore aggiornamento (Composite): {result.ErrorMessage}"; // 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); } // 6. Esegui tutte le operazioni di associazione in parallelo var allAssociationTasks = createAssociationTasks.Concat(updateAssociationTasks).ToList(); if (allAssociationTasks.Any()) { Logger.LogInformation("COMPOSITE: Avvio di {TaskCount} operazioni di associazione in parallelo ({CreateCount} creazioni, {UpdateCount} aggiornamenti) usando DbContext separati", allAssociationTasks.Count, createAssociationTasks.Count, updateAssociationTasks.Count); var startTime = DateTime.UtcNow; await Task.WhenAll(allAssociationTasks); var endTime = DateTime.UtcNow; Logger.LogInformation("COMPOSITE: Operazioni di associazione completate in {ElapsedMs}ms con esecuzione parallela reale", (endTime - startTime).TotalMilliseconds); } else { Logger.LogInformation("COMPOSITE: Nessuna operazione di associazione da eseguire"); } // 6.5 Sincronizza le cancellazioni (se abilitato) int deletedCount = 0; if (useRecordAssociations && !string.IsNullOrEmpty(sourceKeyField)) { try { Logger.LogInformation("COMPOSITE: Verifica sincronizzazione cancellazioni..."); // Estrai tutti i valori chiave presenti nella sorgente var sourceKeyValues = records .Select(r => r.ContainsKey(sourceKeyField) ? r[sourceKeyField]?.ToString() : null) .Where(k => !string.IsNullOrEmpty(k)) .Cast() .Distinct() .ToList(); Logger.LogInformation("COMPOSITE: Trovati {Count} valori chiave nella sorgente", sourceKeyValues.Count); // Sincronizza le cancellazioni var deletionOptions = new DeletionSyncOptions { Action = DeletionAction.Delete // Default: elimina fisicamente }; var deletionResult = await DeletionSyncService.SyncDeletionsAsync( sourceKeyValues, selectedRestEntity.Name, selectedRestCredential, salesforceClient, deletionOptions); deletedCount = deletionResult.DeletedRecordsSynced; if (deletionResult.DeletedRecordsDetected > 0) { Logger.LogInformation("COMPOSITE: Sincronizzazione cancellazioni: {Detected} rilevati, {Synced} sincronizzati, {Errors} errori", deletionResult.DeletedRecordsDetected, deletionResult.DeletedRecordsSynced, deletionResult.SyncErrors); // Aggiungi i dettagli delle cancellazioni ai risultati del trasferimento if (deletionResult.DeletedRecordsSynced > 0) { transferResults.Add(new TransferResult { RecordNumber = transferResults.Count + 1, Status = "deleted", Message = $"{deletionResult.DeletedRecordsSynced} record cancellati dalla destinazione" }); } // Aggiungi eventuali errori di sincronizzazione foreach (var error in deletionResult.Errors.Take(5)) // Primi 5 errori { transferResults.Add(new TransferResult { RecordNumber = transferResults.Count + 1, Status = "error", Message = $"Errore sincronizzazione cancellazione: {error}" }); } } } catch (Exception delEx) { Logger.LogError(delEx, "COMPOSITE: Errore durante la sincronizzazione delle cancellazioni"); transferResults.Add(new TransferResult { RecordNumber = transferResults.Count + 1, Status = "error", Message = $"Errore sincronizzazione cancellazioni: {delEx.Message}" }); } } // 7. Mostra risultati (inclusi i record saltati) var skippedCount = finalRecordsSkipped.Count; ShowTransferResults(successCount, updatedCount, deletedCount, errorCount, skippedCount); Logger.LogInformation("Trasferimento COMPOSITE completato. Inserimenti: {SuccessCount}, Aggiornamenti: {UpdatedCount}, Cancellazioni: {DeletedCount}, Saltati: {SkippedCount}, Errori: {ErrorCount}", successCount, updatedCount, deletedCount, skippedCount, errorCount); } catch (Exception ex) { Logger.LogError(ex, "Errore generale nel trasferimento dati COMPOSITE"); transferMessage = $"Errore nel trasferimento dati COMPOSITE: {ex.Message}"; transferMessageType = "error"; } finally { isTransferringData = false; } } private async Task CreateAssociationAsync(Dictionary originalRecord, string entityId, int recordNumber, string? dataHash = null) { try { // Cattura i valori condivisi all'inizio per evitare race conditions var currentSourceKeyField = sourceKeyField; var currentEntityName = selectedRestEntity?.Name ?? ""; var currentCredentialName = selectedRestCredential ?? ""; var currentMappingCount = fieldMappings.Count; var currentSourceType = selectedSourceType; 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(); // Trova il campo destinazione (REST API) mappato al campo chiave sorgente string? mappedDestinationField = null; Logger.LogDebug("MAPPING DEBUG: Cercando il campo destinazione mappato al campo chiave sorgente '{SourceKeyField}'", currentSourceKeyField); Logger.LogDebug("MAPPING DEBUG: Mappings disponibili: {Mappings}", string.Join(", ", fieldMappings.Select(m => $"{m.Key} -> {m.Value}"))); // Cerca nel dizionario il campo destinazione corrispondente al campo chiave sorgente if (fieldMappings.TryGetValue(currentSourceKeyField, out var destinationFieldName)) { mappedDestinationField = destinationFieldName; Logger.LogDebug("MAPPING DEBUG: Trovato mapping: campo sorgente '{SourceField}' è mappato al campo destinazione '{DestField}'", currentSourceKeyField, mappedDestinationField); } else { Logger.LogWarning("MAPPING DEBUG: Campo chiave sorgente '{SourceKeyField}' NON trovato nei mappings! Il campo MappedDestinationField non verrà popolato.", currentSourceKeyField); } var association = new KeyAssociation { KeyValue = sourceKey, SourceKeyField = currentSourceKeyField, DestinationKeyField = destinationKeyField, MappedDestinationField = mappedDestinationField, // Campo destinazione mappato al campo chiave sorgente DestinationEntity = currentEntityName, DestinationId = entityId, 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, DataHashGenerated = true }) }; var associationId = await CredentialService.SaveKeyAssociationParallelAsync(association); Logger.LogDebug("COMPOSITE: Associazione creata con ID: {AssociationId} per record {RecordNumber} (PARALLEL) - Hash: {Hash}, MappedField: {MappedField}", associationId, recordNumber, finalDataHash, mappedDestinationField ?? "N/A"); } catch (Exception ex) { Logger.LogWarning(ex, "Errore nella creazione dell'associazione per record {RecordNumber}", recordNumber); } } 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; // 🔄 RESET PRE-DISCOVERY FLAG: Dopo il primo aggiornamento, resetta il flag // in modo che i successivi trasferimenti usino il controllo hash standard if (!string.IsNullOrEmpty(existingAssociation.AdditionalInfo)) { try { var additionalInfo = System.Text.Json.JsonSerializer.Deserialize>(existingAssociation.AdditionalInfo); if (additionalInfo != null && additionalInfo.ContainsKey("PreDiscovery")) { additionalInfo.Remove("PreDiscovery"); existingAssociation.AdditionalInfo = System.Text.Json.JsonSerializer.Serialize(additionalInfo); Logger.LogDebug("COMPOSITE: Flag Pre-Discovery resettato per entityId {EntityId}", entityId); } } catch (Exception ex) { Logger.LogWarning(ex, "COMPOSITE: Errore nel reset del flag Pre-Discovery per entityId {EntityId}", entityId); } } 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 { // Non abbiamo un metodo FindKeyAssociationByDestinationIdAsync, quindi per ora usiamo UpdateKeyAssociationLastVerifiedAsync // se abbiamo l'ID dell'associazione. In alternativa, potremmo cercare l'associazione con tutti i criteri. Logger.LogDebug("COMPOSITE: Aggiornamento verifica associazione per entityId {EntityId}", entityId); // Per ora non facciamo nulla - l'associazione è già aggiornata nel batch } catch (Exception ex) { Logger.LogWarning(ex, "Errore nell'aggiornamento dell'associazione per entityId {EntityId}", entityId); } return Task.CompletedTask; } private async Task HandleFailedUpdateAsync(Dictionary originalRecord, int recordNumber) { try { var sourceKey = GenerateSourceKey(originalRecord); if (string.IsNullOrEmpty(sourceKey)) return; var existingAssociation = await CredentialService.FindKeyAssociationByValueParallelAsync( sourceKey, selectedRestEntity?.Name ?? "", selectedRestCredential ?? ""); if (existingAssociation != null) { await CredentialService.DeleteKeyAssociationParallelAsync(existingAssociation.Id); Logger.LogInformation("COMPOSITE: Associazione non valida eliminata per record {RecordNumber}", recordNumber); } } catch (Exception ex) { Logger.LogWarning(ex, "Errore nell'eliminazione dell'associazione non valida per record {RecordNumber}", recordNumber); } } private void ShowTransferResults(int successCount, int updatedCount, int deletedCount, int errorCount, int skippedCount = 0) { if (errorCount == 0) { var message = $"Trasferimento COMPOSITE completato con successo! "; var messageParts = new List(); if (successCount > 0) messageParts.Add($"{successCount} record inseriti"); if (updatedCount > 0) messageParts.Add($"{updatedCount} record aggiornati"); if (deletedCount > 0) messageParts.Add($"{deletedCount} record cancellati"); if (skippedCount > 0) messageParts.Add($"{skippedCount} record saltati (dati non modificati)"); message += string.Join(", ", messageParts) + "."; transferMessage = message; transferMessageType = "success"; } else { var message = $"Trasferimento COMPOSITE completato con errori. "; var messageParts = new List(); if (successCount > 0) messageParts.Add($"Inserimenti: {successCount}"); if (updatedCount > 0) messageParts.Add($"Aggiornamenti: {updatedCount}"); if (deletedCount > 0) messageParts.Add($"Cancellazioni: {deletedCount}"); if (skippedCount > 0) messageParts.Add($"Saltati: {skippedCount}"); messageParts.Add($"Errori: {errorCount}"); message += string.Join(", ", messageParts); transferMessage = message; transferMessageType = "error"; } } private bool IsSalesforceClient() { return currentRestClient is DataConnection.REST.Implementations.SalesforceServiceClient; } }