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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 13:22:44 +02:00
parent fb0e9809f6
commit 1d77173c49
18 changed files with 1673 additions and 23 deletions
@@ -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");
}
}
@@ -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.&nbsp;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");
}
}
@@ -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.&nbsp;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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
}
@@ -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.&nbsp;B. bereits {@code SUCCESS}).</li>
* <li>{@link ManualFileCopyFileSystemFailure} ein technischer Dateisystemfehler
* ist während der Kopie aufgetreten (z.&nbsp;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 {
}
@@ -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");
}
}
@@ -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);
}
@@ -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);
}
}