feat: Implementato sistema di associazioni chiave per prevenire duplicati nel data coupling

BREAKING CHANGE: Rimosso completamente il vecchio sistema RecordAssociation

Modifiche principali:
- Sostituito RecordAssociation con KeyAssociation basato sui valori delle chiavi
- Implementata logica robusta di UPDATE vs INSERT basata su associazioni esistenti
- Aggiunta normalizzazione delle chiavi (.Trim()) per consistenza
- Implementato fallback nella ricerca associazioni per maggiore affidabilità
- Sostituita verifica pre-UPDATE con tentativo diretto più efficiente

Componenti modificati:
- Nuovo modello: KeyAssociation.cs con campi ottimizzati
- Nuovo servizio: KeyAssociationService.cs con metodi completi
- Aggiornato: DataCoupler.razor con logica migliorata di gestione associazioni
- Aggiornato: CredentialDbContext per gestire solo KeyAssociations
- Aggiornati: tutti i servizi di interfaccia per supportare il nuovo sistema
- Creata: pagina KeyAssociations.razor per gestione associazioni
- Aggiornato: NavMenu.razor con link alla gestione associazioni

Miglioramenti tecnici:
- Logica di UPDATE più robusta: tenta direttamente l'aggiornamento invece di verificare prima l'esistenza
- Gestione errori migliorata con cleanup automatico delle associazioni non valide
- Debug logging estensivo per troubleshooting
- Fallback nella ricerca associazioni se parametri specifici falliscono
- Normalizzazione valori chiave per prevenire problemi di whitespace

Risultato:
Il sistema ora previene correttamente i duplicati utilizzando le associazioni per decidere
se fare INSERT (nuovo record) o UPDATE (record esistente) basandosi sui valori delle chiavi.

Database:
- Creata migrazione EF per rimuovere RecordAssociations e aggiungere KeyAssociations
- Eliminati file e codice legacy non più necessari
This commit is contained in:
2025-06-29 20:44:20 +02:00
parent 2238ddc4bf
commit 04f0403f12
23 changed files with 2051 additions and 1161 deletions
+112 -68
View File
@@ -834,13 +834,24 @@
<input class="form-check-input" type="checkbox" id="useAssociations"
@bind="useRecordAssociations" />
<label class="form-check-label" for="useAssociations">
<strong>Utilizza sistema di associazioni per tracking automatico degli aggiornamenti</strong>
<br><small class="text-muted">Raccomandato: il sistema manterrà traccia delle associazioni tra record sorgente e destinazione</small>
<strong>Utilizza sistema di associazioni basato sui valori delle chiavi</strong>
<br><small class="text-muted">Raccomandato: il sistema manterrà traccia delle associazioni tra valori chiave e record di destinazione, permettendo aggiornamenti indipendentemente dalla sorgente</small>
</label>
</div>
@if (useRecordAssociations)
{
<div class="alert alert-info">
<i class="fas fa-lightbulb"></i>
<strong>Come funziona il nuovo sistema:</strong>
<ul class="mb-0 mt-2">
<li>Ogni valore di chiave univoco viene associato a un record di destinazione</li>
<li>Più sorgenti diverse possono gestire lo stesso oggetto business usando lo stesso valore chiave</li>
<li>Gli aggiornamenti avvengono automaticamente quando si trova un'associazione esistente</li>
<li>Il sistema individua automaticamente le chiavi dove possibile, ma puoi sempre scegliere manualmente</li>
</ul>
</div>
<div class="row">
<div class="col-md-6">
<label class="form-label">Campo Chiave Sorgente: <span class="text-danger">*</span></label>
@@ -2126,79 +2137,101 @@
// Genera la chiave sorgente per questo record
var sourceKey = GenerateSourceKey(record);
var currentSourceName = selectedSourceType == "database"
? (useCustomQuery ? "custom_query" : selectedTable)
: selectedSheet;
// NUOVA LOGICA: Cerca associazione esistente
// NUOVO SISTEMA: Cerca associazione esistente basata sul valore della chiave
if (useRecordAssociations && !string.IsNullOrEmpty(sourceKey))
{
var existingAssociation = await CredentialService.FindRecordAssociationAsync(
currentSourceName, sourceKey, selectedRestEntity.Name);
Logger.LogInformation("ASSOCIATION DEBUG: Cerco associazione - KeyValue: '{KeyValue}', Entity: '{Entity}', Credential: '{Credential}'",
sourceKey, selectedRestEntity.Name, selectedRestCredential);
// Cerca se esiste già un'associazione per questo valore chiave
var existingAssociation = await CredentialService.FindKeyAssociationByValueAsync(
sourceKey, selectedRestEntity.Name, selectedRestCredential);
// FALLBACK: Se non troviamo l'associazione con tutti i parametri, proviamo solo con il KeyValue
if (existingAssociation == null)
{
Logger.LogWarning("ASSOCIATION DEBUG: Associazione non trovata con parametri specifici, provo solo con KeyValue: '{KeyValue}'", sourceKey);
existingAssociation = await CredentialService.FindKeyAssociationByValueAsync(sourceKey);
if (existingAssociation != null)
{
Logger.LogWarning("ASSOCIATION DEBUG: Trovata associazione con fallback - ID: {AssociationId}, Entity: '{Entity}', Credential: '{Credential}'",
existingAssociation.Id, existingAssociation.DestinationEntity, existingAssociation.RestCredentialName);
// Verifica se l'associazione trovata è compatibile
if (existingAssociation.DestinationEntity != selectedRestEntity.Name ||
existingAssociation.RestCredentialName != selectedRestCredential)
{
Logger.LogWarning("ASSOCIATION DEBUG: Associazione non compatibile - Entity: '{FoundEntity}' vs '{ExpectedEntity}', Credential: '{FoundCredential}' vs '{ExpectedCredential}'",
existingAssociation.DestinationEntity, selectedRestEntity.Name, existingAssociation.RestCredentialName, selectedRestCredential);
existingAssociation = null;
}
}
}
Logger.LogInformation("ASSOCIATION DEBUG: Associazione finale: {Found}. ID: {AssociationId}, DestinationId: '{DestinationId}', IsActive: {IsActive}",
existingAssociation != null, existingAssociation?.Id, existingAssociation?.DestinationId, existingAssociation?.IsActive);
if (existingAssociation != null && existingAssociation.IsActive)
{
// VALIDAZIONE: Verifica se l'ID di destinazione esiste ancora nel sistema target
bool destinationExists = false;
// Prova direttamente l'aggiornamento - più efficiente che verificare prima l'esistenza
Logger.LogInformation("ASSOCIATION DEBUG: Tentativo aggiornamento record esistente - DestinationId: '{DestinationId}'", existingAssociation.DestinationId);
try
{
// Usa il campo ID appropriato per cercare l'entità
var idField = GetEntityIdField(); // Potrebbe essere "DocEntry", "id", "Id", etc.
var searchKeys = new Dictionary<string, object> { { idField, existingAssociation.DestinationId } };
var foundEntities = await currentRestClient.FindEntitiesByKeysAsync(
selectedRestEntity.Name, searchKeys);
destinationExists = foundEntities != null && foundEntities.Any();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Errore nella verifica dell'esistenza dell'entità {EntityId} - assumo che non esista", existingAssociation.DestinationId);
destinationExists = false;
}
if (!destinationExists)
{
// L'ID di destinazione non esiste più - elimina l'associazione non valida
Logger.LogWarning("ID destinazione {DestinationId} non più valido per associazione {AssociationId} - eliminazione associazione",
existingAssociation.DestinationId, existingAssociation.Id);
var updateResult = await currentRestClient.UpdateEntityAsync(
selectedRestEntity.Name, existingAssociation.DestinationId, restData);
await CredentialService.DeleteRecordAssociationAsync(existingAssociation.Id);
transferResult.Status = "error";
transferResult.Message = $"Associazione non valida eliminata (ID destinazione {existingAssociation.DestinationId} non esiste più) - creazione nuovo record";
// Procedi con la creazione di un nuovo record
goto CreateNewRecord;
if (updateResult != null)
{
updatedCount++;
transferResult.Status = "updated";
transferResult.Message = $"Record aggiornato con successo tramite associazione (ID: {existingAssociation.DestinationId})";
transferResult.EntityId = existingAssociation.DestinationId;
// Aggiorna l'associazione con la data di ultimo aggiornamento e verifica
existingAssociation.UpdatedAt = DateTime.UtcNow;
existingAssociation.LastVerifiedAt = DateTime.UtcNow;
await CredentialService.UpdateKeyAssociationAsync(existingAssociation);
Logger.LogInformation("ASSOCIATION DEBUG: Record aggiornato con successo tramite associazione: {EntityId} per valore chiave {KeyValue}",
existingAssociation.DestinationId, sourceKey);
transferResults.Add(transferResult);
recordNumber++;
continue;
}
else
{
// Update fallito ma senza eccezione - probabilmente l'entità non esiste più
Logger.LogWarning("ASSOCIATION DEBUG: Aggiornamento fallito (result null) per associazione {AssociationId} - elimino associazione e creo nuovo record", existingAssociation.Id);
goto HandleInvalidAssociation;
}
}
catch (Exception updateEx)
{
// Update fallito con eccezione - probabilmente l'entità non esiste più
Logger.LogWarning(updateEx, "ASSOCIATION DEBUG: Aggiornamento fallito per associazione {AssociationId} - elimino associazione e creo nuovo record", existingAssociation.Id);
goto HandleInvalidAssociation;
}
// L'ID di destinazione esiste - procedi con l'aggiornamento
var updateResult = await currentRestClient.UpdateEntityAsync(
selectedRestEntity.Name, existingAssociation.DestinationId, restData);
if (updateResult != null)
HandleInvalidAssociation:
// L'ID di destinazione non esiste più o l'update è fallito - elimina l'associazione non valida
try
{
updatedCount++;
transferResult.Status = "updated";
transferResult.Message = $"Record aggiornato con successo tramite associazione (ID: {existingAssociation.DestinationId})";
transferResult.EntityId = existingAssociation.DestinationId;
// Aggiorna l'associazione con la data di ultimo aggiornamento
existingAssociation.UpdatedAt = DateTime.UtcNow;
await CredentialService.UpdateRecordAssociationAsync(existingAssociation);
Logger.LogDebug("Record aggiornato tramite associazione: {EntityId} per chiave sorgente {SourceKey}",
existingAssociation.DestinationId, sourceKey);
await CredentialService.DeleteKeyAssociationAsync(existingAssociation.Id);
Logger.LogInformation("ASSOCIATION DEBUG: Associazione non valida eliminata: {AssociationId}", existingAssociation.Id);
}
else
catch (Exception delEx)
{
// Se l'aggiornamento fallisce, prova a creare un nuovo record
Logger.LogWarning("Aggiornamento fallito per associazione {AssociationId}, provo a creare nuovo record", existingAssociation.Id);
goto CreateNewRecord;
Logger.LogWarning(delEx, "Errore nell'eliminazione dell'associazione non valida {AssociationId}", existingAssociation.Id);
}
transferResults.Add(transferResult);
recordNumber++;
continue;
transferResult.Status = "info";
transferResult.Message = $"Associazione non valida eliminata (aggiornamento fallito) - creazione nuovo record";
// Procedi con la creazione di un nuovo record (non aggiungere il result qui, sarà aggiunto dopo CreateNewRecord)
}
}
@@ -2212,31 +2245,41 @@
transferResult.Status = "success";
transferResult.Message = "Record inserito con successo";
transferResult.EntityId = result.ContainsKey("id") ? result["id"]?.ToString() :
result.ContainsKey("Id") ? result["Id"]?.ToString() : null;
result.ContainsKey("Id") ? result["Id"]?.ToString() :
result.ContainsKey("DocEntry") ? result["DocEntry"]?.ToString() : null;
// Crea associazione solo se abbiamo una chiave sorgente e un ID destinazione
if (useRecordAssociations && !string.IsNullOrEmpty(sourceKey) && !string.IsNullOrEmpty(transferResult.EntityId))
{
try
{
var association = new RecordAssociation
// Determina i campi chiave automaticamente
var destinationKeyField = GetEntityIdField(); // Campo chiave nella destinazione
var association = new KeyAssociation
{
SourceName = currentSourceName,
SourceType = selectedSourceType,
SourceKey = sourceKey,
KeyValue = sourceKey,
SourceKeyField = sourceKeyField,
DestinationKeyField = destinationKeyField,
DestinationEntity = selectedRestEntity.Name,
DestinationId = transferResult.EntityId,
RestCredentialName = selectedRestCredential,
CreatedAt = DateTime.UtcNow,
LastVerifiedAt = DateTime.UtcNow,
AdditionalInfo = System.Text.Json.JsonSerializer.Serialize(new
{
TransferDate = DateTime.UtcNow,
RecordNumber = recordNumber,
MappingCount = fieldMappings.Count
MappingCount = fieldMappings.Count,
SourceType = selectedSourceType
})
};
await CredentialService.SaveRecordAssociationAsync(association);
Logger.LogDebug("Associazione creata: {SourceKey} -> {DestinationId}", sourceKey, transferResult.EntityId);
Logger.LogInformation("ASSOCIATION DEBUG: Creazione nuova associazione - KeyValue: '{KeyValue}', Entity: '{Entity}', DestinationId: '{DestinationId}', Credential: '{Credential}'",
sourceKey, selectedRestEntity.Name, transferResult.EntityId, selectedRestCredential);
var associationId = await CredentialService.SaveKeyAssociationAsync(association);
Logger.LogInformation("DEBUG: Associazione salvata con ID: {AssociationId}", associationId);
}
catch (Exception assocEx)
{
@@ -2599,7 +2642,8 @@
throw new InvalidOperationException($"Il valore del campo chiave '{sourceKeyField}' è vuoto o null per questo record.");
}
return keyValue;
// Normalizza il valore della chiave (trim e gestione case-sensitive)
return keyValue.Trim();
}
catch (Exception ex)
{