diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java index 2dcced1..16695cb 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java @@ -313,7 +313,7 @@ public class DocumentProcessingCoordinator { Instant attemptStart) { Instant now = Instant.now(); - ProcessingOutcome outcome = mapOutcomeForNewDocument(pipelineOutcome); + ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForNewDocument(pipelineOutcome); DocumentRecord newRecord = buildNewDocumentRecord(fingerprint, candidate, outcome, now); persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, txOps -> txOps.createDocumentRecord(newRecord)); @@ -333,7 +333,7 @@ public class DocumentProcessingCoordinator { Instant attemptStart) { Instant now = Instant.now(); - ProcessingOutcome outcome = mapOutcomeForKnownDocument(pipelineOutcome, existingRecord.failureCounters()); + ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForKnownDocument(pipelineOutcome, existingRecord.failureCounters()); DocumentRecord updatedRecord = buildUpdatedDocumentRecord(existingRecord, candidate, outcome, now); persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, txOps -> txOps.updateDocumentRecord(updatedRecord)); @@ -350,77 +350,23 @@ public class DocumentProcessingCoordinator { * @param pipelineOutcome the pipeline result * @return the outcome with status, counters and retryable flag */ - private ProcessingOutcome mapOutcomeForNewDocument(DocumentProcessingOutcome pipelineOutcome) { - return mapOutcomeForKnownDocument(pipelineOutcome, FailureCounters.zero()); + private ProcessingOutcomeTransition.ProcessingOutcome mapOutcomeForNewDocument( + DocumentProcessingOutcome pipelineOutcome) { + return ProcessingOutcomeTransition.forNewDocument(pipelineOutcome); } /** * Maps an outcome to status, counters, and retryable flag, taking the * existing failure counters into account. - *
- * Minimal rules applied here: - *
- * Tightly scoped to {@link DocumentProcessingCoordinator}; not exposed outside this class. - * - * @param overallStatus the overall status to persist - * @param counters the updated failure counters to persist - * @param retryable whether the failure is retryable in a later run - */ - private record ProcessingOutcome( - ProcessingStatus overallStatus, - FailureCounters counters, - boolean retryable) { - } } \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/ProcessingOutcomeTransition.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/ProcessingOutcomeTransition.java new file mode 100644 index 0000000..454edcc --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/ProcessingOutcomeTransition.java @@ -0,0 +1,122 @@ +package de.gecheckt.pdf.umbenenner.application.service; + +import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome; +import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed; +import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; +import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError; + +/** + * Pure status and counter transition policy for document processing outcomes. + *
+ * This class encapsulates the deterministic rules for mapping a pipeline outcome + * (success, content error, or technical error) to a processing status, updated + * failure counters, and retryability flag. + *
+ * The transition logic is independent of persistence, orchestration, or any + * infrastructure concern. It is purely declarative and stateless. + * + *
+ * For new documents, all failure counters start at zero. + * + * @param pipelineOutcome the outcome from the extraction and pre-check pipeline + * @return the mapped outcome with status, counters, and retryability + */ + static ProcessingOutcome forNewDocument(DocumentProcessingOutcome pipelineOutcome) { + return forKnownDocument(pipelineOutcome, FailureCounters.zero()); + } + + /** + * Maps a pipeline outcome to a processing outcome, considering the existing + * failure counter state from a known document's history. + *
+ * This method applies the deterministic transition rules to produce an updated + * status, counters, and retryable flag. + * + * @param pipelineOutcome the outcome from the extraction and pre-check pipeline + * @param existingCounters the current failure counter values from the document's master record + * @return the mapped outcome with updated status, counters, and retryability + */ + static ProcessingOutcome forKnownDocument( + DocumentProcessingOutcome pipelineOutcome, + FailureCounters existingCounters) { + + return switch (pipelineOutcome) { + case de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed ignored -> { + // Success: document passed all pre-checks + yield new ProcessingOutcome( + ProcessingStatus.SUCCESS, + existingCounters, // counters unchanged on success + false // not retryable + ); + } + + case PreCheckFailed contentError -> { + // Deterministic content error: apply the 1-retry rule + FailureCounters updatedCounters = existingCounters.withIncrementedContentErrorCount(); + boolean isFirstOccurrence = existingCounters.contentErrorCount() == 0; + + if (isFirstOccurrence) { + // First content error → FAILED_RETRYABLE + yield new ProcessingOutcome( + ProcessingStatus.FAILED_RETRYABLE, + updatedCounters, + true + ); + } else { + // Second (or later) content error → FAILED_FINAL + yield new ProcessingOutcome( + ProcessingStatus.FAILED_FINAL, + updatedCounters, + false + ); + } + } + + case TechnicalDocumentError technicalError -> { + // Technical error after fingerprinting: always FAILED_RETRYABLE, increment transient counter + yield new ProcessingOutcome( + ProcessingStatus.FAILED_RETRYABLE, + existingCounters.withIncrementedTransientErrorCount(), + true + ); + } + }; + } + + /** + * Value type carrying the status, updated counters, and retryable flag + * after transition from a pipeline outcome. + * + * @param overallStatus the overall processing status to persist + * @param counters the updated failure counters to persist + * @param retryable whether a failure is retryable in a later run + */ + record ProcessingOutcome( + ProcessingStatus overallStatus, + FailureCounters counters, + boolean retryable) { + } +}