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: - *

* * @param pipelineOutcome the pipeline result * @param existingCounters the current failure counters from the master record * @return the outcome with updated status, counters and retryable flag */ - private ProcessingOutcome mapOutcomeForKnownDocument( + private ProcessingOutcomeTransition.ProcessingOutcome mapOutcomeForKnownDocument( 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 - ); - } - }; + return ProcessingOutcomeTransition.forKnownDocument(pipelineOutcome, existingCounters); } // ------------------------------------------------------------------------- @@ -430,7 +376,7 @@ public class DocumentProcessingCoordinator { private DocumentRecord buildNewDocumentRecord( DocumentFingerprint fingerprint, SourceDocumentCandidate candidate, - ProcessingOutcome outcome, + ProcessingOutcomeTransition.ProcessingOutcome outcome, Instant now) { boolean success = outcome.overallStatus() == ProcessingStatus.SUCCESS; return new DocumentRecord( @@ -449,7 +395,7 @@ public class DocumentProcessingCoordinator { private DocumentRecord buildUpdatedDocumentRecord( DocumentRecord existingRecord, SourceDocumentCandidate candidate, - ProcessingOutcome outcome, + ProcessingOutcomeTransition.ProcessingOutcome outcome, Instant now) { boolean success = outcome.overallStatus() == ProcessingStatus.SUCCESS; return new DocumentRecord( @@ -501,7 +447,7 @@ public class DocumentProcessingCoordinator { BatchRunContext context, Instant attemptStart, Instant now, - ProcessingOutcome outcome, + ProcessingOutcomeTransition.ProcessingOutcome outcome, Consumer recordWriter) { try { @@ -547,7 +493,7 @@ public class DocumentProcessingCoordinator { int attemptNumber, Instant startedAt, Instant endedAt, - ProcessingOutcome outcome) { + ProcessingOutcomeTransition.ProcessingOutcome outcome) { String failureClass = null; String failureMessage = null; @@ -577,7 +523,7 @@ public class DocumentProcessingCoordinator { * @param outcome the outcome * @return a non-null failure message string */ - private String buildFailureMessage(ProcessingOutcome outcome) { + private String buildFailureMessage(ProcessingOutcomeTransition.ProcessingOutcome outcome) { return switch (outcome.overallStatus()) { case FAILED_RETRYABLE -> "Processing failed (retryable). " + "ContentErrors=" + outcome.counters().contentErrorCount() @@ -589,23 +535,4 @@ public class DocumentProcessingCoordinator { }; } - // ------------------------------------------------------------------------- - // Internal value type: outcome - // ------------------------------------------------------------------------- - - /** - * Internal value type carrying the status, updated counters, and retryable flag - * after mapping from an outcome. - *

- * 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. + * + *

Transition rules

+ * + */ +final class ProcessingOutcomeTransition { + + private ProcessingOutcomeTransition() { + // Static utility class; no instances + } + + /** + * Maps a pipeline outcome to a processing outcome for a brand-new document. + *

+ * 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) { + } +}