Fix #31: Manuelle Dateinamen-Eingabe für nicht verarbeitete Dateien

Nicht-erfolgreiche Zeilen (FAILED, FAILED_RETRYABLE, SKIPPED_FINAL_FAILURE)
können im Detailbereich des Verarbeitungslauf-Tabs nun einen manuellen
Zieldateinamen erhalten. Beim Bestätigen wird die Quelldatei mit dem
benutzerdefinierten Namen ins Zielverzeichnis kopiert und der Stammsatz
atomar auf SUCCESS gehoben.

Neuer Inbound-Port ManualFileCopyUseCase mit sealed Result-Hierarchie,
Default-Implementierung mit Best-Effort-Rollback bei Persistenzfehler
sowie GUI-Brücke GuiManualFileCopyPort. Die GUI entscheidet anhand des
Status zwischen Umbenennen (SUCCESS, SKIPPED_ALREADY_PROCESSED) und
Kopieren (FAILED_*, SKIPPED_FINAL_FAILURE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 13:22:44 +02:00
parent fb0e9809f6
commit 1d77173c49
18 changed files with 1673 additions and 23 deletions
@@ -25,6 +25,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalDocumentContextPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
@@ -56,6 +57,9 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfigurat
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameUseCase;
@@ -84,6 +88,7 @@ import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
@@ -687,6 +692,7 @@ public class BootstrapRunner {
de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort resetPort =
this::resetDocumentStatusForGui;
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy;
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
if (configPathOverride.isEmpty()) {
@@ -705,6 +711,7 @@ public class BootstrapRunner {
miniRunLauncher,
resetPort,
manualRenamePort,
manualCopyPort,
historicalDocumentContextPort);
}
@@ -728,6 +735,7 @@ public class BootstrapRunner {
miniRunLauncher,
resetPort,
manualRenamePort,
manualCopyPort,
historicalDocumentContextPort);
}
@@ -737,7 +745,8 @@ public class BootstrapRunner {
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetPort, manualRenamePort, historicalDocumentContextPort);
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
historicalDocumentContextPort);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -756,6 +765,7 @@ public class BootstrapRunner {
miniRunLauncher,
resetPort,
manualRenamePort,
manualCopyPort,
historicalDocumentContextPort);
}
}
@@ -1007,6 +1017,40 @@ public class BootstrapRunner {
processingLogger);
}
/**
* Erstellt einen vollständig verdrahteten {@link ManualFileCopyUseCase} für den
* gegebenen Startkonfigurations-Stand.
* <p>
* Teilt die Wiring-Konventionen mit dem Batch-Pfad: SQLite-URL-Aufbau, Adapter-Instanzen
* und Logger-Konfiguration werden nach dem gleichen Muster erzeugt.
*
* @param startConfig die validierte Startkonfiguration; darf nicht null sein
* @return ein einsatzbereiter Use-Case; nie null
*/
private ManualFileCopyUseCase buildProductionManualFileCopyUseCase(
StartConfiguration startConfig) {
String jdbcUrl = buildJdbcUrl(startConfig);
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
TargetFolderPort targetFolderPort =
new FilesystemTargetFolderAdapter(startConfig.targetFolder());
TargetFileCopyPort targetFileCopyPort =
new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
ClockPort clockPort = new SystemClockAdapter();
AiContentSensitivity aiContentSensitivity =
resolveAiContentSensitivity(startConfig.logAiSensitive());
ProcessingLogger processingLogger = new Log4jProcessingLogger(
DefaultManualFileCopyUseCase.class, aiContentSensitivity);
return new DefaultManualFileCopyUseCase(
documentRecordRepository,
targetFolderPort,
targetFileCopyPort,
unitOfWorkPort,
clockPort,
processingLogger);
}
/**
* Führt eine manuelle Umbenennung einer Zieldatei durch, ausgelöst von der GUI.
* <p>
@@ -1070,6 +1114,70 @@ public class BootstrapRunner {
}
}
/**
* Führt eine manuelle Kopie der Quelldatei eines bislang nicht erfolgreich
* verarbeiteten Dokuments mit benutzerdefiniertem Zieldateinamen ins Zielverzeichnis
* durch, ausgelöst von der GUI.
* <p>
* Lädt und validiert die Konfiguration aus {@code configFilePath}, baut den Use-Case
* auf und delegiert die Operation. Alle Fehler beim Laden oder Validieren der
* Konfiguration werden als strukturiertes {@link ManualFileCopyResult} zurückgegeben.
*
* @param configFilePath Pfad zur {@code .properties}-Datei; muss existieren
* @param request die Kopieranfrage; darf nicht null sein
* @return das Ergebnis der Kopieroperation; nie null
*/
ManualFileCopyResult performGuiManualFileCopy(
Path configFilePath,
ManualFileCopyRequest request) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
Objects.requireNonNull(request, "request must not be null");
LOG.info("GUI-Dateikopie: Anfrage für Fingerprint={}, Zielname={}.",
request.fingerprint().sha256Hex(), request.desiredBaseFileName());
if (!Files.exists(configFilePath)) {
String msg = "Konfigurationsdatei nicht gefunden: " + configFilePath;
LOG.error("GUI-Dateikopie: {}", msg);
return new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileCopyFileSystemFailure(msg);
}
try {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
ManualFileCopyUseCase useCase = buildProductionManualFileCopyUseCase(config);
ManualFileCopyResult result = useCase.copy(request);
LOG.info("GUI-Dateikopie abgeschlossen: Ergebnis={}.", result.getClass().getSimpleName());
return result;
} catch (ConfigurationLoadingException e) {
LOG.error("GUI-Dateikopie: Konfiguration konnte nicht geladen werden: {}",
e.getMessage(), e);
return new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileCopyPersistenceFailure(
"Konfiguration konnte nicht geladen werden: " + e.getMessage());
} catch (InvalidStartConfigurationException e) {
LOG.error("GUI-Dateikopie: Konfiguration ist nicht lauffähig: {}", e.getMessage());
return new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileCopyPersistenceFailure(
"Die Konfiguration ist nicht lauffähig: " + e.getMessage());
} catch (DocumentPersistenceException e) {
LOG.error("GUI-Dateikopie: SQLite-Initialisierung fehlgeschlagen: {}",
e.getMessage(), e);
return new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileCopyPersistenceFailure(
"SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
} catch (RuntimeException e) {
LOG.error("GUI-Dateikopie: Unerwarteter Fehler: {}", e.getMessage(), e);
return new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileCopyFileSystemFailure(
"Unerwarteter Fehler: "
+ (e.getMessage() == null
? e.getClass().getSimpleName()
: e.getMessage()));
}
}
/**
* Resolves the historical processing context for the document identified by
* {@code fingerprint}, using the configuration at {@code configFilePath}.