feat: Implementato sistema Pre-Discovery con supporto esecuzioni schedulate

- Fix FindEntitiesByKeysAsync: SOQL query universale al posto di External ID GET
  * Disabilitato External ID GET (funziona solo per campi marcati External ID)
  * Query SOQL come metodo primario funzionante per tutti i campi
  * Logging dettagliato per diagnostica ricerche Salesforce

- Pre-Discovery nei trasferimenti manuali (DataCoupler.razor.cs)
  * Ricerca automatica nella destinazione quando KeyAssociation non esiste
  * Creazione associazione con marker CreatedBy="PreDiscovery"
  * Aggiornamento forzato senza controllo hash per associazioni Pre-Discovery
  * Supporto parallel processing thread-safe

- Pre-Discovery nei trasferimenti schedulati (ScheduledProfileExecutionService.cs)
  * Logica identica a trasferimenti manuali
  * Marker aggiuntivo ScheduledTransfer=true per tracciabilità
  * Aggiornamento forzato per prima sincronizzazione

- Sistema aggiornamento forzato
  * Verifica AdditionalInfo per identificare associazioni Pre-Discovery
  * Skip controllo hash per associazioni appena scoperte
  * Garantisce sincronizzazione dati al primo trasferimento

Vantaggi:
- Prevenzione automatica duplicati in Salesforce
- Recupero record esistenti senza associazioni
- Parità funzionale tra esecuzioni manuali e schedulate
- Performance ottimizzate con controllo hash per esecuzioni successive

Docs: PRE_DISCOVERY_SYSTEM.md, PRE_DISCOVERY_FORCED_UPDATE.md, SALESFORCE_FIND_ENTITIES_FIX.md
This commit is contained in:
2025-10-21 00:48:03 +02:00
parent 5d9b9756cf
commit 39d7124ce1
6 changed files with 1330 additions and 39 deletions
@@ -534,24 +534,147 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
}
}
if (existingAssociation != null && existingAssociation.IsActive)
// 🔍 PRE-DISCOVERY: Se non esiste associazione, cerca nella destinazione
if (existingAssociation == null && !string.IsNullOrEmpty(profile.SourceKeyField))
{
// CONTROLLO HASH: Verifica se i dati sono cambiati
var existingHash = existingAssociation.Data_Hash;
_logger.LogInformation("PRE-DISCOVERY SCHEDULED: Nessuna associazione trovata per '{KeyValue}'. Cerco nella destinazione...", sourceKey);
if (!string.IsNullOrEmpty(existingHash) && existingHash.Equals(currentDataHash, StringComparison.OrdinalIgnoreCase))
// Cerca il campo destinazione mappato al campo chiave sorgente
if (fieldMappings.TryGetValue(profile.SourceKeyField, out var mappedDestinationFieldName))
{
// I dati non sono cambiati, salta questo record
recordsSkipped.Add((record, recordNumber, "Dati non modificati (hash identico)"));
_logger.LogDebug("COMPOSITE SCHEDULED: Record {RecordNumber} saltato - hash identico: {Hash}",
recordNumber, currentDataHash);
try
{
// Prepara i campi di ricerca: usa il campo mappato + il valore della chiave
var searchFields = new Dictionary<string, object>
{
{ mappedDestinationFieldName, sourceKey }
};
_logger.LogInformation("PRE-DISCOVERY SCHEDULED: Cerco in '{Entity}' dove {Field} = '{Value}'",
currentEntityName, mappedDestinationFieldName, sourceKey);
// Cerca nella destinazione REST
var existingEntities = await restClient.FindEntitiesByKeysAsync(
currentEntityName, searchFields);
if (existingEntities != null && existingEntities.Count > 0)
{
// Trovato! Prendi il primo risultato
var foundEntity = existingEntities[0];
// Estrai l'ID del record trovato
var destinationId = foundEntity.ContainsKey("Id")
? foundEntity["Id"]?.ToString()
: foundEntity.ContainsKey("id")
? foundEntity["id"]?.ToString()
: null;
if (!string.IsNullOrEmpty(destinationId))
{
_logger.LogInformation("PRE-DISCOVERY SCHEDULED: ✅ Trovato record esistente! KeyValue: '{KeyValue}' -> DestinationId: '{DestinationId}'",
sourceKey, destinationId);
// Crea l'associazione prima di procedere
var newAssociation = new KeyAssociation
{
KeyValue = sourceKey,
SourceKeyField = profile.SourceKeyField,
DestinationKeyField = "Id",
MappedDestinationField = mappedDestinationFieldName,
DestinationEntity = currentEntityName,
DestinationId = destinationId,
RestCredentialName = currentCredentialName,
CreatedAt = DateTime.UtcNow,
LastVerifiedAt = DateTime.UtcNow,
IsActive = true,
Data_Hash = currentDataHash,
AdditionalInfo = JsonSerializer.Serialize(new
{
CreatedBy = "PreDiscovery",
DiscoveredAt = DateTime.UtcNow,
MappingCount = fieldMappings.Count,
ScheduledTransfer = true
})
};
// Salva l'associazione (metodo parallelo thread-safe)
var associationId = await _dataConnectionCredentialService.SaveKeyAssociationParallelAsync(newAssociation);
_logger.LogInformation("PRE-DISCOVERY SCHEDULED: Associazione creata con ID: {AssociationId}", associationId);
// Usa l'associazione appena creata per il resto del flusso
existingAssociation = newAssociation;
existingAssociation.Id = associationId;
}
else
{
_logger.LogWarning("PRE-DISCOVERY SCHEDULED: Record trovato ma senza ID valido per KeyValue: '{KeyValue}'", sourceKey);
}
}
else
{
_logger.LogInformation("PRE-DISCOVERY SCHEDULED: Nessun record esistente trovato per KeyValue: '{KeyValue}'", sourceKey);
}
}
catch (Exception discEx)
{
_logger.LogWarning(discEx, "PRE-DISCOVERY SCHEDULED: Errore durante la ricerca nella destinazione per KeyValue: '{KeyValue}'", sourceKey);
// Continua comunque, il record verrà creato normalmente
}
}
else
{
// I dati sono cambiati o l'hash è vuoto, procedi con l'aggiornamento
_logger.LogWarning("PRE-DISCOVERY SCHEDULED: Campo chiave '{SourceKeyField}' non trovato nei mappings. Skip discovery.", profile.SourceKeyField);
}
}
if (existingAssociation != null && existingAssociation.IsActive)
{
// Verifica se l'associazione è stata creata dal Pre-Discovery
var isPreDiscoveryAssociation = false;
if (!string.IsNullOrEmpty(existingAssociation.AdditionalInfo))
{
try
{
var additionalInfo = JsonSerializer.Deserialize<Dictionary<string, object>>(existingAssociation.AdditionalInfo);
if (additionalInfo != null && additionalInfo.ContainsKey("CreatedBy"))
{
var createdBy = additionalInfo["CreatedBy"]?.ToString();
isPreDiscoveryAssociation = createdBy == "PreDiscovery";
}
}
catch
{
// Ignora errori di parsing
}
}
// 🔍 PRE-DISCOVERY: Se l'associazione è stata appena creata dal Pre-Discovery, FORZA l'aggiornamento
if (isPreDiscoveryAssociation)
{
// Forza aggiornamento senza controllo hash
recordsForUpdate.Add((restData, existingAssociation.DestinationId, record, recordNumber, currentDataHash));
_logger.LogDebug("COMPOSITE SCHEDULED: Record {RecordNumber} marcato per aggiornamento (EntityId: {EntityId}) - hash diverso: old={OldHash}, new={NewHash}",
recordNumber, existingAssociation.DestinationId, existingHash ?? "NULL", currentDataHash);
_logger.LogInformation("COMPOSITE SCHEDULED: Record {RecordNumber} marcato per AGGIORNAMENTO FORZATO (Pre-Discovery) - EntityId: {EntityId}",
recordNumber, existingAssociation.DestinationId);
}
else
{
// CONTROLLO HASH: Verifica se i dati sono cambiati (solo per associazioni esistenti)
var existingHash = existingAssociation.Data_Hash;
if (!string.IsNullOrEmpty(existingHash) && existingHash.Equals(currentDataHash, StringComparison.OrdinalIgnoreCase))
{
// I dati non sono cambiati, salta questo record
recordsSkipped.Add((record, recordNumber, "Dati non modificati (hash identico)"));
_logger.LogDebug("COMPOSITE SCHEDULED: Record {RecordNumber} saltato - hash identico: {Hash}",
recordNumber, currentDataHash);
}
else
{
// I dati sono cambiati o l'hash è vuoto, procedi con l'aggiornamento
recordsForUpdate.Add((restData, existingAssociation.DestinationId, record, recordNumber, currentDataHash));
_logger.LogDebug("COMPOSITE SCHEDULED: Record {RecordNumber} marcato per aggiornamento (EntityId: {EntityId}) - hash diverso: old={OldHash}, new={NewHash}",
recordNumber, existingAssociation.DestinationId, existingHash ?? "NULL", currentDataHash);
}
}
}
else