diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 134a403..d2de40c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -107,8 +107,11 @@ - **Parallel Processing**: Elaborazione parallela batch multipli - **Performance**: 10-25x più veloce per grandi dataset - **Riduzione API Calls**: 60-90% in meno chiamate +- **Batch Describe Metadata**: `BatchDescribeSObjectsAsync` raggruppa le describe degli SObject in chunk da 25 (N chiamate singole → ⌈N/25⌉ richieste batch); per 200 SObject: da 201 a 9 chiamate +- **Discovery Parallela**: `DiscoverEntitySummariesAsync` e `DiscoverEntitiesAsync` eseguite in parallelo; UI interattiva dopo le summaries, dettagli completano in background #### Metodi Batch Implementati: +- `BatchDescribeSObjectsAsync`: Describe batch SObject tramite Composite API (max 25 per request) — discovery metadati ottimizzata - `BatchExecuteQueriesAsync`: Esecuzione parallela multiple query SOQL - `BatchFindEntitiesByKeysAsync`: Ricerca batch entità con diverse chiavi - `BatchGetEntitiesByIdsAsync`: Recupero batch tramite ID (max 200 per query) @@ -117,6 +120,10 @@ - `ExtractLargeDatasetAsync`: Estrattore intelligente con auto-detect strategia - `ExtractRecentlyModifiedAsync`: Sincronizzazione incrementale +#### Correzioni Scheduler (Febbraio 2026): +- **ExternalIdRelationshipsJson / DefaultValuesJson preservati**: Fix ai blocchi di update profilo esistente in `DataCoupler.razor.cs` — i campi JSON venivano ignorati nella copia e quindi azzerati; ora entrambi i path (riattivazione + sovrascrittura) li propagano correttamente +- **Esclusione campi External ID dal mapping normale**: In `ScheduledProfileExecutionService.TransformRecordForRest`, i campi sorgente usati nelle External ID Relationships vengono ora esclusi dal loop di field mapping standard (comportamento allineato alla UI manuale) + #### File Chiave: - `Data_Coupler/Pages/DataCoupler.razor.cs` - `DataConnection/REST/Implementations/SalesforceServiceClient.cs` @@ -528,8 +535,8 @@ --- -**Versione**: 2.1 -**Ultimo Aggiornamento**: 2 Febbraio 2026 +**Versione**: 2.2 +**Ultimo Aggiornamento**: 20 Febbraio 2026 **Framework**: .NET 9.0 **Sviluppatore**: Alessio Dalsanto **Repository**: https://github.com/AlessioDalsi/Data-Coupler diff --git a/AGENTS.md b/AGENTS.md index 2712db0..18f88ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,30 @@ - **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati - **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza +## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Optimizations (Febbraio 2026)** + +### Salesforce Batch Describe via Composite API +**Data Aggiornamento**: Febbraio 2026 + +La discovery dei metadati Salesforce è stata ottimizzata tramite la Composite Batch API: + +#### **`BatchDescribeSObjectsAsync`** (nuovo metodo privato in `SalesforceServiceClient`) +- Raggruppa i nomi degli SObject in chunk da 25 +- Ogni chunk viene inviato come singola `POST /services/data/vXX.0/composite/batch` +- I risultati vengono processati in parallelo via `Task.WhenAll` +- **Risparmio concreto**: per 200 SObject, da 201 chiamate API a sole 9 + +#### **Discovery Parallela in `RESTMethod.cs`** +- `DiscoverEntitySummariesAsync` (rapida, 1 chiamata) e `DiscoverEntitiesAsync` (batch) partono in parallelo +- La lista entità diventa interattiva dopo ~0.3 s; i dettagli completano in background +- `StateHasChanged()` chiamato dopo le summaries per aggiornare subito la UI + +#### **Fix Scheduler: External ID Relationships e Default Values** +- **Bug 1** (`DataCoupler.razor.cs`): in entrambi i blocchi di update profilo esistente (riattivazione profilo inattivo + sovrascrittura profilo attivo), i campi `ExternalIdRelationshipsJson` e `DefaultValuesJson` venivano omessi nella copia → cancellati silenziosamente ad ogni re-salvataggio +- **Bug 2** (`ScheduledProfileExecutionService.cs`): `TransformRecordForRest` non escludeva i campi sorgente usati nelle External ID Relationships dal loop di mapping normale, causando dati duplicati nell'entità destinazione (stessa logica già presente nella UI manuale, ora allineata allo scheduler) + +--- + ## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Batch Extraction** ### Miglioramenti Significativi alle Performance REST @@ -1151,7 +1175,7 @@ builder.Services.AddScoped(); try { - // First, get list of all SObjects + // Step 1: get list of all SObjects (1 API call) var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/"; var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken); response.EnsureSuccessStatusCode(); - var sobjectsResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (sobjectsResponse?.SObjects != null) + var sobjectsResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (sobjectsResponse?.SObjects != null) { - // For demo purposes, limit to first 20 objects to avoid too many API calls - var limitedSObjects = sobjectsResponse.SObjects.ToList(); + var sObjectNames = sobjectsResponse.SObjects + .Where(s => !string.IsNullOrEmpty(s.Name)) + .Select(s => s.Name!) + .ToList(); - // Process SObjects in parallel for better performance - var semaphore = new SemaphoreSlim(20, 20); // Limit concurrent requests to 5 - var tasks = limitedSObjects.Where(sobject => !string.IsNullOrEmpty(sobject.Name)) - .Select(async sobject => + Console.WriteLine($"DiscoverEntities: {sObjectNames.Count} SObjects. Using Composite Batch API ({Math.Ceiling((double)sObjectNames.Count / 25)} request(s) instead of {sObjectNames.Count})."); + + // Step 2: batch describe all SObjects via Composite Batch API (25 per request) + var describeResults = await BatchDescribeSObjectsAsync(sObjectNames, cancellationToken); + + foreach (var sobject in sobjectsResponse.SObjects) + { + if (string.IsNullOrEmpty(sobject.Name)) continue; + if (!describeResults.TryGetValue(sobject.Name, out var describeResult) || describeResult?.Fields == null) + continue; + + var entityInfo = new RestEntityInfo { Name = sobject.Name }; + foreach (var field in describeResult.Fields) { - await semaphore.WaitAsync(cancellationToken); - try + if (string.IsNullOrEmpty(field.Name)) continue; + entityInfo.Properties.Add(new RestPropertyInfo { - // Get detailed field information for each SObject - var describeEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/{sobject.Name}/describe/"; - var describeResponse = await _httpClient.GetAsync(describeEndpoint, cancellationToken); - - if (describeResponse.IsSuccessStatusCode) - { - var describeResult = await describeResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - - if (describeResult?.Fields != null) - { - var entityInfo = new RestEntityInfo - { - Name = sobject.Name - }; - - foreach (var field in describeResult.Fields) - { - if (string.IsNullOrEmpty(field.Name)) continue; - - var propInfo = new RestPropertyInfo - { - Name = field.Name, - Type = field.Type ?? "string", - IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) - }; - entityInfo.Properties.Add(propInfo); - } - - return entityInfo; - } - } - return null; - } - catch (Exception ex) - { - Console.WriteLine($"Error describing SObject {sobject.Name}: {ex.Message}"); - return null; - } - finally - { - semaphore.Release(); - } - }); - - var results = await Task.WhenAll(tasks); - entities.AddRange(results.Where(result => result != null)!); + Name = field.Name, + Type = field.Type ?? "string", + IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) + }); + } + entities.Add(entityInfo); + } } } catch (HttpRequestException ex) @@ -382,6 +355,116 @@ namespace DataConnection.REST.Implementations return null; } + /// + /// Describes multiple SObjects in batches using the Salesforce Composite Batch API. + /// Reduces API calls from N (one per object) to ceil(N/25) by grouping up to 25 describe + /// requests per Composite Batch call. + /// + private async Task> BatchDescribeSObjectsAsync( + List sObjectNames, CancellationToken cancellationToken) + { + const int maxBatchSize = 25; + var allResults = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Split into batches of 25 (Salesforce Composite Batch limit) + var batches = new List<(List Names, int BatchNumber)>(); + for (int i = 0; i < sObjectNames.Count; i += maxBatchSize) + { + var chunk = sObjectNames.Skip(i).Take(maxBatchSize).ToList(); + batches.Add((chunk, (i / maxBatchSize) + 1)); + } + + Console.WriteLine($"BatchDescribeSObjects: {sObjectNames.Count} objects → {batches.Count} Composite Batch request(s)"); + + var batchEndpoint = $"{_instanceUrl}/services/data/v60.0/composite/batch"; + + // Execute all batches in parallel + var batchTasks = batches.Select(async b => + { + Console.WriteLine($"BatchDescribeSObjects: sending batch {b.BatchNumber}/{batches.Count} ({b.Names.Count} objects)"); + var batchRequest = new SalesforceBatchDescribeRequest + { + BatchRequests = b.Names.Select(name => new SalesforceBatchDescribeSubRequest + { + Method = "GET", + Url = $"/services/data/v60.0/sobjects/{name}/describe/" + }).ToList() + }; + + var jsonContent = new StringContent( + JsonSerializer.Serialize(batchRequest, SalesforceJsonOptions), + System.Text.Encoding.UTF8, + "application/json" + ); + + var batchResults = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + var response = await _httpClient.PostAsync(batchEndpoint, jsonContent, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var err = await response.Content.ReadAsStringAsync(cancellationToken); + Console.WriteLine($"BatchDescribeSObjects batch {b.BatchNumber} failed: {response.StatusCode} - {err}"); + foreach (var name in b.Names) batchResults[name] = null; + return batchResults; + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var batchResponse = JsonSerializer.Deserialize(responseContent, SalesforceJsonOptions); + + if (batchResponse?.Results != null) + { + for (int i = 0; i < b.Names.Count; i++) + { + var objectName = b.Names[i]; + if (i >= batchResponse.Results.Count) + { + batchResults[objectName] = null; + continue; + } + + var subResponse = batchResponse.Results[i]; + if (subResponse.StatusCode >= 200 && subResponse.StatusCode < 300 && subResponse.Result.HasValue) + { + try + { + batchResults[objectName] = JsonSerializer.Deserialize( + subResponse.Result.Value.GetRawText(), SalesforceJsonOptions); + } + catch (JsonException ex) + { + Console.WriteLine($"BatchDescribeSObjects: failed to parse describe for {objectName}: {ex.Message}"); + batchResults[objectName] = null; + } + } + else + { + Console.WriteLine($"BatchDescribeSObjects: describe for {objectName} returned status {subResponse.StatusCode}"); + batchResults[objectName] = null; + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"BatchDescribeSObjects: exception in batch {b.BatchNumber}: {ex.Message}"); + foreach (var name in b.Names) batchResults[name] = null; + } + + return batchResults; + }); + + var allBatchResults = await Task.WhenAll(batchTasks); + foreach (var batchResult in allBatchResults) + foreach (var kvp in batchResult) + allResults[kvp.Key] = kvp.Value; + + var successCount = allResults.Values.Count(v => v != null); + Console.WriteLine($"BatchDescribeSObjects completed: {successCount}/{sObjectNames.Count} objects described successfully."); + return allResults; + } + /// /// Creates a new SObject in Salesforce. /// @@ -1631,6 +1714,43 @@ namespace DataConnection.REST.Implementations public string? NextRecordsUrl { get; set; } } + // ===== Composite Batch API models (for parallel describe calls) ===== + + private class SalesforceBatchDescribeRequest + { + [JsonPropertyName("batchRequests")] + public List BatchRequests { get; set; } = new(); + } + + private class SalesforceBatchDescribeSubRequest + { + [JsonPropertyName("method")] + public string Method { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + } + + private class SalesforceBatchDescribeResponse + { + [JsonPropertyName("hasErrors")] + public bool HasErrors { get; set; } + + [JsonPropertyName("results")] + public List Results { get; set; } = new(); + } + + private class SalesforceBatchDescribeSubResponse + { + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } + + [JsonPropertyName("result")] + public JsonElement? Result { get; set; } + } + + // ===== Composite API models (for create/update/query operations) ===== + private class SalesforceCompositeRequest { [JsonPropertyName("compositeRequest")] diff --git a/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs b/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs index cb67ff2..83e5f7a 100644 --- a/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs +++ b/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs @@ -140,23 +140,29 @@ public partial class DataCoupler : ComponentBase Logger.LogInformation("Autenticazione completata con successo per il servizio REST {ServiceType}", credential.ServiceType); - // Discovery delle entità disponibili usando il metodo batch ottimizzato - Logger.LogInformation("Iniziando discovery batch delle entità REST..."); - restEntities = await currentRestDiscovery.DiscoverEntitySummariesAsync(); - isRestConnected = true; + // Avvia entrambe le discovery in parallelo: + // - DiscoverEntitySummariesAsync è veloce (1 API call) → sblocca la UI subito + // - DiscoverEntitiesAsync è pesante (batch describe) → completa in background + Logger.LogInformation("Avvio discovery parallela: entity summaries + entity details (batch)..."); - Logger.LogInformation("Discovery batch completato: trovate {EntityCount} entità REST", restEntities.Count); - - // Carica anche i dettagli completi delle entità per External ID Relationships + var summariesTask = currentRestDiscovery.DiscoverEntitySummariesAsync(); + var entitiesTask = currentRestDiscovery.DiscoverEntitiesAsync(); + + // Attendi le summaries (veloci) e rendi la UI interattiva immediatamente + restEntities = await summariesTask; + isRestConnected = true; + StateHasChanged(); + Logger.LogInformation("Entity summaries completate: {EntityCount} entità. UI interattiva.", restEntities.Count); + + // Attendi i dettagli completi (già in esecuzione in parallelo) try { - Logger.LogInformation("Caricamento dettagli entità per External ID Relationships..."); - availableRelationshipObjects = await currentRestDiscovery.DiscoverEntitiesAsync(); - Logger.LogInformation("Caricati {Count} oggetti disponibili per External ID Relationships", availableRelationshipObjects.Count); + availableRelationshipObjects = await entitiesTask; + Logger.LogInformation("Entity details (batch) completati: {Count} oggetti disponibili per External ID Relationships.", availableRelationshipObjects.Count); } catch (Exception ex) { - Logger.LogWarning(ex, "Impossibile caricare i dettagli delle entità per External ID Relationships"); + Logger.LogWarning(ex, "Impossibile completare il caricamento dei dettagli entità per External ID Relationships"); availableRelationshipObjects = new List(); } } diff --git a/Data_Coupler/Pages/DataCoupler.razor.cs b/Data_Coupler/Pages/DataCoupler.razor.cs index 0595765..a94d274 100644 --- a/Data_Coupler/Pages/DataCoupler.razor.cs +++ b/Data_Coupler/Pages/DataCoupler.razor.cs @@ -441,8 +441,26 @@ public partial class DataCoupler : ComponentBase if (relationships != null && relationships.Any()) { externalIdRelationships.Clear(); - externalIdRelationships.AddRange(relationships); - Logger.LogInformation("External ID Relationships caricate - Totale: {Count}", externalIdRelationships.Count); + + // 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) @@ -546,6 +564,8 @@ public partial class DataCoupler : ComponentBase 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; @@ -579,6 +599,8 @@ public partial class DataCoupler : ComponentBase 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; @@ -1550,20 +1572,13 @@ public partial class DataCoupler : ComponentBase return; } - // Determina il nome della relazione in base al tipo di oggetto - // Salesforce: oggetti STANDARD usano solo il nome (es. "Account") - // oggetti CUSTOM (finiscono con __c) usano __r (es. "CustomObject__r") - string relationshipName; - if (selectedRelationshipObject.EndsWith("__c")) - { - // Oggetto custom: rimuovi __c e aggiungi __r - relationshipName = selectedRelationshipObject.Replace("__c", "__r"); - } - else - { - // Oggetto standard: usa solo il nome - relationshipName = selectedRelationshipObject; - } + // 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 @@ -1606,6 +1621,47 @@ public partial class DataCoupler : ComponentBase } } + /// + /// 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 + /// + /// Nome dell'oggetto correlato (es. "Account", "Custom_Company__c") + /// True se l'oggetto destinazione è custom + /// Nome normalizzato della relazione (es. "Account__r", "Account", "Custom_Company__r") + 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 GetExternalIdFieldsForSelectedObject() { if (string.IsNullOrEmpty(selectedRelationshipObject)) diff --git a/Data_Coupler/Services/ScheduledProfileExecutionService.cs b/Data_Coupler/Services/ScheduledProfileExecutionService.cs index 550a962..6fbb4cf 100644 --- a/Data_Coupler/Services/ScheduledProfileExecutionService.cs +++ b/Data_Coupler/Services/ScheduledProfileExecutionService.cs @@ -171,18 +171,25 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic _logger.LogInformation("Caricate {Count} External ID Relationships dal profilo", externalIdRelationships.Count); } + // 4.6. Parse Default Values + var defaultValues = ParseDefaultValues(profile.DefaultValuesJson); + if (defaultValues.Any()) + { + _logger.LogInformation("Caricati {Count} default values dal profilo", defaultValues.Count); + } + // 5. Determina se utilizzare Salesforce Composite API bool useSalesforceComposite = restClient is DataConnection.REST.Implementations.SalesforceServiceClient; if (useSalesforceComposite) { _logger.LogInformation("Utilizzo Salesforce Composite API per il trasferimento"); - return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, externalIdRelationships, enableDeletionSync); + return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync); } else { _logger.LogInformation("Utilizzo metodo trasferimento standard per il trasferimento"); - return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, externalIdRelationships, enableDeletionSync); + return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync); } } catch (Exception ex) @@ -417,6 +424,53 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic return relationships; } + /// + /// Parse del JSON dei default values + /// + private Dictionary ParseDefaultValues(string? defaultValuesJson) + { + var defaultValues = new Dictionary(); + + if (string.IsNullOrEmpty(defaultValuesJson)) + { + _logger.LogDebug("DefaultValues JSON è vuoto o null"); + return defaultValues; + } + + _logger.LogDebug("Parsing DefaultValues JSON: {Json}", defaultValuesJson); + + try + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var deserializedDefaults = JsonSerializer.Deserialize>(defaultValuesJson, options); + if (deserializedDefaults != null) + { + foreach (var entry in deserializedDefaults) + { + defaultValues[entry.Key] = (entry.Value.Value, entry.Value.Type); + _logger.LogDebug("Default value: {Field} = {Value} ({Type})", + entry.Key, entry.Value.Value, entry.Value.Type); + } + + _logger.LogInformation("Trovati {Count} default values nel JSON", defaultValues.Count); + } + else + { + _logger.LogWarning("Deserializzazione ritornato null per DefaultValues JSON"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore nel parsing dei default values: {Json}", defaultValuesJson); + } + + return defaultValues; + } + /// /// Ottiene tutti i record dal database /// @@ -685,6 +739,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic RestEntitySummary restEntity, RestApiCredential restCredential, Dictionary fieldMappings, + Dictionary defaultValues, List externalIdRelationships, bool enableDeletionSync = false) { @@ -699,8 +754,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic { try { - // 1. Trasforma il record utilizzando i field mappings e External ID Relationships - var restData = TransformRecordForRest(record, fieldMappings, externalIdRelationships); + // 1. Trasforma il record utilizzando i field mappings, default values e External ID Relationships + var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships); // 2. Gestione associazioni record se abilitata string? entityId = null; @@ -810,6 +865,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic RestEntitySummary restEntity, RestApiCredential restCredential, Dictionary fieldMappings, + Dictionary defaultValues, List externalIdRelationships, bool enableDeletionSync = false) { @@ -820,7 +876,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic if (!(restClient is DataConnection.REST.Implementations.SalesforceServiceClient salesforceClient)) { _logger.LogWarning("Client REST non è SalesforceServiceClient, fallback al metodo standard"); - return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, externalIdRelationships, enableDeletionSync); + return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync); } try @@ -851,7 +907,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic var recordNumber = indexedRecord.RecordNumber; // Trasforma il record in base ai mapping e External ID Relationships (operazione locale, thread-safe) - var restData = TransformRecordForRest(record, fieldMappings, externalIdRelationships); + var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships); // Genera la chiave sorgente e l'hash dei dati per questo record (include MAPPING_SIGNATURE) var sourceKey = GenerateSourceKey(record, profile.SourceKeyField); @@ -1144,12 +1200,30 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic private Dictionary TransformRecordForRest( Dictionary sourceRecord, Dictionary fieldMappings, + Dictionary defaultValues, List? externalIdRelationships = null) { var restData = new Dictionary(); + // Costruisce un set dei campi sorgente usati esclusivamente come External ID Relationship: + // questi NON devono essere inviati anche come mapping normale (stessa logica della UI manuale). + var externalIdSourceFields = (externalIdRelationships != null) + ? externalIdRelationships + .Where(r => !string.IsNullOrWhiteSpace(r.SourceField)) + .Select(r => r.SourceField) + .ToHashSet() + : new HashSet(); + + // 1. Applica field mappings (escludendo i campi sorgente usati per External ID Relationships) foreach (var mapping in fieldMappings) { + // Salta il campo se è usato come sorgente in un External ID Relationship + if (externalIdSourceFields.Contains(mapping.Key)) + { + _logger.LogDebug("Campo sorgente '{SourceField}' usato in External ID Relationship, escluso dal mapping normale", mapping.Key); + continue; + } + if (sourceRecord.ContainsKey(mapping.Key)) { var value = sourceRecord[mapping.Key]; @@ -1164,7 +1238,22 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic } } - // Aggiungi External ID Relationships (per Salesforce) + // 2. Applica default values (solo se il campo non è già stato mappato) + foreach (var defaultValue in defaultValues) + { + if (!restData.ContainsKey(defaultValue.Key)) + { + var (value, type) = defaultValue.Value; + if (value != null) + { + restData[defaultValue.Key] = value; + _logger.LogDebug("Applicato default value: {Field} = {Value} ({Type})", + defaultValue.Key, value, type); + } + } + } + + // 3. Aggiungi External ID Relationships (per Salesforce) if (externalIdRelationships != null && externalIdRelationships.Any()) { foreach (var relationship in externalIdRelationships) diff --git a/EXTERNAL_ID_RELATIONSHIPS_IMPLEMENTATION.md b/EXTERNAL_ID_RELATIONSHIPS_IMPLEMENTATION.md index 8cf4ee4..4d1bd2c 100644 --- a/EXTERNAL_ID_RELATIONSHIPS_IMPLEMENTATION.md +++ b/EXTERNAL_ID_RELATIONSHIPS_IMPLEMENTATION.md @@ -196,40 +196,126 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0'); ## 📊 Formato Dati Salesforce -### Esempio di Trasformazione +### ⚠️ REGOLA IMPORTANTE: Formato Basato sull'Oggetto DESTINAZIONE + +Il formato delle External ID Relationships dipende dal **tipo dell'oggetto DESTINAZIONE** (quello che stai creando/aggiornando), **NON** dal tipo dell'oggetto correlato: + +#### **Se l'Oggetto DESTINAZIONE è CUSTOM** (es. `Sales_Quote__c`, `Custom_Order__c`): +- ✅ Tutte le relazioni usano `__r`, sia per oggetti standard che custom +- **Oggetto Standard**: `"Account__r": { "External_ID__c": "value" }` +- **Oggetto Custom**: `"Custom_Company__r": { "External_ID__c": "value" }` + +#### **Se l'Oggetto DESTINAZIONE è STANDARD** (es. `Opportunity`, `Contact`): +- ✅ Solo oggetti custom correlati usano `__r` +- **Oggetto Standard**: `"Account": { "External_ID__c": "value" }` +- **Oggetto Custom**: `"Custom_Company__r": { "External_ID__c": "value" }` + +### Esempi Pratici + +#### Esempio 1: Destinazione CUSTOM → Relazione a Oggetto STANDARD + +**Scenario**: Creo un record `Sales_Quote__c` collegato ad `Account` standard **Configurazione:** -- **Relationship Name**: `Account__r` +- **Destination Object**: `Sales_Quote__c` (CUSTOM) +- **Relationship Name**: `Account__r` ⚠️ **Usa __r anche se Account è standard!** +- **Related Object**: `Account` +- **External ID Field**: `Codice_ERP__c` +- **Source Field**: `customerCode` + +**Record Trasformato:** +```json +{ + "Name": "Quote 2024-001", + "Quote_Code__c": "Q001", + "Account__r": { + "Codice_ERP__c": "C60000" + } +} +``` + +#### Esempio 2: Destinazione STANDARD → Relazione a Oggetto STANDARD + +**Scenario**: Creo un record `Opportunity` collegato ad `Account` + +**Configurazione:** +- **Destination Object**: `Opportunity` (STANDARD) +- **Relationship Name**: `Account` ⚠️ **NON usa __r** - **Related Object**: `Account` - **External ID Field**: `Country__c` -- **Source Field**: `CountryCode` (dalla tabella sorgente) +- **Source Field**: `CountryCode` + +**Record Trasformato:** +```json +{ + "Name": "New Deal 2024", + "StageName": "Prospecting", + "Account": { + "Country__c": "US" + } +} +``` + +#### Esempio 3: Destinazione CUSTOM → Relazione a Oggetto CUSTOM + +**Configurazione:** +- **Destination Object**: `Sales_Quote__c` (CUSTOM) +- **Relationship Name**: `Custom_Territory__r` +- **Related Object**: `Custom_Territory__c` +- **External ID Field**: `Territory_Code__c` +- **Source Field**: `territoryCode` **Record Sorgente:** ```json { - "ProductName": "Widget A", - "Price": 99.99, - "CountryCode": "US" + "quoteName": "Quote A", + "territoryCode": "NORTH-WEST" } ``` -**Record Trasformato per Salesforce:** +**Record Trasformato:** ```json { - "Name": "Widget A", - "Price__c": 99.99, - "Account__r": { - "Country__c": "US" + "Name": "Quote A", + "Custom_Territory__r": { + "Territory_Code__c": "NORTH-WEST" } } ``` +### Logica di Normalizzazione Automatica + +Il sistema implementa il metodo `NormalizeRelationshipName()` che garantisce il formato corretto: + +```csharp +private string NormalizeRelationshipName(string relatedObjectName, bool isDestinationCustom) +{ + if (isDestinationCustom) + { + // Destinazione CUSTOM: tutte le relazioni usano __r + if (relatedObjectName.EndsWith("__c")) + return relatedObjectName.Replace("__c", "__r"); // Custom_Obj__c → Custom_Obj__r + else + return relatedObjectName + "__r"; // Account → Account__r + } + else + { + // Destinazione STANDARD: solo oggetti custom usano __r + if (relatedObjectName.EndsWith("__c")) + return relatedObjectName.Replace("__c", "__r"); // Custom_Obj__c → Custom_Obj__r + else + return relatedObjectName; // Account → Account (no suffix) + } +} +``` + ### Vantaggi External ID 1. **Nessun ID Salesforce Richiesto**: Non serve conoscere l'ID Salesforce dell'Account -2. **Lookup Automatico**: Salesforce cerca automaticamente l'Account con `Country__c = "US"` -3. **Upsert Intelligente**: Se non trova l'Account, può crearlo automaticamente (se configurato) +2. **Lookup Automatico**: Salesforce cerca automaticamente l'oggetto correlato tramite External ID +3. **Upsert Intelligente**: Se non trova l'oggetto, può crearlo automaticamente (se configurato) 4. **Manutenzione Semplificata**: I codici esterni sono più stabili degli ID interni +5. **Normalizzazione Automatica**: Il sistema corregge automaticamente i nomi quando carica profili salvati ## 🔄 Flusso Operativo @@ -307,6 +393,14 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0'); - Solo dopo field mappings configurati (`fieldMappings.Any()`) - Migliora UX evitando confusione per altre API +5. **⭐ Normalizzazione Automatica RelationshipName (FIX CRITICO - 17 Feb 2026)** + - **Problema Risolto**: Errore `"No such column 'Account' on sobject of type Sales_Quote__c"` + - **Causa**: Il formato dipende dall'oggetto DESTINAZIONE, non dall'oggetto correlato + - **Soluzione**: Metodo `NormalizeRelationshipName()` controlla tipo oggetto destinazione + - **Funzionalità**: Corregge automaticamente i RelationshipName al caricamento profili + - **Regola**: Se destinazione è custom → usa SEMPRE `__r` per tutte le relazioni + - **Benefici**: Profili esistenti vengono corretti automaticamente senza intervento manuale + ### Potenziali Estensioni Future 1. **Validazione Avanzata**: Verifica esistenza oggetto/campo su Salesforce prima di salvare @@ -319,6 +413,13 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0'); ### Errori Comuni +**⚠️ Errore: "No such column 'Account' on sobject of type Sales_Quote__c"** +- **Causa**: RelationshipName incorretto per oggetto destinazione custom +- **Spiegazione**: Quando l'oggetto DESTINAZIONE è custom (es. `Sales_Quote__c`), TUTTE le relazioni devono usare `__r`, anche per oggetti standard +- **Soluzione AUTOMATICA**: ✅ Il sistema ora normalizza automaticamente i nomi delle relazioni +- **Esempio**: Se destinazione è `Sales_Quote__c` e correlato è `Account` → usa `Account__r` (non `Account`) +- **Fix Manuale**: Se usi profili vecchi, il sistema correggerà automaticamente al caricamento + **Errore: "External ID field not found"** - Causa: Campo External ID non esiste sull'oggetto Salesforce - Soluzione: Verificare che il campo sia configurato come External ID in Salesforce @@ -343,7 +444,8 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0'); --- -**Implementazione Completata**: 3 Febbraio 2026 +**Implementazione Iniziale**: 3 Febbraio 2026 +**Ultimo Aggiornamento**: 17 Febbraio 2026 - ⭐ **FIX CRITICO**: Normalizzazione automatica RelationshipName **Framework**: .NET 9.0 **Pattern**: Repository + DTO + Service Layer **Database**: SQLite con Entity Framework Core diff --git a/README.md b/README.md index b5340d9..02b90f4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ Data-Coupler è una soluzione integrata per la gestione di connessioni dati e cr - **DataConnection**: Libreria per connessioni a database e API REST - **Data_Coupler**: Applicazione Blazor Server per l'interfaccia utente +### 🆕 Novità Recenti (Febbraio 2026) +- ✅ **Salesforce Batch Describe via Composite API**: I metadati degli SObject vengono ora recuperati in batch (25 per chiamata) invece di N chiamate singole, riducendo drasticamente il consumo di API durante la discovery +- ✅ **Discovery REST Parallela**: `DiscoverEntitySummariesAsync` e `DiscoverEntitiesAsync` vengono eseguite in parallelo; la lista entità diventa interattiva quasi subito, i dettagli arrivano in background +- ✅ **Fix Scheduler External ID Relationships**: Corretti due bug nello schedulatore — `ExternalIdRelationshipsJson` e `DefaultValuesJson` venivano azzerati al re-salvataggio del profilo; i campi sorgente usati nelle relazioni External ID non venivano esclusi dal mapping normale + ### 🆕 Novità Recenti (Gennaio 2026) - ✅ **Schedulazione File CSV/Excel**: Supporto completo per schedulare trasferimenti da file - ✅ **Validazione Percorsi**: Validazione file prima del salvataggio profili