diff --git a/docs/runtime-event-processing.md b/docs/runtime-event-processing.md index 032c7c1..d395fb6 100644 --- a/docs/runtime-event-processing.md +++ b/docs/runtime-event-processing.md @@ -294,3 +294,60 @@ This prevents unrelated vehicle events from being copied into a driver result si `parameters` take precedence in the tachograph profile. The compatibility endpoint maps these values to `expandVehicleEvents` and `vehicleExpansionPaddingMinutes`. + +## Tachograph parity validation + +A validation endpoint is available to compare the legacy tachograph file-session Esper endpoint with the generic runtime event-processing profile: + +```http +POST /api/eventhub/runtime-processing/event-processing/validation/tachograph-parity +``` + +This endpoint runs both paths for the selected session(s) and driver(s): + +```text +legacy file-session path + /api/eventhub/tachograph-file-sessions/{sessionId}/drivers/{driverKey}/processing/esper-events + +runtime event-processing path + /api/eventhub/runtime-processing/event-processing + profileKey = tachograph-driver-esper-v1 +``` + +Example request: + +```json +{ + "sessionId": "{{sessionId}}", + "driverKey": "{{driverKey}}", + "occurredFrom": "2026-05-01T00:00:00Z", + "occurredTo": "2026-05-31T23:59:59Z", + "expandVehicleEvents": true, + "vehicleExpansionPaddingMinutes": 15, + "significantDrivingMinutes": 3, + "minimumRestPeriodMinutes": 720, + "includeDebug": true +} +``` + +For multiple uploaded tachograph sessions, use `sessionIds` or `compositeSessionId`. The validation service resolves the selected drivers, executes the generic runtime profile, then compares count-level parity per driver. + +Compared categories include: + +```text +activityIntervals +drivingIntervals +drivingInterruptionIntervals +drivingInterruptionVehicleChangeIntervals +dailyWeeklyRestCandidateIntervals +dailyWeeklyRestCandidateCoverageIntervals +unclassifiedDailyWeeklyRestCandidateCoverageIntervals +potentialHomeOvernightStayIntervals +potentialInVehicleOvernightStayIntervals +potentialInVehicleTripIntervals +vehicleUsageIntervals +vuCardAbsentIntervals +supportGeoEvents +``` + +For a single session, this is a direct parity check against the original file-session endpoint. For multiple sessions, the reference side is the sum of the individual file-session endpoint results per driver; runtime processing may intentionally deduplicate or merge across session boundaries, so differences should be reviewed with the debug/audit output. diff --git a/postman/eventhub-runtime-event-processing.postman_collection.json b/postman/eventhub-runtime-event-processing.postman_collection.json index f11c589..acbd9c2 100644 --- a/postman/eventhub-runtime-event-processing.postman_collection.json +++ b/postman/eventhub-runtime-event-processing.postman_collection.json @@ -159,6 +159,36 @@ ] } } + }, + { + "name": "Validate tachograph profile parity - single session", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing/validation/tachograph-parity", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "eventhub", + "runtime-processing", + "event-processing", + "validation", + "tachograph-parity" + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"sessionId\": \"{{sessionId}}\",\n \"driverKey\": \"{{driverKey}}\",\n \"occurredFrom\": \"2026-05-01T00:00:00Z\",\n \"occurredTo\": \"2026-05-31T23:59:59Z\",\n \"expandVehicleEvents\": true,\n \"vehicleExpansionPaddingMinutes\": 15,\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720,\n \"includeDebug\": true\n}" + } + } } ] } \ No newline at end of file diff --git a/src/main/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingController.java b/src/main/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingController.java index b5545af..6c0f0e4 100644 --- a/src/main/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingController.java +++ b/src/main/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingController.java @@ -7,6 +7,9 @@ import at.procon.eventhub.processing.eventprocessing.RuntimeEventProcessingServi import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest; import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto; import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingProfileDescriptorDto; +import at.procon.eventhub.processing.eventprocessing.validation.RuntimeTachographParityValidationApiRequest; +import at.procon.eventhub.processing.eventprocessing.validation.RuntimeTachographParityValidationResultDto; +import at.procon.eventhub.processing.eventprocessing.validation.RuntimeTachographParityValidationService; import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle; import at.procon.eventhub.processing.service.UnifiedRuntimeDerivedProjectionService; import at.procon.eventhub.processing.service.UnifiedRuntimeDriverTimelineService; @@ -31,13 +34,25 @@ public class UnifiedRuntimeProcessingController { private final UnifiedRuntimeDerivedProjectionService runtimeDerivedProjectionService; private final UnifiedRuntimeTachographEsperScopeProcessingService tachographEsperScopeProcessingService; private final RuntimeEventProcessingService runtimeEventProcessingService; + private final RuntimeTachographParityValidationService tachographParityValidationService; public UnifiedRuntimeProcessingController( UnifiedRuntimeEventAssemblyService eventAssemblyService, UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService, UnifiedRuntimeDerivedProjectionService runtimeDerivedProjectionService ) { - this(eventAssemblyService, runtimeDriverTimelineService, runtimeDerivedProjectionService, null, null); + this(eventAssemblyService, runtimeDriverTimelineService, runtimeDerivedProjectionService, null, null, null); + } + + public UnifiedRuntimeProcessingController( + UnifiedRuntimeEventAssemblyService eventAssemblyService, + UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService, + UnifiedRuntimeDerivedProjectionService runtimeDerivedProjectionService, + UnifiedRuntimeTachographEsperScopeProcessingService tachographEsperScopeProcessingService, + RuntimeEventProcessingService runtimeEventProcessingService + ) { + this(eventAssemblyService, runtimeDriverTimelineService, runtimeDerivedProjectionService, + tachographEsperScopeProcessingService, runtimeEventProcessingService, null); } @Autowired @@ -46,13 +61,15 @@ public class UnifiedRuntimeProcessingController { UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService, UnifiedRuntimeDerivedProjectionService runtimeDerivedProjectionService, UnifiedRuntimeTachographEsperScopeProcessingService tachographEsperScopeProcessingService, - RuntimeEventProcessingService runtimeEventProcessingService + RuntimeEventProcessingService runtimeEventProcessingService, + RuntimeTachographParityValidationService tachographParityValidationService ) { this.eventAssemblyService = eventAssemblyService; this.runtimeDriverTimelineService = runtimeDriverTimelineService; this.runtimeDerivedProjectionService = runtimeDerivedProjectionService; this.tachographEsperScopeProcessingService = tachographEsperScopeProcessingService; this.runtimeEventProcessingService = runtimeEventProcessingService; + this.tachographParityValidationService = tachographParityValidationService; } @PostMapping("/driver-events") @@ -94,6 +111,17 @@ public class UnifiedRuntimeProcessingController { return ResponseEntity.ok(runtimeEventProcessingService.process(request)); } + + @PostMapping("/event-processing/validation/tachograph-parity") + public ResponseEntity validateTachographParity( + @RequestBody RuntimeTachographParityValidationApiRequest request + ) { + if (tachographParityValidationService == null) { + throw new IllegalStateException("Runtime tachograph parity validation service is not configured."); + } + return ResponseEntity.ok(tachographParityValidationService.validate(request)); + } + @PostMapping("/tachograph/esper-processing") public ResponseEntity runTachographEsperProcessing( @RequestBody UnifiedRuntimeProcessingApiRequest request diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographDriverParityResultDto.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographDriverParityResultDto.java new file mode 100644 index 0000000..80859d9 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographDriverParityResultDto.java @@ -0,0 +1,20 @@ +package at.procon.eventhub.processing.eventprocessing.validation; + +import java.util.List; + +public record RuntimeTachographDriverParityResultDto( + String driverKey, + String status, + String referenceMode, + int referenceSessionCount, + boolean runtimePartitionPresent, + List comparisons, + List notes, + List warnings +) { + public RuntimeTachographDriverParityResultDto { + comparisons = comparisons == null ? List.of() : List.copyOf(comparisons); + notes = notes == null ? List.of() : List.copyOf(notes); + warnings = warnings == null ? List.of() : List.copyOf(warnings); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityCategoryComparisonDto.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityCategoryComparisonDto.java new file mode 100644 index 0000000..89dd808 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityCategoryComparisonDto.java @@ -0,0 +1,9 @@ +package at.procon.eventhub.processing.eventprocessing.validation; + +public record RuntimeTachographParityCategoryComparisonDto( + String category, + int fileSessionCount, + int runtimeCount, + boolean equal +) { +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationApiRequest.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationApiRequest.java new file mode 100644 index 0000000..e3eda2c --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationApiRequest.java @@ -0,0 +1,61 @@ +package at.procon.eventhub.processing.eventprocessing.validation; + +import java.time.OffsetDateTime; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +public record RuntimeTachographParityValidationApiRequest( + UUID sessionId, + List sessionIds, + UUID compositeSessionId, + String driverKey, + Set driverKeys, + Boolean includeAllDrivers, + OffsetDateTime occurredFrom, + OffsetDateTime occurredTo, + Boolean expandVehicleEvents, + Integer vehicleExpansionPaddingMinutes, + Integer significantDrivingMinutes, + Integer minimumRestPeriodMinutes, + Boolean includeDebug +) { + public RuntimeTachographParityValidationApiRequest { + sessionIds = sessionIds == null ? List.of() : List.copyOf(sessionIds); + driverKeys = driverKeys == null ? Set.of() : Set.copyOf(driverKeys); + vehicleExpansionPaddingMinutes = vehicleExpansionPaddingMinutes == null + ? null + : Math.max(0, vehicleExpansionPaddingMinutes); + significantDrivingMinutes = significantDrivingMinutes == null ? null : Math.max(1, significantDrivingMinutes); + minimumRestPeriodMinutes = minimumRestPeriodMinutes == null ? null : Math.max(1, minimumRestPeriodMinutes); + } + + public Set requestedDriverKeys() { + LinkedHashSet result = new LinkedHashSet<>(); + if (driverKey != null && !driverKey.isBlank()) { + result.add(driverKey.trim()); + } + driverKeys.stream() + .filter(value -> value != null && !value.isBlank()) + .map(String::trim) + .forEach(result::add); + return Set.copyOf(result); + } + + public boolean includeAllDriversOrDefault() { + return includeAllDrivers == null ? requestedDriverKeys().isEmpty() : includeAllDrivers; + } + + public boolean expandVehicleEventsOrDefault() { + return expandVehicleEvents == null || expandVehicleEvents; + } + + public boolean includeDebugOrDefault() { + return includeDebug != null && includeDebug; + } + + public int vehicleExpansionPaddingMinutesOrDefault() { + return vehicleExpansionPaddingMinutes == null ? 0 : Math.max(0, vehicleExpansionPaddingMinutes); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationResultDto.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationResultDto.java new file mode 100644 index 0000000..57d4e88 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationResultDto.java @@ -0,0 +1,23 @@ +package at.procon.eventhub.processing.eventprocessing.validation; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record RuntimeTachographParityValidationResultDto( + String profileKey, + String status, + List sessionIds, + int selectedDriverCount, + Map driverResults, + List notes, + List warnings +) { + public RuntimeTachographParityValidationResultDto { + sessionIds = sessionIds == null ? List.of() : List.copyOf(sessionIds); + driverResults = driverResults == null ? Map.of() : Map.copyOf(new LinkedHashMap<>(driverResults)); + notes = notes == null ? List.of() : List.copyOf(notes); + warnings = warnings == null ? List.of() : List.copyOf(warnings); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationService.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationService.java new file mode 100644 index 0000000..584099a --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationService.java @@ -0,0 +1,388 @@ +package at.procon.eventhub.processing.eventprocessing.validation; + +import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto; +import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; +import at.procon.eventhub.processing.eventprocessing.RuntimeEventProcessingService; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventPartitioningApiRequest; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingPartitionResultDto; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto; +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy; +import at.procon.eventhub.processing.eventprocessing.profile.TachographDriverEsperRuntimeEventProcessingProfile; +import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; +import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperEventsProcessingRequest; +import at.procon.eventhub.tachographfilesession.model.TachographCompositeSession; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionNotFoundException; +import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionRepository; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionNotFoundException; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionRepository; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; + +@Service +public class RuntimeTachographParityValidationService { + + private final TachographFileSessionRepository fileSessionRepository; + private final TachographCompositeSessionRepository compositeSessionRepository; + private final TachographFileSessionProcessingService fileSessionProcessingService; + private final RuntimeEventProcessingService runtimeEventProcessingService; + + public RuntimeTachographParityValidationService( + TachographFileSessionRepository fileSessionRepository, + TachographCompositeSessionRepository compositeSessionRepository, + TachographFileSessionProcessingService fileSessionProcessingService, + RuntimeEventProcessingService runtimeEventProcessingService + ) { + this.fileSessionRepository = fileSessionRepository; + this.compositeSessionRepository = compositeSessionRepository; + this.fileSessionProcessingService = fileSessionProcessingService; + this.runtimeEventProcessingService = runtimeEventProcessingService; + } + + public RuntimeTachographParityValidationResultDto validate(RuntimeTachographParityValidationApiRequest request) { + RuntimeTachographParityValidationApiRequest effectiveRequest = request == null + ? new RuntimeTachographParityValidationApiRequest(null, List.of(), null, null, Set.of(), true, + null, null, true, 0, null, null, false) + : request; + List sessionIds = resolveSessionIds(effectiveRequest); + Set driverKeys = resolveDriverKeys(effectiveRequest, sessionIds); + if (driverKeys.isEmpty()) { + throw new IllegalArgumentException("No driver keys could be resolved for tachograph parity validation."); + } + + RuntimeEventProcessingResultDto runtimeResult = runtimeEventProcessingService.process(genericRuntimeRequest( + effectiveRequest, + sessionIds, + driverKeys + )); + + LinkedHashMap driverResults = new LinkedHashMap<>(); + List warnings = new ArrayList<>(runtimeResult.warnings()); + for (String driverKey : driverKeys) { + RuntimeTachographDriverParityResultDto driverResult = compareDriver( + effectiveRequest, + sessionIds, + driverKey, + runtimeResult + ); + driverResults.put(driverKey, driverResult); + warnings.addAll(driverResult.warnings()); + } + + String status = driverResults.values().stream().allMatch(result -> "EQUAL".equals(result.status())) + ? "EQUAL" + : "DIFFERENT"; + List notes = new ArrayList<>(runtimeResult.notes()); + notes.add("Validation compares the legacy tachograph file-session esper-events path with the generic runtime event-processing profile '" + + TachographDriverEsperRuntimeEventProcessingProfile.PROFILE_KEY + "'."); + if (sessionIds.size() > 1) { + notes.add("For multiple sessions, file-session reference counts are summed per driver. Runtime processing may intentionally merge or deduplicate intervals across session boundaries, so category differences need domain review."); + } + + return new RuntimeTachographParityValidationResultDto( + TachographDriverEsperRuntimeEventProcessingProfile.PROFILE_KEY, + status, + sessionIds, + driverResults.size(), + driverResults, + notes, + warnings + ); + } + + private RuntimeTachographDriverParityResultDto compareDriver( + RuntimeTachographParityValidationApiRequest request, + List sessionIds, + String driverKey, + RuntimeEventProcessingResultDto runtimeResult + ) { + RuntimeProjectionCounts runtimeCounts = runtimeCounts(runtimeResult, driverKey); + ReferenceProjectionCounts referenceCounts = referenceCounts(request, sessionIds, driverKey); + List comparisons = comparisons(referenceCounts.counts(), runtimeCounts.counts()); + boolean runtimePresent = runtimeCounts.present(); + boolean referencePresent = referenceCounts.referenceSessionCount() > 0; + boolean allEqual = comparisons.stream().allMatch(RuntimeTachographParityCategoryComparisonDto::equal); + + String status; + if (!referencePresent) { + status = "REFERENCE_MISSING"; + } else if (!runtimePresent) { + status = "RUNTIME_MISSING"; + } else if (allEqual) { + status = "EQUAL"; + } else { + status = "DIFFERENT"; + } + + List notes = new ArrayList<>(); + notes.add("Reference mode: " + referenceCounts.referenceMode() + "."); + notes.add("Reference session count: " + referenceCounts.referenceSessionCount() + "."); + if (sessionIds.size() > 1) { + notes.add("Reference counts are summed from individual file-session endpoint results for this driver."); + } + List warnings = new ArrayList<>(); + if (!referencePresent) { + warnings.add("Driver " + driverKey + " was not found in any selected tachograph file session."); + } + if (!runtimePresent) { + warnings.add("Generic runtime event-processing profile did not return a partition for driver " + driverKey + "."); + } + if (runtimePresent && referencePresent && !allEqual) { + warnings.add("Runtime result differs from file-session reference result for driver " + driverKey + "."); + } + return new RuntimeTachographDriverParityResultDto( + driverKey, + status, + referenceCounts.referenceMode(), + referenceCounts.referenceSessionCount(), + runtimePresent, + comparisons, + notes, + warnings + ); + } + + private RuntimeEventProcessingApiRequest genericRuntimeRequest( + RuntimeTachographParityValidationApiRequest request, + List sessionIds, + Set driverKeys + ) { + UnifiedRuntimeProcessingApiRequest scope = new UnifiedRuntimeProcessingApiRequest( + null, + sessionIds, + null, + null, + Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION), + UnifiedRuntimeEventBackend.SOURCE_DB, + null, + driverKeys, + request.includeAllDriversOrDefault(), + null, + false, + null, + null, + null, + request.occurredFrom(), + request.occurredTo(), + request.expandVehicleEventsOrDefault(), + request.vehicleExpansionPaddingMinutesOrDefault(), + request.significantDrivingMinutes(), + request.minimumRestPeriodMinutes() + ); + RuntimeEventPartitioningApiRequest partitioning = new RuntimeEventPartitioningApiRequest( + RuntimeEventPartitioningStrategy.DRIVER, + null, + request.includeAllDriversOrDefault(), + driverKeys, + request.includeAllDriversOrDefault(), + null, + false, + request.expandVehicleEventsOrDefault(), + request.vehicleExpansionPaddingMinutesOrDefault(), + request.includeDebugOrDefault() + ); + Map parameters = new LinkedHashMap<>(); + if (request.significantDrivingMinutes() != null) { + parameters.put("significantDrivingMinutes", request.significantDrivingMinutes()); + } + if (request.minimumRestPeriodMinutes() != null) { + parameters.put("minimumRestPeriodMinutes", request.minimumRestPeriodMinutes()); + } + parameters.put("attachVehicleOnlyEvents", request.expandVehicleEventsOrDefault()); + parameters.put("vehicleEvidencePaddingMinutes", request.vehicleExpansionPaddingMinutesOrDefault()); + parameters.put("includePartitionDebug", request.includeDebugOrDefault()); + return new RuntimeEventProcessingApiRequest( + TachographDriverEsperRuntimeEventProcessingProfile.PROFILE_KEY, + scope, + partitioning, + parameters + ); + } + + private List resolveSessionIds(RuntimeTachographParityValidationApiRequest request) { + LinkedHashSet result = new LinkedHashSet<>(); + if (request.sessionId() != null) { + result.add(request.sessionId()); + } + result.addAll(request.sessionIds()); + if (request.compositeSessionId() != null) { + TachographCompositeSession compositeSession = compositeSessionRepository.find(request.compositeSessionId()) + .orElseThrow(() -> new TachographCompositeSessionNotFoundException(request.compositeSessionId())); + result.addAll(compositeSession.memberSessionIds()); + } + if (result.isEmpty()) { + throw new IllegalArgumentException("sessionId, sessionIds, or compositeSessionId is required for tachograph parity validation."); + } + result.forEach(this::requireFileSession); + return List.copyOf(result); + } + + private Set resolveDriverKeys(RuntimeTachographParityValidationApiRequest request, List sessionIds) { + LinkedHashSet result = new LinkedHashSet<>(request.requestedDriverKeys()); + if (request.includeAllDriversOrDefault() || result.isEmpty()) { + for (UUID sessionId : sessionIds) { + result.addAll(requireFileSession(sessionId).driversByKey().keySet()); + } + } + return Set.copyOf(result); + } + + private TachographFileSession requireFileSession(UUID sessionId) { + return fileSessionRepository.find(sessionId) + .orElseThrow(() -> new TachographFileSessionNotFoundException(sessionId)); + } + + private ReferenceProjectionCounts referenceCounts( + RuntimeTachographParityValidationApiRequest request, + List sessionIds, + String driverKey + ) { + ProjectionCounts aggregate = ProjectionCounts.empty(); + int referenceSessionCount = 0; + TachographEsperEventsProcessingRequest fileSessionRequest = new TachographEsperEventsProcessingRequest( + request.occurredFrom(), + request.occurredTo(), + request.significantDrivingMinutes(), + request.minimumRestPeriodMinutes() + ); + for (UUID sessionId : sessionIds) { + TachographFileSession session = requireFileSession(sessionId); + if (!session.driversByKey().containsKey(driverKey)) { + continue; + } + TachographEsperDriverProcessingResultDto projection = fileSessionProcessingService.getEsperDriverProcessingResults( + sessionId, + driverKey, + fileSessionRequest + ); + aggregate = aggregate.plus(ProjectionCounts.from(projection)); + referenceSessionCount++; + } + String referenceMode = sessionIds.size() == 1 ? "SINGLE_FILE_SESSION" : "SUM_OF_FILE_SESSION_RESULTS"; + return new ReferenceProjectionCounts(aggregate, referenceMode, referenceSessionCount); + } + + private RuntimeProjectionCounts runtimeCounts(RuntimeEventProcessingResultDto runtimeResult, String driverKey) { + RuntimeEventProcessingPartitionResultDto partitionResult = runtimeResult.partitionResults().get(driverKey); + if (partitionResult == null) { + return new RuntimeProjectionCounts(ProjectionCounts.empty(), false); + } + Object result = partitionResult.result(); + if (result instanceof UnifiedRuntimeDerivedProjectionResultDto projectionResult) { + return new RuntimeProjectionCounts(ProjectionCounts.from(projectionResult.projection()), projectionResult.projection() != null); + } + return new RuntimeProjectionCounts(ProjectionCounts.empty(), false); + } + + private List comparisons( + ProjectionCounts reference, + ProjectionCounts runtime + ) { + return List.of( + comparison("activityIntervals", reference.activityIntervalCount(), runtime.activityIntervalCount()), + comparison("drivingIntervals", reference.drivingIntervalCount(), runtime.drivingIntervalCount()), + comparison("drivingInterruptionIntervals", reference.drivingInterruptionIntervalCount(), runtime.drivingInterruptionIntervalCount()), + comparison("drivingInterruptionVehicleChangeIntervals", reference.drivingInterruptionVehicleChangeIntervalCount(), runtime.drivingInterruptionVehicleChangeIntervalCount()), + comparison("dailyWeeklyRestCandidateIntervals", reference.dailyWeeklyRestCandidateIntervalCount(), runtime.dailyWeeklyRestCandidateIntervalCount()), + comparison("dailyWeeklyRestCandidateCoverageIntervals", reference.dailyWeeklyRestCandidateCoverageIntervalCount(), runtime.dailyWeeklyRestCandidateCoverageIntervalCount()), + comparison("unclassifiedDailyWeeklyRestCandidateCoverageIntervals", reference.unclassifiedDailyWeeklyRestCandidateCoverageIntervalCount(), runtime.unclassifiedDailyWeeklyRestCandidateCoverageIntervalCount()), + comparison("potentialHomeOvernightStayIntervals", reference.potentialHomeOvernightStayIntervalCount(), runtime.potentialHomeOvernightStayIntervalCount()), + comparison("potentialInVehicleOvernightStayIntervals", reference.potentialInVehicleOvernightStayIntervalCount(), runtime.potentialInVehicleOvernightStayIntervalCount()), + comparison("potentialInVehicleTripIntervals", reference.potentialInVehicleTripIntervalCount(), runtime.potentialInVehicleTripIntervalCount()), + comparison("vehicleUsageIntervals", reference.vehicleUsageIntervalCount(), runtime.vehicleUsageIntervalCount()), + comparison("vuCardAbsentIntervals", reference.vuCardAbsentIntervalCount(), runtime.vuCardAbsentIntervalCount()), + comparison("supportGeoEvents", reference.supportGeoEventCount(), runtime.supportGeoEventCount()) + ); + } + + private RuntimeTachographParityCategoryComparisonDto comparison(String category, int fileSessionCount, int runtimeCount) { + return new RuntimeTachographParityCategoryComparisonDto( + category, + fileSessionCount, + runtimeCount, + fileSessionCount == runtimeCount + ); + } + + private record ReferenceProjectionCounts( + ProjectionCounts counts, + String referenceMode, + int referenceSessionCount + ) { + } + + private record RuntimeProjectionCounts( + ProjectionCounts counts, + boolean present + ) { + } + + private record ProjectionCounts( + int activityIntervalCount, + int drivingIntervalCount, + int drivingInterruptionIntervalCount, + int drivingInterruptionVehicleChangeIntervalCount, + int dailyWeeklyRestCandidateIntervalCount, + int dailyWeeklyRestCandidateCoverageIntervalCount, + int unclassifiedDailyWeeklyRestCandidateCoverageIntervalCount, + int potentialHomeOvernightStayIntervalCount, + int potentialInVehicleOvernightStayIntervalCount, + int potentialInVehicleTripIntervalCount, + int vehicleUsageIntervalCount, + int vuCardAbsentIntervalCount, + int supportGeoEventCount + ) { + static ProjectionCounts empty() { + return new ProjectionCounts(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + static ProjectionCounts from(TachographEsperDriverProcessingResultDto projection) { + if (projection == null) { + return empty(); + } + return new ProjectionCounts( + projection.activityIntervalCount(), + projection.drivingIntervalCount(), + projection.drivingInterruptionIntervalCount(), + projection.drivingInterruptionVehicleChangeIntervalCount(), + projection.dailyWeeklyRestCandidateIntervalCount(), + projection.dailyWeeklyRestCandidateCoverageIntervalCount(), + projection.unclassifiedDailyWeeklyRestCandidateCoverageIntervalCount(), + projection.potentialHomeOvernightStayIntervalCount(), + projection.potentialInVehicleOvernightStayIntervalCount(), + projection.potentialInVehicleTripIntervalCount(), + projection.vehicleUsageIntervalCount(), + projection.vuCardAbsentIntervalCount(), + projection.supportGeoEventCount() + ); + } + + ProjectionCounts plus(ProjectionCounts other) { + return new ProjectionCounts( + activityIntervalCount + other.activityIntervalCount, + drivingIntervalCount + other.drivingIntervalCount, + drivingInterruptionIntervalCount + other.drivingInterruptionIntervalCount, + drivingInterruptionVehicleChangeIntervalCount + other.drivingInterruptionVehicleChangeIntervalCount, + dailyWeeklyRestCandidateIntervalCount + other.dailyWeeklyRestCandidateIntervalCount, + dailyWeeklyRestCandidateCoverageIntervalCount + other.dailyWeeklyRestCandidateCoverageIntervalCount, + unclassifiedDailyWeeklyRestCandidateCoverageIntervalCount + other.unclassifiedDailyWeeklyRestCandidateCoverageIntervalCount, + potentialHomeOvernightStayIntervalCount + other.potentialHomeOvernightStayIntervalCount, + potentialInVehicleOvernightStayIntervalCount + other.potentialInVehicleOvernightStayIntervalCount, + potentialInVehicleTripIntervalCount + other.potentialInVehicleTripIntervalCount, + vehicleUsageIntervalCount + other.vehicleUsageIntervalCount, + vuCardAbsentIntervalCount + other.vuCardAbsentIntervalCount, + supportGeoEventCount + other.supportGeoEventCount + ); + } + } +} diff --git a/src/test/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingControllerTest.java b/src/test/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingControllerTest.java index e93c671..9eda623 100644 --- a/src/test/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingControllerTest.java +++ b/src/test/java/at/procon/eventhub/processing/api/UnifiedRuntimeProcessingControllerTest.java @@ -21,6 +21,10 @@ import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingR import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingProfileDescriptorDto; import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingPartitionResultDto; import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy; +import at.procon.eventhub.processing.eventprocessing.validation.RuntimeTachographDriverParityResultDto; +import at.procon.eventhub.processing.eventprocessing.validation.RuntimeTachographParityCategoryComparisonDto; +import at.procon.eventhub.processing.eventprocessing.validation.RuntimeTachographParityValidationResultDto; +import at.procon.eventhub.processing.eventprocessing.validation.RuntimeTachographParityValidationService; import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef; import at.procon.eventhub.processing.model.UnifiedEventSourceFamily; import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend; @@ -447,6 +451,71 @@ class UnifiedRuntimeProcessingControllerTest { .andExpect(jsonPath("$.notes[0]").value("generic adapter")); } + + @Test + void validatesTachographParityViaRuntimeApi() throws Exception { + UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class); + UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.class); + UnifiedRuntimeDerivedProjectionService derivedProjectionService = org.mockito.Mockito.mock(UnifiedRuntimeDerivedProjectionService.class); + RuntimeEventProcessingService runtimeEventProcessingService = org.mockito.Mockito.mock(RuntimeEventProcessingService.class); + RuntimeTachographParityValidationService parityValidationService = org.mockito.Mockito.mock(RuntimeTachographParityValidationService.class); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController( + eventAssemblyService, + timelineService, + derivedProjectionService, + null, + runtimeEventProcessingService, + parityValidationService + )) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler()) + .build(); + + UUID sessionId = UUID.randomUUID(); + when(parityValidationService.validate(any())) + .thenReturn(new RuntimeTachographParityValidationResultDto( + "tachograph-driver-esper-v1", + "EQUAL", + List.of(sessionId), + 1, + Map.of("12:123", new RuntimeTachographDriverParityResultDto( + "12:123", + "EQUAL", + "SINGLE_FILE_SESSION", + 1, + true, + List.of(new RuntimeTachographParityCategoryComparisonDto( + "activityIntervals", + 2, + 2, + true + )), + List.of("validated"), + List.of() + )), + List.of("validation complete"), + List.of() + )); + + mockMvc.perform(post("/api/eventhub/runtime-processing/event-processing/validation/tachograph-parity") + .contentType("application/json") + .content(""" + { + "sessionId": "%s", + "driverKey": "12:123", + "occurredFrom": "2026-05-01T08:00:00Z", + "occurredTo": "2026-05-01T10:00:00Z", + "includeDebug": true + } + """.formatted(sessionId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.profileKey").value("tachograph-driver-esper-v1")) + .andExpect(jsonPath("$.status").value("EQUAL")) + .andExpect(jsonPath("$.driverResults['12:123'].status").value("EQUAL")) + .andExpect(jsonPath("$.driverResults['12:123'].comparisons[0].category").value("activityIntervals")) + .andExpect(jsonPath("$.driverResults['12:123'].comparisons[0].equal").value(true)); + } + @Test void returnsBadRequestForInvalidRuntimeRequest() throws Exception { UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class); diff --git a/src/test/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationServiceTest.java b/src/test/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationServiceTest.java new file mode 100644 index 0000000..49a129f --- /dev/null +++ b/src/test/java/at/procon/eventhub/processing/eventprocessing/validation/RuntimeTachographParityValidationServiceTest.java @@ -0,0 +1,185 @@ +package at.procon.eventhub.processing.eventprocessing.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto; +import at.procon.eventhub.processing.eventprocessing.RuntimeEventProcessingService; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingPartitionResultDto; +import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto; +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy; +import at.procon.eventhub.processing.eventprocessing.profile.TachographDriverEsperRuntimeEventProcessingProfile; +import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.ExtractionStats; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionRepository; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService; +import at.procon.eventhub.tachographfilesession.service.TachographFileSessionRepository; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class RuntimeTachographParityValidationServiceTest { + + @Test + void reportsEqualWhenRuntimeProfileMatchesFileSessionReferenceCounts() { + TachographFileSessionRepository fileSessionRepository = org.mockito.Mockito.mock(TachographFileSessionRepository.class); + TachographCompositeSessionRepository compositeSessionRepository = org.mockito.Mockito.mock(TachographCompositeSessionRepository.class); + TachographFileSessionProcessingService fileSessionProcessingService = org.mockito.Mockito.mock(TachographFileSessionProcessingService.class); + RuntimeEventProcessingService runtimeEventProcessingService = org.mockito.Mockito.mock(RuntimeEventProcessingService.class); + RuntimeTachographParityValidationService service = new RuntimeTachographParityValidationService( + fileSessionRepository, + compositeSessionRepository, + fileSessionProcessingService, + runtimeEventProcessingService + ); + + UUID sessionId = UUID.randomUUID(); + String driverKey = "12:123"; + TachographFileSession session = session(sessionId, driverKey); + when(fileSessionRepository.find(sessionId)).thenReturn(Optional.of(session)); + when(fileSessionProcessingService.getEsperDriverProcessingResults(eq(sessionId), eq(driverKey), any())) + .thenReturn(projection(sessionId, driverKey, 2, 1, 1)); + + UnifiedRuntimeProcessingRequest runtimeRequest = UnifiedRuntimeProcessingRequest.forTachographFileSession( + sessionId, + driverKey, + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T10:00:00Z"), + true, + 0 + ); + UnifiedRuntimeDerivedProjectionResultDto runtimeDriverResult = new UnifiedRuntimeDerivedProjectionResultDto( + runtimeRequest, + 4, + 1, + 0, + 4, + List.of(), + projection(sessionId, driverKey, 2, 1, 1), + List.of() + ); + when(runtimeEventProcessingService.process(any())) + .thenReturn(new RuntimeEventProcessingResultDto( + TachographDriverEsperRuntimeEventProcessingProfile.PROFILE_KEY, + RuntimeEventPartitioningStrategy.DRIVER, + runtimeRequest, + 4, + 1, + 1, + List.of(), + Map.of(driverKey, new RuntimeEventProcessingPartitionResultDto( + "DRIVER", + driverKey, + "UnifiedRuntimeDerivedProjectionResultDto", + runtimeDriverResult, + Map.of() + )), + List.of(), + List.of() + )); + + RuntimeTachographParityValidationResultDto result = service.validate(new RuntimeTachographParityValidationApiRequest( + sessionId, + List.of(), + null, + driverKey, + Set.of(), + false, + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T10:00:00Z"), + true, + 0, + 3, + 720, + true + )); + + assertThat(result.status()).isEqualTo("EQUAL"); + assertThat(result.driverResults()).containsKey(driverKey); + assertThat(result.driverResults().get(driverKey).comparisons()) + .anySatisfy(comparison -> { + assertThat(comparison.category()).isEqualTo("activityIntervals"); + assertThat(comparison.fileSessionCount()).isEqualTo(2); + assertThat(comparison.runtimeCount()).isEqualTo(2); + assertThat(comparison.equal()).isTrue(); + }); + } + + private TachographFileSession session(UUID sessionId, String driverKey) { + DriverExtractionSession driver = new DriverExtractionSession( + driverKey, + null, + null, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + return new TachographFileSession( + sessionId, + null, + Map.of(driverKey, driver), + new ExtractionStats(1, 0, 0, 0, 0, 0), + List.of(), + Instant.now(), + Instant.now().plusSeconds(3600) + ); + } + + private TachographEsperDriverProcessingResultDto projection( + UUID sessionId, + String driverKey, + int activityCount, + int drivingCount, + int interruptionCount + ) { + return new TachographEsperDriverProcessingResultDto( + sessionId, + driverKey, + "DRIVER_CARD", + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T10:00:00Z"), + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T10:00:00Z"), + activityCount, + drivingCount, + interruptionCount, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + } +}