#7: Historien-Tab mit Liste, Detail, Filter, Status-Reset und Eintrag-Loeschen

Implementiert den vollstaendigen Historien-Tab (Verlauf) als vierten Tab der GUI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 13:57:07 +02:00
parent 5d5dee0bbf
commit 46fc1d4fa4
31 changed files with 3095 additions and 17 deletions
@@ -60,5 +60,28 @@ public interface UnitOfWorkPort {
* @throws DocumentPersistenceException if the delete fails due to a technical error
*/
void resetDocumentByFingerprint(DocumentFingerprint fingerprint);
/**
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
* ohne die Versuchshistorie zu löschen.
* <p>
* Folgende Felder werden aktualisiert:
* <ul>
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
* <li>{@code content_error_count} → {@code 0}</li>
* <li>{@code transient_error_count} → {@code 0}</li>
* <li>{@code last_failure_instant} → {@code null}</li>
* </ul>
* Nicht geändert werden: {@code created_at}, {@code last_success_instant},
* {@code last_target_path}, {@code last_target_file_name} sowie alle
* {@code processing_attempt}-Einträge, die vollständig erhalten bleiben.
* <p>
* Nach diesem Aufruf gilt das Dokument beim nächsten Lauf als verarbeitbar.
*
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
void resetDocumentStatusForRetry(DocumentFingerprint fingerprint);
}
}
@@ -0,0 +1,50 @@
package de.gecheckt.pdf.umbenenner.application.port.out.history;
import java.time.Instant;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
/**
* Einzelzeile der Dokumentenliste im Historien-Tab.
* <p>
* Enthält alle Felder, die für die linke Tabelle des Historien-Tabs benötigt werden.
* Die Felder stammen aus {@code document_record} und einem {@code COUNT}-Ausdruck über
* {@code processing_attempt}.
*
* @param fingerprint Inhalts-basierter Dokumentbezeichner; nie {@code null}
* @param overallStatus aktueller Gesamtstatus des Dokuments; nie {@code null}
* @param sourceFileName zuletzt bekannter Quelldateiname; nie {@code null}
* @param targetFileName zuletzt bekannter Zieldateiname; {@code null} falls noch kein
* erfolgreicher Lauf stattgefunden hat
* @param sourcePath zuletzt bekannter Quellpfad (opaker Locator-Wert); nie {@code null}
* @param updatedAt Zeitpunkt der letzten Aktualisierung des Stammsatzes; nie {@code null}
* @param attemptCount Anzahl historisierter Verarbeitungsversuche; immer &gt;= 0
*/
public record DocumentHistoryRow(
DocumentFingerprint fingerprint,
ProcessingStatus overallStatus,
String sourceFileName,
String targetFileName,
String sourcePath,
Instant updatedAt,
long attemptCount) {
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws NullPointerException wenn ein Pflichtfeld {@code null} ist
* @throws IllegalArgumentException wenn {@code attemptCount} negativ ist
*/
public DocumentHistoryRow {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
Objects.requireNonNull(overallStatus, "overallStatus darf nicht null sein");
Objects.requireNonNull(sourceFileName, "sourceFileName darf nicht null sein");
Objects.requireNonNull(sourcePath, "sourcePath darf nicht null sein");
Objects.requireNonNull(updatedAt, "updatedAt darf nicht null sein");
if (attemptCount < 0) {
throw new IllegalArgumentException("attemptCount darf nicht negativ sein, war: " + attemptCount);
}
}
}
@@ -0,0 +1,65 @@
package de.gecheckt.pdf.umbenenner.application.port.out.history;
/**
* Abfrageparameter für den Historien-Tab.
* <p>
* Kapselt Freitextsuche, optionalen Status-Filter und das Limit der zurückzugebenden
* Zeilen. Das Limit ist bewusst auf 501 gesetzt, damit die aufrufende Schicht erkennen
* kann, ob mehr als 500 Treffer vorhanden sind.
*
* @param searchText optionaler Suchbegriff (Teilstring, case-insensitiv); {@code null}
* oder leer bedeutet keine Texteinschränkung
* @param statusFilter optionaler Status-Filter als Enum-Name; {@code null} bedeutet alle
* Status werden angezeigt
* @param limit maximale Anzahl zurückzugebender Zeilen; muss &gt;= 1 sein
*/
public record HistoryQuery(
String searchText,
String statusFilter,
int limit) {
/**
* Standard-Limit: 501 Zeilen abfragen, um bei Bedarf „mehr vorhanden" erkennen zu können.
*/
public static final int DEFAULT_LIMIT = 501;
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws IllegalArgumentException wenn {@code limit} kleiner als 1 ist
*/
public HistoryQuery {
if (limit < 1) {
throw new IllegalArgumentException("limit muss mindestens 1 sein, war: " + limit);
}
}
/**
* Erzeugt eine Abfrage ohne Filter mit Standard-Limit.
*
* @return neue Abfrage ohne Einschränkungen
*/
public static HistoryQuery unfiltered() {
return new HistoryQuery(null, null, DEFAULT_LIMIT);
}
/**
* Erzeugt eine Abfrage mit Freitextsuche und Standard-Limit.
*
* @param searchText Suchbegriff; {@code null} oder leer bedeutet kein Filter
* @return neue Abfrage mit Textfilter
*/
public static HistoryQuery withSearchText(String searchText) {
return new HistoryQuery(searchText, null, DEFAULT_LIMIT);
}
/**
* Erzeugt eine Abfrage mit Status-Filter und Standard-Limit.
*
* @param statusFilter Enum-Name des gewünschten Status; {@code null} bedeutet kein Filter
* @return neue Abfrage mit Status-Filter
*/
public static HistoryQuery withStatus(String statusFilter) {
return new HistoryQuery(null, statusFilter, DEFAULT_LIMIT);
}
}
@@ -0,0 +1,61 @@
package de.gecheckt.pdf.umbenenner.application.port.out.history;
import java.util.List;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Outbound-Port für lesende Historien-Abfragen aus dem Historien-Tab.
* <p>
* Kapselt alle Datenbanklese-Operationen, die der Historien-Tab benötigt.
* Die Implementierung liegt ausschließlich in {@code pdf-umbenenner-adapter-out}.
* Die Application-Schicht kennt nur diesen Port-Vertrag keine JDBC-Typen.
*
* <h2>Architektur</h2>
* <p>
* Dieser Port ist bewusst von {@link de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository}
* und {@link de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository}
* getrennt, damit die bestehenden Repositories nicht mit GUI-spezifischen Methoden
* aufgebläht werden.
*/
public interface HistoryQueryPort {
/**
* Lädt eine gefilterte und sortierte Übersicht aller Dokumenteneinträge.
* <p>
* Sortierung: {@code updated_at DESC, fingerprint ASC} (stabiler Tie-Breaker).
* Das in {@link HistoryQuery#limit()} angegebene Limit wird direkt als SQL-{@code LIMIT}
* angewendet. Wenn das Limit 501 beträgt und 501 Zeilen zurückgegeben werden, gibt es
* mehr als 500 Treffer.
*
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit; darf nicht {@code null} sein
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
* technischen Datenbankfehlern
*/
List<DocumentHistoryRow> loadOverview(HistoryQuery query);
/**
* Lädt den vollständigen Dokumenten-Stammsatz für den angegebenen Fingerprint.
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
* technischen Datenbankfehlern
*/
Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint);
/**
* Lädt alle historisierten Verarbeitungsversuche für den angegebenen Fingerprint,
* aufsteigend sortiert nach {@code attempt_number}.
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return unveränderliche Liste der Versuche; nie {@code null}; kann leer sein
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
* technischen Datenbankfehlern
*/
List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint);
}
@@ -0,0 +1,12 @@
/**
* Outbound-Ports und DTOs für lesende Historien-Abfragen des Historien-Tabs.
* <p>
* Enthält den {@link de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort}
* sowie die zugehörigen Datentypen
* {@link de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery} und
* {@link de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow}.
* Diese Typen sind bewusst vom bestehenden {@code port.out}-Paket getrennt,
* damit die allgemeinen Repository-Schnittstellen nicht mit GUI-spezifischen Methoden
* belastet werden.
*/
package de.gecheckt.pdf.umbenenner.application.port.out.history;
@@ -0,0 +1,65 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Use-Case-Implementierung für das vollständige Löschen eines Dokumenteintrags
* aus dem Historien-Tab.
* <p>
* Löscht innerhalb einer Transaktion in der korrekten Reihenfolge, um den
* Foreign-Key-Constraint zwischen {@code processing_attempt.fingerprint} und
* {@code document_record.fingerprint} zu erfüllen (kein {@code ON DELETE CASCADE}):
* <ol>
* <li>Alle {@code processing_attempt}-Einträge zum Fingerprint</li>
* <li>Den {@code document_record}-Stammsatz zum Fingerprint</li>
* </ol>
* Die Operation ist idempotent: wenn kein Datensatz für den Fingerprint existiert,
* kehrt die Methode stillschweigend zurück.
* <p>
* <strong>Hinweis:</strong> Diese Aktion ist destruktiv und nicht rückgängig zu machen.
* Die GUI muss vor dem Aufruf einen Bestätigungsdialog anzeigen.
*/
public class DefaultDeleteDocumentHistoryUseCase {
private static final Logger logger = LogManager.getLogger(DefaultDeleteDocumentHistoryUseCase.class);
private final UnitOfWorkPort unitOfWorkPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Persistenz-Port.
*
* @param unitOfWorkPort Port für transaktionale Persistenzoperationen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code unitOfWorkPort} null ist
*/
public DefaultDeleteDocumentHistoryUseCase(UnitOfWorkPort unitOfWorkPort) {
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort darf nicht null sein");
}
/**
* Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
* <p>
* Die Löschung erfolgt in einer einzigen Transaktion. Versuche werden vor dem
* Stammsatz gelöscht, damit der Foreign-Key-Constraint eingehalten wird.
*
* @param fingerprint der Dokumentbezeichner, dessen Daten vollständig gelöscht werden sollen;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
* @throws NullPointerException wenn {@code fingerprint} null ist
*/
public void deleteHistory(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
// Nutzung der bestehenden Transaktion mit korrekter Löschreihenfolge:
// zuerst Versuche, dann Stammsatz (FK-Constraint)
unitOfWorkPort.executeInTransaction(tx -> tx.resetDocumentByFingerprint(fingerprint));
logger.info("Dokumenteintrag vollständig gelöscht für Fingerprint: {}", fingerprint.sha256Hex());
}
}
@@ -0,0 +1,74 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Use-Case-Implementierung für das Laden der Detailansicht eines Dokuments im Historien-Tab.
* <p>
* Kombiniert den Dokument-Stammsatz und alle historisierten Verarbeitungsversuche
* für einen bestimmten Fingerprint in einem einzigen Ergebnisobjekt.
* <p>
* Wird kein Stammsatz gefunden (z. B. weil das Dokument zwischenzeitlich gelöscht wurde),
* liefert {@link #loadDetails(DocumentFingerprint)} ein leeres {@link Optional}.
*/
public class DefaultHistoryDetailsUseCase {
private final HistoryQueryPort historyQueryPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Abfrage-Port.
*
* @param historyQueryPort Port für lesende Historienabfragen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code historyQueryPort} null ist
*/
public DefaultHistoryDetailsUseCase(HistoryQueryPort historyQueryPort) {
this.historyQueryPort = Objects.requireNonNull(historyQueryPort, "historyQueryPort darf nicht null sein");
}
/**
* Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return Optional mit den Detaildaten, oder leer wenn kein Stammsatz gefunden wurde
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
public Optional<HistoryDetailsResult> loadDetails(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
Optional<DocumentRecord> record = historyQueryPort.findRecordByFingerprint(fingerprint);
if (record.isEmpty()) {
return Optional.empty();
}
List<ProcessingAttempt> attempts = historyQueryPort.findAttemptsByFingerprint(fingerprint);
return Optional.of(new HistoryDetailsResult(record.get(), attempts));
}
/**
* Ergebnis einer Historien-Detailabfrage.
*
* @param record Dokument-Stammsatz; nie {@code null}
* @param attempts alle historisierten Verarbeitungsversuche aufsteigend nach Versuchsnummer;
* nie {@code null}; kann leer sein
*/
public record HistoryDetailsResult(DocumentRecord record, List<ProcessingAttempt> attempts) {
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws NullPointerException wenn {@code record} oder {@code attempts} null ist
*/
public HistoryDetailsResult {
Objects.requireNonNull(record, "record darf nicht null sein");
Objects.requireNonNull(attempts, "attempts darf nicht null sein");
}
}
}
@@ -0,0 +1,82 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.List;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
/**
* Use-Case-Implementierung für das Laden der Dokumentenliste im Historien-Tab.
* <p>
* Delegiert die Datenbankabfrage vollständig an {@link HistoryQueryPort} und
* wertet das LIMIT-501-Ergebnis aus, um der GUI signalisieren zu können, ob
* weitere Einträge vorhanden sind, die durch einen engeren Filter erreichbar wären.
* <p>
* <strong>LIMIT-501-Technik:</strong> Die Query wird mit {@code limit + 1 = 501}
* ausgeführt (sofern das übergebene Limit 500 beträgt). Wenn die Datenbank 501
* Zeilen zurückgibt, existieren mehr als 500 Treffer. Die zurückgegebene Liste
* enthält dann exakt 500 Zeilen (das letzte Element wird verworfen) und
* {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
*/
public class DefaultHistoryOverviewUseCase {
private static final int MAX_DISPLAY_COUNT = 500;
private final HistoryQueryPort historyQueryPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Abfrage-Port.
*
* @param historyQueryPort Port für lesende Historienabfragen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code historyQueryPort} null ist
*/
public DefaultHistoryOverviewUseCase(HistoryQueryPort historyQueryPort) {
this.historyQueryPort = Objects.requireNonNull(historyQueryPort, "historyQueryPort darf nicht null sein");
}
/**
* Lädt die Dokumentenliste auf Basis der übergebenen Abfrageparameter.
* <p>
* Intern wird ein Limit von 501 verwendet, um erkennen zu können, ob mehr
* als 500 Treffer vorhanden sind.
*
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit; darf nicht {@code null} sein
* @return Ergebnisobjekt mit Trefferlist und {@code hasMore}-Flag; nie {@code null}
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
public HistoryOverviewResult loadOverview(HistoryQuery query) {
Objects.requireNonNull(query, "query darf nicht null sein");
List<DocumentHistoryRow> rows = historyQueryPort.loadOverview(query);
if (rows.size() > MAX_DISPLAY_COUNT) {
// 501 Zeilen zurückgegeben: mehr als 500 Treffer vorhanden
List<DocumentHistoryRow> truncated = List.copyOf(rows.subList(0, MAX_DISPLAY_COUNT));
return new HistoryOverviewResult(truncated, true);
}
return new HistoryOverviewResult(List.copyOf(rows), false);
}
/**
* Ergebnis einer Historien-Übersichtsabfrage.
*
* @param rows Liste der Trefferzeilen; nie {@code null}; enthält maximal 500 Einträge
* @param hasMore {@code true}, wenn mehr als 500 Treffer vorhanden sind und durch
* einen engeren Filter eingegrenzt werden könnten
*/
public record HistoryOverviewResult(List<DocumentHistoryRow> rows, boolean hasMore) {
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws NullPointerException wenn {@code rows} null ist
*/
public HistoryOverviewResult {
Objects.requireNonNull(rows, "rows darf nicht null sein");
}
}
}
@@ -0,0 +1,69 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Use-Case-Implementierung für den feldgenauen Status-Reset aus dem Historien-Tab.
* <p>
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
* ohne die Versuchshistorie zu löschen:
* <ul>
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
* <li>{@code content_error_count} → {@code 0}</li>
* <li>{@code transient_error_count} → {@code 0}</li>
* <li>{@code last_failure_instant} → {@code null}</li>
* </ul>
* Nicht geändert werden: {@code created_at}, {@code last_success_instant},
* {@code last_target_path}, {@code last_target_file_name} sowie alle
* {@code processing_attempt}-Einträge, die vollständig erhalten bleiben.
* <p>
* Nach dem Reset gilt das Dokument beim nächsten Verarbeitungslauf als verarbeitbar,
* da {@code READY_FOR_AI} der einzige Trigger für die Verarbeitungslogik ist.
* <p>
* <strong>Abgrenzung:</strong> Dieser Use-Case unterscheidet sich von
* {@link DefaultResetDocumentStatusUseCase}, der alle Persistenzdaten (Stammsatz und
* Versuchshistorie) vollständig löscht und das Dokument so behandelt, als wäre es
* noch nie verarbeitet worden.
*/
public class DefaultHistoryResetDocumentStatusUseCase {
private static final Logger logger = LogManager.getLogger(DefaultHistoryResetDocumentStatusUseCase.class);
private final UnitOfWorkPort unitOfWorkPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Persistenz-Port.
*
* @param unitOfWorkPort Port für transaktionale Persistenzoperationen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code unitOfWorkPort} null ist
*/
public DefaultHistoryResetDocumentStatusUseCase(UnitOfWorkPort unitOfWorkPort) {
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort darf nicht null sein");
}
/**
* Führt den feldgenauen Status-Reset für den angegebenen Fingerprint durch.
* <p>
* Die Operation ist atomar: entweder werden alle vier Felder aktualisiert,
* oder keine Änderung findet statt (Rollback).
*
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
* @throws NullPointerException wenn {@code fingerprint} null ist
*/
public void resetStatus(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
unitOfWorkPort.executeInTransaction(tx -> tx.resetDocumentStatusForRetry(fingerprint));
logger.info("Feldgenauer Status-Reset durchgeführt für Fingerprint: {}", fingerprint.sha256Hex());
}
}