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:
+37
@@ -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);
|
||||
}
|
||||
+7
@@ -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.
|
||||
|
||||
+69
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user