V2.9: Integrierte PDF-Vorschau und editierbarer Dateiname im Verarbeitungslauf

Neu im Tab "Verarbeitungslauf":
- Integrierte PDF-Vorschau der Quelldatei mit Lazy Rendering (Seite 1 sofort,
  weitere Seiten on-demand), Cache pro Selektion, "latest preview request wins"
- Editierbarer KI-Dateinamenvorschlag mit Live-Validierung, Dirty-State-Dialog
  bei Zeilen-/Tabwechsel, Schließen und Laufstart, atomare FS+DB-Transaktion
  inkl. Rollback und Fingerprint-basierter Konfliktauflösung

Architektur:
- Neuer Application-Use-Case ManualFileRenameUseCase und Outbound-Port
  TargetFileRenamePort mit Filesystem-Adapter
- Neuer GuiManualFileRenamePort, verdrahtet im Bootstrap
- GuiBatchRunResultRow um correctedFileName erweitert
- GuiBatchRunTab auf SplitPane-Layout (60/40) umgebaut, Detail-Panel mit
  KI-Begründung, FileNameEditorPane und PdfPreviewPane
- Spike-Code (PdfViewerSpike) entfernt, produktive Implementierung ersetzt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 12:30:55 +02:00
parent f6b265b370
commit d3fbfc4094
34 changed files with 3823 additions and 188 deletions
@@ -0,0 +1,25 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.util.Objects;
/**
* Ergebnis, wenn das zu umbennende 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 ManualFileRenameDocumentNotFound(String reason) implements ManualFileRenameResult {
/**
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
*
* @throws NullPointerException wenn {@code reason} null ist
*/
public ManualFileRenameDocumentNotFound {
Objects.requireNonNull(reason, "reason must not be null");
}
}
@@ -0,0 +1,28 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.util.Objects;
/**
* Ergebnis, wenn die Umbenennung der Zieldatei 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 ManualFileRenameFileSystemFailure(String message) implements ManualFileRenameResult {
/**
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
*
* @throws NullPointerException wenn {@code message} null ist
*/
public ManualFileRenameFileSystemFailure {
Objects.requireNonNull(message, "message must not be null");
}
}
@@ -0,0 +1,31 @@
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
* Umbenennung befindet.
* <p>
* Eine Umbenennung ist nur möglich, wenn das Dokument den Status
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} hat und
* ein gültiger {@code lastTargetFileName} sowie {@code lastTargetPath} vorhanden sind.
* Dieses Ergebnis wird zurückgegeben, wenn eine dieser Voraussetzungen nicht erfüllt ist,
* z. B.:
* <ul>
* <li>der Status ist nicht {@code SUCCESS} (z. B. {@code FAILED_FINAL}), oder</li>
* <li>{@code lastTargetFileName} oder {@code lastTargetPath} ist {@code null}.</li>
* </ul>
*
* @param reason menschenlesbare Begründung für den ungültigen Zustand; nie null
*/
public record ManualFileRenameInvalidState(String reason) implements ManualFileRenameResult {
/**
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
*
* @throws NullPointerException wenn {@code reason} null ist
*/
public ManualFileRenameInvalidState {
Objects.requireNonNull(reason, "reason must not be null");
}
}
@@ -0,0 +1,32 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.util.Objects;
/**
* Ergebnis, wenn keine Umbenennung notwendig ist, weil die Zieldatei mit dem
* gewünschten Namen bereits vorhanden ist und denselben Inhalt hat (gleicher Fingerprint).
* <p>
* Dieses Ergebnis tritt auf, wenn:
* <ul>
* <li>der gewünschte neue Dateiname bereits dem aktuellen {@code lastTargetFileName}
* entspricht, oder</li>
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort#resolveUniqueFilename}
* einen {@link de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile}
* zurückliefert (identischer Fingerprint im Zielordner).</li>
* </ul>
* Weder Dateisystem noch Persistenz werden in diesem Fall verändert.
*
* @param existingFileName der Dateiname (ohne Pfad) der bereits vorhandenen identischen
* Datei; nie null
*/
public record ManualFileRenameNoOpIdenticalTarget(String existingFileName) implements ManualFileRenameResult {
/**
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
*
* @throws NullPointerException wenn {@code existingFileName} null ist
*/
public ManualFileRenameNoOpIdenticalTarget {
Objects.requireNonNull(existingFileName, "existingFileName must not be null");
}
}
@@ -0,0 +1,29 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.util.Objects;
/**
* Ergebnis, wenn die Persistenzaktualisierung nach erfolgreicher Dateisystem-Umbenennung
* fehlgeschlagen ist.
* <p>
* Gibt an, dass die Zieldatei im Dateisystem erfolgreich umbenannt werden konnte, jedoch
* die anschließende Aktualisierung des Dokument-Stammsatzes in der Persistenz fehlgeschlagen
* ist. Der Use-Case versucht in diesem Fall, die Dateisystem-Umbenennung rückgängig zu
* machen (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 ManualFileRenamePersistenceFailure(String message) implements ManualFileRenameResult {
/**
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
*
* @throws NullPointerException wenn {@code message} null ist
*/
public ManualFileRenamePersistenceFailure {
Objects.requireNonNull(message, "message must not be null");
}
}
@@ -0,0 +1,36 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Anfrage an den {@link ManualFileRenameUseCase} zum manuellen Umbenennen einer Zieldatei.
* <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, das umbenannt 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 ManualFileRenameRequest(
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 ManualFileRenameRequest {
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");
}
}
}
@@ -0,0 +1,32 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Versiegeltes Ergebnis-Interface für eine manuelle Dateiumbenennung via
* {@link ManualFileRenameUseCase}.
* <p>
* Mögliche Ergebnisse:
* <ul>
* <li>{@link ManualFileRenameSuccess} Umbenennung war erfolgreich (ggf. mit Suffix).</li>
* <li>{@link ManualFileRenameNoOpIdenticalTarget} keine Aktion erforderlich, da die
* Zieldatei bereits denselben Inhalt hat.</li>
* <li>{@link ManualFileRenameDocumentNotFound} das Dokument wurde in der Persistenz
* nicht gefunden.</li>
* <li>{@link ManualFileRenameInvalidState} das Dokument befindet sich in einem
* ungültigen Zustand für eine Umbenennung.</li>
* <li>{@link ManualFileRenameSourceFileMissing} die bisherige Zieldatei existiert
* im Zielordner nicht mehr.</li>
* <li>{@link ManualFileRenameFileSystemFailure} ein technischer Dateisystemfehler
* ist aufgetreten.</li>
* <li>{@link ManualFileRenamePersistenceFailure} die Persistenzaktualisierung ist
* fehlgeschlagen (Dateisystem ggf. zurückgerollt).</li>
* </ul>
*/
public sealed interface ManualFileRenameResult
permits ManualFileRenameSuccess,
ManualFileRenameNoOpIdenticalTarget,
ManualFileRenameDocumentNotFound,
ManualFileRenameInvalidState,
ManualFileRenameSourceFileMissing,
ManualFileRenameFileSystemFailure,
ManualFileRenamePersistenceFailure {
}
@@ -0,0 +1,29 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.util.Objects;
/**
* Ergebnis, wenn die bisherige Zieldatei im Zielordner nicht mehr vorhanden ist.
* <p>
* Gibt an, dass die in der Persistenz gespeicherte Zieldatei ({@code lastTargetFileName})
* zum Zeitpunkt des Umbenennungsversuchs nicht mehr im Zielordner existiert. Dies kann
* eintreten, wenn die Datei zwischenzeitlich von einem externen Prozess gelöscht oder
* verschoben wurde.
* <p>
* Gemäß dem Alles-oder-Nichts-Prinzip wird in diesem Fall die Persistenz nicht
* aktualisiert.
*
* @param expectedFileName der Dateiname (ohne Pfad), der im Zielordner erwartet wurde,
* aber nicht gefunden wurde; nie null
*/
public record ManualFileRenameSourceFileMissing(String expectedFileName) implements ManualFileRenameResult {
/**
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
*
* @throws NullPointerException wenn {@code expectedFileName} null ist
*/
public ManualFileRenameSourceFileMissing {
Objects.requireNonNull(expectedFileName, "expectedFileName must not be null");
}
}
@@ -0,0 +1,34 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.util.Objects;
/**
* Ergebnis einer erfolgreich abgeschlossenen manuellen Dateiumbenennung.
* <p>
* Gibt an, dass die Zieldatei im Dateisystem erfolgreich umbenannt und der
* Dokument-Stammsatz in der Persistenz aktualisiert wurde.
*
* @param previousFileName der Dateiname (ohne Pfad) vor der Umbenennung; nie null
* @param appliedFileName der tatsächlich angewendete Dateiname (ohne Pfad) nach der
* Umbenennung; 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 ManualFileRenameSuccess(
String previousFileName,
String appliedFileName,
boolean conflictSuffixApplied) implements ManualFileRenameResult {
/**
* Kompakter Konstruktor zur Validierung der Pflichtfelder.
*
* @throws NullPointerException wenn {@code previousFileName} oder
* {@code appliedFileName} null sind
*/
public ManualFileRenameSuccess {
Objects.requireNonNull(previousFileName, "previousFileName must not be null");
Objects.requireNonNull(appliedFileName, "appliedFileName must not be null");
}
}
@@ -0,0 +1,39 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Inbound-Port für die manuelle Umbenennung einer bereits erfolgreich verarbeiteten
* Zieldatei.
* <p>
* Ermöglicht dem Benutzer, den von der KI vorgeschlagenen Dateinamen nachträglich
* zu korrigieren. Der Use-Case führt die Umbenennung als atomare Operation durch:
* Dateisystem und Persistenz werden entweder beide aktualisiert oder beide bleiben
* im vorherigen Zustand.
* <p>
* Eine Umbenennung ist nur für Dokumente mit Status
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} zulässig,
* die einen bekannten letzten Zieldateinamen haben.
* <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 Aktion ({@link ManualFileRenameNoOpIdenticalTarget})</li>
* <li>Verschiedener Fingerprint → automatische Suffix-Vergabe ({@code (1)}, {@code (2)}, …)</li>
* </ul>
*/
public interface ManualFileRenameUseCase {
/**
* Benennt die Zieldatei eines erfolgreich verarbeiteten Dokuments manuell um.
* <p>
* Der Aufruf ist atomar: Entweder werden Dateisystem und Persistenz beide
* aktualisiert, oder beide bleiben unverändert. Bei einem Persistenzfehler
* nach erfolgreicher Dateisystem-Umbenennung wird die Umbenennung im Dateisystem
* im Rahmen eines Best-Effort-Rollbacks rückgängig gemacht.
*
* @param request die Umbenennungsanfrage mit Fingerprint und gewünschtem Basisdateinamen;
* darf nicht null sein
* @return das Ergebnis der Umbenennung; nie null
* @throws NullPointerException wenn {@code request} null ist
*/
ManualFileRenameResult rename(ManualFileRenameRequest request);
}
@@ -0,0 +1,26 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.util.Objects;
/**
* Ergebnis einer fehlgeschlagenen Umbenennung, weil die Quelldatei im Zielordner
* nicht mehr vorhanden ist.
* <p>
* Gibt an, dass {@link TargetFileRenamePort#rename(String, String)} die Datei mit dem
* angegebenen {@code oldFileName} nicht gefunden hat. Dies kann eintreten, wenn die
* Datei zwischenzeitlich von einem anderen Prozess gelöscht oder verschoben wurde.
*
* @param oldFileName der Dateiname (ohne Pfad), der im Zielordner erwartet wurde, aber
* nicht gefunden wurde; nie null
*/
public record TargetFileRenameFailureFileNotFound(String oldFileName) implements TargetFileRenameResult {
/**
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
*
* @throws NullPointerException wenn {@code oldFileName} null ist
*/
public TargetFileRenameFailureFileNotFound {
Objects.requireNonNull(oldFileName, "oldFileName must not be null");
}
}
@@ -0,0 +1,27 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.util.Objects;
/**
* Ergebnis einer fehlgeschlagenen Umbenennung, weil der gewünschte neue Dateiname im
* Zielordner bereits existiert und nicht die gleiche Datei ist.
* <p>
* Dieser Zustand sollte durch eine vorherige Auflösung via
* {@link TargetFolderPort#resolveUniqueFilename(String, de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint)}
* normalerweise verhindert werden. Das Ergebnis dient der defensiven Fehlerbehandlung
* für Race-Conditions oder unvorhergesehene Konkurrenz durch andere Prozesse.
*
* @param newFileName der Dateiname (ohne Pfad), der bereits im Zielordner existiert;
* nie null
*/
public record TargetFileRenameFailureTargetExists(String newFileName) implements TargetFileRenameResult {
/**
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
*
* @throws NullPointerException wenn {@code newFileName} null ist
*/
public TargetFileRenameFailureTargetExists {
Objects.requireNonNull(newFileName, "newFileName must not be null");
}
}
@@ -0,0 +1,41 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Outbound-Port für das Umbenennen einer bereits existierenden Datei im Zielordner.
* <p>
* Dieser Port kapselt die reine Dateisystem-Operation des Umbenennens. Er ist
* provider-neutral und kennt ausschließlich opake Dateinamen-Strings keine
* {@code Path}-, {@code File}- oder NIO-Typen. Die Übersetzung in tatsächliche
* Dateisystemoperationen obliegt ausschließlich der Adapter-Implementierung.
* <p>
* <strong>Zuständigkeit:</strong> Dieser Port ist nicht für die Suffix-Logik bei
* Namenskollisionen zuständig. Die Auflösung eines eindeutigen Zieldateinamens
* (inkl. Suffix-Vergabe) erfolgt über
* {@link TargetFolderPort#resolveUniqueFilename(String, de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint)}.
* <p>
* <strong>Architekturgrenze:</strong> Keine {@code Path}-, {@code File}-, NIO- oder
* JDBC-Typen erscheinen in diesem Interface oder in Typen, die es referenziert.
*/
public interface TargetFileRenamePort {
/**
* Benennt eine existierende Datei im Zielordner von {@code oldFileName} zu
* {@code newFileName} um.
* <p>
* Die Methode erwartet, dass {@code oldFileName} im Zielordner vorhanden ist.
* Ist {@code newFileName} bereits vorhanden und nicht identisch mit {@code oldFileName},
* wird {@link TargetFileRenameFailureTargetExists} zurückgegeben. Die eigentliche
* Konfliktvermeidung (Suffix-Vergabe) liegt im Verantwortungsbereich des Aufrufers.
*
* @param oldFileName der aktuell im Zielordner vorhandene Dateiname (ohne Pfad);
* darf nicht null oder leer sein
* @param newFileName der gewünschte neue Dateiname (ohne Pfad);
* darf nicht null oder leer sein
* @return {@link TargetFileRenameSuccess} bei Erfolg,
* {@link TargetFileRenameFailureFileNotFound} wenn {@code oldFileName} nicht existiert,
* {@link TargetFileRenameFailureTargetExists} wenn {@code newFileName} bereits durch
* eine andere Datei belegt ist,
* {@link TargetFileRenameTechnicalFailure} bei einem sonstigen technischen Fehler
*/
TargetFileRenameResult rename(String oldFileName, String newFileName);
}
@@ -0,0 +1,23 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Versiegeltes Ergebnis-Interface für eine Umbenennung einer Zieldatei via
* {@link TargetFileRenamePort}.
* <p>
* Mögliche Ergebnisse:
* <ul>
* <li>{@link TargetFileRenameSuccess} die Umbenennung war erfolgreich.</li>
* <li>{@link TargetFileRenameFailureFileNotFound} die ursprüngliche Datei wurde
* im Zielordner nicht gefunden.</li>
* <li>{@link TargetFileRenameFailureTargetExists} der gewünschte neue Dateiname
* existiert bereits und gehört zu einer anderen Datei.</li>
* <li>{@link TargetFileRenameTechnicalFailure} ein technischer Fehler beim
* Dateisystemzugriff ist aufgetreten.</li>
* </ul>
*/
public sealed interface TargetFileRenameResult
permits TargetFileRenameSuccess,
TargetFileRenameFailureFileNotFound,
TargetFileRenameFailureTargetExists,
TargetFileRenameTechnicalFailure {
}
@@ -0,0 +1,10 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Ergebnis einer erfolgreichen Umbenennung einer Zieldatei via {@link TargetFileRenamePort}.
* <p>
* Gibt an, dass die Datei im Zielordner erfolgreich von ihrem alten auf den neuen
* Dateinamen umbenannt wurde.
*/
public record TargetFileRenameSuccess() implements TargetFileRenameResult {
}
@@ -0,0 +1,24 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.util.Objects;
/**
* Ergebnis einer technisch fehlgeschlagenen Umbenennung einer Zieldatei.
* <p>
* Gibt an, dass beim Umbenennen ein nicht klassifizierbarer technischer Fehler
* aufgetreten ist, z. B. fehlende Schreibrechte, gesperrte Datei durch einen anderen
* Prozess oder ein nicht erreichbares Netzlaufwerk.
*
* @param message menschenlesbare Beschreibung des aufgetretenen Fehlers; nie null
*/
public record TargetFileRenameTechnicalFailure(String message) implements TargetFileRenameResult {
/**
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
*
* @throws NullPointerException wenn {@code message} null ist
*/
public TargetFileRenameTechnicalFailure {
Objects.requireNonNull(message, "message must not be null");
}
}
@@ -0,0 +1,237 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameDocumentNotFound;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameFileSystemFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameInvalidState;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameNoOpIdenticalTarget;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenamePersistenceFailure;
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.ManualFileRenameSourceFileMissing;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSuccess;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
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.ExistingIdenticalTargetFile;
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.TargetFileRenameFailureFileNotFound;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameFailureTargetExists;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenamePort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameTechnicalFailure;
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;
/**
* Standardimplementierung von {@link ManualFileRenameUseCase}.
* <p>
* Führt die manuelle Umbenennung einer Zieldatei 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.</li>
* <li>Prüfen, ob der gewünschte Name bereits dem aktuellen entspricht (No-Op).</li>
* <li>Eindeutigen Zieldateinamen über {@link TargetFolderPort} auflösen.</li>
* <li>Zieldatei im Dateisystem umbenennen via {@link TargetFileRenamePort}.</li>
* <li>Dokument-Stammsatz in der Persistenz aktualisieren.</li>
* <li>Bei Persistenzfehler: Best-Effort-Rollback der Dateisystem-Umbenennung.</li>
* </ol>
* <p>
* Eine Umbenennung ist ausschließlich für Dokumente mit Status
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} zulässig,
* die einen bekannten letzten Zieldateinamen und Zielpfad haben.
*/
public class DefaultManualFileRenameUseCase implements ManualFileRenameUseCase {
private final DocumentRecordRepository repository;
private final TargetFolderPort targetFolderPort;
private final TargetFileRenamePort targetFileRenamePort;
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;
* darf nicht null sein
* @param targetFileRenamePort Port zum physischen Umbenennen einer Zieldatei;
* 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 DefaultManualFileRenameUseCase(
DocumentRecordRepository repository,
TargetFolderPort targetFolderPort,
TargetFileRenamePort targetFileRenamePort,
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.targetFileRenamePort = Objects.requireNonNull(targetFileRenamePort, "targetFileRenamePort 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");
}
/**
* Benennt die Zieldatei eines erfolgreich verarbeiteten Dokuments manuell um.
* <p>
* Der Aufruf ist atomar: Entweder werden Dateisystem und Persistenz beide
* aktualisiert, oder beide bleiben unverändert. Bei einem Persistenzfehler nach
* erfolgreicher Dateisystem-Umbenennung wird die Umbenennung im Dateisystem im
* Rahmen eines Best-Effort-Rollbacks rückgängig gemacht.
*
* @param request die Umbenennungsanfrage mit Fingerprint und gewünschtem Basisdateinamen;
* darf nicht null sein
* @return das Ergebnis der Umbenennung; nie null
* @throws NullPointerException wenn {@code request} null ist
*/
@Override
public ManualFileRenameResult rename(ManualFileRenameRequest request) {
Objects.requireNonNull(request, "request must not be null");
DocumentFingerprint fingerprint = request.fingerprint();
String desiredFullName = request.desiredBaseFileName() + ".pdf";
logger.info("Manuelle Umbenennung angefordert: Fingerprint={}, Zielname={}",
fingerprint.sha256Hex(), desiredFullName);
// Schritt 1: Dokument-Stammsatz laden und Zustand prüfen
var lookupResult = repository.findByFingerprint(fingerprint);
if (lookupResult instanceof DocumentTerminalFinalFailure) {
logger.warn("Manuelle Umbenennung verweigert: Dokument hat terminalen Fehlerstatus. Fingerprint={}",
fingerprint.sha256Hex());
return new ManualFileRenameInvalidState(
"Dokument ist final fehlgeschlagen und kann nicht umbenannt werden.");
}
if (!(lookupResult instanceof DocumentTerminalSuccess terminalSuccess)) {
logger.warn("Manuelle Umbenennung verweigert: Dokument nicht gefunden oder nicht im Erfolgsstatus. Fingerprint={}",
fingerprint.sha256Hex());
return new ManualFileRenameDocumentNotFound(
"Kein erfolgreich verarbeitetes Dokument mit dem angegebenen Fingerprint gefunden.");
}
DocumentRecord record = terminalSuccess.record();
if (record.lastTargetFileName() == null || record.lastTargetPath() == null) {
logger.warn("Manuelle Umbenennung verweigert: Kein Zieldateiname im Stammsatz vorhanden. Fingerprint={}",
fingerprint.sha256Hex());
return new ManualFileRenameInvalidState(
"Dokument hat keinen gespeicherten Zieldateinamen und kann nicht umbenannt werden.");
}
String currentFileName = record.lastTargetFileName();
// Schritt 2: Prüfen, ob der gewünschte Name bereits dem aktuellen entspricht
if (desiredFullName.equals(currentFileName)) {
logger.info("Manuelle Umbenennung: Kein Handlungsbedarf, Name ist bereits identisch. Fingerprint={}",
fingerprint.sha256Hex());
return new ManualFileRenameNoOpIdenticalTarget(currentFileName);
}
// Schritt 3: Eindeutigen Zieldateinamen über TargetFolderPort auflösen
var resolutionResult = targetFolderPort.resolveUniqueFilename(desiredFullName, fingerprint);
if (resolutionResult instanceof ExistingIdenticalTargetFile identical) {
logger.info("Manuelle Umbenennung: Identische Datei bereits im Zielordner vorhanden. Fingerprint={}",
fingerprint.sha256Hex());
return new ManualFileRenameNoOpIdenticalTarget(identical.existingFilename());
}
if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) {
logger.warn("Manuelle Umbenennung fehlgeschlagen: Technischer Fehler beim Zielordner-Zugriff. Fingerprint={}, Ursache={}",
fingerprint.sha256Hex(), folderFailure.errorMessage());
return new ManualFileRenameFileSystemFailure(
"Zielordner nicht zugänglich: " + folderFailure.errorMessage());
}
// resolutionResult ist jetzt ResolvedTargetFilename
String appliedFileName = ((ResolvedTargetFilename) resolutionResult).resolvedFilename();
// Schritt 4: Zieldatei im Dateisystem umbenennen
var renameResult = targetFileRenamePort.rename(currentFileName, appliedFileName);
if (renameResult instanceof TargetFileRenameFailureFileNotFound notFound) {
logger.warn("Manuelle Umbenennung fehlgeschlagen: Bisherige Zieldatei nicht gefunden. Fingerprint={}, Datei={}",
fingerprint.sha256Hex(), notFound.oldFileName());
return new ManualFileRenameSourceFileMissing(notFound.oldFileName());
}
if (renameResult instanceof TargetFileRenameFailureTargetExists targetExists) {
logger.warn("Manuelle Umbenennung fehlgeschlagen: Zieldatei bereits vorhanden (defensiv). Fingerprint={}, Datei={}",
fingerprint.sha256Hex(), targetExists.newFileName());
return new ManualFileRenameFileSystemFailure(
"Zieldatei bereits vorhanden: " + targetExists.newFileName());
}
if (renameResult instanceof TargetFileRenameTechnicalFailure technical) {
logger.warn("Manuelle Umbenennung fehlgeschlagen: Technischer Dateisystemfehler. Fingerprint={}, Ursache={}",
fingerprint.sha256Hex(), technical.message());
return new ManualFileRenameFileSystemFailure(technical.message());
}
// Schritt 5: Persistenz aktualisieren (renameResult ist jetzt TargetFileRenameSuccess)
DocumentRecord updatedRecord = new DocumentRecord(
record.fingerprint(),
record.lastKnownSourceLocator(),
record.lastKnownSourceFileName(),
record.overallStatus(),
record.failureCounters(),
record.lastFailureInstant(),
record.lastSuccessInstant(),
record.createdAt(),
clock.now(),
record.lastTargetPath(),
appliedFileName);
try {
unitOfWorkPort.executeInTransaction(tx -> tx.updateDocumentRecord(updatedRecord));
} catch (RuntimeException persistenceException) {
// Best-Effort-Rollback: Dateisystem-Umbenennung rückgängig machen
String errorMessage = persistenceException.getMessage() != null
? persistenceException.getMessage()
: persistenceException.getClass().getSimpleName();
logger.warn("Manuelle Umbenennung: Persistenzfehler nach erfolgreicher Dateisystem-Umbenennung. " +
"Versuche Rollback. Fingerprint={}, Ursache={}", fingerprint.sha256Hex(), errorMessage);
var rollbackResult = targetFileRenamePort.rename(appliedFileName, currentFileName);
if (!(rollbackResult instanceof TargetFileRenameSuccess)) {
logger.error("Rollback der Dateisystem-Umbenennung fehlgeschlagen: {} → {}. " +
"Dateisystem und Persistenz sind möglicherweise inkonsistent. Fingerprint={}",
appliedFileName, currentFileName, fingerprint.sha256Hex());
}
return new ManualFileRenamePersistenceFailure(
"Persistenzfehler nach Umbenennung: " + errorMessage);
}
boolean conflictSuffixApplied = !appliedFileName.equals(desiredFullName);
logger.info("Manuelle Umbenennung erfolgreich: {} → {} (Suffix angewendet: {})",
currentFileName, appliedFileName, conflictSuffixApplied);
return new ManualFileRenameSuccess(currentFileName, appliedFileName, conflictSuffixApplied);
}
}