#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:
+16
-2
@@ -429,7 +429,13 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
private final GuiBatchRunTab batchRunTab;
|
private final GuiBatchRunTab batchRunTab;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dritter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
|
* Dritter Haupt-Tab: Historien-Tab „Verlauf". Wird während der Workspace-Konstruktion
|
||||||
|
* erstellt und in den {@link #tabPane} eingehängt.
|
||||||
|
*/
|
||||||
|
private final de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab historyTab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vierter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
|
||||||
* und in den {@link #tabPane} eingehängt.
|
* und in den {@link #tabPane} eingehängt.
|
||||||
*/
|
*/
|
||||||
private final GuiPromptEditorTab promptEditorTab;
|
private final GuiPromptEditorTab promptEditorTab;
|
||||||
@@ -518,6 +524,14 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
this::editorSourceFolder,
|
this::editorSourceFolder,
|
||||||
this::editorTargetFolder);
|
this::editorTargetFolder);
|
||||||
|
|
||||||
|
this.historyTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab(
|
||||||
|
effectiveContext.historyOverviewPort(),
|
||||||
|
effectiveContext.historyDetailsPort(),
|
||||||
|
effectiveContext.historyResetDocumentStatusPort(),
|
||||||
|
effectiveContext.deleteDocumentHistoryPort(),
|
||||||
|
this.batchRunTab::isRunning,
|
||||||
|
this::loadedConfigurationPath);
|
||||||
|
|
||||||
String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile();
|
String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile();
|
||||||
int maxTitleLength;
|
int maxTitleLength;
|
||||||
try {
|
try {
|
||||||
@@ -1296,7 +1310,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
scrollPane.setPadding(new Insets(0));
|
scrollPane.setPadding(new Insets(0));
|
||||||
editorTab.setContent(scrollPane);
|
editorTab.setContent(scrollPane);
|
||||||
|
|
||||||
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), promptEditorTab.tab());
|
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), historyTab.tab(), promptEditorTab.tab());
|
||||||
root.setCenter(tabPane);
|
root.setCenter(tabPane);
|
||||||
|
|
||||||
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
|
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
|
||||||
|
|||||||
+52
-5
@@ -11,6 +11,10 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
|
|||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
@@ -68,7 +72,11 @@ public record GuiStartupContext(
|
|||||||
GuiManualFileCopyPort manualFileCopyPort,
|
GuiManualFileCopyPort manualFileCopyPort,
|
||||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||||
String applicationVersion,
|
String applicationVersion,
|
||||||
GuiPromptEditorPort promptEditorPort) {
|
GuiPromptEditorPort promptEditorPort,
|
||||||
|
GuiHistoryOverviewPort historyOverviewPort,
|
||||||
|
GuiHistoryDetailsPort historyDetailsPort,
|
||||||
|
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
|
||||||
|
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a fully wired startup context.
|
* Creates a fully wired startup context.
|
||||||
@@ -134,6 +142,14 @@ public record GuiStartupContext(
|
|||||||
// Null-Fallback für Testumgebungen ohne gepacktes JAR
|
// Null-Fallback für Testumgebungen ohne gepacktes JAR
|
||||||
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
|
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
|
||||||
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
||||||
|
historyOverviewPort = Objects.requireNonNull(historyOverviewPort,
|
||||||
|
"historyOverviewPort must not be null");
|
||||||
|
historyDetailsPort = Objects.requireNonNull(historyDetailsPort,
|
||||||
|
"historyDetailsPort must not be null");
|
||||||
|
historyResetDocumentStatusPort = Objects.requireNonNull(historyResetDocumentStatusPort,
|
||||||
|
"historyResetDocumentStatusPort must not be null");
|
||||||
|
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
|
||||||
|
"deleteDocumentHistoryPort must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,7 +191,9 @@ public record GuiStartupContext(
|
|||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(), noOpDeleteHistoryPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,7 +229,9 @@ public record GuiStartupContext(
|
|||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(), noOpDeleteHistoryPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -247,7 +267,9 @@ public record GuiStartupContext(
|
|||||||
technicalTestOrchestrator, correctionExecutionService,
|
technicalTestOrchestrator, correctionExecutionService,
|
||||||
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
||||||
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(), noOpDeleteHistoryPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||||
@@ -363,7 +385,11 @@ public record GuiStartupContext(
|
|||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(),
|
noOpHistoricalDocumentContextPort(),
|
||||||
"dev",
|
"dev",
|
||||||
noOpPromptEditorPort());
|
noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(),
|
||||||
|
noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(),
|
||||||
|
noOpDeleteHistoryPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuiPromptEditorPort noOpPromptEditorPort() {
|
private static GuiPromptEditorPort noOpPromptEditorPort() {
|
||||||
@@ -391,4 +417,25 @@ public record GuiStartupContext(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort
|
||||||
|
noOpHistoryOverviewPort() {
|
||||||
|
return (configFilePath, query) -> new de.gecheckt.pdf.umbenenner.application.usecase
|
||||||
|
.DefaultHistoryOverviewUseCase.HistoryOverviewResult(java.util.List.of(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort
|
||||||
|
noOpHistoryDetailsPort() {
|
||||||
|
return (configFilePath, fingerprint) -> java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort
|
||||||
|
noOpHistoryResetPort() {
|
||||||
|
return (configFilePath, fingerprint) -> { /* kein Reset in diesem Startkontext */ };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort
|
||||||
|
noOpDeleteHistoryPort() {
|
||||||
|
return (configFilePath, fingerprint) -> { /* kein Löschen in diesem Startkontext */ };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Löscht den Dokument-Stammsatz und alle zugehörigen Verarbeitungsversuche
|
||||||
|
* vollständig und transaktional. Die Löschung ist destruktiv und nicht
|
||||||
|
* rückgängig zu machen.
|
||||||
|
* <p>
|
||||||
|
* Die GUI muss vor dem Aufruf dieses Ports einen Bestätigungsdialog mit
|
||||||
|
* explizitem Warnhinweis anzeigen.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis die Löschung abgeschlossen ist.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiDeleteDocumentHistoryPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||||
|
* <p>
|
||||||
|
* Die Löschung erfolgt in der korrekten Reihenfolge innerhalb einer Transaktion:
|
||||||
|
* zuerst alle {@code processing_attempt}-Einträge, dann der {@code document_record}-Stammsatz.
|
||||||
|
* Ist kein Datensatz vorhanden, kehrt die Methode stillschweigend zurück.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
void deleteHistory(Path configFilePath, DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||||
|
* Es ist eine modul-interne Brücke, über die Bootstrap die Detaildaten
|
||||||
|
* für einen ausgewählten Dokumenteintrag bereitstellt.
|
||||||
|
* <p>
|
||||||
|
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
|
||||||
|
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis das Ergebnis vollständig vorliegt.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiHistoryDetailsPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return Optional mit den Detaildaten, oder leer wenn kein Eintrag gefunden wurde
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
Optional<HistoryDetailsResult> loadDetails(Path configFilePath, DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||||
|
* Es ist eine modul-interne Brücke, über die Bootstrap die Dokumentenliste
|
||||||
|
* für den Historien-Tab bereitstellt, ohne dass der GUI-Adapter direkt auf
|
||||||
|
* Repository-Implementierungen zugreift.
|
||||||
|
* <p>
|
||||||
|
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
|
||||||
|
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann,
|
||||||
|
* ohne den Pfad global zu speichern.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis das Ergebnis vollständig vorliegt.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiHistoryOverviewPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die gefilterte Dokumentenübersicht für den Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Bei mehr als 500 Treffern enthält das Ergebnis genau 500 Zeilen und
|
||||||
|
* {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @return Ergebnisobjekt mit Trefferliste und {@code hasMore}-Flag; nie {@code null}
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
HistoryOverviewResult loadOverview(Path configFilePath, HistoryQuery query);
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryResetDocumentStatusUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Führt einen feldgenauen Status-Reset durch: ausschließlich {@code overall_status},
|
||||||
|
* {@code content_error_count}, {@code transient_error_count} und
|
||||||
|
* {@code last_failure_instant} werden zurückgesetzt. Die Versuchshistorie bleibt
|
||||||
|
* vollständig erhalten. Nach dem Reset gilt das Dokument beim nächsten
|
||||||
|
* Verarbeitungslauf als verarbeitbar.
|
||||||
|
* <p>
|
||||||
|
* <strong>Abgrenzung zu {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort}:</strong>
|
||||||
|
* Der bestehende Reset-Port im {@code batchrun}-Paket löscht alle Persistenzdaten
|
||||||
|
* (Stammsatz und Versuchshistorie) vollständig. Dieser Port hier führt ausschließlich
|
||||||
|
* einen feldgenauen Update durch und lässt die Versuchshistorie unangetastet.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis die Operation abgeschlossen ist.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiHistoryResetDocumentStatusPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Status des Dokuments feldgenau zurück.
|
||||||
|
* <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>
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
void resetStatus(Path configFilePath, DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+794
@@ -0,0 +1,794 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.function.BooleanSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
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.DocumentHistoryRow;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
|
import javafx.scene.control.ComboBox;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.ScrollPane;
|
||||||
|
import javafx.scene.control.SelectionMode;
|
||||||
|
import javafx.scene.control.SplitPane;
|
||||||
|
import javafx.scene.control.Tab;
|
||||||
|
import javafx.scene.control.TableCell;
|
||||||
|
import javafx.scene.control.TableColumn;
|
||||||
|
import javafx.scene.control.TableView;
|
||||||
|
import javafx.scene.control.TextArea;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.GridPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dritter Haupt-Tab des JavaFX-Editorfensters: der Historien-Tab „Verlauf".
|
||||||
|
* <p>
|
||||||
|
* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
|
||||||
|
* zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%),
|
||||||
|
* rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%).
|
||||||
|
*
|
||||||
|
* <h2>Layout</h2>
|
||||||
|
* <pre>
|
||||||
|
* ┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
* │ [ Suchfeld ] [ Status ▾ ] [ Aktualisieren ] │
|
||||||
|
* ├────────────────────────┬────────────────────────────────────────┤
|
||||||
|
* │ Dokumentenliste (~55%) │ Detailbereich (~45%) │
|
||||||
|
* │ │ Dokument-Info │
|
||||||
|
* │ │ Versuche-Tabelle │
|
||||||
|
* │ │ KI-Begründung │
|
||||||
|
* ├────────────────────────┴────────────────────────────────────────┤
|
||||||
|
* │ [ Status zurücksetzen ] [ Eintrag löschen ] Statuszeile │
|
||||||
|
* └─────────────────────────────────────────────────────────────────┘
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>Alle DB-Zugriffe laufen auf einem Hintergrund-Worker-Thread.
|
||||||
|
* UI-Updates erfolgen ausschließlich via {@code Platform.runLater()}.
|
||||||
|
* Destruktive Aktionen (Reset, Löschen) sind während eines aktiven
|
||||||
|
* Verarbeitungslaufs deaktiviert.
|
||||||
|
*/
|
||||||
|
public final class GuiHistoryTab {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiHistoryTab.class);
|
||||||
|
|
||||||
|
private static final String TAB_TITLE = "Verlauf";
|
||||||
|
private static final String EMPTY_DB_TEXT = "Noch keine Verarbeitungen vorhanden.";
|
||||||
|
private static final String TOO_MANY_RESULTS_TEXT =
|
||||||
|
"Weitere Einträge vorhanden – Filter verwenden um die Trefferliste einzuschränken.";
|
||||||
|
private static final String DETAIL_PLACEHOLDER = "Dokument auswählen für Details";
|
||||||
|
private static final String NO_REASONING_TEXT = "Kein KI-Reasoning für diesen Versuch vorhanden.";
|
||||||
|
private static final String LOADING_TEXT = "Wird geladen …";
|
||||||
|
private static final String LAUF_AKTIV_HINWEIS = "Aktion während Verarbeitungslauf nicht möglich.";
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIMESTAMP_FMT =
|
||||||
|
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm").withZone(ZoneId.systemDefault());
|
||||||
|
|
||||||
|
// ---- Bridge-Ports ---------------------------------------------------
|
||||||
|
private final GuiHistoryOverviewPort overviewPort;
|
||||||
|
private final GuiHistoryDetailsPort detailsPort;
|
||||||
|
private final GuiHistoryResetDocumentStatusPort resetPort;
|
||||||
|
private final GuiDeleteDocumentHistoryPort deletePort;
|
||||||
|
private final BooleanSupplier runningCheck;
|
||||||
|
/** Liefert den Pfad zur aktuell geladenen Konfigurationsdatei, oder {@code null} wenn keine geladen. */
|
||||||
|
private final Supplier<Path> configPathSupplier;
|
||||||
|
|
||||||
|
// ---- JavaFX-Knoten --------------------------------------------------
|
||||||
|
private final Tab tab = new Tab(TAB_TITLE);
|
||||||
|
|
||||||
|
private final TextField searchField = new TextField();
|
||||||
|
private final ComboBox<String> statusFilterBox = new ComboBox<>();
|
||||||
|
private final Button refreshButton = new Button("Aktualisieren");
|
||||||
|
|
||||||
|
private final TableView<DocumentHistoryRow> overviewTable = new TableView<>();
|
||||||
|
private final ObservableList<DocumentHistoryRow> overviewItems = FXCollections.observableArrayList();
|
||||||
|
|
||||||
|
private final Label statusBarLabel = new Label();
|
||||||
|
private final Label moreThanMaxLabel = new Label();
|
||||||
|
|
||||||
|
// Detailbereich
|
||||||
|
private final GridPane detailGrid = new GridPane();
|
||||||
|
private final Label detailFingerprintLabel = new Label();
|
||||||
|
private final Label detailSourceFileLabel = new Label();
|
||||||
|
private final Label detailSourcePathLabel = new Label();
|
||||||
|
private final Label detailStatusLabel = new Label();
|
||||||
|
private final Label detailCreatedLabel = new Label();
|
||||||
|
private final Label detailUpdatedLabel = new Label();
|
||||||
|
|
||||||
|
private final TableView<ProcessingAttempt> attemptsTable = new TableView<>();
|
||||||
|
private final ObservableList<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList();
|
||||||
|
private final TextArea reasoningArea = new TextArea();
|
||||||
|
|
||||||
|
private final Button resetButton = new Button("Status zurücksetzen");
|
||||||
|
private final Button deleteButton = new Button("Eintrag löschen");
|
||||||
|
|
||||||
|
// ---- Zustand --------------------------------------------------------
|
||||||
|
private final ExecutorService workerPool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den Historien-Tab.
|
||||||
|
*
|
||||||
|
* @param overviewPort Brücke zur Dokumentenübersicht; darf nicht {@code null} sein
|
||||||
|
* @param detailsPort Brücke zur Detailansicht; darf nicht {@code null} sein
|
||||||
|
* @param resetPort Brücke zum feldgenauen Status-Reset; darf nicht {@code null} sein
|
||||||
|
* @param deletePort Brücke zum vollständigen Löschen; darf nicht {@code null} sein
|
||||||
|
* @param runningCheck Liefert {@code true} wenn gerade ein Verarbeitungslauf aktiv ist;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param configPathSupplier Liefert den Pfad zur aktuell geladenen Konfigurationsdatei,
|
||||||
|
* oder {@code null} wenn keine geladen ist; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public GuiHistoryTab(
|
||||||
|
GuiHistoryOverviewPort overviewPort,
|
||||||
|
GuiHistoryDetailsPort detailsPort,
|
||||||
|
GuiHistoryResetDocumentStatusPort resetPort,
|
||||||
|
GuiDeleteDocumentHistoryPort deletePort,
|
||||||
|
BooleanSupplier runningCheck,
|
||||||
|
Supplier<Path> configPathSupplier) {
|
||||||
|
this.overviewPort = Objects.requireNonNull(overviewPort, "overviewPort darf nicht null sein");
|
||||||
|
this.detailsPort = Objects.requireNonNull(detailsPort, "detailsPort darf nicht null sein");
|
||||||
|
this.resetPort = Objects.requireNonNull(resetPort, "resetPort darf nicht null sein");
|
||||||
|
this.deletePort = Objects.requireNonNull(deletePort, "deletePort darf nicht null sein");
|
||||||
|
this.runningCheck = Objects.requireNonNull(runningCheck, "runningCheck darf nicht null sein");
|
||||||
|
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier darf nicht null sein");
|
||||||
|
|
||||||
|
this.workerPool = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "HistoryTabWorker");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
buildUi();
|
||||||
|
wireEvents();
|
||||||
|
tab.setClosable(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den JavaFX-{@link Tab}, der in die TabPane eingefügt werden kann.
|
||||||
|
*
|
||||||
|
* @return der Tab; nie {@code null}
|
||||||
|
*/
|
||||||
|
public Tab tab() {
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Dokumentenübersicht neu – muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
* Wird vom Tab-Wechsel-Listener ausgelöst.
|
||||||
|
*/
|
||||||
|
public void refresh() {
|
||||||
|
loadOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// UI-Aufbau
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void buildUi() {
|
||||||
|
// --- Toolbar ---
|
||||||
|
searchField.setPromptText("Suche nach Dateiname …");
|
||||||
|
searchField.setPrefWidth(300);
|
||||||
|
Tooltip.install(searchField, new Tooltip(
|
||||||
|
"Freitextsuche über Quell- und Zieldateiname (Groß-/Kleinschreibung egal)."));
|
||||||
|
|
||||||
|
statusFilterBox.getItems().add("Alle Status");
|
||||||
|
for (ProcessingStatus s : ProcessingStatus.values()) {
|
||||||
|
statusFilterBox.getItems().add(s.name());
|
||||||
|
}
|
||||||
|
statusFilterBox.getSelectionModel().selectFirst();
|
||||||
|
Tooltip.install(statusFilterBox, new Tooltip("Status-Filter: nur Einträge mit diesem Status anzeigen."));
|
||||||
|
|
||||||
|
refreshButton.setTooltip(new Tooltip("Dokumentenliste neu aus der Datenbank laden."));
|
||||||
|
|
||||||
|
Region spacer = new Region();
|
||||||
|
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||||
|
|
||||||
|
HBox toolbar = new HBox(8, searchField, statusFilterBox, spacer, refreshButton);
|
||||||
|
toolbar.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
toolbar.setPadding(new Insets(6, 8, 6, 8));
|
||||||
|
|
||||||
|
// --- Dokumentenliste (links) ---
|
||||||
|
buildOverviewTable();
|
||||||
|
|
||||||
|
moreThanMaxLabel.setStyle("-fx-text-fill: #d98200; -fx-font-style: italic;");
|
||||||
|
moreThanMaxLabel.setVisible(false);
|
||||||
|
moreThanMaxLabel.setManaged(false);
|
||||||
|
|
||||||
|
VBox leftPane = new VBox(4, overviewTable, moreThanMaxLabel);
|
||||||
|
VBox.setVgrow(overviewTable, Priority.ALWAYS);
|
||||||
|
leftPane.setPadding(new Insets(0, 4, 0, 0));
|
||||||
|
|
||||||
|
// --- Detailbereich (rechts) ---
|
||||||
|
VBox rightPane = buildDetailPane();
|
||||||
|
|
||||||
|
// --- SplitPane ---
|
||||||
|
SplitPane splitPane = new SplitPane(leftPane, rightPane);
|
||||||
|
splitPane.setDividerPositions(0.55);
|
||||||
|
|
||||||
|
// --- Aktionsleiste unten ---
|
||||||
|
resetButton.setTooltip(new Tooltip(
|
||||||
|
"Setzt Status, Fehlerzähler und letzten Fehlerzeitpunkt zurück. "
|
||||||
|
+ "Versuche bleiben erhalten. Das Dokument wird beim nächsten Lauf erneut verarbeitet."));
|
||||||
|
deleteButton.setTooltip(new Tooltip(
|
||||||
|
"Löscht den Eintrag und alle Versuche vollständig. "
|
||||||
|
+ "Diese Aktion ist nicht rückgängig zu machen."));
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
deleteButton.setDisable(true);
|
||||||
|
|
||||||
|
statusBarLabel.setStyle("-fx-text-fill: #555555; -fx-font-style: italic;");
|
||||||
|
|
||||||
|
HBox actionBar = new HBox(8, resetButton, deleteButton, spacerNew(), statusBarLabel);
|
||||||
|
actionBar.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
actionBar.setPadding(new Insets(6, 8, 6, 8));
|
||||||
|
|
||||||
|
// --- Gesamtlayout ---
|
||||||
|
BorderPane content = new BorderPane();
|
||||||
|
content.setTop(toolbar);
|
||||||
|
content.setCenter(splitPane);
|
||||||
|
content.setBottom(actionBar);
|
||||||
|
BorderPane.setMargin(toolbar, Insets.EMPTY);
|
||||||
|
|
||||||
|
tab.setContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildOverviewTable() {
|
||||||
|
overviewTable.setItems(overviewItems);
|
||||||
|
overviewTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||||
|
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
|
||||||
|
overviewTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
|
||||||
|
|
||||||
|
// Status-Icon-Spalte
|
||||||
|
TableColumn<DocumentHistoryRow, String> statusCol = new TableColumn<>("Status");
|
||||||
|
statusCol.setCellValueFactory(cell ->
|
||||||
|
new SimpleStringProperty(statusIcon(cell.getValue().overallStatus())));
|
||||||
|
statusCol.setCellFactory(col -> new TableCell<>() {
|
||||||
|
@Override
|
||||||
|
protected void updateItem(String icon, boolean empty) {
|
||||||
|
super.updateItem(icon, empty);
|
||||||
|
if (empty || icon == null) {
|
||||||
|
setText(null);
|
||||||
|
setTooltip(null);
|
||||||
|
} else {
|
||||||
|
setText(icon);
|
||||||
|
DocumentHistoryRow row = getTableView().getItems().get(getIndex());
|
||||||
|
setStyle("-fx-text-fill: " + statusColor(row.overallStatus()) + "; -fx-font-weight: bold;");
|
||||||
|
setTooltip(new Tooltip(statusTooltip(row.overallStatus())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
statusCol.setPrefWidth(60);
|
||||||
|
statusCol.setMaxWidth(70);
|
||||||
|
|
||||||
|
// Quelldateiname
|
||||||
|
TableColumn<DocumentHistoryRow, String> sourceCol = new TableColumn<>("Quelldatei");
|
||||||
|
sourceCol.setCellValueFactory(cell ->
|
||||||
|
new SimpleStringProperty(cell.getValue().sourceFileName()));
|
||||||
|
sourceCol.setCellFactory(col -> ellipsisCell());
|
||||||
|
|
||||||
|
// Zieldateiname
|
||||||
|
TableColumn<DocumentHistoryRow, String> targetCol = new TableColumn<>("Zieldatei");
|
||||||
|
targetCol.setCellValueFactory(cell ->
|
||||||
|
new SimpleStringProperty(
|
||||||
|
cell.getValue().targetFileName() != null ? cell.getValue().targetFileName() : "—"));
|
||||||
|
targetCol.setCellFactory(col -> ellipsisCell());
|
||||||
|
|
||||||
|
// Letzter Versuch
|
||||||
|
TableColumn<DocumentHistoryRow, String> updatedCol = new TableColumn<>("Letzter Versuch");
|
||||||
|
updatedCol.setCellValueFactory(cell ->
|
||||||
|
new SimpleStringProperty(formatInstant(cell.getValue().updatedAt())));
|
||||||
|
updatedCol.setPrefWidth(140);
|
||||||
|
updatedCol.setMaxWidth(160);
|
||||||
|
|
||||||
|
// Anzahl Versuche
|
||||||
|
TableColumn<DocumentHistoryRow, String> countCol = new TableColumn<>("Versuche");
|
||||||
|
countCol.setCellValueFactory(cell ->
|
||||||
|
new SimpleStringProperty(String.valueOf(cell.getValue().attemptCount())));
|
||||||
|
countCol.setPrefWidth(70);
|
||||||
|
countCol.setMaxWidth(80);
|
||||||
|
|
||||||
|
overviewTable.getColumns().setAll(statusCol, sourceCol, targetCol, updatedCol, countCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
private VBox buildDetailPane() {
|
||||||
|
// Dokument-Info
|
||||||
|
detailGrid.setHgap(8);
|
||||||
|
detailGrid.setVgap(4);
|
||||||
|
detailGrid.setPadding(new Insets(8));
|
||||||
|
|
||||||
|
addDetailRow(0, "Fingerprint:", detailFingerprintLabel);
|
||||||
|
addDetailRow(1, "Quelldatei:", detailSourceFileLabel);
|
||||||
|
addDetailRow(2, "Quellpfad:", detailSourcePathLabel);
|
||||||
|
addDetailRow(3, "Status:", detailStatusLabel);
|
||||||
|
addDetailRow(4, "Erstellt:", detailCreatedLabel);
|
||||||
|
addDetailRow(5, "Aktualisiert:", detailUpdatedLabel);
|
||||||
|
|
||||||
|
Label detailTitle = new Label("Dokument-Details");
|
||||||
|
detailTitle.setStyle("-fx-font-weight: bold;");
|
||||||
|
|
||||||
|
// Versuche-Tabelle
|
||||||
|
buildAttemptsTable();
|
||||||
|
Label attemptsTitle = new Label("Verarbeitungsversuche");
|
||||||
|
attemptsTitle.setStyle("-fx-font-weight: bold;");
|
||||||
|
|
||||||
|
// KI-Begründung
|
||||||
|
reasoningArea.setEditable(false);
|
||||||
|
reasoningArea.setWrapText(true);
|
||||||
|
reasoningArea.setPrefRowCount(4);
|
||||||
|
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||||
|
Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)");
|
||||||
|
reasoningTitle.setStyle("-fx-font-weight: bold;");
|
||||||
|
|
||||||
|
VBox rightPane = new VBox(8,
|
||||||
|
detailTitle, detailGrid,
|
||||||
|
attemptsTitle, attemptsTable,
|
||||||
|
reasoningTitle, reasoningArea);
|
||||||
|
rightPane.setPadding(new Insets(4, 8, 4, 4));
|
||||||
|
VBox.setVgrow(attemptsTable, Priority.ALWAYS);
|
||||||
|
|
||||||
|
ScrollPane scroll = new ScrollPane(rightPane);
|
||||||
|
scroll.setFitToWidth(true);
|
||||||
|
scroll.setFitToHeight(true);
|
||||||
|
|
||||||
|
VBox wrapper = new VBox(scroll);
|
||||||
|
VBox.setVgrow(scroll, Priority.ALWAYS);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildAttemptsTable() {
|
||||||
|
attemptsTable.setItems(attemptsItems);
|
||||||
|
attemptsTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||||
|
attemptsTable.setPlaceholder(new Label("Keine Versuche vorhanden."));
|
||||||
|
attemptsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
|
||||||
|
attemptsTable.setPrefHeight(150);
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> numCol = new TableColumn<>("#");
|
||||||
|
numCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(String.valueOf(c.getValue().attemptNumber())));
|
||||||
|
numCol.setPrefWidth(40);
|
||||||
|
numCol.setMaxWidth(50);
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> dateCol = new TableColumn<>("Datum");
|
||||||
|
dateCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(formatInstant(c.getValue().endedAt())));
|
||||||
|
dateCol.setPrefWidth(130);
|
||||||
|
dateCol.setMaxWidth(150);
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> statusCol = new TableColumn<>("Status");
|
||||||
|
statusCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(
|
||||||
|
statusIcon(c.getValue().status()) + " " + c.getValue().status().name()));
|
||||||
|
statusCol.setPrefWidth(140);
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> providerCol = new TableColumn<>("Provider");
|
||||||
|
providerCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(
|
||||||
|
c.getValue().aiProvider() != null ? c.getValue().aiProvider() : "—"));
|
||||||
|
providerCol.setPrefWidth(90);
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> modelCol = new TableColumn<>("Modell");
|
||||||
|
modelCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(
|
||||||
|
c.getValue().modelName() != null ? c.getValue().modelName() : "—"));
|
||||||
|
modelCol.setCellFactory(col -> ellipsisCell());
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> fileNameCol = new TableColumn<>("Vorgeschlagener Name");
|
||||||
|
fileNameCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(
|
||||||
|
c.getValue().finalTargetFileName() != null
|
||||||
|
? c.getValue().finalTargetFileName() : "—"));
|
||||||
|
fileNameCol.setCellFactory(col -> ellipsisCell());
|
||||||
|
|
||||||
|
attemptsTable.getColumns().setAll(numCol, dateCol, statusCol, providerCol, modelCol, fileNameCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Event-Verdrahtung
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void wireEvents() {
|
||||||
|
refreshButton.setOnAction(e -> loadOverview());
|
||||||
|
|
||||||
|
// Debounce-artige Aktualisierung bei Texteingabe: direkte Suche bei Enter,
|
||||||
|
// sonst über Fokus-Verlust oder expliziten Aktualisieren-Button
|
||||||
|
searchField.setOnAction(e -> loadOverview());
|
||||||
|
|
||||||
|
statusFilterBox.setOnAction(e -> loadOverview());
|
||||||
|
|
||||||
|
// Detailbereich bei Zeilenselektion
|
||||||
|
overviewTable.getSelectionModel().selectedItemProperty().addListener(
|
||||||
|
(obs, old, selected) -> {
|
||||||
|
if (selected == null) {
|
||||||
|
clearDetailPane();
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
deleteButton.setDisable(true);
|
||||||
|
} else {
|
||||||
|
resetButton.setDisable(runningCheck.getAsBoolean());
|
||||||
|
deleteButton.setDisable(runningCheck.getAsBoolean());
|
||||||
|
loadDetails(selected.fingerprint());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resetButton.setOnAction(e -> handleResetAction());
|
||||||
|
deleteButton.setOnAction(e -> handleDeleteAction());
|
||||||
|
|
||||||
|
// Tab soll beim ersten Betreten automatisch laden
|
||||||
|
tab.selectedProperty().addListener((obs, oldVal, selected) -> {
|
||||||
|
if (Boolean.TRUE.equals(selected)) {
|
||||||
|
loadOverview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Daten laden (Worker-Thread)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void loadOverview() {
|
||||||
|
statusBarLabel.setText(LOADING_TEXT);
|
||||||
|
overviewItems.clear();
|
||||||
|
moreThanMaxLabel.setVisible(false);
|
||||||
|
moreThanMaxLabel.setManaged(false);
|
||||||
|
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
statusBarLabel.setText("Keine Konfiguration geladen – bitte zuerst eine Konfigurationsdatei öffnen.");
|
||||||
|
overviewTable.setPlaceholder(new Label("Keine Konfiguration geladen."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String searchText = searchField.getText();
|
||||||
|
String selectedStatus = statusFilterBox.getSelectionModel().getSelectedItem();
|
||||||
|
String statusFilter = (selectedStatus == null || "Alle Status".equals(selectedStatus))
|
||||||
|
? null : selectedStatus;
|
||||||
|
|
||||||
|
HistoryQuery query = new HistoryQuery(searchText, statusFilter, HistoryQuery.DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
workerPool.submit(() -> {
|
||||||
|
try {
|
||||||
|
HistoryOverviewResult result = overviewPort.loadOverview(configPath, query);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
overviewItems.setAll(result.rows());
|
||||||
|
if (result.hasMore()) {
|
||||||
|
moreThanMaxLabel.setText(TOO_MANY_RESULTS_TEXT);
|
||||||
|
moreThanMaxLabel.setVisible(true);
|
||||||
|
moreThanMaxLabel.setManaged(true);
|
||||||
|
} else {
|
||||||
|
moreThanMaxLabel.setVisible(false);
|
||||||
|
moreThanMaxLabel.setManaged(false);
|
||||||
|
}
|
||||||
|
if (result.rows().isEmpty()) {
|
||||||
|
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
|
||||||
|
statusBarLabel.setText("Keine Einträge gefunden.");
|
||||||
|
} else {
|
||||||
|
statusBarLabel.setText(result.rows().size() + " Einträge geladen.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.error("Fehler beim Laden der Historienübersicht: {}", ex.getMessage(), ex);
|
||||||
|
Platform.runLater(() ->
|
||||||
|
statusBarLabel.setText("Fehler beim Laden: " + ex.getMessage()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadDetails(DocumentFingerprint fingerprint) {
|
||||||
|
reasoningArea.setText(LOADING_TEXT);
|
||||||
|
attemptsItems.clear();
|
||||||
|
clearDetailFields();
|
||||||
|
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
workerPool.submit(() -> {
|
||||||
|
try {
|
||||||
|
Optional<HistoryDetailsResult> result = detailsPort.loadDetails(configPath, fingerprint);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
if (result.isEmpty()) {
|
||||||
|
clearDetailPane();
|
||||||
|
statusBarLabel.setText("Eintrag nicht mehr vorhanden.");
|
||||||
|
} else {
|
||||||
|
populateDetailPane(result.get());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.error("Fehler beim Laden der Dokumentdetails für {}: {}",
|
||||||
|
fingerprint.sha256Hex(), ex.getMessage(), ex);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
reasoningArea.setText("Fehler beim Laden der Details: " + ex.getMessage());
|
||||||
|
statusBarLabel.setText("Fehler beim Laden der Details.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Aktionen
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void handleResetAction() {
|
||||||
|
if (runningCheck.getAsBoolean()) {
|
||||||
|
showInfo(LAUF_AKTIV_HINWEIS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
|
||||||
|
if (selected == null) return;
|
||||||
|
|
||||||
|
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
|
||||||
|
confirm.setTitle("Status zurücksetzen");
|
||||||
|
confirm.setHeaderText("Status zurücksetzen?");
|
||||||
|
confirm.setContentText(
|
||||||
|
"Setzt den Status des Dokuments auf READY_FOR_AI zurück.\n"
|
||||||
|
+ "Fehlerzähler und letzter Fehlerzeitpunkt werden gelöscht.\n"
|
||||||
|
+ "Die Versuchshistorie bleibt vollständig erhalten.\n\n"
|
||||||
|
+ "Das Dokument wird beim nächsten Verarbeitungslauf erneut verarbeitet.\n\n"
|
||||||
|
+ "Quelldatei: " + selected.sourceFileName());
|
||||||
|
Optional<ButtonType> choice = confirm.showAndWait();
|
||||||
|
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
|
||||||
|
|
||||||
|
DocumentFingerprint fp = selected.fingerprint();
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
showInfo("Keine Konfiguration geladen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
deleteButton.setDisable(true);
|
||||||
|
statusBarLabel.setText("Status wird zurückgesetzt …");
|
||||||
|
|
||||||
|
workerPool.submit(() -> {
|
||||||
|
try {
|
||||||
|
resetPort.resetStatus(configPath, fp);
|
||||||
|
LOG.info("Status-Reset durchgeführt für Fingerprint: {}", fp.sha256Hex());
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
statusBarLabel.setText("Status erfolgreich zurückgesetzt.");
|
||||||
|
loadOverview();
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.error("Status-Reset fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
statusBarLabel.setText("Fehler beim Status-Reset: " + ex.getMessage());
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
deleteButton.setDisable(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleDeleteAction() {
|
||||||
|
if (runningCheck.getAsBoolean()) {
|
||||||
|
showInfo(LAUF_AKTIV_HINWEIS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
|
||||||
|
if (selected == null) return;
|
||||||
|
|
||||||
|
Alert confirm = new Alert(Alert.AlertType.WARNING);
|
||||||
|
confirm.setTitle("Eintrag löschen");
|
||||||
|
confirm.setHeaderText("Eintrag vollständig löschen?");
|
||||||
|
confirm.setContentText(
|
||||||
|
"Der Stammsatz und ALLE Verarbeitungsversuche werden unwiderruflich gelöscht.\n"
|
||||||
|
+ "Diese Aktion kann nicht rückgängig gemacht werden.\n\n"
|
||||||
|
+ "Quelldatei: " + selected.sourceFileName());
|
||||||
|
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
|
||||||
|
Optional<ButtonType> choice = confirm.showAndWait();
|
||||||
|
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
|
||||||
|
|
||||||
|
DocumentFingerprint fp = selected.fingerprint();
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
showInfo("Keine Konfiguration geladen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
deleteButton.setDisable(true);
|
||||||
|
statusBarLabel.setText("Eintrag wird gelöscht …");
|
||||||
|
|
||||||
|
workerPool.submit(() -> {
|
||||||
|
try {
|
||||||
|
deletePort.deleteHistory(configPath, fp);
|
||||||
|
LOG.info("Dokumenteintrag gelöscht für Fingerprint: {}", fp.sha256Hex());
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
statusBarLabel.setText("Eintrag erfolgreich gelöscht.");
|
||||||
|
clearDetailPane();
|
||||||
|
loadOverview();
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.error("Löschen fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
statusBarLabel.setText("Fehler beim Löschen: " + ex.getMessage());
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
deleteButton.setDisable(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Detail-Bereich befüllen / leeren
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void populateDetailPane(HistoryDetailsResult result) {
|
||||||
|
DocumentRecord record = result.record();
|
||||||
|
String fpFull = record.fingerprint().sha256Hex();
|
||||||
|
detailFingerprintLabel.setText(fpFull.substring(0, Math.min(12, fpFull.length())) + " …");
|
||||||
|
detailFingerprintLabel.setTooltip(new Tooltip(fpFull));
|
||||||
|
detailSourceFileLabel.setText(record.lastKnownSourceFileName());
|
||||||
|
detailSourcePathLabel.setText(record.lastKnownSourceLocator().value());
|
||||||
|
detailSourcePathLabel.setTooltip(new Tooltip(record.lastKnownSourceLocator().value()));
|
||||||
|
String icon = statusIcon(record.overallStatus());
|
||||||
|
detailStatusLabel.setText(icon + " " + record.overallStatus().name());
|
||||||
|
detailStatusLabel.setStyle("-fx-text-fill: " + statusColor(record.overallStatus()) + ";");
|
||||||
|
detailStatusLabel.setTooltip(new Tooltip(statusTooltip(record.overallStatus())));
|
||||||
|
detailCreatedLabel.setText(formatInstant(record.createdAt()));
|
||||||
|
detailUpdatedLabel.setText(formatInstant(record.updatedAt()));
|
||||||
|
|
||||||
|
attemptsItems.setAll(result.attempts());
|
||||||
|
|
||||||
|
// Neuesten Versuch selektieren und Begründung anzeigen
|
||||||
|
if (!result.attempts().isEmpty()) {
|
||||||
|
ProcessingAttempt last = result.attempts().get(result.attempts().size() - 1);
|
||||||
|
attemptsTable.getSelectionModel().select(last);
|
||||||
|
showReasoning(last);
|
||||||
|
} else {
|
||||||
|
reasoningArea.setText(NO_REASONING_TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// KI-Begründung bei Versuchs-Selektion aktualisieren
|
||||||
|
attemptsTable.getSelectionModel().selectedItemProperty().addListener(
|
||||||
|
(obs, old, attempt) -> {
|
||||||
|
if (attempt != null) {
|
||||||
|
showReasoning(attempt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showReasoning(ProcessingAttempt attempt) {
|
||||||
|
String reasoning = attempt.aiReasoning();
|
||||||
|
reasoningArea.setText(reasoning != null && !reasoning.isBlank()
|
||||||
|
? reasoning : NO_REASONING_TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearDetailPane() {
|
||||||
|
clearDetailFields();
|
||||||
|
attemptsItems.clear();
|
||||||
|
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearDetailFields() {
|
||||||
|
detailFingerprintLabel.setText("");
|
||||||
|
detailFingerprintLabel.setTooltip(null);
|
||||||
|
detailSourceFileLabel.setText("");
|
||||||
|
detailSourcePathLabel.setText("");
|
||||||
|
detailSourcePathLabel.setTooltip(null);
|
||||||
|
detailStatusLabel.setText("");
|
||||||
|
detailStatusLabel.setStyle("");
|
||||||
|
detailStatusLabel.setTooltip(null);
|
||||||
|
detailCreatedLabel.setText("");
|
||||||
|
detailUpdatedLabel.setText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Hilfsmethoden
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void addDetailRow(int row, String labelText, Label valueLabel) {
|
||||||
|
Label label = new Label(labelText);
|
||||||
|
label.setStyle("-fx-font-weight: bold;");
|
||||||
|
valueLabel.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
GridPane.setHgrow(valueLabel, Priority.ALWAYS);
|
||||||
|
detailGrid.add(label, 0, row);
|
||||||
|
detailGrid.add(valueLabel, 1, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatInstant(Instant instant) {
|
||||||
|
if (instant == null) return "—";
|
||||||
|
return TIMESTAMP_FMT.format(instant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String statusIcon(ProcessingStatus status) {
|
||||||
|
if (status == null) return "?";
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> "✓";
|
||||||
|
case FAILED_RETRYABLE -> "↻";
|
||||||
|
case FAILED_FINAL -> "×";
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> "≡";
|
||||||
|
case SKIPPED_FINAL_FAILURE -> "⊘";
|
||||||
|
case READY_FOR_AI -> "⟳";
|
||||||
|
case PROPOSAL_READY -> "◇";
|
||||||
|
case PROCESSING -> "▶";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String statusColor(ProcessingStatus status) {
|
||||||
|
if (status == null) return "#000000";
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> "#2e7d32";
|
||||||
|
case FAILED_RETRYABLE -> "#d98200";
|
||||||
|
case FAILED_FINAL -> "#c62828";
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> "#757575";
|
||||||
|
case SKIPPED_FINAL_FAILURE -> "#424242";
|
||||||
|
case READY_FOR_AI -> "#1565c0";
|
||||||
|
case PROPOSAL_READY -> "#0288d1";
|
||||||
|
case PROCESSING -> "#9e9e9e";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String statusTooltip(ProcessingStatus status) {
|
||||||
|
if (status == null) return "";
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> "Erfolgreich verarbeitet und umbenannt.";
|
||||||
|
case FAILED_RETRYABLE -> "Temporärer Fehler – wird beim nächsten Lauf automatisch erneut versucht.";
|
||||||
|
case FAILED_FINAL -> "Dauerhaft nicht verarbeitbar – z. B. kein Textinhalt (Foto-PDF), "
|
||||||
|
+ "Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch.";
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen – wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
|
||||||
|
case SKIPPED_FINAL_FAILURE -> "Endgültig übersprungen nach wiederholten Fehlern.";
|
||||||
|
case READY_FOR_AI -> "Wartet auf Verarbeitung.";
|
||||||
|
case PROPOSAL_READY -> "KI-Vorschlag liegt vor, wartet auf Bestätigung.";
|
||||||
|
case PROCESSING -> "Wird gerade verarbeitet.";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> TableCell<T, String> ellipsisCell() {
|
||||||
|
return new TableCell<>() {
|
||||||
|
@Override
|
||||||
|
protected void updateItem(String item, boolean empty) {
|
||||||
|
super.updateItem(item, empty);
|
||||||
|
if (empty || item == null) {
|
||||||
|
setText(null);
|
||||||
|
setTooltip(null);
|
||||||
|
} else {
|
||||||
|
setText(item);
|
||||||
|
setTooltip(new Tooltip(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Region spacerNew() {
|
||||||
|
Region r = new Region();
|
||||||
|
HBox.setHgrow(r, Priority.ALWAYS);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showInfo(String message) {
|
||||||
|
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||||
|
alert.setTitle("Hinweis");
|
||||||
|
alert.setHeaderText(null);
|
||||||
|
alert.setContentText(message);
|
||||||
|
alert.showAndWait();
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* GUI-Adapter für den Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Enthält die Bridge-Interfaces {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort} und
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort}
|
||||||
|
* sowie die JavaFX-Komponente {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab}.
|
||||||
|
* <p>
|
||||||
|
* Die Bridge-Interfaces werden von Bootstrap implementiert und über
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext} in den GUI-Adapter injiziert.
|
||||||
|
* Die GUI-Komponenten kennen ausschließlich diese Interfaces –
|
||||||
|
* niemals direkt Repository- oder Use-Case-Implementierungen.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
+6
-4
@@ -244,14 +244,16 @@ class GuiAdapterSmokeTest {
|
|||||||
"The 'Speichern' button must be visible");
|
"The 'Speichern' button must be visible");
|
||||||
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
||||||
"The 'Speichern unter' button must be visible");
|
"The 'Speichern unter' button must be visible");
|
||||||
assertEquals(3, workspace.tabPane().getTabs().size(),
|
assertEquals(4, workspace.tabPane().getTabs().size(),
|
||||||
"Configuration tab, processing-run tab and prompt editor tab must all be present");
|
"Configuration tab, processing-run tab, history tab and prompt editor tab must all be present");
|
||||||
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
||||||
"The first tab must use the configuration label");
|
"The first tab must use the configuration label");
|
||||||
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
|
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
|
||||||
"The second tab must host the processing-run view");
|
"The second tab must host the processing-run view");
|
||||||
assertEquals("Prompt", workspace.tabPane().getTabs().get(2).getText(),
|
assertEquals("Verlauf", workspace.tabPane().getTabs().get(2).getText(),
|
||||||
"The third tab must host the prompt editor");
|
"The third tab must host the history view");
|
||||||
|
assertEquals("Prompt", workspace.tabPane().getTabs().get(3).getText(),
|
||||||
|
"The fourth tab must host the prompt editor");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
||||||
String.join(",", workspace.sectionTitles()),
|
String.join(",", workspace.sectionTitles()),
|
||||||
|
|||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.control.Tab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monocle-basierte Headless-Smoke-Tests für {@link GuiHistoryTab}.
|
||||||
|
* <p>
|
||||||
|
* Geprüfte Szenarien:
|
||||||
|
* <ul>
|
||||||
|
* <li>Tab wird mit Titel „Verlauf" erstellt.</li>
|
||||||
|
* <li>Tab ist nicht schließbar.</li>
|
||||||
|
* <li>Ohne geladene Konfiguration bleibt die Übersicht leer (null-configPath).</li>
|
||||||
|
* <li>Mit leerem Übersichts-Port bleibt die Tabelle leer.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
class GuiHistoryTabSmokeTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"JavaFX Platform muss innerhalb des Timeouts starten");
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
verifyLatch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownJavaFxPlatform() {
|
||||||
|
// Gemeinsame Platform – kein Platform.exit().
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Stubs
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private static GuiHistoryOverviewPort emptyOverviewPort() {
|
||||||
|
return (configFilePath, query) ->
|
||||||
|
new HistoryOverviewResult(List.of(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiHistoryDetailsPort emptyDetailsPort() {
|
||||||
|
return (configFilePath, fingerprint) -> Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiHistoryResetDocumentStatusPort noOpResetPort() {
|
||||||
|
return (configFilePath, fingerprint) -> { /* no-op */ };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiDeleteDocumentHistoryPort noOpDeletePort() {
|
||||||
|
return (configFilePath, fingerprint) -> { /* no-op */ };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiHistoryTab buildTab(Path configPath) {
|
||||||
|
return new GuiHistoryTab(
|
||||||
|
emptyOverviewPort(),
|
||||||
|
emptyDetailsPort(),
|
||||||
|
noOpResetPort(),
|
||||||
|
noOpDeletePort(),
|
||||||
|
() -> false,
|
||||||
|
() -> configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tab_shouldHaveTitleVerlauf() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicReference<Tab> tabRef = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiHistoryTab historyTab = buildTab(null);
|
||||||
|
tabRef.set(historyTab.tab());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
|
||||||
|
assertEquals("Verlauf", tabRef.get().getText(), "Tab-Titel muss 'Verlauf' sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tab_shouldNotBeClosable() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicBoolean closableRef = new AtomicBoolean(true);
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiHistoryTab historyTab = buildTab(null);
|
||||||
|
closableRef.set(historyTab.tab().isClosable());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertFalse(closableRef.get(), "Tab darf nicht schließbar sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void construction_withNullConfigPath_doesNotThrow() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
// Konstruktion mit null-configPath-Supplier muss möglich sein
|
||||||
|
GuiHistoryTab historyTab = buildTab(null);
|
||||||
|
assertNotNull(historyTab.tab());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void construction_withConfigPath_doesNotThrow() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
Path dummyPath = Paths.get("config/application.properties");
|
||||||
|
GuiHistoryTab historyTab = buildTab(dummyPath);
|
||||||
|
assertNotNull(historyTab.tab());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+409
@@ -0,0 +1,409 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
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.DocumentRecord;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
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;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-Implementierung von {@link HistoryQueryPort}.
|
||||||
|
* <p>
|
||||||
|
* Kapselt alle lesenden Datenbankoperationen für den Historien-Tab.
|
||||||
|
* Sämtliche JDBC-Details sind strikt in dieser Klasse eingeschlossen;
|
||||||
|
* keine JDBC-Typen erscheinen im Port-Interface oder in Domänen-/Application-Typen.
|
||||||
|
* <p>
|
||||||
|
* <strong>Suche:</strong> Freitextsuche ist case-insensitiv (via {@code LOWER()}).
|
||||||
|
* Sonderzeichen {@code %} und {@code _} in der Benutzereingabe werden vor dem
|
||||||
|
* SQL-LIKE-Aufruf mit {@code \} escaped.
|
||||||
|
* <p>
|
||||||
|
* <strong>Sortierung:</strong> Standard absteigend nach {@code updated_at},
|
||||||
|
* Tie-Breaker aufsteigend nach {@code fingerprint} (stabil und reproduzierbar).
|
||||||
|
* <p>
|
||||||
|
* <strong>Limit:</strong> Wird direkt als SQL-{@code LIMIT} angewendet.
|
||||||
|
* Ein Limit von 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr
|
||||||
|
* als 500 Treffer vorhanden sind.
|
||||||
|
*/
|
||||||
|
public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
|
||||||
|
|
||||||
|
private static final Logger logger = LogManager.getLogger(SqliteHistoryQueryAdapter.class);
|
||||||
|
|
||||||
|
private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";
|
||||||
|
|
||||||
|
private final String jdbcUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
|
||||||
|
*
|
||||||
|
* @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
|
||||||
|
* @throws NullPointerException wenn {@code jdbcUrl} null ist
|
||||||
|
* @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
|
||||||
|
*/
|
||||||
|
public SqliteHistoryQueryAdapter(String jdbcUrl) {
|
||||||
|
Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
|
||||||
|
if (jdbcUrl.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
|
||||||
|
}
|
||||||
|
this.jdbcUrl = jdbcUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
* <p>
|
||||||
|
* Die SQL-Abfrage aggregiert die Versuchsanzahl per {@code COUNT}-Subquery.
|
||||||
|
* Freitextsuche und Status-Filter werden als optionale WHERE-Klauseln ergänzt.
|
||||||
|
*
|
||||||
|
* @param query Abfrageparameter; darf nicht {@code null} sein
|
||||||
|
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
|
||||||
|
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
Objects.requireNonNull(query, "query darf nicht null sein");
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT
|
||||||
|
dr.fingerprint,
|
||||||
|
dr.overall_status,
|
||||||
|
dr.last_known_source_file_name,
|
||||||
|
dr.last_target_file_name,
|
||||||
|
dr.last_known_source_locator,
|
||||||
|
dr.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM processing_attempt pa WHERE pa.fingerprint = dr.fingerprint) AS attempt_count
|
||||||
|
FROM document_record dr
|
||||||
|
WHERE 1=1
|
||||||
|
""");
|
||||||
|
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
|
||||||
|
// Freitextsuche: case-insensitiv über Quelldateiname und Zieldateiname
|
||||||
|
String searchText = query.searchText();
|
||||||
|
if (searchText != null && !searchText.isBlank()) {
|
||||||
|
String escaped = escapeSqlLike(searchText.strip().toLowerCase());
|
||||||
|
sql.append(" AND (LOWER(dr.last_known_source_file_name) LIKE ? ESCAPE '\\' "
|
||||||
|
+ "OR LOWER(dr.last_target_file_name) LIKE ? ESCAPE '\\')");
|
||||||
|
String pattern = "%" + escaped + "%";
|
||||||
|
params.add(pattern);
|
||||||
|
params.add(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status-Filter
|
||||||
|
String statusFilter = query.statusFilter();
|
||||||
|
if (statusFilter != null && !statusFilter.isBlank()) {
|
||||||
|
sql.append(" AND dr.overall_status = ?");
|
||||||
|
params.add(statusFilter.strip());
|
||||||
|
}
|
||||||
|
|
||||||
|
sql.append(" ORDER BY dr.updated_at DESC, dr.fingerprint ASC");
|
||||||
|
sql.append(" LIMIT ?");
|
||||||
|
params.add(query.limit());
|
||||||
|
|
||||||
|
try (Connection connection = getConnection();
|
||||||
|
Statement pragmaStmt = connection.createStatement();
|
||||||
|
PreparedStatement stmt = connection.prepareStatement(sql.toString())) {
|
||||||
|
|
||||||
|
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
|
||||||
|
|
||||||
|
for (int i = 0; i < params.size(); i++) {
|
||||||
|
stmt.setObject(i + 1, params.get(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
List<DocumentHistoryRow> rows = new ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
rows.add(mapToDocumentHistoryRow(rs));
|
||||||
|
}
|
||||||
|
logger.debug("Historien-Übersicht geladen: {} Zeilen (Limit {})", rows.size(), query.limit());
|
||||||
|
return List.copyOf(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
String message = "Historien-Übersicht konnte nicht geladen werden: " + e.getMessage();
|
||||||
|
logger.error(message, e);
|
||||||
|
throw new DocumentPersistenceException(message, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
|
||||||
|
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
last_known_source_locator,
|
||||||
|
last_known_source_file_name,
|
||||||
|
overall_status,
|
||||||
|
content_error_count,
|
||||||
|
transient_error_count,
|
||||||
|
last_failure_instant,
|
||||||
|
last_success_instant,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
last_target_path,
|
||||||
|
last_target_file_name
|
||||||
|
FROM document_record
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (Connection connection = getConnection();
|
||||||
|
PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
stmt.setString(1, fingerprint.sha256Hex());
|
||||||
|
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return Optional.of(mapToDocumentRecord(rs, fingerprint));
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
String message = "Dokument-Stammsatz konnte nicht geladen werden für Fingerprint '"
|
||||||
|
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||||
|
logger.error(message, e);
|
||||||
|
throw new DocumentPersistenceException(message, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return unveränderliche Liste der Versuche aufsteigend nach {@code attempt_number};
|
||||||
|
* nie {@code null}; kann leer sein
|
||||||
|
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
fingerprint, run_id, attempt_number, started_at, ended_at,
|
||||||
|
status, failure_class, failure_message, retryable,
|
||||||
|
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
|
||||||
|
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
|
||||||
|
final_target_file_name
|
||||||
|
FROM processing_attempt
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
ORDER BY attempt_number ASC
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (Connection connection = getConnection();
|
||||||
|
Statement pragmaStmt = connection.createStatement();
|
||||||
|
PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
|
||||||
|
stmt.setString(1, fingerprint.sha256Hex());
|
||||||
|
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
List<ProcessingAttempt> attempts = new ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
attempts.add(mapToProcessingAttempt(rs));
|
||||||
|
}
|
||||||
|
return List.copyOf(attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
String message = "Verarbeitungsversuche konnten nicht geladen werden für Fingerprint '"
|
||||||
|
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||||
|
logger.error(message, e);
|
||||||
|
throw new DocumentPersistenceException(message, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Mapping-Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildet eine ResultSet-Zeile auf eine {@link DocumentHistoryRow} ab.
|
||||||
|
*
|
||||||
|
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||||
|
* @return die gemappte Zeile; nie {@code null}
|
||||||
|
* @throws SQLException bei JDBC-Lesefehlern
|
||||||
|
*/
|
||||||
|
private DocumentHistoryRow mapToDocumentHistoryRow(ResultSet rs) throws SQLException {
|
||||||
|
String fpHex = rs.getString("fingerprint");
|
||||||
|
String statusStr = rs.getString("overall_status");
|
||||||
|
String sourceFileName = rs.getString("last_known_source_file_name");
|
||||||
|
String targetFileName = rs.getString("last_target_file_name"); // nullable
|
||||||
|
String sourcePath = rs.getString("last_known_source_locator");
|
||||||
|
String updatedAtStr = rs.getString("updated_at");
|
||||||
|
long attemptCount = rs.getLong("attempt_count");
|
||||||
|
|
||||||
|
return new DocumentHistoryRow(
|
||||||
|
new DocumentFingerprint(fpHex),
|
||||||
|
ProcessingStatus.valueOf(statusStr),
|
||||||
|
sourceFileName,
|
||||||
|
targetFileName,
|
||||||
|
sourcePath,
|
||||||
|
stringToInstant(updatedAtStr),
|
||||||
|
attemptCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildet eine ResultSet-Zeile auf einen {@link DocumentRecord} ab.
|
||||||
|
*
|
||||||
|
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||||
|
* @param fingerprint der Fingerprint, der bereits bekannt ist
|
||||||
|
* @return der gemappte Stammsatz; nie {@code null}
|
||||||
|
* @throws SQLException bei JDBC-Lesefehlern
|
||||||
|
*/
|
||||||
|
private DocumentRecord mapToDocumentRecord(ResultSet rs, DocumentFingerprint fingerprint) throws SQLException {
|
||||||
|
return new DocumentRecord(
|
||||||
|
fingerprint,
|
||||||
|
new SourceDocumentLocator(rs.getString("last_known_source_locator")),
|
||||||
|
rs.getString("last_known_source_file_name"),
|
||||||
|
ProcessingStatus.valueOf(rs.getString("overall_status")),
|
||||||
|
new FailureCounters(
|
||||||
|
rs.getInt("content_error_count"),
|
||||||
|
rs.getInt("transient_error_count")),
|
||||||
|
stringToInstant(rs.getString("last_failure_instant")),
|
||||||
|
stringToInstant(rs.getString("last_success_instant")),
|
||||||
|
stringToInstant(rs.getString("created_at")),
|
||||||
|
stringToInstant(rs.getString("updated_at")),
|
||||||
|
rs.getString("last_target_path"),
|
||||||
|
rs.getString("last_target_file_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildet eine ResultSet-Zeile auf einen {@link ProcessingAttempt} ab.
|
||||||
|
*
|
||||||
|
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||||
|
* @return der gemappte Versuch; nie {@code null}
|
||||||
|
* @throws SQLException bei JDBC-Lesefehlern
|
||||||
|
*/
|
||||||
|
private ProcessingAttempt mapToProcessingAttempt(ResultSet rs) throws SQLException {
|
||||||
|
String resolvedDateStr = rs.getString("resolved_date");
|
||||||
|
LocalDate resolvedDate = resolvedDateStr != null ? LocalDate.parse(resolvedDateStr) : null;
|
||||||
|
|
||||||
|
String dateSourceStr = rs.getString("date_source");
|
||||||
|
DateSource dateSource = dateSourceStr != null ? DateSource.valueOf(dateSourceStr) : null;
|
||||||
|
|
||||||
|
int processedPageCountRaw = rs.getInt("processed_page_count");
|
||||||
|
Integer processedPageCount = rs.wasNull() ? null : processedPageCountRaw;
|
||||||
|
|
||||||
|
int sentCharacterCountRaw = rs.getInt("sent_character_count");
|
||||||
|
Integer sentCharacterCount = rs.wasNull() ? null : sentCharacterCountRaw;
|
||||||
|
|
||||||
|
return new ProcessingAttempt(
|
||||||
|
new DocumentFingerprint(rs.getString("fingerprint")),
|
||||||
|
new RunId(rs.getString("run_id")),
|
||||||
|
rs.getInt("attempt_number"),
|
||||||
|
stringToInstant(rs.getString("started_at")),
|
||||||
|
stringToInstant(rs.getString("ended_at")),
|
||||||
|
ProcessingStatus.valueOf(rs.getString("status")),
|
||||||
|
rs.getString("failure_class"),
|
||||||
|
rs.getString("failure_message"),
|
||||||
|
rs.getBoolean("retryable"),
|
||||||
|
rs.getString("ai_provider"),
|
||||||
|
rs.getString("model_name"),
|
||||||
|
rs.getString("prompt_identifier"),
|
||||||
|
processedPageCount,
|
||||||
|
sentCharacterCount,
|
||||||
|
rs.getString("ai_raw_response"),
|
||||||
|
rs.getString("ai_reasoning"),
|
||||||
|
resolvedDate,
|
||||||
|
dateSource,
|
||||||
|
rs.getString("validated_title"),
|
||||||
|
rs.getString("final_target_file_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SQL-LIKE Escaping
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escaped Sonderzeichen {@code %} und {@code _} in einer LIKE-Eingabe mit {@code \}.
|
||||||
|
* <p>
|
||||||
|
* Der Escape-Charakter {@code \} muss in der SQL-Abfrage als
|
||||||
|
* {@code ESCAPE '\'} angegeben werden.
|
||||||
|
*
|
||||||
|
* @param input die rohe Benutzereingabe; darf nicht {@code null} sein
|
||||||
|
* @return der escaped String; nie {@code null}
|
||||||
|
*/
|
||||||
|
private static String escapeSqlLike(String input) {
|
||||||
|
return input
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("%", "\\%")
|
||||||
|
.replace("_", "\\_");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// JDBC-Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffnet eine neue Datenbankverbindung zur konfigurierten SQLite-Datei.
|
||||||
|
* <p>
|
||||||
|
* Kann in Unterklassen überschrieben werden, um eine gemeinsam genutzte
|
||||||
|
* Transaktions-Verbindung bereitzustellen.
|
||||||
|
*
|
||||||
|
* @return eine neue Datenbankverbindung
|
||||||
|
* @throws SQLException wenn die Verbindung nicht hergestellt werden kann
|
||||||
|
*/
|
||||||
|
protected Connection getConnection() throws SQLException {
|
||||||
|
return DriverManager.getConnection(jdbcUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst einen Instant aus einer String-Darstellung.
|
||||||
|
* <p>
|
||||||
|
* Unterstützt ISO-8601 (modern) und das Legacy-Format {@code yyyy-MM-dd HH:mm:ss} (UTC).
|
||||||
|
*
|
||||||
|
* @param stringValue die String-Darstellung; kann {@code null} sein
|
||||||
|
* @return das geparste Instant, oder {@code null} wenn die Eingabe leer oder nicht parsbar ist
|
||||||
|
*/
|
||||||
|
private Instant stringToInstant(String stringValue) {
|
||||||
|
if (stringValue == null || stringValue.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Instant.parse(stringValue);
|
||||||
|
} catch (Exception e) {
|
||||||
|
try {
|
||||||
|
LocalDateTime dateTime = LocalDateTime.parse(stringValue,
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
return dateTime.atZone(ZoneId.of("UTC")).toInstant();
|
||||||
|
} catch (Exception fallback) {
|
||||||
|
logger.warn("Instant konnte nicht geparst werden '{}': {}", stringValue, fallback.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-2
@@ -171,7 +171,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
// Delete attempts first (FK constraint: processing_attempt → document_record)
|
// Zuerst Versuche löschen (FK-Constraint: processing_attempt → document_record)
|
||||||
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
|
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
|
||||||
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
|
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
|
||||||
@Override
|
@Override
|
||||||
@@ -181,7 +181,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
};
|
};
|
||||||
attemptRepo.deleteAllByFingerprint(fingerprint);
|
attemptRepo.deleteAllByFingerprint(fingerprint);
|
||||||
|
|
||||||
// Then delete the master record
|
// Dann den Stammsatz löschen
|
||||||
SqliteDocumentRecordRepositoryAdapter recordRepo =
|
SqliteDocumentRecordRepositoryAdapter recordRepo =
|
||||||
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
|
||||||
@Override
|
@Override
|
||||||
@@ -191,5 +191,45 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
};
|
};
|
||||||
recordRepo.deleteByFingerprint(fingerprint);
|
recordRepo.deleteByFingerprint(fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
|
||||||
|
* ohne die Versuchshistorie zu löschen.
|
||||||
|
* <p>
|
||||||
|
* Die Felder {@code overall_status}, {@code content_error_count},
|
||||||
|
* {@code transient_error_count} und {@code last_failure_instant} werden innerhalb
|
||||||
|
* der laufenden Transaktion per direktem SQL-UPDATE aktualisiert.
|
||||||
|
* Alle anderen Felder sowie alle {@code processing_attempt}-Einträge bleiben unverändert.
|
||||||
|
* <p>
|
||||||
|
* Ist kein Stammsatz für den Fingerprint vorhanden, kehrt die Methode stillschweigend zurück.
|
||||||
|
*
|
||||||
|
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
UPDATE document_record SET
|
||||||
|
overall_status = 'READY_FOR_AI',
|
||||||
|
content_error_count = 0,
|
||||||
|
transient_error_count = 0,
|
||||||
|
last_failure_instant = NULL
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (java.sql.PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, fingerprint.sha256Hex());
|
||||||
|
stmt.executeUpdate();
|
||||||
|
logger.debug("Status-Reset (feldgenau) für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
} catch (java.sql.SQLException e) {
|
||||||
|
String message = "Status-Reset fehlgeschlagen für Fingerprint '"
|
||||||
|
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||||
|
logger.error(message, e);
|
||||||
|
throw new DocumentPersistenceException(message, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+23
@@ -60,5 +60,28 @@ public interface UnitOfWorkPort {
|
|||||||
* @throws DocumentPersistenceException if the delete fails due to a technical error
|
* @throws DocumentPersistenceException if the delete fails due to a technical error
|
||||||
*/
|
*/
|
||||||
void resetDocumentByFingerprint(DocumentFingerprint fingerprint);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+50
@@ -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 >= 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -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 >= 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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);
|
||||||
|
}
|
||||||
+12
@@ -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;
|
||||||
+65
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -1416,6 +1416,11 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
// No-op in tests
|
// No-op in tests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
operations.accept(mockOps);
|
operations.accept(mockOps);
|
||||||
|
|||||||
+10
@@ -1396,6 +1396,11 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
// No-op
|
// No-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1604,6 +1609,11 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
// No-op in tests
|
// No-op in tests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
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.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link DefaultDeleteDocumentHistoryUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Prüft, dass ausschließlich {@code resetDocumentByFingerprint} aufgerufen wird
|
||||||
|
* (vollständige Löschung inklusive Versuchen, FK-sicher), Null-Guards greifen
|
||||||
|
* und Port-Fehler propagiert werden.
|
||||||
|
*/
|
||||||
|
class DefaultDeleteDocumentHistoryUseCaseTest {
|
||||||
|
|
||||||
|
private static final DocumentFingerprint FP =
|
||||||
|
new DocumentFingerprint("b".repeat(64));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Null-Guards
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_nullPort_throwsNPE() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new DefaultDeleteDocumentHistoryUseCase(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteHistory_nullFingerprint_throwsNPE() {
|
||||||
|
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||||
|
new DefaultDeleteDocumentHistoryUseCase(noOpPort());
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> useCase.deleteHistory(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path: vollständige Löschung
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteHistory_callsResetDocumentByFingerprint() {
|
||||||
|
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||||
|
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||||
|
|
||||||
|
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||||
|
new DefaultDeleteDocumentHistoryUseCase(port);
|
||||||
|
useCase.deleteHistory(FP);
|
||||||
|
|
||||||
|
assertThat(ops.resetByFingerprintFingerprints)
|
||||||
|
.containsExactly(FP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteHistory_doesNotCallResetDocumentStatusForRetry() {
|
||||||
|
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||||
|
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||||
|
|
||||||
|
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||||
|
new DefaultDeleteDocumentHistoryUseCase(port);
|
||||||
|
useCase.deleteHistory(FP);
|
||||||
|
|
||||||
|
assertThat(ops.resetStatusForRetryFingerprints).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Port-Fehler wird propagiert
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteHistory_portThrows_exceptionPropagated() {
|
||||||
|
UnitOfWorkPort failingPort = operations ->
|
||||||
|
operations.accept(new UnitOfWorkPort.TransactionOperations() {
|
||||||
|
@Override
|
||||||
|
public void saveProcessingAttempt(ProcessingAttempt attempt) { }
|
||||||
|
@Override
|
||||||
|
public void createDocumentRecord(DocumentRecord record) { }
|
||||||
|
@Override
|
||||||
|
public void updateDocumentRecord(DocumentRecord record) { }
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
throw new DocumentPersistenceException("Simulated DB error");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||||
|
});
|
||||||
|
|
||||||
|
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||||
|
new DefaultDeleteDocumentHistoryUseCase(failingPort);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> useCase.deleteHistory(FP))
|
||||||
|
.isInstanceOf(DocumentPersistenceException.class)
|
||||||
|
.hasMessageContaining("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static UnitOfWorkPort noOpPort() {
|
||||||
|
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
|
||||||
|
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||||
|
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet {@code resetDocumentByFingerprint}- und {@code resetDocumentStatusForRetry}-Aufrufe auf.
|
||||||
|
*/
|
||||||
|
private static class RecordingTransactionOperations
|
||||||
|
implements UnitOfWorkPort.TransactionOperations {
|
||||||
|
|
||||||
|
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
|
||||||
|
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||||
|
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
resetByFingerprintFingerprints.add(fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
resetStatusForRetryFingerprints.add(fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
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.FailureCounters;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
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;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link DefaultHistoryDetailsUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Prüft den Happy-Path (Stammsatz vorhanden), das leere-Optional-Verhalten
|
||||||
|
* (kein Stammsatz), Null-Guards und Port-Fehler-Propagation.
|
||||||
|
*/
|
||||||
|
class DefaultHistoryDetailsUseCaseTest {
|
||||||
|
|
||||||
|
private static final DocumentFingerprint FP =
|
||||||
|
new DocumentFingerprint("a".repeat(64));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Null-Guards
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_nullPort_throwsNPE() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new DefaultHistoryDetailsUseCase(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadDetails_nullFingerprint_throwsNPE() {
|
||||||
|
DefaultHistoryDetailsUseCase useCase =
|
||||||
|
new DefaultHistoryDetailsUseCase(emptyPort());
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> useCase.loadDetails(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Kein Stammsatz vorhanden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadDetails_noRecord_returnsEmpty() {
|
||||||
|
DefaultHistoryDetailsUseCase useCase =
|
||||||
|
new DefaultHistoryDetailsUseCase(emptyPort());
|
||||||
|
|
||||||
|
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path: Stammsatz vorhanden, Versuche vorhanden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadDetails_recordExists_returnsResultWithRecordAndAttempts() {
|
||||||
|
DocumentRecord record = buildRecord(FP);
|
||||||
|
ProcessingAttempt attempt = buildAttempt(FP);
|
||||||
|
|
||||||
|
HistoryQueryPort port = new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Optional.of(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return List.of(attempt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port);
|
||||||
|
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
|
||||||
|
|
||||||
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().record()).isSameAs(record);
|
||||||
|
assertThat(result.get().attempts()).containsExactly(attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Stammsatz vorhanden, keine Versuche
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadDetails_recordExistsNoAttempts_returnsResultWithEmptyAttempts() {
|
||||||
|
DocumentRecord record = buildRecord(FP);
|
||||||
|
|
||||||
|
HistoryQueryPort port = new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Optional.of(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port);
|
||||||
|
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
|
||||||
|
|
||||||
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().attempts()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Port-Fehler wird propagiert
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadDetails_portThrowsOnRecord_exceptionPropagated() {
|
||||||
|
HistoryQueryPort failingPort = new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
throw new DocumentPersistenceException("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(failingPort);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> useCase.loadDetails(FP))
|
||||||
|
.isInstanceOf(DocumentPersistenceException.class)
|
||||||
|
.hasMessageContaining("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static HistoryQueryPort emptyPort() {
|
||||||
|
return new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentRecord buildRecord(DocumentFingerprint fp) {
|
||||||
|
return new DocumentRecord(
|
||||||
|
fp,
|
||||||
|
new SourceDocumentLocator("/source"),
|
||||||
|
"source.pdf",
|
||||||
|
ProcessingStatus.SUCCESS,
|
||||||
|
new FailureCounters(0, 0),
|
||||||
|
null,
|
||||||
|
Instant.now(),
|
||||||
|
Instant.now(),
|
||||||
|
Instant.now(),
|
||||||
|
"/target",
|
||||||
|
"2024-01-01 - Dokument.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcessingAttempt buildAttempt(DocumentFingerprint fp) {
|
||||||
|
return ProcessingAttempt.withoutAiFields(
|
||||||
|
fp,
|
||||||
|
new de.gecheckt.pdf.umbenenner.domain.model.RunId(
|
||||||
|
java.util.UUID.randomUUID().toString()),
|
||||||
|
1,
|
||||||
|
Instant.now(),
|
||||||
|
Instant.now(),
|
||||||
|
ProcessingStatus.SUCCESS,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
}
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
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.ProcessingAttempt;
|
||||||
|
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;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link DefaultHistoryOverviewUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Prüft den Happy-Path, das LIMIT-501-Verhalten und Null-Guards.
|
||||||
|
*/
|
||||||
|
class DefaultHistoryOverviewUseCaseTest {
|
||||||
|
|
||||||
|
private static final DocumentFingerprint FP =
|
||||||
|
new DocumentFingerprint("a".repeat(64));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Null-Guards
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_nullPort_throwsNPE() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new DefaultHistoryOverviewUseCase(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_nullQuery_throwsNPE() {
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(emptyPort());
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> useCase.loadOverview(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path: leer
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_emptyDatabase_returnsEmptyResultWithoutMore() {
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(emptyPort());
|
||||||
|
|
||||||
|
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||||
|
|
||||||
|
assertThat(result.rows()).isEmpty();
|
||||||
|
assertThat(result.hasMore()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path: weniger als 500 Treffer
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_fewerThan500Results_returnsAllRowsWithoutMore() {
|
||||||
|
List<DocumentHistoryRow> rows = buildRows(10);
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||||
|
|
||||||
|
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||||
|
|
||||||
|
assertThat(result.rows()).hasSize(10);
|
||||||
|
assertThat(result.hasMore()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// LIMIT-501-Technik
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_exactly500Results_returnsAllWithoutMore() {
|
||||||
|
List<DocumentHistoryRow> rows = buildRows(500);
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||||
|
|
||||||
|
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||||
|
|
||||||
|
assertThat(result.rows()).hasSize(500);
|
||||||
|
assertThat(result.hasMore()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_moreThan500Results_returns500RowsWithHasMore() {
|
||||||
|
List<DocumentHistoryRow> rows = buildRows(501);
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||||
|
|
||||||
|
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||||
|
|
||||||
|
assertThat(result.rows()).hasSize(500);
|
||||||
|
assertThat(result.hasMore()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_resultListIsImmutable() {
|
||||||
|
List<DocumentHistoryRow> rows = buildRows(3);
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||||
|
|
||||||
|
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> result.rows().add(buildRow("0".repeat(64))))
|
||||||
|
.isInstanceOf(UnsupportedOperationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Port-Fehler wird propagiert
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_portThrows_exceptionPropagated() {
|
||||||
|
HistoryQueryPort failingPort = new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
throw new DocumentPersistenceException("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(failingPort);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> useCase.loadOverview(HistoryQuery.unfiltered()))
|
||||||
|
.isInstanceOf(DocumentPersistenceException.class)
|
||||||
|
.hasMessageContaining("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static HistoryQueryPort emptyPort() {
|
||||||
|
return fixedPort(Collections.emptyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HistoryQueryPort fixedPort(List<DocumentHistoryRow> rows) {
|
||||||
|
return new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
return new ArrayList<>(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<DocumentHistoryRow> buildRows(int count) {
|
||||||
|
List<DocumentHistoryRow> result = new ArrayList<>();
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
String hex = String.format("%064x", i);
|
||||||
|
result.add(buildRow(hex));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentHistoryRow buildRow(String fpHex) {
|
||||||
|
return new DocumentHistoryRow(
|
||||||
|
new DocumentFingerprint(fpHex),
|
||||||
|
ProcessingStatus.SUCCESS,
|
||||||
|
"source.pdf",
|
||||||
|
"2024-01-01 - Dokument.pdf",
|
||||||
|
"/source",
|
||||||
|
Instant.now(),
|
||||||
|
1L);
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
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.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link DefaultHistoryResetDocumentStatusUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Prüft, dass ausschließlich {@code resetDocumentStatusForRetry} aufgerufen wird
|
||||||
|
* (nicht {@code resetDocumentByFingerprint}), Null-Guards greifen und
|
||||||
|
* Port-Fehler propagiert werden.
|
||||||
|
*/
|
||||||
|
class DefaultHistoryResetDocumentStatusUseCaseTest {
|
||||||
|
|
||||||
|
private static final DocumentFingerprint FP =
|
||||||
|
new DocumentFingerprint("a".repeat(64));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Null-Guards
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_nullPort_throwsNPE() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new DefaultHistoryResetDocumentStatusUseCase(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetStatus_nullFingerprint_throwsNPE() {
|
||||||
|
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultHistoryResetDocumentStatusUseCase(noOpPort());
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> useCase.resetStatus(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path: feldgenauer Reset
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetStatus_callsResetDocumentStatusForRetry() {
|
||||||
|
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||||
|
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||||
|
|
||||||
|
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultHistoryResetDocumentStatusUseCase(port);
|
||||||
|
useCase.resetStatus(FP);
|
||||||
|
|
||||||
|
assertThat(ops.resetStatusForRetryFingerprints)
|
||||||
|
.containsExactly(FP);
|
||||||
|
assertThat(ops.resetByFingerprintFingerprints)
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetStatus_doesNotCallResetDocumentByFingerprint() {
|
||||||
|
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||||
|
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||||
|
|
||||||
|
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultHistoryResetDocumentStatusUseCase(port);
|
||||||
|
useCase.resetStatus(FP);
|
||||||
|
|
||||||
|
assertThat(ops.resetByFingerprintFingerprints).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Port-Fehler wird propagiert
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetStatus_portThrows_exceptionPropagated() {
|
||||||
|
UnitOfWorkPort failingPort = operations ->
|
||||||
|
operations.accept(new UnitOfWorkPort.TransactionOperations() {
|
||||||
|
@Override
|
||||||
|
public void saveProcessingAttempt(ProcessingAttempt attempt) { }
|
||||||
|
@Override
|
||||||
|
public void createDocumentRecord(DocumentRecord record) { }
|
||||||
|
@Override
|
||||||
|
public void updateDocumentRecord(DocumentRecord record) { }
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
throw new DocumentPersistenceException("Simulated DB error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultHistoryResetDocumentStatusUseCase(failingPort);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> useCase.resetStatus(FP))
|
||||||
|
.isInstanceOf(DocumentPersistenceException.class)
|
||||||
|
.hasMessageContaining("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static UnitOfWorkPort noOpPort() {
|
||||||
|
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
|
||||||
|
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||||
|
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet {@code resetDocumentStatusForRetry}- und {@code resetDocumentByFingerprint}-Aufrufe auf.
|
||||||
|
*/
|
||||||
|
private static class RecordingTransactionOperations
|
||||||
|
implements UnitOfWorkPort.TransactionOperations {
|
||||||
|
|
||||||
|
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
|
||||||
|
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||||
|
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
resetByFingerprintFingerprints.add(fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
resetStatusForRetryFingerprints.add(fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -549,6 +549,7 @@ class DefaultManualFileCopyUseCaseTest {
|
|||||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void updateDocumentRecord(DocumentRecord record) { }
|
@Override public void updateDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations {
|
private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations {
|
||||||
@@ -562,5 +563,6 @@ class DefaultManualFileCopyUseCaseTest {
|
|||||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
|
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
|
||||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -620,6 +620,7 @@ class DefaultManualFileRenameUseCaseTest {
|
|||||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void updateDocumentRecord(DocumentRecord record) { }
|
@Override public void updateDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zeichnet updateDocumentRecord-Aufrufe auf. */
|
/** Zeichnet updateDocumentRecord-Aufrufe auf. */
|
||||||
@@ -634,5 +635,6 @@ class DefaultManualFileRenameUseCaseTest {
|
|||||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
|
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
|
||||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -216,5 +216,10 @@ class DefaultResetDocumentStatusUseCaseTest {
|
|||||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
recorded.add(fingerprint);
|
recorded.add(fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+159
-4
@@ -87,7 +87,18 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatal
|
|||||||
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
|
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
|
||||||
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
|
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
|
||||||
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
|
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteHistoryQueryAdapter;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryResetDocumentStatusUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultPromptEditorUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultPromptEditorUseCase;
|
||||||
@@ -808,6 +819,10 @@ public class BootstrapRunner {
|
|||||||
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
|
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
|
||||||
GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy;
|
GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy;
|
||||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
|
||||||
|
GuiHistoryOverviewPort historyOverviewPort = this::loadHistoryOverviewForGui;
|
||||||
|
GuiHistoryDetailsPort historyDetailsPort = this::loadHistoryDetailsForGui;
|
||||||
|
GuiHistoryResetDocumentStatusPort historyResetPort = this::resetHistoryDocumentStatusForGui;
|
||||||
|
GuiDeleteDocumentHistoryPort deleteHistoryPort = this::deleteDocumentHistoryForGui;
|
||||||
// Versionsnummer aus dem MANIFEST.MF des gepackten JARs lesen; Fallback "dev" bei IDE-Start
|
// Versionsnummer aus dem MANIFEST.MF des gepackten JARs lesen; Fallback "dev" bei IDE-Start
|
||||||
String applicationVersion = ApplicationVersionProvider.resolveVersion();
|
String applicationVersion = ApplicationVersionProvider.resolveVersion();
|
||||||
|
|
||||||
@@ -830,7 +845,11 @@ public class BootstrapRunner {
|
|||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort,
|
historicalDocumentContextPort,
|
||||||
applicationVersion,
|
applicationVersion,
|
||||||
noOpGuiPromptEditorPort());
|
noOpGuiPromptEditorPort(),
|
||||||
|
historyOverviewPort,
|
||||||
|
historyDetailsPort,
|
||||||
|
historyResetPort,
|
||||||
|
deleteHistoryPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
Path configPath = Paths.get(configPathOverride.get());
|
Path configPath = Paths.get(configPathOverride.get());
|
||||||
@@ -856,7 +875,11 @@ public class BootstrapRunner {
|
|||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort,
|
historicalDocumentContextPort,
|
||||||
applicationVersion,
|
applicationVersion,
|
||||||
noOpGuiPromptEditorPort());
|
noOpGuiPromptEditorPort(),
|
||||||
|
historyOverviewPort,
|
||||||
|
historyDetailsPort,
|
||||||
|
historyResetPort,
|
||||||
|
deleteHistoryPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||||
@@ -868,7 +891,8 @@ public class BootstrapRunner {
|
|||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
|
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
|
||||||
historicalDocumentContextPort, applicationVersion, promptEditorPort);
|
historicalDocumentContextPort, applicationVersion, promptEditorPort,
|
||||||
|
historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort);
|
||||||
} catch (GuiConfigurationLoadException e) {
|
} catch (GuiConfigurationLoadException e) {
|
||||||
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
||||||
e.getMessage(), e);
|
e.getMessage(), e);
|
||||||
@@ -890,7 +914,11 @@ public class BootstrapRunner {
|
|||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort,
|
historicalDocumentContextPort,
|
||||||
applicationVersion,
|
applicationVersion,
|
||||||
noOpGuiPromptEditorPort());
|
noOpGuiPromptEditorPort(),
|
||||||
|
historyOverviewPort,
|
||||||
|
historyDetailsPort,
|
||||||
|
historyResetPort,
|
||||||
|
deleteHistoryPort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1421,6 +1449,133 @@ public class BootstrapRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die gefilterte Dokumentenübersicht für den Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Verdrahtet {@link SqliteHistoryQueryAdapter} und {@link DefaultHistoryOverviewUseCase}
|
||||||
|
* frisch pro Aufruf, da keine persistente Verbindung über GUI-Interaktionen hinweg
|
||||||
|
* gehalten wird.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
|
||||||
|
* @param query Abfrageparameter; darf nicht {@code null} sein
|
||||||
|
* @return Ergebnis mit gefundenen Zeilen und hasMore-Flag; nie {@code null}
|
||||||
|
*/
|
||||||
|
DefaultHistoryOverviewUseCase.HistoryOverviewResult loadHistoryOverviewForGui(
|
||||||
|
Path configFilePath,
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery query) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(query, "query must not be null");
|
||||||
|
try {
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
initializeSchema(config);
|
||||||
|
String jdbcUrl = buildJdbcUrl(config);
|
||||||
|
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
|
||||||
|
DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(historyQueryPort);
|
||||||
|
return useCase.loadOverview(query);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Historienübersicht konnte nicht geladen werden: {}", e.getMessage(), e);
|
||||||
|
throw new DocumentPersistenceException(
|
||||||
|
"Historienübersicht konnte nicht geladen werden: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||||
|
* <p>
|
||||||
|
* Verdrahtet {@link SqliteHistoryQueryAdapter} und {@link DefaultHistoryDetailsUseCase}
|
||||||
|
* frisch pro Aufruf.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return Optional mit den Detaildaten, oder leer wenn kein Eintrag gefunden wurde
|
||||||
|
*/
|
||||||
|
Optional<DefaultHistoryDetailsUseCase.HistoryDetailsResult> loadHistoryDetailsForGui(
|
||||||
|
Path configFilePath,
|
||||||
|
DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
|
try {
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
initializeSchema(config);
|
||||||
|
String jdbcUrl = buildJdbcUrl(config);
|
||||||
|
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
|
||||||
|
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(historyQueryPort);
|
||||||
|
return useCase.loadDetails(fingerprint);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Historiendetails für {} konnten nicht geladen werden: {}",
|
||||||
|
fingerprint.sha256Hex(), e.getMessage(), e);
|
||||||
|
throw new DocumentPersistenceException(
|
||||||
|
"Historiendetails konnten nicht geladen werden: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt den feldgenauen Status-Reset für das angegebene Dokument durch.
|
||||||
|
* <p>
|
||||||
|
* Setzt {@code overall_status}, {@code content_error_count}, {@code transient_error_count}
|
||||||
|
* und {@code last_failure_instant} zurück. Die Versuchshistorie bleibt erhalten.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
void resetHistoryDocumentStatusForGui(
|
||||||
|
Path configFilePath,
|
||||||
|
DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
|
LOG.info("Historien-Status-Reset für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
try {
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
initializeSchema(config);
|
||||||
|
String jdbcUrl = buildJdbcUrl(config);
|
||||||
|
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
|
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultHistoryResetDocumentStatusUseCase(unitOfWorkPort);
|
||||||
|
useCase.resetStatus(fingerprint);
|
||||||
|
LOG.info("Historien-Status-Reset abgeschlossen für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Historien-Status-Reset fehlgeschlagen für {}: {}",
|
||||||
|
fingerprint.sha256Hex(), e.getMessage(), e);
|
||||||
|
throw new DocumentPersistenceException(
|
||||||
|
"Status-Reset fehlgeschlagen: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht den Stammsatz und alle Verarbeitungsversuche für das angegebene Dokument.
|
||||||
|
* <p>
|
||||||
|
* Die Löschung ist destruktiv und nicht rückgängig zu machen.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
void deleteDocumentHistoryForGui(
|
||||||
|
Path configFilePath,
|
||||||
|
DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
|
LOG.info("Historien-Löschen für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
try {
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
initializeSchema(config);
|
||||||
|
String jdbcUrl = buildJdbcUrl(config);
|
||||||
|
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
|
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||||
|
new DefaultDeleteDocumentHistoryUseCase(unitOfWorkPort);
|
||||||
|
useCase.deleteHistory(fingerprint);
|
||||||
|
LOG.info("Historien-Löschen abgeschlossen für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Historien-Löschen fehlgeschlagen für {}: {}",
|
||||||
|
fingerprint.sha256Hex(), e.getMessage(), e);
|
||||||
|
throw new DocumentPersistenceException(
|
||||||
|
"Löschen fehlgeschlagen: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
|
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
|
||||||
* recorded as a failure with the given error message.
|
* recorded as a failure with the given error message.
|
||||||
|
|||||||
Reference in New Issue
Block a user