344853fde9
## Bulk Pre-Discovery e riduzione query SQLite/SOQL
### KeyAssociationService — FindAssociationsByKeyValuesBulkAsync (nuovo)
- Aggiunta query bulk 'WHERE KeyValue IN (...)' per recuperare N associazioni con 1 sola query SQLite
(chunking a 500 chiavi per rispettare il limite ~999 parametri di SQLite)
- Aggiunta interfaccia IKeyAssociationService e delegata in DataConnectionCredentialService / IDataConnectionCredentialService
### AssociationService — BatchFindOrCreateAssociationsAsync (nuovo)
- Nuovo metodo bulk che sostituisce i loop per-record durante l'analisi composite:
1) 1 query SQLite bulk per tutte le chiavi
2) Per le chiavi non trovate: SOQL 'IN (...)' su Salesforce in chunk da 200 via BatchExecuteQueriesAsync
(ceil(K/25) HTTP Composite call invece di K singole)
3) Salvataggio parallelo delle associazioni pre-discovery scoperte
- Fallback per-record automatico per client REST non Salesforce
- Aggiornata interfaccia IAssociationService con documentazione XML completa
### DataCoupler.razor.cs — STEP A/B nel flusso COMPOSITE
- Pre-Discovery spostata FUORI dal loop parallelo (STEP A, prima dell'analisi)
- associationsByKey pre-popolato con BatchFindOrCreateAssociationsAsync
- STEP B: il loop parallelo usa TryGetValue O(1) invece di query async per record
- Rimozione blocco ~40 righe di per-record lookup / fallback duplicati
## Salesforce Composite API — Batch Delete e Patch
### SalesforceServiceClient — metodi batch (nuovi)
- BatchDeleteEntitiesAsync: elimina N record con ceil(N/25) Composite call invece di N
- BatchPatchSingleFieldAsync: aggiorna un singolo campo su N record tramite BatchUpdateEntitiesAsync
### DeletionSyncService — refactoring batch
- ExecuteBatchedSalesforceDeletionsAsync: orchestrazione batch per Delete / Deactivate / Mark su Salesforce
- ExecuteSequentialDeletionsAsync: loop sequenziale esistente estratto in metodo riutilizzabile
- Dispatcher: Salesforce -> batch Composite, altri client REST -> sequenziale
## Supporto OLE DB (database)
### DatabaseSchemaProviderFactory
- Aggiunto case DatabaseType.OleDb -> new OleDbSchemaProvider() nel factory switch
### DatabaseMethod.cs
- Aggiunto metodo IsOleDbConnection() (parallelo a IsOdbcConnection())
- Query validation e manager temporaneo estesi a OLE DB oltre che ODBC
- GetLimitedQuery: aggiunto case OleDb -> 'SELECT TOP N FROM (subquery)'
## Salesforce OAuth2 — fix client_credentials
### CredentialService.cs
- Aggiunto 'GrantType' alla HashSet serviceSpecificKeys per preservarlo nella serializzazione AdditionalParameters
### DataConnectionCredentialService.cs
- Refactored BuildRestServiceOptions in helper statico riutilizzato da entrambi i metodi GetRestServiceOptions
- Mapping coerente ClientId/ClientSecret/GrantType per Salesforce (allineato a DataConnectionFactory)
- TestSalesforceOAuthLogin: branch esplicito per client_credentials (no username/password/token)
con validazione preventiva ClientId+ClientSecret obbligatori
- Log flow label (password|client_credentials) in tutti i messaggi di autenticazione
## VS Code tasks
### .vscode/tasks.json
- Rimosso task generico 'Publish Data_Coupler'
- Aggiunti due task separati: win-x64 e win-x86, entrambi SingleFile + Self-Contained + ReadyToRun
594 lines
23 KiB
C#
594 lines
23 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using CredentialManager.Models;
|
|
using CredentialManager.Services;
|
|
using DataConnection.CredentialManagement.Interfaces;
|
|
using DataConnection.REST.Implementations;
|
|
using DataConnection.REST.Interfaces;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Data_Coupler.Services;
|
|
|
|
/// <summary>
|
|
/// Servizio per la gestione delle associazioni tra record sorgente e destinazione.
|
|
/// Include logica di Pre-Discovery per trovare record esistenti prima di creare duplicati.
|
|
/// </summary>
|
|
public class AssociationService : IAssociationService
|
|
{
|
|
private readonly IDataConnectionCredentialService _credentialService;
|
|
private readonly ILogger<AssociationService> _logger;
|
|
|
|
public AssociationService(
|
|
IDataConnectionCredentialService credentialService,
|
|
ILogger<AssociationService> logger)
|
|
{
|
|
_credentialService = credentialService ?? throw new ArgumentNullException(nameof(credentialService));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Trova o crea un'associazione tramite Pre-Discovery.
|
|
/// Se non esiste un'associazione locale, cerca nella destinazione REST.
|
|
/// Se trova un record, crea l'associazione automaticamente.
|
|
/// </summary>
|
|
/// <param name="request">Parametri per la ricerca/creazione associazione</param>
|
|
/// <returns>Associazione esistente o appena creata, null se non trovata</returns>
|
|
public async Task<KeyAssociation?> FindOrCreateAssociationAsync(PreDiscoveryRequest request)
|
|
{
|
|
if (request == null)
|
|
throw new ArgumentNullException(nameof(request));
|
|
|
|
if (string.IsNullOrEmpty(request.SourceKey))
|
|
{
|
|
_logger.LogWarning("FindOrCreateAssociationAsync: SourceKey vuoto, skip");
|
|
return null;
|
|
}
|
|
|
|
// Step 1: Cerca associazione esistente
|
|
var existingAssociation = await FindExistingAssociationAsync(request);
|
|
if (existingAssociation != null)
|
|
{
|
|
_logger.LogDebug("Associazione esistente trovata: ID={AssociationId}, DestinationId={DestinationId}",
|
|
existingAssociation.Id, existingAssociation.DestinationId);
|
|
return existingAssociation;
|
|
}
|
|
|
|
// Step 2: Pre-Discovery nella destinazione REST
|
|
if (request.EnablePreDiscovery)
|
|
{
|
|
var discoveredAssociation = await PerformPreDiscoveryAsync(request);
|
|
if (discoveredAssociation != null)
|
|
{
|
|
return discoveredAssociation;
|
|
}
|
|
}
|
|
|
|
// Non trovata né in locale né in destinazione
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Versione bulk del find-or-create — vedi <see cref="IAssociationService.BatchFindOrCreateAssociationsAsync"/>.
|
|
/// </summary>
|
|
public async Task<Dictionary<string, KeyAssociation>> BatchFindOrCreateAssociationsAsync(
|
|
IEnumerable<string> sourceKeys,
|
|
PreDiscoveryRequest commonRequest)
|
|
{
|
|
if (commonRequest == null)
|
|
throw new ArgumentNullException(nameof(commonRequest));
|
|
|
|
var distinctKeys = sourceKeys
|
|
.Where(k => !string.IsNullOrEmpty(k))
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
var result = new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
|
|
if (distinctKeys.Count == 0)
|
|
return result;
|
|
|
|
// STEP 1 — Bulk lookup nel DB locale (1 query SQLite invece di N)
|
|
Dictionary<string, KeyAssociation> localMatches;
|
|
try
|
|
{
|
|
localMatches = await _credentialService.FindKeyAssociationsByValuesBulkAsync(
|
|
distinctKeys, commonRequest.DestinationEntity, commonRequest.CredentialName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "BULK PRE-DISCOVERY: bulk lookup locale fallito, fallback per-record");
|
|
// Fallback per-record (mantiene comportamento esistente in caso di problemi)
|
|
foreach (var key in distinctKeys)
|
|
{
|
|
var req = ClonePreDiscoveryRequest(commonRequest, key);
|
|
var found = await FindOrCreateAssociationAsync(req);
|
|
if (found != null) result[key] = found;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
foreach (var kvp in localMatches)
|
|
result[kvp.Key] = kvp.Value;
|
|
|
|
var missingKeys = distinctKeys.Where(k => !result.ContainsKey(k)).ToList();
|
|
|
|
_logger.LogInformation("BULK PRE-DISCOVERY: {Local}/{Total} associazioni trovate localmente, {Missing} da cercare nella destinazione",
|
|
localMatches.Count, distinctKeys.Count, missingKeys.Count);
|
|
|
|
if (missingKeys.Count == 0 || !commonRequest.EnablePreDiscovery)
|
|
return result;
|
|
|
|
// STEP 2 — Pre-Discovery batched sulla destinazione REST
|
|
// Verifica che il campo chiave sia mappato
|
|
if (string.IsNullOrEmpty(commonRequest.SourceKeyField) ||
|
|
!commonRequest.FieldMappings.TryGetValue(commonRequest.SourceKeyField, out var mappedField))
|
|
{
|
|
_logger.LogWarning("BULK PRE-DISCOVERY: campo chiave '{SourceKeyField}' non mappato; skip discovery",
|
|
commonRequest.SourceKeyField);
|
|
return result;
|
|
}
|
|
|
|
// Solo SalesforceServiceClient supporta SOQL IN ottimizzata; per altri client si ricade al per-record.
|
|
if (commonRequest.RestClient is SalesforceServiceClient sfClient)
|
|
{
|
|
var discovered = await PerformBulkPreDiscoverySalesforceAsync(
|
|
sfClient, missingKeys, mappedField, commonRequest);
|
|
|
|
foreach (var kvp in discovered)
|
|
result[kvp.Key] = kvp.Value;
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug("BULK PRE-DISCOVERY: client REST non Salesforce, fallback per-record per {Count} chiavi", missingKeys.Count);
|
|
foreach (var key in missingKeys)
|
|
{
|
|
var req = ClonePreDiscoveryRequest(commonRequest, key);
|
|
var found = await PerformPreDiscoveryAsync(req);
|
|
if (found != null) result[key] = found;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pre-Discovery batched specifica per Salesforce: usa SOQL <c>WHERE field IN (...)</c>
|
|
/// per recuperare in pochissime chiamate API tutti i record che matchano una qualsiasi delle chiavi mancanti.
|
|
/// </summary>
|
|
private async Task<Dictionary<string, KeyAssociation>> PerformBulkPreDiscoverySalesforceAsync(
|
|
SalesforceServiceClient sfClient,
|
|
List<string> missingKeys,
|
|
string mappedDestinationField,
|
|
PreDiscoveryRequest commonRequest)
|
|
{
|
|
var output = new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
|
|
|
|
// Chunk per stare sotto il limite SOQL/URL (~16 KB GET): ~200 valori per query
|
|
const int chunkSize = 200;
|
|
var queries = new List<string>();
|
|
for (int i = 0; i < missingKeys.Count; i += chunkSize)
|
|
{
|
|
var chunk = missingKeys.Skip(i).Take(chunkSize).ToList();
|
|
|
|
var sb = new StringBuilder();
|
|
sb.Append("SELECT Id, ");
|
|
sb.Append(mappedDestinationField);
|
|
sb.Append(" FROM ");
|
|
sb.Append(commonRequest.DestinationEntity);
|
|
sb.Append(" WHERE ");
|
|
sb.Append(mappedDestinationField);
|
|
sb.Append(" IN (");
|
|
sb.Append(string.Join(",", chunk.Select(v => $"'{v.Replace("'", "\\'")}'")));
|
|
sb.Append(')');
|
|
|
|
queries.Add(sb.ToString());
|
|
}
|
|
|
|
_logger.LogInformation("BULK PRE-DISCOVERY: {QueryCount} SOQL IN-query (~{ChunkSize} chiavi/query, Composite API ceil(N/25) HTTP call)",
|
|
queries.Count, chunkSize);
|
|
|
|
// BatchExecuteQueriesAsync raggruppa fino a 25 query in 1 Composite request
|
|
var batchResults = await sfClient.BatchExecuteQueriesAsync(queries);
|
|
|
|
// Indicizza i risultati per chiave: dal record letto leggiamo il valore di mappedDestinationField
|
|
var entityIdByKey = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var batchResult in batchResults.Where(r => r.Success && r.Records != null))
|
|
{
|
|
foreach (var record in batchResult.Records)
|
|
{
|
|
if (!record.TryGetValue(mappedDestinationField, out var keyVal) || keyVal == null)
|
|
continue;
|
|
|
|
var keyStr = keyVal.ToString();
|
|
if (string.IsNullOrEmpty(keyStr))
|
|
continue;
|
|
|
|
var idStr = ExtractDestinationId(record);
|
|
if (string.IsNullOrEmpty(idStr))
|
|
continue;
|
|
|
|
// In caso di duplicati in Salesforce, prendiamo il primo
|
|
if (!entityIdByKey.ContainsKey(keyStr))
|
|
entityIdByKey[keyStr] = idStr;
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("BULK PRE-DISCOVERY: trovati {Found}/{Missing} record esistenti nella destinazione",
|
|
entityIdByKey.Count, missingKeys.Count);
|
|
|
|
if (entityIdByKey.Count == 0)
|
|
return output;
|
|
|
|
// Salvataggio associazioni Pre-Discovery in parallelo
|
|
var saveTasks = entityIdByKey.Select(async kvp =>
|
|
{
|
|
try
|
|
{
|
|
var newAssoc = new KeyAssociation
|
|
{
|
|
KeyValue = kvp.Key,
|
|
SourceKeyField = commonRequest.SourceKeyField,
|
|
DestinationKeyField = commonRequest.DestinationKeyField ?? "Id",
|
|
MappedDestinationField = mappedDestinationField,
|
|
DestinationEntity = commonRequest.DestinationEntity,
|
|
DestinationId = kvp.Value,
|
|
RestCredentialName = commonRequest.CredentialName,
|
|
CreatedAt = DateTime.UtcNow,
|
|
LastVerifiedAt = DateTime.UtcNow,
|
|
IsActive = true,
|
|
AdditionalInfo = JsonSerializer.Serialize(new Dictionary<string, object>
|
|
{
|
|
{ "CreatedBy", "PreDiscovery" },
|
|
{ "DiscoveredAt", DateTime.UtcNow },
|
|
{ "MappingCount", commonRequest.FieldMappings.Count },
|
|
{ "BulkPreDiscovery", true },
|
|
{ "ScheduledTransfer", commonRequest.IsScheduledTransfer },
|
|
{ "SourceType", commonRequest.SourceType ?? string.Empty }
|
|
})
|
|
};
|
|
|
|
var id = commonRequest.UseParallelMethod
|
|
? await _credentialService.SaveKeyAssociationParallelAsync(newAssoc)
|
|
: await _credentialService.SaveKeyAssociationAsync(newAssoc);
|
|
|
|
newAssoc.Id = id;
|
|
return (kvp.Key, newAssoc);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "BULK PRE-DISCOVERY: errore nel salvataggio associazione per KeyValue '{KeyValue}'", kvp.Key);
|
|
return (kvp.Key, (KeyAssociation?)null);
|
|
}
|
|
});
|
|
|
|
var savedResults = await Task.WhenAll(saveTasks);
|
|
foreach (var (key, assoc) in savedResults)
|
|
{
|
|
if (assoc != null) output[key] = assoc;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
private static PreDiscoveryRequest ClonePreDiscoveryRequest(PreDiscoveryRequest source, string sourceKey)
|
|
{
|
|
return new PreDiscoveryRequest
|
|
{
|
|
SourceKey = sourceKey,
|
|
SourceKeyField = source.SourceKeyField,
|
|
DestinationEntity = source.DestinationEntity,
|
|
CredentialName = source.CredentialName,
|
|
DestinationKeyField = source.DestinationKeyField,
|
|
FieldMappings = source.FieldMappings,
|
|
RestClient = source.RestClient,
|
|
CurrentDataHash = source.CurrentDataHash,
|
|
EnablePreDiscovery = source.EnablePreDiscovery,
|
|
UseParallelMethod = source.UseParallelMethod,
|
|
IsScheduledTransfer = source.IsScheduledTransfer,
|
|
SourceType = source.SourceType
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifica se un'associazione è stata creata dal Pre-Discovery
|
|
/// controllando il campo AdditionalInfo
|
|
/// </summary>
|
|
public bool IsPreDiscoveryAssociation(KeyAssociation association)
|
|
{
|
|
if (association == null || string.IsNullOrEmpty(association.AdditionalInfo))
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var additionalInfo = JsonSerializer.Deserialize<Dictionary<string, object>>(association.AdditionalInfo);
|
|
if (additionalInfo != null && additionalInfo.ContainsKey("CreatedBy"))
|
|
{
|
|
var createdBy = additionalInfo["CreatedBy"]?.ToString();
|
|
return createdBy == "PreDiscovery";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Errore parsing AdditionalInfo per AssociationId={Id}", association.Id);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
#region Private Methods
|
|
|
|
/// <summary>
|
|
/// Cerca un'associazione esistente nel database locale
|
|
/// </summary>
|
|
private async Task<KeyAssociation?> FindExistingAssociationAsync(PreDiscoveryRequest request)
|
|
{
|
|
_logger.LogDebug("Cerco associazione per KeyValue='{KeyValue}', Entity='{Entity}', Credential='{Credential}'",
|
|
request.SourceKey, request.DestinationEntity, request.CredentialName);
|
|
|
|
// Cerca con tutti i parametri
|
|
var association = request.UseParallelMethod
|
|
? await _credentialService.FindKeyAssociationByValueParallelAsync(
|
|
request.SourceKey, request.DestinationEntity, request.CredentialName)
|
|
: await _credentialService.FindKeyAssociationByValueAsync(
|
|
request.SourceKey, request.DestinationEntity, request.CredentialName);
|
|
|
|
// FALLBACK: Se non trovata, cerca solo per KeyValue
|
|
if (association == null)
|
|
{
|
|
_logger.LogDebug("Associazione non trovata con parametri specifici, provo solo con KeyValue");
|
|
|
|
association = request.UseParallelMethod
|
|
? await _credentialService.FindKeyAssociationByValueParallelAsync(request.SourceKey)
|
|
: await _credentialService.FindKeyAssociationByValueAsync(request.SourceKey);
|
|
|
|
if (association != null)
|
|
{
|
|
// Verifica compatibilità
|
|
if (association.DestinationEntity != request.DestinationEntity ||
|
|
association.RestCredentialName != request.CredentialName)
|
|
{
|
|
_logger.LogDebug("Associazione non compatibile: Entity={FoundEntity} vs {ExpectedEntity}, Credential={FoundCred} vs {ExpectedCred}",
|
|
association.DestinationEntity, request.DestinationEntity,
|
|
association.RestCredentialName, request.CredentialName);
|
|
association = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return association;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Esegue il Pre-Discovery cercando nella destinazione REST
|
|
/// </summary>
|
|
private async Task<KeyAssociation?> PerformPreDiscoveryAsync(PreDiscoveryRequest request)
|
|
{
|
|
_logger.LogInformation("PRE-DISCOVERY: Nessuna associazione trovata per '{KeyValue}'. Cerco nella destinazione...",
|
|
request.SourceKey);
|
|
|
|
// Verifica che il campo chiave sia mappato
|
|
if (!request.FieldMappings.TryGetValue(request.SourceKeyField, out var mappedDestinationFieldName))
|
|
{
|
|
_logger.LogWarning("PRE-DISCOVERY: Campo chiave '{SourceKeyField}' non trovato nei mappings. Skip discovery.",
|
|
request.SourceKeyField);
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Prepara i campi di ricerca
|
|
var searchFields = new Dictionary<string, object>
|
|
{
|
|
{ mappedDestinationFieldName, request.SourceKey }
|
|
};
|
|
|
|
_logger.LogInformation("PRE-DISCOVERY: Cerco in '{Entity}' dove {Field} = '{Value}'",
|
|
request.DestinationEntity, mappedDestinationFieldName, request.SourceKey);
|
|
|
|
// Cerca nella destinazione REST
|
|
var existingEntities = await request.RestClient.FindEntitiesByKeysAsync(
|
|
request.DestinationEntity, searchFields);
|
|
|
|
if (existingEntities == null || existingEntities.Count == 0)
|
|
{
|
|
_logger.LogInformation("PRE-DISCOVERY: Nessun record esistente trovato per KeyValue: '{KeyValue}'",
|
|
request.SourceKey);
|
|
return null;
|
|
}
|
|
|
|
// Trovato! Prendi il primo risultato
|
|
var foundEntity = existingEntities[0];
|
|
|
|
// Estrai l'ID del record trovato
|
|
var destinationId = ExtractDestinationId(foundEntity);
|
|
if (string.IsNullOrEmpty(destinationId))
|
|
{
|
|
_logger.LogWarning("PRE-DISCOVERY: Record trovato ma senza ID valido per KeyValue: '{KeyValue}'",
|
|
request.SourceKey);
|
|
return null;
|
|
}
|
|
|
|
_logger.LogInformation("PRE-DISCOVERY: ✅ Trovato record esistente! KeyValue: '{KeyValue}' -> DestinationId: '{DestinationId}'",
|
|
request.SourceKey, destinationId);
|
|
|
|
// Crea l'associazione
|
|
var newAssociation = CreatePreDiscoveryAssociation(
|
|
request, mappedDestinationFieldName, destinationId);
|
|
|
|
// Salva l'associazione
|
|
var associationId = request.UseParallelMethod
|
|
? await _credentialService.SaveKeyAssociationParallelAsync(newAssociation)
|
|
: await _credentialService.SaveKeyAssociationAsync(newAssociation);
|
|
|
|
_logger.LogInformation("PRE-DISCOVERY: Associazione creata con ID: {AssociationId}", associationId);
|
|
|
|
newAssociation.Id = associationId;
|
|
return newAssociation;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "PRE-DISCOVERY: Errore durante la ricerca nella destinazione per KeyValue: '{KeyValue}'",
|
|
request.SourceKey);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Estrae l'ID di destinazione dal record trovato
|
|
/// </summary>
|
|
private string? ExtractDestinationId(Dictionary<string, object> entity)
|
|
{
|
|
// Prova "Id" (case-sensitive)
|
|
if (entity.ContainsKey("Id"))
|
|
return entity["Id"]?.ToString();
|
|
|
|
// Prova "id" (lowercase)
|
|
if (entity.ContainsKey("id"))
|
|
return entity["id"]?.ToString();
|
|
|
|
// Prova case-insensitive
|
|
var idKey = entity.Keys.FirstOrDefault(k => k.Equals("Id", StringComparison.OrdinalIgnoreCase));
|
|
return idKey != null ? entity[idKey]?.ToString() : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Crea una nuova associazione con marker Pre-Discovery
|
|
/// </summary>
|
|
private KeyAssociation CreatePreDiscoveryAssociation(
|
|
PreDiscoveryRequest request,
|
|
string mappedDestinationFieldName,
|
|
string destinationId)
|
|
{
|
|
var additionalInfo = new Dictionary<string, object>
|
|
{
|
|
{ "CreatedBy", "PreDiscovery" },
|
|
{ "DiscoveredAt", DateTime.UtcNow },
|
|
{ "MappingCount", request.FieldMappings.Count }
|
|
};
|
|
|
|
// Aggiungi info su scheduled transfer se specificato
|
|
if (request.IsScheduledTransfer)
|
|
{
|
|
additionalInfo.Add("ScheduledTransfer", true);
|
|
}
|
|
|
|
// Aggiungi source type se specificato
|
|
if (!string.IsNullOrEmpty(request.SourceType))
|
|
{
|
|
additionalInfo.Add("SourceType", request.SourceType);
|
|
}
|
|
|
|
return new KeyAssociation
|
|
{
|
|
KeyValue = request.SourceKey,
|
|
SourceKeyField = request.SourceKeyField,
|
|
DestinationKeyField = request.DestinationKeyField ?? "Id",
|
|
MappedDestinationField = mappedDestinationFieldName,
|
|
DestinationEntity = request.DestinationEntity,
|
|
DestinationId = destinationId,
|
|
RestCredentialName = request.CredentialName,
|
|
CreatedAt = DateTime.UtcNow,
|
|
LastVerifiedAt = DateTime.UtcNow,
|
|
IsActive = true,
|
|
Data_Hash = request.CurrentDataHash,
|
|
AdditionalInfo = JsonSerializer.Serialize(additionalInfo)
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
/// <summary>
|
|
/// Interfaccia per il servizio di gestione associazioni
|
|
/// </summary>
|
|
public interface IAssociationService
|
|
{
|
|
Task<KeyAssociation?> FindOrCreateAssociationAsync(PreDiscoveryRequest request);
|
|
bool IsPreDiscoveryAssociation(KeyAssociation association);
|
|
|
|
/// <summary>
|
|
/// Versione bulk del find-or-create.
|
|
/// 1) Una sola query SQLite (WHERE KeyValue IN …) per recuperare le associazioni esistenti.
|
|
/// 2) Per le chiavi non trovate localmente, una manciata di SOQL "IN" su Salesforce
|
|
/// (~200 chiavi per query, Composite API: ceil(K/25) HTTP call) invece di K chiamate singole.
|
|
/// 3) Le associazioni Pre-Discovery scoperte vengono salvate e restituite.
|
|
/// </summary>
|
|
/// <param name="sourceKeys">Lista (non vuota) dei valori chiave sorgente per tutti i record da analizzare.</param>
|
|
/// <param name="commonRequest">Parametri condivisi (entity, credential, restClient, mappings, ecc.).
|
|
/// <see cref="PreDiscoveryRequest.SourceKey"/> e <see cref="PreDiscoveryRequest.CurrentDataHash"/>
|
|
/// sono ignorati; vengono presi dal parametro <paramref name="sourceKeys"/>.</param>
|
|
/// <returns>Dizionario KeyValue → KeyAssociation (solo per chiavi trovate/create).</returns>
|
|
Task<Dictionary<string, KeyAssociation>> BatchFindOrCreateAssociationsAsync(
|
|
IEnumerable<string> sourceKeys,
|
|
PreDiscoveryRequest commonRequest);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parametri per la ricerca/creazione di associazioni con Pre-Discovery
|
|
/// </summary>
|
|
public class PreDiscoveryRequest
|
|
{
|
|
/// <summary>
|
|
/// Valore della chiave sorgente (es: "C00001")
|
|
/// </summary>
|
|
public string SourceKey { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Nome del campo chiave nella sorgente (es: "CardCode")
|
|
/// </summary>
|
|
public string SourceKeyField { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Nome dell'entità destinazione (es: "Account")
|
|
/// </summary>
|
|
public string DestinationEntity { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Nome della credenziale REST (es: "Salesforce_Prod")
|
|
/// </summary>
|
|
public string CredentialName { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Campo ID nella destinazione (default: "Id")
|
|
/// </summary>
|
|
public string? DestinationKeyField { get; set; }
|
|
|
|
/// <summary>
|
|
/// Mappings campo sorgente -> campo destinazione
|
|
/// </summary>
|
|
public Dictionary<string, string> FieldMappings { get; set; } = new();
|
|
|
|
/// <summary>
|
|
/// Client REST per effettuare la ricerca nella destinazione
|
|
/// </summary>
|
|
public IRestServiceClient RestClient { get; set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Hash dei dati correnti da salvare nell'associazione
|
|
/// </summary>
|
|
public string? CurrentDataHash { get; set; }
|
|
|
|
/// <summary>
|
|
/// Se abilitare il Pre-Discovery (default: true)
|
|
/// </summary>
|
|
public bool EnablePreDiscovery { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Se usare i metodi paralleli thread-safe (default: false)
|
|
/// </summary>
|
|
public bool UseParallelMethod { get; set; } = false;
|
|
|
|
/// <summary>
|
|
/// Se la richiesta proviene da un trasferimento schedulato
|
|
/// </summary>
|
|
public bool IsScheduledTransfer { get; set; } = false;
|
|
|
|
/// <summary>
|
|
/// Tipo di sorgente (database, file, etc.)
|
|
/// </summary>
|
|
public string? SourceType { get; set; }
|
|
}
|