Compare commits
8 Commits
v2.3.0
...
335d587c89
| Author | SHA1 | Date | |
|---|---|---|---|
| 335d587c89 | |||
| b1f83aa7ab | |||
| 20ca84e4f7 | |||
| 91704eb944 | |||
| 3abfed91e1 | |||
| 9d146d521e | |||
| 2e25b451c9 | |||
| 201a15de1f |
@@ -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
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/csharp,visualstudiocode,visualstudio
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=csharp,visualstudiocode,visualstudio
|
||||
|
||||
# Data-Coupler specific
|
||||
# Version file generato automaticamente durante il build
|
||||
Data_Coupler/wwwroot/version.json
|
||||
|
||||
### Csharp ###
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
|
||||
@@ -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<Data_Coupler.Services.IBackupService, Data_Coupler.Se
|
||||
|
||||
---
|
||||
|
||||
**Versione**: 1.0
|
||||
**Ultimo Aggiornamento**: Settembre 2024
|
||||
**Versione**: 1.1
|
||||
**Ultimo Aggiornamento**: 20 Febbraio 2026
|
||||
**Framework**: .NET 9.0
|
||||
**Sviluppatore**: Alessio Dalsanto
|
||||
@@ -109,6 +109,7 @@ public class DataCouplerProfileService : IDataCouplerProfileService
|
||||
existingProfile.DestinationTable = profile.DestinationTable;
|
||||
existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
|
||||
existingProfile.FieldMappingJson = profile.FieldMappingJson;
|
||||
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
|
||||
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
|
||||
existingProfile.SourceKeyField = profile.SourceKeyField;
|
||||
existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.5" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.3" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
|
||||
<PackageReference Include="System.Data.Odbc" Version="9.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -175,70 +175,43 @@ namespace DataConnection.REST.Implementations
|
||||
var entities = new List<RestEntityInfo>();
|
||||
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<SalesforceSObjectsResponse>(cancellationToken: cancellationToken); if (sobjectsResponse?.SObjects != null)
|
||||
var sobjectsResponse = await response.Content.ReadFromJsonAsync<SalesforceSObjectsResponse>(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<SalesforceDescribeResponse>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<string, SalesforceDescribeResponse?>> BatchDescribeSObjectsAsync(
|
||||
List<string> sObjectNames, CancellationToken cancellationToken)
|
||||
{
|
||||
const int maxBatchSize = 25;
|
||||
var allResults = new Dictionary<string, SalesforceDescribeResponse?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Split into batches of 25 (Salesforce Composite Batch limit)
|
||||
var batches = new List<(List<string> 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<string, SalesforceDescribeResponse?>(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<SalesforceBatchDescribeResponse>(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<SalesforceDescribeResponse>(
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SObject in Salesforce.
|
||||
/// </summary>
|
||||
@@ -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<SalesforceBatchDescribeSubRequest> 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<SalesforceBatchDescribeSubResponse> 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")]
|
||||
|
||||
@@ -42,4 +42,49 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Target per generare automaticamente version.json prima del build -->
|
||||
<!-- SOLO per sviluppo locale, NON in CI/CD (il workflow lo genera prima del Docker build) -->
|
||||
<Target Name="GenerateVersionJson" BeforeTargets="BeforeBuild;BeforeRebuild"
|
||||
Condition="'$(ContinuousIntegrationBuild)' != 'true' AND '$(SkipVersionGeneration)' != 'true'">
|
||||
<!-- Esegui git per ottenere il tag più recente e informazioni commit -->
|
||||
<Exec Command="git describe --tags --abbrev=0 2>nul" ConsoleToMSBuild="true" IgnoreExitCode="true">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="GitLatestTag" />
|
||||
</Exec>
|
||||
<Exec Command="git rev-parse --short HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitSha" />
|
||||
</Exec>
|
||||
<Exec Command="git rev-parse --abbrev-ref HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="GitBranch" />
|
||||
</Exec>
|
||||
|
||||
<!-- Estrai la versione dal tag (rimuovi il prefisso 'v') -->
|
||||
<PropertyGroup>
|
||||
<ActualVersion Condition="'$(GitLatestTag)' != '' and $(GitLatestTag.StartsWith('v'))">$(GitLatestTag.Substring(1))</ActualVersion>
|
||||
<ActualVersion Condition="'$(GitLatestTag)' != '' and !$(GitLatestTag.StartsWith('v'))">$(GitLatestTag)</ActualVersion>
|
||||
<ActualVersion Condition="'$(GitLatestTag)' == ''">1.0.0-dev</ActualVersion>
|
||||
<ActualCommitSha Condition="'$(GitCommitSha)' != ''">$(GitCommitSha)</ActualCommitSha>
|
||||
<ActualCommitSha Condition="'$(GitCommitSha)' == ''">unknown</ActualCommitSha>
|
||||
<ActualBranch Condition="'$(GitBranch)' != ''">$(GitBranch)</ActualBranch>
|
||||
<ActualBranch Condition="'$(GitBranch)' == ''">local</ActualBranch>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Genera il contenuto JSON -->
|
||||
<PropertyGroup>
|
||||
<FinalVersionJsonContent>
|
||||
{
|
||||
"version": "$(ActualVersion)",
|
||||
"commitSha": "$(ActualCommitSha)",
|
||||
"branch": "$(ActualBranch)",
|
||||
"buildDate": "$([System.DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss'))",
|
||||
"buildEnvironment": "Development"
|
||||
}
|
||||
</FinalVersionJsonContent>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Scrivi il file version.json -->
|
||||
<WriteLinesToFile File="wwwroot\version.json" Lines="$(FinalVersionJsonContent)" Overwrite="true" />
|
||||
|
||||
<Message Text="Generated version.json with version $(ActualVersion) from git tag (local dev build)" Importance="high" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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<RestEntityInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@code {
|
||||
private int currentCount = 0;
|
||||
|
||||
private void IncrementCount()
|
||||
private void IncrementCount()
|
||||
{
|
||||
currentCount++;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse del JSON dei default values
|
||||
/// </summary>
|
||||
private Dictionary<string, (object? Value, string? Type)> ParseDefaultValues(string? defaultValuesJson)
|
||||
{
|
||||
var defaultValues = new Dictionary<string, (object? Value, string? Type)>();
|
||||
|
||||
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<Dictionary<string, DefaultValueDto>>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene tutti i record dal database
|
||||
/// </summary>
|
||||
@@ -685,6 +739,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
|
||||
RestEntitySummary restEntity,
|
||||
RestApiCredential restCredential,
|
||||
Dictionary<string, string> fieldMappings,
|
||||
Dictionary<string, (object? Value, string? Type)> defaultValues,
|
||||
List<ExternalIdRelationshipDto> 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<string, string> fieldMappings,
|
||||
Dictionary<string, (object? Value, string? Type)> defaultValues,
|
||||
List<ExternalIdRelationshipDto> 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<string, object> TransformRecordForRest(
|
||||
Dictionary<string, object> sourceRecord,
|
||||
Dictionary<string, string> fieldMappings,
|
||||
Dictionary<string, (object? Value, string? Type)> defaultValues,
|
||||
List<ExternalIdRelationshipDto>? externalIdRelationships = null)
|
||||
{
|
||||
var restData = new Dictionary<string, object>();
|
||||
|
||||
// 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<string>();
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": "2.2.0",
|
||||
"commitSha": "01f7846",
|
||||
"branch": "development",
|
||||
"buildDate": "2026-02-02",
|
||||
"buildEnvironment": "Local"
|
||||
}
|
||||
+12
-8
@@ -20,24 +20,28 @@ COPY . .
|
||||
|
||||
# Build del progetto principale
|
||||
WORKDIR "/src/Data_Coupler"
|
||||
RUN dotnet build "Data_Coupler.csproj" -c Release -o /app/build
|
||||
RUN dotnet build "Data_Coupler.csproj" -c Release -o /app/build /p:ContinuousIntegrationBuild=true
|
||||
|
||||
# Stage 2: Publish
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /app/publish \
|
||||
/p:UseAppHost=false \
|
||||
/p:SelfContained=false \
|
||||
/p:PublishTrimmed=false \
|
||||
/p:PublishSingleFile=false
|
||||
/p:PublishSingleFile=false \
|
||||
/p:ContinuousIntegrationBuild=true
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Installa le dipendenze necessarie per ExcelDataReader e altre librerie
|
||||
RUN apk add --no-cache \
|
||||
# Installa le dipendenze necessarie per ExcelDataReader e SQLite
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libgdiplus \
|
||||
icu-libs \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
libc6-dev \
|
||||
sqlite3 \
|
||||
libsqlite3-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Crea la directory per il database con i permessi corretti
|
||||
RUN mkdir -p /var/lib/Data_Coupler && \
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ COPY ["Components/Components.csproj", "Components/"]
|
||||
COPY ["nuget.config", "./"]
|
||||
|
||||
# Ripristina le dipendenze per tutti i progetti con package cache ultra-corto
|
||||
RUN dotnet restore "Data_Coupler/Data_Coupler.csproj" --disable-parallel --packages /p
|
||||
RUN dotnet restore "Data_Coupler/Data_Coupler.csproj" --runtime win-x64 --disable-parallel --packages /p
|
||||
|
||||
# Copia tutto il codice sorgente
|
||||
COPY ["Data_Coupler/", "Data_Coupler/"]
|
||||
@@ -27,7 +27,7 @@ RUN dotnet build "Data_Coupler.csproj" -c Release -o /o --no-restore
|
||||
|
||||
# Stage 2: Publish
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /p --no-restore /p:UseAppHost=false
|
||||
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /p --no-restore -r win-x64 --self-contained false
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0-nanoserver-ltsc2022 AS final
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user