Fix #41: Historischen KI-Dateinamen für übersprungene Dokumente in Ergebnistabelle anzeigen

Neue Komponenten:
- ResolveHistoricalFileNameUseCase (port/in) und DefaultResolveHistoricalFileNameUseCase (usecase)
- GuiHistoricalFileNamePort (GUI-interner Port, folgt dem Muster von GuiManualFileRenamePort)

GuiBatchRunCoordinator ruft in toRow() für SKIPPED-Zeilen ohne finalName den
historicalFileNamePort auf und trägt den Rückgabewert als neuen Dateinamen ein.

Bootstrap verdrahtet resolveHistoricalFileNameForGui als GuiHistoricalFileNamePort
und übergibt ihn über GuiStartupContext an den GUI-Adapter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 10:54:31 +02:00
parent 385bda5331
commit 1db6e27be8
10 changed files with 490 additions and 25 deletions
@@ -0,0 +1,37 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Inbound port for resolving the historical target filename for a previously processed document.
* <p>
* Used to populate the "Neuer Dateiname" column in the GUI result table for documents that
* were skipped in the current run because they were already in a terminal state from a
* previous run. For documents that previously reached
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS}, the last
* successfully written target filename is returned. For all other terminal states
* (e.g. {@code FAILED_FINAL}) that were never copied to the target folder, an empty
* {@link Optional} is returned.
* <p>
* <strong>Architecture boundary:</strong> Implementations of this port must not expose
* JDBC, SQLite, filesystem or HTTP types through this interface. All infrastructure details
* remain in the adapter layer.
*/
public interface ResolveHistoricalFileNameUseCase {
/**
* Returns the last successfully written target filename for the document identified
* by the given fingerprint, or an empty {@link Optional} if no such filename exists.
* <p>
* The method never throws: technical failures during the repository lookup are caught
* and result in an empty return value.
*
* @param fingerprint content-based document identity; must not be {@code null}
* @return the last target filename written for this document, or empty if the document
* never reached a successful terminal state or the lookup failed
* @throws NullPointerException if {@code fingerprint} is {@code null}
*/
Optional<String> resolveHistoricalFileName(DocumentFingerprint fingerprint);
}
@@ -28,6 +28,13 @@
* — Event and summary value types carried to the observer</li>
* </ul>
* <p>
* <p>
* Query use cases (for GUI adapters):
* <ul>
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalFileNameUseCase}
* — Resolves the last known target filename for a document identified by its fingerprint</li>
* </ul>
* <p>
* Architecture Rule: Inbound ports are independent of implementation and contain no business logic.
* They define "what can be done to the application". All dependencies point inward;
* adapters depend on ports, not vice versa.
@@ -0,0 +1,69 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalFileNameUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Default implementation of {@link ResolveHistoricalFileNameUseCase}.
* <p>
* Queries the {@link DocumentRecordRepository} for the master record of the given fingerprint.
* If the record represents a document that previously reached a successful terminal state,
* the last known target filename ({@code lastTargetFileName}) is returned.
* <p>
* For all other terminal states (e.g. documents that finally failed without ever producing
* a target copy) or when no master record exists, an empty {@link Optional} is returned.
* Technical failures during the repository lookup are caught silently and treated as
* an absent result so that the calling GUI layer is never forced to handle exceptions
* from this query path.
*/
public class DefaultResolveHistoricalFileNameUseCase implements ResolveHistoricalFileNameUseCase {
private final DocumentRecordRepository documentRecordRepository;
/**
* Creates the use case with the required document master record repository.
*
* @param documentRecordRepository repository for reading document master records;
* must not be {@code null}
* @throws NullPointerException if {@code documentRecordRepository} is {@code null}
*/
public DefaultResolveHistoricalFileNameUseCase(
DocumentRecordRepository documentRecordRepository) {
this.documentRecordRepository = Objects.requireNonNull(
documentRecordRepository, "documentRecordRepository must not be null");
}
/**
* Returns the last successfully written target filename for the given fingerprint,
* or an empty {@link Optional} if no such filename exists.
* <p>
* The method queries the document master record. Only documents with a prior
* successful terminal state carry a non-null {@code lastTargetFileName}. For all
* other states (unknown, processable, finally failed) and on any technical lookup
* failure, an empty {@link Optional} is returned.
*
* @param fingerprint content-based document identity; must not be {@code null}
* @return the historical target filename, or empty if not available
* @throws NullPointerException if {@code fingerprint} is {@code null}
*/
@Override
public Optional<String> resolveHistoricalFileName(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
try {
DocumentRecordLookupResult result =
documentRecordRepository.findByFingerprint(fingerprint);
if (result instanceof DocumentTerminalSuccess success) {
return Optional.ofNullable(success.record().lastTargetFileName());
}
return Optional.empty();
} catch (Exception e) {
return Optional.empty();
}
}
}
@@ -0,0 +1,152 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
/**
* Unit tests for {@link DefaultResolveHistoricalFileNameUseCase}.
*/
class DefaultResolveHistoricalFileNameUseCaseTest {
private static final DocumentFingerprint FP = new DocumentFingerprint("a".repeat(64));
@Test
void constructor_withNullRepository_throws() {
assertThatNullPointerException()
.isThrownBy(() -> new DefaultResolveHistoricalFileNameUseCase(null))
.withMessageContaining("documentRecordRepository");
}
@Test
void resolveHistoricalFileName_withNullFingerprint_throws() {
var useCase = new DefaultResolveHistoricalFileNameUseCase(stubRepo(new DocumentUnknown()));
assertThatNullPointerException()
.isThrownBy(() -> useCase.resolveHistoricalFileName(null))
.withMessageContaining("fingerprint");
}
@Test
void resolveHistoricalFileName_forSuccessRecord_returnsLastTargetFileName() {
DocumentRecord record = buildRecord(ProcessingStatus.SUCCESS, "2026-01-01 - Rechnung.pdf");
var useCase = new DefaultResolveHistoricalFileNameUseCase(
stubRepo(new DocumentTerminalSuccess(record)));
Optional<String> result = useCase.resolveHistoricalFileName(FP);
assertThat(result).contains("2026-01-01 - Rechnung.pdf");
}
@Test
void resolveHistoricalFileName_forSuccessRecordWithNullLastTargetFileName_returnsEmpty() {
DocumentRecord record = buildRecord(ProcessingStatus.SUCCESS, null);
var useCase = new DefaultResolveHistoricalFileNameUseCase(
stubRepo(new DocumentTerminalSuccess(record)));
assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
}
@Test
void resolveHistoricalFileName_forUnknownDocument_returnsEmpty() {
var useCase = new DefaultResolveHistoricalFileNameUseCase(stubRepo(new DocumentUnknown()));
assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
}
@Test
void resolveHistoricalFileName_forFinalFailure_returnsEmpty() {
DocumentRecord record = buildRecord(ProcessingStatus.FAILED_FINAL, null);
var useCase = new DefaultResolveHistoricalFileNameUseCase(
stubRepo(new DocumentTerminalFinalFailure(record)));
assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
}
@Test
void resolveHistoricalFileName_forProcessableDocument_returnsEmpty() {
DocumentRecord record = buildRecord(ProcessingStatus.READY_FOR_AI, null);
var useCase = new DefaultResolveHistoricalFileNameUseCase(
stubRepo(new DocumentKnownProcessable(record)));
assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
}
@Test
void resolveHistoricalFileName_forPersistenceLookupFailure_returnsEmpty() {
var useCase = new DefaultResolveHistoricalFileNameUseCase(
stubRepo(new PersistenceLookupTechnicalFailure("DB-Fehler", null)));
assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
}
@Test
void resolveHistoricalFileName_whenRepositoryThrows_returnsEmpty() {
DocumentRecordRepository throwingRepo = new DocumentRecordRepository() {
@Override
public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) {
throw new DocumentPersistenceException("Verbindungsfehler", null);
}
@Override
public void create(DocumentRecord record) {}
@Override
public void update(DocumentRecord record) {}
@Override
public void deleteByFingerprint(DocumentFingerprint fingerprint) {}
};
var useCase = new DefaultResolveHistoricalFileNameUseCase(throwingRepo);
assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static DocumentRecordRepository stubRepo(DocumentRecordLookupResult result) {
return new DocumentRecordRepository() {
@Override
public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) {
return result;
}
@Override
public void create(DocumentRecord record) {}
@Override
public void update(DocumentRecord record) {}
@Override
public void deleteByFingerprint(DocumentFingerprint fingerprint) {}
};
}
private static DocumentRecord buildRecord(ProcessingStatus status, String lastTargetFileName) {
return new DocumentRecord(
FP,
new SourceDocumentLocator("quell/pfad"),
"original.pdf",
status,
FailureCounters.zero(),
null,
status == ProcessingStatus.SUCCESS ? Instant.now() : null,
Instant.now(),
Instant.now(),
lastTargetFileName != null ? "ziel/ordner" : null,
lastTargetFileName);
}
}