From 1d77173c49af2029307c7b45413174b856907f46 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Mon, 27 Apr 2026 13:22:44 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20#31:=20Manuelle=20Dateinamen-Eingabe=20f?= =?UTF-8?q?=C3=BCr=20nicht=20verarbeitete=20Dateien?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../gui/GuiConfigurationEditorWorkspace.java | 24 + .../adapter/in/gui/GuiStartupContext.java | 25 +- .../in/gui/batchrun/FileNameEditorPane.java | 55 +- .../in/gui/batchrun/GuiBatchRunTab.java | 192 +++++- .../gui/batchrun/GuiManualFileCopyPort.java | 48 ++ .../gui/batchrun/FileNameEditorPaneTest.java | 109 +++- .../in/ManualFileCopyDocumentNotFound.java | 25 + .../in/ManualFileCopyFileSystemFailure.java | 29 + .../port/in/ManualFileCopyInvalidState.java | 30 + .../in/ManualFileCopyNoOpIdenticalTarget.java | 28 + .../in/ManualFileCopyPersistenceFailure.java | 30 + .../port/in/ManualFileCopyRequest.java | 38 ++ .../port/in/ManualFileCopyResult.java | 34 ++ .../port/in/ManualFileCopySuccess.java | 31 + .../port/in/ManualFileCopyUseCase.java | 58 ++ .../usecase/DefaultManualFileCopyUseCase.java | 264 ++++++++ .../DefaultManualFileCopyUseCaseTest.java | 566 ++++++++++++++++++ .../umbenenner/bootstrap/BootstrapRunner.java | 110 +++- 18 files changed, 1673 insertions(+), 23 deletions(-) create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiManualFileCopyPort.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyDocumentNotFound.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyFileSystemFailure.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyInvalidState.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyNoOpIdenticalTarget.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyPersistenceFailure.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyRequest.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyResult.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopySuccess.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyUseCase.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCase.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCaseTest.java diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index 9b5b432..74f0b55 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -18,6 +18,7 @@ import org.apache.logging.log4j.Logger; import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher; import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab; 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.batchrun.GuiMiniRunLauncher; import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort; @@ -372,6 +373,13 @@ public final class GuiConfigurationEditorWorkspace { */ private final GuiManualFileRenamePort manualFileRenamePort; + /** + * Port used by the processing-run tab to copy a source file to the target folder for + * documents that have not yet been successfully processed (FAILED- oder SKIPPED-Status). + * Supplied by Bootstrap via the startup context. + */ + private final GuiManualFileCopyPort manualFileCopyPort; + /** * Port used by the processing-run coordinator to resolve the historical processing context * for skipped documents. Supplied by Bootstrap via the startup context. @@ -453,6 +461,7 @@ public final class GuiConfigurationEditorWorkspace { this.miniRunLauncher = effectiveContext.miniRunLauncher(); this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort(); this.manualFileRenamePort = effectiveContext.manualFileRenamePort(); + this.manualFileCopyPort = effectiveContext.manualFileCopyPort(); this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort(); this.batchRunTab = new GuiBatchRunTab( () -> this.batchRunLauncher, @@ -462,6 +471,7 @@ public final class GuiConfigurationEditorWorkspace { this::isSavedConfigurationReady, this::applyBatchRunLockState, () -> this.manualFileRenamePort, + () -> this.manualFileCopyPort, () -> this.historicalDocumentContextPort, this::editorSourceFolder, this::editorTargetFolder); @@ -495,6 +505,20 @@ public final class GuiConfigurationEditorWorkspace { return manualFileRenamePort; } + /** + * Liefert den Port für das manuelle Kopieren der Quelldatei eines bislang nicht + * erfolgreich verarbeiteten Dokuments ins Zielverzeichnis. + *

+ * Wird von Bootstrap bereitgestellt. Der Verarbeitungslauf-Tab nutzt diesen Port, + * wenn der Benutzer für ein FAILED- oder SKIPPED-Dokument einen manuellen + * Zieldateinamen bestätigt. + * + * @return den {@link GuiManualFileCopyPort}; nie {@code null} + */ + public GuiManualFileCopyPort manualFileCopyPort() { + return manualFileCopyPort; + } + /** * Returns the currently loaded configuration file path, or {@code null} when the * editor has never loaded a file from disk. The processing-run tab uses this value to diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java index 6872d68..f65fc64 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java @@ -7,6 +7,7 @@ import java.util.Set; 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.batchrun.GuiMiniRunLauncher; import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort; @@ -41,7 +42,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; * to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted * mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to * reset the persistence status of selected documents, and the - * {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI, and + * {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI, + * the {@link GuiManualFileCopyPort} used to manually copy a source file to the target + * folder for documents that have not yet been successfully processed, and * the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing * context for documents that were skipped in the current run. *

@@ -63,6 +66,7 @@ public record GuiStartupContext( GuiMiniRunLauncher miniRunLauncher, GuiResetDocumentStatusPort resetDocumentStatusPort, GuiManualFileRenamePort manualFileRenamePort, + GuiManualFileCopyPort manualFileCopyPort, GuiHistoricalDocumentContextPort historicalDocumentContextPort) { /** @@ -85,6 +89,9 @@ public record GuiStartupContext( * documents; must not be {@code null} * @param manualFileRenamePort bridge that renames a target file manually from the GUI; * must not be {@code null} + * @param manualFileCopyPort bridge that copies a source file to the target folder for + * documents that have not yet been successfully processed; + * must not be {@code null} * @param historicalDocumentContextPort bridge that resolves the historical processing context * for skipped documents; must not be {@code null} */ @@ -115,6 +122,8 @@ public record GuiStartupContext( "resetDocumentStatusPort must not be null"); manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort, "manualFileRenamePort must not be null"); + manualFileCopyPort = Objects.requireNonNull(manualFileCopyPort, + "manualFileCopyPort must not be null"); historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort, "historicalDocumentContextPort must not be null"); } @@ -157,6 +166,7 @@ public record GuiStartupContext( modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(), + rejectingManualFileCopyPort(), noOpHistoricalDocumentContextPort()); } @@ -192,6 +202,7 @@ public record GuiStartupContext( modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(), + rejectingManualFileCopyPort(), noOpHistoricalDocumentContextPort()); } @@ -227,7 +238,8 @@ public record GuiStartupContext( modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, correctionExecutionService, rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(), - rejectingManualFileRenamePort(), noOpHistoricalDocumentContextPort()); + rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), + noOpHistoricalDocumentContextPort()); } private static GuiBatchRunLauncher rejectingBatchRunLauncher() { @@ -253,7 +265,13 @@ public record GuiStartupContext( private static GuiManualFileRenamePort rejectingManualFileRenamePort() { return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in .ManualFileRenameFileSystemFailure( - "Kein Umbennennungs-Port in diesem Startkontext verfügbar."); + "Kein Umbenennungs-Port in diesem Startkontext verfügbar."); + } + + private static GuiManualFileCopyPort rejectingManualFileCopyPort() { + return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in + .ManualFileCopyFileSystemFailure( + "Kein Kopier-Port in diesem Startkontext verfügbar."); } private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() { @@ -334,6 +352,7 @@ public record GuiStartupContext( rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(), + rejectingManualFileCopyPort(), noOpHistoricalDocumentContextPort()); } } diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPane.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPane.java index 025959d..4e08483 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPane.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPane.java @@ -144,8 +144,18 @@ public final class FileNameEditorPane { *

* Der KI-Vorschlag wird aus {@link GuiBatchRunResultRow#finalFileName()} abgeleitet, * der letzte gespeicherte Name aus {@link GuiBatchRunResultRow#effectiveFileName()}. - * Bei nicht editierbaren Status (FAILED_*, SKIPPED_*, reset-pending, kein SUCCESS) - * wird das Feld deaktiviert. + * Editierbarkeitsregeln: + *

* * @param row die neu selektierte Zeile; {@code null} führt zu {@link #clearSelection()} * @param targetFolderPath Zielordner-Pfad für die Pfadlängen-Validierung; darf @@ -160,7 +170,17 @@ public final class FileNameEditorPane { this.aiProposal = stripPdfExtension(row.finalFileName()); this.lastSavedName = stripPdfExtension(row.effectiveFileName()); - boolean editable = isRowEditable(row) && lastSavedName.isPresent(); + boolean editable; + if (row.resetPending()) { + editable = false; + } else if (requiresExistingTargetForRename(row.status())) { + // Umbenennen einer existierenden Zieldatei: nur sinnvoll, wenn ein + // gespeicherter Name vorliegt. + editable = lastSavedName.isPresent(); + } else { + // Manuelle Kopie: das Feld ist auch ohne gespeicherten Namen editierbar. + editable = isRowEditable(row); + } this.selectionEditable = editable; suppressValidation = true; @@ -172,6 +192,18 @@ public final class FileNameEditorPane { refreshUiState(); } + /** + * Liefert {@code true}, wenn die Zeile einen Status hat, bei dem die Editierung + * eine bestehende Zieldatei umbenennt (im Gegensatz zur Kopie der Quelldatei). + * + * @param status der aggregierte Abschlussstatus der Zeile + * @return {@code true} für SUCCESS und SKIPPED_ALREADY_PROCESSED; sonst {@code false} + */ + private static boolean requiresExistingTargetForRename(DocumentCompletionStatus status) { + return status == DocumentCompletionStatus.SUCCESS + || status == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED; + } + /** * Leert die Komponente und deaktiviert die Eingabe. Wird aufgerufen wenn keine Zeile * selektiert ist. @@ -407,11 +439,20 @@ public final class FileNameEditorPane { return folderLength + separatorLength + baseName.length() + PDF_EXTENSION.length(); } + /** + * Liefert {@code true}, wenn die Zeile fachlich für eine manuelle Dateinamens-Aktion + * editierbar ist. + *

+ * Editierbar sind alle nicht-resetpending-Zeilen unabhängig davon, ob die Aktion + * eine Zieldatei umbenennt (SUCCESS, SKIPPED_ALREADY_PROCESSED) oder die Quelldatei + * kopiert (FAILED_*, SKIPPED_FINAL_FAILURE). Die genaue Aktion wird vom Tab anhand + * des Status entschieden. + * + * @param row die Zeile, deren Editierbarkeit geprüft werden soll + * @return {@code true} wenn die Zeile editierbar ist; sonst {@code false} + */ private static boolean isRowEditable(GuiBatchRunResultRow row) { - if (row.resetPending()) { - return false; - } - return row.status() == DocumentCompletionStatus.SUCCESS; + return !row.resetPending(); } private static Optional stripPdfExtension(Optional fileNameWithExtension) { diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java index b52ac9e..d4ce846 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java @@ -22,6 +22,14 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyDocumentNotFound; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyFileSystemFailure; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyInvalidState; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyNoOpIdenticalTarget; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyPersistenceFailure; +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.ManualFileCopySuccess; import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameDocumentNotFound; import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameFileSystemFailure; import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameInvalidState; @@ -195,6 +203,7 @@ public final class GuiBatchRunTab { private final Runnable onRunStateChanged; private final GuiBatchRunCoordinator coordinator; private final Supplier manualFileRenamePortSupplier; + private final Supplier manualFileCopyPortSupplier; private final Supplier historicalDocumentContextPortSupplier; private final Supplier> sourceFolderSupplier; private final Supplier> targetFolderSupplier; @@ -232,7 +241,10 @@ public final class GuiBatchRunTab { * null sein * @param onRunStateChanged Callback wenn das Lauf-Flag kippt; darf nicht * null sein - * @param manualFileRenamePortSupplier Supplier für den manuellen Umbennennungs-Port; + * @param manualFileRenamePortSupplier Supplier für den manuellen Umbenennungs-Port; + * darf nicht null sein + * @param manualFileCopyPortSupplier Supplier für den manuellen Kopier-Port (für FAILED-/ + * SKIPPED-Zeilen ohne existierende Zieldatei); * darf nicht null sein * @param historicalDocumentContextPortSupplier Supplier für den historischen Kontext-Port; * darf nicht null sein @@ -248,6 +260,7 @@ public final class GuiBatchRunTab { BooleanSupplier savedConfigurationReadyCheck, Runnable onRunStateChanged, Supplier manualFileRenamePortSupplier, + Supplier manualFileCopyPortSupplier, Supplier historicalDocumentContextPortSupplier, Supplier> sourceFolderSupplier, Supplier> targetFolderSupplier) { @@ -260,6 +273,8 @@ public final class GuiBatchRunTab { this.onRunStateChanged = Objects.requireNonNull(onRunStateChanged, "onRunStateChanged must not be null"); this.manualFileRenamePortSupplier = Objects.requireNonNull( manualFileRenamePortSupplier, "manualFileRenamePortSupplier must not be null"); + this.manualFileCopyPortSupplier = Objects.requireNonNull( + manualFileCopyPortSupplier, "manualFileCopyPortSupplier must not be null"); this.historicalDocumentContextPortSupplier = Objects.requireNonNull( historicalDocumentContextPortSupplier, "historicalDocumentContextPortSupplier must not be null"); this.sourceFolderSupplier = Objects.requireNonNull( @@ -315,6 +330,7 @@ public final class GuiBatchRunTab { savedConfigurationReadyCheck, onRunStateChanged, () -> GuiBatchRunTab::rejectingRename, + () -> GuiBatchRunTab::rejectingCopy, () -> (cfgPath, fp) -> Optional.empty(), Optional::empty, Optional::empty); @@ -322,7 +338,7 @@ public final class GuiBatchRunTab { /** * Rückwärtskompatible Variante mit Mini-Lauf- und Rücksetz-Fähigkeit, aber ohne - * manuellen Umbennennungs-Port und Ordner-Supplier. + * manuellen Umbenennungs-Port und Ordner-Supplier. * * @param launcherSupplier Supplier für den Batch-Lauf-Launcher; * darf nicht null sein @@ -348,6 +364,7 @@ public final class GuiBatchRunTab { savedConfigurationReadyCheck, onRunStateChanged, () -> GuiBatchRunTab::rejectingRename, + () -> GuiBatchRunTab::rejectingCopy, () -> (cfgPath, fp) -> Optional.empty(), Optional::empty, Optional::empty); @@ -749,9 +766,19 @@ public final class GuiBatchRunTab { } /** - * Verarbeitet den Speichern-Callback des Dateiname-Editors. Läuft auf einem - * Hintergrund-Worker-Thread; das Ergebnis wird per {@code Platform.runLater} - * zurück auf den FX-Thread übertragen. + * Verarbeitet den Speichern-Callback des Dateiname-Editors. Läuft die eigentliche + * Aktion auf einem Hintergrund-Worker-Thread; das Ergebnis wird per + * {@code Platform.runLater} zurück auf den FX-Thread übertragen. + *

+ * Die konkrete Aktion hängt vom Status der Zeile ab: + *

* * @param desiredBaseName der gewünschte Basisname ohne {@code .pdf}-Endung */ @@ -765,7 +792,23 @@ public final class GuiBatchRunTab { showMessage(NO_SAVED_CONFIGURATION_HINT); return; } - GuiManualFileRenamePort port = manualFileRenamePortSupplier.get(); + + if (requiresCopyAction(row.status())) { + GuiManualFileCopyPort copyPort = manualFileCopyPortSupplier.get(); + ManualFileCopyRequest copyRequest = + new ManualFileCopyRequest(row.fingerprint(), desiredBaseName); + + LOG.info("Manuelle Dateikopie angefordert: {} (Status {}) → {}.pdf", + row.originalFileName(), row.status(), desiredBaseName); + + renameExecutor.submit(() -> { + ManualFileCopyResult result = copyPort.copy(configPath, copyRequest); + Platform.runLater(() -> handleCopyResult(result, row)); + }); + return; + } + + GuiManualFileRenamePort renamePort = manualFileRenamePortSupplier.get(); ManualFileRenameRequest request = new ManualFileRenameRequest(row.fingerprint(), desiredBaseName); @@ -773,11 +816,138 @@ public final class GuiBatchRunTab { row.effectiveFileName().orElse("?"), desiredBaseName); renameExecutor.submit(() -> { - ManualFileRenameResult result = port.rename(configPath, request); + ManualFileRenameResult result = renamePort.rename(configPath, request); Platform.runLater(() -> handleRenameResult(result, row)); }); } + /** + * Liefert {@code true}, wenn der Status der Zeile dazu führt, dass die Quelldatei + * neu kopiert werden muss (statt eine bestehende Zieldatei umzubenennen). + * + * @param status der aggregierte Abschlussstatus der Zeile + * @return {@code true} bei FAILED_*- und SKIPPED_FINAL_FAILURE-Status + */ + private static boolean requiresCopyAction(DocumentCompletionStatus status) { + return switch (status) { + case FAILED_RETRYABLE, + FAILED_PERMANENT, + SKIPPED_FINAL_FAILURE -> true; + case SUCCESS, + SKIPPED_ALREADY_PROCESSED -> false; + }; + } + + /** + * Verarbeitet das Ergebnis einer manuellen Dateikopie auf dem FX-Thread. + *

+ * Bei Erfolg wird die Zeile in der Tabelle so aktualisiert, dass sie als + * {@code SUCCESS} mit dem neu vergebenen Zieldateinamen erscheint und der + * Detailbereich anschließend wie für eine erfolgreich verarbeitete Zeile bedienbar + * ist (Umbenennen statt erneutem Kopieren). + * + * @param result das Ergebnis des Use-Case-Aufrufs + * @param row die Zeile, für die die Kopie angefordert wurde + */ + private void handleCopyResult(ManualFileCopyResult result, GuiBatchRunResultRow row) { + switch (result) { + case ManualFileCopySuccess success -> { + LOG.info("Manuelle Dateikopie erfolgreich: {} → {} (Suffix: {})", + row.originalFileName(), success.appliedFileName(), + success.conflictSuffixApplied()); + GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, success.appliedFileName()); + currentlySelectedRow = updatedRow; + fileNameEditor.clearDirtyState(); + upsertResultRowByFingerprint(updatedRow); + String targetFolder = targetFolderSupplier.get().orElse(""); + fileNameEditor.loadSelection(updatedRow, targetFolder); + String msg = "Datei kopiert und gespeichert: " + success.appliedFileName(); + if (success.conflictSuffixApplied()) { + msg += " (Suffix wegen Namenskonflikt angehängt)"; + } + showMessage(msg); + refreshAggregateCountersFromItems(); + } + case ManualFileCopyNoOpIdenticalTarget noOp -> { + LOG.info("Manuelle Dateikopie: identische Zieldatei {} bereits vorhanden – kein Schreibvorgang.", + noOp.existingFileName()); + GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, noOp.existingFileName()); + currentlySelectedRow = updatedRow; + fileNameEditor.clearDirtyState(); + upsertResultRowByFingerprint(updatedRow); + String targetFolder = targetFolderSupplier.get().orElse(""); + fileNameEditor.loadSelection(updatedRow, targetFolder); + showMessage("Identische Datei bereits vorhanden – Status auf SUCCESS gesetzt"); + refreshAggregateCountersFromItems(); + } + case ManualFileCopyDocumentNotFound notFound -> { + LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", notFound.reason()); + showMessage("Fehler: Dokument nicht gefunden – " + notFound.reason()); + } + case ManualFileCopyInvalidState invalidState -> { + LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", invalidState.reason()); + showMessage("Fehler: Ungültiger Dokumentstatus – " + invalidState.reason()); + } + case ManualFileCopyFileSystemFailure fsFail -> { + LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", fsFail.message()); + showMessage("Dateisystemfehler: " + fsFail.message()); + } + case ManualFileCopyPersistenceFailure persistFail -> { + LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", persistFail.message()); + showMessage("Persistenzfehler (Zielkopie wurde zurückgerollt): " + + persistFail.message()); + } + } + } + + /** + * Baut eine neue Zeilen-Sicht für ein Dokument, das per manueller Dateikopie auf + * {@code SUCCESS} gehoben wurde. Status, korrigierter Dateiname und das Zurücksetzen + * der Fehlermeldung machen die Zeile danach im Detailbereich behandelbar wie eine + * regulär erfolgreich verarbeitete Zeile. + * + * @param previous die ursprüngliche Zeile (FAILED- oder SKIPPED-Status) + * @param appliedFileName der finale Zieldateiname inklusive Endung + * @return eine aktualisierte Zeile mit Status {@code SUCCESS} + */ + private static GuiBatchRunResultRow buildSuccessRowAfterCopy( + GuiBatchRunResultRow previous, String appliedFileName) { + return new GuiBatchRunResultRow( + previous.originalFileName(), + previous.fingerprint(), + DocumentCompletionStatus.SUCCESS, + Optional.of(appliedFileName), + Optional.empty(), + previous.resolvedDate(), + previous.aiReasoning(), + Optional.empty(), + previous.processingDuration(), + false, + Optional.empty()); + } + + /** + * Aktualisiert die aggregierten Zähler (Erfolg, Fehler, Übersprungen) anhand der + * aktuellen Tabellenzeilen. Wird nach Statusänderungen einzelner Zeilen außerhalb + * eines Laufs aufgerufen, damit die Anzeige konsistent bleibt. + */ + private void refreshAggregateCountersFromItems() { + int success = 0; + int failed = 0; + int skipped = 0; + for (GuiBatchRunResultRow item : resultItems) { + switch (item.status()) { + case SUCCESS -> success++; + case FAILED_RETRYABLE, FAILED_PERMANENT -> failed++; + case SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> skipped++; + } + } + this.successCount = success; + this.failedCount = failed; + this.skippedCount = skipped; + updateCounterLabel(); + } + /** * Verarbeitet das Ergebnis einer manuellen Dateiumbenennung auf dem FX-Thread. * @@ -1304,7 +1474,13 @@ public final class GuiBatchRunTab { private static ManualFileRenameResult rejectingRename( Path p, ManualFileRenameRequest req) { return new ManualFileRenameFileSystemFailure( - "Kein Umbennennungs-Port in diesem Startkontext verfügbar."); + "Kein Umbenennungs-Port in diesem Startkontext verfügbar."); + } + + private static ManualFileCopyResult rejectingCopy( + Path p, ManualFileCopyRequest req) { + return new ManualFileCopyFileSystemFailure( + "Kein Kopier-Port in diesem Startkontext verfügbar."); } // ------------------------------------------------------------------------- diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiManualFileCopyPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiManualFileCopyPort.java new file mode 100644 index 0000000..2850832 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiManualFileCopyPort.java @@ -0,0 +1,48 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun; + +import java.nio.file.Path; + +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult; + +/** + * Inbound-Brücke für die manuelle Kopie der Quelldatei eines bislang nicht erfolgreich + * verarbeiteten Dokuments aus der GUI. + *

+ * Wird von Bootstrap per Methoden-Referenz befüllt und vom GUI-Code aufgerufen, wenn der + * Benutzer für ein nicht erfolgreich verarbeitetes Dokument (Status {@code FAILED_*} oder + * {@code SKIPPED_FINAL_FAILURE}) einen manuellen Zieldateinamen bestätigt. Der Port + * kapselt das vollständige Wiring (Konfigurationsauflösung, Use-Case-Konstruktion und + * Ausführung), sodass der GUI-Adapter keine Kenntnis von infrastrukturellen + * Implementierungsdetails benötigt. + * + *

Threadingmodell

+ *

+ * Der Port darf auf einem beliebigen Thread aufgerufen werden. Die Implementierung ist + * synchron und blockierend: Sie kehrt erst zurück, wenn die Kopie abgeschlossen oder + * fehlgeschlagen ist. Aufrufer aus dem GUI-Layer müssen den Aufruf daher auf einem + * Hintergrund-Worker-Thread ausführen und das Ergebnis anschließend per + * {@code Platform.runLater} auf den JavaFX-Application-Thread zurückführen. + * + *

Exception-Vertrag

+ *

+ * Implementierungen dürfen keine geprüften Ausnahmen propagieren. Unerwartete + * Laufzeitausnahmen sollen abgefangen und als passendes {@link ManualFileCopyResult} + * zurückgegeben werden. + */ +@FunctionalInterface +public interface GuiManualFileCopyPort { + + /** + * Kopiert die Quelldatei eines Dokuments mit benutzerdefiniertem Zieldateinamen ins + * Zielverzeichnis und aktualisiert den Dokument-Stammsatz auf {@code SUCCESS}. + * + * @param configFilePath Pfad zur {@code .properties}-Datei, die SQLite-Datenbank, + * Quell- und Zielordner beschreibt; darf nicht {@code null} sein; + * muss existieren und lesbar sein + * @param request die Kopieranfrage mit Fingerprint und gewünschtem + * Basisdateinamen; darf nicht {@code null} sein + * @return das Ergebnis der Kopieroperation; nie {@code null} + */ + ManualFileCopyResult copy(Path configFilePath, ManualFileCopyRequest request); +} diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPaneTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPaneTest.java index 0e6997b..0afc596 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPaneTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPaneTest.java @@ -332,23 +332,124 @@ class FileNameEditorPaneTest { } // ------------------------------------------------------------------------- - // Status FAILED → deaktiviert + // Editierbarkeit nach Status // ------------------------------------------------------------------------- @Test - void loadSelection_failedStatus_disablesTextField() throws Exception { + void loadSelection_failedPermanentStatus_enablesTextFieldForManualCopy() throws Exception { runOnFx(() -> { FileNameEditorPane pane = new FileNameEditorPane(); + // FAILED-Zeile ohne KI-Vorschlag und ohne gespeicherten Namen: muss + // dennoch editierbar sein, damit der Benutzer einen manuellen + // Zieldateinamen für die Kopie eingeben kann. GuiBatchRunResultRow row = new GuiBatchRunResultRow( "test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Duration.ofMillis(1)); pane.loadSelection(row, "C:\\target"); - assertTrue(pane.textField().isDisable(), - "FAILED-Status soll TextField deaktivieren"); + assertFalse(pane.textField().isDisable(), + "FAILED-Status soll TextField für manuelle Kopie aktivieren"); }); } + @Test + void loadSelection_failedRetryableStatus_enablesTextFieldForManualCopy() throws Exception { + runOnFx(() -> { + FileNameEditorPane pane = new FileNameEditorPane(); + GuiBatchRunResultRow row = new GuiBatchRunResultRow( + "test.pdf", FP, DocumentCompletionStatus.FAILED_RETRYABLE, + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), + Duration.ofMillis(1)); + pane.loadSelection(row, "C:\\target"); + assertFalse(pane.textField().isDisable(), + "FAILED_RETRYABLE soll TextField für manuelle Kopie aktivieren"); + }); + } + + @Test + void loadSelection_skippedFinalFailureStatus_enablesTextFieldForManualCopy() throws Exception { + runOnFx(() -> { + FileNameEditorPane pane = new FileNameEditorPane(); + // SKIPPED_FINAL_FAILURE: Zieldatei existiert nicht → manuelle Kopie zulässig + GuiBatchRunResultRow row = new GuiBatchRunResultRow( + "test.pdf", FP, DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), + Duration.ofMillis(1)); + pane.loadSelection(row, "C:\\target"); + assertFalse(pane.textField().isDisable(), + "SKIPPED_FINAL_FAILURE soll TextField für manuelle Kopie aktivieren"); + }); + } + + @Test + void loadSelection_skippedAlreadyProcessedStatus_enablesTextFieldForRename() throws Exception { + runOnFx(() -> { + FileNameEditorPane pane = new FileNameEditorPane(); + // SKIPPED_ALREADY_PROCESSED: Zieldatei existiert (lastSavedName gesetzt) → Rename + GuiBatchRunResultRow row = new GuiBatchRunResultRow( + "test.pdf", FP, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, + Optional.of("2026-01-01 - Bestehend.pdf"), + Optional.empty(), Optional.empty(), Optional.empty(), + Duration.ofMillis(1)); + pane.loadSelection(row, "C:\\target"); + assertFalse(pane.textField().isDisable(), + "SKIPPED_ALREADY_PROCESSED soll TextField für Umbenennung aktivieren"); + assertEquals("2026-01-01 - Bestehend", pane.textField().getText()); + }); + } + + @Test + void loadSelection_skippedAlreadyProcessed_withoutSavedName_disablesTextField() throws Exception { + runOnFx(() -> { + FileNameEditorPane pane = new FileNameEditorPane(); + // SKIPPED_ALREADY_PROCESSED ohne lastSavedName: keine bestehende Zieldatei zum Umbenennen + GuiBatchRunResultRow row = new GuiBatchRunResultRow( + "test.pdf", FP, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), + Duration.ofMillis(1)); + pane.loadSelection(row, "C:\\target"); + assertTrue(pane.textField().isDisable(), + "SKIPPED_ALREADY_PROCESSED ohne gespeicherten Namen soll TextField deaktivieren"); + }); + } + + @Test + void loadSelection_resetPending_disablesTextField() throws Exception { + runOnFx(() -> { + FileNameEditorPane pane = new FileNameEditorPane(); + GuiBatchRunResultRow row = new GuiBatchRunResultRow( + "test.pdf", FP, DocumentCompletionStatus.SUCCESS, + Optional.of("2026-01-01 - X.pdf"), + Optional.empty(), Optional.empty(), Optional.empty(), + Duration.ofMillis(1), true); + pane.loadSelection(row, "C:\\target"); + assertTrue(pane.textField().isDisable(), + "resetPending soll TextField unabhängig vom Status deaktivieren"); + }); + } + + @Test + void enter_whenFailedStatus_triggersSaveCallback() throws Exception { + AtomicReference capturedName = new AtomicReference<>(); + runOnFx(() -> { + FileNameEditorPane pane = new FileNameEditorPane(); + pane.setOnSaveRequested(capturedName::set); + // FAILED-Zeile ohne gespeicherten Namen: User tippt manuellen Namen ein + GuiBatchRunResultRow row = new GuiBatchRunResultRow( + "test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT, + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), + Duration.ofMillis(1)); + pane.loadSelection(row, "C:\\target"); + pane.textField().setText("2026-04-27 - Manuell benannt"); + pane.textField().getOnKeyPressed().handle( + new javafx.scene.input.KeyEvent( + javafx.scene.input.KeyEvent.KEY_PRESSED, + "", "", KeyCode.ENTER, false, false, false, false)); + }); + assertEquals("2026-04-27 - Manuell benannt", capturedName.get(), + "Enter soll Save-Callback auch bei FAILED-Status mit manueller Eingabe auslösen"); + } + // ------------------------------------------------------------------------- // Hilfsmethoden // ------------------------------------------------------------------------- diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyDocumentNotFound.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyDocumentNotFound.java new file mode 100644 index 0000000..4d9a06a --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyDocumentNotFound.java @@ -0,0 +1,25 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +import java.util.Objects; + +/** + * Ergebnis, wenn das zu kopierende Dokument in der Persistenz nicht gefunden wurde. + *

+ * Gibt an, dass kein Dokument-Stammsatz mit dem angegebenen Fingerprint existiert. + * Dies kann eintreten, wenn der Fingerprint ungültig ist oder der Datensatz + * zwischenzeitlich gelöscht wurde. + * + * @param reason menschenlesbare Begründung, warum das Dokument nicht gefunden wurde; + * nie null + */ +public record ManualFileCopyDocumentNotFound(String reason) implements ManualFileCopyResult { + + /** + * Kompakter Konstruktor zur Validierung des Pflichtfelds. + * + * @throws NullPointerException wenn {@code reason} null ist + */ + public ManualFileCopyDocumentNotFound { + Objects.requireNonNull(reason, "reason must not be null"); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyFileSystemFailure.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyFileSystemFailure.java new file mode 100644 index 0000000..35cc47c --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyFileSystemFailure.java @@ -0,0 +1,29 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +import java.util.Objects; + +/** + * Ergebnis, wenn die Kopie der Quelldatei ins Zielverzeichnis im Dateisystem + * fehlgeschlagen ist. + *

+ * Gibt an, dass ein technischer Fehler beim Dateisystemzugriff aufgetreten ist, + * z. B. fehlende Schreibrechte, gesperrte Datei durch einen anderen Prozess oder + * ein nicht erreichbares Netzlaufwerk. Ebenfalls verwendet, wenn der Zielordner-Port + * einen technischen Fehler meldet. + *

+ * Gemäß dem Alles-oder-Nichts-Prinzip wird in diesem Fall die Persistenz nicht + * aktualisiert. + * + * @param message menschenlesbare Beschreibung des aufgetretenen Fehlers; nie null + */ +public record ManualFileCopyFileSystemFailure(String message) implements ManualFileCopyResult { + + /** + * Kompakter Konstruktor zur Validierung des Pflichtfelds. + * + * @throws NullPointerException wenn {@code message} null ist + */ + public ManualFileCopyFileSystemFailure { + Objects.requireNonNull(message, "message must not be null"); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyInvalidState.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyInvalidState.java new file mode 100644 index 0000000..20b26db --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyInvalidState.java @@ -0,0 +1,30 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +import java.util.Objects; + +/** + * Ergebnis, wenn das Dokument sich in einem ungültigen Zustand für eine manuelle + * Kopie befindet. + *

+ * Eine manuelle Kopie ist nur für Dokumente sinnvoll, deren Quelldatei noch nicht + * erfolgreich ins Zielverzeichnis kopiert wurde. Dieses Ergebnis wird zurückgegeben, + * wenn z. B.: + *

+ * + * @param reason menschenlesbare Begründung für den ungültigen Zustand; nie null + */ +public record ManualFileCopyInvalidState(String reason) implements ManualFileCopyResult { + + /** + * Kompakter Konstruktor zur Validierung des Pflichtfelds. + * + * @throws NullPointerException wenn {@code reason} null ist + */ + public ManualFileCopyInvalidState { + Objects.requireNonNull(reason, "reason must not be null"); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyNoOpIdenticalTarget.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyNoOpIdenticalTarget.java new file mode 100644 index 0000000..e662a53 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyNoOpIdenticalTarget.java @@ -0,0 +1,28 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +import java.util.Objects; + +/** + * Ergebnis, wenn keine Kopie notwendig ist, weil im Zielverzeichnis bereits eine Datei + * mit identischem Inhalt (gleicher Fingerprint) vorhanden ist. + *

+ * Der Dokument-Stammsatz wird in diesem Fall trotzdem konsistent auf + * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} gehoben und + * der vorhandene Zieldateiname wird übernommen, sodass das Dokument fachlich als + * abgeschlossen gilt. + * + * @param existingFileName der Dateiname (ohne Pfad) der bereits vorhandenen identischen + * Zieldatei; nie null + */ +public record ManualFileCopyNoOpIdenticalTarget(String existingFileName) + implements ManualFileCopyResult { + + /** + * Kompakter Konstruktor zur Validierung des Pflichtfelds. + * + * @throws NullPointerException wenn {@code existingFileName} null ist + */ + public ManualFileCopyNoOpIdenticalTarget { + Objects.requireNonNull(existingFileName, "existingFileName must not be null"); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyPersistenceFailure.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyPersistenceFailure.java new file mode 100644 index 0000000..b83043f --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyPersistenceFailure.java @@ -0,0 +1,30 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +import java.util.Objects; + +/** + * Ergebnis, wenn die Persistenzaktualisierung nach erfolgreicher Dateikopie + * fehlgeschlagen ist. + *

+ * Gibt an, dass die Quelldatei zwar erfolgreich ins Zielverzeichnis kopiert werden + * konnte, jedoch die anschließende Aktualisierung des Dokument-Stammsatzes in der + * Persistenz fehlgeschlagen ist. Der Use-Case versucht in diesem Fall, die + * geschriebene Zieldatei wieder zu entfernen (Best-Effort-Rollback). + *

+ * Schlägt auch der Rollback fehl, wird dies auf ERROR-Ebene protokolliert. In jedem + * Fall bleibt dieses Ergebnis die Rückgabe, sodass der Aufrufer den Benutzer + * informieren kann. + * + * @param message menschenlesbare Beschreibung des Persistenzfehlers; nie null + */ +public record ManualFileCopyPersistenceFailure(String message) implements ManualFileCopyResult { + + /** + * Kompakter Konstruktor zur Validierung des Pflichtfelds. + * + * @throws NullPointerException wenn {@code message} null ist + */ + public ManualFileCopyPersistenceFailure { + Objects.requireNonNull(message, "message must not be null"); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyRequest.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyRequest.java new file mode 100644 index 0000000..ca5d1e6 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyRequest.java @@ -0,0 +1,38 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +import java.util.Objects; + +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; + +/** + * Anfrage an den {@link ManualFileCopyUseCase} zum manuellen Kopieren der Quelldatei + * eines bisher nicht erfolgreich verarbeiteten Dokuments mit einem benutzerdefinierten + * Zieldateinamen. + *

+ * Der Benutzer gibt im GUI ausschließlich den Basistitel ohne {@code .pdf}-Endung an. + * Der Use-Case hängt die Erweiterung selbst an. + * + * @param fingerprint Inhalts-Fingerabdruck des Dokuments, dessen Quelldatei + * kopiert werden soll; nie null + * @param desiredBaseFileName gewünschter Basisdateiname ohne {@code .pdf}-Endung; + * nie null; darf nicht leer oder nur aus Leerzeichen bestehen + */ +public record ManualFileCopyRequest( + DocumentFingerprint fingerprint, + String desiredBaseFileName) { + + /** + * Kompakter Konstruktor zur Validierung der Pflichtfelder. + * + * @throws NullPointerException wenn {@code fingerprint} oder + * {@code desiredBaseFileName} null sind + * @throws IllegalArgumentException wenn {@code desiredBaseFileName} leer ist + */ + public ManualFileCopyRequest { + Objects.requireNonNull(fingerprint, "fingerprint must not be null"); + Objects.requireNonNull(desiredBaseFileName, "desiredBaseFileName must not be null"); + if (desiredBaseFileName.isBlank()) { + throw new IllegalArgumentException("desiredBaseFileName must not be blank"); + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyResult.java new file mode 100644 index 0000000..d85b1d9 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyResult.java @@ -0,0 +1,34 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +/** + * Versiegeltes Ergebnis-Interface für eine manuelle Dateikopie via + * {@link ManualFileCopyUseCase}. + *

+ * Mögliche Ergebnisse: + *

+ */ +public sealed interface ManualFileCopyResult + permits ManualFileCopySuccess, + ManualFileCopyNoOpIdenticalTarget, + ManualFileCopyDocumentNotFound, + ManualFileCopyInvalidState, + ManualFileCopyFileSystemFailure, + ManualFileCopyPersistenceFailure { +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopySuccess.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopySuccess.java new file mode 100644 index 0000000..fd1ae46 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopySuccess.java @@ -0,0 +1,31 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +import java.util.Objects; + +/** + * Ergebnis einer erfolgreich abgeschlossenen manuellen Dateikopie. + *

+ * Die Quelldatei wurde unter dem (ggf. mit Suffix versehenen) Zieldateinamen ins + * Zielverzeichnis kopiert und der Dokument-Stammsatz wurde auf + * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} aktualisiert. + * + * @param appliedFileName der tatsächlich angewendete Dateiname (ohne Pfad) der + * Zielkopie; kann bei Konflikten ein Suffix wie {@code (1)} + * enthalten; nie null + * @param conflictSuffixApplied {@code true} wenn dem gewünschten Basisdateinamen ein + * Konflikt-Suffix angehängt wurde, weil der Wunschname bereits + * durch eine andere Datei belegt war + */ +public record ManualFileCopySuccess( + String appliedFileName, + boolean conflictSuffixApplied) implements ManualFileCopyResult { + + /** + * Kompakter Konstruktor zur Validierung der Pflichtfelder. + * + * @throws NullPointerException wenn {@code appliedFileName} null ist + */ + public ManualFileCopySuccess { + Objects.requireNonNull(appliedFileName, "appliedFileName must not be null"); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyUseCase.java new file mode 100644 index 0000000..f9e8612 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileCopyUseCase.java @@ -0,0 +1,58 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +/** + * Inbound-Port für die manuelle Kopie der Quelldatei eines bislang nicht erfolgreich + * verarbeiteten Dokuments mit benutzerdefiniertem Zieldateinamen. + *

+ * Ermöglicht dem Benutzer, ein Dokument trotz fehlgeschlagener oder übersprungener + * automatischer Verarbeitung manuell ins Zielverzeichnis zu überführen, ohne den + * regulären KI-gestützten Verarbeitungspfad erneut anzustoßen. Der Use-Case führt die + * Kopie als atomare Operation durch: Dateisystem und Persistenz werden entweder beide + * konsistent aktualisiert oder beide bleiben im vorherigen Zustand. + *

+ * Anwendungsbereich: Dokumente mit Status + * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#FAILED_RETRYABLE}, + * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#FAILED_FINAL}, + * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#READY_FOR_AI} oder + * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#PROPOSAL_READY}. + * Für Dokumente mit Status + * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} ist + * stattdessen {@link ManualFileRenameUseCase} zu verwenden, da dort bereits eine + * Zieldatei existiert. + *

+ * Konfliktsemantik: Existiert im Zielordner bereits eine Datei mit dem + * gewünschten Namen, wird anhand des Inhalts-Fingerprints entschieden: + *

+ *

+ * Quellintegrität: Die Quelldatei wird nicht verändert, verschoben oder + * gelöscht. Es entsteht ausschließlich eine Kopie im Zielordner. + *

+ * Erfolg: Bei erfolgreicher Operation wechselt der Dokumentstatus auf + * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS}. Das Dokument + * gilt damit fachlich als abgeschlossen und wird in zukünftigen Läufen mit + * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SKIPPED_ALREADY_PROCESSED} + * übersprungen. + */ +public interface ManualFileCopyUseCase { + + /** + * Kopiert die Quelldatei eines Dokuments mit benutzerdefiniertem Zieldateinamen ins + * Zielverzeichnis und aktualisiert den Dokument-Stammsatz auf + * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS}. + *

+ * Der Aufruf ist atomar: Entweder werden Dateisystem und Persistenz beide + * aktualisiert, oder beide bleiben unverändert. Bei einem Persistenzfehler nach + * erfolgreicher Kopie wird die Zieldatei im Rahmen eines Best-Effort-Rollbacks + * wieder entfernt. + * + * @param request die Kopieranfrage mit Fingerprint und gewünschtem Basisdateinamen; + * darf nicht null sein + * @return das Ergebnis der Kopieroperation; nie null + * @throws NullPointerException wenn {@code request} null ist + */ + ManualFileCopyResult copy(ManualFileCopyRequest request); +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCase.java new file mode 100644 index 0000000..000cd09 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCase.java @@ -0,0 +1,264 @@ +package de.gecheckt.pdf.umbenenner.application.usecase; + +import java.util.Objects; + +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyDocumentNotFound; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyFileSystemFailure; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyInvalidState; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyNoOpIdenticalTarget; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyPersistenceFailure; +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.ManualFileCopySuccess; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyUseCase; +import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown; +import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile; +import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger; +import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; +import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; + +/** + * Standardimplementierung von {@link ManualFileCopyUseCase}. + *

+ * Führt die manuelle Kopie der Quelldatei eines bislang nicht erfolgreich verarbeiteten + * Dokuments ins Zielverzeichnis als atomare Operation durch: Entweder werden Dateisystem + * und Persistenz beide aktualisiert, oder beide bleiben im vorherigen Zustand. + *

+ * Ablauf: + *

    + *
  1. Dokument-Stammsatz aus dem Repository laden und Zustand prüfen + * (Status muss verarbeitbar oder final fehlgeschlagen sein, nicht {@code SUCCESS}).
  2. + *
  3. Eindeutigen Zieldateinamen über {@link TargetFolderPort} auflösen.
  4. + *
  5. Wenn der Zielordner bereits eine Datei mit identischem Inhalt enthält, wird + * keine erneute Kopie geschrieben – der Stammsatz wird trotzdem konsistent + * auf {@code SUCCESS} gehoben.
  6. + *
  7. Andernfalls Quelldatei via {@link TargetFileCopyPort} unter dem aufgelösten + * Namen ins Zielverzeichnis kopieren.
  8. + *
  9. Dokument-Stammsatz in der Persistenz aktualisieren: Status auf + * {@link ProcessingStatus#SUCCESS}, {@code lastTargetPath} und + * {@code lastTargetFileName} setzen, {@code lastSuccessInstant} und + * {@code updatedAt} aktualisieren.
  10. + *
  11. Bei Persistenzfehler: Best-Effort-Rollback der geschriebenen Zieldatei.
  12. + *
+ *

+ * Eine manuelle Kopie ist ausschließlich für Dokumente mit nicht-erfolgreichem + * Status zulässig. Für Dokumente mit Status {@link ProcessingStatus#SUCCESS} + * ist die manuelle Umbenennung der bestehenden Zieldatei vorgesehen. + */ +public class DefaultManualFileCopyUseCase implements ManualFileCopyUseCase { + + private final DocumentRecordRepository repository; + private final TargetFolderPort targetFolderPort; + private final TargetFileCopyPort targetFileCopyPort; + private final UnitOfWorkPort unitOfWorkPort; + private final ClockPort clock; + private final ProcessingLogger logger; + + /** + * Erstellt den Use-Case mit allen erforderlichen Ports. + * + * @param repository Repository zum Lesen und Schreiben des Dokument-Stammsatzes; + * darf nicht null sein + * @param targetFolderPort Port zur Auflösung eindeutiger Zieldateinamen sowie + * Best-Effort-Aufräumen einer geschriebenen Zieldatei; + * darf nicht null sein + * @param targetFileCopyPort Port zum physischen Kopieren der Quelldatei in den + * Zielordner; darf nicht null sein + * @param unitOfWorkPort Port zur atomaren Persistenzaktualisierung; + * darf nicht null sein + * @param clock Port zur Abfrage des aktuellen Zeitstempels; + * darf nicht null sein + * @param logger für die Protokollierung von Betriebsereignissen; + * darf nicht null sein + * @throws NullPointerException wenn einer der Parameter null ist + */ + public DefaultManualFileCopyUseCase( + DocumentRecordRepository repository, + TargetFolderPort targetFolderPort, + TargetFileCopyPort targetFileCopyPort, + UnitOfWorkPort unitOfWorkPort, + ClockPort clock, + ProcessingLogger logger) { + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + this.targetFolderPort = Objects.requireNonNull(targetFolderPort, "targetFolderPort must not be null"); + this.targetFileCopyPort = Objects.requireNonNull(targetFileCopyPort, "targetFileCopyPort must not be null"); + this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort must not be null"); + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + this.logger = Objects.requireNonNull(logger, "logger must not be null"); + } + + /** + * Kopiert die Quelldatei eines Dokuments mit benutzerdefiniertem Zieldateinamen ins + * Zielverzeichnis und aktualisiert den Dokument-Stammsatz auf {@code SUCCESS}. + *

+ * Der Aufruf ist atomar: Entweder werden Dateisystem und Persistenz beide + * aktualisiert, oder beide bleiben unverändert. Bei einem Persistenzfehler nach + * erfolgreicher Kopie wird die geschriebene Zieldatei im Rahmen eines Best-Effort- + * Rollbacks wieder entfernt. + * + * @param request die Kopieranfrage mit Fingerprint und gewünschtem Basisdateinamen; + * darf nicht null sein + * @return das Ergebnis der Kopieroperation; nie null + * @throws NullPointerException wenn {@code request} null ist + */ + @Override + public ManualFileCopyResult copy(ManualFileCopyRequest request) { + Objects.requireNonNull(request, "request must not be null"); + + DocumentFingerprint fingerprint = request.fingerprint(); + String desiredFullName = request.desiredBaseFileName() + ".pdf"; + + logger.info("Manuelle Dateikopie angefordert: Fingerprint={}, Zielname={}", + fingerprint.sha256Hex(), desiredFullName); + + // Schritt 1: Dokument-Stammsatz laden und Zustand prüfen + DocumentRecordLookupResult lookupResult = repository.findByFingerprint(fingerprint); + + DocumentRecord record; + if (lookupResult instanceof DocumentTerminalFinalFailure terminalFailure) { + record = terminalFailure.record(); + } else if (lookupResult instanceof DocumentKnownProcessable known) { + record = known.record(); + ProcessingStatus status = record.overallStatus(); + if (status == ProcessingStatus.SUCCESS) { + // Defensiv: SUCCESS sollte über DocumentTerminalSuccess auflösen, nicht hier. + logger.warn("Manuelle Dateikopie verweigert: Dokument bereits SUCCESS. Fingerprint={}", + fingerprint.sha256Hex()); + return new ManualFileCopyInvalidState( + "Dokument ist bereits erfolgreich verarbeitet. Bitte die Umbenennung der " + + "Zieldatei verwenden."); + } + } else if (lookupResult instanceof DocumentTerminalSuccess) { + logger.warn("Manuelle Dateikopie verweigert: Dokument bereits SUCCESS. Fingerprint={}", + fingerprint.sha256Hex()); + return new ManualFileCopyInvalidState( + "Dokument ist bereits erfolgreich verarbeitet. Bitte die Umbenennung der " + + "Zieldatei verwenden."); + } else if (lookupResult instanceof DocumentUnknown) { + logger.warn("Manuelle Dateikopie verweigert: Dokument unbekannt. Fingerprint={}", + fingerprint.sha256Hex()); + return new ManualFileCopyDocumentNotFound( + "Kein Dokument mit dem angegebenen Fingerprint gefunden."); + } else if (lookupResult instanceof PersistenceLookupTechnicalFailure failure) { + logger.warn("Manuelle Dateikopie fehlgeschlagen: Lookup-Fehler. Fingerprint={}, Ursache={}", + fingerprint.sha256Hex(), failure.errorMessage()); + return new ManualFileCopyPersistenceFailure( + "Persistenzfehler beim Laden des Dokumentstammsatzes: " + failure.errorMessage()); + } else { + // Defensiv: nicht erreichbar dank sealed type, aber erforderlich für die Compiler- + // Vollständigkeitsprüfung in älteren Werkzeugen. + return new ManualFileCopyDocumentNotFound( + "Unbekanntes Lookup-Ergebnis: " + lookupResult.getClass().getSimpleName()); + } + + // Schritt 2: Eindeutigen Zieldateinamen über TargetFolderPort auflösen + TargetFilenameResolutionResult resolutionResult = + targetFolderPort.resolveUniqueFilename(desiredFullName, fingerprint); + + boolean noOpIdentical = false; + String appliedFileName; + + if (resolutionResult instanceof ExistingIdenticalTargetFile identical) { + noOpIdentical = true; + appliedFileName = identical.existingFilename(); + logger.info("Manuelle Dateikopie: Identische Datei bereits im Zielordner vorhanden. Fingerprint={}", + fingerprint.sha256Hex()); + } else if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) { + logger.warn("Manuelle Dateikopie fehlgeschlagen: Zielordnerzugriff. Fingerprint={}, Ursache={}", + fingerprint.sha256Hex(), folderFailure.errorMessage()); + return new ManualFileCopyFileSystemFailure( + "Zielordner nicht zugänglich: " + folderFailure.errorMessage()); + } else if (resolutionResult instanceof ResolvedTargetFilename resolved) { + appliedFileName = resolved.resolvedFilename(); + } else { + return new ManualFileCopyFileSystemFailure( + "Unbekanntes Auflösungsergebnis: " + resolutionResult.getClass().getSimpleName()); + } + + // Schritt 3: Quelldatei kopieren – nur wenn keine identische Zieldatei existiert + if (!noOpIdentical) { + var copyResult = targetFileCopyPort.copyToTarget( + record.lastKnownSourceLocator(), appliedFileName); + if (copyResult instanceof TargetFileCopyTechnicalFailure technicalFailure) { + logger.warn("Manuelle Dateikopie fehlgeschlagen: Dateisystemfehler. Fingerprint={}, Ursache={}", + fingerprint.sha256Hex(), technicalFailure.errorMessage()); + return new ManualFileCopyFileSystemFailure(technicalFailure.errorMessage()); + } + if (!(copyResult instanceof TargetFileCopySuccess)) { + return new ManualFileCopyFileSystemFailure( + "Unbekanntes Kopier-Ergebnis: " + copyResult.getClass().getSimpleName()); + } + } + + // Schritt 4: Dokument-Stammsatz aktualisieren + var now = clock.now(); + DocumentRecord updatedRecord = new DocumentRecord( + record.fingerprint(), + record.lastKnownSourceLocator(), + record.lastKnownSourceFileName(), + ProcessingStatus.SUCCESS, + record.failureCounters(), + record.lastFailureInstant(), + now, + record.createdAt(), + now, + targetFolderPort.getTargetFolderLocator(), + appliedFileName); + + try { + unitOfWorkPort.executeInTransaction(tx -> tx.updateDocumentRecord(updatedRecord)); + } catch (RuntimeException persistenceException) { + String errorMessage = persistenceException.getMessage() != null + ? persistenceException.getMessage() + : persistenceException.getClass().getSimpleName(); + + logger.warn("Manuelle Dateikopie: Persistenzfehler nach erfolgreicher Kopie. " + + "Versuche Rollback. Fingerprint={}, Ursache={}", + fingerprint.sha256Hex(), errorMessage); + + if (!noOpIdentical) { + // Best-Effort-Rollback: nur die *neu* geschriebene Zieldatei entfernen, + // niemals eine bereits zuvor vorhandene identische Datei. + try { + targetFolderPort.tryDeleteTargetFile(appliedFileName); + } catch (RuntimeException rollbackException) { + logger.error("Rollback der Zielkopie fehlgeschlagen: {}. " + + "Dateisystem und Persistenz sind möglicherweise inkonsistent. Fingerprint={}", + appliedFileName, fingerprint.sha256Hex()); + } + } + + return new ManualFileCopyPersistenceFailure( + "Persistenzfehler nach Kopie: " + errorMessage); + } + + boolean conflictSuffixApplied = !noOpIdentical && !appliedFileName.equals(desiredFullName); + + if (noOpIdentical) { + logger.info("Manuelle Dateikopie abgeschlossen ohne Schreibvorgang: identische Zieldatei {}.", + appliedFileName); + return new ManualFileCopyNoOpIdenticalTarget(appliedFileName); + } + + logger.info("Manuelle Dateikopie erfolgreich: {} (Suffix angewendet: {})", + appliedFileName, conflictSuffixApplied); + + return new ManualFileCopySuccess(appliedFileName, conflictSuffixApplied); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCaseTest.java new file mode 100644 index 0000000..40341c2 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCaseTest.java @@ -0,0 +1,566 @@ +package de.gecheckt.pdf.umbenenner.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyDocumentNotFound; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyFileSystemFailure; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyInvalidState; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyNoOpIdenticalTarget; +import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyPersistenceFailure; +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.ManualFileCopySuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown; +import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile; +import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters; +import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; +import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger; +import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort; +import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; +import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; + +/** + * Tests für {@link DefaultManualFileCopyUseCase}. + *

+ * Alle Mocks sind handgeschrieben (kein Mockito). Jeder Test prüft das zurückgegebene + * Ergebnis und die für das atomare Verhalten relevanten Port-Aufrufe. + */ +class DefaultManualFileCopyUseCaseTest { + + private static final DocumentFingerprint FINGERPRINT = + new DocumentFingerprint("b".repeat(64)); + + private static final String DESIRED_BASE = "2024-01-01 - Manuell benannt"; + private static final String DESIRED_FULL = DESIRED_BASE + ".pdf"; + + private static final Instant FIXED_NOW = Instant.parse("2024-06-01T10:00:00Z"); + + // ------------------------------------------------------------------------- + // Hilfsmethoden zum Erstellen von Testdaten + // ------------------------------------------------------------------------- + + private static DocumentRecord recordWithStatus(ProcessingStatus status) { + return new DocumentRecord( + FINGERPRINT, + new SourceDocumentLocator("/quelle/datei.pdf"), + "datei.pdf", + status, + FailureCounters.zero(), + FIXED_NOW.minusSeconds(60), + null, + FIXED_NOW.minusSeconds(120), + FIXED_NOW.minusSeconds(60), + null, + null); + } + + private static DocumentRecord successRecord() { + return new DocumentRecord( + FINGERPRINT, + new SourceDocumentLocator("/quelle/datei.pdf"), + "datei.pdf", + ProcessingStatus.SUCCESS, + FailureCounters.zero(), + null, + FIXED_NOW.minusSeconds(60), + FIXED_NOW.minusSeconds(120), + FIXED_NOW.minusSeconds(60), + "/zielordner", + "alt.pdf"); + } + + // ------------------------------------------------------------------------- + // Stub-Helfer + // ------------------------------------------------------------------------- + + private static ProcessingLogger noOpLogger() { + return new ProcessingLogger() { + @Override public void info(String msg, Object... args) { } + @Override public void debug(String msg, Object... args) { } + @Override public void debugSensitiveAiContent(String msg, Object... args) { } + @Override public void warn(String msg, Object... args) { } + @Override public void error(String msg, Object... args) { } + }; + } + + private static ClockPort fixedClock() { + return () -> FIXED_NOW; + } + + private static DocumentRecordRepository repositoryReturning(DocumentRecordLookupResult result) { + return new DocumentRecordRepository() { + @Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fp) { return result; } + @Override public void create(DocumentRecord r) { } + @Override public void update(DocumentRecord r) { } + @Override public void deleteByFingerprint(DocumentFingerprint fp) { } + }; + } + + private static TargetFolderPort folderPortReturning(TargetFilenameResolutionResult result) { + return new TargetFolderPort() { + @Override public String getTargetFolderLocator() { return "/zielordner"; } + @Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) { return result; } + @Override public void tryDeleteTargetFile(String name) { } + }; + } + + private static TargetFileCopyPort copyPortReturning(TargetFileCopyResult result) { + return (sourceLocator, resolvedFilename) -> result; + } + + private static UnitOfWorkPort alwaysSucceedingUnitOfWork() { + return ops -> ops.accept(new NoOpTransactionOperations()); + } + + private static UnitOfWorkPort throwingUnitOfWork(RuntimeException ex) { + return ops -> { throw ex; }; + } + + // ------------------------------------------------------------------------- + // Testfall 1: Erfolgreicher Pfad ohne Konflikt (FAILED_FINAL als Eingangsstatus) + // ------------------------------------------------------------------------- + + @Test + void copy_returnsSuccess_andUpdatesRecordToSuccess_whenNoConflict() { + List updatedRecords = new ArrayList<>(); + UnitOfWorkPort uow = ops -> ops.accept(new RecordCapturingTransactionOperations(updatedRecords)); + + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentTerminalFinalFailure( + recordWithStatus(ProcessingStatus.FAILED_FINAL))), + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + copyPortReturning(new TargetFileCopySuccess()), + uow, + fixedClock(), + noOpLogger()); + + ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE)); + + assertThat(result).isInstanceOf(ManualFileCopySuccess.class); + ManualFileCopySuccess success = (ManualFileCopySuccess) result; + assertThat(success.appliedFileName()).isEqualTo(DESIRED_FULL); + assertThat(success.conflictSuffixApplied()).isFalse(); + + assertThat(updatedRecords).hasSize(1); + DocumentRecord updated = updatedRecords.get(0); + assertThat(updated.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS); + assertThat(updated.lastTargetFileName()).isEqualTo(DESIRED_FULL); + assertThat(updated.lastTargetPath()).isEqualTo("/zielordner"); + assertThat(updated.lastSuccessInstant()).isEqualTo(FIXED_NOW); + assertThat(updated.updatedAt()).isEqualTo(FIXED_NOW); + } + + // ------------------------------------------------------------------------- + // Testfall 2: Konflikt mit anderer Datei → Suffix angewendet + // ------------------------------------------------------------------------- + + @Test + void copy_appliesSuffix_whenConflictWithDifferentFingerprint() { + String suffixedName = DESIRED_BASE + "(1).pdf"; + + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentTerminalFinalFailure( + recordWithStatus(ProcessingStatus.FAILED_FINAL))), + folderPortReturning(new ResolvedTargetFilename(suffixedName)), + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger()); + + ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE)); + + assertThat(result).isInstanceOf(ManualFileCopySuccess.class); + ManualFileCopySuccess success = (ManualFileCopySuccess) result; + assertThat(success.appliedFileName()).isEqualTo(suffixedName); + assertThat(success.conflictSuffixApplied()).isTrue(); + } + + // ------------------------------------------------------------------------- + // Testfall 3: Identische Zieldatei vorhanden → No-Op, Stammsatz wird trotzdem + // auf SUCCESS gehoben + // ------------------------------------------------------------------------- + + @Test + void copy_returnsNoOp_andUpdatesRecord_whenTargetFolderReportsIdenticalContent() { + List updatedRecords = new ArrayList<>(); + List copyAttempts = new ArrayList<>(); + + TargetFileCopyPort copyPort = (locator, name) -> { + copyAttempts.add(name); + return new TargetFileCopySuccess(); + }; + UnitOfWorkPort uow = ops -> ops.accept(new RecordCapturingTransactionOperations(updatedRecords)); + + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentTerminalFinalFailure( + recordWithStatus(ProcessingStatus.FAILED_FINAL))), + folderPortReturning(new ExistingIdenticalTargetFile(DESIRED_FULL)), + copyPort, + uow, + fixedClock(), + noOpLogger()); + + ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE)); + + assertThat(result).isInstanceOf(ManualFileCopyNoOpIdenticalTarget.class); + assertThat(((ManualFileCopyNoOpIdenticalTarget) result).existingFileName()).isEqualTo(DESIRED_FULL); + + // Es darf KEIN Schreibvorgang erfolgen, wenn identischer Inhalt schon existiert + assertThat(copyAttempts).isEmpty(); + + // Stammsatz wurde dennoch konsistent fortgeschrieben + assertThat(updatedRecords).hasSize(1); + assertThat(updatedRecords.get(0).overallStatus()).isEqualTo(ProcessingStatus.SUCCESS); + assertThat(updatedRecords.get(0).lastTargetFileName()).isEqualTo(DESIRED_FULL); + } + + // ------------------------------------------------------------------------- + // Testfall 4: Dokument unbekannt → DocumentNotFound + // ------------------------------------------------------------------------- + + @Test + void copy_returnsDocumentNotFound_whenRepositoryReturnsDocumentUnknown() { + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentUnknown()), + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger()); + + ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE)); + + assertThat(result).isInstanceOf(ManualFileCopyDocumentNotFound.class); + } + + // ------------------------------------------------------------------------- + // Testfall 5: Dokument bereits SUCCESS → InvalidState + // ------------------------------------------------------------------------- + + @Test + void copy_returnsInvalidState_whenDocumentAlreadySuccess() { + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentTerminalSuccess(successRecord())), + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger()); + + ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE)); + + assertThat(result).isInstanceOf(ManualFileCopyInvalidState.class); + } + + // ------------------------------------------------------------------------- + // Testfall 6: DocumentKnownProcessable mit FAILED_RETRYABLE → Erfolg + // ------------------------------------------------------------------------- + + @Test + void copy_acceptsDocumentKnownProcessable_withFailedRetryable() { + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentKnownProcessable( + recordWithStatus(ProcessingStatus.FAILED_RETRYABLE))), + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger()); + + ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE)); + + assertThat(result).isInstanceOf(ManualFileCopySuccess.class); + } + + // ------------------------------------------------------------------------- + // Testfall 7: Lookup-Fehler → PersistenceFailure + // ------------------------------------------------------------------------- + + @Test + void copy_returnsPersistenceFailure_whenLookupFails() { + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new PersistenceLookupTechnicalFailure( + "DB nicht erreichbar", new RuntimeException("boom"))), + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger()); + + ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE)); + + assertThat(result).isInstanceOf(ManualFileCopyPersistenceFailure.class); + } + + // ------------------------------------------------------------------------- + // Testfall 8: Zielordnerzugriff scheitert → FileSystemFailure + // ------------------------------------------------------------------------- + + @Test + void copy_returnsFileSystemFailure_whenTargetFolderTechnicalFailure() { + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentTerminalFinalFailure( + recordWithStatus(ProcessingStatus.FAILED_FINAL))), + folderPortReturning(new TargetFolderTechnicalFailure("Laufwerk weg")), + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger()); + + ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE)); + + assertThat(result).isInstanceOf(ManualFileCopyFileSystemFailure.class); + assertThat(((ManualFileCopyFileSystemFailure) result).message()).contains("Laufwerk weg"); + } + + // ------------------------------------------------------------------------- + // Testfall 9: Kopie scheitert → FileSystemFailure + // ------------------------------------------------------------------------- + + @Test + void copy_returnsFileSystemFailure_whenCopyFails() { + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentTerminalFinalFailure( + recordWithStatus(ProcessingStatus.FAILED_FINAL))), + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + copyPortReturning(new TargetFileCopyTechnicalFailure( + "Quelldatei nicht lesbar", true)), + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger()); + + ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE)); + + assertThat(result).isInstanceOf(ManualFileCopyFileSystemFailure.class); + assertThat(((ManualFileCopyFileSystemFailure) result).message()).contains("Quelldatei nicht lesbar"); + } + + // ------------------------------------------------------------------------- + // Testfall 10: Persistenzfehler nach erfolgreicher Kopie → Best-Effort-Rollback + // ------------------------------------------------------------------------- + + @Test + void copy_attemptsTargetFileDeletion_whenPersistenceFails() { + List deletedFiles = new ArrayList<>(); + TargetFolderPort folderPort = new TargetFolderPort() { + @Override public String getTargetFolderLocator() { return "/zielordner"; } + @Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) { + return new ResolvedTargetFilename(DESIRED_FULL); + } + @Override public void tryDeleteTargetFile(String name) { + deletedFiles.add(name); + } + }; + + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentTerminalFinalFailure( + recordWithStatus(ProcessingStatus.FAILED_FINAL))), + folderPort, + copyPortReturning(new TargetFileCopySuccess()), + throwingUnitOfWork(new DocumentPersistenceException("DB explodiert")), + fixedClock(), + noOpLogger()); + + ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE)); + + assertThat(result).isInstanceOf(ManualFileCopyPersistenceFailure.class); + // Best-Effort-Rollback: gerade geschriebene Datei löschen + assertThat(deletedFiles).containsExactly(DESIRED_FULL); + } + + // ------------------------------------------------------------------------- + // Testfall 11: Persistenzfehler bei No-Op (identischer Inhalt) → KEIN Löschen + // ------------------------------------------------------------------------- + + @Test + void copy_doesNotDeleteIdenticalTargetFile_whenPersistenceFailsAfterNoOp() { + List deletedFiles = new ArrayList<>(); + TargetFolderPort folderPort = new TargetFolderPort() { + @Override public String getTargetFolderLocator() { return "/zielordner"; } + @Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) { + return new ExistingIdenticalTargetFile(DESIRED_FULL); + } + @Override public void tryDeleteTargetFile(String name) { + deletedFiles.add(name); + } + }; + + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentTerminalFinalFailure( + recordWithStatus(ProcessingStatus.FAILED_FINAL))), + folderPort, + copyPortReturning(new TargetFileCopySuccess()), + throwingUnitOfWork(new DocumentPersistenceException("DB explodiert")), + fixedClock(), + noOpLogger()); + + ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE)); + + assertThat(result).isInstanceOf(ManualFileCopyPersistenceFailure.class); + // Identische, vor dem Lauf bereits vorhandene Datei darf nicht gelöscht werden + assertThat(deletedFiles).isEmpty(); + } + + // ------------------------------------------------------------------------- + // Testfall 12: .pdf-Erweiterung wird automatisch angehängt + // ------------------------------------------------------------------------- + + @Test + void copy_appendsPdfExtensionAutomatically() { + List baseNames = new ArrayList<>(); + TargetFolderPort folderPort = new TargetFolderPort() { + @Override public String getTargetFolderLocator() { return "/zielordner"; } + @Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) { + baseNames.add(baseName); + return new ResolvedTargetFilename(baseName); + } + @Override public void tryDeleteTargetFile(String name) { } + }; + + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentTerminalFinalFailure( + recordWithStatus(ProcessingStatus.FAILED_FINAL))), + folderPort, + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger()); + + useCase.copy(new ManualFileCopyRequest(FINGERPRINT, "Ohne Erweiterung")); + + assertThat(baseNames).hasSize(1); + assertThat(baseNames.get(0)).isEqualTo("Ohne Erweiterung.pdf"); + } + + // ------------------------------------------------------------------------- + // Konstruktor-Null-Guards + // ------------------------------------------------------------------------- + + @Test + void constructor_rejectsNullRepository() { + assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase( + null, + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger())); + } + + @Test + void constructor_rejectsNullTargetFolderPort() { + assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentUnknown()), + null, + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger())); + } + + @Test + void constructor_rejectsNullTargetFileCopyPort() { + assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentUnknown()), + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + null, + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger())); + } + + @Test + void constructor_rejectsNullUnitOfWorkPort() { + assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentUnknown()), + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + copyPortReturning(new TargetFileCopySuccess()), + null, + fixedClock(), + noOpLogger())); + } + + @Test + void constructor_rejectsNullClock() { + assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentUnknown()), + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + null, + noOpLogger())); + } + + @Test + void constructor_rejectsNullLogger() { + assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentUnknown()), + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + fixedClock(), + null)); + } + + @Test + void copy_rejectsNullRequest() { + DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( + repositoryReturning(new DocumentUnknown()), + folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)), + copyPortReturning(new TargetFileCopySuccess()), + alwaysSucceedingUnitOfWork(), + fixedClock(), + noOpLogger()); + + assertThatNullPointerException().isThrownBy(() -> useCase.copy(null)); + } + + // ------------------------------------------------------------------------- + // Hilfsklassen + // ------------------------------------------------------------------------- + + private static class NoOpTransactionOperations implements UnitOfWorkPort.TransactionOperations { + @Override public void saveProcessingAttempt(ProcessingAttempt attempt) { } + @Override public void createDocumentRecord(DocumentRecord record) { } + @Override public void updateDocumentRecord(DocumentRecord record) { } + @Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { } + } + + private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations { + private final List captured; + + RecordCapturingTransactionOperations(List captured) { + this.captured = captured; + } + + @Override public void saveProcessingAttempt(ProcessingAttempt attempt) { } + @Override public void createDocumentRecord(DocumentRecord record) { } + @Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); } + @Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { } + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 8a4f902..b7274fa 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -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. + *

+ * 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. *

@@ -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. + *

+ * 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}.