Fix: Risolto double-mapping negli External ID Relationships per Salesforce
- Implementata funzionalità completa External ID Relationships nell'interfaccia di mapping
- Corretto bug double-mapping: i campi sorgente usati per External ID non vengono più inclusi nei mapping normali
- Risolto errore MALFORMED_ID causato dall'invio duplicato di campi come proprietà dirette e nested objects
- Implementata logica corretta per relationship names: oggetti standard usano il nome diretto, custom objects usano suffisso __r
- Aggiunta UI a 3 colonne (Object, External ID Field, Source Field) per configurazione External ID Relationships
- Migrazione database per supporto External ID Relationships nei profili
- Aggiornato ProfileSaver.razor.cs per salvare/caricare External ID Relationships
- Aggiornato ScheduledProfileExecutionService.cs per gestire External ID nelle esecuzioni schedulate
- Formato JSON output corretto: { 'Account': { 'CardCode__c': 'V50000' } }
Documentazione: EXTERNAL_ID_RELATIONSHIPS_IMPLEMENTATION.md
This commit is contained in:
@@ -474,12 +474,27 @@ else
|
||||
<label class="form-label">Host/Server *</label>
|
||||
<InputText class="form-control" @bind-Value="currentDatabaseCredential.Host"
|
||||
placeholder="es. localhost o server.dominio.com" />
|
||||
@if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer)
|
||||
{
|
||||
<div class="form-text">
|
||||
<strong>SQL Server locale:</strong><br/>
|
||||
• Named Instance: <code>localhost\SQLEXPRESS</code> o <code>.\SQLEXPRESS</code><br/>
|
||||
• LocalDB: <code>(localdb)\MSSQLLocalDB</code><br/>
|
||||
• Default: <code>localhost</code> o <code>.</code> (usa porta 1433)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Porta *</label>
|
||||
<InputNumber class="form-control" @bind-Value="currentDatabaseCredential.Port" />
|
||||
@if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer)
|
||||
{
|
||||
<div class="form-text">
|
||||
<small>Ignorata per named instances e LocalDB</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -495,13 +510,26 @@ else
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username *</label>
|
||||
<InputText class="form-control" @bind-Value="currentDatabaseCredential.Username" />
|
||||
<InputText class="form-control" @bind-Value="currentDatabaseCredential.Username"
|
||||
placeholder="o scrivi 'Integrated' per Windows Auth" />
|
||||
@if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer)
|
||||
{
|
||||
<div class="form-text">
|
||||
<small>Per Windows Authentication, scrivi <strong>Integrated</strong> o lascia vuoto</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password *</label>
|
||||
<InputText type="password" class="form-control" @bind-Value="currentDatabaseCredential.Password" />
|
||||
@if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer)
|
||||
{
|
||||
<div class="form-text">
|
||||
<small>Non richiesta per Windows Authentication</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -994,13 +1022,28 @@ else
|
||||
else
|
||||
{
|
||||
// Altri database: validazione standard (Host, Username, Password)
|
||||
if (string.IsNullOrEmpty(currentDatabaseCredential.Host) ||
|
||||
string.IsNullOrEmpty(currentDatabaseCredential.Username) ||
|
||||
string.IsNullOrEmpty(currentDatabaseCredential.Password))
|
||||
// Per SQL Server, permetti Windows Authentication (username vuoto o "Integrated")
|
||||
bool isSqlServerWithWindowsAuth = currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer &&
|
||||
(string.IsNullOrWhiteSpace(currentDatabaseCredential.Username) ||
|
||||
currentDatabaseCredential.Username.Equals("Integrated", StringComparison.OrdinalIgnoreCase) ||
|
||||
currentDatabaseCredential.Username.Equals("Windows", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (string.IsNullOrEmpty(currentDatabaseCredential.Host))
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", "Compila tutti i campi obbligatori (Host, Username, Password).");
|
||||
await JSRuntime.InvokeVoidAsync("alert", "Il campo Host è obbligatorio.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSqlServerWithWindowsAuth)
|
||||
{
|
||||
// Per database che non usano Windows Authentication, richiedi username e password
|
||||
if (string.IsNullOrEmpty(currentDatabaseCredential.Username) ||
|
||||
string.IsNullOrEmpty(currentDatabaseCredential.Password))
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", "Username e Password sono obbligatori. Per SQL Server con Windows Authentication, inserisci 'Integrated' come username.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (success, message) = await CredentialService.TestDatabaseConnectionAsync(currentDatabaseCredential);
|
||||
|
||||
@@ -974,6 +974,119 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sezione External ID Relationships (Salesforce) -->
|
||||
@if (selectedRestEntity != null && currentRestDiscovery != null && IsSalesforceClient())
|
||||
{
|
||||
<div class="mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-link"></i> External ID Relationships (Salesforce)
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>Relating Records by External ID</strong><br>
|
||||
<small>
|
||||
Crea relazioni tra oggetti usando ID esterni invece degli ID interni di Salesforce.<br>
|
||||
Esempio: Collega Opportunity ad Account usando <code>Account.CardCode__c = "C60000"</code>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Form per aggiungere nuova relazione -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Oggetto Correlato:</label>
|
||||
<select class="form-select" @bind="selectedRelationshipObject" @bind:after="OnRelationshipObjectSelected">
|
||||
<option value="">-- Seleziona Oggetto --</option>
|
||||
@foreach (var entity in availableRelationshipObjects.OrderBy(e => e.Name))
|
||||
{
|
||||
<option value="@entity.Name">@entity.Name</option>
|
||||
}
|
||||
</select>
|
||||
<small class="text-muted">Es: Account, Contact</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">External ID Field:</label>
|
||||
<select class="form-select" @bind="selectedExternalIdField" disabled="@string.IsNullOrEmpty(selectedRelationshipObject)">
|
||||
<option value="">-- Seleziona Campo --</option>
|
||||
@foreach (var field in GetExternalIdFieldsForSelectedObject())
|
||||
{
|
||||
<option value="@field">@field</option>
|
||||
}
|
||||
</select>
|
||||
<small class="text-muted">Es: Country__c, CardCode__c</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Campo Sorgente:</label>
|
||||
<select class="form-select" @bind="selectedRelationshipSourceField">
|
||||
<option value="">-- Seleziona Campo --</option>
|
||||
@foreach (var field in GetSourceFieldsForRelationship())
|
||||
{
|
||||
<option value="@field">@field</option>
|
||||
}
|
||||
</select>
|
||||
<small class="text-muted">Valore da usare per la relazione</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button class="btn btn-primary w-100" @onclick="AddExternalIdRelationship"
|
||||
disabled="@(string.IsNullOrEmpty(selectedRelationshipObject) || string.IsNullOrEmpty(selectedExternalIdField) || string.IsNullOrEmpty(selectedRelationshipSourceField))">
|
||||
<i class="fas fa-plus"></i> Aggiungi Relazione
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabella relazioni configurate -->
|
||||
@if (externalIdRelationships.Any())
|
||||
{
|
||||
<div class="mt-3">
|
||||
<h6>Relazioni Configurate (@externalIdRelationships.Count)</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Oggetto Correlato</th>
|
||||
<th>External ID Field</th>
|
||||
<th>Campo Sorgente</th>
|
||||
<th>Formato JSON Output</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var rel in externalIdRelationships)
|
||||
{
|
||||
<tr>
|
||||
<td><strong>@rel.RelatedObjectName</strong></td>
|
||||
<td><code>@rel.ExternalIdField</code></td>
|
||||
<td><span class="badge bg-info">@rel.SourceField</span></td>
|
||||
<td><small class="text-muted">@($"\"{rel.RelationshipName}\": {{ \"{rel.ExternalIdField}\": \"value\" }}")</small></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" @onclick="@(() => RemoveExternalIdRelationship(rel))">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-secondary">
|
||||
<i class="fas fa-info-circle"></i> Nessuna relazione External ID configurata. Aggiungine una se necessario.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Sezione Mappature Correnti --> @if (fieldMappings.Any())
|
||||
{
|
||||
<div class="mt-4">
|
||||
@@ -1153,6 +1266,8 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
@@ -1198,7 +1313,8 @@
|
||||
DestinationCredentialId="@(GetCurrentDestinationCredentialIdAsync().Result)"
|
||||
DestinationCredentialName="@selectedRestCredential"
|
||||
DestinationEndpoint="@selectedRestEntity?.Name"
|
||||
FieldMappings="@GetCurrentFieldMappings()"
|
||||
FieldMappings="@GetCurrentFieldMappings()"
|
||||
ExternalIdRelationships="@externalIdRelationships"
|
||||
SourceKeyField="@sourceKeyField"
|
||||
UseRecordAssociations="@useRecordAssociations"
|
||||
OnProfileSaved="@OnProfileSaved" />
|
||||
|
||||
@@ -54,6 +54,13 @@ public partial class DataCoupler : ComponentBase
|
||||
private Dictionary<string, string> fieldMappings = new(); // DbColumn -> RestProperty
|
||||
private HashSet<string> keyFields = new(); // REST properties marked as keys
|
||||
private string selectedDbColumn = "";
|
||||
|
||||
// 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
|
||||
@@ -374,6 +381,33 @@ public partial class DataCoupler : ComponentBase
|
||||
{
|
||||
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();
|
||||
externalIdRelationships.AddRange(relationships);
|
||||
Logger.LogInformation("External ID Relationships caricate - 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;
|
||||
@@ -688,6 +722,7 @@ public partial class DataCoupler : ComponentBase
|
||||
ResetDestinationState();
|
||||
fieldMappings.Clear();
|
||||
keyFields.Clear();
|
||||
externalIdRelationships.Clear(); // Reset relazioni
|
||||
transferResults.Clear();
|
||||
transferMessage = "";
|
||||
}
|
||||
@@ -1316,6 +1351,9 @@ public partial class DataCoupler : ComponentBase
|
||||
Logger.LogInformation("Rimosso mapping specifico per campo: {DbColumn}", dbColumn);
|
||||
}
|
||||
}
|
||||
Logger.LogInformation("Rimosso mapping specifico per campo: {DbColumn}", dbColumn);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearAllMappings()
|
||||
{
|
||||
@@ -1325,8 +1363,128 @@ public partial class DataCoupler : ComponentBase
|
||||
sourceKeyField = "";
|
||||
transferMessage = "";
|
||||
transferMessageType = "";
|
||||
externalIdRelationships.Clear(); // Pulisce anche le relazioni
|
||||
Logger.LogInformation("Tutti i mapping 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 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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
@@ -1943,11 +2101,25 @@ public partial class DataCoupler : ComponentBase
|
||||
{
|
||||
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();
|
||||
|
||||
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];
|
||||
@@ -1962,6 +2134,35 @@ public partial class DataCoupler : ComponentBase
|
||||
}
|
||||
}
|
||||
|
||||
// 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}",
|
||||
string.Join(", ", dbRecord.Keys),
|
||||
string.Join(", ", restData.Keys));
|
||||
|
||||
Reference in New Issue
Block a user