Fix #31: Manuelle Dateinamen-Eingabe für nicht verarbeitete Dateien
Nicht-erfolgreiche Zeilen (FAILED, FAILED_RETRYABLE, SKIPPED_FINAL_FAILURE) können im Detailbereich des Verarbeitungslauf-Tabs nun einen manuellen Zieldateinamen erhalten. Beim Bestätigen wird die Quelldatei mit dem benutzerdefinierten Namen ins Zielverzeichnis kopiert und der Stammsatz atomar auf SUCCESS gehoben. Neuer Inbound-Port ManualFileCopyUseCase mit sealed Result-Hierarchie, Default-Implementierung mit Best-Effort-Rollback bei Persistenzfehler sowie GUI-Brücke GuiManualFileCopyPort. Die GUI entscheidet anhand des Status zwischen Umbenennen (SUCCESS, SKIPPED_ALREADY_PROCESSED) und Kopieren (FAILED_*, SKIPPED_FINAL_FAILURE). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+24
@@ -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.GuiBatchRunLauncher;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
|
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.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.GuiManualFileRenamePort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
||||||
@@ -372,6 +373,13 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
private final GuiManualFileRenamePort manualFileRenamePort;
|
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
|
* Port used by the processing-run coordinator to resolve the historical processing context
|
||||||
* for skipped documents. Supplied by Bootstrap via the startup context.
|
* for skipped documents. Supplied by Bootstrap via the startup context.
|
||||||
@@ -453,6 +461,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
this.miniRunLauncher = effectiveContext.miniRunLauncher();
|
this.miniRunLauncher = effectiveContext.miniRunLauncher();
|
||||||
this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
|
this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
|
||||||
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
|
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
|
||||||
|
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
|
||||||
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
|
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
|
||||||
this.batchRunTab = new GuiBatchRunTab(
|
this.batchRunTab = new GuiBatchRunTab(
|
||||||
() -> this.batchRunLauncher,
|
() -> this.batchRunLauncher,
|
||||||
@@ -462,6 +471,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
this::isSavedConfigurationReady,
|
this::isSavedConfigurationReady,
|
||||||
this::applyBatchRunLockState,
|
this::applyBatchRunLockState,
|
||||||
() -> this.manualFileRenamePort,
|
() -> this.manualFileRenamePort,
|
||||||
|
() -> this.manualFileCopyPort,
|
||||||
() -> this.historicalDocumentContextPort,
|
() -> this.historicalDocumentContextPort,
|
||||||
this::editorSourceFolder,
|
this::editorSourceFolder,
|
||||||
this::editorTargetFolder);
|
this::editorTargetFolder);
|
||||||
@@ -495,6 +505,20 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
return manualFileRenamePort;
|
return manualFileRenamePort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Port für das manuelle Kopieren der Quelldatei eines bislang nicht
|
||||||
|
* erfolgreich verarbeiteten Dokuments ins Zielverzeichnis.
|
||||||
|
* <p>
|
||||||
|
* 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
|
* 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
|
* editor has never loaded a file from disk. The processing-run tab uses this value to
|
||||||
|
|||||||
+22
-3
@@ -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.GuiBatchRunLaunchOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
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.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.GuiManualFileRenamePort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
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
|
* to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted
|
||||||
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
|
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
|
||||||
* reset the persistence status of selected documents, and the
|
* 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
|
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
|
||||||
* context for documents that were skipped in the current run.
|
* context for documents that were skipped in the current run.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -63,6 +66,7 @@ public record GuiStartupContext(
|
|||||||
GuiMiniRunLauncher miniRunLauncher,
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
GuiResetDocumentStatusPort resetDocumentStatusPort,
|
GuiResetDocumentStatusPort resetDocumentStatusPort,
|
||||||
GuiManualFileRenamePort manualFileRenamePort,
|
GuiManualFileRenamePort manualFileRenamePort,
|
||||||
|
GuiManualFileCopyPort manualFileCopyPort,
|
||||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,6 +89,9 @@ public record GuiStartupContext(
|
|||||||
* documents; must not be {@code null}
|
* documents; must not be {@code null}
|
||||||
* @param manualFileRenamePort bridge that renames a target file manually from the GUI;
|
* @param manualFileRenamePort bridge that renames a target file manually from the GUI;
|
||||||
* must not be {@code null}
|
* 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
|
* @param historicalDocumentContextPort bridge that resolves the historical processing context
|
||||||
* for skipped documents; must not be {@code null}
|
* for skipped documents; must not be {@code null}
|
||||||
*/
|
*/
|
||||||
@@ -115,6 +122,8 @@ public record GuiStartupContext(
|
|||||||
"resetDocumentStatusPort must not be null");
|
"resetDocumentStatusPort must not be null");
|
||||||
manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort,
|
manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort,
|
||||||
"manualFileRenamePort must not be null");
|
"manualFileRenamePort must not be null");
|
||||||
|
manualFileCopyPort = Objects.requireNonNull(manualFileCopyPort,
|
||||||
|
"manualFileCopyPort must not be null");
|
||||||
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
|
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
|
||||||
"historicalDocumentContextPort must not be null");
|
"historicalDocumentContextPort must not be null");
|
||||||
}
|
}
|
||||||
@@ -157,6 +166,7 @@ public record GuiStartupContext(
|
|||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
||||||
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort());
|
noOpHistoricalDocumentContextPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +202,7 @@ public record GuiStartupContext(
|
|||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
||||||
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort());
|
noOpHistoricalDocumentContextPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +238,8 @@ public record GuiStartupContext(
|
|||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService,
|
technicalTestOrchestrator, correctionExecutionService,
|
||||||
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
||||||
rejectingManualFileRenamePort(), noOpHistoricalDocumentContextPort());
|
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
||||||
|
noOpHistoricalDocumentContextPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||||
@@ -253,7 +265,13 @@ public record GuiStartupContext(
|
|||||||
private static GuiManualFileRenamePort rejectingManualFileRenamePort() {
|
private static GuiManualFileRenamePort rejectingManualFileRenamePort() {
|
||||||
return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in
|
return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
.ManualFileRenameFileSystemFailure(
|
.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() {
|
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
|
||||||
@@ -334,6 +352,7 @@ public record GuiStartupContext(
|
|||||||
rejectingMiniRunLauncher(),
|
rejectingMiniRunLauncher(),
|
||||||
rejectingResetPort(),
|
rejectingResetPort(),
|
||||||
rejectingManualFileRenamePort(),
|
rejectingManualFileRenamePort(),
|
||||||
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort());
|
noOpHistoricalDocumentContextPort());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-7
@@ -144,8 +144,18 @@ public final class FileNameEditorPane {
|
|||||||
* <p>
|
* <p>
|
||||||
* Der KI-Vorschlag wird aus {@link GuiBatchRunResultRow#finalFileName()} abgeleitet,
|
* Der KI-Vorschlag wird aus {@link GuiBatchRunResultRow#finalFileName()} abgeleitet,
|
||||||
* der letzte gespeicherte Name aus {@link GuiBatchRunResultRow#effectiveFileName()}.
|
* der letzte gespeicherte Name aus {@link GuiBatchRunResultRow#effectiveFileName()}.
|
||||||
* Bei nicht editierbaren Status (FAILED_*, SKIPPED_*, reset-pending, kein SUCCESS)
|
* Editierbarkeitsregeln:
|
||||||
* wird das Feld deaktiviert.
|
* <ul>
|
||||||
|
* <li>{@code resetPending} → nicht editierbar.</li>
|
||||||
|
* <li>{@code SUCCESS} und {@code SKIPPED_ALREADY_PROCESSED} → editierbar, sofern
|
||||||
|
* ein bisher gespeicherter Zieldateiname vorliegt (Umbenennen einer existierenden
|
||||||
|
* Zieldatei).</li>
|
||||||
|
* <li>{@code FAILED_RETRYABLE}, {@code FAILED_PERMANENT} und
|
||||||
|
* {@code SKIPPED_FINAL_FAILURE} → editierbar; das Eingabefeld erlaubt die
|
||||||
|
* Eingabe eines manuellen Zieldateinamens auch dann, wenn (noch) kein
|
||||||
|
* Vorschlag oder gespeicherter Name vorliegt (Kopieren der Quelldatei
|
||||||
|
* mit manuellem Namen).</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* @param row die neu selektierte Zeile; {@code null} führt zu {@link #clearSelection()}
|
* @param row die neu selektierte Zeile; {@code null} führt zu {@link #clearSelection()}
|
||||||
* @param targetFolderPath Zielordner-Pfad für die Pfadlängen-Validierung; darf
|
* @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.aiProposal = stripPdfExtension(row.finalFileName());
|
||||||
this.lastSavedName = stripPdfExtension(row.effectiveFileName());
|
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;
|
this.selectionEditable = editable;
|
||||||
|
|
||||||
suppressValidation = true;
|
suppressValidation = true;
|
||||||
@@ -172,6 +192,18 @@ public final class FileNameEditorPane {
|
|||||||
refreshUiState();
|
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
|
* Leert die Komponente und deaktiviert die Eingabe. Wird aufgerufen wenn keine Zeile
|
||||||
* selektiert ist.
|
* selektiert ist.
|
||||||
@@ -407,11 +439,20 @@ public final class FileNameEditorPane {
|
|||||||
return folderLength + separatorLength + baseName.length() + PDF_EXTENSION.length();
|
return folderLength + separatorLength + baseName.length() + PDF_EXTENSION.length();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert {@code true}, wenn die Zeile fachlich für eine manuelle Dateinamens-Aktion
|
||||||
|
* editierbar ist.
|
||||||
|
* <p>
|
||||||
|
* 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) {
|
private static boolean isRowEditable(GuiBatchRunResultRow row) {
|
||||||
if (row.resetPending()) {
|
return !row.resetPending();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return row.status() == DocumentCompletionStatus.SUCCESS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Optional<String> stripPdfExtension(Optional<String> fileNameWithExtension) {
|
private static Optional<String> stripPdfExtension(Optional<String> fileNameWithExtension) {
|
||||||
|
|||||||
+184
-8
@@ -22,6 +22,14 @@ import org.apache.logging.log4j.LogManager;
|
|||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
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.ManualFileRenameDocumentNotFound;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameFileSystemFailure;
|
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameFileSystemFailure;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameInvalidState;
|
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameInvalidState;
|
||||||
@@ -195,6 +203,7 @@ public final class GuiBatchRunTab {
|
|||||||
private final Runnable onRunStateChanged;
|
private final Runnable onRunStateChanged;
|
||||||
private final GuiBatchRunCoordinator coordinator;
|
private final GuiBatchRunCoordinator coordinator;
|
||||||
private final Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier;
|
private final Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier;
|
||||||
|
private final Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier;
|
||||||
private final Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier;
|
private final Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier;
|
||||||
private final Supplier<Optional<Path>> sourceFolderSupplier;
|
private final Supplier<Optional<Path>> sourceFolderSupplier;
|
||||||
private final Supplier<Optional<String>> targetFolderSupplier;
|
private final Supplier<Optional<String>> targetFolderSupplier;
|
||||||
@@ -232,7 +241,10 @@ public final class GuiBatchRunTab {
|
|||||||
* null sein
|
* null sein
|
||||||
* @param onRunStateChanged Callback wenn das Lauf-Flag kippt; darf nicht
|
* @param onRunStateChanged Callback wenn das Lauf-Flag kippt; darf nicht
|
||||||
* null sein
|
* 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
|
* darf nicht null sein
|
||||||
* @param historicalDocumentContextPortSupplier Supplier für den historischen Kontext-Port;
|
* @param historicalDocumentContextPortSupplier Supplier für den historischen Kontext-Port;
|
||||||
* darf nicht null sein
|
* darf nicht null sein
|
||||||
@@ -248,6 +260,7 @@ public final class GuiBatchRunTab {
|
|||||||
BooleanSupplier savedConfigurationReadyCheck,
|
BooleanSupplier savedConfigurationReadyCheck,
|
||||||
Runnable onRunStateChanged,
|
Runnable onRunStateChanged,
|
||||||
Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier,
|
Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier,
|
||||||
|
Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
|
||||||
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
|
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
|
||||||
Supplier<Optional<Path>> sourceFolderSupplier,
|
Supplier<Optional<Path>> sourceFolderSupplier,
|
||||||
Supplier<Optional<String>> targetFolderSupplier) {
|
Supplier<Optional<String>> targetFolderSupplier) {
|
||||||
@@ -260,6 +273,8 @@ public final class GuiBatchRunTab {
|
|||||||
this.onRunStateChanged = Objects.requireNonNull(onRunStateChanged, "onRunStateChanged must not be null");
|
this.onRunStateChanged = Objects.requireNonNull(onRunStateChanged, "onRunStateChanged must not be null");
|
||||||
this.manualFileRenamePortSupplier = Objects.requireNonNull(
|
this.manualFileRenamePortSupplier = Objects.requireNonNull(
|
||||||
manualFileRenamePortSupplier, "manualFileRenamePortSupplier must not be null");
|
manualFileRenamePortSupplier, "manualFileRenamePortSupplier must not be null");
|
||||||
|
this.manualFileCopyPortSupplier = Objects.requireNonNull(
|
||||||
|
manualFileCopyPortSupplier, "manualFileCopyPortSupplier must not be null");
|
||||||
this.historicalDocumentContextPortSupplier = Objects.requireNonNull(
|
this.historicalDocumentContextPortSupplier = Objects.requireNonNull(
|
||||||
historicalDocumentContextPortSupplier, "historicalDocumentContextPortSupplier must not be null");
|
historicalDocumentContextPortSupplier, "historicalDocumentContextPortSupplier must not be null");
|
||||||
this.sourceFolderSupplier = Objects.requireNonNull(
|
this.sourceFolderSupplier = Objects.requireNonNull(
|
||||||
@@ -315,6 +330,7 @@ public final class GuiBatchRunTab {
|
|||||||
savedConfigurationReadyCheck,
|
savedConfigurationReadyCheck,
|
||||||
onRunStateChanged,
|
onRunStateChanged,
|
||||||
() -> GuiBatchRunTab::rejectingRename,
|
() -> GuiBatchRunTab::rejectingRename,
|
||||||
|
() -> GuiBatchRunTab::rejectingCopy,
|
||||||
() -> (cfgPath, fp) -> Optional.empty(),
|
() -> (cfgPath, fp) -> Optional.empty(),
|
||||||
Optional::empty,
|
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
|
* 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;
|
* @param launcherSupplier Supplier für den Batch-Lauf-Launcher;
|
||||||
* darf nicht null sein
|
* darf nicht null sein
|
||||||
@@ -348,6 +364,7 @@ public final class GuiBatchRunTab {
|
|||||||
savedConfigurationReadyCheck,
|
savedConfigurationReadyCheck,
|
||||||
onRunStateChanged,
|
onRunStateChanged,
|
||||||
() -> GuiBatchRunTab::rejectingRename,
|
() -> GuiBatchRunTab::rejectingRename,
|
||||||
|
() -> GuiBatchRunTab::rejectingCopy,
|
||||||
() -> (cfgPath, fp) -> Optional.empty(),
|
() -> (cfgPath, fp) -> Optional.empty(),
|
||||||
Optional::empty,
|
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
|
* Verarbeitet den Speichern-Callback des Dateiname-Editors. Läuft die eigentliche
|
||||||
* Hintergrund-Worker-Thread; das Ergebnis wird per {@code Platform.runLater}
|
* Aktion auf einem Hintergrund-Worker-Thread; das Ergebnis wird per
|
||||||
* zurück auf den FX-Thread übertragen.
|
* {@code Platform.runLater} zurück auf den FX-Thread übertragen.
|
||||||
|
* <p>
|
||||||
|
* Die konkrete Aktion hängt vom Status der Zeile ab:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code SUCCESS} und {@code SKIPPED_ALREADY_PROCESSED} → bestehende Zieldatei
|
||||||
|
* wird umbenannt (via {@link GuiManualFileRenamePort}).</li>
|
||||||
|
* <li>{@code FAILED_RETRYABLE}, {@code FAILED_PERMANENT} und
|
||||||
|
* {@code SKIPPED_FINAL_FAILURE} → Quelldatei wird mit dem manuellen Namen
|
||||||
|
* ins Zielverzeichnis kopiert (via {@link GuiManualFileCopyPort}); der
|
||||||
|
* Dokumentstatus wechselt anschließend auf {@code SUCCESS}.</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* @param desiredBaseName der gewünschte Basisname ohne {@code .pdf}-Endung
|
* @param desiredBaseName der gewünschte Basisname ohne {@code .pdf}-Endung
|
||||||
*/
|
*/
|
||||||
@@ -765,7 +792,23 @@ public final class GuiBatchRunTab {
|
|||||||
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
||||||
return;
|
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 =
|
ManualFileRenameRequest request =
|
||||||
new ManualFileRenameRequest(row.fingerprint(), desiredBaseName);
|
new ManualFileRenameRequest(row.fingerprint(), desiredBaseName);
|
||||||
|
|
||||||
@@ -773,11 +816,138 @@ public final class GuiBatchRunTab {
|
|||||||
row.effectiveFileName().orElse("?"), desiredBaseName);
|
row.effectiveFileName().orElse("?"), desiredBaseName);
|
||||||
|
|
||||||
renameExecutor.submit(() -> {
|
renameExecutor.submit(() -> {
|
||||||
ManualFileRenameResult result = port.rename(configPath, request);
|
ManualFileRenameResult result = renamePort.rename(configPath, request);
|
||||||
Platform.runLater(() -> handleRenameResult(result, row));
|
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.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
* Verarbeitet das Ergebnis einer manuellen Dateiumbenennung auf dem FX-Thread.
|
||||||
*
|
*
|
||||||
@@ -1304,7 +1474,13 @@ public final class GuiBatchRunTab {
|
|||||||
private static ManualFileRenameResult rejectingRename(
|
private static ManualFileRenameResult rejectingRename(
|
||||||
Path p, ManualFileRenameRequest req) {
|
Path p, ManualFileRenameRequest req) {
|
||||||
return new ManualFileRenameFileSystemFailure(
|
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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
+48
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <h2>Threadingmodell</h2>
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <h2>Exception-Vertrag</h2>
|
||||||
|
* <p>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
+105
-4
@@ -332,23 +332,124 @@ class FileNameEditorPaneTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Status FAILED → deaktiviert
|
// Editierbarkeit nach Status
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void loadSelection_failedStatus_disablesTextField() throws Exception {
|
void loadSelection_failedPermanentStatus_enablesTextFieldForManualCopy() throws Exception {
|
||||||
runOnFx(() -> {
|
runOnFx(() -> {
|
||||||
FileNameEditorPane pane = new FileNameEditorPane();
|
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(
|
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||||
"test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT,
|
"test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||||
Duration.ofMillis(1));
|
Duration.ofMillis(1));
|
||||||
pane.loadSelection(row, "C:\\target");
|
pane.loadSelection(row, "C:\\target");
|
||||||
assertTrue(pane.textField().isDisable(),
|
assertFalse(pane.textField().isDisable(),
|
||||||
"FAILED-Status soll TextField deaktivieren");
|
"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<String> 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
|
// Hilfsmethoden
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
+25
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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.:
|
||||||
|
* <ul>
|
||||||
|
* <li>der Dokumentstatus bereits {@code SUCCESS} ist (in diesem Fall ist eine
|
||||||
|
* Umbenennung der existierenden Zieldatei vorgesehen, keine neue Kopie), oder</li>
|
||||||
|
* <li>im Stammsatz keine verwertbare Quelldatei-Information hinterlegt ist.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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).
|
||||||
|
* <p>
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiegeltes Ergebnis-Interface für eine manuelle Dateikopie via
|
||||||
|
* {@link ManualFileCopyUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Mögliche Ergebnisse:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link ManualFileCopySuccess} – die Quelldatei wurde unter dem gewünschten
|
||||||
|
* Namen (ggf. mit Suffix) ins Zielverzeichnis kopiert und der Dokument-Stammsatz
|
||||||
|
* auf {@code SUCCESS} aktualisiert.</li>
|
||||||
|
* <li>{@link ManualFileCopyNoOpIdenticalTarget} – im Zielverzeichnis liegt bereits
|
||||||
|
* eine Datei mit identischem Inhalt; es ist keine Kopie erforderlich, der
|
||||||
|
* Stammsatz wurde dennoch konsistent auf {@code SUCCESS} gehoben.</li>
|
||||||
|
* <li>{@link ManualFileCopyDocumentNotFound} – das Dokument wurde in der Persistenz
|
||||||
|
* nicht gefunden.</li>
|
||||||
|
* <li>{@link ManualFileCopyInvalidState} – das Dokument befindet sich in einem
|
||||||
|
* ungültigen Zustand für eine manuelle Kopie (z. B. bereits {@code SUCCESS}).</li>
|
||||||
|
* <li>{@link ManualFileCopyFileSystemFailure} – ein technischer Dateisystemfehler
|
||||||
|
* ist während der Kopie aufgetreten (z. B. Quelldatei nicht vorhanden,
|
||||||
|
* fehlende Schreibrechte, gesperrte Datei).</li>
|
||||||
|
* <li>{@link ManualFileCopyPersistenceFailure} – die Persistenzaktualisierung ist
|
||||||
|
* nach erfolgreicher Kopie fehlgeschlagen (Zieldatei wurde im Rahmen eines
|
||||||
|
* Best-Effort-Rollbacks gelöscht).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public sealed interface ManualFileCopyResult
|
||||||
|
permits ManualFileCopySuccess,
|
||||||
|
ManualFileCopyNoOpIdenticalTarget,
|
||||||
|
ManualFileCopyDocumentNotFound,
|
||||||
|
ManualFileCopyInvalidState,
|
||||||
|
ManualFileCopyFileSystemFailure,
|
||||||
|
ManualFileCopyPersistenceFailure {
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis einer erfolgreich abgeschlossenen manuellen Dateikopie.
|
||||||
|
* <p>
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* <strong>Anwendungsbereich:</strong> 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.
|
||||||
|
* <p>
|
||||||
|
* <strong>Konfliktsemantik:</strong> Existiert im Zielordner bereits eine Datei mit dem
|
||||||
|
* gewünschten Namen, wird anhand des Inhalts-Fingerprints entschieden:
|
||||||
|
* <ul>
|
||||||
|
* <li>Gleicher Fingerprint → keine erneute Kopie ({@link ManualFileCopyNoOpIdenticalTarget}),
|
||||||
|
* Stammsatz wird trotzdem auf {@code SUCCESS} gehoben.</li>
|
||||||
|
* <li>Verschiedener Fingerprint → automatische Suffix-Vergabe ({@code (1)}, {@code (2)}, …).</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* <strong>Quellintegrität:</strong> Die Quelldatei wird nicht verändert, verschoben oder
|
||||||
|
* gelöscht. Es entsteht ausschließlich eine Kopie im Zielordner.
|
||||||
|
* <p>
|
||||||
|
* <strong>Erfolg:</strong> 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}.
|
||||||
|
* <p>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
+264
@@ -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}.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* <strong>Ablauf:</strong>
|
||||||
|
* <ol>
|
||||||
|
* <li>Dokument-Stammsatz aus dem Repository laden und Zustand prüfen
|
||||||
|
* (Status muss verarbeitbar oder final fehlgeschlagen sein, nicht {@code SUCCESS}).</li>
|
||||||
|
* <li>Eindeutigen Zieldateinamen über {@link TargetFolderPort} auflösen.</li>
|
||||||
|
* <li>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.</li>
|
||||||
|
* <li>Andernfalls Quelldatei via {@link TargetFileCopyPort} unter dem aufgelösten
|
||||||
|
* Namen ins Zielverzeichnis kopieren.</li>
|
||||||
|
* <li>Dokument-Stammsatz in der Persistenz aktualisieren: Status auf
|
||||||
|
* {@link ProcessingStatus#SUCCESS}, {@code lastTargetPath} und
|
||||||
|
* {@code lastTargetFileName} setzen, {@code lastSuccessInstant} und
|
||||||
|
* {@code updatedAt} aktualisieren.</li>
|
||||||
|
* <li>Bei Persistenzfehler: Best-Effort-Rollback der geschriebenen Zieldatei.</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* 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}.
|
||||||
|
* <p>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+566
@@ -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}.
|
||||||
|
* <p>
|
||||||
|
* 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<DocumentRecord> 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<DocumentRecord> updatedRecords = new ArrayList<>();
|
||||||
|
List<String> 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<String> 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<String> 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<String> 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<DocumentRecord> captured;
|
||||||
|
|
||||||
|
RecordCapturingTransactionOperations(List<DocumentRecord> 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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
-1
@@ -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.GuiBatchRunLaunchOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
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.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.GuiManualFileRenamePort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
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.config.startup.StartConfiguration;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
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.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.ManualFileRenameRequest;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
|
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameUseCase;
|
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.AiResponseValidator;
|
||||||
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
|
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
|
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.DefaultManualFileRenameUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
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 =
|
de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort resetPort =
|
||||||
this::resetDocumentStatusForGui;
|
this::resetDocumentStatusForGui;
|
||||||
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
|
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
|
||||||
|
GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy;
|
||||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
|
||||||
|
|
||||||
if (configPathOverride.isEmpty()) {
|
if (configPathOverride.isEmpty()) {
|
||||||
@@ -705,6 +711,7 @@ public class BootstrapRunner {
|
|||||||
miniRunLauncher,
|
miniRunLauncher,
|
||||||
resetPort,
|
resetPort,
|
||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
|
manualCopyPort,
|
||||||
historicalDocumentContextPort);
|
historicalDocumentContextPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,6 +735,7 @@ public class BootstrapRunner {
|
|||||||
miniRunLauncher,
|
miniRunLauncher,
|
||||||
resetPort,
|
resetPort,
|
||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
|
manualCopyPort,
|
||||||
historicalDocumentContextPort);
|
historicalDocumentContextPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,7 +745,8 @@ public class BootstrapRunner {
|
|||||||
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
|
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
|
||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
miniRunLauncher, resetPort, manualRenamePort, historicalDocumentContextPort);
|
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
|
||||||
|
historicalDocumentContextPort);
|
||||||
} catch (GuiConfigurationLoadException e) {
|
} catch (GuiConfigurationLoadException e) {
|
||||||
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
||||||
e.getMessage(), e);
|
e.getMessage(), e);
|
||||||
@@ -756,6 +765,7 @@ public class BootstrapRunner {
|
|||||||
miniRunLauncher,
|
miniRunLauncher,
|
||||||
resetPort,
|
resetPort,
|
||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
|
manualCopyPort,
|
||||||
historicalDocumentContextPort);
|
historicalDocumentContextPort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1007,6 +1017,40 @@ public class BootstrapRunner {
|
|||||||
processingLogger);
|
processingLogger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen vollständig verdrahteten {@link ManualFileCopyUseCase} für den
|
||||||
|
* gegebenen Startkonfigurations-Stand.
|
||||||
|
* <p>
|
||||||
|
* Teilt die Wiring-Konventionen mit dem Batch-Pfad: SQLite-URL-Aufbau, Adapter-Instanzen
|
||||||
|
* und Logger-Konfiguration werden nach dem gleichen Muster erzeugt.
|
||||||
|
*
|
||||||
|
* @param startConfig die validierte Startkonfiguration; darf nicht null sein
|
||||||
|
* @return ein einsatzbereiter Use-Case; nie null
|
||||||
|
*/
|
||||||
|
private ManualFileCopyUseCase buildProductionManualFileCopyUseCase(
|
||||||
|
StartConfiguration startConfig) {
|
||||||
|
String jdbcUrl = buildJdbcUrl(startConfig);
|
||||||
|
DocumentRecordRepository documentRecordRepository =
|
||||||
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
|
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
|
TargetFolderPort targetFolderPort =
|
||||||
|
new FilesystemTargetFolderAdapter(startConfig.targetFolder());
|
||||||
|
TargetFileCopyPort targetFileCopyPort =
|
||||||
|
new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
|
||||||
|
ClockPort clockPort = new SystemClockAdapter();
|
||||||
|
AiContentSensitivity aiContentSensitivity =
|
||||||
|
resolveAiContentSensitivity(startConfig.logAiSensitive());
|
||||||
|
ProcessingLogger processingLogger = new Log4jProcessingLogger(
|
||||||
|
DefaultManualFileCopyUseCase.class, aiContentSensitivity);
|
||||||
|
return new DefaultManualFileCopyUseCase(
|
||||||
|
documentRecordRepository,
|
||||||
|
targetFolderPort,
|
||||||
|
targetFileCopyPort,
|
||||||
|
unitOfWorkPort,
|
||||||
|
clockPort,
|
||||||
|
processingLogger);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Führt eine manuelle Umbenennung einer Zieldatei durch, ausgelöst von der GUI.
|
* Führt eine manuelle Umbenennung einer Zieldatei durch, ausgelöst von der GUI.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -1070,6 +1114,70 @@ public class BootstrapRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt eine manuelle Kopie der Quelldatei eines bislang nicht erfolgreich
|
||||||
|
* verarbeiteten Dokuments mit benutzerdefiniertem Zieldateinamen ins Zielverzeichnis
|
||||||
|
* durch, ausgelöst von der GUI.
|
||||||
|
* <p>
|
||||||
|
* Lädt und validiert die Konfiguration aus {@code configFilePath}, baut den Use-Case
|
||||||
|
* auf und delegiert die Operation. Alle Fehler beim Laden oder Validieren der
|
||||||
|
* Konfiguration werden als strukturiertes {@link ManualFileCopyResult} zurückgegeben.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur {@code .properties}-Datei; muss existieren
|
||||||
|
* @param request die Kopieranfrage; darf nicht null sein
|
||||||
|
* @return das Ergebnis der Kopieroperation; nie null
|
||||||
|
*/
|
||||||
|
ManualFileCopyResult performGuiManualFileCopy(
|
||||||
|
Path configFilePath,
|
||||||
|
ManualFileCopyRequest request) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(request, "request must not be null");
|
||||||
|
LOG.info("GUI-Dateikopie: Anfrage für Fingerprint={}, Zielname={}.",
|
||||||
|
request.fingerprint().sha256Hex(), request.desiredBaseFileName());
|
||||||
|
|
||||||
|
if (!Files.exists(configFilePath)) {
|
||||||
|
String msg = "Konfigurationsdatei nicht gefunden: " + configFilePath;
|
||||||
|
LOG.error("GUI-Dateikopie: {}", msg);
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.ManualFileCopyFileSystemFailure(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
initializeSchema(config);
|
||||||
|
ManualFileCopyUseCase useCase = buildProductionManualFileCopyUseCase(config);
|
||||||
|
ManualFileCopyResult result = useCase.copy(request);
|
||||||
|
LOG.info("GUI-Dateikopie abgeschlossen: Ergebnis={}.", result.getClass().getSimpleName());
|
||||||
|
return result;
|
||||||
|
} catch (ConfigurationLoadingException e) {
|
||||||
|
LOG.error("GUI-Dateikopie: Konfiguration konnte nicht geladen werden: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.ManualFileCopyPersistenceFailure(
|
||||||
|
"Konfiguration konnte nicht geladen werden: " + e.getMessage());
|
||||||
|
} catch (InvalidStartConfigurationException e) {
|
||||||
|
LOG.error("GUI-Dateikopie: Konfiguration ist nicht lauffähig: {}", e.getMessage());
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.ManualFileCopyPersistenceFailure(
|
||||||
|
"Die Konfiguration ist nicht lauffähig: " + e.getMessage());
|
||||||
|
} catch (DocumentPersistenceException e) {
|
||||||
|
LOG.error("GUI-Dateikopie: SQLite-Initialisierung fehlgeschlagen: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.ManualFileCopyPersistenceFailure(
|
||||||
|
"SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Dateikopie: Unerwarteter Fehler: {}", e.getMessage(), e);
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.ManualFileCopyFileSystemFailure(
|
||||||
|
"Unerwarteter Fehler: "
|
||||||
|
+ (e.getMessage() == null
|
||||||
|
? e.getClass().getSimpleName()
|
||||||
|
: e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the historical processing context for the document identified by
|
* Resolves the historical processing context for the document identified by
|
||||||
* {@code fingerprint}, using the configuration at {@code configFilePath}.
|
* {@code fingerprint}, using the configuration at {@code configFilePath}.
|
||||||
|
|||||||
Reference in New Issue
Block a user