Files
Data-Coupler/Data_Coupler/Pages/DataCoupler.razor.cs
T
Alessio Dal Santo 335d587c89
Build and Push Docker Images / Build Linux Container (push) Successful in 6m56s
Build and Push Docker Images / Build Windows Container (push) Has been cancelled
Build and Push Docker Images / Create Multi-Platform Manifest (push) Has been cancelled
[Feature] Salesforce: batch describe metadati, discovery parallela e fix scheduler External ID
- Salesforce Composite Batch API per describe SObject: le describe sono ora
  raggruppate in chunk da 25 e inviate come singole POST a /composite/batch,
  riducendo le chiamate API da N a ceil(N/25); per 200 SObject: da 201 a 9 chiamate.

- Discovery entita' REST in parallelo: DiscoverEntitySummariesAsync e
  DiscoverEntitiesAsync avviate simultaneamente; la lista entita' diventa
  interattiva subito dopo le summaries, i dettagli completano in background
  con StateHasChanged() per aggiornare l'UI istantaneamente.

- Fix scheduler - preservazione ExternalIdRelationshipsJson e DefaultValuesJson:
  in DataCoupler.razor.cs entrambi i blocchi di update profilo esistente
  (riattivazione profilo inattivo e sovrascrittura profilo attivo) omettevano
  questi campi nella copia, causandone l'azzeramento silenzioso ad ogni
  re-salvataggio. Ora entrambi i percorsi propagano correttamente i campi JSON.

- Fix scheduler - esclusione campi sorgente External ID dal mapping normale:
  in ScheduledProfileExecutionService.TransformRecordForRest i campi sorgente
  usati nelle External ID Relationships venivano inclusi anche nel loop di
  field mapping standard, generando dati duplicati nell'entita' destinazione.
  Ora il comportamento e' allineato alla UI manuale (TransformRecordToRestEntity).

- Aggiornata documentazione: README.md, AGENTS.md, copilot-instructions.md
2026-02-20 14:59:13 +01:00

4012 lines
173 KiB
C#

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<DataCoupler> 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<string, IEnumerable<string>> fileSheets = new(); // SheetName -> Columns
private Dictionary<string, List<Dictionary<string, object>>> 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<string, string> fieldMappings = new(); // DbColumn -> RestProperty (legacy)
private List<FieldMappingEntry> fieldMappingEntries = new(); // New system: supporta sia mapping che default values
private Dictionary<string, (object? Value, string? Type)> defaultValues = new(); // DestinationField -> (DefaultValue, Type)
private HashSet<string> keyFields = new(); // REST properties marked as keys
private string selectedDbColumn = "";
// UI per configurazione mapping/default value
private bool isAddingDefaultValue = false; // Toggle tra mapping normale e default value
private string defaultValueField = ""; // Campo destinazione per default value
private string defaultValueInput = ""; // Input utente per default value
private string defaultValueType = "string"; // Tipo del default value (string, int, decimal, boolean, datetime)
// External ID Relationships (Salesforce)
private List<ExternalIdRelationshipDto> externalIdRelationships = new();
private string selectedRelationshipObject = "";
private string selectedExternalIdField = "";
private string selectedRelationshipSourceField = "";
private List<RestEntityInfo> availableRelationshipObjects = new(); // Oggetti disponibili per relazioni
// 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<TransferResult> transferResults = new();
private bool showDetailedResults = false;
// Gestione Profili
private List<DataCouplerProfile> 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();
fieldMappingEntries.Clear();
keyFields.Clear();
foreach (var mapping in mappings)
{
fieldMappings[mapping.SourceField] = mapping.DestinationField;
fieldMappingEntries.Add(FieldMappingEntry.CreateFieldMapping(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 4.5: Applica default values se disponibili
if (!string.IsNullOrEmpty(profile.DefaultValuesJson))
{
Logger.LogInformation("Step 4.5 - Applicazione default values...");
try
{
var deserializedDefaults = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, DefaultValueDto>>(
profile.DefaultValuesJson,
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });
if (deserializedDefaults != null)
{
defaultValues.Clear();
foreach (var entry in deserializedDefaults)
{
defaultValues[entry.Key] = (entry.Value.Value, entry.Value.Type);
fieldMappingEntries.Add(FieldMappingEntry.CreateDefaultValue(entry.Key, entry.Value.Value, entry.Value.Type));
Logger.LogInformation("Default value applicato: {Field} = {Value} ({Type})",
entry.Key, entry.Value.Value, entry.Value.Type);
}
Logger.LogInformation("Default values applicati - Totale: {Count}", defaultValues.Count);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Errore nel caricamento dei default values dal profilo");
}
}
else
{
Logger.LogInformation("Nessun default value 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 5.5: Carica External ID Relationships (Salesforce)
if (!string.IsNullOrEmpty(profile.ExternalIdRelationshipsJson))
{
Logger.LogInformation("Step 5.5 - Caricamento External ID Relationships...");
try
{
var relationships = System.Text.Json.JsonSerializer.Deserialize<List<ExternalIdRelationshipDto>>(
profile.ExternalIdRelationshipsJson,
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });
if (relationships != null && relationships.Any())
{
externalIdRelationships.Clear();
// Normalizza i RelationshipName in base al tipo di oggetto destinazione
bool isDestinationCustom = selectedRestEntity?.Name?.EndsWith("__c") ?? false;
foreach (var rel in relationships)
{
// Normalizza il RelationshipName
string normalizedName = NormalizeRelationshipName(rel.RelatedObjectName, isDestinationCustom);
if (normalizedName != rel.RelationshipName)
{
Logger.LogInformation("Normalizzato RelationshipName: {Old} → {New} (Destination: {Destination}, IsCustom: {IsCustom})",
rel.RelationshipName, normalizedName, selectedRestEntity?.Name, isDestinationCustom);
rel.RelationshipName = normalizedName;
}
externalIdRelationships.Add(rel);
}
Logger.LogInformation("External ID Relationships caricate e normalizzate - Totale: {Count}", externalIdRelationships.Count);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Errore nel caricamento delle External ID Relationships dal profilo");
}
}
else
{
Logger.LogInformation("Nessuna External ID Relationship 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.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
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<bool>("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.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
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<bool>("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<bool>("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<FieldMappingDto> GetCurrentFieldMappings()
{
var mappings = new List<FieldMappingDto>();
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();
fieldMappingEntries.Clear();
defaultValues.Clear();
keyFields.Clear();
externalIdRelationships.Clear(); // Reset relazioni
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();
}
/// <summary>
/// Valida e carica un file dal percorso specificato manualmente
/// </summary>
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();
}
}
/// <summary>
/// Processa un file CSV dal percorso specificato
/// </summary>
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<Dictionary<string, object>>();
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
var values = ParseCsvLine(line, separator);
var row = new Dictionary<string, object>();
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);
}
/// <summary>
/// Processa un file Excel dal percorso specificato
/// </summary>
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<string>();
var dataRows = new List<Dictionary<string, object>>();
foreach (DataColumn column in table.Columns)
{
headers.Add(column.ColumnName);
}
foreach (DataRow dataRow in table.Rows)
{
var row = new Dictionary<string, object>();
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<Dictionary<string, object>>();
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<string, object>();
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<string> ParseCsvLine(string line, char separator = ',')
{
var result = new List<string>();
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<string>();
var dataRows = new List<Dictionary<string, object>>();
// 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<string, object>();
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<Dictionary<string, object>> GetCurrentPageData()
{
if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet))
return new List<Dictionary<string, object>>();
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;
// Aggiorna anche la lista FieldMappingEntries
var existingEntry = fieldMappingEntries.FirstOrDefault(e =>
e.Type == CredentialManager.Models.MappingType.FieldMapping && e.SourceField == selectedDbColumn);
if (existingEntry != null)
{
fieldMappingEntries.Remove(existingEntry);
}
fieldMappingEntries.Add(FieldMappingEntry.CreateFieldMapping(selectedDbColumn, selectedRestProperty));
Logger.LogInformation("Creato mapping: {DbColumn} -> {RestProperty}", selectedDbColumn, selectedRestProperty);
// Deseleziona i campi
selectedDbColumn = "";
selectedRestProperty = "";
}
private void CreateDefaultValue()
{
if (string.IsNullOrEmpty(selectedRestProperty) || string.IsNullOrEmpty(defaultValueInput))
return;
try
{
// Converti il valore nel tipo appropriato
object? convertedValue = ConvertDefaultValue(defaultValueInput, defaultValueType);
// Rimuovi eventuale default value esistente per questo campo
if (defaultValues.ContainsKey(selectedRestProperty))
{
defaultValues.Remove(selectedRestProperty);
}
// Rimuovi anche dalla lista entries
var existingEntry = fieldMappingEntries.FirstOrDefault(e =>
e.Type == CredentialManager.Models.MappingType.DefaultValue && e.DestinationField == selectedRestProperty);
if (existingEntry != null)
{
fieldMappingEntries.Remove(existingEntry);
}
// Aggiungi il nuovo default value
defaultValues[selectedRestProperty] = (convertedValue, defaultValueType);
fieldMappingEntries.Add(FieldMappingEntry.CreateDefaultValue(selectedRestProperty, convertedValue, defaultValueType));
Logger.LogInformation("Creato default value: {RestProperty} = {Value} ({Type})",
selectedRestProperty, convertedValue, defaultValueType);
// Reset campi
selectedRestProperty = "";
defaultValueInput = "";
isAddingDefaultValue = false;
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore nella conversione del valore di default");
transferMessage = $"Errore: {ex.Message}";
transferMessageType = "error";
}
}
private object? ConvertDefaultValue(string input, string type)
{
if (string.IsNullOrEmpty(input))
return null;
return type.ToLower() switch
{
"string" => input,
"int" => int.Parse(input),
"long" => long.Parse(input),
"decimal" => decimal.Parse(input, System.Globalization.CultureInfo.InvariantCulture),
"double" => double.Parse(input, System.Globalization.CultureInfo.InvariantCulture),
"float" => float.Parse(input, System.Globalization.CultureInfo.InvariantCulture),
"boolean" => bool.Parse(input),
"datetime" => DateTime.Parse(input),
"datetimeoffset" => DateTimeOffset.Parse(input),
_ => input
};
}
private void RemoveMapping()
{
if (string.IsNullOrEmpty(selectedDbColumn) || !fieldMappings.ContainsKey(selectedDbColumn))
return;
fieldMappings.Remove(selectedDbColumn);
// Rimuovi anche dalla lista entries
var entry = fieldMappingEntries.FirstOrDefault(e =>
e.Type == CredentialManager.Models.MappingType.FieldMapping && e.SourceField == selectedDbColumn);
if (entry != null)
{
fieldMappingEntries.Remove(entry);
}
Logger.LogInformation("Rimosso mapping per campo: {DbColumn}", selectedDbColumn);
}
private void RemoveDefaultValue(string destinationField)
{
if (defaultValues.ContainsKey(destinationField))
{
defaultValues.Remove(destinationField);
// Rimuovi anche dalla lista entries
var entry = fieldMappingEntries.FirstOrDefault(e =>
e.Type == CredentialManager.Models.MappingType.DefaultValue && e.DestinationField == destinationField);
if (entry != null)
{
fieldMappingEntries.Remove(entry);
}
Logger.LogInformation("Rimosso default value per campo: {Field}", destinationField);
StateHasChanged();
}
}
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();
fieldMappingEntries.Clear();
defaultValues.Clear();
selectedDbColumn = "";
selectedRestProperty = "";
sourceKeyField = "";
transferMessage = "";
transferMessageType = "";
isAddingDefaultValue = false;
defaultValueField = "";
defaultValueInput = "";
externalIdRelationships.Clear(); // Pulisce anche le relazioni
Logger.LogInformation("Tutti i mapping, default values e le configurazioni sono stati cancellati");
}
// External ID Relationships Methods
private void OnRelationshipObjectSelected()
{
// Il valore è già impostato tramite @bind, resettiamo solo i campi dipendenti
selectedExternalIdField = ""; // Reset campo External ID quando cambia l'oggetto
selectedRelationshipSourceField = ""; // Reset anche campo sorgente
StateHasChanged();
}
private void AddExternalIdRelationship()
{
if (string.IsNullOrEmpty(selectedRelationshipObject) ||
string.IsNullOrEmpty(selectedExternalIdField) ||
string.IsNullOrEmpty(selectedRelationshipSourceField))
{
Logger.LogWarning("Impossibile aggiungere relazione: campi mancanti");
return;
}
// Trova il nome dell'oggetto correlato
var relatedObject = availableRelationshipObjects.FirstOrDefault(o => o.Name == selectedRelationshipObject);
if (relatedObject == null)
{
Logger.LogWarning("Oggetto correlato non trovato: {ObjectName}", selectedRelationshipObject);
return;
}
// Determina il nome della relazione usando il metodo helper
bool isDestinationCustom = selectedRestEntity?.Name?.EndsWith("__c") ?? false;
string relationshipName = NormalizeRelationshipName(selectedRelationshipObject, isDestinationCustom);
Logger.LogDebug("Creazione relazione - Destinazione: {Destination} (Custom: {IsCustom}), Correlato: {Related}, RelationshipName: {RelationshipName}",
selectedRestEntity?.Name, isDestinationCustom, selectedRelationshipObject, relationshipName);
// Crea la relazione
var relationship = new ExternalIdRelationshipDto
{
RelationshipName = relationshipName,
RelatedObjectName = selectedRelationshipObject,
ExternalIdField = selectedExternalIdField,
SourceField = selectedRelationshipSourceField
};
// Verifica duplicati
if (externalIdRelationships.Any(r =>
r.RelatedObjectName == relationship.RelatedObjectName &&
r.ExternalIdField == relationship.ExternalIdField))
{
Logger.LogWarning("Relazione già esistente per questo oggetto e campo External ID");
return;
}
externalIdRelationships.Add(relationship);
Logger.LogInformation("Aggiunta relazione External ID: {Relationship}.{Field} <- {SourceField}",
relationship.RelationshipName, relationship.ExternalIdField, relationship.SourceField);
// Reset campi
selectedRelationshipObject = "";
selectedExternalIdField = "";
selectedRelationshipSourceField = "";
StateHasChanged();
}
private void RemoveExternalIdRelationship(ExternalIdRelationshipDto relationship)
{
if (externalIdRelationships.Remove(relationship))
{
Logger.LogInformation("Rimossa relazione External ID: {Relationship}.{Field}",
relationship.RelationshipName, relationship.ExternalIdField);
StateHasChanged();
}
}
/// <summary>
/// Normalizza il nome della relazione in base al tipo di oggetto destinazione.
/// Salesforce External ID Relationships:
/// - Se l'oggetto DESTINAZIONE è CUSTOM → usa sempre __r per tutte le relazioni
/// - Se l'oggetto DESTINAZIONE è STANDARD → usa __r solo per oggetti custom correlati
/// </summary>
/// <param name="relatedObjectName">Nome dell'oggetto correlato (es. "Account", "Custom_Company__c")</param>
/// <param name="isDestinationCustom">True se l'oggetto destinazione è custom</param>
/// <returns>Nome normalizzato della relazione (es. "Account__r", "Account", "Custom_Company__r")</returns>
private string NormalizeRelationshipName(string relatedObjectName, bool isDestinationCustom)
{
if (isDestinationCustom)
{
// Destinazione CUSTOM: tutte le relazioni usano __r
if (relatedObjectName.EndsWith("__c"))
{
// Oggetto correlato custom: rimuovi __c e aggiungi __r
return relatedObjectName.Replace("__c", "__r");
}
else
{
// Oggetto correlato standard: aggiungi __r
return relatedObjectName + "__r";
}
}
else
{
// Destinazione STANDARD: solo oggetti custom correlati usano __r
if (relatedObjectName.EndsWith("__c"))
{
// Oggetto correlato custom: rimuovi __c e aggiungi __r
return relatedObjectName.Replace("__c", "__r");
}
else
{
// Oggetto correlato standard: usa solo il nome
return relatedObjectName;
}
}
}
private List<string> GetExternalIdFieldsForSelectedObject()
{
if (string.IsNullOrEmpty(selectedRelationshipObject))
return new List<string>();
var entity = availableRelationshipObjects.FirstOrDefault(e => e.Name == selectedRelationshipObject);
if (entity == null)
return new List<string>();
// Filtra i campi che potrebbero essere External ID (tipicamente campo con __c o specifici tipi)
return entity.Properties
.Where(p => p.Name.EndsWith("__c") || p.Name == "Id" || p.Name.Contains("External"))
.Select(p => p.Name)
.OrderBy(p => p)
.ToList();
}
private List<string> GetSourceFieldsForRelationship()
{
// Restituisce i campi sorgente disponibili
if (selectedSourceType == "database")
{
if (useCustomQuery && queryColumns.Any())
return queryColumns.ToList();
else if (!useCustomQuery && !string.IsNullOrEmpty(selectedTable) && databaseTables.ContainsKey(selectedTable))
return databaseTables[selectedTable].Select(c => c.Name).ToList();
}
else if (selectedSourceType == "file" && fileSheets.ContainsKey(selectedSheet))
{
return fileSheets[selectedSheet].ToList();
}
return new List<string>();
}
private void AutoMapFields()
{
if (restEntityDetails == null)
return;
IEnumerable<string> sourceColumns = new List<string>();
// 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<string>();
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<string>();
int recordNumber = 1;
foreach (var record in records)
{
var transferResult = new TransferResult
{
RecordNumber = recordNumber,
RecordData = new Dictionary<string, object>(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<string>()
.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<string>();
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<string>();
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<IEnumerable<Dictionary<string, object>>> GetAllRecordsFromSource()
{
if (selectedSourceType == "database")
{
return await GetAllRecordsFromDatabase();
}
else if (selectedSourceType == "file")
{
return await GetAllRecordsFromFile();
}
return new List<Dictionary<string, object>>();
}
private async Task<IEnumerable<Dictionary<string, object>>> GetAllRecordsFromDatabase()
{
if (currentDatabaseManager == null)
return new List<Dictionary<string, object>>();
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<IEnumerable<Dictionary<string, object>>> GetAllRecordsFromFile()
{
if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet))
{
return new List<Dictionary<string, object>>();
}
await Task.CompletedTask;
return fileData[selectedSheet];
}
private Dictionary<string, object> TransformRecordToRestEntity(Dictionary<string, object> dbRecord)
{
var restData = new Dictionary<string, object>();
// Crea un set con i campi sorgente usati in External ID Relationships
// per escluderli dai mapping normali (verranno gestiti separatamente)
var externalIdSourceFields = externalIdRelationships
.Where(r => !string.IsNullOrWhiteSpace(r.SourceField))
.Select(r => r.SourceField)
.ToHashSet();
// STEP 1: Applica i mapping normali (campo sorgente -> campo destinazione)
foreach (var mapping in fieldMappings)
{
string dbColumn = mapping.Key;
string restProperty = mapping.Value;
// Salta il mapping se il campo è usato in un External ID Relationship
if (externalIdSourceFields.Contains(dbColumn))
{
Logger.LogDebug("Campo {DbColumn} usato in External ID Relationship, escluso da mapping normale", dbColumn);
continue;
}
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;
}
}
}
// STEP 2: Applica i valori di default per i campi NON ancora popolati
foreach (var defaultValue in defaultValues)
{
string destinationField = defaultValue.Key;
var (value, valueType) = defaultValue.Value;
// Applica il default value solo se il campo non è già stato popolato dal mapping
if (!restData.ContainsKey(destinationField))
{
if (value != null)
{
restData[destinationField] = value;
Logger.LogDebug("Applicato default value: {Field} = {Value} ({Type})",
destinationField, value, valueType);
}
}
else
{
Logger.LogDebug("Campo {Field} già popolato da mapping, default value ignorato", destinationField);
}
}
// STEP 3: Aggiungi External ID Relationships (per Salesforce)
if (externalIdRelationships.Any())
{
foreach (var relationship in externalIdRelationships)
{
if (!string.IsNullOrWhiteSpace(relationship.SourceField) &&
dbRecord.ContainsKey(relationship.SourceField))
{
var sourceValue = dbRecord[relationship.SourceField];
var transformedValue = TransformValue(sourceValue, relationship.SourceField, relationship.ExternalIdField);
if (transformedValue != null)
{
// Crea il dizionario annidato per l'External ID Relationship
// Formato: { "Account": { "CardCode__c": "V50000" } }
var externalIdObject = new Dictionary<string, object>
{
{ relationship.ExternalIdField, transformedValue }
};
restData[relationship.RelationshipName] = externalIdObject;
Logger.LogDebug("Aggiunta External ID Relationship: {RelationshipName}.{ExternalIdField} = {Value} (from {SourceField})",
relationship.RelationshipName, relationship.ExternalIdField, transformedValue, relationship.SourceField);
}
}
}
}
Logger.LogDebug("Record trasformato: {DbColumns} → {RestProperties} (inclusi {DefaultCount} default values)",
string.Join(", ", dbRecord.Keys),
string.Join(", ", restData.Keys),
defaultValues.Count(dv => restData.ContainsKey(dv.Key)));
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<char, int>();
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
}
/// <summary>
/// Verifica se il pulsante di trasferimento può essere abilitato
/// </summary>
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;
}
/// <summary>
/// Ottiene il messaggio di errore che spiega perché il trasferimento non può essere avviato
/// </summary>
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"
};
}
/// <summary>
/// Genera una chiave univoca per il record sorgente
/// </summary>
private string GenerateSourceKey(Dictionary<string, object> 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;
}
}
/// <summary>
/// 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.
/// </summary>
private string GenerateDataHash(Dictionary<string, object> record)
{
try
{
var valuesForHash = new List<string>();
// 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;
}
}
/// <summary>
/// Gestisce la connessione al database con schema specifico
/// </summary>
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();
}
}
/// <summary>
/// Scopre automaticamente lo schema del database
/// </summary>
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<string, IEnumerable<DbColumnInfo>> ??
(schema != null ? new Dictionary<string, IEnumerable<DbColumnInfo>>(schema) : new Dictionary<string, IEnumerable<DbColumnInfo>>());
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;
}
}
/// <summary>
/// Carica lo schema per un database specifico
/// </summary>
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<string, IEnumerable<DbColumnInfo>> ??
new Dictionary<string, IEnumerable<DbColumnInfo>>();
// 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;
}
}
/// <summary>
/// Estrae lo schema dal nome completo di una tabella
/// </summary>
private string? ExtractSchemaFromTableName(string fullTableName)
{
if (string.IsNullOrEmpty(fullTableName) || !fullTableName.Contains('.'))
return null;
var parts = fullTableName.Split('.');
return parts.Length > 1 ? parts[0] : null;
}
/// <summary>
/// Ottiene lo schema correntemente utilizzato dal database connesso
/// </summary>
private string? GetCurrentDatabaseSchema()
{
if (!databaseTables.Any())
return null;
var firstTable = databaseTables.Keys.FirstOrDefault();
return !string.IsNullOrEmpty(firstTable) ? ExtractSchemaFromTableName(firstTable) : null;
}
/// <summary>
/// Ottiene il campo ID dell'entità REST selezionata
/// </summary>
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";
}
/// <summary>
/// Verifica se una query è una SELECT query sicura
/// </summary>
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;
}
/// <summary>
/// Pulisce una query SQL rimuovendo caratteri pericolosi
/// </summary>
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;
}
/// <summary>
/// Gestisce il cambio di modalità tra tabelle e query custom
/// </summary>
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();
}
/// <summary>
/// Ottiene l'ID della credenziale sorgente corrente
/// </summary>
private async Task<int?> 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;
}
/// <summary>
/// Ottiene l'ID della credenziale destinazione corrente
/// </summary>
private async Task<int?> 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;
}
/// <summary>
/// Annulla la selezione dello schema
/// </summary>
private void CancelSchemaSelection()
{
showSchemaSelectionModal = false;
selectedSchema = "";
StateHasChanged();
}
/// <summary>
/// Ottiene le colonne di una tabella specifica
/// </summary>
private async Task<List<DbColumnInfo>> GetTableColumns(string fullTableName, string databaseName, string tableName)
{
var columns = new List<DbColumnInfo>();
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;
}
/// <summary>
/// Conferma la selezione dello schema
/// </summary>
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();
}
/// <summary>
/// Carica la lista degli schemi disponibili
/// </summary>
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<string> 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<string> 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<string, object> transformedData, Dictionary<string, object> originalRecord, int recordNumber)>();
var recordsForUpdate = new ConcurrentBag<(Dictionary<string, object> transformedData, string entityId, Dictionary<string, object> originalRecord, int recordNumber, string newDataHash)>();
var recordsSkipped = new ConcurrentBag<(Dictionary<string, object> originalRecord, int recordNumber, string reason)>();
var recordErrors = new ConcurrentBag<TransferResult>();
// Cattura i valori condivisi per evitare race conditions
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<string, string>(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<Dictionary<string, object>>(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<string, object>(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<DataConnection.REST.Implementations.SalesforceServiceClient.CompositeOperationResult>());
var updateTask = Task.FromResult(new List<DataConnection.REST.Implementations.SalesforceServiceClient.CompositeOperationResult>());
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<Task>();
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<Task>();
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<string>()
.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<string, object> 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<string, object> originalRecord, string entityId, string newDataHash)
{
try
{
// Cattura i valori condivisi per evitare race conditions
var currentEntityName = selectedRestEntity?.Name ?? "";
var currentCredentialName = selectedRestCredential ?? "";
var sourceKey = GenerateSourceKey(originalRecord);
if (string.IsNullOrEmpty(sourceKey)) return;
// Trova l'associazione esistente e aggiorna l'hash
var existingAssociation = await CredentialService.FindKeyAssociationByValueParallelAsync(
sourceKey, currentEntityName, currentCredentialName);
if (existingAssociation != null)
{
existingAssociation.Data_Hash = newDataHash;
existingAssociation.LastVerifiedAt = DateTime.UtcNow;
existingAssociation.UpdatedAt = DateTime.UtcNow;
// 🔄 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<Dictionary<string, object>>(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<string, object> 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<string>();
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<string>();
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;
}
}