From e68047feab38c02daa2d00e48aae573fd481cd6e Mon Sep 17 00:00:00 2001 From: trifonovt <87468028+TihomirTrifonov@users.noreply.github.com> Date: Mon, 25 May 2026 22:44:42 +0200 Subject: [PATCH] Add runtime vehicle evidence debug output --- README_PATCH.md | 17 +++ docs/runtime-event-processing.md | 60 ++++++++++- ...ntime-tachograph-esper-scope-processing.md | 5 + ...e-event-processing.postman_collection.json | 6 +- .../dto/RuntimeDriverPartitionDebugDto.java | 24 +++++ ...eVehicleEvidenceAttachmentDecisionDto.java | 25 +++++ .../RuntimeVehicleUsageIntervalDebugDto.java | 27 +++++ ...fiedRuntimeDerivedProjectionResultDto.java | 40 ++++++- ...dRuntimeTachographEsperScopeResultDto.java | 16 +++ .../RuntimeEventPartitioningApiRequest.java | 7 +- .../dto/RuntimeEventProcessingApiRequest.java | 5 +- ...verEsperRuntimeEventProcessingProfile.java | 53 ++++++--- ...DriverVehicleEvidenceAttachmentResult.java | 23 ++++ ...riverVehicleEvidenceAttachmentService.java | 101 +++++++++++++++++- ...TachographEsperScopeProcessingService.java | 38 +++++-- .../RuntimeEventProcessingServiceTest.java | 2 +- ...sperRuntimeEventProcessingProfileTest.java | 20 +++- ...rVehicleEvidenceAttachmentServiceTest.java | 29 +++++ 18 files changed, 456 insertions(+), 42 deletions(-) create mode 100644 README_PATCH.md create mode 100644 src/main/java/at/procon/eventhub/processing/dto/RuntimeDriverPartitionDebugDto.java create mode 100644 src/main/java/at/procon/eventhub/processing/dto/RuntimeVehicleEvidenceAttachmentDecisionDto.java create mode 100644 src/main/java/at/procon/eventhub/processing/dto/RuntimeVehicleUsageIntervalDebugDto.java diff --git a/README_PATCH.md b/README_PATCH.md new file mode 100644 index 0000000..ea2abc8 --- /dev/null +++ b/README_PATCH.md @@ -0,0 +1,17 @@ +# EventHub fix-list patch + +This patch implements the requested remaining items from the fix list, excluding build verification and SQL Server 2008 SQL rewriting. + +## Apply notes + +1. Copy the files from this archive into the project root. +2. Delete the old migration files listed in `DELETE_FILES.txt`. +3. Run the test suite locally with Maven/Java 21. + +## Main changes + +- Renumbered Flyway migrations to remove duplicate `V9` and `V10` versions. +- Removed the duplicated Timescale/Event source record migration. +- Switched local Docker Compose DB from plain PostgreSQL to a TimescaleDB/PostGIS-capable image. +- Added normalized raw tachograph payload metadata for DB-extracted EventHub events. +- Added tests for Flyway version uniqueness and tachograph DB mapper → timeline reconstruction metadata. diff --git a/docs/runtime-event-processing.md b/docs/runtime-event-processing.md index 842bf70..032c7c1 100644 --- a/docs/runtime-event-processing.md +++ b/docs/runtime-event-processing.md @@ -29,7 +29,8 @@ Example response: "significantDrivingMinutes", "minimumRestPeriodMinutes", "attachVehicleOnlyEvents", - "vehicleEvidencePaddingMinutes" + "vehicleEvidencePaddingMinutes", + "includePartitionDebug" ] } ] @@ -63,13 +64,15 @@ POST /api/eventhub/runtime-processing/event-processing "strategy": "DRIVER", "includeAllPartitions": true, "attachVehicleEvidence": true, - "vehicleEvidencePaddingMinutes": 15 + "vehicleEvidencePaddingMinutes": 15, + "includeDebug": true }, "parameters": { "significantDrivingMinutes": 3, "minimumRestPeriodMinutes": 720, "attachVehicleOnlyEvents": true, - "vehicleEvidencePaddingMinutes": 15 + "vehicleEvidencePaddingMinutes": 15, + "includePartitionDebug": true } } ``` @@ -182,6 +185,51 @@ CUSTOM_PROFILE The first tachograph profile currently supports `DRIVER` partitioning. The service partitions mixed event scopes in Java before invoking Esper so that existing single-driver EPL windows cannot mix driver states. +## Partition debug / audit output + +For mixed-source scopes, request debug output when validating why events were or were not attached to a driver partition: + +```json +{ + "partitioning": { + "strategy": "DRIVER", + "includeAllPartitions": true, + "attachVehicleEvidence": true, + "vehicleEvidencePaddingMinutes": 15, + "includeDebug": true + }, + "parameters": { + "includePartitionDebug": true + } +} +``` + +When enabled, each generic `partitionResults[*].metadata.partitionDebug` contains: + +```text +directDriverEventCount +vehicleUsageIntervalCount +candidateVehicleEvidenceEventCount +attachedVehicleEvidenceEventCount +ignoredVehicleEvidenceEventCount +mergedEventCount +vehicleUsageIntervals +vehicleEvidenceDecisions +notes +warnings +``` + +`vehicleEvidenceDecisions` explains every relevant decision, for example: + +```text +DIRECT_DRIVER_EVENT +ATTACHED_VEHICLE_EVIDENCE +IGNORED_NO_OVERLAPPING_VEHICLE_USAGE +IGNORED_ATTACHMENT_DISABLED +``` + +The compatibility response type also has `partitionDebugByDriver`; use the generic endpoint when you need to enable debug output explicitly. Keep debug disabled for high-volume production requests unless you need attribution diagnostics, because the decision list can be large. + ## Compatibility endpoint The old tachograph endpoint remains available: @@ -233,11 +281,13 @@ This prevents unrelated vehicle events from being copied into a driver result si { "partitioning": { "attachVehicleEvidence": true, - "vehicleEvidencePaddingMinutes": 15 + "vehicleEvidencePaddingMinutes": 15, + "includeDebug": true }, "parameters": { "attachVehicleOnlyEvents": true, - "vehicleEvidencePaddingMinutes": 15 + "vehicleEvidencePaddingMinutes": 15, + "includePartitionDebug": true } } ``` diff --git a/docs/runtime-tachograph-esper-scope-processing.md b/docs/runtime-tachograph-esper-scope-processing.md index 668cfce..b06419c 100644 --- a/docs/runtime-tachograph-esper-scope-processing.md +++ b/docs/runtime-tachograph-esper-scope-processing.md @@ -47,3 +47,8 @@ to the generic profile behavior: ``` Attachment is temporal: a vehicle-only event must match a reconstructed driver vehicle-usage interval and occur inside the interval plus configured padding. + + +## Debugging vehicle evidence attachment + +Prefer the generic `/api/eventhub/runtime-processing/event-processing` endpoint with `partitioning.includeDebug=true` or `parameters.includePartitionDebug=true`. The compatibility response type has `partitionDebugByDriver`, but the generic endpoint is the preferred way to enable debug output explicitly. The generic response exposes debug data under `partitionResults[*].metadata.partitionDebug`. diff --git a/postman/eventhub-runtime-event-processing.postman_collection.json b/postman/eventhub-runtime-event-processing.postman_collection.json index 8e641b3..f11c589 100644 --- a/postman/eventhub-runtime-event-processing.postman_collection.json +++ b/postman/eventhub-runtime-event-processing.postman_collection.json @@ -59,7 +59,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"profileKey\": \"tachograph-driver-esper-v1\",\n \"scope\": {\n \"sessionId\": \"{{sessionId}}\",\n \"sourceFamilies\": [\n \"TACHOGRAPH_FILE_SESSION\"\n ],\n \"driverKey\": \"{{driverKey}}\",\n \"occurredFrom\": \"2026-05-01T00:00:00Z\",\n \"occurredTo\": \"2026-05-31T23:59:59Z\",\n \"expandVehicleEvents\": true,\n \"vehicleExpansionPaddingMinutes\": 15\n },\n \"partitioning\": {\n \"strategy\": \"DRIVER\",\n \"partitionKeys\": [\n \"{{driverKey}}\"\n ],\n \"attachVehicleEvidence\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n },\n \"parameters\": {\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720,\n \"attachVehicleOnlyEvents\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n }\n}" + "raw": "{\n \"profileKey\": \"tachograph-driver-esper-v1\",\n \"scope\": {\n \"sessionId\": \"{{sessionId}}\",\n \"sourceFamilies\": [\n \"TACHOGRAPH_FILE_SESSION\"\n ],\n \"driverKey\": \"{{driverKey}}\",\n \"occurredFrom\": \"2026-05-01T00:00:00Z\",\n \"occurredTo\": \"2026-05-31T23:59:59Z\",\n \"expandVehicleEvents\": true,\n \"vehicleExpansionPaddingMinutes\": 15\n },\n \"partitioning\": {\n \"strategy\": \"DRIVER\",\n \"partitionKeys\": [\n \"{{driverKey}}\"\n ],\n \"attachVehicleEvidence\": true,\n \"vehicleEvidencePaddingMinutes\": 15,\n \"includeDebug\": true\n },\n \"parameters\": {\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720,\n \"attachVehicleOnlyEvents\": true,\n \"vehicleEvidencePaddingMinutes\": 15,\n \"includePartitionDebug\": true\n }\n}" }, "url": { "raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing", @@ -87,7 +87,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"profileKey\": \"tachograph-driver-esper-v1\",\n \"scope\": {\n \"sessionIds\": [\n \"{{sessionId}}\",\n \"{{sessionId2}}\"\n ],\n \"sourceFamilies\": [\n \"TACHOGRAPH_FILE_SESSION\"\n ],\n \"occurredFrom\": \"2026-05-01T00:00:00Z\",\n \"occurredTo\": \"2026-05-31T23:59:59Z\",\n \"expandVehicleEvents\": true,\n \"vehicleExpansionPaddingMinutes\": 15\n },\n \"partitioning\": {\n \"strategy\": \"DRIVER\",\n \"includeAllPartitions\": true,\n \"attachVehicleEvidence\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n },\n \"parameters\": {\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720,\n \"attachVehicleOnlyEvents\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n }\n}" + "raw": "{\n \"profileKey\": \"tachograph-driver-esper-v1\",\n \"scope\": {\n \"sessionIds\": [\n \"{{sessionId}}\",\n \"{{sessionId2}}\"\n ],\n \"sourceFamilies\": [\n \"TACHOGRAPH_FILE_SESSION\"\n ],\n \"occurredFrom\": \"2026-05-01T00:00:00Z\",\n \"occurredTo\": \"2026-05-31T23:59:59Z\",\n \"expandVehicleEvents\": true,\n \"vehicleExpansionPaddingMinutes\": 15\n },\n \"partitioning\": {\n \"strategy\": \"DRIVER\",\n \"includeAllPartitions\": true,\n \"attachVehicleEvidence\": true,\n \"vehicleEvidencePaddingMinutes\": 15,\n \"includeDebug\": true\n },\n \"parameters\": {\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720,\n \"attachVehicleOnlyEvents\": true,\n \"vehicleEvidencePaddingMinutes\": 15,\n \"includePartitionDebug\": true\n }\n}" }, "url": { "raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing", @@ -115,7 +115,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"profileKey\": \"tachograph-driver-esper-v1\",\n \"scope\": {\n \"compositeSessionId\": \"{{compositeSessionId}}\",\n \"sourceFamilies\": [\n \"TACHOGRAPH_FILE_SESSION\"\n ],\n \"occurredFrom\": \"2026-05-01T00:00:00Z\",\n \"occurredTo\": \"2026-05-31T23:59:59Z\",\n \"expandVehicleEvents\": true,\n \"vehicleExpansionPaddingMinutes\": 15\n },\n \"partitioning\": {\n \"strategy\": \"DRIVER\",\n \"includeAllPartitions\": true,\n \"attachVehicleEvidence\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n },\n \"parameters\": {\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720,\n \"attachVehicleOnlyEvents\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n }\n}" + "raw": "{\n \"profileKey\": \"tachograph-driver-esper-v1\",\n \"scope\": {\n \"compositeSessionId\": \"{{compositeSessionId}}\",\n \"sourceFamilies\": [\n \"TACHOGRAPH_FILE_SESSION\"\n ],\n \"occurredFrom\": \"2026-05-01T00:00:00Z\",\n \"occurredTo\": \"2026-05-31T23:59:59Z\",\n \"expandVehicleEvents\": true,\n \"vehicleExpansionPaddingMinutes\": 15\n },\n \"partitioning\": {\n \"strategy\": \"DRIVER\",\n \"includeAllPartitions\": true,\n \"attachVehicleEvidence\": true,\n \"vehicleEvidencePaddingMinutes\": 15,\n \"includeDebug\": true\n },\n \"parameters\": {\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720,\n \"attachVehicleOnlyEvents\": true,\n \"vehicleEvidencePaddingMinutes\": 15,\n \"includePartitionDebug\": true\n }\n}" }, "url": { "raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing", diff --git a/src/main/java/at/procon/eventhub/processing/dto/RuntimeDriverPartitionDebugDto.java b/src/main/java/at/procon/eventhub/processing/dto/RuntimeDriverPartitionDebugDto.java new file mode 100644 index 0000000..dbd2ef9 --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/dto/RuntimeDriverPartitionDebugDto.java @@ -0,0 +1,24 @@ +package at.procon.eventhub.processing.dto; + +import java.util.List; + +public record RuntimeDriverPartitionDebugDto( + String driverKey, + int directDriverEventCount, + int vehicleUsageIntervalCount, + int candidateVehicleEvidenceEventCount, + int attachedVehicleEvidenceEventCount, + int ignoredVehicleEvidenceEventCount, + int mergedEventCount, + List vehicleUsageIntervals, + List vehicleEvidenceDecisions, + List notes, + List warnings +) { + public RuntimeDriverPartitionDebugDto { + vehicleUsageIntervals = vehicleUsageIntervals == null ? List.of() : List.copyOf(vehicleUsageIntervals); + vehicleEvidenceDecisions = vehicleEvidenceDecisions == null ? List.of() : List.copyOf(vehicleEvidenceDecisions); + 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/dto/RuntimeVehicleEvidenceAttachmentDecisionDto.java b/src/main/java/at/procon/eventhub/processing/dto/RuntimeVehicleEvidenceAttachmentDecisionDto.java new file mode 100644 index 0000000..a04fa8d --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/dto/RuntimeVehicleEvidenceAttachmentDecisionDto.java @@ -0,0 +1,25 @@ +package at.procon.eventhub.processing.dto; + +import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventScopeType; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Set; + +public record RuntimeVehicleEvidenceAttachmentDecisionDto( + String decision, + String reason, + String eventKey, + String externalSourceEventId, + OffsetDateTime occurredAt, + String eventDomain, + String eventType, + String lifecycle, + RuntimeEventScopeType scopeType, + Set vehicleKeys, + List matchingVehicleUsageIntervalIds +) { + public RuntimeVehicleEvidenceAttachmentDecisionDto { + vehicleKeys = vehicleKeys == null ? Set.of() : Set.copyOf(vehicleKeys); + matchingVehicleUsageIntervalIds = matchingVehicleUsageIntervalIds == null ? List.of() : List.copyOf(matchingVehicleUsageIntervalIds); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/dto/RuntimeVehicleUsageIntervalDebugDto.java b/src/main/java/at/procon/eventhub/processing/dto/RuntimeVehicleUsageIntervalDebugDto.java new file mode 100644 index 0000000..34fbeff --- /dev/null +++ b/src/main/java/at/procon/eventhub/processing/dto/RuntimeVehicleUsageIntervalDebugDto.java @@ -0,0 +1,27 @@ +package at.procon.eventhub.processing.dto; + +import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval; +import java.time.OffsetDateTime; + +public record RuntimeVehicleUsageIntervalDebugDto( + String intervalId, + OffsetDateTime from, + OffsetDateTime to, + String registrationKey, + String vehicleKey, + String sourceKind +) { + public static RuntimeVehicleUsageIntervalDebugDto from(ResolvedVehicleUsageInterval interval) { + if (interval == null) { + return null; + } + return new RuntimeVehicleUsageIntervalDebugDto( + interval.intervalId(), + interval.from(), + interval.to(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind() + ); + } +} diff --git a/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeDerivedProjectionResultDto.java b/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeDerivedProjectionResultDto.java index 8fcb376..e13c4ab 100644 --- a/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeDerivedProjectionResultDto.java +++ b/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeDerivedProjectionResultDto.java @@ -13,10 +13,48 @@ public record UnifiedRuntimeDerivedProjectionResultDto( int mergedEventCount, List discoveredVehicles, TachographEsperDriverProcessingResultDto projection, - List notes + List notes, + RuntimeDriverPartitionDebugDto partitionDebug ) { public UnifiedRuntimeDerivedProjectionResultDto { discoveredVehicles = discoveredVehicles == null ? List.of() : List.copyOf(discoveredVehicles); notes = notes == null ? List.of() : List.copyOf(notes); } + + public UnifiedRuntimeDerivedProjectionResultDto( + UnifiedRuntimeProcessingRequest request, + int driverSeedEventCount, + int discoveredVehicleCount, + int expandedVehicleEventCount, + int mergedEventCount, + List discoveredVehicles, + TachographEsperDriverProcessingResultDto projection, + List notes + ) { + this( + request, + driverSeedEventCount, + discoveredVehicleCount, + expandedVehicleEventCount, + mergedEventCount, + discoveredVehicles, + projection, + notes, + null + ); + } + + public UnifiedRuntimeDerivedProjectionResultDto withPartitionDebug(RuntimeDriverPartitionDebugDto debug) { + return new UnifiedRuntimeDerivedProjectionResultDto( + request, + driverSeedEventCount, + discoveredVehicleCount, + expandedVehicleEventCount, + mergedEventCount, + discoveredVehicles, + projection, + notes, + debug + ); + } } diff --git a/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeTachographEsperScopeResultDto.java b/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeTachographEsperScopeResultDto.java index 9fee16e..0d9b0a5 100644 --- a/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeTachographEsperScopeResultDto.java +++ b/src/main/java/at/procon/eventhub/processing/dto/UnifiedRuntimeTachographEsperScopeResultDto.java @@ -16,12 +16,14 @@ public record UnifiedRuntimeTachographEsperScopeResultDto( int discoveredVehicleCount, List discoveredVehicles, Map driverResults, + Map partitionDebugByDriver, List notes, List warnings ) { public UnifiedRuntimeTachographEsperScopeResultDto { discoveredVehicles = discoveredVehicles == null ? List.of() : List.copyOf(discoveredVehicles); driverResults = driverResults == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(driverResults)); + partitionDebugByDriver = partitionDebugByDriver == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(partitionDebugByDriver)); notes = notes == null ? List.of() : List.copyOf(notes); warnings = warnings == null ? List.of() : List.copyOf(warnings); } @@ -50,9 +52,23 @@ public record UnifiedRuntimeTachographEsperScopeResultDto( genericResult.discoveredVehicleCount(), genericResult.discoveredVehicles(), driverResults, + extractPartitionDebug(genericResult), genericResult.notes(), genericResult.warnings() ); } + private static Map extractPartitionDebug( + RuntimeEventProcessingResultDto genericResult + ) { + LinkedHashMap debugByDriver = new LinkedHashMap<>(); + for (Map.Entry entry : genericResult.partitionResults().entrySet()) { + Object debug = entry.getValue().metadata().get("partitionDebug"); + if (debug instanceof RuntimeDriverPartitionDebugDto partitionDebug) { + debugByDriver.put(entry.getKey(), partitionDebug); + } + } + return debugByDriver; + } + } diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventPartitioningApiRequest.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventPartitioningApiRequest.java index dceee3e..8f9b926 100644 --- a/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventPartitioningApiRequest.java +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventPartitioningApiRequest.java @@ -12,7 +12,8 @@ public record RuntimeEventPartitioningApiRequest( Set vehicleKeys, Boolean includeAllVehicles, Boolean attachVehicleEvidence, - Integer vehicleEvidencePaddingMinutes + Integer vehicleEvidencePaddingMinutes, + Boolean includeDebug ) { public RuntimeEventPartitioningApiRequest { strategy = strategy == null ? RuntimeEventPartitioningStrategy.CUSTOM_PROFILE : strategy; @@ -43,4 +44,8 @@ public record RuntimeEventPartitioningApiRequest( public int vehicleEvidencePaddingMinutesOrDefault(int fallback) { return vehicleEvidencePaddingMinutes == null ? Math.max(0, fallback) : Math.max(0, vehicleEvidencePaddingMinutes); } + + public boolean includeDebugOrDefault() { + return includeDebug != null && includeDebug; + } } diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingApiRequest.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingApiRequest.java index 31f31f6..5c20314 100644 --- a/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingApiRequest.java +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/dto/RuntimeEventProcessingApiRequest.java @@ -20,7 +20,7 @@ public record RuntimeEventProcessingApiRequest( throw new IllegalArgumentException("scope must not be null"); } partitioning = partitioning == null - ? new RuntimeEventPartitioningApiRequest(null, null, null, null, null, null, null, null, null) + ? new RuntimeEventPartitioningApiRequest(null, null, null, null, null, null, null, null, null, null) : partitioning; parameters = parameters == null ? Map.of() @@ -40,7 +40,8 @@ public record RuntimeEventProcessingApiRequest( scope != null ? scope.vehicleKeys() : null, scope != null ? scope.includeAllVehicles() : null, null, - scope != null ? scope.vehicleExpansionPaddingMinutes() : null + scope != null ? scope.vehicleExpansionPaddingMinutes() : null, + null ), Map.of() ); diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfile.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfile.java index 99de4fe..d46e963 100644 --- a/src/main/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfile.java +++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfile.java @@ -55,30 +55,49 @@ public class TachographDriverEsperRuntimeEventProcessingProfile implements Runti @Override public Set optionalParameters() { - return Set.of("significantDrivingMinutes", "minimumRestPeriodMinutes", "attachVehicleOnlyEvents", "vehicleEvidencePaddingMinutes"); + return Set.of( + "significantDrivingMinutes", + "minimumRestPeriodMinutes", + "attachVehicleOnlyEvents", + "vehicleEvidencePaddingMinutes", + "includePartitionDebug" + ); } @Override public RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request) { + boolean includePartitionDebug = booleanParameter( + request.parameters(), + "includePartitionDebug", + request.partitioning() != null && request.partitioning().includeDebugOrDefault() + ); UnifiedRuntimeProcessingApiRequest tachographScopeRequest = applyGenericRequest(request.scope(), request.partitioning(), request.parameters()); - UnifiedRuntimeTachographEsperScopeResultDto tachographResult = tachographScopeProcessingService.processScope(tachographScopeRequest); + UnifiedRuntimeTachographEsperScopeResultDto tachographResult = tachographScopeProcessingService.processScope( + tachographScopeRequest, + includePartitionDebug + ); Map partitionResults = new LinkedHashMap<>(); - tachographResult.driverResults().forEach((driverKey, driverResult) -> partitionResults.put( - driverKey, - new RuntimeEventProcessingPartitionResultDto( - "DRIVER", - driverKey, - "UnifiedRuntimeDerivedProjectionResultDto", - driverResult, - Map.of( - "projectionResultType", driverResult.projection() == null ? "NONE" : "TachographEsperDriverProcessingResultDto", - "driverSeedEventCount", driverResult.driverSeedEventCount(), - "expandedVehicleEventCount", driverResult.expandedVehicleEventCount(), - "mergedEventCount", driverResult.mergedEventCount() - ) - ) - )); + tachographResult.driverResults().forEach((driverKey, driverResult) -> { + Map metadata = new LinkedHashMap<>(); + metadata.put("projectionResultType", driverResult.projection() == null ? "NONE" : "TachographEsperDriverProcessingResultDto"); + metadata.put("driverSeedEventCount", driverResult.driverSeedEventCount()); + metadata.put("expandedVehicleEventCount", driverResult.expandedVehicleEventCount()); + metadata.put("mergedEventCount", driverResult.mergedEventCount()); + if (driverResult.partitionDebug() != null) { + metadata.put("partitionDebug", driverResult.partitionDebug()); + } + partitionResults.put( + driverKey, + new RuntimeEventProcessingPartitionResultDto( + "DRIVER", + driverKey, + "UnifiedRuntimeDerivedProjectionResultDto", + driverResult, + metadata + ) + ); + }); return new RuntimeEventProcessingResultDto( profileKey(), diff --git a/src/main/java/at/procon/eventhub/processing/model/RuntimeDriverVehicleEvidenceAttachmentResult.java b/src/main/java/at/procon/eventhub/processing/model/RuntimeDriverVehicleEvidenceAttachmentResult.java index 7dec607..49748b3 100644 --- a/src/main/java/at/procon/eventhub/processing/model/RuntimeDriverVehicleEvidenceAttachmentResult.java +++ b/src/main/java/at/procon/eventhub/processing/model/RuntimeDriverVehicleEvidenceAttachmentResult.java @@ -1,6 +1,9 @@ package at.procon.eventhub.processing.model; import at.procon.eventhub.dto.EventHubEventDto; +import at.procon.eventhub.processing.dto.RuntimeDriverPartitionDebugDto; +import at.procon.eventhub.processing.dto.RuntimeVehicleEvidenceAttachmentDecisionDto; +import at.procon.eventhub.processing.dto.RuntimeVehicleUsageIntervalDebugDto; import java.util.List; public record RuntimeDriverVehicleEvidenceAttachmentResult( @@ -11,6 +14,8 @@ public record RuntimeDriverVehicleEvidenceAttachmentResult( int vehicleUsageIntervalCount, int candidateVehicleEvidenceEventCount, int ignoredVehicleEvidenceEventCount, + List vehicleUsageIntervals, + List vehicleEvidenceDecisions, List notes, List warnings ) { @@ -18,7 +23,25 @@ public record RuntimeDriverVehicleEvidenceAttachmentResult( directDriverEvents = directDriverEvents == null ? List.of() : List.copyOf(directDriverEvents); attachedVehicleEvidenceEvents = attachedVehicleEvidenceEvents == null ? List.of() : List.copyOf(attachedVehicleEvidenceEvents); mergedEvents = mergedEvents == null ? List.of() : List.copyOf(mergedEvents); + vehicleUsageIntervals = vehicleUsageIntervals == null ? List.of() : List.copyOf(vehicleUsageIntervals); + vehicleEvidenceDecisions = vehicleEvidenceDecisions == null ? List.of() : List.copyOf(vehicleEvidenceDecisions); notes = notes == null ? List.of() : List.copyOf(notes); warnings = warnings == null ? List.of() : List.copyOf(warnings); } + + public RuntimeDriverPartitionDebugDto toPartitionDebug() { + return new RuntimeDriverPartitionDebugDto( + driverKey, + directDriverEvents.size(), + vehicleUsageIntervalCount, + candidateVehicleEvidenceEventCount, + attachedVehicleEvidenceEvents.size(), + ignoredVehicleEvidenceEventCount, + mergedEvents.size(), + vehicleUsageIntervals, + vehicleEvidenceDecisions, + notes, + warnings + ); + } } diff --git a/src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java b/src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java index 26873ed..9d764b9 100644 --- a/src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java +++ b/src/main/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentService.java @@ -2,6 +2,8 @@ package at.procon.eventhub.processing.service; import at.procon.eventhub.dto.EventHubEventDto; import at.procon.eventhub.dto.VehicleRefDto; +import at.procon.eventhub.processing.dto.RuntimeVehicleEvidenceAttachmentDecisionDto; +import at.procon.eventhub.processing.dto.RuntimeVehicleUsageIntervalDebugDto; import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventScopeClassifier; import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventScopeType; import at.procon.eventhub.processing.model.RuntimeDriverVehicleEvidenceAttachmentResult; @@ -38,6 +40,24 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { List runtimeScopeEvents, boolean attachVehicleOnlyEvents, int vehicleEvidencePaddingMinutes + ) { + return attachVehicleEvidence( + driverKey, + directDriverEvents, + runtimeScopeEvents, + attachVehicleOnlyEvents, + vehicleEvidencePaddingMinutes, + false + ); + } + + public RuntimeDriverVehicleEvidenceAttachmentResult attachVehicleEvidence( + String driverKey, + List directDriverEvents, + List runtimeScopeEvents, + boolean attachVehicleOnlyEvents, + int vehicleEvidencePaddingMinutes, + boolean includeDebug ) { List safeDriverEvents = directDriverEvents == null ? List.of() : List.copyOf(directDriverEvents); List safeScopeEvents = runtimeScopeEvents == null ? List.of() : List.copyOf(runtimeScopeEvents); @@ -47,14 +67,19 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { List warnings = new ArrayList<>(); if (!attachVehicleOnlyEvents) { notes.add("Vehicle-only evidence attachment is disabled for driver partition " + driverKey + "."); + List disabledDecisions = includeDebug + ? disabledDecisions(safeScopeEvents) + : List.of(); return new RuntimeDriverVehicleEvidenceAttachmentResult( driverKey, safeDriverEvents, List.of(), deduplicateAndSort(safeDriverEvents, List.of()), 0, - 0, - 0, + disabledDecisions.size(), + disabledDecisions.size(), + List.of(), + disabledDecisions, notes, warnings ); @@ -62,6 +87,12 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { ResolvedDriverTimeline timeline = timelineReconstructor.reconstruct(null, driverKey, safeDriverEvents); List usageIntervals = mergeVehicleUsageIntervals(timeline.vehicleUsageIntervals()); + List usageIntervalDebug = includeDebug + ? usageIntervals.stream().map(RuntimeVehicleUsageIntervalDebugDto::from).toList() + : List.of(); + List decisions = includeDebug + ? directDriverDecisions(safeDriverEvents) + : new ArrayList<>(); List candidateVehicleEvidence = safeScopeEvents.stream() .filter(event -> scopeClassifier.classify(event) == RuntimeEventScopeType.VEHICLE_SCOPED) .toList(); @@ -71,9 +102,25 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { List matchingIntervals = matchingUsageIntervals(vehicleEvent, usageIntervals, paddingMinutes); if (matchingIntervals.isEmpty()) { ignored++; + if (includeDebug) { + decisions.add(decision( + "IGNORED_NO_OVERLAPPING_VEHICLE_USAGE", + "Vehicle-scoped event did not overlap any reconstructed vehicle-usage interval for driver " + driverKey + ".", + vehicleEvent, + List.of() + )); + } continue; } attached.add(vehicleEvent); + if (includeDebug) { + decisions.add(decision( + "ATTACHED_VEHICLE_EVIDENCE", + "Vehicle-scoped event overlapped driver vehicle usage interval(s).", + vehicleEvent, + matchingIntervals + )); + } if (matchingIntervals.size() > 1) { warnings.add("Vehicle-only event " + vehicleEvent.externalSourceEventId() + " matched multiple vehicle-usage intervals for driver " + driverKey @@ -100,11 +147,61 @@ public class RuntimeDriverVehicleEvidenceAttachmentService { usageIntervals.size(), candidateVehicleEvidence.size(), ignored, + usageIntervalDebug, + includeDebug ? decisions : List.of(), notes, warnings ); } + private List disabledDecisions(List runtimeScopeEvents) { + return (runtimeScopeEvents == null ? List.of() : runtimeScopeEvents).stream() + .filter(event -> scopeClassifier.classify(event) == RuntimeEventScopeType.VEHICLE_SCOPED) + .map(event -> decision( + "IGNORED_ATTACHMENT_DISABLED", + "Vehicle evidence attachment was disabled for this partition.", + event, + List.of() + )) + .toList(); + } + + private List directDriverDecisions(List directDriverEvents) { + return (directDriverEvents == null ? List.of() : directDriverEvents).stream() + .map(event -> decision( + "DIRECT_DRIVER_EVENT", + "Event already carries the selected driver reference and belongs directly to the driver partition.", + event, + List.of() + )) + .toList(); + } + + private RuntimeVehicleEvidenceAttachmentDecisionDto decision( + String decision, + String reason, + EventHubEventDto event, + List matchingIntervals + ) { + List intervalIds = (matchingIntervals == null ? List.of() : matchingIntervals).stream() + .map(ResolvedVehicleUsageInterval::intervalId) + .filter(Objects::nonNull) + .toList(); + return new RuntimeVehicleEvidenceAttachmentDecisionDto( + decision, + reason, + dedupKey(event), + event == null ? null : event.externalSourceEventId(), + event == null ? null : event.occurredAt(), + event == null || event.eventDomain() == null ? null : event.eventDomain().name(), + event == null || event.eventType() == null ? null : event.eventType().name(), + event == null || event.lifecycle() == null ? null : event.lifecycle().name(), + scopeClassifier.classify(event), + vehicleKeys(event), + intervalIds + ); + } + private List matchingUsageIntervals( EventHubEventDto vehicleEvent, List usageIntervals, diff --git a/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeTachographEsperScopeProcessingService.java b/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeTachographEsperScopeProcessingService.java index 276a85e..152315e 100644 --- a/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeTachographEsperScopeProcessingService.java +++ b/src/main/java/at/procon/eventhub/processing/service/UnifiedRuntimeTachographEsperScopeProcessingService.java @@ -3,6 +3,7 @@ package at.procon.eventhub.processing.service; import at.procon.eventhub.dto.DriverRefDto; import at.procon.eventhub.dto.EventHubEventDto; import at.procon.eventhub.dto.VehicleRefDto; +import at.procon.eventhub.processing.dto.RuntimeDriverPartitionDebugDto; import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto; import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest; import at.procon.eventhub.processing.dto.UnifiedRuntimeTachographEsperScopeResultDto; @@ -39,6 +40,13 @@ public class UnifiedRuntimeTachographEsperScopeProcessingService { } public UnifiedRuntimeTachographEsperScopeResultDto processScope(UnifiedRuntimeProcessingApiRequest apiRequest) { + return processScope(apiRequest, false); + } + + public UnifiedRuntimeTachographEsperScopeResultDto processScope( + UnifiedRuntimeProcessingApiRequest apiRequest, + boolean includePartitionDebug + ) { UnifiedRuntimeProcessingRequest request = apiRequest.toRuntimeRequest(); UnifiedRuntimeEventBundle broadBundle = eventAssemblyService.assembleDriverScopedEvents(request); LinkedHashSet selectedDriverKeys = selectedDriverKeys(request, broadBundle.mergedEvents()); @@ -47,10 +55,15 @@ public class UnifiedRuntimeTachographEsperScopeProcessingService { } Map driverResults = new LinkedHashMap<>(); + Map partitionDebugByDriver = new LinkedHashMap<>(); Map> attachedVehicleEvidenceByEvent = new LinkedHashMap<>(); List warnings = new ArrayList<>(); for (String driverKey : selectedDriverKeys) { - UnifiedRuntimeEventBundle driverBundle = partitionForDriver(request, broadBundle, driverKey); + DriverPartition driverPartition = partitionForDriver(request, broadBundle, driverKey, includePartitionDebug); + UnifiedRuntimeEventBundle driverBundle = driverPartition.bundle(); + if (driverPartition.debug() != null) { + partitionDebugByDriver.put(driverKey, driverPartition.debug()); + } for (EventHubEventDto attachedEvent : driverBundle.expandedVehicleEvents()) { attachedVehicleEvidenceByEvent .computeIfAbsent(dedupKey(attachedEvent), ignored -> new ArrayList<>()) @@ -71,7 +84,7 @@ public class UnifiedRuntimeTachographEsperScopeProcessingService { driverBundle, driverKey ); - driverResults.put(driverKey, driverResult); + driverResults.put(driverKey, driverPartition.debug() == null ? driverResult : driverResult.withPartitionDebug(driverPartition.debug())); } attachedVehicleEvidenceByEvent.forEach((eventKey, drivers) -> { if (drivers.size() > 1) { @@ -98,6 +111,7 @@ public class UnifiedRuntimeTachographEsperScopeProcessingService { broadBundle.discoveredVehicles().size(), broadBundle.discoveredVehicles(), driverResults, + partitionDebugByDriver, notes, warnings ); @@ -130,10 +144,11 @@ public class UnifiedRuntimeTachographEsperScopeProcessingService { return selected; } - private UnifiedRuntimeEventBundle partitionForDriver( + private DriverPartition partitionForDriver( UnifiedRuntimeProcessingRequest request, UnifiedRuntimeEventBundle broadBundle, - String driverKey + String driverKey, + boolean includePartitionDebug ) { List directDriverEvents = broadBundle.mergedEvents().stream() .filter(event -> Objects.equals(driverKey(event), driverKey)) @@ -143,7 +158,8 @@ public class UnifiedRuntimeTachographEsperScopeProcessingService { directDriverEvents, broadBundle.mergedEvents(), request.expandVehicleEvents(), - request.vehicleExpansionPaddingMinutes() + request.vehicleExpansionPaddingMinutes(), + includePartitionDebug ); List driverVehicles = discoverVehicles(attachmentResult.mergedEvents()); List notes = new ArrayList<>(broadBundle.notes()); @@ -153,7 +169,7 @@ public class UnifiedRuntimeTachographEsperScopeProcessingService { notes.add("Vehicle-usage intervals used for temporal evidence attachment: " + attachmentResult.vehicleUsageIntervalCount() + "."); notes.addAll(attachmentResult.notes()); attachmentResult.warnings().forEach(warning -> notes.add("WARNING: " + warning)); - return new UnifiedRuntimeEventBundle( + UnifiedRuntimeEventBundle bundle = new UnifiedRuntimeEventBundle( request.withDriverKey(driverKey), attachmentResult.directDriverEvents(), driverVehicles, @@ -161,6 +177,16 @@ public class UnifiedRuntimeTachographEsperScopeProcessingService { attachmentResult.mergedEvents(), notes ); + return new DriverPartition( + bundle, + includePartitionDebug ? attachmentResult.toPartitionDebug() : null + ); + } + + private record DriverPartition( + UnifiedRuntimeEventBundle bundle, + RuntimeDriverPartitionDebugDto debug + ) { } private LinkedHashSet discoverDriverKeys(List events) { diff --git a/src/test/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingServiceTest.java b/src/test/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingServiceTest.java index 7cdda71..f622513 100644 --- a/src/test/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingServiceTest.java +++ b/src/test/java/at/procon/eventhub/processing/eventprocessing/RuntimeEventProcessingServiceTest.java @@ -50,7 +50,7 @@ class RuntimeEventProcessingServiceTest { null, null ), - new RuntimeEventPartitioningApiRequest(RuntimeEventPartitioningStrategy.DRIVER, null, false, null, false, null, false, null, null), + new RuntimeEventPartitioningApiRequest(RuntimeEventPartitioningStrategy.DRIVER, null, false, null, false, null, false, null, null, null), Map.of() ); diff --git a/src/test/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfileTest.java b/src/test/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfileTest.java index 51c3bc9..493e202 100644 --- a/src/test/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfileTest.java +++ b/src/test/java/at/procon/eventhub/processing/eventprocessing/profile/TachographDriverEsperRuntimeEventProcessingProfileTest.java @@ -2,6 +2,7 @@ package at.procon.eventhub.processing.eventprocessing.profile; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -34,7 +35,13 @@ class TachographDriverEsperRuntimeEventProcessingProfileTest { assertThat(profile.displayName()).isEqualTo("Tachograph Driver Esper Processing"); assertThat(profile.defaultPartitioningStrategy()).isEqualTo(RuntimeEventPartitioningStrategy.DRIVER); assertThat(profile.supportedPartitioningStrategies()).containsExactly(RuntimeEventPartitioningStrategy.DRIVER); - assertThat(profile.optionalParameters()).containsExactlyInAnyOrder("significantDrivingMinutes", "minimumRestPeriodMinutes", "attachVehicleOnlyEvents", "vehicleEvidencePaddingMinutes"); + assertThat(profile.optionalParameters()).containsExactlyInAnyOrder( + "significantDrivingMinutes", + "minimumRestPeriodMinutes", + "attachVehicleOnlyEvents", + "vehicleEvidencePaddingMinutes", + "includePartitionDebug" + ); } @Test @@ -77,13 +84,15 @@ class TachographDriverEsperRuntimeEventProcessingProfileTest { null, null, null, + null, null ), Map.of( "significantDrivingMinutes", 5, "minimumRestPeriodMinutes", "600", "vehicleEvidencePaddingMinutes", 20, - "attachVehicleOnlyEvents", true + "attachVehicleOnlyEvents", true, + "includePartitionDebug", true ) ); @@ -117,7 +126,7 @@ class TachographDriverEsperRuntimeEventProcessingProfileTest { null, List.of("driver processed") ); - when(scopeService.processScope(any())) + when(scopeService.processScope(any(), anyBoolean())) .thenReturn(new UnifiedRuntimeTachographEsperScopeResultDto( processedRequest, 5, @@ -125,6 +134,7 @@ class TachographDriverEsperRuntimeEventProcessingProfileTest { 1, List.of(), Map.of("12:DRIVER-1", driverResult), + Map.of(), List.of("scope processed"), List.of() )); @@ -138,12 +148,14 @@ class TachographDriverEsperRuntimeEventProcessingProfileTest { assertThat(result.partitionResults().get("12:DRIVER-1").result()).isSameAs(driverResult); ArgumentCaptor captor = ArgumentCaptor.forClass(UnifiedRuntimeProcessingApiRequest.class); - verify(scopeService).processScope(captor.capture()); + ArgumentCaptor debugCaptor = ArgumentCaptor.forClass(Boolean.class); + verify(scopeService).processScope(captor.capture(), debugCaptor.capture()); UnifiedRuntimeProcessingApiRequest delegated = captor.getValue(); assertThat(delegated.driverKeys()).containsExactly("12:DRIVER-1"); assertThat(delegated.significantDrivingMinutes()).isEqualTo(5); assertThat(delegated.minimumRestPeriodMinutes()).isEqualTo(600); assertThat(delegated.vehicleExpansionPaddingMinutes()).isEqualTo(20); assertThat(delegated.expandVehicleEvents()).isTrue(); + assertThat(debugCaptor.getValue()).isTrue(); } } diff --git a/src/test/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentServiceTest.java b/src/test/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentServiceTest.java index a45dfdc..e2e4094 100644 --- a/src/test/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentServiceTest.java +++ b/src/test/java/at/procon/eventhub/processing/service/RuntimeDriverVehicleEvidenceAttachmentServiceTest.java @@ -9,6 +9,7 @@ import at.procon.eventhub.dto.EventLifecycle; import at.procon.eventhub.dto.EventType; import at.procon.eventhub.dto.VehicleRefDto; import at.procon.eventhub.dto.VehicleRegistrationRefDto; +import at.procon.eventhub.processing.dto.RuntimeVehicleEvidenceAttachmentDecisionDto; import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventScopeClassifier; import at.procon.eventhub.processing.model.RuntimeDriverVehicleEvidenceAttachmentResult; import com.fasterxml.jackson.databind.JsonNode; @@ -119,6 +120,34 @@ class RuntimeDriverVehicleEvidenceAttachmentServiceTest { .containsExactly("pos-midnight"); } + @Test + void producesDebugDecisionsForDirectAttachedAndIgnoredEvents() { + List driverEvents = List.of( + cardEvent("card-1-in", EventType.CARD_INSERTED, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T08:00:00Z"), + cardEvent("card-1-out", EventType.CARD_WITHDRAWN, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T18:00:00Z") + ); + EventHubEventDto inside = vehicleOnlyEvent("pos-inside", "VIN-1", "AT:W-1", "2026-05-01T12:00:00Z"); + EventHubEventDto outside = vehicleOnlyEvent("pos-outside", "VIN-1", "AT:W-1", "2026-05-01T20:00:00Z"); + + RuntimeDriverVehicleEvidenceAttachmentResult result = service.attachVehicleEvidence( + "DRIVER-1", + driverEvents, + List.of(driverEvents.get(0), driverEvents.get(1), inside, outside), + true, + 0, + true + ); + + assertThat(result.vehicleUsageIntervals()).hasSize(1); + assertThat(result.vehicleEvidenceDecisions()).extracting(RuntimeVehicleEvidenceAttachmentDecisionDto::decision) + .contains( + "DIRECT_DRIVER_EVENT", + "ATTACHED_VEHICLE_EVIDENCE", + "IGNORED_NO_OVERLAPPING_VEHICLE_USAGE" + ); + assertThat(result.toPartitionDebug().vehicleEvidenceDecisions()).hasSameSizeAs(result.vehicleEvidenceDecisions()); + } + private EventHubEventDto cardEvent( String externalId, EventType eventType,