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: + *
+ * 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
+ * Die konkrete Aktion hängt vom Status der Zeile ab:
+ *
+ * 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.
+ *
+ *
+ * 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.
+ *
+ *
+ * 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
+ * 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.:
+ *
+ * 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:
+ *
+ * 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:
+ *
+ * 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
+ * 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}.
+ *
*
* @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.
+ * Threadingmodell
+ * Exception-Vertrag
+ *
+ *
+ *
+ * @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.
+ *
+ *
+ */
+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.
+ *
+ *
+ *
+ *
+ *