Fix #30: Detailbereich bei SKIPPED-Zeilen mit historischen Informationen befüllen

- Teile DocumentCompletionStatus.SKIPPED in SKIPPED_ALREADY_PROCESSED und
  SKIPPED_FINAL_FAILURE auf, um den Skip-Grund unterscheidbar zu machen
- Führe neuen Typ HistoricalDocumentContext ein (lastTargetFileName,
  lastSuccessInstant, lastFailureInstant, wasEverSuccessful)
- Führe ResolveHistoricalDocumentContextUseCase und
  DefaultResolveHistoricalDocumentContextUseCase ein
- Ersetze GuiHistoricalFileNamePort durch GuiHistoricalDocumentContextPort
- Lade historischen Kontext für übersprungene Zeilen im Coordinator-Worker-Thread
- Zeige im Detailbereich je nach Skip-Grund:
  SKIPPED_ALREADY_PROCESSED: "Bereits erfolgreich verarbeitet am [Datum]. Zieldatei: [Name]."
  SKIPPED_FINAL_FAILURE: "Endgültig fehlgeschlagen am [Datum]. Erneute Verarbeitung nur nach Reset möglich."
- Passe alle betroffenen Tests an

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 12:00:27 +02:00
parent 1db6e27be8
commit 3f5602de01
22 changed files with 605 additions and 103 deletions
@@ -5,10 +5,10 @@ package de.gecheckt.pdf.umbenenner.application.port.in;
* {@link BatchRunProgressObserver#onDocumentCompleted(DocumentCompletionEvent)}
* for one processed candidate.
* <p>
* This enum collapses the finer-grained internal processing status into the four
* This enum collapses the finer-grained internal processing status into five
* buckets that an observer (e.g. a GUI progress view) needs to distinguish:
* successful completion, retryable failure, permanent failure, and an explicit
* skip.
* successful completion, retryable failure, permanent failure, and two explicit
* skip variants.
* <p>
* This classification is purely an observability concern — persistence,
* retry decisions, and all other processing rules continue to work against the
@@ -36,8 +36,16 @@ public enum DocumentCompletionStatus {
FAILED_PERMANENT,
/**
* The candidate was skipped because it was already in a terminal state (either
* previously successful or previously finally failed).
* Der Kandidat wurde übersprungen, weil er in einem früheren Lauf bereits
* erfolgreich verarbeitet wurde. Der Gesamtstatus im Stammsatz lautet
* {@code SUCCESS}.
*/
SKIPPED
SKIPPED_ALREADY_PROCESSED,
/**
* Der Kandidat wurde übersprungen, weil er in einem früheren Lauf bereits
* endgültig fehlgeschlagen ist. Der Gesamtstatus im Stammsatz lautet
* {@code FAILED_FINAL}.
*/
SKIPPED_FINAL_FAILURE
}
@@ -0,0 +1,75 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/**
* Historischer Kontext eines Dokuments, das in einem früheren Lauf bereits terminal
* abgeschlossen wurde.
* <p>
* Wird genutzt, um im GUI-Detailbereich bei übersprungenen Dokumenten Informationen
* darüber anzuzeigen, wann und mit welchem Ergebnis die Datei früher verarbeitet wurde.
* <p>
* Für Dokumente mit früherem Erfolgsstatus ({@code wasEverSuccessful == true}) sind
* {@code lastTargetFileName} und {@code lastSuccessInstant} belegt.
* Für Dokumente, die endgültig fehlgeschlagen sind, ist {@code lastFailureInstant} belegt.
*
* @param lastTargetFileName letzter erfolgreich geschriebener Zieldateiname; leer wenn
* das Dokument nie erfolgreich kopiert wurde
* @param lastSuccessInstant Zeitpunkt der letzten erfolgreichen Verarbeitung; leer wenn
* das Dokument nie erfolgreich verarbeitet wurde
* @param lastFailureInstant Zeitpunkt des letzten Fehlschlags; leer wenn noch kein
* Fehlschlag aufgetreten ist
* @param wasEverSuccessful {@code true} wenn das Dokument mindestens einmal erfolgreich
* verarbeitet wurde
*/
public record HistoricalDocumentContext(
Optional<String> lastTargetFileName,
Optional<Instant> lastSuccessInstant,
Optional<Instant> lastFailureInstant,
boolean wasEverSuccessful) {
/**
* Kompakter Konstruktor zur Normalisierung von {@code null}-Werten.
*
* @throws NullPointerException wenn {@code lastTargetFileName},
* {@code lastSuccessInstant} oder
* {@code lastFailureInstant} {@code null} sind
*/
public HistoricalDocumentContext {
lastTargetFileName = lastTargetFileName == null ? Optional.empty() : lastTargetFileName;
lastSuccessInstant = lastSuccessInstant == null ? Optional.empty() : lastSuccessInstant;
lastFailureInstant = lastFailureInstant == null ? Optional.empty() : lastFailureInstant;
}
/**
* Erstellt einen Kontext für ein erfolgreich verarbeitetes Dokument.
*
* @param lastTargetFileName letzter Zieldateiname; darf {@code null} sein
* @param lastSuccessInstant Zeitpunkt des Erfolgs; darf {@code null} sein
* @return neuer Kontext mit {@code wasEverSuccessful == true}
*/
public static HistoricalDocumentContext ofSuccess(
String lastTargetFileName, Instant lastSuccessInstant) {
return new HistoricalDocumentContext(
Optional.ofNullable(lastTargetFileName),
Optional.ofNullable(lastSuccessInstant),
Optional.empty(),
true);
}
/**
* Erstellt einen Kontext für ein endgültig fehlgeschlagenes Dokument.
*
* @param lastFailureInstant Zeitpunkt des letzten Fehlschlags; darf {@code null} sein
* @return neuer Kontext mit {@code wasEverSuccessful == false}
*/
public static HistoricalDocumentContext ofFinalFailure(Instant lastFailureInstant) {
return new HistoricalDocumentContext(
Optional.empty(),
Optional.empty(),
Optional.ofNullable(lastFailureInstant),
false);
}
}
@@ -0,0 +1,42 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Inbound-Port zum Abfragen des historischen Verarbeitungskontexts eines Dokuments.
* <p>
* Wird im GUI-Verarbeitungslauf-Tab eingesetzt, um für übersprungene Dokumente
* anzuzeigen, wann und mit welchem Ergebnis die Datei in einem früheren Lauf
* verarbeitet wurde.
* <p>
* Für Dokumente mit früherem Erfolgsstatus enthält der zurückgegebene Kontext den
* letzten Zieldateinamen und den Erfolgszeitpunkt. Für endgültig fehlgeschlagene
* Dokumente ist der letzte Fehlzeitpunkt belegt.
* <p>
* <strong>Architekturgrenzen:</strong> Implementierungen dieses Ports dürfen keine
* JDBC-, SQLite-, Dateisystem- oder HTTP-Typen nach außen exponieren. Alle
* Infrastrukturdetails verbleiben in der Adapter-Schicht.
*/
public interface ResolveHistoricalDocumentContextUseCase {
/**
* Gibt den historischen Verarbeitungskontext für das durch den Fingerprint
* identifizierte Dokument zurück.
* <p>
* Liefert einen gefüllten Kontext, wenn das Dokument einen früheren terminalen
* Abschluss (Erfolg oder endgültiger Fehlschlag) hat. Gibt ein leeres
* {@link Optional} zurück, wenn kein passender Stammsatz vorhanden ist oder
* die Abfrage technisch fehlschlägt.
* <p>
* Wirft keine geprüften Ausnahmen: technische Abfragefehler werden intern
* abgefangen und als leeres Ergebnis zurückgegeben.
*
* @param fingerprint inhaltsbasierte Dokumentenidentität; darf nicht {@code null} sein
* @return historischer Kontext des Dokuments, oder leer wenn nicht verfügbar
* @throws NullPointerException wenn {@code fingerprint} {@code null} ist
*/
Optional<HistoricalDocumentContext> resolveHistoricalDocumentContext(
DocumentFingerprint fingerprint);
}
@@ -13,8 +13,9 @@ package de.gecheckt.pdf.umbenenner.application.port.in;
* @param failedCount number of candidates that completed with either
* {@link DocumentCompletionStatus#FAILED_RETRYABLE} or
* {@link DocumentCompletionStatus#FAILED_PERMANENT}; must be &ge; 0
* @param skippedCount number of candidates that completed with
* {@link DocumentCompletionStatus#SKIPPED}; must be &ge; 0
* @param skippedCount number of candidates that completed with either
* {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED} or
* {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}; must be &ge; 0
*/
public record RunSummary(int successCount, int failedCount, int skippedCount) {
@@ -815,7 +815,11 @@ public class DocumentProcessingCoordinator {
logger.debug("Skip attempt #{} persisted for '{}' with status {}.",
attemptNumber, candidate.uniqueIdentifier(), skipStatus);
publishCompletion(candidate, fingerprint, DocumentCompletionStatus.SKIPPED,
DocumentCompletionStatus completionStatus =
skipStatus == ProcessingStatus.SKIPPED_ALREADY_PROCESSED
? DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED
: DocumentCompletionStatus.SKIPPED_FINAL_FAILURE;
publishCompletion(candidate, fingerprint, completionStatus,
null, null, null, null, attemptStart, now);
return true;
@@ -38,7 +38,7 @@ final class CountingCompletionObserver implements Consumer<DocumentCompletionEve
switch (event.status()) {
case SUCCESS -> successCount++;
case FAILED_RETRYABLE, FAILED_PERMANENT -> failedCount++;
case SKIPPED -> skippedCount++;
case SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> skippedCount++;
default -> {
// Defensive — new status values would be a programming error.
throw new IllegalStateException(
@@ -0,0 +1,83 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
import de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalDocumentContextUseCase;
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.domain.model.DocumentFingerprint;
/**
* Standardimplementierung von {@link ResolveHistoricalDocumentContextUseCase}.
* <p>
* Fragt den {@link DocumentRecordRepository} nach dem Stammsatz des angegebenen
* Fingerprints ab. Abhängig vom terminalen Zustand des Dokuments wird ein passender
* {@link HistoricalDocumentContext} zurückgegeben:
* <ul>
* <li>Bei früherem Erfolgsstatus: Zieldateiname und Erfolgszeitpunkt aus dem
* Stammsatz ({@code lastTargetFileName}, {@code lastSuccessInstant}).</li>
* <li>Bei endgültigem Fehlschlag: Fehlzeitpunkt aus dem Stammsatz
* ({@code lastFailureInstant}).</li>
* <li>In allen anderen Fällen (unbekannt, verarbeitbar) sowie bei technischen
* Abfragefehlern: leeres {@link Optional}.</li>
* </ul>
* Technische Fehler bei der Repository-Abfrage werden intern abgefangen; der Aufrufer
* erhält stets ein leeres Ergebnis statt einer Ausnahme.
*/
public class DefaultResolveHistoricalDocumentContextUseCase
implements ResolveHistoricalDocumentContextUseCase {
private final DocumentRecordRepository documentRecordRepository;
/**
* Erstellt den Use Case mit dem erforderlichen Dokument-Stammsatz-Repository.
*
* @param documentRecordRepository Repository zum Lesen von Dokument-Stammsätzen;
* darf nicht {@code null} sein
* @throws NullPointerException wenn {@code documentRecordRepository} {@code null} ist
*/
public DefaultResolveHistoricalDocumentContextUseCase(
DocumentRecordRepository documentRecordRepository) {
this.documentRecordRepository = Objects.requireNonNull(
documentRecordRepository, "documentRecordRepository must not be null");
}
/**
* Gibt den historischen Verarbeitungskontext für das durch den Fingerprint
* identifizierte Dokument zurück.
* <p>
* Für Dokumente mit früherem Erfolgsstatus enthält der Kontext Zieldateiname und
* Erfolgszeitpunkt. Für endgültig fehlgeschlagene Dokumente ist der Fehlzeitpunkt
* belegt. Für alle anderen Zustände oder bei Abfragefehlern wird ein leeres
* {@link Optional} zurückgegeben.
*
* @param fingerprint inhaltsbasierte Dokumentenidentität; darf nicht {@code null} sein
* @return historischer Kontext des Dokuments, oder leer wenn nicht verfügbar
* @throws NullPointerException wenn {@code fingerprint} {@code null} ist
*/
@Override
public Optional<HistoricalDocumentContext> resolveHistoricalDocumentContext(
DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
try {
DocumentRecordLookupResult result =
documentRecordRepository.findByFingerprint(fingerprint);
if (result instanceof DocumentTerminalSuccess success) {
return Optional.of(HistoricalDocumentContext.ofSuccess(
success.record().lastTargetFileName(),
success.record().lastSuccessInstant()));
}
if (result instanceof DocumentTerminalFinalFailure failure) {
return Optional.of(HistoricalDocumentContext.ofFinalFailure(
failure.record().lastFailureInstant()));
}
return Optional.empty();
} catch (Exception e) {
return Optional.empty();
}
}
}
@@ -129,7 +129,7 @@ class BatchRunProgressObservationTest {
assertSame(a, b);
a.onRunStarted(new RunId("r-1"), 5);
a.onDocumentCompleted(new DocumentCompletionEvent(
"x.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED, null, null, null, null, Duration.ZERO));
"x.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, null, null, null, null, Duration.ZERO));
a.onRunEnded(new RunSummary(0, 0, 0));
}
@@ -166,7 +166,7 @@ class BatchRunProgressObservationTest {
DocumentCompletionStatus.SUCCESS,
DocumentCompletionStatus.FAILED_RETRYABLE,
DocumentCompletionStatus.FAILED_PERMANENT,
DocumentCompletionStatus.SKIPPED));
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
new NoOpLock(), new FixedCandidatesPort(
makeCandidate("a.pdf"),
@@ -0,0 +1,180 @@
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.Optional;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
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.FailureCounters;
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
/**
* Unit tests for {@link DefaultResolveHistoricalDocumentContextUseCase}.
*/
class DefaultResolveHistoricalDocumentContextUseCaseTest {
private static final DocumentFingerprint FP = new DocumentFingerprint("a".repeat(64));
private static final Instant NOW = Instant.parse("2026-01-15T10:30:00Z");
@Test
void constructor_withNullRepository_throws() {
assertThatNullPointerException()
.isThrownBy(() -> new DefaultResolveHistoricalDocumentContextUseCase(null))
.withMessageContaining("documentRecordRepository");
}
@Test
void resolve_withNullFingerprint_throws() {
var useCase = new DefaultResolveHistoricalDocumentContextUseCase(
stubRepo(new DocumentUnknown()));
assertThatNullPointerException()
.isThrownBy(() -> useCase.resolveHistoricalDocumentContext(null))
.withMessageContaining("fingerprint");
}
@Test
void resolve_forSuccessRecord_returnsContextWithTargetFileNameAndSuccessInstant() {
DocumentRecord record = buildRecord(ProcessingStatus.SUCCESS,
"2026-01-01 - Rechnung.pdf", NOW, null);
var useCase = new DefaultResolveHistoricalDocumentContextUseCase(
stubRepo(new DocumentTerminalSuccess(record)));
Optional<HistoricalDocumentContext> result =
useCase.resolveHistoricalDocumentContext(FP);
assertThat(result).isPresent();
assertThat(result.get().wasEverSuccessful()).isTrue();
assertThat(result.get().lastTargetFileName()).contains("2026-01-01 - Rechnung.pdf");
assertThat(result.get().lastSuccessInstant()).contains(NOW);
assertThat(result.get().lastFailureInstant()).isEmpty();
}
@Test
void resolve_forSuccessRecordWithNullTargetFileName_returnsContextWithEmptyFileName() {
DocumentRecord record = buildRecord(ProcessingStatus.SUCCESS, null, NOW, null);
var useCase = new DefaultResolveHistoricalDocumentContextUseCase(
stubRepo(new DocumentTerminalSuccess(record)));
Optional<HistoricalDocumentContext> result =
useCase.resolveHistoricalDocumentContext(FP);
assertThat(result).isPresent();
assertThat(result.get().wasEverSuccessful()).isTrue();
assertThat(result.get().lastTargetFileName()).isEmpty();
assertThat(result.get().lastSuccessInstant()).contains(NOW);
}
@Test
void resolve_forFinalFailureRecord_returnsContextWithFailureInstant() {
DocumentRecord record = buildRecord(ProcessingStatus.FAILED_FINAL, null, null, NOW);
var useCase = new DefaultResolveHistoricalDocumentContextUseCase(
stubRepo(new DocumentTerminalFinalFailure(record)));
Optional<HistoricalDocumentContext> result =
useCase.resolveHistoricalDocumentContext(FP);
assertThat(result).isPresent();
assertThat(result.get().wasEverSuccessful()).isFalse();
assertThat(result.get().lastFailureInstant()).contains(NOW);
assertThat(result.get().lastTargetFileName()).isEmpty();
assertThat(result.get().lastSuccessInstant()).isEmpty();
}
@Test
void resolve_forUnknownDocument_returnsEmpty() {
var useCase = new DefaultResolveHistoricalDocumentContextUseCase(
stubRepo(new DocumentUnknown()));
assertThat(useCase.resolveHistoricalDocumentContext(FP)).isEmpty();
}
@Test
void resolve_forProcessableDocument_returnsEmpty() {
DocumentRecord record = buildRecord(ProcessingStatus.READY_FOR_AI, null, null, null);
var useCase = new DefaultResolveHistoricalDocumentContextUseCase(
stubRepo(new DocumentKnownProcessable(record)));
assertThat(useCase.resolveHistoricalDocumentContext(FP)).isEmpty();
}
@Test
void resolve_forPersistenceLookupFailure_returnsEmpty() {
var useCase = new DefaultResolveHistoricalDocumentContextUseCase(
stubRepo(new PersistenceLookupTechnicalFailure("DB-Fehler", null)));
assertThat(useCase.resolveHistoricalDocumentContext(FP)).isEmpty();
}
@Test
void resolve_whenRepositoryThrows_returnsEmpty() {
DocumentRecordRepository throwingRepo = new DocumentRecordRepository() {
@Override
public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) {
throw new DocumentPersistenceException("Verbindungsfehler", null);
}
@Override
public void create(DocumentRecord record) {}
@Override
public void update(DocumentRecord record) {}
@Override
public void deleteByFingerprint(DocumentFingerprint fingerprint) {}
};
var useCase = new DefaultResolveHistoricalDocumentContextUseCase(throwingRepo);
assertThat(useCase.resolveHistoricalDocumentContext(FP)).isEmpty();
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static DocumentRecordRepository stubRepo(DocumentRecordLookupResult result) {
return new DocumentRecordRepository() {
@Override
public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) {
return result;
}
@Override
public void create(DocumentRecord record) {}
@Override
public void update(DocumentRecord record) {}
@Override
public void deleteByFingerprint(DocumentFingerprint fingerprint) {}
};
}
private static DocumentRecord buildRecord(
ProcessingStatus status,
String lastTargetFileName,
Instant lastSuccessInstant,
Instant lastFailureInstant) {
return new DocumentRecord(
FP,
new SourceDocumentLocator("quell/pfad"),
"original.pdf",
status,
FailureCounters.zero(),
lastFailureInstant,
lastSuccessInstant,
Instant.now(),
Instant.now(),
lastTargetFileName != null ? "ziel/ordner" : null,
lastTargetFileName);
}
}